Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 152678bda0 | |||
| dc29794efa | |||
| c623ab0018 | |||
| 204588493b | |||
| 63e142ae59 | |||
| 07ae1ef709 | |||
| ada91fb195 | |||
| cbb1cb8154 | |||
| 32e8fee3b2 | |||
| a74bd8656e | |||
| e310d56f3d | |||
| ab3149fcba | |||
| a6ffd0e129 | |||
| 05c652e45b | |||
| 29ef46d733 | |||
| a47354b470 | |||
| 40fc7e4d4e | |||
| 63766f761d | |||
| 4cc9198a3e | |||
| 4203341227 | |||
| cc78d7aed0 | |||
| 061411d79a | |||
| cbd47f2c3a | |||
| fbaf2e7ab4 | |||
| 01decaeb26 | |||
| 96b7eb4a3e | |||
| 5e783908e3 | |||
| 31519c2a10 | |||
| f406959094 | |||
| e3d72d648e | |||
| e9c859cc6c | |||
| 7fda36ad2e | |||
| 78b80bcaeb | |||
| 724ad827bf | |||
| 2f321fb2aa | |||
| df57fbf8bc | |||
| 84bc52264f | |||
| 8a4b506671 | |||
| 574cd117b2 | |||
| 09aa4f4f71 | |||
| 78aceaed1e | |||
| 5005e56e46 | |||
| ec4441c1e3 | |||
| 97f34496b5 | |||
| 2c9fcbf498 | |||
| 3930b12c41 | |||
| e2937320cf | |||
| 81afc3d2cc | |||
| 63f4617354 | |||
| 35573fd5b0 | |||
| 7b4993e9db | |||
| c27135436d | |||
| 6fb2d48433 | |||
| 1e3e8bda1d | |||
| 8be0e6ff1a |
@@ -115,6 +115,10 @@ jobs:
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package macOS (Intel)
|
||||
run: |
|
||||
@@ -124,6 +128,9 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Notarize macOS Intel app
|
||||
env:
|
||||
@@ -207,6 +214,10 @@ jobs:
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package macOS (Apple Silicon)
|
||||
run: |
|
||||
@@ -216,6 +227,9 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Notarize macOS ARM64 app
|
||||
env:
|
||||
@@ -251,6 +265,12 @@ jobs:
|
||||
build-windows:
|
||||
needs: create-tag
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
id-token: write # Required for OIDC authentication with Azure
|
||||
contents: read
|
||||
env:
|
||||
# Job-level env so AZURE_CLIENT_ID is available for step-level if conditions
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -299,6 +319,10 @@ jobs:
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package Windows
|
||||
shell: bash
|
||||
@@ -307,8 +331,122 @@ jobs:
|
||||
cd apps/frontend && npm run package:win -- --config.extraMetadata.version="$VERSION"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.WIN_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTIFICATE_PASSWORD }}
|
||||
# Disable electron-builder's built-in signing (we use Azure Trusted Signing instead)
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: false
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Azure Login (OIDC)
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Sign Windows executable with Azure Trusted Signing
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
uses: azure/trusted-signing-action@v0.5.11
|
||||
with:
|
||||
endpoint: https://neu.codesigning.azure.net/
|
||||
trusted-signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
certificate-profile-name: ${{ secrets.AZURE_CERTIFICATE_PROFILE }}
|
||||
files-folder: apps/frontend/dist
|
||||
files-folder-filter: exe
|
||||
file-digest: SHA256
|
||||
timestamp-rfc3161: http://timestamp.acs.microsoft.com
|
||||
timestamp-digest: SHA256
|
||||
|
||||
- name: Verify Windows executable is signed
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
cd apps/frontend/dist
|
||||
$exeFile = Get-ChildItem -Filter "*.exe" | Select-Object -First 1
|
||||
if ($exeFile) {
|
||||
Write-Host "Verifying signature on $($exeFile.Name)..."
|
||||
$sig = Get-AuthenticodeSignature -FilePath $exeFile.FullName
|
||||
if ($sig.Status -ne 'Valid') {
|
||||
Write-Host "::error::Signature verification failed: $($sig.Status)"
|
||||
Write-Host "::error::Status Message: $($sig.StatusMessage)"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "✅ Signature verified successfully"
|
||||
Write-Host " Subject: $($sig.SignerCertificate.Subject)"
|
||||
Write-Host " Issuer: $($sig.SignerCertificate.Issuer)"
|
||||
Write-Host " Thumbprint: $($sig.SignerCertificate.Thumbprint)"
|
||||
} else {
|
||||
Write-Host "::error::No .exe file found to verify"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Regenerate checksums after signing
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
cd apps/frontend/dist
|
||||
|
||||
# Find the installer exe (electron-builder names it with "Setup" or just the app name)
|
||||
# electron-builder produces one installer exe per build
|
||||
$exeFiles = Get-ChildItem -Filter "*.exe"
|
||||
if ($exeFiles.Count -eq 0) {
|
||||
Write-Host "::error::No .exe files found in dist folder"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Found $($exeFiles.Count) exe file(s): $($exeFiles.Name -join ', ')"
|
||||
|
||||
$ymlFile = "latest.yml"
|
||||
if (-not (Test-Path $ymlFile)) {
|
||||
Write-Host "::error::$ymlFile not found - cannot update checksums"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$content = Get-Content $ymlFile -Raw
|
||||
$originalContent = $content
|
||||
|
||||
# Process each exe file and update its hash in latest.yml
|
||||
foreach ($exeFile in $exeFiles) {
|
||||
Write-Host "Processing $($exeFile.Name)..."
|
||||
|
||||
# Compute SHA512 hash and convert to base64 (electron-builder format)
|
||||
$bytes = [System.IO.File]::ReadAllBytes($exeFile.FullName)
|
||||
$sha512 = [System.Security.Cryptography.SHA512]::Create()
|
||||
$hashBytes = $sha512.ComputeHash($bytes)
|
||||
$hash = [System.Convert]::ToBase64String($hashBytes)
|
||||
$size = $exeFile.Length
|
||||
|
||||
Write-Host " Hash: $hash"
|
||||
Write-Host " Size: $size"
|
||||
}
|
||||
|
||||
# For electron-builder, latest.yml has a single file entry for the installer
|
||||
# Update the sha512 and size for the primary exe (first one, typically the installer)
|
||||
$primaryExe = $exeFiles | Select-Object -First 1
|
||||
$bytes = [System.IO.File]::ReadAllBytes($primaryExe.FullName)
|
||||
$sha512 = [System.Security.Cryptography.SHA512]::Create()
|
||||
$hashBytes = $sha512.ComputeHash($bytes)
|
||||
$hash = [System.Convert]::ToBase64String($hashBytes)
|
||||
$size = $primaryExe.Length
|
||||
|
||||
# Update sha512 hash (base64 pattern: alphanumeric, +, /, =)
|
||||
$content = $content -replace 'sha512: [A-Za-z0-9+/=]+', "sha512: $hash"
|
||||
# Update size
|
||||
$content = $content -replace 'size: \d+', "size: $size"
|
||||
|
||||
if ($content -eq $originalContent) {
|
||||
Write-Host "::error::Checksum replacement failed - content unchanged. Check if latest.yml format has changed."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Set-Content -Path $ymlFile -Value $content -NoNewline
|
||||
Write-Host "✅ Updated $ymlFile with new base64 hash and size for $($primaryExe.Name)"
|
||||
|
||||
- name: Skip signing notice
|
||||
if: env.AZURE_CLIENT_ID == ''
|
||||
run: echo "::warning::Windows signing skipped - AZURE_CLIENT_ID not configured. The .exe will be unsigned."
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -377,6 +515,10 @@ jobs:
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package Linux
|
||||
run: |
|
||||
@@ -384,6 +526,9 @@ jobs:
|
||||
cd apps/frontend && npm run package:linux -- --config.extraMetadata.version="$VERSION"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -1,227 +0,0 @@
|
||||
name: PR Auto Label
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: pr-auto-label-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
name: Auto Label PR
|
||||
runs-on: ubuntu-latest
|
||||
# Don't run on fork PRs (they can't write labels)
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Auto-label PR
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const pr = context.payload.pull_request;
|
||||
const prNumber = pr.number;
|
||||
const title = pr.title;
|
||||
|
||||
console.log(`::group::PR #${prNumber} - Auto-labeling`);
|
||||
console.log(`Title: ${title}`);
|
||||
|
||||
const labelsToAdd = new Set();
|
||||
const labelsToRemove = new Set();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// TYPE LABELS (from PR title - Conventional Commits)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
const typeMap = {
|
||||
'feat': 'feature',
|
||||
'fix': 'bug',
|
||||
'docs': 'documentation',
|
||||
'refactor': 'refactor',
|
||||
'test': 'test',
|
||||
'ci': 'ci',
|
||||
'chore': 'chore',
|
||||
'perf': 'performance',
|
||||
'style': 'style',
|
||||
'build': 'build'
|
||||
};
|
||||
|
||||
const typeMatch = title.match(/^(\w+)(\(.+?\))?(!)?:/);
|
||||
if (typeMatch) {
|
||||
const type = typeMatch[1].toLowerCase();
|
||||
const isBreaking = typeMatch[3] === '!';
|
||||
|
||||
if (typeMap[type]) {
|
||||
labelsToAdd.add(typeMap[type]);
|
||||
console.log(` 📝 Type: ${type} → ${typeMap[type]}`);
|
||||
}
|
||||
|
||||
if (isBreaking) {
|
||||
labelsToAdd.add('breaking-change');
|
||||
console.log(` ⚠️ Breaking change detected`);
|
||||
}
|
||||
} else {
|
||||
console.log(` ⚠️ No conventional commit prefix found in title`);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// AREA LABELS (from changed files)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
let files = [];
|
||||
try {
|
||||
const { data } = await github.rest.pulls.listFiles({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100
|
||||
});
|
||||
files = data;
|
||||
} catch (e) {
|
||||
console.log(` ⚠️ Could not fetch files: ${e.message}`);
|
||||
}
|
||||
|
||||
const areas = {
|
||||
frontend: false,
|
||||
backend: false,
|
||||
ci: false,
|
||||
docs: false,
|
||||
tests: false
|
||||
};
|
||||
|
||||
for (const file of files) {
|
||||
const path = file.filename;
|
||||
if (path.startsWith('apps/frontend/')) areas.frontend = true;
|
||||
if (path.startsWith('apps/backend/')) areas.backend = true;
|
||||
if (path.startsWith('.github/')) areas.ci = true;
|
||||
if (path.endsWith('.md') || path.startsWith('docs/')) areas.docs = true;
|
||||
if (path.startsWith('tests/') || path.includes('.test.') || path.includes('.spec.')) areas.tests = true;
|
||||
}
|
||||
|
||||
// Determine area label (mutually exclusive)
|
||||
const areaLabels = ['area/frontend', 'area/backend', 'area/fullstack', 'area/ci'];
|
||||
|
||||
if (areas.frontend && areas.backend) {
|
||||
labelsToAdd.add('area/fullstack');
|
||||
areaLabels.filter(l => l !== 'area/fullstack').forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📁 Area: fullstack (${files.length} files)`);
|
||||
} else if (areas.frontend) {
|
||||
labelsToAdd.add('area/frontend');
|
||||
areaLabels.filter(l => l !== 'area/frontend').forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📁 Area: frontend (${files.length} files)`);
|
||||
} else if (areas.backend) {
|
||||
labelsToAdd.add('area/backend');
|
||||
areaLabels.filter(l => l !== 'area/backend').forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📁 Area: backend (${files.length} files)`);
|
||||
} else if (areas.ci) {
|
||||
labelsToAdd.add('area/ci');
|
||||
areaLabels.filter(l => l !== 'area/ci').forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📁 Area: ci (${files.length} files)`);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SIZE LABELS (from lines changed)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
const additions = pr.additions || 0;
|
||||
const deletions = pr.deletions || 0;
|
||||
const totalLines = additions + deletions;
|
||||
|
||||
const sizeLabels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'];
|
||||
let sizeLabel;
|
||||
|
||||
if (totalLines < 10) sizeLabel = 'size/XS';
|
||||
else if (totalLines < 100) sizeLabel = 'size/S';
|
||||
else if (totalLines < 500) sizeLabel = 'size/M';
|
||||
else if (totalLines < 1000) sizeLabel = 'size/L';
|
||||
else sizeLabel = 'size/XL';
|
||||
|
||||
labelsToAdd.add(sizeLabel);
|
||||
sizeLabels.filter(l => l !== sizeLabel).forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📏 Size: ${sizeLabel} (+${additions}/-${deletions} = ${totalLines} lines)`);
|
||||
|
||||
console.log('::endgroup::');
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// APPLY LABELS
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
console.log(`::group::Applying labels`);
|
||||
|
||||
// Remove old labels (in parallel)
|
||||
const removeArray = [...labelsToRemove].filter(l => !labelsToAdd.has(l));
|
||||
if (removeArray.length > 0) {
|
||||
const removePromises = removeArray.map(async (label) => {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: label
|
||||
});
|
||||
console.log(` ✓ Removed: ${label}`);
|
||||
} catch (e) {
|
||||
if (e.status !== 404) {
|
||||
console.log(` ⚠ Could not remove ${label}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
await Promise.all(removePromises);
|
||||
}
|
||||
|
||||
// Add new labels
|
||||
const addArray = [...labelsToAdd];
|
||||
if (addArray.length > 0) {
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: addArray
|
||||
});
|
||||
console.log(` ✓ Added: ${addArray.join(', ')}`);
|
||||
} catch (e) {
|
||||
// Some labels might not exist
|
||||
if (e.status === 404) {
|
||||
core.warning(`Some labels do not exist. Please create them in repository settings.`);
|
||||
// Try adding one by one
|
||||
for (const label of addArray) {
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: [label]
|
||||
});
|
||||
} catch (e2) {
|
||||
console.log(` ⚠ Label '${label}' does not exist`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('::endgroup::');
|
||||
|
||||
// Summary
|
||||
console.log(`✅ PR #${prNumber} labeled: ${addArray.join(', ')}`);
|
||||
|
||||
// Write job summary
|
||||
core.summary
|
||||
.addHeading(`PR #${prNumber} Auto-Labels`, 3)
|
||||
.addTable([
|
||||
[{data: 'Category', header: true}, {data: 'Label', header: true}],
|
||||
['Type', typeMatch ? typeMap[typeMatch[1].toLowerCase()] || 'none' : 'none'],
|
||||
['Area', areas.frontend && areas.backend ? 'fullstack' : areas.frontend ? 'frontend' : areas.backend ? 'backend' : 'other'],
|
||||
['Size', sizeLabel]
|
||||
])
|
||||
.addRaw(`\n**Files changed:** ${files.length}\n`)
|
||||
.addRaw(`**Lines:** +${additions} / -${deletions}\n`);
|
||||
await core.summary.write();
|
||||
@@ -0,0 +1,320 @@
|
||||
name: PR Labeler
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
concurrency:
|
||||
group: pr-labeler-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
name: Auto Label PR
|
||||
runs-on: ubuntu-latest
|
||||
# Security: Prevent fork PRs from modifying labels (they don't have write access)
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Label PR
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CONFIGURATION - Single source of truth for all settings
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const CONFIG = {
|
||||
// Size thresholds (lines changed)
|
||||
SIZE_THRESHOLDS: {
|
||||
XS: 10,
|
||||
S: 100,
|
||||
M: 500,
|
||||
L: 1000
|
||||
},
|
||||
|
||||
// Conventional commit type mappings
|
||||
TYPE_MAP: Object.freeze({
|
||||
'feat': 'feature',
|
||||
'fix': 'bug',
|
||||
'docs': 'documentation',
|
||||
'refactor': 'refactor',
|
||||
'test': 'test',
|
||||
'ci': 'ci',
|
||||
'chore': 'chore',
|
||||
'perf': 'performance',
|
||||
'style': 'style',
|
||||
'build': 'build'
|
||||
}),
|
||||
|
||||
// Area detection paths
|
||||
AREA_PATHS: Object.freeze({
|
||||
frontend: 'apps/frontend/',
|
||||
backend: 'apps/backend/',
|
||||
ci: '.github/'
|
||||
}),
|
||||
|
||||
// Label definitions
|
||||
LABELS: Object.freeze({
|
||||
SIZE: ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'],
|
||||
AREA: ['area/frontend', 'area/backend', 'area/fullstack', 'area/ci'],
|
||||
STATUS: ['🔄 Checking', '✅ Ready for Review', '❌ Checks Failed'],
|
||||
REVIEW: ['Missing AC Approval', 'AC: Approved', 'AC: Changes Requested', 'AC: Needs Re-review']
|
||||
}),
|
||||
|
||||
// Pagination
|
||||
MAX_FILES_PER_PAGE: 100
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// HELPER FUNCTIONS - Small, focused, single responsibility
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Safely parse conventional commit type from PR title
|
||||
* @param {string} title - PR title
|
||||
* @returns {{type: string|null, isBreaking: boolean}}
|
||||
*/
|
||||
function parseConventionalCommit(title) {
|
||||
if (!title || typeof title !== 'string') {
|
||||
return { type: null, isBreaking: false };
|
||||
}
|
||||
|
||||
// Limit input length to prevent ReDoS attacks
|
||||
const safeTitle = title.slice(0, 200);
|
||||
const match = safeTitle.match(/^(\w{1,20})(\([^)]{0,50}\))?(!)?:/);
|
||||
|
||||
if (!match) {
|
||||
return { type: null, isBreaking: false };
|
||||
}
|
||||
|
||||
return {
|
||||
type: match[1].toLowerCase(),
|
||||
isBreaking: match[3] === '!'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine size label based on lines changed
|
||||
* @param {number} totalLines - Total lines changed
|
||||
* @returns {string} Size label
|
||||
*/
|
||||
function determineSizeLabel(totalLines) {
|
||||
const { SIZE_THRESHOLDS } = CONFIG;
|
||||
|
||||
if (totalLines < SIZE_THRESHOLDS.XS) return 'size/XS';
|
||||
if (totalLines < SIZE_THRESHOLDS.S) return 'size/S';
|
||||
if (totalLines < SIZE_THRESHOLDS.M) return 'size/M';
|
||||
if (totalLines < SIZE_THRESHOLDS.L) return 'size/L';
|
||||
return 'size/XL';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect areas affected by file changes
|
||||
* @param {Array} files - List of changed files
|
||||
* @returns {{frontend: boolean, backend: boolean, ci: boolean}}
|
||||
*/
|
||||
function detectAreas(files) {
|
||||
const areas = { frontend: false, backend: false, ci: false };
|
||||
const { AREA_PATHS } = CONFIG;
|
||||
|
||||
for (const file of files) {
|
||||
const path = file.filename || '';
|
||||
if (path.startsWith(AREA_PATHS.frontend)) areas.frontend = true;
|
||||
if (path.startsWith(AREA_PATHS.backend)) areas.backend = true;
|
||||
if (path.startsWith(AREA_PATHS.ci)) areas.ci = true;
|
||||
}
|
||||
|
||||
return areas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine area label based on detected areas
|
||||
* @param {{frontend: boolean, backend: boolean, ci: boolean}} areas
|
||||
* @returns {string|null} Area label or null
|
||||
*/
|
||||
function determineAreaLabel(areas) {
|
||||
if (areas.frontend && areas.backend) return 'area/fullstack';
|
||||
if (areas.frontend) return 'area/frontend';
|
||||
if (areas.backend) return 'area/backend';
|
||||
if (areas.ci) return 'area/ci';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove labels from PR (with error handling)
|
||||
* @param {Array} labels - Labels to remove
|
||||
* @param {number} prNumber - PR number
|
||||
*/
|
||||
async function removeLabels(labels, prNumber) {
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
await Promise.allSettled(labels.map(async (label) => {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: label
|
||||
});
|
||||
console.log(` ✓ Removed: ${label}`);
|
||||
} catch (e) {
|
||||
// 404 means label wasn't present - that's fine
|
||||
if (e.status !== 404) {
|
||||
console.log(` ⚠ Failed to remove ${label}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add labels to PR (with error handling)
|
||||
* @param {Array} labels - Labels to add
|
||||
* @param {number} prNumber - PR number
|
||||
*/
|
||||
async function addLabels(labels, prNumber) {
|
||||
if (labels.length === 0) return;
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels
|
||||
});
|
||||
console.log(` ✓ Added: ${labels.join(', ')}`);
|
||||
} catch (e) {
|
||||
if (e.status === 404) {
|
||||
core.warning(`One or more labels do not exist. Create them in repository settings.`);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch PR files with full pagination support
|
||||
* @param {number} prNumber - PR number
|
||||
* @returns {Array} List of all files (paginated)
|
||||
*/
|
||||
async function fetchPRFiles(prNumber) {
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
try {
|
||||
// Use paginate to fetch ALL files, not just first 100
|
||||
const files = await github.paginate(
|
||||
github.rest.pulls.listFiles,
|
||||
{ owner, repo, pull_number: prNumber, per_page: CONFIG.MAX_FILES_PER_PAGE }
|
||||
);
|
||||
return files;
|
||||
} catch (e) {
|
||||
console.log(` ⚠ Could not fetch files: ${e.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// MAIN LOGIC - Orchestrates the labeling process
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const pr = context.payload.pull_request;
|
||||
const prNumber = pr.number;
|
||||
const title = pr.title || '';
|
||||
const isNewPR = context.payload.action === 'opened' || context.payload.action === 'reopened';
|
||||
|
||||
console.log(`::group::PR #${prNumber} - Auto-labeling`);
|
||||
console.log(`Title: ${title.slice(0, 100)}${title.length > 100 ? '...' : ''}`);
|
||||
console.log(`Action: ${context.payload.action}`);
|
||||
|
||||
const labelsToAdd = new Set();
|
||||
const labelsToRemove = new Set();
|
||||
|
||||
// 1. Parse conventional commit type
|
||||
const { type, isBreaking } = parseConventionalCommit(title);
|
||||
if (type && CONFIG.TYPE_MAP[type]) {
|
||||
labelsToAdd.add(CONFIG.TYPE_MAP[type]);
|
||||
console.log(` 📝 Type: ${type} → ${CONFIG.TYPE_MAP[type]}`);
|
||||
} else {
|
||||
console.log(` ℹ️ No conventional commit prefix detected`);
|
||||
}
|
||||
|
||||
if (isBreaking) {
|
||||
labelsToAdd.add('breaking-change');
|
||||
console.log(` ⚠️ Breaking change detected`);
|
||||
}
|
||||
|
||||
// 2. Detect areas from changed files
|
||||
const files = await fetchPRFiles(prNumber);
|
||||
const areas = detectAreas(files);
|
||||
const areaLabel = determineAreaLabel(areas);
|
||||
|
||||
if (areaLabel) {
|
||||
labelsToAdd.add(areaLabel);
|
||||
CONFIG.LABELS.AREA.filter(l => l !== areaLabel).forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📁 Area: ${areaLabel.replace('area/', '')}`);
|
||||
}
|
||||
|
||||
// 3. Calculate size label
|
||||
const totalLines = (pr.additions || 0) + (pr.deletions || 0);
|
||||
const sizeLabel = determineSizeLabel(totalLines);
|
||||
labelsToAdd.add(sizeLabel);
|
||||
CONFIG.LABELS.SIZE.filter(l => l !== sizeLabel).forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📏 Size: ${sizeLabel} (${totalLines} lines)`);
|
||||
|
||||
// 4. Set status label (only on new PRs - let pr-status-gate handle updates on pushes)
|
||||
// Note: On synchronize events, CI workflows will trigger pr-status-gate when they complete
|
||||
if (isNewPR) {
|
||||
labelsToAdd.add('🔄 Checking');
|
||||
CONFIG.LABELS.STATUS.filter(l => l !== '🔄 Checking').forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 🔄 Status: Checking`);
|
||||
} else {
|
||||
console.log(` ℹ️ Status: Unchanged (will be updated by pr-status-gate)`);
|
||||
}
|
||||
|
||||
// 5. Add review label for new PRs only
|
||||
if (isNewPR) {
|
||||
labelsToAdd.add('Missing AC Approval');
|
||||
console.log(` ⏳ Review: Missing AC Approval`);
|
||||
}
|
||||
|
||||
console.log('::endgroup::');
|
||||
|
||||
// 6. Apply label changes
|
||||
console.log(`::group::Applying labels`);
|
||||
|
||||
// Remove labels that should be replaced (exclude ones we're adding)
|
||||
const removeList = [...labelsToRemove].filter(l => !labelsToAdd.has(l));
|
||||
await removeLabels(removeList, prNumber);
|
||||
|
||||
// Add new labels
|
||||
await addLabels([...labelsToAdd], prNumber);
|
||||
|
||||
console.log('::endgroup::');
|
||||
console.log(`✅ PR #${prNumber} labeled successfully`);
|
||||
|
||||
// 7. Write job summary
|
||||
const summaryType = type ? CONFIG.TYPE_MAP[type] || 'unknown' : 'none';
|
||||
const summaryArea = areaLabel ? areaLabel.replace('area/', '') : 'other';
|
||||
|
||||
await core.summary
|
||||
.addHeading(`PR #${prNumber} Auto-Labels`, 3)
|
||||
.addTable([
|
||||
[{ data: 'Category', header: true }, { data: 'Label', header: true }],
|
||||
['Type', summaryType],
|
||||
['Area', summaryArea],
|
||||
['Size', sizeLabel],
|
||||
['Status', isNewPR ? '🔄 Checking' : '(unchanged)'],
|
||||
['Review', isNewPR ? 'Missing AC Approval' : '(unchanged)']
|
||||
])
|
||||
.addRaw(`\n**Files:** ${files.length} | **Lines:** +${pr.additions || 0} / -${pr.deletions || 0}\n`)
|
||||
.write();
|
||||
@@ -1,72 +0,0 @@
|
||||
name: PR Status Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: pr-status-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
mark-checking:
|
||||
name: Set Checking Status
|
||||
runs-on: ubuntu-latest
|
||||
# Don't run on fork PRs (they can't write labels)
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Update PR status label
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const statusLabels = ['🔄 Checking', '✅ Ready for Review', '❌ Checks Failed'];
|
||||
|
||||
console.log(`::group::PR #${prNumber} - Setting status to Checking`);
|
||||
|
||||
// Remove old status labels (parallel for speed)
|
||||
const removePromises = statusLabels.map(async (label) => {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: label
|
||||
});
|
||||
console.log(` ✓ Removed: ${label}`);
|
||||
} catch (e) {
|
||||
if (e.status !== 404) {
|
||||
console.log(` ⚠ Could not remove ${label}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(removePromises);
|
||||
|
||||
// Add checking label
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: ['🔄 Checking']
|
||||
});
|
||||
console.log(` ✓ Added: 🔄 Checking`);
|
||||
} catch (e) {
|
||||
// Label might not exist - create helpful error
|
||||
if (e.status === 404) {
|
||||
core.warning(`Label '🔄 Checking' does not exist. Please create it in repository settings.`);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log('::endgroup::');
|
||||
console.log(`✅ PR #${prNumber} marked as checking`);
|
||||
@@ -5,187 +5,581 @@ on:
|
||||
workflows: [CI, Lint, Quality Security]
|
||||
types: [completed]
|
||||
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
|
||||
pull_request:
|
||||
types: [synchronize]
|
||||
|
||||
concurrency:
|
||||
group: pr-status-gate-${{ github.event.workflow_run.pull_requests[0].number || github.event.issue.number || github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
checks: read
|
||||
|
||||
env:
|
||||
# Shared configuration - single source of truth
|
||||
REQUIRED_CHECKS: |
|
||||
CI / test-frontend
|
||||
CI / test-python (3.12)
|
||||
CI / test-python (3.13)
|
||||
Lint / python
|
||||
Quality Security / CodeQL (javascript-typescript)
|
||||
Quality Security / CodeQL (python)
|
||||
Quality Security / Python Security (Bandit)
|
||||
Quality Security / Security Summary
|
||||
|
||||
jobs:
|
||||
update-status:
|
||||
name: Update PR Status
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# JOB 1: CI STATUS (triggered by workflow_run)
|
||||
# Updates CI status labels when monitored workflows complete
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
update-ci-status:
|
||||
name: Update CI Status
|
||||
runs-on: ubuntu-latest
|
||||
# Only run if this workflow_run is associated with a PR
|
||||
if: github.event.workflow_run.pull_requests[0] != null
|
||||
if: github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0] != null
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Check all required checks and update label
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
REQUIRED_CHECKS: ${{ env.REQUIRED_CHECKS }}
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
// NOTE: STATUS_LABELS is intentionally duplicated across jobs.
|
||||
// GitHub Actions jobs run in isolated contexts and cannot share runtime constants.
|
||||
// If label values change, update ALL occurrences: update-ci-status, check-status-command
|
||||
const STATUS_LABELS = Object.freeze({
|
||||
CHECKING: '🔄 Checking',
|
||||
PASSED: '✅ Ready for Review',
|
||||
FAILED: '❌ Checks Failed'
|
||||
});
|
||||
|
||||
const REQUIRED_CHECKS = process.env.REQUIRED_CHECKS
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
async function fetchCheckRuns(sha) {
|
||||
const { owner, repo } = context.repo;
|
||||
// Let the configured retries (retries: 3) handle transient failures
|
||||
// Don't catch errors - allow them to propagate for retry logic
|
||||
const checkRuns = await github.paginate(
|
||||
github.rest.checks.listForRef,
|
||||
{ owner, repo, ref: sha, per_page: 100 },
|
||||
(response) => response.data
|
||||
);
|
||||
return checkRuns;
|
||||
}
|
||||
|
||||
function analyzeChecks(checkRuns) {
|
||||
const results = [];
|
||||
let allComplete = true;
|
||||
let anyFailed = false;
|
||||
|
||||
for (const checkName of REQUIRED_CHECKS) {
|
||||
const check = checkRuns.find(c => c.name === checkName);
|
||||
|
||||
if (!check) {
|
||||
results.push({ name: checkName, status: '⏳ Pending', complete: false });
|
||||
allComplete = false;
|
||||
} else if (check.status !== 'completed') {
|
||||
results.push({ name: checkName, status: '🔄 Running', complete: false });
|
||||
allComplete = false;
|
||||
} else if (check.conclusion === 'success') {
|
||||
results.push({ name: checkName, status: '✅ Passed', complete: true });
|
||||
} else if (check.conclusion === 'skipped') {
|
||||
results.push({ name: checkName, status: '⏭️ Skipped', complete: true, skipped: true });
|
||||
} else {
|
||||
results.push({ name: checkName, status: '❌ Failed', complete: true, failed: true });
|
||||
anyFailed = true;
|
||||
}
|
||||
}
|
||||
return { allComplete, anyFailed, results };
|
||||
}
|
||||
|
||||
async function updateStatusLabels(prNumber, newLabel) {
|
||||
const { owner, repo } = context.repo;
|
||||
const allLabels = Object.values(STATUS_LABELS);
|
||||
|
||||
// Remove all status labels first - throw on non-404 errors to prevent conflicting labels
|
||||
for (const label of allLabels) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label });
|
||||
} catch (e) {
|
||||
if (e && e.status !== 404) {
|
||||
// Throw to prevent adding new label if removal failed (could cause conflicting labels)
|
||||
throw new Error(`Failed to remove label '${label}': ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [newLabel] });
|
||||
} catch (e) {
|
||||
if (e && e.status === 404) {
|
||||
core.warning(`Label '${newLabel}' does not exist`);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main logic
|
||||
const prNumber = context.payload.workflow_run.pull_requests[0].number;
|
||||
const headSha = context.payload.workflow_run.head_sha;
|
||||
const triggerWorkflow = context.payload.workflow_run.name;
|
||||
|
||||
console.log(`PR #${prNumber} - Triggered by: ${triggerWorkflow}, SHA: ${headSha.slice(0, 8)}`);
|
||||
|
||||
const checkRuns = await fetchCheckRuns(headSha);
|
||||
console.log(`Found ${checkRuns.length} check runs`);
|
||||
const { allComplete, anyFailed, results } = analyzeChecks(checkRuns);
|
||||
|
||||
for (const r of results) {
|
||||
console.log(` ${r.status} ${r.name}`);
|
||||
}
|
||||
|
||||
if (!allComplete) {
|
||||
const pending = results.filter(r => !r.complete).length;
|
||||
console.log(`⏳ ${pending}/${REQUIRED_CHECKS.length} checks pending`);
|
||||
// Update to CHECKING status if checks are still running (prevents stale Ready/Failed status)
|
||||
await updateStatusLabels(prNumber, STATUS_LABELS.CHECKING);
|
||||
return;
|
||||
}
|
||||
|
||||
const newLabel = anyFailed ? STATUS_LABELS.FAILED : STATUS_LABELS.PASSED;
|
||||
await updateStatusLabels(prNumber, newLabel);
|
||||
|
||||
const passedCount = results.filter(r => r.status === '✅ Passed').length;
|
||||
const failedCount = results.filter(r => r.failed).length;
|
||||
|
||||
if (anyFailed) {
|
||||
console.log(`❌ PR #${prNumber}: ${failedCount} check(s) failed`);
|
||||
} else {
|
||||
console.log(`✅ PR #${prNumber}: Ready for review (${passedCount}/${REQUIRED_CHECKS.length} passed)`);
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# JOB 2: /check-status COMMAND
|
||||
# Manual status check - anyone can trigger by commenting /check-status
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
check-status-command:
|
||||
name: Check Status Command
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '/check-status')
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Run status check and post report
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
REQUIRED_CHECKS: ${{ env.REQUIRED_CHECKS }}
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
// NOTE: STATUS_LABELS is intentionally duplicated across jobs.
|
||||
// GitHub Actions jobs run in isolated contexts and cannot share runtime constants.
|
||||
// If label values change, update ALL occurrences: update-ci-status, check-status-command
|
||||
const STATUS_LABELS = Object.freeze({
|
||||
CHECKING: '🔄 Checking',
|
||||
PASSED: '✅ Ready for Review',
|
||||
FAILED: '❌ Checks Failed'
|
||||
});
|
||||
|
||||
// NOTE: REVIEW_LABELS is intentionally duplicated across jobs.
|
||||
// If label values change, update ALL occurrences: check-status-command, update-review-status
|
||||
const REVIEW_LABELS = Object.freeze([
|
||||
'Missing AC Approval',
|
||||
'AC: Approved',
|
||||
'AC: Changes Requested',
|
||||
'AC: Blocked',
|
||||
'AC: Needs Re-review',
|
||||
'AC: Reviewed'
|
||||
]);
|
||||
|
||||
const REQUIRED_CHECKS = process.env.REQUIRED_CHECKS
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.issue.number;
|
||||
const requestedBy = context.payload.comment.user.login;
|
||||
|
||||
// Get PR details
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner, repo, pull_number: prNumber
|
||||
});
|
||||
const headSha = pr.head.sha;
|
||||
|
||||
console.log(`PR #${prNumber} - /check-status by @${requestedBy}, SHA: ${headSha.slice(0, 8)}`);
|
||||
|
||||
// Fetch check runs with pagination to handle >100 checks
|
||||
const checkRuns = await github.paginate(
|
||||
github.rest.checks.listForRef,
|
||||
{ owner, repo, ref: headSha, per_page: 100 },
|
||||
(response) => response.data
|
||||
);
|
||||
console.log(`Found ${checkRuns.length} check runs`);
|
||||
|
||||
// Analyze results
|
||||
const results = [];
|
||||
let allComplete = true;
|
||||
let anyFailed = false;
|
||||
|
||||
for (const checkName of REQUIRED_CHECKS) {
|
||||
const check = checkRuns.find(c => c.name === checkName);
|
||||
|
||||
if (!check) {
|
||||
results.push({ name: checkName, emoji: '⏳', complete: false });
|
||||
allComplete = false;
|
||||
} else if (check.status !== 'completed') {
|
||||
results.push({ name: checkName, emoji: '🔄', complete: false });
|
||||
allComplete = false;
|
||||
} else if (check.conclusion === 'success') {
|
||||
results.push({ name: checkName, emoji: '✅', complete: true });
|
||||
} else if (check.conclusion === 'skipped') {
|
||||
results.push({ name: checkName, emoji: '⏭️', complete: true, skipped: true });
|
||||
} else {
|
||||
results.push({ name: checkName, emoji: '❌', complete: true, failed: true });
|
||||
anyFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Get current labels
|
||||
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner, repo, issue_number: prNumber
|
||||
});
|
||||
const labelNames = currentLabels.map(l => l.name);
|
||||
const currentStatusLabel = Object.values(STATUS_LABELS).find(l => labelNames.includes(l)) || 'None';
|
||||
const currentReviewLabel = REVIEW_LABELS.find(l => labelNames.includes(l)) || 'None';
|
||||
|
||||
// Update label if all checks complete
|
||||
let newStatusLabel = STATUS_LABELS.CHECKING;
|
||||
let statusChanged = false;
|
||||
|
||||
if (allComplete) {
|
||||
newStatusLabel = anyFailed ? STATUS_LABELS.FAILED : STATUS_LABELS.PASSED;
|
||||
|
||||
if (newStatusLabel !== currentStatusLabel) {
|
||||
statusChanged = true;
|
||||
// Remove all status labels first - throw on non-404 errors to prevent conflicting labels
|
||||
for (const label of Object.values(STATUS_LABELS)) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label });
|
||||
} catch (e) {
|
||||
if (e && e.status !== 404) {
|
||||
throw new Error(`Failed to remove label '${label}': ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [newStatusLabel] });
|
||||
}
|
||||
}
|
||||
|
||||
// Build status report
|
||||
const passedCount = results.filter(r => r.emoji === '✅').length;
|
||||
let statusEmoji = '🔄';
|
||||
if (allComplete && !anyFailed) statusEmoji = '✅';
|
||||
else if (allComplete && anyFailed) statusEmoji = '❌';
|
||||
|
||||
const checksTable = results.map(r => `| ${r.emoji} | ${r.name} |`).join('\n');
|
||||
|
||||
const lines = [
|
||||
`## ${statusEmoji} PR Status Report`,
|
||||
'',
|
||||
`| Label | Value |`,
|
||||
`|-------|-------|`,
|
||||
`| CI Status | ${newStatusLabel} |`,
|
||||
`| AC Review | ${currentReviewLabel} |`,
|
||||
''
|
||||
];
|
||||
|
||||
if (statusChanged) {
|
||||
lines.push(`> Status updated: \`${currentStatusLabel}\` → \`${newStatusLabel}\``);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push(`### CI Checks (${passedCount}/${REQUIRED_CHECKS.length} passed)`);
|
||||
lines.push('');
|
||||
lines.push('| Status | Check |');
|
||||
lines.push('|--------|-------|');
|
||||
lines.push(checksTable);
|
||||
lines.push('');
|
||||
lines.push('---');
|
||||
lines.push(`<sub>Triggered by \`/check-status\` from @${requestedBy}</sub>`);
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber, body: lines.join('\n')
|
||||
});
|
||||
|
||||
console.log(`✅ Posted status report to PR #${prNumber}`);
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# JOB 3: AUTO-CLAUDE REVIEW
|
||||
# Processes Auto-Claude review comments from trusted sources
|
||||
# Security: Only bots and collaborators can update labels
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
update-review-status:
|
||||
name: Update Review Status
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
!contains(github.event.comment.body, '/check-status')
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Check for Auto-Claude review
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
// Security configuration
|
||||
// SECURITY: Only [bot] suffixed accounts are protected by GitHub.
|
||||
// Regular usernames can be registered by anyone and are NOT trusted.
|
||||
const TRUSTED_BOT_ACCOUNTS = Object.freeze([
|
||||
'github-actions[bot]',
|
||||
'auto-claude[bot]'
|
||||
]);
|
||||
|
||||
const TRUSTED_AUTHOR_ASSOCIATIONS = Object.freeze([
|
||||
'COLLABORATOR',
|
||||
'MEMBER',
|
||||
'OWNER'
|
||||
]);
|
||||
|
||||
const IDENTIFIER_PATTERNS = Object.freeze([
|
||||
'🤖 Auto Claude PR Review',
|
||||
'Auto Claude Review',
|
||||
'Auto-Claude Review'
|
||||
]);
|
||||
|
||||
// SECURITY: Regex patterns are tightened to prevent false matches
|
||||
// Using \s* instead of .* and requiring specific emoji + verdict format
|
||||
const VERDICTS = Object.freeze({
|
||||
APPROVED: {
|
||||
patterns: ['Auto Claude Review - APPROVED', '✅ Auto Claude Review - APPROVED'],
|
||||
// Match: "Merge Verdict:" followed by whitespace/emoji, then ✅, then APPROVED/READY TO MERGE
|
||||
regex: /Merge Verdict:\s*✅\s*(?:APPROVED|READY TO MERGE)/i,
|
||||
label: 'AC: Approved'
|
||||
},
|
||||
CHANGES_REQUESTED: {
|
||||
patterns: ['NEEDS REVISION', 'Needs Revision'],
|
||||
// Match: "Merge Verdict:" followed by whitespace/emoji, then 🟠
|
||||
regex: /Merge Verdict:\s*🟠/,
|
||||
label: 'AC: Changes Requested'
|
||||
},
|
||||
BLOCKED: {
|
||||
patterns: ['BLOCKED'],
|
||||
// Match: "Merge Verdict:" followed by whitespace/emoji, then 🔴
|
||||
regex: /Merge Verdict:\s*🔴/,
|
||||
label: 'AC: Blocked'
|
||||
}
|
||||
});
|
||||
|
||||
// NOTE: REVIEW_LABELS is intentionally duplicated across jobs.
|
||||
// GitHub Actions jobs run in isolated contexts and cannot share runtime constants.
|
||||
// If label values change, update ALL occurrences: check-status-command, update-review-status
|
||||
const REVIEW_LABELS = Object.freeze([
|
||||
'Missing AC Approval',
|
||||
'AC: Approved',
|
||||
'AC: Changes Requested',
|
||||
'AC: Blocked',
|
||||
'AC: Needs Re-review',
|
||||
'AC: Reviewed'
|
||||
]);
|
||||
|
||||
// Helper functions
|
||||
// SECURITY: Verify both username AND account type to prevent spoofing
|
||||
function isTrustedBot(username, userType) {
|
||||
const isKnownBot = TRUSTED_BOT_ACCOUNTS.some(t => username.toLowerCase() === t.toLowerCase());
|
||||
// Only trust if it's a known bot account AND GitHub confirms it's a Bot type
|
||||
return isKnownBot && userType === 'Bot';
|
||||
}
|
||||
|
||||
function isTrustedAssociation(assoc) {
|
||||
return TRUSTED_AUTHOR_ASSOCIATIONS.includes(assoc);
|
||||
}
|
||||
|
||||
function isAutoClaudeComment(body) {
|
||||
return IDENTIFIER_PATTERNS.some(p => body.includes(p));
|
||||
}
|
||||
|
||||
function parseVerdict(body) {
|
||||
const safeBody = body.slice(0, 5000);
|
||||
for (const [key, config] of Object.entries(VERDICTS)) {
|
||||
const patternMatch = config.patterns.some(p => safeBody.includes(p));
|
||||
const regexMatch = config.regex && config.regex.test(safeBody);
|
||||
if (patternMatch || regexMatch) {
|
||||
return { verdict: key, label: config.label };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function updateReviewLabels(prNumber, newLabel) {
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
// Remove all review labels first - throw on non-404 errors to prevent conflicting labels
|
||||
for (const label of REVIEW_LABELS) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label });
|
||||
console.log(` Removed: ${label}`);
|
||||
} catch (e) {
|
||||
if (e && e.status !== 404) {
|
||||
// Throw to prevent adding new label if removal failed (could cause conflicting labels)
|
||||
throw new Error(`Failed to remove label '${label}': ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [newLabel] });
|
||||
console.log(` Added: ${newLabel}`);
|
||||
} catch (e) {
|
||||
if (e && e.status === 404) {
|
||||
core.warning(`Label '${newLabel}' does not exist`);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main logic
|
||||
const prNumber = context.payload.issue.number;
|
||||
const comment = context.payload.comment;
|
||||
const commenter = comment.user.login;
|
||||
const commenterType = comment.user.type;
|
||||
const authorAssociation = comment.author_association;
|
||||
const body = comment.body || '';
|
||||
|
||||
console.log(`PR #${prNumber} - Comment by: ${commenter} (type: ${commenterType}, assoc: ${authorAssociation})`);
|
||||
|
||||
// Security checks
|
||||
// SECURITY: Bot status requires BOTH username match AND verified Bot type
|
||||
const isBot = isTrustedBot(commenter, commenterType);
|
||||
const isCollaborator = isTrustedAssociation(authorAssociation);
|
||||
const isACComment = isAutoClaudeComment(body);
|
||||
|
||||
console.log(` Trusted bot: ${isBot}, Collaborator: ${isCollaborator}, AC comment: ${isACComment}`);
|
||||
|
||||
if (!isBot && !isCollaborator) {
|
||||
console.log('Skipping: Not a trusted bot or collaborator');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isACComment) {
|
||||
console.log('Skipping: Not an Auto-Claude comment');
|
||||
return;
|
||||
}
|
||||
|
||||
const verdictResult = parseVerdict(body);
|
||||
if (!verdictResult) {
|
||||
console.log('Skipping: Could not parse verdict');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Verdict: ${verdictResult.verdict} → ${verdictResult.label}`);
|
||||
await updateReviewLabels(prNumber, verdictResult.label);
|
||||
console.log(`✅ PR #${prNumber} review status updated`);
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# JOB 4: RE-REVIEW ON PUSH
|
||||
# When new commits pushed after AC approval, require re-review
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
require-re-review:
|
||||
name: Require Re-review on Push
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && github.event.action == 'synchronize'
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Check and reset AC approval if needed
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.workflow_run.pull_requests[0].number;
|
||||
const headSha = context.payload.workflow_run.head_sha;
|
||||
const triggerWorkflow = context.payload.workflow_run.name;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const pusher = context.payload.sender.login;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// REQUIRED CHECK RUNS - Job-level checks (not workflow-level)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Format: "{Workflow Name} / {Job Name}" or "{Workflow Name} / {Job Custom Name}"
|
||||
//
|
||||
// To find check names: Go to PR → Checks tab → copy exact name
|
||||
// To update: Edit this list when workflow jobs are added/renamed/removed
|
||||
//
|
||||
// Last validated: 2026-01-02
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
const requiredChecks = [
|
||||
// CI workflow (ci.yml) - 3 checks
|
||||
'CI / test-frontend',
|
||||
'CI / test-python (3.12)',
|
||||
'CI / test-python (3.13)',
|
||||
// Lint workflow (lint.yml) - 1 check
|
||||
'Lint / python',
|
||||
// Quality Security workflow (quality-security.yml) - 4 checks
|
||||
'Quality Security / CodeQL (javascript-typescript)',
|
||||
'Quality Security / CodeQL (python)',
|
||||
'Quality Security / Python Security (Bandit)',
|
||||
'Quality Security / Security Summary'
|
||||
];
|
||||
console.log(`PR #${prNumber} - New commits by: ${pusher}`);
|
||||
|
||||
const statusLabels = {
|
||||
checking: '🔄 Checking',
|
||||
passed: '✅ Ready for Review',
|
||||
failed: '❌ Checks Failed'
|
||||
};
|
||||
// Get current labels
|
||||
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner, repo, issue_number: prNumber
|
||||
});
|
||||
const labelNames = labels.map(l => l.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('');
|
||||
// Check if PR was approved
|
||||
const wasApproved = labelNames.includes('AC: Approved');
|
||||
|
||||
// Fetch all check runs for this commit
|
||||
let allCheckRuns = [];
|
||||
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 {
|
||||
const { data } = await github.rest.checks.listForRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: headSha,
|
||||
per_page: 100
|
||||
await github.rest.issues.removeLabel({
|
||||
owner, repo, issue_number: prNumber, name: 'AC: Approved'
|
||||
});
|
||||
allCheckRuns = data.check_runs;
|
||||
console.log(`Found ${allCheckRuns.length} total check runs`);
|
||||
} catch (error) {
|
||||
// Add warning annotation so maintainers are alerted
|
||||
core.warning(`Failed to fetch check runs for PR #${prNumber}: ${error.message}. PR label may be outdated.`);
|
||||
console.log(`::error::Failed to fetch check runs: ${error.message}`);
|
||||
console.log('::endgroup::');
|
||||
return;
|
||||
}
|
||||
|
||||
let allComplete = true;
|
||||
let anyFailed = false;
|
||||
const results = [];
|
||||
|
||||
// Check each required check
|
||||
for (const checkName of requiredChecks) {
|
||||
const check = allCheckRuns.find(c => c.name === checkName);
|
||||
|
||||
if (!check) {
|
||||
results.push({ name: checkName, status: '⏳ Pending', complete: false });
|
||||
allComplete = false;
|
||||
} else if (check.status !== 'completed') {
|
||||
results.push({ name: checkName, status: '🔄 Running', complete: false });
|
||||
allComplete = false;
|
||||
} else if (check.conclusion === 'success') {
|
||||
results.push({ name: checkName, status: '✅ Passed', complete: true });
|
||||
} else if (check.conclusion === 'skipped') {
|
||||
// Skipped checks are treated as passed (e.g., path filters, conditional jobs)
|
||||
results.push({ name: checkName, status: '⏭️ Skipped', complete: true, skipped: true });
|
||||
} else {
|
||||
results.push({ name: checkName, status: '❌ Failed', complete: true, failed: true });
|
||||
anyFailed = true;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Print results table
|
||||
console.log('');
|
||||
console.log('Check Status:');
|
||||
console.log('─'.repeat(70));
|
||||
for (const r of results) {
|
||||
const shortName = r.name.length > 55 ? r.name.substring(0, 52) + '...' : r.name;
|
||||
console.log(` ${r.status.padEnd(12)} ${shortName}`);
|
||||
}
|
||||
console.log('─'.repeat(70));
|
||||
console.log('::endgroup::');
|
||||
|
||||
// Only update label if all required checks are complete
|
||||
if (!allComplete) {
|
||||
const pending = results.filter(r => !r.complete).length;
|
||||
console.log(`⏳ ${pending}/${requiredChecks.length} checks still pending - keeping current label`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine final label
|
||||
const newLabel = anyFailed ? statusLabels.failed : statusLabels.passed;
|
||||
|
||||
console.log(`::group::Updating PR #${prNumber} label`);
|
||||
|
||||
// Remove old status labels
|
||||
for (const label of Object.values(statusLabels)) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: label
|
||||
});
|
||||
console.log(` ✓ Removed: ${label}`);
|
||||
} catch (e) {
|
||||
if (e.status !== 404) {
|
||||
console.log(` ⚠ Could not remove ${label}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add final status label
|
||||
// Add AC: Needs Re-review
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: [newLabel]
|
||||
owner, repo, issue_number: prNumber, labels: ['AC: Needs Re-review']
|
||||
});
|
||||
console.log(` ✓ Added: ${newLabel}`);
|
||||
console.log(' Added: AC: Needs Re-review');
|
||||
} catch (e) {
|
||||
if (e.status === 404) {
|
||||
core.warning(`Label '${newLabel}' does not exist. Please create it in repository settings.`);
|
||||
if (e && e.status === 404) {
|
||||
core.warning("Label 'AC: Needs Re-review' does not exist");
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log('::endgroup::');
|
||||
// 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>`
|
||||
];
|
||||
|
||||
// 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;
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber, body: commentLines.join('\n')
|
||||
});
|
||||
|
||||
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();
|
||||
console.log(`✅ Posted re-review notification to PR #${prNumber}`);
|
||||
|
||||
@@ -64,6 +64,10 @@ jobs:
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package macOS (Intel)
|
||||
run: cd apps/frontend && npm run package:mac -- --x64
|
||||
@@ -71,6 +75,9 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Notarize macOS Intel app
|
||||
env:
|
||||
@@ -151,6 +158,10 @@ jobs:
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package macOS (Apple Silicon)
|
||||
run: cd apps/frontend && npm run package:mac -- --arm64
|
||||
@@ -158,6 +169,9 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Notarize macOS ARM64 app
|
||||
env:
|
||||
@@ -193,6 +207,12 @@ jobs:
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
id-token: write # Required for OIDC authentication with Azure
|
||||
contents: read
|
||||
env:
|
||||
# Job-level env so AZURE_CLIENT_ID is available for step-level if conditions
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -238,13 +258,131 @@ jobs:
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package Windows
|
||||
run: cd apps/frontend && npm run package:win
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.WIN_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTIFICATE_PASSWORD }}
|
||||
# Disable electron-builder's built-in signing (we use Azure Trusted Signing instead)
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: false
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Azure Login (OIDC)
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Sign Windows executable with Azure Trusted Signing
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
uses: azure/trusted-signing-action@v0.5.11
|
||||
with:
|
||||
endpoint: https://neu.codesigning.azure.net/
|
||||
trusted-signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
certificate-profile-name: ${{ secrets.AZURE_CERTIFICATE_PROFILE }}
|
||||
files-folder: apps/frontend/dist
|
||||
files-folder-filter: exe
|
||||
file-digest: SHA256
|
||||
timestamp-rfc3161: http://timestamp.acs.microsoft.com
|
||||
timestamp-digest: SHA256
|
||||
|
||||
- name: Verify Windows executable is signed
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
cd apps/frontend/dist
|
||||
$exeFile = Get-ChildItem -Filter "*.exe" | Select-Object -First 1
|
||||
if ($exeFile) {
|
||||
Write-Host "Verifying signature on $($exeFile.Name)..."
|
||||
$sig = Get-AuthenticodeSignature -FilePath $exeFile.FullName
|
||||
if ($sig.Status -ne 'Valid') {
|
||||
Write-Host "::error::Signature verification failed: $($sig.Status)"
|
||||
Write-Host "::error::Status Message: $($sig.StatusMessage)"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "✅ Signature verified successfully"
|
||||
Write-Host " Subject: $($sig.SignerCertificate.Subject)"
|
||||
Write-Host " Issuer: $($sig.SignerCertificate.Issuer)"
|
||||
Write-Host " Thumbprint: $($sig.SignerCertificate.Thumbprint)"
|
||||
} else {
|
||||
Write-Host "::error::No .exe file found to verify"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Regenerate checksums after signing
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
cd apps/frontend/dist
|
||||
|
||||
# Find the installer exe (electron-builder names it with "Setup" or just the app name)
|
||||
# electron-builder produces one installer exe per build
|
||||
$exeFiles = Get-ChildItem -Filter "*.exe"
|
||||
if ($exeFiles.Count -eq 0) {
|
||||
Write-Host "::error::No .exe files found in dist folder"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Found $($exeFiles.Count) exe file(s): $($exeFiles.Name -join ', ')"
|
||||
|
||||
$ymlFile = "latest.yml"
|
||||
if (-not (Test-Path $ymlFile)) {
|
||||
Write-Host "::error::$ymlFile not found - cannot update checksums"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$content = Get-Content $ymlFile -Raw
|
||||
$originalContent = $content
|
||||
|
||||
# Process each exe file and update its hash in latest.yml
|
||||
foreach ($exeFile in $exeFiles) {
|
||||
Write-Host "Processing $($exeFile.Name)..."
|
||||
|
||||
# Compute SHA512 hash and convert to base64 (electron-builder format)
|
||||
$bytes = [System.IO.File]::ReadAllBytes($exeFile.FullName)
|
||||
$sha512 = [System.Security.Cryptography.SHA512]::Create()
|
||||
$hashBytes = $sha512.ComputeHash($bytes)
|
||||
$hash = [System.Convert]::ToBase64String($hashBytes)
|
||||
$size = $exeFile.Length
|
||||
|
||||
Write-Host " Hash: $hash"
|
||||
Write-Host " Size: $size"
|
||||
}
|
||||
|
||||
# For electron-builder, latest.yml has a single file entry for the installer
|
||||
# Update the sha512 and size for the primary exe (first one, typically the installer)
|
||||
$primaryExe = $exeFiles | Select-Object -First 1
|
||||
$bytes = [System.IO.File]::ReadAllBytes($primaryExe.FullName)
|
||||
$sha512 = [System.Security.Cryptography.SHA512]::Create()
|
||||
$hashBytes = $sha512.ComputeHash($bytes)
|
||||
$hash = [System.Convert]::ToBase64String($hashBytes)
|
||||
$size = $primaryExe.Length
|
||||
|
||||
# Update sha512 hash (base64 pattern: alphanumeric, +, /, =)
|
||||
$content = $content -replace 'sha512: [A-Za-z0-9+/=]+', "sha512: $hash"
|
||||
# Update size
|
||||
$content = $content -replace 'size: \d+', "size: $size"
|
||||
|
||||
if ($content -eq $originalContent) {
|
||||
Write-Host "::error::Checksum replacement failed - content unchanged. Check if latest.yml format has changed."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Set-Content -Path $ymlFile -Value $content -NoNewline
|
||||
Write-Host "✅ Updated $ymlFile with new base64 hash and size for $($primaryExe.Name)"
|
||||
|
||||
- name: Skip signing notice
|
||||
if: env.AZURE_CLIENT_ID == ''
|
||||
run: echo "::warning::Windows signing skipped - AZURE_CLIENT_ID not configured. The .exe will be unsigned."
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -309,11 +447,18 @@ jobs:
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package Linux
|
||||
run: cd apps/frontend && npm run package:linux
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -14,6 +14,7 @@ Desktop.ini
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
/config.json
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
@@ -164,3 +165,6 @@ _bmad-output/
|
||||
/docs
|
||||
OPUS_ANALYSIS_AND_IDEAS.md
|
||||
/.github/agents
|
||||
|
||||
# Auto Claude generated files
|
||||
.security-key
|
||||
|
||||
+35
-2
@@ -1,5 +1,6 @@
|
||||
repos:
|
||||
# Version sync - propagate root package.json version to all files
|
||||
# NOTE: Skip in worktrees - version sync modifies root files which don't exist in worktree
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: version-sync
|
||||
@@ -8,6 +9,12 @@ repos:
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
# Skip in worktrees - .git is a file pointing to main repo, not a directory
|
||||
# Version sync modifies root-level files that may not exist in worktree context
|
||||
if [ -f ".git" ]; then
|
||||
echo "Skipping version-sync in worktree (root files not accessible)"
|
||||
exit 0
|
||||
fi
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
if [ -n "$VERSION" ]; then
|
||||
|
||||
@@ -81,6 +88,7 @@ repos:
|
||||
|
||||
# Python tests (apps/backend/) - skip slow/integration tests for pre-commit speed
|
||||
# Tests to skip: graphiti (external deps), merge_file_tracker/service_orchestrator/worktree/workspace (Windows path/git issues)
|
||||
# NOTE: Skip this hook in worktrees (where .git is a file, not a directory)
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: pytest
|
||||
@@ -89,6 +97,12 @@ repos:
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
# Skip in worktrees - .git is a file pointing to main repo, not a directory
|
||||
# This prevents path resolution issues with ../../tests/ in worktree context
|
||||
if [ -f ".git" ]; then
|
||||
echo "Skipping pytest in worktree (path resolution would fail)"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/backend
|
||||
if [ -f ".venv/bin/pytest" ]; then
|
||||
PYTEST_CMD=".venv/bin/pytest"
|
||||
@@ -113,18 +127,37 @@ repos:
|
||||
pass_filenames: false
|
||||
|
||||
# Frontend linting (apps/frontend/)
|
||||
# NOTE: These hooks check for worktree context to avoid npm/node_modules issues
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: eslint
|
||||
name: ESLint
|
||||
entry: bash -c 'cd apps/frontend && npm run lint'
|
||||
entry: bash
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
# Skip in worktrees if node_modules doesn't exist (dependencies not installed)
|
||||
if [ -f ".git" ] && [ ! -d "apps/frontend/node_modules" ]; then
|
||||
echo "Skipping ESLint in worktree (node_modules not found)"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/frontend && npm run lint
|
||||
language: system
|
||||
files: ^apps/frontend/.*\.(ts|tsx|js|jsx)$
|
||||
pass_filenames: false
|
||||
|
||||
- id: typecheck
|
||||
name: TypeScript Check
|
||||
entry: bash -c 'cd apps/frontend && npm run typecheck'
|
||||
entry: bash
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
# Skip in worktrees if node_modules doesn't exist (dependencies not installed)
|
||||
if [ -f ".git" ] && [ ! -d "apps/frontend/node_modules" ]; then
|
||||
echo "Skipping TypeScript check in worktree (node_modules not found)"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/frontend && npm run typecheck
|
||||
language: system
|
||||
files: ^apps/frontend/.*\.(ts|tsx)$
|
||||
pass_filenames: false
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
# Root Cause Investigation: Task Workflow Halts After Planning Stage
|
||||
|
||||
## Investigation Summary
|
||||
|
||||
After adding comprehensive logging to the task loading and plan update pipeline, I've analyzed the data flow from backend to frontend to identify why subtasks fail to display after spec completion.
|
||||
|
||||
## Data Flow Analysis
|
||||
|
||||
### Current Architecture
|
||||
|
||||
```
|
||||
Backend (Python)
|
||||
↓
|
||||
Creates implementation_plan.json
|
||||
↓
|
||||
Emits IPC event: 'task:progress' with plan data
|
||||
↓
|
||||
Frontend (Electron Renderer)
|
||||
↓
|
||||
useIpc.ts: onTaskProgress handler (batched)
|
||||
↓
|
||||
task-store.ts: updateTaskFromPlan(taskId, plan)
|
||||
↓
|
||||
Creates subtasks from plan.phases.flatMap(phase => phase.subtasks)
|
||||
↓
|
||||
UI: TaskSubtasks.tsx renders subtasks
|
||||
```
|
||||
|
||||
### Critical Code Paths
|
||||
|
||||
**1. Plan Update Handler** (`apps/frontend/src/renderer/hooks/useIpc.ts:131-135`)
|
||||
```typescript
|
||||
window.electronAPI.onTaskProgress(
|
||||
(taskId: string, plan: ImplementationPlan) => {
|
||||
queueUpdate(taskId, { plan });
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
**2. Subtask Creation** (`apps/frontend/src/renderer/stores/task-store.ts:124-133`)
|
||||
```typescript
|
||||
const subtasks: Subtask[] = plan.phases.flatMap((phase) =>
|
||||
phase.subtasks.map((subtask) => ({
|
||||
id: subtask.id,
|
||||
title: subtask.description,
|
||||
description: subtask.description,
|
||||
status: subtask.status,
|
||||
files: [],
|
||||
verification: subtask.verification as Subtask['verification']
|
||||
}))
|
||||
);
|
||||
```
|
||||
|
||||
**3. Initial Task Loading** (`apps/frontend/src/main/project-store.ts:461-470`)
|
||||
```typescript
|
||||
const subtasks = plan?.phases?.flatMap((phase) => {
|
||||
const items = phase.subtasks || (phase as { chunks?: PlanSubtask[] }).chunks || [];
|
||||
return items.map((subtask) => ({
|
||||
id: subtask.id,
|
||||
title: subtask.description,
|
||||
description: subtask.description,
|
||||
status: subtask.status,
|
||||
files: []
|
||||
}));
|
||||
}) || [];
|
||||
```
|
||||
|
||||
## Root Cause Identification
|
||||
|
||||
### Primary Root Cause: Early Plan Update Event with Empty Phases
|
||||
|
||||
**What's Happening:**
|
||||
|
||||
1. **Backend creates `implementation_plan.json` in stages:**
|
||||
- First writes the file with minimal structure: `{ "feature": "...", "phases": [] }`
|
||||
- Then adds phases and subtasks incrementally
|
||||
- Emits IPC event each time the plan is updated
|
||||
|
||||
2. **Frontend receives the FIRST plan update event:**
|
||||
- Plan has `feature` and basic metadata
|
||||
- **But `phases` array is EMPTY: `[]`**
|
||||
- `updateTaskFromPlan` is called with this incomplete plan
|
||||
- Subtasks are created as empty array: `plan.phases.flatMap(...)` → `[]`
|
||||
|
||||
3. **Later plan updates with full subtask data are ignored:**
|
||||
- When backend writes the complete plan with subtasks
|
||||
- Another IPC event is emitted
|
||||
- But due to race conditions or event handling issues, this update doesn't reach the frontend
|
||||
- Or it does reach but the task UI doesn't refresh
|
||||
|
||||
**Evidence from Code:**
|
||||
|
||||
Looking at `updateTaskFromPlan` (task-store.ts:106-190):
|
||||
- Line 108-114: Logs show `phases: plan.phases?.length || 0`
|
||||
- Line 112: If plan has 0 phases, `totalSubtasks` will be 0
|
||||
- Line 124-133: `plan.phases.flatMap(...)` on empty array creates `subtasks = []`
|
||||
- **No validation to check if plan is complete before updating state**
|
||||
|
||||
**Why "!" Indicators Appear:**
|
||||
|
||||
The "!" indicators likely come from the UI attempting to render subtasks when:
|
||||
- Subtask count shows as 18 (from later plan update metadata)
|
||||
- But `task.subtasks` array is actually empty `[]` (from early plan update)
|
||||
- This mismatch causes the UI to show warning indicators
|
||||
|
||||
### Secondary Contributing Factors
|
||||
|
||||
**A. No Plan Validation Before State Update**
|
||||
|
||||
Current code in `updateTaskFromPlan` immediately creates subtasks from whatever plan data it receives:
|
||||
```typescript
|
||||
const subtasks: Subtask[] = plan.phases.flatMap((phase) =>
|
||||
phase.subtasks.map((subtask) => ({ ... }))
|
||||
);
|
||||
```
|
||||
|
||||
**Problem:** No check if plan is "ready" or "complete" before updating state.
|
||||
|
||||
**B. Missing Reload Trigger After Spec Completion**
|
||||
|
||||
When spec creation completes and the full plan is written:
|
||||
- The IPC event might not fire again
|
||||
- Or the event fires but the batching mechanism drops it
|
||||
- Frontend state remains stuck with empty subtasks
|
||||
|
||||
**C. Race Condition in Batch Update Queue**
|
||||
|
||||
In `useIpc.ts:92-112`, the batching mechanism queues updates:
|
||||
```typescript
|
||||
function queueUpdate(taskId: string, update: BatchedUpdate): void {
|
||||
const existing = batchQueue.get(taskId) || {};
|
||||
batchQueue.set(taskId, { ...existing, ...update });
|
||||
}
|
||||
```
|
||||
|
||||
**Problem:** If two plan updates arrive within 16ms:
|
||||
- First update has empty phases: `{ plan: { phases: [] } }`
|
||||
- Second update has full phases: `{ plan: { phases: [...18 subtasks...] } }`
|
||||
- Second update **overwrites** first in the queue
|
||||
- But if order gets reversed, empty plan overwrites full plan
|
||||
|
||||
## Log Evidence to Look For
|
||||
|
||||
To confirm this root cause, check console logs for:
|
||||
|
||||
### 1. Plan Loading Sequence
|
||||
```
|
||||
[updateTaskFromPlan] called with plan:
|
||||
taskId: "xxx"
|
||||
feature: "..."
|
||||
phases: 0 ← SMOKING GUN: phases array is empty
|
||||
totalSubtasks: 0 ← No subtasks
|
||||
```
|
||||
|
||||
If you see `phases: 0` followed later by no update with `phases: 3` (or more), the early empty plan is stuck in state.
|
||||
|
||||
### 2. Multiple Plan Updates
|
||||
```
|
||||
[updateTaskFromPlan] called with plan:
|
||||
phases: 0
|
||||
totalSubtasks: 0
|
||||
|
||||
[updateTaskFromPlan] called with plan: ← This might never appear
|
||||
phases: 3
|
||||
totalSubtasks: 18
|
||||
```
|
||||
|
||||
If second log never appears, the plan update event isn't firing after spec completion.
|
||||
|
||||
### 3. Project Store Loading
|
||||
```
|
||||
[ProjectStore] Loading implementation_plan.json for spec: xxx
|
||||
[ProjectStore] Loaded plan for xxx:
|
||||
phaseCount: 0 ← Empty plan loaded from disk
|
||||
subtaskCount: 0
|
||||
```
|
||||
|
||||
If plan file on disk has empty phases, the issue is in backend plan writing.
|
||||
|
||||
### 4. Plan File Utils
|
||||
```
|
||||
[plan-file-utils] Reading implementation_plan.json to update status
|
||||
[plan-file-utils] Successfully persisted status ← Plan exists but might be incomplete
|
||||
```
|
||||
|
||||
Check if plan file reads/writes are happening during spec creation.
|
||||
|
||||
## Proposed Fix Approach
|
||||
|
||||
### Fix 1: Add Plan Completeness Validation (Immediate Fix)
|
||||
|
||||
**File:** `apps/frontend/src/renderer/stores/task-store.ts`
|
||||
|
||||
**Change:** Only update subtasks if plan has valid phases and subtasks:
|
||||
|
||||
```typescript
|
||||
updateTaskFromPlan: (taskId, plan) =>
|
||||
set((state) => {
|
||||
console.log('[updateTaskFromPlan] called with plan:', { ... });
|
||||
|
||||
const index = findTaskIndex(state.tasks, taskId);
|
||||
if (index === -1) {
|
||||
console.log('[updateTaskFromPlan] Task not found:', taskId);
|
||||
return state;
|
||||
}
|
||||
|
||||
// VALIDATION: Don't update if plan is incomplete
|
||||
if (!plan.phases || plan.phases.length === 0) {
|
||||
console.warn('[updateTaskFromPlan] Plan has no phases, skipping update:', taskId);
|
||||
return state; // Keep existing state, don't overwrite with empty data
|
||||
}
|
||||
|
||||
const totalSubtasks = plan.phases.reduce((acc, p) => acc + (p.subtasks?.length || 0), 0);
|
||||
if (totalSubtasks === 0) {
|
||||
console.warn('[updateTaskFromPlan] Plan has no subtasks, skipping update:', taskId);
|
||||
return state; // Keep existing state
|
||||
}
|
||||
|
||||
// ... rest of existing code to create subtasks ...
|
||||
})
|
||||
```
|
||||
|
||||
### Fix 2: Trigger Reload After Spec Completion (Comprehensive Fix)
|
||||
|
||||
**File:** `apps/frontend/src/renderer/hooks/useIpc.ts`
|
||||
|
||||
**Change:** Add explicit "spec completed" event handler that reloads the task:
|
||||
|
||||
```typescript
|
||||
// Add new IPC event listener
|
||||
const cleanupSpecComplete = window.electronAPI.onSpecComplete(
|
||||
async (taskId: string) => {
|
||||
console.log('[IPC] Spec completed for task:', taskId);
|
||||
// Force reload the task from disk to get the complete plan
|
||||
const task = useTaskStore.getState().tasks.find(t => t.id === taskId);
|
||||
if (task) {
|
||||
// Reload plan from file
|
||||
const result = await window.electronAPI.getTaskPlan(task.projectId, taskId);
|
||||
if (result.success && result.data) {
|
||||
updateTaskFromPlan(taskId, result.data);
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### Fix 3: Prevent Plan Overwrite in Batch Queue (Race Condition Fix)
|
||||
|
||||
**File:** `apps/frontend/src/renderer/hooks/useIpc.ts`
|
||||
|
||||
**Change:** Don't overwrite plan if incoming plan has fewer subtasks than existing:
|
||||
|
||||
```typescript
|
||||
function queueUpdate(taskId: string, update: BatchedUpdate): void {
|
||||
const existing = batchQueue.get(taskId) || {};
|
||||
|
||||
// For plan updates, only accept if it has MORE data than existing
|
||||
let mergedPlan = existing.plan;
|
||||
if (update.plan) {
|
||||
const existingSubtasks = existing.plan?.phases?.flatMap(p => p.subtasks || []).length || 0;
|
||||
const newSubtasks = update.plan.phases?.flatMap(p => p.subtasks || []).length || 0;
|
||||
|
||||
if (newSubtasks >= existingSubtasks) {
|
||||
mergedPlan = update.plan; // Accept new plan
|
||||
} else {
|
||||
console.warn('[IPC Batch] Rejecting plan update with fewer subtasks:',
|
||||
{ taskId, existing: existingSubtasks, new: newSubtasks });
|
||||
// Keep existing plan, don't overwrite with less complete data
|
||||
}
|
||||
}
|
||||
|
||||
// ... rest of existing code ...
|
||||
}
|
||||
```
|
||||
|
||||
## Testing the Fix
|
||||
|
||||
### Manual Verification Steps
|
||||
|
||||
1. **Create a new task** and move it to "In Progress"
|
||||
2. **Watch the console logs** for:
|
||||
```
|
||||
[updateTaskFromPlan] called with plan: { phases: 0, totalSubtasks: 0 }
|
||||
```
|
||||
3. **Wait for spec to complete** (planning phase finishes)
|
||||
4. **Check console logs** for:
|
||||
```
|
||||
[updateTaskFromPlan] called with plan: { phases: 3, totalSubtasks: 18 }
|
||||
```
|
||||
5. **Expand subtask list** in task card
|
||||
6. **Verify:** Subtasks display with full details, no "!" indicators
|
||||
|
||||
### Expected Outcome After Fix
|
||||
|
||||
- ✅ Empty/incomplete plan updates are ignored
|
||||
- ✅ Only complete plans with phases and subtasks update the UI
|
||||
- ✅ Subtasks display with id, description, and status
|
||||
- ✅ No "!" warning indicators
|
||||
- ✅ Subtask count shows "0/18 completed" (not "0/0")
|
||||
- ✅ Plan pulsing animation stops when spec completes
|
||||
- ✅ Resume functionality works without infinite loop
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ **This Investigation** - Root cause identified (COMPLETE)
|
||||
2. 🔄 **Subtask 2-1** - Implement Fix 1 (validation in updateTaskFromPlan)
|
||||
3. 🔄 **Subtask 2-2** - Add data validation before subtask state updates
|
||||
4. 🔄 **Subtask 2-3** - Fix pulsing animation condition
|
||||
5. 🔄 **Subtask 2-4** - Fix resume logic to reload plan if subtasks missing
|
||||
6. 🔄 **Phase 3** - Add comprehensive tests to prevent regressions
|
||||
|
||||
## Conclusion
|
||||
|
||||
**Root Cause:** Frontend receives and accepts incomplete plan data (empty `phases` array) during the spec creation process, before subtasks are written. This overwrites any existing subtask data and leaves the UI in a stuck state with no subtasks to display.
|
||||
|
||||
**Fix Priority:** Implement Fix 1 (validation) immediately to prevent incomplete plans from updating state. This is a minimal, low-risk change that will resolve the core issue.
|
||||
|
||||
**Long-term Solution:** Add explicit event handling for spec completion (Fix 2) and improve batch queue logic (Fix 3) to make the system more robust against race conditions and out-of-order updates.
|
||||
@@ -4,11 +4,9 @@
|
||||
|
||||

|
||||
|
||||
<!-- TOP_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.2)
|
||||
<!-- TOP_VERSION_BADGE_END -->
|
||||
[](./agpl-3.0.txt)
|
||||
[](https://discord.gg/KCXaPBr4Dj)
|
||||
[](https://www.youtube.com/@AndreMikalsen)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/actions)
|
||||
|
||||
---
|
||||
@@ -59,7 +57,6 @@
|
||||
- **Claude Pro/Max subscription** - [Get one here](https://claude.ai/upgrade)
|
||||
- **Claude Code CLI** - `npm install -g @anthropic-ai/claude-code`
|
||||
- **Git repository** - Your project must be initialized as a git repo
|
||||
- **Python 3.12+** - Required for the backend and Memory Layer
|
||||
|
||||
---
|
||||
|
||||
@@ -148,113 +145,11 @@ See [guides/CLI-USAGE.md](guides/CLI-USAGE.md) for complete CLI documentation.
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
## Development
|
||||
|
||||
Create `apps/backend/.env` from the example:
|
||||
Want to build from source or contribute? See [CONTRIBUTING.md](CONTRIBUTING.md) for complete development setup instructions.
|
||||
|
||||
```bash
|
||||
cp apps/backend/.env.example apps/backend/.env
|
||||
```
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `CLAUDE_CODE_OAUTH_TOKEN` | Yes | OAuth token from `claude setup-token` |
|
||||
| `GRAPHITI_ENABLED` | No | Enable Memory Layer for cross-session context |
|
||||
| `AUTO_BUILD_MODEL` | No | Override the default Claude model |
|
||||
| `GITLAB_TOKEN` | No | GitLab Personal Access Token for GitLab integration |
|
||||
| `GITLAB_INSTANCE_URL` | No | GitLab instance URL (defaults to gitlab.com) |
|
||||
| `LINEAR_API_KEY` | No | Linear API key for task sync |
|
||||
|
||||
---
|
||||
|
||||
## Building from Source
|
||||
|
||||
For contributors and development:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/AndyMik90/Auto-Claude.git
|
||||
cd Auto-Claude
|
||||
|
||||
# Install all dependencies
|
||||
npm run install:all
|
||||
|
||||
# Run in development mode
|
||||
npm run dev
|
||||
|
||||
# Or build and run
|
||||
npm start
|
||||
```
|
||||
|
||||
**System requirements for building:**
|
||||
- Node.js 24+
|
||||
- Python 3.12+
|
||||
- npm 10+
|
||||
|
||||
**Installing dependencies by platform:**
|
||||
|
||||
<details>
|
||||
<summary><b>Windows</b></summary>
|
||||
|
||||
```bash
|
||||
winget install Python.Python.3.12
|
||||
winget install OpenJS.NodeJS.LTS
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>macOS</b></summary>
|
||||
|
||||
```bash
|
||||
brew install python@3.12 node@24
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Linux (Ubuntu/Debian)</b></summary>
|
||||
|
||||
```bash
|
||||
sudo apt install python3.12 python3.12-venv
|
||||
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
|
||||
sudo apt install -y nodejs
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Linux (Fedora)</b></summary>
|
||||
|
||||
```bash
|
||||
sudo dnf install python3.12 nodejs npm
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed development setup.
|
||||
|
||||
### Building Flatpak
|
||||
|
||||
To build the Flatpak package, you need additional dependencies:
|
||||
|
||||
```bash
|
||||
# Fedora/RHEL
|
||||
sudo dnf install flatpak-builder
|
||||
|
||||
# Ubuntu/Debian
|
||||
sudo apt install flatpak-builder
|
||||
|
||||
# Install required Flatpak runtimes
|
||||
flatpak install flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
|
||||
flatpak install flathub org.electronjs.Electron2.BaseApp//25.08
|
||||
|
||||
# Build the Flatpak
|
||||
cd apps/frontend
|
||||
npm run package:flatpak
|
||||
```
|
||||
|
||||
The Flatpak will be created in `apps/frontend/dist/`.
|
||||
For Linux-specific builds (Flatpak, AppImage), see [guides/linux.md](guides/linux.md).
|
||||
|
||||
---
|
||||
|
||||
@@ -284,7 +179,7 @@ All releases are:
|
||||
| `npm run package:mac` | Package for macOS |
|
||||
| `npm run package:win` | Package for Windows |
|
||||
| `npm run package:linux` | Package for Linux |
|
||||
| `npm run package:flatpak` | Package as Flatpak |
|
||||
| `npm run package:flatpak` | Package as Flatpak (see [guides/linux.md](guides/linux.md)) |
|
||||
| `npm run lint` | Run linter |
|
||||
| `npm test` | Run frontend tests |
|
||||
| `npm run test:backend` | Run backend tests |
|
||||
@@ -316,3 +211,11 @@ We welcome contributions! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for:
|
||||
Auto Claude is free to use. If you modify and distribute it, or run it as a service, your code must also be open source under AGPL-3.0.
|
||||
|
||||
Commercial licensing available for closed-source use cases.
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://github.com/AndyMik90/Auto-Claude/stargazers)
|
||||
|
||||
[](https://star-history.com/#AndyMik90/Auto-Claude&Date)
|
||||
|
||||
@@ -26,7 +26,7 @@ auto-claude/agents/
|
||||
### `utils.py` (3.6 KB)
|
||||
- Git operations: `get_latest_commit()`, `get_commit_count()`
|
||||
- Plan management: `load_implementation_plan()`, `find_subtask_in_plan()`, `find_phase_for_subtask()`
|
||||
- Workspace sync: `sync_plan_to_source()`
|
||||
- Workspace sync: `sync_spec_to_source()`
|
||||
|
||||
### `memory.py` (13 KB)
|
||||
- Dual-layer memory system (Graphiti primary, file-based fallback)
|
||||
@@ -73,7 +73,7 @@ from agents import (
|
||||
# Utilities
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_plan_to_source,
|
||||
sync_spec_to_source,
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -14,6 +14,10 @@ This module provides:
|
||||
Uses lazy imports to avoid circular dependencies.
|
||||
"""
|
||||
|
||||
# Explicit import required by CodeQL static analysis
|
||||
# (CodeQL doesn't recognize __getattr__ dynamic exports)
|
||||
from .utils import sync_spec_to_source
|
||||
|
||||
__all__ = [
|
||||
# Main API
|
||||
"run_autonomous_agent",
|
||||
@@ -32,7 +36,7 @@ __all__ = [
|
||||
"load_implementation_plan",
|
||||
"find_subtask_in_plan",
|
||||
"find_phase_for_subtask",
|
||||
"sync_plan_to_source",
|
||||
"sync_spec_to_source",
|
||||
# Constants
|
||||
"AUTO_CONTINUE_DELAY_SECONDS",
|
||||
"HUMAN_INTERVENTION_FILE",
|
||||
@@ -77,7 +81,7 @@ def __getattr__(name):
|
||||
"get_commit_count",
|
||||
"get_latest_commit",
|
||||
"load_implementation_plan",
|
||||
"sync_plan_to_source",
|
||||
"sync_spec_to_source",
|
||||
):
|
||||
from .utils import (
|
||||
find_phase_for_subtask,
|
||||
@@ -85,7 +89,7 @@ def __getattr__(name):
|
||||
get_commit_count,
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_plan_to_source,
|
||||
sync_spec_to_source,
|
||||
)
|
||||
|
||||
return locals()[name]
|
||||
|
||||
@@ -7,6 +7,7 @@ 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
|
||||
@@ -37,6 +38,7 @@ 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,
|
||||
@@ -62,7 +64,7 @@ from .utils import (
|
||||
get_commit_count,
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_plan_to_source,
|
||||
sync_spec_to_source,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -90,6 +92,10 @@ async def run_autonomous_agent(
|
||||
verbose: Whether to show detailed output
|
||||
source_spec_dir: Original spec directory in main project (for syncing from worktree)
|
||||
"""
|
||||
# Set environment variable for security hooks to find the correct project directory
|
||||
# This is needed because os.getcwd() may return the wrong directory in worktree mode
|
||||
os.environ[PROJECT_DIR_ENV_VAR] = str(project_dir.resolve())
|
||||
|
||||
# Initialize recovery manager (handles memory persistence)
|
||||
recovery_manager = RecoveryManager(spec_dir, project_dir)
|
||||
|
||||
@@ -404,7 +410,7 @@ async def run_autonomous_agent(
|
||||
print_status("Linear notified of stuck subtask", "info")
|
||||
elif is_planning_phase and source_spec_dir:
|
||||
# After planning phase, sync the newly created implementation plan back to source
|
||||
if sync_plan_to_source(spec_dir, source_spec_dir):
|
||||
if sync_spec_to_source(spec_dir, source_spec_dir):
|
||||
print_status("Implementation plan synced to main project", "success")
|
||||
|
||||
# Handle session status
|
||||
|
||||
@@ -40,7 +40,7 @@ from .utils import (
|
||||
get_commit_count,
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_plan_to_source,
|
||||
sync_spec_to_source,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -82,7 +82,7 @@ async def post_session_processing(
|
||||
print(muted("--- Post-Session Processing ---"))
|
||||
|
||||
# Sync implementation plan back to source (for worktree mode)
|
||||
if sync_plan_to_source(spec_dir, source_spec_dir):
|
||||
if sync_spec_to_source(spec_dir, source_spec_dir):
|
||||
print_status("Implementation plan synced to main project", "success")
|
||||
|
||||
# Check if implementation plan was updated
|
||||
@@ -445,8 +445,9 @@ async def run_agent_session(
|
||||
result_content = getattr(block, "content", "")
|
||||
is_error = getattr(block, "is_error", False)
|
||||
|
||||
# Check if command was blocked by security hook
|
||||
if "blocked" in str(result_content).lower():
|
||||
# Check if this is an error (not just content containing "blocked")
|
||||
if is_error and "blocked" in str(result_content).lower():
|
||||
# Actual blocked command by security hook
|
||||
debug_error(
|
||||
"session",
|
||||
f"Tool BLOCKED: {current_tool}",
|
||||
|
||||
@@ -4,9 +4,16 @@ Session Memory Tools
|
||||
|
||||
Tools for recording and retrieving session memory, including discoveries,
|
||||
gotchas, and patterns.
|
||||
|
||||
Dual-storage approach:
|
||||
- File-based: Always available, works offline, spec-specific
|
||||
- LadybugDB: When Graphiti is enabled, also saves to graph database for
|
||||
cross-session retrieval and Memory UI display
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -19,6 +26,108 @@ 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:
|
||||
# Check if Graphiti is enabled
|
||||
from graphiti_config import is_graphiti_enabled
|
||||
|
||||
if not is_graphiti_enabled():
|
||||
return False
|
||||
|
||||
from integrations.graphiti.queries_pkg.graphiti import GraphitiMemory
|
||||
|
||||
memory = GraphitiMemory(spec_dir, project_dir)
|
||||
try:
|
||||
if save_type == "discovery":
|
||||
# Save as codebase discovery
|
||||
# Format: {file_path: description}
|
||||
result = await memory.save_codebase_discoveries(
|
||||
{data["file_path"]: data["description"]}
|
||||
)
|
||||
elif save_type == "gotcha":
|
||||
# Save as gotcha
|
||||
gotcha_text = data["gotcha"]
|
||||
if data.get("context"):
|
||||
gotcha_text += f" (Context: {data['context']})"
|
||||
result = await memory.save_gotcha(gotcha_text)
|
||||
elif save_type == "pattern":
|
||||
# Save as pattern
|
||||
result = await memory.save_pattern(data["pattern"])
|
||||
else:
|
||||
result = False
|
||||
return result
|
||||
finally:
|
||||
await memory.close()
|
||||
|
||||
except ImportError as e:
|
||||
logger.debug(f"Graphiti not available for memory tools: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save to Graphiti: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _save_to_graphiti_sync(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
save_type: str,
|
||||
data: dict,
|
||||
) -> bool:
|
||||
"""
|
||||
Save data to Graphiti/LadybugDB (synchronous wrapper for sync contexts only).
|
||||
|
||||
NOTE: This should only be called from synchronous code. For async callers,
|
||||
use _save_to_graphiti_async() directly to ensure proper resource cleanup.
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory for GraphitiMemory initialization
|
||||
project_dir: Project root directory
|
||||
save_type: Type of save - 'discovery', 'gotcha', or 'pattern'
|
||||
data: Data to save
|
||||
|
||||
Returns:
|
||||
True if save succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Check if we're already in an async context
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
# We're in an async context - caller should use _save_to_graphiti_async
|
||||
# Log a warning and return False to avoid the resource leak bug
|
||||
logger.warning(
|
||||
"_save_to_graphiti_sync called from async context. "
|
||||
"Use _save_to_graphiti_async instead for proper cleanup."
|
||||
)
|
||||
return False
|
||||
except RuntimeError:
|
||||
# No running loop - safe to create one
|
||||
return asyncio.run(
|
||||
_save_to_graphiti_async(spec_dir, project_dir, save_type, data)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save to Graphiti: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
"""
|
||||
@@ -45,7 +154,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
{"file_path": str, "description": str, "category": str},
|
||||
)
|
||||
async def record_discovery(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Record a discovery to the codebase map."""
|
||||
"""Record a discovery to the codebase map (file + Graphiti)."""
|
||||
file_path = args["file_path"]
|
||||
description = args["description"]
|
||||
category = args.get("category", "general")
|
||||
@@ -54,8 +163,10 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
memory_dir.mkdir(exist_ok=True)
|
||||
|
||||
codebase_map_file = memory_dir / "codebase_map.json"
|
||||
saved_to_graphiti = False
|
||||
|
||||
try:
|
||||
# PRIMARY: Save to file-based storage (always works)
|
||||
# Load existing map or create new
|
||||
if codebase_map_file.exists():
|
||||
with open(codebase_map_file) as f:
|
||||
@@ -77,11 +188,23 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
with open(codebase_map_file, "w") as f:
|
||||
json.dump(codebase_map, f, indent=2)
|
||||
|
||||
# SECONDARY: Also save to Graphiti/LadybugDB (for Memory UI)
|
||||
saved_to_graphiti = await _save_to_graphiti_async(
|
||||
spec_dir,
|
||||
project_dir,
|
||||
"discovery",
|
||||
{
|
||||
"file_path": file_path,
|
||||
"description": f"[{category}] {description}",
|
||||
},
|
||||
)
|
||||
|
||||
storage_note = " (also saved to memory graph)" if saved_to_graphiti else ""
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Recorded discovery for '{file_path}': {description}",
|
||||
"text": f"Recorded discovery for '{file_path}': {description}{storage_note}",
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -102,7 +225,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
{"gotcha": str, "context": str},
|
||||
)
|
||||
async def record_gotcha(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Record a gotcha to session memory."""
|
||||
"""Record a gotcha to session memory (file + Graphiti)."""
|
||||
gotcha = args["gotcha"]
|
||||
context = args.get("context", "")
|
||||
|
||||
@@ -110,8 +233,10 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
memory_dir.mkdir(exist_ok=True)
|
||||
|
||||
gotchas_file = memory_dir / "gotchas.md"
|
||||
saved_to_graphiti = False
|
||||
|
||||
try:
|
||||
# PRIMARY: Save to file-based storage (always works)
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
entry = f"\n## [{timestamp}]\n{gotcha}"
|
||||
@@ -126,7 +251,20 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
)
|
||||
f.write(entry)
|
||||
|
||||
return {"content": [{"type": "text", "text": f"Recorded gotcha: {gotcha}"}]}
|
||||
# SECONDARY: Also save to Graphiti/LadybugDB (for Memory UI)
|
||||
saved_to_graphiti = await _save_to_graphiti_async(
|
||||
spec_dir,
|
||||
project_dir,
|
||||
"gotcha",
|
||||
{"gotcha": gotcha, "context": context},
|
||||
)
|
||||
|
||||
storage_note = " (also saved to memory graph)" if saved_to_graphiti else ""
|
||||
return {
|
||||
"content": [
|
||||
{"type": "text", "text": f"Recorded gotcha: {gotcha}{storage_note}"}
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
|
||||
+103
-38
@@ -8,40 +8,38 @@ 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."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
result = run_git(
|
||||
["rev-parse", "HEAD"],
|
||||
cwd=project_dir,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
except subprocess.CalledProcessError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def get_commit_count(project_dir: Path) -> int:
|
||||
"""Get the total number of commits."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-list", "--count", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return int(result.stdout.strip())
|
||||
except (subprocess.CalledProcessError, ValueError):
|
||||
return 0
|
||||
result = run_git(
|
||||
["rev-list", "--count", "HEAD"],
|
||||
cwd=project_dir,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
try:
|
||||
return int(result.stdout.strip())
|
||||
except ValueError:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def load_implementation_plan(spec_dir: Path) -> dict | None:
|
||||
@@ -74,16 +72,32 @@ def find_phase_for_subtask(plan: dict, subtask_id: str) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def sync_plan_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool:
|
||||
def sync_spec_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool:
|
||||
"""
|
||||
Sync implementation_plan.json from worktree back to source spec directory.
|
||||
Sync ALL spec files from worktree back to source spec directory.
|
||||
|
||||
When running in isolated mode (worktrees), the agent updates the implementation
|
||||
plan inside the worktree. This function syncs those changes back to the main
|
||||
project's spec directory so the frontend/UI can see the progress.
|
||||
When running in isolated mode (worktrees), the agent creates and updates
|
||||
many files inside the worktree's spec directory. This function syncs ALL
|
||||
of them back to the main project's spec directory.
|
||||
|
||||
IMPORTANT: Since .auto-claude/ is gitignored, this sync happens to the
|
||||
local filesystem regardless of what branch the user is on. The worktree
|
||||
may be on a different branch (e.g., auto-claude/093-task), but the sync
|
||||
target is always the main project's .auto-claude/specs/ directory.
|
||||
|
||||
Files synced (all files in spec directory):
|
||||
- implementation_plan.json - Task status and subtask completion
|
||||
- build-progress.txt - Session-by-session progress notes
|
||||
- task_logs.json - Execution logs
|
||||
- review_state.json - QA review state
|
||||
- critique_report.json - Spec critique findings
|
||||
- suggested_commit_message.txt - Commit suggestions
|
||||
- REGRESSION_TEST_REPORT.md - Test regression report
|
||||
- spec.md, context.json, etc. - Original spec files (for completeness)
|
||||
- memory/ directory - Codebase map, patterns, gotchas, session insights
|
||||
|
||||
Args:
|
||||
spec_dir: Current spec directory (may be inside worktree)
|
||||
spec_dir: Current spec directory (inside worktree)
|
||||
source_spec_dir: Original spec directory in main project (outside worktree)
|
||||
|
||||
Returns:
|
||||
@@ -100,17 +114,68 @@ def sync_plan_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool:
|
||||
if spec_dir_resolved == source_spec_dir_resolved:
|
||||
return False # Same directory, no sync needed
|
||||
|
||||
# Sync the implementation plan
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if not plan_file.exists():
|
||||
return False
|
||||
synced_any = False
|
||||
|
||||
source_plan_file = source_spec_dir / "implementation_plan.json"
|
||||
# Ensure source directory exists
|
||||
source_spec_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
shutil.copy2(plan_file, source_plan_file)
|
||||
logger.debug(f"Synced implementation plan to source: {source_plan_file}")
|
||||
return True
|
||||
# Sync all files and directories from worktree spec to source spec
|
||||
for item in spec_dir.iterdir():
|
||||
# Skip symlinks to prevent path traversal attacks
|
||||
if item.is_symlink():
|
||||
logger.warning(f"Skipping symlink during sync: {item.name}")
|
||||
continue
|
||||
|
||||
source_item = source_spec_dir / item.name
|
||||
|
||||
if item.is_file():
|
||||
# Copy file (preserves timestamps)
|
||||
shutil.copy2(item, source_item)
|
||||
logger.debug(f"Synced {item.name} to source")
|
||||
synced_any = True
|
||||
|
||||
elif item.is_dir():
|
||||
# Recursively sync directory
|
||||
_sync_directory(item, source_item)
|
||||
synced_any = True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to sync implementation plan to source: {e}")
|
||||
return False
|
||||
logger.warning(f"Failed to sync spec directory to source: {e}")
|
||||
|
||||
return synced_any
|
||||
|
||||
|
||||
def _sync_directory(source_dir: Path, target_dir: Path) -> None:
|
||||
"""
|
||||
Recursively sync a directory from source to target.
|
||||
|
||||
Args:
|
||||
source_dir: Source directory (in worktree)
|
||||
target_dir: Target directory (in main project)
|
||||
"""
|
||||
# Create target directory if needed
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for item in source_dir.iterdir():
|
||||
# Skip symlinks to prevent path traversal attacks
|
||||
if item.is_symlink():
|
||||
logger.warning(
|
||||
f"Skipping symlink during sync: {source_dir.name}/{item.name}"
|
||||
)
|
||||
continue
|
||||
|
||||
target_item = target_dir / item.name
|
||||
|
||||
if item.is_file():
|
||||
shutil.copy2(item, target_item)
|
||||
logger.debug(f"Synced {source_dir.name}/{item.name} to source")
|
||||
elif item.is_dir():
|
||||
# Recurse into subdirectories
|
||||
_sync_directory(item, target_item)
|
||||
|
||||
|
||||
# Keep the old name as an alias for backward compatibility
|
||||
def sync_plan_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool:
|
||||
"""Alias for sync_spec_to_source for backward compatibility."""
|
||||
return sync_spec_to_source(spec_dir, source_spec_dir)
|
||||
|
||||
@@ -387,12 +387,40 @@ async def run_insight_extraction(
|
||||
|
||||
# Collect the response
|
||||
response_text = ""
|
||||
message_count = 0
|
||||
text_blocks_found = 0
|
||||
|
||||
async for msg in client.receive_response():
|
||||
msg_type = type(msg).__name__
|
||||
message_count += 1
|
||||
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
if hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
# Must check block type - only TextBlock has .text attribute
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
text_blocks_found += 1
|
||||
if block.text: # Only add non-empty text
|
||||
response_text += block.text
|
||||
else:
|
||||
logger.debug(
|
||||
f"Found empty TextBlock in response (block #{text_blocks_found})"
|
||||
)
|
||||
|
||||
# Log response collection summary
|
||||
logger.debug(
|
||||
f"Insight extraction response: {message_count} messages, "
|
||||
f"{text_blocks_found} text blocks, {len(response_text)} chars collected"
|
||||
)
|
||||
|
||||
# Validate we received content before parsing
|
||||
if not response_text.strip():
|
||||
logger.warning(
|
||||
f"Insight extraction returned empty response. "
|
||||
f"Messages received: {message_count}, TextBlocks found: {text_blocks_found}. "
|
||||
f"This may indicate the AI model did not respond with text content."
|
||||
)
|
||||
return None
|
||||
|
||||
# Parse JSON from response
|
||||
return parse_insights(response_text)
|
||||
@@ -415,6 +443,11 @@ def parse_insights(response_text: str) -> dict | None:
|
||||
# Try to extract JSON from the response
|
||||
text = response_text.strip()
|
||||
|
||||
# Early validation - check for empty response
|
||||
if not text:
|
||||
logger.warning("Cannot parse insights: response text is empty")
|
||||
return None
|
||||
|
||||
# Handle markdown code blocks
|
||||
if text.startswith("```"):
|
||||
# Remove code block markers
|
||||
@@ -422,17 +455,26 @@ def parse_insights(response_text: str) -> dict | None:
|
||||
# Remove first line (```json or ```)
|
||||
if lines[0].startswith("```"):
|
||||
lines = lines[1:]
|
||||
# Remove last line if it's ``
|
||||
# Remove last line if it's ```
|
||||
if lines and lines[-1].strip() == "```":
|
||||
lines = lines[:-1]
|
||||
text = "\n".join(lines)
|
||||
text = "\n".join(lines).strip()
|
||||
|
||||
# Check again after removing code blocks
|
||||
if not text:
|
||||
logger.warning(
|
||||
"Cannot parse insights: response contained only markdown code block markers with no content"
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
insights = json.loads(text)
|
||||
|
||||
# Validate structure
|
||||
if not isinstance(insights, dict):
|
||||
logger.warning("Insights is not a dict")
|
||||
logger.warning(
|
||||
f"Insights is not a dict, got type: {type(insights).__name__}"
|
||||
)
|
||||
return None
|
||||
|
||||
# Ensure required keys exist with defaults
|
||||
@@ -446,7 +488,13 @@ def parse_insights(response_text: str) -> dict | None:
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Failed to parse insights JSON: {e}")
|
||||
logger.debug(f"Response text was: {text[:500]}")
|
||||
# Show more context in the error message
|
||||
preview_length = min(500, len(text))
|
||||
logger.warning(
|
||||
f"Response text preview (first {preview_length} chars): {text[:preview_length]}"
|
||||
)
|
||||
if len(text) > preview_length:
|
||||
logger.warning(f"... (total length: {len(text)} chars)")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ Commands for creating and managing multiple tasks from batch files.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from ui import highlight, print_status
|
||||
@@ -212,5 +214,53 @@ def handle_batch_cleanup_command(project_dir: str, dry_run: bool = True) -> bool
|
||||
print(f" └─ .auto-claude/worktrees/tasks/{spec_name}/")
|
||||
print()
|
||||
print("Run with --no-dry-run to actually delete")
|
||||
else:
|
||||
# Actually delete specs and worktrees
|
||||
deleted_count = 0
|
||||
for spec_name in completed:
|
||||
spec_path = specs_dir / spec_name
|
||||
wt_path = worktrees_dir / spec_name
|
||||
|
||||
# Remove worktree first (if exists)
|
||||
if wt_path.exists():
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "worktree", "remove", "--force", str(wt_path)],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print_status(f"Removed worktree: {spec_name}", "success")
|
||||
else:
|
||||
# Fallback: remove directory manually if git fails
|
||||
shutil.rmtree(wt_path, ignore_errors=True)
|
||||
print_status(
|
||||
f"Removed worktree directory: {spec_name}", "success"
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
# Timeout: fall back to manual removal
|
||||
shutil.rmtree(wt_path, ignore_errors=True)
|
||||
print_status(
|
||||
f"Worktree removal timed out, removed directory: {spec_name}",
|
||||
"warning",
|
||||
)
|
||||
except Exception as e:
|
||||
print_status(
|
||||
f"Failed to remove worktree {spec_name}: {e}", "warning"
|
||||
)
|
||||
|
||||
# Remove spec directory
|
||||
if spec_path.exists():
|
||||
try:
|
||||
shutil.rmtree(spec_path)
|
||||
print_status(f"Removed spec: {spec_name}", "success")
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
print_status(f"Failed to remove spec {spec_name}: {e}", "error")
|
||||
|
||||
print()
|
||||
print_status(f"Cleaned up {deleted_count} spec(s)", "info")
|
||||
|
||||
return True
|
||||
|
||||
@@ -79,7 +79,7 @@ def handle_build_command(
|
||||
base_branch: Base branch for worktree creation (default: current branch)
|
||||
"""
|
||||
# Lazy imports to avoid loading heavy modules
|
||||
from agent import run_autonomous_agent, sync_plan_to_source
|
||||
from agent import run_autonomous_agent, sync_spec_to_source
|
||||
from debug import (
|
||||
debug,
|
||||
debug_info,
|
||||
@@ -274,7 +274,7 @@ def handle_build_command(
|
||||
|
||||
# Sync implementation plan to main project after QA
|
||||
# This ensures the main project has the latest status (human_review)
|
||||
if sync_plan_to_source(spec_dir, source_spec_dir):
|
||||
if sync_spec_to_source(spec_dir, source_spec_dir):
|
||||
debug_info(
|
||||
"run.py", "Implementation plan synced to main project after QA"
|
||||
)
|
||||
|
||||
@@ -38,6 +38,7 @@ 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,
|
||||
@@ -153,6 +154,30 @@ 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(
|
||||
@@ -365,6 +390,21 @@ def main() -> None:
|
||||
handle_discard_command(project_dir, spec_dir.name)
|
||||
return
|
||||
|
||||
if args.create_pr:
|
||||
# Pass args.pr_target directly - WorktreeManager._detect_base_branch
|
||||
# handles base branch detection internally when target_branch is None
|
||||
result = handle_create_pr_command(
|
||||
project_dir=project_dir,
|
||||
spec_name=spec_dir.name,
|
||||
target_branch=args.pr_target,
|
||||
title=args.pr_title,
|
||||
draft=args.pr_draft,
|
||||
)
|
||||
# JSON output is already printed by handle_create_pr_command
|
||||
if not result.get("success"):
|
||||
sys.exit(1)
|
||||
return
|
||||
|
||||
# Handle QA commands
|
||||
if args.qa_status:
|
||||
handle_qa_status_command(spec_dir)
|
||||
|
||||
@@ -15,7 +15,47 @@ 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 dotenv import load_dotenv
|
||||
from core.dependency_validator import validate_platform_dependencies
|
||||
|
||||
|
||||
def import_dotenv():
|
||||
"""
|
||||
Import and return load_dotenv with helpful error message if not installed.
|
||||
|
||||
This centralized function ensures consistent error messaging across all
|
||||
runner scripts when python-dotenv is not available.
|
||||
|
||||
Returns:
|
||||
The load_dotenv function
|
||||
|
||||
Raises:
|
||||
SystemExit: If dotenv cannot be imported, with helpful installation instructions.
|
||||
"""
|
||||
try:
|
||||
from dotenv import load_dotenv as _load_dotenv
|
||||
|
||||
return _load_dotenv
|
||||
except ImportError:
|
||||
sys.exit(
|
||||
"Error: Required Python package 'python-dotenv' is not installed.\n"
|
||||
"\n"
|
||||
"This usually means you're not using the virtual environment.\n"
|
||||
"\n"
|
||||
"To fix this:\n"
|
||||
"1. From the 'apps/backend/' directory, activate the venv:\n"
|
||||
" source .venv/bin/activate # Linux/macOS\n"
|
||||
" .venv\\Scripts\\activate # Windows\n"
|
||||
"\n"
|
||||
"2. Or install dependencies directly:\n"
|
||||
" pip install python-dotenv\n"
|
||||
" pip install -r requirements.txt\n"
|
||||
"\n"
|
||||
f"Current Python: {sys.executable}\n"
|
||||
)
|
||||
|
||||
|
||||
# Load .env with helpful error if dependencies not installed
|
||||
load_dotenv = import_dotenv()
|
||||
from graphiti_config import get_graphiti_status
|
||||
from linear_integration import LinearManager
|
||||
from linear_updater import is_linear_enabled
|
||||
@@ -115,6 +155,9 @@ def validate_environment(spec_dir: Path) -> bool:
|
||||
Returns:
|
||||
True if valid, False otherwise (with error messages printed)
|
||||
"""
|
||||
# Validate platform-specific dependencies first (exits if missing)
|
||||
validate_platform_dependencies()
|
||||
|
||||
valid = True
|
||||
|
||||
# Check for OAuth token (API keys are not supported)
|
||||
|
||||
@@ -5,6 +5,7 @@ Workspace Commands
|
||||
CLI commands for workspace management (merge, review, discard, list, cleanup)
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -22,6 +23,8 @@ 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,
|
||||
@@ -30,6 +33,7 @@ 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,
|
||||
@@ -67,6 +71,7 @@ def _detect_default_branch(project_dir: Path) -> str:
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return env_branch
|
||||
@@ -78,6 +83,7 @@ def _detect_default_branch(project_dir: Path) -> str:
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return branch
|
||||
@@ -90,18 +96,32 @@ def _get_changed_files_from_git(
|
||||
worktree_path: Path, base_branch: str = "main"
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get list of changed files from git diff between base branch and HEAD.
|
||||
Get list of files changed by the task (not files changed on base branch).
|
||||
|
||||
Uses merge-base to accurately identify only the files modified in the worktree,
|
||||
not files that changed on the base branch since the worktree was created.
|
||||
|
||||
Args:
|
||||
worktree_path: Path to the worktree
|
||||
base_branch: Base branch to compare against (default: main)
|
||||
|
||||
Returns:
|
||||
List of changed file paths
|
||||
List of changed file paths (task changes only)
|
||||
"""
|
||||
try:
|
||||
# First, get the merge-base (the point where the worktree branched)
|
||||
merge_base_result = subprocess.run(
|
||||
["git", "merge-base", base_branch, "HEAD"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
merge_base = merge_base_result.stdout.strip()
|
||||
|
||||
# Use two-dot diff from merge-base to get only task's changes
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", f"{base_branch}...HEAD"],
|
||||
["git", "diff", "--name-only", f"{merge_base}..HEAD"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -113,10 +133,10 @@ def _get_changed_files_from_git(
|
||||
# Log the failure before trying fallback
|
||||
debug_warning(
|
||||
"workspace_commands",
|
||||
f"git diff (three-dot) failed: returncode={e.returncode}, "
|
||||
f"git diff with merge-base failed: returncode={e.returncode}, "
|
||||
f"stderr={e.stderr.strip() if e.stderr else 'N/A'}",
|
||||
)
|
||||
# Fallback: try without the three-dot notation
|
||||
# Fallback: try direct two-arg diff (less accurate but works)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", base_branch, "HEAD"],
|
||||
@@ -131,12 +151,176 @@ def _get_changed_files_from_git(
|
||||
# Log the failure before returning empty list
|
||||
debug_warning(
|
||||
"workspace_commands",
|
||||
f"git diff (two-arg) failed: returncode={e.returncode}, "
|
||||
f"git diff (fallback) failed: returncode={e.returncode}, "
|
||||
f"stderr={e.stderr.strip() if e.stderr else 'N/A'}",
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def _detect_worktree_base_branch(
|
||||
project_dir: Path,
|
||||
worktree_path: Path,
|
||||
spec_name: str,
|
||||
) -> str | None:
|
||||
"""
|
||||
Detect which branch a worktree was created from.
|
||||
|
||||
Tries multiple strategies:
|
||||
1. Check worktree config file (.auto-claude/worktree-config.json)
|
||||
2. Find merge-base with known branches (develop, main, master)
|
||||
3. Return None if unable to detect
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
worktree_path: Path to the worktree
|
||||
spec_name: Name of the spec
|
||||
|
||||
Returns:
|
||||
The detected base branch name, or None if unable to detect
|
||||
"""
|
||||
# Strategy 1: Check for worktree config file
|
||||
config_path = worktree_path / ".auto-claude" / "worktree-config.json"
|
||||
if config_path.exists():
|
||||
try:
|
||||
config = json.loads(config_path.read_text())
|
||||
if config.get("base_branch"):
|
||||
debug(
|
||||
MODULE,
|
||||
f"Found base branch in worktree config: {config['base_branch']}",
|
||||
)
|
||||
return config["base_branch"]
|
||||
except Exception as e:
|
||||
debug_warning(MODULE, f"Failed to read worktree config: {e}")
|
||||
|
||||
# Strategy 2: Find which branch has the closest merge-base
|
||||
# Check common branches: develop, main, master
|
||||
spec_branch = f"auto-claude/{spec_name}"
|
||||
candidate_branches = ["develop", "main", "master"]
|
||||
|
||||
best_branch = None
|
||||
best_commits_behind = float("inf")
|
||||
|
||||
for branch in candidate_branches:
|
||||
try:
|
||||
# Check if branch exists
|
||||
check = subprocess.run(
|
||||
["git", "rev-parse", "--verify", branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if check.returncode != 0:
|
||||
continue
|
||||
|
||||
# Get merge base
|
||||
merge_base_result = subprocess.run(
|
||||
["git", "merge-base", branch, spec_branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if merge_base_result.returncode != 0:
|
||||
continue
|
||||
|
||||
merge_base = merge_base_result.stdout.strip()
|
||||
|
||||
# Count commits between merge-base and branch tip
|
||||
# The branch with fewer commits ahead is likely the one we branched from
|
||||
ahead_result = subprocess.run(
|
||||
["git", "rev-list", "--count", f"{merge_base}..{branch}"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if ahead_result.returncode == 0:
|
||||
commits_ahead = int(ahead_result.stdout.strip())
|
||||
debug(
|
||||
MODULE,
|
||||
f"Branch {branch} is {commits_ahead} commits ahead of merge-base",
|
||||
)
|
||||
if commits_ahead < best_commits_behind:
|
||||
best_commits_behind = commits_ahead
|
||||
best_branch = branch
|
||||
except Exception as e:
|
||||
debug_warning(MODULE, f"Error checking branch {branch}: {e}")
|
||||
continue
|
||||
|
||||
if best_branch:
|
||||
debug(
|
||||
MODULE,
|
||||
f"Detected base branch from git history: {best_branch} (commits ahead: {best_commits_behind})",
|
||||
)
|
||||
return best_branch
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _detect_parallel_task_conflicts(
|
||||
project_dir: Path,
|
||||
current_task_id: str,
|
||||
current_task_files: list[str],
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Detect potential conflicts between this task and other active tasks.
|
||||
|
||||
Uses existing evolution data to check if any of this task's files
|
||||
have been modified by other active tasks. This is a lightweight check
|
||||
that doesn't require re-processing all files.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
current_task_id: ID of the current task
|
||||
current_task_files: Files modified by this task (from git diff)
|
||||
|
||||
Returns:
|
||||
List of conflict dictionaries with 'file' and 'tasks' keys
|
||||
"""
|
||||
try:
|
||||
from merge import MergeOrchestrator
|
||||
|
||||
# Initialize orchestrator just to access evolution data
|
||||
orchestrator = MergeOrchestrator(
|
||||
project_dir,
|
||||
enable_ai=False,
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
# Get all active tasks from evolution data
|
||||
active_tasks = orchestrator.evolution_tracker.get_active_tasks()
|
||||
|
||||
# Remove current task from active tasks
|
||||
other_active_tasks = active_tasks - {current_task_id}
|
||||
|
||||
if not other_active_tasks:
|
||||
return []
|
||||
|
||||
# Convert current task files to a set for fast lookup
|
||||
current_files_set = set(current_task_files)
|
||||
|
||||
# Get files modified by other active tasks
|
||||
conflicts = []
|
||||
other_task_files = orchestrator.evolution_tracker.get_files_modified_by_tasks(
|
||||
list(other_active_tasks)
|
||||
)
|
||||
|
||||
# Find intersection - files modified by both this task and other tasks
|
||||
for file_path, tasks in other_task_files.items():
|
||||
if file_path in current_files_set:
|
||||
# This file was modified by both current task and other task(s)
|
||||
all_tasks = [current_task_id] + tasks
|
||||
conflicts.append({"file": file_path, "tasks": all_tasks})
|
||||
|
||||
return conflicts
|
||||
|
||||
except Exception as e:
|
||||
# If anything fails, just return empty - parallel task detection is optional
|
||||
debug_warning(
|
||||
"workspace_commands",
|
||||
f"Parallel task conflict detection failed: {e}",
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
# Import debug utilities
|
||||
try:
|
||||
from debug import (
|
||||
@@ -352,7 +536,9 @@ def handle_cleanup_worktrees_command(project_dir: Path) -> None:
|
||||
cleanup_all_worktrees(project_dir, confirm=True)
|
||||
|
||||
|
||||
def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
|
||||
def _check_git_merge_conflicts(
|
||||
project_dir: Path, spec_name: str, base_branch: str | None = None
|
||||
) -> dict:
|
||||
"""
|
||||
Check for git-level merge conflicts WITHOUT modifying the working directory.
|
||||
|
||||
@@ -362,6 +548,7 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_name: Name of the spec
|
||||
base_branch: Branch the task was created from (default: auto-detect)
|
||||
|
||||
Returns:
|
||||
Dictionary with git conflict information:
|
||||
@@ -380,21 +567,25 @@ def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
|
||||
"has_conflicts": False,
|
||||
"conflicting_files": [],
|
||||
"needs_rebase": False,
|
||||
"base_branch": "main",
|
||||
"base_branch": base_branch or "main",
|
||||
"spec_branch": spec_branch,
|
||||
"commits_behind": 0,
|
||||
}
|
||||
|
||||
try:
|
||||
# Get the current branch (base branch)
|
||||
base_result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if base_result.returncode == 0:
|
||||
result["base_branch"] = base_result.stdout.strip()
|
||||
# Use provided base_branch, or detect from current HEAD
|
||||
if not base_branch:
|
||||
base_result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if base_result.returncode == 0:
|
||||
result["base_branch"] = base_result.stdout.strip()
|
||||
else:
|
||||
result["base_branch"] = base_branch
|
||||
debug(MODULE, f"Using provided base branch: {base_branch}")
|
||||
|
||||
# Get the merge base commit
|
||||
merge_base_result = subprocess.run(
|
||||
@@ -553,7 +744,6 @@ def handle_merge_preview_command(
|
||||
spec_name=spec_name,
|
||||
)
|
||||
|
||||
from merge import MergeOrchestrator
|
||||
from workspace import get_existing_build_worktree
|
||||
|
||||
worktree_path = get_existing_build_worktree(project_dir, spec_name)
|
||||
@@ -580,16 +770,32 @@ def handle_merge_preview_command(
|
||||
}
|
||||
|
||||
try:
|
||||
# First, check for git-level conflicts (diverged branches)
|
||||
git_conflicts = _check_git_merge_conflicts(project_dir, spec_name)
|
||||
|
||||
# Determine the task's source branch (where the task was created from)
|
||||
# Use provided base_branch (from task metadata), or fall back to detected default
|
||||
# Priority:
|
||||
# 1. Provided base_branch (from task metadata)
|
||||
# 2. Detect from worktree's git history (find which branch it diverged from)
|
||||
# 3. Fall back to default branch detection (main/master)
|
||||
task_source_branch = base_branch
|
||||
if not task_source_branch:
|
||||
# Auto-detect the default branch (main/master) that worktrees are typically created from
|
||||
# Try to detect from worktree's git history
|
||||
task_source_branch = _detect_worktree_base_branch(
|
||||
project_dir, worktree_path, spec_name
|
||||
)
|
||||
if not task_source_branch:
|
||||
# Fall back to auto-detecting main/master
|
||||
task_source_branch = _detect_default_branch(project_dir)
|
||||
|
||||
debug(
|
||||
MODULE,
|
||||
f"Using task source branch: {task_source_branch}",
|
||||
provided=base_branch is not None,
|
||||
)
|
||||
|
||||
# Check for git-level conflicts (diverged branches) using the task's source branch
|
||||
git_conflicts = _check_git_merge_conflicts(
|
||||
project_dir, spec_name, base_branch=task_source_branch
|
||||
)
|
||||
|
||||
# Get actual changed files from git diff (this is the authoritative count)
|
||||
all_changed_files = _get_changed_files_from_git(
|
||||
worktree_path, task_source_branch
|
||||
@@ -600,49 +806,39 @@ def handle_merge_preview_command(
|
||||
changed_files=all_changed_files[:10], # Log first 10
|
||||
)
|
||||
|
||||
debug(MODULE, "Initializing MergeOrchestrator for preview...")
|
||||
# OPTIMIZATION: Skip expensive refresh_from_git() and preview_merge() calls
|
||||
# For merge-preview, we only need to detect:
|
||||
# 1. Git conflicts (task vs base branch) - already calculated in _check_git_merge_conflicts()
|
||||
# 2. Parallel task conflicts (this task vs other active tasks)
|
||||
#
|
||||
# For parallel task detection, we just check if this task's files overlap
|
||||
# with files OTHER tasks have already recorded - no need to re-process all files.
|
||||
|
||||
# Initialize the orchestrator
|
||||
orchestrator = MergeOrchestrator(
|
||||
project_dir,
|
||||
enable_ai=False, # Don't use AI for preview
|
||||
dry_run=True, # Don't write anything
|
||||
debug(MODULE, "Checking for parallel task conflicts (lightweight)...")
|
||||
|
||||
# Check for parallel task conflicts by looking at existing evolution data
|
||||
parallel_conflicts = _detect_parallel_task_conflicts(
|
||||
project_dir, spec_name, all_changed_files
|
||||
)
|
||||
|
||||
# Refresh evolution data from the worktree
|
||||
# Compare against the task's source branch (where the task was created from)
|
||||
debug(
|
||||
MODULE,
|
||||
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
|
||||
f"Parallel task conflicts detected: {len(parallel_conflicts)}",
|
||||
conflicts=parallel_conflicts[:5] if parallel_conflicts else [],
|
||||
)
|
||||
|
||||
# Get merge preview (semantic conflicts between parallel tasks)
|
||||
debug(MODULE, "Generating merge preview...")
|
||||
preview = orchestrator.preview_merge([spec_name])
|
||||
|
||||
# Transform semantic conflicts to UI-friendly format
|
||||
# Build conflict list - start with parallel task conflicts
|
||||
conflicts = []
|
||||
for c in preview.get("conflicts", []):
|
||||
debug_verbose(
|
||||
MODULE,
|
||||
"Processing semantic conflict",
|
||||
file=c.get("file", ""),
|
||||
severity=c.get("severity", "unknown"),
|
||||
)
|
||||
for pc in parallel_conflicts:
|
||||
conflicts.append(
|
||||
{
|
||||
"file": c.get("file", ""),
|
||||
"location": c.get("location", ""),
|
||||
"tasks": c.get("tasks", []),
|
||||
"severity": c.get("severity", "unknown"),
|
||||
"canAutoMerge": c.get("can_auto_merge", False),
|
||||
"strategy": c.get("strategy"),
|
||||
"reason": c.get("reason", ""),
|
||||
"type": "semantic",
|
||||
"file": pc["file"],
|
||||
"location": "file-level",
|
||||
"tasks": pc["tasks"],
|
||||
"severity": "medium",
|
||||
"canAutoMerge": False,
|
||||
"strategy": None,
|
||||
"reason": f"File modified by multiple active tasks: {', '.join(pc['tasks'])}",
|
||||
"type": "parallel",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -669,13 +865,14 @@ def handle_merge_preview_command(
|
||||
}
|
||||
)
|
||||
|
||||
summary = preview.get("summary", {})
|
||||
# Count only non-lock-file conflicts
|
||||
git_conflict_count = len(git_conflicts.get("conflicting_files", [])) - len(
|
||||
lock_files_excluded
|
||||
)
|
||||
total_conflicts = summary.get("total_conflicts", 0) + git_conflict_count
|
||||
conflict_files = summary.get("conflict_files", 0) + git_conflict_count
|
||||
# Calculate totals from our conflict lists (git conflicts + parallel conflicts)
|
||||
parallel_conflict_count = len(parallel_conflicts)
|
||||
total_conflicts = git_conflict_count + parallel_conflict_count
|
||||
conflict_files = git_conflict_count + parallel_conflict_count
|
||||
|
||||
# Filter lock files from the git conflicts list for the response
|
||||
non_lock_conflicting_files = [
|
||||
@@ -761,7 +958,7 @@ def handle_merge_preview_command(
|
||||
"totalFiles": total_files_from_git,
|
||||
"conflictFiles": conflict_files,
|
||||
"totalConflicts": total_conflicts,
|
||||
"autoMergeable": summary.get("auto_mergeable", 0),
|
||||
"autoMergeable": 0, # Not tracking auto-merge in lightweight mode
|
||||
"hasGitConflicts": git_conflicts["has_conflicts"]
|
||||
and len(non_lock_conflicting_files) > 0,
|
||||
# Include path-mapped AI merge count for UI display
|
||||
@@ -776,10 +973,9 @@ def handle_merge_preview_command(
|
||||
"Merge preview complete",
|
||||
total_files=result["summary"]["totalFiles"],
|
||||
total_files_source="git_diff",
|
||||
semantic_tracked_files=summary.get("total_files", 0),
|
||||
total_conflicts=result["summary"]["totalConflicts"],
|
||||
has_git_conflicts=git_conflicts["has_conflicts"],
|
||||
auto_mergeable=result["summary"]["autoMergeable"],
|
||||
parallel_conflicts=parallel_conflict_count,
|
||||
path_mapped_ai_merges=len(path_mapped_ai_merges),
|
||||
total_renames=len(path_mappings),
|
||||
)
|
||||
@@ -805,3 +1001,220 @@ def handle_merge_preview_command(
|
||||
"pathMappedAIMergeCount": 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def handle_create_pr_command(
|
||||
project_dir: Path,
|
||||
spec_name: str,
|
||||
target_branch: str | None = None,
|
||||
title: str | None = None,
|
||||
draft: bool = False,
|
||||
) -> CreatePRResult:
|
||||
"""
|
||||
Handle the --create-pr command: push branch and create a GitHub PR.
|
||||
|
||||
Args:
|
||||
project_dir: Path to the project directory
|
||||
spec_name: Name of the spec (e.g., "001-feature-name")
|
||||
target_branch: Target branch for PR (defaults to base branch)
|
||||
title: Custom PR title (defaults to spec name)
|
||||
draft: Whether to create as draft PR
|
||||
|
||||
Returns:
|
||||
CreatePRResult with success status, pr_url, and any errors
|
||||
"""
|
||||
from core.worktree import WorktreeManager
|
||||
|
||||
print_banner()
|
||||
print("\n" + "=" * 70)
|
||||
print(" CREATE PULL REQUEST")
|
||||
print("=" * 70)
|
||||
|
||||
# Check if worktree exists
|
||||
worktree_path = get_existing_build_worktree(project_dir, spec_name)
|
||||
if not worktree_path:
|
||||
print(f"\n{icon(Icons.ERROR)} No build found for spec: {spec_name}")
|
||||
print("\nA completed build worktree is required to create a PR.")
|
||||
print("Run your build first, then use --create-pr.")
|
||||
error_result: CreatePRResult = {
|
||||
"success": False,
|
||||
"error": "No build found for this spec",
|
||||
}
|
||||
return error_result
|
||||
|
||||
# Create worktree manager
|
||||
manager = WorktreeManager(project_dir, base_branch=target_branch)
|
||||
|
||||
print(f"\n{icon(Icons.BRANCH)} Pushing branch and creating PR...")
|
||||
print(f" Spec: {spec_name}")
|
||||
print(f" Target: {target_branch or manager.base_branch}")
|
||||
if title:
|
||||
print(f" Title: {title}")
|
||||
if draft:
|
||||
print(" Mode: Draft PR")
|
||||
|
||||
# Push and create PR with exception handling for clean JSON output
|
||||
try:
|
||||
raw_result = manager.push_and_create_pr(
|
||||
spec_name=spec_name,
|
||||
target_branch=target_branch,
|
||||
title=title,
|
||||
draft=draft,
|
||||
)
|
||||
except Exception as e:
|
||||
debug_error(MODULE, f"Exception during PR creation: {e}")
|
||||
error_result: CreatePRResult = {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": "Failed to create PR",
|
||||
}
|
||||
print(f"\n{icon(Icons.ERROR)} Failed to create PR: {e}")
|
||||
print(json.dumps(error_result))
|
||||
return error_result
|
||||
|
||||
# Convert PushAndCreatePRResult to CreatePRResult
|
||||
result: CreatePRResult = {
|
||||
"success": raw_result.get("success", False),
|
||||
"pr_url": raw_result.get("pr_url"),
|
||||
"already_exists": raw_result.get("already_exists", False),
|
||||
"error": raw_result.get("error"),
|
||||
"message": raw_result.get("message"),
|
||||
"pushed": raw_result.get("pushed", False),
|
||||
"remote": raw_result.get("remote", ""),
|
||||
"branch": raw_result.get("branch", ""),
|
||||
}
|
||||
|
||||
if result.get("success"):
|
||||
pr_url = result.get("pr_url")
|
||||
already_exists = result.get("already_exists", False)
|
||||
|
||||
if already_exists:
|
||||
print(f"\n{icon(Icons.SUCCESS)} PR already exists!")
|
||||
else:
|
||||
print(f"\n{icon(Icons.SUCCESS)} PR created successfully!")
|
||||
|
||||
if pr_url:
|
||||
print(f"\n{icon(Icons.LINK)} {pr_url}")
|
||||
else:
|
||||
print(f"\n{icon(Icons.INFO)} Check GitHub for the PR URL")
|
||||
|
||||
print("\nNext steps:")
|
||||
print(" 1. Review the PR on GitHub")
|
||||
print(" 2. Request reviews from your team")
|
||||
print(" 3. Merge when approved")
|
||||
|
||||
# Output JSON for frontend parsing
|
||||
print(json.dumps(result))
|
||||
return result
|
||||
else:
|
||||
error = result.get("error", "Unknown error")
|
||||
print(f"\n{icon(Icons.ERROR)} Failed to create PR: {error}")
|
||||
# Output JSON for frontend parsing
|
||||
print(json.dumps(result))
|
||||
return result
|
||||
|
||||
|
||||
def cleanup_old_worktrees_command(
|
||||
project_dir: Path, days: int = 30, dry_run: bool = False
|
||||
) -> dict:
|
||||
"""
|
||||
Clean up old worktrees that haven't been modified in the specified number of days.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
days: Number of days threshold (default: 30)
|
||||
dry_run: If True, only show what would be removed (default: False)
|
||||
|
||||
Returns:
|
||||
Dictionary with cleanup results
|
||||
"""
|
||||
try:
|
||||
manager = WorktreeManager(project_dir)
|
||||
|
||||
removed, failed = manager.cleanup_old_worktrees(
|
||||
days_threshold=days, dry_run=dry_run
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"removed": removed,
|
||||
"failed": failed,
|
||||
"dry_run": dry_run,
|
||||
"days_threshold": days,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"removed": [],
|
||||
"failed": [],
|
||||
}
|
||||
|
||||
|
||||
def worktree_summary_command(project_dir: Path) -> dict:
|
||||
"""
|
||||
Get a summary of all worktrees with age information.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
|
||||
Returns:
|
||||
Dictionary with worktree summary data
|
||||
"""
|
||||
try:
|
||||
manager = WorktreeManager(project_dir)
|
||||
|
||||
# Print to console for CLI usage
|
||||
manager.print_worktree_summary()
|
||||
|
||||
# Also return data for programmatic access
|
||||
worktrees = manager.list_all_worktrees()
|
||||
warning = manager.get_worktree_count_warning()
|
||||
|
||||
# Categorize by age
|
||||
recent = []
|
||||
week_old = []
|
||||
month_old = []
|
||||
very_old = []
|
||||
unknown_age = []
|
||||
|
||||
for info in worktrees:
|
||||
data = {
|
||||
"spec_name": info.spec_name,
|
||||
"days_since_last_commit": info.days_since_last_commit,
|
||||
"commit_count": info.commit_count,
|
||||
}
|
||||
|
||||
if info.days_since_last_commit is None:
|
||||
unknown_age.append(data)
|
||||
elif info.days_since_last_commit < 7:
|
||||
recent.append(data)
|
||||
elif info.days_since_last_commit < 30:
|
||||
week_old.append(data)
|
||||
elif info.days_since_last_commit < 90:
|
||||
month_old.append(data)
|
||||
else:
|
||||
very_old.append(data)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"total_worktrees": len(worktrees),
|
||||
"categories": {
|
||||
"recent": recent,
|
||||
"week_old": week_old,
|
||||
"month_old": month_old,
|
||||
"very_old": very_old,
|
||||
"unknown_age": unknown_age,
|
||||
},
|
||||
"warning": warning,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"total_worktrees": 0,
|
||||
"categories": {},
|
||||
"warning": None,
|
||||
}
|
||||
|
||||
@@ -231,7 +231,9 @@ async def _call_claude(prompt: str) -> str:
|
||||
msg_type = type(msg).__name__
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
if hasattr(block, "text"):
|
||||
# Must check block type - only TextBlock has .text attribute
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
|
||||
logger.info(f"Generated commit message: {len(response_text)} chars")
|
||||
|
||||
@@ -39,7 +39,7 @@ from agents import (
|
||||
run_followup_planner,
|
||||
save_session_memory,
|
||||
save_session_to_graphiti,
|
||||
sync_plan_to_source,
|
||||
sync_spec_to_source,
|
||||
)
|
||||
|
||||
# Ensure all exports are available at module level
|
||||
@@ -57,7 +57,7 @@ __all__ = [
|
||||
"load_implementation_plan",
|
||||
"find_subtask_in_plan",
|
||||
"find_phase_for_subtask",
|
||||
"sync_plan_to_source",
|
||||
"sync_spec_to_source",
|
||||
"AUTO_CONTINUE_DELAY_SECONDS",
|
||||
"HUMAN_INTERVENTION_FILE",
|
||||
]
|
||||
|
||||
@@ -36,6 +36,8 @@ SDK_ENV_VARS = [
|
||||
"DISABLE_TELEMETRY",
|
||||
"DISABLE_COST_WARNINGS",
|
||||
"API_TIMEOUT_MS",
|
||||
# Windows-specific: Git Bash path for Claude Code CLI
|
||||
"CLAUDE_CODE_GIT_BASH_PATH",
|
||||
]
|
||||
|
||||
|
||||
@@ -215,6 +217,85 @@ def require_auth_token() -> str:
|
||||
return token
|
||||
|
||||
|
||||
def _find_git_bash_path() -> str | None:
|
||||
"""
|
||||
Find git-bash (bash.exe) path on Windows.
|
||||
|
||||
Uses 'where git' to find git.exe, then derives bash.exe location from it.
|
||||
Git for Windows installs bash.exe in the 'bin' directory alongside git.exe
|
||||
or in the parent 'bin' directory when git.exe is in 'cmd'.
|
||||
|
||||
Returns:
|
||||
Full path to bash.exe if found, None otherwise
|
||||
"""
|
||||
if platform.system() != "Windows":
|
||||
return None
|
||||
|
||||
# If already set in environment, use that
|
||||
existing = os.environ.get("CLAUDE_CODE_GIT_BASH_PATH")
|
||||
if existing and os.path.exists(existing):
|
||||
return existing
|
||||
|
||||
git_path = None
|
||||
|
||||
# Method 1: Use 'where' command to find git.exe
|
||||
try:
|
||||
# Use where.exe explicitly for reliability
|
||||
result = subprocess.run(
|
||||
["where.exe", "git"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
shell=False,
|
||||
)
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
git_paths = result.stdout.strip().splitlines()
|
||||
if git_paths:
|
||||
git_path = git_paths[0].strip()
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
|
||||
# Intentionally suppress errors - best-effort detection with fallback to common paths
|
||||
pass
|
||||
|
||||
# Method 2: Check common installation paths if 'where' didn't work
|
||||
if not git_path:
|
||||
common_git_paths = [
|
||||
os.path.expandvars(r"%PROGRAMFILES%\Git\cmd\git.exe"),
|
||||
os.path.expandvars(r"%PROGRAMFILES%\Git\bin\git.exe"),
|
||||
os.path.expandvars(r"%PROGRAMFILES(X86)%\Git\cmd\git.exe"),
|
||||
os.path.expandvars(r"%LOCALAPPDATA%\Programs\Git\cmd\git.exe"),
|
||||
]
|
||||
for path in common_git_paths:
|
||||
if os.path.exists(path):
|
||||
git_path = path
|
||||
break
|
||||
|
||||
if not git_path:
|
||||
return None
|
||||
|
||||
# Derive bash.exe location from git.exe location
|
||||
# Git for Windows structure:
|
||||
# C:\...\Git\cmd\git.exe -> bash.exe is at C:\...\Git\bin\bash.exe
|
||||
# C:\...\Git\bin\git.exe -> bash.exe is at C:\...\Git\bin\bash.exe
|
||||
# C:\...\Git\mingw64\bin\git.exe -> bash.exe is at C:\...\Git\bin\bash.exe
|
||||
git_dir = os.path.dirname(git_path)
|
||||
git_parent = os.path.dirname(git_dir)
|
||||
git_grandparent = os.path.dirname(git_parent)
|
||||
|
||||
# Check common bash.exe locations relative to git installation
|
||||
possible_bash_paths = [
|
||||
os.path.join(git_parent, "bin", "bash.exe"), # cmd -> bin
|
||||
os.path.join(git_dir, "bash.exe"), # If git.exe is in bin
|
||||
os.path.join(git_grandparent, "bin", "bash.exe"), # mingw64/bin -> bin
|
||||
]
|
||||
|
||||
for bash_path in possible_bash_paths:
|
||||
if os.path.exists(bash_path):
|
||||
return bash_path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_sdk_env_vars() -> dict[str, str]:
|
||||
"""
|
||||
Get environment variables to pass to SDK.
|
||||
@@ -222,6 +303,8 @@ def get_sdk_env_vars() -> dict[str, str]:
|
||||
Collects relevant env vars (ANTHROPIC_BASE_URL, etc.) that should
|
||||
be passed through to the claude-agent-sdk subprocess.
|
||||
|
||||
On Windows, auto-detects CLAUDE_CODE_GIT_BASH_PATH if not already set.
|
||||
|
||||
Returns:
|
||||
Dict of env var name -> value for non-empty vars
|
||||
"""
|
||||
@@ -230,6 +313,14 @@ def get_sdk_env_vars() -> dict[str, str]:
|
||||
value = os.environ.get(var)
|
||||
if value:
|
||||
env[var] = value
|
||||
|
||||
# On Windows, auto-detect git-bash path if not already set
|
||||
# Claude Code CLI requires bash.exe to run on Windows
|
||||
if platform.system() == "Windows" and "CLAUDE_CODE_GIT_BASH_PATH" not in env:
|
||||
bash_path = _find_git_bash_path()
|
||||
if bash_path:
|
||||
env["CLAUDE_CODE_GIT_BASH_PATH"] = bash_path
|
||||
|
||||
return env
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import platform
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -488,6 +489,12 @@ def create_client(
|
||||
# Collect env vars to pass to SDK (ANTHROPIC_BASE_URL, etc.)
|
||||
sdk_env = get_sdk_env_vars()
|
||||
|
||||
# Debug: Log git-bash path detection on Windows
|
||||
if "CLAUDE_CODE_GIT_BASH_PATH" in sdk_env:
|
||||
logger.info(f"Git Bash path found: {sdk_env['CLAUDE_CODE_GIT_BASH_PATH']}")
|
||||
elif platform.system() == "Windows":
|
||||
logger.warning("Git Bash path not detected on Windows!")
|
||||
|
||||
# Check if Linear integration is enabled
|
||||
linear_enabled = is_linear_enabled()
|
||||
linear_api_key = os.environ.get("LINEAR_API_KEY", "")
|
||||
@@ -538,6 +545,48 @@ def create_client(
|
||||
# cases where Claude uses absolute paths for file operations
|
||||
project_path_str = str(project_dir.resolve())
|
||||
spec_path_str = str(spec_dir.resolve())
|
||||
|
||||
# Detect if we're running in a worktree and get the original project directory
|
||||
# Worktrees are located in either:
|
||||
# - .auto-claude/worktrees/tasks/{spec-name}/ (new location)
|
||||
# - .worktrees/{spec-name}/ (legacy location)
|
||||
# When running in a worktree, we need to allow access to both the worktree
|
||||
# and the original project's .auto-claude/ directory for spec files
|
||||
original_project_permissions = []
|
||||
resolved_project_path = project_dir.resolve()
|
||||
|
||||
# Check for worktree paths and extract original project directory
|
||||
# This handles spec worktrees, PR review worktrees, and legacy worktrees
|
||||
# Note: Windows paths are normalized to forward slashes before comparison
|
||||
worktree_markers = [
|
||||
"/.auto-claude/worktrees/tasks/", # Spec/task worktrees
|
||||
"/.auto-claude/github/pr/worktrees/", # PR review worktrees
|
||||
"/.worktrees/", # Legacy worktree location
|
||||
]
|
||||
project_path_posix = str(resolved_project_path).replace("\\", "/")
|
||||
|
||||
for marker in worktree_markers:
|
||||
if marker in project_path_posix:
|
||||
# Extract the original project directory (parent of worktree location)
|
||||
# Use rsplit to get the rightmost occurrence (handles nested projects)
|
||||
original_project_str = project_path_posix.rsplit(marker, 1)[0]
|
||||
original_project_dir = Path(original_project_str)
|
||||
|
||||
# Grant permissions for relevant directories in the original project
|
||||
permission_ops = ["Read", "Write", "Edit", "Glob", "Grep"]
|
||||
dirs_to_permit = [
|
||||
original_project_dir / ".auto-claude",
|
||||
original_project_dir / ".worktrees", # Legacy support
|
||||
]
|
||||
|
||||
for dir_path in dirs_to_permit:
|
||||
if dir_path.exists():
|
||||
path_str = str(dir_path.resolve())
|
||||
original_project_permissions.extend(
|
||||
[f"{op}({path_str}/**)" for op in permission_ops]
|
||||
)
|
||||
break
|
||||
|
||||
security_settings = {
|
||||
"sandbox": {"enabled": True, "autoAllowBashIfSandboxed": True},
|
||||
"permissions": {
|
||||
@@ -560,6 +609,9 @@ def create_client(
|
||||
f"Read({spec_path_str}/**)",
|
||||
f"Write({spec_path_str}/**)",
|
||||
f"Edit({spec_path_str}/**)",
|
||||
# Allow original project's .auto-claude/ and .worktrees/ directories
|
||||
# when running in a worktree (fixes issue #385 - permission errors)
|
||||
*original_project_permissions,
|
||||
# Bash permission granted here, but actual commands are validated
|
||||
# by the bash_security_hook (see security.py for allowed commands)
|
||||
"Bash(*)",
|
||||
@@ -596,6 +648,8 @@ def create_client(
|
||||
print(f"Security settings: {settings_file}")
|
||||
print(" - Sandbox enabled (OS-level bash isolation)")
|
||||
print(f" - Filesystem restricted to: {project_dir.resolve()}")
|
||||
if original_project_permissions:
|
||||
print(" - Worktree permissions: granted for original project directories")
|
||||
print(" - Bash commands restricted to allowlist")
|
||||
if max_thinking_tokens:
|
||||
print(f" - Extended thinking: {max_thinking_tokens:,} tokens")
|
||||
@@ -742,6 +796,12 @@ def create_client(
|
||||
"settings": str(settings_file.resolve()),
|
||||
"env": sdk_env, # Pass ANTHROPIC_BASE_URL etc. to subprocess
|
||||
"max_thinking_tokens": max_thinking_tokens, # Extended thinking budget
|
||||
"max_buffer_size": 10
|
||||
* 1024
|
||||
* 1024, # 10MB buffer (default: 1MB) - fixes large tool results
|
||||
# Enable file checkpointing to track file read/write state across tool calls
|
||||
# This prevents "File has not been read yet" errors in recovery sessions
|
||||
"enable_file_checkpointing": True,
|
||||
}
|
||||
|
||||
# Add structured output format if specified
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Dependency Validator
|
||||
====================
|
||||
|
||||
Validates platform-specific dependencies are installed before running agents.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def validate_platform_dependencies() -> None:
|
||||
"""
|
||||
Validate that platform-specific dependencies are installed.
|
||||
|
||||
Raises:
|
||||
SystemExit: If required platform-specific dependencies are missing,
|
||||
with helpful installation instructions.
|
||||
"""
|
||||
# Check Windows-specific dependencies
|
||||
if sys.platform == "win32" and sys.version_info >= (3, 12):
|
||||
try:
|
||||
import pywintypes # noqa: F401
|
||||
except ImportError:
|
||||
_exit_with_pywin32_error()
|
||||
|
||||
|
||||
def _exit_with_pywin32_error() -> None:
|
||||
"""Exit with helpful error message for missing pywin32."""
|
||||
# Use sys.prefix to detect the virtual environment path
|
||||
# This works for venv and poetry environments
|
||||
venv_activate = Path(sys.prefix) / "Scripts" / "activate"
|
||||
|
||||
sys.exit(
|
||||
"Error: Required Windows dependency 'pywin32' is not installed.\n"
|
||||
"\n"
|
||||
"Auto Claude requires pywin32 on Windows for LadybugDB/Graphiti memory integration.\n"
|
||||
"\n"
|
||||
"To fix this:\n"
|
||||
"1. Activate your virtual environment:\n"
|
||||
f" {venv_activate}\n"
|
||||
"\n"
|
||||
"2. Install pywin32:\n"
|
||||
" pip install pywin32>=306\n"
|
||||
"\n"
|
||||
" Or reinstall all dependencies:\n"
|
||||
" pip install -r requirements.txt\n"
|
||||
"\n"
|
||||
f"Current Python: {sys.executable}\n"
|
||||
)
|
||||
@@ -0,0 +1,142 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Git Executable Finder
|
||||
======================
|
||||
|
||||
Utility to find the git executable, with Windows-specific fallbacks.
|
||||
Separated into its own module to avoid circular imports.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
_cached_git_path: str | None = None
|
||||
|
||||
|
||||
def get_git_executable() -> str:
|
||||
"""Find the git executable, with Windows-specific fallbacks.
|
||||
|
||||
Returns the path to git executable. On Windows, checks multiple sources:
|
||||
1. CLAUDE_CODE_GIT_BASH_PATH env var (set by Electron frontend)
|
||||
2. shutil.which (if git is in PATH)
|
||||
3. Common installation locations
|
||||
4. Windows 'where' command
|
||||
|
||||
Caches the result after first successful find.
|
||||
"""
|
||||
global _cached_git_path
|
||||
|
||||
# Return cached result if available
|
||||
if _cached_git_path is not None:
|
||||
return _cached_git_path
|
||||
|
||||
git_path = _find_git_executable()
|
||||
_cached_git_path = git_path
|
||||
return git_path
|
||||
|
||||
|
||||
def _find_git_executable() -> str:
|
||||
"""Internal function to find git executable."""
|
||||
# 1. Check CLAUDE_CODE_GIT_BASH_PATH (set by Electron frontend)
|
||||
# This env var points to bash.exe, we can derive git.exe from it
|
||||
bash_path = os.environ.get("CLAUDE_CODE_GIT_BASH_PATH")
|
||||
if bash_path:
|
||||
try:
|
||||
bash_path_obj = Path(bash_path)
|
||||
if bash_path_obj.exists():
|
||||
git_dir = bash_path_obj.parent.parent
|
||||
# Try cmd/git.exe first (preferred), then bin/git.exe
|
||||
for git_subpath in ["cmd/git.exe", "bin/git.exe"]:
|
||||
git_path = git_dir / git_subpath
|
||||
if git_path.is_file():
|
||||
return str(git_path)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
|
||||
# 2. Try shutil.which (works if git is in PATH)
|
||||
git_path = shutil.which("git")
|
||||
if git_path:
|
||||
return git_path
|
||||
|
||||
# 3. Windows-specific: check common installation locations
|
||||
if os.name == "nt":
|
||||
common_paths = [
|
||||
os.path.expandvars(r"%PROGRAMFILES%\Git\cmd\git.exe"),
|
||||
os.path.expandvars(r"%PROGRAMFILES%\Git\bin\git.exe"),
|
||||
os.path.expandvars(r"%PROGRAMFILES(X86)%\Git\cmd\git.exe"),
|
||||
os.path.expandvars(r"%LOCALAPPDATA%\Programs\Git\cmd\git.exe"),
|
||||
r"C:\Program Files\Git\cmd\git.exe",
|
||||
r"C:\Program Files (x86)\Git\cmd\git.exe",
|
||||
]
|
||||
for path in common_paths:
|
||||
try:
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# 4. Try 'where' command with shell=True (more reliable on Windows)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
"where git",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
shell=True,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
found_path = result.stdout.strip().split("\n")[0].strip()
|
||||
if found_path and os.path.isfile(found_path):
|
||||
return found_path
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
pass
|
||||
|
||||
# Default fallback - let subprocess handle it (may fail)
|
||||
return "git"
|
||||
|
||||
|
||||
def run_git(
|
||||
args: list[str],
|
||||
cwd: Path | str | None = None,
|
||||
timeout: int = 60,
|
||||
input_data: str | None = None,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run a git command with proper executable finding.
|
||||
|
||||
Args:
|
||||
args: Git command arguments (without 'git' prefix)
|
||||
cwd: Working directory for the command
|
||||
timeout: Command timeout in seconds (default: 60)
|
||||
input_data: Optional string data to pass to stdin
|
||||
|
||||
Returns:
|
||||
CompletedProcess with command results.
|
||||
"""
|
||||
git = get_git_executable()
|
||||
try:
|
||||
return subprocess.run(
|
||||
[git] + args,
|
||||
cwd=cwd,
|
||||
input=input_data,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return subprocess.CompletedProcess(
|
||||
args=[git] + args,
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr=f"Command timed out after {timeout} seconds",
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return subprocess.CompletedProcess(
|
||||
args=[git] + args,
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr="Git executable not found. Please ensure git is installed and in PATH.",
|
||||
)
|
||||
@@ -52,4 +52,8 @@ def emit_phase(
|
||||
print(f"{PHASE_MARKER_PREFIX}{json.dumps(payload, default=str)}", flush=True)
|
||||
except (OSError, UnicodeEncodeError) as e:
|
||||
if _DEBUG:
|
||||
print(f"[phase_event] emit failed: {e}", file=sys.stderr, flush=True)
|
||||
try:
|
||||
sys.stderr.write(f"[phase_event] emit failed: {e}\n")
|
||||
sys.stderr.flush()
|
||||
except (OSError, UnicodeEncodeError):
|
||||
pass # Truly silent on complete I/O failure
|
||||
|
||||
@@ -90,12 +90,18 @@ from core.workspace.git_utils import (
|
||||
from core.workspace.git_utils import (
|
||||
detect_file_renames as _detect_file_renames,
|
||||
)
|
||||
from core.workspace.git_utils import (
|
||||
get_binary_file_content_from_ref as _get_binary_file_content_from_ref,
|
||||
)
|
||||
from core.workspace.git_utils import (
|
||||
get_changed_files_from_branch as _get_changed_files_from_branch,
|
||||
)
|
||||
from core.workspace.git_utils import (
|
||||
get_file_content_from_ref as _get_file_content_from_ref,
|
||||
)
|
||||
from core.workspace.git_utils import (
|
||||
is_binary_file as _is_binary_file,
|
||||
)
|
||||
from core.workspace.git_utils import (
|
||||
is_lock_file as _is_lock_file,
|
||||
)
|
||||
@@ -239,14 +245,16 @@ def merge_existing_build(
|
||||
if smart_result is not None:
|
||||
# Smart merge handled it (success or identified conflicts)
|
||||
if smart_result.get("success"):
|
||||
# Check if smart merge resolved git conflicts or path-mapped files
|
||||
# Check if smart merge actually DID work (resolved conflicts via AI)
|
||||
# NOTE: "files_merged" in stats is misleading - it's "files TO merge" not "files WERE merged"
|
||||
# The smart merge preview returns this count but doesn't actually perform the merge
|
||||
# in the no-conflict path. We only skip git merge if AI actually did work.
|
||||
stats = smart_result.get("stats", {})
|
||||
had_conflicts = stats.get("conflicts_resolved", 0) > 0
|
||||
files_merged = stats.get("files_merged", 0) > 0
|
||||
ai_assisted = stats.get("ai_assisted", 0) > 0
|
||||
|
||||
if had_conflicts or files_merged or ai_assisted:
|
||||
# Git conflicts were resolved OR path-mapped files were AI merged
|
||||
if had_conflicts or ai_assisted:
|
||||
# AI actually resolved conflicts or assisted with merges
|
||||
# Changes are already written and staged - no need for git merge
|
||||
_print_merge_success(
|
||||
no_commit, stats, spec_name=spec_name, keep_worktree=True
|
||||
@@ -258,7 +266,8 @@ def merge_existing_build(
|
||||
|
||||
return True
|
||||
else:
|
||||
# No conflicts and no files merged - do standard git merge
|
||||
# No conflicts needed AI resolution - do standard git merge
|
||||
# This is the common case: no divergence, just need to merge changes
|
||||
success_result = manager.merge_worktree(
|
||||
spec_name, delete_after=False, no_commit=no_commit
|
||||
)
|
||||
@@ -773,28 +782,44 @@ def _resolve_git_conflicts_with_ai(
|
||||
print(muted(f" Copying {len(new_files)} new file(s) first (dependencies)..."))
|
||||
for file_path, status in new_files:
|
||||
try:
|
||||
content = _get_file_content_from_ref(
|
||||
project_dir, spec_branch, file_path
|
||||
)
|
||||
if content is not None:
|
||||
# Apply path mapping - write to new location if file was renamed
|
||||
target_file_path = _apply_path_mapping(file_path, path_mappings)
|
||||
target_path = project_dir / target_file_path
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.write_text(content, encoding="utf-8")
|
||||
subprocess.run(
|
||||
["git", "add", target_file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
# Apply path mapping - write to new location if file was renamed
|
||||
target_file_path = _apply_path_mapping(file_path, path_mappings)
|
||||
target_path = project_dir / target_file_path
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Handle binary files differently - use bytes instead of text
|
||||
if _is_binary_file(file_path):
|
||||
binary_content = _get_binary_file_content_from_ref(
|
||||
project_dir, spec_branch, file_path
|
||||
)
|
||||
resolved_files.append(target_file_path)
|
||||
if target_file_path != file_path:
|
||||
debug(
|
||||
MODULE,
|
||||
f"Copied new file with path mapping: {file_path} -> {target_file_path}",
|
||||
if binary_content is not None:
|
||||
target_path.write_bytes(binary_content)
|
||||
subprocess.run(
|
||||
["git", "add", target_file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
else:
|
||||
debug(MODULE, f"Copied new file: {file_path}")
|
||||
resolved_files.append(target_file_path)
|
||||
debug(MODULE, f"Copied new binary file: {file_path}")
|
||||
else:
|
||||
content = _get_file_content_from_ref(
|
||||
project_dir, spec_branch, file_path
|
||||
)
|
||||
if content is not None:
|
||||
target_path.write_text(content, encoding="utf-8")
|
||||
subprocess.run(
|
||||
["git", "add", target_file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
resolved_files.append(target_file_path)
|
||||
if target_file_path != file_path:
|
||||
debug(
|
||||
MODULE,
|
||||
f"Copied new file with path mapping: {file_path} -> {target_file_path}",
|
||||
)
|
||||
else:
|
||||
debug(MODULE, f"Copied new file: {file_path}")
|
||||
except Exception as e:
|
||||
debug_warning(MODULE, f"Could not copy new file {file_path}: {e}")
|
||||
|
||||
@@ -1118,24 +1143,44 @@ def _resolve_git_conflicts_with_ai(
|
||||
)
|
||||
else:
|
||||
# Modified without path change - simple copy
|
||||
content = _get_file_content_from_ref(
|
||||
project_dir, spec_branch, file_path
|
||||
)
|
||||
if content is not None:
|
||||
target_path = project_dir / target_file_path
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.write_text(content, encoding="utf-8")
|
||||
subprocess.run(
|
||||
["git", "add", target_file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
# Check if binary file to use correct read/write method
|
||||
target_path = project_dir / target_file_path
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if _is_binary_file(file_path):
|
||||
binary_content = _get_binary_file_content_from_ref(
|
||||
project_dir, spec_branch, file_path
|
||||
)
|
||||
resolved_files.append(target_file_path)
|
||||
if target_file_path != file_path:
|
||||
debug(
|
||||
MODULE,
|
||||
f"Merged with path mapping: {file_path} -> {target_file_path}",
|
||||
if binary_content is not None:
|
||||
target_path.write_bytes(binary_content)
|
||||
subprocess.run(
|
||||
["git", "add", target_file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
resolved_files.append(target_file_path)
|
||||
if target_file_path != file_path:
|
||||
debug(
|
||||
MODULE,
|
||||
f"Merged binary with path mapping: {file_path} -> {target_file_path}",
|
||||
)
|
||||
else:
|
||||
content = _get_file_content_from_ref(
|
||||
project_dir, spec_branch, file_path
|
||||
)
|
||||
if content is not None:
|
||||
target_path.write_text(content, encoding="utf-8")
|
||||
subprocess.run(
|
||||
["git", "add", target_file_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
resolved_files.append(target_file_path)
|
||||
if target_file_path != file_path:
|
||||
debug(
|
||||
MODULE,
|
||||
f"Merged with path mapping: {file_path} -> {target_file_path}",
|
||||
)
|
||||
except Exception as e:
|
||||
print(muted(f" Warning: Could not process {file_path}: {e}"))
|
||||
|
||||
@@ -1431,7 +1476,9 @@ async def _merge_file_with_ai_async(
|
||||
msg_type = type(msg).__name__
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
if hasattr(block, "text"):
|
||||
# Must check block type - only TextBlock has .text attribute
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
|
||||
if response_text:
|
||||
|
||||
@@ -62,6 +62,7 @@ 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,
|
||||
@@ -70,6 +71,7 @@ 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,
|
||||
@@ -117,6 +119,7 @@ __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",
|
||||
|
||||
@@ -10,6 +10,45 @@ 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
|
||||
@@ -33,6 +72,7 @@ LOCK_FILES = {
|
||||
}
|
||||
|
||||
BINARY_EXTENSIONS = {
|
||||
# Images
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
@@ -41,6 +81,11 @@ BINARY_EXTENSIONS = {
|
||||
".webp",
|
||||
".bmp",
|
||||
".svg",
|
||||
".tiff",
|
||||
".tif",
|
||||
".heic",
|
||||
".heif",
|
||||
# Documents
|
||||
".pdf",
|
||||
".doc",
|
||||
".docx",
|
||||
@@ -48,32 +93,63 @@ 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",
|
||||
".mp4",
|
||||
".wav",
|
||||
".ogg",
|
||||
".flac",
|
||||
".aac",
|
||||
".m4a",
|
||||
# Video
|
||||
".mp4",
|
||||
".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
|
||||
@@ -113,9 +189,8 @@ def detect_file_renames(
|
||||
# -M flag enables rename detection
|
||||
# --diff-filter=R shows only renames
|
||||
# --name-status shows status and file names
|
||||
result = subprocess.run(
|
||||
result = run_git(
|
||||
[
|
||||
"git",
|
||||
"log",
|
||||
"--name-status",
|
||||
"-M",
|
||||
@@ -124,8 +199,6 @@ def detect_file_renames(
|
||||
f"{from_ref}..{to_ref}",
|
||||
],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
@@ -175,39 +248,21 @@ def get_merge_base(project_dir: Path, ref1: str, ref2: str) -> str | None:
|
||||
Returns:
|
||||
Merge-base commit hash, or None if not found
|
||||
"""
|
||||
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
|
||||
result = run_git(["merge-base", ref1, ref2], cwd=project_dir)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
return None
|
||||
|
||||
|
||||
def has_uncommitted_changes(project_dir: Path) -> bool:
|
||||
"""Check if user has unsaved work."""
|
||||
result = subprocess.run(
|
||||
["git", "status", "--porcelain"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
result = run_git(["status", "--porcelain"], cwd=project_dir)
|
||||
return bool(result.stdout.strip())
|
||||
|
||||
|
||||
def get_current_branch(project_dir: Path) -> str:
|
||||
"""Get the current branch name."""
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
result = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=project_dir)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
@@ -239,11 +294,29 @@ 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=True,
|
||||
text=False, # Return bytes, not text
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout
|
||||
@@ -268,11 +341,9 @@ def get_changed_files_from_branch(
|
||||
Returns:
|
||||
List of (file_path, status) tuples
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-status", f"{base_branch}...{spec_branch}"],
|
||||
result = run_git(
|
||||
["diff", "--name-status", f"{base_branch}...{spec_branch}"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
files = []
|
||||
@@ -289,15 +360,23 @@ 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."""
|
||||
# These patterns cover the internal spec/build files that shouldn't be merged
|
||||
"""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)
|
||||
excluded_patterns = [
|
||||
".auto-claude/",
|
||||
"auto-claude/specs/",
|
||||
]
|
||||
for pattern in excluded_patterns:
|
||||
if file_path.startswith(pattern):
|
||||
if normalized.startswith(pattern):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -491,11 +570,9 @@ def create_conflict_file_with_git(
|
||||
try:
|
||||
# git merge-file <current> <base> <other>
|
||||
# Exit codes: 0 = clean merge, 1 = conflicts, >1 = error
|
||||
result = subprocess.run(
|
||||
["git", "merge-file", "-p", main_path, base_path, wt_path],
|
||||
result = run_git(
|
||||
["merge-file", "-p", main_path, base_path, wt_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Read the merged content
|
||||
@@ -522,5 +599,6 @@ _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
|
||||
|
||||
@@ -8,11 +8,12 @@ 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,
|
||||
@@ -267,6 +268,43 @@ def setup_workspace(
|
||||
f"Environment files copied: {', '.join(copied_env_files)}", "success"
|
||||
)
|
||||
|
||||
# Copy security configuration files if they exist
|
||||
# Note: Unlike env files, security files always overwrite to ensure
|
||||
# the worktree uses the same security rules as the main project.
|
||||
# This prevents security bypasses through stale worktree configs.
|
||||
security_files = [
|
||||
ALLOWLIST_FILENAME,
|
||||
PROFILE_FILENAME,
|
||||
]
|
||||
security_files_copied = []
|
||||
|
||||
for filename in security_files:
|
||||
source_file = project_dir / filename
|
||||
if source_file.is_file():
|
||||
target_file = worktree_info.path / filename
|
||||
try:
|
||||
shutil.copy2(source_file, target_file)
|
||||
security_files_copied.append(filename)
|
||||
except (OSError, PermissionError) as e:
|
||||
debug_warning(MODULE, f"Failed to copy {filename}: {e}")
|
||||
print_status(
|
||||
f"Warning: Could not copy {filename} to worktree", "warning"
|
||||
)
|
||||
|
||||
if security_files_copied:
|
||||
print_status(
|
||||
f"Security config copied: {', '.join(security_files_copied)}", "success"
|
||||
)
|
||||
|
||||
# Ensure .auto-claude/ is in the worktree's .gitignore
|
||||
# This is critical because the worktree inherits .gitignore from the base branch,
|
||||
# which may not have .auto-claude/ if that change wasn't committed/pushed.
|
||||
# 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():
|
||||
@@ -368,11 +406,9 @@ def initialize_timeline_tracking(
|
||||
files_to_modify.extend(subtask.get("files", []))
|
||||
|
||||
# Get the current branch point commit
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
result = run_git(
|
||||
["rev-parse", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
branch_point = result.stdout.strip() if result.returncode == 0 else None
|
||||
|
||||
|
||||
+782
-41
@@ -19,8 +19,126 @@ import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import TypedDict, TypeVar
|
||||
|
||||
from core.git_executable import get_git_executable, run_git
|
||||
from debug import debug_warning
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
def _is_retryable_network_error(stderr: str) -> bool:
|
||||
"""Check if an error is a retryable network/connection issue."""
|
||||
stderr_lower = stderr.lower()
|
||||
return any(
|
||||
term in stderr_lower
|
||||
for term in ["connection", "network", "timeout", "reset", "refused"]
|
||||
)
|
||||
|
||||
|
||||
def _is_retryable_http_error(stderr: str) -> bool:
|
||||
"""
|
||||
Check if an HTTP error is retryable (5xx errors, timeouts).
|
||||
Excludes auth errors (401, 403) and client errors (404, 422).
|
||||
"""
|
||||
stderr_lower = stderr.lower()
|
||||
# Check for HTTP 5xx errors (server errors are retryable)
|
||||
if re.search(r"http[s]?\s*5\d{2}", stderr_lower):
|
||||
return True
|
||||
# Check for HTTP timeout patterns
|
||||
if "http" in stderr_lower and "timeout" in stderr_lower:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _with_retry(
|
||||
operation: Callable[[], tuple[bool, T | None, str]],
|
||||
max_retries: int = 3,
|
||||
is_retryable: Callable[[str], bool] | None = None,
|
||||
on_retry: Callable[[int, str], None] | None = None,
|
||||
) -> tuple[T | None, str]:
|
||||
"""
|
||||
Execute an operation with retry logic.
|
||||
|
||||
Args:
|
||||
operation: Function that returns a tuple of (success: bool, result: T | None, error: str).
|
||||
On success (success=True), result contains the value and error is empty.
|
||||
On failure (success=False), result is None and error contains the message.
|
||||
max_retries: Maximum number of retry attempts
|
||||
is_retryable: Function to check if error is retryable based on error message
|
||||
on_retry: Optional callback called before each retry with (attempt, error)
|
||||
|
||||
Returns:
|
||||
Tuple of (result, last_error) where result is T on success, None on failure
|
||||
"""
|
||||
last_error = ""
|
||||
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
success, result, error = operation()
|
||||
if success:
|
||||
return result, ""
|
||||
|
||||
last_error = error
|
||||
|
||||
# Check if error is retryable
|
||||
if is_retryable and attempt < max_retries and is_retryable(error):
|
||||
if on_retry:
|
||||
on_retry(attempt, error)
|
||||
backoff = 2 ** (attempt - 1)
|
||||
time.sleep(backoff)
|
||||
continue
|
||||
|
||||
break
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
last_error = "Operation timed out"
|
||||
if attempt < max_retries:
|
||||
if on_retry:
|
||||
on_retry(attempt, last_error)
|
||||
backoff = 2 ** (attempt - 1)
|
||||
time.sleep(backoff)
|
||||
continue
|
||||
break
|
||||
|
||||
return None, last_error
|
||||
|
||||
|
||||
class PushBranchResult(TypedDict, total=False):
|
||||
"""Result of pushing a branch to remote."""
|
||||
|
||||
success: bool
|
||||
branch: str
|
||||
remote: str
|
||||
error: str
|
||||
|
||||
|
||||
class PullRequestResult(TypedDict, total=False):
|
||||
"""Result of creating a pull request."""
|
||||
|
||||
success: bool
|
||||
pr_url: str | None # None when PR was created but URL couldn't be extracted
|
||||
already_exists: bool
|
||||
error: str
|
||||
message: str
|
||||
|
||||
|
||||
class PushAndCreatePRResult(TypedDict, total=False):
|
||||
"""Result of push_and_create_pr operation."""
|
||||
|
||||
success: bool
|
||||
pushed: bool
|
||||
remote: str
|
||||
branch: str
|
||||
pr_url: str | None # None when PR was created but URL couldn't be extracted
|
||||
already_exists: bool
|
||||
error: str
|
||||
message: str
|
||||
|
||||
|
||||
class WorktreeError(Exception):
|
||||
@@ -42,6 +160,8 @@ class WorktreeInfo:
|
||||
files_changed: int = 0
|
||||
additions: int = 0
|
||||
deletions: int = 0
|
||||
last_commit_date: datetime | None = None
|
||||
days_since_last_commit: int | None = None
|
||||
|
||||
|
||||
class WorktreeManager:
|
||||
@@ -52,6 +172,11 @@ class WorktreeManager:
|
||||
a corresponding branch auto-claude/{spec-name}.
|
||||
"""
|
||||
|
||||
# Timeout constants for subprocess operations
|
||||
GIT_PUSH_TIMEOUT = 120 # 2 minutes for git push (network operations)
|
||||
GH_CLI_TIMEOUT = 60 # 1 minute for gh CLI commands
|
||||
GH_QUERY_TIMEOUT = 30 # 30 seconds for gh CLI queries
|
||||
|
||||
def __init__(self, project_dir: Path, base_branch: str | None = None):
|
||||
self.project_dir = project_dir
|
||||
self.base_branch = base_branch or self._detect_base_branch()
|
||||
@@ -74,13 +199,9 @@ class WorktreeManager:
|
||||
env_branch = os.getenv("DEFAULT_BRANCH")
|
||||
if env_branch:
|
||||
# Verify the branch exists
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--verify", env_branch],
|
||||
result = run_git(
|
||||
["rev-parse", "--verify", env_branch],
|
||||
cwd=self.project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return env_branch
|
||||
@@ -91,13 +212,9 @@ class WorktreeManager:
|
||||
|
||||
# 2. Auto-detect main/master
|
||||
for branch in ["main", "master"]:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--verify", branch],
|
||||
result = run_git(
|
||||
["rev-parse", "--verify", branch],
|
||||
cwd=self.project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return branch
|
||||
@@ -111,30 +228,29 @@ class WorktreeManager:
|
||||
|
||||
def _get_current_branch(self) -> str:
|
||||
"""Get the current git branch."""
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
result = run_git(
|
||||
["rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=self.project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise WorktreeError(f"Failed to get current branch: {result.stderr}")
|
||||
return result.stdout.strip()
|
||||
|
||||
def _run_git(
|
||||
self, args: list[str], cwd: Path | None = None
|
||||
self, args: list[str], cwd: Path | None = None, timeout: int = 60
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run a git command and return the result."""
|
||||
return subprocess.run(
|
||||
["git"] + args,
|
||||
cwd=cwd or self.project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
"""Run a git command and return the result.
|
||||
|
||||
Args:
|
||||
args: Git command arguments (without 'git' prefix)
|
||||
cwd: Working directory for the command
|
||||
timeout: Command timeout in seconds (default: 60)
|
||||
|
||||
Returns:
|
||||
CompletedProcess with command results. On timeout, returns a
|
||||
CompletedProcess with returncode=-1 and timeout error in stderr.
|
||||
"""
|
||||
return run_git(args, cwd=cwd or self.project_dir, timeout=timeout)
|
||||
|
||||
def _unstage_gitignored_files(self) -> None:
|
||||
"""
|
||||
@@ -157,14 +273,10 @@ class WorktreeManager:
|
||||
|
||||
# 1. Check which staged files are gitignored
|
||||
# git check-ignore returns the files that ARE ignored
|
||||
result = subprocess.run(
|
||||
["git", "check-ignore", "--stdin"],
|
||||
result = run_git(
|
||||
["check-ignore", "--stdin"],
|
||||
cwd=self.project_dir,
|
||||
input="\n".join(staged_files),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
input_data="\n".join(staged_files),
|
||||
)
|
||||
|
||||
if result.stdout.strip():
|
||||
@@ -179,8 +291,10 @@ class WorktreeManager:
|
||||
file = file.strip()
|
||||
if not file:
|
||||
continue
|
||||
# Normalize path separators for cross-platform (Windows backslash support)
|
||||
normalized = file.replace("\\", "/")
|
||||
for pattern in auto_claude_patterns:
|
||||
if file.startswith(pattern) or f"/{pattern}" in file:
|
||||
if normalized.startswith(pattern) or f"/{pattern}" in normalized:
|
||||
files_to_unstage.add(file)
|
||||
break
|
||||
|
||||
@@ -199,8 +313,19 @@ class WorktreeManager:
|
||||
# ==================== Per-Spec Worktree Methods ====================
|
||||
|
||||
def get_worktree_path(self, spec_name: str) -> Path:
|
||||
"""Get the worktree path for a spec."""
|
||||
return self.worktrees_dir / spec_name
|
||||
"""Get the worktree path for a spec (checks new and legacy locations)."""
|
||||
# New path first (.auto-claude/worktrees/tasks/)
|
||||
new_path = self.worktrees_dir / spec_name
|
||||
if new_path.exists():
|
||||
return new_path
|
||||
|
||||
# Legacy fallback (.worktrees/ instead of .auto-claude/worktrees/tasks/)
|
||||
legacy_path = self.project_dir / ".worktrees" / spec_name
|
||||
if legacy_path.exists():
|
||||
return legacy_path
|
||||
|
||||
# Return new path as default for creation
|
||||
return new_path
|
||||
|
||||
def get_branch_name(self, spec_name: str) -> str:
|
||||
"""Get the branch name for a spec."""
|
||||
@@ -261,6 +386,8 @@ class WorktreeManager:
|
||||
"files_changed": 0,
|
||||
"additions": 0,
|
||||
"deletions": 0,
|
||||
"last_commit_date": None,
|
||||
"days_since_last_commit": None,
|
||||
}
|
||||
|
||||
if not worktree_path.exists():
|
||||
@@ -273,6 +400,52 @@ class WorktreeManager:
|
||||
if result.returncode == 0:
|
||||
stats["commit_count"] = int(result.stdout.strip() or "0")
|
||||
|
||||
# Last commit date (most recent commit in this worktree)
|
||||
result = self._run_git(
|
||||
["log", "-1", "--format=%cd", "--date=iso"], cwd=worktree_path
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
try:
|
||||
# Parse ISO date format: "2026-01-04 00:25:25 +0100"
|
||||
date_str = result.stdout.strip()
|
||||
# Convert git format to ISO format for fromisoformat()
|
||||
# "2026-01-04 00:25:25 +0100" -> "2026-01-04T00:25:25+01:00"
|
||||
parts = date_str.rsplit(" ", 1)
|
||||
if len(parts) == 2:
|
||||
date_part, tz_part = parts
|
||||
# Convert timezone format: "+0100" -> "+01:00"
|
||||
if len(tz_part) == 5 and (
|
||||
tz_part.startswith("+") or tz_part.startswith("-")
|
||||
):
|
||||
tz_formatted = f"{tz_part[:3]}:{tz_part[3:]}"
|
||||
iso_str = f"{date_part.replace(' ', 'T')}{tz_formatted}"
|
||||
last_commit_date = datetime.fromisoformat(iso_str)
|
||||
stats["last_commit_date"] = last_commit_date
|
||||
# Use timezone-aware now() for accurate comparison
|
||||
now_aware = datetime.now(last_commit_date.tzinfo)
|
||||
stats["days_since_last_commit"] = (
|
||||
now_aware - last_commit_date
|
||||
).days
|
||||
else:
|
||||
# Fallback for unexpected timezone format
|
||||
last_commit_date = datetime.strptime(
|
||||
parts[0], "%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
stats["last_commit_date"] = last_commit_date
|
||||
stats["days_since_last_commit"] = (
|
||||
datetime.now() - last_commit_date
|
||||
).days
|
||||
else:
|
||||
# No timezone in output
|
||||
last_commit_date = datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S")
|
||||
stats["last_commit_date"] = last_commit_date
|
||||
stats["days_since_last_commit"] = (
|
||||
datetime.now() - last_commit_date
|
||||
).days
|
||||
except (ValueError, TypeError) as e:
|
||||
# If parsing fails, silently continue without date info
|
||||
pass
|
||||
|
||||
# Diff stats
|
||||
result = self._run_git(
|
||||
["diff", "--shortstat", f"{self.base_branch}...HEAD"], cwd=worktree_path
|
||||
@@ -327,9 +500,33 @@ class WorktreeManager:
|
||||
# Delete branch if it exists (from previous attempt)
|
||||
self._run_git(["branch", "-D", branch_name])
|
||||
|
||||
# Create worktree with new branch from base
|
||||
# Fetch latest from remote to ensure we have the most up-to-date code
|
||||
# GitHub/remote is the source of truth, not the local branch
|
||||
fetch_result = self._run_git(["fetch", "origin", self.base_branch])
|
||||
if fetch_result.returncode != 0:
|
||||
print(
|
||||
f"Warning: Could not fetch {self.base_branch} from origin: {fetch_result.stderr}"
|
||||
)
|
||||
print("Falling back to local branch...")
|
||||
|
||||
# Determine the start point for the worktree
|
||||
# Prefer origin/{base_branch} (remote) over local branch to ensure we have latest code
|
||||
remote_ref = f"origin/{self.base_branch}"
|
||||
start_point = self.base_branch # Default to local branch
|
||||
|
||||
# Check if remote ref exists and use it as the source of truth
|
||||
check_remote = self._run_git(["rev-parse", "--verify", remote_ref])
|
||||
if check_remote.returncode == 0:
|
||||
start_point = remote_ref
|
||||
print(f"Creating worktree from remote: {remote_ref}")
|
||||
else:
|
||||
print(
|
||||
f"Remote ref {remote_ref} not found, using local branch: {self.base_branch}"
|
||||
)
|
||||
|
||||
# Create worktree with new branch from the start point (remote preferred)
|
||||
result = self._run_git(
|
||||
["worktree", "add", "-b", branch_name, str(worktree_path), self.base_branch]
|
||||
["worktree", "add", "-b", branch_name, str(worktree_path), start_point]
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
@@ -475,15 +672,27 @@ class WorktreeManager:
|
||||
# ==================== Listing & Discovery ====================
|
||||
|
||||
def list_all_worktrees(self) -> list[WorktreeInfo]:
|
||||
"""List all spec worktrees."""
|
||||
"""List all spec worktrees (includes legacy .worktrees/ location)."""
|
||||
worktrees = []
|
||||
seen_specs = set()
|
||||
|
||||
# Check new location first
|
||||
if self.worktrees_dir.exists():
|
||||
for item in self.worktrees_dir.iterdir():
|
||||
if item.is_dir():
|
||||
info = self.get_worktree_info(item.name)
|
||||
if info:
|
||||
worktrees.append(info)
|
||||
seen_specs.add(item.name)
|
||||
|
||||
# Check legacy location (.worktrees/)
|
||||
legacy_dir = self.project_dir / ".worktrees"
|
||||
if legacy_dir.exists():
|
||||
for item in legacy_dir.iterdir():
|
||||
if item.is_dir() and item.name not in seen_specs:
|
||||
info = self.get_worktree_info(item.name)
|
||||
if info:
|
||||
worktrees.append(info)
|
||||
|
||||
return worktrees
|
||||
|
||||
@@ -594,3 +803,535 @@ class WorktreeManager:
|
||||
cwd = worktree_path
|
||||
result = self._run_git(["status", "--porcelain"], cwd=cwd)
|
||||
return bool(result.stdout.strip())
|
||||
|
||||
# ==================== PR Creation Methods ====================
|
||||
|
||||
def push_branch(self, spec_name: str, force: bool = False) -> PushBranchResult:
|
||||
"""
|
||||
Push a spec's branch to the remote origin with retry logic.
|
||||
|
||||
Args:
|
||||
spec_name: The spec folder name
|
||||
force: Whether to force push (use with caution)
|
||||
|
||||
Returns:
|
||||
PushBranchResult with keys:
|
||||
- success: bool
|
||||
- branch: str (branch name)
|
||||
- remote: str (if successful)
|
||||
- error: str (if failed)
|
||||
"""
|
||||
info = self.get_worktree_info(spec_name)
|
||||
if not info:
|
||||
return PushBranchResult(
|
||||
success=False,
|
||||
error=f"No worktree found for spec: {spec_name}",
|
||||
)
|
||||
|
||||
# Push the branch to origin
|
||||
push_args = ["push", "-u", "origin", info.branch]
|
||||
if force:
|
||||
push_args.insert(1, "--force")
|
||||
|
||||
def do_push() -> tuple[bool, PushBranchResult | None, str]:
|
||||
"""Execute push operation for retry wrapper."""
|
||||
try:
|
||||
git_executable = get_git_executable()
|
||||
result = subprocess.run(
|
||||
[git_executable] + push_args,
|
||||
cwd=info.path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=self.GIT_PUSH_TIMEOUT,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return (
|
||||
True,
|
||||
PushBranchResult(
|
||||
success=True,
|
||||
branch=info.branch,
|
||||
remote="origin",
|
||||
),
|
||||
"",
|
||||
)
|
||||
return (False, None, result.stderr)
|
||||
except FileNotFoundError:
|
||||
return (False, None, "git executable not found")
|
||||
|
||||
max_retries = 3
|
||||
result, last_error = _with_retry(
|
||||
operation=do_push,
|
||||
max_retries=max_retries,
|
||||
is_retryable=_is_retryable_network_error,
|
||||
)
|
||||
|
||||
if result:
|
||||
return result
|
||||
|
||||
# Handle timeout error message
|
||||
if last_error == "Operation timed out":
|
||||
return PushBranchResult(
|
||||
success=False,
|
||||
branch=info.branch,
|
||||
error=f"Push timed out after {max_retries} attempts.",
|
||||
)
|
||||
|
||||
return PushBranchResult(
|
||||
success=False,
|
||||
branch=info.branch,
|
||||
error=f"Failed to push branch: {last_error}",
|
||||
)
|
||||
|
||||
def create_pull_request(
|
||||
self,
|
||||
spec_name: str,
|
||||
target_branch: str | None = None,
|
||||
title: str | None = None,
|
||||
draft: bool = False,
|
||||
) -> PullRequestResult:
|
||||
"""
|
||||
Create a GitHub pull request for a spec's branch using gh CLI with retry logic.
|
||||
|
||||
Args:
|
||||
spec_name: The spec folder name
|
||||
target_branch: Target branch for PR (defaults to base_branch)
|
||||
title: PR title (defaults to spec name)
|
||||
draft: Whether to create as draft PR
|
||||
|
||||
Returns:
|
||||
PullRequestResult with keys:
|
||||
- success: bool
|
||||
- pr_url: str (if created)
|
||||
- already_exists: bool (if PR already exists)
|
||||
- error: str (if failed)
|
||||
"""
|
||||
info = self.get_worktree_info(spec_name)
|
||||
if not info:
|
||||
return PullRequestResult(
|
||||
success=False,
|
||||
error=f"No worktree found for spec: {spec_name}",
|
||||
)
|
||||
|
||||
target = target_branch or self.base_branch
|
||||
pr_title = title or f"auto-claude: {spec_name}"
|
||||
|
||||
# Get PR body from spec.md if available
|
||||
pr_body = self._extract_spec_summary(spec_name)
|
||||
|
||||
# Build gh pr create command
|
||||
gh_args = [
|
||||
"gh",
|
||||
"pr",
|
||||
"create",
|
||||
"--base",
|
||||
target,
|
||||
"--head",
|
||||
info.branch,
|
||||
"--title",
|
||||
pr_title,
|
||||
"--body",
|
||||
pr_body,
|
||||
]
|
||||
if draft:
|
||||
gh_args.append("--draft")
|
||||
|
||||
def is_pr_retryable(stderr: str) -> bool:
|
||||
"""Check if PR creation error is retryable (network or HTTP 5xx)."""
|
||||
return _is_retryable_network_error(stderr) or _is_retryable_http_error(
|
||||
stderr
|
||||
)
|
||||
|
||||
def do_create_pr() -> tuple[bool, PullRequestResult | None, str]:
|
||||
"""Execute PR creation for retry wrapper."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
gh_args,
|
||||
cwd=info.path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=self.GH_CLI_TIMEOUT,
|
||||
)
|
||||
|
||||
# Check for "already exists" case (success, no retry needed)
|
||||
if result.returncode != 0 and "already exists" in result.stderr.lower():
|
||||
existing_url = self._get_existing_pr_url(spec_name, target)
|
||||
result_dict = PullRequestResult(
|
||||
success=True,
|
||||
pr_url=existing_url,
|
||||
already_exists=True,
|
||||
)
|
||||
if existing_url is None:
|
||||
result_dict["message"] = (
|
||||
"PR already exists but URL could not be retrieved"
|
||||
)
|
||||
return (True, result_dict, "")
|
||||
|
||||
if result.returncode == 0:
|
||||
# Extract PR URL from output
|
||||
pr_url: str | None = result.stdout.strip()
|
||||
if not pr_url.startswith("http"):
|
||||
# Try to find URL in output
|
||||
# Use general pattern to support GitHub Enterprise instances
|
||||
# Matches any HTTPS URL with /pull/<number> path
|
||||
match = re.search(r"https://[^\s]+/pull/\d+", result.stdout)
|
||||
if match:
|
||||
pr_url = match.group(0)
|
||||
else:
|
||||
# Invalid output - no valid URL found
|
||||
pr_url = None
|
||||
|
||||
return (
|
||||
True,
|
||||
PullRequestResult(
|
||||
success=True,
|
||||
pr_url=pr_url,
|
||||
already_exists=False,
|
||||
),
|
||||
"",
|
||||
)
|
||||
|
||||
return (False, None, result.stderr)
|
||||
|
||||
except FileNotFoundError:
|
||||
# gh CLI not installed - not retryable, raise to exit retry loop
|
||||
raise
|
||||
|
||||
max_retries = 3
|
||||
try:
|
||||
result, last_error = _with_retry(
|
||||
operation=do_create_pr,
|
||||
max_retries=max_retries,
|
||||
is_retryable=is_pr_retryable,
|
||||
)
|
||||
|
||||
if result:
|
||||
return result
|
||||
|
||||
# Handle timeout error message
|
||||
if last_error == "Operation timed out":
|
||||
return PullRequestResult(
|
||||
success=False,
|
||||
error=f"PR creation timed out after {max_retries} attempts.",
|
||||
)
|
||||
|
||||
return PullRequestResult(
|
||||
success=False,
|
||||
error=f"Failed to create PR: {last_error}",
|
||||
)
|
||||
|
||||
except FileNotFoundError:
|
||||
# gh CLI not installed
|
||||
return PullRequestResult(
|
||||
success=False,
|
||||
error="gh CLI not found. Install from https://cli.github.com/",
|
||||
)
|
||||
|
||||
def _extract_spec_summary(self, spec_name: str) -> str:
|
||||
"""Extract a summary from spec.md for PR body."""
|
||||
worktree_path = self.get_worktree_path(spec_name)
|
||||
spec_path = worktree_path / ".auto-claude" / "specs" / spec_name / "spec.md"
|
||||
|
||||
if not spec_path.exists():
|
||||
# Try project spec path
|
||||
spec_path = (
|
||||
self.project_dir / ".auto-claude" / "specs" / spec_name / "spec.md"
|
||||
)
|
||||
|
||||
if not spec_path.exists():
|
||||
return "Auto-generated PR from Auto-Claude build."
|
||||
|
||||
try:
|
||||
content = spec_path.read_text(encoding="utf-8")
|
||||
# Extract first few paragraphs (skip title, get overview)
|
||||
lines = content.split("\n")
|
||||
summary_lines = []
|
||||
in_content = False
|
||||
|
||||
for line in lines:
|
||||
# Skip title headers
|
||||
if line.startswith("# "):
|
||||
continue
|
||||
# Start capturing after first content line
|
||||
if line.strip() and not line.startswith("#"):
|
||||
in_content = True
|
||||
if in_content:
|
||||
if line.startswith("## ") and summary_lines:
|
||||
break # Stop at next section
|
||||
summary_lines.append(line)
|
||||
if len(summary_lines) >= 10: # Limit to ~10 lines
|
||||
break
|
||||
|
||||
summary = "\n".join(summary_lines).strip()
|
||||
if summary:
|
||||
return summary
|
||||
except (OSError, UnicodeDecodeError) as e:
|
||||
# Silently fall back to default - file read errors shouldn't block PR creation
|
||||
debug_warning(
|
||||
"worktree", f"Could not extract spec summary for PR body: {e}"
|
||||
)
|
||||
|
||||
return "Auto-generated PR from Auto-Claude build."
|
||||
|
||||
def _get_existing_pr_url(self, spec_name: str, target_branch: str) -> str | None:
|
||||
"""Get the URL of an existing PR for this branch."""
|
||||
info = self.get_worktree_info(spec_name)
|
||||
if not info:
|
||||
return None
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["gh", "pr", "view", info.branch, "--json", "url", "--jq", ".url"],
|
||||
cwd=info.path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=self.GH_QUERY_TIMEOUT,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
except (
|
||||
subprocess.TimeoutExpired,
|
||||
FileNotFoundError,
|
||||
subprocess.SubprocessError,
|
||||
) as e:
|
||||
# Silently ignore errors when fetching existing PR URL - this is a best-effort
|
||||
# lookup that may fail due to network issues, missing gh CLI, or auth problems.
|
||||
# Returning None allows the caller to handle missing URLs gracefully.
|
||||
debug_warning("worktree", f"Could not get existing PR URL: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def push_and_create_pr(
|
||||
self,
|
||||
spec_name: str,
|
||||
target_branch: str | None = None,
|
||||
title: str | None = None,
|
||||
draft: bool = False,
|
||||
force_push: bool = False,
|
||||
) -> PushAndCreatePRResult:
|
||||
"""
|
||||
Push branch and create a pull request in one operation.
|
||||
|
||||
Args:
|
||||
spec_name: The spec folder name
|
||||
target_branch: Target branch for PR (defaults to base_branch)
|
||||
title: PR title (defaults to spec name)
|
||||
draft: Whether to create as draft PR
|
||||
force_push: Whether to force push the branch
|
||||
|
||||
Returns:
|
||||
PushAndCreatePRResult with keys:
|
||||
- success: bool
|
||||
- pr_url: str (if created)
|
||||
- pushed: bool (if push succeeded)
|
||||
- already_exists: bool (if PR already exists)
|
||||
- error: str (if failed)
|
||||
"""
|
||||
# Step 1: Push the branch
|
||||
push_result = self.push_branch(spec_name, force=force_push)
|
||||
if not push_result.get("success"):
|
||||
return PushAndCreatePRResult(
|
||||
success=False,
|
||||
pushed=False,
|
||||
error=push_result.get("error", "Push failed"),
|
||||
)
|
||||
|
||||
# Step 2: Create the PR
|
||||
pr_result = self.create_pull_request(
|
||||
spec_name=spec_name,
|
||||
target_branch=target_branch,
|
||||
title=title,
|
||||
draft=draft,
|
||||
)
|
||||
|
||||
# Combine results
|
||||
return PushAndCreatePRResult(
|
||||
success=pr_result.get("success", False),
|
||||
pushed=True,
|
||||
remote=push_result.get("remote"),
|
||||
branch=push_result.get("branch"),
|
||||
pr_url=pr_result.get("pr_url"),
|
||||
already_exists=pr_result.get("already_exists", False),
|
||||
error=pr_result.get("error"),
|
||||
)
|
||||
|
||||
# ==================== Worktree Cleanup Methods ====================
|
||||
|
||||
def get_old_worktrees(
|
||||
self, days_threshold: int = 30, include_stats: bool = False
|
||||
) -> list[WorktreeInfo] | list[str]:
|
||||
"""
|
||||
Find worktrees that haven't been modified in the specified number of days.
|
||||
|
||||
Args:
|
||||
days_threshold: Number of days without activity to consider a worktree old (default: 30)
|
||||
include_stats: If True, return full WorktreeInfo objects; if False, return just spec names
|
||||
|
||||
Returns:
|
||||
List of old worktrees (either WorktreeInfo objects or spec names based on include_stats)
|
||||
"""
|
||||
old_worktrees = []
|
||||
|
||||
for worktree_info in self.list_all_worktrees():
|
||||
# Skip if we can't determine age
|
||||
if worktree_info.days_since_last_commit is None:
|
||||
continue
|
||||
|
||||
if worktree_info.days_since_last_commit >= days_threshold:
|
||||
if include_stats:
|
||||
old_worktrees.append(worktree_info)
|
||||
else:
|
||||
old_worktrees.append(worktree_info.spec_name)
|
||||
|
||||
return old_worktrees
|
||||
|
||||
def cleanup_old_worktrees(
|
||||
self, days_threshold: int = 30, dry_run: bool = False
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""
|
||||
Remove worktrees that haven't been modified in the specified number of days.
|
||||
|
||||
Args:
|
||||
days_threshold: Number of days without activity to consider a worktree old (default: 30)
|
||||
dry_run: If True, only report what would be removed without actually removing
|
||||
|
||||
Returns:
|
||||
Tuple of (removed_specs, failed_specs) containing spec names
|
||||
"""
|
||||
old_worktrees = self.get_old_worktrees(
|
||||
days_threshold=days_threshold, include_stats=True
|
||||
)
|
||||
|
||||
if not old_worktrees:
|
||||
print(f"No worktrees found older than {days_threshold} days.")
|
||||
return ([], [])
|
||||
|
||||
removed = []
|
||||
failed = []
|
||||
|
||||
if dry_run:
|
||||
print(f"\n[DRY RUN] Would remove {len(old_worktrees)} old worktrees:")
|
||||
for info in old_worktrees:
|
||||
print(
|
||||
f" - {info.spec_name} (last activity: {info.days_since_last_commit} days ago)"
|
||||
)
|
||||
return ([], [])
|
||||
|
||||
print(f"\nRemoving {len(old_worktrees)} old worktrees...")
|
||||
for info in old_worktrees:
|
||||
try:
|
||||
self.remove_worktree(info.spec_name, delete_branch=True)
|
||||
removed.append(info.spec_name)
|
||||
print(
|
||||
f" ✓ Removed {info.spec_name} (last activity: {info.days_since_last_commit} days ago)"
|
||||
)
|
||||
except Exception as e:
|
||||
failed.append(info.spec_name)
|
||||
print(f" ✗ Failed to remove {info.spec_name}: {e}")
|
||||
|
||||
if removed:
|
||||
print(f"\nSuccessfully removed {len(removed)} worktree(s).")
|
||||
if failed:
|
||||
print(f"Failed to remove {len(failed)} worktree(s).")
|
||||
|
||||
return (removed, failed)
|
||||
|
||||
def get_worktree_count_warning(
|
||||
self, warning_threshold: int = 10, critical_threshold: int = 20
|
||||
) -> str | None:
|
||||
"""
|
||||
Check worktree count and return a warning message if threshold is exceeded.
|
||||
|
||||
Args:
|
||||
warning_threshold: Number of worktrees to trigger a warning (default: 10)
|
||||
critical_threshold: Number of worktrees to trigger a critical warning (default: 20)
|
||||
|
||||
Returns:
|
||||
Warning message string if threshold exceeded, None otherwise
|
||||
"""
|
||||
worktrees = self.list_all_worktrees()
|
||||
count = len(worktrees)
|
||||
|
||||
if count >= critical_threshold:
|
||||
old_worktrees = self.get_old_worktrees(days_threshold=30)
|
||||
old_count = len(old_worktrees)
|
||||
return (
|
||||
f"CRITICAL: {count} worktrees detected! "
|
||||
f"Consider cleaning up old worktrees ({old_count} are 30+ days old). "
|
||||
f"Run cleanup to remove stale worktrees."
|
||||
)
|
||||
elif count >= warning_threshold:
|
||||
old_worktrees = self.get_old_worktrees(days_threshold=30)
|
||||
old_count = len(old_worktrees)
|
||||
return (
|
||||
f"WARNING: {count} worktrees detected. "
|
||||
f"{old_count} are 30+ days old and may be safe to clean up."
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def print_worktree_summary(self) -> None:
|
||||
"""Print a summary of all worktrees with age information."""
|
||||
worktrees = self.list_all_worktrees()
|
||||
|
||||
if not worktrees:
|
||||
print("No worktrees found.")
|
||||
return
|
||||
|
||||
print(f"\n{'=' * 80}")
|
||||
print(f"Worktree Summary ({len(worktrees)} total)")
|
||||
print(f"{'=' * 80}\n")
|
||||
|
||||
# Group by age
|
||||
recent = [] # < 7 days
|
||||
week_old = [] # 7-30 days
|
||||
month_old = [] # 30-90 days
|
||||
very_old = [] # > 90 days
|
||||
unknown_age = []
|
||||
|
||||
for info in worktrees:
|
||||
if info.days_since_last_commit is None:
|
||||
unknown_age.append(info)
|
||||
elif info.days_since_last_commit < 7:
|
||||
recent.append(info)
|
||||
elif info.days_since_last_commit < 30:
|
||||
week_old.append(info)
|
||||
elif info.days_since_last_commit < 90:
|
||||
month_old.append(info)
|
||||
else:
|
||||
very_old.append(info)
|
||||
|
||||
def print_group(title: str, items: list[WorktreeInfo]):
|
||||
if not items:
|
||||
return
|
||||
print(f"{title} ({len(items)}):")
|
||||
for info in sorted(items, key=lambda x: x.spec_name):
|
||||
age_str = (
|
||||
f"{info.days_since_last_commit}d ago"
|
||||
if info.days_since_last_commit is not None
|
||||
else "unknown"
|
||||
)
|
||||
print(f" - {info.spec_name} (last activity: {age_str})")
|
||||
print()
|
||||
|
||||
print_group("Recent (< 7 days)", recent)
|
||||
print_group("Week Old (7-30 days)", week_old)
|
||||
print_group("Month Old (30-90 days)", month_old)
|
||||
print_group("Very Old (> 90 days)", very_old)
|
||||
print_group("Unknown Age", unknown_age)
|
||||
|
||||
# Print cleanup suggestions
|
||||
if month_old or very_old:
|
||||
total_old = len(month_old) + len(very_old)
|
||||
print(f"{'=' * 80}")
|
||||
print(
|
||||
f"💡 Suggestion: {total_old} worktree(s) are 30+ days old and may be safe to clean up."
|
||||
)
|
||||
print(" Review these worktrees and run cleanup if no longer needed.")
|
||||
print(f"{'=' * 80}\n")
|
||||
|
||||
+114
-15
@@ -6,6 +6,32 @@ 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:
|
||||
"""
|
||||
@@ -27,17 +53,8 @@ def ensure_gitignore_entry(project_dir: Path, entry: str = ".auto-claude/") -> b
|
||||
content = gitignore_path.read_text()
|
||||
lines = content.splitlines()
|
||||
|
||||
# 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
|
||||
if _entry_exists_in_gitignore(lines, entry):
|
||||
return False # Already exists
|
||||
|
||||
# Entry doesn't exist, append it
|
||||
# Ensure file ends with newline before adding our entry
|
||||
@@ -59,11 +76,58 @@ 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 it's in .gitignore.
|
||||
Creates the directory if needed and ensures all auto-claude files are in .gitignore.
|
||||
|
||||
Args:
|
||||
project_dir: The project root directory
|
||||
@@ -78,16 +142,18 @@ 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 .auto-claude is in .gitignore (only on first creation)
|
||||
# Ensure all auto-claude entries are in .gitignore (only on first creation)
|
||||
gitignore_updated = False
|
||||
if dir_created:
|
||||
gitignore_updated = ensure_gitignore_entry(project_dir, ".auto-claude/")
|
||||
added = ensure_all_gitignore_entries(project_dir)
|
||||
gitignore_updated = len(added) > 0
|
||||
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():
|
||||
gitignore_updated = ensure_gitignore_entry(project_dir, ".auto-claude/")
|
||||
added = ensure_all_gitignore_entries(project_dir)
|
||||
gitignore_updated = len(added) > 0
|
||||
marker.touch()
|
||||
|
||||
return auto_claude_dir, gitignore_updated
|
||||
@@ -109,3 +175,36 @@ 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,10 +622,23 @@ def get_graphiti_status() -> dict:
|
||||
status["errors"] = errors
|
||||
# Errors are informational - embedder is optional (keyword search fallback)
|
||||
|
||||
# Available if is_valid() returns True (just needs enabled flag)
|
||||
status["available"] = config.is_valid()
|
||||
if not status["available"]:
|
||||
# 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():
|
||||
status["reason"] = errors[0] if errors else "Configuration invalid"
|
||||
return status
|
||||
|
||||
# Try importing the required Graphiti packages
|
||||
try:
|
||||
# Attempt to import the main graphiti_memory module
|
||||
import graphiti_core # noqa: F401
|
||||
from graphiti_core.driver.falkordb_driver import FalkorDriver # noqa: F401
|
||||
|
||||
# If we got here, packages are importable
|
||||
status["available"] = True
|
||||
except ImportError as e:
|
||||
status["available"] = False
|
||||
status["reason"] = f"Graphiti packages not installed: {e}"
|
||||
|
||||
return status
|
||||
|
||||
|
||||
@@ -34,8 +34,25 @@ def _apply_ladybug_monkeypatch() -> bool:
|
||||
sys.modules["kuzu"] = real_ladybug
|
||||
logger.info("Applied LadybugDB monkeypatch (kuzu -> real_ladybug)")
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
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}")
|
||||
|
||||
# Fall back to native kuzu
|
||||
try:
|
||||
|
||||
@@ -9,7 +9,7 @@ conflict resolution, enabling multiple AI agents to work in parallel without
|
||||
traditional merge conflicts.
|
||||
|
||||
Components:
|
||||
- SemanticAnalyzer: Tree-sitter based semantic change extraction
|
||||
- SemanticAnalyzer: Regex-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,7 +82,9 @@ def create_claude_resolver() -> AIResolver:
|
||||
msg_type = type(msg).__name__
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
if hasattr(block, "text"):
|
||||
# Must check block type - only TextBlock has .text attribute
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
|
||||
logger.info(f"AI merge response: {len(response_text)} chars")
|
||||
|
||||
@@ -68,6 +68,7 @@ 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.
|
||||
@@ -79,6 +80,9 @@ 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
|
||||
@@ -87,8 +91,8 @@ class ModificationTracker:
|
||||
|
||||
# Get or create evolution
|
||||
if rel_path not in evolutions:
|
||||
logger.warning(f"File {rel_path} not being tracked")
|
||||
# Note: We could auto-create here, but for now return None
|
||||
# Debug level: this is expected for files not in baseline (e.g., from main's changes)
|
||||
logger.debug(f"File {rel_path} not in evolution tracking - skipping")
|
||||
return None
|
||||
|
||||
evolution = evolutions.get(rel_path)
|
||||
@@ -105,9 +109,19 @@ class ModificationTracker:
|
||||
content_hash_before=compute_content_hash(old_content),
|
||||
)
|
||||
|
||||
# Analyze semantic changes
|
||||
analysis = self.analyzer.analyze_diff(rel_path, old_content, new_content)
|
||||
semantic_changes = analysis.changes
|
||||
# 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
|
||||
|
||||
# Update snapshot
|
||||
snapshot.completed_at = datetime.now()
|
||||
@@ -121,6 +135,7 @@ 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
|
||||
|
||||
@@ -130,6 +145,7 @@ 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.
|
||||
@@ -142,6 +158,10 @@ 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:
|
||||
@@ -154,12 +174,27 @@ 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 list of files changed in the worktree vs target branch
|
||||
# Get the merge-base to accurately identify task-only changes
|
||||
# Using two-dot diff (merge-base..HEAD) returns only files changed by the task,
|
||||
# not files changed on the target branch since divergence
|
||||
merge_base_result = subprocess.run(
|
||||
["git", "merge-base", target_branch, "HEAD"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
merge_base = merge_base_result.stdout.strip()
|
||||
|
||||
# Get list of files changed in the worktree since the merge-base
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", f"{target_branch}...HEAD"],
|
||||
["git", "diff", "--name-only", f"{merge_base}..HEAD"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -175,55 +210,103 @@ 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:
|
||||
show_result = subprocess.run(
|
||||
["git", "show", f"{target_branch}:{file_path}"],
|
||||
# Get the diff for this file (using merge-base for accurate task-only diff)
|
||||
diff_result = subprocess.run(
|
||||
["git", "diff", f"{merge_base}..HEAD", "--", file_path],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
old_content = show_result.stdout
|
||||
except subprocess.CalledProcessError:
|
||||
# File is new
|
||||
old_content = ""
|
||||
|
||||
current_file = worktree_path / file_path
|
||||
if current_file.exists():
|
||||
# Get content before (from merge-base - the point where task branched)
|
||||
try:
|
||||
new_content = current_file.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
new_content = current_file.read_text(
|
||||
encoding="utf-8", errors="replace"
|
||||
show_result = subprocess.run(
|
||||
["git", "show", f"{merge_base}:{file_path}"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
else:
|
||||
# File was deleted
|
||||
new_content = ""
|
||||
old_content = show_result.stdout
|
||||
except subprocess.CalledProcessError:
|
||||
# File is new
|
||||
old_content = ""
|
||||
|
||||
# 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,
|
||||
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)"
|
||||
)
|
||||
|
||||
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}")
|
||||
@@ -248,35 +331,23 @@ class ModificationTracker:
|
||||
|
||||
def _detect_target_branch(self, worktree_path: Path) -> str:
|
||||
"""
|
||||
Detect the target branch to compare against for a worktree.
|
||||
Detect the base branch to compare against for a worktree.
|
||||
|
||||
This finds the branch that the worktree was created from by looking
|
||||
at the merge-base between the worktree and common branch names.
|
||||
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.
|
||||
|
||||
Args:
|
||||
worktree_path: Path to the worktree
|
||||
|
||||
Returns:
|
||||
The detected target branch name, defaults to 'main' if detection fails
|
||||
The detected base 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(
|
||||
@@ -286,14 +357,39 @@ 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
|
||||
|
||||
# Default to main
|
||||
# Before defaulting to 'main', verify it exists
|
||||
# This handles non-standard projects that use trunk, production, etc.
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--verify", "main"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
debug_warning(
|
||||
MODULE,
|
||||
"Could not find merge-base with standard branches, defaulting to 'main'",
|
||||
worktree_path=str(worktree_path),
|
||||
)
|
||||
return "main"
|
||||
except subprocess.CalledProcessError:
|
||||
pass
|
||||
|
||||
# Last resort: use HEAD~10 as a fallback comparison point
|
||||
# This allows modification tracking even on non-standard branch setups
|
||||
debug_warning(
|
||||
MODULE,
|
||||
"Could not detect target branch, defaulting to 'main'",
|
||||
"No standard base branch found, modification tracking may be limited",
|
||||
worktree_path=str(worktree_path),
|
||||
)
|
||||
return "main"
|
||||
return "HEAD~10"
|
||||
|
||||
@@ -327,6 +327,7 @@ 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.
|
||||
@@ -338,11 +339,16 @@ 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,6 +19,35 @@ from pathlib import Path
|
||||
from .types import ChangeType, SemanticChange, TaskSnapshot
|
||||
|
||||
|
||||
def detect_line_ending(content: str) -> str:
|
||||
"""
|
||||
Detect line ending style in content using priority-based detection.
|
||||
|
||||
Uses a priority order (CRLF > CR > LF) to detect the line ending style.
|
||||
CRLF is checked first because it contains LF, so presence of any CRLF
|
||||
indicates Windows-style endings. This approach is fast and works well
|
||||
for files that consistently use one style.
|
||||
|
||||
Note: This returns the first detected style by priority, not the most
|
||||
frequent style. For files with mixed line endings, consider normalizing
|
||||
to a single style before processing.
|
||||
|
||||
Args:
|
||||
content: File content to analyze
|
||||
|
||||
Returns:
|
||||
The detected line ending string: "\\r\\n", "\\r", or "\\n"
|
||||
"""
|
||||
# Check for CRLF first (Windows) - must check before LF since CRLF contains LF
|
||||
if "\r\n" in content:
|
||||
return "\r\n"
|
||||
# Check for CR (classic Mac, rare but possible)
|
||||
if "\r" in content:
|
||||
return "\r"
|
||||
# Default to LF (Unix/modern Mac)
|
||||
return "\n"
|
||||
|
||||
|
||||
def apply_single_task_changes(
|
||||
baseline: str,
|
||||
snapshot: TaskSnapshot,
|
||||
@@ -35,7 +64,16 @@ def apply_single_task_changes(
|
||||
Returns:
|
||||
Modified content with changes applied
|
||||
"""
|
||||
content = baseline
|
||||
# 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"
|
||||
|
||||
for change in snapshot.semantic_changes:
|
||||
if change.content_before and change.content_after:
|
||||
@@ -45,14 +83,19 @@ def apply_single_task_changes(
|
||||
# Addition - need to determine where to add
|
||||
if change.change_type == ChangeType.ADD_IMPORT:
|
||||
# Add import at top
|
||||
# Use splitlines() to handle all line ending styles (LF, CRLF, CR)
|
||||
lines = content.splitlines()
|
||||
import_end = find_import_end(lines, file_path)
|
||||
lines.insert(import_end, change.content_after)
|
||||
content = "\n".join(lines)
|
||||
content = line_ending.join(lines)
|
||||
elif change.change_type == ChangeType.ADD_FUNCTION:
|
||||
# Add function at end (before exports)
|
||||
content += f"\n\n{change.content_after}"
|
||||
content += f"{line_ending}{line_ending}{change.content_after}"
|
||||
|
||||
# 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
|
||||
|
||||
@@ -73,7 +116,16 @@ def combine_non_conflicting_changes(
|
||||
Returns:
|
||||
Combined content with all changes applied
|
||||
"""
|
||||
content = baseline
|
||||
# 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"
|
||||
|
||||
# Group changes by type for proper ordering
|
||||
imports: list[SemanticChange] = []
|
||||
@@ -97,14 +149,13 @@ def combine_non_conflicting_changes(
|
||||
|
||||
# Add imports
|
||||
if imports:
|
||||
# Use splitlines() to handle all line ending styles (LF, CRLF, CR)
|
||||
lines = content.splitlines()
|
||||
import_end = find_import_end(lines, file_path)
|
||||
for imp in imports:
|
||||
if imp.content_after and imp.content_after not in content:
|
||||
lines.insert(import_end, imp.content_after)
|
||||
import_end += 1
|
||||
content = "\n".join(lines)
|
||||
content = line_ending.join(lines)
|
||||
|
||||
# Apply modifications
|
||||
for mod in modifications:
|
||||
@@ -114,15 +165,21 @@ def combine_non_conflicting_changes(
|
||||
# Add functions
|
||||
for func in functions:
|
||||
if func.content_after:
|
||||
content += f"\n\n{func.content_after}"
|
||||
content += f"{line_ending}{line_ending}{func.content_after}"
|
||||
|
||||
# Apply other changes
|
||||
for change in other:
|
||||
if change.content_after and not change.content_before:
|
||||
content += f"\n{change.content_after}"
|
||||
content += f"{line_ending}{change.content_after}"
|
||||
elif change.content_before and change.content_after:
|
||||
content = content.replace(change.content_before, change.content_after)
|
||||
|
||||
# Restore original line ending style if it was CRLF
|
||||
if original_line_ending == "\r\n":
|
||||
content = content.replace("\n", "\r\n")
|
||||
elif original_line_ending == "\r":
|
||||
content = content.replace("\n", "\r")
|
||||
|
||||
return content
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
"""
|
||||
Semantic analyzer package for AST-based code analysis.
|
||||
Semantic analyzer package for 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: Fallback regex-based analysis
|
||||
- regex_analyzer.py: Regex-based analysis for code changes
|
||||
"""
|
||||
|
||||
from .models import ExtractedElement
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
"""
|
||||
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)
|
||||
@@ -1,114 +0,0 @@
|
||||
"""
|
||||
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 fallback analysis when tree-sitter is not available.
|
||||
Regex-based semantic analysis for code changes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -17,7 +17,7 @@ def analyze_with_regex(
|
||||
ext: str,
|
||||
) -> FileAnalysis:
|
||||
"""
|
||||
Fallback analysis using regex when tree-sitter isn't available.
|
||||
Analyze code changes using regex patterns.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file being analyzed
|
||||
|
||||
@@ -2,32 +2,27 @@
|
||||
Semantic Analyzer
|
||||
=================
|
||||
|
||||
Analyzes code changes at a semantic level using tree-sitter.
|
||||
Analyzes code changes at a semantic level using regex-based heuristics.
|
||||
|
||||
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.
|
||||
This module provides analysis of code changes, extracting meaningful
|
||||
semantic changes like "added import", "modified function", "wrapped JSX element"
|
||||
rather than line-level diffs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .types import ChangeType, FileAnalysis
|
||||
from .types import 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
|
||||
@@ -43,71 +38,18 @@ 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"
|
||||
|
||||
# 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
|
||||
# Import regex-based analyzer
|
||||
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.
|
||||
|
||||
Uses tree-sitter for AST-based analysis when available,
|
||||
falling back to regex-based heuristics when not.
|
||||
Analyzes code changes at a semantic level using regex-based heuristics.
|
||||
|
||||
Example:
|
||||
analyzer = SemanticAnalyzer()
|
||||
@@ -117,28 +59,8 @@ class SemanticAnalyzer:
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""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)")
|
||||
"""Initialize the analyzer."""
|
||||
debug(MODULE, "Initializing SemanticAnalyzer (regex-based)")
|
||||
|
||||
def analyze_diff(
|
||||
self,
|
||||
@@ -171,13 +93,8 @@ class SemanticAnalyzer:
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
# 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)
|
||||
# Use regex-based analysis
|
||||
analysis = analyze_with_regex(file_path, before, after, ext)
|
||||
|
||||
debug_success(
|
||||
MODULE,
|
||||
@@ -201,83 +118,6 @@ class SemanticAnalyzer:
|
||||
|
||||
return analysis
|
||||
|
||||
def _analyze_with_tree_sitter(
|
||||
self,
|
||||
file_path: str,
|
||||
before: str,
|
||||
after: str,
|
||||
ext: str,
|
||||
) -> FileAnalysis:
|
||||
"""Analyze using tree-sitter AST parsing."""
|
||||
parser = self._parsers[ext]
|
||||
|
||||
# Normalize line endings to LF for consistent cross-platform behavior
|
||||
# This ensures byte positions and line counts work correctly on all platforms
|
||||
before_normalized = before.replace("\r\n", "\n").replace("\r", "\n")
|
||||
after_normalized = after.replace("\r\n", "\n").replace("\r", "\n")
|
||||
|
||||
tree_before = parser.parse(bytes(before_normalized, "utf-8"))
|
||||
tree_after = parser.parse(bytes(after_normalized, "utf-8"))
|
||||
|
||||
# Extract structural elements from both versions
|
||||
# Use normalized content to match tree-sitter byte positions
|
||||
elements_before = self._extract_elements(tree_before, before_normalized, ext)
|
||||
elements_after = self._extract_elements(tree_after, after_normalized, ext)
|
||||
|
||||
# Compare and generate semantic changes
|
||||
changes = compare_elements(elements_before, elements_after, ext)
|
||||
|
||||
# Build the analysis
|
||||
analysis = FileAnalysis(file_path=file_path, changes=changes)
|
||||
|
||||
# Populate summary fields
|
||||
for change in changes:
|
||||
if change.change_type in {
|
||||
ChangeType.MODIFY_FUNCTION,
|
||||
ChangeType.ADD_HOOK_CALL,
|
||||
}:
|
||||
analysis.functions_modified.add(change.target)
|
||||
elif change.change_type == ChangeType.ADD_FUNCTION:
|
||||
analysis.functions_added.add(change.target)
|
||||
elif change.change_type == ChangeType.ADD_IMPORT:
|
||||
analysis.imports_added.add(change.target)
|
||||
elif change.change_type == ChangeType.REMOVE_IMPORT:
|
||||
analysis.imports_removed.add(change.target)
|
||||
elif change.change_type in {
|
||||
ChangeType.MODIFY_CLASS,
|
||||
ChangeType.ADD_METHOD,
|
||||
}:
|
||||
analysis.classes_modified.add(change.target.split(".")[0])
|
||||
|
||||
analysis.total_lines_changed += change.line_end - change.line_start + 1
|
||||
|
||||
return analysis
|
||||
|
||||
def _extract_elements(
|
||||
self,
|
||||
tree: Tree,
|
||||
source: str,
|
||||
ext: str,
|
||||
) -> dict[str, ExtractedElement]:
|
||||
"""Extract structural elements from a syntax tree."""
|
||||
elements: dict[str, ExtractedElement] = {}
|
||||
source_bytes = bytes(source, "utf-8")
|
||||
|
||||
def get_text(node: Node) -> str:
|
||||
return source_bytes[node.start_byte : node.end_byte].decode("utf-8")
|
||||
|
||||
def get_line(byte_pos: int) -> int:
|
||||
# Convert byte position to line number (1-indexed)
|
||||
return source[:byte_pos].count("\n") + 1
|
||||
|
||||
# Language-specific extraction
|
||||
if ext == ".py":
|
||||
extract_python_elements(tree.root_node, elements, get_text, get_line)
|
||||
elif ext in {".js", ".jsx", ".ts", ".tsx"}:
|
||||
extract_js_elements(tree.root_node, elements, get_text, get_line, ext)
|
||||
|
||||
return elements
|
||||
|
||||
def analyze_file(self, file_path: str, content: str) -> FileAnalysis:
|
||||
"""
|
||||
Analyze a single file's structure (not a diff).
|
||||
@@ -297,12 +137,7 @@ class SemanticAnalyzer:
|
||||
@property
|
||||
def supported_extensions(self) -> set[str]:
|
||||
"""Get the set of supported file extensions."""
|
||||
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"}
|
||||
return {".py", ".js", ".jsx", ".ts", ".tsx"}
|
||||
|
||||
def is_supported(self, file_path: str) -> bool:
|
||||
"""Check if a file type is supported for semantic analysis."""
|
||||
|
||||
@@ -16,6 +16,7 @@ Output:
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
@@ -23,6 +24,10 @@ 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 = {
|
||||
@@ -31,10 +36,26 @@ KNOWN_EMBEDDING_MODELS = {
|
||||
"dim": 768,
|
||||
"description": "Google EmbeddingGemma (lightweight)",
|
||||
},
|
||||
"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"},
|
||||
"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",
|
||||
},
|
||||
"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"},
|
||||
@@ -63,6 +84,7 @@ RECOMMENDED_EMBEDDING_MODELS = [
|
||||
"size_estimate": "3.1 GB",
|
||||
"dim": 2560,
|
||||
"badge": "recommended",
|
||||
"min_ollama_version": "0.10.0",
|
||||
},
|
||||
{
|
||||
"name": "qwen3-embedding:8b",
|
||||
@@ -70,6 +92,7 @@ RECOMMENDED_EMBEDDING_MODELS = [
|
||||
"size_estimate": "6.0 GB",
|
||||
"dim": 4096,
|
||||
"badge": "quality",
|
||||
"min_ollama_version": "0.10.0",
|
||||
},
|
||||
{
|
||||
"name": "qwen3-embedding:0.6b",
|
||||
@@ -77,6 +100,7 @@ RECOMMENDED_EMBEDDING_MODELS = [
|
||||
"size_estimate": "494 MB",
|
||||
"dim": 1024,
|
||||
"badge": "fast",
|
||||
"min_ollama_version": "0.10.0",
|
||||
},
|
||||
{
|
||||
"name": "embeddinggemma",
|
||||
@@ -112,6 +136,22 @@ 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}
|
||||
@@ -145,6 +185,14 @@ 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()
|
||||
@@ -192,6 +240,19 @@ 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
|
||||
@@ -200,12 +261,18 @@ 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": result.get("version", "unknown"),
|
||||
"version": version,
|
||||
"supports_new_models": version_gte(
|
||||
version, MIN_OLLAMA_VERSION_FOR_NEW_MODELS
|
||||
)
|
||||
if version != "unknown"
|
||||
else None,
|
||||
},
|
||||
)
|
||||
else:
|
||||
@@ -319,6 +386,9 @@ 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()
|
||||
@@ -330,17 +400,30 @@ def cmd_get_recommended_models(args) -> None:
|
||||
installed_names.add(name)
|
||||
installed_names.add(base_name)
|
||||
|
||||
# Build recommended list with install status
|
||||
# Build recommended list with install status and compatibility
|
||||
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,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -350,6 +433,7 @@ def cmd_get_recommended_models(args) -> None:
|
||||
"recommended": recommended,
|
||||
"count": len(recommended),
|
||||
"url": base_url,
|
||||
"ollama_version": ollama_version,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -363,6 +447,19 @@ 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")
|
||||
@@ -376,6 +473,22 @@ 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(
|
||||
|
||||
@@ -22,6 +22,68 @@ 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.
|
||||
@@ -358,6 +420,20 @@ 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`:
|
||||
@@ -618,6 +694,31 @@ 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.
|
||||
@@ -634,7 +735,7 @@ The system **automatically scans for secrets** before every commit. If secrets a
|
||||
api_key = os.environ.get("API_KEY")
|
||||
```
|
||||
3. **Update .env.example** - Add placeholder for the new variable
|
||||
4. **Re-stage and retry** - `git add . && git commit ...`
|
||||
4. **Re-stage and retry** - `git add . ':!.auto-claude' && git commit ...`
|
||||
|
||||
**If it's a false positive:**
|
||||
- Add the file pattern to `.secretsignore` in the project root
|
||||
@@ -643,7 +744,17 @@ The system **automatically scans for secrets** before every commit. If secrets a
|
||||
### Create the Commit
|
||||
|
||||
```bash
|
||||
git add .
|
||||
# FIRST: Make sure you're in the working directory root (check YOUR ENVIRONMENT section at top)
|
||||
pwd # Should match your working directory
|
||||
|
||||
# Add all files EXCEPT .auto-claude directory (spec files should never be committed)
|
||||
git add . ':!.auto-claude'
|
||||
|
||||
# If git add fails with "pathspec did not match", you have a path problem:
|
||||
# 1. Run pwd to see where you are
|
||||
# 2. Run git status to see what git sees
|
||||
# 3. Adjust your paths accordingly
|
||||
|
||||
git commit -m "auto-claude: Complete [subtask-id] - [subtask description]
|
||||
|
||||
- Files modified: [list]
|
||||
@@ -651,6 +762,9 @@ git commit -m "auto-claude: Complete [subtask-id] - [subtask description]
|
||||
- Phase progress: [X]/[Y] subtasks complete"
|
||||
```
|
||||
|
||||
**CRITICAL**: The `:!.auto-claude` pathspec exclusion ensures spec files are NEVER committed.
|
||||
These are internal tracking files that must stay local.
|
||||
|
||||
### DO NOT Push to Remote
|
||||
|
||||
**IMPORTANT**: Do NOT run `git push`. All work stays local until the user reviews and approves.
|
||||
@@ -956,6 +1070,17 @@ Prepare → Test (small batch) → Execute (full) → Cleanup
|
||||
- Clean, working state
|
||||
- **Secret scan must pass before commit**
|
||||
|
||||
### Git Configuration - NEVER MODIFY
|
||||
**CRITICAL**: You MUST NOT modify git user configuration. Never run:
|
||||
- `git config user.name`
|
||||
- `git config user.email`
|
||||
- `git config --local user.*`
|
||||
- `git config --global user.*`
|
||||
|
||||
The repository inherits the user's configured git identity. Creating "Test User" or
|
||||
any other fake identity breaks attribution and causes serious issues. If you need
|
||||
to commit changes, use the existing git identity - do NOT set a new one.
|
||||
|
||||
### The Golden Rule
|
||||
**FIX BUGS NOW.** The next session has no memory.
|
||||
|
||||
|
||||
@@ -106,6 +106,24 @@ Since this is a follow-up review, focus on:
|
||||
- Check for framework protections you might miss
|
||||
- Provide the actual code snippet as evidence
|
||||
|
||||
### Verify Before Reporting "Missing" Safeguards
|
||||
|
||||
For findings claiming something is **missing** (no fallback, no validation, no error handling):
|
||||
|
||||
**Ask yourself**: "Have I verified this is actually missing, or did I just not see it?"
|
||||
|
||||
- Read the **complete function/method** containing the issue, not just the flagged line
|
||||
- Check for guards, fallbacks, or defensive code that may appear later in the function
|
||||
- Look for comments indicating intentional design choices
|
||||
- If uncertain, use the Read/Grep tools to confirm
|
||||
|
||||
**Your evidence must prove absence exists — not just that you didn't see it.**
|
||||
|
||||
❌ **Weak**: "The code defaults to 'main' without checking if it exists"
|
||||
✅ **Strong**: "I read the complete `_detect_target_branch()` function. There is no existence check before the default return."
|
||||
|
||||
**Only report if you can confidently say**: "I verified the complete scope and the safeguard does not exist."
|
||||
|
||||
## Evidence Requirements
|
||||
|
||||
Every finding MUST include an `evidence` field with:
|
||||
|
||||
@@ -131,7 +131,21 @@ After all agents complete:
|
||||
|
||||
## Verdict Guidelines
|
||||
|
||||
### CRITICAL: CI Status ALWAYS Factors Into Verdict
|
||||
|
||||
**CI status is provided in the context and MUST be considered:**
|
||||
|
||||
- ❌ **Failing CI = BLOCKED** - If ANY CI checks are failing, verdict MUST be BLOCKED regardless of code quality
|
||||
- ⏳ **Pending CI = NEEDS_REVISION** - If CI is still running, verdict cannot be READY_TO_MERGE
|
||||
- ⏸️ **Awaiting approval = BLOCKED** - Fork PR workflows awaiting maintainer approval block merge
|
||||
- ✅ **All passing = Continue with code analysis** - Only then do code findings determine verdict
|
||||
|
||||
**Always mention CI status in your verdict_reasoning.** For example:
|
||||
- "BLOCKED: 2 CI checks failing (CodeQL, test-frontend). Fix CI before merge."
|
||||
- "READY_TO_MERGE: All CI checks passing and all findings resolved."
|
||||
|
||||
### READY_TO_MERGE
|
||||
- **All CI checks passing** (no failing, no pending)
|
||||
- All previous findings verified as resolved OR dismissed as false positives
|
||||
- No CONFIRMED_VALID critical/high issues remaining
|
||||
- No new critical/high issues
|
||||
@@ -139,11 +153,13 @@ After all agents complete:
|
||||
- Contributor questions addressed
|
||||
|
||||
### MERGE_WITH_CHANGES
|
||||
- **All CI checks passing**
|
||||
- Previous findings resolved
|
||||
- Only LOW severity new issues (suggestions)
|
||||
- Optional polish items can be addressed post-merge
|
||||
|
||||
### NEEDS_REVISION (Strict Quality Gates)
|
||||
- **CI checks pending** OR
|
||||
- HIGH or MEDIUM severity findings CONFIRMED_VALID (not dismissed as false positive)
|
||||
- New HIGH or MEDIUM severity issues introduced
|
||||
- Important contributor concerns unaddressed
|
||||
@@ -151,6 +167,8 @@ After all agents complete:
|
||||
- **Note: Only count findings that passed validation** (dismissed_false_positive findings don't block)
|
||||
|
||||
### BLOCKED
|
||||
- **Any CI checks failing** OR
|
||||
- **Workflows awaiting maintainer approval** (fork PRs) OR
|
||||
- CRITICAL findings remain CONFIRMED_VALID (not dismissed as false positive)
|
||||
- New CRITICAL issues introduced
|
||||
- Fundamental problems with the fix approach
|
||||
@@ -234,6 +252,7 @@ false positives persist forever and developers lose trust in the review system.
|
||||
|
||||
## Context You Will Receive
|
||||
|
||||
- **CI Status (CRITICAL)** - Passing/failing/pending checks and specific failed check names
|
||||
- Previous review summary and findings
|
||||
- New commits since last review (SHAs, messages)
|
||||
- Diff of changes since last review
|
||||
|
||||
@@ -78,6 +78,21 @@ Verify that the code logic is correct, handles all edge cases, and doesn't intro
|
||||
- Logic bugs must be demonstrable with a concrete example
|
||||
- If the edge case is theoretical without practical impact, don't report it
|
||||
|
||||
### Verify Before Claiming "Missing" Edge Case Handling
|
||||
|
||||
When your finding claims an edge case is **not handled** (no check for empty, null, zero, etc.):
|
||||
|
||||
**Ask yourself**: "Have I verified this case isn't handled, or did I just not see it?"
|
||||
|
||||
- Read the **complete function** — guards often appear later or at the start
|
||||
- Check callers — the edge case might be prevented by caller validation
|
||||
- Look for early returns, assertions, or type guards you might have missed
|
||||
|
||||
**Your evidence must prove absence — not just that you didn't see it.**
|
||||
|
||||
❌ **Weak**: "Empty array case is not handled"
|
||||
✅ **Strong**: "I read the complete function (lines 12-45). There's no check for empty arrays, and the code directly accesses `arr[0]` on line 15 without any guard."
|
||||
|
||||
### Severity Classification (All block merge except LOW)
|
||||
- **CRITICAL** (Blocker): Bug that will cause wrong results or crashes in production
|
||||
- Example: Off-by-one causing data corruption, race condition causing lost updates
|
||||
|
||||
@@ -79,6 +79,21 @@ Perform a thorough code quality review of the provided code changes. Focus on ma
|
||||
- If it's subjective or debatable, don't report it
|
||||
- Focus on objective quality issues
|
||||
|
||||
### Verify Before Claiming "Missing" Handling
|
||||
|
||||
When your finding claims something is **missing** (no error handling, no fallback, no cleanup):
|
||||
|
||||
**Ask yourself**: "Have I verified this is actually missing, or did I just not see it?"
|
||||
|
||||
- Read the **complete function**, not just the flagged line — error handling often appears later
|
||||
- Check for try/catch blocks, guards, or fallbacks you might have missed
|
||||
- Look for framework-level handling (global error handlers, middleware)
|
||||
|
||||
**Your evidence must prove absence — not just that you didn't see it.**
|
||||
|
||||
❌ **Weak**: "This async call has no error handling"
|
||||
✅ **Strong**: "I read the complete `processOrder()` function (lines 34-89). The `fetch()` call on line 45 has no try/catch, and there's no `.catch()` anywhere in the function."
|
||||
|
||||
### Severity Classification (All block merge except LOW)
|
||||
- **CRITICAL** (Blocker): Bug that will cause failures in production
|
||||
- Example: Unhandled promise rejection, memory leak
|
||||
|
||||
@@ -74,6 +74,21 @@ Perform a thorough security review of the provided code changes, focusing ONLY o
|
||||
- If you're unsure, don't report it
|
||||
- Prefer false negatives over false positives
|
||||
|
||||
### Verify Before Claiming "Missing" Protections
|
||||
|
||||
When your finding claims protection is **missing** (no validation, no sanitization, no auth check):
|
||||
|
||||
**Ask yourself**: "Have I verified this is actually missing, or did I just not see it?"
|
||||
|
||||
- Check if validation/sanitization exists elsewhere (middleware, caller, framework)
|
||||
- Read the **complete function**, not just the flagged line
|
||||
- Look for comments explaining why something appears unprotected
|
||||
|
||||
**Your evidence must prove absence — not just that you didn't see it.**
|
||||
|
||||
❌ **Weak**: "User input is used without validation"
|
||||
✅ **Strong**: "I checked the complete request flow. Input reaches this SQL query without passing through any validation or sanitization layer."
|
||||
|
||||
### Severity Classification (All block merge except LOW)
|
||||
- **CRITICAL** (Blocker): Exploitable vulnerability leading to data breach, RCE, or system compromise
|
||||
- Example: SQL injection, hardcoded admin password
|
||||
|
||||
@@ -80,6 +80,68 @@ lsof -iTCP -sTCP:LISTEN | grep -E "node|python|next|vite"
|
||||
|
||||
---
|
||||
|
||||
## 🚨 CRITICAL: PATH CONFUSION PREVENTION 🚨
|
||||
|
||||
**THE #1 BUG IN MONOREPOS: Doubled paths after `cd` commands**
|
||||
|
||||
### The Problem
|
||||
|
||||
After running `cd ./apps/frontend`, your current directory changes. If you then use paths like `apps/frontend/src/file.ts`, you're creating **doubled paths** like `apps/frontend/apps/frontend/src/file.ts`.
|
||||
|
||||
### The Solution: ALWAYS CHECK YOUR CWD
|
||||
|
||||
**BEFORE every git command or file operation:**
|
||||
|
||||
```bash
|
||||
# Step 1: Check where you are
|
||||
pwd
|
||||
|
||||
# Step 2: Use paths RELATIVE TO CURRENT DIRECTORY
|
||||
# If pwd shows: /path/to/project/apps/frontend
|
||||
# Then use: git add src/file.ts
|
||||
# NOT: git add apps/frontend/src/file.ts
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
**❌ WRONG - Path gets doubled:**
|
||||
```bash
|
||||
cd ./apps/frontend
|
||||
git add apps/frontend/src/file.ts # Looks for apps/frontend/apps/frontend/src/file.ts
|
||||
```
|
||||
|
||||
**✅ CORRECT - Use relative path from current directory:**
|
||||
```bash
|
||||
cd ./apps/frontend
|
||||
pwd # Shows: /path/to/project/apps/frontend
|
||||
git add src/file.ts # Correctly adds apps/frontend/src/file.ts from project root
|
||||
```
|
||||
|
||||
**✅ ALSO CORRECT - Stay at root, use full relative path:**
|
||||
```bash
|
||||
# Don't change directory at all
|
||||
git add ./apps/frontend/src/file.ts # Works from project root
|
||||
```
|
||||
|
||||
### Mandatory Pre-Command Check
|
||||
|
||||
**Before EVERY git add, git commit, or file operation in a monorepo:**
|
||||
|
||||
```bash
|
||||
# 1. Where am I?
|
||||
pwd
|
||||
|
||||
# 2. What files am I targeting?
|
||||
ls -la [target-path] # Verify the path exists
|
||||
|
||||
# 3. Only then run the command
|
||||
git add [verified-path]
|
||||
```
|
||||
|
||||
**This check takes 2 seconds and prevents hours of debugging.**
|
||||
|
||||
---
|
||||
|
||||
## PHASE 3: FIX ISSUES ONE BY ONE
|
||||
|
||||
For each issue in the fix request:
|
||||
@@ -166,8 +228,45 @@ If any issue is not fixed, go back to Phase 3.
|
||||
|
||||
## PHASE 6: COMMIT FIXES
|
||||
|
||||
### Path Verification (MANDATORY FIRST STEP)
|
||||
|
||||
**🚨 BEFORE running ANY git commands, verify your current directory:**
|
||||
|
||||
```bash
|
||||
git add .
|
||||
# Step 1: Where am I?
|
||||
pwd
|
||||
|
||||
# Step 2: What files do I want to commit?
|
||||
# If you changed to a subdirectory (e.g., cd apps/frontend),
|
||||
# you need to use paths RELATIVE TO THAT DIRECTORY, not from project root
|
||||
|
||||
# Step 3: Verify paths exist
|
||||
ls -la [path-to-files] # Make sure the path is correct from your current location
|
||||
|
||||
# Example in a monorepo:
|
||||
# If pwd shows: /project/apps/frontend
|
||||
# Then use: git add src/file.ts
|
||||
# NOT: git add apps/frontend/src/file.ts (this would look for apps/frontend/apps/frontend/src/file.ts)
|
||||
```
|
||||
|
||||
**CRITICAL RULE:** If you're in a subdirectory, either:
|
||||
- **Option A:** Return to project root: `cd [back to working directory]`
|
||||
- **Option B:** Use paths relative to your CURRENT directory (check with `pwd`)
|
||||
|
||||
### Create the Commit
|
||||
|
||||
```bash
|
||||
# FIRST: Make sure you're in the working directory root
|
||||
pwd # Should match your working directory
|
||||
|
||||
# Add all files EXCEPT .auto-claude directory (spec files should never be committed)
|
||||
git add . ':!.auto-claude'
|
||||
|
||||
# If git add fails with "pathspec did not match", you have a path problem:
|
||||
# 1. Run pwd to see where you are
|
||||
# 2. Run git status to see what git sees
|
||||
# 3. Adjust your paths accordingly
|
||||
|
||||
git commit -m "fix: Address QA issues (qa-requested)
|
||||
|
||||
Fixes:
|
||||
@@ -182,6 +281,8 @@ Verified:
|
||||
QA Fix Session: [N]"
|
||||
```
|
||||
|
||||
**CRITICAL**: The `:!.auto-claude` pathspec exclusion ensures spec files are NEVER committed.
|
||||
|
||||
**NOTE**: Do NOT push to remote. All work stays local until user reviews and approves.
|
||||
|
||||
---
|
||||
@@ -304,6 +405,13 @@ npx prisma migrate dev --name [name]
|
||||
- How you verified
|
||||
- Commit messages
|
||||
|
||||
### Git Configuration - NEVER MODIFY
|
||||
**CRITICAL**: You MUST NOT modify git user configuration. Never run:
|
||||
- `git config user.name`
|
||||
- `git config user.email`
|
||||
|
||||
The repository inherits the user's configured git identity. Do NOT set test users.
|
||||
|
||||
---
|
||||
|
||||
## QA LOOP BEHAVIOR
|
||||
|
||||
@@ -35,8 +35,8 @@ cat project_index.json
|
||||
# 4. Check build progress
|
||||
cat build-progress.txt
|
||||
|
||||
# 5. See what files were changed
|
||||
git diff main --name-only
|
||||
# 5. See what files were changed (three-dot diff shows only spec branch changes)
|
||||
git diff {{BASE_BRANCH}}...HEAD --name-status
|
||||
|
||||
# 6. Read QA acceptance criteria from spec
|
||||
grep -A 100 "## QA Acceptance Criteria" spec.md
|
||||
@@ -514,7 +514,7 @@ All acceptance criteria verified:
|
||||
The implementation is production-ready.
|
||||
Sign-off recorded in implementation_plan.json.
|
||||
|
||||
Ready for merge to main.
|
||||
Ready for merge to {{BASE_BRANCH}}.
|
||||
```
|
||||
|
||||
### If Rejected:
|
||||
|
||||
@@ -62,6 +62,11 @@ def generate_environment_context(project_dir: Path, spec_dir: Path) -> str:
|
||||
Your filesystem is restricted to your working directory. All file paths should be
|
||||
relative to this location. Do NOT use absolute paths.
|
||||
|
||||
**⚠️ CRITICAL:** Before ANY git command or file operation, run `pwd` to verify your current
|
||||
directory. If you've used `cd` to change directories, you MUST use paths relative to your
|
||||
NEW location, not the working directory. See the PATH CONFUSION PREVENTION section in the
|
||||
coder prompt for detailed examples.
|
||||
|
||||
**Important Files:**
|
||||
- Spec: `{relative_spec}/spec.md`
|
||||
- Plan: `{relative_spec}/implementation_plan.json`
|
||||
|
||||
@@ -7,7 +7,9 @@ Supports dynamic prompt assembly based on project type for context optimization.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from .project_context import (
|
||||
@@ -16,6 +18,133 @@ from .project_context import (
|
||||
load_project_index,
|
||||
)
|
||||
|
||||
|
||||
def _validate_branch_name(branch: str | None) -> str | None:
|
||||
"""
|
||||
Validate a git branch name for safety and correctness.
|
||||
|
||||
Args:
|
||||
branch: The branch name to validate
|
||||
|
||||
Returns:
|
||||
The validated branch name, or None if invalid
|
||||
"""
|
||||
if not branch or not isinstance(branch, str):
|
||||
return None
|
||||
|
||||
# Trim whitespace
|
||||
branch = branch.strip()
|
||||
|
||||
# Reject empty or whitespace-only strings
|
||||
if not branch:
|
||||
return None
|
||||
|
||||
# Enforce maximum length (git refs can be long, but 255 is reasonable)
|
||||
if len(branch) > 255:
|
||||
return None
|
||||
|
||||
# Require at least one alphanumeric character
|
||||
if not any(c.isalnum() for c in branch):
|
||||
return None
|
||||
|
||||
# Only allow common git-ref characters: letters, numbers, ., _, -, /
|
||||
# This prevents prompt injection and other security issues
|
||||
if not re.match(r"^[A-Za-z0-9._/-]+$", branch):
|
||||
return None
|
||||
|
||||
# Reject suspicious patterns that could be prompt injection attempts
|
||||
# (newlines, control characters are already blocked by the regex above)
|
||||
|
||||
return branch
|
||||
|
||||
|
||||
def _get_base_branch_from_metadata(spec_dir: Path) -> str | None:
|
||||
"""
|
||||
Read baseBranch from task_metadata.json if it exists.
|
||||
|
||||
Args:
|
||||
spec_dir: Directory containing the spec files
|
||||
|
||||
Returns:
|
||||
The baseBranch from metadata, or None if not found or invalid
|
||||
"""
|
||||
metadata_path = spec_dir / "task_metadata.json"
|
||||
if metadata_path.exists():
|
||||
try:
|
||||
with open(metadata_path, encoding="utf-8") as f:
|
||||
metadata = json.load(f)
|
||||
base_branch = metadata.get("baseBranch")
|
||||
# Validate the branch name before returning
|
||||
return _validate_branch_name(base_branch)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _detect_base_branch(spec_dir: Path, project_dir: Path) -> str:
|
||||
"""
|
||||
Detect the base branch for a project/task.
|
||||
|
||||
Priority order:
|
||||
1. baseBranch from task_metadata.json (task-level override)
|
||||
2. DEFAULT_BRANCH environment variable
|
||||
3. Auto-detect main/master/develop (if they exist in git)
|
||||
4. Fall back to "main"
|
||||
|
||||
Args:
|
||||
spec_dir: Directory containing the spec files
|
||||
project_dir: Project root directory
|
||||
|
||||
Returns:
|
||||
The detected base branch name
|
||||
"""
|
||||
# 1. Check task_metadata.json for task-specific baseBranch
|
||||
metadata_branch = _get_base_branch_from_metadata(spec_dir)
|
||||
if metadata_branch:
|
||||
return metadata_branch
|
||||
|
||||
# 2. Check for DEFAULT_BRANCH env var
|
||||
env_branch = _validate_branch_name(os.getenv("DEFAULT_BRANCH"))
|
||||
if env_branch:
|
||||
# Verify the branch exists (with timeout to prevent hanging)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--verify", env_branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=3,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return env_branch
|
||||
except subprocess.TimeoutExpired:
|
||||
# Treat timeout as branch verification failure
|
||||
pass
|
||||
|
||||
# 3. Auto-detect main/master/develop
|
||||
for branch in ["main", "master", "develop"]:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--verify", branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=3,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return branch
|
||||
except subprocess.TimeoutExpired:
|
||||
# Treat timeout as branch verification failure, try next branch
|
||||
continue
|
||||
|
||||
# 4. Fall back to "main"
|
||||
return "main"
|
||||
|
||||
|
||||
# Directory containing prompt files
|
||||
# prompts/ is a sibling directory of prompts_pkg/, so go up one level first
|
||||
PROMPTS_DIR = Path(__file__).parent.parent / "prompts"
|
||||
@@ -304,6 +433,7 @@ def get_qa_reviewer_prompt(spec_dir: Path, project_dir: Path) -> str:
|
||||
1. Loads the base QA reviewer prompt
|
||||
2. Detects project capabilities from project_index.json
|
||||
3. Injects only relevant MCP tool documentation (Electron, Puppeteer, DB, API)
|
||||
4. Detects and injects the correct base branch for git comparisons
|
||||
|
||||
This saves context window by excluding irrelevant tool docs.
|
||||
For example, a CLI Python project won't get Electron validation docs.
|
||||
@@ -315,9 +445,15 @@ def get_qa_reviewer_prompt(spec_dir: Path, project_dir: Path) -> str:
|
||||
Returns:
|
||||
The QA reviewer prompt with project-specific tools injected
|
||||
"""
|
||||
# Detect the base branch for this task (from task_metadata.json or auto-detect)
|
||||
base_branch = _detect_base_branch(spec_dir, project_dir)
|
||||
|
||||
# Load base QA reviewer prompt
|
||||
base_prompt = _load_prompt_file("qa_reviewer.md")
|
||||
|
||||
# Replace {{BASE_BRANCH}} placeholder with the actual base branch
|
||||
base_prompt = base_prompt.replace("{{BASE_BRANCH}}", base_branch)
|
||||
|
||||
# Load project index and detect capabilities
|
||||
project_index = load_project_index(project_dir)
|
||||
capabilities = detect_project_capabilities(project_index)
|
||||
@@ -347,6 +483,17 @@ Your spec and progress files are located at:
|
||||
|
||||
The project root is: `{project_dir}`
|
||||
|
||||
## GIT BRANCH CONFIGURATION
|
||||
|
||||
**Base branch for comparison:** `{base_branch}`
|
||||
|
||||
When checking for unrelated changes, use three-dot diff syntax:
|
||||
```bash
|
||||
git diff {base_branch}...HEAD --name-status
|
||||
```
|
||||
|
||||
This shows only changes made in the spec branch since it diverged from `{base_branch}`.
|
||||
|
||||
---
|
||||
|
||||
## PROJECT CAPABILITIES DETECTED
|
||||
|
||||
@@ -6,6 +6,7 @@ Main QA loop that coordinates reviewer and fixer sessions until
|
||||
approval or max iterations.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time as time_module
|
||||
from pathlib import Path
|
||||
|
||||
@@ -22,6 +23,7 @@ from linear_updater import (
|
||||
from phase_config import get_phase_model, get_phase_thinking_budget
|
||||
from phase_event import ExecutionPhase, emit_phase
|
||||
from progress import count_subtasks, is_build_complete
|
||||
from security.constants import PROJECT_DIR_ENV_VAR
|
||||
from task_logger import (
|
||||
LogPhase,
|
||||
get_task_logger,
|
||||
@@ -83,6 +85,10 @@ async def run_qa_validation_loop(
|
||||
Returns:
|
||||
True if QA approved, False otherwise
|
||||
"""
|
||||
# Set environment variable for security hooks to find the correct project directory
|
||||
# This is needed because os.getcwd() may return the wrong directory in worktree mode
|
||||
os.environ[PROJECT_DIR_ENV_VAR] = str(project_dir.resolve())
|
||||
|
||||
debug_section("qa_loop", "QA Validation Loop")
|
||||
debug(
|
||||
"qa_loop",
|
||||
|
||||
+187
-32
@@ -185,24 +185,31 @@ def cmd_get_memories(args):
|
||||
"""
|
||||
|
||||
result = conn.execute(query, parameters={"limit": limit})
|
||||
df = result.get_as_df()
|
||||
|
||||
# Process results without pandas (iterate through result set directly)
|
||||
memories = []
|
||||
for _, row in df.iterrows():
|
||||
while result.has_next():
|
||||
row = result.get_next()
|
||||
# Row order: uuid, name, created_at, content, description, group_id
|
||||
uuid_val = serialize_value(row[0]) if len(row) > 0 else None
|
||||
name_val = serialize_value(row[1]) if len(row) > 1 else ""
|
||||
created_at_val = serialize_value(row[2]) if len(row) > 2 else None
|
||||
content_val = serialize_value(row[3]) if len(row) > 3 else ""
|
||||
description_val = serialize_value(row[4]) if len(row) > 4 else ""
|
||||
group_id_val = serialize_value(row[5]) if len(row) > 5 else ""
|
||||
|
||||
memory = {
|
||||
"id": row.get("uuid") or row.get("name", "unknown"),
|
||||
"name": row.get("name", ""),
|
||||
"type": infer_episode_type(row.get("name", ""), row.get("content", "")),
|
||||
"timestamp": row.get("created_at") or datetime.now().isoformat(),
|
||||
"content": row.get("content")
|
||||
or row.get("description")
|
||||
or row.get("name", ""),
|
||||
"description": row.get("description", ""),
|
||||
"group_id": row.get("group_id", ""),
|
||||
"id": uuid_val or name_val or "unknown",
|
||||
"name": name_val or "",
|
||||
"type": infer_episode_type(name_val or "", content_val or ""),
|
||||
"timestamp": created_at_val or datetime.now().isoformat(),
|
||||
"content": content_val or description_val or name_val or "",
|
||||
"description": description_val or "",
|
||||
"group_id": group_id_val or "",
|
||||
}
|
||||
|
||||
# Extract session number if present
|
||||
session_num = extract_session_number(row.get("name", ""))
|
||||
session_num = extract_session_number(name_val or "")
|
||||
if session_num:
|
||||
memory["session_number"] = session_num
|
||||
|
||||
@@ -251,24 +258,31 @@ def cmd_search(args):
|
||||
result = conn.execute(
|
||||
query, parameters={"search_query": search_query, "limit": limit}
|
||||
)
|
||||
df = result.get_as_df()
|
||||
|
||||
# Process results without pandas
|
||||
memories = []
|
||||
for _, row in df.iterrows():
|
||||
while result.has_next():
|
||||
row = result.get_next()
|
||||
# Row order: uuid, name, created_at, content, description, group_id
|
||||
uuid_val = serialize_value(row[0]) if len(row) > 0 else None
|
||||
name_val = serialize_value(row[1]) if len(row) > 1 else ""
|
||||
created_at_val = serialize_value(row[2]) if len(row) > 2 else None
|
||||
content_val = serialize_value(row[3]) if len(row) > 3 else ""
|
||||
description_val = serialize_value(row[4]) if len(row) > 4 else ""
|
||||
group_id_val = serialize_value(row[5]) if len(row) > 5 else ""
|
||||
|
||||
memory = {
|
||||
"id": row.get("uuid") or row.get("name", "unknown"),
|
||||
"name": row.get("name", ""),
|
||||
"type": infer_episode_type(row.get("name", ""), row.get("content", "")),
|
||||
"timestamp": row.get("created_at") or datetime.now().isoformat(),
|
||||
"content": row.get("content")
|
||||
or row.get("description")
|
||||
or row.get("name", ""),
|
||||
"description": row.get("description", ""),
|
||||
"group_id": row.get("group_id", ""),
|
||||
"id": uuid_val or name_val or "unknown",
|
||||
"name": name_val or "",
|
||||
"type": infer_episode_type(name_val or "", content_val or ""),
|
||||
"timestamp": created_at_val or datetime.now().isoformat(),
|
||||
"content": content_val or description_val or name_val or "",
|
||||
"description": description_val or "",
|
||||
"group_id": group_id_val or "",
|
||||
"score": 1.0, # Keyword match score
|
||||
}
|
||||
|
||||
session_num = extract_session_number(row.get("name", ""))
|
||||
session_num = extract_session_number(name_val or "")
|
||||
if session_num:
|
||||
memory["session_number"] = session_num
|
||||
|
||||
@@ -461,19 +475,26 @@ def cmd_get_entities(args):
|
||||
"""
|
||||
|
||||
result = conn.execute(query, parameters={"limit": limit})
|
||||
df = result.get_as_df()
|
||||
|
||||
# Process results without pandas
|
||||
entities = []
|
||||
for _, row in df.iterrows():
|
||||
if not row.get("summary"):
|
||||
while result.has_next():
|
||||
row = result.get_next()
|
||||
# Row order: uuid, name, summary, created_at
|
||||
uuid_val = serialize_value(row[0]) if len(row) > 0 else None
|
||||
name_val = serialize_value(row[1]) if len(row) > 1 else ""
|
||||
summary_val = serialize_value(row[2]) if len(row) > 2 else ""
|
||||
created_at_val = serialize_value(row[3]) if len(row) > 3 else None
|
||||
|
||||
if not summary_val:
|
||||
continue
|
||||
|
||||
entity = {
|
||||
"id": row.get("uuid") or row.get("name", "unknown"),
|
||||
"name": row.get("name", ""),
|
||||
"type": infer_entity_type(row.get("name", "")),
|
||||
"timestamp": row.get("created_at") or datetime.now().isoformat(),
|
||||
"content": row.get("summary", ""),
|
||||
"id": uuid_val or name_val or "unknown",
|
||||
"name": name_val or "",
|
||||
"type": infer_entity_type(name_val or ""),
|
||||
"timestamp": created_at_val or datetime.now().isoformat(),
|
||||
"content": summary_val or "",
|
||||
}
|
||||
entities.append(entity)
|
||||
|
||||
@@ -488,6 +509,118 @@ def cmd_get_entities(args):
|
||||
output_error(f"Query failed: {e}")
|
||||
|
||||
|
||||
def cmd_add_episode(args):
|
||||
"""
|
||||
Add a new episode to the memory database.
|
||||
|
||||
This is called from the Electron main process to save PR review insights,
|
||||
patterns, gotchas, and other memories directly to the LadybugDB database.
|
||||
|
||||
Args:
|
||||
args.db_path: Path to database directory
|
||||
args.database: Database name
|
||||
args.name: Episode name/title
|
||||
args.content: Episode content (JSON string)
|
||||
args.episode_type: Type of episode (session_insight, pattern, gotcha, task_outcome, pr_review)
|
||||
args.group_id: Optional group ID for namespacing
|
||||
"""
|
||||
if not apply_monkeypatch():
|
||||
output_error("Neither kuzu nor LadybugDB is installed")
|
||||
return
|
||||
|
||||
try:
|
||||
import uuid as uuid_module
|
||||
|
||||
try:
|
||||
import kuzu
|
||||
except ImportError:
|
||||
import real_ladybug as kuzu
|
||||
|
||||
# Parse content from JSON if provided
|
||||
content = args.content
|
||||
if content:
|
||||
try:
|
||||
# Try to parse as JSON to validate
|
||||
parsed = json.loads(content)
|
||||
# Re-serialize to ensure consistent formatting
|
||||
content = json.dumps(parsed)
|
||||
except json.JSONDecodeError:
|
||||
# If not valid JSON, use as-is
|
||||
pass
|
||||
|
||||
# Generate unique ID
|
||||
episode_uuid = str(uuid_module.uuid4())
|
||||
created_at = datetime.now().isoformat()
|
||||
|
||||
# Get database path - create directory if needed
|
||||
full_path = Path(args.db_path) / args.database
|
||||
if not full_path.exists():
|
||||
# For new databases, create the parent directory
|
||||
Path(args.db_path).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Open database (creates it if it doesn't exist)
|
||||
db = kuzu.Database(str(full_path))
|
||||
conn = kuzu.Connection(db)
|
||||
|
||||
# Always try to create the Episodic table if it doesn't exist
|
||||
# This handles both new databases and existing databases without the table
|
||||
try:
|
||||
conn.execute("""
|
||||
CREATE NODE TABLE IF NOT EXISTS Episodic (
|
||||
uuid STRING PRIMARY KEY,
|
||||
name STRING,
|
||||
content STRING,
|
||||
source_description STRING,
|
||||
group_id STRING,
|
||||
created_at STRING
|
||||
)
|
||||
""")
|
||||
except Exception as schema_err:
|
||||
# Table might already exist with different schema - that's ok
|
||||
# The insert will fail if schema is incompatible
|
||||
sys.stderr.write(f"Schema creation note: {schema_err}\n")
|
||||
|
||||
# Insert the episode
|
||||
try:
|
||||
insert_query = """
|
||||
CREATE (e:Episodic {
|
||||
uuid: $uuid,
|
||||
name: $name,
|
||||
content: $content,
|
||||
source_description: $description,
|
||||
group_id: $group_id,
|
||||
created_at: $created_at
|
||||
})
|
||||
"""
|
||||
conn.execute(
|
||||
insert_query,
|
||||
parameters={
|
||||
"uuid": episode_uuid,
|
||||
"name": args.name,
|
||||
"content": content,
|
||||
"description": f"[{args.episode_type}] {args.name}",
|
||||
"group_id": args.group_id or "",
|
||||
"created_at": created_at,
|
||||
},
|
||||
)
|
||||
|
||||
output_json(
|
||||
True,
|
||||
data={
|
||||
"id": episode_uuid,
|
||||
"name": args.name,
|
||||
"type": args.episode_type,
|
||||
"timestamp": created_at,
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
output_error(f"Failed to insert episode: {e}")
|
||||
|
||||
except Exception as e:
|
||||
output_error(f"Failed to add episode: {e}")
|
||||
|
||||
|
||||
def infer_episode_type(name: str, content: str = "") -> str:
|
||||
"""Infer the episode type from its name and content."""
|
||||
name_lower = (name or "").lower()
|
||||
@@ -580,6 +713,27 @@ def main():
|
||||
"--limit", type=int, default=20, help="Maximum results"
|
||||
)
|
||||
|
||||
# add-episode command (for saving memories from Electron app)
|
||||
add_parser = subparsers.add_parser(
|
||||
"add-episode",
|
||||
help="Add an episode to the memory database (called from Electron)",
|
||||
)
|
||||
add_parser.add_argument("db_path", help="Path to database directory")
|
||||
add_parser.add_argument("database", help="Database name")
|
||||
add_parser.add_argument("--name", required=True, help="Episode name/title")
|
||||
add_parser.add_argument(
|
||||
"--content", required=True, help="Episode content (JSON string)"
|
||||
)
|
||||
add_parser.add_argument(
|
||||
"--type",
|
||||
dest="episode_type",
|
||||
default="session_insight",
|
||||
help="Episode type (session_insight, pattern, gotcha, task_outcome, pr_review)",
|
||||
)
|
||||
add_parser.add_argument(
|
||||
"--group-id", dest="group_id", help="Optional group ID for namespacing"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.command:
|
||||
@@ -594,6 +748,7 @@ def main():
|
||||
"search": cmd_search,
|
||||
"semantic-search": cmd_semantic_search,
|
||||
"get-entities": cmd_get_entities,
|
||||
"add-episode": cmd_add_episode,
|
||||
}
|
||||
|
||||
handler = commands.get(args.command)
|
||||
|
||||
@@ -10,6 +10,10 @@ tomli>=2.0.0; python_version < "3.11"
|
||||
real_ladybug>=0.13.0; python_version >= "3.12"
|
||||
graphiti-core>=0.5.0; python_version >= "3.12"
|
||||
|
||||
# Windows-specific dependency for LadybugDB/Graphiti
|
||||
# pywin32 provides Windows system bindings required by real_ladybug
|
||||
pywin32>=306; sys_platform == "win32" and python_version >= "3.12"
|
||||
|
||||
# Google AI (optional - for Gemini LLM and embeddings)
|
||||
google-generativeai>=0.8.0
|
||||
|
||||
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
PR Worktree Cleanup Utility
|
||||
============================
|
||||
|
||||
Command-line tool for managing PR review worktrees.
|
||||
|
||||
Usage:
|
||||
python cleanup_pr_worktrees.py --list # List all worktrees
|
||||
python cleanup_pr_worktrees.py --cleanup # Run cleanup policies
|
||||
python cleanup_pr_worktrees.py --cleanup-all # Remove ALL worktrees
|
||||
python cleanup_pr_worktrees.py --stats # Show cleanup statistics
|
||||
"""
|
||||
|
||||
import argparse
|
||||
|
||||
# Load module directly to avoid import issues
|
||||
import importlib.util
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
services_dir = Path(__file__).parent / "services"
|
||||
module_path = services_dir / "pr_worktree_manager.py"
|
||||
|
||||
spec = importlib.util.spec_from_file_location("pr_worktree_manager", module_path)
|
||||
pr_worktree_module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(pr_worktree_module)
|
||||
|
||||
PRWorktreeManager = pr_worktree_module.PRWorktreeManager
|
||||
DEFAULT_PR_WORKTREE_MAX_AGE_DAYS = pr_worktree_module.DEFAULT_PR_WORKTREE_MAX_AGE_DAYS
|
||||
DEFAULT_MAX_PR_WORKTREES = pr_worktree_module.DEFAULT_MAX_PR_WORKTREES
|
||||
_get_max_age_days = pr_worktree_module._get_max_age_days
|
||||
_get_max_pr_worktrees = pr_worktree_module._get_max_pr_worktrees
|
||||
|
||||
|
||||
def find_project_root() -> Path:
|
||||
"""Find the git project root directory."""
|
||||
current = Path.cwd()
|
||||
while current != current.parent:
|
||||
if (current / ".git").exists():
|
||||
return current
|
||||
current = current.parent
|
||||
raise RuntimeError("Not in a git repository")
|
||||
|
||||
|
||||
def list_worktrees(manager: PRWorktreeManager) -> None:
|
||||
"""List all PR review worktrees."""
|
||||
worktrees = manager.get_worktree_info()
|
||||
|
||||
if not worktrees:
|
||||
print("No PR review worktrees found.")
|
||||
return
|
||||
|
||||
print(f"\nFound {len(worktrees)} PR review worktrees:\n")
|
||||
print(f"{'Directory':<40} {'Age (days)':<12} {'PR':<6}")
|
||||
print("-" * 60)
|
||||
|
||||
for wt in worktrees:
|
||||
pr_str = f"#{wt.pr_number}" if wt.pr_number else "N/A"
|
||||
print(f"{wt.path.name:<40} {wt.age_days:>10.1f} {pr_str:>6}")
|
||||
|
||||
print()
|
||||
|
||||
|
||||
def show_stats(manager: PRWorktreeManager) -> None:
|
||||
"""Show worktree cleanup statistics."""
|
||||
worktrees = manager.get_worktree_info()
|
||||
registered = manager.get_registered_worktrees()
|
||||
# Use resolved paths for consistent comparison (handles macOS symlinks)
|
||||
registered_resolved = {p.resolve() for p in registered}
|
||||
|
||||
# Get current policy values (may be overridden by env vars)
|
||||
max_age_days = _get_max_age_days()
|
||||
max_worktrees = _get_max_pr_worktrees()
|
||||
|
||||
total = len(worktrees)
|
||||
orphaned = sum(
|
||||
1 for wt in worktrees if wt.path.resolve() not in registered_resolved
|
||||
)
|
||||
expired = sum(1 for wt in worktrees if wt.age_days > max_age_days)
|
||||
excess = max(0, total - max_worktrees)
|
||||
|
||||
print("\nPR Worktree Statistics:")
|
||||
print(f" Total worktrees: {total}")
|
||||
print(f" Registered with git: {len(registered)}")
|
||||
print(f" Orphaned (not in git): {orphaned}")
|
||||
print(f" Expired (>{max_age_days} days): {expired}")
|
||||
print(f" Excess (>{max_worktrees} limit): {excess}")
|
||||
print()
|
||||
print("Cleanup Policies:")
|
||||
print(f" Max age: {max_age_days} days")
|
||||
print(f" Max count: {max_worktrees} worktrees")
|
||||
print()
|
||||
|
||||
|
||||
def cleanup_worktrees(manager: PRWorktreeManager, force: bool = False) -> None:
|
||||
"""Run cleanup policies on worktrees."""
|
||||
print("\nRunning PR worktree cleanup...")
|
||||
if force:
|
||||
print("WARNING: Force cleanup - removing ALL worktrees!")
|
||||
count = manager.cleanup_all_worktrees()
|
||||
print(f"Removed {count} worktrees.")
|
||||
else:
|
||||
stats = manager.cleanup_worktrees()
|
||||
if stats["total"] == 0:
|
||||
print("No worktrees needed cleanup.")
|
||||
else:
|
||||
print("\nCleanup complete:")
|
||||
print(f" Orphaned removed: {stats['orphaned']}")
|
||||
print(f" Expired removed: {stats['expired']}")
|
||||
print(f" Excess removed: {stats['excess']}")
|
||||
print(f" Total removed: {stats['total']}")
|
||||
print()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Manage PR review worktrees",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
python cleanup_pr_worktrees.py --list
|
||||
python cleanup_pr_worktrees.py --cleanup
|
||||
python cleanup_pr_worktrees.py --stats
|
||||
python cleanup_pr_worktrees.py --cleanup-all
|
||||
|
||||
Environment variables:
|
||||
MAX_PR_WORKTREES=10 # Max number of worktrees to keep
|
||||
PR_WORKTREE_MAX_AGE_DAYS=7 # Max age in days before cleanup
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--list", action="store_true", help="List all PR review worktrees"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--cleanup",
|
||||
action="store_true",
|
||||
help="Run cleanup policies (remove orphaned, expired, and excess worktrees)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--cleanup-all",
|
||||
action="store_true",
|
||||
help="Remove ALL PR review worktrees (dangerous!)",
|
||||
)
|
||||
|
||||
parser.add_argument("--stats", action="store_true", help="Show cleanup statistics")
|
||||
|
||||
parser.add_argument(
|
||||
"--project-dir",
|
||||
type=Path,
|
||||
help="Project directory (default: auto-detect git root)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Require at least one action
|
||||
if not any([args.list, args.cleanup, args.cleanup_all, args.stats]):
|
||||
parser.print_help()
|
||||
return 1
|
||||
|
||||
try:
|
||||
# Find project directory
|
||||
if args.project_dir:
|
||||
project_dir = args.project_dir
|
||||
else:
|
||||
project_dir = find_project_root()
|
||||
|
||||
print(f"Project directory: {project_dir}")
|
||||
|
||||
# Create manager
|
||||
manager = PRWorktreeManager(
|
||||
project_dir=project_dir, worktree_dir=".auto-claude/github/pr/worktrees"
|
||||
)
|
||||
|
||||
# Execute actions
|
||||
if args.stats:
|
||||
show_stats(manager)
|
||||
|
||||
if args.list:
|
||||
list_worktrees(manager)
|
||||
|
||||
if args.cleanup:
|
||||
cleanup_worktrees(manager, force=False)
|
||||
|
||||
if args.cleanup_all:
|
||||
response = input(
|
||||
"This will remove ALL PR worktrees. Are you sure? (yes/no): "
|
||||
)
|
||||
if response.lower() == "yes":
|
||||
cleanup_worktrees(manager, force=True)
|
||||
else:
|
||||
print("Aborted.")
|
||||
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -875,6 +875,128 @@ class GHClient:
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
async def get_workflows_awaiting_approval(self, pr_number: int) -> dict[str, Any]:
|
||||
"""
|
||||
Get workflow runs awaiting approval for a PR from a fork.
|
||||
|
||||
Workflows from forked repositories require manual approval before running.
|
||||
These are NOT included in `gh pr checks` and must be queried separately.
|
||||
|
||||
Args:
|
||||
pr_number: PR number
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- awaiting_approval: Number of workflows waiting for approval
|
||||
- workflow_runs: List of workflow runs with id, name, html_url
|
||||
- can_approve: Whether this token can approve workflows
|
||||
"""
|
||||
try:
|
||||
# First, get the PR's head SHA to filter workflow runs
|
||||
pr_args = ["pr", "view", str(pr_number), "--json", "headRefOid"]
|
||||
pr_args = self._add_repo_flag(pr_args)
|
||||
pr_result = await self.run(pr_args, timeout=30.0)
|
||||
pr_data = json.loads(pr_result.stdout) if pr_result.stdout.strip() else {}
|
||||
head_sha = pr_data.get("headRefOid", "")
|
||||
|
||||
if not head_sha:
|
||||
return {
|
||||
"awaiting_approval": 0,
|
||||
"workflow_runs": [],
|
||||
"can_approve": False,
|
||||
}
|
||||
|
||||
# Query workflow runs with action_required status
|
||||
# Note: We need to use the API endpoint as gh CLI doesn't have direct support
|
||||
endpoint = (
|
||||
"repos/{owner}/{repo}/actions/runs?status=action_required&per_page=100"
|
||||
)
|
||||
args = ["api", "--method", "GET", endpoint]
|
||||
|
||||
result = await self.run(args, timeout=30.0)
|
||||
data = json.loads(result.stdout) if result.stdout.strip() else {}
|
||||
all_runs = data.get("workflow_runs", [])
|
||||
|
||||
# Filter to only runs for this PR's head SHA
|
||||
pr_runs = [
|
||||
{
|
||||
"id": run.get("id"),
|
||||
"name": run.get("name"),
|
||||
"html_url": run.get("html_url"),
|
||||
"workflow_name": run.get("workflow", {}).get("name", "Unknown"),
|
||||
}
|
||||
for run in all_runs
|
||||
if run.get("head_sha") == head_sha
|
||||
]
|
||||
|
||||
return {
|
||||
"awaiting_approval": len(pr_runs),
|
||||
"workflow_runs": pr_runs,
|
||||
"can_approve": True, # Assume token has permission, will fail if not
|
||||
}
|
||||
except (GHCommandError, GHTimeoutError, json.JSONDecodeError) as e:
|
||||
logger.warning(
|
||||
f"Failed to get workflows awaiting approval for #{pr_number}: {e}"
|
||||
)
|
||||
return {
|
||||
"awaiting_approval": 0,
|
||||
"workflow_runs": [],
|
||||
"can_approve": False,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
async def approve_workflow_run(self, run_id: int) -> bool:
|
||||
"""
|
||||
Approve a workflow run that's waiting for approval (from a fork).
|
||||
|
||||
Args:
|
||||
run_id: The workflow run ID to approve
|
||||
|
||||
Returns:
|
||||
True if approval succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
endpoint = f"repos/{{owner}}/{{repo}}/actions/runs/{run_id}/approve"
|
||||
args = ["api", "--method", "POST", endpoint]
|
||||
|
||||
await self.run(args, timeout=30.0)
|
||||
logger.info(f"Approved workflow run {run_id}")
|
||||
return True
|
||||
except (GHCommandError, GHTimeoutError) as e:
|
||||
logger.warning(f"Failed to approve workflow run {run_id}: {e}")
|
||||
return False
|
||||
|
||||
async def get_pr_checks_comprehensive(self, pr_number: int) -> dict[str, Any]:
|
||||
"""
|
||||
Get comprehensive CI status including workflows awaiting approval.
|
||||
|
||||
This combines:
|
||||
- Standard check runs from `gh pr checks`
|
||||
- Workflows awaiting approval (for fork PRs)
|
||||
|
||||
Args:
|
||||
pr_number: PR number
|
||||
|
||||
Returns:
|
||||
Dict with all check information including awaiting_approval count
|
||||
"""
|
||||
# Get standard checks
|
||||
checks = await self.get_pr_checks(pr_number)
|
||||
|
||||
# Get workflows awaiting approval
|
||||
awaiting = await self.get_workflows_awaiting_approval(pr_number)
|
||||
|
||||
# Merge the results
|
||||
checks["awaiting_approval"] = awaiting.get("awaiting_approval", 0)
|
||||
checks["awaiting_workflow_runs"] = awaiting.get("workflow_runs", [])
|
||||
|
||||
# Update pending count to include awaiting approval
|
||||
checks["pending"] = checks.get("pending", 0) + awaiting.get(
|
||||
"awaiting_approval", 0
|
||||
)
|
||||
|
||||
return checks
|
||||
|
||||
async def get_pr_files(self, pr_number: int) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Get files changed by a PR using the PR files endpoint.
|
||||
@@ -1007,7 +1129,9 @@ class GHClient:
|
||||
Returns:
|
||||
Tuple of:
|
||||
- List of file objects that are part of the PR (filtered if blob comparison used)
|
||||
- List of commit objects that are part of the PR and after base_sha
|
||||
- List of commit objects that are part of the PR and after base_sha.
|
||||
NOTE: Returns empty list if rebase/force-push detected, since commit SHAs
|
||||
are rewritten and we cannot determine which commits are truly "new".
|
||||
"""
|
||||
# Get PR's canonical files (these are the actual PR changes)
|
||||
pr_files = await self.get_pr_files(pr_number)
|
||||
@@ -1072,12 +1196,14 @@ class GHClient:
|
||||
f"{unchanged_count} unchanged (skipped)"
|
||||
)
|
||||
|
||||
# Return filtered files but all commits (can't filter commits after rebase)
|
||||
return changed_files, pr_commits
|
||||
# Return filtered files but empty commits list (can't determine "new" commits after rebase)
|
||||
# After a rebase, all commit SHAs are rewritten so we can't identify which are truly new.
|
||||
# The file changes via blob comparison are the reliable source of what changed.
|
||||
return changed_files, []
|
||||
|
||||
# No blob data available - return all files and commits
|
||||
# No blob data available - return all files but empty commits (can't determine new commits)
|
||||
logger.warning(
|
||||
"No reviewed_file_blobs available for blob comparison. "
|
||||
"Returning all PR files."
|
||||
"No reviewed_file_blobs available for blob comparison after rebase. "
|
||||
"Returning all PR files with empty commits list."
|
||||
)
|
||||
return pr_files, pr_commits
|
||||
return pr_files, []
|
||||
|
||||
@@ -65,6 +65,17 @@ class MergeVerdict(str, Enum):
|
||||
BLOCKED = "blocked" # Critical issues, cannot merge
|
||||
|
||||
|
||||
# Constants for branch-behind messaging (DRY - used across multiple reviewers)
|
||||
BRANCH_BEHIND_BLOCKER_MSG = (
|
||||
"Branch Out of Date: PR branch is behind the base branch and needs to be updated"
|
||||
)
|
||||
BRANCH_BEHIND_REASONING = (
|
||||
"Branch is out of date with base branch. Update branch first - "
|
||||
"if no conflicts arise, you can merge. If merge conflicts arise, "
|
||||
"resolve them and run follow-up review again."
|
||||
)
|
||||
|
||||
|
||||
class AICommentVerdict(str, Enum):
|
||||
"""Verdict on AI tool comments (CodeRabbit, Cursor, Greptile, etc.)."""
|
||||
|
||||
@@ -570,6 +581,10 @@ class FollowupReviewContext:
|
||||
"" # BEHIND, BLOCKED, CLEAN, DIRTY, HAS_HOOKS, UNKNOWN, UNSTABLE
|
||||
)
|
||||
|
||||
# CI status - passed to AI orchestrator so it can factor into verdict
|
||||
# Dict with: passing, failing, pending, failed_checks, awaiting_approval
|
||||
ci_status: dict = field(default_factory=dict)
|
||||
|
||||
# Error flag - if set, context gathering failed and data may be incomplete
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ try:
|
||||
from .context_gatherer import PRContext, PRContextGatherer
|
||||
from .gh_client import GHClient
|
||||
from .models import (
|
||||
BRANCH_BEHIND_BLOCKER_MSG,
|
||||
BRANCH_BEHIND_REASONING,
|
||||
AICommentTriage,
|
||||
AICommentVerdict,
|
||||
AutoFixState,
|
||||
@@ -50,6 +52,8 @@ except (ImportError, ValueError, SystemError):
|
||||
from context_gatherer import PRContext, PRContextGatherer
|
||||
from gh_client import GHClient
|
||||
from models import (
|
||||
BRANCH_BEHIND_BLOCKER_MSG,
|
||||
BRANCH_BEHIND_REASONING,
|
||||
AICommentTriage,
|
||||
AICommentVerdict,
|
||||
AutoFixState,
|
||||
@@ -389,17 +393,38 @@ class GitHubOrchestrator:
|
||||
pr_number=pr_number,
|
||||
)
|
||||
|
||||
# Check CI status
|
||||
ci_status = await self.gh_client.get_pr_checks(pr_number)
|
||||
# Check CI status (comprehensive - includes workflows awaiting approval)
|
||||
ci_status = await self.gh_client.get_pr_checks_comprehensive(pr_number)
|
||||
|
||||
# Log CI status with awaiting approval info
|
||||
awaiting = ci_status.get("awaiting_approval", 0)
|
||||
pending_without_awaiting = ci_status.get("pending", 0) - awaiting
|
||||
ci_log_parts = [
|
||||
f"{ci_status.get('passing', 0)} passing",
|
||||
f"{ci_status.get('failing', 0)} failing",
|
||||
]
|
||||
if pending_without_awaiting > 0:
|
||||
ci_log_parts.append(f"{pending_without_awaiting} pending")
|
||||
if awaiting > 0:
|
||||
ci_log_parts.append(f"{awaiting} awaiting approval")
|
||||
print(
|
||||
f"[DEBUG orchestrator] CI status: {ci_status.get('passing', 0)} passing, "
|
||||
f"{ci_status.get('failing', 0)} failing, {ci_status.get('pending', 0)} pending",
|
||||
f"[orchestrator] CI status: {', '.join(ci_log_parts)}",
|
||||
flush=True,
|
||||
)
|
||||
if awaiting > 0:
|
||||
print(
|
||||
f"[orchestrator] ⚠️ {awaiting} workflow(s) from fork need maintainer approval to run",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Generate verdict (now includes CI status)
|
||||
# Generate verdict (includes CI status and merge conflict check)
|
||||
verdict, verdict_reasoning, blockers = self._generate_verdict(
|
||||
findings, structural_issues, ai_triages, ci_status
|
||||
findings,
|
||||
structural_issues,
|
||||
ai_triages,
|
||||
ci_status,
|
||||
has_merge_conflicts=pr_context.has_merge_conflicts,
|
||||
merge_state_status=pr_context.merge_state_status,
|
||||
)
|
||||
print(
|
||||
f"[DEBUG orchestrator] Verdict: {verdict.value} - {verdict_reasoning}",
|
||||
@@ -430,6 +455,7 @@ class GitHubOrchestrator:
|
||||
structural_issues=structural_issues,
|
||||
ai_triages=ai_triages,
|
||||
risk_assessment=risk_assessment,
|
||||
ci_status=ci_status,
|
||||
)
|
||||
|
||||
# Get HEAD SHA for follow-up review tracking
|
||||
@@ -500,6 +526,9 @@ class GitHubOrchestrator:
|
||||
# Save result
|
||||
await result.save(self.github_dir)
|
||||
|
||||
# Note: PR review memory is now saved by the Electron app after the review completes
|
||||
# This ensures memory is saved to the embedded LadybugDB managed by the app
|
||||
|
||||
# Mark as reviewed (head_sha already fetched above)
|
||||
if head_sha:
|
||||
self.bot_detector.mark_reviewed(pr_number, head_sha)
|
||||
@@ -615,19 +644,29 @@ class GitHubOrchestrator:
|
||||
await result.save(self.github_dir)
|
||||
return result
|
||||
|
||||
# Check if there are new commits
|
||||
if not followup_context.commits_since_review:
|
||||
# Check if there are changes to review (commits OR files via blob comparison)
|
||||
# After a rebase/force-push, commits_since_review will be empty (commit
|
||||
# SHAs are rewritten), but files_changed_since_review will contain files
|
||||
# that actually changed content based on blob SHA comparison.
|
||||
has_commits = bool(followup_context.commits_since_review)
|
||||
has_file_changes = bool(followup_context.files_changed_since_review)
|
||||
|
||||
if not has_commits and not has_file_changes:
|
||||
base_sha = previous_review.reviewed_commit_sha[:8]
|
||||
print(
|
||||
f"[Followup] No new commits since last review at {previous_review.reviewed_commit_sha[:8]}",
|
||||
f"[Followup] No changes since last review at {base_sha}",
|
||||
flush=True,
|
||||
)
|
||||
# Return a result indicating no changes
|
||||
no_change_summary = (
|
||||
"No new commits since last review. Previous findings still apply."
|
||||
)
|
||||
result = PRReviewResult(
|
||||
pr_number=pr_number,
|
||||
repo=self.config.repo,
|
||||
success=True,
|
||||
findings=previous_review.findings,
|
||||
summary="No new commits since last review. Previous findings still apply.",
|
||||
summary=no_change_summary,
|
||||
overall_status=previous_review.overall_status,
|
||||
verdict=previous_review.verdict,
|
||||
verdict_reasoning="No changes since last review.",
|
||||
@@ -639,13 +678,26 @@ class GitHubOrchestrator:
|
||||
await result.save(self.github_dir)
|
||||
return result
|
||||
|
||||
# Build progress message based on what changed
|
||||
if has_commits:
|
||||
num_commits = len(followup_context.commits_since_review)
|
||||
change_desc = f"{num_commits} new commits"
|
||||
else:
|
||||
# Rebase detected - files changed but no trackable commits
|
||||
num_files = len(followup_context.files_changed_since_review)
|
||||
change_desc = f"{num_files} files (rebase detected)"
|
||||
|
||||
self._report_progress(
|
||||
"analyzing",
|
||||
30,
|
||||
f"Analyzing {len(followup_context.commits_since_review)} new commits...",
|
||||
f"Analyzing {change_desc}...",
|
||||
pr_number=pr_number,
|
||||
)
|
||||
|
||||
# Fetch CI status BEFORE calling reviewer so AI can factor it into verdict
|
||||
ci_status = await self.gh_client.get_pr_checks_comprehensive(pr_number)
|
||||
followup_context.ci_status = ci_status
|
||||
|
||||
# Use parallel orchestrator for follow-up if enabled
|
||||
if self.config.use_parallel_orchestrator:
|
||||
print(
|
||||
@@ -690,9 +742,9 @@ class GitHubOrchestrator:
|
||||
)
|
||||
result = await reviewer.review_followup(followup_context)
|
||||
|
||||
# Check CI status and override verdict if failing
|
||||
ci_status = await self.gh_client.get_pr_checks(pr_number)
|
||||
failed_checks = ci_status.get("failed_checks", [])
|
||||
# Fallback: ensure CI failures block merge even if AI didn't factor it in
|
||||
# (CI status was already passed to AI via followup_context.ci_status)
|
||||
failed_checks = followup_context.ci_status.get("failed_checks", [])
|
||||
if failed_checks:
|
||||
print(
|
||||
f"[Followup] CI checks failing: {failed_checks}",
|
||||
@@ -724,6 +776,9 @@ class GitHubOrchestrator:
|
||||
# Save result
|
||||
await result.save(self.github_dir)
|
||||
|
||||
# Note: PR review memory is now saved by the Electron app after the review completes
|
||||
# This ensures memory is saved to the embedded LadybugDB managed by the app
|
||||
|
||||
# Mark as reviewed with new commit SHA
|
||||
if result.reviewed_commit_sha:
|
||||
self.bot_detector.mark_reviewed(pr_number, result.reviewed_commit_sha)
|
||||
@@ -751,15 +806,33 @@ class GitHubOrchestrator:
|
||||
structural_issues: list[StructuralIssue],
|
||||
ai_triages: list[AICommentTriage],
|
||||
ci_status: dict | None = None,
|
||||
has_merge_conflicts: bool = False,
|
||||
merge_state_status: str = "",
|
||||
) -> tuple[MergeVerdict, str, list[str]]:
|
||||
"""
|
||||
Generate merge verdict based on all findings and CI status.
|
||||
Generate merge verdict based on all findings, CI status, and merge conflicts.
|
||||
|
||||
NEW: Strengthened to block on verification failures, redundancy issues,
|
||||
and failing CI checks.
|
||||
Blocks on:
|
||||
- Merge conflicts (must be resolved before merging)
|
||||
- Verification failures
|
||||
- Redundancy issues
|
||||
- Failing CI checks
|
||||
|
||||
Warns on (NEEDS_REVISION):
|
||||
- Branch behind base (out of date)
|
||||
"""
|
||||
blockers = []
|
||||
ci_status = ci_status or {}
|
||||
is_branch_behind = merge_state_status == "BEHIND"
|
||||
|
||||
# CRITICAL: Merge conflicts block merging - check first
|
||||
if has_merge_conflicts:
|
||||
blockers.append(
|
||||
"Merge Conflicts: PR has conflicts with base branch that must be resolved"
|
||||
)
|
||||
# Branch behind base is a warning, not a hard blocker
|
||||
elif is_branch_behind:
|
||||
blockers.append(BRANCH_BEHIND_BLOCKER_MSG)
|
||||
|
||||
# Count by severity
|
||||
critical = [f for f in findings if f.severity == ReviewSeverity.CRITICAL]
|
||||
@@ -801,6 +874,13 @@ class GitHubOrchestrator:
|
||||
for check_name in failed_checks:
|
||||
blockers.append(f"CI Failed: {check_name}")
|
||||
|
||||
# Workflows awaiting approval block merging (fork PRs)
|
||||
awaiting_approval = ci_status.get("awaiting_approval", 0)
|
||||
if awaiting_approval > 0:
|
||||
blockers.append(
|
||||
f"Workflows Pending: {awaiting_approval} workflow(s) awaiting maintainer approval"
|
||||
)
|
||||
|
||||
# NEW: Verification failures block merging
|
||||
for f in verification_failures:
|
||||
note = f" - {f.verification_note}" if f.verification_note else ""
|
||||
@@ -833,15 +913,29 @@ class GitHubOrchestrator:
|
||||
)
|
||||
blockers.append(f"{t.tool_name}: {summary}")
|
||||
|
||||
# Determine verdict with CI, verification and redundancy checks
|
||||
# Determine verdict with merge conflicts, CI, verification and redundancy checks
|
||||
if blockers:
|
||||
# Merge conflicts are the highest priority blocker
|
||||
if has_merge_conflicts:
|
||||
verdict = MergeVerdict.BLOCKED
|
||||
reasoning = (
|
||||
"Blocked: PR has merge conflicts with base branch. "
|
||||
"Resolve conflicts before merge."
|
||||
)
|
||||
# CI failures are always blockers
|
||||
if failed_checks:
|
||||
elif failed_checks:
|
||||
verdict = MergeVerdict.BLOCKED
|
||||
reasoning = (
|
||||
f"Blocked: {len(failed_checks)} CI check(s) failing. "
|
||||
"Fix CI before merge."
|
||||
)
|
||||
# Workflows awaiting approval block merging
|
||||
elif awaiting_approval > 0:
|
||||
verdict = MergeVerdict.BLOCKED
|
||||
reasoning = (
|
||||
f"Blocked: {awaiting_approval} workflow(s) awaiting approval. "
|
||||
"Approve workflows on GitHub to run CI checks."
|
||||
)
|
||||
# NEW: Prioritize verification failures
|
||||
elif verification_failures:
|
||||
verdict = MergeVerdict.BLOCKED
|
||||
@@ -863,6 +957,12 @@ class GitHubOrchestrator:
|
||||
elif len(critical) > 0:
|
||||
verdict = MergeVerdict.BLOCKED
|
||||
reasoning = f"Blocked by {len(critical)} critical issues"
|
||||
# Branch behind is a soft blocker - NEEDS_REVISION, not BLOCKED
|
||||
elif is_branch_behind:
|
||||
verdict = MergeVerdict.NEEDS_REVISION
|
||||
reasoning = BRANCH_BEHIND_REASONING
|
||||
if low:
|
||||
reasoning += f" {len(low)} non-blocking suggestion(s) to consider."
|
||||
else:
|
||||
verdict = MergeVerdict.NEEDS_REVISION
|
||||
reasoning = f"{len(blockers)} issues must be addressed"
|
||||
@@ -946,6 +1046,7 @@ class GitHubOrchestrator:
|
||||
structural_issues: list[StructuralIssue],
|
||||
ai_triages: list[AICommentTriage],
|
||||
risk_assessment: dict,
|
||||
ci_status: dict | None = None,
|
||||
) -> str:
|
||||
"""Generate enhanced summary with verdict, risk, and actionable next steps."""
|
||||
verdict_emoji = {
|
||||
@@ -955,8 +1056,19 @@ class GitHubOrchestrator:
|
||||
MergeVerdict.BLOCKED: "🔴",
|
||||
}
|
||||
|
||||
# Generate bottom line for quick scanning
|
||||
bottom_line = self._generate_bottom_line(
|
||||
verdict=verdict,
|
||||
ci_status=ci_status,
|
||||
blockers=blockers,
|
||||
findings=findings,
|
||||
)
|
||||
|
||||
lines = [
|
||||
f"### Merge Verdict: {verdict_emoji.get(verdict, '⚪')} {verdict.value.upper().replace('_', ' ')}",
|
||||
"",
|
||||
f"> {bottom_line}",
|
||||
"",
|
||||
verdict_reasoning,
|
||||
"",
|
||||
"### Risk Assessment",
|
||||
@@ -1023,6 +1135,70 @@ class GitHubOrchestrator:
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _generate_bottom_line(
|
||||
self,
|
||||
verdict: MergeVerdict,
|
||||
ci_status: dict | None,
|
||||
blockers: list[str],
|
||||
findings: list[PRReviewFinding],
|
||||
) -> str:
|
||||
"""Generate a one-line summary for quick scanning at the top of the review."""
|
||||
# Check CI status
|
||||
ci = ci_status or {}
|
||||
pending_ci = ci.get("pending", 0)
|
||||
failing_ci = ci.get("failing", 0)
|
||||
awaiting_approval = ci.get("awaiting_approval", 0)
|
||||
|
||||
# Count blocking findings and issues
|
||||
blocking_findings = [
|
||||
f for f in findings if f.severity.value in ("critical", "high", "medium")
|
||||
]
|
||||
code_blockers = [
|
||||
b for b in blockers if "CI" not in b and "Merge Conflict" not in b
|
||||
]
|
||||
has_merge_conflicts = any("Merge Conflict" in b for b in blockers)
|
||||
|
||||
# Determine the bottom line based on verdict and context
|
||||
if verdict == MergeVerdict.READY_TO_MERGE:
|
||||
return (
|
||||
"**✅ Ready to merge** - All checks passing, no blocking issues found."
|
||||
)
|
||||
|
||||
elif verdict == MergeVerdict.BLOCKED:
|
||||
if has_merge_conflicts:
|
||||
return "**🔴 Blocked** - Merge conflicts must be resolved before merge."
|
||||
elif failing_ci > 0:
|
||||
return f"**🔴 Blocked** - {failing_ci} CI check(s) failing. Fix CI before merge."
|
||||
elif awaiting_approval > 0:
|
||||
return "**🔴 Blocked** - Awaiting maintainer approval for fork PR workflow."
|
||||
elif blocking_findings:
|
||||
return f"**🔴 Blocked** - {len(blocking_findings)} critical/high/medium issue(s) must be fixed."
|
||||
else:
|
||||
return "**🔴 Blocked** - Critical issues must be resolved before merge."
|
||||
|
||||
elif verdict == MergeVerdict.NEEDS_REVISION:
|
||||
# Key insight: distinguish "waiting on CI" from "needs code fixes"
|
||||
# Check code issues FIRST before checking pending CI
|
||||
if blocking_findings:
|
||||
return f"**🟠 Needs revision** - {len(blocking_findings)} issue(s) require attention."
|
||||
elif code_blockers:
|
||||
return f"**🟠 Needs revision** - {len(code_blockers)} structural/other issue(s) require attention."
|
||||
elif pending_ci > 0:
|
||||
# Only show "Ready once CI passes" when no code issues exist
|
||||
return f"**⏳ Ready once CI passes** - {pending_ci} check(s) pending, no blocking code issues."
|
||||
else:
|
||||
return "**🟠 Needs revision** - See details below."
|
||||
|
||||
elif verdict == MergeVerdict.MERGE_WITH_CHANGES:
|
||||
if pending_ci > 0:
|
||||
return (
|
||||
"**🟡 Can merge once CI passes** - Minor suggestions, no blockers."
|
||||
)
|
||||
else:
|
||||
return "**🟡 Can merge** - Minor suggestions noted, no blockers."
|
||||
|
||||
return "**📝 Review complete** - See details below."
|
||||
|
||||
def _format_review_body(self, result: PRReviewResult) -> str:
|
||||
"""Format the review body for posting to GitHub."""
|
||||
return result.summary
|
||||
|
||||
@@ -56,8 +56,10 @@ if sys.platform == "win32":
|
||||
# Add backend to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
# Load .env file
|
||||
from dotenv import load_dotenv
|
||||
# Load .env file with centralized error handling
|
||||
from cli.utils import import_dotenv
|
||||
|
||||
load_dotenv = import_dotenv()
|
||||
|
||||
env_file = Path(__file__).parent.parent.parent / ".env"
|
||||
if env_file.exists():
|
||||
|
||||
@@ -32,8 +32,11 @@ from claude_agent_sdk import AgentDefinition
|
||||
try:
|
||||
from ...core.client import create_client
|
||||
from ...phase_config import get_thinking_budget
|
||||
from ..context_gatherer import _validate_git_ref
|
||||
from ..gh_client import GHClient
|
||||
from ..models import (
|
||||
BRANCH_BEHIND_BLOCKER_MSG,
|
||||
BRANCH_BEHIND_REASONING,
|
||||
GitHubRunnerConfig,
|
||||
MergeVerdict,
|
||||
PRReviewFinding,
|
||||
@@ -41,12 +44,16 @@ try:
|
||||
ReviewSeverity,
|
||||
)
|
||||
from .category_utils import map_category
|
||||
from .pr_worktree_manager import PRWorktreeManager
|
||||
from .pydantic_models import ParallelFollowupResponse
|
||||
from .sdk_utils import process_sdk_stream
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from context_gatherer import _validate_git_ref
|
||||
from core.client import create_client
|
||||
from gh_client import GHClient
|
||||
from models import (
|
||||
BRANCH_BEHIND_BLOCKER_MSG,
|
||||
BRANCH_BEHIND_REASONING,
|
||||
GitHubRunnerConfig,
|
||||
MergeVerdict,
|
||||
PRReviewFinding,
|
||||
@@ -55,6 +62,7 @@ except (ImportError, ValueError, SystemError):
|
||||
)
|
||||
from phase_config import get_thinking_budget
|
||||
from services.category_utils import map_category
|
||||
from services.pr_worktree_manager import PRWorktreeManager
|
||||
from services.pydantic_models import ParallelFollowupResponse
|
||||
from services.sdk_utils import process_sdk_stream
|
||||
|
||||
@@ -64,6 +72,9 @@ logger = logging.getLogger(__name__)
|
||||
# Check if debug mode is enabled
|
||||
DEBUG_MODE = os.environ.get("DEBUG", "").lower() in ("true", "1", "yes")
|
||||
|
||||
# Directory for PR review worktrees (shared with initial reviewer)
|
||||
PR_WORKTREE_DIR = ".auto-claude/github/pr/worktrees"
|
||||
|
||||
# Severity mapping for AI responses
|
||||
_SEVERITY_MAPPING = {
|
||||
"critical": ReviewSeverity.CRITICAL,
|
||||
@@ -108,6 +119,7 @@ class ParallelFollowupReviewer:
|
||||
self.github_dir = Path(github_dir)
|
||||
self.config = config
|
||||
self.progress_callback = progress_callback
|
||||
self.worktree_manager = PRWorktreeManager(project_dir, PR_WORKTREE_DIR)
|
||||
|
||||
def _report_progress(self, phase: str, progress: int, message: str, **kwargs):
|
||||
"""Report progress if callback is set."""
|
||||
@@ -138,6 +150,37 @@ class ParallelFollowupReviewer:
|
||||
logger.warning(f"Prompt file not found: {prompt_file}")
|
||||
return ""
|
||||
|
||||
def _create_pr_worktree(self, head_sha: str, pr_number: int) -> Path:
|
||||
"""Create a temporary worktree at the PR head commit.
|
||||
|
||||
Args:
|
||||
head_sha: The commit SHA of the PR head (validated before use)
|
||||
pr_number: The PR number for naming
|
||||
|
||||
Returns:
|
||||
Path to the created worktree
|
||||
|
||||
Raises:
|
||||
RuntimeError: If worktree creation fails
|
||||
ValueError: If head_sha fails validation (command injection prevention)
|
||||
"""
|
||||
# SECURITY: Validate git ref before use in subprocess calls
|
||||
if not _validate_git_ref(head_sha):
|
||||
raise ValueError(
|
||||
f"Invalid git ref: '{head_sha}'. "
|
||||
"Must contain only alphanumeric characters, dots, slashes, underscores, and hyphens."
|
||||
)
|
||||
|
||||
return self.worktree_manager.create_worktree(head_sha, pr_number)
|
||||
|
||||
def _cleanup_pr_worktree(self, worktree_path: Path) -> None:
|
||||
"""Remove a temporary PR review worktree with fallback chain.
|
||||
|
||||
Args:
|
||||
worktree_path: Path to the worktree to remove
|
||||
"""
|
||||
self.worktree_manager.remove_worktree(worktree_path)
|
||||
|
||||
def _define_specialist_agents(self) -> dict[str, AgentDefinition]:
|
||||
"""
|
||||
Define specialist agents for follow-up review.
|
||||
@@ -267,6 +310,44 @@ class ParallelFollowupReviewer:
|
||||
|
||||
return "\n\n---\n\n".join(ai_content)
|
||||
|
||||
def _format_ci_status(self, context: FollowupReviewContext) -> str:
|
||||
"""Format CI status for the prompt."""
|
||||
ci_status = context.ci_status
|
||||
if not ci_status:
|
||||
return "CI status not available."
|
||||
|
||||
passing = ci_status.get("passing", 0)
|
||||
failing = ci_status.get("failing", 0)
|
||||
pending = ci_status.get("pending", 0)
|
||||
failed_checks = ci_status.get("failed_checks", [])
|
||||
awaiting_approval = ci_status.get("awaiting_approval", 0)
|
||||
|
||||
lines = []
|
||||
|
||||
# Overall status
|
||||
if failing > 0:
|
||||
lines.append(f"⚠️ **{failing} CI check(s) FAILING** - PR cannot be merged")
|
||||
elif pending > 0:
|
||||
lines.append(f"⏳ **{pending} CI check(s) pending** - Wait for completion")
|
||||
elif passing > 0:
|
||||
lines.append(f"✅ **All {passing} CI check(s) passing**")
|
||||
else:
|
||||
lines.append("No CI checks configured")
|
||||
|
||||
# List failed checks
|
||||
if failed_checks:
|
||||
lines.append("\n**Failed checks:**")
|
||||
for check in failed_checks:
|
||||
lines.append(f" - ❌ {check}")
|
||||
|
||||
# Awaiting approval (fork PRs)
|
||||
if awaiting_approval > 0:
|
||||
lines.append(
|
||||
f"\n⏸️ **{awaiting_approval} workflow(s) awaiting maintainer approval** (fork PR)"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _build_orchestrator_prompt(self, context: FollowupReviewContext) -> str:
|
||||
"""Build full prompt for orchestrator with follow-up context."""
|
||||
# Load orchestrator prompt
|
||||
@@ -279,6 +360,7 @@ class ParallelFollowupReviewer:
|
||||
commits = self._format_commits(context)
|
||||
contributor_comments = self._format_comments(context)
|
||||
ai_reviews = self._format_ai_reviews(context)
|
||||
ci_status = self._format_ci_status(context)
|
||||
|
||||
# Truncate diff if too long
|
||||
MAX_DIFF_CHARS = 100_000
|
||||
@@ -297,6 +379,9 @@ class ParallelFollowupReviewer:
|
||||
**New Commits:** {len(context.commits_since_review)}
|
||||
**Files Changed:** {len(context.files_changed_since_review)}
|
||||
|
||||
### CI Status (CRITICAL - Must Factor Into Verdict)
|
||||
{ci_status}
|
||||
|
||||
### Previous Review Summary
|
||||
{context.previous_review.summary[:500] if context.previous_review.summary else "No summary available."}
|
||||
|
||||
@@ -325,6 +410,7 @@ class ParallelFollowupReviewer:
|
||||
Now analyze this follow-up and delegate to the appropriate specialist agents.
|
||||
Remember: YOU decide which agents to invoke based on YOUR analysis.
|
||||
The SDK will run invoked agents in parallel automatically.
|
||||
**CRITICAL: Your verdict MUST account for CI status. Failing CI = BLOCKED verdict.**
|
||||
"""
|
||||
|
||||
return base_prompt + followup_context
|
||||
@@ -343,6 +429,9 @@ The SDK will run invoked agents in parallel automatically.
|
||||
f"[ParallelFollowup] Starting follow-up review for PR #{context.pr_number}"
|
||||
)
|
||||
|
||||
# Track worktree for cleanup
|
||||
worktree_path: Path | None = None
|
||||
|
||||
try:
|
||||
self._report_progress(
|
||||
"orchestrating",
|
||||
@@ -354,13 +443,48 @@ The SDK will run invoked agents in parallel automatically.
|
||||
# Build orchestrator prompt
|
||||
prompt = self._build_orchestrator_prompt(context)
|
||||
|
||||
# Get project root
|
||||
# Get project root - default to local checkout
|
||||
project_root = (
|
||||
self.project_dir.parent.parent
|
||||
if self.project_dir.name == "backend"
|
||||
else self.project_dir
|
||||
)
|
||||
|
||||
# Create temporary worktree at PR head commit for isolated review
|
||||
# This ensures agents read from the correct PR state, not the current checkout
|
||||
head_sha = context.current_commit_sha
|
||||
if head_sha and _validate_git_ref(head_sha):
|
||||
try:
|
||||
if DEBUG_MODE:
|
||||
print(
|
||||
f"[Followup] DEBUG: Creating worktree for head_sha={head_sha}",
|
||||
flush=True,
|
||||
)
|
||||
worktree_path = self._create_pr_worktree(
|
||||
head_sha, context.pr_number
|
||||
)
|
||||
project_root = worktree_path
|
||||
print(
|
||||
f"[Followup] Using worktree at {worktree_path.name} for PR review",
|
||||
flush=True,
|
||||
)
|
||||
except Exception as e:
|
||||
if DEBUG_MODE:
|
||||
print(
|
||||
f"[Followup] DEBUG: Worktree creation FAILED: {e}",
|
||||
flush=True,
|
||||
)
|
||||
logger.warning(
|
||||
f"[ParallelFollowup] Worktree creation failed, "
|
||||
f"falling back to local checkout: {e}"
|
||||
)
|
||||
# Fallback to original behavior if worktree creation fails
|
||||
else:
|
||||
logger.warning(
|
||||
f"[ParallelFollowup] Invalid or missing head_sha '{head_sha}', "
|
||||
"using local checkout"
|
||||
)
|
||||
|
||||
# Use model and thinking level from config (user settings)
|
||||
model = self.config.model or "claude-sonnet-4-5-20250929"
|
||||
thinking_level = self.config.thinking_level or "medium"
|
||||
@@ -461,15 +585,60 @@ The SDK will run invoked agents in parallel automatically.
|
||||
f"{len(resolved_ids)} resolved, {len(unresolved_ids)} unresolved"
|
||||
)
|
||||
|
||||
# Generate blockers from critical/high/medium severity findings
|
||||
# (Medium also blocks merge in our strict quality gates approach)
|
||||
blockers = []
|
||||
|
||||
# CRITICAL: Merge conflicts block merging - check FIRST before summary generation
|
||||
# This must happen before _generate_summary so the summary reflects merge conflict status
|
||||
if context.has_merge_conflicts:
|
||||
blockers.append(
|
||||
"Merge Conflicts: PR has conflicts with base branch that must be resolved"
|
||||
)
|
||||
# Override verdict to BLOCKED if merge conflicts exist
|
||||
verdict = MergeVerdict.BLOCKED
|
||||
verdict_reasoning = (
|
||||
"Blocked: PR has merge conflicts with base branch. "
|
||||
"Resolve conflicts before merge."
|
||||
)
|
||||
print(
|
||||
"[ParallelFollowup] ⚠️ PR has merge conflicts - blocking merge",
|
||||
flush=True,
|
||||
)
|
||||
# Check if branch is behind base (out of date) - warning, not hard blocker
|
||||
elif context.merge_state_status == "BEHIND":
|
||||
blockers.append(BRANCH_BEHIND_BLOCKER_MSG)
|
||||
# Use NEEDS_REVISION since potential conflicts are unknown until branch is updated
|
||||
# Must handle both READY_TO_MERGE and MERGE_WITH_CHANGES verdicts
|
||||
if verdict in (
|
||||
MergeVerdict.READY_TO_MERGE,
|
||||
MergeVerdict.MERGE_WITH_CHANGES,
|
||||
):
|
||||
verdict = MergeVerdict.NEEDS_REVISION
|
||||
verdict_reasoning = BRANCH_BEHIND_REASONING
|
||||
print(
|
||||
"[ParallelFollowup] ⚠️ PR branch is behind base - needs update",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
for finding in unique_findings:
|
||||
if finding.severity in (
|
||||
ReviewSeverity.CRITICAL,
|
||||
ReviewSeverity.HIGH,
|
||||
ReviewSeverity.MEDIUM,
|
||||
):
|
||||
blockers.append(f"{finding.category.value}: {finding.title}")
|
||||
|
||||
# Extract validation counts
|
||||
dismissed_count = len(result_data.get("dismissed_false_positive_ids", []))
|
||||
confirmed_count = result_data.get("confirmed_valid_count", 0)
|
||||
needs_human_count = result_data.get("needs_human_review_count", 0)
|
||||
|
||||
# Generate summary
|
||||
# Generate summary (AFTER merge conflict check so it reflects correct verdict)
|
||||
summary = self._generate_summary(
|
||||
verdict=verdict,
|
||||
verdict_reasoning=verdict_reasoning,
|
||||
blockers=blockers,
|
||||
resolved_count=len(resolved_ids),
|
||||
unresolved_count=len(unresolved_ids),
|
||||
new_count=len(new_finding_ids),
|
||||
@@ -477,6 +646,7 @@ The SDK will run invoked agents in parallel automatically.
|
||||
dismissed_false_positive_count=dismissed_count,
|
||||
confirmed_valid_count=confirmed_count,
|
||||
needs_human_review_count=needs_human_count,
|
||||
ci_status=context.ci_status,
|
||||
)
|
||||
|
||||
# Map verdict to overall_status
|
||||
@@ -489,17 +659,6 @@ The SDK will run invoked agents in parallel automatically.
|
||||
else:
|
||||
overall_status = "approve"
|
||||
|
||||
# Generate blockers from critical/high/medium severity findings
|
||||
# (Medium also blocks merge in our strict quality gates approach)
|
||||
blockers = []
|
||||
for finding in unique_findings:
|
||||
if finding.severity in (
|
||||
ReviewSeverity.CRITICAL,
|
||||
ReviewSeverity.HIGH,
|
||||
ReviewSeverity.MEDIUM,
|
||||
):
|
||||
blockers.append(f"{finding.category.value}: {finding.title}")
|
||||
|
||||
# Get file blob SHAs for rebase-resistant follow-up reviews
|
||||
# Blob SHAs persist across rebases - same content = same blob SHA
|
||||
file_blobs: dict[str, str] = {}
|
||||
@@ -567,6 +726,10 @@ The SDK will run invoked agents in parallel automatically.
|
||||
is_followup_review=True,
|
||||
reviewed_commit_sha=context.current_commit_sha,
|
||||
)
|
||||
finally:
|
||||
# Always cleanup worktree, even on error
|
||||
if worktree_path:
|
||||
self._cleanup_pr_worktree(worktree_path)
|
||||
|
||||
def _parse_structured_output(
|
||||
self, data: dict, context: FollowupReviewContext
|
||||
@@ -826,6 +989,7 @@ The SDK will run invoked agents in parallel automatically.
|
||||
self,
|
||||
verdict: MergeVerdict,
|
||||
verdict_reasoning: str,
|
||||
blockers: list[str],
|
||||
resolved_count: int,
|
||||
unresolved_count: int,
|
||||
new_count: int,
|
||||
@@ -833,13 +997,15 @@ The SDK will run invoked agents in parallel automatically.
|
||||
dismissed_false_positive_count: int = 0,
|
||||
confirmed_valid_count: int = 0,
|
||||
needs_human_review_count: int = 0,
|
||||
ci_status: dict | None = None,
|
||||
) -> str:
|
||||
"""Generate a human-readable summary of the follow-up review."""
|
||||
# Use same emojis as orchestrator.py for consistency
|
||||
status_emoji = {
|
||||
MergeVerdict.READY_TO_MERGE: "✅",
|
||||
MergeVerdict.MERGE_WITH_CHANGES: "⚠️",
|
||||
MergeVerdict.NEEDS_REVISION: "🔄",
|
||||
MergeVerdict.BLOCKED: "🚫",
|
||||
MergeVerdict.MERGE_WITH_CHANGES: "🟡",
|
||||
MergeVerdict.NEEDS_REVISION: "🟠",
|
||||
MergeVerdict.BLOCKED: "🔴",
|
||||
}
|
||||
|
||||
emoji = status_emoji.get(verdict, "📝")
|
||||
@@ -847,6 +1013,15 @@ The SDK will run invoked agents in parallel automatically.
|
||||
", ".join(agents_invoked) if agents_invoked else "orchestrator only"
|
||||
)
|
||||
|
||||
# Generate a prominent bottom-line summary for quick scanning
|
||||
bottom_line = self._generate_bottom_line(
|
||||
verdict=verdict,
|
||||
ci_status=ci_status,
|
||||
unresolved_count=unresolved_count,
|
||||
new_count=new_count,
|
||||
blockers=blockers,
|
||||
)
|
||||
|
||||
# Build validation section if there are validation results
|
||||
validation_section = ""
|
||||
if (
|
||||
@@ -859,15 +1034,26 @@ The SDK will run invoked agents in parallel automatically.
|
||||
- 🔍 **Dismissed as False Positives**: {dismissed_false_positive_count} findings were re-investigated and found to be incorrect
|
||||
- ✓ **Confirmed Valid**: {confirmed_valid_count} findings verified as genuine issues
|
||||
- 👤 **Needs Human Review**: {needs_human_review_count} findings require manual verification
|
||||
"""
|
||||
|
||||
# Build blockers section if there are any blockers
|
||||
blockers_section = ""
|
||||
if blockers:
|
||||
blockers_list = "\n".join(f"- {b}" for b in blockers)
|
||||
blockers_section = f"""
|
||||
### 🚨 Blocking Issues
|
||||
{blockers_list}
|
||||
"""
|
||||
|
||||
summary = f"""## {emoji} Follow-up Review: {verdict.value.replace("_", " ").title()}
|
||||
|
||||
> {bottom_line}
|
||||
|
||||
### Resolution Status
|
||||
- ✅ **Resolved**: {resolved_count} previous findings addressed
|
||||
- ❌ **Unresolved**: {unresolved_count} previous findings remain
|
||||
- 🆕 **New Issues**: {new_count} new findings in recent changes
|
||||
{validation_section}
|
||||
{validation_section}{blockers_section}
|
||||
### Verdict
|
||||
{verdict_reasoning}
|
||||
|
||||
@@ -878,3 +1064,65 @@ Agents invoked: {agents_str}
|
||||
*This is an AI-generated follow-up review using parallel specialist analysis with finding validation.*
|
||||
"""
|
||||
return summary
|
||||
|
||||
def _generate_bottom_line(
|
||||
self,
|
||||
verdict: MergeVerdict,
|
||||
ci_status: dict | None,
|
||||
unresolved_count: int,
|
||||
new_count: int,
|
||||
blockers: list[str],
|
||||
) -> str:
|
||||
"""Generate a one-line summary for quick scanning at the top of the review."""
|
||||
# Check CI status
|
||||
ci = ci_status or {}
|
||||
pending_ci = ci.get("pending", 0)
|
||||
failing_ci = ci.get("failing", 0)
|
||||
awaiting_approval = ci.get("awaiting_approval", 0)
|
||||
|
||||
# Count blocking issues (excluding CI-related ones)
|
||||
code_blockers = [
|
||||
b for b in blockers if "CI" not in b and "Merge Conflict" not in b
|
||||
]
|
||||
has_merge_conflicts = any("Merge Conflict" in b for b in blockers)
|
||||
|
||||
# Determine the bottom line based on verdict and context
|
||||
if verdict == MergeVerdict.READY_TO_MERGE:
|
||||
return "**✅ Ready to merge** - All checks passing and findings addressed."
|
||||
|
||||
elif verdict == MergeVerdict.BLOCKED:
|
||||
if has_merge_conflicts:
|
||||
return "**🔴 Blocked** - Merge conflicts must be resolved before merge."
|
||||
elif failing_ci > 0:
|
||||
return f"**🔴 Blocked** - {failing_ci} CI check(s) failing. Fix CI before merge."
|
||||
elif awaiting_approval > 0:
|
||||
return "**🔴 Blocked** - Awaiting maintainer approval for fork PR workflow."
|
||||
elif code_blockers:
|
||||
return f"**🔴 Blocked** - {len(code_blockers)} blocking issue(s) require fixes."
|
||||
else:
|
||||
return "**🔴 Blocked** - Critical issues must be resolved before merge."
|
||||
|
||||
elif verdict == MergeVerdict.NEEDS_REVISION:
|
||||
# Key insight: distinguish "waiting on CI" from "needs code fixes"
|
||||
# Check code issues FIRST before checking pending CI
|
||||
if unresolved_count > 0:
|
||||
return f"**🟠 Needs revision** - {unresolved_count} unresolved finding(s) from previous review."
|
||||
elif code_blockers:
|
||||
return f"**🟠 Needs revision** - {len(code_blockers)} blocking issue(s) require fixes."
|
||||
elif new_count > 0:
|
||||
return f"**🟠 Needs revision** - {new_count} new issue(s) found in recent changes."
|
||||
elif pending_ci > 0:
|
||||
# Only show "Ready once CI passes" when no code issues exist
|
||||
return f"**⏳ Ready once CI passes** - {pending_ci} check(s) pending, all findings addressed."
|
||||
else:
|
||||
return "**🟠 Needs revision** - See details below."
|
||||
|
||||
elif verdict == MergeVerdict.MERGE_WITH_CHANGES:
|
||||
if pending_ci > 0:
|
||||
return (
|
||||
"**🟡 Can merge once CI passes** - Minor suggestions, no blockers."
|
||||
)
|
||||
else:
|
||||
return "**🟡 Can merge** - Minor suggestions noted, no blockers."
|
||||
|
||||
return "**📝 Review complete** - See details below."
|
||||
|
||||
@@ -20,9 +20,6 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -34,6 +31,8 @@ try:
|
||||
from ..context_gatherer import PRContext, _validate_git_ref
|
||||
from ..gh_client import GHClient
|
||||
from ..models import (
|
||||
BRANCH_BEHIND_BLOCKER_MSG,
|
||||
BRANCH_BEHIND_REASONING,
|
||||
GitHubRunnerConfig,
|
||||
MergeVerdict,
|
||||
PRReviewFinding,
|
||||
@@ -41,6 +40,7 @@ try:
|
||||
ReviewSeverity,
|
||||
)
|
||||
from .category_utils import map_category
|
||||
from .pr_worktree_manager import PRWorktreeManager
|
||||
from .pydantic_models import ParallelOrchestratorResponse
|
||||
from .sdk_utils import process_sdk_stream
|
||||
except (ImportError, ValueError, SystemError):
|
||||
@@ -48,6 +48,8 @@ except (ImportError, ValueError, SystemError):
|
||||
from core.client import create_client
|
||||
from gh_client import GHClient
|
||||
from models import (
|
||||
BRANCH_BEHIND_BLOCKER_MSG,
|
||||
BRANCH_BEHIND_REASONING,
|
||||
GitHubRunnerConfig,
|
||||
MergeVerdict,
|
||||
PRReviewFinding,
|
||||
@@ -56,6 +58,7 @@ except (ImportError, ValueError, SystemError):
|
||||
)
|
||||
from phase_config import get_thinking_budget
|
||||
from services.category_utils import map_category
|
||||
from services.pr_worktree_manager import PRWorktreeManager
|
||||
from services.pydantic_models import ParallelOrchestratorResponse
|
||||
from services.sdk_utils import process_sdk_stream
|
||||
|
||||
@@ -94,6 +97,7 @@ class ParallelOrchestratorReviewer:
|
||||
self.github_dir = Path(github_dir)
|
||||
self.config = config
|
||||
self.progress_callback = progress_callback
|
||||
self.worktree_manager = PRWorktreeManager(project_dir, PR_WORKTREE_DIR)
|
||||
|
||||
def _report_progress(self, phase: str, progress: int, message: str, **kwargs):
|
||||
"""Report progress if callback is set."""
|
||||
@@ -145,78 +149,7 @@ class ParallelOrchestratorReviewer:
|
||||
"Must contain only alphanumeric characters, dots, slashes, underscores, and hyphens."
|
||||
)
|
||||
|
||||
worktree_name = f"pr-{pr_number}-{uuid.uuid4().hex[:8]}"
|
||||
worktree_dir = self.project_dir / PR_WORKTREE_DIR
|
||||
|
||||
if DEBUG_MODE:
|
||||
print(f"[PRReview] DEBUG: project_dir={self.project_dir}", flush=True)
|
||||
print(f"[PRReview] DEBUG: worktree_dir={worktree_dir}", flush=True)
|
||||
print(f"[PRReview] DEBUG: head_sha={head_sha}", flush=True)
|
||||
|
||||
worktree_dir.mkdir(parents=True, exist_ok=True)
|
||||
worktree_path = worktree_dir / worktree_name
|
||||
|
||||
if DEBUG_MODE:
|
||||
print(f"[PRReview] DEBUG: worktree_path={worktree_path}", flush=True)
|
||||
print(
|
||||
f"[PRReview] DEBUG: worktree_dir exists={worktree_dir.exists()}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Fetch the commit if not available locally (handles fork PRs)
|
||||
fetch_result = subprocess.run(
|
||||
["git", "fetch", "origin", head_sha],
|
||||
cwd=self.project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if DEBUG_MODE:
|
||||
print(
|
||||
f"[PRReview] DEBUG: fetch returncode={fetch_result.returncode}",
|
||||
flush=True,
|
||||
)
|
||||
if fetch_result.stderr:
|
||||
print(
|
||||
f"[PRReview] DEBUG: fetch stderr={fetch_result.stderr[:200]}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Create detached worktree at the PR commit
|
||||
result = subprocess.run(
|
||||
["git", "worktree", "add", "--detach", str(worktree_path), head_sha],
|
||||
cwd=self.project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120, # Worktree add can be slow for large repos
|
||||
)
|
||||
|
||||
if DEBUG_MODE:
|
||||
print(
|
||||
f"[PRReview] DEBUG: worktree add returncode={result.returncode}",
|
||||
flush=True,
|
||||
)
|
||||
if result.stderr:
|
||||
print(
|
||||
f"[PRReview] DEBUG: worktree add stderr={result.stderr[:200]}",
|
||||
flush=True,
|
||||
)
|
||||
if result.stdout:
|
||||
print(
|
||||
f"[PRReview] DEBUG: worktree add stdout={result.stdout[:200]}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Failed to create worktree: {result.stderr}")
|
||||
|
||||
if DEBUG_MODE:
|
||||
print(
|
||||
f"[PRReview] DEBUG: worktree created, exists={worktree_path.exists()}",
|
||||
flush=True,
|
||||
)
|
||||
logger.info(f"[PRReview] Created worktree at {worktree_path}")
|
||||
return worktree_path
|
||||
return self.worktree_manager.create_worktree(head_sha, pr_number)
|
||||
|
||||
def _cleanup_pr_worktree(self, worktree_path: Path) -> None:
|
||||
"""Remove a temporary PR review worktree with fallback chain.
|
||||
@@ -224,100 +157,16 @@ class ParallelOrchestratorReviewer:
|
||||
Args:
|
||||
worktree_path: Path to the worktree to remove
|
||||
"""
|
||||
if DEBUG_MODE:
|
||||
print(
|
||||
f"[PRReview] DEBUG: _cleanup_pr_worktree called with {worktree_path}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if not worktree_path or not worktree_path.exists():
|
||||
if DEBUG_MODE:
|
||||
print(
|
||||
"[PRReview] DEBUG: worktree path doesn't exist, skipping cleanup",
|
||||
flush=True,
|
||||
)
|
||||
return
|
||||
|
||||
if DEBUG_MODE:
|
||||
print(
|
||||
f"[PRReview] DEBUG: Attempting to remove worktree at {worktree_path}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Try 1: git worktree remove
|
||||
result = subprocess.run(
|
||||
["git", "worktree", "remove", "--force", str(worktree_path)],
|
||||
cwd=self.project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if DEBUG_MODE:
|
||||
print(
|
||||
f"[PRReview] DEBUG: worktree remove returncode={result.returncode}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.info(f"[PRReview] Cleaned up worktree: {worktree_path.name}")
|
||||
return
|
||||
|
||||
# Try 2: shutil.rmtree fallback
|
||||
try:
|
||||
shutil.rmtree(worktree_path, ignore_errors=True)
|
||||
subprocess.run(
|
||||
["git", "worktree", "prune"],
|
||||
cwd=self.project_dir,
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
logger.warning(f"[PRReview] Used shutil fallback for: {worktree_path.name}")
|
||||
except Exception as e:
|
||||
logger.error(f"[PRReview] Failed to cleanup worktree {worktree_path}: {e}")
|
||||
self.worktree_manager.remove_worktree(worktree_path)
|
||||
|
||||
def _cleanup_stale_pr_worktrees(self) -> None:
|
||||
"""Clean up orphaned PR review worktrees on startup."""
|
||||
worktree_dir = self.project_dir / PR_WORKTREE_DIR
|
||||
if not worktree_dir.exists():
|
||||
return
|
||||
|
||||
# Get registered worktrees from git
|
||||
result = subprocess.run(
|
||||
["git", "worktree", "list", "--porcelain"],
|
||||
cwd=self.project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
registered = set()
|
||||
for line in result.stdout.split("\n"):
|
||||
if line.startswith("worktree "):
|
||||
# Safely parse - check bounds to prevent IndexError
|
||||
parts = line.split(" ", 1)
|
||||
if len(parts) > 1 and parts[1]:
|
||||
registered.add(Path(parts[1]))
|
||||
|
||||
# Remove unregistered directories
|
||||
stale_count = 0
|
||||
for item in worktree_dir.iterdir():
|
||||
if item.is_dir() and item not in registered:
|
||||
logger.info(f"[PRReview] Removing stale worktree: {item.name}")
|
||||
shutil.rmtree(item, ignore_errors=True)
|
||||
stale_count += 1
|
||||
|
||||
if stale_count > 0:
|
||||
subprocess.run(
|
||||
["git", "worktree", "prune"],
|
||||
cwd=self.project_dir,
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
"""Clean up orphaned, expired, and excess PR review worktrees on startup."""
|
||||
stats = self.worktree_manager.cleanup_worktrees()
|
||||
if stats["total"] > 0:
|
||||
logger.info(
|
||||
f"[PRReview] Cleanup: removed {stats['total']} worktrees "
|
||||
f"(orphaned={stats['orphaned']}, expired={stats['expired']}, excess={stats['excess']})"
|
||||
)
|
||||
if DEBUG_MODE:
|
||||
print(
|
||||
f"[PRReview] DEBUG: Cleaned up {stale_count} stale worktree(s)",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
def _define_specialist_agents(self) -> dict[str, AgentDefinition]:
|
||||
"""
|
||||
@@ -771,9 +620,11 @@ The SDK will run invoked agents in parallel automatically.
|
||||
f"[ParallelOrchestrator] Review complete: {len(unique_findings)} findings"
|
||||
)
|
||||
|
||||
# Generate verdict
|
||||
# Generate verdict (includes merge conflict check and branch-behind check)
|
||||
verdict, verdict_reasoning, blockers = self._generate_verdict(
|
||||
unique_findings
|
||||
unique_findings,
|
||||
has_merge_conflicts=context.has_merge_conflicts,
|
||||
merge_state_status=context.merge_state_status,
|
||||
)
|
||||
|
||||
# Generate summary
|
||||
@@ -1017,10 +868,23 @@ The SDK will run invoked agents in parallel automatically.
|
||||
return unique
|
||||
|
||||
def _generate_verdict(
|
||||
self, findings: list[PRReviewFinding]
|
||||
self,
|
||||
findings: list[PRReviewFinding],
|
||||
has_merge_conflicts: bool = False,
|
||||
merge_state_status: str = "",
|
||||
) -> tuple[MergeVerdict, str, list[str]]:
|
||||
"""Generate merge verdict based on findings."""
|
||||
"""Generate merge verdict based on findings, merge conflict status, and branch state."""
|
||||
blockers = []
|
||||
is_branch_behind = merge_state_status == "BEHIND"
|
||||
|
||||
# CRITICAL: Merge conflicts block merging - check first
|
||||
if has_merge_conflicts:
|
||||
blockers.append(
|
||||
"Merge Conflicts: PR has conflicts with base branch that must be resolved"
|
||||
)
|
||||
# Branch behind base is a warning, not a hard blocker
|
||||
elif is_branch_behind:
|
||||
blockers.append(BRANCH_BEHIND_BLOCKER_MSG)
|
||||
|
||||
critical = [f for f in findings if f.severity == ReviewSeverity.CRITICAL]
|
||||
high = [f for f in findings if f.severity == ReviewSeverity.HIGH]
|
||||
@@ -1031,8 +895,25 @@ The SDK will run invoked agents in parallel automatically.
|
||||
blockers.append(f"Critical: {f.title} ({f.file}:{f.line})")
|
||||
|
||||
if blockers:
|
||||
verdict = MergeVerdict.BLOCKED
|
||||
reasoning = f"Blocked by {len(blockers)} critical issue(s)"
|
||||
# Merge conflicts are the highest priority blocker
|
||||
if has_merge_conflicts:
|
||||
verdict = MergeVerdict.BLOCKED
|
||||
reasoning = (
|
||||
"Blocked: PR has merge conflicts with base branch. "
|
||||
"Resolve conflicts before merge."
|
||||
)
|
||||
elif critical:
|
||||
verdict = MergeVerdict.BLOCKED
|
||||
reasoning = f"Blocked by {len(critical)} critical issue(s)"
|
||||
# Branch behind is a soft blocker - NEEDS_REVISION, not BLOCKED
|
||||
elif is_branch_behind:
|
||||
verdict = MergeVerdict.NEEDS_REVISION
|
||||
reasoning = BRANCH_BEHIND_REASONING
|
||||
if low:
|
||||
reasoning += f" {len(low)} non-blocking suggestion(s) to consider."
|
||||
else:
|
||||
verdict = MergeVerdict.BLOCKED
|
||||
reasoning = f"Blocked by {len(blockers)} issue(s)"
|
||||
elif high or medium:
|
||||
# High and Medium severity findings block merge
|
||||
verdict = MergeVerdict.NEEDS_REVISION
|
||||
|
||||
@@ -242,7 +242,9 @@ class PRReviewEngine:
|
||||
msg_type = type(msg).__name__
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
if hasattr(block, "text"):
|
||||
# Must check block type - only TextBlock has .text attribute
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
result_text += block.text
|
||||
|
||||
if review_pass == ReviewPass.QUICK_SCAN:
|
||||
@@ -502,7 +504,9 @@ class PRReviewEngine:
|
||||
msg_type = type(msg).__name__
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
if hasattr(block, "text"):
|
||||
# Must check block type - only TextBlock has .text attribute
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
result_text += block.text
|
||||
except Exception as e:
|
||||
print(f"[AI] Structural pass error: {e}", flush=True)
|
||||
@@ -558,7 +562,9 @@ class PRReviewEngine:
|
||||
msg_type = type(msg).__name__
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
if hasattr(block, "text"):
|
||||
# Must check block type - only TextBlock has .text attribute
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
result_text += block.text
|
||||
except Exception as e:
|
||||
print(f"[AI] AI triage pass error: {e}", flush=True)
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
"""
|
||||
PR Worktree Manager
|
||||
===================
|
||||
|
||||
Manages lifecycle of PR review worktrees with cleanup policies.
|
||||
|
||||
Features:
|
||||
- Age-based cleanup (remove worktrees older than N days)
|
||||
- Count-based cleanup (keep only N most recent worktrees)
|
||||
- Orphaned worktree cleanup (worktrees not registered with git)
|
||||
- Automatic cleanup on review completion
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default cleanup policies (can be overridden via environment variables)
|
||||
DEFAULT_MAX_PR_WORKTREES = 10 # Max worktrees to keep
|
||||
DEFAULT_PR_WORKTREE_MAX_AGE_DAYS = 7 # Max age in days
|
||||
|
||||
|
||||
def _get_max_pr_worktrees() -> int:
|
||||
"""Get max worktrees setting, read at runtime for testability."""
|
||||
try:
|
||||
value = int(os.environ.get("MAX_PR_WORKTREES", str(DEFAULT_MAX_PR_WORKTREES)))
|
||||
return value if value > 0 else DEFAULT_MAX_PR_WORKTREES
|
||||
except (ValueError, TypeError):
|
||||
return DEFAULT_MAX_PR_WORKTREES
|
||||
|
||||
|
||||
def _get_max_age_days() -> int:
|
||||
"""Get max age setting, read at runtime for testability."""
|
||||
try:
|
||||
value = int(
|
||||
os.environ.get(
|
||||
"PR_WORKTREE_MAX_AGE_DAYS", str(DEFAULT_PR_WORKTREE_MAX_AGE_DAYS)
|
||||
)
|
||||
)
|
||||
return value if value >= 0 else DEFAULT_PR_WORKTREE_MAX_AGE_DAYS
|
||||
except (ValueError, TypeError):
|
||||
return DEFAULT_PR_WORKTREE_MAX_AGE_DAYS
|
||||
|
||||
|
||||
# Safe pattern for git refs (SHA, branch names)
|
||||
# Allows: alphanumeric, dots, underscores, hyphens, forward slashes
|
||||
import re
|
||||
|
||||
SAFE_REF_PATTERN = re.compile(r"^[a-zA-Z0-9._/\-]+$")
|
||||
|
||||
|
||||
class WorktreeInfo(NamedTuple):
|
||||
"""Information about a PR worktree."""
|
||||
|
||||
path: Path
|
||||
age_days: float
|
||||
pr_number: int | None = None
|
||||
|
||||
|
||||
class PRWorktreeManager:
|
||||
"""
|
||||
Manages PR review worktrees with automatic cleanup policies.
|
||||
|
||||
Cleanup policies:
|
||||
1. Remove worktrees older than PR_WORKTREE_MAX_AGE_DAYS (default: 7 days)
|
||||
2. Keep only MAX_PR_WORKTREES most recent worktrees (default: 10)
|
||||
3. Remove orphaned worktrees (not registered with git)
|
||||
"""
|
||||
|
||||
def __init__(self, project_dir: Path, worktree_dir: str | Path):
|
||||
"""
|
||||
Initialize the worktree manager.
|
||||
|
||||
Args:
|
||||
project_dir: Root directory of the git project
|
||||
worktree_dir: Directory where PR worktrees are stored (relative to project_dir)
|
||||
"""
|
||||
self.project_dir = Path(project_dir)
|
||||
self.worktree_base_dir = self.project_dir / worktree_dir
|
||||
|
||||
def create_worktree(
|
||||
self, head_sha: str, pr_number: int, auto_cleanup: bool = True
|
||||
) -> Path:
|
||||
"""
|
||||
Create a PR worktree with automatic cleanup of old worktrees.
|
||||
|
||||
Args:
|
||||
head_sha: Git commit SHA to checkout
|
||||
pr_number: PR number for naming
|
||||
auto_cleanup: If True (default), run cleanup before creating
|
||||
|
||||
Returns:
|
||||
Path to the created worktree
|
||||
|
||||
Raises:
|
||||
RuntimeError: If worktree creation fails
|
||||
ValueError: If head_sha or pr_number are invalid
|
||||
"""
|
||||
# Validate inputs to prevent command injection
|
||||
if not head_sha or not SAFE_REF_PATTERN.match(head_sha):
|
||||
raise ValueError(
|
||||
f"Invalid head_sha: must match pattern {SAFE_REF_PATTERN.pattern}"
|
||||
)
|
||||
if not isinstance(pr_number, int) or pr_number <= 0:
|
||||
raise ValueError(
|
||||
f"Invalid pr_number: must be a positive integer, got {pr_number}"
|
||||
)
|
||||
|
||||
# Run cleanup before creating new worktree (can be disabled for tests)
|
||||
if auto_cleanup:
|
||||
self.cleanup_worktrees()
|
||||
|
||||
# Generate worktree name with timestamp for uniqueness
|
||||
sha_short = head_sha[:8]
|
||||
timestamp = int(time.time() * 1000) # Millisecond precision
|
||||
worktree_name = f"pr-{pr_number}-{sha_short}-{timestamp}"
|
||||
|
||||
# Create worktree directory
|
||||
self.worktree_base_dir.mkdir(parents=True, exist_ok=True)
|
||||
worktree_path = self.worktree_base_dir / worktree_name
|
||||
|
||||
logger.debug(f"Creating worktree: {worktree_path}")
|
||||
|
||||
try:
|
||||
# Fetch the commit if not available locally (handles fork PRs)
|
||||
fetch_result = subprocess.run(
|
||||
["git", "fetch", "origin", head_sha],
|
||||
cwd=self.project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
if fetch_result.returncode != 0:
|
||||
logger.warning(
|
||||
f"Could not fetch {head_sha} from origin (fork PR?): {fetch_result.stderr}"
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(
|
||||
f"Timeout fetching {head_sha} from origin, continuing anyway"
|
||||
)
|
||||
|
||||
try:
|
||||
# Create detached worktree at the PR commit
|
||||
result = subprocess.run(
|
||||
["git", "worktree", "add", "--detach", str(worktree_path), head_sha],
|
||||
cwd=self.project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
# Check for fatal errors in stderr (git outputs info to stderr too)
|
||||
stderr = result.stderr.strip()
|
||||
# Clean up partial worktree on failure
|
||||
if worktree_path.exists():
|
||||
shutil.rmtree(worktree_path, ignore_errors=True)
|
||||
raise RuntimeError(f"Failed to create worktree: {stderr}")
|
||||
|
||||
# Verify the worktree was actually created
|
||||
if not worktree_path.exists():
|
||||
raise RuntimeError(
|
||||
f"Worktree creation reported success but path does not exist: {worktree_path}"
|
||||
)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
# Clean up partial worktree on timeout
|
||||
if worktree_path.exists():
|
||||
shutil.rmtree(worktree_path, ignore_errors=True)
|
||||
raise RuntimeError(f"Timeout creating worktree for {head_sha}")
|
||||
|
||||
logger.info(f"[WorktreeManager] Created worktree at {worktree_path}")
|
||||
return worktree_path
|
||||
|
||||
def remove_worktree(self, worktree_path: Path) -> None:
|
||||
"""
|
||||
Remove a PR worktree with fallback chain.
|
||||
|
||||
Args:
|
||||
worktree_path: Path to the worktree to remove
|
||||
"""
|
||||
if not worktree_path or not worktree_path.exists():
|
||||
return
|
||||
|
||||
logger.debug(f"Removing worktree: {worktree_path}")
|
||||
|
||||
# Try 1: git worktree remove
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "worktree", "remove", "--force", str(worktree_path)],
|
||||
cwd=self.project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
logger.info(f"[WorktreeManager] Removed worktree: {worktree_path.name}")
|
||||
return
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning(
|
||||
f"Timeout removing worktree {worktree_path.name}, falling back to shutil"
|
||||
)
|
||||
|
||||
# Try 2: shutil.rmtree fallback
|
||||
try:
|
||||
shutil.rmtree(worktree_path, ignore_errors=True)
|
||||
subprocess.run(
|
||||
["git", "worktree", "prune"],
|
||||
cwd=self.project_dir,
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
logger.warning(
|
||||
f"[WorktreeManager] Used shutil fallback for: {worktree_path.name}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[WorktreeManager] Failed to remove worktree {worktree_path}: {e}"
|
||||
)
|
||||
|
||||
def get_worktree_info(self) -> list[WorktreeInfo]:
|
||||
"""
|
||||
Get information about all PR worktrees.
|
||||
|
||||
Returns:
|
||||
List of WorktreeInfo objects sorted by age (oldest first)
|
||||
"""
|
||||
if not self.worktree_base_dir.exists():
|
||||
return []
|
||||
|
||||
worktrees = []
|
||||
current_time = time.time()
|
||||
|
||||
for item in self.worktree_base_dir.iterdir():
|
||||
if not item.is_dir():
|
||||
continue
|
||||
|
||||
# Get modification time
|
||||
mtime = item.stat().st_mtime
|
||||
age_seconds = current_time - mtime
|
||||
age_days = age_seconds / 86400 # Convert seconds to days
|
||||
|
||||
# Extract PR number from directory name (format: pr-XXX-sha)
|
||||
pr_number = None
|
||||
if item.name.startswith("pr-"):
|
||||
parts = item.name.split("-")
|
||||
if len(parts) >= 2:
|
||||
try:
|
||||
pr_number = int(parts[1])
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
worktrees.append(
|
||||
WorktreeInfo(path=item, age_days=age_days, pr_number=pr_number)
|
||||
)
|
||||
|
||||
# Sort by age (oldest first)
|
||||
worktrees.sort(key=lambda x: x.age_days, reverse=True)
|
||||
|
||||
return worktrees
|
||||
|
||||
def get_registered_worktrees(self) -> set[Path]:
|
||||
"""
|
||||
Get set of worktrees registered with git.
|
||||
|
||||
Returns:
|
||||
Set of resolved Path objects for registered worktrees
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "worktree", "list", "--porcelain"],
|
||||
cwd=self.project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Timeout listing worktrees, returning empty set")
|
||||
return set()
|
||||
|
||||
registered = set()
|
||||
for line in result.stdout.split("\n"):
|
||||
if line.startswith("worktree "):
|
||||
parts = line.split(" ", 1)
|
||||
if len(parts) > 1 and parts[1]:
|
||||
registered.add(Path(parts[1]))
|
||||
|
||||
return registered
|
||||
|
||||
def cleanup_worktrees(self, force: bool = False) -> dict[str, int]:
|
||||
"""
|
||||
Clean up PR worktrees based on age and count policies.
|
||||
|
||||
Cleanup order:
|
||||
1. Remove orphaned worktrees (not registered with git)
|
||||
2. Remove worktrees older than PR_WORKTREE_MAX_AGE_DAYS
|
||||
3. If still over MAX_PR_WORKTREES, remove oldest worktrees
|
||||
|
||||
Args:
|
||||
force: If True, skip age check and only enforce count limit
|
||||
|
||||
Returns:
|
||||
Dict with cleanup statistics: {
|
||||
'orphaned': count,
|
||||
'expired': count,
|
||||
'excess': count,
|
||||
'total': count
|
||||
}
|
||||
"""
|
||||
stats = {"orphaned": 0, "expired": 0, "excess": 0, "total": 0}
|
||||
|
||||
if not self.worktree_base_dir.exists():
|
||||
return stats
|
||||
|
||||
# Get registered worktrees (resolved paths for consistent comparison)
|
||||
registered = self.get_registered_worktrees()
|
||||
registered_resolved = {p.resolve() for p in registered}
|
||||
|
||||
# Get all PR worktree info
|
||||
worktrees = self.get_worktree_info()
|
||||
|
||||
# Phase 1: Remove orphaned worktrees
|
||||
for wt in worktrees:
|
||||
if wt.path.resolve() not in registered_resolved:
|
||||
logger.info(
|
||||
f"[WorktreeManager] Removing orphaned worktree: {wt.path.name} (age: {wt.age_days:.1f} days)"
|
||||
)
|
||||
shutil.rmtree(wt.path, ignore_errors=True)
|
||||
stats["orphaned"] += 1
|
||||
|
||||
# Refresh worktree list after orphan cleanup
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "worktree", "prune"],
|
||||
cwd=self.project_dir,
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Timeout pruning worktrees, continuing anyway")
|
||||
|
||||
# Refresh registered worktrees after prune (git's internal registry may have changed)
|
||||
registered_resolved = {p.resolve() for p in self.get_registered_worktrees()}
|
||||
|
||||
# Get fresh worktree info for remaining worktrees (use resolved paths)
|
||||
worktrees = [
|
||||
wt
|
||||
for wt in self.get_worktree_info()
|
||||
if wt.path.resolve() in registered_resolved
|
||||
]
|
||||
|
||||
# Phase 2: Remove expired worktrees (older than max age)
|
||||
max_age_days = _get_max_age_days()
|
||||
if not force:
|
||||
for wt in worktrees:
|
||||
if wt.age_days > max_age_days:
|
||||
logger.info(
|
||||
f"[WorktreeManager] Removing expired worktree: {wt.path.name} (age: {wt.age_days:.1f} days, max: {max_age_days} days)"
|
||||
)
|
||||
self.remove_worktree(wt.path)
|
||||
stats["expired"] += 1
|
||||
|
||||
# Refresh worktree list after expiration cleanup (use resolved paths)
|
||||
registered_resolved = {p.resolve() for p in self.get_registered_worktrees()}
|
||||
worktrees = [
|
||||
wt
|
||||
for wt in self.get_worktree_info()
|
||||
if wt.path.resolve() in registered_resolved
|
||||
]
|
||||
|
||||
# Phase 3: Remove excess worktrees (keep only max_pr_worktrees most recent)
|
||||
max_pr_worktrees = _get_max_pr_worktrees()
|
||||
if len(worktrees) > max_pr_worktrees:
|
||||
# worktrees are already sorted by age (oldest first)
|
||||
excess_count = len(worktrees) - max_pr_worktrees
|
||||
for wt in worktrees[:excess_count]:
|
||||
logger.info(
|
||||
f"[WorktreeManager] Removing excess worktree: {wt.path.name} (count: {len(worktrees)}, max: {max_pr_worktrees})"
|
||||
)
|
||||
self.remove_worktree(wt.path)
|
||||
stats["excess"] += 1
|
||||
|
||||
stats["total"] = stats["orphaned"] + stats["expired"] + stats["excess"]
|
||||
|
||||
if stats["total"] > 0:
|
||||
logger.info(
|
||||
f"[WorktreeManager] Cleanup complete: {stats['total']} worktrees removed "
|
||||
f"(orphaned={stats['orphaned']}, expired={stats['expired']}, excess={stats['excess']})"
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
f"No cleanup needed (current: {len(worktrees)}, max: {max_pr_worktrees})"
|
||||
)
|
||||
|
||||
return stats
|
||||
|
||||
def cleanup_all_worktrees(self) -> int:
|
||||
"""
|
||||
Remove ALL PR worktrees (for testing or emergency cleanup).
|
||||
|
||||
Returns:
|
||||
Number of worktrees removed
|
||||
"""
|
||||
if not self.worktree_base_dir.exists():
|
||||
return 0
|
||||
|
||||
worktrees = self.get_worktree_info()
|
||||
count = 0
|
||||
|
||||
for wt in worktrees:
|
||||
logger.info(f"[WorktreeManager] Removing worktree: {wt.path.name}")
|
||||
self.remove_worktree(wt.path)
|
||||
count += 1
|
||||
|
||||
if count > 0:
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "worktree", "prune"],
|
||||
cwd=self.project_dir,
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Timeout pruning worktrees after cleanup")
|
||||
logger.info(f"[WorktreeManager] Removed all {count} PR worktrees")
|
||||
|
||||
return count
|
||||
@@ -140,7 +140,9 @@ async def spawn_security_review(
|
||||
msg_type = type(msg).__name__
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
if hasattr(block, "text"):
|
||||
# Must check block type - only TextBlock has .text attribute
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
result_text += block.text
|
||||
|
||||
# Parse findings
|
||||
@@ -223,7 +225,9 @@ async def spawn_quality_review(
|
||||
msg_type = type(msg).__name__
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
if hasattr(block, "text"):
|
||||
# Must check block type - only TextBlock has .text attribute
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
result_text += block.text
|
||||
|
||||
findings = _parse_findings_from_response(result_text, source="quality_agent")
|
||||
@@ -316,7 +320,9 @@ Output findings in JSON format:
|
||||
msg_type = type(msg).__name__
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
if hasattr(block, "text"):
|
||||
# Must check block type - only TextBlock has .text attribute
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
result_text += block.text
|
||||
|
||||
findings = _parse_findings_from_response(result_text, source="deep_analysis")
|
||||
|
||||
@@ -235,8 +235,9 @@ async def process_sdk_stream(
|
||||
if on_tool_use:
|
||||
on_tool_use(tool_name, tool_id, tool_input)
|
||||
|
||||
# Collect text
|
||||
if hasattr(block, "text"):
|
||||
# Collect text - must check block type since only TextBlock has .text
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
result_text += block.text
|
||||
# Always print text content preview (not just in DEBUG_MODE)
|
||||
text_preview = block.text[:500].replace("\n", " ").strip()
|
||||
|
||||
@@ -87,7 +87,9 @@ class TriageEngine:
|
||||
msg_type = type(msg).__name__
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
if hasattr(block, "text"):
|
||||
# Must check block type - only TextBlock has .text attribute
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
|
||||
return self.parser.parse_triage_result(
|
||||
|
||||
@@ -26,8 +26,10 @@ from pathlib import Path
|
||||
# Add backend to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
# Load .env file
|
||||
from dotenv import load_dotenv
|
||||
# Load .env file with centralized error handling
|
||||
from cli.utils import import_dotenv
|
||||
|
||||
load_dotenv = import_dotenv()
|
||||
|
||||
env_file = Path(__file__).parent.parent.parent / ".env"
|
||||
if env_file.exists():
|
||||
|
||||
@@ -234,7 +234,9 @@ Provide your review in the following JSON format:
|
||||
msg_type = type(msg).__name__
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
if hasattr(block, "text"):
|
||||
# Must check block type - only TextBlock has .text attribute
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
result_text += block.text
|
||||
|
||||
self._report_progress(
|
||||
|
||||
@@ -26,8 +26,10 @@ from pathlib import Path
|
||||
# Add auto-claude to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
# Load .env file from auto-claude/ directory
|
||||
from dotenv import load_dotenv
|
||||
# Load .env file with centralized error handling
|
||||
from cli.utils import import_dotenv
|
||||
|
||||
load_dotenv = import_dotenv()
|
||||
|
||||
env_file = Path(__file__).parent.parent / ".env"
|
||||
if env_file.exists():
|
||||
|
||||
@@ -15,8 +15,10 @@ from pathlib import Path
|
||||
# Add auto-claude to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
# Load .env file from auto-claude/ directory
|
||||
from dotenv import load_dotenv
|
||||
# Load .env file with centralized error handling
|
||||
from cli.utils import import_dotenv
|
||||
|
||||
load_dotenv = import_dotenv()
|
||||
|
||||
env_file = Path(__file__).parent.parent / ".env"
|
||||
if env_file.exists():
|
||||
|
||||
@@ -20,8 +20,10 @@ from pathlib import Path
|
||||
# Add auto-claude to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
# Load .env file from auto-claude/ directory
|
||||
from dotenv import load_dotenv
|
||||
# Load .env file with centralized error handling
|
||||
from cli.utils import import_dotenv
|
||||
|
||||
load_dotenv = import_dotenv()
|
||||
|
||||
env_file = Path(__file__).parent.parent / ".env"
|
||||
if env_file.exists():
|
||||
|
||||
@@ -26,11 +26,11 @@ The AI considers:
|
||||
- Risk factors and edge cases
|
||||
|
||||
Usage:
|
||||
python auto-claude/spec_runner.py --task "Add user authentication"
|
||||
python auto-claude/spec_runner.py --interactive
|
||||
python auto-claude/spec_runner.py --continue 001-feature
|
||||
python auto-claude/spec_runner.py --task "Fix button color" --complexity simple
|
||||
python auto-claude/spec_runner.py --task "Simple fix" --no-ai-assessment
|
||||
python runners/spec_runner.py --task "Add user authentication"
|
||||
python runners/spec_runner.py --interactive
|
||||
python runners/spec_runner.py --continue 001-feature
|
||||
python runners/spec_runner.py --task "Fix button color" --complexity simple
|
||||
python runners/spec_runner.py --task "Simple fix" --no-ai-assessment
|
||||
"""
|
||||
|
||||
import sys
|
||||
@@ -81,8 +81,10 @@ if sys.platform == "win32":
|
||||
# Add auto-claude to path (parent of runners/)
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
# Load .env file
|
||||
from dotenv import load_dotenv
|
||||
# Load .env file with centralized error handling
|
||||
from cli.utils import import_dotenv
|
||||
|
||||
load_dotenv = import_dotenv()
|
||||
|
||||
env_file = Path(__file__).parent.parent / ".env"
|
||||
dev_env_file = Path(__file__).parent.parent.parent / "dev" / "auto-claude" / ".env"
|
||||
@@ -198,9 +200,21 @@ Examples:
|
||||
default=None,
|
||||
help="Base branch for creating worktrees (default: auto-detect or current branch)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--direct",
|
||||
action="store_true",
|
||||
help="Build directly in project without worktree isolation (default: use isolated worktree)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Warn user about direct mode risks
|
||||
if args.direct:
|
||||
print_status(
|
||||
"Direct mode: Building in project directory without worktree isolation",
|
||||
"warning",
|
||||
)
|
||||
|
||||
# Handle task from file if provided
|
||||
task_description = args.task
|
||||
if args.task_file:
|
||||
@@ -328,6 +342,10 @@ Examples:
|
||||
if args.base_branch:
|
||||
run_cmd.extend(["--base-branch", args.base_branch])
|
||||
|
||||
# Pass --direct flag if specified (skip worktree isolation)
|
||||
if args.direct:
|
||||
run_cmd.append("--direct")
|
||||
|
||||
# Note: Model configuration for subsequent phases (planning, coding, qa)
|
||||
# is read from task_metadata.json by run.py, so we don't pass it here.
|
||||
# This allows per-phase configuration when using Auto profile.
|
||||
|
||||
@@ -62,7 +62,9 @@ from .validator import (
|
||||
validate_chmod_command,
|
||||
validate_dropdb_command,
|
||||
validate_dropuser_command,
|
||||
validate_git_command,
|
||||
validate_git_commit,
|
||||
validate_git_config,
|
||||
validate_init_script,
|
||||
validate_kill_command,
|
||||
validate_killall_command,
|
||||
@@ -93,7 +95,9 @@ __all__ = [
|
||||
"validate_chmod_command",
|
||||
"validate_rm_command",
|
||||
"validate_init_script",
|
||||
"validate_git_command",
|
||||
"validate_git_commit",
|
||||
"validate_git_config",
|
||||
"validate_dropdb_command",
|
||||
"validate_dropuser_command",
|
||||
"validate_psql_command",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
Security Constants
|
||||
==================
|
||||
|
||||
Shared constants for the security module.
|
||||
"""
|
||||
|
||||
# Environment variable name for the project directory
|
||||
# Set by agents (coder.py, loop.py) at startup to ensure security hooks
|
||||
# can find the correct project directory even in worktree mode.
|
||||
PROJECT_DIR_ENV_VAR = "AUTO_CLAUDE_PROJECT_DIR"
|
||||
|
||||
# Security configuration filenames
|
||||
# These are the files that control which commands are allowed to run.
|
||||
ALLOWLIST_FILENAME = ".auto-claude-allowlist"
|
||||
PROFILE_FILENAME = ".auto-claude-security.json"
|
||||
@@ -2,7 +2,9 @@
|
||||
Git Validators
|
||||
==============
|
||||
|
||||
Validators for git operations (commit with secret scanning).
|
||||
Validators for git operations:
|
||||
- Commit with secret scanning
|
||||
- Config protection (prevent setting test users)
|
||||
"""
|
||||
|
||||
import shlex
|
||||
@@ -10,8 +12,203 @@ from pathlib import Path
|
||||
|
||||
from .validation_models import ValidationResult
|
||||
|
||||
# =============================================================================
|
||||
# BLOCKED GIT CONFIG PATTERNS
|
||||
# =============================================================================
|
||||
|
||||
def validate_git_commit(command_string: str) -> ValidationResult:
|
||||
# Git config keys that agents must NOT modify
|
||||
# These are identity settings that should inherit from the user's global config
|
||||
#
|
||||
# NOTE: This validation covers command-line arguments (git config, git -c).
|
||||
# Environment variables (GIT_AUTHOR_NAME, GIT_AUTHOR_EMAIL, GIT_COMMITTER_NAME,
|
||||
# GIT_COMMITTER_EMAIL) are NOT validated here as they require pre-execution
|
||||
# environment filtering, which is handled at the sandbox/hook level.
|
||||
BLOCKED_GIT_CONFIG_KEYS = {
|
||||
"user.name",
|
||||
"user.email",
|
||||
"author.name",
|
||||
"author.email",
|
||||
"committer.name",
|
||||
"committer.email",
|
||||
}
|
||||
|
||||
|
||||
def validate_git_config(command_string: str) -> ValidationResult:
|
||||
"""
|
||||
Validate git config commands - block identity changes.
|
||||
|
||||
Agents should not set user.name, user.email, etc. as this:
|
||||
1. Breaks commit attribution
|
||||
2. Can create fake "Test User" identities
|
||||
3. Overrides the user's legitimate git identity
|
||||
|
||||
Args:
|
||||
command_string: The full git command string
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
try:
|
||||
tokens = shlex.split(command_string)
|
||||
except ValueError:
|
||||
return False, "Could not parse git command" # Fail closed on parse errors
|
||||
|
||||
if len(tokens) < 2 or tokens[0] != "git" or tokens[1] != "config":
|
||||
return True, "" # Not a git config command
|
||||
|
||||
# Check for read-only operations first - these are always allowed
|
||||
# --get, --get-all, --get-regexp, --list are all read operations
|
||||
read_only_flags = {"--get", "--get-all", "--get-regexp", "--list", "-l"}
|
||||
for token in tokens[2:]:
|
||||
if token in read_only_flags:
|
||||
return True, "" # Read operation, allow it
|
||||
|
||||
# Extract the config key from the command
|
||||
# git config [options] <key> [value] - key is typically after config and any options
|
||||
config_key = None
|
||||
for token in tokens[2:]:
|
||||
# Skip options (start with -)
|
||||
if token.startswith("-"):
|
||||
continue
|
||||
# First non-option token is the config key
|
||||
config_key = token.lower()
|
||||
break
|
||||
|
||||
if not config_key:
|
||||
return True, "" # No config key specified (e.g., git config --list)
|
||||
|
||||
# Check if the exact config key is blocked
|
||||
for blocked_key in BLOCKED_GIT_CONFIG_KEYS:
|
||||
if config_key == blocked_key:
|
||||
return False, (
|
||||
f"BLOCKED: Cannot modify git identity configuration\n\n"
|
||||
f"You attempted to set '{blocked_key}' which is not allowed.\n\n"
|
||||
f"WHY: Git identity (user.name, user.email) must inherit from the user's "
|
||||
f"global git configuration. Setting fake identities like 'Test User' breaks "
|
||||
f"commit attribution and causes serious issues.\n\n"
|
||||
f"WHAT TO DO: Simply commit without setting any user configuration. "
|
||||
f"The repository will use the correct identity automatically."
|
||||
)
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_git_inline_config(tokens: list[str]) -> ValidationResult:
|
||||
"""
|
||||
Check for blocked config keys passed via git -c flag.
|
||||
|
||||
Git allows inline config with: git -c key=value <command>
|
||||
This bypasses 'git config' validation, so we must check all git commands
|
||||
for -c flags containing blocked identity keys.
|
||||
|
||||
Args:
|
||||
tokens: Parsed command tokens
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
i = 1 # Start after 'git'
|
||||
while i < len(tokens):
|
||||
token = tokens[i]
|
||||
|
||||
# Check for -c flag (can be "-c key=value" or "-c" "key=value")
|
||||
if token == "-c":
|
||||
# Next token should be the key=value
|
||||
if i + 1 < len(tokens):
|
||||
config_pair = tokens[i + 1]
|
||||
# Extract the key from key=value
|
||||
if "=" in config_pair:
|
||||
config_key = config_pair.split("=", 1)[0].lower()
|
||||
if config_key in BLOCKED_GIT_CONFIG_KEYS:
|
||||
return False, (
|
||||
f"BLOCKED: Cannot set git identity via -c flag\n\n"
|
||||
f"You attempted to use '-c {config_pair}' which sets a blocked "
|
||||
f"identity configuration.\n\n"
|
||||
f"WHY: Git identity (user.name, user.email) must inherit from the "
|
||||
f"user's global git configuration. Setting fake identities breaks "
|
||||
f"commit attribution and causes serious issues.\n\n"
|
||||
f"WHAT TO DO: Remove the -c flag and commit normally. "
|
||||
f"The repository will use the correct identity automatically."
|
||||
)
|
||||
i += 2 # Skip -c and its value
|
||||
continue
|
||||
elif token.startswith("-c"):
|
||||
# Handle -ckey=value format (no space)
|
||||
config_pair = token[2:] # Remove "-c" prefix
|
||||
if "=" in config_pair:
|
||||
config_key = config_pair.split("=", 1)[0].lower()
|
||||
if config_key in BLOCKED_GIT_CONFIG_KEYS:
|
||||
return False, (
|
||||
f"BLOCKED: Cannot set git identity via -c flag\n\n"
|
||||
f"You attempted to use '{token}' which sets a blocked "
|
||||
f"identity configuration.\n\n"
|
||||
f"WHY: Git identity (user.name, user.email) must inherit from the "
|
||||
f"user's global git configuration. Setting fake identities breaks "
|
||||
f"commit attribution and causes serious issues.\n\n"
|
||||
f"WHAT TO DO: Remove the -c flag and commit normally. "
|
||||
f"The repository will use the correct identity automatically."
|
||||
)
|
||||
|
||||
i += 1
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_git_command(command_string: str) -> ValidationResult:
|
||||
"""
|
||||
Main git validator that checks all git security rules.
|
||||
|
||||
Currently validates:
|
||||
- git -c: Block identity changes via inline config on ANY git command
|
||||
- git config: Block identity changes
|
||||
- git commit: Run secret scanning
|
||||
|
||||
Args:
|
||||
command_string: The full git command string
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
try:
|
||||
tokens = shlex.split(command_string)
|
||||
except ValueError:
|
||||
return False, "Could not parse git command"
|
||||
|
||||
if not tokens or tokens[0] != "git":
|
||||
return True, ""
|
||||
|
||||
if len(tokens) < 2:
|
||||
return True, "" # Just "git" with no subcommand
|
||||
|
||||
# Check for blocked -c flags on ANY git command (security bypass prevention)
|
||||
is_valid, error_msg = validate_git_inline_config(tokens)
|
||||
if not is_valid:
|
||||
return is_valid, error_msg
|
||||
|
||||
# Find the actual subcommand (skip global options like -c, -C, --git-dir, etc.)
|
||||
subcommand = None
|
||||
for token in tokens[1:]:
|
||||
# Skip options and their values
|
||||
if token.startswith("-"):
|
||||
continue
|
||||
subcommand = token
|
||||
break
|
||||
|
||||
if not subcommand:
|
||||
return True, "" # No subcommand found
|
||||
|
||||
# Check git config commands
|
||||
if subcommand == "config":
|
||||
return validate_git_config(command_string)
|
||||
|
||||
# Check git commit commands (secret scanning)
|
||||
if subcommand == "commit":
|
||||
return validate_git_commit_secrets(command_string)
|
||||
|
||||
return True, ""
|
||||
|
||||
|
||||
def validate_git_commit_secrets(command_string: str) -> ValidationResult:
|
||||
"""
|
||||
Validate git commit commands - run secret scan before allowing commit.
|
||||
|
||||
@@ -99,3 +296,8 @@ def validate_git_commit(command_string: str) -> ValidationResult:
|
||||
)
|
||||
|
||||
return False, "\n".join(error_lines)
|
||||
|
||||
|
||||
# Backwards compatibility alias - the registry uses this name
|
||||
# Now delegates to the comprehensive validator
|
||||
validate_git_commit = validate_git_command
|
||||
|
||||
@@ -65,8 +65,21 @@ async def bash_security_hook(
|
||||
if not command:
|
||||
return {}
|
||||
|
||||
# Get the working directory from input_data (SDK passes it there, not in context)
|
||||
cwd = input_data.get("cwd") or os.getcwd()
|
||||
# Get the working directory from context or use current directory
|
||||
# Priority:
|
||||
# 1. Environment variable PROJECT_DIR_ENV_VAR (set by agent on startup)
|
||||
# 2. input_data cwd (passed by SDK in the tool call)
|
||||
# 3. Context cwd (should be set by ClaudeSDKClient but sometimes isn't)
|
||||
# 4. Current working directory (fallback, may be incorrect in worktree mode)
|
||||
from .constants import PROJECT_DIR_ENV_VAR
|
||||
|
||||
cwd = os.environ.get(PROJECT_DIR_ENV_VAR)
|
||||
if not cwd:
|
||||
cwd = input_data.get("cwd")
|
||||
if not cwd and context and hasattr(context, "cwd"):
|
||||
cwd = context.cwd
|
||||
if not cwd:
|
||||
cwd = os.getcwd()
|
||||
|
||||
# Get or create security profile
|
||||
# Note: In actual use, spec_dir would be passed through context
|
||||
|
||||
@@ -4,11 +4,137 @@ Command Parsing Utilities
|
||||
|
||||
Functions for parsing and extracting commands from shell command strings.
|
||||
Handles compound commands, pipes, subshells, and various shell constructs.
|
||||
|
||||
Windows Compatibility Note:
|
||||
--------------------------
|
||||
On Windows, commands containing paths with backslashes can cause shlex.split()
|
||||
to fail (e.g., incomplete commands with unclosed quotes). This module includes
|
||||
a fallback parser that extracts command names even from malformed commands,
|
||||
ensuring security validation can still proceed.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
from pathlib import PurePosixPath, PureWindowsPath
|
||||
|
||||
|
||||
def _cross_platform_basename(path: str) -> str:
|
||||
"""
|
||||
Extract the basename from a path in a cross-platform way.
|
||||
|
||||
Handles both Windows paths (C:\\dir\\cmd.exe) and POSIX paths (/dir/cmd)
|
||||
regardless of the current platform. This is critical for running tests
|
||||
on Linux CI while handling Windows-style paths.
|
||||
|
||||
Args:
|
||||
path: A file path string (Windows or POSIX format)
|
||||
|
||||
Returns:
|
||||
The basename of the path (e.g., "python.exe" from "C:\\Python312\\python.exe")
|
||||
"""
|
||||
# Strip surrounding quotes if present
|
||||
path = path.strip("'\"")
|
||||
|
||||
# Check if this looks like a Windows path (contains backslash or drive letter)
|
||||
if "\\" in path or (len(path) >= 2 and path[1] == ":"):
|
||||
# Use PureWindowsPath to handle Windows paths on any platform
|
||||
return PureWindowsPath(path).name
|
||||
|
||||
# For POSIX paths or simple command names, use PurePosixPath
|
||||
# (os.path.basename works but PurePosixPath is more explicit)
|
||||
return PurePosixPath(path).name
|
||||
|
||||
|
||||
def _fallback_extract_commands(command_string: str) -> list[str]:
|
||||
"""
|
||||
Fallback command extraction when shlex.split() fails.
|
||||
|
||||
Uses regex to extract command names from potentially malformed commands.
|
||||
This is more permissive than shlex but ensures we can at least identify
|
||||
the commands being executed for security validation.
|
||||
|
||||
Args:
|
||||
command_string: The command string to parse
|
||||
|
||||
Returns:
|
||||
List of command names extracted from the string
|
||||
"""
|
||||
commands = []
|
||||
|
||||
# Shell keywords to skip
|
||||
shell_keywords = {
|
||||
"if",
|
||||
"then",
|
||||
"else",
|
||||
"elif",
|
||||
"fi",
|
||||
"for",
|
||||
"while",
|
||||
"until",
|
||||
"do",
|
||||
"done",
|
||||
"case",
|
||||
"esac",
|
||||
"in",
|
||||
"function",
|
||||
}
|
||||
|
||||
# First, split by common shell operators
|
||||
# This regex splits on &&, ||, |, ; while being careful about quotes
|
||||
# We're being permissive here since shlex already failed
|
||||
parts = re.split(r"\s*(?:&&|\|\||\|)\s*|;\s*", command_string)
|
||||
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
|
||||
# Skip variable assignments at the start (VAR=value cmd)
|
||||
while re.match(r"^[A-Za-z_][A-Za-z0-9_]*=\S*\s+", part):
|
||||
part = re.sub(r"^[A-Za-z_][A-Za-z0-9_]*=\S*\s+", "", part)
|
||||
|
||||
if not part:
|
||||
continue
|
||||
|
||||
# Strategy: Extract command from the BEGINNING of the part
|
||||
# Handle various formats:
|
||||
# - Simple: python3, npm, git
|
||||
# - Unix path: /usr/bin/python
|
||||
# - Windows path: C:\Python312\python.exe
|
||||
# - Quoted with spaces: "C:\Program Files\python.exe"
|
||||
|
||||
# Extract first token, handling quoted strings with spaces
|
||||
first_token_match = re.match(r'^(?:"([^"]+)"|\'([^\']+)\'|([^\s]+))', part)
|
||||
if not first_token_match:
|
||||
continue
|
||||
|
||||
# Pick whichever capture group matched (double-quoted, single-quoted, or unquoted)
|
||||
first_token = (
|
||||
first_token_match.group(1)
|
||||
or first_token_match.group(2)
|
||||
or first_token_match.group(3)
|
||||
)
|
||||
|
||||
# Now extract just the command name from this token
|
||||
# Handle Windows paths (C:\dir\cmd.exe) and Unix paths (/dir/cmd)
|
||||
# Use cross-platform basename for reliable path handling on any OS
|
||||
cmd = _cross_platform_basename(first_token)
|
||||
|
||||
# Remove Windows extensions
|
||||
cmd = re.sub(r"\.(exe|cmd|bat|ps1|sh)$", "", cmd, flags=re.IGNORECASE)
|
||||
|
||||
# Clean up any remaining quotes or special chars at the start
|
||||
cmd = re.sub(r'^["\'\\/]+', "", cmd)
|
||||
|
||||
# Skip tokens that look like function calls or code fragments (not shell commands)
|
||||
# These appear when splitting on semicolons inside malformed quoted strings
|
||||
if "(" in cmd or ")" in cmd or "." in cmd:
|
||||
continue
|
||||
|
||||
if cmd and cmd.lower() not in shell_keywords:
|
||||
commands.append(cmd)
|
||||
|
||||
return commands
|
||||
|
||||
|
||||
def split_command_segments(command_string: str) -> list[str]:
|
||||
@@ -32,13 +158,46 @@ def split_command_segments(command_string: str) -> list[str]:
|
||||
return result
|
||||
|
||||
|
||||
def _contains_windows_path(command_string: str) -> bool:
|
||||
"""
|
||||
Check if a command string contains Windows-style paths.
|
||||
|
||||
Windows paths with backslashes cause issues with shlex.split() because
|
||||
backslashes are interpreted as escape characters in POSIX mode.
|
||||
|
||||
Args:
|
||||
command_string: The command string to check
|
||||
|
||||
Returns:
|
||||
True if Windows paths are detected
|
||||
"""
|
||||
# Pattern matches:
|
||||
# - Drive letter paths: C:\, D:\, etc.
|
||||
# - Backslash followed by a path component (2+ chars to avoid escape sequences like \n, \t)
|
||||
# The second char must be alphanumeric, underscore, or another path separator
|
||||
# This avoids false positives on escape sequences which are single-char after backslash
|
||||
return bool(re.search(r"[A-Za-z]:\\|\\[A-Za-z][A-Za-z0-9_\\/]", command_string))
|
||||
|
||||
|
||||
def extract_commands(command_string: str) -> list[str]:
|
||||
"""
|
||||
Extract command names from a shell command string.
|
||||
|
||||
Handles pipes, command chaining (&&, ||, ;), and subshells.
|
||||
Returns the base command names (without paths).
|
||||
|
||||
On Windows or when commands contain malformed quoting (common with
|
||||
Windows paths in bash-style commands), falls back to regex-based
|
||||
extraction to ensure security validation can proceed.
|
||||
"""
|
||||
# If command contains Windows paths, use fallback parser directly
|
||||
# because shlex.split() interprets backslashes as escape characters
|
||||
if _contains_windows_path(command_string):
|
||||
fallback_commands = _fallback_extract_commands(command_string)
|
||||
if fallback_commands:
|
||||
return fallback_commands
|
||||
# Continue with shlex if fallback found nothing
|
||||
|
||||
commands = []
|
||||
|
||||
# Split on semicolons that aren't inside quotes
|
||||
@@ -53,7 +212,12 @@ def extract_commands(command_string: str) -> list[str]:
|
||||
tokens = shlex.split(segment)
|
||||
except ValueError:
|
||||
# Malformed command (unclosed quotes, etc.)
|
||||
# Return empty to trigger block (fail-safe)
|
||||
# This is common on Windows with backslash paths in quoted strings
|
||||
# Use fallback parser instead of blocking
|
||||
fallback_commands = _fallback_extract_commands(command_string)
|
||||
if fallback_commands:
|
||||
return fallback_commands
|
||||
# If fallback also found nothing, return empty to trigger block
|
||||
return []
|
||||
|
||||
if not tokens:
|
||||
@@ -106,7 +270,8 @@ def extract_commands(command_string: str) -> list[str]:
|
||||
|
||||
if expect_command:
|
||||
# Extract the base command name (handle paths like /usr/bin/python)
|
||||
cmd = os.path.basename(token)
|
||||
# Use cross-platform basename for Windows paths on Linux CI
|
||||
cmd = _cross_platform_basename(token)
|
||||
commands.append(cmd)
|
||||
expect_command = False
|
||||
|
||||
|
||||
@@ -9,11 +9,12 @@ Uses project_analyzer to create dynamic security profiles based on detected stac
|
||||
from pathlib import Path
|
||||
|
||||
from project_analyzer import (
|
||||
ProjectAnalyzer,
|
||||
SecurityProfile,
|
||||
get_or_create_profile,
|
||||
)
|
||||
|
||||
from .constants import ALLOWLIST_FILENAME, PROFILE_FILENAME
|
||||
|
||||
# =============================================================================
|
||||
# GLOBAL STATE
|
||||
# =============================================================================
|
||||
@@ -23,18 +24,33 @@ _cached_profile: SecurityProfile | None = None
|
||||
_cached_project_dir: Path | None = None
|
||||
_cached_spec_dir: Path | None = None # Track spec directory for cache key
|
||||
_cached_profile_mtime: float | None = None # Track file modification time
|
||||
_cached_allowlist_mtime: float | None = None # Track allowlist modification time
|
||||
|
||||
|
||||
def _get_profile_path(project_dir: Path) -> Path:
|
||||
"""Get the security profile file path for a project."""
|
||||
return project_dir / ProjectAnalyzer.PROFILE_FILENAME
|
||||
return project_dir / PROFILE_FILENAME
|
||||
|
||||
|
||||
def _get_allowlist_path(project_dir: Path) -> Path:
|
||||
"""Get the allowlist file path for a project."""
|
||||
return project_dir / ALLOWLIST_FILENAME
|
||||
|
||||
|
||||
def _get_profile_mtime(project_dir: Path) -> float | None:
|
||||
"""Get the modification time of the security profile file, or None if not exists."""
|
||||
profile_path = _get_profile_path(project_dir)
|
||||
try:
|
||||
return profile_path.stat().st_mtime if profile_path.exists() else None
|
||||
return profile_path.stat().st_mtime
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _get_allowlist_mtime(project_dir: Path) -> float | None:
|
||||
"""Get the modification time of the allowlist file, or None if not exists."""
|
||||
allowlist_path = _get_allowlist_path(project_dir)
|
||||
try:
|
||||
return allowlist_path.stat().st_mtime
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
@@ -49,6 +65,7 @@ def get_security_profile(
|
||||
- The project directory changes
|
||||
- The security profile file is created (was None, now exists)
|
||||
- The security profile file is modified (mtime changed)
|
||||
- The allowlist file is created, modified, or deleted
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
@@ -57,7 +74,11 @@ def get_security_profile(
|
||||
Returns:
|
||||
SecurityProfile for the project
|
||||
"""
|
||||
global _cached_profile, _cached_project_dir, _cached_spec_dir, _cached_profile_mtime
|
||||
global _cached_profile
|
||||
global _cached_project_dir
|
||||
global _cached_spec_dir
|
||||
global _cached_profile_mtime
|
||||
global _cached_allowlist_mtime
|
||||
|
||||
project_dir = Path(project_dir).resolve()
|
||||
resolved_spec_dir = Path(spec_dir).resolve() if spec_dir else None
|
||||
@@ -68,30 +89,40 @@ def get_security_profile(
|
||||
and _cached_project_dir == project_dir
|
||||
and _cached_spec_dir == resolved_spec_dir
|
||||
):
|
||||
# Check if file has been created or modified since caching
|
||||
current_mtime = _get_profile_mtime(project_dir)
|
||||
# Cache is valid if:
|
||||
# - Both are None (file never existed and still doesn't)
|
||||
# - Both have same mtime (file unchanged)
|
||||
if current_mtime == _cached_profile_mtime:
|
||||
# Check if files have been created or modified since caching
|
||||
current_profile_mtime = _get_profile_mtime(project_dir)
|
||||
current_allowlist_mtime = _get_allowlist_mtime(project_dir)
|
||||
|
||||
# Cache is valid if both mtimes are unchanged
|
||||
if (
|
||||
current_profile_mtime == _cached_profile_mtime
|
||||
and current_allowlist_mtime == _cached_allowlist_mtime
|
||||
):
|
||||
return _cached_profile
|
||||
|
||||
# File was created or modified - invalidate cache
|
||||
# (This happens when analyzer creates the file after agent starts)
|
||||
# File was created, modified, or deleted - invalidate cache
|
||||
# (This happens when analyzer creates the file after agent starts,
|
||||
# or when user adds/updates the allowlist)
|
||||
|
||||
# Analyze and cache
|
||||
_cached_profile = get_or_create_profile(project_dir, spec_dir)
|
||||
_cached_project_dir = project_dir
|
||||
_cached_spec_dir = resolved_spec_dir
|
||||
_cached_profile_mtime = _get_profile_mtime(project_dir)
|
||||
_cached_allowlist_mtime = _get_allowlist_mtime(project_dir)
|
||||
|
||||
return _cached_profile
|
||||
|
||||
|
||||
def reset_profile_cache() -> None:
|
||||
"""Reset the cached profile (useful for testing or re-analysis)."""
|
||||
global _cached_profile, _cached_project_dir, _cached_spec_dir, _cached_profile_mtime
|
||||
global _cached_profile
|
||||
global _cached_project_dir
|
||||
global _cached_spec_dir
|
||||
global _cached_profile_mtime
|
||||
global _cached_allowlist_mtime
|
||||
_cached_profile = None
|
||||
_cached_project_dir = None
|
||||
_cached_spec_dir = None
|
||||
_cached_profile_mtime = None
|
||||
_cached_allowlist_mtime = None
|
||||
|
||||
@@ -33,7 +33,11 @@ from .filesystem_validators import (
|
||||
validate_init_script,
|
||||
validate_rm_command,
|
||||
)
|
||||
from .git_validators import validate_git_commit
|
||||
from .git_validators import (
|
||||
validate_git_command,
|
||||
validate_git_commit,
|
||||
validate_git_config,
|
||||
)
|
||||
from .process_validators import (
|
||||
validate_kill_command,
|
||||
validate_killall_command,
|
||||
@@ -60,6 +64,8 @@ __all__ = [
|
||||
"validate_init_script",
|
||||
# Git validators
|
||||
"validate_git_commit",
|
||||
"validate_git_command",
|
||||
"validate_git_config",
|
||||
# Database validators
|
||||
"validate_dropdb_command",
|
||||
"validate_dropuser_command",
|
||||
|
||||
@@ -73,9 +73,12 @@ Be concise and use bullet points. Skip boilerplate and meta-commentary.
|
||||
await client.query(prompt)
|
||||
response_text = ""
|
||||
async for msg in client.receive_response():
|
||||
if hasattr(msg, "content"):
|
||||
msg_type = type(msg).__name__
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
if hasattr(block, "text"):
|
||||
# Must check block type - only TextBlock has .text attribute
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
return response_text.strip()
|
||||
except Exception as e:
|
||||
|
||||
@@ -88,17 +88,20 @@ class StreamingLogCapture:
|
||||
inp = block.input
|
||||
if isinstance(inp, dict):
|
||||
# Extract meaningful input description
|
||||
# Increased limits to avoid hiding critical information
|
||||
if "pattern" in inp:
|
||||
tool_input = f"pattern: {inp['pattern']}"
|
||||
elif "file_path" in inp:
|
||||
fp = inp["file_path"]
|
||||
if len(fp) > 50:
|
||||
fp = "..." + fp[-47:]
|
||||
# Show last 200 chars for paths (enough for most file paths)
|
||||
if len(fp) > 200:
|
||||
fp = "..." + fp[-197:]
|
||||
tool_input = fp
|
||||
elif "command" in inp:
|
||||
cmd = inp["command"]
|
||||
if len(cmd) > 50:
|
||||
cmd = cmd[:47] + "..."
|
||||
# Show first 300 chars for commands (enough for most commands)
|
||||
if len(cmd) > 300:
|
||||
cmd = cmd[:297] + "..."
|
||||
tool_input = cmd
|
||||
elif "path" in inp:
|
||||
tool_input = inp["path"]
|
||||
|
||||
@@ -406,10 +406,10 @@ class TaskLogger:
|
||||
"""
|
||||
phase_key = (phase or self.current_phase or LogPhase.CODING).value
|
||||
|
||||
# Truncate long inputs for display
|
||||
# Truncate long inputs for display (increased limit to avoid hiding critical info)
|
||||
display_input = tool_input
|
||||
if display_input and len(display_input) > 100:
|
||||
display_input = display_input[:97] + "..."
|
||||
if display_input and len(display_input) > 300:
|
||||
display_input = display_input[:297] + "..."
|
||||
|
||||
entry = LogEntry(
|
||||
timestamp=self._timestamp(),
|
||||
@@ -462,10 +462,10 @@ class TaskLogger:
|
||||
"""
|
||||
phase_key = (phase or self.current_phase or LogPhase.CODING).value
|
||||
|
||||
# Truncate long results for display
|
||||
# Truncate long results for display (increased limit to avoid hiding critical info)
|
||||
display_result = result
|
||||
if display_result and len(display_result) > 100:
|
||||
display_result = display_result[:97] + "..."
|
||||
if display_result and len(display_result) > 300:
|
||||
display_result = display_result[:297] + "..."
|
||||
|
||||
status = "Done" if success else "Error"
|
||||
content = f"[{tool_name}] {status}"
|
||||
|
||||
@@ -95,11 +95,54 @@ def box(
|
||||
for line in content:
|
||||
# Strip ANSI for length calculation
|
||||
visible_line = re.sub(r"\033\[[0-9;]*m", "", line)
|
||||
padding = inner_width - len(visible_line) - 2 # -2 for padding spaces
|
||||
visible_len = len(visible_line)
|
||||
padding = inner_width - visible_len - 2 # -2 for padding spaces
|
||||
|
||||
if padding < 0:
|
||||
# Truncate if too long
|
||||
line = line[: inner_width - 5] + "..."
|
||||
padding = 0
|
||||
# Line is too long - need to truncate intelligently
|
||||
# Calculate how much to remove (visible characters only)
|
||||
chars_to_remove = abs(padding) + 3 # +3 for "..."
|
||||
target_len = visible_len - chars_to_remove
|
||||
|
||||
if target_len <= 0:
|
||||
# Line is way too long, just show "..."
|
||||
line = "..."
|
||||
padding = inner_width - 5 # 3 for "..." + 2 for padding
|
||||
else:
|
||||
# Truncate the visible text, preserving ANSI codes for what remains
|
||||
# Split line into segments (ANSI code vs text)
|
||||
segments = re.split(r"(\033\[[0-9;]*m)", line)
|
||||
visible_chars = 0
|
||||
result_segments = []
|
||||
|
||||
for segment in segments:
|
||||
if re.match(r"\033\[[0-9;]*m", segment):
|
||||
# ANSI code - include it without counting
|
||||
result_segments.append(segment)
|
||||
else:
|
||||
# Text segment - count visible characters
|
||||
remaining_space = target_len - visible_chars
|
||||
if remaining_space <= 0:
|
||||
break
|
||||
if len(segment) <= remaining_space:
|
||||
result_segments.append(segment)
|
||||
visible_chars += len(segment)
|
||||
else:
|
||||
# Truncate this segment at word boundary if possible
|
||||
truncated = segment[:remaining_space]
|
||||
# Try to truncate at last space to avoid mid-word cuts
|
||||
last_space = truncated.rfind(" ")
|
||||
if (
|
||||
last_space > remaining_space * 0.7
|
||||
): # Only if space is in last 30%
|
||||
truncated = truncated[:last_space]
|
||||
result_segments.append(truncated)
|
||||
visible_chars += len(truncated)
|
||||
break
|
||||
|
||||
line = "".join(result_segments) + "..."
|
||||
padding = 0
|
||||
|
||||
lines.append(v + " " + line + " " * (padding + 1) + v)
|
||||
|
||||
# Bottom border
|
||||
|
||||
@@ -13,6 +13,61 @@ import os
|
||||
import sys
|
||||
|
||||
|
||||
def enable_windows_ansi_support() -> bool:
|
||||
"""
|
||||
Enable ANSI escape sequence support on Windows.
|
||||
|
||||
Windows 10 (build 10586+) supports ANSI escape sequences natively,
|
||||
but they must be explicitly enabled via the Windows API.
|
||||
|
||||
Returns:
|
||||
True if ANSI support was enabled, False otherwise
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return True # Non-Windows always has ANSI support
|
||||
|
||||
try:
|
||||
import ctypes
|
||||
from ctypes import wintypes
|
||||
|
||||
# Windows constants
|
||||
STD_OUTPUT_HANDLE = -11
|
||||
STD_ERROR_HANDLE = -12
|
||||
ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
|
||||
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
|
||||
# Get handles
|
||||
for handle_id in (STD_OUTPUT_HANDLE, STD_ERROR_HANDLE):
|
||||
handle = kernel32.GetStdHandle(handle_id)
|
||||
if handle == -1:
|
||||
continue
|
||||
|
||||
# Get current console mode
|
||||
mode = wintypes.DWORD()
|
||||
if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
|
||||
continue
|
||||
|
||||
# Enable ANSI support if not already enabled
|
||||
if not (mode.value & ENABLE_VIRTUAL_TERMINAL_PROCESSING):
|
||||
kernel32.SetConsoleMode(
|
||||
handle, mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING
|
||||
)
|
||||
|
||||
return True
|
||||
except (ImportError, AttributeError, OSError):
|
||||
# Fall back to colorama if available
|
||||
try:
|
||||
import colorama
|
||||
|
||||
colorama.init()
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def configure_safe_encoding() -> None:
|
||||
"""
|
||||
Configure stdout/stderr to handle Unicode safely on Windows.
|
||||
@@ -54,8 +109,9 @@ def configure_safe_encoding() -> None:
|
||||
pass
|
||||
|
||||
|
||||
# Configure safe encoding on module import
|
||||
# Configure safe encoding and ANSI support on module import
|
||||
configure_safe_encoding()
|
||||
WINDOWS_ANSI_ENABLED = enable_windows_ansi_support()
|
||||
|
||||
|
||||
def _is_fancy_ui_enabled() -> bool:
|
||||
|
||||
@@ -39,9 +39,10 @@ class Icons:
|
||||
FILE = ("📄", "[F]")
|
||||
GEAR = ("⚙", "[*]")
|
||||
SEARCH = ("🔍", "[?]")
|
||||
BRANCH = ("", "[B]")
|
||||
BRANCH = ("🌿", "[BR]") # [BR] to avoid collision with BLOCKED [B]
|
||||
COMMIT = ("◉", "(@)")
|
||||
LIGHTNING = ("⚡", "!")
|
||||
LINK = ("🔗", "[L]") # For PR URLs
|
||||
|
||||
# Progress
|
||||
SUBTASK = ("▣", "#")
|
||||
|
||||
@@ -19,6 +19,34 @@
|
||||
# Shows detailed information about app update checks and downloads
|
||||
# DEBUG_UPDATER=true
|
||||
|
||||
# ============================================
|
||||
# SENTRY ERROR REPORTING
|
||||
# ============================================
|
||||
|
||||
# Sentry DSN for anonymous error reporting
|
||||
# If not set, error reporting is completely disabled (safe for forks)
|
||||
#
|
||||
# For official builds: Set in CI/CD secrets
|
||||
# For local testing: Uncomment and add your DSN
|
||||
#
|
||||
# SENTRY_DSN=https://your-dsn@sentry.io/project-id
|
||||
|
||||
# Force enable Sentry in development mode (normally disabled in dev)
|
||||
# Only works when SENTRY_DSN is also set
|
||||
# SENTRY_DEV=true
|
||||
|
||||
# Trace sample rate for performance monitoring (0.0 to 1.0)
|
||||
# Controls what percentage of transactions are sampled
|
||||
# Default: 0.1 (10%) in production, 0 in development
|
||||
# Set to 0 to disable performance monitoring entirely
|
||||
# SENTRY_TRACES_SAMPLE_RATE=0.1
|
||||
|
||||
# Profile sample rate for profiling (0.0 to 1.0)
|
||||
# Controls what percentage of sampled transactions include profiling data
|
||||
# Default: 0.1 (10%) in production, 0 in development
|
||||
# Set to 0 to disable profiling entirely
|
||||
# SENTRY_PROFILES_SAMPLE_RATE=0.1
|
||||
|
||||
# ============================================
|
||||
# HOW TO USE
|
||||
# ============================================
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
/**
|
||||
* End-to-End tests for full task workflow
|
||||
* Tests: create → spec → subtasks → resume
|
||||
*
|
||||
* NOTE: These tests require the Electron app to be built first.
|
||||
* Run `npm run build` before running E2E tests.
|
||||
*
|
||||
* To run: npx playwright test task-workflow --config=e2e/playwright.config.ts
|
||||
*/
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { mkdirSync, mkdtempSync, rmSync, existsSync, writeFileSync, readFileSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
|
||||
// Test data directory - created securely with mkdtempSync to prevent TOCTOU attacks
|
||||
let TEST_DATA_DIR: string;
|
||||
let TEST_PROJECT_DIR: string;
|
||||
let SPECS_DIR: string;
|
||||
|
||||
// Setup test environment with secure temp directory
|
||||
function setupTestEnvironment(): void {
|
||||
// Create secure temp directory with random suffix
|
||||
TEST_DATA_DIR = mkdtempSync(path.join(tmpdir(), 'auto-claude-task-workflow-e2e-'));
|
||||
TEST_PROJECT_DIR = path.join(TEST_DATA_DIR, 'test-project');
|
||||
SPECS_DIR = path.join(TEST_PROJECT_DIR, '.auto-claude', 'specs');
|
||||
mkdirSync(TEST_PROJECT_DIR, { recursive: true });
|
||||
mkdirSync(SPECS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Cleanup test environment
|
||||
function cleanupTestEnvironment(): void {
|
||||
if (existsSync(TEST_DATA_DIR)) {
|
||||
rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to create a task spec with subtasks
|
||||
function createTaskWithSubtasks(
|
||||
specId: string,
|
||||
subtaskStatuses: Array<'pending' | 'in_progress' | 'completed'>
|
||||
): void {
|
||||
const specDir = path.join(SPECS_DIR, specId);
|
||||
mkdirSync(specDir, { recursive: true });
|
||||
|
||||
// Create spec.md
|
||||
writeFileSync(
|
||||
path.join(specDir, 'spec.md'),
|
||||
`# ${specId}\n\n## Overview\n\nTest task for workflow validation.\n\n## Acceptance Criteria\n\n- [ ] All subtasks completed\n- [ ] Tests pass\n`
|
||||
);
|
||||
|
||||
// Create requirements.json
|
||||
writeFileSync(
|
||||
path.join(specDir, 'requirements.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
task_description: `Test task ${specId}`,
|
||||
user_requirements: ['Requirement 1', 'Requirement 2'],
|
||||
acceptance_criteria: ['All subtasks completed', 'Tests pass'],
|
||||
context: []
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
// Create implementation_plan.json with subtasks
|
||||
const subtasks = subtaskStatuses.map((status, index) => ({
|
||||
id: `subtask-${index + 1}`,
|
||||
phase: 'Implementation',
|
||||
service: 'backend',
|
||||
description: `Subtask ${index + 1}: Implement feature part ${index + 1}`,
|
||||
files_to_modify: [`src/file${index + 1}.py`],
|
||||
files_to_create: [],
|
||||
pattern_files: [],
|
||||
verification_command: 'pytest tests/',
|
||||
status: status,
|
||||
notes: status === 'completed' ? 'Completed successfully' : ''
|
||||
}));
|
||||
|
||||
writeFileSync(
|
||||
path.join(specDir, 'implementation_plan.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
feature: `Test Feature ${specId}`,
|
||||
workflow_type: 'feature',
|
||||
services_involved: ['backend'],
|
||||
subtasks: subtasks,
|
||||
final_acceptance: ['All subtasks completed', 'Tests pass'],
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
spec_file: 'spec.md'
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
// Create build-progress.txt
|
||||
writeFileSync(
|
||||
path.join(specDir, 'build-progress.txt'),
|
||||
`Task Progress: ${specId}\n\nSubtasks: ${subtasks.length}\nCompleted: ${subtasks.filter(s => s.status === 'completed').length}\n`
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to simulate task resumption
|
||||
function simulateTaskResume(specId: string): void {
|
||||
const planPath = path.join(SPECS_DIR, specId, 'implementation_plan.json');
|
||||
const plan = JSON.parse(readFileSync(planPath, 'utf-8'));
|
||||
|
||||
// Find first pending subtask and mark as in_progress
|
||||
const pendingSubtask = plan.subtasks.find((st: { status: string }) => st.status === 'pending');
|
||||
if (pendingSubtask) {
|
||||
pendingSubtask.status = 'in_progress';
|
||||
pendingSubtask.notes = 'Resumed from checkpoint';
|
||||
}
|
||||
|
||||
plan.updated_at = new Date().toISOString();
|
||||
writeFileSync(planPath, JSON.stringify(plan, null, 2));
|
||||
}
|
||||
|
||||
test.describe('Task Workflow E2E Tests', () => {
|
||||
test.beforeAll(() => {
|
||||
setupTestEnvironment();
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
cleanupTestEnvironment();
|
||||
});
|
||||
|
||||
test('should create task directory structure', () => {
|
||||
const specId = '001-test-task';
|
||||
const specDir = path.join(SPECS_DIR, specId);
|
||||
mkdirSync(specDir, { recursive: true });
|
||||
|
||||
// Verify directory created
|
||||
expect(existsSync(specDir)).toBe(true);
|
||||
});
|
||||
|
||||
test('should generate spec.md file', () => {
|
||||
const specId = '002-task-with-spec';
|
||||
const specDir = path.join(SPECS_DIR, specId);
|
||||
mkdirSync(specDir, { recursive: true });
|
||||
|
||||
// Write spec
|
||||
const specContent = '# Test Task\n\n## Overview\n\nThis is a test task.\n';
|
||||
writeFileSync(path.join(specDir, 'spec.md'), specContent);
|
||||
|
||||
// Verify spec file
|
||||
expect(existsSync(path.join(specDir, 'spec.md'))).toBe(true);
|
||||
const content = readFileSync(path.join(specDir, 'spec.md'), 'utf-8');
|
||||
expect(content).toContain('Test Task');
|
||||
});
|
||||
|
||||
test('should create implementation plan with subtasks', () => {
|
||||
const specId = '003-task-with-subtasks';
|
||||
createTaskWithSubtasks(specId, ['pending', 'pending', 'pending']);
|
||||
|
||||
const planPath = path.join(SPECS_DIR, specId, 'implementation_plan.json');
|
||||
expect(existsSync(planPath)).toBe(true);
|
||||
|
||||
const plan = JSON.parse(readFileSync(planPath, 'utf-8'));
|
||||
expect(plan.subtasks).toBeDefined();
|
||||
expect(plan.subtasks.length).toBe(3);
|
||||
expect(plan.subtasks[0].status).toBe('pending');
|
||||
});
|
||||
|
||||
test('should track subtask progress', () => {
|
||||
const specId = '004-task-in-progress';
|
||||
createTaskWithSubtasks(specId, ['completed', 'in_progress', 'pending']);
|
||||
|
||||
const planPath = path.join(SPECS_DIR, specId, 'implementation_plan.json');
|
||||
const plan = JSON.parse(readFileSync(planPath, 'utf-8'));
|
||||
|
||||
expect(plan.subtasks[0].status).toBe('completed');
|
||||
expect(plan.subtasks[1].status).toBe('in_progress');
|
||||
expect(plan.subtasks[2].status).toBe('pending');
|
||||
});
|
||||
|
||||
test('should resume task from checkpoint', () => {
|
||||
const specId = '005-task-resume';
|
||||
createTaskWithSubtasks(specId, ['completed', 'pending', 'pending']);
|
||||
|
||||
// Verify initial state
|
||||
let plan = JSON.parse(readFileSync(path.join(SPECS_DIR, specId, 'implementation_plan.json'), 'utf-8'));
|
||||
expect(plan.subtasks[1].status).toBe('pending');
|
||||
|
||||
// Simulate resume
|
||||
simulateTaskResume(specId);
|
||||
|
||||
// Verify resumed state
|
||||
plan = JSON.parse(readFileSync(path.join(SPECS_DIR, specId, 'implementation_plan.json'), 'utf-8'));
|
||||
expect(plan.subtasks[1].status).toBe('in_progress');
|
||||
expect(plan.subtasks[1].notes).toContain('Resumed from checkpoint');
|
||||
});
|
||||
|
||||
test('should complete all subtasks in sequence', () => {
|
||||
const specId = '006-task-completion';
|
||||
createTaskWithSubtasks(specId, ['completed', 'completed', 'completed']);
|
||||
|
||||
const plan = JSON.parse(readFileSync(path.join(SPECS_DIR, specId, 'implementation_plan.json'), 'utf-8'));
|
||||
const allCompleted = plan.subtasks.every((st: { status: string }) => st.status === 'completed');
|
||||
|
||||
expect(allCompleted).toBe(true);
|
||||
});
|
||||
|
||||
test('should maintain build progress log', () => {
|
||||
const specId = '007-task-with-progress';
|
||||
createTaskWithSubtasks(specId, ['completed', 'in_progress', 'pending']);
|
||||
|
||||
const progressPath = path.join(SPECS_DIR, specId, 'build-progress.txt');
|
||||
expect(existsSync(progressPath)).toBe(true);
|
||||
|
||||
const progressContent = readFileSync(progressPath, 'utf-8');
|
||||
expect(progressContent).toContain('Task Progress');
|
||||
expect(progressContent).toContain('Subtasks: 3');
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Full Task Workflow Integration', () => {
|
||||
test.beforeAll(() => {
|
||||
setupTestEnvironment();
|
||||
});
|
||||
|
||||
test.afterAll(() => {
|
||||
cleanupTestEnvironment();
|
||||
});
|
||||
|
||||
test('should complete full workflow: create → spec → subtasks → resume → complete', () => {
|
||||
const specId = '100-full-workflow';
|
||||
|
||||
// Step 1: Create task
|
||||
const specDir = path.join(SPECS_DIR, specId);
|
||||
mkdirSync(specDir, { recursive: true });
|
||||
expect(existsSync(specDir)).toBe(true);
|
||||
|
||||
// Step 2: Generate spec
|
||||
writeFileSync(
|
||||
path.join(specDir, 'spec.md'),
|
||||
'# Full Workflow Test\n\n## Overview\n\nComplete workflow test.\n'
|
||||
);
|
||||
expect(existsSync(path.join(specDir, 'spec.md'))).toBe(true);
|
||||
|
||||
// Step 3: Create subtasks
|
||||
createTaskWithSubtasks(specId, ['pending', 'pending', 'pending']);
|
||||
let plan = JSON.parse(readFileSync(path.join(specDir, 'implementation_plan.json'), 'utf-8'));
|
||||
expect(plan.subtasks.length).toBe(3);
|
||||
|
||||
// Step 4: Start first subtask
|
||||
plan.subtasks[0].status = 'in_progress';
|
||||
writeFileSync(path.join(specDir, 'implementation_plan.json'), JSON.stringify(plan, null, 2));
|
||||
|
||||
plan = JSON.parse(readFileSync(path.join(specDir, 'implementation_plan.json'), 'utf-8'));
|
||||
expect(plan.subtasks[0].status).toBe('in_progress');
|
||||
|
||||
// Step 5: Complete first subtask
|
||||
plan.subtasks[0].status = 'completed';
|
||||
plan.subtasks[0].notes = 'First subtask completed';
|
||||
writeFileSync(path.join(specDir, 'implementation_plan.json'), JSON.stringify(plan, null, 2));
|
||||
|
||||
// Step 6: Resume with second subtask
|
||||
simulateTaskResume(specId);
|
||||
plan = JSON.parse(readFileSync(path.join(specDir, 'implementation_plan.json'), 'utf-8'));
|
||||
expect(plan.subtasks[1].status).toBe('in_progress');
|
||||
|
||||
// Step 7: Complete remaining subtasks
|
||||
plan.subtasks[1].status = 'completed';
|
||||
plan.subtasks[2].status = 'completed';
|
||||
writeFileSync(path.join(specDir, 'implementation_plan.json'), JSON.stringify(plan, null, 2));
|
||||
|
||||
// Step 8: Verify all completed
|
||||
plan = JSON.parse(readFileSync(path.join(specDir, 'implementation_plan.json'), 'utf-8'));
|
||||
const allCompleted = plan.subtasks.every((st: { status: string }) => st.status === 'completed');
|
||||
expect(allCompleted).toBe(true);
|
||||
|
||||
// Step 9: Verify final state
|
||||
expect(plan.subtasks[0].notes).toContain('First subtask completed');
|
||||
expect(plan.subtasks[1].notes).toContain('Resumed from checkpoint');
|
||||
});
|
||||
|
||||
test('should handle workflow interruption and recovery', () => {
|
||||
const specId = '101-workflow-recovery';
|
||||
|
||||
// Create task with partial progress
|
||||
createTaskWithSubtasks(specId, ['completed', 'in_progress', 'pending']);
|
||||
|
||||
// Simulate interruption (task status is saved)
|
||||
const planPath = path.join(SPECS_DIR, specId, 'implementation_plan.json');
|
||||
let plan = JSON.parse(readFileSync(planPath, 'utf-8'));
|
||||
expect(plan.subtasks[1].status).toBe('in_progress');
|
||||
|
||||
// Simulate recovery: complete interrupted subtask
|
||||
plan.subtasks[1].status = 'completed';
|
||||
plan.subtasks[1].notes = 'Recovered and completed';
|
||||
writeFileSync(planPath, JSON.stringify(plan, null, 2));
|
||||
|
||||
// Resume with next subtask
|
||||
simulateTaskResume(specId);
|
||||
plan = JSON.parse(readFileSync(planPath, 'utf-8'));
|
||||
|
||||
// Verify recovery successful
|
||||
expect(plan.subtasks[1].status).toBe('completed');
|
||||
expect(plan.subtasks[2].status).toBe('in_progress');
|
||||
});
|
||||
|
||||
test('should validate workflow data integrity', () => {
|
||||
const specId = '102-data-integrity';
|
||||
createTaskWithSubtasks(specId, ['pending', 'pending', 'pending']);
|
||||
|
||||
const specDir = path.join(SPECS_DIR, specId);
|
||||
|
||||
// Verify all required files exist
|
||||
expect(existsSync(path.join(specDir, 'spec.md'))).toBe(true);
|
||||
expect(existsSync(path.join(specDir, 'requirements.json'))).toBe(true);
|
||||
expect(existsSync(path.join(specDir, 'implementation_plan.json'))).toBe(true);
|
||||
expect(existsSync(path.join(specDir, 'build-progress.txt'))).toBe(true);
|
||||
|
||||
// Verify data structure integrity
|
||||
const requirements = JSON.parse(readFileSync(path.join(specDir, 'requirements.json'), 'utf-8'));
|
||||
expect(requirements.task_description).toBeDefined();
|
||||
expect(requirements.acceptance_criteria).toBeDefined();
|
||||
|
||||
const plan = JSON.parse(readFileSync(path.join(specDir, 'implementation_plan.json'), 'utf-8'));
|
||||
expect(plan.feature).toBeDefined();
|
||||
expect(plan.subtasks).toBeDefined();
|
||||
expect(plan.created_at).toBeDefined();
|
||||
expect(plan.updated_at).toBeDefined();
|
||||
|
||||
// Verify subtask structure
|
||||
plan.subtasks.forEach((subtask: {
|
||||
id: string;
|
||||
description: string;
|
||||
status: string;
|
||||
verification_command: string;
|
||||
}) => {
|
||||
expect(subtask.id).toBeDefined();
|
||||
expect(subtask.description).toBeDefined();
|
||||
expect(subtask.status).toMatch(/^(pending|in_progress|completed)$/);
|
||||
expect(subtask.verification_command).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
/**
|
||||
* End-to-End tests for terminal copy/paste functionality
|
||||
* Tests copy/paste keyboard shortcuts in the Electron app
|
||||
*
|
||||
* These tests require the Electron app to be built first.
|
||||
* Run `npm run build` before running E2E tests.
|
||||
*
|
||||
* To run: npx playwright test terminal-copy-paste.e2e.ts --config=e2e/playwright.config.ts
|
||||
*/
|
||||
import { test, expect, _electron as electron, ElectronApplication, Page } from '@playwright/test';
|
||||
import { mkdirSync, rmSync, existsSync } from 'fs';
|
||||
import path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
// Global Navigator declaration for clipboard
|
||||
declare global {
|
||||
interface Navigator {
|
||||
clipboard: {
|
||||
readText(): Promise<string>;
|
||||
writeText(text: string): Promise<void>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Test data directory
|
||||
const TEST_DATA_DIR = path.join(os.tmpdir(), 'auto-claude-terminal-e2e');
|
||||
|
||||
// Determine platform for platform-specific tests
|
||||
const platform = process.platform;
|
||||
const isMac = platform === 'darwin';
|
||||
const isWindows = platform === 'win32';
|
||||
const isLinux = platform === 'linux';
|
||||
|
||||
// Setup test environment
|
||||
function setupTestEnvironment(): void {
|
||||
if (existsSync(TEST_DATA_DIR)) {
|
||||
rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Cleanup test environment
|
||||
function cleanupTestEnvironment(): void {
|
||||
if (existsSync(TEST_DATA_DIR)) {
|
||||
rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to get platform-specific copy shortcut
|
||||
function getCopyShortcutKey(): string {
|
||||
return isMac ? 'Meta' : 'Control';
|
||||
}
|
||||
|
||||
// Helper to check if test should run on current platform
|
||||
function shouldRunForPlatform(testPlatform: 'all' | 'windows' | 'linux' | 'mac'): boolean {
|
||||
if (testPlatform === 'all') return true;
|
||||
if (testPlatform === 'windows') return isWindows;
|
||||
if (testPlatform === 'linux') return isLinux;
|
||||
if (testPlatform === 'mac') return isMac;
|
||||
return false;
|
||||
}
|
||||
|
||||
test.describe('Terminal Copy/Paste Flows', () => {
|
||||
let app: ElectronApplication;
|
||||
let window: Page;
|
||||
let isAppReady = false;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
setupTestEnvironment();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
cleanupTestEnvironment();
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
// Launch Electron app
|
||||
const appPath = path.join(__dirname, '..');
|
||||
app = await electron.launch({ args: [appPath] });
|
||||
|
||||
window = await app.firstWindow({
|
||||
timeout: 15000
|
||||
});
|
||||
|
||||
// Wait for app to be ready
|
||||
try {
|
||||
await window.waitForSelector('body', { timeout: 10000 });
|
||||
isAppReady = true;
|
||||
} catch (error) {
|
||||
console.error('App failed to load:', error);
|
||||
isAppReady = false;
|
||||
}
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
if (app) {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test('should copy selected text to clipboard', async () => {
|
||||
test.skip(!isAppReady, 'App not ready');
|
||||
test.skip(!shouldRunForPlatform('all'), 'Test not applicable to this platform');
|
||||
|
||||
// Look for terminal element - skip if not found
|
||||
const terminalSelector = '.xterm';
|
||||
const terminalExists = await window.locator(terminalSelector).count() > 0;
|
||||
test.skip(!terminalExists, 'Terminal element not found');
|
||||
|
||||
// Run a command to produce output
|
||||
const terminal = window.locator(terminalSelector).first();
|
||||
await terminal.click();
|
||||
|
||||
// Type echo command and press enter
|
||||
await window.keyboard.type('echo "test output for copy"');
|
||||
await window.keyboard.press('Enter');
|
||||
|
||||
// Wait for output to appear in terminal
|
||||
await expect(terminal).toContainText('test output for copy', { timeout: 5000 });
|
||||
|
||||
// Select text (triple click to select line)
|
||||
await terminal.click({ clickCount: 3 });
|
||||
|
||||
// Wait for selection to be active
|
||||
await window.waitForTimeout(100);
|
||||
|
||||
// Press copy shortcut (Cmd+C on Mac, Ctrl+C on Windows/Linux)
|
||||
const copyKey = getCopyShortcutKey();
|
||||
await window.keyboard.press(`${copyKey}+c`);
|
||||
|
||||
// Wait briefly for clipboard operation
|
||||
await window.waitForTimeout(100);
|
||||
|
||||
// Verify clipboard contains selected text
|
||||
const clipboardText = await window.evaluate(async () => {
|
||||
return await navigator.clipboard.readText();
|
||||
});
|
||||
|
||||
expect(clipboardText).toContain('test output for copy');
|
||||
});
|
||||
|
||||
test('should send interrupt signal when no text selected', async () => {
|
||||
test.skip(!isAppReady, 'App not ready');
|
||||
test.skip(!shouldRunForPlatform('all'), 'Test not applicable to this platform');
|
||||
|
||||
const terminalSelector = '.xterm';
|
||||
const terminalExists = await window.locator(terminalSelector).count() > 0;
|
||||
test.skip(!terminalExists, 'Terminal element not found');
|
||||
|
||||
const terminal = window.locator(terminalSelector).first();
|
||||
await terminal.click();
|
||||
|
||||
// Start a long-running process (sleep on Linux/Mac, timeout on Windows)
|
||||
const sleepCommand = isWindows ? 'timeout 10' : 'sleep 10';
|
||||
await window.keyboard.type(sleepCommand);
|
||||
await window.keyboard.press('Enter');
|
||||
|
||||
// Wait for process to start
|
||||
await window.waitForTimeout(500);
|
||||
|
||||
// Press Ctrl+C without selection (should send interrupt)
|
||||
await window.keyboard.press('Control+c');
|
||||
|
||||
// Wait for interrupt to be processed - look for ^C or new prompt
|
||||
await expect(terminal).toContainText(/\^C|[$#>]/, { timeout: 3000 });
|
||||
});
|
||||
|
||||
test('should paste clipboard text into terminal', async () => {
|
||||
test.skip(!isAppReady, 'App not ready');
|
||||
test.skip(!shouldRunForPlatform('all'), 'Test not applicable to this platform');
|
||||
|
||||
const terminalSelector = '.xterm';
|
||||
const terminalExists = await window.locator(terminalSelector).count() > 0;
|
||||
test.skip(!terminalExists, 'Terminal element not found');
|
||||
|
||||
// Set clipboard content
|
||||
const testText = 'hello world from clipboard';
|
||||
await window.evaluate(async (text) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
}, testText);
|
||||
|
||||
const terminal = window.locator(terminalSelector).first();
|
||||
await terminal.click();
|
||||
|
||||
// Press paste shortcut
|
||||
const pasteKey = isMac ? 'Meta' : 'Control';
|
||||
await window.keyboard.press(`${pasteKey}+v`);
|
||||
|
||||
// Wait briefly for paste to complete
|
||||
await window.waitForTimeout(100);
|
||||
|
||||
// Press Enter to execute the pasted command
|
||||
await window.keyboard.press('Enter');
|
||||
|
||||
// Verify text was pasted (terminal should show the pasted text or output)
|
||||
await expect(terminal).toContainText(testText, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('should handle Linux CTRL+SHIFT+C copy shortcut', async () => {
|
||||
test.skip(!isAppReady, 'App not ready');
|
||||
test.skip(!shouldRunForPlatform('linux'), 'Linux-specific test');
|
||||
|
||||
const terminalSelector = '.xterm';
|
||||
const terminalExists = await window.locator(terminalSelector).count() > 0;
|
||||
test.skip(!terminalExists, 'Terminal element not found');
|
||||
|
||||
const terminal = window.locator(terminalSelector).first();
|
||||
await terminal.click();
|
||||
|
||||
// Type command to generate output
|
||||
await window.keyboard.type('echo "linux copy test"');
|
||||
await window.keyboard.press('Enter');
|
||||
|
||||
// Wait for output
|
||||
await expect(terminal).toContainText('linux copy test', { timeout: 5000 });
|
||||
|
||||
// Select text
|
||||
await terminal.click({ clickCount: 3 });
|
||||
await window.waitForTimeout(100);
|
||||
|
||||
// Press CTRL+SHIFT+C (Linux copy shortcut)
|
||||
await window.keyboard.down('Control');
|
||||
await window.keyboard.down('Shift');
|
||||
await window.keyboard.press('c');
|
||||
await window.keyboard.up('Shift');
|
||||
await window.keyboard.up('Control');
|
||||
|
||||
// Wait briefly for clipboard operation
|
||||
await window.waitForTimeout(100);
|
||||
|
||||
// Verify clipboard contains selected text
|
||||
const clipboardText = await window.evaluate(async () => {
|
||||
return await navigator.clipboard.readText();
|
||||
});
|
||||
|
||||
expect(clipboardText).toContain('linux copy test');
|
||||
});
|
||||
|
||||
test('should handle Linux CTRL+SHIFT+V paste shortcut', async () => {
|
||||
test.skip(!isAppReady, 'App not ready');
|
||||
test.skip(!shouldRunForPlatform('linux'), 'Linux-specific test');
|
||||
|
||||
const terminalSelector = '.xterm';
|
||||
const terminalExists = await window.locator(terminalSelector).count() > 0;
|
||||
test.skip(!terminalExists, 'Terminal element not found');
|
||||
|
||||
// Set clipboard content
|
||||
const testText = 'pasted via ctrl+shift+v';
|
||||
await window.evaluate(async (text) => {
|
||||
await navigator.clipboard.writeText(text);
|
||||
}, testText);
|
||||
|
||||
const terminal = window.locator(terminalSelector).first();
|
||||
await terminal.click();
|
||||
|
||||
// Press CTRL+SHIFT+V (Linux paste shortcut)
|
||||
await window.keyboard.down('Control');
|
||||
await window.keyboard.down('Shift');
|
||||
await window.keyboard.press('v');
|
||||
await window.keyboard.up('Shift');
|
||||
await window.keyboard.up('Control');
|
||||
|
||||
// Wait briefly for paste to complete
|
||||
await window.waitForTimeout(100);
|
||||
|
||||
// Press Enter to execute
|
||||
await window.keyboard.press('Enter');
|
||||
|
||||
// Verify text was pasted
|
||||
await expect(terminal).toContainText(testText, { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('should verify existing shortcuts still work', async () => {
|
||||
test.skip(!isAppReady, 'App not ready');
|
||||
test.skip(!shouldRunForPlatform('all'), 'Test not applicable to this platform');
|
||||
|
||||
const terminalSelector = '.xterm';
|
||||
const terminalExists = await window.locator(terminalSelector).count() > 0;
|
||||
test.skip(!terminalExists, 'Terminal element not found');
|
||||
|
||||
const terminal = window.locator(terminalSelector).first();
|
||||
await terminal.click();
|
||||
|
||||
// Test SHIFT+Enter (multi-line input)
|
||||
await window.keyboard.type('echo "line 1"');
|
||||
await window.keyboard.down('Shift');
|
||||
await window.keyboard.press('Enter');
|
||||
await window.keyboard.up('Shift');
|
||||
await window.keyboard.type('echo "line 2"');
|
||||
await window.keyboard.press('Enter');
|
||||
|
||||
// Verify multi-line input worked (both commands should execute)
|
||||
await expect(terminal).toContainText('line 1', { timeout: 5000 });
|
||||
await expect(terminal).toContainText('line 2', { timeout: 5000 });
|
||||
});
|
||||
|
||||
test('should handle clipboard errors gracefully', async () => {
|
||||
test.skip(!isAppReady, 'App not ready');
|
||||
test.skip(!shouldRunForPlatform('all'), 'Test not applicable to this platform');
|
||||
|
||||
const terminalSelector = '.xterm';
|
||||
const terminalExists = await window.locator(terminalSelector).count() > 0;
|
||||
test.skip(!terminalExists, 'Terminal element not found');
|
||||
|
||||
// Mock clipboard permission denial by clearing clipboard
|
||||
await window.evaluate(async () => {
|
||||
// Try to read clipboard (may fail if permission denied)
|
||||
try {
|
||||
await navigator.clipboard.readText();
|
||||
} catch (_error) {
|
||||
// Expected - clipboard may not be accessible in test environment
|
||||
console.warn('Clipboard not accessible (expected in some environments)');
|
||||
}
|
||||
});
|
||||
|
||||
const terminal = window.locator(terminalSelector).first();
|
||||
await terminal.click();
|
||||
|
||||
// Try to paste even if clipboard is not accessible
|
||||
const pasteKey = isMac ? 'Meta' : 'Control';
|
||||
await window.keyboard.press(`${pasteKey}+v`);
|
||||
|
||||
// Wait briefly to ensure terminal remains stable
|
||||
await window.waitForTimeout(100);
|
||||
|
||||
// Try typing to verify terminal still works
|
||||
await window.keyboard.type('echo "terminal still works"');
|
||||
await window.keyboard.press('Enter');
|
||||
|
||||
// Verify terminal still functions after clipboard error
|
||||
await expect(terminal).toContainText('terminal still works', { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
@@ -69,6 +69,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toast": "^1.2.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@sentry/electron": "^7.5.0",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tanstack/react-virtual": "^3.13.13",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
@@ -79,10 +80,12 @@
|
||||
"chokidar": "^5.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^16.6.1",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.6.2",
|
||||
"i18next": "^25.7.3",
|
||||
"lucide-react": "^0.562.0",
|
||||
"minimatch": "^10.1.1",
|
||||
"motion": "^12.23.26",
|
||||
"proper-lockfile": "^4.1.2",
|
||||
"react": "^19.2.3",
|
||||
@@ -106,6 +109,7 @@
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/minimatch": "^5.1.2",
|
||||
"@types/node": "^25.0.0",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -210,7 +214,7 @@
|
||||
]
|
||||
},
|
||||
"linux": {
|
||||
"icon": "resources/icon.png",
|
||||
"icon": "resources/icons",
|
||||
"target": [
|
||||
"AppImage",
|
||||
"deb",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 921 B |
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.2 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user