Compare commits

...

27 Commits

Author SHA1 Message Date
Alex d789d0ad15 Merge branch 'develop' into enhance-workflows 2026-01-06 12:10:36 +01:00
tallinn102 574cd117b2 fix: pass augmented env to Claude CLI validation on macOS (#640)
When Electron apps launch from Finder/Dock on macOS, they don't inherit
the user's shell PATH. This causes Claude CLI detection to fail because
the `claude` script (which uses `#!/usr/bin/env node`) cannot find the
Node.js binary.

The fix passes `getAugmentedEnv()` to `execFileSync` in `validateClaude()`,
which includes `/opt/homebrew/bin` and other common binary locations in
the PATH. This allows `env node` to find Node.js when validating the
Claude CLI.

Fixes an issue where Auto Claude would report "Claude CLI not found"
even though it was properly installed via npm.

Signed-off-by: Tallinn Terlich <tallinn1022@gmail.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
2026-01-06 11:39:14 +01:00
Alex 09aa4f4f71 fix: WIndows not finding the gith bash path (#724)
* fix: WIndows not finding the gith bash path

* Update apps/frontend/src/main/utils/windows-paths.ts

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update apps/backend/core/client.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update apps/backend/core/auth.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* fix: improve code quality in Windows path detection

- Use splitlines() instead of split("\n") for robust cross-platform line handling
- Add explanatory comment for intentionally suppressed exceptions
- Standardize Windows detection to platform.system() for consistency

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 10:47:10 +01:00
Ginanjar Noviawan 78aceaed1e fix(profiles): support API profiles in auth check and model resolution (#608)
* fix(profiles): support API profiles in auth check and model resolution

- useClaudeTokenCheck() now checks for active API profile in addition
  to OAuth token, preventing unnecessary OAuth prompts when using
  custom Anthropic-compatible endpoints

- agent-queue.ts now passes model shorthand (opus/sonnet/haiku) to
  backend instead of resolved full model ID, allowing backend to
  use API profile's custom model mappings via env vars

Fixes issue where Ideation/Roadmap would prompt for OAuth even when
a valid API profile was configured and active.

* Refactor token check with useCallback in EnvConfigModal

Wrapped the checkToken function in useCallback and updated useEffect dependencies to use checkToken instead of activeProfileId.

* Improve error handling in Claude token check hook

Adds logic to set an error message if the OAuth token check fails and there is no API profile fallback.

* Refactor API profile check in useClaudeTokenCheck

Simplifies the logic for determining if an API profile exists by computing hasAPIProfile once using the closure-captured activeProfileId.

---------

Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
2026-01-06 07:30:05 +01:00
aaronson2012 5005e56e46 Fix Window Size on Hi-DPI Displays (#696)
* Initial plan

* Fix window maximize issue for high DPI displays with scaling

Co-authored-by: aaronson2012 <15083264+aaronson2012@users.noreply.github.com>

* Apply review feedback: use full work area for min dimensions

Co-authored-by: aaronson2012 <15083264+aaronson2012@users.noreply.github.com>

* Update apps/frontend/src/main/index.ts

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update apps/frontend/src/main/index.ts

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Initial plan

* Add try/catch for screen.getPrimaryDisplay() with validation and fallback, add type annotations and module-level constants

Co-authored-by: aaronson2012 <15083264+aaronson2012@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
2026-01-05 23:02:50 +01:00
StillKnotKnown ec4441c1e3 fix: centralize Claude CLI invocation (#680)
* fix: centralize Claude CLI invocation

Use shared resolver and PATH prepending for CLI calls.
Add tests to cover resolver behavior and handler usage.

* fix: harden Claude CLI auth checks

Handle PATH edge cases and Windows matching in CLI resolver.
Add auth error scenarios and CLI command escaping in env handlers.
Extend tests for resolver and auth error coverage.

* test: extend Claude CLI handler coverage

Cover Windows PATH case-insensitive behavior and session state assertions.

* test: cover invokeClaude profile flows

Add temp token, config dir, and profile switch assertions.

* test: assert oauth token file write

* test: cover claude invoke error paths

* test: streamline claude terminal mocks

* fix: track claude profile usage

* test: cover windows path normalization

* chore: align claude invoke spacing

* fix: harden Claude CLI invocation handling

* test: align Claude CLI PATH handling

---------

Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
2026-01-05 23:02:34 +01:00
Michael Ludlow 97f34496b5 fix(github): pass OAuth token to Python runner subprocesses (fixes #563) (#698)
The getRunnerEnv utility was missing the OAuth token from the Claude
Profile Manager. It only included API profile env vars (ANTHROPIC_*)
for custom endpoints, but not the CLAUDE_CODE_OAUTH_TOKEN needed for
default Claude authentication.

Root cause: The OAuth token is stored encrypted in Electron's profile
storage (macOS Keychain via safeStorage), not as a system env var.
The getProfileEnv() function retrieves and decrypts it.

This fixes the 401 authentication errors in PR review, autofix,
and triage handlers that all use getRunnerEnv().

Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
2026-01-05 22:31:27 +01:00
Rooki 2c9fcbf498 chore: Update Linux app icon to use multiple resolution sizes and fix .deb icon (#672)
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
2026-01-05 22:17:22 +01:00
Orinks 3930b12c41 fix(a11y): Add missing ARIA attributes for screen reader accessibility (#634)
* fix(a11y): add missing ARIA attributes for screen reader accessibility

Add comprehensive ARIA attributes across frontend components:

- aria-label on icon-only buttons (close, edit, delete, add, refresh)
- aria-required on required form fields (description, title, phase)
- role="alert" on validation error messages
- aria-expanded/aria-controls on collapsible sections
- role="radiogroup" on button groups acting as radio selects

Components updated:
- GitHubSetupModal: repo action buttons, owner/visibility selection
- TaskCreationWizard: description, image removal, toggles
- TaskEditDialog: description, advanced/images toggles
- AddFeatureDialog: title, description, phase fields
- AddProjectModal: action buttons, error messages
- KanbanBoard: add task, archive buttons
- TaskHeader, FeatureDetailPanel, RoadmapHeader: icon buttons
- WelcomeScreen, Sidebar, ProjectTabBar: various buttons

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(a11y): address PR review feedback

- Remove redundant aria-required from Select component (keep only on SelectTrigger)
- Replace hardcoded aria-label strings with i18n translation keys
- Add translation strings for en and fr locales

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(a11y): convert remaining hardcoded aria-labels to i18n

- Add useTranslation to components missing i18n support
- Convert all hardcoded aria-label strings to translation keys
- Add accessibility translation keys to common and tasks namespaces
- Add French translations for all new aria-label keys
- Update radiogroup aria-labels in GitHubSetupModal to use i18n

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(a11y): add screen reader indication for external links

- Add sr-only text indicating links open in new window
- Add aria-hidden to decorative ExternalLink icons
- Add aria-labels for external link buttons
- Update SafeLink component in Insights for markdown links
- Add translation keys for external link accessibility

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(a11y): improve CollapsibleSection and FileTreeItem accessibility

CollapsibleSection:
- Add type="button" to prevent form submission
- Add aria-expanded and aria-controls for screen readers
- Add aria-hidden to decorative chevron icons
- Use React useId hook for unique content IDs

FileTreeItem:
- Add keyboard support (Enter/Space) for directory toggle
- Add role="button" and tabIndex for keyboard focus
- Add aria-expanded state for directories
- Add aria-labels for expand/collapse actions with i18n
- Add focus ring styling for keyboard navigation
- Mark decorative icons as aria-hidden

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(a11y): add aria-labels to icon buttons in FileExplorer and IssueList

- Add aria-label to refresh and close buttons in FileExplorerPanel
- Add aria-label to refresh button in IssueListHeader
- Mark decorative icons as aria-hidden

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(a11y): convert TaskHeader edit button strings to i18n

- Replace hardcoded aria-label and tooltip text with translation keys
- Add editTask and cannotEditWhileRunning keys to en/fr locales

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(a11y): convert remaining hardcoded strings to i18n

- WelcomeScreen: convert project button aria-label to i18n
- TaskCreationWizard: add useTranslation and convert remove image aria-label
- TaskEditDialog: add useTranslation and convert paste hint to i18n
- Insights: refactor SafeLink to use factory pattern with translated
  "opens in new window" text

Added translation keys for en/fr:
- welcome:recentProjects.openProjectAriaLabel
- tasks:images.removeImageAriaLabel
- tasks:images.pasteHint

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(a11y): make ClaudeCodeStatusBadge aria-label match visible text

The aria-label was "Learn more (opens in new window)" but the visible
text was "Learn more about Claude Code" - these didn't match.

Added specific translation key navigation:claudeCode.learnMoreAriaLabel
that includes the full visible text plus "(opens in new window)" suffix.
Removed redundant sr-only span since aria-label now provides the complete
accessible name.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(a11y): pass markdownComponents as prop to MessageBubble

After moving markdownComponents inside the Insights component for i18n
support, MessageBubble (defined outside Insights) couldn't access it.

- Import Components type from react-markdown
- Add markdownComponents prop to MessageBubbleProps
- Update MessageBubble to use the prop instead of free variable
- Pass markdownComponents from Insights when rendering MessageBubble

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(a11y): add fallback string to learnMoreAriaLabel translation

Added fallback string to match the pattern used elsewhere in the file,
ensuring the aria-label has a sensible default if translation is missing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(a11y): add aria-keyshortcuts to navigation sidebar buttons

Screen readers can now announce keyboard shortcuts (K, A, G, etc.)
when focusing on navigation items.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(a11y): clarify close tab button removes project from app

Screen readers now announce "Close tab (removes project from app)"
instead of just "Close tab" to match the confirmation dialog behavior.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Merge develop

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 19:46:54 +01:00
eddie333016 e2937320cf docs: add stars badge and star history chart to README (#675)
* docs: add GitHub stars badge and star history chart to README

Co-Authored-By: Warp <agent@warp.dev>

* Update README.md

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

---------

Co-authored-by: Warp <agent@warp.dev>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
2026-01-05 14:46:52 +01:00
AndyMik90 81afc3d2cc fix(terminal): resolve React Fast Refresh hook error in usePtyProcess
The hook was using useTerminalStore selectors which can fail during
React Fast Refresh (HMR) with 'Should have a queue' errors. This happens
because during module hot replacement, React's internal hook queue may
not be ready when the component re-initializes.

The fix replaces selector-based hook calls:
  const setTerminalStatus = useTerminalStore((state) => state.setTerminalStatus)

With a getState() pattern that doesn't rely on React's hook queue:
  const getStore = useCallback(() => useTerminalStore.getState(), [])

This pattern is already used successfully in useTerminalEvents.ts and
other parts of the codebase for accessing Zustand store actions within
callbacks.

Fixes: AUTO-CLAUDE-1
2026-01-05 14:43:31 +01:00
AndyMik90 63f4617354 sentry dev support + sessions handling in terminals 2026-01-05 14:40:24 +01:00
Vinícius Santos 35573fd5b0 fix(frontend): detect @lydell/node-pty prebuilts in postinstall (#673)
The postinstall script was failing on Windows because it only checked
for native binaries in the traditional node-pty/build/Release location.
This project uses @lydell/node-pty which distributes prebuilt binaries
via separate platform-specific packages (e.g., @lydell/node-pty-win32-x64).

Changes:
- Add checks for @lydell/node-pty platform-specific prebuilt packages
- Support npm workspaces by checking both local and root node_modules
- Skip unnecessary electron-rebuild when prebuilts are already available

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 14:37:42 +01:00
Andy 7b4993e9db Fix/small fixes all around (#645)
* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:13:41 +01:00
StillKnotKnown c27135436d fix: detect Claude CLI installed via NVM on Linux/macOS (#623)
* fix: detect Claude CLI installed via NVM on Linux/macOS

When the Electron app launches from a GUI environment (not a terminal),
NVM is not sourced in the shell environment. This causes the npm-based
CLI detection to fail because npm itself is not in PATH.

Added explicit NVM path detection that scans ~/.nvm/versions/node/ for
installed Node versions and checks for the Claude CLI in each version's
bin directory. This ensures Claude CLI installed via npm global install
under NVM can be found regardless of how the app was launched.

Changes:
- Added NVM path scanning in cli-tool-manager.ts
- Added 'nvm' to ToolDetectionResult source type
- Added i18n labels for NVM source (en/fr)
- Added unit tests for NVM detection logic

Fixes Claude CLI detection for users who installed Claude Code via
`npm install -g @anthropic-ai/claude-code` under NVM on Linux/macOS.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* fix: prefer newest NVM Node version when locating CLI

---------

Co-authored-by: CraigR <stillknotknown@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
2026-01-05 12:19:39 +01:00
StillKnotKnown 6fb2d48433 fix: improve GLM presets, ideation auth, and Insights env (#648)
* fix: improve api profile presets and ideation auth

Add GLM presets and improve profile dialog layout.
Align ideation auth flow with API profiles.
Expand Insights env setup and add tests.

* github: pass api profile env to python runners

* test: clean up github runner env temp dirs

* refactor: centralize Claude.md env helper

* fix: repair insights env return

* test: fix typecheck and vitest sentry mocks

* refactor: share sentry mocks and types

---------

Co-authored-by: StillKnotKnown <stillknotknown@users.noreply.github.com>
Co-authored-by: Andy <119136210+AndyMik90@users.noreply.github.com>
2026-01-05 12:05:14 +01:00
Andy 1e3e8bda1d Fix/update app (#594)
* cleanup/readme

* fix(updater): remove redundant source updater and add beta→stable downgrade

The app had two update systems: electron-updater (correct) and a
redundant "source updater" that caused version desync. After updating,
getEffectiveVersion() checked stale .update-metadata.json first,
showing wrong version numbers.

Changes:
- Remove redundant auto-claude-updater and source update handlers
- Clean up stale metadata directories on app startup
- Use app.getVersion() directly for version display
- Add beta→stable downgrade when user disables beta updates
- Fetch latest stable from GitHub API and offer to install

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(updater): enable stable version downgrade with allowDowngrade flag

Fixes critical issue where electron-updater's semver comparison prevented
downloading older stable versions when on a beta release. Also addresses
several robustness issues in the update mechanism:

- Set allowDowngrade=true in downloadStableVersion() to enable downgrades
- Add dedicated IPC channel APP_UPDATE_DOWNLOAD_STABLE for stable downloads
- Add HTTP status code validation to GitHub API requests
- Add 10-second timeout to prevent hanging requests
- Add JSON array validation before processing releases
- Fix fire-and-forget async call with proper error handling
- Fix UI handlers to check IPCResult and reset loading state on failure
- Clear beta update info when disabling beta so stable downgrade UI shows

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 11:00:20 +01:00
Andy 8be0e6ff1a feat(sentry): add anonymous error reporting with privacy controls (#636)
* feat(sentry): add anonymous error reporting with privacy controls

Integrate @sentry/electron for crash reporting in both main and renderer
processes. Key features:

- Enabled by default with clear privacy messaging during onboarding
- Mid-session toggle via beforeSend hooks (no restart required)
- Comprehensive path masking for macOS, Windows, and Linux usernames
- Complete event sanitization: stack traces, breadcrumbs, tags, contexts,
  extra data, request info, and user info (cleared entirely)
- Race condition prevention: events dropped until settings are loaded
- Shared privacy utilities to eliminate code duplication
- Settings toggle in Debug & Logs section with i18n support (en/fr)
- New PrivacyStep in onboarding wizard explaining data collection

Privacy approach: usernames masked from all paths, project paths remain
visible for debugging (documented as intentional behavior).

* feat(sentry): move DSN to environment variable for fork protection

Previously the Sentry DSN was hardcoded, which caused forks to
send errors to the original project's Sentry account. This created
cost concerns and data pollution.

Changes:
- Remove hardcoded DSN from sentry-privacy.ts
- Main process reads DSN from SENTRY_DSN env var
- Add IPC handler to expose DSN to renderer process
- Renderer fetches DSN via IPC (async initialization)
- Add SENTRY_DSN and SENTRY_DEV documentation to .env.example

Now forks without the env var have Sentry disabled, while official
builds can inject it via CI/CD secrets.

* fix(sentry): address PR review findings and add sample rate env vars

PR Review fixes:
- Fix path masking regex to handle paths at end of strings (lookahead)
- Add error handling to PrivacyStep when save fails
- Add user feedback when Sentry toggle fails in DebugSettings
- Add .catch() handler for async Sentry initialization in main.tsx

New features:
- Add SENTRY_TRACES_SAMPLE_RATE env var (0.0-1.0, default 0.1)
- Add SENTRY_PROFILES_SAMPLE_RATE env var (0.0-1.0, default 0.1)
- Add getSentryConfig IPC to share config with renderer

This allows controlling Sentry sampling via environment variables to
prevent filling up error logs with duplicate issues.

* fix(sentry): only mark settings loaded on successful load

Fixes privacy violation where Sentry would send error reports even if user
had opted out. Previously, markSettingsLoaded() was called in finally block
regardless of success, causing the store to retain DEFAULT_APP_SETTINGS
(sentryEnabled: true) on load failure while marking settings as "loaded".

Now markSettingsLoaded() is only called inside the success condition, so if
settings fail to load, Sentry's beforeSend drops all events (safe default).
2026-01-05 10:35:46 +01:00
Alex Madera 324edfa7d5 fix bugbot comments 2026-01-04 09:01:00 +01:00
Alex b5f452b428 Merge branch 'develop' into enhance-workflows 2026-01-04 08:42:27 +01:00
Andy b0de22a988 Merge branch 'develop' into enhance-workflows 2026-01-03 23:55:57 +01:00
Alex 3c49173210 Merge branch 'develop' into enhance-workflows 2026-01-03 15:21:56 +01:00
Andy d3587c3c42 Merge branch 'develop' into enhance-workflows 2026-01-02 19:46:07 +01:00
Alex Madera f6c9a84acb fix(ci): update action versions for Node.js compatibility
- Update actions/first-interaction to v1.4.0 (Node 16+)
- Keep actions/setup-node@v4 with node-version: 24

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 16:42:09 +01:00
Alex 91b80c0df0 Merge branch 'develop' into enhance-workflows 2026-01-02 16:40:23 +01:00
Alex Madera 052e6a0970 ci: enterprise-grade unified CI pipeline
- Consolidate 16 workflows → 7 workflows
- Single CI workflow per PR (Stage 1→2→3→4)
- Smart path filtering (skip tests for docs-only changes)
- Auto-labeling (type, area, size, status)
- Production-ready with proper documentation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 16:36:47 +01:00
Alex Madera 90f5b59c35 draft: improve yml to be less cluster 2026-01-02 15:35:26 +01:00
245 changed files with 13288 additions and 4190 deletions
+693 -30
View File
@@ -1,49 +1,386 @@
# ╔═══════════════════════════════════════════════════════════════════════════════╗
# ║ ║
# ║ AUTO CLAUDE - CI PIPELINE ║
# ║ ║
# ║ A unified, enterprise-grade CI workflow for pull request validation ║
# ║ and scheduled security scanning. ║
# ║ ║
# ║ TRIGGERS: ║
# ║ - Pull requests to main/develop branches ║
# ║ - Weekly scheduled security scans (Monday 00:00 UTC) ║
# ║ - Manual workflow dispatch ║
# ║ ║
# ║ WORKFLOW STAGES: ║
# ║ ┌─────────────────────────────────────────────────────────────────────────┐ ║
# ║ │ Stage 1: PR Setup - Label PR and set "Checking" status │ ║
# ║ │ Stage 2: Change Detection - Determine which tests to run │ ║
# ║ │ Stage 3: Quality Gates - Run tests, linting, and security scans │ ║
# ║ │ Stage 4: Status Update - Update PR with final status │ ║
# ║ └─────────────────────────────────────────────────────────────────────────┘ ║
# ║ ║
# ║ SMART PATH FILTERING (PR mode): ║
# ║ - Backend changes (apps/backend/**, tests/**) → Python tests + lint ║
# ║ - Frontend changes (apps/frontend/**) → Frontend tests + build ║
# ║ - No code changes (docs, CI configs only) → Skip tests, mark ready ║
# ║ ║
# ║ SCHEDULED SECURITY SCANS: ║
# ║ - Weekly CodeQL analysis for Python and JavaScript/TypeScript ║
# ║ - Weekly Bandit security scan for Python backend ║
# ║ - Detects newly discovered CVEs in existing code ║
# ║ ║
# ║ LABELS APPLIED (PR mode only): ║
# ║ - Status: 🔄 Checking → ✅ Ready for Review / ❌ Checks Failed ║
# ║ - Type: feature, bug, docs, refactor, ci, chore (from PR title) ║
# ║ - Area: area/frontend, area/backend, area/fullstack, area/ci ║
# ║ - Size: size/XS, size/S, size/M, size/L, size/XL ║
# ║ ║
# ║ MAINTAINERS: See CONTRIBUTING.md for workflow modification guidelines. ║
# ║ ║
# ╚═══════════════════════════════════════════════════════════════════════════════╝
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
types: [opened, synchronize, reopened]
# Weekly scheduled security scans to detect newly discovered CVEs
# Runs every Monday at midnight UTC
schedule:
- cron: '0 0 * * 1'
# Allow manual triggering for security scans
workflow_dispatch:
# ─────────────────────────────────────────────────────────────────────────────────
# CONCURRENCY: Cancel redundant runs when new commits are pushed to the same PR
# ─────────────────────────────────────────────────────────────────────────────────
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
group: ci-${{ github.head_ref || github.ref }}
cancel-in-progress: true
# ─────────────────────────────────────────────────────────────────────────────────
# PERMISSIONS: Minimal permissions required for this workflow
# ─────────────────────────────────────────────────────────────────────────────────
permissions:
contents: read
actions: read
contents: read # Read repository contents
actions: read # Read workflow runs
security-events: write # Upload CodeQL results
pull-requests: write # Update PR labels
checks: read # Read check run status
# ═══════════════════════════════════════════════════════════════════════════════════
# JOBS
# ═══════════════════════════════════════════════════════════════════════════════════
jobs:
# Python tests
test-python:
# ┌─────────────────────────────────────────────────────────────────────────────┐
# │ STAGE 1: PR SETUP │
# │ │
# │ Purpose: Initialize PR with labels and "Checking" status │
# │ Runs: First, before any other job │
# │ Labels: Status (Checking), Type, Area, Size │
# └─────────────────────────────────────────────────────────────────────────────┘
stage-1-setup:
name: "Stage 1: PR Setup"
runs-on: ubuntu-latest
# Skip for fork PRs (they cannot write labels) and scheduled runs (no PR context)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 5
steps:
- name: "1.1 Apply Labels and Set Checking Status"
uses: actions/github-script@v7
with:
retries: 3
retry-exempt-status-codes: 400,401,403,404,422
script: |
const { owner, repo } = context.repo;
const pr = context.payload.pull_request;
const prNumber = pr.number;
const title = pr.title;
console.log(`\n${'═'.repeat(60)}`);
console.log(` PR #${prNumber}: ${title}`);
console.log(`${'═'.repeat(60)}\n`);
const labelsToAdd = new Set();
const labelsToRemove = new Set();
// ─────────────────────────────────────────────────────────────
// STATUS LABEL: Set to "Checking" while CI runs
// ─────────────────────────────────────────────────────────────
const statusLabels = ['🔄 Checking', '✅ Ready for Review', '❌ Checks Failed'];
statusLabels.forEach(l => labelsToRemove.add(l));
labelsToAdd.add('🔄 Checking');
console.log('Status: 🔄 Checking');
// ─────────────────────────────────────────────────────────────
// TYPE LABEL: Extracted from Conventional Commit prefix
// Format: type(scope)!: description
// Examples: feat:, fix(ui):, docs!:, refactor(api):
// ─────────────────────────────────────────────────────────────
const typeMap = {
'feat': 'feature', // New feature
'fix': 'bug', // Bug fix
'docs': 'documentation',// Documentation only
'refactor': 'refactor', // Code refactoring
'test': 'test', // Adding/updating tests
'ci': 'ci', // CI/CD changes
'chore': 'chore', // Maintenance tasks
'perf': 'performance', // Performance improvements
'style': 'style', // Code style changes
'build': 'build' // Build system changes
};
const typeMatch = title.match(/^(\w+)(\(.+?\))?(!)?:/);
if (typeMatch) {
const type = typeMatch[1].toLowerCase();
const isBreaking = typeMatch[3] === '!';
if (typeMap[type]) {
labelsToAdd.add(typeMap[type]);
console.log(`Type: ${typeMap[type]}`);
}
if (isBreaking) {
labelsToAdd.add('breaking-change');
console.log('⚠️ Breaking change detected');
}
} else {
console.log('Type: (no conventional commit prefix detected)');
}
// ─────────────────────────────────────────────────────────────
// AREA LABEL: Determined by which files were changed
// ─────────────────────────────────────────────────────────────
let files = [];
try {
const { data } = await github.rest.pulls.listFiles({
owner, repo, pull_number: prNumber, per_page: 100
});
files = data;
} catch (e) {
console.log(`Warning: Could not fetch changed files: ${e.message}`);
}
const areas = { frontend: false, backend: false, ci: false };
for (const file of files) {
const path = file.filename;
if (path.startsWith('apps/frontend/')) areas.frontend = true;
if (path.startsWith('apps/backend/') || path.startsWith('tests/')) areas.backend = true;
if (path.startsWith('.github/')) areas.ci = true;
}
// Area labels are mutually exclusive
const areaLabels = ['area/frontend', 'area/backend', 'area/fullstack', 'area/ci'];
let areaLabel = null;
if (areas.frontend && areas.backend) {
areaLabel = 'area/fullstack';
} else if (areas.frontend) {
areaLabel = 'area/frontend';
} else if (areas.backend) {
areaLabel = 'area/backend';
} else if (areas.ci) {
areaLabel = 'area/ci';
}
if (areaLabel) {
labelsToAdd.add(areaLabel);
areaLabels.filter(l => l !== areaLabel).forEach(l => labelsToRemove.add(l));
console.log(`Area: ${areaLabel.replace('area/', '')}`);
}
// ─────────────────────────────────────────────────────────────
// SIZE LABEL: Based on total lines changed
// XS: <10, S: <100, M: <500, L: <1000, XL: >=1000
// ─────────────────────────────────────────────────────────────
const additions = pr.additions || 0;
const deletions = pr.deletions || 0;
const totalLines = additions + deletions;
const sizeLabels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'];
const sizeLabel = totalLines < 10 ? 'size/XS' :
totalLines < 100 ? 'size/S' :
totalLines < 500 ? 'size/M' :
totalLines < 1000 ? 'size/L' : 'size/XL';
labelsToAdd.add(sizeLabel);
sizeLabels.filter(l => l !== sizeLabel).forEach(l => labelsToRemove.add(l));
console.log(`Size: ${sizeLabel.replace('size/', '')} (+${additions}/-${deletions} = ${totalLines} lines)`);
// ─────────────────────────────────────────────────────────────
// APPLY LABELS
// ─────────────────────────────────────────────────────────────
console.log(`\n${'─'.repeat(60)}`);
console.log('Applying labels...');
// Remove old labels first
const removeArray = [...labelsToRemove].filter(l => !labelsToAdd.has(l));
for (const label of removeArray) {
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: label
});
} catch (e) {
// Ignore 404 (label not present)
}
}
// Add new labels
const addArray = [...labelsToAdd];
if (addArray.length > 0) {
try {
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: addArray
});
console.log(`✓ Labels applied: ${addArray.join(', ')}`);
} catch (e) {
if (e.status === 404) {
core.warning('Some labels do not exist. Please create them in Settings > Labels.');
// Try adding labels one by one
for (const label of addArray) {
try {
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: [label]
});
} catch (e2) {
console.log(` ⚠ Label '${label}' does not exist`);
}
}
}
}
}
console.log(`${'─'.repeat(60)}\n`);
# ┌─────────────────────────────────────────────────────────────────────────────┐
# │ STAGE 2: CHANGE DETECTION │
# │ │
# │ Purpose: Analyze changed files to determine which tests to run │
# │ Runs: After Stage 1 completes │
# │ Outputs: backend, frontend, any_code, skip_tests │
# └─────────────────────────────────────────────────────────────────────────────┘
stage-2-changes:
name: "Stage 2: Detect Changes"
runs-on: ubuntu-latest
needs: stage-1-setup
# Always run even if stage-1 was skipped (fork PRs)
if: always()
timeout-minutes: 5
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
any_code: ${{ steps.filter.outputs.any_code }}
skip_tests: ${{ steps.evaluate.outputs.skip_tests }}
scheduled_scan: ${{ steps.evaluate.outputs.scheduled_scan }}
steps:
- name: "2.1 Checkout Repository"
uses: actions/checkout@v4
- name: "2.2 Analyze Changed Files"
id: filter
uses: dorny/paths-filter@v3
with:
filters: |
backend:
- 'apps/backend/**'
- 'tests/**'
- 'requirements*.txt'
frontend:
- 'apps/frontend/**'
- 'package*.json'
any_code:
- 'apps/**'
- 'tests/**'
- 'package*.json'
- 'requirements*.txt'
- name: "2.3 Evaluate Test Requirements"
id: evaluate
run: |
echo ""
echo "═══════════════════════════════════════════════════════════"
echo " CHANGE DETECTION RESULTS"
echo "═══════════════════════════════════════════════════════════"
echo ""
# For scheduled runs, always run security scans on all code
if [ "${{ github.event_name }}" = "schedule" ] || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo " Event type: ${{ github.event_name }}"
echo " → Scheduled/manual security scan - running all checks"
echo ""
echo "skip_tests=false" >> $GITHUB_OUTPUT
echo "scheduled_scan=true" >> $GITHUB_OUTPUT
else
echo " Backend changes: ${{ steps.filter.outputs.backend }}"
echo " Frontend changes: ${{ steps.filter.outputs.frontend }}"
echo " Any code changes: ${{ steps.filter.outputs.any_code }}"
echo ""
echo "scheduled_scan=false" >> $GITHUB_OUTPUT
if [ "${{ steps.filter.outputs.any_code }}" = "false" ]; then
echo "skip_tests=true" >> $GITHUB_OUTPUT
echo " → No code changes detected"
echo " → Tests will be SKIPPED (docs/config only changes)"
else
echo "skip_tests=false" >> $GITHUB_OUTPUT
echo " → Code changes detected"
echo " → Tests will be EXECUTED"
fi
fi
echo ""
echo "═══════════════════════════════════════════════════════════"
# ┌─────────────────────────────────────────────────────────────────────────────┐
# │ STAGE 3: QUALITY GATES │
# │ │
# │ Purpose: Run tests, linting, and security scans based on detected changes │
# │ Runs: After Stage 2, jobs run in parallel where possible │
# │ Jobs: Python Tests, Frontend Tests, Python Lint, CodeQL, Bandit │
# └─────────────────────────────────────────────────────────────────────────────┘
# ─────────────────────────────────────────────────────────────────────────────
# PYTHON TESTS: Run pytest across Python version matrix
# Triggered: When backend files change
# ─────────────────────────────────────────────────────────────────────────────
stage-3-test-python:
name: "Stage 3: Python Tests (${{ matrix.python-version }})"
runs-on: ubuntu-latest
needs: stage-2-changes
if: needs.stage-2-changes.outputs.backend == 'true'
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
python-version: ['3.12', '3.13']
steps:
- name: Checkout
- name: "3.1 Checkout Repository"
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
- name: "3.2 Setup Python ${{ matrix.python-version }}"
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
- name: "3.3 Setup UV Package Manager"
uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: Install dependencies
- name: "3.4 Install Dependencies"
working-directory: apps/backend
run: |
uv venv
uv pip install -r requirements.txt
uv pip install -r ../../tests/requirements-test.txt
- name: Run tests
- name: "3.5 Run Test Suite"
working-directory: apps/backend
env:
PYTHONPATH: ${{ github.workspace }}/apps/backend
@@ -51,16 +388,20 @@ jobs:
source .venv/bin/activate
pytest ../../tests/ -v --tb=short -x
- name: Run tests with coverage
- name: "3.6 Run Tests with Coverage"
if: matrix.python-version == '3.12'
working-directory: apps/backend
env:
PYTHONPATH: ${{ github.workspace }}/apps/backend
run: |
source .venv/bin/activate
pytest ../../tests/ -v --cov=. --cov-report=xml --cov-report=term-missing --cov-fail-under=20
pytest ../../tests/ -v \
--cov=. \
--cov-report=xml \
--cov-report=term-missing \
--cov-fail-under=20
- name: Upload coverage reports
- name: "3.7 Upload Coverage to Codecov"
if: matrix.python-version == '3.12'
uses: codecov/codecov-action@v4
with:
@@ -69,44 +410,366 @@ jobs:
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
# Frontend lint, typecheck, test, and build
test-frontend:
# ─────────────────────────────────────────────────────────────────────────────
# FRONTEND TESTS: Lint, typecheck, test, and build
# Triggered: When frontend files change
# ─────────────────────────────────────────────────────────────────────────────
stage-3-test-frontend:
name: "Stage 3: Frontend Tests"
runs-on: ubuntu-latest
needs: stage-2-changes
if: needs.stage-2-changes.outputs.frontend == 'true'
timeout-minutes: 15
steps:
- name: Checkout
- name: "3.1 Checkout Repository"
uses: actions/checkout@v4
- name: Setup Node.js
- name: "3.2 Setup Node.js"
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Get npm cache directory
id: npm-cache
run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
- name: "3.3 Cache npm Dependencies"
uses: actions/cache@v4
with:
path: ${{ steps.npm-cache.outputs.dir }}
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install dependencies
- name: "3.4 Install Dependencies"
working-directory: apps/frontend
run: npm ci --ignore-scripts
- name: Lint
- name: "3.5 Run ESLint"
working-directory: apps/frontend
run: npm run lint
- name: Type check
- name: "3.6 Run TypeScript Type Check"
working-directory: apps/frontend
run: npm run typecheck
- name: Run tests
- name: "3.7 Run Unit Tests"
working-directory: apps/frontend
run: npm run test
- name: Build
- name: "3.8 Build Application"
working-directory: apps/frontend
run: npm run build
# ─────────────────────────────────────────────────────────────────────────────
# PYTHON LINT: Check code style and formatting with Ruff
# Triggered: When backend files change
# ─────────────────────────────────────────────────────────────────────────────
stage-3-lint-python:
name: "Stage 3: Python Lint"
runs-on: ubuntu-latest
needs: stage-2-changes
if: needs.stage-2-changes.outputs.backend == 'true'
timeout-minutes: 10
steps:
- name: "3.1 Checkout Repository"
uses: actions/checkout@v4
- name: "3.2 Setup Python"
uses: actions/setup-python@v5
with:
python-version: '3.12'
# Ruff version pinned to match .pre-commit-config.yaml
- name: "3.3 Install Ruff"
run: pip install ruff==0.14.10
- name: "3.4 Run Ruff Linter"
run: ruff check apps/backend/ --output-format=github
- name: "3.5 Check Code Formatting"
run: ruff format apps/backend/ --check --diff
# ─────────────────────────────────────────────────────────────────────────────
# CODEQL: Static analysis for security vulnerabilities
# Triggered: When any code files change OR on scheduled/manual security scans
# ─────────────────────────────────────────────────────────────────────────────
stage-3-codeql:
name: "Stage 3: CodeQL (${{ matrix.language }})"
runs-on: ubuntu-latest
needs: stage-2-changes
# Run on code changes OR scheduled/manual security scans
if: needs.stage-2-changes.outputs.any_code == 'true' || needs.stage-2-changes.outputs.scheduled_scan == 'true'
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
language: [python, javascript-typescript]
steps:
- name: "3.1 Checkout Repository"
uses: actions/checkout@v4
- name: "3.2 Initialize CodeQL"
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: "3.3 Autobuild"
uses: github/codeql-action/autobuild@v3
- name: "3.4 Run CodeQL Analysis"
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"
# ─────────────────────────────────────────────────────────────────────────────
# PYTHON SECURITY: Bandit security scanner for Python code
# Triggered: When backend files change OR on scheduled/manual security scans
# ─────────────────────────────────────────────────────────────────────────────
stage-3-security-python:
name: "Stage 3: Python Security"
runs-on: ubuntu-latest
needs: stage-2-changes
# Run on backend changes OR scheduled/manual security scans
if: needs.stage-2-changes.outputs.backend == 'true' || needs.stage-2-changes.outputs.scheduled_scan == 'true'
timeout-minutes: 10
steps:
- name: "3.1 Checkout Repository"
uses: actions/checkout@v4
- name: "3.2 Setup Python"
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: "3.3 Install Bandit"
run: pip install bandit
- name: "3.4 Run Bandit Security Scan"
id: bandit
run: |
# Run Bandit and capture exit code
# Exit 0 = no issues, Exit 1 = issues found, Exit > 1 = real error
set +e
bandit -r apps/backend/ -ll -ii -f json -o bandit-report.json
BANDIT_EXIT=$?
set -e
if [ $BANDIT_EXIT -eq 0 ]; then
echo "✓ Bandit scan completed - no issues found"
echo "scan_status=clean" >> $GITHUB_OUTPUT
elif [ $BANDIT_EXIT -eq 1 ]; then
echo "⚠ Bandit scan completed - security issues found"
echo "scan_status=issues_found" >> $GITHUB_OUTPUT
else
echo "✗ Bandit scan failed with exit code $BANDIT_EXIT"
echo " This indicates a configuration error or missing directory"
exit $BANDIT_EXIT
fi
- name: "3.5 Analyze Security Results"
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const scanStatus = '${{ steps.bandit.outputs.scan_status }}';
if (!fs.existsSync('bandit-report.json')) {
core.setFailed('Bandit report not found - scan failed before producing output');
return;
}
// If scan was clean, we can skip detailed analysis
if (scanStatus === 'clean') {
console.log('\n' + '═'.repeat(60));
console.log(' BANDIT SECURITY SCAN RESULTS');
console.log('═'.repeat(60));
console.log('\n✓ No security issues found\n');
console.log('═'.repeat(60) + '\n');
return;
}
const report = JSON.parse(fs.readFileSync('bandit-report.json', 'utf8'));
const results = report.results || [];
// Categorize by severity
const high = results.filter(r => r.issue_severity === 'HIGH');
const medium = results.filter(r => r.issue_severity === 'MEDIUM');
const low = results.filter(r => r.issue_severity === 'LOW');
console.log('\n' + '═'.repeat(60));
console.log(' BANDIT SECURITY SCAN RESULTS');
console.log('═'.repeat(60));
console.log(`\n HIGH: ${high.length}`);
console.log(` MEDIUM: ${medium.length}`);
console.log(` LOW: ${low.length}\n`);
if (high.length > 0) {
console.log('─'.repeat(60));
console.log(' HIGH SEVERITY ISSUES:');
console.log('─'.repeat(60));
for (const issue of high) {
console.log(`\n 📍 ${issue.filename}:${issue.line_number}`);
console.log(` ${issue.issue_text}`);
console.log(` Test: ${issue.test_id} (${issue.test_name})`);
}
console.log('\n' + '═'.repeat(60));
core.setFailed(`Found ${high.length} high severity security issue(s)`);
} else {
console.log('✓ No high severity security issues found');
console.log('═'.repeat(60) + '\n');
}
# ┌─────────────────────────────────────────────────────────────────────────────┐
# │ STAGE 4: STATUS UPDATE │
# │ │
# │ Purpose: Aggregate results from all quality gates and update PR status │
# │ Runs: After ALL Stage 3 jobs complete (success, failure, or skipped) │
# │ Updates: PR label to "Ready for Review" or "Checks Failed" │
# └─────────────────────────────────────────────────────────────────────────────┘
stage-4-status:
name: "Stage 4: Update PR Status"
runs-on: ubuntu-latest
needs:
- stage-2-changes
- stage-3-test-python
- stage-3-test-frontend
- stage-3-lint-python
- stage-3-codeql
- stage-3-security-python
# Always run to update status, even if previous jobs failed or were skipped
# For scheduled runs, skip PR label updates (no PR context)
if: always() && (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
timeout-minutes: 5
steps:
- name: "4.1 Evaluate CI Results and Update PR"
uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const eventName = '${{ github.event_name }}';
const isScheduledRun = eventName === 'schedule' || eventName === 'workflow_dispatch';
const prNumber = context.payload.pull_request?.number;
// ─────────────────────────────────────────────────────────────
// COLLECT RESULTS FROM ALL QUALITY GATE JOBS
// ─────────────────────────────────────────────────────────────
const results = {
'Python Tests': '${{ needs.stage-3-test-python.result }}',
'Frontend Tests': '${{ needs.stage-3-test-frontend.result }}',
'Python Lint': '${{ needs.stage-3-lint-python.result }}',
'CodeQL': '${{ needs.stage-3-codeql.result }}',
'Python Security': '${{ needs.stage-3-security-python.result }}'
};
const skipTests = '${{ needs.stage-2-changes.outputs.skip_tests }}' === 'true';
console.log('\n' + '═'.repeat(60));
if (isScheduledRun) {
console.log(' SCHEDULED SECURITY SCAN RESULTS');
} else {
console.log(' CI PIPELINE RESULTS');
}
console.log('═'.repeat(60) + '\n');
if (skipTests && !isScheduledRun) {
console.log(' ️ No code changes detected - tests were skipped\n');
}
console.log(' Job Results:');
console.log(' ' + '─'.repeat(40));
for (const [job, result] of Object.entries(results)) {
const icon = result === 'success' ? '✓' :
result === 'skipped' ? '○' :
result === 'failure' ? '✗' : '?';
console.log(` ${icon} ${job}: ${result}`);
}
console.log(' ' + '─'.repeat(40) + '\n');
// ─────────────────────────────────────────────────────────────
// DETERMINE FINAL STATUS
// Success and skipped are acceptable; failure is not
// ─────────────────────────────────────────────────────────────
const acceptable = ['success', 'skipped'];
const failed = Object.entries(results)
.filter(([_, result]) => !acceptable.includes(result))
.map(([job, _]) => job);
const statusLabels = {
checking: '🔄 Checking',
passed: '✅ Ready for Review',
failed: '❌ Checks Failed'
};
// ─────────────────────────────────────────────────────────────
// UPDATE PR LABELS (skip for scheduled runs - no PR context)
// ─────────────────────────────────────────────────────────────
if (!isScheduledRun && prNumber) {
// Remove all status labels first
for (const label of Object.values(statusLabels)) {
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: label
});
} catch (e) {
// Ignore 404 (label not present)
}
}
// Add appropriate status label
const newLabel = failed.length > 0 ? statusLabels.failed : statusLabels.passed;
try {
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: [newLabel]
});
} catch (e) {
if (e.status === 404) {
core.warning(`Label '${newLabel}' does not exist. Please create it in Settings > Labels.`);
}
}
}
// ─────────────────────────────────────────────────────────────
// GENERATE SUMMARY
// ─────────────────────────────────────────────────────────────
if (failed.length > 0) {
const resultType = isScheduledRun ? 'SECURITY SCAN FAILED' : 'CI FAILED';
console.log(` ❌ RESULT: ${resultType}`);
console.log(` Failed jobs: ${failed.join(', ')}`);
console.log('\n' + '═'.repeat(60) + '\n');
core.summary.addRaw(`## ❌ ${resultType}\n\n`);
core.summary.addRaw(`The following checks failed:\n`);
for (const job of failed) {
core.summary.addRaw(`- ${job}\n`);
}
core.setFailed(`${resultType}: ${failed.join(', ')}`);
} else if (isScheduledRun) {
console.log(` ✅ RESULT: SECURITY SCAN PASSED`);
console.log(` All security checks passed`);
console.log('\n' + '═'.repeat(60) + '\n');
core.summary.addRaw(`## ✅ Weekly Security Scan Passed\n\n`);
core.summary.addRaw(`All scheduled security checks (CodeQL, Bandit) completed successfully.\n`);
} else if (skipTests) {
console.log(` ✅ RESULT: READY FOR REVIEW`);
console.log(` No code changes - tests skipped`);
console.log('\n' + '═'.repeat(60) + '\n');
core.summary.addRaw(`## ✅ Ready for Review\n\n`);
core.summary.addRaw(`No code changes detected. Documentation or configuration changes only.\n`);
} else {
console.log(` ✅ RESULT: READY FOR REVIEW`);
console.log(` All quality gates passed`);
console.log('\n' + '═'.repeat(60) + '\n');
core.summary.addRaw(`## ✅ Ready for Review\n\n`);
core.summary.addRaw(`All CI checks passed successfully.\n`);
}
await core.summary.write();
+121
View File
@@ -0,0 +1,121 @@
name: Community
# Consolidated community automation:
# - Welcome messages for first-time contributors (issues & PRs)
# - Auto-label issues based on form selection
# - Mark and close stale issues
on:
issues:
types: [opened]
pull_request_target:
types: [opened]
schedule:
- cron: '0 0 * * 0' # Every Sunday at midnight UTC
workflow_dispatch: # Allow manual trigger for stale check
jobs:
# ═══════════════════════════════════════════════════════════════════════════
# WELCOME - First-time contributor welcome messages
# ═══════════════════════════════════════════════════════════════════════════
welcome:
name: Welcome New Contributors
runs-on: ubuntu-latest
if: github.event_name == 'pull_request_target' || github.event_name == 'issues'
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/first-interaction@v1.4.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
issue-message: |
Thanks for opening your first issue!
A maintainer will triage this soon. In the meantime:
- Make sure you've provided all the requested info
- Join our [Discord](https://discord.gg/QhRnz9m5HE) for faster help
pr-message: |
Thanks for your first PR!
A maintainer will review it soon. Please make sure:
- Your branch is synced with `develop`
- CI checks pass
- You've followed our [contribution guide](https://github.com/AndyMik90/Auto-Claude/blob/develop/CONTRIBUTING.md)
Welcome to the Auto Claude community!
# ═══════════════════════════════════════════════════════════════════════════
# ISSUE LABELS - Auto-label issues based on form area selection
# ═══════════════════════════════════════════════════════════════════════════
issue-labels:
name: Label Issue by Area
runs-on: ubuntu-latest
if: github.event_name == 'issues'
permissions:
issues: write
steps:
- name: Add area label from form
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
const body = issue.body || '';
console.log(`Processing issue #${issue.number}: ${issue.title}`);
// Map form selection to label
const areaMap = {
'Frontend': 'area/frontend',
'Backend': 'area/backend',
'Fullstack': 'area/fullstack'
};
const labels = [];
for (const [key, label] of Object.entries(areaMap)) {
if (body.includes(key)) {
console.log(`Found area: ${key}, adding label: ${label}`);
labels.push(label);
break;
}
}
if (labels.length > 0) {
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: labels
});
console.log(`Successfully added labels: ${labels.join(', ')}`);
} catch (error) {
core.setFailed(`Failed to add labels: ${error.message}`);
}
} else {
console.log('No matching area found in issue body');
}
# ═══════════════════════════════════════════════════════════════════════════
# STALE - Mark and close inactive issues
# ═══════════════════════════════════════════════════════════════════════════
stale:
name: Mark Stale Issues
runs-on: ubuntu-latest
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
permissions:
issues: write
steps:
- uses: actions/stale@v9
with:
stale-issue-message: |
This issue has been inactive for 60 days. It will be closed in 14 days if there's no activity.
- If this is still relevant, please comment or update the issue
- If you're working on this, add the `in-progress` label
close-issue-message: 'Closed due to inactivity. Feel free to reopen if still relevant.'
stale-issue-label: 'stale'
days-before-stale: 60
days-before-close: 14
exempt-issue-labels: 'priority/critical,priority/high,in-progress,blocked'
-53
View File
@@ -1,53 +0,0 @@
name: Issue Auto Label
on:
issues:
types: [opened]
jobs:
label-area:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Add area label from form
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
const body = issue.body || '';
console.log(`Processing issue #${issue.number}: ${issue.title}`);
// Map form selection to label
const areaMap = {
'Frontend': 'area/frontend',
'Backend': 'area/backend',
'Fullstack': 'area/fullstack'
};
const labels = [];
for (const [key, label] of Object.entries(areaMap)) {
if (body.includes(key)) {
console.log(`Found area: ${key}, adding label: ${label}`);
labels.push(label);
break;
}
}
if (labels.length > 0) {
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: labels
});
console.log(`Successfully added labels: ${labels.join(', ')}`);
} catch (error) {
core.setFailed(`Failed to add labels: ${error.message}`);
}
} else {
console.log('No matching area found in issue body');
}
-34
View File
@@ -1,34 +0,0 @@
name: Lint
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
concurrency:
group: lint-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
# Python linting
python:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
# Pin ruff version to match .pre-commit-config.yaml (astral-sh/ruff-pre-commit rev)
- name: Install ruff
run: pip install ruff==0.14.10
- name: Run ruff check
run: ruff check apps/backend/ --output-format=github
- name: Run ruff format check
run: ruff format apps/backend/ --check --diff
-227
View File
@@ -1,227 +0,0 @@
name: PR Auto Label
on:
pull_request:
types: [opened, synchronize, reopened]
# Cancel in-progress runs for the same PR
concurrency:
group: pr-auto-label-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
contents: read
pull-requests: write
jobs:
label:
name: Auto Label PR
runs-on: ubuntu-latest
# Don't run on fork PRs (they can't write labels)
if: github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 5
steps:
- name: Auto-label PR
uses: actions/github-script@v7
with:
retries: 3
retry-exempt-status-codes: 400,401,403,404,422
script: |
const { owner, repo } = context.repo;
const pr = context.payload.pull_request;
const prNumber = pr.number;
const title = pr.title;
console.log(`::group::PR #${prNumber} - Auto-labeling`);
console.log(`Title: ${title}`);
const labelsToAdd = new Set();
const labelsToRemove = new Set();
// ═══════════════════════════════════════════════════════════════
// TYPE LABELS (from PR title - Conventional Commits)
// ═══════════════════════════════════════════════════════════════
const typeMap = {
'feat': 'feature',
'fix': 'bug',
'docs': 'documentation',
'refactor': 'refactor',
'test': 'test',
'ci': 'ci',
'chore': 'chore',
'perf': 'performance',
'style': 'style',
'build': 'build'
};
const typeMatch = title.match(/^(\w+)(\(.+?\))?(!)?:/);
if (typeMatch) {
const type = typeMatch[1].toLowerCase();
const isBreaking = typeMatch[3] === '!';
if (typeMap[type]) {
labelsToAdd.add(typeMap[type]);
console.log(` 📝 Type: ${type} → ${typeMap[type]}`);
}
if (isBreaking) {
labelsToAdd.add('breaking-change');
console.log(` ⚠️ Breaking change detected`);
}
} else {
console.log(` ⚠️ No conventional commit prefix found in title`);
}
// ═══════════════════════════════════════════════════════════════
// AREA LABELS (from changed files)
// ═══════════════════════════════════════════════════════════════
let files = [];
try {
const { data } = await github.rest.pulls.listFiles({
owner,
repo,
pull_number: prNumber,
per_page: 100
});
files = data;
} catch (e) {
console.log(` ⚠️ Could not fetch files: ${e.message}`);
}
const areas = {
frontend: false,
backend: false,
ci: false,
docs: false,
tests: false
};
for (const file of files) {
const path = file.filename;
if (path.startsWith('apps/frontend/')) areas.frontend = true;
if (path.startsWith('apps/backend/')) areas.backend = true;
if (path.startsWith('.github/')) areas.ci = true;
if (path.endsWith('.md') || path.startsWith('docs/')) areas.docs = true;
if (path.startsWith('tests/') || path.includes('.test.') || path.includes('.spec.')) areas.tests = true;
}
// Determine area label (mutually exclusive)
const areaLabels = ['area/frontend', 'area/backend', 'area/fullstack', 'area/ci'];
if (areas.frontend && areas.backend) {
labelsToAdd.add('area/fullstack');
areaLabels.filter(l => l !== 'area/fullstack').forEach(l => labelsToRemove.add(l));
console.log(` 📁 Area: fullstack (${files.length} files)`);
} else if (areas.frontend) {
labelsToAdd.add('area/frontend');
areaLabels.filter(l => l !== 'area/frontend').forEach(l => labelsToRemove.add(l));
console.log(` 📁 Area: frontend (${files.length} files)`);
} else if (areas.backend) {
labelsToAdd.add('area/backend');
areaLabels.filter(l => l !== 'area/backend').forEach(l => labelsToRemove.add(l));
console.log(` 📁 Area: backend (${files.length} files)`);
} else if (areas.ci) {
labelsToAdd.add('area/ci');
areaLabels.filter(l => l !== 'area/ci').forEach(l => labelsToRemove.add(l));
console.log(` 📁 Area: ci (${files.length} files)`);
}
// ═══════════════════════════════════════════════════════════════
// SIZE LABELS (from lines changed)
// ═══════════════════════════════════════════════════════════════
const additions = pr.additions || 0;
const deletions = pr.deletions || 0;
const totalLines = additions + deletions;
const sizeLabels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'];
let sizeLabel;
if (totalLines < 10) sizeLabel = 'size/XS';
else if (totalLines < 100) sizeLabel = 'size/S';
else if (totalLines < 500) sizeLabel = 'size/M';
else if (totalLines < 1000) sizeLabel = 'size/L';
else sizeLabel = 'size/XL';
labelsToAdd.add(sizeLabel);
sizeLabels.filter(l => l !== sizeLabel).forEach(l => labelsToRemove.add(l));
console.log(` 📏 Size: ${sizeLabel} (+${additions}/-${deletions} = ${totalLines} lines)`);
console.log('::endgroup::');
// ═══════════════════════════════════════════════════════════════
// APPLY LABELS
// ═══════════════════════════════════════════════════════════════
console.log(`::group::Applying labels`);
// Remove old labels (in parallel)
const removeArray = [...labelsToRemove].filter(l => !labelsToAdd.has(l));
if (removeArray.length > 0) {
const removePromises = removeArray.map(async (label) => {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: label
});
console.log(` ✓ Removed: ${label}`);
} catch (e) {
if (e.status !== 404) {
console.log(` ⚠ Could not remove ${label}: ${e.message}`);
}
}
});
await Promise.all(removePromises);
}
// Add new labels
const addArray = [...labelsToAdd];
if (addArray.length > 0) {
try {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: addArray
});
console.log(` ✓ Added: ${addArray.join(', ')}`);
} catch (e) {
// Some labels might not exist
if (e.status === 404) {
core.warning(`Some labels do not exist. Please create them in repository settings.`);
// Try adding one by one
for (const label of addArray) {
try {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: [label]
});
} catch (e2) {
console.log(` ⚠ Label '${label}' does not exist`);
}
}
} else {
throw e;
}
}
}
console.log('::endgroup::');
// Summary
console.log(`✅ PR #${prNumber} labeled: ${addArray.join(', ')}`);
// Write job summary
core.summary
.addHeading(`PR #${prNumber} Auto-Labels`, 3)
.addTable([
[{data: 'Category', header: true}, {data: 'Label', header: true}],
['Type', typeMatch ? typeMap[typeMatch[1].toLowerCase()] || 'none' : 'none'],
['Area', areas.frontend && areas.backend ? 'fullstack' : areas.frontend ? 'frontend' : areas.backend ? 'backend' : 'other'],
['Size', sizeLabel]
])
.addRaw(`\n**Files changed:** ${files.length}\n`)
.addRaw(`**Lines:** +${additions} / -${deletions}\n`);
await core.summary.write();
-72
View File
@@ -1,72 +0,0 @@
name: PR Status Check
on:
pull_request:
types: [opened, synchronize, reopened]
# Cancel in-progress runs for the same PR
concurrency:
group: pr-status-${{ github.event.pull_request.number }}
cancel-in-progress: true
permissions:
pull-requests: write
jobs:
mark-checking:
name: Set Checking Status
runs-on: ubuntu-latest
# Don't run on fork PRs (they can't write labels)
if: github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 5
steps:
- name: Update PR status label
uses: actions/github-script@v7
with:
retries: 3
retry-exempt-status-codes: 400,401,403,404,422
script: |
const { owner, repo } = context.repo;
const prNumber = context.payload.pull_request.number;
const statusLabels = ['🔄 Checking', '✅ Ready for Review', '❌ Checks Failed'];
console.log(`::group::PR #${prNumber} - Setting status to Checking`);
// Remove old status labels (parallel for speed)
const removePromises = statusLabels.map(async (label) => {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: label
});
console.log(` ✓ Removed: ${label}`);
} catch (e) {
if (e.status !== 404) {
console.log(` ⚠ Could not remove ${label}: ${e.message}`);
}
}
});
await Promise.all(removePromises);
// Add checking label
try {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: ['🔄 Checking']
});
console.log(` ✓ Added: 🔄 Checking`);
} catch (e) {
// Label might not exist - create helpful error
if (e.status === 404) {
core.warning(`Label '🔄 Checking' does not exist. Please create it in repository settings.`);
}
throw e;
}
console.log('::endgroup::');
console.log(`✅ PR #${prNumber} marked as checking`);
-191
View File
@@ -1,191 +0,0 @@
name: PR Status Gate
on:
workflow_run:
workflows: [CI, Lint, Quality Security]
types: [completed]
permissions:
pull-requests: write
checks: read
jobs:
update-status:
name: Update PR Status
runs-on: ubuntu-latest
# Only run if this workflow_run is associated with a PR
if: github.event.workflow_run.pull_requests[0] != null
timeout-minutes: 5
steps:
- name: Check all required checks and update label
uses: actions/github-script@v7
with:
retries: 3
retry-exempt-status-codes: 400,401,403,404,422
script: |
const { owner, repo } = context.repo;
const prNumber = context.payload.workflow_run.pull_requests[0].number;
const headSha = context.payload.workflow_run.head_sha;
const triggerWorkflow = context.payload.workflow_run.name;
// ═══════════════════════════════════════════════════════════════════════
// REQUIRED CHECK RUNS - Job-level checks (not workflow-level)
// ═══════════════════════════════════════════════════════════════════════
// Format: "{Workflow Name} / {Job Name}" or "{Workflow Name} / {Job Custom Name}"
//
// To find check names: Go to PR → Checks tab → copy exact name
// To update: Edit this list when workflow jobs are added/renamed/removed
//
// Last validated: 2026-01-02
// ═══════════════════════════════════════════════════════════════════════
const requiredChecks = [
// CI workflow (ci.yml) - 3 checks
'CI / test-frontend',
'CI / test-python (3.12)',
'CI / test-python (3.13)',
// Lint workflow (lint.yml) - 1 check
'Lint / python',
// Quality Security workflow (quality-security.yml) - 4 checks
'Quality Security / CodeQL (javascript-typescript)',
'Quality Security / CodeQL (python)',
'Quality Security / Python Security (Bandit)',
'Quality Security / Security Summary'
];
const statusLabels = {
checking: '🔄 Checking',
passed: '✅ Ready for Review',
failed: '❌ Checks Failed'
};
console.log(`::group::PR #${prNumber} - Checking required checks`);
console.log(`Triggered by: ${triggerWorkflow}`);
console.log(`Head SHA: ${headSha}`);
console.log(`Required checks: ${requiredChecks.length}`);
console.log('');
// Fetch all check runs for this commit
let allCheckRuns = [];
try {
const { data } = await github.rest.checks.listForRef({
owner,
repo,
ref: headSha,
per_page: 100
});
allCheckRuns = data.check_runs;
console.log(`Found ${allCheckRuns.length} total check runs`);
} catch (error) {
// Add warning annotation so maintainers are alerted
core.warning(`Failed to fetch check runs for PR #${prNumber}: ${error.message}. PR label may be outdated.`);
console.log(`::error::Failed to fetch check runs: ${error.message}`);
console.log('::endgroup::');
return;
}
let allComplete = true;
let anyFailed = false;
const results = [];
// Check each required check
for (const checkName of requiredChecks) {
const check = allCheckRuns.find(c => c.name === checkName);
if (!check) {
results.push({ name: checkName, status: '⏳ Pending', complete: false });
allComplete = false;
} else if (check.status !== 'completed') {
results.push({ name: checkName, status: '🔄 Running', complete: false });
allComplete = false;
} else if (check.conclusion === 'success') {
results.push({ name: checkName, status: '✅ Passed', complete: true });
} else if (check.conclusion === 'skipped') {
// Skipped checks are treated as passed (e.g., path filters, conditional jobs)
results.push({ name: checkName, status: '⏭️ Skipped', complete: true, skipped: true });
} else {
results.push({ name: checkName, status: '❌ Failed', complete: true, failed: true });
anyFailed = true;
}
}
// Print results table
console.log('');
console.log('Check Status:');
console.log('─'.repeat(70));
for (const r of results) {
const shortName = r.name.length > 55 ? r.name.substring(0, 52) + '...' : r.name;
console.log(` ${r.status.padEnd(12)} ${shortName}`);
}
console.log('─'.repeat(70));
console.log('::endgroup::');
// Only update label if all required checks are complete
if (!allComplete) {
const pending = results.filter(r => !r.complete).length;
console.log(`⏳ ${pending}/${requiredChecks.length} checks still pending - keeping current label`);
return;
}
// Determine final label
const newLabel = anyFailed ? statusLabels.failed : statusLabels.passed;
console.log(`::group::Updating PR #${prNumber} label`);
// Remove old status labels
for (const label of Object.values(statusLabels)) {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: label
});
console.log(` ✓ Removed: ${label}`);
} catch (e) {
if (e.status !== 404) {
console.log(` ⚠ Could not remove ${label}: ${e.message}`);
}
}
}
// Add final status label
try {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: [newLabel]
});
console.log(` ✓ Added: ${newLabel}`);
} catch (e) {
if (e.status === 404) {
core.warning(`Label '${newLabel}' does not exist. Please create it in repository settings.`);
}
throw e;
}
console.log('::endgroup::');
// Summary
const passedCount = results.filter(r => r.status === '✅ Passed').length;
const skippedCount = results.filter(r => r.skipped).length;
const failedCount = results.filter(r => r.failed).length;
if (anyFailed) {
console.log(`❌ PR #${prNumber} has ${failedCount} failing check(s)`);
core.summary.addRaw(`## ❌ PR #${prNumber} - Checks Failed\n\n`);
core.summary.addRaw(`**${failedCount}** of **${requiredChecks.length}** required checks failed.\n\n`);
} else {
const skippedNote = skippedCount > 0 ? ` (${skippedCount} skipped)` : '';
const totalSuccessful = passedCount + skippedCount;
console.log(`✅ PR #${prNumber} is ready for review (${totalSuccessful}/${requiredChecks.length} checks succeeded${skippedNote})`);
core.summary.addRaw(`## ✅ PR #${prNumber} - Ready for Review\n\n`);
core.summary.addRaw(`All **${requiredChecks.length}** required checks succeeded${skippedNote}.\n\n`);
}
// Add results to summary
core.summary.addTable([
[{data: 'Check', header: true}, {data: 'Status', header: true}],
...results.map(r => [r.name, r.status])
]);
await core.summary.write();
+64
View File
@@ -34,6 +34,70 @@ jobs:
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Package version: $VERSION"
# Validate all version files are in sync before proceeding
- name: Validate version sync
run: |
echo "Validating version synchronization across all files..."
ROOT_VERSION=$(node -p "require('./package.json').version")
FRONTEND_VERSION=$(node -p "require('./apps/frontend/package.json').version")
# Extract Python version - handles both formats: __version__ = "X.Y.Z" or __version__="X.Y.Z"
BACKEND_VERSION=$(grep -oP '__version__\s*=\s*["\x27]\K[^"\x27]+' apps/backend/__init__.py || echo "NOT_FOUND")
echo "=========================================="
echo "Version Sync Validation"
echo "=========================================="
echo "Root package.json: $ROOT_VERSION"
echo "Frontend package.json: $FRONTEND_VERSION"
echo "Backend __init__.py: $BACKEND_VERSION"
echo "=========================================="
ERRORS=0
if [ "$ROOT_VERSION" != "$FRONTEND_VERSION" ]; then
echo "::error::Version mismatch: root package.json ($ROOT_VERSION) != frontend package.json ($FRONTEND_VERSION)"
ERRORS=$((ERRORS + 1))
fi
if [ "$BACKEND_VERSION" = "NOT_FOUND" ]; then
echo "::error::Could not extract version from apps/backend/__init__.py"
ERRORS=$((ERRORS + 1))
elif [ "$ROOT_VERSION" != "$BACKEND_VERSION" ]; then
echo "::error::Version mismatch: root package.json ($ROOT_VERSION) != backend __init__.py ($BACKEND_VERSION)"
ERRORS=$((ERRORS + 1))
fi
if [ $ERRORS -gt 0 ]; then
echo ""
echo "::error::═══════════════════════════════════════════════════════════════════════"
echo "::error:: VERSION SYNC FAILED"
echo "::error::═══════════════════════════════════════════════════════════════════════"
echo "::error::"
echo "::error:: All version files must be in sync before releasing."
echo "::error::"
echo "::error:: To fix this, use the bump-version script:"
echo "::error:: node scripts/bump-version.js <patch|minor|major|X.Y.Z>"
echo "::error::"
echo "::error:: This will update all version files automatically."
echo "::error::═══════════════════════════════════════════════════════════════════════"
# Add to job summary
echo "## Version Sync Failed" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| File | Version |" >> $GITHUB_STEP_SUMMARY
echo "|------|---------|" >> $GITHUB_STEP_SUMMARY
echo "| package.json | $ROOT_VERSION |" >> $GITHUB_STEP_SUMMARY
echo "| apps/frontend/package.json | $FRONTEND_VERSION |" >> $GITHUB_STEP_SUMMARY
echo "| apps/backend/__init__.py | $BACKEND_VERSION |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Use \`node scripts/bump-version.js <version>\` to sync all files." >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo ""
echo "All version files are in sync: $ROOT_VERSION"
- name: Get latest tag version
id: latest_tag
run: |
-178
View File
@@ -1,178 +0,0 @@
name: Quality Security
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
schedule:
- cron: '0 0 * * 1' # Weekly on Monday at midnight UTC
# Cancel in-progress runs for the same branch/PR
concurrency:
group: security-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
security-events: write
actions: read
jobs:
codeql:
name: CodeQL (${{ matrix.language }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
language: [python, javascript-typescript]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"
python-security:
name: Python Security (Bandit)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install Bandit
run: pip install bandit
- name: Run Bandit security scan
id: bandit
run: |
echo "::group::Running Bandit security scan"
# Run Bandit; exit code 1 means issues found (expected), other codes are errors
# Flags: -r=recursive, -ll=severity LOW+, -ii=confidence LOW+, -f=format, -o=output
bandit -r apps/backend/ -ll -ii -f json -o bandit-report.json || BANDIT_EXIT=$?
if [ "${BANDIT_EXIT:-0}" -gt 1 ]; then
echo "::error::Bandit scan failed with exit code $BANDIT_EXIT"
exit 1
fi
echo "::endgroup::"
- name: Analyze Bandit results
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
// Check if report exists
if (!fs.existsSync('bandit-report.json')) {
core.setFailed('Bandit report not found - scan may have failed');
return;
}
const report = JSON.parse(fs.readFileSync('bandit-report.json', 'utf8'));
const results = report.results || [];
// Categorize by severity
const high = results.filter(r => r.issue_severity === 'HIGH');
const medium = results.filter(r => r.issue_severity === 'MEDIUM');
const low = results.filter(r => r.issue_severity === 'LOW');
console.log(`::group::Bandit Security Scan Results`);
console.log(`Found ${results.length} issues:`);
console.log(` 🔴 HIGH: ${high.length}`);
console.log(` 🟡 MEDIUM: ${medium.length}`);
console.log(` 🟢 LOW: ${low.length}`);
console.log('');
// Print high severity issues
if (high.length > 0) {
console.log('High Severity Issues:');
console.log('─'.repeat(60));
for (const issue of high) {
console.log(` ${issue.filename}:${issue.line_number}`);
console.log(` ${issue.issue_text}`);
console.log(` Test: ${issue.test_id} (${issue.test_name})`);
console.log('');
}
}
console.log('::endgroup::');
// Build summary
let summary = `## 🔒 Python Security Scan (Bandit)\n\n`;
summary += `| Severity | Count |\n`;
summary += `|----------|-------|\n`;
summary += `| 🔴 High | ${high.length} |\n`;
summary += `| 🟡 Medium | ${medium.length} |\n`;
summary += `| 🟢 Low | ${low.length} |\n\n`;
if (high.length > 0) {
summary += `### High Severity Issues\n\n`;
for (const issue of high) {
summary += `- **${issue.filename}:${issue.line_number}**\n`;
summary += ` - ${issue.issue_text}\n`;
summary += ` - Test: \`${issue.test_id}\` (${issue.test_name})\n\n`;
}
}
core.summary.addRaw(summary);
await core.summary.write();
// Fail if high severity issues found
if (high.length > 0) {
core.setFailed(`Found ${high.length} high severity security issue(s)`);
} else {
console.log('✅ No high severity security issues found');
}
# Summary job that waits for all security checks
security-summary:
name: Security Summary
runs-on: ubuntu-latest
needs: [codeql, python-security]
if: always()
timeout-minutes: 5
steps:
- name: Check security results
uses: actions/github-script@v7
with:
script: |
const codeql = '${{ needs.codeql.result }}';
const bandit = '${{ needs.python-security.result }}';
console.log('Security Check Results:');
console.log(` CodeQL: ${codeql}`);
console.log(` Bandit: ${bandit}`);
// Only 'failure' is a real failure; 'skipped' is acceptable (e.g., path filters)
const acceptable = ['success', 'skipped'];
const codeqlOk = acceptable.includes(codeql);
const banditOk = acceptable.includes(bandit);
const allPassed = codeqlOk && banditOk;
if (allPassed) {
console.log('\n✅ All security checks passed');
core.summary.addRaw('## ✅ Security Checks Passed\n\nAll security scans completed successfully.');
} else {
console.log('\n❌ Some security checks failed');
core.summary.addRaw('## ❌ Security Checks Failed\n\nOne or more security scans found issues.');
core.setFailed('Security checks failed');
}
await core.summary.write();
-25
View File
@@ -1,25 +0,0 @@
name: Stale Issues
on:
schedule:
- cron: '0 0 * * 0' # Every Sunday
workflow_dispatch:
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/stale@v9
with:
stale-issue-message: |
This issue has been inactive for 60 days. It will be closed in 14 days if there's no activity.
- If this is still relevant, please comment or update the issue
- If you're working on this, add the `in-progress` label
close-issue-message: 'Closed due to inactivity. Feel free to reopen if still relevant.'
stale-issue-label: 'stale'
days-before-stale: 60
days-before-close: 14
exempt-issue-labels: 'priority/critical,priority/high,in-progress,blocked'
-63
View File
@@ -1,63 +0,0 @@
name: Test on Tag
on:
push:
tags:
- 'v*'
jobs:
# Python tests
test-python:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.12', '3.13']
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: Install dependencies
working-directory: apps/backend
run: |
uv venv
uv pip install -r requirements.txt
uv pip install -r ../../tests/requirements-test.txt
- name: Run tests
working-directory: apps/backend
env:
PYTHONPATH: ${{ github.workspace }}/apps/backend
run: |
source .venv/bin/activate
pytest ../../tests/ -v --tb=short
# Frontend tests
test-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Install dependencies
working-directory: apps/frontend
run: npm ci --ignore-scripts
- name: Run tests
working-directory: apps/frontend
run: npm run test
-71
View File
@@ -1,71 +0,0 @@
name: Validate Version
on:
push:
tags:
- 'v*'
jobs:
validate-version:
name: Validate package.json version matches tag
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Extract version from tag
id: tag_version
run: |
# Extract version from tag (e.g., v2.5.5 -> 2.5.5)
TAG_VERSION=${GITHUB_REF#refs/tags/v}
echo "version=$TAG_VERSION" >> $GITHUB_OUTPUT
echo "Tag version: $TAG_VERSION"
- name: Extract version from package.json
id: package_version
run: |
# Read version from package.json
PACKAGE_VERSION=$(node -p "require('./apps/frontend/package.json').version")
echo "version=$PACKAGE_VERSION" >> $GITHUB_OUTPUT
echo "Package.json version: $PACKAGE_VERSION"
- name: Compare versions
run: |
TAG_VERSION="${{ steps.tag_version.outputs.version }}"
PACKAGE_VERSION="${{ steps.package_version.outputs.version }}"
echo "=========================================="
echo "Version Validation"
echo "=========================================="
echo "Git tag version: v$TAG_VERSION"
echo "package.json version: $PACKAGE_VERSION"
echo "=========================================="
if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then
echo ""
echo "❌ ERROR: Version mismatch detected!"
echo ""
echo "The version in package.json ($PACKAGE_VERSION) does not match"
echo "the git tag version ($TAG_VERSION)."
echo ""
echo "To fix this:"
echo " 1. Delete this tag: git tag -d v$TAG_VERSION"
echo " 2. Update package.json version to $TAG_VERSION"
echo " 3. Commit the change"
echo " 4. Recreate the tag: git tag -a v$TAG_VERSION -m 'Release v$TAG_VERSION'"
echo ""
echo "Or use the automated script:"
echo " node scripts/bump-version.js $TAG_VERSION"
echo ""
exit 1
fi
echo ""
echo "✅ SUCCESS: Versions match!"
echo ""
- name: Version validation result
if: success()
run: |
echo "::notice::Version validation passed - package.json version matches tag v${{ steps.tag_version.outputs.version }}"
-33
View File
@@ -1,33 +0,0 @@
name: Welcome
on:
pull_request_target:
types: [opened]
issues:
types: [opened]
jobs:
welcome:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/first-interaction@v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
issue-message: |
👋 Thanks for opening your first issue!
A maintainer will triage this soon. In the meantime:
- Make sure you've provided all the requested info
- Join our [Discord](https://discord.gg/QhRnz9m5HE) for faster help
pr-message: |
🎉 Thanks for your first PR!
A maintainer will review it soon. Please make sure:
- Your branch is synced with `develop`
- CI checks pass
- You've followed our [contribution guide](https://github.com/AndyMik90/Auto-Claude/blob/develop/CONTRIBUTING.md)
Welcome to the Auto Claude community!
+13 -110
View File
@@ -4,11 +4,9 @@
![Auto Claude Kanban Board](.github/assets/Auto-Claude-Kanban.png)
<!-- TOP_VERSION_BADGE -->
[![Version](https://img.shields.io/badge/version-2.7.2-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.2)
<!-- TOP_VERSION_BADGE_END -->
[![License](https://img.shields.io/badge/license-AGPL--3.0-green?style=flat-square)](./agpl-3.0.txt)
[![Discord](https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/KCXaPBr4Dj)
[![YouTube](https://img.shields.io/badge/YouTube-Subscribe-FF0000?style=flat-square&logo=youtube&logoColor=white)](https://www.youtube.com/@AndreMikalsen)
[![CI](https://img.shields.io/github/actions/workflow/status/AndyMik90/Auto-Claude/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/AndyMik90/Auto-Claude/actions)
---
@@ -59,7 +57,6 @@
- **Claude Pro/Max subscription** - [Get one here](https://claude.ai/upgrade)
- **Claude Code CLI** - `npm install -g @anthropic-ai/claude-code`
- **Git repository** - Your project must be initialized as a git repo
- **Python 3.12+** - Required for the backend and Memory Layer
---
@@ -148,113 +145,11 @@ See [guides/CLI-USAGE.md](guides/CLI-USAGE.md) for complete CLI documentation.
---
## Configuration
## Development
Create `apps/backend/.env` from the example:
Want to build from source or contribute? See [CONTRIBUTING.md](CONTRIBUTING.md) for complete development setup instructions.
```bash
cp apps/backend/.env.example apps/backend/.env
```
| Variable | Required | Description |
|----------|----------|-------------|
| `CLAUDE_CODE_OAUTH_TOKEN` | Yes | OAuth token from `claude setup-token` |
| `GRAPHITI_ENABLED` | No | Enable Memory Layer for cross-session context |
| `AUTO_BUILD_MODEL` | No | Override the default Claude model |
| `GITLAB_TOKEN` | No | GitLab Personal Access Token for GitLab integration |
| `GITLAB_INSTANCE_URL` | No | GitLab instance URL (defaults to gitlab.com) |
| `LINEAR_API_KEY` | No | Linear API key for task sync |
---
## Building from Source
For contributors and development:
```bash
# Clone the repository
git clone https://github.com/AndyMik90/Auto-Claude.git
cd Auto-Claude
# Install all dependencies
npm run install:all
# Run in development mode
npm run dev
# Or build and run
npm start
```
**System requirements for building:**
- Node.js 24+
- Python 3.12+
- npm 10+
**Installing dependencies by platform:**
<details>
<summary><b>Windows</b></summary>
```bash
winget install Python.Python.3.12
winget install OpenJS.NodeJS.LTS
```
</details>
<details>
<summary><b>macOS</b></summary>
```bash
brew install python@3.12 node@24
```
</details>
<details>
<summary><b>Linux (Ubuntu/Debian)</b></summary>
```bash
sudo apt install python3.12 python3.12-venv
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
sudo apt install -y nodejs
```
</details>
<details>
<summary><b>Linux (Fedora)</b></summary>
```bash
sudo dnf install python3.12 nodejs npm
```
</details>
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed development setup.
### Building Flatpak
To build the Flatpak package, you need additional dependencies:
```bash
# Fedora/RHEL
sudo dnf install flatpak-builder
# Ubuntu/Debian
sudo apt install flatpak-builder
# Install required Flatpak runtimes
flatpak install flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
flatpak install flathub org.electronjs.Electron2.BaseApp//25.08
# Build the Flatpak
cd apps/frontend
npm run package:flatpak
```
The Flatpak will be created in `apps/frontend/dist/`.
For Linux-specific builds (Flatpak, AppImage), see [guides/linux.md](guides/linux.md).
---
@@ -284,7 +179,7 @@ All releases are:
| `npm run package:mac` | Package for macOS |
| `npm run package:win` | Package for Windows |
| `npm run package:linux` | Package for Linux |
| `npm run package:flatpak` | Package as Flatpak |
| `npm run package:flatpak` | Package as Flatpak (see [guides/linux.md](guides/linux.md)) |
| `npm run lint` | Run linter |
| `npm test` | Run frontend tests |
| `npm run test:backend` | Run backend tests |
@@ -316,3 +211,11 @@ We welcome contributions! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for:
Auto Claude is free to use. If you modify and distribute it, or run it as a service, your code must also be open source under AGPL-3.0.
Commercial licensing available for closed-source use cases.
---
## Star History
[![GitHub Repo stars](https://img.shields.io/github/stars/AndyMik90/Auto-Claude?style=social)](https://github.com/AndyMik90/Auto-Claude/stargazers)
[![Star History Chart](https://api.star-history.com/svg?repos=AndyMik90/Auto-Claude&type=Date)](https://star-history.com/#AndyMik90/Auto-Claude&Date)
+2 -2
View File
@@ -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,
)
```
+7 -3
View File
@@ -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]
+2 -2
View File
@@ -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
+2 -2
View File
@@ -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
+113 -4
View File
@@ -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 {
+87 -18
View File
@@ -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)
+50
View File
@@ -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
+2 -2
View File
@@ -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"
)
+29 -6
View File
@@ -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
+2 -2
View File
@@ -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",
]
+91
View File
@@ -36,6 +36,8 @@ SDK_ENV_VARS = [
"DISABLE_TELEMETRY",
"DISABLE_COST_WARNINGS",
"API_TIMEOUT_MS",
# Windows-specific: Git Bash path for Claude Code CLI
"CLAUDE_CODE_GIT_BASH_PATH",
]
@@ -215,6 +217,85 @@ def require_auth_token() -> str:
return token
def _find_git_bash_path() -> str | None:
"""
Find git-bash (bash.exe) path on Windows.
Uses 'where git' to find git.exe, then derives bash.exe location from it.
Git for Windows installs bash.exe in the 'bin' directory alongside git.exe
or in the parent 'bin' directory when git.exe is in 'cmd'.
Returns:
Full path to bash.exe if found, None otherwise
"""
if platform.system() != "Windows":
return None
# If already set in environment, use that
existing = os.environ.get("CLAUDE_CODE_GIT_BASH_PATH")
if existing and os.path.exists(existing):
return existing
git_path = None
# Method 1: Use 'where' command to find git.exe
try:
# Use where.exe explicitly for reliability
result = subprocess.run(
["where.exe", "git"],
capture_output=True,
text=True,
timeout=5,
shell=False,
)
if result.returncode == 0 and result.stdout.strip():
git_paths = result.stdout.strip().splitlines()
if git_paths:
git_path = git_paths[0].strip()
except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
# Intentionally suppress errors - best-effort detection with fallback to common paths
pass
# Method 2: Check common installation paths if 'where' didn't work
if not git_path:
common_git_paths = [
os.path.expandvars(r"%PROGRAMFILES%\Git\cmd\git.exe"),
os.path.expandvars(r"%PROGRAMFILES%\Git\bin\git.exe"),
os.path.expandvars(r"%PROGRAMFILES(X86)%\Git\cmd\git.exe"),
os.path.expandvars(r"%LOCALAPPDATA%\Programs\Git\cmd\git.exe"),
]
for path in common_git_paths:
if os.path.exists(path):
git_path = path
break
if not git_path:
return None
# Derive bash.exe location from git.exe location
# Git for Windows structure:
# C:\...\Git\cmd\git.exe -> bash.exe is at C:\...\Git\bin\bash.exe
# C:\...\Git\bin\git.exe -> bash.exe is at C:\...\Git\bin\bash.exe
# C:\...\Git\mingw64\bin\git.exe -> bash.exe is at C:\...\Git\bin\bash.exe
git_dir = os.path.dirname(git_path)
git_parent = os.path.dirname(git_dir)
git_grandparent = os.path.dirname(git_parent)
# Check common bash.exe locations relative to git installation
possible_bash_paths = [
os.path.join(git_parent, "bin", "bash.exe"), # cmd -> bin
os.path.join(git_dir, "bash.exe"), # If git.exe is in bin
os.path.join(git_grandparent, "bin", "bash.exe"), # mingw64/bin -> bin
]
for bash_path in possible_bash_paths:
if os.path.exists(bash_path):
return bash_path
return None
def get_sdk_env_vars() -> dict[str, str]:
"""
Get environment variables to pass to SDK.
@@ -222,6 +303,8 @@ def get_sdk_env_vars() -> dict[str, str]:
Collects relevant env vars (ANTHROPIC_BASE_URL, etc.) that should
be passed through to the claude-agent-sdk subprocess.
On Windows, auto-detects CLAUDE_CODE_GIT_BASH_PATH if not already set.
Returns:
Dict of env var name -> value for non-empty vars
"""
@@ -230,6 +313,14 @@ def get_sdk_env_vars() -> dict[str, str]:
value = os.environ.get(var)
if value:
env[var] = value
# On Windows, auto-detect git-bash path if not already set
# Claude Code CLI requires bash.exe to run on Windows
if platform.system() == "Windows" and "CLAUDE_CODE_GIT_BASH_PATH" not in env:
bash_path = _find_git_bash_path()
if bash_path:
env["CLAUDE_CODE_GIT_BASH_PATH"] = bash_path
return env
+7
View File
@@ -16,6 +16,7 @@ import copy
import json
import logging
import os
import platform
import threading
import time
from pathlib import Path
@@ -488,6 +489,12 @@ def create_client(
# Collect env vars to pass to SDK (ANTHROPIC_BASE_URL, etc.)
sdk_env = get_sdk_env_vars()
# Debug: Log git-bash path detection on Windows
if "CLAUDE_CODE_GIT_BASH_PATH" in sdk_env:
logger.info(f"Git Bash path found: {sdk_env['CLAUDE_CODE_GIT_BASH_PATH']}")
elif platform.system() == "Windows":
logger.warning("Git Bash path not detected on Windows!")
# Check if Linear integration is enabled
linear_enabled = is_linear_enabled()
linear_api_key = os.environ.get("LINEAR_API_KEY", "")
+5 -1
View File
@@ -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
+9
View File
@@ -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():
+56 -12
View File
@@ -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:
@@ -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,
+40 -7
View File
@@ -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)
+17 -2
View File
@@ -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.
@@ -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
+11 -1
View File
@@ -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
+3 -3
View File
@@ -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:
+147
View File
@@ -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
+187 -32
View File
@@ -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)
+133 -7
View File
@@ -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, []
+4
View File
@@ -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
+71 -12
View File
@@ -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
@@ -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
+4
View File
@@ -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",
+204 -2
View File
@@ -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] <key> [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 <command>
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
+7 -1
View File
@@ -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",
+28
View File
@@ -19,6 +19,34 @@
# Shows detailed information about app update checks and downloads
# DEBUG_UPDATER=true
# ============================================
# SENTRY ERROR REPORTING
# ============================================
# Sentry DSN for anonymous error reporting
# If not set, error reporting is completely disabled (safe for forks)
#
# For official builds: Set in CI/CD secrets
# For local testing: Uncomment and add your DSN
#
# SENTRY_DSN=https://your-dsn@sentry.io/project-id
# Force enable Sentry in development mode (normally disabled in dev)
# Only works when SENTRY_DSN is also set
# SENTRY_DEV=true
# Trace sample rate for performance monitoring (0.0 to 1.0)
# Controls what percentage of transactions are sampled
# Default: 0.1 (10%) in production, 0 in development
# Set to 0 to disable performance monitoring entirely
# SENTRY_TRACES_SAMPLE_RATE=0.1
# Profile sample rate for profiling (0.0 to 1.0)
# Controls what percentage of sampled transactions include profiling data
# Default: 0.1 (10%) in production, 0 in development
# Set to 0 to disable profiling entirely
# SENTRY_PROFILES_SAMPLE_RATE=0.1
# ============================================
# HOW TO USE
# ============================================
+5 -1
View File
@@ -69,6 +69,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-tooltip": "^1.2.8",
"@sentry/electron": "^7.5.0",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-virtual": "^3.13.13",
"@xterm/addon-fit": "^0.11.0",
@@ -79,10 +80,12 @@
"chokidar": "^5.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dotenv": "^16.6.1",
"electron-log": "^5.4.3",
"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",
@@ -106,6 +109,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",
@@ -210,7 +214,7 @@
]
},
"linux": {
"icon": "resources/icon.png",
"icon": "resources/icons",
"target": [
"AppImage",
"deb",
Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 921 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

+33 -5
View File
@@ -93,12 +93,40 @@ function runElectronRebuild() {
* Check if node-pty is already built
*/
function isNodePtyBuilt() {
const buildDir = path.join(__dirname, '..', 'node_modules', 'node-pty', 'build', 'Release');
if (!fs.existsSync(buildDir)) return false;
// Check traditional node-pty build location (local node_modules)
const localBuildDir = path.join(__dirname, '..', 'node_modules', 'node-pty', 'build', 'Release');
if (fs.existsSync(localBuildDir)) {
const files = fs.readdirSync(localBuildDir);
if (files.some((f) => f.endsWith('.node'))) return true;
}
// Check for the main .node file
const files = fs.readdirSync(buildDir);
return files.some((f) => f.endsWith('.node'));
// Check root node_modules (for npm workspaces)
const rootBuildDir = path.join(__dirname, '..', '..', '..', 'node_modules', 'node-pty', 'build', 'Release');
if (fs.existsSync(rootBuildDir)) {
const files = fs.readdirSync(rootBuildDir);
if (files.some((f) => f.endsWith('.node'))) return true;
}
// Check for @lydell/node-pty with platform-specific prebuilts
const arch = os.arch();
const platform = os.platform();
const platformPkg = `@lydell/node-pty-${platform}-${arch}`;
// Check local node_modules
const localLydellDir = path.join(__dirname, '..', 'node_modules', platformPkg);
if (fs.existsSync(localLydellDir)) {
const files = fs.readdirSync(localLydellDir);
if (files.some((f) => f.endsWith('.node'))) return true;
}
// Check root node_modules (for npm workspaces)
const rootLydellDir = path.join(__dirname, '..', '..', '..', 'node_modules', platformPkg);
if (fs.existsSync(rootLydellDir)) {
const files = fs.readdirSync(rootLydellDir);
if (files.some((f) => f.endsWith('.node'))) return true;
}
return false;
}
/**
+11 -2
View File
@@ -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
@@ -125,6 +126,13 @@ export const nativeTheme = {
on: vi.fn()
};
// Mock screen
export const screen = {
getPrimaryDisplay: vi.fn(() => ({
workAreaSize: { width: 1920, height: 1080 }
}))
};
export default {
app,
ipcMain,
@@ -133,5 +141,6 @@ export default {
dialog,
contextBridge,
shell,
nativeTheme
nativeTheme,
screen
};
@@ -0,0 +1 @@
export * from './sentry-electron-shared';
@@ -0,0 +1 @@
export * from './sentry-electron-shared';
@@ -0,0 +1,26 @@
export type SentryErrorEvent = Record<string, unknown>;
export type SentryScope = {
setContext: (key: string, value: Record<string, unknown>) => void;
};
export type SentryInitOptions = {
beforeSend?: (event: SentryErrorEvent) => SentryErrorEvent | null;
tracesSampleRate?: number;
profilesSampleRate?: number;
dsn?: string;
environment?: string;
release?: string;
debug?: boolean;
enabled?: boolean;
};
export function init(_options: SentryInitOptions): void {}
export function captureException(_error: Error): void {}
export function withScope(callback: (scope: SentryScope) => void): void {
callback({
setContext: () => {}
});
}
@@ -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
+8
View File
@@ -28,6 +28,14 @@ Object.defineProperty(global, 'localStorage', {
value: localStorageMock
});
// Mock scrollIntoView for Radix Select in jsdom
if (typeof HTMLElement !== 'undefined' && !HTMLElement.prototype.scrollIntoView) {
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
value: vi.fn(),
writable: true
});
}
// Test data directory for isolated file operations
export const TEST_DATA_DIR = '/tmp/auto-claude-ui-tests';
@@ -0,0 +1,126 @@
import path from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mockGetToolPath = vi.fn<() => string>();
const mockGetAugmentedEnv = vi.fn<() => Record<string, string>>();
vi.mock('../cli-tool-manager', () => ({
getToolPath: mockGetToolPath,
}));
vi.mock('../env-utils', () => ({
getAugmentedEnv: mockGetAugmentedEnv,
}));
describe('claude-cli-utils', () => {
beforeEach(() => {
mockGetToolPath.mockReset();
mockGetAugmentedEnv.mockReset();
vi.resetModules();
});
it('prepends the CLI directory to PATH when the command is absolute', async () => {
const command = process.platform === 'win32'
? 'C:\\Tools\\claude\\claude.exe'
: '/opt/claude/bin/claude';
const env = {
PATH: process.platform === 'win32'
? 'C:\\Windows\\System32'
: '/usr/bin',
HOME: '/tmp',
};
mockGetToolPath.mockReturnValue(command);
mockGetAugmentedEnv.mockReturnValue(env);
const { getClaudeCliInvocation } = await import('../claude-cli-utils');
const result = getClaudeCliInvocation();
const separator = process.platform === 'win32' ? ';' : ':';
expect(result.command).toBe(command);
expect(result.env.PATH.split(separator)[0]).toBe(path.dirname(command));
expect(result.env.HOME).toBe(env.HOME);
});
it('sets PATH to the command directory when PATH is empty', async () => {
const command = process.platform === 'win32'
? 'C:\\Tools\\claude\\claude.exe'
: '/opt/claude/bin/claude';
const env = { PATH: '' };
mockGetToolPath.mockReturnValue(command);
mockGetAugmentedEnv.mockReturnValue(env);
const { getClaudeCliInvocation } = await import('../claude-cli-utils');
const result = getClaudeCliInvocation();
expect(result.env.PATH).toBe(path.dirname(command));
});
it('sets PATH to the command directory when PATH is missing', async () => {
const command = process.platform === 'win32'
? 'C:\\Tools\\claude\\claude.exe'
: '/opt/claude/bin/claude';
const env = {};
mockGetToolPath.mockReturnValue(command);
mockGetAugmentedEnv.mockReturnValue(env);
const { getClaudeCliInvocation } = await import('../claude-cli-utils');
const result = getClaudeCliInvocation();
expect(result.env.PATH).toBe(path.dirname(command));
});
it('keeps PATH unchanged when the command is not absolute', async () => {
const env = {
PATH: process.platform === 'win32'
? 'C:\\Windows;C:\\Windows\\System32'
: '/usr/bin:/bin',
};
mockGetToolPath.mockReturnValue('claude');
mockGetAugmentedEnv.mockReturnValue(env);
const { getClaudeCliInvocation } = await import('../claude-cli-utils');
const result = getClaudeCliInvocation();
expect(result.command).toBe('claude');
expect(result.env.PATH).toBe(env.PATH);
});
it('does not duplicate the command directory in PATH', async () => {
const command = process.platform === 'win32'
? 'C:\\Tools\\claude\\claude.exe'
: '/opt/claude/bin/claude';
const commandDir = path.dirname(command);
const separator = process.platform === 'win32' ? ';' : ':';
const env = { PATH: `${commandDir}${separator}/usr/bin` };
mockGetToolPath.mockReturnValue(command);
mockGetAugmentedEnv.mockReturnValue(env);
const { getClaudeCliInvocation } = await import('../claude-cli-utils');
const result = getClaudeCliInvocation();
expect(result.env.PATH).toBe(env.PATH);
});
it('treats PATH entries case-insensitively on Windows', async () => {
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', { value: 'win32' });
try {
const command = 'C:\\Tools\\claude\\claude.exe';
const env = { PATH: 'c:\\tools\\claude;C:\\Windows' };
mockGetToolPath.mockReturnValue(command);
mockGetAugmentedEnv.mockReturnValue(env);
const { getClaudeCliInvocation } = await import('../claude-cli-utils');
const result = getClaudeCliInvocation();
expect(result.env.PATH).toBe(env.PATH);
} finally {
if (originalPlatform) {
Object.defineProperty(process, 'platform', originalPlatform);
}
}
});
});
@@ -0,0 +1,314 @@
/**
* Unit tests for cli-tool-manager
* Tests CLI tool detection with focus on NVM path detection
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { existsSync, readdirSync } from 'fs';
import os from 'os';
import { execFileSync } from 'child_process';
import { app } from 'electron';
import { getToolInfo, clearToolCache } from '../cli-tool-manager';
// Mock Electron app
vi.mock('electron', () => ({
app: {
isPackaged: false,
getPath: vi.fn()
}
}));
// Mock os module
vi.mock('os', () => ({
default: {
homedir: vi.fn(() => '/mock/home')
}
}));
// Mock fs module - need to mock both sync and promises
vi.mock('fs', () => {
const mockDirent = (
name: string,
isDir: boolean
): { name: string; isDirectory: () => boolean } => ({
name,
isDirectory: () => isDir
});
return {
existsSync: vi.fn(),
readdirSync: vi.fn(),
promises: {}
};
});
// Mock child_process for execFileSync (used in validation)
vi.mock('child_process', () => ({
execFileSync: vi.fn()
}));
// Mock env-utils to avoid PATH augmentation complexity
vi.mock('../env-utils', () => ({
findExecutable: vi.fn(() => null), // Return null to force platform-specific path checking
getAugmentedEnv: vi.fn(() => ({ PATH: '' }))
}));
// Mock homebrew-python utility
vi.mock('../utils/homebrew-python', () => ({
findHomebrewPython: vi.fn(() => null)
}));
describe('cli-tool-manager - Claude CLI NVM detection', () => {
beforeEach(() => {
vi.clearAllMocks();
// Set default platform to Linux
Object.defineProperty(process, 'platform', {
value: 'linux',
writable: true
});
});
afterEach(() => {
clearToolCache();
});
const mockHomeDir = '/mock/home';
describe('NVM path detection on Unix/Linux/macOS', () => {
it('should detect Claude CLI in NVM directory when multiple Node versions exist', () => {
// Mock home directory
vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
// Mock NVM directory exists
vi.mocked(existsSync).mockImplementation((filePath) => {
const pathStr = String(filePath);
// NVM versions directory exists
if (pathStr.includes('.nvm/versions/node')) {
return true;
}
// Claude CLI exists in v22.17.0
if (pathStr.includes('v22.17.0/bin/claude')) {
return true;
}
return false;
});
// Mock readdirSync to return Node version directories
vi.mocked(readdirSync).mockImplementation((filePath, options) => {
const pathStr = String(filePath);
if (pathStr.includes('.nvm/versions/node')) {
return [
{ name: 'v20.11.0', isDirectory: () => true },
{ name: 'v22.17.0', isDirectory: () => true }
] as any;
}
return [] as any;
});
// Mock execFileSync to return version for validation
vi.mocked(execFileSync).mockReturnValue('claude-code version 1.0.0\n');
const result = getToolInfo('claude');
expect(result.found).toBe(true);
expect(result.path).toContain('v22.17.0');
expect(result.path).toContain('bin/claude');
expect(result.source).toBe('nvm');
});
it('should try multiple NVM Node versions until finding Claude CLI', () => {
vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
vi.mocked(existsSync).mockImplementation((filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('.nvm/versions/node')) {
return true;
}
// Only v24.12.0 has Claude CLI
if (pathStr.includes('v24.12.0/bin/claude')) {
return true;
}
return false;
});
vi.mocked(readdirSync).mockImplementation((filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('.nvm/versions/node')) {
return [
{ name: 'v18.20.0', isDirectory: () => true },
{ name: 'v20.11.0', isDirectory: () => true },
{ name: 'v24.12.0', isDirectory: () => true }
] as any;
}
return [] as any;
});
vi.mocked(execFileSync).mockReturnValue('claude-code version 1.0.0\n');
const result = getToolInfo('claude');
expect(result.found).toBe(true);
expect(result.path).toContain('v24.12.0');
expect(result.source).toBe('nvm');
});
it('should skip non-version directories in NVM (e.g., does not start with "v")', () => {
vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
vi.mocked(existsSync).mockImplementation((filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('.nvm/versions/node')) {
return true;
}
// Only the correctly named version has Claude
if (pathStr.includes('v22.17.0/bin/claude')) {
return true;
}
return false;
});
vi.mocked(readdirSync).mockImplementation((filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('.nvm/versions/node')) {
return [
{ name: 'current', isDirectory: () => true }, // Should be skipped
{ name: 'system', isDirectory: () => true }, // Should be skipped
{ name: 'v22.17.0', isDirectory: () => true } // Should be checked
] as any;
}
return [] as any;
});
vi.mocked(execFileSync).mockReturnValue('claude-code version 1.0.0\n');
const result = getToolInfo('claude');
expect(result.found).toBe(true);
expect(result.path).toContain('v22.17.0');
});
it('should not check NVM paths on Windows', () => {
Object.defineProperty(process, 'platform', {
value: 'win32',
writable: true
});
vi.mocked(os.homedir).mockReturnValue('C:\\Users\\test');
// Even if NVM directory exists on Windows, should not check it
vi.mocked(existsSync).mockReturnValue(false);
vi.mocked(readdirSync).mockReturnValue([]);
const result = getToolInfo('claude');
// Should not be found from NVM on Windows
expect(result.source).not.toBe('nvm');
});
it('should handle missing NVM directory gracefully', () => {
vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
// NVM directory does not exist
vi.mocked(existsSync).mockReturnValue(false);
const result = getToolInfo('claude');
// Should not find via NVM
expect(result.source).not.toBe('nvm');
expect(result.found).toBe(false);
});
it('should handle readdirSync errors gracefully', () => {
vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readdirSync).mockImplementation(() => {
throw new Error('Permission denied');
});
const result = getToolInfo('claude');
// Should not crash, should fall back to other detection methods
expect(result.source).not.toBe('nvm');
});
it('should validate Claude CLI before returning NVM path', () => {
vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
vi.mocked(existsSync).mockImplementation((filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('.nvm/versions/node')) {
return true;
}
if (pathStr.includes('v22.17.0/bin/claude')) {
return true;
}
return false;
});
vi.mocked(readdirSync).mockImplementation(() => {
return [{ name: 'v22.17.0', isDirectory: () => true }] as any;
});
// Mock validation failure (execFileSync throws)
vi.mocked(execFileSync).mockImplementation(() => {
throw new Error('Command failed');
});
const result = getToolInfo('claude');
// Should not return unvalidated path
expect(result.found).toBe(false);
expect(result.source).not.toBe('nvm');
});
it('should handle NVM directory with no version subdirectories', () => {
vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
vi.mocked(existsSync).mockImplementation((filePath) => {
return String(filePath).includes('.nvm/versions/node');
});
// Empty NVM directory
vi.mocked(readdirSync).mockReturnValue([]);
const result = getToolInfo('claude');
expect(result.source).not.toBe('nvm');
});
});
describe('NVM on macOS', () => {
it('should detect Claude CLI via NVM on macOS', () => {
Object.defineProperty(process, 'platform', {
value: 'darwin',
writable: true
});
vi.mocked(os.homedir).mockReturnValue('/Users/test');
vi.mocked(existsSync).mockImplementation((filePath) => {
const pathStr = String(filePath);
if (pathStr.includes('.nvm/versions/node')) {
return true;
}
if (pathStr.includes('v22.17.0/bin/claude')) {
return true;
}
return false;
});
vi.mocked(readdirSync).mockImplementation(() => {
return [{ name: 'v22.17.0', isDirectory: () => true }] as any;
});
vi.mocked(execFileSync).mockReturnValue('claude-code version 1.0.0\n');
const result = getToolInfo('claude');
expect(result.found).toBe(true);
expect(result.source).toBe('nvm');
expect(result.path).toContain('v22.17.0');
});
});
});
@@ -0,0 +1,219 @@
import { EventEmitter } from 'events';
import path from 'path';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { IPC_CHANNELS } from '../../shared/constants';
const {
mockGetClaudeCliInvocation,
mockGetProject,
spawnMock,
mockIpcMain,
} = vi.hoisted(() => {
const ipcMain = new (class {
handlers = new Map<string, Function>();
handle(channel: string, handler: Function): void {
this.handlers.set(channel, handler);
}
getHandler(channel: string): Function | undefined {
return this.handlers.get(channel);
}
})();
return {
mockGetClaudeCliInvocation: vi.fn(),
mockGetProject: vi.fn(),
spawnMock: vi.fn(),
mockIpcMain: ipcMain,
};
});
vi.mock('../claude-cli-utils', () => ({
getClaudeCliInvocation: mockGetClaudeCliInvocation,
}));
vi.mock('../project-store', () => ({
projectStore: {
getProject: mockGetProject,
},
}));
vi.mock('child_process', () => ({
spawn: spawnMock,
}));
vi.mock('electron', () => ({
app: {
getPath: vi.fn((name: string) => {
if (name === 'userData') return path.join('/tmp', 'userData');
return '/tmp';
}),
},
ipcMain: mockIpcMain,
}));
import { registerEnvHandlers } from '../ipc-handlers/env-handlers';
function createProc(): EventEmitter & { stdout?: EventEmitter; stderr?: EventEmitter } {
const proc = new EventEmitter() as EventEmitter & {
stdout?: EventEmitter;
stderr?: EventEmitter;
};
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
return proc;
}
describe('env-handlers Claude CLI usage', () => {
beforeEach(() => {
mockGetClaudeCliInvocation.mockReset();
mockGetProject.mockReset();
spawnMock.mockReset();
});
it('uses resolved Claude CLI path/env for auth checks', async () => {
const claudeEnv = { PATH: '/opt/claude/bin:/usr/bin' };
const command = '/opt/claude/bin/claude';
mockGetClaudeCliInvocation.mockReturnValue({
command,
env: claudeEnv,
});
mockGetProject.mockReturnValue({ id: 'p1', path: '/tmp/project' });
const procs: ReturnType<typeof createProc>[] = [];
spawnMock.mockImplementation(() => {
const proc = createProc();
procs.push(proc);
return proc;
});
registerEnvHandlers(() => null);
const handler = mockIpcMain.getHandler(IPC_CHANNELS.ENV_CHECK_CLAUDE_AUTH);
if (!handler) {
throw new Error('ENV_CHECK_CLAUDE_AUTH handler not registered');
}
const resultPromise = handler({}, 'p1');
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock).toHaveBeenCalledWith(
command,
['--version'],
expect.objectContaining({ cwd: '/tmp/project', env: claudeEnv, shell: false })
);
procs[0].emit('close', 0);
await Promise.resolve();
expect(spawnMock).toHaveBeenCalledTimes(2);
expect(spawnMock).toHaveBeenCalledWith(
command,
['api', '--help'],
expect.objectContaining({ cwd: '/tmp/project', env: claudeEnv, shell: false })
);
procs[1].emit('close', 0);
const result = await resultPromise;
expect(result).toEqual({ success: true, data: { success: true, authenticated: true } });
});
it('uses resolved Claude CLI path/env for setup-token', async () => {
const claudeEnv = { PATH: '/opt/claude/bin:/usr/bin' };
const command = '/opt/claude/bin/claude';
mockGetClaudeCliInvocation.mockReturnValue({
command,
env: claudeEnv,
});
mockGetProject.mockReturnValue({ id: 'p2', path: '/tmp/project' });
const proc = createProc();
spawnMock.mockReturnValue(proc);
registerEnvHandlers(() => null);
const handler = mockIpcMain.getHandler(IPC_CHANNELS.ENV_INVOKE_CLAUDE_SETUP);
if (!handler) {
throw new Error('ENV_INVOKE_CLAUDE_SETUP handler not registered');
}
const resultPromise = handler({}, 'p2');
expect(spawnMock).toHaveBeenCalledWith(
command,
['setup-token'],
expect.objectContaining({
cwd: '/tmp/project',
env: claudeEnv,
shell: false,
stdio: 'inherit'
})
);
proc.emit('close', 0);
const result = await resultPromise;
expect(result).toEqual({ success: true, data: { success: true, authenticated: true } });
});
it('returns an error when Claude CLI resolution throws', async () => {
mockGetClaudeCliInvocation.mockImplementation(() => {
throw new Error('Claude CLI exploded');
});
mockGetProject.mockReturnValue({ id: 'p3', path: '/tmp/project' });
registerEnvHandlers(() => null);
const handler = mockIpcMain.getHandler(IPC_CHANNELS.ENV_CHECK_CLAUDE_AUTH);
if (!handler) {
throw new Error('ENV_CHECK_CLAUDE_AUTH handler not registered');
}
const result = await handler({}, 'p3');
expect(result.success).toBe(false);
expect(result.error).toContain('Claude CLI exploded');
expect(spawnMock).not.toHaveBeenCalled();
});
it('returns an error when Claude CLI command is missing', async () => {
mockGetClaudeCliInvocation.mockReturnValue({ command: '', env: {} });
mockGetProject.mockReturnValue({ id: 'p4', path: '/tmp/project' });
registerEnvHandlers(() => null);
const handler = mockIpcMain.getHandler(IPC_CHANNELS.ENV_CHECK_CLAUDE_AUTH);
if (!handler) {
throw new Error('ENV_CHECK_CLAUDE_AUTH handler not registered');
}
const result = await handler({}, 'p4');
expect(result.success).toBe(false);
expect(result.error).toContain('Claude CLI path not resolved');
expect(spawnMock).not.toHaveBeenCalled();
});
it('returns an error when Claude CLI exits with a non-zero code', async () => {
const claudeEnv = { PATH: '/opt/claude/bin:/usr/bin' };
const command = '/opt/claude/bin/claude';
mockGetClaudeCliInvocation.mockReturnValue({
command,
env: claudeEnv,
});
mockGetProject.mockReturnValue({ id: 'p5', path: '/tmp/project' });
const proc = createProc();
spawnMock.mockReturnValue(proc);
registerEnvHandlers(() => null);
const handler = mockIpcMain.getHandler(IPC_CHANNELS.ENV_CHECK_CLAUDE_AUTH);
if (!handler) {
throw new Error('ENV_CHECK_CLAUDE_AUTH handler not registered');
}
const resultPromise = handler({}, 'p5');
expect(spawnMock).toHaveBeenCalledWith(
command,
['--version'],
expect.objectContaining({ cwd: '/tmp/project', env: claudeEnv, shell: false })
);
proc.emit('close', 1);
const result = await resultPromise;
expect(result.success).toBe(false);
expect(result.error).toContain('Claude CLI not found');
});
});
@@ -0,0 +1,99 @@
/**
* @vitest-environment node
*/
import path from 'path';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { InsightsConfig } from '../insights/config';
vi.mock('electron', () => ({
app: {
getAppPath: () => '/app',
getPath: () => '/tmp',
isPackaged: false
}
}));
vi.mock('../rate-limit-detector', () => ({
getProfileEnv: () => ({ CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token' })
}));
const mockGetApiProfileEnv = vi.fn();
vi.mock('../services/profile', () => ({
getAPIProfileEnv: (...args: unknown[]) => mockGetApiProfileEnv(...args)
}));
const mockGetPythonEnv = vi.fn();
vi.mock('../python-env-manager', () => ({
pythonEnvManager: {
getPythonEnv: () => mockGetPythonEnv()
}
}));
describe('InsightsConfig', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
process.env = { ...originalEnv, TEST_ENV: 'ok' };
mockGetApiProfileEnv.mockResolvedValue({
ANTHROPIC_BASE_URL: 'https://api.z.ai',
ANTHROPIC_AUTH_TOKEN: 'key'
});
mockGetPythonEnv.mockReturnValue({ PYTHONPATH: '/site-packages' });
});
afterEach(() => {
process.env = { ...originalEnv };
vi.clearAllMocks();
vi.restoreAllMocks();
});
it('should build process env with python and profile settings', async () => {
const config = new InsightsConfig();
vi.spyOn(config, 'loadAutoBuildEnv').mockReturnValue({ CUSTOM_ENV: '1' });
vi.spyOn(config, 'getAutoBuildSourcePath').mockReturnValue('/backend');
const env = await config.getProcessEnv();
expect(env.TEST_ENV).toBe('ok');
expect(env.CUSTOM_ENV).toBe('1');
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe('oauth-token');
expect(env.ANTHROPIC_BASE_URL).toBe('https://api.z.ai');
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('key');
expect(env.PYTHONPATH).toBe(['/site-packages', '/backend'].join(path.delimiter));
});
it('should clear ANTHROPIC env vars in OAuth mode when no API profile is set', async () => {
const config = new InsightsConfig();
mockGetApiProfileEnv.mockResolvedValue({});
process.env = {
...originalEnv,
ANTHROPIC_AUTH_TOKEN: 'stale-token',
ANTHROPIC_BASE_URL: 'https://stale.example'
};
const env = await config.getProcessEnv();
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('');
expect(env.ANTHROPIC_BASE_URL).toBe('');
});
it('should set PYTHONPATH only to auto-build path when python env has none', async () => {
const config = new InsightsConfig();
mockGetPythonEnv.mockReturnValue({});
vi.spyOn(config, 'getAutoBuildSourcePath').mockReturnValue('/backend');
const env = await config.getProcessEnv();
expect(env.PYTHONPATH).toBe('/backend');
});
it('should keep PYTHONPATH from python env when auto-build path is missing', async () => {
const config = new InsightsConfig();
mockGetPythonEnv.mockReturnValue({ PYTHONPATH: '/site-packages' });
vi.spyOn(config, 'getAutoBuildSourcePath').mockReturnValue(null);
const env = await config.getProcessEnv();
expect(env.PYTHONPATH).toBe('/site-packages');
});
});
@@ -17,6 +17,62 @@ import { readSettingsFile } from '../settings-utils';
import type { AppSettings } from '../../shared/types/settings';
import { getOAuthModeClearVars } from './env-utils';
import { getAugmentedEnv } from '../env-utils';
import { getToolInfo } from '../cli-tool-manager';
function deriveGitBashPath(gitExePath: string): string | null {
if (process.platform !== 'win32') {
return null;
}
try {
const gitDir = path.dirname(gitExePath); // e.g., D:\...\Git\mingw64\bin
const gitDirName = path.basename(gitDir).toLowerCase();
// Find Git installation root
let gitRoot: string;
if (gitDirName === 'cmd') {
// .../Git/cmd/git.exe -> .../Git
gitRoot = path.dirname(gitDir);
} else if (gitDirName === 'bin') {
// Could be .../Git/bin/git.exe OR .../Git/mingw64/bin/git.exe
const parent = path.dirname(gitDir);
const parentName = path.basename(parent).toLowerCase();
if (parentName === 'mingw64' || parentName === 'mingw32') {
// .../Git/mingw64/bin/git.exe -> .../Git
gitRoot = path.dirname(parent);
} else {
// .../Git/bin/git.exe -> .../Git
gitRoot = parent;
}
} else {
// Unknown structure - try to find 'bin' sibling
gitRoot = path.dirname(gitDir);
}
// Bash.exe is in Git/bin/bash.exe
const bashPath = path.join(gitRoot, 'bin', 'bash.exe');
if (existsSync(bashPath)) {
console.log('[AgentProcess] Derived git-bash path:', bashPath);
return bashPath;
}
// Fallback: check one level up if gitRoot didn't work
const altBashPath = path.join(path.dirname(gitRoot), 'bin', 'bash.exe');
if (existsSync(altBashPath)) {
console.log('[AgentProcess] Found git-bash at alternate path:', altBashPath);
return altBashPath;
}
console.warn('[AgentProcess] Could not find bash.exe from git path:', gitExePath);
return null;
} catch (error) {
console.error('[AgentProcess] Error deriving git-bash path:', error);
return null;
}
}
/**
* Process spawning and lifecycle management
@@ -59,8 +115,28 @@ export class AgentProcessManager {
// Use getAugmentedEnv() to ensure common tool paths (dotnet, homebrew, etc.)
// are available even when app is launched from Finder/Dock
const augmentedEnv = getAugmentedEnv();
// On Windows, detect and pass git-bash path for Claude Code CLI
// Electron can detect git via where.exe, but Python subprocess may not have the same PATH
const gitBashEnv: Record<string, string> = {};
if (process.platform === 'win32' && !process.env.CLAUDE_CODE_GIT_BASH_PATH) {
try {
const gitInfo = getToolInfo('git');
if (gitInfo.found && gitInfo.path) {
const bashPath = deriveGitBashPath(gitInfo.path);
if (bashPath) {
gitBashEnv['CLAUDE_CODE_GIT_BASH_PATH'] = bashPath;
console.log('[AgentProcess] Setting CLAUDE_CODE_GIT_BASH_PATH:', bashPath);
}
}
} catch (error) {
console.warn('[AgentProcess] Failed to detect git-bash path:', error);
}
}
return {
...augmentedEnv,
...gitBashEnv,
...extraEnv,
...profileEnv,
PYTHONUNBUFFERED: '1',
+4 -5
View File
@@ -7,7 +7,6 @@ import { AgentEvents } from './agent-events';
import { AgentProcessManager } from './agent-process';
import { RoadmapConfig } from './types';
import type { IdeationConfig, Idea } from '../../shared/types';
import { MODEL_ID_MAP } from '../../shared/constants';
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from '../rate-limit-detector';
import { getAPIProfileEnv } from '../services/profile';
import { getOAuthModeClearVars } from './env-utils';
@@ -96,9 +95,9 @@ export class AgentQueueManager {
}
// Add model and thinking level from config
// Pass shorthand (opus/sonnet/haiku) - backend resolves using API profile env vars
if (config?.model) {
const modelId = MODEL_ID_MAP[config.model] || MODEL_ID_MAP['opus'];
args.push('--model', modelId);
args.push('--model', config.model);
}
if (config?.thinkingLevel) {
args.push('--thinking-level', config.thinkingLevel);
@@ -172,9 +171,9 @@ export class AgentQueueManager {
}
// Add model and thinking level from config
// Pass shorthand (opus/sonnet/haiku) - backend resolves using API profile env vars
if (config.model) {
const modelId = MODEL_ID_MAP[config.model] || MODEL_ID_MAP['opus'];
args.push('--model', modelId);
args.push('--model', config.model);
}
if (config.thinkingLevel) {
args.push('--thinking-level', config.thinkingLevel);
@@ -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: '',
@@ -28,7 +28,12 @@ export function getOAuthModeClearVars(apiProfileEnv: Record<string, string>): 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: '',
+216 -1
View File
@@ -18,12 +18,16 @@
*/
import { autoUpdater } from 'electron-updater';
import { app } from 'electron';
import { app, net } from 'electron';
import type { BrowserWindow } from 'electron';
import { IPC_CHANNELS } from '../shared/constants';
import type { AppUpdateInfo } from '../shared/types';
import { compareVersions } from './updater/version-manager';
// GitHub repo info for API calls
const GITHUB_OWNER = 'AndyMik90';
const GITHUB_REPO = 'Auto-Claude';
// Debug mode - DEBUG_UPDATER=true or development mode
const DEBUG_UPDATER = process.env.DEBUG_UPDATER === 'true' || process.env.NODE_ENV === 'development';
@@ -251,3 +255,214 @@ export function quitAndInstall(): void {
export function getCurrentVersion(): string {
return autoUpdater.currentVersion.version;
}
/**
* Check if a version string represents a prerelease (beta, alpha, rc, etc.)
*/
export function isPrerelease(version: string): boolean {
return /-(alpha|beta|rc|dev|canary)\.\d+$/i.test(version) || version.includes('-');
}
// Timeout for GitHub API requests (10 seconds)
const GITHUB_API_TIMEOUT = 10000;
/**
* Fetch the latest stable release from GitHub API
* Returns the latest non-prerelease version
*/
async function fetchLatestStableRelease(): Promise<AppUpdateInfo | null> {
const fetchPromise = new Promise<AppUpdateInfo | null>((resolve) => {
const url = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases`;
console.warn('[app-updater] Fetching releases from:', url);
const request = net.request({
url,
method: 'GET'
});
request.setHeader('Accept', 'application/vnd.github.v3+json');
request.setHeader('User-Agent', `Auto-Claude/${getCurrentVersion()}`);
let data = '';
request.on('response', (response) => {
// Validate HTTP status code
const statusCode = response.statusCode;
if (statusCode !== 200) {
// 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) {
console.error('[app-updater] Repository or releases not found');
}
resolve(null);
return;
}
response.on('data', (chunk) => {
data += chunk.toString();
});
response.on('end', () => {
try {
const parsed = JSON.parse(data);
// Validate response is an array
if (!Array.isArray(parsed)) {
console.error('[app-updater] Unexpected response format - expected array, got:', typeof parsed);
resolve(null);
return;
}
const releases = parsed as Array<{
tag_name: string;
prerelease: boolean;
draft: boolean;
body?: string;
published_at?: string;
html_url?: string;
}>;
// Find the first non-prerelease, non-draft release
const latestStable = releases.find(r => !r.prerelease && !r.draft);
if (!latestStable) {
console.warn('[app-updater] No stable release found');
resolve(null);
return;
}
const version = latestStable.tag_name.replace(/^v/, '');
// 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,
releaseNotes: latestStable.body,
releaseDate: latestStable.published_at
});
} catch (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) => {
// 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);
});
request.end();
});
// Add timeout to prevent hanging indefinitely
const timeoutPromise = new Promise<AppUpdateInfo | null>((resolve) => {
setTimeout(() => {
console.error(`[app-updater] GitHub API request timed out after ${GITHUB_API_TIMEOUT}ms`);
resolve(null);
}, GITHUB_API_TIMEOUT);
});
return Promise.race([fetchPromise, timeoutPromise]);
}
/**
* Check if we should offer a downgrade to stable
* Called when user disables beta updates while on a prerelease version
*
* Returns the latest stable version if:
* 1. Current version is a prerelease
* 2. A stable version exists
*/
export async function checkForStableDowngrade(): Promise<AppUpdateInfo | null> {
const currentVersion = getCurrentVersion();
// Only check for downgrade if currently on a prerelease
if (!isPrerelease(currentVersion)) {
console.warn('[app-updater] Current version is not a prerelease, no downgrade needed');
return null;
}
console.warn('[app-updater] Current version is prerelease:', currentVersion);
console.warn('[app-updater] Checking for stable version to downgrade to...');
const latestStable = await fetchLatestStableRelease();
if (!latestStable) {
console.warn('[app-updater] No stable release available for downgrade');
return null;
}
console.warn('[app-updater] Stable downgrade available:', latestStable.version);
return latestStable;
}
/**
* Set update channel with optional downgrade check
* When switching from beta to stable, checks if user should be offered a downgrade
*
* @param channel - The update channel to switch to
* @param triggerDowngradeCheck - Whether to check for stable downgrade (when disabling beta)
*/
export async function setUpdateChannelWithDowngradeCheck(
channel: UpdateChannel,
triggerDowngradeCheck = false
): Promise<AppUpdateInfo | null> {
autoUpdater.channel = channel;
console.warn(`[app-updater] Update channel set to: ${channel}`);
// If switching to stable and downgrade check requested, look for stable version
if (channel === 'latest' && triggerDowngradeCheck) {
const stableVersion = await checkForStableDowngrade();
if (stableVersion && mainWindow) {
// Notify the renderer about the available stable downgrade
mainWindow.webContents.send(IPC_CHANNELS.APP_UPDATE_STABLE_DOWNGRADE, stableVersion);
}
return stableVersion;
}
return null;
}
/**
* Download a specific version (for downgrade)
* Uses electron-updater with allowDowngrade enabled to download older stable versions
*/
export async function downloadStableVersion(): Promise<void> {
// Switch to stable channel
autoUpdater.channel = 'latest';
// Enable downgrade to allow downloading older versions (e.g., stable when on beta)
autoUpdater.allowDowngrade = true;
console.warn('[app-updater] Downloading stable version (allowDowngrade=true)...');
try {
// Force a fresh check on the stable channel, then download
const result = await autoUpdater.checkForUpdates();
if (result) {
await autoUpdater.downloadUpdate();
} else {
throw new Error('No stable version available for download');
}
} catch (error) {
console.error('[app-updater] Failed to download stable version:', error);
throw error;
} finally {
// Reset allowDowngrade to prevent unintended downgrades in normal update checks
autoUpdater.allowDowngrade = false;
}
}
@@ -1,48 +0,0 @@
/**
* Auto Claude Source Updater
*
* Checks GitHub Releases for updates and downloads them.
* GitHub Releases are the single source of truth for versioning.
*
* Update flow:
* 1. Check GitHub Releases API for the latest release
* 2. Compare release tag with current app version
* 3. If update available, download release tarball and apply
* 4. Existing project update system handles pushing to individual projects
*
* Versioning:
* - Single source of truth: GitHub Releases
* - Current version: app.getVersion() (from package.json at build time)
* - Latest version: Fetched from GitHub Releases API
* - To release: Create a GitHub release with tag (e.g., v1.2.0)
*/
// Export types
export type {
GitHubRelease,
AutoBuildUpdateCheck,
AutoBuildUpdateResult,
UpdateProgressCallback,
UpdateMetadata
} from './updater/types';
// Export version management
export { getBundledVersion, getEffectiveVersion } from './updater/version-manager';
// Export path resolution
export {
getBundledSourcePath,
getEffectiveSourcePath
} from './updater/path-resolver';
// Export update checking
export { checkForUpdates } from './updater/update-checker';
// Export update installation
export { downloadAndApplyUpdate } from './updater/update-installer';
// Export update status
export {
hasPendingSourceUpdate,
getUpdateMetadata
} from './updater/update-status';
@@ -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';
@@ -0,0 +1,49 @@
import path from 'path';
import { getAugmentedEnv } from './env-utils';
import { getToolPath } from './cli-tool-manager';
export type ClaudeCliInvocation = {
command: string;
env: Record<string, string>;
};
function ensureCommandDirInPath(command: string, env: Record<string, string>): Record<string, string> {
if (!path.isAbsolute(command)) {
return env;
}
const pathSeparator = process.platform === 'win32' ? ';' : ':';
const commandDir = path.dirname(command);
const currentPath = env.PATH || '';
const pathEntries = currentPath.split(pathSeparator);
const normalizedCommandDir = path.normalize(commandDir);
const hasCommandDir = process.platform === 'win32'
? pathEntries
.map((entry) => path.normalize(entry).toLowerCase())
.includes(normalizedCommandDir.toLowerCase())
: pathEntries
.map((entry) => path.normalize(entry))
.includes(normalizedCommandDir);
if (hasCommandDir) {
return env;
}
return {
...env,
PATH: [commandDir, currentPath].filter(Boolean).join(pathSeparator),
};
}
/**
* Returns the Claude CLI command path and an environment with PATH updated to include the CLI directory.
*/
export function getClaudeCliInvocation(): ClaudeCliInvocation {
const command = getToolPath('claude');
const env = getAugmentedEnv();
return {
command,
env: ensureCommandDirInPath(command, env),
};
}
+86 -3
View File
@@ -21,13 +21,18 @@
*/
import { execFileSync } from 'child_process';
import { existsSync } from 'fs';
import { existsSync, readdirSync } from 'fs';
import path from 'path';
import os from 'os';
import { app } from 'electron';
import { findExecutable } from './env-utils';
import { findExecutable, getAugmentedEnv } from './env-utils';
import type { ToolDetectionResult } from '../shared/types';
import { findHomebrewPython as findHomebrewPythonUtil } from './utils/homebrew-python';
import {
getWindowsExecutablePaths,
WINDOWS_GIT_PATHS,
findWindowsExecutableViaWhere,
} from './utils/windows-paths';
/**
* Supported CLI tools managed by this system
@@ -392,7 +397,40 @@ class CLIToolManager {
}
}
// 4. Not found - fallback to 'git'
// 4. Windows-specific detection using 'where' command (most reliable for custom installs)
if (process.platform === 'win32') {
// First try 'where' command - finds git regardless of installation location
const whereGitPath = findWindowsExecutableViaWhere('git', '[Git]');
if (whereGitPath) {
const validation = this.validateGit(whereGitPath);
if (validation.valid) {
return {
found: true,
path: whereGitPath,
version: validation.version,
source: 'system-path',
message: `Using Windows Git: ${whereGitPath}`,
};
}
}
// Fallback to checking common installation paths
const windowsPaths = getWindowsExecutablePaths(WINDOWS_GIT_PATHS, '[Git]');
for (const winGitPath of windowsPaths) {
const validation = this.validateGit(winGitPath);
if (validation.valid) {
return {
found: true,
path: winGitPath,
version: validation.version,
source: 'system-path',
message: `Using Windows Git: ${winGitPath}`,
};
}
}
}
// 5. Not found - fallback to 'git'
return {
found: false,
source: 'fallback',
@@ -594,6 +632,50 @@ class CLIToolManager {
path.join(homeDir, 'bin', 'claude'),
];
// 4.5. NVM (Node Version Manager) paths for Unix/Linux/macOS
// NVM installs global npm packages in ~/.nvm/versions/node/vX.X.X/bin/
// This is important when the app launches from GUI without NVM sourced
if (process.platform !== 'win32') {
const nvmVersionsDir = path.join(homeDir, '.nvm', 'versions', 'node');
try {
if (existsSync(nvmVersionsDir)) {
const nodeVersions = readdirSync(nvmVersionsDir, { withFileTypes: true });
const versionDirs = nodeVersions
.filter((entry) => entry.isDirectory() && entry.name.startsWith('v'))
.sort((a, b) => {
const vA = a.name.slice(1).split('.').map(Number);
const vB = b.name.slice(1).split('.').map(Number);
for (let i = 0; i < 3; i++) {
const diff = (vB[i] ?? 0) - (vA[i] ?? 0);
if (diff !== 0) {
return diff;
}
}
return 0;
});
for (const entry of versionDirs) {
const nvmClaudePath = path.join(nvmVersionsDir, entry.name, 'bin', 'claude');
if (existsSync(nvmClaudePath)) {
const validation = this.validateClaude(nvmClaudePath);
if (validation.valid) {
return {
found: true,
path: nvmClaudePath,
version: validation.version,
source: 'nvm',
message: `Using NVM Claude CLI: ${nvmClaudePath}`,
};
}
}
}
}
} catch (error) {
// Silently fail if unable to read NVM directory
console.warn(`[Claude CLI] Unable to read NVM directory: ${error}`);
}
}
for (const claudePath of platformPaths) {
if (existsSync(claudePath)) {
const validation = this.validateClaude(claudePath);
@@ -759,6 +841,7 @@ class CLIToolManager {
timeout: 5000,
windowsHide: true,
shell: needsShell,
env: getAugmentedEnv(),
}).trim();
// Claude CLI version output format: "claude-code version X.Y.Z" or similar
+118 -6
View File
@@ -1,6 +1,28 @@
import { app, BrowserWindow, shell, nativeImage, session } from 'electron';
// Load .env file FIRST before any other imports that might use process.env
import { config } from 'dotenv';
import { resolve, dirname } from 'path';
import { existsSync } from 'fs';
// Load .env from apps/frontend directory
// In development: __dirname is out/main (compiled), so go up 2 levels
// In production: app resources directory
const possibleEnvPaths = [
resolve(__dirname, '../../.env'), // Development: out/main -> apps/frontend/.env
resolve(__dirname, '../../../.env'), // Alternative: might be in different location
resolve(process.cwd(), 'apps/frontend/.env'), // Fallback: from workspace root
];
for (const envPath of possibleEnvPaths) {
if (existsSync(envPath)) {
config({ path: envPath });
console.log(`[dotenv] Loaded environment from: ${envPath}`);
break;
}
}
import { app, BrowserWindow, shell, nativeImage, session, screen } from 'electron';
import { join } from 'path';
import { accessSync, readFileSync, writeFileSync } from 'fs';
import { accessSync, readFileSync, writeFileSync, rmSync } from 'fs';
import { electronApp, optimizer, is } from '@electron-toolkit/utils';
import { setupIpcHandlers } from './ipc-setup';
import { AgentManager } from './agent';
@@ -12,11 +34,32 @@ import { initializeAppUpdater } from './app-updater';
import { DEFAULT_APP_SETTINGS } from '../shared/constants';
import { readSettingsFile } from './settings-utils';
import { setupErrorLogging } from './app-logger';
import { initSentryMain } from './sentry';
import type { AppSettings } from '../shared/types';
// ─────────────────────────────────────────────────────────────────────────────
// Window sizing constants
// ─────────────────────────────────────────────────────────────────────────────
/** Preferred window width on startup */
const WINDOW_PREFERRED_WIDTH: number = 1400;
/** Preferred window height on startup */
const WINDOW_PREFERRED_HEIGHT: number = 900;
/** Absolute minimum window width (supports high DPI displays with scaling) */
const WINDOW_MIN_WIDTH: number = 800;
/** Absolute minimum window height (supports high DPI displays with scaling) */
const WINDOW_MIN_HEIGHT: number = 500;
/** Margin from screen edges to avoid edge-to-edge windows */
const WINDOW_SCREEN_MARGIN: number = 20;
/** Default screen dimensions used as fallback when screen.getPrimaryDisplay() fails */
const DEFAULT_SCREEN_WIDTH: number = 1920;
const DEFAULT_SCREEN_HEIGHT: number = 1080;
// Setup error logging early (captures uncaught exceptions)
setupErrorLogging();
// Initialize Sentry for error tracking (respects user's sentryEnabled setting)
initSentryMain();
/**
* Load app settings synchronously (for use during startup).
* This is a simple merge with defaults - no migrations or auto-detection.
@@ -26,6 +69,32 @@ function loadSettingsSync(): AppSettings {
return { ...DEFAULT_APP_SETTINGS, ...savedSettings } as AppSettings;
}
/**
* Clean up stale update metadata files from the redundant source updater system.
*
* The old "source updater" wrote .update-metadata.json files that could persist
* across app updates and cause version display desync. This cleanup ensures
* we use the actual bundled version from app.getVersion().
*/
function cleanupStaleUpdateMetadata(): void {
const userData = app.getPath('userData');
const stalePaths = [
join(userData, 'auto-claude-source'),
join(userData, 'backend-source'),
];
for (const stalePath of stalePaths) {
if (existsSync(stalePath)) {
try {
rmSync(stalePath, { recursive: true, force: true });
console.warn(`[main] Cleaned up stale update metadata: ${stalePath}`);
} catch (e) {
console.warn(`[main] Failed to clean up stale metadata at ${stalePath}:`, e);
}
}
}
}
// Get icon path based on platform
function getIconPath(): string {
// In dev mode, __dirname is out/main, so we go up to project root then into resources
@@ -54,12 +123,51 @@ let agentManager: AgentManager | null = null;
let terminalManager: TerminalManager | null = null;
function createWindow(): void {
// Get the primary display's work area (accounts for taskbar, dock, etc.)
// Wrapped in try/catch to handle potential failures with fallback to safe defaults
let workAreaSize: { width: number; height: number };
try {
const display = screen.getPrimaryDisplay();
// Validate the returned object has expected structure with valid dimensions
if (
display &&
display.workAreaSize &&
typeof display.workAreaSize.width === 'number' &&
typeof display.workAreaSize.height === 'number' &&
display.workAreaSize.width > 0 &&
display.workAreaSize.height > 0
) {
workAreaSize = display.workAreaSize;
} else {
console.error(
'[main] screen.getPrimaryDisplay() returned unexpected structure:',
JSON.stringify(display)
);
workAreaSize = { width: DEFAULT_SCREEN_WIDTH, height: DEFAULT_SCREEN_HEIGHT };
}
} catch (error: unknown) {
console.error('[main] Failed to get primary display, using fallback dimensions:', error);
workAreaSize = { width: DEFAULT_SCREEN_WIDTH, height: DEFAULT_SCREEN_HEIGHT };
}
// Calculate available space with a small margin to avoid edge-to-edge windows
const availableWidth: number = workAreaSize.width - WINDOW_SCREEN_MARGIN;
const availableHeight: number = workAreaSize.height - WINDOW_SCREEN_MARGIN;
// Calculate actual dimensions (preferred, but capped to margin-adjusted available space)
const width: number = Math.min(WINDOW_PREFERRED_WIDTH, availableWidth);
const height: number = Math.min(WINDOW_PREFERRED_HEIGHT, availableHeight);
// Ensure minimum dimensions don't exceed the actual initial window size
const minWidth: number = Math.min(WINDOW_MIN_WIDTH, width);
const minHeight: number = Math.min(WINDOW_MIN_HEIGHT, height);
// Create the browser window
mainWindow = new BrowserWindow({
width: 1400,
height: 900,
minWidth: 1000,
minHeight: 700,
width,
height,
minWidth,
minHeight,
show: false,
autoHideMenuBar: true,
titleBarStyle: 'hiddenInset',
@@ -129,6 +237,10 @@ app.whenReady().then(() => {
.catch((err) => console.warn('[main] Failed to clear cache:', err));
}
// Clean up stale update metadata from the old source updater system
// This prevents version display desync after electron-updater installs a new version
cleanupStaleUpdateMetadata();
// Set dock icon on macOS
if (process.platform === 'darwin') {
const iconPath = getIconPath();
+35 -6
View File
@@ -1,9 +1,10 @@
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';
import { pythonEnvManager, getConfiguredPythonPath } from '../python-env-manager';
import { getValidatedPythonPath } from '../python-detector';
import { getConfiguredPythonPath, pythonEnvManager } from '../python-env-manager';
import { getAugmentedEnv } from '../env-utils';
import { getEffectiveSourcePath } from '../updater/path-resolver';
@@ -105,23 +106,51 @@ export class InsightsConfig {
* Get complete environment for process execution
* Includes system env, auto-claude env, and active Claude profile
*/
getProcessEnv(): Record<string, string> {
async getProcessEnv(): Promise<Record<string, string>> {
const autoBuildEnv = this.loadAutoBuildEnv();
const profileEnv = getProfileEnv();
// Get Python environment (PYTHONPATH for bundled packages like python-dotenv)
const apiProfileEnv = await getAPIProfileEnv();
const oauthModeClearVars = getOAuthModeClearVars(apiProfileEnv);
const pythonEnv = pythonEnvManager.getPythonEnv();
const autoBuildSource = this.getAutoBuildSourcePath();
const pythonPathParts = (pythonEnv.PYTHONPATH ?? '')
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => path.resolve(entry));
if (autoBuildSource) {
const normalizedAutoBuildSource = path.resolve(autoBuildSource);
const autoBuildComparator = process.platform === 'win32'
? normalizedAutoBuildSource.toLowerCase()
: normalizedAutoBuildSource;
const hasAutoBuildSource = pythonPathParts.some((entry) => {
const candidate = process.platform === 'win32' ? entry.toLowerCase() : entry;
return candidate === autoBuildComparator;
});
if (!hasAutoBuildSource) {
pythonPathParts.push(normalizedAutoBuildSource);
}
}
const combinedPythonPath = pythonPathParts.join(path.delimiter);
// Use getAugmentedEnv() to ensure common tool paths (claude, dotnet, etc.)
// are available even when app is launched from Finder/Dock
// are available even when app is launched from Finder/Dock.
const augmentedEnv = getAugmentedEnv();
return {
...augmentedEnv,
...pythonEnv, // Include PYTHONPATH for bundled site-packages
...autoBuildEnv,
...oauthModeClearVars,
...profileEnv,
...apiProfileEnv,
PYTHONUNBUFFERED: '1',
PYTHONIOENCODING: 'utf-8',
PYTHONUTF8: '1'
PYTHONUTF8: '1',
...(combinedPythonPath ? { PYTHONPATH: combinedPythonPath } : {})
};
}
}
@@ -85,7 +85,7 @@ export class InsightsExecutor extends EventEmitter {
} as InsightsChatStatus);
// Get process environment
const processEnv = this.config.getProcessEnv();
const processEnv = await this.config.getProcessEnv();
// Write conversation history to temp file to avoid Windows command-line length limit
const historyFile = path.join(
@@ -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,
@@ -11,6 +11,7 @@ import type { IPCResult, AppUpdateInfo } from '../../shared/types';
import {
checkForUpdates,
downloadUpdate,
downloadStableVersion,
quitAndInstall,
getCurrentVersion
} from '../app-updater';
@@ -65,6 +66,26 @@ export function registerAppUpdateHandlers(): void {
}
);
/**
* APP_UPDATE_DOWNLOAD_STABLE: Download stable version (for downgrade from beta)
* Uses allowDowngrade to download an older stable version
*/
ipcMain.handle(
IPC_CHANNELS.APP_UPDATE_DOWNLOAD_STABLE,
async (): Promise<IPCResult> => {
try {
await downloadStableVersion();
return { success: true };
} catch (error) {
console.error('[app-update-handlers] Download stable version failed:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to download stable version'
};
}
}
);
/**
* APP_UPDATE_INSTALL: Quit and install update
* Quits the app and installs the downloaded update
@@ -1,321 +0,0 @@
import { ipcMain } from 'electron';
import type { BrowserWindow } from 'electron';
import { IPC_CHANNELS } from '../../shared/constants';
import type { IPCResult } from '../../shared/types';
import path from 'path';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import type { AutoBuildSourceUpdateProgress, SourceEnvConfig, SourceEnvCheckResult } from '../../shared/types';
import { checkForUpdates as checkSourceUpdates, downloadAndApplyUpdate, getBundledVersion, getEffectiveVersion, getEffectiveSourcePath } from '../auto-claude-updater';
import { debugLog } from '../../shared/utils/debug-logger';
/**
* Register all autobuild-source-related IPC handlers
*/
export function registerAutobuildSourceHandlers(
getMainWindow: () => BrowserWindow | null
): void {
// ============================================
// Auto Claude Source Update Operations
// ============================================
ipcMain.handle(
IPC_CHANNELS.AUTOBUILD_SOURCE_CHECK,
async (): Promise<IPCResult<{ updateAvailable: boolean; currentVersion: string; latestVersion?: string; releaseNotes?: string; releaseUrl?: string; error?: string }>> => {
console.log('[autobuild-source] Check for updates called');
debugLog('[IPC] AUTOBUILD_SOURCE_CHECK called');
try {
const result = await checkSourceUpdates();
console.log('[autobuild-source] Check result:', JSON.stringify(result, null, 2));
debugLog('[IPC] AUTOBUILD_SOURCE_CHECK result:', result);
return { success: true, data: result };
} catch (error) {
console.error('[autobuild-source] Check error:', error);
debugLog('[IPC] AUTOBUILD_SOURCE_CHECK error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to check for updates'
};
}
}
);
ipcMain.on(
IPC_CHANNELS.AUTOBUILD_SOURCE_DOWNLOAD,
() => {
debugLog('[IPC] Autobuild source download requested');
const mainWindow = getMainWindow();
if (!mainWindow) {
debugLog('[IPC] No main window available, aborting update');
return;
}
// Start download in background
downloadAndApplyUpdate((progress) => {
debugLog('[IPC] Update progress:', progress.stage, progress.message);
mainWindow.webContents.send(
IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS,
progress
);
}).then((result) => {
if (result.success) {
debugLog('[IPC] Update completed successfully, version:', result.version);
mainWindow.webContents.send(
IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS,
{
stage: 'complete',
message: `Updated to version ${result.version}`,
newVersion: result.version // Include new version for UI refresh
} as AutoBuildSourceUpdateProgress
);
} else {
debugLog('[IPC] Update failed:', result.error);
mainWindow.webContents.send(
IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS,
{
stage: 'error',
message: result.error || 'Update failed'
} as AutoBuildSourceUpdateProgress
);
}
}).catch((error) => {
debugLog('[IPC] Update error:', error instanceof Error ? error.message : error);
mainWindow.webContents.send(
IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS,
{
stage: 'error',
message: error instanceof Error ? error.message : 'Update failed'
} as AutoBuildSourceUpdateProgress
);
});
// Send initial progress
mainWindow.webContents.send(
IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS,
{
stage: 'checking',
message: 'Starting update...'
} as AutoBuildSourceUpdateProgress
);
}
);
ipcMain.handle(
IPC_CHANNELS.AUTOBUILD_SOURCE_VERSION,
async (): Promise<IPCResult<string>> => {
try {
// Use effective version which accounts for source updates
const version = getEffectiveVersion();
debugLog('[IPC] Returning effective version:', version);
return { success: true, data: version };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to get version'
};
}
}
);
// ============================================
// Auto Claude Source Environment Operations
// ============================================
/**
* Parse an .env file content into a key-value object
*/
const parseSourceEnvFile = (content: string): Record<string, string> => {
const vars: Record<string, string> = {};
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const eqIndex = trimmed.indexOf('=');
if (eqIndex > 0) {
const key = trimmed.substring(0, eqIndex).trim();
let value = trimmed.substring(eqIndex + 1).trim();
// Remove quotes if present
if ((value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
vars[key] = value;
}
}
return vars;
};
ipcMain.handle(
IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_GET,
async (): Promise<IPCResult<SourceEnvConfig>> => {
try {
const sourcePath = getEffectiveSourcePath();
if (!sourcePath) {
return {
success: true,
data: {
hasClaudeToken: false,
envExists: false,
sourcePath: undefined
}
};
}
const envPath = path.join(sourcePath, '.env');
const envExists = existsSync(envPath);
if (!envExists) {
return {
success: true,
data: {
hasClaudeToken: false,
envExists: false,
sourcePath
}
};
}
const content = readFileSync(envPath, 'utf-8');
const vars = parseSourceEnvFile(content);
const hasToken = !!vars['CLAUDE_CODE_OAUTH_TOKEN'];
return {
success: true,
data: {
hasClaudeToken: hasToken,
claudeOAuthToken: hasToken ? vars['CLAUDE_CODE_OAUTH_TOKEN'] : undefined,
envExists: true,
sourcePath
}
};
} catch (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<IPCResult> => {
try {
const sourcePath = getEffectiveSourcePath();
if (!sourcePath) {
return {
success: false,
error: 'Auto-Claude source path not found. Please configure it in App Settings.'
};
}
const envPath = path.join(sourcePath, '.env');
// Read existing content or start fresh
let existingContent = '';
const existingVars: Record<string, string> = {};
if (existsSync(envPath)) {
existingContent = readFileSync(envPath, 'utf-8');
Object.assign(existingVars, parseSourceEnvFile(existingContent));
}
// Update the token
if (config.claudeOAuthToken !== undefined) {
existingVars['CLAUDE_CODE_OAUTH_TOKEN'] = config.claudeOAuthToken;
}
// Rebuild the .env file preserving comments and structure
const lines = existingContent.split('\n');
const processedKeys = new Set<string>();
const outputLines: string[] = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) {
outputLines.push(line);
continue;
}
const eqIndex = trimmed.indexOf('=');
if (eqIndex > 0) {
const key = trimmed.substring(0, eqIndex).trim();
if (key in existingVars) {
outputLines.push(`${key}=${existingVars[key]}`);
processedKeys.add(key);
} else {
outputLines.push(line);
}
} else {
outputLines.push(line);
}
}
// Add any new keys that weren't in the original file
for (const [key, value] of Object.entries(existingVars)) {
if (!processedKeys.has(key)) {
outputLines.push(`${key}=${value}`);
}
}
writeFileSync(envPath, outputLines.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<IPCResult<SourceEnvCheckResult>> => {
try {
const sourcePath = getEffectiveSourcePath();
if (!sourcePath) {
return {
success: true,
data: {
hasToken: false,
sourcePath: undefined,
error: 'Auto-Claude source path not found'
}
};
}
const envPath = path.join(sourcePath, '.env');
if (!existsSync(envPath)) {
return {
success: true,
data: {
hasToken: false,
sourcePath,
error: '.env file does not exist'
}
};
}
const content = readFileSync(envPath, 'utf-8');
const vars = parseSourceEnvFile(content);
const hasToken = !!vars['CLAUDE_CODE_OAUTH_TOKEN'] && vars['CLAUDE_CODE_OAUTH_TOKEN'].length > 0;
return {
success: true,
data: {
hasToken,
sourcePath
}
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to check source token'
};
}
}
);
}
@@ -8,6 +8,8 @@ import { existsSync, readFileSync, writeFileSync } from 'fs';
import { spawn } from 'child_process';
import { projectStore } from '../project-store';
import { parseEnvFile } from './utils';
import { getClaudeCliInvocation } from '../claude-cli-utils';
import { debugError } from '../../shared/utils/debug-logger';
// GitLab environment variable keys
const GITLAB_ENV_KEYS = {
@@ -25,6 +27,25 @@ function envLine(vars: Record<string, string>, key: string, defaultVal: string =
return vars[key] ? `${key}=${vars[key]}` : `# ${key}=${defaultVal}`;
}
type ResolvedClaudeCliInvocation =
| { command: string; env: Record<string, string> }
| { error: string };
function resolveClaudeCliInvocation(): ResolvedClaudeCliInvocation {
try {
const invocation = getClaudeCliInvocation();
if (!invocation?.command) {
throw new Error('Claude CLI path not resolved');
}
return { command: invocation.command, env: invocation.env };
} catch (error) {
debugError('[IPC] Failed to resolve Claude CLI path:', error);
return {
error: error instanceof Error ? error.message : 'Failed to resolve Claude CLI path',
};
}
}
/**
* Register all env-related IPC handlers
@@ -552,13 +573,20 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
return { success: false, error: 'Project not found' };
}
const resolved = resolveClaudeCliInvocation();
if ('error' in resolved) {
return { success: false, error: resolved.error };
}
const claudeCmd = resolved.command;
const claudeEnv = resolved.env;
try {
// Check if Claude CLI is available and authenticated
const result = await new Promise<ClaudeAuthResult>((resolve) => {
const proc = spawn('claude', ['--version'], {
const proc = spawn(claudeCmd, ['--version'], {
cwd: project.path,
env: { ...process.env },
shell: true
env: claudeEnv,
shell: false
});
let _stdout = '';
@@ -576,10 +604,10 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
if (code === 0) {
// Claude CLI is available, check if authenticated
// Run a simple command that requires auth
const authCheck = spawn('claude', ['api', '--help'], {
const authCheck = spawn(claudeCmd, ['api', '--help'], {
cwd: project.path,
env: { ...process.env },
shell: true
env: claudeEnv,
shell: false
});
authCheck.on('close', (authCode: number | null) => {
@@ -614,6 +642,9 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
});
});
if (!result.success) {
return { success: false, error: result.error || 'Failed to check Claude auth' };
}
return { success: true, data: result };
} catch (error) {
return {
@@ -632,13 +663,20 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
return { success: false, error: 'Project not found' };
}
const resolved = resolveClaudeCliInvocation();
if ('error' in resolved) {
return { success: false, error: resolved.error };
}
const claudeCmd = resolved.command;
const claudeEnv = resolved.env;
try {
// Run claude setup-token which will open browser for OAuth
const result = await new Promise<ClaudeAuthResult>((resolve) => {
const proc = spawn('claude', ['setup-token'], {
const proc = spawn(claudeCmd, ['setup-token'], {
cwd: project.path,
env: { ...process.env },
shell: true,
env: claudeEnv,
shell: false,
stdio: 'inherit' // This allows the terminal to handle the interactive auth
});
@@ -666,6 +704,9 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
});
});
if (!result.success) {
return { success: false, error: result.error || 'Failed to invoke Claude setup' };
}
return { success: true, data: result };
} catch (error) {
return {
@@ -0,0 +1,260 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import type { Project } from '../../../../shared/types';
import { IPC_CHANNELS } from '../../../../shared/constants';
import type { BrowserWindow } from 'electron';
import type { AgentManager } from '../../../agent/agent-manager';
import type { createIPCCommunicators as createIPCCommunicatorsType } from '../utils/ipc-communicator';
const mockIpcMain = vi.hoisted(() => {
class HoistedMockIpcMain {
handlers = new Map<string, Function>();
listeners = new Map<string, Function>();
handle(channel: string, handler: Function): void {
this.handlers.set(channel, handler);
}
on(channel: string, listener: Function): void {
this.listeners.set(channel, listener);
}
async invokeHandler(channel: string, ...args: unknown[]): Promise<unknown> {
const handler = this.handlers.get(channel);
if (!handler) {
throw new Error(`No handler for channel: ${channel}`);
}
return handler({}, ...args);
}
async emit(channel: string, ...args: unknown[]): Promise<void> {
const listener = this.listeners.get(channel);
if (!listener) {
throw new Error(`No listener for channel: ${channel}`);
}
await listener({}, ...args);
}
reset(): void {
this.handlers.clear();
this.listeners.clear();
}
}
return new HoistedMockIpcMain();
});
const mockRunPythonSubprocess = vi.fn();
const mockValidateGitHubModule = vi.fn();
const mockGetRunnerEnv = vi.fn();
type CreateIPCCommunicators = typeof createIPCCommunicatorsType;
const mockCreateIPCCommunicators = vi.fn(
(..._args: Parameters<CreateIPCCommunicators>) => ({
sendProgress: vi.fn(),
sendComplete: vi.fn(),
sendError: vi.fn(),
})
) as unknown as CreateIPCCommunicators;
const projectRef: { current: Project | null } = { current: null };
const tempDirs: string[] = [];
vi.mock('electron', () => ({
ipcMain: mockIpcMain,
BrowserWindow: class {},
app: {
getPath: vi.fn(() => '/tmp'),
on: vi.fn(),
},
}));
vi.mock('../../../agent/agent-manager', () => ({
AgentManager: class {
startSpecCreation = vi.fn();
},
}));
vi.mock('../utils/ipc-communicator', () => ({
createIPCCommunicators: (...args: Parameters<CreateIPCCommunicators>) =>
mockCreateIPCCommunicators(...args),
}));
vi.mock('../utils/project-middleware', () => ({
withProjectOrNull: async (_projectId: string, handler: (project: Project) => Promise<unknown>) => {
if (!projectRef.current) {
return null;
}
return handler(projectRef.current);
},
}));
vi.mock('../utils/subprocess-runner', () => ({
runPythonSubprocess: (...args: unknown[]) => mockRunPythonSubprocess(...args),
validateGitHubModule: (...args: unknown[]) => mockValidateGitHubModule(...args),
getPythonPath: () => '/tmp/python',
getRunnerPath: () => '/tmp/runner.py',
buildRunnerArgs: (_runnerPath: string, _projectPath: string, command: string, args: string[] = []) => [
'runner.py',
command,
...args,
],
}));
vi.mock('../utils/runner-env', () => ({
getRunnerEnv: (...args: unknown[]) => mockGetRunnerEnv(...args),
}));
vi.mock('../utils', () => ({
getGitHubConfig: vi.fn(() => null),
githubFetch: vi.fn(),
}));
vi.mock('../../../settings-utils', () => ({
readSettingsFile: vi.fn(() => ({})),
}));
function createMockWindow(): BrowserWindow {
return { webContents: { send: vi.fn() } } as unknown as BrowserWindow;
}
function createProject(): Project {
const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'github-env-test-'));
tempDirs.push(projectPath);
return {
id: 'project-1',
name: 'Test Project',
path: projectPath,
autoBuildPath: '.auto-claude',
settings: {
model: 'default',
memoryBackend: 'file',
linearSync: false,
notifications: {
onTaskComplete: false,
onTaskFailed: false,
onReviewNeeded: false,
sound: false,
},
graphitiMcpEnabled: false,
useClaudeMd: true,
},
createdAt: new Date(),
updatedAt: new Date(),
};
}
describe('GitHub runner env usage', () => {
beforeEach(() => {
vi.clearAllMocks();
mockIpcMain.reset();
projectRef.current = createProject();
mockValidateGitHubModule.mockResolvedValue({ valid: true, backendPath: '/tmp/backend' });
mockGetRunnerEnv.mockResolvedValue({ ANTHROPIC_AUTH_TOKEN: 'token' });
});
afterEach(() => {
for (const dir of tempDirs) {
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch {
// Ignore cleanup errors for already-removed temp dirs.
}
}
tempDirs.length = 0;
});
it('passes runner env to PR review subprocess', async () => {
const { registerPRHandlers } = await import('../pr-handlers');
mockRunPythonSubprocess.mockReturnValue({
process: { pid: 123 },
promise: Promise.resolve({
success: true,
exitCode: 0,
stdout: '',
stderr: '',
data: {
prNumber: 123,
repo: 'test/repo',
success: true,
findings: [],
summary: '',
overallStatus: 'comment',
reviewedAt: new Date().toISOString(),
},
}),
});
registerPRHandlers(() => createMockWindow());
await mockIpcMain.emit(IPC_CHANNELS.GITHUB_PR_REVIEW, projectRef.current?.id, 123);
expect(mockGetRunnerEnv).toHaveBeenCalledWith({ USE_CLAUDE_MD: 'true' });
expect(mockRunPythonSubprocess).toHaveBeenCalledWith(
expect.objectContaining({
env: { ANTHROPIC_AUTH_TOKEN: 'token' },
})
);
});
it('passes runner env to triage subprocess', async () => {
const { registerTriageHandlers } = await import('../triage-handlers');
mockRunPythonSubprocess.mockReturnValue({
process: { pid: 124 },
promise: Promise.resolve({
success: true,
exitCode: 0,
stdout: '',
stderr: '',
data: [],
}),
});
registerTriageHandlers(() => createMockWindow());
await mockIpcMain.emit(IPC_CHANNELS.GITHUB_TRIAGE_RUN, projectRef.current?.id);
expect(mockGetRunnerEnv).toHaveBeenCalledWith();
expect(mockRunPythonSubprocess).toHaveBeenCalledWith(
expect.objectContaining({
env: { ANTHROPIC_AUTH_TOKEN: 'token' },
})
);
});
it('passes runner env to autofix analyze preview subprocess', async () => {
const { registerAutoFixHandlers } = await import('../autofix-handlers');
const { AgentManager: MockedAgentManager } = await import('../../../agent/agent-manager');
mockRunPythonSubprocess.mockReturnValue({
process: { pid: 125 },
promise: Promise.resolve({
success: true,
exitCode: 0,
stdout: '',
stderr: '',
data: {
totalIssues: 0,
primaryIssue: null,
proposedBatches: [],
singleIssues: [],
},
}),
});
const agentManager: AgentManager = new MockedAgentManager();
const getMainWindow: () => BrowserWindow | null = () => createMockWindow();
registerAutoFixHandlers(agentManager, getMainWindow);
await mockIpcMain.emit(IPC_CHANNELS.GITHUB_AUTOFIX_ANALYZE_PREVIEW, projectRef.current?.id);
expect(mockGetRunnerEnv).toHaveBeenCalledWith();
expect(mockRunPythonSubprocess).toHaveBeenCalledWith(
expect.objectContaining({
env: { ANTHROPIC_AUTH_TOKEN: 'token' },
})
);
});
});
@@ -28,6 +28,7 @@ import {
parseJSONFromOutput,
} from './utils/subprocess-runner';
import { AgentManager } from '../../agent/agent-manager';
import { getRunnerEnv } from './utils/runner-env';
// Debug logging
const { debug: debugLog } = createContextLogger('GitHub AutoFix');
@@ -277,11 +278,13 @@ async function checkNewIssues(project: Project): Promise<Array<{number: number}>
const backendPath = validation.backendPath!;
const args = buildRunnerArgs(getRunnerPath(backendPath), project.path, 'check-new');
const subprocessEnv = await getRunnerEnv();
const { promise } = runPythonSubprocess<Array<{number: number}>>({
pythonPath: getPythonPath(backendPath),
args,
cwd: backendPath,
env: subprocessEnv,
onComplete: (stdout) => {
return parseJSONFromOutput<Array<{number: number}>>(stdout);
},
@@ -361,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');
@@ -607,6 +618,7 @@ export function registerAutoFixHandlers(
const backendPath = validation.backendPath!;
const additionalArgs = issueNumbers && issueNumbers.length > 0 ? issueNumbers.map(n => n.toString()) : [];
const args = buildRunnerArgs(getRunnerPath(backendPath), project.path, 'batch-issues', additionalArgs);
const subprocessEnv = await getRunnerEnv();
debugLog('Spawning batch process', { args });
@@ -614,6 +626,7 @@ export function registerAutoFixHandlers(
pythonPath: getPythonPath(backendPath),
args,
cwd: backendPath,
env: subprocessEnv,
onProgress: (percent, message) => {
sendProgress({
phase: 'batching',
@@ -728,12 +741,14 @@ export function registerAutoFixHandlers(
}
const args = buildRunnerArgs(getRunnerPath(backendPath), project.path, 'analyze-preview', additionalArgs);
const subprocessEnv = await getRunnerEnv();
debugLog('Spawning analyze-preview process', { args });
const { promise } = runPythonSubprocess<AnalyzePreviewResult>({
pythonPath: getPythonPath(backendPath),
args,
cwd: backendPath,
env: subprocessEnv,
onProgress: (percent, message) => {
sendProgress({ phase: 'analyzing', progress: percent, message });
},
@@ -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
@@ -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
@@ -16,10 +16,12 @@ 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';
import { createIPCCommunicators } from './utils/ipc-communicator';
import { getRunnerEnv } from './utils/runner-env';
import {
runPythonSubprocess,
getPythonPath,
@@ -70,6 +72,13 @@ function getReviewKey(projectId: string, prNumber: number): string {
return `${projectId}:${prNumber}`;
}
/**
* Returns env vars for Claude.md usage; enabled unless explicitly opted out.
*/
function getClaudeMdEnv(project: Project): Record<string, string> | undefined {
return project.settings?.useClaudeMd !== false ? { USE_CLAUDE_MD: 'true' } : undefined;
}
/**
* PR review finding from AI analysis
*/
@@ -125,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<string, number>;
total_findings?: number;
blockers?: string[];
risk_assessment?: Record<string, string>;
};
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<void> {
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<string, number>);
// 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
*/
@@ -630,10 +792,9 @@ async function runPRReview(
const logCollector = new PRLogCollector(project, prNumber, repo, false);
// Build environment with project settings
const subprocessEnv: Record<string, string> = {};
if (project.settings?.useClaudeMd !== false) {
subprocessEnv['USE_CLAUDE_MD'] = 'true';
}
const subprocessEnv = await getRunnerEnv(
getClaudeMdEnv(project)
);
const { process: childProcess, promise } = runPythonSubprocess<PRReviewResult>({
pythonPath: getPythonPath(backendPath),
@@ -683,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)
@@ -699,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<PRData[]> => {
debugLog('listPRs handler called', { projectId });
async (_, projectId: string, page: number = 1): Promise<PRData[]> => {
debugLog('listPRs handler called', { projectId, page });
const result = await withProjectOrNull(projectId, async (project) => {
const config = getGitHubConfig(project);
if (!config) {
@@ -712,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;
@@ -732,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,
@@ -866,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<Record<number, PRReviewResult | null>> => {
debugLog('getReviewsBatch handler called', { projectId, count: prNumbers.length });
const result = await withProjectOrNull(projectId, async (project) => {
const reviews: Record<number, PRReviewResult | null> = {};
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,
@@ -969,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<boolean> => {
debugLog('postPRReview handler called', { projectId, prNumber, selectedCount: selectedFindingIds?.length });
async (_, projectId: string, prNumber: number, selectedFindingIds?: string[], options?: { forceApprove?: boolean }): Promise<boolean> => {
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) {
@@ -993,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');
}
@@ -1491,10 +1709,9 @@ export function registerPRHandlers(
const logCollector = new PRLogCollector(project, prNumber, repo, true);
// Build environment with project settings
const followupEnv: Record<string, string> = {};
if (project.settings?.useClaudeMd !== false) {
followupEnv['USE_CLAUDE_MD'] = 'true';
}
const followupEnv = await getRunnerEnv(
getClaudeMdEnv(project)
);
const { process: childProcess, promise } = runPythonSubprocess<PRReviewResult>({
pythonPath: getPythonPath(backendPath),
@@ -1543,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',
@@ -1573,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<boolean> => {
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<PRReviewMemory[]> => {
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<PRReviewMemory[]> => {
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');
}
@@ -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<SpecCreationData> {
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'),
@@ -19,6 +19,7 @@ import type { Project, AppSettings } from '../../../shared/types';
import { createContextLogger } from './utils/logger';
import { withProjectOrNull } from './utils/project-middleware';
import { createIPCCommunicators } from './utils/ipc-communicator';
import { getRunnerEnv } from './utils/runner-env';
import {
runPythonSubprocess,
getPythonPath,
@@ -254,10 +255,13 @@ async function runTriage(
debugLog('Spawning triage process', { args, model, thinkingLevel });
const subprocessEnv = await getRunnerEnv();
const { promise } = runPythonSubprocess<TriageResult[]>({
pythonPath: getPythonPath(backendPath),
args,
cwd: backendPath,
env: subprocessEnv,
onProgress: (percent, message) => {
debugLog('Progress update', { percent, message });
sendProgress({
@@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockGetAPIProfileEnv = vi.fn();
const mockGetOAuthModeClearVars = vi.fn();
vi.mock('../../../../services/profile', () => ({
getAPIProfileEnv: (...args: unknown[]) => mockGetAPIProfileEnv(...args),
}));
vi.mock('../../../../agent/env-utils', () => ({
getOAuthModeClearVars: (...args: unknown[]) => mockGetOAuthModeClearVars(...args),
}));
import { getRunnerEnv } from '../runner-env';
describe('getRunnerEnv', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('merges API profile env with OAuth clear vars', async () => {
mockGetAPIProfileEnv.mockResolvedValue({
ANTHROPIC_AUTH_TOKEN: 'token',
ANTHROPIC_BASE_URL: 'https://api.example.com',
});
mockGetOAuthModeClearVars.mockReturnValue({
ANTHROPIC_AUTH_TOKEN: '',
});
const result = await getRunnerEnv();
expect(mockGetOAuthModeClearVars).toHaveBeenCalledWith({
ANTHROPIC_AUTH_TOKEN: 'token',
ANTHROPIC_BASE_URL: 'https://api.example.com',
});
expect(result).toEqual({
ANTHROPIC_AUTH_TOKEN: '',
ANTHROPIC_BASE_URL: 'https://api.example.com',
});
});
it('includes extra env values', async () => {
mockGetAPIProfileEnv.mockResolvedValue({
ANTHROPIC_AUTH_TOKEN: 'token',
});
mockGetOAuthModeClearVars.mockReturnValue({});
const result = await getRunnerEnv({ USE_CLAUDE_MD: 'true' });
expect(result).toEqual({
ANTHROPIC_AUTH_TOKEN: 'token',
USE_CLAUDE_MD: 'true',
});
});
});
@@ -0,0 +1,31 @@
import { getOAuthModeClearVars } from '../../../agent/env-utils';
import { getAPIProfileEnv } from '../../../services/profile';
import { getProfileEnv } from '../../../rate-limit-detector';
/**
* Get environment variables for Python runner subprocesses.
*
* Environment variable precedence (lowest to highest):
* 1. apiProfileEnv - Custom Anthropic-compatible API profile (ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN)
* 2. oauthModeClearVars - Clears stale ANTHROPIC_* vars when in OAuth mode
* 3. profileEnv - Claude OAuth token from profile manager (CLAUDE_CODE_OAUTH_TOKEN)
* 4. extraEnv - Caller-specific vars (e.g., USE_CLAUDE_MD)
*
* The profileEnv is critical for OAuth authentication (#563) - it retrieves the
* decrypted OAuth token from the profile manager's encrypted storage (macOS Keychain
* via Electron's safeStorage API).
*/
export async function getRunnerEnv(
extraEnv?: Record<string, string>
): Promise<Record<string, string>> {
const apiProfileEnv = await getAPIProfileEnv();
const oauthModeClearVars = getOAuthModeClearVars(apiProfileEnv);
const profileEnv = getProfileEnv();
return {
...apiProfileEnv,
...oauthModeClearVars,
...profileEnv, // OAuth token from profile manager (fixes #563)
...extraEnv,
};
}
@@ -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);
@@ -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');
@@ -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<boolean> {
export async function createSpecForIssue(
project: Project,
issue: GitLabAPIIssue,
config: GitLabConfig
config: GitLabConfig,
baseBranch?: string
): Promise<GitLabTaskInfo | null> {
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
@@ -23,7 +23,6 @@ import { registerEnvHandlers } from './env-handlers';
import { registerLinearHandlers } from './linear-handlers';
import { registerGithubHandlers } from './github-handlers';
import { registerGitlabHandlers } from './gitlab-handlers';
import { registerAutobuildSourceHandlers } from './autobuild-source-handlers';
import { registerIdeationHandlers } from './ideation-handlers';
import { registerChangelogHandlers } from './changelog-handlers';
import { registerInsightsHandlers } from './insights-handlers';
@@ -92,9 +91,6 @@ export function setupIpcHandlers(
// GitLab integration handlers
registerGitlabHandlers(agentManager, getMainWindow);
// Auto-build source update handlers
registerAutobuildSourceHandlers(getMainWindow);
// Ideation handlers
registerIdeationHandlers(agentManager, getMainWindow);
@@ -140,7 +136,6 @@ export {
registerLinearHandlers,
registerGithubHandlers,
registerGitlabHandlers,
registerAutobuildSourceHandlers,
registerIdeationHandlers,
registerChangelogHandlers,
registerInsightsHandlers,
@@ -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 [];
}
@@ -304,9 +304,10 @@ ${existingVars['GRAPHITI_DATABASE'] ? `GRAPHITI_DATABASE=${existingVars['GRAPHIT
try {
// Check if Claude CLI is available and authenticated
const result = await new Promise<ClaudeAuthResult>((resolve) => {
const proc = spawn('claude', ['--version'], {
const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation();
const proc = spawn(claudeCmd, ['--version'], {
cwd: project.path,
env: { ...process.env },
env: claudeEnv,
shell: true
});
@@ -325,9 +326,9 @@ ${existingVars['GRAPHITI_DATABASE'] ? `GRAPHITI_DATABASE=${existingVars['GRAPHIT
if (code === 0) {
// Claude CLI is available, check if authenticated
// Run a simple command that requires auth
const authCheck = spawn('claude', ['api', '--help'], {
const authCheck = spawn(claudeCmd, ['api', '--help'], {
cwd: project.path,
env: { ...process.env },
env: claudeEnv,
shell: true
});
@@ -384,9 +385,10 @@ ${existingVars['GRAPHITI_DATABASE'] ? `GRAPHITI_DATABASE=${existingVars['GRAPHIT
try {
// Run claude setup-token which will open browser for OAuth
const result = await new Promise<ClaudeAuthResult>((resolve) => {
const proc = spawn('claude', ['setup-token'], {
const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation();
const proc = spawn(claudeCmd, ['setup-token'], {
cwd: project.path,
env: { ...process.env },
env: claudeEnv,
shell: true,
stdio: 'inherit' // This allows the terminal to handle the interactive auth
});
@@ -1,19 +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 { getEffectiveVersion } from '../auto-claude-updater';
import { setUpdateChannel } from '../app-updater';
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();
@@ -34,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
);
}
@@ -211,8 +216,16 @@ export function registerSettingsHandlers(
// Update auto-updater channel if betaUpdates setting changed
if (settings.betaUpdates !== undefined) {
const channel = settings.betaUpdates ? 'beta' : 'latest';
setUpdateChannel(channel);
if (settings.betaUpdates) {
// Enabling beta updates - just switch channel
setUpdateChannel('beta');
} else {
// Disabling beta updates - switch to stable and check if downgrade is available
// This will notify the renderer if user is on a prerelease and stable version exists
setUpdateChannelWithDowngradeCheck('latest', true).catch((err) => {
console.error('[settings-handlers] Failed to check for stable downgrade:', err);
});
}
}
return { success: true };
@@ -372,8 +385,8 @@ export function registerSettingsHandlers(
// ============================================
ipcMain.handle(IPC_CHANNELS.APP_VERSION, async (): Promise<string> => {
// Use effective version which accounts for source updates
const version = getEffectiveVersion();
// Return the actual bundled version from package.json
const version = app.getVersion();
console.log('[settings-handlers] APP_VERSION returning:', version);
return version;
});
@@ -499,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<IPCResult<SourceEnvConfig>> => {
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<IPCResult> => {
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<string, string> = {};
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<IPCResult<SourceEnvCheckResult>> => {
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'
};
}
}
);
}
@@ -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);
}
@@ -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
}
}
}
@@ -4,9 +4,10 @@ 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 '../../auto-claude-updater';
import { getEffectiveSourcePath } from '../../updater/path-resolver';
import { getProfileEnv } from '../../rate-limit-detector';
import { findTaskAndProject } from './shared';
import { parsePythonCommand } from '../../python-detector';
@@ -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();
@@ -9,6 +9,7 @@ import { projectStore } from '../project-store';
import { terminalNameGenerator } from '../terminal-name-generator';
import { debugLog, debugError } from '../../shared/utils/debug-logger';
import { escapeShellArg, escapeShellArgWindows } from '../../shared/utils/shell-escape';
import { getClaudeCliInvocation } from '../claude-cli-utils';
/**
@@ -76,6 +77,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,
@@ -329,20 +346,30 @@ export function registerTerminalHandlers(
// Build the login command with the profile's config dir
// Use platform-specific syntax and escaping for environment variables
let loginCommand: string;
const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation();
const pathPrefix = claudeEnv.PATH
? (process.platform === 'win32'
? `set "PATH=${escapeShellArgWindows(claudeEnv.PATH)}" && `
: `export PATH=${escapeShellArg(claudeEnv.PATH)} && `)
: '';
const shellClaudeCmd = process.platform === 'win32'
? `"${escapeShellArgWindows(claudeCmd)}"`
: escapeShellArg(claudeCmd);
if (!profile.isDefault && profile.configDir) {
if (process.platform === 'win32') {
// SECURITY: Use Windows-specific escaping for cmd.exe
const escapedConfigDir = escapeShellArgWindows(profile.configDir);
// Windows cmd.exe syntax: set "VAR=value" with %VAR% for expansion
loginCommand = `set "CLAUDE_CONFIG_DIR=${escapedConfigDir}" && echo Config dir: %CLAUDE_CONFIG_DIR% && claude setup-token`;
loginCommand = `${pathPrefix}set "CLAUDE_CONFIG_DIR=${escapedConfigDir}" && echo Config dir: %CLAUDE_CONFIG_DIR% && ${shellClaudeCmd} setup-token`;
} else {
// SECURITY: Use POSIX escaping for bash/zsh
const escapedConfigDir = escapeShellArg(profile.configDir);
// Unix/Mac bash/zsh syntax: export VAR=value with $VAR for expansion
loginCommand = `export CLAUDE_CONFIG_DIR=${escapedConfigDir} && echo "Config dir: $CLAUDE_CONFIG_DIR" && claude setup-token`;
loginCommand = `${pathPrefix}export CLAUDE_CONFIG_DIR=${escapedConfigDir} && echo "Config dir: $CLAUDE_CONFIG_DIR" && ${shellClaudeCmd} setup-token`;
}
} else {
loginCommand = 'claude setup-token';
loginCommand = `${pathPrefix}${shellClaudeCmd} setup-token`;
}
debugLog('[IPC] Sending login command to terminal:', loginCommand);

Some files were not shown because too many files have changed in this diff Show More