Compare commits
112 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0f538624f | |||
| 52a4fcc6d3 | |||
| fb6b7fc6d2 | |||
| 0f9c5b8403 | |||
| 5d8ede2331 | |||
| da31b68774 | |||
| 2effa53517 | |||
| c15bb31146 | |||
| 203a970ab5 | |||
| 3c0708b749 | |||
| 666794b5fc | |||
| ac8dfcac7b | |||
| 798ca79ddb | |||
| bdb0154977 | |||
| 515b73b553 | |||
| 88c7605985 | |||
| 62a7551571 | |||
| 717fba0440 | |||
| 0a571d3a2b | |||
| 2f662469e9 | |||
| e7e6b52128 | |||
| 230de5fc03 | |||
| 4bdf7a0c5b | |||
| a39ea49d4d | |||
| 9aef0dd0b8 | |||
| 05131217cf | |||
| 321c971289 | |||
| 98b12ed807 | |||
| aaa83131f4 | |||
| 68548e33c8 | |||
| 7751588e56 | |||
| 8b4ce58c56 | |||
| bc22064535 | |||
| db0cbea3f2 | |||
| 0ca2e3f697 | |||
| 2d3b7fb4b6 | |||
| 9bbdef0949 | |||
| 753dc8bbe3 | |||
| 20f20fa321 | |||
| eabe7c7d1b | |||
| 1fa7a9c769 | |||
| 7881b2d1e9 | |||
| 4e71361b2d | |||
| 4dcc5afae8 | |||
| e9782db044 | |||
| 40d04d7c7d | |||
| fef07c9517 | |||
| 9d43abedde | |||
| d51f45621b | |||
| 787667e98e | |||
| 9734b70b05 | |||
| fec6b9f339 | |||
| d3a63b09fd | |||
| 50e3111ae2 | |||
| 8a80b1d5c8 | |||
| cb6b216534 | |||
| 661e47c341 | |||
| e80ef79d12 | |||
| e1b0f743bf | |||
| 92c6f2786d | |||
| 1c14227324 | |||
| c0a02a453d | |||
| 086429cb49 | |||
| d9fb8f29d5 | |||
| d0b0b3df0d | |||
| 937a60f8bd | |||
| 7a51cbd5e5 | |||
| 26beefe39d | |||
| 8416f3076a | |||
| 217249c8a3 | |||
| 8bb3df917e | |||
| 5106c6e9b2 | |||
| 3ff612742f | |||
| 7f19c2e1f3 | |||
| d98e28305d | |||
| 0b874d4b33 | |||
| 50dd10788a | |||
| f1cc5a09f2 | |||
| b005fa5c86 | |||
| d79f2da411 | |||
| 5ac566e2a2 | |||
| f49d4817b1 | |||
| 1e1d7d9b68 | |||
| e74a3dffd2 | |||
| 6ac8250b5b | |||
| a2cee6941f | |||
| d4cad80a73 | |||
| 596e95137b | |||
| d42041c5b5 | |||
| a3f87540c6 | |||
| f843811292 | |||
| 5e8c53080f | |||
| 348de6dfe7 | |||
| 0f7d6e0530 | |||
| 5ccdb6abc5 | |||
| 6ec8549f63 | |||
| 53527293cd | |||
| 02bef954f3 | |||
| f168bdc3ac | |||
| e3eec68aab | |||
| 407a0bee5e | |||
| 8f766ad16e | |||
| ced2ad479f | |||
| 05f5d3038b | |||
| 6951251b33 | |||
| 30e7536b63 | |||
| 220faf0fb4 | |||
| f96c6301f4 | |||
| ebd8340d82 | |||
| df779530e7 | |||
| 0adaddacaa | |||
| 91f7051dda |
@@ -71,11 +71,6 @@ jobs:
|
||||
# Use tag for real releases, develop branch for dry runs
|
||||
ref: ${{ github.event.inputs.dry_run == 'true' && 'develop' || format('v{0}', needs.create-tag.outputs.version) }}
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -97,28 +92,16 @@ 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-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8-rust
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-rust-
|
||||
python-bundle-${{ runner.os }}-x64-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package macOS (Intel)
|
||||
run: |
|
||||
@@ -128,9 +111,6 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Notarize macOS Intel app
|
||||
env:
|
||||
@@ -173,11 +153,6 @@ jobs:
|
||||
# Use tag for real releases, develop branch for dry runs
|
||||
ref: ${{ github.event.inputs.dry_run == 'true' && 'develop' || format('v{0}', needs.create-tag.outputs.version) }}
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -196,28 +171,16 @@ 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-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
key: python-bundle-${{ runner.os }}-arm64-3.12.8
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-arm64-3.12.8-
|
||||
python-bundle-${{ runner.os }}-arm64-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package macOS (Apple Silicon)
|
||||
run: |
|
||||
@@ -227,9 +190,6 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Notarize macOS ARM64 app
|
||||
env:
|
||||
@@ -265,23 +225,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:
|
||||
# Use tag for real releases, develop branch for dry runs
|
||||
ref: ${{ github.event.inputs.dry_run == 'true' && 'develop' || format('v{0}', needs.create-tag.outputs.version) }}
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -301,28 +250,16 @@ 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-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-
|
||||
python-bundle-${{ runner.os }}-x64-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package Windows
|
||||
shell: bash
|
||||
@@ -331,122 +268,8 @@ jobs:
|
||||
cd apps/frontend && npm run package:win -- --config.extraMetadata.version="$VERSION"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Disable electron-builder's built-in signing (we use Azure Trusted Signing instead)
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: false
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Azure Login (OIDC)
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Sign Windows executable with Azure Trusted Signing
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
uses: azure/trusted-signing-action@v0.5.11
|
||||
with:
|
||||
endpoint: https://neu.codesigning.azure.net/
|
||||
trusted-signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
certificate-profile-name: ${{ secrets.AZURE_CERTIFICATE_PROFILE }}
|
||||
files-folder: apps/frontend/dist
|
||||
files-folder-filter: exe
|
||||
file-digest: SHA256
|
||||
timestamp-rfc3161: http://timestamp.acs.microsoft.com
|
||||
timestamp-digest: SHA256
|
||||
|
||||
- name: Verify Windows executable is signed
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
cd apps/frontend/dist
|
||||
$exeFile = Get-ChildItem -Filter "*.exe" | Select-Object -First 1
|
||||
if ($exeFile) {
|
||||
Write-Host "Verifying signature on $($exeFile.Name)..."
|
||||
$sig = Get-AuthenticodeSignature -FilePath $exeFile.FullName
|
||||
if ($sig.Status -ne 'Valid') {
|
||||
Write-Host "::error::Signature verification failed: $($sig.Status)"
|
||||
Write-Host "::error::Status Message: $($sig.StatusMessage)"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "✅ Signature verified successfully"
|
||||
Write-Host " Subject: $($sig.SignerCertificate.Subject)"
|
||||
Write-Host " Issuer: $($sig.SignerCertificate.Issuer)"
|
||||
Write-Host " Thumbprint: $($sig.SignerCertificate.Thumbprint)"
|
||||
} else {
|
||||
Write-Host "::error::No .exe file found to verify"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Regenerate checksums after signing
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
cd apps/frontend/dist
|
||||
|
||||
# Find the installer exe (electron-builder names it with "Setup" or just the app name)
|
||||
# electron-builder produces one installer exe per build
|
||||
$exeFiles = Get-ChildItem -Filter "*.exe"
|
||||
if ($exeFiles.Count -eq 0) {
|
||||
Write-Host "::error::No .exe files found in dist folder"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Found $($exeFiles.Count) exe file(s): $($exeFiles.Name -join ', ')"
|
||||
|
||||
$ymlFile = "latest.yml"
|
||||
if (-not (Test-Path $ymlFile)) {
|
||||
Write-Host "::error::$ymlFile not found - cannot update checksums"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$content = Get-Content $ymlFile -Raw
|
||||
$originalContent = $content
|
||||
|
||||
# Process each exe file and update its hash in latest.yml
|
||||
foreach ($exeFile in $exeFiles) {
|
||||
Write-Host "Processing $($exeFile.Name)..."
|
||||
|
||||
# Compute SHA512 hash and convert to base64 (electron-builder format)
|
||||
$bytes = [System.IO.File]::ReadAllBytes($exeFile.FullName)
|
||||
$sha512 = [System.Security.Cryptography.SHA512]::Create()
|
||||
$hashBytes = $sha512.ComputeHash($bytes)
|
||||
$hash = [System.Convert]::ToBase64String($hashBytes)
|
||||
$size = $exeFile.Length
|
||||
|
||||
Write-Host " Hash: $hash"
|
||||
Write-Host " Size: $size"
|
||||
}
|
||||
|
||||
# For electron-builder, latest.yml has a single file entry for the installer
|
||||
# Update the sha512 and size for the primary exe (first one, typically the installer)
|
||||
$primaryExe = $exeFiles | Select-Object -First 1
|
||||
$bytes = [System.IO.File]::ReadAllBytes($primaryExe.FullName)
|
||||
$sha512 = [System.Security.Cryptography.SHA512]::Create()
|
||||
$hashBytes = $sha512.ComputeHash($bytes)
|
||||
$hash = [System.Convert]::ToBase64String($hashBytes)
|
||||
$size = $primaryExe.Length
|
||||
|
||||
# Update sha512 hash (base64 pattern: alphanumeric, +, /, =)
|
||||
$content = $content -replace 'sha512: [A-Za-z0-9+/=]+', "sha512: $hash"
|
||||
# Update size
|
||||
$content = $content -replace 'size: \d+', "size: $size"
|
||||
|
||||
if ($content -eq $originalContent) {
|
||||
Write-Host "::error::Checksum replacement failed - content unchanged. Check if latest.yml format has changed."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Set-Content -Path $ymlFile -Value $content -NoNewline
|
||||
Write-Host "✅ Updated $ymlFile with new base64 hash and size for $($primaryExe.Name)"
|
||||
|
||||
- name: Skip signing notice
|
||||
if: env.AZURE_CLIENT_ID == ''
|
||||
run: echo "::warning::Windows signing skipped - AZURE_CLIENT_ID not configured. The .exe will be unsigned."
|
||||
CSC_LINK: ${{ secrets.WIN_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -465,11 +288,6 @@ jobs:
|
||||
# Use tag for real releases, develop branch for dry runs
|
||||
ref: ${{ github.event.inputs.dry_run == 'true' && 'develop' || format('v{0}', needs.create-tag.outputs.version) }}
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -497,28 +315,16 @@ 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-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-
|
||||
python-bundle-${{ runner.os }}-x64-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package Linux
|
||||
run: |
|
||||
@@ -526,9 +332,6 @@ jobs:
|
||||
cd apps/frontend && npm run package:linux -- --config.extraMetadata.version="$VERSION"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
name: PR Auto Label
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: pr-auto-label-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
name: Auto Label PR
|
||||
runs-on: ubuntu-latest
|
||||
# Don't run on fork PRs (they can't write labels)
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Auto-label PR
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const pr = context.payload.pull_request;
|
||||
const prNumber = pr.number;
|
||||
const title = pr.title;
|
||||
|
||||
console.log(`::group::PR #${prNumber} - Auto-labeling`);
|
||||
console.log(`Title: ${title}`);
|
||||
|
||||
const labelsToAdd = new Set();
|
||||
const labelsToRemove = new Set();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// TYPE LABELS (from PR title - Conventional Commits)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
const typeMap = {
|
||||
'feat': 'feature',
|
||||
'fix': 'bug',
|
||||
'docs': 'documentation',
|
||||
'refactor': 'refactor',
|
||||
'test': 'test',
|
||||
'ci': 'ci',
|
||||
'chore': 'chore',
|
||||
'perf': 'performance',
|
||||
'style': 'style',
|
||||
'build': 'build'
|
||||
};
|
||||
|
||||
const typeMatch = title.match(/^(\w+)(\(.+?\))?(!)?:/);
|
||||
if (typeMatch) {
|
||||
const type = typeMatch[1].toLowerCase();
|
||||
const isBreaking = typeMatch[3] === '!';
|
||||
|
||||
if (typeMap[type]) {
|
||||
labelsToAdd.add(typeMap[type]);
|
||||
console.log(` 📝 Type: ${type} → ${typeMap[type]}`);
|
||||
}
|
||||
|
||||
if (isBreaking) {
|
||||
labelsToAdd.add('breaking-change');
|
||||
console.log(` ⚠️ Breaking change detected`);
|
||||
}
|
||||
} else {
|
||||
console.log(` ⚠️ No conventional commit prefix found in title`);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// AREA LABELS (from changed files)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
let files = [];
|
||||
try {
|
||||
const { data } = await github.rest.pulls.listFiles({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100
|
||||
});
|
||||
files = data;
|
||||
} catch (e) {
|
||||
console.log(` ⚠️ Could not fetch files: ${e.message}`);
|
||||
}
|
||||
|
||||
const areas = {
|
||||
frontend: false,
|
||||
backend: false,
|
||||
ci: false,
|
||||
docs: false,
|
||||
tests: false
|
||||
};
|
||||
|
||||
for (const file of files) {
|
||||
const path = file.filename;
|
||||
if (path.startsWith('apps/frontend/')) areas.frontend = true;
|
||||
if (path.startsWith('apps/backend/')) areas.backend = true;
|
||||
if (path.startsWith('.github/')) areas.ci = true;
|
||||
if (path.endsWith('.md') || path.startsWith('docs/')) areas.docs = true;
|
||||
if (path.startsWith('tests/') || path.includes('.test.') || path.includes('.spec.')) areas.tests = true;
|
||||
}
|
||||
|
||||
// Determine area label (mutually exclusive)
|
||||
const areaLabels = ['area/frontend', 'area/backend', 'area/fullstack', 'area/ci'];
|
||||
|
||||
if (areas.frontend && areas.backend) {
|
||||
labelsToAdd.add('area/fullstack');
|
||||
areaLabels.filter(l => l !== 'area/fullstack').forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📁 Area: fullstack (${files.length} files)`);
|
||||
} else if (areas.frontend) {
|
||||
labelsToAdd.add('area/frontend');
|
||||
areaLabels.filter(l => l !== 'area/frontend').forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📁 Area: frontend (${files.length} files)`);
|
||||
} else if (areas.backend) {
|
||||
labelsToAdd.add('area/backend');
|
||||
areaLabels.filter(l => l !== 'area/backend').forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📁 Area: backend (${files.length} files)`);
|
||||
} else if (areas.ci) {
|
||||
labelsToAdd.add('area/ci');
|
||||
areaLabels.filter(l => l !== 'area/ci').forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📁 Area: ci (${files.length} files)`);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SIZE LABELS (from lines changed)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
const additions = pr.additions || 0;
|
||||
const deletions = pr.deletions || 0;
|
||||
const totalLines = additions + deletions;
|
||||
|
||||
const sizeLabels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'];
|
||||
let sizeLabel;
|
||||
|
||||
if (totalLines < 10) sizeLabel = 'size/XS';
|
||||
else if (totalLines < 100) sizeLabel = 'size/S';
|
||||
else if (totalLines < 500) sizeLabel = 'size/M';
|
||||
else if (totalLines < 1000) sizeLabel = 'size/L';
|
||||
else sizeLabel = 'size/XL';
|
||||
|
||||
labelsToAdd.add(sizeLabel);
|
||||
sizeLabels.filter(l => l !== sizeLabel).forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📏 Size: ${sizeLabel} (+${additions}/-${deletions} = ${totalLines} lines)`);
|
||||
|
||||
console.log('::endgroup::');
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// APPLY LABELS
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
console.log(`::group::Applying labels`);
|
||||
|
||||
// Remove old labels (in parallel)
|
||||
const removeArray = [...labelsToRemove].filter(l => !labelsToAdd.has(l));
|
||||
if (removeArray.length > 0) {
|
||||
const removePromises = removeArray.map(async (label) => {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: label
|
||||
});
|
||||
console.log(` ✓ Removed: ${label}`);
|
||||
} catch (e) {
|
||||
if (e.status !== 404) {
|
||||
console.log(` ⚠ Could not remove ${label}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
await Promise.all(removePromises);
|
||||
}
|
||||
|
||||
// Add new labels
|
||||
const addArray = [...labelsToAdd];
|
||||
if (addArray.length > 0) {
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: addArray
|
||||
});
|
||||
console.log(` ✓ Added: ${addArray.join(', ')}`);
|
||||
} catch (e) {
|
||||
// Some labels might not exist
|
||||
if (e.status === 404) {
|
||||
core.warning(`Some labels do not exist. Please create them in repository settings.`);
|
||||
// Try adding one by one
|
||||
for (const label of addArray) {
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: [label]
|
||||
});
|
||||
} catch (e2) {
|
||||
console.log(` ⚠ Label '${label}' does not exist`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('::endgroup::');
|
||||
|
||||
// Summary
|
||||
console.log(`✅ PR #${prNumber} labeled: ${addArray.join(', ')}`);
|
||||
|
||||
// Write job summary
|
||||
core.summary
|
||||
.addHeading(`PR #${prNumber} Auto-Labels`, 3)
|
||||
.addTable([
|
||||
[{data: 'Category', header: true}, {data: 'Label', header: true}],
|
||||
['Type', typeMatch ? typeMap[typeMatch[1].toLowerCase()] || 'none' : 'none'],
|
||||
['Area', areas.frontend && areas.backend ? 'fullstack' : areas.frontend ? 'frontend' : areas.backend ? 'backend' : 'other'],
|
||||
['Size', sizeLabel]
|
||||
])
|
||||
.addRaw(`\n**Files changed:** ${files.length}\n`)
|
||||
.addRaw(`**Lines:** +${additions} / -${deletions}\n`);
|
||||
await core.summary.write();
|
||||
@@ -1,320 +0,0 @@
|
||||
name: PR Labeler
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
concurrency:
|
||||
group: pr-labeler-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
name: Auto Label PR
|
||||
runs-on: ubuntu-latest
|
||||
# Security: Prevent fork PRs from modifying labels (they don't have write access)
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Label PR
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CONFIGURATION - Single source of truth for all settings
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const CONFIG = {
|
||||
// Size thresholds (lines changed)
|
||||
SIZE_THRESHOLDS: {
|
||||
XS: 10,
|
||||
S: 100,
|
||||
M: 500,
|
||||
L: 1000
|
||||
},
|
||||
|
||||
// Conventional commit type mappings
|
||||
TYPE_MAP: Object.freeze({
|
||||
'feat': 'feature',
|
||||
'fix': 'bug',
|
||||
'docs': 'documentation',
|
||||
'refactor': 'refactor',
|
||||
'test': 'test',
|
||||
'ci': 'ci',
|
||||
'chore': 'chore',
|
||||
'perf': 'performance',
|
||||
'style': 'style',
|
||||
'build': 'build'
|
||||
}),
|
||||
|
||||
// Area detection paths
|
||||
AREA_PATHS: Object.freeze({
|
||||
frontend: 'apps/frontend/',
|
||||
backend: 'apps/backend/',
|
||||
ci: '.github/'
|
||||
}),
|
||||
|
||||
// Label definitions
|
||||
LABELS: Object.freeze({
|
||||
SIZE: ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'],
|
||||
AREA: ['area/frontend', 'area/backend', 'area/fullstack', 'area/ci'],
|
||||
STATUS: ['🔄 Checking', '✅ Ready for Review', '❌ Checks Failed'],
|
||||
REVIEW: ['Missing AC Approval', 'AC: Approved', 'AC: Changes Requested', 'AC: Needs Re-review']
|
||||
}),
|
||||
|
||||
// Pagination
|
||||
MAX_FILES_PER_PAGE: 100
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// HELPER FUNCTIONS - Small, focused, single responsibility
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Safely parse conventional commit type from PR title
|
||||
* @param {string} title - PR title
|
||||
* @returns {{type: string|null, isBreaking: boolean}}
|
||||
*/
|
||||
function parseConventionalCommit(title) {
|
||||
if (!title || typeof title !== 'string') {
|
||||
return { type: null, isBreaking: false };
|
||||
}
|
||||
|
||||
// Limit input length to prevent ReDoS attacks
|
||||
const safeTitle = title.slice(0, 200);
|
||||
const match = safeTitle.match(/^(\w{1,20})(\([^)]{0,50}\))?(!)?:/);
|
||||
|
||||
if (!match) {
|
||||
return { type: null, isBreaking: false };
|
||||
}
|
||||
|
||||
return {
|
||||
type: match[1].toLowerCase(),
|
||||
isBreaking: match[3] === '!'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine size label based on lines changed
|
||||
* @param {number} totalLines - Total lines changed
|
||||
* @returns {string} Size label
|
||||
*/
|
||||
function determineSizeLabel(totalLines) {
|
||||
const { SIZE_THRESHOLDS } = CONFIG;
|
||||
|
||||
if (totalLines < SIZE_THRESHOLDS.XS) return 'size/XS';
|
||||
if (totalLines < SIZE_THRESHOLDS.S) return 'size/S';
|
||||
if (totalLines < SIZE_THRESHOLDS.M) return 'size/M';
|
||||
if (totalLines < SIZE_THRESHOLDS.L) return 'size/L';
|
||||
return 'size/XL';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect areas affected by file changes
|
||||
* @param {Array} files - List of changed files
|
||||
* @returns {{frontend: boolean, backend: boolean, ci: boolean}}
|
||||
*/
|
||||
function detectAreas(files) {
|
||||
const areas = { frontend: false, backend: false, ci: false };
|
||||
const { AREA_PATHS } = CONFIG;
|
||||
|
||||
for (const file of files) {
|
||||
const path = file.filename || '';
|
||||
if (path.startsWith(AREA_PATHS.frontend)) areas.frontend = true;
|
||||
if (path.startsWith(AREA_PATHS.backend)) areas.backend = true;
|
||||
if (path.startsWith(AREA_PATHS.ci)) areas.ci = true;
|
||||
}
|
||||
|
||||
return areas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine area label based on detected areas
|
||||
* @param {{frontend: boolean, backend: boolean, ci: boolean}} areas
|
||||
* @returns {string|null} Area label or null
|
||||
*/
|
||||
function determineAreaLabel(areas) {
|
||||
if (areas.frontend && areas.backend) return 'area/fullstack';
|
||||
if (areas.frontend) return 'area/frontend';
|
||||
if (areas.backend) return 'area/backend';
|
||||
if (areas.ci) return 'area/ci';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove labels from PR (with error handling)
|
||||
* @param {Array} labels - Labels to remove
|
||||
* @param {number} prNumber - PR number
|
||||
*/
|
||||
async function removeLabels(labels, prNumber) {
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
await Promise.allSettled(labels.map(async (label) => {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: label
|
||||
});
|
||||
console.log(` ✓ Removed: ${label}`);
|
||||
} catch (e) {
|
||||
// 404 means label wasn't present - that's fine
|
||||
if (e.status !== 404) {
|
||||
console.log(` ⚠ Failed to remove ${label}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add labels to PR (with error handling)
|
||||
* @param {Array} labels - Labels to add
|
||||
* @param {number} prNumber - PR number
|
||||
*/
|
||||
async function addLabels(labels, prNumber) {
|
||||
if (labels.length === 0) return;
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels
|
||||
});
|
||||
console.log(` ✓ Added: ${labels.join(', ')}`);
|
||||
} catch (e) {
|
||||
if (e.status === 404) {
|
||||
core.warning(`One or more labels do not exist. Create them in repository settings.`);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch PR files with full pagination support
|
||||
* @param {number} prNumber - PR number
|
||||
* @returns {Array} List of all files (paginated)
|
||||
*/
|
||||
async function fetchPRFiles(prNumber) {
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
try {
|
||||
// Use paginate to fetch ALL files, not just first 100
|
||||
const files = await github.paginate(
|
||||
github.rest.pulls.listFiles,
|
||||
{ owner, repo, pull_number: prNumber, per_page: CONFIG.MAX_FILES_PER_PAGE }
|
||||
);
|
||||
return files;
|
||||
} catch (e) {
|
||||
console.log(` ⚠ Could not fetch files: ${e.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// MAIN LOGIC - Orchestrates the labeling process
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const pr = context.payload.pull_request;
|
||||
const prNumber = pr.number;
|
||||
const title = pr.title || '';
|
||||
const isNewPR = context.payload.action === 'opened' || context.payload.action === 'reopened';
|
||||
|
||||
console.log(`::group::PR #${prNumber} - Auto-labeling`);
|
||||
console.log(`Title: ${title.slice(0, 100)}${title.length > 100 ? '...' : ''}`);
|
||||
console.log(`Action: ${context.payload.action}`);
|
||||
|
||||
const labelsToAdd = new Set();
|
||||
const labelsToRemove = new Set();
|
||||
|
||||
// 1. Parse conventional commit type
|
||||
const { type, isBreaking } = parseConventionalCommit(title);
|
||||
if (type && CONFIG.TYPE_MAP[type]) {
|
||||
labelsToAdd.add(CONFIG.TYPE_MAP[type]);
|
||||
console.log(` 📝 Type: ${type} → ${CONFIG.TYPE_MAP[type]}`);
|
||||
} else {
|
||||
console.log(` ℹ️ No conventional commit prefix detected`);
|
||||
}
|
||||
|
||||
if (isBreaking) {
|
||||
labelsToAdd.add('breaking-change');
|
||||
console.log(` ⚠️ Breaking change detected`);
|
||||
}
|
||||
|
||||
// 2. Detect areas from changed files
|
||||
const files = await fetchPRFiles(prNumber);
|
||||
const areas = detectAreas(files);
|
||||
const areaLabel = determineAreaLabel(areas);
|
||||
|
||||
if (areaLabel) {
|
||||
labelsToAdd.add(areaLabel);
|
||||
CONFIG.LABELS.AREA.filter(l => l !== areaLabel).forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📁 Area: ${areaLabel.replace('area/', '')}`);
|
||||
}
|
||||
|
||||
// 3. Calculate size label
|
||||
const totalLines = (pr.additions || 0) + (pr.deletions || 0);
|
||||
const sizeLabel = determineSizeLabel(totalLines);
|
||||
labelsToAdd.add(sizeLabel);
|
||||
CONFIG.LABELS.SIZE.filter(l => l !== sizeLabel).forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📏 Size: ${sizeLabel} (${totalLines} lines)`);
|
||||
|
||||
// 4. Set status label (only on new PRs - let pr-status-gate handle updates on pushes)
|
||||
// Note: On synchronize events, CI workflows will trigger pr-status-gate when they complete
|
||||
if (isNewPR) {
|
||||
labelsToAdd.add('🔄 Checking');
|
||||
CONFIG.LABELS.STATUS.filter(l => l !== '🔄 Checking').forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 🔄 Status: Checking`);
|
||||
} else {
|
||||
console.log(` ℹ️ Status: Unchanged (will be updated by pr-status-gate)`);
|
||||
}
|
||||
|
||||
// 5. Add review label for new PRs only
|
||||
if (isNewPR) {
|
||||
labelsToAdd.add('Missing AC Approval');
|
||||
console.log(` ⏳ Review: Missing AC Approval`);
|
||||
}
|
||||
|
||||
console.log('::endgroup::');
|
||||
|
||||
// 6. Apply label changes
|
||||
console.log(`::group::Applying labels`);
|
||||
|
||||
// Remove labels that should be replaced (exclude ones we're adding)
|
||||
const removeList = [...labelsToRemove].filter(l => !labelsToAdd.has(l));
|
||||
await removeLabels(removeList, prNumber);
|
||||
|
||||
// Add new labels
|
||||
await addLabels([...labelsToAdd], prNumber);
|
||||
|
||||
console.log('::endgroup::');
|
||||
console.log(`✅ PR #${prNumber} labeled successfully`);
|
||||
|
||||
// 7. Write job summary
|
||||
const summaryType = type ? CONFIG.TYPE_MAP[type] || 'unknown' : 'none';
|
||||
const summaryArea = areaLabel ? areaLabel.replace('area/', '') : 'other';
|
||||
|
||||
await core.summary
|
||||
.addHeading(`PR #${prNumber} Auto-Labels`, 3)
|
||||
.addTable([
|
||||
[{ data: 'Category', header: true }, { data: 'Label', header: true }],
|
||||
['Type', summaryType],
|
||||
['Area', summaryArea],
|
||||
['Size', sizeLabel],
|
||||
['Status', isNewPR ? '🔄 Checking' : '(unchanged)'],
|
||||
['Review', isNewPR ? 'Missing AC Approval' : '(unchanged)']
|
||||
])
|
||||
.addRaw(`\n**Files:** ${files.length} | **Lines:** +${pr.additions || 0} / -${pr.deletions || 0}\n`)
|
||||
.write();
|
||||
@@ -0,0 +1,72 @@
|
||||
name: PR Status Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: pr-status-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
mark-checking:
|
||||
name: Set Checking Status
|
||||
runs-on: ubuntu-latest
|
||||
# Don't run on fork PRs (they can't write labels)
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Update PR status label
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const statusLabels = ['🔄 Checking', '✅ Ready for Review', '❌ Checks Failed'];
|
||||
|
||||
console.log(`::group::PR #${prNumber} - Setting status to Checking`);
|
||||
|
||||
// Remove old status labels (parallel for speed)
|
||||
const removePromises = statusLabels.map(async (label) => {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: label
|
||||
});
|
||||
console.log(` ✓ Removed: ${label}`);
|
||||
} catch (e) {
|
||||
if (e.status !== 404) {
|
||||
console.log(` ⚠ Could not remove ${label}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(removePromises);
|
||||
|
||||
// Add checking label
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: ['🔄 Checking']
|
||||
});
|
||||
console.log(` ✓ Added: 🔄 Checking`);
|
||||
} catch (e) {
|
||||
// Label might not exist - create helpful error
|
||||
if (e.status === 404) {
|
||||
core.warning(`Label '🔄 Checking' does not exist. Please create it in repository settings.`);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log('::endgroup::');
|
||||
console.log(`✅ PR #${prNumber} marked as checking`);
|
||||
@@ -2,584 +2,194 @@ name: PR Status Gate
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI, Lint, Quality Security]
|
||||
workflows: [CI, Lint, Quality Security, CLA Assistant, Quality Commit Lint]
|
||||
types: [completed]
|
||||
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
|
||||
pull_request:
|
||||
types: [synchronize]
|
||||
|
||||
concurrency:
|
||||
group: pr-status-gate-${{ github.event.workflow_run.pull_requests[0].number || github.event.issue.number || github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
checks: read
|
||||
|
||||
env:
|
||||
# Shared configuration - single source of truth
|
||||
REQUIRED_CHECKS: |
|
||||
CI / test-frontend
|
||||
CI / test-python (3.12)
|
||||
CI / test-python (3.13)
|
||||
Lint / python
|
||||
Quality Security / CodeQL (javascript-typescript)
|
||||
Quality Security / CodeQL (python)
|
||||
Quality Security / Python Security (Bandit)
|
||||
Quality Security / Security Summary
|
||||
|
||||
jobs:
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# JOB 1: CI STATUS (triggered by workflow_run)
|
||||
# Updates CI status labels when monitored workflows complete
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
update-ci-status:
|
||||
name: Update CI Status
|
||||
update-status:
|
||||
name: Update PR Status
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0] != null
|
||||
# Only run if this workflow_run is associated with a PR
|
||||
if: github.event.workflow_run.pull_requests[0] != null
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Check all required checks and update label
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
REQUIRED_CHECKS: ${{ env.REQUIRED_CHECKS }}
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
// NOTE: STATUS_LABELS is intentionally duplicated across jobs.
|
||||
// GitHub Actions jobs run in isolated contexts and cannot share runtime constants.
|
||||
// If label values change, update ALL occurrences: update-ci-status, check-status-command
|
||||
const STATUS_LABELS = Object.freeze({
|
||||
CHECKING: '🔄 Checking',
|
||||
PASSED: '✅ Ready for Review',
|
||||
FAILED: '❌ Checks Failed'
|
||||
});
|
||||
|
||||
const REQUIRED_CHECKS = process.env.REQUIRED_CHECKS
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
async function fetchCheckRuns(sha) {
|
||||
const { owner, repo } = context.repo;
|
||||
// Let the configured retries (retries: 3) handle transient failures
|
||||
// Don't catch errors - allow them to propagate for retry logic
|
||||
const checkRuns = await github.paginate(
|
||||
github.rest.checks.listForRef,
|
||||
{ owner, repo, ref: sha, per_page: 100 },
|
||||
(response) => response.data
|
||||
);
|
||||
return checkRuns;
|
||||
}
|
||||
|
||||
function analyzeChecks(checkRuns) {
|
||||
const results = [];
|
||||
let allComplete = true;
|
||||
let anyFailed = false;
|
||||
|
||||
for (const checkName of REQUIRED_CHECKS) {
|
||||
const check = checkRuns.find(c => c.name === checkName);
|
||||
|
||||
if (!check) {
|
||||
results.push({ name: checkName, status: '⏳ Pending', complete: false });
|
||||
allComplete = false;
|
||||
} else if (check.status !== 'completed') {
|
||||
results.push({ name: checkName, status: '🔄 Running', complete: false });
|
||||
allComplete = false;
|
||||
} else if (check.conclusion === 'success') {
|
||||
results.push({ name: checkName, status: '✅ Passed', complete: true });
|
||||
} else if (check.conclusion === 'skipped') {
|
||||
results.push({ name: checkName, status: '⏭️ Skipped', complete: true, skipped: true });
|
||||
} else {
|
||||
results.push({ name: checkName, status: '❌ Failed', complete: true, failed: true });
|
||||
anyFailed = true;
|
||||
}
|
||||
}
|
||||
return { allComplete, anyFailed, results };
|
||||
}
|
||||
|
||||
async function updateStatusLabels(prNumber, newLabel) {
|
||||
const { owner, repo } = context.repo;
|
||||
const allLabels = Object.values(STATUS_LABELS);
|
||||
|
||||
// Remove all status labels first - throw on non-404 errors to prevent conflicting labels
|
||||
for (const label of allLabels) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label });
|
||||
} catch (e) {
|
||||
if (e && e.status !== 404) {
|
||||
// Throw to prevent adding new label if removal failed (could cause conflicting labels)
|
||||
throw new Error(`Failed to remove label '${label}': ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [newLabel] });
|
||||
} catch (e) {
|
||||
if (e && e.status === 404) {
|
||||
core.warning(`Label '${newLabel}' does not exist`);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main logic
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.workflow_run.pull_requests[0].number;
|
||||
const headSha = context.payload.workflow_run.head_sha;
|
||||
const triggerWorkflow = context.payload.workflow_run.name;
|
||||
|
||||
console.log(`PR #${prNumber} - Triggered by: ${triggerWorkflow}, SHA: ${headSha.slice(0, 8)}`);
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// REQUIRED CHECK RUNS - Job-level checks (not workflow-level)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Format: "{Workflow Name} / {Job Name} (pull_request)"
|
||||
//
|
||||
// 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: 2025-12-26
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
const requiredChecks = [
|
||||
// CI workflow (ci.yml) - 3 checks
|
||||
'CI / test-frontend (pull_request)',
|
||||
'CI / test-python (3.12) (pull_request)',
|
||||
'CI / test-python (3.13) (pull_request)',
|
||||
// Lint workflow (lint.yml) - 1 check
|
||||
'Lint / python (pull_request)',
|
||||
// Quality Security workflow (quality-security.yml) - 4 checks
|
||||
'Quality Security / CodeQL (javascript-typescript) (pull_request)',
|
||||
'Quality Security / CodeQL (python) (pull_request)',
|
||||
'Quality Security / Python Security (Bandit) (pull_request)',
|
||||
'Quality Security / Security Summary (pull_request)',
|
||||
// CLA Assistant workflow (cla.yml) - 1 check
|
||||
'CLA Assistant / CLA Check',
|
||||
// Quality Commit Lint workflow (quality-commit-lint.yml) - 1 check
|
||||
'Quality Commit Lint / Conventional Commits (pull_request)'
|
||||
];
|
||||
|
||||
const checkRuns = await fetchCheckRuns(headSha);
|
||||
console.log(`Found ${checkRuns.length} check runs`);
|
||||
const { allComplete, anyFailed, results } = analyzeChecks(checkRuns);
|
||||
const statusLabels = {
|
||||
checking: '🔄 Checking',
|
||||
passed: '✅ Ready for Review',
|
||||
failed: '❌ Checks Failed'
|
||||
};
|
||||
|
||||
for (const r of results) {
|
||||
console.log(` ${r.status} ${r.name}`);
|
||||
}
|
||||
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('');
|
||||
|
||||
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);
|
||||
// Fetch all check runs for this commit
|
||||
let allCheckRuns = [];
|
||||
try {
|
||||
const { data } = await github.rest.checks.listForRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: headSha,
|
||||
per_page: 100
|
||||
});
|
||||
allCheckRuns = data.check_runs;
|
||||
console.log(`Found ${allCheckRuns.length} total check runs`);
|
||||
} catch (error) {
|
||||
// Add warning annotation so maintainers are alerted
|
||||
core.warning(`Failed to fetch check runs for PR #${prNumber}: ${error.message}. PR label may be outdated.`);
|
||||
console.log(`::error::Failed to fetch check runs: ${error.message}`);
|
||||
console.log('::endgroup::');
|
||||
return;
|
||||
}
|
||||
|
||||
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 = [];
|
||||
|
||||
for (const checkName of REQUIRED_CHECKS) {
|
||||
const check = checkRuns.find(c => c.name === checkName);
|
||||
// Check each required check
|
||||
for (const checkName of requiredChecks) {
|
||||
const check = allCheckRuns.find(c => c.name === checkName);
|
||||
|
||||
if (!check) {
|
||||
results.push({ name: checkName, emoji: '⏳', complete: false });
|
||||
results.push({ name: checkName, status: '⏳ Pending', complete: false });
|
||||
allComplete = false;
|
||||
} else if (check.status !== 'completed') {
|
||||
results.push({ name: checkName, emoji: '🔄', complete: false });
|
||||
results.push({ name: checkName, status: '🔄 Running', complete: false });
|
||||
allComplete = false;
|
||||
} else if (check.conclusion === 'success') {
|
||||
results.push({ name: checkName, emoji: '✅', complete: true });
|
||||
results.push({ name: checkName, status: '✅ Passed', complete: true });
|
||||
} else if (check.conclusion === 'skipped') {
|
||||
results.push({ name: checkName, emoji: '⏭️', complete: true, skipped: true });
|
||||
// Skipped checks are treated as passed (e.g., path filters, conditional jobs)
|
||||
results.push({ name: checkName, status: '⏭️ Skipped', complete: true, skipped: true });
|
||||
} else {
|
||||
results.push({ name: checkName, emoji: '❌', complete: true, failed: true });
|
||||
results.push({ name: checkName, status: '❌ Failed', complete: true, failed: true });
|
||||
anyFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Get current labels
|
||||
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner, repo, issue_number: prNumber
|
||||
});
|
||||
const labelNames = currentLabels.map(l => l.name);
|
||||
const currentStatusLabel = Object.values(STATUS_LABELS).find(l => labelNames.includes(l)) || 'None';
|
||||
const currentReviewLabel = REVIEW_LABELS.find(l => labelNames.includes(l)) || 'None';
|
||||
// Print results table
|
||||
console.log('');
|
||||
console.log('Check Status:');
|
||||
console.log('─'.repeat(70));
|
||||
for (const r of results) {
|
||||
const shortName = r.name.length > 55 ? r.name.substring(0, 52) + '...' : r.name;
|
||||
console.log(` ${r.status.padEnd(12)} ${shortName}`);
|
||||
}
|
||||
console.log('─'.repeat(70));
|
||||
console.log('::endgroup::');
|
||||
|
||||
// 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] });
|
||||
}
|
||||
// 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 = '❌';
|
||||
// Determine final label
|
||||
const newLabel = anyFailed ? statusLabels.failed : statusLabels.passed;
|
||||
|
||||
const checksTable = results.map(r => `| ${r.emoji} | ${r.name} |`).join('\n');
|
||||
|
||||
const lines = [
|
||||
`## ${statusEmoji} PR Status Report`,
|
||||
'',
|
||||
`| Label | Value |`,
|
||||
`|-------|-------|`,
|
||||
`| CI Status | ${newStatusLabel} |`,
|
||||
`| AC Review | ${currentReviewLabel} |`,
|
||||
''
|
||||
];
|
||||
|
||||
if (statusChanged) {
|
||||
lines.push(`> Status updated: \`${currentStatusLabel}\` → \`${newStatusLabel}\``);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push(`### CI Checks (${passedCount}/${REQUIRED_CHECKS.length} passed)`);
|
||||
lines.push('');
|
||||
lines.push('| Status | Check |');
|
||||
lines.push('|--------|-------|');
|
||||
lines.push(checksTable);
|
||||
lines.push('');
|
||||
lines.push('---');
|
||||
lines.push(`<sub>Triggered by \`/check-status\` from @${requestedBy}</sub>`);
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber, body: lines.join('\n')
|
||||
});
|
||||
|
||||
console.log(`✅ Posted status report to PR #${prNumber}`);
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# JOB 3: AUTO-CLAUDE REVIEW
|
||||
# Processes Auto-Claude review comments from trusted sources
|
||||
# Security: Only bots and collaborators can update labels
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
update-review-status:
|
||||
name: Update Review Status
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
!contains(github.event.comment.body, '/check-status')
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Check for Auto-Claude review
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
// Security configuration
|
||||
// SECURITY: Only [bot] suffixed accounts are protected by GitHub.
|
||||
// Regular usernames can be registered by anyone and are NOT trusted.
|
||||
const TRUSTED_BOT_ACCOUNTS = Object.freeze([
|
||||
'github-actions[bot]',
|
||||
'auto-claude[bot]'
|
||||
]);
|
||||
|
||||
const TRUSTED_AUTHOR_ASSOCIATIONS = Object.freeze([
|
||||
'COLLABORATOR',
|
||||
'MEMBER',
|
||||
'OWNER'
|
||||
]);
|
||||
|
||||
const IDENTIFIER_PATTERNS = Object.freeze([
|
||||
'🤖 Auto Claude PR Review',
|
||||
'Auto Claude Review',
|
||||
'Auto-Claude Review'
|
||||
]);
|
||||
|
||||
// SECURITY: Regex patterns are tightened to prevent false matches
|
||||
// Using \s* instead of .* and requiring specific emoji + verdict format
|
||||
const VERDICTS = Object.freeze({
|
||||
APPROVED: {
|
||||
patterns: ['Auto Claude Review - APPROVED', '✅ Auto Claude Review - APPROVED'],
|
||||
// Match: "Merge Verdict:" followed by whitespace/emoji, then ✅, then APPROVED/READY TO MERGE
|
||||
regex: /Merge Verdict:\s*✅\s*(?:APPROVED|READY TO MERGE)/i,
|
||||
label: 'AC: Approved'
|
||||
},
|
||||
CHANGES_REQUESTED: {
|
||||
patterns: ['NEEDS REVISION', 'Needs Revision'],
|
||||
// Match: "Merge Verdict:" followed by whitespace/emoji, then 🟠
|
||||
regex: /Merge Verdict:\s*🟠/,
|
||||
label: 'AC: Changes Requested'
|
||||
},
|
||||
BLOCKED: {
|
||||
patterns: ['BLOCKED'],
|
||||
// Match: "Merge Verdict:" followed by whitespace/emoji, then 🔴
|
||||
regex: /Merge Verdict:\s*🔴/,
|
||||
label: 'AC: Blocked'
|
||||
}
|
||||
});
|
||||
|
||||
// NOTE: REVIEW_LABELS is intentionally duplicated across jobs.
|
||||
// GitHub Actions jobs run in isolated contexts and cannot share runtime constants.
|
||||
// If label values change, update ALL occurrences: check-status-command, update-review-status
|
||||
const REVIEW_LABELS = Object.freeze([
|
||||
'Missing AC Approval',
|
||||
'AC: Approved',
|
||||
'AC: Changes Requested',
|
||||
'AC: Blocked',
|
||||
'AC: Needs Re-review',
|
||||
'AC: Reviewed'
|
||||
]);
|
||||
|
||||
// Helper functions
|
||||
// SECURITY: Verify both username AND account type to prevent spoofing
|
||||
function isTrustedBot(username, userType) {
|
||||
const isKnownBot = TRUSTED_BOT_ACCOUNTS.some(t => username.toLowerCase() === t.toLowerCase());
|
||||
// Only trust if it's a known bot account AND GitHub confirms it's a Bot type
|
||||
return isKnownBot && userType === 'Bot';
|
||||
}
|
||||
|
||||
function isTrustedAssociation(assoc) {
|
||||
return TRUSTED_AUTHOR_ASSOCIATIONS.includes(assoc);
|
||||
}
|
||||
|
||||
function isAutoClaudeComment(body) {
|
||||
return IDENTIFIER_PATTERNS.some(p => body.includes(p));
|
||||
}
|
||||
|
||||
function parseVerdict(body) {
|
||||
const safeBody = body.slice(0, 5000);
|
||||
for (const [key, config] of Object.entries(VERDICTS)) {
|
||||
const patternMatch = config.patterns.some(p => safeBody.includes(p));
|
||||
const regexMatch = config.regex && config.regex.test(safeBody);
|
||||
if (patternMatch || regexMatch) {
|
||||
return { verdict: key, label: config.label };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function updateReviewLabels(prNumber, newLabel) {
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
// Remove all review labels first - throw on non-404 errors to prevent conflicting labels
|
||||
for (const label of REVIEW_LABELS) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label });
|
||||
console.log(` Removed: ${label}`);
|
||||
} catch (e) {
|
||||
if (e && e.status !== 404) {
|
||||
// Throw to prevent adding new label if removal failed (could cause conflicting labels)
|
||||
throw new Error(`Failed to remove label '${label}': ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log(`::group::Updating PR #${prNumber} label`);
|
||||
|
||||
// Remove old status labels
|
||||
for (const label of Object.values(statusLabels)) {
|
||||
try {
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [newLabel] });
|
||||
console.log(` Added: ${newLabel}`);
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: label
|
||||
});
|
||||
console.log(` ✓ Removed: ${label}`);
|
||||
} catch (e) {
|
||||
if (e && e.status === 404) {
|
||||
core.warning(`Label '${newLabel}' does not exist`);
|
||||
} else {
|
||||
throw e;
|
||||
if (e.status !== 404) {
|
||||
console.log(` ⚠ Could not remove ${label}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main logic
|
||||
const prNumber = context.payload.issue.number;
|
||||
const comment = context.payload.comment;
|
||||
const commenter = comment.user.login;
|
||||
const commenterType = comment.user.type;
|
||||
const authorAssociation = comment.author_association;
|
||||
const body = comment.body || '';
|
||||
|
||||
console.log(`PR #${prNumber} - Comment by: ${commenter} (type: ${commenterType}, assoc: ${authorAssociation})`);
|
||||
|
||||
// Security checks
|
||||
// SECURITY: Bot status requires BOTH username match AND verified Bot type
|
||||
const isBot = isTrustedBot(commenter, commenterType);
|
||||
const isCollaborator = isTrustedAssociation(authorAssociation);
|
||||
const isACComment = isAutoClaudeComment(body);
|
||||
|
||||
console.log(` Trusted bot: ${isBot}, Collaborator: ${isCollaborator}, AC comment: ${isACComment}`);
|
||||
|
||||
if (!isBot && !isCollaborator) {
|
||||
console.log('Skipping: Not a trusted bot or collaborator');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isACComment) {
|
||||
console.log('Skipping: Not an Auto-Claude comment');
|
||||
return;
|
||||
}
|
||||
|
||||
const verdictResult = parseVerdict(body);
|
||||
if (!verdictResult) {
|
||||
console.log('Skipping: Could not parse verdict');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Verdict: ${verdictResult.verdict} → ${verdictResult.label}`);
|
||||
await updateReviewLabels(prNumber, verdictResult.label);
|
||||
console.log(`✅ PR #${prNumber} review status updated`);
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# JOB 4: RE-REVIEW ON PUSH
|
||||
# When new commits pushed after AC approval, require re-review
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
require-re-review:
|
||||
name: Require Re-review on Push
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && github.event.action == 'synchronize'
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Check and reset AC approval if needed
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const pusher = context.payload.sender.login;
|
||||
|
||||
console.log(`PR #${prNumber} - New commits by: ${pusher}`);
|
||||
|
||||
// Get current labels
|
||||
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner, repo, issue_number: prNumber
|
||||
});
|
||||
const labelNames = labels.map(l => l.name);
|
||||
|
||||
// Check if PR was approved
|
||||
const wasApproved = labelNames.includes('AC: Approved');
|
||||
|
||||
if (!wasApproved) {
|
||||
console.log('PR was not AC-approved, no action needed');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('PR was AC-approved, resetting to require re-review');
|
||||
|
||||
// Remove AC: Approved - throw on non-404 errors to prevent conflicting labels
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner, repo, issue_number: prNumber, name: 'AC: Approved'
|
||||
});
|
||||
console.log(' Removed: AC: Approved');
|
||||
} catch (e) {
|
||||
if (e && e.status !== 404) {
|
||||
// Throw to prevent adding 'AC: Needs Re-review' if removal failed (could cause conflicting labels)
|
||||
core.error(`Failed to remove 'AC: Approved' label: ${e.message}`);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Add AC: Needs Re-review
|
||||
// Add final status label
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: prNumber, labels: ['AC: Needs Re-review']
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: [newLabel]
|
||||
});
|
||||
console.log(' Added: AC: Needs Re-review');
|
||||
console.log(` ✓ Added: ${newLabel}`);
|
||||
} catch (e) {
|
||||
if (e && e.status === 404) {
|
||||
core.warning("Label 'AC: Needs Re-review' does not exist");
|
||||
} else {
|
||||
throw e;
|
||||
if (e.status === 404) {
|
||||
core.warning(`Label '${newLabel}' does not exist. Please create it in repository settings.`);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Post notification comment
|
||||
const commentLines = [
|
||||
'## 🔄 Re-review Required',
|
||||
'',
|
||||
'New commits were pushed after Auto-Claude approval.',
|
||||
'',
|
||||
'| Previous | Current |',
|
||||
'|----------|---------|',
|
||||
'| `AC: Approved` | `AC: Needs Re-review` |',
|
||||
'',
|
||||
'Please run Auto-Claude review again or request a manual review.',
|
||||
'',
|
||||
'---',
|
||||
`<sub>Triggered by push from @${pusher}</sub>`
|
||||
];
|
||||
console.log('::endgroup::');
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber, body: commentLines.join('\n')
|
||||
});
|
||||
// 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;
|
||||
|
||||
console.log(`✅ Posted re-review notification to PR #${prNumber}`);
|
||||
if (anyFailed) {
|
||||
console.log(`❌ PR #${prNumber} has ${failedCount} failing check(s)`);
|
||||
core.summary.addRaw(`## ❌ PR #${prNumber} - Checks Failed\n\n`);
|
||||
core.summary.addRaw(`**${failedCount}** of **${requiredChecks.length}** required checks failed.\n\n`);
|
||||
} else {
|
||||
const skippedNote = skippedCount > 0 ? ` (${skippedCount} skipped)` : '';
|
||||
const totalSuccessful = passedCount + skippedCount;
|
||||
console.log(`✅ PR #${prNumber} is ready for review (${totalSuccessful}/${requiredChecks.length} checks succeeded${skippedNote})`);
|
||||
core.summary.addRaw(`## ✅ PR #${prNumber} - Ready for Review\n\n`);
|
||||
core.summary.addRaw(`All **${requiredChecks.length}** required checks succeeded${skippedNote}.\n\n`);
|
||||
}
|
||||
|
||||
// Add results to summary
|
||||
core.summary.addTable([
|
||||
[{data: 'Check', header: true}, {data: 'Status', header: true}],
|
||||
...results.map(r => [r.name, r.status])
|
||||
]);
|
||||
await core.summary.write();
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
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:
|
||||
# 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
|
||||
# If package.json version is newer than the latest tag, creates a new tag
|
||||
# which then triggers the release.yml workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -12,13 +10,6 @@ on:
|
||||
paths:
|
||||
- 'apps/frontend/package.json'
|
||||
- 'package.json'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: 'Force release even if version check fails (use with caution)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
check-and-tag:
|
||||
@@ -61,141 +52,23 @@ jobs:
|
||||
run: |
|
||||
PACKAGE_VERSION="${{ steps.package.outputs.version }}"
|
||||
LATEST_VERSION="${{ steps.latest_tag.outputs.version }}"
|
||||
FORCE="${{ github.event.inputs.force }}"
|
||||
|
||||
echo "Comparing: package=$PACKAGE_VERSION vs latest_tag=$LATEST_VERSION"
|
||||
|
||||
# Use npx semver for proper semantic version comparison
|
||||
# This correctly handles pre-release versions (2.7.3 > 2.7.3-beta.1)
|
||||
if npx -y semver "$PACKAGE_VERSION" -r ">$LATEST_VERSION" > /dev/null 2>&1; then
|
||||
# Use sort -V for version comparison
|
||||
HIGHER=$(printf '%s\n%s' "$PACKAGE_VERSION" "$LATEST_VERSION" | sort -V | tail -n1)
|
||||
|
||||
if [ "$HIGHER" = "$PACKAGE_VERSION" ] && [ "$PACKAGE_VERSION" != "$LATEST_VERSION" ]; then
|
||||
echo "should_release=true" >> $GITHUB_OUTPUT
|
||||
echo "new_version=$PACKAGE_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "✅ New release needed: v$PACKAGE_VERSION"
|
||||
elif [ "$FORCE" = "true" ]; then
|
||||
echo "should_release=true" >> $GITHUB_OUTPUT
|
||||
echo "new_version=$PACKAGE_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "⚠️ Force release enabled: v$PACKAGE_VERSION"
|
||||
else
|
||||
echo "should_release=false" >> $GITHUB_OUTPUT
|
||||
echo "⏭️ No release needed (package version not newer than latest tag)"
|
||||
fi
|
||||
|
||||
# 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<<CHANGELOG_EOF"
|
||||
echo "$CHANGELOG_CONTENT"
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> $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'
|
||||
if: steps.check.outputs.should_release == 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.check.outputs.new_version }}"
|
||||
TAG="v$VERSION"
|
||||
@@ -212,19 +85,17 @@ jobs:
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
if [ "${{ steps.check.outputs.should_release }}" = "true" ] && [ "${{ steps.changelog.outputs.changelog_valid }}" = "true" ]; then
|
||||
if [ "${{ steps.check.outputs.should_release }}" = "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. Use changelog from CHANGELOG.md" >> $GITHUB_STEP_SUMMARY
|
||||
echo "2. Generate changelog from PRs" >> $GITHUB_STEP_SUMMARY
|
||||
echo "3. Create GitHub release" >> $GITHUB_STEP_SUMMARY
|
||||
echo "4. Update README with new version" >> $GITHUB_STEP_SUMMARY
|
||||
elif [ "${{ steps.check.outputs.should_release }}" = "false" ]; then
|
||||
else
|
||||
echo "## ⏭️ No Release Needed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Package version:** ${{ steps.package.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
+164
-281
@@ -1,5 +1,4 @@
|
||||
name: Release
|
||||
# Triggers on version tags (v*) to build and publish releases
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -47,28 +46,16 @@ 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-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8-rust
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-rust-
|
||||
python-bundle-${{ runner.os }}-x64-
|
||||
|
||||
- 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
|
||||
@@ -76,9 +63,6 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Notarize macOS Intel app
|
||||
env:
|
||||
@@ -109,8 +93,6 @@ 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:
|
||||
@@ -141,28 +123,16 @@ 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-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
key: python-bundle-${{ runner.os }}-arm64-3.12.8
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-arm64-3.12.8-
|
||||
python-bundle-${{ runner.os }}-arm64-
|
||||
|
||||
- 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
|
||||
@@ -170,9 +140,6 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Notarize macOS ARM64 app
|
||||
env:
|
||||
@@ -203,17 +170,9 @@ 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
|
||||
|
||||
@@ -241,149 +200,23 @@ 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-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-
|
||||
python-bundle-${{ runner.os }}-x64-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package Windows
|
||||
run: cd apps/frontend && npm run package:win
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Disable electron-builder's built-in signing (we use Azure Trusted Signing instead)
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: false
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Azure Login (OIDC)
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Sign Windows executable with Azure Trusted Signing
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
uses: azure/trusted-signing-action@v0.5.11
|
||||
with:
|
||||
endpoint: https://neu.codesigning.azure.net/
|
||||
trusted-signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
certificate-profile-name: ${{ secrets.AZURE_CERTIFICATE_PROFILE }}
|
||||
files-folder: apps/frontend/dist
|
||||
files-folder-filter: exe
|
||||
file-digest: SHA256
|
||||
timestamp-rfc3161: http://timestamp.acs.microsoft.com
|
||||
timestamp-digest: SHA256
|
||||
|
||||
- name: Verify Windows executable is signed
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
cd apps/frontend/dist
|
||||
$exeFile = Get-ChildItem -Filter "*.exe" | Select-Object -First 1
|
||||
if ($exeFile) {
|
||||
Write-Host "Verifying signature on $($exeFile.Name)..."
|
||||
$sig = Get-AuthenticodeSignature -FilePath $exeFile.FullName
|
||||
if ($sig.Status -ne 'Valid') {
|
||||
Write-Host "::error::Signature verification failed: $($sig.Status)"
|
||||
Write-Host "::error::Status Message: $($sig.StatusMessage)"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "✅ Signature verified successfully"
|
||||
Write-Host " Subject: $($sig.SignerCertificate.Subject)"
|
||||
Write-Host " Issuer: $($sig.SignerCertificate.Issuer)"
|
||||
Write-Host " Thumbprint: $($sig.SignerCertificate.Thumbprint)"
|
||||
} else {
|
||||
Write-Host "::error::No .exe file found to verify"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Regenerate checksums after signing
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
cd apps/frontend/dist
|
||||
|
||||
# Find the installer exe (electron-builder names it with "Setup" or just the app name)
|
||||
# electron-builder produces one installer exe per build
|
||||
$exeFiles = Get-ChildItem -Filter "*.exe"
|
||||
if ($exeFiles.Count -eq 0) {
|
||||
Write-Host "::error::No .exe files found in dist folder"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Found $($exeFiles.Count) exe file(s): $($exeFiles.Name -join ', ')"
|
||||
|
||||
$ymlFile = "latest.yml"
|
||||
if (-not (Test-Path $ymlFile)) {
|
||||
Write-Host "::error::$ymlFile not found - cannot update checksums"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$content = Get-Content $ymlFile -Raw
|
||||
$originalContent = $content
|
||||
|
||||
# Process each exe file and update its hash in latest.yml
|
||||
foreach ($exeFile in $exeFiles) {
|
||||
Write-Host "Processing $($exeFile.Name)..."
|
||||
|
||||
# Compute SHA512 hash and convert to base64 (electron-builder format)
|
||||
$bytes = [System.IO.File]::ReadAllBytes($exeFile.FullName)
|
||||
$sha512 = [System.Security.Cryptography.SHA512]::Create()
|
||||
$hashBytes = $sha512.ComputeHash($bytes)
|
||||
$hash = [System.Convert]::ToBase64String($hashBytes)
|
||||
$size = $exeFile.Length
|
||||
|
||||
Write-Host " Hash: $hash"
|
||||
Write-Host " Size: $size"
|
||||
}
|
||||
|
||||
# For electron-builder, latest.yml has a single file entry for the installer
|
||||
# Update the sha512 and size for the primary exe (first one, typically the installer)
|
||||
$primaryExe = $exeFiles | Select-Object -First 1
|
||||
$bytes = [System.IO.File]::ReadAllBytes($primaryExe.FullName)
|
||||
$sha512 = [System.Security.Cryptography.SHA512]::Create()
|
||||
$hashBytes = $sha512.ComputeHash($bytes)
|
||||
$hash = [System.Convert]::ToBase64String($hashBytes)
|
||||
$size = $primaryExe.Length
|
||||
|
||||
# Update sha512 hash (base64 pattern: alphanumeric, +, /, =)
|
||||
$content = $content -replace 'sha512: [A-Za-z0-9+/=]+', "sha512: $hash"
|
||||
# Update size
|
||||
$content = $content -replace 'size: \d+', "size: $size"
|
||||
|
||||
if ($content -eq $originalContent) {
|
||||
Write-Host "::error::Checksum replacement failed - content unchanged. Check if latest.yml format has changed."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Set-Content -Path $ymlFile -Value $content -NoNewline
|
||||
Write-Host "✅ Updated $ymlFile with new base64 hash and size for $($primaryExe.Name)"
|
||||
|
||||
- name: Skip signing notice
|
||||
if: env.AZURE_CLIENT_ID == ''
|
||||
run: echo "::warning::Windows signing skipped - AZURE_CLIENT_ID not configured. The .exe will be unsigned."
|
||||
CSC_LINK: ${{ secrets.WIN_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -391,8 +224,6 @@ jobs:
|
||||
name: windows-builds
|
||||
path: |
|
||||
apps/frontend/dist/*.exe
|
||||
apps/frontend/dist/*.yml
|
||||
apps/frontend/dist/*.blockmap
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -430,36 +261,21 @@ 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-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-
|
||||
python-bundle-${{ runner.os }}-x64-
|
||||
|
||||
- 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
|
||||
@@ -469,8 +285,6 @@ 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]
|
||||
@@ -490,30 +304,16 @@ 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" -o -name "*.yml" -o -name "*.blockmap" \) -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" \) -exec cp {} release-assets/ \;
|
||||
|
||||
# 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."
|
||||
# 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."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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:"
|
||||
echo "Found $artifact_count artifact(s):"
|
||||
ls -la release-assets/
|
||||
|
||||
- name: Generate checksums
|
||||
@@ -522,6 +322,144 @@ jobs:
|
||||
sha256sum ./* > checksums.sha256
|
||||
cat checksums.sha256
|
||||
|
||||
- name: Scan with VirusTotal
|
||||
id: virustotal
|
||||
continue-on-error: true
|
||||
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
|
||||
env:
|
||||
VT_API_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }}
|
||||
run: |
|
||||
if [ -z "$VT_API_KEY" ]; then
|
||||
echo "::warning::VIRUSTOTAL_API_KEY not configured, skipping scan"
|
||||
echo "vt_results=" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "## VirusTotal Scan Results" > vt_results.md
|
||||
echo "" >> vt_results.md
|
||||
|
||||
for file in release-assets/*.{exe,dmg,AppImage,deb,flatpak}; do
|
||||
[ -f "$file" ] || continue
|
||||
filename=$(basename "$file")
|
||||
filesize=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file")
|
||||
echo "Scanning $filename (${filesize} bytes)..."
|
||||
|
||||
# For files > 32MB, get a special upload URL first
|
||||
if [ "$filesize" -gt 33554432 ]; then
|
||||
echo " Large file detected, requesting upload URL..."
|
||||
upload_url_response=$(curl -s --request GET \
|
||||
--url "https://www.virustotal.com/api/v3/files/upload_url" \
|
||||
--header "x-apikey: $VT_API_KEY")
|
||||
|
||||
upload_url=$(echo "$upload_url_response" | jq -r '.data // empty')
|
||||
if [ -z "$upload_url" ]; then
|
||||
echo "::warning::Failed to get upload URL for large file $filename"
|
||||
echo "Response: $upload_url_response"
|
||||
echo "- $filename - ⚠️ Upload failed (large file)" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
api_url="$upload_url"
|
||||
else
|
||||
api_url="https://www.virustotal.com/api/v3/files"
|
||||
fi
|
||||
|
||||
# Upload file to VirusTotal
|
||||
response=$(curl -s --request POST \
|
||||
--url "$api_url" \
|
||||
--header "x-apikey: $VT_API_KEY" \
|
||||
--form "file=@$file")
|
||||
|
||||
# Check if response is valid JSON before parsing
|
||||
if ! echo "$response" | jq -e . >/dev/null 2>&1; then
|
||||
echo "::warning::VirusTotal returned invalid JSON for $filename"
|
||||
echo "Response (first 500 chars): ${response:0:500}"
|
||||
echo "- $filename - ⚠️ Scan failed (invalid response)" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check for API error response
|
||||
error_code=$(echo "$response" | jq -r '.error.code // empty')
|
||||
if [ -n "$error_code" ]; then
|
||||
error_msg=$(echo "$response" | jq -r '.error.message // "Unknown error"')
|
||||
echo "::warning::VirusTotal API error for $filename: $error_code - $error_msg"
|
||||
echo "- $filename - ⚠️ Scan failed ($error_code)" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
|
||||
# Extract analysis ID
|
||||
analysis_id=$(echo "$response" | jq -r '.data.id // empty')
|
||||
|
||||
if [ -z "$analysis_id" ]; then
|
||||
echo "::warning::Failed to upload $filename to VirusTotal"
|
||||
echo "Response: $response"
|
||||
echo "- $filename - ⚠️ Upload failed" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Uploaded $filename, analysis ID: $analysis_id"
|
||||
|
||||
# Wait for analysis to complete (max 5 minutes per file)
|
||||
analysis=""
|
||||
for i in {1..30}; do
|
||||
sleep 10
|
||||
analysis=$(curl -s --request GET \
|
||||
--url "https://www.virustotal.com/api/v3/analyses/$analysis_id" \
|
||||
--header "x-apikey: $VT_API_KEY")
|
||||
|
||||
# Validate JSON response
|
||||
if ! echo "$analysis" | jq -e . >/dev/null 2>&1; then
|
||||
echo " Warning: Invalid JSON response on attempt $i, retrying..."
|
||||
continue
|
||||
fi
|
||||
|
||||
status=$(echo "$analysis" | jq -r '.data.attributes.status // "unknown"')
|
||||
echo " Status: $status (attempt $i/30)"
|
||||
|
||||
if [ "$status" = "completed" ]; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Final validation that we have valid analysis data
|
||||
if ! echo "$analysis" | jq -e '.data.attributes.stats' >/dev/null 2>&1; then
|
||||
echo "::warning::Could not get complete analysis for $filename, using local hash"
|
||||
file_hash=$(sha256sum "$file" | cut -d' ' -f1)
|
||||
echo "- [$filename](https://www.virustotal.com/gui/file/$file_hash) - ⚠️ Analysis incomplete" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
|
||||
# Get file hash for permanent URL
|
||||
file_hash=$(echo "$analysis" | jq -r '.meta.file_info.sha256 // empty')
|
||||
|
||||
if [ -z "$file_hash" ]; then
|
||||
# Fallback: calculate hash locally
|
||||
file_hash=$(sha256sum "$file" | cut -d' ' -f1)
|
||||
fi
|
||||
|
||||
# Get detection stats
|
||||
malicious=$(echo "$analysis" | jq -r '.data.attributes.stats.malicious // 0')
|
||||
suspicious=$(echo "$analysis" | jq -r '.data.attributes.stats.suspicious // 0')
|
||||
undetected=$(echo "$analysis" | jq -r '.data.attributes.stats.undetected // 0')
|
||||
|
||||
vt_url="https://www.virustotal.com/gui/file/$file_hash"
|
||||
|
||||
if [ "$malicious" -gt 0 ] || [ "$suspicious" -gt 0 ]; then
|
||||
echo "::warning::$filename has $malicious malicious and $suspicious suspicious detections (likely false positives)"
|
||||
echo "- [$filename]($vt_url) - ⚠️ **$malicious malicious, $suspicious suspicious** detections (review recommended)" >> vt_results.md
|
||||
else
|
||||
echo "$filename is clean ($undetected engines, 0 detections)"
|
||||
echo "- [$filename]($vt_url) - ✅ Clean ($undetected engines, 0 detections)" >> vt_results.md
|
||||
fi
|
||||
done
|
||||
|
||||
echo "" >> vt_results.md
|
||||
|
||||
# Save results for release notes
|
||||
cat vt_results.md
|
||||
echo "vt_results<<EOF" >> $GITHUB_OUTPUT
|
||||
cat vt_results.md >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Dry run summary
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == true }}
|
||||
run: |
|
||||
@@ -535,77 +473,23 @@ jobs:
|
||||
cat release-assets/checksums.sha256 >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Extract changelog from CHANGELOG.md
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
- name: Generate changelog
|
||||
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
|
||||
id: changelog
|
||||
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"
|
||||
REPO="${{ github.repository }}"
|
||||
CHANGELOG_CONTENT="Release v$VERSION"$'\n\n'"See [CHANGELOG.md](https://github.com/${REPO}/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<<CHANGELOG_EOF"
|
||||
cat changelog-body.md
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> $GITHUB_OUTPUT
|
||||
uses: release-drafter/release-drafter@v6
|
||||
with:
|
||||
config-name: release-drafter.yml
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create Release
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
body: |
|
||||
${{ steps.changelog.outputs.body }}
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md
|
||||
|
||||
_VirusTotal scan results will be added automatically after release._
|
||||
${{ steps.virustotal.outputs.vt_results }}
|
||||
files: release-assets/*
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.ref, 'beta') || contains(github.ref, 'alpha') }}
|
||||
@@ -616,8 +500,7 @@ jobs:
|
||||
update-readme:
|
||||
needs: [create-release]
|
||||
runs-on: ubuntu-latest
|
||||
# Only update README on actual releases (tag push), not dry runs
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
name: Test Azure Auth
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test-auth:
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Azure Login (OIDC)
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Success
|
||||
run: echo "Azure authentication successful!"
|
||||
@@ -1,343 +0,0 @@
|
||||
name: VirusTotal Scan
|
||||
|
||||
# Runs AFTER release is published to avoid blocking release creation
|
||||
# VirusTotal scans can take 5+ minutes per file, which delays releases
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to scan (e.g., v2.8.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
# Prevent TOCTOU race condition when updating release notes
|
||||
# If two runs target the same tag, queue them instead of running in parallel
|
||||
concurrency:
|
||||
group: virustotal-${{ github.event.inputs.tag || github.event.release.tag_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
name: Scan release assets
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # Required to update release notes
|
||||
steps:
|
||||
- name: Determine release tag
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Check for API key
|
||||
id: check-key
|
||||
env:
|
||||
VT_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }}
|
||||
run: |
|
||||
if [ -z "$VT_KEY" ]; then
|
||||
echo "::warning::VIRUSTOTAL_API_KEY not configured, skipping scan"
|
||||
echo "has_key=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_key=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Download release assets
|
||||
if: steps.check-key.outputs.has_key == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${{ steps.tag.outputs.tag }}"
|
||||
echo "Downloading assets for release $TAG..."
|
||||
|
||||
mkdir -p release-assets
|
||||
|
||||
# First verify the release exists
|
||||
if ! gh release view "$TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
||||
echo "::error::Release $TAG not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Download assets, distinguishing between "no matching assets" and real errors
|
||||
set +e
|
||||
gh release download "$TAG" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--pattern "*.exe" \
|
||||
--pattern "*.dmg" \
|
||||
--pattern "*.AppImage" \
|
||||
--pattern "*.deb" \
|
||||
--pattern "*.flatpak" \
|
||||
--dir release-assets 2>&1
|
||||
exit_code=$?
|
||||
set -e
|
||||
|
||||
if [ $exit_code -ne 0 ]; then
|
||||
# Check if it's just "no assets matched" vs a real error
|
||||
asset_count=$(gh release view "$TAG" --repo "${{ github.repository }}" --json assets -q '.assets | length')
|
||||
if [ "$asset_count" -eq 0 ]; then
|
||||
echo "Release has no assets yet (this is OK for new releases)"
|
||||
else
|
||||
# Check if any scannable assets exist that should have been downloaded
|
||||
scannable_assets=$(gh release view "$TAG" --repo "${{ github.repository }}" --json assets \
|
||||
-q '.assets[].name | select(test("\\.(exe|dmg|AppImage|deb|flatpak)$"))' | wc -l)
|
||||
if [ "$scannable_assets" -gt 0 ]; then
|
||||
echo "::error::Download failed - $scannable_assets scannable asset(s) exist but download failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "No assets matched the patterns (exe, dmg, AppImage, deb, flatpak)"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Downloaded assets:"
|
||||
ls -la release-assets/ || echo "No assets found"
|
||||
|
||||
- name: Scan with VirusTotal
|
||||
if: steps.check-key.outputs.has_key == 'true'
|
||||
id: virustotal
|
||||
env:
|
||||
VT_API_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }}
|
||||
run: |
|
||||
echo "## VirusTotal Scan Results" > vt_results.md
|
||||
echo "" >> vt_results.md
|
||||
|
||||
# Check if there are any files to scan
|
||||
shopt -s nullglob
|
||||
files=(release-assets/*.{exe,dmg,AppImage,deb,flatpak})
|
||||
if [ ${#files[@]} -eq 0 ]; then
|
||||
echo "No scannable files found in release assets"
|
||||
echo "- No executable files found in release" >> vt_results.md
|
||||
echo "vt_results<<EOF" >> $GITHUB_OUTPUT
|
||||
cat vt_results.md >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for file in "${files[@]}"; do
|
||||
[ -f "$file" ] || continue
|
||||
filename=$(basename "$file")
|
||||
filesize=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file")
|
||||
echo "Scanning $filename (${filesize} bytes)..."
|
||||
|
||||
# VirusTotal requires special upload URL for files > 32MB
|
||||
LARGE_FILE_THRESHOLD=33554432 # 32 MB in bytes
|
||||
if [ "$filesize" -gt "$LARGE_FILE_THRESHOLD" ]; then
|
||||
echo " Large file detected, requesting upload URL..."
|
||||
upload_http_response=$(curl -s -w '\n%{http_code}' --request GET \
|
||||
--url "https://www.virustotal.com/api/v3/files/upload_url" \
|
||||
--header "x-apikey: $VT_API_KEY")
|
||||
upload_http_code=$(echo "$upload_http_response" | tail -1)
|
||||
upload_url_response=$(echo "$upload_http_response" | sed '$d')
|
||||
|
||||
if [ "$upload_http_code" != "200" ]; then
|
||||
echo "::warning::Failed to get upload URL for large file $filename (HTTP $upload_http_code)"
|
||||
echo "- $filename - ⚠️ Upload failed (large file, HTTP $upload_http_code)" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
|
||||
upload_url=$(echo "$upload_url_response" | jq -r '.data // empty')
|
||||
if [ -z "$upload_url" ]; then
|
||||
echo "::warning::Failed to get upload URL for large file $filename"
|
||||
echo "Response: $upload_url_response"
|
||||
echo "- $filename - ⚠️ Upload failed (large file)" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
api_url="$upload_url"
|
||||
else
|
||||
api_url="https://www.virustotal.com/api/v3/files"
|
||||
fi
|
||||
|
||||
# Upload file to VirusTotal (capture HTTP status code)
|
||||
http_response=$(curl -s -w '\n%{http_code}' --request POST \
|
||||
--url "$api_url" \
|
||||
--header "x-apikey: $VT_API_KEY" \
|
||||
--form "file=@$file")
|
||||
http_code=$(echo "$http_response" | tail -1)
|
||||
response=$(echo "$http_response" | sed '$d')
|
||||
|
||||
# Check HTTP status code first
|
||||
if [ "$http_code" != "200" ]; then
|
||||
echo "::warning::VirusTotal returned HTTP $http_code for $filename"
|
||||
if [ "$http_code" = "429" ]; then
|
||||
echo "- $filename - ⚠️ Scan failed (rate limited)" >> vt_results.md
|
||||
elif [ "$http_code" = "403" ]; then
|
||||
echo "- $filename - ⚠️ Scan failed (forbidden - check API key)" >> vt_results.md
|
||||
else
|
||||
echo "- $filename - ⚠️ Scan failed (HTTP $http_code)" >> vt_results.md
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check if response is valid JSON before parsing
|
||||
if ! echo "$response" | jq -e . >/dev/null 2>&1; then
|
||||
echo "::warning::VirusTotal returned invalid JSON for $filename"
|
||||
echo "Response (first 500 chars): ${response:0:500}"
|
||||
echo "- $filename - ⚠️ Scan failed (invalid response)" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check for API error response
|
||||
error_code=$(echo "$response" | jq -r '.error.code // empty')
|
||||
if [ -n "$error_code" ]; then
|
||||
error_msg=$(echo "$response" | jq -r '.error.message // "Unknown error"')
|
||||
echo "::warning::VirusTotal API error for $filename: $error_code - $error_msg"
|
||||
echo "- $filename - ⚠️ Scan failed ($error_code)" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
|
||||
# Extract analysis ID
|
||||
analysis_id=$(echo "$response" | jq -r '.data.id // empty')
|
||||
|
||||
if [ -z "$analysis_id" ]; then
|
||||
echo "::warning::Failed to upload $filename to VirusTotal"
|
||||
echo "Response: $response"
|
||||
echo "- $filename - ⚠️ Upload failed" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Uploaded $filename, analysis ID: $analysis_id"
|
||||
|
||||
# Wait for analysis to complete (max 5 minutes per file)
|
||||
analysis=""
|
||||
for i in {1..30}; do
|
||||
sleep 10
|
||||
analysis_http_response=$(curl -s -w '\n%{http_code}' --request GET \
|
||||
--url "https://www.virustotal.com/api/v3/analyses/$analysis_id" \
|
||||
--header "x-apikey: $VT_API_KEY")
|
||||
analysis_http_code=$(echo "$analysis_http_response" | tail -1)
|
||||
analysis=$(echo "$analysis_http_response" | sed '$d')
|
||||
|
||||
# Check HTTP status code
|
||||
if [ "$analysis_http_code" != "200" ]; then
|
||||
echo " Warning: HTTP $analysis_http_code on attempt $i, retrying..."
|
||||
if [ "$analysis_http_code" = "429" ]; then
|
||||
echo " Rate limited, waiting longer..."
|
||||
sleep 30
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
# Validate JSON response
|
||||
if ! echo "$analysis" | jq -e . >/dev/null 2>&1; then
|
||||
echo " Warning: Invalid JSON response on attempt $i, retrying..."
|
||||
continue
|
||||
fi
|
||||
|
||||
status=$(echo "$analysis" | jq -r '.data.attributes.status // "unknown"')
|
||||
echo " Status: $status (attempt $i/30)"
|
||||
|
||||
if [ "$status" = "completed" ]; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Handle analysis timeout - if loop completed without status=completed
|
||||
if [ "$status" != "completed" ]; then
|
||||
echo "::warning::Analysis timed out for $filename (status: $status after 5 minutes)"
|
||||
file_hash=$(sha256sum "$file" | cut -d' ' -f1)
|
||||
echo "- [$filename](https://www.virustotal.com/gui/file/$file_hash) - ⚠️ Analysis timed out" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
|
||||
# Final validation that we have valid analysis data
|
||||
if ! echo "$analysis" | jq -e '.data.attributes.stats' >/dev/null 2>&1; then
|
||||
echo "::warning::Could not get complete analysis for $filename, using local hash"
|
||||
file_hash=$(sha256sum "$file" | cut -d' ' -f1)
|
||||
echo "- [$filename](https://www.virustotal.com/gui/file/$file_hash) - ⚠️ Analysis incomplete" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
|
||||
# Get file hash for permanent URL
|
||||
file_hash=$(echo "$analysis" | jq -r '.meta.file_info.sha256 // empty')
|
||||
|
||||
if [ -z "$file_hash" ]; then
|
||||
# Fallback: calculate hash locally
|
||||
file_hash=$(sha256sum "$file" | cut -d' ' -f1)
|
||||
fi
|
||||
|
||||
# Get detection stats
|
||||
malicious=$(echo "$analysis" | jq -r '.data.attributes.stats.malicious // 0')
|
||||
suspicious=$(echo "$analysis" | jq -r '.data.attributes.stats.suspicious // 0')
|
||||
undetected=$(echo "$analysis" | jq -r '.data.attributes.stats.undetected // 0')
|
||||
|
||||
vt_url="https://www.virustotal.com/gui/file/$file_hash"
|
||||
|
||||
if [ "$malicious" -gt 0 ] || [ "$suspicious" -gt 0 ]; then
|
||||
echo "::warning::$filename has $malicious malicious and $suspicious suspicious detections (likely false positives)"
|
||||
echo "- [$filename]($vt_url) - ⚠️ **$malicious malicious, $suspicious suspicious** detections (review recommended)" >> vt_results.md
|
||||
else
|
||||
echo "$filename is clean ($undetected engines, 0 detections)"
|
||||
echo "- [$filename]($vt_url) - ✅ Clean ($undetected engines, 0 detections)" >> vt_results.md
|
||||
fi
|
||||
done
|
||||
|
||||
echo "" >> vt_results.md
|
||||
|
||||
# Save results for next step
|
||||
cat vt_results.md
|
||||
echo "vt_results<<EOF" >> $GITHUB_OUTPUT
|
||||
cat vt_results.md >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Update release notes with scan results
|
||||
if: steps.check-key.outputs.has_key == 'true' && steps.virustotal.outputs.vt_results != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${{ steps.tag.outputs.tag }}"
|
||||
|
||||
# Get current release body with error checking
|
||||
if ! current_body=$(gh release view "$TAG" --repo "${{ github.repository }}" --json body -q '.body'); then
|
||||
echo "::error::Failed to fetch current release body for $TAG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Additional safeguard for empty body
|
||||
if [ -z "$current_body" ]; then
|
||||
echo "::warning::Release body is empty, this may indicate a problem"
|
||||
fi
|
||||
|
||||
# Check if VirusTotal results already exist in the body
|
||||
if echo "$current_body" | grep -q "## VirusTotal Scan Results"; then
|
||||
echo "VirusTotal results already in release notes, skipping update"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Use file-based approach to avoid shell expansion issues
|
||||
# First, write current body to file
|
||||
echo "$current_body" > release-body.md
|
||||
|
||||
# Remove placeholder text if present (portable sed approach)
|
||||
sed '/_VirusTotal scan results will be added automatically after release\./d' release-body.md > release-body.tmp && mv release-body.tmp release-body.md
|
||||
|
||||
# Append separator and VT results
|
||||
echo "" >> release-body.md
|
||||
echo "---" >> release-body.md
|
||||
echo "" >> release-body.md
|
||||
cat vt_results.md >> release-body.md
|
||||
|
||||
# Update release using --notes-file to avoid shell quoting issues
|
||||
gh release edit "$TAG" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--notes-file release-body.md
|
||||
|
||||
echo "✅ Updated release notes with VirusTotal scan results"
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## VirusTotal Scan Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Release:** ${{ steps.tag.outputs.tag }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${{ steps.check-key.outputs.has_key }}" = "false" ]; then
|
||||
echo "⚠️ Scan skipped: VIRUSTOTAL_API_KEY not configured" >> $GITHUB_STEP_SUMMARY
|
||||
elif [ -f vt_results.md ]; then
|
||||
cat vt_results.md >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "No scan results available" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
@@ -14,7 +14,6 @@ Desktop.ini
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
/config.json
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
@@ -164,8 +163,3 @@ _bmad-output/
|
||||
.claude/
|
||||
/docs
|
||||
OPUS_ANALYSIS_AND_IDEAS.md
|
||||
/.github/agents
|
||||
|
||||
# Auto Claude generated files
|
||||
.security-key
|
||||
/shared_docs
|
||||
+57
-111
@@ -1,14 +1,5 @@
|
||||
#!/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..."
|
||||
|
||||
# =============================================================================
|
||||
@@ -45,44 +36,14 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
|
||||
echo " Updated apps/backend/__init__.py to $VERSION"
|
||||
fi
|
||||
|
||||
# Sync to README.md - section-aware updates (stable vs beta)
|
||||
# Sync to README.md
|
||||
if [ -f "README.md" ]; then
|
||||
# Escape hyphens for shields.io badge format (shields.io uses -- for literal hyphens)
|
||||
ESCAPED_VERSION=$(echo "$VERSION" | sed 's/-/--/g')
|
||||
|
||||
# Detect if this is a prerelease (contains - after base version, e.g., 2.7.2-beta.10)
|
||||
if echo "$VERSION" | grep -q '-'; then
|
||||
# PRERELEASE: Update only beta sections
|
||||
echo " Detected PRERELEASE version: $VERSION"
|
||||
|
||||
# Update beta version badge (orange)
|
||||
sed -i.bak "s/beta-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-orange/beta-$ESCAPED_VERSION-orange/g" README.md
|
||||
|
||||
# Update beta version badge link (within BETA_VERSION_BADGE section)
|
||||
sed -i.bak '/<!-- BETA_VERSION_BADGE -->/,/<!-- BETA_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
|
||||
|
||||
# Update beta download links (within BETA_DOWNLOADS section only)
|
||||
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb" "linux-x86_64.flatpak"; do
|
||||
sed -i.bak '/<!-- BETA_DOWNLOADS -->/,/<!-- BETA_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
|
||||
done
|
||||
else
|
||||
# STABLE: Update stable sections and top badge
|
||||
echo " Detected STABLE version: $VERSION"
|
||||
|
||||
# Update top version badge (blue) - within TOP_VERSION_BADGE section
|
||||
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s/version-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/version-'"$ESCAPED_VERSION"'-blue/g' README.md
|
||||
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
|
||||
|
||||
# Update stable version badge (blue) - within STABLE_VERSION_BADGE section
|
||||
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s/stable-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/stable-'"$ESCAPED_VERSION"'-blue/g' README.md
|
||||
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
|
||||
|
||||
# Update stable download links (within STABLE_DOWNLOADS section only)
|
||||
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb"; do
|
||||
sed -i.bak '/<!-- STABLE_DOWNLOADS -->/,/<!-- STABLE_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
|
||||
done
|
||||
fi
|
||||
|
||||
# Update version badge - match both stable (X.Y.Z) and prerelease (X.Y.Z-prerelease.N or X.Y.Z--prerelease.N)
|
||||
sed -i.bak "s/version-[0-9]*\.[0-9]*\.[0-9]*\(-\{1,2\}[a-z]*\.[0-9]*\)*-blue/version-$ESCAPED_VERSION-blue/g" README.md
|
||||
# Update download links - match both stable and prerelease versions
|
||||
sed -i.bak "s/Auto-Claude-[0-9]*\.[0-9]*\.[0-9]*\(-[a-z]*\.[0-9]*\)*/Auto-Claude-$VERSION/g" README.md
|
||||
rm -f README.md.bak
|
||||
git add README.md
|
||||
echo " Updated README.md to $VERSION"
|
||||
@@ -111,54 +72,47 @@ if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
|
||||
fi
|
||||
|
||||
if [ -n "$RUFF" ]; then
|
||||
# Get only staged Python files in apps/backend (process only what's being committed)
|
||||
STAGED_PY_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep "^apps/backend/.*\.py$" || true)
|
||||
|
||||
if [ -n "$STAGED_PY_FILES" ]; then
|
||||
# Run ruff linting (auto-fix) only on staged files
|
||||
echo "Running ruff lint on staged files..."
|
||||
echo "$STAGED_PY_FILES" | xargs $RUFF check --fix
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Ruff lint failed. Please fix Python linting errors before committing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run ruff format (auto-fix) only on staged files
|
||||
echo "Running ruff format on staged files..."
|
||||
echo "$STAGED_PY_FILES" | xargs $RUFF format
|
||||
|
||||
# Re-stage only the files that were originally staged (in case ruff modified them)
|
||||
echo "$STAGED_PY_FILES" | xargs git add
|
||||
# Run ruff linting (auto-fix)
|
||||
echo "Running ruff lint..."
|
||||
$RUFF check apps/backend/ --fix
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Ruff lint failed. Please fix Python linting errors before committing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run ruff format (auto-fix)
|
||||
echo "Running ruff format..."
|
||||
$RUFF format apps/backend/
|
||||
|
||||
# Stage any files that were auto-fixed by ruff (POSIX-compliant)
|
||||
find apps/backend -name "*.py" -type f -exec git add {} + 2>/dev/null || true
|
||||
else
|
||||
echo "Warning: ruff not found, skipping Python linting. Install with: uv pip install ruff"
|
||||
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
|
||||
else
|
||||
PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
|
||||
fi
|
||||
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
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Python tests failed. Please fix failing tests before committing."
|
||||
exit 1
|
||||
fi
|
||||
cd ../..
|
||||
|
||||
echo "Backend checks passed!"
|
||||
fi
|
||||
@@ -170,44 +124,36 @@ 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..."
|
||||
# Use subshell to isolate directory changes and prevent worktree corruption
|
||||
(
|
||||
cd apps/frontend
|
||||
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 lint-staged (handles staged .ts/.tsx files)
|
||||
npm exec lint-staged
|
||||
|
||||
# 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
|
||||
|
||||
# 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
|
||||
)
|
||||
# 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
|
||||
|
||||
# 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
|
||||
|
||||
cd ../..
|
||||
echo "Frontend checks passed!"
|
||||
fi
|
||||
|
||||
|
||||
+8
-68
@@ -1,6 +1,5 @@
|
||||
repos:
|
||||
# Version sync - propagate root package.json version to all files
|
||||
# NOTE: Skip in worktrees - version sync modifies root files which don't exist in worktree
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: version-sync
|
||||
@@ -9,12 +8,6 @@ repos:
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
# Skip in worktrees - .git is a file pointing to main repo, not a directory
|
||||
# Version sync modifies root-level files that may not exist in worktree context
|
||||
if [ -f ".git" ]; then
|
||||
echo "Skipping version-sync in worktree (root files not accessible)"
|
||||
exit 0
|
||||
fi
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
if [ -n "$VERSION" ]; then
|
||||
|
||||
@@ -32,41 +25,14 @@ repos:
|
||||
# Sync to apps/backend/__init__.py
|
||||
sed -i.bak "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" apps/backend/__init__.py && rm -f apps/backend/__init__.py.bak
|
||||
|
||||
# Sync to README.md - section-aware updates (stable vs beta)
|
||||
# Sync to README.md - shields.io version badge (text and URL)
|
||||
ESCAPED_VERSION=$(echo "$VERSION" | sed 's/-/--/g')
|
||||
sed -i.bak -e "s/version-[0-9]*\.[0-9]*\.[0-9]*\(-\{1,2\}[a-z]*\.[0-9]*\)*-blue/version-$ESCAPED_VERSION-blue/g" -e "s|releases/tag/v[0-9.a-z-]*)|releases/tag/v$VERSION)|g" README.md
|
||||
|
||||
# Detect if this is a prerelease (contains - after base version)
|
||||
if echo "$VERSION" | grep -q '-'; then
|
||||
# PRERELEASE: Update only beta sections
|
||||
echo " Detected PRERELEASE version: $VERSION"
|
||||
|
||||
# Update beta version badge (orange)
|
||||
sed -i.bak "s/beta-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-orange/beta-$ESCAPED_VERSION-orange/g" README.md
|
||||
|
||||
# Update beta version badge link
|
||||
sed -i.bak '/<!-- BETA_VERSION_BADGE -->/,/<!-- BETA_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
|
||||
|
||||
# Update beta download links (within BETA_DOWNLOADS section only)
|
||||
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb" "linux-x86_64.flatpak"; do
|
||||
sed -i.bak '/<!-- BETA_DOWNLOADS -->/,/<!-- BETA_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
|
||||
done
|
||||
else
|
||||
# STABLE: Update stable sections and top badge
|
||||
echo " Detected STABLE version: $VERSION"
|
||||
|
||||
# Update top version badge (blue)
|
||||
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s/version-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/version-'"$ESCAPED_VERSION"'-blue/g' README.md
|
||||
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
|
||||
|
||||
# Update stable version badge (blue)
|
||||
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s/stable-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/stable-'"$ESCAPED_VERSION"'-blue/g' README.md
|
||||
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
|
||||
|
||||
# Update stable download links (within STABLE_DOWNLOADS section only)
|
||||
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb"; do
|
||||
sed -i.bak '/<!-- STABLE_DOWNLOADS -->/,/<!-- STABLE_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
|
||||
done
|
||||
fi
|
||||
# Sync to README.md - download links with correct filenames and URLs
|
||||
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb"; do
|
||||
sed -i.bak "s|Auto-Claude-[0-9.a-z-]*-${SUFFIX}](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-${SUFFIX})|Auto-Claude-${VERSION}-${SUFFIX}](https://github.com/AndyMik90/Auto-Claude/releases/download/v${VERSION}/Auto-Claude-${VERSION}-${SUFFIX})|g" README.md
|
||||
done
|
||||
rm -f README.md.bak
|
||||
|
||||
# Stage changes
|
||||
@@ -88,7 +54,6 @@ repos:
|
||||
|
||||
# Python tests (apps/backend/) - skip slow/integration tests for pre-commit speed
|
||||
# Tests to skip: graphiti (external deps), merge_file_tracker/service_orchestrator/worktree/workspace (Windows path/git issues)
|
||||
# NOTE: Skip this hook in worktrees (where .git is a file, not a directory)
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: pytest
|
||||
@@ -97,12 +62,6 @@ repos:
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
# Skip in worktrees - .git is a file pointing to main repo, not a directory
|
||||
# This prevents path resolution issues with ../../tests/ in worktree context
|
||||
if [ -f ".git" ]; then
|
||||
echo "Skipping pytest in worktree (path resolution would fail)"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/backend
|
||||
if [ -f ".venv/bin/pytest" ]; then
|
||||
PYTEST_CMD=".venv/bin/pytest"
|
||||
@@ -127,37 +86,18 @@ repos:
|
||||
pass_filenames: false
|
||||
|
||||
# Frontend linting (apps/frontend/)
|
||||
# NOTE: These hooks check for worktree context to avoid npm/node_modules issues
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: eslint
|
||||
name: ESLint
|
||||
entry: bash
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
# Skip in worktrees if node_modules doesn't exist (dependencies not installed)
|
||||
if [ -f ".git" ] && [ ! -d "apps/frontend/node_modules" ]; then
|
||||
echo "Skipping ESLint in worktree (node_modules not found)"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/frontend && npm run lint
|
||||
entry: bash -c 'cd apps/frontend && npm run lint'
|
||||
language: system
|
||||
files: ^apps/frontend/.*\.(ts|tsx|js|jsx)$
|
||||
pass_filenames: false
|
||||
|
||||
- id: typecheck
|
||||
name: TypeScript Check
|
||||
entry: bash
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
# Skip in worktrees if node_modules doesn't exist (dependencies not installed)
|
||||
if [ -f ".git" ] && [ ! -d "apps/frontend/node_modules" ]; then
|
||||
echo "Skipping TypeScript check in worktree (node_modules not found)"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/frontend && npm run typecheck
|
||||
entry: bash -c 'cd apps/frontend && npm run typecheck'
|
||||
language: system
|
||||
files: ^apps/frontend/.*\.(ts|tsx)$
|
||||
pass_filenames: false
|
||||
|
||||
-945
@@ -1,948 +1,3 @@
|
||||
## 2.7.4 - Terminal & Workflow Enhancements
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- Added task worktrees section in terminal with ability to invoke Claude with YOLO mode (--dangerously-skip-permissions)
|
||||
|
||||
- Added searchable branch combobox to worktree creation dialog for easier branch selection
|
||||
|
||||
- Added Claude Code version rollback feature to switch between installed versions
|
||||
|
||||
- Embedded Sentry DSN at build time for better error tracking in packaged apps
|
||||
|
||||
### 🛠️ Improvements
|
||||
|
||||
- Made worktree isolation prominent in UI to help users understand workspace isolation
|
||||
|
||||
- Enhanced terminal recreation logic with retry mechanism for more reliable terminal recovery
|
||||
|
||||
- Improved worktree name input UX for better user experience
|
||||
|
||||
- Improved Claude CLI detection with installation selector when multiple versions found
|
||||
|
||||
- Enhanced terminal drag and drop reordering with collision detection
|
||||
|
||||
- Synced worktree config to renderer on terminal restoration for consistency
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Fixed Windows claude.cmd validation in GUI to work reliably across different setups
|
||||
|
||||
- Fixed profile manager initialization timing issue before auth checks
|
||||
|
||||
- Fixed terminal recreation and label reset when user closes Claude
|
||||
|
||||
- Fixed duplicate Kanban task creation that occurred on rapid button clicks
|
||||
|
||||
- Fixed GitHub PR preloading to prevent loading PRs currently under review
|
||||
|
||||
- Fixed UI to display actual base branch name instead of hardcoded "main"
|
||||
|
||||
- Fixed Claude CLI detection to properly identify available installations
|
||||
|
||||
- Fixed broken pipe errors in backend with Sentry integration
|
||||
|
||||
- Fixed app update state persistence for Install button visibility
|
||||
|
||||
- Fixed merge logic to include files with content changes even when semantic analysis is empty
|
||||
|
||||
- Fixed security profile inheritance in worktrees and shell -c command validation
|
||||
|
||||
- Fixed auth auto-switch on 401 errors and improved OAuth-only profile handling
|
||||
|
||||
- Fixed "already up to date" case handling in worktree operations
|
||||
|
||||
- Resolved circular import issues in GitHub context gatherer and services
|
||||
|
||||
---
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix: validate Windows claude.cmd reliably in GUI by @Umaru in 1ae3359b
|
||||
- fix: await profile manager initialization before auth check by @StillKnotKnown in c8374bc1
|
||||
- feat: add file/screenshot upload to QA feedback interface by @Andy in 88277f84
|
||||
- feat(terminal): add task worktrees section and remove terminal limit by @Andy in 17118b07
|
||||
- fix(terminal): enhance terminal recreation logic with retry mechanism by @Andy in df1b8a3f
|
||||
- fix(terminal): improve worktree name input UX by @Andy in 54e9f228
|
||||
- feat(ui): make worktree isolation prominent in UI by @Andy in 4dbb7ee4
|
||||
- feat(terminal): add YOLO mode to invoke Claude with --dangerously-skip-permissions by @Andy in d48e5f68
|
||||
- fix(ui): prevent duplicate Kanban task creation on rapid button clicks by @Andy in 2d1d3ef1
|
||||
- feat(sentry): embed Sentry DSN at build time for packaged apps by @Andy in aed28c5f
|
||||
- fix(github): resolve circular import issues in context_gatherer and services by @Andy in 0307a4a9
|
||||
- fix(github-prs): prevent preloading of PRs currently under review by @Andy in 1babcc86
|
||||
- fix(ui): display actual base branch name instead of hardcoded main by @Andy in 5d07d5f1
|
||||
- ci(release): move VirusTotal scan to separate post-release workflow by @Andy in 553d1e8d
|
||||
- fix: improve Claude CLI detection and add installation selector by @Andy in e07a0dbd
|
||||
- fix(backend): add Sentry integration and fix broken pipe errors by @Andy in aa9fbe9d
|
||||
- fix(app-update): persist downloaded update state for Install button visibility by @Andy in 6f059bb5
|
||||
- fix(terminal): detect Claude exit and reset label when user closes Claude by @Andy in 14982e66
|
||||
- fix(merge): include files with content changes even when semantic analysis is empty by @Andy in 4736b6b6
|
||||
- fix(frontend): sync worktree config to renderer on terminal restoration by @Andy in 68fe0860
|
||||
- feat(frontend): add searchable branch combobox to worktree creation dialog by @Andy in 2a2dc3b8
|
||||
- fix(security): inherit security profiles in worktrees and validate shell -c commands by @Andy in 750ea8d1
|
||||
- feat(frontend): add Claude Code version rollback feature by @Andy in 8d21978f
|
||||
- fix(ACS-181): enable auto-switch on 401 auth errors & OAuth-only profiles by @Michael Ludlow in e7427321
|
||||
- fix(terminal): add collision detection for terminal drag and drop reordering by @Andy in 1701160b
|
||||
- fix(worktree): handle "already up to date" case correctly by @StillKnotKnown in 74ed4320
|
||||
|
||||
## Thanks to all contributors
|
||||
|
||||
@Umaru, @StillKnotKnown, @Andy, @Michael Ludlow, @AndyMik90
|
||||
|
||||
## 2.7.3 - Reliability & Stability Focus
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- Add terminal copy/paste keyboard shortcuts for Windows/Linux
|
||||
|
||||
- Add Sentry environment variables to CI build workflows for error monitoring
|
||||
|
||||
- Add Claude Code changelog link to version notifiers
|
||||
|
||||
- Enhance PR merge readiness checks with branch state validation
|
||||
|
||||
- Add PR creation workflow for task worktrees
|
||||
|
||||
- Add prominent verdict summary to PR review comments
|
||||
|
||||
- Add Dart/Flutter/Melos support to security profiles
|
||||
|
||||
- Custom Anthropic compatible API profile management
|
||||
|
||||
- Add terminal dropdown with inbuilt and external options in task review
|
||||
|
||||
- Centralize CLI tool path management
|
||||
|
||||
- Add terminal support for worktrees
|
||||
|
||||
- Add Files tab to task details panel
|
||||
|
||||
- Enhance PR review page to include PRs filters
|
||||
|
||||
- Add GitLab integration
|
||||
|
||||
- Add Flatpak packaging support for Linux
|
||||
|
||||
- Bundle Python 3.12 with packaged Electron app
|
||||
|
||||
- Add iOS/Swift project detection
|
||||
|
||||
- Add automated PR review with follow-up support
|
||||
|
||||
- Add i18n internationalization system
|
||||
|
||||
- Add OpenRouter as LLM/embedding provider
|
||||
|
||||
- Add UI scale feature with 75-200% range
|
||||
|
||||
### 🛠️ Improvements
|
||||
|
||||
- Extract shared task form components for consistent modals
|
||||
|
||||
- Simplify task description handling and improve modal layout
|
||||
|
||||
- Replace confidence scoring with evidence-based validation in GitHub reviews
|
||||
|
||||
- Convert synchronous I/O to async operations in worktree handlers
|
||||
|
||||
- Remove top bars from UI
|
||||
|
||||
- Improve task card title readability
|
||||
|
||||
- Add path-aware AI merge resolution and device code streaming
|
||||
|
||||
- Increase Claude SDK JSON buffer size to 10MB
|
||||
|
||||
- Improve performance by removing projectTabs from useEffect dependencies
|
||||
|
||||
- Normalize feature status values for Kanban display
|
||||
|
||||
- Improve GLM presets, ideation auth, and Insights env
|
||||
|
||||
- Detect and clear cross-platform CLI paths in settings
|
||||
|
||||
- Improve CLI tool detection and add Claude CLI path settings
|
||||
|
||||
- Multiple bug fixes including binary file handling and semantic tracking
|
||||
|
||||
- Centralize Claude CLI invocation across the application
|
||||
|
||||
- Improve PR review with structured outputs and fork support
|
||||
|
||||
- Improve task card description truncation for better display
|
||||
|
||||
- Improve GitHub PR review with better evidence-based findings
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Implement atomic JSON writes to prevent file corruption
|
||||
|
||||
- Prevent "Render frame was disposed" crash in frontend
|
||||
|
||||
- Strip ANSI escape codes from roadmap/ideation progress messages
|
||||
|
||||
- Resolve integrations freeze and improve rate limit handling
|
||||
|
||||
- Use shared project-wide memory for cross-spec learning
|
||||
|
||||
- Add isinstance(dict) validation to Graphiti to prevent AttributeError
|
||||
|
||||
- Enforce implementation_plan schema in planner
|
||||
|
||||
- Remove obsolete @lydell/node-pty extraResources entry from build
|
||||
|
||||
- Add Post Clean Review button for clean PR reviews
|
||||
|
||||
- Fix Kanban status flip-flop and phase state inconsistency
|
||||
|
||||
- Resolve multiple merge-related issues affecting worktree operations
|
||||
|
||||
- Show running review state when switching back to PR with in-progress review
|
||||
|
||||
- Properly quote Windows .cmd/.bat paths in spawn() calls
|
||||
|
||||
- Improve Claude CLI detection on Windows with space-containing paths
|
||||
|
||||
- Display subtask titles instead of UUIDs in UI
|
||||
|
||||
- Use HTTP for Azure Trusted Signing timestamp URL in CI
|
||||
|
||||
- Fix Kanban state transitions and status flip-flop bug
|
||||
|
||||
- Use selectedPR from hook to restore Files changed list
|
||||
|
||||
- Automate auto labeling based on comments
|
||||
|
||||
- Fix subtasks tab not updating on Linux
|
||||
|
||||
- Add PYTHONPATH to subprocess environment for bundled packages
|
||||
|
||||
- Prevent crash after worktree creation in terminal
|
||||
|
||||
- Ensure PATH includes system directories when launched from Electron
|
||||
|
||||
- Grant worktree access to original project directories
|
||||
|
||||
- Filter task IPC events by project to prevent cross-project interference
|
||||
|
||||
- Verify critical packages exist, not just marker file during Python bundling
|
||||
|
||||
- Await async sendMessage to prevent race condition in insights
|
||||
|
||||
- Add pywin32 dependency for LadybugDB on Windows
|
||||
|
||||
- Handle Ollama version errors during model pull
|
||||
|
||||
- Add helpful error message when Python dependencies are missing
|
||||
|
||||
- Prevent app freeze by making Claude CLI detection non-blocking
|
||||
|
||||
- Use Homebrew for Ollama installation on macOS
|
||||
|
||||
- Use --continue instead of --resume for Claude session restoration
|
||||
|
||||
- Add context menu for keyboard-accessible task status changes
|
||||
|
||||
- Security allowlist now works correctly in worktree mode
|
||||
|
||||
- Fix InvestigationDialog overflow issue
|
||||
|
||||
- Auto-create .env from .env.example during backend install
|
||||
|
||||
- Show OAuth terminal during profile authentication
|
||||
|
||||
- Pass augmented env to Claude CLI validation on macOS
|
||||
|
||||
- Fix Git bash path detection on Windows
|
||||
|
||||
- Support API profiles in auth check and model resolution
|
||||
|
||||
- Window size adjustment on Hi-DPI displays
|
||||
|
||||
- Centralize Claude CLI invocation
|
||||
|
||||
- Pass OAuth token to Python runner subprocesses for GitHub operations
|
||||
|
||||
- Resolve React Fast Refresh hook error in usePtyProcess
|
||||
|
||||
- Detect @lydell/node-pty prebuilts in postinstall
|
||||
|
||||
- Detect Claude CLI installed via NVM on Linux/macOS
|
||||
|
||||
- Allow toggle deselection and improve embedding model name matching
|
||||
|
||||
- Sanitize environment to prevent PYTHONHOME contamination
|
||||
|
||||
- Check .claude.json for OAuth auth in profile scorer
|
||||
|
||||
- Use shell mode for Windows command spawning in MCP
|
||||
|
||||
- Update TaskCard description truncation for improved display
|
||||
|
||||
- Change hardcoded Opus defaults to Sonnet
|
||||
|
||||
- Include update manifests for architecture-specific auto-updates
|
||||
|
||||
- Fix security hook cwd extraction and PATH issues
|
||||
|
||||
- Filter empty env vars to prevent OAuth token override
|
||||
|
||||
- Persist human_review status (worktree plan path fix)
|
||||
|
||||
- Resolve PATH and PYTHONPATH issues in insights and changelog services
|
||||
|
||||
- Pass electron version explicitly to electron-rebuild on Windows
|
||||
|
||||
- Complete refresh button implementation for Kanban
|
||||
|
||||
- Fixed version-specific links in readme and pre-commit hook
|
||||
|
||||
- Preserve terminal state when switching projects
|
||||
|
||||
- Close parent modal when Edit dialog opens
|
||||
|
||||
- Solve LadybugDB problem on Windows during npm install
|
||||
|
||||
- Handle Windows CRLF line endings in regex fallback
|
||||
|
||||
- Respect preferred terminal setting for Windows PTY shell
|
||||
|
||||
- Detect and clear cross-platform CLI paths in settings
|
||||
|
||||
- Preserve original task description after spec creation
|
||||
|
||||
- Fix learning loop to retrieve patterns and gotchas
|
||||
|
||||
- Resolve frontend lag and update dependencies
|
||||
|
||||
- Allow external HTTPS images in Content-Security-Policy
|
||||
|
||||
- Use temporary worktree for PR review isolation
|
||||
|
||||
- Prefer versioned Homebrew Python over system python3
|
||||
|
||||
- Support bun.lock text format for Bun 1.2.0+
|
||||
|
||||
- Create spec.md during roadmap-to-task conversion
|
||||
|
||||
- Treat LOW-only findings as ready to merge in PR review
|
||||
|
||||
- Prevent infinite re-render loop in task selection
|
||||
|
||||
- Accept Python 3.12+ in install-backend.js
|
||||
|
||||
- Infinite loop in useTaskDetail merge preview loading
|
||||
|
||||
- Resolve EINVAL error when opening worktree in VS Code on Windows
|
||||
|
||||
- Add fallback to prevent tasks stuck in ai_review status
|
||||
|
||||
- Add spec_dir to SDK permissions
|
||||
|
||||
- Add --base-branch argument support to spec_runner
|
||||
|
||||
- Allow Windows to run PR Reviewer
|
||||
|
||||
- Respect task_metadata.json model selection
|
||||
|
||||
- Add .js extension to electron-log/main imports
|
||||
|
||||
- Move Swift detection before Ruby detection in analyzer
|
||||
|
||||
- Prevent TaskEditDialog from unmounting when opened
|
||||
|
||||
- Add iOS/Swift project detection
|
||||
|
||||
- Memory Status card respects configured embedding provider
|
||||
|
||||
- Remove projectTabs from useEffect dependencies to fix re-render loop
|
||||
|
||||
- Invalidate profile cache when file is created/modified
|
||||
|
||||
- Handle Python paths with spaces in subprocess
|
||||
|
||||
- Preserve terminal state when switching projects
|
||||
|
||||
- Add C#/Java/Swift/Kotlin project files to security hash
|
||||
|
||||
- Make backend tests pass on Windows
|
||||
|
||||
- Stop tracking spec files in git
|
||||
|
||||
- Sync status to worktree implementation plan to prevent reset
|
||||
|
||||
- Fix task status persistence reverting on refresh
|
||||
|
||||
- Proper semver comparison for pre-release versions
|
||||
|
||||
- Use venv Python for all services to fix dotenv errors
|
||||
|
||||
- Use explicit Windows System32 tar path in build
|
||||
|
||||
- Use PowerShell for tar extraction on Windows
|
||||
|
||||
- Add --force-local flag to tar on Windows
|
||||
|
||||
- Add explicit GET method to gh api comment fetches
|
||||
|
||||
- Support archiving tasks across all worktree locations
|
||||
|
||||
- Validate backend source path before using it
|
||||
|
||||
- Resolve spawn python ENOENT error on Linux
|
||||
|
||||
- Resolve CodeQL file system race conditions and unused variables
|
||||
|
||||
- Use correct electron-builder arch flags
|
||||
|
||||
- Use develop branch for dry-run builds in beta-release workflow
|
||||
|
||||
- Accept bug_fix workflow_type alias during planning
|
||||
|
||||
- Normalize relative paths to posix
|
||||
|
||||
- Update path resolution for ollama_model_detector.py in memory handlers
|
||||
|
||||
- Resolve Python detection and backend packaging issues
|
||||
|
||||
- Add future annotations import to discovery.py
|
||||
|
||||
- Add global spec numbering lock to prevent collisions
|
||||
|
||||
- Add Python 3.10+ version validation and GitHub Actions Python setup
|
||||
|
||||
- Correct welcome workflow PR message
|
||||
|
||||
- Hide status badge when execution phase badge is showing
|
||||
|
||||
- Stop running process when task status changes away from in_progress
|
||||
|
||||
- Remove legacy path from auto-claude source detection
|
||||
|
||||
- Resolve Python environment race condition
|
||||
|
||||
- Persist staged task state across app restarts
|
||||
|
||||
- Update progress calculation to include just-completed ideation type
|
||||
|
||||
- Add missing ARIA attributes for screen reader accessibility
|
||||
|
||||
- Restore missing aria-label attributes on icon buttons
|
||||
|
||||
- Enable scrolling in Project Files list in Task Creation Wizard
|
||||
|
||||
---
|
||||
|
||||
## What's Changed
|
||||
|
||||
- chore: bump version to 2.7.3 by @Test User in 53e2ef6c
|
||||
- fix(core): implement atomic JSON writes to prevent file corruption (ACS-209) (#915) by @StillKnotKnown in 3c56a1ba
|
||||
- fix(frontend): prevent "Render frame was disposed" crash (ACS-211) (#918) by @StillKnotKnown in 179744e2
|
||||
- fix(frontend): strip ANSI escape codes from roadmap/ideation progress messages (ACS-219) (#933) by @StillKnotKnown in 9e86de76
|
||||
- fix(ACS-175): Resolve integrations freeze and improve rate limit handling (#839) by @Michael Ludlow in 3ca15e1c
|
||||
- fix(memory): use shared project-wide memory for cross-spec learning (#905) by @StillKnotKnown in 0c139add
|
||||
- fix(graphiti): add isinstance(dict) validation to prevent AttributeError (ACS-215) (#924) by @StillKnotKnown in d9e3b286
|
||||
- fix(planner): enforce implementation_plan schema (issue #884) (#912) by @Umaru in 29d28bf0
|
||||
- fix(build): remove obsolete @lydell/node-pty extraResources entry by @Test User in c4e08aee
|
||||
- fix(ui): add Post Clean Review button for clean PR reviews (ACS-201) (#894) by @StillKnotKnown in f43c7c51
|
||||
- fix(ACS-203): Fix Kanban status flip-flop and phase state inconsistency (#898) by @StillKnotKnown in 96fc6129
|
||||
- fix(merge): resolve multiple merge-related issues (ACS-194, ACS-179, ACS-174, ACS-163) (#885) by @StillKnotKnown in d024eec1
|
||||
- fix(github-prs): show running review state when switching back to PR with in-progress review (ACS-200) (#890) by @StillKnotKnown in d9ed8179
|
||||
- fix: properly quote Windows .cmd/.bat paths in spawn() calls (#889) by @StillKnotKnown in 6dc538c8
|
||||
- Fix/worktree branch selection (#854) by @Andy in a6bd8842
|
||||
- refactor(ui): extract shared task form components for consistent modals (#765) by @Andy in df540ec5
|
||||
- fix(ui): persist staged task state across app restarts (#800) by @Andy in 91bd2401
|
||||
- fix: improve Claude CLI detection on Windows with space-containing paths (#827) by @Umaru in 11710c55
|
||||
- fix(ui): display subtask titles instead of UUIDs (#844) (#849) by @Andy in 660e1ada
|
||||
- fix(ci): use HTTP for Azure Trusted Signing timestamp URL (#843) by @Andy in 152678bd
|
||||
- fix(ACS-51, ACS-55, ACS-71): Fix Kanban state transitions and status flip-flop bug (#824) by @Adam Slaker in dc29794e
|
||||
- fix(github): use selectedPR from hook to restore Files changed list (#822) by @StillKnotKnown in c623ab00
|
||||
- ci(release): add Azure Trusted Signing for Windows builds (#805) by @Andy in 20458849
|
||||
- feat: Add Sentry environment variables to CI build workflows (#803) by @Andy in 63e142ae
|
||||
- Fix pydantic_core missing module error during packaging (#806) by @Maxim Kosterin in 07ae1ef7
|
||||
- feat: add Claude Code changelog link to version notifiers (#820) by @StillKnotKnown in ada91fb1
|
||||
- feat(github): enhance PR merge readiness checks with branch state validation (#751) by @Andy in cbb1cb81
|
||||
- fix: automate auto labeling based on comments (#812) by @Alex in 32e8fee3
|
||||
- feat: add PR creation workflow for task worktrees (#677) by @ThrownLemon in a74bd865
|
||||
- fix: increase Claude SDK JSON buffer size to 10MB (#815) by @StillKnotKnown in e310d56f
|
||||
- fix(a11y): restore missing aria-label attributes on icon buttons (#808) by @Orinks in ab3149fc
|
||||
- feat: Add terminal copy/paste keyboard shortcuts for Windows/Linux (#786) by @StillKnotKnown in a6ffd0e1
|
||||
- fix(ui): enable scrolling in Project Files list in Task Creation Wizard (#757) (#785) by @Ashwinhegde19 in 05c652e4
|
||||
- fix: resolve subtasks tab not updating on Linux (#794) by @StillKnotKnown in 29ef46d7
|
||||
- fix: add PYTHONPATH to subprocess environment for bundled packages (#139) (#777) by @Andy in a47354b4
|
||||
- fix(terminal): prevent crash after worktree creation (#771) by @Andy in 40fc7e4d
|
||||
- feat(pr-review): add prominent verdict summary to PR review comments (#780) by @Andy in 63766f76
|
||||
- fix(frontend): ensure PATH includes system directories when launched (#748) by @Marcelo Czerewacz in 4cc9198a
|
||||
- fix(permissions): grant worktree access to original project directories (#385) (#776) by @Andy in 42033412
|
||||
- fix(multi-project): filter task IPC events by project to prevent cross-project interference (#723) (#775) by @Andy in cc78d7ae
|
||||
- fix(python-bundling): verify critical packages exist, not just marker file (#416) (#774) by @Andy in 061411d7
|
||||
- fix(insights): await async sendMessage to prevent race condition (#613) (#773) by @Andy in cbd47f2c
|
||||
- fix(windows): add pywin32 dependency for LadybugDB (#627) (#778) by @Andy in fbaf2e7a
|
||||
- fix(memory): handle Ollama version errors during model pull (#760) by @Brett Bonner in 01decaeb
|
||||
- ACS-103 Windows can finish a task (#739) by @Alex in 96b7eb4a
|
||||
- fix(roadmap): normalize feature status values for Kanban display [ACS-115] (#763) by @Michael Ludlow in 5e783908
|
||||
- fix: add helpful error message when Python dependencies are missing (ACS-145) (#755) by @StillKnotKnown in 31519c2a
|
||||
- fix(startup): prevent app freeze by making Claude CLI detection non-blocking (#680 regression) (#720) by @Adam Slaker in f4069590
|
||||
- refactor: simplify task description handling and improve modal layout (#750) by @Andy in e3d72d64
|
||||
- fix(memory): use Homebrew for Ollama installation on macOS (#742) by @Michael Ludlow in e9c859cc
|
||||
- fix: use --continue instead of --resume for Claude session restoration (#699) by @Andy in 7fda36ad
|
||||
- fix: Multiple bug fixes including binary file handling and semantic tracking (#732) by @Andy in 78b80bca
|
||||
- fix(a11y): Add context menu for keyboard-accessible task status changes (#710) by @Orinks in 724ad827
|
||||
- Fix: Security allowlist not working in worktree mode (#646) by @arcker in 2f321fb2
|
||||
- fix: InvestigationDialog overflow issue (#669) by @Masanori Uehara in df57fbf8
|
||||
- fix(setup): auto-create .env from .env.example during backend install (#713) by @Crimson341 in 84bc5226
|
||||
- fix: show OAuth terminal during profile authentication (#671) by @Bogdan Dragomir in 8a4b5066
|
||||
- fix: pass augmented env to Claude CLI validation on macOS (#640) by @tallinn102 in 574cd117
|
||||
- fix: WIndows not finding the gith bash path (#724) by @Alex in 09aa4f4f
|
||||
- fix(profiles): support API profiles in auth check and model resolution (#608) by @Ginanjar Noviawan in 78aceaed
|
||||
- Fix Window Size on Hi-DPI Displays (#696) by @aaronson2012 in 5005e56e
|
||||
- fix: centralize Claude CLI invocation (#680) by @StillKnotKnown in ec4441c1
|
||||
- fix(github): pass OAuth token to Python runner subprocesses (fixes #563) (#698) by @Michael Ludlow in 97f34496
|
||||
- chore: Update Linux app icon to use multiple resolution sizes and fix .deb icon (#672) by @Rooki in 2c9fcbf4
|
||||
- fix(a11y): Add missing ARIA attributes for screen reader accessibility (#634) by @Orinks in 3930b12c
|
||||
- docs: add stars badge and star history chart to README (#675) by @eddie333016 in e2937320
|
||||
- fix(terminal): resolve React Fast Refresh hook error in usePtyProcess by @AndyMik90 in 81afc3d2
|
||||
- sentry dev support + sessions handling in terminals by @AndyMik90 in 63f46173
|
||||
- fix(frontend): detect @lydell/node-pty prebuilts in postinstall (#673) by @Vinícius Santos in 35573fd5
|
||||
- Fix/small fixes all around (#645) by @Andy in 7b4993e9
|
||||
- fix: detect Claude CLI installed via NVM on Linux/macOS (#623) by @StillKnotKnown in c2713543
|
||||
- fix: improve GLM presets, ideation auth, and Insights env (#648) by @StillKnotKnown in 6fb2d484
|
||||
- Fix/update app (#594) by @Andy in 1e3e8bda
|
||||
- feat(sentry): add anonymous error reporting with privacy controls (#636) by @Andy in 8be0e6ff
|
||||
- fix(settings): allow toggle deselection and improve embedding model name matching (#661) by @Michael Ludlow in 234d44f6
|
||||
- fix(python): sanitize environment to prevent PYTHONHOME contamination (#664) by @Michael Ludlow in 65f60898
|
||||
- fix: check .claude.json for OAuth auth in profile scorer (#652) by @Michael Ludlow in eeef8a3d
|
||||
- fix(mcp): use shell mode for Windows command spawning (#572) by @Andy in e1e89430
|
||||
- fix(ui): update TaskCard description truncation for improved display (#637) by @Andy in b7203124
|
||||
- fix: change hardcoded Opus defaults to Sonnet (fix #433) (#633) by @Michael Ludlow in 46c41f8f
|
||||
- Fix/small fixes 2.7.3 (#631) by @Andy in 39da8193
|
||||
- fix(ci): include update manifests for architecture-specific auto-updates (#611) by @Hunter Luisi in f7b02e87
|
||||
- fix: security hook cwd extraction and PATH issues (#555, #556) (#587) by @Hunter Luisi in 4ec9db8c
|
||||
- fix(frontend): filter empty env vars to prevent OAuth token override (#520) by @Ashwinhegde19 in 556f0b21
|
||||
- refactor(github-review): replace confidence scoring with evidence-based validation (#628) by @Andy in acdd7d9b
|
||||
- feat(terminal): add worktree support for terminals (#625) by @Andy in 13535f1b
|
||||
- fix: human_review status persistence bug (worktree plan path fix) (#605) by @Michael Ludlow in 7177c799
|
||||
- fix(frontend): resolve PATH and PYTHONPATH issues in insights and changelog services (#558) (#610) by @Hunter Luisi in f5be7943
|
||||
- fix: pass electron version explicitly to electron-rebuild on Windows (#622) by @Vinícius Santos in 14b3db56
|
||||
- fix(kanban): complete refresh button implementation (#584) by @Michael Ludlow in 6c855905
|
||||
- feat: add Dart/Flutter/Melos support to security profiles (#583) by @Mitsu in 4a833048
|
||||
- docs: update stable download links to v2.7.2 (#579) by @Alex in 5efc2c56
|
||||
- Improving Task Card Title Readability (#461) by @Vinícius Santos in 3086233f
|
||||
- feat: custom Anthropic compatible API profile management (#181) by @Ginanjar Noviawan in d278963b
|
||||
- 2.7.2 release by @AndyMik90 in 6ac3012f
|
||||
- fix: Solve ladybug problem on running npm install all on windows (#576) by @Alex in effaa681
|
||||
- fix(merge): handle Windows CRLF line endings in regex fallback by @AndyMik90 in 04de8c78
|
||||
- ci(release): add CHANGELOG.md validation and fix release workflow by @AndyMik90 in 6d4231ed
|
||||
- 🔥 hotfix(electron): restore app functionality on Windows broken by GPU cache errors (#569) by @sniggl in dedd0757
|
||||
- fix(ci): cache pip wheels to speed up Intel Mac builds by @AndyMik90 in 90dddc28
|
||||
- feat(terminal): respect preferred terminal setting for Windows PTY shell by @AndyMik90 in 90a20320
|
||||
- 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
|
||||
- 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
|
||||
|
||||
@Test User, @StillKnotKnown, @Umaru, @Andy, @Adam Slaker, @Michael Ludlow, @Maxim Kosterin, @ThrownLemon, @Ashwinhegde19, @Orinks, @Marcelo Czerewacz, @Brett Bonner, @Alex, @Rooki, @eddie333016, @AndyMik90, @Vinícius Santos, @arcker, @Masanori Uehara, @Crimson341, @Bogdan Dragomir, @tallinn102, @Ginanjar Noviawan, @aaronson2012, @Hunter Luisi, @Navid, @Mulaveesala Pranaveswar, @sniggl, @Abe Diaz, @Mitsu, @Joe, @Illia Filippov, @Ian, @Brian, @Kevin Rajan, @HSSAINI Saad, @JoshuaRileyDev, @souky-byte, @Alex, @Oluwatosin Oyeladun, @Daniel Frey, @delyethan, @Joris Slagter, @Fernando Possebon, @Enes Cingöz, @Todd W. Bucy, @dependabot[bot], @rayBlock
|
||||
|
||||
## 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
|
||||
|
||||
@@ -370,7 +370,6 @@ The frontend uses `react-i18next` for internationalization. All labels, buttons,
|
||||
- `settings.json` - Settings page content
|
||||
- `dialogs.json` - Dialog boxes and modals
|
||||
- `tasks.json` - Task/spec related content
|
||||
- `errors.json` - Error messages (structured error information with substitution support)
|
||||
- `onboarding.json` - Onboarding wizard content
|
||||
- `welcome.json` - Welcome screen content
|
||||
|
||||
@@ -386,16 +385,6 @@ const { t } = useTranslation(['navigation', 'common']);
|
||||
<span>GitHub PRs</span> // ❌ WRONG
|
||||
```
|
||||
|
||||
**Error messages with substitution:**
|
||||
|
||||
```tsx
|
||||
// For error messages with dynamic content, use interpolation
|
||||
const { t } = useTranslation(['errors']);
|
||||
|
||||
// errors.json: { "task": { "parseError": "Failed to parse: {{error}}" } }
|
||||
<span>{t('errors:task.parseError', { error: errorMessage })}</span>
|
||||
```
|
||||
|
||||
**When adding new UI text:**
|
||||
1. Add the translation key to ALL language files (at minimum: `en/*.json` and `fr/*.json`)
|
||||
2. Use `namespace:section.key` format (e.g., `navigation:items.githubPRs`)
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
|
||||

|
||||
|
||||
<!-- TOP_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.1)
|
||||
<!-- TOP_VERSION_BADGE_END -->
|
||||
[](./agpl-3.0.txt)
|
||||
[](https://discord.gg/KCXaPBr4Dj)
|
||||
[](https://www.youtube.com/@AndreMikalsen)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/actions)
|
||||
|
||||
---
|
||||
@@ -16,18 +18,17 @@
|
||||
### Stable Release
|
||||
|
||||
<!-- STABLE_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.4)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.1)
|
||||
<!-- STABLE_VERSION_BADGE_END -->
|
||||
|
||||
<!-- STABLE_DOWNLOADS -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **Windows** | [Auto-Claude-2.7.3-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.3/Auto-Claude-2.7.3-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.3-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.3/Auto-Claude-2.7.3-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.3-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.3/Auto-Claude-2.7.3-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.3-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.3/Auto-Claude-2.7.3-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.3-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.3/Auto-Claude-2.7.3-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.3-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.3/Auto-Claude-2.7.3-linux-x86_64.flatpak) |
|
||||
| **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) |
|
||||
<!-- STABLE_DOWNLOADS_END -->
|
||||
|
||||
### Beta Release
|
||||
@@ -58,6 +59,7 @@
|
||||
- **Claude Pro/Max subscription** - [Get one here](https://claude.ai/upgrade)
|
||||
- **Claude Code CLI** - `npm install -g @anthropic-ai/claude-code`
|
||||
- **Git repository** - Your project must be initialized as a git repo
|
||||
- **Python 3.12+** - Required for the backend and Memory Layer
|
||||
|
||||
---
|
||||
|
||||
@@ -146,11 +148,113 @@ See [guides/CLI-USAGE.md](guides/CLI-USAGE.md) for complete CLI documentation.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
## Configuration
|
||||
|
||||
Want to build from source or contribute? See [CONTRIBUTING.md](CONTRIBUTING.md) for complete development setup instructions.
|
||||
Create `apps/backend/.env` from the example:
|
||||
|
||||
For Linux-specific builds (Flatpak, AppImage), see [guides/linux.md](guides/linux.md).
|
||||
```bash
|
||||
cp apps/backend/.env.example apps/backend/.env
|
||||
```
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `CLAUDE_CODE_OAUTH_TOKEN` | Yes | OAuth token from `claude setup-token` |
|
||||
| `GRAPHITI_ENABLED` | No | Enable Memory Layer for cross-session context |
|
||||
| `AUTO_BUILD_MODEL` | No | Override the default Claude model |
|
||||
| `GITLAB_TOKEN` | No | GitLab Personal Access Token for GitLab integration |
|
||||
| `GITLAB_INSTANCE_URL` | No | GitLab instance URL (defaults to gitlab.com) |
|
||||
| `LINEAR_API_KEY` | No | Linear API key for task sync |
|
||||
|
||||
---
|
||||
|
||||
## Building from Source
|
||||
|
||||
For contributors and development:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/AndyMik90/Auto-Claude.git
|
||||
cd Auto-Claude
|
||||
|
||||
# Install all dependencies
|
||||
npm run install:all
|
||||
|
||||
# Run in development mode
|
||||
npm run dev
|
||||
|
||||
# Or build and run
|
||||
npm start
|
||||
```
|
||||
|
||||
**System requirements for building:**
|
||||
- Node.js 24+
|
||||
- Python 3.12+
|
||||
- npm 10+
|
||||
|
||||
**Installing dependencies by platform:**
|
||||
|
||||
<details>
|
||||
<summary><b>Windows</b></summary>
|
||||
|
||||
```bash
|
||||
winget install Python.Python.3.12
|
||||
winget install OpenJS.NodeJS.LTS
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>macOS</b></summary>
|
||||
|
||||
```bash
|
||||
brew install python@3.12 node@24
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Linux (Ubuntu/Debian)</b></summary>
|
||||
|
||||
```bash
|
||||
sudo apt install python3.12 python3.12-venv
|
||||
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
|
||||
sudo apt install -y nodejs
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Linux (Fedora)</b></summary>
|
||||
|
||||
```bash
|
||||
sudo dnf install python3.12 nodejs npm
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed development setup.
|
||||
|
||||
### Building Flatpak
|
||||
|
||||
To build the Flatpak package, you need additional dependencies:
|
||||
|
||||
```bash
|
||||
# Fedora/RHEL
|
||||
sudo dnf install flatpak-builder
|
||||
|
||||
# Ubuntu/Debian
|
||||
sudo apt install flatpak-builder
|
||||
|
||||
# Install required Flatpak runtimes
|
||||
flatpak install flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
|
||||
flatpak install flathub org.electronjs.Electron2.BaseApp//25.08
|
||||
|
||||
# Build the Flatpak
|
||||
cd apps/frontend
|
||||
npm run package:flatpak
|
||||
```
|
||||
|
||||
The Flatpak will be created in `apps/frontend/dist/`.
|
||||
|
||||
---
|
||||
|
||||
@@ -180,7 +284,7 @@ All releases are:
|
||||
| `npm run package:mac` | Package for macOS |
|
||||
| `npm run package:win` | Package for Windows |
|
||||
| `npm run package:linux` | Package for Linux |
|
||||
| `npm run package:flatpak` | Package as Flatpak (see [guides/linux.md](guides/linux.md)) |
|
||||
| `npm run package:flatpak` | Package as Flatpak |
|
||||
| `npm run lint` | Run linter |
|
||||
| `npm test` | Run frontend tests |
|
||||
| `npm run test:backend` | Run backend tests |
|
||||
@@ -212,11 +316,3 @@ We welcome contributions! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for:
|
||||
Auto Claude is free to use. If you modify and distribute it, or run it as a service, your code must also be open source under AGPL-3.0.
|
||||
|
||||
Commercial licensing available for closed-source use cases.
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://github.com/AndyMik90/Auto-Claude/stargazers)
|
||||
|
||||
[](https://star-history.com/#AndyMik90/Auto-Claude&Date)
|
||||
|
||||
+23
-90
@@ -69,38 +69,9 @@ 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: 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
|
||||
### Step 2: Push and Create PR
|
||||
|
||||
```bash
|
||||
# Push your branch
|
||||
@@ -110,25 +81,24 @@ git push origin your-branch
|
||||
gh pr create --base main --title "Release v2.8.0"
|
||||
```
|
||||
|
||||
### Step 4: Merge to Main
|
||||
### Step 3: 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. **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:
|
||||
2. **Create a git tag** (e.g., `v2.8.0`)
|
||||
3. **Trigger the release workflow** (`release.yml`)
|
||||
4. **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)
|
||||
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
|
||||
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
|
||||
|
||||
### Step 5: Verify
|
||||
### Step 4: Verify
|
||||
|
||||
After merging, check:
|
||||
- [GitHub Actions](https://github.com/AndyMik90/Auto-Claude/actions) - ensure all workflows pass
|
||||
@@ -143,49 +113,28 @@ We follow [Semantic Versioning](https://semver.org/):
|
||||
- **MINOR** (0.X.0): New features, backwards compatible
|
||||
- **PATCH** (0.0.X): Bug fixes, backwards compatible
|
||||
|
||||
## Changelog Management
|
||||
## Changelog Generation
|
||||
|
||||
Release notes are managed in `CHANGELOG.md` and used for GitHub releases.
|
||||
Changelogs are automatically generated from merged PRs using [Release Drafter](https://github.com/release-drafter/release-drafter).
|
||||
|
||||
### Changelog Format
|
||||
### PR Labels for Changelog Categories
|
||||
|
||||
Each version entry in `CHANGELOG.md` should follow this format:
|
||||
| Label | Category |
|
||||
|-------|----------|
|
||||
| `feature`, `enhancement` | New Features |
|
||||
| `bug`, `fix` | Bug Fixes |
|
||||
| `improvement`, `refactor` | Improvements |
|
||||
| `documentation` | Documentation |
|
||||
| (any other) | Other Changes |
|
||||
|
||||
```markdown
|
||||
## X.Y.Z - Release Title
|
||||
|
||||
### ✨ New Features
|
||||
- Feature description with context
|
||||
|
||||
### 🛠️ 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")
|
||||
**Tip:** Add appropriate labels to your PRs for better changelog organization.
|
||||
|
||||
## Workflows
|
||||
|
||||
| Workflow | Trigger | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `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 |
|
||||
| `prepare-release.yml` | Push to `main` | Detects version bump, creates tag |
|
||||
| `release.yml` | Tag `v*` pushed | Builds binaries, 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 |
|
||||
|
||||
@@ -204,22 +153,6 @@ The release workflow **validates** that `CHANGELOG.md` has an entry for the vers
|
||||
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
|
||||
|
||||
@@ -19,5 +19,5 @@ Quick Start:
|
||||
See README.md for full documentation.
|
||||
"""
|
||||
|
||||
__version__ = "2.7.4"
|
||||
__version__ = "2.7.2-beta.10"
|
||||
__author__ = "Auto Claude Team"
|
||||
|
||||
@@ -26,7 +26,7 @@ auto-claude/agents/
|
||||
### `utils.py` (3.6 KB)
|
||||
- Git operations: `get_latest_commit()`, `get_commit_count()`
|
||||
- Plan management: `load_implementation_plan()`, `find_subtask_in_plan()`, `find_phase_for_subtask()`
|
||||
- Workspace sync: `sync_spec_to_source()`
|
||||
- Workspace sync: `sync_plan_to_source()`
|
||||
|
||||
### `memory.py` (13 KB)
|
||||
- Dual-layer memory system (Graphiti primary, file-based fallback)
|
||||
@@ -73,7 +73,7 @@ from agents import (
|
||||
# Utilities
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_spec_to_source,
|
||||
sync_plan_to_source,
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -14,10 +14,6 @@ This module provides:
|
||||
Uses lazy imports to avoid circular dependencies.
|
||||
"""
|
||||
|
||||
# Explicit import required by CodeQL static analysis
|
||||
# (CodeQL doesn't recognize __getattr__ dynamic exports)
|
||||
from .utils import sync_spec_to_source
|
||||
|
||||
__all__ = [
|
||||
# Main API
|
||||
"run_autonomous_agent",
|
||||
@@ -36,7 +32,7 @@ __all__ = [
|
||||
"load_implementation_plan",
|
||||
"find_subtask_in_plan",
|
||||
"find_phase_for_subtask",
|
||||
"sync_spec_to_source",
|
||||
"sync_plan_to_source",
|
||||
# Constants
|
||||
"AUTO_CONTINUE_DELAY_SECONDS",
|
||||
"HUMAN_INTERVENTION_FILE",
|
||||
@@ -81,7 +77,7 @@ def __getattr__(name):
|
||||
"get_commit_count",
|
||||
"get_latest_commit",
|
||||
"load_implementation_plan",
|
||||
"sync_spec_to_source",
|
||||
"sync_plan_to_source",
|
||||
):
|
||||
from .utils import (
|
||||
find_phase_for_subtask,
|
||||
@@ -89,7 +85,7 @@ def __getattr__(name):
|
||||
get_commit_count,
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_spec_to_source,
|
||||
sync_plan_to_source,
|
||||
)
|
||||
|
||||
return locals()[name]
|
||||
|
||||
@@ -7,7 +7,6 @@ Main autonomous agent loop that runs the coder agent to implement subtasks.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from core.client import create_client
|
||||
@@ -38,7 +37,6 @@ from prompt_generator import (
|
||||
)
|
||||
from prompts import is_first_run
|
||||
from recovery import RecoveryManager
|
||||
from security.constants import PROJECT_DIR_ENV_VAR
|
||||
from task_logger import (
|
||||
LogPhase,
|
||||
get_task_logger,
|
||||
@@ -64,7 +62,7 @@ from .utils import (
|
||||
get_commit_count,
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_spec_to_source,
|
||||
sync_plan_to_source,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -92,10 +90,6 @@ async def run_autonomous_agent(
|
||||
verbose: Whether to show detailed output
|
||||
source_spec_dir: Original spec directory in main project (for syncing from worktree)
|
||||
"""
|
||||
# Set environment variable for security hooks to find the correct project directory
|
||||
# This is needed because os.getcwd() may return the wrong directory in worktree mode
|
||||
os.environ[PROJECT_DIR_ENV_VAR] = str(project_dir.resolve())
|
||||
|
||||
# Initialize recovery manager (handles memory persistence)
|
||||
recovery_manager = RecoveryManager(spec_dir, project_dir)
|
||||
|
||||
@@ -136,25 +130,6 @@ 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(
|
||||
@@ -242,8 +217,8 @@ async def run_autonomous_agent(
|
||||
print("To continue, run the script again without --max-iterations")
|
||||
break
|
||||
|
||||
# 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)
|
||||
# Get the next subtask to work on
|
||||
next_subtask = 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
|
||||
|
||||
@@ -282,35 +257,16 @@ async def run_autonomous_agent(
|
||||
phase_thinking_budget = get_phase_thinking_budget(spec_dir, current_phase)
|
||||
|
||||
# Create client (fresh context) with phase-specific model and thinking
|
||||
# Use appropriate agent_type for correct tool permissions and thinking budget
|
||||
client = create_client(
|
||||
project_dir,
|
||||
spec_dir,
|
||||
phase_model,
|
||||
agent_type="planner" if first_run else "coder",
|
||||
max_thinking_tokens=phase_thinking_budget,
|
||||
)
|
||||
|
||||
# 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
|
||||
planner_context = await get_graphiti_context(
|
||||
spec_dir,
|
||||
project_dir,
|
||||
{
|
||||
"description": "Planning implementation for new feature",
|
||||
"id": "planner",
|
||||
},
|
||||
)
|
||||
if planner_context:
|
||||
prompt += "\n\n" + planner_context
|
||||
print_status("Graphiti memory context loaded for planner", "success")
|
||||
|
||||
first_run = False
|
||||
current_log_phase = LogPhase.PLANNING
|
||||
|
||||
@@ -332,10 +288,6 @@ 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!")
|
||||
@@ -394,46 +346,8 @@ 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) ===
|
||||
# Only run post-session processing for coding sessions.
|
||||
if subtask_id and current_log_phase == LogPhase.CODING:
|
||||
if subtask_id and not first_run:
|
||||
linear_is_enabled = (
|
||||
linear_task is not None and linear_task.task_id is not None
|
||||
)
|
||||
@@ -471,9 +385,9 @@ async def run_autonomous_agent(
|
||||
attempt_count=attempt_count,
|
||||
)
|
||||
print_status("Linear notified of stuck subtask", "info")
|
||||
elif plan_validated and source_spec_dir:
|
||||
elif is_planning_phase and source_spec_dir:
|
||||
# After planning phase, sync the newly created implementation plan back to source
|
||||
if sync_spec_to_source(spec_dir, source_spec_dir):
|
||||
if sync_plan_to_source(spec_dir, source_spec_dir):
|
||||
print_status("Implementation plan synced to main project", "success")
|
||||
|
||||
# Handle session status
|
||||
@@ -505,9 +419,7 @@ async def run_autonomous_agent(
|
||||
print_progress_summary(spec_dir)
|
||||
|
||||
# Update state back to building
|
||||
status_manager.update(
|
||||
state=BuildState.PLANNING if is_planning_phase else BuildState.BUILDING
|
||||
)
|
||||
status_manager.update(state=BuildState.BUILDING)
|
||||
|
||||
# Show next subtask info
|
||||
next_subtask = get_next_subtask(spec_dir)
|
||||
|
||||
@@ -24,7 +24,6 @@ from graphiti_config import get_graphiti_status, is_graphiti_enabled
|
||||
# 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__)
|
||||
|
||||
@@ -114,20 +113,24 @@ async def get_graphiti_context(
|
||||
debug("memory", "Graphiti not enabled, skipping context retrieval")
|
||||
return None
|
||||
|
||||
memory = None
|
||||
try:
|
||||
# Use centralized helper for GraphitiMemory instantiation
|
||||
memory = get_graphiti_memory(spec_dir, project_dir)
|
||||
if memory is None:
|
||||
from graphiti_memory import GraphitiMemory
|
||||
|
||||
# Create memory manager
|
||||
memory = GraphitiMemory(spec_dir, project_dir)
|
||||
|
||||
if not memory.is_enabled:
|
||||
if is_debug_enabled():
|
||||
debug_warning("memory", "GraphitiMemory not available")
|
||||
debug_warning("memory", "GraphitiMemory.is_enabled=False")
|
||||
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
|
||||
@@ -143,26 +146,20 @@ async def get_graphiti_context(
|
||||
# Get relevant context
|
||||
context_items = await memory.get_relevant_context(query, num_results=5)
|
||||
|
||||
# Get patterns and gotchas specifically (THE FIX for learning loop!)
|
||||
# This retrieves PATTERN and GOTCHA episode types for cross-session learning
|
||||
patterns, gotchas = await memory.get_patterns_and_gotchas(
|
||||
query, num_results=3, min_score=0.5
|
||||
)
|
||||
|
||||
# Also get recent session history
|
||||
session_history = await memory.get_session_history(limit=3)
|
||||
|
||||
await memory.close()
|
||||
|
||||
if is_debug_enabled():
|
||||
debug(
|
||||
"memory",
|
||||
"Graphiti context retrieval complete",
|
||||
context_items_found=len(context_items) if context_items else 0,
|
||||
patterns_found=len(patterns) if patterns else 0,
|
||||
gotchas_found=len(gotchas) if gotchas else 0,
|
||||
session_history_found=len(session_history) if session_history else 0,
|
||||
)
|
||||
|
||||
if not context_items and not session_history and not patterns and not gotchas:
|
||||
if not context_items and not session_history:
|
||||
if is_debug_enabled():
|
||||
debug("memory", "No relevant context found in Graphiti")
|
||||
return None
|
||||
@@ -178,34 +175,6 @@ async def get_graphiti_context(
|
||||
item_type = item.get("type", "unknown")
|
||||
sections.append(f"- **[{item_type}]** {content}\n")
|
||||
|
||||
# Add patterns section (cross-session learning)
|
||||
if patterns:
|
||||
sections.append("### Learned Patterns\n")
|
||||
sections.append("_Patterns discovered in previous sessions:_\n")
|
||||
for p in patterns:
|
||||
pattern_text = p.get("pattern", "")
|
||||
applies_to = p.get("applies_to", "")
|
||||
if applies_to:
|
||||
sections.append(
|
||||
f"- **Pattern**: {pattern_text}\n _Applies to:_ {applies_to}\n"
|
||||
)
|
||||
else:
|
||||
sections.append(f"- **Pattern**: {pattern_text}\n")
|
||||
|
||||
# Add gotchas section (cross-session learning)
|
||||
if gotchas:
|
||||
sections.append("### Known Gotchas\n")
|
||||
sections.append("_Pitfalls to avoid:_\n")
|
||||
for g in gotchas:
|
||||
gotcha_text = g.get("gotcha", "")
|
||||
solution = g.get("solution", "")
|
||||
if solution:
|
||||
sections.append(
|
||||
f"- **Gotcha**: {gotcha_text}\n _Solution:_ {solution}\n"
|
||||
)
|
||||
else:
|
||||
sections.append(f"- **Gotcha**: {gotcha_text}\n")
|
||||
|
||||
if session_history:
|
||||
sections.append("### Recent Session Insights\n")
|
||||
for session in session_history[:2]: # Only show last 2
|
||||
@@ -224,20 +193,14 @@ 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(
|
||||
@@ -322,24 +285,20 @@ async def save_session_memory(
|
||||
if is_debug_enabled():
|
||||
debug("memory", "Attempting PRIMARY storage: Graphiti")
|
||||
|
||||
memory = None
|
||||
try:
|
||||
# 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"),
|
||||
)
|
||||
from graphiti_memory import GraphitiMemory
|
||||
|
||||
if memory is not None and memory.is_enabled:
|
||||
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"),
|
||||
)
|
||||
|
||||
if memory.is_enabled:
|
||||
if is_debug_enabled():
|
||||
debug("memory", "Saving to Graphiti...")
|
||||
|
||||
@@ -356,6 +315,8 @@ 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)"
|
||||
@@ -376,32 +337,23 @@ 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(
|
||||
"GraphitiMemory.is_enabled=False, falling back to file-based"
|
||||
"Graphiti memory not enabled, falling back to file-based"
|
||||
)
|
||||
if is_debug_enabled():
|
||||
debug_warning("memory", "GraphitiMemory disabled, using FALLBACK")
|
||||
debug_warning(
|
||||
"memory", "GraphitiMemory.is_enabled=False, 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")
|
||||
|
||||
@@ -141,7 +141,7 @@ async def run_followup_planner(
|
||||
if pending_subtasks:
|
||||
# Reset the plan status to in_progress (in case planner didn't)
|
||||
plan.reset_for_followup()
|
||||
await plan.async_save(plan_file)
|
||||
plan.save(plan_file)
|
||||
|
||||
print()
|
||||
content = [
|
||||
|
||||
@@ -21,7 +21,6 @@ from progress import (
|
||||
is_build_complete,
|
||||
)
|
||||
from recovery import RecoveryManager
|
||||
from security.tool_input_validator import get_safe_tool_input
|
||||
from task_logger import (
|
||||
LogEntryType,
|
||||
LogPhase,
|
||||
@@ -40,7 +39,7 @@ from .utils import (
|
||||
get_commit_count,
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_spec_to_source,
|
||||
sync_plan_to_source,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -82,7 +81,7 @@ async def post_session_processing(
|
||||
print(muted("--- Post-Session Processing ---"))
|
||||
|
||||
# Sync implementation plan back to source (for worktree mode)
|
||||
if sync_spec_to_source(spec_dir, source_spec_dir):
|
||||
if sync_plan_to_source(spec_dir, source_spec_dir):
|
||||
print_status("Implementation plan synced to main project", "success")
|
||||
|
||||
# Check if implementation plan was updated
|
||||
@@ -387,43 +386,41 @@ async def run_agent_session(
|
||||
)
|
||||
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
||||
tool_name = block.name
|
||||
tool_input_display = None
|
||||
tool_input = None
|
||||
tool_count += 1
|
||||
|
||||
# Safely extract tool input (handles None, non-dict, etc.)
|
||||
inp = get_safe_tool_input(block)
|
||||
|
||||
# Extract meaningful tool input for display
|
||||
if inp:
|
||||
if "pattern" in inp:
|
||||
tool_input_display = f"pattern: {inp['pattern']}"
|
||||
elif "file_path" in inp:
|
||||
fp = inp["file_path"]
|
||||
if len(fp) > 50:
|
||||
fp = "..." + fp[-47:]
|
||||
tool_input_display = fp
|
||||
elif "command" in inp:
|
||||
cmd = inp["command"]
|
||||
if len(cmd) > 50:
|
||||
cmd = cmd[:47] + "..."
|
||||
tool_input_display = cmd
|
||||
elif "path" in inp:
|
||||
tool_input_display = inp["path"]
|
||||
if hasattr(block, "input") and block.input:
|
||||
inp = block.input
|
||||
if isinstance(inp, dict):
|
||||
if "pattern" in inp:
|
||||
tool_input = f"pattern: {inp['pattern']}"
|
||||
elif "file_path" in inp:
|
||||
fp = inp["file_path"]
|
||||
if len(fp) > 50:
|
||||
fp = "..." + fp[-47:]
|
||||
tool_input = fp
|
||||
elif "command" in inp:
|
||||
cmd = inp["command"]
|
||||
if len(cmd) > 50:
|
||||
cmd = cmd[:47] + "..."
|
||||
tool_input = cmd
|
||||
elif "path" in inp:
|
||||
tool_input = inp["path"]
|
||||
|
||||
debug(
|
||||
"session",
|
||||
f"Tool call #{tool_count}: {tool_name}",
|
||||
tool_input=tool_input_display,
|
||||
full_input=str(inp)[:500] if inp else None,
|
||||
tool_input=tool_input,
|
||||
full_input=str(block.input)[:500]
|
||||
if hasattr(block, "input")
|
||||
else None,
|
||||
)
|
||||
|
||||
# Log tool start (handles printing too)
|
||||
if task_logger:
|
||||
task_logger.tool_start(
|
||||
tool_name,
|
||||
tool_input_display,
|
||||
phase,
|
||||
print_to_console=True,
|
||||
tool_name, tool_input, phase, print_to_console=True
|
||||
)
|
||||
else:
|
||||
print(f"\n[Tool: {tool_name}]", flush=True)
|
||||
@@ -445,9 +442,8 @@ async def run_agent_session(
|
||||
result_content = getattr(block, "content", "")
|
||||
is_error = getattr(block, "is_error", False)
|
||||
|
||||
# Check if this is an error (not just content containing "blocked")
|
||||
if is_error and "blocked" in str(result_content).lower():
|
||||
# Actual blocked command by security hook
|
||||
# Check if command was blocked by security hook
|
||||
if "blocked" in str(result_content).lower():
|
||||
debug_error(
|
||||
"session",
|
||||
f"Tool BLOCKED: {current_tool}",
|
||||
|
||||
@@ -216,9 +216,8 @@ AGENT_CONFIGS = {
|
||||
# QA PHASES (Read + test + browser + Graphiti memory)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"qa_reviewer": {
|
||||
# Read + Write/Edit (for QA reports and plan updates) + Bash (for tests)
|
||||
# Note: Reviewer writes to spec directory only (qa_report.md, implementation_plan.json)
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
|
||||
# Read-only + Bash (for running tests) - reviewer should NOT edit code
|
||||
"tools": BASE_READ_TOOLS + ["Bash"] + WEB_TOOLS,
|
||||
"mcp_servers": ["context7", "graphiti", "auto-claude", "browser"],
|
||||
"mcp_servers_optional": ["linear"], # For updating issue status
|
||||
"auto_claude_tools": [
|
||||
|
||||
@@ -4,16 +4,9 @@ Session Memory Tools
|
||||
|
||||
Tools for recording and retrieving session memory, including discoveries,
|
||||
gotchas, and patterns.
|
||||
|
||||
Dual-storage approach:
|
||||
- File-based: Always available, works offline, spec-specific
|
||||
- LadybugDB: When Graphiti is enabled, also saves to graph database for
|
||||
cross-session retrieval and Memory UI display
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -26,110 +19,6 @@ except ImportError:
|
||||
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:
|
||||
"""
|
||||
@@ -156,7 +45,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
{"file_path": str, "description": str, "category": str},
|
||||
)
|
||||
async def record_discovery(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Record a discovery to the codebase map (file + Graphiti)."""
|
||||
"""Record a discovery to the codebase map."""
|
||||
file_path = args["file_path"]
|
||||
description = args["description"]
|
||||
category = args.get("category", "general")
|
||||
@@ -165,10 +54,8 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
memory_dir.mkdir(exist_ok=True)
|
||||
|
||||
codebase_map_file = memory_dir / "codebase_map.json"
|
||||
saved_to_graphiti = False
|
||||
|
||||
try:
|
||||
# PRIMARY: Save to file-based storage (always works)
|
||||
# Load existing map or create new
|
||||
if codebase_map_file.exists():
|
||||
with open(codebase_map_file) as f:
|
||||
@@ -190,23 +77,11 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
with open(codebase_map_file, "w") as f:
|
||||
json.dump(codebase_map, f, indent=2)
|
||||
|
||||
# SECONDARY: Also save to Graphiti/LadybugDB (for Memory UI)
|
||||
saved_to_graphiti = 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}{storage_note}",
|
||||
"text": f"Recorded discovery for '{file_path}': {description}",
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -227,7 +102,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
{"gotcha": str, "context": str},
|
||||
)
|
||||
async def record_gotcha(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Record a gotcha to session memory (file + Graphiti)."""
|
||||
"""Record a gotcha to session memory."""
|
||||
gotcha = args["gotcha"]
|
||||
context = args.get("context", "")
|
||||
|
||||
@@ -235,10 +110,8 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
memory_dir.mkdir(exist_ok=True)
|
||||
|
||||
gotchas_file = memory_dir / "gotchas.md"
|
||||
saved_to_graphiti = False
|
||||
|
||||
try:
|
||||
# PRIMARY: Save to file-based storage (always works)
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
entry = f"\n## [{timestamp}]\n{gotcha}"
|
||||
@@ -253,20 +126,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
)
|
||||
f.write(entry)
|
||||
|
||||
# SECONDARY: Also save to Graphiti/LadybugDB (for Memory UI)
|
||||
saved_to_graphiti = 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}"}
|
||||
]
|
||||
}
|
||||
return {"content": [{"type": "text", "text": f"Recorded gotcha: {gotcha}"}]}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
|
||||
+38
-103
@@ -8,38 +8,40 @@ Helper functions for git operations, plan management, and file syncing.
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from core.git_executable import run_git
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_latest_commit(project_dir: Path) -> str | None:
|
||||
"""Get the hash of the latest git commit."""
|
||||
result = run_git(
|
||||
["rev-parse", "HEAD"],
|
||||
cwd=project_dir,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
return None
|
||||
except subprocess.CalledProcessError:
|
||||
return None
|
||||
|
||||
|
||||
def get_commit_count(project_dir: Path) -> int:
|
||||
"""Get the total number of commits."""
|
||||
result = run_git(
|
||||
["rev-list", "--count", "HEAD"],
|
||||
cwd=project_dir,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
try:
|
||||
return int(result.stdout.strip())
|
||||
except ValueError:
|
||||
return 0
|
||||
return 0
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-list", "--count", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return int(result.stdout.strip())
|
||||
except (subprocess.CalledProcessError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def load_implementation_plan(spec_dir: Path) -> dict | None:
|
||||
@@ -72,32 +74,16 @@ def find_phase_for_subtask(plan: dict, subtask_id: str) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def sync_spec_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool:
|
||||
def sync_plan_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool:
|
||||
"""
|
||||
Sync ALL spec files from worktree back to source spec directory.
|
||||
Sync implementation_plan.json from worktree back to source spec directory.
|
||||
|
||||
When running in isolated mode (worktrees), the agent creates and updates
|
||||
many files inside the worktree's spec directory. This function syncs ALL
|
||||
of them back to the main project's spec directory.
|
||||
|
||||
IMPORTANT: Since .auto-claude/ is gitignored, this sync happens to the
|
||||
local filesystem regardless of what branch the user is on. The worktree
|
||||
may be on a different branch (e.g., auto-claude/093-task), but the sync
|
||||
target is always the main project's .auto-claude/specs/ directory.
|
||||
|
||||
Files synced (all files in spec directory):
|
||||
- implementation_plan.json - Task status and subtask completion
|
||||
- build-progress.txt - Session-by-session progress notes
|
||||
- task_logs.json - Execution logs
|
||||
- review_state.json - QA review state
|
||||
- critique_report.json - Spec critique findings
|
||||
- suggested_commit_message.txt - Commit suggestions
|
||||
- REGRESSION_TEST_REPORT.md - Test regression report
|
||||
- spec.md, context.json, etc. - Original spec files (for completeness)
|
||||
- memory/ directory - Codebase map, patterns, gotchas, session insights
|
||||
When running in isolated mode (worktrees), the agent updates the implementation
|
||||
plan inside the worktree. This function syncs those changes back to the main
|
||||
project's spec directory so the frontend/UI can see the progress.
|
||||
|
||||
Args:
|
||||
spec_dir: Current spec directory (inside worktree)
|
||||
spec_dir: Current spec directory (may be inside worktree)
|
||||
source_spec_dir: Original spec directory in main project (outside worktree)
|
||||
|
||||
Returns:
|
||||
@@ -114,68 +100,17 @@ def sync_spec_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool:
|
||||
if spec_dir_resolved == source_spec_dir_resolved:
|
||||
return False # Same directory, no sync needed
|
||||
|
||||
synced_any = False
|
||||
# Sync the implementation plan
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if not plan_file.exists():
|
||||
return False
|
||||
|
||||
# Ensure source directory exists
|
||||
source_spec_dir.mkdir(parents=True, exist_ok=True)
|
||||
source_plan_file = source_spec_dir / "implementation_plan.json"
|
||||
|
||||
try:
|
||||
# Sync all files and directories from worktree spec to source spec
|
||||
for item in spec_dir.iterdir():
|
||||
# Skip symlinks to prevent path traversal attacks
|
||||
if item.is_symlink():
|
||||
logger.warning(f"Skipping symlink during sync: {item.name}")
|
||||
continue
|
||||
|
||||
source_item = source_spec_dir / item.name
|
||||
|
||||
if item.is_file():
|
||||
# Copy file (preserves timestamps)
|
||||
shutil.copy2(item, source_item)
|
||||
logger.debug(f"Synced {item.name} to source")
|
||||
synced_any = True
|
||||
|
||||
elif item.is_dir():
|
||||
# Recursively sync directory
|
||||
_sync_directory(item, source_item)
|
||||
synced_any = True
|
||||
|
||||
shutil.copy2(plan_file, source_plan_file)
|
||||
logger.debug(f"Synced implementation plan to source: {source_plan_file}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to sync spec directory to source: {e}")
|
||||
|
||||
return synced_any
|
||||
|
||||
|
||||
def _sync_directory(source_dir: Path, target_dir: Path) -> None:
|
||||
"""
|
||||
Recursively sync a directory from source to target.
|
||||
|
||||
Args:
|
||||
source_dir: Source directory (in worktree)
|
||||
target_dir: Target directory (in main project)
|
||||
"""
|
||||
# Create target directory if needed
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for item in source_dir.iterdir():
|
||||
# Skip symlinks to prevent path traversal attacks
|
||||
if item.is_symlink():
|
||||
logger.warning(
|
||||
f"Skipping symlink during sync: {source_dir.name}/{item.name}"
|
||||
)
|
||||
continue
|
||||
|
||||
target_item = target_dir / item.name
|
||||
|
||||
if item.is_file():
|
||||
shutil.copy2(item, target_item)
|
||||
logger.debug(f"Synced {source_dir.name}/{item.name} to source")
|
||||
elif item.is_dir():
|
||||
# Recurse into subdirectories
|
||||
_sync_directory(item, target_item)
|
||||
|
||||
|
||||
# Keep the old name as an alias for backward compatibility
|
||||
def sync_plan_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool:
|
||||
"""Alias for sync_spec_to_source for backward compatibility."""
|
||||
return sync_spec_to_source(spec_dir, source_spec_dir)
|
||||
logger.warning(f"Failed to sync implementation plan to source: {e}")
|
||||
return False
|
||||
|
||||
@@ -408,6 +408,6 @@ class FrameworkAnalyzer(BaseAnalyzer):
|
||||
return "pnpm"
|
||||
elif self._exists("yarn.lock"):
|
||||
return "yarn"
|
||||
elif self._exists("bun.lockb") or self._exists("bun.lock"):
|
||||
elif self._exists("bun.lockb"):
|
||||
return "bun"
|
||||
return "npm"
|
||||
|
||||
@@ -387,40 +387,12 @@ async def run_insight_extraction(
|
||||
|
||||
# Collect the response
|
||||
response_text = ""
|
||||
message_count = 0
|
||||
text_blocks_found = 0
|
||||
|
||||
async for msg in client.receive_response():
|
||||
msg_type = type(msg).__name__
|
||||
message_count += 1
|
||||
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
# Must check block type - only TextBlock has .text attribute
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
text_blocks_found += 1
|
||||
if block.text: # Only add non-empty text
|
||||
response_text += block.text
|
||||
else:
|
||||
logger.debug(
|
||||
f"Found empty TextBlock in response (block #{text_blocks_found})"
|
||||
)
|
||||
|
||||
# Log response collection summary
|
||||
logger.debug(
|
||||
f"Insight extraction response: {message_count} messages, "
|
||||
f"{text_blocks_found} text blocks, {len(response_text)} chars collected"
|
||||
)
|
||||
|
||||
# Validate we received content before parsing
|
||||
if not response_text.strip():
|
||||
logger.warning(
|
||||
f"Insight extraction returned empty response. "
|
||||
f"Messages received: {message_count}, TextBlocks found: {text_blocks_found}. "
|
||||
f"This may indicate the AI model did not respond with text content."
|
||||
)
|
||||
return None
|
||||
if hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
|
||||
# Parse JSON from response
|
||||
return parse_insights(response_text)
|
||||
@@ -443,11 +415,6 @@ def parse_insights(response_text: str) -> dict | None:
|
||||
# Try to extract JSON from the response
|
||||
text = response_text.strip()
|
||||
|
||||
# Early validation - check for empty response
|
||||
if not text:
|
||||
logger.warning("Cannot parse insights: response text is empty")
|
||||
return None
|
||||
|
||||
# Handle markdown code blocks
|
||||
if text.startswith("```"):
|
||||
# Remove code block markers
|
||||
@@ -455,26 +422,17 @@ def parse_insights(response_text: str) -> dict | None:
|
||||
# Remove first line (```json or ```)
|
||||
if lines[0].startswith("```"):
|
||||
lines = lines[1:]
|
||||
# Remove last line if it's ```
|
||||
# Remove last line if it's ``
|
||||
if lines and lines[-1].strip() == "```":
|
||||
lines = lines[:-1]
|
||||
text = "\n".join(lines).strip()
|
||||
|
||||
# Check again after removing code blocks
|
||||
if not text:
|
||||
logger.warning(
|
||||
"Cannot parse insights: response contained only markdown code block markers with no content"
|
||||
)
|
||||
return None
|
||||
text = "\n".join(lines)
|
||||
|
||||
try:
|
||||
insights = json.loads(text)
|
||||
|
||||
# Validate structure
|
||||
if not isinstance(insights, dict):
|
||||
logger.warning(
|
||||
f"Insights is not a dict, got type: {type(insights).__name__}"
|
||||
)
|
||||
logger.warning("Insights is not a dict")
|
||||
return None
|
||||
|
||||
# Ensure required keys exist with defaults
|
||||
@@ -488,13 +446,7 @@ def parse_insights(response_text: str) -> dict | None:
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Failed to parse insights JSON: {e}")
|
||||
# Show more context in the error message
|
||||
preview_length = min(500, len(text))
|
||||
logger.warning(
|
||||
f"Response text preview (first {preview_length} chars): {text[:preview_length]}"
|
||||
)
|
||||
if len(text) > preview_length:
|
||||
logger.warning(f"... (total length: {len(text)} chars)")
|
||||
logger.debug(f"Response text was: {text[:500]}")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -275,7 +275,7 @@ class TestDiscovery:
|
||||
return "yarn"
|
||||
if (project_dir / "package-lock.json").exists():
|
||||
return "npm"
|
||||
if (project_dir / "bun.lockb").exists() or (project_dir / "bun.lock").exists():
|
||||
if (project_dir / "bun.lockb").exists():
|
||||
return "bun"
|
||||
if (project_dir / "uv.lock").exists():
|
||||
return "uv"
|
||||
|
||||
@@ -6,8 +6,7 @@ Commands for creating and managing multiple tasks from batch files.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from ui import highlight, print_status
|
||||
@@ -176,17 +175,23 @@ def handle_batch_status_command(project_dir: str) -> bool:
|
||||
|
||||
def handle_batch_cleanup_command(project_dir: str, dry_run: bool = True) -> bool:
|
||||
"""
|
||||
Clean up completed specs and worktrees.
|
||||
|
||||
Args:
|
||||
project_dir: Project directory
|
||||
dry_run: If True, show what would be deleted
|
||||
|
||||
Clean up completed spec directories and their associated worktree paths.
|
||||
|
||||
Finds spec directories under <project_dir>/.auto-claude/specs that contain a `qa_report.md` (treated as completed),
|
||||
and, when run in dry-run mode, prints the specs and corresponding worktree paths that would be removed.
|
||||
|
||||
Parameters:
|
||||
project_dir (str): Path to the project root.
|
||||
dry_run (bool): If True, print what would be removed instead of performing deletions.
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
True if the command completed.
|
||||
"""
|
||||
from core.config import get_worktree_base_path
|
||||
|
||||
specs_dir = Path(project_dir) / ".auto-claude" / "specs"
|
||||
worktrees_dir = Path(project_dir) / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktree_base_path = get_worktree_base_path(Path(project_dir))
|
||||
worktrees_dir = Path(project_dir) / worktree_base_path
|
||||
|
||||
if not specs_dir.exists():
|
||||
print_status("No specs directory found", "info")
|
||||
@@ -211,56 +216,8 @@ 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" └─ .auto-claude/worktrees/tasks/{spec_name}/")
|
||||
print(f" └─ {worktree_base_path}/{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
|
||||
return True
|
||||
@@ -79,7 +79,7 @@ def handle_build_command(
|
||||
base_branch: Base branch for worktree creation (default: current branch)
|
||||
"""
|
||||
# Lazy imports to avoid loading heavy modules
|
||||
from agent import run_autonomous_agent, sync_spec_to_source
|
||||
from agent import run_autonomous_agent, sync_plan_to_source
|
||||
from debug import (
|
||||
debug,
|
||||
debug_info,
|
||||
@@ -87,7 +87,6 @@ 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
|
||||
@@ -195,14 +194,6 @@ 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
|
||||
@@ -283,7 +274,7 @@ def handle_build_command(
|
||||
|
||||
# Sync implementation plan to main project after QA
|
||||
# This ensures the main project has the latest status (human_review)
|
||||
if sync_spec_to_source(spec_dir, source_spec_dir):
|
||||
if sync_plan_to_source(spec_dir, source_spec_dir):
|
||||
debug_info(
|
||||
"run.py", "Implementation plan synced to main project after QA"
|
||||
)
|
||||
|
||||
+14
-73
@@ -15,6 +15,8 @@ _PARENT_DIR = Path(__file__).parent.parent
|
||||
if str(_PARENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
from .batch_commands import (
|
||||
handle_batch_cleanup_command,
|
||||
@@ -38,7 +40,6 @@ from .utils import (
|
||||
)
|
||||
from .workspace_commands import (
|
||||
handle_cleanup_worktrees_command,
|
||||
handle_create_pr_command,
|
||||
handle_discard_command,
|
||||
handle_list_worktrees_command,
|
||||
handle_merge_command,
|
||||
@@ -154,30 +155,6 @@ Environment Variables:
|
||||
action="store_true",
|
||||
help="Discard an existing build (requires confirmation)",
|
||||
)
|
||||
build_group.add_argument(
|
||||
"--create-pr",
|
||||
action="store_true",
|
||||
help="Push branch and create a GitHub Pull Request",
|
||||
)
|
||||
|
||||
# PR options
|
||||
parser.add_argument(
|
||||
"--pr-target",
|
||||
type=str,
|
||||
metavar="BRANCH",
|
||||
help="With --create-pr: target branch for PR (default: auto-detect)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pr-title",
|
||||
type=str,
|
||||
metavar="TITLE",
|
||||
help="With --create-pr: custom PR title (default: generated from spec name)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pr-draft",
|
||||
action="store_true",
|
||||
help="With --create-pr: create as draft PR",
|
||||
)
|
||||
|
||||
# Merge options
|
||||
parser.add_argument(
|
||||
@@ -284,32 +261,14 @@ Environment Variables:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main CLI entry point."""
|
||||
"""
|
||||
Entry point for the CLI: sets up the environment, parses arguments, and dispatches the requested command.
|
||||
|
||||
This function initializes runtime environment and debugging, resolves the project directory (and loads a project-specific .auto-claude/.env file if present), determines the model choice from the CLI or the AUTO_BUILD_MODEL environment variable, and routes control to the appropriate handler based on parsed CLI flags (examples include listing specs, worktree management, batch operations, merge/preview/review/discard flows, QA and follow-up commands, or the normal build flow). Exits the process with a non-zero status when required by invalid input or failing command outcomes.
|
||||
"""
|
||||
# Set up environment first
|
||||
setup_environment()
|
||||
|
||||
# Initialize Sentry early to capture any startup errors
|
||||
from core.sentry import capture_exception, init_sentry
|
||||
|
||||
init_sentry(component="cli")
|
||||
|
||||
try:
|
||||
_run_cli()
|
||||
except KeyboardInterrupt:
|
||||
# Clean exit on Ctrl+C
|
||||
sys.exit(130)
|
||||
except Exception as e:
|
||||
# Capture unexpected errors to Sentry
|
||||
capture_exception(e)
|
||||
print(f"\nUnexpected error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _run_cli() -> None:
|
||||
"""Run the CLI logic (extracted for error handling)."""
|
||||
# Import here to avoid import errors during startup
|
||||
from core.sentry import set_context
|
||||
|
||||
# Parse arguments
|
||||
args = parse_args()
|
||||
|
||||
@@ -323,6 +282,12 @@ def _run_cli() -> None:
|
||||
project_dir = get_project_dir(args.project_dir)
|
||||
debug("run.py", f"Using project directory: {project_dir}")
|
||||
|
||||
# Load project-specific .env file (overrides backend .env)
|
||||
project_env = project_dir / ".auto-claude" / ".env"
|
||||
if project_env.exists():
|
||||
load_dotenv(project_env, override=True)
|
||||
debug("run.py", f"Loaded project .env from: {project_env}")
|
||||
|
||||
# Get model from CLI arg or env var (None if not explicitly set)
|
||||
# This allows get_phase_model() to fall back to task_metadata.json
|
||||
model = args.model or os.environ.get("AUTO_BUILD_MODEL")
|
||||
@@ -380,15 +345,6 @@ def _run_cli() -> None:
|
||||
|
||||
debug_success("run.py", "Spec found", spec_dir=str(spec_dir))
|
||||
|
||||
# Set Sentry context for error tracking
|
||||
set_context(
|
||||
"spec",
|
||||
{
|
||||
"name": spec_dir.name,
|
||||
"project": str(project_dir),
|
||||
},
|
||||
)
|
||||
|
||||
# Handle build management commands
|
||||
if args.merge_preview:
|
||||
from cli.workspace_commands import handle_merge_preview_command
|
||||
@@ -421,21 +377,6 @@ def _run_cli() -> 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)
|
||||
@@ -481,4 +422,4 @@ def _run_cli() -> None:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
@@ -1,217 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
JSON Recovery Utility
|
||||
=====================
|
||||
|
||||
Detects and repairs corrupted JSON files in specs directories.
|
||||
|
||||
Usage:
|
||||
python -m cli.recovery --project-dir /path/to/project --detect
|
||||
python -m cli.recovery --project-dir /path/to/project --spec-id 004-feature --delete
|
||||
python -m cli.recovery --project-dir /path/to/project --all --delete
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from cli.utils import find_specs_dir
|
||||
|
||||
|
||||
def check_json_file(filepath: Path) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Check if a JSON file is valid.
|
||||
|
||||
Returns:
|
||||
(is_valid, error_message)
|
||||
"""
|
||||
try:
|
||||
with open(filepath, encoding="utf-8") as f:
|
||||
json.load(f)
|
||||
return True, None
|
||||
except json.JSONDecodeError as e:
|
||||
return False, str(e)
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def detect_corrupted_files(specs_dir: Path) -> list[tuple[Path, str]]:
|
||||
"""
|
||||
Scan specs directory recursively for corrupted JSON files.
|
||||
|
||||
Returns:
|
||||
List of (filepath, error_message) tuples
|
||||
"""
|
||||
corrupted = []
|
||||
|
||||
if not specs_dir.exists():
|
||||
return corrupted
|
||||
|
||||
# Recursively scan for JSON files (includes nested files like memory/*.json)
|
||||
for json_file in specs_dir.rglob("*.json"):
|
||||
is_valid, error = check_json_file(json_file)
|
||||
if not is_valid:
|
||||
# Type narrowing: error is str when is_valid is False
|
||||
assert error is not None
|
||||
corrupted.append((json_file, error))
|
||||
|
||||
return corrupted
|
||||
|
||||
|
||||
def backup_corrupted_file(filepath: Path) -> bool:
|
||||
"""
|
||||
Backup a corrupted file by renaming it with a .corrupted suffix.
|
||||
|
||||
Args:
|
||||
filepath: Path to the corrupted file
|
||||
|
||||
Returns:
|
||||
True if backed up successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Create backup before deleting
|
||||
base_backup_path = filepath.with_suffix(f"{filepath.suffix}.corrupted")
|
||||
backup_path = base_backup_path
|
||||
|
||||
# Handle existing backup files by generating unique name with UUID
|
||||
if backup_path.exists():
|
||||
# Use UUID for unique naming to avoid races
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
backup_path = filepath.with_suffix(
|
||||
f"{filepath.suffix}.corrupted.{unique_suffix}"
|
||||
)
|
||||
|
||||
filepath.rename(backup_path)
|
||||
print(f" [BACKUP] Moved corrupted file to: {backup_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" [ERROR] Failed to backup file: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Detect and repair corrupted JSON files in specs directories"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--project-dir",
|
||||
type=Path,
|
||||
default=Path.cwd(),
|
||||
help="Project directory (default: current directory)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--specs-dir",
|
||||
type=Path,
|
||||
help="Specs directory path (overrides auto-detection)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--detect",
|
||||
action="store_true",
|
||||
help="Detect corrupted JSON files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--spec-id",
|
||||
type=str,
|
||||
help="Specific spec ID to fix (e.g., 004-feature)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--delete",
|
||||
action="store_true",
|
||||
help="Delete corrupted files (creates .corrupted backup)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="Fix all corrupted files (requires --delete)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate --all requires --delete
|
||||
if args.all and not args.delete:
|
||||
parser.error("--all requires --delete")
|
||||
|
||||
# Find specs directory
|
||||
if args.specs_dir:
|
||||
specs_dir = args.specs_dir
|
||||
else:
|
||||
specs_dir = find_specs_dir(args.project_dir)
|
||||
|
||||
print(f"[INFO] Scanning specs directory: {specs_dir}")
|
||||
|
||||
# Default to detect mode if no flags provided
|
||||
if not args.detect and not args.delete:
|
||||
args.detect = True
|
||||
|
||||
# Detect corrupted files (dry-run when detect-only, otherwise for deletion)
|
||||
corrupted = detect_corrupted_files(specs_dir)
|
||||
|
||||
# Detect-only mode: show results and exit
|
||||
if args.detect and not args.delete:
|
||||
if not corrupted:
|
||||
print("[OK] No corrupted JSON files found")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"\n[FOUND] {len(corrupted)} corrupted file(s):\n")
|
||||
for filepath, error in corrupted:
|
||||
print(f" - {filepath.relative_to(specs_dir.parent)}")
|
||||
print(f" Error: {error}")
|
||||
print()
|
||||
# Exit with error code when corrupted files are found
|
||||
sys.exit(1)
|
||||
|
||||
# Delete corrupted files
|
||||
if args.delete:
|
||||
if args.spec_id:
|
||||
# Delete specific spec
|
||||
spec_dir = (specs_dir / args.spec_id).resolve()
|
||||
specs_dir_resolved = specs_dir.resolve()
|
||||
# Validate path doesn't escape specs directory
|
||||
if not spec_dir.is_relative_to(specs_dir_resolved):
|
||||
print("[ERROR] Invalid spec ID: path traversal detected")
|
||||
sys.exit(1)
|
||||
|
||||
if not spec_dir.exists():
|
||||
print(f"[ERROR] Spec directory not found: {spec_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"[INFO] Processing spec: {args.spec_id}")
|
||||
has_failures = False
|
||||
for json_file in spec_dir.rglob("*.json"):
|
||||
is_valid, error = check_json_file(json_file)
|
||||
if not is_valid:
|
||||
print(f" [CORRUPTED] {json_file.name}")
|
||||
if not backup_corrupted_file(json_file):
|
||||
has_failures = True
|
||||
|
||||
if has_failures:
|
||||
sys.exit(1)
|
||||
|
||||
elif args.all:
|
||||
# Delete all corrupted files
|
||||
# Use the already-detected corrupted list, or re-scan if needed
|
||||
if not corrupted:
|
||||
corrupted = detect_corrupted_files(specs_dir)
|
||||
if not corrupted:
|
||||
print("[OK] No corrupted files to delete")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"\n[INFO] Backing up {len(corrupted)} corrupted file(s):\n")
|
||||
has_failures = False
|
||||
for filepath, _ in corrupted:
|
||||
# backup_corrupted_file prints its own [BACKUP] message
|
||||
if not backup_corrupted_file(filepath):
|
||||
has_failures = True
|
||||
|
||||
if has_failures:
|
||||
sys.exit(1)
|
||||
|
||||
else:
|
||||
print("[ERROR] Must specify --spec-id or --all with --delete")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -15,47 +15,7 @@ if str(_PARENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
from core.auth import get_auth_token, get_auth_token_source
|
||||
from core.dependency_validator import validate_platform_dependencies
|
||||
|
||||
|
||||
def import_dotenv():
|
||||
"""
|
||||
Import and return load_dotenv with helpful error message if not installed.
|
||||
|
||||
This centralized function ensures consistent error messaging across all
|
||||
runner scripts when python-dotenv is not available.
|
||||
|
||||
Returns:
|
||||
The load_dotenv function
|
||||
|
||||
Raises:
|
||||
SystemExit: If dotenv cannot be imported, with helpful installation instructions.
|
||||
"""
|
||||
try:
|
||||
from dotenv import load_dotenv as _load_dotenv
|
||||
|
||||
return _load_dotenv
|
||||
except ImportError:
|
||||
sys.exit(
|
||||
"Error: Required Python package 'python-dotenv' is not installed.\n"
|
||||
"\n"
|
||||
"This usually means you're not using the virtual environment.\n"
|
||||
"\n"
|
||||
"To fix this:\n"
|
||||
"1. From the 'apps/backend/' directory, activate the venv:\n"
|
||||
" source .venv/bin/activate # Linux/macOS\n"
|
||||
" .venv\\Scripts\\activate # Windows\n"
|
||||
"\n"
|
||||
"2. Or install dependencies directly:\n"
|
||||
" pip install python-dotenv\n"
|
||||
" pip install -r requirements.txt\n"
|
||||
"\n"
|
||||
f"Current Python: {sys.executable}\n"
|
||||
)
|
||||
|
||||
|
||||
# Load .env with helpful error if dependencies not installed
|
||||
load_dotenv = import_dotenv()
|
||||
from dotenv import load_dotenv
|
||||
from graphiti_config import get_graphiti_status
|
||||
from linear_integration import LinearManager
|
||||
from linear_updater import is_linear_enabled
|
||||
@@ -68,8 +28,8 @@ from ui import (
|
||||
muted,
|
||||
)
|
||||
|
||||
# Configuration - uses shorthand that resolves via API Profile if configured
|
||||
DEFAULT_MODEL = "sonnet" # Changed from "opus" (fix #433)
|
||||
# Configuration
|
||||
DEFAULT_MODEL = "claude-opus-4-5-20251101"
|
||||
|
||||
|
||||
def setup_environment() -> Path:
|
||||
@@ -122,7 +82,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 / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktree_base = project_dir / ".worktrees"
|
||||
if worktree_base.exists():
|
||||
# Try exact match in worktree
|
||||
worktree_spec = (
|
||||
@@ -155,9 +115,6 @@ def validate_environment(spec_dir: Path) -> bool:
|
||||
Returns:
|
||||
True if valid, False otherwise (with error messages printed)
|
||||
"""
|
||||
# Validate platform-specific dependencies first (exits if missing)
|
||||
validate_platform_dependencies()
|
||||
|
||||
valid = True
|
||||
|
||||
# Check for OAuth token (API keys are not supported)
|
||||
@@ -255,19 +212,3 @@ def get_project_dir(provided_dir: Path | None) -> Path:
|
||||
project_dir = project_dir.parent.parent
|
||||
|
||||
return project_dir
|
||||
|
||||
|
||||
def find_specs_dir(project_dir: Path) -> Path:
|
||||
"""
|
||||
Find the specs directory for a project.
|
||||
|
||||
Returns the '.auto-claude/specs' directory path.
|
||||
The directory is guaranteed to exist (get_specs_dir calls init_auto_claude_dir).
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
|
||||
Returns:
|
||||
Path to specs directory (always returns a valid Path)
|
||||
"""
|
||||
return get_specs_dir(project_dir)
|
||||
|
||||
@@ -5,7 +5,6 @@ Workspace Commands
|
||||
CLI commands for workspace management (merge, review, discard, list, cleanup)
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -23,8 +22,6 @@ from core.workspace.git_utils import (
|
||||
get_merge_base,
|
||||
is_lock_file,
|
||||
)
|
||||
from core.worktree import PushAndCreatePRResult as CreatePRResult
|
||||
from core.worktree import WorktreeManager
|
||||
from debug import debug_warning
|
||||
from ui import (
|
||||
Icons,
|
||||
@@ -33,7 +30,6 @@ from ui import (
|
||||
from workspace import (
|
||||
cleanup_all_worktrees,
|
||||
discard_existing_build,
|
||||
get_existing_build_worktree,
|
||||
list_all_worktrees,
|
||||
merge_existing_build,
|
||||
review_existing_build,
|
||||
@@ -71,7 +67,6 @@ 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
|
||||
@@ -83,7 +78,6 @@ def _detect_default_branch(project_dir: Path) -> str:
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return branch
|
||||
@@ -96,32 +90,18 @@ def _get_changed_files_from_git(
|
||||
worktree_path: Path, base_branch: str = "main"
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get list of files changed by the task (not files changed on base branch).
|
||||
|
||||
Uses merge-base to accurately identify only the files modified in the worktree,
|
||||
not files that changed on the base branch since the worktree was created.
|
||||
Get list of changed files from git diff between base branch and HEAD.
|
||||
|
||||
Args:
|
||||
worktree_path: Path to the worktree
|
||||
base_branch: Base branch to compare against (default: main)
|
||||
|
||||
Returns:
|
||||
List of changed file paths (task changes only)
|
||||
List of changed file paths
|
||||
"""
|
||||
try:
|
||||
# First, get the merge-base (the point where the worktree branched)
|
||||
merge_base_result = subprocess.run(
|
||||
["git", "merge-base", base_branch, "HEAD"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
merge_base = merge_base_result.stdout.strip()
|
||||
|
||||
# Use two-dot diff from merge-base to get only task's changes
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", f"{merge_base}..HEAD"],
|
||||
["git", "diff", "--name-only", f"{base_branch}...HEAD"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -133,10 +113,10 @@ def _get_changed_files_from_git(
|
||||
# Log the failure before trying fallback
|
||||
debug_warning(
|
||||
"workspace_commands",
|
||||
f"git diff with merge-base failed: returncode={e.returncode}, "
|
||||
f"git diff (three-dot) failed: returncode={e.returncode}, "
|
||||
f"stderr={e.stderr.strip() if e.stderr else 'N/A'}",
|
||||
)
|
||||
# Fallback: try direct two-arg diff (less accurate but works)
|
||||
# Fallback: try without the three-dot notation
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", base_branch, "HEAD"],
|
||||
@@ -151,176 +131,12 @@ def _get_changed_files_from_git(
|
||||
# Log the failure before returning empty list
|
||||
debug_warning(
|
||||
"workspace_commands",
|
||||
f"git diff (fallback) failed: returncode={e.returncode}, "
|
||||
f"git diff (two-arg) failed: returncode={e.returncode}, "
|
||||
f"stderr={e.stderr.strip() if e.stderr else 'N/A'}",
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def _detect_worktree_base_branch(
|
||||
project_dir: Path,
|
||||
worktree_path: Path,
|
||||
spec_name: str,
|
||||
) -> str | None:
|
||||
"""
|
||||
Detect which branch a worktree was created from.
|
||||
|
||||
Tries multiple strategies:
|
||||
1. Check worktree config file (.auto-claude/worktree-config.json)
|
||||
2. Find merge-base with known branches (develop, main, master)
|
||||
3. Return None if unable to detect
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
worktree_path: Path to the worktree
|
||||
spec_name: Name of the spec
|
||||
|
||||
Returns:
|
||||
The detected base branch name, or None if unable to detect
|
||||
"""
|
||||
# Strategy 1: Check for worktree config file
|
||||
config_path = worktree_path / ".auto-claude" / "worktree-config.json"
|
||||
if config_path.exists():
|
||||
try:
|
||||
config = json.loads(config_path.read_text())
|
||||
if config.get("base_branch"):
|
||||
debug(
|
||||
MODULE,
|
||||
f"Found base branch in worktree config: {config['base_branch']}",
|
||||
)
|
||||
return config["base_branch"]
|
||||
except Exception as e:
|
||||
debug_warning(MODULE, f"Failed to read worktree config: {e}")
|
||||
|
||||
# Strategy 2: Find which branch has the closest merge-base
|
||||
# Check common branches: develop, main, master
|
||||
spec_branch = f"auto-claude/{spec_name}"
|
||||
candidate_branches = ["develop", "main", "master"]
|
||||
|
||||
best_branch = None
|
||||
best_commits_behind = float("inf")
|
||||
|
||||
for branch in candidate_branches:
|
||||
try:
|
||||
# Check if branch exists
|
||||
check = subprocess.run(
|
||||
["git", "rev-parse", "--verify", branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if check.returncode != 0:
|
||||
continue
|
||||
|
||||
# Get merge base
|
||||
merge_base_result = subprocess.run(
|
||||
["git", "merge-base", branch, spec_branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if merge_base_result.returncode != 0:
|
||||
continue
|
||||
|
||||
merge_base = merge_base_result.stdout.strip()
|
||||
|
||||
# Count commits between merge-base and branch tip
|
||||
# The branch with fewer commits ahead is likely the one we branched from
|
||||
ahead_result = subprocess.run(
|
||||
["git", "rev-list", "--count", f"{merge_base}..{branch}"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if ahead_result.returncode == 0:
|
||||
commits_ahead = int(ahead_result.stdout.strip())
|
||||
debug(
|
||||
MODULE,
|
||||
f"Branch {branch} is {commits_ahead} commits ahead of merge-base",
|
||||
)
|
||||
if commits_ahead < best_commits_behind:
|
||||
best_commits_behind = commits_ahead
|
||||
best_branch = branch
|
||||
except Exception as e:
|
||||
debug_warning(MODULE, f"Error checking branch {branch}: {e}")
|
||||
continue
|
||||
|
||||
if best_branch:
|
||||
debug(
|
||||
MODULE,
|
||||
f"Detected base branch from git history: {best_branch} (commits ahead: {best_commits_behind})",
|
||||
)
|
||||
return best_branch
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _detect_parallel_task_conflicts(
|
||||
project_dir: Path,
|
||||
current_task_id: str,
|
||||
current_task_files: list[str],
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Detect potential conflicts between this task and other active tasks.
|
||||
|
||||
Uses existing evolution data to check if any of this task's files
|
||||
have been modified by other active tasks. This is a lightweight check
|
||||
that doesn't require re-processing all files.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
current_task_id: ID of the current task
|
||||
current_task_files: Files modified by this task (from git diff)
|
||||
|
||||
Returns:
|
||||
List of conflict dictionaries with 'file' and 'tasks' keys
|
||||
"""
|
||||
try:
|
||||
from merge import MergeOrchestrator
|
||||
|
||||
# Initialize orchestrator just to access evolution data
|
||||
orchestrator = MergeOrchestrator(
|
||||
project_dir,
|
||||
enable_ai=False,
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
# Get all active tasks from evolution data
|
||||
active_tasks = orchestrator.evolution_tracker.get_active_tasks()
|
||||
|
||||
# Remove current task from active tasks
|
||||
other_active_tasks = active_tasks - {current_task_id}
|
||||
|
||||
if not other_active_tasks:
|
||||
return []
|
||||
|
||||
# Convert current task files to a set for fast lookup
|
||||
current_files_set = set(current_task_files)
|
||||
|
||||
# Get files modified by other active tasks
|
||||
conflicts = []
|
||||
other_task_files = orchestrator.evolution_tracker.get_files_modified_by_tasks(
|
||||
list(other_active_tasks)
|
||||
)
|
||||
|
||||
# Find intersection - files modified by both this task and other tasks
|
||||
for file_path, tasks in other_task_files.items():
|
||||
if file_path in current_files_set:
|
||||
# This file was modified by both current task and other task(s)
|
||||
all_tasks = [current_task_id] + tasks
|
||||
conflicts.append({"file": file_path, "tasks": all_tasks})
|
||||
|
||||
return conflicts
|
||||
|
||||
except Exception as e:
|
||||
# If anything fails, just return empty - parallel task detection is optional
|
||||
debug_warning(
|
||||
"workspace_commands",
|
||||
f"Parallel task conflict detection failed: {e}",
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
# Import debug utilities
|
||||
try:
|
||||
from debug import (
|
||||
@@ -536,9 +352,7 @@ def handle_cleanup_worktrees_command(project_dir: Path) -> None:
|
||||
cleanup_all_worktrees(project_dir, confirm=True)
|
||||
|
||||
|
||||
def _check_git_merge_conflicts(
|
||||
project_dir: Path, spec_name: str, base_branch: str | None = None
|
||||
) -> dict:
|
||||
def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
|
||||
"""
|
||||
Check for git-level merge conflicts WITHOUT modifying the working directory.
|
||||
|
||||
@@ -548,7 +362,6 @@ def _check_git_merge_conflicts(
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_name: Name of the spec
|
||||
base_branch: Branch the task was created from (default: auto-detect)
|
||||
|
||||
Returns:
|
||||
Dictionary with git conflict information:
|
||||
@@ -567,25 +380,21 @@ def _check_git_merge_conflicts(
|
||||
"has_conflicts": False,
|
||||
"conflicting_files": [],
|
||||
"needs_rebase": False,
|
||||
"base_branch": base_branch or "main",
|
||||
"base_branch": "main",
|
||||
"spec_branch": spec_branch,
|
||||
"commits_behind": 0,
|
||||
}
|
||||
|
||||
try:
|
||||
# Use provided base_branch, or detect from current HEAD
|
||||
if not base_branch:
|
||||
base_result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if base_result.returncode == 0:
|
||||
result["base_branch"] = base_result.stdout.strip()
|
||||
else:
|
||||
result["base_branch"] = base_branch
|
||||
debug(MODULE, f"Using provided base branch: {base_branch}")
|
||||
# Get the current branch (base branch)
|
||||
base_result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if base_result.returncode == 0:
|
||||
result["base_branch"] = base_result.stdout.strip()
|
||||
|
||||
# Get the merge base commit
|
||||
merge_base_result = subprocess.run(
|
||||
@@ -744,6 +553,7 @@ def handle_merge_preview_command(
|
||||
spec_name=spec_name,
|
||||
)
|
||||
|
||||
from merge import MergeOrchestrator
|
||||
from workspace import get_existing_build_worktree
|
||||
|
||||
worktree_path = get_existing_build_worktree(project_dir, spec_name)
|
||||
@@ -770,32 +580,16 @@ def handle_merge_preview_command(
|
||||
}
|
||||
|
||||
try:
|
||||
# First, check for git-level conflicts (diverged branches)
|
||||
git_conflicts = _check_git_merge_conflicts(project_dir, spec_name)
|
||||
|
||||
# Determine the task's source branch (where the task was created from)
|
||||
# Priority:
|
||||
# 1. Provided base_branch (from task metadata)
|
||||
# 2. Detect from worktree's git history (find which branch it diverged from)
|
||||
# 3. Fall back to default branch detection (main/master)
|
||||
# Use provided base_branch (from task metadata), or fall back to detected default
|
||||
task_source_branch = base_branch
|
||||
if not task_source_branch:
|
||||
# Try to detect from worktree's git history
|
||||
task_source_branch = _detect_worktree_base_branch(
|
||||
project_dir, worktree_path, spec_name
|
||||
)
|
||||
if not task_source_branch:
|
||||
# Fall back to auto-detecting main/master
|
||||
# Auto-detect the default branch (main/master) that worktrees are typically created from
|
||||
task_source_branch = _detect_default_branch(project_dir)
|
||||
|
||||
debug(
|
||||
MODULE,
|
||||
f"Using task source branch: {task_source_branch}",
|
||||
provided=base_branch is not None,
|
||||
)
|
||||
|
||||
# Check for git-level conflicts (diverged branches) using the task's source branch
|
||||
git_conflicts = _check_git_merge_conflicts(
|
||||
project_dir, spec_name, base_branch=task_source_branch
|
||||
)
|
||||
|
||||
# Get actual changed files from git diff (this is the authoritative count)
|
||||
all_changed_files = _get_changed_files_from_git(
|
||||
worktree_path, task_source_branch
|
||||
@@ -806,39 +600,49 @@ def handle_merge_preview_command(
|
||||
changed_files=all_changed_files[:10], # Log first 10
|
||||
)
|
||||
|
||||
# OPTIMIZATION: Skip expensive refresh_from_git() and preview_merge() calls
|
||||
# For merge-preview, we only need to detect:
|
||||
# 1. Git conflicts (task vs base branch) - already calculated in _check_git_merge_conflicts()
|
||||
# 2. Parallel task conflicts (this task vs other active tasks)
|
||||
#
|
||||
# For parallel task detection, we just check if this task's files overlap
|
||||
# with files OTHER tasks have already recorded - no need to re-process all files.
|
||||
debug(MODULE, "Initializing MergeOrchestrator for preview...")
|
||||
|
||||
debug(MODULE, "Checking for parallel task conflicts (lightweight)...")
|
||||
|
||||
# Check for parallel task conflicts by looking at existing evolution data
|
||||
parallel_conflicts = _detect_parallel_task_conflicts(
|
||||
project_dir, spec_name, all_changed_files
|
||||
# Initialize the orchestrator
|
||||
orchestrator = MergeOrchestrator(
|
||||
project_dir,
|
||||
enable_ai=False, # Don't use AI for preview
|
||||
dry_run=True, # Don't write anything
|
||||
)
|
||||
|
||||
# Refresh evolution data from the worktree
|
||||
# Compare against the task's source branch (where the task was created from)
|
||||
debug(
|
||||
MODULE,
|
||||
f"Parallel task conflicts detected: {len(parallel_conflicts)}",
|
||||
conflicts=parallel_conflicts[:5] if parallel_conflicts else [],
|
||||
f"Refreshing evolution data from worktree: {worktree_path}",
|
||||
task_source_branch=task_source_branch,
|
||||
)
|
||||
orchestrator.evolution_tracker.refresh_from_git(
|
||||
spec_name, worktree_path, target_branch=task_source_branch
|
||||
)
|
||||
|
||||
# Build conflict list - start with parallel task conflicts
|
||||
# Get merge preview (semantic conflicts between parallel tasks)
|
||||
debug(MODULE, "Generating merge preview...")
|
||||
preview = orchestrator.preview_merge([spec_name])
|
||||
|
||||
# Transform semantic conflicts to UI-friendly format
|
||||
conflicts = []
|
||||
for pc in parallel_conflicts:
|
||||
for c in preview.get("conflicts", []):
|
||||
debug_verbose(
|
||||
MODULE,
|
||||
"Processing semantic conflict",
|
||||
file=c.get("file", ""),
|
||||
severity=c.get("severity", "unknown"),
|
||||
)
|
||||
conflicts.append(
|
||||
{
|
||||
"file": pc["file"],
|
||||
"location": "file-level",
|
||||
"tasks": pc["tasks"],
|
||||
"severity": "medium",
|
||||
"canAutoMerge": False,
|
||||
"strategy": None,
|
||||
"reason": f"File modified by multiple active tasks: {', '.join(pc['tasks'])}",
|
||||
"type": "parallel",
|
||||
"file": c.get("file", ""),
|
||||
"location": c.get("location", ""),
|
||||
"tasks": c.get("tasks", []),
|
||||
"severity": c.get("severity", "unknown"),
|
||||
"canAutoMerge": c.get("can_auto_merge", False),
|
||||
"strategy": c.get("strategy"),
|
||||
"reason": c.get("reason", ""),
|
||||
"type": "semantic",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -865,14 +669,13 @@ def handle_merge_preview_command(
|
||||
}
|
||||
)
|
||||
|
||||
summary = preview.get("summary", {})
|
||||
# Count only non-lock-file conflicts
|
||||
git_conflict_count = len(git_conflicts.get("conflicting_files", [])) - len(
|
||||
lock_files_excluded
|
||||
)
|
||||
# Calculate totals from our conflict lists (git conflicts + parallel conflicts)
|
||||
parallel_conflict_count = len(parallel_conflicts)
|
||||
total_conflicts = git_conflict_count + parallel_conflict_count
|
||||
conflict_files = git_conflict_count + parallel_conflict_count
|
||||
total_conflicts = summary.get("total_conflicts", 0) + git_conflict_count
|
||||
conflict_files = summary.get("conflict_files", 0) + git_conflict_count
|
||||
|
||||
# Filter lock files from the git conflicts list for the response
|
||||
non_lock_conflicting_files = [
|
||||
@@ -958,7 +761,7 @@ def handle_merge_preview_command(
|
||||
"totalFiles": total_files_from_git,
|
||||
"conflictFiles": conflict_files,
|
||||
"totalConflicts": total_conflicts,
|
||||
"autoMergeable": 0, # Not tracking auto-merge in lightweight mode
|
||||
"autoMergeable": summary.get("auto_mergeable", 0),
|
||||
"hasGitConflicts": git_conflicts["has_conflicts"]
|
||||
and len(non_lock_conflicting_files) > 0,
|
||||
# Include path-mapped AI merge count for UI display
|
||||
@@ -973,9 +776,10 @@ def handle_merge_preview_command(
|
||||
"Merge preview complete",
|
||||
total_files=result["summary"]["totalFiles"],
|
||||
total_files_source="git_diff",
|
||||
semantic_tracked_files=summary.get("total_files", 0),
|
||||
total_conflicts=result["summary"]["totalConflicts"],
|
||||
has_git_conflicts=git_conflicts["has_conflicts"],
|
||||
parallel_conflicts=parallel_conflict_count,
|
||||
auto_mergeable=result["summary"]["autoMergeable"],
|
||||
path_mapped_ai_merges=len(path_mapped_ai_merges),
|
||||
total_renames=len(path_mappings),
|
||||
)
|
||||
@@ -1001,220 +805,3 @@ def handle_merge_preview_command(
|
||||
"pathMappedAIMergeCount": 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def handle_create_pr_command(
|
||||
project_dir: Path,
|
||||
spec_name: str,
|
||||
target_branch: str | None = None,
|
||||
title: str | None = None,
|
||||
draft: bool = False,
|
||||
) -> CreatePRResult:
|
||||
"""
|
||||
Handle the --create-pr command: push branch and create a GitHub PR.
|
||||
|
||||
Args:
|
||||
project_dir: Path to the project directory
|
||||
spec_name: Name of the spec (e.g., "001-feature-name")
|
||||
target_branch: Target branch for PR (defaults to base branch)
|
||||
title: Custom PR title (defaults to spec name)
|
||||
draft: Whether to create as draft PR
|
||||
|
||||
Returns:
|
||||
CreatePRResult with success status, pr_url, and any errors
|
||||
"""
|
||||
from core.worktree import WorktreeManager
|
||||
|
||||
print_banner()
|
||||
print("\n" + "=" * 70)
|
||||
print(" CREATE PULL REQUEST")
|
||||
print("=" * 70)
|
||||
|
||||
# Check if worktree exists
|
||||
worktree_path = get_existing_build_worktree(project_dir, spec_name)
|
||||
if not worktree_path:
|
||||
print(f"\n{icon(Icons.ERROR)} No build found for spec: {spec_name}")
|
||||
print("\nA completed build worktree is required to create a PR.")
|
||||
print("Run your build first, then use --create-pr.")
|
||||
error_result: CreatePRResult = {
|
||||
"success": False,
|
||||
"error": "No build found for this spec",
|
||||
}
|
||||
return error_result
|
||||
|
||||
# Create worktree manager
|
||||
manager = WorktreeManager(project_dir, base_branch=target_branch)
|
||||
|
||||
print(f"\n{icon(Icons.BRANCH)} Pushing branch and creating PR...")
|
||||
print(f" Spec: {spec_name}")
|
||||
print(f" Target: {target_branch or manager.base_branch}")
|
||||
if title:
|
||||
print(f" Title: {title}")
|
||||
if draft:
|
||||
print(" Mode: Draft PR")
|
||||
|
||||
# Push and create PR with exception handling for clean JSON output
|
||||
try:
|
||||
raw_result = manager.push_and_create_pr(
|
||||
spec_name=spec_name,
|
||||
target_branch=target_branch,
|
||||
title=title,
|
||||
draft=draft,
|
||||
)
|
||||
except Exception as e:
|
||||
debug_error(MODULE, f"Exception during PR creation: {e}")
|
||||
error_result: CreatePRResult = {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": "Failed to create PR",
|
||||
}
|
||||
print(f"\n{icon(Icons.ERROR)} Failed to create PR: {e}")
|
||||
print(json.dumps(error_result))
|
||||
return error_result
|
||||
|
||||
# Convert PushAndCreatePRResult to CreatePRResult
|
||||
result: CreatePRResult = {
|
||||
"success": raw_result.get("success", False),
|
||||
"pr_url": raw_result.get("pr_url"),
|
||||
"already_exists": raw_result.get("already_exists", False),
|
||||
"error": raw_result.get("error"),
|
||||
"message": raw_result.get("message"),
|
||||
"pushed": raw_result.get("pushed", False),
|
||||
"remote": raw_result.get("remote", ""),
|
||||
"branch": raw_result.get("branch", ""),
|
||||
}
|
||||
|
||||
if result.get("success"):
|
||||
pr_url = result.get("pr_url")
|
||||
already_exists = result.get("already_exists", False)
|
||||
|
||||
if already_exists:
|
||||
print(f"\n{icon(Icons.SUCCESS)} PR already exists!")
|
||||
else:
|
||||
print(f"\n{icon(Icons.SUCCESS)} PR created successfully!")
|
||||
|
||||
if pr_url:
|
||||
print(f"\n{icon(Icons.LINK)} {pr_url}")
|
||||
else:
|
||||
print(f"\n{icon(Icons.INFO)} Check GitHub for the PR URL")
|
||||
|
||||
print("\nNext steps:")
|
||||
print(" 1. Review the PR on GitHub")
|
||||
print(" 2. Request reviews from your team")
|
||||
print(" 3. Merge when approved")
|
||||
|
||||
# Output JSON for frontend parsing
|
||||
print(json.dumps(result))
|
||||
return result
|
||||
else:
|
||||
error = result.get("error", "Unknown error")
|
||||
print(f"\n{icon(Icons.ERROR)} Failed to create PR: {error}")
|
||||
# Output JSON for frontend parsing
|
||||
print(json.dumps(result))
|
||||
return result
|
||||
|
||||
|
||||
def cleanup_old_worktrees_command(
|
||||
project_dir: Path, days: int = 30, dry_run: bool = False
|
||||
) -> dict:
|
||||
"""
|
||||
Clean up old worktrees that haven't been modified in the specified number of days.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
days: Number of days threshold (default: 30)
|
||||
dry_run: If True, only show what would be removed (default: False)
|
||||
|
||||
Returns:
|
||||
Dictionary with cleanup results
|
||||
"""
|
||||
try:
|
||||
manager = WorktreeManager(project_dir)
|
||||
|
||||
removed, failed = manager.cleanup_old_worktrees(
|
||||
days_threshold=days, dry_run=dry_run
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"removed": removed,
|
||||
"failed": failed,
|
||||
"dry_run": dry_run,
|
||||
"days_threshold": days,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"removed": [],
|
||||
"failed": [],
|
||||
}
|
||||
|
||||
|
||||
def worktree_summary_command(project_dir: Path) -> dict:
|
||||
"""
|
||||
Get a summary of all worktrees with age information.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
|
||||
Returns:
|
||||
Dictionary with worktree summary data
|
||||
"""
|
||||
try:
|
||||
manager = WorktreeManager(project_dir)
|
||||
|
||||
# Print to console for CLI usage
|
||||
manager.print_worktree_summary()
|
||||
|
||||
# Also return data for programmatic access
|
||||
worktrees = manager.list_all_worktrees()
|
||||
warning = manager.get_worktree_count_warning()
|
||||
|
||||
# Categorize by age
|
||||
recent = []
|
||||
week_old = []
|
||||
month_old = []
|
||||
very_old = []
|
||||
unknown_age = []
|
||||
|
||||
for info in worktrees:
|
||||
data = {
|
||||
"spec_name": info.spec_name,
|
||||
"days_since_last_commit": info.days_since_last_commit,
|
||||
"commit_count": info.commit_count,
|
||||
}
|
||||
|
||||
if info.days_since_last_commit is None:
|
||||
unknown_age.append(data)
|
||||
elif info.days_since_last_commit < 7:
|
||||
recent.append(data)
|
||||
elif info.days_since_last_commit < 30:
|
||||
week_old.append(data)
|
||||
elif info.days_since_last_commit < 90:
|
||||
month_old.append(data)
|
||||
else:
|
||||
very_old.append(data)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"total_worktrees": len(worktrees),
|
||||
"categories": {
|
||||
"recent": recent,
|
||||
"week_old": week_old,
|
||||
"month_old": month_old,
|
||||
"very_old": very_old,
|
||||
"unknown_age": unknown_age,
|
||||
},
|
||||
"warning": warning,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"total_worktrees": 0,
|
||||
"categories": {},
|
||||
"warning": None,
|
||||
}
|
||||
|
||||
@@ -231,9 +231,7 @@ async def _call_claude(prompt: str) -> str:
|
||||
msg_type = type(msg).__name__
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
# Must check block type - only TextBlock has .text attribute
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
if hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
|
||||
logger.info(f"Generated commit message: {len(response_text)} chars")
|
||||
|
||||
@@ -39,7 +39,7 @@ from agents import (
|
||||
run_followup_planner,
|
||||
save_session_memory,
|
||||
save_session_to_graphiti,
|
||||
sync_spec_to_source,
|
||||
sync_plan_to_source,
|
||||
)
|
||||
|
||||
# Ensure all exports are available at module level
|
||||
@@ -57,7 +57,7 @@ __all__ = [
|
||||
"load_implementation_plan",
|
||||
"find_subtask_in_plan",
|
||||
"find_phase_for_subtask",
|
||||
"sync_spec_to_source",
|
||||
"sync_plan_to_source",
|
||||
"AUTO_CONTINUE_DELAY_SECONDS",
|
||||
"HUMAN_INTERVENTION_FILE",
|
||||
]
|
||||
|
||||
@@ -23,21 +23,12 @@ AUTH_TOKEN_ENV_VARS = [
|
||||
# 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",
|
||||
]
|
||||
|
||||
|
||||
@@ -217,85 +208,6 @@ def require_auth_token() -> str:
|
||||
return token
|
||||
|
||||
|
||||
def _find_git_bash_path() -> str | None:
|
||||
"""
|
||||
Find git-bash (bash.exe) path on Windows.
|
||||
|
||||
Uses 'where git' to find git.exe, then derives bash.exe location from it.
|
||||
Git for Windows installs bash.exe in the 'bin' directory alongside git.exe
|
||||
or in the parent 'bin' directory when git.exe is in 'cmd'.
|
||||
|
||||
Returns:
|
||||
Full path to bash.exe if found, None otherwise
|
||||
"""
|
||||
if platform.system() != "Windows":
|
||||
return None
|
||||
|
||||
# If already set in environment, use that
|
||||
existing = os.environ.get("CLAUDE_CODE_GIT_BASH_PATH")
|
||||
if existing and os.path.exists(existing):
|
||||
return existing
|
||||
|
||||
git_path = None
|
||||
|
||||
# Method 1: Use 'where' command to find git.exe
|
||||
try:
|
||||
# Use where.exe explicitly for reliability
|
||||
result = subprocess.run(
|
||||
["where.exe", "git"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
shell=False,
|
||||
)
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
git_paths = result.stdout.strip().splitlines()
|
||||
if git_paths:
|
||||
git_path = git_paths[0].strip()
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
|
||||
# Intentionally suppress errors - best-effort detection with fallback to common paths
|
||||
pass
|
||||
|
||||
# Method 2: Check common installation paths if 'where' didn't work
|
||||
if not git_path:
|
||||
common_git_paths = [
|
||||
os.path.expandvars(r"%PROGRAMFILES%\Git\cmd\git.exe"),
|
||||
os.path.expandvars(r"%PROGRAMFILES%\Git\bin\git.exe"),
|
||||
os.path.expandvars(r"%PROGRAMFILES(X86)%\Git\cmd\git.exe"),
|
||||
os.path.expandvars(r"%LOCALAPPDATA%\Programs\Git\cmd\git.exe"),
|
||||
]
|
||||
for path in common_git_paths:
|
||||
if os.path.exists(path):
|
||||
git_path = path
|
||||
break
|
||||
|
||||
if not git_path:
|
||||
return None
|
||||
|
||||
# Derive bash.exe location from git.exe location
|
||||
# Git for Windows structure:
|
||||
# C:\...\Git\cmd\git.exe -> bash.exe is at C:\...\Git\bin\bash.exe
|
||||
# C:\...\Git\bin\git.exe -> bash.exe is at C:\...\Git\bin\bash.exe
|
||||
# C:\...\Git\mingw64\bin\git.exe -> bash.exe is at C:\...\Git\bin\bash.exe
|
||||
git_dir = os.path.dirname(git_path)
|
||||
git_parent = os.path.dirname(git_dir)
|
||||
git_grandparent = os.path.dirname(git_parent)
|
||||
|
||||
# Check common bash.exe locations relative to git installation
|
||||
possible_bash_paths = [
|
||||
os.path.join(git_parent, "bin", "bash.exe"), # cmd -> bin
|
||||
os.path.join(git_dir, "bash.exe"), # If git.exe is in bin
|
||||
os.path.join(git_grandparent, "bin", "bash.exe"), # mingw64/bin -> bin
|
||||
]
|
||||
|
||||
for bash_path in possible_bash_paths:
|
||||
if os.path.exists(bash_path):
|
||||
return bash_path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_sdk_env_vars() -> dict[str, str]:
|
||||
"""
|
||||
Get environment variables to pass to SDK.
|
||||
@@ -303,8 +215,6 @@ 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
|
||||
"""
|
||||
@@ -313,14 +223,6 @@ 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
|
||||
|
||||
|
||||
|
||||
+3
-462
@@ -12,401 +12,13 @@ The client factory now uses AGENT_CONFIGS from agents/tools_pkg/models.py as the
|
||||
single source of truth for phase-aware tool and MCP server configuration.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =============================================================================
|
||||
# Project Index Cache
|
||||
# =============================================================================
|
||||
# Caches project index and capabilities to avoid reloading on every create_client() call.
|
||||
# This significantly reduces the time to create new agent sessions.
|
||||
|
||||
_PROJECT_INDEX_CACHE: dict[str, tuple[dict[str, Any], dict[str, bool], float]] = {}
|
||||
_CACHE_TTL_SECONDS = 300 # 5 minute TTL
|
||||
_CACHE_LOCK = threading.Lock() # Protects _PROJECT_INDEX_CACHE access
|
||||
|
||||
|
||||
def _get_cached_project_data(
|
||||
project_dir: Path,
|
||||
) -> tuple[dict[str, Any], dict[str, bool]]:
|
||||
"""
|
||||
Get project index and capabilities with caching.
|
||||
|
||||
Args:
|
||||
project_dir: Path to the project directory
|
||||
|
||||
Returns:
|
||||
Tuple of (project_index, project_capabilities)
|
||||
"""
|
||||
|
||||
key = str(project_dir.resolve())
|
||||
now = time.time()
|
||||
debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
|
||||
|
||||
# Check cache with lock
|
||||
with _CACHE_LOCK:
|
||||
if key in _PROJECT_INDEX_CACHE:
|
||||
cached_index, cached_capabilities, cached_time = _PROJECT_INDEX_CACHE[key]
|
||||
cache_age = now - cached_time
|
||||
if cache_age < _CACHE_TTL_SECONDS:
|
||||
if debug:
|
||||
print(
|
||||
f"[ClientCache] Cache HIT for project index (age: {cache_age:.1f}s / TTL: {_CACHE_TTL_SECONDS}s)"
|
||||
)
|
||||
logger.debug(f"Using cached project index for {project_dir}")
|
||||
# Return deep copies to prevent callers from corrupting the cache
|
||||
return copy.deepcopy(cached_index), copy.deepcopy(cached_capabilities)
|
||||
elif debug:
|
||||
print(
|
||||
f"[ClientCache] Cache EXPIRED for project index (age: {cache_age:.1f}s > TTL: {_CACHE_TTL_SECONDS}s)"
|
||||
)
|
||||
|
||||
# Cache miss or expired - load fresh data (outside lock to avoid blocking)
|
||||
load_start = time.time()
|
||||
logger.debug(f"Loading project index for {project_dir}")
|
||||
project_index = load_project_index(project_dir)
|
||||
project_capabilities = detect_project_capabilities(project_index)
|
||||
|
||||
if debug:
|
||||
load_duration = (time.time() - load_start) * 1000
|
||||
print(
|
||||
f"[ClientCache] Cache MISS - loaded project index in {load_duration:.1f}ms"
|
||||
)
|
||||
|
||||
# Store in cache with lock - use double-checked locking pattern
|
||||
# Re-check if another thread populated the cache while we were loading
|
||||
with _CACHE_LOCK:
|
||||
if key in _PROJECT_INDEX_CACHE:
|
||||
cached_index, cached_capabilities, cached_time = _PROJECT_INDEX_CACHE[key]
|
||||
cache_age = time.time() - cached_time
|
||||
if cache_age < _CACHE_TTL_SECONDS:
|
||||
# Another thread already cached valid data while we were loading
|
||||
if debug:
|
||||
print(
|
||||
"[ClientCache] Cache was populated by another thread, using cached data"
|
||||
)
|
||||
# Return deep copies to prevent callers from corrupting the cache
|
||||
return copy.deepcopy(cached_index), copy.deepcopy(cached_capabilities)
|
||||
# Either no cache entry or it's expired - store our fresh data
|
||||
_PROJECT_INDEX_CACHE[key] = (project_index, project_capabilities, time.time())
|
||||
|
||||
# Return the freshly loaded data (no need to copy since it's not from cache)
|
||||
return project_index, project_capabilities
|
||||
|
||||
|
||||
def invalidate_project_cache(project_dir: Path | None = None) -> None:
|
||||
"""
|
||||
Invalidate the project index cache.
|
||||
|
||||
Args:
|
||||
project_dir: Specific project to invalidate, or None to clear all
|
||||
"""
|
||||
with _CACHE_LOCK:
|
||||
if project_dir is None:
|
||||
_PROJECT_INDEX_CACHE.clear()
|
||||
logger.debug("Cleared all project index cache entries")
|
||||
else:
|
||||
key = str(project_dir.resolve())
|
||||
if key in _PROJECT_INDEX_CACHE:
|
||||
del _PROJECT_INDEX_CACHE[key]
|
||||
logger.debug(f"Invalidated project index cache for {project_dir}")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Claude CLI Path Detection
|
||||
# =============================================================================
|
||||
# Cross-platform detection of Claude Code CLI binary.
|
||||
# This mirrors the frontend's cli-tool-manager.ts logic to ensure consistency.
|
||||
|
||||
_CLAUDE_CLI_CACHE: dict[str, str | None] = {}
|
||||
_CLI_CACHE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _get_claude_detection_paths() -> dict[str, list[str]]:
|
||||
"""
|
||||
Get all candidate paths for Claude CLI detection.
|
||||
|
||||
Returns platform-specific paths where Claude CLI might be installed.
|
||||
|
||||
IMPORTANT: This function mirrors the frontend's getClaudeDetectionPaths()
|
||||
in apps/frontend/src/main/cli-tool-manager.ts. Both implementations MUST
|
||||
be kept in sync to ensure consistent detection behavior across the
|
||||
Python backend and Electron frontend.
|
||||
|
||||
When adding new detection paths, update BOTH:
|
||||
1. This function (_get_claude_detection_paths in client.py)
|
||||
2. getClaudeDetectionPaths() in cli-tool-manager.ts
|
||||
|
||||
Returns:
|
||||
Dict with 'homebrew', 'platform', and 'nvm' path lists
|
||||
"""
|
||||
home_dir = Path.home()
|
||||
is_windows = platform.system() == "Windows"
|
||||
|
||||
homebrew_paths = [
|
||||
"/opt/homebrew/bin/claude", # Apple Silicon
|
||||
"/usr/local/bin/claude", # Intel Mac
|
||||
]
|
||||
|
||||
if is_windows:
|
||||
platform_paths = [
|
||||
str(home_dir / "AppData" / "Local" / "Programs" / "claude" / "claude.exe"),
|
||||
str(home_dir / "AppData" / "Roaming" / "npm" / "claude.cmd"),
|
||||
str(home_dir / ".local" / "bin" / "claude.exe"),
|
||||
"C:\\Program Files\\Claude\\claude.exe",
|
||||
"C:\\Program Files (x86)\\Claude\\claude.exe",
|
||||
]
|
||||
else:
|
||||
platform_paths = [
|
||||
str(home_dir / ".local" / "bin" / "claude"),
|
||||
str(home_dir / "bin" / "claude"),
|
||||
]
|
||||
|
||||
nvm_versions_dir = str(home_dir / ".nvm" / "versions" / "node")
|
||||
|
||||
return {
|
||||
"homebrew": homebrew_paths,
|
||||
"platform": platform_paths,
|
||||
"nvm_versions_dir": nvm_versions_dir,
|
||||
}
|
||||
|
||||
|
||||
def _is_secure_path(path_str: str) -> bool:
|
||||
"""
|
||||
Validate that a path doesn't contain dangerous characters.
|
||||
|
||||
Prevents command injection attacks by rejecting paths with shell metacharacters,
|
||||
directory traversal patterns, or environment variable expansion.
|
||||
|
||||
Args:
|
||||
path_str: Path to validate
|
||||
|
||||
Returns:
|
||||
True if the path is safe, False otherwise
|
||||
"""
|
||||
import re
|
||||
|
||||
dangerous_patterns = [
|
||||
r'[;&|`${}[\]<>!"^]', # Shell metacharacters
|
||||
r"%[^%]+%", # Windows environment variable expansion
|
||||
r"\.\./", # Unix directory traversal
|
||||
r"\.\.\\", # Windows directory traversal
|
||||
r"[\r\n]", # Newlines (command injection)
|
||||
]
|
||||
|
||||
for pattern in dangerous_patterns:
|
||||
if re.search(pattern, path_str):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _validate_claude_cli(cli_path: str) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Validate that a Claude CLI path is executable and returns a version.
|
||||
|
||||
Includes security validation to prevent command injection attacks.
|
||||
|
||||
Args:
|
||||
cli_path: Path to the Claude CLI executable
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, version_string or None)
|
||||
|
||||
Note:
|
||||
Cross-references with frontend's validateClaudeCliAsync() in
|
||||
apps/frontend/src/main/ipc-handlers/claude-code-handlers.ts
|
||||
Both should be kept in sync for consistent behavior.
|
||||
"""
|
||||
import re
|
||||
|
||||
# Security validation: reject paths with shell metacharacters or directory traversal
|
||||
if not _is_secure_path(cli_path):
|
||||
logger.warning(f"Rejecting insecure Claude CLI path: {cli_path}")
|
||||
return False, None
|
||||
|
||||
try:
|
||||
is_windows = platform.system() == "Windows"
|
||||
|
||||
# Augment PATH with the CLI directory for proper resolution
|
||||
env = os.environ.copy()
|
||||
cli_dir = os.path.dirname(cli_path)
|
||||
if cli_dir:
|
||||
env["PATH"] = cli_dir + os.pathsep + env.get("PATH", "")
|
||||
|
||||
# For Windows .cmd/.bat files, use cmd.exe with proper quoting
|
||||
# /d = disable AutoRun registry commands
|
||||
# /s = strip first and last quotes, preserving inner quotes
|
||||
# /c = run command then terminate
|
||||
if is_windows and cli_path.lower().endswith((".cmd", ".bat")):
|
||||
# Get cmd.exe path from environment or use default
|
||||
cmd_exe = os.environ.get("ComSpec") or os.path.join(
|
||||
os.environ.get("SystemRoot", "C:\\Windows"), "System32", "cmd.exe"
|
||||
)
|
||||
# Use double-quoted command line for paths with spaces
|
||||
cmd_line = f'""{cli_path}" --version"'
|
||||
result = subprocess.run(
|
||||
[cmd_exe, "/d", "/s", "/c", cmd_line],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
env=env,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW,
|
||||
)
|
||||
else:
|
||||
result = subprocess.run(
|
||||
[cli_path, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
env=env,
|
||||
creationflags=subprocess.CREATE_NO_WINDOW if is_windows else 0,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
# Extract version from output (e.g., "claude-code version 1.0.0")
|
||||
output = result.stdout.strip()
|
||||
match = re.search(r"(\d+\.\d+\.\d+)", output)
|
||||
version = match.group(1) if match else output.split("\n")[0]
|
||||
return True, version
|
||||
|
||||
return False, None
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
|
||||
logger.debug(f"Claude CLI validation failed for {cli_path}: {e}")
|
||||
return False, None
|
||||
|
||||
|
||||
def find_claude_cli() -> str | None:
|
||||
"""
|
||||
Find the Claude Code CLI binary path.
|
||||
|
||||
Uses cross-platform detection with the following priority:
|
||||
1. CLAUDE_CLI_PATH environment variable (user override)
|
||||
2. shutil.which() - system PATH lookup
|
||||
3. Homebrew paths (macOS)
|
||||
4. NVM paths (Unix - checks Node.js version manager)
|
||||
5. Platform-specific standard locations
|
||||
|
||||
Returns:
|
||||
Path to Claude CLI if found and valid, None otherwise
|
||||
"""
|
||||
# Check cache first
|
||||
cache_key = "claude_cli"
|
||||
with _CLI_CACHE_LOCK:
|
||||
if cache_key in _CLAUDE_CLI_CACHE:
|
||||
cached = _CLAUDE_CLI_CACHE[cache_key]
|
||||
logger.debug(f"Using cached Claude CLI path: {cached}")
|
||||
return cached
|
||||
|
||||
is_windows = platform.system() == "Windows"
|
||||
paths = _get_claude_detection_paths()
|
||||
|
||||
# 1. Check environment variable override
|
||||
env_path = os.environ.get("CLAUDE_CLI_PATH")
|
||||
if env_path:
|
||||
if Path(env_path).exists():
|
||||
valid, version = _validate_claude_cli(env_path)
|
||||
if valid:
|
||||
logger.info(f"Using CLAUDE_CLI_PATH: {env_path} (v{version})")
|
||||
with _CLI_CACHE_LOCK:
|
||||
_CLAUDE_CLI_CACHE[cache_key] = env_path
|
||||
return env_path
|
||||
logger.warning(f"CLAUDE_CLI_PATH is set but invalid: {env_path}")
|
||||
|
||||
# 2. Try shutil.which() - most reliable cross-platform PATH lookup
|
||||
which_path = shutil.which("claude")
|
||||
if which_path:
|
||||
valid, version = _validate_claude_cli(which_path)
|
||||
if valid:
|
||||
logger.info(f"Found Claude CLI in PATH: {which_path} (v{version})")
|
||||
with _CLI_CACHE_LOCK:
|
||||
_CLAUDE_CLI_CACHE[cache_key] = which_path
|
||||
return which_path
|
||||
|
||||
# 3. Homebrew paths (macOS)
|
||||
if platform.system() == "Darwin":
|
||||
for hb_path in paths["homebrew"]:
|
||||
if Path(hb_path).exists():
|
||||
valid, version = _validate_claude_cli(hb_path)
|
||||
if valid:
|
||||
logger.info(f"Found Claude CLI (Homebrew): {hb_path} (v{version})")
|
||||
with _CLI_CACHE_LOCK:
|
||||
_CLAUDE_CLI_CACHE[cache_key] = hb_path
|
||||
return hb_path
|
||||
|
||||
# 4. NVM paths (Unix only) - check Node.js version manager installations
|
||||
if not is_windows:
|
||||
nvm_dir = Path(paths["nvm_versions_dir"])
|
||||
if nvm_dir.exists():
|
||||
try:
|
||||
# Get all version directories and sort by version (newest first)
|
||||
version_dirs = []
|
||||
for entry in nvm_dir.iterdir():
|
||||
if entry.is_dir() and entry.name.startswith("v"):
|
||||
# Parse version: v20.0.0 -> (20, 0, 0)
|
||||
try:
|
||||
parts = entry.name[1:].split(".")
|
||||
if len(parts) == 3:
|
||||
version_dirs.append(
|
||||
(tuple(int(p) for p in parts), entry.name)
|
||||
)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Sort by version descending (newest first)
|
||||
version_dirs.sort(reverse=True)
|
||||
|
||||
for _, version_name in version_dirs:
|
||||
nvm_claude = nvm_dir / version_name / "bin" / "claude"
|
||||
if nvm_claude.exists():
|
||||
valid, version = _validate_claude_cli(str(nvm_claude))
|
||||
if valid:
|
||||
logger.info(
|
||||
f"Found Claude CLI (NVM): {nvm_claude} (v{version})"
|
||||
)
|
||||
with _CLI_CACHE_LOCK:
|
||||
_CLAUDE_CLI_CACHE[cache_key] = str(nvm_claude)
|
||||
return str(nvm_claude)
|
||||
except OSError as e:
|
||||
logger.debug(f"Error scanning NVM directory: {e}")
|
||||
|
||||
# 5. Platform-specific standard locations
|
||||
for plat_path in paths["platform"]:
|
||||
if Path(plat_path).exists():
|
||||
valid, version = _validate_claude_cli(plat_path)
|
||||
if valid:
|
||||
logger.info(f"Found Claude CLI: {plat_path} (v{version})")
|
||||
with _CLI_CACHE_LOCK:
|
||||
_CLAUDE_CLI_CACHE[cache_key] = plat_path
|
||||
return plat_path
|
||||
|
||||
# Not found
|
||||
logger.warning(
|
||||
"Claude CLI not found. Install with: npm install -g @anthropic-ai/claude-code"
|
||||
)
|
||||
with _CLI_CACHE_LOCK:
|
||||
_CLAUDE_CLI_CACHE[cache_key] = None
|
||||
return None
|
||||
|
||||
|
||||
def clear_claude_cli_cache() -> None:
|
||||
"""Clear the Claude CLI path cache, forcing re-detection on next call."""
|
||||
with _CLI_CACHE_LOCK:
|
||||
_CLAUDE_CLI_CACHE.clear()
|
||||
logger.debug("Claude CLI cache cleared")
|
||||
|
||||
|
||||
from agents.tools_pkg import (
|
||||
CONTEXT7_TOOLS,
|
||||
ELECTRON_TOOLS,
|
||||
@@ -775,12 +387,6 @@ def create_client(
|
||||
# Collect env vars to pass to SDK (ANTHROPIC_BASE_URL, etc.)
|
||||
sdk_env = get_sdk_env_vars()
|
||||
|
||||
# Debug: Log git-bash path detection on Windows
|
||||
if "CLAUDE_CODE_GIT_BASH_PATH" in sdk_env:
|
||||
logger.info(f"Git Bash path found: {sdk_env['CLAUDE_CODE_GIT_BASH_PATH']}")
|
||||
elif platform.system() == "Windows":
|
||||
logger.warning("Git Bash path not detected on Windows!")
|
||||
|
||||
# Check if Linear integration is enabled
|
||||
linear_enabled = is_linear_enabled()
|
||||
linear_api_key = os.environ.get("LINEAR_API_KEY", "")
|
||||
@@ -790,8 +396,8 @@ def create_client(
|
||||
|
||||
# Load project capabilities for dynamic MCP tool selection
|
||||
# This enables context-aware tool injection based on project type
|
||||
# Uses caching to avoid reloading on every create_client() call
|
||||
project_index, project_capabilities = _get_cached_project_data(project_dir)
|
||||
project_index = load_project_index(project_dir)
|
||||
project_capabilities = detect_project_capabilities(project_index)
|
||||
|
||||
# Load per-project MCP configuration from .auto-claude/.env
|
||||
mcp_config = load_project_mcp_config(project_dir)
|
||||
@@ -831,48 +437,6 @@ def create_client(
|
||||
# cases where Claude uses absolute paths for file operations
|
||||
project_path_str = str(project_dir.resolve())
|
||||
spec_path_str = str(spec_dir.resolve())
|
||||
|
||||
# Detect if we're running in a worktree and get the original project directory
|
||||
# Worktrees are located in either:
|
||||
# - .auto-claude/worktrees/tasks/{spec-name}/ (new location)
|
||||
# - .worktrees/{spec-name}/ (legacy location)
|
||||
# When running in a worktree, we need to allow access to both the worktree
|
||||
# and the original project's .auto-claude/ directory for spec files
|
||||
original_project_permissions = []
|
||||
resolved_project_path = project_dir.resolve()
|
||||
|
||||
# Check for worktree paths and extract original project directory
|
||||
# This handles spec worktrees, PR review worktrees, and legacy worktrees
|
||||
# Note: Windows paths are normalized to forward slashes before comparison
|
||||
worktree_markers = [
|
||||
"/.auto-claude/worktrees/tasks/", # Spec/task worktrees
|
||||
"/.auto-claude/github/pr/worktrees/", # PR review worktrees
|
||||
"/.worktrees/", # Legacy worktree location
|
||||
]
|
||||
project_path_posix = str(resolved_project_path).replace("\\", "/")
|
||||
|
||||
for marker in worktree_markers:
|
||||
if marker in project_path_posix:
|
||||
# Extract the original project directory (parent of worktree location)
|
||||
# Use rsplit to get the rightmost occurrence (handles nested projects)
|
||||
original_project_str = project_path_posix.rsplit(marker, 1)[0]
|
||||
original_project_dir = Path(original_project_str)
|
||||
|
||||
# Grant permissions for relevant directories in the original project
|
||||
permission_ops = ["Read", "Write", "Edit", "Glob", "Grep"]
|
||||
dirs_to_permit = [
|
||||
original_project_dir / ".auto-claude",
|
||||
original_project_dir / ".worktrees", # Legacy support
|
||||
]
|
||||
|
||||
for dir_path in dirs_to_permit:
|
||||
if dir_path.exists():
|
||||
path_str = str(dir_path.resolve())
|
||||
original_project_permissions.extend(
|
||||
[f"{op}({path_str}/**)" for op in permission_ops]
|
||||
)
|
||||
break
|
||||
|
||||
security_settings = {
|
||||
"sandbox": {"enabled": True, "autoAllowBashIfSandboxed": True},
|
||||
"permissions": {
|
||||
@@ -895,9 +459,6 @@ def create_client(
|
||||
f"Read({spec_path_str}/**)",
|
||||
f"Write({spec_path_str}/**)",
|
||||
f"Edit({spec_path_str}/**)",
|
||||
# Allow original project's .auto-claude/ and .worktrees/ directories
|
||||
# when running in a worktree (fixes issue #385 - permission errors)
|
||||
*original_project_permissions,
|
||||
# Bash permission granted here, but actual commands are validated
|
||||
# by the bash_security_hook (see security.py for allowed commands)
|
||||
"Bash(*)",
|
||||
@@ -934,8 +495,6 @@ def create_client(
|
||||
print(f"Security settings: {settings_file}")
|
||||
print(" - Sandbox enabled (OS-level bash isolation)")
|
||||
print(f" - Filesystem restricted to: {project_dir.resolve()}")
|
||||
if original_project_permissions:
|
||||
print(" - Worktree permissions: granted for original project directories")
|
||||
print(" - Bash commands restricted to allowlist")
|
||||
if max_thinking_tokens:
|
||||
print(f" - Extended thinking: {max_thinking_tokens:,} tokens")
|
||||
@@ -1066,16 +625,8 @@ def create_client(
|
||||
print(" - CLAUDE.md: disabled by project settings")
|
||||
print()
|
||||
|
||||
# Find Claude CLI path for SDK
|
||||
# This ensures the SDK can find the Claude Code binary even if it's not in PATH
|
||||
cli_path = find_claude_cli()
|
||||
if cli_path:
|
||||
print(f" - Claude CLI: {cli_path}")
|
||||
else:
|
||||
print(" - Claude CLI: using SDK default detection")
|
||||
|
||||
# Build options dict, conditionally including output_format
|
||||
options_kwargs: dict[str, Any] = {
|
||||
options_kwargs = {
|
||||
"model": model,
|
||||
"system_prompt": base_prompt,
|
||||
"allowed_tools": allowed_tools_list,
|
||||
@@ -1090,18 +641,8 @@ 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 CLI path if found (helps SDK find Claude Code in non-standard locations)
|
||||
if cli_path:
|
||||
options_kwargs["cli_path"] = cli_path
|
||||
|
||||
# Add structured output format if specified
|
||||
# See: https://platform.claude.com/docs/en/agent-sdk/structured-outputs
|
||||
if output_format:
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
Core configuration for Auto Claude.
|
||||
|
||||
This module provides centralized configuration management for Auto Claude,
|
||||
including worktree path resolution and validation. It ensures consistent
|
||||
configuration access across the entire backend codebase.
|
||||
|
||||
Constants:
|
||||
WORKTREE_BASE_PATH_VAR (str): Environment variable name for custom worktree base path.
|
||||
DEFAULT_WORKTREE_PATH (str): Default worktree directory name relative to project root.
|
||||
|
||||
Example:
|
||||
>>> from core.config import get_worktree_base_path
|
||||
>>> from pathlib import Path
|
||||
>>>
|
||||
>>> # Get worktree path with validation
|
||||
>>> project_dir = Path("/path/to/project")
|
||||
>>> worktree_path = get_worktree_base_path(project_dir)
|
||||
>>> full_path = project_dir / worktree_path
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Environment variable names
|
||||
WORKTREE_BASE_PATH_VAR = "WORKTREE_BASE_PATH"
|
||||
"""str: Environment variable name for configuring custom worktree base path.
|
||||
|
||||
Users can set this environment variable in their project's .env file to specify
|
||||
a custom location for worktree directories, supporting both relative and absolute paths.
|
||||
"""
|
||||
|
||||
# Default values
|
||||
DEFAULT_WORKTREE_PATH = ".worktrees"
|
||||
"""str: Default worktree directory name.
|
||||
|
||||
This is the fallback value used when WORKTREE_BASE_PATH is not set or when
|
||||
validation fails (e.g., path points to .auto-claude/ or .git/ directories).
|
||||
"""
|
||||
|
||||
|
||||
def get_worktree_base_path(project_dir: Path | None = None) -> str:
|
||||
"""
|
||||
Determine the validated worktree base path from the WORKTREE_BASE_PATH environment variable or the default.
|
||||
|
||||
Parameters:
|
||||
project_dir (Path | None): Optional project root used to resolve relative paths and perform stricter validation. If omitted, only basic pattern checks are applied.
|
||||
|
||||
Returns:
|
||||
str: The configured worktree base path string, or DEFAULT_WORKTREE_PATH ('.worktrees') if the configured value is invalid or points inside the project's `.auto-claude` or `.git` directories.
|
||||
"""
|
||||
worktree_base_path = os.getenv(WORKTREE_BASE_PATH_VAR, DEFAULT_WORKTREE_PATH)
|
||||
|
||||
# If no project_dir provided, return as-is (basic validation only)
|
||||
if not project_dir:
|
||||
# Check for obviously dangerous patterns
|
||||
normalized = Path(worktree_base_path).as_posix()
|
||||
if ".auto-claude" in normalized or ".git" in normalized:
|
||||
return DEFAULT_WORKTREE_PATH
|
||||
return worktree_base_path
|
||||
|
||||
# Resolve the absolute path
|
||||
if Path(worktree_base_path).is_absolute():
|
||||
resolved = Path(worktree_base_path).resolve()
|
||||
else:
|
||||
resolved = (project_dir / worktree_base_path).resolve()
|
||||
|
||||
# Prevent paths inside .auto-claude/ or .git/
|
||||
auto_claude_dir = (project_dir / ".auto-claude").resolve()
|
||||
git_dir = (project_dir / ".git").resolve()
|
||||
|
||||
resolved_str = str(resolved)
|
||||
if resolved_str.startswith(str(auto_claude_dir)) or resolved_str.startswith(
|
||||
str(git_dir)
|
||||
):
|
||||
return DEFAULT_WORKTREE_PATH
|
||||
|
||||
return worktree_base_path
|
||||
@@ -1,50 +0,0 @@
|
||||
"""
|
||||
Dependency Validator
|
||||
====================
|
||||
|
||||
Validates platform-specific dependencies are installed before running agents.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def validate_platform_dependencies() -> None:
|
||||
"""
|
||||
Validate that platform-specific dependencies are installed.
|
||||
|
||||
Raises:
|
||||
SystemExit: If required platform-specific dependencies are missing,
|
||||
with helpful installation instructions.
|
||||
"""
|
||||
# Check Windows-specific dependencies
|
||||
if sys.platform == "win32" and sys.version_info >= (3, 12):
|
||||
try:
|
||||
import pywintypes # noqa: F401
|
||||
except ImportError:
|
||||
_exit_with_pywin32_error()
|
||||
|
||||
|
||||
def _exit_with_pywin32_error() -> None:
|
||||
"""Exit with helpful error message for missing pywin32."""
|
||||
# Use sys.prefix to detect the virtual environment path
|
||||
# This works for venv and poetry environments
|
||||
venv_activate = Path(sys.prefix) / "Scripts" / "activate"
|
||||
|
||||
sys.exit(
|
||||
"Error: Required Windows dependency 'pywin32' is not installed.\n"
|
||||
"\n"
|
||||
"Auto Claude requires pywin32 on Windows for LadybugDB/Graphiti memory integration.\n"
|
||||
"\n"
|
||||
"To fix this:\n"
|
||||
"1. Activate your virtual environment:\n"
|
||||
f" {venv_activate}\n"
|
||||
"\n"
|
||||
"2. Install pywin32:\n"
|
||||
" pip install pywin32>=306\n"
|
||||
"\n"
|
||||
" Or reinstall all dependencies:\n"
|
||||
" pip install -r requirements.txt\n"
|
||||
"\n"
|
||||
f"Current Python: {sys.executable}\n"
|
||||
)
|
||||
@@ -1,121 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Atomic File Write Utilities
|
||||
============================
|
||||
|
||||
Synchronous utilities for atomic file writes to prevent corruption.
|
||||
|
||||
Uses temp file + os.replace() pattern which is atomic on POSIX systems
|
||||
and atomic on Windows when source and destination are on the same volume.
|
||||
|
||||
Usage:
|
||||
from core.file_utils import write_json_atomic
|
||||
|
||||
write_json_atomic("/path/to/file.json", {"key": "value"})
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import IO, Any, Literal
|
||||
|
||||
|
||||
@contextmanager
|
||||
def atomic_write(
|
||||
filepath: str | Path,
|
||||
mode: Literal["w", "wb", "wt"] = "w",
|
||||
encoding: str | None = "utf-8",
|
||||
) -> Iterator[IO]:
|
||||
"""
|
||||
Atomic file write using temp file and rename.
|
||||
|
||||
Writes to .tmp file first, then atomically replaces target file
|
||||
using os.replace() which is atomic on POSIX systems and same-volume Windows.
|
||||
|
||||
Note: This function supports both text and binary modes. For binary modes
|
||||
(mode containing 'b'), encoding must be None.
|
||||
|
||||
Args:
|
||||
filepath: Target file path
|
||||
mode: File open mode (default: "w", text mode only)
|
||||
encoding: File encoding for text modes, None for binary (default: "utf-8")
|
||||
|
||||
Example:
|
||||
with atomic_write("/path/to/file.json") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
Yields:
|
||||
File handle to temp file
|
||||
"""
|
||||
filepath = Path(filepath)
|
||||
filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Binary modes require encoding=None
|
||||
actual_encoding = None if "b" in mode else encoding
|
||||
|
||||
# Create temp file in same directory for atomic rename
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
dir=filepath.parent, prefix=f".{filepath.name}.tmp.", suffix=""
|
||||
)
|
||||
|
||||
# Open temp file with requested mode
|
||||
# If fdopen fails, close fd and clean up temp file
|
||||
try:
|
||||
f = os.fdopen(fd, mode, encoding=actual_encoding)
|
||||
except Exception:
|
||||
os.close(fd)
|
||||
os.unlink(tmp_path)
|
||||
raise
|
||||
|
||||
try:
|
||||
with f:
|
||||
yield f
|
||||
except Exception:
|
||||
# Clean up temp file on error (replace didn't happen yet)
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception as cleanup_err:
|
||||
# Best-effort cleanup, ignore errors to not mask original exception
|
||||
# Log cleanup failure for debugging (orphaned temp files may accumulate)
|
||||
logging.warning(
|
||||
f"Failed to cleanup temp file {tmp_path}: {cleanup_err}",
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
else:
|
||||
# Atomic replace - only runs if no exception was raised
|
||||
# If os.replace itself fails, do NOT clean up (may be partially renamed)
|
||||
os.replace(tmp_path, filepath)
|
||||
|
||||
|
||||
def write_json_atomic(
|
||||
filepath: str | Path,
|
||||
data: Any,
|
||||
indent: int = 2,
|
||||
ensure_ascii: bool = False,
|
||||
encoding: str = "utf-8",
|
||||
) -> None:
|
||||
"""
|
||||
Write JSON data to file atomically.
|
||||
|
||||
This function prevents file corruption by:
|
||||
1. Writing to a temporary file first
|
||||
2. Only replacing the target file if the write succeeds
|
||||
3. Using os.replace() for atomicity
|
||||
|
||||
Args:
|
||||
filepath: Target file path
|
||||
data: Data to serialize as JSON
|
||||
indent: JSON indentation (default: 2)
|
||||
ensure_ascii: Whether to escape non-ASCII characters (default: False)
|
||||
encoding: File encoding (default: "utf-8")
|
||||
|
||||
Example:
|
||||
write_json_atomic("/path/to/file.json", {"key": "value"})
|
||||
"""
|
||||
with atomic_write(filepath, "w", encoding=encoding) as f:
|
||||
json.dump(data, f, indent=indent, ensure_ascii=ensure_ascii)
|
||||
@@ -1,142 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Git Executable Finder
|
||||
======================
|
||||
|
||||
Utility to find the git executable, with Windows-specific fallbacks.
|
||||
Separated into its own module to avoid circular imports.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
_cached_git_path: str | None = None
|
||||
|
||||
|
||||
def get_git_executable() -> str:
|
||||
"""Find the git executable, with Windows-specific fallbacks.
|
||||
|
||||
Returns the path to git executable. On Windows, checks multiple sources:
|
||||
1. CLAUDE_CODE_GIT_BASH_PATH env var (set by Electron frontend)
|
||||
2. shutil.which (if git is in PATH)
|
||||
3. Common installation locations
|
||||
4. Windows 'where' command
|
||||
|
||||
Caches the result after first successful find.
|
||||
"""
|
||||
global _cached_git_path
|
||||
|
||||
# Return cached result if available
|
||||
if _cached_git_path is not None:
|
||||
return _cached_git_path
|
||||
|
||||
git_path = _find_git_executable()
|
||||
_cached_git_path = git_path
|
||||
return git_path
|
||||
|
||||
|
||||
def _find_git_executable() -> str:
|
||||
"""Internal function to find git executable."""
|
||||
# 1. Check CLAUDE_CODE_GIT_BASH_PATH (set by Electron frontend)
|
||||
# This env var points to bash.exe, we can derive git.exe from it
|
||||
bash_path = os.environ.get("CLAUDE_CODE_GIT_BASH_PATH")
|
||||
if bash_path:
|
||||
try:
|
||||
bash_path_obj = Path(bash_path)
|
||||
if bash_path_obj.exists():
|
||||
git_dir = bash_path_obj.parent.parent
|
||||
# Try cmd/git.exe first (preferred), then bin/git.exe
|
||||
for git_subpath in ["cmd/git.exe", "bin/git.exe"]:
|
||||
git_path = git_dir / git_subpath
|
||||
if git_path.is_file():
|
||||
return str(git_path)
|
||||
except (OSError, ValueError):
|
||||
pass # Invalid path or permission error - try next method
|
||||
|
||||
# 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 # 'where' command failed - fall through to default
|
||||
|
||||
# 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.",
|
||||
)
|
||||
@@ -1,94 +0,0 @@
|
||||
"""
|
||||
I/O Utilities for Safe Console Output
|
||||
=====================================
|
||||
|
||||
Safe I/O operations for processes running as subprocesses.
|
||||
|
||||
When the backend runs as a subprocess of the Electron app, the parent
|
||||
process may close the pipe at any time (e.g., user closes the app,
|
||||
process killed, etc.). This module provides utilities to handle these
|
||||
cases gracefully.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Track if pipe is broken to avoid repeated failed writes
|
||||
_pipe_broken = False
|
||||
|
||||
|
||||
def safe_print(message: str, flush: bool = True) -> None:
|
||||
"""
|
||||
Print to stdout with BrokenPipeError handling.
|
||||
|
||||
When running as a subprocess (e.g., from Electron), the parent process
|
||||
may close the pipe at any time. This function gracefully handles that
|
||||
case instead of raising an exception.
|
||||
|
||||
Args:
|
||||
message: The message to print
|
||||
flush: Whether to flush stdout after printing (default True)
|
||||
"""
|
||||
global _pipe_broken
|
||||
|
||||
# Skip if we already know the pipe is broken
|
||||
if _pipe_broken:
|
||||
return
|
||||
|
||||
try:
|
||||
print(message, flush=flush)
|
||||
except BrokenPipeError:
|
||||
# Pipe closed by parent process - this is expected during shutdown
|
||||
_pipe_broken = True
|
||||
# Quietly close stdout to prevent further errors
|
||||
try:
|
||||
sys.stdout.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("Output pipe closed by parent process")
|
||||
except ValueError as e:
|
||||
# Handle writes to closed file (can happen after stdout.close())
|
||||
if "closed file" in str(e).lower():
|
||||
_pipe_broken = True
|
||||
logger.debug("Output stream closed")
|
||||
else:
|
||||
# Re-raise unexpected ValueErrors
|
||||
raise
|
||||
except OSError as e:
|
||||
# Handle other pipe-related errors (EPIPE, etc.)
|
||||
if e.errno == 32: # EPIPE - Broken pipe
|
||||
_pipe_broken = True
|
||||
try:
|
||||
sys.stdout.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("Output pipe closed (EPIPE)")
|
||||
else:
|
||||
# Re-raise unexpected OS errors
|
||||
raise
|
||||
|
||||
|
||||
def is_pipe_broken() -> bool:
|
||||
"""Check if the output pipe has been closed."""
|
||||
return _pipe_broken
|
||||
|
||||
|
||||
def reset_pipe_state() -> None:
|
||||
"""
|
||||
Reset pipe broken state.
|
||||
|
||||
Useful for testing or when starting a new subprocess context where
|
||||
stdout has been reopened. Should only be called when stdout is known
|
||||
to be functional (e.g., in a fresh subprocess with a new stdout).
|
||||
|
||||
Warning:
|
||||
Calling this after stdout has been closed will result in safe_print()
|
||||
attempting to write to the closed stream. The ValueError will be
|
||||
caught and the pipe will be marked as broken again.
|
||||
"""
|
||||
global _pipe_broken
|
||||
_pipe_broken = False
|
||||
@@ -52,8 +52,4 @@ def emit_phase(
|
||||
print(f"{PHASE_MARKER_PREFIX}{json.dumps(payload, default=str)}", flush=True)
|
||||
except (OSError, UnicodeEncodeError) as e:
|
||||
if _DEBUG:
|
||||
try:
|
||||
sys.stderr.write(f"[phase_event] emit failed: {e}\n")
|
||||
sys.stderr.flush()
|
||||
except (OSError, UnicodeEncodeError):
|
||||
pass # Truly silent on complete I/O failure
|
||||
print(f"[phase_event] emit failed: {e}", file=sys.stderr, flush=True)
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"""
|
||||
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
|
||||
@@ -11,7 +11,6 @@ Enhanced with colored output, icons, and better visual formatting.
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from core.plan_normalization import normalize_subtask_aliases
|
||||
from ui import (
|
||||
Icons,
|
||||
bold,
|
||||
@@ -380,7 +379,7 @@ def get_current_phase(spec_dir: Path) -> dict | None:
|
||||
plan = json.load(f)
|
||||
|
||||
for phase in plan.get("phases", []):
|
||||
subtasks = phase.get("subtasks", phase.get("chunks", []))
|
||||
subtasks = phase.get("subtasks", [])
|
||||
# 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:
|
||||
@@ -416,39 +415,24 @@ def get_next_subtask(spec_dir: Path) -> dict | None:
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
with open(plan_file) as f:
|
||||
plan = json.load(f)
|
||||
|
||||
phases = plan.get("phases", [])
|
||||
|
||||
# Build a map of phase completion
|
||||
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(
|
||||
phase_complete = {}
|
||||
for phase in phases:
|
||||
phase_id = phase.get("id") or phase.get("phase")
|
||||
subtasks = phase.get("subtasks", [])
|
||||
phase_complete[phase_id] = all(
|
||||
s.get("status") == "completed" for s in subtasks
|
||||
)
|
||||
|
||||
# Find next available subtask
|
||||
for phase in phases:
|
||||
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)]
|
||||
phase_id = phase.get("id") or phase.get("phase")
|
||||
depends_on = phase.get("depends_on", [])
|
||||
|
||||
# Check if dependencies are satisfied
|
||||
deps_satisfied = all(phase_complete.get(dep, False) for dep in depends_on)
|
||||
@@ -456,16 +440,13 @@ def get_next_subtask(spec_dir: Path) -> dict | None:
|
||||
continue
|
||||
|
||||
# Find first pending subtask in this phase
|
||||
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"
|
||||
for subtask in phase.get("subtasks", []):
|
||||
if subtask.get("status") == "pending":
|
||||
return {
|
||||
**subtask_out,
|
||||
"phase_id": phase_id,
|
||||
"phase_name": phase.get("name"),
|
||||
"phase_num": phase.get("phase"),
|
||||
**subtask,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
@@ -1,406 +0,0 @@
|
||||
"""
|
||||
Sentry Error Tracking for Python Backend
|
||||
=========================================
|
||||
|
||||
Initializes Sentry for the Python backend with:
|
||||
- Privacy-preserving path masking (usernames removed)
|
||||
- Release tracking matching the Electron frontend
|
||||
- Environment variable configuration (same as frontend)
|
||||
|
||||
Configuration:
|
||||
- SENTRY_DSN: Required to enable Sentry (same as frontend)
|
||||
- SENTRY_TRACES_SAMPLE_RATE: Performance monitoring sample rate (0-1, default: 0.1)
|
||||
- SENTRY_ENVIRONMENT: Override environment (default: auto-detected)
|
||||
|
||||
Privacy Note:
|
||||
- Usernames are masked from all file paths
|
||||
- Project paths remain visible for debugging (this is expected)
|
||||
- No user identifiers are collected
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Track initialization state
|
||||
_sentry_initialized = False
|
||||
_sentry_enabled = False
|
||||
|
||||
# Production trace sample rate (10%)
|
||||
PRODUCTION_TRACE_SAMPLE_RATE = 0.1
|
||||
|
||||
|
||||
def _get_version() -> str:
|
||||
"""
|
||||
Get the application version.
|
||||
|
||||
Tries to read from package.json in the frontend directory,
|
||||
falling back to a default version.
|
||||
"""
|
||||
try:
|
||||
# Try to find package.json relative to this file
|
||||
backend_dir = Path(__file__).parent.parent
|
||||
frontend_dir = backend_dir.parent / "frontend"
|
||||
package_json = frontend_dir / "package.json"
|
||||
|
||||
if package_json.exists():
|
||||
import json
|
||||
|
||||
with open(package_json) as f:
|
||||
data = json.load(f)
|
||||
return data.get("version", "0.0.0")
|
||||
except Exception as e:
|
||||
logger.debug(f"Version detection failed: {e}")
|
||||
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
def _mask_user_paths(text: str) -> str:
|
||||
"""
|
||||
Mask user-specific paths for privacy.
|
||||
|
||||
Replaces usernames in common OS path patterns:
|
||||
- macOS: /Users/username/... becomes /Users/***/...
|
||||
- Windows: C:\\Users\\username\\... becomes C:\\Users\\***\\...
|
||||
- Linux: /home/username/... becomes /home/***/...
|
||||
- WSL: /mnt/c/Users/username/... becomes /mnt/c/Users/***/...
|
||||
|
||||
Note: Project paths remain visible for debugging purposes.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
# macOS: /Users/username/...
|
||||
text = re.sub(r"/Users/[^/]+(?=/|$)", "/Users/***", text)
|
||||
|
||||
# Windows: C:\Users\username\...
|
||||
text = re.sub(
|
||||
r"[A-Za-z]:\\Users\\[^\\]+(?=\\|$)",
|
||||
lambda m: f"{m.group(0)[0]}:\\Users\\***",
|
||||
text,
|
||||
)
|
||||
|
||||
# Linux: /home/username/...
|
||||
text = re.sub(r"/home/[^/]+(?=/|$)", "/home/***", text)
|
||||
|
||||
# WSL: /mnt/c/Users/username/... (accessing Windows filesystem from WSL)
|
||||
text = re.sub(
|
||||
r"/mnt/[a-z]/Users/[^/]+(?=/|$)",
|
||||
lambda m: f"{m.group(0)[:6]}/Users/***",
|
||||
text,
|
||||
)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def _mask_object_paths(obj: Any, _depth: int = 0) -> Any:
|
||||
"""
|
||||
Recursively mask paths in an object.
|
||||
|
||||
Args:
|
||||
obj: The object to mask paths in
|
||||
_depth: Current recursion depth (internal use)
|
||||
|
||||
Returns:
|
||||
Object with paths masked
|
||||
"""
|
||||
# Prevent stack overflow on deeply nested or circular structures
|
||||
if _depth > 50:
|
||||
return obj
|
||||
|
||||
if obj is None:
|
||||
return obj
|
||||
|
||||
if isinstance(obj, str):
|
||||
return _mask_user_paths(obj)
|
||||
|
||||
if isinstance(obj, list):
|
||||
return [_mask_object_paths(item, _depth + 1) for item in obj]
|
||||
|
||||
if isinstance(obj, dict):
|
||||
return {
|
||||
key: _mask_object_paths(value, _depth + 1) for key, value in obj.items()
|
||||
}
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
def _before_send(event: dict, hint: dict) -> dict | None:
|
||||
"""
|
||||
Process event before sending to Sentry.
|
||||
|
||||
Applies privacy masking to all paths in the event.
|
||||
"""
|
||||
if not _sentry_enabled:
|
||||
return None
|
||||
|
||||
# Mask paths in exception stack traces
|
||||
if "exception" in event and "values" in event["exception"]:
|
||||
for exception in event["exception"]["values"]:
|
||||
if "stacktrace" in exception and "frames" in exception["stacktrace"]:
|
||||
for frame in exception["stacktrace"]["frames"]:
|
||||
if "filename" in frame:
|
||||
frame["filename"] = _mask_user_paths(frame["filename"])
|
||||
if "abs_path" in frame:
|
||||
frame["abs_path"] = _mask_user_paths(frame["abs_path"])
|
||||
if "value" in exception:
|
||||
exception["value"] = _mask_user_paths(exception["value"])
|
||||
|
||||
# Mask paths in breadcrumbs
|
||||
if "breadcrumbs" in event:
|
||||
for breadcrumb in event.get("breadcrumbs", {}).get("values", []):
|
||||
if "message" in breadcrumb:
|
||||
breadcrumb["message"] = _mask_user_paths(breadcrumb["message"])
|
||||
if "data" in breadcrumb:
|
||||
breadcrumb["data"] = _mask_object_paths(breadcrumb["data"])
|
||||
|
||||
# Mask paths in message
|
||||
if "message" in event:
|
||||
event["message"] = _mask_user_paths(event["message"])
|
||||
|
||||
# Mask paths in tags
|
||||
if "tags" in event:
|
||||
event["tags"] = _mask_object_paths(event["tags"])
|
||||
|
||||
# Mask paths in contexts
|
||||
if "contexts" in event:
|
||||
event["contexts"] = _mask_object_paths(event["contexts"])
|
||||
|
||||
# Mask paths in extra data
|
||||
if "extra" in event:
|
||||
event["extra"] = _mask_object_paths(event["extra"])
|
||||
|
||||
# Clear user info for privacy
|
||||
if "user" in event:
|
||||
event["user"] = {}
|
||||
|
||||
return event
|
||||
|
||||
|
||||
def init_sentry(
|
||||
component: str = "backend",
|
||||
force_enable: bool = False,
|
||||
) -> bool:
|
||||
"""
|
||||
Initialize Sentry for the Python backend.
|
||||
|
||||
Args:
|
||||
component: Component name for tagging (e.g., "backend", "github-runner")
|
||||
force_enable: Force enable even without packaged app detection
|
||||
|
||||
Returns:
|
||||
True if Sentry was initialized, False otherwise
|
||||
"""
|
||||
global _sentry_initialized, _sentry_enabled
|
||||
|
||||
if _sentry_initialized:
|
||||
return _sentry_enabled
|
||||
|
||||
_sentry_initialized = True
|
||||
|
||||
# Get DSN from environment variable
|
||||
dsn = os.environ.get("SENTRY_DSN", "")
|
||||
|
||||
if not dsn:
|
||||
logger.debug("[Sentry] No SENTRY_DSN configured - error reporting disabled")
|
||||
return False
|
||||
|
||||
# Check if we should enable Sentry
|
||||
# Enable if:
|
||||
# - Running from packaged app (detected by __compiled__ or frozen)
|
||||
# - SENTRY_DEV=true is set
|
||||
# - force_enable is True
|
||||
is_packaged = getattr(sys, "frozen", False) or hasattr(sys, "__compiled__")
|
||||
sentry_dev = os.environ.get("SENTRY_DEV", "").lower() in ("true", "1", "yes")
|
||||
should_enable = is_packaged or sentry_dev or force_enable
|
||||
|
||||
if not should_enable:
|
||||
logger.debug(
|
||||
"[Sentry] Development mode - error reporting disabled (set SENTRY_DEV=true to enable)"
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||
except ImportError:
|
||||
logger.warning("[Sentry] sentry-sdk not installed - error reporting disabled")
|
||||
return False
|
||||
|
||||
# Get configuration from environment variables
|
||||
version = _get_version()
|
||||
environment = os.environ.get(
|
||||
"SENTRY_ENVIRONMENT", "production" if is_packaged else "development"
|
||||
)
|
||||
|
||||
# Get sample rates
|
||||
traces_sample_rate = PRODUCTION_TRACE_SAMPLE_RATE
|
||||
try:
|
||||
env_rate = os.environ.get("SENTRY_TRACES_SAMPLE_RATE")
|
||||
if env_rate:
|
||||
parsed = float(env_rate)
|
||||
if 0 <= parsed <= 1:
|
||||
traces_sample_rate = parsed
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Configure logging integration to capture errors and warnings
|
||||
logging_integration = LoggingIntegration(
|
||||
level=logging.INFO, # Capture INFO and above as breadcrumbs
|
||||
event_level=logging.ERROR, # Send ERROR and above as events
|
||||
)
|
||||
|
||||
# Initialize Sentry
|
||||
sentry_sdk.init(
|
||||
dsn=dsn,
|
||||
environment=environment,
|
||||
release=f"auto-claude@{version}",
|
||||
traces_sample_rate=traces_sample_rate,
|
||||
before_send=_before_send,
|
||||
integrations=[logging_integration],
|
||||
# Don't send PII
|
||||
send_default_pii=False,
|
||||
)
|
||||
|
||||
# Set component tag
|
||||
sentry_sdk.set_tag("component", component)
|
||||
|
||||
_sentry_enabled = True
|
||||
logger.info(
|
||||
f"[Sentry] Backend initialized (component: {component}, release: auto-claude@{version}, traces: {traces_sample_rate})"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def capture_exception(error: Exception, **kwargs) -> None:
|
||||
"""
|
||||
Capture an exception and send to Sentry.
|
||||
|
||||
Safe to call even if Sentry is not initialized.
|
||||
|
||||
Args:
|
||||
error: The exception to capture
|
||||
**kwargs: Additional context to attach to the event
|
||||
"""
|
||||
if not _sentry_enabled:
|
||||
logger.error(f"[Sentry] Not enabled, exception not captured: {error}")
|
||||
return
|
||||
|
||||
try:
|
||||
import sentry_sdk
|
||||
|
||||
with sentry_sdk.push_scope() as scope:
|
||||
for key, value in kwargs.items():
|
||||
# Apply defensive path masking for extra data
|
||||
masked_value = (
|
||||
_mask_object_paths(value)
|
||||
if isinstance(value, (str, dict, list))
|
||||
else value
|
||||
)
|
||||
scope.set_extra(key, masked_value)
|
||||
sentry_sdk.capture_exception(error)
|
||||
except ImportError:
|
||||
logger.error(f"[Sentry] SDK not installed, exception not captured: {error}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Sentry] Failed to capture exception: {e}")
|
||||
|
||||
|
||||
def capture_message(message: str, level: str = "info", **kwargs) -> None:
|
||||
"""
|
||||
Capture a message and send to Sentry.
|
||||
|
||||
Safe to call even if Sentry is not initialized.
|
||||
|
||||
Args:
|
||||
message: The message to capture
|
||||
level: Log level (debug, info, warning, error, fatal)
|
||||
**kwargs: Additional context to attach to the event
|
||||
"""
|
||||
if not _sentry_enabled:
|
||||
return
|
||||
|
||||
try:
|
||||
import sentry_sdk
|
||||
|
||||
with sentry_sdk.push_scope() as scope:
|
||||
for key, value in kwargs.items():
|
||||
# Apply defensive path masking for extra data (same as capture_exception)
|
||||
masked_value = (
|
||||
_mask_object_paths(value)
|
||||
if isinstance(value, (str, dict, list))
|
||||
else value
|
||||
)
|
||||
scope.set_extra(key, masked_value)
|
||||
sentry_sdk.capture_message(message, level=level)
|
||||
except ImportError:
|
||||
logger.debug("[Sentry] SDK not installed")
|
||||
except Exception as e:
|
||||
logger.error(f"[Sentry] Failed to capture message: {e}")
|
||||
|
||||
|
||||
def set_context(name: str, data: dict) -> None:
|
||||
"""
|
||||
Set context data for subsequent events.
|
||||
|
||||
Safe to call even if Sentry is not initialized.
|
||||
|
||||
Args:
|
||||
name: Context name (e.g., "pr_review", "spec")
|
||||
data: Context data dictionary
|
||||
"""
|
||||
if not _sentry_enabled:
|
||||
return
|
||||
|
||||
try:
|
||||
import sentry_sdk
|
||||
|
||||
# Apply path masking to context data before sending to Sentry
|
||||
masked_data = _mask_object_paths(data)
|
||||
sentry_sdk.set_context(name, masked_data)
|
||||
except ImportError:
|
||||
logger.debug("[Sentry] SDK not installed")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to set context '{name}': {e}")
|
||||
|
||||
|
||||
def set_tag(key: str, value: str) -> None:
|
||||
"""
|
||||
Set a tag for subsequent events.
|
||||
|
||||
Safe to call even if Sentry is not initialized.
|
||||
|
||||
Args:
|
||||
key: Tag key
|
||||
value: Tag value
|
||||
"""
|
||||
if not _sentry_enabled:
|
||||
return
|
||||
|
||||
try:
|
||||
import sentry_sdk
|
||||
|
||||
# Apply path masking to tag value
|
||||
masked_value = _mask_user_paths(value) if isinstance(value, str) else value
|
||||
sentry_sdk.set_tag(key, masked_value)
|
||||
except ImportError:
|
||||
logger.debug("[Sentry] SDK not installed")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to set tag '{key}': {e}")
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
"""Check if Sentry is enabled."""
|
||||
return _sentry_enabled
|
||||
|
||||
|
||||
def is_initialized() -> bool:
|
||||
"""Check if Sentry initialization has been attempted."""
|
||||
return _sentry_initialized
|
||||
@@ -26,7 +26,6 @@ from pathlib import Path
|
||||
from agents.tools_pkg import get_agent_config, get_default_thinking_level
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
from core.auth import get_sdk_env_vars, require_auth_token
|
||||
from core.client import find_claude_cli
|
||||
from phase_config import get_thinking_budget
|
||||
|
||||
|
||||
@@ -85,22 +84,14 @@ def create_simple_client(
|
||||
thinking_level = get_default_thinking_level(agent_type)
|
||||
max_thinking_tokens = get_thinking_budget(thinking_level)
|
||||
|
||||
# Find Claude CLI path (handles non-standard installations)
|
||||
cli_path = find_claude_cli()
|
||||
|
||||
# Build options dict
|
||||
options_kwargs = {
|
||||
"model": model,
|
||||
"system_prompt": system_prompt,
|
||||
"allowed_tools": allowed_tools,
|
||||
"max_turns": max_turns,
|
||||
"cwd": str(cwd.resolve()) if cwd else None,
|
||||
"env": sdk_env,
|
||||
"max_thinking_tokens": max_thinking_tokens,
|
||||
}
|
||||
|
||||
# Add CLI path if found
|
||||
if cli_path:
|
||||
options_kwargs["cli_path"] = cli_path
|
||||
|
||||
return ClaudeSDKClient(options=ClaudeAgentOptions(**options_kwargs))
|
||||
return ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model=model,
|
||||
system_prompt=system_prompt,
|
||||
allowed_tools=allowed_tools,
|
||||
max_turns=max_turns,
|
||||
cwd=str(cwd.resolve()) if cwd else None,
|
||||
env=sdk_env,
|
||||
max_thinking_tokens=max_thinking_tokens,
|
||||
)
|
||||
)
|
||||
|
||||
+204
-783
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ Workspace Management Package
|
||||
=============================
|
||||
|
||||
Handles workspace isolation through Git worktrees, where each spec
|
||||
gets its own isolated worktree in .auto-claude/worktrees/tasks/{spec-name}/.
|
||||
gets its own isolated worktree in .worktrees/{spec-name}/.
|
||||
|
||||
This package provides:
|
||||
- Workspace setup and configuration
|
||||
@@ -28,10 +28,6 @@ _workspace_module = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_workspace_module)
|
||||
merge_existing_build = _workspace_module.merge_existing_build
|
||||
_run_parallel_merges = _workspace_module._run_parallel_merges
|
||||
AI_MERGE_SYSTEM_PROMPT = _workspace_module.AI_MERGE_SYSTEM_PROMPT
|
||||
_build_merge_prompt = _workspace_module._build_merge_prompt
|
||||
_check_git_conflicts = _workspace_module._check_git_conflicts
|
||||
_rebase_spec_branch = _workspace_module._rebase_spec_branch
|
||||
|
||||
# Models and Enums
|
||||
# Display Functions
|
||||
@@ -66,7 +62,6 @@ from .git_utils import (
|
||||
MAX_SYNTAX_FIX_RETRIES,
|
||||
MERGE_LOCK_TIMEOUT,
|
||||
_create_conflict_file_with_git,
|
||||
_get_binary_file_content_from_ref,
|
||||
_get_changed_files_from_branch,
|
||||
_get_file_content_from_ref,
|
||||
_is_binary_file,
|
||||
@@ -75,7 +70,6 @@ from .git_utils import (
|
||||
_is_process_running,
|
||||
_validate_merged_syntax,
|
||||
create_conflict_file_with_git,
|
||||
get_binary_file_content_from_ref,
|
||||
get_changed_files_from_branch,
|
||||
get_current_branch,
|
||||
get_existing_build_worktree,
|
||||
@@ -111,10 +105,6 @@ __all__ = [
|
||||
# Merge Operations (from workspace.py)
|
||||
"merge_existing_build",
|
||||
"_run_parallel_merges", # Private but used internally
|
||||
"AI_MERGE_SYSTEM_PROMPT", # System prompt for AI merge (ACS-194)
|
||||
"_build_merge_prompt", # Internal prompt builder (ACS-194)
|
||||
"_check_git_conflicts", # Internal git conflict detection (ACS-224)
|
||||
"_rebase_spec_branch", # Internal rebase function (ACS-224)
|
||||
# Models
|
||||
"WorkspaceMode",
|
||||
"WorkspaceChoice",
|
||||
@@ -127,7 +117,6 @@ __all__ = [
|
||||
"get_current_branch",
|
||||
"get_existing_build_worktree",
|
||||
"get_file_content_from_ref",
|
||||
"get_binary_file_content_from_ref",
|
||||
"get_changed_files_from_branch",
|
||||
"is_process_running",
|
||||
"is_binary_file",
|
||||
|
||||
@@ -149,14 +149,7 @@ def print_merge_success(
|
||||
|
||||
|
||||
def print_conflict_info(result: dict) -> None:
|
||||
"""Print information about conflicts that occurred during merge.
|
||||
|
||||
The conflicts can be either:
|
||||
- List of strings (file paths) - for git conflict markers
|
||||
- List of dicts with keys: file, reason, severity - for AI merge failures
|
||||
"""
|
||||
import shlex
|
||||
|
||||
"""Print information about conflicts that occurred during merge."""
|
||||
from ui import highlight, muted, warning
|
||||
|
||||
conflicts = result.get("conflicts", [])
|
||||
@@ -169,57 +162,12 @@ def print_conflict_info(result: dict) -> None:
|
||||
f" {len(conflicts)} file{'s' if len(conflicts) != 1 else ''} had conflicts:"
|
||||
)
|
||||
)
|
||||
|
||||
# Extract file paths from conflicts (handle both strings and dicts)
|
||||
file_paths: list[str] = []
|
||||
has_marker_conflicts = False
|
||||
has_ai_conflicts = False
|
||||
for conflict in conflicts:
|
||||
if isinstance(conflict, str):
|
||||
# Simple string - just the file path
|
||||
file_paths.append(conflict)
|
||||
print(f" {highlight(conflict)}")
|
||||
has_marker_conflicts = True
|
||||
elif isinstance(conflict, dict):
|
||||
# Dict with file, reason, severity keys
|
||||
file_path = conflict.get("file", "unknown")
|
||||
reason = conflict.get("reason", "")
|
||||
severity = conflict.get("severity", "medium")
|
||||
|
||||
# Add severity indicator
|
||||
severity_icon = ""
|
||||
if severity == "critical":
|
||||
severity_icon = "⛔"
|
||||
elif severity == "high":
|
||||
severity_icon = "🔴"
|
||||
elif severity == "medium":
|
||||
severity_icon = "🟡"
|
||||
|
||||
file_paths.append(file_path)
|
||||
# Only add space if icon is present (no trailing space when empty)
|
||||
icon_with_space = f" {severity_icon}" if severity_icon else ""
|
||||
print(f" {highlight(file_path)}{icon_with_space}")
|
||||
if reason:
|
||||
print(f" {muted(reason)}")
|
||||
has_ai_conflicts = True
|
||||
|
||||
for conflict_file in conflicts:
|
||||
print(f" {highlight(conflict_file)}")
|
||||
print()
|
||||
if has_marker_conflicts:
|
||||
print(
|
||||
muted(
|
||||
" Some files may contain conflict markers (<<<<<<< ======= >>>>>>>)."
|
||||
)
|
||||
)
|
||||
if has_ai_conflicts:
|
||||
print(
|
||||
muted(
|
||||
" Some files could not be auto-merged; review and resolve as needed."
|
||||
)
|
||||
)
|
||||
print(muted(" Then run:"))
|
||||
# Quote paths and dedupe while preserving order
|
||||
quoted = " ".join(shlex.quote(p) for p in dict.fromkeys(file_paths))
|
||||
print(f" git add {quoted}")
|
||||
print(muted(" These files have conflict markers (<<<<<<< ======= >>>>>>>)"))
|
||||
print(muted(" Review and resolve them, then run:"))
|
||||
print(f" git add {' '.join(conflicts)}")
|
||||
print(" git commit")
|
||||
print()
|
||||
|
||||
|
||||
@@ -169,15 +169,7 @@ def handle_workspace_choice(
|
||||
if staging_path:
|
||||
print(highlight(f" cd {staging_path}"))
|
||||
else:
|
||||
worktree_path = get_existing_build_worktree(project_dir, spec_name)
|
||||
if worktree_path:
|
||||
print(highlight(f" cd {worktree_path}"))
|
||||
else:
|
||||
print(
|
||||
highlight(
|
||||
f" cd {project_dir}/.auto-claude/worktrees/tasks/{spec_name}"
|
||||
)
|
||||
)
|
||||
print(highlight(f" cd {project_dir}/.worktrees/{spec_name}"))
|
||||
|
||||
# Show likely test/run commands
|
||||
if staging_path:
|
||||
@@ -240,15 +232,7 @@ def handle_workspace_choice(
|
||||
if staging_path:
|
||||
print(highlight(f" cd {staging_path}"))
|
||||
else:
|
||||
worktree_path = get_existing_build_worktree(project_dir, spec_name)
|
||||
if worktree_path:
|
||||
print(highlight(f" cd {worktree_path}"))
|
||||
else:
|
||||
print(
|
||||
highlight(
|
||||
f" cd {project_dir}/.auto-claude/worktrees/tasks/{spec_name}"
|
||||
)
|
||||
)
|
||||
print(highlight(f" cd {project_dir}/.worktrees/{spec_name}"))
|
||||
print()
|
||||
print("When you're ready to add it:")
|
||||
print(highlight(f" python auto-claude/run.py --spec {spec_name} --merge"))
|
||||
|
||||
@@ -10,45 +10,6 @@ import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from core.git_executable import get_git_executable, run_git
|
||||
|
||||
__all__ = [
|
||||
# Exported helpers
|
||||
"get_git_executable",
|
||||
"run_git",
|
||||
# Constants
|
||||
"MAX_FILE_LINES_FOR_AI",
|
||||
"MAX_PARALLEL_AI_MERGES",
|
||||
"LOCK_FILES",
|
||||
"BINARY_EXTENSIONS",
|
||||
"MERGE_LOCK_TIMEOUT",
|
||||
"MAX_SYNTAX_FIX_RETRIES",
|
||||
# Functions
|
||||
"detect_file_renames",
|
||||
"apply_path_mapping",
|
||||
"get_merge_base",
|
||||
"has_uncommitted_changes",
|
||||
"get_current_branch",
|
||||
"get_existing_build_worktree",
|
||||
"get_file_content_from_ref",
|
||||
"get_binary_file_content_from_ref",
|
||||
"get_changed_files_from_branch",
|
||||
"is_process_running",
|
||||
"is_binary_file",
|
||||
"is_lock_file",
|
||||
"validate_merged_syntax",
|
||||
"create_conflict_file_with_git",
|
||||
# Backward compat aliases
|
||||
"_is_process_running",
|
||||
"_is_binary_file",
|
||||
"_is_lock_file",
|
||||
"_validate_merged_syntax",
|
||||
"_get_file_content_from_ref",
|
||||
"_get_binary_file_content_from_ref",
|
||||
"_get_changed_files_from_branch",
|
||||
"_create_conflict_file_with_git",
|
||||
]
|
||||
|
||||
# Constants for merge limits
|
||||
MAX_FILE_LINES_FOR_AI = 5000 # Skip AI for files larger than this
|
||||
MAX_PARALLEL_AI_MERGES = 5 # Limit concurrent AI merge operations
|
||||
@@ -61,7 +22,6 @@ LOCK_FILES = {
|
||||
"pnpm-lock.yaml",
|
||||
"yarn.lock",
|
||||
"bun.lockb",
|
||||
"bun.lock",
|
||||
"Pipfile.lock",
|
||||
"poetry.lock",
|
||||
"uv.lock",
|
||||
@@ -72,7 +32,6 @@ LOCK_FILES = {
|
||||
}
|
||||
|
||||
BINARY_EXTENSIONS = {
|
||||
# Images
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
@@ -81,11 +40,6 @@ BINARY_EXTENSIONS = {
|
||||
".webp",
|
||||
".bmp",
|
||||
".svg",
|
||||
".tiff",
|
||||
".tif",
|
||||
".heic",
|
||||
".heif",
|
||||
# Documents
|
||||
".pdf",
|
||||
".doc",
|
||||
".docx",
|
||||
@@ -93,63 +47,32 @@ BINARY_EXTENSIONS = {
|
||||
".xlsx",
|
||||
".ppt",
|
||||
".pptx",
|
||||
# Archives
|
||||
".zip",
|
||||
".tar",
|
||||
".gz",
|
||||
".rar",
|
||||
".7z",
|
||||
".bz2",
|
||||
".xz",
|
||||
".zst",
|
||||
# Executables and libraries
|
||||
".exe",
|
||||
".dll",
|
||||
".so",
|
||||
".dylib",
|
||||
".bin",
|
||||
".msi",
|
||||
".app",
|
||||
# WebAssembly
|
||||
".wasm",
|
||||
# Audio
|
||||
".mp3",
|
||||
".wav",
|
||||
".ogg",
|
||||
".flac",
|
||||
".aac",
|
||||
".m4a",
|
||||
# Video
|
||||
".mp4",
|
||||
".wav",
|
||||
".avi",
|
||||
".mov",
|
||||
".mkv",
|
||||
".webm",
|
||||
".wmv",
|
||||
".flv",
|
||||
# Fonts
|
||||
".woff",
|
||||
".woff2",
|
||||
".ttf",
|
||||
".otf",
|
||||
".eot",
|
||||
# Compiled code
|
||||
".pyc",
|
||||
".pyo",
|
||||
".class",
|
||||
".o",
|
||||
".obj",
|
||||
# Data files
|
||||
".dat",
|
||||
".db",
|
||||
".sqlite",
|
||||
".sqlite3",
|
||||
# Other binary formats
|
||||
".cur",
|
||||
".ani",
|
||||
".pbm",
|
||||
".pgm",
|
||||
".ppm",
|
||||
}
|
||||
|
||||
# Merge lock timeout in seconds
|
||||
@@ -189,8 +112,9 @@ def detect_file_renames(
|
||||
# -M flag enables rename detection
|
||||
# --diff-filter=R shows only renames
|
||||
# --name-status shows status and file names
|
||||
result = run_git(
|
||||
result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"log",
|
||||
"--name-status",
|
||||
"-M",
|
||||
@@ -199,6 +123,8 @@ def detect_file_renames(
|
||||
f"{from_ref}..{to_ref}",
|
||||
],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
@@ -248,21 +174,39 @@ def get_merge_base(project_dir: Path, ref1: str, ref2: str) -> str | None:
|
||||
Returns:
|
||||
Merge-base commit hash, or None if not found
|
||||
"""
|
||||
result = run_git(["merge-base", ref1, ref2], cwd=project_dir)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "merge-base", ref1, ref2],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def has_uncommitted_changes(project_dir: Path) -> bool:
|
||||
"""Check if user has unsaved work."""
|
||||
result = run_git(["status", "--porcelain"], cwd=project_dir)
|
||||
result = subprocess.run(
|
||||
["git", "status", "--porcelain"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return bool(result.stdout.strip())
|
||||
|
||||
|
||||
def get_current_branch(project_dir: Path) -> str:
|
||||
"""Get the current branch name."""
|
||||
result = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=project_dir)
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
@@ -277,16 +221,10 @@ def get_existing_build_worktree(project_dir: Path, spec_name: str) -> Path | Non
|
||||
Returns:
|
||||
Path to the worktree if it exists for this spec, None otherwise
|
||||
"""
|
||||
# New path first
|
||||
new_path = project_dir / ".auto-claude" / "worktrees" / "tasks" / spec_name
|
||||
if new_path.exists():
|
||||
return new_path
|
||||
|
||||
# Legacy fallback
|
||||
legacy_path = project_dir / ".worktrees" / spec_name
|
||||
if legacy_path.exists():
|
||||
return legacy_path
|
||||
|
||||
# Per-spec worktree path: .worktrees/{spec-name}/
|
||||
worktree_path = project_dir / ".worktrees" / spec_name
|
||||
if worktree_path.exists():
|
||||
return worktree_path
|
||||
return None
|
||||
|
||||
|
||||
@@ -294,29 +232,11 @@ def get_file_content_from_ref(
|
||||
project_dir: Path, ref: str, file_path: str
|
||||
) -> str | None:
|
||||
"""Get file content from a git ref (branch, commit, etc.)."""
|
||||
result = run_git(["show", f"{ref}:{file_path}"], cwd=project_dir)
|
||||
if result.returncode == 0:
|
||||
return result.stdout
|
||||
return None
|
||||
|
||||
|
||||
def get_binary_file_content_from_ref(
|
||||
project_dir: Path, ref: str, file_path: str
|
||||
) -> bytes | None:
|
||||
"""Get binary file content from a git ref (branch, commit, etc.).
|
||||
|
||||
Unlike get_file_content_from_ref, this returns raw bytes without
|
||||
text decoding, suitable for binary files like images, audio, etc.
|
||||
|
||||
Note: Uses subprocess directly with get_git_executable() since
|
||||
run_git() always returns text output.
|
||||
"""
|
||||
git = get_git_executable()
|
||||
result = subprocess.run(
|
||||
[git, "show", f"{ref}:{file_path}"],
|
||||
["git", "show", f"{ref}:{file_path}"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=False, # Return bytes, not text
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout
|
||||
@@ -341,9 +261,11 @@ def get_changed_files_from_branch(
|
||||
Returns:
|
||||
List of (file_path, status) tuples
|
||||
"""
|
||||
result = run_git(
|
||||
["diff", "--name-status", f"{base_branch}...{spec_branch}"],
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-status", f"{base_branch}...{spec_branch}"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
files = []
|
||||
@@ -360,23 +282,15 @@ def get_changed_files_from_branch(
|
||||
return files
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
"""Normalize path separators to forward slashes for cross-platform comparison."""
|
||||
return path.replace("\\", "/")
|
||||
|
||||
|
||||
def _is_auto_claude_file(file_path: str) -> bool:
|
||||
"""Check if a file is in the .auto-claude or auto-claude/specs directory.
|
||||
|
||||
Handles both forward slashes (Unix/Git output) and backslashes (Windows).
|
||||
"""
|
||||
normalized = _normalize_path(file_path)
|
||||
"""Check if a file is in the .auto-claude or auto-claude/specs directory."""
|
||||
# These patterns cover the internal spec/build files that shouldn't be merged
|
||||
excluded_patterns = [
|
||||
".auto-claude/",
|
||||
"auto-claude/specs/",
|
||||
]
|
||||
for pattern in excluded_patterns:
|
||||
if normalized.startswith(pattern):
|
||||
if file_path.startswith(pattern):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -570,9 +484,11 @@ def create_conflict_file_with_git(
|
||||
try:
|
||||
# git merge-file <current> <base> <other>
|
||||
# Exit codes: 0 = clean merge, 1 = conflicts, >1 = error
|
||||
result = run_git(
|
||||
["merge-file", "-p", main_path, base_path, wt_path],
|
||||
result = subprocess.run(
|
||||
["git", "merge-file", "-p", main_path, base_path, wt_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Read the merged content
|
||||
@@ -599,6 +515,5 @@ _is_binary_file = is_binary_file
|
||||
_is_lock_file = is_lock_file
|
||||
_validate_merged_syntax = validate_merged_syntax
|
||||
_get_file_content_from_ref = get_file_content_from_ref
|
||||
_get_binary_file_content_from_ref = get_binary_file_content_from_ref
|
||||
_get_changed_files_from_branch = get_changed_files_from_branch
|
||||
_create_conflict_file_with_git = create_conflict_file_with_git
|
||||
|
||||
@@ -6,6 +6,7 @@ Workspace Models
|
||||
Data classes and enums for workspace management.
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
@@ -227,12 +228,15 @@ class SpecNumberLock:
|
||||
|
||||
def get_next_spec_number(self) -> int:
|
||||
"""
|
||||
Scan all spec locations and return the next available spec number.
|
||||
|
||||
Must be called while lock is held.
|
||||
|
||||
Compute the next global spec number by scanning the project's specs and all worktree specs.
|
||||
|
||||
Requires the spec-numbering lock to be held; caches the computed maximum for subsequent calls.
|
||||
|
||||
Returns:
|
||||
Next available spec number (global max + 1)
|
||||
int: The next available spec number (highest existing spec number + 1).
|
||||
|
||||
Raises:
|
||||
SpecNumberLockError: If the lock has not been acquired when called.
|
||||
"""
|
||||
if not self.acquired:
|
||||
raise SpecNumberLockError(
|
||||
@@ -249,7 +253,10 @@ class SpecNumberLock:
|
||||
max_number = max(max_number, self._scan_specs_dir(main_specs_dir))
|
||||
|
||||
# 2. Scan all worktree specs
|
||||
worktrees_dir = self.project_dir / ".auto-claude" / "worktrees" / "tasks"
|
||||
from core.config import get_worktree_base_path
|
||||
|
||||
worktree_base_path = get_worktree_base_path(self.project_dir)
|
||||
worktrees_dir = self.project_dir / worktree_base_path
|
||||
if worktrees_dir.exists():
|
||||
for worktree in worktrees_dir.iterdir():
|
||||
if worktree.is_dir():
|
||||
@@ -272,4 +279,4 @@ class SpecNumberLock:
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return max_num
|
||||
return max_num
|
||||
@@ -8,12 +8,11 @@ Functions for setting up and initializing workspaces.
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from core.git_executable import run_git
|
||||
from merge import FileTimelineTracker
|
||||
from security.constants import ALLOWLIST_FILENAME, PROFILE_FILENAME
|
||||
from ui import (
|
||||
Icons,
|
||||
MenuOption,
|
||||
@@ -268,60 +267,6 @@ def setup_workspace(
|
||||
f"Environment files copied: {', '.join(copied_env_files)}", "success"
|
||||
)
|
||||
|
||||
# Copy security configuration files if they exist
|
||||
# Note: Unlike env files, security files always overwrite to ensure
|
||||
# the worktree uses the same security rules as the main project.
|
||||
# This prevents security bypasses through stale worktree configs.
|
||||
security_files = [
|
||||
ALLOWLIST_FILENAME,
|
||||
PROFILE_FILENAME,
|
||||
]
|
||||
security_files_copied = []
|
||||
|
||||
for filename in security_files:
|
||||
source_file = project_dir / filename
|
||||
if source_file.is_file():
|
||||
target_file = worktree_info.path / filename
|
||||
try:
|
||||
shutil.copy2(source_file, target_file)
|
||||
security_files_copied.append(filename)
|
||||
except (OSError, PermissionError) as e:
|
||||
debug_warning(MODULE, f"Failed to copy {filename}: {e}")
|
||||
print_status(
|
||||
f"Warning: Could not copy {filename} to worktree", "warning"
|
||||
)
|
||||
|
||||
if security_files_copied:
|
||||
print_status(
|
||||
f"Security config copied: {', '.join(security_files_copied)}", "success"
|
||||
)
|
||||
|
||||
# Mark the security profile as inherited from parent project
|
||||
# This prevents hash-based re-analysis which would produce a broken profile
|
||||
# (worktrees lack node_modules and other build artifacts needed for detection)
|
||||
if PROFILE_FILENAME in security_files_copied:
|
||||
profile_path = worktree_info.path / PROFILE_FILENAME
|
||||
try:
|
||||
with open(profile_path) as f:
|
||||
profile_data = json.load(f)
|
||||
profile_data["inherited_from"] = str(project_dir.resolve())
|
||||
with open(profile_path, "w") as f:
|
||||
json.dump(profile_data, f, indent=2)
|
||||
debug(
|
||||
MODULE, f"Marked security profile as inherited from {project_dir}"
|
||||
)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
debug_warning(MODULE, f"Failed to mark profile as inherited: {e}")
|
||||
|
||||
# Ensure .auto-claude/ is in the worktree's .gitignore
|
||||
# This is critical because the worktree inherits .gitignore from the base branch,
|
||||
# which may not have .auto-claude/ if that change wasn't committed/pushed.
|
||||
# Without this, spec files would be committed to the worktree's branch.
|
||||
from init import ensure_gitignore_entry
|
||||
|
||||
if ensure_gitignore_entry(worktree_info.path, ".auto-claude/"):
|
||||
debug(MODULE, "Added .auto-claude/ to worktree's .gitignore")
|
||||
|
||||
# Copy spec files to worktree if provided
|
||||
localized_spec_dir = None
|
||||
if source_spec_dir and source_spec_dir.exists():
|
||||
@@ -423,9 +368,11 @@ def initialize_timeline_tracking(
|
||||
files_to_modify.extend(subtask.get("files", []))
|
||||
|
||||
# Get the current branch point commit
|
||||
result = run_git(
|
||||
["rev-parse", "HEAD"],
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
branch_point = result.stdout.strip() if result.returncode == 0 else None
|
||||
|
||||
|
||||
+144
-839
File diff suppressed because it is too large
Load Diff
@@ -25,7 +25,7 @@ class IdeationConfigManager:
|
||||
include_roadmap_context: bool = True,
|
||||
include_kanban_context: bool = True,
|
||||
max_ideas_per_type: int = 5,
|
||||
model: str = "sonnet", # Changed from "opus" (fix #433)
|
||||
model: str = "claude-opus-4-5-20251101",
|
||||
thinking_level: str = "medium",
|
||||
refresh: bool = False,
|
||||
append: bool = False,
|
||||
|
||||
@@ -17,7 +17,7 @@ from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from client import create_client
|
||||
from phase_config import get_thinking_budget, resolve_model_id
|
||||
from phase_config import get_thinking_budget
|
||||
from ui import print_status
|
||||
|
||||
# Ideation types
|
||||
@@ -56,7 +56,7 @@ class IdeationGenerator:
|
||||
self,
|
||||
project_dir: Path,
|
||||
output_dir: Path,
|
||||
model: str = "sonnet", # Changed from "opus" (fix #433)
|
||||
model: str = "claude-opus-4-5-20251101",
|
||||
thinking_level: str = "medium",
|
||||
max_ideas_per_type: int = 5,
|
||||
):
|
||||
@@ -94,7 +94,7 @@ class IdeationGenerator:
|
||||
client = create_client(
|
||||
self.project_dir,
|
||||
self.output_dir,
|
||||
resolve_model_id(self.model),
|
||||
self.model,
|
||||
max_thinking_tokens=self.thinking_budget,
|
||||
)
|
||||
|
||||
@@ -187,7 +187,7 @@ Write the fixed JSON to the file now.
|
||||
client = create_client(
|
||||
self.project_dir,
|
||||
self.output_dir,
|
||||
resolve_model_id(self.model),
|
||||
self.model,
|
||||
max_thinking_tokens=self.thinking_budget,
|
||||
)
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ class IdeationOrchestrator:
|
||||
include_roadmap_context: bool = True,
|
||||
include_kanban_context: bool = True,
|
||||
max_ideas_per_type: int = 5,
|
||||
model: str = "sonnet", # Changed from "opus" (fix #433)
|
||||
model: str = "claude-opus-4-5-20251101",
|
||||
thinking_level: str = "medium",
|
||||
refresh: bool = False,
|
||||
append: bool = False,
|
||||
|
||||
@@ -31,6 +31,6 @@ class IdeationConfig:
|
||||
include_roadmap_context: bool = True
|
||||
include_kanban_context: bool = True
|
||||
max_ideas_per_type: int = 5
|
||||
model: str = "sonnet" # Changed from "opus" (fix #433)
|
||||
model: str = "claude-opus-4-5-20251101"
|
||||
refresh: bool = False
|
||||
append: bool = False # If True, preserve existing ideas when merging
|
||||
|
||||
@@ -7,15 +7,11 @@ Defines the complete implementation plan for a feature/task with progress
|
||||
tracking, status management, and follow-up capabilities.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import json
|
||||
from dataclasses import dataclass, field, fields
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from core.file_utils import write_json_atomic
|
||||
|
||||
from .enums import PhaseType, SubtaskStatus, WorkflowType
|
||||
from .phase import Phase
|
||||
from .subtask import Subtask
|
||||
@@ -102,57 +98,18 @@ class ImplementationPlan:
|
||||
qa_signoff=data.get("qa_signoff"),
|
||||
)
|
||||
|
||||
def _update_timestamps_and_status(self) -> None:
|
||||
"""Update timestamps and status before saving.
|
||||
|
||||
Sets updated_at to now, initializes created_at if needed, and updates
|
||||
status based on subtask completion.
|
||||
"""
|
||||
def save(self, path: Path):
|
||||
"""Save plan to JSON file."""
|
||||
self.updated_at = datetime.now().isoformat()
|
||||
if not self.created_at:
|
||||
self.created_at = self.updated_at
|
||||
|
||||
# Auto-update status based on subtask completion
|
||||
self.update_status_from_subtasks()
|
||||
|
||||
def save(self, path: Path) -> None:
|
||||
"""Save plan to JSON file using atomic write to prevent corruption."""
|
||||
self._update_timestamps_and_status()
|
||||
# Use atomic write to prevent corruption on crash/interrupt
|
||||
write_json_atomic(path, self.to_dict(), indent=2, ensure_ascii=False)
|
||||
|
||||
async def async_save(self, path: Path) -> None:
|
||||
"""
|
||||
Async version of save() - runs file I/O in thread pool to avoid blocking event loop.
|
||||
|
||||
Use this from async contexts (like agent sessions) to prevent blocking.
|
||||
Restores in-memory state if the write fails.
|
||||
"""
|
||||
# Capture full state for potential rollback (handles future field additions)
|
||||
old_state = self.to_dict()
|
||||
|
||||
# Update state and capture dict
|
||||
self._update_timestamps_and_status()
|
||||
data = self.to_dict()
|
||||
|
||||
# Run sync write in thread pool to avoid blocking event loop
|
||||
loop = asyncio.get_running_loop()
|
||||
partial_write = functools.partial(
|
||||
write_json_atomic,
|
||||
path,
|
||||
data,
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
try:
|
||||
await loop.run_in_executor(None, partial_write)
|
||||
except Exception:
|
||||
# Restore full state from captured dict on write failure
|
||||
# This reverts all fields modified by _update_timestamps_and_status()
|
||||
restored = self.from_dict(old_state)
|
||||
# Copy restored fields back to self (dataclass __init__ returns new instance)
|
||||
for field in fields(self):
|
||||
setattr(self, field.name, getattr(restored, field.name))
|
||||
raise
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(self.to_dict(), f, indent=2, ensure_ascii=False)
|
||||
|
||||
def update_status_from_subtasks(self):
|
||||
"""Update overall status and planStatus based on subtask completion state.
|
||||
|
||||
+15
-114
@@ -6,32 +6,6 @@ Handles first-time setup of .auto-claude directory and ensures proper gitignore
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# All entries that should be added to .gitignore for auto-claude projects
|
||||
AUTO_CLAUDE_GITIGNORE_ENTRIES = [
|
||||
".auto-claude/",
|
||||
".auto-claude-security.json",
|
||||
".auto-claude-status",
|
||||
".claude_settings.json",
|
||||
".worktrees/",
|
||||
".security-key",
|
||||
"logs/security/",
|
||||
]
|
||||
|
||||
|
||||
def _entry_exists_in_gitignore(lines: list[str], entry: str) -> bool:
|
||||
"""Check if an entry already exists in gitignore (handles trailing slash variations)."""
|
||||
entry_normalized = entry.rstrip("/")
|
||||
for line in lines:
|
||||
line_stripped = line.strip()
|
||||
# Match both "entry" and "entry/"
|
||||
if (
|
||||
line_stripped == entry
|
||||
or line_stripped == entry_normalized
|
||||
or line_stripped == entry_normalized + "/"
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def ensure_gitignore_entry(project_dir: Path, entry: str = ".auto-claude/") -> bool:
|
||||
"""
|
||||
@@ -53,8 +27,17 @@ def ensure_gitignore_entry(project_dir: Path, entry: str = ".auto-claude/") -> b
|
||||
content = gitignore_path.read_text()
|
||||
lines = content.splitlines()
|
||||
|
||||
if _entry_exists_in_gitignore(lines, entry):
|
||||
return False # Already exists
|
||||
# Check if entry already exists (exact match or with trailing newline variations)
|
||||
entry_normalized = entry.rstrip("/")
|
||||
for line in lines:
|
||||
line_stripped = line.strip()
|
||||
# Match both ".auto-claude" and ".auto-claude/"
|
||||
if (
|
||||
line_stripped == entry
|
||||
or line_stripped == entry_normalized
|
||||
or line_stripped == entry_normalized + "/"
|
||||
):
|
||||
return False # Already exists
|
||||
|
||||
# Entry doesn't exist, append it
|
||||
# Ensure file ends with newline before adding our entry
|
||||
@@ -76,58 +59,11 @@ def ensure_gitignore_entry(project_dir: Path, entry: str = ".auto-claude/") -> b
|
||||
return True
|
||||
|
||||
|
||||
def ensure_all_gitignore_entries(project_dir: Path) -> list[str]:
|
||||
"""
|
||||
Ensure all auto-claude related entries exist in the project's .gitignore file.
|
||||
|
||||
Creates .gitignore if it doesn't exist.
|
||||
|
||||
Args:
|
||||
project_dir: The project root directory
|
||||
|
||||
Returns:
|
||||
List of entries that were added (empty if all already existed)
|
||||
"""
|
||||
gitignore_path = project_dir / ".gitignore"
|
||||
added_entries: list[str] = []
|
||||
|
||||
# Read existing content or start fresh
|
||||
if gitignore_path.exists():
|
||||
content = gitignore_path.read_text()
|
||||
lines = content.splitlines()
|
||||
else:
|
||||
content = ""
|
||||
lines = []
|
||||
|
||||
# Find entries that need to be added
|
||||
entries_to_add = [
|
||||
entry
|
||||
for entry in AUTO_CLAUDE_GITIGNORE_ENTRIES
|
||||
if not _entry_exists_in_gitignore(lines, entry)
|
||||
]
|
||||
|
||||
if not entries_to_add:
|
||||
return []
|
||||
|
||||
# Build the new content to append
|
||||
# Ensure file ends with newline before adding our entries
|
||||
if content and not content.endswith("\n"):
|
||||
content += "\n"
|
||||
|
||||
content += "\n# Auto Claude generated files\n"
|
||||
for entry in entries_to_add:
|
||||
content += entry + "\n"
|
||||
added_entries.append(entry)
|
||||
|
||||
gitignore_path.write_text(content)
|
||||
return added_entries
|
||||
|
||||
|
||||
def init_auto_claude_dir(project_dir: Path) -> tuple[Path, bool]:
|
||||
"""
|
||||
Initialize the .auto-claude directory for a project.
|
||||
|
||||
Creates the directory if needed and ensures all auto-claude files are in .gitignore.
|
||||
Creates the directory if needed and ensures it's in .gitignore.
|
||||
|
||||
Args:
|
||||
project_dir: The project root directory
|
||||
@@ -142,18 +78,16 @@ def init_auto_claude_dir(project_dir: Path) -> tuple[Path, bool]:
|
||||
dir_created = not auto_claude_dir.exists()
|
||||
auto_claude_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Ensure all auto-claude entries are in .gitignore (only on first creation)
|
||||
# Ensure .auto-claude is in .gitignore (only on first creation)
|
||||
gitignore_updated = False
|
||||
if dir_created:
|
||||
added = ensure_all_gitignore_entries(project_dir)
|
||||
gitignore_updated = len(added) > 0
|
||||
gitignore_updated = ensure_gitignore_entry(project_dir, ".auto-claude/")
|
||||
else:
|
||||
# Even if dir exists, check gitignore on first run
|
||||
# Use a marker file to track if we've already checked
|
||||
marker = auto_claude_dir / ".gitignore_checked"
|
||||
if not marker.exists():
|
||||
added = ensure_all_gitignore_entries(project_dir)
|
||||
gitignore_updated = len(added) > 0
|
||||
gitignore_updated = ensure_gitignore_entry(project_dir, ".auto-claude/")
|
||||
marker.touch()
|
||||
|
||||
return auto_claude_dir, gitignore_updated
|
||||
@@ -175,36 +109,3 @@ def get_auto_claude_dir(project_dir: Path, ensure_exists: bool = True) -> Path:
|
||||
return auto_claude_dir
|
||||
|
||||
return Path(project_dir) / ".auto-claude"
|
||||
|
||||
|
||||
def repair_gitignore(project_dir: Path) -> list[str]:
|
||||
"""
|
||||
Repair an existing project's .gitignore to include all auto-claude entries.
|
||||
|
||||
This is useful for projects created before all entries were being added,
|
||||
or when gitignore entries were manually removed.
|
||||
|
||||
Also resets the .gitignore_checked marker to allow future updates.
|
||||
|
||||
Args:
|
||||
project_dir: The project root directory
|
||||
|
||||
Returns:
|
||||
List of entries that were added (empty if all already existed)
|
||||
"""
|
||||
project_dir = Path(project_dir)
|
||||
auto_claude_dir = project_dir / ".auto-claude"
|
||||
|
||||
# Remove the marker file so future checks will also run
|
||||
marker = auto_claude_dir / ".gitignore_checked"
|
||||
if marker.exists():
|
||||
marker.unlink()
|
||||
|
||||
# Add all missing entries
|
||||
added = ensure_all_gitignore_entries(project_dir)
|
||||
|
||||
# Re-create the marker
|
||||
if auto_claude_dir.exists():
|
||||
marker.touch()
|
||||
|
||||
return added
|
||||
|
||||
@@ -622,23 +622,10 @@ def get_graphiti_status() -> dict:
|
||||
status["errors"] = errors
|
||||
# Errors are informational - embedder is optional (keyword search fallback)
|
||||
|
||||
# CRITICAL FIX: Actually verify packages are importable before reporting available
|
||||
# Don't just check config.is_valid() - actually try to import the module
|
||||
if not config.is_valid():
|
||||
# Available if is_valid() returns True (just needs enabled flag)
|
||||
status["available"] = config.is_valid()
|
||||
if not status["available"]:
|
||||
status["reason"] = errors[0] if errors else "Configuration invalid"
|
||||
return status
|
||||
|
||||
# Try importing the required Graphiti packages
|
||||
try:
|
||||
# Attempt to import the main graphiti_memory module
|
||||
import graphiti_core # noqa: F401
|
||||
from graphiti_core.driver.falkordb_driver import FalkorDriver # noqa: F401
|
||||
|
||||
# If we got here, packages are importable
|
||||
status["available"] = True
|
||||
except ImportError as e:
|
||||
status["available"] = False
|
||||
status["reason"] = f"Graphiti packages not installed: {e}"
|
||||
|
||||
return status
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ from .queries_pkg.schema import (
|
||||
def get_graphiti_memory(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
group_id_mode: str = GroupIdMode.PROJECT,
|
||||
group_id_mode: str = GroupIdMode.SPEC,
|
||||
) -> GraphitiMemory:
|
||||
"""
|
||||
Get a GraphitiMemory instance for the given spec.
|
||||
@@ -60,14 +60,10 @@ def get_graphiti_memory(
|
||||
Args:
|
||||
spec_dir: Spec directory
|
||||
project_dir: Project root directory
|
||||
group_id_mode: "spec" for isolated memory, "project" for shared (default)
|
||||
group_id_mode: "spec" for isolated memory, "project" for shared
|
||||
|
||||
Returns:
|
||||
GraphitiMemory instance
|
||||
|
||||
Note:
|
||||
Default changed from SPEC to PROJECT to enable cross-spec learning across
|
||||
the entire project. Use GroupIdMode.SPEC explicitly for isolated per-spec memory.
|
||||
"""
|
||||
return GraphitiMemory(spec_dir, project_dir, group_id_mode)
|
||||
|
||||
|
||||
@@ -34,25 +34,8 @@ def _apply_ladybug_monkeypatch() -> bool:
|
||||
sys.modules["kuzu"] = real_ladybug
|
||||
logger.info("Applied LadybugDB monkeypatch (kuzu -> real_ladybug)")
|
||||
return True
|
||||
except ImportError as e:
|
||||
logger.debug(f"LadybugDB import failed: {e}")
|
||||
# On Windows with Python 3.12+, provide more specific error details
|
||||
# (pywin32 is only required for Python 3.12+ per requirements.txt)
|
||||
if sys.platform == "win32" and sys.version_info >= (3, 12):
|
||||
# Check if it's the pywin32 error using both name attribute and string match
|
||||
# for robustness across Python versions
|
||||
is_pywin32_error = (
|
||||
(hasattr(e, "name") and e.name in ("pywintypes", "pywin32", "win32api"))
|
||||
or "pywintypes" in str(e)
|
||||
or "pywin32" in str(e)
|
||||
)
|
||||
if is_pywin32_error:
|
||||
logger.error(
|
||||
"LadybugDB requires pywin32 on Windows. "
|
||||
"Install with: pip install pywin32>=306"
|
||||
)
|
||||
else:
|
||||
logger.debug(f"Windows-specific import issue: {e}")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Fall back to native kuzu
|
||||
try:
|
||||
|
||||
@@ -343,34 +343,6 @@ class GraphitiMemory:
|
||||
|
||||
return await self._search.get_similar_task_outcomes(task_description, limit)
|
||||
|
||||
async def get_patterns_and_gotchas(
|
||||
self,
|
||||
query: str,
|
||||
num_results: int = 5,
|
||||
min_score: float = 0.5,
|
||||
) -> tuple[list[dict], list[dict]]:
|
||||
"""
|
||||
Get patterns and gotchas relevant to the query.
|
||||
|
||||
This method specifically retrieves PATTERN and GOTCHA episode types
|
||||
to enable cross-session learning. Unlike get_relevant_context(),
|
||||
it filters for these specific types rather than doing generic search.
|
||||
|
||||
Args:
|
||||
query: Search query (task description)
|
||||
num_results: Max results per type
|
||||
min_score: Minimum relevance score (0.0-1.0)
|
||||
|
||||
Returns:
|
||||
Tuple of (patterns, gotchas) lists
|
||||
"""
|
||||
if not await self._ensure_initialized():
|
||||
return [], []
|
||||
|
||||
return await self._search.get_patterns_and_gotchas(
|
||||
query, num_results, min_score
|
||||
)
|
||||
|
||||
# Status and utility methods
|
||||
|
||||
def get_status_summary(self) -> dict:
|
||||
|
||||
@@ -10,8 +10,6 @@ import logging
|
||||
from pathlib import Path
|
||||
|
||||
from .schema import (
|
||||
EPISODE_TYPE_GOTCHA,
|
||||
EPISODE_TYPE_PATTERN,
|
||||
EPISODE_TYPE_SESSION_INSIGHT,
|
||||
EPISODE_TYPE_TASK_OUTCOME,
|
||||
MAX_CONTEXT_RESULTS,
|
||||
@@ -57,7 +55,6 @@ class GraphitiSearch:
|
||||
query: str,
|
||||
num_results: int = MAX_CONTEXT_RESULTS,
|
||||
include_project_context: bool = True,
|
||||
min_score: float = 0.0,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Search for relevant context based on a query.
|
||||
@@ -107,12 +104,6 @@ class GraphitiSearch:
|
||||
}
|
||||
)
|
||||
|
||||
# Filter by minimum score if specified
|
||||
if min_score > 0:
|
||||
context_items = [
|
||||
item for item in context_items if item.get("score", 0) >= min_score
|
||||
]
|
||||
|
||||
logger.info(
|
||||
f"Found {len(context_items)} relevant context items for: {query[:50]}..."
|
||||
)
|
||||
@@ -154,9 +145,6 @@ class GraphitiSearch:
|
||||
data = (
|
||||
json.loads(content) if isinstance(content, str) else content
|
||||
)
|
||||
# Ensure data is a dict before processing (fixes ACS-215)
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
if data.get("type") == EPISODE_TYPE_SESSION_INSIGHT:
|
||||
# Filter by spec if requested
|
||||
if (
|
||||
@@ -165,7 +153,7 @@ class GraphitiSearch:
|
||||
):
|
||||
continue
|
||||
sessions.append(data)
|
||||
except (json.JSONDecodeError, TypeError, AttributeError):
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
|
||||
# Sort by session number and return latest
|
||||
@@ -208,9 +196,6 @@ class GraphitiSearch:
|
||||
data = (
|
||||
json.loads(content) if isinstance(content, str) else content
|
||||
)
|
||||
# Ensure data is a dict before processing (fixes ACS-215)
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
if data.get("type") == EPISODE_TYPE_TASK_OUTCOME:
|
||||
outcomes.append(
|
||||
{
|
||||
@@ -220,7 +205,7 @@ class GraphitiSearch:
|
||||
"score": getattr(result, "score", 0.0),
|
||||
}
|
||||
)
|
||||
except (json.JSONDecodeError, TypeError, AttributeError):
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
|
||||
return outcomes[:limit]
|
||||
@@ -228,113 +213,3 @@ class GraphitiSearch:
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get similar task outcomes: {e}")
|
||||
return []
|
||||
|
||||
async def get_patterns_and_gotchas(
|
||||
self,
|
||||
query: str,
|
||||
num_results: int = 5,
|
||||
min_score: float = 0.5,
|
||||
) -> tuple[list[dict], list[dict]]:
|
||||
"""
|
||||
Retrieve patterns and gotchas relevant to the current task.
|
||||
|
||||
Unlike get_relevant_context(), this specifically filters for
|
||||
EPISODE_TYPE_PATTERN and EPISODE_TYPE_GOTCHA episodes to enable
|
||||
cross-session learning.
|
||||
|
||||
Args:
|
||||
query: Search query (task description)
|
||||
num_results: Max results per type
|
||||
min_score: Minimum relevance score (0.0-1.0)
|
||||
|
||||
Returns:
|
||||
Tuple of (patterns, gotchas) lists
|
||||
"""
|
||||
patterns = []
|
||||
gotchas = []
|
||||
|
||||
try:
|
||||
# Search with query focused on patterns
|
||||
pattern_results = await self.client.graphiti.search(
|
||||
query=f"pattern: {query}",
|
||||
group_ids=[self.group_id],
|
||||
num_results=num_results * 2,
|
||||
)
|
||||
|
||||
for result in pattern_results:
|
||||
content = getattr(result, "content", None) or getattr(
|
||||
result, "fact", None
|
||||
)
|
||||
score = getattr(result, "score", 0.0)
|
||||
|
||||
if score < min_score:
|
||||
continue
|
||||
|
||||
if content and EPISODE_TYPE_PATTERN in str(content):
|
||||
try:
|
||||
data = (
|
||||
json.loads(content) if isinstance(content, str) else content
|
||||
)
|
||||
# Ensure data is a dict before processing (fixes ACS-215)
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
if data.get("type") == EPISODE_TYPE_PATTERN:
|
||||
patterns.append(
|
||||
{
|
||||
"pattern": data.get("pattern", ""),
|
||||
"applies_to": data.get("applies_to", ""),
|
||||
"example": data.get("example", ""),
|
||||
"score": score,
|
||||
}
|
||||
)
|
||||
except (json.JSONDecodeError, TypeError, AttributeError):
|
||||
continue
|
||||
|
||||
# Search with query focused on gotchas
|
||||
gotcha_results = await self.client.graphiti.search(
|
||||
query=f"gotcha pitfall avoid: {query}",
|
||||
group_ids=[self.group_id],
|
||||
num_results=num_results * 2,
|
||||
)
|
||||
|
||||
for result in gotcha_results:
|
||||
content = getattr(result, "content", None) or getattr(
|
||||
result, "fact", None
|
||||
)
|
||||
score = getattr(result, "score", 0.0)
|
||||
|
||||
if score < min_score:
|
||||
continue
|
||||
|
||||
if content and EPISODE_TYPE_GOTCHA in str(content):
|
||||
try:
|
||||
data = (
|
||||
json.loads(content) if isinstance(content, str) else content
|
||||
)
|
||||
# Ensure data is a dict before processing (fixes ACS-215)
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
if data.get("type") == EPISODE_TYPE_GOTCHA:
|
||||
gotchas.append(
|
||||
{
|
||||
"gotcha": data.get("gotcha", ""),
|
||||
"trigger": data.get("trigger", ""),
|
||||
"solution": data.get("solution", ""),
|
||||
"score": score,
|
||||
}
|
||||
)
|
||||
except (json.JSONDecodeError, TypeError, AttributeError):
|
||||
continue
|
||||
|
||||
# Sort by score and limit
|
||||
patterns.sort(key=lambda x: x.get("score", 0), reverse=True)
|
||||
gotchas.sort(key=lambda x: x.get("score", 0), reverse=True)
|
||||
|
||||
logger.info(
|
||||
f"Found {len(patterns)} patterns and {len(gotchas)} gotchas for: {query[:50]}..."
|
||||
)
|
||||
return patterns[:num_results], gotchas[:num_results]
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get patterns/gotchas: {e}")
|
||||
return [], []
|
||||
|
||||
@@ -118,7 +118,6 @@ def _create_linear_client() -> ClaudeSDKClient:
|
||||
get_sdk_env_vars,
|
||||
require_auth_token,
|
||||
)
|
||||
from phase_config import resolve_model_id
|
||||
|
||||
require_auth_token() # Raises ValueError if no token found
|
||||
ensure_claude_code_oauth_token()
|
||||
@@ -131,7 +130,7 @@ def _create_linear_client() -> ClaudeSDKClient:
|
||||
|
||||
return ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model=resolve_model_id("haiku"), # Resolves via API Profile if configured
|
||||
model="claude-haiku-4-5", # Fast & cheap model for simple API calls
|
||||
system_prompt="You are a Linear API assistant. Execute the requested Linear operation precisely.",
|
||||
allowed_tools=LINEAR_TOOLS,
|
||||
mcp_servers={
|
||||
|
||||
@@ -10,13 +10,10 @@ Handles checking if Graphiti is available and managing async operations.
|
||||
import asyncio
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from graphiti_memory import GraphitiMemory
|
||||
|
||||
|
||||
def is_graphiti_memory_enabled() -> bool:
|
||||
"""
|
||||
@@ -37,9 +34,7 @@ def is_graphiti_memory_enabled() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def get_graphiti_memory(
|
||||
spec_dir: Path, project_dir: Path | None = None
|
||||
) -> "GraphitiMemory | None":
|
||||
def get_graphiti_memory(spec_dir: Path, project_dir: Path | None = None):
|
||||
"""
|
||||
Get a GraphitiMemory instance if available.
|
||||
|
||||
@@ -54,12 +49,11 @@ def get_graphiti_memory(
|
||||
return None
|
||||
|
||||
try:
|
||||
from graphiti_memory import GraphitiMemory, GroupIdMode
|
||||
from graphiti_memory import GraphitiMemory
|
||||
|
||||
if project_dir is None:
|
||||
project_dir = spec_dir.parent.parent
|
||||
# Use project-wide shared memory for cross-spec learning
|
||||
return GraphitiMemory(spec_dir, project_dir, group_id_mode=GroupIdMode.PROJECT)
|
||||
return GraphitiMemory(spec_dir, project_dir)
|
||||
except ImportError:
|
||||
return None
|
||||
|
||||
@@ -106,7 +100,7 @@ async def save_to_graphiti_async(
|
||||
True if save succeeded, False otherwise
|
||||
"""
|
||||
graphiti = get_graphiti_memory(spec_dir, project_dir)
|
||||
if graphiti is None:
|
||||
if not graphiti:
|
||||
return False
|
||||
|
||||
try:
|
||||
@@ -126,17 +120,13 @@ async def save_to_graphiti_async(
|
||||
for gotcha in discoveries.get("gotchas_encountered", []):
|
||||
await graphiti.save_gotcha(gotcha)
|
||||
|
||||
await graphiti.close()
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save to Graphiti: {e}")
|
||||
try:
|
||||
await graphiti.close()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
finally:
|
||||
# Always close the graphiti connection (swallow exceptions to avoid overriding)
|
||||
if graphiti is not None:
|
||||
try:
|
||||
await graphiti.close()
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Failed to close Graphiti memory connection", exc_info=True
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@ conflict resolution, enabling multiple AI agents to work in parallel without
|
||||
traditional merge conflicts.
|
||||
|
||||
Components:
|
||||
- SemanticAnalyzer: Regex-based semantic change extraction
|
||||
- SemanticAnalyzer: Tree-sitter based semantic change extraction
|
||||
- ConflictDetector: Rule-based conflict detection and compatibility analysis
|
||||
- AutoMerger: Deterministic merge strategies (no AI needed)
|
||||
- AIResolver: Minimal-context AI resolution for ambiguous conflicts
|
||||
|
||||
@@ -82,9 +82,7 @@ def create_claude_resolver() -> AIResolver:
|
||||
msg_type = type(msg).__name__
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
# Must check block type - only TextBlock has .text attribute
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
if hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
|
||||
logger.info(f"AI merge response: {len(response_text)} chars")
|
||||
|
||||
@@ -102,7 +102,7 @@ class EvolutionQueries:
|
||||
modifications = []
|
||||
for file_path, evolution in evolutions.items():
|
||||
snapshot = evolution.get_task_snapshot(task_id)
|
||||
if snapshot and snapshot.has_modifications:
|
||||
if snapshot and snapshot.semantic_changes:
|
||||
modifications.append((file_path, snapshot))
|
||||
return modifications
|
||||
|
||||
@@ -125,7 +125,7 @@ class EvolutionQueries:
|
||||
|
||||
for file_path, evolution in evolutions.items():
|
||||
for snapshot in evolution.task_snapshots:
|
||||
if snapshot.task_id in task_ids and snapshot.has_modifications:
|
||||
if snapshot.task_id in task_ids and snapshot.semantic_changes:
|
||||
if file_path not in file_tasks:
|
||||
file_tasks[file_path] = []
|
||||
file_tasks[file_path].append(snapshot.task_id)
|
||||
|
||||
@@ -68,7 +68,6 @@ class ModificationTracker:
|
||||
new_content: str,
|
||||
evolutions: dict[str, FileEvolution],
|
||||
raw_diff: str | None = None,
|
||||
skip_semantic_analysis: bool = False,
|
||||
) -> TaskSnapshot | None:
|
||||
"""
|
||||
Record a file modification by a task.
|
||||
@@ -80,9 +79,6 @@ class ModificationTracker:
|
||||
new_content: File content after modification
|
||||
evolutions: Current evolution data (will be updated)
|
||||
raw_diff: Optional unified diff for reference
|
||||
skip_semantic_analysis: If True, skip expensive semantic analysis.
|
||||
Use this for lightweight file tracking when only conflict
|
||||
detection is needed (not conflict resolution).
|
||||
|
||||
Returns:
|
||||
Updated TaskSnapshot, or None if file not being tracked
|
||||
@@ -91,8 +87,8 @@ class ModificationTracker:
|
||||
|
||||
# Get or create evolution
|
||||
if rel_path not in evolutions:
|
||||
# Debug level: this is expected for files not in baseline (e.g., from main's changes)
|
||||
logger.debug(f"File {rel_path} not in evolution tracking - skipping")
|
||||
logger.warning(f"File {rel_path} not being tracked")
|
||||
# Note: We could auto-create here, but for now return None
|
||||
return None
|
||||
|
||||
evolution = evolutions.get(rel_path)
|
||||
@@ -109,19 +105,9 @@ class ModificationTracker:
|
||||
content_hash_before=compute_content_hash(old_content),
|
||||
)
|
||||
|
||||
# Analyze semantic changes (or skip for lightweight tracking)
|
||||
if skip_semantic_analysis:
|
||||
# Fast path: just track the file change without analysis
|
||||
# This is used for files that don't have conflicts
|
||||
semantic_changes = []
|
||||
debug(
|
||||
MODULE,
|
||||
f"Skipping semantic analysis for {rel_path} (lightweight tracking)",
|
||||
)
|
||||
else:
|
||||
# Full analysis (only for conflict files)
|
||||
analysis = self.analyzer.analyze_diff(rel_path, old_content, new_content)
|
||||
semantic_changes = analysis.changes
|
||||
# Analyze semantic changes
|
||||
analysis = self.analyzer.analyze_diff(rel_path, old_content, new_content)
|
||||
semantic_changes = analysis.changes
|
||||
|
||||
# Update snapshot
|
||||
snapshot.completed_at = datetime.now()
|
||||
@@ -135,7 +121,6 @@ class ModificationTracker:
|
||||
logger.info(
|
||||
f"Recorded modification to {rel_path} by {task_id}: "
|
||||
f"{len(semantic_changes)} semantic changes"
|
||||
+ (" (lightweight)" if skip_semantic_analysis else "")
|
||||
)
|
||||
return snapshot
|
||||
|
||||
@@ -145,7 +130,6 @@ class ModificationTracker:
|
||||
worktree_path: Path,
|
||||
evolutions: dict[str, FileEvolution],
|
||||
target_branch: str | None = None,
|
||||
analyze_only_files: set[str] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Refresh task snapshots by analyzing git diff from worktree.
|
||||
@@ -158,10 +142,6 @@ class ModificationTracker:
|
||||
worktree_path: Path to the task's worktree
|
||||
evolutions: Current evolution data (will be updated)
|
||||
target_branch: Branch to compare against (default: detect from worktree)
|
||||
analyze_only_files: If provided, only run full semantic analysis on
|
||||
these files. Other files will be tracked with lightweight mode
|
||||
(no semantic analysis). This optimizes performance by only
|
||||
analyzing files that have actual conflicts.
|
||||
"""
|
||||
# Determine the target branch to compare against
|
||||
if not target_branch:
|
||||
@@ -174,27 +154,12 @@ class ModificationTracker:
|
||||
task_id=task_id,
|
||||
worktree_path=str(worktree_path),
|
||||
target_branch=target_branch,
|
||||
analyze_only_files=list(analyze_only_files)[:10]
|
||||
if analyze_only_files
|
||||
else "all",
|
||||
)
|
||||
|
||||
try:
|
||||
# Get the merge-base to accurately identify task-only changes
|
||||
# Using two-dot diff (merge-base..HEAD) returns only files changed by the task,
|
||||
# not files changed on the target branch since divergence
|
||||
merge_base_result = subprocess.run(
|
||||
["git", "merge-base", target_branch, "HEAD"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
merge_base = merge_base_result.stdout.strip()
|
||||
|
||||
# Get list of files changed in the worktree since the merge-base
|
||||
# Get list of files changed in the worktree vs target branch
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", f"{merge_base}..HEAD"],
|
||||
["git", "diff", "--name-only", f"{target_branch}...HEAD"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -210,104 +175,56 @@ class ModificationTracker:
|
||||
else changed_files,
|
||||
)
|
||||
|
||||
processed_count = 0
|
||||
for file_path in changed_files:
|
||||
# Get the diff for this file
|
||||
diff_result = subprocess.run(
|
||||
["git", "diff", f"{target_branch}...HEAD", "--", file_path],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Get content before (from target branch) and after (current)
|
||||
try:
|
||||
# Get the diff for this file (using merge-base for accurate task-only diff)
|
||||
diff_result = subprocess.run(
|
||||
["git", "diff", f"{merge_base}..HEAD", "--", file_path],
|
||||
show_result = subprocess.run(
|
||||
["git", "show", f"{target_branch}:{file_path}"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
old_content = show_result.stdout
|
||||
except subprocess.CalledProcessError:
|
||||
# File is new
|
||||
old_content = ""
|
||||
|
||||
# Get content before (from merge-base - the point where task branched)
|
||||
current_file = worktree_path / file_path
|
||||
if current_file.exists():
|
||||
try:
|
||||
show_result = subprocess.run(
|
||||
["git", "show", f"{merge_base}:{file_path}"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
new_content = current_file.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
new_content = current_file.read_text(
|
||||
encoding="utf-8", errors="replace"
|
||||
)
|
||||
old_content = show_result.stdout
|
||||
except subprocess.CalledProcessError:
|
||||
# File is new
|
||||
old_content = ""
|
||||
else:
|
||||
# File was deleted
|
||||
new_content = ""
|
||||
|
||||
current_file = worktree_path / file_path
|
||||
if current_file.exists():
|
||||
try:
|
||||
new_content = current_file.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
new_content = current_file.read_text(
|
||||
encoding="utf-8", errors="replace"
|
||||
)
|
||||
else:
|
||||
# File was deleted
|
||||
new_content = ""
|
||||
|
||||
# Auto-create FileEvolution entry if not already tracked
|
||||
# This handles retroactive tracking when capture_baselines wasn't called
|
||||
rel_path = self.storage.get_relative_path(file_path)
|
||||
if rel_path not in evolutions:
|
||||
evolutions[rel_path] = FileEvolution(
|
||||
file_path=rel_path,
|
||||
baseline_commit=merge_base,
|
||||
baseline_captured_at=datetime.now(),
|
||||
baseline_content_hash=compute_content_hash(old_content),
|
||||
baseline_snapshot_path="", # Not storing baseline file
|
||||
task_snapshots=[],
|
||||
)
|
||||
debug(
|
||||
MODULE,
|
||||
f"Auto-created evolution entry for {rel_path}",
|
||||
baseline_commit=merge_base[:8],
|
||||
)
|
||||
|
||||
# Determine if this file needs full semantic analysis
|
||||
# If analyze_only_files is provided, only analyze files in that set
|
||||
# Otherwise, analyze all files (backward compatible)
|
||||
skip_analysis = False
|
||||
if analyze_only_files is not None:
|
||||
skip_analysis = rel_path not in analyze_only_files
|
||||
|
||||
# Record the modification
|
||||
self.record_modification(
|
||||
task_id=task_id,
|
||||
file_path=file_path,
|
||||
old_content=old_content,
|
||||
new_content=new_content,
|
||||
evolutions=evolutions,
|
||||
raw_diff=diff_result.stdout,
|
||||
skip_semantic_analysis=skip_analysis,
|
||||
)
|
||||
processed_count += 1
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Log error but continue with remaining files
|
||||
logger.warning(
|
||||
f"Failed to process {file_path} in refresh_from_git: {e}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Calculate how many files were fully analyzed vs just tracked
|
||||
if analyze_only_files is not None:
|
||||
analyzed_count = len(
|
||||
[f for f in changed_files if f in analyze_only_files]
|
||||
)
|
||||
tracked_only_count = processed_count - analyzed_count
|
||||
logger.info(
|
||||
f"Refreshed {processed_count}/{len(changed_files)} files from worktree for task {task_id} "
|
||||
f"(analyzed: {analyzed_count}, tracked only: {tracked_only_count})"
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"Refreshed {processed_count}/{len(changed_files)} files from worktree for task {task_id} "
|
||||
"(full analysis on all files)"
|
||||
# Record the modification
|
||||
self.record_modification(
|
||||
task_id=task_id,
|
||||
file_path=file_path,
|
||||
old_content=old_content,
|
||||
new_content=new_content,
|
||||
evolutions=evolutions,
|
||||
raw_diff=diff_result.stdout,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Refreshed {len(changed_files)} files from worktree for task {task_id}"
|
||||
)
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
logger.error(f"Failed to refresh from git: {e}")
|
||||
|
||||
@@ -331,23 +248,35 @@ class ModificationTracker:
|
||||
|
||||
def _detect_target_branch(self, worktree_path: Path) -> str:
|
||||
"""
|
||||
Detect the base branch to compare against for a worktree.
|
||||
Detect the target branch to compare against for a worktree.
|
||||
|
||||
This finds the branch that the worktree was created FROM by looking
|
||||
for common branch names (main, master, develop) that have a valid
|
||||
merge-base with the worktree.
|
||||
|
||||
Note: We don't use upstream tracking because that returns the worktree's
|
||||
own branch (e.g., origin/auto-claude/...) rather than the base branch.
|
||||
This finds the branch that the worktree was created from by looking
|
||||
at the merge-base between the worktree and common branch names.
|
||||
|
||||
Args:
|
||||
worktree_path: Path to the worktree
|
||||
|
||||
Returns:
|
||||
The detected base branch name, defaults to 'main' if detection fails
|
||||
The detected target branch name, defaults to 'main' if detection fails
|
||||
"""
|
||||
# Try to get the upstream tracking branch
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
upstream = result.stdout.strip()
|
||||
# Extract branch name from origin/branch format
|
||||
if "/" in upstream:
|
||||
return upstream.split("/", 1)[1]
|
||||
return upstream
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
|
||||
# Try common branch names and find which one has a valid merge-base
|
||||
# This is the reliable way to find what branch the worktree diverged from
|
||||
for branch in ["main", "master", "develop"]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
@@ -357,39 +286,14 @@ class ModificationTracker:
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
debug(
|
||||
MODULE,
|
||||
f"Detected base branch: {branch}",
|
||||
worktree_path=str(worktree_path),
|
||||
)
|
||||
return branch
|
||||
except subprocess.CalledProcessError:
|
||||
continue
|
||||
|
||||
# Before defaulting to 'main', verify it exists
|
||||
# This handles non-standard projects that use trunk, production, etc.
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--verify", "main"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
"Could not find merge-base with standard branches, defaulting to 'main'",
|
||||
worktree_path=str(worktree_path),
|
||||
)
|
||||
return "main"
|
||||
except subprocess.CalledProcessError:
|
||||
pass # 'main' branch doesn't exist - fall through to last resort
|
||||
|
||||
# Last resort: use HEAD~10 as a fallback comparison point
|
||||
# This allows modification tracking even on non-standard branch setups
|
||||
# Default to main
|
||||
debug_warning(
|
||||
MODULE,
|
||||
"No standard base branch found, modification tracking may be limited",
|
||||
"Could not detect target branch, defaulting to 'main'",
|
||||
worktree_path=str(worktree_path),
|
||||
)
|
||||
return "HEAD~10"
|
||||
return "main"
|
||||
|
||||
@@ -327,7 +327,6 @@ class FileEvolutionTracker:
|
||||
task_id: str,
|
||||
worktree_path: Path,
|
||||
target_branch: str | None = None,
|
||||
analyze_only_files: set[str] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Refresh task snapshots by analyzing git diff from worktree.
|
||||
@@ -339,16 +338,11 @@ class FileEvolutionTracker:
|
||||
task_id: The task identifier
|
||||
worktree_path: Path to the task's worktree
|
||||
target_branch: Branch to compare against (default: auto-detect)
|
||||
analyze_only_files: If provided, only run full semantic analysis on
|
||||
these files. Other files will be tracked with lightweight mode
|
||||
(no semantic analysis). This optimizes performance by only
|
||||
analyzing files that have actual conflicts.
|
||||
"""
|
||||
self.modification_tracker.refresh_from_git(
|
||||
task_id=task_id,
|
||||
worktree_path=worktree_path,
|
||||
evolutions=self._evolutions,
|
||||
target_branch=target_branch,
|
||||
analyze_only_files=analyze_only_files,
|
||||
)
|
||||
self._save_evolutions()
|
||||
|
||||
@@ -19,35 +19,6 @@ from pathlib import Path
|
||||
from .types import ChangeType, SemanticChange, TaskSnapshot
|
||||
|
||||
|
||||
def detect_line_ending(content: str) -> str:
|
||||
"""
|
||||
Detect line ending style in content using priority-based detection.
|
||||
|
||||
Uses a priority order (CRLF > CR > LF) to detect the line ending style.
|
||||
CRLF is checked first because it contains LF, so presence of any CRLF
|
||||
indicates Windows-style endings. This approach is fast and works well
|
||||
for files that consistently use one style.
|
||||
|
||||
Note: This returns the first detected style by priority, not the most
|
||||
frequent style. For files with mixed line endings, consider normalizing
|
||||
to a single style before processing.
|
||||
|
||||
Args:
|
||||
content: File content to analyze
|
||||
|
||||
Returns:
|
||||
The detected line ending string: "\\r\\n", "\\r", or "\\n"
|
||||
"""
|
||||
# Check for CRLF first (Windows) - must check before LF since CRLF contains LF
|
||||
if "\r\n" in content:
|
||||
return "\r\n"
|
||||
# Check for CR (classic Mac, rare but possible)
|
||||
if "\r" in content:
|
||||
return "\r"
|
||||
# Default to LF (Unix/modern Mac)
|
||||
return "\n"
|
||||
|
||||
|
||||
def apply_single_task_changes(
|
||||
baseline: str,
|
||||
snapshot: TaskSnapshot,
|
||||
@@ -64,16 +35,7 @@ def apply_single_task_changes(
|
||||
Returns:
|
||||
Modified content with changes applied
|
||||
"""
|
||||
# Detect line ending style before normalizing
|
||||
original_line_ending = detect_line_ending(baseline)
|
||||
|
||||
# Normalize to LF for consistent matching with regex_analyzer output
|
||||
# The regex_analyzer normalizes content to LF when extracting content_before/after,
|
||||
# so we must also normalize baseline to ensure replace() matches correctly
|
||||
content = baseline.replace("\r\n", "\n").replace("\r", "\n")
|
||||
|
||||
# Use LF for internal processing
|
||||
line_ending = "\n"
|
||||
content = baseline
|
||||
|
||||
for change in snapshot.semantic_changes:
|
||||
if change.content_before and change.content_after:
|
||||
@@ -83,19 +45,13 @@ def apply_single_task_changes(
|
||||
# Addition - need to determine where to add
|
||||
if change.change_type == ChangeType.ADD_IMPORT:
|
||||
# Add import at top
|
||||
lines = content.splitlines()
|
||||
lines = content.split("\n")
|
||||
import_end = find_import_end(lines, file_path)
|
||||
lines.insert(import_end, change.content_after)
|
||||
content = line_ending.join(lines)
|
||||
content = "\n".join(lines)
|
||||
elif change.change_type == ChangeType.ADD_FUNCTION:
|
||||
# Add function at end (before exports)
|
||||
content += f"{line_ending}{line_ending}{change.content_after}"
|
||||
|
||||
# Restore original line ending style if it was CRLF
|
||||
if original_line_ending == "\r\n":
|
||||
content = content.replace("\n", "\r\n")
|
||||
elif original_line_ending == "\r":
|
||||
content = content.replace("\n", "\r")
|
||||
content += f"\n\n{change.content_after}"
|
||||
|
||||
return content
|
||||
|
||||
@@ -116,16 +72,7 @@ def combine_non_conflicting_changes(
|
||||
Returns:
|
||||
Combined content with all changes applied
|
||||
"""
|
||||
# Detect line ending style before normalizing
|
||||
original_line_ending = detect_line_ending(baseline)
|
||||
|
||||
# Normalize to LF for consistent matching with regex_analyzer output
|
||||
# The regex_analyzer normalizes content to LF when extracting content_before/after,
|
||||
# so we must also normalize baseline to ensure replace() matches correctly
|
||||
content = baseline.replace("\r\n", "\n").replace("\r", "\n")
|
||||
|
||||
# Use LF for internal processing
|
||||
line_ending = "\n"
|
||||
content = baseline
|
||||
|
||||
# Group changes by type for proper ordering
|
||||
imports: list[SemanticChange] = []
|
||||
@@ -149,13 +96,13 @@ def combine_non_conflicting_changes(
|
||||
|
||||
# Add imports
|
||||
if imports:
|
||||
lines = content.splitlines()
|
||||
lines = content.split("\n")
|
||||
import_end = find_import_end(lines, file_path)
|
||||
for imp in imports:
|
||||
if imp.content_after and imp.content_after not in content:
|
||||
lines.insert(import_end, imp.content_after)
|
||||
import_end += 1
|
||||
content = line_ending.join(lines)
|
||||
content = "\n".join(lines)
|
||||
|
||||
# Apply modifications
|
||||
for mod in modifications:
|
||||
@@ -165,21 +112,15 @@ def combine_non_conflicting_changes(
|
||||
# Add functions
|
||||
for func in functions:
|
||||
if func.content_after:
|
||||
content += f"{line_ending}{line_ending}{func.content_after}"
|
||||
content += f"\n\n{func.content_after}"
|
||||
|
||||
# Apply other changes
|
||||
for change in other:
|
||||
if change.content_after and not change.content_before:
|
||||
content += f"{line_ending}{change.content_after}"
|
||||
content += f"\n{change.content_after}"
|
||||
elif change.content_before and change.content_after:
|
||||
content = content.replace(change.content_before, change.content_after)
|
||||
|
||||
# Restore original line ending style if it was CRLF
|
||||
if original_line_ending == "\r\n":
|
||||
content = content.replace("\n", "\r\n")
|
||||
elif original_line_ending == "\r":
|
||||
content = content.replace("\n", "\r")
|
||||
|
||||
return content
|
||||
|
||||
|
||||
|
||||
@@ -27,19 +27,28 @@ def find_worktree(project_dir: Path, task_id: str) -> Path | None:
|
||||
Returns:
|
||||
Path to the worktree, or None if not found
|
||||
"""
|
||||
# Check new path first
|
||||
new_worktrees_dir = project_dir / ".auto-claude" / "worktrees" / "tasks"
|
||||
if new_worktrees_dir.exists():
|
||||
for entry in new_worktrees_dir.iterdir():
|
||||
# Check common locations
|
||||
worktrees_dir = project_dir / ".worktrees"
|
||||
if worktrees_dir.exists():
|
||||
# Look for worktree with task_id in name
|
||||
for entry in worktrees_dir.iterdir():
|
||||
if entry.is_dir() and task_id in entry.name:
|
||||
return entry
|
||||
|
||||
# Legacy fallback for backwards compatibility
|
||||
legacy_worktrees_dir = project_dir / ".worktrees"
|
||||
if legacy_worktrees_dir.exists():
|
||||
for entry in legacy_worktrees_dir.iterdir():
|
||||
if entry.is_dir() and task_id in entry.name:
|
||||
return entry
|
||||
# Try git worktree list
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "worktree", "list", "--porcelain"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
for line in result.stdout.split("\n"):
|
||||
if line.startswith("worktree ") and task_id in line:
|
||||
return Path(line.split(" ", 1)[1])
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -75,18 +75,6 @@ class MergePipeline:
|
||||
# If only one task modified the file, no conflict possible
|
||||
if len(task_snapshots) == 1:
|
||||
snapshot = task_snapshots[0]
|
||||
|
||||
# Check if file has modifications but semantic analysis returned empty
|
||||
# This happens for: function body changes, unsupported file types (Rust, Go, etc.)
|
||||
# In this case, signal that the caller should use the worktree version directly
|
||||
if snapshot.has_modifications and not snapshot.semantic_changes:
|
||||
return MergeResult(
|
||||
decision=MergeDecision.DIRECT_COPY,
|
||||
file_path=file_path,
|
||||
merged_content=None, # Caller must read from worktree
|
||||
explanation=f"File modified by {snapshot.task_id} but no semantic changes detected - use worktree version",
|
||||
)
|
||||
|
||||
merged = apply_single_task_changes(baseline_content, snapshot, file_path)
|
||||
return MergeResult(
|
||||
decision=MergeDecision.AUTO_MERGED,
|
||||
|
||||
@@ -203,58 +203,6 @@ class MergeOrchestrator:
|
||||
)
|
||||
return self._merge_pipeline
|
||||
|
||||
def _read_worktree_file_for_direct_copy(
|
||||
self,
|
||||
file_path: str,
|
||||
worktree_path: Path | None,
|
||||
) -> tuple[str | None, bool]:
|
||||
"""
|
||||
Read file content from worktree for DIRECT_COPY merge.
|
||||
|
||||
Args:
|
||||
file_path: Relative path to the file
|
||||
worktree_path: Path to the worktree directory
|
||||
|
||||
Returns:
|
||||
Tuple of (content, success). If success is False, content is None
|
||||
and the caller should mark the merge as FAILED.
|
||||
"""
|
||||
if not worktree_path:
|
||||
logger.warning(
|
||||
f"DIRECT_COPY: No worktree path provided for file: {file_path}"
|
||||
)
|
||||
debug_warning(
|
||||
MODULE,
|
||||
"DIRECT_COPY: No worktree path provided",
|
||||
file=file_path,
|
||||
)
|
||||
return None, False
|
||||
|
||||
worktree_file = worktree_path / file_path
|
||||
if not worktree_file.exists():
|
||||
logger.warning(f"DIRECT_COPY: Worktree file not found: {worktree_file}")
|
||||
debug_warning(
|
||||
MODULE,
|
||||
"DIRECT_COPY: Worktree file not found",
|
||||
file=str(worktree_file),
|
||||
)
|
||||
return None, False
|
||||
|
||||
try:
|
||||
content = worktree_file.read_text(encoding="utf-8")
|
||||
debug_detailed(
|
||||
MODULE,
|
||||
f"Read file from worktree for direct copy: {file_path}",
|
||||
)
|
||||
return content, True
|
||||
except UnicodeDecodeError:
|
||||
content = worktree_file.read_text(encoding="utf-8", errors="replace")
|
||||
debug_detailed(
|
||||
MODULE,
|
||||
f"Read file from worktree with encoding fallback: {file_path}",
|
||||
)
|
||||
return content, True
|
||||
|
||||
def merge_task(
|
||||
self,
|
||||
task_id: str,
|
||||
@@ -327,20 +275,6 @@ class MergeOrchestrator:
|
||||
task_snapshots=[snapshot],
|
||||
target_branch=target_branch,
|
||||
)
|
||||
|
||||
# Handle DIRECT_COPY: read file directly from worktree
|
||||
# This happens when file has modifications but semantic analysis
|
||||
# couldn't parse the changes (body modifications, unsupported languages)
|
||||
if result.decision == MergeDecision.DIRECT_COPY:
|
||||
content, success = self._read_worktree_file_for_direct_copy(
|
||||
file_path, worktree_path
|
||||
)
|
||||
if success:
|
||||
result.merged_content = content
|
||||
else:
|
||||
result.decision = MergeDecision.FAILED
|
||||
result.error = "Worktree file not found for DIRECT_COPY"
|
||||
|
||||
report.file_results[file_path] = result
|
||||
self._update_stats(report.stats, result)
|
||||
debug_verbose(
|
||||
@@ -440,41 +374,12 @@ class MergeOrchestrator:
|
||||
task_snapshots=snapshots,
|
||||
target_branch=target_branch,
|
||||
)
|
||||
|
||||
# Handle DIRECT_COPY: read file directly from worktree
|
||||
# For multi-task merges, use the first task's worktree that modified this file
|
||||
if result.decision == MergeDecision.DIRECT_COPY:
|
||||
# Find the worktree path from the first task that modified this file
|
||||
worktree_path = None
|
||||
for tid in modifying_tasks:
|
||||
for req in requests:
|
||||
if req.task_id == tid and req.worktree_path:
|
||||
worktree_path = req.worktree_path
|
||||
break
|
||||
if worktree_path:
|
||||
break
|
||||
|
||||
content, success = self._read_worktree_file_for_direct_copy(
|
||||
file_path, worktree_path
|
||||
)
|
||||
if success:
|
||||
result.merged_content = content
|
||||
else:
|
||||
result.decision = MergeDecision.FAILED
|
||||
result.error = "Worktree file not found for DIRECT_COPY"
|
||||
|
||||
report.file_results[file_path] = result
|
||||
self._update_stats(report.stats, result)
|
||||
|
||||
report.success = report.stats.files_failed == 0
|
||||
|
||||
except Exception as e:
|
||||
debug_error(
|
||||
MODULE,
|
||||
"Merge failed for tasks",
|
||||
task_ids=[r.task_id for r in requests],
|
||||
error=str(e),
|
||||
)
|
||||
logger.exception("Merge failed")
|
||||
report.success = False
|
||||
report.error = str(e)
|
||||
@@ -684,7 +589,7 @@ class MergeOrchestrator:
|
||||
|
||||
written = []
|
||||
for file_path, result in report.file_results.items():
|
||||
if result.merged_content is not None:
|
||||
if result.merged_content:
|
||||
out_path = output_dir / file_path
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(result.merged_content, encoding="utf-8")
|
||||
@@ -735,7 +640,7 @@ class MergeOrchestrator:
|
||||
)
|
||||
stats.conflicts_auto_resolved += len(result.conflicts_resolved)
|
||||
|
||||
if result.decision in (MergeDecision.AUTO_MERGED, MergeDecision.DIRECT_COPY):
|
||||
if result.decision == MergeDecision.AUTO_MERGED:
|
||||
stats.files_auto_merged += 1
|
||||
elif result.decision == MergeDecision.AI_MERGED:
|
||||
stats.files_ai_merged += 1
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""
|
||||
Semantic analyzer package for code analysis.
|
||||
Semantic analyzer package for AST-based code analysis.
|
||||
|
||||
This package provides modular semantic analysis capabilities:
|
||||
- models.py: Data structures for extracted elements
|
||||
- python_analyzer.py: Python-specific AST extraction
|
||||
- js_analyzer.py: JavaScript/TypeScript-specific AST extraction
|
||||
- comparison.py: Element comparison and change classification
|
||||
- regex_analyzer.py: Regex-based analysis for code changes
|
||||
- regex_analyzer.py: Fallback regex-based analysis
|
||||
"""
|
||||
|
||||
from .models import ExtractedElement
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
JavaScript/TypeScript-specific semantic analysis using tree-sitter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from .models import ExtractedElement
|
||||
|
||||
try:
|
||||
from tree_sitter import Node
|
||||
except ImportError:
|
||||
Node = None
|
||||
|
||||
|
||||
def extract_js_elements(
|
||||
node: Node,
|
||||
elements: dict[str, ExtractedElement],
|
||||
get_text: Callable[[Node], str],
|
||||
get_line: Callable[[int], int],
|
||||
ext: str,
|
||||
parent: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Extract structural elements from JavaScript/TypeScript AST.
|
||||
|
||||
Args:
|
||||
node: The tree-sitter node to extract from
|
||||
elements: Dictionary to populate with extracted elements
|
||||
get_text: Function to extract text from a node
|
||||
get_line: Function to convert byte position to line number
|
||||
ext: File extension (.js, .jsx, .ts, .tsx)
|
||||
parent: Parent element name for nested elements
|
||||
"""
|
||||
for child in node.children:
|
||||
if child.type == "import_statement":
|
||||
text = get_text(child)
|
||||
# Try to extract the source module
|
||||
source_node = child.child_by_field_name("source")
|
||||
if source_node:
|
||||
source = get_text(source_node).strip("'\"")
|
||||
elements[f"import:{source}"] = ExtractedElement(
|
||||
element_type="import",
|
||||
name=source,
|
||||
start_line=get_line(child.start_byte),
|
||||
end_line=get_line(child.end_byte),
|
||||
content=text,
|
||||
)
|
||||
|
||||
elif child.type in {"function_declaration", "function"}:
|
||||
name_node = child.child_by_field_name("name")
|
||||
if name_node:
|
||||
name = get_text(name_node)
|
||||
full_name = f"{parent}.{name}" if parent else name
|
||||
elements[f"function:{full_name}"] = ExtractedElement(
|
||||
element_type="function",
|
||||
name=full_name,
|
||||
start_line=get_line(child.start_byte),
|
||||
end_line=get_line(child.end_byte),
|
||||
content=get_text(child),
|
||||
parent=parent,
|
||||
)
|
||||
|
||||
elif child.type == "arrow_function":
|
||||
# Arrow functions are usually assigned to variables
|
||||
# We'll catch these via variable declarations
|
||||
pass
|
||||
|
||||
elif child.type in {"lexical_declaration", "variable_declaration"}:
|
||||
# const/let/var declarations
|
||||
for declarator in child.children:
|
||||
if declarator.type == "variable_declarator":
|
||||
name_node = declarator.child_by_field_name("name")
|
||||
value_node = declarator.child_by_field_name("value")
|
||||
if name_node:
|
||||
name = get_text(name_node)
|
||||
content = get_text(child)
|
||||
|
||||
# Check if it's a function (arrow function or function expression)
|
||||
is_function = False
|
||||
if value_node and value_node.type in {
|
||||
"arrow_function",
|
||||
"function",
|
||||
}:
|
||||
is_function = True
|
||||
elements[f"function:{name}"] = ExtractedElement(
|
||||
element_type="function",
|
||||
name=name,
|
||||
start_line=get_line(child.start_byte),
|
||||
end_line=get_line(child.end_byte),
|
||||
content=content,
|
||||
parent=parent,
|
||||
)
|
||||
else:
|
||||
elements[f"variable:{name}"] = ExtractedElement(
|
||||
element_type="variable",
|
||||
name=name,
|
||||
start_line=get_line(child.start_byte),
|
||||
end_line=get_line(child.end_byte),
|
||||
content=content,
|
||||
parent=parent,
|
||||
)
|
||||
|
||||
elif child.type == "class_declaration":
|
||||
name_node = child.child_by_field_name("name")
|
||||
if name_node:
|
||||
name = get_text(name_node)
|
||||
elements[f"class:{name}"] = ExtractedElement(
|
||||
element_type="class",
|
||||
name=name,
|
||||
start_line=get_line(child.start_byte),
|
||||
end_line=get_line(child.end_byte),
|
||||
content=get_text(child),
|
||||
)
|
||||
# Recurse into class body
|
||||
body = child.child_by_field_name("body")
|
||||
if body:
|
||||
extract_js_elements(
|
||||
body, elements, get_text, get_line, ext, parent=name
|
||||
)
|
||||
|
||||
elif child.type == "method_definition":
|
||||
name_node = child.child_by_field_name("name")
|
||||
if name_node:
|
||||
name = get_text(name_node)
|
||||
full_name = f"{parent}.{name}" if parent else name
|
||||
elements[f"method:{full_name}"] = ExtractedElement(
|
||||
element_type="method",
|
||||
name=full_name,
|
||||
start_line=get_line(child.start_byte),
|
||||
end_line=get_line(child.end_byte),
|
||||
content=get_text(child),
|
||||
parent=parent,
|
||||
)
|
||||
|
||||
elif child.type == "export_statement":
|
||||
# Recurse into exports to find the actual declaration
|
||||
extract_js_elements(child, elements, get_text, get_line, ext, parent)
|
||||
|
||||
# TypeScript specific
|
||||
elif child.type in {"interface_declaration", "type_alias_declaration"}:
|
||||
name_node = child.child_by_field_name("name")
|
||||
if name_node:
|
||||
name = get_text(name_node)
|
||||
elem_type = "interface" if "interface" in child.type else "type"
|
||||
elements[f"{elem_type}:{name}"] = ExtractedElement(
|
||||
element_type=elem_type,
|
||||
name=name,
|
||||
start_line=get_line(child.start_byte),
|
||||
end_line=get_line(child.end_byte),
|
||||
content=get_text(child),
|
||||
)
|
||||
|
||||
# Recurse into statement blocks
|
||||
elif child.type in {"program", "statement_block", "class_body"}:
|
||||
extract_js_elements(child, elements, get_text, get_line, ext, parent)
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Python-specific semantic analysis using tree-sitter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
|
||||
from .models import ExtractedElement
|
||||
|
||||
try:
|
||||
from tree_sitter import Node
|
||||
except ImportError:
|
||||
Node = None
|
||||
|
||||
|
||||
def extract_python_elements(
|
||||
node: Node,
|
||||
elements: dict[str, ExtractedElement],
|
||||
get_text: Callable[[Node], str],
|
||||
get_line: Callable[[int], int],
|
||||
parent: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Extract structural elements from Python AST.
|
||||
|
||||
Args:
|
||||
node: The tree-sitter node to extract from
|
||||
elements: Dictionary to populate with extracted elements
|
||||
get_text: Function to extract text from a node
|
||||
get_line: Function to convert byte position to line number
|
||||
parent: Parent element name for nested elements
|
||||
"""
|
||||
for child in node.children:
|
||||
if child.type == "import_statement":
|
||||
# import x, y
|
||||
text = get_text(child)
|
||||
# Extract module names
|
||||
for name_node in child.children:
|
||||
if name_node.type == "dotted_name":
|
||||
name = get_text(name_node)
|
||||
elements[f"import:{name}"] = ExtractedElement(
|
||||
element_type="import",
|
||||
name=name,
|
||||
start_line=get_line(child.start_byte),
|
||||
end_line=get_line(child.end_byte),
|
||||
content=text,
|
||||
)
|
||||
|
||||
elif child.type == "import_from_statement":
|
||||
# from x import y, z
|
||||
text = get_text(child)
|
||||
module = None
|
||||
for sub in child.children:
|
||||
if sub.type == "dotted_name":
|
||||
module = get_text(sub)
|
||||
break
|
||||
if module:
|
||||
elements[f"import_from:{module}"] = ExtractedElement(
|
||||
element_type="import_from",
|
||||
name=module,
|
||||
start_line=get_line(child.start_byte),
|
||||
end_line=get_line(child.end_byte),
|
||||
content=text,
|
||||
)
|
||||
|
||||
elif child.type == "function_definition":
|
||||
name_node = child.child_by_field_name("name")
|
||||
if name_node:
|
||||
name = get_text(name_node)
|
||||
full_name = f"{parent}.{name}" if parent else name
|
||||
elements[f"function:{full_name}"] = ExtractedElement(
|
||||
element_type="function",
|
||||
name=full_name,
|
||||
start_line=get_line(child.start_byte),
|
||||
end_line=get_line(child.end_byte),
|
||||
content=get_text(child),
|
||||
parent=parent,
|
||||
)
|
||||
|
||||
elif child.type == "class_definition":
|
||||
name_node = child.child_by_field_name("name")
|
||||
if name_node:
|
||||
name = get_text(name_node)
|
||||
elements[f"class:{name}"] = ExtractedElement(
|
||||
element_type="class",
|
||||
name=name,
|
||||
start_line=get_line(child.start_byte),
|
||||
end_line=get_line(child.end_byte),
|
||||
content=get_text(child),
|
||||
)
|
||||
# Recurse into class body for methods
|
||||
body = child.child_by_field_name("body")
|
||||
if body:
|
||||
extract_python_elements(
|
||||
body, elements, get_text, get_line, parent=name
|
||||
)
|
||||
|
||||
elif child.type == "decorated_definition":
|
||||
# Handle decorated functions/classes
|
||||
for sub in child.children:
|
||||
if sub.type in {"function_definition", "class_definition"}:
|
||||
extract_python_elements(child, elements, get_text, get_line, parent)
|
||||
break
|
||||
|
||||
# Recurse for other compound statements
|
||||
elif child.type in {
|
||||
"if_statement",
|
||||
"while_statement",
|
||||
"for_statement",
|
||||
"try_statement",
|
||||
"with_statement",
|
||||
}:
|
||||
extract_python_elements(child, elements, get_text, get_line, parent)
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Regex-based semantic analysis for code changes.
|
||||
Regex-based fallback analysis when tree-sitter is not available.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,7 +17,7 @@ def analyze_with_regex(
|
||||
ext: str,
|
||||
) -> FileAnalysis:
|
||||
"""
|
||||
Analyze code changes using regex patterns.
|
||||
Fallback analysis using regex when tree-sitter isn't available.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file being analyzed
|
||||
@@ -30,16 +30,11 @@ def analyze_with_regex(
|
||||
"""
|
||||
changes: list[SemanticChange] = []
|
||||
|
||||
# Normalize line endings to LF for consistent cross-platform behavior
|
||||
# This handles Windows CRLF, old Mac CR, and Unix LF
|
||||
before_normalized = before.replace("\r\n", "\n").replace("\r", "\n")
|
||||
after_normalized = after.replace("\r\n", "\n").replace("\r", "\n")
|
||||
|
||||
# Get a unified diff
|
||||
diff = list(
|
||||
difflib.unified_diff(
|
||||
before_normalized.splitlines(keepends=True),
|
||||
after_normalized.splitlines(keepends=True),
|
||||
before.splitlines(keepends=True),
|
||||
after.splitlines(keepends=True),
|
||||
lineterm="",
|
||||
)
|
||||
)
|
||||
@@ -94,22 +89,8 @@ def analyze_with_regex(
|
||||
# Detect function changes (simplified)
|
||||
func_pattern = get_function_pattern(ext)
|
||||
if func_pattern:
|
||||
# For JS/TS patterns with alternation, findall() returns tuples
|
||||
# Extract the non-empty match from each tuple
|
||||
def extract_func_names(matches):
|
||||
names = set()
|
||||
for match in matches:
|
||||
if isinstance(match, tuple):
|
||||
# Get the first non-empty group from the tuple
|
||||
name = next((m for m in match if m), None)
|
||||
if name:
|
||||
names.add(name)
|
||||
elif match:
|
||||
names.add(match)
|
||||
return names
|
||||
|
||||
funcs_before = extract_func_names(func_pattern.findall(before_normalized))
|
||||
funcs_after = extract_func_names(func_pattern.findall(after_normalized))
|
||||
funcs_before = set(func_pattern.findall(before))
|
||||
funcs_after = set(func_pattern.findall(after))
|
||||
|
||||
for func in funcs_after - funcs_before:
|
||||
changes.append(
|
||||
|
||||
@@ -2,27 +2,32 @@
|
||||
Semantic Analyzer
|
||||
=================
|
||||
|
||||
Analyzes code changes at a semantic level using regex-based heuristics.
|
||||
Analyzes code changes at a semantic level using tree-sitter.
|
||||
|
||||
This module provides analysis of code changes, extracting meaningful
|
||||
semantic changes like "added import", "modified function", "wrapped JSX element"
|
||||
rather than line-level diffs.
|
||||
This module provides AST-based analysis of code changes, extracting
|
||||
meaningful semantic changes like "added import", "modified function",
|
||||
"wrapped JSX element" rather than line-level diffs.
|
||||
|
||||
When tree-sitter is not available, falls back to regex-based heuristics.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .types import FileAnalysis
|
||||
from .types import ChangeType, FileAnalysis
|
||||
|
||||
# Import debug utilities
|
||||
try:
|
||||
from debug import (
|
||||
debug,
|
||||
debug_detailed,
|
||||
debug_error,
|
||||
debug_success,
|
||||
debug_verbose,
|
||||
is_debug_enabled,
|
||||
)
|
||||
except ImportError:
|
||||
# Fallback if debug module not available
|
||||
@@ -38,18 +43,71 @@ except ImportError:
|
||||
def debug_success(*args, **kwargs):
|
||||
pass
|
||||
|
||||
def debug_error(*args, **kwargs):
|
||||
pass
|
||||
|
||||
def is_debug_enabled():
|
||||
return False
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
MODULE = "merge.semantic_analyzer"
|
||||
|
||||
# Import regex-based analyzer
|
||||
# Try to import tree-sitter - it's optional but recommended
|
||||
TREE_SITTER_AVAILABLE = False
|
||||
try:
|
||||
import tree_sitter # noqa: F401
|
||||
from tree_sitter import Language, Node, Parser, Tree
|
||||
|
||||
TREE_SITTER_AVAILABLE = True
|
||||
logger.info("tree-sitter available, using AST-based analysis")
|
||||
except ImportError:
|
||||
logger.warning("tree-sitter not available, using regex-based fallback")
|
||||
Tree = None
|
||||
Node = None
|
||||
|
||||
# Try to import language bindings
|
||||
LANGUAGES_AVAILABLE: dict[str, Any] = {}
|
||||
if TREE_SITTER_AVAILABLE:
|
||||
try:
|
||||
import tree_sitter_python as tspython
|
||||
|
||||
LANGUAGES_AVAILABLE[".py"] = tspython.language()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import tree_sitter_javascript as tsjs
|
||||
|
||||
LANGUAGES_AVAILABLE[".js"] = tsjs.language()
|
||||
LANGUAGES_AVAILABLE[".jsx"] = tsjs.language()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import tree_sitter_typescript as tsts
|
||||
|
||||
LANGUAGES_AVAILABLE[".ts"] = tsts.language_typescript()
|
||||
LANGUAGES_AVAILABLE[".tsx"] = tsts.language_tsx()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Import our modular components
|
||||
from .semantic_analysis.comparison import compare_elements
|
||||
from .semantic_analysis.models import ExtractedElement
|
||||
from .semantic_analysis.regex_analyzer import analyze_with_regex
|
||||
|
||||
if TREE_SITTER_AVAILABLE:
|
||||
from .semantic_analysis.js_analyzer import extract_js_elements
|
||||
from .semantic_analysis.python_analyzer import extract_python_elements
|
||||
|
||||
|
||||
class SemanticAnalyzer:
|
||||
"""
|
||||
Analyzes code changes at a semantic level using regex-based heuristics.
|
||||
Analyzes code changes at a semantic level.
|
||||
|
||||
Uses tree-sitter for AST-based analysis when available,
|
||||
falling back to regex-based heuristics when not.
|
||||
|
||||
Example:
|
||||
analyzer = SemanticAnalyzer()
|
||||
@@ -59,8 +117,28 @@ class SemanticAnalyzer:
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the analyzer."""
|
||||
debug(MODULE, "Initializing SemanticAnalyzer (regex-based)")
|
||||
"""Initialize the analyzer with available parsers."""
|
||||
self._parsers: dict[str, Parser] = {}
|
||||
|
||||
debug(
|
||||
MODULE,
|
||||
"Initializing SemanticAnalyzer",
|
||||
tree_sitter_available=TREE_SITTER_AVAILABLE,
|
||||
)
|
||||
|
||||
if TREE_SITTER_AVAILABLE:
|
||||
for ext, lang in LANGUAGES_AVAILABLE.items():
|
||||
parser = Parser()
|
||||
parser.language = Language(lang)
|
||||
self._parsers[ext] = parser
|
||||
debug_detailed(MODULE, f"Initialized parser for {ext}")
|
||||
debug_success(
|
||||
MODULE,
|
||||
"SemanticAnalyzer initialized",
|
||||
parsers=list(self._parsers.keys()),
|
||||
)
|
||||
else:
|
||||
debug(MODULE, "Using regex-based fallback (tree-sitter not available)")
|
||||
|
||||
def analyze_diff(
|
||||
self,
|
||||
@@ -93,8 +171,13 @@ class SemanticAnalyzer:
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
# Use regex-based analysis
|
||||
analysis = analyze_with_regex(file_path, before, after, ext)
|
||||
# Use tree-sitter if available for this language
|
||||
if ext in self._parsers:
|
||||
debug_detailed(MODULE, f"Using tree-sitter parser for {ext}")
|
||||
analysis = self._analyze_with_tree_sitter(file_path, before, after, ext)
|
||||
else:
|
||||
debug_detailed(MODULE, f"Using regex fallback for {ext}")
|
||||
analysis = analyze_with_regex(file_path, before, after, ext)
|
||||
|
||||
debug_success(
|
||||
MODULE,
|
||||
@@ -118,6 +201,77 @@ class SemanticAnalyzer:
|
||||
|
||||
return analysis
|
||||
|
||||
def _analyze_with_tree_sitter(
|
||||
self,
|
||||
file_path: str,
|
||||
before: str,
|
||||
after: str,
|
||||
ext: str,
|
||||
) -> FileAnalysis:
|
||||
"""Analyze using tree-sitter AST parsing."""
|
||||
parser = self._parsers[ext]
|
||||
|
||||
tree_before = parser.parse(bytes(before, "utf-8"))
|
||||
tree_after = parser.parse(bytes(after, "utf-8"))
|
||||
|
||||
# Extract structural elements from both versions
|
||||
elements_before = self._extract_elements(tree_before, before, ext)
|
||||
elements_after = self._extract_elements(tree_after, after, ext)
|
||||
|
||||
# Compare and generate semantic changes
|
||||
changes = compare_elements(elements_before, elements_after, ext)
|
||||
|
||||
# Build the analysis
|
||||
analysis = FileAnalysis(file_path=file_path, changes=changes)
|
||||
|
||||
# Populate summary fields
|
||||
for change in changes:
|
||||
if change.change_type in {
|
||||
ChangeType.MODIFY_FUNCTION,
|
||||
ChangeType.ADD_HOOK_CALL,
|
||||
}:
|
||||
analysis.functions_modified.add(change.target)
|
||||
elif change.change_type == ChangeType.ADD_FUNCTION:
|
||||
analysis.functions_added.add(change.target)
|
||||
elif change.change_type == ChangeType.ADD_IMPORT:
|
||||
analysis.imports_added.add(change.target)
|
||||
elif change.change_type == ChangeType.REMOVE_IMPORT:
|
||||
analysis.imports_removed.add(change.target)
|
||||
elif change.change_type in {
|
||||
ChangeType.MODIFY_CLASS,
|
||||
ChangeType.ADD_METHOD,
|
||||
}:
|
||||
analysis.classes_modified.add(change.target.split(".")[0])
|
||||
|
||||
analysis.total_lines_changed += change.line_end - change.line_start + 1
|
||||
|
||||
return analysis
|
||||
|
||||
def _extract_elements(
|
||||
self,
|
||||
tree: Tree,
|
||||
source: str,
|
||||
ext: str,
|
||||
) -> dict[str, ExtractedElement]:
|
||||
"""Extract structural elements from a syntax tree."""
|
||||
elements: dict[str, ExtractedElement] = {}
|
||||
source_bytes = bytes(source, "utf-8")
|
||||
|
||||
def get_text(node: Node) -> str:
|
||||
return source_bytes[node.start_byte : node.end_byte].decode("utf-8")
|
||||
|
||||
def get_line(byte_pos: int) -> int:
|
||||
# Convert byte position to line number (1-indexed)
|
||||
return source[:byte_pos].count("\n") + 1
|
||||
|
||||
# Language-specific extraction
|
||||
if ext == ".py":
|
||||
extract_python_elements(tree.root_node, elements, get_text, get_line)
|
||||
elif ext in {".js", ".jsx", ".ts", ".tsx"}:
|
||||
extract_js_elements(tree.root_node, elements, get_text, get_line, ext)
|
||||
|
||||
return elements
|
||||
|
||||
def analyze_file(self, file_path: str, content: str) -> FileAnalysis:
|
||||
"""
|
||||
Analyze a single file's structure (not a diff).
|
||||
@@ -137,7 +291,12 @@ class SemanticAnalyzer:
|
||||
@property
|
||||
def supported_extensions(self) -> set[str]:
|
||||
"""Get the set of supported file extensions."""
|
||||
return {".py", ".js", ".jsx", ".ts", ".tsx"}
|
||||
if TREE_SITTER_AVAILABLE:
|
||||
# Tree-sitter extensions plus regex fallbacks
|
||||
return set(self._parsers.keys()) | {".py", ".js", ".jsx", ".ts", ".tsx"}
|
||||
else:
|
||||
# Only regex-supported extensions
|
||||
return {".py", ".js", ".jsx", ".ts", ".tsx"}
|
||||
|
||||
def is_supported(self, file_path: str) -> bool:
|
||||
"""Check if a file type is supported for semantic analysis."""
|
||||
|
||||
@@ -189,14 +189,7 @@ class TimelineGitHelper:
|
||||
task_id.replace("task-", "") if task_id.startswith("task-") else task_id
|
||||
)
|
||||
|
||||
worktree_path = (
|
||||
self.project_path
|
||||
/ ".auto-claude"
|
||||
/ "worktrees"
|
||||
/ "tasks"
|
||||
/ spec_name
|
||||
/ file_path
|
||||
)
|
||||
worktree_path = self.project_path / ".worktrees" / spec_name / file_path
|
||||
if worktree_path.exists():
|
||||
try:
|
||||
return worktree_path.read_text(encoding="utf-8")
|
||||
|
||||
@@ -133,7 +133,6 @@ class MergeDecision(Enum):
|
||||
AI_MERGED = "ai_merged" # AI resolved the conflict
|
||||
NEEDS_HUMAN_REVIEW = "needs_human_review" # Flagged for human
|
||||
FAILED = "failed" # Could not merge
|
||||
DIRECT_COPY = "direct_copy" # Use worktree version directly (no semantic merge)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -415,34 +414,6 @@ class TaskSnapshot:
|
||||
raw_diff=data.get("raw_diff"),
|
||||
)
|
||||
|
||||
@property
|
||||
def has_modifications(self) -> bool:
|
||||
"""
|
||||
Check if this snapshot represents actual file modifications.
|
||||
|
||||
Returns True if the file was modified, using content hash comparison
|
||||
as the source of truth. This handles cases where the semantic analyzer
|
||||
couldn't detect changes (e.g., function body modifications, unsupported
|
||||
file types like Rust) but the file was actually changed.
|
||||
|
||||
Also returns True for newly created files (where content_hash_before
|
||||
is empty but content_hash_after is set).
|
||||
"""
|
||||
# If we have semantic changes, the file was definitely modified
|
||||
if self.semantic_changes:
|
||||
return True
|
||||
|
||||
# Handle new files: if before is empty but after has content, it's a new file
|
||||
if not self.content_hash_before and self.content_hash_after:
|
||||
return True
|
||||
|
||||
# Fall back to content hash comparison for files where semantic
|
||||
# analysis returned empty (body modifications, unsupported languages)
|
||||
if self.content_hash_before and self.content_hash_after:
|
||||
return self.content_hash_before != self.content_hash_after
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileEvolution:
|
||||
@@ -563,11 +534,7 @@ class MergeResult:
|
||||
@property
|
||||
def success(self) -> bool:
|
||||
"""Check if merge was successful."""
|
||||
return self.decision in {
|
||||
MergeDecision.AUTO_MERGED,
|
||||
MergeDecision.AI_MERGED,
|
||||
MergeDecision.DIRECT_COPY,
|
||||
}
|
||||
return self.decision in {MergeDecision.AUTO_MERGED, MergeDecision.AI_MERGED}
|
||||
|
||||
@property
|
||||
def needs_human_review(self) -> bool:
|
||||
|
||||
@@ -16,7 +16,6 @@ Output:
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
@@ -24,10 +23,6 @@ from typing import Any
|
||||
|
||||
DEFAULT_OLLAMA_URL = "http://localhost:11434"
|
||||
|
||||
# Minimum Ollama version required for newer embedding models (qwen3-embedding, etc.)
|
||||
# These models were added in Ollama 0.10.0
|
||||
MIN_OLLAMA_VERSION_FOR_NEW_MODELS = "0.10.0"
|
||||
|
||||
# Known embedding models and their dimensions
|
||||
# This list helps identify embedding models from the model name
|
||||
KNOWN_EMBEDDING_MODELS = {
|
||||
@@ -36,26 +31,10 @@ KNOWN_EMBEDDING_MODELS = {
|
||||
"dim": 768,
|
||||
"description": "Google EmbeddingGemma (lightweight)",
|
||||
},
|
||||
"qwen3-embedding": {
|
||||
"dim": 1024,
|
||||
"description": "Qwen3 Embedding (0.6B)",
|
||||
"min_version": "0.10.0",
|
||||
},
|
||||
"qwen3-embedding:0.6b": {
|
||||
"dim": 1024,
|
||||
"description": "Qwen3 Embedding 0.6B",
|
||||
"min_version": "0.10.0",
|
||||
},
|
||||
"qwen3-embedding:4b": {
|
||||
"dim": 2560,
|
||||
"description": "Qwen3 Embedding 4B",
|
||||
"min_version": "0.10.0",
|
||||
},
|
||||
"qwen3-embedding:8b": {
|
||||
"dim": 4096,
|
||||
"description": "Qwen3 Embedding 8B",
|
||||
"min_version": "0.10.0",
|
||||
},
|
||||
"qwen3-embedding": {"dim": 1024, "description": "Qwen3 Embedding (0.6B)"},
|
||||
"qwen3-embedding:0.6b": {"dim": 1024, "description": "Qwen3 Embedding 0.6B"},
|
||||
"qwen3-embedding:4b": {"dim": 2560, "description": "Qwen3 Embedding 4B"},
|
||||
"qwen3-embedding:8b": {"dim": 4096, "description": "Qwen3 Embedding 8B"},
|
||||
"bge-base-en": {"dim": 768, "description": "BAAI General Embedding - Base"},
|
||||
"bge-large-en": {"dim": 1024, "description": "BAAI General Embedding - Large"},
|
||||
"bge-small-en": {"dim": 384, "description": "BAAI General Embedding - Small"},
|
||||
@@ -84,7 +63,6 @@ RECOMMENDED_EMBEDDING_MODELS = [
|
||||
"size_estimate": "3.1 GB",
|
||||
"dim": 2560,
|
||||
"badge": "recommended",
|
||||
"min_ollama_version": "0.10.0",
|
||||
},
|
||||
{
|
||||
"name": "qwen3-embedding:8b",
|
||||
@@ -92,7 +70,6 @@ RECOMMENDED_EMBEDDING_MODELS = [
|
||||
"size_estimate": "6.0 GB",
|
||||
"dim": 4096,
|
||||
"badge": "quality",
|
||||
"min_ollama_version": "0.10.0",
|
||||
},
|
||||
{
|
||||
"name": "qwen3-embedding:0.6b",
|
||||
@@ -100,7 +77,6 @@ RECOMMENDED_EMBEDDING_MODELS = [
|
||||
"size_estimate": "494 MB",
|
||||
"dim": 1024,
|
||||
"badge": "fast",
|
||||
"min_ollama_version": "0.10.0",
|
||||
},
|
||||
{
|
||||
"name": "embeddinggemma",
|
||||
@@ -136,22 +112,6 @@ EMBEDDING_PATTERNS = [
|
||||
]
|
||||
|
||||
|
||||
def parse_version(version_str: str | None) -> tuple[int, ...]:
|
||||
"""Parse a version string like '0.10.0' into a tuple for comparison."""
|
||||
if not version_str or not isinstance(version_str, str):
|
||||
return (0, 0, 0)
|
||||
# Extract just the numeric parts (handles versions like "0.10.0-rc1")
|
||||
match = re.match(r"(\d+)\.(\d+)\.(\d+)", version_str)
|
||||
if match:
|
||||
return tuple(int(x) for x in match.groups())
|
||||
return (0, 0, 0)
|
||||
|
||||
|
||||
def version_gte(version: str | None, min_version: str | None) -> bool:
|
||||
"""Check if version >= min_version."""
|
||||
return parse_version(version) >= parse_version(min_version)
|
||||
|
||||
|
||||
def output_json(success: bool, data: Any = None, error: str | None = None) -> None:
|
||||
"""Output JSON result to stdout and exit."""
|
||||
result = {"success": success}
|
||||
@@ -185,14 +145,6 @@ def fetch_ollama_api(base_url: str, endpoint: str, timeout: int = 5) -> dict | N
|
||||
return None
|
||||
|
||||
|
||||
def get_ollama_version(base_url: str) -> str | None:
|
||||
"""Get the Ollama server version."""
|
||||
result = fetch_ollama_api(base_url, "api/version")
|
||||
if result:
|
||||
return result.get("version")
|
||||
return None
|
||||
|
||||
|
||||
def is_embedding_model(model_name: str) -> bool:
|
||||
"""Check if a model name suggests it's an embedding model."""
|
||||
name_lower = model_name.lower()
|
||||
@@ -240,19 +192,6 @@ def get_embedding_description(model_name: str) -> str:
|
||||
return "Embedding model"
|
||||
|
||||
|
||||
def get_model_min_version(model_name: str) -> str | None:
|
||||
"""Get the minimum Ollama version required for a model."""
|
||||
name_lower = model_name.lower()
|
||||
|
||||
# Sort keys by length descending to match more specific names first
|
||||
# e.g., "qwen3-embedding:8b" before "qwen3-embedding"
|
||||
for known_model in sorted(KNOWN_EMBEDDING_MODELS.keys(), key=len, reverse=True):
|
||||
if known_model in name_lower:
|
||||
return KNOWN_EMBEDDING_MODELS[known_model].get("min_version")
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def cmd_check_status(args) -> None:
|
||||
"""Check if Ollama is running and accessible."""
|
||||
base_url = args.base_url or DEFAULT_OLLAMA_URL
|
||||
@@ -261,18 +200,12 @@ def cmd_check_status(args) -> None:
|
||||
result = fetch_ollama_api(base_url, "api/version")
|
||||
|
||||
if result:
|
||||
version = result.get("version", "unknown")
|
||||
output_json(
|
||||
True,
|
||||
data={
|
||||
"running": True,
|
||||
"url": base_url,
|
||||
"version": version,
|
||||
"supports_new_models": version_gte(
|
||||
version, MIN_OLLAMA_VERSION_FOR_NEW_MODELS
|
||||
)
|
||||
if version != "unknown"
|
||||
else None,
|
||||
"version": result.get("version", "unknown"),
|
||||
},
|
||||
)
|
||||
else:
|
||||
@@ -386,9 +319,6 @@ def cmd_get_recommended_models(args) -> None:
|
||||
"""Get recommended embedding models with install status."""
|
||||
base_url = args.base_url or DEFAULT_OLLAMA_URL
|
||||
|
||||
# Get Ollama version for compatibility checking
|
||||
ollama_version = get_ollama_version(base_url)
|
||||
|
||||
# Get currently installed models
|
||||
result = fetch_ollama_api(base_url, "api/tags")
|
||||
installed_names = set()
|
||||
@@ -400,30 +330,17 @@ def cmd_get_recommended_models(args) -> None:
|
||||
installed_names.add(name)
|
||||
installed_names.add(base_name)
|
||||
|
||||
# Build recommended list with install status and compatibility
|
||||
# Build recommended list with install status
|
||||
recommended = []
|
||||
for model in RECOMMENDED_EMBEDDING_MODELS:
|
||||
name = model["name"]
|
||||
base_name = name.split(":")[0] if ":" in name else name
|
||||
is_installed = name in installed_names or base_name in installed_names
|
||||
|
||||
# Check version compatibility
|
||||
min_version = model.get("min_ollama_version")
|
||||
is_compatible = True
|
||||
compatibility_note = None
|
||||
if min_version and ollama_version:
|
||||
is_compatible = version_gte(ollama_version, min_version)
|
||||
if not is_compatible:
|
||||
compatibility_note = f"Requires Ollama {min_version}+"
|
||||
elif min_version and not ollama_version:
|
||||
compatibility_note = "Version compatibility could not be verified"
|
||||
|
||||
recommended.append(
|
||||
{
|
||||
**model,
|
||||
"installed": is_installed,
|
||||
"compatible": is_compatible,
|
||||
"compatibility_note": compatibility_note,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -433,7 +350,6 @@ def cmd_get_recommended_models(args) -> None:
|
||||
"recommended": recommended,
|
||||
"count": len(recommended),
|
||||
"url": base_url,
|
||||
"ollama_version": ollama_version,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -447,19 +363,6 @@ def cmd_pull_model(args) -> None:
|
||||
output_error("Model name is required")
|
||||
return
|
||||
|
||||
# Check Ollama version compatibility before attempting pull
|
||||
ollama_version = get_ollama_version(base_url)
|
||||
min_version = get_model_min_version(model_name)
|
||||
|
||||
if min_version and ollama_version:
|
||||
if not version_gte(ollama_version, min_version):
|
||||
output_error(
|
||||
f"Model '{model_name}' requires Ollama {min_version} or newer. "
|
||||
f"Your version is {ollama_version}. "
|
||||
f"Please upgrade Ollama: https://ollama.com/download"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
url = f"{base_url.rstrip('/')}/api/pull"
|
||||
data = json.dumps({"name": model_name}).encode("utf-8")
|
||||
@@ -473,22 +376,6 @@ def cmd_pull_model(args) -> None:
|
||||
try:
|
||||
progress = json.loads(line.decode("utf-8"))
|
||||
|
||||
# Check for error in the streaming response
|
||||
# This handles cases like "requires newer version of Ollama"
|
||||
if "error" in progress:
|
||||
error_msg = progress["error"]
|
||||
# Clean up the error message (remove extra whitespace/newlines)
|
||||
error_msg = " ".join(error_msg.split())
|
||||
# Check if it's a version-related error
|
||||
if "newer version" in error_msg.lower():
|
||||
error_msg = (
|
||||
f"Model '{model_name}' requires a newer version of Ollama. "
|
||||
f"Your version: {ollama_version or 'unknown'}. "
|
||||
f"Please upgrade: https://ollama.com/download"
|
||||
)
|
||||
output_error(error_msg)
|
||||
return
|
||||
|
||||
# Emit progress as NDJSON to stderr for main process to parse
|
||||
if "completed" in progress and "total" in progress:
|
||||
print(
|
||||
|
||||
@@ -7,7 +7,6 @@ Reads configuration from task_metadata.json and provides resolved model IDs.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
@@ -47,10 +46,10 @@ SPEC_PHASE_THINKING_LEVELS: dict[str, str] = {
|
||||
"complexity_assessment": "medium",
|
||||
}
|
||||
|
||||
# Default phase configuration (fallback, matches 'Balanced' profile)
|
||||
# Default phase configuration (matches UI defaults)
|
||||
DEFAULT_PHASE_MODELS: dict[str, str] = {
|
||||
"spec": "sonnet",
|
||||
"planning": "sonnet", # Changed from "opus" (fix #433)
|
||||
"planning": "opus",
|
||||
"coding": "sonnet",
|
||||
"qa": "sonnet",
|
||||
}
|
||||
@@ -95,34 +94,17 @@ def resolve_model_id(model: str) -> str:
|
||||
Resolve a model shorthand (haiku, sonnet, opus) to a full model ID.
|
||||
If the model is already a full ID, return it unchanged.
|
||||
|
||||
Priority:
|
||||
1. Environment variable override (from API Profile)
|
||||
2. Hardcoded MODEL_ID_MAP
|
||||
3. Pass through unchanged (assume full model ID)
|
||||
|
||||
Args:
|
||||
model: Model shorthand or full ID
|
||||
|
||||
Returns:
|
||||
Full Claude model ID
|
||||
"""
|
||||
# Check for environment variable override (from API Profile custom model mappings)
|
||||
# Check if it's a shorthand
|
||||
if model in MODEL_ID_MAP:
|
||||
env_var_map = {
|
||||
"haiku": "ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
"sonnet": "ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
"opus": "ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
}
|
||||
env_var = env_var_map.get(model)
|
||||
if env_var:
|
||||
env_value = os.environ.get(env_var)
|
||||
if env_value:
|
||||
return env_value
|
||||
|
||||
# Fall back to hardcoded mapping
|
||||
return MODEL_ID_MAP[model]
|
||||
|
||||
# Already a full model ID or unknown shorthand
|
||||
# Already a full model ID
|
||||
return model
|
||||
|
||||
|
||||
|
||||
@@ -188,38 +188,10 @@ class ProjectAnalyzer:
|
||||
return hasher.hexdigest()
|
||||
|
||||
def should_reanalyze(self, profile: SecurityProfile) -> bool:
|
||||
"""Check if project has changed since last analysis.
|
||||
|
||||
Never re-analyzes inherited profiles (from worktrees) since they
|
||||
came from a validated parent project with full context (e.g., node_modules).
|
||||
"""
|
||||
# Never re-analyze inherited profiles - they came from a validated parent
|
||||
# But validate that inherited_from points to a legitimate parent
|
||||
if profile.inherited_from:
|
||||
parent = Path(profile.inherited_from)
|
||||
# Validate the inherited_from path:
|
||||
# 1. Must exist and be a directory
|
||||
# 2. Current project must be a descendant of the parent
|
||||
# 3. Parent must contain a valid security profile
|
||||
if (
|
||||
parent.exists()
|
||||
and parent.is_dir()
|
||||
and self._is_descendant_of(self.project_dir, parent)
|
||||
and (parent / self.PROFILE_FILENAME).exists()
|
||||
):
|
||||
return False
|
||||
# If validation fails, treat as non-inherited and check hash
|
||||
"""Check if project has changed since last analysis."""
|
||||
current_hash = self.compute_project_hash()
|
||||
return current_hash != profile.project_hash
|
||||
|
||||
def _is_descendant_of(self, child: Path, parent: Path) -> bool:
|
||||
"""Check if child path is a descendant of parent path."""
|
||||
try:
|
||||
child.resolve().relative_to(parent.resolve())
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def analyze(self, force: bool = False) -> SecurityProfile:
|
||||
"""
|
||||
Perform full project analysis.
|
||||
@@ -233,12 +205,7 @@ class ProjectAnalyzer:
|
||||
# Check for existing profile
|
||||
existing = self.load_profile()
|
||||
if existing and not force and not self.should_reanalyze(existing):
|
||||
if existing.inherited_from:
|
||||
print("Using inherited security profile from parent project")
|
||||
else:
|
||||
print(
|
||||
f"Using cached security profile (hash: {existing.project_hash[:8]})"
|
||||
)
|
||||
print(f"Using cached security profile (hash: {existing.project_hash[:8]})")
|
||||
return existing
|
||||
|
||||
print("Analyzing project structure for security profile...")
|
||||
|
||||
@@ -158,10 +158,6 @@ VALIDATED_COMMANDS: dict[str, str] = {
|
||||
"pkill": "validate_pkill",
|
||||
"kill": "validate_kill",
|
||||
"killall": "validate_killall",
|
||||
# Shell interpreters - validate commands inside -c
|
||||
"bash": "validate_shell_c",
|
||||
"sh": "validate_shell_c",
|
||||
"zsh": "validate_shell_c",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -173,16 +173,12 @@ LANGUAGE_COMMANDS: dict[str, set[str]] = {
|
||||
"zig",
|
||||
},
|
||||
"dart": {
|
||||
# Core Dart CLI (modern unified tool)
|
||||
"dart",
|
||||
"pub",
|
||||
# Flutter CLI (included in Dart language for SDK detection)
|
||||
"flutter",
|
||||
# Legacy commands (deprecated but may exist in older projects)
|
||||
"dart2js",
|
||||
"dartanalyzer",
|
||||
"dartdoc",
|
||||
"dartfmt",
|
||||
"pub",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -33,9 +33,6 @@ PACKAGE_MANAGER_COMMANDS: dict[str, set[str]] = {
|
||||
"brew": {"brew"},
|
||||
"apt": {"apt", "apt-get", "dpkg"},
|
||||
"nix": {"nix", "nix-shell", "nix-build", "nix-env"},
|
||||
# Dart/Flutter package managers
|
||||
"pub": {"pub", "dart"},
|
||||
"melos": {"melos", "dart", "flutter"},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -23,8 +23,6 @@ VERSION_MANAGER_COMMANDS: dict[str, set[str]] = {
|
||||
"rustup": {"rustup"},
|
||||
"sdkman": {"sdk"},
|
||||
"jabba": {"jabba"},
|
||||
# Dart/Flutter version managers
|
||||
"fvm": {"fvm", "flutter"},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -52,9 +52,6 @@ class SecurityProfile:
|
||||
project_dir: str = ""
|
||||
created_at: str = ""
|
||||
project_hash: str = ""
|
||||
inherited_from: str = (
|
||||
"" # Source project path if inherited from parent (e.g., worktree)
|
||||
)
|
||||
|
||||
def get_all_allowed_commands(self) -> set[str]:
|
||||
"""Get the complete set of allowed commands."""
|
||||
@@ -67,7 +64,7 @@ class SecurityProfile:
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to JSON-serializable dict."""
|
||||
result = {
|
||||
return {
|
||||
"base_commands": sorted(self.base_commands),
|
||||
"stack_commands": sorted(self.stack_commands),
|
||||
"script_commands": sorted(self.script_commands),
|
||||
@@ -78,10 +75,6 @@ class SecurityProfile:
|
||||
"created_at": self.created_at,
|
||||
"project_hash": self.project_hash,
|
||||
}
|
||||
# Only include inherited_from if set (to keep backward compatibility)
|
||||
if self.inherited_from:
|
||||
result["inherited_from"] = self.inherited_from
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "SecurityProfile":
|
||||
@@ -94,7 +87,6 @@ class SecurityProfile:
|
||||
project_dir=data.get("project_dir", ""),
|
||||
created_at=data.get("created_at", ""),
|
||||
project_hash=data.get("project_hash", ""),
|
||||
inherited_from=data.get("inherited_from", ""),
|
||||
)
|
||||
|
||||
if "detected_stack" in data:
|
||||
|
||||
@@ -126,7 +126,7 @@ class StackDetector:
|
||||
self.stack.package_managers.append("yarn")
|
||||
if self.parser.file_exists("pnpm-lock.yaml"):
|
||||
self.stack.package_managers.append("pnpm")
|
||||
if self.parser.file_exists("bun.lockb", "bun.lock"):
|
||||
if self.parser.file_exists("bun.lockb"):
|
||||
self.stack.package_managers.append("bun")
|
||||
if self.parser.file_exists("deno.json", "deno.jsonc"):
|
||||
self.stack.package_managers.append("deno")
|
||||
@@ -164,12 +164,6 @@ class StackDetector:
|
||||
if self.parser.file_exists("build.gradle", "build.gradle.kts"):
|
||||
self.stack.package_managers.append("gradle")
|
||||
|
||||
# Dart/Flutter package managers
|
||||
if self.parser.file_exists("pubspec.yaml", "pubspec.lock"):
|
||||
self.stack.package_managers.append("pub")
|
||||
if self.parser.file_exists("melos.yaml"):
|
||||
self.stack.package_managers.append("melos")
|
||||
|
||||
def detect_databases(self) -> None:
|
||||
"""Detect databases from config files and dependencies."""
|
||||
# Check for database config files
|
||||
@@ -364,6 +358,3 @@ class StackDetector:
|
||||
self.stack.version_managers.append("rbenv")
|
||||
if self.parser.file_exists("rust-toolchain.toml", "rust-toolchain"):
|
||||
self.stack.version_managers.append("rustup")
|
||||
# Flutter Version Manager
|
||||
if self.parser.file_exists(".fvm", ".fvmrc", "fvm_config.json"):
|
||||
self.stack.version_managers.append("fvm")
|
||||
|
||||
@@ -22,68 +22,6 @@ environment at the start of each prompt in the "YOUR ENVIRONMENT" section. Pay c
|
||||
|
||||
---
|
||||
|
||||
## 🚨 CRITICAL: PATH CONFUSION PREVENTION 🚨
|
||||
|
||||
**THE #1 BUG IN MONOREPOS: Doubled paths after `cd` commands**
|
||||
|
||||
### The Problem
|
||||
|
||||
After running `cd ./apps/frontend`, your current directory changes. If you then use paths like `apps/frontend/src/file.ts`, you're creating **doubled paths** like `apps/frontend/apps/frontend/src/file.ts`.
|
||||
|
||||
### The Solution: ALWAYS CHECK YOUR CWD
|
||||
|
||||
**BEFORE every git command or file operation:**
|
||||
|
||||
```bash
|
||||
# Step 1: Check where you are
|
||||
pwd
|
||||
|
||||
# Step 2: Use paths RELATIVE TO CURRENT DIRECTORY
|
||||
# If pwd shows: /path/to/project/apps/frontend
|
||||
# Then use: git add src/file.ts
|
||||
# NOT: git add apps/frontend/src/file.ts
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
**❌ WRONG - Path gets doubled:**
|
||||
```bash
|
||||
cd ./apps/frontend
|
||||
git add apps/frontend/src/file.ts # Looks for apps/frontend/apps/frontend/src/file.ts
|
||||
```
|
||||
|
||||
**✅ CORRECT - Use relative path from current directory:**
|
||||
```bash
|
||||
cd ./apps/frontend
|
||||
pwd # Shows: /path/to/project/apps/frontend
|
||||
git add src/file.ts # Correctly adds apps/frontend/src/file.ts from project root
|
||||
```
|
||||
|
||||
**✅ ALSO CORRECT - Stay at root, use full relative path:**
|
||||
```bash
|
||||
# Don't change directory at all
|
||||
git add ./apps/frontend/src/file.ts # Works from project root
|
||||
```
|
||||
|
||||
### Mandatory Pre-Command Check
|
||||
|
||||
**Before EVERY git add, git commit, or file operation in a monorepo:**
|
||||
|
||||
```bash
|
||||
# 1. Where am I?
|
||||
pwd
|
||||
|
||||
# 2. What files am I targeting?
|
||||
ls -la [target-path] # Verify the path exists
|
||||
|
||||
# 3. Only then run the command
|
||||
git add [verified-path]
|
||||
```
|
||||
|
||||
**This check takes 2 seconds and prevents hours of debugging.**
|
||||
|
||||
---
|
||||
|
||||
## STEP 1: GET YOUR BEARINGS (MANDATORY)
|
||||
|
||||
First, check your environment. The prompt should tell you your working directory and spec location.
|
||||
@@ -420,20 +358,6 @@ In your response, acknowledge the checklist:
|
||||
|
||||
## STEP 6: IMPLEMENT THE SUBTASK
|
||||
|
||||
### Verify Your Location FIRST
|
||||
|
||||
**MANDATORY: Before implementing anything, confirm where you are:**
|
||||
|
||||
```bash
|
||||
# This should match the "Working Directory" in YOUR ENVIRONMENT section above
|
||||
pwd
|
||||
```
|
||||
|
||||
If you change directories during implementation (e.g., `cd apps/frontend`), remember:
|
||||
- Your file paths must be RELATIVE TO YOUR NEW LOCATION
|
||||
- Before any git operation, run `pwd` again to verify your location
|
||||
- See the "PATH CONFUSION PREVENTION" section above for examples
|
||||
|
||||
### Mark as In Progress
|
||||
|
||||
Update `implementation_plan.json`:
|
||||
@@ -694,31 +618,6 @@ After successful verification, update the subtask:
|
||||
|
||||
## STEP 9: COMMIT YOUR PROGRESS
|
||||
|
||||
### Path Verification (MANDATORY FIRST STEP)
|
||||
|
||||
**🚨 BEFORE running ANY git commands, verify your current directory:**
|
||||
|
||||
```bash
|
||||
# Step 1: Where am I?
|
||||
pwd
|
||||
|
||||
# Step 2: What files do I want to commit?
|
||||
# If you changed to a subdirectory (e.g., cd apps/frontend),
|
||||
# you need to use paths RELATIVE TO THAT DIRECTORY, not from project root
|
||||
|
||||
# Step 3: Verify paths exist
|
||||
ls -la [path-to-files] # Make sure the path is correct from your current location
|
||||
|
||||
# Example in a monorepo:
|
||||
# If pwd shows: /project/apps/frontend
|
||||
# Then use: git add src/file.ts
|
||||
# NOT: git add apps/frontend/src/file.ts (this would look for apps/frontend/apps/frontend/src/file.ts)
|
||||
```
|
||||
|
||||
**CRITICAL RULE:** If you're in a subdirectory, either:
|
||||
- **Option A:** Return to project root: `cd [back to working directory]`
|
||||
- **Option B:** Use paths relative to your CURRENT directory (check with `pwd`)
|
||||
|
||||
### Secret Scanning (Automatic)
|
||||
|
||||
The system **automatically scans for secrets** before every commit. If secrets are detected, the commit will be blocked and you'll receive detailed instructions on how to fix it.
|
||||
@@ -735,7 +634,7 @@ The system **automatically scans for secrets** before every commit. If secrets a
|
||||
api_key = os.environ.get("API_KEY")
|
||||
```
|
||||
3. **Update .env.example** - Add placeholder for the new variable
|
||||
4. **Re-stage and retry** - `git add . ':!.auto-claude' && git commit ...`
|
||||
4. **Re-stage and retry** - `git add . && git commit ...`
|
||||
|
||||
**If it's a false positive:**
|
||||
- Add the file pattern to `.secretsignore` in the project root
|
||||
@@ -744,17 +643,7 @@ The system **automatically scans for secrets** before every commit. If secrets a
|
||||
### Create the Commit
|
||||
|
||||
```bash
|
||||
# FIRST: Make sure you're in the working directory root (check YOUR ENVIRONMENT section at top)
|
||||
pwd # Should match your working directory
|
||||
|
||||
# Add all files EXCEPT .auto-claude directory (spec files should never be committed)
|
||||
git add . ':!.auto-claude'
|
||||
|
||||
# If git add fails with "pathspec did not match", you have a path problem:
|
||||
# 1. Run pwd to see where you are
|
||||
# 2. Run git status to see what git sees
|
||||
# 3. Adjust your paths accordingly
|
||||
|
||||
git add .
|
||||
git commit -m "auto-claude: Complete [subtask-id] - [subtask description]
|
||||
|
||||
- Files modified: [list]
|
||||
@@ -762,9 +651,6 @@ git commit -m "auto-claude: Complete [subtask-id] - [subtask description]
|
||||
- Phase progress: [X]/[Y] subtasks complete"
|
||||
```
|
||||
|
||||
**CRITICAL**: The `:!.auto-claude` pathspec exclusion ensures spec files are NEVER committed.
|
||||
These are internal tracking files that must stay local.
|
||||
|
||||
### DO NOT Push to Remote
|
||||
|
||||
**IMPORTANT**: Do NOT run `git push`. All work stays local until the user reviews and approves.
|
||||
@@ -1070,17 +956,6 @@ Prepare → Test (small batch) → Execute (full) → Cleanup
|
||||
- Clean, working state
|
||||
- **Secret scan must pass before commit**
|
||||
|
||||
### Git Configuration - NEVER MODIFY
|
||||
**CRITICAL**: You MUST NOT modify git user configuration. Never run:
|
||||
- `git config user.name`
|
||||
- `git config user.email`
|
||||
- `git config --local user.*`
|
||||
- `git config --global user.*`
|
||||
|
||||
The repository inherits the user's configured git identity. Creating "Test User" or
|
||||
any other fake identity breaks attribution and causes serious issues. If you need
|
||||
to commit changes, use the existing git identity - do NOT set a new one.
|
||||
|
||||
### The Golden Rule
|
||||
**FIX BUGS NOW.** The next session has no memory.
|
||||
|
||||
|
||||
@@ -6,23 +6,6 @@ You are a focused codebase fit review agent. You have been spawned by the orches
|
||||
|
||||
Ensure new code integrates well with the existing codebase. Check for consistency with project conventions, reuse of existing utilities, and architectural alignment. Focus ONLY on codebase fit - not security, logic correctness, or general quality.
|
||||
|
||||
## CRITICAL: PR Scope and Context
|
||||
|
||||
### What IS in scope (report these issues):
|
||||
1. **Codebase fit issues in changed code** - New code not following project patterns
|
||||
2. **Missed reuse opportunities** - "Existing `utils.ts` has a helper for this"
|
||||
3. **Inconsistent with PR's own changes** - "You used `camelCase` here but `snake_case` elsewhere in the PR"
|
||||
4. **Breaking conventions in touched areas** - "Your change deviates from the pattern in this file"
|
||||
|
||||
### What is NOT in scope (do NOT report):
|
||||
1. **Pre-existing inconsistencies** - Old code that doesn't follow patterns
|
||||
2. **Unrelated suggestions** - Don't suggest patterns for code the PR didn't touch
|
||||
|
||||
**Key distinction:**
|
||||
- ✅ "Your new component doesn't follow the existing pattern in `components/`" - GOOD
|
||||
- ✅ "Consider using existing `formatDate()` helper instead of new implementation" - GOOD
|
||||
- ❌ "The old `legacy/` folder uses different naming conventions" - BAD (pre-existing)
|
||||
|
||||
## Codebase Fit Focus Areas
|
||||
|
||||
### 1. Naming Conventions
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
# Finding Validator Agent
|
||||
|
||||
You are a finding re-investigator using EVIDENCE-BASED VALIDATION. For each unresolved finding from a previous PR review, you must actively investigate whether it is a REAL issue or a FALSE POSITIVE.
|
||||
|
||||
**Core Principle: Evidence, not confidence scores.** Either you can prove the issue exists with actual code, or you can't. There is no middle ground.
|
||||
|
||||
Your job is to prevent false positives from persisting indefinitely by actually reading the code and verifying the issue exists.
|
||||
|
||||
## CRITICAL: Check PR Scope First
|
||||
|
||||
**Before investigating any finding, verify it's within THIS PR's scope:**
|
||||
|
||||
1. **Check if the file is in the PR's changed files list** - If not, likely out-of-scope
|
||||
2. **Check if the line number exists** - If finding cites line 710 but file has 600 lines, it's hallucinated
|
||||
3. **Check for PR references in commit messages** - Commits like `fix: something (#584)` are from OTHER PRs
|
||||
|
||||
**Dismiss findings as `dismissed_false_positive` if:**
|
||||
- The finding references a file NOT in the PR's changed files list AND is not about impact on that file
|
||||
- The line number doesn't exist in the file (hallucinated)
|
||||
- The finding is about code from a merged branch commit (not this PR's work)
|
||||
|
||||
**Keep findings valid if they're about:**
|
||||
- Issues in code the PR actually changed
|
||||
- Impact of PR changes on other code (e.g., "this change breaks callers in X")
|
||||
- Missing updates to related code (e.g., "you updated A but forgot B")
|
||||
|
||||
## Your Mission
|
||||
|
||||
For each finding you receive:
|
||||
1. **VERIFY SCOPE** - Is this file/line actually part of this PR?
|
||||
2. **READ** the actual code at the file/line location using the Read tool
|
||||
3. **ANALYZE** whether the described issue actually exists in the code
|
||||
4. **PROVIDE** concrete code evidence - the actual code that proves or disproves the issue
|
||||
5. **RETURN** validation status with evidence (binary decision based on what the code shows)
|
||||
|
||||
## Investigation Process
|
||||
|
||||
### Step 1: Fetch the Code
|
||||
|
||||
Use the Read tool to get the actual code at `finding.file` around `finding.line`.
|
||||
Get sufficient context (±20 lines minimum).
|
||||
|
||||
```
|
||||
Read the file: {finding.file}
|
||||
Focus on lines around: {finding.line}
|
||||
```
|
||||
|
||||
### Step 2: Analyze with Fresh Eyes - NEVER ASSUME
|
||||
|
||||
**CRITICAL: Do NOT assume the original finding is correct.** The original reviewer may have:
|
||||
- Hallucinated line numbers that don't exist
|
||||
- Misread or misunderstood the code
|
||||
- Missed validation/sanitization in callers or surrounding code
|
||||
- Made assumptions without actually reading the implementation
|
||||
- Confused similar-looking code patterns
|
||||
|
||||
**You MUST actively verify by asking:**
|
||||
- Does the code at this exact line ACTUALLY have this issue?
|
||||
- Did I READ the actual implementation, not just the function name?
|
||||
- Is there validation/sanitization BEFORE this code is reached?
|
||||
- Is there framework protection I'm not accounting for?
|
||||
- Does this line number even EXIST in the file?
|
||||
|
||||
**NEVER:**
|
||||
- Trust the finding description without reading the code
|
||||
- Assume a function is vulnerable based on its name
|
||||
- Skip checking surrounding context (±20 lines minimum)
|
||||
- Confirm a finding just because "it sounds plausible"
|
||||
|
||||
Be HIGHLY skeptical. AI reviews frequently produce false positives. Your job is to catch them.
|
||||
|
||||
### Step 3: Document Evidence
|
||||
|
||||
You MUST provide concrete evidence:
|
||||
- **Exact code snippet** you examined (copy-paste from the file) - this is the PROOF
|
||||
- **Line numbers** where you found (or didn't find) the issue
|
||||
- **Your analysis** connecting the code to your conclusion
|
||||
- **Verification flag** - did this code actually exist at the specified location?
|
||||
|
||||
## Validation Statuses
|
||||
|
||||
### `confirmed_valid`
|
||||
Use when your code evidence PROVES the issue IS real:
|
||||
- The problematic code pattern exists exactly as described
|
||||
- You can point to the specific lines showing the vulnerability/bug
|
||||
- The code quality issue genuinely impacts the codebase
|
||||
- **Key question**: Does your code_evidence field contain the actual problematic code?
|
||||
|
||||
### `dismissed_false_positive`
|
||||
Use when your code evidence PROVES the issue does NOT exist:
|
||||
- The described code pattern is not actually present (code_evidence shows different code)
|
||||
- There is mitigating code that prevents the issue (code_evidence shows the mitigation)
|
||||
- The finding was based on incorrect assumptions (code_evidence shows reality)
|
||||
- The line number doesn't exist or contains different code than claimed
|
||||
- **Key question**: Does your code_evidence field show code that disproves the original finding?
|
||||
|
||||
### `needs_human_review`
|
||||
Use when you CANNOT find definitive evidence either way:
|
||||
- The issue requires runtime analysis to verify (static code doesn't prove/disprove)
|
||||
- The code is too complex to analyze statically
|
||||
- You found the code but can't determine if it's actually a problem
|
||||
- **Key question**: Is your code_evidence inconclusive?
|
||||
|
||||
## Output Format
|
||||
|
||||
Return one result per finding:
|
||||
|
||||
```json
|
||||
{
|
||||
"finding_id": "SEC-001",
|
||||
"validation_status": "confirmed_valid",
|
||||
"code_evidence": "const query = `SELECT * FROM users WHERE id = ${userId}`;",
|
||||
"line_range": [45, 45],
|
||||
"explanation": "SQL injection vulnerability confirmed. User input 'userId' is directly interpolated into the SQL query at line 45 without any sanitization. The query is executed via db.execute() on line 46.",
|
||||
"evidence_verified_in_file": true
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"finding_id": "QUAL-002",
|
||||
"validation_status": "dismissed_false_positive",
|
||||
"code_evidence": "function processInput(data: string): string {\n const sanitized = DOMPurify.sanitize(data);\n return sanitized;\n}",
|
||||
"line_range": [23, 26],
|
||||
"explanation": "The original finding claimed XSS vulnerability, but the code uses DOMPurify.sanitize() before output. The input is properly sanitized at line 24 before being returned. The code evidence proves the issue does NOT exist.",
|
||||
"evidence_verified_in_file": true
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"finding_id": "LOGIC-003",
|
||||
"validation_status": "needs_human_review",
|
||||
"code_evidence": "async function handleRequest(req) {\n // Complex async logic...\n}",
|
||||
"line_range": [100, 150],
|
||||
"explanation": "The original finding claims a race condition, but verifying this requires understanding the runtime behavior and concurrency model. The static code doesn't provide definitive evidence either way.",
|
||||
"evidence_verified_in_file": true
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"finding_id": "HALLUC-004",
|
||||
"validation_status": "dismissed_false_positive",
|
||||
"code_evidence": "// Line 710 does not exist - file only has 600 lines",
|
||||
"line_range": [600, 600],
|
||||
"explanation": "The original finding claimed an issue at line 710, but the file only has 600 lines. This is a hallucinated finding - the code doesn't exist.",
|
||||
"evidence_verified_in_file": false
|
||||
}
|
||||
```
|
||||
|
||||
## Evidence Guidelines
|
||||
|
||||
Validation is binary based on what the code evidence shows:
|
||||
|
||||
| Scenario | Status | Evidence Required |
|
||||
|----------|--------|-------------------|
|
||||
| Code shows the exact problem claimed | `confirmed_valid` | Problematic code snippet |
|
||||
| Code shows issue doesn't exist or is mitigated | `dismissed_false_positive` | Code proving issue is absent |
|
||||
| Code couldn't be found (hallucinated line/file) | `dismissed_false_positive` | Note that code doesn't exist |
|
||||
| Code found but can't prove/disprove statically | `needs_human_review` | The inconclusive code |
|
||||
|
||||
**Decision rules:**
|
||||
- If `code_evidence` contains problematic code → `confirmed_valid`
|
||||
- If `code_evidence` proves issue doesn't exist → `dismissed_false_positive`
|
||||
- If `evidence_verified_in_file` is false → `dismissed_false_positive` (hallucinated finding)
|
||||
- If you can't determine from the code → `needs_human_review`
|
||||
|
||||
## Common False Positive Patterns
|
||||
|
||||
Watch for these patterns that often indicate false positives:
|
||||
|
||||
1. **Non-existent line number**: The line number cited doesn't exist or is beyond EOF - hallucinated finding
|
||||
2. **Merged branch code**: Finding is about code from a commit like `fix: something (#584)` - another PR
|
||||
3. **Pre-existing issue, not impact**: Finding flags old bug in untouched code without showing how PR changes relate
|
||||
4. **Sanitization elsewhere**: Input is validated/sanitized before reaching the flagged code
|
||||
5. **Internal-only code**: Code only handles trusted internal data, not user input
|
||||
6. **Framework protection**: Framework provides automatic protection (e.g., ORM parameterization)
|
||||
7. **Dead code**: The flagged code is never executed in the current codebase
|
||||
8. **Test code**: The issue is in test files where it's acceptable
|
||||
9. **Misread syntax**: Original reviewer misunderstood the language syntax
|
||||
|
||||
**Note**: Findings about files outside the PR's changed list are NOT automatically false positives if they're about:
|
||||
- Impact of PR changes on that file (e.g., "your change breaks X")
|
||||
- Missing related updates (e.g., "you forgot to update Y")
|
||||
|
||||
## Common Valid Issue Patterns
|
||||
|
||||
These patterns often confirm the issue is real:
|
||||
|
||||
1. **Direct string concatenation** in SQL/commands with user input
|
||||
2. **Missing null checks** where null values can flow through
|
||||
3. **Hardcoded credentials** that are actually used (not examples)
|
||||
4. **Missing error handling** in critical paths
|
||||
5. **Race conditions** with clear concurrent access
|
||||
|
||||
## Critical Rules
|
||||
|
||||
1. **ALWAYS read the actual code** - Never rely on memory or the original finding description
|
||||
2. **ALWAYS provide code_evidence** - No empty strings. Quote the actual code.
|
||||
3. **Be skeptical of original findings** - Many AI reviews produce false positives
|
||||
4. **Evidence is binary** - The code either shows the problem or it doesn't
|
||||
5. **When evidence is inconclusive, escalate** - Use `needs_human_review` rather than guessing
|
||||
6. **Look for mitigations** - Check surrounding code for sanitization/validation
|
||||
7. **Check the full context** - Read ±20 lines, not just the flagged line
|
||||
8. **Verify code exists** - Set `evidence_verified_in_file` to false if the code/line doesn't exist
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
- **Trusting the original finding blindly** - Always verify with actual code
|
||||
- **Dismissing without reading code** - Must provide code_evidence that proves your point
|
||||
- **Vague explanations** - Be specific about what the code shows and why it proves/disproves the issue
|
||||
- **Missing line numbers** - Always include line_range
|
||||
- **Speculative conclusions** - Only conclude what the code evidence actually proves
|
||||
@@ -71,12 +71,10 @@ Review the diff since the last review for NEW issues:
|
||||
- Regressions that break previously working code
|
||||
- Missing error handling in new code paths
|
||||
|
||||
**NEVER ASSUME - ALWAYS VERIFY:**
|
||||
- Actually READ the code before reporting any finding
|
||||
- Verify the issue exists at the exact line you cite
|
||||
- Check for validation/mitigation in surrounding code
|
||||
**Apply the 80% confidence threshold:**
|
||||
- Only report issues you're confident about
|
||||
- Don't re-report issues from the previous review
|
||||
- Focus on genuinely new problems with code EVIDENCE
|
||||
- Focus on genuinely new problems
|
||||
|
||||
### Phase 3: Comment Review
|
||||
|
||||
@@ -139,11 +137,11 @@ Return a JSON object with this structure:
|
||||
"id": "new-finding-1",
|
||||
"severity": "medium",
|
||||
"category": "security",
|
||||
"confidence": 0.85,
|
||||
"title": "New hardcoded API key in config",
|
||||
"description": "A new API key was added in config.ts line 45 without using environment variables.",
|
||||
"file": "src/config.ts",
|
||||
"line": 45,
|
||||
"evidence": "const API_KEY = 'sk-prod-abc123xyz789';",
|
||||
"suggested_fix": "Move to environment variable: process.env.EXTERNAL_API_KEY"
|
||||
}
|
||||
],
|
||||
@@ -177,11 +175,11 @@ Same format as initial review findings:
|
||||
- **id**: Unique identifier for new finding
|
||||
- **severity**: `critical` | `high` | `medium` | `low`
|
||||
- **category**: `security` | `quality` | `logic` | `test` | `docs` | `pattern` | `performance`
|
||||
- **confidence**: Float 0.80-1.0
|
||||
- **title**: Short summary (max 80 chars)
|
||||
- **description**: Detailed explanation
|
||||
- **file**: Relative file path
|
||||
- **line**: Line number
|
||||
- **evidence**: **REQUIRED** - Actual code snippet proving the issue exists
|
||||
- **suggested_fix**: How to resolve
|
||||
|
||||
### verdict
|
||||
|
||||
@@ -11,23 +11,6 @@ Review the incremental diff for:
|
||||
4. Potential regressions
|
||||
5. Incomplete implementations
|
||||
|
||||
## CRITICAL: PR Scope and Context
|
||||
|
||||
### What IS in scope (report these issues):
|
||||
1. **Issues in changed code** - Problems in files/lines actually modified by this PR
|
||||
2. **Impact on unchanged code** - "This change breaks callers in `other_file.ts`"
|
||||
3. **Missing related changes** - "Similar pattern in `utils.ts` wasn't updated"
|
||||
4. **Incomplete implementations** - "New field added but not handled in serializer"
|
||||
|
||||
### What is NOT in scope (do NOT report):
|
||||
1. **Pre-existing bugs** - Old bugs in code this PR didn't touch
|
||||
2. **Code from merged branches** - Commits with PR references like `(#584)` are from other PRs
|
||||
3. **Unrelated improvements** - Don't suggest refactoring untouched code
|
||||
|
||||
**Key distinction:**
|
||||
- ✅ "Your change breaks the caller in `auth.ts`" - GOOD (impact analysis)
|
||||
- ❌ "The old code in `legacy.ts` has a bug" - BAD (pre-existing, not this PR)
|
||||
|
||||
## Focus Areas
|
||||
|
||||
Since this is a follow-up review, focus on:
|
||||
@@ -91,47 +74,15 @@ Since this is a follow-up review, focus on:
|
||||
- Minor optimizations
|
||||
- Documentation gaps
|
||||
|
||||
## NEVER ASSUME - ALWAYS VERIFY
|
||||
## Confidence Scoring
|
||||
|
||||
**Before reporting ANY new finding:**
|
||||
Rate confidence (0.0-1.0) based on:
|
||||
- **>0.9**: Obvious, verifiable issue
|
||||
- **0.8-0.9**: High confidence with clear evidence
|
||||
- **0.7-0.8**: Likely issue but some uncertainty
|
||||
- **<0.7**: Possible issue, needs verification
|
||||
|
||||
1. **NEVER assume code is vulnerable** - Read the actual implementation
|
||||
2. **NEVER assume validation is missing** - Check callers and surrounding code
|
||||
3. **NEVER assume based on function names** - `unsafeQuery()` might actually be safe
|
||||
4. **NEVER report without reading the code** - Verify the issue exists at the exact line
|
||||
|
||||
**You MUST:**
|
||||
- Actually READ the code at the file/line you cite
|
||||
- Verify there's no sanitization/validation before this code
|
||||
- Check for framework protections you might miss
|
||||
- Provide the actual code snippet as evidence
|
||||
|
||||
### Verify Before Reporting "Missing" Safeguards
|
||||
|
||||
For findings claiming something is **missing** (no fallback, no validation, no error handling):
|
||||
|
||||
**Ask yourself**: "Have I verified this is actually missing, or did I just not see it?"
|
||||
|
||||
- Read the **complete function/method** containing the issue, not just the flagged line
|
||||
- Check for guards, fallbacks, or defensive code that may appear later in the function
|
||||
- Look for comments indicating intentional design choices
|
||||
- If uncertain, use the Read/Grep tools to confirm
|
||||
|
||||
**Your evidence must prove absence exists — not just that you didn't see it.**
|
||||
|
||||
❌ **Weak**: "The code defaults to 'main' without checking if it exists"
|
||||
✅ **Strong**: "I read the complete `_detect_target_branch()` function. There is no existence check before the default return."
|
||||
|
||||
**Only report if you can confidently say**: "I verified the complete scope and the safeguard does not exist."
|
||||
|
||||
## Evidence Requirements
|
||||
|
||||
Every finding MUST include an `evidence` field with:
|
||||
- The actual problematic code copy-pasted from the diff
|
||||
- The specific line numbers where the issue exists
|
||||
- Proof that the issue is real, not speculative
|
||||
|
||||
**No evidence = No finding**
|
||||
Only report findings with confidence >0.7.
|
||||
|
||||
## Output Format
|
||||
|
||||
@@ -148,7 +99,7 @@ Return findings in this structure:
|
||||
"description": "The new login validation query concatenates user input directly into the SQL string without sanitization.",
|
||||
"category": "security",
|
||||
"severity": "critical",
|
||||
"evidence": "query = f\"SELECT * FROM users WHERE email = '{email}'\"",
|
||||
"confidence": 0.95,
|
||||
"suggested_fix": "Use parameterized queries: cursor.execute('SELECT * FROM users WHERE email = ?', (email,))",
|
||||
"fixable": true,
|
||||
"source_agent": "new-code-reviewer",
|
||||
@@ -162,7 +113,7 @@ Return findings in this structure:
|
||||
"description": "The fix for LOGIC-003 removed a null check that was protecting against undefined input. Now input.data can be null.",
|
||||
"category": "regression",
|
||||
"severity": "high",
|
||||
"evidence": "result = input.data.process() # input.data can be null, was previously: if input and input.data:",
|
||||
"confidence": 0.88,
|
||||
"suggested_fix": "Restore null check: if (input && input.data) { ... }",
|
||||
"fixable": true,
|
||||
"source_agent": "new-code-reviewer",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user