Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d789d0ad15 | |||
| 574cd117b2 | |||
| 09aa4f4f71 | |||
| 78aceaed1e | |||
| 5005e56e46 | |||
| ec4441c1e3 | |||
| 97f34496b5 | |||
| 2c9fcbf498 | |||
| 3930b12c41 | |||
| e2937320cf | |||
| 81afc3d2cc | |||
| 63f4617354 | |||
| 35573fd5b0 | |||
| 7b4993e9db | |||
| c27135436d | |||
| 6fb2d48433 | |||
| 1e3e8bda1d | |||
| 8be0e6ff1a | |||
| 324edfa7d5 | |||
| b5f452b428 | |||
| b0de22a988 | |||
| 3c49173210 | |||
| d3587c3c42 | |||
| f6c9a84acb | |||
| 91b80c0df0 | |||
| 052e6a0970 | |||
| 90f5b59c35 |
@@ -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();
|
||||
|
||||
@@ -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'
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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
|
||||
@@ -1,227 +0,0 @@
|
||||
name: PR Auto Label
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: pr-auto-label-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
name: Auto Label PR
|
||||
runs-on: ubuntu-latest
|
||||
# Don't run on fork PRs (they can't write labels)
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Auto-label PR
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const pr = context.payload.pull_request;
|
||||
const prNumber = pr.number;
|
||||
const title = pr.title;
|
||||
|
||||
console.log(`::group::PR #${prNumber} - Auto-labeling`);
|
||||
console.log(`Title: ${title}`);
|
||||
|
||||
const labelsToAdd = new Set();
|
||||
const labelsToRemove = new Set();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// TYPE LABELS (from PR title - Conventional Commits)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
const typeMap = {
|
||||
'feat': 'feature',
|
||||
'fix': 'bug',
|
||||
'docs': 'documentation',
|
||||
'refactor': 'refactor',
|
||||
'test': 'test',
|
||||
'ci': 'ci',
|
||||
'chore': 'chore',
|
||||
'perf': 'performance',
|
||||
'style': 'style',
|
||||
'build': 'build'
|
||||
};
|
||||
|
||||
const typeMatch = title.match(/^(\w+)(\(.+?\))?(!)?:/);
|
||||
if (typeMatch) {
|
||||
const type = typeMatch[1].toLowerCase();
|
||||
const isBreaking = typeMatch[3] === '!';
|
||||
|
||||
if (typeMap[type]) {
|
||||
labelsToAdd.add(typeMap[type]);
|
||||
console.log(` 📝 Type: ${type} → ${typeMap[type]}`);
|
||||
}
|
||||
|
||||
if (isBreaking) {
|
||||
labelsToAdd.add('breaking-change');
|
||||
console.log(` ⚠️ Breaking change detected`);
|
||||
}
|
||||
} else {
|
||||
console.log(` ⚠️ No conventional commit prefix found in title`);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// AREA LABELS (from changed files)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
let files = [];
|
||||
try {
|
||||
const { data } = await github.rest.pulls.listFiles({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100
|
||||
});
|
||||
files = data;
|
||||
} catch (e) {
|
||||
console.log(` ⚠️ Could not fetch files: ${e.message}`);
|
||||
}
|
||||
|
||||
const areas = {
|
||||
frontend: false,
|
||||
backend: false,
|
||||
ci: false,
|
||||
docs: false,
|
||||
tests: false
|
||||
};
|
||||
|
||||
for (const file of files) {
|
||||
const path = file.filename;
|
||||
if (path.startsWith('apps/frontend/')) areas.frontend = true;
|
||||
if (path.startsWith('apps/backend/')) areas.backend = true;
|
||||
if (path.startsWith('.github/')) areas.ci = true;
|
||||
if (path.endsWith('.md') || path.startsWith('docs/')) areas.docs = true;
|
||||
if (path.startsWith('tests/') || path.includes('.test.') || path.includes('.spec.')) areas.tests = true;
|
||||
}
|
||||
|
||||
// Determine area label (mutually exclusive)
|
||||
const areaLabels = ['area/frontend', 'area/backend', 'area/fullstack', 'area/ci'];
|
||||
|
||||
if (areas.frontend && areas.backend) {
|
||||
labelsToAdd.add('area/fullstack');
|
||||
areaLabels.filter(l => l !== 'area/fullstack').forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📁 Area: fullstack (${files.length} files)`);
|
||||
} else if (areas.frontend) {
|
||||
labelsToAdd.add('area/frontend');
|
||||
areaLabels.filter(l => l !== 'area/frontend').forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📁 Area: frontend (${files.length} files)`);
|
||||
} else if (areas.backend) {
|
||||
labelsToAdd.add('area/backend');
|
||||
areaLabels.filter(l => l !== 'area/backend').forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📁 Area: backend (${files.length} files)`);
|
||||
} else if (areas.ci) {
|
||||
labelsToAdd.add('area/ci');
|
||||
areaLabels.filter(l => l !== 'area/ci').forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📁 Area: ci (${files.length} files)`);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SIZE LABELS (from lines changed)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
const additions = pr.additions || 0;
|
||||
const deletions = pr.deletions || 0;
|
||||
const totalLines = additions + deletions;
|
||||
|
||||
const sizeLabels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'];
|
||||
let sizeLabel;
|
||||
|
||||
if (totalLines < 10) sizeLabel = 'size/XS';
|
||||
else if (totalLines < 100) sizeLabel = 'size/S';
|
||||
else if (totalLines < 500) sizeLabel = 'size/M';
|
||||
else if (totalLines < 1000) sizeLabel = 'size/L';
|
||||
else sizeLabel = 'size/XL';
|
||||
|
||||
labelsToAdd.add(sizeLabel);
|
||||
sizeLabels.filter(l => l !== sizeLabel).forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📏 Size: ${sizeLabel} (+${additions}/-${deletions} = ${totalLines} lines)`);
|
||||
|
||||
console.log('::endgroup::');
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// APPLY LABELS
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
console.log(`::group::Applying labels`);
|
||||
|
||||
// Remove old labels (in parallel)
|
||||
const removeArray = [...labelsToRemove].filter(l => !labelsToAdd.has(l));
|
||||
if (removeArray.length > 0) {
|
||||
const removePromises = removeArray.map(async (label) => {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: label
|
||||
});
|
||||
console.log(` ✓ Removed: ${label}`);
|
||||
} catch (e) {
|
||||
if (e.status !== 404) {
|
||||
console.log(` ⚠ Could not remove ${label}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
await Promise.all(removePromises);
|
||||
}
|
||||
|
||||
// Add new labels
|
||||
const addArray = [...labelsToAdd];
|
||||
if (addArray.length > 0) {
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: addArray
|
||||
});
|
||||
console.log(` ✓ Added: ${addArray.join(', ')}`);
|
||||
} catch (e) {
|
||||
// Some labels might not exist
|
||||
if (e.status === 404) {
|
||||
core.warning(`Some labels do not exist. Please create them in repository settings.`);
|
||||
// Try adding one by one
|
||||
for (const label of addArray) {
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: [label]
|
||||
});
|
||||
} catch (e2) {
|
||||
console.log(` ⚠ Label '${label}' does not exist`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('::endgroup::');
|
||||
|
||||
// Summary
|
||||
console.log(`✅ PR #${prNumber} labeled: ${addArray.join(', ')}`);
|
||||
|
||||
// Write job summary
|
||||
core.summary
|
||||
.addHeading(`PR #${prNumber} Auto-Labels`, 3)
|
||||
.addTable([
|
||||
[{data: 'Category', header: true}, {data: 'Label', header: true}],
|
||||
['Type', typeMatch ? typeMap[typeMatch[1].toLowerCase()] || 'none' : 'none'],
|
||||
['Area', areas.frontend && areas.backend ? 'fullstack' : areas.frontend ? 'frontend' : areas.backend ? 'backend' : 'other'],
|
||||
['Size', sizeLabel]
|
||||
])
|
||||
.addRaw(`\n**Files changed:** ${files.length}\n`)
|
||||
.addRaw(`**Lines:** +${additions} / -${deletions}\n`);
|
||||
await core.summary.write();
|
||||
@@ -1,72 +0,0 @@
|
||||
name: PR Status Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: pr-status-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
mark-checking:
|
||||
name: Set Checking Status
|
||||
runs-on: ubuntu-latest
|
||||
# Don't run on fork PRs (they can't write labels)
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Update PR status label
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const statusLabels = ['🔄 Checking', '✅ Ready for Review', '❌ Checks Failed'];
|
||||
|
||||
console.log(`::group::PR #${prNumber} - Setting status to Checking`);
|
||||
|
||||
// Remove old status labels (parallel for speed)
|
||||
const removePromises = statusLabels.map(async (label) => {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: label
|
||||
});
|
||||
console.log(` ✓ Removed: ${label}`);
|
||||
} catch (e) {
|
||||
if (e.status !== 404) {
|
||||
console.log(` ⚠ Could not remove ${label}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(removePromises);
|
||||
|
||||
// Add checking label
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: ['🔄 Checking']
|
||||
});
|
||||
console.log(` ✓ Added: 🔄 Checking`);
|
||||
} catch (e) {
|
||||
// Label might not exist - create helpful error
|
||||
if (e.status === 404) {
|
||||
core.warning(`Label '🔄 Checking' does not exist. Please create it in repository settings.`);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log('::endgroup::');
|
||||
console.log(`✅ PR #${prNumber} marked as checking`);
|
||||
@@ -1,191 +0,0 @@
|
||||
name: PR Status Gate
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI, Lint, Quality Security]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
checks: read
|
||||
|
||||
jobs:
|
||||
update-status:
|
||||
name: Update PR Status
|
||||
runs-on: ubuntu-latest
|
||||
# Only run if this workflow_run is associated with a PR
|
||||
if: github.event.workflow_run.pull_requests[0] != null
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check all required checks and update label
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.workflow_run.pull_requests[0].number;
|
||||
const headSha = context.payload.workflow_run.head_sha;
|
||||
const triggerWorkflow = context.payload.workflow_run.name;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// REQUIRED CHECK RUNS - Job-level checks (not workflow-level)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Format: "{Workflow Name} / {Job Name}" or "{Workflow Name} / {Job Custom Name}"
|
||||
//
|
||||
// To find check names: Go to PR → Checks tab → copy exact name
|
||||
// To update: Edit this list when workflow jobs are added/renamed/removed
|
||||
//
|
||||
// Last validated: 2026-01-02
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
const requiredChecks = [
|
||||
// CI workflow (ci.yml) - 3 checks
|
||||
'CI / test-frontend',
|
||||
'CI / test-python (3.12)',
|
||||
'CI / test-python (3.13)',
|
||||
// Lint workflow (lint.yml) - 1 check
|
||||
'Lint / python',
|
||||
// Quality Security workflow (quality-security.yml) - 4 checks
|
||||
'Quality Security / CodeQL (javascript-typescript)',
|
||||
'Quality Security / CodeQL (python)',
|
||||
'Quality Security / Python Security (Bandit)',
|
||||
'Quality Security / Security Summary'
|
||||
];
|
||||
|
||||
const statusLabels = {
|
||||
checking: '🔄 Checking',
|
||||
passed: '✅ Ready for Review',
|
||||
failed: '❌ Checks Failed'
|
||||
};
|
||||
|
||||
console.log(`::group::PR #${prNumber} - Checking required checks`);
|
||||
console.log(`Triggered by: ${triggerWorkflow}`);
|
||||
console.log(`Head SHA: ${headSha}`);
|
||||
console.log(`Required checks: ${requiredChecks.length}`);
|
||||
console.log('');
|
||||
|
||||
// Fetch all check runs for this commit
|
||||
let allCheckRuns = [];
|
||||
try {
|
||||
const { data } = await github.rest.checks.listForRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: headSha,
|
||||
per_page: 100
|
||||
});
|
||||
allCheckRuns = data.check_runs;
|
||||
console.log(`Found ${allCheckRuns.length} total check runs`);
|
||||
} catch (error) {
|
||||
// Add warning annotation so maintainers are alerted
|
||||
core.warning(`Failed to fetch check runs for PR #${prNumber}: ${error.message}. PR label may be outdated.`);
|
||||
console.log(`::error::Failed to fetch check runs: ${error.message}`);
|
||||
console.log('::endgroup::');
|
||||
return;
|
||||
}
|
||||
|
||||
let allComplete = true;
|
||||
let anyFailed = false;
|
||||
const results = [];
|
||||
|
||||
// Check each required check
|
||||
for (const checkName of requiredChecks) {
|
||||
const check = allCheckRuns.find(c => c.name === checkName);
|
||||
|
||||
if (!check) {
|
||||
results.push({ name: checkName, status: '⏳ Pending', complete: false });
|
||||
allComplete = false;
|
||||
} else if (check.status !== 'completed') {
|
||||
results.push({ name: checkName, status: '🔄 Running', complete: false });
|
||||
allComplete = false;
|
||||
} else if (check.conclusion === 'success') {
|
||||
results.push({ name: checkName, status: '✅ Passed', complete: true });
|
||||
} else if (check.conclusion === 'skipped') {
|
||||
// Skipped checks are treated as passed (e.g., path filters, conditional jobs)
|
||||
results.push({ name: checkName, status: '⏭️ Skipped', complete: true, skipped: true });
|
||||
} else {
|
||||
results.push({ name: checkName, status: '❌ Failed', complete: true, failed: true });
|
||||
anyFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Print results table
|
||||
console.log('');
|
||||
console.log('Check Status:');
|
||||
console.log('─'.repeat(70));
|
||||
for (const r of results) {
|
||||
const shortName = r.name.length > 55 ? r.name.substring(0, 52) + '...' : r.name;
|
||||
console.log(` ${r.status.padEnd(12)} ${shortName}`);
|
||||
}
|
||||
console.log('─'.repeat(70));
|
||||
console.log('::endgroup::');
|
||||
|
||||
// Only update label if all required checks are complete
|
||||
if (!allComplete) {
|
||||
const pending = results.filter(r => !r.complete).length;
|
||||
console.log(`⏳ ${pending}/${requiredChecks.length} checks still pending - keeping current label`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine final label
|
||||
const newLabel = anyFailed ? statusLabels.failed : statusLabels.passed;
|
||||
|
||||
console.log(`::group::Updating PR #${prNumber} label`);
|
||||
|
||||
// Remove old status labels
|
||||
for (const label of Object.values(statusLabels)) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: label
|
||||
});
|
||||
console.log(` ✓ Removed: ${label}`);
|
||||
} catch (e) {
|
||||
if (e.status !== 404) {
|
||||
console.log(` ⚠ Could not remove ${label}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add final status label
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: [newLabel]
|
||||
});
|
||||
console.log(` ✓ Added: ${newLabel}`);
|
||||
} catch (e) {
|
||||
if (e.status === 404) {
|
||||
core.warning(`Label '${newLabel}' does not exist. Please create it in repository settings.`);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log('::endgroup::');
|
||||
|
||||
// Summary
|
||||
const passedCount = results.filter(r => r.status === '✅ Passed').length;
|
||||
const skippedCount = results.filter(r => r.skipped).length;
|
||||
const failedCount = results.filter(r => r.failed).length;
|
||||
|
||||
if (anyFailed) {
|
||||
console.log(`❌ PR #${prNumber} has ${failedCount} failing check(s)`);
|
||||
core.summary.addRaw(`## ❌ PR #${prNumber} - Checks Failed\n\n`);
|
||||
core.summary.addRaw(`**${failedCount}** of **${requiredChecks.length}** required checks failed.\n\n`);
|
||||
} else {
|
||||
const skippedNote = skippedCount > 0 ? ` (${skippedCount} skipped)` : '';
|
||||
const totalSuccessful = passedCount + skippedCount;
|
||||
console.log(`✅ PR #${prNumber} is ready for review (${totalSuccessful}/${requiredChecks.length} checks succeeded${skippedNote})`);
|
||||
core.summary.addRaw(`## ✅ PR #${prNumber} - Ready for Review\n\n`);
|
||||
core.summary.addRaw(`All **${requiredChecks.length}** required checks succeeded${skippedNote}.\n\n`);
|
||||
}
|
||||
|
||||
// Add results to summary
|
||||
core.summary.addTable([
|
||||
[{data: 'Check', header: true}, {data: 'Status', header: true}],
|
||||
...results.map(r => [r.name, r.status])
|
||||
]);
|
||||
await core.summary.write();
|
||||
@@ -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: |
|
||||
|
||||
@@ -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();
|
||||
@@ -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'
|
||||
@@ -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
|
||||
@@ -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 }}"
|
||||
@@ -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,11 +4,9 @@
|
||||
|
||||

|
||||
|
||||
<!-- TOP_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.2)
|
||||
<!-- TOP_VERSION_BADGE_END -->
|
||||
[](./agpl-3.0.txt)
|
||||
[](https://discord.gg/KCXaPBr4Dj)
|
||||
[](https://www.youtube.com/@AndreMikalsen)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/actions)
|
||||
|
||||
---
|
||||
@@ -59,7 +57,6 @@
|
||||
- **Claude Pro/Max subscription** - [Get one here](https://claude.ai/upgrade)
|
||||
- **Claude Code CLI** - `npm install -g @anthropic-ai/claude-code`
|
||||
- **Git repository** - Your project must be initialized as a git repo
|
||||
- **Python 3.12+** - Required for the backend and Memory Layer
|
||||
|
||||
---
|
||||
|
||||
@@ -148,113 +145,11 @@ See [guides/CLI-USAGE.md](guides/CLI-USAGE.md) for complete CLI documentation.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
## Development
|
||||
|
||||
Create `apps/backend/.env` from the example:
|
||||
Want to build from source or contribute? See [CONTRIBUTING.md](CONTRIBUTING.md) for complete development setup instructions.
|
||||
|
||||
```bash
|
||||
cp apps/backend/.env.example apps/backend/.env
|
||||
```
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `CLAUDE_CODE_OAUTH_TOKEN` | Yes | OAuth token from `claude setup-token` |
|
||||
| `GRAPHITI_ENABLED` | No | Enable Memory Layer for cross-session context |
|
||||
| `AUTO_BUILD_MODEL` | No | Override the default Claude model |
|
||||
| `GITLAB_TOKEN` | No | GitLab Personal Access Token for GitLab integration |
|
||||
| `GITLAB_INSTANCE_URL` | No | GitLab instance URL (defaults to gitlab.com) |
|
||||
| `LINEAR_API_KEY` | No | Linear API key for task sync |
|
||||
|
||||
---
|
||||
|
||||
## Building from Source
|
||||
|
||||
For contributors and development:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/AndyMik90/Auto-Claude.git
|
||||
cd Auto-Claude
|
||||
|
||||
# Install all dependencies
|
||||
npm run install:all
|
||||
|
||||
# Run in development mode
|
||||
npm run dev
|
||||
|
||||
# Or build and run
|
||||
npm start
|
||||
```
|
||||
|
||||
**System requirements for building:**
|
||||
- Node.js 24+
|
||||
- Python 3.12+
|
||||
- npm 10+
|
||||
|
||||
**Installing dependencies by platform:**
|
||||
|
||||
<details>
|
||||
<summary><b>Windows</b></summary>
|
||||
|
||||
```bash
|
||||
winget install Python.Python.3.12
|
||||
winget install OpenJS.NodeJS.LTS
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>macOS</b></summary>
|
||||
|
||||
```bash
|
||||
brew install python@3.12 node@24
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Linux (Ubuntu/Debian)</b></summary>
|
||||
|
||||
```bash
|
||||
sudo apt install python3.12 python3.12-venv
|
||||
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
|
||||
sudo apt install -y nodejs
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Linux (Fedora)</b></summary>
|
||||
|
||||
```bash
|
||||
sudo dnf install python3.12 nodejs npm
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed development setup.
|
||||
|
||||
### Building Flatpak
|
||||
|
||||
To build the Flatpak package, you need additional dependencies:
|
||||
|
||||
```bash
|
||||
# Fedora/RHEL
|
||||
sudo dnf install flatpak-builder
|
||||
|
||||
# Ubuntu/Debian
|
||||
sudo apt install flatpak-builder
|
||||
|
||||
# Install required Flatpak runtimes
|
||||
flatpak install flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
|
||||
flatpak install flathub org.electronjs.Electron2.BaseApp//25.08
|
||||
|
||||
# Build the Flatpak
|
||||
cd apps/frontend
|
||||
npm run package:flatpak
|
||||
```
|
||||
|
||||
The Flatpak will be created in `apps/frontend/dist/`.
|
||||
For Linux-specific builds (Flatpak, AppImage), see [guides/linux.md](guides/linux.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -284,7 +179,7 @@ All releases are:
|
||||
| `npm run package:mac` | Package for macOS |
|
||||
| `npm run package:win` | Package for Windows |
|
||||
| `npm run package:linux` | Package for Linux |
|
||||
| `npm run package:flatpak` | Package as Flatpak |
|
||||
| `npm run package:flatpak` | Package as Flatpak (see [guides/linux.md](guides/linux.md)) |
|
||||
| `npm run lint` | Run linter |
|
||||
| `npm test` | Run frontend tests |
|
||||
| `npm run test:backend` | Run backend tests |
|
||||
@@ -316,3 +211,11 @@ We welcome contributions! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for:
|
||||
Auto Claude is free to use. If you modify and distribute it, or run it as a service, your code must also be open source under AGPL-3.0.
|
||||
|
||||
Commercial licensing available for closed-source use cases.
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://github.com/AndyMik90/Auto-Claude/stargazers)
|
||||
|
||||
[](https://star-history.com/#AndyMik90/Auto-Claude&Date)
|
||||
|
||||
@@ -26,7 +26,7 @@ auto-claude/agents/
|
||||
### `utils.py` (3.6 KB)
|
||||
- Git operations: `get_latest_commit()`, `get_commit_count()`
|
||||
- Plan management: `load_implementation_plan()`, `find_subtask_in_plan()`, `find_phase_for_subtask()`
|
||||
- Workspace sync: `sync_plan_to_source()`
|
||||
- Workspace sync: `sync_spec_to_source()`
|
||||
|
||||
### `memory.py` (13 KB)
|
||||
- Dual-layer memory system (Graphiti primary, file-based fallback)
|
||||
@@ -73,7 +73,7 @@ from agents import (
|
||||
# Utilities
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_plan_to_source,
|
||||
sync_spec_to_source,
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ This module provides:
|
||||
Uses lazy imports to avoid circular dependencies.
|
||||
"""
|
||||
|
||||
# Explicit import required by CodeQL static analysis
|
||||
# (CodeQL doesn't recognize __getattr__ dynamic exports)
|
||||
from .utils import sync_spec_to_source
|
||||
|
||||
__all__ = [
|
||||
# Main API
|
||||
"run_autonomous_agent",
|
||||
@@ -32,7 +36,7 @@ __all__ = [
|
||||
"load_implementation_plan",
|
||||
"find_subtask_in_plan",
|
||||
"find_phase_for_subtask",
|
||||
"sync_plan_to_source",
|
||||
"sync_spec_to_source",
|
||||
# Constants
|
||||
"AUTO_CONTINUE_DELAY_SECONDS",
|
||||
"HUMAN_INTERVENTION_FILE",
|
||||
@@ -77,7 +81,7 @@ def __getattr__(name):
|
||||
"get_commit_count",
|
||||
"get_latest_commit",
|
||||
"load_implementation_plan",
|
||||
"sync_plan_to_source",
|
||||
"sync_spec_to_source",
|
||||
):
|
||||
from .utils import (
|
||||
find_phase_for_subtask,
|
||||
@@ -85,7 +89,7 @@ def __getattr__(name):
|
||||
get_commit_count,
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_plan_to_source,
|
||||
sync_spec_to_source,
|
||||
)
|
||||
|
||||
return locals()[name]
|
||||
|
||||
@@ -62,7 +62,7 @@ from .utils import (
|
||||
get_commit_count,
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_plan_to_source,
|
||||
sync_spec_to_source,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -404,7 +404,7 @@ async def run_autonomous_agent(
|
||||
print_status("Linear notified of stuck subtask", "info")
|
||||
elif is_planning_phase and source_spec_dir:
|
||||
# After planning phase, sync the newly created implementation plan back to source
|
||||
if sync_plan_to_source(spec_dir, source_spec_dir):
|
||||
if sync_spec_to_source(spec_dir, source_spec_dir):
|
||||
print_status("Implementation plan synced to main project", "success")
|
||||
|
||||
# Handle session status
|
||||
|
||||
@@ -40,7 +40,7 @@ from .utils import (
|
||||
get_commit_count,
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_plan_to_source,
|
||||
sync_spec_to_source,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -82,7 +82,7 @@ async def post_session_processing(
|
||||
print(muted("--- Post-Session Processing ---"))
|
||||
|
||||
# Sync implementation plan back to source (for worktree mode)
|
||||
if sync_plan_to_source(spec_dir, source_spec_dir):
|
||||
if sync_spec_to_source(spec_dir, source_spec_dir):
|
||||
print_status("Implementation plan synced to main project", "success")
|
||||
|
||||
# Check if implementation plan was updated
|
||||
|
||||
@@ -4,9 +4,16 @@ Session Memory Tools
|
||||
|
||||
Tools for recording and retrieving session memory, including discoveries,
|
||||
gotchas, and patterns.
|
||||
|
||||
Dual-storage approach:
|
||||
- File-based: Always available, works offline, spec-specific
|
||||
- LadybugDB: When Graphiti is enabled, also saves to graph database for
|
||||
cross-session retrieval and Memory UI display
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -19,6 +26,79 @@ except ImportError:
|
||||
SDK_TOOLS_AVAILABLE = False
|
||||
tool = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _save_to_graphiti_sync(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
save_type: str,
|
||||
data: dict,
|
||||
) -> bool:
|
||||
"""
|
||||
Save data to Graphiti/LadybugDB (synchronous wrapper for async operation).
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory for GraphitiMemory initialization
|
||||
project_dir: Project root directory
|
||||
save_type: Type of save - 'discovery', 'gotcha', or 'pattern'
|
||||
data: Data to save
|
||||
|
||||
Returns:
|
||||
True if save succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Check if Graphiti is enabled
|
||||
from graphiti_config import is_graphiti_enabled
|
||||
|
||||
if not is_graphiti_enabled():
|
||||
return False
|
||||
|
||||
from integrations.graphiti.queries_pkg.graphiti import GraphitiMemory
|
||||
|
||||
async def _async_save():
|
||||
memory = GraphitiMemory(spec_dir, project_dir)
|
||||
try:
|
||||
if save_type == "discovery":
|
||||
# Save as codebase discovery
|
||||
# Format: {file_path: description}
|
||||
result = await memory.save_codebase_discoveries(
|
||||
{data["file_path"]: data["description"]}
|
||||
)
|
||||
elif save_type == "gotcha":
|
||||
# Save as gotcha
|
||||
gotcha_text = data["gotcha"]
|
||||
if data.get("context"):
|
||||
gotcha_text += f" (Context: {data['context']})"
|
||||
result = await memory.save_gotcha(gotcha_text)
|
||||
elif save_type == "pattern":
|
||||
# Save as pattern
|
||||
result = await memory.save_pattern(data["pattern"])
|
||||
else:
|
||||
result = False
|
||||
return result
|
||||
finally:
|
||||
await memory.close()
|
||||
|
||||
# Run async operation in event loop
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
# If we're already in an async context, schedule the task
|
||||
# Don't block - just fire and forget for the Graphiti save
|
||||
# The file-based save is the primary, Graphiti is supplementary
|
||||
asyncio.ensure_future(_async_save())
|
||||
return False # Can't confirm async success, file-based is source of truth
|
||||
except RuntimeError:
|
||||
# No running loop, create one
|
||||
return asyncio.run(_async_save())
|
||||
|
||||
except ImportError as e:
|
||||
logger.debug(f"Graphiti not available for memory tools: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save to Graphiti: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
"""
|
||||
@@ -45,7 +125,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
{"file_path": str, "description": str, "category": str},
|
||||
)
|
||||
async def record_discovery(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Record a discovery to the codebase map."""
|
||||
"""Record a discovery to the codebase map (file + Graphiti)."""
|
||||
file_path = args["file_path"]
|
||||
description = args["description"]
|
||||
category = args.get("category", "general")
|
||||
@@ -54,8 +134,10 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
memory_dir.mkdir(exist_ok=True)
|
||||
|
||||
codebase_map_file = memory_dir / "codebase_map.json"
|
||||
saved_to_graphiti = False
|
||||
|
||||
try:
|
||||
# PRIMARY: Save to file-based storage (always works)
|
||||
# Load existing map or create new
|
||||
if codebase_map_file.exists():
|
||||
with open(codebase_map_file) as f:
|
||||
@@ -77,11 +159,23 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
with open(codebase_map_file, "w") as f:
|
||||
json.dump(codebase_map, f, indent=2)
|
||||
|
||||
# SECONDARY: Also save to Graphiti/LadybugDB (for Memory UI)
|
||||
saved_to_graphiti = _save_to_graphiti_sync(
|
||||
spec_dir,
|
||||
project_dir,
|
||||
"discovery",
|
||||
{
|
||||
"file_path": file_path,
|
||||
"description": f"[{category}] {description}",
|
||||
},
|
||||
)
|
||||
|
||||
storage_note = " (also saved to memory graph)" if saved_to_graphiti else ""
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Recorded discovery for '{file_path}': {description}",
|
||||
"text": f"Recorded discovery for '{file_path}': {description}{storage_note}",
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -102,7 +196,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
{"gotcha": str, "context": str},
|
||||
)
|
||||
async def record_gotcha(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Record a gotcha to session memory."""
|
||||
"""Record a gotcha to session memory (file + Graphiti)."""
|
||||
gotcha = args["gotcha"]
|
||||
context = args.get("context", "")
|
||||
|
||||
@@ -110,8 +204,10 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
memory_dir.mkdir(exist_ok=True)
|
||||
|
||||
gotchas_file = memory_dir / "gotchas.md"
|
||||
saved_to_graphiti = False
|
||||
|
||||
try:
|
||||
# PRIMARY: Save to file-based storage (always works)
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
entry = f"\n## [{timestamp}]\n{gotcha}"
|
||||
@@ -126,7 +222,20 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
)
|
||||
f.write(entry)
|
||||
|
||||
return {"content": [{"type": "text", "text": f"Recorded gotcha: {gotcha}"}]}
|
||||
# SECONDARY: Also save to Graphiti/LadybugDB (for Memory UI)
|
||||
saved_to_graphiti = _save_to_graphiti_sync(
|
||||
spec_dir,
|
||||
project_dir,
|
||||
"gotcha",
|
||||
{"gotcha": gotcha, "context": context},
|
||||
)
|
||||
|
||||
storage_note = " (also saved to memory graph)" if saved_to_graphiti else ""
|
||||
return {
|
||||
"content": [
|
||||
{"type": "text", "text": f"Recorded gotcha: {gotcha}{storage_note}"}
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
|
||||
@@ -23,9 +23,10 @@ def get_latest_commit(project_dir: Path) -> str | None:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
timeout=10,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except subprocess.CalledProcessError:
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
|
||||
return None
|
||||
|
||||
|
||||
@@ -38,9 +39,10 @@ def get_commit_count(project_dir: Path) -> int:
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
timeout=10,
|
||||
)
|
||||
return int(result.stdout.strip())
|
||||
except (subprocess.CalledProcessError, ValueError):
|
||||
except (subprocess.CalledProcessError, subprocess.TimeoutExpired, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
@@ -74,16 +76,32 @@ def find_phase_for_subtask(plan: dict, subtask_id: str) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def sync_plan_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool:
|
||||
def sync_spec_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool:
|
||||
"""
|
||||
Sync implementation_plan.json from worktree back to source spec directory.
|
||||
Sync ALL spec files from worktree back to source spec directory.
|
||||
|
||||
When running in isolated mode (worktrees), the agent updates the implementation
|
||||
plan inside the worktree. This function syncs those changes back to the main
|
||||
project's spec directory so the frontend/UI can see the progress.
|
||||
When running in isolated mode (worktrees), the agent creates and updates
|
||||
many files inside the worktree's spec directory. This function syncs ALL
|
||||
of them back to the main project's spec directory.
|
||||
|
||||
IMPORTANT: Since .auto-claude/ is gitignored, this sync happens to the
|
||||
local filesystem regardless of what branch the user is on. The worktree
|
||||
may be on a different branch (e.g., auto-claude/093-task), but the sync
|
||||
target is always the main project's .auto-claude/specs/ directory.
|
||||
|
||||
Files synced (all files in spec directory):
|
||||
- implementation_plan.json - Task status and subtask completion
|
||||
- build-progress.txt - Session-by-session progress notes
|
||||
- task_logs.json - Execution logs
|
||||
- review_state.json - QA review state
|
||||
- critique_report.json - Spec critique findings
|
||||
- suggested_commit_message.txt - Commit suggestions
|
||||
- REGRESSION_TEST_REPORT.md - Test regression report
|
||||
- spec.md, context.json, etc. - Original spec files (for completeness)
|
||||
- memory/ directory - Codebase map, patterns, gotchas, session insights
|
||||
|
||||
Args:
|
||||
spec_dir: Current spec directory (may be inside worktree)
|
||||
spec_dir: Current spec directory (inside worktree)
|
||||
source_spec_dir: Original spec directory in main project (outside worktree)
|
||||
|
||||
Returns:
|
||||
@@ -100,17 +118,68 @@ def sync_plan_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool:
|
||||
if spec_dir_resolved == source_spec_dir_resolved:
|
||||
return False # Same directory, no sync needed
|
||||
|
||||
# Sync the implementation plan
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if not plan_file.exists():
|
||||
return False
|
||||
synced_any = False
|
||||
|
||||
source_plan_file = source_spec_dir / "implementation_plan.json"
|
||||
# Ensure source directory exists
|
||||
source_spec_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
shutil.copy2(plan_file, source_plan_file)
|
||||
logger.debug(f"Synced implementation plan to source: {source_plan_file}")
|
||||
return True
|
||||
# Sync all files and directories from worktree spec to source spec
|
||||
for item in spec_dir.iterdir():
|
||||
# Skip symlinks to prevent path traversal attacks
|
||||
if item.is_symlink():
|
||||
logger.warning(f"Skipping symlink during sync: {item.name}")
|
||||
continue
|
||||
|
||||
source_item = source_spec_dir / item.name
|
||||
|
||||
if item.is_file():
|
||||
# Copy file (preserves timestamps)
|
||||
shutil.copy2(item, source_item)
|
||||
logger.debug(f"Synced {item.name} to source")
|
||||
synced_any = True
|
||||
|
||||
elif item.is_dir():
|
||||
# Recursively sync directory
|
||||
_sync_directory(item, source_item)
|
||||
synced_any = True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to sync implementation plan to source: {e}")
|
||||
return False
|
||||
logger.warning(f"Failed to sync spec directory to source: {e}")
|
||||
|
||||
return synced_any
|
||||
|
||||
|
||||
def _sync_directory(source_dir: Path, target_dir: Path) -> None:
|
||||
"""
|
||||
Recursively sync a directory from source to target.
|
||||
|
||||
Args:
|
||||
source_dir: Source directory (in worktree)
|
||||
target_dir: Target directory (in main project)
|
||||
"""
|
||||
# Create target directory if needed
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for item in source_dir.iterdir():
|
||||
# Skip symlinks to prevent path traversal attacks
|
||||
if item.is_symlink():
|
||||
logger.warning(
|
||||
f"Skipping symlink during sync: {source_dir.name}/{item.name}"
|
||||
)
|
||||
continue
|
||||
|
||||
target_item = target_dir / item.name
|
||||
|
||||
if item.is_file():
|
||||
shutil.copy2(item, target_item)
|
||||
logger.debug(f"Synced {source_dir.name}/{item.name} to source")
|
||||
elif item.is_dir():
|
||||
# Recurse into subdirectories
|
||||
_sync_directory(item, target_item)
|
||||
|
||||
|
||||
# Keep the old name as an alias for backward compatibility
|
||||
def sync_plan_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool:
|
||||
"""Alias for sync_spec_to_source for backward compatibility."""
|
||||
return sync_spec_to_source(spec_dir, source_spec_dir)
|
||||
|
||||
@@ -6,6 +6,8 @@ Commands for creating and managing multiple tasks from batch files.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from ui import highlight, print_status
|
||||
@@ -212,5 +214,53 @@ def handle_batch_cleanup_command(project_dir: str, dry_run: bool = True) -> bool
|
||||
print(f" └─ .auto-claude/worktrees/tasks/{spec_name}/")
|
||||
print()
|
||||
print("Run with --no-dry-run to actually delete")
|
||||
else:
|
||||
# Actually delete specs and worktrees
|
||||
deleted_count = 0
|
||||
for spec_name in completed:
|
||||
spec_path = specs_dir / spec_name
|
||||
wt_path = worktrees_dir / spec_name
|
||||
|
||||
# Remove worktree first (if exists)
|
||||
if wt_path.exists():
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "worktree", "remove", "--force", str(wt_path)],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print_status(f"Removed worktree: {spec_name}", "success")
|
||||
else:
|
||||
# Fallback: remove directory manually if git fails
|
||||
shutil.rmtree(wt_path, ignore_errors=True)
|
||||
print_status(
|
||||
f"Removed worktree directory: {spec_name}", "success"
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
# Timeout: fall back to manual removal
|
||||
shutil.rmtree(wt_path, ignore_errors=True)
|
||||
print_status(
|
||||
f"Worktree removal timed out, removed directory: {spec_name}",
|
||||
"warning",
|
||||
)
|
||||
except Exception as e:
|
||||
print_status(
|
||||
f"Failed to remove worktree {spec_name}: {e}", "warning"
|
||||
)
|
||||
|
||||
# Remove spec directory
|
||||
if spec_path.exists():
|
||||
try:
|
||||
shutil.rmtree(spec_path)
|
||||
print_status(f"Removed spec: {spec_name}", "success")
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
print_status(f"Failed to remove spec {spec_name}: {e}", "error")
|
||||
|
||||
print()
|
||||
print_status(f"Cleaned up {deleted_count} spec(s)", "info")
|
||||
|
||||
return True
|
||||
|
||||
@@ -79,7 +79,7 @@ def handle_build_command(
|
||||
base_branch: Base branch for worktree creation (default: current branch)
|
||||
"""
|
||||
# Lazy imports to avoid loading heavy modules
|
||||
from agent import run_autonomous_agent, sync_plan_to_source
|
||||
from agent import run_autonomous_agent, sync_spec_to_source
|
||||
from debug import (
|
||||
debug,
|
||||
debug_info,
|
||||
@@ -274,7 +274,7 @@ def handle_build_command(
|
||||
|
||||
# Sync implementation plan to main project after QA
|
||||
# This ensures the main project has the latest status (human_review)
|
||||
if sync_plan_to_source(spec_dir, source_spec_dir):
|
||||
if sync_spec_to_source(spec_dir, source_spec_dir):
|
||||
debug_info(
|
||||
"run.py", "Implementation plan synced to main project after QA"
|
||||
)
|
||||
|
||||
@@ -67,6 +67,7 @@ def _detect_default_branch(project_dir: Path) -> str:
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return env_branch
|
||||
@@ -78,6 +79,7 @@ def _detect_default_branch(project_dir: Path) -> str:
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return branch
|
||||
@@ -90,18 +92,32 @@ def _get_changed_files_from_git(
|
||||
worktree_path: Path, base_branch: str = "main"
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get list of changed files from git diff between base branch and HEAD.
|
||||
Get list of files changed by the task (not files changed on base branch).
|
||||
|
||||
Uses merge-base to accurately identify only the files modified in the worktree,
|
||||
not files that changed on the base branch since the worktree was created.
|
||||
|
||||
Args:
|
||||
worktree_path: Path to the worktree
|
||||
base_branch: Base branch to compare against (default: main)
|
||||
|
||||
Returns:
|
||||
List of changed file paths
|
||||
List of changed file paths (task changes only)
|
||||
"""
|
||||
try:
|
||||
# First, get the merge-base (the point where the worktree branched)
|
||||
merge_base_result = subprocess.run(
|
||||
["git", "merge-base", base_branch, "HEAD"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
merge_base = merge_base_result.stdout.strip()
|
||||
|
||||
# Use two-dot diff from merge-base to get only task's changes
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", f"{base_branch}...HEAD"],
|
||||
["git", "diff", "--name-only", f"{merge_base}..HEAD"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -113,10 +129,10 @@ def _get_changed_files_from_git(
|
||||
# Log the failure before trying fallback
|
||||
debug_warning(
|
||||
"workspace_commands",
|
||||
f"git diff (three-dot) failed: returncode={e.returncode}, "
|
||||
f"git diff with merge-base failed: returncode={e.returncode}, "
|
||||
f"stderr={e.stderr.strip() if e.stderr else 'N/A'}",
|
||||
)
|
||||
# Fallback: try without the three-dot notation
|
||||
# Fallback: try direct two-arg diff (less accurate but works)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", base_branch, "HEAD"],
|
||||
@@ -131,7 +147,7 @@ def _get_changed_files_from_git(
|
||||
# Log the failure before returning empty list
|
||||
debug_warning(
|
||||
"workspace_commands",
|
||||
f"git diff (two-arg) failed: returncode={e.returncode}, "
|
||||
f"git diff (fallback) failed: returncode={e.returncode}, "
|
||||
f"stderr={e.stderr.strip() if e.stderr else 'N/A'}",
|
||||
)
|
||||
return []
|
||||
@@ -600,6 +616,13 @@ def handle_merge_preview_command(
|
||||
changed_files=all_changed_files[:10], # Log first 10
|
||||
)
|
||||
|
||||
# NOTE: We intentionally do NOT have a fast path here.
|
||||
# Even if commits_behind == 0 (main hasn't moved), we still need to:
|
||||
# 1. Call refresh_from_git() to update evolution data for this task
|
||||
# 2. Call preview_merge() to detect potential conflicts with OTHER parallel tasks
|
||||
# that may be tracked in the evolution data but haven't been merged yet.
|
||||
# Skipping semantic analysis when commits_behind == 0 would miss these conflicts.
|
||||
|
||||
debug(MODULE, "Initializing MergeOrchestrator for preview...")
|
||||
|
||||
# Initialize the orchestrator
|
||||
|
||||
@@ -39,7 +39,7 @@ from agents import (
|
||||
run_followup_planner,
|
||||
save_session_memory,
|
||||
save_session_to_graphiti,
|
||||
sync_plan_to_source,
|
||||
sync_spec_to_source,
|
||||
)
|
||||
|
||||
# Ensure all exports are available at module level
|
||||
@@ -57,7 +57,7 @@ __all__ = [
|
||||
"load_implementation_plan",
|
||||
"find_subtask_in_plan",
|
||||
"find_phase_for_subtask",
|
||||
"sync_plan_to_source",
|
||||
"sync_spec_to_source",
|
||||
"AUTO_CONTINUE_DELAY_SECONDS",
|
||||
"HUMAN_INTERVENTION_FILE",
|
||||
]
|
||||
|
||||
@@ -36,6 +36,8 @@ SDK_ENV_VARS = [
|
||||
"DISABLE_TELEMETRY",
|
||||
"DISABLE_COST_WARNINGS",
|
||||
"API_TIMEOUT_MS",
|
||||
# Windows-specific: Git Bash path for Claude Code CLI
|
||||
"CLAUDE_CODE_GIT_BASH_PATH",
|
||||
]
|
||||
|
||||
|
||||
@@ -215,6 +217,85 @@ def require_auth_token() -> str:
|
||||
return token
|
||||
|
||||
|
||||
def _find_git_bash_path() -> str | None:
|
||||
"""
|
||||
Find git-bash (bash.exe) path on Windows.
|
||||
|
||||
Uses 'where git' to find git.exe, then derives bash.exe location from it.
|
||||
Git for Windows installs bash.exe in the 'bin' directory alongside git.exe
|
||||
or in the parent 'bin' directory when git.exe is in 'cmd'.
|
||||
|
||||
Returns:
|
||||
Full path to bash.exe if found, None otherwise
|
||||
"""
|
||||
if platform.system() != "Windows":
|
||||
return None
|
||||
|
||||
# If already set in environment, use that
|
||||
existing = os.environ.get("CLAUDE_CODE_GIT_BASH_PATH")
|
||||
if existing and os.path.exists(existing):
|
||||
return existing
|
||||
|
||||
git_path = None
|
||||
|
||||
# Method 1: Use 'where' command to find git.exe
|
||||
try:
|
||||
# Use where.exe explicitly for reliability
|
||||
result = subprocess.run(
|
||||
["where.exe", "git"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
shell=False,
|
||||
)
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
git_paths = result.stdout.strip().splitlines()
|
||||
if git_paths:
|
||||
git_path = git_paths[0].strip()
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
|
||||
# Intentionally suppress errors - best-effort detection with fallback to common paths
|
||||
pass
|
||||
|
||||
# Method 2: Check common installation paths if 'where' didn't work
|
||||
if not git_path:
|
||||
common_git_paths = [
|
||||
os.path.expandvars(r"%PROGRAMFILES%\Git\cmd\git.exe"),
|
||||
os.path.expandvars(r"%PROGRAMFILES%\Git\bin\git.exe"),
|
||||
os.path.expandvars(r"%PROGRAMFILES(X86)%\Git\cmd\git.exe"),
|
||||
os.path.expandvars(r"%LOCALAPPDATA%\Programs\Git\cmd\git.exe"),
|
||||
]
|
||||
for path in common_git_paths:
|
||||
if os.path.exists(path):
|
||||
git_path = path
|
||||
break
|
||||
|
||||
if not git_path:
|
||||
return None
|
||||
|
||||
# Derive bash.exe location from git.exe location
|
||||
# Git for Windows structure:
|
||||
# C:\...\Git\cmd\git.exe -> bash.exe is at C:\...\Git\bin\bash.exe
|
||||
# C:\...\Git\bin\git.exe -> bash.exe is at C:\...\Git\bin\bash.exe
|
||||
# C:\...\Git\mingw64\bin\git.exe -> bash.exe is at C:\...\Git\bin\bash.exe
|
||||
git_dir = os.path.dirname(git_path)
|
||||
git_parent = os.path.dirname(git_dir)
|
||||
git_grandparent = os.path.dirname(git_parent)
|
||||
|
||||
# Check common bash.exe locations relative to git installation
|
||||
possible_bash_paths = [
|
||||
os.path.join(git_parent, "bin", "bash.exe"), # cmd -> bin
|
||||
os.path.join(git_dir, "bash.exe"), # If git.exe is in bin
|
||||
os.path.join(git_grandparent, "bin", "bash.exe"), # mingw64/bin -> bin
|
||||
]
|
||||
|
||||
for bash_path in possible_bash_paths:
|
||||
if os.path.exists(bash_path):
|
||||
return bash_path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_sdk_env_vars() -> dict[str, str]:
|
||||
"""
|
||||
Get environment variables to pass to SDK.
|
||||
@@ -222,6 +303,8 @@ def get_sdk_env_vars() -> dict[str, str]:
|
||||
Collects relevant env vars (ANTHROPIC_BASE_URL, etc.) that should
|
||||
be passed through to the claude-agent-sdk subprocess.
|
||||
|
||||
On Windows, auto-detects CLAUDE_CODE_GIT_BASH_PATH if not already set.
|
||||
|
||||
Returns:
|
||||
Dict of env var name -> value for non-empty vars
|
||||
"""
|
||||
@@ -230,6 +313,14 @@ def get_sdk_env_vars() -> dict[str, str]:
|
||||
value = os.environ.get(var)
|
||||
if value:
|
||||
env[var] = value
|
||||
|
||||
# On Windows, auto-detect git-bash path if not already set
|
||||
# Claude Code CLI requires bash.exe to run on Windows
|
||||
if platform.system() == "Windows" and "CLAUDE_CODE_GIT_BASH_PATH" not in env:
|
||||
bash_path = _find_git_bash_path()
|
||||
if bash_path:
|
||||
env["CLAUDE_CODE_GIT_BASH_PATH"] = bash_path
|
||||
|
||||
return env
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -488,6 +489,12 @@ def create_client(
|
||||
# Collect env vars to pass to SDK (ANTHROPIC_BASE_URL, etc.)
|
||||
sdk_env = get_sdk_env_vars()
|
||||
|
||||
# Debug: Log git-bash path detection on Windows
|
||||
if "CLAUDE_CODE_GIT_BASH_PATH" in sdk_env:
|
||||
logger.info(f"Git Bash path found: {sdk_env['CLAUDE_CODE_GIT_BASH_PATH']}")
|
||||
elif platform.system() == "Windows":
|
||||
logger.warning("Git Bash path not detected on Windows!")
|
||||
|
||||
# Check if Linear integration is enabled
|
||||
linear_enabled = is_linear_enabled()
|
||||
linear_api_key = os.environ.get("LINEAR_API_KEY", "")
|
||||
|
||||
@@ -52,4 +52,8 @@ def emit_phase(
|
||||
print(f"{PHASE_MARKER_PREFIX}{json.dumps(payload, default=str)}", flush=True)
|
||||
except (OSError, UnicodeEncodeError) as e:
|
||||
if _DEBUG:
|
||||
print(f"[phase_event] emit failed: {e}", file=sys.stderr, flush=True)
|
||||
try:
|
||||
sys.stderr.write(f"[phase_event] emit failed: {e}\n")
|
||||
sys.stderr.flush()
|
||||
except (OSError, UnicodeEncodeError):
|
||||
pass # Truly silent on complete I/O failure
|
||||
|
||||
@@ -267,6 +267,15 @@ def setup_workspace(
|
||||
f"Environment files copied: {', '.join(copied_env_files)}", "success"
|
||||
)
|
||||
|
||||
# Ensure .auto-claude/ is in the worktree's .gitignore
|
||||
# This is critical because the worktree inherits .gitignore from the base branch,
|
||||
# which may not have .auto-claude/ if that change wasn't committed/pushed.
|
||||
# Without this, spec files would be committed to the worktree's branch.
|
||||
from init import ensure_gitignore_entry
|
||||
|
||||
if ensure_gitignore_entry(worktree_info.path, ".auto-claude/"):
|
||||
debug(MODULE, "Added .auto-claude/ to worktree's .gitignore")
|
||||
|
||||
# Copy spec files to worktree if provided
|
||||
localized_spec_dir = None
|
||||
if source_spec_dir and source_spec_dir.exists():
|
||||
|
||||
@@ -124,17 +124,37 @@ class WorktreeManager:
|
||||
return result.stdout.strip()
|
||||
|
||||
def _run_git(
|
||||
self, args: list[str], cwd: Path | None = None
|
||||
self, args: list[str], cwd: Path | None = None, timeout: int = 60
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run a git command and return the result."""
|
||||
return subprocess.run(
|
||||
["git"] + args,
|
||||
cwd=cwd or self.project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
"""Run a git command and return the result.
|
||||
|
||||
Args:
|
||||
args: Git command arguments (without 'git' prefix)
|
||||
cwd: Working directory for the command
|
||||
timeout: Command timeout in seconds (default: 60)
|
||||
|
||||
Returns:
|
||||
CompletedProcess with command results. On timeout, returns a
|
||||
CompletedProcess with returncode=-1 and timeout error in stderr.
|
||||
"""
|
||||
try:
|
||||
return subprocess.run(
|
||||
["git"] + args,
|
||||
cwd=cwd or self.project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
# Return a failed result on timeout instead of raising
|
||||
return subprocess.CompletedProcess(
|
||||
args=["git"] + args,
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr=f"Command timed out after {timeout} seconds",
|
||||
)
|
||||
|
||||
def _unstage_gitignored_files(self) -> None:
|
||||
"""
|
||||
@@ -327,9 +347,33 @@ class WorktreeManager:
|
||||
# Delete branch if it exists (from previous attempt)
|
||||
self._run_git(["branch", "-D", branch_name])
|
||||
|
||||
# Create worktree with new branch from base
|
||||
# Fetch latest from remote to ensure we have the most up-to-date code
|
||||
# GitHub/remote is the source of truth, not the local branch
|
||||
fetch_result = self._run_git(["fetch", "origin", self.base_branch])
|
||||
if fetch_result.returncode != 0:
|
||||
print(
|
||||
f"Warning: Could not fetch {self.base_branch} from origin: {fetch_result.stderr}"
|
||||
)
|
||||
print("Falling back to local branch...")
|
||||
|
||||
# Determine the start point for the worktree
|
||||
# Prefer origin/{base_branch} (remote) over local branch to ensure we have latest code
|
||||
remote_ref = f"origin/{self.base_branch}"
|
||||
start_point = self.base_branch # Default to local branch
|
||||
|
||||
# Check if remote ref exists and use it as the source of truth
|
||||
check_remote = self._run_git(["rev-parse", "--verify", remote_ref])
|
||||
if check_remote.returncode == 0:
|
||||
start_point = remote_ref
|
||||
print(f"Creating worktree from remote: {remote_ref}")
|
||||
else:
|
||||
print(
|
||||
f"Remote ref {remote_ref} not found, using local branch: {self.base_branch}"
|
||||
)
|
||||
|
||||
# Create worktree with new branch from the start point (remote preferred)
|
||||
result = self._run_git(
|
||||
["worktree", "add", "-b", branch_name, str(worktree_path), self.base_branch]
|
||||
["worktree", "add", "-b", branch_name, str(worktree_path), start_point]
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
|
||||
@@ -87,8 +87,8 @@ class ModificationTracker:
|
||||
|
||||
# Get or create evolution
|
||||
if rel_path not in evolutions:
|
||||
logger.warning(f"File {rel_path} not being tracked")
|
||||
# Note: We could auto-create here, but for now return None
|
||||
# Debug level: this is expected for files not in baseline (e.g., from main's changes)
|
||||
logger.debug(f"File {rel_path} not in evolution tracking - skipping")
|
||||
return None
|
||||
|
||||
evolution = evolutions.get(rel_path)
|
||||
@@ -157,9 +157,21 @@ class ModificationTracker:
|
||||
)
|
||||
|
||||
try:
|
||||
# Get list of files changed in the worktree vs target branch
|
||||
# Get the merge-base to accurately identify task-only changes
|
||||
# Using two-dot diff (merge-base..HEAD) returns only files changed by the task,
|
||||
# not files changed on the target branch since divergence
|
||||
merge_base_result = subprocess.run(
|
||||
["git", "merge-base", target_branch, "HEAD"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
merge_base = merge_base_result.stdout.strip()
|
||||
|
||||
# Get list of files changed in the worktree since the merge-base
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", f"{target_branch}...HEAD"],
|
||||
["git", "diff", "--name-only", f"{merge_base}..HEAD"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -176,19 +188,19 @@ class ModificationTracker:
|
||||
)
|
||||
|
||||
for file_path in changed_files:
|
||||
# Get the diff for this file
|
||||
# Get the diff for this file (using merge-base for accurate task-only diff)
|
||||
diff_result = subprocess.run(
|
||||
["git", "diff", f"{target_branch}...HEAD", "--", file_path],
|
||||
["git", "diff", f"{merge_base}..HEAD", "--", file_path],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Get content before (from target branch) and after (current)
|
||||
# Get content before (from merge-base - the point where task branched)
|
||||
try:
|
||||
show_result = subprocess.run(
|
||||
["git", "show", f"{target_branch}:{file_path}"],
|
||||
["git", "show", f"{merge_base}:{file_path}"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
|
||||
@@ -19,6 +19,35 @@ from pathlib import Path
|
||||
from .types import ChangeType, SemanticChange, TaskSnapshot
|
||||
|
||||
|
||||
def detect_line_ending(content: str) -> str:
|
||||
"""
|
||||
Detect line ending style in content using priority-based detection.
|
||||
|
||||
Uses a priority order (CRLF > CR > LF) to detect the line ending style.
|
||||
CRLF is checked first because it contains LF, so presence of any CRLF
|
||||
indicates Windows-style endings. This approach is fast and works well
|
||||
for files that consistently use one style.
|
||||
|
||||
Note: This returns the first detected style by priority, not the most
|
||||
frequent style. For files with mixed line endings, consider normalizing
|
||||
to a single style before processing.
|
||||
|
||||
Args:
|
||||
content: File content to analyze
|
||||
|
||||
Returns:
|
||||
The detected line ending string: "\\r\\n", "\\r", or "\\n"
|
||||
"""
|
||||
# Check for CRLF first (Windows) - must check before LF since CRLF contains LF
|
||||
if "\r\n" in content:
|
||||
return "\r\n"
|
||||
# Check for CR (classic Mac, rare but possible)
|
||||
if "\r" in content:
|
||||
return "\r"
|
||||
# Default to LF (Unix/modern Mac)
|
||||
return "\n"
|
||||
|
||||
|
||||
def apply_single_task_changes(
|
||||
baseline: str,
|
||||
snapshot: TaskSnapshot,
|
||||
@@ -37,6 +66,9 @@ def apply_single_task_changes(
|
||||
"""
|
||||
content = baseline
|
||||
|
||||
# Detect line ending style once at the start to use consistently
|
||||
line_ending = detect_line_ending(content)
|
||||
|
||||
for change in snapshot.semantic_changes:
|
||||
if change.content_before and change.content_after:
|
||||
# Modification - replace
|
||||
@@ -45,14 +77,13 @@ def apply_single_task_changes(
|
||||
# Addition - need to determine where to add
|
||||
if change.change_type == ChangeType.ADD_IMPORT:
|
||||
# Add import at top
|
||||
# Use splitlines() to handle all line ending styles (LF, CRLF, CR)
|
||||
lines = content.splitlines()
|
||||
import_end = find_import_end(lines, file_path)
|
||||
lines.insert(import_end, change.content_after)
|
||||
content = "\n".join(lines)
|
||||
content = line_ending.join(lines)
|
||||
elif change.change_type == ChangeType.ADD_FUNCTION:
|
||||
# Add function at end (before exports)
|
||||
content += f"\n\n{change.content_after}"
|
||||
content += f"{line_ending}{line_ending}{change.content_after}"
|
||||
|
||||
return content
|
||||
|
||||
@@ -75,6 +106,9 @@ def combine_non_conflicting_changes(
|
||||
"""
|
||||
content = baseline
|
||||
|
||||
# Detect line ending style once at the start to use consistently
|
||||
line_ending = detect_line_ending(content)
|
||||
|
||||
# Group changes by type for proper ordering
|
||||
imports: list[SemanticChange] = []
|
||||
functions: list[SemanticChange] = []
|
||||
@@ -97,14 +131,13 @@ def combine_non_conflicting_changes(
|
||||
|
||||
# Add imports
|
||||
if imports:
|
||||
# Use splitlines() to handle all line ending styles (LF, CRLF, CR)
|
||||
lines = content.splitlines()
|
||||
import_end = find_import_end(lines, file_path)
|
||||
for imp in imports:
|
||||
if imp.content_after and imp.content_after not in content:
|
||||
lines.insert(import_end, imp.content_after)
|
||||
import_end += 1
|
||||
content = "\n".join(lines)
|
||||
content = line_ending.join(lines)
|
||||
|
||||
# Apply modifications
|
||||
for mod in modifications:
|
||||
@@ -114,12 +147,12 @@ def combine_non_conflicting_changes(
|
||||
# Add functions
|
||||
for func in functions:
|
||||
if func.content_after:
|
||||
content += f"\n\n{func.content_after}"
|
||||
content += f"{line_ending}{line_ending}{func.content_after}"
|
||||
|
||||
# Apply other changes
|
||||
for change in other:
|
||||
if change.content_after and not change.content_before:
|
||||
content += f"\n{change.content_after}"
|
||||
content += f"{line_ending}{change.content_after}"
|
||||
elif change.content_before and change.content_after:
|
||||
content = content.replace(change.content_before, change.content_after)
|
||||
|
||||
|
||||
@@ -634,7 +634,7 @@ The system **automatically scans for secrets** before every commit. If secrets a
|
||||
api_key = os.environ.get("API_KEY")
|
||||
```
|
||||
3. **Update .env.example** - Add placeholder for the new variable
|
||||
4. **Re-stage and retry** - `git add . && git commit ...`
|
||||
4. **Re-stage and retry** - `git add . ':!.auto-claude' && git commit ...`
|
||||
|
||||
**If it's a false positive:**
|
||||
- Add the file pattern to `.secretsignore` in the project root
|
||||
@@ -643,7 +643,8 @@ The system **automatically scans for secrets** before every commit. If secrets a
|
||||
### Create the Commit
|
||||
|
||||
```bash
|
||||
git add .
|
||||
# Add all files EXCEPT .auto-claude directory (spec files should never be committed)
|
||||
git add . ':!.auto-claude'
|
||||
git commit -m "auto-claude: Complete [subtask-id] - [subtask description]
|
||||
|
||||
- Files modified: [list]
|
||||
@@ -651,6 +652,9 @@ git commit -m "auto-claude: Complete [subtask-id] - [subtask description]
|
||||
- Phase progress: [X]/[Y] subtasks complete"
|
||||
```
|
||||
|
||||
**CRITICAL**: The `:!.auto-claude` pathspec exclusion ensures spec files are NEVER committed.
|
||||
These are internal tracking files that must stay local.
|
||||
|
||||
### DO NOT Push to Remote
|
||||
|
||||
**IMPORTANT**: Do NOT run `git push`. All work stays local until the user reviews and approves.
|
||||
@@ -956,6 +960,17 @@ Prepare → Test (small batch) → Execute (full) → Cleanup
|
||||
- Clean, working state
|
||||
- **Secret scan must pass before commit**
|
||||
|
||||
### Git Configuration - NEVER MODIFY
|
||||
**CRITICAL**: You MUST NOT modify git user configuration. Never run:
|
||||
- `git config user.name`
|
||||
- `git config user.email`
|
||||
- `git config --local user.*`
|
||||
- `git config --global user.*`
|
||||
|
||||
The repository inherits the user's configured git identity. Creating "Test User" or
|
||||
any other fake identity breaks attribution and causes serious issues. If you need
|
||||
to commit changes, use the existing git identity - do NOT set a new one.
|
||||
|
||||
### The Golden Rule
|
||||
**FIX BUGS NOW.** The next session has no memory.
|
||||
|
||||
|
||||
@@ -131,7 +131,21 @@ After all agents complete:
|
||||
|
||||
## Verdict Guidelines
|
||||
|
||||
### CRITICAL: CI Status ALWAYS Factors Into Verdict
|
||||
|
||||
**CI status is provided in the context and MUST be considered:**
|
||||
|
||||
- ❌ **Failing CI = BLOCKED** - If ANY CI checks are failing, verdict MUST be BLOCKED regardless of code quality
|
||||
- ⏳ **Pending CI = NEEDS_REVISION** - If CI is still running, verdict cannot be READY_TO_MERGE
|
||||
- ⏸️ **Awaiting approval = BLOCKED** - Fork PR workflows awaiting maintainer approval block merge
|
||||
- ✅ **All passing = Continue with code analysis** - Only then do code findings determine verdict
|
||||
|
||||
**Always mention CI status in your verdict_reasoning.** For example:
|
||||
- "BLOCKED: 2 CI checks failing (CodeQL, test-frontend). Fix CI before merge."
|
||||
- "READY_TO_MERGE: All CI checks passing and all findings resolved."
|
||||
|
||||
### READY_TO_MERGE
|
||||
- **All CI checks passing** (no failing, no pending)
|
||||
- All previous findings verified as resolved OR dismissed as false positives
|
||||
- No CONFIRMED_VALID critical/high issues remaining
|
||||
- No new critical/high issues
|
||||
@@ -139,11 +153,13 @@ After all agents complete:
|
||||
- Contributor questions addressed
|
||||
|
||||
### MERGE_WITH_CHANGES
|
||||
- **All CI checks passing**
|
||||
- Previous findings resolved
|
||||
- Only LOW severity new issues (suggestions)
|
||||
- Optional polish items can be addressed post-merge
|
||||
|
||||
### NEEDS_REVISION (Strict Quality Gates)
|
||||
- **CI checks pending** OR
|
||||
- HIGH or MEDIUM severity findings CONFIRMED_VALID (not dismissed as false positive)
|
||||
- New HIGH or MEDIUM severity issues introduced
|
||||
- Important contributor concerns unaddressed
|
||||
@@ -151,6 +167,8 @@ After all agents complete:
|
||||
- **Note: Only count findings that passed validation** (dismissed_false_positive findings don't block)
|
||||
|
||||
### BLOCKED
|
||||
- **Any CI checks failing** OR
|
||||
- **Workflows awaiting maintainer approval** (fork PRs) OR
|
||||
- CRITICAL findings remain CONFIRMED_VALID (not dismissed as false positive)
|
||||
- New CRITICAL issues introduced
|
||||
- Fundamental problems with the fix approach
|
||||
@@ -234,6 +252,7 @@ false positives persist forever and developers lose trust in the review system.
|
||||
|
||||
## Context You Will Receive
|
||||
|
||||
- **CI Status (CRITICAL)** - Passing/failing/pending checks and specific failed check names
|
||||
- Previous review summary and findings
|
||||
- New commits since last review (SHAs, messages)
|
||||
- Diff of changes since last review
|
||||
|
||||
@@ -167,7 +167,8 @@ If any issue is not fixed, go back to Phase 3.
|
||||
## PHASE 6: COMMIT FIXES
|
||||
|
||||
```bash
|
||||
git add .
|
||||
# Add all files EXCEPT .auto-claude directory (spec files should never be committed)
|
||||
git add . ':!.auto-claude'
|
||||
git commit -m "fix: Address QA issues (qa-requested)
|
||||
|
||||
Fixes:
|
||||
@@ -182,6 +183,8 @@ Verified:
|
||||
QA Fix Session: [N]"
|
||||
```
|
||||
|
||||
**CRITICAL**: The `:!.auto-claude` pathspec exclusion ensures spec files are NEVER committed.
|
||||
|
||||
**NOTE**: Do NOT push to remote. All work stays local until user reviews and approves.
|
||||
|
||||
---
|
||||
@@ -304,6 +307,13 @@ npx prisma migrate dev --name [name]
|
||||
- How you verified
|
||||
- Commit messages
|
||||
|
||||
### Git Configuration - NEVER MODIFY
|
||||
**CRITICAL**: You MUST NOT modify git user configuration. Never run:
|
||||
- `git config user.name`
|
||||
- `git config user.email`
|
||||
|
||||
The repository inherits the user's configured git identity. Do NOT set test users.
|
||||
|
||||
---
|
||||
|
||||
## QA LOOP BEHAVIOR
|
||||
|
||||
@@ -35,8 +35,8 @@ cat project_index.json
|
||||
# 4. Check build progress
|
||||
cat build-progress.txt
|
||||
|
||||
# 5. See what files were changed
|
||||
git diff main --name-only
|
||||
# 5. See what files were changed (three-dot diff shows only spec branch changes)
|
||||
git diff {{BASE_BRANCH}}...HEAD --name-status
|
||||
|
||||
# 6. Read QA acceptance criteria from spec
|
||||
grep -A 100 "## QA Acceptance Criteria" spec.md
|
||||
@@ -514,7 +514,7 @@ All acceptance criteria verified:
|
||||
The implementation is production-ready.
|
||||
Sign-off recorded in implementation_plan.json.
|
||||
|
||||
Ready for merge to main.
|
||||
Ready for merge to {{BASE_BRANCH}}.
|
||||
```
|
||||
|
||||
### If Rejected:
|
||||
|
||||
@@ -7,7 +7,9 @@ Supports dynamic prompt assembly based on project type for context optimization.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from .project_context import (
|
||||
@@ -16,6 +18,133 @@ from .project_context import (
|
||||
load_project_index,
|
||||
)
|
||||
|
||||
|
||||
def _validate_branch_name(branch: str | None) -> str | None:
|
||||
"""
|
||||
Validate a git branch name for safety and correctness.
|
||||
|
||||
Args:
|
||||
branch: The branch name to validate
|
||||
|
||||
Returns:
|
||||
The validated branch name, or None if invalid
|
||||
"""
|
||||
if not branch or not isinstance(branch, str):
|
||||
return None
|
||||
|
||||
# Trim whitespace
|
||||
branch = branch.strip()
|
||||
|
||||
# Reject empty or whitespace-only strings
|
||||
if not branch:
|
||||
return None
|
||||
|
||||
# Enforce maximum length (git refs can be long, but 255 is reasonable)
|
||||
if len(branch) > 255:
|
||||
return None
|
||||
|
||||
# Require at least one alphanumeric character
|
||||
if not any(c.isalnum() for c in branch):
|
||||
return None
|
||||
|
||||
# Only allow common git-ref characters: letters, numbers, ., _, -, /
|
||||
# This prevents prompt injection and other security issues
|
||||
if not re.match(r"^[A-Za-z0-9._/-]+$", branch):
|
||||
return None
|
||||
|
||||
# Reject suspicious patterns that could be prompt injection attempts
|
||||
# (newlines, control characters are already blocked by the regex above)
|
||||
|
||||
return branch
|
||||
|
||||
|
||||
def _get_base_branch_from_metadata(spec_dir: Path) -> str | None:
|
||||
"""
|
||||
Read baseBranch from task_metadata.json if it exists.
|
||||
|
||||
Args:
|
||||
spec_dir: Directory containing the spec files
|
||||
|
||||
Returns:
|
||||
The baseBranch from metadata, or None if not found or invalid
|
||||
"""
|
||||
metadata_path = spec_dir / "task_metadata.json"
|
||||
if metadata_path.exists():
|
||||
try:
|
||||
with open(metadata_path, encoding="utf-8") as f:
|
||||
metadata = json.load(f)
|
||||
base_branch = metadata.get("baseBranch")
|
||||
# Validate the branch name before returning
|
||||
return _validate_branch_name(base_branch)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _detect_base_branch(spec_dir: Path, project_dir: Path) -> str:
|
||||
"""
|
||||
Detect the base branch for a project/task.
|
||||
|
||||
Priority order:
|
||||
1. baseBranch from task_metadata.json (task-level override)
|
||||
2. DEFAULT_BRANCH environment variable
|
||||
3. Auto-detect main/master/develop (if they exist in git)
|
||||
4. Fall back to "main"
|
||||
|
||||
Args:
|
||||
spec_dir: Directory containing the spec files
|
||||
project_dir: Project root directory
|
||||
|
||||
Returns:
|
||||
The detected base branch name
|
||||
"""
|
||||
# 1. Check task_metadata.json for task-specific baseBranch
|
||||
metadata_branch = _get_base_branch_from_metadata(spec_dir)
|
||||
if metadata_branch:
|
||||
return metadata_branch
|
||||
|
||||
# 2. Check for DEFAULT_BRANCH env var
|
||||
env_branch = _validate_branch_name(os.getenv("DEFAULT_BRANCH"))
|
||||
if env_branch:
|
||||
# Verify the branch exists (with timeout to prevent hanging)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--verify", env_branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=3,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return env_branch
|
||||
except subprocess.TimeoutExpired:
|
||||
# Treat timeout as branch verification failure
|
||||
pass
|
||||
|
||||
# 3. Auto-detect main/master/develop
|
||||
for branch in ["main", "master", "develop"]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--verify", branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=3,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return branch
|
||||
except subprocess.TimeoutExpired:
|
||||
# Treat timeout as branch verification failure, try next branch
|
||||
continue
|
||||
|
||||
# 4. Fall back to "main"
|
||||
return "main"
|
||||
|
||||
|
||||
# Directory containing prompt files
|
||||
# prompts/ is a sibling directory of prompts_pkg/, so go up one level first
|
||||
PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
|
||||
@@ -304,6 +433,7 @@ def get_qa_reviewer_prompt(spec_dir: Path, project_dir: Path) -> str:
|
||||
1. Loads the base QA reviewer prompt
|
||||
2. Detects project capabilities from project_index.json
|
||||
3. Injects only relevant MCP tool documentation (Electron, Puppeteer, DB, API)
|
||||
4. Detects and injects the correct base branch for git comparisons
|
||||
|
||||
This saves context window by excluding irrelevant tool docs.
|
||||
For example, a CLI Python project won't get Electron validation docs.
|
||||
@@ -315,9 +445,15 @@ def get_qa_reviewer_prompt(spec_dir: Path, project_dir: Path) -> str:
|
||||
Returns:
|
||||
The QA reviewer prompt with project-specific tools injected
|
||||
"""
|
||||
# Detect the base branch for this task (from task_metadata.json or auto-detect)
|
||||
base_branch = _detect_base_branch(spec_dir, project_dir)
|
||||
|
||||
# Load base QA reviewer prompt
|
||||
base_prompt = _load_prompt_file("qa_reviewer.md")
|
||||
|
||||
# Replace {{BASE_BRANCH}} placeholder with the actual base branch
|
||||
base_prompt = base_prompt.replace("{{BASE_BRANCH}}", base_branch)
|
||||
|
||||
# Load project index and detect capabilities
|
||||
project_index = load_project_index(project_dir)
|
||||
capabilities = detect_project_capabilities(project_index)
|
||||
@@ -347,6 +483,17 @@ Your spec and progress files are located at:
|
||||
|
||||
The project root is: `{project_dir}`
|
||||
|
||||
## GIT BRANCH CONFIGURATION
|
||||
|
||||
**Base branch for comparison:** `{base_branch}`
|
||||
|
||||
When checking for unrelated changes, use three-dot diff syntax:
|
||||
```bash
|
||||
git diff {base_branch}...HEAD --name-status
|
||||
```
|
||||
|
||||
This shows only changes made in the spec branch since it diverged from `{base_branch}`.
|
||||
|
||||
---
|
||||
|
||||
## PROJECT CAPABILITIES DETECTED
|
||||
|
||||
@@ -185,24 +185,31 @@ def cmd_get_memories(args):
|
||||
"""
|
||||
|
||||
result = conn.execute(query, parameters={"limit": limit})
|
||||
df = result.get_as_df()
|
||||
|
||||
# Process results without pandas (iterate through result set directly)
|
||||
memories = []
|
||||
for _, row in df.iterrows():
|
||||
while result.has_next():
|
||||
row = result.get_next()
|
||||
# Row order: uuid, name, created_at, content, description, group_id
|
||||
uuid_val = serialize_value(row[0]) if len(row) > 0 else None
|
||||
name_val = serialize_value(row[1]) if len(row) > 1 else ""
|
||||
created_at_val = serialize_value(row[2]) if len(row) > 2 else None
|
||||
content_val = serialize_value(row[3]) if len(row) > 3 else ""
|
||||
description_val = serialize_value(row[4]) if len(row) > 4 else ""
|
||||
group_id_val = serialize_value(row[5]) if len(row) > 5 else ""
|
||||
|
||||
memory = {
|
||||
"id": row.get("uuid") or row.get("name", "unknown"),
|
||||
"name": row.get("name", ""),
|
||||
"type": infer_episode_type(row.get("name", ""), row.get("content", "")),
|
||||
"timestamp": row.get("created_at") or datetime.now().isoformat(),
|
||||
"content": row.get("content")
|
||||
or row.get("description")
|
||||
or row.get("name", ""),
|
||||
"description": row.get("description", ""),
|
||||
"group_id": row.get("group_id", ""),
|
||||
"id": uuid_val or name_val or "unknown",
|
||||
"name": name_val or "",
|
||||
"type": infer_episode_type(name_val or "", content_val or ""),
|
||||
"timestamp": created_at_val or datetime.now().isoformat(),
|
||||
"content": content_val or description_val or name_val or "",
|
||||
"description": description_val or "",
|
||||
"group_id": group_id_val or "",
|
||||
}
|
||||
|
||||
# Extract session number if present
|
||||
session_num = extract_session_number(row.get("name", ""))
|
||||
session_num = extract_session_number(name_val or "")
|
||||
if session_num:
|
||||
memory["session_number"] = session_num
|
||||
|
||||
@@ -251,24 +258,31 @@ def cmd_search(args):
|
||||
result = conn.execute(
|
||||
query, parameters={"search_query": search_query, "limit": limit}
|
||||
)
|
||||
df = result.get_as_df()
|
||||
|
||||
# Process results without pandas
|
||||
memories = []
|
||||
for _, row in df.iterrows():
|
||||
while result.has_next():
|
||||
row = result.get_next()
|
||||
# Row order: uuid, name, created_at, content, description, group_id
|
||||
uuid_val = serialize_value(row[0]) if len(row) > 0 else None
|
||||
name_val = serialize_value(row[1]) if len(row) > 1 else ""
|
||||
created_at_val = serialize_value(row[2]) if len(row) > 2 else None
|
||||
content_val = serialize_value(row[3]) if len(row) > 3 else ""
|
||||
description_val = serialize_value(row[4]) if len(row) > 4 else ""
|
||||
group_id_val = serialize_value(row[5]) if len(row) > 5 else ""
|
||||
|
||||
memory = {
|
||||
"id": row.get("uuid") or row.get("name", "unknown"),
|
||||
"name": row.get("name", ""),
|
||||
"type": infer_episode_type(row.get("name", ""), row.get("content", "")),
|
||||
"timestamp": row.get("created_at") or datetime.now().isoformat(),
|
||||
"content": row.get("content")
|
||||
or row.get("description")
|
||||
or row.get("name", ""),
|
||||
"description": row.get("description", ""),
|
||||
"group_id": row.get("group_id", ""),
|
||||
"id": uuid_val or name_val or "unknown",
|
||||
"name": name_val or "",
|
||||
"type": infer_episode_type(name_val or "", content_val or ""),
|
||||
"timestamp": created_at_val or datetime.now().isoformat(),
|
||||
"content": content_val or description_val or name_val or "",
|
||||
"description": description_val or "",
|
||||
"group_id": group_id_val or "",
|
||||
"score": 1.0, # Keyword match score
|
||||
}
|
||||
|
||||
session_num = extract_session_number(row.get("name", ""))
|
||||
session_num = extract_session_number(name_val or "")
|
||||
if session_num:
|
||||
memory["session_number"] = session_num
|
||||
|
||||
@@ -461,19 +475,26 @@ def cmd_get_entities(args):
|
||||
"""
|
||||
|
||||
result = conn.execute(query, parameters={"limit": limit})
|
||||
df = result.get_as_df()
|
||||
|
||||
# Process results without pandas
|
||||
entities = []
|
||||
for _, row in df.iterrows():
|
||||
if not row.get("summary"):
|
||||
while result.has_next():
|
||||
row = result.get_next()
|
||||
# Row order: uuid, name, summary, created_at
|
||||
uuid_val = serialize_value(row[0]) if len(row) > 0 else None
|
||||
name_val = serialize_value(row[1]) if len(row) > 1 else ""
|
||||
summary_val = serialize_value(row[2]) if len(row) > 2 else ""
|
||||
created_at_val = serialize_value(row[3]) if len(row) > 3 else None
|
||||
|
||||
if not summary_val:
|
||||
continue
|
||||
|
||||
entity = {
|
||||
"id": row.get("uuid") or row.get("name", "unknown"),
|
||||
"name": row.get("name", ""),
|
||||
"type": infer_entity_type(row.get("name", "")),
|
||||
"timestamp": row.get("created_at") or datetime.now().isoformat(),
|
||||
"content": row.get("summary", ""),
|
||||
"id": uuid_val or name_val or "unknown",
|
||||
"name": name_val or "",
|
||||
"type": infer_entity_type(name_val or ""),
|
||||
"timestamp": created_at_val or datetime.now().isoformat(),
|
||||
"content": summary_val or "",
|
||||
}
|
||||
entities.append(entity)
|
||||
|
||||
@@ -488,6 +509,118 @@ def cmd_get_entities(args):
|
||||
output_error(f"Query failed: {e}")
|
||||
|
||||
|
||||
def cmd_add_episode(args):
|
||||
"""
|
||||
Add a new episode to the memory database.
|
||||
|
||||
This is called from the Electron main process to save PR review insights,
|
||||
patterns, gotchas, and other memories directly to the LadybugDB database.
|
||||
|
||||
Args:
|
||||
args.db_path: Path to database directory
|
||||
args.database: Database name
|
||||
args.name: Episode name/title
|
||||
args.content: Episode content (JSON string)
|
||||
args.episode_type: Type of episode (session_insight, pattern, gotcha, task_outcome, pr_review)
|
||||
args.group_id: Optional group ID for namespacing
|
||||
"""
|
||||
if not apply_monkeypatch():
|
||||
output_error("Neither kuzu nor LadybugDB is installed")
|
||||
return
|
||||
|
||||
try:
|
||||
import uuid as uuid_module
|
||||
|
||||
try:
|
||||
import kuzu
|
||||
except ImportError:
|
||||
import real_ladybug as kuzu
|
||||
|
||||
# Parse content from JSON if provided
|
||||
content = args.content
|
||||
if content:
|
||||
try:
|
||||
# Try to parse as JSON to validate
|
||||
parsed = json.loads(content)
|
||||
# Re-serialize to ensure consistent formatting
|
||||
content = json.dumps(parsed)
|
||||
except json.JSONDecodeError:
|
||||
# If not valid JSON, use as-is
|
||||
pass
|
||||
|
||||
# Generate unique ID
|
||||
episode_uuid = str(uuid_module.uuid4())
|
||||
created_at = datetime.now().isoformat()
|
||||
|
||||
# Get database path - create directory if needed
|
||||
full_path = Path(args.db_path) / args.database
|
||||
if not full_path.exists():
|
||||
# For new databases, create the parent directory
|
||||
Path(args.db_path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Open database (creates it if it doesn't exist)
|
||||
db = kuzu.Database(str(full_path))
|
||||
conn = kuzu.Connection(db)
|
||||
|
||||
# Always try to create the Episodic table if it doesn't exist
|
||||
# This handles both new databases and existing databases without the table
|
||||
try:
|
||||
conn.execute("""
|
||||
CREATE NODE TABLE IF NOT EXISTS Episodic (
|
||||
uuid STRING PRIMARY KEY,
|
||||
name STRING,
|
||||
content STRING,
|
||||
source_description STRING,
|
||||
group_id STRING,
|
||||
created_at STRING
|
||||
)
|
||||
""")
|
||||
except Exception as schema_err:
|
||||
# Table might already exist with different schema - that's ok
|
||||
# The insert will fail if schema is incompatible
|
||||
sys.stderr.write(f"Schema creation note: {schema_err}\n")
|
||||
|
||||
# Insert the episode
|
||||
try:
|
||||
insert_query = """
|
||||
CREATE (e:Episodic {
|
||||
uuid: $uuid,
|
||||
name: $name,
|
||||
content: $content,
|
||||
source_description: $description,
|
||||
group_id: $group_id,
|
||||
created_at: $created_at
|
||||
})
|
||||
"""
|
||||
conn.execute(
|
||||
insert_query,
|
||||
parameters={
|
||||
"uuid": episode_uuid,
|
||||
"name": args.name,
|
||||
"content": content,
|
||||
"description": f"[{args.episode_type}] {args.name}",
|
||||
"group_id": args.group_id or "",
|
||||
"created_at": created_at,
|
||||
},
|
||||
)
|
||||
|
||||
output_json(
|
||||
True,
|
||||
data={
|
||||
"id": episode_uuid,
|
||||
"name": args.name,
|
||||
"type": args.episode_type,
|
||||
"timestamp": created_at,
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
output_error(f"Failed to insert episode: {e}")
|
||||
|
||||
except Exception as e:
|
||||
output_error(f"Failed to add episode: {e}")
|
||||
|
||||
|
||||
def infer_episode_type(name: str, content: str = "") -> str:
|
||||
"""Infer the episode type from its name and content."""
|
||||
name_lower = (name or "").lower()
|
||||
@@ -580,6 +713,27 @@ def main():
|
||||
"--limit", type=int, default=20, help="Maximum results"
|
||||
)
|
||||
|
||||
# add-episode command (for saving memories from Electron app)
|
||||
add_parser = subparsers.add_parser(
|
||||
"add-episode",
|
||||
help="Add an episode to the memory database (called from Electron)",
|
||||
)
|
||||
add_parser.add_argument("db_path", help="Path to database directory")
|
||||
add_parser.add_argument("database", help="Database name")
|
||||
add_parser.add_argument("--name", required=True, help="Episode name/title")
|
||||
add_parser.add_argument(
|
||||
"--content", required=True, help="Episode content (JSON string)"
|
||||
)
|
||||
add_parser.add_argument(
|
||||
"--type",
|
||||
dest="episode_type",
|
||||
default="session_insight",
|
||||
help="Episode type (session_insight, pattern, gotcha, task_outcome, pr_review)",
|
||||
)
|
||||
add_parser.add_argument(
|
||||
"--group-id", dest="group_id", help="Optional group ID for namespacing"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
@@ -594,6 +748,7 @@ def main():
|
||||
"search": cmd_search,
|
||||
"semantic-search": cmd_semantic_search,
|
||||
"get-entities": cmd_get_entities,
|
||||
"add-episode": cmd_add_episode,
|
||||
}
|
||||
|
||||
handler = commands.get(args.command)
|
||||
|
||||
@@ -875,6 +875,128 @@ class GHClient:
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
async def get_workflows_awaiting_approval(self, pr_number: int) -> dict[str, Any]:
|
||||
"""
|
||||
Get workflow runs awaiting approval for a PR from a fork.
|
||||
|
||||
Workflows from forked repositories require manual approval before running.
|
||||
These are NOT included in `gh pr checks` and must be queried separately.
|
||||
|
||||
Args:
|
||||
pr_number: PR number
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- awaiting_approval: Number of workflows waiting for approval
|
||||
- workflow_runs: List of workflow runs with id, name, html_url
|
||||
- can_approve: Whether this token can approve workflows
|
||||
"""
|
||||
try:
|
||||
# First, get the PR's head SHA to filter workflow runs
|
||||
pr_args = ["pr", "view", str(pr_number), "--json", "headRefOid"]
|
||||
pr_args = self._add_repo_flag(pr_args)
|
||||
pr_result = await self.run(pr_args, timeout=30.0)
|
||||
pr_data = json.loads(pr_result.stdout) if pr_result.stdout.strip() else {}
|
||||
head_sha = pr_data.get("headRefOid", "")
|
||||
|
||||
if not head_sha:
|
||||
return {
|
||||
"awaiting_approval": 0,
|
||||
"workflow_runs": [],
|
||||
"can_approve": False,
|
||||
}
|
||||
|
||||
# Query workflow runs with action_required status
|
||||
# Note: We need to use the API endpoint as gh CLI doesn't have direct support
|
||||
endpoint = (
|
||||
"repos/{owner}/{repo}/actions/runs?status=action_required&per_page=100"
|
||||
)
|
||||
args = ["api", "--method", "GET", endpoint]
|
||||
|
||||
result = await self.run(args, timeout=30.0)
|
||||
data = json.loads(result.stdout) if result.stdout.strip() else {}
|
||||
all_runs = data.get("workflow_runs", [])
|
||||
|
||||
# Filter to only runs for this PR's head SHA
|
||||
pr_runs = [
|
||||
{
|
||||
"id": run.get("id"),
|
||||
"name": run.get("name"),
|
||||
"html_url": run.get("html_url"),
|
||||
"workflow_name": run.get("workflow", {}).get("name", "Unknown"),
|
||||
}
|
||||
for run in all_runs
|
||||
if run.get("head_sha") == head_sha
|
||||
]
|
||||
|
||||
return {
|
||||
"awaiting_approval": len(pr_runs),
|
||||
"workflow_runs": pr_runs,
|
||||
"can_approve": True, # Assume token has permission, will fail if not
|
||||
}
|
||||
except (GHCommandError, GHTimeoutError, json.JSONDecodeError) as e:
|
||||
logger.warning(
|
||||
f"Failed to get workflows awaiting approval for #{pr_number}: {e}"
|
||||
)
|
||||
return {
|
||||
"awaiting_approval": 0,
|
||||
"workflow_runs": [],
|
||||
"can_approve": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
async def approve_workflow_run(self, run_id: int) -> bool:
|
||||
"""
|
||||
Approve a workflow run that's waiting for approval (from a fork).
|
||||
|
||||
Args:
|
||||
run_id: The workflow run ID to approve
|
||||
|
||||
Returns:
|
||||
True if approval succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
endpoint = f"repos/{{owner}}/{{repo}}/actions/runs/{run_id}/approve"
|
||||
args = ["api", "--method", "POST", endpoint]
|
||||
|
||||
await self.run(args, timeout=30.0)
|
||||
logger.info(f"Approved workflow run {run_id}")
|
||||
return True
|
||||
except (GHCommandError, GHTimeoutError) as e:
|
||||
logger.warning(f"Failed to approve workflow run {run_id}: {e}")
|
||||
return False
|
||||
|
||||
async def get_pr_checks_comprehensive(self, pr_number: int) -> dict[str, Any]:
|
||||
"""
|
||||
Get comprehensive CI status including workflows awaiting approval.
|
||||
|
||||
This combines:
|
||||
- Standard check runs from `gh pr checks`
|
||||
- Workflows awaiting approval (for fork PRs)
|
||||
|
||||
Args:
|
||||
pr_number: PR number
|
||||
|
||||
Returns:
|
||||
Dict with all check information including awaiting_approval count
|
||||
"""
|
||||
# Get standard checks
|
||||
checks = await self.get_pr_checks(pr_number)
|
||||
|
||||
# Get workflows awaiting approval
|
||||
awaiting = await self.get_workflows_awaiting_approval(pr_number)
|
||||
|
||||
# Merge the results
|
||||
checks["awaiting_approval"] = awaiting.get("awaiting_approval", 0)
|
||||
checks["awaiting_workflow_runs"] = awaiting.get("workflow_runs", [])
|
||||
|
||||
# Update pending count to include awaiting approval
|
||||
checks["pending"] = checks.get("pending", 0) + awaiting.get(
|
||||
"awaiting_approval", 0
|
||||
)
|
||||
|
||||
return checks
|
||||
|
||||
async def get_pr_files(self, pr_number: int) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get files changed by a PR using the PR files endpoint.
|
||||
@@ -1007,7 +1129,9 @@ class GHClient:
|
||||
Returns:
|
||||
Tuple of:
|
||||
- List of file objects that are part of the PR (filtered if blob comparison used)
|
||||
- List of commit objects that are part of the PR and after base_sha
|
||||
- List of commit objects that are part of the PR and after base_sha.
|
||||
NOTE: Returns empty list if rebase/force-push detected, since commit SHAs
|
||||
are rewritten and we cannot determine which commits are truly "new".
|
||||
"""
|
||||
# Get PR's canonical files (these are the actual PR changes)
|
||||
pr_files = await self.get_pr_files(pr_number)
|
||||
@@ -1072,12 +1196,14 @@ class GHClient:
|
||||
f"{unchanged_count} unchanged (skipped)"
|
||||
)
|
||||
|
||||
# Return filtered files but all commits (can't filter commits after rebase)
|
||||
return changed_files, pr_commits
|
||||
# Return filtered files but empty commits list (can't determine "new" commits after rebase)
|
||||
# After a rebase, all commit SHAs are rewritten so we can't identify which are truly new.
|
||||
# The file changes via blob comparison are the reliable source of what changed.
|
||||
return changed_files, []
|
||||
|
||||
# No blob data available - return all files and commits
|
||||
# No blob data available - return all files but empty commits (can't determine new commits)
|
||||
logger.warning(
|
||||
"No reviewed_file_blobs available for blob comparison. "
|
||||
"Returning all PR files."
|
||||
"No reviewed_file_blobs available for blob comparison after rebase. "
|
||||
"Returning all PR files with empty commits list."
|
||||
)
|
||||
return pr_files, pr_commits
|
||||
return pr_files, []
|
||||
|
||||
@@ -570,6 +570,10 @@ class FollowupReviewContext:
|
||||
"" # BEHIND, BLOCKED, CLEAN, DIRTY, HAS_HOOKS, UNKNOWN, UNSTABLE
|
||||
)
|
||||
|
||||
# CI status - passed to AI orchestrator so it can factor into verdict
|
||||
# Dict with: passing, failing, pending, failed_checks, awaiting_approval
|
||||
ci_status: dict = field(default_factory=dict)
|
||||
|
||||
# Error flag - if set, context gathering failed and data may be incomplete
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@@ -389,13 +389,29 @@ class GitHubOrchestrator:
|
||||
pr_number=pr_number,
|
||||
)
|
||||
|
||||
# Check CI status
|
||||
ci_status = await self.gh_client.get_pr_checks(pr_number)
|
||||
# Check CI status (comprehensive - includes workflows awaiting approval)
|
||||
ci_status = await self.gh_client.get_pr_checks_comprehensive(pr_number)
|
||||
|
||||
# Log CI status with awaiting approval info
|
||||
awaiting = ci_status.get("awaiting_approval", 0)
|
||||
pending_without_awaiting = ci_status.get("pending", 0) - awaiting
|
||||
ci_log_parts = [
|
||||
f"{ci_status.get('passing', 0)} passing",
|
||||
f"{ci_status.get('failing', 0)} failing",
|
||||
]
|
||||
if pending_without_awaiting > 0:
|
||||
ci_log_parts.append(f"{pending_without_awaiting} pending")
|
||||
if awaiting > 0:
|
||||
ci_log_parts.append(f"{awaiting} awaiting approval")
|
||||
print(
|
||||
f"[DEBUG orchestrator] CI status: {ci_status.get('passing', 0)} passing, "
|
||||
f"{ci_status.get('failing', 0)} failing, {ci_status.get('pending', 0)} pending",
|
||||
f"[orchestrator] CI status: {', '.join(ci_log_parts)}",
|
||||
flush=True,
|
||||
)
|
||||
if awaiting > 0:
|
||||
print(
|
||||
f"[orchestrator] ⚠️ {awaiting} workflow(s) from fork need maintainer approval to run",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Generate verdict (now includes CI status)
|
||||
verdict, verdict_reasoning, blockers = self._generate_verdict(
|
||||
@@ -500,6 +516,9 @@ class GitHubOrchestrator:
|
||||
# Save result
|
||||
await result.save(self.github_dir)
|
||||
|
||||
# Note: PR review memory is now saved by the Electron app after the review completes
|
||||
# This ensures memory is saved to the embedded LadybugDB managed by the app
|
||||
|
||||
# Mark as reviewed (head_sha already fetched above)
|
||||
if head_sha:
|
||||
self.bot_detector.mark_reviewed(pr_number, head_sha)
|
||||
@@ -615,19 +634,29 @@ class GitHubOrchestrator:
|
||||
await result.save(self.github_dir)
|
||||
return result
|
||||
|
||||
# Check if there are new commits
|
||||
if not followup_context.commits_since_review:
|
||||
# Check if there are changes to review (commits OR files via blob comparison)
|
||||
# After a rebase/force-push, commits_since_review will be empty (commit
|
||||
# SHAs are rewritten), but files_changed_since_review will contain files
|
||||
# that actually changed content based on blob SHA comparison.
|
||||
has_commits = bool(followup_context.commits_since_review)
|
||||
has_file_changes = bool(followup_context.files_changed_since_review)
|
||||
|
||||
if not has_commits and not has_file_changes:
|
||||
base_sha = previous_review.reviewed_commit_sha[:8]
|
||||
print(
|
||||
f"[Followup] No new commits since last review at {previous_review.reviewed_commit_sha[:8]}",
|
||||
f"[Followup] No changes since last review at {base_sha}",
|
||||
flush=True,
|
||||
)
|
||||
# Return a result indicating no changes
|
||||
no_change_summary = (
|
||||
"No new commits since last review. Previous findings still apply."
|
||||
)
|
||||
result = PRReviewResult(
|
||||
pr_number=pr_number,
|
||||
repo=self.config.repo,
|
||||
success=True,
|
||||
findings=previous_review.findings,
|
||||
summary="No new commits since last review. Previous findings still apply.",
|
||||
summary=no_change_summary,
|
||||
overall_status=previous_review.overall_status,
|
||||
verdict=previous_review.verdict,
|
||||
verdict_reasoning="No changes since last review.",
|
||||
@@ -639,13 +668,26 @@ class GitHubOrchestrator:
|
||||
await result.save(self.github_dir)
|
||||
return result
|
||||
|
||||
# Build progress message based on what changed
|
||||
if has_commits:
|
||||
num_commits = len(followup_context.commits_since_review)
|
||||
change_desc = f"{num_commits} new commits"
|
||||
else:
|
||||
# Rebase detected - files changed but no trackable commits
|
||||
num_files = len(followup_context.files_changed_since_review)
|
||||
change_desc = f"{num_files} files (rebase detected)"
|
||||
|
||||
self._report_progress(
|
||||
"analyzing",
|
||||
30,
|
||||
f"Analyzing {len(followup_context.commits_since_review)} new commits...",
|
||||
f"Analyzing {change_desc}...",
|
||||
pr_number=pr_number,
|
||||
)
|
||||
|
||||
# Fetch CI status BEFORE calling reviewer so AI can factor it into verdict
|
||||
ci_status = await self.gh_client.get_pr_checks_comprehensive(pr_number)
|
||||
followup_context.ci_status = ci_status
|
||||
|
||||
# Use parallel orchestrator for follow-up if enabled
|
||||
if self.config.use_parallel_orchestrator:
|
||||
print(
|
||||
@@ -690,9 +732,9 @@ class GitHubOrchestrator:
|
||||
)
|
||||
result = await reviewer.review_followup(followup_context)
|
||||
|
||||
# Check CI status and override verdict if failing
|
||||
ci_status = await self.gh_client.get_pr_checks(pr_number)
|
||||
failed_checks = ci_status.get("failed_checks", [])
|
||||
# Fallback: ensure CI failures block merge even if AI didn't factor it in
|
||||
# (CI status was already passed to AI via followup_context.ci_status)
|
||||
failed_checks = followup_context.ci_status.get("failed_checks", [])
|
||||
if failed_checks:
|
||||
print(
|
||||
f"[Followup] CI checks failing: {failed_checks}",
|
||||
@@ -724,6 +766,9 @@ class GitHubOrchestrator:
|
||||
# Save result
|
||||
await result.save(self.github_dir)
|
||||
|
||||
# Note: PR review memory is now saved by the Electron app after the review completes
|
||||
# This ensures memory is saved to the embedded LadybugDB managed by the app
|
||||
|
||||
# Mark as reviewed with new commit SHA
|
||||
if result.reviewed_commit_sha:
|
||||
self.bot_detector.mark_reviewed(pr_number, result.reviewed_commit_sha)
|
||||
@@ -801,6 +846,13 @@ class GitHubOrchestrator:
|
||||
for check_name in failed_checks:
|
||||
blockers.append(f"CI Failed: {check_name}")
|
||||
|
||||
# Workflows awaiting approval block merging (fork PRs)
|
||||
awaiting_approval = ci_status.get("awaiting_approval", 0)
|
||||
if awaiting_approval > 0:
|
||||
blockers.append(
|
||||
f"Workflows Pending: {awaiting_approval} workflow(s) awaiting maintainer approval"
|
||||
)
|
||||
|
||||
# NEW: Verification failures block merging
|
||||
for f in verification_failures:
|
||||
note = f" - {f.verification_note}" if f.verification_note else ""
|
||||
@@ -842,6 +894,13 @@ class GitHubOrchestrator:
|
||||
f"Blocked: {len(failed_checks)} CI check(s) failing. "
|
||||
"Fix CI before merge."
|
||||
)
|
||||
# Workflows awaiting approval block merging
|
||||
elif awaiting_approval > 0:
|
||||
verdict = MergeVerdict.BLOCKED
|
||||
reasoning = (
|
||||
f"Blocked: {awaiting_approval} workflow(s) awaiting approval. "
|
||||
"Approve workflows on GitHub to run CI checks."
|
||||
)
|
||||
# NEW: Prioritize verification failures
|
||||
elif verification_failures:
|
||||
verdict = MergeVerdict.BLOCKED
|
||||
|
||||
@@ -21,6 +21,9 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -32,6 +35,7 @@ from claude_agent_sdk import AgentDefinition
|
||||
try:
|
||||
from ...core.client import create_client
|
||||
from ...phase_config import get_thinking_budget
|
||||
from ..context_gatherer import _validate_git_ref
|
||||
from ..gh_client import GHClient
|
||||
from ..models import (
|
||||
GitHubRunnerConfig,
|
||||
@@ -44,6 +48,7 @@ try:
|
||||
from .pydantic_models import ParallelFollowupResponse
|
||||
from .sdk_utils import process_sdk_stream
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from context_gatherer import _validate_git_ref
|
||||
from core.client import create_client
|
||||
from gh_client import GHClient
|
||||
from models import (
|
||||
@@ -64,6 +69,9 @@ logger = logging.getLogger(__name__)
|
||||
# Check if debug mode is enabled
|
||||
DEBUG_MODE = os.environ.get("DEBUG", "").lower() in ("true", "1", "yes")
|
||||
|
||||
# Directory for PR review worktrees (shared with initial reviewer)
|
||||
PR_WORKTREE_DIR = ".auto-claude/github/pr/worktrees"
|
||||
|
||||
# Severity mapping for AI responses
|
||||
_SEVERITY_MAPPING = {
|
||||
"critical": ReviewSeverity.CRITICAL,
|
||||
@@ -138,6 +146,122 @@ class ParallelFollowupReviewer:
|
||||
logger.warning(f"Prompt file not found: {prompt_file}")
|
||||
return ""
|
||||
|
||||
def _create_pr_worktree(self, head_sha: str, pr_number: int) -> Path:
|
||||
"""Create a temporary worktree at the PR head commit.
|
||||
|
||||
Args:
|
||||
head_sha: The commit SHA of the PR head (validated before use)
|
||||
pr_number: The PR number for naming
|
||||
|
||||
Returns:
|
||||
Path to the created worktree
|
||||
|
||||
Raises:
|
||||
RuntimeError: If worktree creation fails
|
||||
ValueError: If head_sha fails validation (command injection prevention)
|
||||
"""
|
||||
# SECURITY: Validate git ref before use in subprocess calls
|
||||
if not _validate_git_ref(head_sha):
|
||||
raise ValueError(
|
||||
f"Invalid git ref: '{head_sha}'. "
|
||||
"Must contain only alphanumeric characters, dots, slashes, underscores, and hyphens."
|
||||
)
|
||||
|
||||
worktree_name = f"pr-followup-{pr_number}-{uuid.uuid4().hex[:8]}"
|
||||
worktree_dir = self.project_dir / PR_WORKTREE_DIR
|
||||
|
||||
if DEBUG_MODE:
|
||||
print(f"[Followup] DEBUG: project_dir={self.project_dir}", flush=True)
|
||||
print(f"[Followup] DEBUG: worktree_dir={worktree_dir}", flush=True)
|
||||
print(f"[Followup] DEBUG: head_sha={head_sha}", flush=True)
|
||||
|
||||
worktree_dir.mkdir(parents=True, exist_ok=True)
|
||||
worktree_path = worktree_dir / worktree_name
|
||||
|
||||
if DEBUG_MODE:
|
||||
print(f"[Followup] DEBUG: worktree_path={worktree_path}", flush=True)
|
||||
|
||||
# Fetch the commit if not available locally (handles fork PRs)
|
||||
fetch_result = subprocess.run(
|
||||
["git", "fetch", "origin", head_sha],
|
||||
cwd=self.project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if DEBUG_MODE:
|
||||
print(
|
||||
f"[Followup] DEBUG: fetch returncode={fetch_result.returncode}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Create detached worktree at the PR commit
|
||||
result = subprocess.run(
|
||||
["git", "worktree", "add", "--detach", str(worktree_path), head_sha],
|
||||
cwd=self.project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
if DEBUG_MODE:
|
||||
print(
|
||||
f"[Followup] DEBUG: worktree add returncode={result.returncode}",
|
||||
flush=True,
|
||||
)
|
||||
if result.stderr:
|
||||
print(
|
||||
f"[Followup] DEBUG: worktree add stderr={result.stderr[:200]}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Failed to create worktree: {result.stderr}")
|
||||
|
||||
logger.info(f"[Followup] Created worktree at {worktree_path}")
|
||||
return worktree_path
|
||||
|
||||
def _cleanup_pr_worktree(self, worktree_path: Path) -> None:
|
||||
"""Remove a temporary PR review worktree with fallback chain.
|
||||
|
||||
Args:
|
||||
worktree_path: Path to the worktree to remove
|
||||
"""
|
||||
if not worktree_path or not worktree_path.exists():
|
||||
return
|
||||
|
||||
if DEBUG_MODE:
|
||||
print(
|
||||
f"[Followup] DEBUG: Cleaning up worktree at {worktree_path}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Try 1: git worktree remove
|
||||
result = subprocess.run(
|
||||
["git", "worktree", "remove", "--force", str(worktree_path)],
|
||||
cwd=self.project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.info(f"[Followup] Cleaned up worktree: {worktree_path.name}")
|
||||
return
|
||||
|
||||
# Try 2: shutil.rmtree fallback
|
||||
try:
|
||||
shutil.rmtree(worktree_path, ignore_errors=True)
|
||||
subprocess.run(
|
||||
["git", "worktree", "prune"],
|
||||
cwd=self.project_dir,
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
logger.warning(f"[Followup] Used shutil fallback for: {worktree_path.name}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Followup] Failed to cleanup worktree {worktree_path}: {e}")
|
||||
|
||||
def _define_specialist_agents(self) -> dict[str, AgentDefinition]:
|
||||
"""
|
||||
Define specialist agents for follow-up review.
|
||||
@@ -267,6 +391,44 @@ class ParallelFollowupReviewer:
|
||||
|
||||
return "\n\n---\n\n".join(ai_content)
|
||||
|
||||
def _format_ci_status(self, context: FollowupReviewContext) -> str:
|
||||
"""Format CI status for the prompt."""
|
||||
ci_status = context.ci_status
|
||||
if not ci_status:
|
||||
return "CI status not available."
|
||||
|
||||
passing = ci_status.get("passing", 0)
|
||||
failing = ci_status.get("failing", 0)
|
||||
pending = ci_status.get("pending", 0)
|
||||
failed_checks = ci_status.get("failed_checks", [])
|
||||
awaiting_approval = ci_status.get("awaiting_approval", 0)
|
||||
|
||||
lines = []
|
||||
|
||||
# Overall status
|
||||
if failing > 0:
|
||||
lines.append(f"⚠️ **{failing} CI check(s) FAILING** - PR cannot be merged")
|
||||
elif pending > 0:
|
||||
lines.append(f"⏳ **{pending} CI check(s) pending** - Wait for completion")
|
||||
elif passing > 0:
|
||||
lines.append(f"✅ **All {passing} CI check(s) passing**")
|
||||
else:
|
||||
lines.append("No CI checks configured")
|
||||
|
||||
# List failed checks
|
||||
if failed_checks:
|
||||
lines.append("\n**Failed checks:**")
|
||||
for check in failed_checks:
|
||||
lines.append(f" - ❌ {check}")
|
||||
|
||||
# Awaiting approval (fork PRs)
|
||||
if awaiting_approval > 0:
|
||||
lines.append(
|
||||
f"\n⏸️ **{awaiting_approval} workflow(s) awaiting maintainer approval** (fork PR)"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _build_orchestrator_prompt(self, context: FollowupReviewContext) -> str:
|
||||
"""Build full prompt for orchestrator with follow-up context."""
|
||||
# Load orchestrator prompt
|
||||
@@ -279,6 +441,7 @@ class ParallelFollowupReviewer:
|
||||
commits = self._format_commits(context)
|
||||
contributor_comments = self._format_comments(context)
|
||||
ai_reviews = self._format_ai_reviews(context)
|
||||
ci_status = self._format_ci_status(context)
|
||||
|
||||
# Truncate diff if too long
|
||||
MAX_DIFF_CHARS = 100_000
|
||||
@@ -297,6 +460,9 @@ class ParallelFollowupReviewer:
|
||||
**New Commits:** {len(context.commits_since_review)}
|
||||
**Files Changed:** {len(context.files_changed_since_review)}
|
||||
|
||||
### CI Status (CRITICAL - Must Factor Into Verdict)
|
||||
{ci_status}
|
||||
|
||||
### Previous Review Summary
|
||||
{context.previous_review.summary[:500] if context.previous_review.summary else "No summary available."}
|
||||
|
||||
@@ -325,6 +491,7 @@ class ParallelFollowupReviewer:
|
||||
Now analyze this follow-up and delegate to the appropriate specialist agents.
|
||||
Remember: YOU decide which agents to invoke based on YOUR analysis.
|
||||
The SDK will run invoked agents in parallel automatically.
|
||||
**CRITICAL: Your verdict MUST account for CI status. Failing CI = BLOCKED verdict.**
|
||||
"""
|
||||
|
||||
return base_prompt + followup_context
|
||||
@@ -343,6 +510,9 @@ The SDK will run invoked agents in parallel automatically.
|
||||
f"[ParallelFollowup] Starting follow-up review for PR #{context.pr_number}"
|
||||
)
|
||||
|
||||
# Track worktree for cleanup
|
||||
worktree_path: Path | None = None
|
||||
|
||||
try:
|
||||
self._report_progress(
|
||||
"orchestrating",
|
||||
@@ -354,13 +524,48 @@ The SDK will run invoked agents in parallel automatically.
|
||||
# Build orchestrator prompt
|
||||
prompt = self._build_orchestrator_prompt(context)
|
||||
|
||||
# Get project root
|
||||
# Get project root - default to local checkout
|
||||
project_root = (
|
||||
self.project_dir.parent.parent
|
||||
if self.project_dir.name == "backend"
|
||||
else self.project_dir
|
||||
)
|
||||
|
||||
# Create temporary worktree at PR head commit for isolated review
|
||||
# This ensures agents read from the correct PR state, not the current checkout
|
||||
head_sha = context.current_commit_sha
|
||||
if head_sha and _validate_git_ref(head_sha):
|
||||
try:
|
||||
if DEBUG_MODE:
|
||||
print(
|
||||
f"[Followup] DEBUG: Creating worktree for head_sha={head_sha}",
|
||||
flush=True,
|
||||
)
|
||||
worktree_path = self._create_pr_worktree(
|
||||
head_sha, context.pr_number
|
||||
)
|
||||
project_root = worktree_path
|
||||
print(
|
||||
f"[Followup] Using worktree at {worktree_path.name} for PR review",
|
||||
flush=True,
|
||||
)
|
||||
except Exception as e:
|
||||
if DEBUG_MODE:
|
||||
print(
|
||||
f"[Followup] DEBUG: Worktree creation FAILED: {e}",
|
||||
flush=True,
|
||||
)
|
||||
logger.warning(
|
||||
f"[ParallelFollowup] Worktree creation failed, "
|
||||
f"falling back to local checkout: {e}"
|
||||
)
|
||||
# Fallback to original behavior if worktree creation fails
|
||||
else:
|
||||
logger.warning(
|
||||
f"[ParallelFollowup] Invalid or missing head_sha '{head_sha}', "
|
||||
"using local checkout"
|
||||
)
|
||||
|
||||
# Use model and thinking level from config (user settings)
|
||||
model = self.config.model or "claude-sonnet-4-5-20250929"
|
||||
thinking_level = self.config.thinking_level or "medium"
|
||||
@@ -567,6 +772,10 @@ The SDK will run invoked agents in parallel automatically.
|
||||
is_followup_review=True,
|
||||
reviewed_commit_sha=context.current_commit_sha,
|
||||
)
|
||||
finally:
|
||||
# Always cleanup worktree, even on error
|
||||
if worktree_path:
|
||||
self._cleanup_pr_worktree(worktree_path)
|
||||
|
||||
def _parse_structured_output(
|
||||
self, data: dict, context: FollowupReviewContext
|
||||
|
||||
@@ -62,7 +62,9 @@ from .validator import (
|
||||
validate_chmod_command,
|
||||
validate_dropdb_command,
|
||||
validate_dropuser_command,
|
||||
validate_git_command,
|
||||
validate_git_commit,
|
||||
validate_git_config,
|
||||
validate_init_script,
|
||||
validate_kill_command,
|
||||
validate_killall_command,
|
||||
@@ -93,7 +95,9 @@ __all__ = [
|
||||
"validate_chmod_command",
|
||||
"validate_rm_command",
|
||||
"validate_init_script",
|
||||
"validate_git_command",
|
||||
"validate_git_commit",
|
||||
"validate_git_config",
|
||||
"validate_dropdb_command",
|
||||
"validate_dropuser_command",
|
||||
"validate_psql_command",
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
Git Validators
|
||||
==============
|
||||
|
||||
Validators for git operations (commit with secret scanning).
|
||||
Validators for git operations:
|
||||
- Commit with secret scanning
|
||||
- Config protection (prevent setting test users)
|
||||
"""
|
||||
|
||||
import shlex
|
||||
@@ -10,8 +12,203 @@ from pathlib import Path
|
||||
|
||||
from .validation_models import ValidationResult
|
||||
|
||||
# =============================================================================
|
||||
# BLOCKED GIT CONFIG PATTERNS
|
||||
# =============================================================================
|
||||
|
||||
def validate_git_commit(command_string: str) -> ValidationResult:
|
||||
# Git config keys that agents must NOT modify
|
||||
# These are identity settings that should inherit from the user's global config
|
||||
#
|
||||
# NOTE: This validation covers command-line arguments (git config, git -c).
|
||||
# Environment variables (GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL, GIT_COMMITTER_NAME,
|
||||
# GIT_COMMITTER_EMAIL) are NOT validated here as they require pre-execution
|
||||
# environment filtering, which is handled at the sandbox/hook level.
|
||||
BLOCKED_GIT_CONFIG_KEYS = {
|
||||
"user.name",
|
||||
"user.email",
|
||||
"author.name",
|
||||
"author.email",
|
||||
"committer.name",
|
||||
"committer.email",
|
||||
}
|
||||
|
||||
|
||||
def validate_git_config(command_string: str) -> ValidationResult:
|
||||
"""
|
||||
Validate git config commands - block identity changes.
|
||||
|
||||
Agents should not set user.name, user.email, etc. as this:
|
||||
1. Breaks commit attribution
|
||||
2. Can create fake "Test User" identities
|
||||
3. Overrides the user's legitimate git identity
|
||||
|
||||
Args:
|
||||
command_string: The full git command string
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
try:
|
||||
tokens = shlex.split(command_string)
|
||||
except ValueError:
|
||||
return False, "Could not parse git command" # Fail closed on parse errors
|
||||
|
||||
if len(tokens) < 2 or tokens[0] != "git" or tokens[1] != "config":
|
||||
return True, "" # Not a git config command
|
||||
|
||||
# Check for read-only operations first - these are always allowed
|
||||
# --get, --get-all, --get-regexp, --list are all read operations
|
||||
read_only_flags = {"--get", "--get-all", "--get-regexp", "--list", "-l"}
|
||||
for token in tokens[2:]:
|
||||
if token in read_only_flags:
|
||||
return True, "" # Read operation, allow it
|
||||
|
||||
# Extract the config key from the command
|
||||
# git config [options] <key> [value] - key is typically after config and any options
|
||||
config_key = None
|
||||
for token in tokens[2:]:
|
||||
# Skip options (start with -)
|
||||
if token.startswith("-"):
|
||||
continue
|
||||
# First non-option token is the config key
|
||||
config_key = token.lower()
|
||||
break
|
||||
|
||||
if not config_key:
|
||||
return True, "" # No config key specified (e.g., git config --list)
|
||||
|
||||
# Check if the exact config key is blocked
|
||||
for blocked_key in BLOCKED_GIT_CONFIG_KEYS:
|
||||
if config_key == blocked_key:
|
||||
return False, (
|
||||
f"BLOCKED: Cannot modify git identity configuration\n\n"
|
||||
f"You attempted to set '{blocked_key}' which is not allowed.\n\n"
|
||||
f"WHY: Git identity (user.name, user.email) must inherit from the user's "
|
||||
f"global git configuration. Setting fake identities like 'Test User' breaks "
|
||||
f"commit attribution and causes serious issues.\n\n"
|
||||
f"WHAT TO DO: Simply commit without setting any user configuration. "
|
||||
f"The repository will use the correct identity automatically."
|
||||
)
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_git_inline_config(tokens: list[str]) -> ValidationResult:
|
||||
"""
|
||||
Check for blocked config keys passed via git -c flag.
|
||||
|
||||
Git allows inline config with: git -c key=value <command>
|
||||
This bypasses 'git config' validation, so we must check all git commands
|
||||
for -c flags containing blocked identity keys.
|
||||
|
||||
Args:
|
||||
tokens: Parsed command tokens
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
i = 1 # Start after 'git'
|
||||
while i < len(tokens):
|
||||
token = tokens[i]
|
||||
|
||||
# Check for -c flag (can be "-c key=value" or "-c" "key=value")
|
||||
if token == "-c":
|
||||
# Next token should be the key=value
|
||||
if i + 1 < len(tokens):
|
||||
config_pair = tokens[i + 1]
|
||||
# Extract the key from key=value
|
||||
if "=" in config_pair:
|
||||
config_key = config_pair.split("=", 1)[0].lower()
|
||||
if config_key in BLOCKED_GIT_CONFIG_KEYS:
|
||||
return False, (
|
||||
f"BLOCKED: Cannot set git identity via -c flag\n\n"
|
||||
f"You attempted to use '-c {config_pair}' which sets a blocked "
|
||||
f"identity configuration.\n\n"
|
||||
f"WHY: Git identity (user.name, user.email) must inherit from the "
|
||||
f"user's global git configuration. Setting fake identities breaks "
|
||||
f"commit attribution and causes serious issues.\n\n"
|
||||
f"WHAT TO DO: Remove the -c flag and commit normally. "
|
||||
f"The repository will use the correct identity automatically."
|
||||
)
|
||||
i += 2 # Skip -c and its value
|
||||
continue
|
||||
elif token.startswith("-c"):
|
||||
# Handle -ckey=value format (no space)
|
||||
config_pair = token[2:] # Remove "-c" prefix
|
||||
if "=" in config_pair:
|
||||
config_key = config_pair.split("=", 1)[0].lower()
|
||||
if config_key in BLOCKED_GIT_CONFIG_KEYS:
|
||||
return False, (
|
||||
f"BLOCKED: Cannot set git identity via -c flag\n\n"
|
||||
f"You attempted to use '{token}' which sets a blocked "
|
||||
f"identity configuration.\n\n"
|
||||
f"WHY: Git identity (user.name, user.email) must inherit from the "
|
||||
f"user's global git configuration. Setting fake identities breaks "
|
||||
f"commit attribution and causes serious issues.\n\n"
|
||||
f"WHAT TO DO: Remove the -c flag and commit normally. "
|
||||
f"The repository will use the correct identity automatically."
|
||||
)
|
||||
|
||||
i += 1
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_git_command(command_string: str) -> ValidationResult:
|
||||
"""
|
||||
Main git validator that checks all git security rules.
|
||||
|
||||
Currently validates:
|
||||
- git -c: Block identity changes via inline config on ANY git command
|
||||
- git config: Block identity changes
|
||||
- git commit: Run secret scanning
|
||||
|
||||
Args:
|
||||
command_string: The full git command string
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
try:
|
||||
tokens = shlex.split(command_string)
|
||||
except ValueError:
|
||||
return False, "Could not parse git command"
|
||||
|
||||
if not tokens or tokens[0] != "git":
|
||||
return True, ""
|
||||
|
||||
if len(tokens) < 2:
|
||||
return True, "" # Just "git" with no subcommand
|
||||
|
||||
# Check for blocked -c flags on ANY git command (security bypass prevention)
|
||||
is_valid, error_msg = validate_git_inline_config(tokens)
|
||||
if not is_valid:
|
||||
return is_valid, error_msg
|
||||
|
||||
# Find the actual subcommand (skip global options like -c, -C, --git-dir, etc.)
|
||||
subcommand = None
|
||||
for token in tokens[1:]:
|
||||
# Skip options and their values
|
||||
if token.startswith("-"):
|
||||
continue
|
||||
subcommand = token
|
||||
break
|
||||
|
||||
if not subcommand:
|
||||
return True, "" # No subcommand found
|
||||
|
||||
# Check git config commands
|
||||
if subcommand == "config":
|
||||
return validate_git_config(command_string)
|
||||
|
||||
# Check git commit commands (secret scanning)
|
||||
if subcommand == "commit":
|
||||
return validate_git_commit_secrets(command_string)
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_git_commit_secrets(command_string: str) -> ValidationResult:
|
||||
"""
|
||||
Validate git commit commands - run secret scan before allowing commit.
|
||||
|
||||
@@ -99,3 +296,8 @@ def validate_git_commit(command_string: str) -> ValidationResult:
|
||||
)
|
||||
|
||||
return False, "\n".join(error_lines)
|
||||
|
||||
|
||||
# Backwards compatibility alias - the registry uses this name
|
||||
# Now delegates to the comprehensive validator
|
||||
validate_git_commit = validate_git_command
|
||||
|
||||
@@ -33,7 +33,11 @@ from .filesystem_validators import (
|
||||
validate_init_script,
|
||||
validate_rm_command,
|
||||
)
|
||||
from .git_validators import validate_git_commit
|
||||
from .git_validators import (
|
||||
validate_git_command,
|
||||
validate_git_commit,
|
||||
validate_git_config,
|
||||
)
|
||||
from .process_validators import (
|
||||
validate_kill_command,
|
||||
validate_killall_command,
|
||||
@@ -60,6 +64,8 @@ __all__ = [
|
||||
"validate_init_script",
|
||||
# Git validators
|
||||
"validate_git_commit",
|
||||
"validate_git_command",
|
||||
"validate_git_config",
|
||||
# Database validators
|
||||
"validate_dropdb_command",
|
||||
"validate_dropuser_command",
|
||||
|
||||
@@ -19,6 +19,34 @@
|
||||
# Shows detailed information about app update checks and downloads
|
||||
# DEBUG_UPDATER=true
|
||||
|
||||
# ============================================
|
||||
# SENTRY ERROR REPORTING
|
||||
# ============================================
|
||||
|
||||
# Sentry DSN for anonymous error reporting
|
||||
# If not set, error reporting is completely disabled (safe for forks)
|
||||
#
|
||||
# For official builds: Set in CI/CD secrets
|
||||
# For local testing: Uncomment and add your DSN
|
||||
#
|
||||
# SENTRY_DSN=https://your-dsn@sentry.io/project-id
|
||||
|
||||
# Force enable Sentry in development mode (normally disabled in dev)
|
||||
# Only works when SENTRY_DSN is also set
|
||||
# SENTRY_DEV=true
|
||||
|
||||
# Trace sample rate for performance monitoring (0.0 to 1.0)
|
||||
# Controls what percentage of transactions are sampled
|
||||
# Default: 0.1 (10%) in production, 0 in development
|
||||
# Set to 0 to disable performance monitoring entirely
|
||||
# SENTRY_TRACES_SAMPLE_RATE=0.1
|
||||
|
||||
# Profile sample rate for profiling (0.0 to 1.0)
|
||||
# Controls what percentage of sampled transactions include profiling data
|
||||
# Default: 0.1 (10%) in production, 0 in development
|
||||
# Set to 0 to disable profiling entirely
|
||||
# SENTRY_PROFILES_SAMPLE_RATE=0.1
|
||||
|
||||
# ============================================
|
||||
# HOW TO USE
|
||||
# ============================================
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/electron": "^7.5.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/react-virtual": "^3.13.13",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
@@ -79,10 +80,12 @@
|
||||
"chokidar": "^5.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^16.6.1",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"i18next": "^25.7.3",
|
||||
"lucide-react": "^0.562.0",
|
||||
"minimatch": "^10.1.1",
|
||||
"motion": "^12.23.26",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"react": "^19.2.3",
|
||||
@@ -106,6 +109,7 @@
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/node": "^25.0.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -210,7 +214,7 @@
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"icon": "resources/icon.png",
|
||||
"icon": "resources/icons",
|
||||
"target": [
|
||||
"AppImage",
|
||||
"deb",
|
||||
|
||||
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 921 B |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 290 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
@@ -93,12 +93,40 @@ function runElectronRebuild() {
|
||||
* Check if node-pty is already built
|
||||
*/
|
||||
function isNodePtyBuilt() {
|
||||
const buildDir = path.join(__dirname, '..', 'node_modules', 'node-pty', 'build', 'Release');
|
||||
if (!fs.existsSync(buildDir)) return false;
|
||||
// Check traditional node-pty build location (local node_modules)
|
||||
const localBuildDir = path.join(__dirname, '..', 'node_modules', 'node-pty', 'build', 'Release');
|
||||
if (fs.existsSync(localBuildDir)) {
|
||||
const files = fs.readdirSync(localBuildDir);
|
||||
if (files.some((f) => f.endsWith('.node'))) return true;
|
||||
}
|
||||
|
||||
// Check for the main .node file
|
||||
const files = fs.readdirSync(buildDir);
|
||||
return files.some((f) => f.endsWith('.node'));
|
||||
// Check root node_modules (for npm workspaces)
|
||||
const rootBuildDir = path.join(__dirname, '..', '..', '..', 'node_modules', 'node-pty', 'build', 'Release');
|
||||
if (fs.existsSync(rootBuildDir)) {
|
||||
const files = fs.readdirSync(rootBuildDir);
|
||||
if (files.some((f) => f.endsWith('.node'))) return true;
|
||||
}
|
||||
|
||||
// Check for @lydell/node-pty with platform-specific prebuilts
|
||||
const arch = os.arch();
|
||||
const platform = os.platform();
|
||||
const platformPkg = `@lydell/node-pty-${platform}-${arch}`;
|
||||
|
||||
// Check local node_modules
|
||||
const localLydellDir = path.join(__dirname, '..', 'node_modules', platformPkg);
|
||||
if (fs.existsSync(localLydellDir)) {
|
||||
const files = fs.readdirSync(localLydellDir);
|
||||
if (files.some((f) => f.endsWith('.node'))) return true;
|
||||
}
|
||||
|
||||
// Check root node_modules (for npm workspaces)
|
||||
const rootLydellDir = path.join(__dirname, '..', '..', '..', 'node_modules', platformPkg);
|
||||
if (fs.existsSync(rootLydellDir)) {
|
||||
const files = fs.readdirSync(rootLydellDir);
|
||||
if (files.some((f) => f.endsWith('.node'))) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -56,7 +56,8 @@ export const ipcRenderer = {
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
removeAllListeners: vi.fn()
|
||||
removeAllListeners: vi.fn(),
|
||||
setMaxListeners: vi.fn()
|
||||
};
|
||||
|
||||
// Mock BrowserWindow
|
||||
@@ -125,6 +126,13 @@ export const nativeTheme = {
|
||||
on: vi.fn()
|
||||
};
|
||||
|
||||
// Mock screen
|
||||
export const screen = {
|
||||
getPrimaryDisplay: vi.fn(() => ({
|
||||
workAreaSize: { width: 1920, height: 1080 }
|
||||
}))
|
||||
};
|
||||
|
||||
export default {
|
||||
app,
|
||||
ipcMain,
|
||||
@@ -133,5 +141,6 @@ export default {
|
||||
dialog,
|
||||
contextBridge,
|
||||
shell,
|
||||
nativeTheme
|
||||
nativeTheme,
|
||||
screen
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from './sentry-electron-shared';
|
||||
@@ -0,0 +1 @@
|
||||
export * from './sentry-electron-shared';
|
||||
@@ -0,0 +1,26 @@
|
||||
export type SentryErrorEvent = Record<string, unknown>;
|
||||
|
||||
export type SentryScope = {
|
||||
setContext: (key: string, value: Record<string, unknown>) => void;
|
||||
};
|
||||
|
||||
export type SentryInitOptions = {
|
||||
beforeSend?: (event: SentryErrorEvent) => SentryErrorEvent | null;
|
||||
tracesSampleRate?: number;
|
||||
profilesSampleRate?: number;
|
||||
dsn?: string;
|
||||
environment?: string;
|
||||
release?: string;
|
||||
debug?: boolean;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export function init(_options: SentryInitOptions): void {}
|
||||
|
||||
export function captureException(_error: Error): void {}
|
||||
|
||||
export function withScope(callback: (scope: SentryScope) => void): void {
|
||||
callback({
|
||||
setContext: () => {}
|
||||
});
|
||||
}
|
||||
@@ -11,7 +11,8 @@ const mockIpcRenderer = {
|
||||
on: vi.fn(),
|
||||
once: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
removeAllListeners: vi.fn()
|
||||
removeAllListeners: vi.fn(),
|
||||
setMaxListeners: vi.fn()
|
||||
};
|
||||
|
||||
// Mock contextBridge
|
||||
|
||||
@@ -28,6 +28,14 @@ Object.defineProperty(global, 'localStorage', {
|
||||
value: localStorageMock
|
||||
});
|
||||
|
||||
// Mock scrollIntoView for Radix Select in jsdom
|
||||
if (typeof HTMLElement !== 'undefined' && !HTMLElement.prototype.scrollIntoView) {
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||||
value: vi.fn(),
|
||||
writable: true
|
||||
});
|
||||
}
|
||||
|
||||
// Test data directory for isolated file operations
|
||||
export const TEST_DATA_DIR = '/tmp/auto-claude-ui-tests';
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import path from 'path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mockGetToolPath = vi.fn<() => string>();
|
||||
const mockGetAugmentedEnv = vi.fn<() => Record<string, string>>();
|
||||
|
||||
vi.mock('../cli-tool-manager', () => ({
|
||||
getToolPath: mockGetToolPath,
|
||||
}));
|
||||
|
||||
vi.mock('../env-utils', () => ({
|
||||
getAugmentedEnv: mockGetAugmentedEnv,
|
||||
}));
|
||||
|
||||
describe('claude-cli-utils', () => {
|
||||
beforeEach(() => {
|
||||
mockGetToolPath.mockReset();
|
||||
mockGetAugmentedEnv.mockReset();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('prepends the CLI directory to PATH when the command is absolute', async () => {
|
||||
const command = process.platform === 'win32'
|
||||
? 'C:\\Tools\\claude\\claude.exe'
|
||||
: '/opt/claude/bin/claude';
|
||||
const env = {
|
||||
PATH: process.platform === 'win32'
|
||||
? 'C:\\Windows\\System32'
|
||||
: '/usr/bin',
|
||||
HOME: '/tmp',
|
||||
};
|
||||
mockGetToolPath.mockReturnValue(command);
|
||||
mockGetAugmentedEnv.mockReturnValue(env);
|
||||
|
||||
const { getClaudeCliInvocation } = await import('../claude-cli-utils');
|
||||
const result = getClaudeCliInvocation();
|
||||
|
||||
const separator = process.platform === 'win32' ? ';' : ':';
|
||||
expect(result.command).toBe(command);
|
||||
expect(result.env.PATH.split(separator)[0]).toBe(path.dirname(command));
|
||||
expect(result.env.HOME).toBe(env.HOME);
|
||||
});
|
||||
|
||||
it('sets PATH to the command directory when PATH is empty', async () => {
|
||||
const command = process.platform === 'win32'
|
||||
? 'C:\\Tools\\claude\\claude.exe'
|
||||
: '/opt/claude/bin/claude';
|
||||
const env = { PATH: '' };
|
||||
mockGetToolPath.mockReturnValue(command);
|
||||
mockGetAugmentedEnv.mockReturnValue(env);
|
||||
|
||||
const { getClaudeCliInvocation } = await import('../claude-cli-utils');
|
||||
const result = getClaudeCliInvocation();
|
||||
|
||||
expect(result.env.PATH).toBe(path.dirname(command));
|
||||
});
|
||||
|
||||
it('sets PATH to the command directory when PATH is missing', async () => {
|
||||
const command = process.platform === 'win32'
|
||||
? 'C:\\Tools\\claude\\claude.exe'
|
||||
: '/opt/claude/bin/claude';
|
||||
const env = {};
|
||||
mockGetToolPath.mockReturnValue(command);
|
||||
mockGetAugmentedEnv.mockReturnValue(env);
|
||||
|
||||
const { getClaudeCliInvocation } = await import('../claude-cli-utils');
|
||||
const result = getClaudeCliInvocation();
|
||||
|
||||
expect(result.env.PATH).toBe(path.dirname(command));
|
||||
});
|
||||
|
||||
it('keeps PATH unchanged when the command is not absolute', async () => {
|
||||
const env = {
|
||||
PATH: process.platform === 'win32'
|
||||
? 'C:\\Windows;C:\\Windows\\System32'
|
||||
: '/usr/bin:/bin',
|
||||
};
|
||||
mockGetToolPath.mockReturnValue('claude');
|
||||
mockGetAugmentedEnv.mockReturnValue(env);
|
||||
|
||||
const { getClaudeCliInvocation } = await import('../claude-cli-utils');
|
||||
const result = getClaudeCliInvocation();
|
||||
|
||||
expect(result.command).toBe('claude');
|
||||
expect(result.env.PATH).toBe(env.PATH);
|
||||
});
|
||||
|
||||
it('does not duplicate the command directory in PATH', async () => {
|
||||
const command = process.platform === 'win32'
|
||||
? 'C:\\Tools\\claude\\claude.exe'
|
||||
: '/opt/claude/bin/claude';
|
||||
const commandDir = path.dirname(command);
|
||||
const separator = process.platform === 'win32' ? ';' : ':';
|
||||
const env = { PATH: `${commandDir}${separator}/usr/bin` };
|
||||
|
||||
mockGetToolPath.mockReturnValue(command);
|
||||
mockGetAugmentedEnv.mockReturnValue(env);
|
||||
|
||||
const { getClaudeCliInvocation } = await import('../claude-cli-utils');
|
||||
const result = getClaudeCliInvocation();
|
||||
|
||||
expect(result.env.PATH).toBe(env.PATH);
|
||||
});
|
||||
|
||||
it('treats PATH entries case-insensitively on Windows', async () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', { value: 'win32' });
|
||||
|
||||
try {
|
||||
const command = 'C:\\Tools\\claude\\claude.exe';
|
||||
const env = { PATH: 'c:\\tools\\claude;C:\\Windows' };
|
||||
|
||||
mockGetToolPath.mockReturnValue(command);
|
||||
mockGetAugmentedEnv.mockReturnValue(env);
|
||||
|
||||
const { getClaudeCliInvocation } = await import('../claude-cli-utils');
|
||||
const result = getClaudeCliInvocation();
|
||||
|
||||
expect(result.env.PATH).toBe(env.PATH);
|
||||
} finally {
|
||||
if (originalPlatform) {
|
||||
Object.defineProperty(process, 'platform', originalPlatform);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Unit tests for cli-tool-manager
|
||||
* Tests CLI tool detection with focus on NVM path detection
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { existsSync, readdirSync } from 'fs';
|
||||
import os from 'os';
|
||||
import { execFileSync } from 'child_process';
|
||||
import { app } from 'electron';
|
||||
import { getToolInfo, clearToolCache } from '../cli-tool-manager';
|
||||
|
||||
// Mock Electron app
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getPath: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock os module
|
||||
vi.mock('os', () => ({
|
||||
default: {
|
||||
homedir: vi.fn(() => '/mock/home')
|
||||
}
|
||||
}));
|
||||
|
||||
// Mock fs module - need to mock both sync and promises
|
||||
vi.mock('fs', () => {
|
||||
const mockDirent = (
|
||||
name: string,
|
||||
isDir: boolean
|
||||
): { name: string; isDirectory: () => boolean } => ({
|
||||
name,
|
||||
isDirectory: () => isDir
|
||||
});
|
||||
|
||||
return {
|
||||
existsSync: vi.fn(),
|
||||
readdirSync: vi.fn(),
|
||||
promises: {}
|
||||
};
|
||||
});
|
||||
|
||||
// Mock child_process for execFileSync (used in validation)
|
||||
vi.mock('child_process', () => ({
|
||||
execFileSync: vi.fn()
|
||||
}));
|
||||
|
||||
// Mock env-utils to avoid PATH augmentation complexity
|
||||
vi.mock('../env-utils', () => ({
|
||||
findExecutable: vi.fn(() => null), // Return null to force platform-specific path checking
|
||||
getAugmentedEnv: vi.fn(() => ({ PATH: '' }))
|
||||
}));
|
||||
|
||||
// Mock homebrew-python utility
|
||||
vi.mock('../utils/homebrew-python', () => ({
|
||||
findHomebrewPython: vi.fn(() => null)
|
||||
}));
|
||||
|
||||
describe('cli-tool-manager - Claude CLI NVM detection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Set default platform to Linux
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'linux',
|
||||
writable: true
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearToolCache();
|
||||
});
|
||||
|
||||
const mockHomeDir = '/mock/home';
|
||||
|
||||
describe('NVM path detection on Unix/Linux/macOS', () => {
|
||||
it('should detect Claude CLI in NVM directory when multiple Node versions exist', () => {
|
||||
// Mock home directory
|
||||
vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
|
||||
|
||||
// Mock NVM directory exists
|
||||
vi.mocked(existsSync).mockImplementation((filePath) => {
|
||||
const pathStr = String(filePath);
|
||||
// NVM versions directory exists
|
||||
if (pathStr.includes('.nvm/versions/node')) {
|
||||
return true;
|
||||
}
|
||||
// Claude CLI exists in v22.17.0
|
||||
if (pathStr.includes('v22.17.0/bin/claude')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Mock readdirSync to return Node version directories
|
||||
vi.mocked(readdirSync).mockImplementation((filePath, options) => {
|
||||
const pathStr = String(filePath);
|
||||
if (pathStr.includes('.nvm/versions/node')) {
|
||||
return [
|
||||
{ name: 'v20.11.0', isDirectory: () => true },
|
||||
{ name: 'v22.17.0', isDirectory: () => true }
|
||||
] as any;
|
||||
}
|
||||
return [] as any;
|
||||
});
|
||||
|
||||
// Mock execFileSync to return version for validation
|
||||
vi.mocked(execFileSync).mockReturnValue('claude-code version 1.0.0\n');
|
||||
|
||||
const result = getToolInfo('claude');
|
||||
|
||||
expect(result.found).toBe(true);
|
||||
expect(result.path).toContain('v22.17.0');
|
||||
expect(result.path).toContain('bin/claude');
|
||||
expect(result.source).toBe('nvm');
|
||||
});
|
||||
|
||||
it('should try multiple NVM Node versions until finding Claude CLI', () => {
|
||||
vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
|
||||
|
||||
vi.mocked(existsSync).mockImplementation((filePath) => {
|
||||
const pathStr = String(filePath);
|
||||
if (pathStr.includes('.nvm/versions/node')) {
|
||||
return true;
|
||||
}
|
||||
// Only v24.12.0 has Claude CLI
|
||||
if (pathStr.includes('v24.12.0/bin/claude')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
vi.mocked(readdirSync).mockImplementation((filePath) => {
|
||||
const pathStr = String(filePath);
|
||||
if (pathStr.includes('.nvm/versions/node')) {
|
||||
return [
|
||||
{ name: 'v18.20.0', isDirectory: () => true },
|
||||
{ name: 'v20.11.0', isDirectory: () => true },
|
||||
{ name: 'v24.12.0', isDirectory: () => true }
|
||||
] as any;
|
||||
}
|
||||
return [] as any;
|
||||
});
|
||||
|
||||
vi.mocked(execFileSync).mockReturnValue('claude-code version 1.0.0\n');
|
||||
|
||||
const result = getToolInfo('claude');
|
||||
|
||||
expect(result.found).toBe(true);
|
||||
expect(result.path).toContain('v24.12.0');
|
||||
expect(result.source).toBe('nvm');
|
||||
});
|
||||
|
||||
it('should skip non-version directories in NVM (e.g., does not start with "v")', () => {
|
||||
vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
|
||||
|
||||
vi.mocked(existsSync).mockImplementation((filePath) => {
|
||||
const pathStr = String(filePath);
|
||||
if (pathStr.includes('.nvm/versions/node')) {
|
||||
return true;
|
||||
}
|
||||
// Only the correctly named version has Claude
|
||||
if (pathStr.includes('v22.17.0/bin/claude')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
vi.mocked(readdirSync).mockImplementation((filePath) => {
|
||||
const pathStr = String(filePath);
|
||||
if (pathStr.includes('.nvm/versions/node')) {
|
||||
return [
|
||||
{ name: 'current', isDirectory: () => true }, // Should be skipped
|
||||
{ name: 'system', isDirectory: () => true }, // Should be skipped
|
||||
{ name: 'v22.17.0', isDirectory: () => true } // Should be checked
|
||||
] as any;
|
||||
}
|
||||
return [] as any;
|
||||
});
|
||||
|
||||
vi.mocked(execFileSync).mockReturnValue('claude-code version 1.0.0\n');
|
||||
|
||||
const result = getToolInfo('claude');
|
||||
|
||||
expect(result.found).toBe(true);
|
||||
expect(result.path).toContain('v22.17.0');
|
||||
});
|
||||
|
||||
it('should not check NVM paths on Windows', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'win32',
|
||||
writable: true
|
||||
});
|
||||
|
||||
vi.mocked(os.homedir).mockReturnValue('C:\\Users\\test');
|
||||
|
||||
// Even if NVM directory exists on Windows, should not check it
|
||||
vi.mocked(existsSync).mockReturnValue(false);
|
||||
vi.mocked(readdirSync).mockReturnValue([]);
|
||||
|
||||
const result = getToolInfo('claude');
|
||||
|
||||
// Should not be found from NVM on Windows
|
||||
expect(result.source).not.toBe('nvm');
|
||||
});
|
||||
|
||||
it('should handle missing NVM directory gracefully', () => {
|
||||
vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
|
||||
|
||||
// NVM directory does not exist
|
||||
vi.mocked(existsSync).mockReturnValue(false);
|
||||
|
||||
const result = getToolInfo('claude');
|
||||
|
||||
// Should not find via NVM
|
||||
expect(result.source).not.toBe('nvm');
|
||||
expect(result.found).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle readdirSync errors gracefully', () => {
|
||||
vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
|
||||
|
||||
vi.mocked(existsSync).mockReturnValue(true);
|
||||
vi.mocked(readdirSync).mockImplementation(() => {
|
||||
throw new Error('Permission denied');
|
||||
});
|
||||
|
||||
const result = getToolInfo('claude');
|
||||
|
||||
// Should not crash, should fall back to other detection methods
|
||||
expect(result.source).not.toBe('nvm');
|
||||
});
|
||||
|
||||
it('should validate Claude CLI before returning NVM path', () => {
|
||||
vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
|
||||
|
||||
vi.mocked(existsSync).mockImplementation((filePath) => {
|
||||
const pathStr = String(filePath);
|
||||
if (pathStr.includes('.nvm/versions/node')) {
|
||||
return true;
|
||||
}
|
||||
if (pathStr.includes('v22.17.0/bin/claude')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
vi.mocked(readdirSync).mockImplementation(() => {
|
||||
return [{ name: 'v22.17.0', isDirectory: () => true }] as any;
|
||||
});
|
||||
|
||||
// Mock validation failure (execFileSync throws)
|
||||
vi.mocked(execFileSync).mockImplementation(() => {
|
||||
throw new Error('Command failed');
|
||||
});
|
||||
|
||||
const result = getToolInfo('claude');
|
||||
|
||||
// Should not return unvalidated path
|
||||
expect(result.found).toBe(false);
|
||||
expect(result.source).not.toBe('nvm');
|
||||
});
|
||||
|
||||
it('should handle NVM directory with no version subdirectories', () => {
|
||||
vi.mocked(os.homedir).mockReturnValue(mockHomeDir);
|
||||
|
||||
vi.mocked(existsSync).mockImplementation((filePath) => {
|
||||
return String(filePath).includes('.nvm/versions/node');
|
||||
});
|
||||
|
||||
// Empty NVM directory
|
||||
vi.mocked(readdirSync).mockReturnValue([]);
|
||||
|
||||
const result = getToolInfo('claude');
|
||||
|
||||
expect(result.source).not.toBe('nvm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('NVM on macOS', () => {
|
||||
it('should detect Claude CLI via NVM on macOS', () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
value: 'darwin',
|
||||
writable: true
|
||||
});
|
||||
|
||||
vi.mocked(os.homedir).mockReturnValue('/Users/test');
|
||||
|
||||
vi.mocked(existsSync).mockImplementation((filePath) => {
|
||||
const pathStr = String(filePath);
|
||||
if (pathStr.includes('.nvm/versions/node')) {
|
||||
return true;
|
||||
}
|
||||
if (pathStr.includes('v22.17.0/bin/claude')) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
vi.mocked(readdirSync).mockImplementation(() => {
|
||||
return [{ name: 'v22.17.0', isDirectory: () => true }] as any;
|
||||
});
|
||||
|
||||
vi.mocked(execFileSync).mockReturnValue('claude-code version 1.0.0\n');
|
||||
|
||||
const result = getToolInfo('claude');
|
||||
|
||||
expect(result.found).toBe(true);
|
||||
expect(result.source).toBe('nvm');
|
||||
expect(result.path).toContain('v22.17.0');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,219 @@
|
||||
import { EventEmitter } from 'events';
|
||||
import path from 'path';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
const {
|
||||
mockGetClaudeCliInvocation,
|
||||
mockGetProject,
|
||||
spawnMock,
|
||||
mockIpcMain,
|
||||
} = vi.hoisted(() => {
|
||||
const ipcMain = new (class {
|
||||
handlers = new Map<string, Function>();
|
||||
|
||||
handle(channel: string, handler: Function): void {
|
||||
this.handlers.set(channel, handler);
|
||||
}
|
||||
|
||||
getHandler(channel: string): Function | undefined {
|
||||
return this.handlers.get(channel);
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
mockGetClaudeCliInvocation: vi.fn(),
|
||||
mockGetProject: vi.fn(),
|
||||
spawnMock: vi.fn(),
|
||||
mockIpcMain: ipcMain,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../claude-cli-utils', () => ({
|
||||
getClaudeCliInvocation: mockGetClaudeCliInvocation,
|
||||
}));
|
||||
|
||||
vi.mock('../project-store', () => ({
|
||||
projectStore: {
|
||||
getProject: mockGetProject,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
spawn: spawnMock,
|
||||
}));
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: vi.fn((name: string) => {
|
||||
if (name === 'userData') return path.join('/tmp', 'userData');
|
||||
return '/tmp';
|
||||
}),
|
||||
},
|
||||
ipcMain: mockIpcMain,
|
||||
}));
|
||||
|
||||
import { registerEnvHandlers } from '../ipc-handlers/env-handlers';
|
||||
|
||||
function createProc(): EventEmitter & { stdout?: EventEmitter; stderr?: EventEmitter } {
|
||||
const proc = new EventEmitter() as EventEmitter & {
|
||||
stdout?: EventEmitter;
|
||||
stderr?: EventEmitter;
|
||||
};
|
||||
proc.stdout = new EventEmitter();
|
||||
proc.stderr = new EventEmitter();
|
||||
return proc;
|
||||
}
|
||||
|
||||
describe('env-handlers Claude CLI usage', () => {
|
||||
beforeEach(() => {
|
||||
mockGetClaudeCliInvocation.mockReset();
|
||||
mockGetProject.mockReset();
|
||||
spawnMock.mockReset();
|
||||
});
|
||||
|
||||
it('uses resolved Claude CLI path/env for auth checks', async () => {
|
||||
const claudeEnv = { PATH: '/opt/claude/bin:/usr/bin' };
|
||||
const command = '/opt/claude/bin/claude';
|
||||
mockGetClaudeCliInvocation.mockReturnValue({
|
||||
command,
|
||||
env: claudeEnv,
|
||||
});
|
||||
mockGetProject.mockReturnValue({ id: 'p1', path: '/tmp/project' });
|
||||
|
||||
const procs: ReturnType<typeof createProc>[] = [];
|
||||
spawnMock.mockImplementation(() => {
|
||||
const proc = createProc();
|
||||
procs.push(proc);
|
||||
return proc;
|
||||
});
|
||||
|
||||
registerEnvHandlers(() => null);
|
||||
const handler = mockIpcMain.getHandler(IPC_CHANNELS.ENV_CHECK_CLAUDE_AUTH);
|
||||
if (!handler) {
|
||||
throw new Error('ENV_CHECK_CLAUDE_AUTH handler not registered');
|
||||
}
|
||||
|
||||
const resultPromise = handler({}, 'p1');
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
command,
|
||||
['--version'],
|
||||
expect.objectContaining({ cwd: '/tmp/project', env: claudeEnv, shell: false })
|
||||
);
|
||||
|
||||
procs[0].emit('close', 0);
|
||||
await Promise.resolve();
|
||||
|
||||
expect(spawnMock).toHaveBeenCalledTimes(2);
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
command,
|
||||
['api', '--help'],
|
||||
expect.objectContaining({ cwd: '/tmp/project', env: claudeEnv, shell: false })
|
||||
);
|
||||
|
||||
procs[1].emit('close', 0);
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result).toEqual({ success: true, data: { success: true, authenticated: true } });
|
||||
});
|
||||
|
||||
it('uses resolved Claude CLI path/env for setup-token', async () => {
|
||||
const claudeEnv = { PATH: '/opt/claude/bin:/usr/bin' };
|
||||
const command = '/opt/claude/bin/claude';
|
||||
mockGetClaudeCliInvocation.mockReturnValue({
|
||||
command,
|
||||
env: claudeEnv,
|
||||
});
|
||||
mockGetProject.mockReturnValue({ id: 'p2', path: '/tmp/project' });
|
||||
|
||||
const proc = createProc();
|
||||
spawnMock.mockReturnValue(proc);
|
||||
|
||||
registerEnvHandlers(() => null);
|
||||
const handler = mockIpcMain.getHandler(IPC_CHANNELS.ENV_INVOKE_CLAUDE_SETUP);
|
||||
if (!handler) {
|
||||
throw new Error('ENV_INVOKE_CLAUDE_SETUP handler not registered');
|
||||
}
|
||||
|
||||
const resultPromise = handler({}, 'p2');
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
command,
|
||||
['setup-token'],
|
||||
expect.objectContaining({
|
||||
cwd: '/tmp/project',
|
||||
env: claudeEnv,
|
||||
shell: false,
|
||||
stdio: 'inherit'
|
||||
})
|
||||
);
|
||||
|
||||
proc.emit('close', 0);
|
||||
const result = await resultPromise;
|
||||
expect(result).toEqual({ success: true, data: { success: true, authenticated: true } });
|
||||
});
|
||||
|
||||
it('returns an error when Claude CLI resolution throws', async () => {
|
||||
mockGetClaudeCliInvocation.mockImplementation(() => {
|
||||
throw new Error('Claude CLI exploded');
|
||||
});
|
||||
mockGetProject.mockReturnValue({ id: 'p3', path: '/tmp/project' });
|
||||
|
||||
registerEnvHandlers(() => null);
|
||||
const handler = mockIpcMain.getHandler(IPC_CHANNELS.ENV_CHECK_CLAUDE_AUTH);
|
||||
if (!handler) {
|
||||
throw new Error('ENV_CHECK_CLAUDE_AUTH handler not registered');
|
||||
}
|
||||
|
||||
const result = await handler({}, 'p3');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Claude CLI exploded');
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns an error when Claude CLI command is missing', async () => {
|
||||
mockGetClaudeCliInvocation.mockReturnValue({ command: '', env: {} });
|
||||
mockGetProject.mockReturnValue({ id: 'p4', path: '/tmp/project' });
|
||||
|
||||
registerEnvHandlers(() => null);
|
||||
const handler = mockIpcMain.getHandler(IPC_CHANNELS.ENV_CHECK_CLAUDE_AUTH);
|
||||
if (!handler) {
|
||||
throw new Error('ENV_CHECK_CLAUDE_AUTH handler not registered');
|
||||
}
|
||||
|
||||
const result = await handler({}, 'p4');
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Claude CLI path not resolved');
|
||||
expect(spawnMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns an error when Claude CLI exits with a non-zero code', async () => {
|
||||
const claudeEnv = { PATH: '/opt/claude/bin:/usr/bin' };
|
||||
const command = '/opt/claude/bin/claude';
|
||||
mockGetClaudeCliInvocation.mockReturnValue({
|
||||
command,
|
||||
env: claudeEnv,
|
||||
});
|
||||
mockGetProject.mockReturnValue({ id: 'p5', path: '/tmp/project' });
|
||||
|
||||
const proc = createProc();
|
||||
spawnMock.mockReturnValue(proc);
|
||||
|
||||
registerEnvHandlers(() => null);
|
||||
const handler = mockIpcMain.getHandler(IPC_CHANNELS.ENV_CHECK_CLAUDE_AUTH);
|
||||
if (!handler) {
|
||||
throw new Error('ENV_CHECK_CLAUDE_AUTH handler not registered');
|
||||
}
|
||||
|
||||
const resultPromise = handler({}, 'p5');
|
||||
expect(spawnMock).toHaveBeenCalledWith(
|
||||
command,
|
||||
['--version'],
|
||||
expect.objectContaining({ cwd: '/tmp/project', env: claudeEnv, shell: false })
|
||||
);
|
||||
proc.emit('close', 1);
|
||||
|
||||
const result = await resultPromise;
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Claude CLI not found');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import path from 'path';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { InsightsConfig } from '../insights/config';
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getAppPath: () => '/app',
|
||||
getPath: () => '/tmp',
|
||||
isPackaged: false
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('../rate-limit-detector', () => ({
|
||||
getProfileEnv: () => ({ CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token' })
|
||||
}));
|
||||
|
||||
const mockGetApiProfileEnv = vi.fn();
|
||||
vi.mock('../services/profile', () => ({
|
||||
getAPIProfileEnv: (...args: unknown[]) => mockGetApiProfileEnv(...args)
|
||||
}));
|
||||
|
||||
const mockGetPythonEnv = vi.fn();
|
||||
vi.mock('../python-env-manager', () => ({
|
||||
pythonEnvManager: {
|
||||
getPythonEnv: () => mockGetPythonEnv()
|
||||
}
|
||||
}));
|
||||
|
||||
describe('InsightsConfig', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv, TEST_ENV: 'ok' };
|
||||
mockGetApiProfileEnv.mockResolvedValue({
|
||||
ANTHROPIC_BASE_URL: 'https://api.z.ai',
|
||||
ANTHROPIC_AUTH_TOKEN: 'key'
|
||||
});
|
||||
mockGetPythonEnv.mockReturnValue({ PYTHONPATH: '/site-packages' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
vi.clearAllMocks();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should build process env with python and profile settings', async () => {
|
||||
const config = new InsightsConfig();
|
||||
vi.spyOn(config, 'loadAutoBuildEnv').mockReturnValue({ CUSTOM_ENV: '1' });
|
||||
vi.spyOn(config, 'getAutoBuildSourcePath').mockReturnValue('/backend');
|
||||
|
||||
const env = await config.getProcessEnv();
|
||||
|
||||
expect(env.TEST_ENV).toBe('ok');
|
||||
expect(env.CUSTOM_ENV).toBe('1');
|
||||
expect(env.CLAUDE_CODE_OAUTH_TOKEN).toBe('oauth-token');
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('https://api.z.ai');
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('key');
|
||||
expect(env.PYTHONPATH).toBe(['/site-packages', '/backend'].join(path.delimiter));
|
||||
});
|
||||
|
||||
it('should clear ANTHROPIC env vars in OAuth mode when no API profile is set', async () => {
|
||||
const config = new InsightsConfig();
|
||||
mockGetApiProfileEnv.mockResolvedValue({});
|
||||
process.env = {
|
||||
...originalEnv,
|
||||
ANTHROPIC_AUTH_TOKEN: 'stale-token',
|
||||
ANTHROPIC_BASE_URL: 'https://stale.example'
|
||||
};
|
||||
|
||||
const env = await config.getProcessEnv();
|
||||
|
||||
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('');
|
||||
expect(env.ANTHROPIC_BASE_URL).toBe('');
|
||||
});
|
||||
|
||||
it('should set PYTHONPATH only to auto-build path when python env has none', async () => {
|
||||
const config = new InsightsConfig();
|
||||
mockGetPythonEnv.mockReturnValue({});
|
||||
vi.spyOn(config, 'getAutoBuildSourcePath').mockReturnValue('/backend');
|
||||
|
||||
const env = await config.getProcessEnv();
|
||||
|
||||
expect(env.PYTHONPATH).toBe('/backend');
|
||||
});
|
||||
|
||||
it('should keep PYTHONPATH from python env when auto-build path is missing', async () => {
|
||||
const config = new InsightsConfig();
|
||||
mockGetPythonEnv.mockReturnValue({ PYTHONPATH: '/site-packages' });
|
||||
vi.spyOn(config, 'getAutoBuildSourcePath').mockReturnValue(null);
|
||||
|
||||
const env = await config.getProcessEnv();
|
||||
|
||||
expect(env.PYTHONPATH).toBe('/site-packages');
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,62 @@ import { readSettingsFile } from '../settings-utils';
|
||||
import type { AppSettings } from '../../shared/types/settings';
|
||||
import { getOAuthModeClearVars } from './env-utils';
|
||||
import { getAugmentedEnv } from '../env-utils';
|
||||
import { getToolInfo } from '../cli-tool-manager';
|
||||
|
||||
|
||||
function deriveGitBashPath(gitExePath: string): string | null {
|
||||
if (process.platform !== 'win32') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const gitDir = path.dirname(gitExePath); // e.g., D:\...\Git\mingw64\bin
|
||||
const gitDirName = path.basename(gitDir).toLowerCase();
|
||||
|
||||
// Find Git installation root
|
||||
let gitRoot: string;
|
||||
|
||||
if (gitDirName === 'cmd') {
|
||||
// .../Git/cmd/git.exe -> .../Git
|
||||
gitRoot = path.dirname(gitDir);
|
||||
} else if (gitDirName === 'bin') {
|
||||
// Could be .../Git/bin/git.exe OR .../Git/mingw64/bin/git.exe
|
||||
const parent = path.dirname(gitDir);
|
||||
const parentName = path.basename(parent).toLowerCase();
|
||||
if (parentName === 'mingw64' || parentName === 'mingw32') {
|
||||
// .../Git/mingw64/bin/git.exe -> .../Git
|
||||
gitRoot = path.dirname(parent);
|
||||
} else {
|
||||
// .../Git/bin/git.exe -> .../Git
|
||||
gitRoot = parent;
|
||||
}
|
||||
} else {
|
||||
// Unknown structure - try to find 'bin' sibling
|
||||
gitRoot = path.dirname(gitDir);
|
||||
}
|
||||
|
||||
// Bash.exe is in Git/bin/bash.exe
|
||||
const bashPath = path.join(gitRoot, 'bin', 'bash.exe');
|
||||
|
||||
if (existsSync(bashPath)) {
|
||||
console.log('[AgentProcess] Derived git-bash path:', bashPath);
|
||||
return bashPath;
|
||||
}
|
||||
|
||||
// Fallback: check one level up if gitRoot didn't work
|
||||
const altBashPath = path.join(path.dirname(gitRoot), 'bin', 'bash.exe');
|
||||
if (existsSync(altBashPath)) {
|
||||
console.log('[AgentProcess] Found git-bash at alternate path:', altBashPath);
|
||||
return altBashPath;
|
||||
}
|
||||
|
||||
console.warn('[AgentProcess] Could not find bash.exe from git path:', gitExePath);
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('[AgentProcess] Error deriving git-bash path:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process spawning and lifecycle management
|
||||
@@ -59,8 +115,28 @@ export class AgentProcessManager {
|
||||
// Use getAugmentedEnv() to ensure common tool paths (dotnet, homebrew, etc.)
|
||||
// are available even when app is launched from Finder/Dock
|
||||
const augmentedEnv = getAugmentedEnv();
|
||||
|
||||
// On Windows, detect and pass git-bash path for Claude Code CLI
|
||||
// Electron can detect git via where.exe, but Python subprocess may not have the same PATH
|
||||
const gitBashEnv: Record<string, string> = {};
|
||||
if (process.platform === 'win32' && !process.env.CLAUDE_CODE_GIT_BASH_PATH) {
|
||||
try {
|
||||
const gitInfo = getToolInfo('git');
|
||||
if (gitInfo.found && gitInfo.path) {
|
||||
const bashPath = deriveGitBashPath(gitInfo.path);
|
||||
if (bashPath) {
|
||||
gitBashEnv['CLAUDE_CODE_GIT_BASH_PATH'] = bashPath;
|
||||
console.log('[AgentProcess] Setting CLAUDE_CODE_GIT_BASH_PATH:', bashPath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[AgentProcess] Failed to detect git-bash path:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...augmentedEnv,
|
||||
...gitBashEnv,
|
||||
...extraEnv,
|
||||
...profileEnv,
|
||||
PYTHONUNBUFFERED: '1',
|
||||
|
||||
@@ -7,7 +7,6 @@ import { AgentEvents } from './agent-events';
|
||||
import { AgentProcessManager } from './agent-process';
|
||||
import { RoadmapConfig } from './types';
|
||||
import type { IdeationConfig, Idea } from '../../shared/types';
|
||||
import { MODEL_ID_MAP } from '../../shared/constants';
|
||||
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from '../rate-limit-detector';
|
||||
import { getAPIProfileEnv } from '../services/profile';
|
||||
import { getOAuthModeClearVars } from './env-utils';
|
||||
@@ -96,9 +95,9 @@ export class AgentQueueManager {
|
||||
}
|
||||
|
||||
// Add model and thinking level from config
|
||||
// Pass shorthand (opus/sonnet/haiku) - backend resolves using API profile env vars
|
||||
if (config?.model) {
|
||||
const modelId = MODEL_ID_MAP[config.model] || MODEL_ID_MAP['opus'];
|
||||
args.push('--model', modelId);
|
||||
args.push('--model', config.model);
|
||||
}
|
||||
if (config?.thinkingLevel) {
|
||||
args.push('--thinking-level', config.thinkingLevel);
|
||||
@@ -172,9 +171,9 @@ export class AgentQueueManager {
|
||||
}
|
||||
|
||||
// Add model and thinking level from config
|
||||
// Pass shorthand (opus/sonnet/haiku) - backend resolves using API profile env vars
|
||||
if (config.model) {
|
||||
const modelId = MODEL_ID_MAP[config.model] || MODEL_ID_MAP['opus'];
|
||||
args.push('--model', modelId);
|
||||
args.push('--model', config.model);
|
||||
}
|
||||
if (config.thinkingLevel) {
|
||||
args.push('--thinking-level', config.thinkingLevel);
|
||||
|
||||
@@ -12,6 +12,7 @@ describe('getOAuthModeClearVars', () => {
|
||||
const result = getOAuthModeClearVars({});
|
||||
|
||||
expect(result).toEqual({
|
||||
ANTHROPIC_API_KEY: '',
|
||||
ANTHROPIC_AUTH_TOKEN: '',
|
||||
ANTHROPIC_BASE_URL: '',
|
||||
ANTHROPIC_MODEL: '',
|
||||
@@ -25,6 +26,7 @@ describe('getOAuthModeClearVars', () => {
|
||||
const result = getOAuthModeClearVars({});
|
||||
|
||||
// Verify all known ANTHROPIC_* vars are cleared
|
||||
expect(result.ANTHROPIC_API_KEY).toBe('');
|
||||
expect(result.ANTHROPIC_AUTH_TOKEN).toBe('');
|
||||
expect(result.ANTHROPIC_BASE_URL).toBe('');
|
||||
expect(result.ANTHROPIC_MODEL).toBe('');
|
||||
@@ -85,6 +87,7 @@ describe('getOAuthModeClearVars', () => {
|
||||
|
||||
// Should treat null as OAuth mode and return clearing vars
|
||||
expect(result).toEqual({
|
||||
ANTHROPIC_API_KEY: '',
|
||||
ANTHROPIC_AUTH_TOKEN: '',
|
||||
ANTHROPIC_BASE_URL: '',
|
||||
ANTHROPIC_MODEL: '',
|
||||
@@ -101,6 +104,7 @@ describe('getOAuthModeClearVars', () => {
|
||||
expect(result1).toEqual(result2);
|
||||
// Use specific expected keys instead of magic number
|
||||
const expectedKeys = [
|
||||
'ANTHROPIC_API_KEY',
|
||||
'ANTHROPIC_AUTH_TOKEN',
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_MODEL',
|
||||
@@ -117,6 +121,7 @@ describe('getOAuthModeClearVars', () => {
|
||||
|
||||
// Should treat as OAuth mode since no ANTHROPIC_* keys present
|
||||
expect(result).toEqual({
|
||||
ANTHROPIC_API_KEY: '',
|
||||
ANTHROPIC_AUTH_TOKEN: '',
|
||||
ANTHROPIC_BASE_URL: '',
|
||||
ANTHROPIC_MODEL: '',
|
||||
|
||||
@@ -28,7 +28,12 @@ export function getOAuthModeClearVars(apiProfileEnv: Record<string, string>): Re
|
||||
// In OAuth mode (no API profile), clear all ANTHROPIC_* vars
|
||||
// Setting to empty string ensures they override any values from process.env
|
||||
// Python's `if token:` checks treat empty strings as falsy
|
||||
//
|
||||
// IMPORTANT: ANTHROPIC_API_KEY is included to prevent Claude Code from using
|
||||
// API keys that may be present in the shell environment instead of OAuth tokens.
|
||||
// Without clearing this, Claude Code would show "Claude API" instead of "Claude Max".
|
||||
return {
|
||||
ANTHROPIC_API_KEY: '',
|
||||
ANTHROPIC_AUTH_TOKEN: '',
|
||||
ANTHROPIC_BASE_URL: '',
|
||||
ANTHROPIC_MODEL: '',
|
||||
|
||||
@@ -18,12 +18,16 @@
|
||||
*/
|
||||
|
||||
import { autoUpdater } from 'electron-updater';
|
||||
import { app } from 'electron';
|
||||
import { app, net } from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { IPC_CHANNELS } from '../shared/constants';
|
||||
import type { AppUpdateInfo } from '../shared/types';
|
||||
import { compareVersions } from './updater/version-manager';
|
||||
|
||||
// GitHub repo info for API calls
|
||||
const GITHUB_OWNER = 'AndyMik90';
|
||||
const GITHUB_REPO = 'Auto-Claude';
|
||||
|
||||
// Debug mode - DEBUG_UPDATER=true or development mode
|
||||
const DEBUG_UPDATER = process.env.DEBUG_UPDATER === 'true' || process.env.NODE_ENV === 'development';
|
||||
|
||||
@@ -251,3 +255,214 @@ export function quitAndInstall(): void {
|
||||
export function getCurrentVersion(): string {
|
||||
return autoUpdater.currentVersion.version;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a version string represents a prerelease (beta, alpha, rc, etc.)
|
||||
*/
|
||||
export function isPrerelease(version: string): boolean {
|
||||
return /-(alpha|beta|rc|dev|canary)\.\d+$/i.test(version) || version.includes('-');
|
||||
}
|
||||
|
||||
// Timeout for GitHub API requests (10 seconds)
|
||||
const GITHUB_API_TIMEOUT = 10000;
|
||||
|
||||
/**
|
||||
* Fetch the latest stable release from GitHub API
|
||||
* Returns the latest non-prerelease version
|
||||
*/
|
||||
async function fetchLatestStableRelease(): Promise<AppUpdateInfo | null> {
|
||||
const fetchPromise = new Promise<AppUpdateInfo | null>((resolve) => {
|
||||
const url = `https://api.github.com/repos/${GITHUB_OWNER}/${GITHUB_REPO}/releases`;
|
||||
console.warn('[app-updater] Fetching releases from:', url);
|
||||
|
||||
const request = net.request({
|
||||
url,
|
||||
method: 'GET'
|
||||
});
|
||||
|
||||
request.setHeader('Accept', 'application/vnd.github.v3+json');
|
||||
request.setHeader('User-Agent', `Auto-Claude/${getCurrentVersion()}`);
|
||||
|
||||
let data = '';
|
||||
|
||||
request.on('response', (response) => {
|
||||
// Validate HTTP status code
|
||||
const statusCode = response.statusCode;
|
||||
if (statusCode !== 200) {
|
||||
// Sanitize statusCode to prevent log injection
|
||||
// Convert to number and validate range to ensure it's a valid HTTP status code
|
||||
const numericCode = Number(statusCode);
|
||||
const safeStatusCode = (Number.isInteger(numericCode) && numericCode >= 100 && numericCode < 600)
|
||||
? String(numericCode)
|
||||
: 'unknown';
|
||||
console.error(`[app-updater] GitHub API error: HTTP ${safeStatusCode}`);
|
||||
if (statusCode === 403) {
|
||||
console.error('[app-updater] Rate limit may have been exceeded');
|
||||
} else if (statusCode === 404) {
|
||||
console.error('[app-updater] Repository or releases not found');
|
||||
}
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
response.on('data', (chunk) => {
|
||||
data += chunk.toString();
|
||||
});
|
||||
|
||||
response.on('end', () => {
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
|
||||
// Validate response is an array
|
||||
if (!Array.isArray(parsed)) {
|
||||
console.error('[app-updater] Unexpected response format - expected array, got:', typeof parsed);
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const releases = parsed as Array<{
|
||||
tag_name: string;
|
||||
prerelease: boolean;
|
||||
draft: boolean;
|
||||
body?: string;
|
||||
published_at?: string;
|
||||
html_url?: string;
|
||||
}>;
|
||||
|
||||
// Find the first non-prerelease, non-draft release
|
||||
const latestStable = releases.find(r => !r.prerelease && !r.draft);
|
||||
|
||||
if (!latestStable) {
|
||||
console.warn('[app-updater] No stable release found');
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const version = latestStable.tag_name.replace(/^v/, '');
|
||||
// Sanitize version string for logging (remove control characters and limit length)
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const safeVersion = String(version).replace(/[\x00-\x1f\x7f]/g, '').slice(0, 50);
|
||||
console.warn('[app-updater] Found latest stable release:', safeVersion);
|
||||
|
||||
resolve({
|
||||
version,
|
||||
releaseNotes: latestStable.body,
|
||||
releaseDate: latestStable.published_at
|
||||
});
|
||||
} catch (e) {
|
||||
// Sanitize error message for logging (prevent log injection from malformed JSON)
|
||||
const safeError = e instanceof Error ? e.message : 'Unknown parse error';
|
||||
console.error('[app-updater] Failed to parse releases JSON:', safeError);
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
request.on('error', (error) => {
|
||||
// Sanitize error message for logging (use only the message property)
|
||||
const safeErrorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('[app-updater] Failed to fetch releases:', safeErrorMessage);
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
request.end();
|
||||
});
|
||||
|
||||
// Add timeout to prevent hanging indefinitely
|
||||
const timeoutPromise = new Promise<AppUpdateInfo | null>((resolve) => {
|
||||
setTimeout(() => {
|
||||
console.error(`[app-updater] GitHub API request timed out after ${GITHUB_API_TIMEOUT}ms`);
|
||||
resolve(null);
|
||||
}, GITHUB_API_TIMEOUT);
|
||||
});
|
||||
|
||||
return Promise.race([fetchPromise, timeoutPromise]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we should offer a downgrade to stable
|
||||
* Called when user disables beta updates while on a prerelease version
|
||||
*
|
||||
* Returns the latest stable version if:
|
||||
* 1. Current version is a prerelease
|
||||
* 2. A stable version exists
|
||||
*/
|
||||
export async function checkForStableDowngrade(): Promise<AppUpdateInfo | null> {
|
||||
const currentVersion = getCurrentVersion();
|
||||
|
||||
// Only check for downgrade if currently on a prerelease
|
||||
if (!isPrerelease(currentVersion)) {
|
||||
console.warn('[app-updater] Current version is not a prerelease, no downgrade needed');
|
||||
return null;
|
||||
}
|
||||
|
||||
console.warn('[app-updater] Current version is prerelease:', currentVersion);
|
||||
console.warn('[app-updater] Checking for stable version to downgrade to...');
|
||||
|
||||
const latestStable = await fetchLatestStableRelease();
|
||||
|
||||
if (!latestStable) {
|
||||
console.warn('[app-updater] No stable release available for downgrade');
|
||||
return null;
|
||||
}
|
||||
|
||||
console.warn('[app-updater] Stable downgrade available:', latestStable.version);
|
||||
return latestStable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set update channel with optional downgrade check
|
||||
* When switching from beta to stable, checks if user should be offered a downgrade
|
||||
*
|
||||
* @param channel - The update channel to switch to
|
||||
* @param triggerDowngradeCheck - Whether to check for stable downgrade (when disabling beta)
|
||||
*/
|
||||
export async function setUpdateChannelWithDowngradeCheck(
|
||||
channel: UpdateChannel,
|
||||
triggerDowngradeCheck = false
|
||||
): Promise<AppUpdateInfo | null> {
|
||||
autoUpdater.channel = channel;
|
||||
console.warn(`[app-updater] Update channel set to: ${channel}`);
|
||||
|
||||
// If switching to stable and downgrade check requested, look for stable version
|
||||
if (channel === 'latest' && triggerDowngradeCheck) {
|
||||
const stableVersion = await checkForStableDowngrade();
|
||||
|
||||
if (stableVersion && mainWindow) {
|
||||
// Notify the renderer about the available stable downgrade
|
||||
mainWindow.webContents.send(IPC_CHANNELS.APP_UPDATE_STABLE_DOWNGRADE, stableVersion);
|
||||
}
|
||||
|
||||
return stableVersion;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a specific version (for downgrade)
|
||||
* Uses electron-updater with allowDowngrade enabled to download older stable versions
|
||||
*/
|
||||
export async function downloadStableVersion(): Promise<void> {
|
||||
// Switch to stable channel
|
||||
autoUpdater.channel = 'latest';
|
||||
// Enable downgrade to allow downloading older versions (e.g., stable when on beta)
|
||||
autoUpdater.allowDowngrade = true;
|
||||
console.warn('[app-updater] Downloading stable version (allowDowngrade=true)...');
|
||||
|
||||
try {
|
||||
// Force a fresh check on the stable channel, then download
|
||||
const result = await autoUpdater.checkForUpdates();
|
||||
if (result) {
|
||||
await autoUpdater.downloadUpdate();
|
||||
} else {
|
||||
throw new Error('No stable version available for download');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[app-updater] Failed to download stable version:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
// Reset allowDowngrade to prevent unintended downgrades in normal update checks
|
||||
autoUpdater.allowDowngrade = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* Auto Claude Source Updater
|
||||
*
|
||||
* Checks GitHub Releases for updates and downloads them.
|
||||
* GitHub Releases are the single source of truth for versioning.
|
||||
*
|
||||
* Update flow:
|
||||
* 1. Check GitHub Releases API for the latest release
|
||||
* 2. Compare release tag with current app version
|
||||
* 3. If update available, download release tarball and apply
|
||||
* 4. Existing project update system handles pushing to individual projects
|
||||
*
|
||||
* Versioning:
|
||||
* - Single source of truth: GitHub Releases
|
||||
* - Current version: app.getVersion() (from package.json at build time)
|
||||
* - Latest version: Fetched from GitHub Releases API
|
||||
* - To release: Create a GitHub release with tag (e.g., v1.2.0)
|
||||
*/
|
||||
|
||||
// Export types
|
||||
export type {
|
||||
GitHubRelease,
|
||||
AutoBuildUpdateCheck,
|
||||
AutoBuildUpdateResult,
|
||||
UpdateProgressCallback,
|
||||
UpdateMetadata
|
||||
} from './updater/types';
|
||||
|
||||
// Export version management
|
||||
export { getBundledVersion, getEffectiveVersion } from './updater/version-manager';
|
||||
|
||||
// Export path resolution
|
||||
export {
|
||||
getBundledSourcePath,
|
||||
getEffectiveSourcePath
|
||||
} from './updater/path-resolver';
|
||||
|
||||
// Export update checking
|
||||
export { checkForUpdates } from './updater/update-checker';
|
||||
|
||||
// Export update installation
|
||||
export { downloadAndApplyUpdate } from './updater/update-installer';
|
||||
|
||||
// Export update status
|
||||
export {
|
||||
hasPendingSourceUpdate,
|
||||
getUpdateMetadata
|
||||
} from './updater/update-status';
|
||||
@@ -1,5 +1,4 @@
|
||||
import { spawn } from 'child_process';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import type { GitCommit } from '../../shared/types';
|
||||
import { getProfileEnv } from '../rate-limit-detector';
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import path from 'path';
|
||||
import { getAugmentedEnv } from './env-utils';
|
||||
import { getToolPath } from './cli-tool-manager';
|
||||
|
||||
export type ClaudeCliInvocation = {
|
||||
command: string;
|
||||
env: Record<string, string>;
|
||||
};
|
||||
|
||||
function ensureCommandDirInPath(command: string, env: Record<string, string>): Record<string, string> {
|
||||
if (!path.isAbsolute(command)) {
|
||||
return env;
|
||||
}
|
||||
|
||||
const pathSeparator = process.platform === 'win32' ? ';' : ':';
|
||||
const commandDir = path.dirname(command);
|
||||
const currentPath = env.PATH || '';
|
||||
const pathEntries = currentPath.split(pathSeparator);
|
||||
const normalizedCommandDir = path.normalize(commandDir);
|
||||
const hasCommandDir = process.platform === 'win32'
|
||||
? pathEntries
|
||||
.map((entry) => path.normalize(entry).toLowerCase())
|
||||
.includes(normalizedCommandDir.toLowerCase())
|
||||
: pathEntries
|
||||
.map((entry) => path.normalize(entry))
|
||||
.includes(normalizedCommandDir);
|
||||
|
||||
if (hasCommandDir) {
|
||||
return env;
|
||||
}
|
||||
|
||||
return {
|
||||
...env,
|
||||
PATH: [commandDir, currentPath].filter(Boolean).join(pathSeparator),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Claude CLI command path and an environment with PATH updated to include the CLI directory.
|
||||
*/
|
||||
export function getClaudeCliInvocation(): ClaudeCliInvocation {
|
||||
const command = getToolPath('claude');
|
||||
const env = getAugmentedEnv();
|
||||
|
||||
return {
|
||||
command,
|
||||
env: ensureCommandDirInPath(command, env),
|
||||
};
|
||||
}
|
||||
@@ -21,13 +21,18 @@
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'child_process';
|
||||
import { existsSync } from 'fs';
|
||||
import { existsSync, readdirSync } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { app } from 'electron';
|
||||
import { findExecutable } from './env-utils';
|
||||
import { findExecutable, getAugmentedEnv } from './env-utils';
|
||||
import type { ToolDetectionResult } from '../shared/types';
|
||||
import { findHomebrewPython as findHomebrewPythonUtil } from './utils/homebrew-python';
|
||||
import {
|
||||
getWindowsExecutablePaths,
|
||||
WINDOWS_GIT_PATHS,
|
||||
findWindowsExecutableViaWhere,
|
||||
} from './utils/windows-paths';
|
||||
|
||||
/**
|
||||
* Supported CLI tools managed by this system
|
||||
@@ -392,7 +397,40 @@ class CLIToolManager {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Not found - fallback to 'git'
|
||||
// 4. Windows-specific detection using 'where' command (most reliable for custom installs)
|
||||
if (process.platform === 'win32') {
|
||||
// First try 'where' command - finds git regardless of installation location
|
||||
const whereGitPath = findWindowsExecutableViaWhere('git', '[Git]');
|
||||
if (whereGitPath) {
|
||||
const validation = this.validateGit(whereGitPath);
|
||||
if (validation.valid) {
|
||||
return {
|
||||
found: true,
|
||||
path: whereGitPath,
|
||||
version: validation.version,
|
||||
source: 'system-path',
|
||||
message: `Using Windows Git: ${whereGitPath}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to checking common installation paths
|
||||
const windowsPaths = getWindowsExecutablePaths(WINDOWS_GIT_PATHS, '[Git]');
|
||||
for (const winGitPath of windowsPaths) {
|
||||
const validation = this.validateGit(winGitPath);
|
||||
if (validation.valid) {
|
||||
return {
|
||||
found: true,
|
||||
path: winGitPath,
|
||||
version: validation.version,
|
||||
source: 'system-path',
|
||||
message: `Using Windows Git: ${winGitPath}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Not found - fallback to 'git'
|
||||
return {
|
||||
found: false,
|
||||
source: 'fallback',
|
||||
@@ -594,6 +632,50 @@ class CLIToolManager {
|
||||
path.join(homeDir, 'bin', 'claude'),
|
||||
];
|
||||
|
||||
// 4.5. NVM (Node Version Manager) paths for Unix/Linux/macOS
|
||||
// NVM installs global npm packages in ~/.nvm/versions/node/vX.X.X/bin/
|
||||
// This is important when the app launches from GUI without NVM sourced
|
||||
if (process.platform !== 'win32') {
|
||||
const nvmVersionsDir = path.join(homeDir, '.nvm', 'versions', 'node');
|
||||
try {
|
||||
if (existsSync(nvmVersionsDir)) {
|
||||
const nodeVersions = readdirSync(nvmVersionsDir, { withFileTypes: true });
|
||||
const versionDirs = nodeVersions
|
||||
.filter((entry) => entry.isDirectory() && entry.name.startsWith('v'))
|
||||
.sort((a, b) => {
|
||||
const vA = a.name.slice(1).split('.').map(Number);
|
||||
const vB = b.name.slice(1).split('.').map(Number);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const diff = (vB[i] ?? 0) - (vA[i] ?? 0);
|
||||
if (diff !== 0) {
|
||||
return diff;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
for (const entry of versionDirs) {
|
||||
const nvmClaudePath = path.join(nvmVersionsDir, entry.name, 'bin', 'claude');
|
||||
if (existsSync(nvmClaudePath)) {
|
||||
const validation = this.validateClaude(nvmClaudePath);
|
||||
if (validation.valid) {
|
||||
return {
|
||||
found: true,
|
||||
path: nvmClaudePath,
|
||||
version: validation.version,
|
||||
source: 'nvm',
|
||||
message: `Using NVM Claude CLI: ${nvmClaudePath}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently fail if unable to read NVM directory
|
||||
console.warn(`[Claude CLI] Unable to read NVM directory: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const claudePath of platformPaths) {
|
||||
if (existsSync(claudePath)) {
|
||||
const validation = this.validateClaude(claudePath);
|
||||
@@ -759,6 +841,7 @@ class CLIToolManager {
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
shell: needsShell,
|
||||
env: getAugmentedEnv(),
|
||||
}).trim();
|
||||
|
||||
// Claude CLI version output format: "claude-code version X.Y.Z" or similar
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
import { app, BrowserWindow, shell, nativeImage, session } from 'electron';
|
||||
// Load .env file FIRST before any other imports that might use process.env
|
||||
import { config } from 'dotenv';
|
||||
import { resolve, dirname } from 'path';
|
||||
import { existsSync } from 'fs';
|
||||
|
||||
// Load .env from apps/frontend directory
|
||||
// In development: __dirname is out/main (compiled), so go up 2 levels
|
||||
// In production: app resources directory
|
||||
const possibleEnvPaths = [
|
||||
resolve(__dirname, '../../.env'), // Development: out/main -> apps/frontend/.env
|
||||
resolve(__dirname, '../../../.env'), // Alternative: might be in different location
|
||||
resolve(process.cwd(), 'apps/frontend/.env'), // Fallback: from workspace root
|
||||
];
|
||||
|
||||
for (const envPath of possibleEnvPaths) {
|
||||
if (existsSync(envPath)) {
|
||||
config({ path: envPath });
|
||||
console.log(`[dotenv] Loaded environment from: ${envPath}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
import { app, BrowserWindow, shell, nativeImage, session, screen } from 'electron';
|
||||
import { join } from 'path';
|
||||
import { accessSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { accessSync, readFileSync, writeFileSync, rmSync } from 'fs';
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils';
|
||||
import { setupIpcHandlers } from './ipc-setup';
|
||||
import { AgentManager } from './agent';
|
||||
@@ -12,11 +34,32 @@ import { initializeAppUpdater } from './app-updater';
|
||||
import { DEFAULT_APP_SETTINGS } from '../shared/constants';
|
||||
import { readSettingsFile } from './settings-utils';
|
||||
import { setupErrorLogging } from './app-logger';
|
||||
import { initSentryMain } from './sentry';
|
||||
import type { AppSettings } from '../shared/types';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Window sizing constants
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
/** Preferred window width on startup */
|
||||
const WINDOW_PREFERRED_WIDTH: number = 1400;
|
||||
/** Preferred window height on startup */
|
||||
const WINDOW_PREFERRED_HEIGHT: number = 900;
|
||||
/** Absolute minimum window width (supports high DPI displays with scaling) */
|
||||
const WINDOW_MIN_WIDTH: number = 800;
|
||||
/** Absolute minimum window height (supports high DPI displays with scaling) */
|
||||
const WINDOW_MIN_HEIGHT: number = 500;
|
||||
/** Margin from screen edges to avoid edge-to-edge windows */
|
||||
const WINDOW_SCREEN_MARGIN: number = 20;
|
||||
/** Default screen dimensions used as fallback when screen.getPrimaryDisplay() fails */
|
||||
const DEFAULT_SCREEN_WIDTH: number = 1920;
|
||||
const DEFAULT_SCREEN_HEIGHT: number = 1080;
|
||||
|
||||
// Setup error logging early (captures uncaught exceptions)
|
||||
setupErrorLogging();
|
||||
|
||||
// Initialize Sentry for error tracking (respects user's sentryEnabled setting)
|
||||
initSentryMain();
|
||||
|
||||
/**
|
||||
* Load app settings synchronously (for use during startup).
|
||||
* This is a simple merge with defaults - no migrations or auto-detection.
|
||||
@@ -26,6 +69,32 @@ function loadSettingsSync(): AppSettings {
|
||||
return { ...DEFAULT_APP_SETTINGS, ...savedSettings } as AppSettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up stale update metadata files from the redundant source updater system.
|
||||
*
|
||||
* The old "source updater" wrote .update-metadata.json files that could persist
|
||||
* across app updates and cause version display desync. This cleanup ensures
|
||||
* we use the actual bundled version from app.getVersion().
|
||||
*/
|
||||
function cleanupStaleUpdateMetadata(): void {
|
||||
const userData = app.getPath('userData');
|
||||
const stalePaths = [
|
||||
join(userData, 'auto-claude-source'),
|
||||
join(userData, 'backend-source'),
|
||||
];
|
||||
|
||||
for (const stalePath of stalePaths) {
|
||||
if (existsSync(stalePath)) {
|
||||
try {
|
||||
rmSync(stalePath, { recursive: true, force: true });
|
||||
console.warn(`[main] Cleaned up stale update metadata: ${stalePath}`);
|
||||
} catch (e) {
|
||||
console.warn(`[main] Failed to clean up stale metadata at ${stalePath}:`, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get icon path based on platform
|
||||
function getIconPath(): string {
|
||||
// In dev mode, __dirname is out/main, so we go up to project root then into resources
|
||||
@@ -54,12 +123,51 @@ let agentManager: AgentManager | null = null;
|
||||
let terminalManager: TerminalManager | null = null;
|
||||
|
||||
function createWindow(): void {
|
||||
// Get the primary display's work area (accounts for taskbar, dock, etc.)
|
||||
// Wrapped in try/catch to handle potential failures with fallback to safe defaults
|
||||
let workAreaSize: { width: number; height: number };
|
||||
try {
|
||||
const display = screen.getPrimaryDisplay();
|
||||
// Validate the returned object has expected structure with valid dimensions
|
||||
if (
|
||||
display &&
|
||||
display.workAreaSize &&
|
||||
typeof display.workAreaSize.width === 'number' &&
|
||||
typeof display.workAreaSize.height === 'number' &&
|
||||
display.workAreaSize.width > 0 &&
|
||||
display.workAreaSize.height > 0
|
||||
) {
|
||||
workAreaSize = display.workAreaSize;
|
||||
} else {
|
||||
console.error(
|
||||
'[main] screen.getPrimaryDisplay() returned unexpected structure:',
|
||||
JSON.stringify(display)
|
||||
);
|
||||
workAreaSize = { width: DEFAULT_SCREEN_WIDTH, height: DEFAULT_SCREEN_HEIGHT };
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error('[main] Failed to get primary display, using fallback dimensions:', error);
|
||||
workAreaSize = { width: DEFAULT_SCREEN_WIDTH, height: DEFAULT_SCREEN_HEIGHT };
|
||||
}
|
||||
|
||||
// Calculate available space with a small margin to avoid edge-to-edge windows
|
||||
const availableWidth: number = workAreaSize.width - WINDOW_SCREEN_MARGIN;
|
||||
const availableHeight: number = workAreaSize.height - WINDOW_SCREEN_MARGIN;
|
||||
|
||||
// Calculate actual dimensions (preferred, but capped to margin-adjusted available space)
|
||||
const width: number = Math.min(WINDOW_PREFERRED_WIDTH, availableWidth);
|
||||
const height: number = Math.min(WINDOW_PREFERRED_HEIGHT, availableHeight);
|
||||
|
||||
// Ensure minimum dimensions don't exceed the actual initial window size
|
||||
const minWidth: number = Math.min(WINDOW_MIN_WIDTH, width);
|
||||
const minHeight: number = Math.min(WINDOW_MIN_HEIGHT, height);
|
||||
|
||||
// Create the browser window
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 900,
|
||||
minWidth: 1000,
|
||||
minHeight: 700,
|
||||
width,
|
||||
height,
|
||||
minWidth,
|
||||
minHeight,
|
||||
show: false,
|
||||
autoHideMenuBar: true,
|
||||
titleBarStyle: 'hiddenInset',
|
||||
@@ -129,6 +237,10 @@ app.whenReady().then(() => {
|
||||
.catch((err) => console.warn('[main] Failed to clear cache:', err));
|
||||
}
|
||||
|
||||
// Clean up stale update metadata from the old source updater system
|
||||
// This prevents version display desync after electron-updater installs a new version
|
||||
cleanupStaleUpdateMetadata();
|
||||
|
||||
// Set dock icon on macOS
|
||||
if (process.platform === 'darwin') {
|
||||
const iconPath = getIconPath();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync } from 'fs';
|
||||
import { app } from 'electron';
|
||||
import { getProfileEnv } from '../rate-limit-detector';
|
||||
import { getAPIProfileEnv } from '../services/profile';
|
||||
import { getOAuthModeClearVars } from '../agent/env-utils';
|
||||
import { pythonEnvManager, getConfiguredPythonPath } from '../python-env-manager';
|
||||
import { getValidatedPythonPath } from '../python-detector';
|
||||
import { getConfiguredPythonPath, pythonEnvManager } from '../python-env-manager';
|
||||
import { getAugmentedEnv } from '../env-utils';
|
||||
import { getEffectiveSourcePath } from '../updater/path-resolver';
|
||||
|
||||
@@ -105,23 +106,51 @@ export class InsightsConfig {
|
||||
* Get complete environment for process execution
|
||||
* Includes system env, auto-claude env, and active Claude profile
|
||||
*/
|
||||
getProcessEnv(): Record<string, string> {
|
||||
async getProcessEnv(): Promise<Record<string, string>> {
|
||||
const autoBuildEnv = this.loadAutoBuildEnv();
|
||||
const profileEnv = getProfileEnv();
|
||||
// Get Python environment (PYTHONPATH for bundled packages like python-dotenv)
|
||||
const apiProfileEnv = await getAPIProfileEnv();
|
||||
const oauthModeClearVars = getOAuthModeClearVars(apiProfileEnv);
|
||||
const pythonEnv = pythonEnvManager.getPythonEnv();
|
||||
const autoBuildSource = this.getAutoBuildSourcePath();
|
||||
const pythonPathParts = (pythonEnv.PYTHONPATH ?? '')
|
||||
.split(path.delimiter)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
.map((entry) => path.resolve(entry));
|
||||
|
||||
if (autoBuildSource) {
|
||||
const normalizedAutoBuildSource = path.resolve(autoBuildSource);
|
||||
const autoBuildComparator = process.platform === 'win32'
|
||||
? normalizedAutoBuildSource.toLowerCase()
|
||||
: normalizedAutoBuildSource;
|
||||
const hasAutoBuildSource = pythonPathParts.some((entry) => {
|
||||
const candidate = process.platform === 'win32' ? entry.toLowerCase() : entry;
|
||||
return candidate === autoBuildComparator;
|
||||
});
|
||||
|
||||
if (!hasAutoBuildSource) {
|
||||
pythonPathParts.push(normalizedAutoBuildSource);
|
||||
}
|
||||
}
|
||||
|
||||
const combinedPythonPath = pythonPathParts.join(path.delimiter);
|
||||
|
||||
// Use getAugmentedEnv() to ensure common tool paths (claude, dotnet, etc.)
|
||||
// are available even when app is launched from Finder/Dock
|
||||
// are available even when app is launched from Finder/Dock.
|
||||
const augmentedEnv = getAugmentedEnv();
|
||||
|
||||
return {
|
||||
...augmentedEnv,
|
||||
...pythonEnv, // Include PYTHONPATH for bundled site-packages
|
||||
...autoBuildEnv,
|
||||
...oauthModeClearVars,
|
||||
...profileEnv,
|
||||
...apiProfileEnv,
|
||||
PYTHONUNBUFFERED: '1',
|
||||
PYTHONIOENCODING: 'utf-8',
|
||||
PYTHONUTF8: '1'
|
||||
PYTHONUTF8: '1',
|
||||
...(combinedPythonPath ? { PYTHONPATH: combinedPythonPath } : {})
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ export class InsightsExecutor extends EventEmitter {
|
||||
} as InsightsChatStatus);
|
||||
|
||||
// Get process environment
|
||||
const processEnv = this.config.getProcessEnv();
|
||||
const processEnv = await this.config.getProcessEnv();
|
||||
|
||||
// Write conversation history to temp file to avoid Windows command-line length limit
|
||||
const historyFile = path.join(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import path from 'path';
|
||||
import { IPC_CHANNELS, getSpecsDir, AUTO_BUILD_PATHS } from '../../shared/constants';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import type {
|
||||
SDKRateLimitInfo,
|
||||
Task,
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { IPCResult, AppUpdateInfo } from '../../shared/types';
|
||||
import {
|
||||
checkForUpdates,
|
||||
downloadUpdate,
|
||||
downloadStableVersion,
|
||||
quitAndInstall,
|
||||
getCurrentVersion
|
||||
} from '../app-updater';
|
||||
@@ -65,6 +66,26 @@ export function registerAppUpdateHandlers(): void {
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* APP_UPDATE_DOWNLOAD_STABLE: Download stable version (for downgrade from beta)
|
||||
* Uses allowDowngrade to download an older stable version
|
||||
*/
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.APP_UPDATE_DOWNLOAD_STABLE,
|
||||
async (): Promise<IPCResult> => {
|
||||
try {
|
||||
await downloadStableVersion();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[app-update-handlers] Download stable version failed:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to download stable version'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* APP_UPDATE_INSTALL: Quit and install update
|
||||
* Quits the app and installs the downloaded update
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { IPC_CHANNELS } from '../../shared/constants';
|
||||
import type { IPCResult } from '../../shared/types';
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import type { AutoBuildSourceUpdateProgress, SourceEnvConfig, SourceEnvCheckResult } from '../../shared/types';
|
||||
import { checkForUpdates as checkSourceUpdates, downloadAndApplyUpdate, getBundledVersion, getEffectiveVersion, getEffectiveSourcePath } from '../auto-claude-updater';
|
||||
import { debugLog } from '../../shared/utils/debug-logger';
|
||||
|
||||
|
||||
/**
|
||||
* Register all autobuild-source-related IPC handlers
|
||||
*/
|
||||
export function registerAutobuildSourceHandlers(
|
||||
getMainWindow: () => BrowserWindow | null
|
||||
): void {
|
||||
// ============================================
|
||||
// Auto Claude Source Update Operations
|
||||
// ============================================
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_CHECK,
|
||||
async (): Promise<IPCResult<{ updateAvailable: boolean; currentVersion: string; latestVersion?: string; releaseNotes?: string; releaseUrl?: string; error?: string }>> => {
|
||||
console.log('[autobuild-source] Check for updates called');
|
||||
debugLog('[IPC] AUTOBUILD_SOURCE_CHECK called');
|
||||
try {
|
||||
const result = await checkSourceUpdates();
|
||||
console.log('[autobuild-source] Check result:', JSON.stringify(result, null, 2));
|
||||
debugLog('[IPC] AUTOBUILD_SOURCE_CHECK result:', result);
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
console.error('[autobuild-source] Check error:', error);
|
||||
debugLog('[IPC] AUTOBUILD_SOURCE_CHECK error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to check for updates'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_DOWNLOAD,
|
||||
() => {
|
||||
debugLog('[IPC] Autobuild source download requested');
|
||||
const mainWindow = getMainWindow();
|
||||
if (!mainWindow) {
|
||||
debugLog('[IPC] No main window available, aborting update');
|
||||
return;
|
||||
}
|
||||
|
||||
// Start download in background
|
||||
downloadAndApplyUpdate((progress) => {
|
||||
debugLog('[IPC] Update progress:', progress.stage, progress.message);
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS,
|
||||
progress
|
||||
);
|
||||
}).then((result) => {
|
||||
if (result.success) {
|
||||
debugLog('[IPC] Update completed successfully, version:', result.version);
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS,
|
||||
{
|
||||
stage: 'complete',
|
||||
message: `Updated to version ${result.version}`,
|
||||
newVersion: result.version // Include new version for UI refresh
|
||||
} as AutoBuildSourceUpdateProgress
|
||||
);
|
||||
} else {
|
||||
debugLog('[IPC] Update failed:', result.error);
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS,
|
||||
{
|
||||
stage: 'error',
|
||||
message: result.error || 'Update failed'
|
||||
} as AutoBuildSourceUpdateProgress
|
||||
);
|
||||
}
|
||||
}).catch((error) => {
|
||||
debugLog('[IPC] Update error:', error instanceof Error ? error.message : error);
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS,
|
||||
{
|
||||
stage: 'error',
|
||||
message: error instanceof Error ? error.message : 'Update failed'
|
||||
} as AutoBuildSourceUpdateProgress
|
||||
);
|
||||
});
|
||||
|
||||
// Send initial progress
|
||||
mainWindow.webContents.send(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_PROGRESS,
|
||||
{
|
||||
stage: 'checking',
|
||||
message: 'Starting update...'
|
||||
} as AutoBuildSourceUpdateProgress
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_VERSION,
|
||||
async (): Promise<IPCResult<string>> => {
|
||||
try {
|
||||
// Use effective version which accounts for source updates
|
||||
const version = getEffectiveVersion();
|
||||
debugLog('[IPC] Returning effective version:', version);
|
||||
return { success: true, data: version };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get version'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Auto Claude Source Environment Operations
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Parse an .env file content into a key-value object
|
||||
*/
|
||||
const parseSourceEnvFile = (content: string): Record<string, string> => {
|
||||
const vars: Record<string, string> = {};
|
||||
for (const line of content.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex > 0) {
|
||||
const key = trimmed.substring(0, eqIndex).trim();
|
||||
let value = trimmed.substring(eqIndex + 1).trim();
|
||||
// Remove quotes if present
|
||||
if ((value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
vars[key] = value;
|
||||
}
|
||||
}
|
||||
return vars;
|
||||
};
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_GET,
|
||||
async (): Promise<IPCResult<SourceEnvConfig>> => {
|
||||
try {
|
||||
const sourcePath = getEffectiveSourcePath();
|
||||
if (!sourcePath) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
hasClaudeToken: false,
|
||||
envExists: false,
|
||||
sourcePath: undefined
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const envPath = path.join(sourcePath, '.env');
|
||||
const envExists = existsSync(envPath);
|
||||
|
||||
if (!envExists) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
hasClaudeToken: false,
|
||||
envExists: false,
|
||||
sourcePath
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const content = readFileSync(envPath, 'utf-8');
|
||||
const vars = parseSourceEnvFile(content);
|
||||
const hasToken = !!vars['CLAUDE_CODE_OAUTH_TOKEN'];
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
hasClaudeToken: hasToken,
|
||||
claudeOAuthToken: hasToken ? vars['CLAUDE_CODE_OAUTH_TOKEN'] : undefined,
|
||||
envExists: true,
|
||||
sourcePath
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get source env'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_UPDATE,
|
||||
async (_, config: { claudeOAuthToken?: string }): Promise<IPCResult> => {
|
||||
try {
|
||||
const sourcePath = getEffectiveSourcePath();
|
||||
if (!sourcePath) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Auto-Claude source path not found. Please configure it in App Settings.'
|
||||
};
|
||||
}
|
||||
|
||||
const envPath = path.join(sourcePath, '.env');
|
||||
|
||||
// Read existing content or start fresh
|
||||
let existingContent = '';
|
||||
const existingVars: Record<string, string> = {};
|
||||
|
||||
if (existsSync(envPath)) {
|
||||
existingContent = readFileSync(envPath, 'utf-8');
|
||||
Object.assign(existingVars, parseSourceEnvFile(existingContent));
|
||||
}
|
||||
|
||||
// Update the token
|
||||
if (config.claudeOAuthToken !== undefined) {
|
||||
existingVars['CLAUDE_CODE_OAUTH_TOKEN'] = config.claudeOAuthToken;
|
||||
}
|
||||
|
||||
// Rebuild the .env file preserving comments and structure
|
||||
const lines = existingContent.split('\n');
|
||||
const processedKeys = new Set<string>();
|
||||
const outputLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) {
|
||||
outputLines.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const eqIndex = trimmed.indexOf('=');
|
||||
if (eqIndex > 0) {
|
||||
const key = trimmed.substring(0, eqIndex).trim();
|
||||
if (key in existingVars) {
|
||||
outputLines.push(`${key}=${existingVars[key]}`);
|
||||
processedKeys.add(key);
|
||||
} else {
|
||||
outputLines.push(line);
|
||||
}
|
||||
} else {
|
||||
outputLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
// Add any new keys that weren't in the original file
|
||||
for (const [key, value] of Object.entries(existingVars)) {
|
||||
if (!processedKeys.has(key)) {
|
||||
outputLines.push(`${key}=${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(envPath, outputLines.join('\n'));
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update source env'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_CHECK_TOKEN,
|
||||
async (): Promise<IPCResult<SourceEnvCheckResult>> => {
|
||||
try {
|
||||
const sourcePath = getEffectiveSourcePath();
|
||||
if (!sourcePath) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
hasToken: false,
|
||||
sourcePath: undefined,
|
||||
error: 'Auto-Claude source path not found'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const envPath = path.join(sourcePath, '.env');
|
||||
if (!existsSync(envPath)) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
hasToken: false,
|
||||
sourcePath,
|
||||
error: '.env file does not exist'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const content = readFileSync(envPath, 'utf-8');
|
||||
const vars = parseSourceEnvFile(content);
|
||||
const hasToken = !!vars['CLAUDE_CODE_OAUTH_TOKEN'] && vars['CLAUDE_CODE_OAUTH_TOKEN'].length > 0;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
hasToken,
|
||||
sourcePath
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to check source token'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { existsSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { spawn } from 'child_process';
|
||||
import { projectStore } from '../project-store';
|
||||
import { parseEnvFile } from './utils';
|
||||
import { getClaudeCliInvocation } from '../claude-cli-utils';
|
||||
import { debugError } from '../../shared/utils/debug-logger';
|
||||
|
||||
// GitLab environment variable keys
|
||||
const GITLAB_ENV_KEYS = {
|
||||
@@ -25,6 +27,25 @@ function envLine(vars: Record<string, string>, key: string, defaultVal: string =
|
||||
return vars[key] ? `${key}=${vars[key]}` : `# ${key}=${defaultVal}`;
|
||||
}
|
||||
|
||||
type ResolvedClaudeCliInvocation =
|
||||
| { command: string; env: Record<string, string> }
|
||||
| { error: string };
|
||||
|
||||
function resolveClaudeCliInvocation(): ResolvedClaudeCliInvocation {
|
||||
try {
|
||||
const invocation = getClaudeCliInvocation();
|
||||
if (!invocation?.command) {
|
||||
throw new Error('Claude CLI path not resolved');
|
||||
}
|
||||
return { command: invocation.command, env: invocation.env };
|
||||
} catch (error) {
|
||||
debugError('[IPC] Failed to resolve Claude CLI path:', error);
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Failed to resolve Claude CLI path',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Register all env-related IPC handlers
|
||||
@@ -552,13 +573,20 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const resolved = resolveClaudeCliInvocation();
|
||||
if ('error' in resolved) {
|
||||
return { success: false, error: resolved.error };
|
||||
}
|
||||
const claudeCmd = resolved.command;
|
||||
const claudeEnv = resolved.env;
|
||||
|
||||
try {
|
||||
// Check if Claude CLI is available and authenticated
|
||||
const result = await new Promise<ClaudeAuthResult>((resolve) => {
|
||||
const proc = spawn('claude', ['--version'], {
|
||||
const proc = spawn(claudeCmd, ['--version'], {
|
||||
cwd: project.path,
|
||||
env: { ...process.env },
|
||||
shell: true
|
||||
env: claudeEnv,
|
||||
shell: false
|
||||
});
|
||||
|
||||
let _stdout = '';
|
||||
@@ -576,10 +604,10 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
|
||||
if (code === 0) {
|
||||
// Claude CLI is available, check if authenticated
|
||||
// Run a simple command that requires auth
|
||||
const authCheck = spawn('claude', ['api', '--help'], {
|
||||
const authCheck = spawn(claudeCmd, ['api', '--help'], {
|
||||
cwd: project.path,
|
||||
env: { ...process.env },
|
||||
shell: true
|
||||
env: claudeEnv,
|
||||
shell: false
|
||||
});
|
||||
|
||||
authCheck.on('close', (authCode: number | null) => {
|
||||
@@ -614,6 +642,9 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
|
||||
});
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Failed to check Claude auth' };
|
||||
}
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -632,13 +663,20 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
|
||||
return { success: false, error: 'Project not found' };
|
||||
}
|
||||
|
||||
const resolved = resolveClaudeCliInvocation();
|
||||
if ('error' in resolved) {
|
||||
return { success: false, error: resolved.error };
|
||||
}
|
||||
const claudeCmd = resolved.command;
|
||||
const claudeEnv = resolved.env;
|
||||
|
||||
try {
|
||||
// Run claude setup-token which will open browser for OAuth
|
||||
const result = await new Promise<ClaudeAuthResult>((resolve) => {
|
||||
const proc = spawn('claude', ['setup-token'], {
|
||||
const proc = spawn(claudeCmd, ['setup-token'], {
|
||||
cwd: project.path,
|
||||
env: { ...process.env },
|
||||
shell: true,
|
||||
env: claudeEnv,
|
||||
shell: false,
|
||||
stdio: 'inherit' // This allows the terminal to handle the interactive auth
|
||||
});
|
||||
|
||||
@@ -666,6 +704,9 @@ ${existingVars['GRAPHITI_DB_PATH'] ? `GRAPHITI_DB_PATH=${existingVars['GRAPHITI_
|
||||
});
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Failed to invoke Claude setup' };
|
||||
}
|
||||
return { success: true, data: result };
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import type { Project } from '../../../../shared/types';
|
||||
import { IPC_CHANNELS } from '../../../../shared/constants';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { AgentManager } from '../../../agent/agent-manager';
|
||||
import type { createIPCCommunicators as createIPCCommunicatorsType } from '../utils/ipc-communicator';
|
||||
|
||||
const mockIpcMain = vi.hoisted(() => {
|
||||
class HoistedMockIpcMain {
|
||||
handlers = new Map<string, Function>();
|
||||
listeners = new Map<string, Function>();
|
||||
|
||||
handle(channel: string, handler: Function): void {
|
||||
this.handlers.set(channel, handler);
|
||||
}
|
||||
|
||||
on(channel: string, listener: Function): void {
|
||||
this.listeners.set(channel, listener);
|
||||
}
|
||||
|
||||
async invokeHandler(channel: string, ...args: unknown[]): Promise<unknown> {
|
||||
const handler = this.handlers.get(channel);
|
||||
if (!handler) {
|
||||
throw new Error(`No handler for channel: ${channel}`);
|
||||
}
|
||||
return handler({}, ...args);
|
||||
}
|
||||
|
||||
async emit(channel: string, ...args: unknown[]): Promise<void> {
|
||||
const listener = this.listeners.get(channel);
|
||||
if (!listener) {
|
||||
throw new Error(`No listener for channel: ${channel}`);
|
||||
}
|
||||
await listener({}, ...args);
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.handlers.clear();
|
||||
this.listeners.clear();
|
||||
}
|
||||
}
|
||||
|
||||
return new HoistedMockIpcMain();
|
||||
});
|
||||
|
||||
const mockRunPythonSubprocess = vi.fn();
|
||||
const mockValidateGitHubModule = vi.fn();
|
||||
const mockGetRunnerEnv = vi.fn();
|
||||
type CreateIPCCommunicators = typeof createIPCCommunicatorsType;
|
||||
|
||||
const mockCreateIPCCommunicators = vi.fn(
|
||||
(..._args: Parameters<CreateIPCCommunicators>) => ({
|
||||
sendProgress: vi.fn(),
|
||||
sendComplete: vi.fn(),
|
||||
sendError: vi.fn(),
|
||||
})
|
||||
) as unknown as CreateIPCCommunicators;
|
||||
|
||||
const projectRef: { current: Project | null } = { current: null };
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
ipcMain: mockIpcMain,
|
||||
BrowserWindow: class {},
|
||||
app: {
|
||||
getPath: vi.fn(() => '/tmp'),
|
||||
on: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../agent/agent-manager', () => ({
|
||||
AgentManager: class {
|
||||
startSpecCreation = vi.fn();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../utils/ipc-communicator', () => ({
|
||||
createIPCCommunicators: (...args: Parameters<CreateIPCCommunicators>) =>
|
||||
mockCreateIPCCommunicators(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/project-middleware', () => ({
|
||||
withProjectOrNull: async (_projectId: string, handler: (project: Project) => Promise<unknown>) => {
|
||||
if (!projectRef.current) {
|
||||
return null;
|
||||
}
|
||||
return handler(projectRef.current);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../utils/subprocess-runner', () => ({
|
||||
runPythonSubprocess: (...args: unknown[]) => mockRunPythonSubprocess(...args),
|
||||
validateGitHubModule: (...args: unknown[]) => mockValidateGitHubModule(...args),
|
||||
getPythonPath: () => '/tmp/python',
|
||||
getRunnerPath: () => '/tmp/runner.py',
|
||||
buildRunnerArgs: (_runnerPath: string, _projectPath: string, command: string, args: string[] = []) => [
|
||||
'runner.py',
|
||||
command,
|
||||
...args,
|
||||
],
|
||||
}));
|
||||
|
||||
vi.mock('../utils/runner-env', () => ({
|
||||
getRunnerEnv: (...args: unknown[]) => mockGetRunnerEnv(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../utils', () => ({
|
||||
getGitHubConfig: vi.fn(() => null),
|
||||
githubFetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../settings-utils', () => ({
|
||||
readSettingsFile: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
function createMockWindow(): BrowserWindow {
|
||||
return { webContents: { send: vi.fn() } } as unknown as BrowserWindow;
|
||||
}
|
||||
|
||||
function createProject(): Project {
|
||||
const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'github-env-test-'));
|
||||
tempDirs.push(projectPath);
|
||||
return {
|
||||
id: 'project-1',
|
||||
name: 'Test Project',
|
||||
path: projectPath,
|
||||
autoBuildPath: '.auto-claude',
|
||||
settings: {
|
||||
model: 'default',
|
||||
memoryBackend: 'file',
|
||||
linearSync: false,
|
||||
notifications: {
|
||||
onTaskComplete: false,
|
||||
onTaskFailed: false,
|
||||
onReviewNeeded: false,
|
||||
sound: false,
|
||||
},
|
||||
graphitiMcpEnabled: false,
|
||||
useClaudeMd: true,
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('GitHub runner env usage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockIpcMain.reset();
|
||||
projectRef.current = createProject();
|
||||
mockValidateGitHubModule.mockResolvedValue({ valid: true, backendPath: '/tmp/backend' });
|
||||
mockGetRunnerEnv.mockResolvedValue({ ANTHROPIC_AUTH_TOKEN: 'token' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs) {
|
||||
try {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors for already-removed temp dirs.
|
||||
}
|
||||
}
|
||||
tempDirs.length = 0;
|
||||
});
|
||||
|
||||
it('passes runner env to PR review subprocess', async () => {
|
||||
const { registerPRHandlers } = await import('../pr-handlers');
|
||||
|
||||
mockRunPythonSubprocess.mockReturnValue({
|
||||
process: { pid: 123 },
|
||||
promise: Promise.resolve({
|
||||
success: true,
|
||||
exitCode: 0,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
data: {
|
||||
prNumber: 123,
|
||||
repo: 'test/repo',
|
||||
success: true,
|
||||
findings: [],
|
||||
summary: '',
|
||||
overallStatus: 'comment',
|
||||
reviewedAt: new Date().toISOString(),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
registerPRHandlers(() => createMockWindow());
|
||||
await mockIpcMain.emit(IPC_CHANNELS.GITHUB_PR_REVIEW, projectRef.current?.id, 123);
|
||||
|
||||
expect(mockGetRunnerEnv).toHaveBeenCalledWith({ USE_CLAUDE_MD: 'true' });
|
||||
expect(mockRunPythonSubprocess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
env: { ANTHROPIC_AUTH_TOKEN: 'token' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('passes runner env to triage subprocess', async () => {
|
||||
const { registerTriageHandlers } = await import('../triage-handlers');
|
||||
|
||||
mockRunPythonSubprocess.mockReturnValue({
|
||||
process: { pid: 124 },
|
||||
promise: Promise.resolve({
|
||||
success: true,
|
||||
exitCode: 0,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
data: [],
|
||||
}),
|
||||
});
|
||||
|
||||
registerTriageHandlers(() => createMockWindow());
|
||||
await mockIpcMain.emit(IPC_CHANNELS.GITHUB_TRIAGE_RUN, projectRef.current?.id);
|
||||
|
||||
expect(mockGetRunnerEnv).toHaveBeenCalledWith();
|
||||
expect(mockRunPythonSubprocess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
env: { ANTHROPIC_AUTH_TOKEN: 'token' },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('passes runner env to autofix analyze preview subprocess', async () => {
|
||||
const { registerAutoFixHandlers } = await import('../autofix-handlers');
|
||||
const { AgentManager: MockedAgentManager } = await import('../../../agent/agent-manager');
|
||||
|
||||
mockRunPythonSubprocess.mockReturnValue({
|
||||
process: { pid: 125 },
|
||||
promise: Promise.resolve({
|
||||
success: true,
|
||||
exitCode: 0,
|
||||
stdout: '',
|
||||
stderr: '',
|
||||
data: {
|
||||
totalIssues: 0,
|
||||
primaryIssue: null,
|
||||
proposedBatches: [],
|
||||
singleIssues: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const agentManager: AgentManager = new MockedAgentManager();
|
||||
const getMainWindow: () => BrowserWindow | null = () => createMockWindow();
|
||||
|
||||
registerAutoFixHandlers(agentManager, getMainWindow);
|
||||
await mockIpcMain.emit(IPC_CHANNELS.GITHUB_AUTOFIX_ANALYZE_PREVIEW, projectRef.current?.id);
|
||||
|
||||
expect(mockGetRunnerEnv).toHaveBeenCalledWith();
|
||||
expect(mockRunPythonSubprocess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
env: { ANTHROPIC_AUTH_TOKEN: 'token' },
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
parseJSONFromOutput,
|
||||
} from './utils/subprocess-runner';
|
||||
import { AgentManager } from '../../agent/agent-manager';
|
||||
import { getRunnerEnv } from './utils/runner-env';
|
||||
|
||||
// Debug logging
|
||||
const { debug: debugLog } = createContextLogger('GitHub AutoFix');
|
||||
@@ -277,11 +278,13 @@ async function checkNewIssues(project: Project): Promise<Array<{number: number}>
|
||||
|
||||
const backendPath = validation.backendPath!;
|
||||
const args = buildRunnerArgs(getRunnerPath(backendPath), project.path, 'check-new');
|
||||
const subprocessEnv = await getRunnerEnv();
|
||||
|
||||
const { promise } = runPythonSubprocess<Array<{number: number}>>({
|
||||
pythonPath: getPythonPath(backendPath),
|
||||
args,
|
||||
cwd: backendPath,
|
||||
env: subprocessEnv,
|
||||
onComplete: (stdout) => {
|
||||
return parseJSONFromOutput<Array<{number: number}>>(stdout);
|
||||
},
|
||||
@@ -361,7 +364,15 @@ async function startAutoFix(
|
||||
|
||||
// Create spec
|
||||
const taskDescription = buildInvestigationTask(issue.number, issue.title, issueContext);
|
||||
const specData = await createSpecForIssue(project, issue.number, issue.title, taskDescription, issue.html_url, labels);
|
||||
const specData = await createSpecForIssue(
|
||||
project,
|
||||
issue.number,
|
||||
issue.title,
|
||||
taskDescription,
|
||||
issue.html_url,
|
||||
labels,
|
||||
project.settings?.mainBranch // Pass project's configured main branch
|
||||
);
|
||||
|
||||
// Save auto-fix state
|
||||
const issuesDir = path.join(getGitHubDir(project), 'issues');
|
||||
@@ -607,6 +618,7 @@ export function registerAutoFixHandlers(
|
||||
const backendPath = validation.backendPath!;
|
||||
const additionalArgs = issueNumbers && issueNumbers.length > 0 ? issueNumbers.map(n => n.toString()) : [];
|
||||
const args = buildRunnerArgs(getRunnerPath(backendPath), project.path, 'batch-issues', additionalArgs);
|
||||
const subprocessEnv = await getRunnerEnv();
|
||||
|
||||
debugLog('Spawning batch process', { args });
|
||||
|
||||
@@ -614,6 +626,7 @@ export function registerAutoFixHandlers(
|
||||
pythonPath: getPythonPath(backendPath),
|
||||
args,
|
||||
cwd: backendPath,
|
||||
env: subprocessEnv,
|
||||
onProgress: (percent, message) => {
|
||||
sendProgress({
|
||||
phase: 'batching',
|
||||
@@ -728,12 +741,14 @@ export function registerAutoFixHandlers(
|
||||
}
|
||||
|
||||
const args = buildRunnerArgs(getRunnerPath(backendPath), project.path, 'analyze-preview', additionalArgs);
|
||||
const subprocessEnv = await getRunnerEnv();
|
||||
debugLog('Spawning analyze-preview process', { args });
|
||||
|
||||
const { promise } = runPythonSubprocess<AnalyzePreviewResult>({
|
||||
pythonPath: getPythonPath(backendPath),
|
||||
args,
|
||||
cwd: backendPath,
|
||||
env: subprocessEnv,
|
||||
onProgress: (percent, message) => {
|
||||
sendProgress({ phase: 'analyzing', progress: percent, message });
|
||||
},
|
||||
|
||||
@@ -66,7 +66,8 @@ ${issue.body || 'No description provided.'}
|
||||
issue.title,
|
||||
description,
|
||||
issue.html_url,
|
||||
labelNames
|
||||
labelNames,
|
||||
project.settings?.mainBranch // Pass project's configured main branch
|
||||
);
|
||||
|
||||
// Start spec creation with the existing spec directory
|
||||
|
||||
@@ -148,7 +148,8 @@ export function registerInvestigateIssue(
|
||||
issue.title,
|
||||
taskDescription,
|
||||
issue.html_url,
|
||||
labels
|
||||
labels,
|
||||
project.settings?.mainBranch // Pass project's configured main branch
|
||||
);
|
||||
|
||||
// NOTE: We intentionally do NOT call agentManager.startSpecCreation() here
|
||||
|
||||
@@ -16,10 +16,12 @@ import { IPC_CHANNELS, MODEL_ID_MAP, DEFAULT_FEATURE_MODELS, DEFAULT_FEATURE_THI
|
||||
import { getGitHubConfig, githubFetch } from './utils';
|
||||
import { readSettingsFile } from '../../settings-utils';
|
||||
import { getAugmentedEnv } from '../../env-utils';
|
||||
import { getMemoryService, getDefaultDbPath } from '../../memory-service';
|
||||
import type { Project, AppSettings } from '../../../shared/types';
|
||||
import { createContextLogger } from './utils/logger';
|
||||
import { withProjectOrNull } from './utils/project-middleware';
|
||||
import { createIPCCommunicators } from './utils/ipc-communicator';
|
||||
import { getRunnerEnv } from './utils/runner-env';
|
||||
import {
|
||||
runPythonSubprocess,
|
||||
getPythonPath,
|
||||
@@ -70,6 +72,13 @@ function getReviewKey(projectId: string, prNumber: number): string {
|
||||
return `${projectId}:${prNumber}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns env vars for Claude.md usage; enabled unless explicitly opted out.
|
||||
*/
|
||||
function getClaudeMdEnv(project: Project): Record<string, string> | undefined {
|
||||
return project.settings?.useClaudeMd !== false ? { USE_CLAUDE_MD: 'true' } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* PR review finding from AI analysis
|
||||
*/
|
||||
@@ -125,6 +134,159 @@ export interface NewCommitsCheck {
|
||||
hasCommitsAfterPosting?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* PR review memory stored in the memory layer
|
||||
* Represents key insights and learnings from a PR review
|
||||
*/
|
||||
export interface PRReviewMemory {
|
||||
prNumber: number;
|
||||
repo: string;
|
||||
verdict: string;
|
||||
timestamp: string;
|
||||
summary: {
|
||||
verdict: string;
|
||||
verdict_reasoning?: string;
|
||||
finding_counts?: Record<string, number>;
|
||||
total_findings?: number;
|
||||
blockers?: string[];
|
||||
risk_assessment?: Record<string, string>;
|
||||
};
|
||||
keyFindings: Array<{
|
||||
severity: string;
|
||||
category: string;
|
||||
title: string;
|
||||
description: string;
|
||||
file: string;
|
||||
line: number;
|
||||
}>;
|
||||
patterns: string[];
|
||||
gotchas: string[];
|
||||
isFollowup: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save PR review insights to the Electron memory layer (LadybugDB)
|
||||
*
|
||||
* Called after a PR review completes to persist learnings for cross-session context.
|
||||
* Extracts key findings, patterns, and gotchas from the review result.
|
||||
*
|
||||
* @param result The completed PR review result
|
||||
* @param repo Repository name (owner/repo)
|
||||
* @param isFollowup Whether this is a follow-up review
|
||||
*/
|
||||
async function savePRReviewToMemory(
|
||||
result: PRReviewResult,
|
||||
repo: string,
|
||||
isFollowup: boolean = false
|
||||
): Promise<void> {
|
||||
const settings = readSettingsFile();
|
||||
if (!settings?.memoryEnabled) {
|
||||
debugLog('Memory not enabled, skipping PR review memory save');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const memoryService = getMemoryService({
|
||||
dbPath: getDefaultDbPath(),
|
||||
database: 'auto_claude_memory',
|
||||
});
|
||||
|
||||
// Build the memory content with comprehensive insights
|
||||
// We want to capture ALL meaningful findings so the AI can learn from patterns
|
||||
|
||||
// Prioritize findings: critical > high > medium > low
|
||||
// Include all critical/high, top 5 medium, top 3 low
|
||||
const criticalFindings = result.findings.filter(f => f.severity === 'critical');
|
||||
const highFindings = result.findings.filter(f => f.severity === 'high');
|
||||
const mediumFindings = result.findings.filter(f => f.severity === 'medium').slice(0, 5);
|
||||
const lowFindings = result.findings.filter(f => f.severity === 'low').slice(0, 3);
|
||||
|
||||
const keyFindingsToSave = [
|
||||
...criticalFindings,
|
||||
...highFindings,
|
||||
...mediumFindings,
|
||||
...lowFindings,
|
||||
].map(f => ({
|
||||
severity: f.severity,
|
||||
category: f.category,
|
||||
title: f.title,
|
||||
description: f.description.substring(0, 500), // Truncate for storage
|
||||
file: f.file,
|
||||
line: f.line,
|
||||
}));
|
||||
|
||||
// Extract gotchas: security issues, critical bugs, and common mistakes
|
||||
const gotchaCategories = ['security', 'error_handling', 'data_validation', 'race_condition'];
|
||||
const gotchasToSave = result.findings
|
||||
.filter(f =>
|
||||
f.severity === 'critical' ||
|
||||
f.severity === 'high' ||
|
||||
gotchaCategories.includes(f.category?.toLowerCase() || '')
|
||||
)
|
||||
.map(f => `[${f.category}] ${f.title}: ${f.description.substring(0, 300)}`);
|
||||
|
||||
// Extract patterns: group findings by category to identify recurring issues
|
||||
const categoryGroups = result.findings.reduce((acc, f) => {
|
||||
const cat = f.category || 'general';
|
||||
acc[cat] = (acc[cat] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
// Patterns are categories that appear multiple times (indicates a systematic issue)
|
||||
const patternsToSave = Object.entries(categoryGroups)
|
||||
.filter(([_, count]) => count >= 2)
|
||||
.map(([category, count]) => `${category}: ${count} occurrences`);
|
||||
|
||||
const memoryContent: PRReviewMemory = {
|
||||
prNumber: result.prNumber,
|
||||
repo,
|
||||
verdict: result.overallStatus || 'unknown',
|
||||
timestamp: new Date().toISOString(),
|
||||
summary: {
|
||||
verdict: result.overallStatus || 'unknown',
|
||||
finding_counts: {
|
||||
critical: criticalFindings.length,
|
||||
high: highFindings.length,
|
||||
medium: result.findings.filter(f => f.severity === 'medium').length,
|
||||
low: result.findings.filter(f => f.severity === 'low').length,
|
||||
},
|
||||
total_findings: result.findings.length,
|
||||
},
|
||||
keyFindings: keyFindingsToSave,
|
||||
patterns: patternsToSave,
|
||||
gotchas: gotchasToSave,
|
||||
isFollowup,
|
||||
};
|
||||
|
||||
// Add follow-up specific info if applicable
|
||||
if (isFollowup && result.resolvedFindings && result.unresolvedFindings) {
|
||||
memoryContent.summary.verdict_reasoning =
|
||||
`Resolved: ${result.resolvedFindings.length}, Unresolved: ${result.unresolvedFindings.length}`;
|
||||
}
|
||||
|
||||
// Save to memory as a pr_review episode
|
||||
const episodeName = `PR #${result.prNumber} ${isFollowup ? 'Follow-up ' : ''}Review - ${repo}`;
|
||||
const saveResult = await memoryService.addEpisode(
|
||||
episodeName,
|
||||
memoryContent,
|
||||
'pr_review',
|
||||
`pr_review_${repo.replace('/', '_')}`
|
||||
);
|
||||
|
||||
if (saveResult.success) {
|
||||
debugLog('PR review saved to memory', { prNumber: result.prNumber, episodeId: saveResult.id });
|
||||
} else {
|
||||
debugLog('Failed to save PR review to memory', { error: saveResult.error });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Don't fail the review if memory save fails
|
||||
debugLog('Error saving PR review to memory', {
|
||||
error: error instanceof Error ? error.message : error
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* PR data from GitHub API
|
||||
*/
|
||||
@@ -630,10 +792,9 @@ async function runPRReview(
|
||||
const logCollector = new PRLogCollector(project, prNumber, repo, false);
|
||||
|
||||
// Build environment with project settings
|
||||
const subprocessEnv: Record<string, string> = {};
|
||||
if (project.settings?.useClaudeMd !== false) {
|
||||
subprocessEnv['USE_CLAUDE_MD'] = 'true';
|
||||
}
|
||||
const subprocessEnv = await getRunnerEnv(
|
||||
getClaudeMdEnv(project)
|
||||
);
|
||||
|
||||
const { process: childProcess, promise } = runPythonSubprocess<PRReviewResult>({
|
||||
pythonPath: getPythonPath(backendPath),
|
||||
@@ -683,6 +844,12 @@ async function runPRReview(
|
||||
|
||||
// Finalize logs with success
|
||||
logCollector.finalize(true);
|
||||
|
||||
// Save PR review insights to memory (async, non-blocking)
|
||||
savePRReviewToMemory(result.data!, repo, false).catch(err => {
|
||||
debugLog('Failed to save PR review to memory', { error: err.message });
|
||||
});
|
||||
|
||||
return result.data!;
|
||||
} finally {
|
||||
// Clean up the registry when done (success or error)
|
||||
@@ -699,11 +866,11 @@ export function registerPRHandlers(
|
||||
): void {
|
||||
debugLog('Registering PR handlers');
|
||||
|
||||
// List open PRs
|
||||
// List open PRs with pagination support
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_PR_LIST,
|
||||
async (_, projectId: string): Promise<PRData[]> => {
|
||||
debugLog('listPRs handler called', { projectId });
|
||||
async (_, projectId: string, page: number = 1): Promise<PRData[]> => {
|
||||
debugLog('listPRs handler called', { projectId, page });
|
||||
const result = await withProjectOrNull(projectId, async (project) => {
|
||||
const config = getGitHubConfig(project);
|
||||
if (!config) {
|
||||
@@ -712,9 +879,10 @@ export function registerPRHandlers(
|
||||
}
|
||||
|
||||
try {
|
||||
// Use pagination: per_page=100 (GitHub max), page=1,2,3...
|
||||
const prs = await githubFetch(
|
||||
config.token,
|
||||
`/repos/${config.repo}/pulls?state=open&per_page=50`
|
||||
`/repos/${config.repo}/pulls?state=open&per_page=100&page=${page}`
|
||||
) as Array<{
|
||||
number: number;
|
||||
title: string;
|
||||
@@ -732,7 +900,7 @@ export function registerPRHandlers(
|
||||
html_url: string;
|
||||
}>;
|
||||
|
||||
debugLog('Fetched PRs', { count: prs.length });
|
||||
debugLog('Fetched PRs', { count: prs.length, page });
|
||||
return prs.map(pr => ({
|
||||
number: pr.number,
|
||||
title: pr.title,
|
||||
@@ -866,6 +1034,23 @@ export function registerPRHandlers(
|
||||
}
|
||||
);
|
||||
|
||||
// Batch get saved reviews - more efficient than individual calls
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_PR_GET_REVIEWS_BATCH,
|
||||
async (_, projectId: string, prNumbers: number[]): Promise<Record<number, PRReviewResult | null>> => {
|
||||
debugLog('getReviewsBatch handler called', { projectId, count: prNumbers.length });
|
||||
const result = await withProjectOrNull(projectId, async (project) => {
|
||||
const reviews: Record<number, PRReviewResult | null> = {};
|
||||
for (const prNumber of prNumbers) {
|
||||
reviews[prNumber] = getReviewResult(project, prNumber);
|
||||
}
|
||||
debugLog('Batch loaded reviews', { count: Object.values(reviews).filter(r => r !== null).length });
|
||||
return reviews;
|
||||
});
|
||||
return result ?? {};
|
||||
}
|
||||
);
|
||||
|
||||
// Get PR review logs
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_PR_GET_LOGS,
|
||||
@@ -969,8 +1154,8 @@ export function registerPRHandlers(
|
||||
// Post review to GitHub
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_PR_POST_REVIEW,
|
||||
async (_, projectId: string, prNumber: number, selectedFindingIds?: string[]): Promise<boolean> => {
|
||||
debugLog('postPRReview handler called', { projectId, prNumber, selectedCount: selectedFindingIds?.length });
|
||||
async (_, projectId: string, prNumber: number, selectedFindingIds?: string[], options?: { forceApprove?: boolean }): Promise<boolean> => {
|
||||
debugLog('postPRReview handler called', { projectId, prNumber, selectedCount: selectedFindingIds?.length, forceApprove: options?.forceApprove });
|
||||
const postResult = await withProjectOrNull(projectId, async (project) => {
|
||||
const result = getReviewResult(project, prNumber);
|
||||
if (!result) {
|
||||
@@ -993,36 +1178,69 @@ export function registerPRHandlers(
|
||||
|
||||
debugLog('Posting findings', { total: result.findings.length, selected: findings.length });
|
||||
|
||||
// Build review body
|
||||
let body = `## 🤖 Auto Claude PR Review\n\n${result.summary}\n\n`;
|
||||
// Build review body - different format for auto-approve with suggestions
|
||||
let body: string;
|
||||
|
||||
if (findings.length > 0) {
|
||||
// Show selected count vs total if filtered
|
||||
const countText = selectedSet
|
||||
? `${findings.length} selected of ${result.findings.length} total`
|
||||
: `${findings.length} total`;
|
||||
body += `### Findings (${countText})\n\n`;
|
||||
if (options?.forceApprove) {
|
||||
// Auto-approve format: clean approval message with optional suggestions
|
||||
body = `## ✅ Auto Claude Review - APPROVED\n\n`;
|
||||
body += `**Status:** Ready to Merge\n\n`;
|
||||
body += `**Summary:** ${result.summary}\n\n`;
|
||||
|
||||
for (const f of findings) {
|
||||
const emoji = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' }[f.severity] || '⚪';
|
||||
body += `#### ${emoji} [${f.severity.toUpperCase()}] ${f.title}\n`;
|
||||
body += `📁 \`${f.file}:${f.line}\`\n\n`;
|
||||
body += `${f.description}\n\n`;
|
||||
// Only show suggested fix if it has actual content
|
||||
const suggestedFix = f.suggestedFix?.trim();
|
||||
if (suggestedFix) {
|
||||
body += `**Suggested fix:**\n\`\`\`\n${suggestedFix}\n\`\`\`\n\n`;
|
||||
if (findings.length > 0) {
|
||||
body += `---\n\n`;
|
||||
body += `### 💡 Suggestions (${findings.length})\n\n`;
|
||||
body += `*These are non-blocking suggestions for consideration:*\n\n`;
|
||||
|
||||
for (const f of findings) {
|
||||
const emoji = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' }[f.severity] || '⚪';
|
||||
body += `#### ${emoji} [${f.severity.toUpperCase()}] ${f.title}\n`;
|
||||
body += `📁 \`${f.file}:${f.line}\`\n\n`;
|
||||
body += `${f.description}\n\n`;
|
||||
const suggestedFix = f.suggestedFix?.trim();
|
||||
if (suggestedFix) {
|
||||
body += `**Suggested fix:**\n\`\`\`\n${suggestedFix}\n\`\`\`\n\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body += `---\n*This automated review found no blocking issues. The PR can be safely merged.*\n\n`;
|
||||
body += `*Generated by Auto Claude*`;
|
||||
} else {
|
||||
body += `*No findings selected for this review.*\n\n`;
|
||||
// Standard review format
|
||||
body = `## 🤖 Auto Claude PR Review\n\n${result.summary}\n\n`;
|
||||
|
||||
if (findings.length > 0) {
|
||||
// Show selected count vs total if filtered
|
||||
const countText = selectedSet
|
||||
? `${findings.length} selected of ${result.findings.length} total`
|
||||
: `${findings.length} total`;
|
||||
body += `### Findings (${countText})\n\n`;
|
||||
|
||||
for (const f of findings) {
|
||||
const emoji = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵' }[f.severity] || '⚪';
|
||||
body += `#### ${emoji} [${f.severity.toUpperCase()}] ${f.title}\n`;
|
||||
body += `📁 \`${f.file}:${f.line}\`\n\n`;
|
||||
body += `${f.description}\n\n`;
|
||||
// Only show suggested fix if it has actual content
|
||||
const suggestedFix = f.suggestedFix?.trim();
|
||||
if (suggestedFix) {
|
||||
body += `**Suggested fix:**\n\`\`\`\n${suggestedFix}\n\`\`\`\n\n`;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
body += `*No findings selected for this review.*\n\n`;
|
||||
}
|
||||
|
||||
body += `---\n*This review was generated by Auto Claude.*`;
|
||||
}
|
||||
|
||||
body += `---\n*This review was generated by Auto Claude.*`;
|
||||
|
||||
// Determine review status based on selected findings
|
||||
// Determine review status based on selected findings (or force approve)
|
||||
let overallStatus = result.overallStatus;
|
||||
if (selectedSet) {
|
||||
if (options?.forceApprove) {
|
||||
// Force approve regardless of findings
|
||||
overallStatus = 'approve';
|
||||
} else if (selectedSet) {
|
||||
const hasBlocker = findings.some(f => f.severity === 'critical' || f.severity === 'high');
|
||||
overallStatus = hasBlocker ? 'request_changes' : (findings.length > 0 ? 'comment' : 'approve');
|
||||
}
|
||||
@@ -1491,10 +1709,9 @@ export function registerPRHandlers(
|
||||
const logCollector = new PRLogCollector(project, prNumber, repo, true);
|
||||
|
||||
// Build environment with project settings
|
||||
const followupEnv: Record<string, string> = {};
|
||||
if (project.settings?.useClaudeMd !== false) {
|
||||
followupEnv['USE_CLAUDE_MD'] = 'true';
|
||||
}
|
||||
const followupEnv = await getRunnerEnv(
|
||||
getClaudeMdEnv(project)
|
||||
);
|
||||
|
||||
const { process: childProcess, promise } = runPythonSubprocess<PRReviewResult>({
|
||||
pythonPath: getPythonPath(backendPath),
|
||||
@@ -1543,6 +1760,11 @@ export function registerPRHandlers(
|
||||
// Finalize logs with success
|
||||
logCollector.finalize(true);
|
||||
|
||||
// Save follow-up PR review insights to memory (async, non-blocking)
|
||||
savePRReviewToMemory(result.data!, repo, true).catch(err => {
|
||||
debugLog('Failed to save follow-up PR review to memory', { error: err.message });
|
||||
});
|
||||
|
||||
debugLog('Follow-up review completed', { prNumber, findingsCount: result.data?.findings.length });
|
||||
sendProgress({
|
||||
phase: 'complete',
|
||||
@@ -1573,5 +1795,226 @@ export function registerPRHandlers(
|
||||
}
|
||||
);
|
||||
|
||||
// Get workflows awaiting approval for a PR (fork PRs)
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_WORKFLOWS_AWAITING_APPROVAL,
|
||||
async (_, projectId: string, prNumber: number): Promise<{
|
||||
awaiting_approval: number;
|
||||
workflow_runs: Array<{ id: number; name: string; html_url: string; workflow_name: string }>;
|
||||
can_approve: boolean;
|
||||
error?: string;
|
||||
}> => {
|
||||
debugLog('getWorkflowsAwaitingApproval handler called', { projectId, prNumber });
|
||||
const result = await withProjectOrNull(projectId, async (project) => {
|
||||
const config = getGitHubConfig(project);
|
||||
if (!config) {
|
||||
return { awaiting_approval: 0, workflow_runs: [], can_approve: false, error: 'No GitHub config' };
|
||||
}
|
||||
|
||||
try {
|
||||
// First get the PR's head SHA
|
||||
const prData = await githubFetch(
|
||||
config.token,
|
||||
`/repos/${config.repo}/pulls/${prNumber}`
|
||||
) as { head?: { sha?: string } };
|
||||
|
||||
const headSha = prData?.head?.sha;
|
||||
if (!headSha) {
|
||||
return { awaiting_approval: 0, workflow_runs: [], can_approve: false };
|
||||
}
|
||||
|
||||
// Query workflow runs with action_required status
|
||||
const runsData = await githubFetch(
|
||||
config.token,
|
||||
`/repos/${config.repo}/actions/runs?status=action_required&per_page=100`
|
||||
) as { workflow_runs?: Array<{ id: number; name: string; html_url: string; head_sha: string; workflow?: { name?: string } }> };
|
||||
|
||||
const allRuns = runsData?.workflow_runs || [];
|
||||
|
||||
// Filter to only runs for this PR's head SHA
|
||||
const prRuns = allRuns
|
||||
.filter(run => run.head_sha === headSha)
|
||||
.map(run => ({
|
||||
id: run.id,
|
||||
name: run.name,
|
||||
html_url: run.html_url,
|
||||
workflow_name: run.workflow?.name || 'Unknown',
|
||||
}));
|
||||
|
||||
debugLog('Found workflows awaiting approval', { prNumber, count: prRuns.length });
|
||||
|
||||
return {
|
||||
awaiting_approval: prRuns.length,
|
||||
workflow_runs: prRuns,
|
||||
can_approve: true, // Assume token has permission; will fail if not
|
||||
};
|
||||
} catch (error) {
|
||||
debugLog('Failed to get workflows awaiting approval', { prNumber, error: error instanceof Error ? error.message : error });
|
||||
return {
|
||||
awaiting_approval: 0,
|
||||
workflow_runs: [],
|
||||
can_approve: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return result ?? { awaiting_approval: 0, workflow_runs: [], can_approve: false };
|
||||
}
|
||||
);
|
||||
|
||||
// Approve a workflow run
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_WORKFLOW_APPROVE,
|
||||
async (_, projectId: string, runId: number): Promise<boolean> => {
|
||||
debugLog('approveWorkflow handler called', { projectId, runId });
|
||||
const result = await withProjectOrNull(projectId, async (project) => {
|
||||
const config = getGitHubConfig(project);
|
||||
if (!config) {
|
||||
debugLog('No GitHub config found');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Approve the workflow run
|
||||
await githubFetch(
|
||||
config.token,
|
||||
`/repos/${config.repo}/actions/runs/${runId}/approve`,
|
||||
{ method: 'POST' }
|
||||
);
|
||||
|
||||
debugLog('Workflow approved successfully', { runId });
|
||||
return true;
|
||||
} catch (error) {
|
||||
debugLog('Failed to approve workflow', { runId, error: error instanceof Error ? error.message : error });
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return result ?? false;
|
||||
}
|
||||
);
|
||||
|
||||
// Get PR review memories from the memory layer
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_PR_MEMORY_GET,
|
||||
async (_, projectId: string, limit: number = 10): Promise<PRReviewMemory[]> => {
|
||||
debugLog('getPRReviewMemories handler called', { projectId, limit });
|
||||
const result = await withProjectOrNull(projectId, async (project) => {
|
||||
const memoryDir = path.join(getGitHubDir(project), 'memory', project.name || 'unknown');
|
||||
const memories: PRReviewMemory[] = [];
|
||||
|
||||
// Try to load from file-based storage
|
||||
try {
|
||||
const indexPath = path.join(memoryDir, 'reviews_index.json');
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
debugLog('No PR review memories found', { projectId });
|
||||
return [];
|
||||
}
|
||||
|
||||
const indexContent = fs.readFileSync(indexPath, 'utf-8');
|
||||
const index = JSON.parse(sanitizeNetworkData(indexContent));
|
||||
const reviews = index.reviews || [];
|
||||
|
||||
// Load individual review memories
|
||||
for (const entry of reviews.slice(0, limit)) {
|
||||
try {
|
||||
const reviewPath = path.join(memoryDir, `pr_${entry.pr_number}_review.json`);
|
||||
if (fs.existsSync(reviewPath)) {
|
||||
const reviewContent = fs.readFileSync(reviewPath, 'utf-8');
|
||||
const memory = JSON.parse(sanitizeNetworkData(reviewContent));
|
||||
memories.push({
|
||||
prNumber: memory.pr_number,
|
||||
repo: memory.repo,
|
||||
verdict: memory.summary?.verdict || 'unknown',
|
||||
timestamp: memory.timestamp,
|
||||
summary: memory.summary,
|
||||
keyFindings: memory.key_findings || [],
|
||||
patterns: memory.patterns || [],
|
||||
gotchas: memory.gotchas || [],
|
||||
isFollowup: memory.is_followup || false,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
debugLog('Failed to load PR review memory', { prNumber: entry.pr_number, error: err instanceof Error ? err.message : err });
|
||||
}
|
||||
}
|
||||
|
||||
debugLog('Loaded PR review memories', { count: memories.length });
|
||||
return memories;
|
||||
} catch (error) {
|
||||
debugLog('Failed to load PR review memories', { error: error instanceof Error ? error.message : error });
|
||||
return [];
|
||||
}
|
||||
});
|
||||
return result ?? [];
|
||||
}
|
||||
);
|
||||
|
||||
// Search PR review memories
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.GITHUB_PR_MEMORY_SEARCH,
|
||||
async (_, projectId: string, query: string, limit: number = 10): Promise<PRReviewMemory[]> => {
|
||||
debugLog('searchPRReviewMemories handler called', { projectId, query, limit });
|
||||
const result = await withProjectOrNull(projectId, async (project) => {
|
||||
const memoryDir = path.join(getGitHubDir(project), 'memory', project.name || 'unknown');
|
||||
const memories: PRReviewMemory[] = [];
|
||||
const queryLower = query.toLowerCase();
|
||||
|
||||
// Search through file-based storage
|
||||
try {
|
||||
const indexPath = path.join(memoryDir, 'reviews_index.json');
|
||||
if (!fs.existsSync(indexPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const indexContent = fs.readFileSync(indexPath, 'utf-8');
|
||||
const index = JSON.parse(sanitizeNetworkData(indexContent));
|
||||
const reviews = index.reviews || [];
|
||||
|
||||
// Search individual review memories
|
||||
for (const entry of reviews) {
|
||||
try {
|
||||
const reviewPath = path.join(memoryDir, `pr_${entry.pr_number}_review.json`);
|
||||
if (fs.existsSync(reviewPath)) {
|
||||
const reviewContent = fs.readFileSync(reviewPath, 'utf-8');
|
||||
|
||||
// Check if content matches query
|
||||
if (reviewContent.toLowerCase().includes(queryLower)) {
|
||||
const memory = JSON.parse(sanitizeNetworkData(reviewContent));
|
||||
memories.push({
|
||||
prNumber: memory.pr_number,
|
||||
repo: memory.repo,
|
||||
verdict: memory.summary?.verdict || 'unknown',
|
||||
timestamp: memory.timestamp,
|
||||
summary: memory.summary,
|
||||
keyFindings: memory.key_findings || [],
|
||||
patterns: memory.patterns || [],
|
||||
gotchas: memory.gotchas || [],
|
||||
isFollowup: memory.is_followup || false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Stop if we have enough
|
||||
if (memories.length >= limit) {
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
debugLog('Failed to search PR review memory', { prNumber: entry.pr_number, error: err instanceof Error ? err.message : err });
|
||||
}
|
||||
}
|
||||
|
||||
debugLog('Found matching PR review memories', { count: memories.length, query });
|
||||
return memories;
|
||||
} catch (error) {
|
||||
debugLog('Failed to search PR review memories', { error: error instanceof Error ? error.message : error });
|
||||
return [];
|
||||
}
|
||||
});
|
||||
return result ?? [];
|
||||
}
|
||||
);
|
||||
|
||||
debugLog('PR handlers registered');
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AUTO_BUILD_PATHS, getSpecsDir } from '../../../shared/constants';
|
||||
import type { Project, TaskMetadata } from '../../../shared/types';
|
||||
import { withSpecNumberLock } from '../../utils/spec-number-lock';
|
||||
import { debugLog } from './utils/logger';
|
||||
import { labelMatchesWholeWord } from '../shared/label-utils';
|
||||
|
||||
export interface SpecCreationData {
|
||||
specId: string;
|
||||
@@ -55,7 +56,14 @@ function determineCategoryFromLabels(labels: string[]): 'feature' | 'bug_fix' |
|
||||
}
|
||||
|
||||
// Check for infrastructure labels
|
||||
if (lowerLabels.some(l => l.includes('infrastructure') || l.includes('devops') || l.includes('deployment') || l.includes('ci') || l.includes('cd'))) {
|
||||
// Use whole-word matching for 'ci' and 'cd' to avoid false positives like 'acid' or 'decide'
|
||||
if (lowerLabels.some(l =>
|
||||
l.includes('infrastructure') ||
|
||||
l.includes('devops') ||
|
||||
l.includes('deployment') ||
|
||||
labelMatchesWholeWord(l, 'ci') ||
|
||||
labelMatchesWholeWord(l, 'cd')
|
||||
)) {
|
||||
return 'infrastructure';
|
||||
}
|
||||
|
||||
@@ -89,7 +97,8 @@ export async function createSpecForIssue(
|
||||
issueTitle: string,
|
||||
taskDescription: string,
|
||||
githubUrl: string,
|
||||
labels: string[] = []
|
||||
labels: string[] = [],
|
||||
baseBranch?: string
|
||||
): Promise<SpecCreationData> {
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const specsDir = path.join(project.path, specsBaseDir);
|
||||
@@ -144,7 +153,10 @@ export async function createSpecForIssue(
|
||||
sourceType: 'github',
|
||||
githubIssueNumber: issueNumber,
|
||||
githubUrl,
|
||||
category
|
||||
category,
|
||||
// Store baseBranch for worktree creation and QA comparison
|
||||
// This comes from project.settings.mainBranch or task-level override
|
||||
...(baseBranch && { baseBranch })
|
||||
};
|
||||
writeFileSync(
|
||||
path.join(specDir, 'task_metadata.json'),
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { Project, AppSettings } from '../../../shared/types';
|
||||
import { createContextLogger } from './utils/logger';
|
||||
import { withProjectOrNull } from './utils/project-middleware';
|
||||
import { createIPCCommunicators } from './utils/ipc-communicator';
|
||||
import { getRunnerEnv } from './utils/runner-env';
|
||||
import {
|
||||
runPythonSubprocess,
|
||||
getPythonPath,
|
||||
@@ -254,10 +255,13 @@ async function runTriage(
|
||||
|
||||
debugLog('Spawning triage process', { args, model, thinkingLevel });
|
||||
|
||||
const subprocessEnv = await getRunnerEnv();
|
||||
|
||||
const { promise } = runPythonSubprocess<TriageResult[]>({
|
||||
pythonPath: getPythonPath(backendPath),
|
||||
args,
|
||||
cwd: backendPath,
|
||||
env: subprocessEnv,
|
||||
onProgress: (percent, message) => {
|
||||
debugLog('Progress update', { percent, message });
|
||||
sendProgress({
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const mockGetAPIProfileEnv = vi.fn();
|
||||
const mockGetOAuthModeClearVars = vi.fn();
|
||||
|
||||
vi.mock('../../../../services/profile', () => ({
|
||||
getAPIProfileEnv: (...args: unknown[]) => mockGetAPIProfileEnv(...args),
|
||||
}));
|
||||
|
||||
vi.mock('../../../../agent/env-utils', () => ({
|
||||
getOAuthModeClearVars: (...args: unknown[]) => mockGetOAuthModeClearVars(...args),
|
||||
}));
|
||||
|
||||
import { getRunnerEnv } from '../runner-env';
|
||||
|
||||
describe('getRunnerEnv', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('merges API profile env with OAuth clear vars', async () => {
|
||||
mockGetAPIProfileEnv.mockResolvedValue({
|
||||
ANTHROPIC_AUTH_TOKEN: 'token',
|
||||
ANTHROPIC_BASE_URL: 'https://api.example.com',
|
||||
});
|
||||
mockGetOAuthModeClearVars.mockReturnValue({
|
||||
ANTHROPIC_AUTH_TOKEN: '',
|
||||
});
|
||||
|
||||
const result = await getRunnerEnv();
|
||||
|
||||
expect(mockGetOAuthModeClearVars).toHaveBeenCalledWith({
|
||||
ANTHROPIC_AUTH_TOKEN: 'token',
|
||||
ANTHROPIC_BASE_URL: 'https://api.example.com',
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ANTHROPIC_AUTH_TOKEN: '',
|
||||
ANTHROPIC_BASE_URL: 'https://api.example.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('includes extra env values', async () => {
|
||||
mockGetAPIProfileEnv.mockResolvedValue({
|
||||
ANTHROPIC_AUTH_TOKEN: 'token',
|
||||
});
|
||||
mockGetOAuthModeClearVars.mockReturnValue({});
|
||||
|
||||
const result = await getRunnerEnv({ USE_CLAUDE_MD: 'true' });
|
||||
|
||||
expect(result).toEqual({
|
||||
ANTHROPIC_AUTH_TOKEN: 'token',
|
||||
USE_CLAUDE_MD: 'true',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { getOAuthModeClearVars } from '../../../agent/env-utils';
|
||||
import { getAPIProfileEnv } from '../../../services/profile';
|
||||
import { getProfileEnv } from '../../../rate-limit-detector';
|
||||
|
||||
/**
|
||||
* Get environment variables for Python runner subprocesses.
|
||||
*
|
||||
* Environment variable precedence (lowest to highest):
|
||||
* 1. apiProfileEnv - Custom Anthropic-compatible API profile (ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN)
|
||||
* 2. oauthModeClearVars - Clears stale ANTHROPIC_* vars when in OAuth mode
|
||||
* 3. profileEnv - Claude OAuth token from profile manager (CLAUDE_CODE_OAUTH_TOKEN)
|
||||
* 4. extraEnv - Caller-specific vars (e.g., USE_CLAUDE_MD)
|
||||
*
|
||||
* The profileEnv is critical for OAuth authentication (#563) - it retrieves the
|
||||
* decrypted OAuth token from the profile manager's encrypted storage (macOS Keychain
|
||||
* via Electron's safeStorage API).
|
||||
*/
|
||||
export async function getRunnerEnv(
|
||||
extraEnv?: Record<string, string>
|
||||
): Promise<Record<string, string>> {
|
||||
const apiProfileEnv = await getAPIProfileEnv();
|
||||
const oauthModeClearVars = getOAuthModeClearVars(apiProfileEnv);
|
||||
const profileEnv = getProfileEnv();
|
||||
|
||||
return {
|
||||
...apiProfileEnv,
|
||||
...oauthModeClearVars,
|
||||
...profileEnv, // OAuth token from profile manager (fixes #563)
|
||||
...extraEnv,
|
||||
};
|
||||
}
|
||||
@@ -63,7 +63,7 @@ export function registerImportIssues(): void {
|
||||
) as GitLabAPIIssue;
|
||||
|
||||
// Create a spec/task from the issue
|
||||
const task = await createSpecForIssue(project, apiIssue, config);
|
||||
const task = await createSpecForIssue(project, apiIssue, config, project.settings?.mainBranch);
|
||||
|
||||
if (task) {
|
||||
tasks.push(task);
|
||||
|
||||
@@ -158,7 +158,7 @@ export function registerInvestigateIssue(
|
||||
});
|
||||
|
||||
// Create spec for the issue
|
||||
const task = await createSpecForIssue(project, issue, config);
|
||||
const task = await createSpecForIssue(project, issue, config, project.settings?.mainBranch);
|
||||
|
||||
if (!task) {
|
||||
sendError(getMainWindow, project.id, 'Failed to create task from issue');
|
||||
|
||||
@@ -7,6 +7,7 @@ import { mkdir, writeFile, readFile, stat } from 'fs/promises';
|
||||
import path from 'path';
|
||||
import type { Project } from '../../../shared/types';
|
||||
import type { GitLabAPIIssue, GitLabConfig } from './types';
|
||||
import { labelMatchesWholeWord } from '../shared/label-utils';
|
||||
|
||||
/**
|
||||
* Simplified task info returned when creating a spec from a GitLab issue.
|
||||
@@ -60,6 +61,47 @@ function debugLog(message: string, data?: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine task category based on GitLab issue labels
|
||||
* Maps to TaskCategory type from shared/types/task.ts
|
||||
*/
|
||||
function determineCategoryFromLabels(labels: string[]): 'feature' | 'bug_fix' | 'refactoring' | 'documentation' | 'security' | 'performance' | 'ui_ux' | 'infrastructure' | 'testing' {
|
||||
const lowerLabels = labels.map(l => l.toLowerCase());
|
||||
|
||||
if (lowerLabels.some(l => l.includes('bug') || l.includes('defect') || l.includes('error') || l.includes('fix'))) {
|
||||
return 'bug_fix';
|
||||
}
|
||||
if (lowerLabels.some(l => l.includes('security') || l.includes('vulnerability') || l.includes('cve'))) {
|
||||
return 'security';
|
||||
}
|
||||
if (lowerLabels.some(l => l.includes('performance') || l.includes('optimization') || l.includes('speed'))) {
|
||||
return 'performance';
|
||||
}
|
||||
if (lowerLabels.some(l => l.includes('ui') || l.includes('ux') || l.includes('design') || l.includes('styling'))) {
|
||||
return 'ui_ux';
|
||||
}
|
||||
// Use whole-word matching for 'ci' and 'cd' to avoid false positives like 'acid' or 'decide'
|
||||
if (lowerLabels.some(l =>
|
||||
l.includes('infrastructure') ||
|
||||
l.includes('devops') ||
|
||||
l.includes('deployment') ||
|
||||
labelMatchesWholeWord(l, 'ci') ||
|
||||
labelMatchesWholeWord(l, 'cd')
|
||||
)) {
|
||||
return 'infrastructure';
|
||||
}
|
||||
if (lowerLabels.some(l => l.includes('test') || l.includes('testing') || l.includes('qa'))) {
|
||||
return 'testing';
|
||||
}
|
||||
if (lowerLabels.some(l => l.includes('refactor') || l.includes('cleanup') || l.includes('maintenance') || l.includes('chore') || l.includes('tech-debt') || l.includes('technical debt'))) {
|
||||
return 'refactoring';
|
||||
}
|
||||
if (lowerLabels.some(l => l.includes('documentation') || l.includes('docs'))) {
|
||||
return 'documentation';
|
||||
}
|
||||
return 'feature';
|
||||
}
|
||||
|
||||
function stripControlChars(value: string, allowNewlines: boolean): string {
|
||||
let sanitized = '';
|
||||
for (let i = 0; i < value.length; i += 1) {
|
||||
@@ -258,7 +300,8 @@ async function pathExists(filePath: string): Promise<boolean> {
|
||||
export async function createSpecForIssue(
|
||||
project: Project,
|
||||
issue: GitLabAPIIssue,
|
||||
config: GitLabConfig
|
||||
config: GitLabConfig,
|
||||
baseBranch?: string
|
||||
): Promise<GitLabTaskInfo | null> {
|
||||
try {
|
||||
// Validate and sanitize network data before writing to disk
|
||||
@@ -321,7 +364,7 @@ export async function createSpecForIssue(
|
||||
const taskContent = buildIssueContext(safeIssue, safeProject, config.instanceUrl);
|
||||
await writeFile(path.join(specDir, 'TASK.md'), taskContent, 'utf-8');
|
||||
|
||||
// Create metadata.json
|
||||
// Create metadata.json (legacy format for GitLab-specific data)
|
||||
const metadata = {
|
||||
source: 'gitlab',
|
||||
gitlab: {
|
||||
@@ -339,6 +382,21 @@ export async function createSpecForIssue(
|
||||
};
|
||||
await writeFile(metadataPath, JSON.stringify(metadata, null, 2), 'utf-8');
|
||||
|
||||
// Create task_metadata.json (consistent with GitHub format for backend compatibility)
|
||||
const taskMetadata = {
|
||||
sourceType: 'gitlab' as const,
|
||||
gitlabIssueIid: safeIssue.iid,
|
||||
gitlabUrl: safeIssue.web_url,
|
||||
category: determineCategoryFromLabels(safeIssue.labels || []),
|
||||
// Store baseBranch for worktree creation and QA comparison
|
||||
...(baseBranch && { baseBranch })
|
||||
};
|
||||
await writeFile(
|
||||
path.join(specDir, 'task_metadata.json'),
|
||||
JSON.stringify(taskMetadata, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
|
||||
debugLog('Created spec for issue:', { iid: safeIssue.iid, specDir });
|
||||
|
||||
// Return task info
|
||||
|
||||
@@ -23,7 +23,6 @@ import { registerEnvHandlers } from './env-handlers';
|
||||
import { registerLinearHandlers } from './linear-handlers';
|
||||
import { registerGithubHandlers } from './github-handlers';
|
||||
import { registerGitlabHandlers } from './gitlab-handlers';
|
||||
import { registerAutobuildSourceHandlers } from './autobuild-source-handlers';
|
||||
import { registerIdeationHandlers } from './ideation-handlers';
|
||||
import { registerChangelogHandlers } from './changelog-handlers';
|
||||
import { registerInsightsHandlers } from './insights-handlers';
|
||||
@@ -92,9 +91,6 @@ export function setupIpcHandlers(
|
||||
// GitLab integration handlers
|
||||
registerGitlabHandlers(agentManager, getMainWindow);
|
||||
|
||||
// Auto-build source update handlers
|
||||
registerAutobuildSourceHandlers(getMainWindow);
|
||||
|
||||
// Ideation handlers
|
||||
registerIdeationHandlers(agentManager, getMainWindow);
|
||||
|
||||
@@ -140,7 +136,6 @@ export {
|
||||
registerLinearHandlers,
|
||||
registerGithubHandlers,
|
||||
registerGitlabHandlers,
|
||||
registerAutobuildSourceHandlers,
|
||||
registerIdeationHandlers,
|
||||
registerChangelogHandlers,
|
||||
registerInsightsHandlers,
|
||||
|
||||
@@ -34,16 +34,56 @@ import { getEffectiveSourcePath } from '../updater/path-resolver';
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Get list of git branches for a directory
|
||||
* Get list of git branches for a directory (both local and remote)
|
||||
*/
|
||||
function getGitBranches(projectPath: string): string[] {
|
||||
try {
|
||||
const result = execFileSync(getToolPath('git'), ['branch', '--list', '--format=%(refname:short)'], {
|
||||
// First fetch to ensure we have latest remote refs
|
||||
try {
|
||||
execFileSync(getToolPath('git'), ['fetch', '--prune'], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 10000 // 10 second timeout for fetch
|
||||
});
|
||||
} catch {
|
||||
// Fetch may fail if offline or no remote, continue with local refs
|
||||
}
|
||||
|
||||
// Get all branches (local + remote) using --all flag
|
||||
const result = execFileSync(getToolPath('git'), ['branch', '--all', '--format=%(refname:short)'], {
|
||||
cwd: projectPath,
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
return result.trim().split('\n').filter(b => b.trim());
|
||||
|
||||
const branches = result.trim().split('\n')
|
||||
.filter(b => b.trim())
|
||||
.map(b => {
|
||||
// Remote branches come as "origin/branch-name", keep the full name
|
||||
// but remove the "origin/" prefix for display while keeping it usable
|
||||
return b.trim();
|
||||
})
|
||||
// Remove HEAD pointer entries like "origin/HEAD"
|
||||
.filter(b => !b.endsWith('/HEAD'))
|
||||
// Remove duplicates (local branch may exist alongside remote)
|
||||
.filter((branch, index, self) => {
|
||||
// If it's a remote branch (origin/x) and local version exists, keep local
|
||||
if (branch.startsWith('origin/')) {
|
||||
const localName = branch.replace('origin/', '');
|
||||
return !self.includes(localName);
|
||||
}
|
||||
return self.indexOf(branch) === index;
|
||||
});
|
||||
|
||||
// Sort: local branches first, then remote branches
|
||||
return branches.sort((a, b) => {
|
||||
const aIsRemote = a.startsWith('origin/');
|
||||
const bIsRemote = b.startsWith('origin/');
|
||||
if (aIsRemote && !bIsRemote) return 1;
|
||||
if (!aIsRemote && bIsRemote) return -1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -304,9 +304,10 @@ ${existingVars['GRAPHITI_DATABASE'] ? `GRAPHITI_DATABASE=${existingVars['GRAPHIT
|
||||
try {
|
||||
// Check if Claude CLI is available and authenticated
|
||||
const result = await new Promise<ClaudeAuthResult>((resolve) => {
|
||||
const proc = spawn('claude', ['--version'], {
|
||||
const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation();
|
||||
const proc = spawn(claudeCmd, ['--version'], {
|
||||
cwd: project.path,
|
||||
env: { ...process.env },
|
||||
env: claudeEnv,
|
||||
shell: true
|
||||
});
|
||||
|
||||
@@ -325,9 +326,9 @@ ${existingVars['GRAPHITI_DATABASE'] ? `GRAPHITI_DATABASE=${existingVars['GRAPHIT
|
||||
if (code === 0) {
|
||||
// Claude CLI is available, check if authenticated
|
||||
// Run a simple command that requires auth
|
||||
const authCheck = spawn('claude', ['api', '--help'], {
|
||||
const authCheck = spawn(claudeCmd, ['api', '--help'], {
|
||||
cwd: project.path,
|
||||
env: { ...process.env },
|
||||
env: claudeEnv,
|
||||
shell: true
|
||||
});
|
||||
|
||||
@@ -384,9 +385,10 @@ ${existingVars['GRAPHITI_DATABASE'] ? `GRAPHITI_DATABASE=${existingVars['GRAPHIT
|
||||
try {
|
||||
// Run claude setup-token which will open browser for OAuth
|
||||
const result = await new Promise<ClaudeAuthResult>((resolve) => {
|
||||
const proc = spawn('claude', ['setup-token'], {
|
||||
const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation();
|
||||
const proc = spawn(claudeCmd, ['setup-token'], {
|
||||
cwd: project.path,
|
||||
env: { ...process.env },
|
||||
env: claudeEnv,
|
||||
shell: true,
|
||||
stdio: 'inherit' // This allows the terminal to handle the interactive auth
|
||||
});
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { ipcMain, dialog, app, shell } from 'electron';
|
||||
import { existsSync, writeFileSync, mkdirSync, statSync } from 'fs';
|
||||
import { existsSync, writeFileSync, mkdirSync, statSync, readFileSync } from 'fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import path from 'path';
|
||||
import { is } from '@electron-toolkit/utils';
|
||||
import { IPC_CHANNELS, DEFAULT_APP_SETTINGS, DEFAULT_AGENT_PROFILES } from '../../shared/constants';
|
||||
import type {
|
||||
AppSettings,
|
||||
IPCResult
|
||||
IPCResult,
|
||||
SourceEnvConfig,
|
||||
SourceEnvCheckResult
|
||||
} from '../../shared/types';
|
||||
import { AgentManager } from '../agent';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { getEffectiveVersion } from '../auto-claude-updater';
|
||||
import { setUpdateChannel } from '../app-updater';
|
||||
import { setUpdateChannel, setUpdateChannelWithDowngradeCheck } from '../app-updater';
|
||||
import { getSettingsPath, readSettingsFile } from '../settings-utils';
|
||||
import { configureTools, getToolPath, getToolInfo, isPathFromWrongPlatform } from '../cli-tool-manager';
|
||||
import { parseEnvFile } from './utils';
|
||||
|
||||
const settingsPath = getSettingsPath();
|
||||
|
||||
@@ -34,13 +36,16 @@ const detectAutoBuildSourcePath = (): string | null => {
|
||||
);
|
||||
} else {
|
||||
// Production mode paths (packaged app)
|
||||
// On Windows/Linux/macOS, the app might be installed anywhere
|
||||
// We check common locations relative to the app bundle
|
||||
// The backend is bundled as extraResources/backend
|
||||
// On all platforms, it should be at process.resourcesPath/backend
|
||||
possiblePaths.push(
|
||||
path.resolve(process.resourcesPath, 'backend') // Primary: extraResources/backend
|
||||
);
|
||||
// Fallback paths for different app structures
|
||||
const appPath = app.getAppPath();
|
||||
possiblePaths.push(
|
||||
path.resolve(appPath, '..', 'backend'), // Sibling to app
|
||||
path.resolve(appPath, '..', '..', 'backend'), // Up 2 from app
|
||||
path.resolve(process.resourcesPath, '..', 'backend') // Relative to resources
|
||||
path.resolve(appPath, '..', 'backend'), // Sibling to asar
|
||||
path.resolve(appPath, '..', '..', 'Resources', 'backend') // macOS bundle structure
|
||||
);
|
||||
}
|
||||
|
||||
@@ -211,8 +216,16 @@ export function registerSettingsHandlers(
|
||||
|
||||
// Update auto-updater channel if betaUpdates setting changed
|
||||
if (settings.betaUpdates !== undefined) {
|
||||
const channel = settings.betaUpdates ? 'beta' : 'latest';
|
||||
setUpdateChannel(channel);
|
||||
if (settings.betaUpdates) {
|
||||
// Enabling beta updates - just switch channel
|
||||
setUpdateChannel('beta');
|
||||
} else {
|
||||
// Disabling beta updates - switch to stable and check if downgrade is available
|
||||
// This will notify the renderer if user is on a prerelease and stable version exists
|
||||
setUpdateChannelWithDowngradeCheck('latest', true).catch((err) => {
|
||||
console.error('[settings-handlers] Failed to check for stable downgrade:', err);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
@@ -372,8 +385,8 @@ export function registerSettingsHandlers(
|
||||
// ============================================
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.APP_VERSION, async (): Promise<string> => {
|
||||
// Use effective version which accounts for source updates
|
||||
const version = getEffectiveVersion();
|
||||
// Return the actual bundled version from package.json
|
||||
const version = app.getVersion();
|
||||
console.log('[settings-handlers] APP_VERSION returning:', version);
|
||||
return version;
|
||||
});
|
||||
@@ -499,4 +512,238 @@ export function registerSettingsHandlers(
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Auto-Build Source Environment Operations
|
||||
// ============================================
|
||||
|
||||
/**
|
||||
* Helper to get source .env path from settings
|
||||
*
|
||||
* In production mode, the .env file is NOT bundled (excluded in electron-builder config).
|
||||
* We store the source .env in app userData directory instead, which is writable.
|
||||
* The sourcePath points to the bundled backend for reference, but envPath is in userData.
|
||||
*/
|
||||
const getSourceEnvPath = (): {
|
||||
sourcePath: string | null;
|
||||
envPath: string | null;
|
||||
isProduction: boolean;
|
||||
} => {
|
||||
const savedSettings = readSettingsFile();
|
||||
const settings = { ...DEFAULT_APP_SETTINGS, ...savedSettings };
|
||||
|
||||
// Get autoBuildPath from settings or try to auto-detect
|
||||
let sourcePath: string | null = settings.autoBuildPath || null;
|
||||
if (!sourcePath) {
|
||||
sourcePath = detectAutoBuildSourcePath();
|
||||
}
|
||||
|
||||
if (!sourcePath) {
|
||||
return { sourcePath: null, envPath: null, isProduction: !is.dev };
|
||||
}
|
||||
|
||||
// In production, use userData directory for .env since resources may be read-only
|
||||
// In development, use the actual source path
|
||||
let envPath: string;
|
||||
if (is.dev) {
|
||||
envPath = path.join(sourcePath, '.env');
|
||||
} else {
|
||||
// Production: store .env in userData/backend/.env
|
||||
const userDataBackendDir = path.join(app.getPath('userData'), 'backend');
|
||||
if (!existsSync(userDataBackendDir)) {
|
||||
mkdirSync(userDataBackendDir, { recursive: true });
|
||||
}
|
||||
envPath = path.join(userDataBackendDir, '.env');
|
||||
}
|
||||
|
||||
return {
|
||||
sourcePath,
|
||||
envPath,
|
||||
isProduction: !is.dev
|
||||
};
|
||||
};
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_GET,
|
||||
async (): Promise<IPCResult<SourceEnvConfig>> => {
|
||||
try {
|
||||
const { sourcePath, envPath } = getSourceEnvPath();
|
||||
|
||||
// Load global settings to check for global token fallback
|
||||
const savedSettings = readSettingsFile();
|
||||
const globalSettings = { ...DEFAULT_APP_SETTINGS, ...savedSettings };
|
||||
|
||||
if (!sourcePath) {
|
||||
// Even without source path, check global token
|
||||
const globalToken = globalSettings.globalClaudeOAuthToken;
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
hasClaudeToken: !!globalToken && globalToken.length > 0,
|
||||
claudeOAuthToken: globalToken,
|
||||
envExists: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const envExists = envPath ? existsSync(envPath) : false;
|
||||
let hasClaudeToken = false;
|
||||
let claudeOAuthToken: string | undefined;
|
||||
|
||||
// First, check source .env file
|
||||
if (envExists && envPath) {
|
||||
const content = readFileSync(envPath, 'utf-8');
|
||||
const vars = parseEnvFile(content);
|
||||
claudeOAuthToken = vars['CLAUDE_CODE_OAUTH_TOKEN'];
|
||||
hasClaudeToken = !!claudeOAuthToken && claudeOAuthToken.length > 0;
|
||||
}
|
||||
|
||||
// Fallback to global settings if no token in source .env
|
||||
if (!hasClaudeToken && globalSettings.globalClaudeOAuthToken) {
|
||||
claudeOAuthToken = globalSettings.globalClaudeOAuthToken;
|
||||
hasClaudeToken = true;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
hasClaudeToken,
|
||||
claudeOAuthToken,
|
||||
sourcePath,
|
||||
envExists
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
// Log the error for debugging in production
|
||||
console.error('[AUTOBUILD_SOURCE_ENV_GET] Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to get source env'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_UPDATE,
|
||||
async (_, config: { claudeOAuthToken?: string }): Promise<IPCResult> => {
|
||||
try {
|
||||
const { sourcePath, envPath } = getSourceEnvPath();
|
||||
|
||||
if (!sourcePath || !envPath) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Auto-build source path not configured. Please set it in Settings.'
|
||||
};
|
||||
}
|
||||
|
||||
// Read existing content or start fresh (avoiding TOCTOU race condition)
|
||||
let existingVars: Record<string, string> = {};
|
||||
try {
|
||||
const content = readFileSync(envPath, 'utf-8');
|
||||
existingVars = parseEnvFile(content);
|
||||
} catch (_readError) {
|
||||
// File doesn't exist or can't be read - start with empty vars
|
||||
// This is expected for first-time setup
|
||||
}
|
||||
|
||||
// Update with new values
|
||||
if (config.claudeOAuthToken !== undefined) {
|
||||
existingVars['CLAUDE_CODE_OAUTH_TOKEN'] = config.claudeOAuthToken;
|
||||
}
|
||||
|
||||
// Generate content
|
||||
const lines: string[] = [
|
||||
'# Auto Claude Framework Environment Variables',
|
||||
'# Managed by Auto Claude UI',
|
||||
'',
|
||||
'# Claude Code OAuth Token (REQUIRED)',
|
||||
`CLAUDE_CODE_OAUTH_TOKEN=${existingVars['CLAUDE_CODE_OAUTH_TOKEN'] || ''}`,
|
||||
''
|
||||
];
|
||||
|
||||
// Preserve other existing variables
|
||||
for (const [key, value] of Object.entries(existingVars)) {
|
||||
if (key !== 'CLAUDE_CODE_OAUTH_TOKEN') {
|
||||
lines.push(`${key}=${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
writeFileSync(envPath, lines.join('\n'));
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to update source env'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.AUTOBUILD_SOURCE_ENV_CHECK_TOKEN,
|
||||
async (): Promise<IPCResult<SourceEnvCheckResult>> => {
|
||||
try {
|
||||
const { sourcePath, envPath, isProduction } = getSourceEnvPath();
|
||||
|
||||
// Load global settings to check for global token fallback
|
||||
const savedSettings = readSettingsFile();
|
||||
const globalSettings = { ...DEFAULT_APP_SETTINGS, ...savedSettings };
|
||||
|
||||
// Check global token first as it's the primary method
|
||||
const globalToken = globalSettings.globalClaudeOAuthToken;
|
||||
const hasGlobalToken = !!globalToken && globalToken.length > 0;
|
||||
|
||||
if (!sourcePath) {
|
||||
// In production, no source path is acceptable if global token exists
|
||||
if (hasGlobalToken) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
hasToken: true,
|
||||
sourcePath: isProduction ? app.getPath('userData') : undefined
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
hasToken: false,
|
||||
error: isProduction
|
||||
? 'Please configure Claude OAuth token in Settings > API Configuration'
|
||||
: 'Auto-build source path not configured'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Check source .env file
|
||||
let hasEnvToken = false;
|
||||
if (envPath && existsSync(envPath)) {
|
||||
const content = readFileSync(envPath, 'utf-8');
|
||||
const vars = parseEnvFile(content);
|
||||
const token = vars['CLAUDE_CODE_OAUTH_TOKEN'];
|
||||
hasEnvToken = !!token && token.length > 0;
|
||||
}
|
||||
|
||||
// Token exists if either source .env has it OR global settings has it
|
||||
const hasToken = hasEnvToken || hasGlobalToken;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
hasToken,
|
||||
sourcePath
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
// Log the error for debugging in production
|
||||
console.error('[AUTOBUILD_SOURCE_ENV_CHECK_TOKEN] Error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to check source token'
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Shared label matching utilities
|
||||
* Used by both GitHub and GitLab spec-utils for category detection
|
||||
*/
|
||||
|
||||
/**
|
||||
* Escape special regex characters in a string.
|
||||
* This ensures that terms like "c++" or "c#" are matched literally.
|
||||
*
|
||||
* @param str - The string to escape
|
||||
* @returns The escaped string safe for use in a RegExp
|
||||
*/
|
||||
function escapeRegExp(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a label contains a whole-word match for a term.
|
||||
* Uses word boundaries to prevent false positives (e.g., 'acid' matching 'ci').
|
||||
*
|
||||
* The term is escaped to handle regex metacharacters safely, so terms like
|
||||
* "c++" or "c#" are matched literally rather than being interpreted as regex.
|
||||
*
|
||||
* @param label - The label to check (already lowercased)
|
||||
* @param term - The term to search for (will be escaped for regex safety)
|
||||
* @returns true if the label contains the term as a whole word
|
||||
*/
|
||||
export function labelMatchesWholeWord(label: string, term: string): boolean {
|
||||
// Escape regex metacharacters in the term to match literally
|
||||
const escapedTerm = escapeRegExp(term);
|
||||
// Use word boundary regex to match whole words only
|
||||
const regex = new RegExp(`\\b${escapedTerm}\\b`);
|
||||
return regex.test(label);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { ipcMain, BrowserWindow } from 'electron';
|
||||
import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir } from '../../../shared/constants';
|
||||
import type { IPCResult, TaskStartOptions, TaskStatus } from '../../../shared/types';
|
||||
import path from 'path';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from 'fs';
|
||||
import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync } from 'fs';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { AgentManager } from '../../agent';
|
||||
import { fileWatcher } from '../../file-watcher';
|
||||
@@ -12,7 +12,6 @@ import { getClaudeProfileManager } from '../../claude-profile-manager';
|
||||
import {
|
||||
getPlanPath,
|
||||
persistPlanStatus,
|
||||
persistPlanStatusSync,
|
||||
createPlanIfNotExists
|
||||
} from './plan-file-utils';
|
||||
import { findTaskWorktree } from '../../worktree-paths';
|
||||
@@ -672,17 +671,35 @@ export function registerTaskExecutionHandlers(
|
||||
return { success: false, error: 'Task not found' };
|
||||
}
|
||||
|
||||
// Get the spec directory
|
||||
const autoBuildDir = project.autoBuildPath || '.auto-claude';
|
||||
const specDir = path.join(
|
||||
// Get the spec directory - use task.specsPath if available (handles worktree vs main)
|
||||
// This is critical: task might exist in worktree, and getTasks() prefers worktree version.
|
||||
// If we write to main project but task is in worktree, the worktree's old status takes precedence on refresh.
|
||||
const specDir = task.specsPath || path.join(
|
||||
project.path,
|
||||
autoBuildDir,
|
||||
'specs',
|
||||
getSpecsDir(project.autoBuildPath),
|
||||
task.specId
|
||||
);
|
||||
|
||||
// Update implementation_plan.json
|
||||
const planPath = path.join(specDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN);
|
||||
console.log(`[Recovery] Writing to plan file at: ${planPath} (task location: ${task.location || 'main'})`);
|
||||
|
||||
// Also update the OTHER location if task exists in both main and worktree
|
||||
// This ensures consistency regardless of which version getTasks() prefers
|
||||
const specsBaseDir = getSpecsDir(project.autoBuildPath);
|
||||
const mainSpecDir = path.join(project.path, specsBaseDir, task.specId);
|
||||
const worktreePath = findTaskWorktree(project.path, task.specId);
|
||||
const worktreeSpecDir = worktreePath ? path.join(worktreePath, specsBaseDir, task.specId) : null;
|
||||
|
||||
// Collect all plan file paths that need updating
|
||||
const planPathsToUpdate: string[] = [planPath];
|
||||
if (mainSpecDir !== specDir && existsSync(path.join(mainSpecDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN))) {
|
||||
planPathsToUpdate.push(path.join(mainSpecDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN));
|
||||
}
|
||||
if (worktreeSpecDir && worktreeSpecDir !== specDir && existsSync(path.join(worktreeSpecDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN))) {
|
||||
planPathsToUpdate.push(path.join(worktreeSpecDir, AUTO_BUILD_PATHS.IMPLEMENTATION_PLAN));
|
||||
}
|
||||
console.log(`[Recovery] Will update ${planPathsToUpdate.length} plan file(s):`, planPathsToUpdate);
|
||||
|
||||
try {
|
||||
// Read the plan to analyze subtask progress
|
||||
@@ -744,14 +761,25 @@ export function registerTaskExecutionHandlers(
|
||||
// Just update status in plan file (project store reads from file, no separate update needed)
|
||||
plan.status = 'human_review';
|
||||
plan.planStatus = 'review';
|
||||
try {
|
||||
// Use atomic write to prevent TOCTOU race conditions
|
||||
atomicWriteFileSync(planPath, JSON.stringify(plan, null, 2));
|
||||
} catch (writeError) {
|
||||
console.error('[Recovery] Failed to write plan file:', writeError);
|
||||
|
||||
// Write to ALL plan file locations to ensure consistency
|
||||
const planContent = JSON.stringify(plan, null, 2);
|
||||
let writeSucceededForComplete = false;
|
||||
for (const pathToUpdate of planPathsToUpdate) {
|
||||
try {
|
||||
atomicWriteFileSync(pathToUpdate, planContent);
|
||||
console.log(`[Recovery] Successfully wrote to: ${pathToUpdate}`);
|
||||
writeSucceededForComplete = true;
|
||||
} catch (writeError) {
|
||||
console.error(`[Recovery] Failed to write plan file at ${pathToUpdate}:`, writeError);
|
||||
// Continue trying other paths
|
||||
}
|
||||
}
|
||||
|
||||
if (!writeSucceededForComplete) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to write plan file'
|
||||
error: 'Failed to write plan file during recovery (all locations failed)'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -798,11 +826,19 @@ export function registerTaskExecutionHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Use atomic write to prevent TOCTOU race conditions
|
||||
atomicWriteFileSync(planPath, JSON.stringify(plan, null, 2));
|
||||
} catch (writeError) {
|
||||
console.error('[Recovery] Failed to write plan file:', writeError);
|
||||
// Write to ALL plan file locations to ensure consistency
|
||||
const planContent = JSON.stringify(plan, null, 2);
|
||||
let writeSucceeded = false;
|
||||
for (const pathToUpdate of planPathsToUpdate) {
|
||||
try {
|
||||
atomicWriteFileSync(pathToUpdate, planContent);
|
||||
console.log(`[Recovery] Successfully wrote to: ${pathToUpdate}`);
|
||||
writeSucceeded = true;
|
||||
} catch (writeError) {
|
||||
console.error(`[Recovery] Failed to write plan file at ${pathToUpdate}:`, writeError);
|
||||
}
|
||||
}
|
||||
if (!writeSucceeded) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to write plan file during recovery'
|
||||
@@ -854,17 +890,20 @@ export function registerTaskExecutionHandlers(
|
||||
// Set status to in_progress for the restart
|
||||
newStatus = 'in_progress';
|
||||
|
||||
// Update plan status for restart
|
||||
// Update plan status for restart - write to ALL locations
|
||||
if (plan) {
|
||||
plan.status = 'in_progress';
|
||||
plan.planStatus = 'in_progress';
|
||||
try {
|
||||
// Use atomic write to prevent TOCTOU race conditions
|
||||
atomicWriteFileSync(planPath, JSON.stringify(plan, null, 2));
|
||||
} catch (writeError) {
|
||||
console.error('[Recovery] Failed to write plan file for restart:', writeError);
|
||||
// Continue with restart attempt even if file write fails
|
||||
// The plan status will be updated by the agent when it starts
|
||||
const restartPlanContent = JSON.stringify(plan, null, 2);
|
||||
for (const pathToUpdate of planPathsToUpdate) {
|
||||
try {
|
||||
atomicWriteFileSync(pathToUpdate, restartPlanContent);
|
||||
console.log(`[Recovery] Wrote restart status to: ${pathToUpdate}`);
|
||||
} catch (writeError) {
|
||||
console.error(`[Recovery] Failed to write plan file for restart at ${pathToUpdate}:`, writeError);
|
||||
// Continue with restart attempt even if file write fails
|
||||
// The plan status will be updated by the agent when it starts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,10 @@ import type { IPCResult, WorktreeStatus, WorktreeDiff, WorktreeDiffFile, Worktre
|
||||
import path from 'path';
|
||||
import { existsSync, readdirSync, statSync, readFileSync } from 'fs';
|
||||
import { execSync, execFileSync, spawn, spawnSync, exec, execFile } from 'child_process';
|
||||
import { minimatch } from 'minimatch';
|
||||
import { projectStore } from '../../project-store';
|
||||
import { getConfiguredPythonPath, PythonEnvManager, pythonEnvManager as pythonEnvManagerSingleton } from '../../python-env-manager';
|
||||
import { getEffectiveSourcePath } from '../../auto-claude-updater';
|
||||
import { getEffectiveSourcePath } from '../../updater/path-resolver';
|
||||
import { getProfileEnv } from '../../rate-limit-detector';
|
||||
import { findTaskAndProject } from './shared';
|
||||
import { parsePythonCommand } from '../../python-detector';
|
||||
@@ -59,6 +60,145 @@ function getUtilitySettings(): { model: string; modelId: string; thinkingLevel:
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Check if a repository is misconfigured as bare but has source files.
|
||||
* If so, automatically fix the configuration by unsetting core.bare.
|
||||
*
|
||||
* This can happen when git worktree operations incorrectly set bare=true,
|
||||
* or when users manually misconfigure the repository.
|
||||
*
|
||||
* @param projectPath - Path to check and potentially fix
|
||||
* @returns true if fixed, false if no fix needed or not fixable
|
||||
*/
|
||||
function fixMisconfiguredBareRepo(projectPath: string): boolean {
|
||||
try {
|
||||
// Check if bare=true is set
|
||||
const bareConfig = execFileSync(
|
||||
getToolPath('git'),
|
||||
['config', '--get', 'core.bare'],
|
||||
{ cwd: projectPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
).trim().toLowerCase();
|
||||
|
||||
if (bareConfig !== 'true') {
|
||||
return false; // Not marked as bare, nothing to fix
|
||||
}
|
||||
|
||||
// Check if there are source files (indicating misconfiguration)
|
||||
// A truly bare repo would only have git internals, not source code
|
||||
// This covers multiple ecosystems: JS/TS, Python, Rust, Go, Java, C#, etc.
|
||||
//
|
||||
// Markers are separated into exact matches and glob patterns for efficiency.
|
||||
// Exact matches use existsSync() directly, while glob patterns use minimatch
|
||||
// against a cached directory listing.
|
||||
const EXACT_MARKERS = [
|
||||
// JavaScript/TypeScript ecosystem
|
||||
'package.json', 'apps', 'src',
|
||||
// Python ecosystem
|
||||
'pyproject.toml', 'setup.py', 'requirements.txt', 'Pipfile',
|
||||
// Rust ecosystem
|
||||
'Cargo.toml',
|
||||
// Go ecosystem
|
||||
'go.mod', 'go.sum', 'cmd', 'main.go',
|
||||
// Java/JVM ecosystem
|
||||
'pom.xml', 'build.gradle', 'build.gradle.kts',
|
||||
// Ruby ecosystem
|
||||
'Gemfile', 'Rakefile',
|
||||
// PHP ecosystem
|
||||
'composer.json',
|
||||
// General project markers
|
||||
'Makefile', 'CMakeLists.txt', 'README.md', 'LICENSE'
|
||||
];
|
||||
|
||||
const GLOB_MARKERS = [
|
||||
// .NET/C# ecosystem - patterns that need glob matching
|
||||
'*.csproj', '*.sln', '*.fsproj'
|
||||
];
|
||||
|
||||
// Check exact matches first (fast path)
|
||||
const hasExactMatch = EXACT_MARKERS.some(marker =>
|
||||
existsSync(path.join(projectPath, marker))
|
||||
);
|
||||
|
||||
if (hasExactMatch) {
|
||||
// Found a project marker, proceed to fix
|
||||
} else {
|
||||
// Check glob patterns - read directory once and cache for all patterns
|
||||
let directoryFiles: string[] | null = null;
|
||||
const MAX_FILES_TO_CHECK = 500; // Limit to avoid reading huge directories
|
||||
|
||||
const hasGlobMatch = GLOB_MARKERS.some(pattern => {
|
||||
// Validate pattern - only support simple glob patterns for security
|
||||
if (pattern.includes('..') || pattern.includes('/')) {
|
||||
console.warn(`[GIT] Unsupported glob pattern ignored: ${pattern}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Lazy-load directory listing, cached across patterns
|
||||
if (directoryFiles === null) {
|
||||
try {
|
||||
const allFiles = readdirSync(projectPath);
|
||||
// Limit to first N entries to avoid performance issues
|
||||
directoryFiles = allFiles.slice(0, MAX_FILES_TO_CHECK);
|
||||
if (allFiles.length > MAX_FILES_TO_CHECK) {
|
||||
console.warn(`[GIT] Directory has ${allFiles.length} entries, checking only first ${MAX_FILES_TO_CHECK}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Log the error for debugging instead of silently swallowing
|
||||
console.warn(`[GIT] Failed to read directory ${projectPath}:`, error instanceof Error ? error.message : String(error));
|
||||
directoryFiles = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Use minimatch for proper glob pattern matching
|
||||
return directoryFiles.some(file => minimatch(file, pattern, { nocase: true }));
|
||||
});
|
||||
|
||||
if (!hasGlobMatch) {
|
||||
return false; // Legitimately bare repo
|
||||
}
|
||||
}
|
||||
|
||||
// Fix the misconfiguration
|
||||
console.warn('[GIT] Detected misconfigured bare repository with source files. Auto-fixing by unsetting core.bare...');
|
||||
execFileSync(
|
||||
getToolPath('git'),
|
||||
['config', '--unset', 'core.bare'],
|
||||
{ cwd: projectPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
console.warn('[GIT] Fixed: core.bare has been unset. Git operations should now work correctly.');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a path is a valid git working tree (not a bare repository).
|
||||
* Returns true if the path is inside a git repository with a working tree.
|
||||
*
|
||||
* NOTE: This is a pure check with no side-effects. If you need to fix
|
||||
* misconfigured bare repos before an operation, call fixMisconfiguredBareRepo()
|
||||
* explicitly before calling this function.
|
||||
*
|
||||
* @param projectPath - Path to check
|
||||
* @returns true if it's a valid working tree, false if bare or not a git repo
|
||||
*/
|
||||
function isGitWorkTree(projectPath: string): boolean {
|
||||
try {
|
||||
// Use git rev-parse --is-inside-work-tree which returns "true" for working trees
|
||||
// and fails for bare repos or non-git directories
|
||||
const result = execFileSync(
|
||||
getToolPath('git'),
|
||||
['rev-parse', '--is-inside-work-tree'],
|
||||
{ cwd: projectPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
return result.trim() === 'true';
|
||||
} catch {
|
||||
// Not a working tree (could be bare repo or not a git repo at all)
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IDE and Terminal detection and launching utilities
|
||||
*/
|
||||
@@ -1406,6 +1546,12 @@ export function registerWorktreeHandlers(
|
||||
|
||||
debug('Found task:', task.specId, 'project:', project.path);
|
||||
|
||||
// Auto-fix any misconfigured bare repo before merge operation
|
||||
// This prevents issues where git operations fail due to incorrect bare=true config
|
||||
if (fixMisconfiguredBareRepo(project.path)) {
|
||||
debug('Fixed misconfigured bare repository at:', project.path);
|
||||
}
|
||||
|
||||
// Use run.py --merge to handle the merge
|
||||
const sourcePath = getEffectiveSourcePath();
|
||||
if (!sourcePath) {
|
||||
@@ -1449,14 +1595,18 @@ export function registerWorktreeHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
// Get git status before merge
|
||||
try {
|
||||
const gitStatusBefore = execFileSync(getToolPath('git'), ['status', '--short'], { cwd: project.path, encoding: 'utf-8' });
|
||||
debug('Git status BEFORE merge in main project:\n', gitStatusBefore || '(clean)');
|
||||
const gitBranch = execFileSync(getToolPath('git'), ['branch', '--show-current'], { cwd: project.path, encoding: 'utf-8' }).trim();
|
||||
debug('Current branch:', gitBranch);
|
||||
} catch (e) {
|
||||
debug('Failed to get git status before:', e);
|
||||
// Get git status before merge (only if project is a working tree, not a bare repo)
|
||||
if (isGitWorkTree(project.path)) {
|
||||
try {
|
||||
const gitStatusBefore = execFileSync(getToolPath('git'), ['status', '--short'], { cwd: project.path, encoding: 'utf-8' });
|
||||
debug('Git status BEFORE merge in main project:\n', gitStatusBefore || '(clean)');
|
||||
const gitBranch = execFileSync(getToolPath('git'), ['branch', '--show-current'], { cwd: project.path, encoding: 'utf-8' }).trim();
|
||||
debug('Current branch:', gitBranch);
|
||||
} catch (e) {
|
||||
debug('Failed to get git status before:', e);
|
||||
}
|
||||
} else {
|
||||
debug('Project is a bare repository - skipping pre-merge git status check');
|
||||
}
|
||||
|
||||
const args = [
|
||||
@@ -1600,14 +1750,18 @@ export function registerWorktreeHandlers(
|
||||
debug('Full stdout:', stdout);
|
||||
debug('Full stderr:', stderr);
|
||||
|
||||
// Get git status after merge
|
||||
try {
|
||||
const gitStatusAfter = execFileSync(getToolPath('git'), ['status', '--short'], { cwd: project.path, encoding: 'utf-8' });
|
||||
debug('Git status AFTER merge in main project:\n', gitStatusAfter || '(clean)');
|
||||
const gitDiffStaged = execFileSync(getToolPath('git'), ['diff', '--staged', '--stat'], { cwd: project.path, encoding: 'utf-8' });
|
||||
debug('Staged changes:\n', gitDiffStaged || '(none)');
|
||||
} catch (e) {
|
||||
debug('Failed to get git status after:', e);
|
||||
// Get git status after merge (only if project is a working tree, not a bare repo)
|
||||
if (isGitWorkTree(project.path)) {
|
||||
try {
|
||||
const gitStatusAfter = execFileSync(getToolPath('git'), ['status', '--short'], { cwd: project.path, encoding: 'utf-8' });
|
||||
debug('Git status AFTER merge in main project:\n', gitStatusAfter || '(clean)');
|
||||
const gitDiffStaged = execFileSync(getToolPath('git'), ['diff', '--staged', '--stat'], { cwd: project.path, encoding: 'utf-8' });
|
||||
debug('Staged changes:\n', gitDiffStaged || '(none)');
|
||||
} catch (e) {
|
||||
debug('Failed to get git status after:', e);
|
||||
}
|
||||
} else {
|
||||
debug('Project is a bare repository - skipping git status check (this is normal for worktree-based projects)');
|
||||
}
|
||||
|
||||
if (code === 0) {
|
||||
@@ -1619,33 +1773,39 @@ export function registerWorktreeHandlers(
|
||||
let mergeAlreadyCommitted = false;
|
||||
|
||||
if (isStageOnly) {
|
||||
try {
|
||||
const gitDiffStaged = execFileSync(getToolPath('git'), ['diff', '--staged', '--stat'], { cwd: project.path, encoding: 'utf-8' });
|
||||
hasActualStagedChanges = gitDiffStaged.trim().length > 0;
|
||||
debug('Stage-only verification: hasActualStagedChanges:', hasActualStagedChanges);
|
||||
// Only check staged changes if project is a working tree (not bare repo)
|
||||
if (isGitWorkTree(project.path)) {
|
||||
try {
|
||||
const gitDiffStaged = execFileSync(getToolPath('git'), ['diff', '--staged', '--stat'], { cwd: project.path, encoding: 'utf-8' });
|
||||
hasActualStagedChanges = gitDiffStaged.trim().length > 0;
|
||||
debug('Stage-only verification: hasActualStagedChanges:', hasActualStagedChanges);
|
||||
|
||||
if (!hasActualStagedChanges) {
|
||||
// Check if worktree branch was already merged (merge commit exists)
|
||||
const specBranch = `auto-claude/${task.specId}`;
|
||||
try {
|
||||
// Check if current branch contains all commits from spec branch
|
||||
// git merge-base --is-ancestor returns exit code 0 if true, 1 if false
|
||||
execFileSync(
|
||||
'git',
|
||||
['merge-base', '--is-ancestor', specBranch, 'HEAD'],
|
||||
{ cwd: project.path, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
// If we reach here, the command succeeded (exit code 0) - branch is merged
|
||||
mergeAlreadyCommitted = true;
|
||||
debug('Merge already committed check:', mergeAlreadyCommitted);
|
||||
} catch {
|
||||
// Exit code 1 means not merged, or branch may not exist
|
||||
mergeAlreadyCommitted = false;
|
||||
debug('Could not check merge status, assuming not merged');
|
||||
if (!hasActualStagedChanges) {
|
||||
// Check if worktree branch was already merged (merge commit exists)
|
||||
const specBranch = `auto-claude/${task.specId}`;
|
||||
try {
|
||||
// Check if current branch contains all commits from spec branch
|
||||
// git merge-base --is-ancestor returns exit code 0 if true, 1 if false
|
||||
execFileSync(
|
||||
getToolPath('git'),
|
||||
['merge-base', '--is-ancestor', specBranch, 'HEAD'],
|
||||
{ cwd: project.path, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
// If we reach here, the command succeeded (exit code 0) - branch is merged
|
||||
mergeAlreadyCommitted = true;
|
||||
debug('Merge already committed check:', mergeAlreadyCommitted);
|
||||
} catch {
|
||||
// Exit code 1 means not merged, or branch may not exist
|
||||
mergeAlreadyCommitted = false;
|
||||
debug('Could not check merge status, assuming not merged');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debug('Failed to verify staged changes:', e);
|
||||
}
|
||||
} catch (e) {
|
||||
debug('Failed to verify staged changes:', e);
|
||||
} else {
|
||||
// For bare repos, skip staging verification - merge happens in worktree
|
||||
debug('Project is a bare repository - skipping staged changes verification');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1857,8 +2017,17 @@ export function registerWorktreeHandlers(
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Check if there were conflicts
|
||||
const hasConflicts = stdout.includes('conflict') || stderr.includes('conflict');
|
||||
// Check if there were actual merge conflicts
|
||||
// More specific patterns to avoid false positives from debug output like "files_with_conflicts: 0"
|
||||
const conflictPatterns = [
|
||||
/CONFLICT \(/i, // Git merge conflict marker
|
||||
/merge conflict/i, // Explicit merge conflict message
|
||||
/\bconflict detected\b/i, // Our own conflict detection message
|
||||
/\bconflicts? found\b/i, // "conflicts found" or "conflict found"
|
||||
/Automatic merge failed/i, // Git's automatic merge failure message
|
||||
];
|
||||
const combinedOutput = stdout + stderr;
|
||||
const hasConflicts = conflictPatterns.some(pattern => pattern.test(combinedOutput));
|
||||
debug('Merge failed. hasConflicts:', hasConflicts);
|
||||
|
||||
resolve({
|
||||
@@ -1935,27 +2104,31 @@ export function registerWorktreeHandlers(
|
||||
}
|
||||
console.warn('[IPC] Found task:', task.specId, 'project:', project.name);
|
||||
|
||||
// Check for uncommitted changes in the main project
|
||||
// Check for uncommitted changes in the main project (only if not a bare repo)
|
||||
let hasUncommittedChanges = false;
|
||||
let uncommittedFiles: string[] = [];
|
||||
try {
|
||||
const gitStatus = execFileSync(getToolPath('git'), ['status', '--porcelain'], {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
if (isGitWorkTree(project.path)) {
|
||||
try {
|
||||
const gitStatus = execFileSync(getToolPath('git'), ['status', '--porcelain'], {
|
||||
cwd: project.path,
|
||||
encoding: 'utf-8'
|
||||
});
|
||||
|
||||
if (gitStatus && gitStatus.trim()) {
|
||||
// Parse the status output to get file names
|
||||
// Format: XY filename (where X and Y are status chars, then space, then filename)
|
||||
uncommittedFiles = gitStatus
|
||||
.split('\n')
|
||||
.filter(line => line.trim())
|
||||
.map(line => line.substring(3).trim()); // Skip 2 status chars + 1 space, trim any trailing whitespace
|
||||
if (gitStatus && gitStatus.trim()) {
|
||||
// Parse the status output to get file names
|
||||
// Format: XY filename (where X and Y are status chars, then space, then filename)
|
||||
uncommittedFiles = gitStatus
|
||||
.split('\n')
|
||||
.filter(line => line.trim())
|
||||
.map(line => line.substring(3).trim()); // Skip 2 status chars + 1 space, trim any trailing whitespace
|
||||
|
||||
hasUncommittedChanges = uncommittedFiles.length > 0;
|
||||
hasUncommittedChanges = uncommittedFiles.length > 0;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[IPC] Failed to check git status:', e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[IPC] Failed to check git status:', e);
|
||||
} else {
|
||||
console.warn('[IPC] Project is a bare repository - skipping uncommitted changes check');
|
||||
}
|
||||
|
||||
const sourcePath = getEffectiveSourcePath();
|
||||
|
||||
@@ -9,6 +9,7 @@ import { projectStore } from '../project-store';
|
||||
import { terminalNameGenerator } from '../terminal-name-generator';
|
||||
import { debugLog, debugError } from '../../shared/utils/debug-logger';
|
||||
import { escapeShellArg, escapeShellArgWindows } from '../../shared/utils/shell-escape';
|
||||
import { getClaudeCliInvocation } from '../claude-cli-utils';
|
||||
|
||||
|
||||
/**
|
||||
@@ -76,6 +77,22 @@ export function registerTerminalHandlers(
|
||||
}
|
||||
);
|
||||
|
||||
// Set terminal title (user renamed terminal in renderer)
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.TERMINAL_SET_TITLE,
|
||||
(_, id: string, title: string) => {
|
||||
terminalManager.setTitle(id, title);
|
||||
}
|
||||
);
|
||||
|
||||
// Set terminal worktree config (user changed worktree association in renderer)
|
||||
ipcMain.on(
|
||||
IPC_CHANNELS.TERMINAL_SET_WORKTREE_CONFIG,
|
||||
(_, id: string, config: import('../../shared/types').TerminalWorktreeConfig | undefined) => {
|
||||
terminalManager.setWorktreeConfig(id, config);
|
||||
}
|
||||
);
|
||||
|
||||
// Claude profile management (multi-account support)
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.CLAUDE_PROFILES_GET,
|
||||
@@ -329,20 +346,30 @@ export function registerTerminalHandlers(
|
||||
// Build the login command with the profile's config dir
|
||||
// Use platform-specific syntax and escaping for environment variables
|
||||
let loginCommand: string;
|
||||
const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation();
|
||||
const pathPrefix = claudeEnv.PATH
|
||||
? (process.platform === 'win32'
|
||||
? `set "PATH=${escapeShellArgWindows(claudeEnv.PATH)}" && `
|
||||
: `export PATH=${escapeShellArg(claudeEnv.PATH)} && `)
|
||||
: '';
|
||||
const shellClaudeCmd = process.platform === 'win32'
|
||||
? `"${escapeShellArgWindows(claudeCmd)}"`
|
||||
: escapeShellArg(claudeCmd);
|
||||
|
||||
if (!profile.isDefault && profile.configDir) {
|
||||
if (process.platform === 'win32') {
|
||||
// SECURITY: Use Windows-specific escaping for cmd.exe
|
||||
const escapedConfigDir = escapeShellArgWindows(profile.configDir);
|
||||
// Windows cmd.exe syntax: set "VAR=value" with %VAR% for expansion
|
||||
loginCommand = `set "CLAUDE_CONFIG_DIR=${escapedConfigDir}" && echo Config dir: %CLAUDE_CONFIG_DIR% && claude setup-token`;
|
||||
loginCommand = `${pathPrefix}set "CLAUDE_CONFIG_DIR=${escapedConfigDir}" && echo Config dir: %CLAUDE_CONFIG_DIR% && ${shellClaudeCmd} setup-token`;
|
||||
} else {
|
||||
// SECURITY: Use POSIX escaping for bash/zsh
|
||||
const escapedConfigDir = escapeShellArg(profile.configDir);
|
||||
// Unix/Mac bash/zsh syntax: export VAR=value with $VAR for expansion
|
||||
loginCommand = `export CLAUDE_CONFIG_DIR=${escapedConfigDir} && echo "Config dir: $CLAUDE_CONFIG_DIR" && claude setup-token`;
|
||||
loginCommand = `${pathPrefix}export CLAUDE_CONFIG_DIR=${escapedConfigDir} && echo "Config dir: $CLAUDE_CONFIG_DIR" && ${shellClaudeCmd} setup-token`;
|
||||
}
|
||||
} else {
|
||||
loginCommand = 'claude setup-token';
|
||||
loginCommand = `${pathPrefix}${shellClaudeCmd} setup-token`;
|
||||
}
|
||||
|
||||
debugLog('[IPC] Sending login command to terminal:', loginCommand);
|
||||
|
||||