Compare commits

..

1 Commits

Author SHA1 Message Date
Alex Madera a17d201041 fix windows issue 2026-01-05 17:30:36 +01:00
256 changed files with 4864 additions and 13357 deletions
+30 -693
View File
@@ -1,386 +1,49 @@
# ╔═══════════════════════════════════════════════════════════════════════════════╗
# ║ ║
# ║ 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.head_ref || github.ref }}
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
# ─────────────────────────────────────────────────────────────────────────────────
# PERMISSIONS: Minimal permissions required for this workflow
# ─────────────────────────────────────────────────────────────────────────────────
permissions:
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
# ═══════════════════════════════════════════════════════════════════════════════════
contents: read
actions: read
jobs:
# ┌─────────────────────────────────────────────────────────────────────────────┐
# │ 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"
# Python tests
test-python:
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: "3.1 Checkout Repository"
- name: Checkout
uses: actions/checkout@v4
- name: "3.2 Setup Python ${{ matrix.python-version }}"
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: "3.3 Setup UV Package Manager"
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: "3.4 Install Dependencies"
- 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: "3.5 Run Test Suite"
- name: Run tests
working-directory: apps/backend
env:
PYTHONPATH: ${{ github.workspace }}/apps/backend
@@ -388,20 +51,16 @@ jobs:
source .venv/bin/activate
pytest ../../tests/ -v --tb=short -x
- name: "3.6 Run Tests with Coverage"
- name: 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: "3.7 Upload Coverage to Codecov"
- name: Upload coverage reports
if: matrix.python-version == '3.12'
uses: codecov/codecov-action@v4
with:
@@ -410,366 +69,44 @@ jobs:
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
# ─────────────────────────────────────────────────────────────────────────────
# FRONTEND TESTS: Lint, typecheck, test, and build
# Triggered: When frontend files change
# ─────────────────────────────────────────────────────────────────────────────
stage-3-test-frontend:
name: "Stage 3: Frontend Tests"
# Frontend lint, typecheck, test, and build
test-frontend:
runs-on: ubuntu-latest
needs: stage-2-changes
if: needs.stage-2-changes.outputs.frontend == 'true'
timeout-minutes: 15
steps:
- name: "3.1 Checkout Repository"
- name: Checkout
uses: actions/checkout@v4
- name: "3.2 Setup Node.js"
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: "3.3 Cache npm Dependencies"
uses: actions/cache@v4
- name: Get npm cache directory
id: npm-cache
run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
with:
path: ~/.npm
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: "3.4 Install Dependencies"
- name: Install dependencies
working-directory: apps/frontend
run: npm ci --ignore-scripts
- name: "3.5 Run ESLint"
- name: Lint
working-directory: apps/frontend
run: npm run lint
- name: "3.6 Run TypeScript Type Check"
- name: Type check
working-directory: apps/frontend
run: npm run typecheck
- name: "3.7 Run Unit Tests"
- name: Run tests
working-directory: apps/frontend
run: npm run test
- name: "3.8 Build Application"
- name: Build
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
@@ -1,121 +0,0 @@
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
@@ -0,0 +1,53 @@
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
@@ -0,0 +1,34 @@
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
@@ -0,0 +1,227 @@
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
@@ -0,0 +1,72 @@
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
@@ -0,0 +1,191 @@
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,70 +34,6 @@ 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
@@ -0,0 +1,178 @@
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
@@ -0,0 +1,25 @@
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
@@ -0,0 +1,63 @@
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
@@ -0,0 +1,71 @@
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
@@ -0,0 +1,33 @@
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!
+110 -13
View File
@@ -4,9 +4,11 @@
![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)
---
@@ -57,6 +59,7 @@
- **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
---
@@ -145,11 +148,113 @@ See [guides/CLI-USAGE.md](guides/CLI-USAGE.md) for complete CLI documentation.
---
## Development
## Configuration
Want to build from source or contribute? See [CONTRIBUTING.md](CONTRIBUTING.md) for complete development setup instructions.
Create `apps/backend/.env` from the example:
For Linux-specific builds (Flatpak, AppImage), see [guides/linux.md](guides/linux.md).
```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/`.
---
@@ -179,7 +284,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 (see [guides/linux.md](guides/linux.md)) |
| `npm run package:flatpak` | Package as Flatpak |
| `npm run lint` | Run linter |
| `npm test` | Run frontend tests |
| `npm run test:backend` | Run backend tests |
@@ -211,11 +316,3 @@ 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_spec_to_source()`
- Workspace sync: `sync_plan_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_spec_to_source,
sync_plan_to_source,
)
```
+3 -7
View File
@@ -14,10 +14,6 @@ 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",
@@ -36,7 +32,7 @@ __all__ = [
"load_implementation_plan",
"find_subtask_in_plan",
"find_phase_for_subtask",
"sync_spec_to_source",
"sync_plan_to_source",
# Constants
"AUTO_CONTINUE_DELAY_SECONDS",
"HUMAN_INTERVENTION_FILE",
@@ -81,7 +77,7 @@ def __getattr__(name):
"get_commit_count",
"get_latest_commit",
"load_implementation_plan",
"sync_spec_to_source",
"sync_plan_to_source",
):
from .utils import (
find_phase_for_subtask,
@@ -89,7 +85,7 @@ def __getattr__(name):
get_commit_count,
get_latest_commit,
load_implementation_plan,
sync_spec_to_source,
sync_plan_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_spec_to_source,
sync_plan_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_spec_to_source(spec_dir, source_spec_dir):
if sync_plan_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_spec_to_source,
sync_plan_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_spec_to_source(spec_dir, source_spec_dir):
if sync_plan_to_source(spec_dir, source_spec_dir):
print_status("Implementation plan synced to main project", "success")
# Check if implementation plan was updated
+4 -113
View File
@@ -4,16 +4,9 @@ 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
@@ -26,79 +19,6 @@ 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:
"""
@@ -125,7 +45,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 (file + Graphiti)."""
"""Record a discovery to the codebase map."""
file_path = args["file_path"]
description = args["description"]
category = args.get("category", "general")
@@ -134,10 +54,8 @@ 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:
@@ -159,23 +77,11 @@ 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}{storage_note}",
"text": f"Recorded discovery for '{file_path}': {description}",
}
]
}
@@ -196,7 +102,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 (file + Graphiti)."""
"""Record a gotcha to session memory."""
gotcha = args["gotcha"]
context = args.get("context", "")
@@ -204,10 +110,8 @@ 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}"
@@ -222,20 +126,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
)
f.write(entry)
# 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}"}
]
}
return {"content": [{"type": "text", "text": f"Recorded gotcha: {gotcha}"}]}
except Exception as e:
return {
+24 -89
View File
@@ -11,38 +11,40 @@ import shutil
import subprocess
from pathlib import Path
from core.git_bash import get_git_executable_path
logger = logging.getLogger(__name__)
def get_latest_commit(project_dir: Path) -> str | None:
"""Get the hash of the latest git commit."""
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "rev-parse", "HEAD"],
[git_path, "rev-parse", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
check=True,
timeout=10,
)
return result.stdout.strip()
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
except subprocess.CalledProcessError:
return None
def get_commit_count(project_dir: Path) -> int:
"""Get the total number of commits."""
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "rev-list", "--count", "HEAD"],
[git_path, "rev-list", "--count", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
check=True,
timeout=10,
)
return int(result.stdout.strip())
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, ValueError):
except (subprocess.CalledProcessError, ValueError):
return 0
@@ -76,32 +78,16 @@ def find_phase_for_subtask(plan: dict, subtask_id: str) -> dict | None:
return None
def sync_spec_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool:
def sync_plan_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool:
"""
Sync ALL spec files from worktree back to source spec directory.
Sync implementation_plan.json from worktree back to source spec directory.
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
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.
Args:
spec_dir: Current spec directory (inside worktree)
spec_dir: Current spec directory (may be inside worktree)
source_spec_dir: Original spec directory in main project (outside worktree)
Returns:
@@ -118,68 +104,17 @@ def sync_spec_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
synced_any = False
# Sync the implementation plan
plan_file = spec_dir / "implementation_plan.json"
if not plan_file.exists():
return False
# Ensure source directory exists
source_spec_dir.mkdir(parents=True, exist_ok=True)
source_plan_file = source_spec_dir / "implementation_plan.json"
try:
# 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
shutil.copy2(plan_file, source_plan_file)
logger.debug(f"Synced implementation plan to source: {source_plan_file}")
return True
except Exception as e:
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)
logger.warning(f"Failed to sync implementation plan to source: {e}")
return False
+8 -3
View File
@@ -18,6 +18,8 @@ import subprocess
from pathlib import Path
from typing import Any
from core.git_bash import get_git_executable_path
logger = logging.getLogger(__name__)
# Check for Claude SDK availability
@@ -86,8 +88,9 @@ def get_session_diff(
return "(No changes - same commit)"
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "diff", commit_before, commit_after],
[git_path, "diff", commit_before, commit_after],
cwd=project_dir,
capture_output=True,
text=True,
@@ -131,8 +134,9 @@ def get_changed_files(
return []
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "diff", "--name-only", commit_before, commit_after],
[git_path, "diff", "--name-only", commit_before, commit_after],
cwd=project_dir,
capture_output=True,
text=True,
@@ -156,8 +160,9 @@ def get_commit_messages(
return "(No commits)"
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "log", "--oneline", f"{commit_before}..{commit_after}"],
[git_path, "log", "--oneline", f"{commit_before}..{commit_after}"],
cwd=project_dir,
capture_output=True,
text=True,
-50
View File
@@ -6,8 +6,6 @@ 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
@@ -214,53 +212,5 @@ 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_spec_to_source
from agent import run_autonomous_agent, sync_plan_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_spec_to_source(spec_dir, source_spec_dir):
if sync_plan_to_source(spec_dir, source_spec_dir):
debug_info(
"run.py", "Implementation plan synced to main project after QA"
)
+24 -40
View File
@@ -9,6 +9,8 @@ import subprocess
import sys
from pathlib import Path
from core.git_bash import get_git_executable_path
# Ensure parent directory is in path for imports (before other imports)
_PARENT_DIR = Path(__file__).parent.parent
if str(_PARENT_DIR) not in sys.path:
@@ -58,16 +60,17 @@ def _detect_default_branch(project_dir: Path) -> str:
"""
import os
git_path = get_git_executable_path()
# 1. Check for DEFAULT_BRANCH env var
env_branch = os.getenv("DEFAULT_BRANCH")
if env_branch:
# Verify the branch exists
result = subprocess.run(
["git", "rev-parse", "--verify", env_branch],
[git_path, "rev-parse", "--verify", env_branch],
cwd=project_dir,
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
return env_branch
@@ -75,11 +78,10 @@ def _detect_default_branch(project_dir: Path) -> str:
# 2. Auto-detect main/master
for branch in ["main", "master"]:
result = subprocess.run(
["git", "rev-parse", "--verify", branch],
[git_path, "rev-parse", "--verify", branch],
cwd=project_dir,
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0:
return branch
@@ -92,32 +94,19 @@ def _get_changed_files_from_git(
worktree_path: Path, base_branch: str = "main"
) -> list[str]:
"""
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.
Get list of changed files from git diff between base branch and HEAD.
Args:
worktree_path: Path to the worktree
base_branch: Base branch to compare against (default: main)
Returns:
List of changed file paths (task changes only)
List of changed file paths
"""
git_path = get_git_executable_path()
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"{merge_base}..HEAD"],
[git_path, "diff", "--name-only", f"{base_branch}...HEAD"],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -129,13 +118,13 @@ def _get_changed_files_from_git(
# Log the failure before trying fallback
debug_warning(
"workspace_commands",
f"git diff with merge-base failed: returncode={e.returncode}, "
f"git diff (three-dot) failed: returncode={e.returncode}, "
f"stderr={e.stderr.strip() if e.stderr else 'N/A'}",
)
# Fallback: try direct two-arg diff (less accurate but works)
# Fallback: try without the three-dot notation
try:
result = subprocess.run(
["git", "diff", "--name-only", base_branch, "HEAD"],
[git_path, "diff", "--name-only", base_branch, "HEAD"],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -147,7 +136,7 @@ def _get_changed_files_from_git(
# Log the failure before returning empty list
debug_warning(
"workspace_commands",
f"git diff (fallback) failed: returncode={e.returncode}, "
f"git diff (two-arg) failed: returncode={e.returncode}, "
f"stderr={e.stderr.strip() if e.stderr else 'N/A'}",
)
return []
@@ -242,8 +231,9 @@ def _generate_and_save_commit_message(project_dir: Path, spec_name: str) -> None
diff_summary = ""
files_changed = []
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "diff", "--staged", "--stat"],
[git_path, "diff", "--staged", "--stat"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -253,7 +243,7 @@ def _generate_and_save_commit_message(project_dir: Path, spec_name: str) -> None
# Get list of changed files
result = subprocess.run(
["git", "diff", "--staged", "--name-only"],
[git_path, "diff", "--staged", "--name-only"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -391,6 +381,7 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
debug(MODULE, "Checking for git-level merge conflicts (non-destructive)...")
git_path = get_git_executable_path()
spec_branch = f"auto-claude/{spec_name}"
result = {
"has_conflicts": False,
@@ -404,7 +395,7 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
try:
# Get the current branch (base branch)
base_result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
[git_path, "rev-parse", "--abbrev-ref", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -414,7 +405,7 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
# Get the merge base commit
merge_base_result = subprocess.run(
["git", "merge-base", result["base_branch"], spec_branch],
[git_path, "merge-base", result["base_branch"], spec_branch],
cwd=project_dir,
capture_output=True,
text=True,
@@ -427,7 +418,7 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
# Count commits main is ahead
ahead_result = subprocess.run(
["git", "rev-list", "--count", f"{merge_base}..{result['base_branch']}"],
[git_path, "rev-list", "--count", f"{merge_base}..{result['base_branch']}"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -446,7 +437,7 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
# Note: --write-tree mode only accepts 2 branches (it auto-finds the merge base)
merge_tree_result = subprocess.run(
[
"git",
git_path,
"merge-tree",
"--write-tree",
"--no-messages",
@@ -490,7 +481,7 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
if not result["conflicting_files"]:
# Files changed in main since merge-base
main_files_result = subprocess.run(
["git", "diff", "--name-only", merge_base, result["base_branch"]],
[git_path, "diff", "--name-only", merge_base, result["base_branch"]],
cwd=project_dir,
capture_output=True,
text=True,
@@ -503,7 +494,7 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
# Files changed in spec branch since merge-base
spec_files_result = subprocess.run(
["git", "diff", "--name-only", merge_base, spec_branch],
[git_path, "diff", "--name-only", merge_base, spec_branch],
cwd=project_dir,
capture_output=True,
text=True,
@@ -616,13 +607,6 @@ 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_spec_to_source,
sync_plan_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_spec_to_source",
"sync_plan_to_source",
"AUTO_CONTINUE_DELAY_SECONDS",
"HUMAN_INTERVENTION_FILE",
]
+10 -88
View File
@@ -7,10 +7,15 @@ for custom API endpoints.
"""
import json
import logging
import os
import platform
import subprocess
from core.git_bash import get_git_bash_env
logger = logging.getLogger(__name__)
# Priority order for auth token resolution
# NOTE: We intentionally do NOT fall back to ANTHROPIC_API_KEY.
# Auto Claude is designed to use Claude Code OAuth tokens only.
@@ -36,8 +41,6 @@ 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",
]
@@ -217,85 +220,6 @@ 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.
@@ -303,7 +227,7 @@ 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.
On Windows, also includes CLAUDE_CODE_GIT_BASH_PATH if git-bash is found.
Returns:
Dict of env var name -> value for non-empty vars
@@ -314,12 +238,10 @@ def get_sdk_env_vars() -> dict[str, str]:
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
# On Windows, add git-bash path for Claude SDK sandbox
git_bash_env = get_git_bash_env()
if git_bash_env:
env.update(git_bash_env)
return env
-7
View File
@@ -16,7 +16,6 @@ import copy
import json
import logging
import os
import platform
import threading
import time
from pathlib import Path
@@ -489,12 +488,6 @@ 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", "")
+434
View File
@@ -0,0 +1,434 @@
"""
Windows Git Bash Detection
Detects git-bash (bash.exe from Git for Windows) for Claude Agent SDK.
The SDK requires bash.exe on Windows for its sandbox functionality.
Detection Priority:
1. CLAUDE_CODE_GIT_BASH_PATH environment variable (user override)
2. Standard Git installation paths (Program Files)
3. User-specific installations (LocalAppData, Scoop, Chocolatey)
Note: This detects bash.exe, NOT git.exe. They are different executables:
- git.exe: The Git CLI
- bash.exe: Unix shell bundled with Git for Windows (required by Claude SDK)
"""
import logging
import os
import platform
from dataclasses import dataclass
from typing import Optional
logger = logging.getLogger(__name__)
# Environment variable for user override
ENV_VAR_NAME = "CLAUDE_CODE_GIT_BASH_PATH"
@dataclass
class GitBashDetectionResult:
"""Result of git-bash detection attempt."""
found: bool
path: Optional[str] = None
source: str = "not-found"
message: str = ""
# Module-level cache (persists for process lifetime)
_cache: Optional[GitBashDetectionResult] = None
def _find_git_executable() -> Optional[str]:
"""
Find git.exe using multiple methods.
GUI apps on Windows often have different PATH than terminal sessions.
We try multiple approaches to find git.
Returns:
Path to git.exe if found, None otherwise
"""
import shutil
import subprocess
debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
# Method 1: shutil.which (standard PATH lookup)
git_path = shutil.which("git")
if git_path:
if debug:
print(f"[GitBash] Found git via shutil.which: {git_path}")
return git_path
# Method 2: Windows 'where' command (searches PATH differently)
try:
result = subprocess.run(
["where", "git"],
capture_output=True,
text=True,
timeout=5,
creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, 'CREATE_NO_WINDOW') else 0
)
if result.returncode == 0 and result.stdout.strip():
git_path = result.stdout.strip().split('\n')[0].strip()
if os.path.isfile(git_path):
if debug:
print(f"[GitBash] Found git via 'where' command: {git_path}")
return git_path
except Exception as e:
if debug:
print(f"[GitBash] 'where git' failed: {e}")
# Method 3: Check Windows Registry for Git install location
try:
import winreg
for hive in [winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER]:
for key_path in [
r"SOFTWARE\GitForWindows",
r"SOFTWARE\WOW6432Node\GitForWindows",
]:
try:
with winreg.OpenKey(hive, key_path) as key:
install_path, _ = winreg.QueryValueEx(key, "InstallPath")
if install_path:
# Try multiple possible git.exe locations
for subpath in ["cmd\\git.exe", "bin\\git.exe", "mingw64\\bin\\git.exe"]:
git_exe = os.path.join(install_path, subpath)
if os.path.isfile(git_exe):
if debug:
print(f"[GitBash] Found git via registry: {git_exe}")
return git_exe
except (FileNotFoundError, OSError):
continue
except ImportError:
pass # winreg not available (non-Windows)
return None
def _find_bash_from_registry() -> Optional[str]:
"""
Find bash.exe directly from Windows Registry Git InstallPath.
This is more reliable than PATH-based detection for GUI apps.
Returns:
Path to bash.exe if found via registry, None otherwise
"""
debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
try:
import winreg
for hive in [winreg.HKEY_LOCAL_MACHINE, winreg.HKEY_CURRENT_USER]:
for key_path in [
r"SOFTWARE\GitForWindows",
r"SOFTWARE\WOW6432Node\GitForWindows",
]:
try:
with winreg.OpenKey(hive, key_path) as key:
install_path, _ = winreg.QueryValueEx(key, "InstallPath")
if install_path:
# Check for bash.exe in standard locations
bash_exe = os.path.join(install_path, "bin", "bash.exe")
if os.path.isfile(bash_exe):
if debug:
print(f"[GitBash] Found bash.exe via registry: {bash_exe}")
return bash_exe
# Try usr/bin as fallback
bash_exe = os.path.join(install_path, "usr", "bin", "bash.exe")
if os.path.isfile(bash_exe):
if debug:
print(f"[GitBash] Found bash.exe via registry: {bash_exe}")
return bash_exe
except (FileNotFoundError, OSError):
continue
except ImportError:
pass # winreg not available (non-Windows)
return None
def _derive_bash_from_git(git_path: str) -> Optional[str]:
"""
Derive bash.exe location from git.exe path.
Git for Windows structure:
<install>/cmd/git.exe
<install>/bin/bash.exe
<install>/mingw64/bin/git.exe
Args:
git_path: Path to git.exe
Returns:
Path to bash.exe if found, None otherwise
"""
git_dir = os.path.dirname(git_path)
git_parent = os.path.dirname(git_dir)
# Build list of possible bash locations
possible_paths = []
# If git is in cmd/ folder
if git_dir.endswith("cmd"):
possible_paths.extend([
os.path.join(git_parent, "bin", "bash.exe"),
os.path.join(git_parent, "usr", "bin", "bash.exe"),
])
# If git is in mingw64/bin/ folder
if "mingw64" in git_dir:
git_root = git_parent
while git_root and not git_root.endswith("mingw64"):
git_root = os.path.dirname(git_root)
if git_root:
install_root = os.path.dirname(git_root)
possible_paths.extend([
os.path.join(install_root, "bin", "bash.exe"),
os.path.join(install_root, "usr", "bin", "bash.exe"),
])
# Generic fallbacks
possible_paths.extend([
os.path.join(git_parent, "bin", "bash.exe"),
os.path.join(git_parent, "usr", "bin", "bash.exe"),
])
for bash_path in possible_paths:
if os.path.isfile(bash_path):
return bash_path
return None
def _find_git_from_path() -> Optional[str]:
"""
Find bash.exe by first locating git.exe.
Returns:
Path to bash.exe if found via git location, None otherwise
"""
git_path = _find_git_executable()
if not git_path:
return None
return _derive_bash_from_git(git_path)
def _get_candidate_paths() -> list[str]:
"""
Get list of candidate paths for bash.exe on Windows.
Returns paths in priority order (most common first).
"""
candidates = [
# Standard 64-bit Git installation
r"C:\Program Files\Git\bin\bash.exe",
# Standard 32-bit Git installation
r"C:\Program Files (x86)\Git\bin\bash.exe",
# User-specific installation (Git installer option)
os.path.expandvars(r"%LOCALAPPDATA%\Programs\Git\bin\bash.exe"),
# Chocolatey installation
r"C:\ProgramData\chocolatey\lib\git\tools\bin\bash.exe",
# Scoop installation
os.path.expandvars(r"%USERPROFILE%\scoop\apps\git\current\bin\bash.exe"),
# Git for Windows SDK
r"C:\Git\bin\bash.exe",
# Alternative usr/bin location (some Git versions)
r"C:\Program Files\Git\usr\bin\bash.exe",
]
return candidates
def detect_git_bash(force_refresh: bool = False) -> GitBashDetectionResult:
"""
Detect git-bash installation on Windows.
Args:
force_refresh: If True, bypass cache and re-detect
Returns:
GitBashDetectionResult with detection outcome
"""
global _cache
# Non-Windows: not applicable
if platform.system() != "Windows":
return GitBashDetectionResult(
found=False,
source="not-applicable",
message="Git Bash detection only applies to Windows"
)
# Return cached result if available
if _cache is not None and not force_refresh:
logger.debug(f"[GitBash] Using cached result: {_cache.path} ({_cache.source})")
return _cache
debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
if debug:
print("[GitBash] Starting detection...")
# Priority 1: Environment variable override
env_path = os.environ.get(ENV_VAR_NAME)
if env_path:
if os.path.isfile(env_path):
result = GitBashDetectionResult(
found=True,
path=env_path,
source="env-var",
message=f"Using {ENV_VAR_NAME}: {env_path}"
)
print(f"[GitBash] {result.message}")
_cache = result
return result
else:
print(f"[GitBash] WARNING: {ENV_VAR_NAME} set but path does not exist: {env_path}")
# Priority 2: Windows Registry (most reliable for GUI apps)
if debug:
print("[GitBash] Checking Windows Registry...")
registry_bash = _find_bash_from_registry()
if registry_bash:
result = GitBashDetectionResult(
found=True,
path=registry_bash,
source="windows-registry",
message=f"Found git-bash via registry: {registry_bash}"
)
print(f"[GitBash] {result.message}")
_cache = result
return result
# Priority 3: Check standard candidate paths
candidates = _get_candidate_paths()
if debug:
print(f"[GitBash] Checking {len(candidates)} standard paths...")
for candidate_path in candidates:
resolved = os.path.expandvars(candidate_path)
if os.path.isfile(resolved):
result = GitBashDetectionResult(
found=True,
path=resolved,
source="standard-path",
message=f"Found git-bash at standard path: {resolved}"
)
print(f"[GitBash] {result.message}")
_cache = result
return result
elif debug:
print(f"[GitBash] Not found: {resolved}")
# Priority 4: Derive from git.exe in PATH (handles custom PATH setups)
if debug:
print("[GitBash] Checking PATH-based git installation...")
path_based_bash = _find_git_from_path()
if path_based_bash:
result = GitBashDetectionResult(
found=True,
path=path_based_bash,
source="derived-from-path",
message=f"Found git-bash via PATH: {path_based_bash}"
)
print(f"[GitBash] {result.message}")
_cache = result
return result
elif debug:
print("[GitBash] Could not derive bash.exe from git PATH")
# Not found
result = GitBashDetectionResult(
found=False,
source="not-found",
message=(
"Git Bash not found. Install Git for Windows from https://git-scm.com/downloads/win "
f"or set {ENV_VAR_NAME} environment variable."
)
)
print(f"[GitBash] WARNING: {result.message}")
_cache = result
return result
def get_git_bash_path() -> Optional[str]:
"""
Get the path to bash.exe if available.
Convenience function that returns just the path or None.
Returns:
Path to bash.exe or None if not found
"""
result = detect_git_bash()
return result.path if result.found else None
def get_git_bash_env() -> dict[str, str]:
"""
Get environment variables for Claude SDK with git-bash path.
Returns a dict suitable for merging into SDK environment variables.
Only returns the variable if git-bash was found.
Returns:
Dict with CLAUDE_CODE_GIT_BASH_PATH if found, empty dict otherwise
"""
# Skip on non-Windows
if platform.system() != "Windows":
return {}
result = detect_git_bash()
if result.found and result.path:
return {ENV_VAR_NAME: result.path}
return {}
def clear_cache() -> None:
"""Clear the detection cache. Useful for testing."""
global _cache
_cache = None
logger.debug("[GitBash] Cache cleared")
# Module-level cache for git executable path
_git_cache: Optional[str] = None
def get_git_executable_path() -> str:
"""
Get the path to git executable.
On Windows, uses multi-method detection (shutil.which, registry, standard paths).
On other platforms, returns "git" and relies on PATH.
Returns:
Path to git executable, or "git" as fallback
"""
global _git_cache
# Return cached result if available
if _git_cache is not None:
return _git_cache
# On non-Windows, just use "git" from PATH
if platform.system() != "Windows":
_git_cache = "git"
return _git_cache
# On Windows, try to find the full path
git_path = _find_git_executable()
if git_path:
_git_cache = git_path
debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
if debug:
print(f"[Git] Using detected git: {git_path}")
return _git_cache
# Fallback to "git" and hope it's in PATH
_git_cache = "git"
return _git_cache
+1 -5
View File
@@ -52,8 +52,4 @@ def emit_phase(
print(f"{PHASE_MARKER_PREFIX}{json.dumps(payload, default=str)}", flush=True)
except (OSError, UnicodeEncodeError) as e:
if _DEBUG:
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
print(f"[phase_event] emit failed: {e}", file=sys.stderr, flush=True)
+20 -16
View File
@@ -20,6 +20,7 @@ Public API is exported via workspace/__init__.py for backward compatibility.
import subprocess
from pathlib import Path
from core.git_bash import get_git_executable_path
from ui import (
Icons,
bold,
@@ -180,8 +181,9 @@ def merge_existing_build(
# Detect current branch - this is where user wants changes merged
# Normal workflow: user is on their feature branch (e.g., version/2.5.5)
# and wants to merge the spec changes into it, then PR to main
git_path = get_git_executable_path()
current_branch_result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
[git_path, "rev-parse", "--abbrev-ref", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -545,6 +547,7 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
"""
import re
git_path = get_git_executable_path()
spec_branch = f"auto-claude/{spec_name}"
result = {
"has_conflicts": False,
@@ -556,7 +559,7 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
try:
# Get current branch
base_result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
[git_path, "rev-parse", "--abbrev-ref", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -566,7 +569,7 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
# Get merge base
merge_base_result = subprocess.run(
["git", "merge-base", result["base_branch"], spec_branch],
[git_path, "merge-base", result["base_branch"], spec_branch],
cwd=project_dir,
capture_output=True,
text=True,
@@ -579,13 +582,13 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
# Get commit hashes
main_commit_result = subprocess.run(
["git", "rev-parse", result["base_branch"]],
[git_path, "rev-parse", result["base_branch"]],
cwd=project_dir,
capture_output=True,
text=True,
)
spec_commit_result = subprocess.run(
["git", "rev-parse", spec_branch],
[git_path, "rev-parse", spec_branch],
cwd=project_dir,
capture_output=True,
text=True,
@@ -602,7 +605,7 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
# Note: --write-tree mode only accepts 2 branches (it auto-finds the merge base)
merge_tree_result = subprocess.run(
[
"git",
git_path,
"merge-tree",
"--write-tree",
"--no-messages",
@@ -639,7 +642,7 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
# Fallback: if we didn't parse conflicts, use diff to find files changed in both branches
if not result["conflicting_files"]:
main_files_result = subprocess.run(
["git", "diff", "--name-only", merge_base, main_commit],
[git_path, "diff", "--name-only", merge_base, main_commit],
cwd=project_dir,
capture_output=True,
text=True,
@@ -651,7 +654,7 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict:
)
spec_files_result = subprocess.run(
["git", "diff", "--name-only", merge_base, spec_commit],
[git_path, "diff", "--name-only", merge_base, spec_commit],
cwd=project_dir,
capture_output=True,
text=True,
@@ -728,8 +731,9 @@ def _resolve_git_conflicts_with_ai(
)
# Get merge-base commit
git_path = get_git_executable_path()
merge_base_result = subprocess.run(
["git", "merge-base", base_branch, spec_branch],
[git_path, "merge-base", base_branch, spec_branch],
cwd=project_dir,
capture_output=True,
text=True,
@@ -783,7 +787,7 @@ def _resolve_git_conflicts_with_ai(
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(content, encoding="utf-8")
subprocess.run(
["git", "add", target_file_path],
[git_path, "add", target_file_path],
cwd=project_dir,
capture_output=True,
)
@@ -904,7 +908,7 @@ def _resolve_git_conflicts_with_ai(
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(merged_content, encoding="utf-8")
subprocess.run(
["git", "add", file_path], cwd=project_dir, capture_output=True
[git_path, "add", file_path], cwd=project_dir, capture_output=True
)
resolved_files.append(file_path)
print(success(f"{file_path} (new file)"))
@@ -914,7 +918,7 @@ def _resolve_git_conflicts_with_ai(
if target_path.exists():
target_path.unlink()
subprocess.run(
["git", "add", file_path],
[git_path, "add", file_path],
cwd=project_dir,
capture_output=True,
)
@@ -960,7 +964,7 @@ def _resolve_git_conflicts_with_ai(
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(result.merged_content, encoding="utf-8")
subprocess.run(
["git", "add", result.file_path],
[git_path, "add", result.file_path],
cwd=project_dir,
capture_output=True,
)
@@ -1079,7 +1083,7 @@ def _resolve_git_conflicts_with_ai(
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(result.merged_content, encoding="utf-8")
subprocess.run(
["git", "add", result.file_path],
[git_path, "add", result.file_path],
cwd=project_dir,
capture_output=True,
)
@@ -1112,7 +1116,7 @@ def _resolve_git_conflicts_with_ai(
if target_path.exists():
target_path.unlink()
subprocess.run(
["git", "add", target_file_path],
[git_path, "add", target_file_path],
cwd=project_dir,
capture_output=True,
)
@@ -1126,7 +1130,7 @@ def _resolve_git_conflicts_with_ai(
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(content, encoding="utf-8")
subprocess.run(
["git", "add", target_file_path],
[git_path, "add", target_file_path],
cwd=project_dir,
capture_output=True,
)
+16 -7
View File
@@ -10,6 +10,8 @@ import json
import subprocess
from pathlib import Path
from core.git_bash import get_git_executable_path
# Constants for merge limits
MAX_FILE_LINES_FOR_AI = 5000 # Skip AI for files larger than this
MAX_PARALLEL_AI_MERGES = 5 # Limit concurrent AI merge operations
@@ -113,9 +115,10 @@ def detect_file_renames(
# -M flag enables rename detection
# --diff-filter=R shows only renames
# --name-status shows status and file names
git_path = get_git_executable_path()
result = subprocess.run(
[
"git",
git_path,
"log",
"--name-status",
"-M",
@@ -176,8 +179,9 @@ def get_merge_base(project_dir: Path, ref1: str, ref2: str) -> str | None:
Merge-base commit hash, or None if not found
"""
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "merge-base", ref1, ref2],
[git_path, "merge-base", ref1, ref2],
cwd=project_dir,
capture_output=True,
text=True,
@@ -191,8 +195,9 @@ def get_merge_base(project_dir: Path, ref1: str, ref2: str) -> str | None:
def has_uncommitted_changes(project_dir: Path) -> bool:
"""Check if user has unsaved work."""
git_path = get_git_executable_path()
result = subprocess.run(
["git", "status", "--porcelain"],
[git_path, "status", "--porcelain"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -202,8 +207,9 @@ def has_uncommitted_changes(project_dir: Path) -> bool:
def get_current_branch(project_dir: Path) -> str:
"""Get the current branch name."""
git_path = get_git_executable_path()
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
[git_path, "rev-parse", "--abbrev-ref", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -239,8 +245,9 @@ def get_file_content_from_ref(
project_dir: Path, ref: str, file_path: str
) -> str | None:
"""Get file content from a git ref (branch, commit, etc.)."""
git_path = get_git_executable_path()
result = subprocess.run(
["git", "show", f"{ref}:{file_path}"],
[git_path, "show", f"{ref}:{file_path}"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -268,8 +275,9 @@ def get_changed_files_from_branch(
Returns:
List of (file_path, status) tuples
"""
git_path = get_git_executable_path()
result = subprocess.run(
["git", "diff", "--name-status", f"{base_branch}...{spec_branch}"],
[git_path, "diff", "--name-status", f"{base_branch}...{spec_branch}"],
cwd=project_dir,
capture_output=True,
text=True,
@@ -491,8 +499,9 @@ def create_conflict_file_with_git(
try:
# git merge-file <current> <base> <other>
# Exit codes: 0 = clean merge, 1 = conflicts, >1 = error
git_path = get_git_executable_path()
result = subprocess.run(
["git", "merge-file", "-p", main_path, base_path, wt_path],
[git_path, "merge-file", "-p", main_path, base_path, wt_path],
cwd=project_dir,
capture_output=True,
text=True,
+3 -10
View File
@@ -12,6 +12,7 @@ import subprocess
import sys
from pathlib import Path
from core.git_bash import get_git_executable_path
from merge import FileTimelineTracker
from ui import (
Icons,
@@ -267,15 +268,6 @@ 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():
@@ -377,8 +369,9 @@ def initialize_timeline_tracking(
files_to_modify.extend(subtask.get("files", []))
# Get the current branch point commit
git_path = get_git_executable_path()
result = subprocess.run(
["git", "rev-parse", "HEAD"],
[git_path, "rev-parse", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
+23 -60
View File
@@ -22,6 +22,8 @@ import subprocess
from dataclasses import dataclass
from pathlib import Path
from core.git_bash import get_git_executable_path
class WorktreeError(Exception):
"""Error during worktree operations."""
@@ -70,12 +72,14 @@ class WorktreeManager:
Returns:
The detected base branch name
"""
git_path = get_git_executable_path()
# 1. Check for DEFAULT_BRANCH env var
env_branch = os.getenv("DEFAULT_BRANCH")
if env_branch:
# Verify the branch exists
result = subprocess.run(
["git", "rev-parse", "--verify", env_branch],
[git_path, "rev-parse", "--verify", env_branch],
cwd=self.project_dir,
capture_output=True,
text=True,
@@ -92,7 +96,7 @@ class WorktreeManager:
# 2. Auto-detect main/master
for branch in ["main", "master"]:
result = subprocess.run(
["git", "rev-parse", "--verify", branch],
[git_path, "rev-parse", "--verify", branch],
cwd=self.project_dir,
capture_output=True,
text=True,
@@ -111,8 +115,9 @@ class WorktreeManager:
def _get_current_branch(self) -> str:
"""Get the current git branch."""
git_path = get_git_executable_path()
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
[git_path, "rev-parse", "--abbrev-ref", "HEAD"],
cwd=self.project_dir,
capture_output=True,
text=True,
@@ -124,37 +129,18 @@ class WorktreeManager:
return result.stdout.strip()
def _run_git(
self, args: list[str], cwd: Path | None = None, timeout: int = 60
self, args: list[str], cwd: Path | None = None
) -> subprocess.CompletedProcess:
"""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",
)
"""Run a git command and return the result."""
git_path = get_git_executable_path()
return subprocess.run(
[git_path] + args,
cwd=cwd or self.project_dir,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
def _unstage_gitignored_files(self) -> None:
"""
@@ -177,8 +163,9 @@ class WorktreeManager:
# 1. Check which staged files are gitignored
# git check-ignore returns the files that ARE ignored
git_path = get_git_executable_path()
result = subprocess.run(
["git", "check-ignore", "--stdin"],
[git_path, "check-ignore", "--stdin"],
cwd=self.project_dir,
input="\n".join(staged_files),
capture_output=True,
@@ -347,33 +334,9 @@ class WorktreeManager:
# Delete branch if it exists (from previous attempt)
self._run_git(["branch", "-D", branch_name])
# 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)
# Create worktree with new branch from base
result = self._run_git(
["worktree", "add", "-b", branch_name, str(worktree_path), start_point]
["worktree", "add", "-b", branch_name, str(worktree_path), self.base_branch]
)
if result.returncode != 0:
@@ -15,6 +15,8 @@ import subprocess
from datetime import datetime
from pathlib import Path
from core.git_bash import get_git_executable_path
from ..types import FileEvolution, TaskSnapshot, compute_content_hash
from .storage import EvolutionStorage
@@ -93,8 +95,9 @@ class BaselineCapture:
List of absolute paths to trackable files
"""
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "ls-files"],
[git_path, "ls-files"],
cwd=self.storage.project_dir,
capture_output=True,
text=True,
@@ -124,8 +127,9 @@ class BaselineCapture:
Git commit SHA, or "unknown" if not available
"""
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "rev-parse", "HEAD"],
[git_path, "rev-parse", "HEAD"],
cwd=self.storage.project_dir,
capture_output=True,
text=True,
@@ -15,6 +15,8 @@ import subprocess
from datetime import datetime
from pathlib import Path
from core.git_bash import get_git_executable_path
from ..semantic_analyzer import SemanticAnalyzer
from ..types import FileEvolution, TaskSnapshot, compute_content_hash
from .storage import EvolutionStorage
@@ -87,8 +89,8 @@ class ModificationTracker:
# Get or create evolution
if rel_path not in evolutions:
# 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")
logger.warning(f"File {rel_path} not being tracked")
# Note: We could auto-create here, but for now return None
return None
evolution = evolutions.get(rel_path)
@@ -157,21 +159,10 @@ class ModificationTracker:
)
try:
# 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
git_path = get_git_executable_path()
# Get list of files changed in the worktree vs target branch
result = subprocess.run(
["git", "diff", "--name-only", f"{merge_base}..HEAD"],
[git_path, "diff", "--name-only", f"{target_branch}...HEAD"],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -188,19 +179,19 @@ class ModificationTracker:
)
for file_path in changed_files:
# Get the diff for this file (using merge-base for accurate task-only diff)
# Get the diff for this file
diff_result = subprocess.run(
["git", "diff", f"{merge_base}..HEAD", "--", file_path],
[git_path, "diff", f"{target_branch}...HEAD", "--", file_path],
cwd=worktree_path,
capture_output=True,
text=True,
check=True,
)
# Get content before (from merge-base - the point where task branched)
# Get content before (from target branch) and after (current)
try:
show_result = subprocess.run(
["git", "show", f"{merge_base}:{file_path}"],
[git_path, "show", f"{target_branch}:{file_path}"],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -271,10 +262,12 @@ class ModificationTracker:
Returns:
The detected target branch name, defaults to 'main' if detection fails
"""
git_path = get_git_executable_path()
# Try to get the upstream tracking branch
try:
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
[git_path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -292,7 +285,7 @@ class ModificationTracker:
for branch in ["main", "master", "develop"]:
try:
result = subprocess.run(
["git", "merge-base", branch, "HEAD"],
[git_path, "merge-base", branch, "HEAD"],
cwd=worktree_path,
capture_output=True,
text=True,
+7 -40
View File
@@ -19,35 +19,6 @@ 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,
@@ -66,9 +37,6 @@ 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
@@ -77,13 +45,14 @@ 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 = line_ending.join(lines)
content = "\n".join(lines)
elif change.change_type == ChangeType.ADD_FUNCTION:
# Add function at end (before exports)
content += f"{line_ending}{line_ending}{change.content_after}"
content += f"\n\n{change.content_after}"
return content
@@ -106,9 +75,6 @@ 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] = []
@@ -131,13 +97,14 @@ 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 = line_ending.join(lines)
content = "\n".join(lines)
# Apply modifications
for mod in modifications:
@@ -147,12 +114,12 @@ def combine_non_conflicting_changes(
# Add functions
for func in functions:
if func.content_after:
content += f"{line_ending}{line_ending}{func.content_after}"
content += f"\n\n{func.content_after}"
# Apply other changes
for change in other:
if change.content_after and not change.content_before:
content += f"{line_ending}{change.content_after}"
content += f"\n{change.content_after}"
elif change.content_before and change.content_after:
content = content.replace(change.content_before, change.content_after)
+4 -1
View File
@@ -15,6 +15,8 @@ from __future__ import annotations
import subprocess
from pathlib import Path
from core.git_bash import get_git_executable_path
def find_worktree(project_dir: Path, task_id: str) -> Path | None:
"""
@@ -57,8 +59,9 @@ def get_file_from_branch(project_dir: Path, file_path: str, branch: str) -> str
File content as string, or None if file doesn't exist on branch
"""
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "show", f"{branch}:{file_path}"],
[git_path, "show", f"{branch}:{file_path}"],
cwd=project_dir,
capture_output=True,
text=True,
+23 -11
View File
@@ -17,6 +17,8 @@ import logging
import subprocess
from pathlib import Path
from core.git_bash import get_git_executable_path
logger = logging.getLogger(__name__)
# Import debug utilities
@@ -56,8 +58,9 @@ class TimelineGitHelper:
def get_current_main_commit(self) -> str:
"""Get the current HEAD commit on main branch."""
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "rev-parse", "HEAD"],
[git_path, "rev-parse", "HEAD"],
cwd=self.project_path,
capture_output=True,
text=True,
@@ -81,8 +84,9 @@ class TimelineGitHelper:
File content as string, or None if file doesn't exist at that commit
"""
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "show", f"{commit_hash}:{file_path}"],
[git_path, "show", f"{commit_hash}:{file_path}"],
cwd=self.project_path,
capture_output=True,
text=True,
@@ -104,9 +108,10 @@ class TimelineGitHelper:
List of file paths changed in the commit
"""
try:
git_path = get_git_executable_path()
result = subprocess.run(
[
"git",
git_path,
"diff-tree",
"--no-commit-id",
"--name-only",
@@ -134,9 +139,11 @@ class TimelineGitHelper:
"""
info = {}
try:
git_path = get_git_executable_path()
# Get commit message
result = subprocess.run(
["git", "log", "-1", "--format=%s", commit_hash],
[git_path, "log", "-1", "--format=%s", commit_hash],
cwd=self.project_path,
capture_output=True,
text=True,
@@ -146,7 +153,7 @@ class TimelineGitHelper:
# Get author
result = subprocess.run(
["git", "log", "-1", "--format=%an", commit_hash],
[git_path, "log", "-1", "--format=%an", commit_hash],
cwd=self.project_path,
capture_output=True,
text=True,
@@ -156,7 +163,7 @@ class TimelineGitHelper:
# Get diff stat
result = subprocess.run(
["git", "diff-tree", "--stat", "--no-commit-id", commit_hash],
[git_path, "diff-tree", "--stat", "--no-commit-id", commit_hash],
cwd=self.project_path,
capture_output=True,
text=True,
@@ -221,8 +228,9 @@ class TimelineGitHelper:
target_branch = self._detect_target_branch(worktree_path)
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "diff", "--name-only", f"{target_branch}...HEAD"],
[git_path, "diff", "--name-only", f"{target_branch}...HEAD"],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -254,8 +262,9 @@ class TimelineGitHelper:
target_branch = self._detect_target_branch(worktree_path)
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "merge-base", target_branch, "HEAD"],
[git_path, "merge-base", target_branch, "HEAD"],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -284,10 +293,12 @@ class TimelineGitHelper:
Returns:
The detected target branch name, defaults to 'main' if detection fails
"""
git_path = get_git_executable_path()
# Try to get the upstream tracking branch
try:
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
[git_path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -305,7 +316,7 @@ class TimelineGitHelper:
for branch in ["main", "master", "develop"]:
try:
result = subprocess.run(
["git", "merge-base", branch, "HEAD"],
[git_path, "merge-base", branch, "HEAD"],
cwd=worktree_path,
capture_output=True,
text=True,
@@ -330,8 +341,9 @@ class TimelineGitHelper:
Number of commits between the two points
"""
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "rev-list", "--count", f"{from_commit}..{to_commit}"],
[git_path, "rev-list", "--count", f"{from_commit}..{to_commit}"],
cwd=self.project_path,
capture_output=True,
text=True,
+2 -17
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 . ':!.auto-claude' && git commit ...`
4. **Re-stage and retry** - `git add . && git commit ...`
**If it's a false positive:**
- Add the file pattern to `.secretsignore` in the project root
@@ -643,8 +643,7 @@ The system **automatically scans for secrets** before every commit. If secrets a
### Create the Commit
```bash
# Add all files EXCEPT .auto-claude directory (spec files should never be committed)
git add . ':!.auto-claude'
git add .
git commit -m "auto-claude: Complete [subtask-id] - [subtask description]
- Files modified: [list]
@@ -652,9 +651,6 @@ 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.
@@ -960,17 +956,6 @@ 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,21 +131,7 @@ 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
@@ -153,13 +139,11 @@ 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
@@ -167,8 +151,6 @@ 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
@@ -252,7 +234,6 @@ 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
+1 -11
View File
@@ -167,8 +167,7 @@ If any issue is not fixed, go back to Phase 3.
## PHASE 6: COMMIT FIXES
```bash
# Add all files EXCEPT .auto-claude directory (spec files should never be committed)
git add . ':!.auto-claude'
git add .
git commit -m "fix: Address QA issues (qa-requested)
Fixes:
@@ -183,8 +182,6 @@ 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.
---
@@ -307,13 +304,6 @@ 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 (three-dot diff shows only spec branch changes)
git diff {{BASE_BRANCH}}...HEAD --name-status
# 5. See what files were changed
git diff main --name-only
# 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 {{BASE_BRANCH}}.
Ready for merge to main.
```
### If Rejected:
-147
View File
@@ -7,9 +7,7 @@ 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 (
@@ -18,133 +16,6 @@ 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"
@@ -433,7 +304,6 @@ 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.
@@ -445,15 +315,9 @@ 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)
@@ -483,17 +347,6 @@ 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
+32 -187
View File
@@ -185,31 +185,24 @@ 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 = []
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 ""
for _, row in df.iterrows():
memory = {
"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 "",
"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", ""),
}
# Extract session number if present
session_num = extract_session_number(name_val or "")
session_num = extract_session_number(row.get("name", ""))
if session_num:
memory["session_number"] = session_num
@@ -258,31 +251,24 @@ 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 = []
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 ""
for _, row in df.iterrows():
memory = {
"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 "",
"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", ""),
"score": 1.0, # Keyword match score
}
session_num = extract_session_number(name_val or "")
session_num = extract_session_number(row.get("name", ""))
if session_num:
memory["session_number"] = session_num
@@ -475,26 +461,19 @@ def cmd_get_entities(args):
"""
result = conn.execute(query, parameters={"limit": limit})
df = result.get_as_df()
# Process results without pandas
entities = []
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:
for _, row in df.iterrows():
if not row.get("summary"):
continue
entity = {
"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 "",
"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", ""),
}
entities.append(entity)
@@ -509,118 +488,6 @@ 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()
@@ -713,27 +580,6 @@ 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:
@@ -748,7 +594,6 @@ 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)
+7 -133
View File
@@ -875,128 +875,6 @@ 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.
@@ -1129,9 +1007,7 @@ 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.
NOTE: Returns empty list if rebase/force-push detected, since commit SHAs
are rewritten and we cannot determine which commits are truly "new".
- List of commit objects that are part of the PR and after base_sha
"""
# Get PR's canonical files (these are the actual PR changes)
pr_files = await self.get_pr_files(pr_number)
@@ -1196,14 +1072,12 @@ class GHClient:
f"{unchanged_count} unchanged (skipped)"
)
# 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, []
# Return filtered files but all commits (can't filter commits after rebase)
return changed_files, pr_commits
# No blob data available - return all files but empty commits (can't determine new commits)
# No blob data available - return all files and commits
logger.warning(
"No reviewed_file_blobs available for blob comparison after rebase. "
"Returning all PR files with empty commits list."
"No reviewed_file_blobs available for blob comparison. "
"Returning all PR files."
)
return pr_files, []
return pr_files, pr_commits
-4
View File
@@ -570,10 +570,6 @@ 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
+12 -71
View File
@@ -389,29 +389,13 @@ class GitHubOrchestrator:
pr_number=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")
# Check CI status
ci_status = await self.gh_client.get_pr_checks(pr_number)
print(
f"[orchestrator] CI status: {', '.join(ci_log_parts)}",
f"[DEBUG orchestrator] CI status: {ci_status.get('passing', 0)} passing, "
f"{ci_status.get('failing', 0)} failing, {ci_status.get('pending', 0)} pending",
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(
@@ -516,9 +500,6 @@ 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)
@@ -634,29 +615,19 @@ class GitHubOrchestrator:
await result.save(self.github_dir)
return result
# 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]
# Check if there are new commits
if not followup_context.commits_since_review:
print(
f"[Followup] No changes since last review at {base_sha}",
f"[Followup] No new commits since last review at {previous_review.reviewed_commit_sha[:8]}",
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_change_summary,
summary="No new commits since last review. Previous findings still apply.",
overall_status=previous_review.overall_status,
verdict=previous_review.verdict,
verdict_reasoning="No changes since last review.",
@@ -668,26 +639,13 @@ 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 {change_desc}...",
f"Analyzing {len(followup_context.commits_since_review)} new commits...",
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(
@@ -732,9 +690,9 @@ class GitHubOrchestrator:
)
result = await reviewer.review_followup(followup_context)
# 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", [])
# 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", [])
if failed_checks:
print(
f"[Followup] CI checks failing: {failed_checks}",
@@ -766,9 +724,6 @@ 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)
@@ -846,13 +801,6 @@ 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 ""
@@ -894,13 +842,6 @@ 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,9 +21,6 @@ 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
@@ -35,7 +32,6 @@ 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,
@@ -48,7 +44,6 @@ 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 (
@@ -69,9 +64,6 @@ 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,
@@ -146,122 +138,6 @@ 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.
@@ -391,44 +267,6 @@ 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
@@ -441,7 +279,6 @@ 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
@@ -460,9 +297,6 @@ 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."}
@@ -491,7 +325,6 @@ 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
@@ -510,9 +343,6 @@ 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",
@@ -524,48 +354,13 @@ The SDK will run invoked agents in parallel automatically.
# Build orchestrator prompt
prompt = self._build_orchestrator_prompt(context)
# Get project root - default to local checkout
# Get project root
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"
@@ -772,10 +567,6 @@ 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
@@ -27,6 +27,7 @@ from pathlib import Path
from typing import Any
from claude_agent_sdk import AgentDefinition
from core.git_bash import get_git_executable_path
try:
from ...core.client import create_client
@@ -164,8 +165,9 @@ class ParallelOrchestratorReviewer:
)
# Fetch the commit if not available locally (handles fork PRs)
git_path = get_git_executable_path()
fetch_result = subprocess.run(
["git", "fetch", "origin", head_sha],
[git_path, "fetch", "origin", head_sha],
cwd=self.project_dir,
capture_output=True,
text=True,
@@ -184,7 +186,7 @@ class ParallelOrchestratorReviewer:
# Create detached worktree at the PR commit
result = subprocess.run(
["git", "worktree", "add", "--detach", str(worktree_path), head_sha],
[git_path, "worktree", "add", "--detach", str(worktree_path), head_sha],
cwd=self.project_dir,
capture_output=True,
text=True,
@@ -245,8 +247,9 @@ class ParallelOrchestratorReviewer:
)
# Try 1: git worktree remove
git_path = get_git_executable_path()
result = subprocess.run(
["git", "worktree", "remove", "--force", str(worktree_path)],
[git_path, "worktree", "remove", "--force", str(worktree_path)],
cwd=self.project_dir,
capture_output=True,
text=True,
@@ -267,7 +270,7 @@ class ParallelOrchestratorReviewer:
try:
shutil.rmtree(worktree_path, ignore_errors=True)
subprocess.run(
["git", "worktree", "prune"],
[git_path, "worktree", "prune"],
cwd=self.project_dir,
capture_output=True,
timeout=30,
@@ -283,8 +286,9 @@ class ParallelOrchestratorReviewer:
return
# Get registered worktrees from git
git_path = get_git_executable_path()
result = subprocess.run(
["git", "worktree", "list", "--porcelain"],
[git_path, "worktree", "list", "--porcelain"],
cwd=self.project_dir,
capture_output=True,
text=True,
@@ -308,7 +312,7 @@ class ParallelOrchestratorReviewer:
if stale_count > 0:
subprocess.run(
["git", "worktree", "prune"],
[git_path, "worktree", "prune"],
cwd=self.project_dir,
capture_output=True,
timeout=30,
+11 -2
View File
@@ -47,6 +47,8 @@ if sys.version_info < (3, 10): # noqa: UP036
import asyncio
import io
import os
import platform
import subprocess
from pathlib import Path
# Configure safe encoding on Windows BEFORE any imports that might print
@@ -340,8 +342,15 @@ Examples:
print(f" {muted('Running:')} {' '.join(run_cmd)}")
print()
# Execute run.py - replace current process
os.execv(sys.executable, run_cmd)
# Execute run.py
# On Windows, use subprocess.run() to properly handle paths with spaces
# os.execv() on Windows has issues with argument quoting for paths containing spaces
if platform.system() == "Windows":
result = subprocess.run(run_cmd)
sys.exit(result.returncode)
else:
# On Unix, os.execv() replaces the current process (more efficient)
os.execv(sys.executable, run_cmd)
sys.exit(0)
-4
View File
@@ -62,9 +62,7 @@ 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,
@@ -95,9 +93,7 @@ __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",
+2 -204
View File
@@ -2,9 +2,7 @@
Git Validators
==============
Validators for git operations:
- Commit with secret scanning
- Config protection (prevent setting test users)
Validators for git operations (commit with secret scanning).
"""
import shlex
@@ -12,203 +10,8 @@ from pathlib import Path
from .validation_models import ValidationResult
# =============================================================================
# BLOCKED GIT CONFIG PATTERNS
# =============================================================================
# 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:
def validate_git_commit(command_string: str) -> ValidationResult:
"""
Validate git commit commands - run secret scan before allowing commit.
@@ -296,8 +99,3 @@ def validate_git_commit_secrets(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
+6 -2
View File
@@ -22,6 +22,8 @@ import sys
from dataclasses import dataclass
from pathlib import Path
from core.git_bash import get_git_executable_path
# =============================================================================
# SECRET PATTERNS
# =============================================================================
@@ -364,8 +366,9 @@ def scan_content(content: str, file_path: str) -> list[SecretMatch]:
def get_staged_files() -> list[str]:
"""Get list of staged files from git (excluding deleted files)."""
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"],
[git_path, "diff", "--cached", "--name-only", "--diff-filter=ACM"],
capture_output=True,
text=True,
check=True,
@@ -379,8 +382,9 @@ def get_staged_files() -> list[str]:
def get_all_tracked_files() -> list[str]:
"""Get all tracked files in the repository."""
try:
git_path = get_git_executable_path()
result = subprocess.run(
["git", "ls-files"],
[git_path, "ls-files"],
capture_output=True,
text=True,
check=True,
+1 -7
View File
@@ -33,11 +33,7 @@ from .filesystem_validators import (
validate_init_script,
validate_rm_command,
)
from .git_validators import (
validate_git_command,
validate_git_commit,
validate_git_config,
)
from .git_validators import validate_git_commit
from .process_validators import (
validate_kill_command,
validate_killall_command,
@@ -64,8 +60,6 @@ __all__ = [
"validate_init_script",
# Git validators
"validate_git_commit",
"validate_git_command",
"validate_git_config",
# Database validators
"validate_dropdb_command",
"validate_dropuser_command",
+4 -1
View File
@@ -20,6 +20,8 @@ from datetime import datetime
from enum import Enum
from pathlib import Path
from core.git_bash import get_git_executable_path
class FailureType(Enum):
"""Types of failures that can occur during autonomous builds."""
@@ -425,8 +427,9 @@ class RecoveryManager:
"""
try:
# Use git reset --hard to rollback
git_path = get_git_executable_path()
result = subprocess.run(
["git", "reset", "--hard", commit_hash],
[git_path, "reset", "--hard", commit_hash],
cwd=self.project_dir,
capture_output=True,
text=True,
-28
View File
@@ -19,34 +19,6 @@
# 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
# ============================================
+1 -5
View File
@@ -69,7 +69,6 @@
"@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",
@@ -80,12 +79,10 @@
"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",
@@ -109,7 +106,6 @@
"@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",
@@ -214,7 +210,7 @@
]
},
"linux": {
"icon": "resources/icons",
"icon": "resources/icon.png",
"target": [
"AppImage",
"deb",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 921 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

+5 -33
View File
@@ -93,40 +93,12 @@ function runElectronRebuild() {
* Check if node-pty is already built
*/
function isNodePtyBuilt() {
// 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;
}
const buildDir = path.join(__dirname, '..', 'node_modules', 'node-pty', 'build', 'Release');
if (!fs.existsSync(buildDir)) return false;
// 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;
// Check for the main .node file
const files = fs.readdirSync(buildDir);
return files.some((f) => f.endsWith('.node'));
}
/**
+2 -11
View File
@@ -56,8 +56,7 @@ export const ipcRenderer = {
on: vi.fn(),
once: vi.fn(),
removeListener: vi.fn(),
removeAllListeners: vi.fn(),
setMaxListeners: vi.fn()
removeAllListeners: vi.fn()
};
// Mock BrowserWindow
@@ -126,13 +125,6 @@ export const nativeTheme = {
on: vi.fn()
};
// Mock screen
export const screen = {
getPrimaryDisplay: vi.fn(() => ({
workAreaSize: { width: 1920, height: 1080 }
}))
};
export default {
app,
ipcMain,
@@ -141,6 +133,5 @@ export default {
dialog,
contextBridge,
shell,
nativeTheme,
screen
nativeTheme
};
@@ -1 +0,0 @@
export * from './sentry-electron-shared';
@@ -1 +0,0 @@
export * from './sentry-electron-shared';
@@ -1,26 +0,0 @@
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,8 +11,7 @@ const mockIpcRenderer = {
on: vi.fn(),
once: vi.fn(),
removeListener: vi.fn(),
removeAllListeners: vi.fn(),
setMaxListeners: vi.fn()
removeAllListeners: vi.fn()
};
// Mock contextBridge
-8
View File
@@ -28,14 +28,6 @@ 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';
@@ -1,126 +0,0 @@
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);
}
}
});
});
@@ -1,314 +0,0 @@
/**
* 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');
});
});
});
@@ -1,219 +0,0 @@
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');
});
});
@@ -1,99 +0,0 @@
/**
* @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,62 +17,6 @@ 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
@@ -115,28 +59,8 @@ 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',
+5 -4
View File
@@ -7,6 +7,7 @@ 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';
@@ -95,9 +96,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) {
args.push('--model', config.model);
const modelId = MODEL_ID_MAP[config.model] || MODEL_ID_MAP['opus'];
args.push('--model', modelId);
}
if (config?.thinkingLevel) {
args.push('--thinking-level', config.thinkingLevel);
@@ -171,9 +172,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) {
args.push('--model', config.model);
const modelId = MODEL_ID_MAP[config.model] || MODEL_ID_MAP['opus'];
args.push('--model', modelId);
}
if (config.thinkingLevel) {
args.push('--thinking-level', config.thinkingLevel);
@@ -12,7 +12,6 @@ describe('getOAuthModeClearVars', () => {
const result = getOAuthModeClearVars({});
expect(result).toEqual({
ANTHROPIC_API_KEY: '',
ANTHROPIC_AUTH_TOKEN: '',
ANTHROPIC_BASE_URL: '',
ANTHROPIC_MODEL: '',
@@ -26,7 +25,6 @@ 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('');
@@ -87,7 +85,6 @@ 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: '',
@@ -104,7 +101,6 @@ 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',
@@ -121,7 +117,6 @@ 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,12 +28,7 @@ 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: '',
+1 -216
View File
@@ -18,16 +18,12 @@
*/
import { autoUpdater } from 'electron-updater';
import { app, net } from 'electron';
import { app } 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';
@@ -255,214 +251,3 @@ 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;
}
}
@@ -0,0 +1,48 @@
/**
* 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,4 +1,5 @@
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';
@@ -1,49 +0,0 @@
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),
};
}
+78 -86
View File
@@ -21,18 +21,13 @@
*/
import { execFileSync } from 'child_process';
import { existsSync, readdirSync } from 'fs';
import { existsSync } from 'fs';
import path from 'path';
import os from 'os';
import { app } from 'electron';
import { findExecutable, getAugmentedEnv } from './env-utils';
import { findExecutable } 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
@@ -382,7 +377,24 @@ class CLIToolManager {
}
}
// 3. System PATH (augmented)
// 3. Windows Registry (most reliable for GUI apps on Windows)
if (process.platform === 'win32') {
const registryGit = this.findGitFromWindowsRegistry();
if (registryGit) {
const validation = this.validateGit(registryGit);
if (validation.valid) {
return {
found: true,
path: registryGit,
version: validation.version,
source: 'windows-registry',
message: `Using Git from registry: ${registryGit}`,
};
}
}
}
// 4. System PATH (augmented)
const gitPath = findExecutable('git');
if (gitPath) {
const validation = this.validateGit(gitPath);
@@ -397,39 +409,6 @@ class CLIToolManager {
}
}
// 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,
@@ -632,50 +611,6 @@ 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);
@@ -760,6 +695,64 @@ class CLIToolManager {
}
}
/**
* Find Git installation from Windows Registry
*
* Git for Windows registers its install path in the registry.
* This is more reliable than PATH for GUI apps.
*
* @returns Path to git.exe if found via registry, null otherwise
*/
private findGitFromWindowsRegistry(): string | null {
if (process.platform !== 'win32') {
return null;
}
try {
// Use reg.exe to query the registry (works without native modules)
const registryPaths = [
'HKLM\\SOFTWARE\\GitForWindows',
'HKCU\\SOFTWARE\\GitForWindows',
'HKLM\\SOFTWARE\\WOW6432Node\\GitForWindows',
];
for (const regPath of registryPaths) {
try {
const result = execFileSync('reg', ['query', regPath, '/v', 'InstallPath'], {
encoding: 'utf-8',
timeout: 5000,
windowsHide: true,
});
// Parse the registry output to get InstallPath
const match = result.match(/InstallPath\s+REG_SZ\s+(.+)/);
if (match && match[1]) {
const installPath = match[1].trim();
// Check for git.exe in cmd folder
const gitExe = path.join(installPath, 'cmd', 'git.exe');
if (existsSync(gitExe)) {
console.warn(`[Git] Found via registry: ${gitExe}`);
return gitExe;
}
// Fallback to bin folder
const gitExeBin = path.join(installPath, 'bin', 'git.exe');
if (existsSync(gitExeBin)) {
console.warn(`[Git] Found via registry: ${gitExeBin}`);
return gitExeBin;
}
}
} catch {
// Registry key not found, try next
continue;
}
}
} catch (error) {
console.warn('[Git] Registry lookup failed:', error);
}
return null;
}
/**
* Validate Git availability and version
*
@@ -841,7 +834,6 @@ class CLIToolManager {
timeout: 5000,
windowsHide: true,
shell: needsShell,
env: getAugmentedEnv(),
}).trim();
// Claude CLI version output format: "claude-code version X.Y.Z" or similar
+6 -118
View File
@@ -1,28 +1,6 @@
// 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 { app, BrowserWindow, shell, nativeImage, session } from 'electron';
import { join } from 'path';
import { accessSync, readFileSync, writeFileSync, rmSync } from 'fs';
import { accessSync, readFileSync, writeFileSync } from 'fs';
import { electronApp, optimizer, is } from '@electron-toolkit/utils';
import { setupIpcHandlers } from './ipc-setup';
import { AgentManager } from './agent';
@@ -34,32 +12,11 @@ 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.
@@ -69,32 +26,6 @@ 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
@@ -123,51 +54,12 @@ 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,
height,
minWidth,
minHeight,
width: 1400,
height: 900,
minWidth: 1000,
minHeight: 700,
show: false,
autoHideMenuBar: true,
titleBarStyle: 'hiddenInset',
@@ -237,10 +129,6 @@ 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();
+6 -35
View File
@@ -1,10 +1,9 @@
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';
@@ -106,51 +105,23 @@ export class InsightsConfig {
* Get complete environment for process execution
* Includes system env, auto-claude env, and active Claude profile
*/
async getProcessEnv(): Promise<Record<string, string>> {
getProcessEnv(): Record<string, string> {
const autoBuildEnv = this.loadAutoBuildEnv();
const profileEnv = getProfileEnv();
const apiProfileEnv = await getAPIProfileEnv();
const oauthModeClearVars = getOAuthModeClearVars(apiProfileEnv);
// Get Python environment (PYTHONPATH for bundled packages like python-dotenv)
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',
...(combinedPythonPath ? { PYTHONPATH: combinedPythonPath } : {})
PYTHONUTF8: '1'
};
}
}
@@ -85,7 +85,7 @@ export class InsightsExecutor extends EventEmitter {
} as InsightsChatStatus);
// Get process environment
const processEnv = await this.config.getProcessEnv();
const processEnv = this.config.getProcessEnv();
// Write conversation history to temp file to avoid Windows command-line length limit
const historyFile = path.join(
@@ -1,5 +1,6 @@
import type { BrowserWindow } from 'electron';
import { IPC_CHANNELS } from '../../shared/constants';
import path from 'path';
import { IPC_CHANNELS, getSpecsDir, AUTO_BUILD_PATHS } from '../../shared/constants';
import type {
SDKRateLimitInfo,
Task,
@@ -11,7 +11,6 @@ import type { IPCResult, AppUpdateInfo } from '../../shared/types';
import {
checkForUpdates,
downloadUpdate,
downloadStableVersion,
quitAndInstall,
getCurrentVersion
} from '../app-updater';
@@ -66,26 +65,6 @@ 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
@@ -0,0 +1,321 @@
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,8 +8,6 @@ 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 = {
@@ -27,25 +25,6 @@ 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
@@ -573,20 +552,13 @@ ${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(claudeCmd, ['--version'], {
const proc = spawn('claude', ['--version'], {
cwd: project.path,
env: claudeEnv,
shell: false
env: { ...process.env },
shell: true
});
let _stdout = '';
@@ -604,10 +576,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(claudeCmd, ['api', '--help'], {
const authCheck = spawn('claude', ['api', '--help'], {
cwd: project.path,
env: claudeEnv,
shell: false
env: { ...process.env },
shell: true
});
authCheck.on('close', (authCode: number | null) => {
@@ -642,9 +614,6 @@ ${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 {
@@ -663,20 +632,13 @@ ${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(claudeCmd, ['setup-token'], {
const proc = spawn('claude', ['setup-token'], {
cwd: project.path,
env: claudeEnv,
shell: false,
env: { ...process.env },
shell: true,
stdio: 'inherit' // This allows the terminal to handle the interactive auth
});
@@ -704,9 +666,6 @@ ${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 {
@@ -1,260 +0,0 @@
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,7 +28,6 @@ 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');
@@ -278,13 +277,11 @@ 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);
},
@@ -364,15 +361,7 @@ 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,
project.settings?.mainBranch // Pass project's configured main branch
);
const specData = await createSpecForIssue(project, issue.number, issue.title, taskDescription, issue.html_url, labels);
// Save auto-fix state
const issuesDir = path.join(getGitHubDir(project), 'issues');
@@ -618,7 +607,6 @@ 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 });
@@ -626,7 +614,6 @@ export function registerAutoFixHandlers(
pythonPath: getPythonPath(backendPath),
args,
cwd: backendPath,
env: subprocessEnv,
onProgress: (percent, message) => {
sendProgress({
phase: 'batching',
@@ -741,14 +728,12 @@ 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,8 +66,7 @@ ${issue.body || 'No description provided.'}
issue.title,
description,
issue.html_url,
labelNames,
project.settings?.mainBranch // Pass project's configured main branch
labelNames
);
// Start spec creation with the existing spec directory
@@ -148,8 +148,7 @@ export function registerInvestigateIssue(
issue.title,
taskDescription,
issue.html_url,
labels,
project.settings?.mainBranch // Pass project's configured main branch
labels
);
// NOTE: We intentionally do NOT call agentManager.startSpecCreation() here
@@ -16,12 +16,10 @@ 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,
@@ -72,13 +70,6 @@ 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
*/
@@ -134,159 +125,6 @@ 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
*/
@@ -792,9 +630,10 @@ async function runPRReview(
const logCollector = new PRLogCollector(project, prNumber, repo, false);
// Build environment with project settings
const subprocessEnv = await getRunnerEnv(
getClaudeMdEnv(project)
);
const subprocessEnv: Record<string, string> = {};
if (project.settings?.useClaudeMd !== false) {
subprocessEnv['USE_CLAUDE_MD'] = 'true';
}
const { process: childProcess, promise } = runPythonSubprocess<PRReviewResult>({
pythonPath: getPythonPath(backendPath),
@@ -844,12 +683,6 @@ 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)
@@ -866,11 +699,11 @@ export function registerPRHandlers(
): void {
debugLog('Registering PR handlers');
// List open PRs with pagination support
// List open PRs
ipcMain.handle(
IPC_CHANNELS.GITHUB_PR_LIST,
async (_, projectId: string, page: number = 1): Promise<PRData[]> => {
debugLog('listPRs handler called', { projectId, page });
async (_, projectId: string): Promise<PRData[]> => {
debugLog('listPRs handler called', { projectId });
const result = await withProjectOrNull(projectId, async (project) => {
const config = getGitHubConfig(project);
if (!config) {
@@ -879,10 +712,9 @@ 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=100&page=${page}`
`/repos/${config.repo}/pulls?state=open&per_page=50`
) as Array<{
number: number;
title: string;
@@ -900,7 +732,7 @@ export function registerPRHandlers(
html_url: string;
}>;
debugLog('Fetched PRs', { count: prs.length, page });
debugLog('Fetched PRs', { count: prs.length });
return prs.map(pr => ({
number: pr.number,
title: pr.title,
@@ -1034,23 +866,6 @@ 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,
@@ -1154,8 +969,8 @@ export function registerPRHandlers(
// Post review to GitHub
ipcMain.handle(
IPC_CHANNELS.GITHUB_PR_POST_REVIEW,
async (_, projectId: string, prNumber: number, selectedFindingIds?: string[], options?: { forceApprove?: boolean }): Promise<boolean> => {
debugLog('postPRReview handler called', { projectId, prNumber, selectedCount: selectedFindingIds?.length, forceApprove: options?.forceApprove });
async (_, projectId: string, prNumber: number, selectedFindingIds?: string[]): Promise<boolean> => {
debugLog('postPRReview handler called', { projectId, prNumber, selectedCount: selectedFindingIds?.length });
const postResult = await withProjectOrNull(projectId, async (project) => {
const result = getReviewResult(project, prNumber);
if (!result) {
@@ -1178,69 +993,36 @@ export function registerPRHandlers(
debugLog('Posting findings', { total: result.findings.length, selected: findings.length });
// Build review body - different format for auto-approve with suggestions
let body: string;
// Build review body
let body = `## 🤖 Auto Claude PR Review\n\n${result.summary}\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`;
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 (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`;
}
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`;
}
}
body += `---\n*This automated review found no blocking issues. The PR can be safely merged.*\n\n`;
body += `*Generated by Auto Claude*`;
} else {
// 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 += `*No findings selected for this review.*\n\n`;
}
// Determine review status based on selected findings (or force approve)
body += `---\n*This review was generated by Auto Claude.*`;
// Determine review status based on selected findings
let overallStatus = result.overallStatus;
if (options?.forceApprove) {
// Force approve regardless of findings
overallStatus = 'approve';
} else if (selectedSet) {
if (selectedSet) {
const hasBlocker = findings.some(f => f.severity === 'critical' || f.severity === 'high');
overallStatus = hasBlocker ? 'request_changes' : (findings.length > 0 ? 'comment' : 'approve');
}
@@ -1709,9 +1491,10 @@ export function registerPRHandlers(
const logCollector = new PRLogCollector(project, prNumber, repo, true);
// Build environment with project settings
const followupEnv = await getRunnerEnv(
getClaudeMdEnv(project)
);
const followupEnv: Record<string, string> = {};
if (project.settings?.useClaudeMd !== false) {
followupEnv['USE_CLAUDE_MD'] = 'true';
}
const { process: childProcess, promise } = runPythonSubprocess<PRReviewResult>({
pythonPath: getPythonPath(backendPath),
@@ -1760,11 +1543,6 @@ 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',
@@ -1795,226 +1573,5 @@ 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,7 +8,6 @@ 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;
@@ -56,14 +55,7 @@ function determineCategoryFromLabels(labels: string[]): 'feature' | 'bug_fix' |
}
// Check for infrastructure labels
// 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')
)) {
if (lowerLabels.some(l => l.includes('infrastructure') || l.includes('devops') || l.includes('deployment') || l.includes('ci') || l.includes('cd'))) {
return 'infrastructure';
}
@@ -97,8 +89,7 @@ export async function createSpecForIssue(
issueTitle: string,
taskDescription: string,
githubUrl: string,
labels: string[] = [],
baseBranch?: string
labels: string[] = []
): Promise<SpecCreationData> {
const specsBaseDir = getSpecsDir(project.autoBuildPath);
const specsDir = path.join(project.path, specsBaseDir);
@@ -153,10 +144,7 @@ export async function createSpecForIssue(
sourceType: 'github',
githubIssueNumber: issueNumber,
githubUrl,
category,
// Store baseBranch for worktree creation and QA comparison
// This comes from project.settings.mainBranch or task-level override
...(baseBranch && { baseBranch })
category
};
writeFileSync(
path.join(specDir, 'task_metadata.json'),
@@ -19,7 +19,6 @@ 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,
@@ -255,13 +254,10 @@ 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({
@@ -1,55 +0,0 @@
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',
});
});
});
@@ -1,31 +0,0 @@
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,
};
}

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