diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml index f19d3e607a..271ff679a9 100644 --- a/.github/workflows/beta-release.yml +++ b/.github/workflows/beta-release.yml @@ -97,16 +97,28 @@ jobs: - name: Install Rust toolchain (for building native Python packages) uses: dtolnay/rust-toolchain@stable + - name: Cache pip wheel cache (for compiled packages like real_ladybug) + uses: actions/cache@v4 + with: + path: ~/Library/Caches/pip + key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }} + restore-keys: | + pip-wheel-${{ runner.os }}-x64- + - name: Cache bundled Python uses: actions/cache@v4 with: path: apps/frontend/python-runtime - key: python-bundle-${{ runner.os }}-x64-3.12.8-rust + key: python-bundle-${{ runner.os }}-x64-3.12.8-rust-${{ hashFiles('apps/backend/requirements.txt') }} restore-keys: | - python-bundle-${{ runner.os }}-x64- + python-bundle-${{ runner.os }}-x64-3.12.8-rust- - name: Build application run: cd apps/frontend && npm run build + env: + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }} + SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }} - name: Package macOS (Intel) run: | @@ -116,6 +128,9 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} CSC_LINK: ${{ secrets.MAC_CERTIFICATE }} CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }} + SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }} - name: Notarize macOS Intel app env: @@ -181,16 +196,28 @@ jobs: - name: Install dependencies run: cd apps/frontend && npm ci + - name: Cache pip wheel cache + uses: actions/cache@v4 + with: + path: ~/Library/Caches/pip + key: pip-wheel-${{ runner.os }}-arm64-${{ hashFiles('apps/backend/requirements.txt') }} + restore-keys: | + pip-wheel-${{ runner.os }}-arm64- + - name: Cache bundled Python uses: actions/cache@v4 with: path: apps/frontend/python-runtime - key: python-bundle-${{ runner.os }}-arm64-3.12.8 + key: python-bundle-${{ runner.os }}-arm64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }} restore-keys: | - python-bundle-${{ runner.os }}-arm64- + python-bundle-${{ runner.os }}-arm64-3.12.8- - name: Build application run: cd apps/frontend && npm run build + env: + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }} + SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }} - name: Package macOS (Apple Silicon) run: | @@ -200,6 +227,9 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} CSC_LINK: ${{ secrets.MAC_CERTIFICATE }} CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }} + SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }} - name: Notarize macOS ARM64 app env: @@ -235,6 +265,12 @@ jobs: build-windows: needs: create-tag runs-on: windows-latest + permissions: + id-token: write # Required for OIDC authentication with Azure + contents: read + env: + # Job-level env so AZURE_CLIENT_ID is available for step-level if conditions + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} steps: - uses: actions/checkout@v4 with: @@ -265,16 +301,28 @@ jobs: - name: Install dependencies run: cd apps/frontend && npm ci + - name: Cache pip wheel cache + uses: actions/cache@v4 + with: + path: ~\AppData\Local\pip\Cache + key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }} + restore-keys: | + pip-wheel-${{ runner.os }}-x64- + - name: Cache bundled Python uses: actions/cache@v4 with: path: apps/frontend/python-runtime - key: python-bundle-${{ runner.os }}-x64-3.12.8 + key: python-bundle-${{ runner.os }}-x64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }} restore-keys: | - python-bundle-${{ runner.os }}-x64- + python-bundle-${{ runner.os }}-x64-3.12.8- - name: Build application run: cd apps/frontend && npm run build + env: + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }} + SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }} - name: Package Windows shell: bash @@ -283,8 +331,122 @@ jobs: cd apps/frontend && npm run package:win -- --config.extraMetadata.version="$VERSION" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CSC_LINK: ${{ secrets.WIN_CERTIFICATE }} - CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTIFICATE_PASSWORD }} + # Disable electron-builder's built-in signing (we use Azure Trusted Signing instead) + CSC_IDENTITY_AUTO_DISCOVERY: false + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }} + SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }} + + - name: Azure Login (OIDC) + if: env.AZURE_CLIENT_ID != '' + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Sign Windows executable with Azure Trusted Signing + if: env.AZURE_CLIENT_ID != '' + uses: azure/trusted-signing-action@v0.5.11 + with: + endpoint: https://neu.codesigning.azure.net/ + trusted-signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + certificate-profile-name: ${{ secrets.AZURE_CERTIFICATE_PROFILE }} + files-folder: apps/frontend/dist + files-folder-filter: exe + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + + - name: Verify Windows executable is signed + if: env.AZURE_CLIENT_ID != '' + shell: pwsh + run: | + cd apps/frontend/dist + $exeFile = Get-ChildItem -Filter "*.exe" | Select-Object -First 1 + if ($exeFile) { + Write-Host "Verifying signature on $($exeFile.Name)..." + $sig = Get-AuthenticodeSignature -FilePath $exeFile.FullName + if ($sig.Status -ne 'Valid') { + Write-Host "::error::Signature verification failed: $($sig.Status)" + Write-Host "::error::Status Message: $($sig.StatusMessage)" + exit 1 + } + Write-Host "✅ Signature verified successfully" + Write-Host " Subject: $($sig.SignerCertificate.Subject)" + Write-Host " Issuer: $($sig.SignerCertificate.Issuer)" + Write-Host " Thumbprint: $($sig.SignerCertificate.Thumbprint)" + } else { + Write-Host "::error::No .exe file found to verify" + exit 1 + } + + - name: Regenerate checksums after signing + if: env.AZURE_CLIENT_ID != '' + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + cd apps/frontend/dist + + # Find the installer exe (electron-builder names it with "Setup" or just the app name) + # electron-builder produces one installer exe per build + $exeFiles = Get-ChildItem -Filter "*.exe" + if ($exeFiles.Count -eq 0) { + Write-Host "::error::No .exe files found in dist folder" + exit 1 + } + + Write-Host "Found $($exeFiles.Count) exe file(s): $($exeFiles.Name -join ', ')" + + $ymlFile = "latest.yml" + if (-not (Test-Path $ymlFile)) { + Write-Host "::error::$ymlFile not found - cannot update checksums" + exit 1 + } + + $content = Get-Content $ymlFile -Raw + $originalContent = $content + + # Process each exe file and update its hash in latest.yml + foreach ($exeFile in $exeFiles) { + Write-Host "Processing $($exeFile.Name)..." + + # Compute SHA512 hash and convert to base64 (electron-builder format) + $bytes = [System.IO.File]::ReadAllBytes($exeFile.FullName) + $sha512 = [System.Security.Cryptography.SHA512]::Create() + $hashBytes = $sha512.ComputeHash($bytes) + $hash = [System.Convert]::ToBase64String($hashBytes) + $size = $exeFile.Length + + Write-Host " Hash: $hash" + Write-Host " Size: $size" + } + + # For electron-builder, latest.yml has a single file entry for the installer + # Update the sha512 and size for the primary exe (first one, typically the installer) + $primaryExe = $exeFiles | Select-Object -First 1 + $bytes = [System.IO.File]::ReadAllBytes($primaryExe.FullName) + $sha512 = [System.Security.Cryptography.SHA512]::Create() + $hashBytes = $sha512.ComputeHash($bytes) + $hash = [System.Convert]::ToBase64String($hashBytes) + $size = $primaryExe.Length + + # Update sha512 hash (base64 pattern: alphanumeric, +, /, =) + $content = $content -replace 'sha512: [A-Za-z0-9+/=]+', "sha512: $hash" + # Update size + $content = $content -replace 'size: \d+', "size: $size" + + if ($content -eq $originalContent) { + Write-Host "::error::Checksum replacement failed - content unchanged. Check if latest.yml format has changed." + exit 1 + } + + Set-Content -Path $ymlFile -Value $content -NoNewline + Write-Host "✅ Updated $ymlFile with new base64 hash and size for $($primaryExe.Name)" + + - name: Skip signing notice + if: env.AZURE_CLIENT_ID == '' + run: echo "::warning::Windows signing skipped - AZURE_CLIENT_ID not configured. The .exe will be unsigned." - name: Upload artifacts uses: actions/upload-artifact@v4 @@ -335,16 +497,28 @@ jobs: flatpak install -y --user flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08 flatpak install -y --user flathub org.electronjs.Electron2.BaseApp//25.08 + - name: Cache pip wheel cache + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }} + restore-keys: | + pip-wheel-${{ runner.os }}-x64- + - name: Cache bundled Python uses: actions/cache@v4 with: path: apps/frontend/python-runtime - key: python-bundle-${{ runner.os }}-x64-3.12.8 + key: python-bundle-${{ runner.os }}-x64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }} restore-keys: | - python-bundle-${{ runner.os }}-x64- + python-bundle-${{ runner.os }}-x64-3.12.8- - name: Build application run: cd apps/frontend && npm run build + env: + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }} + SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }} - name: Package Linux run: | @@ -352,6 +526,9 @@ jobs: cd apps/frontend && npm run package:linux -- --config.extraMetadata.version="$VERSION" env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }} + SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }} - name: Upload artifacts uses: actions/upload-artifact@v4 diff --git a/.github/workflows/pr-auto-label.yml b/.github/workflows/pr-auto-label.yml deleted file mode 100644 index ac6775e7b8..0000000000 --- a/.github/workflows/pr-auto-label.yml +++ /dev/null @@ -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(); diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml new file mode 100644 index 0000000000..989eaec525 --- /dev/null +++ b/.github/workflows/pr-labeler.yml @@ -0,0 +1,320 @@ +name: PR Labeler + +on: + pull_request: + types: [opened, synchronize, reopened] + +concurrency: + group: pr-labeler-${{ github.event.pull_request.number }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + +jobs: + label: + name: Auto Label PR + runs-on: ubuntu-latest + # Security: Prevent fork PRs from modifying labels (they don't have write access) + if: github.event.pull_request.head.repo.full_name == github.repository + timeout-minutes: 5 + + steps: + - name: Label PR + uses: actions/github-script@v7 + with: + retries: 3 + retry-exempt-status-codes: 400,401,403,404,422 + script: | + // ═══════════════════════════════════════════════════════════════ + // CONFIGURATION - Single source of truth for all settings + // ═══════════════════════════════════════════════════════════════ + + const CONFIG = { + // Size thresholds (lines changed) + SIZE_THRESHOLDS: { + XS: 10, + S: 100, + M: 500, + L: 1000 + }, + + // Conventional commit type mappings + TYPE_MAP: Object.freeze({ + 'feat': 'feature', + 'fix': 'bug', + 'docs': 'documentation', + 'refactor': 'refactor', + 'test': 'test', + 'ci': 'ci', + 'chore': 'chore', + 'perf': 'performance', + 'style': 'style', + 'build': 'build' + }), + + // Area detection paths + AREA_PATHS: Object.freeze({ + frontend: 'apps/frontend/', + backend: 'apps/backend/', + ci: '.github/' + }), + + // Label definitions + LABELS: Object.freeze({ + SIZE: ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'], + AREA: ['area/frontend', 'area/backend', 'area/fullstack', 'area/ci'], + STATUS: ['🔄 Checking', '✅ Ready for Review', '❌ Checks Failed'], + REVIEW: ['Missing AC Approval', 'AC: Approved', 'AC: Changes Requested', 'AC: Needs Re-review'] + }), + + // Pagination + MAX_FILES_PER_PAGE: 100 + }; + + // ═══════════════════════════════════════════════════════════════ + // HELPER FUNCTIONS - Small, focused, single responsibility + // ═══════════════════════════════════════════════════════════════ + + /** + * Safely parse conventional commit type from PR title + * @param {string} title - PR title + * @returns {{type: string|null, isBreaking: boolean}} + */ + function parseConventionalCommit(title) { + if (!title || typeof title !== 'string') { + return { type: null, isBreaking: false }; + } + + // Limit input length to prevent ReDoS attacks + const safeTitle = title.slice(0, 200); + const match = safeTitle.match(/^(\w{1,20})(\([^)]{0,50}\))?(!)?:/); + + if (!match) { + return { type: null, isBreaking: false }; + } + + return { + type: match[1].toLowerCase(), + isBreaking: match[3] === '!' + }; + } + + /** + * Determine size label based on lines changed + * @param {number} totalLines - Total lines changed + * @returns {string} Size label + */ + function determineSizeLabel(totalLines) { + const { SIZE_THRESHOLDS } = CONFIG; + + if (totalLines < SIZE_THRESHOLDS.XS) return 'size/XS'; + if (totalLines < SIZE_THRESHOLDS.S) return 'size/S'; + if (totalLines < SIZE_THRESHOLDS.M) return 'size/M'; + if (totalLines < SIZE_THRESHOLDS.L) return 'size/L'; + return 'size/XL'; + } + + /** + * Detect areas affected by file changes + * @param {Array} files - List of changed files + * @returns {{frontend: boolean, backend: boolean, ci: boolean}} + */ + function detectAreas(files) { + const areas = { frontend: false, backend: false, ci: false }; + const { AREA_PATHS } = CONFIG; + + for (const file of files) { + const path = file.filename || ''; + if (path.startsWith(AREA_PATHS.frontend)) areas.frontend = true; + if (path.startsWith(AREA_PATHS.backend)) areas.backend = true; + if (path.startsWith(AREA_PATHS.ci)) areas.ci = true; + } + + return areas; + } + + /** + * Determine area label based on detected areas + * @param {{frontend: boolean, backend: boolean, ci: boolean}} areas + * @returns {string|null} Area label or null + */ + function determineAreaLabel(areas) { + if (areas.frontend && areas.backend) return 'area/fullstack'; + if (areas.frontend) return 'area/frontend'; + if (areas.backend) return 'area/backend'; + if (areas.ci) return 'area/ci'; + return null; + } + + /** + * Remove labels from PR (with error handling) + * @param {Array} labels - Labels to remove + * @param {number} prNumber - PR number + */ + async function removeLabels(labels, prNumber) { + const { owner, repo } = context.repo; + + await Promise.allSettled(labels.map(async (label) => { + try { + await github.rest.issues.removeLabel({ + owner, + repo, + issue_number: prNumber, + name: label + }); + console.log(` ✓ Removed: ${label}`); + } catch (e) { + // 404 means label wasn't present - that's fine + if (e.status !== 404) { + console.log(` ⚠ Failed to remove ${label}: ${e.message}`); + } + } + })); + } + + /** + * Add labels to PR (with error handling) + * @param {Array} labels - Labels to add + * @param {number} prNumber - PR number + */ + async function addLabels(labels, prNumber) { + if (labels.length === 0) return; + + const { owner, repo } = context.repo; + + try { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number: prNumber, + labels + }); + console.log(` ✓ Added: ${labels.join(', ')}`); + } catch (e) { + if (e.status === 404) { + core.warning(`One or more labels do not exist. Create them in repository settings.`); + } else { + throw e; + } + } + } + + /** + * Fetch PR files with full pagination support + * @param {number} prNumber - PR number + * @returns {Array} List of all files (paginated) + */ + async function fetchPRFiles(prNumber) { + const { owner, repo } = context.repo; + + try { + // Use paginate to fetch ALL files, not just first 100 + const files = await github.paginate( + github.rest.pulls.listFiles, + { owner, repo, pull_number: prNumber, per_page: CONFIG.MAX_FILES_PER_PAGE } + ); + return files; + } catch (e) { + console.log(` ⚠ Could not fetch files: ${e.message}`); + return []; + } + } + + // ═══════════════════════════════════════════════════════════════ + // MAIN LOGIC - Orchestrates the labeling process + // ═══════════════════════════════════════════════════════════════ + + const { owner, repo } = context.repo; + const pr = context.payload.pull_request; + const prNumber = pr.number; + const title = pr.title || ''; + const isNewPR = context.payload.action === 'opened' || context.payload.action === 'reopened'; + + console.log(`::group::PR #${prNumber} - Auto-labeling`); + console.log(`Title: ${title.slice(0, 100)}${title.length > 100 ? '...' : ''}`); + console.log(`Action: ${context.payload.action}`); + + const labelsToAdd = new Set(); + const labelsToRemove = new Set(); + + // 1. Parse conventional commit type + const { type, isBreaking } = parseConventionalCommit(title); + if (type && CONFIG.TYPE_MAP[type]) { + labelsToAdd.add(CONFIG.TYPE_MAP[type]); + console.log(` 📝 Type: ${type} → ${CONFIG.TYPE_MAP[type]}`); + } else { + console.log(` ℹ️ No conventional commit prefix detected`); + } + + if (isBreaking) { + labelsToAdd.add('breaking-change'); + console.log(` ⚠️ Breaking change detected`); + } + + // 2. Detect areas from changed files + const files = await fetchPRFiles(prNumber); + const areas = detectAreas(files); + const areaLabel = determineAreaLabel(areas); + + if (areaLabel) { + labelsToAdd.add(areaLabel); + CONFIG.LABELS.AREA.filter(l => l !== areaLabel).forEach(l => labelsToRemove.add(l)); + console.log(` 📁 Area: ${areaLabel.replace('area/', '')}`); + } + + // 3. Calculate size label + const totalLines = (pr.additions || 0) + (pr.deletions || 0); + const sizeLabel = determineSizeLabel(totalLines); + labelsToAdd.add(sizeLabel); + CONFIG.LABELS.SIZE.filter(l => l !== sizeLabel).forEach(l => labelsToRemove.add(l)); + console.log(` 📏 Size: ${sizeLabel} (${totalLines} lines)`); + + // 4. Set status label (only on new PRs - let pr-status-gate handle updates on pushes) + // Note: On synchronize events, CI workflows will trigger pr-status-gate when they complete + if (isNewPR) { + labelsToAdd.add('🔄 Checking'); + CONFIG.LABELS.STATUS.filter(l => l !== '🔄 Checking').forEach(l => labelsToRemove.add(l)); + console.log(` 🔄 Status: Checking`); + } else { + console.log(` ℹ️ Status: Unchanged (will be updated by pr-status-gate)`); + } + + // 5. Add review label for new PRs only + if (isNewPR) { + labelsToAdd.add('Missing AC Approval'); + console.log(` ⏳ Review: Missing AC Approval`); + } + + console.log('::endgroup::'); + + // 6. Apply label changes + console.log(`::group::Applying labels`); + + // Remove labels that should be replaced (exclude ones we're adding) + const removeList = [...labelsToRemove].filter(l => !labelsToAdd.has(l)); + await removeLabels(removeList, prNumber); + + // Add new labels + await addLabels([...labelsToAdd], prNumber); + + console.log('::endgroup::'); + console.log(`✅ PR #${prNumber} labeled successfully`); + + // 7. Write job summary + const summaryType = type ? CONFIG.TYPE_MAP[type] || 'unknown' : 'none'; + const summaryArea = areaLabel ? areaLabel.replace('area/', '') : 'other'; + + await core.summary + .addHeading(`PR #${prNumber} Auto-Labels`, 3) + .addTable([ + [{ data: 'Category', header: true }, { data: 'Label', header: true }], + ['Type', summaryType], + ['Area', summaryArea], + ['Size', sizeLabel], + ['Status', isNewPR ? '🔄 Checking' : '(unchanged)'], + ['Review', isNewPR ? 'Missing AC Approval' : '(unchanged)'] + ]) + .addRaw(`\n**Files:** ${files.length} | **Lines:** +${pr.additions || 0} / -${pr.deletions || 0}\n`) + .write(); diff --git a/.github/workflows/pr-status-check.yml b/.github/workflows/pr-status-check.yml deleted file mode 100644 index 95c6239e94..0000000000 --- a/.github/workflows/pr-status-check.yml +++ /dev/null @@ -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`); diff --git a/.github/workflows/pr-status-gate.yml b/.github/workflows/pr-status-gate.yml index b28b896d2b..69cb9bd593 100644 --- a/.github/workflows/pr-status-gate.yml +++ b/.github/workflows/pr-status-gate.yml @@ -5,187 +5,581 @@ on: workflows: [CI, Lint, Quality Security] types: [completed] + issue_comment: + types: [created, edited] + + pull_request: + types: [synchronize] + +concurrency: + group: pr-status-gate-${{ github.event.workflow_run.pull_requests[0].number || github.event.issue.number || github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + permissions: pull-requests: write checks: read +env: + # Shared configuration - single source of truth + REQUIRED_CHECKS: | + CI / test-frontend + CI / test-python (3.12) + CI / test-python (3.13) + Lint / python + Quality Security / CodeQL (javascript-typescript) + Quality Security / CodeQL (python) + Quality Security / Python Security (Bandit) + Quality Security / Security Summary + jobs: - update-status: - name: Update PR Status + # ═══════════════════════════════════════════════════════════════════════════ + # JOB 1: CI STATUS (triggered by workflow_run) + # Updates CI status labels when monitored workflows complete + # ═══════════════════════════════════════════════════════════════════════════ + update-ci-status: + name: Update CI Status runs-on: ubuntu-latest - # Only run if this workflow_run is associated with a PR - if: github.event.workflow_run.pull_requests[0] != null + if: github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0] != null timeout-minutes: 5 + steps: - name: Check all required checks and update label uses: actions/github-script@v7 + env: + REQUIRED_CHECKS: ${{ env.REQUIRED_CHECKS }} with: retries: 3 retry-exempt-status-codes: 400,401,403,404,422 script: | - const { owner, repo } = context.repo; + // NOTE: STATUS_LABELS is intentionally duplicated across jobs. + // GitHub Actions jobs run in isolated contexts and cannot share runtime constants. + // If label values change, update ALL occurrences: update-ci-status, check-status-command + const STATUS_LABELS = Object.freeze({ + CHECKING: '🔄 Checking', + PASSED: '✅ Ready for Review', + FAILED: '❌ Checks Failed' + }); + + const REQUIRED_CHECKS = process.env.REQUIRED_CHECKS + .split('\n') + .map(s => s.trim()) + .filter(Boolean); + + async function fetchCheckRuns(sha) { + const { owner, repo } = context.repo; + // Let the configured retries (retries: 3) handle transient failures + // Don't catch errors - allow them to propagate for retry logic + const checkRuns = await github.paginate( + github.rest.checks.listForRef, + { owner, repo, ref: sha, per_page: 100 }, + (response) => response.data + ); + return checkRuns; + } + + function analyzeChecks(checkRuns) { + const results = []; + let allComplete = true; + let anyFailed = false; + + for (const checkName of REQUIRED_CHECKS) { + const check = checkRuns.find(c => c.name === checkName); + + if (!check) { + results.push({ name: checkName, status: '⏳ Pending', complete: false }); + allComplete = false; + } else if (check.status !== 'completed') { + results.push({ name: checkName, status: '🔄 Running', complete: false }); + allComplete = false; + } else if (check.conclusion === 'success') { + results.push({ name: checkName, status: '✅ Passed', complete: true }); + } else if (check.conclusion === 'skipped') { + results.push({ name: checkName, status: '⏭️ Skipped', complete: true, skipped: true }); + } else { + results.push({ name: checkName, status: '❌ Failed', complete: true, failed: true }); + anyFailed = true; + } + } + return { allComplete, anyFailed, results }; + } + + async function updateStatusLabels(prNumber, newLabel) { + const { owner, repo } = context.repo; + const allLabels = Object.values(STATUS_LABELS); + + // Remove all status labels first - throw on non-404 errors to prevent conflicting labels + for (const label of allLabels) { + try { + await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label }); + } catch (e) { + if (e && e.status !== 404) { + // Throw to prevent adding new label if removal failed (could cause conflicting labels) + throw new Error(`Failed to remove label '${label}': ${e.message}`); + } + } + } + + try { + await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [newLabel] }); + } catch (e) { + if (e && e.status === 404) { + core.warning(`Label '${newLabel}' does not exist`); + } else { + throw e; + } + } + } + + // Main logic const prNumber = context.payload.workflow_run.pull_requests[0].number; const headSha = context.payload.workflow_run.head_sha; const triggerWorkflow = context.payload.workflow_run.name; - // ═══════════════════════════════════════════════════════════════════════ - // 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' - ]; + console.log(`PR #${prNumber} - Triggered by: ${triggerWorkflow}, SHA: ${headSha.slice(0, 8)}`); - const statusLabels = { - checking: '🔄 Checking', - passed: '✅ Ready for Review', - failed: '❌ Checks Failed' - }; + const checkRuns = await fetchCheckRuns(headSha); + console.log(`Found ${checkRuns.length} check runs`); + const { allComplete, anyFailed, results } = analyzeChecks(checkRuns); - 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(''); + for (const r of results) { + console.log(` ${r.status} ${r.name}`); + } - // 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::'); + if (!allComplete) { + const pending = results.filter(r => !r.complete).length; + console.log(`⏳ ${pending}/${REQUIRED_CHECKS.length} checks pending`); + // Update to CHECKING status if checks are still running (prevents stale Ready/Failed status) + await updateStatusLabels(prNumber, STATUS_LABELS.CHECKING); return; } + const newLabel = anyFailed ? STATUS_LABELS.FAILED : STATUS_LABELS.PASSED; + await updateStatusLabels(prNumber, newLabel); + + const passedCount = results.filter(r => r.status === '✅ Passed').length; + const failedCount = results.filter(r => r.failed).length; + + if (anyFailed) { + console.log(`❌ PR #${prNumber}: ${failedCount} check(s) failed`); + } else { + console.log(`✅ PR #${prNumber}: Ready for review (${passedCount}/${REQUIRED_CHECKS.length} passed)`); + } + + # ═══════════════════════════════════════════════════════════════════════════ + # JOB 2: /check-status COMMAND + # Manual status check - anyone can trigger by commenting /check-status + # ═══════════════════════════════════════════════════════════════════════════ + check-status-command: + name: Check Status Command + runs-on: ubuntu-latest + if: | + github.event_name == 'issue_comment' && + github.event.issue.pull_request && + contains(github.event.comment.body, '/check-status') + timeout-minutes: 5 + + steps: + - name: Run status check and post report + uses: actions/github-script@v7 + env: + REQUIRED_CHECKS: ${{ env.REQUIRED_CHECKS }} + with: + retries: 3 + retry-exempt-status-codes: 400,401,403,404,422 + script: | + // NOTE: STATUS_LABELS is intentionally duplicated across jobs. + // GitHub Actions jobs run in isolated contexts and cannot share runtime constants. + // If label values change, update ALL occurrences: update-ci-status, check-status-command + const STATUS_LABELS = Object.freeze({ + CHECKING: '🔄 Checking', + PASSED: '✅ Ready for Review', + FAILED: '❌ Checks Failed' + }); + + // NOTE: REVIEW_LABELS is intentionally duplicated across jobs. + // If label values change, update ALL occurrences: check-status-command, update-review-status + const REVIEW_LABELS = Object.freeze([ + 'Missing AC Approval', + 'AC: Approved', + 'AC: Changes Requested', + 'AC: Blocked', + 'AC: Needs Re-review', + 'AC: Reviewed' + ]); + + const REQUIRED_CHECKS = process.env.REQUIRED_CHECKS + .split('\n') + .map(s => s.trim()) + .filter(Boolean); + + const { owner, repo } = context.repo; + const prNumber = context.payload.issue.number; + const requestedBy = context.payload.comment.user.login; + + // Get PR details + const { data: pr } = await github.rest.pulls.get({ + owner, repo, pull_number: prNumber + }); + const headSha = pr.head.sha; + + console.log(`PR #${prNumber} - /check-status by @${requestedBy}, SHA: ${headSha.slice(0, 8)}`); + + // Fetch check runs with pagination to handle >100 checks + const checkRuns = await github.paginate( + github.rest.checks.listForRef, + { owner, repo, ref: headSha, per_page: 100 }, + (response) => response.data + ); + console.log(`Found ${checkRuns.length} check runs`); + + // Analyze results + const results = []; let allComplete = true; let anyFailed = false; - const results = []; - // Check each required check - for (const checkName of requiredChecks) { - const check = allCheckRuns.find(c => c.name === checkName); + for (const checkName of REQUIRED_CHECKS) { + const check = checkRuns.find(c => c.name === checkName); if (!check) { - results.push({ name: checkName, status: '⏳ Pending', complete: false }); + results.push({ name: checkName, emoji: '⏳', complete: false }); allComplete = false; } else if (check.status !== 'completed') { - results.push({ name: checkName, status: '🔄 Running', complete: false }); + results.push({ name: checkName, emoji: '🔄', complete: false }); allComplete = false; } else if (check.conclusion === 'success') { - results.push({ name: checkName, status: '✅ Passed', complete: true }); + results.push({ name: checkName, emoji: '✅', 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 }); + results.push({ name: checkName, emoji: '⏭️', complete: true, skipped: true }); } else { - results.push({ name: checkName, status: '❌ Failed', complete: true, failed: true }); + results.push({ name: checkName, emoji: '❌', 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}`); + // Get current labels + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner, repo, issue_number: prNumber + }); + const labelNames = currentLabels.map(l => l.name); + const currentStatusLabel = Object.values(STATUS_LABELS).find(l => labelNames.includes(l)) || 'None'; + const currentReviewLabel = REVIEW_LABELS.find(l => labelNames.includes(l)) || 'None'; + + // Update label if all checks complete + let newStatusLabel = STATUS_LABELS.CHECKING; + let statusChanged = false; + + if (allComplete) { + newStatusLabel = anyFailed ? STATUS_LABELS.FAILED : STATUS_LABELS.PASSED; + + if (newStatusLabel !== currentStatusLabel) { + statusChanged = true; + // Remove all status labels first - throw on non-404 errors to prevent conflicting labels + for (const label of Object.values(STATUS_LABELS)) { + try { + await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label }); + } catch (e) { + if (e && e.status !== 404) { + throw new Error(`Failed to remove label '${label}': ${e.message}`); + } + } + } + await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [newStatusLabel] }); + } } - 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; + // Build status report + const passedCount = results.filter(r => r.emoji === '✅').length; + let statusEmoji = '🔄'; + if (allComplete && !anyFailed) statusEmoji = '✅'; + else if (allComplete && anyFailed) statusEmoji = '❌'; + + const checksTable = results.map(r => `| ${r.emoji} | ${r.name} |`).join('\n'); + + const lines = [ + `## ${statusEmoji} PR Status Report`, + '', + `| Label | Value |`, + `|-------|-------|`, + `| CI Status | ${newStatusLabel} |`, + `| AC Review | ${currentReviewLabel} |`, + '' + ]; + + if (statusChanged) { + lines.push(`> Status updated: \`${currentStatusLabel}\` → \`${newStatusLabel}\``); + lines.push(''); } - // Determine final label - const newLabel = anyFailed ? statusLabels.failed : statusLabels.passed; + lines.push(`### CI Checks (${passedCount}/${REQUIRED_CHECKS.length} passed)`); + lines.push(''); + lines.push('| Status | Check |'); + lines.push('|--------|-------|'); + lines.push(checksTable); + lines.push(''); + lines.push('---'); + lines.push(`Triggered by \`/check-status\` from @${requestedBy}`); - console.log(`::group::Updating PR #${prNumber} label`); + await github.rest.issues.createComment({ + owner, repo, issue_number: prNumber, body: lines.join('\n') + }); + + console.log(`✅ Posted status report to PR #${prNumber}`); + + # ═══════════════════════════════════════════════════════════════════════════ + # JOB 3: AUTO-CLAUDE REVIEW + # Processes Auto-Claude review comments from trusted sources + # Security: Only bots and collaborators can update labels + # ═══════════════════════════════════════════════════════════════════════════ + update-review-status: + name: Update Review Status + runs-on: ubuntu-latest + if: | + github.event_name == 'issue_comment' && + github.event.issue.pull_request && + !contains(github.event.comment.body, '/check-status') + timeout-minutes: 5 + + steps: + - name: Check for Auto-Claude review + uses: actions/github-script@v7 + with: + retries: 3 + retry-exempt-status-codes: 400,401,403,404,422 + script: | + // Security configuration + // SECURITY: Only [bot] suffixed accounts are protected by GitHub. + // Regular usernames can be registered by anyone and are NOT trusted. + const TRUSTED_BOT_ACCOUNTS = Object.freeze([ + 'github-actions[bot]', + 'auto-claude[bot]' + ]); + + const TRUSTED_AUTHOR_ASSOCIATIONS = Object.freeze([ + 'COLLABORATOR', + 'MEMBER', + 'OWNER' + ]); + + const IDENTIFIER_PATTERNS = Object.freeze([ + '🤖 Auto Claude PR Review', + 'Auto Claude Review', + 'Auto-Claude Review' + ]); + + // SECURITY: Regex patterns are tightened to prevent false matches + // Using \s* instead of .* and requiring specific emoji + verdict format + const VERDICTS = Object.freeze({ + APPROVED: { + patterns: ['Auto Claude Review - APPROVED', '✅ Auto Claude Review - APPROVED'], + // Match: "Merge Verdict:" followed by whitespace/emoji, then ✅, then APPROVED/READY TO MERGE + regex: /Merge Verdict:\s*✅\s*(?:APPROVED|READY TO MERGE)/i, + label: 'AC: Approved' + }, + CHANGES_REQUESTED: { + patterns: ['NEEDS REVISION', 'Needs Revision'], + // Match: "Merge Verdict:" followed by whitespace/emoji, then 🟠 + regex: /Merge Verdict:\s*🟠/, + label: 'AC: Changes Requested' + }, + BLOCKED: { + patterns: ['BLOCKED'], + // Match: "Merge Verdict:" followed by whitespace/emoji, then 🔴 + regex: /Merge Verdict:\s*🔴/, + label: 'AC: Blocked' + } + }); + + // NOTE: REVIEW_LABELS is intentionally duplicated across jobs. + // GitHub Actions jobs run in isolated contexts and cannot share runtime constants. + // If label values change, update ALL occurrences: check-status-command, update-review-status + const REVIEW_LABELS = Object.freeze([ + 'Missing AC Approval', + 'AC: Approved', + 'AC: Changes Requested', + 'AC: Blocked', + 'AC: Needs Re-review', + 'AC: Reviewed' + ]); + + // Helper functions + // SECURITY: Verify both username AND account type to prevent spoofing + function isTrustedBot(username, userType) { + const isKnownBot = TRUSTED_BOT_ACCOUNTS.some(t => username.toLowerCase() === t.toLowerCase()); + // Only trust if it's a known bot account AND GitHub confirms it's a Bot type + return isKnownBot && userType === 'Bot'; + } + + function isTrustedAssociation(assoc) { + return TRUSTED_AUTHOR_ASSOCIATIONS.includes(assoc); + } + + function isAutoClaudeComment(body) { + return IDENTIFIER_PATTERNS.some(p => body.includes(p)); + } + + function parseVerdict(body) { + const safeBody = body.slice(0, 5000); + for (const [key, config] of Object.entries(VERDICTS)) { + const patternMatch = config.patterns.some(p => safeBody.includes(p)); + const regexMatch = config.regex && config.regex.test(safeBody); + if (patternMatch || regexMatch) { + return { verdict: key, label: config.label }; + } + } + return null; + } + + async function updateReviewLabels(prNumber, newLabel) { + const { owner, repo } = context.repo; + + // Remove all review labels first - throw on non-404 errors to prevent conflicting labels + for (const label of REVIEW_LABELS) { + try { + await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label }); + console.log(` Removed: ${label}`); + } catch (e) { + if (e && e.status !== 404) { + // Throw to prevent adding new label if removal failed (could cause conflicting labels) + throw new Error(`Failed to remove label '${label}': ${e.message}`); + } + } + } - // 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}`); + await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [newLabel] }); + console.log(` Added: ${newLabel}`); } catch (e) { - if (e.status !== 404) { - console.log(` ⚠ Could not remove ${label}: ${e.message}`); + if (e && e.status === 404) { + core.warning(`Label '${newLabel}' does not exist`); + } else { + throw e; } } } - // Add final status label + // Main logic + const prNumber = context.payload.issue.number; + const comment = context.payload.comment; + const commenter = comment.user.login; + const commenterType = comment.user.type; + const authorAssociation = comment.author_association; + const body = comment.body || ''; + + console.log(`PR #${prNumber} - Comment by: ${commenter} (type: ${commenterType}, assoc: ${authorAssociation})`); + + // Security checks + // SECURITY: Bot status requires BOTH username match AND verified Bot type + const isBot = isTrustedBot(commenter, commenterType); + const isCollaborator = isTrustedAssociation(authorAssociation); + const isACComment = isAutoClaudeComment(body); + + console.log(` Trusted bot: ${isBot}, Collaborator: ${isCollaborator}, AC comment: ${isACComment}`); + + if (!isBot && !isCollaborator) { + console.log('Skipping: Not a trusted bot or collaborator'); + return; + } + + if (!isACComment) { + console.log('Skipping: Not an Auto-Claude comment'); + return; + } + + const verdictResult = parseVerdict(body); + if (!verdictResult) { + console.log('Skipping: Could not parse verdict'); + return; + } + + console.log(`Verdict: ${verdictResult.verdict} → ${verdictResult.label}`); + await updateReviewLabels(prNumber, verdictResult.label); + console.log(`✅ PR #${prNumber} review status updated`); + + # ═══════════════════════════════════════════════════════════════════════════ + # JOB 4: RE-REVIEW ON PUSH + # When new commits pushed after AC approval, require re-review + # ═══════════════════════════════════════════════════════════════════════════ + require-re-review: + name: Require Re-review on Push + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' && github.event.action == 'synchronize' + timeout-minutes: 5 + + steps: + - name: Check and reset AC approval if needed + uses: actions/github-script@v7 + with: + retries: 3 + retry-exempt-status-codes: 400,401,403,404,422 + script: | + const { owner, repo } = context.repo; + const prNumber = context.payload.pull_request.number; + const pusher = context.payload.sender.login; + + console.log(`PR #${prNumber} - New commits by: ${pusher}`); + + // Get current labels + const { data: labels } = await github.rest.issues.listLabelsOnIssue({ + owner, repo, issue_number: prNumber + }); + const labelNames = labels.map(l => l.name); + + // Check if PR was approved + const wasApproved = labelNames.includes('AC: Approved'); + + if (!wasApproved) { + console.log('PR was not AC-approved, no action needed'); + return; + } + + console.log('PR was AC-approved, resetting to require re-review'); + + // Remove AC: Approved - throw on non-404 errors to prevent conflicting labels try { - await github.rest.issues.addLabels({ - owner, - repo, - issue_number: prNumber, - labels: [newLabel] + await github.rest.issues.removeLabel({ + owner, repo, issue_number: prNumber, name: 'AC: Approved' }); - console.log(` ✓ Added: ${newLabel}`); + console.log(' Removed: AC: Approved'); } catch (e) { - if (e.status === 404) { - core.warning(`Label '${newLabel}' does not exist. Please create it in repository settings.`); + if (e && e.status !== 404) { + // Throw to prevent adding 'AC: Needs Re-review' if removal failed (could cause conflicting labels) + core.error(`Failed to remove 'AC: Approved' label: ${e.message}`); + throw e; } - throw e; } - console.log('::endgroup::'); + // Add AC: Needs Re-review + try { + await github.rest.issues.addLabels({ + owner, repo, issue_number: prNumber, labels: ['AC: Needs Re-review'] + }); + console.log(' Added: AC: Needs Re-review'); + } catch (e) { + if (e && e.status === 404) { + core.warning("Label 'AC: Needs Re-review' does not exist"); + } else { + throw e; + } + } - // 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; + // Post notification comment + const commentLines = [ + '## 🔄 Re-review Required', + '', + 'New commits were pushed after Auto-Claude approval.', + '', + '| Previous | Current |', + '|----------|---------|', + '| `AC: Approved` | `AC: Needs Re-review` |', + '', + 'Please run Auto-Claude review again or request a manual review.', + '', + '---', + `Triggered by push from @${pusher}` + ]; - 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`); - } + await github.rest.issues.createComment({ + owner, repo, issue_number: prNumber, body: commentLines.join('\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(); + console.log(`✅ Posted re-review notification to PR #${prNumber}`); diff --git a/.github/workflows/prepare-release.yml b/.github/workflows/prepare-release.yml index d50940c188..ac10837861 100644 --- a/.github/workflows/prepare-release.yml +++ b/.github/workflows/prepare-release.yml @@ -1,8 +1,10 @@ name: Prepare Release # Triggers when code is pushed to main (e.g., merging develop → main) -# If package.json version is newer than the latest tag, creates a new tag -# which then triggers the release.yml workflow +# If package.json version is newer than the latest tag: +# 1. Validates CHANGELOG.md has an entry for this version (FAILS if missing) +# 2. Extracts release notes from CHANGELOG.md +# 3. Creates a new tag which triggers release.yml on: push: @@ -67,8 +69,122 @@ jobs: echo "⏭️ No release needed (package version not newer than latest tag)" fi - - name: Create and push tag + # CRITICAL: Validate CHANGELOG.md has entry for this version BEFORE creating tag + - name: Validate and extract changelog if: steps.check.outputs.should_release == 'true' + id: changelog + run: | + VERSION="${{ steps.check.outputs.new_version }}" + CHANGELOG_FILE="CHANGELOG.md" + + echo "🔍 Validating CHANGELOG.md for version $VERSION..." + + if [ ! -f "$CHANGELOG_FILE" ]; then + echo "::error::CHANGELOG.md not found! Please create CHANGELOG.md with release notes." + exit 1 + fi + + # Extract changelog section for this version + # Looks for "## X.Y.Z" header and captures until next "## " or "---" or end + CHANGELOG_CONTENT=$(awk -v ver="$VERSION" ' + BEGIN { found=0; content="" } + /^## / { + if (found) exit + # Match version at start of header (e.g., "## 2.7.3 -" or "## 2.7.3") + if ($2 == ver || $2 ~ "^"ver"[[:space:]]*-") { + found=1 + # Skip the header line itself, we will add our own + next + } + } + /^---$/ { if (found) exit } + found { content = content $0 "\n" } + END { + if (!found) { + print "NOT_FOUND" + exit 1 + } + # Trim leading/trailing whitespace + gsub(/^[[:space:]]+|[[:space:]]+$/, "", content) + print content + } + ' "$CHANGELOG_FILE") + + if [ "$CHANGELOG_CONTENT" = "NOT_FOUND" ] || [ -z "$CHANGELOG_CONTENT" ]; then + echo "" + echo "::error::═══════════════════════════════════════════════════════════════════════" + echo "::error:: CHANGELOG VALIDATION FAILED" + echo "::error::═══════════════════════════════════════════════════════════════════════" + echo "::error::" + echo "::error:: Version $VERSION not found in CHANGELOG.md!" + echo "::error::" + echo "::error:: Before releasing, please update CHANGELOG.md with an entry like:" + echo "::error::" + echo "::error:: ## $VERSION - Your Release Title" + echo "::error::" + echo "::error:: ### ✨ New Features" + echo "::error:: - Feature description" + echo "::error::" + echo "::error:: ### 🐛 Bug Fixes" + echo "::error:: - Fix description" + echo "::error::" + echo "::error::═══════════════════════════════════════════════════════════════════════" + echo "" + + # Also add to job summary for visibility + echo "## ❌ Release Blocked: Missing Changelog" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "Version **$VERSION** was not found in CHANGELOG.md." >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### How to fix:" >> $GITHUB_STEP_SUMMARY + echo "1. Update CHANGELOG.md with release notes for version $VERSION" >> $GITHUB_STEP_SUMMARY + echo "2. Commit and push the changes" >> $GITHUB_STEP_SUMMARY + echo "3. The release will automatically retry" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### Expected format:" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`markdown" >> $GITHUB_STEP_SUMMARY + echo "## $VERSION - Release Title" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### ✨ New Features" >> $GITHUB_STEP_SUMMARY + echo "- Feature description" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "### 🐛 Bug Fixes" >> $GITHUB_STEP_SUMMARY + echo "- Fix description" >> $GITHUB_STEP_SUMMARY + echo "\`\`\`" >> $GITHUB_STEP_SUMMARY + + exit 1 + fi + + echo "✅ Found changelog entry for version $VERSION" + echo "" + echo "--- Extracted Release Notes ---" + echo "$CHANGELOG_CONTENT" + echo "--- End Release Notes ---" + + # Save changelog to file for artifact upload + echo "$CHANGELOG_CONTENT" > changelog-extract.md + + # Also save to output (for short changelogs) + # Using heredoc for multiline output + { + echo "content<> $GITHUB_OUTPUT + + echo "changelog_valid=true" >> $GITHUB_OUTPUT + + # Upload changelog as artifact for release.yml to use + - name: Upload changelog artifact + if: steps.check.outputs.should_release == 'true' && steps.changelog.outputs.changelog_valid == 'true' + uses: actions/upload-artifact@v4 + with: + name: changelog-${{ steps.check.outputs.new_version }} + path: changelog-extract.md + retention-days: 1 + + - name: Create and push tag + if: steps.check.outputs.should_release == 'true' && steps.changelog.outputs.changelog_valid == 'true' run: | VERSION="${{ steps.check.outputs.new_version }}" TAG="v$VERSION" @@ -85,17 +201,19 @@ jobs: - name: Summary run: | - if [ "${{ steps.check.outputs.should_release }}" = "true" ]; then + if [ "${{ steps.check.outputs.should_release }}" = "true" ] && [ "${{ steps.changelog.outputs.changelog_valid }}" = "true" ]; then echo "## 🚀 Release Triggered" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Version:** v${{ steps.check.outputs.new_version }}" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY + echo "✅ Changelog validated and extracted from CHANGELOG.md" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY echo "The release workflow has been triggered and will:" >> $GITHUB_STEP_SUMMARY echo "1. Build binaries for all platforms" >> $GITHUB_STEP_SUMMARY - echo "2. Generate changelog from PRs" >> $GITHUB_STEP_SUMMARY + echo "2. Use changelog from CHANGELOG.md" >> $GITHUB_STEP_SUMMARY echo "3. Create GitHub release" >> $GITHUB_STEP_SUMMARY echo "4. Update README with new version" >> $GITHUB_STEP_SUMMARY - else + elif [ "${{ steps.check.outputs.should_release }}" = "false" ]; then echo "## ⏭️ No Release Needed" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Package version:** ${{ steps.package.outputs.version }}" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c6b6ddc99c..6ca7f72858 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -46,16 +46,28 @@ jobs: - name: Install Rust toolchain (for building native Python packages) uses: dtolnay/rust-toolchain@stable + - name: Cache pip wheel cache (for compiled packages like real_ladybug) + uses: actions/cache@v4 + with: + path: ~/Library/Caches/pip + key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }} + restore-keys: | + pip-wheel-${{ runner.os }}-x64- + - name: Cache bundled Python uses: actions/cache@v4 with: path: apps/frontend/python-runtime - key: python-bundle-${{ runner.os }}-x64-3.12.8-rust + key: python-bundle-${{ runner.os }}-x64-3.12.8-rust-${{ hashFiles('apps/backend/requirements.txt') }} restore-keys: | - python-bundle-${{ runner.os }}-x64- + python-bundle-${{ runner.os }}-x64-3.12.8-rust- - name: Build application run: cd apps/frontend && npm run build + env: + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }} + SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }} - name: Package macOS (Intel) run: cd apps/frontend && npm run package:mac -- --x64 @@ -63,6 +75,9 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} CSC_LINK: ${{ secrets.MAC_CERTIFICATE }} CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }} + SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }} - name: Notarize macOS Intel app env: @@ -93,6 +108,8 @@ jobs: path: | apps/frontend/dist/*.dmg apps/frontend/dist/*.zip + apps/frontend/dist/*.yml + apps/frontend/dist/*.blockmap # Apple Silicon build on ARM64 runner for native compilation build-macos-arm64: @@ -123,16 +140,28 @@ jobs: - name: Install dependencies run: cd apps/frontend && npm ci + - name: Cache pip wheel cache + uses: actions/cache@v4 + with: + path: ~/Library/Caches/pip + key: pip-wheel-${{ runner.os }}-arm64-${{ hashFiles('apps/backend/requirements.txt') }} + restore-keys: | + pip-wheel-${{ runner.os }}-arm64- + - name: Cache bundled Python uses: actions/cache@v4 with: path: apps/frontend/python-runtime - key: python-bundle-${{ runner.os }}-arm64-3.12.8 + key: python-bundle-${{ runner.os }}-arm64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }} restore-keys: | - python-bundle-${{ runner.os }}-arm64- + python-bundle-${{ runner.os }}-arm64-3.12.8- - name: Build application run: cd apps/frontend && npm run build + env: + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }} + SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }} - name: Package macOS (Apple Silicon) run: cd apps/frontend && npm run package:mac -- --arm64 @@ -140,6 +169,9 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} CSC_LINK: ${{ secrets.MAC_CERTIFICATE }} CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }} + SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }} - name: Notarize macOS ARM64 app env: @@ -170,9 +202,17 @@ jobs: path: | apps/frontend/dist/*.dmg apps/frontend/dist/*.zip + apps/frontend/dist/*.yml + apps/frontend/dist/*.blockmap build-windows: runs-on: windows-latest + permissions: + id-token: write # Required for OIDC authentication with Azure + contents: read + env: + # Job-level env so AZURE_CLIENT_ID is available for step-level if conditions + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} steps: - uses: actions/checkout@v4 @@ -200,23 +240,149 @@ jobs: - name: Install dependencies run: cd apps/frontend && npm ci + - name: Cache pip wheel cache + uses: actions/cache@v4 + with: + path: ~\AppData\Local\pip\Cache + key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }} + restore-keys: | + pip-wheel-${{ runner.os }}-x64- + - name: Cache bundled Python uses: actions/cache@v4 with: path: apps/frontend/python-runtime - key: python-bundle-${{ runner.os }}-x64-3.12.8 + key: python-bundle-${{ runner.os }}-x64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }} restore-keys: | - python-bundle-${{ runner.os }}-x64- + python-bundle-${{ runner.os }}-x64-3.12.8- - name: Build application run: cd apps/frontend && npm run build + env: + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }} + SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }} - name: Package Windows run: cd apps/frontend && npm run package:win env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - CSC_LINK: ${{ secrets.WIN_CERTIFICATE }} - CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTIFICATE_PASSWORD }} + # Disable electron-builder's built-in signing (we use Azure Trusted Signing instead) + CSC_IDENTITY_AUTO_DISCOVERY: false + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }} + SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }} + + - name: Azure Login (OIDC) + if: env.AZURE_CLIENT_ID != '' + uses: azure/login@v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Sign Windows executable with Azure Trusted Signing + if: env.AZURE_CLIENT_ID != '' + uses: azure/trusted-signing-action@v0.5.11 + with: + endpoint: https://neu.codesigning.azure.net/ + trusted-signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + certificate-profile-name: ${{ secrets.AZURE_CERTIFICATE_PROFILE }} + files-folder: apps/frontend/dist + files-folder-filter: exe + file-digest: SHA256 + timestamp-rfc3161: http://timestamp.acs.microsoft.com + timestamp-digest: SHA256 + + - name: Verify Windows executable is signed + if: env.AZURE_CLIENT_ID != '' + shell: pwsh + run: | + cd apps/frontend/dist + $exeFile = Get-ChildItem -Filter "*.exe" | Select-Object -First 1 + if ($exeFile) { + Write-Host "Verifying signature on $($exeFile.Name)..." + $sig = Get-AuthenticodeSignature -FilePath $exeFile.FullName + if ($sig.Status -ne 'Valid') { + Write-Host "::error::Signature verification failed: $($sig.Status)" + Write-Host "::error::Status Message: $($sig.StatusMessage)" + exit 1 + } + Write-Host "✅ Signature verified successfully" + Write-Host " Subject: $($sig.SignerCertificate.Subject)" + Write-Host " Issuer: $($sig.SignerCertificate.Issuer)" + Write-Host " Thumbprint: $($sig.SignerCertificate.Thumbprint)" + } else { + Write-Host "::error::No .exe file found to verify" + exit 1 + } + + - name: Regenerate checksums after signing + if: env.AZURE_CLIENT_ID != '' + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + cd apps/frontend/dist + + # Find the installer exe (electron-builder names it with "Setup" or just the app name) + # electron-builder produces one installer exe per build + $exeFiles = Get-ChildItem -Filter "*.exe" + if ($exeFiles.Count -eq 0) { + Write-Host "::error::No .exe files found in dist folder" + exit 1 + } + + Write-Host "Found $($exeFiles.Count) exe file(s): $($exeFiles.Name -join ', ')" + + $ymlFile = "latest.yml" + if (-not (Test-Path $ymlFile)) { + Write-Host "::error::$ymlFile not found - cannot update checksums" + exit 1 + } + + $content = Get-Content $ymlFile -Raw + $originalContent = $content + + # Process each exe file and update its hash in latest.yml + foreach ($exeFile in $exeFiles) { + Write-Host "Processing $($exeFile.Name)..." + + # Compute SHA512 hash and convert to base64 (electron-builder format) + $bytes = [System.IO.File]::ReadAllBytes($exeFile.FullName) + $sha512 = [System.Security.Cryptography.SHA512]::Create() + $hashBytes = $sha512.ComputeHash($bytes) + $hash = [System.Convert]::ToBase64String($hashBytes) + $size = $exeFile.Length + + Write-Host " Hash: $hash" + Write-Host " Size: $size" + } + + # For electron-builder, latest.yml has a single file entry for the installer + # Update the sha512 and size for the primary exe (first one, typically the installer) + $primaryExe = $exeFiles | Select-Object -First 1 + $bytes = [System.IO.File]::ReadAllBytes($primaryExe.FullName) + $sha512 = [System.Security.Cryptography.SHA512]::Create() + $hashBytes = $sha512.ComputeHash($bytes) + $hash = [System.Convert]::ToBase64String($hashBytes) + $size = $primaryExe.Length + + # Update sha512 hash (base64 pattern: alphanumeric, +, /, =) + $content = $content -replace 'sha512: [A-Za-z0-9+/=]+', "sha512: $hash" + # Update size + $content = $content -replace 'size: \d+', "size: $size" + + if ($content -eq $originalContent) { + Write-Host "::error::Checksum replacement failed - content unchanged. Check if latest.yml format has changed." + exit 1 + } + + Set-Content -Path $ymlFile -Value $content -NoNewline + Write-Host "✅ Updated $ymlFile with new base64 hash and size for $($primaryExe.Name)" + + - name: Skip signing notice + if: env.AZURE_CLIENT_ID == '' + run: echo "::warning::Windows signing skipped - AZURE_CLIENT_ID not configured. The .exe will be unsigned." - name: Upload artifacts uses: actions/upload-artifact@v4 @@ -224,6 +390,8 @@ jobs: name: windows-builds path: | apps/frontend/dist/*.exe + apps/frontend/dist/*.yml + apps/frontend/dist/*.blockmap build-linux: runs-on: ubuntu-latest @@ -261,21 +429,36 @@ jobs: flatpak install -y --user flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08 flatpak install -y --user flathub org.electronjs.Electron2.BaseApp//25.08 + - name: Cache pip wheel cache + uses: actions/cache@v4 + with: + path: ~/.cache/pip + key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }} + restore-keys: | + pip-wheel-${{ runner.os }}-x64- + - name: Cache bundled Python uses: actions/cache@v4 with: path: apps/frontend/python-runtime - key: python-bundle-${{ runner.os }}-x64-3.12.8 + key: python-bundle-${{ runner.os }}-x64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }} restore-keys: | - python-bundle-${{ runner.os }}-x64- + python-bundle-${{ runner.os }}-x64-3.12.8- - name: Build application run: cd apps/frontend && npm run build + env: + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }} + SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }} - name: Package Linux run: cd apps/frontend && npm run package:linux env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + SENTRY_DSN: ${{ secrets.SENTRY_DSN }} + SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }} + SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }} - name: Upload artifacts uses: actions/upload-artifact@v4 @@ -285,6 +468,8 @@ jobs: apps/frontend/dist/*.AppImage apps/frontend/dist/*.deb apps/frontend/dist/*.flatpak + apps/frontend/dist/*.yml + apps/frontend/dist/*.blockmap create-release: needs: [build-macos-intel, build-macos-arm64, build-windows, build-linux] @@ -304,16 +489,30 @@ jobs: - name: Flatten and validate artifacts run: | mkdir -p release-assets - find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) -exec cp {} release-assets/ \; + find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; - # Validate that at least one artifact was copied - artifact_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) | wc -l) - if [ "$artifact_count" -eq 0 ]; then - echo "::error::No build artifacts found! Expected .dmg, .zip, .exe, .AppImage, .deb, or .flatpak files." + # Validate that installer files exist (not just manifests) + installer_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) | wc -l) + if [ "$installer_count" -eq 0 ]; then + echo "::error::No installer artifacts found! Expected .dmg, .zip, .exe, .AppImage, .deb, or .flatpak files." exit 1 fi - echo "Found $artifact_count artifact(s):" + echo "Found $installer_count installer(s):" + find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) -exec basename {} \; + + # Validate that electron-updater manifest files are present (required for auto-updates) + yml_count=$(find release-assets -type f -name "*.yml" | wc -l) + if [ "$yml_count" -eq 0 ]; then + echo "::error::No update manifest (.yml) files found! Auto-update architecture detection will not work." + exit 1 + fi + + echo "Found $yml_count manifest file(s):" + find release-assets -type f -name "*.yml" -exec basename {} \; + + echo "" + echo "All release assets:" ls -la release-assets/ - name: Generate checksums @@ -473,23 +672,78 @@ jobs: cat release-assets/checksums.sha256 >> $GITHUB_STEP_SUMMARY echo "\`\`\`" >> $GITHUB_STEP_SUMMARY - - name: Generate changelog - if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }} + - name: Extract changelog from CHANGELOG.md + if: ${{ github.event_name == 'push' }} id: changelog - uses: release-drafter/release-drafter@v6 - with: - config-name: release-drafter.yml - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Extract version from tag (v2.7.2 -> 2.7.2) + VERSION=${GITHUB_REF_NAME#v} + CHANGELOG_FILE="CHANGELOG.md" + + echo "📋 Extracting release notes for version $VERSION from CHANGELOG.md..." + + if [ ! -f "$CHANGELOG_FILE" ]; then + echo "::warning::CHANGELOG.md not found, using minimal release notes" + echo "body=Release v$VERSION" >> $GITHUB_OUTPUT + exit 0 + fi + + # Extract changelog section for this version + # Looks for "## X.Y.Z" header and captures until next "## " or "---" + CHANGELOG_CONTENT=$(awk -v ver="$VERSION" ' + BEGIN { found=0; content="" } + /^## / { + if (found) exit + # Match version at start of header (e.g., "## 2.7.3 -" or "## 2.7.3") + if ($2 == ver || $2 ~ "^"ver"[[:space:]]*-") { + found=1 + next + } + } + /^---$/ { if (found) exit } + found { content = content $0 "\n" } + END { + if (!found) { + print "NOT_FOUND" + exit 0 + } + # Trim leading/trailing whitespace + gsub(/^[[:space:]]+|[[:space:]]+$/, "", content) + print content + } + ' "$CHANGELOG_FILE") + + if [ "$CHANGELOG_CONTENT" = "NOT_FOUND" ] || [ -z "$CHANGELOG_CONTENT" ]; then + echo "::warning::Version $VERSION not found in CHANGELOG.md, using minimal release notes" + CHANGELOG_CONTENT="Release v$VERSION + +See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details." + fi + + echo "✅ Extracted changelog content" + + # Save to file first (more reliable for multiline) + echo "$CHANGELOG_CONTENT" > changelog-body.md + + # Use file-based output for multiline content + { + echo "body<> $GITHUB_OUTPUT - name: Create Release - if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }} + if: ${{ github.event_name == 'push' }} uses: softprops/action-gh-release@v2 with: body: | ${{ steps.changelog.outputs.body }} + --- + ${{ steps.virustotal.outputs.vt_results }} + + **Full Changelog**: https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md files: release-assets/* draft: false prerelease: ${{ contains(github.ref, 'beta') || contains(github.ref, 'alpha') }} @@ -500,7 +754,8 @@ jobs: update-readme: needs: [create-release] runs-on: ubuntu-latest - if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }} + # Only update README on actual releases (tag push), not dry runs + if: ${{ github.event_name == 'push' }} permissions: contents: write steps: diff --git a/.gitignore b/.gitignore index 7f53e4c59a..6d2e458532 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ Desktop.ini .env .env.* !.env.example +/config.json *.pem *.key *.crt @@ -163,3 +164,7 @@ _bmad-output/ .claude/ /docs OPUS_ANALYSIS_AND_IDEAS.md +/.github/agents + +# Auto Claude generated files +.security-key diff --git a/.husky/pre-commit b/.husky/pre-commit index 5e0e6a21a0..90f4edccb8 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,5 +1,14 @@ #!/bin/sh +# Preserve git worktree context - prevent HEAD corruption in worktrees +if [ -f ".git" ]; then + WORKTREE_GIT_DIR=$(cat .git | sed 's/gitdir: //') + if [ -n "$WORKTREE_GIT_DIR" ]; then + export GIT_DIR="$WORKTREE_GIT_DIR" + export GIT_WORK_TREE="$(pwd)" + fi +fi + echo "Running pre-commit checks..." # ============================================================================= @@ -126,28 +135,30 @@ if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then fi # Run pytest (skip slow/integration tests and Windows-incompatible tests for pre-commit speed) + # Use subshell to isolate directory changes and prevent worktree corruption echo "Running Python tests..." - cd apps/backend - # Tests to skip: graphiti (external deps), merge_file_tracker/service_orchestrator/worktree/workspace (Windows path/git issues) - IGNORE_TESTS="--ignore=../../tests/test_graphiti.py --ignore=../../tests/test_merge_file_tracker.py --ignore=../../tests/test_service_orchestrator.py --ignore=../../tests/test_worktree.py --ignore=../../tests/test_workspace.py" - if [ -d ".venv" ]; then - # Use venv if it exists - if [ -f ".venv/bin/pytest" ]; then - PYTHONPATH=. .venv/bin/pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS - elif [ -f ".venv/Scripts/pytest.exe" ]; then - # Windows - PYTHONPATH=. .venv/Scripts/pytest.exe ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS + ( + cd apps/backend + # Tests to skip: graphiti (external deps), merge_file_tracker/service_orchestrator/worktree/workspace (Windows path/git issues) + IGNORE_TESTS="--ignore=../../tests/test_graphiti.py --ignore=../../tests/test_merge_file_tracker.py --ignore=../../tests/test_service_orchestrator.py --ignore=../../tests/test_worktree.py --ignore=../../tests/test_workspace.py" + if [ -d ".venv" ]; then + # Use venv if it exists + if [ -f ".venv/bin/pytest" ]; then + PYTHONPATH=. .venv/bin/pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS + elif [ -f ".venv/Scripts/pytest.exe" ]; then + # Windows + PYTHONPATH=. .venv/Scripts/pytest.exe ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS + else + PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS + fi else PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS fi - else - PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS - fi + ) if [ $? -ne 0 ]; then echo "Python tests failed. Please fix failing tests before committing." exit 1 fi - cd ../.. echo "Backend checks passed!" fi @@ -159,40 +170,44 @@ fi # Check if there are staged files in apps/frontend if git diff --cached --name-only | grep -q "^apps/frontend/"; then echo "Frontend changes detected, running frontend checks..." - cd apps/frontend - - # Run lint-staged (handles staged .ts/.tsx files) - npm exec lint-staged - if [ $? -ne 0 ]; then - echo "lint-staged failed. Please fix linting errors before committing." - exit 1 - fi + # Use subshell to isolate directory changes and prevent worktree corruption + ( + cd apps/frontend + + # Run lint-staged (handles staged .ts/.tsx files) + npm exec lint-staged + if [ $? -ne 0 ]; then + echo "lint-staged failed. Please fix linting errors before committing." + exit 1 + fi - # Run TypeScript type check - echo "Running type check..." - npm run typecheck - if [ $? -ne 0 ]; then - echo "Type check failed. Please fix TypeScript errors before committing." - exit 1 - fi + # Run TypeScript type check + echo "Running type check..." + npm run typecheck + if [ $? -ne 0 ]; then + echo "Type check failed. Please fix TypeScript errors before committing." + exit 1 + fi - # Run linting - echo "Running lint..." - npm run lint - if [ $? -ne 0 ]; then - echo "Lint failed. Run 'npm run lint:fix' to auto-fix issues." - exit 1 - fi + # Run linting + echo "Running lint..." + npm run lint + if [ $? -ne 0 ]; then + echo "Lint failed. Run 'npm run lint:fix' to auto-fix issues." + exit 1 + fi - # Check for vulnerabilities (only high severity) - echo "Checking for vulnerabilities..." - npm audit --audit-level=high + # Check for vulnerabilities (only high severity) + echo "Checking for vulnerabilities..." + npm audit --audit-level=high + if [ $? -ne 0 ]; then + echo "High severity vulnerabilities found. Run 'npm audit fix' to resolve." + exit 1 + fi + ) if [ $? -ne 0 ]; then - echo "High severity vulnerabilities found. Run 'npm audit fix' to resolve." exit 1 fi - - cd ../.. echo "Frontend checks passed!" fi diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f67b77c813..0f996bccc2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,6 @@ repos: # Version sync - propagate root package.json version to all files + # NOTE: Skip in worktrees - version sync modifies root files which don't exist in worktree - repo: local hooks: - id: version-sync @@ -8,6 +9,12 @@ repos: args: - -c - | + # Skip in worktrees - .git is a file pointing to main repo, not a directory + # Version sync modifies root-level files that may not exist in worktree context + if [ -f ".git" ]; then + echo "Skipping version-sync in worktree (root files not accessible)" + exit 0 + fi VERSION=$(node -p "require('./package.json').version") if [ -n "$VERSION" ]; then @@ -81,6 +88,7 @@ repos: # Python tests (apps/backend/) - skip slow/integration tests for pre-commit speed # Tests to skip: graphiti (external deps), merge_file_tracker/service_orchestrator/worktree/workspace (Windows path/git issues) + # NOTE: Skip this hook in worktrees (where .git is a file, not a directory) - repo: local hooks: - id: pytest @@ -89,6 +97,12 @@ repos: args: - -c - | + # Skip in worktrees - .git is a file pointing to main repo, not a directory + # This prevents path resolution issues with ../../tests/ in worktree context + if [ -f ".git" ]; then + echo "Skipping pytest in worktree (path resolution would fail)" + exit 0 + fi cd apps/backend if [ -f ".venv/bin/pytest" ]; then PYTEST_CMD=".venv/bin/pytest" @@ -113,18 +127,37 @@ repos: pass_filenames: false # Frontend linting (apps/frontend/) + # NOTE: These hooks check for worktree context to avoid npm/node_modules issues - repo: local hooks: - id: eslint name: ESLint - entry: bash -c 'cd apps/frontend && npm run lint' + entry: bash + args: + - -c + - | + # Skip in worktrees if node_modules doesn't exist (dependencies not installed) + if [ -f ".git" ] && [ ! -d "apps/frontend/node_modules" ]; then + echo "Skipping ESLint in worktree (node_modules not found)" + exit 0 + fi + cd apps/frontend && npm run lint language: system files: ^apps/frontend/.*\.(ts|tsx|js|jsx)$ pass_filenames: false - id: typecheck name: TypeScript Check - entry: bash -c 'cd apps/frontend && npm run typecheck' + entry: bash + args: + - -c + - | + # Skip in worktrees if node_modules doesn't exist (dependencies not installed) + if [ -f ".git" ] && [ ! -d "apps/frontend/node_modules" ]; then + echo "Skipping TypeScript check in worktree (node_modules not found)" + exit 0 + fi + cd apps/frontend && npm run typecheck language: system files: ^apps/frontend/.*\.(ts|tsx)$ pass_filenames: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fb1a26e82..22c43eb8da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,283 @@ +## 2.7.2 - Stability & Performance Enhancements + +### ✨ New Features + +- Added refresh button to Kanban board for manually reloading tasks + +- Terminal dropdown with built-in and external options in task review + +- Centralized CLI tool path management with customizable settings + +- Files tab in task details panel for better file organization + +- Enhanced PR review page with filtering capabilities + +- GitLab integration support + +- Automated PR review with follow-up support and structured outputs + +- UI scale feature with 75-200% range for accessibility + +- Python 3.12 bundled with packaged Electron app + +- OpenRouter support as LLM/embedding provider + +- Internationalization (i18n) system for multi-language support + +- Flatpak packaging support for Linux + +- Path-aware AI merge resolution with device code streaming + +### 🛠️ Improvements + +- Improved terminal experience with persistent state when switching projects + +- Enhanced PR review with structured outputs and fork support + +- Better UX for display and scaling changes + +- Convert synchronous I/O to async operations in worktree handlers + +- Enhanced logs for commit linting stage + +- Remove top navigation bars for cleaner UI + +- Enhanced PR detail area visual design + +- Improved CLI tool detection with more language support + +- Added iOS/Swift project detection + +- Optimize performance by removing projectTabs from useEffect dependencies + +- Improved Python detection and version validation for compatibility + +### 🐛 Bug Fixes + +- Fixed CI Python setup and PR status gate checks + +- Fixed cross-platform CLI path detection and clearing in settings + +- Preserve original task description after spec creation + +- Fixed learning loop to retrieve patterns and gotchas from memory + +- Resolved frontend lag and updated dependencies + +- Fixed Content-Security-Policy to allow external HTTPS images + +- Fixed PR review isolation by using temporary worktree + +- Fixed Homebrew Python detection to prefer versioned Python over system python3 + +- Added support for Bun 1.2.0+ lock file format detection + +- Fixed infinite re-render loop in task selection + +- Fixed infinite loop in task detail merge preview loading + +- Resolved Windows EINVAL error when opening worktree in VS Code + +- Fixed fallback to prevent tasks stuck in ai_review status + +- Fixed SDK permissions to include spec_dir + +- Added --base-branch argument support to spec_runner + +- Allow Windows to run CC PR Reviewer + +- Fixed model selection to respect task_metadata.json + +- Improved GitHub PR review by passing repo parameter explicitly + +- Fixed electron-log imports with .js extension + +- Fixed Swift detection order in project analyzer + +- Prevent TaskEditDialog from unmounting when opened + +- Fixed subprocess handling for Python paths with spaces + +- Fixed file system race conditions and unused variables in security scanning + +- Resolved Python detection and backend packaging issues + +- Fixed version-specific links in README and pre-commit hooks + +- Fixed task status persistence reverting on refresh + +- Proper semver comparison for pre-release versions + +- Use virtual environment Python for all services to fix dotenv errors + +- Fixed explicit Windows System32 tar path for builds + +- Added augmented PATH environment to all GitHub CLI calls + +- Use PowerShell for tar extraction on Windows + +- Added --force-local flag to tar on Windows + +- Stop tracking spec files in git + +- Fixed GitHub API calls with explicit GET method for comment fetches + +- Support archiving tasks across all worktree locations + +- Validated backend source path before using it + +- Resolved spawn Python ENOENT error on Linux + +- Fixed CodeQL alerts for uncontrolled command line + +- Resolved GitHub follow-up review API issues + +- Fixed relative path normalization to POSIX format + +- Accepted bug_fix workflow_type alias during planning + +- Added global spec numbering lock to prevent collisions + +- Fixed ideation status sync + +- Stopped running process when task status changes away from in_progress + +- Removed legacy path from auto-claude source detection + +- Resolved Python environment race condition + +--- + +## What's Changed + +- fix(ci): add Python setup to beta-release and fix PR status gate checks (#565) by @Andy in c2148bb9 +- fix: detect and clear cross-platform CLI paths in settings (#535) by @Andy in 29e45505 +- fix(ui): preserve original task description after spec creation (#536) by @Andy in 7990dcb4 +- fix(memory): fix learning loop to retrieve patterns and gotchas (#530) by @Andy in f58c2578 +- fix: resolve frontend lag and update dependencies (#526) by @Andy in 30f7951a +- feat(kanban): add refresh button to manually reload tasks (#548) by @Adryan Serage in 252242f9 +- fix(csp): allow external HTTPS images in Content-Security-Policy (#549) by @Michael Ludlow in 3db02c5d +- fix(pr-review): use temporary worktree for PR review isolation (#532) by @Andy in 344ec65e +- fix: prefer versioned Homebrew Python over system python3 (#494) by @Navid in 8d58dd6f +- fix(detection): support bun.lock text format for Bun 1.2.0+ (#525) by @Andy in 4da8cd66 +- chore: bump version to 2.7.2-beta.12 (#460) by @Andy in 8e5c11ac +- Fix/windows issues (#471) by @Andy in 72106109 +- fix(ci): add Rust toolchain for Intel Mac builds (#459) by @Andy in 52a4fcc6 +- fix: create spec.md during roadmap-to-task conversion (#446) by @Mulaveesala Pranaveswar in fb6b7fc6 +- fix(pr-review): treat LOW-only findings as ready to merge (#455) by @Andy in 0f9c5b84 +- Fix/2.7.2 beta12 (#424) by @Andy in 5d8ede23 +- feat: remove top bars (#386) by @Vinícius Santos in da31b687 +- fix: prevent infinite re-render loop in task selection useEffect (#442) by @Abe Diaz in 2effa535 +- fix: accept Python 3.12+ in install-backend.js (#443) by @Abe Diaz in c15bb311 +- fix: infinite loop in useTaskDetail merge preview loading (#444) by @Abe Diaz in 203a970a +- fix(windows): resolve EINVAL error when opening worktree in VS Code (#434) by @Vinícius Santos in 3c0708b7 +- feat(frontend): Add Files tab to task details panel (#430) by @Mitsu in 666794b5 +- refactor: remove deprecated TaskDetailPanel component (#432) by @Mitsu in ac8dfcac +- fix(ui): add fallback to prevent tasks stuck in ai_review status (#397) by @Michael Ludlow in 798ca79d +- feat: Enhance the look of the PR Detail area (#427) by @Alex in bdb01549 +- ci: remove conventional commits PR title validation workflow by @AndyMik90 in 515b73b5 +- fix(client): add spec_dir to SDK permissions (#429) by @Mitsu in 88c76059 +- fix(spec_runner): add --base-branch argument support (#428) by @Mitsu in 62a75515 +- feat: enhance pr review page to include PRs filters (#423) by @Alex in 717fba04 +- feat: add gitlab integration (#254) by @Mitsu in 0a571d3a +- fix: Allow windows to run CC PR Reviewer (#406) by @Alex in 2f662469 +- fix(model): respect task_metadata.json model selection (#415) by @Andy in e7e6b521 +- feat(build): add Flatpak packaging support for Linux (#404) by @Mitsu in 230de5fc +- fix(github): pass repo parameter to GHClient for explicit PR resolution (#413) by @Andy in 4bdf7a0c +- chore(ci): remove redundant CLA GitHub Action workflow by @AndyMik90 in a39ea49d +- fix(frontend): add .js extension to electron-log/main imports by @AndyMik90 in 9aef0dd0 +- fix: 2.7.2 bug fixes and improvements (#388) by @Andy in 05131217 +- fix(analyzer): move Swift detection before Ruby detection (#401) by @Michael Ludlow in 321c9712 +- fix(ui): prevent TaskEditDialog from unmounting when opened (#395) by @Michael Ludlow in 98b12ed8 +- fix: improve CLI tool detection and add Claude CLI path settings (#393) by @Joe in aaa83131 +- feat(analyzer): add iOS/Swift project detection (#389) by @Michael Ludlow in 68548e33 +- fix(github): improve PR review with structured outputs and fork support (#363) by @Andy in 7751588e +- fix(ideation): update progress calculation to include just-completed ideation type (#381) by @Illia Filippov in 8b4ce58c +- Fixes failing spec - "gh CLI Check Handler - should return installed: true when gh CLI is found" (#370) by @Ian in bc220645 +- fix: Memory Status card respects configured embedding provider (#336) (#373) by @Michael Ludlow in db0cbea3 +- fix: fixed version-specific links in readme and pre-commit hook that updates them (#378) by @Ian in 0ca2e3f6 +- docs: add security research documentation (#361) by @Brian in 2d3b7fb4 +- fix/Improving UX for Display/Scaling Changes (#332) by @Kevin Rajan in 9bbdef09 +- fix(perf): remove projectTabs from useEffect deps to fix re-render loop (#362) by @Michael Ludlow in 753dc8bb +- fix(security): invalidate profile cache when file is created/modified (#355) by @Michael Ludlow in 20f20fa3 +- fix(subprocess): handle Python paths with spaces (#352) by @Michael Ludlow in eabe7c7d +- fix: Resolve pre-commit hook failures with version sync, pytest path, ruff version, and broken quality-dco workflow (#334) by @Ian in 1fa7a9c7 +- fix(terminal): preserve terminal state when switching projects (#358) by @Andy in 7881b2d1 +- fix(analyzer): add C#/Java/Swift/Kotlin project files to security hash (#351) by @Michael Ludlow in 4e71361b +- fix: make backend tests pass on Windows (#282) by @Oluwatosin Oyeladun in 4dcc5afa +- fix(ui): close parent modal when Edit dialog opens (#354) by @Michael Ludlow in e9782db0 +- chore: bump version to 2.7.2-beta.10 by @AndyMik90 in 40d04d7c +- feat: add terminal dropdown with inbuilt and external options in task review (#347) by @JoshuaRileyDev in fef07c95 +- refactor: remove deprecated code across backend and frontend (#348) by @Mitsu in 9d43abed +- feat: centralize CLI tool path management (#341) by @HSSAINI Saad in d51f4562 +- refactor(components): remove deprecated TaskDetailPanel re-export (#344) by @Mitsu in 787667e9 +- chore: Refactor/kanban realtime status sync (#249) by @souky-byte in 9734b70b +- refactor(settings): remove deprecated ProjectSettings modal and hooks (#343) by @Mitsu in fec6b9f3 +- perf: convert synchronous I/O to async operations in worktree handlers (#337) by @JoshuaRileyDev in d3a63b09 +- feat: bump version (#329) by @Alex in 50e3111a +- fix(ci): remove version bump to fix branch protection conflict (#325) by @Michael Ludlow in 8a80b1d5 +- fix(tasks): sync status to worktree implementation plan to prevent reset (#243) (#323) by @Alex in cb6b2165 +- fix(ci): add auto-updater manifest files and version auto-update (#317) by @Michael Ludlow in 661e47c3 +- fix(project): fix task status persistence reverting on refresh (#246) (#318) by @Michael Ludlow in e80ef79d +- fix(updater): proper semver comparison for pre-release versions (#313) by @Michael Ludlow in e1b0f743 +- fix(python): use venv Python for all services to fix dotenv errors (#311) by @Alex in 92c6f278 +- chore(ci): cancel in-progress runs (#302) by @Oluwatosin Oyeladun in 1c142273 +- fix(build): use explicit Windows System32 tar path (#308) by @Andy in c0a02a45 +- fix(github): add augmented PATH env to all gh CLI calls by @AndyMik90 in 086429cb +- fix(build): use PowerShell for tar extraction on Windows by @AndyMik90 in d9fb8f29 +- fix(build): add --force-local flag to tar on Windows (#303) by @Andy in d0b0b3df +- fix: stop tracking spec files in git (#295) by @Andy in 937a60f8 +- Fix/2.7.2 fixes (#300) by @Andy in 7a51cbd5 +- feat(merge,oauth): add path-aware AI merge resolution and device code streaming (#296) by @Andy in 26beefe3 +- feat: enhance the logs for the commit linting stage (#293) by @Alex in 8416f307 +- fix(github): add explicit GET method to gh api comment fetches (#294) by @Andy in 217249c8 +- fix(frontend): support archiving tasks across all worktree locations (#286) by @Andy in 8bb3df91 +- Potential fix for code scanning alert no. 224: Uncontrolled command line (#285) by @Andy in 5106c6e9 +- fix(frontend): validate backend source path before using it (#287) by @Andy in 3ff61274 +- feat(python): bundle Python 3.12 with packaged Electron app (#284) by @Andy in 7f19c2e1 +- fix: resolve spawn python ENOENT error on Linux by using getAugmentedEnv() (#281) by @Todd W. Bucy in d98e2830 +- fix(ci): add write permissions to beta-release update-version job by @AndyMik90 in 0b874d4b +- chore(deps): bump @xterm/xterm from 5.5.0 to 6.0.0 in /apps/frontend (#270) by @dependabot[bot] in 50dd1078 +- fix(github): resolve follow-up review API issues by @AndyMik90 in f1cc5a09 +- fix(security): resolve CodeQL file system race conditions and unused variables (#277) by @Andy in b005fa5c +- fix(ci): use correct electron-builder arch flags (#278) by @Andy in d79f2da4 +- chore(deps): bump jsdom from 26.1.0 to 27.3.0 in /apps/frontend (#268) by @dependabot[bot] in 5ac566e2 +- chore(deps): bump typescript-eslint in /apps/frontend (#269) by @dependabot[bot] in f49d4817 +- fix(ci): use develop branch for dry-run builds in beta-release workflow (#276) by @Andy in 1e1d7d9b +- fix: accept bug_fix workflow_type alias during planning (#240) by @Daniel Frey in e74a3dff +- fix(paths): normalize relative paths to posix (#239) by @Daniel Frey in 6ac8250b +- chore(deps): bump @electron/rebuild in /apps/frontend (#271) by @dependabot[bot] in a2cee694 +- chore(deps): bump vitest from 4.0.15 to 4.0.16 in /apps/frontend (#272) by @dependabot[bot] in d4cad80a +- feat(github): add automated PR review with follow-up support (#252) by @Andy in 596e9513 +- ci: implement enterprise-grade PR quality gates and security scanning (#266) by @Alex in d42041c5 +- fix: update path resolution for ollama_model_detector.py in memory handlers (#263) by @delyethan in a3f87540 +- feat: add i18n internationalization system (#248) by @Mitsu in f8438112 +- Revert "Feat/Auto Fix Github issues and do extensive AI PR reviews (#250)" (#251) by @Andy in 5e8c5308 +- Feat/Auto Fix Github issues and do extensive AI PR reviews (#250) by @Andy in 348de6df +- fix: resolve Python detection and backend packaging issues (#241) by @HSSAINI Saad in 0f7d6e05 +- fix: add future annotations import to discovery.py (#229) by @Joris Slagter in 5ccdb6ab +- Fix/ideation status sync (#212) by @souky-byte in 6ec8549f +- fix(core): add global spec numbering lock to prevent collisions (#209) by @Andy in 53527293 +- feat: Add OpenRouter as LLM/embedding provider (#162) by @Fernando Possebon in 02bef954 +- fix: Add Python 3.10+ version validation and GitHub Actions Python setup (#180 #167) (#208) by @Fernando Possebon in f168bdc3 +- fix(ci): correct welcome workflow PR message (#206) by @Andy in e3eec68a +- Feat/beta release (#193) by @Andy in 407a0bee +- feat/beta-release (#190) by @Andy in 8f766ad1 +- fix/PRs from old main setup to apps structure (#185) by @Andy in ced2ad47 +- fix: hide status badge when execution phase badge is showing (#154) by @Andy in 05f5d303 +- feat: Add UI scale feature with 75-200% range (#125) by @Enes Cingöz in 6951251b +- fix(task): stop running process when task status changes away from in_progress by @AndyMik90 in 30e7536b +- Fix/linear 400 error by @Andy in 220faf0f +- fix: remove legacy path from auto-claude source detection (#148) by @Joris Slagter in f96c6301 +- fix: resolve Python environment race condition (#142) by @Joris Slagter in ebd8340d +- Feat: Ollama download progress tracking with new apps structure (#141) by @rayBlock in df779530 +- Feature/apps restructure v2.7.2 (#138) by @Andy in 0adaddac +- docs: Add Git Flow branching strategy to CONTRIBUTING.md by @AndyMik90 in 91f7051d + +## Thanks to all contributors + +@Andy, @Adryan Serage, @Michael Ludlow, @Navid, @Mulaveesala Pranaveswar, @Vinícius Santos, @Abe Diaz, @Mitsu, @Alex, @AndyMik90, @Joe, @Illia Filippov, @Ian, @Brian, @Kevin Rajan, @Oluwatosin Oyeladun, @JoshuaRileyDev, @HSSAINI Saad, @souky-byte, @Todd W. Bucy, @dependabot[bot], @Daniel Frey, @delyethan, @Joris Slagter, @Fernando Possebon, @Enes Cingöz, @rayBlock + ## 2.7.1 - Build Pipeline Enhancements ### 🛠️ Improvements diff --git a/INVESTIGATION.md b/INVESTIGATION.md new file mode 100644 index 0000000000..2daae34b7b --- /dev/null +++ b/INVESTIGATION.md @@ -0,0 +1,318 @@ +# Root Cause Investigation: Task Workflow Halts After Planning Stage + +## Investigation Summary + +After adding comprehensive logging to the task loading and plan update pipeline, I've analyzed the data flow from backend to frontend to identify why subtasks fail to display after spec completion. + +## Data Flow Analysis + +### Current Architecture + +``` +Backend (Python) + ↓ +Creates implementation_plan.json + ↓ +Emits IPC event: 'task:progress' with plan data + ↓ +Frontend (Electron Renderer) + ↓ +useIpc.ts: onTaskProgress handler (batched) + ↓ +task-store.ts: updateTaskFromPlan(taskId, plan) + ↓ +Creates subtasks from plan.phases.flatMap(phase => phase.subtasks) + ↓ +UI: TaskSubtasks.tsx renders subtasks +``` + +### Critical Code Paths + +**1. Plan Update Handler** (`apps/frontend/src/renderer/hooks/useIpc.ts:131-135`) +```typescript +window.electronAPI.onTaskProgress( + (taskId: string, plan: ImplementationPlan) => { + queueUpdate(taskId, { plan }); + } +); +``` + +**2. Subtask Creation** (`apps/frontend/src/renderer/stores/task-store.ts:124-133`) +```typescript +const subtasks: Subtask[] = plan.phases.flatMap((phase) => + phase.subtasks.map((subtask) => ({ + id: subtask.id, + title: subtask.description, + description: subtask.description, + status: subtask.status, + files: [], + verification: subtask.verification as Subtask['verification'] + })) +); +``` + +**3. Initial Task Loading** (`apps/frontend/src/main/project-store.ts:461-470`) +```typescript +const subtasks = plan?.phases?.flatMap((phase) => { + const items = phase.subtasks || (phase as { chunks?: PlanSubtask[] }).chunks || []; + return items.map((subtask) => ({ + id: subtask.id, + title: subtask.description, + description: subtask.description, + status: subtask.status, + files: [] + })); +}) || []; +``` + +## Root Cause Identification + +### Primary Root Cause: Early Plan Update Event with Empty Phases + +**What's Happening:** + +1. **Backend creates `implementation_plan.json` in stages:** + - First writes the file with minimal structure: `{ "feature": "...", "phases": [] }` + - Then adds phases and subtasks incrementally + - Emits IPC event each time the plan is updated + +2. **Frontend receives the FIRST plan update event:** + - Plan has `feature` and basic metadata + - **But `phases` array is EMPTY: `[]`** + - `updateTaskFromPlan` is called with this incomplete plan + - Subtasks are created as empty array: `plan.phases.flatMap(...)` → `[]` + +3. **Later plan updates with full subtask data are ignored:** + - When backend writes the complete plan with subtasks + - Another IPC event is emitted + - But due to race conditions or event handling issues, this update doesn't reach the frontend + - Or it does reach but the task UI doesn't refresh + +**Evidence from Code:** + +Looking at `updateTaskFromPlan` (task-store.ts:106-190): +- Line 108-114: Logs show `phases: plan.phases?.length || 0` +- Line 112: If plan has 0 phases, `totalSubtasks` will be 0 +- Line 124-133: `plan.phases.flatMap(...)` on empty array creates `subtasks = []` +- **No validation to check if plan is complete before updating state** + +**Why "!" Indicators Appear:** + +The "!" indicators likely come from the UI attempting to render subtasks when: +- Subtask count shows as 18 (from later plan update metadata) +- But `task.subtasks` array is actually empty `[]` (from early plan update) +- This mismatch causes the UI to show warning indicators + +### Secondary Contributing Factors + +**A. No Plan Validation Before State Update** + +Current code in `updateTaskFromPlan` immediately creates subtasks from whatever plan data it receives: +```typescript +const subtasks: Subtask[] = plan.phases.flatMap((phase) => + phase.subtasks.map((subtask) => ({ ... })) +); +``` + +**Problem:** No check if plan is "ready" or "complete" before updating state. + +**B. Missing Reload Trigger After Spec Completion** + +When spec creation completes and the full plan is written: +- The IPC event might not fire again +- Or the event fires but the batching mechanism drops it +- Frontend state remains stuck with empty subtasks + +**C. Race Condition in Batch Update Queue** + +In `useIpc.ts:92-112`, the batching mechanism queues updates: +```typescript +function queueUpdate(taskId: string, update: BatchedUpdate): void { + const existing = batchQueue.get(taskId) || {}; + batchQueue.set(taskId, { ...existing, ...update }); +} +``` + +**Problem:** If two plan updates arrive within 16ms: +- First update has empty phases: `{ plan: { phases: [] } }` +- Second update has full phases: `{ plan: { phases: [...18 subtasks...] } }` +- Second update **overwrites** first in the queue +- But if order gets reversed, empty plan overwrites full plan + +## Log Evidence to Look For + +To confirm this root cause, check console logs for: + +### 1. Plan Loading Sequence +``` +[updateTaskFromPlan] called with plan: + taskId: "xxx" + feature: "..." + phases: 0 ← SMOKING GUN: phases array is empty + totalSubtasks: 0 ← No subtasks +``` + +If you see `phases: 0` followed later by no update with `phases: 3` (or more), the early empty plan is stuck in state. + +### 2. Multiple Plan Updates +``` +[updateTaskFromPlan] called with plan: + phases: 0 + totalSubtasks: 0 + +[updateTaskFromPlan] called with plan: ← This might never appear + phases: 3 + totalSubtasks: 18 +``` + +If second log never appears, the plan update event isn't firing after spec completion. + +### 3. Project Store Loading +``` +[ProjectStore] Loading implementation_plan.json for spec: xxx +[ProjectStore] Loaded plan for xxx: + phaseCount: 0 ← Empty plan loaded from disk + subtaskCount: 0 +``` + +If plan file on disk has empty phases, the issue is in backend plan writing. + +### 4. Plan File Utils +``` +[plan-file-utils] Reading implementation_plan.json to update status +[plan-file-utils] Successfully persisted status ← Plan exists but might be incomplete +``` + +Check if plan file reads/writes are happening during spec creation. + +## Proposed Fix Approach + +### Fix 1: Add Plan Completeness Validation (Immediate Fix) + +**File:** `apps/frontend/src/renderer/stores/task-store.ts` + +**Change:** Only update subtasks if plan has valid phases and subtasks: + +```typescript +updateTaskFromPlan: (taskId, plan) => + set((state) => { + console.log('[updateTaskFromPlan] called with plan:', { ... }); + + const index = findTaskIndex(state.tasks, taskId); + if (index === -1) { + console.log('[updateTaskFromPlan] Task not found:', taskId); + return state; + } + + // VALIDATION: Don't update if plan is incomplete + if (!plan.phases || plan.phases.length === 0) { + console.warn('[updateTaskFromPlan] Plan has no phases, skipping update:', taskId); + return state; // Keep existing state, don't overwrite with empty data + } + + const totalSubtasks = plan.phases.reduce((acc, p) => acc + (p.subtasks?.length || 0), 0); + if (totalSubtasks === 0) { + console.warn('[updateTaskFromPlan] Plan has no subtasks, skipping update:', taskId); + return state; // Keep existing state + } + + // ... rest of existing code to create subtasks ... + }) +``` + +### Fix 2: Trigger Reload After Spec Completion (Comprehensive Fix) + +**File:** `apps/frontend/src/renderer/hooks/useIpc.ts` + +**Change:** Add explicit "spec completed" event handler that reloads the task: + +```typescript +// Add new IPC event listener +const cleanupSpecComplete = window.electronAPI.onSpecComplete( + async (taskId: string) => { + console.log('[IPC] Spec completed for task:', taskId); + // Force reload the task from disk to get the complete plan + const task = useTaskStore.getState().tasks.find(t => t.id === taskId); + if (task) { + // Reload plan from file + const result = await window.electronAPI.getTaskPlan(task.projectId, taskId); + if (result.success && result.data) { + updateTaskFromPlan(taskId, result.data); + } + } + } +); +``` + +### Fix 3: Prevent Plan Overwrite in Batch Queue (Race Condition Fix) + +**File:** `apps/frontend/src/renderer/hooks/useIpc.ts` + +**Change:** Don't overwrite plan if incoming plan has fewer subtasks than existing: + +```typescript +function queueUpdate(taskId: string, update: BatchedUpdate): void { + const existing = batchQueue.get(taskId) || {}; + + // For plan updates, only accept if it has MORE data than existing + let mergedPlan = existing.plan; + if (update.plan) { + const existingSubtasks = existing.plan?.phases?.flatMap(p => p.subtasks || []).length || 0; + const newSubtasks = update.plan.phases?.flatMap(p => p.subtasks || []).length || 0; + + if (newSubtasks >= existingSubtasks) { + mergedPlan = update.plan; // Accept new plan + } else { + console.warn('[IPC Batch] Rejecting plan update with fewer subtasks:', + { taskId, existing: existingSubtasks, new: newSubtasks }); + // Keep existing plan, don't overwrite with less complete data + } + } + + // ... rest of existing code ... +} +``` + +## Testing the Fix + +### Manual Verification Steps + +1. **Create a new task** and move it to "In Progress" +2. **Watch the console logs** for: + ``` + [updateTaskFromPlan] called with plan: { phases: 0, totalSubtasks: 0 } + ``` +3. **Wait for spec to complete** (planning phase finishes) +4. **Check console logs** for: + ``` + [updateTaskFromPlan] called with plan: { phases: 3, totalSubtasks: 18 } + ``` +5. **Expand subtask list** in task card +6. **Verify:** Subtasks display with full details, no "!" indicators + +### Expected Outcome After Fix + +- ✅ Empty/incomplete plan updates are ignored +- ✅ Only complete plans with phases and subtasks update the UI +- ✅ Subtasks display with id, description, and status +- ✅ No "!" warning indicators +- ✅ Subtask count shows "0/18 completed" (not "0/0") +- ✅ Plan pulsing animation stops when spec completes +- ✅ Resume functionality works without infinite loop + +## Next Steps + +1. ✅ **This Investigation** - Root cause identified (COMPLETE) +2. 🔄 **Subtask 2-1** - Implement Fix 1 (validation in updateTaskFromPlan) +3. 🔄 **Subtask 2-2** - Add data validation before subtask state updates +4. 🔄 **Subtask 2-3** - Fix pulsing animation condition +5. 🔄 **Subtask 2-4** - Fix resume logic to reload plan if subtasks missing +6. 🔄 **Phase 3** - Add comprehensive tests to prevent regressions + +## Conclusion + +**Root Cause:** Frontend receives and accepts incomplete plan data (empty `phases` array) during the spec creation process, before subtasks are written. This overwrites any existing subtask data and leaves the UI in a stuck state with no subtasks to display. + +**Fix Priority:** Implement Fix 1 (validation) immediately to prevent incomplete plans from updating state. This is a minimal, low-risk change that will resolve the core issue. + +**Long-term Solution:** Add explicit event handling for spec completion (Fix 2) and improve batch queue logic (Fix 3) to make the system more robust against race conditions and out-of-order updates. diff --git a/README.md b/README.md index d22c5216a2..b5c6f60cef 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,9 @@ ![Auto Claude Kanban Board](.github/assets/Auto-Claude-Kanban.png) - -[![Version](https://img.shields.io/badge/version-2.7.2-blue?style=flat-square)](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.2) - [![License](https://img.shields.io/badge/license-AGPL--3.0-green?style=flat-square)](./agpl-3.0.txt) [![Discord](https://img.shields.io/badge/Discord-Join%20Community-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/KCXaPBr4Dj) +[![YouTube](https://img.shields.io/badge/YouTube-Subscribe-FF0000?style=flat-square&logo=youtube&logoColor=white)](https://www.youtube.com/@AndreMikalsen) [![CI](https://img.shields.io/github/actions/workflow/status/AndyMik90/Auto-Claude/ci.yml?branch=main&style=flat-square&label=CI)](https://github.com/AndyMik90/Auto-Claude/actions) --- @@ -24,11 +22,11 @@ | Platform | Download | |----------|----------| -| **Windows** | [Auto-Claude-2.7.1-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-win32-x64.exe) | -| **macOS (Apple Silicon)** | [Auto-Claude-2.7.1-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-darwin-arm64.dmg) | -| **macOS (Intel)** | [Auto-Claude-2.7.1-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-darwin-x64.dmg) | -| **Linux** | [Auto-Claude-2.7.1-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-linux-x86_64.AppImage) | -| **Linux (Debian)** | [Auto-Claude-2.7.1-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-linux-amd64.deb) | +| **Windows** | [Auto-Claude-2.7.2-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-win32-x64.exe) | +| **macOS (Apple Silicon)** | [Auto-Claude-2.7.2-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-darwin-arm64.dmg) | +| **macOS (Intel)** | [Auto-Claude-2.7.2-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-darwin-x64.dmg) | +| **Linux** | [Auto-Claude-2.7.2-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-linux-x86_64.AppImage) | +| **Linux (Debian)** | [Auto-Claude-2.7.2-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-linux-amd64.deb) | ### Beta Release @@ -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:** - -
-Windows - -```bash -winget install Python.Python.3.12 -winget install OpenJS.NodeJS.LTS -``` - -
- -
-macOS - -```bash -brew install python@3.12 node@24 -``` - -
- -
-Linux (Ubuntu/Debian) - -```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 -``` - -
- -
-Linux (Fedora) - -```bash -sudo dnf install python3.12 nodejs npm -``` - -
- -See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed development setup. - -### Building Flatpak - -To build the Flatpak package, you need additional dependencies: - -```bash -# Fedora/RHEL -sudo dnf install flatpak-builder - -# Ubuntu/Debian -sudo apt install flatpak-builder - -# Install required Flatpak runtimes -flatpak install flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08 -flatpak install flathub org.electronjs.Electron2.BaseApp//25.08 - -# Build the Flatpak -cd apps/frontend -npm run package:flatpak -``` - -The Flatpak will be created in `apps/frontend/dist/`. +For Linux-specific builds (Flatpak, AppImage), see [guides/linux.md](guides/linux.md). --- @@ -284,7 +179,7 @@ All releases are: | `npm run package:mac` | Package for macOS | | `npm run package:win` | Package for Windows | | `npm run package:linux` | Package for Linux | -| `npm run package:flatpak` | Package as Flatpak | +| `npm run package:flatpak` | Package as Flatpak (see [guides/linux.md](guides/linux.md)) | | `npm run lint` | Run linter | | `npm test` | Run frontend tests | | `npm run test:backend` | Run backend tests | @@ -316,3 +211,11 @@ We welcome contributions! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for: Auto Claude is free to use. If you modify and distribute it, or run it as a service, your code must also be open source under AGPL-3.0. Commercial licensing available for closed-source use cases. + +--- + +## Star History + +[![GitHub Repo stars](https://img.shields.io/github/stars/AndyMik90/Auto-Claude?style=social)](https://github.com/AndyMik90/Auto-Claude/stargazers) + +[![Star History Chart](https://api.star-history.com/svg?repos=AndyMik90/Auto-Claude&type=Date)](https://star-history.com/#AndyMik90/Auto-Claude&Date) diff --git a/RELEASE.md b/RELEASE.md index d7f6eb10dd..21d0e6b53d 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -69,9 +69,38 @@ This will: - Update `apps/frontend/package.json` - Update `package.json` (root) - Update `apps/backend/__init__.py` +- Check if `CHANGELOG.md` has an entry for the new version (warns if missing) - Create a commit with message `chore: bump version to X.Y.Z` -### Step 2: Push and Create PR +### Step 2: Update CHANGELOG.md (REQUIRED) + +**IMPORTANT: The release will fail if CHANGELOG.md doesn't have an entry for the new version.** + +Add release notes to `CHANGELOG.md` at the top of the file: + +```markdown +## 2.8.0 - Your Release Title + +### ✨ New Features +- Feature description + +### 🛠️ Improvements +- Improvement description + +### 🐛 Bug Fixes +- Fix description + +--- +``` + +Then amend the version bump commit: + +```bash +git add CHANGELOG.md +git commit --amend --no-edit +``` + +### Step 3: Push and Create PR ```bash # Push your branch @@ -81,24 +110,25 @@ git push origin your-branch gh pr create --base main --title "Release v2.8.0" ``` -### Step 3: Merge to Main +### Step 4: Merge to Main Once the PR is approved and merged to `main`, GitHub Actions will automatically: 1. **Detect the version bump** (`prepare-release.yml`) -2. **Create a git tag** (e.g., `v2.8.0`) -3. **Trigger the release workflow** (`release.yml`) -4. **Build binaries** for all platforms: +2. **Validate CHANGELOG.md** has an entry for the new version (FAILS if missing) +3. **Extract release notes** from CHANGELOG.md +4. **Create a git tag** (e.g., `v2.8.0`) +5. **Trigger the release workflow** (`release.yml`) +6. **Build binaries** for all platforms: - macOS Intel (x64) - code signed & notarized - macOS Apple Silicon (arm64) - code signed & notarized - Windows (NSIS installer) - code signed - Linux (AppImage + .deb) -5. **Generate changelog** from merged PRs (using release-drafter) -6. **Scan binaries** with VirusTotal -7. **Create GitHub release** with all artifacts -8. **Update README** with new version badge and download links +7. **Scan binaries** with VirusTotal +8. **Create GitHub release** with release notes from CHANGELOG.md +9. **Update README** with new version badge and download links -### Step 4: Verify +### Step 5: Verify After merging, check: - [GitHub Actions](https://github.com/AndyMik90/Auto-Claude/actions) - ensure all workflows pass @@ -113,28 +143,49 @@ We follow [Semantic Versioning](https://semver.org/): - **MINOR** (0.X.0): New features, backwards compatible - **PATCH** (0.0.X): Bug fixes, backwards compatible -## Changelog Generation +## Changelog Management + +Release notes are managed in `CHANGELOG.md` and used for GitHub releases. + +### Changelog Format -Changelogs are automatically generated from merged PRs using [Release Drafter](https://github.com/release-drafter/release-drafter). +Each version entry in `CHANGELOG.md` should follow this format: -### PR Labels for Changelog Categories +```markdown +## X.Y.Z - Release Title -| Label | Category | -|-------|----------| -| `feature`, `enhancement` | New Features | -| `bug`, `fix` | Bug Fixes | -| `improvement`, `refactor` | Improvements | -| `documentation` | Documentation | -| (any other) | Other Changes | +### ✨ New Features +- Feature description with context -**Tip:** Add appropriate labels to your PRs for better changelog organization. +### 🛠️ Improvements +- Improvement description + +### 🐛 Bug Fixes +- Fix description + +--- +``` + +### Changelog Validation + +The release workflow **validates** that `CHANGELOG.md` has an entry for the version being released: + +- If the entry is **missing**, the release is **blocked** with a clear error message +- If the entry **exists**, its content is used for the GitHub release notes + +### Writing Good Release Notes + +- **Be specific**: Instead of "Fixed bug", write "Fixed crash when opening large files" +- **Group by impact**: Features first, then improvements, then fixes +- **Credit contributors**: Mention contributors for significant changes +- **Link issues**: Reference GitHub issues where relevant (e.g., "Fixes #123") ## Workflows | Workflow | Trigger | Purpose | |----------|---------|---------| -| `prepare-release.yml` | Push to `main` | Detects version bump, creates tag | -| `release.yml` | Tag `v*` pushed | Builds binaries, creates release | +| `prepare-release.yml` | Push to `main` | Detects version bump, **validates CHANGELOG.md**, creates tag | +| `release.yml` | Tag `v*` pushed | Builds binaries, extracts changelog, creates release | | `validate-version.yml` | Tag `v*` pushed | Validates tag matches package.json | | `update-readme` (in release.yml) | After release | Updates README with new version | @@ -153,6 +204,22 @@ Changelogs are automatically generated from merged PRs using [Release Drafter](h git diff HEAD~1 --name-only | grep package.json ``` +### Release blocked: Missing changelog entry + +If you see "CHANGELOG VALIDATION FAILED" in the workflow: + +1. The `prepare-release.yml` workflow validated that `CHANGELOG.md` doesn't have an entry for the new version +2. **Fix**: Add an entry to `CHANGELOG.md` with the format `## X.Y.Z - Title` +3. Commit and push the changelog update +4. The workflow will automatically retry when the changes are pushed to `main` + +```bash +# Add changelog entry, then: +git add CHANGELOG.md +git commit -m "docs: add changelog for vX.Y.Z" +git push origin main +``` + ### Build failed after tag was created - The release won't be published if builds fail diff --git a/apps/backend/agents/README.md b/apps/backend/agents/README.md index 1cf2b2fb81..85253eae26 100644 --- a/apps/backend/agents/README.md +++ b/apps/backend/agents/README.md @@ -26,7 +26,7 @@ auto-claude/agents/ ### `utils.py` (3.6 KB) - Git operations: `get_latest_commit()`, `get_commit_count()` - Plan management: `load_implementation_plan()`, `find_subtask_in_plan()`, `find_phase_for_subtask()` -- Workspace sync: `sync_plan_to_source()` +- Workspace sync: `sync_spec_to_source()` ### `memory.py` (13 KB) - Dual-layer memory system (Graphiti primary, file-based fallback) @@ -73,7 +73,7 @@ from agents import ( # Utilities get_latest_commit, load_implementation_plan, - sync_plan_to_source, + sync_spec_to_source, ) ``` diff --git a/apps/backend/agents/__init__.py b/apps/backend/agents/__init__.py index 37dae174c4..4eed468607 100644 --- a/apps/backend/agents/__init__.py +++ b/apps/backend/agents/__init__.py @@ -14,6 +14,10 @@ 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 @@ "load_implementation_plan", "find_subtask_in_plan", "find_phase_for_subtask", - "sync_plan_to_source", + "sync_spec_to_source", # Constants "AUTO_CONTINUE_DELAY_SECONDS", "HUMAN_INTERVENTION_FILE", @@ -77,7 +81,7 @@ def __getattr__(name): "get_commit_count", "get_latest_commit", "load_implementation_plan", - "sync_plan_to_source", + "sync_spec_to_source", ): from .utils import ( find_phase_for_subtask, @@ -85,7 +89,7 @@ def __getattr__(name): get_commit_count, get_latest_commit, load_implementation_plan, - sync_plan_to_source, + sync_spec_to_source, ) return locals()[name] diff --git a/apps/backend/agents/coder.py b/apps/backend/agents/coder.py index 39d43b30a0..f0b7ff23dc 100644 --- a/apps/backend/agents/coder.py +++ b/apps/backend/agents/coder.py @@ -7,6 +7,7 @@ import asyncio import logging +import os from pathlib import Path from core.client import create_client @@ -37,6 +38,7 @@ ) from prompts import is_first_run from recovery import RecoveryManager +from security.constants import PROJECT_DIR_ENV_VAR from task_logger import ( LogPhase, get_task_logger, @@ -62,7 +64,7 @@ get_commit_count, get_latest_commit, load_implementation_plan, - sync_plan_to_source, + sync_spec_to_source, ) logger = logging.getLogger(__name__) @@ -90,6 +92,10 @@ async def run_autonomous_agent( verbose: Whether to show detailed output source_spec_dir: Original spec directory in main project (for syncing from worktree) """ + # Set environment variable for security hooks to find the correct project directory + # This is needed because os.getcwd() may return the wrong directory in worktree mode + os.environ[PROJECT_DIR_ENV_VAR] = str(project_dir.resolve()) + # Initialize recovery manager (handles memory persistence) recovery_manager = RecoveryManager(spec_dir, project_dir) @@ -130,6 +136,25 @@ async def run_autonomous_agent( # Track which phase we're in for logging current_log_phase = LogPhase.CODING is_planning_phase = False + planning_retry_context: str | None = None + planning_validation_failures = 0 + max_planning_validation_retries = 3 + + def _validate_and_fix_implementation_plan() -> tuple[bool, list[str]]: + from spec.validate_pkg import SpecValidator, auto_fix_plan + + spec_validator = SpecValidator(spec_dir) + result = spec_validator.validate_implementation_plan() + if result.valid: + return True, [] + + fixed = auto_fix_plan(spec_dir) + if fixed: + result = spec_validator.validate_implementation_plan() + if result.valid: + return True, [] + + return False, result.errors if first_run: print_status( @@ -217,8 +242,8 @@ async def run_autonomous_agent( print("To continue, run the script again without --max-iterations") break - # Get the next subtask to work on - next_subtask = get_next_subtask(spec_dir) + # Get the next subtask to work on (planner sessions shouldn't bind to a subtask) + next_subtask = None if first_run else get_next_subtask(spec_dir) subtask_id = next_subtask.get("id") if next_subtask else None phase_name = next_subtask.get("phase_name") if next_subtask else None @@ -269,6 +294,8 @@ async def run_autonomous_agent( # Generate appropriate prompt if first_run: prompt = generate_planner_prompt(spec_dir, project_dir) + if planning_retry_context: + prompt += "\n\n" + planning_retry_context # Retrieve Graphiti memory context for planning phase # This gives the planner knowledge of previous patterns, gotchas, and insights @@ -305,6 +332,10 @@ async def run_autonomous_agent( task_logger.start_phase( LogPhase.CODING, "Starting implementation..." ) + # In worktree mode, the UI prefers planning logs from the main spec dir. + # Ensure the planning->coding transition is immediately reflected there. + if sync_spec_to_source(spec_dir, source_spec_dir): + print_status("Phase transition synced to main project", "success") if not next_subtask: print("No pending subtasks found - build may be complete!") @@ -363,8 +394,46 @@ async def run_autonomous_agent( client, prompt, spec_dir, verbose, phase=current_log_phase ) + plan_validated = False + if is_planning_phase and status != "error": + valid, errors = _validate_and_fix_implementation_plan() + if valid: + plan_validated = True + planning_retry_context = None + else: + planning_validation_failures += 1 + if planning_validation_failures >= max_planning_validation_retries: + print_status( + "implementation_plan.json validation failed too many times", + "error", + ) + for err in errors: + print(f" - {err}") + status_manager.update(state=BuildState.ERROR) + return + + print_status( + "implementation_plan.json invalid - retrying planner", "warning" + ) + for err in errors: + print(f" - {err}") + + planning_retry_context = ( + "## IMPLEMENTATION PLAN VALIDATION ERRORS\n\n" + "The previous `implementation_plan.json` is INVALID.\n" + "You MUST rewrite it to match the required schema:\n" + "- Top-level: `feature`, `workflow_type`, `phases`\n" + "- Each phase: `id` (or `phase`) and `name`, and `subtasks`\n" + "- Each subtask: `id`, `description`, `status` (use `pending` for not started)\n\n" + "Validation errors:\n" + "\n".join(f"- {e}" for e in errors) + ) + # Stay in planning mode for the next iteration + first_run = True + status = "continue" + # === POST-SESSION PROCESSING (100% reliable) === - if subtask_id and not first_run: + # Only run post-session processing for coding sessions. + if subtask_id and current_log_phase == LogPhase.CODING: linear_is_enabled = ( linear_task is not None and linear_task.task_id is not None ) @@ -402,9 +471,9 @@ async def run_autonomous_agent( attempt_count=attempt_count, ) print_status("Linear notified of stuck subtask", "info") - elif is_planning_phase and source_spec_dir: + elif plan_validated 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 @@ -436,7 +505,9 @@ async def run_autonomous_agent( print_progress_summary(spec_dir) # Update state back to building - status_manager.update(state=BuildState.BUILDING) + status_manager.update( + state=BuildState.PLANNING if is_planning_phase else BuildState.BUILDING + ) # Show next subtask info next_subtask = get_next_subtask(spec_dir) diff --git a/apps/backend/agents/memory_manager.py b/apps/backend/agents/memory_manager.py index bef15d9005..a1ea6a5cfb 100644 --- a/apps/backend/agents/memory_manager.py +++ b/apps/backend/agents/memory_manager.py @@ -24,6 +24,7 @@ # Import from parent memory package # Now safe since this module is named memory_manager (not memory) from memory import save_session_insights as save_file_based_memory +from memory.graphiti_helpers import get_graphiti_memory logger = logging.getLogger(__name__) @@ -113,24 +114,20 @@ async def get_graphiti_context( debug("memory", "Graphiti not enabled, skipping context retrieval") return None + memory = None try: - from graphiti_memory import GraphitiMemory - - # Create memory manager - memory = GraphitiMemory(spec_dir, project_dir) - - if not memory.is_enabled: + # Use centralized helper for GraphitiMemory instantiation + memory = get_graphiti_memory(spec_dir, project_dir) + if memory is None: if is_debug_enabled(): - debug_warning("memory", "GraphitiMemory.is_enabled=False") + debug_warning("memory", "GraphitiMemory not available") return None - # Build search query from subtask description subtask_desc = subtask.get("description", "") subtask_id = subtask.get("id", "") query = f"{subtask_desc} {subtask_id}".strip() if not query: - await memory.close() if is_debug_enabled(): debug_warning("memory", "Empty query, skipping context retrieval") return None @@ -155,8 +152,6 @@ async def get_graphiti_context( # Also get recent session history session_history = await memory.get_session_history(limit=3) - await memory.close() - if is_debug_enabled(): debug( "memory", @@ -229,14 +224,20 @@ async def get_graphiti_context( return "\n".join(sections) - except ImportError: - logger.debug("Graphiti packages not installed") - if is_debug_enabled(): - debug_warning("memory", "Graphiti packages not installed") - return None except Exception as e: logger.warning(f"Failed to get Graphiti context: {e}") + if is_debug_enabled(): + debug_error("memory", "Graphiti context retrieval failed", error=str(e)) return None + finally: + # Always close the memory connection (swallow exceptions to avoid overriding) + if memory is not None: + try: + await memory.close() + except Exception as e: + logger.debug( + "Failed to close Graphiti memory connection", exc_info=True + ) async def save_session_memory( @@ -321,20 +322,24 @@ async def save_session_memory( if is_debug_enabled(): debug("memory", "Attempting PRIMARY storage: Graphiti") + memory = None try: - from graphiti_memory import GraphitiMemory - - memory = GraphitiMemory(spec_dir, project_dir) - - if is_debug_enabled(): - debug_detailed( - "memory", - "GraphitiMemory instance created", - is_enabled=memory.is_enabled, - group_id=getattr(memory, "group_id", "unknown"), - ) + # Use centralized helper for GraphitiMemory instantiation + memory = get_graphiti_memory(spec_dir, project_dir) + if memory is None: + if is_debug_enabled(): + debug_warning("memory", "GraphitiMemory not available") + # Continue to file-based fallback + else: + if is_debug_enabled(): + debug_detailed( + "memory", + "GraphitiMemory instance created", + is_enabled=memory.is_enabled, + group_id=getattr(memory, "group_id", "unknown"), + ) - if memory.is_enabled: + if memory is not None and memory.is_enabled: if is_debug_enabled(): debug("memory", "Saving to Graphiti...") @@ -351,8 +356,6 @@ async def save_session_memory( # Fallback to basic session insights result = await memory.save_session_insights(session_num, insights) - await memory.close() - if result: logger.info( f"Session {session_num} insights saved to Graphiti (primary)" @@ -373,23 +376,32 @@ async def save_session_memory( debug_warning( "memory", "Graphiti save returned False, using FALLBACK" ) + elif memory is None: + if is_debug_enabled(): + debug_warning( + "memory", "GraphitiMemory not available, using FALLBACK" + ) else: + # memory is not None but memory.is_enabled is False logger.warning( - "Graphiti memory not enabled, falling back to file-based" + "GraphitiMemory.is_enabled=False, falling back to file-based" ) if is_debug_enabled(): - debug_warning( - "memory", "GraphitiMemory.is_enabled=False, using FALLBACK" - ) + debug_warning("memory", "GraphitiMemory disabled, using FALLBACK") - except ImportError as e: - logger.debug("Graphiti packages not installed, falling back to file-based") - if is_debug_enabled(): - debug_warning("memory", "Graphiti packages not installed", error=str(e)) except Exception as e: logger.warning(f"Graphiti save failed: {e}, falling back to file-based") if is_debug_enabled(): debug_error("memory", "Graphiti save failed", error=str(e)) + finally: + # Always close the memory connection (swallow exceptions to avoid overriding) + if memory is not None: + try: + await memory.close() + except Exception as e: + logger.debug( + "Failed to close Graphiti memory connection", exc_info=e + ) else: if is_debug_enabled(): debug("memory", "Graphiti not enabled, skipping to FALLBACK") diff --git a/apps/backend/agents/session.py b/apps/backend/agents/session.py index 89a5d5d48c..263bf17efb 100644 --- a/apps/backend/agents/session.py +++ b/apps/backend/agents/session.py @@ -40,7 +40,7 @@ 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 @@ -445,8 +445,9 @@ async def run_agent_session( result_content = getattr(block, "content", "") is_error = getattr(block, "is_error", False) - # Check if command was blocked by security hook - if "blocked" in str(result_content).lower(): + # Check if this is an error (not just content containing "blocked") + if is_error and "blocked" in str(result_content).lower(): + # Actual blocked command by security hook debug_error( "session", f"Tool BLOCKED: {current_tool}", diff --git a/apps/backend/agents/tools_pkg/tools/memory.py b/apps/backend/agents/tools_pkg/tools/memory.py index ac361ab78c..52457a5991 100644 --- a/apps/backend/agents/tools_pkg/tools/memory.py +++ b/apps/backend/agents/tools_pkg/tools/memory.py @@ -4,9 +4,16 @@ 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,110 @@ SDK_TOOLS_AVAILABLE = False tool = None +logger = logging.getLogger(__name__) + + +async def _save_to_graphiti_async( + spec_dir: Path, + project_dir: Path, + save_type: str, + data: dict, +) -> bool: + """ + Save data to Graphiti/LadybugDB (async implementation). + + 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: + # Use centralized helper for GraphitiMemory instantiation + # The helper handles enablement checks internally + from memory.graphiti_helpers import get_graphiti_memory + + memory = get_graphiti_memory(spec_dir, project_dir) + if memory is None: + return False + + 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: + # Always close the memory connection (swallow exceptions to avoid overriding) + try: + await memory.close() + except Exception as e: + logger.debug( + "Failed to close Graphiti memory connection", exc_info=True + ) + + except Exception as e: + logger.warning(f"Failed to save to Graphiti: {e}") + return False + + +def _save_to_graphiti_sync( + spec_dir: Path, + project_dir: Path, + save_type: str, + data: dict, +) -> bool: + """ + Save data to Graphiti/LadybugDB (synchronous wrapper for sync contexts only). + + NOTE: This should only be called from synchronous code. For async callers, + use _save_to_graphiti_async() directly to ensure proper resource cleanup. + + Args: + spec_dir: Spec directory for GraphitiMemory initialization + project_dir: Project root directory + save_type: Type of save - 'discovery', 'gotcha', or 'pattern' + data: Data to save + + Returns: + True if save succeeded, False otherwise + """ + try: + # Check if we're already in an async context + try: + asyncio.get_running_loop() + # We're in an async context - caller should use _save_to_graphiti_async + # Log a warning and return False to avoid the resource leak bug + logger.warning( + "_save_to_graphiti_sync called from async context. " + "Use _save_to_graphiti_async instead for proper cleanup." + ) + return False + except RuntimeError: + # No running loop - safe to create one + return asyncio.run( + _save_to_graphiti_async(spec_dir, project_dir, save_type, data) + ) + except Exception as e: + logger.warning(f"Failed to save to Graphiti: {e}") + return False + def create_memory_tools(spec_dir: Path, project_dir: Path) -> list: """ @@ -45,7 +156,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 +165,10 @@ async def record_discovery(args: dict[str, Any]) -> dict[str, Any]: 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 +190,23 @@ async def record_discovery(args: dict[str, Any]) -> dict[str, Any]: 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 = await _save_to_graphiti_async( + 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 +227,7 @@ async def record_discovery(args: dict[str, Any]) -> dict[str, Any]: {"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 +235,10 @@ async def record_gotcha(args: dict[str, Any]) -> dict[str, Any]: 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 +253,20 @@ async def record_gotcha(args: dict[str, Any]) -> dict[str, Any]: ) f.write(entry) - return {"content": [{"type": "text", "text": f"Recorded gotcha: {gotcha}"}]} + # SECONDARY: Also save to Graphiti/LadybugDB (for Memory UI) + saved_to_graphiti = await _save_to_graphiti_async( + spec_dir, + project_dir, + "gotcha", + {"gotcha": gotcha, "context": context}, + ) + + storage_note = " (also saved to memory graph)" if saved_to_graphiti else "" + return { + "content": [ + {"type": "text", "text": f"Recorded gotcha: {gotcha}{storage_note}"} + ] + } except Exception as e: return { diff --git a/apps/backend/agents/utils.py b/apps/backend/agents/utils.py index 8ce33c9224..614cdb795a 100644 --- a/apps/backend/agents/utils.py +++ b/apps/backend/agents/utils.py @@ -8,40 +8,38 @@ import json import logging import shutil -import subprocess from pathlib import Path +from core.git_executable import run_git + logger = logging.getLogger(__name__) def get_latest_commit(project_dir: Path) -> str | None: """Get the hash of the latest git commit.""" - try: - result = subprocess.run( - ["git", "rev-parse", "HEAD"], - cwd=project_dir, - capture_output=True, - text=True, - check=True, - ) + result = run_git( + ["rev-parse", "HEAD"], + cwd=project_dir, + timeout=10, + ) + if result.returncode == 0: return result.stdout.strip() - except subprocess.CalledProcessError: - return None + return None def get_commit_count(project_dir: Path) -> int: """Get the total number of commits.""" - try: - result = subprocess.run( - ["git", "rev-list", "--count", "HEAD"], - cwd=project_dir, - capture_output=True, - text=True, - check=True, - ) - return int(result.stdout.strip()) - except (subprocess.CalledProcessError, ValueError): - return 0 + result = run_git( + ["rev-list", "--count", "HEAD"], + cwd=project_dir, + timeout=10, + ) + if result.returncode == 0: + try: + return int(result.stdout.strip()) + except ValueError: + return 0 + return 0 def load_implementation_plan(spec_dir: Path) -> dict | None: @@ -74,16 +72,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. - - 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. + Sync ALL spec files from worktree back to source spec directory. + + When running in isolated mode (worktrees), the agent creates and updates + many files inside the worktree's spec directory. This function syncs ALL + of them back to the main project's spec directory. + + IMPORTANT: Since .auto-claude/ is gitignored, this sync happens to the + local filesystem regardless of what branch the user is on. The worktree + may be on a different branch (e.g., auto-claude/093-task), but the sync + target is always the main project's .auto-claude/specs/ directory. + + Files synced (all files in spec directory): + - implementation_plan.json - Task status and subtask completion + - build-progress.txt - Session-by-session progress notes + - task_logs.json - Execution logs + - review_state.json - QA review state + - critique_report.json - Spec critique findings + - suggested_commit_message.txt - Commit suggestions + - REGRESSION_TEST_REPORT.md - Test regression report + - spec.md, context.json, etc. - Original spec files (for completeness) + - memory/ directory - Codebase map, patterns, gotchas, session insights 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 +114,68 @@ def sync_plan_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool: if spec_dir_resolved == source_spec_dir_resolved: return False # Same directory, no sync needed - # Sync the implementation plan - plan_file = spec_dir / "implementation_plan.json" - if not plan_file.exists(): - return False + synced_any = False - source_plan_file = source_spec_dir / "implementation_plan.json" + # Ensure source directory exists + source_spec_dir.mkdir(parents=True, exist_ok=True) try: - shutil.copy2(plan_file, source_plan_file) - logger.debug(f"Synced implementation plan to source: {source_plan_file}") - return True + # Sync all files and directories from worktree spec to source spec + for item in spec_dir.iterdir(): + # Skip symlinks to prevent path traversal attacks + if item.is_symlink(): + logger.warning(f"Skipping symlink during sync: {item.name}") + continue + + source_item = source_spec_dir / item.name + + if item.is_file(): + # Copy file (preserves timestamps) + shutil.copy2(item, source_item) + logger.debug(f"Synced {item.name} to source") + synced_any = True + + elif item.is_dir(): + # Recursively sync directory + _sync_directory(item, source_item) + synced_any = True + except Exception as e: - logger.warning(f"Failed to sync implementation plan to source: {e}") - return False + logger.warning(f"Failed to sync spec directory to source: {e}") + + return synced_any + + +def _sync_directory(source_dir: Path, target_dir: Path) -> None: + """ + Recursively sync a directory from source to target. + + Args: + source_dir: Source directory (in worktree) + target_dir: Target directory (in main project) + """ + # Create target directory if needed + target_dir.mkdir(parents=True, exist_ok=True) + + for item in source_dir.iterdir(): + # Skip symlinks to prevent path traversal attacks + if item.is_symlink(): + logger.warning( + f"Skipping symlink during sync: {source_dir.name}/{item.name}" + ) + continue + + target_item = target_dir / item.name + + if item.is_file(): + shutil.copy2(item, target_item) + logger.debug(f"Synced {source_dir.name}/{item.name} to source") + elif item.is_dir(): + # Recurse into subdirectories + _sync_directory(item, target_item) + + +# Keep the old name as an alias for backward compatibility +def sync_plan_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool: + """Alias for sync_spec_to_source for backward compatibility.""" + return sync_spec_to_source(spec_dir, source_spec_dir) diff --git a/apps/backend/analysis/insight_extractor.py b/apps/backend/analysis/insight_extractor.py index 75974d6b59..7b461afbae 100644 --- a/apps/backend/analysis/insight_extractor.py +++ b/apps/backend/analysis/insight_extractor.py @@ -387,12 +387,40 @@ async def run_insight_extraction( # Collect the response response_text = "" + message_count = 0 + text_blocks_found = 0 + async for msg in client.receive_response(): msg_type = type(msg).__name__ + message_count += 1 + if msg_type == "AssistantMessage" and hasattr(msg, "content"): for block in msg.content: - if hasattr(block, "text"): - response_text += block.text + # Must check block type - only TextBlock has .text attribute + block_type = type(block).__name__ + if block_type == "TextBlock" and hasattr(block, "text"): + text_blocks_found += 1 + if block.text: # Only add non-empty text + response_text += block.text + else: + logger.debug( + f"Found empty TextBlock in response (block #{text_blocks_found})" + ) + + # Log response collection summary + logger.debug( + f"Insight extraction response: {message_count} messages, " + f"{text_blocks_found} text blocks, {len(response_text)} chars collected" + ) + + # Validate we received content before parsing + if not response_text.strip(): + logger.warning( + f"Insight extraction returned empty response. " + f"Messages received: {message_count}, TextBlocks found: {text_blocks_found}. " + f"This may indicate the AI model did not respond with text content." + ) + return None # Parse JSON from response return parse_insights(response_text) @@ -415,6 +443,11 @@ def parse_insights(response_text: str) -> dict | None: # Try to extract JSON from the response text = response_text.strip() + # Early validation - check for empty response + if not text: + logger.warning("Cannot parse insights: response text is empty") + return None + # Handle markdown code blocks if text.startswith("```"): # Remove code block markers @@ -422,17 +455,26 @@ def parse_insights(response_text: str) -> dict | None: # Remove first line (```json or ```) if lines[0].startswith("```"): lines = lines[1:] - # Remove last line if it's `` + # Remove last line if it's ``` if lines and lines[-1].strip() == "```": lines = lines[:-1] - text = "\n".join(lines) + text = "\n".join(lines).strip() + + # Check again after removing code blocks + if not text: + logger.warning( + "Cannot parse insights: response contained only markdown code block markers with no content" + ) + return None try: insights = json.loads(text) # Validate structure if not isinstance(insights, dict): - logger.warning("Insights is not a dict") + logger.warning( + f"Insights is not a dict, got type: {type(insights).__name__}" + ) return None # Ensure required keys exist with defaults @@ -446,7 +488,13 @@ def parse_insights(response_text: str) -> dict | None: except json.JSONDecodeError as e: logger.warning(f"Failed to parse insights JSON: {e}") - logger.debug(f"Response text was: {text[:500]}") + # Show more context in the error message + preview_length = min(500, len(text)) + logger.warning( + f"Response text preview (first {preview_length} chars): {text[:preview_length]}" + ) + if len(text) > preview_length: + logger.warning(f"... (total length: {len(text)} chars)") return None diff --git a/apps/backend/cli/batch_commands.py b/apps/backend/cli/batch_commands.py index 28a82ea90a..959df5eeac 100644 --- a/apps/backend/cli/batch_commands.py +++ b/apps/backend/cli/batch_commands.py @@ -6,6 +6,8 @@ """ import json +import shutil +import subprocess from pathlib import Path from ui import highlight, print_status @@ -184,7 +186,7 @@ def handle_batch_cleanup_command(project_dir: str, dry_run: bool = True) -> bool True if successful """ specs_dir = Path(project_dir) / ".auto-claude" / "specs" - worktrees_dir = Path(project_dir) / ".worktrees" + worktrees_dir = Path(project_dir) / ".auto-claude" / "worktrees" / "tasks" if not specs_dir.exists(): print_status("No specs directory found", "info") @@ -209,8 +211,56 @@ def handle_batch_cleanup_command(project_dir: str, dry_run: bool = True) -> bool print(f" - {spec_name}") wt_path = worktrees_dir / spec_name if wt_path.exists(): - print(f" └─ .worktrees/{spec_name}/") + print(f" └─ .auto-claude/worktrees/tasks/{spec_name}/") print() print("Run with --no-dry-run to actually delete") + else: + # Actually delete specs and worktrees + deleted_count = 0 + for spec_name in completed: + spec_path = specs_dir / spec_name + wt_path = worktrees_dir / spec_name + + # Remove worktree first (if exists) + if wt_path.exists(): + try: + result = subprocess.run( + ["git", "worktree", "remove", "--force", str(wt_path)], + cwd=project_dir, + capture_output=True, + text=True, + timeout=30, + ) + if result.returncode == 0: + print_status(f"Removed worktree: {spec_name}", "success") + else: + # Fallback: remove directory manually if git fails + shutil.rmtree(wt_path, ignore_errors=True) + print_status( + f"Removed worktree directory: {spec_name}", "success" + ) + except subprocess.TimeoutExpired: + # Timeout: fall back to manual removal + shutil.rmtree(wt_path, ignore_errors=True) + print_status( + f"Worktree removal timed out, removed directory: {spec_name}", + "warning", + ) + except Exception as e: + print_status( + f"Failed to remove worktree {spec_name}: {e}", "warning" + ) + + # Remove spec directory + if spec_path.exists(): + try: + shutil.rmtree(spec_path) + print_status(f"Removed spec: {spec_name}", "success") + deleted_count += 1 + except Exception as e: + print_status(f"Failed to remove spec {spec_name}: {e}", "error") + + print() + print_status(f"Cleaned up {deleted_count} spec(s)", "info") return True diff --git a/apps/backend/cli/build_commands.py b/apps/backend/cli/build_commands.py index 19dc17ca6b..99fdb96f60 100644 --- a/apps/backend/cli/build_commands.py +++ b/apps/backend/cli/build_commands.py @@ -79,7 +79,7 @@ def handle_build_command( base_branch: Base branch for worktree creation (default: current branch) """ # Lazy imports to avoid loading heavy modules - from agent import run_autonomous_agent, sync_plan_to_source + from agent import run_autonomous_agent, sync_spec_to_source from debug import ( debug, debug_info, @@ -87,6 +87,7 @@ def handle_build_command( debug_success, ) from phase_config import get_phase_model + from prompts_pkg.prompts import get_base_branch_from_metadata from qa_loop import run_qa_validation_loop, should_run_qa from .utils import print_banner, validate_environment @@ -194,6 +195,14 @@ def handle_build_command( auto_continue=auto_continue, ) + # If base_branch not provided via CLI, try to read from task_metadata.json + # This ensures the backend uses the branch configured in the frontend + if base_branch is None: + metadata_branch = get_base_branch_from_metadata(spec_dir) + if metadata_branch: + base_branch = metadata_branch + debug("run.py", f"Using base branch from task metadata: {base_branch}") + if workspace_mode == WorkspaceMode.ISOLATED: # Keep reference to original spec directory for syncing progress back source_spec_dir = spec_dir @@ -274,7 +283,7 @@ def handle_build_command( # Sync implementation plan to main project after QA # This ensures the main project has the latest status (human_review) - if sync_plan_to_source(spec_dir, source_spec_dir): + if sync_spec_to_source(spec_dir, source_spec_dir): debug_info( "run.py", "Implementation plan synced to main project after QA" ) diff --git a/apps/backend/cli/main.py b/apps/backend/cli/main.py index 9b910b5311..cfb6a6a414 100644 --- a/apps/backend/cli/main.py +++ b/apps/backend/cli/main.py @@ -38,6 +38,7 @@ ) from .workspace_commands import ( handle_cleanup_worktrees_command, + handle_create_pr_command, handle_discard_command, handle_list_worktrees_command, handle_merge_command, @@ -153,6 +154,30 @@ def parse_args() -> argparse.Namespace: action="store_true", help="Discard an existing build (requires confirmation)", ) + build_group.add_argument( + "--create-pr", + action="store_true", + help="Push branch and create a GitHub Pull Request", + ) + + # PR options + parser.add_argument( + "--pr-target", + type=str, + metavar="BRANCH", + help="With --create-pr: target branch for PR (default: auto-detect)", + ) + parser.add_argument( + "--pr-title", + type=str, + metavar="TITLE", + help="With --create-pr: custom PR title (default: generated from spec name)", + ) + parser.add_argument( + "--pr-draft", + action="store_true", + help="With --create-pr: create as draft PR", + ) # Merge options parser.add_argument( @@ -365,6 +390,21 @@ def main() -> None: handle_discard_command(project_dir, spec_dir.name) return + if args.create_pr: + # Pass args.pr_target directly - WorktreeManager._detect_base_branch + # handles base branch detection internally when target_branch is None + result = handle_create_pr_command( + project_dir=project_dir, + spec_name=spec_dir.name, + target_branch=args.pr_target, + title=args.pr_title, + draft=args.pr_draft, + ) + # JSON output is already printed by handle_create_pr_command + if not result.get("success"): + sys.exit(1) + return + # Handle QA commands if args.qa_status: handle_qa_status_command(spec_dir) diff --git a/apps/backend/cli/utils.py b/apps/backend/cli/utils.py index f18954654a..0e2a7b427a 100644 --- a/apps/backend/cli/utils.py +++ b/apps/backend/cli/utils.py @@ -15,7 +15,47 @@ sys.path.insert(0, str(_PARENT_DIR)) from core.auth import get_auth_token, get_auth_token_source -from dotenv import load_dotenv +from core.dependency_validator import validate_platform_dependencies + + +def import_dotenv(): + """ + Import and return load_dotenv with helpful error message if not installed. + + This centralized function ensures consistent error messaging across all + runner scripts when python-dotenv is not available. + + Returns: + The load_dotenv function + + Raises: + SystemExit: If dotenv cannot be imported, with helpful installation instructions. + """ + try: + from dotenv import load_dotenv as _load_dotenv + + return _load_dotenv + except ImportError: + sys.exit( + "Error: Required Python package 'python-dotenv' is not installed.\n" + "\n" + "This usually means you're not using the virtual environment.\n" + "\n" + "To fix this:\n" + "1. From the 'apps/backend/' directory, activate the venv:\n" + " source .venv/bin/activate # Linux/macOS\n" + " .venv\\Scripts\\activate # Windows\n" + "\n" + "2. Or install dependencies directly:\n" + " pip install python-dotenv\n" + " pip install -r requirements.txt\n" + "\n" + f"Current Python: {sys.executable}\n" + ) + + +# Load .env with helpful error if dependencies not installed +load_dotenv = import_dotenv() from graphiti_config import get_graphiti_status from linear_integration import LinearManager from linear_updater import is_linear_enabled @@ -28,8 +68,8 @@ muted, ) -# Configuration -DEFAULT_MODEL = "claude-opus-4-5-20251101" +# Configuration - uses shorthand that resolves via API Profile if configured +DEFAULT_MODEL = "sonnet" # Changed from "opus" (fix #433) def setup_environment() -> Path: @@ -82,7 +122,7 @@ def find_spec(project_dir: Path, spec_identifier: str) -> Path | None: return spec_folder # Check worktree specs (for merge-preview, merge, review, discard operations) - worktree_base = project_dir / ".worktrees" + worktree_base = project_dir / ".auto-claude" / "worktrees" / "tasks" if worktree_base.exists(): # Try exact match in worktree worktree_spec = ( @@ -115,6 +155,9 @@ def validate_environment(spec_dir: Path) -> bool: Returns: True if valid, False otherwise (with error messages printed) """ + # Validate platform-specific dependencies first (exits if missing) + validate_platform_dependencies() + valid = True # Check for OAuth token (API keys are not supported) diff --git a/apps/backend/cli/workspace_commands.py b/apps/backend/cli/workspace_commands.py index 5e3d68a5aa..85f9f7327d 100644 --- a/apps/backend/cli/workspace_commands.py +++ b/apps/backend/cli/workspace_commands.py @@ -5,6 +5,7 @@ CLI commands for workspace management (merge, review, discard, list, cleanup) """ +import json import subprocess import sys from pathlib import Path @@ -22,6 +23,8 @@ get_merge_base, is_lock_file, ) +from core.worktree import PushAndCreatePRResult as CreatePRResult +from core.worktree import WorktreeManager from debug import debug_warning from ui import ( Icons, @@ -30,6 +33,7 @@ from workspace import ( cleanup_all_worktrees, discard_existing_build, + get_existing_build_worktree, list_all_worktrees, merge_existing_build, review_existing_build, @@ -67,6 +71,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 +83,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 +96,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 +133,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,12 +151,176 @@ 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 [] +def _detect_worktree_base_branch( + project_dir: Path, + worktree_path: Path, + spec_name: str, +) -> str | None: + """ + Detect which branch a worktree was created from. + + Tries multiple strategies: + 1. Check worktree config file (.auto-claude/worktree-config.json) + 2. Find merge-base with known branches (develop, main, master) + 3. Return None if unable to detect + + Args: + project_dir: Project root directory + worktree_path: Path to the worktree + spec_name: Name of the spec + + Returns: + The detected base branch name, or None if unable to detect + """ + # Strategy 1: Check for worktree config file + config_path = worktree_path / ".auto-claude" / "worktree-config.json" + if config_path.exists(): + try: + config = json.loads(config_path.read_text()) + if config.get("base_branch"): + debug( + MODULE, + f"Found base branch in worktree config: {config['base_branch']}", + ) + return config["base_branch"] + except Exception as e: + debug_warning(MODULE, f"Failed to read worktree config: {e}") + + # Strategy 2: Find which branch has the closest merge-base + # Check common branches: develop, main, master + spec_branch = f"auto-claude/{spec_name}" + candidate_branches = ["develop", "main", "master"] + + best_branch = None + best_commits_behind = float("inf") + + for branch in candidate_branches: + try: + # Check if branch exists + check = subprocess.run( + ["git", "rev-parse", "--verify", branch], + cwd=project_dir, + capture_output=True, + text=True, + ) + if check.returncode != 0: + continue + + # Get merge base + merge_base_result = subprocess.run( + ["git", "merge-base", branch, spec_branch], + cwd=project_dir, + capture_output=True, + text=True, + ) + if merge_base_result.returncode != 0: + continue + + merge_base = merge_base_result.stdout.strip() + + # Count commits between merge-base and branch tip + # The branch with fewer commits ahead is likely the one we branched from + ahead_result = subprocess.run( + ["git", "rev-list", "--count", f"{merge_base}..{branch}"], + cwd=project_dir, + capture_output=True, + text=True, + ) + if ahead_result.returncode == 0: + commits_ahead = int(ahead_result.stdout.strip()) + debug( + MODULE, + f"Branch {branch} is {commits_ahead} commits ahead of merge-base", + ) + if commits_ahead < best_commits_behind: + best_commits_behind = commits_ahead + best_branch = branch + except Exception as e: + debug_warning(MODULE, f"Error checking branch {branch}: {e}") + continue + + if best_branch: + debug( + MODULE, + f"Detected base branch from git history: {best_branch} (commits ahead: {best_commits_behind})", + ) + return best_branch + + return None + + +def _detect_parallel_task_conflicts( + project_dir: Path, + current_task_id: str, + current_task_files: list[str], +) -> list[dict]: + """ + Detect potential conflicts between this task and other active tasks. + + Uses existing evolution data to check if any of this task's files + have been modified by other active tasks. This is a lightweight check + that doesn't require re-processing all files. + + Args: + project_dir: Project root directory + current_task_id: ID of the current task + current_task_files: Files modified by this task (from git diff) + + Returns: + List of conflict dictionaries with 'file' and 'tasks' keys + """ + try: + from merge import MergeOrchestrator + + # Initialize orchestrator just to access evolution data + orchestrator = MergeOrchestrator( + project_dir, + enable_ai=False, + dry_run=True, + ) + + # Get all active tasks from evolution data + active_tasks = orchestrator.evolution_tracker.get_active_tasks() + + # Remove current task from active tasks + other_active_tasks = active_tasks - {current_task_id} + + if not other_active_tasks: + return [] + + # Convert current task files to a set for fast lookup + current_files_set = set(current_task_files) + + # Get files modified by other active tasks + conflicts = [] + other_task_files = orchestrator.evolution_tracker.get_files_modified_by_tasks( + list(other_active_tasks) + ) + + # Find intersection - files modified by both this task and other tasks + for file_path, tasks in other_task_files.items(): + if file_path in current_files_set: + # This file was modified by both current task and other task(s) + all_tasks = [current_task_id] + tasks + conflicts.append({"file": file_path, "tasks": all_tasks}) + + return conflicts + + except Exception as e: + # If anything fails, just return empty - parallel task detection is optional + debug_warning( + "workspace_commands", + f"Parallel task conflict detection failed: {e}", + ) + return [] + + # Import debug utilities try: from debug import ( @@ -352,7 +536,9 @@ def handle_cleanup_worktrees_command(project_dir: Path) -> None: cleanup_all_worktrees(project_dir, confirm=True) -def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict: +def _check_git_merge_conflicts( + project_dir: Path, spec_name: str, base_branch: str | None = None +) -> dict: """ Check for git-level merge conflicts WITHOUT modifying the working directory. @@ -362,6 +548,7 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict: Args: project_dir: Project root directory spec_name: Name of the spec + base_branch: Branch the task was created from (default: auto-detect) Returns: Dictionary with git conflict information: @@ -380,21 +567,25 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict: "has_conflicts": False, "conflicting_files": [], "needs_rebase": False, - "base_branch": "main", + "base_branch": base_branch or "main", "spec_branch": spec_branch, "commits_behind": 0, } try: - # Get the current branch (base branch) - base_result = subprocess.run( - ["git", "rev-parse", "--abbrev-ref", "HEAD"], - cwd=project_dir, - capture_output=True, - text=True, - ) - if base_result.returncode == 0: - result["base_branch"] = base_result.stdout.strip() + # Use provided base_branch, or detect from current HEAD + if not base_branch: + base_result = subprocess.run( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=project_dir, + capture_output=True, + text=True, + ) + if base_result.returncode == 0: + result["base_branch"] = base_result.stdout.strip() + else: + result["base_branch"] = base_branch + debug(MODULE, f"Using provided base branch: {base_branch}") # Get the merge base commit merge_base_result = subprocess.run( @@ -553,7 +744,6 @@ def handle_merge_preview_command( spec_name=spec_name, ) - from merge import MergeOrchestrator from workspace import get_existing_build_worktree worktree_path = get_existing_build_worktree(project_dir, spec_name) @@ -580,16 +770,32 @@ def handle_merge_preview_command( } try: - # First, check for git-level conflicts (diverged branches) - git_conflicts = _check_git_merge_conflicts(project_dir, spec_name) - # Determine the task's source branch (where the task was created from) - # Use provided base_branch (from task metadata), or fall back to detected default + # Priority: + # 1. Provided base_branch (from task metadata) + # 2. Detect from worktree's git history (find which branch it diverged from) + # 3. Fall back to default branch detection (main/master) task_source_branch = base_branch if not task_source_branch: - # Auto-detect the default branch (main/master) that worktrees are typically created from + # Try to detect from worktree's git history + task_source_branch = _detect_worktree_base_branch( + project_dir, worktree_path, spec_name + ) + if not task_source_branch: + # Fall back to auto-detecting main/master task_source_branch = _detect_default_branch(project_dir) + debug( + MODULE, + f"Using task source branch: {task_source_branch}", + provided=base_branch is not None, + ) + + # Check for git-level conflicts (diverged branches) using the task's source branch + git_conflicts = _check_git_merge_conflicts( + project_dir, spec_name, base_branch=task_source_branch + ) + # Get actual changed files from git diff (this is the authoritative count) all_changed_files = _get_changed_files_from_git( worktree_path, task_source_branch @@ -600,49 +806,39 @@ def handle_merge_preview_command( changed_files=all_changed_files[:10], # Log first 10 ) - debug(MODULE, "Initializing MergeOrchestrator for preview...") + # OPTIMIZATION: Skip expensive refresh_from_git() and preview_merge() calls + # For merge-preview, we only need to detect: + # 1. Git conflicts (task vs base branch) - already calculated in _check_git_merge_conflicts() + # 2. Parallel task conflicts (this task vs other active tasks) + # + # For parallel task detection, we just check if this task's files overlap + # with files OTHER tasks have already recorded - no need to re-process all files. - # Initialize the orchestrator - orchestrator = MergeOrchestrator( - project_dir, - enable_ai=False, # Don't use AI for preview - dry_run=True, # Don't write anything - ) + debug(MODULE, "Checking for parallel task conflicts (lightweight)...") - # Refresh evolution data from the worktree - # Compare against the task's source branch (where the task was created from) + # Check for parallel task conflicts by looking at existing evolution data + parallel_conflicts = _detect_parallel_task_conflicts( + project_dir, spec_name, all_changed_files + ) debug( MODULE, - f"Refreshing evolution data from worktree: {worktree_path}", - task_source_branch=task_source_branch, + f"Parallel task conflicts detected: {len(parallel_conflicts)}", + conflicts=parallel_conflicts[:5] if parallel_conflicts else [], ) - orchestrator.evolution_tracker.refresh_from_git( - spec_name, worktree_path, target_branch=task_source_branch - ) - - # Get merge preview (semantic conflicts between parallel tasks) - debug(MODULE, "Generating merge preview...") - preview = orchestrator.preview_merge([spec_name]) - # Transform semantic conflicts to UI-friendly format + # Build conflict list - start with parallel task conflicts conflicts = [] - for c in preview.get("conflicts", []): - debug_verbose( - MODULE, - "Processing semantic conflict", - file=c.get("file", ""), - severity=c.get("severity", "unknown"), - ) + for pc in parallel_conflicts: conflicts.append( { - "file": c.get("file", ""), - "location": c.get("location", ""), - "tasks": c.get("tasks", []), - "severity": c.get("severity", "unknown"), - "canAutoMerge": c.get("can_auto_merge", False), - "strategy": c.get("strategy"), - "reason": c.get("reason", ""), - "type": "semantic", + "file": pc["file"], + "location": "file-level", + "tasks": pc["tasks"], + "severity": "medium", + "canAutoMerge": False, + "strategy": None, + "reason": f"File modified by multiple active tasks: {', '.join(pc['tasks'])}", + "type": "parallel", } ) @@ -669,13 +865,14 @@ def handle_merge_preview_command( } ) - summary = preview.get("summary", {}) # Count only non-lock-file conflicts git_conflict_count = len(git_conflicts.get("conflicting_files", [])) - len( lock_files_excluded ) - total_conflicts = summary.get("total_conflicts", 0) + git_conflict_count - conflict_files = summary.get("conflict_files", 0) + git_conflict_count + # Calculate totals from our conflict lists (git conflicts + parallel conflicts) + parallel_conflict_count = len(parallel_conflicts) + total_conflicts = git_conflict_count + parallel_conflict_count + conflict_files = git_conflict_count + parallel_conflict_count # Filter lock files from the git conflicts list for the response non_lock_conflicting_files = [ @@ -761,7 +958,7 @@ def handle_merge_preview_command( "totalFiles": total_files_from_git, "conflictFiles": conflict_files, "totalConflicts": total_conflicts, - "autoMergeable": summary.get("auto_mergeable", 0), + "autoMergeable": 0, # Not tracking auto-merge in lightweight mode "hasGitConflicts": git_conflicts["has_conflicts"] and len(non_lock_conflicting_files) > 0, # Include path-mapped AI merge count for UI display @@ -776,10 +973,9 @@ def handle_merge_preview_command( "Merge preview complete", total_files=result["summary"]["totalFiles"], total_files_source="git_diff", - semantic_tracked_files=summary.get("total_files", 0), total_conflicts=result["summary"]["totalConflicts"], has_git_conflicts=git_conflicts["has_conflicts"], - auto_mergeable=result["summary"]["autoMergeable"], + parallel_conflicts=parallel_conflict_count, path_mapped_ai_merges=len(path_mapped_ai_merges), total_renames=len(path_mappings), ) @@ -805,3 +1001,220 @@ def handle_merge_preview_command( "pathMappedAIMergeCount": 0, }, } + + +def handle_create_pr_command( + project_dir: Path, + spec_name: str, + target_branch: str | None = None, + title: str | None = None, + draft: bool = False, +) -> CreatePRResult: + """ + Handle the --create-pr command: push branch and create a GitHub PR. + + Args: + project_dir: Path to the project directory + spec_name: Name of the spec (e.g., "001-feature-name") + target_branch: Target branch for PR (defaults to base branch) + title: Custom PR title (defaults to spec name) + draft: Whether to create as draft PR + + Returns: + CreatePRResult with success status, pr_url, and any errors + """ + from core.worktree import WorktreeManager + + print_banner() + print("\n" + "=" * 70) + print(" CREATE PULL REQUEST") + print("=" * 70) + + # Check if worktree exists + worktree_path = get_existing_build_worktree(project_dir, spec_name) + if not worktree_path: + print(f"\n{icon(Icons.ERROR)} No build found for spec: {spec_name}") + print("\nA completed build worktree is required to create a PR.") + print("Run your build first, then use --create-pr.") + error_result: CreatePRResult = { + "success": False, + "error": "No build found for this spec", + } + return error_result + + # Create worktree manager + manager = WorktreeManager(project_dir, base_branch=target_branch) + + print(f"\n{icon(Icons.BRANCH)} Pushing branch and creating PR...") + print(f" Spec: {spec_name}") + print(f" Target: {target_branch or manager.base_branch}") + if title: + print(f" Title: {title}") + if draft: + print(" Mode: Draft PR") + + # Push and create PR with exception handling for clean JSON output + try: + raw_result = manager.push_and_create_pr( + spec_name=spec_name, + target_branch=target_branch, + title=title, + draft=draft, + ) + except Exception as e: + debug_error(MODULE, f"Exception during PR creation: {e}") + error_result: CreatePRResult = { + "success": False, + "error": str(e), + "message": "Failed to create PR", + } + print(f"\n{icon(Icons.ERROR)} Failed to create PR: {e}") + print(json.dumps(error_result)) + return error_result + + # Convert PushAndCreatePRResult to CreatePRResult + result: CreatePRResult = { + "success": raw_result.get("success", False), + "pr_url": raw_result.get("pr_url"), + "already_exists": raw_result.get("already_exists", False), + "error": raw_result.get("error"), + "message": raw_result.get("message"), + "pushed": raw_result.get("pushed", False), + "remote": raw_result.get("remote", ""), + "branch": raw_result.get("branch", ""), + } + + if result.get("success"): + pr_url = result.get("pr_url") + already_exists = result.get("already_exists", False) + + if already_exists: + print(f"\n{icon(Icons.SUCCESS)} PR already exists!") + else: + print(f"\n{icon(Icons.SUCCESS)} PR created successfully!") + + if pr_url: + print(f"\n{icon(Icons.LINK)} {pr_url}") + else: + print(f"\n{icon(Icons.INFO)} Check GitHub for the PR URL") + + print("\nNext steps:") + print(" 1. Review the PR on GitHub") + print(" 2. Request reviews from your team") + print(" 3. Merge when approved") + + # Output JSON for frontend parsing + print(json.dumps(result)) + return result + else: + error = result.get("error", "Unknown error") + print(f"\n{icon(Icons.ERROR)} Failed to create PR: {error}") + # Output JSON for frontend parsing + print(json.dumps(result)) + return result + + +def cleanup_old_worktrees_command( + project_dir: Path, days: int = 30, dry_run: bool = False +) -> dict: + """ + Clean up old worktrees that haven't been modified in the specified number of days. + + Args: + project_dir: Project root directory + days: Number of days threshold (default: 30) + dry_run: If True, only show what would be removed (default: False) + + Returns: + Dictionary with cleanup results + """ + try: + manager = WorktreeManager(project_dir) + + removed, failed = manager.cleanup_old_worktrees( + days_threshold=days, dry_run=dry_run + ) + + return { + "success": True, + "removed": removed, + "failed": failed, + "dry_run": dry_run, + "days_threshold": days, + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "removed": [], + "failed": [], + } + + +def worktree_summary_command(project_dir: Path) -> dict: + """ + Get a summary of all worktrees with age information. + + Args: + project_dir: Project root directory + + Returns: + Dictionary with worktree summary data + """ + try: + manager = WorktreeManager(project_dir) + + # Print to console for CLI usage + manager.print_worktree_summary() + + # Also return data for programmatic access + worktrees = manager.list_all_worktrees() + warning = manager.get_worktree_count_warning() + + # Categorize by age + recent = [] + week_old = [] + month_old = [] + very_old = [] + unknown_age = [] + + for info in worktrees: + data = { + "spec_name": info.spec_name, + "days_since_last_commit": info.days_since_last_commit, + "commit_count": info.commit_count, + } + + if info.days_since_last_commit is None: + unknown_age.append(data) + elif info.days_since_last_commit < 7: + recent.append(data) + elif info.days_since_last_commit < 30: + week_old.append(data) + elif info.days_since_last_commit < 90: + month_old.append(data) + else: + very_old.append(data) + + return { + "success": True, + "total_worktrees": len(worktrees), + "categories": { + "recent": recent, + "week_old": week_old, + "month_old": month_old, + "very_old": very_old, + "unknown_age": unknown_age, + }, + "warning": warning, + } + + except Exception as e: + return { + "success": False, + "error": str(e), + "total_worktrees": 0, + "categories": {}, + "warning": None, + } diff --git a/apps/backend/commit_message.py b/apps/backend/commit_message.py index 0518f20fba..b90242590c 100644 --- a/apps/backend/commit_message.py +++ b/apps/backend/commit_message.py @@ -231,7 +231,9 @@ async def _call_claude(prompt: str) -> str: msg_type = type(msg).__name__ if msg_type == "AssistantMessage" and hasattr(msg, "content"): for block in msg.content: - if hasattr(block, "text"): + # Must check block type - only TextBlock has .text attribute + block_type = type(block).__name__ + if block_type == "TextBlock" and hasattr(block, "text"): response_text += block.text logger.info(f"Generated commit message: {len(response_text)} chars") diff --git a/apps/backend/core/agent.py b/apps/backend/core/agent.py index 8b2cc8d540..6d9ffe3702 100644 --- a/apps/backend/core/agent.py +++ b/apps/backend/core/agent.py @@ -39,7 +39,7 @@ 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 @@ "load_implementation_plan", "find_subtask_in_plan", "find_phase_for_subtask", - "sync_plan_to_source", + "sync_spec_to_source", "AUTO_CONTINUE_DELAY_SECONDS", "HUMAN_INTERVENTION_FILE", ] diff --git a/apps/backend/core/auth.py b/apps/backend/core/auth.py index be105e1ff9..ce105a0caf 100644 --- a/apps/backend/core/auth.py +++ b/apps/backend/core/auth.py @@ -23,12 +23,21 @@ # Environment variables to pass through to SDK subprocess # NOTE: ANTHROPIC_API_KEY is intentionally excluded to prevent silent API billing SDK_ENV_VARS = [ + # API endpoint configuration "ANTHROPIC_BASE_URL", "ANTHROPIC_AUTH_TOKEN", + # Model overrides (from API Profile custom model mappings) + "ANTHROPIC_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + # SDK behavior configuration "NO_PROXY", "DISABLE_TELEMETRY", "DISABLE_COST_WARNINGS", "API_TIMEOUT_MS", + # Windows-specific: Git Bash path for Claude Code CLI + "CLAUDE_CODE_GIT_BASH_PATH", ] @@ -208,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. @@ -215,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 """ @@ -223,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 diff --git a/apps/backend/core/client.py b/apps/backend/core/client.py index 3d8dbe8de6..69c9c0e239 100644 --- a/apps/backend/core/client.py +++ b/apps/backend/core/client.py @@ -16,6 +16,7 @@ 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", "") @@ -538,6 +545,48 @@ def create_client( # cases where Claude uses absolute paths for file operations project_path_str = str(project_dir.resolve()) spec_path_str = str(spec_dir.resolve()) + + # Detect if we're running in a worktree and get the original project directory + # Worktrees are located in either: + # - .auto-claude/worktrees/tasks/{spec-name}/ (new location) + # - .worktrees/{spec-name}/ (legacy location) + # When running in a worktree, we need to allow access to both the worktree + # and the original project's .auto-claude/ directory for spec files + original_project_permissions = [] + resolved_project_path = project_dir.resolve() + + # Check for worktree paths and extract original project directory + # This handles spec worktrees, PR review worktrees, and legacy worktrees + # Note: Windows paths are normalized to forward slashes before comparison + worktree_markers = [ + "/.auto-claude/worktrees/tasks/", # Spec/task worktrees + "/.auto-claude/github/pr/worktrees/", # PR review worktrees + "/.worktrees/", # Legacy worktree location + ] + project_path_posix = str(resolved_project_path).replace("\\", "/") + + for marker in worktree_markers: + if marker in project_path_posix: + # Extract the original project directory (parent of worktree location) + # Use rsplit to get the rightmost occurrence (handles nested projects) + original_project_str = project_path_posix.rsplit(marker, 1)[0] + original_project_dir = Path(original_project_str) + + # Grant permissions for relevant directories in the original project + permission_ops = ["Read", "Write", "Edit", "Glob", "Grep"] + dirs_to_permit = [ + original_project_dir / ".auto-claude", + original_project_dir / ".worktrees", # Legacy support + ] + + for dir_path in dirs_to_permit: + if dir_path.exists(): + path_str = str(dir_path.resolve()) + original_project_permissions.extend( + [f"{op}({path_str}/**)" for op in permission_ops] + ) + break + security_settings = { "sandbox": {"enabled": True, "autoAllowBashIfSandboxed": True}, "permissions": { @@ -560,6 +609,9 @@ def create_client( f"Read({spec_path_str}/**)", f"Write({spec_path_str}/**)", f"Edit({spec_path_str}/**)", + # Allow original project's .auto-claude/ and .worktrees/ directories + # when running in a worktree (fixes issue #385 - permission errors) + *original_project_permissions, # Bash permission granted here, but actual commands are validated # by the bash_security_hook (see security.py for allowed commands) "Bash(*)", @@ -596,6 +648,8 @@ def create_client( print(f"Security settings: {settings_file}") print(" - Sandbox enabled (OS-level bash isolation)") print(f" - Filesystem restricted to: {project_dir.resolve()}") + if original_project_permissions: + print(" - Worktree permissions: granted for original project directories") print(" - Bash commands restricted to allowlist") if max_thinking_tokens: print(f" - Extended thinking: {max_thinking_tokens:,} tokens") @@ -742,6 +796,12 @@ def create_client( "settings": str(settings_file.resolve()), "env": sdk_env, # Pass ANTHROPIC_BASE_URL etc. to subprocess "max_thinking_tokens": max_thinking_tokens, # Extended thinking budget + "max_buffer_size": 10 + * 1024 + * 1024, # 10MB buffer (default: 1MB) - fixes large tool results + # Enable file checkpointing to track file read/write state across tool calls + # This prevents "File has not been read yet" errors in recovery sessions + "enable_file_checkpointing": True, } # Add structured output format if specified diff --git a/apps/backend/core/dependency_validator.py b/apps/backend/core/dependency_validator.py new file mode 100644 index 0000000000..8517cb3631 --- /dev/null +++ b/apps/backend/core/dependency_validator.py @@ -0,0 +1,50 @@ +""" +Dependency Validator +==================== + +Validates platform-specific dependencies are installed before running agents. +""" + +import sys +from pathlib import Path + + +def validate_platform_dependencies() -> None: + """ + Validate that platform-specific dependencies are installed. + + Raises: + SystemExit: If required platform-specific dependencies are missing, + with helpful installation instructions. + """ + # Check Windows-specific dependencies + if sys.platform == "win32" and sys.version_info >= (3, 12): + try: + import pywintypes # noqa: F401 + except ImportError: + _exit_with_pywin32_error() + + +def _exit_with_pywin32_error() -> None: + """Exit with helpful error message for missing pywin32.""" + # Use sys.prefix to detect the virtual environment path + # This works for venv and poetry environments + venv_activate = Path(sys.prefix) / "Scripts" / "activate" + + sys.exit( + "Error: Required Windows dependency 'pywin32' is not installed.\n" + "\n" + "Auto Claude requires pywin32 on Windows for LadybugDB/Graphiti memory integration.\n" + "\n" + "To fix this:\n" + "1. Activate your virtual environment:\n" + f" {venv_activate}\n" + "\n" + "2. Install pywin32:\n" + " pip install pywin32>=306\n" + "\n" + " Or reinstall all dependencies:\n" + " pip install -r requirements.txt\n" + "\n" + f"Current Python: {sys.executable}\n" + ) diff --git a/apps/backend/core/git_executable.py b/apps/backend/core/git_executable.py new file mode 100644 index 0000000000..d17a3e07ef --- /dev/null +++ b/apps/backend/core/git_executable.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Git Executable Finder +====================== + +Utility to find the git executable, with Windows-specific fallbacks. +Separated into its own module to avoid circular imports. +""" + +import os +import shutil +import subprocess +from pathlib import Path + +_cached_git_path: str | None = None + + +def get_git_executable() -> str: + """Find the git executable, with Windows-specific fallbacks. + + Returns the path to git executable. On Windows, checks multiple sources: + 1. CLAUDE_CODE_GIT_BASH_PATH env var (set by Electron frontend) + 2. shutil.which (if git is in PATH) + 3. Common installation locations + 4. Windows 'where' command + + Caches the result after first successful find. + """ + global _cached_git_path + + # Return cached result if available + if _cached_git_path is not None: + return _cached_git_path + + git_path = _find_git_executable() + _cached_git_path = git_path + return git_path + + +def _find_git_executable() -> str: + """Internal function to find git executable.""" + # 1. Check CLAUDE_CODE_GIT_BASH_PATH (set by Electron frontend) + # This env var points to bash.exe, we can derive git.exe from it + bash_path = os.environ.get("CLAUDE_CODE_GIT_BASH_PATH") + if bash_path: + try: + bash_path_obj = Path(bash_path) + if bash_path_obj.exists(): + git_dir = bash_path_obj.parent.parent + # Try cmd/git.exe first (preferred), then bin/git.exe + for git_subpath in ["cmd/git.exe", "bin/git.exe"]: + git_path = git_dir / git_subpath + if git_path.is_file(): + return str(git_path) + except (OSError, ValueError): + pass + + # 2. Try shutil.which (works if git is in PATH) + git_path = shutil.which("git") + if git_path: + return git_path + + # 3. Windows-specific: check common installation locations + if os.name == "nt": + common_paths = [ + os.path.expandvars(r"%PROGRAMFILES%\Git\cmd\git.exe"), + os.path.expandvars(r"%PROGRAMFILES%\Git\bin\git.exe"), + os.path.expandvars(r"%PROGRAMFILES(X86)%\Git\cmd\git.exe"), + os.path.expandvars(r"%LOCALAPPDATA%\Programs\Git\cmd\git.exe"), + r"C:\Program Files\Git\cmd\git.exe", + r"C:\Program Files (x86)\Git\cmd\git.exe", + ] + for path in common_paths: + try: + if os.path.isfile(path): + return path + except OSError: + continue + + # 4. Try 'where' command with shell=True (more reliable on Windows) + try: + result = subprocess.run( + "where git", + capture_output=True, + text=True, + timeout=5, + shell=True, + ) + if result.returncode == 0 and result.stdout.strip(): + found_path = result.stdout.strip().split("\n")[0].strip() + if found_path and os.path.isfile(found_path): + return found_path + except (subprocess.TimeoutExpired, OSError): + pass + + # Default fallback - let subprocess handle it (may fail) + return "git" + + +def run_git( + args: list[str], + cwd: Path | str | None = None, + timeout: int = 60, + input_data: str | None = None, +) -> subprocess.CompletedProcess: + """Run a git command with proper executable finding. + + Args: + args: Git command arguments (without 'git' prefix) + cwd: Working directory for the command + timeout: Command timeout in seconds (default: 60) + input_data: Optional string data to pass to stdin + + Returns: + CompletedProcess with command results. + """ + git = get_git_executable() + try: + return subprocess.run( + [git] + args, + cwd=cwd, + input=input_data, + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + ) + except subprocess.TimeoutExpired: + return subprocess.CompletedProcess( + args=[git] + args, + returncode=-1, + stdout="", + stderr=f"Command timed out after {timeout} seconds", + ) + except FileNotFoundError: + return subprocess.CompletedProcess( + args=[git] + args, + returncode=-1, + stdout="", + stderr="Git executable not found. Please ensure git is installed and in PATH.", + ) diff --git a/apps/backend/core/phase_event.py b/apps/backend/core/phase_event.py index a86321cf02..acc034605b 100644 --- a/apps/backend/core/phase_event.py +++ b/apps/backend/core/phase_event.py @@ -52,4 +52,8 @@ def emit_phase( print(f"{PHASE_MARKER_PREFIX}{json.dumps(payload, default=str)}", flush=True) except (OSError, UnicodeEncodeError) as e: if _DEBUG: - print(f"[phase_event] emit failed: {e}", file=sys.stderr, flush=True) + try: + sys.stderr.write(f"[phase_event] emit failed: {e}\n") + sys.stderr.flush() + except (OSError, UnicodeEncodeError): + pass # Truly silent on complete I/O failure diff --git a/apps/backend/core/plan_normalization.py b/apps/backend/core/plan_normalization.py new file mode 100644 index 0000000000..cef97d0b2b --- /dev/null +++ b/apps/backend/core/plan_normalization.py @@ -0,0 +1,50 @@ +""" +Implementation Plan Normalization Utilities +=========================================== + +Small helpers for normalizing common LLM/legacy field variants in +implementation_plan.json without changing status semantics. +""" + +from typing import Any + + +def normalize_subtask_aliases(subtask: dict[str, Any]) -> tuple[dict[str, Any], bool]: + """Normalize common subtask field aliases. + + - If `id` is missing and `subtask_id` exists, copy it into `id` as a string. + - If `description` is missing/empty and `title` is a non-empty string, copy it + into `description`. + """ + + normalized = dict(subtask) + changed = False + + id_value = normalized.get("id") + id_missing = ( + "id" not in normalized + or id_value is None + or (isinstance(id_value, str) and not id_value.strip()) + ) + if id_missing and "subtask_id" in normalized: + subtask_id = normalized.get("subtask_id") + if subtask_id is not None: + subtask_id_str = str(subtask_id).strip() + if subtask_id_str: + normalized["id"] = subtask_id_str + changed = True + + description_value = normalized.get("description") + description_missing = ( + "description" not in normalized + or description_value is None + or (isinstance(description_value, str) and not description_value.strip()) + ) + title = normalized.get("title") + if description_missing and isinstance(title, str): + title_str = title.strip() + if title_str: + normalized["description"] = title_str + changed = True + + return normalized, changed diff --git a/apps/backend/core/progress.py b/apps/backend/core/progress.py index 1e41604565..ce4f7f5089 100644 --- a/apps/backend/core/progress.py +++ b/apps/backend/core/progress.py @@ -11,6 +11,7 @@ import json from pathlib import Path +from core.plan_normalization import normalize_subtask_aliases from ui import ( Icons, bold, @@ -379,7 +380,7 @@ def get_current_phase(spec_dir: Path) -> dict | None: plan = json.load(f) for phase in plan.get("phases", []): - subtasks = phase.get("subtasks", []) + subtasks = phase.get("subtasks", phase.get("chunks", [])) # Phase is current if it has incomplete subtasks and dependencies are met has_incomplete = any(s.get("status") != "completed" for s in subtasks) if has_incomplete: @@ -415,24 +416,39 @@ def get_next_subtask(spec_dir: Path) -> dict | None: return None try: - with open(plan_file) as f: + with open(plan_file, encoding="utf-8") as f: plan = json.load(f) phases = plan.get("phases", []) # Build a map of phase completion - phase_complete = {} - for phase in phases: - phase_id = phase.get("id") or phase.get("phase") - subtasks = phase.get("subtasks", []) - phase_complete[phase_id] = all( + phase_complete: dict[str, bool] = {} + for i, phase in enumerate(phases): + phase_id_value = phase.get("id") + phase_id_raw = ( + phase_id_value if phase_id_value is not None else phase.get("phase") + ) + phase_id_key = ( + str(phase_id_raw) if phase_id_raw is not None else f"unknown:{i}" + ) + subtasks = phase.get("subtasks", phase.get("chunks", [])) + phase_complete[phase_id_key] = all( s.get("status") == "completed" for s in subtasks ) # Find next available subtask for phase in phases: - phase_id = phase.get("id") or phase.get("phase") - depends_on = phase.get("depends_on", []) + phase_id_value = phase.get("id") + phase_id = ( + phase_id_value if phase_id_value is not None else phase.get("phase") + ) + depends_on_raw = phase.get("depends_on", []) + if isinstance(depends_on_raw, list): + depends_on = [str(d) for d in depends_on_raw if d is not None] + elif depends_on_raw is None: + depends_on = [] + else: + depends_on = [str(depends_on_raw)] # Check if dependencies are satisfied deps_satisfied = all(phase_complete.get(dep, False) for dep in depends_on) @@ -440,13 +456,16 @@ def get_next_subtask(spec_dir: Path) -> dict | None: continue # Find first pending subtask in this phase - for subtask in phase.get("subtasks", []): - if subtask.get("status") == "pending": + for subtask in phase.get("subtasks", phase.get("chunks", [])): + status = subtask.get("status", "pending") + if status in {"pending", "not_started", "not started"}: + subtask_out, _changed = normalize_subtask_aliases(subtask) + subtask_out["status"] = "pending" return { + **subtask_out, "phase_id": phase_id, "phase_name": phase.get("name"), "phase_num": phase.get("phase"), - **subtask, } return None diff --git a/apps/backend/core/workspace.py b/apps/backend/core/workspace.py index ddfd49059b..cd1c8ad5cd 100644 --- a/apps/backend/core/workspace.py +++ b/apps/backend/core/workspace.py @@ -4,7 +4,7 @@ ============================================= Handles workspace isolation through Git worktrees, where each spec -gets its own isolated worktree in .worktrees/{spec-name}/. +gets its own isolated worktree in .auto-claude/worktrees/tasks/{spec-name}/. This module has been refactored for better maintainability: - Models and enums: workspace/models.py @@ -90,12 +90,18 @@ def is_debug_enabled(): from core.workspace.git_utils import ( detect_file_renames as _detect_file_renames, ) +from core.workspace.git_utils import ( + get_binary_file_content_from_ref as _get_binary_file_content_from_ref, +) from core.workspace.git_utils import ( get_changed_files_from_branch as _get_changed_files_from_branch, ) from core.workspace.git_utils import ( get_file_content_from_ref as _get_file_content_from_ref, ) +from core.workspace.git_utils import ( + is_binary_file as _is_binary_file, +) from core.workspace.git_utils import ( is_lock_file as _is_lock_file, ) @@ -239,14 +245,17 @@ def merge_existing_build( if smart_result is not None: # Smart merge handled it (success or identified conflicts) if smart_result.get("success"): - # Check if smart merge resolved git conflicts or path-mapped files + # Check if smart merge actually DID work (resolved conflicts via AI) + # NOTE: "files_merged" in stats is misleading - it's "files TO merge" not "files WERE merged" + # The smart merge preview returns this count but doesn't actually perform the merge + # in the no-conflict path. We only skip git merge if AI actually did work. stats = smart_result.get("stats", {}) had_conflicts = stats.get("conflicts_resolved", 0) > 0 - files_merged = stats.get("files_merged", 0) > 0 ai_assisted = stats.get("ai_assisted", 0) > 0 + direct_copy = stats.get("direct_copy", False) - if had_conflicts or files_merged or ai_assisted: - # Git conflicts were resolved OR path-mapped files were AI merged + if had_conflicts or ai_assisted or direct_copy: + # AI resolved conflicts, assisted with merges, or direct copy was used # Changes are already written and staged - no need for git merge _print_merge_success( no_commit, stats, spec_name=spec_name, keep_worktree=True @@ -258,7 +267,8 @@ def merge_existing_build( return True else: - # No conflicts and no files merged - do standard git merge + # No conflicts needed AI resolution - do standard git merge + # This is the common case: no divergence, just need to merge changes success_result = manager.merge_worktree( spec_name, delete_after=False, no_commit=no_commit ) @@ -267,6 +277,13 @@ def merge_existing_build( no_commit, stats, spec_name=spec_name, keep_worktree=True ) return True + else: + # Standard git merge failed - report error and don't continue + print() + print_status( + "Merge failed. Please check the errors above.", "error" + ) + return False elif smart_result.get("git_conflicts"): # Had git conflicts that AI couldn't fully resolve resolved = smart_result.get("resolved", []) @@ -490,6 +507,155 @@ def _try_smart_merge_inner( "error": resolution_result.get("error"), } + # Check if branches diverged but no actual conflicts (can do direct copy) + if git_conflicts.get("diverged_but_no_conflicts"): + debug(MODULE, "Branches diverged but no conflicts - doing direct file copy") + print(muted(" Branches diverged but no conflicts detected")) + print(muted(" Copying changed files directly from worktree...")) + + # Get changed files from spec branch + spec_branch = f"auto-claude/{spec_name}" + base_branch = git_conflicts.get("base_branch", "main") + + # Get merge-base for diff + merge_base_result = subprocess.run( + ["git", "merge-base", base_branch, spec_branch], + cwd=project_dir, + capture_output=True, + text=True, + ) + merge_base = ( + merge_base_result.stdout.strip() + if merge_base_result.returncode == 0 + else None + ) + + if merge_base: + # Get list of changed files in spec branch + changed_files = _get_changed_files_from_branch( + project_dir, merge_base, spec_branch + ) + + resolved_files = [] + skipped_files = [] # Track files that failed to copy + files_to_stage = [] + for file_path, status in changed_files: + if _is_auto_claude_file(file_path): + continue + + try: + target_path = project_dir / file_path + + if status == "D": + # Deleted in worktree + if target_path.exists(): + target_path.unlink() + files_to_stage.append(file_path) + resolved_files.append(file_path) + print(success(f" ✓ {file_path} (deleted)")) + else: + # New or modified - copy from spec branch + target_path.parent.mkdir(parents=True, exist_ok=True) + + if _is_binary_file(file_path): + binary_content = _get_binary_file_content_from_ref( + project_dir, spec_branch, file_path + ) + if binary_content is not None: + target_path.write_bytes(binary_content) + files_to_stage.append(file_path) + resolved_files.append(file_path) + status_label = ( + "new file" if status == "A" else "updated" + ) + print( + success(f" ✓ {file_path} ({status_label})") + ) + else: + skipped_files.append(file_path) + debug_warning( + MODULE, + f"Could not retrieve binary content for {file_path}", + ) + else: + content = _get_file_content_from_ref( + project_dir, spec_branch, file_path + ) + if content is not None: + target_path.write_text(content, encoding="utf-8") + files_to_stage.append(file_path) + resolved_files.append(file_path) + status_label = ( + "new file" if status == "A" else "updated" + ) + print( + success(f" ✓ {file_path} ({status_label})") + ) + else: + skipped_files.append(file_path) + debug_warning( + MODULE, + f"Could not retrieve content for {file_path}", + ) + + except Exception as e: + skipped_files.append(file_path) + debug_warning(MODULE, f"Could not copy {file_path}: {e}") + + # Stage all files in a single git add call for efficiency + if files_to_stage: + try: + subprocess.run( + ["git", "add"] + files_to_stage, + cwd=project_dir, + capture_output=True, + text=True, + check=True, + ) + except subprocess.CalledProcessError as e: + debug_warning( + MODULE, f"Failed to stage files for direct copy: {e.stderr}" + ) + # Return failure - files were written but not staged + return { + "success": False, + "error": f"Failed to stage files: {e.stderr}", + "resolved_files": [], + } + + # Build result - check for skipped files to detect partial merges + result = { + "success": len(skipped_files) == 0, + "resolved_files": resolved_files, + "stats": { + "files_merged": len(resolved_files), + "conflicts_resolved": 0, + "ai_assisted": 0, + "auto_merged": len(resolved_files), + "direct_copy": True, # Flag indicating direct copy was used + "skipped_count": len(skipped_files), + }, + } + if skipped_files: + result["skipped_files"] = skipped_files + result["partial_success"] = len(resolved_files) > 0 + print() + print( + warning( + f" ⚠ {len(skipped_files)} file(s) could not be retrieved:" + ) + ) + for skipped_file in skipped_files: + print(muted(f" - {skipped_file}")) + print(muted(" These files may need manual review.")) + return result + else: + # merge-base failed - branches may not share history + debug_warning( + MODULE, + "Could not find merge-base between branches - falling back to semantic analysis", + ) + # No git conflicts - proceed with semantic analysis debug(MODULE, "No git conflicts, proceeding with semantic analysis") preview = orchestrator.preview_merge([spec_name]) @@ -614,11 +780,10 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict: text=True, ) - # merge-tree returns exit code 1 if there are conflicts + # merge-tree returns exit code 1 if there are actual text conflicts + # Exit code 0 means clean merge possible if merge_tree_result.returncode != 0: - result["has_conflicts"] = True - - # Parse the output for conflicting files + # Parse the output for ACTUAL conflicting files (look for CONFLICT markers) output = merge_tree_result.stdout + merge_tree_result.stderr for line in output.split("\n"): if "CONFLICT" in line: @@ -636,38 +801,27 @@ def _check_git_conflicts(project_dir: Path, spec_name: str) -> dict: ): result["conflicting_files"].append(file_path) - # Fallback: if we didn't parse conflicts, use diff to find files changed in both branches - if not result["conflicting_files"]: - main_files_result = subprocess.run( - ["git", "diff", "--name-only", merge_base, main_commit], - cwd=project_dir, - capture_output=True, - text=True, - ) - main_files = ( - set(main_files_result.stdout.strip().split("\n")) - if main_files_result.stdout.strip() - else set() - ) - - spec_files_result = subprocess.run( - ["git", "diff", "--name-only", merge_base, spec_commit], - cwd=project_dir, - capture_output=True, - text=True, + # Only set has_conflicts if we found ACTUAL CONFLICT markers + # A non-zero exit code without CONFLICT markers just means branches diverged + # but git can auto-merge them - we handle this with direct file copy + if result["conflicting_files"]: + result["has_conflicts"] = True + debug( + MODULE, + f"Found {len(result['conflicting_files'])} actual git conflicts", + files=result["conflicting_files"], ) - spec_files = ( - set(spec_files_result.stdout.strip().split("\n")) - if spec_files_result.stdout.strip() - else set() + else: + # No CONFLICT markers = no actual conflicts + # Branches diverged but changes don't overlap - git can auto-merge + # We'll handle this by copying files directly from spec branch + debug( + MODULE, + "No CONFLICT markers - branches diverged but can be auto-merged", + merge_tree_returncode=merge_tree_result.returncode, ) - - # Files modified in both = potential conflicts - # Filter out .auto-claude files - they should never be merged - conflicting = main_files & spec_files - result["conflicting_files"] = [ - f for f in conflicting if not _is_auto_claude_file(f) - ] + result["has_conflicts"] = False + result["diverged_but_no_conflicts"] = True # Flag for direct copy except Exception as e: print(muted(f" Error checking git conflicts: {e}")) @@ -773,28 +927,44 @@ def _resolve_git_conflicts_with_ai( print(muted(f" Copying {len(new_files)} new file(s) first (dependencies)...")) for file_path, status in new_files: try: - content = _get_file_content_from_ref( - project_dir, spec_branch, file_path - ) - if content is not None: - # Apply path mapping - write to new location if file was renamed - target_file_path = _apply_path_mapping(file_path, path_mappings) - target_path = project_dir / target_file_path - target_path.parent.mkdir(parents=True, exist_ok=True) - target_path.write_text(content, encoding="utf-8") - subprocess.run( - ["git", "add", target_file_path], - cwd=project_dir, - capture_output=True, + # Apply path mapping - write to new location if file was renamed + target_file_path = _apply_path_mapping(file_path, path_mappings) + target_path = project_dir / target_file_path + target_path.parent.mkdir(parents=True, exist_ok=True) + + # Handle binary files differently - use bytes instead of text + if _is_binary_file(file_path): + binary_content = _get_binary_file_content_from_ref( + project_dir, spec_branch, file_path ) - resolved_files.append(target_file_path) - if target_file_path != file_path: - debug( - MODULE, - f"Copied new file with path mapping: {file_path} -> {target_file_path}", + if binary_content is not None: + target_path.write_bytes(binary_content) + subprocess.run( + ["git", "add", target_file_path], + cwd=project_dir, + capture_output=True, ) - else: - debug(MODULE, f"Copied new file: {file_path}") + resolved_files.append(target_file_path) + debug(MODULE, f"Copied new binary file: {file_path}") + else: + content = _get_file_content_from_ref( + project_dir, spec_branch, file_path + ) + if content is not None: + target_path.write_text(content, encoding="utf-8") + subprocess.run( + ["git", "add", target_file_path], + cwd=project_dir, + capture_output=True, + ) + resolved_files.append(target_file_path) + if target_file_path != file_path: + debug( + MODULE, + f"Copied new file with path mapping: {file_path} -> {target_file_path}", + ) + else: + debug(MODULE, f"Copied new file: {file_path}") except Exception as e: debug_warning(MODULE, f"Could not copy new file {file_path}: {e}") @@ -804,6 +974,7 @@ def _resolve_git_conflicts_with_ai( tuple[str, str | None] ] = [] # (file_path, merged_content or None for delete) lock_files_excluded: list[str] = [] # Lock files excluded from merge + auto_merged_simple: set[str] = set() # Files that were auto-merged via simple 3-way debug(MODULE, "Categorizing conflicting files for parallel processing") @@ -862,28 +1033,50 @@ def _resolve_git_conflicts_with_ai( f" {target_file_path}: lock file (excluded - will use main version)", ) else: - # Regular file - needs AI merge - # Store the TARGET path for writing, but track original for content retrieval - files_needing_ai_merge.append( - ParallelMergeTask( - file_path=target_file_path, # Use target path for writing - main_content=main_content, - worktree_content=worktree_content, - base_content=base_content, - spec_name=spec_name, - project_dir=project_dir, - ) - ) - debug( - MODULE, - f" {file_path}: needs AI merge" - + ( - f" (will write to {target_file_path})" - if target_file_path != file_path - else "" - ), + # File exists in both - try simple 3-way merge FIRST (no AI needed) + # This handles cases where: + # - Only one side changed from base (ours==base or theirs==base) + # - Both sides made identical changes (ours==theirs) + simple_success, simple_merged = _try_simple_3way_merge( + base_content, main_content, worktree_content ) + if simple_success and simple_merged is not None: + # Simple 3-way merge succeeded - no AI needed! + simple_merges.append((target_file_path, simple_merged)) + auto_merged_simple.add(target_file_path) # Track for stats + debug( + MODULE, + f" {file_path}: auto-merged (simple 3-way, no AI needed)" + + ( + f" (will write to {target_file_path})" + if target_file_path != file_path + else "" + ), + ) + else: + # Simple merge failed - needs AI merge + # Store the TARGET path for writing, but track original for content retrieval + files_needing_ai_merge.append( + ParallelMergeTask( + file_path=target_file_path, # Use target path for writing + main_content=main_content, + worktree_content=worktree_content, + base_content=base_content, + spec_name=spec_name, + project_dir=project_dir, + ) + ) + debug( + MODULE, + f" {file_path}: needs AI merge (both sides changed differently)" + + ( + f" (will write to {target_file_path})" + if target_file_path != file_path + else "" + ), + ) + except Exception as e: print(error(f" ✗ Failed to categorize {file_path}: {e}")) remaining_conflicts.append( @@ -907,7 +1100,18 @@ def _resolve_git_conflicts_with_ai( ["git", "add", file_path], cwd=project_dir, capture_output=True ) resolved_files.append(file_path) - print(success(f" ✓ {file_path} (new file)")) + # Show appropriate message based on merge type + if file_path in auto_merged_simple: + print(success(f" ✓ {file_path} (auto-merged)")) + auto_merged_count += 1 # Count for stats + elif file_path in lock_files_excluded: + print( + success( + f" ✓ {file_path} (lock file - kept main version)" + ) + ) + else: + print(success(f" ✓ {file_path} (new file)")) else: # Delete the file target_path = project_dir / file_path @@ -1118,24 +1322,44 @@ def _resolve_git_conflicts_with_ai( ) else: # Modified without path change - simple copy - content = _get_file_content_from_ref( - project_dir, spec_branch, file_path - ) - if content is not None: - target_path = project_dir / target_file_path - target_path.parent.mkdir(parents=True, exist_ok=True) - target_path.write_text(content, encoding="utf-8") - subprocess.run( - ["git", "add", target_file_path], - cwd=project_dir, - capture_output=True, + # Check if binary file to use correct read/write method + target_path = project_dir / target_file_path + target_path.parent.mkdir(parents=True, exist_ok=True) + + if _is_binary_file(file_path): + binary_content = _get_binary_file_content_from_ref( + project_dir, spec_branch, file_path ) - resolved_files.append(target_file_path) - if target_file_path != file_path: - debug( - MODULE, - f"Merged with path mapping: {file_path} -> {target_file_path}", + if binary_content is not None: + target_path.write_bytes(binary_content) + subprocess.run( + ["git", "add", target_file_path], + cwd=project_dir, + capture_output=True, + ) + resolved_files.append(target_file_path) + if target_file_path != file_path: + debug( + MODULE, + f"Merged binary with path mapping: {file_path} -> {target_file_path}", + ) + else: + content = _get_file_content_from_ref( + project_dir, spec_branch, file_path + ) + if content is not None: + target_path.write_text(content, encoding="utf-8") + subprocess.run( + ["git", "add", target_file_path], + cwd=project_dir, + capture_output=True, ) + resolved_files.append(target_file_path) + if target_file_path != file_path: + debug( + MODULE, + f"Merged with path mapping: {file_path} -> {target_file_path}", + ) except Exception as e: print(muted(f" Warning: Could not process {file_path}: {e}")) @@ -1153,6 +1377,9 @@ def _resolve_git_conflicts_with_ai( "conflicts_resolved": len(conflicting_files) - len(remaining_conflicts), "ai_assisted": ai_merged_count, "auto_merged": auto_merged_count, + "simple_3way_merged": len( + auto_merged_simple + ), # Files auto-merged without AI "parallel_ai_merges": len(files_needing_ai_merge), "lock_files_excluded": len(lock_files_excluded), }, @@ -1205,18 +1432,41 @@ def _resolve_git_conflicts_with_ai( _merge_logger = logging.getLogger(__name__) # System prompt for AI file merging -AI_MERGE_SYSTEM_PROMPT = """You are an expert code merge assistant. Your task is to perform a 3-way merge of code files. - -RULES: -1. Preserve all functional changes from both versions (ours and theirs) -2. Maintain code style consistency -3. Resolve conflicts by understanding the semantic purpose of each change -4. When changes are independent (different functions/sections), include both -5. When changes overlap, combine them logically or prefer the more complete version -6. Preserve all imports from both versions -7. Output ONLY the merged code - no explanations, no markdown, no code fences - -IMPORTANT: Output the raw merged file content only. Do not wrap in code blocks.""" +AI_MERGE_SYSTEM_PROMPT = """You are an expert code merge assistant specializing in intelligent 3-way merges. Your task is to merge code changes from two branches while preserving all meaningful changes. + +CONTEXT: +- "OURS" = current main branch (target for merge) +- "THEIRS" = task worktree branch (changes being merged in) +- "BASE" = common ancestor before changes + +MERGE STRATEGY: +1. **Preserve all functional changes** - Include all features, bug fixes, and improvements from both versions +2. **Combine independent changes** - If changes are in different functions/sections, include both +3. **Resolve overlapping changes intelligently**: + - Prefer the more complete/updated implementation + - Combine logic if both versions add value + - When in doubt, favor the version that better addresses the task's intent +4. **Maintain syntactic correctness** - Ensure the merged code is valid and compiles/runs +5. **Preserve imports and dependencies** from both versions + +HANDLING COMMON PATTERNS: +- New functions/classes: Include all from both versions +- Modified functions: Merge changes logically, prefer more complete version +- Imports: Union of all imports from both versions +- Comments/Documentation: Include relevant documentation from both +- Configuration: Merge settings, with conflict resolution favoring task-specific values + +CRITICAL RULES: +- Output ONLY the merged code - no explanations, no prose, no markdown fences +- If you cannot determine the correct merge, make a reasonable decision based on best practices +- Never output error messages like "I need more context" - always provide a best-effort merge +- Ensure the output is complete and syntactically valid code""" + +# Model constants for AI merge two-tier strategy (ACS-194) +MERGE_FAST_MODEL = "claude-haiku-4-5-20251001" # Fast model for simple merges +MERGE_CAPABLE_MODEL = "claude-sonnet-4-5-20250929" # Capable model for complex merges +MERGE_FAST_THINKING = 1024 # Lower thinking for fast/simple merges +MERGE_COMPLEX_THINKING = 16000 # Higher thinking for complex merges def _infer_language_from_path(file_path: str) -> str: @@ -1305,7 +1555,7 @@ def _build_merge_prompt( if len(base_content) > 10000: base_content = base_content[:10000] + "\n... (truncated)" base_section = f""" -BASE (common ancestor): +BASE (common ancestor before changes): ```{language} {base_content} ``` @@ -1317,20 +1567,22 @@ def _build_merge_prompt( if len(worktree_content) > 15000: worktree_content = worktree_content[:15000] + "\n... (truncated)" - prompt = f"""Perform a 3-way merge for file: {file_path} -Task being merged: {spec_name} + prompt = f"""FILE: {file_path} +TASK: {spec_name} + +This is a 3-way code merge. You must combine changes from both versions. {base_section} -OURS (current main branch): +OURS (current main branch - target for merge): ```{language} {main_content} ``` -THEIRS (changes from task worktree): +THEIRS (task worktree branch - changes being merged): ```{language} {worktree_content} ``` -Merge these versions, preserving all meaningful changes from both. Output only the merged file content, no explanations.""" +OUTPUT THE MERGED CODE ONLY. No explanations, no markdown fences.""" return prompt @@ -1348,6 +1600,112 @@ def _strip_code_fences(content: str) -> str: return content +async def _attempt_ai_merge( + task: "ParallelMergeTask", + prompt: str, + model: str = MERGE_FAST_MODEL, + max_thinking_tokens: int = MERGE_FAST_THINKING, +) -> tuple[bool, str | None, str]: + """ + Attempt an AI merge with a specific model. + + Args: + task: The merge task with file contents + prompt: The merge prompt + model: Model to use for merge + max_thinking_tokens: Max thinking tokens for the model + + Returns: + Tuple of (success, merged_content, error_message) + """ + try: + from core.simple_client import create_simple_client + except ImportError: + return False, None, "core.simple_client not available" + + client = create_simple_client( + agent_type="merge_resolver", + model=model, + system_prompt=AI_MERGE_SYSTEM_PROMPT, + max_thinking_tokens=max_thinking_tokens, + ) + + response_text = "" + async with client: + await client.query(prompt) + + async for msg in client.receive_response(): + msg_type = type(msg).__name__ + if msg_type == "AssistantMessage" and hasattr(msg, "content"): + for block in msg.content: + block_type = type(block).__name__ + if block_type == "TextBlock" and hasattr(block, "text"): + response_text += block.text + + if response_text: + merged_content = _strip_code_fences(response_text.strip()) + + # Check if AI returned natural language instead of code (case-insensitive) + # More robust detection: (1) Check if patterns are at START of line, (2) Check for + # absence of code patterns like imports, function definitions, braces, etc. + natural_language_patterns = [ + "i need to", + "let me", + "i cannot", + "i'm unable", + "the file appears", + "i don't have", + "unfortunately", + "i apologize", + ] + + first_line = merged_content.split("\n")[0] if merged_content else "" + first_line_stripped = first_line.lstrip() + first_line_lower = first_line_stripped.lower() + + # Check if first line STARTS with natural language pattern (not just contains it) + starts_with_prose = any( + first_line_lower.startswith(pattern) + for pattern in natural_language_patterns + ) + + # Also check for absence of common code patterns to reduce false positives + has_code_patterns = any( + pattern in merged_content[:500] # Check first 500 chars for code patterns + for pattern in [ + "import ", # Python/JS/TypeScript imports + "from ", # Python imports + "def ", # Python functions + "function ", # JavaScript functions + "const ", # JavaScript/TypeScript const + "class ", # Class definitions + "{", # Braces indicate code + "}", # Braces indicate code + "#!", # Shebang + "