Compare commits

..

10 Commits

Author SHA1 Message Date
Alex d789d0ad15 Merge branch 'develop' into enhance-workflows 2026-01-06 12:10:36 +01:00
Alex Madera 324edfa7d5 fix bugbot comments 2026-01-04 09:01:00 +01:00
Alex b5f452b428 Merge branch 'develop' into enhance-workflows 2026-01-04 08:42:27 +01:00
Andy b0de22a988 Merge branch 'develop' into enhance-workflows 2026-01-03 23:55:57 +01:00
Alex 3c49173210 Merge branch 'develop' into enhance-workflows 2026-01-03 15:21:56 +01:00
Andy d3587c3c42 Merge branch 'develop' into enhance-workflows 2026-01-02 19:46:07 +01:00
Alex Madera f6c9a84acb fix(ci): update action versions for Node.js compatibility
- Update actions/first-interaction to v1.4.0 (Node 16+)
- Keep actions/setup-node@v4 with node-version: 24

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

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

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-02 16:36:47 +01:00
Alex Madera 90f5b59c35 draft: improve yml to be less cluster 2026-01-02 15:35:26 +01:00
229 changed files with 4017 additions and 18321 deletions
+2 -147
View File
@@ -115,10 +115,6 @@ jobs:
- name: Build application
run: cd apps/frontend && npm run build
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Package macOS (Intel)
run: |
@@ -128,9 +124,6 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Notarize macOS Intel app
env:
@@ -214,10 +207,6 @@ jobs:
- name: Build application
run: cd apps/frontend && npm run build
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Package macOS (Apple Silicon)
run: |
@@ -227,9 +216,6 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Notarize macOS ARM64 app
env:
@@ -265,12 +251,6 @@ jobs:
build-windows:
needs: create-tag
runs-on: windows-latest
permissions:
id-token: write # Required for OIDC authentication with Azure
contents: read
env:
# Job-level env so AZURE_CLIENT_ID is available for step-level if conditions
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
steps:
- uses: actions/checkout@v4
with:
@@ -319,10 +299,6 @@ jobs:
- name: Build application
run: cd apps/frontend && npm run build
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Package Windows
shell: bash
@@ -331,122 +307,8 @@ jobs:
cd apps/frontend && npm run package:win -- --config.extraMetadata.version="$VERSION"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Disable electron-builder's built-in signing (we use Azure Trusted Signing instead)
CSC_IDENTITY_AUTO_DISCOVERY: false
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Azure Login (OIDC)
if: env.AZURE_CLIENT_ID != ''
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Sign Windows executable with Azure Trusted Signing
if: env.AZURE_CLIENT_ID != ''
uses: azure/trusted-signing-action@v0.5.11
with:
endpoint: https://neu.codesigning.azure.net/
trusted-signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
certificate-profile-name: ${{ secrets.AZURE_CERTIFICATE_PROFILE }}
files-folder: apps/frontend/dist
files-folder-filter: exe
file-digest: SHA256
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256
- name: Verify Windows executable is signed
if: env.AZURE_CLIENT_ID != ''
shell: pwsh
run: |
cd apps/frontend/dist
$exeFile = Get-ChildItem -Filter "*.exe" | Select-Object -First 1
if ($exeFile) {
Write-Host "Verifying signature on $($exeFile.Name)..."
$sig = Get-AuthenticodeSignature -FilePath $exeFile.FullName
if ($sig.Status -ne 'Valid') {
Write-Host "::error::Signature verification failed: $($sig.Status)"
Write-Host "::error::Status Message: $($sig.StatusMessage)"
exit 1
}
Write-Host "✅ Signature verified successfully"
Write-Host " Subject: $($sig.SignerCertificate.Subject)"
Write-Host " Issuer: $($sig.SignerCertificate.Issuer)"
Write-Host " Thumbprint: $($sig.SignerCertificate.Thumbprint)"
} else {
Write-Host "::error::No .exe file found to verify"
exit 1
}
- name: Regenerate checksums after signing
if: env.AZURE_CLIENT_ID != ''
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
cd apps/frontend/dist
# Find the installer exe (electron-builder names it with "Setup" or just the app name)
# electron-builder produces one installer exe per build
$exeFiles = Get-ChildItem -Filter "*.exe"
if ($exeFiles.Count -eq 0) {
Write-Host "::error::No .exe files found in dist folder"
exit 1
}
Write-Host "Found $($exeFiles.Count) exe file(s): $($exeFiles.Name -join ', ')"
$ymlFile = "latest.yml"
if (-not (Test-Path $ymlFile)) {
Write-Host "::error::$ymlFile not found - cannot update checksums"
exit 1
}
$content = Get-Content $ymlFile -Raw
$originalContent = $content
# Process each exe file and update its hash in latest.yml
foreach ($exeFile in $exeFiles) {
Write-Host "Processing $($exeFile.Name)..."
# Compute SHA512 hash and convert to base64 (electron-builder format)
$bytes = [System.IO.File]::ReadAllBytes($exeFile.FullName)
$sha512 = [System.Security.Cryptography.SHA512]::Create()
$hashBytes = $sha512.ComputeHash($bytes)
$hash = [System.Convert]::ToBase64String($hashBytes)
$size = $exeFile.Length
Write-Host " Hash: $hash"
Write-Host " Size: $size"
}
# For electron-builder, latest.yml has a single file entry for the installer
# Update the sha512 and size for the primary exe (first one, typically the installer)
$primaryExe = $exeFiles | Select-Object -First 1
$bytes = [System.IO.File]::ReadAllBytes($primaryExe.FullName)
$sha512 = [System.Security.Cryptography.SHA512]::Create()
$hashBytes = $sha512.ComputeHash($bytes)
$hash = [System.Convert]::ToBase64String($hashBytes)
$size = $primaryExe.Length
# Update sha512 hash (base64 pattern: alphanumeric, +, /, =)
$content = $content -replace 'sha512: [A-Za-z0-9+/=]+', "sha512: $hash"
# Update size
$content = $content -replace 'size: \d+', "size: $size"
if ($content -eq $originalContent) {
Write-Host "::error::Checksum replacement failed - content unchanged. Check if latest.yml format has changed."
exit 1
}
Set-Content -Path $ymlFile -Value $content -NoNewline
Write-Host "✅ Updated $ymlFile with new base64 hash and size for $($primaryExe.Name)"
- name: Skip signing notice
if: env.AZURE_CLIENT_ID == ''
run: echo "::warning::Windows signing skipped - AZURE_CLIENT_ID not configured. The .exe will be unsigned."
CSC_LINK: ${{ secrets.WIN_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTIFICATE_PASSWORD }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -515,10 +377,6 @@ jobs:
- name: Build application
run: cd apps/frontend && npm run build
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Package Linux
run: |
@@ -526,9 +384,6 @@ jobs:
cd apps/frontend && npm run package:linux -- --config.extraMetadata.version="$VERSION"
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
+693 -30
View File
@@ -1,49 +1,386 @@
# ╔═══════════════════════════════════════════════════════════════════════════════╗
# ║ ║
# ║ AUTO CLAUDE - CI PIPELINE ║
# ║ ║
# ║ A unified, enterprise-grade CI workflow for pull request validation ║
# ║ and scheduled security scanning. ║
# ║ ║
# ║ TRIGGERS: ║
# ║ - Pull requests to main/develop branches ║
# ║ - Weekly scheduled security scans (Monday 00:00 UTC) ║
# ║ - Manual workflow dispatch ║
# ║ ║
# ║ WORKFLOW STAGES: ║
# ║ ┌─────────────────────────────────────────────────────────────────────────┐ ║
# ║ │ Stage 1: PR Setup - Label PR and set "Checking" status │ ║
# ║ │ Stage 2: Change Detection - Determine which tests to run │ ║
# ║ │ Stage 3: Quality Gates - Run tests, linting, and security scans │ ║
# ║ │ Stage 4: Status Update - Update PR with final status │ ║
# ║ └─────────────────────────────────────────────────────────────────────────┘ ║
# ║ ║
# ║ SMART PATH FILTERING (PR mode): ║
# ║ - Backend changes (apps/backend/**, tests/**) → Python tests + lint ║
# ║ - Frontend changes (apps/frontend/**) → Frontend tests + build ║
# ║ - No code changes (docs, CI configs only) → Skip tests, mark ready ║
# ║ ║
# ║ SCHEDULED SECURITY SCANS: ║
# ║ - Weekly CodeQL analysis for Python and JavaScript/TypeScript ║
# ║ - Weekly Bandit security scan for Python backend ║
# ║ - Detects newly discovered CVEs in existing code ║
# ║ ║
# ║ LABELS APPLIED (PR mode only): ║
# ║ - Status: 🔄 Checking → ✅ Ready for Review / ❌ Checks Failed ║
# ║ - Type: feature, bug, docs, refactor, ci, chore (from PR title) ║
# ║ - Area: area/frontend, area/backend, area/fullstack, area/ci ║
# ║ - Size: size/XS, size/S, size/M, size/L, size/XL ║
# ║ ║
# ║ MAINTAINERS: See CONTRIBUTING.md for workflow modification guidelines. ║
# ║ ║
# ╚═══════════════════════════════════════════════════════════════════════════════╝
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
types: [opened, synchronize, reopened]
# Weekly scheduled security scans to detect newly discovered CVEs
# Runs every Monday at midnight UTC
schedule:
- cron: '0 0 * * 1'
# Allow manual triggering for security scans
workflow_dispatch:
# ─────────────────────────────────────────────────────────────────────────────────
# CONCURRENCY: Cancel redundant runs when new commits are pushed to the same PR
# ─────────────────────────────────────────────────────────────────────────────────
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
group: ci-${{ github.head_ref || github.ref }}
cancel-in-progress: true
# ─────────────────────────────────────────────────────────────────────────────────
# PERMISSIONS: Minimal permissions required for this workflow
# ─────────────────────────────────────────────────────────────────────────────────
permissions:
contents: read
actions: read
contents: read # Read repository contents
actions: read # Read workflow runs
security-events: write # Upload CodeQL results
pull-requests: write # Update PR labels
checks: read # Read check run status
# ═══════════════════════════════════════════════════════════════════════════════════
# JOBS
# ═══════════════════════════════════════════════════════════════════════════════════
jobs:
# Python tests
test-python:
# ┌─────────────────────────────────────────────────────────────────────────────┐
# │ STAGE 1: PR SETUP │
# │ │
# │ Purpose: Initialize PR with labels and "Checking" status │
# │ Runs: First, before any other job │
# │ Labels: Status (Checking), Type, Area, Size │
# └─────────────────────────────────────────────────────────────────────────────┘
stage-1-setup:
name: "Stage 1: PR Setup"
runs-on: ubuntu-latest
# Skip for fork PRs (they cannot write labels) and scheduled runs (no PR context)
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 5
steps:
- name: "1.1 Apply Labels and Set Checking Status"
uses: actions/github-script@v7
with:
retries: 3
retry-exempt-status-codes: 400,401,403,404,422
script: |
const { owner, repo } = context.repo;
const pr = context.payload.pull_request;
const prNumber = pr.number;
const title = pr.title;
console.log(`\n${'═'.repeat(60)}`);
console.log(` PR #${prNumber}: ${title}`);
console.log(`${'═'.repeat(60)}\n`);
const labelsToAdd = new Set();
const labelsToRemove = new Set();
// ─────────────────────────────────────────────────────────────
// STATUS LABEL: Set to "Checking" while CI runs
// ─────────────────────────────────────────────────────────────
const statusLabels = ['🔄 Checking', '✅ Ready for Review', '❌ Checks Failed'];
statusLabels.forEach(l => labelsToRemove.add(l));
labelsToAdd.add('🔄 Checking');
console.log('Status: 🔄 Checking');
// ─────────────────────────────────────────────────────────────
// TYPE LABEL: Extracted from Conventional Commit prefix
// Format: type(scope)!: description
// Examples: feat:, fix(ui):, docs!:, refactor(api):
// ─────────────────────────────────────────────────────────────
const typeMap = {
'feat': 'feature', // New feature
'fix': 'bug', // Bug fix
'docs': 'documentation',// Documentation only
'refactor': 'refactor', // Code refactoring
'test': 'test', // Adding/updating tests
'ci': 'ci', // CI/CD changes
'chore': 'chore', // Maintenance tasks
'perf': 'performance', // Performance improvements
'style': 'style', // Code style changes
'build': 'build' // Build system changes
};
const typeMatch = title.match(/^(\w+)(\(.+?\))?(!)?:/);
if (typeMatch) {
const type = typeMatch[1].toLowerCase();
const isBreaking = typeMatch[3] === '!';
if (typeMap[type]) {
labelsToAdd.add(typeMap[type]);
console.log(`Type: ${typeMap[type]}`);
}
if (isBreaking) {
labelsToAdd.add('breaking-change');
console.log('⚠️ Breaking change detected');
}
} else {
console.log('Type: (no conventional commit prefix detected)');
}
// ─────────────────────────────────────────────────────────────
// AREA LABEL: Determined by which files were changed
// ─────────────────────────────────────────────────────────────
let files = [];
try {
const { data } = await github.rest.pulls.listFiles({
owner, repo, pull_number: prNumber, per_page: 100
});
files = data;
} catch (e) {
console.log(`Warning: Could not fetch changed files: ${e.message}`);
}
const areas = { frontend: false, backend: false, ci: false };
for (const file of files) {
const path = file.filename;
if (path.startsWith('apps/frontend/')) areas.frontend = true;
if (path.startsWith('apps/backend/') || path.startsWith('tests/')) areas.backend = true;
if (path.startsWith('.github/')) areas.ci = true;
}
// Area labels are mutually exclusive
const areaLabels = ['area/frontend', 'area/backend', 'area/fullstack', 'area/ci'];
let areaLabel = null;
if (areas.frontend && areas.backend) {
areaLabel = 'area/fullstack';
} else if (areas.frontend) {
areaLabel = 'area/frontend';
} else if (areas.backend) {
areaLabel = 'area/backend';
} else if (areas.ci) {
areaLabel = 'area/ci';
}
if (areaLabel) {
labelsToAdd.add(areaLabel);
areaLabels.filter(l => l !== areaLabel).forEach(l => labelsToRemove.add(l));
console.log(`Area: ${areaLabel.replace('area/', '')}`);
}
// ─────────────────────────────────────────────────────────────
// SIZE LABEL: Based on total lines changed
// XS: <10, S: <100, M: <500, L: <1000, XL: >=1000
// ─────────────────────────────────────────────────────────────
const additions = pr.additions || 0;
const deletions = pr.deletions || 0;
const totalLines = additions + deletions;
const sizeLabels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'];
const sizeLabel = totalLines < 10 ? 'size/XS' :
totalLines < 100 ? 'size/S' :
totalLines < 500 ? 'size/M' :
totalLines < 1000 ? 'size/L' : 'size/XL';
labelsToAdd.add(sizeLabel);
sizeLabels.filter(l => l !== sizeLabel).forEach(l => labelsToRemove.add(l));
console.log(`Size: ${sizeLabel.replace('size/', '')} (+${additions}/-${deletions} = ${totalLines} lines)`);
// ─────────────────────────────────────────────────────────────
// APPLY LABELS
// ─────────────────────────────────────────────────────────────
console.log(`\n${'─'.repeat(60)}`);
console.log('Applying labels...');
// Remove old labels first
const removeArray = [...labelsToRemove].filter(l => !labelsToAdd.has(l));
for (const label of removeArray) {
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: label
});
} catch (e) {
// Ignore 404 (label not present)
}
}
// Add new labels
const addArray = [...labelsToAdd];
if (addArray.length > 0) {
try {
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: addArray
});
console.log(`✓ Labels applied: ${addArray.join(', ')}`);
} catch (e) {
if (e.status === 404) {
core.warning('Some labels do not exist. Please create them in Settings > Labels.');
// Try adding labels one by one
for (const label of addArray) {
try {
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: [label]
});
} catch (e2) {
console.log(` ⚠ Label '${label}' does not exist`);
}
}
}
}
}
console.log(`${'─'.repeat(60)}\n`);
# ┌─────────────────────────────────────────────────────────────────────────────┐
# │ STAGE 2: CHANGE DETECTION │
# │ │
# │ Purpose: Analyze changed files to determine which tests to run │
# │ Runs: After Stage 1 completes │
# │ Outputs: backend, frontend, any_code, skip_tests │
# └─────────────────────────────────────────────────────────────────────────────┘
stage-2-changes:
name: "Stage 2: Detect Changes"
runs-on: ubuntu-latest
needs: stage-1-setup
# Always run even if stage-1 was skipped (fork PRs)
if: always()
timeout-minutes: 5
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
any_code: ${{ steps.filter.outputs.any_code }}
skip_tests: ${{ steps.evaluate.outputs.skip_tests }}
scheduled_scan: ${{ steps.evaluate.outputs.scheduled_scan }}
steps:
- name: "2.1 Checkout Repository"
uses: actions/checkout@v4
- name: "2.2 Analyze Changed Files"
id: filter
uses: dorny/paths-filter@v3
with:
filters: |
backend:
- 'apps/backend/**'
- 'tests/**'
- 'requirements*.txt'
frontend:
- 'apps/frontend/**'
- 'package*.json'
any_code:
- 'apps/**'
- 'tests/**'
- 'package*.json'
- 'requirements*.txt'
- name: "2.3 Evaluate Test Requirements"
id: evaluate
run: |
echo ""
echo "═══════════════════════════════════════════════════════════"
echo " CHANGE DETECTION RESULTS"
echo "═══════════════════════════════════════════════════════════"
echo ""
# For scheduled runs, always run security scans on all code
if [ "${{ github.event_name }}" = "schedule" ] || [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo " Event type: ${{ github.event_name }}"
echo " → Scheduled/manual security scan - running all checks"
echo ""
echo "skip_tests=false" >> $GITHUB_OUTPUT
echo "scheduled_scan=true" >> $GITHUB_OUTPUT
else
echo " Backend changes: ${{ steps.filter.outputs.backend }}"
echo " Frontend changes: ${{ steps.filter.outputs.frontend }}"
echo " Any code changes: ${{ steps.filter.outputs.any_code }}"
echo ""
echo "scheduled_scan=false" >> $GITHUB_OUTPUT
if [ "${{ steps.filter.outputs.any_code }}" = "false" ]; then
echo "skip_tests=true" >> $GITHUB_OUTPUT
echo " → No code changes detected"
echo " → Tests will be SKIPPED (docs/config only changes)"
else
echo "skip_tests=false" >> $GITHUB_OUTPUT
echo " → Code changes detected"
echo " → Tests will be EXECUTED"
fi
fi
echo ""
echo "═══════════════════════════════════════════════════════════"
# ┌─────────────────────────────────────────────────────────────────────────────┐
# │ STAGE 3: QUALITY GATES │
# │ │
# │ Purpose: Run tests, linting, and security scans based on detected changes │
# │ Runs: After Stage 2, jobs run in parallel where possible │
# │ Jobs: Python Tests, Frontend Tests, Python Lint, CodeQL, Bandit │
# └─────────────────────────────────────────────────────────────────────────────┘
# ─────────────────────────────────────────────────────────────────────────────
# PYTHON TESTS: Run pytest across Python version matrix
# Triggered: When backend files change
# ─────────────────────────────────────────────────────────────────────────────
stage-3-test-python:
name: "Stage 3: Python Tests (${{ matrix.python-version }})"
runs-on: ubuntu-latest
needs: stage-2-changes
if: needs.stage-2-changes.outputs.backend == 'true'
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
python-version: ['3.12', '3.13']
steps:
- name: Checkout
- name: "3.1 Checkout Repository"
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
- name: "3.2 Setup Python ${{ matrix.python-version }}"
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
- name: "3.3 Setup UV Package Manager"
uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: Install dependencies
- name: "3.4 Install Dependencies"
working-directory: apps/backend
run: |
uv venv
uv pip install -r requirements.txt
uv pip install -r ../../tests/requirements-test.txt
- name: Run tests
- name: "3.5 Run Test Suite"
working-directory: apps/backend
env:
PYTHONPATH: ${{ github.workspace }}/apps/backend
@@ -51,16 +388,20 @@ jobs:
source .venv/bin/activate
pytest ../../tests/ -v --tb=short -x
- name: Run tests with coverage
- name: "3.6 Run Tests with Coverage"
if: matrix.python-version == '3.12'
working-directory: apps/backend
env:
PYTHONPATH: ${{ github.workspace }}/apps/backend
run: |
source .venv/bin/activate
pytest ../../tests/ -v --cov=. --cov-report=xml --cov-report=term-missing --cov-fail-under=20
pytest ../../tests/ -v \
--cov=. \
--cov-report=xml \
--cov-report=term-missing \
--cov-fail-under=20
- name: Upload coverage reports
- name: "3.7 Upload Coverage to Codecov"
if: matrix.python-version == '3.12'
uses: codecov/codecov-action@v4
with:
@@ -69,44 +410,366 @@ jobs:
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
# Frontend lint, typecheck, test, and build
test-frontend:
# ─────────────────────────────────────────────────────────────────────────────
# FRONTEND TESTS: Lint, typecheck, test, and build
# Triggered: When frontend files change
# ─────────────────────────────────────────────────────────────────────────────
stage-3-test-frontend:
name: "Stage 3: Frontend Tests"
runs-on: ubuntu-latest
needs: stage-2-changes
if: needs.stage-2-changes.outputs.frontend == 'true'
timeout-minutes: 15
steps:
- name: Checkout
- name: "3.1 Checkout Repository"
uses: actions/checkout@v4
- name: Setup Node.js
- name: "3.2 Setup Node.js"
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Get npm cache directory
id: npm-cache
run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT"
- uses: actions/cache@v4
- name: "3.3 Cache npm Dependencies"
uses: actions/cache@v4
with:
path: ${{ steps.npm-cache.outputs.dir }}
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ runner.os }}-npm-
- name: Install dependencies
- name: "3.4 Install Dependencies"
working-directory: apps/frontend
run: npm ci --ignore-scripts
- name: Lint
- name: "3.5 Run ESLint"
working-directory: apps/frontend
run: npm run lint
- name: Type check
- name: "3.6 Run TypeScript Type Check"
working-directory: apps/frontend
run: npm run typecheck
- name: Run tests
- name: "3.7 Run Unit Tests"
working-directory: apps/frontend
run: npm run test
- name: Build
- name: "3.8 Build Application"
working-directory: apps/frontend
run: npm run build
# ─────────────────────────────────────────────────────────────────────────────
# PYTHON LINT: Check code style and formatting with Ruff
# Triggered: When backend files change
# ─────────────────────────────────────────────────────────────────────────────
stage-3-lint-python:
name: "Stage 3: Python Lint"
runs-on: ubuntu-latest
needs: stage-2-changes
if: needs.stage-2-changes.outputs.backend == 'true'
timeout-minutes: 10
steps:
- name: "3.1 Checkout Repository"
uses: actions/checkout@v4
- name: "3.2 Setup Python"
uses: actions/setup-python@v5
with:
python-version: '3.12'
# Ruff version pinned to match .pre-commit-config.yaml
- name: "3.3 Install Ruff"
run: pip install ruff==0.14.10
- name: "3.4 Run Ruff Linter"
run: ruff check apps/backend/ --output-format=github
- name: "3.5 Check Code Formatting"
run: ruff format apps/backend/ --check --diff
# ─────────────────────────────────────────────────────────────────────────────
# CODEQL: Static analysis for security vulnerabilities
# Triggered: When any code files change OR on scheduled/manual security scans
# ─────────────────────────────────────────────────────────────────────────────
stage-3-codeql:
name: "Stage 3: CodeQL (${{ matrix.language }})"
runs-on: ubuntu-latest
needs: stage-2-changes
# Run on code changes OR scheduled/manual security scans
if: needs.stage-2-changes.outputs.any_code == 'true' || needs.stage-2-changes.outputs.scheduled_scan == 'true'
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
language: [python, javascript-typescript]
steps:
- name: "3.1 Checkout Repository"
uses: actions/checkout@v4
- name: "3.2 Initialize CodeQL"
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: "3.3 Autobuild"
uses: github/codeql-action/autobuild@v3
- name: "3.4 Run CodeQL Analysis"
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"
# ─────────────────────────────────────────────────────────────────────────────
# PYTHON SECURITY: Bandit security scanner for Python code
# Triggered: When backend files change OR on scheduled/manual security scans
# ─────────────────────────────────────────────────────────────────────────────
stage-3-security-python:
name: "Stage 3: Python Security"
runs-on: ubuntu-latest
needs: stage-2-changes
# Run on backend changes OR scheduled/manual security scans
if: needs.stage-2-changes.outputs.backend == 'true' || needs.stage-2-changes.outputs.scheduled_scan == 'true'
timeout-minutes: 10
steps:
- name: "3.1 Checkout Repository"
uses: actions/checkout@v4
- name: "3.2 Setup Python"
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: "3.3 Install Bandit"
run: pip install bandit
- name: "3.4 Run Bandit Security Scan"
id: bandit
run: |
# Run Bandit and capture exit code
# Exit 0 = no issues, Exit 1 = issues found, Exit > 1 = real error
set +e
bandit -r apps/backend/ -ll -ii -f json -o bandit-report.json
BANDIT_EXIT=$?
set -e
if [ $BANDIT_EXIT -eq 0 ]; then
echo "✓ Bandit scan completed - no issues found"
echo "scan_status=clean" >> $GITHUB_OUTPUT
elif [ $BANDIT_EXIT -eq 1 ]; then
echo "⚠ Bandit scan completed - security issues found"
echo "scan_status=issues_found" >> $GITHUB_OUTPUT
else
echo "✗ Bandit scan failed with exit code $BANDIT_EXIT"
echo " This indicates a configuration error or missing directory"
exit $BANDIT_EXIT
fi
- name: "3.5 Analyze Security Results"
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const scanStatus = '${{ steps.bandit.outputs.scan_status }}';
if (!fs.existsSync('bandit-report.json')) {
core.setFailed('Bandit report not found - scan failed before producing output');
return;
}
// If scan was clean, we can skip detailed analysis
if (scanStatus === 'clean') {
console.log('\n' + '═'.repeat(60));
console.log(' BANDIT SECURITY SCAN RESULTS');
console.log('═'.repeat(60));
console.log('\n✓ No security issues found\n');
console.log('═'.repeat(60) + '\n');
return;
}
const report = JSON.parse(fs.readFileSync('bandit-report.json', 'utf8'));
const results = report.results || [];
// Categorize by severity
const high = results.filter(r => r.issue_severity === 'HIGH');
const medium = results.filter(r => r.issue_severity === 'MEDIUM');
const low = results.filter(r => r.issue_severity === 'LOW');
console.log('\n' + '═'.repeat(60));
console.log(' BANDIT SECURITY SCAN RESULTS');
console.log('═'.repeat(60));
console.log(`\n HIGH: ${high.length}`);
console.log(` MEDIUM: ${medium.length}`);
console.log(` LOW: ${low.length}\n`);
if (high.length > 0) {
console.log('─'.repeat(60));
console.log(' HIGH SEVERITY ISSUES:');
console.log('─'.repeat(60));
for (const issue of high) {
console.log(`\n 📍 ${issue.filename}:${issue.line_number}`);
console.log(` ${issue.issue_text}`);
console.log(` Test: ${issue.test_id} (${issue.test_name})`);
}
console.log('\n' + '═'.repeat(60));
core.setFailed(`Found ${high.length} high severity security issue(s)`);
} else {
console.log('✓ No high severity security issues found');
console.log('═'.repeat(60) + '\n');
}
# ┌─────────────────────────────────────────────────────────────────────────────┐
# │ STAGE 4: STATUS UPDATE │
# │ │
# │ Purpose: Aggregate results from all quality gates and update PR status │
# │ Runs: After ALL Stage 3 jobs complete (success, failure, or skipped) │
# │ Updates: PR label to "Ready for Review" or "Checks Failed" │
# └─────────────────────────────────────────────────────────────────────────────┘
stage-4-status:
name: "Stage 4: Update PR Status"
runs-on: ubuntu-latest
needs:
- stage-2-changes
- stage-3-test-python
- stage-3-test-frontend
- stage-3-lint-python
- stage-3-codeql
- stage-3-security-python
# Always run to update status, even if previous jobs failed or were skipped
# For scheduled runs, skip PR label updates (no PR context)
if: always() && (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch')
timeout-minutes: 5
steps:
- name: "4.1 Evaluate CI Results and Update PR"
uses: actions/github-script@v7
with:
script: |
const { owner, repo } = context.repo;
const eventName = '${{ github.event_name }}';
const isScheduledRun = eventName === 'schedule' || eventName === 'workflow_dispatch';
const prNumber = context.payload.pull_request?.number;
// ─────────────────────────────────────────────────────────────
// COLLECT RESULTS FROM ALL QUALITY GATE JOBS
// ─────────────────────────────────────────────────────────────
const results = {
'Python Tests': '${{ needs.stage-3-test-python.result }}',
'Frontend Tests': '${{ needs.stage-3-test-frontend.result }}',
'Python Lint': '${{ needs.stage-3-lint-python.result }}',
'CodeQL': '${{ needs.stage-3-codeql.result }}',
'Python Security': '${{ needs.stage-3-security-python.result }}'
};
const skipTests = '${{ needs.stage-2-changes.outputs.skip_tests }}' === 'true';
console.log('\n' + '═'.repeat(60));
if (isScheduledRun) {
console.log(' SCHEDULED SECURITY SCAN RESULTS');
} else {
console.log(' CI PIPELINE RESULTS');
}
console.log('═'.repeat(60) + '\n');
if (skipTests && !isScheduledRun) {
console.log(' ️ No code changes detected - tests were skipped\n');
}
console.log(' Job Results:');
console.log(' ' + '─'.repeat(40));
for (const [job, result] of Object.entries(results)) {
const icon = result === 'success' ? '✓' :
result === 'skipped' ? '○' :
result === 'failure' ? '✗' : '?';
console.log(` ${icon} ${job}: ${result}`);
}
console.log(' ' + '─'.repeat(40) + '\n');
// ─────────────────────────────────────────────────────────────
// DETERMINE FINAL STATUS
// Success and skipped are acceptable; failure is not
// ─────────────────────────────────────────────────────────────
const acceptable = ['success', 'skipped'];
const failed = Object.entries(results)
.filter(([_, result]) => !acceptable.includes(result))
.map(([job, _]) => job);
const statusLabels = {
checking: '🔄 Checking',
passed: '✅ Ready for Review',
failed: '❌ Checks Failed'
};
// ─────────────────────────────────────────────────────────────
// UPDATE PR LABELS (skip for scheduled runs - no PR context)
// ─────────────────────────────────────────────────────────────
if (!isScheduledRun && prNumber) {
// Remove all status labels first
for (const label of Object.values(statusLabels)) {
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: label
});
} catch (e) {
// Ignore 404 (label not present)
}
}
// Add appropriate status label
const newLabel = failed.length > 0 ? statusLabels.failed : statusLabels.passed;
try {
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: [newLabel]
});
} catch (e) {
if (e.status === 404) {
core.warning(`Label '${newLabel}' does not exist. Please create it in Settings > Labels.`);
}
}
}
// ─────────────────────────────────────────────────────────────
// GENERATE SUMMARY
// ─────────────────────────────────────────────────────────────
if (failed.length > 0) {
const resultType = isScheduledRun ? 'SECURITY SCAN FAILED' : 'CI FAILED';
console.log(` ❌ RESULT: ${resultType}`);
console.log(` Failed jobs: ${failed.join(', ')}`);
console.log('\n' + '═'.repeat(60) + '\n');
core.summary.addRaw(`## ❌ ${resultType}\n\n`);
core.summary.addRaw(`The following checks failed:\n`);
for (const job of failed) {
core.summary.addRaw(`- ${job}\n`);
}
core.setFailed(`${resultType}: ${failed.join(', ')}`);
} else if (isScheduledRun) {
console.log(` ✅ RESULT: SECURITY SCAN PASSED`);
console.log(` All security checks passed`);
console.log('\n' + '═'.repeat(60) + '\n');
core.summary.addRaw(`## ✅ Weekly Security Scan Passed\n\n`);
core.summary.addRaw(`All scheduled security checks (CodeQL, Bandit) completed successfully.\n`);
} else if (skipTests) {
console.log(` ✅ RESULT: READY FOR REVIEW`);
console.log(` No code changes - tests skipped`);
console.log('\n' + '═'.repeat(60) + '\n');
core.summary.addRaw(`## ✅ Ready for Review\n\n`);
core.summary.addRaw(`No code changes detected. Documentation or configuration changes only.\n`);
} else {
console.log(` ✅ RESULT: READY FOR REVIEW`);
console.log(` All quality gates passed`);
console.log('\n' + '═'.repeat(60) + '\n');
core.summary.addRaw(`## ✅ Ready for Review\n\n`);
core.summary.addRaw(`All CI checks passed successfully.\n`);
}
await core.summary.write();
+121
View File
@@ -0,0 +1,121 @@
name: Community
# Consolidated community automation:
# - Welcome messages for first-time contributors (issues & PRs)
# - Auto-label issues based on form selection
# - Mark and close stale issues
on:
issues:
types: [opened]
pull_request_target:
types: [opened]
schedule:
- cron: '0 0 * * 0' # Every Sunday at midnight UTC
workflow_dispatch: # Allow manual trigger for stale check
jobs:
# ═══════════════════════════════════════════════════════════════════════════
# WELCOME - First-time contributor welcome messages
# ═══════════════════════════════════════════════════════════════════════════
welcome:
name: Welcome New Contributors
runs-on: ubuntu-latest
if: github.event_name == 'pull_request_target' || github.event_name == 'issues'
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/first-interaction@v1.4.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
issue-message: |
Thanks for opening your first issue!
A maintainer will triage this soon. In the meantime:
- Make sure you've provided all the requested info
- Join our [Discord](https://discord.gg/QhRnz9m5HE) for faster help
pr-message: |
Thanks for your first PR!
A maintainer will review it soon. Please make sure:
- Your branch is synced with `develop`
- CI checks pass
- You've followed our [contribution guide](https://github.com/AndyMik90/Auto-Claude/blob/develop/CONTRIBUTING.md)
Welcome to the Auto Claude community!
# ═══════════════════════════════════════════════════════════════════════════
# ISSUE LABELS - Auto-label issues based on form area selection
# ═══════════════════════════════════════════════════════════════════════════
issue-labels:
name: Label Issue by Area
runs-on: ubuntu-latest
if: github.event_name == 'issues'
permissions:
issues: write
steps:
- name: Add area label from form
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
const body = issue.body || '';
console.log(`Processing issue #${issue.number}: ${issue.title}`);
// Map form selection to label
const areaMap = {
'Frontend': 'area/frontend',
'Backend': 'area/backend',
'Fullstack': 'area/fullstack'
};
const labels = [];
for (const [key, label] of Object.entries(areaMap)) {
if (body.includes(key)) {
console.log(`Found area: ${key}, adding label: ${label}`);
labels.push(label);
break;
}
}
if (labels.length > 0) {
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: labels
});
console.log(`Successfully added labels: ${labels.join(', ')}`);
} catch (error) {
core.setFailed(`Failed to add labels: ${error.message}`);
}
} else {
console.log('No matching area found in issue body');
}
# ═══════════════════════════════════════════════════════════════════════════
# STALE - Mark and close inactive issues
# ═══════════════════════════════════════════════════════════════════════════
stale:
name: Mark Stale Issues
runs-on: ubuntu-latest
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
permissions:
issues: write
steps:
- uses: actions/stale@v9
with:
stale-issue-message: |
This issue has been inactive for 60 days. It will be closed in 14 days if there's no activity.
- If this is still relevant, please comment or update the issue
- If you're working on this, add the `in-progress` label
close-issue-message: 'Closed due to inactivity. Feel free to reopen if still relevant.'
stale-issue-label: 'stale'
days-before-stale: 60
days-before-close: 14
exempt-issue-labels: 'priority/critical,priority/high,in-progress,blocked'
-53
View File
@@ -1,53 +0,0 @@
name: Issue Auto Label
on:
issues:
types: [opened]
jobs:
label-area:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Add area label from form
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
const body = issue.body || '';
console.log(`Processing issue #${issue.number}: ${issue.title}`);
// Map form selection to label
const areaMap = {
'Frontend': 'area/frontend',
'Backend': 'area/backend',
'Fullstack': 'area/fullstack'
};
const labels = [];
for (const [key, label] of Object.entries(areaMap)) {
if (body.includes(key)) {
console.log(`Found area: ${key}, adding label: ${label}`);
labels.push(label);
break;
}
}
if (labels.length > 0) {
try {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: labels
});
console.log(`Successfully added labels: ${labels.join(', ')}`);
} catch (error) {
core.setFailed(`Failed to add labels: ${error.message}`);
}
} else {
console.log('No matching area found in issue body');
}
-34
View File
@@ -1,34 +0,0 @@
name: Lint
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
concurrency:
group: lint-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
# Python linting
python:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
# Pin ruff version to match .pre-commit-config.yaml (astral-sh/ruff-pre-commit rev)
- name: Install ruff
run: pip install ruff==0.14.10
- name: Run ruff check
run: ruff check apps/backend/ --output-format=github
- name: Run ruff format check
run: ruff format apps/backend/ --check --diff
-320
View File
@@ -1,320 +0,0 @@
name: PR Labeler
on:
pull_request:
types: [opened, synchronize, reopened]
concurrency:
group: pr-labeler-${{ 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
# Security: Prevent fork PRs from modifying labels (they don't have write access)
if: github.event.pull_request.head.repo.full_name == github.repository
timeout-minutes: 5
steps:
- name: Label PR
uses: actions/github-script@v7
with:
retries: 3
retry-exempt-status-codes: 400,401,403,404,422
script: |
// ═══════════════════════════════════════════════════════════════
// CONFIGURATION - Single source of truth for all settings
// ═══════════════════════════════════════════════════════════════
const CONFIG = {
// Size thresholds (lines changed)
SIZE_THRESHOLDS: {
XS: 10,
S: 100,
M: 500,
L: 1000
},
// Conventional commit type mappings
TYPE_MAP: Object.freeze({
'feat': 'feature',
'fix': 'bug',
'docs': 'documentation',
'refactor': 'refactor',
'test': 'test',
'ci': 'ci',
'chore': 'chore',
'perf': 'performance',
'style': 'style',
'build': 'build'
}),
// Area detection paths
AREA_PATHS: Object.freeze({
frontend: 'apps/frontend/',
backend: 'apps/backend/',
ci: '.github/'
}),
// Label definitions
LABELS: Object.freeze({
SIZE: ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'],
AREA: ['area/frontend', 'area/backend', 'area/fullstack', 'area/ci'],
STATUS: ['🔄 Checking', '✅ Ready for Review', '❌ Checks Failed'],
REVIEW: ['Missing AC Approval', 'AC: Approved', 'AC: Changes Requested', 'AC: Needs Re-review']
}),
// Pagination
MAX_FILES_PER_PAGE: 100
};
// ═══════════════════════════════════════════════════════════════
// HELPER FUNCTIONS - Small, focused, single responsibility
// ═══════════════════════════════════════════════════════════════
/**
* Safely parse conventional commit type from PR title
* @param {string} title - PR title
* @returns {{type: string|null, isBreaking: boolean}}
*/
function parseConventionalCommit(title) {
if (!title || typeof title !== 'string') {
return { type: null, isBreaking: false };
}
// Limit input length to prevent ReDoS attacks
const safeTitle = title.slice(0, 200);
const match = safeTitle.match(/^(\w{1,20})(\([^)]{0,50}\))?(!)?:/);
if (!match) {
return { type: null, isBreaking: false };
}
return {
type: match[1].toLowerCase(),
isBreaking: match[3] === '!'
};
}
/**
* Determine size label based on lines changed
* @param {number} totalLines - Total lines changed
* @returns {string} Size label
*/
function determineSizeLabel(totalLines) {
const { SIZE_THRESHOLDS } = CONFIG;
if (totalLines < SIZE_THRESHOLDS.XS) return 'size/XS';
if (totalLines < SIZE_THRESHOLDS.S) return 'size/S';
if (totalLines < SIZE_THRESHOLDS.M) return 'size/M';
if (totalLines < SIZE_THRESHOLDS.L) return 'size/L';
return 'size/XL';
}
/**
* Detect areas affected by file changes
* @param {Array} files - List of changed files
* @returns {{frontend: boolean, backend: boolean, ci: boolean}}
*/
function detectAreas(files) {
const areas = { frontend: false, backend: false, ci: false };
const { AREA_PATHS } = CONFIG;
for (const file of files) {
const path = file.filename || '';
if (path.startsWith(AREA_PATHS.frontend)) areas.frontend = true;
if (path.startsWith(AREA_PATHS.backend)) areas.backend = true;
if (path.startsWith(AREA_PATHS.ci)) areas.ci = true;
}
return areas;
}
/**
* Determine area label based on detected areas
* @param {{frontend: boolean, backend: boolean, ci: boolean}} areas
* @returns {string|null} Area label or null
*/
function determineAreaLabel(areas) {
if (areas.frontend && areas.backend) return 'area/fullstack';
if (areas.frontend) return 'area/frontend';
if (areas.backend) return 'area/backend';
if (areas.ci) return 'area/ci';
return null;
}
/**
* Remove labels from PR (with error handling)
* @param {Array} labels - Labels to remove
* @param {number} prNumber - PR number
*/
async function removeLabels(labels, prNumber) {
const { owner, repo } = context.repo;
await Promise.allSettled(labels.map(async (label) => {
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: label
});
console.log(` ✓ Removed: ${label}`);
} catch (e) {
// 404 means label wasn't present - that's fine
if (e.status !== 404) {
console.log(` ⚠ Failed to remove ${label}: ${e.message}`);
}
}
}));
}
/**
* Add labels to PR (with error handling)
* @param {Array} labels - Labels to add
* @param {number} prNumber - PR number
*/
async function addLabels(labels, prNumber) {
if (labels.length === 0) return;
const { owner, repo } = context.repo;
try {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels
});
console.log(` ✓ Added: ${labels.join(', ')}`);
} catch (e) {
if (e.status === 404) {
core.warning(`One or more labels do not exist. Create them in repository settings.`);
} else {
throw e;
}
}
}
/**
* Fetch PR files with full pagination support
* @param {number} prNumber - PR number
* @returns {Array} List of all files (paginated)
*/
async function fetchPRFiles(prNumber) {
const { owner, repo } = context.repo;
try {
// Use paginate to fetch ALL files, not just first 100
const files = await github.paginate(
github.rest.pulls.listFiles,
{ owner, repo, pull_number: prNumber, per_page: CONFIG.MAX_FILES_PER_PAGE }
);
return files;
} catch (e) {
console.log(` ⚠ Could not fetch files: ${e.message}`);
return [];
}
}
// ═══════════════════════════════════════════════════════════════
// MAIN LOGIC - Orchestrates the labeling process
// ═══════════════════════════════════════════════════════════════
const { owner, repo } = context.repo;
const pr = context.payload.pull_request;
const prNumber = pr.number;
const title = pr.title || '';
const isNewPR = context.payload.action === 'opened' || context.payload.action === 'reopened';
console.log(`::group::PR #${prNumber} - Auto-labeling`);
console.log(`Title: ${title.slice(0, 100)}${title.length > 100 ? '...' : ''}`);
console.log(`Action: ${context.payload.action}`);
const labelsToAdd = new Set();
const labelsToRemove = new Set();
// 1. Parse conventional commit type
const { type, isBreaking } = parseConventionalCommit(title);
if (type && CONFIG.TYPE_MAP[type]) {
labelsToAdd.add(CONFIG.TYPE_MAP[type]);
console.log(` 📝 Type: ${type} → ${CONFIG.TYPE_MAP[type]}`);
} else {
console.log(` ️ No conventional commit prefix detected`);
}
if (isBreaking) {
labelsToAdd.add('breaking-change');
console.log(` ⚠️ Breaking change detected`);
}
// 2. Detect areas from changed files
const files = await fetchPRFiles(prNumber);
const areas = detectAreas(files);
const areaLabel = determineAreaLabel(areas);
if (areaLabel) {
labelsToAdd.add(areaLabel);
CONFIG.LABELS.AREA.filter(l => l !== areaLabel).forEach(l => labelsToRemove.add(l));
console.log(` 📁 Area: ${areaLabel.replace('area/', '')}`);
}
// 3. Calculate size label
const totalLines = (pr.additions || 0) + (pr.deletions || 0);
const sizeLabel = determineSizeLabel(totalLines);
labelsToAdd.add(sizeLabel);
CONFIG.LABELS.SIZE.filter(l => l !== sizeLabel).forEach(l => labelsToRemove.add(l));
console.log(` 📏 Size: ${sizeLabel} (${totalLines} lines)`);
// 4. Set status label (only on new PRs - let pr-status-gate handle updates on pushes)
// Note: On synchronize events, CI workflows will trigger pr-status-gate when they complete
if (isNewPR) {
labelsToAdd.add('🔄 Checking');
CONFIG.LABELS.STATUS.filter(l => l !== '🔄 Checking').forEach(l => labelsToRemove.add(l));
console.log(` 🔄 Status: Checking`);
} else {
console.log(` ️ Status: Unchanged (will be updated by pr-status-gate)`);
}
// 5. Add review label for new PRs only
if (isNewPR) {
labelsToAdd.add('Missing AC Approval');
console.log(` ⏳ Review: Missing AC Approval`);
}
console.log('::endgroup::');
// 6. Apply label changes
console.log(`::group::Applying labels`);
// Remove labels that should be replaced (exclude ones we're adding)
const removeList = [...labelsToRemove].filter(l => !labelsToAdd.has(l));
await removeLabels(removeList, prNumber);
// Add new labels
await addLabels([...labelsToAdd], prNumber);
console.log('::endgroup::');
console.log(`✅ PR #${prNumber} labeled successfully`);
// 7. Write job summary
const summaryType = type ? CONFIG.TYPE_MAP[type] || 'unknown' : 'none';
const summaryArea = areaLabel ? areaLabel.replace('area/', '') : 'other';
await core.summary
.addHeading(`PR #${prNumber} Auto-Labels`, 3)
.addTable([
[{ data: 'Category', header: true }, { data: 'Label', header: true }],
['Type', summaryType],
['Area', summaryArea],
['Size', sizeLabel],
['Status', isNewPR ? '🔄 Checking' : '(unchanged)'],
['Review', isNewPR ? 'Missing AC Approval' : '(unchanged)']
])
.addRaw(`\n**Files:** ${files.length} | **Lines:** +${pr.additions || 0} / -${pr.deletions || 0}\n`)
.write();
-585
View File
@@ -1,585 +0,0 @@
name: PR Status Gate
on:
workflow_run:
workflows: [CI, Lint, Quality Security]
types: [completed]
issue_comment:
types: [created, edited]
pull_request:
types: [synchronize]
concurrency:
group: pr-status-gate-${{ github.event.workflow_run.pull_requests[0].number || github.event.issue.number || github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
permissions:
pull-requests: write
checks: read
env:
# Shared configuration - single source of truth
REQUIRED_CHECKS: |
CI / test-frontend
CI / test-python (3.12)
CI / test-python (3.13)
Lint / python
Quality Security / CodeQL (javascript-typescript)
Quality Security / CodeQL (python)
Quality Security / Python Security (Bandit)
Quality Security / Security Summary
jobs:
# ═══════════════════════════════════════════════════════════════════════════
# JOB 1: CI STATUS (triggered by workflow_run)
# Updates CI status labels when monitored workflows complete
# ═══════════════════════════════════════════════════════════════════════════
update-ci-status:
name: Update CI Status
runs-on: ubuntu-latest
if: github.event_name == 'workflow_run' && 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
env:
REQUIRED_CHECKS: ${{ env.REQUIRED_CHECKS }}
with:
retries: 3
retry-exempt-status-codes: 400,401,403,404,422
script: |
// NOTE: STATUS_LABELS is intentionally duplicated across jobs.
// GitHub Actions jobs run in isolated contexts and cannot share runtime constants.
// If label values change, update ALL occurrences: update-ci-status, check-status-command
const STATUS_LABELS = Object.freeze({
CHECKING: '🔄 Checking',
PASSED: '✅ Ready for Review',
FAILED: '❌ Checks Failed'
});
const REQUIRED_CHECKS = process.env.REQUIRED_CHECKS
.split('\n')
.map(s => s.trim())
.filter(Boolean);
async function fetchCheckRuns(sha) {
const { owner, repo } = context.repo;
// Let the configured retries (retries: 3) handle transient failures
// Don't catch errors - allow them to propagate for retry logic
const checkRuns = await github.paginate(
github.rest.checks.listForRef,
{ owner, repo, ref: sha, per_page: 100 },
(response) => response.data
);
return checkRuns;
}
function analyzeChecks(checkRuns) {
const results = [];
let allComplete = true;
let anyFailed = false;
for (const checkName of REQUIRED_CHECKS) {
const check = checkRuns.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') {
results.push({ name: checkName, status: '⏭️ Skipped', complete: true, skipped: true });
} else {
results.push({ name: checkName, status: '❌ Failed', complete: true, failed: true });
anyFailed = true;
}
}
return { allComplete, anyFailed, results };
}
async function updateStatusLabels(prNumber, newLabel) {
const { owner, repo } = context.repo;
const allLabels = Object.values(STATUS_LABELS);
// Remove all status labels first - throw on non-404 errors to prevent conflicting labels
for (const label of allLabels) {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label });
} catch (e) {
if (e && e.status !== 404) {
// Throw to prevent adding new label if removal failed (could cause conflicting labels)
throw new Error(`Failed to remove label '${label}': ${e.message}`);
}
}
}
try {
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [newLabel] });
} catch (e) {
if (e && e.status === 404) {
core.warning(`Label '${newLabel}' does not exist`);
} else {
throw e;
}
}
}
// Main logic
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;
console.log(`PR #${prNumber} - Triggered by: ${triggerWorkflow}, SHA: ${headSha.slice(0, 8)}`);
const checkRuns = await fetchCheckRuns(headSha);
console.log(`Found ${checkRuns.length} check runs`);
const { allComplete, anyFailed, results } = analyzeChecks(checkRuns);
for (const r of results) {
console.log(` ${r.status} ${r.name}`);
}
if (!allComplete) {
const pending = results.filter(r => !r.complete).length;
console.log(`⏳ ${pending}/${REQUIRED_CHECKS.length} checks pending`);
// Update to CHECKING status if checks are still running (prevents stale Ready/Failed status)
await updateStatusLabels(prNumber, STATUS_LABELS.CHECKING);
return;
}
const newLabel = anyFailed ? STATUS_LABELS.FAILED : STATUS_LABELS.PASSED;
await updateStatusLabels(prNumber, newLabel);
const passedCount = results.filter(r => r.status === '✅ Passed').length;
const failedCount = results.filter(r => r.failed).length;
if (anyFailed) {
console.log(`❌ PR #${prNumber}: ${failedCount} check(s) failed`);
} else {
console.log(`✅ PR #${prNumber}: Ready for review (${passedCount}/${REQUIRED_CHECKS.length} passed)`);
}
# ═══════════════════════════════════════════════════════════════════════════
# JOB 2: /check-status COMMAND
# Manual status check - anyone can trigger by commenting /check-status
# ═══════════════════════════════════════════════════════════════════════════
check-status-command:
name: Check Status Command
runs-on: ubuntu-latest
if: |
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
contains(github.event.comment.body, '/check-status')
timeout-minutes: 5
steps:
- name: Run status check and post report
uses: actions/github-script@v7
env:
REQUIRED_CHECKS: ${{ env.REQUIRED_CHECKS }}
with:
retries: 3
retry-exempt-status-codes: 400,401,403,404,422
script: |
// NOTE: STATUS_LABELS is intentionally duplicated across jobs.
// GitHub Actions jobs run in isolated contexts and cannot share runtime constants.
// If label values change, update ALL occurrences: update-ci-status, check-status-command
const STATUS_LABELS = Object.freeze({
CHECKING: '🔄 Checking',
PASSED: '✅ Ready for Review',
FAILED: '❌ Checks Failed'
});
// NOTE: REVIEW_LABELS is intentionally duplicated across jobs.
// If label values change, update ALL occurrences: check-status-command, update-review-status
const REVIEW_LABELS = Object.freeze([
'Missing AC Approval',
'AC: Approved',
'AC: Changes Requested',
'AC: Blocked',
'AC: Needs Re-review',
'AC: Reviewed'
]);
const REQUIRED_CHECKS = process.env.REQUIRED_CHECKS
.split('\n')
.map(s => s.trim())
.filter(Boolean);
const { owner, repo } = context.repo;
const prNumber = context.payload.issue.number;
const requestedBy = context.payload.comment.user.login;
// Get PR details
const { data: pr } = await github.rest.pulls.get({
owner, repo, pull_number: prNumber
});
const headSha = pr.head.sha;
console.log(`PR #${prNumber} - /check-status by @${requestedBy}, SHA: ${headSha.slice(0, 8)}`);
// Fetch check runs with pagination to handle >100 checks
const checkRuns = await github.paginate(
github.rest.checks.listForRef,
{ owner, repo, ref: headSha, per_page: 100 },
(response) => response.data
);
console.log(`Found ${checkRuns.length} check runs`);
// Analyze results
const results = [];
let allComplete = true;
let anyFailed = false;
for (const checkName of REQUIRED_CHECKS) {
const check = checkRuns.find(c => c.name === checkName);
if (!check) {
results.push({ name: checkName, emoji: '⏳', complete: false });
allComplete = false;
} else if (check.status !== 'completed') {
results.push({ name: checkName, emoji: '🔄', complete: false });
allComplete = false;
} else if (check.conclusion === 'success') {
results.push({ name: checkName, emoji: '✅', complete: true });
} else if (check.conclusion === 'skipped') {
results.push({ name: checkName, emoji: '⏭️', complete: true, skipped: true });
} else {
results.push({ name: checkName, emoji: '❌', complete: true, failed: true });
anyFailed = true;
}
}
// Get current labels
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
owner, repo, issue_number: prNumber
});
const labelNames = currentLabels.map(l => l.name);
const currentStatusLabel = Object.values(STATUS_LABELS).find(l => labelNames.includes(l)) || 'None';
const currentReviewLabel = REVIEW_LABELS.find(l => labelNames.includes(l)) || 'None';
// Update label if all checks complete
let newStatusLabel = STATUS_LABELS.CHECKING;
let statusChanged = false;
if (allComplete) {
newStatusLabel = anyFailed ? STATUS_LABELS.FAILED : STATUS_LABELS.PASSED;
if (newStatusLabel !== currentStatusLabel) {
statusChanged = true;
// Remove all status labels first - throw on non-404 errors to prevent conflicting labels
for (const label of Object.values(STATUS_LABELS)) {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label });
} catch (e) {
if (e && e.status !== 404) {
throw new Error(`Failed to remove label '${label}': ${e.message}`);
}
}
}
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [newStatusLabel] });
}
}
// Build status report
const passedCount = results.filter(r => r.emoji === '✅').length;
let statusEmoji = '🔄';
if (allComplete && !anyFailed) statusEmoji = '✅';
else if (allComplete && anyFailed) statusEmoji = '❌';
const checksTable = results.map(r => `| ${r.emoji} | ${r.name} |`).join('\n');
const lines = [
`## ${statusEmoji} PR Status Report`,
'',
`| Label | Value |`,
`|-------|-------|`,
`| CI Status | ${newStatusLabel} |`,
`| AC Review | ${currentReviewLabel} |`,
''
];
if (statusChanged) {
lines.push(`> Status updated: \`${currentStatusLabel}\` → \`${newStatusLabel}\``);
lines.push('');
}
lines.push(`### CI Checks (${passedCount}/${REQUIRED_CHECKS.length} passed)`);
lines.push('');
lines.push('| Status | Check |');
lines.push('|--------|-------|');
lines.push(checksTable);
lines.push('');
lines.push('---');
lines.push(`<sub>Triggered by \`/check-status\` from @${requestedBy}</sub>`);
await github.rest.issues.createComment({
owner, repo, issue_number: prNumber, body: lines.join('\n')
});
console.log(`✅ Posted status report to PR #${prNumber}`);
# ═══════════════════════════════════════════════════════════════════════════
# JOB 3: AUTO-CLAUDE REVIEW
# Processes Auto-Claude review comments from trusted sources
# Security: Only bots and collaborators can update labels
# ═══════════════════════════════════════════════════════════════════════════
update-review-status:
name: Update Review Status
runs-on: ubuntu-latest
if: |
github.event_name == 'issue_comment' &&
github.event.issue.pull_request &&
!contains(github.event.comment.body, '/check-status')
timeout-minutes: 5
steps:
- name: Check for Auto-Claude review
uses: actions/github-script@v7
with:
retries: 3
retry-exempt-status-codes: 400,401,403,404,422
script: |
// Security configuration
// SECURITY: Only [bot] suffixed accounts are protected by GitHub.
// Regular usernames can be registered by anyone and are NOT trusted.
const TRUSTED_BOT_ACCOUNTS = Object.freeze([
'github-actions[bot]',
'auto-claude[bot]'
]);
const TRUSTED_AUTHOR_ASSOCIATIONS = Object.freeze([
'COLLABORATOR',
'MEMBER',
'OWNER'
]);
const IDENTIFIER_PATTERNS = Object.freeze([
'🤖 Auto Claude PR Review',
'Auto Claude Review',
'Auto-Claude Review'
]);
// SECURITY: Regex patterns are tightened to prevent false matches
// Using \s* instead of .* and requiring specific emoji + verdict format
const VERDICTS = Object.freeze({
APPROVED: {
patterns: ['Auto Claude Review - APPROVED', '✅ Auto Claude Review - APPROVED'],
// Match: "Merge Verdict:" followed by whitespace/emoji, then ✅, then APPROVED/READY TO MERGE
regex: /Merge Verdict:\s*✅\s*(?:APPROVED|READY TO MERGE)/i,
label: 'AC: Approved'
},
CHANGES_REQUESTED: {
patterns: ['NEEDS REVISION', 'Needs Revision'],
// Match: "Merge Verdict:" followed by whitespace/emoji, then 🟠
regex: /Merge Verdict:\s*🟠/,
label: 'AC: Changes Requested'
},
BLOCKED: {
patterns: ['BLOCKED'],
// Match: "Merge Verdict:" followed by whitespace/emoji, then 🔴
regex: /Merge Verdict:\s*🔴/,
label: 'AC: Blocked'
}
});
// NOTE: REVIEW_LABELS is intentionally duplicated across jobs.
// GitHub Actions jobs run in isolated contexts and cannot share runtime constants.
// If label values change, update ALL occurrences: check-status-command, update-review-status
const REVIEW_LABELS = Object.freeze([
'Missing AC Approval',
'AC: Approved',
'AC: Changes Requested',
'AC: Blocked',
'AC: Needs Re-review',
'AC: Reviewed'
]);
// Helper functions
// SECURITY: Verify both username AND account type to prevent spoofing
function isTrustedBot(username, userType) {
const isKnownBot = TRUSTED_BOT_ACCOUNTS.some(t => username.toLowerCase() === t.toLowerCase());
// Only trust if it's a known bot account AND GitHub confirms it's a Bot type
return isKnownBot && userType === 'Bot';
}
function isTrustedAssociation(assoc) {
return TRUSTED_AUTHOR_ASSOCIATIONS.includes(assoc);
}
function isAutoClaudeComment(body) {
return IDENTIFIER_PATTERNS.some(p => body.includes(p));
}
function parseVerdict(body) {
const safeBody = body.slice(0, 5000);
for (const [key, config] of Object.entries(VERDICTS)) {
const patternMatch = config.patterns.some(p => safeBody.includes(p));
const regexMatch = config.regex && config.regex.test(safeBody);
if (patternMatch || regexMatch) {
return { verdict: key, label: config.label };
}
}
return null;
}
async function updateReviewLabels(prNumber, newLabel) {
const { owner, repo } = context.repo;
// Remove all review labels first - throw on non-404 errors to prevent conflicting labels
for (const label of REVIEW_LABELS) {
try {
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label });
console.log(` Removed: ${label}`);
} catch (e) {
if (e && e.status !== 404) {
// Throw to prevent adding new label if removal failed (could cause conflicting labels)
throw new Error(`Failed to remove label '${label}': ${e.message}`);
}
}
}
try {
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [newLabel] });
console.log(` Added: ${newLabel}`);
} catch (e) {
if (e && e.status === 404) {
core.warning(`Label '${newLabel}' does not exist`);
} else {
throw e;
}
}
}
// Main logic
const prNumber = context.payload.issue.number;
const comment = context.payload.comment;
const commenter = comment.user.login;
const commenterType = comment.user.type;
const authorAssociation = comment.author_association;
const body = comment.body || '';
console.log(`PR #${prNumber} - Comment by: ${commenter} (type: ${commenterType}, assoc: ${authorAssociation})`);
// Security checks
// SECURITY: Bot status requires BOTH username match AND verified Bot type
const isBot = isTrustedBot(commenter, commenterType);
const isCollaborator = isTrustedAssociation(authorAssociation);
const isACComment = isAutoClaudeComment(body);
console.log(` Trusted bot: ${isBot}, Collaborator: ${isCollaborator}, AC comment: ${isACComment}`);
if (!isBot && !isCollaborator) {
console.log('Skipping: Not a trusted bot or collaborator');
return;
}
if (!isACComment) {
console.log('Skipping: Not an Auto-Claude comment');
return;
}
const verdictResult = parseVerdict(body);
if (!verdictResult) {
console.log('Skipping: Could not parse verdict');
return;
}
console.log(`Verdict: ${verdictResult.verdict} → ${verdictResult.label}`);
await updateReviewLabels(prNumber, verdictResult.label);
console.log(`✅ PR #${prNumber} review status updated`);
# ═══════════════════════════════════════════════════════════════════════════
# JOB 4: RE-REVIEW ON PUSH
# When new commits pushed after AC approval, require re-review
# ═══════════════════════════════════════════════════════════════════════════
require-re-review:
name: Require Re-review on Push
runs-on: ubuntu-latest
if: github.event_name == 'pull_request' && github.event.action == 'synchronize'
timeout-minutes: 5
steps:
- name: Check and reset AC approval if needed
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 pusher = context.payload.sender.login;
console.log(`PR #${prNumber} - New commits by: ${pusher}`);
// Get current labels
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
owner, repo, issue_number: prNumber
});
const labelNames = labels.map(l => l.name);
// Check if PR was approved
const wasApproved = labelNames.includes('AC: Approved');
if (!wasApproved) {
console.log('PR was not AC-approved, no action needed');
return;
}
console.log('PR was AC-approved, resetting to require re-review');
// Remove AC: Approved - throw on non-404 errors to prevent conflicting labels
try {
await github.rest.issues.removeLabel({
owner, repo, issue_number: prNumber, name: 'AC: Approved'
});
console.log(' Removed: AC: Approved');
} catch (e) {
if (e && e.status !== 404) {
// Throw to prevent adding 'AC: Needs Re-review' if removal failed (could cause conflicting labels)
core.error(`Failed to remove 'AC: Approved' label: ${e.message}`);
throw e;
}
}
// Add AC: Needs Re-review
try {
await github.rest.issues.addLabels({
owner, repo, issue_number: prNumber, labels: ['AC: Needs Re-review']
});
console.log(' Added: AC: Needs Re-review');
} catch (e) {
if (e && e.status === 404) {
core.warning("Label 'AC: Needs Re-review' does not exist");
} else {
throw e;
}
}
// Post notification comment
const commentLines = [
'## 🔄 Re-review Required',
'',
'New commits were pushed after Auto-Claude approval.',
'',
'| Previous | Current |',
'|----------|---------|',
'| `AC: Approved` | `AC: Needs Re-review` |',
'',
'Please run Auto-Claude review again or request a manual review.',
'',
'---',
`<sub>Triggered by push from @${pusher}</sub>`
];
await github.rest.issues.createComment({
owner, repo, issue_number: prNumber, body: commentLines.join('\n')
});
console.log(`✅ Posted re-review notification to PR #${prNumber}`);
+64
View File
@@ -34,6 +34,70 @@ jobs:
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Package version: $VERSION"
# Validate all version files are in sync before proceeding
- name: Validate version sync
run: |
echo "Validating version synchronization across all files..."
ROOT_VERSION=$(node -p "require('./package.json').version")
FRONTEND_VERSION=$(node -p "require('./apps/frontend/package.json').version")
# Extract Python version - handles both formats: __version__ = "X.Y.Z" or __version__="X.Y.Z"
BACKEND_VERSION=$(grep -oP '__version__\s*=\s*["\x27]\K[^"\x27]+' apps/backend/__init__.py || echo "NOT_FOUND")
echo "=========================================="
echo "Version Sync Validation"
echo "=========================================="
echo "Root package.json: $ROOT_VERSION"
echo "Frontend package.json: $FRONTEND_VERSION"
echo "Backend __init__.py: $BACKEND_VERSION"
echo "=========================================="
ERRORS=0
if [ "$ROOT_VERSION" != "$FRONTEND_VERSION" ]; then
echo "::error::Version mismatch: root package.json ($ROOT_VERSION) != frontend package.json ($FRONTEND_VERSION)"
ERRORS=$((ERRORS + 1))
fi
if [ "$BACKEND_VERSION" = "NOT_FOUND" ]; then
echo "::error::Could not extract version from apps/backend/__init__.py"
ERRORS=$((ERRORS + 1))
elif [ "$ROOT_VERSION" != "$BACKEND_VERSION" ]; then
echo "::error::Version mismatch: root package.json ($ROOT_VERSION) != backend __init__.py ($BACKEND_VERSION)"
ERRORS=$((ERRORS + 1))
fi
if [ $ERRORS -gt 0 ]; then
echo ""
echo "::error::═══════════════════════════════════════════════════════════════════════"
echo "::error:: VERSION SYNC FAILED"
echo "::error::═══════════════════════════════════════════════════════════════════════"
echo "::error::"
echo "::error:: All version files must be in sync before releasing."
echo "::error::"
echo "::error:: To fix this, use the bump-version script:"
echo "::error:: node scripts/bump-version.js <patch|minor|major|X.Y.Z>"
echo "::error::"
echo "::error:: This will update all version files automatically."
echo "::error::═══════════════════════════════════════════════════════════════════════"
# Add to job summary
echo "## Version Sync Failed" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| File | Version |" >> $GITHUB_STEP_SUMMARY
echo "|------|---------|" >> $GITHUB_STEP_SUMMARY
echo "| package.json | $ROOT_VERSION |" >> $GITHUB_STEP_SUMMARY
echo "| apps/frontend/package.json | $FRONTEND_VERSION |" >> $GITHUB_STEP_SUMMARY
echo "| apps/backend/__init__.py | $BACKEND_VERSION |" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Use \`node scripts/bump-version.js <version>\` to sync all files." >> $GITHUB_STEP_SUMMARY
exit 1
fi
echo ""
echo "All version files are in sync: $ROOT_VERSION"
- name: Get latest tag version
id: latest_tag
run: |
-178
View File
@@ -1,178 +0,0 @@
name: Quality Security
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
schedule:
- cron: '0 0 * * 1' # Weekly on Monday at midnight UTC
# Cancel in-progress runs for the same branch/PR
concurrency:
group: security-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
security-events: write
actions: read
jobs:
codeql:
name: CodeQL (${{ matrix.language }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
language: [python, javascript-typescript]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
queries: +security-extended,security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"
python-security:
name: Python Security (Bandit)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install Bandit
run: pip install bandit
- name: Run Bandit security scan
id: bandit
run: |
echo "::group::Running Bandit security scan"
# Run Bandit; exit code 1 means issues found (expected), other codes are errors
# Flags: -r=recursive, -ll=severity LOW+, -ii=confidence LOW+, -f=format, -o=output
bandit -r apps/backend/ -ll -ii -f json -o bandit-report.json || BANDIT_EXIT=$?
if [ "${BANDIT_EXIT:-0}" -gt 1 ]; then
echo "::error::Bandit scan failed with exit code $BANDIT_EXIT"
exit 1
fi
echo "::endgroup::"
- name: Analyze Bandit results
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
// Check if report exists
if (!fs.existsSync('bandit-report.json')) {
core.setFailed('Bandit report not found - scan may have failed');
return;
}
const report = JSON.parse(fs.readFileSync('bandit-report.json', 'utf8'));
const results = report.results || [];
// Categorize by severity
const high = results.filter(r => r.issue_severity === 'HIGH');
const medium = results.filter(r => r.issue_severity === 'MEDIUM');
const low = results.filter(r => r.issue_severity === 'LOW');
console.log(`::group::Bandit Security Scan Results`);
console.log(`Found ${results.length} issues:`);
console.log(` 🔴 HIGH: ${high.length}`);
console.log(` 🟡 MEDIUM: ${medium.length}`);
console.log(` 🟢 LOW: ${low.length}`);
console.log('');
// Print high severity issues
if (high.length > 0) {
console.log('High Severity Issues:');
console.log('─'.repeat(60));
for (const issue of high) {
console.log(` ${issue.filename}:${issue.line_number}`);
console.log(` ${issue.issue_text}`);
console.log(` Test: ${issue.test_id} (${issue.test_name})`);
console.log('');
}
}
console.log('::endgroup::');
// Build summary
let summary = `## 🔒 Python Security Scan (Bandit)\n\n`;
summary += `| Severity | Count |\n`;
summary += `|----------|-------|\n`;
summary += `| 🔴 High | ${high.length} |\n`;
summary += `| 🟡 Medium | ${medium.length} |\n`;
summary += `| 🟢 Low | ${low.length} |\n\n`;
if (high.length > 0) {
summary += `### High Severity Issues\n\n`;
for (const issue of high) {
summary += `- **${issue.filename}:${issue.line_number}**\n`;
summary += ` - ${issue.issue_text}\n`;
summary += ` - Test: \`${issue.test_id}\` (${issue.test_name})\n\n`;
}
}
core.summary.addRaw(summary);
await core.summary.write();
// Fail if high severity issues found
if (high.length > 0) {
core.setFailed(`Found ${high.length} high severity security issue(s)`);
} else {
console.log('✅ No high severity security issues found');
}
# Summary job that waits for all security checks
security-summary:
name: Security Summary
runs-on: ubuntu-latest
needs: [codeql, python-security]
if: always()
timeout-minutes: 5
steps:
- name: Check security results
uses: actions/github-script@v7
with:
script: |
const codeql = '${{ needs.codeql.result }}';
const bandit = '${{ needs.python-security.result }}';
console.log('Security Check Results:');
console.log(` CodeQL: ${codeql}`);
console.log(` Bandit: ${bandit}`);
// Only 'failure' is a real failure; 'skipped' is acceptable (e.g., path filters)
const acceptable = ['success', 'skipped'];
const codeqlOk = acceptable.includes(codeql);
const banditOk = acceptable.includes(bandit);
const allPassed = codeqlOk && banditOk;
if (allPassed) {
console.log('\n✅ All security checks passed');
core.summary.addRaw('## ✅ Security Checks Passed\n\nAll security scans completed successfully.');
} else {
console.log('\n❌ Some security checks failed');
core.summary.addRaw('## ❌ Security Checks Failed\n\nOne or more security scans found issues.');
core.setFailed('Security checks failed');
}
await core.summary.write();
+2 -147
View File
@@ -64,10 +64,6 @@ jobs:
- name: Build application
run: cd apps/frontend && npm run build
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Package macOS (Intel)
run: cd apps/frontend && npm run package:mac -- --x64
@@ -75,9 +71,6 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Notarize macOS Intel app
env:
@@ -158,10 +151,6 @@ jobs:
- name: Build application
run: cd apps/frontend && npm run build
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Package macOS (Apple Silicon)
run: cd apps/frontend && npm run package:mac -- --arm64
@@ -169,9 +158,6 @@ jobs:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Notarize macOS ARM64 app
env:
@@ -207,12 +193,6 @@ jobs:
build-windows:
runs-on: windows-latest
permissions:
id-token: write # Required for OIDC authentication with Azure
contents: read
env:
# Job-level env so AZURE_CLIENT_ID is available for step-level if conditions
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
steps:
- uses: actions/checkout@v4
@@ -258,131 +238,13 @@ jobs:
- name: Build application
run: cd apps/frontend && npm run build
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Package Windows
run: cd apps/frontend && npm run package:win
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Disable electron-builder's built-in signing (we use Azure Trusted Signing instead)
CSC_IDENTITY_AUTO_DISCOVERY: false
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Azure Login (OIDC)
if: env.AZURE_CLIENT_ID != ''
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Sign Windows executable with Azure Trusted Signing
if: env.AZURE_CLIENT_ID != ''
uses: azure/trusted-signing-action@v0.5.11
with:
endpoint: https://neu.codesigning.azure.net/
trusted-signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
certificate-profile-name: ${{ secrets.AZURE_CERTIFICATE_PROFILE }}
files-folder: apps/frontend/dist
files-folder-filter: exe
file-digest: SHA256
timestamp-rfc3161: http://timestamp.acs.microsoft.com
timestamp-digest: SHA256
- name: Verify Windows executable is signed
if: env.AZURE_CLIENT_ID != ''
shell: pwsh
run: |
cd apps/frontend/dist
$exeFile = Get-ChildItem -Filter "*.exe" | Select-Object -First 1
if ($exeFile) {
Write-Host "Verifying signature on $($exeFile.Name)..."
$sig = Get-AuthenticodeSignature -FilePath $exeFile.FullName
if ($sig.Status -ne 'Valid') {
Write-Host "::error::Signature verification failed: $($sig.Status)"
Write-Host "::error::Status Message: $($sig.StatusMessage)"
exit 1
}
Write-Host "✅ Signature verified successfully"
Write-Host " Subject: $($sig.SignerCertificate.Subject)"
Write-Host " Issuer: $($sig.SignerCertificate.Issuer)"
Write-Host " Thumbprint: $($sig.SignerCertificate.Thumbprint)"
} else {
Write-Host "::error::No .exe file found to verify"
exit 1
}
- name: Regenerate checksums after signing
if: env.AZURE_CLIENT_ID != ''
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
cd apps/frontend/dist
# Find the installer exe (electron-builder names it with "Setup" or just the app name)
# electron-builder produces one installer exe per build
$exeFiles = Get-ChildItem -Filter "*.exe"
if ($exeFiles.Count -eq 0) {
Write-Host "::error::No .exe files found in dist folder"
exit 1
}
Write-Host "Found $($exeFiles.Count) exe file(s): $($exeFiles.Name -join ', ')"
$ymlFile = "latest.yml"
if (-not (Test-Path $ymlFile)) {
Write-Host "::error::$ymlFile not found - cannot update checksums"
exit 1
}
$content = Get-Content $ymlFile -Raw
$originalContent = $content
# Process each exe file and update its hash in latest.yml
foreach ($exeFile in $exeFiles) {
Write-Host "Processing $($exeFile.Name)..."
# Compute SHA512 hash and convert to base64 (electron-builder format)
$bytes = [System.IO.File]::ReadAllBytes($exeFile.FullName)
$sha512 = [System.Security.Cryptography.SHA512]::Create()
$hashBytes = $sha512.ComputeHash($bytes)
$hash = [System.Convert]::ToBase64String($hashBytes)
$size = $exeFile.Length
Write-Host " Hash: $hash"
Write-Host " Size: $size"
}
# For electron-builder, latest.yml has a single file entry for the installer
# Update the sha512 and size for the primary exe (first one, typically the installer)
$primaryExe = $exeFiles | Select-Object -First 1
$bytes = [System.IO.File]::ReadAllBytes($primaryExe.FullName)
$sha512 = [System.Security.Cryptography.SHA512]::Create()
$hashBytes = $sha512.ComputeHash($bytes)
$hash = [System.Convert]::ToBase64String($hashBytes)
$size = $primaryExe.Length
# Update sha512 hash (base64 pattern: alphanumeric, +, /, =)
$content = $content -replace 'sha512: [A-Za-z0-9+/=]+', "sha512: $hash"
# Update size
$content = $content -replace 'size: \d+', "size: $size"
if ($content -eq $originalContent) {
Write-Host "::error::Checksum replacement failed - content unchanged. Check if latest.yml format has changed."
exit 1
}
Set-Content -Path $ymlFile -Value $content -NoNewline
Write-Host "✅ Updated $ymlFile with new base64 hash and size for $($primaryExe.Name)"
- name: Skip signing notice
if: env.AZURE_CLIENT_ID == ''
run: echo "::warning::Windows signing skipped - AZURE_CLIENT_ID not configured. The .exe will be unsigned."
CSC_LINK: ${{ secrets.WIN_CERTIFICATE }}
CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTIFICATE_PASSWORD }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -447,18 +309,11 @@ jobs:
- name: Build application
run: cd apps/frontend && npm run build
env:
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Package Linux
run: cd apps/frontend && npm run package:linux
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
-25
View File
@@ -1,25 +0,0 @@
name: Stale Issues
on:
schedule:
- cron: '0 0 * * 0' # Every Sunday
workflow_dispatch:
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- uses: actions/stale@v9
with:
stale-issue-message: |
This issue has been inactive for 60 days. It will be closed in 14 days if there's no activity.
- If this is still relevant, please comment or update the issue
- If you're working on this, add the `in-progress` label
close-issue-message: 'Closed due to inactivity. Feel free to reopen if still relevant.'
stale-issue-label: 'stale'
days-before-stale: 60
days-before-close: 14
exempt-issue-labels: 'priority/critical,priority/high,in-progress,blocked'
-63
View File
@@ -1,63 +0,0 @@
name: Test on Tag
on:
push:
tags:
- 'v*'
jobs:
# Python tests
test-python:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.12', '3.13']
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install uv
uses: astral-sh/setup-uv@v4
with:
version: "latest"
- name: Install dependencies
working-directory: apps/backend
run: |
uv venv
uv pip install -r requirements.txt
uv pip install -r ../../tests/requirements-test.txt
- name: Run tests
working-directory: apps/backend
env:
PYTHONPATH: ${{ github.workspace }}/apps/backend
run: |
source .venv/bin/activate
pytest ../../tests/ -v --tb=short
# Frontend tests
test-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24'
- name: Install dependencies
working-directory: apps/frontend
run: npm ci --ignore-scripts
- name: Run tests
working-directory: apps/frontend
run: npm run test
-71
View File
@@ -1,71 +0,0 @@
name: Validate Version
on:
push:
tags:
- 'v*'
jobs:
validate-version:
name: Validate package.json version matches tag
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Extract version from tag
id: tag_version
run: |
# Extract version from tag (e.g., v2.5.5 -> 2.5.5)
TAG_VERSION=${GITHUB_REF#refs/tags/v}
echo "version=$TAG_VERSION" >> $GITHUB_OUTPUT
echo "Tag version: $TAG_VERSION"
- name: Extract version from package.json
id: package_version
run: |
# Read version from package.json
PACKAGE_VERSION=$(node -p "require('./apps/frontend/package.json').version")
echo "version=$PACKAGE_VERSION" >> $GITHUB_OUTPUT
echo "Package.json version: $PACKAGE_VERSION"
- name: Compare versions
run: |
TAG_VERSION="${{ steps.tag_version.outputs.version }}"
PACKAGE_VERSION="${{ steps.package_version.outputs.version }}"
echo "=========================================="
echo "Version Validation"
echo "=========================================="
echo "Git tag version: v$TAG_VERSION"
echo "package.json version: $PACKAGE_VERSION"
echo "=========================================="
if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then
echo ""
echo "❌ ERROR: Version mismatch detected!"
echo ""
echo "The version in package.json ($PACKAGE_VERSION) does not match"
echo "the git tag version ($TAG_VERSION)."
echo ""
echo "To fix this:"
echo " 1. Delete this tag: git tag -d v$TAG_VERSION"
echo " 2. Update package.json version to $TAG_VERSION"
echo " 3. Commit the change"
echo " 4. Recreate the tag: git tag -a v$TAG_VERSION -m 'Release v$TAG_VERSION'"
echo ""
echo "Or use the automated script:"
echo " node scripts/bump-version.js $TAG_VERSION"
echo ""
exit 1
fi
echo ""
echo "✅ SUCCESS: Versions match!"
echo ""
- name: Version validation result
if: success()
run: |
echo "::notice::Version validation passed - package.json version matches tag v${{ steps.tag_version.outputs.version }}"
-33
View File
@@ -1,33 +0,0 @@
name: Welcome
on:
pull_request_target:
types: [opened]
issues:
types: [opened]
jobs:
welcome:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/first-interaction@v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
issue-message: |
👋 Thanks for opening your first issue!
A maintainer will triage this soon. In the meantime:
- Make sure you've provided all the requested info
- Join our [Discord](https://discord.gg/QhRnz9m5HE) for faster help
pr-message: |
🎉 Thanks for your first PR!
A maintainer will review it soon. Please make sure:
- Your branch is synced with `develop`
- CI checks pass
- You've followed our [contribution guide](https://github.com/AndyMik90/Auto-Claude/blob/develop/CONTRIBUTING.md)
Welcome to the Auto Claude community!
-4
View File
@@ -14,7 +14,6 @@ Desktop.ini
.env
.env.*
!.env.example
/config.json
*.pem
*.key
*.crt
@@ -165,6 +164,3 @@ _bmad-output/
/docs
OPUS_ANALYSIS_AND_IDEAS.md
/.github/agents
# Auto Claude generated files
.security-key
+2 -35
View File
@@ -1,6 +1,5 @@
repos:
# Version sync - propagate root package.json version to all files
# NOTE: Skip in worktrees - version sync modifies root files which don't exist in worktree
- repo: local
hooks:
- id: version-sync
@@ -9,12 +8,6 @@ repos:
args:
- -c
- |
# Skip in worktrees - .git is a file pointing to main repo, not a directory
# Version sync modifies root-level files that may not exist in worktree context
if [ -f ".git" ]; then
echo "Skipping version-sync in worktree (root files not accessible)"
exit 0
fi
VERSION=$(node -p "require('./package.json').version")
if [ -n "$VERSION" ]; then
@@ -88,7 +81,6 @@ repos:
# Python tests (apps/backend/) - skip slow/integration tests for pre-commit speed
# Tests to skip: graphiti (external deps), merge_file_tracker/service_orchestrator/worktree/workspace (Windows path/git issues)
# NOTE: Skip this hook in worktrees (where .git is a file, not a directory)
- repo: local
hooks:
- id: pytest
@@ -97,12 +89,6 @@ repos:
args:
- -c
- |
# Skip in worktrees - .git is a file pointing to main repo, not a directory
# This prevents path resolution issues with ../../tests/ in worktree context
if [ -f ".git" ]; then
echo "Skipping pytest in worktree (path resolution would fail)"
exit 0
fi
cd apps/backend
if [ -f ".venv/bin/pytest" ]; then
PYTEST_CMD=".venv/bin/pytest"
@@ -127,37 +113,18 @@ repos:
pass_filenames: false
# Frontend linting (apps/frontend/)
# NOTE: These hooks check for worktree context to avoid npm/node_modules issues
- repo: local
hooks:
- id: eslint
name: ESLint
entry: bash
args:
- -c
- |
# Skip in worktrees if node_modules doesn't exist (dependencies not installed)
if [ -f ".git" ] && [ ! -d "apps/frontend/node_modules" ]; then
echo "Skipping ESLint in worktree (node_modules not found)"
exit 0
fi
cd apps/frontend && npm run lint
entry: bash -c 'cd apps/frontend && npm run lint'
language: system
files: ^apps/frontend/.*\.(ts|tsx|js|jsx)$
pass_filenames: false
- id: typecheck
name: TypeScript Check
entry: bash
args:
- -c
- |
# Skip in worktrees if node_modules doesn't exist (dependencies not installed)
if [ -f ".git" ] && [ ! -d "apps/frontend/node_modules" ]; then
echo "Skipping TypeScript check in worktree (node_modules not found)"
exit 0
fi
cd apps/frontend && npm run typecheck
entry: bash -c 'cd apps/frontend && npm run typecheck'
language: system
files: ^apps/frontend/.*\.(ts|tsx)$
pass_filenames: false
-318
View File
@@ -1,318 +0,0 @@
# Root Cause Investigation: Task Workflow Halts After Planning Stage
## Investigation Summary
After adding comprehensive logging to the task loading and plan update pipeline, I've analyzed the data flow from backend to frontend to identify why subtasks fail to display after spec completion.
## Data Flow Analysis
### Current Architecture
```
Backend (Python)
Creates implementation_plan.json
Emits IPC event: 'task:progress' with plan data
Frontend (Electron Renderer)
useIpc.ts: onTaskProgress handler (batched)
task-store.ts: updateTaskFromPlan(taskId, plan)
Creates subtasks from plan.phases.flatMap(phase => phase.subtasks)
UI: TaskSubtasks.tsx renders subtasks
```
### Critical Code Paths
**1. Plan Update Handler** (`apps/frontend/src/renderer/hooks/useIpc.ts:131-135`)
```typescript
window.electronAPI.onTaskProgress(
(taskId: string, plan: ImplementationPlan) => {
queueUpdate(taskId, { plan });
}
);
```
**2. Subtask Creation** (`apps/frontend/src/renderer/stores/task-store.ts:124-133`)
```typescript
const subtasks: Subtask[] = plan.phases.flatMap((phase) =>
phase.subtasks.map((subtask) => ({
id: subtask.id,
title: subtask.description,
description: subtask.description,
status: subtask.status,
files: [],
verification: subtask.verification as Subtask['verification']
}))
);
```
**3. Initial Task Loading** (`apps/frontend/src/main/project-store.ts:461-470`)
```typescript
const subtasks = plan?.phases?.flatMap((phase) => {
const items = phase.subtasks || (phase as { chunks?: PlanSubtask[] }).chunks || [];
return items.map((subtask) => ({
id: subtask.id,
title: subtask.description,
description: subtask.description,
status: subtask.status,
files: []
}));
}) || [];
```
## Root Cause Identification
### Primary Root Cause: Early Plan Update Event with Empty Phases
**What's Happening:**
1. **Backend creates `implementation_plan.json` in stages:**
- First writes the file with minimal structure: `{ "feature": "...", "phases": [] }`
- Then adds phases and subtasks incrementally
- Emits IPC event each time the plan is updated
2. **Frontend receives the FIRST plan update event:**
- Plan has `feature` and basic metadata
- **But `phases` array is EMPTY: `[]`**
- `updateTaskFromPlan` is called with this incomplete plan
- Subtasks are created as empty array: `plan.phases.flatMap(...)``[]`
3. **Later plan updates with full subtask data are ignored:**
- When backend writes the complete plan with subtasks
- Another IPC event is emitted
- But due to race conditions or event handling issues, this update doesn't reach the frontend
- Or it does reach but the task UI doesn't refresh
**Evidence from Code:**
Looking at `updateTaskFromPlan` (task-store.ts:106-190):
- Line 108-114: Logs show `phases: plan.phases?.length || 0`
- Line 112: If plan has 0 phases, `totalSubtasks` will be 0
- Line 124-133: `plan.phases.flatMap(...)` on empty array creates `subtasks = []`
- **No validation to check if plan is complete before updating state**
**Why "!" Indicators Appear:**
The "!" indicators likely come from the UI attempting to render subtasks when:
- Subtask count shows as 18 (from later plan update metadata)
- But `task.subtasks` array is actually empty `[]` (from early plan update)
- This mismatch causes the UI to show warning indicators
### Secondary Contributing Factors
**A. No Plan Validation Before State Update**
Current code in `updateTaskFromPlan` immediately creates subtasks from whatever plan data it receives:
```typescript
const subtasks: Subtask[] = plan.phases.flatMap((phase) =>
phase.subtasks.map((subtask) => ({ ... }))
);
```
**Problem:** No check if plan is "ready" or "complete" before updating state.
**B. Missing Reload Trigger After Spec Completion**
When spec creation completes and the full plan is written:
- The IPC event might not fire again
- Or the event fires but the batching mechanism drops it
- Frontend state remains stuck with empty subtasks
**C. Race Condition in Batch Update Queue**
In `useIpc.ts:92-112`, the batching mechanism queues updates:
```typescript
function queueUpdate(taskId: string, update: BatchedUpdate): void {
const existing = batchQueue.get(taskId) || {};
batchQueue.set(taskId, { ...existing, ...update });
}
```
**Problem:** If two plan updates arrive within 16ms:
- First update has empty phases: `{ plan: { phases: [] } }`
- Second update has full phases: `{ plan: { phases: [...18 subtasks...] } }`
- Second update **overwrites** first in the queue
- But if order gets reversed, empty plan overwrites full plan
## Log Evidence to Look For
To confirm this root cause, check console logs for:
### 1. Plan Loading Sequence
```
[updateTaskFromPlan] called with plan:
taskId: "xxx"
feature: "..."
phases: 0 ← SMOKING GUN: phases array is empty
totalSubtasks: 0 ← No subtasks
```
If you see `phases: 0` followed later by no update with `phases: 3` (or more), the early empty plan is stuck in state.
### 2. Multiple Plan Updates
```
[updateTaskFromPlan] called with plan:
phases: 0
totalSubtasks: 0
[updateTaskFromPlan] called with plan: ← This might never appear
phases: 3
totalSubtasks: 18
```
If second log never appears, the plan update event isn't firing after spec completion.
### 3. Project Store Loading
```
[ProjectStore] Loading implementation_plan.json for spec: xxx
[ProjectStore] Loaded plan for xxx:
phaseCount: 0 ← Empty plan loaded from disk
subtaskCount: 0
```
If plan file on disk has empty phases, the issue is in backend plan writing.
### 4. Plan File Utils
```
[plan-file-utils] Reading implementation_plan.json to update status
[plan-file-utils] Successfully persisted status ← Plan exists but might be incomplete
```
Check if plan file reads/writes are happening during spec creation.
## Proposed Fix Approach
### Fix 1: Add Plan Completeness Validation (Immediate Fix)
**File:** `apps/frontend/src/renderer/stores/task-store.ts`
**Change:** Only update subtasks if plan has valid phases and subtasks:
```typescript
updateTaskFromPlan: (taskId, plan) =>
set((state) => {
console.log('[updateTaskFromPlan] called with plan:', { ... });
const index = findTaskIndex(state.tasks, taskId);
if (index === -1) {
console.log('[updateTaskFromPlan] Task not found:', taskId);
return state;
}
// VALIDATION: Don't update if plan is incomplete
if (!plan.phases || plan.phases.length === 0) {
console.warn('[updateTaskFromPlan] Plan has no phases, skipping update:', taskId);
return state; // Keep existing state, don't overwrite with empty data
}
const totalSubtasks = plan.phases.reduce((acc, p) => acc + (p.subtasks?.length || 0), 0);
if (totalSubtasks === 0) {
console.warn('[updateTaskFromPlan] Plan has no subtasks, skipping update:', taskId);
return state; // Keep existing state
}
// ... rest of existing code to create subtasks ...
})
```
### Fix 2: Trigger Reload After Spec Completion (Comprehensive Fix)
**File:** `apps/frontend/src/renderer/hooks/useIpc.ts`
**Change:** Add explicit "spec completed" event handler that reloads the task:
```typescript
// Add new IPC event listener
const cleanupSpecComplete = window.electronAPI.onSpecComplete(
async (taskId: string) => {
console.log('[IPC] Spec completed for task:', taskId);
// Force reload the task from disk to get the complete plan
const task = useTaskStore.getState().tasks.find(t => t.id === taskId);
if (task) {
// Reload plan from file
const result = await window.electronAPI.getTaskPlan(task.projectId, taskId);
if (result.success && result.data) {
updateTaskFromPlan(taskId, result.data);
}
}
}
);
```
### Fix 3: Prevent Plan Overwrite in Batch Queue (Race Condition Fix)
**File:** `apps/frontend/src/renderer/hooks/useIpc.ts`
**Change:** Don't overwrite plan if incoming plan has fewer subtasks than existing:
```typescript
function queueUpdate(taskId: string, update: BatchedUpdate): void {
const existing = batchQueue.get(taskId) || {};
// For plan updates, only accept if it has MORE data than existing
let mergedPlan = existing.plan;
if (update.plan) {
const existingSubtasks = existing.plan?.phases?.flatMap(p => p.subtasks || []).length || 0;
const newSubtasks = update.plan.phases?.flatMap(p => p.subtasks || []).length || 0;
if (newSubtasks >= existingSubtasks) {
mergedPlan = update.plan; // Accept new plan
} else {
console.warn('[IPC Batch] Rejecting plan update with fewer subtasks:',
{ taskId, existing: existingSubtasks, new: newSubtasks });
// Keep existing plan, don't overwrite with less complete data
}
}
// ... rest of existing code ...
}
```
## Testing the Fix
### Manual Verification Steps
1. **Create a new task** and move it to "In Progress"
2. **Watch the console logs** for:
```
[updateTaskFromPlan] called with plan: { phases: 0, totalSubtasks: 0 }
```
3. **Wait for spec to complete** (planning phase finishes)
4. **Check console logs** for:
```
[updateTaskFromPlan] called with plan: { phases: 3, totalSubtasks: 18 }
```
5. **Expand subtask list** in task card
6. **Verify:** Subtasks display with full details, no "!" indicators
### Expected Outcome After Fix
- ✅ Empty/incomplete plan updates are ignored
- ✅ Only complete plans with phases and subtasks update the UI
- ✅ Subtasks display with id, description, and status
- ✅ No "!" warning indicators
- ✅ Subtask count shows "0/18 completed" (not "0/0")
- ✅ Plan pulsing animation stops when spec completes
- ✅ Resume functionality works without infinite loop
## Next Steps
1. ✅ **This Investigation** - Root cause identified (COMPLETE)
2. 🔄 **Subtask 2-1** - Implement Fix 1 (validation in updateTaskFromPlan)
3. 🔄 **Subtask 2-2** - Add data validation before subtask state updates
4. 🔄 **Subtask 2-3** - Fix pulsing animation condition
5. 🔄 **Subtask 2-4** - Fix resume logic to reload plan if subtasks missing
6. 🔄 **Phase 3** - Add comprehensive tests to prevent regressions
## Conclusion
**Root Cause:** Frontend receives and accepts incomplete plan data (empty `phases` array) during the spec creation process, before subtasks are written. This overwrites any existing subtask data and leaves the UI in a stuck state with no subtasks to display.
**Fix Priority:** Implement Fix 1 (validation) immediately to prevent incomplete plans from updating state. This is a minimal, low-risk change that will resolve the core issue.
**Long-term Solution:** Add explicit event handling for spec completion (Fix 2) and improve batch queue logic (Fix 3) to make the system more robust against race conditions and out-of-order updates.
-6
View File
@@ -7,7 +7,6 @@ Main autonomous agent loop that runs the coder agent to implement subtasks.
import asyncio
import logging
import os
from pathlib import Path
from core.client import create_client
@@ -38,7 +37,6 @@ from prompt_generator import (
)
from prompts import is_first_run
from recovery import RecoveryManager
from security.constants import PROJECT_DIR_ENV_VAR
from task_logger import (
LogPhase,
get_task_logger,
@@ -92,10 +90,6 @@ async def run_autonomous_agent(
verbose: Whether to show detailed output
source_spec_dir: Original spec directory in main project (for syncing from worktree)
"""
# Set environment variable for security hooks to find the correct project directory
# This is needed because os.getcwd() may return the wrong directory in worktree mode
os.environ[PROJECT_DIR_ENV_VAR] = str(project_dir.resolve())
# Initialize recovery manager (handles memory persistence)
recovery_manager = RecoveryManager(spec_dir, project_dir)
+2 -3
View File
@@ -445,9 +445,8 @@ async def run_agent_session(
result_content = getattr(block, "content", "")
is_error = getattr(block, "is_error", False)
# Check if this is an error (not just content containing "blocked")
if is_error and "blocked" in str(result_content).lower():
# Actual blocked command by security hook
# Check if command was blocked by security hook
if "blocked" in str(result_content).lower():
debug_error(
"session",
f"Tool BLOCKED: {current_tool}",
+38 -67
View File
@@ -29,14 +29,14 @@ except ImportError:
logger = logging.getLogger(__name__)
async def _save_to_graphiti_async(
def _save_to_graphiti_sync(
spec_dir: Path,
project_dir: Path,
save_type: str,
data: dict,
) -> bool:
"""
Save data to Graphiti/LadybugDB (async implementation).
Save data to Graphiti/LadybugDB (synchronous wrapper for async operation).
Args:
spec_dir: Spec directory for GraphitiMemory initialization
@@ -56,28 +56,41 @@ async def _save_to_graphiti_async(
from integrations.graphiti.queries_pkg.graphiti import GraphitiMemory
memory = GraphitiMemory(spec_dir, project_dir)
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:
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()
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}")
@@ -87,48 +100,6 @@ async def _save_to_graphiti_async(
return False
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 sync contexts only).
NOTE: This should only be called from synchronous code. For async callers,
use _save_to_graphiti_async() directly to ensure proper resource cleanup.
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 we're already in an async context
try:
asyncio.get_running_loop()
# We're in an async context - caller should use _save_to_graphiti_async
# Log a warning and return False to avoid the resource leak bug
logger.warning(
"_save_to_graphiti_sync called from async context. "
"Use _save_to_graphiti_async instead for proper cleanup."
)
return False
except RuntimeError:
# No running loop - safe to create one
return asyncio.run(
_save_to_graphiti_async(spec_dir, project_dir, save_type, data)
)
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:
"""
Create session memory tools.
@@ -189,7 +160,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
json.dump(codebase_map, f, indent=2)
# SECONDARY: Also save to Graphiti/LadybugDB (for Memory UI)
saved_to_graphiti = await _save_to_graphiti_async(
saved_to_graphiti = _save_to_graphiti_sync(
spec_dir,
project_dir,
"discovery",
@@ -252,7 +223,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 = await _save_to_graphiti_async(
saved_to_graphiti = _save_to_graphiti_sync(
spec_dir,
project_dir,
"gotcha",
+24 -20
View File
@@ -8,38 +8,42 @@ Helper functions for git operations, plan management, and file syncing.
import json
import logging
import shutil
import subprocess
from pathlib import Path
from core.git_executable import run_git
logger = logging.getLogger(__name__)
def get_latest_commit(project_dir: Path) -> str | None:
"""Get the hash of the latest git commit."""
result = run_git(
["rev-parse", "HEAD"],
cwd=project_dir,
timeout=10,
)
if result.returncode == 0:
try:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
check=True,
timeout=10,
)
return result.stdout.strip()
return None
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
return None
def get_commit_count(project_dir: Path) -> int:
"""Get the total number of commits."""
result = run_git(
["rev-list", "--count", "HEAD"],
cwd=project_dir,
timeout=10,
)
if result.returncode == 0:
try:
return int(result.stdout.strip())
except ValueError:
return 0
return 0
try:
result = subprocess.run(
["git", "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):
return 0
def load_implementation_plan(spec_dir: Path) -> dict | None:
+6 -54
View File
@@ -387,40 +387,12 @@ async def run_insight_extraction(
# Collect the response
response_text = ""
message_count = 0
text_blocks_found = 0
async for msg in client.receive_response():
msg_type = type(msg).__name__
message_count += 1
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
for block in msg.content:
# Must check block type - only TextBlock has .text attribute
block_type = type(block).__name__
if block_type == "TextBlock" and hasattr(block, "text"):
text_blocks_found += 1
if block.text: # Only add non-empty text
response_text += block.text
else:
logger.debug(
f"Found empty TextBlock in response (block #{text_blocks_found})"
)
# Log response collection summary
logger.debug(
f"Insight extraction response: {message_count} messages, "
f"{text_blocks_found} text blocks, {len(response_text)} chars collected"
)
# Validate we received content before parsing
if not response_text.strip():
logger.warning(
f"Insight extraction returned empty response. "
f"Messages received: {message_count}, TextBlocks found: {text_blocks_found}. "
f"This may indicate the AI model did not respond with text content."
)
return None
if hasattr(block, "text"):
response_text += block.text
# Parse JSON from response
return parse_insights(response_text)
@@ -443,11 +415,6 @@ def parse_insights(response_text: str) -> dict | None:
# Try to extract JSON from the response
text = response_text.strip()
# Early validation - check for empty response
if not text:
logger.warning("Cannot parse insights: response text is empty")
return None
# Handle markdown code blocks
if text.startswith("```"):
# Remove code block markers
@@ -455,26 +422,17 @@ def parse_insights(response_text: str) -> dict | None:
# Remove first line (```json or ```)
if lines[0].startswith("```"):
lines = lines[1:]
# Remove last line if it's ```
# Remove last line if it's ``
if lines and lines[-1].strip() == "```":
lines = lines[:-1]
text = "\n".join(lines).strip()
# Check again after removing code blocks
if not text:
logger.warning(
"Cannot parse insights: response contained only markdown code block markers with no content"
)
return None
text = "\n".join(lines)
try:
insights = json.loads(text)
# Validate structure
if not isinstance(insights, dict):
logger.warning(
f"Insights is not a dict, got type: {type(insights).__name__}"
)
logger.warning("Insights is not a dict")
return None
# Ensure required keys exist with defaults
@@ -488,13 +446,7 @@ def parse_insights(response_text: str) -> dict | None:
except json.JSONDecodeError as e:
logger.warning(f"Failed to parse insights JSON: {e}")
# Show more context in the error message
preview_length = min(500, len(text))
logger.warning(
f"Response text preview (first {preview_length} chars): {text[:preview_length]}"
)
if len(text) > preview_length:
logger.warning(f"... (total length: {len(text)} chars)")
logger.debug(f"Response text was: {text[:500]}")
return None
-40
View File
@@ -38,7 +38,6 @@ from .utils import (
)
from .workspace_commands import (
handle_cleanup_worktrees_command,
handle_create_pr_command,
handle_discard_command,
handle_list_worktrees_command,
handle_merge_command,
@@ -154,30 +153,6 @@ Environment Variables:
action="store_true",
help="Discard an existing build (requires confirmation)",
)
build_group.add_argument(
"--create-pr",
action="store_true",
help="Push branch and create a GitHub Pull Request",
)
# PR options
parser.add_argument(
"--pr-target",
type=str,
metavar="BRANCH",
help="With --create-pr: target branch for PR (default: auto-detect)",
)
parser.add_argument(
"--pr-title",
type=str,
metavar="TITLE",
help="With --create-pr: custom PR title (default: generated from spec name)",
)
parser.add_argument(
"--pr-draft",
action="store_true",
help="With --create-pr: create as draft PR",
)
# Merge options
parser.add_argument(
@@ -390,21 +365,6 @@ def main() -> None:
handle_discard_command(project_dir, spec_dir.name)
return
if args.create_pr:
# Pass args.pr_target directly - WorktreeManager._detect_base_branch
# handles base branch detection internally when target_branch is None
result = handle_create_pr_command(
project_dir=project_dir,
spec_name=spec_dir.name,
target_branch=args.pr_target,
title=args.pr_title,
draft=args.pr_draft,
)
# JSON output is already printed by handle_create_pr_command
if not result.get("success"):
sys.exit(1)
return
# Handle QA commands
if args.qa_status:
handle_qa_status_command(spec_dir)
+1 -44
View File
@@ -15,47 +15,7 @@ if str(_PARENT_DIR) not in sys.path:
sys.path.insert(0, str(_PARENT_DIR))
from core.auth import get_auth_token, get_auth_token_source
from core.dependency_validator import validate_platform_dependencies
def import_dotenv():
"""
Import and return load_dotenv with helpful error message if not installed.
This centralized function ensures consistent error messaging across all
runner scripts when python-dotenv is not available.
Returns:
The load_dotenv function
Raises:
SystemExit: If dotenv cannot be imported, with helpful installation instructions.
"""
try:
from dotenv import load_dotenv as _load_dotenv
return _load_dotenv
except ImportError:
sys.exit(
"Error: Required Python package 'python-dotenv' is not installed.\n"
"\n"
"This usually means you're not using the virtual environment.\n"
"\n"
"To fix this:\n"
"1. From the 'apps/backend/' directory, activate the venv:\n"
" source .venv/bin/activate # Linux/macOS\n"
" .venv\\Scripts\\activate # Windows\n"
"\n"
"2. Or install dependencies directly:\n"
" pip install python-dotenv\n"
" pip install -r requirements.txt\n"
"\n"
f"Current Python: {sys.executable}\n"
)
# Load .env with helpful error if dependencies not installed
load_dotenv = import_dotenv()
from dotenv import load_dotenv
from graphiti_config import get_graphiti_status
from linear_integration import LinearManager
from linear_updater import is_linear_enabled
@@ -155,9 +115,6 @@ def validate_environment(spec_dir: Path) -> bool:
Returns:
True if valid, False otherwise (with error messages printed)
"""
# Validate platform-specific dependencies first (exits if missing)
validate_platform_dependencies()
valid = True
# Check for OAuth token (API keys are not supported)
+63 -453
View File
@@ -5,7 +5,6 @@ Workspace Commands
CLI commands for workspace management (merge, review, discard, list, cleanup)
"""
import json
import subprocess
import sys
from pathlib import Path
@@ -23,8 +22,6 @@ from core.workspace.git_utils import (
get_merge_base,
is_lock_file,
)
from core.worktree import PushAndCreatePRResult as CreatePRResult
from core.worktree import WorktreeManager
from debug import debug_warning
from ui import (
Icons,
@@ -33,7 +30,6 @@ from ui import (
from workspace import (
cleanup_all_worktrees,
discard_existing_build,
get_existing_build_worktree,
list_all_worktrees,
merge_existing_build,
review_existing_build,
@@ -157,170 +153,6 @@ def _get_changed_files_from_git(
return []
def _detect_worktree_base_branch(
project_dir: Path,
worktree_path: Path,
spec_name: str,
) -> str | None:
"""
Detect which branch a worktree was created from.
Tries multiple strategies:
1. Check worktree config file (.auto-claude/worktree-config.json)
2. Find merge-base with known branches (develop, main, master)
3. Return None if unable to detect
Args:
project_dir: Project root directory
worktree_path: Path to the worktree
spec_name: Name of the spec
Returns:
The detected base branch name, or None if unable to detect
"""
# Strategy 1: Check for worktree config file
config_path = worktree_path / ".auto-claude" / "worktree-config.json"
if config_path.exists():
try:
config = json.loads(config_path.read_text())
if config.get("base_branch"):
debug(
MODULE,
f"Found base branch in worktree config: {config['base_branch']}",
)
return config["base_branch"]
except Exception as e:
debug_warning(MODULE, f"Failed to read worktree config: {e}")
# Strategy 2: Find which branch has the closest merge-base
# Check common branches: develop, main, master
spec_branch = f"auto-claude/{spec_name}"
candidate_branches = ["develop", "main", "master"]
best_branch = None
best_commits_behind = float("inf")
for branch in candidate_branches:
try:
# Check if branch exists
check = subprocess.run(
["git", "rev-parse", "--verify", branch],
cwd=project_dir,
capture_output=True,
text=True,
)
if check.returncode != 0:
continue
# Get merge base
merge_base_result = subprocess.run(
["git", "merge-base", branch, spec_branch],
cwd=project_dir,
capture_output=True,
text=True,
)
if merge_base_result.returncode != 0:
continue
merge_base = merge_base_result.stdout.strip()
# Count commits between merge-base and branch tip
# The branch with fewer commits ahead is likely the one we branched from
ahead_result = subprocess.run(
["git", "rev-list", "--count", f"{merge_base}..{branch}"],
cwd=project_dir,
capture_output=True,
text=True,
)
if ahead_result.returncode == 0:
commits_ahead = int(ahead_result.stdout.strip())
debug(
MODULE,
f"Branch {branch} is {commits_ahead} commits ahead of merge-base",
)
if commits_ahead < best_commits_behind:
best_commits_behind = commits_ahead
best_branch = branch
except Exception as e:
debug_warning(MODULE, f"Error checking branch {branch}: {e}")
continue
if best_branch:
debug(
MODULE,
f"Detected base branch from git history: {best_branch} (commits ahead: {best_commits_behind})",
)
return best_branch
return None
def _detect_parallel_task_conflicts(
project_dir: Path,
current_task_id: str,
current_task_files: list[str],
) -> list[dict]:
"""
Detect potential conflicts between this task and other active tasks.
Uses existing evolution data to check if any of this task's files
have been modified by other active tasks. This is a lightweight check
that doesn't require re-processing all files.
Args:
project_dir: Project root directory
current_task_id: ID of the current task
current_task_files: Files modified by this task (from git diff)
Returns:
List of conflict dictionaries with 'file' and 'tasks' keys
"""
try:
from merge import MergeOrchestrator
# Initialize orchestrator just to access evolution data
orchestrator = MergeOrchestrator(
project_dir,
enable_ai=False,
dry_run=True,
)
# Get all active tasks from evolution data
active_tasks = orchestrator.evolution_tracker.get_active_tasks()
# Remove current task from active tasks
other_active_tasks = active_tasks - {current_task_id}
if not other_active_tasks:
return []
# Convert current task files to a set for fast lookup
current_files_set = set(current_task_files)
# Get files modified by other active tasks
conflicts = []
other_task_files = orchestrator.evolution_tracker.get_files_modified_by_tasks(
list(other_active_tasks)
)
# Find intersection - files modified by both this task and other tasks
for file_path, tasks in other_task_files.items():
if file_path in current_files_set:
# This file was modified by both current task and other task(s)
all_tasks = [current_task_id] + tasks
conflicts.append({"file": file_path, "tasks": all_tasks})
return conflicts
except Exception as e:
# If anything fails, just return empty - parallel task detection is optional
debug_warning(
"workspace_commands",
f"Parallel task conflict detection failed: {e}",
)
return []
# Import debug utilities
try:
from debug import (
@@ -536,9 +368,7 @@ def handle_cleanup_worktrees_command(project_dir: Path) -> None:
cleanup_all_worktrees(project_dir, confirm=True)
def _check_git_merge_conflicts(
project_dir: Path, spec_name: str, base_branch: str | None = None
) -> dict:
def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
"""
Check for git-level merge conflicts WITHOUT modifying the working directory.
@@ -548,7 +378,6 @@ def _check_git_merge_conflicts(
Args:
project_dir: Project root directory
spec_name: Name of the spec
base_branch: Branch the task was created from (default: auto-detect)
Returns:
Dictionary with git conflict information:
@@ -567,25 +396,21 @@ def _check_git_merge_conflicts(
"has_conflicts": False,
"conflicting_files": [],
"needs_rebase": False,
"base_branch": base_branch or "main",
"base_branch": "main",
"spec_branch": spec_branch,
"commits_behind": 0,
}
try:
# Use provided base_branch, or detect from current HEAD
if not base_branch:
base_result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
)
if base_result.returncode == 0:
result["base_branch"] = base_result.stdout.strip()
else:
result["base_branch"] = base_branch
debug(MODULE, f"Using provided base branch: {base_branch}")
# Get the current branch (base branch)
base_result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
)
if base_result.returncode == 0:
result["base_branch"] = base_result.stdout.strip()
# Get the merge base commit
merge_base_result = subprocess.run(
@@ -744,6 +569,7 @@ def handle_merge_preview_command(
spec_name=spec_name,
)
from merge import MergeOrchestrator
from workspace import get_existing_build_worktree
worktree_path = get_existing_build_worktree(project_dir, spec_name)
@@ -770,32 +596,16 @@ def handle_merge_preview_command(
}
try:
# First, check for git-level conflicts (diverged branches)
git_conflicts = _check_git_merge_conflicts(project_dir, spec_name)
# Determine the task's source branch (where the task was created from)
# Priority:
# 1. Provided base_branch (from task metadata)
# 2. Detect from worktree's git history (find which branch it diverged from)
# 3. Fall back to default branch detection (main/master)
# Use provided base_branch (from task metadata), or fall back to detected default
task_source_branch = base_branch
if not task_source_branch:
# Try to detect from worktree's git history
task_source_branch = _detect_worktree_base_branch(
project_dir, worktree_path, spec_name
)
if not task_source_branch:
# Fall back to auto-detecting main/master
# Auto-detect the default branch (main/master) that worktrees are typically created from
task_source_branch = _detect_default_branch(project_dir)
debug(
MODULE,
f"Using task source branch: {task_source_branch}",
provided=base_branch is not None,
)
# Check for git-level conflicts (diverged branches) using the task's source branch
git_conflicts = _check_git_merge_conflicts(
project_dir, spec_name, base_branch=task_source_branch
)
# Get actual changed files from git diff (this is the authoritative count)
all_changed_files = _get_changed_files_from_git(
worktree_path, task_source_branch
@@ -806,39 +616,56 @@ def handle_merge_preview_command(
changed_files=all_changed_files[:10], # Log first 10
)
# OPTIMIZATION: Skip expensive refresh_from_git() and preview_merge() calls
# For merge-preview, we only need to detect:
# 1. Git conflicts (task vs base branch) - already calculated in _check_git_merge_conflicts()
# 2. Parallel task conflicts (this task vs other active tasks)
#
# For parallel task detection, we just check if this task's files overlap
# with files OTHER tasks have already recorded - no need to re-process all files.
# 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, "Checking for parallel task conflicts (lightweight)...")
debug(MODULE, "Initializing MergeOrchestrator for preview...")
# Check for parallel task conflicts by looking at existing evolution data
parallel_conflicts = _detect_parallel_task_conflicts(
project_dir, spec_name, all_changed_files
# Initialize the orchestrator
orchestrator = MergeOrchestrator(
project_dir,
enable_ai=False, # Don't use AI for preview
dry_run=True, # Don't write anything
)
# Refresh evolution data from the worktree
# Compare against the task's source branch (where the task was created from)
debug(
MODULE,
f"Parallel task conflicts detected: {len(parallel_conflicts)}",
conflicts=parallel_conflicts[:5] if parallel_conflicts else [],
f"Refreshing evolution data from worktree: {worktree_path}",
task_source_branch=task_source_branch,
)
orchestrator.evolution_tracker.refresh_from_git(
spec_name, worktree_path, target_branch=task_source_branch
)
# Build conflict list - start with parallel task conflicts
# Get merge preview (semantic conflicts between parallel tasks)
debug(MODULE, "Generating merge preview...")
preview = orchestrator.preview_merge([spec_name])
# Transform semantic conflicts to UI-friendly format
conflicts = []
for pc in parallel_conflicts:
for c in preview.get("conflicts", []):
debug_verbose(
MODULE,
"Processing semantic conflict",
file=c.get("file", ""),
severity=c.get("severity", "unknown"),
)
conflicts.append(
{
"file": pc["file"],
"location": "file-level",
"tasks": pc["tasks"],
"severity": "medium",
"canAutoMerge": False,
"strategy": None,
"reason": f"File modified by multiple active tasks: {', '.join(pc['tasks'])}",
"type": "parallel",
"file": c.get("file", ""),
"location": c.get("location", ""),
"tasks": c.get("tasks", []),
"severity": c.get("severity", "unknown"),
"canAutoMerge": c.get("can_auto_merge", False),
"strategy": c.get("strategy"),
"reason": c.get("reason", ""),
"type": "semantic",
}
)
@@ -865,14 +692,13 @@ def handle_merge_preview_command(
}
)
summary = preview.get("summary", {})
# Count only non-lock-file conflicts
git_conflict_count = len(git_conflicts.get("conflicting_files", [])) - len(
lock_files_excluded
)
# Calculate totals from our conflict lists (git conflicts + parallel conflicts)
parallel_conflict_count = len(parallel_conflicts)
total_conflicts = git_conflict_count + parallel_conflict_count
conflict_files = git_conflict_count + parallel_conflict_count
total_conflicts = summary.get("total_conflicts", 0) + git_conflict_count
conflict_files = summary.get("conflict_files", 0) + git_conflict_count
# Filter lock files from the git conflicts list for the response
non_lock_conflicting_files = [
@@ -958,7 +784,7 @@ def handle_merge_preview_command(
"totalFiles": total_files_from_git,
"conflictFiles": conflict_files,
"totalConflicts": total_conflicts,
"autoMergeable": 0, # Not tracking auto-merge in lightweight mode
"autoMergeable": summary.get("auto_mergeable", 0),
"hasGitConflicts": git_conflicts["has_conflicts"]
and len(non_lock_conflicting_files) > 0,
# Include path-mapped AI merge count for UI display
@@ -973,9 +799,10 @@ def handle_merge_preview_command(
"Merge preview complete",
total_files=result["summary"]["totalFiles"],
total_files_source="git_diff",
semantic_tracked_files=summary.get("total_files", 0),
total_conflicts=result["summary"]["totalConflicts"],
has_git_conflicts=git_conflicts["has_conflicts"],
parallel_conflicts=parallel_conflict_count,
auto_mergeable=result["summary"]["autoMergeable"],
path_mapped_ai_merges=len(path_mapped_ai_merges),
total_renames=len(path_mappings),
)
@@ -1001,220 +828,3 @@ def handle_merge_preview_command(
"pathMappedAIMergeCount": 0,
},
}
def handle_create_pr_command(
project_dir: Path,
spec_name: str,
target_branch: str | None = None,
title: str | None = None,
draft: bool = False,
) -> CreatePRResult:
"""
Handle the --create-pr command: push branch and create a GitHub PR.
Args:
project_dir: Path to the project directory
spec_name: Name of the spec (e.g., "001-feature-name")
target_branch: Target branch for PR (defaults to base branch)
title: Custom PR title (defaults to spec name)
draft: Whether to create as draft PR
Returns:
CreatePRResult with success status, pr_url, and any errors
"""
from core.worktree import WorktreeManager
print_banner()
print("\n" + "=" * 70)
print(" CREATE PULL REQUEST")
print("=" * 70)
# Check if worktree exists
worktree_path = get_existing_build_worktree(project_dir, spec_name)
if not worktree_path:
print(f"\n{icon(Icons.ERROR)} No build found for spec: {spec_name}")
print("\nA completed build worktree is required to create a PR.")
print("Run your build first, then use --create-pr.")
error_result: CreatePRResult = {
"success": False,
"error": "No build found for this spec",
}
return error_result
# Create worktree manager
manager = WorktreeManager(project_dir, base_branch=target_branch)
print(f"\n{icon(Icons.BRANCH)} Pushing branch and creating PR...")
print(f" Spec: {spec_name}")
print(f" Target: {target_branch or manager.base_branch}")
if title:
print(f" Title: {title}")
if draft:
print(" Mode: Draft PR")
# Push and create PR with exception handling for clean JSON output
try:
raw_result = manager.push_and_create_pr(
spec_name=spec_name,
target_branch=target_branch,
title=title,
draft=draft,
)
except Exception as e:
debug_error(MODULE, f"Exception during PR creation: {e}")
error_result: CreatePRResult = {
"success": False,
"error": str(e),
"message": "Failed to create PR",
}
print(f"\n{icon(Icons.ERROR)} Failed to create PR: {e}")
print(json.dumps(error_result))
return error_result
# Convert PushAndCreatePRResult to CreatePRResult
result: CreatePRResult = {
"success": raw_result.get("success", False),
"pr_url": raw_result.get("pr_url"),
"already_exists": raw_result.get("already_exists", False),
"error": raw_result.get("error"),
"message": raw_result.get("message"),
"pushed": raw_result.get("pushed", False),
"remote": raw_result.get("remote", ""),
"branch": raw_result.get("branch", ""),
}
if result.get("success"):
pr_url = result.get("pr_url")
already_exists = result.get("already_exists", False)
if already_exists:
print(f"\n{icon(Icons.SUCCESS)} PR already exists!")
else:
print(f"\n{icon(Icons.SUCCESS)} PR created successfully!")
if pr_url:
print(f"\n{icon(Icons.LINK)} {pr_url}")
else:
print(f"\n{icon(Icons.INFO)} Check GitHub for the PR URL")
print("\nNext steps:")
print(" 1. Review the PR on GitHub")
print(" 2. Request reviews from your team")
print(" 3. Merge when approved")
# Output JSON for frontend parsing
print(json.dumps(result))
return result
else:
error = result.get("error", "Unknown error")
print(f"\n{icon(Icons.ERROR)} Failed to create PR: {error}")
# Output JSON for frontend parsing
print(json.dumps(result))
return result
def cleanup_old_worktrees_command(
project_dir: Path, days: int = 30, dry_run: bool = False
) -> dict:
"""
Clean up old worktrees that haven't been modified in the specified number of days.
Args:
project_dir: Project root directory
days: Number of days threshold (default: 30)
dry_run: If True, only show what would be removed (default: False)
Returns:
Dictionary with cleanup results
"""
try:
manager = WorktreeManager(project_dir)
removed, failed = manager.cleanup_old_worktrees(
days_threshold=days, dry_run=dry_run
)
return {
"success": True,
"removed": removed,
"failed": failed,
"dry_run": dry_run,
"days_threshold": days,
}
except Exception as e:
return {
"success": False,
"error": str(e),
"removed": [],
"failed": [],
}
def worktree_summary_command(project_dir: Path) -> dict:
"""
Get a summary of all worktrees with age information.
Args:
project_dir: Project root directory
Returns:
Dictionary with worktree summary data
"""
try:
manager = WorktreeManager(project_dir)
# Print to console for CLI usage
manager.print_worktree_summary()
# Also return data for programmatic access
worktrees = manager.list_all_worktrees()
warning = manager.get_worktree_count_warning()
# Categorize by age
recent = []
week_old = []
month_old = []
very_old = []
unknown_age = []
for info in worktrees:
data = {
"spec_name": info.spec_name,
"days_since_last_commit": info.days_since_last_commit,
"commit_count": info.commit_count,
}
if info.days_since_last_commit is None:
unknown_age.append(data)
elif info.days_since_last_commit < 7:
recent.append(data)
elif info.days_since_last_commit < 30:
week_old.append(data)
elif info.days_since_last_commit < 90:
month_old.append(data)
else:
very_old.append(data)
return {
"success": True,
"total_worktrees": len(worktrees),
"categories": {
"recent": recent,
"week_old": week_old,
"month_old": month_old,
"very_old": very_old,
"unknown_age": unknown_age,
},
"warning": warning,
}
except Exception as e:
return {
"success": False,
"error": str(e),
"total_worktrees": 0,
"categories": {},
"warning": None,
}
+1 -3
View File
@@ -231,9 +231,7 @@ async def _call_claude(prompt: str) -> str:
msg_type = type(msg).__name__
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
for block in msg.content:
# Must check block type - only TextBlock has .text attribute
block_type = type(block).__name__
if block_type == "TextBlock" and hasattr(block, "text"):
if hasattr(block, "text"):
response_text += block.text
logger.info(f"Generated commit message: {len(response_text)} chars")
-53
View File
@@ -545,48 +545,6 @@ def create_client(
# cases where Claude uses absolute paths for file operations
project_path_str = str(project_dir.resolve())
spec_path_str = str(spec_dir.resolve())
# Detect if we're running in a worktree and get the original project directory
# Worktrees are located in either:
# - .auto-claude/worktrees/tasks/{spec-name}/ (new location)
# - .worktrees/{spec-name}/ (legacy location)
# When running in a worktree, we need to allow access to both the worktree
# and the original project's .auto-claude/ directory for spec files
original_project_permissions = []
resolved_project_path = project_dir.resolve()
# Check for worktree paths and extract original project directory
# This handles spec worktrees, PR review worktrees, and legacy worktrees
# Note: Windows paths are normalized to forward slashes before comparison
worktree_markers = [
"/.auto-claude/worktrees/tasks/", # Spec/task worktrees
"/.auto-claude/github/pr/worktrees/", # PR review worktrees
"/.worktrees/", # Legacy worktree location
]
project_path_posix = str(resolved_project_path).replace("\\", "/")
for marker in worktree_markers:
if marker in project_path_posix:
# Extract the original project directory (parent of worktree location)
# Use rsplit to get the rightmost occurrence (handles nested projects)
original_project_str = project_path_posix.rsplit(marker, 1)[0]
original_project_dir = Path(original_project_str)
# Grant permissions for relevant directories in the original project
permission_ops = ["Read", "Write", "Edit", "Glob", "Grep"]
dirs_to_permit = [
original_project_dir / ".auto-claude",
original_project_dir / ".worktrees", # Legacy support
]
for dir_path in dirs_to_permit:
if dir_path.exists():
path_str = str(dir_path.resolve())
original_project_permissions.extend(
[f"{op}({path_str}/**)" for op in permission_ops]
)
break
security_settings = {
"sandbox": {"enabled": True, "autoAllowBashIfSandboxed": True},
"permissions": {
@@ -609,9 +567,6 @@ def create_client(
f"Read({spec_path_str}/**)",
f"Write({spec_path_str}/**)",
f"Edit({spec_path_str}/**)",
# Allow original project's .auto-claude/ and .worktrees/ directories
# when running in a worktree (fixes issue #385 - permission errors)
*original_project_permissions,
# Bash permission granted here, but actual commands are validated
# by the bash_security_hook (see security.py for allowed commands)
"Bash(*)",
@@ -648,8 +603,6 @@ def create_client(
print(f"Security settings: {settings_file}")
print(" - Sandbox enabled (OS-level bash isolation)")
print(f" - Filesystem restricted to: {project_dir.resolve()}")
if original_project_permissions:
print(" - Worktree permissions: granted for original project directories")
print(" - Bash commands restricted to allowlist")
if max_thinking_tokens:
print(f" - Extended thinking: {max_thinking_tokens:,} tokens")
@@ -796,12 +749,6 @@ def create_client(
"settings": str(settings_file.resolve()),
"env": sdk_env, # Pass ANTHROPIC_BASE_URL etc. to subprocess
"max_thinking_tokens": max_thinking_tokens, # Extended thinking budget
"max_buffer_size": 10
* 1024
* 1024, # 10MB buffer (default: 1MB) - fixes large tool results
# Enable file checkpointing to track file read/write state across tool calls
# This prevents "File has not been read yet" errors in recovery sessions
"enable_file_checkpointing": True,
}
# Add structured output format if specified
-50
View File
@@ -1,50 +0,0 @@
"""
Dependency Validator
====================
Validates platform-specific dependencies are installed before running agents.
"""
import sys
from pathlib import Path
def validate_platform_dependencies() -> None:
"""
Validate that platform-specific dependencies are installed.
Raises:
SystemExit: If required platform-specific dependencies are missing,
with helpful installation instructions.
"""
# Check Windows-specific dependencies
if sys.platform == "win32" and sys.version_info >= (3, 12):
try:
import pywintypes # noqa: F401
except ImportError:
_exit_with_pywin32_error()
def _exit_with_pywin32_error() -> None:
"""Exit with helpful error message for missing pywin32."""
# Use sys.prefix to detect the virtual environment path
# This works for venv and poetry environments
venv_activate = Path(sys.prefix) / "Scripts" / "activate"
sys.exit(
"Error: Required Windows dependency 'pywin32' is not installed.\n"
"\n"
"Auto Claude requires pywin32 on Windows for LadybugDB/Graphiti memory integration.\n"
"\n"
"To fix this:\n"
"1. Activate your virtual environment:\n"
f" {venv_activate}\n"
"\n"
"2. Install pywin32:\n"
" pip install pywin32>=306\n"
"\n"
" Or reinstall all dependencies:\n"
" pip install -r requirements.txt\n"
"\n"
f"Current Python: {sys.executable}\n"
)
-142
View File
@@ -1,142 +0,0 @@
#!/usr/bin/env python3
"""
Git Executable Finder
======================
Utility to find the git executable, with Windows-specific fallbacks.
Separated into its own module to avoid circular imports.
"""
import os
import shutil
import subprocess
from pathlib import Path
_cached_git_path: str | None = None
def get_git_executable() -> str:
"""Find the git executable, with Windows-specific fallbacks.
Returns the path to git executable. On Windows, checks multiple sources:
1. CLAUDE_CODE_GIT_BASH_PATH env var (set by Electron frontend)
2. shutil.which (if git is in PATH)
3. Common installation locations
4. Windows 'where' command
Caches the result after first successful find.
"""
global _cached_git_path
# Return cached result if available
if _cached_git_path is not None:
return _cached_git_path
git_path = _find_git_executable()
_cached_git_path = git_path
return git_path
def _find_git_executable() -> str:
"""Internal function to find git executable."""
# 1. Check CLAUDE_CODE_GIT_BASH_PATH (set by Electron frontend)
# This env var points to bash.exe, we can derive git.exe from it
bash_path = os.environ.get("CLAUDE_CODE_GIT_BASH_PATH")
if bash_path:
try:
bash_path_obj = Path(bash_path)
if bash_path_obj.exists():
git_dir = bash_path_obj.parent.parent
# Try cmd/git.exe first (preferred), then bin/git.exe
for git_subpath in ["cmd/git.exe", "bin/git.exe"]:
git_path = git_dir / git_subpath
if git_path.is_file():
return str(git_path)
except (OSError, ValueError):
pass
# 2. Try shutil.which (works if git is in PATH)
git_path = shutil.which("git")
if git_path:
return git_path
# 3. Windows-specific: check common installation locations
if os.name == "nt":
common_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"),
r"C:\Program Files\Git\cmd\git.exe",
r"C:\Program Files (x86)\Git\cmd\git.exe",
]
for path in common_paths:
try:
if os.path.isfile(path):
return path
except OSError:
continue
# 4. Try 'where' command with shell=True (more reliable on Windows)
try:
result = subprocess.run(
"where git",
capture_output=True,
text=True,
timeout=5,
shell=True,
)
if result.returncode == 0 and result.stdout.strip():
found_path = result.stdout.strip().split("\n")[0].strip()
if found_path and os.path.isfile(found_path):
return found_path
except (subprocess.TimeoutExpired, OSError):
pass
# Default fallback - let subprocess handle it (may fail)
return "git"
def run_git(
args: list[str],
cwd: Path | str | None = None,
timeout: int = 60,
input_data: str | None = None,
) -> subprocess.CompletedProcess:
"""Run a git command with proper executable finding.
Args:
args: Git command arguments (without 'git' prefix)
cwd: Working directory for the command
timeout: Command timeout in seconds (default: 60)
input_data: Optional string data to pass to stdin
Returns:
CompletedProcess with command results.
"""
git = get_git_executable()
try:
return subprocess.run(
[git] + args,
cwd=cwd,
input=input_data,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
)
except subprocess.TimeoutExpired:
return subprocess.CompletedProcess(
args=[git] + args,
returncode=-1,
stdout="",
stderr=f"Command timed out after {timeout} seconds",
)
except FileNotFoundError:
return subprocess.CompletedProcess(
args=[git] + args,
returncode=-1,
stdout="",
stderr="Git executable not found. Please ensure git is installed and in PATH.",
)
+42 -89
View File
@@ -90,18 +90,12 @@ from core.workspace.git_utils import (
from core.workspace.git_utils import (
detect_file_renames as _detect_file_renames,
)
from core.workspace.git_utils import (
get_binary_file_content_from_ref as _get_binary_file_content_from_ref,
)
from core.workspace.git_utils import (
get_changed_files_from_branch as _get_changed_files_from_branch,
)
from core.workspace.git_utils import (
get_file_content_from_ref as _get_file_content_from_ref,
)
from core.workspace.git_utils import (
is_binary_file as _is_binary_file,
)
from core.workspace.git_utils import (
is_lock_file as _is_lock_file,
)
@@ -245,16 +239,14 @@ def merge_existing_build(
if smart_result is not None:
# Smart merge handled it (success or identified conflicts)
if smart_result.get("success"):
# Check if smart merge actually DID work (resolved conflicts via AI)
# NOTE: "files_merged" in stats is misleading - it's "files TO merge" not "files WERE merged"
# The smart merge preview returns this count but doesn't actually perform the merge
# in the no-conflict path. We only skip git merge if AI actually did work.
# Check if smart merge resolved git conflicts or path-mapped files
stats = smart_result.get("stats", {})
had_conflicts = stats.get("conflicts_resolved", 0) > 0
files_merged = stats.get("files_merged", 0) > 0
ai_assisted = stats.get("ai_assisted", 0) > 0
if had_conflicts or ai_assisted:
# AI actually resolved conflicts or assisted with merges
if had_conflicts or files_merged or ai_assisted:
# Git conflicts were resolved OR path-mapped files were AI merged
# Changes are already written and staged - no need for git merge
_print_merge_success(
no_commit, stats, spec_name=spec_name, keep_worktree=True
@@ -266,8 +258,7 @@ def merge_existing_build(
return True
else:
# No conflicts needed AI resolution - do standard git merge
# This is the common case: no divergence, just need to merge changes
# No conflicts and no files merged - do standard git merge
success_result = manager.merge_worktree(
spec_name, delete_after=False, no_commit=no_commit
)
@@ -782,44 +773,28 @@ def _resolve_git_conflicts_with_ai(
print(muted(f" Copying {len(new_files)} new file(s) first (dependencies)..."))
for file_path, status in new_files:
try:
# Apply path mapping - write to new location if file was renamed
target_file_path = _apply_path_mapping(file_path, path_mappings)
target_path = project_dir / target_file_path
target_path.parent.mkdir(parents=True, exist_ok=True)
# Handle binary files differently - use bytes instead of text
if _is_binary_file(file_path):
binary_content = _get_binary_file_content_from_ref(
project_dir, spec_branch, file_path
content = _get_file_content_from_ref(
project_dir, spec_branch, file_path
)
if content is not None:
# Apply path mapping - write to new location if file was renamed
target_file_path = _apply_path_mapping(file_path, path_mappings)
target_path = project_dir / target_file_path
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(content, encoding="utf-8")
subprocess.run(
["git", "add", target_file_path],
cwd=project_dir,
capture_output=True,
)
if binary_content is not None:
target_path.write_bytes(binary_content)
subprocess.run(
["git", "add", target_file_path],
cwd=project_dir,
capture_output=True,
resolved_files.append(target_file_path)
if target_file_path != file_path:
debug(
MODULE,
f"Copied new file with path mapping: {file_path} -> {target_file_path}",
)
resolved_files.append(target_file_path)
debug(MODULE, f"Copied new binary file: {file_path}")
else:
content = _get_file_content_from_ref(
project_dir, spec_branch, file_path
)
if content is not None:
target_path.write_text(content, encoding="utf-8")
subprocess.run(
["git", "add", target_file_path],
cwd=project_dir,
capture_output=True,
)
resolved_files.append(target_file_path)
if target_file_path != file_path:
debug(
MODULE,
f"Copied new file with path mapping: {file_path} -> {target_file_path}",
)
else:
debug(MODULE, f"Copied new file: {file_path}")
else:
debug(MODULE, f"Copied new file: {file_path}")
except Exception as e:
debug_warning(MODULE, f"Could not copy new file {file_path}: {e}")
@@ -1143,44 +1118,24 @@ def _resolve_git_conflicts_with_ai(
)
else:
# Modified without path change - simple copy
# Check if binary file to use correct read/write method
target_path = project_dir / target_file_path
target_path.parent.mkdir(parents=True, exist_ok=True)
if _is_binary_file(file_path):
binary_content = _get_binary_file_content_from_ref(
project_dir, spec_branch, file_path
content = _get_file_content_from_ref(
project_dir, spec_branch, file_path
)
if content is not None:
target_path = project_dir / target_file_path
target_path.parent.mkdir(parents=True, exist_ok=True)
target_path.write_text(content, encoding="utf-8")
subprocess.run(
["git", "add", target_file_path],
cwd=project_dir,
capture_output=True,
)
if binary_content is not None:
target_path.write_bytes(binary_content)
subprocess.run(
["git", "add", target_file_path],
cwd=project_dir,
capture_output=True,
resolved_files.append(target_file_path)
if target_file_path != file_path:
debug(
MODULE,
f"Merged with path mapping: {file_path} -> {target_file_path}",
)
resolved_files.append(target_file_path)
if target_file_path != file_path:
debug(
MODULE,
f"Merged binary with path mapping: {file_path} -> {target_file_path}",
)
else:
content = _get_file_content_from_ref(
project_dir, spec_branch, file_path
)
if content is not None:
target_path.write_text(content, encoding="utf-8")
subprocess.run(
["git", "add", target_file_path],
cwd=project_dir,
capture_output=True,
)
resolved_files.append(target_file_path)
if target_file_path != file_path:
debug(
MODULE,
f"Merged with path mapping: {file_path} -> {target_file_path}",
)
except Exception as e:
print(muted(f" Warning: Could not process {file_path}: {e}"))
@@ -1476,9 +1431,7 @@ async def _merge_file_with_ai_async(
msg_type = type(msg).__name__
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
for block in msg.content:
# Must check block type - only TextBlock has .text attribute
block_type = type(block).__name__
if block_type == "TextBlock" and hasattr(block, "text"):
if hasattr(block, "text"):
response_text += block.text
if response_text:
-3
View File
@@ -62,7 +62,6 @@ from .git_utils import (
MAX_SYNTAX_FIX_RETRIES,
MERGE_LOCK_TIMEOUT,
_create_conflict_file_with_git,
_get_binary_file_content_from_ref,
_get_changed_files_from_branch,
_get_file_content_from_ref,
_is_binary_file,
@@ -71,7 +70,6 @@ from .git_utils import (
_is_process_running,
_validate_merged_syntax,
create_conflict_file_with_git,
get_binary_file_content_from_ref,
get_changed_files_from_branch,
get_current_branch,
get_existing_build_worktree,
@@ -119,7 +117,6 @@ __all__ = [
"get_current_branch",
"get_existing_build_worktree",
"get_file_content_from_ref",
"get_binary_file_content_from_ref",
"get_changed_files_from_branch",
"is_process_running",
"is_binary_file",
+41 -119
View File
@@ -10,45 +10,6 @@ import json
import subprocess
from pathlib import Path
from core.git_executable import get_git_executable, run_git
__all__ = [
# Exported helpers
"get_git_executable",
"run_git",
# Constants
"MAX_FILE_LINES_FOR_AI",
"MAX_PARALLEL_AI_MERGES",
"LOCK_FILES",
"BINARY_EXTENSIONS",
"MERGE_LOCK_TIMEOUT",
"MAX_SYNTAX_FIX_RETRIES",
# Functions
"detect_file_renames",
"apply_path_mapping",
"get_merge_base",
"has_uncommitted_changes",
"get_current_branch",
"get_existing_build_worktree",
"get_file_content_from_ref",
"get_binary_file_content_from_ref",
"get_changed_files_from_branch",
"is_process_running",
"is_binary_file",
"is_lock_file",
"validate_merged_syntax",
"create_conflict_file_with_git",
# Backward compat aliases
"_is_process_running",
"_is_binary_file",
"_is_lock_file",
"_validate_merged_syntax",
"_get_file_content_from_ref",
"_get_binary_file_content_from_ref",
"_get_changed_files_from_branch",
"_create_conflict_file_with_git",
]
# 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
@@ -72,7 +33,6 @@ LOCK_FILES = {
}
BINARY_EXTENSIONS = {
# Images
".png",
".jpg",
".jpeg",
@@ -81,11 +41,6 @@ BINARY_EXTENSIONS = {
".webp",
".bmp",
".svg",
".tiff",
".tif",
".heic",
".heif",
# Documents
".pdf",
".doc",
".docx",
@@ -93,63 +48,32 @@ BINARY_EXTENSIONS = {
".xlsx",
".ppt",
".pptx",
# Archives
".zip",
".tar",
".gz",
".rar",
".7z",
".bz2",
".xz",
".zst",
# Executables and libraries
".exe",
".dll",
".so",
".dylib",
".bin",
".msi",
".app",
# WebAssembly
".wasm",
# Audio
".mp3",
".wav",
".ogg",
".flac",
".aac",
".m4a",
# Video
".mp4",
".wav",
".avi",
".mov",
".mkv",
".webm",
".wmv",
".flv",
# Fonts
".woff",
".woff2",
".ttf",
".otf",
".eot",
# Compiled code
".pyc",
".pyo",
".class",
".o",
".obj",
# Data files
".dat",
".db",
".sqlite",
".sqlite3",
# Other binary formats
".cur",
".ani",
".pbm",
".pgm",
".ppm",
}
# Merge lock timeout in seconds
@@ -189,8 +113,9 @@ def detect_file_renames(
# -M flag enables rename detection
# --diff-filter=R shows only renames
# --name-status shows status and file names
result = run_git(
result = subprocess.run(
[
"git",
"log",
"--name-status",
"-M",
@@ -199,6 +124,8 @@ def detect_file_renames(
f"{from_ref}..{to_ref}",
],
cwd=project_dir,
capture_output=True,
text=True,
)
if result.returncode == 0:
@@ -248,21 +175,39 @@ def get_merge_base(project_dir: Path, ref1: str, ref2: str) -> str | None:
Returns:
Merge-base commit hash, or None if not found
"""
result = run_git(["merge-base", ref1, ref2], cwd=project_dir)
if result.returncode == 0:
return result.stdout.strip()
try:
result = subprocess.run(
["git", "merge-base", ref1, ref2],
cwd=project_dir,
capture_output=True,
text=True,
)
if result.returncode == 0:
return result.stdout.strip()
except Exception:
pass
return None
def has_uncommitted_changes(project_dir: Path) -> bool:
"""Check if user has unsaved work."""
result = run_git(["status", "--porcelain"], cwd=project_dir)
result = subprocess.run(
["git", "status", "--porcelain"],
cwd=project_dir,
capture_output=True,
text=True,
)
return bool(result.stdout.strip())
def get_current_branch(project_dir: Path) -> str:
"""Get the current branch name."""
result = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=project_dir)
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
)
return result.stdout.strip()
@@ -294,29 +239,11 @@ 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.)."""
result = run_git(["show", f"{ref}:{file_path}"], cwd=project_dir)
if result.returncode == 0:
return result.stdout
return None
def get_binary_file_content_from_ref(
project_dir: Path, ref: str, file_path: str
) -> bytes | None:
"""Get binary file content from a git ref (branch, commit, etc.).
Unlike get_file_content_from_ref, this returns raw bytes without
text decoding, suitable for binary files like images, audio, etc.
Note: Uses subprocess directly with get_git_executable() since
run_git() always returns text output.
"""
git = get_git_executable()
result = subprocess.run(
[git, "show", f"{ref}:{file_path}"],
["git", "show", f"{ref}:{file_path}"],
cwd=project_dir,
capture_output=True,
text=False, # Return bytes, not text
text=True,
)
if result.returncode == 0:
return result.stdout
@@ -341,9 +268,11 @@ def get_changed_files_from_branch(
Returns:
List of (file_path, status) tuples
"""
result = run_git(
["diff", "--name-status", f"{base_branch}...{spec_branch}"],
result = subprocess.run(
["git", "diff", "--name-status", f"{base_branch}...{spec_branch}"],
cwd=project_dir,
capture_output=True,
text=True,
)
files = []
@@ -360,23 +289,15 @@ def get_changed_files_from_branch(
return files
def _normalize_path(path: str) -> str:
"""Normalize path separators to forward slashes for cross-platform comparison."""
return path.replace("\\", "/")
def _is_auto_claude_file(file_path: str) -> bool:
"""Check if a file is in the .auto-claude or auto-claude/specs directory.
Handles both forward slashes (Unix/Git output) and backslashes (Windows).
"""
normalized = _normalize_path(file_path)
"""Check if a file is in the .auto-claude or auto-claude/specs directory."""
# These patterns cover the internal spec/build files that shouldn't be merged
excluded_patterns = [
".auto-claude/",
"auto-claude/specs/",
]
for pattern in excluded_patterns:
if normalized.startswith(pattern):
if file_path.startswith(pattern):
return True
return False
@@ -570,9 +491,11 @@ def create_conflict_file_with_git(
try:
# git merge-file <current> <base> <other>
# Exit codes: 0 = clean merge, 1 = conflicts, >1 = error
result = run_git(
["merge-file", "-p", main_path, base_path, wt_path],
result = subprocess.run(
["git", "merge-file", "-p", main_path, base_path, wt_path],
cwd=project_dir,
capture_output=True,
text=True,
)
# Read the merged content
@@ -599,6 +522,5 @@ _is_binary_file = is_binary_file
_is_lock_file = is_lock_file
_validate_merged_syntax = validate_merged_syntax
_get_file_content_from_ref = get_file_content_from_ref
_get_binary_file_content_from_ref = get_binary_file_content_from_ref
_get_changed_files_from_branch = get_changed_files_from_branch
_create_conflict_file_with_git = create_conflict_file_with_git
+5 -32
View File
@@ -8,12 +8,11 @@ Functions for setting up and initializing workspaces.
import json
import shutil
import subprocess
import sys
from pathlib import Path
from core.git_executable import run_git
from merge import FileTimelineTracker
from security.constants import ALLOWLIST_FILENAME, PROFILE_FILENAME
from ui import (
Icons,
MenuOption,
@@ -268,34 +267,6 @@ def setup_workspace(
f"Environment files copied: {', '.join(copied_env_files)}", "success"
)
# Copy security configuration files if they exist
# Note: Unlike env files, security files always overwrite to ensure
# the worktree uses the same security rules as the main project.
# This prevents security bypasses through stale worktree configs.
security_files = [
ALLOWLIST_FILENAME,
PROFILE_FILENAME,
]
security_files_copied = []
for filename in security_files:
source_file = project_dir / filename
if source_file.is_file():
target_file = worktree_info.path / filename
try:
shutil.copy2(source_file, target_file)
security_files_copied.append(filename)
except (OSError, PermissionError) as e:
debug_warning(MODULE, f"Failed to copy {filename}: {e}")
print_status(
f"Warning: Could not copy {filename} to worktree", "warning"
)
if security_files_copied:
print_status(
f"Security config copied: {', '.join(security_files_copied)}", "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.
@@ -406,9 +377,11 @@ def initialize_timeline_tracking(
files_to_modify.extend(subtask.get("files", []))
# Get the current branch point commit
result = run_git(
["rev-parse", "HEAD"],
result = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
)
branch_point = result.stdout.strip() if result.returncode == 0 else None
+47 -744
View File
@@ -19,126 +19,8 @@ import os
import re
import shutil
import subprocess
import time
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import TypedDict, TypeVar
from core.git_executable import get_git_executable, run_git
from debug import debug_warning
T = TypeVar("T")
def _is_retryable_network_error(stderr: str) -> bool:
"""Check if an error is a retryable network/connection issue."""
stderr_lower = stderr.lower()
return any(
term in stderr_lower
for term in ["connection", "network", "timeout", "reset", "refused"]
)
def _is_retryable_http_error(stderr: str) -> bool:
"""
Check if an HTTP error is retryable (5xx errors, timeouts).
Excludes auth errors (401, 403) and client errors (404, 422).
"""
stderr_lower = stderr.lower()
# Check for HTTP 5xx errors (server errors are retryable)
if re.search(r"http[s]?\s*5\d{2}", stderr_lower):
return True
# Check for HTTP timeout patterns
if "http" in stderr_lower and "timeout" in stderr_lower:
return True
return False
def _with_retry(
operation: Callable[[], tuple[bool, T | None, str]],
max_retries: int = 3,
is_retryable: Callable[[str], bool] | None = None,
on_retry: Callable[[int, str], None] | None = None,
) -> tuple[T | None, str]:
"""
Execute an operation with retry logic.
Args:
operation: Function that returns a tuple of (success: bool, result: T | None, error: str).
On success (success=True), result contains the value and error is empty.
On failure (success=False), result is None and error contains the message.
max_retries: Maximum number of retry attempts
is_retryable: Function to check if error is retryable based on error message
on_retry: Optional callback called before each retry with (attempt, error)
Returns:
Tuple of (result, last_error) where result is T on success, None on failure
"""
last_error = ""
for attempt in range(1, max_retries + 1):
try:
success, result, error = operation()
if success:
return result, ""
last_error = error
# Check if error is retryable
if is_retryable and attempt < max_retries and is_retryable(error):
if on_retry:
on_retry(attempt, error)
backoff = 2 ** (attempt - 1)
time.sleep(backoff)
continue
break
except subprocess.TimeoutExpired:
last_error = "Operation timed out"
if attempt < max_retries:
if on_retry:
on_retry(attempt, last_error)
backoff = 2 ** (attempt - 1)
time.sleep(backoff)
continue
break
return None, last_error
class PushBranchResult(TypedDict, total=False):
"""Result of pushing a branch to remote."""
success: bool
branch: str
remote: str
error: str
class PullRequestResult(TypedDict, total=False):
"""Result of creating a pull request."""
success: bool
pr_url: str | None # None when PR was created but URL couldn't be extracted
already_exists: bool
error: str
message: str
class PushAndCreatePRResult(TypedDict, total=False):
"""Result of push_and_create_pr operation."""
success: bool
pushed: bool
remote: str
branch: str
pr_url: str | None # None when PR was created but URL couldn't be extracted
already_exists: bool
error: str
message: str
class WorktreeError(Exception):
@@ -160,8 +42,6 @@ class WorktreeInfo:
files_changed: int = 0
additions: int = 0
deletions: int = 0
last_commit_date: datetime | None = None
days_since_last_commit: int | None = None
class WorktreeManager:
@@ -172,11 +52,6 @@ class WorktreeManager:
a corresponding branch auto-claude/{spec-name}.
"""
# Timeout constants for subprocess operations
GIT_PUSH_TIMEOUT = 120 # 2 minutes for git push (network operations)
GH_CLI_TIMEOUT = 60 # 1 minute for gh CLI commands
GH_QUERY_TIMEOUT = 30 # 30 seconds for gh CLI queries
def __init__(self, project_dir: Path, base_branch: str | None = None):
self.project_dir = project_dir
self.base_branch = base_branch or self._detect_base_branch()
@@ -199,9 +74,13 @@ class WorktreeManager:
env_branch = os.getenv("DEFAULT_BRANCH")
if env_branch:
# Verify the branch exists
result = run_git(
["rev-parse", "--verify", env_branch],
result = subprocess.run(
["git", "rev-parse", "--verify", env_branch],
cwd=self.project_dir,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if result.returncode == 0:
return env_branch
@@ -212,9 +91,13 @@ class WorktreeManager:
# 2. Auto-detect main/master
for branch in ["main", "master"]:
result = run_git(
["rev-parse", "--verify", branch],
result = subprocess.run(
["git", "rev-parse", "--verify", branch],
cwd=self.project_dir,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if result.returncode == 0:
return branch
@@ -228,9 +111,13 @@ class WorktreeManager:
def _get_current_branch(self) -> str:
"""Get the current git branch."""
result = run_git(
["rev-parse", "--abbrev-ref", "HEAD"],
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=self.project_dir,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if result.returncode != 0:
raise WorktreeError(f"Failed to get current branch: {result.stderr}")
@@ -250,7 +137,24 @@ class WorktreeManager:
CompletedProcess with command results. On timeout, returns a
CompletedProcess with returncode=-1 and timeout error in stderr.
"""
return run_git(args, cwd=cwd or self.project_dir, timeout=timeout)
try:
return subprocess.run(
["git"] + args,
cwd=cwd or self.project_dir,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout,
)
except subprocess.TimeoutExpired:
# Return a failed result on timeout instead of raising
return subprocess.CompletedProcess(
args=["git"] + args,
returncode=-1,
stdout="",
stderr=f"Command timed out after {timeout} seconds",
)
def _unstage_gitignored_files(self) -> None:
"""
@@ -273,10 +177,14 @@ class WorktreeManager:
# 1. Check which staged files are gitignored
# git check-ignore returns the files that ARE ignored
result = run_git(
["check-ignore", "--stdin"],
result = subprocess.run(
["git", "check-ignore", "--stdin"],
cwd=self.project_dir,
input_data="\n".join(staged_files),
input="\n".join(staged_files),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if result.stdout.strip():
@@ -291,10 +199,8 @@ class WorktreeManager:
file = file.strip()
if not file:
continue
# Normalize path separators for cross-platform (Windows backslash support)
normalized = file.replace("\\", "/")
for pattern in auto_claude_patterns:
if normalized.startswith(pattern) or f"/{pattern}" in normalized:
if file.startswith(pattern) or f"/{pattern}" in file:
files_to_unstage.add(file)
break
@@ -313,19 +219,8 @@ class WorktreeManager:
# ==================== Per-Spec Worktree Methods ====================
def get_worktree_path(self, spec_name: str) -> Path:
"""Get the worktree path for a spec (checks new and legacy locations)."""
# New path first (.auto-claude/worktrees/tasks/)
new_path = self.worktrees_dir / spec_name
if new_path.exists():
return new_path
# Legacy fallback (.worktrees/ instead of .auto-claude/worktrees/tasks/)
legacy_path = self.project_dir / ".worktrees" / spec_name
if legacy_path.exists():
return legacy_path
# Return new path as default for creation
return new_path
"""Get the worktree path for a spec."""
return self.worktrees_dir / spec_name
def get_branch_name(self, spec_name: str) -> str:
"""Get the branch name for a spec."""
@@ -386,8 +281,6 @@ class WorktreeManager:
"files_changed": 0,
"additions": 0,
"deletions": 0,
"last_commit_date": None,
"days_since_last_commit": None,
}
if not worktree_path.exists():
@@ -400,52 +293,6 @@ class WorktreeManager:
if result.returncode == 0:
stats["commit_count"] = int(result.stdout.strip() or "0")
# Last commit date (most recent commit in this worktree)
result = self._run_git(
["log", "-1", "--format=%cd", "--date=iso"], cwd=worktree_path
)
if result.returncode == 0 and result.stdout.strip():
try:
# Parse ISO date format: "2026-01-04 00:25:25 +0100"
date_str = result.stdout.strip()
# Convert git format to ISO format for fromisoformat()
# "2026-01-04 00:25:25 +0100" -> "2026-01-04T00:25:25+01:00"
parts = date_str.rsplit(" ", 1)
if len(parts) == 2:
date_part, tz_part = parts
# Convert timezone format: "+0100" -> "+01:00"
if len(tz_part) == 5 and (
tz_part.startswith("+") or tz_part.startswith("-")
):
tz_formatted = f"{tz_part[:3]}:{tz_part[3:]}"
iso_str = f"{date_part.replace(' ', 'T')}{tz_formatted}"
last_commit_date = datetime.fromisoformat(iso_str)
stats["last_commit_date"] = last_commit_date
# Use timezone-aware now() for accurate comparison
now_aware = datetime.now(last_commit_date.tzinfo)
stats["days_since_last_commit"] = (
now_aware - last_commit_date
).days
else:
# Fallback for unexpected timezone format
last_commit_date = datetime.strptime(
parts[0], "%Y-%m-%d %H:%M:%S"
)
stats["last_commit_date"] = last_commit_date
stats["days_since_last_commit"] = (
datetime.now() - last_commit_date
).days
else:
# No timezone in output
last_commit_date = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
stats["last_commit_date"] = last_commit_date
stats["days_since_last_commit"] = (
datetime.now() - last_commit_date
).days
except (ValueError, TypeError) as e:
# If parsing fails, silently continue without date info
pass
# Diff stats
result = self._run_git(
["diff", "--shortstat", f"{self.base_branch}...HEAD"], cwd=worktree_path
@@ -672,27 +519,15 @@ class WorktreeManager:
# ==================== Listing & Discovery ====================
def list_all_worktrees(self) -> list[WorktreeInfo]:
"""List all spec worktrees (includes legacy .worktrees/ location)."""
"""List all spec worktrees."""
worktrees = []
seen_specs = set()
# Check new location first
if self.worktrees_dir.exists():
for item in self.worktrees_dir.iterdir():
if item.is_dir():
info = self.get_worktree_info(item.name)
if info:
worktrees.append(info)
seen_specs.add(item.name)
# Check legacy location (.worktrees/)
legacy_dir = self.project_dir / ".worktrees"
if legacy_dir.exists():
for item in legacy_dir.iterdir():
if item.is_dir() and item.name not in seen_specs:
info = self.get_worktree_info(item.name)
if info:
worktrees.append(info)
return worktrees
@@ -803,535 +638,3 @@ class WorktreeManager:
cwd = worktree_path
result = self._run_git(["status", "--porcelain"], cwd=cwd)
return bool(result.stdout.strip())
# ==================== PR Creation Methods ====================
def push_branch(self, spec_name: str, force: bool = False) -> PushBranchResult:
"""
Push a spec's branch to the remote origin with retry logic.
Args:
spec_name: The spec folder name
force: Whether to force push (use with caution)
Returns:
PushBranchResult with keys:
- success: bool
- branch: str (branch name)
- remote: str (if successful)
- error: str (if failed)
"""
info = self.get_worktree_info(spec_name)
if not info:
return PushBranchResult(
success=False,
error=f"No worktree found for spec: {spec_name}",
)
# Push the branch to origin
push_args = ["push", "-u", "origin", info.branch]
if force:
push_args.insert(1, "--force")
def do_push() -> tuple[bool, PushBranchResult | None, str]:
"""Execute push operation for retry wrapper."""
try:
git_executable = get_git_executable()
result = subprocess.run(
[git_executable] + push_args,
cwd=info.path,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=self.GIT_PUSH_TIMEOUT,
)
if result.returncode == 0:
return (
True,
PushBranchResult(
success=True,
branch=info.branch,
remote="origin",
),
"",
)
return (False, None, result.stderr)
except FileNotFoundError:
return (False, None, "git executable not found")
max_retries = 3
result, last_error = _with_retry(
operation=do_push,
max_retries=max_retries,
is_retryable=_is_retryable_network_error,
)
if result:
return result
# Handle timeout error message
if last_error == "Operation timed out":
return PushBranchResult(
success=False,
branch=info.branch,
error=f"Push timed out after {max_retries} attempts.",
)
return PushBranchResult(
success=False,
branch=info.branch,
error=f"Failed to push branch: {last_error}",
)
def create_pull_request(
self,
spec_name: str,
target_branch: str | None = None,
title: str | None = None,
draft: bool = False,
) -> PullRequestResult:
"""
Create a GitHub pull request for a spec's branch using gh CLI with retry logic.
Args:
spec_name: The spec folder name
target_branch: Target branch for PR (defaults to base_branch)
title: PR title (defaults to spec name)
draft: Whether to create as draft PR
Returns:
PullRequestResult with keys:
- success: bool
- pr_url: str (if created)
- already_exists: bool (if PR already exists)
- error: str (if failed)
"""
info = self.get_worktree_info(spec_name)
if not info:
return PullRequestResult(
success=False,
error=f"No worktree found for spec: {spec_name}",
)
target = target_branch or self.base_branch
pr_title = title or f"auto-claude: {spec_name}"
# Get PR body from spec.md if available
pr_body = self._extract_spec_summary(spec_name)
# Build gh pr create command
gh_args = [
"gh",
"pr",
"create",
"--base",
target,
"--head",
info.branch,
"--title",
pr_title,
"--body",
pr_body,
]
if draft:
gh_args.append("--draft")
def is_pr_retryable(stderr: str) -> bool:
"""Check if PR creation error is retryable (network or HTTP 5xx)."""
return _is_retryable_network_error(stderr) or _is_retryable_http_error(
stderr
)
def do_create_pr() -> tuple[bool, PullRequestResult | None, str]:
"""Execute PR creation for retry wrapper."""
try:
result = subprocess.run(
gh_args,
cwd=info.path,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=self.GH_CLI_TIMEOUT,
)
# Check for "already exists" case (success, no retry needed)
if result.returncode != 0 and "already exists" in result.stderr.lower():
existing_url = self._get_existing_pr_url(spec_name, target)
result_dict = PullRequestResult(
success=True,
pr_url=existing_url,
already_exists=True,
)
if existing_url is None:
result_dict["message"] = (
"PR already exists but URL could not be retrieved"
)
return (True, result_dict, "")
if result.returncode == 0:
# Extract PR URL from output
pr_url: str | None = result.stdout.strip()
if not pr_url.startswith("http"):
# Try to find URL in output
# Use general pattern to support GitHub Enterprise instances
# Matches any HTTPS URL with /pull/<number> path
match = re.search(r"https://[^\s]+/pull/\d+", result.stdout)
if match:
pr_url = match.group(0)
else:
# Invalid output - no valid URL found
pr_url = None
return (
True,
PullRequestResult(
success=True,
pr_url=pr_url,
already_exists=False,
),
"",
)
return (False, None, result.stderr)
except FileNotFoundError:
# gh CLI not installed - not retryable, raise to exit retry loop
raise
max_retries = 3
try:
result, last_error = _with_retry(
operation=do_create_pr,
max_retries=max_retries,
is_retryable=is_pr_retryable,
)
if result:
return result
# Handle timeout error message
if last_error == "Operation timed out":
return PullRequestResult(
success=False,
error=f"PR creation timed out after {max_retries} attempts.",
)
return PullRequestResult(
success=False,
error=f"Failed to create PR: {last_error}",
)
except FileNotFoundError:
# gh CLI not installed
return PullRequestResult(
success=False,
error="gh CLI not found. Install from https://cli.github.com/",
)
def _extract_spec_summary(self, spec_name: str) -> str:
"""Extract a summary from spec.md for PR body."""
worktree_path = self.get_worktree_path(spec_name)
spec_path = worktree_path / ".auto-claude" / "specs" / spec_name / "spec.md"
if not spec_path.exists():
# Try project spec path
spec_path = (
self.project_dir / ".auto-claude" / "specs" / spec_name / "spec.md"
)
if not spec_path.exists():
return "Auto-generated PR from Auto-Claude build."
try:
content = spec_path.read_text(encoding="utf-8")
# Extract first few paragraphs (skip title, get overview)
lines = content.split("\n")
summary_lines = []
in_content = False
for line in lines:
# Skip title headers
if line.startswith("# "):
continue
# Start capturing after first content line
if line.strip() and not line.startswith("#"):
in_content = True
if in_content:
if line.startswith("## ") and summary_lines:
break # Stop at next section
summary_lines.append(line)
if len(summary_lines) >= 10: # Limit to ~10 lines
break
summary = "\n".join(summary_lines).strip()
if summary:
return summary
except (OSError, UnicodeDecodeError) as e:
# Silently fall back to default - file read errors shouldn't block PR creation
debug_warning(
"worktree", f"Could not extract spec summary for PR body: {e}"
)
return "Auto-generated PR from Auto-Claude build."
def _get_existing_pr_url(self, spec_name: str, target_branch: str) -> str | None:
"""Get the URL of an existing PR for this branch."""
info = self.get_worktree_info(spec_name)
if not info:
return None
try:
result = subprocess.run(
["gh", "pr", "view", info.branch, "--json", "url", "--jq", ".url"],
cwd=info.path,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=self.GH_QUERY_TIMEOUT,
)
if result.returncode == 0:
return result.stdout.strip()
except (
subprocess.TimeoutExpired,
FileNotFoundError,
subprocess.SubprocessError,
) as e:
# Silently ignore errors when fetching existing PR URL - this is a best-effort
# lookup that may fail due to network issues, missing gh CLI, or auth problems.
# Returning None allows the caller to handle missing URLs gracefully.
debug_warning("worktree", f"Could not get existing PR URL: {e}")
return None
def push_and_create_pr(
self,
spec_name: str,
target_branch: str | None = None,
title: str | None = None,
draft: bool = False,
force_push: bool = False,
) -> PushAndCreatePRResult:
"""
Push branch and create a pull request in one operation.
Args:
spec_name: The spec folder name
target_branch: Target branch for PR (defaults to base_branch)
title: PR title (defaults to spec name)
draft: Whether to create as draft PR
force_push: Whether to force push the branch
Returns:
PushAndCreatePRResult with keys:
- success: bool
- pr_url: str (if created)
- pushed: bool (if push succeeded)
- already_exists: bool (if PR already exists)
- error: str (if failed)
"""
# Step 1: Push the branch
push_result = self.push_branch(spec_name, force=force_push)
if not push_result.get("success"):
return PushAndCreatePRResult(
success=False,
pushed=False,
error=push_result.get("error", "Push failed"),
)
# Step 2: Create the PR
pr_result = self.create_pull_request(
spec_name=spec_name,
target_branch=target_branch,
title=title,
draft=draft,
)
# Combine results
return PushAndCreatePRResult(
success=pr_result.get("success", False),
pushed=True,
remote=push_result.get("remote"),
branch=push_result.get("branch"),
pr_url=pr_result.get("pr_url"),
already_exists=pr_result.get("already_exists", False),
error=pr_result.get("error"),
)
# ==================== Worktree Cleanup Methods ====================
def get_old_worktrees(
self, days_threshold: int = 30, include_stats: bool = False
) -> list[WorktreeInfo] | list[str]:
"""
Find worktrees that haven't been modified in the specified number of days.
Args:
days_threshold: Number of days without activity to consider a worktree old (default: 30)
include_stats: If True, return full WorktreeInfo objects; if False, return just spec names
Returns:
List of old worktrees (either WorktreeInfo objects or spec names based on include_stats)
"""
old_worktrees = []
for worktree_info in self.list_all_worktrees():
# Skip if we can't determine age
if worktree_info.days_since_last_commit is None:
continue
if worktree_info.days_since_last_commit >= days_threshold:
if include_stats:
old_worktrees.append(worktree_info)
else:
old_worktrees.append(worktree_info.spec_name)
return old_worktrees
def cleanup_old_worktrees(
self, days_threshold: int = 30, dry_run: bool = False
) -> tuple[list[str], list[str]]:
"""
Remove worktrees that haven't been modified in the specified number of days.
Args:
days_threshold: Number of days without activity to consider a worktree old (default: 30)
dry_run: If True, only report what would be removed without actually removing
Returns:
Tuple of (removed_specs, failed_specs) containing spec names
"""
old_worktrees = self.get_old_worktrees(
days_threshold=days_threshold, include_stats=True
)
if not old_worktrees:
print(f"No worktrees found older than {days_threshold} days.")
return ([], [])
removed = []
failed = []
if dry_run:
print(f"\n[DRY RUN] Would remove {len(old_worktrees)} old worktrees:")
for info in old_worktrees:
print(
f" - {info.spec_name} (last activity: {info.days_since_last_commit} days ago)"
)
return ([], [])
print(f"\nRemoving {len(old_worktrees)} old worktrees...")
for info in old_worktrees:
try:
self.remove_worktree(info.spec_name, delete_branch=True)
removed.append(info.spec_name)
print(
f" ✓ Removed {info.spec_name} (last activity: {info.days_since_last_commit} days ago)"
)
except Exception as e:
failed.append(info.spec_name)
print(f" ✗ Failed to remove {info.spec_name}: {e}")
if removed:
print(f"\nSuccessfully removed {len(removed)} worktree(s).")
if failed:
print(f"Failed to remove {len(failed)} worktree(s).")
return (removed, failed)
def get_worktree_count_warning(
self, warning_threshold: int = 10, critical_threshold: int = 20
) -> str | None:
"""
Check worktree count and return a warning message if threshold is exceeded.
Args:
warning_threshold: Number of worktrees to trigger a warning (default: 10)
critical_threshold: Number of worktrees to trigger a critical warning (default: 20)
Returns:
Warning message string if threshold exceeded, None otherwise
"""
worktrees = self.list_all_worktrees()
count = len(worktrees)
if count >= critical_threshold:
old_worktrees = self.get_old_worktrees(days_threshold=30)
old_count = len(old_worktrees)
return (
f"CRITICAL: {count} worktrees detected! "
f"Consider cleaning up old worktrees ({old_count} are 30+ days old). "
f"Run cleanup to remove stale worktrees."
)
elif count >= warning_threshold:
old_worktrees = self.get_old_worktrees(days_threshold=30)
old_count = len(old_worktrees)
return (
f"WARNING: {count} worktrees detected. "
f"{old_count} are 30+ days old and may be safe to clean up."
)
return None
def print_worktree_summary(self) -> None:
"""Print a summary of all worktrees with age information."""
worktrees = self.list_all_worktrees()
if not worktrees:
print("No worktrees found.")
return
print(f"\n{'=' * 80}")
print(f"Worktree Summary ({len(worktrees)} total)")
print(f"{'=' * 80}\n")
# Group by age
recent = [] # < 7 days
week_old = [] # 7-30 days
month_old = [] # 30-90 days
very_old = [] # > 90 days
unknown_age = []
for info in worktrees:
if info.days_since_last_commit is None:
unknown_age.append(info)
elif info.days_since_last_commit < 7:
recent.append(info)
elif info.days_since_last_commit < 30:
week_old.append(info)
elif info.days_since_last_commit < 90:
month_old.append(info)
else:
very_old.append(info)
def print_group(title: str, items: list[WorktreeInfo]):
if not items:
return
print(f"{title} ({len(items)}):")
for info in sorted(items, key=lambda x: x.spec_name):
age_str = (
f"{info.days_since_last_commit}d ago"
if info.days_since_last_commit is not None
else "unknown"
)
print(f" - {info.spec_name} (last activity: {age_str})")
print()
print_group("Recent (< 7 days)", recent)
print_group("Week Old (7-30 days)", week_old)
print_group("Month Old (30-90 days)", month_old)
print_group("Very Old (> 90 days)", very_old)
print_group("Unknown Age", unknown_age)
# Print cleanup suggestions
if month_old or very_old:
total_old = len(month_old) + len(very_old)
print(f"{'=' * 80}")
print(
f"💡 Suggestion: {total_old} worktree(s) are 30+ days old and may be safe to clean up."
)
print(" Review these worktrees and run cleanup if no longer needed.")
print(f"{'=' * 80}\n")
+15 -114
View File
@@ -6,32 +6,6 @@ Handles first-time setup of .auto-claude directory and ensures proper gitignore
from pathlib import Path
# All entries that should be added to .gitignore for auto-claude projects
AUTO_CLAUDE_GITIGNORE_ENTRIES = [
".auto-claude/",
".auto-claude-security.json",
".auto-claude-status",
".claude_settings.json",
".worktrees/",
".security-key",
"logs/security/",
]
def _entry_exists_in_gitignore(lines: list[str], entry: str) -> bool:
"""Check if an entry already exists in gitignore (handles trailing slash variations)."""
entry_normalized = entry.rstrip("/")
for line in lines:
line_stripped = line.strip()
# Match both "entry" and "entry/"
if (
line_stripped == entry
or line_stripped == entry_normalized
or line_stripped == entry_normalized + "/"
):
return True
return False
def ensure_gitignore_entry(project_dir: Path, entry: str = ".auto-claude/") -> bool:
"""
@@ -53,8 +27,17 @@ def ensure_gitignore_entry(project_dir: Path, entry: str = ".auto-claude/") -> b
content = gitignore_path.read_text()
lines = content.splitlines()
if _entry_exists_in_gitignore(lines, entry):
return False # Already exists
# Check if entry already exists (exact match or with trailing newline variations)
entry_normalized = entry.rstrip("/")
for line in lines:
line_stripped = line.strip()
# Match both ".auto-claude" and ".auto-claude/"
if (
line_stripped == entry
or line_stripped == entry_normalized
or line_stripped == entry_normalized + "/"
):
return False # Already exists
# Entry doesn't exist, append it
# Ensure file ends with newline before adding our entry
@@ -76,58 +59,11 @@ def ensure_gitignore_entry(project_dir: Path, entry: str = ".auto-claude/") -> b
return True
def ensure_all_gitignore_entries(project_dir: Path) -> list[str]:
"""
Ensure all auto-claude related entries exist in the project's .gitignore file.
Creates .gitignore if it doesn't exist.
Args:
project_dir: The project root directory
Returns:
List of entries that were added (empty if all already existed)
"""
gitignore_path = project_dir / ".gitignore"
added_entries: list[str] = []
# Read existing content or start fresh
if gitignore_path.exists():
content = gitignore_path.read_text()
lines = content.splitlines()
else:
content = ""
lines = []
# Find entries that need to be added
entries_to_add = [
entry
for entry in AUTO_CLAUDE_GITIGNORE_ENTRIES
if not _entry_exists_in_gitignore(lines, entry)
]
if not entries_to_add:
return []
# Build the new content to append
# Ensure file ends with newline before adding our entries
if content and not content.endswith("\n"):
content += "\n"
content += "\n# Auto Claude generated files\n"
for entry in entries_to_add:
content += entry + "\n"
added_entries.append(entry)
gitignore_path.write_text(content)
return added_entries
def init_auto_claude_dir(project_dir: Path) -> tuple[Path, bool]:
"""
Initialize the .auto-claude directory for a project.
Creates the directory if needed and ensures all auto-claude files are in .gitignore.
Creates the directory if needed and ensures it's in .gitignore.
Args:
project_dir: The project root directory
@@ -142,18 +78,16 @@ def init_auto_claude_dir(project_dir: Path) -> tuple[Path, bool]:
dir_created = not auto_claude_dir.exists()
auto_claude_dir.mkdir(parents=True, exist_ok=True)
# Ensure all auto-claude entries are in .gitignore (only on first creation)
# Ensure .auto-claude is in .gitignore (only on first creation)
gitignore_updated = False
if dir_created:
added = ensure_all_gitignore_entries(project_dir)
gitignore_updated = len(added) > 0
gitignore_updated = ensure_gitignore_entry(project_dir, ".auto-claude/")
else:
# Even if dir exists, check gitignore on first run
# Use a marker file to track if we've already checked
marker = auto_claude_dir / ".gitignore_checked"
if not marker.exists():
added = ensure_all_gitignore_entries(project_dir)
gitignore_updated = len(added) > 0
gitignore_updated = ensure_gitignore_entry(project_dir, ".auto-claude/")
marker.touch()
return auto_claude_dir, gitignore_updated
@@ -175,36 +109,3 @@ def get_auto_claude_dir(project_dir: Path, ensure_exists: bool = True) -> Path:
return auto_claude_dir
return Path(project_dir) / ".auto-claude"
def repair_gitignore(project_dir: Path) -> list[str]:
"""
Repair an existing project's .gitignore to include all auto-claude entries.
This is useful for projects created before all entries were being added,
or when gitignore entries were manually removed.
Also resets the .gitignore_checked marker to allow future updates.
Args:
project_dir: The project root directory
Returns:
List of entries that were added (empty if all already existed)
"""
project_dir = Path(project_dir)
auto_claude_dir = project_dir / ".auto-claude"
# Remove the marker file so future checks will also run
marker = auto_claude_dir / ".gitignore_checked"
if marker.exists():
marker.unlink()
# Add all missing entries
added = ensure_all_gitignore_entries(project_dir)
# Re-create the marker
if auto_claude_dir.exists():
marker.touch()
return added
+3 -16
View File
@@ -622,23 +622,10 @@ def get_graphiti_status() -> dict:
status["errors"] = errors
# Errors are informational - embedder is optional (keyword search fallback)
# CRITICAL FIX: Actually verify packages are importable before reporting available
# Don't just check config.is_valid() - actually try to import the module
if not config.is_valid():
# Available if is_valid() returns True (just needs enabled flag)
status["available"] = config.is_valid()
if not status["available"]:
status["reason"] = errors[0] if errors else "Configuration invalid"
return status
# Try importing the required Graphiti packages
try:
# Attempt to import the main graphiti_memory module
import graphiti_core # noqa: F401
from graphiti_core.driver.falkordb_driver import FalkorDriver # noqa: F401
# If we got here, packages are importable
status["available"] = True
except ImportError as e:
status["available"] = False
status["reason"] = f"Graphiti packages not installed: {e}"
return status
@@ -34,25 +34,8 @@ def _apply_ladybug_monkeypatch() -> bool:
sys.modules["kuzu"] = real_ladybug
logger.info("Applied LadybugDB monkeypatch (kuzu -> real_ladybug)")
return True
except ImportError as e:
logger.debug(f"LadybugDB import failed: {e}")
# On Windows with Python 3.12+, provide more specific error details
# (pywin32 is only required for Python 3.12+ per requirements.txt)
if sys.platform == "win32" and sys.version_info >= (3, 12):
# Check if it's the pywin32 error using both name attribute and string match
# for robustness across Python versions
is_pywin32_error = (
(hasattr(e, "name") and e.name in ("pywintypes", "pywin32", "win32api"))
or "pywintypes" in str(e)
or "pywin32" in str(e)
)
if is_pywin32_error:
logger.error(
"LadybugDB requires pywin32 on Windows. "
"Install with: pip install pywin32>=306"
)
else:
logger.debug(f"Windows-specific import issue: {e}")
except ImportError:
pass
# Fall back to native kuzu
try:
+1 -1
View File
@@ -9,7 +9,7 @@ conflict resolution, enabling multiple AI agents to work in parallel without
traditional merge conflicts.
Components:
- SemanticAnalyzer: Regex-based semantic change extraction
- SemanticAnalyzer: Tree-sitter based semantic change extraction
- ConflictDetector: Rule-based conflict detection and compatibility analysis
- AutoMerger: Deterministic merge strategies (no AI needed)
- AIResolver: Minimal-context AI resolution for ambiguous conflicts
@@ -82,9 +82,7 @@ def create_claude_resolver() -> AIResolver:
msg_type = type(msg).__name__
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
for block in msg.content:
# Must check block type - only TextBlock has .text attribute
block_type = type(block).__name__
if block_type == "TextBlock" and hasattr(block, "text"):
if hasattr(block, "text"):
response_text += block.text
logger.info(f"AI merge response: {len(response_text)} chars")
@@ -68,7 +68,6 @@ class ModificationTracker:
new_content: str,
evolutions: dict[str, FileEvolution],
raw_diff: str | None = None,
skip_semantic_analysis: bool = False,
) -> TaskSnapshot | None:
"""
Record a file modification by a task.
@@ -80,9 +79,6 @@ class ModificationTracker:
new_content: File content after modification
evolutions: Current evolution data (will be updated)
raw_diff: Optional unified diff for reference
skip_semantic_analysis: If True, skip expensive semantic analysis.
Use this for lightweight file tracking when only conflict
detection is needed (not conflict resolution).
Returns:
Updated TaskSnapshot, or None if file not being tracked
@@ -109,19 +105,9 @@ class ModificationTracker:
content_hash_before=compute_content_hash(old_content),
)
# Analyze semantic changes (or skip for lightweight tracking)
if skip_semantic_analysis:
# Fast path: just track the file change without analysis
# This is used for files that don't have conflicts
semantic_changes = []
debug(
MODULE,
f"Skipping semantic analysis for {rel_path} (lightweight tracking)",
)
else:
# Full analysis (only for conflict files)
analysis = self.analyzer.analyze_diff(rel_path, old_content, new_content)
semantic_changes = analysis.changes
# Analyze semantic changes
analysis = self.analyzer.analyze_diff(rel_path, old_content, new_content)
semantic_changes = analysis.changes
# Update snapshot
snapshot.completed_at = datetime.now()
@@ -135,7 +121,6 @@ class ModificationTracker:
logger.info(
f"Recorded modification to {rel_path} by {task_id}: "
f"{len(semantic_changes)} semantic changes"
+ (" (lightweight)" if skip_semantic_analysis else "")
)
return snapshot
@@ -145,7 +130,6 @@ class ModificationTracker:
worktree_path: Path,
evolutions: dict[str, FileEvolution],
target_branch: str | None = None,
analyze_only_files: set[str] | None = None,
) -> None:
"""
Refresh task snapshots by analyzing git diff from worktree.
@@ -158,10 +142,6 @@ class ModificationTracker:
worktree_path: Path to the task's worktree
evolutions: Current evolution data (will be updated)
target_branch: Branch to compare against (default: detect from worktree)
analyze_only_files: If provided, only run full semantic analysis on
these files. Other files will be tracked with lightweight mode
(no semantic analysis). This optimizes performance by only
analyzing files that have actual conflicts.
"""
# Determine the target branch to compare against
if not target_branch:
@@ -174,9 +154,6 @@ class ModificationTracker:
task_id=task_id,
worktree_path=str(worktree_path),
target_branch=target_branch,
analyze_only_files=list(analyze_only_files)[:10]
if analyze_only_files
else "all",
)
try:
@@ -210,104 +187,56 @@ class ModificationTracker:
else changed_files,
)
processed_count = 0
for file_path in changed_files:
# Get the diff for this file (using merge-base for accurate task-only diff)
diff_result = subprocess.run(
["git", "diff", f"{merge_base}..HEAD", "--", file_path],
cwd=worktree_path,
capture_output=True,
text=True,
check=True,
)
# Get content before (from merge-base - the point where task branched)
try:
# Get the diff for this file (using merge-base for accurate task-only diff)
diff_result = subprocess.run(
["git", "diff", f"{merge_base}..HEAD", "--", file_path],
show_result = subprocess.run(
["git", "show", f"{merge_base}:{file_path}"],
cwd=worktree_path,
capture_output=True,
text=True,
check=True,
)
old_content = show_result.stdout
except subprocess.CalledProcessError:
# File is new
old_content = ""
# Get content before (from merge-base - the point where task branched)
current_file = worktree_path / file_path
if current_file.exists():
try:
show_result = subprocess.run(
["git", "show", f"{merge_base}:{file_path}"],
cwd=worktree_path,
capture_output=True,
text=True,
check=True,
new_content = current_file.read_text(encoding="utf-8")
except UnicodeDecodeError:
new_content = current_file.read_text(
encoding="utf-8", errors="replace"
)
old_content = show_result.stdout
except subprocess.CalledProcessError:
# File is new
old_content = ""
else:
# File was deleted
new_content = ""
current_file = worktree_path / file_path
if current_file.exists():
try:
new_content = current_file.read_text(encoding="utf-8")
except UnicodeDecodeError:
new_content = current_file.read_text(
encoding="utf-8", errors="replace"
)
else:
# File was deleted
new_content = ""
# Auto-create FileEvolution entry if not already tracked
# This handles retroactive tracking when capture_baselines wasn't called
rel_path = self.storage.get_relative_path(file_path)
if rel_path not in evolutions:
evolutions[rel_path] = FileEvolution(
file_path=rel_path,
baseline_commit=merge_base,
baseline_captured_at=datetime.now(),
baseline_content_hash=compute_content_hash(old_content),
baseline_snapshot_path="", # Not storing baseline file
task_snapshots=[],
)
debug(
MODULE,
f"Auto-created evolution entry for {rel_path}",
baseline_commit=merge_base[:8],
)
# Determine if this file needs full semantic analysis
# If analyze_only_files is provided, only analyze files in that set
# Otherwise, analyze all files (backward compatible)
skip_analysis = False
if analyze_only_files is not None:
skip_analysis = rel_path not in analyze_only_files
# Record the modification
self.record_modification(
task_id=task_id,
file_path=file_path,
old_content=old_content,
new_content=new_content,
evolutions=evolutions,
raw_diff=diff_result.stdout,
skip_semantic_analysis=skip_analysis,
)
processed_count += 1
except subprocess.CalledProcessError as e:
# Log error but continue with remaining files
logger.warning(
f"Failed to process {file_path} in refresh_from_git: {e}"
)
continue
# Calculate how many files were fully analyzed vs just tracked
if analyze_only_files is not None:
analyzed_count = len(
[f for f in changed_files if f in analyze_only_files]
)
tracked_only_count = processed_count - analyzed_count
logger.info(
f"Refreshed {processed_count}/{len(changed_files)} files from worktree for task {task_id} "
f"(analyzed: {analyzed_count}, tracked only: {tracked_only_count})"
)
else:
logger.info(
f"Refreshed {processed_count}/{len(changed_files)} files from worktree for task {task_id} "
"(full analysis on all files)"
# Record the modification
self.record_modification(
task_id=task_id,
file_path=file_path,
old_content=old_content,
new_content=new_content,
evolutions=evolutions,
raw_diff=diff_result.stdout,
)
logger.info(
f"Refreshed {len(changed_files)} files from worktree for task {task_id}"
)
except subprocess.CalledProcessError as e:
logger.error(f"Failed to refresh from git: {e}")
@@ -331,23 +260,35 @@ class ModificationTracker:
def _detect_target_branch(self, worktree_path: Path) -> str:
"""
Detect the base branch to compare against for a worktree.
Detect the target branch to compare against for a worktree.
This finds the branch that the worktree was created FROM by looking
for common branch names (main, master, develop) that have a valid
merge-base with the worktree.
Note: We don't use upstream tracking because that returns the worktree's
own branch (e.g., origin/auto-claude/...) rather than the base branch.
This finds the branch that the worktree was created from by looking
at the merge-base between the worktree and common branch names.
Args:
worktree_path: Path to the worktree
Returns:
The detected base branch name, defaults to 'main' if detection fails
The detected target branch name, defaults to 'main' if detection fails
"""
# Try to get the upstream tracking branch
try:
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
cwd=worktree_path,
capture_output=True,
text=True,
)
if result.returncode == 0 and result.stdout.strip():
upstream = result.stdout.strip()
# Extract branch name from origin/branch format
if "/" in upstream:
return upstream.split("/", 1)[1]
return upstream
except subprocess.CalledProcessError:
pass
# Try common branch names and find which one has a valid merge-base
# This is the reliable way to find what branch the worktree diverged from
for branch in ["main", "master", "develop"]:
try:
result = subprocess.run(
@@ -357,39 +298,14 @@ class ModificationTracker:
text=True,
)
if result.returncode == 0:
debug(
MODULE,
f"Detected base branch: {branch}",
worktree_path=str(worktree_path),
)
return branch
except subprocess.CalledProcessError:
continue
# Before defaulting to 'main', verify it exists
# This handles non-standard projects that use trunk, production, etc.
try:
result = subprocess.run(
["git", "rev-parse", "--verify", "main"],
cwd=worktree_path,
capture_output=True,
text=True,
)
if result.returncode == 0:
debug_warning(
MODULE,
"Could not find merge-base with standard branches, defaulting to 'main'",
worktree_path=str(worktree_path),
)
return "main"
except subprocess.CalledProcessError:
pass
# Last resort: use HEAD~10 as a fallback comparison point
# This allows modification tracking even on non-standard branch setups
# Default to main
debug_warning(
MODULE,
"No standard base branch found, modification tracking may be limited",
"Could not detect target branch, defaulting to 'main'",
worktree_path=str(worktree_path),
)
return "HEAD~10"
return "main"
@@ -327,7 +327,6 @@ class FileEvolutionTracker:
task_id: str,
worktree_path: Path,
target_branch: str | None = None,
analyze_only_files: set[str] | None = None,
) -> None:
"""
Refresh task snapshots by analyzing git diff from worktree.
@@ -339,16 +338,11 @@ class FileEvolutionTracker:
task_id: The task identifier
worktree_path: Path to the task's worktree
target_branch: Branch to compare against (default: auto-detect)
analyze_only_files: If provided, only run full semantic analysis on
these files. Other files will be tracked with lightweight mode
(no semantic analysis). This optimizes performance by only
analyzing files that have actual conflicts.
"""
self.modification_tracker.refresh_from_git(
task_id=task_id,
worktree_path=worktree_path,
evolutions=self._evolutions,
target_branch=target_branch,
analyze_only_files=analyze_only_files,
)
self._save_evolutions()
+6 -30
View File
@@ -64,16 +64,10 @@ def apply_single_task_changes(
Returns:
Modified content with changes applied
"""
# Detect line ending style before normalizing
original_line_ending = detect_line_ending(baseline)
content = baseline
# Normalize to LF for consistent matching with regex_analyzer output
# The regex_analyzer normalizes content to LF when extracting content_before/after,
# so we must also normalize baseline to ensure replace() matches correctly
content = baseline.replace("\r\n", "\n").replace("\r", "\n")
# Use LF for internal processing
line_ending = "\n"
# 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:
@@ -91,12 +85,6 @@ def apply_single_task_changes(
# Add function at end (before exports)
content += f"{line_ending}{line_ending}{change.content_after}"
# Restore original line ending style if it was CRLF
if original_line_ending == "\r\n":
content = content.replace("\n", "\r\n")
elif original_line_ending == "\r":
content = content.replace("\n", "\r")
return content
@@ -116,16 +104,10 @@ def combine_non_conflicting_changes(
Returns:
Combined content with all changes applied
"""
# Detect line ending style before normalizing
original_line_ending = detect_line_ending(baseline)
content = baseline
# Normalize to LF for consistent matching with regex_analyzer output
# The regex_analyzer normalizes content to LF when extracting content_before/after,
# so we must also normalize baseline to ensure replace() matches correctly
content = baseline.replace("\r\n", "\n").replace("\r", "\n")
# Use LF for internal processing
line_ending = "\n"
# 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] = []
@@ -174,12 +156,6 @@ def combine_non_conflicting_changes(
elif change.content_before and change.content_after:
content = content.replace(change.content_before, change.content_after)
# Restore original line ending style if it was CRLF
if original_line_ending == "\r\n":
content = content.replace("\n", "\r\n")
elif original_line_ending == "\r":
content = content.replace("\n", "\r")
return content
@@ -1,10 +1,12 @@
"""
Semantic analyzer package for code analysis.
Semantic analyzer package for AST-based code analysis.
This package provides modular semantic analysis capabilities:
- models.py: Data structures for extracted elements
- python_analyzer.py: Python-specific AST extraction
- js_analyzer.py: JavaScript/TypeScript-specific AST extraction
- comparison.py: Element comparison and change classification
- regex_analyzer.py: Regex-based analysis for code changes
- regex_analyzer.py: Fallback regex-based analysis
"""
from .models import ExtractedElement
@@ -0,0 +1,157 @@
"""
JavaScript/TypeScript-specific semantic analysis using tree-sitter.
"""
from __future__ import annotations
from collections.abc import Callable
from .models import ExtractedElement
try:
from tree_sitter import Node
except ImportError:
Node = None
def extract_js_elements(
node: Node,
elements: dict[str, ExtractedElement],
get_text: Callable[[Node], str],
get_line: Callable[[int], int],
ext: str,
parent: str | None = None,
) -> None:
"""
Extract structural elements from JavaScript/TypeScript AST.
Args:
node: The tree-sitter node to extract from
elements: Dictionary to populate with extracted elements
get_text: Function to extract text from a node
get_line: Function to convert byte position to line number
ext: File extension (.js, .jsx, .ts, .tsx)
parent: Parent element name for nested elements
"""
for child in node.children:
if child.type == "import_statement":
text = get_text(child)
# Try to extract the source module
source_node = child.child_by_field_name("source")
if source_node:
source = get_text(source_node).strip("'\"")
elements[f"import:{source}"] = ExtractedElement(
element_type="import",
name=source,
start_line=get_line(child.start_byte),
end_line=get_line(child.end_byte),
content=text,
)
elif child.type in {"function_declaration", "function"}:
name_node = child.child_by_field_name("name")
if name_node:
name = get_text(name_node)
full_name = f"{parent}.{name}" if parent else name
elements[f"function:{full_name}"] = ExtractedElement(
element_type="function",
name=full_name,
start_line=get_line(child.start_byte),
end_line=get_line(child.end_byte),
content=get_text(child),
parent=parent,
)
elif child.type == "arrow_function":
# Arrow functions are usually assigned to variables
# We'll catch these via variable declarations
pass
elif child.type in {"lexical_declaration", "variable_declaration"}:
# const/let/var declarations
for declarator in child.children:
if declarator.type == "variable_declarator":
name_node = declarator.child_by_field_name("name")
value_node = declarator.child_by_field_name("value")
if name_node:
name = get_text(name_node)
content = get_text(child)
# Check if it's a function (arrow function or function expression)
is_function = False
if value_node and value_node.type in {
"arrow_function",
"function",
}:
is_function = True
elements[f"function:{name}"] = ExtractedElement(
element_type="function",
name=name,
start_line=get_line(child.start_byte),
end_line=get_line(child.end_byte),
content=content,
parent=parent,
)
else:
elements[f"variable:{name}"] = ExtractedElement(
element_type="variable",
name=name,
start_line=get_line(child.start_byte),
end_line=get_line(child.end_byte),
content=content,
parent=parent,
)
elif child.type == "class_declaration":
name_node = child.child_by_field_name("name")
if name_node:
name = get_text(name_node)
elements[f"class:{name}"] = ExtractedElement(
element_type="class",
name=name,
start_line=get_line(child.start_byte),
end_line=get_line(child.end_byte),
content=get_text(child),
)
# Recurse into class body
body = child.child_by_field_name("body")
if body:
extract_js_elements(
body, elements, get_text, get_line, ext, parent=name
)
elif child.type == "method_definition":
name_node = child.child_by_field_name("name")
if name_node:
name = get_text(name_node)
full_name = f"{parent}.{name}" if parent else name
elements[f"method:{full_name}"] = ExtractedElement(
element_type="method",
name=full_name,
start_line=get_line(child.start_byte),
end_line=get_line(child.end_byte),
content=get_text(child),
parent=parent,
)
elif child.type == "export_statement":
# Recurse into exports to find the actual declaration
extract_js_elements(child, elements, get_text, get_line, ext, parent)
# TypeScript specific
elif child.type in {"interface_declaration", "type_alias_declaration"}:
name_node = child.child_by_field_name("name")
if name_node:
name = get_text(name_node)
elem_type = "interface" if "interface" in child.type else "type"
elements[f"{elem_type}:{name}"] = ExtractedElement(
element_type=elem_type,
name=name,
start_line=get_line(child.start_byte),
end_line=get_line(child.end_byte),
content=get_text(child),
)
# Recurse into statement blocks
elif child.type in {"program", "statement_block", "class_body"}:
extract_js_elements(child, elements, get_text, get_line, ext, parent)
@@ -0,0 +1,114 @@
"""
Python-specific semantic analysis using tree-sitter.
"""
from __future__ import annotations
from collections.abc import Callable
from .models import ExtractedElement
try:
from tree_sitter import Node
except ImportError:
Node = None
def extract_python_elements(
node: Node,
elements: dict[str, ExtractedElement],
get_text: Callable[[Node], str],
get_line: Callable[[int], int],
parent: str | None = None,
) -> None:
"""
Extract structural elements from Python AST.
Args:
node: The tree-sitter node to extract from
elements: Dictionary to populate with extracted elements
get_text: Function to extract text from a node
get_line: Function to convert byte position to line number
parent: Parent element name for nested elements
"""
for child in node.children:
if child.type == "import_statement":
# import x, y
text = get_text(child)
# Extract module names
for name_node in child.children:
if name_node.type == "dotted_name":
name = get_text(name_node)
elements[f"import:{name}"] = ExtractedElement(
element_type="import",
name=name,
start_line=get_line(child.start_byte),
end_line=get_line(child.end_byte),
content=text,
)
elif child.type == "import_from_statement":
# from x import y, z
text = get_text(child)
module = None
for sub in child.children:
if sub.type == "dotted_name":
module = get_text(sub)
break
if module:
elements[f"import_from:{module}"] = ExtractedElement(
element_type="import_from",
name=module,
start_line=get_line(child.start_byte),
end_line=get_line(child.end_byte),
content=text,
)
elif child.type == "function_definition":
name_node = child.child_by_field_name("name")
if name_node:
name = get_text(name_node)
full_name = f"{parent}.{name}" if parent else name
elements[f"function:{full_name}"] = ExtractedElement(
element_type="function",
name=full_name,
start_line=get_line(child.start_byte),
end_line=get_line(child.end_byte),
content=get_text(child),
parent=parent,
)
elif child.type == "class_definition":
name_node = child.child_by_field_name("name")
if name_node:
name = get_text(name_node)
elements[f"class:{name}"] = ExtractedElement(
element_type="class",
name=name,
start_line=get_line(child.start_byte),
end_line=get_line(child.end_byte),
content=get_text(child),
)
# Recurse into class body for methods
body = child.child_by_field_name("body")
if body:
extract_python_elements(
body, elements, get_text, get_line, parent=name
)
elif child.type == "decorated_definition":
# Handle decorated functions/classes
for sub in child.children:
if sub.type in {"function_definition", "class_definition"}:
extract_python_elements(child, elements, get_text, get_line, parent)
break
# Recurse for other compound statements
elif child.type in {
"if_statement",
"while_statement",
"for_statement",
"try_statement",
"with_statement",
}:
extract_python_elements(child, elements, get_text, get_line, parent)
@@ -1,5 +1,5 @@
"""
Regex-based semantic analysis for code changes.
Regex-based fallback analysis when tree-sitter is not available.
"""
from __future__ import annotations
@@ -17,7 +17,7 @@ def analyze_with_regex(
ext: str,
) -> FileAnalysis:
"""
Analyze code changes using regex patterns.
Fallback analysis using regex when tree-sitter isn't available.
Args:
file_path: Path to the file being analyzed
+177 -12
View File
@@ -2,27 +2,32 @@
Semantic Analyzer
=================
Analyzes code changes at a semantic level using regex-based heuristics.
Analyzes code changes at a semantic level using tree-sitter.
This module provides analysis of code changes, extracting meaningful
semantic changes like "added import", "modified function", "wrapped JSX element"
rather than line-level diffs.
This module provides AST-based analysis of code changes, extracting
meaningful semantic changes like "added import", "modified function",
"wrapped JSX element" rather than line-level diffs.
When tree-sitter is not available, falls back to regex-based heuristics.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from .types import FileAnalysis
from .types import ChangeType, FileAnalysis
# Import debug utilities
try:
from debug import (
debug,
debug_detailed,
debug_error,
debug_success,
debug_verbose,
is_debug_enabled,
)
except ImportError:
# Fallback if debug module not available
@@ -38,18 +43,71 @@ except ImportError:
def debug_success(*args, **kwargs):
pass
def debug_error(*args, **kwargs):
pass
def is_debug_enabled():
return False
logger = logging.getLogger(__name__)
MODULE = "merge.semantic_analyzer"
# Import regex-based analyzer
# Try to import tree-sitter - it's optional but recommended
TREE_SITTER_AVAILABLE = False
try:
import tree_sitter # noqa: F401
from tree_sitter import Language, Node, Parser, Tree
TREE_SITTER_AVAILABLE = True
logger.info("tree-sitter available, using AST-based analysis")
except ImportError:
logger.warning("tree-sitter not available, using regex-based fallback")
Tree = None
Node = None
# Try to import language bindings
LANGUAGES_AVAILABLE: dict[str, Any] = {}
if TREE_SITTER_AVAILABLE:
try:
import tree_sitter_python as tspython
LANGUAGES_AVAILABLE[".py"] = tspython.language()
except ImportError:
pass
try:
import tree_sitter_javascript as tsjs
LANGUAGES_AVAILABLE[".js"] = tsjs.language()
LANGUAGES_AVAILABLE[".jsx"] = tsjs.language()
except ImportError:
pass
try:
import tree_sitter_typescript as tsts
LANGUAGES_AVAILABLE[".ts"] = tsts.language_typescript()
LANGUAGES_AVAILABLE[".tsx"] = tsts.language_tsx()
except ImportError:
pass
# Import our modular components
from .semantic_analysis.comparison import compare_elements
from .semantic_analysis.models import ExtractedElement
from .semantic_analysis.regex_analyzer import analyze_with_regex
if TREE_SITTER_AVAILABLE:
from .semantic_analysis.js_analyzer import extract_js_elements
from .semantic_analysis.python_analyzer import extract_python_elements
class SemanticAnalyzer:
"""
Analyzes code changes at a semantic level using regex-based heuristics.
Analyzes code changes at a semantic level.
Uses tree-sitter for AST-based analysis when available,
falling back to regex-based heuristics when not.
Example:
analyzer = SemanticAnalyzer()
@@ -59,8 +117,28 @@ class SemanticAnalyzer:
"""
def __init__(self):
"""Initialize the analyzer."""
debug(MODULE, "Initializing SemanticAnalyzer (regex-based)")
"""Initialize the analyzer with available parsers."""
self._parsers: dict[str, Parser] = {}
debug(
MODULE,
"Initializing SemanticAnalyzer",
tree_sitter_available=TREE_SITTER_AVAILABLE,
)
if TREE_SITTER_AVAILABLE:
for ext, lang in LANGUAGES_AVAILABLE.items():
parser = Parser()
parser.language = Language(lang)
self._parsers[ext] = parser
debug_detailed(MODULE, f"Initialized parser for {ext}")
debug_success(
MODULE,
"SemanticAnalyzer initialized",
parsers=list(self._parsers.keys()),
)
else:
debug(MODULE, "Using regex-based fallback (tree-sitter not available)")
def analyze_diff(
self,
@@ -93,8 +171,13 @@ class SemanticAnalyzer:
task_id=task_id,
)
# Use regex-based analysis
analysis = analyze_with_regex(file_path, before, after, ext)
# Use tree-sitter if available for this language
if ext in self._parsers:
debug_detailed(MODULE, f"Using tree-sitter parser for {ext}")
analysis = self._analyze_with_tree_sitter(file_path, before, after, ext)
else:
debug_detailed(MODULE, f"Using regex fallback for {ext}")
analysis = analyze_with_regex(file_path, before, after, ext)
debug_success(
MODULE,
@@ -118,6 +201,83 @@ class SemanticAnalyzer:
return analysis
def _analyze_with_tree_sitter(
self,
file_path: str,
before: str,
after: str,
ext: str,
) -> FileAnalysis:
"""Analyze using tree-sitter AST parsing."""
parser = self._parsers[ext]
# Normalize line endings to LF for consistent cross-platform behavior
# This ensures byte positions and line counts work correctly on all platforms
before_normalized = before.replace("\r\n", "\n").replace("\r", "\n")
after_normalized = after.replace("\r\n", "\n").replace("\r", "\n")
tree_before = parser.parse(bytes(before_normalized, "utf-8"))
tree_after = parser.parse(bytes(after_normalized, "utf-8"))
# Extract structural elements from both versions
# Use normalized content to match tree-sitter byte positions
elements_before = self._extract_elements(tree_before, before_normalized, ext)
elements_after = self._extract_elements(tree_after, after_normalized, ext)
# Compare and generate semantic changes
changes = compare_elements(elements_before, elements_after, ext)
# Build the analysis
analysis = FileAnalysis(file_path=file_path, changes=changes)
# Populate summary fields
for change in changes:
if change.change_type in {
ChangeType.MODIFY_FUNCTION,
ChangeType.ADD_HOOK_CALL,
}:
analysis.functions_modified.add(change.target)
elif change.change_type == ChangeType.ADD_FUNCTION:
analysis.functions_added.add(change.target)
elif change.change_type == ChangeType.ADD_IMPORT:
analysis.imports_added.add(change.target)
elif change.change_type == ChangeType.REMOVE_IMPORT:
analysis.imports_removed.add(change.target)
elif change.change_type in {
ChangeType.MODIFY_CLASS,
ChangeType.ADD_METHOD,
}:
analysis.classes_modified.add(change.target.split(".")[0])
analysis.total_lines_changed += change.line_end - change.line_start + 1
return analysis
def _extract_elements(
self,
tree: Tree,
source: str,
ext: str,
) -> dict[str, ExtractedElement]:
"""Extract structural elements from a syntax tree."""
elements: dict[str, ExtractedElement] = {}
source_bytes = bytes(source, "utf-8")
def get_text(node: Node) -> str:
return source_bytes[node.start_byte : node.end_byte].decode("utf-8")
def get_line(byte_pos: int) -> int:
# Convert byte position to line number (1-indexed)
return source[:byte_pos].count("\n") + 1
# Language-specific extraction
if ext == ".py":
extract_python_elements(tree.root_node, elements, get_text, get_line)
elif ext in {".js", ".jsx", ".ts", ".tsx"}:
extract_js_elements(tree.root_node, elements, get_text, get_line, ext)
return elements
def analyze_file(self, file_path: str, content: str) -> FileAnalysis:
"""
Analyze a single file's structure (not a diff).
@@ -137,7 +297,12 @@ class SemanticAnalyzer:
@property
def supported_extensions(self) -> set[str]:
"""Get the set of supported file extensions."""
return {".py", ".js", ".jsx", ".ts", ".tsx"}
if TREE_SITTER_AVAILABLE:
# Tree-sitter extensions plus regex fallbacks
return set(self._parsers.keys()) | {".py", ".js", ".jsx", ".ts", ".tsx"}
else:
# Only regex-supported extensions
return {".py", ".js", ".jsx", ".ts", ".tsx"}
def is_supported(self, file_path: str) -> bool:
"""Check if a file type is supported for semantic analysis."""
+6 -119
View File
@@ -16,7 +16,6 @@ Output:
import argparse
import json
import re
import sys
import urllib.error
import urllib.request
@@ -24,10 +23,6 @@ from typing import Any
DEFAULT_OLLAMA_URL = "http://localhost:11434"
# Minimum Ollama version required for newer embedding models (qwen3-embedding, etc.)
# These models were added in Ollama 0.10.0
MIN_OLLAMA_VERSION_FOR_NEW_MODELS = "0.10.0"
# Known embedding models and their dimensions
# This list helps identify embedding models from the model name
KNOWN_EMBEDDING_MODELS = {
@@ -36,26 +31,10 @@ KNOWN_EMBEDDING_MODELS = {
"dim": 768,
"description": "Google EmbeddingGemma (lightweight)",
},
"qwen3-embedding": {
"dim": 1024,
"description": "Qwen3 Embedding (0.6B)",
"min_version": "0.10.0",
},
"qwen3-embedding:0.6b": {
"dim": 1024,
"description": "Qwen3 Embedding 0.6B",
"min_version": "0.10.0",
},
"qwen3-embedding:4b": {
"dim": 2560,
"description": "Qwen3 Embedding 4B",
"min_version": "0.10.0",
},
"qwen3-embedding:8b": {
"dim": 4096,
"description": "Qwen3 Embedding 8B",
"min_version": "0.10.0",
},
"qwen3-embedding": {"dim": 1024, "description": "Qwen3 Embedding (0.6B)"},
"qwen3-embedding:0.6b": {"dim": 1024, "description": "Qwen3 Embedding 0.6B"},
"qwen3-embedding:4b": {"dim": 2560, "description": "Qwen3 Embedding 4B"},
"qwen3-embedding:8b": {"dim": 4096, "description": "Qwen3 Embedding 8B"},
"bge-base-en": {"dim": 768, "description": "BAAI General Embedding - Base"},
"bge-large-en": {"dim": 1024, "description": "BAAI General Embedding - Large"},
"bge-small-en": {"dim": 384, "description": "BAAI General Embedding - Small"},
@@ -84,7 +63,6 @@ RECOMMENDED_EMBEDDING_MODELS = [
"size_estimate": "3.1 GB",
"dim": 2560,
"badge": "recommended",
"min_ollama_version": "0.10.0",
},
{
"name": "qwen3-embedding:8b",
@@ -92,7 +70,6 @@ RECOMMENDED_EMBEDDING_MODELS = [
"size_estimate": "6.0 GB",
"dim": 4096,
"badge": "quality",
"min_ollama_version": "0.10.0",
},
{
"name": "qwen3-embedding:0.6b",
@@ -100,7 +77,6 @@ RECOMMENDED_EMBEDDING_MODELS = [
"size_estimate": "494 MB",
"dim": 1024,
"badge": "fast",
"min_ollama_version": "0.10.0",
},
{
"name": "embeddinggemma",
@@ -136,22 +112,6 @@ EMBEDDING_PATTERNS = [
]
def parse_version(version_str: str | None) -> tuple[int, ...]:
"""Parse a version string like '0.10.0' into a tuple for comparison."""
if not version_str or not isinstance(version_str, str):
return (0, 0, 0)
# Extract just the numeric parts (handles versions like "0.10.0-rc1")
match = re.match(r"(\d+)\.(\d+)\.(\d+)", version_str)
if match:
return tuple(int(x) for x in match.groups())
return (0, 0, 0)
def version_gte(version: str | None, min_version: str | None) -> bool:
"""Check if version >= min_version."""
return parse_version(version) >= parse_version(min_version)
def output_json(success: bool, data: Any = None, error: str | None = None) -> None:
"""Output JSON result to stdout and exit."""
result = {"success": success}
@@ -185,14 +145,6 @@ def fetch_ollama_api(base_url: str, endpoint: str, timeout: int = 5) -> dict | N
return None
def get_ollama_version(base_url: str) -> str | None:
"""Get the Ollama server version."""
result = fetch_ollama_api(base_url, "api/version")
if result:
return result.get("version")
return None
def is_embedding_model(model_name: str) -> bool:
"""Check if a model name suggests it's an embedding model."""
name_lower = model_name.lower()
@@ -240,19 +192,6 @@ def get_embedding_description(model_name: str) -> str:
return "Embedding model"
def get_model_min_version(model_name: str) -> str | None:
"""Get the minimum Ollama version required for a model."""
name_lower = model_name.lower()
# Sort keys by length descending to match more specific names first
# e.g., "qwen3-embedding:8b" before "qwen3-embedding"
for known_model in sorted(KNOWN_EMBEDDING_MODELS.keys(), key=len, reverse=True):
if known_model in name_lower:
return KNOWN_EMBEDDING_MODELS[known_model].get("min_version")
return None
def cmd_check_status(args) -> None:
"""Check if Ollama is running and accessible."""
base_url = args.base_url or DEFAULT_OLLAMA_URL
@@ -261,18 +200,12 @@ def cmd_check_status(args) -> None:
result = fetch_ollama_api(base_url, "api/version")
if result:
version = result.get("version", "unknown")
output_json(
True,
data={
"running": True,
"url": base_url,
"version": version,
"supports_new_models": version_gte(
version, MIN_OLLAMA_VERSION_FOR_NEW_MODELS
)
if version != "unknown"
else None,
"version": result.get("version", "unknown"),
},
)
else:
@@ -386,9 +319,6 @@ def cmd_get_recommended_models(args) -> None:
"""Get recommended embedding models with install status."""
base_url = args.base_url or DEFAULT_OLLAMA_URL
# Get Ollama version for compatibility checking
ollama_version = get_ollama_version(base_url)
# Get currently installed models
result = fetch_ollama_api(base_url, "api/tags")
installed_names = set()
@@ -400,30 +330,17 @@ def cmd_get_recommended_models(args) -> None:
installed_names.add(name)
installed_names.add(base_name)
# Build recommended list with install status and compatibility
# Build recommended list with install status
recommended = []
for model in RECOMMENDED_EMBEDDING_MODELS:
name = model["name"]
base_name = name.split(":")[0] if ":" in name else name
is_installed = name in installed_names or base_name in installed_names
# Check version compatibility
min_version = model.get("min_ollama_version")
is_compatible = True
compatibility_note = None
if min_version and ollama_version:
is_compatible = version_gte(ollama_version, min_version)
if not is_compatible:
compatibility_note = f"Requires Ollama {min_version}+"
elif min_version and not ollama_version:
compatibility_note = "Version compatibility could not be verified"
recommended.append(
{
**model,
"installed": is_installed,
"compatible": is_compatible,
"compatibility_note": compatibility_note,
}
)
@@ -433,7 +350,6 @@ def cmd_get_recommended_models(args) -> None:
"recommended": recommended,
"count": len(recommended),
"url": base_url,
"ollama_version": ollama_version,
},
)
@@ -447,19 +363,6 @@ def cmd_pull_model(args) -> None:
output_error("Model name is required")
return
# Check Ollama version compatibility before attempting pull
ollama_version = get_ollama_version(base_url)
min_version = get_model_min_version(model_name)
if min_version and ollama_version:
if not version_gte(ollama_version, min_version):
output_error(
f"Model '{model_name}' requires Ollama {min_version} or newer. "
f"Your version is {ollama_version}. "
f"Please upgrade Ollama: https://ollama.com/download"
)
return
try:
url = f"{base_url.rstrip('/')}/api/pull"
data = json.dumps({"name": model_name}).encode("utf-8")
@@ -473,22 +376,6 @@ def cmd_pull_model(args) -> None:
try:
progress = json.loads(line.decode("utf-8"))
# Check for error in the streaming response
# This handles cases like "requires newer version of Ollama"
if "error" in progress:
error_msg = progress["error"]
# Clean up the error message (remove extra whitespace/newlines)
error_msg = " ".join(error_msg.split())
# Check if it's a version-related error
if "newer version" in error_msg.lower():
error_msg = (
f"Model '{model_name}' requires a newer version of Ollama. "
f"Your version: {ollama_version or 'unknown'}. "
f"Please upgrade: https://ollama.com/download"
)
output_error(error_msg)
return
# Emit progress as NDJSON to stderr for main process to parse
if "completed" in progress and "total" in progress:
print(
-110
View File
@@ -22,68 +22,6 @@ environment at the start of each prompt in the "YOUR ENVIRONMENT" section. Pay c
---
## 🚨 CRITICAL: PATH CONFUSION PREVENTION 🚨
**THE #1 BUG IN MONOREPOS: Doubled paths after `cd` commands**
### The Problem
After running `cd ./apps/frontend`, your current directory changes. If you then use paths like `apps/frontend/src/file.ts`, you're creating **doubled paths** like `apps/frontend/apps/frontend/src/file.ts`.
### The Solution: ALWAYS CHECK YOUR CWD
**BEFORE every git command or file operation:**
```bash
# Step 1: Check where you are
pwd
# Step 2: Use paths RELATIVE TO CURRENT DIRECTORY
# If pwd shows: /path/to/project/apps/frontend
# Then use: git add src/file.ts
# NOT: git add apps/frontend/src/file.ts
```
### Examples
**❌ WRONG - Path gets doubled:**
```bash
cd ./apps/frontend
git add apps/frontend/src/file.ts # Looks for apps/frontend/apps/frontend/src/file.ts
```
**✅ CORRECT - Use relative path from current directory:**
```bash
cd ./apps/frontend
pwd # Shows: /path/to/project/apps/frontend
git add src/file.ts # Correctly adds apps/frontend/src/file.ts from project root
```
**✅ ALSO CORRECT - Stay at root, use full relative path:**
```bash
# Don't change directory at all
git add ./apps/frontend/src/file.ts # Works from project root
```
### Mandatory Pre-Command Check
**Before EVERY git add, git commit, or file operation in a monorepo:**
```bash
# 1. Where am I?
pwd
# 2. What files am I targeting?
ls -la [target-path] # Verify the path exists
# 3. Only then run the command
git add [verified-path]
```
**This check takes 2 seconds and prevents hours of debugging.**
---
## STEP 1: GET YOUR BEARINGS (MANDATORY)
First, check your environment. The prompt should tell you your working directory and spec location.
@@ -420,20 +358,6 @@ In your response, acknowledge the checklist:
## STEP 6: IMPLEMENT THE SUBTASK
### Verify Your Location FIRST
**MANDATORY: Before implementing anything, confirm where you are:**
```bash
# This should match the "Working Directory" in YOUR ENVIRONMENT section above
pwd
```
If you change directories during implementation (e.g., `cd apps/frontend`), remember:
- Your file paths must be RELATIVE TO YOUR NEW LOCATION
- Before any git operation, run `pwd` again to verify your location
- See the "PATH CONFUSION PREVENTION" section above for examples
### Mark as In Progress
Update `implementation_plan.json`:
@@ -694,31 +618,6 @@ After successful verification, update the subtask:
## STEP 9: COMMIT YOUR PROGRESS
### Path Verification (MANDATORY FIRST STEP)
**🚨 BEFORE running ANY git commands, verify your current directory:**
```bash
# Step 1: Where am I?
pwd
# Step 2: What files do I want to commit?
# If you changed to a subdirectory (e.g., cd apps/frontend),
# you need to use paths RELATIVE TO THAT DIRECTORY, not from project root
# Step 3: Verify paths exist
ls -la [path-to-files] # Make sure the path is correct from your current location
# Example in a monorepo:
# If pwd shows: /project/apps/frontend
# Then use: git add src/file.ts
# NOT: git add apps/frontend/src/file.ts (this would look for apps/frontend/apps/frontend/src/file.ts)
```
**CRITICAL RULE:** If you're in a subdirectory, either:
- **Option A:** Return to project root: `cd [back to working directory]`
- **Option B:** Use paths relative to your CURRENT directory (check with `pwd`)
### Secret Scanning (Automatic)
The system **automatically scans for secrets** before every commit. If secrets are detected, the commit will be blocked and you'll receive detailed instructions on how to fix it.
@@ -744,17 +643,8 @@ The system **automatically scans for secrets** before every commit. If secrets a
### Create the Commit
```bash
# FIRST: Make sure you're in the working directory root (check YOUR ENVIRONMENT section at top)
pwd # Should match your working directory
# Add all files EXCEPT .auto-claude directory (spec files should never be committed)
git add . ':!.auto-claude'
# If git add fails with "pathspec did not match", you have a path problem:
# 1. Run pwd to see where you are
# 2. Run git status to see what git sees
# 3. Adjust your paths accordingly
git commit -m "auto-claude: Complete [subtask-id] - [subtask description]
- Files modified: [list]
@@ -106,24 +106,6 @@ Since this is a follow-up review, focus on:
- Check for framework protections you might miss
- Provide the actual code snippet as evidence
### Verify Before Reporting "Missing" Safeguards
For findings claiming something is **missing** (no fallback, no validation, no error handling):
**Ask yourself**: "Have I verified this is actually missing, or did I just not see it?"
- Read the **complete function/method** containing the issue, not just the flagged line
- Check for guards, fallbacks, or defensive code that may appear later in the function
- Look for comments indicating intentional design choices
- If uncertain, use the Read/Grep tools to confirm
**Your evidence must prove absence exists — not just that you didn't see it.**
**Weak**: "The code defaults to 'main' without checking if it exists"
**Strong**: "I read the complete `_detect_target_branch()` function. There is no existence check before the default return."
**Only report if you can confidently say**: "I verified the complete scope and the safeguard does not exist."
## Evidence Requirements
Every finding MUST include an `evidence` field with:
@@ -78,21 +78,6 @@ Verify that the code logic is correct, handles all edge cases, and doesn't intro
- Logic bugs must be demonstrable with a concrete example
- If the edge case is theoretical without practical impact, don't report it
### Verify Before Claiming "Missing" Edge Case Handling
When your finding claims an edge case is **not handled** (no check for empty, null, zero, etc.):
**Ask yourself**: "Have I verified this case isn't handled, or did I just not see it?"
- Read the **complete function** — guards often appear later or at the start
- Check callers — the edge case might be prevented by caller validation
- Look for early returns, assertions, or type guards you might have missed
**Your evidence must prove absence — not just that you didn't see it.**
**Weak**: "Empty array case is not handled"
**Strong**: "I read the complete function (lines 12-45). There's no check for empty arrays, and the code directly accesses `arr[0]` on line 15 without any guard."
### Severity Classification (All block merge except LOW)
- **CRITICAL** (Blocker): Bug that will cause wrong results or crashes in production
- Example: Off-by-one causing data corruption, race condition causing lost updates
@@ -79,21 +79,6 @@ Perform a thorough code quality review of the provided code changes. Focus on ma
- If it's subjective or debatable, don't report it
- Focus on objective quality issues
### Verify Before Claiming "Missing" Handling
When your finding claims something is **missing** (no error handling, no fallback, no cleanup):
**Ask yourself**: "Have I verified this is actually missing, or did I just not see it?"
- Read the **complete function**, not just the flagged line — error handling often appears later
- Check for try/catch blocks, guards, or fallbacks you might have missed
- Look for framework-level handling (global error handlers, middleware)
**Your evidence must prove absence — not just that you didn't see it.**
**Weak**: "This async call has no error handling"
**Strong**: "I read the complete `processOrder()` function (lines 34-89). The `fetch()` call on line 45 has no try/catch, and there's no `.catch()` anywhere in the function."
### Severity Classification (All block merge except LOW)
- **CRITICAL** (Blocker): Bug that will cause failures in production
- Example: Unhandled promise rejection, memory leak
@@ -74,21 +74,6 @@ Perform a thorough security review of the provided code changes, focusing ONLY o
- If you're unsure, don't report it
- Prefer false negatives over false positives
### Verify Before Claiming "Missing" Protections
When your finding claims protection is **missing** (no validation, no sanitization, no auth check):
**Ask yourself**: "Have I verified this is actually missing, or did I just not see it?"
- Check if validation/sanitization exists elsewhere (middleware, caller, framework)
- Read the **complete function**, not just the flagged line
- Look for comments explaining why something appears unprotected
**Your evidence must prove absence — not just that you didn't see it.**
**Weak**: "User input is used without validation"
**Strong**: "I checked the complete request flow. Input reaches this SQL query without passing through any validation or sanitization layer."
### Severity Classification (All block merge except LOW)
- **CRITICAL** (Blocker): Exploitable vulnerability leading to data breach, RCE, or system compromise
- Example: SQL injection, hardcoded admin password
-98
View File
@@ -80,68 +80,6 @@ lsof -iTCP -sTCP:LISTEN | grep -E "node|python|next|vite"
---
## 🚨 CRITICAL: PATH CONFUSION PREVENTION 🚨
**THE #1 BUG IN MONOREPOS: Doubled paths after `cd` commands**
### The Problem
After running `cd ./apps/frontend`, your current directory changes. If you then use paths like `apps/frontend/src/file.ts`, you're creating **doubled paths** like `apps/frontend/apps/frontend/src/file.ts`.
### The Solution: ALWAYS CHECK YOUR CWD
**BEFORE every git command or file operation:**
```bash
# Step 1: Check where you are
pwd
# Step 2: Use paths RELATIVE TO CURRENT DIRECTORY
# If pwd shows: /path/to/project/apps/frontend
# Then use: git add src/file.ts
# NOT: git add apps/frontend/src/file.ts
```
### Examples
**❌ WRONG - Path gets doubled:**
```bash
cd ./apps/frontend
git add apps/frontend/src/file.ts # Looks for apps/frontend/apps/frontend/src/file.ts
```
**✅ CORRECT - Use relative path from current directory:**
```bash
cd ./apps/frontend
pwd # Shows: /path/to/project/apps/frontend
git add src/file.ts # Correctly adds apps/frontend/src/file.ts from project root
```
**✅ ALSO CORRECT - Stay at root, use full relative path:**
```bash
# Don't change directory at all
git add ./apps/frontend/src/file.ts # Works from project root
```
### Mandatory Pre-Command Check
**Before EVERY git add, git commit, or file operation in a monorepo:**
```bash
# 1. Where am I?
pwd
# 2. What files am I targeting?
ls -la [target-path] # Verify the path exists
# 3. Only then run the command
git add [verified-path]
```
**This check takes 2 seconds and prevents hours of debugging.**
---
## PHASE 3: FIX ISSUES ONE BY ONE
For each issue in the fix request:
@@ -228,45 +166,9 @@ If any issue is not fixed, go back to Phase 3.
## PHASE 6: COMMIT FIXES
### Path Verification (MANDATORY FIRST STEP)
**🚨 BEFORE running ANY git commands, verify your current directory:**
```bash
# Step 1: Where am I?
pwd
# Step 2: What files do I want to commit?
# If you changed to a subdirectory (e.g., cd apps/frontend),
# you need to use paths RELATIVE TO THAT DIRECTORY, not from project root
# Step 3: Verify paths exist
ls -la [path-to-files] # Make sure the path is correct from your current location
# Example in a monorepo:
# If pwd shows: /project/apps/frontend
# Then use: git add src/file.ts
# NOT: git add apps/frontend/src/file.ts (this would look for apps/frontend/apps/frontend/src/file.ts)
```
**CRITICAL RULE:** If you're in a subdirectory, either:
- **Option A:** Return to project root: `cd [back to working directory]`
- **Option B:** Use paths relative to your CURRENT directory (check with `pwd`)
### Create the Commit
```bash
# FIRST: Make sure you're in the working directory root
pwd # Should match your working directory
# Add all files EXCEPT .auto-claude directory (spec files should never be committed)
git add . ':!.auto-claude'
# If git add fails with "pathspec did not match", you have a path problem:
# 1. Run pwd to see where you are
# 2. Run git status to see what git sees
# 3. Adjust your paths accordingly
git commit -m "fix: Address QA issues (qa-requested)
Fixes:
@@ -62,11 +62,6 @@ def generate_environment_context(project_dir: Path, spec_dir: Path) -> str:
Your filesystem is restricted to your working directory. All file paths should be
relative to this location. Do NOT use absolute paths.
** CRITICAL:** Before ANY git command or file operation, run `pwd` to verify your current
directory. If you've used `cd` to change directories, you MUST use paths relative to your
NEW location, not the working directory. See the PATH CONFUSION PREVENTION section in the
coder prompt for detailed examples.
**Important Files:**
- Spec: `{relative_spec}/spec.md`
- Plan: `{relative_spec}/implementation_plan.json`
-6
View File
@@ -6,7 +6,6 @@ Main QA loop that coordinates reviewer and fixer sessions until
approval or max iterations.
"""
import os
import time as time_module
from pathlib import Path
@@ -23,7 +22,6 @@ from linear_updater import (
from phase_config import get_phase_model, get_phase_thinking_budget
from phase_event import ExecutionPhase, emit_phase
from progress import count_subtasks, is_build_complete
from security.constants import PROJECT_DIR_ENV_VAR
from task_logger import (
LogPhase,
get_task_logger,
@@ -85,10 +83,6 @@ async def run_qa_validation_loop(
Returns:
True if QA approved, False otherwise
"""
# Set environment variable for security hooks to find the correct project directory
# This is needed because os.getcwd() may return the wrong directory in worktree mode
os.environ[PROJECT_DIR_ENV_VAR] = str(project_dir.resolve())
debug_section("qa_loop", "QA Validation Loop")
debug(
"qa_loop",
-4
View File
@@ -10,10 +10,6 @@ tomli>=2.0.0; python_version < "3.11"
real_ladybug>=0.13.0; python_version >= "3.12"
graphiti-core>=0.5.0; python_version >= "3.12"
# Windows-specific dependency for LadybugDB/Graphiti
# pywin32 provides Windows system bindings required by real_ladybug
pywin32>=306; sys_platform == "win32" and python_version >= "3.12"
# Google AI (optional - for Gemini LLM and embeddings)
google-generativeai>=0.8.0
@@ -1,205 +0,0 @@
#!/usr/bin/env python3
"""
PR Worktree Cleanup Utility
============================
Command-line tool for managing PR review worktrees.
Usage:
python cleanup_pr_worktrees.py --list # List all worktrees
python cleanup_pr_worktrees.py --cleanup # Run cleanup policies
python cleanup_pr_worktrees.py --cleanup-all # Remove ALL worktrees
python cleanup_pr_worktrees.py --stats # Show cleanup statistics
"""
import argparse
# Load module directly to avoid import issues
import importlib.util
import sys
from pathlib import Path
services_dir = Path(__file__).parent / "services"
module_path = services_dir / "pr_worktree_manager.py"
spec = importlib.util.spec_from_file_location("pr_worktree_manager", module_path)
pr_worktree_module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(pr_worktree_module)
PRWorktreeManager = pr_worktree_module.PRWorktreeManager
DEFAULT_PR_WORKTREE_MAX_AGE_DAYS = pr_worktree_module.DEFAULT_PR_WORKTREE_MAX_AGE_DAYS
DEFAULT_MAX_PR_WORKTREES = pr_worktree_module.DEFAULT_MAX_PR_WORKTREES
_get_max_age_days = pr_worktree_module._get_max_age_days
_get_max_pr_worktrees = pr_worktree_module._get_max_pr_worktrees
def find_project_root() -> Path:
"""Find the git project root directory."""
current = Path.cwd()
while current != current.parent:
if (current / ".git").exists():
return current
current = current.parent
raise RuntimeError("Not in a git repository")
def list_worktrees(manager: PRWorktreeManager) -> None:
"""List all PR review worktrees."""
worktrees = manager.get_worktree_info()
if not worktrees:
print("No PR review worktrees found.")
return
print(f"\nFound {len(worktrees)} PR review worktrees:\n")
print(f"{'Directory':<40} {'Age (days)':<12} {'PR':<6}")
print("-" * 60)
for wt in worktrees:
pr_str = f"#{wt.pr_number}" if wt.pr_number else "N/A"
print(f"{wt.path.name:<40} {wt.age_days:>10.1f} {pr_str:>6}")
print()
def show_stats(manager: PRWorktreeManager) -> None:
"""Show worktree cleanup statistics."""
worktrees = manager.get_worktree_info()
registered = manager.get_registered_worktrees()
# Use resolved paths for consistent comparison (handles macOS symlinks)
registered_resolved = {p.resolve() for p in registered}
# Get current policy values (may be overridden by env vars)
max_age_days = _get_max_age_days()
max_worktrees = _get_max_pr_worktrees()
total = len(worktrees)
orphaned = sum(
1 for wt in worktrees if wt.path.resolve() not in registered_resolved
)
expired = sum(1 for wt in worktrees if wt.age_days > max_age_days)
excess = max(0, total - max_worktrees)
print("\nPR Worktree Statistics:")
print(f" Total worktrees: {total}")
print(f" Registered with git: {len(registered)}")
print(f" Orphaned (not in git): {orphaned}")
print(f" Expired (>{max_age_days} days): {expired}")
print(f" Excess (>{max_worktrees} limit): {excess}")
print()
print("Cleanup Policies:")
print(f" Max age: {max_age_days} days")
print(f" Max count: {max_worktrees} worktrees")
print()
def cleanup_worktrees(manager: PRWorktreeManager, force: bool = False) -> None:
"""Run cleanup policies on worktrees."""
print("\nRunning PR worktree cleanup...")
if force:
print("WARNING: Force cleanup - removing ALL worktrees!")
count = manager.cleanup_all_worktrees()
print(f"Removed {count} worktrees.")
else:
stats = manager.cleanup_worktrees()
if stats["total"] == 0:
print("No worktrees needed cleanup.")
else:
print("\nCleanup complete:")
print(f" Orphaned removed: {stats['orphaned']}")
print(f" Expired removed: {stats['expired']}")
print(f" Excess removed: {stats['excess']}")
print(f" Total removed: {stats['total']}")
print()
def main():
parser = argparse.ArgumentParser(
description="Manage PR review worktrees",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
python cleanup_pr_worktrees.py --list
python cleanup_pr_worktrees.py --cleanup
python cleanup_pr_worktrees.py --stats
python cleanup_pr_worktrees.py --cleanup-all
Environment variables:
MAX_PR_WORKTREES=10 # Max number of worktrees to keep
PR_WORKTREE_MAX_AGE_DAYS=7 # Max age in days before cleanup
""",
)
parser.add_argument(
"--list", action="store_true", help="List all PR review worktrees"
)
parser.add_argument(
"--cleanup",
action="store_true",
help="Run cleanup policies (remove orphaned, expired, and excess worktrees)",
)
parser.add_argument(
"--cleanup-all",
action="store_true",
help="Remove ALL PR review worktrees (dangerous!)",
)
parser.add_argument("--stats", action="store_true", help="Show cleanup statistics")
parser.add_argument(
"--project-dir",
type=Path,
help="Project directory (default: auto-detect git root)",
)
args = parser.parse_args()
# Require at least one action
if not any([args.list, args.cleanup, args.cleanup_all, args.stats]):
parser.print_help()
return 1
try:
# Find project directory
if args.project_dir:
project_dir = args.project_dir
else:
project_dir = find_project_root()
print(f"Project directory: {project_dir}")
# Create manager
manager = PRWorktreeManager(
project_dir=project_dir, worktree_dir=".auto-claude/github/pr/worktrees"
)
# Execute actions
if args.stats:
show_stats(manager)
if args.list:
list_worktrees(manager)
if args.cleanup:
cleanup_worktrees(manager, force=False)
if args.cleanup_all:
response = input(
"This will remove ALL PR worktrees. Are you sure? (yes/no): "
)
if response.lower() == "yes":
cleanup_worktrees(manager, force=True)
else:
print("Aborted.")
return 0
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return 1
if __name__ == "__main__":
sys.exit(main())
-11
View File
@@ -65,17 +65,6 @@ class MergeVerdict(str, Enum):
BLOCKED = "blocked" # Critical issues, cannot merge
# Constants for branch-behind messaging (DRY - used across multiple reviewers)
BRANCH_BEHIND_BLOCKER_MSG = (
"Branch Out of Date: PR branch is behind the base branch and needs to be updated"
)
BRANCH_BEHIND_REASONING = (
"Branch is out of date with base branch. Update branch first - "
"if no conflicts arise, you can merge. If merge conflicts arise, "
"resolve them and run follow-up review again."
)
class AICommentVerdict(str, Enum):
"""Verdict on AI tool comments (CodeRabbit, Cursor, Greptile, etc.)."""
+7 -124
View File
@@ -24,8 +24,6 @@ try:
from .context_gatherer import PRContext, PRContextGatherer
from .gh_client import GHClient
from .models import (
BRANCH_BEHIND_BLOCKER_MSG,
BRANCH_BEHIND_REASONING,
AICommentTriage,
AICommentVerdict,
AutoFixState,
@@ -52,8 +50,6 @@ except (ImportError, ValueError, SystemError):
from context_gatherer import PRContext, PRContextGatherer
from gh_client import GHClient
from models import (
BRANCH_BEHIND_BLOCKER_MSG,
BRANCH_BEHIND_REASONING,
AICommentTriage,
AICommentVerdict,
AutoFixState,
@@ -417,14 +413,9 @@ class GitHubOrchestrator:
flush=True,
)
# Generate verdict (includes CI status and merge conflict check)
# Generate verdict (now includes CI status)
verdict, verdict_reasoning, blockers = self._generate_verdict(
findings,
structural_issues,
ai_triages,
ci_status,
has_merge_conflicts=pr_context.has_merge_conflicts,
merge_state_status=pr_context.merge_state_status,
findings, structural_issues, ai_triages, ci_status
)
print(
f"[DEBUG orchestrator] Verdict: {verdict.value} - {verdict_reasoning}",
@@ -455,7 +446,6 @@ class GitHubOrchestrator:
structural_issues=structural_issues,
ai_triages=ai_triages,
risk_assessment=risk_assessment,
ci_status=ci_status,
)
# Get HEAD SHA for follow-up review tracking
@@ -806,33 +796,15 @@ class GitHubOrchestrator:
structural_issues: list[StructuralIssue],
ai_triages: list[AICommentTriage],
ci_status: dict | None = None,
has_merge_conflicts: bool = False,
merge_state_status: str = "",
) -> tuple[MergeVerdict, str, list[str]]:
"""
Generate merge verdict based on all findings, CI status, and merge conflicts.
Generate merge verdict based on all findings and CI status.
Blocks on:
- Merge conflicts (must be resolved before merging)
- Verification failures
- Redundancy issues
- Failing CI checks
Warns on (NEEDS_REVISION):
- Branch behind base (out of date)
NEW: Strengthened to block on verification failures, redundancy issues,
and failing CI checks.
"""
blockers = []
ci_status = ci_status or {}
is_branch_behind = merge_state_status == "BEHIND"
# CRITICAL: Merge conflicts block merging - check first
if has_merge_conflicts:
blockers.append(
"Merge Conflicts: PR has conflicts with base branch that must be resolved"
)
# Branch behind base is a warning, not a hard blocker
elif is_branch_behind:
blockers.append(BRANCH_BEHIND_BLOCKER_MSG)
# Count by severity
critical = [f for f in findings if f.severity == ReviewSeverity.CRITICAL]
@@ -913,17 +885,10 @@ class GitHubOrchestrator:
)
blockers.append(f"{t.tool_name}: {summary}")
# Determine verdict with merge conflicts, CI, verification and redundancy checks
# Determine verdict with CI, verification and redundancy checks
if blockers:
# Merge conflicts are the highest priority blocker
if has_merge_conflicts:
verdict = MergeVerdict.BLOCKED
reasoning = (
"Blocked: PR has merge conflicts with base branch. "
"Resolve conflicts before merge."
)
# CI failures are always blockers
elif failed_checks:
if failed_checks:
verdict = MergeVerdict.BLOCKED
reasoning = (
f"Blocked: {len(failed_checks)} CI check(s) failing. "
@@ -957,12 +922,6 @@ class GitHubOrchestrator:
elif len(critical) > 0:
verdict = MergeVerdict.BLOCKED
reasoning = f"Blocked by {len(critical)} critical issues"
# Branch behind is a soft blocker - NEEDS_REVISION, not BLOCKED
elif is_branch_behind:
verdict = MergeVerdict.NEEDS_REVISION
reasoning = BRANCH_BEHIND_REASONING
if low:
reasoning += f" {len(low)} non-blocking suggestion(s) to consider."
else:
verdict = MergeVerdict.NEEDS_REVISION
reasoning = f"{len(blockers)} issues must be addressed"
@@ -1046,7 +1005,6 @@ class GitHubOrchestrator:
structural_issues: list[StructuralIssue],
ai_triages: list[AICommentTriage],
risk_assessment: dict,
ci_status: dict | None = None,
) -> str:
"""Generate enhanced summary with verdict, risk, and actionable next steps."""
verdict_emoji = {
@@ -1056,19 +1014,8 @@ class GitHubOrchestrator:
MergeVerdict.BLOCKED: "🔴",
}
# Generate bottom line for quick scanning
bottom_line = self._generate_bottom_line(
verdict=verdict,
ci_status=ci_status,
blockers=blockers,
findings=findings,
)
lines = [
f"### Merge Verdict: {verdict_emoji.get(verdict, '')} {verdict.value.upper().replace('_', ' ')}",
"",
f"> {bottom_line}",
"",
verdict_reasoning,
"",
"### Risk Assessment",
@@ -1135,70 +1082,6 @@ class GitHubOrchestrator:
return "\n".join(lines)
def _generate_bottom_line(
self,
verdict: MergeVerdict,
ci_status: dict | None,
blockers: list[str],
findings: list[PRReviewFinding],
) -> str:
"""Generate a one-line summary for quick scanning at the top of the review."""
# Check CI status
ci = ci_status or {}
pending_ci = ci.get("pending", 0)
failing_ci = ci.get("failing", 0)
awaiting_approval = ci.get("awaiting_approval", 0)
# Count blocking findings and issues
blocking_findings = [
f for f in findings if f.severity.value in ("critical", "high", "medium")
]
code_blockers = [
b for b in blockers if "CI" not in b and "Merge Conflict" not in b
]
has_merge_conflicts = any("Merge Conflict" in b for b in blockers)
# Determine the bottom line based on verdict and context
if verdict == MergeVerdict.READY_TO_MERGE:
return (
"**✅ Ready to merge** - All checks passing, no blocking issues found."
)
elif verdict == MergeVerdict.BLOCKED:
if has_merge_conflicts:
return "**🔴 Blocked** - Merge conflicts must be resolved before merge."
elif failing_ci > 0:
return f"**🔴 Blocked** - {failing_ci} CI check(s) failing. Fix CI before merge."
elif awaiting_approval > 0:
return "**🔴 Blocked** - Awaiting maintainer approval for fork PR workflow."
elif blocking_findings:
return f"**🔴 Blocked** - {len(blocking_findings)} critical/high/medium issue(s) must be fixed."
else:
return "**🔴 Blocked** - Critical issues must be resolved before merge."
elif verdict == MergeVerdict.NEEDS_REVISION:
# Key insight: distinguish "waiting on CI" from "needs code fixes"
# Check code issues FIRST before checking pending CI
if blocking_findings:
return f"**🟠 Needs revision** - {len(blocking_findings)} issue(s) require attention."
elif code_blockers:
return f"**🟠 Needs revision** - {len(code_blockers)} structural/other issue(s) require attention."
elif pending_ci > 0:
# Only show "Ready once CI passes" when no code issues exist
return f"**⏳ Ready once CI passes** - {pending_ci} check(s) pending, no blocking code issues."
else:
return "**🟠 Needs revision** - See details below."
elif verdict == MergeVerdict.MERGE_WITH_CHANGES:
if pending_ci > 0:
return (
"**🟡 Can merge once CI passes** - Minor suggestions, no blockers."
)
else:
return "**🟡 Can merge** - Minor suggestions noted, no blockers."
return "**📝 Review complete** - See details below."
def _format_review_body(self, result: PRReviewResult) -> str:
"""Format the review body for posting to GitHub."""
return result.summary
+2 -4
View File
@@ -56,10 +56,8 @@ if sys.platform == "win32":
# Add backend to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
# Load .env file with centralized error handling
from cli.utils import import_dotenv
load_dotenv = import_dotenv()
# Load .env file
from dotenv import load_dotenv
env_file = Path(__file__).parent.parent.parent / ".env"
if env_file.exists():
@@ -21,6 +21,9 @@ from __future__ import annotations
import hashlib
import logging
import os
import shutil
import subprocess
import uuid
from pathlib import Path
from typing import TYPE_CHECKING
@@ -35,8 +38,6 @@ try:
from ..context_gatherer import _validate_git_ref
from ..gh_client import GHClient
from ..models import (
BRANCH_BEHIND_BLOCKER_MSG,
BRANCH_BEHIND_REASONING,
GitHubRunnerConfig,
MergeVerdict,
PRReviewFinding,
@@ -44,7 +45,6 @@ try:
ReviewSeverity,
)
from .category_utils import map_category
from .pr_worktree_manager import PRWorktreeManager
from .pydantic_models import ParallelFollowupResponse
from .sdk_utils import process_sdk_stream
except (ImportError, ValueError, SystemError):
@@ -52,8 +52,6 @@ except (ImportError, ValueError, SystemError):
from core.client import create_client
from gh_client import GHClient
from models import (
BRANCH_BEHIND_BLOCKER_MSG,
BRANCH_BEHIND_REASONING,
GitHubRunnerConfig,
MergeVerdict,
PRReviewFinding,
@@ -62,7 +60,6 @@ except (ImportError, ValueError, SystemError):
)
from phase_config import get_thinking_budget
from services.category_utils import map_category
from services.pr_worktree_manager import PRWorktreeManager
from services.pydantic_models import ParallelFollowupResponse
from services.sdk_utils import process_sdk_stream
@@ -119,7 +116,6 @@ class ParallelFollowupReviewer:
self.github_dir = Path(github_dir)
self.config = config
self.progress_callback = progress_callback
self.worktree_manager = PRWorktreeManager(project_dir, PR_WORKTREE_DIR)
def _report_progress(self, phase: str, progress: int, message: str, **kwargs):
"""Report progress if callback is set."""
@@ -171,7 +167,59 @@ class ParallelFollowupReviewer:
"Must contain only alphanumeric characters, dots, slashes, underscores, and hyphens."
)
return self.worktree_manager.create_worktree(head_sha, pr_number)
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.
@@ -179,7 +227,40 @@ class ParallelFollowupReviewer:
Args:
worktree_path: Path to the worktree to remove
"""
self.worktree_manager.remove_worktree(worktree_path)
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]:
"""
@@ -585,60 +666,15 @@ The SDK will run invoked agents in parallel automatically.
f"{len(resolved_ids)} resolved, {len(unresolved_ids)} unresolved"
)
# Generate blockers from critical/high/medium severity findings
# (Medium also blocks merge in our strict quality gates approach)
blockers = []
# CRITICAL: Merge conflicts block merging - check FIRST before summary generation
# This must happen before _generate_summary so the summary reflects merge conflict status
if context.has_merge_conflicts:
blockers.append(
"Merge Conflicts: PR has conflicts with base branch that must be resolved"
)
# Override verdict to BLOCKED if merge conflicts exist
verdict = MergeVerdict.BLOCKED
verdict_reasoning = (
"Blocked: PR has merge conflicts with base branch. "
"Resolve conflicts before merge."
)
print(
"[ParallelFollowup] ⚠️ PR has merge conflicts - blocking merge",
flush=True,
)
# Check if branch is behind base (out of date) - warning, not hard blocker
elif context.merge_state_status == "BEHIND":
blockers.append(BRANCH_BEHIND_BLOCKER_MSG)
# Use NEEDS_REVISION since potential conflicts are unknown until branch is updated
# Must handle both READY_TO_MERGE and MERGE_WITH_CHANGES verdicts
if verdict in (
MergeVerdict.READY_TO_MERGE,
MergeVerdict.MERGE_WITH_CHANGES,
):
verdict = MergeVerdict.NEEDS_REVISION
verdict_reasoning = BRANCH_BEHIND_REASONING
print(
"[ParallelFollowup] ⚠️ PR branch is behind base - needs update",
flush=True,
)
for finding in unique_findings:
if finding.severity in (
ReviewSeverity.CRITICAL,
ReviewSeverity.HIGH,
ReviewSeverity.MEDIUM,
):
blockers.append(f"{finding.category.value}: {finding.title}")
# Extract validation counts
dismissed_count = len(result_data.get("dismissed_false_positive_ids", []))
confirmed_count = result_data.get("confirmed_valid_count", 0)
needs_human_count = result_data.get("needs_human_review_count", 0)
# Generate summary (AFTER merge conflict check so it reflects correct verdict)
# Generate summary
summary = self._generate_summary(
verdict=verdict,
verdict_reasoning=verdict_reasoning,
blockers=blockers,
resolved_count=len(resolved_ids),
unresolved_count=len(unresolved_ids),
new_count=len(new_finding_ids),
@@ -646,7 +682,6 @@ The SDK will run invoked agents in parallel automatically.
dismissed_false_positive_count=dismissed_count,
confirmed_valid_count=confirmed_count,
needs_human_review_count=needs_human_count,
ci_status=context.ci_status,
)
# Map verdict to overall_status
@@ -659,6 +694,17 @@ The SDK will run invoked agents in parallel automatically.
else:
overall_status = "approve"
# Generate blockers from critical/high/medium severity findings
# (Medium also blocks merge in our strict quality gates approach)
blockers = []
for finding in unique_findings:
if finding.severity in (
ReviewSeverity.CRITICAL,
ReviewSeverity.HIGH,
ReviewSeverity.MEDIUM,
):
blockers.append(f"{finding.category.value}: {finding.title}")
# Get file blob SHAs for rebase-resistant follow-up reviews
# Blob SHAs persist across rebases - same content = same blob SHA
file_blobs: dict[str, str] = {}
@@ -989,7 +1035,6 @@ The SDK will run invoked agents in parallel automatically.
self,
verdict: MergeVerdict,
verdict_reasoning: str,
blockers: list[str],
resolved_count: int,
unresolved_count: int,
new_count: int,
@@ -997,15 +1042,13 @@ The SDK will run invoked agents in parallel automatically.
dismissed_false_positive_count: int = 0,
confirmed_valid_count: int = 0,
needs_human_review_count: int = 0,
ci_status: dict | None = None,
) -> str:
"""Generate a human-readable summary of the follow-up review."""
# Use same emojis as orchestrator.py for consistency
status_emoji = {
MergeVerdict.READY_TO_MERGE: "",
MergeVerdict.MERGE_WITH_CHANGES: "🟡",
MergeVerdict.NEEDS_REVISION: "🟠",
MergeVerdict.BLOCKED: "🔴",
MergeVerdict.MERGE_WITH_CHANGES: "⚠️",
MergeVerdict.NEEDS_REVISION: "🔄",
MergeVerdict.BLOCKED: "🚫",
}
emoji = status_emoji.get(verdict, "📝")
@@ -1013,15 +1056,6 @@ The SDK will run invoked agents in parallel automatically.
", ".join(agents_invoked) if agents_invoked else "orchestrator only"
)
# Generate a prominent bottom-line summary for quick scanning
bottom_line = self._generate_bottom_line(
verdict=verdict,
ci_status=ci_status,
unresolved_count=unresolved_count,
new_count=new_count,
blockers=blockers,
)
# Build validation section if there are validation results
validation_section = ""
if (
@@ -1034,26 +1068,15 @@ The SDK will run invoked agents in parallel automatically.
- 🔍 **Dismissed as False Positives**: {dismissed_false_positive_count} findings were re-investigated and found to be incorrect
- **Confirmed Valid**: {confirmed_valid_count} findings verified as genuine issues
- 👤 **Needs Human Review**: {needs_human_review_count} findings require manual verification
"""
# Build blockers section if there are any blockers
blockers_section = ""
if blockers:
blockers_list = "\n".join(f"- {b}" for b in blockers)
blockers_section = f"""
### 🚨 Blocking Issues
{blockers_list}
"""
summary = f"""## {emoji} Follow-up Review: {verdict.value.replace("_", " ").title()}
> {bottom_line}
### Resolution Status
- **Resolved**: {resolved_count} previous findings addressed
- **Unresolved**: {unresolved_count} previous findings remain
- 🆕 **New Issues**: {new_count} new findings in recent changes
{validation_section}{blockers_section}
{validation_section}
### Verdict
{verdict_reasoning}
@@ -1064,65 +1087,3 @@ Agents invoked: {agents_str}
*This is an AI-generated follow-up review using parallel specialist analysis with finding validation.*
"""
return summary
def _generate_bottom_line(
self,
verdict: MergeVerdict,
ci_status: dict | None,
unresolved_count: int,
new_count: int,
blockers: list[str],
) -> str:
"""Generate a one-line summary for quick scanning at the top of the review."""
# Check CI status
ci = ci_status or {}
pending_ci = ci.get("pending", 0)
failing_ci = ci.get("failing", 0)
awaiting_approval = ci.get("awaiting_approval", 0)
# Count blocking issues (excluding CI-related ones)
code_blockers = [
b for b in blockers if "CI" not in b and "Merge Conflict" not in b
]
has_merge_conflicts = any("Merge Conflict" in b for b in blockers)
# Determine the bottom line based on verdict and context
if verdict == MergeVerdict.READY_TO_MERGE:
return "**✅ Ready to merge** - All checks passing and findings addressed."
elif verdict == MergeVerdict.BLOCKED:
if has_merge_conflicts:
return "**🔴 Blocked** - Merge conflicts must be resolved before merge."
elif failing_ci > 0:
return f"**🔴 Blocked** - {failing_ci} CI check(s) failing. Fix CI before merge."
elif awaiting_approval > 0:
return "**🔴 Blocked** - Awaiting maintainer approval for fork PR workflow."
elif code_blockers:
return f"**🔴 Blocked** - {len(code_blockers)} blocking issue(s) require fixes."
else:
return "**🔴 Blocked** - Critical issues must be resolved before merge."
elif verdict == MergeVerdict.NEEDS_REVISION:
# Key insight: distinguish "waiting on CI" from "needs code fixes"
# Check code issues FIRST before checking pending CI
if unresolved_count > 0:
return f"**🟠 Needs revision** - {unresolved_count} unresolved finding(s) from previous review."
elif code_blockers:
return f"**🟠 Needs revision** - {len(code_blockers)} blocking issue(s) require fixes."
elif new_count > 0:
return f"**🟠 Needs revision** - {new_count} new issue(s) found in recent changes."
elif pending_ci > 0:
# Only show "Ready once CI passes" when no code issues exist
return f"**⏳ Ready once CI passes** - {pending_ci} check(s) pending, all findings addressed."
else:
return "**🟠 Needs revision** - See details below."
elif verdict == MergeVerdict.MERGE_WITH_CHANGES:
if pending_ci > 0:
return (
"**🟡 Can merge once CI passes** - Minor suggestions, no blockers."
)
else:
return "**🟡 Can merge** - Minor suggestions noted, no blockers."
return "**📝 Review complete** - See details below."
@@ -20,6 +20,9 @@ from __future__ import annotations
import hashlib
import logging
import os
import shutil
import subprocess
import uuid
from pathlib import Path
from typing import Any
@@ -31,8 +34,6 @@ try:
from ..context_gatherer import PRContext, _validate_git_ref
from ..gh_client import GHClient
from ..models import (
BRANCH_BEHIND_BLOCKER_MSG,
BRANCH_BEHIND_REASONING,
GitHubRunnerConfig,
MergeVerdict,
PRReviewFinding,
@@ -40,7 +41,6 @@ try:
ReviewSeverity,
)
from .category_utils import map_category
from .pr_worktree_manager import PRWorktreeManager
from .pydantic_models import ParallelOrchestratorResponse
from .sdk_utils import process_sdk_stream
except (ImportError, ValueError, SystemError):
@@ -48,8 +48,6 @@ except (ImportError, ValueError, SystemError):
from core.client import create_client
from gh_client import GHClient
from models import (
BRANCH_BEHIND_BLOCKER_MSG,
BRANCH_BEHIND_REASONING,
GitHubRunnerConfig,
MergeVerdict,
PRReviewFinding,
@@ -58,7 +56,6 @@ except (ImportError, ValueError, SystemError):
)
from phase_config import get_thinking_budget
from services.category_utils import map_category
from services.pr_worktree_manager import PRWorktreeManager
from services.pydantic_models import ParallelOrchestratorResponse
from services.sdk_utils import process_sdk_stream
@@ -97,7 +94,6 @@ class ParallelOrchestratorReviewer:
self.github_dir = Path(github_dir)
self.config = config
self.progress_callback = progress_callback
self.worktree_manager = PRWorktreeManager(project_dir, PR_WORKTREE_DIR)
def _report_progress(self, phase: str, progress: int, message: str, **kwargs):
"""Report progress if callback is set."""
@@ -149,7 +145,78 @@ class ParallelOrchestratorReviewer:
"Must contain only alphanumeric characters, dots, slashes, underscores, and hyphens."
)
return self.worktree_manager.create_worktree(head_sha, pr_number)
worktree_name = f"pr-{pr_number}-{uuid.uuid4().hex[:8]}"
worktree_dir = self.project_dir / PR_WORKTREE_DIR
if DEBUG_MODE:
print(f"[PRReview] DEBUG: project_dir={self.project_dir}", flush=True)
print(f"[PRReview] DEBUG: worktree_dir={worktree_dir}", flush=True)
print(f"[PRReview] 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"[PRReview] DEBUG: worktree_path={worktree_path}", flush=True)
print(
f"[PRReview] DEBUG: worktree_dir exists={worktree_dir.exists()}",
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"[PRReview] DEBUG: fetch returncode={fetch_result.returncode}",
flush=True,
)
if fetch_result.stderr:
print(
f"[PRReview] DEBUG: fetch stderr={fetch_result.stderr[:200]}",
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, # Worktree add can be slow for large repos
)
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: worktree add returncode={result.returncode}",
flush=True,
)
if result.stderr:
print(
f"[PRReview] DEBUG: worktree add stderr={result.stderr[:200]}",
flush=True,
)
if result.stdout:
print(
f"[PRReview] DEBUG: worktree add stdout={result.stdout[:200]}",
flush=True,
)
if result.returncode != 0:
raise RuntimeError(f"Failed to create worktree: {result.stderr}")
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: worktree created, exists={worktree_path.exists()}",
flush=True,
)
logger.info(f"[PRReview] 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.
@@ -157,16 +224,100 @@ class ParallelOrchestratorReviewer:
Args:
worktree_path: Path to the worktree to remove
"""
self.worktree_manager.remove_worktree(worktree_path)
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: _cleanup_pr_worktree called with {worktree_path}",
flush=True,
)
if not worktree_path or not worktree_path.exists():
if DEBUG_MODE:
print(
"[PRReview] DEBUG: worktree path doesn't exist, skipping cleanup",
flush=True,
)
return
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: Attempting to remove 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 DEBUG_MODE:
print(
f"[PRReview] DEBUG: worktree remove returncode={result.returncode}",
flush=True,
)
if result.returncode == 0:
logger.info(f"[PRReview] 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"[PRReview] Used shutil fallback for: {worktree_path.name}")
except Exception as e:
logger.error(f"[PRReview] Failed to cleanup worktree {worktree_path}: {e}")
def _cleanup_stale_pr_worktrees(self) -> None:
"""Clean up orphaned, expired, and excess PR review worktrees on startup."""
stats = self.worktree_manager.cleanup_worktrees()
if stats["total"] > 0:
logger.info(
f"[PRReview] Cleanup: removed {stats['total']} worktrees "
f"(orphaned={stats['orphaned']}, expired={stats['expired']}, excess={stats['excess']})"
"""Clean up orphaned PR review worktrees on startup."""
worktree_dir = self.project_dir / PR_WORKTREE_DIR
if not worktree_dir.exists():
return
# Get registered worktrees from git
result = subprocess.run(
["git", "worktree", "list", "--porcelain"],
cwd=self.project_dir,
capture_output=True,
text=True,
timeout=30,
)
registered = set()
for line in result.stdout.split("\n"):
if line.startswith("worktree "):
# Safely parse - check bounds to prevent IndexError
parts = line.split(" ", 1)
if len(parts) > 1 and parts[1]:
registered.add(Path(parts[1]))
# Remove unregistered directories
stale_count = 0
for item in worktree_dir.iterdir():
if item.is_dir() and item not in registered:
logger.info(f"[PRReview] Removing stale worktree: {item.name}")
shutil.rmtree(item, ignore_errors=True)
stale_count += 1
if stale_count > 0:
subprocess.run(
["git", "worktree", "prune"],
cwd=self.project_dir,
capture_output=True,
timeout=30,
)
if DEBUG_MODE:
print(
f"[PRReview] DEBUG: Cleaned up {stale_count} stale worktree(s)",
flush=True,
)
def _define_specialist_agents(self) -> dict[str, AgentDefinition]:
"""
@@ -620,11 +771,9 @@ The SDK will run invoked agents in parallel automatically.
f"[ParallelOrchestrator] Review complete: {len(unique_findings)} findings"
)
# Generate verdict (includes merge conflict check and branch-behind check)
# Generate verdict
verdict, verdict_reasoning, blockers = self._generate_verdict(
unique_findings,
has_merge_conflicts=context.has_merge_conflicts,
merge_state_status=context.merge_state_status,
unique_findings
)
# Generate summary
@@ -868,23 +1017,10 @@ The SDK will run invoked agents in parallel automatically.
return unique
def _generate_verdict(
self,
findings: list[PRReviewFinding],
has_merge_conflicts: bool = False,
merge_state_status: str = "",
self, findings: list[PRReviewFinding]
) -> tuple[MergeVerdict, str, list[str]]:
"""Generate merge verdict based on findings, merge conflict status, and branch state."""
"""Generate merge verdict based on findings."""
blockers = []
is_branch_behind = merge_state_status == "BEHIND"
# CRITICAL: Merge conflicts block merging - check first
if has_merge_conflicts:
blockers.append(
"Merge Conflicts: PR has conflicts with base branch that must be resolved"
)
# Branch behind base is a warning, not a hard blocker
elif is_branch_behind:
blockers.append(BRANCH_BEHIND_BLOCKER_MSG)
critical = [f for f in findings if f.severity == ReviewSeverity.CRITICAL]
high = [f for f in findings if f.severity == ReviewSeverity.HIGH]
@@ -895,25 +1031,8 @@ The SDK will run invoked agents in parallel automatically.
blockers.append(f"Critical: {f.title} ({f.file}:{f.line})")
if blockers:
# Merge conflicts are the highest priority blocker
if has_merge_conflicts:
verdict = MergeVerdict.BLOCKED
reasoning = (
"Blocked: PR has merge conflicts with base branch. "
"Resolve conflicts before merge."
)
elif critical:
verdict = MergeVerdict.BLOCKED
reasoning = f"Blocked by {len(critical)} critical issue(s)"
# Branch behind is a soft blocker - NEEDS_REVISION, not BLOCKED
elif is_branch_behind:
verdict = MergeVerdict.NEEDS_REVISION
reasoning = BRANCH_BEHIND_REASONING
if low:
reasoning += f" {len(low)} non-blocking suggestion(s) to consider."
else:
verdict = MergeVerdict.BLOCKED
reasoning = f"Blocked by {len(blockers)} issue(s)"
verdict = MergeVerdict.BLOCKED
reasoning = f"Blocked by {len(blockers)} critical issue(s)"
elif high or medium:
# High and Medium severity findings block merge
verdict = MergeVerdict.NEEDS_REVISION
@@ -242,9 +242,7 @@ class PRReviewEngine:
msg_type = type(msg).__name__
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
for block in msg.content:
# Must check block type - only TextBlock has .text attribute
block_type = type(block).__name__
if block_type == "TextBlock" and hasattr(block, "text"):
if hasattr(block, "text"):
result_text += block.text
if review_pass == ReviewPass.QUICK_SCAN:
@@ -504,9 +502,7 @@ class PRReviewEngine:
msg_type = type(msg).__name__
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
for block in msg.content:
# Must check block type - only TextBlock has .text attribute
block_type = type(block).__name__
if block_type == "TextBlock" and hasattr(block, "text"):
if hasattr(block, "text"):
result_text += block.text
except Exception as e:
print(f"[AI] Structural pass error: {e}", flush=True)
@@ -562,9 +558,7 @@ class PRReviewEngine:
msg_type = type(msg).__name__
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
for block in msg.content:
# Must check block type - only TextBlock has .text attribute
block_type = type(block).__name__
if block_type == "TextBlock" and hasattr(block, "text"):
if hasattr(block, "text"):
result_text += block.text
except Exception as e:
print(f"[AI] AI triage pass error: {e}", flush=True)
@@ -1,437 +0,0 @@
"""
PR Worktree Manager
===================
Manages lifecycle of PR review worktrees with cleanup policies.
Features:
- Age-based cleanup (remove worktrees older than N days)
- Count-based cleanup (keep only N most recent worktrees)
- Orphaned worktree cleanup (worktrees not registered with git)
- Automatic cleanup on review completion
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import time
from pathlib import Path
from typing import NamedTuple
logger = logging.getLogger(__name__)
# Default cleanup policies (can be overridden via environment variables)
DEFAULT_MAX_PR_WORKTREES = 10 # Max worktrees to keep
DEFAULT_PR_WORKTREE_MAX_AGE_DAYS = 7 # Max age in days
def _get_max_pr_worktrees() -> int:
"""Get max worktrees setting, read at runtime for testability."""
try:
value = int(os.environ.get("MAX_PR_WORKTREES", str(DEFAULT_MAX_PR_WORKTREES)))
return value if value > 0 else DEFAULT_MAX_PR_WORKTREES
except (ValueError, TypeError):
return DEFAULT_MAX_PR_WORKTREES
def _get_max_age_days() -> int:
"""Get max age setting, read at runtime for testability."""
try:
value = int(
os.environ.get(
"PR_WORKTREE_MAX_AGE_DAYS", str(DEFAULT_PR_WORKTREE_MAX_AGE_DAYS)
)
)
return value if value >= 0 else DEFAULT_PR_WORKTREE_MAX_AGE_DAYS
except (ValueError, TypeError):
return DEFAULT_PR_WORKTREE_MAX_AGE_DAYS
# Safe pattern for git refs (SHA, branch names)
# Allows: alphanumeric, dots, underscores, hyphens, forward slashes
import re
SAFE_REF_PATTERN = re.compile(r"^[a-zA-Z0-9._/\-]+$")
class WorktreeInfo(NamedTuple):
"""Information about a PR worktree."""
path: Path
age_days: float
pr_number: int | None = None
class PRWorktreeManager:
"""
Manages PR review worktrees with automatic cleanup policies.
Cleanup policies:
1. Remove worktrees older than PR_WORKTREE_MAX_AGE_DAYS (default: 7 days)
2. Keep only MAX_PR_WORKTREES most recent worktrees (default: 10)
3. Remove orphaned worktrees (not registered with git)
"""
def __init__(self, project_dir: Path, worktree_dir: str | Path):
"""
Initialize the worktree manager.
Args:
project_dir: Root directory of the git project
worktree_dir: Directory where PR worktrees are stored (relative to project_dir)
"""
self.project_dir = Path(project_dir)
self.worktree_base_dir = self.project_dir / worktree_dir
def create_worktree(
self, head_sha: str, pr_number: int, auto_cleanup: bool = True
) -> Path:
"""
Create a PR worktree with automatic cleanup of old worktrees.
Args:
head_sha: Git commit SHA to checkout
pr_number: PR number for naming
auto_cleanup: If True (default), run cleanup before creating
Returns:
Path to the created worktree
Raises:
RuntimeError: If worktree creation fails
ValueError: If head_sha or pr_number are invalid
"""
# Validate inputs to prevent command injection
if not head_sha or not SAFE_REF_PATTERN.match(head_sha):
raise ValueError(
f"Invalid head_sha: must match pattern {SAFE_REF_PATTERN.pattern}"
)
if not isinstance(pr_number, int) or pr_number <= 0:
raise ValueError(
f"Invalid pr_number: must be a positive integer, got {pr_number}"
)
# Run cleanup before creating new worktree (can be disabled for tests)
if auto_cleanup:
self.cleanup_worktrees()
# Generate worktree name with timestamp for uniqueness
sha_short = head_sha[:8]
timestamp = int(time.time() * 1000) # Millisecond precision
worktree_name = f"pr-{pr_number}-{sha_short}-{timestamp}"
# Create worktree directory
self.worktree_base_dir.mkdir(parents=True, exist_ok=True)
worktree_path = self.worktree_base_dir / worktree_name
logger.debug(f"Creating worktree: {worktree_path}")
try:
# 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 fetch_result.returncode != 0:
logger.warning(
f"Could not fetch {head_sha} from origin (fork PR?): {fetch_result.stderr}"
)
except subprocess.TimeoutExpired:
logger.warning(
f"Timeout fetching {head_sha} from origin, continuing anyway"
)
try:
# 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 result.returncode != 0:
# Check for fatal errors in stderr (git outputs info to stderr too)
stderr = result.stderr.strip()
# Clean up partial worktree on failure
if worktree_path.exists():
shutil.rmtree(worktree_path, ignore_errors=True)
raise RuntimeError(f"Failed to create worktree: {stderr}")
# Verify the worktree was actually created
if not worktree_path.exists():
raise RuntimeError(
f"Worktree creation reported success but path does not exist: {worktree_path}"
)
except subprocess.TimeoutExpired:
# Clean up partial worktree on timeout
if worktree_path.exists():
shutil.rmtree(worktree_path, ignore_errors=True)
raise RuntimeError(f"Timeout creating worktree for {head_sha}")
logger.info(f"[WorktreeManager] Created worktree at {worktree_path}")
return worktree_path
def remove_worktree(self, worktree_path: Path) -> None:
"""
Remove a PR worktree with fallback chain.
Args:
worktree_path: Path to the worktree to remove
"""
if not worktree_path or not worktree_path.exists():
return
logger.debug(f"Removing worktree: {worktree_path}")
# Try 1: git worktree remove
try:
result = subprocess.run(
["git", "worktree", "remove", "--force", str(worktree_path)],
cwd=self.project_dir,
capture_output=True,
text=True,
timeout=60,
)
if result.returncode == 0:
logger.info(f"[WorktreeManager] Removed worktree: {worktree_path.name}")
return
except subprocess.TimeoutExpired:
logger.warning(
f"Timeout removing worktree {worktree_path.name}, falling back to shutil"
)
# 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"[WorktreeManager] Used shutil fallback for: {worktree_path.name}"
)
except Exception as e:
logger.error(
f"[WorktreeManager] Failed to remove worktree {worktree_path}: {e}"
)
def get_worktree_info(self) -> list[WorktreeInfo]:
"""
Get information about all PR worktrees.
Returns:
List of WorktreeInfo objects sorted by age (oldest first)
"""
if not self.worktree_base_dir.exists():
return []
worktrees = []
current_time = time.time()
for item in self.worktree_base_dir.iterdir():
if not item.is_dir():
continue
# Get modification time
mtime = item.stat().st_mtime
age_seconds = current_time - mtime
age_days = age_seconds / 86400 # Convert seconds to days
# Extract PR number from directory name (format: pr-XXX-sha)
pr_number = None
if item.name.startswith("pr-"):
parts = item.name.split("-")
if len(parts) >= 2:
try:
pr_number = int(parts[1])
except ValueError:
pass
worktrees.append(
WorktreeInfo(path=item, age_days=age_days, pr_number=pr_number)
)
# Sort by age (oldest first)
worktrees.sort(key=lambda x: x.age_days, reverse=True)
return worktrees
def get_registered_worktrees(self) -> set[Path]:
"""
Get set of worktrees registered with git.
Returns:
Set of resolved Path objects for registered worktrees
"""
try:
result = subprocess.run(
["git", "worktree", "list", "--porcelain"],
cwd=self.project_dir,
capture_output=True,
text=True,
timeout=30,
)
except subprocess.TimeoutExpired:
logger.warning("Timeout listing worktrees, returning empty set")
return set()
registered = set()
for line in result.stdout.split("\n"):
if line.startswith("worktree "):
parts = line.split(" ", 1)
if len(parts) > 1 and parts[1]:
registered.add(Path(parts[1]))
return registered
def cleanup_worktrees(self, force: bool = False) -> dict[str, int]:
"""
Clean up PR worktrees based on age and count policies.
Cleanup order:
1. Remove orphaned worktrees (not registered with git)
2. Remove worktrees older than PR_WORKTREE_MAX_AGE_DAYS
3. If still over MAX_PR_WORKTREES, remove oldest worktrees
Args:
force: If True, skip age check and only enforce count limit
Returns:
Dict with cleanup statistics: {
'orphaned': count,
'expired': count,
'excess': count,
'total': count
}
"""
stats = {"orphaned": 0, "expired": 0, "excess": 0, "total": 0}
if not self.worktree_base_dir.exists():
return stats
# Get registered worktrees (resolved paths for consistent comparison)
registered = self.get_registered_worktrees()
registered_resolved = {p.resolve() for p in registered}
# Get all PR worktree info
worktrees = self.get_worktree_info()
# Phase 1: Remove orphaned worktrees
for wt in worktrees:
if wt.path.resolve() not in registered_resolved:
logger.info(
f"[WorktreeManager] Removing orphaned worktree: {wt.path.name} (age: {wt.age_days:.1f} days)"
)
shutil.rmtree(wt.path, ignore_errors=True)
stats["orphaned"] += 1
# Refresh worktree list after orphan cleanup
try:
subprocess.run(
["git", "worktree", "prune"],
cwd=self.project_dir,
capture_output=True,
timeout=30,
)
except subprocess.TimeoutExpired:
logger.warning("Timeout pruning worktrees, continuing anyway")
# Refresh registered worktrees after prune (git's internal registry may have changed)
registered_resolved = {p.resolve() for p in self.get_registered_worktrees()}
# Get fresh worktree info for remaining worktrees (use resolved paths)
worktrees = [
wt
for wt in self.get_worktree_info()
if wt.path.resolve() in registered_resolved
]
# Phase 2: Remove expired worktrees (older than max age)
max_age_days = _get_max_age_days()
if not force:
for wt in worktrees:
if wt.age_days > max_age_days:
logger.info(
f"[WorktreeManager] Removing expired worktree: {wt.path.name} (age: {wt.age_days:.1f} days, max: {max_age_days} days)"
)
self.remove_worktree(wt.path)
stats["expired"] += 1
# Refresh worktree list after expiration cleanup (use resolved paths)
registered_resolved = {p.resolve() for p in self.get_registered_worktrees()}
worktrees = [
wt
for wt in self.get_worktree_info()
if wt.path.resolve() in registered_resolved
]
# Phase 3: Remove excess worktrees (keep only max_pr_worktrees most recent)
max_pr_worktrees = _get_max_pr_worktrees()
if len(worktrees) > max_pr_worktrees:
# worktrees are already sorted by age (oldest first)
excess_count = len(worktrees) - max_pr_worktrees
for wt in worktrees[:excess_count]:
logger.info(
f"[WorktreeManager] Removing excess worktree: {wt.path.name} (count: {len(worktrees)}, max: {max_pr_worktrees})"
)
self.remove_worktree(wt.path)
stats["excess"] += 1
stats["total"] = stats["orphaned"] + stats["expired"] + stats["excess"]
if stats["total"] > 0:
logger.info(
f"[WorktreeManager] Cleanup complete: {stats['total']} worktrees removed "
f"(orphaned={stats['orphaned']}, expired={stats['expired']}, excess={stats['excess']})"
)
else:
logger.debug(
f"No cleanup needed (current: {len(worktrees)}, max: {max_pr_worktrees})"
)
return stats
def cleanup_all_worktrees(self) -> int:
"""
Remove ALL PR worktrees (for testing or emergency cleanup).
Returns:
Number of worktrees removed
"""
if not self.worktree_base_dir.exists():
return 0
worktrees = self.get_worktree_info()
count = 0
for wt in worktrees:
logger.info(f"[WorktreeManager] Removing worktree: {wt.path.name}")
self.remove_worktree(wt.path)
count += 1
if count > 0:
try:
subprocess.run(
["git", "worktree", "prune"],
cwd=self.project_dir,
capture_output=True,
timeout=30,
)
except subprocess.TimeoutExpired:
logger.warning("Timeout pruning worktrees after cleanup")
logger.info(f"[WorktreeManager] Removed all {count} PR worktrees")
return count
@@ -140,9 +140,7 @@ async def spawn_security_review(
msg_type = type(msg).__name__
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
for block in msg.content:
# Must check block type - only TextBlock has .text attribute
block_type = type(block).__name__
if block_type == "TextBlock" and hasattr(block, "text"):
if hasattr(block, "text"):
result_text += block.text
# Parse findings
@@ -225,9 +223,7 @@ async def spawn_quality_review(
msg_type = type(msg).__name__
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
for block in msg.content:
# Must check block type - only TextBlock has .text attribute
block_type = type(block).__name__
if block_type == "TextBlock" and hasattr(block, "text"):
if hasattr(block, "text"):
result_text += block.text
findings = _parse_findings_from_response(result_text, source="quality_agent")
@@ -320,9 +316,7 @@ Output findings in JSON format:
msg_type = type(msg).__name__
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
for block in msg.content:
# Must check block type - only TextBlock has .text attribute
block_type = type(block).__name__
if block_type == "TextBlock" and hasattr(block, "text"):
if hasattr(block, "text"):
result_text += block.text
findings = _parse_findings_from_response(result_text, source="deep_analysis")
@@ -235,9 +235,8 @@ async def process_sdk_stream(
if on_tool_use:
on_tool_use(tool_name, tool_id, tool_input)
# Collect text - must check block type since only TextBlock has .text
block_type = type(block).__name__
if block_type == "TextBlock" and hasattr(block, "text"):
# Collect text
if hasattr(block, "text"):
result_text += block.text
# Always print text content preview (not just in DEBUG_MODE)
text_preview = block.text[:500].replace("\n", " ").strip()
@@ -87,9 +87,7 @@ class TriageEngine:
msg_type = type(msg).__name__
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
for block in msg.content:
# Must check block type - only TextBlock has .text attribute
block_type = type(block).__name__
if block_type == "TextBlock" and hasattr(block, "text"):
if hasattr(block, "text"):
response_text += block.text
return self.parser.parse_triage_result(
+2 -4
View File
@@ -26,10 +26,8 @@ from pathlib import Path
# Add backend to path
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
# Load .env file with centralized error handling
from cli.utils import import_dotenv
load_dotenv = import_dotenv()
# Load .env file
from dotenv import load_dotenv
env_file = Path(__file__).parent.parent.parent / ".env"
if env_file.exists():
@@ -234,9 +234,7 @@ Provide your review in the following JSON format:
msg_type = type(msg).__name__
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
for block in msg.content:
# Must check block type - only TextBlock has .text attribute
block_type = type(block).__name__
if block_type == "TextBlock" and hasattr(block, "text"):
if hasattr(block, "text"):
result_text += block.text
self._report_progress(
+2 -4
View File
@@ -26,10 +26,8 @@ from pathlib import Path
# Add auto-claude to path
sys.path.insert(0, str(Path(__file__).parent.parent))
# Load .env file with centralized error handling
from cli.utils import import_dotenv
load_dotenv = import_dotenv()
# Load .env file from auto-claude/ directory
from dotenv import load_dotenv
env_file = Path(__file__).parent.parent / ".env"
if env_file.exists():
+2 -4
View File
@@ -15,10 +15,8 @@ from pathlib import Path
# Add auto-claude to path
sys.path.insert(0, str(Path(__file__).parent.parent))
# Load .env file with centralized error handling
from cli.utils import import_dotenv
load_dotenv = import_dotenv()
# Load .env file from auto-claude/ directory
from dotenv import load_dotenv
env_file = Path(__file__).parent.parent / ".env"
if env_file.exists():
+2 -4
View File
@@ -20,10 +20,8 @@ from pathlib import Path
# Add auto-claude to path
sys.path.insert(0, str(Path(__file__).parent.parent))
# Load .env file with centralized error handling
from cli.utils import import_dotenv
load_dotenv = import_dotenv()
# Load .env file from auto-claude/ directory
from dotenv import load_dotenv
env_file = Path(__file__).parent.parent / ".env"
if env_file.exists():
+7 -25
View File
@@ -26,11 +26,11 @@ The AI considers:
- Risk factors and edge cases
Usage:
python runners/spec_runner.py --task "Add user authentication"
python runners/spec_runner.py --interactive
python runners/spec_runner.py --continue 001-feature
python runners/spec_runner.py --task "Fix button color" --complexity simple
python runners/spec_runner.py --task "Simple fix" --no-ai-assessment
python auto-claude/spec_runner.py --task "Add user authentication"
python auto-claude/spec_runner.py --interactive
python auto-claude/spec_runner.py --continue 001-feature
python auto-claude/spec_runner.py --task "Fix button color" --complexity simple
python auto-claude/spec_runner.py --task "Simple fix" --no-ai-assessment
"""
import sys
@@ -81,10 +81,8 @@ if sys.platform == "win32":
# Add auto-claude to path (parent of runners/)
sys.path.insert(0, str(Path(__file__).parent.parent))
# Load .env file with centralized error handling
from cli.utils import import_dotenv
load_dotenv = import_dotenv()
# Load .env file
from dotenv import load_dotenv
env_file = Path(__file__).parent.parent / ".env"
dev_env_file = Path(__file__).parent.parent.parent / "dev" / "auto-claude" / ".env"
@@ -200,21 +198,9 @@ Examples:
default=None,
help="Base branch for creating worktrees (default: auto-detect or current branch)",
)
parser.add_argument(
"--direct",
action="store_true",
help="Build directly in project without worktree isolation (default: use isolated worktree)",
)
args = parser.parse_args()
# Warn user about direct mode risks
if args.direct:
print_status(
"Direct mode: Building in project directory without worktree isolation",
"warning",
)
# Handle task from file if provided
task_description = args.task
if args.task_file:
@@ -342,10 +328,6 @@ Examples:
if args.base_branch:
run_cmd.extend(["--base-branch", args.base_branch])
# Pass --direct flag if specified (skip worktree isolation)
if args.direct:
run_cmd.append("--direct")
# Note: Model configuration for subsequent phases (planning, coding, qa)
# is read from task_metadata.json by run.py, so we don't pass it here.
# This allows per-phase configuration when using Auto profile.
-16
View File
@@ -1,16 +0,0 @@
"""
Security Constants
==================
Shared constants for the security module.
"""
# Environment variable name for the project directory
# Set by agents (coder.py, loop.py) at startup to ensure security hooks
# can find the correct project directory even in worktree mode.
PROJECT_DIR_ENV_VAR = "AUTO_CLAUDE_PROJECT_DIR"
# Security configuration filenames
# These are the files that control which commands are allowed to run.
ALLOWLIST_FILENAME = ".auto-claude-allowlist"
PROFILE_FILENAME = ".auto-claude-security.json"
+2 -15
View File
@@ -65,21 +65,8 @@ async def bash_security_hook(
if not command:
return {}
# Get the working directory from context or use current directory
# Priority:
# 1. Environment variable PROJECT_DIR_ENV_VAR (set by agent on startup)
# 2. input_data cwd (passed by SDK in the tool call)
# 3. Context cwd (should be set by ClaudeSDKClient but sometimes isn't)
# 4. Current working directory (fallback, may be incorrect in worktree mode)
from .constants import PROJECT_DIR_ENV_VAR
cwd = os.environ.get(PROJECT_DIR_ENV_VAR)
if not cwd:
cwd = input_data.get("cwd")
if not cwd and context and hasattr(context, "cwd"):
cwd = context.cwd
if not cwd:
cwd = os.getcwd()
# Get the working directory from input_data (SDK passes it there, not in context)
cwd = input_data.get("cwd") or os.getcwd()
# Get or create security profile
# Note: In actual use, spec_dir would be passed through context
+3 -168
View File
@@ -4,137 +4,11 @@ Command Parsing Utilities
Functions for parsing and extracting commands from shell command strings.
Handles compound commands, pipes, subshells, and various shell constructs.
Windows Compatibility Note:
--------------------------
On Windows, commands containing paths with backslashes can cause shlex.split()
to fail (e.g., incomplete commands with unclosed quotes). This module includes
a fallback parser that extracts command names even from malformed commands,
ensuring security validation can still proceed.
"""
import os
import re
import shlex
from pathlib import PurePosixPath, PureWindowsPath
def _cross_platform_basename(path: str) -> str:
"""
Extract the basename from a path in a cross-platform way.
Handles both Windows paths (C:\\dir\\cmd.exe) and POSIX paths (/dir/cmd)
regardless of the current platform. This is critical for running tests
on Linux CI while handling Windows-style paths.
Args:
path: A file path string (Windows or POSIX format)
Returns:
The basename of the path (e.g., "python.exe" from "C:\\Python312\\python.exe")
"""
# Strip surrounding quotes if present
path = path.strip("'\"")
# Check if this looks like a Windows path (contains backslash or drive letter)
if "\\" in path or (len(path) >= 2 and path[1] == ":"):
# Use PureWindowsPath to handle Windows paths on any platform
return PureWindowsPath(path).name
# For POSIX paths or simple command names, use PurePosixPath
# (os.path.basename works but PurePosixPath is more explicit)
return PurePosixPath(path).name
def _fallback_extract_commands(command_string: str) -> list[str]:
"""
Fallback command extraction when shlex.split() fails.
Uses regex to extract command names from potentially malformed commands.
This is more permissive than shlex but ensures we can at least identify
the commands being executed for security validation.
Args:
command_string: The command string to parse
Returns:
List of command names extracted from the string
"""
commands = []
# Shell keywords to skip
shell_keywords = {
"if",
"then",
"else",
"elif",
"fi",
"for",
"while",
"until",
"do",
"done",
"case",
"esac",
"in",
"function",
}
# First, split by common shell operators
# This regex splits on &&, ||, |, ; while being careful about quotes
# We're being permissive here since shlex already failed
parts = re.split(r"\s*(?:&&|\|\||\|)\s*|;\s*", command_string)
for part in parts:
part = part.strip()
if not part:
continue
# Skip variable assignments at the start (VAR=value cmd)
while re.match(r"^[A-Za-z_][A-Za-z0-9_]*=\S*\s+", part):
part = re.sub(r"^[A-Za-z_][A-Za-z0-9_]*=\S*\s+", "", part)
if not part:
continue
# Strategy: Extract command from the BEGINNING of the part
# Handle various formats:
# - Simple: python3, npm, git
# - Unix path: /usr/bin/python
# - Windows path: C:\Python312\python.exe
# - Quoted with spaces: "C:\Program Files\python.exe"
# Extract first token, handling quoted strings with spaces
first_token_match = re.match(r'^(?:"([^"]+)"|\'([^\']+)\'|([^\s]+))', part)
if not first_token_match:
continue
# Pick whichever capture group matched (double-quoted, single-quoted, or unquoted)
first_token = (
first_token_match.group(1)
or first_token_match.group(2)
or first_token_match.group(3)
)
# Now extract just the command name from this token
# Handle Windows paths (C:\dir\cmd.exe) and Unix paths (/dir/cmd)
# Use cross-platform basename for reliable path handling on any OS
cmd = _cross_platform_basename(first_token)
# Remove Windows extensions
cmd = re.sub(r"\.(exe|cmd|bat|ps1|sh)$", "", cmd, flags=re.IGNORECASE)
# Clean up any remaining quotes or special chars at the start
cmd = re.sub(r'^["\'\\/]+', "", cmd)
# Skip tokens that look like function calls or code fragments (not shell commands)
# These appear when splitting on semicolons inside malformed quoted strings
if "(" in cmd or ")" in cmd or "." in cmd:
continue
if cmd and cmd.lower() not in shell_keywords:
commands.append(cmd)
return commands
def split_command_segments(command_string: str) -> list[str]:
@@ -158,46 +32,13 @@ def split_command_segments(command_string: str) -> list[str]:
return result
def _contains_windows_path(command_string: str) -> bool:
"""
Check if a command string contains Windows-style paths.
Windows paths with backslashes cause issues with shlex.split() because
backslashes are interpreted as escape characters in POSIX mode.
Args:
command_string: The command string to check
Returns:
True if Windows paths are detected
"""
# Pattern matches:
# - Drive letter paths: C:\, D:\, etc.
# - Backslash followed by a path component (2+ chars to avoid escape sequences like \n, \t)
# The second char must be alphanumeric, underscore, or another path separator
# This avoids false positives on escape sequences which are single-char after backslash
return bool(re.search(r"[A-Za-z]:\\|\\[A-Za-z][A-Za-z0-9_\\/]", command_string))
def extract_commands(command_string: str) -> list[str]:
"""
Extract command names from a shell command string.
Handles pipes, command chaining (&&, ||, ;), and subshells.
Returns the base command names (without paths).
On Windows or when commands contain malformed quoting (common with
Windows paths in bash-style commands), falls back to regex-based
extraction to ensure security validation can proceed.
"""
# If command contains Windows paths, use fallback parser directly
# because shlex.split() interprets backslashes as escape characters
if _contains_windows_path(command_string):
fallback_commands = _fallback_extract_commands(command_string)
if fallback_commands:
return fallback_commands
# Continue with shlex if fallback found nothing
commands = []
# Split on semicolons that aren't inside quotes
@@ -212,12 +53,7 @@ def extract_commands(command_string: str) -> list[str]:
tokens = shlex.split(segment)
except ValueError:
# Malformed command (unclosed quotes, etc.)
# This is common on Windows with backslash paths in quoted strings
# Use fallback parser instead of blocking
fallback_commands = _fallback_extract_commands(command_string)
if fallback_commands:
return fallback_commands
# If fallback also found nothing, return empty to trigger block
# Return empty to trigger block (fail-safe)
return []
if not tokens:
@@ -270,8 +106,7 @@ def extract_commands(command_string: str) -> list[str]:
if expect_command:
# Extract the base command name (handle paths like /usr/bin/python)
# Use cross-platform basename for Windows paths on Linux CI
cmd = _cross_platform_basename(token)
cmd = os.path.basename(token)
commands.append(cmd)
expect_command = False
+13 -44
View File
@@ -9,12 +9,11 @@ Uses project_analyzer to create dynamic security profiles based on detected stac
from pathlib import Path
from project_analyzer import (
ProjectAnalyzer,
SecurityProfile,
get_or_create_profile,
)
from .constants import ALLOWLIST_FILENAME, PROFILE_FILENAME
# =============================================================================
# GLOBAL STATE
# =============================================================================
@@ -24,33 +23,18 @@ _cached_profile: SecurityProfile | None = None
_cached_project_dir: Path | None = None
_cached_spec_dir: Path | None = None # Track spec directory for cache key
_cached_profile_mtime: float | None = None # Track file modification time
_cached_allowlist_mtime: float | None = None # Track allowlist modification time
def _get_profile_path(project_dir: Path) -> Path:
"""Get the security profile file path for a project."""
return project_dir / PROFILE_FILENAME
def _get_allowlist_path(project_dir: Path) -> Path:
"""Get the allowlist file path for a project."""
return project_dir / ALLOWLIST_FILENAME
return project_dir / ProjectAnalyzer.PROFILE_FILENAME
def _get_profile_mtime(project_dir: Path) -> float | None:
"""Get the modification time of the security profile file, or None if not exists."""
profile_path = _get_profile_path(project_dir)
try:
return profile_path.stat().st_mtime
except OSError:
return None
def _get_allowlist_mtime(project_dir: Path) -> float | None:
"""Get the modification time of the allowlist file, or None if not exists."""
allowlist_path = _get_allowlist_path(project_dir)
try:
return allowlist_path.stat().st_mtime
return profile_path.stat().st_mtime if profile_path.exists() else None
except OSError:
return None
@@ -65,7 +49,6 @@ def get_security_profile(
- The project directory changes
- The security profile file is created (was None, now exists)
- The security profile file is modified (mtime changed)
- The allowlist file is created, modified, or deleted
Args:
project_dir: Project root directory
@@ -74,11 +57,7 @@ def get_security_profile(
Returns:
SecurityProfile for the project
"""
global _cached_profile
global _cached_project_dir
global _cached_spec_dir
global _cached_profile_mtime
global _cached_allowlist_mtime
global _cached_profile, _cached_project_dir, _cached_spec_dir, _cached_profile_mtime
project_dir = Path(project_dir).resolve()
resolved_spec_dir = Path(spec_dir).resolve() if spec_dir else None
@@ -89,40 +68,30 @@ def get_security_profile(
and _cached_project_dir == project_dir
and _cached_spec_dir == resolved_spec_dir
):
# Check if files have been created or modified since caching
current_profile_mtime = _get_profile_mtime(project_dir)
current_allowlist_mtime = _get_allowlist_mtime(project_dir)
# Cache is valid if both mtimes are unchanged
if (
current_profile_mtime == _cached_profile_mtime
and current_allowlist_mtime == _cached_allowlist_mtime
):
# Check if file has been created or modified since caching
current_mtime = _get_profile_mtime(project_dir)
# Cache is valid if:
# - Both are None (file never existed and still doesn't)
# - Both have same mtime (file unchanged)
if current_mtime == _cached_profile_mtime:
return _cached_profile
# File was created, modified, or deleted - invalidate cache
# (This happens when analyzer creates the file after agent starts,
# or when user adds/updates the allowlist)
# File was created or modified - invalidate cache
# (This happens when analyzer creates the file after agent starts)
# Analyze and cache
_cached_profile = get_or_create_profile(project_dir, spec_dir)
_cached_project_dir = project_dir
_cached_spec_dir = resolved_spec_dir
_cached_profile_mtime = _get_profile_mtime(project_dir)
_cached_allowlist_mtime = _get_allowlist_mtime(project_dir)
return _cached_profile
def reset_profile_cache() -> None:
"""Reset the cached profile (useful for testing or re-analysis)."""
global _cached_profile
global _cached_project_dir
global _cached_spec_dir
global _cached_profile_mtime
global _cached_allowlist_mtime
global _cached_profile, _cached_project_dir, _cached_spec_dir, _cached_profile_mtime
_cached_profile = None
_cached_project_dir = None
_cached_spec_dir = None
_cached_profile_mtime = None
_cached_allowlist_mtime = None
+2 -5
View File
@@ -73,12 +73,9 @@ Be concise and use bullet points. Skip boilerplate and meta-commentary.
await client.query(prompt)
response_text = ""
async for msg in client.receive_response():
msg_type = type(msg).__name__
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
if hasattr(msg, "content"):
for block in msg.content:
# Must check block type - only TextBlock has .text attribute
block_type = type(block).__name__
if block_type == "TextBlock" and hasattr(block, "text"):
if hasattr(block, "text"):
response_text += block.text
return response_text.strip()
except Exception as e:
+4 -7
View File
@@ -88,20 +88,17 @@ class StreamingLogCapture:
inp = block.input
if isinstance(inp, dict):
# Extract meaningful input description
# Increased limits to avoid hiding critical information
if "pattern" in inp:
tool_input = f"pattern: {inp['pattern']}"
elif "file_path" in inp:
fp = inp["file_path"]
# Show last 200 chars for paths (enough for most file paths)
if len(fp) > 200:
fp = "..." + fp[-197:]
if len(fp) > 50:
fp = "..." + fp[-47:]
tool_input = fp
elif "command" in inp:
cmd = inp["command"]
# Show first 300 chars for commands (enough for most commands)
if len(cmd) > 300:
cmd = cmd[:297] + "..."
if len(cmd) > 50:
cmd = cmd[:47] + "..."
tool_input = cmd
elif "path" in inp:
tool_input = inp["path"]
+6 -6
View File
@@ -406,10 +406,10 @@ class TaskLogger:
"""
phase_key = (phase or self.current_phase or LogPhase.CODING).value
# Truncate long inputs for display (increased limit to avoid hiding critical info)
# Truncate long inputs for display
display_input = tool_input
if display_input and len(display_input) > 300:
display_input = display_input[:297] + "..."
if display_input and len(display_input) > 100:
display_input = display_input[:97] + "..."
entry = LogEntry(
timestamp=self._timestamp(),
@@ -462,10 +462,10 @@ class TaskLogger:
"""
phase_key = (phase or self.current_phase or LogPhase.CODING).value
# Truncate long results for display (increased limit to avoid hiding critical info)
# Truncate long results for display
display_result = result
if display_result and len(display_result) > 300:
display_result = display_result[:297] + "..."
if display_result and len(display_result) > 100:
display_result = display_result[:97] + "..."
status = "Done" if success else "Error"
content = f"[{tool_name}] {status}"
+4 -47
View File
@@ -95,54 +95,11 @@ def box(
for line in content:
# Strip ANSI for length calculation
visible_line = re.sub(r"\033\[[0-9;]*m", "", line)
visible_len = len(visible_line)
padding = inner_width - visible_len - 2 # -2 for padding spaces
padding = inner_width - len(visible_line) - 2 # -2 for padding spaces
if padding < 0:
# Line is too long - need to truncate intelligently
# Calculate how much to remove (visible characters only)
chars_to_remove = abs(padding) + 3 # +3 for "..."
target_len = visible_len - chars_to_remove
if target_len <= 0:
# Line is way too long, just show "..."
line = "..."
padding = inner_width - 5 # 3 for "..." + 2 for padding
else:
# Truncate the visible text, preserving ANSI codes for what remains
# Split line into segments (ANSI code vs text)
segments = re.split(r"(\033\[[0-9;]*m)", line)
visible_chars = 0
result_segments = []
for segment in segments:
if re.match(r"\033\[[0-9;]*m", segment):
# ANSI code - include it without counting
result_segments.append(segment)
else:
# Text segment - count visible characters
remaining_space = target_len - visible_chars
if remaining_space <= 0:
break
if len(segment) <= remaining_space:
result_segments.append(segment)
visible_chars += len(segment)
else:
# Truncate this segment at word boundary if possible
truncated = segment[:remaining_space]
# Try to truncate at last space to avoid mid-word cuts
last_space = truncated.rfind(" ")
if (
last_space > remaining_space * 0.7
): # Only if space is in last 30%
truncated = truncated[:last_space]
result_segments.append(truncated)
visible_chars += len(truncated)
break
line = "".join(result_segments) + "..."
padding = 0
# Truncate if too long
line = line[: inner_width - 5] + "..."
padding = 0
lines.append(v + " " + line + " " * (padding + 1) + v)
# Bottom border
+1 -57
View File
@@ -13,61 +13,6 @@ import os
import sys
def enable_windows_ansi_support() -> bool:
"""
Enable ANSI escape sequence support on Windows.
Windows 10 (build 10586+) supports ANSI escape sequences natively,
but they must be explicitly enabled via the Windows API.
Returns:
True if ANSI support was enabled, False otherwise
"""
if sys.platform != "win32":
return True # Non-Windows always has ANSI support
try:
import ctypes
from ctypes import wintypes
# Windows constants
STD_OUTPUT_HANDLE = -11
STD_ERROR_HANDLE = -12
ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
kernel32 = ctypes.windll.kernel32
# Get handles
for handle_id in (STD_OUTPUT_HANDLE, STD_ERROR_HANDLE):
handle = kernel32.GetStdHandle(handle_id)
if handle == -1:
continue
# Get current console mode
mode = wintypes.DWORD()
if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
continue
# Enable ANSI support if not already enabled
if not (mode.value & ENABLE_VIRTUAL_TERMINAL_PROCESSING):
kernel32.SetConsoleMode(
handle, mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING
)
return True
except (ImportError, AttributeError, OSError):
# Fall back to colorama if available
try:
import colorama
colorama.init()
return True
except ImportError:
pass
return False
def configure_safe_encoding() -> None:
"""
Configure stdout/stderr to handle Unicode safely on Windows.
@@ -109,9 +54,8 @@ def configure_safe_encoding() -> None:
pass
# Configure safe encoding and ANSI support on module import
# Configure safe encoding on module import
configure_safe_encoding()
WINDOWS_ANSI_ENABLED = enable_windows_ansi_support()
def _is_fancy_ui_enabled() -> bool:
+1 -2
View File
@@ -39,10 +39,9 @@ class Icons:
FILE = ("📄", "[F]")
GEAR = ("", "[*]")
SEARCH = ("🔍", "[?]")
BRANCH = ("🌿", "[BR]") # [BR] to avoid collision with BLOCKED [B]
BRANCH = ("", "[B]")
COMMIT = ("", "(@)")
LIGHTNING = ("", "!")
LINK = ("🔗", "[L]") # For PR URLs
# Progress
SUBTASK = ("", "#")
-341
View File
@@ -1,341 +0,0 @@
/**
* End-to-End tests for full task workflow
* Tests: create spec subtasks resume
*
* NOTE: These tests require the Electron app to be built first.
* Run `npm run build` before running E2E tests.
*
* To run: npx playwright test task-workflow --config=e2e/playwright.config.ts
*/
import { test, expect } from '@playwright/test';
import { mkdirSync, mkdtempSync, rmSync, existsSync, writeFileSync, readFileSync } from 'fs';
import { tmpdir } from 'os';
import path from 'path';
// Test data directory - created securely with mkdtempSync to prevent TOCTOU attacks
let TEST_DATA_DIR: string;
let TEST_PROJECT_DIR: string;
let SPECS_DIR: string;
// Setup test environment with secure temp directory
function setupTestEnvironment(): void {
// Create secure temp directory with random suffix
TEST_DATA_DIR = mkdtempSync(path.join(tmpdir(), 'auto-claude-task-workflow-e2e-'));
TEST_PROJECT_DIR = path.join(TEST_DATA_DIR, 'test-project');
SPECS_DIR = path.join(TEST_PROJECT_DIR, '.auto-claude', 'specs');
mkdirSync(TEST_PROJECT_DIR, { recursive: true });
mkdirSync(SPECS_DIR, { recursive: true });
}
// Cleanup test environment
function cleanupTestEnvironment(): void {
if (existsSync(TEST_DATA_DIR)) {
rmSync(TEST_DATA_DIR, { recursive: true, force: true });
}
}
// Helper to create a task spec with subtasks
function createTaskWithSubtasks(
specId: string,
subtaskStatuses: Array<'pending' | 'in_progress' | 'completed'>
): void {
const specDir = path.join(SPECS_DIR, specId);
mkdirSync(specDir, { recursive: true });
// Create spec.md
writeFileSync(
path.join(specDir, 'spec.md'),
`# ${specId}\n\n## Overview\n\nTest task for workflow validation.\n\n## Acceptance Criteria\n\n- [ ] All subtasks completed\n- [ ] Tests pass\n`
);
// Create requirements.json
writeFileSync(
path.join(specDir, 'requirements.json'),
JSON.stringify(
{
task_description: `Test task ${specId}`,
user_requirements: ['Requirement 1', 'Requirement 2'],
acceptance_criteria: ['All subtasks completed', 'Tests pass'],
context: []
},
null,
2
)
);
// Create implementation_plan.json with subtasks
const subtasks = subtaskStatuses.map((status, index) => ({
id: `subtask-${index + 1}`,
phase: 'Implementation',
service: 'backend',
description: `Subtask ${index + 1}: Implement feature part ${index + 1}`,
files_to_modify: [`src/file${index + 1}.py`],
files_to_create: [],
pattern_files: [],
verification_command: 'pytest tests/',
status: status,
notes: status === 'completed' ? 'Completed successfully' : ''
}));
writeFileSync(
path.join(specDir, 'implementation_plan.json'),
JSON.stringify(
{
feature: `Test Feature ${specId}`,
workflow_type: 'feature',
services_involved: ['backend'],
subtasks: subtasks,
final_acceptance: ['All subtasks completed', 'Tests pass'],
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
spec_file: 'spec.md'
},
null,
2
)
);
// Create build-progress.txt
writeFileSync(
path.join(specDir, 'build-progress.txt'),
`Task Progress: ${specId}\n\nSubtasks: ${subtasks.length}\nCompleted: ${subtasks.filter(s => s.status === 'completed').length}\n`
);
}
// Helper to simulate task resumption
function simulateTaskResume(specId: string): void {
const planPath = path.join(SPECS_DIR, specId, 'implementation_plan.json');
const plan = JSON.parse(readFileSync(planPath, 'utf-8'));
// Find first pending subtask and mark as in_progress
const pendingSubtask = plan.subtasks.find((st: { status: string }) => st.status === 'pending');
if (pendingSubtask) {
pendingSubtask.status = 'in_progress';
pendingSubtask.notes = 'Resumed from checkpoint';
}
plan.updated_at = new Date().toISOString();
writeFileSync(planPath, JSON.stringify(plan, null, 2));
}
test.describe('Task Workflow E2E Tests', () => {
test.beforeAll(() => {
setupTestEnvironment();
});
test.afterAll(() => {
cleanupTestEnvironment();
});
test('should create task directory structure', () => {
const specId = '001-test-task';
const specDir = path.join(SPECS_DIR, specId);
mkdirSync(specDir, { recursive: true });
// Verify directory created
expect(existsSync(specDir)).toBe(true);
});
test('should generate spec.md file', () => {
const specId = '002-task-with-spec';
const specDir = path.join(SPECS_DIR, specId);
mkdirSync(specDir, { recursive: true });
// Write spec
const specContent = '# Test Task\n\n## Overview\n\nThis is a test task.\n';
writeFileSync(path.join(specDir, 'spec.md'), specContent);
// Verify spec file
expect(existsSync(path.join(specDir, 'spec.md'))).toBe(true);
const content = readFileSync(path.join(specDir, 'spec.md'), 'utf-8');
expect(content).toContain('Test Task');
});
test('should create implementation plan with subtasks', () => {
const specId = '003-task-with-subtasks';
createTaskWithSubtasks(specId, ['pending', 'pending', 'pending']);
const planPath = path.join(SPECS_DIR, specId, 'implementation_plan.json');
expect(existsSync(planPath)).toBe(true);
const plan = JSON.parse(readFileSync(planPath, 'utf-8'));
expect(plan.subtasks).toBeDefined();
expect(plan.subtasks.length).toBe(3);
expect(plan.subtasks[0].status).toBe('pending');
});
test('should track subtask progress', () => {
const specId = '004-task-in-progress';
createTaskWithSubtasks(specId, ['completed', 'in_progress', 'pending']);
const planPath = path.join(SPECS_DIR, specId, 'implementation_plan.json');
const plan = JSON.parse(readFileSync(planPath, 'utf-8'));
expect(plan.subtasks[0].status).toBe('completed');
expect(plan.subtasks[1].status).toBe('in_progress');
expect(plan.subtasks[2].status).toBe('pending');
});
test('should resume task from checkpoint', () => {
const specId = '005-task-resume';
createTaskWithSubtasks(specId, ['completed', 'pending', 'pending']);
// Verify initial state
let plan = JSON.parse(readFileSync(path.join(SPECS_DIR, specId, 'implementation_plan.json'), 'utf-8'));
expect(plan.subtasks[1].status).toBe('pending');
// Simulate resume
simulateTaskResume(specId);
// Verify resumed state
plan = JSON.parse(readFileSync(path.join(SPECS_DIR, specId, 'implementation_plan.json'), 'utf-8'));
expect(plan.subtasks[1].status).toBe('in_progress');
expect(plan.subtasks[1].notes).toContain('Resumed from checkpoint');
});
test('should complete all subtasks in sequence', () => {
const specId = '006-task-completion';
createTaskWithSubtasks(specId, ['completed', 'completed', 'completed']);
const plan = JSON.parse(readFileSync(path.join(SPECS_DIR, specId, 'implementation_plan.json'), 'utf-8'));
const allCompleted = plan.subtasks.every((st: { status: string }) => st.status === 'completed');
expect(allCompleted).toBe(true);
});
test('should maintain build progress log', () => {
const specId = '007-task-with-progress';
createTaskWithSubtasks(specId, ['completed', 'in_progress', 'pending']);
const progressPath = path.join(SPECS_DIR, specId, 'build-progress.txt');
expect(existsSync(progressPath)).toBe(true);
const progressContent = readFileSync(progressPath, 'utf-8');
expect(progressContent).toContain('Task Progress');
expect(progressContent).toContain('Subtasks: 3');
});
});
test.describe('Full Task Workflow Integration', () => {
test.beforeAll(() => {
setupTestEnvironment();
});
test.afterAll(() => {
cleanupTestEnvironment();
});
test('should complete full workflow: create → spec → subtasks → resume → complete', () => {
const specId = '100-full-workflow';
// Step 1: Create task
const specDir = path.join(SPECS_DIR, specId);
mkdirSync(specDir, { recursive: true });
expect(existsSync(specDir)).toBe(true);
// Step 2: Generate spec
writeFileSync(
path.join(specDir, 'spec.md'),
'# Full Workflow Test\n\n## Overview\n\nComplete workflow test.\n'
);
expect(existsSync(path.join(specDir, 'spec.md'))).toBe(true);
// Step 3: Create subtasks
createTaskWithSubtasks(specId, ['pending', 'pending', 'pending']);
let plan = JSON.parse(readFileSync(path.join(specDir, 'implementation_plan.json'), 'utf-8'));
expect(plan.subtasks.length).toBe(3);
// Step 4: Start first subtask
plan.subtasks[0].status = 'in_progress';
writeFileSync(path.join(specDir, 'implementation_plan.json'), JSON.stringify(plan, null, 2));
plan = JSON.parse(readFileSync(path.join(specDir, 'implementation_plan.json'), 'utf-8'));
expect(plan.subtasks[0].status).toBe('in_progress');
// Step 5: Complete first subtask
plan.subtasks[0].status = 'completed';
plan.subtasks[0].notes = 'First subtask completed';
writeFileSync(path.join(specDir, 'implementation_plan.json'), JSON.stringify(plan, null, 2));
// Step 6: Resume with second subtask
simulateTaskResume(specId);
plan = JSON.parse(readFileSync(path.join(specDir, 'implementation_plan.json'), 'utf-8'));
expect(plan.subtasks[1].status).toBe('in_progress');
// Step 7: Complete remaining subtasks
plan.subtasks[1].status = 'completed';
plan.subtasks[2].status = 'completed';
writeFileSync(path.join(specDir, 'implementation_plan.json'), JSON.stringify(plan, null, 2));
// Step 8: Verify all completed
plan = JSON.parse(readFileSync(path.join(specDir, 'implementation_plan.json'), 'utf-8'));
const allCompleted = plan.subtasks.every((st: { status: string }) => st.status === 'completed');
expect(allCompleted).toBe(true);
// Step 9: Verify final state
expect(plan.subtasks[0].notes).toContain('First subtask completed');
expect(plan.subtasks[1].notes).toContain('Resumed from checkpoint');
});
test('should handle workflow interruption and recovery', () => {
const specId = '101-workflow-recovery';
// Create task with partial progress
createTaskWithSubtasks(specId, ['completed', 'in_progress', 'pending']);
// Simulate interruption (task status is saved)
const planPath = path.join(SPECS_DIR, specId, 'implementation_plan.json');
let plan = JSON.parse(readFileSync(planPath, 'utf-8'));
expect(plan.subtasks[1].status).toBe('in_progress');
// Simulate recovery: complete interrupted subtask
plan.subtasks[1].status = 'completed';
plan.subtasks[1].notes = 'Recovered and completed';
writeFileSync(planPath, JSON.stringify(plan, null, 2));
// Resume with next subtask
simulateTaskResume(specId);
plan = JSON.parse(readFileSync(planPath, 'utf-8'));
// Verify recovery successful
expect(plan.subtasks[1].status).toBe('completed');
expect(plan.subtasks[2].status).toBe('in_progress');
});
test('should validate workflow data integrity', () => {
const specId = '102-data-integrity';
createTaskWithSubtasks(specId, ['pending', 'pending', 'pending']);
const specDir = path.join(SPECS_DIR, specId);
// Verify all required files exist
expect(existsSync(path.join(specDir, 'spec.md'))).toBe(true);
expect(existsSync(path.join(specDir, 'requirements.json'))).toBe(true);
expect(existsSync(path.join(specDir, 'implementation_plan.json'))).toBe(true);
expect(existsSync(path.join(specDir, 'build-progress.txt'))).toBe(true);
// Verify data structure integrity
const requirements = JSON.parse(readFileSync(path.join(specDir, 'requirements.json'), 'utf-8'));
expect(requirements.task_description).toBeDefined();
expect(requirements.acceptance_criteria).toBeDefined();
const plan = JSON.parse(readFileSync(path.join(specDir, 'implementation_plan.json'), 'utf-8'));
expect(plan.feature).toBeDefined();
expect(plan.subtasks).toBeDefined();
expect(plan.created_at).toBeDefined();
expect(plan.updated_at).toBeDefined();
// Verify subtask structure
plan.subtasks.forEach((subtask: {
id: string;
description: string;
status: string;
verification_command: string;
}) => {
expect(subtask.id).toBeDefined();
expect(subtask.description).toBeDefined();
expect(subtask.status).toMatch(/^(pending|in_progress|completed)$/);
expect(subtask.verification_command).toBeDefined();
});
});
});
@@ -1,335 +0,0 @@
/**
* End-to-End tests for terminal copy/paste functionality
* Tests copy/paste keyboard shortcuts in the Electron app
*
* These tests require the Electron app to be built first.
* Run `npm run build` before running E2E tests.
*
* To run: npx playwright test terminal-copy-paste.e2e.ts --config=e2e/playwright.config.ts
*/
import { test, expect, _electron as electron, ElectronApplication, Page } from '@playwright/test';
import { mkdirSync, rmSync, existsSync } from 'fs';
import path from 'path';
import * as os from 'os';
// Global Navigator declaration for clipboard
declare global {
interface Navigator {
clipboard: {
readText(): Promise<string>;
writeText(text: string): Promise<void>;
};
}
}
// Test data directory
const TEST_DATA_DIR = path.join(os.tmpdir(), 'auto-claude-terminal-e2e');
// Determine platform for platform-specific tests
const platform = process.platform;
const isMac = platform === 'darwin';
const isWindows = platform === 'win32';
const isLinux = platform === 'linux';
// Setup test environment
function setupTestEnvironment(): void {
if (existsSync(TEST_DATA_DIR)) {
rmSync(TEST_DATA_DIR, { recursive: true, force: true });
}
mkdirSync(TEST_DATA_DIR, { recursive: true });
}
// Cleanup test environment
function cleanupTestEnvironment(): void {
if (existsSync(TEST_DATA_DIR)) {
rmSync(TEST_DATA_DIR, { recursive: true, force: true });
}
}
// Helper to get platform-specific copy shortcut
function getCopyShortcutKey(): string {
return isMac ? 'Meta' : 'Control';
}
// Helper to check if test should run on current platform
function shouldRunForPlatform(testPlatform: 'all' | 'windows' | 'linux' | 'mac'): boolean {
if (testPlatform === 'all') return true;
if (testPlatform === 'windows') return isWindows;
if (testPlatform === 'linux') return isLinux;
if (testPlatform === 'mac') return isMac;
return false;
}
test.describe('Terminal Copy/Paste Flows', () => {
let app: ElectronApplication;
let window: Page;
let isAppReady = false;
test.beforeAll(async () => {
setupTestEnvironment();
});
test.afterAll(async () => {
cleanupTestEnvironment();
});
test.beforeEach(async () => {
// Launch Electron app
const appPath = path.join(__dirname, '..');
app = await electron.launch({ args: [appPath] });
window = await app.firstWindow({
timeout: 15000
});
// Wait for app to be ready
try {
await window.waitForSelector('body', { timeout: 10000 });
isAppReady = true;
} catch (error) {
console.error('App failed to load:', error);
isAppReady = false;
}
});
test.afterEach(async () => {
if (app) {
await app.close();
}
});
test.describe.configure({ mode: 'serial' });
test('should copy selected text to clipboard', async () => {
test.skip(!isAppReady, 'App not ready');
test.skip(!shouldRunForPlatform('all'), 'Test not applicable to this platform');
// Look for terminal element - skip if not found
const terminalSelector = '.xterm';
const terminalExists = await window.locator(terminalSelector).count() > 0;
test.skip(!terminalExists, 'Terminal element not found');
// Run a command to produce output
const terminal = window.locator(terminalSelector).first();
await terminal.click();
// Type echo command and press enter
await window.keyboard.type('echo "test output for copy"');
await window.keyboard.press('Enter');
// Wait for output to appear in terminal
await expect(terminal).toContainText('test output for copy', { timeout: 5000 });
// Select text (triple click to select line)
await terminal.click({ clickCount: 3 });
// Wait for selection to be active
await window.waitForTimeout(100);
// Press copy shortcut (Cmd+C on Mac, Ctrl+C on Windows/Linux)
const copyKey = getCopyShortcutKey();
await window.keyboard.press(`${copyKey}+c`);
// Wait briefly for clipboard operation
await window.waitForTimeout(100);
// Verify clipboard contains selected text
const clipboardText = await window.evaluate(async () => {
return await navigator.clipboard.readText();
});
expect(clipboardText).toContain('test output for copy');
});
test('should send interrupt signal when no text selected', async () => {
test.skip(!isAppReady, 'App not ready');
test.skip(!shouldRunForPlatform('all'), 'Test not applicable to this platform');
const terminalSelector = '.xterm';
const terminalExists = await window.locator(terminalSelector).count() > 0;
test.skip(!terminalExists, 'Terminal element not found');
const terminal = window.locator(terminalSelector).first();
await terminal.click();
// Start a long-running process (sleep on Linux/Mac, timeout on Windows)
const sleepCommand = isWindows ? 'timeout 10' : 'sleep 10';
await window.keyboard.type(sleepCommand);
await window.keyboard.press('Enter');
// Wait for process to start
await window.waitForTimeout(500);
// Press Ctrl+C without selection (should send interrupt)
await window.keyboard.press('Control+c');
// Wait for interrupt to be processed - look for ^C or new prompt
await expect(terminal).toContainText(/\^C|[$#>]/, { timeout: 3000 });
});
test('should paste clipboard text into terminal', async () => {
test.skip(!isAppReady, 'App not ready');
test.skip(!shouldRunForPlatform('all'), 'Test not applicable to this platform');
const terminalSelector = '.xterm';
const terminalExists = await window.locator(terminalSelector).count() > 0;
test.skip(!terminalExists, 'Terminal element not found');
// Set clipboard content
const testText = 'hello world from clipboard';
await window.evaluate(async (text) => {
await navigator.clipboard.writeText(text);
}, testText);
const terminal = window.locator(terminalSelector).first();
await terminal.click();
// Press paste shortcut
const pasteKey = isMac ? 'Meta' : 'Control';
await window.keyboard.press(`${pasteKey}+v`);
// Wait briefly for paste to complete
await window.waitForTimeout(100);
// Press Enter to execute the pasted command
await window.keyboard.press('Enter');
// Verify text was pasted (terminal should show the pasted text or output)
await expect(terminal).toContainText(testText, { timeout: 5000 });
});
test('should handle Linux CTRL+SHIFT+C copy shortcut', async () => {
test.skip(!isAppReady, 'App not ready');
test.skip(!shouldRunForPlatform('linux'), 'Linux-specific test');
const terminalSelector = '.xterm';
const terminalExists = await window.locator(terminalSelector).count() > 0;
test.skip(!terminalExists, 'Terminal element not found');
const terminal = window.locator(terminalSelector).first();
await terminal.click();
// Type command to generate output
await window.keyboard.type('echo "linux copy test"');
await window.keyboard.press('Enter');
// Wait for output
await expect(terminal).toContainText('linux copy test', { timeout: 5000 });
// Select text
await terminal.click({ clickCount: 3 });
await window.waitForTimeout(100);
// Press CTRL+SHIFT+C (Linux copy shortcut)
await window.keyboard.down('Control');
await window.keyboard.down('Shift');
await window.keyboard.press('c');
await window.keyboard.up('Shift');
await window.keyboard.up('Control');
// Wait briefly for clipboard operation
await window.waitForTimeout(100);
// Verify clipboard contains selected text
const clipboardText = await window.evaluate(async () => {
return await navigator.clipboard.readText();
});
expect(clipboardText).toContain('linux copy test');
});
test('should handle Linux CTRL+SHIFT+V paste shortcut', async () => {
test.skip(!isAppReady, 'App not ready');
test.skip(!shouldRunForPlatform('linux'), 'Linux-specific test');
const terminalSelector = '.xterm';
const terminalExists = await window.locator(terminalSelector).count() > 0;
test.skip(!terminalExists, 'Terminal element not found');
// Set clipboard content
const testText = 'pasted via ctrl+shift+v';
await window.evaluate(async (text) => {
await navigator.clipboard.writeText(text);
}, testText);
const terminal = window.locator(terminalSelector).first();
await terminal.click();
// Press CTRL+SHIFT+V (Linux paste shortcut)
await window.keyboard.down('Control');
await window.keyboard.down('Shift');
await window.keyboard.press('v');
await window.keyboard.up('Shift');
await window.keyboard.up('Control');
// Wait briefly for paste to complete
await window.waitForTimeout(100);
// Press Enter to execute
await window.keyboard.press('Enter');
// Verify text was pasted
await expect(terminal).toContainText(testText, { timeout: 5000 });
});
test('should verify existing shortcuts still work', async () => {
test.skip(!isAppReady, 'App not ready');
test.skip(!shouldRunForPlatform('all'), 'Test not applicable to this platform');
const terminalSelector = '.xterm';
const terminalExists = await window.locator(terminalSelector).count() > 0;
test.skip(!terminalExists, 'Terminal element not found');
const terminal = window.locator(terminalSelector).first();
await terminal.click();
// Test SHIFT+Enter (multi-line input)
await window.keyboard.type('echo "line 1"');
await window.keyboard.down('Shift');
await window.keyboard.press('Enter');
await window.keyboard.up('Shift');
await window.keyboard.type('echo "line 2"');
await window.keyboard.press('Enter');
// Verify multi-line input worked (both commands should execute)
await expect(terminal).toContainText('line 1', { timeout: 5000 });
await expect(terminal).toContainText('line 2', { timeout: 5000 });
});
test('should handle clipboard errors gracefully', async () => {
test.skip(!isAppReady, 'App not ready');
test.skip(!shouldRunForPlatform('all'), 'Test not applicable to this platform');
const terminalSelector = '.xterm';
const terminalExists = await window.locator(terminalSelector).count() > 0;
test.skip(!terminalExists, 'Terminal element not found');
// Mock clipboard permission denial by clearing clipboard
await window.evaluate(async () => {
// Try to read clipboard (may fail if permission denied)
try {
await navigator.clipboard.readText();
} catch (_error) {
// Expected - clipboard may not be accessible in test environment
console.warn('Clipboard not accessible (expected in some environments)');
}
});
const terminal = window.locator(terminalSelector).first();
await terminal.click();
// Try to paste even if clipboard is not accessible
const pasteKey = isMac ? 'Meta' : 'Control';
await window.keyboard.press(`${pasteKey}+v`);
// Wait briefly to ensure terminal remains stable
await window.waitForTimeout(100);
// Try typing to verify terminal still works
await window.keyboard.type('echo "terminal still works"');
await window.keyboard.press('Enter');
// Verify terminal still functions after clipboard error
await expect(terminal).toContainText('terminal still works', { timeout: 5000 });
});
});
+3 -44
View File
@@ -610,13 +610,11 @@ function installPackages(pythonBin, requirementsPath, targetSitePackages) {
// Install packages directly to target directory
// --no-compile: Don't create .pyc files (saves space, Python will work without them)
// --target: Install to specific directory
// --only-binary: Force binary wheels for pydantic (prevents silent source build failures)
// Note: We intentionally DO use pip's cache to preserve built wheels for packages
// like real_ladybug that must be compiled from source on Intel Mac (no PyPI wheel)
const pipArgs = [
'-m', 'pip', 'install',
'--no-compile',
'--only-binary', 'pydantic,pydantic-core',
'--target', targetSitePackages,
'-r', requirementsPath,
];
@@ -704,32 +702,9 @@ async function downloadPython(targetPlatform, targetArch, options = {}) {
try {
const version = verifyPythonBinary(pythonBin);
console.log(`[download-python] Verified: ${version}`);
// Verify critical packages exist (fixes GitHub issue #416)
// Without this check, corrupted caches with missing packages would be accepted
// Note: Same list exists in python-env-manager.ts - keep them in sync
// This validation assumes traditional Python packages with __init__.py (not PEP 420 namespace packages)
const criticalPackages = ['claude_agent_sdk', 'dotenv', 'pydantic_core'];
const missingPackages = criticalPackages.filter(pkg => {
const pkgPath = path.join(sitePackagesDir, pkg);
// Check both directory and __init__.py for more robust validation
const initFile = path.join(pkgPath, '__init__.py');
return !fs.existsSync(pkgPath) || !fs.existsSync(initFile);
});
if (missingPackages.length > 0) {
console.log(`[download-python] Critical packages missing or incomplete: ${missingPackages.join(', ')}`);
console.log(`[download-python] Reinstalling packages...`);
// Remove site-packages to force reinstall, keep Python binary
// Flow continues below to re-install packages (skipPackages check at line 794)
fs.rmSync(sitePackagesDir, { recursive: true, force: true });
} else {
console.log(`[download-python] All critical packages verified`);
return { success: true, pythonPath: pythonBin, sitePackagesPath: sitePackagesDir };
}
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
console.log(`[download-python] Existing installation is broken: ${errorMsg}`);
return { success: true, pythonPath: pythonBin, sitePackagesPath: sitePackagesDir };
} catch {
console.log(`[download-python] Existing installation is broken, re-downloading...`);
fs.rmSync(platformDir, { recursive: true, force: true });
}
}
@@ -809,22 +784,6 @@ async function downloadPython(targetPlatform, targetArch, options = {}) {
// Install packages
installPackages(pythonBin, requirementsPath, sitePackagesDir);
// Verify critical packages were installed before creating marker (fixes #416)
// Note: Same list exists in python-env-manager.ts - keep them in sync
// This validation assumes traditional Python packages with __init__.py (not PEP 420 namespace packages)
const criticalPackages = ['claude_agent_sdk', 'dotenv', 'pydantic_core'];
const postInstallMissing = criticalPackages.filter(pkg => {
const pkgPath = path.join(sitePackagesDir, pkg);
const initFile = path.join(pkgPath, '__init__.py');
return !fs.existsSync(pkgPath) || !fs.existsSync(initFile);
});
if (postInstallMissing.length > 0) {
throw new Error(`Package installation failed - missing critical packages: ${postInstallMissing.join(', ')}`);
}
console.log(`[download-python] All critical packages verified after installation`);
// Create marker file to indicate successful bundling
fs.writeFileSync(packagesMarker, JSON.stringify({
bundledAt: new Date().toISOString(),
@@ -302,7 +302,7 @@ describe('Subprocess Spawn Integration', () => {
await manager.startTaskExecution('task-2', TEST_PROJECT_PATH, 'spec-001');
expect(manager.getRunningTasks()).toHaveLength(2);
}, 15000);
});
it('should use configured Python path', async () => {
const { spawn } = await import('child_process');
@@ -1,382 +0,0 @@
/**
* Integration tests for task lifecycle
* Tests spec completion to subtask loading workflow (IPC communication)
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mkdirSync, mkdtempSync, writeFileSync, rmSync, existsSync } from 'fs';
import { tmpdir } from 'os';
import path from 'path';
// Test directories - created securely with mkdtempSync to prevent TOCTOU attacks
let TEST_DIR: string;
let TEST_PROJECT_PATH: string;
let TEST_SPEC_DIR: string;
// Mock ipcRenderer for renderer-side tests
const mockIpcRenderer = {
invoke: vi.fn(),
send: vi.fn(),
on: vi.fn(),
once: vi.fn(),
removeListener: vi.fn(),
removeAllListeners: vi.fn(),
setMaxListeners: vi.fn()
};
// Mock contextBridge
const exposedApis: Record<string, unknown> = {};
const mockContextBridge = {
exposeInMainWorld: vi.fn((name: string, api: unknown) => {
exposedApis[name] = api;
})
};
vi.mock('electron', () => ({
ipcRenderer: mockIpcRenderer,
contextBridge: mockContextBridge
}));
// Sample implementation plan with subtasks
function createTestPlan(overrides: Record<string, unknown> = {}): object {
return {
feature: 'Test Feature',
workflow_type: 'feature',
services_involved: ['frontend'],
phases: [
{
id: 'phase-1',
name: 'Implementation Phase',
type: 'implementation',
subtasks: [
{
id: 'subtask-1-1',
description: 'Implement feature A',
status: 'pending',
files_to_modify: ['file1.ts'],
files_to_create: [],
service: 'frontend'
},
{
id: 'subtask-1-2',
description: 'Add unit tests for feature A',
status: 'pending',
files_to_modify: [],
files_to_create: ['file1.test.ts'],
service: 'frontend'
}
]
}
],
status: 'in_progress',
planStatus: 'in_progress',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
...overrides
};
}
// Sample implementation plan with empty phases (incomplete state)
function createIncompletePlan(): object {
return {
feature: 'Test Feature',
workflow_type: 'feature',
services_involved: ['frontend'],
phases: [],
status: 'planning',
planStatus: 'planning',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
};
}
// Setup test directories with secure temp directory
function setupTestDirs(): void {
// Create secure temp directory with random suffix
TEST_DIR = mkdtempSync(path.join(tmpdir(), 'task-lifecycle-test-'));
TEST_PROJECT_PATH = path.join(TEST_DIR, 'test-project');
TEST_SPEC_DIR = path.join(TEST_PROJECT_PATH, '.auto-claude/specs/001-test-feature');
mkdirSync(TEST_SPEC_DIR, { recursive: true });
}
// Cleanup test directories
function cleanupTestDirs(): void {
if (TEST_DIR && existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true });
}
}
describe('Task Lifecycle Integration', () => {
beforeEach(async () => {
cleanupTestDirs();
setupTestDirs();
vi.clearAllMocks();
vi.resetModules();
Object.keys(exposedApis).forEach((key) => delete exposedApis[key]);
});
afterEach(() => {
cleanupTestDirs();
vi.clearAllMocks();
});
describe('Spec completion to subtask loading', () => {
it('should load subtasks from implementation_plan.json after spec completion', async () => {
// Create implementation_plan.json with full subtask data
const planPath = path.join(TEST_SPEC_DIR, 'implementation_plan.json');
const plan = createTestPlan();
writeFileSync(planPath, JSON.stringify(plan, null, 2));
// Import preload script to get electronAPI
await import('../../preload/index');
const electronAPI = exposedApis['electronAPI'] as Record<string, unknown>;
// Mock IPC response for getTasks (loads implementation_plan.json)
mockIpcRenderer.invoke.mockResolvedValueOnce({
success: true,
data: [
{
id: 'task-001',
name: 'Test Feature',
status: 'spec_complete',
specDir: TEST_SPEC_DIR,
plan: plan
}
]
});
// Call getTasks to load plan data
const getTasks = electronAPI['getTasks'] as (projectId: string) => Promise<unknown>;
const result = await getTasks('project-id');
// Verify IPC invocation
expect(mockIpcRenderer.invoke).toHaveBeenCalledWith('task:list', 'project-id');
// Verify task data includes plan with subtasks
expect(result).toMatchObject({
success: true,
data: expect.arrayContaining([
expect.objectContaining({
plan: expect.objectContaining({
phases: expect.arrayContaining([
expect.objectContaining({
subtasks: expect.arrayContaining([
expect.objectContaining({
id: 'subtask-1-1',
description: 'Implement feature A',
status: 'pending'
}),
expect.objectContaining({
id: 'subtask-1-2',
description: 'Add unit tests for feature A',
status: 'pending'
})
])
})
])
})
})
])
});
});
it('should handle incomplete plan data with empty phases array', async () => {
// Create implementation_plan.json with incomplete data (empty phases)
const planPath = path.join(TEST_SPEC_DIR, 'implementation_plan.json');
const incompletePlan = createIncompletePlan();
writeFileSync(planPath, JSON.stringify(incompletePlan, null, 2));
await import('../../preload/index');
const electronAPI = exposedApis['electronAPI'] as Record<string, unknown>;
// Mock IPC response for getTasks
mockIpcRenderer.invoke.mockResolvedValueOnce({
success: true,
data: [
{
id: 'task-001',
name: 'Test Feature',
status: 'planning',
specDir: TEST_SPEC_DIR,
plan: incompletePlan
}
]
});
const getTasks = electronAPI['getTasks'] as (projectId: string) => Promise<unknown>;
const result = await getTasks('project-id');
// Verify task data reflects incomplete state
expect(result).toMatchObject({
success: true,
data: expect.arrayContaining([
expect.objectContaining({
plan: expect.objectContaining({
phases: [],
status: 'planning'
})
})
])
});
});
it('should emit task:statusChange event when task transitions from planning to spec_complete', async () => {
await import('../../preload/index');
const electronAPI = exposedApis['electronAPI'] as Record<string, unknown>;
// Setup event listener
const callback = vi.fn();
const onTaskStatusChange = electronAPI['onTaskStatusChange'] as (cb: Function) => Function;
onTaskStatusChange(callback);
// Verify listener was registered
expect(mockIpcRenderer.on).toHaveBeenCalledWith(
'task:statusChange',
expect.any(Function)
);
// Simulate status change event from main process
// The event handler signature is: (_event, taskId, status)
const eventHandler = mockIpcRenderer.on.mock.calls.find(
(call) => call[0] === 'task:statusChange'
)?.[1];
if (eventHandler) {
eventHandler({}, 'task-001', 'spec_complete');
}
// Verify callback was invoked with correct parameters (taskId, status, projectId)
// Note: projectId is optional and undefined when not provided
expect(callback).toHaveBeenCalledWith('task-001', 'spec_complete', undefined);
});
it('should emit task:progress event with updated plan during spec creation', async () => {
await import('../../preload/index');
const electronAPI = exposedApis['electronAPI'] as Record<string, unknown>;
// Setup event listener
const callback = vi.fn();
const onTaskProgress = electronAPI['onTaskProgress'] as (cb: Function) => Function;
onTaskProgress(callback);
// Verify listener was registered
expect(mockIpcRenderer.on).toHaveBeenCalledWith(
'task:progress',
expect.any(Function)
);
// Simulate progress event with plan update
// The event handler signature is: (_event, taskId, plan)
const eventHandler = mockIpcRenderer.on.mock.calls.find(
(call) => call[0] === 'task:progress'
)?.[1];
const plan = createTestPlan();
if (eventHandler) {
eventHandler({}, 'task-001', plan);
}
// Verify callback was invoked with correct parameters (taskId, plan, projectId)
// Note: projectId is optional and undefined when not provided
expect(callback).toHaveBeenCalledWith(
'task-001',
expect.objectContaining({
phases: expect.arrayContaining([
expect.objectContaining({
subtasks: expect.any(Array)
})
])
}),
undefined
);
});
it('should handle task resume by reloading implementation plan', async () => {
// Create implementation_plan.json
const planPath = path.join(TEST_SPEC_DIR, 'implementation_plan.json');
const plan = createTestPlan();
writeFileSync(planPath, JSON.stringify(plan, null, 2));
await import('../../preload/index');
const electronAPI = exposedApis['electronAPI'] as Record<string, unknown>;
// Mock IPC response for task start (resume)
mockIpcRenderer.invoke.mockResolvedValueOnce({
success: true,
message: 'Task resumed'
});
// Call startTask (resume)
const startTask = electronAPI['startTask'] as (id: string, options?: object) => void;
startTask('task-001', { resume: true });
// Verify IPC send was called
expect(mockIpcRenderer.send).toHaveBeenCalledWith(
'task:start',
'task-001',
{ resume: true }
);
});
it('should handle task update status IPC call', async () => {
await import('../../preload/index');
// Note: electronAPI is exposed but we test the IPC channel directly below
// Check if updateTaskStatus method exists (might be part of updateTask)
// Based on IPC_CHANNELS, we have TASK_UPDATE_STATUS
mockIpcRenderer.invoke.mockResolvedValueOnce({
success: true
});
// Since updateTaskStatus might not be directly exposed, we test the IPC channel directly
const result = await mockIpcRenderer.invoke('task:updateStatus', 'task-001', 'in_progress');
expect(mockIpcRenderer.invoke).toHaveBeenCalledWith(
'task:updateStatus',
'task-001',
'in_progress'
);
expect(result).toMatchObject({ success: true });
});
});
describe('Event listener cleanup', () => {
it('should cleanup task:progress listener when cleanup function is called', async () => {
await import('../../preload/index');
const electronAPI = exposedApis['electronAPI'] as Record<string, unknown>;
const callback = vi.fn();
const onTaskProgress = electronAPI['onTaskProgress'] as (cb: Function) => Function;
const cleanup = onTaskProgress(callback);
expect(typeof cleanup).toBe('function');
// Call cleanup
cleanup();
expect(mockIpcRenderer.removeListener).toHaveBeenCalledWith(
'task:progress',
expect.any(Function)
);
});
it('should cleanup task:statusChange listener when cleanup function is called', async () => {
await import('../../preload/index');
const electronAPI = exposedApis['electronAPI'] as Record<string, unknown>;
const callback = vi.fn();
const onTaskStatusChange = electronAPI['onTaskStatusChange'] as (cb: Function) => Function;
const cleanup = onTaskStatusChange(callback);
expect(typeof cleanup).toBe('function');
// Call cleanup
cleanup();
expect(mockIpcRenderer.removeListener).toHaveBeenCalledWith(
'task:statusChange',
expect.any(Function)
);
});
});
});
@@ -1,728 +0,0 @@
/**
* @vitest-environment jsdom
*/
/**
* Integration tests for terminal copy/paste functionality
* Tests xterm.js selection API integration with clipboard operations
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, act } from '@testing-library/react';
import React from 'react';
import type { Mock } from 'vitest';
import { Terminal as XTerm } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { SerializeAddon } from '@xterm/addon-serialize';
// Mock xterm.js and its addons
vi.mock('@xterm/xterm', () => ({
Terminal: vi.fn().mockImplementation(function() {
return {
open: vi.fn(),
loadAddon: vi.fn(),
attachCustomKeyEventHandler: vi.fn(),
hasSelection: vi.fn(function() { return false; }),
getSelection: vi.fn(function() { return ''; }),
paste: vi.fn(),
input: vi.fn(),
onData: vi.fn(),
onResize: vi.fn(),
dispose: vi.fn(),
write: vi.fn(),
cols: 80,
rows: 24
};
})
}));
vi.mock('@xterm/addon-fit', () => ({
FitAddon: vi.fn().mockImplementation(function() {
return {
fit: vi.fn()
};
})
}));
vi.mock('@xterm/addon-web-links', () => ({
WebLinksAddon: vi.fn().mockImplementation(function() {
return {};
})
}));
vi.mock('@xterm/addon-serialize', () => ({
SerializeAddon: vi.fn().mockImplementation(function() {
return {
serialize: vi.fn(function() { return ''; }),
dispose: vi.fn()
};
})
}));
describe('Terminal copy/paste integration', () => {
let mockClipboard: {
writeText: Mock;
readText: Mock;
};
beforeEach(() => {
vi.clearAllMocks();
// Mock ResizeObserver
global.ResizeObserver = vi.fn().mockImplementation(function() {
return {
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn()
};
});
// Mock navigator.clipboard
mockClipboard = {
writeText: vi.fn().mockResolvedValue(undefined),
readText: vi.fn().mockResolvedValue('clipboard content')
};
Object.defineProperty(global.navigator, 'clipboard', {
value: mockClipboard,
writable: true
});
// Mock window.electronAPI
(window as unknown as { electronAPI: unknown }).electronAPI = {
sendTerminalInput: vi.fn()
};
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('xterm.js selection API integration with clipboard write', () => {
it('should integrate xterm.hasSelection() with clipboard write', async () => {
const { useXterm } = await import('../../renderer/components/terminal/useXterm');
let keyEventHandler: ((event: KeyboardEvent) => boolean) | null = null;
const mockHasSelection = vi.fn(function() { return true; });
const mockGetSelection = vi.fn(function() { return 'selected terminal text'; });
// Override XTerm mock to be constructable
(XTerm as unknown as Mock).mockImplementation(function() {
return {
open: vi.fn(),
loadAddon: vi.fn(),
attachCustomKeyEventHandler: vi.fn(function(handler: (event: KeyboardEvent) => boolean) {
keyEventHandler = handler;
}),
hasSelection: mockHasSelection,
getSelection: mockGetSelection,
paste: vi.fn(),
input: vi.fn(),
onData: vi.fn(),
onResize: vi.fn(),
dispose: vi.fn(),
write: vi.fn(),
cols: 80,
rows: 24
};
});
// Need to also override the addon mocks to be constructable
(FitAddon as unknown as Mock).mockImplementation(function() {
return { fit: vi.fn() };
});
(WebLinksAddon as unknown as Mock).mockImplementation(function() {
return {};
});
(SerializeAddon as unknown as Mock).mockImplementation(function() {
return {
serialize: vi.fn(function() { return ''; }),
dispose: vi.fn()
};
});
// Create a test wrapper component that provides the DOM element
const TestWrapper = () => {
const { terminalRef } = useXterm({ terminalId: 'test-terminal' });
return React.createElement('div', { ref: terminalRef });
};
render(React.createElement(TestWrapper));
await act(async () => {
// Simulate copy operation
const event = new KeyboardEvent('keydown', {
key: 'c',
ctrlKey: true
});
if (keyEventHandler) {
keyEventHandler(event);
// Wait for clipboard write
await new Promise(resolve => setTimeout(resolve, 0));
}
});
// Verify integration: hasSelection() called
expect(mockHasSelection).toHaveBeenCalled();
// Verify integration: getSelection() called when hasSelection returns true
expect(mockGetSelection).toHaveBeenCalled();
// Verify integration: clipboard.writeText() called with selection
expect(mockClipboard.writeText).toHaveBeenCalledWith('selected terminal text');
});
it('should not call getSelection when hasSelection returns false', async () => {
const { useXterm } = await import('../../renderer/components/terminal/useXterm');
let keyEventHandler: ((event: KeyboardEvent) => boolean) | null = null;
const mockHasSelection = vi.fn(function() { return false; });
const mockGetSelection = vi.fn(function() { return ''; });
// Override XTerm mock to be constructable
(XTerm as unknown as Mock).mockImplementation(function() {
return {
open: vi.fn(),
loadAddon: vi.fn(),
attachCustomKeyEventHandler: vi.fn(function(handler: (event: KeyboardEvent) => boolean) {
keyEventHandler = handler;
}),
hasSelection: mockHasSelection,
getSelection: mockGetSelection,
paste: vi.fn(),
input: vi.fn(),
onData: vi.fn(),
onResize: vi.fn(),
dispose: vi.fn(),
write: vi.fn(),
cols: 80,
rows: 24
};
});
// Need to also override the addon mocks to be constructable
(FitAddon as unknown as Mock).mockImplementation(function() {
return { fit: vi.fn() };
});
(WebLinksAddon as unknown as Mock).mockImplementation(function() {
return {};
});
(SerializeAddon as unknown as Mock).mockImplementation(function() {
return {
serialize: vi.fn(function() { return ''; }),
dispose: vi.fn()
};
});
// Create a test wrapper component that provides the DOM element
const TestWrapper = () => {
const { terminalRef } = useXterm({ terminalId: 'test-terminal' });
return React.createElement('div', { ref: terminalRef });
};
render(React.createElement(TestWrapper));
await act(async () => {
const event = new KeyboardEvent('keydown', {
key: 'c',
ctrlKey: true
});
if (keyEventHandler) {
keyEventHandler(event);
}
});
// Verify hasSelection was called
expect(mockHasSelection).toHaveBeenCalled();
// Verify getSelection was NOT called (no selection)
expect(mockGetSelection).not.toHaveBeenCalled();
// Verify clipboard was NOT written to
expect(mockClipboard.writeText).not.toHaveBeenCalled();
});
});
describe('clipboard read with xterm paste integration', () => {
let originalNavigatorPlatform: string;
beforeEach(() => {
// Capture original navigator.platform
originalNavigatorPlatform = navigator.platform;
});
afterEach(() => {
// Restore navigator.platform
Object.defineProperty(navigator, 'platform', {
value: originalNavigatorPlatform,
writable: true
});
});
it('should integrate clipboard.readText() with xterm.paste()', async () => {
const { useXterm } = await import('../../renderer/components/terminal/useXterm');
// Mock Windows platform
Object.defineProperty(navigator, 'platform', {
value: 'Win32',
writable: true
});
let keyEventHandler: ((event: KeyboardEvent) => boolean) | null = null;
const mockPaste = vi.fn();
// Override XTerm mock to be constructable
(XTerm as unknown as Mock).mockImplementation(function() {
return {
open: vi.fn(),
loadAddon: vi.fn(),
attachCustomKeyEventHandler: vi.fn(function(handler: (event: KeyboardEvent) => boolean) {
keyEventHandler = handler;
}),
hasSelection: vi.fn(),
getSelection: vi.fn(),
paste: mockPaste,
input: vi.fn(),
onData: vi.fn(),
onResize: vi.fn(),
dispose: vi.fn(),
write: vi.fn(),
cols: 80,
rows: 24
};
});
// Need to also override the addon mocks to be constructable
(FitAddon as unknown as Mock).mockImplementation(function() {
return { fit: vi.fn() };
});
(WebLinksAddon as unknown as Mock).mockImplementation(function() {
return {};
});
(SerializeAddon as unknown as Mock).mockImplementation(function() {
return {
serialize: vi.fn(function() { return ''; }),
dispose: vi.fn()
};
});
mockClipboard.readText.mockResolvedValue('pasted text');
// Create a test wrapper component that provides the DOM element
const TestWrapper = () => {
const { terminalRef } = useXterm({ terminalId: 'test-terminal' });
return React.createElement('div', { ref: terminalRef });
};
render(React.createElement(TestWrapper));
await act(async () => {
const event = new KeyboardEvent('keydown', {
key: 'v',
ctrlKey: true
});
if (keyEventHandler) {
keyEventHandler(event);
// Wait for clipboard read and paste
await new Promise(resolve => setTimeout(resolve, 0));
}
});
// Verify integration: clipboard.readText() called
expect(mockClipboard.readText).toHaveBeenCalled();
// Verify integration: xterm.paste() called with clipboard content
expect(mockPaste).toHaveBeenCalledWith('pasted text');
});
it('should not paste when clipboard is empty', async () => {
const { useXterm } = await import('../../renderer/components/terminal/useXterm');
// Mock Linux platform
Object.defineProperty(navigator, 'platform', {
value: 'Linux',
writable: true
});
let keyEventHandler: ((event: KeyboardEvent) => boolean) | null = null;
const mockPaste = vi.fn();
// Override XTerm mock to be constructable
(XTerm as unknown as Mock).mockImplementation(function() {
return {
open: vi.fn(),
loadAddon: vi.fn(),
attachCustomKeyEventHandler: vi.fn(function(handler: (event: KeyboardEvent) => boolean) {
keyEventHandler = handler;
}),
hasSelection: vi.fn(),
getSelection: vi.fn(),
paste: mockPaste,
input: vi.fn(),
onData: vi.fn(),
onResize: vi.fn(),
dispose: vi.fn(),
write: vi.fn(),
cols: 80,
rows: 24
};
});
// Need to also override the addon mocks to be constructable
(FitAddon as unknown as Mock).mockImplementation(function() {
return { fit: vi.fn() };
});
(WebLinksAddon as unknown as Mock).mockImplementation(function() {
return {};
});
(SerializeAddon as unknown as Mock).mockImplementation(function() {
return {
serialize: vi.fn(function() { return ''; }),
dispose: vi.fn()
};
});
// Mock empty clipboard
mockClipboard.readText.mockResolvedValue('');
// Create a test wrapper component that provides the DOM element
const TestWrapper = () => {
const { terminalRef } = useXterm({ terminalId: 'test-terminal' });
return React.createElement('div', { ref: terminalRef });
};
render(React.createElement(TestWrapper));
await act(async () => {
const event = new KeyboardEvent('keydown', {
key: 'v',
ctrlKey: true
});
if (keyEventHandler) {
keyEventHandler(event);
// Wait for clipboard read
await new Promise(resolve => setTimeout(resolve, 0));
}
});
// Verify clipboard was read
expect(mockClipboard.readText).toHaveBeenCalled();
// Verify paste was NOT called for empty clipboard
expect(mockPaste).not.toHaveBeenCalled();
});
});
describe('keyboard event propagation', () => {
it('should prevent copy/paste events from interfering with other shortcuts', async () => {
const { useXterm } = await import('../../renderer/components/terminal/useXterm');
let keyEventHandler: ((event: KeyboardEvent) => boolean) | null = null;
let eventCallOrder: string[] = [];
// Override XTerm mock to be constructable
(XTerm as unknown as Mock).mockImplementation(function() {
return {
open: vi.fn(),
loadAddon: vi.fn(),
attachCustomKeyEventHandler: vi.fn(function(handler: (event: KeyboardEvent) => boolean) {
keyEventHandler = handler;
}),
hasSelection: vi.fn(function() { return true; }),
getSelection: vi.fn(function() { return 'selection'; }),
paste: vi.fn(),
input: vi.fn(function(data: string) {
eventCallOrder.push(`input:${data}`);
}),
onData: vi.fn(),
onResize: vi.fn(),
dispose: vi.fn(),
write: vi.fn(),
cols: 80,
rows: 24
};
});
// Need to also override the addon mocks to be constructable
(FitAddon as unknown as Mock).mockImplementation(function() {
return { fit: vi.fn() };
});
(WebLinksAddon as unknown as Mock).mockImplementation(function() {
return {};
});
(SerializeAddon as unknown as Mock).mockImplementation(function() {
return {
serialize: vi.fn(function() { return ''; }),
dispose: vi.fn()
};
});
// Create a test wrapper component that provides the DOM element
const TestWrapper = () => {
const { terminalRef } = useXterm({ terminalId: 'test-terminal' });
return React.createElement('div', { ref: terminalRef });
};
render(React.createElement(TestWrapper));
await act(async () => {
// Test SHIFT+Enter (should work independently of copy/paste)
const shiftEnterEvent = new KeyboardEvent('keydown', {
key: 'Enter',
shiftKey: true,
ctrlKey: false,
metaKey: false
});
if (keyEventHandler) {
keyEventHandler(shiftEnterEvent);
}
// Verify SHIFT+Enter still works (sends newline)
expect(eventCallOrder.some(s => s.includes('\x1b\n'))).toBe(true);
// Test CTRL+C with selection (should not interfere)
eventCallOrder = [];
const copyEvent = new KeyboardEvent('keydown', {
key: 'c',
ctrlKey: true
});
if (keyEventHandler) {
keyEventHandler(copyEvent);
// Wait for clipboard write
await new Promise(resolve => setTimeout(resolve, 0));
}
// Copy should not send input to terminal
expect(eventCallOrder).toHaveLength(0);
// Test CTRL+V (should not interfere)
const pasteEvent = new KeyboardEvent('keydown', {
key: 'v',
ctrlKey: true
});
if (keyEventHandler) {
keyEventHandler(pasteEvent);
// Wait for clipboard read
await new Promise(resolve => setTimeout(resolve, 0));
}
// Paste should use xterm.paste(), not xterm.input()
// The input() should not be called directly
expect(eventCallOrder).toHaveLength(0);
});
});
it('should maintain correct handler ordering for existing shortcuts', async () => {
const { useXterm } = await import('../../renderer/components/terminal/useXterm');
let keyEventHandler: ((event: KeyboardEvent) => boolean) | null = null;
let handlerResults: { key: string; handled: boolean }[] = [];
const mockHasSelection = vi.fn(function() { return false; });
// Override XTerm mock to be constructable
(XTerm as unknown as Mock).mockImplementation(function() {
return {
open: vi.fn(),
loadAddon: vi.fn(),
attachCustomKeyEventHandler: vi.fn(function(handler: (event: KeyboardEvent) => boolean) {
keyEventHandler = handler;
}),
hasSelection: mockHasSelection,
getSelection: vi.fn(),
paste: vi.fn(),
input: vi.fn(),
onData: vi.fn(),
onResize: vi.fn(),
dispose: vi.fn(),
write: vi.fn(),
cols: 80,
rows: 24
};
});
// Need to also override the addon mocks to be constructable
(FitAddon as unknown as Mock).mockImplementation(function() {
return { fit: vi.fn() };
});
(WebLinksAddon as unknown as Mock).mockImplementation(function() {
return {};
});
(SerializeAddon as unknown as Mock).mockImplementation(function() {
return {
serialize: vi.fn(function() { return ''; }),
dispose: vi.fn()
};
});
// Create a test wrapper component that provides the DOM element
const TestWrapper = () => {
const { terminalRef } = useXterm({ terminalId: 'test-terminal' });
return React.createElement('div', { ref: terminalRef });
};
render(React.createElement(TestWrapper));
// Helper to test key handling
const testKey = (key: string, ctrl: boolean, meta: boolean, shift: boolean) => {
const event = new KeyboardEvent('keydown', {
key,
ctrlKey: ctrl,
metaKey: meta,
shiftKey: shift
});
if (keyEventHandler) {
const handled = keyEventHandler(event);
handlerResults.push({ key, handled });
}
};
await act(async () => {
// Test existing shortcuts (should return false to bubble up)
testKey('1', true, false, false); // Ctrl+1
testKey('Tab', true, false, false); // Ctrl+Tab
testKey('t', true, false, false); // Ctrl+T
testKey('w', true, false, false); // Ctrl+W
// Verify these return false (bubble to window handler)
expect(handlerResults.filter(r => !r.handled)).toHaveLength(4);
// Test copy/paste WITHOUT selection (should pass through to send ^C)
handlerResults = [];
mockHasSelection.mockReturnValue(false);
testKey('c', true, false, false); // Ctrl+C without selection
// Should return true (let ^C pass through to terminal for interrupt signal)
expect(handlerResults[0].handled).toBe(true);
});
});
});
describe('clipboard error handling without breaking terminal', () => {
it('should continue terminal operation after clipboard error', async () => {
const { useXterm } = await import('../../renderer/components/terminal/useXterm');
// Mock Windows platform to enable custom paste handler
Object.defineProperty(navigator, 'platform', {
value: 'Win32',
writable: true
});
let keyEventHandler: ((event: KeyboardEvent) => boolean) | null = null;
const mockPaste = vi.fn();
const mockInput = vi.fn();
const mockSendTerminalInput = vi.fn();
let onDataCallback: ((data: string) => void) | undefined;
let errorLogged = false;
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(function(...args: unknown[]) {
if (String(args[0]).includes('[useXterm]')) {
errorLogged = true;
}
});
// Mock clipboard error
mockClipboard.readText = vi.fn().mockRejectedValue(new Error('Clipboard denied'));
// Mock window.electronAPI with sendTerminalInput
(window as unknown as { electronAPI: { sendTerminalInput: Mock } }).electronAPI = {
sendTerminalInput: mockSendTerminalInput
};
// Override XTerm mock to be constructable
(XTerm as unknown as Mock).mockImplementation(function() {
return {
open: vi.fn(),
loadAddon: vi.fn(),
attachCustomKeyEventHandler: vi.fn(function(handler: (event: KeyboardEvent) => boolean) {
keyEventHandler = handler;
}),
hasSelection: vi.fn(),
getSelection: vi.fn(),
paste: mockPaste,
input: mockInput,
onData: vi.fn(function(callback: (data: string) => void) {
onDataCallback = callback;
}),
onResize: vi.fn(),
dispose: vi.fn(),
write: vi.fn(),
cols: 80,
rows: 24
};
});
// Need to also override the addon mocks to be constructable
(FitAddon as unknown as Mock).mockImplementation(function() {
return { fit: vi.fn() };
});
(WebLinksAddon as unknown as Mock).mockImplementation(function() {
return {};
});
(SerializeAddon as unknown as Mock).mockImplementation(function() {
return {
serialize: vi.fn(function() { return ''; }),
dispose: vi.fn()
};
});
// Create a test wrapper component that provides the DOM element
const TestWrapper = () => {
const { terminalRef } = useXterm({ terminalId: 'test-terminal' });
return React.createElement('div', { ref: terminalRef });
};
render(React.createElement(TestWrapper));
await act(async () => {
// Try to paste (will fail)
const pasteEvent = new KeyboardEvent('keydown', {
key: 'v',
ctrlKey: true
});
if (keyEventHandler) {
keyEventHandler(pasteEvent);
// Wait for clipboard error
await new Promise(resolve => setTimeout(resolve, 0));
}
});
// Verify error was logged
expect(errorLogged).toBe(true);
// Verify terminal still works (can accept input through onData callback)
const inputData = 'test command';
if (onDataCallback) {
onDataCallback(inputData);
}
// Verify input was sent to electronAPI (terminal still functional)
expect(mockSendTerminalInput).toHaveBeenCalledWith('test-terminal', 'test command');
consoleErrorSpy.mockRestore();
});
});
});
@@ -8,13 +8,7 @@ import { existsSync, readdirSync } from 'fs';
import os from 'os';
import { execFileSync } from 'child_process';
import { app } from 'electron';
import {
getToolInfo,
clearToolCache,
getClaudeDetectionPaths,
sortNvmVersionDirs,
buildClaudeDetectionResult
} from '../cli-tool-manager';
import { getToolInfo, clearToolCache } from '../cli-tool-manager';
// Mock Electron app
vi.mock('electron', () => ({
@@ -48,10 +42,9 @@ vi.mock('fs', () => {
};
});
// Mock child_process for execFileSync and execFile (used in validation)
// Mock child_process for execFileSync (used in validation)
vi.mock('child_process', () => ({
execFileSync: vi.fn(),
execFile: vi.fn()
execFileSync: vi.fn()
}));
// Mock env-utils to avoid PATH augmentation complexity
@@ -319,151 +312,3 @@ describe('cli-tool-manager - Claude CLI NVM detection', () => {
});
});
});
/**
* Unit tests for helper functions
*/
describe('cli-tool-manager - Helper Functions', () => {
describe('getClaudeDetectionPaths', () => {
it('should return homebrew paths on macOS', () => {
Object.defineProperty(process, 'platform', {
value: 'darwin',
writable: true
});
const paths = getClaudeDetectionPaths('/Users/test');
expect(paths.homebrewPaths).toContain('/opt/homebrew/bin/claude');
expect(paths.homebrewPaths).toContain('/usr/local/bin/claude');
});
it('should return Windows paths on win32', () => {
Object.defineProperty(process, 'platform', {
value: 'win32',
writable: true
});
const paths = getClaudeDetectionPaths('C:\\Users\\test');
// Windows paths should include AppData and Program Files
expect(paths.platformPaths.some(p => p.includes('AppData'))).toBe(true);
expect(paths.platformPaths.some(p => p.includes('Program Files'))).toBe(true);
});
it('should return Unix paths on Linux', () => {
Object.defineProperty(process, 'platform', {
value: 'linux',
writable: true
});
const paths = getClaudeDetectionPaths('/home/test');
expect(paths.platformPaths.some(p => p.includes('.local/bin/claude'))).toBe(true);
expect(paths.platformPaths.some(p => p.includes('bin/claude'))).toBe(true);
});
it('should return correct NVM versions directory', () => {
const paths = getClaudeDetectionPaths('/home/test');
expect(paths.nvmVersionsDir).toBe('/home/test/.nvm/versions/node');
});
});
describe('sortNvmVersionDirs', () => {
it('should sort versions in descending order (newest first)', () => {
const entries = [
{ name: 'v18.20.0', isDirectory: () => true },
{ name: 'v22.17.0', isDirectory: () => true },
{ name: 'v20.11.0', isDirectory: () => true }
];
const sorted = sortNvmVersionDirs(entries);
expect(sorted).toEqual(['v22.17.0', 'v20.11.0', 'v18.20.0']);
});
it('should filter out non-version directories', () => {
const entries = [
{ name: 'v20.11.0', isDirectory: () => true },
{ name: '.DS_Store', isDirectory: () => false },
{ name: 'node_modules', isDirectory: () => true },
{ name: 'current', isDirectory: () => true },
{ name: 'v22.17.0', isDirectory: () => true }
];
const sorted = sortNvmVersionDirs(entries);
expect(sorted).toEqual(['v22.17.0', 'v20.11.0']);
expect(sorted).not.toContain('.DS_Store');
expect(sorted).not.toContain('node_modules');
expect(sorted).not.toContain('current');
});
it('should return empty array when no valid versions', () => {
const entries = [
{ name: 'current', isDirectory: () => true },
{ name: 'system', isDirectory: () => true }
];
const sorted = sortNvmVersionDirs(entries);
expect(sorted).toEqual([]);
});
it('should handle single entry', () => {
const entries = [{ name: 'v20.11.0', isDirectory: () => true }];
const sorted = sortNvmVersionDirs(entries);
expect(sorted).toEqual(['v20.11.0']);
});
it('should handle empty array', () => {
const sorted = sortNvmVersionDirs([]);
expect(sorted).toEqual([]);
});
});
describe('buildClaudeDetectionResult', () => {
it('should return null when validation fails', () => {
const result = buildClaudeDetectionResult(
'/path/to/claude',
{ valid: false, message: 'Invalid CLI' },
'nvm',
'Found via NVM'
);
expect(result).toBeNull();
});
it('should return proper result when validation succeeds', () => {
const result = buildClaudeDetectionResult(
'/path/to/claude',
{ valid: true, version: '1.0.0', message: 'Valid' },
'nvm',
'Found via NVM'
);
expect(result).not.toBeNull();
expect(result?.found).toBe(true);
expect(result?.path).toBe('/path/to/claude');
expect(result?.version).toBe('1.0.0');
expect(result?.source).toBe('nvm');
expect(result?.message).toContain('Found via NVM');
expect(result?.message).toContain('/path/to/claude');
});
it('should include path in message', () => {
const result = buildClaudeDetectionResult(
'/home/user/.nvm/versions/node/v22.17.0/bin/claude',
{ valid: true, version: '2.0.0', message: 'OK' },
'nvm',
'Detected Claude CLI'
);
expect(result?.message).toContain('Detected Claude CLI');
expect(result?.message).toContain('/home/user/.nvm/versions/node/v22.17.0/bin/claude');
});
});
});
@@ -4,7 +4,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { IPC_CHANNELS } from '../../shared/constants';
const {
mockGetClaudeCliInvocation,
mockGetClaudeCliInvocationAsync,
mockGetProject,
spawnMock,
mockIpcMain,
@@ -23,7 +22,6 @@ const {
return {
mockGetClaudeCliInvocation: vi.fn(),
mockGetClaudeCliInvocationAsync: vi.fn(),
mockGetProject: vi.fn(),
spawnMock: vi.fn(),
mockIpcMain: ipcMain,
@@ -32,7 +30,6 @@ const {
vi.mock('../claude-cli-utils', () => ({
getClaudeCliInvocation: mockGetClaudeCliInvocation,
getClaudeCliInvocationAsync: mockGetClaudeCliInvocationAsync,
}));
vi.mock('../project-store', () => ({
@@ -67,15 +64,9 @@ function createProc(): EventEmitter & { stdout?: EventEmitter; stderr?: EventEmi
return proc;
}
// Helper to flush all pending promises (needed for async mock resolution)
function flushPromises(): Promise<void> {
return new Promise(resolve => setTimeout(resolve, 0));
}
describe('env-handlers Claude CLI usage', () => {
beforeEach(() => {
mockGetClaudeCliInvocation.mockReset();
mockGetClaudeCliInvocationAsync.mockReset();
mockGetProject.mockReset();
spawnMock.mockReset();
});
@@ -83,7 +74,7 @@ describe('env-handlers Claude CLI usage', () => {
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';
mockGetClaudeCliInvocationAsync.mockResolvedValue({
mockGetClaudeCliInvocation.mockReturnValue({
command,
env: claudeEnv,
});
@@ -103,8 +94,6 @@ describe('env-handlers Claude CLI usage', () => {
}
const resultPromise = handler({}, 'p1');
// Wait for async CLI resolution before checking spawn
await flushPromises();
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock).toHaveBeenCalledWith(
command,
@@ -131,7 +120,7 @@ describe('env-handlers Claude CLI usage', () => {
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';
mockGetClaudeCliInvocationAsync.mockResolvedValue({
mockGetClaudeCliInvocation.mockReturnValue({
command,
env: claudeEnv,
});
@@ -147,8 +136,6 @@ describe('env-handlers Claude CLI usage', () => {
}
const resultPromise = handler({}, 'p2');
// Wait for async CLI resolution before checking spawn
await flushPromises();
expect(spawnMock).toHaveBeenCalledWith(
command,
['setup-token'],
@@ -166,7 +153,9 @@ describe('env-handlers Claude CLI usage', () => {
});
it('returns an error when Claude CLI resolution throws', async () => {
mockGetClaudeCliInvocationAsync.mockRejectedValue(new Error('Claude CLI exploded'));
mockGetClaudeCliInvocation.mockImplementation(() => {
throw new Error('Claude CLI exploded');
});
mockGetProject.mockReturnValue({ id: 'p3', path: '/tmp/project' });
registerEnvHandlers(() => null);
@@ -182,7 +171,7 @@ describe('env-handlers Claude CLI usage', () => {
});
it('returns an error when Claude CLI command is missing', async () => {
mockGetClaudeCliInvocationAsync.mockResolvedValue({ command: '', env: {} });
mockGetClaudeCliInvocation.mockReturnValue({ command: '', env: {} });
mockGetProject.mockReturnValue({ id: 'p4', path: '/tmp/project' });
registerEnvHandlers(() => null);
@@ -200,7 +189,7 @@ describe('env-handlers Claude CLI usage', () => {
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';
mockGetClaudeCliInvocationAsync.mockResolvedValue({
mockGetClaudeCliInvocation.mockReturnValue({
command,
env: claudeEnv,
});
@@ -216,8 +205,6 @@ describe('env-handlers Claude CLI usage', () => {
}
const resultPromise = handler({}, 'p5');
// Wait for async CLI resolution before checking spawn
await flushPromises();
expect(spawnMock).toHaveBeenCalledWith(
command,
['--version'],
@@ -520,8 +520,7 @@ describe('IPC Handlers', { timeout: 15000 }, () => {
expect(mockMainWindow.webContents.send).toHaveBeenCalledWith(
'task:log',
'task-1',
'Test log message',
undefined // projectId is undefined when task not found
'Test log message'
);
});
@@ -534,8 +533,7 @@ describe('IPC Handlers', { timeout: 15000 }, () => {
expect(mockMainWindow.webContents.send).toHaveBeenCalledWith(
'task:error',
'task-1',
'Test error message',
undefined // projectId is undefined when task not found
'Test error message'
);
});
@@ -559,8 +557,7 @@ describe('IPC Handlers', { timeout: 15000 }, () => {
expect(mockMainWindow.webContents.send).toHaveBeenCalledWith(
'task:statusChange',
'task-1',
'human_review',
expect.any(String) // projectId for multi-project filtering
'human_review'
);
});
});
@@ -152,11 +152,6 @@ export class AgentManager extends EventEmitter {
}
}
// Workspace mode: --direct skips worktree isolation (default is isolated for safety)
if (metadata?.useWorktree === false) {
args.push('--direct');
}
// Store context for potential restart
this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata, baseBranch);
@@ -205,11 +200,6 @@ export class AgentManager extends EventEmitter {
// Force: When user starts a task from the UI, that IS their approval
args.push('--force');
// Workspace mode: --direct skips worktree isolation (default is isolated for safety)
if (options.useWorktree === false) {
args.push('--direct');
}
// Pass base branch if specified (ensures worktrees are created from the correct branch)
if (options.baseBranch) {
args.push('--base-branch', options.baseBranch);
@@ -268,10 +268,10 @@ describe('AgentProcessManager - API Profile Env Injection (Story 2.3)', () => {
await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution');
const envArg = spawnCalls[0].options.env as Record<string, unknown>;
// OAuth token should be present
expect(envArg.CLAUDE_CODE_OAUTH_TOKEN).toBe('oauth-token-456');
// Stale ANTHROPIC_* vars should be cleared (empty string overrides process.env)
expect(envArg.ANTHROPIC_AUTH_TOKEN).toBe('');
expect(envArg.ANTHROPIC_BASE_URL).toBe('');
@@ -292,7 +292,7 @@ describe('AgentProcessManager - API Profile Env Injection (Story 2.3)', () => {
await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution');
const envArg = spawnCalls[0].options.env as Record<string, unknown>;
// Should clear the base URL (so Python uses default api.anthropic.com)
expect(envArg.ANTHROPIC_BASE_URL).toBe('');
expect(envArg.CLAUDE_CODE_OAUTH_TOKEN).toBe('oauth-token-789');
@@ -314,7 +314,7 @@ describe('AgentProcessManager - API Profile Env Injection (Story 2.3)', () => {
await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution');
const envArg = spawnCalls[0].options.env as Record<string, unknown>;
// Should use API profile vars, NOT clear them
expect(envArg.ANTHROPIC_AUTH_TOKEN).toBe('sk-profile-active');
expect(envArg.ANTHROPIC_BASE_URL).toBe('https://active-profile.com');
+6 -52
View File
@@ -38,40 +38,6 @@ export class AgentQueueManager {
this.emitter = emitter;
}
/**
* Ensure Python environment is ready before spawning processes.
* Prevents the race condition where generation starts before dependencies are installed,
* which would cause it to fall back to system Python and fail with ModuleNotFoundError.
*
* @param projectId - The project ID for error event emission
* @param eventType - The error event type to emit on failure
* @returns true if environment is ready, false if initialization failed (error already emitted)
*/
private async ensurePythonEnvReady(
projectId: string,
eventType: 'ideation-error' | 'roadmap-error'
): Promise<boolean> {
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
if (!pythonEnvManager.isEnvReady()) {
debugLog('[Agent Queue] Python environment not ready, waiting for initialization...');
if (autoBuildSource) {
const status = await pythonEnvManager.initialize(autoBuildSource);
if (!status.ready) {
debugError('[Agent Queue] Python environment initialization failed:', status.error);
this.emitter.emit(eventType, projectId, `Python environment not ready: ${status.error || 'initialization failed'}`);
return false;
}
debugLog('[Agent Queue] Python environment now ready');
} else {
debugError('[Agent Queue] Cannot initialize Python - auto-build source not found');
this.emitter.emit(eventType, projectId, 'Python environment not ready: auto-build source not found');
return false;
}
}
return true;
}
/**
* Start roadmap generation process
*
@@ -229,15 +195,6 @@ export class AgentQueueManager {
): Promise<void> {
debugLog('[Agent Queue] Spawning ideation process:', { projectId, projectPath });
// Run from auto-claude source directory so imports work correctly
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
const cwd = autoBuildSource || process.cwd();
// Ensure Python environment is ready before spawning
if (!await this.ensurePythonEnvReady(projectId, 'ideation-error')) {
return;
}
// Kill existing process for this project if any
const wasKilled = this.processManager.killProcess(projectId);
if (wasKilled) {
@@ -248,6 +205,9 @@ export class AgentQueueManager {
const spawnId = this.state.generateSpawnId();
debugLog('[Agent Queue] Generated spawn ID:', spawnId);
// Run from auto-claude source directory so imports work correctly
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
const cwd = autoBuildSource || process.cwd();
// Get combined environment variables
const combinedEnv = this.processManager.getCombinedEnv(projectPath);
@@ -556,15 +516,6 @@ export class AgentQueueManager {
): Promise<void> {
debugLog('[Agent Queue] Spawning roadmap process:', { projectId, projectPath });
// Run from auto-claude source directory so imports work correctly
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
const cwd = autoBuildSource || process.cwd();
// Ensure Python environment is ready before spawning
if (!await this.ensurePythonEnvReady(projectId, 'roadmap-error')) {
return;
}
// Kill existing process for this project if any
const wasKilled = this.processManager.killProcess(projectId);
if (wasKilled) {
@@ -575,6 +526,9 @@ export class AgentQueueManager {
const spawnId = this.state.generateSpawnId();
debugLog('[Agent Queue] Generated roadmap spawn ID:', spawnId);
// Run from auto-claude source directory so imports work correctly
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
const cwd = autoBuildSource || process.cwd();
// Get combined environment variables
const combinedEnv = this.processManager.getCombinedEnv(projectPath);
+4 -4
View File
@@ -4,18 +4,18 @@
/**
* Get environment variables to clear ANTHROPIC_* vars when in OAuth mode
*
* When switching from API Profile mode to OAuth mode, residual ANTHROPIC_*
*
* When switching from API Profile mode to OAuth mode, residual ANTHROPIC_*
* environment variables from process.env can cause authentication failures.
* This function returns an object with empty strings for these vars when
* no API profile is active, ensuring OAuth tokens are used correctly.
*
*
* **Why empty strings?** Setting environment variables to empty strings (rather than
* undefined) ensures they override any stale values from process.env. Python's SDK
* treats empty strings as falsy in conditional checks like `if token:`, so empty
* strings effectively disable these authentication parameters without leaving
* undefined values that might be ignored during object spreading.
*
*
* @param apiProfileEnv - Environment variables from getAPIProfileEnv()
* @returns Object with empty ANTHROPIC_* vars if in OAuth mode, empty object otherwise
*/
-3
View File
@@ -44,7 +44,6 @@ export interface TaskExecutionOptions {
parallel?: boolean;
workers?: number;
baseBranch?: string;
useWorktree?: boolean; // If false, use --direct mode (no worktree isolation)
}
export interface SpecCreationMetadata {
@@ -66,8 +65,6 @@ export interface SpecCreationMetadata {
// Non-auto profile - single model and thinking level
model?: 'haiku' | 'sonnet' | 'opus';
thinkingLevel?: 'none' | 'low' | 'medium' | 'high' | 'ultrathink';
// Workspace mode - whether to use worktree isolation
useWorktree?: boolean; // If false, use --direct mode (no worktree isolation)
}
export interface IdeationProgressData {
+2 -30
View File
@@ -1,6 +1,6 @@
import path from 'path';
import { getAugmentedEnv, getAugmentedEnvAsync } from './env-utils';
import { getToolPath, getToolPathAsync } from './cli-tool-manager';
import { getAugmentedEnv } from './env-utils';
import { getToolPath } from './cli-tool-manager';
export type ClaudeCliInvocation = {
command: string;
@@ -37,9 +37,6 @@ function ensureCommandDirInPath(command: string, env: Record<string, string>): R
/**
* Returns the Claude CLI command path and an environment with PATH updated to include the CLI directory.
*
* WARNING: This function uses synchronous subprocess calls that block the main process.
* For use in Electron main process, prefer getClaudeCliInvocationAsync() instead.
*/
export function getClaudeCliInvocation(): ClaudeCliInvocation {
const command = getToolPath('claude');
@@ -50,28 +47,3 @@ export function getClaudeCliInvocation(): ClaudeCliInvocation {
env: ensureCommandDirInPath(command, env),
};
}
/**
* Returns the Claude CLI command path and environment asynchronously (non-blocking).
*
* Safe to call from Electron main process without blocking the event loop.
* Uses cached values if available for instant response.
*
* @example
* ```typescript
* const { command, env } = await getClaudeCliInvocationAsync();
* spawn(command, ['--version'], { env });
* ```
*/
export async function getClaudeCliInvocationAsync(): Promise<ClaudeCliInvocation> {
// Run both detections in parallel for efficiency
const [command, env] = await Promise.all([
getToolPathAsync('claude'),
getAugmentedEnvAsync(),
]);
return {
command,
env: ensureCommandDirInPath(command, env),
};
}
@@ -13,7 +13,7 @@
import { app } from 'electron';
import { join } from 'path';
import { mkdir } from 'fs/promises';
import { existsSync, mkdirSync } from 'fs';
import type {
ClaudeProfile,
ClaudeProfileSettings,
@@ -32,7 +32,6 @@ import {
} from './claude-profile/rate-limit-manager';
import {
loadProfileStore,
loadProfileStoreAsync,
saveProfileStore,
ProfileStoreData,
DEFAULT_AUTO_SWITCH_SETTINGS
@@ -58,45 +57,19 @@ import {
*/
export class ClaudeProfileManager {
private storePath: string;
private configDir: string;
private data: ProfileStoreData;
private initialized: boolean = false;
constructor() {
this.configDir = join(app.getPath('userData'), 'config');
this.storePath = join(this.configDir, 'claude-profiles.json');
const configDir = join(app.getPath('userData'), 'config');
this.storePath = join(configDir, 'claude-profiles.json');
// DON'T do file I/O here - defer to async initialize()
// Start with default data until initialized
this.data = this.createDefaultData();
}
/**
* Initialize the profile manager asynchronously (non-blocking)
* This should be called at app startup via initializeClaudeProfileManager()
*/
async initialize(): Promise<void> {
if (this.initialized) return;
// Ensure directory exists (async) - mkdir with recursive:true is idempotent
await mkdir(this.configDir, { recursive: true });
// Load existing data asynchronously
const loadedData = await loadProfileStoreAsync(this.storePath);
if (loadedData) {
this.data = loadedData;
// Ensure directory exists
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true });
}
// else: keep the default data from constructor
this.initialized = true;
console.warn('[ClaudeProfileManager] Initialized asynchronously');
}
/**
* Check if the profile manager has been initialized
*/
isInitialized(): boolean {
return this.initialized;
// Load existing data or initialize with default profile
this.data = this.load();
}
/**
@@ -549,13 +522,11 @@ export class ClaudeProfileManager {
}
}
// Singleton instance and initialization promise
// Singleton instance
let profileManager: ClaudeProfileManager | null = null;
let initPromise: Promise<ClaudeProfileManager> | null = null;
/**
* Get the singleton Claude profile manager instance
* Note: For async contexts, prefer initializeClaudeProfileManager() to ensure initialization
*/
export function getClaudeProfileManager(): ClaudeProfileManager {
if (!profileManager) {
@@ -563,28 +534,3 @@ export function getClaudeProfileManager(): ClaudeProfileManager {
}
return profileManager;
}
/**
* Initialize and get the singleton Claude profile manager instance (async)
* This ensures the profile manager is fully initialized before use.
* Uses promise caching to prevent concurrent initialization.
*/
export async function initializeClaudeProfileManager(): Promise<ClaudeProfileManager> {
if (!profileManager) {
profileManager = new ClaudeProfileManager();
}
// If already initialized, return immediately
if (profileManager.isInitialized()) {
return profileManager;
}
// If initialization is in progress, wait for it (promise caching)
if (!initPromise) {
initPromise = profileManager.initialize().then(() => {
return profileManager!;
});
}
return initPromise;
}

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