Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 086429cb49 | |||
| d9fb8f29d5 | |||
| d0b0b3df0d | |||
| 937a60f8bd | |||
| 7a51cbd5e5 | |||
| 26beefe39d | |||
| 8416f3076a | |||
| 217249c8a3 | |||
| 8bb3df917e | |||
| 5106c6e9b2 | |||
| 3ff612742f | |||
| 7f19c2e1f3 | |||
| d98e28305d | |||
| 0b874d4b33 | |||
| 50dd10788a | |||
| f1cc5a09f2 | |||
| b005fa5c86 | |||
| d79f2da411 | |||
| 5ac566e2a2 | |||
| f49d4817b1 | |||
| 1e1d7d9b68 | |||
| e74a3dffd2 | |||
| 6ac8250b5b | |||
| a2cee6941f | |||
| d4cad80a73 | |||
| 596e95137b | |||
| d42041c5b5 | |||
| a3f87540c6 | |||
| f843811292 | |||
| 5e8c53080f | |||
| 348de6dfe7 | |||
| 0f7d6e0530 | |||
| 5ccdb6abc5 | |||
| 6ec8549f63 | |||
| 53527293cd | |||
| 02bef954f3 | |||
| f168bdc3ac | |||
| e3eec68aab | |||
| 407a0bee5e | |||
| 8f766ad16e | |||
| ced2ad479f | |||
| 05f5d3038b | |||
| 6951251b33 | |||
| 30e7536b63 | |||
| 220faf0fb4 | |||
| f96c6301f4 | |||
| ebd8340d82 | |||
| df779530e7 | |||
| 0adaddacaa | |||
| 91f7051dda |
@@ -55,12 +55,6 @@ jobs:
|
||||
git push origin "v$VERSION"
|
||||
echo "Created tag v$VERSION"
|
||||
|
||||
- name: Create tag only (dry run)
|
||||
if: ${{ github.event.inputs.dry_run == 'true' }}
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
echo "DRY RUN: Would create tag v$VERSION"
|
||||
|
||||
# Intel build on Intel runner for native compilation
|
||||
build-macos-intel:
|
||||
needs: create-tag
|
||||
@@ -71,11 +65,6 @@ jobs:
|
||||
# Use tag for real releases, develop branch for dry runs
|
||||
ref: ${{ github.event.inputs.dry_run == 'true' && 'develop' || format('v{0}', needs.create-tag.outputs.version) }}
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -94,24 +83,13 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: cd apps/frontend && npm ci
|
||||
|
||||
- name: Install Rust toolchain (for building native Python packages)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache pip wheel cache (for compiled packages like real_ladybug)
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/Library/Caches/pip
|
||||
key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
pip-wheel-${{ runner.os }}-x64-
|
||||
|
||||
- name: Cache bundled Python
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: apps/frontend/python-runtime
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8-rust-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-rust-
|
||||
python-bundle-${{ runner.os }}-x64-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
@@ -154,7 +132,6 @@ jobs:
|
||||
path: |
|
||||
apps/frontend/dist/*.dmg
|
||||
apps/frontend/dist/*.zip
|
||||
apps/frontend/dist/*.yml
|
||||
|
||||
# Apple Silicon build on ARM64 runner for native compilation
|
||||
build-macos-arm64:
|
||||
@@ -166,11 +143,6 @@ jobs:
|
||||
# Use tag for real releases, develop branch for dry runs
|
||||
ref: ${{ github.event.inputs.dry_run == 'true' && 'develop' || format('v{0}', needs.create-tag.outputs.version) }}
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -189,21 +161,13 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: cd apps/frontend && npm ci
|
||||
|
||||
- name: Cache pip wheel cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/Library/Caches/pip
|
||||
key: pip-wheel-${{ runner.os }}-arm64-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
pip-wheel-${{ runner.os }}-arm64-
|
||||
|
||||
- name: Cache bundled Python
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: apps/frontend/python-runtime
|
||||
key: python-bundle-${{ runner.os }}-arm64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
key: python-bundle-${{ runner.os }}-arm64-3.12.8
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-arm64-3.12.8-
|
||||
python-bundle-${{ runner.os }}-arm64-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
@@ -246,7 +210,6 @@ jobs:
|
||||
path: |
|
||||
apps/frontend/dist/*.dmg
|
||||
apps/frontend/dist/*.zip
|
||||
apps/frontend/dist/*.yml
|
||||
|
||||
build-windows:
|
||||
needs: create-tag
|
||||
@@ -257,11 +220,6 @@ jobs:
|
||||
# Use tag for real releases, develop branch for dry runs
|
||||
ref: ${{ github.event.inputs.dry_run == 'true' && 'develop' || format('v{0}', needs.create-tag.outputs.version) }}
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -281,21 +239,13 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: cd apps/frontend && npm ci
|
||||
|
||||
- name: Cache pip wheel cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~\AppData\Local\pip\Cache
|
||||
key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
pip-wheel-${{ runner.os }}-x64-
|
||||
|
||||
- name: Cache bundled Python
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: apps/frontend/python-runtime
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-
|
||||
python-bundle-${{ runner.os }}-x64-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
@@ -316,7 +266,6 @@ jobs:
|
||||
name: windows-builds
|
||||
path: |
|
||||
apps/frontend/dist/*.exe
|
||||
apps/frontend/dist/*.yml
|
||||
|
||||
build-linux:
|
||||
needs: create-tag
|
||||
@@ -327,11 +276,6 @@ jobs:
|
||||
# Use tag for real releases, develop branch for dry runs
|
||||
ref: ${{ github.event.inputs.dry_run == 'true' && 'develop' || format('v{0}', needs.create-tag.outputs.version) }}
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
@@ -350,30 +294,13 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: cd apps/frontend && npm ci
|
||||
|
||||
- name: Setup Flatpak
|
||||
run: |
|
||||
set -e
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y flatpak flatpak-builder
|
||||
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak install -y --user flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
|
||||
flatpak install -y --user flathub org.electronjs.Electron2.BaseApp//25.08
|
||||
|
||||
- name: Cache pip wheel cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
pip-wheel-${{ runner.os }}-x64-
|
||||
|
||||
- name: Cache bundled Python
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: apps/frontend/python-runtime
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-
|
||||
python-bundle-${{ runner.os }}-x64-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
@@ -392,8 +319,6 @@ jobs:
|
||||
path: |
|
||||
apps/frontend/dist/*.AppImage
|
||||
apps/frontend/dist/*.deb
|
||||
apps/frontend/dist/*.flatpak
|
||||
apps/frontend/dist/*.yml
|
||||
|
||||
create-release:
|
||||
needs: [create-tag, build-macos-intel, build-macos-arm64, build-windows, build-linux]
|
||||
@@ -415,12 +340,12 @@ jobs:
|
||||
- name: Flatten and validate artifacts
|
||||
run: |
|
||||
mkdir -p release-assets
|
||||
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.yml" \) -exec cp {} release-assets/ \;
|
||||
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \) -exec cp {} release-assets/ \;
|
||||
|
||||
# Validate that at least one artifact was copied
|
||||
artifact_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) | wc -l)
|
||||
artifact_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \) | wc -l)
|
||||
if [ "$artifact_count" -eq 0 ]; then
|
||||
echo "::error::No build artifacts found! Expected .dmg, .zip, .exe, .AppImage, .deb, or .flatpak files."
|
||||
echo "::error::No build artifacts found! Expected .dmg, .zip, .exe, .AppImage, or .deb files."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -478,7 +403,7 @@ jobs:
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Build artifacts created successfully:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) >> $GITHUB_STEP_SUMMARY
|
||||
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \) >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "To create a real release, run this workflow again with dry_run unchecked." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -6,10 +6,6 @@ on:
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
@@ -44,6 +40,7 @@ jobs:
|
||||
uv pip install -r ../../tests/requirements-test.txt
|
||||
|
||||
- name: Run tests
|
||||
if: matrix.python-version != '3.12'
|
||||
working-directory: apps/backend
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}/apps/backend
|
||||
|
||||
@@ -6,10 +6,6 @@ on:
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
concurrency:
|
||||
group: lint-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Python linting
|
||||
python:
|
||||
@@ -23,12 +19,12 @@ jobs:
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
# Pin ruff version to match .pre-commit-config.yaml (astral-sh/ruff-pre-commit rev)
|
||||
- name: Install ruff
|
||||
run: pip install ruff==0.14.10
|
||||
run: pip install ruff
|
||||
|
||||
- name: Run ruff check
|
||||
run: ruff check apps/backend/ --output-format=github
|
||||
|
||||
- name: Run ruff format check
|
||||
run: ruff format apps/backend/ --check --diff
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ name: PR Status Gate
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI, Lint, Quality Security]
|
||||
workflows: [CI, Lint, Quality Security, Quality DCO, Quality Commit Lint]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
@@ -31,25 +31,29 @@ jobs:
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// REQUIRED CHECK RUNS - Job-level checks (not workflow-level)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Format: "{Workflow Name} / {Job Name}" or "{Workflow Name} / {Job Custom Name}"
|
||||
// Format: "{Workflow Name} / {Job Name} (pull_request)"
|
||||
//
|
||||
// To find check names: Go to PR → Checks tab → copy exact name
|
||||
// To update: Edit this list when workflow jobs are added/renamed/removed
|
||||
//
|
||||
// Last validated: 2026-01-02
|
||||
// Last validated: 2025-12-26
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
const requiredChecks = [
|
||||
// CI workflow (ci.yml) - 3 checks
|
||||
'CI / test-frontend',
|
||||
'CI / test-python (3.12)',
|
||||
'CI / test-python (3.13)',
|
||||
'CI / test-frontend (pull_request)',
|
||||
'CI / test-python (3.12) (pull_request)',
|
||||
'CI / test-python (3.13) (pull_request)',
|
||||
// Lint workflow (lint.yml) - 1 check
|
||||
'Lint / python',
|
||||
'Lint / python (pull_request)',
|
||||
// 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'
|
||||
'Quality Security / CodeQL (javascript-typescript) (pull_request)',
|
||||
'Quality Security / CodeQL (python) (pull_request)',
|
||||
'Quality Security / Python Security (Bandit) (pull_request)',
|
||||
'Quality Security / Security Summary (pull_request)',
|
||||
// Quality DCO workflow (quality-dco.yml) - 1 check
|
||||
'Quality DCO / DCO Check (pull_request)',
|
||||
// Quality Commit Lint workflow (quality-commit-lint.yml) - 1 check
|
||||
'Quality Commit Lint / Conventional Commits (pull_request)'
|
||||
];
|
||||
|
||||
const statusLabels = {
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
name: Prepare Release
|
||||
|
||||
# Triggers when code is pushed to main (e.g., merging develop → main)
|
||||
# If package.json version is newer than the latest tag:
|
||||
# 1. Validates CHANGELOG.md has an entry for this version (FAILS if missing)
|
||||
# 2. Extracts release notes from CHANGELOG.md
|
||||
# 3. Creates a new tag which triggers release.yml
|
||||
# If package.json version is newer than the latest tag, creates a new tag
|
||||
# which then triggers the release.yml workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -69,122 +67,8 @@ jobs:
|
||||
echo "⏭️ No release needed (package version not newer than latest tag)"
|
||||
fi
|
||||
|
||||
# CRITICAL: Validate CHANGELOG.md has entry for this version BEFORE creating tag
|
||||
- name: Validate and extract changelog
|
||||
if: steps.check.outputs.should_release == 'true'
|
||||
id: changelog
|
||||
run: |
|
||||
VERSION="${{ steps.check.outputs.new_version }}"
|
||||
CHANGELOG_FILE="CHANGELOG.md"
|
||||
|
||||
echo "🔍 Validating CHANGELOG.md for version $VERSION..."
|
||||
|
||||
if [ ! -f "$CHANGELOG_FILE" ]; then
|
||||
echo "::error::CHANGELOG.md not found! Please create CHANGELOG.md with release notes."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract changelog section for this version
|
||||
# Looks for "## X.Y.Z" header and captures until next "## " or "---" or end
|
||||
CHANGELOG_CONTENT=$(awk -v ver="$VERSION" '
|
||||
BEGIN { found=0; content="" }
|
||||
/^## / {
|
||||
if (found) exit
|
||||
# Match version at start of header (e.g., "## 2.7.3 -" or "## 2.7.3")
|
||||
if ($2 == ver || $2 ~ "^"ver"[[:space:]]*-") {
|
||||
found=1
|
||||
# Skip the header line itself, we will add our own
|
||||
next
|
||||
}
|
||||
}
|
||||
/^---$/ { if (found) exit }
|
||||
found { content = content $0 "\n" }
|
||||
END {
|
||||
if (!found) {
|
||||
print "NOT_FOUND"
|
||||
exit 1
|
||||
}
|
||||
# Trim leading/trailing whitespace
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", content)
|
||||
print content
|
||||
}
|
||||
' "$CHANGELOG_FILE")
|
||||
|
||||
if [ "$CHANGELOG_CONTENT" = "NOT_FOUND" ] || [ -z "$CHANGELOG_CONTENT" ]; then
|
||||
echo ""
|
||||
echo "::error::═══════════════════════════════════════════════════════════════════════"
|
||||
echo "::error:: CHANGELOG VALIDATION FAILED"
|
||||
echo "::error::═══════════════════════════════════════════════════════════════════════"
|
||||
echo "::error::"
|
||||
echo "::error:: Version $VERSION not found in CHANGELOG.md!"
|
||||
echo "::error::"
|
||||
echo "::error:: Before releasing, please update CHANGELOG.md with an entry like:"
|
||||
echo "::error::"
|
||||
echo "::error:: ## $VERSION - Your Release Title"
|
||||
echo "::error::"
|
||||
echo "::error:: ### ✨ New Features"
|
||||
echo "::error:: - Feature description"
|
||||
echo "::error::"
|
||||
echo "::error:: ### 🐛 Bug Fixes"
|
||||
echo "::error:: - Fix description"
|
||||
echo "::error::"
|
||||
echo "::error::═══════════════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# Also add to job summary for visibility
|
||||
echo "## ❌ Release Blocked: Missing Changelog" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Version **$VERSION** was not found in CHANGELOG.md." >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### How to fix:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "1. Update CHANGELOG.md with release notes for version $VERSION" >> $GITHUB_STEP_SUMMARY
|
||||
echo "2. Commit and push the changes" >> $GITHUB_STEP_SUMMARY
|
||||
echo "3. The release will automatically retry" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Expected format:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`markdown" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## $VERSION - Release Title" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### ✨ New Features" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Feature description" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 🐛 Bug Fixes" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Fix description" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Found changelog entry for version $VERSION"
|
||||
echo ""
|
||||
echo "--- Extracted Release Notes ---"
|
||||
echo "$CHANGELOG_CONTENT"
|
||||
echo "--- End Release Notes ---"
|
||||
|
||||
# Save changelog to file for artifact upload
|
||||
echo "$CHANGELOG_CONTENT" > changelog-extract.md
|
||||
|
||||
# Also save to output (for short changelogs)
|
||||
# Using heredoc for multiline output
|
||||
{
|
||||
echo "content<<CHANGELOG_EOF"
|
||||
echo "$CHANGELOG_CONTENT"
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> $GITHUB_OUTPUT
|
||||
|
||||
echo "changelog_valid=true" >> $GITHUB_OUTPUT
|
||||
|
||||
# Upload changelog as artifact for release.yml to use
|
||||
- name: Upload changelog artifact
|
||||
if: steps.check.outputs.should_release == 'true' && steps.changelog.outputs.changelog_valid == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: changelog-${{ steps.check.outputs.new_version }}
|
||||
path: changelog-extract.md
|
||||
retention-days: 1
|
||||
|
||||
- name: Create and push tag
|
||||
if: steps.check.outputs.should_release == 'true' && steps.changelog.outputs.changelog_valid == 'true'
|
||||
if: steps.check.outputs.should_release == 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.check.outputs.new_version }}"
|
||||
TAG="v$VERSION"
|
||||
@@ -201,19 +85,17 @@ jobs:
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
if [ "${{ steps.check.outputs.should_release }}" = "true" ] && [ "${{ steps.changelog.outputs.changelog_valid }}" = "true" ]; then
|
||||
if [ "${{ steps.check.outputs.should_release }}" = "true" ]; then
|
||||
echo "## 🚀 Release Triggered" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version:** v${{ steps.check.outputs.new_version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "✅ Changelog validated and extracted from CHANGELOG.md" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "The release workflow has been triggered and will:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "1. Build binaries for all platforms" >> $GITHUB_STEP_SUMMARY
|
||||
echo "2. Use changelog from CHANGELOG.md" >> $GITHUB_STEP_SUMMARY
|
||||
echo "2. Generate changelog from PRs" >> $GITHUB_STEP_SUMMARY
|
||||
echo "3. Create GitHub release" >> $GITHUB_STEP_SUMMARY
|
||||
echo "4. Update README with new version" >> $GITHUB_STEP_SUMMARY
|
||||
elif [ "${{ steps.check.outputs.should_release }}" = "false" ]; then
|
||||
else
|
||||
echo "## ⏭️ No Release Needed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Package version:** ${{ steps.package.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
name: Quality Commit Lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Conventional Commits
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Validate PR title
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
const title = pr.title;
|
||||
// Sanitize title for safe markdown interpolation (prevent injection)
|
||||
const sanitizedTitle = title.replace(/`/g, "'").replace(/\[/g, '\\[').replace(/\]/g, '\\]');
|
||||
|
||||
console.log(`::group::PR #${pr.number} - Validating PR title`);
|
||||
console.log(`Title: ${title}`);
|
||||
|
||||
// Conventional Commits pattern for PR title
|
||||
// type(scope)?: description (max 100 chars)
|
||||
// Optional ! for breaking changes: feat!: or feat(scope)!:
|
||||
// Scope allows: letters, numbers, hyphens, underscores, slashes, dots
|
||||
const pattern = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-zA-Z0-9_\-\/\.]+\))?!?: .{1,100}$/;
|
||||
|
||||
const isValid = pattern.test(title);
|
||||
console.log(`Valid: ${isValid}`);
|
||||
console.log('::endgroup::');
|
||||
|
||||
if (!isValid) {
|
||||
// Log helpful error message to console (visible in workflow logs)
|
||||
console.log('');
|
||||
console.log('❌ PR title does not follow Conventional Commits format');
|
||||
console.log('');
|
||||
console.log('Expected format: type(scope): description');
|
||||
console.log('');
|
||||
console.log('Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert');
|
||||
console.log('');
|
||||
console.log('Examples of valid PR titles:');
|
||||
console.log(' ✓ feat(auth): add OAuth2 login support');
|
||||
console.log(' ✓ fix(api): handle null response correctly');
|
||||
console.log(' ✓ docs: update README installation steps');
|
||||
console.log(' ✓ chore: update dependencies');
|
||||
console.log('');
|
||||
console.log(`Your title: "${title}"`);
|
||||
console.log('');
|
||||
console.log('Suggested fix for your title:');
|
||||
// Try to suggest a fix based on the title
|
||||
const lowerTitle = title.toLowerCase();
|
||||
const placeholder = '[add description here]';
|
||||
if (lowerTitle.includes('fix') || lowerTitle.includes('bug')) {
|
||||
const cleaned = title.replace(/^(fix(ed|es|ing)?|bug)[:\s]*/i, '').trim();
|
||||
console.log(` → fix: ${cleaned || placeholder}`);
|
||||
} else if (lowerTitle.includes('add') || lowerTitle.includes('new') || lowerTitle.includes('feature')) {
|
||||
const cleaned = title.replace(/^(add(ed|s|ing)?|new|features?)[:\s]*/i, '').trim();
|
||||
console.log(` → feat: ${cleaned || placeholder}`);
|
||||
} else if (lowerTitle.includes('update') || lowerTitle.includes('change')) {
|
||||
const cleaned = title.replace(/^(update[ds]?|chang(ed|es|ing)?)[:\s]*/i, '').trim();
|
||||
console.log(` → chore: ${cleaned || placeholder}`);
|
||||
} else if (lowerTitle.includes('doc') || lowerTitle.includes('readme')) {
|
||||
const cleaned = title.replace(/^(docs?|readme)[:\s]*/i, '').trim();
|
||||
console.log(` → docs: ${cleaned || placeholder}`);
|
||||
} else {
|
||||
console.log(` → feat: ${title}`);
|
||||
console.log(` → fix: ${title}`);
|
||||
console.log(` → chore: ${title}`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
let errorMsg = '## ❌ PR Title Validation Failed\n\n';
|
||||
errorMsg += `Your PR title does not follow [Conventional Commits](https://www.conventionalcommits.org/) format:\n\n`;
|
||||
errorMsg += `> \`${sanitizedTitle}\`\n\n`;
|
||||
errorMsg += '### Expected Format\n\n';
|
||||
errorMsg += '```\ntype(scope): description\n```\n\n';
|
||||
errorMsg += '| Type | Description |\n';
|
||||
errorMsg += '|------|-------------|\n';
|
||||
errorMsg += '| `feat` | New feature |\n';
|
||||
errorMsg += '| `fix` | Bug fix |\n';
|
||||
errorMsg += '| `docs` | Documentation only |\n';
|
||||
errorMsg += '| `style` | Code style (formatting, etc.) |\n';
|
||||
errorMsg += '| `refactor` | Code refactoring |\n';
|
||||
errorMsg += '| `perf` | Performance improvement |\n';
|
||||
errorMsg += '| `test` | Adding/updating tests |\n';
|
||||
errorMsg += '| `build` | Build system changes |\n';
|
||||
errorMsg += '| `ci` | CI/CD changes |\n';
|
||||
errorMsg += '| `chore` | Maintenance tasks |\n';
|
||||
errorMsg += '| `revert` | Reverting changes |\n\n';
|
||||
errorMsg += '### Examples\n\n';
|
||||
errorMsg += '```\nfeat(auth): add OAuth2 login support\nfix(api/users): handle null response correctly\nfix(package.json): update dependencies\ndocs: update README installation steps\nci: add automated release workflow\n```\n\n';
|
||||
errorMsg += '### How to Fix\n\n';
|
||||
errorMsg += 'Edit your PR title to follow the format above.\n';
|
||||
|
||||
core.summary.addRaw(errorMsg);
|
||||
await core.summary.write();
|
||||
|
||||
core.setFailed('PR title does not follow Conventional Commits format');
|
||||
} else {
|
||||
console.log(`✅ PR title follows Conventional Commits format`);
|
||||
|
||||
core.summary
|
||||
.addHeading('✅ PR Title Valid', 3)
|
||||
.addRaw(`PR title follows Conventional Commits format: \`${sanitizedTitle}\``);
|
||||
await core.summary.write();
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
name: Quality DCO
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: DCO Check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check DCO Sign-off
|
||||
uses: dcoapp/dco-check@v1
|
||||
with:
|
||||
require-signoff: true
|
||||
|
||||
- name: DCO Help on Failure
|
||||
if: failure()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const helpMsg = `## ❌ DCO Sign-off Required
|
||||
|
||||
This project requires all commits to be signed off with the Developer Certificate of Origin (DCO).
|
||||
|
||||
### How to Fix
|
||||
|
||||
**Option 1: Sign off your last commit**
|
||||
\`\`\`bash
|
||||
git commit --amend --signoff
|
||||
git push --force-with-lease
|
||||
\`\`\`
|
||||
|
||||
**Option 2: Sign off all commits in this PR**
|
||||
\`\`\`bash
|
||||
git rebase HEAD~N --signoff # Replace N with number of commits
|
||||
git push --force-with-lease
|
||||
\`\`\`
|
||||
|
||||
**Option 3: Configure git to always sign off**
|
||||
\`\`\`bash
|
||||
git config --global format.signoff true
|
||||
\`\`\`
|
||||
|
||||
### What is DCO?
|
||||
|
||||
The [Developer Certificate of Origin](https://developercertificate.org/) is a lightweight way for contributors to certify that they wrote or have the right to submit the code they are contributing.
|
||||
|
||||
By signing off, you agree to the DCO terms:
|
||||
- The contribution was created by you
|
||||
- You have the right to submit it under the project's license
|
||||
- You understand the contribution is public and recorded
|
||||
|
||||
### Sign-off Format
|
||||
|
||||
Your commit message should end with:
|
||||
\`\`\`
|
||||
Signed-off-by: Your Name <your.email@example.com>
|
||||
\`\`\`
|
||||
|
||||
This line is automatically added when you use \`git commit -s\` or \`git commit --signoff\`.
|
||||
`;
|
||||
|
||||
core.summary.addRaw(helpMsg);
|
||||
await core.summary.write();
|
||||
+30
-220
@@ -1,5 +1,4 @@
|
||||
name: Release
|
||||
# Triggers on version tags (v*) to build and publish releases
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -44,24 +43,13 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: cd apps/frontend && npm ci
|
||||
|
||||
- name: Install Rust toolchain (for building native Python packages)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Cache pip wheel cache (for compiled packages like real_ladybug)
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/Library/Caches/pip
|
||||
key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
pip-wheel-${{ runner.os }}-x64-
|
||||
|
||||
- name: Cache bundled Python
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: apps/frontend/python-runtime
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8-rust-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-rust-
|
||||
python-bundle-${{ runner.os }}-x64-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
@@ -132,21 +120,13 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: cd apps/frontend && npm ci
|
||||
|
||||
- name: Cache pip wheel cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/Library/Caches/pip
|
||||
key: pip-wheel-${{ runner.os }}-arm64-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
pip-wheel-${{ runner.os }}-arm64-
|
||||
|
||||
- name: Cache bundled Python
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: apps/frontend/python-runtime
|
||||
key: python-bundle-${{ runner.os }}-arm64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
key: python-bundle-${{ runner.os }}-arm64-3.12.8
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-arm64-3.12.8-
|
||||
python-bundle-${{ runner.os }}-arm64-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
@@ -217,21 +197,13 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: cd apps/frontend && npm ci
|
||||
|
||||
- name: Cache pip wheel cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~\AppData\Local\pip\Cache
|
||||
key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
pip-wheel-${{ runner.os }}-x64-
|
||||
|
||||
- name: Cache bundled Python
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: apps/frontend/python-runtime
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-
|
||||
python-bundle-${{ runner.os }}-x64-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
@@ -278,29 +250,13 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: cd apps/frontend && npm ci
|
||||
|
||||
- name: Setup Flatpak
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y flatpak flatpak-builder
|
||||
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak install -y --user flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
|
||||
flatpak install -y --user flathub org.electronjs.Electron2.BaseApp//25.08
|
||||
|
||||
- name: Cache pip wheel cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
pip-wheel-${{ runner.os }}-x64-
|
||||
|
||||
- name: Cache bundled Python
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: apps/frontend/python-runtime
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-
|
||||
python-bundle-${{ runner.os }}-x64-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
@@ -317,7 +273,6 @@ jobs:
|
||||
path: |
|
||||
apps/frontend/dist/*.AppImage
|
||||
apps/frontend/dist/*.deb
|
||||
apps/frontend/dist/*.flatpak
|
||||
|
||||
create-release:
|
||||
needs: [build-macos-intel, build-macos-arm64, build-windows, build-linux]
|
||||
@@ -337,12 +292,12 @@ jobs:
|
||||
- name: Flatten and validate artifacts
|
||||
run: |
|
||||
mkdir -p release-assets
|
||||
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) -exec cp {} release-assets/ \;
|
||||
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \) -exec cp {} release-assets/ \;
|
||||
|
||||
# Validate that at least one artifact was copied
|
||||
artifact_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) | wc -l)
|
||||
artifact_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \) | wc -l)
|
||||
if [ "$artifact_count" -eq 0 ]; then
|
||||
echo "::error::No build artifacts found! Expected .dmg, .zip, .exe, .AppImage, .deb, or .flatpak files."
|
||||
echo "::error::No build artifacts found! Expected .dmg, .zip, .exe, .AppImage, or .deb files."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -371,7 +326,7 @@ jobs:
|
||||
echo "## VirusTotal Scan Results" > vt_results.md
|
||||
echo "" >> vt_results.md
|
||||
|
||||
for file in release-assets/*.{exe,dmg,AppImage,deb,flatpak}; do
|
||||
for file in release-assets/*.{exe,dmg,AppImage,deb}; do
|
||||
[ -f "$file" ] || continue
|
||||
filename=$(basename "$file")
|
||||
filesize=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file")
|
||||
@@ -506,77 +461,23 @@ jobs:
|
||||
cat release-assets/checksums.sha256 >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Extract changelog from CHANGELOG.md
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
- name: Generate changelog
|
||||
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
|
||||
id: changelog
|
||||
run: |
|
||||
# Extract version from tag (v2.7.2 -> 2.7.2)
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
CHANGELOG_FILE="CHANGELOG.md"
|
||||
|
||||
echo "📋 Extracting release notes for version $VERSION from CHANGELOG.md..."
|
||||
|
||||
if [ ! -f "$CHANGELOG_FILE" ]; then
|
||||
echo "::warning::CHANGELOG.md not found, using minimal release notes"
|
||||
echo "body=Release v$VERSION" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract changelog section for this version
|
||||
# Looks for "## X.Y.Z" header and captures until next "## " or "---"
|
||||
CHANGELOG_CONTENT=$(awk -v ver="$VERSION" '
|
||||
BEGIN { found=0; content="" }
|
||||
/^## / {
|
||||
if (found) exit
|
||||
# Match version at start of header (e.g., "## 2.7.3 -" or "## 2.7.3")
|
||||
if ($2 == ver || $2 ~ "^"ver"[[:space:]]*-") {
|
||||
found=1
|
||||
next
|
||||
}
|
||||
}
|
||||
/^---$/ { if (found) exit }
|
||||
found { content = content $0 "\n" }
|
||||
END {
|
||||
if (!found) {
|
||||
print "NOT_FOUND"
|
||||
exit 0
|
||||
}
|
||||
# Trim leading/trailing whitespace
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", content)
|
||||
print content
|
||||
}
|
||||
' "$CHANGELOG_FILE")
|
||||
|
||||
if [ "$CHANGELOG_CONTENT" = "NOT_FOUND" ] || [ -z "$CHANGELOG_CONTENT" ]; then
|
||||
echo "::warning::Version $VERSION not found in CHANGELOG.md, using minimal release notes"
|
||||
REPO="${{ github.repository }}"
|
||||
CHANGELOG_CONTENT="Release v$VERSION"$'\n\n'"See [CHANGELOG.md](https://github.com/${REPO}/blob/main/CHANGELOG.md) for details."
|
||||
fi
|
||||
|
||||
echo "✅ Extracted changelog content"
|
||||
|
||||
# Save to file first (more reliable for multiline)
|
||||
echo "$CHANGELOG_CONTENT" > changelog-body.md
|
||||
|
||||
# Use file-based output for multiline content
|
||||
{
|
||||
echo "body<<CHANGELOG_EOF"
|
||||
cat changelog-body.md
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> $GITHUB_OUTPUT
|
||||
uses: release-drafter/release-drafter@v6
|
||||
with:
|
||||
config-name: release-drafter.yml
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create Release
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
body: |
|
||||
${{ steps.changelog.outputs.body }}
|
||||
|
||||
---
|
||||
|
||||
${{ steps.virustotal.outputs.vt_results }}
|
||||
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md
|
||||
files: release-assets/*
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.ref, 'beta') || contains(github.ref, 'alpha') }}
|
||||
@@ -587,8 +488,7 @@ jobs:
|
||||
update-readme:
|
||||
needs: [create-release]
|
||||
runs-on: ubuntu-latest
|
||||
# Only update README on actual releases (tag push), not dry runs
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
@@ -597,116 +497,26 @@ jobs:
|
||||
ref: main
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract version and detect release type
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: |
|
||||
# Extract version from tag (v2.7.2 -> 2.7.2)
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# Detect if this is a prerelease (contains - after version, e.g., 2.7.2-beta.10)
|
||||
if [[ "$VERSION" == *-* ]]; then
|
||||
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||
echo "Detected PRERELEASE: $VERSION"
|
||||
else
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
echo "Detected STABLE release: $VERSION"
|
||||
fi
|
||||
echo "Updating README to version: $VERSION"
|
||||
|
||||
- name: Update README.md
|
||||
run: |
|
||||
python3 << 'EOF'
|
||||
import re
|
||||
import sys
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
|
||||
version = "${{ steps.version.outputs.version }}"
|
||||
is_prerelease = "${{ steps.version.outputs.is_prerelease }}" == "true"
|
||||
# Update version badge: version-X.Y.Z-blue
|
||||
sed -i "s/version-[0-9]*\.[0-9]*\.[0-9]*-blue/version-${VERSION}-blue/g" README.md
|
||||
|
||||
# Shields.io escapes hyphens as --
|
||||
version_badge = version.replace("-", "--")
|
||||
# Update download links: Auto-Claude-X.Y.Z
|
||||
sed -i "s/Auto-Claude-[0-9]*\.[0-9]*\.[0-9]*/Auto-Claude-${VERSION}/g" README.md
|
||||
|
||||
# Read README
|
||||
with open("README.md", "r") as f:
|
||||
content = f.read()
|
||||
|
||||
# Semver pattern: matches X.Y.Z or X.Y.Z-prerelease (e.g., 2.7.2, 2.7.2-beta.10)
|
||||
# Prerelease MUST contain a dot (beta.10, alpha.1, rc.1) to avoid matching platform suffixes (win32, darwin)
|
||||
semver = r'\d+\.\d+\.\d+(?:-[a-zA-Z]+\.[a-zA-Z0-9.]+)?'
|
||||
# Shields.io escaped pattern (hyphens as --)
|
||||
semver_badge = r'\d+\.\d+\.\d+(?:--[a-zA-Z]+\.[a-zA-Z0-9.]+)?'
|
||||
|
||||
def update_section(text, start_marker, end_marker, replacements):
|
||||
"""Update content between markers with given replacements."""
|
||||
pattern = f'({re.escape(start_marker)})(.*?)({re.escape(end_marker)})'
|
||||
def replace_section(match):
|
||||
section = match.group(2)
|
||||
for old_pattern, new_value in replacements:
|
||||
section = re.sub(old_pattern, new_value, section)
|
||||
return match.group(1) + section + match.group(3)
|
||||
return re.sub(pattern, replace_section, text, flags=re.DOTALL)
|
||||
|
||||
if is_prerelease:
|
||||
print(f"Updating BETA section to {version} (badge: {version_badge})")
|
||||
|
||||
# Update beta badge
|
||||
content = re.sub(
|
||||
rf'beta-{semver_badge}-orange',
|
||||
f'beta-{version_badge}-orange',
|
||||
content
|
||||
)
|
||||
|
||||
# Update beta version badge link
|
||||
content = update_section(content,
|
||||
'<!-- BETA_VERSION_BADGE -->', '<!-- BETA_VERSION_BADGE_END -->',
|
||||
[(rf'tag/v{semver}\)', f'tag/v{version})')])
|
||||
|
||||
# Update beta downloads
|
||||
content = update_section(content,
|
||||
'<!-- BETA_DOWNLOADS -->', '<!-- BETA_DOWNLOADS_END -->',
|
||||
[
|
||||
(rf'Auto-Claude-{semver}', f'Auto-Claude-{version}'),
|
||||
(rf'download/v{semver}/', f'download/v{version}/'),
|
||||
])
|
||||
else:
|
||||
print(f"Updating STABLE section to {version} (badge: {version_badge})")
|
||||
|
||||
# Update top version badge
|
||||
content = update_section(content,
|
||||
'<!-- TOP_VERSION_BADGE -->', '<!-- TOP_VERSION_BADGE_END -->',
|
||||
[
|
||||
(rf'version-{semver_badge}-blue', f'version-{version_badge}-blue'),
|
||||
(rf'tag/v{semver}\)', f'tag/v{version})'),
|
||||
])
|
||||
|
||||
# Update stable badge
|
||||
content = re.sub(
|
||||
rf'stable-{semver_badge}-blue',
|
||||
f'stable-{version_badge}-blue',
|
||||
content
|
||||
)
|
||||
|
||||
# Update stable version badge link
|
||||
content = update_section(content,
|
||||
'<!-- STABLE_VERSION_BADGE -->', '<!-- STABLE_VERSION_BADGE_END -->',
|
||||
[(rf'tag/v{semver}\)', f'tag/v{version})')])
|
||||
|
||||
# Update stable downloads
|
||||
content = update_section(content,
|
||||
'<!-- STABLE_DOWNLOADS -->', '<!-- STABLE_DOWNLOADS_END -->',
|
||||
[
|
||||
(rf'Auto-Claude-{semver}', f'Auto-Claude-{version}'),
|
||||
(rf'download/v{semver}/', f'download/v{version}/'),
|
||||
])
|
||||
|
||||
# Write updated README
|
||||
with open("README.md", "w") as f:
|
||||
f.write(content)
|
||||
|
||||
print(f"README.md updated for {version} (prerelease={is_prerelease})")
|
||||
EOF
|
||||
|
||||
echo "--- Verifying update ---"
|
||||
grep -E "(stable-|beta-|version-)[0-9]" README.md | head -5
|
||||
echo "README.md updated to version $VERSION"
|
||||
grep -E "(version-|Auto-Claude-)" README.md | head -10
|
||||
|
||||
- name: Commit and push README update
|
||||
run: |
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
with:
|
||||
stale-issue-message: |
|
||||
This issue has been inactive for 60 days. It will be closed in 14 days if there's no activity.
|
||||
|
||||
|
||||
- If this is still relevant, please comment or update the issue
|
||||
- If you're working on this, add the `in-progress` label
|
||||
close-issue-message: 'Closed due to inactivity. Feel free to reopen if still relevant.'
|
||||
|
||||
+4
-12
@@ -1,16 +1,12 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Commit message validation
|
||||
# Enforces conventional commit format: type(scope)!?: description
|
||||
# Enforces conventional commit format: type(scope): description
|
||||
#
|
||||
# Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
|
||||
# Scope allows: letters, numbers, hyphens, underscores, slashes, dots
|
||||
# Optional ! for breaking changes
|
||||
# Examples:
|
||||
# feat(tasks): add drag and drop support
|
||||
# fix(terminal): resolve scroll position issue
|
||||
# feat!: breaking change without scope
|
||||
# feat(api)!: breaking change with scope
|
||||
# docs: update README with setup instructions
|
||||
# chore: update dependencies
|
||||
|
||||
@@ -18,10 +14,8 @@ commit_msg_file=$1
|
||||
commit_msg=$(cat "$commit_msg_file")
|
||||
|
||||
# Regex for conventional commits
|
||||
# Format: type(optional-scope)!?: description
|
||||
# Scope allows: letters, numbers, hyphens, underscores, slashes, dots (consistent with GitHub workflow)
|
||||
# Optional ! for breaking changes: feat!: or feat(scope)!:
|
||||
pattern="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-zA-Z0-9_/.-]+\))?!?: .{1,100}$"
|
||||
# Format: type(optional-scope): description
|
||||
pattern="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9-]+\))?: .{1,100}$"
|
||||
|
||||
# Allow merge commits
|
||||
if echo "$commit_msg" | grep -qE "^Merge "; then
|
||||
@@ -42,7 +36,7 @@ if ! echo "$first_line" | grep -qE "$pattern"; then
|
||||
echo ""
|
||||
echo "Your message: $first_line"
|
||||
echo ""
|
||||
echo "Expected format: type(scope)!?: description"
|
||||
echo "Expected format: type(scope): description"
|
||||
echo ""
|
||||
echo "Valid types:"
|
||||
echo " feat - A new feature"
|
||||
@@ -60,8 +54,6 @@ if ! echo "$first_line" | grep -qE "$pattern"; then
|
||||
echo "Examples:"
|
||||
echo " feat(tasks): add drag and drop support"
|
||||
echo " fix(terminal): resolve scroll position issue"
|
||||
echo " feat!: breaking change without scope"
|
||||
echo " feat(api)!: breaking change with scope"
|
||||
echo " docs: update README"
|
||||
echo " chore: update dependencies"
|
||||
echo ""
|
||||
|
||||
+24
-65
@@ -36,44 +36,12 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
|
||||
echo " Updated apps/backend/__init__.py to $VERSION"
|
||||
fi
|
||||
|
||||
# Sync to README.md - section-aware updates (stable vs beta)
|
||||
# Sync to README.md
|
||||
if [ -f "README.md" ]; then
|
||||
# Escape hyphens for shields.io badge format (shields.io uses -- for literal hyphens)
|
||||
ESCAPED_VERSION=$(echo "$VERSION" | sed 's/-/--/g')
|
||||
|
||||
# Detect if this is a prerelease (contains - after base version, e.g., 2.7.2-beta.10)
|
||||
if echo "$VERSION" | grep -q '-'; then
|
||||
# PRERELEASE: Update only beta sections
|
||||
echo " Detected PRERELEASE version: $VERSION"
|
||||
|
||||
# Update beta version badge (orange)
|
||||
sed -i.bak "s/beta-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-orange/beta-$ESCAPED_VERSION-orange/g" README.md
|
||||
|
||||
# Update beta version badge link (within BETA_VERSION_BADGE section)
|
||||
sed -i.bak '/<!-- BETA_VERSION_BADGE -->/,/<!-- BETA_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
|
||||
|
||||
# Update beta download links (within BETA_DOWNLOADS section only)
|
||||
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb" "linux-x86_64.flatpak"; do
|
||||
sed -i.bak '/<!-- BETA_DOWNLOADS -->/,/<!-- BETA_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
|
||||
done
|
||||
else
|
||||
# STABLE: Update stable sections and top badge
|
||||
echo " Detected STABLE version: $VERSION"
|
||||
|
||||
# Update top version badge (blue) - within TOP_VERSION_BADGE section
|
||||
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s/version-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/version-'"$ESCAPED_VERSION"'-blue/g' README.md
|
||||
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
|
||||
|
||||
# Update stable version badge (blue) - within STABLE_VERSION_BADGE section
|
||||
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s/stable-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/stable-'"$ESCAPED_VERSION"'-blue/g' README.md
|
||||
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
|
||||
|
||||
# Update stable download links (within STABLE_DOWNLOADS section only)
|
||||
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb"; do
|
||||
sed -i.bak '/<!-- STABLE_DOWNLOADS -->/,/<!-- STABLE_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
|
||||
done
|
||||
fi
|
||||
|
||||
# Update version badge
|
||||
sed -i.bak "s/version-[0-9]*\.[0-9]*\.[0-9]*-blue/version-$VERSION-blue/g" README.md
|
||||
# Update download links
|
||||
sed -i.bak "s/Auto-Claude-[0-9]*\.[0-9]*\.[0-9]*/Auto-Claude-$VERSION/g" README.md
|
||||
rm -f README.md.bak
|
||||
git add README.md
|
||||
echo " Updated README.md to $VERSION"
|
||||
@@ -102,25 +70,20 @@ if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
|
||||
fi
|
||||
|
||||
if [ -n "$RUFF" ]; then
|
||||
# Get only staged Python files in apps/backend (process only what's being committed)
|
||||
STAGED_PY_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep "^apps/backend/.*\.py$" || true)
|
||||
|
||||
if [ -n "$STAGED_PY_FILES" ]; then
|
||||
# Run ruff linting (auto-fix) only on staged files
|
||||
echo "Running ruff lint on staged files..."
|
||||
echo "$STAGED_PY_FILES" | xargs $RUFF check --fix
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Ruff lint failed. Please fix Python linting errors before committing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run ruff format (auto-fix) only on staged files
|
||||
echo "Running ruff format on staged files..."
|
||||
echo "$STAGED_PY_FILES" | xargs $RUFF format
|
||||
|
||||
# Re-stage only the files that were originally staged (in case ruff modified them)
|
||||
echo "$STAGED_PY_FILES" | xargs git add
|
||||
# Run ruff linting (auto-fix)
|
||||
echo "Running ruff lint..."
|
||||
$RUFF check apps/backend/ --fix
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Ruff lint failed. Please fix Python linting errors before committing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run ruff format (auto-fix)
|
||||
echo "Running ruff format..."
|
||||
$RUFF format apps/backend/
|
||||
|
||||
# Stage any files that were auto-fixed by ruff (POSIX-compliant)
|
||||
find apps/backend -name "*.py" -type f -exec git add {} + 2>/dev/null || true
|
||||
else
|
||||
echo "Warning: ruff not found, skipping Python linting. Install with: uv pip install ruff"
|
||||
fi
|
||||
@@ -148,7 +111,7 @@ if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
|
||||
exit 1
|
||||
fi
|
||||
cd ../..
|
||||
|
||||
|
||||
echo "Backend checks passed!"
|
||||
fi
|
||||
|
||||
@@ -160,14 +123,10 @@ fi
|
||||
if git diff --cached --name-only | grep -q "^apps/frontend/"; then
|
||||
echo "Frontend changes detected, running frontend checks..."
|
||||
cd apps/frontend
|
||||
|
||||
|
||||
# Run lint-staged (handles staged .ts/.tsx files)
|
||||
npm exec lint-staged
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "lint-staged failed. Please fix linting errors before committing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Run TypeScript type check
|
||||
echo "Running type check..."
|
||||
npm run typecheck
|
||||
@@ -175,7 +134,7 @@ if git diff --cached --name-only | grep -q "^apps/frontend/"; then
|
||||
echo "Type check failed. Please fix TypeScript errors before committing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Run linting
|
||||
echo "Running lint..."
|
||||
npm run lint
|
||||
@@ -183,7 +142,7 @@ if git diff --cached --name-only | grep -q "^apps/frontend/"; then
|
||||
echo "Lint failed. Run 'npm run lint:fix' to auto-fix issues."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
# Check for vulnerabilities (only high severity)
|
||||
echo "Checking for vulnerabilities..."
|
||||
npm audit --audit-level=high
|
||||
@@ -191,7 +150,7 @@ if git diff --cached --name-only | grep -q "^apps/frontend/"; then
|
||||
echo "High severity vulnerabilities found. Run 'npm audit fix' to resolve."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
cd ../..
|
||||
echo "Frontend checks passed!"
|
||||
fi
|
||||
|
||||
+16
-86
@@ -4,74 +4,26 @@ repos:
|
||||
hooks:
|
||||
- id: version-sync
|
||||
name: Version Sync
|
||||
entry: bash
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
if [ -n "$VERSION" ]; then
|
||||
|
||||
# Sync to apps/frontend/package.json
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const p = require('./apps/frontend/package.json');
|
||||
const v = process.argv[1];
|
||||
if (p.version !== v) {
|
||||
p.version = v;
|
||||
fs.writeFileSync('./apps/frontend/package.json', JSON.stringify(p, null, 2) + '\n');
|
||||
}
|
||||
" "$VERSION"
|
||||
|
||||
# Sync to apps/backend/__init__.py
|
||||
sed -i.bak "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" apps/backend/__init__.py && rm -f apps/backend/__init__.py.bak
|
||||
|
||||
# Sync to README.md - section-aware updates (stable vs beta)
|
||||
ESCAPED_VERSION=$(echo "$VERSION" | sed 's/-/--/g')
|
||||
|
||||
# Detect if this is a prerelease (contains - after base version)
|
||||
if echo "$VERSION" | grep -q '-'; then
|
||||
# PRERELEASE: Update only beta sections
|
||||
echo " Detected PRERELEASE version: $VERSION"
|
||||
|
||||
# Update beta version badge (orange)
|
||||
sed -i.bak "s/beta-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-orange/beta-$ESCAPED_VERSION-orange/g" README.md
|
||||
|
||||
# Update beta version badge link
|
||||
sed -i.bak '/<!-- BETA_VERSION_BADGE -->/,/<!-- BETA_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
|
||||
|
||||
# Update beta download links (within BETA_DOWNLOADS section only)
|
||||
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb" "linux-x86_64.flatpak"; do
|
||||
sed -i.bak '/<!-- BETA_DOWNLOADS -->/,/<!-- BETA_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
|
||||
done
|
||||
else
|
||||
# STABLE: Update stable sections and top badge
|
||||
echo " Detected STABLE version: $VERSION"
|
||||
|
||||
# Update top version badge (blue)
|
||||
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s/version-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/version-'"$ESCAPED_VERSION"'-blue/g' README.md
|
||||
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
|
||||
|
||||
# Update stable version badge (blue)
|
||||
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s/stable-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/stable-'"$ESCAPED_VERSION"'-blue/g' README.md
|
||||
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
|
||||
|
||||
# Update stable download links (within STABLE_DOWNLOADS section only)
|
||||
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb"; do
|
||||
sed -i.bak '/<!-- STABLE_DOWNLOADS -->/,/<!-- STABLE_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
|
||||
done
|
||||
fi
|
||||
rm -f README.md.bak
|
||||
|
||||
# Stage changes
|
||||
git add apps/frontend/package.json apps/backend/__init__.py README.md 2>/dev/null || true
|
||||
fi
|
||||
entry: bash -c '
|
||||
VERSION=$(node -p "require(\"./package.json\").version");
|
||||
if [ -n "$VERSION" ]; then
|
||||
# Sync to apps/frontend/package.json
|
||||
node -e "const fs=require(\"fs\");const p=require(\"./apps/frontend/package.json\");if(p.version!==\"$VERSION\"){p.version=\"$VERSION\";fs.writeFileSync(\"./apps/frontend/package.json\",JSON.stringify(p,null,2)+\"\n\");}";
|
||||
# Sync to apps/backend/__init__.py
|
||||
sed -i.bak "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" apps/backend/__init__.py && rm -f apps/backend/__init__.py.bak;
|
||||
# Sync to README.md
|
||||
sed -i.bak "s/version-[0-9]*\.[0-9]*\.[0-9]*-blue/version-$VERSION-blue/g" README.md;
|
||||
sed -i.bak "s/Auto-Claude-[0-9]*\.[0-9]*\.[0-9]*/Auto-Claude-$VERSION/g" README.md && rm -f README.md.bak;
|
||||
git add apps/frontend/package.json apps/backend/__init__.py README.md 2>/dev/null || true;
|
||||
fi
|
||||
'
|
||||
language: system
|
||||
files: ^package\.json$
|
||||
pass_filenames: false
|
||||
|
||||
# Python linting (apps/backend/)
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.14.10
|
||||
rev: v0.8.3
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
@@ -85,29 +37,7 @@ repos:
|
||||
hooks:
|
||||
- id: pytest
|
||||
name: Python Tests
|
||||
entry: bash
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
cd apps/backend
|
||||
if [ -f ".venv/bin/pytest" ]; then
|
||||
PYTEST_CMD=".venv/bin/pytest"
|
||||
elif [ -f ".venv/Scripts/pytest.exe" ]; then
|
||||
PYTEST_CMD=".venv/Scripts/pytest.exe"
|
||||
else
|
||||
PYTEST_CMD="python -m pytest"
|
||||
fi
|
||||
PYTHONPATH=. $PYTEST_CMD \
|
||||
../../tests/ \
|
||||
-v \
|
||||
--tb=short \
|
||||
-x \
|
||||
-m "not slow and not integration" \
|
||||
--ignore=../../tests/test_graphiti.py \
|
||||
--ignore=../../tests/test_merge_file_tracker.py \
|
||||
--ignore=../../tests/test_service_orchestrator.py \
|
||||
--ignore=../../tests/test_worktree.py \
|
||||
--ignore=../../tests/test_workspace.py
|
||||
entry: bash -c 'cd apps/backend && PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" --ignore=../../tests/test_graphiti.py --ignore=../../tests/test_merge_file_tracker.py --ignore=../../tests/test_service_orchestrator.py --ignore=../../tests/test_worktree.py --ignore=../../tests/test_workspace.py'
|
||||
language: system
|
||||
files: ^(apps/backend/.*\.py$|tests/.*\.py$)
|
||||
pass_filenames: false
|
||||
@@ -131,7 +61,7 @@ repos:
|
||||
|
||||
# General checks
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
|
||||
-280
@@ -1,283 +1,3 @@
|
||||
## 2.7.2 - Stability & Performance Enhancements
|
||||
|
||||
### ✨ New Features
|
||||
|
||||
- Added refresh button to Kanban board for manually reloading tasks
|
||||
|
||||
- Terminal dropdown with built-in and external options in task review
|
||||
|
||||
- Centralized CLI tool path management with customizable settings
|
||||
|
||||
- Files tab in task details panel for better file organization
|
||||
|
||||
- Enhanced PR review page with filtering capabilities
|
||||
|
||||
- GitLab integration support
|
||||
|
||||
- Automated PR review with follow-up support and structured outputs
|
||||
|
||||
- UI scale feature with 75-200% range for accessibility
|
||||
|
||||
- Python 3.12 bundled with packaged Electron app
|
||||
|
||||
- OpenRouter support as LLM/embedding provider
|
||||
|
||||
- Internationalization (i18n) system for multi-language support
|
||||
|
||||
- Flatpak packaging support for Linux
|
||||
|
||||
- Path-aware AI merge resolution with device code streaming
|
||||
|
||||
### 🛠️ Improvements
|
||||
|
||||
- Improved terminal experience with persistent state when switching projects
|
||||
|
||||
- Enhanced PR review with structured outputs and fork support
|
||||
|
||||
- Better UX for display and scaling changes
|
||||
|
||||
- Convert synchronous I/O to async operations in worktree handlers
|
||||
|
||||
- Enhanced logs for commit linting stage
|
||||
|
||||
- Remove top navigation bars for cleaner UI
|
||||
|
||||
- Enhanced PR detail area visual design
|
||||
|
||||
- Improved CLI tool detection with more language support
|
||||
|
||||
- Added iOS/Swift project detection
|
||||
|
||||
- Optimize performance by removing projectTabs from useEffect dependencies
|
||||
|
||||
- Improved Python detection and version validation for compatibility
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
- Fixed CI Python setup and PR status gate checks
|
||||
|
||||
- Fixed cross-platform CLI path detection and clearing in settings
|
||||
|
||||
- Preserve original task description after spec creation
|
||||
|
||||
- Fixed learning loop to retrieve patterns and gotchas from memory
|
||||
|
||||
- Resolved frontend lag and updated dependencies
|
||||
|
||||
- Fixed Content-Security-Policy to allow external HTTPS images
|
||||
|
||||
- Fixed PR review isolation by using temporary worktree
|
||||
|
||||
- Fixed Homebrew Python detection to prefer versioned Python over system python3
|
||||
|
||||
- Added support for Bun 1.2.0+ lock file format detection
|
||||
|
||||
- Fixed infinite re-render loop in task selection
|
||||
|
||||
- Fixed infinite loop in task detail merge preview loading
|
||||
|
||||
- Resolved Windows EINVAL error when opening worktree in VS Code
|
||||
|
||||
- Fixed fallback to prevent tasks stuck in ai_review status
|
||||
|
||||
- Fixed SDK permissions to include spec_dir
|
||||
|
||||
- Added --base-branch argument support to spec_runner
|
||||
|
||||
- Allow Windows to run CC PR Reviewer
|
||||
|
||||
- Fixed model selection to respect task_metadata.json
|
||||
|
||||
- Improved GitHub PR review by passing repo parameter explicitly
|
||||
|
||||
- Fixed electron-log imports with .js extension
|
||||
|
||||
- Fixed Swift detection order in project analyzer
|
||||
|
||||
- Prevent TaskEditDialog from unmounting when opened
|
||||
|
||||
- Fixed subprocess handling for Python paths with spaces
|
||||
|
||||
- Fixed file system race conditions and unused variables in security scanning
|
||||
|
||||
- Resolved Python detection and backend packaging issues
|
||||
|
||||
- Fixed version-specific links in README and pre-commit hooks
|
||||
|
||||
- Fixed task status persistence reverting on refresh
|
||||
|
||||
- Proper semver comparison for pre-release versions
|
||||
|
||||
- Use virtual environment Python for all services to fix dotenv errors
|
||||
|
||||
- Fixed explicit Windows System32 tar path for builds
|
||||
|
||||
- Added augmented PATH environment to all GitHub CLI calls
|
||||
|
||||
- Use PowerShell for tar extraction on Windows
|
||||
|
||||
- Added --force-local flag to tar on Windows
|
||||
|
||||
- Stop tracking spec files in git
|
||||
|
||||
- Fixed GitHub API calls with explicit GET method for comment fetches
|
||||
|
||||
- Support archiving tasks across all worktree locations
|
||||
|
||||
- Validated backend source path before using it
|
||||
|
||||
- Resolved spawn Python ENOENT error on Linux
|
||||
|
||||
- Fixed CodeQL alerts for uncontrolled command line
|
||||
|
||||
- Resolved GitHub follow-up review API issues
|
||||
|
||||
- Fixed relative path normalization to POSIX format
|
||||
|
||||
- Accepted bug_fix workflow_type alias during planning
|
||||
|
||||
- Added global spec numbering lock to prevent collisions
|
||||
|
||||
- Fixed ideation status sync
|
||||
|
||||
- Stopped running process when task status changes away from in_progress
|
||||
|
||||
- Removed legacy path from auto-claude source detection
|
||||
|
||||
- Resolved Python environment race condition
|
||||
|
||||
---
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(ci): add Python setup to beta-release and fix PR status gate checks (#565) by @Andy in c2148bb9
|
||||
- fix: detect and clear cross-platform CLI paths in settings (#535) by @Andy in 29e45505
|
||||
- fix(ui): preserve original task description after spec creation (#536) by @Andy in 7990dcb4
|
||||
- fix(memory): fix learning loop to retrieve patterns and gotchas (#530) by @Andy in f58c2578
|
||||
- fix: resolve frontend lag and update dependencies (#526) by @Andy in 30f7951a
|
||||
- feat(kanban): add refresh button to manually reload tasks (#548) by @Adryan Serage in 252242f9
|
||||
- fix(csp): allow external HTTPS images in Content-Security-Policy (#549) by @Michael Ludlow in 3db02c5d
|
||||
- fix(pr-review): use temporary worktree for PR review isolation (#532) by @Andy in 344ec65e
|
||||
- fix: prefer versioned Homebrew Python over system python3 (#494) by @Navid in 8d58dd6f
|
||||
- fix(detection): support bun.lock text format for Bun 1.2.0+ (#525) by @Andy in 4da8cd66
|
||||
- chore: bump version to 2.7.2-beta.12 (#460) by @Andy in 8e5c11ac
|
||||
- Fix/windows issues (#471) by @Andy in 72106109
|
||||
- fix(ci): add Rust toolchain for Intel Mac builds (#459) by @Andy in 52a4fcc6
|
||||
- fix: create spec.md during roadmap-to-task conversion (#446) by @Mulaveesala Pranaveswar in fb6b7fc6
|
||||
- fix(pr-review): treat LOW-only findings as ready to merge (#455) by @Andy in 0f9c5b84
|
||||
- Fix/2.7.2 beta12 (#424) by @Andy in 5d8ede23
|
||||
- feat: remove top bars (#386) by @Vinícius Santos in da31b687
|
||||
- fix: prevent infinite re-render loop in task selection useEffect (#442) by @Abe Diaz in 2effa535
|
||||
- fix: accept Python 3.12+ in install-backend.js (#443) by @Abe Diaz in c15bb311
|
||||
- fix: infinite loop in useTaskDetail merge preview loading (#444) by @Abe Diaz in 203a970a
|
||||
- fix(windows): resolve EINVAL error when opening worktree in VS Code (#434) by @Vinícius Santos in 3c0708b7
|
||||
- feat(frontend): Add Files tab to task details panel (#430) by @Mitsu in 666794b5
|
||||
- refactor: remove deprecated TaskDetailPanel component (#432) by @Mitsu in ac8dfcac
|
||||
- fix(ui): add fallback to prevent tasks stuck in ai_review status (#397) by @Michael Ludlow in 798ca79d
|
||||
- feat: Enhance the look of the PR Detail area (#427) by @Alex in bdb01549
|
||||
- ci: remove conventional commits PR title validation workflow by @AndyMik90 in 515b73b5
|
||||
- fix(client): add spec_dir to SDK permissions (#429) by @Mitsu in 88c76059
|
||||
- fix(spec_runner): add --base-branch argument support (#428) by @Mitsu in 62a75515
|
||||
- feat: enhance pr review page to include PRs filters (#423) by @Alex in 717fba04
|
||||
- feat: add gitlab integration (#254) by @Mitsu in 0a571d3a
|
||||
- fix: Allow windows to run CC PR Reviewer (#406) by @Alex in 2f662469
|
||||
- fix(model): respect task_metadata.json model selection (#415) by @Andy in e7e6b521
|
||||
- feat(build): add Flatpak packaging support for Linux (#404) by @Mitsu in 230de5fc
|
||||
- fix(github): pass repo parameter to GHClient for explicit PR resolution (#413) by @Andy in 4bdf7a0c
|
||||
- chore(ci): remove redundant CLA GitHub Action workflow by @AndyMik90 in a39ea49d
|
||||
- fix(frontend): add .js extension to electron-log/main imports by @AndyMik90 in 9aef0dd0
|
||||
- fix: 2.7.2 bug fixes and improvements (#388) by @Andy in 05131217
|
||||
- fix(analyzer): move Swift detection before Ruby detection (#401) by @Michael Ludlow in 321c9712
|
||||
- fix(ui): prevent TaskEditDialog from unmounting when opened (#395) by @Michael Ludlow in 98b12ed8
|
||||
- fix: improve CLI tool detection and add Claude CLI path settings (#393) by @Joe in aaa83131
|
||||
- feat(analyzer): add iOS/Swift project detection (#389) by @Michael Ludlow in 68548e33
|
||||
- fix(github): improve PR review with structured outputs and fork support (#363) by @Andy in 7751588e
|
||||
- fix(ideation): update progress calculation to include just-completed ideation type (#381) by @Illia Filippov in 8b4ce58c
|
||||
- Fixes failing spec - "gh CLI Check Handler - should return installed: true when gh CLI is found" (#370) by @Ian in bc220645
|
||||
- fix: Memory Status card respects configured embedding provider (#336) (#373) by @Michael Ludlow in db0cbea3
|
||||
- fix: fixed version-specific links in readme and pre-commit hook that updates them (#378) by @Ian in 0ca2e3f6
|
||||
- docs: add security research documentation (#361) by @Brian in 2d3b7fb4
|
||||
- fix/Improving UX for Display/Scaling Changes (#332) by @Kevin Rajan in 9bbdef09
|
||||
- fix(perf): remove projectTabs from useEffect deps to fix re-render loop (#362) by @Michael Ludlow in 753dc8bb
|
||||
- fix(security): invalidate profile cache when file is created/modified (#355) by @Michael Ludlow in 20f20fa3
|
||||
- fix(subprocess): handle Python paths with spaces (#352) by @Michael Ludlow in eabe7c7d
|
||||
- fix: Resolve pre-commit hook failures with version sync, pytest path, ruff version, and broken quality-dco workflow (#334) by @Ian in 1fa7a9c7
|
||||
- fix(terminal): preserve terminal state when switching projects (#358) by @Andy in 7881b2d1
|
||||
- fix(analyzer): add C#/Java/Swift/Kotlin project files to security hash (#351) by @Michael Ludlow in 4e71361b
|
||||
- fix: make backend tests pass on Windows (#282) by @Oluwatosin Oyeladun in 4dcc5afa
|
||||
- fix(ui): close parent modal when Edit dialog opens (#354) by @Michael Ludlow in e9782db0
|
||||
- chore: bump version to 2.7.2-beta.10 by @AndyMik90 in 40d04d7c
|
||||
- feat: add terminal dropdown with inbuilt and external options in task review (#347) by @JoshuaRileyDev in fef07c95
|
||||
- refactor: remove deprecated code across backend and frontend (#348) by @Mitsu in 9d43abed
|
||||
- feat: centralize CLI tool path management (#341) by @HSSAINI Saad in d51f4562
|
||||
- refactor(components): remove deprecated TaskDetailPanel re-export (#344) by @Mitsu in 787667e9
|
||||
- chore: Refactor/kanban realtime status sync (#249) by @souky-byte in 9734b70b
|
||||
- refactor(settings): remove deprecated ProjectSettings modal and hooks (#343) by @Mitsu in fec6b9f3
|
||||
- perf: convert synchronous I/O to async operations in worktree handlers (#337) by @JoshuaRileyDev in d3a63b09
|
||||
- feat: bump version (#329) by @Alex in 50e3111a
|
||||
- fix(ci): remove version bump to fix branch protection conflict (#325) by @Michael Ludlow in 8a80b1d5
|
||||
- fix(tasks): sync status to worktree implementation plan to prevent reset (#243) (#323) by @Alex in cb6b2165
|
||||
- fix(ci): add auto-updater manifest files and version auto-update (#317) by @Michael Ludlow in 661e47c3
|
||||
- fix(project): fix task status persistence reverting on refresh (#246) (#318) by @Michael Ludlow in e80ef79d
|
||||
- fix(updater): proper semver comparison for pre-release versions (#313) by @Michael Ludlow in e1b0f743
|
||||
- fix(python): use venv Python for all services to fix dotenv errors (#311) by @Alex in 92c6f278
|
||||
- chore(ci): cancel in-progress runs (#302) by @Oluwatosin Oyeladun in 1c142273
|
||||
- fix(build): use explicit Windows System32 tar path (#308) by @Andy in c0a02a45
|
||||
- fix(github): add augmented PATH env to all gh CLI calls by @AndyMik90 in 086429cb
|
||||
- fix(build): use PowerShell for tar extraction on Windows by @AndyMik90 in d9fb8f29
|
||||
- fix(build): add --force-local flag to tar on Windows (#303) by @Andy in d0b0b3df
|
||||
- fix: stop tracking spec files in git (#295) by @Andy in 937a60f8
|
||||
- Fix/2.7.2 fixes (#300) by @Andy in 7a51cbd5
|
||||
- feat(merge,oauth): add path-aware AI merge resolution and device code streaming (#296) by @Andy in 26beefe3
|
||||
- feat: enhance the logs for the commit linting stage (#293) by @Alex in 8416f307
|
||||
- fix(github): add explicit GET method to gh api comment fetches (#294) by @Andy in 217249c8
|
||||
- fix(frontend): support archiving tasks across all worktree locations (#286) by @Andy in 8bb3df91
|
||||
- Potential fix for code scanning alert no. 224: Uncontrolled command line (#285) by @Andy in 5106c6e9
|
||||
- fix(frontend): validate backend source path before using it (#287) by @Andy in 3ff61274
|
||||
- feat(python): bundle Python 3.12 with packaged Electron app (#284) by @Andy in 7f19c2e1
|
||||
- fix: resolve spawn python ENOENT error on Linux by using getAugmentedEnv() (#281) by @Todd W. Bucy in d98e2830
|
||||
- fix(ci): add write permissions to beta-release update-version job by @AndyMik90 in 0b874d4b
|
||||
- chore(deps): bump @xterm/xterm from 5.5.0 to 6.0.0 in /apps/frontend (#270) by @dependabot[bot] in 50dd1078
|
||||
- fix(github): resolve follow-up review API issues by @AndyMik90 in f1cc5a09
|
||||
- fix(security): resolve CodeQL file system race conditions and unused variables (#277) by @Andy in b005fa5c
|
||||
- fix(ci): use correct electron-builder arch flags (#278) by @Andy in d79f2da4
|
||||
- chore(deps): bump jsdom from 26.1.0 to 27.3.0 in /apps/frontend (#268) by @dependabot[bot] in 5ac566e2
|
||||
- chore(deps): bump typescript-eslint in /apps/frontend (#269) by @dependabot[bot] in f49d4817
|
||||
- fix(ci): use develop branch for dry-run builds in beta-release workflow (#276) by @Andy in 1e1d7d9b
|
||||
- fix: accept bug_fix workflow_type alias during planning (#240) by @Daniel Frey in e74a3dff
|
||||
- fix(paths): normalize relative paths to posix (#239) by @Daniel Frey in 6ac8250b
|
||||
- chore(deps): bump @electron/rebuild in /apps/frontend (#271) by @dependabot[bot] in a2cee694
|
||||
- chore(deps): bump vitest from 4.0.15 to 4.0.16 in /apps/frontend (#272) by @dependabot[bot] in d4cad80a
|
||||
- feat(github): add automated PR review with follow-up support (#252) by @Andy in 596e9513
|
||||
- ci: implement enterprise-grade PR quality gates and security scanning (#266) by @Alex in d42041c5
|
||||
- fix: update path resolution for ollama_model_detector.py in memory handlers (#263) by @delyethan in a3f87540
|
||||
- feat: add i18n internationalization system (#248) by @Mitsu in f8438112
|
||||
- Revert "Feat/Auto Fix Github issues and do extensive AI PR reviews (#250)" (#251) by @Andy in 5e8c5308
|
||||
- Feat/Auto Fix Github issues and do extensive AI PR reviews (#250) by @Andy in 348de6df
|
||||
- fix: resolve Python detection and backend packaging issues (#241) by @HSSAINI Saad in 0f7d6e05
|
||||
- fix: add future annotations import to discovery.py (#229) by @Joris Slagter in 5ccdb6ab
|
||||
- Fix/ideation status sync (#212) by @souky-byte in 6ec8549f
|
||||
- fix(core): add global spec numbering lock to prevent collisions (#209) by @Andy in 53527293
|
||||
- feat: Add OpenRouter as LLM/embedding provider (#162) by @Fernando Possebon in 02bef954
|
||||
- fix: Add Python 3.10+ version validation and GitHub Actions Python setup (#180 #167) (#208) by @Fernando Possebon in f168bdc3
|
||||
- fix(ci): correct welcome workflow PR message (#206) by @Andy in e3eec68a
|
||||
- Feat/beta release (#193) by @Andy in 407a0bee
|
||||
- feat/beta-release (#190) by @Andy in 8f766ad1
|
||||
- fix/PRs from old main setup to apps structure (#185) by @Andy in ced2ad47
|
||||
- fix: hide status badge when execution phase badge is showing (#154) by @Andy in 05f5d303
|
||||
- feat: Add UI scale feature with 75-200% range (#125) by @Enes Cingöz in 6951251b
|
||||
- fix(task): stop running process when task status changes away from in_progress by @AndyMik90 in 30e7536b
|
||||
- Fix/linear 400 error by @Andy in 220faf0f
|
||||
- fix: remove legacy path from auto-claude source detection (#148) by @Joris Slagter in f96c6301
|
||||
- fix: resolve Python environment race condition (#142) by @Joris Slagter in ebd8340d
|
||||
- Feat: Ollama download progress tracking with new apps structure (#141) by @rayBlock in df779530
|
||||
- Feature/apps restructure v2.7.2 (#138) by @Andy in 0adaddac
|
||||
- docs: Add Git Flow branching strategy to CONTRIBUTING.md by @AndyMik90 in 91f7051d
|
||||
|
||||
## Thanks to all contributors
|
||||
|
||||
@Andy, @Adryan Serage, @Michael Ludlow, @Navid, @Mulaveesala Pranaveswar, @Vinícius Santos, @Abe Diaz, @Mitsu, @Alex, @AndyMik90, @Joe, @Illia Filippov, @Ian, @Brian, @Kevin Rajan, @Oluwatosin Oyeladun, @JoshuaRileyDev, @HSSAINI Saad, @souky-byte, @Todd W. Bucy, @dependabot[bot], @Daniel Frey, @delyethan, @Joris Slagter, @Fernando Possebon, @Enes Cingöz, @rayBlock
|
||||
|
||||
## 2.7.1 - Build Pipeline Enhancements
|
||||
|
||||
### 🛠️ Improvements
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
# Auto Claude Individual Contributor License Agreement
|
||||
|
||||
Thank you for your interest in contributing to Auto Claude. This Contributor License Agreement ("Agreement") documents the rights granted by contributors to the Project.
|
||||
|
||||
By signing this Agreement, you accept and agree to the following terms and conditions for your present and future Contributions submitted to the Project.
|
||||
|
||||
## 1. Definitions
|
||||
|
||||
**"You" (or "Your")** means the individual who submits a Contribution to the Project.
|
||||
|
||||
**"Contribution"** means any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to the Project for inclusion in, or documentation of, the Project. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Project or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Project for the purpose of discussing and improving the Project.
|
||||
|
||||
**"Project"** means Auto Claude, a multi-agent autonomous coding framework, currently available at https://github.com/AndyMik90/Auto-Claude.
|
||||
|
||||
**"Project Owner"** means Andre Mikalsen and any designated successors or assignees.
|
||||
|
||||
## 2. Grant of Copyright License
|
||||
|
||||
Subject to the terms and conditions of this Agreement, You hereby grant to the Project Owner and to recipients of software distributed by the Project Owner a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to:
|
||||
|
||||
- Reproduce, prepare derivative works of, publicly display, publicly perform, and distribute Your Contributions and such derivative works
|
||||
- Sublicense any or all of the foregoing rights to third parties
|
||||
|
||||
## 3. Grant of Patent License
|
||||
|
||||
Subject to the terms and conditions of this Agreement, You hereby grant to the Project Owner and to recipients of software distributed by the Project Owner a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer Your Contributions, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Project to which such Contribution(s) was submitted.
|
||||
|
||||
## 4. Future Licensing Flexibility
|
||||
|
||||
You understand and agree that the Project Owner may, in the future, license the Project, including Your Contributions, under additional licenses beyond the current GNU Affero General Public License version 3.0 (AGPL-3.0). Such additional licenses may include commercial or enterprise licenses.
|
||||
|
||||
This provision ensures the Project has proper licensing flexibility should such licensing options be introduced in the future. The open source version of the Project will continue to be available under AGPL-3.0.
|
||||
|
||||
## 5. Representations
|
||||
|
||||
You represent that:
|
||||
|
||||
(a) You are legally entitled to grant the above licenses. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, or that your employer has waived such rights for your Contributions to the Project.
|
||||
|
||||
(b) Each of Your Contributions is Your original creation. You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions.
|
||||
|
||||
(c) Your Contribution does not violate any third-party rights, including but not limited to intellectual property rights, privacy rights, or contractual obligations.
|
||||
|
||||
## 6. Support and Warranty Disclaimer
|
||||
|
||||
You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all.
|
||||
|
||||
UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING, YOU PROVIDE YOUR CONTRIBUTIONS ON AN "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.
|
||||
|
||||
## 7. No Obligation to Use
|
||||
|
||||
You understand that the decision to include Your Contribution in any project or source repository is entirely at the discretion of the Project Owner, and this Agreement does not guarantee that Your Contributions will be included in any product.
|
||||
|
||||
## 8. Contributor Rights
|
||||
|
||||
You retain full copyright ownership of Your Contributions. Nothing in this Agreement shall be interpreted to prohibit you from licensing Your Contributions under different terms to third parties or from using Your Contributions for any other purpose.
|
||||
|
||||
## 9. Notification
|
||||
|
||||
You agree to notify the Project Owner of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect.
|
||||
|
||||
---
|
||||
|
||||
## How to Sign
|
||||
|
||||
To sign this CLA, comment on your Pull Request with:
|
||||
|
||||
```
|
||||
I have read the CLA Document and I hereby sign the CLA
|
||||
```
|
||||
|
||||
Your signature will be recorded automatically.
|
||||
|
||||
---
|
||||
|
||||
*This CLA is based on the Apache Software Foundation Individual Contributor License Agreement v2.0.*
|
||||
@@ -248,23 +248,6 @@ main (user's branch)
|
||||
4. User runs `--merge` to add to their project
|
||||
5. User pushes to remote when ready
|
||||
|
||||
### Contributing to Upstream
|
||||
|
||||
**CRITICAL: When submitting PRs to AndyMik90/Auto-Claude, always target the `develop` branch, NOT `main`.**
|
||||
|
||||
**Correct workflow for contributions:**
|
||||
1. Fetch upstream: `git fetch upstream`
|
||||
2. Create feature branch from upstream/develop: `git checkout -b fix/my-fix upstream/develop`
|
||||
3. Make changes and commit with sign-off: `git commit -s -m "fix: description"`
|
||||
4. Push to your fork: `git push origin fix/my-fix`
|
||||
5. Create PR targeting `develop`: `gh pr create --repo AndyMik90/Auto-Claude --base develop`
|
||||
|
||||
**Verify before PR:**
|
||||
```bash
|
||||
# Ensure only your commits are included
|
||||
git log --oneline upstream/develop..HEAD
|
||||
```
|
||||
|
||||
### Security Model
|
||||
|
||||
Three-layer defense:
|
||||
|
||||
-107
@@ -4,7 +4,6 @@ Thank you for your interest in contributing to Auto Claude! This document provid
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Contributor License Agreement (CLA)](#contributor-license-agreement-cla)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Development Setup](#development-setup)
|
||||
@@ -24,30 +23,10 @@ Thank you for your interest in contributing to Auto Claude! This document provid
|
||||
- [Pull Request Targets](#pull-request-targets)
|
||||
- [Release Process](#release-process-maintainers)
|
||||
- [Commit Messages](#commit-messages)
|
||||
- [PR Hygiene](#pr-hygiene)
|
||||
- [Pull Request Process](#pull-request-process)
|
||||
- [Issue Reporting](#issue-reporting)
|
||||
- [Architecture Overview](#architecture-overview)
|
||||
|
||||
## Contributor License Agreement (CLA)
|
||||
|
||||
All contributors must sign our Contributor License Agreement (CLA) before contributions can be accepted.
|
||||
|
||||
### Why We Require a CLA
|
||||
|
||||
Auto Claude is currently licensed under AGPL-3.0. The CLA ensures the project has proper licensing flexibility should we introduce additional licensing options (such as commercial/enterprise licenses) in the future.
|
||||
|
||||
You retain full copyright ownership of your contributions.
|
||||
|
||||
### How to Sign
|
||||
|
||||
1. Open a Pull Request
|
||||
2. The CLA bot will automatically comment with instructions
|
||||
3. Comment on the PR with: `I have read the CLA Document and I hereby sign the CLA`
|
||||
4. Done - you only need to sign once, and it applies to all future contributions
|
||||
|
||||
Read the full CLA here: [CLA.md](CLA.md)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before contributing, ensure you have the following installed:
|
||||
@@ -56,7 +35,6 @@ Before contributing, ensure you have the following installed:
|
||||
- **Node.js 24+** - For the Electron frontend
|
||||
- **npm 10+** - Package manager for the frontend (comes with Node.js)
|
||||
- **uv** (recommended) or **pip** - Python package manager
|
||||
- **CMake** - Required for building native dependencies (e.g., LadybugDB)
|
||||
- **Git** - Version control
|
||||
|
||||
### Installing Python 3.12
|
||||
@@ -76,56 +54,6 @@ brew install python@3.12
|
||||
sudo apt install python3.12 python3.12-venv
|
||||
```
|
||||
|
||||
**Linux (Fedora):**
|
||||
```bash
|
||||
sudo dnf install python3.12
|
||||
```
|
||||
|
||||
### Installing Node.js 24+
|
||||
|
||||
**Windows:**
|
||||
```bash
|
||||
winget install OpenJS.NodeJS.LTS
|
||||
```
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
brew install node@24
|
||||
```
|
||||
|
||||
**Linux (Ubuntu/Debian):**
|
||||
```bash
|
||||
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
|
||||
sudo apt install -y nodejs
|
||||
```
|
||||
|
||||
**Linux (Fedora):**
|
||||
```bash
|
||||
sudo dnf install nodejs npm
|
||||
```
|
||||
|
||||
### Installing CMake
|
||||
|
||||
**Windows:**
|
||||
```bash
|
||||
winget install Kitware.CMake
|
||||
```
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
brew install cmake
|
||||
```
|
||||
|
||||
**Linux (Ubuntu/Debian):**
|
||||
```bash
|
||||
sudo apt install cmake
|
||||
```
|
||||
|
||||
**Linux (Fedora):**
|
||||
```bash
|
||||
sudo dnf install cmake
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
The fastest way to get started:
|
||||
@@ -668,41 +596,6 @@ git commit -m "WIP"
|
||||
- **body**: Detailed explanation if needed (wrap at 72 chars)
|
||||
- **footer**: Reference issues, breaking changes
|
||||
|
||||
### PR Hygiene
|
||||
|
||||
**Rebasing:**
|
||||
- **Rebase onto develop** before opening a PR and before merge to maintain linear history
|
||||
- Use `git fetch origin && git rebase origin/develop` to sync your branch
|
||||
- Use `--force-with-lease` when force-pushing rebased branches (safer than `--force`)
|
||||
- Notify reviewers after force-pushing during active review
|
||||
- **Exception:** Never rebase after PR is approved and others have reviewed specific commits
|
||||
|
||||
**Commit organization:**
|
||||
- **Squash fixup commits** (typos, "oops", review feedback) into their parent commits
|
||||
- **Keep logically distinct changes** as separate commits that could be reverted independently
|
||||
- Each commit should compile and pass tests independently
|
||||
- No "WIP", "fix tests", or "lint" commits in final PR - squash these
|
||||
|
||||
**Before requesting review:**
|
||||
```bash
|
||||
# Ensure up-to-date with develop
|
||||
git fetch origin && git rebase origin/develop
|
||||
|
||||
# Clean up commit history (squash fixups, reword messages)
|
||||
git rebase -i origin/develop
|
||||
|
||||
# Force push with safety check
|
||||
git push --force-with-lease
|
||||
|
||||
# Verify everything works
|
||||
npm run test:backend
|
||||
cd apps/frontend && npm test && npm run lint && npm run typecheck
|
||||
```
|
||||
|
||||
**PR size:**
|
||||
- Keep PRs small (<400 lines changed ideally)
|
||||
- Split large features into stacked PRs if possible
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. **Fork the repository** and create your branch from `develop` (not main!)
|
||||
|
||||
@@ -4,9 +4,7 @@
|
||||
|
||||

|
||||
|
||||
<!-- TOP_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.2)
|
||||
<!-- TOP_VERSION_BADGE_END -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/latest)
|
||||
[](./agpl-3.0.txt)
|
||||
[](https://discord.gg/KCXaPBr4Dj)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/actions)
|
||||
@@ -15,40 +13,15 @@
|
||||
|
||||
## Download
|
||||
|
||||
### Stable Release
|
||||
Get the latest pre-built release for your platform:
|
||||
|
||||
<!-- STABLE_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.2)
|
||||
<!-- STABLE_VERSION_BADGE_END -->
|
||||
|
||||
<!-- STABLE_DOWNLOADS -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **Windows** | [Auto-Claude-2.7.1-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.1-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.1-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.1-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.1-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-linux-amd64.deb) |
|
||||
<!-- STABLE_DOWNLOADS_END -->
|
||||
|
||||
### Beta Release
|
||||
|
||||
> ⚠️ Beta releases may contain bugs and breaking changes. [View all releases](https://github.com/AndyMik90/Auto-Claude/releases)
|
||||
|
||||
<!-- BETA_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.2-beta.10)
|
||||
<!-- BETA_VERSION_BADGE_END -->
|
||||
|
||||
<!-- BETA_DOWNLOADS -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **Windows** | [Auto-Claude-2.7.2-beta.10-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.2-beta.10-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.2-beta.10-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.2-beta.10-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.2-beta.10-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.2-beta.10-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-linux-x86_64.flatpak) |
|
||||
<!-- BETA_DOWNLOADS_END -->
|
||||
| Platform | Download | Notes |
|
||||
|----------|----------|-------|
|
||||
| **Windows** | [Auto-Claude-2.7.2.exe](https://github.com/AndyMik90/Auto-Claude/releases/latest) | Installer (NSIS) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.2-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/latest) | M1/M2/M3 Macs |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.2-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/latest) | Intel Macs |
|
||||
| **Linux** | [Auto-Claude-2.7.2.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/latest) | Universal |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.2.deb](https://github.com/AndyMik90/Auto-Claude/releases/latest) | Ubuntu/Debian |
|
||||
|
||||
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
|
||||
|
||||
@@ -83,8 +56,6 @@
|
||||
| **Self-Validating QA** | Built-in quality assurance loop catches issues before you review |
|
||||
| **AI-Powered Merge** | Automatic conflict resolution when integrating back to main |
|
||||
| **Memory Layer** | Agents retain insights across sessions for smarter builds |
|
||||
| **GitHub/GitLab Integration** | Import issues, investigate with AI, create merge requests |
|
||||
| **Linear Integration** | Sync tasks with Linear for team progress tracking |
|
||||
| **Cross-Platform** | Native desktop apps for Windows, macOS, and Linux |
|
||||
| **Auto-Updates** | App updates automatically when new versions are released |
|
||||
|
||||
@@ -161,9 +132,6 @@ cp apps/backend/.env.example apps/backend/.env
|
||||
| `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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -191,71 +159,8 @@ npm start
|
||||
- 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/`.
|
||||
|
||||
---
|
||||
|
||||
## Security
|
||||
@@ -284,7 +189,6 @@ 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 lint` | Run linter |
|
||||
| `npm test` | Run frontend tests |
|
||||
| `npm run test:backend` | Run backend tests |
|
||||
|
||||
+23
-90
@@ -69,38 +69,9 @@ This will:
|
||||
- Update `apps/frontend/package.json`
|
||||
- Update `package.json` (root)
|
||||
- Update `apps/backend/__init__.py`
|
||||
- Check if `CHANGELOG.md` has an entry for the new version (warns if missing)
|
||||
- Create a commit with message `chore: bump version to X.Y.Z`
|
||||
|
||||
### Step 2: Update CHANGELOG.md (REQUIRED)
|
||||
|
||||
**IMPORTANT: The release will fail if CHANGELOG.md doesn't have an entry for the new version.**
|
||||
|
||||
Add release notes to `CHANGELOG.md` at the top of the file:
|
||||
|
||||
```markdown
|
||||
## 2.8.0 - Your Release Title
|
||||
|
||||
### ✨ New Features
|
||||
- Feature description
|
||||
|
||||
### 🛠️ Improvements
|
||||
- Improvement description
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- Fix description
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
Then amend the version bump commit:
|
||||
|
||||
```bash
|
||||
git add CHANGELOG.md
|
||||
git commit --amend --no-edit
|
||||
```
|
||||
|
||||
### Step 3: Push and Create PR
|
||||
### Step 2: Push and Create PR
|
||||
|
||||
```bash
|
||||
# Push your branch
|
||||
@@ -110,25 +81,24 @@ git push origin your-branch
|
||||
gh pr create --base main --title "Release v2.8.0"
|
||||
```
|
||||
|
||||
### Step 4: Merge to Main
|
||||
### Step 3: Merge to Main
|
||||
|
||||
Once the PR is approved and merged to `main`, GitHub Actions will automatically:
|
||||
|
||||
1. **Detect the version bump** (`prepare-release.yml`)
|
||||
2. **Validate CHANGELOG.md** has an entry for the new version (FAILS if missing)
|
||||
3. **Extract release notes** from CHANGELOG.md
|
||||
4. **Create a git tag** (e.g., `v2.8.0`)
|
||||
5. **Trigger the release workflow** (`release.yml`)
|
||||
6. **Build binaries** for all platforms:
|
||||
2. **Create a git tag** (e.g., `v2.8.0`)
|
||||
3. **Trigger the release workflow** (`release.yml`)
|
||||
4. **Build binaries** for all platforms:
|
||||
- macOS Intel (x64) - code signed & notarized
|
||||
- macOS Apple Silicon (arm64) - code signed & notarized
|
||||
- Windows (NSIS installer) - code signed
|
||||
- Linux (AppImage + .deb)
|
||||
7. **Scan binaries** with VirusTotal
|
||||
8. **Create GitHub release** with release notes from CHANGELOG.md
|
||||
9. **Update README** with new version badge and download links
|
||||
5. **Generate changelog** from merged PRs (using release-drafter)
|
||||
6. **Scan binaries** with VirusTotal
|
||||
7. **Create GitHub release** with all artifacts
|
||||
8. **Update README** with new version badge and download links
|
||||
|
||||
### Step 5: Verify
|
||||
### Step 4: Verify
|
||||
|
||||
After merging, check:
|
||||
- [GitHub Actions](https://github.com/AndyMik90/Auto-Claude/actions) - ensure all workflows pass
|
||||
@@ -143,49 +113,28 @@ We follow [Semantic Versioning](https://semver.org/):
|
||||
- **MINOR** (0.X.0): New features, backwards compatible
|
||||
- **PATCH** (0.0.X): Bug fixes, backwards compatible
|
||||
|
||||
## Changelog Management
|
||||
## Changelog Generation
|
||||
|
||||
Release notes are managed in `CHANGELOG.md` and used for GitHub releases.
|
||||
Changelogs are automatically generated from merged PRs using [Release Drafter](https://github.com/release-drafter/release-drafter).
|
||||
|
||||
### Changelog Format
|
||||
### PR Labels for Changelog Categories
|
||||
|
||||
Each version entry in `CHANGELOG.md` should follow this format:
|
||||
| Label | Category |
|
||||
|-------|----------|
|
||||
| `feature`, `enhancement` | New Features |
|
||||
| `bug`, `fix` | Bug Fixes |
|
||||
| `improvement`, `refactor` | Improvements |
|
||||
| `documentation` | Documentation |
|
||||
| (any other) | Other Changes |
|
||||
|
||||
```markdown
|
||||
## X.Y.Z - Release Title
|
||||
|
||||
### ✨ New Features
|
||||
- Feature description with context
|
||||
|
||||
### 🛠️ Improvements
|
||||
- Improvement description
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- Fix description
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
### Changelog Validation
|
||||
|
||||
The release workflow **validates** that `CHANGELOG.md` has an entry for the version being released:
|
||||
|
||||
- If the entry is **missing**, the release is **blocked** with a clear error message
|
||||
- If the entry **exists**, its content is used for the GitHub release notes
|
||||
|
||||
### Writing Good Release Notes
|
||||
|
||||
- **Be specific**: Instead of "Fixed bug", write "Fixed crash when opening large files"
|
||||
- **Group by impact**: Features first, then improvements, then fixes
|
||||
- **Credit contributors**: Mention contributors for significant changes
|
||||
- **Link issues**: Reference GitHub issues where relevant (e.g., "Fixes #123")
|
||||
**Tip:** Add appropriate labels to your PRs for better changelog organization.
|
||||
|
||||
## Workflows
|
||||
|
||||
| Workflow | Trigger | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `prepare-release.yml` | Push to `main` | Detects version bump, **validates CHANGELOG.md**, creates tag |
|
||||
| `release.yml` | Tag `v*` pushed | Builds binaries, extracts changelog, creates release |
|
||||
| `prepare-release.yml` | Push to `main` | Detects version bump, creates tag |
|
||||
| `release.yml` | Tag `v*` pushed | Builds binaries, creates release |
|
||||
| `validate-version.yml` | Tag `v*` pushed | Validates tag matches package.json |
|
||||
| `update-readme` (in release.yml) | After release | Updates README with new version |
|
||||
|
||||
@@ -204,22 +153,6 @@ The release workflow **validates** that `CHANGELOG.md` has an entry for the vers
|
||||
git diff HEAD~1 --name-only | grep package.json
|
||||
```
|
||||
|
||||
### Release blocked: Missing changelog entry
|
||||
|
||||
If you see "CHANGELOG VALIDATION FAILED" in the workflow:
|
||||
|
||||
1. The `prepare-release.yml` workflow validated that `CHANGELOG.md` doesn't have an entry for the new version
|
||||
2. **Fix**: Add an entry to `CHANGELOG.md` with the format `## X.Y.Z - Title`
|
||||
3. Commit and push the changelog update
|
||||
4. The workflow will automatically retry when the changes are pushed to `main`
|
||||
|
||||
```bash
|
||||
# Add changelog entry, then:
|
||||
git add CHANGELOG.md
|
||||
git commit -m "docs: add changelog for vX.Y.Z"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
### Build failed after tag was created
|
||||
|
||||
- The release won't be published if builds fail
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
# Auto Claude uses Claude Code OAuth authentication.
|
||||
# Direct API keys (ANTHROPIC_API_KEY) are NOT supported to prevent silent billing.
|
||||
#
|
||||
# Option 1: Run `claude setup-token` to save token to system keychain (recommended)
|
||||
# (macOS: Keychain, Windows: Credential Manager, Linux: secret-service)
|
||||
# Option 1: Run `claude setup-token` to save token to macOS Keychain (recommended)
|
||||
# Option 2: Set the token explicitly:
|
||||
# CLAUDE_CODE_OAUTH_TOKEN=your-oauth-token-here
|
||||
#
|
||||
@@ -76,38 +75,6 @@
|
||||
# Pre-configured Project ID (OPTIONAL - will create project if not set)
|
||||
# LINEAR_PROJECT_ID=
|
||||
|
||||
# =============================================================================
|
||||
# GITLAB INTEGRATION (OPTIONAL)
|
||||
# =============================================================================
|
||||
# Enable GitLab integration for issue tracking and merge requests.
|
||||
# Supports both GitLab.com and self-hosted GitLab instances.
|
||||
#
|
||||
# Authentication Options (choose one):
|
||||
#
|
||||
# Option 1: glab CLI OAuth (Recommended)
|
||||
# Install glab CLI: https://gitlab.com/gitlab-org/cli#installation
|
||||
# Then run: glab auth login
|
||||
# This opens your browser for OAuth authentication. Once complete,
|
||||
# Auto Claude will automatically use your glab credentials (no env vars needed).
|
||||
# For self-hosted: glab auth login --hostname gitlab.example.com
|
||||
#
|
||||
# Option 2: Personal Access Token
|
||||
# Set GITLAB_TOKEN below. Token auth is used if set, otherwise falls back to glab CLI.
|
||||
|
||||
# GitLab Instance URL (OPTIONAL - defaults to gitlab.com)
|
||||
# For self-hosted: GITLAB_INSTANCE_URL=https://gitlab.example.com
|
||||
# GITLAB_INSTANCE_URL=https://gitlab.com
|
||||
|
||||
# GitLab Personal Access Token (OPTIONAL - only needed if not using glab CLI)
|
||||
# Required scope: api (covers issues, merge requests, releases, project info)
|
||||
# Optional scope: write_repository (only if creating new GitLab projects from local repos)
|
||||
# Get from: https://gitlab.com/-/user_settings/personal_access_tokens
|
||||
# GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# GitLab Project (OPTIONAL - format: group/project or numeric ID)
|
||||
# If not set, will auto-detect from git remote
|
||||
# GITLAB_PROJECT=mygroup/myproject
|
||||
|
||||
# =============================================================================
|
||||
# UI SETTINGS (OPTIONAL)
|
||||
# =============================================================================
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Custom MCP Tools for Auto-Claude Agents
|
||||
========================================
|
||||
|
||||
DEPRECATED: This module is now a compatibility shim.
|
||||
Please import from the tools_pkg package instead:
|
||||
|
||||
from agents.tools_pkg import create_auto_claude_mcp_server, get_allowed_tools
|
||||
|
||||
This file remains for backward compatibility with existing imports.
|
||||
All functionality has been moved to the tools_pkg package for better
|
||||
organization and maintainability.
|
||||
"""
|
||||
|
||||
# Import everything from the package to maintain backward compatibility
|
||||
# Use try/except to handle both relative and absolute imports
|
||||
try:
|
||||
from .tools_pkg import (
|
||||
ELECTRON_TOOLS,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
create_auto_claude_mcp_server,
|
||||
get_allowed_tools,
|
||||
is_electron_mcp_enabled,
|
||||
is_tools_available,
|
||||
)
|
||||
except ImportError:
|
||||
# Fallback for direct execution - import from tools_pkg directly
|
||||
from tools_pkg import (
|
||||
ELECTRON_TOOLS,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
create_auto_claude_mcp_server,
|
||||
get_allowed_tools,
|
||||
is_electron_mcp_enabled,
|
||||
is_tools_available,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Main API
|
||||
"create_auto_claude_mcp_server",
|
||||
"get_allowed_tools",
|
||||
"is_tools_available",
|
||||
# Tool name constants
|
||||
"TOOL_UPDATE_SUBTASK_STATUS",
|
||||
"TOOL_GET_BUILD_PROGRESS",
|
||||
"TOOL_RECORD_DISCOVERY",
|
||||
"TOOL_RECORD_GOTCHA",
|
||||
"TOOL_GET_SESSION_CONTEXT",
|
||||
"TOOL_UPDATE_QA_STATUS",
|
||||
# Electron MCP
|
||||
"ELECTRON_TOOLS",
|
||||
"is_electron_mcp_enabled",
|
||||
]
|
||||
@@ -18,7 +18,6 @@ from linear_updater import (
|
||||
linear_task_stuck,
|
||||
)
|
||||
from phase_config import get_phase_model, get_phase_thinking_budget
|
||||
from phase_event import ExecutionPhase, emit_phase
|
||||
from progress import (
|
||||
count_subtasks,
|
||||
count_subtasks_detailed,
|
||||
@@ -147,7 +146,6 @@ async def run_autonomous_agent(
|
||||
|
||||
# Update status for planning phase
|
||||
status_manager.update(state=BuildState.PLANNING)
|
||||
emit_phase(ExecutionPhase.PLANNING, "Creating implementation plan")
|
||||
is_planning_phase = True
|
||||
current_log_phase = LogPhase.PLANNING
|
||||
|
||||
@@ -175,9 +173,6 @@ async def run_autonomous_agent(
|
||||
if task_logger:
|
||||
task_logger.start_phase(LogPhase.CODING, "Continuing implementation...")
|
||||
|
||||
# Emit phase event when continuing build
|
||||
emit_phase(ExecutionPhase.CODING, "Continuing implementation")
|
||||
|
||||
# Show human intervention hint
|
||||
content = [
|
||||
bold("INTERACTIVE CONTROLS"),
|
||||
@@ -257,33 +252,16 @@ async def run_autonomous_agent(
|
||||
phase_thinking_budget = get_phase_thinking_budget(spec_dir, current_phase)
|
||||
|
||||
# Create client (fresh context) with phase-specific model and thinking
|
||||
# Use appropriate agent_type for correct tool permissions and thinking budget
|
||||
client = create_client(
|
||||
project_dir,
|
||||
spec_dir,
|
||||
phase_model,
|
||||
agent_type="planner" if first_run else "coder",
|
||||
max_thinking_tokens=phase_thinking_budget,
|
||||
)
|
||||
|
||||
# Generate appropriate prompt
|
||||
if first_run:
|
||||
prompt = generate_planner_prompt(spec_dir, project_dir)
|
||||
|
||||
# Retrieve Graphiti memory context for planning phase
|
||||
# This gives the planner knowledge of previous patterns, gotchas, and insights
|
||||
planner_context = await get_graphiti_context(
|
||||
spec_dir,
|
||||
project_dir,
|
||||
{
|
||||
"description": "Planning implementation for new feature",
|
||||
"id": "planner",
|
||||
},
|
||||
)
|
||||
if planner_context:
|
||||
prompt += "\n\n" + planner_context
|
||||
print_status("Graphiti memory context loaded for planner", "success")
|
||||
|
||||
first_run = False
|
||||
current_log_phase = LogPhase.PLANNING
|
||||
|
||||
@@ -295,7 +273,6 @@ async def run_autonomous_agent(
|
||||
if is_planning_phase:
|
||||
is_planning_phase = False
|
||||
current_log_phase = LogPhase.CODING
|
||||
emit_phase(ExecutionPhase.CODING, "Starting implementation")
|
||||
if task_logger:
|
||||
task_logger.end_phase(
|
||||
LogPhase.PLANNING,
|
||||
@@ -409,11 +386,10 @@ async def run_autonomous_agent(
|
||||
|
||||
# Handle session status
|
||||
if status == "complete":
|
||||
# Don't emit COMPLETE here - subtasks are done but QA hasn't run yet
|
||||
# QA loop will emit COMPLETE after actual approval
|
||||
print_build_complete_banner(spec_dir)
|
||||
status_manager.update(state=BuildState.COMPLETE)
|
||||
|
||||
# End coding phase in task logger
|
||||
if task_logger:
|
||||
task_logger.end_phase(
|
||||
LogPhase.CODING,
|
||||
@@ -421,6 +397,7 @@ async def run_autonomous_agent(
|
||||
message="All subtasks completed successfully",
|
||||
)
|
||||
|
||||
# Notify Linear that build is complete (moving to QA)
|
||||
if linear_task and linear_task.task_id:
|
||||
await linear_build_complete(spec_dir)
|
||||
print_status("Linear notified: build complete, ready for QA", "success")
|
||||
@@ -455,7 +432,6 @@ async def run_autonomous_agent(
|
||||
await asyncio.sleep(AUTO_CONTINUE_DELAY_SECONDS)
|
||||
|
||||
elif status == "error":
|
||||
emit_phase(ExecutionPhase.FAILED, "Session encountered an error")
|
||||
print_status("Session encountered an error", "error")
|
||||
print(muted("Will retry with a fresh session..."))
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
|
||||
@@ -146,12 +146,6 @@ async def get_graphiti_context(
|
||||
# Get relevant context
|
||||
context_items = await memory.get_relevant_context(query, num_results=5)
|
||||
|
||||
# Get patterns and gotchas specifically (THE FIX for learning loop!)
|
||||
# This retrieves PATTERN and GOTCHA episode types for cross-session learning
|
||||
patterns, gotchas = await memory.get_patterns_and_gotchas(
|
||||
query, num_results=3, min_score=0.5
|
||||
)
|
||||
|
||||
# Also get recent session history
|
||||
session_history = await memory.get_session_history(limit=3)
|
||||
|
||||
@@ -162,12 +156,10 @@ async def get_graphiti_context(
|
||||
"memory",
|
||||
"Graphiti context retrieval complete",
|
||||
context_items_found=len(context_items) if context_items else 0,
|
||||
patterns_found=len(patterns) if patterns else 0,
|
||||
gotchas_found=len(gotchas) if gotchas else 0,
|
||||
session_history_found=len(session_history) if session_history else 0,
|
||||
)
|
||||
|
||||
if not context_items and not session_history and not patterns and not gotchas:
|
||||
if not context_items and not session_history:
|
||||
if is_debug_enabled():
|
||||
debug("memory", "No relevant context found in Graphiti")
|
||||
return None
|
||||
@@ -183,34 +175,6 @@ async def get_graphiti_context(
|
||||
item_type = item.get("type", "unknown")
|
||||
sections.append(f"- **[{item_type}]** {content}\n")
|
||||
|
||||
# Add patterns section (cross-session learning)
|
||||
if patterns:
|
||||
sections.append("### Learned Patterns\n")
|
||||
sections.append("_Patterns discovered in previous sessions:_\n")
|
||||
for p in patterns:
|
||||
pattern_text = p.get("pattern", "")
|
||||
applies_to = p.get("applies_to", "")
|
||||
if applies_to:
|
||||
sections.append(
|
||||
f"- **Pattern**: {pattern_text}\n _Applies to:_ {applies_to}\n"
|
||||
)
|
||||
else:
|
||||
sections.append(f"- **Pattern**: {pattern_text}\n")
|
||||
|
||||
# Add gotchas section (cross-session learning)
|
||||
if gotchas:
|
||||
sections.append("### Known Gotchas\n")
|
||||
sections.append("_Pitfalls to avoid:_\n")
|
||||
for g in gotchas:
|
||||
gotcha_text = g.get("gotcha", "")
|
||||
solution = g.get("solution", "")
|
||||
if solution:
|
||||
sections.append(
|
||||
f"- **Gotcha**: {gotcha_text}\n _Solution:_ {solution}\n"
|
||||
)
|
||||
else:
|
||||
sections.append(f"- **Gotcha**: {gotcha_text}\n")
|
||||
|
||||
if session_history:
|
||||
sections.append("### Recent Session Insights\n")
|
||||
for session in session_history[:2]: # Only show last 2
|
||||
|
||||
@@ -9,8 +9,7 @@ import logging
|
||||
from pathlib import Path
|
||||
|
||||
from core.client import create_client
|
||||
from phase_config import get_phase_model, get_phase_thinking_budget
|
||||
from phase_event import ExecutionPhase, emit_phase
|
||||
from phase_config import get_phase_thinking_budget
|
||||
from task_logger import (
|
||||
LogPhase,
|
||||
get_task_logger,
|
||||
@@ -68,7 +67,6 @@ async def run_followup_planner(
|
||||
# Initialize status manager for ccstatusline
|
||||
status_manager = StatusManager(project_dir)
|
||||
status_manager.set_active(spec_dir.name, BuildState.PLANNING)
|
||||
emit_phase(ExecutionPhase.PLANNING, "Follow-up planning")
|
||||
|
||||
# Initialize task logger for persistent logging
|
||||
task_logger = get_task_logger(spec_dir)
|
||||
@@ -91,14 +89,12 @@ async def run_followup_planner(
|
||||
task_logger.start_phase(LogPhase.PLANNING, "Starting follow-up planning...")
|
||||
task_logger.set_session(1)
|
||||
|
||||
# Create client with phase-specific model and thinking budget
|
||||
# Respects task_metadata.json configuration when no CLI override
|
||||
planning_model = get_phase_model(spec_dir, "planning", model)
|
||||
# Create client (fresh context) with planning phase thinking budget
|
||||
planning_thinking_budget = get_phase_thinking_budget(spec_dir, "planning")
|
||||
client = create_client(
|
||||
project_dir,
|
||||
spec_dir,
|
||||
planning_model,
|
||||
model,
|
||||
max_thinking_tokens=planning_thinking_budget,
|
||||
)
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ from progress import (
|
||||
is_build_complete,
|
||||
)
|
||||
from recovery import RecoveryManager
|
||||
from security.tool_input_validator import get_safe_tool_input
|
||||
from task_logger import (
|
||||
LogEntryType,
|
||||
LogPhase,
|
||||
@@ -387,43 +386,41 @@ async def run_agent_session(
|
||||
)
|
||||
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
||||
tool_name = block.name
|
||||
tool_input_display = None
|
||||
tool_input = None
|
||||
tool_count += 1
|
||||
|
||||
# Safely extract tool input (handles None, non-dict, etc.)
|
||||
inp = get_safe_tool_input(block)
|
||||
|
||||
# Extract meaningful tool input for display
|
||||
if inp:
|
||||
if "pattern" in inp:
|
||||
tool_input_display = f"pattern: {inp['pattern']}"
|
||||
elif "file_path" in inp:
|
||||
fp = inp["file_path"]
|
||||
if len(fp) > 50:
|
||||
fp = "..." + fp[-47:]
|
||||
tool_input_display = fp
|
||||
elif "command" in inp:
|
||||
cmd = inp["command"]
|
||||
if len(cmd) > 50:
|
||||
cmd = cmd[:47] + "..."
|
||||
tool_input_display = cmd
|
||||
elif "path" in inp:
|
||||
tool_input_display = inp["path"]
|
||||
if hasattr(block, "input") and block.input:
|
||||
inp = block.input
|
||||
if isinstance(inp, dict):
|
||||
if "pattern" in inp:
|
||||
tool_input = f"pattern: {inp['pattern']}"
|
||||
elif "file_path" in inp:
|
||||
fp = inp["file_path"]
|
||||
if len(fp) > 50:
|
||||
fp = "..." + fp[-47:]
|
||||
tool_input = fp
|
||||
elif "command" in inp:
|
||||
cmd = inp["command"]
|
||||
if len(cmd) > 50:
|
||||
cmd = cmd[:47] + "..."
|
||||
tool_input = cmd
|
||||
elif "path" in inp:
|
||||
tool_input = inp["path"]
|
||||
|
||||
debug(
|
||||
"session",
|
||||
f"Tool call #{tool_count}: {tool_name}",
|
||||
tool_input=tool_input_display,
|
||||
full_input=str(inp)[:500] if inp else None,
|
||||
tool_input=tool_input,
|
||||
full_input=str(block.input)[:500]
|
||||
if hasattr(block, "input")
|
||||
else None,
|
||||
)
|
||||
|
||||
# Log tool start (handles printing too)
|
||||
if task_logger:
|
||||
task_logger.tool_start(
|
||||
tool_name,
|
||||
tool_input_display,
|
||||
phase,
|
||||
print_to_console=True,
|
||||
tool_name, tool_input, phase, print_to_console=True
|
||||
)
|
||||
else:
|
||||
print(f"\n[Tool: {tool_name}]", flush=True)
|
||||
|
||||
@@ -30,32 +30,16 @@ Usage:
|
||||
"""
|
||||
|
||||
from .models import (
|
||||
# Agent configuration registry
|
||||
AGENT_CONFIGS,
|
||||
# Base tools
|
||||
BASE_READ_TOOLS,
|
||||
BASE_WRITE_TOOLS,
|
||||
# MCP tool lists
|
||||
CONTEXT7_TOOLS,
|
||||
ELECTRON_TOOLS,
|
||||
GRAPHITI_MCP_TOOLS,
|
||||
LINEAR_TOOLS,
|
||||
PUPPETEER_TOOLS,
|
||||
# Auto-Claude tool names
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
WEB_TOOLS,
|
||||
# Config functions
|
||||
get_agent_config,
|
||||
get_default_thinking_level,
|
||||
get_required_mcp_servers,
|
||||
is_electron_mcp_enabled,
|
||||
)
|
||||
from .permissions import get_all_agent_types, get_allowed_tools
|
||||
from .permissions import get_allowed_tools
|
||||
from .registry import create_auto_claude_mcp_server, is_tools_available
|
||||
|
||||
__all__ = [
|
||||
@@ -63,29 +47,14 @@ __all__ = [
|
||||
"create_auto_claude_mcp_server",
|
||||
"get_allowed_tools",
|
||||
"is_tools_available",
|
||||
# Agent configuration registry
|
||||
"AGENT_CONFIGS",
|
||||
"get_agent_config",
|
||||
"get_required_mcp_servers",
|
||||
"get_default_thinking_level",
|
||||
"get_all_agent_types",
|
||||
# Base tool lists
|
||||
"BASE_READ_TOOLS",
|
||||
"BASE_WRITE_TOOLS",
|
||||
"WEB_TOOLS",
|
||||
# MCP tool lists
|
||||
"CONTEXT7_TOOLS",
|
||||
"LINEAR_TOOLS",
|
||||
"GRAPHITI_MCP_TOOLS",
|
||||
"ELECTRON_TOOLS",
|
||||
"PUPPETEER_TOOLS",
|
||||
# Auto-Claude tool name constants
|
||||
# Tool name constants
|
||||
"TOOL_UPDATE_SUBTASK_STATUS",
|
||||
"TOOL_GET_BUILD_PROGRESS",
|
||||
"TOOL_RECORD_DISCOVERY",
|
||||
"TOOL_RECORD_GOTCHA",
|
||||
"TOOL_GET_SESSION_CONTEXT",
|
||||
"TOOL_UPDATE_QA_STATUS",
|
||||
# Config
|
||||
# Electron MCP
|
||||
"ELECTRON_TOOLS",
|
||||
"is_electron_mcp_enabled",
|
||||
]
|
||||
|
||||
@@ -3,32 +3,12 @@ Tool Models and Constants
|
||||
==========================
|
||||
|
||||
Defines tool name constants and configuration for auto-claude MCP tools.
|
||||
|
||||
This module is the single source of truth for all tool definitions used by
|
||||
the Claude Agent SDK client. Tool lists are organized by category:
|
||||
|
||||
- Base tools: Core file operations (Read, Write, Edit, etc.)
|
||||
- Web tools: Documentation and research (WebFetch, WebSearch)
|
||||
- MCP tools: External integrations (Context7, Linear, Graphiti, etc.)
|
||||
- Auto-Claude tools: Custom build management tools
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# =============================================================================
|
||||
# Base Tools (Built-in Claude Code tools)
|
||||
# =============================================================================
|
||||
|
||||
# Core file operation tools
|
||||
BASE_READ_TOOLS = ["Read", "Glob", "Grep"]
|
||||
BASE_WRITE_TOOLS = ["Write", "Edit", "Bash"]
|
||||
|
||||
# Web tools for documentation lookup and research
|
||||
# Always available to all agents for accessing external information
|
||||
WEB_TOOLS = ["WebFetch", "WebSearch"]
|
||||
|
||||
# =============================================================================
|
||||
# Auto-Claude MCP Tools (Custom build management)
|
||||
# Tool Name Constants
|
||||
# =============================================================================
|
||||
|
||||
# Auto-Claude MCP tool names (prefixed with mcp__auto-claude__)
|
||||
@@ -39,54 +19,8 @@ TOOL_RECORD_GOTCHA = "mcp__auto-claude__record_gotcha"
|
||||
TOOL_GET_SESSION_CONTEXT = "mcp__auto-claude__get_session_context"
|
||||
TOOL_UPDATE_QA_STATUS = "mcp__auto-claude__update_qa_status"
|
||||
|
||||
# =============================================================================
|
||||
# External MCP Tools
|
||||
# =============================================================================
|
||||
|
||||
# Context7 MCP tools for documentation lookup (always enabled)
|
||||
CONTEXT7_TOOLS = [
|
||||
"mcp__context7__resolve-library-id",
|
||||
"mcp__context7__get-library-docs",
|
||||
]
|
||||
|
||||
# Linear MCP tools for project management (when LINEAR_API_KEY is set)
|
||||
LINEAR_TOOLS = [
|
||||
"mcp__linear-server__list_teams",
|
||||
"mcp__linear-server__get_team",
|
||||
"mcp__linear-server__list_projects",
|
||||
"mcp__linear-server__get_project",
|
||||
"mcp__linear-server__create_project",
|
||||
"mcp__linear-server__update_project",
|
||||
"mcp__linear-server__list_issues",
|
||||
"mcp__linear-server__get_issue",
|
||||
"mcp__linear-server__create_issue",
|
||||
"mcp__linear-server__update_issue",
|
||||
"mcp__linear-server__list_comments",
|
||||
"mcp__linear-server__create_comment",
|
||||
"mcp__linear-server__list_issue_statuses",
|
||||
"mcp__linear-server__list_issue_labels",
|
||||
"mcp__linear-server__list_users",
|
||||
"mcp__linear-server__get_user",
|
||||
]
|
||||
|
||||
# Graphiti MCP tools for knowledge graph memory (when GRAPHITI_MCP_URL is set)
|
||||
# See: https://github.com/getzep/graphiti
|
||||
GRAPHITI_MCP_TOOLS = [
|
||||
"mcp__graphiti-memory__search_nodes", # Search entity summaries
|
||||
"mcp__graphiti-memory__search_facts", # Search relationships between entities
|
||||
"mcp__graphiti-memory__add_episode", # Add data to knowledge graph
|
||||
"mcp__graphiti-memory__get_episodes", # Retrieve recent episodes
|
||||
"mcp__graphiti-memory__get_entity_edge", # Get specific entity/relationship
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Browser Automation MCP Tools (QA agents only)
|
||||
# =============================================================================
|
||||
|
||||
# Puppeteer MCP tools for web browser automation
|
||||
# Used for web frontend validation (non-Electron web apps)
|
||||
# NOTE: Screenshots must be compressed (1280x720, quality 60, JPEG) to stay under
|
||||
# Claude SDK's 1MB JSON message buffer limit. See GitHub issue #74.
|
||||
PUPPETEER_TOOLS = [
|
||||
"mcp__puppeteer__puppeteer_connect_active_tab",
|
||||
"mcp__puppeteer__puppeteer_navigate",
|
||||
@@ -102,7 +36,6 @@ PUPPETEER_TOOLS = [
|
||||
# Uses electron-mcp-server to connect to Electron apps via Chrome DevTools Protocol.
|
||||
# Electron app must be started with --remote-debugging-port=9222 (or ELECTRON_DEBUG_PORT).
|
||||
# These tools are only available to QA agents (qa_reviewer, qa_fixer), not Coder/Planner.
|
||||
# NOTE: Screenshots must be compressed to stay under Claude SDK's 1MB JSON message buffer limit.
|
||||
ELECTRON_TOOLS = [
|
||||
"mcp__electron__get_electron_window_info", # Get info about running Electron windows
|
||||
"mcp__electron__take_screenshot", # Capture screenshot of Electron window
|
||||
@@ -110,6 +43,10 @@ ELECTRON_TOOLS = [
|
||||
"mcp__electron__read_electron_logs", # Read console logs from Electron app
|
||||
]
|
||||
|
||||
# Base tools available to all agents
|
||||
BASE_READ_TOOLS = ["Read", "Glob", "Grep"]
|
||||
BASE_WRITE_TOOLS = ["Write", "Edit", "Bash"]
|
||||
|
||||
# =============================================================================
|
||||
# Configuration
|
||||
# =============================================================================
|
||||
@@ -124,387 +61,3 @@ def is_electron_mcp_enabled() -> bool:
|
||||
via Chrome DevTools Protocol on the configured debug port.
|
||||
"""
|
||||
return os.environ.get("ELECTRON_MCP_ENABLED", "").lower() == "true"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Agent Configuration Registry
|
||||
# =============================================================================
|
||||
# Single source of truth for phase → tools → MCP servers mapping.
|
||||
# This enables phase-aware tool control and context window optimization.
|
||||
|
||||
AGENT_CONFIGS = {
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# SPEC CREATION PHASES (Minimal tools, fast startup)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"spec_gatherer": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [], # No MCP needed - just reads project
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"spec_researcher": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7"], # Needs docs lookup
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"spec_writer": {
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
|
||||
"mcp_servers": [], # Just writes spec.md
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"spec_critic": {
|
||||
"tools": BASE_READ_TOOLS,
|
||||
"mcp_servers": [], # Self-critique, no external tools
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "ultrathink",
|
||||
},
|
||||
"spec_discovery": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"spec_context": {
|
||||
"tools": BASE_READ_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"spec_validation": {
|
||||
"tools": BASE_READ_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"spec_compaction": {
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# BUILD PHASES (Full tools + Graphiti memory)
|
||||
# Note: "linear" is conditional on project setting "update_linear_with_tasks"
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"planner": {
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7", "graphiti", "auto-claude"],
|
||||
"mcp_servers_optional": ["linear"], # Only if project setting enabled
|
||||
"auto_claude_tools": [
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"coder": {
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7", "graphiti", "auto-claude"],
|
||||
"mcp_servers_optional": ["linear"],
|
||||
"auto_claude_tools": [
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
],
|
||||
"thinking_default": "none", # Coding doesn't use extended thinking
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# QA PHASES (Read + test + browser + Graphiti memory)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"qa_reviewer": {
|
||||
# Read + Write/Edit (for QA reports and plan updates) + Bash (for tests)
|
||||
# Note: Reviewer writes to spec directory only (qa_report.md, implementation_plan.json)
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7", "graphiti", "auto-claude", "browser"],
|
||||
"mcp_servers_optional": ["linear"], # For updating issue status
|
||||
"auto_claude_tools": [
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"qa_fixer": {
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7", "graphiti", "auto-claude", "browser"],
|
||||
"mcp_servers_optional": ["linear"],
|
||||
"auto_claude_tools": [
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# UTILITY PHASES (Minimal, no MCP)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"insights": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"merge_resolver": {
|
||||
"tools": [], # Text-only analysis
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low",
|
||||
},
|
||||
"commit_message": {
|
||||
"tools": [],
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low",
|
||||
},
|
||||
"pr_reviewer": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS, # Read-only
|
||||
"mcp_servers": ["context7"],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"pr_orchestrator_parallel": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS, # Read-only for parallel PR orchestrator
|
||||
"mcp_servers": ["context7"],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"pr_followup_parallel": {
|
||||
"tools": BASE_READ_TOOLS
|
||||
+ WEB_TOOLS, # Read-only for parallel followup reviewer
|
||||
"mcp_servers": ["context7"],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ANALYSIS PHASES
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"analysis": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7"],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"batch_analysis": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low",
|
||||
},
|
||||
"batch_validation": {
|
||||
"tools": BASE_READ_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low",
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ROADMAP & IDEATION
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"roadmap_discovery": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7"],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"competitor_analysis": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7"], # WebSearch for competitor research
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"ideation": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Agent Config Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_agent_config(agent_type: str) -> dict:
|
||||
"""
|
||||
Get full configuration for an agent type.
|
||||
|
||||
Args:
|
||||
agent_type: The agent type identifier (e.g., 'coder', 'planner', 'qa_reviewer')
|
||||
|
||||
Returns:
|
||||
Configuration dict containing tools, mcp_servers, auto_claude_tools, thinking_default
|
||||
|
||||
Raises:
|
||||
ValueError: If agent_type is not found in AGENT_CONFIGS (strict mode)
|
||||
"""
|
||||
if agent_type not in AGENT_CONFIGS:
|
||||
raise ValueError(
|
||||
f"Unknown agent type: '{agent_type}'. "
|
||||
f"Valid types: {sorted(AGENT_CONFIGS.keys())}"
|
||||
)
|
||||
return AGENT_CONFIGS[agent_type]
|
||||
|
||||
|
||||
def _map_mcp_server_name(
|
||||
name: str, custom_server_ids: list[str] | None = None
|
||||
) -> str | None:
|
||||
"""
|
||||
Map user-friendly MCP server names to internal identifiers.
|
||||
Also accepts custom server IDs directly.
|
||||
|
||||
Args:
|
||||
name: User-provided MCP server name
|
||||
custom_server_ids: List of custom server IDs to accept as-is
|
||||
|
||||
Returns:
|
||||
Internal server identifier or None if not recognized
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
mappings = {
|
||||
"context7": "context7",
|
||||
"graphiti-memory": "graphiti",
|
||||
"graphiti": "graphiti",
|
||||
"linear": "linear",
|
||||
"electron": "electron",
|
||||
"puppeteer": "puppeteer",
|
||||
"auto-claude": "auto-claude",
|
||||
}
|
||||
# Check if it's a known mapping
|
||||
mapped = mappings.get(name.lower().strip())
|
||||
if mapped:
|
||||
return mapped
|
||||
# Check if it's a custom server ID (accept as-is)
|
||||
if custom_server_ids and name in custom_server_ids:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def get_required_mcp_servers(
|
||||
agent_type: str,
|
||||
project_capabilities: dict | None = None,
|
||||
linear_enabled: bool = False,
|
||||
mcp_config: dict | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get MCP servers required for this agent type.
|
||||
|
||||
Handles dynamic server selection:
|
||||
- "browser" → electron (if is_electron) or puppeteer (if is_web_frontend)
|
||||
- "linear" → only if in mcp_servers_optional AND linear_enabled is True
|
||||
- "graphiti" → only if GRAPHITI_MCP_URL is set
|
||||
- Respects per-project MCP config overrides from .auto-claude/.env
|
||||
- Applies per-agent ADD/REMOVE overrides from AGENT_MCP_<agent>_ADD/REMOVE
|
||||
|
||||
Args:
|
||||
agent_type: The agent type identifier
|
||||
project_capabilities: Dict from detect_project_capabilities() or None
|
||||
linear_enabled: Whether Linear integration is enabled for this project
|
||||
mcp_config: Per-project MCP server toggles from .auto-claude/.env
|
||||
Keys: CONTEXT7_ENABLED, LINEAR_MCP_ENABLED, ELECTRON_MCP_ENABLED,
|
||||
PUPPETEER_MCP_ENABLED, AGENT_MCP_<agent>_ADD/REMOVE
|
||||
|
||||
Returns:
|
||||
List of MCP server names to start
|
||||
"""
|
||||
config = get_agent_config(agent_type)
|
||||
servers = list(config.get("mcp_servers", []))
|
||||
|
||||
# Load per-project config (or use defaults)
|
||||
if mcp_config is None:
|
||||
mcp_config = {}
|
||||
|
||||
# Filter context7 if explicitly disabled by project config
|
||||
if "context7" in servers:
|
||||
context7_enabled = mcp_config.get("CONTEXT7_ENABLED", "true")
|
||||
if str(context7_enabled).lower() == "false":
|
||||
servers = [s for s in servers if s != "context7"]
|
||||
|
||||
# Handle optional servers (e.g., Linear if project setting enabled)
|
||||
optional = config.get("mcp_servers_optional", [])
|
||||
if "linear" in optional and linear_enabled:
|
||||
# Also check per-project LINEAR_MCP_ENABLED override
|
||||
linear_mcp_enabled = mcp_config.get("LINEAR_MCP_ENABLED", "true")
|
||||
if str(linear_mcp_enabled).lower() != "false":
|
||||
servers.append("linear")
|
||||
|
||||
# Handle dynamic "browser" → electron/puppeteer based on project type and config
|
||||
if "browser" in servers:
|
||||
servers = [s for s in servers if s != "browser"]
|
||||
if project_capabilities:
|
||||
is_electron = project_capabilities.get("is_electron", False)
|
||||
is_web_frontend = project_capabilities.get("is_web_frontend", False)
|
||||
|
||||
# Check per-project overrides (default false for both)
|
||||
electron_enabled = mcp_config.get("ELECTRON_MCP_ENABLED", "false")
|
||||
puppeteer_enabled = mcp_config.get("PUPPETEER_MCP_ENABLED", "false")
|
||||
|
||||
# Electron: enabled by project config OR global env var
|
||||
if is_electron and (
|
||||
str(electron_enabled).lower() == "true" or is_electron_mcp_enabled()
|
||||
):
|
||||
servers.append("electron")
|
||||
# Puppeteer: enabled by project config (no global env var)
|
||||
elif is_web_frontend and not is_electron:
|
||||
if str(puppeteer_enabled).lower() == "true":
|
||||
servers.append("puppeteer")
|
||||
|
||||
# Filter graphiti if not enabled
|
||||
if "graphiti" in servers:
|
||||
if not os.environ.get("GRAPHITI_MCP_URL"):
|
||||
servers = [s for s in servers if s != "graphiti"]
|
||||
|
||||
# ========== Apply per-agent MCP overrides ==========
|
||||
# Format: AGENT_MCP_<agent_type>_ADD=server1,server2
|
||||
# AGENT_MCP_<agent_type>_REMOVE=server1,server2
|
||||
add_key = f"AGENT_MCP_{agent_type}_ADD"
|
||||
remove_key = f"AGENT_MCP_{agent_type}_REMOVE"
|
||||
|
||||
# Extract custom server IDs for mapping (allows custom servers to be recognized)
|
||||
custom_servers = mcp_config.get("CUSTOM_MCP_SERVERS", [])
|
||||
custom_server_ids = [s.get("id") for s in custom_servers if s.get("id")]
|
||||
|
||||
# Process additions
|
||||
if add_key in mcp_config:
|
||||
additions = [
|
||||
s.strip() for s in str(mcp_config[add_key]).split(",") if s.strip()
|
||||
]
|
||||
for server in additions:
|
||||
mapped = _map_mcp_server_name(server, custom_server_ids)
|
||||
if mapped and mapped not in servers:
|
||||
servers.append(mapped)
|
||||
|
||||
# Process removals (but never remove auto-claude)
|
||||
if remove_key in mcp_config:
|
||||
removals = [
|
||||
s.strip() for s in str(mcp_config[remove_key]).split(",") if s.strip()
|
||||
]
|
||||
for server in removals:
|
||||
mapped = _map_mcp_server_name(server, custom_server_ids)
|
||||
if mapped and mapped != "auto-claude": # auto-claude cannot be removed
|
||||
servers = [s for s in servers if s != mapped]
|
||||
|
||||
return servers
|
||||
|
||||
|
||||
def get_default_thinking_level(agent_type: str) -> str:
|
||||
"""
|
||||
Get default thinking level string for agent type.
|
||||
|
||||
This returns the thinking level name (e.g., 'medium', 'high'), not the token budget.
|
||||
To convert to tokens, use phase_config.get_thinking_budget(level).
|
||||
|
||||
Args:
|
||||
agent_type: The agent type identifier
|
||||
|
||||
Returns:
|
||||
Thinking level string (none, low, medium, high, ultrathink)
|
||||
"""
|
||||
config = get_agent_config(agent_type)
|
||||
return config.get("thinking_default", "medium")
|
||||
|
||||
@@ -8,30 +8,26 @@ pollution and accidental misuse.
|
||||
Supports dynamic tool filtering based on project capabilities to optimize
|
||||
context window usage. For example, Electron tools are only included for
|
||||
Electron projects, not for Next.js or CLI projects.
|
||||
|
||||
This module now uses AGENT_CONFIGS from models.py as the single source of truth
|
||||
for tool permissions. The get_allowed_tools() function remains the primary API
|
||||
for backwards compatibility.
|
||||
"""
|
||||
|
||||
from .models import (
|
||||
AGENT_CONFIGS,
|
||||
CONTEXT7_TOOLS,
|
||||
BASE_READ_TOOLS,
|
||||
BASE_WRITE_TOOLS,
|
||||
ELECTRON_TOOLS,
|
||||
GRAPHITI_MCP_TOOLS,
|
||||
LINEAR_TOOLS,
|
||||
PUPPETEER_TOOLS,
|
||||
get_agent_config,
|
||||
get_required_mcp_servers,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
is_electron_mcp_enabled,
|
||||
)
|
||||
from .registry import is_tools_available
|
||||
|
||||
|
||||
def get_allowed_tools(
|
||||
agent_type: str,
|
||||
project_capabilities: dict | None = None,
|
||||
linear_enabled: bool = False,
|
||||
mcp_config: dict | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get the list of allowed tools for a specific agent type.
|
||||
@@ -39,82 +35,113 @@ def get_allowed_tools(
|
||||
This ensures each agent only sees tools relevant to their role,
|
||||
preventing context pollution and accidental misuse.
|
||||
|
||||
Uses AGENT_CONFIGS as the single source of truth for tool permissions.
|
||||
Dynamic MCP tools are added based on project capabilities and required servers.
|
||||
When project_capabilities is provided, MCP tools are filtered based on
|
||||
the project type. For example:
|
||||
- Electron projects get Electron MCP tools
|
||||
- Web frontends (non-Electron) get Puppeteer MCP tools
|
||||
- CLI projects get neither
|
||||
|
||||
Args:
|
||||
agent_type: Agent type identifier (e.g., 'coder', 'planner', 'qa_reviewer')
|
||||
agent_type: One of 'planner', 'coder', 'qa_reviewer', 'qa_fixer'
|
||||
project_capabilities: Optional dict from detect_project_capabilities()
|
||||
containing flags like is_electron, is_web_frontend, etc.
|
||||
linear_enabled: Whether Linear integration is enabled for this project
|
||||
mcp_config: Per-project MCP server toggles from .auto-claude/.env
|
||||
|
||||
Returns:
|
||||
List of allowed tool names
|
||||
|
||||
Raises:
|
||||
ValueError: If agent_type is not found in AGENT_CONFIGS
|
||||
"""
|
||||
# Get agent configuration (raises ValueError if unknown type)
|
||||
config = get_agent_config(agent_type)
|
||||
# Auto-claude tool mappings by agent type
|
||||
tool_mappings = {
|
||||
"planner": {
|
||||
"base": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
|
||||
"auto_claude": [
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
],
|
||||
},
|
||||
"coder": {
|
||||
"base": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
|
||||
"auto_claude": [
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
],
|
||||
},
|
||||
"qa_reviewer": {
|
||||
"base": BASE_READ_TOOLS + ["Bash"], # Can run tests but not edit
|
||||
"auto_claude": [
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
],
|
||||
},
|
||||
"pr_reviewer": {
|
||||
# PR reviewers can ONLY read - no bash, no edits, no writes
|
||||
# This prevents the agent from switching branches or making changes
|
||||
"base": BASE_READ_TOOLS,
|
||||
"auto_claude": [], # No auto-claude tools needed for PR review
|
||||
},
|
||||
"qa_fixer": {
|
||||
"base": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
|
||||
"auto_claude": [
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# Start with base tools from config
|
||||
tools = list(config.get("tools", []))
|
||||
if agent_type not in tool_mappings:
|
||||
# Default to coder tools
|
||||
agent_type = "coder"
|
||||
|
||||
# Get required MCP servers for this agent
|
||||
required_servers = get_required_mcp_servers(
|
||||
agent_type,
|
||||
project_capabilities,
|
||||
linear_enabled,
|
||||
mcp_config,
|
||||
)
|
||||
mapping = tool_mappings[agent_type]
|
||||
tools = mapping["base"] + mapping["auto_claude"]
|
||||
|
||||
# Add auto-claude tools ONLY if the MCP server is available
|
||||
# This prevents allowing tools that won't work because the server isn't running
|
||||
if "auto-claude" in required_servers and is_tools_available():
|
||||
tools.extend(config.get("auto_claude_tools", []))
|
||||
|
||||
# Add MCP tool names based on required servers
|
||||
tools.extend(_get_mcp_tools_for_servers(required_servers))
|
||||
# Add MCP tools for QA agents only, based on project capabilities
|
||||
if agent_type in ("qa_reviewer", "qa_fixer"):
|
||||
tools.extend(_get_qa_mcp_tools(project_capabilities))
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
def _get_mcp_tools_for_servers(servers: list[str]) -> list[str]:
|
||||
def _get_qa_mcp_tools(project_capabilities: dict | None) -> list[str]:
|
||||
"""
|
||||
Get the list of MCP tools for a list of required servers.
|
||||
Get the list of MCP tools for QA agents based on project capabilities.
|
||||
|
||||
Maps server names to their corresponding tool lists.
|
||||
This function determines which MCP tools to include based on:
|
||||
1. Project type detection (Electron, web frontend, etc.)
|
||||
2. Environment variables (ELECTRON_MCP_ENABLED)
|
||||
|
||||
Args:
|
||||
servers: List of MCP server names (e.g., ['context7', 'linear', 'electron'])
|
||||
project_capabilities: Dict from detect_project_capabilities() or None
|
||||
|
||||
Returns:
|
||||
List of MCP tool names for all specified servers
|
||||
List of MCP tool names to include
|
||||
"""
|
||||
tools = []
|
||||
|
||||
for server in servers:
|
||||
if server == "context7":
|
||||
tools.extend(CONTEXT7_TOOLS)
|
||||
elif server == "linear":
|
||||
tools.extend(LINEAR_TOOLS)
|
||||
elif server == "graphiti":
|
||||
tools.extend(GRAPHITI_MCP_TOOLS)
|
||||
elif server == "electron":
|
||||
# If no capabilities provided, fall back to legacy behavior
|
||||
# (check env var only)
|
||||
if project_capabilities is None:
|
||||
if is_electron_mcp_enabled():
|
||||
tools.extend(ELECTRON_TOOLS)
|
||||
elif server == "puppeteer":
|
||||
tools.extend(PUPPETEER_TOOLS)
|
||||
# auto-claude tools are already added via config["auto_claude_tools"]
|
||||
return tools
|
||||
|
||||
# Project-capability-based tool selection
|
||||
is_electron = project_capabilities.get("is_electron", False)
|
||||
is_web_frontend = project_capabilities.get("is_web_frontend", False)
|
||||
|
||||
# Electron projects get Electron MCP tools (if enabled)
|
||||
if is_electron and is_electron_mcp_enabled():
|
||||
tools.extend(ELECTRON_TOOLS)
|
||||
|
||||
# Web frontends (non-Electron) get Puppeteer tools
|
||||
# Puppeteer is always available, no env var check needed
|
||||
if is_web_frontend and not is_electron:
|
||||
tools.extend(PUPPETEER_TOOLS)
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
def get_all_agent_types() -> list[str]:
|
||||
"""
|
||||
Get all registered agent types.
|
||||
|
||||
Returns:
|
||||
Sorted list of all agent type identifiers
|
||||
"""
|
||||
return sorted(AGENT_CONFIGS.keys())
|
||||
|
||||
@@ -75,15 +75,6 @@ class FrameworkAnalyzer(BaseAnalyzer):
|
||||
content = self._read_file("Cargo.toml")
|
||||
self._detect_rust_framework(content)
|
||||
|
||||
# Swift/iOS detection (check BEFORE Ruby - iOS projects often have Gemfile for CocoaPods/Fastlane)
|
||||
elif self._exists("Package.swift") or any(self.path.glob("*.xcodeproj")):
|
||||
self.analysis["language"] = "Swift"
|
||||
if self._exists("Package.swift"):
|
||||
self.analysis["package_manager"] = "Swift Package Manager"
|
||||
else:
|
||||
self.analysis["package_manager"] = "Xcode"
|
||||
self._detect_swift_framework()
|
||||
|
||||
# Ruby detection
|
||||
elif self._exists("Gemfile"):
|
||||
self.analysis["language"] = "Ruby"
|
||||
@@ -299,115 +290,12 @@ class FrameworkAnalyzer(BaseAnalyzer):
|
||||
if "sidekiq" in content.lower():
|
||||
self.analysis["task_queue"] = "Sidekiq"
|
||||
|
||||
def _detect_swift_framework(self) -> None:
|
||||
"""Detect Swift/iOS framework and dependencies."""
|
||||
try:
|
||||
# Scan Swift files for imports, excluding hidden/vendor dirs
|
||||
swift_files = []
|
||||
for swift_file in self.path.rglob("*.swift"):
|
||||
# Skip hidden directories, node_modules, .worktrees, etc.
|
||||
if any(
|
||||
part.startswith(".") or part in ("node_modules", "Pods", "Carthage")
|
||||
for part in swift_file.parts
|
||||
):
|
||||
continue
|
||||
swift_files.append(swift_file)
|
||||
if len(swift_files) >= 50: # Limit for performance
|
||||
break
|
||||
|
||||
imports = set()
|
||||
for swift_file in swift_files:
|
||||
try:
|
||||
content = swift_file.read_text(encoding="utf-8", errors="ignore")
|
||||
for line in content.split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith("import "):
|
||||
module = line.replace("import ", "").split()[0]
|
||||
imports.add(module)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Detect UI framework
|
||||
if "SwiftUI" in imports:
|
||||
self.analysis["framework"] = "SwiftUI"
|
||||
self.analysis["type"] = "mobile"
|
||||
elif "UIKit" in imports:
|
||||
self.analysis["framework"] = "UIKit"
|
||||
self.analysis["type"] = "mobile"
|
||||
elif "AppKit" in imports:
|
||||
self.analysis["framework"] = "AppKit"
|
||||
self.analysis["type"] = "desktop"
|
||||
|
||||
# Detect iOS/Apple frameworks
|
||||
apple_frameworks = []
|
||||
framework_map = {
|
||||
"Combine": "Combine",
|
||||
"CoreData": "CoreData",
|
||||
"MapKit": "MapKit",
|
||||
"WidgetKit": "WidgetKit",
|
||||
"CoreLocation": "CoreLocation",
|
||||
"StoreKit": "StoreKit",
|
||||
"CloudKit": "CloudKit",
|
||||
"ActivityKit": "ActivityKit",
|
||||
"UserNotifications": "UserNotifications",
|
||||
}
|
||||
for key, name in framework_map.items():
|
||||
if key in imports:
|
||||
apple_frameworks.append(name)
|
||||
|
||||
if apple_frameworks:
|
||||
self.analysis["apple_frameworks"] = apple_frameworks
|
||||
|
||||
# Detect SPM dependencies from Package.swift or xcodeproj
|
||||
dependencies = self._detect_spm_dependencies()
|
||||
if dependencies:
|
||||
self.analysis["spm_dependencies"] = dependencies
|
||||
except Exception:
|
||||
# Silently fail if Swift detection has issues
|
||||
pass
|
||||
|
||||
def _detect_spm_dependencies(self) -> list[str]:
|
||||
"""Detect Swift Package Manager dependencies."""
|
||||
dependencies = []
|
||||
|
||||
# Try Package.swift first
|
||||
if self._exists("Package.swift"):
|
||||
content = self._read_file("Package.swift")
|
||||
# Look for .package(url: "...", patterns
|
||||
import re
|
||||
|
||||
urls = re.findall(r'\.package\s*\([^)]*url:\s*"([^"]+)"', content)
|
||||
for url in urls:
|
||||
# Extract package name from URL
|
||||
name = url.rstrip("/").split("/")[-1].replace(".git", "")
|
||||
if name:
|
||||
dependencies.append(name)
|
||||
|
||||
# Also check xcodeproj for XCRemoteSwiftPackageReference
|
||||
for xcodeproj in self.path.glob("*.xcodeproj"):
|
||||
pbxproj = xcodeproj / "project.pbxproj"
|
||||
if pbxproj.exists():
|
||||
try:
|
||||
content = pbxproj.read_text(encoding="utf-8", errors="ignore")
|
||||
import re
|
||||
|
||||
# Match repositoryURL patterns
|
||||
urls = re.findall(r'repositoryURL\s*=\s*"([^"]+)"', content)
|
||||
for url in urls:
|
||||
name = url.rstrip("/").split("/")[-1].replace(".git", "")
|
||||
if name and name not in dependencies:
|
||||
dependencies.append(name)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return dependencies
|
||||
|
||||
def _detect_node_package_manager(self) -> str:
|
||||
"""Detect Node.js package manager."""
|
||||
if self._exists("pnpm-lock.yaml"):
|
||||
return "pnpm"
|
||||
elif self._exists("yarn.lock"):
|
||||
return "yarn"
|
||||
elif self._exists("bun.lockb") or self._exists("bun.lock"):
|
||||
elif self._exists("bun.lockb"):
|
||||
return "bun"
|
||||
return "npm"
|
||||
|
||||
@@ -366,19 +366,19 @@ async def run_insight_extraction(
|
||||
cwd = str(project_dir.resolve()) if project_dir else os.getcwd()
|
||||
|
||||
try:
|
||||
# Use simple_client for insight extraction
|
||||
from pathlib import Path
|
||||
|
||||
from core.simple_client import create_simple_client
|
||||
|
||||
client = create_simple_client(
|
||||
agent_type="insights",
|
||||
model=model,
|
||||
system_prompt=(
|
||||
"You are an expert code analyst. You extract structured insights from coding sessions. "
|
||||
"Always respond with valid JSON only, no markdown formatting or explanations."
|
||||
),
|
||||
cwd=Path(cwd) if cwd else None,
|
||||
# Create a minimal SDK client for insight extraction
|
||||
# No tools needed - just text generation
|
||||
client = ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model=model,
|
||||
system_prompt=(
|
||||
"You are an expert code analyst. You extract structured insights from coding sessions. "
|
||||
"Always respond with valid JSON only, no markdown formatting or explanations."
|
||||
),
|
||||
allowed_tools=[], # No tools needed for extraction
|
||||
max_turns=1, # Single turn extraction
|
||||
cwd=cwd,
|
||||
)
|
||||
)
|
||||
|
||||
# Use async context manager
|
||||
|
||||
@@ -275,7 +275,7 @@ class TestDiscovery:
|
||||
return "yarn"
|
||||
if (project_dir / "package-lock.json").exists():
|
||||
return "npm"
|
||||
if (project_dir / "bun.lockb").exists() or (project_dir / "bun.lock").exists():
|
||||
if (project_dir / "bun.lockb").exists():
|
||||
return "bun"
|
||||
if (project_dir / "uv.lock").exists():
|
||||
return "uv"
|
||||
|
||||
@@ -15,6 +15,10 @@ _PARENT_DIR = Path(__file__).parent.parent
|
||||
if str(_PARENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
from ui import (
|
||||
Icons,
|
||||
icon,
|
||||
)
|
||||
|
||||
from .batch_commands import (
|
||||
handle_batch_cleanup_command,
|
||||
@@ -197,6 +201,13 @@ Environment Variables:
|
||||
help="Show human review/approval status for a spec",
|
||||
)
|
||||
|
||||
# Dev mode (deprecated)
|
||||
parser.add_argument(
|
||||
"--dev",
|
||||
action="store_true",
|
||||
help="[Deprecated] No longer has any effect - kept for compatibility",
|
||||
)
|
||||
|
||||
# Non-interactive mode (for UI/automation)
|
||||
parser.add_argument(
|
||||
"--auto-continue",
|
||||
@@ -276,14 +287,19 @@ def main() -> None:
|
||||
project_dir = get_project_dir(args.project_dir)
|
||||
debug("run.py", f"Using project directory: {project_dir}")
|
||||
|
||||
# Get model from CLI arg or env var (None if not explicitly set)
|
||||
# This allows get_phase_model() to fall back to task_metadata.json
|
||||
model = args.model or os.environ.get("AUTO_BUILD_MODEL")
|
||||
# Get model (with env var fallback)
|
||||
model = args.model or os.environ.get("AUTO_BUILD_MODEL", DEFAULT_MODEL)
|
||||
|
||||
# Note: --dev flag is deprecated but kept for API compatibility
|
||||
if args.dev:
|
||||
print(
|
||||
f"\n{icon(Icons.GEAR)} Note: --dev flag is deprecated. All specs now use .auto-claude/specs/\n"
|
||||
)
|
||||
|
||||
# Handle --list command
|
||||
if args.list:
|
||||
print_banner()
|
||||
print_specs_list(project_dir)
|
||||
print_specs_list(project_dir, args.dev)
|
||||
return
|
||||
|
||||
# Handle --list-worktrees command
|
||||
@@ -321,14 +337,14 @@ def main() -> None:
|
||||
sys.exit(1)
|
||||
|
||||
# Find the spec
|
||||
debug("run.py", "Finding spec", spec_identifier=args.spec)
|
||||
spec_dir = find_spec(project_dir, args.spec)
|
||||
debug("run.py", "Finding spec", spec_identifier=args.spec, dev_mode=args.dev)
|
||||
spec_dir = find_spec(project_dir, args.spec, args.dev)
|
||||
if not spec_dir:
|
||||
debug_error("run.py", "Spec not found", spec=args.spec)
|
||||
print_banner()
|
||||
print(f"\nError: Spec '{args.spec}' not found")
|
||||
print("\nAvailable specs:")
|
||||
print_specs_list(project_dir)
|
||||
print_specs_list(project_dir, args.dev)
|
||||
sys.exit(1)
|
||||
|
||||
debug_success("run.py", "Spec found", spec_dir=str(spec_dir))
|
||||
|
||||
@@ -19,17 +19,18 @@ from workspace import get_existing_build_worktree
|
||||
from .utils import get_specs_dir
|
||||
|
||||
|
||||
def list_specs(project_dir: Path) -> list[dict]:
|
||||
def list_specs(project_dir: Path, dev_mode: bool = False) -> list[dict]:
|
||||
"""
|
||||
List all specs in the project.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
dev_mode: If True, use dev/auto-claude/specs/
|
||||
|
||||
Returns:
|
||||
List of spec info dicts with keys: number, name, path, status, progress
|
||||
"""
|
||||
specs_dir = get_specs_dir(project_dir)
|
||||
specs_dir = get_specs_dir(project_dir, dev_mode)
|
||||
specs = []
|
||||
|
||||
if not specs_dir.exists():
|
||||
@@ -92,16 +93,19 @@ def list_specs(project_dir: Path) -> list[dict]:
|
||||
return specs
|
||||
|
||||
|
||||
def print_specs_list(project_dir: Path, auto_create: bool = True) -> None:
|
||||
def print_specs_list(
|
||||
project_dir: Path, dev_mode: bool = False, auto_create: bool = True
|
||||
) -> None:
|
||||
"""Print a formatted list of all specs.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
dev_mode: If True, use dev/auto-claude/specs/
|
||||
auto_create: If True and no specs exist, automatically launch spec creation
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
specs = list_specs(project_dir)
|
||||
specs = list_specs(project_dir, dev_mode)
|
||||
|
||||
if not specs:
|
||||
print("\nNo specs found.")
|
||||
|
||||
@@ -54,18 +54,21 @@ def setup_environment() -> Path:
|
||||
return script_dir
|
||||
|
||||
|
||||
def find_spec(project_dir: Path, spec_identifier: str) -> Path | None:
|
||||
def find_spec(
|
||||
project_dir: Path, spec_identifier: str, dev_mode: bool = False
|
||||
) -> Path | None:
|
||||
"""
|
||||
Find a spec by number or full name.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_identifier: Either "001" or "001-feature-name"
|
||||
dev_mode: If True, use dev/auto-claude/specs/
|
||||
|
||||
Returns:
|
||||
Path to spec folder, or None if not found
|
||||
"""
|
||||
specs_dir = get_specs_dir(project_dir)
|
||||
specs_dir = get_specs_dir(project_dir, dev_mode)
|
||||
|
||||
if specs_dir.exists():
|
||||
# Try exact match first
|
||||
|
||||
@@ -186,15 +186,9 @@ Fixes #N (if applicable)"""
|
||||
return prompt
|
||||
|
||||
|
||||
async def _call_claude(prompt: str) -> str:
|
||||
"""Call Claude for commit message generation.
|
||||
|
||||
Reads model/thinking settings from environment variables:
|
||||
- UTILITY_MODEL_ID: Full model ID (e.g., "claude-haiku-4-5-20251001")
|
||||
- UTILITY_THINKING_BUDGET: Thinking budget tokens (e.g., "1024")
|
||||
"""
|
||||
async def _call_claude_haiku(prompt: str) -> str:
|
||||
"""Call Claude Haiku with low thinking for fast commit message generation."""
|
||||
from core.auth import ensure_claude_code_oauth_token, get_auth_token
|
||||
from core.model_config import get_utility_model_config
|
||||
|
||||
if not get_auth_token():
|
||||
logger.warning("No authentication token found")
|
||||
@@ -203,23 +197,19 @@ async def _call_claude(prompt: str) -> str:
|
||||
ensure_claude_code_oauth_token()
|
||||
|
||||
try:
|
||||
from core.simple_client import create_simple_client
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
except ImportError:
|
||||
logger.warning("core.simple_client not available")
|
||||
logger.warning("claude_agent_sdk not installed")
|
||||
return ""
|
||||
|
||||
# Get model settings from environment (passed from frontend)
|
||||
model, thinking_budget = get_utility_model_config()
|
||||
|
||||
logger.info(
|
||||
f"Commit message using model={model}, thinking_budget={thinking_budget}"
|
||||
)
|
||||
|
||||
client = create_simple_client(
|
||||
agent_type="commit_message",
|
||||
model=model,
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
max_thinking_tokens=thinking_budget,
|
||||
client = ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model="claude-haiku-4-5-20251001",
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
allowed_tools=[],
|
||||
max_turns=1,
|
||||
max_thinking_tokens=1024, # Low thinking for speed
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -297,9 +287,11 @@ def generate_commit_message_sync(
|
||||
import concurrent.futures
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
result = pool.submit(lambda: asyncio.run(_call_claude(prompt))).result()
|
||||
result = pool.submit(
|
||||
lambda: asyncio.run(_call_claude_haiku(prompt))
|
||||
).result()
|
||||
else:
|
||||
result = asyncio.run(_call_claude(prompt))
|
||||
result = asyncio.run(_call_claude_haiku(prompt))
|
||||
|
||||
if result:
|
||||
return result
|
||||
@@ -361,7 +353,7 @@ async def generate_commit_message(
|
||||
|
||||
# Call Claude
|
||||
try:
|
||||
result = await _call_claude(prompt)
|
||||
result = await _call_claude_haiku(prompt)
|
||||
if result:
|
||||
return result
|
||||
except Exception as e:
|
||||
|
||||
+17
-66
@@ -34,30 +34,20 @@ SDK_ENV_VARS = [
|
||||
|
||||
def get_token_from_keychain() -> str | None:
|
||||
"""
|
||||
Get authentication token from system credential store.
|
||||
Get authentication token from macOS Keychain.
|
||||
|
||||
Reads Claude Code credentials from:
|
||||
- macOS: Keychain
|
||||
- Windows: Credential Manager
|
||||
- Linux: Not yet supported (use env var)
|
||||
Reads Claude Code credentials from macOS Keychain and extracts the OAuth token.
|
||||
Only works on macOS (Darwin platform).
|
||||
|
||||
Returns:
|
||||
Token string if found, None otherwise
|
||||
Token string if found in Keychain, None otherwise
|
||||
"""
|
||||
system = platform.system()
|
||||
|
||||
if system == "Darwin":
|
||||
return _get_token_from_macos_keychain()
|
||||
elif system == "Windows":
|
||||
return _get_token_from_windows_credential_files()
|
||||
else:
|
||||
# Linux: secret-service not yet implemented
|
||||
# Only attempt on macOS
|
||||
if platform.system() != "Darwin":
|
||||
return None
|
||||
|
||||
|
||||
def _get_token_from_macos_keychain() -> str | None:
|
||||
"""Get token from macOS Keychain."""
|
||||
try:
|
||||
# Query macOS Keychain for Claude Code credentials
|
||||
result = subprocess.run(
|
||||
[
|
||||
"/usr/bin/security",
|
||||
@@ -74,11 +64,14 @@ def _get_token_from_macos_keychain() -> str | None:
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
|
||||
# Parse JSON response
|
||||
credentials_json = result.stdout.strip()
|
||||
if not credentials_json:
|
||||
return None
|
||||
|
||||
data = json.loads(credentials_json)
|
||||
|
||||
# Extract OAuth token from nested structure
|
||||
token = data.get("claudeAiOauth", {}).get("accessToken")
|
||||
|
||||
if not token:
|
||||
@@ -91,45 +84,18 @@ def _get_token_from_macos_keychain() -> str | None:
|
||||
return token
|
||||
|
||||
except (subprocess.TimeoutExpired, json.JSONDecodeError, KeyError, Exception):
|
||||
return None
|
||||
|
||||
|
||||
def _get_token_from_windows_credential_files() -> str | None:
|
||||
"""Get token from Windows credential files.
|
||||
|
||||
Claude Code on Windows stores credentials in ~/.claude/.credentials.json
|
||||
"""
|
||||
try:
|
||||
# Claude Code stores credentials in ~/.claude/.credentials.json
|
||||
cred_paths = [
|
||||
os.path.expandvars(r"%USERPROFILE%\.claude\.credentials.json"),
|
||||
os.path.expandvars(r"%USERPROFILE%\.claude\credentials.json"),
|
||||
os.path.expandvars(r"%LOCALAPPDATA%\Claude\credentials.json"),
|
||||
os.path.expandvars(r"%APPDATA%\Claude\credentials.json"),
|
||||
]
|
||||
|
||||
for cred_path in cred_paths:
|
||||
if os.path.exists(cred_path):
|
||||
with open(cred_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
token = data.get("claudeAiOauth", {}).get("accessToken")
|
||||
if token and token.startswith("sk-ant-oat01-"):
|
||||
return token
|
||||
|
||||
return None
|
||||
|
||||
except (json.JSONDecodeError, KeyError, FileNotFoundError, Exception):
|
||||
# Silently fail - this is a fallback mechanism
|
||||
return None
|
||||
|
||||
|
||||
def get_auth_token() -> str | None:
|
||||
"""
|
||||
Get authentication token from environment variables or system credential store.
|
||||
Get authentication token from environment variables or macOS Keychain.
|
||||
|
||||
Checks multiple sources in priority order:
|
||||
1. CLAUDE_CODE_OAUTH_TOKEN (env var)
|
||||
2. ANTHROPIC_AUTH_TOKEN (CCR/proxy env var for enterprise setups)
|
||||
3. System credential store (macOS Keychain, Windows Credential Manager)
|
||||
3. macOS Keychain (if on Darwin platform)
|
||||
|
||||
NOTE: ANTHROPIC_API_KEY is intentionally NOT supported to prevent
|
||||
silent billing to user's API credits when OAuth is misconfigured.
|
||||
@@ -143,7 +109,7 @@ def get_auth_token() -> str | None:
|
||||
if token:
|
||||
return token
|
||||
|
||||
# Fallback to system credential store
|
||||
# Fallback to macOS Keychain
|
||||
return get_token_from_keychain()
|
||||
|
||||
|
||||
@@ -154,15 +120,9 @@ def get_auth_token_source() -> str | None:
|
||||
if os.environ.get(var):
|
||||
return var
|
||||
|
||||
# Check if token came from system credential store
|
||||
# Check if token came from macOS Keychain
|
||||
if get_token_from_keychain():
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
return "macOS Keychain"
|
||||
elif system == "Windows":
|
||||
return "Windows Credential Files"
|
||||
else:
|
||||
return "System Credential Store"
|
||||
return "macOS Keychain"
|
||||
|
||||
return None
|
||||
|
||||
@@ -182,22 +142,13 @@ def require_auth_token() -> str:
|
||||
"Direct API keys (ANTHROPIC_API_KEY) are not supported.\n\n"
|
||||
)
|
||||
# Provide platform-specific guidance
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
if platform.system() == "Darwin":
|
||||
error_msg += (
|
||||
"To authenticate:\n"
|
||||
" 1. Run: claude setup-token\n"
|
||||
" 2. The token will be saved to macOS Keychain automatically\n\n"
|
||||
"Or set CLAUDE_CODE_OAUTH_TOKEN in your .env file."
|
||||
)
|
||||
elif system == "Windows":
|
||||
error_msg += (
|
||||
"To authenticate:\n"
|
||||
" 1. Run: claude setup-token\n"
|
||||
" 2. The token should be saved to Windows Credential Manager\n\n"
|
||||
"If auto-detection fails, set CLAUDE_CODE_OAUTH_TOKEN in your .env file.\n"
|
||||
"Check: %LOCALAPPDATA%\\Claude\\credentials.json"
|
||||
)
|
||||
else:
|
||||
error_msg += (
|
||||
"To authenticate:\n"
|
||||
|
||||
+186
-575
@@ -6,131 +6,20 @@ Functions for creating and configuring the Claude Agent SDK client.
|
||||
|
||||
All AI interactions should use `create_client()` to ensure consistent OAuth authentication
|
||||
and proper tool/MCP configuration. For simple message calls without full agent sessions,
|
||||
use `create_simple_client()` from `core.simple_client`.
|
||||
|
||||
The client factory now uses AGENT_CONFIGS from agents/tools_pkg/models.py as the
|
||||
single source of truth for phase-aware tool and MCP server configuration.
|
||||
use `ClaudeSDKClient` directly with `allowed_tools=[]` and `max_turns=1`.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =============================================================================
|
||||
# Project Index Cache
|
||||
# =============================================================================
|
||||
# Caches project index and capabilities to avoid reloading on every create_client() call.
|
||||
# This significantly reduces the time to create new agent sessions.
|
||||
|
||||
_PROJECT_INDEX_CACHE: dict[str, tuple[dict[str, Any], dict[str, bool], float]] = {}
|
||||
_CACHE_TTL_SECONDS = 300 # 5 minute TTL
|
||||
_CACHE_LOCK = threading.Lock() # Protects _PROJECT_INDEX_CACHE access
|
||||
|
||||
|
||||
def _get_cached_project_data(
|
||||
project_dir: Path,
|
||||
) -> tuple[dict[str, Any], dict[str, bool]]:
|
||||
"""
|
||||
Get project index and capabilities with caching.
|
||||
|
||||
Args:
|
||||
project_dir: Path to the project directory
|
||||
|
||||
Returns:
|
||||
Tuple of (project_index, project_capabilities)
|
||||
"""
|
||||
|
||||
key = str(project_dir.resolve())
|
||||
now = time.time()
|
||||
debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
|
||||
|
||||
# Check cache with lock
|
||||
with _CACHE_LOCK:
|
||||
if key in _PROJECT_INDEX_CACHE:
|
||||
cached_index, cached_capabilities, cached_time = _PROJECT_INDEX_CACHE[key]
|
||||
cache_age = now - cached_time
|
||||
if cache_age < _CACHE_TTL_SECONDS:
|
||||
if debug:
|
||||
print(
|
||||
f"[ClientCache] Cache HIT for project index (age: {cache_age:.1f}s / TTL: {_CACHE_TTL_SECONDS}s)"
|
||||
)
|
||||
logger.debug(f"Using cached project index for {project_dir}")
|
||||
# Return deep copies to prevent callers from corrupting the cache
|
||||
return copy.deepcopy(cached_index), copy.deepcopy(cached_capabilities)
|
||||
elif debug:
|
||||
print(
|
||||
f"[ClientCache] Cache EXPIRED for project index (age: {cache_age:.1f}s > TTL: {_CACHE_TTL_SECONDS}s)"
|
||||
)
|
||||
|
||||
# Cache miss or expired - load fresh data (outside lock to avoid blocking)
|
||||
load_start = time.time()
|
||||
logger.debug(f"Loading project index for {project_dir}")
|
||||
project_index = load_project_index(project_dir)
|
||||
project_capabilities = detect_project_capabilities(project_index)
|
||||
|
||||
if debug:
|
||||
load_duration = (time.time() - load_start) * 1000
|
||||
print(
|
||||
f"[ClientCache] Cache MISS - loaded project index in {load_duration:.1f}ms"
|
||||
)
|
||||
|
||||
# Store in cache with lock - use double-checked locking pattern
|
||||
# Re-check if another thread populated the cache while we were loading
|
||||
with _CACHE_LOCK:
|
||||
if key in _PROJECT_INDEX_CACHE:
|
||||
cached_index, cached_capabilities, cached_time = _PROJECT_INDEX_CACHE[key]
|
||||
cache_age = time.time() - cached_time
|
||||
if cache_age < _CACHE_TTL_SECONDS:
|
||||
# Another thread already cached valid data while we were loading
|
||||
if debug:
|
||||
print(
|
||||
"[ClientCache] Cache was populated by another thread, using cached data"
|
||||
)
|
||||
# Return deep copies to prevent callers from corrupting the cache
|
||||
return copy.deepcopy(cached_index), copy.deepcopy(cached_capabilities)
|
||||
# Either no cache entry or it's expired - store our fresh data
|
||||
_PROJECT_INDEX_CACHE[key] = (project_index, project_capabilities, time.time())
|
||||
|
||||
# Return the freshly loaded data (no need to copy since it's not from cache)
|
||||
return project_index, project_capabilities
|
||||
|
||||
|
||||
def invalidate_project_cache(project_dir: Path | None = None) -> None:
|
||||
"""
|
||||
Invalidate the project index cache.
|
||||
|
||||
Args:
|
||||
project_dir: Specific project to invalidate, or None to clear all
|
||||
"""
|
||||
with _CACHE_LOCK:
|
||||
if project_dir is None:
|
||||
_PROJECT_INDEX_CACHE.clear()
|
||||
logger.debug("Cleared all project index cache entries")
|
||||
else:
|
||||
key = str(project_dir.resolve())
|
||||
if key in _PROJECT_INDEX_CACHE:
|
||||
del _PROJECT_INDEX_CACHE[key]
|
||||
logger.debug(f"Invalidated project index cache for {project_dir}")
|
||||
|
||||
|
||||
from agents.tools_pkg import (
|
||||
CONTEXT7_TOOLS,
|
||||
ELECTRON_TOOLS,
|
||||
GRAPHITI_MCP_TOOLS,
|
||||
LINEAR_TOOLS,
|
||||
PUPPETEER_TOOLS,
|
||||
from auto_claude_tools import (
|
||||
create_auto_claude_mcp_server,
|
||||
get_allowed_tools,
|
||||
get_required_mcp_servers,
|
||||
is_tools_available,
|
||||
)
|
||||
from auto_claude_tools import (
|
||||
get_allowed_tools as get_agent_allowed_tools,
|
||||
)
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
from claude_agent_sdk.types import HookMatcher
|
||||
from core.auth import get_sdk_env_vars, require_auth_token
|
||||
@@ -139,245 +28,6 @@ from prompts_pkg.project_context import detect_project_capabilities, load_projec
|
||||
from security import bash_security_hook
|
||||
|
||||
|
||||
def _validate_custom_mcp_server(server: dict) -> bool:
|
||||
"""
|
||||
Validate a custom MCP server configuration for security.
|
||||
|
||||
Ensures only expected fields with valid types are present.
|
||||
Rejects configurations that could lead to command injection.
|
||||
|
||||
Args:
|
||||
server: Dict representing a custom MCP server configuration
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
"""
|
||||
if not isinstance(server, dict):
|
||||
return False
|
||||
|
||||
# Required fields
|
||||
required_fields = {"id", "name", "type"}
|
||||
if not all(field in server for field in required_fields):
|
||||
logger.warning(
|
||||
f"Custom MCP server missing required fields: {required_fields - server.keys()}"
|
||||
)
|
||||
return False
|
||||
|
||||
# Validate field types
|
||||
if not isinstance(server.get("id"), str) or not server["id"]:
|
||||
return False
|
||||
if not isinstance(server.get("name"), str) or not server["name"]:
|
||||
return False
|
||||
# FIX: Changed from ('command', 'url') to ('command', 'http') to match actual usage
|
||||
if server.get("type") not in ("command", "http"):
|
||||
logger.warning(f"Invalid MCP server type: {server.get('type')}")
|
||||
return False
|
||||
|
||||
# Allowlist of safe executable commands for MCP servers
|
||||
# Only allow known package managers and interpreters - NO shell commands
|
||||
SAFE_COMMANDS = {
|
||||
"npx",
|
||||
"npm",
|
||||
"node",
|
||||
"python",
|
||||
"python3",
|
||||
"uv",
|
||||
"uvx",
|
||||
}
|
||||
|
||||
# Blocklist of dangerous shell commands that should never be allowed
|
||||
DANGEROUS_COMMANDS = {
|
||||
"bash",
|
||||
"sh",
|
||||
"cmd",
|
||||
"powershell",
|
||||
"pwsh", # PowerShell Core
|
||||
"/bin/bash",
|
||||
"/bin/sh",
|
||||
"/bin/zsh",
|
||||
"/usr/bin/bash",
|
||||
"/usr/bin/sh",
|
||||
"zsh",
|
||||
"fish",
|
||||
}
|
||||
|
||||
# Dangerous interpreter flags that allow arbitrary code execution
|
||||
# Covers Python (-e, -c, -m, -p), Node.js (--eval, --print, loaders), and general
|
||||
DANGEROUS_FLAGS = {
|
||||
"--eval",
|
||||
"-e",
|
||||
"-c",
|
||||
"--exec",
|
||||
"-m", # Python module execution
|
||||
"-p", # Python eval+print
|
||||
"--print", # Node.js print
|
||||
"--input-type=module", # Node.js ES module mode
|
||||
"--experimental-loader", # Node.js custom loaders
|
||||
"--require", # Node.js require injection
|
||||
"-r", # Node.js require shorthand
|
||||
}
|
||||
|
||||
# Type-specific validation
|
||||
if server["type"] == "command":
|
||||
if not isinstance(server.get("command"), str) or not server["command"]:
|
||||
logger.warning("Command-type MCP server missing 'command' field")
|
||||
return False
|
||||
|
||||
# SECURITY FIX: Validate command is in safe list and not in dangerous list
|
||||
command = server.get("command", "")
|
||||
|
||||
# Reject paths - commands must be bare names only (no / or \)
|
||||
# This prevents path traversal like '/custom/malicious' or './evil'
|
||||
if "/" in command or "\\" in command:
|
||||
logger.warning(
|
||||
f"Rejected command with path in MCP server: {command}. "
|
||||
f"Commands must be bare names without path separators."
|
||||
)
|
||||
return False
|
||||
|
||||
if command in DANGEROUS_COMMANDS:
|
||||
logger.warning(
|
||||
f"Rejected dangerous command in MCP server: {command}. "
|
||||
f"Shell commands are not allowed for security reasons."
|
||||
)
|
||||
return False
|
||||
|
||||
if command not in SAFE_COMMANDS:
|
||||
logger.warning(
|
||||
f"Rejected unknown command in MCP server: {command}. "
|
||||
f"Only allowed commands: {', '.join(sorted(SAFE_COMMANDS))}"
|
||||
)
|
||||
return False
|
||||
|
||||
# Validate args is a list of strings if present
|
||||
if "args" in server:
|
||||
if not isinstance(server["args"], list):
|
||||
return False
|
||||
if not all(isinstance(arg, str) for arg in server["args"]):
|
||||
return False
|
||||
# Check for dangerous interpreter flags that allow code execution
|
||||
for arg in server["args"]:
|
||||
if arg in DANGEROUS_FLAGS:
|
||||
logger.warning(
|
||||
f"Rejected dangerous flag '{arg}' in MCP server args. "
|
||||
f"Interpreter code execution flags are not allowed."
|
||||
)
|
||||
return False
|
||||
elif server["type"] == "http":
|
||||
if not isinstance(server.get("url"), str) or not server["url"]:
|
||||
logger.warning("HTTP-type MCP server missing 'url' field")
|
||||
return False
|
||||
# Validate headers is a dict of strings if present
|
||||
if "headers" in server:
|
||||
if not isinstance(server["headers"], dict):
|
||||
return False
|
||||
if not all(
|
||||
isinstance(k, str) and isinstance(v, str)
|
||||
for k, v in server["headers"].items()
|
||||
):
|
||||
return False
|
||||
|
||||
# Optional description must be string if present
|
||||
if "description" in server and not isinstance(server.get("description"), str):
|
||||
return False
|
||||
|
||||
# Reject any unexpected fields that could be exploited
|
||||
allowed_fields = {
|
||||
"id",
|
||||
"name",
|
||||
"type",
|
||||
"command",
|
||||
"args",
|
||||
"url",
|
||||
"headers",
|
||||
"description",
|
||||
}
|
||||
unexpected_fields = set(server.keys()) - allowed_fields
|
||||
if unexpected_fields:
|
||||
logger.warning(f"Custom MCP server has unexpected fields: {unexpected_fields}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def load_project_mcp_config(project_dir: Path) -> dict:
|
||||
"""
|
||||
Load MCP configuration from project's .auto-claude/.env file.
|
||||
|
||||
Returns a dict of MCP-related env vars:
|
||||
- CONTEXT7_ENABLED (default: true)
|
||||
- LINEAR_MCP_ENABLED (default: true)
|
||||
- ELECTRON_MCP_ENABLED (default: false)
|
||||
- PUPPETEER_MCP_ENABLED (default: false)
|
||||
- AGENT_MCP_<agent>_ADD (per-agent MCP additions)
|
||||
- AGENT_MCP_<agent>_REMOVE (per-agent MCP removals)
|
||||
- CUSTOM_MCP_SERVERS (JSON array of custom server configs)
|
||||
|
||||
Args:
|
||||
project_dir: Path to the project directory
|
||||
|
||||
Returns:
|
||||
Dict of MCP configuration values (string values, except CUSTOM_MCP_SERVERS which is parsed JSON)
|
||||
"""
|
||||
env_path = project_dir / ".auto-claude" / ".env"
|
||||
if not env_path.exists():
|
||||
return {}
|
||||
|
||||
config = {}
|
||||
mcp_keys = {
|
||||
"CONTEXT7_ENABLED",
|
||||
"LINEAR_MCP_ENABLED",
|
||||
"ELECTRON_MCP_ENABLED",
|
||||
"PUPPETEER_MCP_ENABLED",
|
||||
}
|
||||
|
||||
try:
|
||||
with open(env_path, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
key = key.strip()
|
||||
value = value.strip().strip("\"'")
|
||||
# Include global MCP toggles
|
||||
if key in mcp_keys:
|
||||
config[key] = value
|
||||
# Include per-agent MCP overrides (AGENT_MCP_<agent>_ADD/REMOVE)
|
||||
elif key.startswith("AGENT_MCP_"):
|
||||
config[key] = value
|
||||
# Include custom MCP servers (parse JSON with schema validation)
|
||||
elif key == "CUSTOM_MCP_SERVERS":
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
if not isinstance(parsed, list):
|
||||
logger.warning(
|
||||
"CUSTOM_MCP_SERVERS must be a JSON array"
|
||||
)
|
||||
config["CUSTOM_MCP_SERVERS"] = []
|
||||
else:
|
||||
# Validate each server and filter out invalid ones
|
||||
valid_servers = []
|
||||
for i, server in enumerate(parsed):
|
||||
if _validate_custom_mcp_server(server):
|
||||
valid_servers.append(server)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Skipping invalid custom MCP server at index {i}"
|
||||
)
|
||||
config["CUSTOM_MCP_SERVERS"] = valid_servers
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(
|
||||
f"Failed to parse CUSTOM_MCP_SERVERS JSON: {value}"
|
||||
)
|
||||
config["CUSTOM_MCP_SERVERS"] = []
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to load project MCP config from {env_path}: {e}")
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def is_graphiti_mcp_enabled() -> bool:
|
||||
"""
|
||||
Check if Graphiti MCP server integration is enabled.
|
||||
@@ -409,28 +59,78 @@ def get_electron_debug_port() -> int:
|
||||
return int(os.environ.get("ELECTRON_DEBUG_PORT", "9222"))
|
||||
|
||||
|
||||
def should_use_claude_md() -> bool:
|
||||
"""Check if CLAUDE.md instructions should be included in system prompt."""
|
||||
return os.environ.get("USE_CLAUDE_MD", "").lower() == "true"
|
||||
# Puppeteer MCP tools for browser automation
|
||||
# NOTE: Screenshots must be compressed (1280x720, quality 60, JPEG) to stay under
|
||||
# Claude SDK's 1MB JSON message buffer limit. See GitHub issue #74.
|
||||
PUPPETEER_TOOLS = [
|
||||
"mcp__puppeteer__puppeteer_connect_active_tab",
|
||||
"mcp__puppeteer__puppeteer_navigate",
|
||||
"mcp__puppeteer__puppeteer_screenshot",
|
||||
"mcp__puppeteer__puppeteer_click",
|
||||
"mcp__puppeteer__puppeteer_fill",
|
||||
"mcp__puppeteer__puppeteer_select",
|
||||
"mcp__puppeteer__puppeteer_hover",
|
||||
"mcp__puppeteer__puppeteer_evaluate",
|
||||
]
|
||||
|
||||
# Linear MCP tools for project management (when LINEAR_API_KEY is set)
|
||||
LINEAR_TOOLS = [
|
||||
"mcp__linear-server__list_teams",
|
||||
"mcp__linear-server__get_team",
|
||||
"mcp__linear-server__list_projects",
|
||||
"mcp__linear-server__get_project",
|
||||
"mcp__linear-server__create_project",
|
||||
"mcp__linear-server__update_project",
|
||||
"mcp__linear-server__list_issues",
|
||||
"mcp__linear-server__get_issue",
|
||||
"mcp__linear-server__create_issue",
|
||||
"mcp__linear-server__update_issue",
|
||||
"mcp__linear-server__list_comments",
|
||||
"mcp__linear-server__create_comment",
|
||||
"mcp__linear-server__list_issue_statuses",
|
||||
"mcp__linear-server__list_issue_labels",
|
||||
"mcp__linear-server__list_users",
|
||||
"mcp__linear-server__get_user",
|
||||
]
|
||||
|
||||
def load_claude_md(project_dir: Path) -> str | None:
|
||||
"""
|
||||
Load CLAUDE.md content from project root if it exists.
|
||||
# Context7 MCP tools for documentation lookup (always enabled)
|
||||
CONTEXT7_TOOLS = [
|
||||
"mcp__context7__resolve-library-id",
|
||||
"mcp__context7__get-library-docs",
|
||||
]
|
||||
|
||||
Args:
|
||||
project_dir: Root directory of the project
|
||||
# Graphiti MCP tools for knowledge graph memory (when GRAPHITI_MCP_ENABLED is set)
|
||||
# See: https://github.com/getzep/graphiti
|
||||
GRAPHITI_MCP_TOOLS = [
|
||||
"mcp__graphiti-memory__search_nodes", # Search entity summaries
|
||||
"mcp__graphiti-memory__search_facts", # Search relationships between entities
|
||||
"mcp__graphiti-memory__add_episode", # Add data to knowledge graph
|
||||
"mcp__graphiti-memory__get_episodes", # Retrieve recent episodes
|
||||
"mcp__graphiti-memory__get_entity_edge", # Get specific entity/relationship
|
||||
]
|
||||
|
||||
Returns:
|
||||
Content of CLAUDE.md if found, None otherwise
|
||||
"""
|
||||
claude_md_path = project_dir / "CLAUDE.md"
|
||||
if claude_md_path.exists():
|
||||
try:
|
||||
return claude_md_path.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
# Electron MCP tools for desktop app automation (when ELECTRON_MCP_ENABLED is set)
|
||||
# Uses electron-mcp-server to connect to Electron apps via Chrome DevTools Protocol.
|
||||
# Electron app must be started with --remote-debugging-port=9222 (or ELECTRON_DEBUG_PORT).
|
||||
# These tools are only available to QA agents (qa_reviewer, qa_fixer), not Coder/Planner.
|
||||
# NOTE: Screenshots must be compressed to stay under Claude SDK's 1MB JSON message buffer limit.
|
||||
# See GitHub issue #74.
|
||||
ELECTRON_TOOLS = [
|
||||
"mcp__electron__get_electron_window_info", # Get info about running Electron windows
|
||||
"mcp__electron__take_screenshot", # Capture screenshot of Electron window
|
||||
"mcp__electron__send_command_to_electron", # Send commands (click, fill, evaluate JS)
|
||||
"mcp__electron__read_electron_logs", # Read console logs from Electron app
|
||||
]
|
||||
|
||||
# Built-in tools
|
||||
BUILTIN_TOOLS = [
|
||||
"Read",
|
||||
"Write",
|
||||
"Edit",
|
||||
"Glob",
|
||||
"Grep",
|
||||
"Bash",
|
||||
]
|
||||
|
||||
|
||||
def create_client(
|
||||
@@ -439,41 +139,25 @@ def create_client(
|
||||
model: str,
|
||||
agent_type: str = "coder",
|
||||
max_thinking_tokens: int | None = None,
|
||||
output_format: dict | None = None,
|
||||
agents: dict | None = None,
|
||||
) -> ClaudeSDKClient:
|
||||
"""
|
||||
Create a Claude Agent SDK client with multi-layered security.
|
||||
|
||||
Uses AGENT_CONFIGS for phase-aware tool and MCP server configuration.
|
||||
Only starts MCP servers that the agent actually needs, reducing context
|
||||
window bloat and startup latency.
|
||||
|
||||
Args:
|
||||
project_dir: Root directory for the project (working directory)
|
||||
spec_dir: Directory containing the spec (for settings file)
|
||||
model: Claude model to use
|
||||
agent_type: Agent type identifier from AGENT_CONFIGS
|
||||
(e.g., 'coder', 'planner', 'qa_reviewer', 'spec_gatherer')
|
||||
agent_type: Type of agent - 'planner', 'coder', 'qa_reviewer', or 'qa_fixer'
|
||||
This determines which custom auto-claude tools are available.
|
||||
max_thinking_tokens: Token budget for extended thinking (None = disabled)
|
||||
- ultrathink: 16000 (spec creation)
|
||||
- high: 10000 (QA review)
|
||||
- medium: 5000 (planning, validation)
|
||||
- None: disabled (coding)
|
||||
output_format: Optional structured output format for validated JSON responses.
|
||||
Use {"type": "json_schema", "schema": Model.model_json_schema()}
|
||||
See: https://platform.claude.com/docs/en/agent-sdk/structured-outputs
|
||||
agents: Optional dict of subagent definitions for SDK parallel execution.
|
||||
Format: {"agent-name": {"description": "...", "prompt": "...",
|
||||
"tools": [...], "model": "inherit"}}
|
||||
See: https://platform.claude.com/docs/en/agent-sdk/subagents
|
||||
|
||||
Returns:
|
||||
Configured ClaudeSDKClient
|
||||
|
||||
Raises:
|
||||
ValueError: If agent_type is not found in AGENT_CONFIGS
|
||||
|
||||
Security layers (defense in depth):
|
||||
1. Sandbox - OS-level bash command isolation prevents filesystem escape
|
||||
2. Permissions - File operations restricted to project_dir only
|
||||
@@ -497,93 +181,71 @@ def create_client(
|
||||
|
||||
# Load project capabilities for dynamic MCP tool selection
|
||||
# This enables context-aware tool injection based on project type
|
||||
# Uses caching to avoid reloading on every create_client() call
|
||||
project_index, project_capabilities = _get_cached_project_data(project_dir)
|
||||
project_index = load_project_index(project_dir)
|
||||
project_capabilities = detect_project_capabilities(project_index)
|
||||
|
||||
# Load per-project MCP configuration from .auto-claude/.env
|
||||
mcp_config = load_project_mcp_config(project_dir)
|
||||
# Build the list of allowed tools
|
||||
# Start with agent-specific tools (includes base tools + auto-claude tools)
|
||||
# Pass project capabilities for dynamic MCP tool filtering
|
||||
if auto_claude_tools_enabled:
|
||||
allowed_tools_list = get_agent_allowed_tools(agent_type, project_capabilities)
|
||||
else:
|
||||
allowed_tools_list = [*BUILTIN_TOOLS]
|
||||
|
||||
# Get allowed tools using phase-aware configuration
|
||||
# This respects AGENT_CONFIGS and only includes tools the agent needs
|
||||
# Also respects per-project MCP configuration
|
||||
allowed_tools_list = get_allowed_tools(
|
||||
agent_type,
|
||||
project_capabilities,
|
||||
linear_enabled,
|
||||
mcp_config,
|
||||
)
|
||||
# Check if Graphiti MCP is enabled
|
||||
graphiti_mcp_enabled = is_graphiti_mcp_enabled()
|
||||
|
||||
# Get required MCP servers for this agent type
|
||||
# This is the key optimization - only start servers the agent needs
|
||||
# Now also respects per-project MCP configuration
|
||||
required_servers = get_required_mcp_servers(
|
||||
agent_type,
|
||||
project_capabilities,
|
||||
linear_enabled,
|
||||
mcp_config,
|
||||
)
|
||||
# Check if Electron MCP is enabled (for QA agents testing Electron apps)
|
||||
electron_mcp_enabled = is_electron_mcp_enabled()
|
||||
|
||||
# Check if Graphiti MCP is enabled (already filtered by get_required_mcp_servers)
|
||||
graphiti_mcp_enabled = "graphiti" in required_servers
|
||||
# Add external MCP tools based on project capabilities
|
||||
# This saves context window by only including relevant tools
|
||||
allowed_tools_list.extend(CONTEXT7_TOOLS) # Always available
|
||||
if linear_enabled:
|
||||
allowed_tools_list.extend(LINEAR_TOOLS)
|
||||
if graphiti_mcp_enabled:
|
||||
allowed_tools_list.extend(GRAPHITI_MCP_TOOLS)
|
||||
# Note: Browser automation tools (ELECTRON_TOOLS, PUPPETEER_TOOLS) are already
|
||||
# added by get_agent_allowed_tools() via _get_qa_mcp_tools() for QA agents
|
||||
|
||||
# Determine browser tools for permissions (already in allowed_tools_list)
|
||||
# Determine which browser automation tools to allow based on project type
|
||||
# Note: Must check "not is_electron" for Puppeteer to avoid tool mismatch
|
||||
# when Electron MCP is disabled for an Electron project
|
||||
browser_tools_permissions = []
|
||||
if "electron" in required_servers:
|
||||
browser_tools_permissions = ELECTRON_TOOLS
|
||||
elif "puppeteer" in required_servers:
|
||||
browser_tools_permissions = PUPPETEER_TOOLS
|
||||
if agent_type in ("qa_reviewer", "qa_fixer"):
|
||||
if project_capabilities.get("is_electron") and electron_mcp_enabled:
|
||||
browser_tools_permissions = ELECTRON_TOOLS
|
||||
elif project_capabilities.get(
|
||||
"is_web_frontend"
|
||||
) and not project_capabilities.get("is_electron"):
|
||||
# Only add Puppeteer for non-Electron web frontends
|
||||
browser_tools_permissions = PUPPETEER_TOOLS
|
||||
|
||||
# Create comprehensive security settings
|
||||
# Note: Using both relative paths ("./**") and absolute paths to handle
|
||||
# cases where Claude uses absolute paths for file operations
|
||||
project_path_str = str(project_dir.resolve())
|
||||
spec_path_str = str(spec_dir.resolve())
|
||||
# Note: Using relative paths ("./**") restricts access to project directory
|
||||
# since cwd is set to project_dir
|
||||
security_settings = {
|
||||
"sandbox": {"enabled": True, "autoAllowBashIfSandboxed": True},
|
||||
"permissions": {
|
||||
"defaultMode": "acceptEdits", # Auto-approve edits within allowed directories
|
||||
"allow": [
|
||||
# Allow all file operations within the project directory
|
||||
# Include both relative (./**) and absolute paths for compatibility
|
||||
"Read(./**)",
|
||||
"Write(./**)",
|
||||
"Edit(./**)",
|
||||
"Glob(./**)",
|
||||
"Grep(./**)",
|
||||
# Also allow absolute paths (Claude sometimes uses full paths)
|
||||
f"Read({project_path_str}/**)",
|
||||
f"Write({project_path_str}/**)",
|
||||
f"Edit({project_path_str}/**)",
|
||||
f"Glob({project_path_str}/**)",
|
||||
f"Grep({project_path_str}/**)",
|
||||
# Allow spec directory explicitly (needed when spec is in worktree)
|
||||
f"Read({spec_path_str}/**)",
|
||||
f"Write({spec_path_str}/**)",
|
||||
f"Edit({spec_path_str}/**)",
|
||||
# Bash permission granted here, but actual commands are validated
|
||||
# by the bash_security_hook (see security.py for allowed commands)
|
||||
"Bash(*)",
|
||||
# Allow web tools for documentation and research
|
||||
"WebFetch(*)",
|
||||
"WebSearch(*)",
|
||||
# Allow MCP tools based on required servers
|
||||
# Format: tool_name(*) allows all arguments
|
||||
*(
|
||||
[f"{tool}(*)" for tool in CONTEXT7_TOOLS]
|
||||
if "context7" in required_servers
|
||||
else []
|
||||
),
|
||||
*(
|
||||
[f"{tool}(*)" for tool in LINEAR_TOOLS]
|
||||
if "linear" in required_servers
|
||||
else []
|
||||
),
|
||||
*(
|
||||
[f"{tool}(*)" for tool in GRAPHITI_MCP_TOOLS]
|
||||
if graphiti_mcp_enabled
|
||||
else []
|
||||
),
|
||||
*[f"{tool}(*)" for tool in browser_tools_permissions],
|
||||
# Allow Context7 MCP tools for documentation lookup
|
||||
*CONTEXT7_TOOLS,
|
||||
# Allow Linear MCP tools for project management (if enabled)
|
||||
*(LINEAR_TOOLS if linear_enabled else []),
|
||||
# Allow Graphiti MCP tools for knowledge graph memory (if enabled)
|
||||
*(GRAPHITI_MCP_TOOLS if graphiti_mcp_enabled else []),
|
||||
# Allow browser automation tools based on project type
|
||||
*browser_tools_permissions,
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -602,26 +264,24 @@ def create_client(
|
||||
else:
|
||||
print(" - Extended thinking: disabled")
|
||||
|
||||
# Build list of MCP servers for display based on required_servers
|
||||
mcp_servers_list = []
|
||||
if "context7" in required_servers:
|
||||
mcp_servers_list.append("context7 (documentation)")
|
||||
if "electron" in required_servers:
|
||||
mcp_servers_list.append(
|
||||
f"electron (desktop automation, port {get_electron_debug_port()})"
|
||||
)
|
||||
if "puppeteer" in required_servers:
|
||||
mcp_servers_list.append("puppeteer (browser automation)")
|
||||
if "linear" in required_servers:
|
||||
# Build list of MCP servers for display
|
||||
mcp_servers_list = ["context7 (documentation)"]
|
||||
if agent_type in ("qa_reviewer", "qa_fixer"):
|
||||
if project_capabilities.get("is_electron") and electron_mcp_enabled:
|
||||
mcp_servers_list.append(
|
||||
f"electron (desktop automation, port {get_electron_debug_port()})"
|
||||
)
|
||||
elif project_capabilities.get(
|
||||
"is_web_frontend"
|
||||
) and not project_capabilities.get("is_electron"):
|
||||
mcp_servers_list.append("puppeteer (browser automation)")
|
||||
if linear_enabled:
|
||||
mcp_servers_list.append("linear (project management)")
|
||||
if graphiti_mcp_enabled:
|
||||
mcp_servers_list.append("graphiti-memory (knowledge graph)")
|
||||
if "auto-claude" in required_servers and auto_claude_tools_enabled:
|
||||
if auto_claude_tools_enabled:
|
||||
mcp_servers_list.append(f"auto-claude ({agent_type} tools)")
|
||||
if mcp_servers_list:
|
||||
print(f" - MCP servers: {', '.join(mcp_servers_list)}")
|
||||
else:
|
||||
print(" - MCP servers: none (minimal configuration)")
|
||||
print(f" - MCP servers: {', '.join(mcp_servers_list)}")
|
||||
|
||||
# Show detected project capabilities for QA agents
|
||||
if agent_type in ("qa_reviewer", "qa_fixer") and any(project_capabilities.values()):
|
||||
@@ -633,125 +293,76 @@ def create_client(
|
||||
print(f" - Project capabilities: {', '.join(caps)}")
|
||||
print()
|
||||
|
||||
# Configure MCP servers - ONLY start servers that are required
|
||||
# This is the key optimization to reduce context bloat and startup latency
|
||||
mcp_servers = {}
|
||||
# Configure MCP servers
|
||||
mcp_servers = {
|
||||
"context7": {"command": "npx", "args": ["-y", "@upstash/context7-mcp"]},
|
||||
}
|
||||
|
||||
if "context7" in required_servers:
|
||||
mcp_servers["context7"] = {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@upstash/context7-mcp"],
|
||||
}
|
||||
# Add browser automation MCP server based on project type
|
||||
if agent_type in ("qa_reviewer", "qa_fixer"):
|
||||
if project_capabilities.get("is_electron") and electron_mcp_enabled:
|
||||
# Electron MCP for desktop apps
|
||||
# Electron app must be started with --remote-debugging-port=<port>
|
||||
mcp_servers["electron"] = {
|
||||
"command": "npm",
|
||||
"args": ["exec", "electron-mcp-server"],
|
||||
}
|
||||
elif project_capabilities.get(
|
||||
"is_web_frontend"
|
||||
) and not project_capabilities.get("is_electron"):
|
||||
# Puppeteer for web frontends (not Electron)
|
||||
mcp_servers["puppeteer"] = {
|
||||
"command": "npx",
|
||||
"args": ["puppeteer-mcp-server"],
|
||||
}
|
||||
|
||||
if "electron" in required_servers:
|
||||
# Electron MCP for desktop apps
|
||||
# Electron app must be started with --remote-debugging-port=<port>
|
||||
mcp_servers["electron"] = {
|
||||
"command": "npm",
|
||||
"args": ["exec", "electron-mcp-server"],
|
||||
}
|
||||
|
||||
if "puppeteer" in required_servers:
|
||||
# Puppeteer for web frontends (not Electron)
|
||||
mcp_servers["puppeteer"] = {
|
||||
"command": "npx",
|
||||
"args": ["puppeteer-mcp-server"],
|
||||
}
|
||||
|
||||
if "linear" in required_servers:
|
||||
# Add Linear MCP server if enabled
|
||||
if linear_enabled:
|
||||
mcp_servers["linear"] = {
|
||||
"type": "http",
|
||||
"url": "https://mcp.linear.app/mcp",
|
||||
"headers": {"Authorization": f"Bearer {linear_api_key}"},
|
||||
}
|
||||
|
||||
# Graphiti MCP server for knowledge graph memory
|
||||
# Add Graphiti MCP server if enabled
|
||||
# Graphiti MCP server for knowledge graph memory (uses embedded LadybugDB)
|
||||
if graphiti_mcp_enabled:
|
||||
mcp_servers["graphiti-memory"] = {
|
||||
"type": "http",
|
||||
"url": get_graphiti_mcp_url(),
|
||||
}
|
||||
|
||||
# Add custom auto-claude MCP server if required and available
|
||||
if "auto-claude" in required_servers and auto_claude_tools_enabled:
|
||||
# Add custom auto-claude MCP server if available
|
||||
auto_claude_mcp_server = None
|
||||
if auto_claude_tools_enabled:
|
||||
auto_claude_mcp_server = create_auto_claude_mcp_server(spec_dir, project_dir)
|
||||
if auto_claude_mcp_server:
|
||||
mcp_servers["auto-claude"] = auto_claude_mcp_server
|
||||
|
||||
# Add custom MCP servers from project config
|
||||
custom_servers = mcp_config.get("CUSTOM_MCP_SERVERS", [])
|
||||
for custom in custom_servers:
|
||||
server_id = custom.get("id")
|
||||
if not server_id:
|
||||
continue
|
||||
# Only include if agent has it in their effective server list
|
||||
if server_id not in required_servers:
|
||||
continue
|
||||
server_type = custom.get("type", "command")
|
||||
if server_type == "command":
|
||||
mcp_servers[server_id] = {
|
||||
"command": custom.get("command", "npx"),
|
||||
"args": custom.get("args", []),
|
||||
}
|
||||
elif server_type == "http":
|
||||
server_config = {
|
||||
"type": "http",
|
||||
"url": custom.get("url", ""),
|
||||
}
|
||||
if custom.get("headers"):
|
||||
server_config["headers"] = custom["headers"]
|
||||
mcp_servers[server_id] = server_config
|
||||
|
||||
# Build system prompt
|
||||
base_prompt = (
|
||||
f"You are an expert full-stack developer building production-quality software. "
|
||||
f"Your working directory is: {project_dir.resolve()}\n"
|
||||
f"Your filesystem access is RESTRICTED to this directory only. "
|
||||
f"Use relative paths (starting with ./) for all file operations. "
|
||||
f"Never use absolute paths or try to access files outside your working directory.\n\n"
|
||||
f"You follow existing code patterns, write clean maintainable code, and verify "
|
||||
f"your work through thorough testing. You communicate progress through Git commits "
|
||||
f"and build-progress.txt updates."
|
||||
return ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model=model,
|
||||
system_prompt=(
|
||||
f"You are an expert full-stack developer building production-quality software. "
|
||||
f"Your working directory is: {project_dir.resolve()}\n"
|
||||
f"Your filesystem access is RESTRICTED to this directory only. "
|
||||
f"Use relative paths (starting with ./) for all file operations. "
|
||||
f"Never use absolute paths or try to access files outside your working directory.\n\n"
|
||||
f"You follow existing code patterns, write clean maintainable code, and verify "
|
||||
f"your work through thorough testing. You communicate progress through Git commits "
|
||||
f"and build-progress.txt updates."
|
||||
),
|
||||
allowed_tools=allowed_tools_list,
|
||||
mcp_servers=mcp_servers,
|
||||
hooks={
|
||||
"PreToolUse": [
|
||||
HookMatcher(matcher="Bash", hooks=[bash_security_hook]),
|
||||
],
|
||||
},
|
||||
max_turns=1000,
|
||||
cwd=str(project_dir.resolve()),
|
||||
settings=str(settings_file.resolve()),
|
||||
env=sdk_env, # Pass ANTHROPIC_BASE_URL etc. to subprocess
|
||||
max_thinking_tokens=max_thinking_tokens, # Extended thinking budget
|
||||
)
|
||||
)
|
||||
|
||||
# Include CLAUDE.md if enabled and present
|
||||
if should_use_claude_md():
|
||||
claude_md_content = load_claude_md(project_dir)
|
||||
if claude_md_content:
|
||||
base_prompt = f"{base_prompt}\n\n# Project Instructions (from CLAUDE.md)\n\n{claude_md_content}"
|
||||
print(" - CLAUDE.md: included in system prompt")
|
||||
else:
|
||||
print(" - CLAUDE.md: not found in project root")
|
||||
else:
|
||||
print(" - CLAUDE.md: disabled by project settings")
|
||||
print()
|
||||
|
||||
# Build options dict, conditionally including output_format
|
||||
options_kwargs = {
|
||||
"model": model,
|
||||
"system_prompt": base_prompt,
|
||||
"allowed_tools": allowed_tools_list,
|
||||
"mcp_servers": mcp_servers,
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
HookMatcher(matcher="Bash", hooks=[bash_security_hook]),
|
||||
],
|
||||
},
|
||||
"max_turns": 1000,
|
||||
"cwd": str(project_dir.resolve()),
|
||||
"settings": str(settings_file.resolve()),
|
||||
"env": sdk_env, # Pass ANTHROPIC_BASE_URL etc. to subprocess
|
||||
"max_thinking_tokens": max_thinking_tokens, # Extended thinking budget
|
||||
}
|
||||
|
||||
# Add structured output format if specified
|
||||
# See: https://platform.claude.com/docs/en/agent-sdk/structured-outputs
|
||||
if output_format:
|
||||
options_kwargs["output_format"] = output_format
|
||||
|
||||
# Add subagent definitions if specified
|
||||
# See: https://platform.claude.com/docs/en/agent-sdk/subagents
|
||||
if agents:
|
||||
options_kwargs["agents"] = agents
|
||||
|
||||
return ClaudeSDKClient(options=ClaudeAgentOptions(**options_kwargs))
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
"""
|
||||
Model Configuration Utilities
|
||||
==============================
|
||||
|
||||
Shared utilities for reading and parsing model configuration from environment variables.
|
||||
Used by both commit_message.py and merge resolver.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default model for utility operations (commit messages, merge resolution)
|
||||
DEFAULT_UTILITY_MODEL = "claude-haiku-4-5-20251001"
|
||||
|
||||
|
||||
def get_utility_model_config(
|
||||
default_model: str = DEFAULT_UTILITY_MODEL,
|
||||
) -> tuple[str, int | None]:
|
||||
"""
|
||||
Get utility model configuration from environment variables.
|
||||
|
||||
Reads UTILITY_MODEL_ID and UTILITY_THINKING_BUDGET from environment,
|
||||
with sensible defaults and validation.
|
||||
|
||||
Args:
|
||||
default_model: Default model ID to use if UTILITY_MODEL_ID not set
|
||||
|
||||
Returns:
|
||||
Tuple of (model_id, thinking_budget) where thinking_budget is None
|
||||
if extended thinking is disabled, or an int representing token budget
|
||||
"""
|
||||
model = os.environ.get("UTILITY_MODEL_ID", default_model)
|
||||
thinking_budget_str = os.environ.get("UTILITY_THINKING_BUDGET", "")
|
||||
|
||||
# Parse thinking budget: empty string = disabled (None), number = budget tokens
|
||||
# Note: 0 is treated as "disable thinking" (same as None) since 0 tokens is meaningless
|
||||
thinking_budget: int | None
|
||||
if not thinking_budget_str:
|
||||
# Empty string means "none" level - disable extended thinking
|
||||
thinking_budget = None
|
||||
else:
|
||||
try:
|
||||
parsed_budget = int(thinking_budget_str)
|
||||
# Validate positive values - 0 or negative are invalid
|
||||
# 0 would mean "thinking enabled but 0 tokens" which is meaningless
|
||||
if parsed_budget <= 0:
|
||||
if parsed_budget == 0:
|
||||
# Zero means disable thinking (same as empty string)
|
||||
logger.debug(
|
||||
"UTILITY_THINKING_BUDGET=0 interpreted as 'disable thinking'"
|
||||
)
|
||||
thinking_budget = None
|
||||
else:
|
||||
logger.warning(
|
||||
f"Negative UTILITY_THINKING_BUDGET value '{thinking_budget_str}' not allowed, using default 1024"
|
||||
)
|
||||
thinking_budget = 1024
|
||||
else:
|
||||
thinking_budget = parsed_budget
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
f"Invalid UTILITY_THINKING_BUDGET value '{thinking_budget_str}', using default 1024"
|
||||
)
|
||||
thinking_budget = 1024
|
||||
|
||||
return model, thinking_budget
|
||||
@@ -1,55 +0,0 @@
|
||||
"""
|
||||
Execution phase event protocol for frontend synchronization.
|
||||
|
||||
Protocol: __EXEC_PHASE__:{"phase":"coding","message":"Starting"}
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
PHASE_MARKER_PREFIX = "__EXEC_PHASE__:"
|
||||
_DEBUG = os.environ.get("DEBUG", "").lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
class ExecutionPhase(str, Enum):
|
||||
"""Maps to frontend's ExecutionPhase type for task card badges."""
|
||||
|
||||
PLANNING = "planning"
|
||||
CODING = "coding"
|
||||
QA_REVIEW = "qa_review"
|
||||
QA_FIXING = "qa_fixing"
|
||||
COMPLETE = "complete"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
def emit_phase(
|
||||
phase: ExecutionPhase | str,
|
||||
message: str = "",
|
||||
*,
|
||||
progress: int | None = None,
|
||||
subtask: str | None = None,
|
||||
) -> None:
|
||||
"""Emit structured phase event to stdout for frontend parsing."""
|
||||
phase_value = phase.value if isinstance(phase, ExecutionPhase) else phase
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"phase": phase_value,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
if progress is not None:
|
||||
if not (0 <= progress <= 100):
|
||||
progress = max(0, min(100, progress))
|
||||
payload["progress"] = progress
|
||||
|
||||
if subtask is not None:
|
||||
payload["subtask"] = subtask
|
||||
|
||||
try:
|
||||
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)
|
||||
@@ -1,97 +0,0 @@
|
||||
"""
|
||||
Simple Claude SDK Client Factory
|
||||
================================
|
||||
|
||||
Factory for creating minimal Claude SDK clients for single-turn utility operations
|
||||
like commit message generation, merge conflict resolution, and batch analysis.
|
||||
|
||||
These clients don't need full security configurations, MCP servers, or hooks.
|
||||
Use `create_client()` from `core.client` for full agent sessions with security.
|
||||
|
||||
Example usage:
|
||||
from core.simple_client import create_simple_client
|
||||
|
||||
# For commit message generation (text-only, no tools)
|
||||
client = create_simple_client(agent_type="commit_message")
|
||||
|
||||
# For merge conflict resolution (text-only, no tools)
|
||||
client = create_simple_client(agent_type="merge_resolver")
|
||||
|
||||
# For insights extraction (read tools only)
|
||||
client = create_simple_client(agent_type="insights", cwd=project_dir)
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from agents.tools_pkg import get_agent_config, get_default_thinking_level
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
from core.auth import get_sdk_env_vars, require_auth_token
|
||||
from phase_config import get_thinking_budget
|
||||
|
||||
|
||||
def create_simple_client(
|
||||
agent_type: str = "merge_resolver",
|
||||
model: str = "claude-haiku-4-5-20251001",
|
||||
system_prompt: str | None = None,
|
||||
cwd: Path | None = None,
|
||||
max_turns: int = 1,
|
||||
max_thinking_tokens: int | None = None,
|
||||
) -> ClaudeSDKClient:
|
||||
"""
|
||||
Create a minimal Claude SDK client for single-turn utility operations.
|
||||
|
||||
This factory creates lightweight clients without MCP servers, security hooks,
|
||||
or full permission configurations. Use for text-only analysis tasks.
|
||||
|
||||
Args:
|
||||
agent_type: Agent type from AGENT_CONFIGS. Determines available tools.
|
||||
Common utility types:
|
||||
- "merge_resolver" - Text-only merge conflict analysis
|
||||
- "commit_message" - Text-only commit message generation
|
||||
- "insights" - Read-only code insight extraction
|
||||
- "batch_analysis" - Read-only batch issue analysis
|
||||
- "batch_validation" - Read-only validation
|
||||
model: Claude model to use (defaults to Haiku for fast/cheap operations)
|
||||
system_prompt: Optional custom system prompt (for specialized tasks)
|
||||
cwd: Working directory for file operations (optional)
|
||||
max_turns: Maximum conversation turns (default: 1 for single-turn)
|
||||
max_thinking_tokens: Override thinking budget (None = use agent default from
|
||||
AGENT_CONFIGS, converted using phase_config.THINKING_BUDGET_MAP)
|
||||
|
||||
Returns:
|
||||
Configured ClaudeSDKClient for single-turn operations
|
||||
|
||||
Raises:
|
||||
ValueError: If agent_type is not found in AGENT_CONFIGS
|
||||
"""
|
||||
# Get authentication
|
||||
oauth_token = require_auth_token()
|
||||
import os
|
||||
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
|
||||
|
||||
# Get environment variables for SDK
|
||||
sdk_env = get_sdk_env_vars()
|
||||
|
||||
# Get agent configuration (raises ValueError if unknown type)
|
||||
config = get_agent_config(agent_type)
|
||||
|
||||
# Get tools from config (no MCP tools for simple clients)
|
||||
allowed_tools = list(config.get("tools", []))
|
||||
|
||||
# Determine thinking budget using the single source of truth (phase_config.py)
|
||||
if max_thinking_tokens is None:
|
||||
thinking_level = get_default_thinking_level(agent_type)
|
||||
max_thinking_tokens = get_thinking_budget(thinking_level)
|
||||
|
||||
return ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model=model,
|
||||
system_prompt=system_prompt,
|
||||
allowed_tools=allowed_tools,
|
||||
max_turns=max_turns,
|
||||
cwd=str(cwd.resolve()) if cwd else None,
|
||||
env=sdk_env,
|
||||
max_thinking_tokens=max_thinking_tokens,
|
||||
)
|
||||
)
|
||||
@@ -1407,20 +1407,23 @@ async def _merge_file_with_ai_async(
|
||||
|
||||
# Call Claude Haiku for fast merge
|
||||
try:
|
||||
from core.simple_client import create_simple_client
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
except ImportError:
|
||||
return ParallelMergeResult(
|
||||
file_path=task.file_path,
|
||||
merged_content=None,
|
||||
success=False,
|
||||
error="core.simple_client not available",
|
||||
error="claude_agent_sdk not installed",
|
||||
)
|
||||
|
||||
client = create_simple_client(
|
||||
agent_type="merge_resolver",
|
||||
model="claude-haiku-4-5-20251001",
|
||||
system_prompt=AI_MERGE_SYSTEM_PROMPT,
|
||||
max_thinking_tokens=1024, # Low thinking for speed
|
||||
client = ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model="claude-haiku-4-5-20251001",
|
||||
system_prompt=AI_MERGE_SYSTEM_PROMPT,
|
||||
allowed_tools=[],
|
||||
max_turns=1,
|
||||
max_thinking_tokens=1024, # Low thinking for speed
|
||||
)
|
||||
)
|
||||
|
||||
response_text = ""
|
||||
|
||||
@@ -22,7 +22,6 @@ LOCK_FILES = {
|
||||
"pnpm-lock.yaml",
|
||||
"yarn.lock",
|
||||
"bun.lockb",
|
||||
"bun.lock",
|
||||
"Pipfile.lock",
|
||||
"poetry.lock",
|
||||
"uv.lock",
|
||||
|
||||
@@ -143,43 +143,6 @@ def choose_workspace(
|
||||
return WorkspaceMode.ISOLATED
|
||||
|
||||
|
||||
def copy_env_files_to_worktree(project_dir: Path, worktree_path: Path) -> list[str]:
|
||||
"""
|
||||
Copy .env files from project root to worktree (without overwriting).
|
||||
|
||||
This ensures the worktree has access to environment variables needed
|
||||
to run the project (e.g., API keys, database URLs).
|
||||
|
||||
Args:
|
||||
project_dir: The main project directory
|
||||
worktree_path: Path to the worktree
|
||||
|
||||
Returns:
|
||||
List of copied file names
|
||||
"""
|
||||
copied = []
|
||||
# Common .env file patterns - copy if they exist
|
||||
env_patterns = [
|
||||
".env",
|
||||
".env.local",
|
||||
".env.development",
|
||||
".env.development.local",
|
||||
".env.test",
|
||||
".env.test.local",
|
||||
]
|
||||
|
||||
for pattern in env_patterns:
|
||||
env_file = project_dir / pattern
|
||||
if env_file.is_file():
|
||||
target = worktree_path / pattern
|
||||
if not target.exists():
|
||||
shutil.copy2(env_file, target)
|
||||
copied.append(pattern)
|
||||
debug(MODULE, f"Copied {pattern} to worktree")
|
||||
|
||||
return copied
|
||||
|
||||
|
||||
def copy_spec_to_worktree(
|
||||
source_spec_dir: Path,
|
||||
worktree_path: Path,
|
||||
@@ -260,13 +223,6 @@ def setup_workspace(
|
||||
# Get or create worktree for THIS SPECIFIC SPEC
|
||||
worktree_info = manager.get_or_create_worktree(spec_name)
|
||||
|
||||
# Copy .env files to worktree so user can run the project
|
||||
copied_env_files = copy_env_files_to_worktree(project_dir, worktree_info.path)
|
||||
if copied_env_files:
|
||||
print_status(
|
||||
f"Environment files copied: {', '.join(copied_env_files)}", "success"
|
||||
)
|
||||
|
||||
# Copy spec files to worktree if provided
|
||||
localized_spec_dir = None
|
||||
if source_spec_dir and source_spec_dir.exists():
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Implementation Plan Manager
|
||||
============================
|
||||
|
||||
DEPRECATED: This module is now a compatibility shim. The implementation has been
|
||||
refactored into the implementation_plan/ package for better modularity.
|
||||
|
||||
Please import from the package directly:
|
||||
from implementation_plan import ImplementationPlan, Subtask, Phase, etc.
|
||||
|
||||
This file re-exports all public APIs for backwards compatibility.
|
||||
|
||||
Core data structures and utilities for subtask-based implementation plans.
|
||||
Replaces the test-centric feature_list.json with implementation_plan.json.
|
||||
|
||||
The key insight: Tests verify outcomes, but SUBTASKS define implementation steps.
|
||||
For complex multi-service features, implementation order matters.
|
||||
|
||||
Workflow Types:
|
||||
- feature: Standard multi-service feature (phases = services)
|
||||
- refactor: Migration/refactor work (phases = stages: add, migrate, remove)
|
||||
- investigation: Bug hunting (phases = investigate, hypothesize, fix)
|
||||
- migration: Data migration (phases = prepare, test, execute, cleanup)
|
||||
- simple: Single-service enhancement (minimal overhead)
|
||||
"""
|
||||
|
||||
# Re-export everything from the implementation_plan package
|
||||
from implementation_plan import (
|
||||
Chunk,
|
||||
ChunkStatus,
|
||||
ImplementationPlan,
|
||||
Phase,
|
||||
PhaseType,
|
||||
Subtask,
|
||||
SubtaskStatus,
|
||||
Verification,
|
||||
VerificationType,
|
||||
WorkflowType,
|
||||
create_feature_plan,
|
||||
create_investigation_plan,
|
||||
create_refactor_plan,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Enums
|
||||
"WorkflowType",
|
||||
"PhaseType",
|
||||
"SubtaskStatus",
|
||||
"VerificationType",
|
||||
# Models
|
||||
"Verification",
|
||||
"Subtask",
|
||||
"Phase",
|
||||
"ImplementationPlan",
|
||||
# Factories
|
||||
"create_feature_plan",
|
||||
"create_investigation_plan",
|
||||
"create_refactor_plan",
|
||||
# Backwards compatibility
|
||||
"Chunk",
|
||||
"ChunkStatus",
|
||||
]
|
||||
|
||||
|
||||
# CLI for testing
|
||||
if __name__ == "__main__":
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python implementation_plan.py <plan.json>")
|
||||
print(" python implementation_plan.py --demo")
|
||||
sys.exit(1)
|
||||
|
||||
if sys.argv[1] == "--demo":
|
||||
# Create a demo plan
|
||||
plan = create_feature_plan(
|
||||
feature="Avatar Upload with Processing",
|
||||
services=["backend", "worker", "frontend"],
|
||||
phases_config=[
|
||||
{
|
||||
"name": "Backend Foundation",
|
||||
"parallel_safe": True,
|
||||
"subtasks": [
|
||||
{
|
||||
"id": "avatar-model",
|
||||
"service": "backend",
|
||||
"description": "Add avatar fields to User model",
|
||||
"files_to_modify": ["app/models/user.py"],
|
||||
"files_to_create": ["migrations/add_avatar.py"],
|
||||
"verification": {
|
||||
"type": "command",
|
||||
"run": "flask db upgrade",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "avatar-endpoint",
|
||||
"service": "backend",
|
||||
"description": "POST /api/users/avatar endpoint",
|
||||
"files_to_modify": ["app/routes/users.py"],
|
||||
"patterns_from": ["app/routes/profile.py"],
|
||||
"verification": {
|
||||
"type": "api",
|
||||
"method": "POST",
|
||||
"url": "/api/users/avatar",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Worker Pipeline",
|
||||
"depends_on": [1],
|
||||
"subtasks": [
|
||||
{
|
||||
"id": "image-task",
|
||||
"service": "worker",
|
||||
"description": "Celery task for image processing",
|
||||
"files_to_create": ["app/tasks/images.py"],
|
||||
"patterns_from": ["app/tasks/reports.py"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Frontend",
|
||||
"depends_on": [1],
|
||||
"subtasks": [
|
||||
{
|
||||
"id": "avatar-component",
|
||||
"service": "frontend",
|
||||
"description": "AvatarUpload React component",
|
||||
"files_to_create": ["src/components/AvatarUpload.tsx"],
|
||||
"patterns_from": ["src/components/FileUpload.tsx"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Integration",
|
||||
"depends_on": [2, 3],
|
||||
"type": "integration",
|
||||
"subtasks": [
|
||||
{
|
||||
"id": "e2e-wiring",
|
||||
"all_services": True,
|
||||
"description": "Connect frontend → backend → worker",
|
||||
"verification": {
|
||||
"type": "browser",
|
||||
"scenario": "Upload → Process → Display",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
plan.final_acceptance = [
|
||||
"User can upload avatar from profile page",
|
||||
"Avatar is automatically resized",
|
||||
"Large/invalid files show error",
|
||||
]
|
||||
|
||||
print(json.dumps(plan.to_dict(), indent=2))
|
||||
print("\n---\n")
|
||||
print(plan.get_status_summary())
|
||||
else:
|
||||
# Load and display existing plan
|
||||
plan = ImplementationPlan.load(Path(sys.argv[1]))
|
||||
print(plan.get_status_summary())
|
||||
+3
-3
@@ -94,9 +94,6 @@ def create_ollama_embedder(config: "GraphitiConfig") -> Any:
|
||||
ProviderNotInstalled: If graphiti-core is not installed
|
||||
ProviderError: If model is not specified
|
||||
"""
|
||||
if not config.ollama_embedding_model:
|
||||
raise ProviderError("Ollama embedder requires OLLAMA_EMBEDDING_MODEL")
|
||||
|
||||
try:
|
||||
from graphiti_core.embedder.openai import OpenAIEmbedder, OpenAIEmbedderConfig
|
||||
except ImportError as e:
|
||||
@@ -106,6 +103,9 @@ def create_ollama_embedder(config: "GraphitiConfig") -> Any:
|
||||
f"Error: {e}"
|
||||
)
|
||||
|
||||
if not config.ollama_embedding_model:
|
||||
raise ProviderError("Ollama embedder requires OLLAMA_EMBEDDING_MODEL")
|
||||
|
||||
# Get embedding dimension (auto-detect for known models, or use configured value)
|
||||
embedding_dim = get_embedding_dim_for_model(
|
||||
config.ollama_embedding_model,
|
||||
|
||||
@@ -27,9 +27,6 @@ def create_openai_llm_client(config: "GraphitiConfig") -> Any:
|
||||
ProviderNotInstalled: If graphiti-core is not installed
|
||||
ProviderError: If API key is missing
|
||||
"""
|
||||
if not config.openai_api_key:
|
||||
raise ProviderError("OpenAI provider requires OPENAI_API_KEY")
|
||||
|
||||
try:
|
||||
from graphiti_core.llm_client.config import LLMConfig
|
||||
from graphiti_core.llm_client.openai_client import OpenAIClient
|
||||
@@ -40,6 +37,9 @@ def create_openai_llm_client(config: "GraphitiConfig") -> Any:
|
||||
f"Error: {e}"
|
||||
)
|
||||
|
||||
if not config.openai_api_key:
|
||||
raise ProviderError("OpenAI provider requires OPENAI_API_KEY")
|
||||
|
||||
llm_config = LLMConfig(
|
||||
api_key=config.openai_api_key,
|
||||
model=config.openai_model,
|
||||
|
||||
@@ -146,12 +146,12 @@ class GraphitiClient:
|
||||
# The original graphiti-core KuzuDriver has build_indices_and_constraints()
|
||||
# as a no-op, which causes FTS search failures
|
||||
from integrations.graphiti.queries_pkg.kuzu_driver_patched import (
|
||||
create_patched_kuzu_driver,
|
||||
PatchedKuzuDriver as KuzuDriver,
|
||||
)
|
||||
|
||||
db_path = self.config.get_db_path()
|
||||
try:
|
||||
self._driver = create_patched_kuzu_driver(db=str(db_path))
|
||||
self._driver = KuzuDriver(db=str(db_path))
|
||||
except (OSError, PermissionError) as e:
|
||||
logger.warning(
|
||||
f"Failed to initialize LadybugDB driver at {db_path}: {e}"
|
||||
|
||||
@@ -343,34 +343,6 @@ class GraphitiMemory:
|
||||
|
||||
return await self._search.get_similar_task_outcomes(task_description, limit)
|
||||
|
||||
async def get_patterns_and_gotchas(
|
||||
self,
|
||||
query: str,
|
||||
num_results: int = 5,
|
||||
min_score: float = 0.5,
|
||||
) -> tuple[list[dict], list[dict]]:
|
||||
"""
|
||||
Get patterns and gotchas relevant to the query.
|
||||
|
||||
This method specifically retrieves PATTERN and GOTCHA episode types
|
||||
to enable cross-session learning. Unlike get_relevant_context(),
|
||||
it filters for these specific types rather than doing generic search.
|
||||
|
||||
Args:
|
||||
query: Search query (task description)
|
||||
num_results: Max results per type
|
||||
min_score: Minimum relevance score (0.0-1.0)
|
||||
|
||||
Returns:
|
||||
Tuple of (patterns, gotchas) lists
|
||||
"""
|
||||
if not await self._ensure_initialized():
|
||||
return [], []
|
||||
|
||||
return await self._search.get_patterns_and_gotchas(
|
||||
query, num_results, min_score
|
||||
)
|
||||
|
||||
# Status and utility methods
|
||||
|
||||
def get_status_summary(self) -> dict:
|
||||
|
||||
@@ -9,7 +9,6 @@ This patched driver fixes both issues for LadybugDB compatibility.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
# Import kuzu (might be real_ladybug via monkeypatch)
|
||||
@@ -18,159 +17,157 @@ try:
|
||||
except ImportError:
|
||||
import real_ladybug as kuzu # type: ignore
|
||||
|
||||
from graphiti_core.driver.driver import GraphProvider
|
||||
from graphiti_core.driver.kuzu_driver import KuzuDriver as OriginalKuzuDriver
|
||||
from graphiti_core.graph_queries import get_fulltext_indices
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_patched_kuzu_driver(db: str = ":memory:", max_concurrent_queries: int = 1):
|
||||
from graphiti_core.driver.driver import GraphProvider
|
||||
from graphiti_core.driver.kuzu_driver import KuzuDriver as OriginalKuzuDriver
|
||||
from graphiti_core.graph_queries import get_fulltext_indices
|
||||
class PatchedKuzuDriver(OriginalKuzuDriver):
|
||||
"""
|
||||
KuzuDriver with proper FTS index creation and parameter handling.
|
||||
|
||||
class PatchedKuzuDriver(OriginalKuzuDriver):
|
||||
Fixes two bugs in graphiti-core:
|
||||
1. FTS indexes are never created (build_indices_and_constraints is a no-op)
|
||||
2. None parameters are filtered out, causing "Parameter not found" errors
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: str = ":memory:",
|
||||
max_concurrent_queries: int = 1,
|
||||
):
|
||||
# Store database path before calling parent (which creates the Database)
|
||||
self._database = db # Required by Graphiti for group_id checks
|
||||
super().__init__(db, max_concurrent_queries)
|
||||
|
||||
async def execute_query(
|
||||
self, cypher_query_: str, **kwargs: Any
|
||||
) -> tuple[list[dict[str, Any]] | list[list[dict[str, Any]]], None, None]:
|
||||
"""
|
||||
KuzuDriver with proper FTS index creation and parameter handling.
|
||||
Execute a Cypher query with proper None parameter handling.
|
||||
|
||||
Fixes two bugs in graphiti-core:
|
||||
1. FTS indexes are never created (build_indices_and_constraints is a no-op)
|
||||
2. None parameters are filtered out, causing "Parameter not found" errors
|
||||
The original driver filters out None values, but LadybugDB requires
|
||||
all referenced parameters to exist. This override keeps None values
|
||||
in the parameters dict.
|
||||
"""
|
||||
# Don't filter out None values - LadybugDB needs them
|
||||
params = {k: v for k, v in kwargs.items()}
|
||||
# Still remove these unsupported parameters
|
||||
params.pop("database_", None)
|
||||
params.pop("routing_", None)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db: str = ":memory:",
|
||||
max_concurrent_queries: int = 1,
|
||||
):
|
||||
# Store database path before calling parent (which creates the Database)
|
||||
self._database = db # Required by Graphiti for group_id checks
|
||||
super().__init__(db, max_concurrent_queries)
|
||||
try:
|
||||
results = await self.client.execute(cypher_query_, parameters=params)
|
||||
except Exception as e:
|
||||
# Truncate long values for logging
|
||||
log_params = {
|
||||
k: (v[:5] if isinstance(v, list) else v) for k, v in params.items()
|
||||
}
|
||||
logger.error(
|
||||
f"Error executing Kuzu query: {e}\n{cypher_query_}\n{log_params}"
|
||||
)
|
||||
raise
|
||||
|
||||
async def execute_query(
|
||||
self, cypher_query_: str, **kwargs: Any
|
||||
) -> tuple[list[dict[str, Any]] | list[list[dict[str, Any]]], None, None]:
|
||||
"""
|
||||
Execute a Cypher query with proper None parameter handling.
|
||||
if not results:
|
||||
return [], None, None
|
||||
|
||||
The original driver filters out None values, but LadybugDB requires
|
||||
all referenced parameters to exist. This override keeps None values
|
||||
in the parameters dict.
|
||||
"""
|
||||
# Don't filter out None values - LadybugDB needs them
|
||||
params = {k: v for k, v in kwargs.items()}
|
||||
# Still remove these unsupported parameters
|
||||
params.pop("database_", None)
|
||||
params.pop("routing_", None)
|
||||
if isinstance(results, list):
|
||||
dict_results = [list(result.rows_as_dict()) for result in results]
|
||||
else:
|
||||
dict_results = list(results.rows_as_dict())
|
||||
return dict_results, None, None # type: ignore
|
||||
|
||||
async def build_indices_and_constraints(self, delete_existing: bool = False):
|
||||
"""
|
||||
Build FTS indexes required for Graphiti's hybrid search.
|
||||
|
||||
The original KuzuDriver has this as a no-op, but we need to actually
|
||||
create the FTS indexes for search to work.
|
||||
|
||||
Args:
|
||||
delete_existing: If True, drop and recreate indexes (default: False)
|
||||
"""
|
||||
logger.info("Building FTS indexes for Kuzu/LadybugDB...")
|
||||
|
||||
# Get the FTS index creation queries from Graphiti
|
||||
fts_queries = get_fulltext_indices(GraphProvider.KUZU)
|
||||
|
||||
# Create a sync connection for index creation
|
||||
conn = kuzu.Connection(self.db)
|
||||
|
||||
try:
|
||||
for query in fts_queries:
|
||||
try:
|
||||
# Check if we need to drop existing index first
|
||||
if delete_existing:
|
||||
# Extract index name from query
|
||||
# Format: CALL CREATE_FTS_INDEX('TableName', 'index_name', [...])
|
||||
parts = query.split("'")
|
||||
if len(parts) >= 4:
|
||||
table_name = parts[1]
|
||||
index_name = parts[3]
|
||||
drop_query = (
|
||||
f"CALL DROP_FTS_INDEX('{table_name}', '{index_name}')"
|
||||
)
|
||||
try:
|
||||
conn.execute(drop_query)
|
||||
logger.debug(
|
||||
f"Dropped existing FTS index: {index_name}"
|
||||
)
|
||||
except Exception:
|
||||
# Index might not exist, that's fine
|
||||
pass
|
||||
|
||||
# Create the FTS index
|
||||
conn.execute(query)
|
||||
logger.debug(f"Created FTS index: {query[:80]}...")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
# Handle "index already exists" gracefully
|
||||
if "already exists" in error_msg or "duplicate" in error_msg:
|
||||
logger.debug(
|
||||
f"FTS index already exists (skipping): {query[:60]}..."
|
||||
)
|
||||
else:
|
||||
# Log but don't fail - some indexes might fail in certain Kuzu versions
|
||||
logger.warning(f"Failed to create FTS index: {e}")
|
||||
logger.debug(f"Query was: {query}")
|
||||
|
||||
logger.info("FTS indexes created successfully")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def setup_schema(self):
|
||||
"""
|
||||
Set up the database schema and install/load the FTS extension.
|
||||
|
||||
Extends the parent setup_schema() to properly set up FTS support.
|
||||
"""
|
||||
conn = kuzu.Connection(self.db)
|
||||
|
||||
try:
|
||||
# First, install the FTS extension (required before loading)
|
||||
try:
|
||||
results = await self.client.execute(cypher_query_, parameters=params)
|
||||
conn.execute("INSTALL fts")
|
||||
logger.debug("Installed FTS extension")
|
||||
except Exception as e:
|
||||
# Truncate long values for logging
|
||||
log_params = {
|
||||
k: (v[:5] if isinstance(v, list) else v) for k, v in params.items()
|
||||
}
|
||||
logger.error(
|
||||
f"Error executing Kuzu query: {e}\n{cypher_query_}\n{log_params}"
|
||||
)
|
||||
raise
|
||||
|
||||
if not results:
|
||||
return [], None, None
|
||||
|
||||
if isinstance(results, list):
|
||||
dict_results = [list(result.rows_as_dict()) for result in results]
|
||||
else:
|
||||
dict_results = list(results.rows_as_dict())
|
||||
return dict_results, None, None # type: ignore
|
||||
|
||||
async def build_indices_and_constraints(self, delete_existing: bool = False):
|
||||
"""
|
||||
Build FTS indexes required for Graphiti's hybrid search.
|
||||
|
||||
The original KuzuDriver has this as a no-op, but we need to actually
|
||||
create the FTS indexes for search to work.
|
||||
|
||||
Args:
|
||||
delete_existing: If True, drop and recreate indexes (default: False)
|
||||
"""
|
||||
logger.info("Building FTS indexes for Kuzu/LadybugDB...")
|
||||
|
||||
# Get the FTS index creation queries from Graphiti
|
||||
fts_queries = get_fulltext_indices(GraphProvider.KUZU)
|
||||
|
||||
# Create a sync connection for index creation
|
||||
conn = kuzu.Connection(self.db)
|
||||
error_msg = str(e).lower()
|
||||
if "already" not in error_msg:
|
||||
logger.debug(f"FTS extension install note: {e}")
|
||||
|
||||
# Then load the FTS extension
|
||||
try:
|
||||
for query in fts_queries:
|
||||
try:
|
||||
# Check if we need to drop existing index first
|
||||
if delete_existing:
|
||||
# Extract index name from query
|
||||
# Format: CALL CREATE_FTS_INDEX('TableName', 'index_name', [...])
|
||||
match = re.search(
|
||||
r"CREATE_FTS_INDEX\('([^']+)',\s*'([^']+)'", query
|
||||
)
|
||||
if match:
|
||||
table_name, index_name = match.groups()
|
||||
drop_query = f"CALL DROP_FTS_INDEX('{table_name}', '{index_name}')"
|
||||
try:
|
||||
conn.execute(drop_query)
|
||||
logger.debug(
|
||||
f"Dropped existing FTS index: {index_name}"
|
||||
)
|
||||
except Exception:
|
||||
# Index might not exist, that's fine
|
||||
pass
|
||||
conn.execute("LOAD EXTENSION fts")
|
||||
logger.debug("Loaded FTS extension")
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
if "already loaded" not in error_msg:
|
||||
logger.debug(f"FTS extension load note: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# Create the FTS index
|
||||
conn.execute(query)
|
||||
logger.debug(f"Created FTS index: {query[:80]}...")
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
# Handle "index already exists" gracefully
|
||||
if "already exists" in error_msg or "duplicate" in error_msg:
|
||||
logger.debug(
|
||||
f"FTS index already exists (skipping): {query[:60]}..."
|
||||
)
|
||||
else:
|
||||
# Log but don't fail - some indexes might fail in certain Kuzu versions
|
||||
logger.warning(f"Failed to create FTS index: {e}")
|
||||
logger.debug(f"Query was: {query}")
|
||||
|
||||
logger.info("FTS indexes created successfully")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def setup_schema(self):
|
||||
"""
|
||||
Set up the database schema and install/load the FTS extension.
|
||||
|
||||
Extends the parent setup_schema() to properly set up FTS support.
|
||||
"""
|
||||
conn = kuzu.Connection(self.db)
|
||||
|
||||
try:
|
||||
# First, install the FTS extension (required before loading)
|
||||
try:
|
||||
conn.execute("INSTALL fts")
|
||||
logger.debug("Installed FTS extension")
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
if "already" not in error_msg:
|
||||
logger.debug(f"FTS extension install note: {e}")
|
||||
|
||||
# Then load the FTS extension
|
||||
try:
|
||||
conn.execute("LOAD EXTENSION fts")
|
||||
logger.debug("Loaded FTS extension")
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
if "already loaded" not in error_msg:
|
||||
logger.debug(f"FTS extension load note: {e}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# Run the parent schema setup (creates tables)
|
||||
super().setup_schema()
|
||||
|
||||
return PatchedKuzuDriver(db=db, max_concurrent_queries=max_concurrent_queries)
|
||||
# Run the parent schema setup (creates tables)
|
||||
super().setup_schema()
|
||||
|
||||
@@ -10,8 +10,6 @@ import logging
|
||||
from pathlib import Path
|
||||
|
||||
from .schema import (
|
||||
EPISODE_TYPE_GOTCHA,
|
||||
EPISODE_TYPE_PATTERN,
|
||||
EPISODE_TYPE_SESSION_INSIGHT,
|
||||
EPISODE_TYPE_TASK_OUTCOME,
|
||||
MAX_CONTEXT_RESULTS,
|
||||
@@ -57,7 +55,6 @@ class GraphitiSearch:
|
||||
query: str,
|
||||
num_results: int = MAX_CONTEXT_RESULTS,
|
||||
include_project_context: bool = True,
|
||||
min_score: float = 0.0,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Search for relevant context based on a query.
|
||||
@@ -107,12 +104,6 @@ class GraphitiSearch:
|
||||
}
|
||||
)
|
||||
|
||||
# Filter by minimum score if specified
|
||||
if min_score > 0:
|
||||
context_items = [
|
||||
item for item in context_items if item.get("score", 0) >= min_score
|
||||
]
|
||||
|
||||
logger.info(
|
||||
f"Found {len(context_items)} relevant context items for: {query[:50]}..."
|
||||
)
|
||||
@@ -162,7 +153,7 @@ class GraphitiSearch:
|
||||
):
|
||||
continue
|
||||
sessions.append(data)
|
||||
except (json.JSONDecodeError, TypeError, AttributeError):
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
|
||||
# Sort by session number and return latest
|
||||
@@ -214,7 +205,7 @@ class GraphitiSearch:
|
||||
"score": getattr(result, "score", 0.0),
|
||||
}
|
||||
)
|
||||
except (json.JSONDecodeError, TypeError, AttributeError):
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
|
||||
return outcomes[:limit]
|
||||
@@ -222,107 +213,3 @@ class GraphitiSearch:
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get similar task outcomes: {e}")
|
||||
return []
|
||||
|
||||
async def get_patterns_and_gotchas(
|
||||
self,
|
||||
query: str,
|
||||
num_results: int = 5,
|
||||
min_score: float = 0.5,
|
||||
) -> tuple[list[dict], list[dict]]:
|
||||
"""
|
||||
Retrieve patterns and gotchas relevant to the current task.
|
||||
|
||||
Unlike get_relevant_context(), this specifically filters for
|
||||
EPISODE_TYPE_PATTERN and EPISODE_TYPE_GOTCHA episodes to enable
|
||||
cross-session learning.
|
||||
|
||||
Args:
|
||||
query: Search query (task description)
|
||||
num_results: Max results per type
|
||||
min_score: Minimum relevance score (0.0-1.0)
|
||||
|
||||
Returns:
|
||||
Tuple of (patterns, gotchas) lists
|
||||
"""
|
||||
patterns = []
|
||||
gotchas = []
|
||||
|
||||
try:
|
||||
# Search with query focused on patterns
|
||||
pattern_results = await self.client.graphiti.search(
|
||||
query=f"pattern: {query}",
|
||||
group_ids=[self.group_id],
|
||||
num_results=num_results * 2,
|
||||
)
|
||||
|
||||
for result in pattern_results:
|
||||
content = getattr(result, "content", None) or getattr(
|
||||
result, "fact", None
|
||||
)
|
||||
score = getattr(result, "score", 0.0)
|
||||
|
||||
if score < min_score:
|
||||
continue
|
||||
|
||||
if content and EPISODE_TYPE_PATTERN in str(content):
|
||||
try:
|
||||
data = (
|
||||
json.loads(content) if isinstance(content, str) else content
|
||||
)
|
||||
if data.get("type") == EPISODE_TYPE_PATTERN:
|
||||
patterns.append(
|
||||
{
|
||||
"pattern": data.get("pattern", ""),
|
||||
"applies_to": data.get("applies_to", ""),
|
||||
"example": data.get("example", ""),
|
||||
"score": score,
|
||||
}
|
||||
)
|
||||
except (json.JSONDecodeError, TypeError, AttributeError):
|
||||
continue
|
||||
|
||||
# Search with query focused on gotchas
|
||||
gotcha_results = await self.client.graphiti.search(
|
||||
query=f"gotcha pitfall avoid: {query}",
|
||||
group_ids=[self.group_id],
|
||||
num_results=num_results * 2,
|
||||
)
|
||||
|
||||
for result in gotcha_results:
|
||||
content = getattr(result, "content", None) or getattr(
|
||||
result, "fact", None
|
||||
)
|
||||
score = getattr(result, "score", 0.0)
|
||||
|
||||
if score < min_score:
|
||||
continue
|
||||
|
||||
if content and EPISODE_TYPE_GOTCHA in str(content):
|
||||
try:
|
||||
data = (
|
||||
json.loads(content) if isinstance(content, str) else content
|
||||
)
|
||||
if data.get("type") == EPISODE_TYPE_GOTCHA:
|
||||
gotchas.append(
|
||||
{
|
||||
"gotcha": data.get("gotcha", ""),
|
||||
"trigger": data.get("trigger", ""),
|
||||
"solution": data.get("solution", ""),
|
||||
"score": score,
|
||||
}
|
||||
)
|
||||
except (json.JSONDecodeError, TypeError, AttributeError):
|
||||
continue
|
||||
|
||||
# Sort by score and limit
|
||||
patterns.sort(key=lambda x: x.get("score", 0), reverse=True)
|
||||
gotchas.sort(key=lambda x: x.get("score", 0), reverse=True)
|
||||
|
||||
logger.info(
|
||||
f"Found {len(patterns)} patterns and {len(gotchas)} gotchas for: {query[:50]}..."
|
||||
)
|
||||
return patterns[:num_results], gotchas[:num_results]
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get patterns/gotchas: {e}")
|
||||
return [], []
|
||||
|
||||
@@ -1,862 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test Script for Ollama Embedding Memory Integration
|
||||
====================================================
|
||||
|
||||
This test validates that the memory system works correctly with local Ollama
|
||||
embedding models (like embeddinggemma, nomic-embed-text) for creating and
|
||||
retrieving memories in the hybrid RAG system.
|
||||
|
||||
The test covers:
|
||||
1. Ollama embedding generation (direct API test)
|
||||
2. Creating memories with Ollama embeddings via GraphitiMemory
|
||||
3. Retrieving memories via semantic search
|
||||
4. Verifying the full create → store → retrieve cycle
|
||||
|
||||
Prerequisites:
|
||||
1. Install Ollama: https://ollama.ai/
|
||||
2. Pull an embedding model:
|
||||
ollama pull embeddinggemma # 768 dimensions (lightweight)
|
||||
ollama pull nomic-embed-text # 768 dimensions (good quality)
|
||||
3. Pull an LLM model (for knowledge graph construction):
|
||||
ollama pull deepseek-r1:7b # or llama3.2:3b, mistral:7b
|
||||
4. Start Ollama server: ollama serve
|
||||
5. Configure environment:
|
||||
export GRAPHITI_ENABLED=true
|
||||
export GRAPHITI_LLM_PROVIDER=ollama
|
||||
export GRAPHITI_EMBEDDER_PROVIDER=ollama
|
||||
export OLLAMA_LLM_MODEL=deepseek-r1:7b
|
||||
export OLLAMA_EMBEDDING_MODEL=embeddinggemma
|
||||
export OLLAMA_EMBEDDING_DIM=768
|
||||
|
||||
NOTE: graphiti-core internally uses an OpenAI reranker for search ranking.
|
||||
For full offline operation, set a dummy key: export OPENAI_API_KEY=dummy
|
||||
The reranker will fail at search time, but embedding creation works.
|
||||
For production, use OpenAI API key for best search quality.
|
||||
|
||||
Usage:
|
||||
cd apps/backend
|
||||
python integrations/graphiti/test_ollama_embedding_memory.py
|
||||
|
||||
# Run specific tests:
|
||||
python integrations/graphiti/test_ollama_embedding_memory.py --test embeddings
|
||||
python integrations/graphiti/test_ollama_embedding_memory.py --test create
|
||||
python integrations/graphiti/test_ollama_embedding_memory.py --test retrieve
|
||||
python integrations/graphiti/test_ollama_embedding_memory.py --test full-cycle
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# Add auto-claude to path
|
||||
auto_claude_dir = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(auto_claude_dir))
|
||||
|
||||
# Load .env file
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
|
||||
env_file = auto_claude_dir / ".env"
|
||||
if env_file.exists():
|
||||
load_dotenv(env_file)
|
||||
print(f"Loaded .env from {env_file}")
|
||||
except ImportError:
|
||||
print("Note: python-dotenv not installed, using environment variables only")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Helper Functions
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def print_header(title: str):
|
||||
"""Print a section header."""
|
||||
print("\n" + "=" * 70)
|
||||
print(f" {title}")
|
||||
print("=" * 70 + "\n")
|
||||
|
||||
|
||||
def print_result(label: str, value: str, success: bool = True):
|
||||
"""Print a result line."""
|
||||
status = "PASS" if success else "FAIL"
|
||||
print(f" [{status}] {label}: {value}")
|
||||
|
||||
|
||||
def print_info(message: str):
|
||||
"""Print an info line."""
|
||||
print(f" INFO: {message}")
|
||||
|
||||
|
||||
def print_step(step: int, message: str):
|
||||
"""Print a step indicator."""
|
||||
print(f"\n Step {step}: {message}")
|
||||
|
||||
|
||||
def apply_ladybug_monkeypatch():
|
||||
"""Apply LadybugDB monkeypatch for embedded database support."""
|
||||
try:
|
||||
import real_ladybug
|
||||
|
||||
sys.modules["kuzu"] = real_ladybug
|
||||
return True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Try native kuzu as fallback
|
||||
try:
|
||||
import kuzu # noqa: F401
|
||||
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 1: Ollama Embedding Generation
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_ollama_embeddings() -> bool:
|
||||
"""
|
||||
Test Ollama embedding generation directly via API.
|
||||
|
||||
This validates that Ollama is running and can generate embeddings
|
||||
with the configured model.
|
||||
"""
|
||||
print_header("Test 1: Ollama Embedding Generation")
|
||||
|
||||
ollama_model = os.environ.get("OLLAMA_EMBEDDING_MODEL", "embeddinggemma")
|
||||
ollama_base_url = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
|
||||
expected_dim = int(os.environ.get("OLLAMA_EMBEDDING_DIM", "768"))
|
||||
|
||||
print(f" Ollama Model: {ollama_model}")
|
||||
print(f" Base URL: {ollama_base_url}")
|
||||
print(f" Expected Dimension: {expected_dim}")
|
||||
print()
|
||||
|
||||
try:
|
||||
import requests
|
||||
except ImportError:
|
||||
print_result("requests library", "Not installed - pip install requests", False)
|
||||
return False
|
||||
|
||||
# Step 1: Check Ollama is running
|
||||
print_step(1, "Checking Ollama server status")
|
||||
try:
|
||||
resp = requests.get(f"{ollama_base_url}/api/tags", timeout=10)
|
||||
if resp.status_code != 200:
|
||||
print_result(
|
||||
"Ollama server",
|
||||
f"Not responding (status {resp.status_code})",
|
||||
False,
|
||||
)
|
||||
return False
|
||||
|
||||
models = resp.json().get("models", [])
|
||||
model_names = [m.get("name", "") for m in models]
|
||||
print_result("Ollama server", f"Running with {len(models)} models", True)
|
||||
|
||||
# Check if embedding model is available
|
||||
embedding_model_found = any(
|
||||
ollama_model in name or ollama_model.split(":")[0] in name
|
||||
for name in model_names
|
||||
)
|
||||
if not embedding_model_found:
|
||||
print_info(f"Model '{ollama_model}' not found. Available: {model_names}")
|
||||
print_info(f"Pull it with: ollama pull {ollama_model}")
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
print_result(
|
||||
"Ollama server",
|
||||
"Not running - start with 'ollama serve'",
|
||||
False,
|
||||
)
|
||||
return False
|
||||
|
||||
# Step 2: Generate test embedding
|
||||
print_step(2, "Generating test embeddings")
|
||||
|
||||
test_texts = [
|
||||
"This is a test memory about implementing OAuth authentication.",
|
||||
"The user prefers using TypeScript for frontend development.",
|
||||
"A gotcha discovered: always validate JWT tokens on the server side.",
|
||||
]
|
||||
|
||||
embeddings = []
|
||||
for i, text in enumerate(test_texts):
|
||||
resp = requests.post(
|
||||
f"{ollama_base_url}/api/embeddings",
|
||||
json={"model": ollama_model, "prompt": text},
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
if resp.status_code != 200:
|
||||
print_result(
|
||||
f"Embedding {i + 1}",
|
||||
f"Failed: {resp.status_code} - {resp.text[:100]}",
|
||||
False,
|
||||
)
|
||||
return False
|
||||
|
||||
data = resp.json()
|
||||
embedding = data.get("embedding", [])
|
||||
embeddings.append(embedding)
|
||||
|
||||
print_result(
|
||||
f"Embedding {i + 1}",
|
||||
f"Generated {len(embedding)} dimensions",
|
||||
True,
|
||||
)
|
||||
|
||||
# Step 3: Validate embedding dimensions
|
||||
print_step(3, "Validating embedding dimensions")
|
||||
|
||||
for i, embedding in enumerate(embeddings):
|
||||
if len(embedding) != expected_dim:
|
||||
print_result(
|
||||
f"Embedding {i + 1} dimension",
|
||||
f"Mismatch! Got {len(embedding)}, expected {expected_dim}",
|
||||
False,
|
||||
)
|
||||
print_info(f"Update OLLAMA_EMBEDDING_DIM={len(embedding)} in your config")
|
||||
return False
|
||||
print_result(
|
||||
f"Embedding {i + 1} dimension", f"{len(embedding)} matches expected", True
|
||||
)
|
||||
|
||||
# Step 4: Test embedding similarity (basic sanity check)
|
||||
print_step(4, "Testing embedding similarity")
|
||||
|
||||
def cosine_similarity(a, b):
|
||||
"""Calculate cosine similarity between two vectors."""
|
||||
dot_product = sum(x * y for x, y in zip(a, b))
|
||||
norm_a = sum(x * x for x in a) ** 0.5
|
||||
norm_b = sum(x * x for x in b) ** 0.5
|
||||
return dot_product / (norm_a * norm_b) if norm_a and norm_b else 0
|
||||
|
||||
# Generate embedding for a similar query
|
||||
query = "OAuth authentication implementation"
|
||||
resp = requests.post(
|
||||
f"{ollama_base_url}/api/embeddings",
|
||||
json={"model": ollama_model, "prompt": query},
|
||||
timeout=60,
|
||||
)
|
||||
query_embedding = resp.json().get("embedding", [])
|
||||
|
||||
similarities = [cosine_similarity(query_embedding, emb) for emb in embeddings]
|
||||
|
||||
print(f" Query: '{query}'")
|
||||
print(" Similarities to test texts:")
|
||||
for i, (text, sim) in enumerate(zip(test_texts, similarities)):
|
||||
print(f" {i + 1}. {sim:.4f} - '{text[:50]}...'")
|
||||
|
||||
# First text (about OAuth) should have highest similarity to OAuth query
|
||||
if similarities[0] > similarities[1] and similarities[0] > similarities[2]:
|
||||
print_result("Semantic similarity", "OAuth query matches OAuth text best", True)
|
||||
else:
|
||||
print_info("Similarity ordering may vary - embeddings are still working")
|
||||
|
||||
print()
|
||||
print_result("Ollama Embeddings", "All tests passed", True)
|
||||
return True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 2: Memory Creation with Ollama
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_memory_creation(test_db_path: Path) -> tuple[Path, Path, bool]:
|
||||
"""
|
||||
Test creating memories using GraphitiMemory with Ollama embeddings.
|
||||
|
||||
Returns:
|
||||
Tuple of (spec_dir, project_dir, success)
|
||||
"""
|
||||
print_header("Test 2: Memory Creation with Ollama Embeddings")
|
||||
|
||||
# Create test directories
|
||||
spec_dir = test_db_path / "test_spec"
|
||||
project_dir = test_db_path / "test_project"
|
||||
spec_dir.mkdir(parents=True, exist_ok=True)
|
||||
project_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f" Spec dir: {spec_dir}")
|
||||
print(f" Project dir: {project_dir}")
|
||||
print(f" Database path: {test_db_path}")
|
||||
print()
|
||||
|
||||
# Override database path for testing
|
||||
os.environ["GRAPHITI_DB_PATH"] = str(test_db_path / "graphiti_db")
|
||||
os.environ["GRAPHITI_DATABASE"] = "test_ollama_memory"
|
||||
|
||||
try:
|
||||
from integrations.graphiti.memory import GraphitiMemory
|
||||
except ImportError as e:
|
||||
print_result("Import GraphitiMemory", f"Failed: {e}", False)
|
||||
return spec_dir, project_dir, False
|
||||
|
||||
# Step 1: Initialize GraphitiMemory
|
||||
print_step(1, "Initializing GraphitiMemory")
|
||||
|
||||
memory = GraphitiMemory(spec_dir, project_dir)
|
||||
print(f" Is enabled: {memory.is_enabled}")
|
||||
print(f" Group ID: {memory.group_id}")
|
||||
|
||||
if not memory.is_enabled:
|
||||
print_result(
|
||||
"GraphitiMemory",
|
||||
"Not enabled - check GRAPHITI_ENABLED=true",
|
||||
False,
|
||||
)
|
||||
return spec_dir, project_dir, False
|
||||
|
||||
init_result = await memory.initialize()
|
||||
if not init_result:
|
||||
print_result("Initialize", "Failed to initialize", False)
|
||||
return spec_dir, project_dir, False
|
||||
|
||||
print_result("Initialize", "SUCCESS", True)
|
||||
|
||||
# Step 2: Save session insights
|
||||
print_step(2, "Saving session insights")
|
||||
|
||||
session_insights = {
|
||||
"subtasks_completed": ["implement-oauth-login", "add-jwt-validation"],
|
||||
"discoveries": {
|
||||
"files_understood": {
|
||||
"auth/oauth.py": "OAuth 2.0 flow implementation with Google/GitHub",
|
||||
"auth/jwt.py": "JWT token generation and validation utilities",
|
||||
},
|
||||
"patterns_found": [
|
||||
"Pattern: Use refresh tokens for long-lived sessions",
|
||||
"Pattern: Store tokens in httpOnly cookies for security",
|
||||
],
|
||||
"gotchas_encountered": [
|
||||
"Gotcha: Always validate JWT signature on server side",
|
||||
"Gotcha: OAuth state parameter prevents CSRF attacks",
|
||||
],
|
||||
},
|
||||
"what_worked": [
|
||||
"Using PyJWT for token handling",
|
||||
"Separating OAuth providers into individual modules",
|
||||
],
|
||||
"what_failed": [],
|
||||
"recommendations_for_next_session": [
|
||||
"Consider adding refresh token rotation",
|
||||
"Add rate limiting to auth endpoints",
|
||||
],
|
||||
}
|
||||
|
||||
save_result = await memory.save_session_insights(
|
||||
session_num=1, insights=session_insights
|
||||
)
|
||||
print_result(
|
||||
"save_session_insights", "SUCCESS" if save_result else "FAILED", save_result
|
||||
)
|
||||
|
||||
# Step 3: Save patterns
|
||||
print_step(3, "Saving code patterns")
|
||||
|
||||
patterns = [
|
||||
"OAuth implementation uses authorization code flow for web apps",
|
||||
"JWT tokens include user ID, roles, and expiration in payload",
|
||||
"Token refresh happens automatically when access token expires",
|
||||
]
|
||||
|
||||
for i, pattern in enumerate(patterns):
|
||||
result = await memory.save_pattern(pattern)
|
||||
print_result(f"save_pattern {i + 1}", "SUCCESS" if result else "FAILED", result)
|
||||
|
||||
# Step 4: Save gotchas
|
||||
print_step(4, "Saving gotchas (pitfalls)")
|
||||
|
||||
gotchas = [
|
||||
"Never store config values in frontend code or files checked into git",
|
||||
"API redirect URIs must exactly match the registered URIs",
|
||||
"Cache expiration times should be short for performance (15 min default)",
|
||||
]
|
||||
|
||||
for i, gotcha in enumerate(gotchas):
|
||||
result = await memory.save_gotcha(gotcha)
|
||||
print_result(f"save_gotcha {i + 1}", "SUCCESS" if result else "FAILED", result)
|
||||
|
||||
# Step 5: Save codebase discoveries
|
||||
print_step(5, "Saving codebase discoveries")
|
||||
|
||||
discoveries = {
|
||||
"api/routes/users.py": "User management API endpoints (list, create, update)",
|
||||
"middleware/logging.py": "Request logging middleware for all routes",
|
||||
"models/user.py": "User model with profile data and role management",
|
||||
"services/notifications.py": "Notification service integrations (email, SMS, push)",
|
||||
}
|
||||
|
||||
discovery_result = await memory.save_codebase_discoveries(discoveries)
|
||||
print_result(
|
||||
"save_codebase_discoveries",
|
||||
"SUCCESS" if discovery_result else "FAILED",
|
||||
discovery_result,
|
||||
)
|
||||
|
||||
# Brief wait for embedding processing
|
||||
print()
|
||||
print_info("Waiting 3 seconds for embedding processing...")
|
||||
await asyncio.sleep(3)
|
||||
|
||||
await memory.close()
|
||||
|
||||
print()
|
||||
print_result("Memory Creation", "All memories saved successfully", True)
|
||||
return spec_dir, project_dir, True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 3: Memory Retrieval with Semantic Search
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_memory_retrieval(spec_dir: Path, project_dir: Path) -> bool:
|
||||
"""
|
||||
Test retrieving memories using semantic search with Ollama embeddings.
|
||||
|
||||
This validates that saved memories can be found via semantic similarity.
|
||||
"""
|
||||
print_header("Test 3: Memory Retrieval with Semantic Search")
|
||||
|
||||
try:
|
||||
from integrations.graphiti.memory import GraphitiMemory
|
||||
except ImportError as e:
|
||||
print_result("Import GraphitiMemory", f"Failed: {e}", False)
|
||||
return False
|
||||
|
||||
# Step 1: Initialize memory (reconnect)
|
||||
print_step(1, "Reconnecting to GraphitiMemory")
|
||||
|
||||
memory = GraphitiMemory(spec_dir, project_dir)
|
||||
init_result = await memory.initialize()
|
||||
|
||||
if not init_result:
|
||||
print_result("Initialize", "Failed to reconnect", False)
|
||||
return False
|
||||
|
||||
print_result("Initialize", "Reconnected successfully", True)
|
||||
|
||||
# Step 2: Semantic search for API-related content
|
||||
print_step(2, "Searching for API-related memories")
|
||||
|
||||
api_query = "How do the API endpoints work in this project?"
|
||||
results = await memory.get_relevant_context(api_query, num_results=5)
|
||||
|
||||
print(f" Query: '{api_query}'")
|
||||
print(f" Found {len(results)} results:")
|
||||
|
||||
api_found = False
|
||||
for i, result in enumerate(results):
|
||||
content = result.get("content", "")[:100]
|
||||
result_type = result.get("type", "unknown")
|
||||
score = result.get("score", 0)
|
||||
print(f" {i + 1}. [{result_type}] (score: {score:.4f}) {content}...")
|
||||
if "api" in content.lower() or "routes" in content.lower():
|
||||
api_found = True
|
||||
|
||||
if api_found:
|
||||
print_result("API search", "Found API-related content", True)
|
||||
else:
|
||||
print_info("API content may not be in top results - checking other queries")
|
||||
|
||||
# Step 3: Search for middleware-related content
|
||||
print_step(3, "Searching for middleware patterns")
|
||||
|
||||
middleware_query = "middleware and request handling best practices"
|
||||
results = await memory.get_relevant_context(middleware_query, num_results=5)
|
||||
|
||||
print(f" Query: '{middleware_query}'")
|
||||
print(f" Found {len(results)} results:")
|
||||
|
||||
middleware_found = False
|
||||
for i, result in enumerate(results):
|
||||
content = result.get("content", "")[:100]
|
||||
result_type = result.get("type", "unknown")
|
||||
score = result.get("score", 0)
|
||||
print(f" {i + 1}. [{result_type}] (score: {score:.4f}) {content}...")
|
||||
if "middleware" in content.lower() or "routes" in content.lower():
|
||||
middleware_found = True
|
||||
|
||||
print_result(
|
||||
"Middleware search",
|
||||
"Found middleware-related content" if middleware_found else "No direct matches",
|
||||
middleware_found or len(results) > 0,
|
||||
)
|
||||
|
||||
# Step 4: Get session history
|
||||
print_step(4, "Retrieving session history")
|
||||
|
||||
history = await memory.get_session_history(limit=3)
|
||||
print(f" Found {len(history)} session records:")
|
||||
|
||||
for i, session in enumerate(history):
|
||||
session_num = session.get("session_number", "?")
|
||||
subtasks = session.get("subtasks_completed", [])
|
||||
print(f" Session {session_num}: {len(subtasks)} subtasks completed")
|
||||
for subtask in subtasks[:3]:
|
||||
print(f" - {subtask}")
|
||||
|
||||
print_result(
|
||||
"Session history", f"Retrieved {len(history)} sessions", len(history) > 0
|
||||
)
|
||||
|
||||
# Step 5: Get status summary
|
||||
print_step(5, "Memory status summary")
|
||||
|
||||
status = memory.get_status_summary()
|
||||
for key, value in status.items():
|
||||
print(f" {key}: {value}")
|
||||
|
||||
await memory.close()
|
||||
|
||||
print()
|
||||
all_passed = len(results) > 0 and len(history) > 0
|
||||
print_result(
|
||||
"Memory Retrieval",
|
||||
"All retrieval tests passed" if all_passed else "Some tests had issues",
|
||||
all_passed,
|
||||
)
|
||||
return all_passed
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Test 4: Full Create → Store → Retrieve Cycle
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def test_full_cycle(test_db_path: Path) -> bool:
|
||||
"""
|
||||
Test the complete memory lifecycle:
|
||||
1. Create unique test data
|
||||
2. Store in graph database with Ollama embeddings
|
||||
3. Search and retrieve via semantic similarity
|
||||
4. Verify retrieved data matches what was stored
|
||||
"""
|
||||
print_header("Test 4: Full Create-Store-Retrieve Cycle")
|
||||
|
||||
# Create fresh test directories
|
||||
spec_dir = test_db_path / "cycle_test_spec"
|
||||
project_dir = test_db_path / "cycle_test_project"
|
||||
spec_dir.mkdir(parents=True, exist_ok=True)
|
||||
project_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Override database path for testing
|
||||
os.environ["GRAPHITI_DB_PATH"] = str(test_db_path / "graphiti_db")
|
||||
os.environ["GRAPHITI_DATABASE"] = "test_full_cycle"
|
||||
|
||||
try:
|
||||
from integrations.graphiti.memory import GraphitiMemory
|
||||
except ImportError as e:
|
||||
print_result("Import", f"Failed: {e}", False)
|
||||
return False
|
||||
|
||||
# Step 1: Create unique test content
|
||||
print_step(1, "Creating unique test content")
|
||||
|
||||
unique_id = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
unique_pattern = (
|
||||
f"Unique pattern {unique_id}: Use dependency injection for database connections"
|
||||
)
|
||||
unique_gotcha = f"Unique gotcha {unique_id}: Always close database connections in finally blocks"
|
||||
|
||||
print(f" Unique ID: {unique_id}")
|
||||
print(f" Pattern: {unique_pattern[:60]}...")
|
||||
print(f" Gotcha: {unique_gotcha[:60]}...")
|
||||
|
||||
# Step 2: Store the content
|
||||
print_step(2, "Storing content in memory system")
|
||||
|
||||
memory = GraphitiMemory(spec_dir, project_dir)
|
||||
init_result = await memory.initialize()
|
||||
|
||||
if not init_result:
|
||||
print_result("Initialize", "Failed", False)
|
||||
return False
|
||||
|
||||
print_result("Initialize", "SUCCESS", True)
|
||||
|
||||
pattern_result = await memory.save_pattern(unique_pattern)
|
||||
print_result(
|
||||
"save_pattern", "SUCCESS" if pattern_result else "FAILED", pattern_result
|
||||
)
|
||||
|
||||
gotcha_result = await memory.save_gotcha(unique_gotcha)
|
||||
print_result("save_gotcha", "SUCCESS" if gotcha_result else "FAILED", gotcha_result)
|
||||
|
||||
# Wait for embedding processing
|
||||
print()
|
||||
print_info("Waiting 4 seconds for embedding processing and indexing...")
|
||||
await asyncio.sleep(4)
|
||||
|
||||
# Step 3: Search for the unique content
|
||||
print_step(3, "Searching for unique content")
|
||||
|
||||
# Search for the pattern
|
||||
pattern_query = "dependency injection database connections"
|
||||
pattern_results = await memory.get_relevant_context(pattern_query, num_results=5)
|
||||
|
||||
print(f" Query: '{pattern_query}'")
|
||||
print(f" Found {len(pattern_results)} results")
|
||||
|
||||
pattern_found = False
|
||||
for result in pattern_results:
|
||||
content = result.get("content", "")
|
||||
if unique_id in content:
|
||||
pattern_found = True
|
||||
print(f" MATCH: {content[:80]}...")
|
||||
|
||||
print_result(
|
||||
"Pattern retrieval",
|
||||
f"Found unique pattern (ID: {unique_id})"
|
||||
if pattern_found
|
||||
else "Unique pattern not in top results",
|
||||
pattern_found,
|
||||
)
|
||||
|
||||
# Search for the gotcha
|
||||
gotcha_query = "database connection cleanup finally block"
|
||||
gotcha_results = await memory.get_relevant_context(gotcha_query, num_results=5)
|
||||
|
||||
print(f" Query: '{gotcha_query}'")
|
||||
print(f" Found {len(gotcha_results)} results")
|
||||
|
||||
gotcha_found = False
|
||||
for result in gotcha_results:
|
||||
content = result.get("content", "")
|
||||
if unique_id in content:
|
||||
gotcha_found = True
|
||||
print(f" MATCH: {content[:80]}...")
|
||||
|
||||
print_result(
|
||||
"Gotcha retrieval",
|
||||
f"Found unique gotcha (ID: {unique_id})"
|
||||
if gotcha_found
|
||||
else "Unique gotcha not in top results",
|
||||
gotcha_found,
|
||||
)
|
||||
|
||||
# Step 4: Verify semantic similarity works
|
||||
print_step(4, "Verifying semantic similarity")
|
||||
|
||||
# Search with semantically similar but different wording
|
||||
alt_query = "closing connections properly in error handling"
|
||||
alt_results = await memory.get_relevant_context(alt_query, num_results=3)
|
||||
|
||||
print(f" Alternative query: '{alt_query}'")
|
||||
print(f" Found {len(alt_results)} semantically similar results:")
|
||||
|
||||
for i, result in enumerate(alt_results):
|
||||
content = result.get("content", "")[:80]
|
||||
score = result.get("score", 0)
|
||||
print(f" {i + 1}. (score: {score:.4f}) {content}...")
|
||||
|
||||
semantic_works = len(alt_results) > 0
|
||||
print_result(
|
||||
"Semantic similarity",
|
||||
"Working - found related content" if semantic_works else "No results",
|
||||
semantic_works,
|
||||
)
|
||||
|
||||
await memory.close()
|
||||
|
||||
# Summary
|
||||
print()
|
||||
cycle_passed = (
|
||||
pattern_result
|
||||
and gotcha_result
|
||||
and (pattern_found or gotcha_found or len(alt_results) > 0)
|
||||
)
|
||||
print_result(
|
||||
"Full Cycle Test",
|
||||
"Create-Store-Retrieve cycle verified"
|
||||
if cycle_passed
|
||||
else "Some steps had issues",
|
||||
cycle_passed,
|
||||
)
|
||||
|
||||
return cycle_passed
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Main Entry Point
|
||||
# ============================================================================
|
||||
|
||||
|
||||
async def main():
|
||||
"""Run Ollama embedding memory tests."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Test Ollama Embedding Memory Integration"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--test",
|
||||
choices=["all", "embeddings", "create", "retrieve", "full-cycle"],
|
||||
default="all",
|
||||
help="Which test to run",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--keep-db",
|
||||
action="store_true",
|
||||
help="Keep test database after completion (default: cleanup)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(" OLLAMA EMBEDDING MEMORY TEST SUITE")
|
||||
print("=" * 70)
|
||||
|
||||
# Configuration check
|
||||
print_header("Configuration Check")
|
||||
|
||||
config_items = {
|
||||
"GRAPHITI_ENABLED": os.environ.get("GRAPHITI_ENABLED", ""),
|
||||
"GRAPHITI_LLM_PROVIDER": os.environ.get("GRAPHITI_LLM_PROVIDER", ""),
|
||||
"GRAPHITI_EMBEDDER_PROVIDER": os.environ.get("GRAPHITI_EMBEDDER_PROVIDER", ""),
|
||||
"OLLAMA_LLM_MODEL": os.environ.get("OLLAMA_LLM_MODEL", ""),
|
||||
"OLLAMA_EMBEDDING_MODEL": os.environ.get("OLLAMA_EMBEDDING_MODEL", ""),
|
||||
"OLLAMA_EMBEDDING_DIM": os.environ.get("OLLAMA_EMBEDDING_DIM", ""),
|
||||
"OLLAMA_BASE_URL": os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434"),
|
||||
"OPENAI_API_KEY": "(set)"
|
||||
if os.environ.get("OPENAI_API_KEY")
|
||||
else "(not set - needed for reranker)",
|
||||
}
|
||||
|
||||
all_configured = True
|
||||
required_keys = [
|
||||
"GRAPHITI_ENABLED",
|
||||
"GRAPHITI_LLM_PROVIDER",
|
||||
"GRAPHITI_EMBEDDER_PROVIDER",
|
||||
"OLLAMA_LLM_MODEL",
|
||||
"OLLAMA_EMBEDDING_MODEL",
|
||||
]
|
||||
|
||||
for key, value in config_items.items():
|
||||
is_optional = key in [
|
||||
"OLLAMA_BASE_URL",
|
||||
"OPENAI_API_KEY",
|
||||
"OLLAMA_EMBEDDING_DIM",
|
||||
]
|
||||
is_set = bool(value) if not is_optional else True
|
||||
display_value = value or "(not set)"
|
||||
if key == "OPENAI_API_KEY":
|
||||
display_value = value # Already formatted above
|
||||
is_set = True # Optional for testing
|
||||
print_result(key, display_value, is_set)
|
||||
if key in required_keys and not bool(os.environ.get(key)):
|
||||
all_configured = False
|
||||
|
||||
if not all_configured:
|
||||
print()
|
||||
print(" Missing required configuration. Please set:")
|
||||
print(" export GRAPHITI_ENABLED=true")
|
||||
print(" export GRAPHITI_LLM_PROVIDER=ollama")
|
||||
print(" export GRAPHITI_EMBEDDER_PROVIDER=ollama")
|
||||
print(" export OLLAMA_LLM_MODEL=deepseek-r1:7b")
|
||||
print(" export OLLAMA_EMBEDDING_MODEL=embeddinggemma")
|
||||
print(" export OLLAMA_EMBEDDING_DIM=768")
|
||||
print(" export OPENAI_API_KEY=dummy # For graphiti-core reranker")
|
||||
print()
|
||||
return
|
||||
|
||||
# Check LadybugDB
|
||||
if not apply_ladybug_monkeypatch():
|
||||
print()
|
||||
print_result("LadybugDB", "Not installed - pip install real-ladybug", False)
|
||||
return
|
||||
|
||||
print_result("LadybugDB", "Installed", True)
|
||||
|
||||
# Create temp directory for test database
|
||||
test_db_path = Path(tempfile.mkdtemp(prefix="ollama_memory_test_"))
|
||||
print()
|
||||
print_info(f"Test database: {test_db_path}")
|
||||
|
||||
# Run tests
|
||||
test = args.test
|
||||
results = {}
|
||||
|
||||
try:
|
||||
if test in ["all", "embeddings"]:
|
||||
results["embeddings"] = await test_ollama_embeddings()
|
||||
|
||||
spec_dir = None
|
||||
project_dir = None
|
||||
|
||||
if test in ["all", "create"]:
|
||||
spec_dir, project_dir, results["create"] = await test_memory_creation(
|
||||
test_db_path
|
||||
)
|
||||
|
||||
if test in ["all", "retrieve"]:
|
||||
if spec_dir and project_dir:
|
||||
results["retrieve"] = await test_memory_retrieval(spec_dir, project_dir)
|
||||
else:
|
||||
print_info(
|
||||
"Skipping retrieve test - no spec/project dir from create test"
|
||||
)
|
||||
|
||||
if test in ["all", "full-cycle"]:
|
||||
results["full-cycle"] = await test_full_cycle(test_db_path)
|
||||
|
||||
finally:
|
||||
# Cleanup unless --keep-db specified
|
||||
if not args.keep_db and test_db_path.exists():
|
||||
print()
|
||||
print_info(f"Cleaning up test database: {test_db_path}")
|
||||
shutil.rmtree(test_db_path, ignore_errors=True)
|
||||
|
||||
# Summary
|
||||
print_header("TEST SUMMARY")
|
||||
|
||||
all_passed = True
|
||||
for test_name, passed in results.items():
|
||||
status = "PASSED" if passed else "FAILED"
|
||||
print(f" {test_name}: {status}")
|
||||
if not passed:
|
||||
all_passed = False
|
||||
|
||||
print()
|
||||
if all_passed:
|
||||
print(" All tests PASSED!")
|
||||
print()
|
||||
print(" The memory system is working correctly with Ollama embeddings.")
|
||||
print(" Memories can be created and retrieved using semantic search.")
|
||||
else:
|
||||
print(" Some tests FAILED. Check the output above for details.")
|
||||
print()
|
||||
print(" Common issues:")
|
||||
print(" - Ollama not running: ollama serve")
|
||||
print(" - Model not pulled: ollama pull embeddinggemma")
|
||||
print(" - Wrong dimension: Update OLLAMA_EMBEDDING_DIM to match model")
|
||||
|
||||
print()
|
||||
print(" Commands:")
|
||||
print(" # Run all tests:")
|
||||
print(" python integrations/graphiti/test_ollama_embedding_memory.py")
|
||||
print()
|
||||
print(" # Run specific test:")
|
||||
print(
|
||||
" python integrations/graphiti/test_ollama_embedding_memory.py --test embeddings"
|
||||
)
|
||||
print(
|
||||
" python integrations/graphiti/test_ollama_embedding_memory.py --test full-cycle"
|
||||
)
|
||||
print()
|
||||
print(" # Keep database for inspection:")
|
||||
print(" python integrations/graphiti/test_ollama_embedding_memory.py --keep-db")
|
||||
print()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -26,16 +26,12 @@ def create_claude_resolver() -> AIResolver:
|
||||
Create an AIResolver configured to use Claude via the Agent SDK.
|
||||
|
||||
Uses the same OAuth token pattern as the rest of the auto-claude framework.
|
||||
Reads model/thinking settings from environment variables:
|
||||
- UTILITY_MODEL_ID: Full model ID (e.g., "claude-haiku-4-5-20251001")
|
||||
- UTILITY_THINKING_BUDGET: Thinking budget tokens (e.g., "1024")
|
||||
|
||||
Returns:
|
||||
Configured AIResolver instance
|
||||
"""
|
||||
# Import here to avoid circular dependency
|
||||
from core.auth import ensure_claude_code_oauth_token, get_auth_token
|
||||
from core.model_config import get_utility_model_config
|
||||
|
||||
from .resolver import AIResolver
|
||||
|
||||
@@ -47,28 +43,23 @@ def create_claude_resolver() -> AIResolver:
|
||||
ensure_claude_code_oauth_token()
|
||||
|
||||
try:
|
||||
from core.simple_client import create_simple_client
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
except ImportError:
|
||||
logger.warning("core.simple_client not available, AI resolution unavailable")
|
||||
logger.warning("claude_agent_sdk not installed, AI resolution unavailable")
|
||||
return AIResolver()
|
||||
|
||||
# Get model settings from environment (passed from frontend)
|
||||
model, thinking_budget = get_utility_model_config()
|
||||
|
||||
logger.info(
|
||||
f"Merge resolver using model={model}, thinking_budget={thinking_budget}"
|
||||
)
|
||||
|
||||
def call_claude(system: str, user: str) -> str:
|
||||
"""Call Claude using the Agent SDK for merge resolution."""
|
||||
|
||||
async def _run_merge() -> str:
|
||||
# Create a minimal client for merge resolution
|
||||
client = create_simple_client(
|
||||
agent_type="merge_resolver",
|
||||
model=model,
|
||||
system_prompt=system,
|
||||
max_thinking_tokens=thinking_budget,
|
||||
client = ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model="sonnet",
|
||||
system_prompt=system,
|
||||
allowed_tools=[], # No tools needed for merge
|
||||
max_turns=1,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -45,8 +45,7 @@ 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()
|
||||
lines = content.split("\n")
|
||||
import_end = find_import_end(lines, file_path)
|
||||
lines.insert(import_end, change.content_after)
|
||||
content = "\n".join(lines)
|
||||
@@ -97,8 +96,7 @@ def combine_non_conflicting_changes(
|
||||
|
||||
# Add imports
|
||||
if imports:
|
||||
# Use splitlines() to handle all line ending styles (LF, CRLF, CR)
|
||||
lines = content.splitlines()
|
||||
lines = content.split("\n")
|
||||
import_end = find_import_end(lines, file_path)
|
||||
for imp in imports:
|
||||
if imp.content_after and imp.content_after not in content:
|
||||
|
||||
@@ -30,16 +30,11 @@ def analyze_with_regex(
|
||||
"""
|
||||
changes: list[SemanticChange] = []
|
||||
|
||||
# Normalize line endings to LF for consistent cross-platform behavior
|
||||
# This handles Windows CRLF, old Mac CR, and Unix LF
|
||||
before_normalized = before.replace("\r\n", "\n").replace("\r", "\n")
|
||||
after_normalized = after.replace("\r\n", "\n").replace("\r", "\n")
|
||||
|
||||
# Get a unified diff
|
||||
diff = list(
|
||||
difflib.unified_diff(
|
||||
before_normalized.splitlines(keepends=True),
|
||||
after_normalized.splitlines(keepends=True),
|
||||
before.splitlines(keepends=True),
|
||||
after.splitlines(keepends=True),
|
||||
lineterm="",
|
||||
)
|
||||
)
|
||||
@@ -94,22 +89,8 @@ def analyze_with_regex(
|
||||
# Detect function changes (simplified)
|
||||
func_pattern = get_function_pattern(ext)
|
||||
if func_pattern:
|
||||
# For JS/TS patterns with alternation, findall() returns tuples
|
||||
# Extract the non-empty match from each tuple
|
||||
def extract_func_names(matches):
|
||||
names = set()
|
||||
for match in matches:
|
||||
if isinstance(match, tuple):
|
||||
# Get the first non-empty group from the tuple
|
||||
name = next((m for m in match if m), None)
|
||||
if name:
|
||||
names.add(name)
|
||||
elif match:
|
||||
names.add(match)
|
||||
return names
|
||||
|
||||
funcs_before = extract_func_names(func_pattern.findall(before_normalized))
|
||||
funcs_after = extract_func_names(func_pattern.findall(after_normalized))
|
||||
funcs_before = set(func_pattern.findall(before))
|
||||
funcs_after = set(func_pattern.findall(after))
|
||||
|
||||
for func in funcs_after - funcs_before:
|
||||
changes.append(
|
||||
|
||||
@@ -211,18 +211,12 @@ class SemanticAnalyzer:
|
||||
"""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"))
|
||||
tree_before = parser.parse(bytes(before, "utf-8"))
|
||||
tree_after = parser.parse(bytes(after, "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)
|
||||
elements_before = self._extract_elements(tree_before, before, ext)
|
||||
elements_after = self._extract_elements(tree_after, after, ext)
|
||||
|
||||
# Compare and generate semantic changes
|
||||
changes = compare_elements(elements_before, elements_after, ext)
|
||||
|
||||
@@ -57,33 +57,18 @@ KNOWN_EMBEDDING_MODELS = {
|
||||
|
||||
# Recommended embedding models for download (shown in UI)
|
||||
RECOMMENDED_EMBEDDING_MODELS = [
|
||||
{
|
||||
"name": "qwen3-embedding:4b",
|
||||
"description": "Qwen3 4B - Balanced quality and speed",
|
||||
"size_estimate": "3.1 GB",
|
||||
"dim": 2560,
|
||||
"badge": "recommended",
|
||||
},
|
||||
{
|
||||
"name": "qwen3-embedding:8b",
|
||||
"description": "Qwen3 8B - Best embedding quality",
|
||||
"size_estimate": "6.0 GB",
|
||||
"dim": 4096,
|
||||
"badge": "quality",
|
||||
},
|
||||
{
|
||||
"name": "qwen3-embedding:0.6b",
|
||||
"description": "Qwen3 0.6B - Smallest and fastest",
|
||||
"size_estimate": "494 MB",
|
||||
"dim": 1024,
|
||||
"badge": "fast",
|
||||
},
|
||||
{
|
||||
"name": "embeddinggemma",
|
||||
"description": "Google's lightweight embedding model (768 dim)",
|
||||
"size_estimate": "621 MB",
|
||||
"dim": 768,
|
||||
},
|
||||
{
|
||||
"name": "qwen3-embedding:0.6b",
|
||||
"description": "Qwen3 small embedding model (1024 dim)",
|
||||
"size_estimate": "494 MB",
|
||||
"dim": 1024,
|
||||
},
|
||||
{
|
||||
"name": "nomic-embed-text",
|
||||
"description": "Popular general-purpose embeddings (768 dim)",
|
||||
@@ -355,59 +340,50 @@ def cmd_get_recommended_models(args) -> None:
|
||||
|
||||
|
||||
def cmd_pull_model(args) -> None:
|
||||
"""Pull (download) an Ollama model using the HTTP API for progress tracking."""
|
||||
model_name = args.model
|
||||
base_url = getattr(args, "base_url", None) or DEFAULT_OLLAMA_URL
|
||||
"""Pull (download) an Ollama model."""
|
||||
import subprocess
|
||||
|
||||
model_name = args.model
|
||||
if not model_name:
|
||||
output_error("Model name is required")
|
||||
return
|
||||
|
||||
try:
|
||||
url = f"{base_url.rstrip('/')}/api/pull"
|
||||
data = json.dumps({"name": model_name}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(url, data=data, method="POST")
|
||||
req.add_header("Content-Type", "application/json")
|
||||
|
||||
with urllib.request.urlopen(req, timeout=600) as response:
|
||||
# Ollama streams NDJSON (newline-delimited JSON) progress
|
||||
for line in response:
|
||||
try:
|
||||
progress = json.loads(line.decode("utf-8"))
|
||||
|
||||
# Emit progress as NDJSON to stderr for main process to parse
|
||||
if "completed" in progress and "total" in progress:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": progress.get("status", "downloading"),
|
||||
"completed": progress.get("completed", 0),
|
||||
"total": progress.get("total", 0),
|
||||
}
|
||||
),
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
elif progress.get("status") == "success":
|
||||
# Download complete
|
||||
pass
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
output_json(
|
||||
True,
|
||||
data={
|
||||
"model": model_name,
|
||||
"status": "completed",
|
||||
"output": ["Download completed successfully"],
|
||||
},
|
||||
# Run ollama pull command
|
||||
process = subprocess.Popen(
|
||||
["ollama", "pull", model_name],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
bufsize=1,
|
||||
)
|
||||
|
||||
except urllib.error.URLError as e:
|
||||
output_error(f"Failed to connect to Ollama: {str(e)}")
|
||||
except urllib.error.HTTPError as e:
|
||||
output_error(f"Ollama API error: {e.code} - {e.reason}")
|
||||
output_lines = []
|
||||
for line in iter(process.stdout.readline, ""):
|
||||
line = line.strip()
|
||||
if line:
|
||||
output_lines.append(line)
|
||||
# Print progress to stderr for streaming
|
||||
print(line, file=sys.stderr, flush=True)
|
||||
|
||||
process.wait()
|
||||
|
||||
if process.returncode == 0:
|
||||
output_json(
|
||||
True,
|
||||
data={
|
||||
"model": model_name,
|
||||
"status": "completed",
|
||||
"output": output_lines,
|
||||
},
|
||||
)
|
||||
else:
|
||||
output_json(
|
||||
False, error=f"Failed to pull model: {' '.join(output_lines[-3:])}"
|
||||
)
|
||||
|
||||
except FileNotFoundError:
|
||||
output_error("Ollama CLI not found. Please install Ollama first.")
|
||||
except Exception as e:
|
||||
output_error(f"Failed to pull model: {str(e)}")
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
"""
|
||||
Phase event facade for frontend synchronization.
|
||||
Re-exports from core.phase_event for clean imports.
|
||||
"""
|
||||
|
||||
from core.phase_event import (
|
||||
PHASE_MARKER_PREFIX,
|
||||
ExecutionPhase,
|
||||
emit_phase,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"PHASE_MARKER_PREFIX",
|
||||
"ExecutionPhase",
|
||||
"emit_phase",
|
||||
]
|
||||
@@ -90,55 +90,28 @@ class ProjectAnalyzer:
|
||||
This allows us to know when to re-analyze.
|
||||
"""
|
||||
hash_files = [
|
||||
# JavaScript/TypeScript
|
||||
"package.json",
|
||||
"package-lock.json",
|
||||
"yarn.lock",
|
||||
"pnpm-lock.yaml",
|
||||
# Python
|
||||
"pyproject.toml",
|
||||
"requirements.txt",
|
||||
"Pipfile",
|
||||
"poetry.lock",
|
||||
# Rust
|
||||
"Cargo.toml",
|
||||
"Cargo.lock",
|
||||
# Go
|
||||
"go.mod",
|
||||
"go.sum",
|
||||
# Ruby
|
||||
"Gemfile",
|
||||
"Gemfile.lock",
|
||||
# PHP
|
||||
"composer.json",
|
||||
"composer.lock",
|
||||
# Dart/Flutter
|
||||
"pubspec.yaml",
|
||||
"pubspec.lock",
|
||||
# Java/Kotlin/Scala
|
||||
"pom.xml",
|
||||
"build.gradle",
|
||||
"build.gradle.kts",
|
||||
"settings.gradle",
|
||||
"settings.gradle.kts",
|
||||
"build.sbt",
|
||||
# Swift
|
||||
"Package.swift",
|
||||
# Infrastructure
|
||||
"Makefile",
|
||||
"Dockerfile",
|
||||
"docker-compose.yml",
|
||||
"docker-compose.yaml",
|
||||
]
|
||||
|
||||
# Glob patterns for project files that can be anywhere in the tree
|
||||
glob_patterns = [
|
||||
"*.csproj", # C# projects
|
||||
"*.sln", # Visual Studio solutions
|
||||
"*.fsproj", # F# projects
|
||||
"*.vbproj", # VB.NET projects
|
||||
]
|
||||
|
||||
hasher = hashlib.md5(usedforsecurity=False)
|
||||
files_found = 0
|
||||
|
||||
@@ -150,36 +123,13 @@ class ProjectAnalyzer:
|
||||
hasher.update(f"{filename}:{stat.st_mtime}:{stat.st_size}".encode())
|
||||
files_found += 1
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# Check glob patterns for project files that can be anywhere
|
||||
for pattern in glob_patterns:
|
||||
for filepath in self.project_dir.glob(f"**/{pattern}"):
|
||||
try:
|
||||
stat = filepath.stat()
|
||||
rel_path = filepath.relative_to(self.project_dir)
|
||||
hasher.update(f"{rel_path}:{stat.st_mtime}:{stat.st_size}".encode())
|
||||
files_found += 1
|
||||
except OSError:
|
||||
continue
|
||||
pass
|
||||
|
||||
# If no config files found, hash the project directory structure
|
||||
# to at least detect when files are added/removed
|
||||
if files_found == 0:
|
||||
# Count source files as a proxy for project structure
|
||||
source_exts = [
|
||||
"*.py",
|
||||
"*.js",
|
||||
"*.ts",
|
||||
"*.go",
|
||||
"*.rs",
|
||||
"*.dart",
|
||||
"*.cs",
|
||||
"*.swift",
|
||||
"*.kt",
|
||||
"*.java",
|
||||
]
|
||||
for ext in source_exts:
|
||||
# Count Python, JS, and other source files as a proxy for project structure
|
||||
for ext in ["*.py", "*.js", "*.ts", "*.go", "*.rs"]:
|
||||
count = len(list(self.project_dir.glob(f"**/{ext}")))
|
||||
hasher.update(f"{ext}:{count}".encode())
|
||||
# Also include the project directory name for uniqueness
|
||||
|
||||
@@ -148,21 +148,6 @@ FRAMEWORK_COMMANDS: dict[str, set[str]] = {
|
||||
# Elixir/Erlang
|
||||
"phoenix": {"mix", "iex"},
|
||||
"ecto": {"mix"},
|
||||
# Dart/Flutter
|
||||
"flutter": {
|
||||
"flutter",
|
||||
"dart",
|
||||
"pub",
|
||||
"fvm", # Flutter Version Manager
|
||||
},
|
||||
"dart_frog": {"dart_frog", "dart"}, # Dart backend framework
|
||||
"serverpod": {"serverpod", "dart"}, # Dart backend framework
|
||||
"shelf": {"dart", "pub"}, # Dart HTTP server middleware
|
||||
"aqueduct": {
|
||||
"aqueduct",
|
||||
"dart",
|
||||
"pub",
|
||||
}, # Dart HTTP framework (deprecated but still used)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -35,40 +35,12 @@ LANGUAGE_COMMANDS: dict[str, set[str]] = {
|
||||
"tsx",
|
||||
},
|
||||
"rust": {
|
||||
# Core toolchain
|
||||
"cargo",
|
||||
"rustc",
|
||||
"rustup",
|
||||
"rustfmt",
|
||||
"clippy",
|
||||
"rust-analyzer",
|
||||
# Cargo subcommand binaries
|
||||
"cargo-clippy",
|
||||
"cargo-fmt",
|
||||
"cargo-miri",
|
||||
# Common dev tools
|
||||
"cargo-watch",
|
||||
"cargo-nextest",
|
||||
"cargo-llvm-cov",
|
||||
"cargo-tarpaulin",
|
||||
# Dependency management
|
||||
"cargo-audit",
|
||||
"cargo-deny",
|
||||
"cargo-outdated",
|
||||
"cargo-edit",
|
||||
"cargo-update",
|
||||
# Build & release
|
||||
"cargo-release",
|
||||
"cargo-dist",
|
||||
"cargo-make",
|
||||
"cargo-xtask",
|
||||
# Cross-compilation & WASM
|
||||
"cross",
|
||||
"wasm-pack",
|
||||
"wasm-bindgen",
|
||||
"trunk",
|
||||
# Documentation & publishing
|
||||
"cargo-doc",
|
||||
"mdbook",
|
||||
},
|
||||
"go": {
|
||||
"go",
|
||||
@@ -172,14 +144,6 @@ LANGUAGE_COMMANDS: dict[str, set[str]] = {
|
||||
"zig": {
|
||||
"zig",
|
||||
},
|
||||
"dart": {
|
||||
"dart",
|
||||
"dart2js",
|
||||
"dartanalyzer",
|
||||
"dartdoc",
|
||||
"dartfmt",
|
||||
"pub",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ class FrameworkDetector:
|
||||
self.detect_python_frameworks()
|
||||
self.detect_ruby_frameworks()
|
||||
self.detect_php_frameworks()
|
||||
self.detect_dart_frameworks()
|
||||
return self.frameworks
|
||||
|
||||
def detect_nodejs_frameworks(self) -> None:
|
||||
@@ -240,26 +239,3 @@ class FrameworkDetector:
|
||||
self.frameworks.append("symfony")
|
||||
if "phpunit/phpunit" in deps:
|
||||
self.frameworks.append("phpunit")
|
||||
|
||||
def detect_dart_frameworks(self) -> None:
|
||||
"""Detect Dart/Flutter frameworks from pubspec.yaml."""
|
||||
# Read pubspec.yaml as text since we don't have a YAML parser
|
||||
content = self.parser.read_text("pubspec.yaml")
|
||||
if not content:
|
||||
return
|
||||
|
||||
content_lower = content.lower()
|
||||
|
||||
# Detect Flutter
|
||||
if "flutter:" in content_lower or "sdk: flutter" in content_lower:
|
||||
self.frameworks.append("flutter")
|
||||
|
||||
# Detect Dart backend frameworks
|
||||
if "dart_frog" in content_lower:
|
||||
self.frameworks.append("dart_frog")
|
||||
if "serverpod" in content_lower:
|
||||
self.frameworks.append("serverpod")
|
||||
if "shelf" in content_lower:
|
||||
self.frameworks.append("shelf")
|
||||
if "aqueduct" in content_lower:
|
||||
self.frameworks.append("aqueduct")
|
||||
|
||||
@@ -113,10 +113,6 @@ class StackDetector:
|
||||
if self.parser.file_exists("Package.swift", "*.swift", "**/*.swift"):
|
||||
self.stack.languages.append("swift")
|
||||
|
||||
# Dart/Flutter
|
||||
if self.parser.file_exists("pubspec.yaml", "*.dart", "**/*.dart"):
|
||||
self.stack.languages.append("dart")
|
||||
|
||||
def detect_package_managers(self) -> None:
|
||||
"""Detect package managers used."""
|
||||
# Node.js package managers
|
||||
@@ -126,7 +122,7 @@ class StackDetector:
|
||||
self.stack.package_managers.append("yarn")
|
||||
if self.parser.file_exists("pnpm-lock.yaml"):
|
||||
self.stack.package_managers.append("pnpm")
|
||||
if self.parser.file_exists("bun.lockb", "bun.lock"):
|
||||
if self.parser.file_exists("bun.lockb"):
|
||||
self.stack.package_managers.append("bun")
|
||||
if self.parser.file_exists("deno.json", "deno.jsonc"):
|
||||
self.stack.package_managers.append("deno")
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
# Codebase Fit Review Agent
|
||||
|
||||
You are a focused codebase fit review agent. You have been spawned by the orchestrating agent to verify that new code fits well within the existing codebase, follows established patterns, and doesn't reinvent existing functionality.
|
||||
|
||||
## Your Mission
|
||||
|
||||
Ensure new code integrates well with the existing codebase. Check for consistency with project conventions, reuse of existing utilities, and architectural alignment. Focus ONLY on codebase fit - not security, logic correctness, or general quality.
|
||||
|
||||
## Codebase Fit Focus Areas
|
||||
|
||||
### 1. Naming Conventions
|
||||
- **Inconsistent Naming**: Using `camelCase` when project uses `snake_case`
|
||||
- **Different Terminology**: Using `user` when codebase uses `account`
|
||||
- **Abbreviation Mismatch**: Using `usr` when codebase spells out `user`
|
||||
- **File Naming**: `MyComponent.tsx` vs `my-component.tsx` vs `myComponent.tsx`
|
||||
- **Directory Structure**: Placing files in wrong directories
|
||||
|
||||
### 2. Pattern Adherence
|
||||
- **Framework Patterns**: Not following React hooks pattern, Django views pattern, etc.
|
||||
- **Project Patterns**: Not following established error handling, logging, or API patterns
|
||||
- **Architectural Patterns**: Violating layer separation (e.g., business logic in controllers)
|
||||
- **State Management**: Using different state management approach than established
|
||||
- **Configuration Patterns**: Different config file format or location
|
||||
|
||||
### 3. Ecosystem Fit
|
||||
- **Reinventing Utilities**: Writing new helper when similar one exists
|
||||
- **Duplicate Functionality**: Adding code that duplicates existing implementation
|
||||
- **Ignoring Shared Code**: Not using established shared components/utilities
|
||||
- **Wrong Abstraction Level**: Creating too specific or too generic solutions
|
||||
- **Missing Integration**: Not integrating with existing systems (logging, metrics, etc.)
|
||||
|
||||
### 4. Architectural Consistency
|
||||
- **Layer Violations**: Calling database directly from UI components
|
||||
- **Dependency Direction**: Wrong dependency direction between modules
|
||||
- **Module Boundaries**: Crossing module boundaries inappropriately
|
||||
- **API Contracts**: Breaking established API patterns
|
||||
- **Data Flow**: Different data flow pattern than established
|
||||
|
||||
### 5. Monolithic File Detection
|
||||
- **Large Files**: Files exceeding 500 lines (should be split)
|
||||
- **God Objects**: Classes/modules doing too many unrelated things
|
||||
- **Mixed Concerns**: UI, business logic, and data access in same file
|
||||
- **Excessive Exports**: Files exporting too many unrelated items
|
||||
|
||||
### 6. Import/Dependency Patterns
|
||||
- **Import Style**: Relative vs absolute imports, import grouping
|
||||
- **Circular Dependencies**: Creating import cycles
|
||||
- **Unused Imports**: Adding imports that aren't used
|
||||
- **Dependency Injection**: Not following DI patterns when established
|
||||
|
||||
## Review Guidelines
|
||||
|
||||
### High Confidence Only
|
||||
- Only report findings with **>80% confidence**
|
||||
- Verify pattern exists in codebase before flagging deviation
|
||||
- Consider if "inconsistency" might be intentional improvement
|
||||
|
||||
### Severity Classification (All block merge except LOW)
|
||||
- **CRITICAL** (Blocker): Architectural violation that will cause maintenance problems
|
||||
- Example: Tight coupling that makes testing impossible
|
||||
- **Blocks merge: YES**
|
||||
- **HIGH** (Required): Significant deviation from established patterns
|
||||
- Example: Reimplementing existing utility, wrong directory structure
|
||||
- **Blocks merge: YES**
|
||||
- **MEDIUM** (Recommended): Inconsistency that affects maintainability
|
||||
- Example: Different naming convention, unused existing helper
|
||||
- **Blocks merge: YES** (AI fixes quickly, so be strict about quality)
|
||||
- **LOW** (Suggestion): Minor convention deviation
|
||||
- Example: Different import ordering, minor naming variation
|
||||
- **Blocks merge: NO** (optional polish)
|
||||
|
||||
### Check Before Reporting
|
||||
Before flagging a "should use existing utility" issue:
|
||||
1. Verify the existing utility actually does what the new code needs
|
||||
2. Check if existing utility has the right signature/behavior
|
||||
3. Consider if the new implementation is intentionally different
|
||||
|
||||
## Code Patterns to Flag
|
||||
|
||||
### Reinventing Existing Utilities
|
||||
```javascript
|
||||
// If codebase has: src/utils/format.ts with formatDate()
|
||||
// Flag this:
|
||||
function formatDateString(date) {
|
||||
return `${date.getMonth()}/${date.getDate()}/${date.getFullYear()}`;
|
||||
}
|
||||
// Should use: import { formatDate } from '@/utils/format';
|
||||
```
|
||||
|
||||
### Naming Convention Violations
|
||||
```python
|
||||
# If codebase uses snake_case:
|
||||
def getUserById(user_id): # Should be: get_user_by_id
|
||||
...
|
||||
|
||||
# If codebase uses specific terminology:
|
||||
class Customer: # Should be: User (if that's the codebase term)
|
||||
...
|
||||
```
|
||||
|
||||
### Architectural Violations
|
||||
```typescript
|
||||
// If codebase separates concerns:
|
||||
// In UI component:
|
||||
const users = await db.query('SELECT * FROM users'); // BAD
|
||||
// Should use: const users = await userService.getAll();
|
||||
|
||||
// If codebase has established API patterns:
|
||||
app.get('/user', ...) // BAD: singular
|
||||
app.get('/users', ...) // GOOD: matches codebase plural pattern
|
||||
```
|
||||
|
||||
### Monolithic Files
|
||||
```typescript
|
||||
// File with 800 lines doing:
|
||||
// - API handlers
|
||||
// - Business logic
|
||||
// - Database queries
|
||||
// - Utility functions
|
||||
// Should be split into separate files per concern
|
||||
```
|
||||
|
||||
### Import Pattern Violations
|
||||
```javascript
|
||||
// If codebase uses absolute imports:
|
||||
import { User } from '../../../models/user'; // BAD
|
||||
import { User } from '@/models/user'; // GOOD
|
||||
|
||||
// If codebase groups imports:
|
||||
// 1. External packages
|
||||
// 2. Internal modules
|
||||
// 3. Relative imports
|
||||
```
|
||||
|
||||
## Output Format
|
||||
|
||||
Provide findings in JSON format:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"file": "src/components/UserCard.tsx",
|
||||
"line": 15,
|
||||
"title": "Reinventing existing date formatting utility",
|
||||
"description": "This file implements custom date formatting, but the codebase already has `formatDate()` in `src/utils/date.ts` that does the same thing.",
|
||||
"category": "codebase_fit",
|
||||
"severity": "high",
|
||||
"existing_code": "src/utils/date.ts:formatDate()",
|
||||
"suggested_fix": "Replace custom implementation with: import { formatDate } from '@/utils/date';",
|
||||
"confidence": 92
|
||||
},
|
||||
{
|
||||
"file": "src/api/customers.ts",
|
||||
"line": 1,
|
||||
"title": "File uses 'customer' but codebase uses 'user'",
|
||||
"description": "This file uses 'customer' terminology but the rest of the codebase consistently uses 'user'. This creates confusion and makes search/navigation harder.",
|
||||
"category": "codebase_fit",
|
||||
"severity": "medium",
|
||||
"codebase_pattern": "src/models/user.ts, src/api/users.ts, src/services/userService.ts",
|
||||
"suggested_fix": "Rename to use 'user' terminology to match codebase conventions",
|
||||
"confidence": 88
|
||||
},
|
||||
{
|
||||
"file": "src/services/orderProcessor.ts",
|
||||
"line": 1,
|
||||
"title": "Monolithic file exceeds 500 lines",
|
||||
"description": "This file is 847 lines and contains order validation, payment processing, inventory management, and notification sending. Each should be separate.",
|
||||
"category": "codebase_fit",
|
||||
"severity": "high",
|
||||
"current_lines": 847,
|
||||
"suggested_fix": "Split into: orderValidator.ts, paymentProcessor.ts, inventoryManager.ts, notificationService.ts",
|
||||
"confidence": 95
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Verify Existing Code**: Before flagging "use existing", verify the existing code actually fits
|
||||
2. **Check Codebase Patterns**: Look at multiple files to confirm a pattern exists
|
||||
3. **Consider Evolution**: Sometimes new code is intentionally better than existing patterns
|
||||
4. **Respect Domain Boundaries**: Different domains might have different conventions
|
||||
5. **Focus on Changed Files**: Don't audit the entire codebase, focus on new/modified code
|
||||
|
||||
## What NOT to Report
|
||||
|
||||
- Security issues (handled by security agent)
|
||||
- Logic correctness (handled by logic agent)
|
||||
- Code quality metrics (handled by quality agent)
|
||||
- Personal preferences about patterns
|
||||
- Style issues covered by linters
|
||||
- Test files that intentionally have different structure
|
||||
|
||||
## Codebase Analysis Tips
|
||||
|
||||
When analyzing codebase fit, look at:
|
||||
1. **Similar Files**: How are other similar files structured?
|
||||
2. **Shared Utilities**: What's in `utils/`, `helpers/`, `shared/`?
|
||||
3. **Naming Patterns**: What naming style do existing files use?
|
||||
4. **Directory Structure**: Where do similar files live?
|
||||
5. **Import Patterns**: How do other files import dependencies?
|
||||
|
||||
Focus on **codebase consistency** - new code fitting seamlessly with existing code.
|
||||
@@ -1,158 +0,0 @@
|
||||
# Finding Validator Agent
|
||||
|
||||
You are a finding re-investigator. For each unresolved finding from a previous PR review, you must actively investigate whether it is a REAL issue or a FALSE POSITIVE.
|
||||
|
||||
Your job is to prevent false positives from persisting indefinitely by actually reading the code and verifying the issue exists.
|
||||
|
||||
## Your Mission
|
||||
|
||||
For each finding you receive:
|
||||
1. **READ** the actual code at the file/line location using the Read tool
|
||||
2. **ANALYZE** whether the described issue actually exists in the code
|
||||
3. **PROVIDE** concrete code evidence for your conclusion
|
||||
4. **RETURN** validation status with evidence
|
||||
|
||||
## Investigation Process
|
||||
|
||||
### Step 1: Fetch the Code
|
||||
|
||||
Use the Read tool to get the actual code at `finding.file` around `finding.line`.
|
||||
Get sufficient context (±20 lines minimum).
|
||||
|
||||
```
|
||||
Read the file: {finding.file}
|
||||
Focus on lines around: {finding.line}
|
||||
```
|
||||
|
||||
### Step 2: Analyze with Fresh Eyes
|
||||
|
||||
**Do NOT assume the original finding is correct.** Ask yourself:
|
||||
- Does the code ACTUALLY have this issue?
|
||||
- Is the described vulnerability/bug/problem present?
|
||||
- Could the original reviewer have misunderstood the code?
|
||||
- Is there context that makes this NOT an issue (e.g., sanitization elsewhere)?
|
||||
|
||||
Be skeptical. The original review may have hallucinated this finding.
|
||||
|
||||
### Step 3: Document Evidence
|
||||
|
||||
You MUST provide concrete evidence:
|
||||
- **Exact code snippet** you examined (copy-paste from the file)
|
||||
- **Line numbers** where you found (or didn't find) the issue
|
||||
- **Your analysis** of whether the issue exists
|
||||
- **Confidence level** (0.0-1.0) in your conclusion
|
||||
|
||||
## Validation Statuses
|
||||
|
||||
### `confirmed_valid`
|
||||
Use when you verify the issue IS real:
|
||||
- The problematic code pattern exists exactly as described
|
||||
- The vulnerability/bug is present and exploitable
|
||||
- The code quality issue genuinely impacts the codebase
|
||||
|
||||
### `dismissed_false_positive`
|
||||
Use when you verify the issue does NOT exist:
|
||||
- The described code pattern is not actually present
|
||||
- The original finding misunderstood the code
|
||||
- There is mitigating code that prevents the issue (e.g., input validation elsewhere)
|
||||
- The finding was based on incorrect assumptions
|
||||
|
||||
### `needs_human_review`
|
||||
Use when you cannot determine with confidence:
|
||||
- The issue requires runtime analysis to verify
|
||||
- The code is too complex to analyze statically
|
||||
- You have conflicting evidence
|
||||
- Your confidence is below 0.70
|
||||
|
||||
## Output Format
|
||||
|
||||
Return one result per finding:
|
||||
|
||||
```json
|
||||
{
|
||||
"finding_id": "SEC-001",
|
||||
"validation_status": "confirmed_valid",
|
||||
"code_evidence": "const query = `SELECT * FROM users WHERE id = ${userId}`;",
|
||||
"line_range": [45, 45],
|
||||
"explanation": "SQL injection vulnerability confirmed. User input 'userId' is directly interpolated into the SQL query at line 45 without any sanitization. The query is executed via db.execute() on line 46.",
|
||||
"confidence": 0.95
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"finding_id": "QUAL-002",
|
||||
"validation_status": "dismissed_false_positive",
|
||||
"code_evidence": "function processInput(data: string): string {\n const sanitized = DOMPurify.sanitize(data);\n return sanitized;\n}",
|
||||
"line_range": [23, 26],
|
||||
"explanation": "The original finding claimed XSS vulnerability, but the code uses DOMPurify.sanitize() before output. The input is properly sanitized at line 24 before being returned.",
|
||||
"confidence": 0.88
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"finding_id": "LOGIC-003",
|
||||
"validation_status": "needs_human_review",
|
||||
"code_evidence": "async function handleRequest(req) {\n // Complex async logic...\n}",
|
||||
"line_range": [100, 150],
|
||||
"explanation": "The original finding claims a race condition, but verifying this requires understanding the runtime behavior and concurrency model. Cannot determine statically.",
|
||||
"confidence": 0.45
|
||||
}
|
||||
```
|
||||
|
||||
## Confidence Guidelines
|
||||
|
||||
Rate your confidence based on how certain you are:
|
||||
|
||||
| Confidence | Meaning |
|
||||
|------------|---------|
|
||||
| 0.90-1.00 | Definitive evidence - code clearly shows the issue exists/doesn't exist |
|
||||
| 0.80-0.89 | Strong evidence - high confidence with minor uncertainty |
|
||||
| 0.70-0.79 | Moderate evidence - likely correct but some ambiguity |
|
||||
| 0.50-0.69 | Uncertain - use `needs_human_review` |
|
||||
| Below 0.50 | Insufficient evidence - must use `needs_human_review` |
|
||||
|
||||
**Minimum thresholds:**
|
||||
- To confirm as `confirmed_valid`: confidence >= 0.70
|
||||
- To dismiss as `dismissed_false_positive`: confidence >= 0.80 (higher bar for dismissal)
|
||||
- If below thresholds: must use `needs_human_review`
|
||||
|
||||
## Common False Positive Patterns
|
||||
|
||||
Watch for these patterns that often indicate false positives:
|
||||
|
||||
1. **Sanitization elsewhere**: Input is validated/sanitized before reaching the flagged code
|
||||
2. **Internal-only code**: Code only handles trusted internal data, not user input
|
||||
3. **Framework protection**: Framework provides automatic protection (e.g., ORM parameterization)
|
||||
4. **Dead code**: The flagged code is never executed in the current codebase
|
||||
5. **Test code**: The issue is in test files where it's acceptable
|
||||
6. **Misread syntax**: Original reviewer misunderstood the language syntax
|
||||
|
||||
## Common Valid Issue Patterns
|
||||
|
||||
These patterns often confirm the issue is real:
|
||||
|
||||
1. **Direct string concatenation** in SQL/commands with user input
|
||||
2. **Missing null checks** where null values can flow through
|
||||
3. **Hardcoded credentials** that are actually used (not examples)
|
||||
4. **Missing error handling** in critical paths
|
||||
5. **Race conditions** with clear concurrent access
|
||||
|
||||
## Critical Rules
|
||||
|
||||
1. **ALWAYS read the actual code** - Never rely on memory or the original finding description
|
||||
2. **ALWAYS provide code_evidence** - No empty strings. Quote the actual code.
|
||||
3. **Be skeptical of original findings** - Many AI reviews produce false positives
|
||||
4. **Higher bar for dismissal** - Need 0.80 confidence to dismiss (vs 0.70 to confirm)
|
||||
5. **When uncertain, escalate** - Use `needs_human_review` rather than guessing
|
||||
6. **Look for mitigations** - Check surrounding code for sanitization/validation
|
||||
7. **Check the full context** - Read ±20 lines, not just the flagged line
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
- **Trusting the original finding blindly** - Always verify
|
||||
- **Dismissing without reading code** - Must provide code_evidence
|
||||
- **Low confidence dismissals** - Needs 0.80+ confidence to dismiss
|
||||
- **Vague explanations** - Be specific about what you found
|
||||
- **Missing line numbers** - Always include line_range
|
||||
@@ -103,16 +103,14 @@ For important unaddressed comments, create a finding:
|
||||
|
||||
### Phase 4: Merge Readiness Assessment
|
||||
|
||||
Determine the verdict based on (Strict Quality Gates - MEDIUM also blocks):
|
||||
Determine the verdict based on:
|
||||
|
||||
| Verdict | Criteria |
|
||||
|---------|----------|
|
||||
| **READY_TO_MERGE** | All previous findings resolved, no new issues, tests pass |
|
||||
| **MERGE_WITH_CHANGES** | Previous findings resolved, only new LOW severity suggestions remain |
|
||||
| **NEEDS_REVISION** | HIGH or MEDIUM severity issues unresolved, or new HIGH/MEDIUM issues found |
|
||||
| **BLOCKED** | CRITICAL issues unresolved or new CRITICAL issues introduced |
|
||||
|
||||
Note: Both HIGH and MEDIUM block merge - AI fixes quickly, so be strict about quality.
|
||||
| **READY_TO_MERGE** | All previous findings resolved, no new critical/high issues, tests pass |
|
||||
| **MERGE_WITH_CHANGES** | Previous findings resolved, only new medium/low issues remain |
|
||||
| **NEEDS_REVISION** | Some high-severity issues unresolved or new high issues found |
|
||||
| **BLOCKED** | Critical issues unresolved or new critical issues introduced |
|
||||
|
||||
## Output Format
|
||||
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
# Comment Analysis Agent (Follow-up)
|
||||
|
||||
You are a specialized agent for analyzing comments and reviews posted since the last PR review. You have been spawned by the orchestrating agent to process feedback from contributors and AI tools.
|
||||
|
||||
## Your Mission
|
||||
|
||||
1. Analyze contributor comments for questions and concerns
|
||||
2. Triage AI tool reviews (CodeRabbit, Cursor, Gemini, etc.)
|
||||
3. Identify issues that need addressing before merge
|
||||
4. Flag unanswered questions
|
||||
|
||||
## Comment Sources
|
||||
|
||||
### Contributor Comments
|
||||
- Direct questions about implementation
|
||||
- Concerns about approach
|
||||
- Suggestions for improvement
|
||||
- Approval or rejection signals
|
||||
|
||||
### AI Tool Reviews
|
||||
Common AI reviewers you'll encounter:
|
||||
- **CodeRabbit**: Comprehensive code analysis
|
||||
- **Cursor**: AI-assisted review comments
|
||||
- **Gemini Code Assist**: Google's code reviewer
|
||||
- **GitHub Copilot**: Inline suggestions
|
||||
- **Greptile**: Codebase-aware analysis
|
||||
- **SonarCloud**: Static analysis findings
|
||||
- **Snyk**: Security scanning results
|
||||
|
||||
## Analysis Framework
|
||||
|
||||
### For Each Comment
|
||||
|
||||
1. **Identify the author**
|
||||
- Is this a human contributor or AI bot?
|
||||
- What's their role (maintainer, contributor, reviewer)?
|
||||
|
||||
2. **Classify sentiment**
|
||||
- question: Asking for clarification
|
||||
- concern: Expressing worry about approach
|
||||
- suggestion: Proposing alternative
|
||||
- praise: Positive feedback
|
||||
- neutral: Informational only
|
||||
|
||||
3. **Assess urgency**
|
||||
- Does this block merge?
|
||||
- Is a response required?
|
||||
- What action is needed?
|
||||
|
||||
4. **Extract actionable items**
|
||||
- What specific change is requested?
|
||||
- Is the concern valid?
|
||||
- How should it be addressed?
|
||||
|
||||
## Triage AI Tool Comments
|
||||
|
||||
### Critical (Must Address)
|
||||
- Security vulnerabilities flagged
|
||||
- Data loss risks
|
||||
- Authentication bypasses
|
||||
- Injection vulnerabilities
|
||||
|
||||
### Important (Should Address)
|
||||
- Logic errors in core paths
|
||||
- Missing error handling
|
||||
- Race conditions
|
||||
- Resource leaks
|
||||
|
||||
### Nice-to-Have (Consider)
|
||||
- Code style suggestions
|
||||
- Performance optimizations
|
||||
- Documentation improvements
|
||||
|
||||
### False Positive (Dismiss)
|
||||
- Incorrect analysis
|
||||
- Not applicable to this context
|
||||
- Already addressed
|
||||
- Stylistic preferences
|
||||
|
||||
## Output Format
|
||||
|
||||
### Comment Analyses
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"comment_id": "IC-12345",
|
||||
"author": "maintainer-jane",
|
||||
"is_ai_bot": false,
|
||||
"requires_response": true,
|
||||
"sentiment": "question",
|
||||
"summary": "Asks why async/await was chosen over callbacks",
|
||||
"action_needed": "Respond explaining the async choice for better error handling"
|
||||
},
|
||||
{
|
||||
"comment_id": "RC-67890",
|
||||
"author": "coderabbitai[bot]",
|
||||
"is_ai_bot": true,
|
||||
"requires_response": false,
|
||||
"sentiment": "suggestion",
|
||||
"summary": "Suggests using optional chaining for null safety",
|
||||
"action_needed": null
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Comment Findings (Issues from Comments)
|
||||
|
||||
When AI tools or contributors identify real issues:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "CMT-001",
|
||||
"file": "src/api/handler.py",
|
||||
"line": 89,
|
||||
"title": "Unhandled exception in error path (from CodeRabbit)",
|
||||
"description": "CodeRabbit correctly identified that the except block at line 89 catches Exception but doesn't log or handle it properly.",
|
||||
"category": "quality",
|
||||
"severity": "medium",
|
||||
"confidence": 0.85,
|
||||
"suggested_fix": "Add proper logging and re-raise or handle the exception appropriately",
|
||||
"fixable": true,
|
||||
"source_agent": "comment-analyzer",
|
||||
"related_to_previous": null
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Prioritization Rules
|
||||
|
||||
1. **Maintainer comments** > Contributor comments > AI bot comments
|
||||
2. **Questions from humans** always require response
|
||||
3. **Security issues from AI** should be verified and escalated
|
||||
4. **Repeated concerns** (same issue from multiple sources) are higher priority
|
||||
|
||||
## What to Flag
|
||||
|
||||
### Must Flag
|
||||
- Unanswered questions from maintainers
|
||||
- Unaddressed security findings from AI tools
|
||||
- Explicit change requests not yet implemented
|
||||
- Blocking concerns from reviewers
|
||||
|
||||
### Should Flag
|
||||
- Valid suggestions not yet addressed
|
||||
- Questions about implementation approach
|
||||
- Concerns about test coverage
|
||||
|
||||
### Can Skip
|
||||
- Resolved discussions
|
||||
- Acknowledged but deferred items
|
||||
- Style-only suggestions
|
||||
- Clearly false positive AI findings
|
||||
|
||||
## Identifying AI Bots
|
||||
|
||||
Common bot patterns:
|
||||
- `*[bot]` suffix (e.g., `coderabbitai[bot]`)
|
||||
- `*-bot` suffix
|
||||
- Known bot names: dependabot, renovate, snyk-bot, sonarcloud
|
||||
- Automated review format (structured markdown)
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Humans first**: Prioritize human feedback over AI suggestions
|
||||
2. **Context matters**: Consider the discussion thread, not just individual comments
|
||||
3. **Don't duplicate**: If an issue is already in previous findings, reference it
|
||||
4. **Be constructive**: Extract actionable items, not just concerns
|
||||
5. **Verify AI findings**: AI tools can be wrong - assess validity
|
||||
|
||||
## Sample Workflow
|
||||
|
||||
1. Collect all comments since last review timestamp
|
||||
2. Separate by source (contributor vs AI bot)
|
||||
3. For each contributor comment:
|
||||
- Classify sentiment and urgency
|
||||
- Check if response/action is needed
|
||||
4. For each AI review:
|
||||
- Triage by severity
|
||||
- Verify if finding is valid
|
||||
- Check if already addressed in new code
|
||||
5. Generate comment_analyses and comment_findings lists
|
||||
@@ -1,162 +0,0 @@
|
||||
# New Code Review Agent (Follow-up)
|
||||
|
||||
You are a specialized agent for reviewing new code added since the last PR review. You have been spawned by the orchestrating agent to identify issues in recently added changes.
|
||||
|
||||
## Your Mission
|
||||
|
||||
Review the incremental diff for:
|
||||
1. Security vulnerabilities
|
||||
2. Logic errors and edge cases
|
||||
3. Code quality issues
|
||||
4. Potential regressions
|
||||
5. Incomplete implementations
|
||||
|
||||
## Focus Areas
|
||||
|
||||
Since this is a follow-up review, focus on:
|
||||
- **New code only**: Don't re-review unchanged code
|
||||
- **Fix quality**: Are the fixes implemented correctly?
|
||||
- **Regressions**: Did fixes break other things?
|
||||
- **Incomplete work**: Are there TODOs or unfinished sections?
|
||||
|
||||
## Review Categories
|
||||
|
||||
### Security (category: "security")
|
||||
- New injection vulnerabilities (SQL, XSS, command)
|
||||
- Hardcoded secrets or credentials
|
||||
- Authentication/authorization gaps
|
||||
- Insecure data handling
|
||||
|
||||
### Logic (category: "logic")
|
||||
- Off-by-one errors
|
||||
- Null/undefined handling
|
||||
- Race conditions
|
||||
- Incorrect boundary checks
|
||||
- State management issues
|
||||
|
||||
### Quality (category: "quality")
|
||||
- Error handling gaps
|
||||
- Resource leaks
|
||||
- Performance anti-patterns
|
||||
- Code duplication
|
||||
|
||||
### Regression (category: "regression")
|
||||
- Fixes that break existing behavior
|
||||
- Removed functionality without replacement
|
||||
- Changed APIs without updating callers
|
||||
- Tests that no longer pass
|
||||
|
||||
### Incomplete Fix (category: "incomplete_fix")
|
||||
- Partial implementations
|
||||
- TODO comments left in code
|
||||
- Error paths not handled
|
||||
- Missing test coverage for fix
|
||||
|
||||
## Severity Guidelines
|
||||
|
||||
### CRITICAL
|
||||
- Security vulnerabilities exploitable in production
|
||||
- Data corruption or loss risks
|
||||
- Complete feature breakage
|
||||
|
||||
### HIGH
|
||||
- Security issues requiring specific conditions
|
||||
- Logic errors affecting core functionality
|
||||
- Regressions in important features
|
||||
|
||||
### MEDIUM
|
||||
- Code quality issues affecting maintainability
|
||||
- Minor logic issues in edge cases
|
||||
- Missing error handling
|
||||
|
||||
### LOW
|
||||
- Style inconsistencies
|
||||
- Minor optimizations
|
||||
- Documentation gaps
|
||||
|
||||
## Confidence Scoring
|
||||
|
||||
Rate confidence (0.0-1.0) based on:
|
||||
- **>0.9**: Obvious, verifiable issue
|
||||
- **0.8-0.9**: High confidence with clear evidence
|
||||
- **0.7-0.8**: Likely issue but some uncertainty
|
||||
- **<0.7**: Possible issue, needs verification
|
||||
|
||||
Only report findings with confidence >0.7.
|
||||
|
||||
## Output Format
|
||||
|
||||
Return findings in this structure:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "NEW-001",
|
||||
"file": "src/auth/login.py",
|
||||
"line": 45,
|
||||
"end_line": 48,
|
||||
"title": "SQL injection in new login query",
|
||||
"description": "The new login validation query concatenates user input directly into the SQL string without sanitization.",
|
||||
"category": "security",
|
||||
"severity": "critical",
|
||||
"confidence": 0.95,
|
||||
"suggested_fix": "Use parameterized queries: cursor.execute('SELECT * FROM users WHERE email = ?', (email,))",
|
||||
"fixable": true,
|
||||
"source_agent": "new-code-reviewer",
|
||||
"related_to_previous": null
|
||||
},
|
||||
{
|
||||
"id": "NEW-002",
|
||||
"file": "src/utils/parser.py",
|
||||
"line": 112,
|
||||
"title": "Fix introduced null pointer regression",
|
||||
"description": "The fix for LOGIC-003 removed a null check that was protecting against undefined input. Now input.data can be null.",
|
||||
"category": "regression",
|
||||
"severity": "high",
|
||||
"confidence": 0.88,
|
||||
"suggested_fix": "Restore null check: if (input && input.data) { ... }",
|
||||
"fixable": true,
|
||||
"source_agent": "new-code-reviewer",
|
||||
"related_to_previous": "LOGIC-003"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## What NOT to Report
|
||||
|
||||
- Issues in unchanged code (that's for initial review)
|
||||
- Style preferences without functional impact
|
||||
- Theoretical issues with <70% confidence
|
||||
- Duplicate findings (check if similar issue exists)
|
||||
- Issues already flagged by previous review
|
||||
|
||||
## Review Strategy
|
||||
|
||||
1. **Scan for red flags first**
|
||||
- eval(), exec(), dangerouslySetInnerHTML
|
||||
- Hardcoded passwords, API keys
|
||||
- SQL string concatenation
|
||||
- Shell command construction
|
||||
|
||||
2. **Check fix correctness**
|
||||
- Does the fix actually address the reported issue?
|
||||
- Are all code paths covered?
|
||||
- Are error cases handled?
|
||||
|
||||
3. **Look for collateral damage**
|
||||
- What else changed in the same files?
|
||||
- Could the fix affect other functionality?
|
||||
- Are there dependent changes needed?
|
||||
|
||||
4. **Verify completeness**
|
||||
- Are there TODOs left behind?
|
||||
- Is there test coverage for the changes?
|
||||
- Is documentation updated if needed?
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Be focused**: Only review new changes, not the entire PR
|
||||
2. **Consider context**: Understand what the fix was trying to achieve
|
||||
3. **Be constructive**: Suggest fixes, not just problems
|
||||
4. **Avoid nitpicking**: Focus on functional issues
|
||||
5. **Link regressions**: If a fix caused a new issue, reference the original finding
|
||||
@@ -1,189 +0,0 @@
|
||||
# Parallel Follow-up Review Orchestrator
|
||||
|
||||
You are the orchestrating agent for follow-up PR reviews. Your job is to analyze incremental changes since the last review and coordinate specialized agents to verify resolution of previous findings and identify new issues.
|
||||
|
||||
## Your Mission
|
||||
|
||||
Perform a focused, efficient follow-up review by:
|
||||
1. Analyzing the scope of changes since the last review
|
||||
2. Delegating to specialized agents based on what needs verification
|
||||
3. Synthesizing findings into a final merge verdict
|
||||
|
||||
## Available Specialist Agents
|
||||
|
||||
You have access to these specialist agents via the Task tool:
|
||||
|
||||
### 1. resolution-verifier
|
||||
**Use for**: Verifying whether previous findings have been addressed
|
||||
- Analyzes diffs to determine if issues are truly fixed
|
||||
- Checks for incomplete or incorrect fixes
|
||||
- Provides confidence scores for each resolution
|
||||
- **Invoke when**: There are previous findings to verify
|
||||
|
||||
### 2. new-code-reviewer
|
||||
**Use for**: Reviewing new code added since last review
|
||||
- Security issues in new code
|
||||
- Logic errors and edge cases
|
||||
- Code quality problems
|
||||
- Regressions that may have been introduced
|
||||
- **Invoke when**: There are substantial code changes (>50 lines diff)
|
||||
|
||||
### 3. comment-analyzer
|
||||
**Use for**: Processing contributor and AI tool feedback
|
||||
- Identifies unanswered questions from contributors
|
||||
- Triages AI tool comments (CodeRabbit, Cursor, Gemini, etc.)
|
||||
- Flags concerns that need addressing
|
||||
- **Invoke when**: There are comments or reviews since last review
|
||||
|
||||
### 4. finding-validator (CRITICAL - Prevent False Positives)
|
||||
**Use for**: Re-investigating unresolved findings to validate they are real issues
|
||||
- Reads the ACTUAL CODE at the finding location with fresh eyes
|
||||
- Actively investigates whether the described issue truly exists
|
||||
- Can DISMISS findings as false positives if original review was incorrect
|
||||
- Can CONFIRM findings as valid if issue is genuine
|
||||
- Requires concrete CODE EVIDENCE for any conclusion
|
||||
- **ALWAYS invoke after resolution-verifier for ALL unresolved findings**
|
||||
- **Invoke when**: There are findings still marked as unresolved
|
||||
|
||||
**Why this is critical**: Initial reviews may produce false positives (hallucinated issues).
|
||||
Without validation, these persist indefinitely. This agent prevents that by actually
|
||||
examining the code and determining if the issue is real.
|
||||
|
||||
## Workflow
|
||||
|
||||
### Phase 1: Analyze Scope
|
||||
Evaluate the follow-up context:
|
||||
- How many new commits?
|
||||
- How many files changed?
|
||||
- What's the diff size?
|
||||
- Are there previous findings to verify?
|
||||
- Are there new comments to process?
|
||||
|
||||
### Phase 2: Delegate to Agents
|
||||
Based on your analysis, invoke the appropriate agents:
|
||||
|
||||
**Always invoke** `resolution-verifier` if there are previous findings.
|
||||
|
||||
**ALWAYS invoke** `finding-validator` for ALL unresolved findings from resolution-verifier.
|
||||
This is CRITICAL to prevent false positives from persisting.
|
||||
|
||||
**Invoke** `new-code-reviewer` if:
|
||||
- Diff is substantial (>50 lines)
|
||||
- Changes touch security-sensitive areas
|
||||
- New files were added
|
||||
- Complex logic was modified
|
||||
|
||||
**Invoke** `comment-analyzer` if:
|
||||
- There are contributor comments since last review
|
||||
- There are AI tool reviews to triage
|
||||
- Questions remain unanswered
|
||||
|
||||
### Phase 3: Validate Unresolved Findings
|
||||
After resolution-verifier returns findings marked as unresolved:
|
||||
1. Pass ALL unresolved findings to finding-validator
|
||||
2. finding-validator will read the actual code at each location
|
||||
3. For each finding, it returns:
|
||||
- `confirmed_valid`: Issue IS real → keep as unresolved
|
||||
- `dismissed_false_positive`: Original finding was WRONG → remove from findings
|
||||
- `needs_human_review`: Cannot determine → flag for human
|
||||
|
||||
### Phase 4: Synthesize Results
|
||||
After all agents complete:
|
||||
1. Combine resolution verifications
|
||||
2. Apply validation results (remove dismissed false positives)
|
||||
3. Merge new findings (deduplicate if needed)
|
||||
4. Incorporate comment analysis
|
||||
5. Generate final verdict based on VALIDATED findings only
|
||||
|
||||
## Verdict Guidelines
|
||||
|
||||
### READY_TO_MERGE
|
||||
- All previous findings verified as resolved OR dismissed as false positives
|
||||
- No CONFIRMED_VALID critical/high issues remaining
|
||||
- No new critical/high issues
|
||||
- No blocking concerns from comments
|
||||
- Contributor questions addressed
|
||||
|
||||
### MERGE_WITH_CHANGES
|
||||
- Previous findings resolved
|
||||
- Only LOW severity new issues (suggestions)
|
||||
- Optional polish items can be addressed post-merge
|
||||
|
||||
### NEEDS_REVISION (Strict Quality Gates)
|
||||
- HIGH or MEDIUM severity findings CONFIRMED_VALID (not dismissed as false positive)
|
||||
- New HIGH or MEDIUM severity issues introduced
|
||||
- Important contributor concerns unaddressed
|
||||
- **Note: Both HIGH and MEDIUM block merge** (AI fixes quickly, so be strict)
|
||||
- **Note: Only count findings that passed validation** (dismissed_false_positive findings don't block)
|
||||
|
||||
### BLOCKED
|
||||
- CRITICAL findings remain CONFIRMED_VALID (not dismissed as false positive)
|
||||
- New CRITICAL issues introduced
|
||||
- Fundamental problems with the fix approach
|
||||
- **Note: Only block for findings that passed validation**
|
||||
|
||||
## Cross-Validation
|
||||
|
||||
When multiple agents report on the same area:
|
||||
- **Agreement boosts confidence**: If resolution-verifier and new-code-reviewer both flag an issue, increase severity
|
||||
- **Conflicts need resolution**: If agents disagree, investigate and document your reasoning
|
||||
- **Track consensus**: Note which findings have cross-agent validation
|
||||
|
||||
## Output Format
|
||||
|
||||
Provide your synthesis as a structured response matching the ParallelFollowupResponse schema:
|
||||
|
||||
```json
|
||||
{
|
||||
"analysis_summary": "Brief summary of what was analyzed",
|
||||
"agents_invoked": ["resolution-verifier", "finding-validator", "new-code-reviewer"],
|
||||
"commits_analyzed": 5,
|
||||
"files_changed": 12,
|
||||
"resolution_verifications": [...],
|
||||
"finding_validations": [
|
||||
{
|
||||
"finding_id": "SEC-001",
|
||||
"validation_status": "confirmed_valid",
|
||||
"code_evidence": "const query = `SELECT * FROM users WHERE id = ${userId}`;",
|
||||
"line_range": [45, 45],
|
||||
"explanation": "SQL injection is present - user input is concatenated...",
|
||||
"confidence": 0.92
|
||||
},
|
||||
{
|
||||
"finding_id": "QUAL-002",
|
||||
"validation_status": "dismissed_false_positive",
|
||||
"code_evidence": "const sanitized = DOMPurify.sanitize(data);",
|
||||
"line_range": [23, 26],
|
||||
"explanation": "Original finding claimed XSS but code uses DOMPurify...",
|
||||
"confidence": 0.88
|
||||
}
|
||||
],
|
||||
"new_findings": [...],
|
||||
"comment_analyses": [...],
|
||||
"comment_findings": [...],
|
||||
"agent_agreement": {
|
||||
"agreed_findings": [],
|
||||
"conflicting_findings": [],
|
||||
"resolution_notes": null
|
||||
},
|
||||
"verdict": "READY_TO_MERGE",
|
||||
"verdict_reasoning": "2 findings resolved, 1 dismissed as false positive, 1 confirmed valid but LOW severity..."
|
||||
}
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Be efficient**: Follow-up reviews should be faster than initial reviews
|
||||
2. **Focus on changes**: Only review what changed since last review
|
||||
3. **Trust but verify**: Don't assume fixes are correct just because files changed
|
||||
4. **Acknowledge progress**: Recognize genuine effort to address feedback
|
||||
5. **Be specific**: Clearly state what blocks merge if verdict is not READY_TO_MERGE
|
||||
|
||||
## Context You Will Receive
|
||||
|
||||
- Previous review summary and findings
|
||||
- New commits since last review (SHAs, messages)
|
||||
- Diff of changes since last review
|
||||
- Files modified since last review
|
||||
- Contributor comments since last review
|
||||
- AI bot comments and reviews since last review
|
||||
@@ -1,128 +0,0 @@
|
||||
# Resolution Verification Agent
|
||||
|
||||
You are a specialized agent for verifying whether previous PR review findings have been addressed. You have been spawned by the orchestrating agent to analyze diffs and determine resolution status.
|
||||
|
||||
## Your Mission
|
||||
|
||||
For each previous finding, determine whether it has been:
|
||||
- **resolved**: The issue is fully fixed
|
||||
- **partially_resolved**: Some aspects fixed, but not complete
|
||||
- **unresolved**: The issue remains or wasn't addressed
|
||||
- **cant_verify**: Not enough information to determine status
|
||||
|
||||
## Verification Process
|
||||
|
||||
For each previous finding:
|
||||
|
||||
### 1. Locate the Issue
|
||||
- Find the file mentioned in the finding
|
||||
- Check if that file was modified in the new changes
|
||||
- If file wasn't modified, the finding is likely **unresolved**
|
||||
|
||||
### 2. Analyze the Fix
|
||||
If the file was modified:
|
||||
- Look at the specific lines mentioned
|
||||
- Check if the problematic code pattern is gone
|
||||
- Verify the fix actually addresses the root cause
|
||||
- Watch for "cosmetic" fixes that don't solve the problem
|
||||
|
||||
### 3. Check for Regressions
|
||||
- Did the fix introduce new problems?
|
||||
- Is the fix approach sound?
|
||||
- Are there edge cases the fix misses?
|
||||
|
||||
### 4. Assign Confidence
|
||||
Rate your confidence (0.0-1.0):
|
||||
- **>0.9**: Clear evidence of resolution/non-resolution
|
||||
- **0.7-0.9**: Strong indicators but some uncertainty
|
||||
- **0.5-0.7**: Mixed signals, moderate confidence
|
||||
- **<0.5**: Unclear, consider marking as cant_verify
|
||||
|
||||
## Resolution Criteria
|
||||
|
||||
### RESOLVED
|
||||
The finding is resolved when:
|
||||
- The problematic code is removed or fixed
|
||||
- The fix addresses the root cause (not just symptoms)
|
||||
- No new issues were introduced by the fix
|
||||
- Edge cases are handled appropriately
|
||||
|
||||
### PARTIALLY_RESOLVED
|
||||
Mark as partially resolved when:
|
||||
- Main issue is fixed but related problems remain
|
||||
- Fix works for common cases but misses edge cases
|
||||
- Some aspects addressed but not all
|
||||
- Workaround applied instead of proper fix
|
||||
|
||||
### UNRESOLVED
|
||||
Mark as unresolved when:
|
||||
- File wasn't modified at all
|
||||
- Code pattern still present
|
||||
- Fix attempt doesn't address the actual issue
|
||||
- Problem was misunderstood
|
||||
|
||||
### CANT_VERIFY
|
||||
Use when:
|
||||
- Diff doesn't include enough context
|
||||
- Issue requires runtime verification
|
||||
- Finding references external dependencies
|
||||
- Not enough information to determine
|
||||
|
||||
## Evidence Requirements
|
||||
|
||||
For each verification, provide:
|
||||
1. **What you looked for**: The code pattern or issue from the finding
|
||||
2. **What you found**: The current state in the diff
|
||||
3. **Why you concluded**: Your reasoning for the status
|
||||
|
||||
## Output Format
|
||||
|
||||
Return verifications in this structure:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"finding_id": "SEC-001",
|
||||
"status": "resolved",
|
||||
"confidence": 0.92,
|
||||
"evidence": "The SQL query at line 45 now uses parameterized queries instead of string concatenation. The fix properly escapes all user inputs.",
|
||||
"resolution_notes": "Changed from f-string to cursor.execute() with parameters"
|
||||
},
|
||||
{
|
||||
"finding_id": "QUAL-002",
|
||||
"status": "partially_resolved",
|
||||
"confidence": 0.75,
|
||||
"evidence": "Error handling was added for the main path, but the fallback path at line 78 still lacks try-catch.",
|
||||
"resolution_notes": "Main function fixed, helper function still needs work"
|
||||
},
|
||||
{
|
||||
"finding_id": "LOGIC-003",
|
||||
"status": "unresolved",
|
||||
"confidence": 0.88,
|
||||
"evidence": "The off-by-one error remains. The loop still uses `<= length` instead of `< length`.",
|
||||
"resolution_notes": null
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### False Positives (Marking resolved when not)
|
||||
- Code moved but same bug exists elsewhere
|
||||
- Variable renamed but logic unchanged
|
||||
- Comments added but no actual fix
|
||||
- Different code path has same issue
|
||||
|
||||
### False Negatives (Marking unresolved when fixed)
|
||||
- Fix uses different approach than expected
|
||||
- Issue fixed via configuration change
|
||||
- Problem resolved by removing feature entirely
|
||||
- Upstream dependency update fixed it
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Be thorough**: Check both the specific line AND surrounding context
|
||||
2. **Consider intent**: What was the fix trying to achieve?
|
||||
3. **Look for patterns**: If one instance was fixed, were all instances fixed?
|
||||
4. **Document clearly**: Your evidence should be verifiable by others
|
||||
5. **When uncertain**: Use lower confidence, don't guess at status
|
||||
@@ -1,203 +0,0 @@
|
||||
# Logic and Correctness Review Agent
|
||||
|
||||
You are a focused logic and correctness review agent. You have been spawned by the orchestrating agent to perform deep analysis of algorithmic correctness, edge cases, and state management.
|
||||
|
||||
## Your Mission
|
||||
|
||||
Verify that the code logic is correct, handles all edge cases, and doesn't introduce subtle bugs. Focus ONLY on logic and correctness issues - not style, security, or general quality.
|
||||
|
||||
## Logic Focus Areas
|
||||
|
||||
### 1. Algorithm Correctness
|
||||
- **Wrong Algorithm**: Using inefficient or incorrect algorithm for the problem
|
||||
- **Incorrect Implementation**: Algorithm logic doesn't match the intended behavior
|
||||
- **Missing Steps**: Algorithm is incomplete or skips necessary operations
|
||||
- **Wrong Data Structure**: Using inappropriate data structure for the operation
|
||||
|
||||
### 2. Edge Cases
|
||||
- **Empty Inputs**: Empty arrays, empty strings, null/undefined values
|
||||
- **Boundary Conditions**: First/last elements, zero, negative numbers, max values
|
||||
- **Single Element**: Arrays with one item, strings with one character
|
||||
- **Large Inputs**: Integer overflow, array size limits, string length limits
|
||||
- **Invalid Inputs**: Wrong types, malformed data, unexpected formats
|
||||
|
||||
### 3. Off-By-One Errors
|
||||
- **Loop Bounds**: `<=` vs `<`, starting at 0 vs 1
|
||||
- **Array Access**: Index out of bounds, fence post errors
|
||||
- **String Operations**: Substring boundaries, character positions
|
||||
- **Range Calculations**: Inclusive vs exclusive ranges
|
||||
|
||||
### 4. State Management
|
||||
- **Race Conditions**: Concurrent access to shared state
|
||||
- **Stale State**: Using outdated values after async operations
|
||||
- **State Mutation**: Unintended side effects from mutations
|
||||
- **Initialization**: Using uninitialized or partially initialized state
|
||||
- **Cleanup**: State not reset when it should be
|
||||
|
||||
### 5. Conditional Logic
|
||||
- **Inverted Conditions**: `!condition` when `condition` was intended
|
||||
- **Missing Conditions**: Incomplete if/else chains
|
||||
- **Wrong Operators**: `&&` vs `||`, `==` vs `===`
|
||||
- **Short-Circuit Issues**: Relying on evaluation order incorrectly
|
||||
- **Truthiness Bugs**: `0`, `""`, `[]` being falsy when they're valid values
|
||||
|
||||
### 6. Async/Concurrent Issues
|
||||
- **Missing Await**: Async function called without await
|
||||
- **Promise Handling**: Unhandled rejections, missing error handling
|
||||
- **Deadlocks**: Circular dependencies in async operations
|
||||
- **Race Conditions**: Multiple async operations accessing same resource
|
||||
- **Order Dependencies**: Operations that must run in sequence but don't
|
||||
|
||||
### 7. Type Coercion & Comparisons
|
||||
- **Implicit Coercion**: `"5" + 3 = "53"` vs `"5" - 3 = 2`
|
||||
- **Equality Bugs**: `==` performing unexpected coercion
|
||||
- **Sorting Issues**: Default string sort on numbers `[1, 10, 2]`
|
||||
- **Falsy Confusion**: `0`, `""`, `null`, `undefined`, `NaN`, `false`
|
||||
|
||||
## Review Guidelines
|
||||
|
||||
### High Confidence Only
|
||||
- Only report findings with **>80% confidence**
|
||||
- Logic bugs must be demonstrable with a concrete example
|
||||
- If the edge case is theoretical without practical impact, don't report it
|
||||
|
||||
### 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
|
||||
- **Blocks merge: YES**
|
||||
- **HIGH** (Required): Logic error that will affect some users/cases
|
||||
- Example: Missing null check, incorrect boundary condition
|
||||
- **Blocks merge: YES**
|
||||
- **MEDIUM** (Recommended): Edge case not handled that could cause issues
|
||||
- Example: Empty array not handled, large input overflow
|
||||
- **Blocks merge: YES** (AI fixes quickly, so be strict about quality)
|
||||
- **LOW** (Suggestion): Minor logic improvement
|
||||
- Example: Unnecessary re-computation, suboptimal algorithm
|
||||
- **Blocks merge: NO** (optional polish)
|
||||
|
||||
### Provide Concrete Examples
|
||||
For each finding, provide:
|
||||
1. A concrete input that triggers the bug
|
||||
2. What the current code produces
|
||||
3. What it should produce
|
||||
|
||||
## Code Patterns to Flag
|
||||
|
||||
### Off-By-One Errors
|
||||
```javascript
|
||||
// BUG: Skips last element
|
||||
for (let i = 0; i < arr.length - 1; i++) { }
|
||||
|
||||
// BUG: Accesses beyond array
|
||||
for (let i = 0; i <= arr.length; i++) { }
|
||||
|
||||
// BUG: Wrong substring bounds
|
||||
str.substring(0, str.length - 1) // Missing last char
|
||||
```
|
||||
|
||||
### Edge Case Failures
|
||||
```javascript
|
||||
// BUG: Crashes on empty array
|
||||
const first = arr[0].value; // TypeError if empty
|
||||
|
||||
// BUG: NaN on empty array
|
||||
const avg = sum / arr.length; // Division by zero
|
||||
|
||||
// BUG: Wrong result for single element
|
||||
const max = Math.max(...arr.slice(1)); // Wrong if arr.length === 1
|
||||
```
|
||||
|
||||
### State & Async Bugs
|
||||
```javascript
|
||||
// BUG: Race condition
|
||||
let count = 0;
|
||||
await Promise.all(items.map(async () => {
|
||||
count++; // Not atomic!
|
||||
}));
|
||||
|
||||
// BUG: Stale closure
|
||||
for (var i = 0; i < 5; i++) {
|
||||
setTimeout(() => console.log(i), 100); // All print 5
|
||||
}
|
||||
|
||||
// BUG: Missing await
|
||||
async function process() {
|
||||
getData(); // Returns immediately, doesn't wait
|
||||
useData(); // Data not ready!
|
||||
}
|
||||
```
|
||||
|
||||
### Conditional Logic Bugs
|
||||
```javascript
|
||||
// BUG: Inverted condition
|
||||
if (!user.isAdmin) {
|
||||
grantAccess(); // Should be if (user.isAdmin)
|
||||
}
|
||||
|
||||
// BUG: Wrong operator precedence
|
||||
if (a || b && c) { // Evaluates as: a || (b && c)
|
||||
// Probably meant: (a || b) && c
|
||||
}
|
||||
|
||||
// BUG: Falsy check fails for 0
|
||||
if (!value) { // Fails when value is 0
|
||||
value = defaultValue;
|
||||
}
|
||||
```
|
||||
|
||||
## Output Format
|
||||
|
||||
Provide findings in JSON format:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"file": "src/utils/array.ts",
|
||||
"line": 23,
|
||||
"title": "Off-by-one error in array iteration",
|
||||
"description": "Loop uses `i < arr.length - 1` which skips the last element. For array [1, 2, 3], only processes [1, 2].",
|
||||
"category": "logic",
|
||||
"severity": "high",
|
||||
"example": {
|
||||
"input": "[1, 2, 3]",
|
||||
"actual_output": "Processes [1, 2]",
|
||||
"expected_output": "Processes [1, 2, 3]"
|
||||
},
|
||||
"suggested_fix": "Change loop to `i < arr.length` to include last element",
|
||||
"confidence": 95
|
||||
},
|
||||
{
|
||||
"file": "src/services/counter.ts",
|
||||
"line": 45,
|
||||
"title": "Race condition in concurrent counter increment",
|
||||
"description": "Multiple async operations increment `count` without synchronization. With 10 concurrent increments, final count could be less than 10.",
|
||||
"category": "logic",
|
||||
"severity": "critical",
|
||||
"example": {
|
||||
"input": "10 concurrent increments",
|
||||
"actual_output": "count might be 7, 8, or 9",
|
||||
"expected_output": "count should be 10"
|
||||
},
|
||||
"suggested_fix": "Use atomic operations or a mutex: await mutex.runExclusive(() => count++)",
|
||||
"confidence": 90
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
1. **Provide Examples**: Every logic bug should have a concrete triggering input
|
||||
2. **Show Impact**: Explain what goes wrong, not just that something is wrong
|
||||
3. **Be Specific**: Point to exact line and explain the logical flaw
|
||||
4. **Consider Context**: Some "bugs" are intentional (e.g., skipping last element on purpose)
|
||||
5. **Focus on Changed Code**: Prioritize reviewing additions over existing code
|
||||
|
||||
## What NOT to Report
|
||||
|
||||
- Style issues (naming, formatting)
|
||||
- Security issues (handled by security agent)
|
||||
- Performance issues (unless it's algorithmic complexity bug)
|
||||
- Code quality (duplication, complexity - handled by quality agent)
|
||||
- Test files with intentionally buggy code for testing
|
||||
|
||||
Focus on **logic correctness** - the code doing what it's supposed to do, handling all cases correctly.
|
||||
@@ -183,14 +183,12 @@ if (!exists) {
|
||||
|
||||
**Deduplicate** - Remove duplicates by (file, line, title)
|
||||
|
||||
**Generate Verdict (Strict Quality Gates):**
|
||||
**Generate Verdict:**
|
||||
- **BLOCKED** - If any CRITICAL issues or tests failing
|
||||
- **NEEDS_REVISION** - If HIGH or MEDIUM severity issues (both block merge)
|
||||
- **MERGE_WITH_CHANGES** - If only LOW severity suggestions
|
||||
- **NEEDS_REVISION** - If HIGH severity issues
|
||||
- **MERGE_WITH_CHANGES** - If only MEDIUM issues
|
||||
- **READY_TO_MERGE** - If no blocking issues + tests pass + good coverage
|
||||
|
||||
Note: MEDIUM severity blocks merge because AI fixes quickly - be strict about quality.
|
||||
|
||||
---
|
||||
|
||||
## Available Tools
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
# Parallel PR Review Orchestrator
|
||||
|
||||
You are an expert PR reviewer orchestrating a comprehensive, parallel code review. Your role is to analyze the PR, delegate to specialized review agents, and synthesize their findings into a final verdict.
|
||||
|
||||
## Core Principle
|
||||
|
||||
**YOU decide which agents to invoke based on YOUR analysis of the PR.** There are no programmatic rules - you evaluate the PR's content, complexity, and risk areas, then delegate to the appropriate specialists.
|
||||
|
||||
## Available Specialist Agents
|
||||
|
||||
You have access to these specialized review agents via the Task tool:
|
||||
|
||||
### security-reviewer
|
||||
**Description**: Security specialist for OWASP Top 10, authentication, injection, cryptographic issues, and sensitive data exposure.
|
||||
**When to use**: PRs touching auth, API endpoints, user input handling, database queries, file operations, or any security-sensitive code.
|
||||
|
||||
### quality-reviewer
|
||||
**Description**: Code quality expert for complexity, duplication, error handling, maintainability, and pattern adherence.
|
||||
**When to use**: PRs with complex logic, large functions, new patterns, or significant business logic changes.
|
||||
|
||||
### logic-reviewer
|
||||
**Description**: Logic and correctness specialist for algorithm verification, edge cases, state management, and race conditions.
|
||||
**When to use**: PRs with algorithmic changes, data transformations, state management, concurrent operations, or bug fixes.
|
||||
|
||||
### codebase-fit-reviewer
|
||||
**Description**: Codebase consistency expert for naming conventions, ecosystem fit, architectural alignment, and avoiding reinvention.
|
||||
**When to use**: PRs introducing new patterns, large additions, or code that might duplicate existing functionality.
|
||||
|
||||
### ai-triage-reviewer
|
||||
**Description**: AI comment validator for triaging comments from CodeRabbit, Gemini Code Assist, Cursor, Greptile, and other AI reviewers.
|
||||
**When to use**: PRs that have existing AI review comments that need validation.
|
||||
|
||||
## Your Workflow
|
||||
|
||||
### Phase 1: Analysis
|
||||
|
||||
Analyze the PR thoroughly:
|
||||
|
||||
1. **Understand the Goal**: What does this PR claim to do? Bug fix? Feature? Refactor?
|
||||
2. **Assess Scope**: How many files? What types? What areas of the codebase?
|
||||
3. **Identify Risk Areas**: Security-sensitive? Complex logic? New patterns?
|
||||
4. **Check for AI Comments**: Are there existing AI reviewer comments to triage?
|
||||
|
||||
### Phase 2: Delegation
|
||||
|
||||
Based on your analysis, invoke the appropriate specialist agents. You can invoke multiple agents in parallel by calling the Task tool multiple times in the same response.
|
||||
|
||||
**Delegation Guidelines** (YOU decide, these are suggestions):
|
||||
|
||||
- **Small PRs (1-5 files)**: At minimum, invoke one agent for deep analysis. Choose based on content.
|
||||
- **Medium PRs (5-20 files)**: Invoke 2-3 agents covering different aspects (e.g., security + quality).
|
||||
- **Large PRs (20+ files)**: Invoke 3-4 agents with focused file assignments.
|
||||
- **Security-sensitive changes**: Always invoke security-reviewer.
|
||||
- **Complex logic changes**: Always invoke logic-reviewer.
|
||||
- **New patterns/large additions**: Always invoke codebase-fit-reviewer.
|
||||
- **Existing AI comments**: Always invoke ai-triage-reviewer.
|
||||
|
||||
**Example delegation**:
|
||||
```
|
||||
For a PR adding a new authentication endpoint:
|
||||
- Invoke security-reviewer for auth logic
|
||||
- Invoke quality-reviewer for code structure
|
||||
- Invoke logic-reviewer for edge cases in auth flow
|
||||
```
|
||||
|
||||
### Phase 3: Synthesis
|
||||
|
||||
After receiving agent results, synthesize findings:
|
||||
|
||||
1. **Aggregate**: Collect all findings from all agents
|
||||
2. **Cross-validate**:
|
||||
- If multiple agents report the same issue → boost confidence
|
||||
- If agents conflict → use your judgment to resolve
|
||||
3. **Deduplicate**: Remove overlapping findings (same file + line + issue type)
|
||||
4. **Filter**: Only include findings with confidence ≥80%
|
||||
5. **Generate Verdict**: Based on severity of remaining findings
|
||||
|
||||
## Output Format
|
||||
|
||||
After synthesis, output your final review in this JSON format:
|
||||
|
||||
```json
|
||||
{
|
||||
"analysis_summary": "Brief description of what you analyzed and why you chose those agents",
|
||||
"agents_invoked": ["security-reviewer", "quality-reviewer"],
|
||||
"findings": [
|
||||
{
|
||||
"id": "finding-1",
|
||||
"file": "src/auth/login.ts",
|
||||
"line": 45,
|
||||
"end_line": 52,
|
||||
"title": "SQL injection vulnerability in user lookup",
|
||||
"description": "User input directly interpolated into SQL query",
|
||||
"category": "security",
|
||||
"severity": "critical",
|
||||
"confidence": 0.95,
|
||||
"suggested_fix": "Use parameterized queries",
|
||||
"fixable": true,
|
||||
"source_agents": ["security-reviewer"],
|
||||
"cross_validated": false
|
||||
}
|
||||
],
|
||||
"agent_agreement": {
|
||||
"agreed_findings": ["finding-1", "finding-3"],
|
||||
"conflicting_findings": [],
|
||||
"resolution_notes": ""
|
||||
},
|
||||
"verdict": "NEEDS_REVISION",
|
||||
"verdict_reasoning": "Critical SQL injection vulnerability must be fixed before merge"
|
||||
}
|
||||
```
|
||||
|
||||
## Verdict Types (Strict Quality Gates)
|
||||
|
||||
We use strict quality gates because AI can fix issues quickly. Only LOW severity findings are optional.
|
||||
|
||||
- **READY_TO_MERGE**: No blocking issues found - can merge
|
||||
- **MERGE_WITH_CHANGES**: Only LOW (Suggestion) severity findings - can merge but consider addressing
|
||||
- **NEEDS_REVISION**: HIGH or MEDIUM severity findings that must be fixed before merge
|
||||
- **BLOCKED**: CRITICAL severity issues or failing tests - must be fixed before merge
|
||||
|
||||
**Severity → Verdict Mapping:**
|
||||
- CRITICAL → BLOCKED (must fix)
|
||||
- HIGH → NEEDS_REVISION (required fix)
|
||||
- MEDIUM → NEEDS_REVISION (recommended, improves quality - also blocks merge)
|
||||
- LOW → MERGE_WITH_CHANGES (optional suggestions)
|
||||
|
||||
## Key Principles
|
||||
|
||||
1. **YOU Decide**: No hardcoded rules - you analyze and choose agents based on content
|
||||
2. **Parallel Execution**: Invoke multiple agents in the same turn for speed
|
||||
3. **Thoroughness**: Every PR deserves analysis - never skip because it "looks simple"
|
||||
4. **Cross-Validation**: Multiple agents agreeing increases confidence
|
||||
5. **High Confidence**: Only report findings with ≥80% confidence
|
||||
6. **Actionable**: Every finding must have a specific, actionable fix
|
||||
7. **Project Agnostic**: Works for any project type - backend, frontend, fullstack, any language
|
||||
|
||||
## Remember
|
||||
|
||||
You are the orchestrator. The specialist agents provide deep expertise, but YOU make the final decisions about:
|
||||
- Which agents to invoke
|
||||
- How to resolve conflicts
|
||||
- What findings to include
|
||||
- What verdict to give
|
||||
|
||||
Quality over speed. A missed bug in production is far worse than spending extra time on review.
|
||||
@@ -62,19 +62,15 @@ 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
|
||||
|
||||
### Severity Classification (All block merge except LOW)
|
||||
- **CRITICAL** (Blocker): Bug that will cause failures in production
|
||||
### Severity Classification
|
||||
- **CRITICAL**: Bug that will cause failures in production
|
||||
- Example: Unhandled promise rejection, memory leak
|
||||
- **Blocks merge: YES**
|
||||
- **HIGH** (Required): Significant quality issue affecting maintainability
|
||||
- **HIGH**: Significant quality issue affecting maintainability
|
||||
- Example: 200-line function, duplicated business logic across 5 files
|
||||
- **Blocks merge: YES**
|
||||
- **MEDIUM** (Recommended): Quality concern that improves code quality
|
||||
- **MEDIUM**: Quality concern worth addressing
|
||||
- Example: Missing error handling, magic numbers
|
||||
- **Blocks merge: YES** (AI fixes quickly, so be strict about quality)
|
||||
- **LOW** (Suggestion): Minor improvement suggestion
|
||||
- **LOW**: Minor improvement suggestion
|
||||
- Example: Variable naming, minor refactoring opportunity
|
||||
- **Blocks merge: NO** (optional polish)
|
||||
|
||||
### Contextual Analysis
|
||||
- Consider project conventions (don't enforce personal preferences)
|
||||
|
||||
@@ -264,11 +264,11 @@ Return a JSON array with this structure:
|
||||
### Required Fields
|
||||
|
||||
- **id**: Unique identifier (e.g., "finding-1", "finding-2")
|
||||
- **severity**: `critical` | `high` | `medium` | `low` (Strict Quality Gates - all block merge except LOW)
|
||||
- **critical** (Blocker): Must fix before merge (security vulnerabilities, data loss risks) - **Blocks merge: YES**
|
||||
- **high** (Required): Should fix before merge (significant bugs, major quality issues) - **Blocks merge: YES**
|
||||
- **medium** (Recommended): Improve code quality (maintainability concerns) - **Blocks merge: YES** (AI fixes quickly)
|
||||
- **low** (Suggestion): Suggestions for improvement (minor enhancements) - **Blocks merge: NO**
|
||||
- **severity**: `critical` | `high` | `medium` | `low`
|
||||
- **critical**: Must fix before merge (security vulnerabilities, data loss risks)
|
||||
- **high**: Should fix before merge (significant bugs, major quality issues)
|
||||
- **medium**: Recommended to fix (code quality, maintainability concerns)
|
||||
- **low**: Suggestions for improvement (minor enhancements)
|
||||
- **category**: `security` | `quality` | `logic` | `test` | `docs` | `pattern` | `performance`
|
||||
- **confidence**: Float 0.0-1.0 representing your confidence this is a genuine issue (must be ≥0.80)
|
||||
- **title**: Short, specific summary (max 80 chars)
|
||||
|
||||
@@ -57,19 +57,15 @@ 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
|
||||
|
||||
### Severity Classification (All block merge except LOW)
|
||||
- **CRITICAL** (Blocker): Exploitable vulnerability leading to data breach, RCE, or system compromise
|
||||
### Severity Classification
|
||||
- **CRITICAL**: Exploitable vulnerability leading to data breach, RCE, or system compromise
|
||||
- Example: SQL injection, hardcoded admin password
|
||||
- **Blocks merge: YES**
|
||||
- **HIGH** (Required): Serious security flaw that could be exploited
|
||||
- **HIGH**: Serious security flaw that could be exploited
|
||||
- Example: Missing authentication check, XSS vulnerability
|
||||
- **Blocks merge: YES**
|
||||
- **MEDIUM** (Recommended): Security weakness that increases risk
|
||||
- **MEDIUM**: Security weakness that increases risk
|
||||
- Example: Weak password requirements, missing security headers
|
||||
- **Blocks merge: YES** (AI fixes quickly, so be strict about security)
|
||||
- **LOW** (Suggestion): Best practice violation, minimal risk
|
||||
- **LOW**: Best practice violation, minimal risk
|
||||
- Example: Using MD5 for non-security checksums
|
||||
- **Blocks merge: NO** (optional polish)
|
||||
|
||||
### Contextual Analysis
|
||||
- Consider the application type (public API vs internal tool)
|
||||
|
||||
+16
-75
@@ -3,19 +3,12 @@ QA Fixer Agent Session
|
||||
=======================
|
||||
|
||||
Runs QA fixer sessions to resolve issues identified by the reviewer.
|
||||
|
||||
Memory Integration:
|
||||
- Retrieves past patterns, fixes, and gotchas before fixing
|
||||
- Saves fix outcomes and learnings after session
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# Memory integration for cross-session learning
|
||||
from agents.memory_manager import get_graphiti_context, save_session_memory
|
||||
from claude_agent_sdk import ClaudeSDKClient
|
||||
from debug import debug, debug_detailed, debug_error, debug_section, debug_success
|
||||
from security.tool_input_validator import get_safe_tool_input
|
||||
from task_logger import (
|
||||
LogEntryType,
|
||||
LogPhase,
|
||||
@@ -51,7 +44,6 @@ async def run_qa_fixer_session(
|
||||
spec_dir: Path,
|
||||
fix_session: int,
|
||||
verbose: bool = False,
|
||||
project_dir: Path | None = None,
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
Run a QA fixer agent session.
|
||||
@@ -61,18 +53,12 @@ async def run_qa_fixer_session(
|
||||
spec_dir: Spec directory
|
||||
fix_session: Fix iteration number
|
||||
verbose: Whether to show detailed output
|
||||
project_dir: Project root directory (for memory context)
|
||||
|
||||
Returns:
|
||||
(status, response_text) where status is:
|
||||
- "fixed" if fixes were applied
|
||||
- "error" if an error occurred
|
||||
"""
|
||||
# Derive project_dir from spec_dir if not provided
|
||||
# spec_dir is typically: /project/.auto-claude/specs/001-name/
|
||||
if project_dir is None:
|
||||
# Walk up from spec_dir to find project root
|
||||
project_dir = spec_dir.parent.parent.parent
|
||||
debug_section("qa_fixer", f"QA Fixer Session {fix_session}")
|
||||
debug(
|
||||
"qa_fixer",
|
||||
@@ -102,20 +88,6 @@ async def run_qa_fixer_session(
|
||||
prompt = load_qa_fixer_prompt()
|
||||
debug_detailed("qa_fixer", "Loaded QA fixer prompt", prompt_length=len(prompt))
|
||||
|
||||
# Retrieve memory context for fixer (past fixes, patterns, gotchas)
|
||||
fixer_memory_context = await get_graphiti_context(
|
||||
spec_dir,
|
||||
project_dir,
|
||||
{
|
||||
"description": "Fixing QA issues and implementing corrections",
|
||||
"id": f"qa_fixer_{fix_session}",
|
||||
},
|
||||
)
|
||||
if fixer_memory_context:
|
||||
prompt += "\n\n" + fixer_memory_context
|
||||
print("✓ Memory context loaded for QA fixer")
|
||||
debug_success("qa_fixer", "Graphiti memory context loaded for fixer")
|
||||
|
||||
# Add session context - use full path so agent can find files
|
||||
prompt += f"\n\n---\n\n**Fix Session**: {fix_session}\n"
|
||||
prompt += f"**Spec Directory**: {spec_dir}\n"
|
||||
@@ -156,35 +128,34 @@ async def run_qa_fixer_session(
|
||||
)
|
||||
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
||||
tool_name = block.name
|
||||
tool_input_display = None
|
||||
tool_input = None
|
||||
tool_count += 1
|
||||
|
||||
# Safely extract tool input (handles None, non-dict, etc.)
|
||||
inp = get_safe_tool_input(block)
|
||||
|
||||
if inp:
|
||||
if "file_path" in inp:
|
||||
fp = inp["file_path"]
|
||||
if len(fp) > 50:
|
||||
fp = "..." + fp[-47:]
|
||||
tool_input_display = fp
|
||||
elif "command" in inp:
|
||||
cmd = inp["command"]
|
||||
if len(cmd) > 50:
|
||||
cmd = cmd[:47] + "..."
|
||||
tool_input_display = cmd
|
||||
if hasattr(block, "input") and block.input:
|
||||
inp = block.input
|
||||
if isinstance(inp, dict):
|
||||
if "file_path" in inp:
|
||||
fp = inp["file_path"]
|
||||
if len(fp) > 50:
|
||||
fp = "..." + fp[-47:]
|
||||
tool_input = fp
|
||||
elif "command" in inp:
|
||||
cmd = inp["command"]
|
||||
if len(cmd) > 50:
|
||||
cmd = cmd[:47] + "..."
|
||||
tool_input = cmd
|
||||
|
||||
debug(
|
||||
"qa_fixer",
|
||||
f"Tool call #{tool_count}: {tool_name}",
|
||||
tool_input=tool_input_display,
|
||||
tool_input=tool_input,
|
||||
)
|
||||
|
||||
# Log tool start (handles printing)
|
||||
if task_logger:
|
||||
task_logger.tool_start(
|
||||
tool_name,
|
||||
tool_input_display,
|
||||
tool_input,
|
||||
LogPhase.VALIDATION,
|
||||
print_to_console=True,
|
||||
)
|
||||
@@ -271,42 +242,12 @@ async def run_qa_fixer_session(
|
||||
if status
|
||||
else False,
|
||||
)
|
||||
|
||||
# Save fixer session insights to memory
|
||||
fixer_discoveries = {
|
||||
"files_understood": {},
|
||||
"patterns_found": [
|
||||
f"QA fixer session {fix_session}: Applied fixes from QA_FIX_REQUEST.md"
|
||||
],
|
||||
"gotchas_encountered": [],
|
||||
}
|
||||
|
||||
if status and status.get("ready_for_qa_revalidation"):
|
||||
debug_success("qa_fixer", "Fixes applied, ready for QA revalidation")
|
||||
# Save successful fix session to memory
|
||||
await save_session_memory(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id=f"qa_fixer_{fix_session}",
|
||||
session_num=fix_session,
|
||||
success=True,
|
||||
subtasks_completed=[f"qa_fixer_{fix_session}"],
|
||||
discoveries=fixer_discoveries,
|
||||
)
|
||||
return "fixed", response_text
|
||||
else:
|
||||
# Fixer didn't update the status properly, but we'll trust it worked
|
||||
debug_success("qa_fixer", "Fixes assumed applied (status not updated)")
|
||||
# Still save to memory as successful (fixes were attempted)
|
||||
await save_session_memory(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id=f"qa_fixer_{fix_session}",
|
||||
session_num=fix_session,
|
||||
success=True,
|
||||
subtasks_completed=[f"qa_fixer_{fix_session}"],
|
||||
discoveries=fixer_discoveries,
|
||||
)
|
||||
return "fixed", response_text
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -20,7 +20,6 @@ from linear_updater import (
|
||||
linear_qa_started,
|
||||
)
|
||||
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 task_logger import (
|
||||
LogPhase,
|
||||
@@ -110,9 +109,6 @@ async def run_qa_validation_loop(
|
||||
print(f" Progress: {completed}/{total} subtasks completed")
|
||||
return False
|
||||
|
||||
# Emit phase event at start of QA validation (before any early returns)
|
||||
emit_phase(ExecutionPhase.QA_REVIEW, "Starting QA validation")
|
||||
|
||||
# Check if there's pending human feedback that needs to be processed
|
||||
fix_request_file = spec_dir / "QA_FIX_REQUEST.md"
|
||||
has_human_feedback = fix_request_file.exists()
|
||||
@@ -130,7 +126,6 @@ async def run_qa_validation_loop(
|
||||
"Human feedback detected - will run fixer first",
|
||||
fix_request_file=str(fix_request_file),
|
||||
)
|
||||
emit_phase(ExecutionPhase.QA_FIXING, "Processing human feedback")
|
||||
print("\n📝 Human feedback detected. Running QA Fixer first...")
|
||||
|
||||
# Get model and thinking budget for fixer (uses QA phase config)
|
||||
@@ -207,9 +202,6 @@ async def run_qa_validation_loop(
|
||||
)
|
||||
|
||||
print(f"\n--- QA Iteration {qa_iteration}/{MAX_QA_ITERATIONS} ---")
|
||||
emit_phase(
|
||||
ExecutionPhase.QA_REVIEW, f"Running QA review iteration {qa_iteration}"
|
||||
)
|
||||
|
||||
# Run QA reviewer with phase-specific model and thinking budget
|
||||
qa_model = get_phase_model(spec_dir, "qa", model)
|
||||
@@ -250,7 +242,6 @@ async def run_qa_validation_loop(
|
||||
)
|
||||
|
||||
if status == "approved":
|
||||
emit_phase(ExecutionPhase.COMPLETE, "QA validation passed")
|
||||
# Reset error tracking on success
|
||||
consecutive_errors = 0
|
||||
last_error_context = None
|
||||
@@ -374,7 +365,6 @@ async def run_qa_validation_loop(
|
||||
model=qa_model,
|
||||
thinking_budget=fixer_thinking_budget,
|
||||
)
|
||||
emit_phase(ExecutionPhase.QA_FIXING, "Fixing QA issues")
|
||||
print("\nRunning QA Fixer Agent...")
|
||||
|
||||
fix_client = create_client(
|
||||
@@ -465,7 +455,6 @@ async def run_qa_validation_loop(
|
||||
print("Retrying with error feedback...")
|
||||
|
||||
# Max iterations reached without approval
|
||||
emit_phase(ExecutionPhase.FAILED, "QA validation incomplete")
|
||||
debug_error(
|
||||
"qa_loop",
|
||||
"QA VALIDATION INCOMPLETE - max iterations reached",
|
||||
|
||||
+13
-72
@@ -4,20 +4,13 @@ QA Reviewer Agent Session
|
||||
|
||||
Runs QA validation sessions to review implementation against
|
||||
acceptance criteria.
|
||||
|
||||
Memory Integration:
|
||||
- Retrieves past patterns, gotchas, and insights before QA session
|
||||
- Saves QA findings (bugs, patterns, validation outcomes) after session
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
# Memory integration for cross-session learning
|
||||
from agents.memory_manager import get_graphiti_context, save_session_memory
|
||||
from claude_agent_sdk import ClaudeSDKClient
|
||||
from debug import debug, debug_detailed, debug_error, debug_section, debug_success
|
||||
from prompts_pkg import get_qa_reviewer_prompt
|
||||
from security.tool_input_validator import get_safe_tool_input
|
||||
from task_logger import (
|
||||
LogEntryType,
|
||||
LogPhase,
|
||||
@@ -88,20 +81,6 @@ async def run_qa_agent_session(
|
||||
project_dir=str(project_dir),
|
||||
)
|
||||
|
||||
# Retrieve memory context for QA (past patterns, gotchas, validation insights)
|
||||
qa_memory_context = await get_graphiti_context(
|
||||
spec_dir,
|
||||
project_dir,
|
||||
{
|
||||
"description": "QA validation and acceptance criteria review",
|
||||
"id": f"qa_reviewer_{qa_session}",
|
||||
},
|
||||
)
|
||||
if qa_memory_context:
|
||||
prompt += "\n\n" + qa_memory_context
|
||||
print("✓ Memory context loaded for QA reviewer")
|
||||
debug_success("qa_reviewer", "Graphiti memory context loaded for QA")
|
||||
|
||||
# Add session context
|
||||
prompt += f"\n\n---\n\n**QA Session**: {qa_session}\n"
|
||||
prompt += f"**Max Iterations**: {max_iterations}\n"
|
||||
@@ -216,33 +195,32 @@ This is attempt {previous_error.get("consecutive_errors", 1) + 1}. If you fail t
|
||||
)
|
||||
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
||||
tool_name = block.name
|
||||
tool_input_display = None
|
||||
tool_input = None
|
||||
tool_count += 1
|
||||
|
||||
# Safely extract tool input (handles None, non-dict, etc.)
|
||||
inp = get_safe_tool_input(block)
|
||||
|
||||
# Extract tool input for display
|
||||
if inp:
|
||||
if "file_path" in inp:
|
||||
fp = inp["file_path"]
|
||||
if len(fp) > 50:
|
||||
fp = "..." + fp[-47:]
|
||||
tool_input_display = fp
|
||||
elif "pattern" in inp:
|
||||
tool_input_display = f"pattern: {inp['pattern']}"
|
||||
if hasattr(block, "input") and block.input:
|
||||
inp = block.input
|
||||
if isinstance(inp, dict):
|
||||
if "file_path" in inp:
|
||||
fp = inp["file_path"]
|
||||
if len(fp) > 50:
|
||||
fp = "..." + fp[-47:]
|
||||
tool_input = fp
|
||||
elif "pattern" in inp:
|
||||
tool_input = f"pattern: {inp['pattern']}"
|
||||
|
||||
debug(
|
||||
"qa_reviewer",
|
||||
f"Tool call #{tool_count}: {tool_name}",
|
||||
tool_input=tool_input_display,
|
||||
tool_input=tool_input,
|
||||
)
|
||||
|
||||
# Log tool start (handles printing)
|
||||
if task_logger:
|
||||
task_logger.tool_start(
|
||||
tool_name,
|
||||
tool_input_display,
|
||||
tool_input,
|
||||
LogPhase.VALIDATION,
|
||||
print_to_console=True,
|
||||
)
|
||||
@@ -327,48 +305,11 @@ This is attempt {previous_error.get("consecutive_errors", 1) + 1}. If you fail t
|
||||
response_length=len(response_text),
|
||||
qa_status=status.get("status") if status else "unknown",
|
||||
)
|
||||
|
||||
# Save QA session insights to memory
|
||||
qa_discoveries = {
|
||||
"files_understood": {},
|
||||
"patterns_found": [],
|
||||
"gotchas_encountered": [],
|
||||
}
|
||||
|
||||
if status and status.get("status") == "approved":
|
||||
debug_success("qa_reviewer", "QA APPROVED")
|
||||
qa_discoveries["patterns_found"].append(
|
||||
f"QA session {qa_session}: All acceptance criteria validated successfully"
|
||||
)
|
||||
# Save successful QA session to memory
|
||||
await save_session_memory(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id=f"qa_reviewer_{qa_session}",
|
||||
session_num=qa_session,
|
||||
success=True,
|
||||
subtasks_completed=[f"qa_reviewer_{qa_session}"],
|
||||
discoveries=qa_discoveries,
|
||||
)
|
||||
return "approved", response_text
|
||||
elif status and status.get("status") == "rejected":
|
||||
debug_error("qa_reviewer", "QA REJECTED")
|
||||
# Extract issues found for memory
|
||||
issues = status.get("issues_found", [])
|
||||
for issue in issues:
|
||||
qa_discoveries["gotchas_encountered"].append(
|
||||
f"QA Issue ({issue.get('type', 'unknown')}): {issue.get('title', 'No title')} at {issue.get('location', 'unknown')}"
|
||||
)
|
||||
# Save rejected QA session to memory (learning from failures)
|
||||
await save_session_memory(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id=f"qa_reviewer_{qa_session}",
|
||||
session_num=qa_session,
|
||||
success=False,
|
||||
subtasks_completed=[],
|
||||
discoveries=qa_discoveries,
|
||||
)
|
||||
return "rejected", response_text
|
||||
else:
|
||||
# Agent didn't update the status properly - provide detailed error
|
||||
|
||||
@@ -12,6 +12,3 @@ graphiti-core>=0.5.0; python_version >= "3.12"
|
||||
|
||||
# Google AI (optional - for Gemini LLM and embeddings)
|
||||
google-generativeai>=0.8.0
|
||||
|
||||
# Pydantic for structured output schemas
|
||||
pydantic>=2.0.0
|
||||
|
||||
@@ -85,7 +85,7 @@ class ClaudeBatchAnalyzer:
|
||||
try:
|
||||
import sys
|
||||
|
||||
import claude_agent_sdk # noqa: F401 - check availability
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
|
||||
backend_path = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(backend_path))
|
||||
@@ -150,13 +150,14 @@ Respond with JSON only:
|
||||
)
|
||||
|
||||
# Using Sonnet for better analysis (still just 1 call)
|
||||
from core.simple_client import create_simple_client
|
||||
|
||||
client = create_simple_client(
|
||||
agent_type="batch_analysis",
|
||||
model="claude-sonnet-4-20250514",
|
||||
system_prompt="You are an expert at analyzing GitHub issues and grouping related ones. Respond ONLY with valid JSON. Do NOT use any tools.",
|
||||
cwd=self.project_dir,
|
||||
client = ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model="claude-sonnet-4-20250514",
|
||||
system_prompt="You are an expert at analyzing GitHub issues and grouping related ones. Respond ONLY with valid JSON. Do NOT use any tools.",
|
||||
allowed_tools=[],
|
||||
max_turns=1,
|
||||
cwd=str(self.project_dir.resolve()),
|
||||
)
|
||||
)
|
||||
|
||||
async with client:
|
||||
@@ -239,6 +240,30 @@ Respond with JSON only:
|
||||
return response_text
|
||||
|
||||
|
||||
# Keep old class for backwards compatibility but mark as deprecated
|
||||
class ClaudeSimilarityDetector(ClaudeBatchAnalyzer):
|
||||
"""DEPRECATED: Use ClaudeBatchAnalyzer instead."""
|
||||
|
||||
async def compare_issues(
|
||||
self,
|
||||
repo: str,
|
||||
issue_a: dict[str, Any],
|
||||
issue_b: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""DEPRECATED: Pairwise comparison. Use analyze_and_batch_issues instead."""
|
||||
logger.warning("ClaudeSimilarityDetector.compare_issues is deprecated")
|
||||
# Simple fallback for any code still using this
|
||||
return {
|
||||
"is_similar": False,
|
||||
"overall_score": 0.0,
|
||||
"reasoning": "DEPRECATED: Use ClaudeBatchAnalyzer.analyze_and_batch_issues",
|
||||
}
|
||||
|
||||
async def precompute_embeddings(self, repo: str, issues: list[dict]) -> int:
|
||||
"""No-op for compatibility."""
|
||||
return 0
|
||||
|
||||
|
||||
class BatchStatus(str, Enum):
|
||||
"""Status of an issue batch."""
|
||||
|
||||
@@ -421,9 +446,12 @@ class IssueBatcher:
|
||||
self.max_batch_size = max_batch_size
|
||||
self.validate_batches_enabled = validate_batches
|
||||
|
||||
# Initialize Claude batch analyzer
|
||||
# Initialize Claude batch analyzer (replaces pairwise similarity detector)
|
||||
self.analyzer = ClaudeBatchAnalyzer(project_dir=self.project_dir)
|
||||
|
||||
# Keep detector for backwards compatibility (deprecated)
|
||||
self.detector = self.analyzer
|
||||
|
||||
# Initialize batch validator (uses Claude SDK with OAuth token)
|
||||
self.validator = (
|
||||
BatchValidator(
|
||||
|
||||
@@ -8,7 +8,6 @@ Reviews whether semantically grouped issues actually belong together.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
@@ -17,8 +16,13 @@ from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Check for Claude SDK availability without importing (avoids unused import warning)
|
||||
CLAUDE_SDK_AVAILABLE = importlib.util.find_spec("claude_agent_sdk") is not None
|
||||
# Check for Claude SDK availability
|
||||
try:
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
|
||||
CLAUDE_SDK_AVAILABLE = True
|
||||
except (ImportError, ValueError, SystemError):
|
||||
CLAUDE_SDK_AVAILABLE = False
|
||||
|
||||
# Default model and thinking configuration
|
||||
DEFAULT_MODEL = "claude-sonnet-4-20250514"
|
||||
@@ -203,14 +207,16 @@ class BatchValidator:
|
||||
|
||||
try:
|
||||
# Create Claude SDK client with extended thinking
|
||||
from core.simple_client import create_simple_client
|
||||
|
||||
client = create_simple_client(
|
||||
agent_type="batch_validation",
|
||||
model=self.model,
|
||||
system_prompt="You are an expert at analyzing GitHub issues and determining if they should be grouped together for a combined fix.",
|
||||
cwd=self.project_dir,
|
||||
max_thinking_tokens=self.thinking_budget, # Extended thinking
|
||||
client = ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model=self.model,
|
||||
system_prompt="You are an expert at analyzing GitHub issues and determining if they should be grouped together for a combined fix.",
|
||||
allowed_tools=[], # No tools needed for this analysis
|
||||
max_turns=1,
|
||||
cwd=str(self.project_dir.resolve()),
|
||||
settings=str(settings_file.resolve()),
|
||||
max_thinking_tokens=self.thinking_budget, # Extended thinking
|
||||
)
|
||||
)
|
||||
|
||||
async with client:
|
||||
|
||||
@@ -34,11 +34,6 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from .file_lock import FileLock, atomic_write
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from file_lock import FileLock, atomic_write
|
||||
|
||||
|
||||
@dataclass
|
||||
class BotDetectionState:
|
||||
@@ -66,14 +61,12 @@ class BotDetectionState:
|
||||
)
|
||||
|
||||
def save(self, state_dir: Path) -> None:
|
||||
"""Save state to disk with file locking for concurrent safety."""
|
||||
"""Save state to disk."""
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
state_file = state_dir / "bot_detection_state.json"
|
||||
|
||||
# Use file locking to prevent concurrent write corruption
|
||||
with FileLock(state_file, timeout=5.0, exclusive=True):
|
||||
with atomic_write(state_file) as f:
|
||||
json.dump(self.to_dict(), f, indent=2)
|
||||
with open(state_file, "w") as f:
|
||||
json.dump(self.to_dict(), f, indent=2)
|
||||
|
||||
@classmethod
|
||||
def load(cls, state_dir: Path) -> BotDetectionState:
|
||||
@@ -318,9 +311,8 @@ class BotDetector:
|
||||
return True, reason
|
||||
|
||||
# Check 2: Is the latest commit by the bot?
|
||||
# Note: GitHub API returns commits oldest-first, so commits[-1] is the latest
|
||||
if commits and not self.review_own_prs:
|
||||
latest_commit = commits[-1] if commits else None
|
||||
latest_commit = commits[0] if commits else None
|
||||
if latest_commit and self.is_bot_commit(latest_commit):
|
||||
reason = "Latest commit authored by bot (likely an auto-fix)"
|
||||
print(f"[BotDetector] SKIP PR #{pr_number}: {reason}")
|
||||
@@ -411,44 +403,3 @@ class BotDetector:
|
||||
"total_reviews_performed": total_reviews,
|
||||
"cooling_off_minutes": self.COOLING_OFF_MINUTES,
|
||||
}
|
||||
|
||||
def cleanup_stale_prs(self, max_age_days: int = 30) -> int:
|
||||
"""
|
||||
Remove tracking state for PRs that haven't been reviewed recently.
|
||||
|
||||
This prevents unbounded growth of the state file by cleaning up
|
||||
entries for PRs that are likely closed/merged.
|
||||
|
||||
Args:
|
||||
max_age_days: Remove PRs not reviewed in this many days (default: 30)
|
||||
|
||||
Returns:
|
||||
Number of PRs cleaned up
|
||||
"""
|
||||
cutoff = datetime.now() - timedelta(days=max_age_days)
|
||||
prs_to_remove: list[str] = []
|
||||
|
||||
for pr_key, last_review_str in self.state.last_review_times.items():
|
||||
try:
|
||||
last_review = datetime.fromisoformat(last_review_str)
|
||||
if last_review < cutoff:
|
||||
prs_to_remove.append(pr_key)
|
||||
except (ValueError, TypeError):
|
||||
# Invalid timestamp - mark for removal
|
||||
prs_to_remove.append(pr_key)
|
||||
|
||||
# Remove stale PRs
|
||||
for pr_key in prs_to_remove:
|
||||
if pr_key in self.state.reviewed_commits:
|
||||
del self.state.reviewed_commits[pr_key]
|
||||
if pr_key in self.state.last_review_times:
|
||||
del self.state.last_review_times[pr_key]
|
||||
|
||||
if prs_to_remove:
|
||||
self.state.save(self.state_dir)
|
||||
print(
|
||||
f"[BotDetector] Cleaned up {len(prs_to_remove)} stale PRs "
|
||||
f"(older than {max_age_days} days)"
|
||||
)
|
||||
|
||||
return len(prs_to_remove)
|
||||
|
||||
@@ -28,45 +28,6 @@ try:
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from gh_client import GHClient, PRTooLargeError
|
||||
|
||||
# Validation patterns for git refs and paths (defense-in-depth)
|
||||
# These patterns allow common valid characters while rejecting potentially dangerous ones
|
||||
SAFE_REF_PATTERN = re.compile(r"^[a-zA-Z0-9._/\-]+$")
|
||||
SAFE_PATH_PATTERN = re.compile(r"^[a-zA-Z0-9._/\-@]+$")
|
||||
|
||||
|
||||
def _validate_git_ref(ref: str) -> bool:
|
||||
"""
|
||||
Validate git ref (branch name or commit SHA) for safe use in commands.
|
||||
|
||||
Args:
|
||||
ref: Git ref to validate
|
||||
|
||||
Returns:
|
||||
True if ref is safe, False otherwise
|
||||
"""
|
||||
if not ref or len(ref) > 256:
|
||||
return False
|
||||
return bool(SAFE_REF_PATTERN.match(ref))
|
||||
|
||||
|
||||
def _validate_file_path(path: str) -> bool:
|
||||
"""
|
||||
Validate file path for safe use in git commands.
|
||||
|
||||
Args:
|
||||
path: File path to validate
|
||||
|
||||
Returns:
|
||||
True if path is safe, False otherwise
|
||||
"""
|
||||
if not path or len(path) > 1024:
|
||||
return False
|
||||
# Reject path traversal attempts
|
||||
if ".." in path or path.startswith("/"):
|
||||
return False
|
||||
return bool(SAFE_PATH_PATTERN.match(path))
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
try:
|
||||
from .models import FollowupReviewContext, PRReviewResult
|
||||
@@ -101,80 +62,27 @@ class AIBotComment:
|
||||
|
||||
|
||||
# Known AI code review bots and their display names
|
||||
# Organized by category for maintainability
|
||||
AI_BOT_PATTERNS: dict[str, str] = {
|
||||
# === AI Code Review Tools ===
|
||||
"coderabbitai": "CodeRabbit",
|
||||
"coderabbit-ai": "CodeRabbit",
|
||||
"coderabbit[bot]": "CodeRabbit",
|
||||
"greptile": "Greptile",
|
||||
"greptile[bot]": "Greptile",
|
||||
"greptile-ai": "Greptile",
|
||||
"greptile-apps": "Greptile",
|
||||
"cursor": "Cursor",
|
||||
"cursor-ai": "Cursor",
|
||||
"cursor[bot]": "Cursor",
|
||||
"sourcery-ai": "Sourcery",
|
||||
"sourcery-ai[bot]": "Sourcery",
|
||||
"sourcery-ai-bot": "Sourcery",
|
||||
"codiumai": "Qodo",
|
||||
"codium-ai[bot]": "Qodo",
|
||||
"codiumai-agent": "Qodo",
|
||||
"qodo-merge-bot": "Qodo",
|
||||
# === Google AI ===
|
||||
"gemini-code-assist": "Gemini Code Assist",
|
||||
"gemini-code-assist[bot]": "Gemini Code Assist",
|
||||
"google-code-assist": "Gemini Code Assist",
|
||||
"google-code-assist[bot]": "Gemini Code Assist",
|
||||
# === AI Coding Assistants ===
|
||||
"copilot": "GitHub Copilot",
|
||||
"copilot[bot]": "GitHub Copilot",
|
||||
"copilot-swe-agent[bot]": "GitHub Copilot",
|
||||
"sweep-ai[bot]": "Sweep AI",
|
||||
"sweep-nightly[bot]": "Sweep AI",
|
||||
"sweep-canary[bot]": "Sweep AI",
|
||||
"bitoagent": "Bito AI",
|
||||
"codeium-ai-superpowers": "Codeium",
|
||||
"devin-ai-integration": "Devin AI",
|
||||
# === GitHub Native Bots ===
|
||||
"github-actions": "GitHub Actions",
|
||||
"github-actions[bot]": "GitHub Actions",
|
||||
"github-advanced-security": "GitHub Advanced Security",
|
||||
"github-advanced-security[bot]": "GitHub Advanced Security",
|
||||
"dependabot": "Dependabot",
|
||||
"dependabot[bot]": "Dependabot",
|
||||
"github-merge-queue[bot]": "GitHub Merge Queue",
|
||||
# === Code Quality & Static Analysis ===
|
||||
"sonarcloud": "SonarCloud",
|
||||
"sonarcloud[bot]": "SonarCloud",
|
||||
"deepsource-autofix": "DeepSource",
|
||||
"deepsource-autofix[bot]": "DeepSource",
|
||||
"deepsourcebot": "DeepSource",
|
||||
"codeclimate[bot]": "CodeClimate",
|
||||
"codefactor-io[bot]": "CodeFactor",
|
||||
"codacy[bot]": "Codacy",
|
||||
# === Security Scanning ===
|
||||
"snyk-bot": "Snyk",
|
||||
"snyk[bot]": "Snyk",
|
||||
"snyk-security-bot": "Snyk",
|
||||
"gitguardian[bot]": "GitGuardian",
|
||||
"semgrep-app[bot]": "Semgrep",
|
||||
"semgrep-bot": "Semgrep",
|
||||
# === Code Coverage ===
|
||||
"codecov[bot]": "Codecov",
|
||||
"codecov-commenter": "Codecov",
|
||||
"coveralls": "Coveralls",
|
||||
"coveralls[bot]": "Coveralls",
|
||||
# === Dependency Management ===
|
||||
"renovate[bot]": "Renovate",
|
||||
"renovate-bot": "Renovate",
|
||||
"self-hosted-renovate[bot]": "Renovate",
|
||||
# === PR Automation ===
|
||||
"mergify[bot]": "Mergify",
|
||||
"imgbotapp": "Imgbot",
|
||||
"imgbot[bot]": "Imgbot",
|
||||
"allstar[bot]": "Allstar",
|
||||
"percy[bot]": "Percy",
|
||||
"sonarcloud": "SonarCloud",
|
||||
"sonarcloud[bot]": "SonarCloud",
|
||||
}
|
||||
|
||||
|
||||
@@ -201,23 +109,18 @@ class PRContext:
|
||||
ai_bot_comments: list[AIBotComment] = field(default_factory=list)
|
||||
# Flag indicating if full diff was skipped (PR > 20K lines)
|
||||
diff_truncated: bool = False
|
||||
# Commit SHAs for worktree creation (PR review isolation)
|
||||
head_sha: str = "" # Commit SHA of PR head (headRefOid)
|
||||
base_sha: str = "" # Commit SHA of PR base (baseRefOid)
|
||||
|
||||
|
||||
class PRContextGatherer:
|
||||
"""Gathers all context needed for PR review BEFORE the AI starts."""
|
||||
|
||||
def __init__(self, project_dir: Path, pr_number: int, repo: str | None = None):
|
||||
def __init__(self, project_dir: Path, pr_number: int):
|
||||
self.project_dir = Path(project_dir)
|
||||
self.pr_number = pr_number
|
||||
self.repo = repo
|
||||
self.gh_client = GHClient(
|
||||
project_dir=self.project_dir,
|
||||
default_timeout=30.0,
|
||||
max_retries=3,
|
||||
repo=repo,
|
||||
)
|
||||
|
||||
async def gather(self) -> PRContext:
|
||||
@@ -236,19 +139,6 @@ class PRContextGatherer:
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Ensure PR refs are available locally (fetches commits for fork PRs)
|
||||
head_sha = pr_data.get("headRefOid", "")
|
||||
base_sha = pr_data.get("baseRefOid", "")
|
||||
refs_available = False
|
||||
if head_sha and base_sha:
|
||||
refs_available = await self._ensure_pr_refs_available(head_sha, base_sha)
|
||||
if not refs_available:
|
||||
print(
|
||||
"[Context] Warning: Could not fetch PR refs locally. "
|
||||
"Will use GitHub API patches as fallback.",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Fetch changed files with content
|
||||
changed_files = await self._fetch_changed_files(pr_data)
|
||||
print(f"[Context] Fetched {len(changed_files)} changed files", flush=True)
|
||||
@@ -294,8 +184,6 @@ class PRContextGatherer:
|
||||
total_deletions=pr_data.get("deletions", 0),
|
||||
ai_bot_comments=ai_bot_comments,
|
||||
diff_truncated=diff_truncated,
|
||||
head_sha=pr_data.get("headRefOid", ""),
|
||||
base_sha=pr_data.get("baseRefOid", ""),
|
||||
)
|
||||
|
||||
async def _fetch_pr_metadata(self) -> dict:
|
||||
@@ -309,8 +197,6 @@ class PRContextGatherer:
|
||||
"state",
|
||||
"headRefName",
|
||||
"baseRefName",
|
||||
"headRefOid", # Commit SHA for head - works even when branch is unavailable locally
|
||||
"baseRefOid", # Commit SHA for base - works even when branch is unavailable locally
|
||||
"author",
|
||||
"files",
|
||||
"additions",
|
||||
@@ -320,83 +206,6 @@ class PRContextGatherer:
|
||||
],
|
||||
)
|
||||
|
||||
async def _ensure_pr_refs_available(self, head_sha: str, base_sha: str) -> bool:
|
||||
"""
|
||||
Ensure PR refs are available locally by fetching the commit SHAs.
|
||||
|
||||
This solves the "fatal: bad revision" error when PR branches aren't
|
||||
available locally (e.g., PRs from forks or unfetched branches).
|
||||
|
||||
Args:
|
||||
head_sha: The head commit SHA (from headRefOid)
|
||||
base_sha: The base commit SHA (from baseRefOid)
|
||||
|
||||
Returns:
|
||||
True if refs are available, False otherwise
|
||||
"""
|
||||
# Validate SHAs before using in git commands
|
||||
if not _validate_git_ref(head_sha):
|
||||
print(
|
||||
f"[Context] Invalid head SHA rejected: {head_sha[:50]}...", flush=True
|
||||
)
|
||||
return False
|
||||
if not _validate_git_ref(base_sha):
|
||||
print(
|
||||
f"[Context] Invalid base SHA rejected: {base_sha[:50]}...", flush=True
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
# Fetch the specific commits - this works even for fork PRs
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git",
|
||||
"fetch",
|
||||
"origin",
|
||||
head_sha,
|
||||
base_sha,
|
||||
cwd=self.project_dir,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30.0)
|
||||
|
||||
if proc.returncode == 0:
|
||||
print(
|
||||
f"[Context] Fetched PR refs: base={base_sha[:8]} → head={head_sha[:8]}",
|
||||
flush=True,
|
||||
)
|
||||
return True
|
||||
else:
|
||||
# If direct SHA fetch fails, try fetching the PR ref
|
||||
print("[Context] Direct SHA fetch failed, trying PR ref...", flush=True)
|
||||
proc2 = await asyncio.create_subprocess_exec(
|
||||
"git",
|
||||
"fetch",
|
||||
"origin",
|
||||
f"pull/{self.pr_number}/head:refs/pr/{self.pr_number}",
|
||||
cwd=self.project_dir,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
await asyncio.wait_for(proc2.communicate(), timeout=30.0)
|
||||
if proc2.returncode == 0:
|
||||
print(
|
||||
f"[Context] Fetched PR ref: refs/pr/{self.pr_number}",
|
||||
flush=True,
|
||||
)
|
||||
return True
|
||||
print(
|
||||
f"[Context] Failed to fetch PR refs: {stderr.decode('utf-8')}",
|
||||
flush=True,
|
||||
)
|
||||
return False
|
||||
except asyncio.TimeoutError:
|
||||
print("[Context] Timeout fetching PR refs", flush=True)
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"[Context] Error fetching PR refs: {e}", flush=True)
|
||||
return False
|
||||
|
||||
async def _fetch_changed_files(self, pr_data: dict) -> list[ChangedFile]:
|
||||
"""
|
||||
Fetch all changed files with their full content.
|
||||
@@ -417,18 +226,16 @@ class PRContextGatherer:
|
||||
|
||||
print(f"[Context] Processing {path} ({status})...", flush=True)
|
||||
|
||||
# Use commit SHAs if available (works for fork PRs), fallback to branch names
|
||||
head_ref = pr_data.get("headRefOid") or pr_data["headRefName"]
|
||||
base_ref = pr_data.get("baseRefOid") or pr_data["baseRefName"]
|
||||
# Get current content (from PR head branch)
|
||||
content = await self._read_file_content(path, pr_data["headRefName"])
|
||||
|
||||
# Get current content (from PR head commit)
|
||||
content = await self._read_file_content(path, head_ref)
|
||||
|
||||
# Get base content (from base commit)
|
||||
base_content = await self._read_file_content(path, base_ref)
|
||||
# Get base content (from base branch)
|
||||
base_content = await self._read_file_content(path, pr_data["baseRefName"])
|
||||
|
||||
# Get the patch for this specific file
|
||||
patch = await self._get_file_patch(path, base_ref, head_ref)
|
||||
patch = await self._get_file_patch(
|
||||
path, pr_data["baseRefName"], pr_data["headRefName"]
|
||||
)
|
||||
|
||||
changed_files.append(
|
||||
ChangedFile(
|
||||
@@ -469,14 +276,6 @@ class PRContextGatherer:
|
||||
Returns:
|
||||
File content as string, or empty string if file doesn't exist
|
||||
"""
|
||||
# Validate inputs to prevent command injection
|
||||
if not _validate_file_path(path):
|
||||
print(f"[Context] Invalid file path rejected: {path[:50]}...", flush=True)
|
||||
return ""
|
||||
if not _validate_git_ref(ref):
|
||||
print(f"[Context] Invalid git ref rejected: {ref[:50]}...", flush=True)
|
||||
return ""
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git",
|
||||
@@ -513,21 +312,6 @@ class PRContextGatherer:
|
||||
Returns:
|
||||
Unified diff patch for this file
|
||||
"""
|
||||
# Validate inputs to prevent command injection
|
||||
if not _validate_file_path(path):
|
||||
print(f"[Context] Invalid file path rejected: {path[:50]}...", flush=True)
|
||||
return ""
|
||||
if not _validate_git_ref(base_ref):
|
||||
print(
|
||||
f"[Context] Invalid base ref rejected: {base_ref[:50]}...", flush=True
|
||||
)
|
||||
return ""
|
||||
if not _validate_git_ref(head_ref):
|
||||
print(
|
||||
f"[Context] Invalid head ref rejected: {head_ref[:50]}...", flush=True
|
||||
)
|
||||
return ""
|
||||
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git",
|
||||
@@ -880,9 +664,8 @@ class PRContextGatherer:
|
||||
# Start from the directory containing the source file
|
||||
base_dir = source_path.parent
|
||||
|
||||
# Resolve relative path - MUST prepend project_dir to resolve correctly
|
||||
# when CWD is different from project root (e.g., running from apps/backend/)
|
||||
resolved = (self.project_dir / base_dir / import_path).resolve()
|
||||
# Resolve relative path
|
||||
resolved = (base_dir / import_path).resolve()
|
||||
|
||||
# Try common extensions if no extension provided
|
||||
if not resolved.suffix:
|
||||
@@ -966,17 +749,14 @@ class FollowupContextGatherer:
|
||||
project_dir: Path,
|
||||
pr_number: int,
|
||||
previous_review: PRReviewResult, # Forward reference
|
||||
repo: str | None = None,
|
||||
):
|
||||
self.project_dir = Path(project_dir)
|
||||
self.pr_number = pr_number
|
||||
self.previous_review = previous_review
|
||||
self.repo = repo
|
||||
self.gh_client = GHClient(
|
||||
project_dir=self.project_dir,
|
||||
default_timeout=30.0,
|
||||
max_retries=3,
|
||||
repo=repo,
|
||||
)
|
||||
|
||||
async def gather(self) -> FollowupReviewContext:
|
||||
@@ -1046,7 +826,6 @@ class FollowupContextGatherer:
|
||||
previous_review=self.previous_review,
|
||||
previous_commit_sha=previous_sha,
|
||||
current_commit_sha=current_sha,
|
||||
error=f"Failed to compare commits: {e}",
|
||||
)
|
||||
|
||||
# Extract data from comparison
|
||||
@@ -1078,15 +857,6 @@ class FollowupContextGatherer:
|
||||
print(f"[Followup] Error fetching comments: {e}", flush=True)
|
||||
comments = {"review_comments": [], "issue_comments": []}
|
||||
|
||||
# Get formal PR reviews since last review (from Cursor, CodeRabbit, etc.)
|
||||
try:
|
||||
pr_reviews = await self.gh_client.get_reviews_since(
|
||||
self.pr_number, self.previous_review.reviewed_at
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[Followup] Error fetching PR reviews: {e}", flush=True)
|
||||
pr_reviews = []
|
||||
|
||||
# Separate AI bot comments from contributor comments
|
||||
ai_comments = []
|
||||
contributor_comments = []
|
||||
@@ -1109,33 +879,8 @@ class FollowupContextGatherer:
|
||||
else:
|
||||
contributor_comments.append(comment)
|
||||
|
||||
# Separate AI bot reviews from contributor reviews
|
||||
ai_reviews = []
|
||||
contributor_reviews = []
|
||||
|
||||
for review in pr_reviews:
|
||||
author = ""
|
||||
if isinstance(review.get("user"), dict):
|
||||
author = review["user"].get("login", "").lower()
|
||||
|
||||
is_ai_bot = any(pattern in author for pattern in AI_BOT_PATTERNS.keys())
|
||||
|
||||
if is_ai_bot:
|
||||
ai_reviews.append(review)
|
||||
else:
|
||||
contributor_reviews.append(review)
|
||||
|
||||
# Combine AI comments and reviews for reporting
|
||||
total_ai_feedback = len(ai_comments) + len(ai_reviews)
|
||||
total_contributor_feedback = len(contributor_comments) + len(
|
||||
contributor_reviews
|
||||
)
|
||||
|
||||
print(
|
||||
f"[Followup] Found {total_contributor_feedback} contributor feedback "
|
||||
f"({len(contributor_comments)} comments, {len(contributor_reviews)} reviews), "
|
||||
f"{total_ai_feedback} AI feedback "
|
||||
f"({len(ai_comments)} comments, {len(ai_reviews)} reviews)",
|
||||
f"[Followup] Found {len(contributor_comments)} contributor comments, {len(ai_comments)} AI comments",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
@@ -1147,8 +892,6 @@ class FollowupContextGatherer:
|
||||
commits_since_review=commits,
|
||||
files_changed_since_review=files_changed,
|
||||
diff_since_review=diff_since_review,
|
||||
contributor_comments_since_review=contributor_comments
|
||||
+ contributor_reviews,
|
||||
contributor_comments_since_review=contributor_comments,
|
||||
ai_bot_comments_since_review=ai_comments,
|
||||
pr_reviews_since_review=pr_reviews,
|
||||
)
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
"""
|
||||
File Locking for Concurrent Operations
|
||||
=====================================
|
||||
======================================
|
||||
|
||||
Thread-safe and process-safe file locking utilities for GitHub automation.
|
||||
Uses fcntl.flock() on Unix systems and msvcrt.locking() on Windows for proper
|
||||
cross-process locking.
|
||||
Uses fcntl.flock() on Unix systems for proper cross-process locking.
|
||||
|
||||
Example Usage:
|
||||
# Simple file locking
|
||||
@@ -15,79 +14,20 @@ Example Usage:
|
||||
# Atomic write with locking
|
||||
async with locked_write("path/to/file.json", timeout=5.0) as f:
|
||||
json.dump(data, f)
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import warnings
|
||||
from collections.abc import Callable
|
||||
from contextlib import asynccontextmanager, contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_IS_WINDOWS = os.name == "nt"
|
||||
_WINDOWS_LOCK_SIZE = 1024 * 1024
|
||||
|
||||
try:
|
||||
import fcntl # type: ignore
|
||||
except ImportError: # pragma: no cover
|
||||
fcntl = None
|
||||
|
||||
try:
|
||||
import msvcrt # type: ignore
|
||||
except ImportError: # pragma: no cover
|
||||
msvcrt = None
|
||||
|
||||
|
||||
def _try_lock(fd: int, exclusive: bool) -> None:
|
||||
if _IS_WINDOWS:
|
||||
if msvcrt is None:
|
||||
raise FileLockError("msvcrt is required for file locking on Windows")
|
||||
if not exclusive:
|
||||
warnings.warn(
|
||||
"Shared file locks are not supported on Windows; using exclusive lock",
|
||||
RuntimeWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
msvcrt.locking(fd, msvcrt.LK_NBLCK, _WINDOWS_LOCK_SIZE)
|
||||
return
|
||||
|
||||
if fcntl is None:
|
||||
raise FileLockError(
|
||||
"fcntl is required for file locking on non-Windows platforms"
|
||||
)
|
||||
|
||||
lock_mode = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
|
||||
fcntl.flock(fd, lock_mode | fcntl.LOCK_NB)
|
||||
|
||||
|
||||
def _unlock(fd: int) -> None:
|
||||
if _IS_WINDOWS:
|
||||
if msvcrt is None:
|
||||
warnings.warn(
|
||||
"msvcrt unavailable; cannot unlock file descriptor",
|
||||
RuntimeWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
return
|
||||
msvcrt.locking(fd, msvcrt.LK_UNLCK, _WINDOWS_LOCK_SIZE)
|
||||
return
|
||||
|
||||
if fcntl is None:
|
||||
warnings.warn(
|
||||
"fcntl unavailable; cannot unlock file descriptor",
|
||||
RuntimeWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
return
|
||||
fcntl.flock(fd, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
class FileLockError(Exception):
|
||||
"""Raised when file locking operations fail."""
|
||||
@@ -103,8 +43,7 @@ class FileLockTimeout(FileLockError):
|
||||
|
||||
class FileLock:
|
||||
"""
|
||||
Cross-process file lock using platform-specific locking (fcntl.flock on Unix,
|
||||
msvcrt.locking on Windows).
|
||||
Cross-process file lock using fcntl.flock().
|
||||
|
||||
Supports both sync and async context managers for flexible usage.
|
||||
|
||||
@@ -150,22 +89,22 @@ class FileLock:
|
||||
self._fd = os.open(str(self._lock_file), os.O_CREAT | os.O_RDWR)
|
||||
|
||||
# Try to acquire lock with timeout
|
||||
lock_mode = fcntl.LOCK_EX if self.exclusive else fcntl.LOCK_SH
|
||||
start_time = time.time()
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Non-blocking lock attempt
|
||||
_try_lock(self._fd, self.exclusive)
|
||||
fcntl.flock(self._fd, lock_mode | fcntl.LOCK_NB)
|
||||
return # Lock acquired
|
||||
except (BlockingIOError, OSError):
|
||||
except BlockingIOError:
|
||||
# Lock held by another process
|
||||
elapsed = time.time() - start_time
|
||||
if elapsed >= self.timeout:
|
||||
os.close(self._fd)
|
||||
self._fd = None
|
||||
raise FileLockTimeout(
|
||||
f"Failed to acquire lock on {self.filepath} within "
|
||||
f"{self.timeout}s"
|
||||
f"Failed to acquire lock on {self.filepath} within {self.timeout}s"
|
||||
)
|
||||
|
||||
# Wait a bit before retrying
|
||||
@@ -175,7 +114,7 @@ class FileLock:
|
||||
"""Release the file lock."""
|
||||
if self._fd is not None:
|
||||
try:
|
||||
_unlock(self._fd)
|
||||
fcntl.flock(self._fd, fcntl.LOCK_UN)
|
||||
os.close(self._fd)
|
||||
except Exception:
|
||||
pass # Best effort cleanup
|
||||
@@ -202,12 +141,12 @@ class FileLock:
|
||||
async def __aenter__(self):
|
||||
"""Async context manager entry."""
|
||||
# Run blocking lock acquisition in thread pool
|
||||
await asyncio.get_running_loop().run_in_executor(None, self._acquire_lock)
|
||||
await asyncio.get_event_loop().run_in_executor(None, self._acquire_lock)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
"""Async context manager exit."""
|
||||
await asyncio.get_running_loop().run_in_executor(None, self._release_lock)
|
||||
await asyncio.get_event_loop().run_in_executor(None, self._release_lock)
|
||||
return False
|
||||
|
||||
|
||||
@@ -253,9 +192,7 @@ def atomic_write(filepath: str | Path, mode: str = "w"):
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def locked_write(
|
||||
filepath: str | Path, timeout: float = 5.0, mode: str = "w"
|
||||
) -> Any:
|
||||
async def locked_write(filepath: str | Path, timeout: float = 5.0, mode: str = "w"):
|
||||
"""
|
||||
Async context manager combining file locking and atomic writes.
|
||||
|
||||
@@ -282,7 +219,7 @@ async def locked_write(
|
||||
|
||||
try:
|
||||
# Atomic write in thread pool (since it uses sync file I/O)
|
||||
fd, tmp_path = await asyncio.get_running_loop().run_in_executor(
|
||||
fd, tmp_path = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: tempfile.mkstemp(
|
||||
dir=filepath.parent, prefix=f".{filepath.name}.tmp.", suffix=""
|
||||
@@ -292,20 +229,20 @@ async def locked_write(
|
||||
try:
|
||||
# Open temp file and yield to caller
|
||||
f = os.fdopen(fd, mode)
|
||||
try:
|
||||
yield f
|
||||
finally:
|
||||
f.close()
|
||||
yield f
|
||||
|
||||
# Ensure file is closed before rename
|
||||
f.close()
|
||||
|
||||
# Atomic replace
|
||||
await asyncio.get_running_loop().run_in_executor(
|
||||
await asyncio.get_event_loop().run_in_executor(
|
||||
None, os.replace, tmp_path, filepath
|
||||
)
|
||||
|
||||
except Exception:
|
||||
# Clean up temp file on error
|
||||
try:
|
||||
await asyncio.get_running_loop().run_in_executor(
|
||||
await asyncio.get_event_loop().run_in_executor(
|
||||
None, os.unlink, tmp_path
|
||||
)
|
||||
except Exception:
|
||||
@@ -318,7 +255,7 @@ async def locked_write(
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def locked_read(filepath: str | Path, timeout: float = 5.0) -> Any:
|
||||
async def locked_read(filepath: str | Path, timeout: float = 5.0):
|
||||
"""
|
||||
Async context manager for locked file reading.
|
||||
|
||||
@@ -401,10 +338,7 @@ async def locked_json_read(filepath: str | Path, timeout: float = 5.0) -> Any:
|
||||
|
||||
|
||||
async def locked_json_update(
|
||||
filepath: str | Path,
|
||||
updater: Callable[[Any], Any],
|
||||
timeout: float = 5.0,
|
||||
indent: int = 2,
|
||||
filepath: str | Path, updater: callable, timeout: float = 5.0, indent: int = 2
|
||||
) -> Any:
|
||||
"""
|
||||
Helper for atomic read-modify-write of JSON files.
|
||||
@@ -439,19 +373,17 @@ async def locked_json_update(
|
||||
|
||||
try:
|
||||
# Read current data
|
||||
def _read_json():
|
||||
if filepath.exists():
|
||||
with open(filepath) as f:
|
||||
return json.load(f)
|
||||
return None
|
||||
|
||||
data = await asyncio.get_running_loop().run_in_executor(None, _read_json)
|
||||
if filepath.exists():
|
||||
with open(filepath) as f:
|
||||
data = json.load(f)
|
||||
else:
|
||||
data = None
|
||||
|
||||
# Apply update function
|
||||
updated_data = updater(data)
|
||||
|
||||
# Write atomically
|
||||
fd, tmp_path = await asyncio.get_running_loop().run_in_executor(
|
||||
fd, tmp_path = await asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
lambda: tempfile.mkstemp(
|
||||
dir=filepath.parent, prefix=f".{filepath.name}.tmp.", suffix=""
|
||||
@@ -462,13 +394,13 @@ async def locked_json_update(
|
||||
with os.fdopen(fd, "w") as f:
|
||||
json.dump(updated_data, f, indent=indent)
|
||||
|
||||
await asyncio.get_running_loop().run_in_executor(
|
||||
await asyncio.get_event_loop().run_in_executor(
|
||||
None, os.replace, tmp_path, filepath
|
||||
)
|
||||
|
||||
except Exception:
|
||||
try:
|
||||
await asyncio.get_running_loop().run_in_executor(
|
||||
await asyncio.get_event_loop().run_in_executor(
|
||||
None, os.unlink, tmp_path
|
||||
)
|
||||
except Exception:
|
||||
|
||||
@@ -84,7 +84,6 @@ class GHClient:
|
||||
default_timeout: float = 30.0,
|
||||
max_retries: int = 3,
|
||||
enable_rate_limiting: bool = True,
|
||||
repo: str | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize GitHub CLI client.
|
||||
@@ -94,14 +93,11 @@ class GHClient:
|
||||
default_timeout: Default timeout in seconds for commands
|
||||
max_retries: Maximum number of retry attempts
|
||||
enable_rate_limiting: Whether to enforce rate limiting (default: True)
|
||||
repo: Repository in 'owner/repo' format. If provided, uses -R flag
|
||||
instead of inferring from git remotes.
|
||||
"""
|
||||
self.project_dir = Path(project_dir)
|
||||
self.default_timeout = default_timeout
|
||||
self.max_retries = max_retries
|
||||
self.enable_rate_limiting = enable_rate_limiting
|
||||
self.repo = repo
|
||||
|
||||
# Initialize rate limiter singleton
|
||||
if enable_rate_limiting:
|
||||
@@ -258,28 +254,6 @@ class GHClient:
|
||||
# Should never reach here, but for type safety
|
||||
raise GHCommandError(f"gh {args[0]} failed after {self.max_retries} attempts")
|
||||
|
||||
# =========================================================================
|
||||
# Helper methods
|
||||
# =========================================================================
|
||||
|
||||
def _add_repo_flag(self, args: list[str]) -> list[str]:
|
||||
"""
|
||||
Add -R flag to command args if repo is configured.
|
||||
|
||||
This ensures gh CLI uses the correct repository instead of
|
||||
inferring from git remotes, which can fail with multiple remotes
|
||||
or when working in worktrees.
|
||||
|
||||
Args:
|
||||
args: Command arguments list
|
||||
|
||||
Returns:
|
||||
Modified args list with -R flag if repo is set
|
||||
"""
|
||||
if self.repo:
|
||||
return args + ["-R", self.repo]
|
||||
return args
|
||||
|
||||
# =========================================================================
|
||||
# Convenience methods for common gh commands
|
||||
# =========================================================================
|
||||
@@ -321,7 +295,6 @@ class GHClient:
|
||||
"--json",
|
||||
",".join(json_fields),
|
||||
]
|
||||
args = self._add_repo_flag(args)
|
||||
|
||||
result = await self.run(args)
|
||||
return json.loads(result.stdout)
|
||||
@@ -361,7 +334,6 @@ class GHClient:
|
||||
"--json",
|
||||
",".join(json_fields),
|
||||
]
|
||||
args = self._add_repo_flag(args)
|
||||
|
||||
result = await self.run(args)
|
||||
return json.loads(result.stdout)
|
||||
@@ -380,7 +352,6 @@ class GHClient:
|
||||
PRTooLargeError: If PR exceeds GitHub's 20,000 line diff limit
|
||||
"""
|
||||
args = ["pr", "diff", str(pr_number)]
|
||||
args = self._add_repo_flag(args)
|
||||
try:
|
||||
result = await self.run(args)
|
||||
return result.stdout
|
||||
@@ -425,7 +396,6 @@ class GHClient:
|
||||
args.append("--comment")
|
||||
|
||||
args.extend(["--body", body])
|
||||
args = self._add_repo_flag(args)
|
||||
|
||||
await self.run(args)
|
||||
return 0 # gh CLI doesn't return review ID
|
||||
@@ -604,7 +574,6 @@ class GHClient:
|
||||
args.extend(["--subject", commit_title])
|
||||
if commit_message:
|
||||
args.extend(["--body", commit_message])
|
||||
args = self._add_repo_flag(args)
|
||||
|
||||
await self.run(args)
|
||||
|
||||
@@ -617,7 +586,6 @@ class GHClient:
|
||||
body: Comment body
|
||||
"""
|
||||
args = ["pr", "comment", str(pr_number), "--body", body]
|
||||
args = self._add_repo_flag(args)
|
||||
await self.run(args)
|
||||
|
||||
async def pr_get_assignees(self, pr_number: int) -> list[str]:
|
||||
@@ -727,73 +695,6 @@ class GHClient:
|
||||
"issue_comments": issue_comments,
|
||||
}
|
||||
|
||||
async def get_reviews_since(
|
||||
self, pr_number: int, since_timestamp: str
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Get all PR reviews (formal review submissions) since a timestamp.
|
||||
|
||||
This fetches formal reviews submitted via the GitHub review mechanism,
|
||||
which is different from review comments (inline comments on files).
|
||||
|
||||
Reviews from AI tools like Cursor, CodeRabbit, Greptile etc. are
|
||||
submitted as formal reviews with body text containing their findings.
|
||||
|
||||
Args:
|
||||
pr_number: PR number
|
||||
since_timestamp: ISO timestamp to filter from (e.g., "2025-12-25T10:30:00Z")
|
||||
|
||||
Returns:
|
||||
List of review objects with fields:
|
||||
- id: Review ID
|
||||
- user: User who submitted the review
|
||||
- body: Review body text (contains AI findings)
|
||||
- state: APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED, PENDING
|
||||
- submitted_at: When the review was submitted
|
||||
- commit_id: Commit SHA the review was made on
|
||||
"""
|
||||
# Fetch all reviews for the PR
|
||||
# Note: The reviews endpoint doesn't support 'since' parameter,
|
||||
# so we fetch all and filter client-side
|
||||
reviews_endpoint = f"repos/{{owner}}/{{repo}}/pulls/{pr_number}/reviews"
|
||||
reviews_args = ["api", "--method", "GET", reviews_endpoint]
|
||||
reviews_result = await self.run(reviews_args, raise_on_error=False)
|
||||
|
||||
reviews = []
|
||||
if reviews_result.returncode == 0:
|
||||
try:
|
||||
all_reviews = json.loads(reviews_result.stdout)
|
||||
# Filter reviews submitted after the timestamp
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Parse since_timestamp, handling both naive and aware formats
|
||||
since_dt = datetime.fromisoformat(
|
||||
since_timestamp.replace("Z", "+00:00")
|
||||
)
|
||||
# Ensure since_dt is timezone-aware (assume UTC if naive)
|
||||
if since_dt.tzinfo is None:
|
||||
since_dt = since_dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
for review in all_reviews:
|
||||
submitted_at = review.get("submitted_at", "")
|
||||
if submitted_at:
|
||||
try:
|
||||
review_dt = datetime.fromisoformat(
|
||||
submitted_at.replace("Z", "+00:00")
|
||||
)
|
||||
# Ensure review_dt is also timezone-aware
|
||||
if review_dt.tzinfo is None:
|
||||
review_dt = review_dt.replace(tzinfo=timezone.utc)
|
||||
if review_dt > since_dt:
|
||||
reviews.append(review)
|
||||
except ValueError:
|
||||
# If we can't parse the date, include the review
|
||||
reviews.append(review)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning(f"Failed to parse reviews for PR #{pr_number}")
|
||||
|
||||
return reviews
|
||||
|
||||
async def get_pr_head_sha(self, pr_number: int) -> str | None:
|
||||
"""
|
||||
Get the current HEAD SHA of a PR.
|
||||
@@ -810,65 +711,3 @@ class GHClient:
|
||||
# Last commit is the HEAD
|
||||
return commits[-1].get("oid")
|
||||
return None
|
||||
|
||||
async def get_pr_checks(self, pr_number: int) -> dict[str, Any]:
|
||||
"""
|
||||
Get CI check runs status for a PR.
|
||||
|
||||
Uses `gh pr checks` to get the status of all check runs.
|
||||
|
||||
Args:
|
||||
pr_number: PR number
|
||||
|
||||
Returns:
|
||||
Dict with:
|
||||
- checks: List of check runs with name, status, conclusion
|
||||
- passing: Number of passing checks
|
||||
- failing: Number of failing checks
|
||||
- pending: Number of pending checks
|
||||
- failed_checks: List of failed check names
|
||||
"""
|
||||
try:
|
||||
args = ["pr", "checks", str(pr_number), "--json", "name,state,conclusion"]
|
||||
args = self._add_repo_flag(args)
|
||||
|
||||
result = await self.run(args, timeout=30.0)
|
||||
checks = json.loads(result.stdout) if result.stdout.strip() else []
|
||||
|
||||
passing = 0
|
||||
failing = 0
|
||||
pending = 0
|
||||
failed_checks = []
|
||||
|
||||
for check in checks:
|
||||
state = check.get("state", "").upper()
|
||||
conclusion = check.get("conclusion", "").upper()
|
||||
name = check.get("name", "Unknown")
|
||||
|
||||
if state == "COMPLETED":
|
||||
if conclusion in ("SUCCESS", "NEUTRAL", "SKIPPED"):
|
||||
passing += 1
|
||||
elif conclusion in ("FAILURE", "TIMED_OUT", "CANCELLED"):
|
||||
failing += 1
|
||||
failed_checks.append(name)
|
||||
else:
|
||||
# PENDING, QUEUED, IN_PROGRESS, etc.
|
||||
pending += 1
|
||||
|
||||
return {
|
||||
"checks": checks,
|
||||
"passing": passing,
|
||||
"failing": failing,
|
||||
"pending": pending,
|
||||
"failed_checks": failed_checks,
|
||||
}
|
||||
except (GHCommandError, GHTimeoutError, json.JSONDecodeError) as e:
|
||||
logger.warning(f"Failed to get PR checks for #{pr_number}: {e}")
|
||||
return {
|
||||
"checks": [],
|
||||
"passing": 0,
|
||||
"failing": 0,
|
||||
"pending": 0,
|
||||
"failed_checks": [],
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
@@ -221,14 +221,6 @@ class PRReviewFinding:
|
||||
)
|
||||
redundant_with: str | None = None # Reference to duplicate code (file:line)
|
||||
|
||||
# NEW: Finding validation fields (from finding-validator re-investigation)
|
||||
validation_status: str | None = (
|
||||
None # confirmed_valid, dismissed_false_positive, needs_human_review
|
||||
)
|
||||
validation_evidence: str | None = None # Code snippet examined during validation
|
||||
validation_confidence: float | None = None # Confidence of validation (0.0-1.0)
|
||||
validation_explanation: str | None = None # Why finding was validated/dismissed
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
@@ -245,11 +237,6 @@ class PRReviewFinding:
|
||||
"confidence": self.confidence,
|
||||
"verification_note": self.verification_note,
|
||||
"redundant_with": self.redundant_with,
|
||||
# Validation fields
|
||||
"validation_status": self.validation_status,
|
||||
"validation_evidence": self.validation_evidence,
|
||||
"validation_confidence": self.validation_confidence,
|
||||
"validation_explanation": self.validation_explanation,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -269,11 +256,6 @@ class PRReviewFinding:
|
||||
confidence=data.get("confidence", 0.85),
|
||||
verification_note=data.get("verification_note"),
|
||||
redundant_with=data.get("redundant_with"),
|
||||
# Validation fields
|
||||
validation_status=data.get("validation_status"),
|
||||
validation_evidence=data.get("validation_evidence"),
|
||||
validation_confidence=data.get("validation_confidence"),
|
||||
validation_explanation=data.get("validation_explanation"),
|
||||
)
|
||||
|
||||
|
||||
@@ -393,13 +375,6 @@ class PRReviewResult:
|
||||
default_factory=list
|
||||
) # New issues in recent commits
|
||||
|
||||
# Posted findings tracking (for frontend state sync)
|
||||
has_posted_findings: bool = False # True if any findings have been posted to GitHub
|
||||
posted_finding_ids: list[str] = field(
|
||||
default_factory=list
|
||||
) # IDs of posted findings
|
||||
posted_at: str | None = None # Timestamp when findings were posted
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
return {
|
||||
"pr_number": self.pr_number,
|
||||
@@ -426,10 +401,6 @@ class PRReviewResult:
|
||||
"resolved_findings": self.resolved_findings,
|
||||
"unresolved_findings": self.unresolved_findings,
|
||||
"new_findings_since_last_review": self.new_findings_since_last_review,
|
||||
# Posted findings tracking
|
||||
"has_posted_findings": self.has_posted_findings,
|
||||
"posted_finding_ids": self.posted_finding_ids,
|
||||
"posted_at": self.posted_at,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -472,10 +443,6 @@ class PRReviewResult:
|
||||
new_findings_since_last_review=data.get(
|
||||
"new_findings_since_last_review", []
|
||||
),
|
||||
# Posted findings tracking
|
||||
has_posted_findings=data.get("has_posted_findings", False),
|
||||
posted_finding_ids=data.get("posted_finding_ids", []),
|
||||
posted_at=data.get("posted_at"),
|
||||
)
|
||||
|
||||
async def save(self, github_dir: Path) -> None:
|
||||
@@ -558,13 +525,6 @@ class FollowupReviewContext:
|
||||
contributor_comments_since_review: list[dict] = field(default_factory=list)
|
||||
ai_bot_comments_since_review: list[dict] = field(default_factory=list)
|
||||
|
||||
# PR reviews since last review (formal review submissions from Cursor, CodeRabbit, etc.)
|
||||
# These are different from comments - they're full review submissions with body text
|
||||
pr_reviews_since_review: list[dict] = field(default_factory=list)
|
||||
|
||||
# Error flag - if set, context gathering failed and data may be incomplete
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TriageResult:
|
||||
@@ -809,12 +769,7 @@ class GitHubRunnerConfig:
|
||||
auto_post_reviews: bool = False
|
||||
allow_fix_commits: bool = True
|
||||
review_own_prs: bool = False # Whether bot can review its own PRs
|
||||
use_orchestrator_review: bool = (
|
||||
True # DEPRECATED: No longer used, kept for config compatibility
|
||||
)
|
||||
use_parallel_orchestrator: bool = (
|
||||
True # Use SDK subagent parallel orchestrator (default)
|
||||
)
|
||||
use_orchestrator_review: bool = True # Use new Opus 4.5 orchestrating agent
|
||||
|
||||
# Model settings
|
||||
model: str = "claude-sonnet-4-20250514"
|
||||
|
||||
@@ -129,7 +129,6 @@ class GitHubOrchestrator:
|
||||
default_timeout=30.0,
|
||||
max_retries=3,
|
||||
enable_rate_limiting=True,
|
||||
repo=config.repo,
|
||||
)
|
||||
|
||||
# Initialize bot detector for preventing infinite loops
|
||||
@@ -277,16 +276,12 @@ class GitHubOrchestrator:
|
||||
# PR REVIEW WORKFLOW
|
||||
# =========================================================================
|
||||
|
||||
async def review_pr(
|
||||
self, pr_number: int, force_review: bool = False
|
||||
) -> PRReviewResult:
|
||||
async def review_pr(self, pr_number: int) -> PRReviewResult:
|
||||
"""
|
||||
Perform AI-powered review of a pull request.
|
||||
|
||||
Args:
|
||||
pr_number: The PR number to review
|
||||
force_review: If True, bypass the "already reviewed" check and force a new review.
|
||||
Useful for re-validating a PR or testing the review system.
|
||||
|
||||
Returns:
|
||||
PRReviewResult with findings and overall assessment
|
||||
@@ -305,9 +300,7 @@ class GitHubOrchestrator:
|
||||
try:
|
||||
# Gather PR context
|
||||
print("[DEBUG orchestrator] Creating context gatherer...", flush=True)
|
||||
gatherer = PRContextGatherer(
|
||||
self.project_dir, pr_number, repo=self.config.repo
|
||||
)
|
||||
gatherer = PRContextGatherer(self.project_dir, pr_number)
|
||||
|
||||
print("[DEBUG orchestrator] Gathering PR context...", flush=True)
|
||||
pr_context = await gatherer.gather()
|
||||
@@ -325,34 +318,11 @@ class GitHubOrchestrator:
|
||||
commits=pr_context.commits,
|
||||
)
|
||||
|
||||
# Allow forcing a review to bypass "already reviewed" check
|
||||
if should_skip and force_review and "Already reviewed" in skip_reason:
|
||||
print(
|
||||
f"[BOT DETECTION] Force review requested - bypassing: {skip_reason}",
|
||||
flush=True,
|
||||
)
|
||||
should_skip = False
|
||||
|
||||
if should_skip:
|
||||
print(
|
||||
f"[BOT DETECTION] Skipping PR #{pr_number}: {skip_reason}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# If skipping because "Already reviewed", return the existing review
|
||||
# instead of creating a new empty "skipped" result
|
||||
if "Already reviewed" in skip_reason:
|
||||
existing_review = PRReviewResult.load(self.github_dir, pr_number)
|
||||
if existing_review:
|
||||
print(
|
||||
"[BOT DETECTION] Returning existing review (no new commits)",
|
||||
flush=True,
|
||||
)
|
||||
# Don't overwrite - return the existing review as-is
|
||||
# The frontend will see "no new commits" via the newCommitsCheck
|
||||
return existing_review
|
||||
|
||||
# For other skip reasons (bot-authored, cooling off), create a skip result
|
||||
result = PRReviewResult(
|
||||
pr_number=pr_number,
|
||||
repo=self.config.repo,
|
||||
@@ -389,17 +359,9 @@ class GitHubOrchestrator:
|
||||
pr_number=pr_number,
|
||||
)
|
||||
|
||||
# Check CI status
|
||||
ci_status = await self.gh_client.get_pr_checks(pr_number)
|
||||
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",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Generate verdict (now includes CI status)
|
||||
# Generate verdict
|
||||
verdict, verdict_reasoning, blockers = self._generate_verdict(
|
||||
findings, structural_issues, ai_triages, ci_status
|
||||
findings, structural_issues, ai_triages
|
||||
)
|
||||
print(
|
||||
f"[DEBUG orchestrator] Verdict: {verdict.value} - {verdict_reasoning}",
|
||||
@@ -570,30 +532,6 @@ class GitHubOrchestrator:
|
||||
)
|
||||
followup_context = await gatherer.gather()
|
||||
|
||||
# Check if context gathering failed
|
||||
if followup_context.error:
|
||||
print(
|
||||
f"[Followup] Context gathering failed: {followup_context.error}",
|
||||
flush=True,
|
||||
)
|
||||
# Return an error result instead of silently returning incomplete data
|
||||
result = PRReviewResult(
|
||||
pr_number=pr_number,
|
||||
repo=self.config.repo,
|
||||
success=False,
|
||||
findings=[],
|
||||
summary=f"Follow-up review failed: {followup_context.error}",
|
||||
overall_status="comment",
|
||||
verdict=MergeVerdict.NEEDS_REVISION,
|
||||
verdict_reasoning=f"Context gathering failed: {followup_context.error}",
|
||||
error=followup_context.error,
|
||||
reviewed_commit_sha=followup_context.current_commit_sha
|
||||
or previous_review.reviewed_commit_sha,
|
||||
is_followup_review=True,
|
||||
)
|
||||
await result.save(self.github_dir)
|
||||
return result
|
||||
|
||||
# Check if there are new commits
|
||||
if not followup_context.commits_since_review:
|
||||
print(
|
||||
@@ -625,80 +563,20 @@ class GitHubOrchestrator:
|
||||
pr_number=pr_number,
|
||||
)
|
||||
|
||||
# Use parallel orchestrator for follow-up if enabled
|
||||
if self.config.use_parallel_orchestrator:
|
||||
print(
|
||||
"[AI] Using parallel orchestrator for follow-up review (SDK subagents)...",
|
||||
flush=True,
|
||||
)
|
||||
try:
|
||||
from .services.parallel_followup_reviewer import (
|
||||
ParallelFollowupReviewer,
|
||||
)
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from services.parallel_followup_reviewer import (
|
||||
ParallelFollowupReviewer,
|
||||
)
|
||||
# Run follow-up review
|
||||
reviewer = FollowupReviewer(
|
||||
project_dir=self.project_dir,
|
||||
github_dir=self.github_dir,
|
||||
config=self.config,
|
||||
progress_callback=lambda p: self._report_progress(
|
||||
p.get("phase", "analyzing"),
|
||||
p.get("progress", 50),
|
||||
p.get("message", "Reviewing..."),
|
||||
pr_number=pr_number,
|
||||
),
|
||||
)
|
||||
|
||||
reviewer = ParallelFollowupReviewer(
|
||||
project_dir=self.project_dir,
|
||||
github_dir=self.github_dir,
|
||||
config=self.config,
|
||||
progress_callback=lambda p: self._report_progress(
|
||||
p.phase if hasattr(p, "phase") else p.get("phase", "analyzing"),
|
||||
p.progress if hasattr(p, "progress") else p.get("progress", 50),
|
||||
p.message
|
||||
if hasattr(p, "message")
|
||||
else p.get("message", "Reviewing..."),
|
||||
pr_number=pr_number,
|
||||
),
|
||||
)
|
||||
result = await reviewer.review(followup_context)
|
||||
else:
|
||||
# Fall back to sequential follow-up reviewer
|
||||
reviewer = FollowupReviewer(
|
||||
project_dir=self.project_dir,
|
||||
github_dir=self.github_dir,
|
||||
config=self.config,
|
||||
progress_callback=lambda p: self._report_progress(
|
||||
p.get("phase", "analyzing"),
|
||||
p.get("progress", 50),
|
||||
p.get("message", "Reviewing..."),
|
||||
pr_number=pr_number,
|
||||
),
|
||||
)
|
||||
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", [])
|
||||
if failed_checks:
|
||||
print(
|
||||
f"[Followup] CI checks failing: {failed_checks}",
|
||||
flush=True,
|
||||
)
|
||||
# Override verdict if CI is failing
|
||||
if result.verdict in (
|
||||
MergeVerdict.READY_TO_MERGE,
|
||||
MergeVerdict.MERGE_WITH_CHANGES,
|
||||
):
|
||||
result.verdict = MergeVerdict.BLOCKED
|
||||
result.verdict_reasoning = (
|
||||
f"Blocked: {len(failed_checks)} CI check(s) failing. "
|
||||
"Fix CI before merge."
|
||||
)
|
||||
result.overall_status = "request_changes"
|
||||
# Add CI failures to blockers
|
||||
for check_name in failed_checks:
|
||||
if f"CI Failed: {check_name}" not in result.blockers:
|
||||
result.blockers.append(f"CI Failed: {check_name}")
|
||||
# Update summary to reflect CI status
|
||||
ci_warning = (
|
||||
f"\n\n**⚠️ CI Status:** {len(failed_checks)} check(s) failing: "
|
||||
f"{', '.join(failed_checks)}"
|
||||
)
|
||||
if ci_warning not in result.summary:
|
||||
result.summary += ci_warning
|
||||
result = await reviewer.review_followup(followup_context)
|
||||
|
||||
# Save result
|
||||
await result.save(self.github_dir)
|
||||
@@ -729,22 +607,17 @@ class GitHubOrchestrator:
|
||||
findings: list[PRReviewFinding],
|
||||
structural_issues: list[StructuralIssue],
|
||||
ai_triages: list[AICommentTriage],
|
||||
ci_status: dict | None = None,
|
||||
) -> tuple[MergeVerdict, str, list[str]]:
|
||||
"""
|
||||
Generate merge verdict based on all findings and CI status.
|
||||
Generate merge verdict based on all findings.
|
||||
|
||||
NEW: Strengthened to block on verification failures, redundancy issues,
|
||||
and failing CI checks.
|
||||
NEW: Strengthened to block on verification failures and redundancy issues.
|
||||
"""
|
||||
blockers = []
|
||||
ci_status = ci_status or {}
|
||||
|
||||
# Count by severity
|
||||
critical = [f for f in findings if f.severity == ReviewSeverity.CRITICAL]
|
||||
high = [f for f in findings if f.severity == ReviewSeverity.HIGH]
|
||||
medium = [f for f in findings if f.severity == ReviewSeverity.MEDIUM]
|
||||
low = [f for f in findings if f.severity == ReviewSeverity.LOW]
|
||||
|
||||
# NEW: Verification failures are ALWAYS blockers (even if not critical severity)
|
||||
verification_failures = [
|
||||
@@ -775,11 +648,6 @@ class GitHubOrchestrator:
|
||||
ai_critical = [t for t in ai_triages if t.verdict == AICommentVerdict.CRITICAL]
|
||||
|
||||
# Build blockers list with NEW categories first
|
||||
# CI failures block merging
|
||||
failed_checks = ci_status.get("failed_checks", [])
|
||||
for check_name in failed_checks:
|
||||
blockers.append(f"CI Failed: {check_name}")
|
||||
|
||||
# NEW: Verification failures block merging
|
||||
for f in verification_failures:
|
||||
note = f" - {f.verification_note}" if f.verification_note else ""
|
||||
@@ -812,17 +680,10 @@ class GitHubOrchestrator:
|
||||
)
|
||||
blockers.append(f"{t.tool_name}: {summary}")
|
||||
|
||||
# Determine verdict with CI, verification and redundancy checks
|
||||
# Determine verdict with NEW verification and redundancy checks
|
||||
if blockers:
|
||||
# CI failures are always blockers
|
||||
if failed_checks:
|
||||
verdict = MergeVerdict.BLOCKED
|
||||
reasoning = (
|
||||
f"Blocked: {len(failed_checks)} CI check(s) failing. "
|
||||
"Fix CI before merge."
|
||||
)
|
||||
# NEW: Prioritize verification failures
|
||||
elif verification_failures:
|
||||
if verification_failures:
|
||||
verdict = MergeVerdict.BLOCKED
|
||||
reasoning = (
|
||||
f"Blocked: Cannot verify {len(verification_failures)} claim(s) in PR. "
|
||||
@@ -845,19 +706,9 @@ class GitHubOrchestrator:
|
||||
else:
|
||||
verdict = MergeVerdict.NEEDS_REVISION
|
||||
reasoning = f"{len(blockers)} issues must be addressed"
|
||||
elif high or medium:
|
||||
# High and Medium severity findings block merge
|
||||
verdict = MergeVerdict.NEEDS_REVISION
|
||||
total = len(high) + len(medium)
|
||||
reasoning = f"{total} issue(s) must be addressed ({len(high)} required, {len(medium)} recommended)"
|
||||
if low:
|
||||
reasoning += f", {len(low)} suggestions"
|
||||
elif low:
|
||||
# Only Low severity suggestions - safe to merge (non-blocking)
|
||||
verdict = MergeVerdict.READY_TO_MERGE
|
||||
reasoning = (
|
||||
f"No blocking issues. {len(low)} non-blocking suggestion(s) to consider"
|
||||
)
|
||||
elif high:
|
||||
verdict = MergeVerdict.MERGE_WITH_CHANGES
|
||||
reasoning = f"{len(high)} high-priority issues to address"
|
||||
else:
|
||||
verdict = MergeVerdict.READY_TO_MERGE
|
||||
reasoning = "No blocking issues found"
|
||||
|
||||
@@ -56,7 +56,6 @@ class GitHubProvider:
|
||||
self._gh_client = GHClient(
|
||||
project_dir=project_dir,
|
||||
enable_rate_limiting=self.enable_rate_limiting,
|
||||
repo=self._repo,
|
||||
)
|
||||
|
||||
@property
|
||||
|
||||
@@ -46,13 +46,6 @@ import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Fix Windows console encoding for Unicode output (emojis, special chars)
|
||||
if sys.platform == "win32":
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||
if hasattr(sys.stderr, "reconfigure"):
|
||||
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
# Add backend to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent))
|
||||
|
||||
@@ -86,70 +79,34 @@ def print_progress(callback: ProgressCallback) -> None:
|
||||
|
||||
def get_config(args) -> GitHubRunnerConfig:
|
||||
"""Build config from CLI args and environment."""
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
token = args.token or os.environ.get("GITHUB_TOKEN", "")
|
||||
bot_token = args.bot_token or os.environ.get("GITHUB_BOT_TOKEN")
|
||||
repo = args.repo or os.environ.get("GITHUB_REPO", "")
|
||||
|
||||
# Find gh CLI - use shutil.which for cross-platform support
|
||||
gh_path = shutil.which("gh")
|
||||
if not gh_path and sys.platform == "win32":
|
||||
# Fallback: check common Windows installation paths
|
||||
common_paths = [
|
||||
r"C:\Program Files\GitHub CLI\gh.exe",
|
||||
r"C:\Program Files (x86)\GitHub CLI\gh.exe",
|
||||
os.path.expandvars(r"%LOCALAPPDATA%\Programs\GitHub CLI\gh.exe"),
|
||||
]
|
||||
for path in common_paths:
|
||||
if os.path.exists(path):
|
||||
gh_path = path
|
||||
break
|
||||
|
||||
if os.environ.get("DEBUG"):
|
||||
print(f"[DEBUG] gh CLI path: {gh_path}", flush=True)
|
||||
print(
|
||||
f"[DEBUG] PATH env: {os.environ.get('PATH', 'NOT SET')[:200]}...",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if not token and gh_path:
|
||||
if not token:
|
||||
# Try to get from gh CLI
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[gh_path, "auth", "token"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
token = result.stdout.strip()
|
||||
except FileNotFoundError:
|
||||
pass # gh not installed or not in PATH
|
||||
import subprocess
|
||||
|
||||
if not repo and gh_path:
|
||||
result = subprocess.run(
|
||||
["gh", "auth", "token"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
token = result.stdout.strip()
|
||||
|
||||
if not repo:
|
||||
# Try to detect from git remote
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
gh_path,
|
||||
"repo",
|
||||
"view",
|
||||
"--json",
|
||||
"nameWithOwner",
|
||||
"-q",
|
||||
".nameWithOwner",
|
||||
],
|
||||
cwd=args.project,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
repo = result.stdout.strip()
|
||||
elif os.environ.get("DEBUG"):
|
||||
print(f"[DEBUG] gh repo view failed: {result.stderr}", flush=True)
|
||||
except FileNotFoundError:
|
||||
pass # gh not installed or not in PATH
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run(
|
||||
["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"],
|
||||
cwd=args.project,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
repo = result.stdout.strip()
|
||||
|
||||
if not token:
|
||||
print("Error: No GitHub token found. Set GITHUB_TOKEN or run 'gh auth login'")
|
||||
@@ -176,44 +133,27 @@ async def cmd_review_pr(args) -> int:
|
||||
import sys
|
||||
|
||||
# Force unbuffered output so Electron sees it in real-time
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(line_buffering=True)
|
||||
if hasattr(sys.stderr, "reconfigure"):
|
||||
sys.stderr.reconfigure(line_buffering=True)
|
||||
sys.stdout.reconfigure(line_buffering=True)
|
||||
sys.stderr.reconfigure(line_buffering=True)
|
||||
|
||||
debug = os.environ.get("DEBUG")
|
||||
if debug:
|
||||
print(f"[DEBUG] Starting PR review for PR #{args.pr_number}", flush=True)
|
||||
print(f"[DEBUG] Project directory: {args.project}", flush=True)
|
||||
print("[DEBUG] Building config...", flush=True)
|
||||
print(f"[DEBUG] Starting PR review for PR #{args.pr_number}", flush=True)
|
||||
print(f"[DEBUG] Project directory: {args.project}", flush=True)
|
||||
|
||||
print("[DEBUG] Building config...", flush=True)
|
||||
config = get_config(args)
|
||||
print(f"[DEBUG] Config built: repo={config.repo}, model={config.model}", flush=True)
|
||||
|
||||
if debug:
|
||||
print(
|
||||
f"[DEBUG] Config built: repo={config.repo}, model={config.model}",
|
||||
flush=True,
|
||||
)
|
||||
print("[DEBUG] Creating orchestrator...", flush=True)
|
||||
|
||||
print("[DEBUG] Creating orchestrator...", flush=True)
|
||||
orchestrator = GitHubOrchestrator(
|
||||
project_dir=args.project,
|
||||
config=config,
|
||||
progress_callback=print_progress,
|
||||
)
|
||||
print("[DEBUG] Orchestrator created", flush=True)
|
||||
|
||||
if debug:
|
||||
print("[DEBUG] Orchestrator created", flush=True)
|
||||
print(
|
||||
f"[DEBUG] Calling orchestrator.review_pr({args.pr_number})...", flush=True
|
||||
)
|
||||
|
||||
# Pass force_review flag if --force was specified
|
||||
force_review = getattr(args, "force", False)
|
||||
result = await orchestrator.review_pr(args.pr_number, force_review=force_review)
|
||||
|
||||
if debug:
|
||||
print(f"[DEBUG] review_pr returned, success={result.success}", flush=True)
|
||||
print(f"[DEBUG] Calling orchestrator.review_pr({args.pr_number})...", flush=True)
|
||||
result = await orchestrator.review_pr(args.pr_number)
|
||||
print(f"[DEBUG] review_pr returned, success={result.success}", flush=True)
|
||||
|
||||
if result.success:
|
||||
print(f"\n{'=' * 60}")
|
||||
@@ -242,38 +182,28 @@ async def cmd_followup_review_pr(args) -> int:
|
||||
import sys
|
||||
|
||||
# Force unbuffered output so Electron sees it in real-time
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(line_buffering=True)
|
||||
if hasattr(sys.stderr, "reconfigure"):
|
||||
sys.stderr.reconfigure(line_buffering=True)
|
||||
sys.stdout.reconfigure(line_buffering=True)
|
||||
sys.stderr.reconfigure(line_buffering=True)
|
||||
|
||||
debug = os.environ.get("DEBUG")
|
||||
if debug:
|
||||
print(f"[DEBUG] Starting follow-up review for PR #{args.pr_number}", flush=True)
|
||||
print(f"[DEBUG] Project directory: {args.project}", flush=True)
|
||||
print("[DEBUG] Building config...", flush=True)
|
||||
print(f"[DEBUG] Starting follow-up review for PR #{args.pr_number}", flush=True)
|
||||
print(f"[DEBUG] Project directory: {args.project}", flush=True)
|
||||
|
||||
print("[DEBUG] Building config...", flush=True)
|
||||
config = get_config(args)
|
||||
print(f"[DEBUG] Config built: repo={config.repo}, model={config.model}", flush=True)
|
||||
|
||||
if debug:
|
||||
print(
|
||||
f"[DEBUG] Config built: repo={config.repo}, model={config.model}",
|
||||
flush=True,
|
||||
)
|
||||
print("[DEBUG] Creating orchestrator...", flush=True)
|
||||
|
||||
print("[DEBUG] Creating orchestrator...", flush=True)
|
||||
orchestrator = GitHubOrchestrator(
|
||||
project_dir=args.project,
|
||||
config=config,
|
||||
progress_callback=print_progress,
|
||||
)
|
||||
print("[DEBUG] Orchestrator created", flush=True)
|
||||
|
||||
if debug:
|
||||
print("[DEBUG] Orchestrator created", flush=True)
|
||||
print(
|
||||
f"[DEBUG] Calling orchestrator.followup_review_pr({args.pr_number})...",
|
||||
flush=True,
|
||||
)
|
||||
print(
|
||||
f"[DEBUG] Calling orchestrator.followup_review_pr({args.pr_number})...",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await orchestrator.followup_review_pr(args.pr_number)
|
||||
@@ -281,10 +211,7 @@ async def cmd_followup_review_pr(args) -> int:
|
||||
print(f"\nFollow-up review failed: {e}")
|
||||
return 1
|
||||
|
||||
if debug:
|
||||
print(
|
||||
f"[DEBUG] followup_review_pr returned, success={result.success}", flush=True
|
||||
)
|
||||
print(f"[DEBUG] followup_review_pr returned, success={result.success}", flush=True)
|
||||
|
||||
if result.success:
|
||||
print(f"\n{'=' * 60}")
|
||||
@@ -683,11 +610,6 @@ def main():
|
||||
action="store_true",
|
||||
help="Automatically post review to GitHub",
|
||||
)
|
||||
review_parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Force a new review even if commit was already reviewed",
|
||||
)
|
||||
|
||||
# followup-review-pr command
|
||||
followup_parser = subparsers.add_parser(
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
"""
|
||||
Category Mapping Utilities
|
||||
===========================
|
||||
|
||||
Shared utilities for mapping AI-generated category names to valid ReviewCategory enum values.
|
||||
|
||||
This module provides a centralized category mapping system used across all PR reviewers
|
||||
(orchestrator, follow-up, parallel) to ensure consistent category normalization.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
try:
|
||||
from ..models import ReviewCategory
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from models import ReviewCategory
|
||||
|
||||
|
||||
# Map AI-generated category names to valid ReviewCategory enum values
|
||||
CATEGORY_MAPPING: dict[str, ReviewCategory] = {
|
||||
# Direct matches (already valid ReviewCategory values)
|
||||
"security": ReviewCategory.SECURITY,
|
||||
"quality": ReviewCategory.QUALITY,
|
||||
"style": ReviewCategory.STYLE,
|
||||
"test": ReviewCategory.TEST,
|
||||
"docs": ReviewCategory.DOCS,
|
||||
"pattern": ReviewCategory.PATTERN,
|
||||
"performance": ReviewCategory.PERFORMANCE,
|
||||
"redundancy": ReviewCategory.REDUNDANCY,
|
||||
"verification_failed": ReviewCategory.VERIFICATION_FAILED,
|
||||
# AI-generated alternatives that need mapping
|
||||
"logic": ReviewCategory.QUALITY, # Logic errors → quality
|
||||
"codebase_fit": ReviewCategory.PATTERN, # Codebase fit → pattern adherence
|
||||
"correctness": ReviewCategory.QUALITY, # Code correctness → quality
|
||||
"consistency": ReviewCategory.PATTERN, # Code consistency → pattern adherence
|
||||
"testing": ReviewCategory.TEST, # Testing → test
|
||||
"documentation": ReviewCategory.DOCS, # Documentation → docs
|
||||
"bug": ReviewCategory.QUALITY, # Bug → quality
|
||||
"error_handling": ReviewCategory.QUALITY, # Error handling → quality
|
||||
"maintainability": ReviewCategory.QUALITY, # Maintainability → quality
|
||||
"readability": ReviewCategory.STYLE, # Readability → style
|
||||
"best_practices": ReviewCategory.PATTERN, # Best practices → pattern (hyphen normalized to underscore)
|
||||
"architecture": ReviewCategory.PATTERN, # Architecture → pattern
|
||||
"complexity": ReviewCategory.QUALITY, # Complexity → quality
|
||||
"dead_code": ReviewCategory.REDUNDANCY, # Dead code → redundancy
|
||||
"unused": ReviewCategory.REDUNDANCY, # Unused code → redundancy
|
||||
# Follow-up specific mappings
|
||||
"regression": ReviewCategory.QUALITY, # Regression → quality
|
||||
"incomplete_fix": ReviewCategory.QUALITY, # Incomplete fix → quality
|
||||
}
|
||||
|
||||
|
||||
def map_category(raw_category: str) -> ReviewCategory:
|
||||
"""
|
||||
Map an AI-generated category string to a valid ReviewCategory enum.
|
||||
|
||||
Args:
|
||||
raw_category: Raw category string from AI (e.g., "best-practices", "logic", "security")
|
||||
|
||||
Returns:
|
||||
ReviewCategory: Normalized category enum value. Defaults to QUALITY if unknown.
|
||||
|
||||
Examples:
|
||||
>>> map_category("security")
|
||||
ReviewCategory.SECURITY
|
||||
>>> map_category("best-practices")
|
||||
ReviewCategory.PATTERN
|
||||
>>> map_category("unknown-category")
|
||||
ReviewCategory.QUALITY
|
||||
"""
|
||||
# Normalize: lowercase, strip whitespace, replace hyphens with underscores
|
||||
normalized = raw_category.lower().strip().replace("-", "_")
|
||||
|
||||
# Look up in mapping, default to QUALITY for unknown categories
|
||||
return CATEGORY_MAPPING.get(normalized, ReviewCategory.QUALITY)
|
||||
@@ -16,6 +16,7 @@ Supports both:
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
@@ -33,9 +34,7 @@ try:
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
)
|
||||
from .category_utils import map_category
|
||||
from .prompt_manager import PromptManager
|
||||
from .pydantic_models import FollowupReviewResponse
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from models import (
|
||||
MergeVerdict,
|
||||
@@ -44,12 +43,21 @@ except (ImportError, ValueError, SystemError):
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
)
|
||||
from services.category_utils import map_category
|
||||
from services.prompt_manager import PromptManager
|
||||
from services.pydantic_models import FollowupReviewResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Category mapping for AI responses
|
||||
_CATEGORY_MAPPING = {
|
||||
"security": ReviewCategory.SECURITY,
|
||||
"quality": ReviewCategory.QUALITY,
|
||||
"logic": ReviewCategory.QUALITY,
|
||||
"test": ReviewCategory.TEST,
|
||||
"docs": ReviewCategory.DOCS,
|
||||
"pattern": ReviewCategory.PATTERN,
|
||||
"performance": ReviewCategory.PERFORMANCE,
|
||||
}
|
||||
|
||||
# Severity mapping for AI responses
|
||||
_SEVERITY_MAPPING = {
|
||||
"critical": ReviewSeverity.CRITICAL,
|
||||
@@ -277,21 +285,17 @@ class FollowupReviewer:
|
||||
resolved.append(finding)
|
||||
else:
|
||||
# File was modified but the specific line wasn't clearly changed
|
||||
# Mark as unresolved - the contributor needs to address the actual issue
|
||||
# "Benefit of the doubt" was wrong - if the line wasn't changed, the issue persists
|
||||
unresolved.append(finding)
|
||||
# Consider it potentially resolved (benefit of the doubt)
|
||||
# Could be more sophisticated with AST analysis
|
||||
resolved.append(finding)
|
||||
|
||||
return resolved, unresolved
|
||||
|
||||
def _line_appears_changed(self, file: str, line: int | None, diff: str) -> bool:
|
||||
def _line_appears_changed(self, file: str, line: int, diff: str) -> bool:
|
||||
"""Check if a specific line appears to have been changed in the diff."""
|
||||
if not diff:
|
||||
return False
|
||||
|
||||
# Handle None or invalid line numbers (legacy data)
|
||||
if line is None or line <= 0:
|
||||
return True # Assume changed if line unknown
|
||||
|
||||
# Look for the file in the diff
|
||||
file_marker = f"--- a/{file}"
|
||||
if file_marker not in diff:
|
||||
@@ -442,20 +446,11 @@ class FollowupReviewer:
|
||||
high_unresolved = sum(
|
||||
1 for f in unresolved_findings if f.severity == ReviewSeverity.HIGH
|
||||
)
|
||||
medium_unresolved = sum(
|
||||
1 for f in unresolved_findings if f.severity == ReviewSeverity.MEDIUM
|
||||
)
|
||||
low_unresolved = sum(
|
||||
1 for f in unresolved_findings if f.severity == ReviewSeverity.LOW
|
||||
)
|
||||
critical_new = sum(
|
||||
1 for f in new_findings if f.severity == ReviewSeverity.CRITICAL
|
||||
)
|
||||
high_new = sum(1 for f in new_findings if f.severity == ReviewSeverity.HIGH)
|
||||
medium_new = sum(1 for f in new_findings if f.severity == ReviewSeverity.MEDIUM)
|
||||
low_new = sum(1 for f in new_findings if f.severity == ReviewSeverity.LOW)
|
||||
|
||||
# Critical and High are always blockers
|
||||
for f in unresolved_findings:
|
||||
if f.severity in [ReviewSeverity.CRITICAL, ReviewSeverity.HIGH]:
|
||||
blockers.append(f"Unresolved: {f.title} ({f.file}:{f.line})")
|
||||
@@ -471,25 +466,17 @@ class FollowupReviewer:
|
||||
f"Still blocked by {critical_unresolved + critical_new} critical issues "
|
||||
f"({critical_unresolved} unresolved, {critical_new} new)"
|
||||
)
|
||||
elif (
|
||||
high_unresolved > 0
|
||||
or high_new > 0
|
||||
or medium_unresolved > 0
|
||||
or medium_new > 0
|
||||
):
|
||||
# High and Medium severity findings block merge
|
||||
elif high_unresolved > 0 or high_new > 0:
|
||||
verdict = MergeVerdict.NEEDS_REVISION
|
||||
total_blocking = high_unresolved + high_new + medium_unresolved + medium_new
|
||||
reasoning = (
|
||||
f"{total_blocking} issue(s) must be addressed "
|
||||
f"({high_unresolved + medium_unresolved} unresolved, {high_new + medium_new} new)"
|
||||
f"{high_unresolved + high_new} high-priority issues "
|
||||
f"({high_unresolved} unresolved, {high_new} new)"
|
||||
)
|
||||
elif low_unresolved > 0 or low_new > 0:
|
||||
# Only Low severity suggestions remaining - safe to merge (non-blocking)
|
||||
verdict = MergeVerdict.READY_TO_MERGE
|
||||
elif len(unresolved_findings) > 0 or len(new_findings) > 0:
|
||||
verdict = MergeVerdict.MERGE_WITH_CHANGES
|
||||
reasoning = (
|
||||
f"{resolved_count} issues resolved. "
|
||||
f"{low_unresolved + low_new} non-blocking suggestion(s) to consider."
|
||||
f"{len(unresolved_findings)} remaining, {len(new_findings)} new minor issues."
|
||||
)
|
||||
else:
|
||||
verdict = MergeVerdict.READY_TO_MERGE
|
||||
@@ -551,10 +538,7 @@ class FollowupReviewer:
|
||||
unresolved: list[PRReviewFinding],
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Run AI-powered follow-up review using structured outputs.
|
||||
|
||||
Uses Claude Agent SDK's native structured output support to guarantee
|
||||
valid JSON responses matching the FollowupReviewResponse schema.
|
||||
Run AI-powered follow-up review using the prompt template.
|
||||
|
||||
Returns parsed AI response with finding resolutions and new findings,
|
||||
or None if AI review fails.
|
||||
@@ -564,7 +548,7 @@ class FollowupReviewer:
|
||||
)
|
||||
|
||||
# Build the context for the AI
|
||||
prompt_template = self.prompt_manager.get_followup_review_prompt()
|
||||
prompt = self.prompt_manager.get_followup_review_prompt()
|
||||
|
||||
# Format previous findings for the prompt
|
||||
previous_findings_text = "\n".join(
|
||||
@@ -597,19 +581,9 @@ class FollowupReviewer:
|
||||
]
|
||||
)
|
||||
|
||||
# Format PR reviews (formal review submissions from Cursor, CodeRabbit, etc.)
|
||||
# These often contain detailed findings in the body, so we include more content
|
||||
pr_reviews_text = "\n\n".join(
|
||||
[
|
||||
f"**@{r.get('user', {}).get('login', 'unknown')}** ({r.get('state', 'COMMENTED')}):\n{r.get('body', '')[:2000]}"
|
||||
for r in context.pr_reviews_since_review
|
||||
if r.get("body", "").strip() # Only include reviews with body content
|
||||
]
|
||||
)
|
||||
|
||||
# Build the full message
|
||||
user_message = f"""
|
||||
{prompt_template}
|
||||
{prompt}
|
||||
|
||||
---
|
||||
|
||||
@@ -639,107 +613,51 @@ class FollowupReviewer:
|
||||
### AI BOT COMMENTS SINCE LAST REVIEW:
|
||||
{ai_comments_text if ai_comments_text else "No AI bot comments."}
|
||||
|
||||
### PR REVIEWS SINCE LAST REVIEW (CodeRabbit, Gemini Code Assist, Cursor, etc.):
|
||||
{pr_reviews_text if pr_reviews_text else "No PR reviews since last review."}
|
||||
|
||||
---
|
||||
|
||||
**IMPORTANT**: Pay special attention to the PR REVIEWS section above. These are formal code reviews from AI tools like CodeRabbit, Gemini Code Assist, Cursor, Greptile, etc. that may have identified issues in the recent changes. You should:
|
||||
1. Consider their findings when evaluating the code
|
||||
2. Create new findings for valid issues they identified that haven't been addressed
|
||||
3. Note if the recent commits addressed concerns raised in these reviews
|
||||
|
||||
Analyze this follow-up review context and provide your structured response.
|
||||
Please analyze this follow-up review context and provide your response in the JSON format specified in the prompt.
|
||||
"""
|
||||
|
||||
try:
|
||||
# Use Claude Agent SDK query() with structured outputs
|
||||
# Reference: https://platform.claude.com/docs/en/agent-sdk/structured-outputs
|
||||
from claude_agent_sdk import ClaudeAgentOptions, query
|
||||
from phase_config import get_thinking_budget
|
||||
# Use ClaudeSDKClient directly for simple message calls
|
||||
# (no agent tools needed, just a single query/response)
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
|
||||
model = self.config.model or "claude-sonnet-4-5-20250929"
|
||||
thinking_level = self.config.thinking_level or "medium"
|
||||
thinking_budget = get_thinking_budget(thinking_level)
|
||||
|
||||
# Debug: Log the schema being sent
|
||||
schema = FollowupReviewResponse.model_json_schema()
|
||||
logger.debug(
|
||||
f"[Followup] Using output_format schema: {list(schema.get('properties', {}).keys())}"
|
||||
)
|
||||
print(f"[Followup] SDK query with output_format, model={model}", flush=True)
|
||||
|
||||
# Iterate through messages from the query
|
||||
# Note: max_turns=2 because structured output uses a tool call + response
|
||||
async for message in query(
|
||||
prompt=user_message,
|
||||
client = ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model=model,
|
||||
system_prompt="You are a code review assistant. Analyze the provided context and provide structured feedback.",
|
||||
system_prompt="You are a code review assistant. Analyze the provided context and respond with valid JSON.",
|
||||
allowed_tools=[],
|
||||
max_turns=2, # Need 2 turns for structured output tool call
|
||||
max_thinking_tokens=thinking_budget,
|
||||
output_format={
|
||||
"type": "json_schema",
|
||||
"schema": schema,
|
||||
},
|
||||
),
|
||||
):
|
||||
msg_type = type(message).__name__
|
||||
max_turns=1,
|
||||
max_thinking_tokens=2048,
|
||||
)
|
||||
)
|
||||
|
||||
# SDK delivers structured output via ToolUseBlock named 'StructuredOutput'
|
||||
# in an AssistantMessage
|
||||
if msg_type == "AssistantMessage":
|
||||
content = getattr(message, "content", [])
|
||||
for block in content:
|
||||
block_type = type(block).__name__
|
||||
if block_type == "ToolUseBlock":
|
||||
tool_name = getattr(block, "name", "")
|
||||
if tool_name == "StructuredOutput":
|
||||
# Extract structured data from tool input
|
||||
structured_data = getattr(block, "input", None)
|
||||
if structured_data:
|
||||
logger.info(
|
||||
"[Followup] Found StructuredOutput tool use"
|
||||
)
|
||||
print(
|
||||
"[Followup] Using SDK structured output",
|
||||
flush=True,
|
||||
)
|
||||
# Validate with Pydantic and convert
|
||||
result = FollowupReviewResponse.model_validate(
|
||||
structured_data
|
||||
)
|
||||
return self._convert_structured_to_internal(result)
|
||||
response_text = ""
|
||||
async with client:
|
||||
await client.query(user_message)
|
||||
|
||||
# Also check for direct structured_output attribute (SDK validated JSON)
|
||||
if (
|
||||
hasattr(message, "structured_output")
|
||||
and message.structured_output
|
||||
):
|
||||
logger.info(
|
||||
"[Followup] Found structured_output attribute on message"
|
||||
)
|
||||
print(
|
||||
"[Followup] Using SDK structured output (direct attribute)",
|
||||
flush=True,
|
||||
)
|
||||
result = FollowupReviewResponse.model_validate(
|
||||
message.structured_output
|
||||
)
|
||||
return self._convert_structured_to_internal(result)
|
||||
async for msg in client.receive_response():
|
||||
msg_type = type(msg).__name__
|
||||
logger.debug(f"AI response message type: {msg_type}")
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
block_type = type(block).__name__
|
||||
logger.debug(f" Content block type: {block_type}")
|
||||
if hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
elif hasattr(block, "thinking"):
|
||||
# Skip thinking blocks - we only want the final text
|
||||
logger.debug(" (skipping thinking block)")
|
||||
|
||||
# Handle ResultMessage for errors
|
||||
if msg_type == "ResultMessage":
|
||||
subtype = getattr(message, "subtype", None)
|
||||
if subtype == "error_max_structured_output_retries":
|
||||
logger.warning(
|
||||
"Claude could not produce valid structured output after retries"
|
||||
)
|
||||
return None
|
||||
if not response_text:
|
||||
logger.warning("AI returned empty response (no text blocks found)")
|
||||
return None
|
||||
|
||||
logger.warning("No structured output received from AI")
|
||||
return None
|
||||
logger.debug(f"AI response text (first 500 chars): {response_text[:500]}")
|
||||
return self._parse_ai_response(response_text)
|
||||
|
||||
except ValueError as e:
|
||||
# OAuth token not found
|
||||
@@ -747,69 +665,96 @@ Analyze this follow-up review context and provide your structured response.
|
||||
print("AI review failed: No OAuth token found", flush=True)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"AI review with structured output failed: {e}")
|
||||
logger.error(f"AI review failed: {e}")
|
||||
return None
|
||||
|
||||
def _convert_structured_to_internal(
|
||||
self, result: FollowupReviewResponse
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Convert Pydantic FollowupReviewResponse to internal dict format.
|
||||
def _parse_ai_response(self, response_text: str) -> dict[str, Any] | None:
|
||||
"""Parse the AI response JSON."""
|
||||
# Extract JSON from response (may be wrapped in markdown code blocks)
|
||||
json_match = re.search(r"```json\s*(.*?)\s*```", response_text, re.DOTALL)
|
||||
if json_match:
|
||||
json_str = json_match.group(1)
|
||||
else:
|
||||
# Try to find raw JSON
|
||||
json_match = re.search(r"\{[\s\S]*\}", response_text)
|
||||
if json_match:
|
||||
json_str = json_match.group(0)
|
||||
else:
|
||||
logger.warning("No JSON found in AI response")
|
||||
return None
|
||||
|
||||
Converts Pydantic finding models to PRReviewFinding dataclass objects
|
||||
for compatibility with existing codebase.
|
||||
"""
|
||||
# Convert new_findings to PRReviewFinding objects
|
||||
new_findings = []
|
||||
for f in result.new_findings:
|
||||
new_findings.append(
|
||||
PRReviewFinding(
|
||||
id=f.id,
|
||||
severity=_SEVERITY_MAPPING.get(f.severity, ReviewSeverity.MEDIUM),
|
||||
category=map_category(f.category),
|
||||
title=f.title,
|
||||
description=f.description,
|
||||
file=f.file,
|
||||
line=f.line,
|
||||
suggested_fix=f.suggested_fix,
|
||||
fixable=f.fixable,
|
||||
)
|
||||
)
|
||||
try:
|
||||
data = json.loads(json_str)
|
||||
|
||||
# Convert comment_findings to PRReviewFinding objects
|
||||
comment_findings = []
|
||||
for f in result.comment_findings:
|
||||
comment_findings.append(
|
||||
PRReviewFinding(
|
||||
id=f.id,
|
||||
severity=_SEVERITY_MAPPING.get(f.severity, ReviewSeverity.LOW),
|
||||
category=map_category(f.category),
|
||||
title=f.title,
|
||||
description=f.description,
|
||||
file=f.file,
|
||||
line=f.line,
|
||||
suggested_fix=f.suggested_fix,
|
||||
fixable=f.fixable,
|
||||
)
|
||||
)
|
||||
# Convert new_findings to PRReviewFinding objects
|
||||
new_findings = []
|
||||
for f in data.get("new_findings", []):
|
||||
try:
|
||||
new_findings.append(
|
||||
PRReviewFinding(
|
||||
id=f.get(
|
||||
"id",
|
||||
hashlib.md5(
|
||||
f.get("title", "").encode(), usedforsecurity=False
|
||||
).hexdigest()[:12],
|
||||
),
|
||||
severity=_SEVERITY_MAPPING.get(
|
||||
f.get("severity", "medium").lower(),
|
||||
ReviewSeverity.MEDIUM,
|
||||
),
|
||||
category=_CATEGORY_MAPPING.get(
|
||||
f.get("category", "quality").lower(),
|
||||
ReviewCategory.QUALITY,
|
||||
),
|
||||
title=f.get("title", "Untitled finding"),
|
||||
description=f.get("description", ""),
|
||||
file=f.get("file", ""),
|
||||
line=f.get("line", 0),
|
||||
suggested_fix=f.get("suggested_fix"),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse finding: {e}")
|
||||
|
||||
# Convert finding_resolutions to dict format
|
||||
finding_resolutions = [
|
||||
{
|
||||
"finding_id": r.finding_id,
|
||||
"status": r.status,
|
||||
"resolution_notes": r.resolution_notes,
|
||||
# Convert comment_findings similarly
|
||||
comment_findings = []
|
||||
for f in data.get("comment_findings", []):
|
||||
try:
|
||||
comment_findings.append(
|
||||
PRReviewFinding(
|
||||
id=f.get(
|
||||
"id",
|
||||
hashlib.md5(
|
||||
f.get("title", "").encode(), usedforsecurity=False
|
||||
).hexdigest()[:12],
|
||||
),
|
||||
severity=_SEVERITY_MAPPING.get(
|
||||
f.get("severity", "low").lower(), ReviewSeverity.LOW
|
||||
),
|
||||
category=_CATEGORY_MAPPING.get(
|
||||
f.get("category", "quality").lower(),
|
||||
ReviewCategory.QUALITY,
|
||||
),
|
||||
title=f.get("title", "Comment needs attention"),
|
||||
description=f.get("description", ""),
|
||||
file=f.get("file", ""),
|
||||
line=f.get("line", 0),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse comment finding: {e}")
|
||||
|
||||
return {
|
||||
"finding_resolutions": data.get("finding_resolutions", []),
|
||||
"new_findings": new_findings,
|
||||
"comment_findings": comment_findings,
|
||||
"verdict": data.get("verdict"),
|
||||
"verdict_reasoning": data.get("verdict_reasoning"),
|
||||
}
|
||||
for r in result.finding_resolutions
|
||||
]
|
||||
|
||||
return {
|
||||
"finding_resolutions": finding_resolutions,
|
||||
"new_findings": new_findings,
|
||||
"comment_findings": comment_findings,
|
||||
"verdict": result.verdict,
|
||||
"verdict_reasoning": result.verdict_reasoning,
|
||||
}
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse AI response JSON: {e}")
|
||||
return None
|
||||
|
||||
def _apply_ai_resolutions(
|
||||
self,
|
||||
|
||||
@@ -0,0 +1,912 @@
|
||||
"""
|
||||
Orchestrating PR Reviewer
|
||||
==========================
|
||||
|
||||
Strategic PR review system using a single Opus 4.5 orchestrating agent
|
||||
that makes human-like decisions about where to focus review effort.
|
||||
|
||||
Replaces the fixed multi-pass system with adaptive, risk-based review.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from ...core.client import create_client
|
||||
from ..context_gatherer import PRContext
|
||||
from ..models import (
|
||||
GitHubRunnerConfig,
|
||||
MergeVerdict,
|
||||
PRReviewFinding,
|
||||
PRReviewResult,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
)
|
||||
from .review_tools import (
|
||||
check_coverage,
|
||||
get_file_content,
|
||||
run_tests,
|
||||
spawn_deep_analysis,
|
||||
spawn_quality_review,
|
||||
spawn_security_review,
|
||||
verify_path_exists,
|
||||
)
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from context_gatherer import PRContext
|
||||
from core.client import create_client
|
||||
from models import (
|
||||
GitHubRunnerConfig,
|
||||
MergeVerdict,
|
||||
PRReviewFinding,
|
||||
PRReviewResult,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
)
|
||||
from services.review_tools import (
|
||||
check_coverage,
|
||||
get_file_content,
|
||||
run_tests,
|
||||
spawn_deep_analysis,
|
||||
spawn_quality_review,
|
||||
spawn_security_review,
|
||||
verify_path_exists,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Map AI-generated category names to valid ReviewCategory enum values
|
||||
# The AI sometimes generates categories that aren't in our enum
|
||||
_CATEGORY_MAPPING = {
|
||||
# Direct matches (already valid)
|
||||
"security": ReviewCategory.SECURITY,
|
||||
"quality": ReviewCategory.QUALITY,
|
||||
"style": ReviewCategory.STYLE,
|
||||
"test": ReviewCategory.TEST,
|
||||
"docs": ReviewCategory.DOCS,
|
||||
"pattern": ReviewCategory.PATTERN,
|
||||
"performance": ReviewCategory.PERFORMANCE,
|
||||
"verification_failed": ReviewCategory.VERIFICATION_FAILED,
|
||||
"redundancy": ReviewCategory.REDUNDANCY,
|
||||
# AI-generated alternatives that need mapping
|
||||
"correctness": ReviewCategory.QUALITY, # Logic/code correctness → quality
|
||||
"consistency": ReviewCategory.PATTERN, # Code consistency → pattern adherence
|
||||
"testing": ReviewCategory.TEST, # Testing → test
|
||||
"documentation": ReviewCategory.DOCS, # Documentation → docs
|
||||
"bug": ReviewCategory.QUALITY, # Bug → quality
|
||||
"logic": ReviewCategory.QUALITY, # Logic error → quality
|
||||
"error_handling": ReviewCategory.QUALITY, # Error handling → quality
|
||||
"maintainability": ReviewCategory.QUALITY, # Maintainability → quality
|
||||
"readability": ReviewCategory.STYLE, # Readability → style
|
||||
"best_practices": ReviewCategory.PATTERN, # Best practices → pattern
|
||||
"best-practices": ReviewCategory.PATTERN, # With hyphen
|
||||
"architecture": ReviewCategory.PATTERN, # Architecture → pattern
|
||||
"complexity": ReviewCategory.QUALITY, # Complexity → quality
|
||||
"dead_code": ReviewCategory.REDUNDANCY, # Dead code → redundancy
|
||||
"unused": ReviewCategory.REDUNDANCY, # Unused → redundancy
|
||||
}
|
||||
|
||||
|
||||
def _map_category(category_str: str) -> ReviewCategory:
|
||||
"""
|
||||
Map an AI-generated category string to a valid ReviewCategory enum.
|
||||
|
||||
Falls back to QUALITY if the category is unknown.
|
||||
"""
|
||||
normalized = category_str.lower().strip().replace("-", "_")
|
||||
return _CATEGORY_MAPPING.get(normalized, ReviewCategory.QUALITY)
|
||||
|
||||
|
||||
class OrchestratorReviewer:
|
||||
"""
|
||||
Strategic PR reviewer using Opus 4.5 for orchestration.
|
||||
|
||||
Makes human-like decisions about:
|
||||
- Which files are high-risk and need deep review
|
||||
- When to spawn focused subagents vs quick scan
|
||||
- Whether to run tests/coverage checks
|
||||
- Final verdict based on aggregated findings
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project_dir: Path,
|
||||
github_dir: Path,
|
||||
config: GitHubRunnerConfig,
|
||||
progress_callback=None,
|
||||
):
|
||||
self.project_dir = Path(project_dir)
|
||||
self.github_dir = Path(github_dir)
|
||||
self.config = config
|
||||
self.progress_callback = progress_callback
|
||||
|
||||
# Token usage tracking
|
||||
self.total_tokens = 0
|
||||
self.MAX_TOTAL_BUDGET = 150_000
|
||||
|
||||
def _report_progress(self, phase: str, progress: int, message: str, **kwargs):
|
||||
"""Report progress if callback is set."""
|
||||
if self.progress_callback:
|
||||
import sys
|
||||
|
||||
if "orchestrator" in sys.modules:
|
||||
ProgressCallback = sys.modules["orchestrator"].ProgressCallback
|
||||
else:
|
||||
try:
|
||||
from ..orchestrator import ProgressCallback
|
||||
except ImportError:
|
||||
from orchestrator import ProgressCallback
|
||||
|
||||
self.progress_callback(
|
||||
ProgressCallback(
|
||||
phase=phase, progress=progress, message=message, **kwargs
|
||||
)
|
||||
)
|
||||
|
||||
async def review(self, context: PRContext) -> PRReviewResult:
|
||||
"""
|
||||
Main review entry point.
|
||||
|
||||
Args:
|
||||
context: Full PR context with all files and patches
|
||||
|
||||
Returns:
|
||||
PRReviewResult with findings and verdict
|
||||
"""
|
||||
logger.info(
|
||||
f"[Orchestrator] Starting strategic review for PR #{context.pr_number}"
|
||||
)
|
||||
|
||||
try:
|
||||
self._report_progress(
|
||||
"orchestrating",
|
||||
20,
|
||||
"Orchestrator analyzing PR structure...",
|
||||
pr_number=context.pr_number,
|
||||
)
|
||||
|
||||
# Build orchestrator prompt with tool definitions
|
||||
prompt = self._build_orchestrator_prompt(context)
|
||||
|
||||
# Create Opus 4.5 client with extended thinking
|
||||
project_root = (
|
||||
self.project_dir.parent.parent
|
||||
if self.project_dir.name == "backend"
|
||||
else self.project_dir
|
||||
)
|
||||
|
||||
client = create_client(
|
||||
project_dir=project_root,
|
||||
spec_dir=self.github_dir,
|
||||
model="claude-opus-4-5-20251101", # Opus for strategic thinking
|
||||
agent_type="pr_reviewer", # Read-only - no bash, no edits
|
||||
max_thinking_tokens=10000, # High budget for strategy
|
||||
)
|
||||
|
||||
self._report_progress(
|
||||
"orchestrating",
|
||||
30,
|
||||
"Orchestrator making strategic decisions...",
|
||||
pr_number=context.pr_number,
|
||||
)
|
||||
|
||||
# Run orchestrator session with tool calling
|
||||
all_findings = []
|
||||
test_result = None
|
||||
result_text = ""
|
||||
tool_calls_made = []
|
||||
|
||||
logger.info(f"[Orchestrator] Sending prompt (length: {len(prompt)} chars)")
|
||||
logger.debug(f"[Orchestrator] Prompt preview: {prompt[:500]}...")
|
||||
|
||||
async with client:
|
||||
await client.query(prompt)
|
||||
|
||||
async for msg in client.receive_response():
|
||||
msg_type = type(msg).__name__
|
||||
logger.debug(f"[Orchestrator] Received message type: {msg_type}")
|
||||
|
||||
# Handle tool calls from orchestrator
|
||||
if msg_type == "ToolUseBlock" or (
|
||||
hasattr(msg, "type") and msg.type == "tool_use"
|
||||
):
|
||||
tool_name = (
|
||||
msg.name
|
||||
if hasattr(msg, "name")
|
||||
else msg.tool_use.name
|
||||
if hasattr(msg, "tool_use")
|
||||
else "unknown"
|
||||
)
|
||||
tool_calls_made.append(tool_name)
|
||||
logger.info(f"[Orchestrator] Tool call detected: {tool_name}")
|
||||
|
||||
tool_result = await self._handle_tool_call(msg, context)
|
||||
# Tools already executed, agent will receive results
|
||||
|
||||
logger.debug(
|
||||
f"[Orchestrator] Tool result: {str(tool_result)[:200]}..."
|
||||
)
|
||||
|
||||
# Track findings from subagents
|
||||
if isinstance(tool_result, dict):
|
||||
if "findings" in tool_result:
|
||||
findings_count = len(tool_result["findings"])
|
||||
logger.info(
|
||||
f"[Orchestrator] Tool returned {findings_count} findings"
|
||||
)
|
||||
all_findings.extend(tool_result["findings"])
|
||||
if "test_result" in tool_result:
|
||||
test_result = tool_result["test_result"]
|
||||
logger.info(
|
||||
f"[Orchestrator] Tool returned test result: {test_result.get('passed', 'unknown')}"
|
||||
)
|
||||
|
||||
# Track token usage from response
|
||||
if hasattr(msg, "usage"):
|
||||
usage = msg.usage
|
||||
tokens_used = getattr(usage, "input_tokens", 0) + getattr(
|
||||
usage, "output_tokens", 0
|
||||
)
|
||||
self.total_tokens += tokens_used
|
||||
logger.debug(
|
||||
f"[Orchestrator] Token usage: +{tokens_used} (total: {self.total_tokens})"
|
||||
)
|
||||
|
||||
# Collect final orchestrator output
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
if hasattr(block, "text"):
|
||||
result_text += block.text
|
||||
logger.debug(
|
||||
f"[Orchestrator] Received text block (length: {len(block.text)})"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[Orchestrator] Session complete. Tool calls made: {tool_calls_made}"
|
||||
)
|
||||
logger.info(
|
||||
f"[Orchestrator] Final text response length: {len(result_text)}"
|
||||
)
|
||||
logger.debug(f"[Orchestrator] Final text preview: {result_text[:500]}...")
|
||||
|
||||
# CRITICAL DEBUG: Print to ensure visibility
|
||||
print(
|
||||
f"[Orchestrator] Session complete. Tool calls: {tool_calls_made}",
|
||||
flush=True,
|
||||
)
|
||||
print(
|
||||
f"[Orchestrator] Final text length: {len(result_text)} chars",
|
||||
flush=True,
|
||||
)
|
||||
print("[Orchestrator] ===== FULL OUTPUT START =====", flush=True)
|
||||
print(result_text, flush=True)
|
||||
print("[Orchestrator] ===== FULL OUTPUT END =====", flush=True)
|
||||
|
||||
self._report_progress(
|
||||
"finalizing",
|
||||
80,
|
||||
"Generating verdict...",
|
||||
pr_number=context.pr_number,
|
||||
)
|
||||
|
||||
# Parse orchestrator's final output
|
||||
orchestrator_findings = self._parse_orchestrator_output(result_text)
|
||||
all_findings.extend(orchestrator_findings)
|
||||
|
||||
# Deduplicate findings
|
||||
unique_findings = self._deduplicate_findings(all_findings)
|
||||
|
||||
logger.info(
|
||||
f"[Orchestrator] Review complete: {len(unique_findings)} findings"
|
||||
)
|
||||
|
||||
# Generate verdict
|
||||
verdict, verdict_reasoning, blockers = self._generate_verdict(
|
||||
unique_findings, test_result
|
||||
)
|
||||
|
||||
# Generate summary
|
||||
summary = self._generate_summary(
|
||||
verdict=verdict,
|
||||
verdict_reasoning=verdict_reasoning,
|
||||
blockers=blockers,
|
||||
findings=unique_findings,
|
||||
test_result=test_result,
|
||||
)
|
||||
|
||||
# Map verdict to overall_status
|
||||
if verdict == MergeVerdict.BLOCKED:
|
||||
overall_status = "request_changes"
|
||||
elif verdict == MergeVerdict.NEEDS_REVISION:
|
||||
overall_status = "request_changes"
|
||||
elif verdict == MergeVerdict.MERGE_WITH_CHANGES:
|
||||
overall_status = "comment"
|
||||
else:
|
||||
overall_status = "approve"
|
||||
|
||||
result = PRReviewResult(
|
||||
pr_number=context.pr_number,
|
||||
repo=self.config.repo,
|
||||
success=True,
|
||||
findings=unique_findings,
|
||||
summary=summary,
|
||||
overall_status=overall_status,
|
||||
verdict=verdict,
|
||||
verdict_reasoning=verdict_reasoning,
|
||||
blockers=blockers,
|
||||
)
|
||||
|
||||
print(
|
||||
f"[Orchestrator] Returning PRReviewResult with {len(result.findings)} findings",
|
||||
flush=True,
|
||||
)
|
||||
print(
|
||||
f"[Orchestrator] Verdict: {result.verdict.value if result.verdict else 'None'}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
self._report_progress(
|
||||
"complete", 100, "Review complete!", pr_number=context.pr_number
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Orchestrator] Review failed: {e}", exc_info=True)
|
||||
result = PRReviewResult(
|
||||
pr_number=context.pr_number,
|
||||
repo=self.config.repo,
|
||||
success=False,
|
||||
error=str(e),
|
||||
)
|
||||
return result
|
||||
|
||||
async def _handle_tool_call(self, tool_msg, context: PRContext) -> dict[str, Any]:
|
||||
"""
|
||||
Handle tool calls from orchestrator.
|
||||
|
||||
The orchestrator can call tools to spawn subagents, run tests, etc.
|
||||
"""
|
||||
# Extract tool name and arguments based on message type
|
||||
if hasattr(tool_msg, "name"):
|
||||
tool_name = tool_msg.name
|
||||
tool_args = tool_msg.input if hasattr(tool_msg, "input") else {}
|
||||
elif hasattr(tool_msg, "tool_use"):
|
||||
tool_name = tool_msg.tool_use.name
|
||||
tool_args = tool_msg.tool_use.input
|
||||
else:
|
||||
logger.warning("[Orchestrator] Unknown tool message format")
|
||||
return {}
|
||||
|
||||
logger.info(f"[Orchestrator] Tool call: {tool_name}")
|
||||
|
||||
# Check token budget
|
||||
if self.total_tokens > self.MAX_TOTAL_BUDGET:
|
||||
logger.warning("[Orchestrator] Token budget exceeded, skipping tool")
|
||||
return {"error": "Token budget exceeded"}
|
||||
|
||||
try:
|
||||
# Dispatch to appropriate tool
|
||||
if tool_name == "spawn_security_review":
|
||||
findings = await spawn_security_review(
|
||||
files=tool_args.get("files", []),
|
||||
focus_areas=tool_args.get("focus_areas", []),
|
||||
pr_context=context,
|
||||
project_dir=self.project_dir,
|
||||
github_dir=self.github_dir,
|
||||
)
|
||||
return {"findings": [f.__dict__ for f in findings]}
|
||||
|
||||
elif tool_name == "spawn_quality_review":
|
||||
findings = await spawn_quality_review(
|
||||
files=tool_args.get("files", []),
|
||||
focus_areas=tool_args.get("focus_areas", []),
|
||||
pr_context=context,
|
||||
project_dir=self.project_dir,
|
||||
github_dir=self.github_dir,
|
||||
)
|
||||
return {"findings": [f.__dict__ for f in findings]}
|
||||
|
||||
elif tool_name == "spawn_deep_analysis":
|
||||
findings = await spawn_deep_analysis(
|
||||
files=tool_args.get("files", []),
|
||||
focus_question=tool_args.get("focus_question", ""),
|
||||
pr_context=context,
|
||||
project_dir=self.project_dir,
|
||||
github_dir=self.github_dir,
|
||||
)
|
||||
return {"findings": [f.__dict__ for f in findings]}
|
||||
|
||||
elif tool_name == "run_tests":
|
||||
test_result = await run_tests(
|
||||
project_dir=self.project_dir,
|
||||
test_paths=tool_args.get("test_paths"),
|
||||
)
|
||||
return {"test_result": test_result.__dict__}
|
||||
|
||||
elif tool_name == "check_coverage":
|
||||
coverage = await check_coverage(
|
||||
project_dir=self.project_dir,
|
||||
changed_files=[f.path for f in context.changed_files],
|
||||
)
|
||||
return (
|
||||
{"coverage": coverage.__dict__} if coverage else {"coverage": None}
|
||||
)
|
||||
|
||||
elif tool_name == "verify_path_exists":
|
||||
path_result = await verify_path_exists(
|
||||
project_dir=self.project_dir,
|
||||
path=tool_args.get("path", ""),
|
||||
)
|
||||
return {"path_check": path_result.__dict__}
|
||||
|
||||
elif tool_name == "get_file_content":
|
||||
content = await get_file_content(
|
||||
project_dir=self.project_dir,
|
||||
file_path=tool_args.get("file_path", ""),
|
||||
)
|
||||
return {"content": content}
|
||||
|
||||
else:
|
||||
logger.warning(f"[Orchestrator] Unknown tool: {tool_name}")
|
||||
return {"error": f"Unknown tool: {tool_name}"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Orchestrator] Tool {tool_name} failed: {e}")
|
||||
return {"error": str(e)}
|
||||
|
||||
def _build_orchestrator_prompt(self, context: PRContext) -> str:
|
||||
"""Build full prompt for orchestrator with PR context and tool definitions."""
|
||||
# Load orchestrator prompt
|
||||
prompt_file = (
|
||||
Path(__file__).parent.parent.parent.parent
|
||||
/ "prompts"
|
||||
/ "github"
|
||||
/ "pr_orchestrator.md"
|
||||
)
|
||||
|
||||
if prompt_file.exists():
|
||||
base_prompt = prompt_file.read_text()
|
||||
else:
|
||||
logger.warning("Orchestrator prompt not found!")
|
||||
base_prompt = "You are a PR reviewer. Review the provided PR."
|
||||
|
||||
# Build PR context
|
||||
files_list = []
|
||||
for file in context.changed_files: # Show ALL files
|
||||
files_list.append(
|
||||
f"- `{file.path}` (+{file.additions}/-{file.deletions}) - {file.status}"
|
||||
)
|
||||
|
||||
# Build composite diff from patches (use individual file patches when diff_truncated)
|
||||
patches = []
|
||||
files_with_patches = 0
|
||||
MAX_DIFF_CHARS = 200_000 # Increase limit to 200K chars for large PRs
|
||||
|
||||
for file in context.changed_files: # Process ALL files, not just first 50
|
||||
if file.patch:
|
||||
patches.append(f"\n### File: {file.path}\n{file.patch}")
|
||||
files_with_patches += 1
|
||||
|
||||
diff_content = "\n".join(patches)
|
||||
|
||||
# Check if diff needs truncation
|
||||
if len(diff_content) > MAX_DIFF_CHARS:
|
||||
logger.warning(
|
||||
f"[Orchestrator] Diff truncated from {len(diff_content)} to {MAX_DIFF_CHARS} chars"
|
||||
)
|
||||
diff_content = (
|
||||
diff_content[:MAX_DIFF_CHARS] + "\n\n... (diff truncated due to size)"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"[Orchestrator] Built context: {len(context.changed_files)} files total, {files_with_patches} with patches, {len(diff_content)} chars diff"
|
||||
)
|
||||
|
||||
# Add truncation warning if needed
|
||||
truncation_note = ""
|
||||
if len(diff_content) >= MAX_DIFF_CHARS or context.diff_truncated:
|
||||
truncation_note = f"""
|
||||
|
||||
**⚠️ IMPORTANT:** This PR is very large. The diff shown below may be truncated.
|
||||
- Files with patches: {files_with_patches}/{len(context.changed_files)}
|
||||
- Use `get_file_content(file_path)` tool to fetch full content of specific files you want to review in depth.
|
||||
"""
|
||||
|
||||
pr_context = f"""
|
||||
---
|
||||
|
||||
## PR Context for Review
|
||||
|
||||
**PR Number:** {context.pr_number}
|
||||
**Title:** {context.title}
|
||||
**Author:** {context.author}
|
||||
**Base:** {context.base_branch} ← **Head:** {context.head_branch}
|
||||
**Files Changed:** {len(context.changed_files)} files
|
||||
**Total Changes:** +{context.total_additions}/-{context.total_deletions} lines
|
||||
{truncation_note}
|
||||
|
||||
### Description
|
||||
{context.description}
|
||||
|
||||
### All Changed Files
|
||||
{chr(10).join(files_list)}
|
||||
|
||||
### Code Changes ({files_with_patches} files with patches)
|
||||
```diff
|
||||
{diff_content}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Now perform your strategic review and use the available tools to spawn subagents, run tests, etc. as needed.
|
||||
"""
|
||||
|
||||
return base_prompt + pr_context
|
||||
|
||||
def _parse_orchestrator_output(self, output: str) -> list[PRReviewFinding]:
|
||||
"""Parse findings from orchestrator's final output."""
|
||||
findings = []
|
||||
|
||||
logger.debug(f"[Orchestrator] Parsing output (length: {len(output)})")
|
||||
print(
|
||||
f"[Orchestrator] PARSING OUTPUT - Length: {len(output)} chars", flush=True
|
||||
)
|
||||
|
||||
try:
|
||||
# Strip markdown code blocks if present
|
||||
# AI often wraps JSON in ```json ... ```
|
||||
import re
|
||||
|
||||
# Find JSON in code blocks first
|
||||
code_block_pattern = r"```(?:json)?\s*(\{[\s\S]*?\})\s*```"
|
||||
code_block_match = re.search(code_block_pattern, output)
|
||||
if code_block_match:
|
||||
# Extract JSON from inside code block
|
||||
json_candidate = code_block_match.group(1)
|
||||
logger.debug(
|
||||
f"[Orchestrator] Found JSON in code block (length: {len(json_candidate)})"
|
||||
)
|
||||
try:
|
||||
response_data = json.loads(json_candidate)
|
||||
findings_data = response_data.get("findings", [])
|
||||
logger.info(
|
||||
f"[Orchestrator] Parsed {len(findings_data)} findings from code block"
|
||||
)
|
||||
print(
|
||||
f"[Orchestrator] Parsed JSON from code block - Verdict: {response_data.get('verdict', 'unknown')}",
|
||||
flush=True,
|
||||
)
|
||||
return self._extract_findings_from_data(findings_data)
|
||||
except json.JSONDecodeError:
|
||||
logger.debug(
|
||||
"[Orchestrator] Code block JSON parse failed, trying raw extraction"
|
||||
)
|
||||
|
||||
# Look for JSON object in output (orchestrator outputs full object, not just array)
|
||||
start = output.find("{")
|
||||
|
||||
# Find matching closing brace by counting braces
|
||||
if start != -1:
|
||||
brace_count = 0
|
||||
end = -1
|
||||
for i in range(start, len(output)):
|
||||
if output[i] == "{":
|
||||
brace_count += 1
|
||||
elif output[i] == "}":
|
||||
brace_count -= 1
|
||||
if brace_count == 0:
|
||||
end = i
|
||||
break
|
||||
|
||||
logger.debug(
|
||||
f"[Orchestrator] JSON object positions: start={start}, end={end}"
|
||||
)
|
||||
|
||||
if end != -1:
|
||||
json_str = output[start : end + 1]
|
||||
logger.debug(
|
||||
f"[Orchestrator] Extracted JSON string (length: {len(json_str)})"
|
||||
)
|
||||
logger.debug(f"[Orchestrator] JSON preview: {json_str[:200]}...")
|
||||
|
||||
# Parse full orchestrator response
|
||||
response_data = json.loads(json_str)
|
||||
logger.info(
|
||||
f"[Orchestrator] Parsed orchestrator response: {response_data.get('verdict', 'unknown')}"
|
||||
)
|
||||
print(
|
||||
f"[Orchestrator] Parsed JSON object - Verdict: {response_data.get('verdict', 'unknown')}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Extract findings array from response
|
||||
findings_data = response_data.get("findings", [])
|
||||
logger.info(
|
||||
f"[Orchestrator] Found {len(findings_data)} finding(s) in response"
|
||||
)
|
||||
print(
|
||||
f"[Orchestrator] Extracted {len(findings_data)} findings from response",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Process findings from JSON object
|
||||
for idx, data in enumerate(findings_data):
|
||||
# Generate unique ID for this finding
|
||||
import hashlib
|
||||
|
||||
finding_id = hashlib.md5(
|
||||
f"{data.get('file', 'unknown')}:{data.get('line', 0)}:{data.get('title', 'Untitled')}".encode(),
|
||||
usedforsecurity=False,
|
||||
).hexdigest()[:12]
|
||||
|
||||
# Map category using flexible mapping (handles AI-generated values)
|
||||
category = _map_category(data.get("category", "quality"))
|
||||
|
||||
# Map severity with fallback
|
||||
try:
|
||||
severity = ReviewSeverity(
|
||||
data.get("severity", "medium").lower()
|
||||
)
|
||||
except ValueError:
|
||||
severity = ReviewSeverity.MEDIUM
|
||||
|
||||
finding = PRReviewFinding(
|
||||
id=finding_id,
|
||||
file=data.get("file", "unknown"),
|
||||
line=data.get("line", 0),
|
||||
title=data.get("title", "Untitled"),
|
||||
description=data.get("description", ""),
|
||||
category=category,
|
||||
severity=severity,
|
||||
suggested_fix=data.get(
|
||||
"suggestion", data.get("suggested_fix", "")
|
||||
),
|
||||
confidence=data.get("confidence", 85) / 100.0
|
||||
if data.get("confidence", 85) > 1
|
||||
else data.get("confidence", 0.85),
|
||||
)
|
||||
findings.append(finding)
|
||||
logger.debug(
|
||||
f"[Orchestrator] Added finding: {finding.title} ({finding.severity.value})"
|
||||
)
|
||||
|
||||
print(
|
||||
f"[Orchestrator] Processed {len(findings)} findings from JSON object",
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"[Orchestrator] Could not find matching closing brace"
|
||||
)
|
||||
return findings
|
||||
elif output.find("[") != -1:
|
||||
# Fallback: Try to parse as array (old format)
|
||||
start = output.find("[")
|
||||
end = output.rfind("]")
|
||||
logger.debug(
|
||||
f"[Orchestrator] Fallback to array parsing: start={start}, end={end}"
|
||||
)
|
||||
|
||||
if start != -1 and end != -1:
|
||||
json_str = output[start : end + 1]
|
||||
logger.debug(
|
||||
f"[Orchestrator] Extracted JSON array (length: {len(json_str)})"
|
||||
)
|
||||
findings_data = json.loads(json_str)
|
||||
logger.info(
|
||||
f"[Orchestrator] Parsed {len(findings_data)} finding(s) from array"
|
||||
)
|
||||
|
||||
for idx, data in enumerate(findings_data):
|
||||
# Generate unique ID for this finding
|
||||
import hashlib
|
||||
|
||||
finding_id = hashlib.md5(
|
||||
f"{data.get('file', 'unknown')}:{data.get('line', 0)}:{data.get('title', 'Untitled')}".encode(),
|
||||
usedforsecurity=False,
|
||||
).hexdigest()[:12]
|
||||
|
||||
# Map category using flexible mapping (handles AI-generated values)
|
||||
category = _map_category(data.get("category", "quality"))
|
||||
|
||||
# Map severity with fallback
|
||||
try:
|
||||
severity = ReviewSeverity(
|
||||
data.get("severity", "medium").lower()
|
||||
)
|
||||
except ValueError:
|
||||
severity = ReviewSeverity.MEDIUM
|
||||
|
||||
finding = PRReviewFinding(
|
||||
id=finding_id,
|
||||
file=data.get("file", "unknown"),
|
||||
line=data.get("line", 0),
|
||||
title=data.get("title", "Untitled"),
|
||||
description=data.get("description", ""),
|
||||
category=category,
|
||||
severity=severity,
|
||||
suggested_fix=data.get(
|
||||
"suggestion", data.get("suggested_fix", "")
|
||||
),
|
||||
confidence=data.get("confidence", 85) / 100.0
|
||||
if data.get("confidence", 85) > 1
|
||||
else data.get("confidence", 0.85),
|
||||
)
|
||||
findings.append(finding)
|
||||
logger.debug(
|
||||
f"[Orchestrator] Added finding: {finding.title} ({finding.severity.value})"
|
||||
)
|
||||
|
||||
else:
|
||||
logger.warning("[Orchestrator] No JSON array found in output")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[Orchestrator] Failed to parse output: {e}", exc_info=True)
|
||||
|
||||
logger.info(f"[Orchestrator] Parsed {len(findings)} total findings from output")
|
||||
return findings
|
||||
|
||||
def _extract_findings_from_data(
|
||||
self, findings_data: list[dict]
|
||||
) -> list[PRReviewFinding]:
|
||||
"""
|
||||
Extract PRReviewFinding objects from parsed JSON findings data.
|
||||
|
||||
Args:
|
||||
findings_data: List of finding dictionaries from JSON
|
||||
|
||||
Returns:
|
||||
List of PRReviewFinding objects
|
||||
"""
|
||||
import hashlib
|
||||
|
||||
findings = []
|
||||
for data in findings_data:
|
||||
# Generate unique ID for this finding
|
||||
finding_id = hashlib.md5(
|
||||
f"{data.get('file', 'unknown')}:{data.get('line', 0)}:{data.get('title', 'Untitled')}".encode(),
|
||||
usedforsecurity=False,
|
||||
).hexdigest()[:12]
|
||||
|
||||
# Map category using flexible mapping (handles AI-generated values)
|
||||
category = _map_category(data.get("category", "quality"))
|
||||
|
||||
# Map severity with fallback
|
||||
try:
|
||||
severity = ReviewSeverity(data.get("severity", "medium").lower())
|
||||
except ValueError:
|
||||
severity = ReviewSeverity.MEDIUM
|
||||
|
||||
finding = PRReviewFinding(
|
||||
id=finding_id,
|
||||
file=data.get("file", "unknown"),
|
||||
line=data.get("line", 0),
|
||||
title=data.get("title", "Untitled"),
|
||||
description=data.get("description", ""),
|
||||
category=category,
|
||||
severity=severity,
|
||||
suggested_fix=data.get("suggestion", data.get("suggested_fix", "")),
|
||||
confidence=data.get("confidence", 85) / 100.0
|
||||
if data.get("confidence", 85) > 1
|
||||
else data.get("confidence", 0.85),
|
||||
)
|
||||
findings.append(finding)
|
||||
logger.debug(
|
||||
f"[Orchestrator] Added finding: {finding.title} ({finding.severity.value})"
|
||||
)
|
||||
|
||||
return findings
|
||||
|
||||
def _deduplicate_findings(
|
||||
self, findings: list[PRReviewFinding]
|
||||
) -> list[PRReviewFinding]:
|
||||
"""Remove duplicate findings."""
|
||||
seen = set()
|
||||
unique = []
|
||||
|
||||
for f in findings:
|
||||
key = (f.file, f.line, f.title.lower().strip())
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(f)
|
||||
|
||||
return unique
|
||||
|
||||
def _generate_verdict(
|
||||
self, findings: list[PRReviewFinding], test_result
|
||||
) -> tuple[MergeVerdict, str, list[str]]:
|
||||
"""Generate merge verdict based on findings and test results."""
|
||||
blockers = []
|
||||
|
||||
# Count by severity
|
||||
critical = [f for f in findings if f.severity == ReviewSeverity.CRITICAL]
|
||||
high = [f for f in findings if f.severity == ReviewSeverity.HIGH]
|
||||
|
||||
# Tests failing is always a blocker
|
||||
if test_result and not test_result.passed:
|
||||
blockers.append(f"Tests failing: {test_result.error or 'Unknown error'}")
|
||||
|
||||
# Critical findings are blockers
|
||||
for f in critical:
|
||||
blockers.append(f"Critical: {f.title} ({f.file}:{f.line})")
|
||||
|
||||
# Determine verdict
|
||||
if blockers or (test_result and not test_result.passed):
|
||||
verdict = MergeVerdict.BLOCKED
|
||||
reasoning = f"Blocked by {len(blockers)} critical issue(s)"
|
||||
elif high:
|
||||
verdict = MergeVerdict.NEEDS_REVISION
|
||||
reasoning = f"{len(high)} high-priority issues must be addressed"
|
||||
elif len(findings) > 0:
|
||||
verdict = MergeVerdict.MERGE_WITH_CHANGES
|
||||
reasoning = f"{len(findings)} issues to address"
|
||||
else:
|
||||
verdict = MergeVerdict.READY_TO_MERGE
|
||||
reasoning = "No blocking issues found"
|
||||
|
||||
return verdict, reasoning, blockers
|
||||
|
||||
def _generate_summary(
|
||||
self,
|
||||
verdict: MergeVerdict,
|
||||
verdict_reasoning: str,
|
||||
blockers: list[str],
|
||||
findings: list[PRReviewFinding],
|
||||
test_result,
|
||||
) -> str:
|
||||
"""Generate PR review summary."""
|
||||
verdict_emoji = {
|
||||
MergeVerdict.READY_TO_MERGE: "✅",
|
||||
MergeVerdict.MERGE_WITH_CHANGES: "🟡",
|
||||
MergeVerdict.NEEDS_REVISION: "🟠",
|
||||
MergeVerdict.BLOCKED: "🔴",
|
||||
}
|
||||
|
||||
lines = [
|
||||
f"### Merge Verdict: {verdict_emoji.get(verdict, '⚪')} {verdict.value.upper().replace('_', ' ')}",
|
||||
verdict_reasoning,
|
||||
"",
|
||||
]
|
||||
|
||||
# Test results
|
||||
if test_result:
|
||||
if test_result.passed:
|
||||
lines.append("✅ **Tests**: All tests passing")
|
||||
else:
|
||||
lines.append(
|
||||
f"❌ **Tests**: Failed - {test_result.error or 'See logs'}"
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
# Blockers
|
||||
if blockers:
|
||||
lines.append("### 🚨 Blocking Issues")
|
||||
for blocker in blockers:
|
||||
lines.append(f"- {blocker}")
|
||||
lines.append("")
|
||||
|
||||
# Findings summary
|
||||
if findings:
|
||||
by_severity = {}
|
||||
for f in findings:
|
||||
severity = f.severity.value
|
||||
if severity not in by_severity:
|
||||
by_severity[severity] = []
|
||||
by_severity[severity].append(f)
|
||||
|
||||
lines.append("### Findings Summary")
|
||||
for severity in ["critical", "high", "medium", "low"]:
|
||||
if severity in by_severity:
|
||||
count = len(by_severity[severity])
|
||||
lines.append(f"- **{severity.capitalize()}**: {count} issue(s)")
|
||||
lines.append("")
|
||||
|
||||
lines.append("---")
|
||||
lines.append("_Generated by Auto Claude Orchestrating PR Reviewer (Opus 4.5)_")
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -1,859 +0,0 @@
|
||||
"""
|
||||
Parallel Follow-up PR Reviewer
|
||||
===============================
|
||||
|
||||
PR follow-up reviewer using Claude Agent SDK subagents for parallel specialist analysis.
|
||||
|
||||
The orchestrator analyzes incremental changes and delegates to specialized agents:
|
||||
- resolution-verifier: Verifies previous findings are addressed
|
||||
- new-code-reviewer: Reviews new code for issues
|
||||
- comment-analyzer: Processes contributor and AI feedback
|
||||
|
||||
Key Design:
|
||||
- AI decides which agents to invoke (NOT programmatic rules)
|
||||
- Subagents defined via SDK `agents={}` parameter
|
||||
- SDK handles parallel execution automatically
|
||||
- User-configured model from frontend settings (no hardcoding)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..models import FollowupReviewContext
|
||||
|
||||
from claude_agent_sdk import AgentDefinition
|
||||
|
||||
try:
|
||||
from ...core.client import create_client
|
||||
from ...phase_config import get_thinking_budget
|
||||
from ..models import (
|
||||
GitHubRunnerConfig,
|
||||
MergeVerdict,
|
||||
PRReviewFinding,
|
||||
PRReviewResult,
|
||||
ReviewSeverity,
|
||||
)
|
||||
from .category_utils import map_category
|
||||
from .pydantic_models import ParallelFollowupResponse
|
||||
from .sdk_utils import process_sdk_stream
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from core.client import create_client
|
||||
from models import (
|
||||
GitHubRunnerConfig,
|
||||
MergeVerdict,
|
||||
PRReviewFinding,
|
||||
PRReviewResult,
|
||||
ReviewSeverity,
|
||||
)
|
||||
from phase_config import get_thinking_budget
|
||||
from services.category_utils import map_category
|
||||
from services.pydantic_models import ParallelFollowupResponse
|
||||
from services.sdk_utils import process_sdk_stream
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Check if debug mode is enabled
|
||||
DEBUG_MODE = os.environ.get("DEBUG", "").lower() in ("true", "1", "yes")
|
||||
|
||||
# Severity mapping for AI responses
|
||||
_SEVERITY_MAPPING = {
|
||||
"critical": ReviewSeverity.CRITICAL,
|
||||
"high": ReviewSeverity.HIGH,
|
||||
"medium": ReviewSeverity.MEDIUM,
|
||||
"low": ReviewSeverity.LOW,
|
||||
}
|
||||
|
||||
|
||||
def _map_severity(severity_str: str) -> ReviewSeverity:
|
||||
"""Map severity string to ReviewSeverity enum."""
|
||||
return _SEVERITY_MAPPING.get(severity_str.lower(), ReviewSeverity.MEDIUM)
|
||||
|
||||
|
||||
class ParallelFollowupReviewer:
|
||||
"""
|
||||
Follow-up PR reviewer using SDK subagents for parallel specialist analysis.
|
||||
|
||||
The orchestrator:
|
||||
1. Analyzes incremental changes since last review
|
||||
2. Delegates to appropriate specialist agents (SDK handles parallel execution)
|
||||
3. Synthesizes findings into a final merge verdict
|
||||
|
||||
Specialist Agents:
|
||||
- resolution-verifier: Verifies previous findings are addressed
|
||||
- new-code-reviewer: Reviews new code for issues
|
||||
- comment-analyzer: Processes contributor and AI feedback
|
||||
|
||||
Model Configuration:
|
||||
- Orchestrator uses user-configured model from frontend settings
|
||||
- Specialist agents use model="inherit" (same as orchestrator)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project_dir: Path,
|
||||
github_dir: Path,
|
||||
config: GitHubRunnerConfig,
|
||||
progress_callback=None,
|
||||
):
|
||||
self.project_dir = Path(project_dir)
|
||||
self.github_dir = Path(github_dir)
|
||||
self.config = config
|
||||
self.progress_callback = progress_callback
|
||||
|
||||
def _report_progress(self, phase: str, progress: int, message: str, **kwargs):
|
||||
"""Report progress if callback is set."""
|
||||
if self.progress_callback:
|
||||
import sys
|
||||
|
||||
if "orchestrator" in sys.modules:
|
||||
ProgressCallback = sys.modules["orchestrator"].ProgressCallback
|
||||
else:
|
||||
try:
|
||||
from ..orchestrator import ProgressCallback
|
||||
except ImportError:
|
||||
from orchestrator import ProgressCallback
|
||||
|
||||
self.progress_callback(
|
||||
ProgressCallback(
|
||||
phase=phase, progress=progress, message=message, **kwargs
|
||||
)
|
||||
)
|
||||
|
||||
def _load_prompt(self, filename: str) -> str:
|
||||
"""Load a prompt file from the prompts/github directory."""
|
||||
prompt_file = (
|
||||
Path(__file__).parent.parent.parent.parent / "prompts" / "github" / filename
|
||||
)
|
||||
if prompt_file.exists():
|
||||
return prompt_file.read_text(encoding="utf-8")
|
||||
logger.warning(f"Prompt file not found: {prompt_file}")
|
||||
return ""
|
||||
|
||||
def _define_specialist_agents(self) -> dict[str, AgentDefinition]:
|
||||
"""
|
||||
Define specialist agents for follow-up review.
|
||||
|
||||
Each agent has:
|
||||
- description: When the orchestrator should invoke this agent
|
||||
- prompt: System prompt for the agent
|
||||
- tools: Tools the agent can use (read-only for PR review)
|
||||
- model: "inherit" = use same model as orchestrator (user's choice)
|
||||
"""
|
||||
# Load agent prompts from files
|
||||
resolution_prompt = self._load_prompt("pr_followup_resolution_agent.md")
|
||||
newcode_prompt = self._load_prompt("pr_followup_newcode_agent.md")
|
||||
comment_prompt = self._load_prompt("pr_followup_comment_agent.md")
|
||||
validator_prompt = self._load_prompt("pr_finding_validator.md")
|
||||
|
||||
return {
|
||||
"resolution-verifier": AgentDefinition(
|
||||
description=(
|
||||
"Resolution verification specialist. Use to verify whether previous "
|
||||
"findings have been addressed. Analyzes diffs to determine if issues "
|
||||
"are truly fixed, partially fixed, or still unresolved. "
|
||||
"Invoke when: There are previous findings to verify."
|
||||
),
|
||||
prompt=resolution_prompt
|
||||
or "You verify whether previous findings are resolved.",
|
||||
tools=["Read", "Grep", "Glob"],
|
||||
model="inherit",
|
||||
),
|
||||
"new-code-reviewer": AgentDefinition(
|
||||
description=(
|
||||
"New code analysis specialist. Reviews code added since last review "
|
||||
"for security, logic, quality issues, and regressions. "
|
||||
"Invoke when: There are substantial code changes (>50 lines diff) or "
|
||||
"changes to security-sensitive areas."
|
||||
),
|
||||
prompt=newcode_prompt or "You review new code for issues.",
|
||||
tools=["Read", "Grep", "Glob"],
|
||||
model="inherit",
|
||||
),
|
||||
"comment-analyzer": AgentDefinition(
|
||||
description=(
|
||||
"Comment and feedback analyst. Processes contributor comments and "
|
||||
"AI tool reviews (CodeRabbit, Cursor, Gemini, etc.) to identify "
|
||||
"unanswered questions and valid concerns. "
|
||||
"Invoke when: There are comments or formal reviews since last review."
|
||||
),
|
||||
prompt=comment_prompt or "You analyze comments and feedback.",
|
||||
tools=["Read", "Grep", "Glob"],
|
||||
model="inherit",
|
||||
),
|
||||
"finding-validator": AgentDefinition(
|
||||
description=(
|
||||
"Finding re-investigation specialist. Re-investigates unresolved findings "
|
||||
"to validate they are actually real issues, not false positives. "
|
||||
"Actively reads the code at the finding location with fresh eyes. "
|
||||
"Can confirm findings as valid OR dismiss them as false positives. "
|
||||
"CRITICAL: Invoke for ALL unresolved findings after resolution-verifier runs. "
|
||||
"Invoke when: There are findings marked as unresolved that need validation."
|
||||
),
|
||||
prompt=validator_prompt
|
||||
or "You validate whether unresolved findings are real issues.",
|
||||
tools=["Read", "Grep", "Glob"],
|
||||
model="inherit",
|
||||
),
|
||||
}
|
||||
|
||||
def _format_previous_findings(self, context: FollowupReviewContext) -> str:
|
||||
"""Format previous findings for the prompt."""
|
||||
previous_findings = context.previous_review.findings
|
||||
if not previous_findings:
|
||||
return "No previous findings to verify."
|
||||
|
||||
lines = []
|
||||
for f in previous_findings:
|
||||
lines.append(
|
||||
f"- **{f.id}** [{f.severity.value}] {f.title}\n"
|
||||
f" File: {f.file}:{f.line}\n"
|
||||
f" {f.description[:200]}..."
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_commits(self, context: FollowupReviewContext) -> str:
|
||||
"""Format new commits for the prompt."""
|
||||
if not context.commits_since_review:
|
||||
return "No new commits."
|
||||
|
||||
lines = []
|
||||
for commit in context.commits_since_review[:20]: # Limit to 20 commits
|
||||
sha = commit.get("sha", "")[:7]
|
||||
message = commit.get("commit", {}).get("message", "").split("\n")[0]
|
||||
author = commit.get("commit", {}).get("author", {}).get("name", "unknown")
|
||||
lines.append(f"- `{sha}` by {author}: {message}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_comments(self, context: FollowupReviewContext) -> str:
|
||||
"""Format contributor comments for the prompt."""
|
||||
if not context.contributor_comments_since_review:
|
||||
return "No contributor comments since last review."
|
||||
|
||||
lines = []
|
||||
for comment in context.contributor_comments_since_review[:15]:
|
||||
author = comment.get("user", {}).get("login", "unknown")
|
||||
body = comment.get("body", "")[:300]
|
||||
lines.append(f"**@{author}**: {body}")
|
||||
return "\n\n".join(lines)
|
||||
|
||||
def _format_ai_reviews(self, context: FollowupReviewContext) -> str:
|
||||
"""Format AI bot reviews and comments for the prompt."""
|
||||
ai_content = []
|
||||
|
||||
# AI bot comments
|
||||
for comment in context.ai_bot_comments_since_review[:10]:
|
||||
author = comment.get("user", {}).get("login", "unknown")
|
||||
body = comment.get("body", "")[:500]
|
||||
ai_content.append(f"**{author}** (comment):\n{body}")
|
||||
|
||||
# Formal PR reviews from AI tools
|
||||
for review in context.pr_reviews_since_review[:5]:
|
||||
author = review.get("user", {}).get("login", "unknown")
|
||||
body = review.get("body", "")[:1000]
|
||||
state = review.get("state", "unknown")
|
||||
ai_content.append(f"**{author}** ({state}):\n{body}")
|
||||
|
||||
if not ai_content:
|
||||
return "No AI tool feedback since last review."
|
||||
|
||||
return "\n\n---\n\n".join(ai_content)
|
||||
|
||||
def _build_orchestrator_prompt(self, context: FollowupReviewContext) -> str:
|
||||
"""Build full prompt for orchestrator with follow-up context."""
|
||||
# Load orchestrator prompt
|
||||
base_prompt = self._load_prompt("pr_followup_orchestrator.md")
|
||||
if not base_prompt:
|
||||
base_prompt = "You are a follow-up PR reviewer. Verify resolutions and find new issues."
|
||||
|
||||
# Build context sections
|
||||
previous_findings = self._format_previous_findings(context)
|
||||
commits = self._format_commits(context)
|
||||
contributor_comments = self._format_comments(context)
|
||||
ai_reviews = self._format_ai_reviews(context)
|
||||
|
||||
# Truncate diff if too long
|
||||
MAX_DIFF_CHARS = 100_000
|
||||
diff_content = context.diff_since_review
|
||||
if len(diff_content) > MAX_DIFF_CHARS:
|
||||
diff_content = diff_content[:MAX_DIFF_CHARS] + "\n\n... (diff truncated)"
|
||||
|
||||
followup_context = f"""
|
||||
---
|
||||
|
||||
## Follow-up Review Context
|
||||
|
||||
**PR Number:** {context.pr_number}
|
||||
**Previous Review Commit:** {context.previous_commit_sha[:8]}
|
||||
**Current HEAD:** {context.current_commit_sha[:8]}
|
||||
**New Commits:** {len(context.commits_since_review)}
|
||||
**Files Changed:** {len(context.files_changed_since_review)}
|
||||
|
||||
### Previous Review Summary
|
||||
{context.previous_review.summary[:500] if context.previous_review.summary else "No summary available."}
|
||||
|
||||
### Previous Findings to Verify
|
||||
{previous_findings}
|
||||
|
||||
### New Commits Since Last Review
|
||||
{commits}
|
||||
|
||||
### Files Changed Since Last Review
|
||||
{chr(10).join(f"- {f}" for f in context.files_changed_since_review[:30])}
|
||||
|
||||
### Contributor Comments Since Last Review
|
||||
{contributor_comments}
|
||||
|
||||
### AI Tool Feedback Since Last Review
|
||||
{ai_reviews}
|
||||
|
||||
### Diff Since Last Review
|
||||
```diff
|
||||
{diff_content}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
return base_prompt + followup_context
|
||||
|
||||
async def review(self, context: FollowupReviewContext) -> PRReviewResult:
|
||||
"""
|
||||
Main follow-up review entry point.
|
||||
|
||||
Args:
|
||||
context: Follow-up context with incremental changes
|
||||
|
||||
Returns:
|
||||
PRReviewResult with findings and verdict
|
||||
"""
|
||||
logger.info(
|
||||
f"[ParallelFollowup] Starting follow-up review for PR #{context.pr_number}"
|
||||
)
|
||||
|
||||
try:
|
||||
self._report_progress(
|
||||
"orchestrating",
|
||||
35,
|
||||
"Parallel orchestrator analyzing follow-up...",
|
||||
pr_number=context.pr_number,
|
||||
)
|
||||
|
||||
# Build orchestrator prompt
|
||||
prompt = self._build_orchestrator_prompt(context)
|
||||
|
||||
# Get project root
|
||||
project_root = (
|
||||
self.project_dir.parent.parent
|
||||
if self.project_dir.name == "backend"
|
||||
else self.project_dir
|
||||
)
|
||||
|
||||
# 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"
|
||||
thinking_budget = get_thinking_budget(thinking_level)
|
||||
|
||||
logger.info(
|
||||
f"[ParallelFollowup] Using model={model}, "
|
||||
f"thinking_level={thinking_level}, thinking_budget={thinking_budget}"
|
||||
)
|
||||
|
||||
# Create client with subagents defined
|
||||
client = create_client(
|
||||
project_dir=project_root,
|
||||
spec_dir=self.github_dir,
|
||||
model=model,
|
||||
agent_type="pr_followup_parallel",
|
||||
max_thinking_tokens=thinking_budget,
|
||||
agents=self._define_specialist_agents(),
|
||||
output_format={
|
||||
"type": "json_schema",
|
||||
"schema": ParallelFollowupResponse.model_json_schema(),
|
||||
},
|
||||
)
|
||||
|
||||
self._report_progress(
|
||||
"orchestrating",
|
||||
40,
|
||||
"Orchestrator delegating to specialist agents...",
|
||||
pr_number=context.pr_number,
|
||||
)
|
||||
|
||||
# Run orchestrator session using shared SDK stream processor
|
||||
async with client:
|
||||
await client.query(prompt)
|
||||
|
||||
print(
|
||||
f"[ParallelFollowup] Running orchestrator ({model})...",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Process SDK stream with shared utility
|
||||
stream_result = await process_sdk_stream(
|
||||
client=client,
|
||||
context_name="ParallelFollowup",
|
||||
)
|
||||
|
||||
# Check for stream processing errors
|
||||
if stream_result.get("error"):
|
||||
logger.error(
|
||||
f"[ParallelFollowup] SDK stream failed: {stream_result['error']}"
|
||||
)
|
||||
raise RuntimeError(
|
||||
f"SDK stream processing failed: {stream_result['error']}"
|
||||
)
|
||||
|
||||
result_text = stream_result["result_text"]
|
||||
structured_output = stream_result["structured_output"]
|
||||
agents_invoked = stream_result["agents_invoked"]
|
||||
msg_count = stream_result["msg_count"]
|
||||
|
||||
self._report_progress(
|
||||
"finalizing",
|
||||
50,
|
||||
"Synthesizing follow-up findings...",
|
||||
pr_number=context.pr_number,
|
||||
)
|
||||
|
||||
# Parse findings from output
|
||||
if structured_output:
|
||||
result_data = self._parse_structured_output(structured_output, context)
|
||||
else:
|
||||
result_data = self._parse_text_output(result_text, context)
|
||||
|
||||
# Extract data
|
||||
findings = result_data.get("findings", [])
|
||||
resolved_ids = result_data.get("resolved_ids", [])
|
||||
unresolved_ids = result_data.get("unresolved_ids", [])
|
||||
new_finding_ids = result_data.get("new_finding_ids", [])
|
||||
verdict = result_data.get("verdict", MergeVerdict.NEEDS_REVISION)
|
||||
verdict_reasoning = result_data.get("verdict_reasoning", "")
|
||||
|
||||
# Use agents from structured output (more reliable than streaming detection)
|
||||
agents_from_result = result_data.get("agents_invoked", [])
|
||||
final_agents = agents_from_result if agents_from_result else agents_invoked
|
||||
logger.info(
|
||||
f"[ParallelFollowup] Session complete. Agents invoked: {final_agents}"
|
||||
)
|
||||
print(
|
||||
f"[ParallelFollowup] Complete. Agents invoked: {final_agents}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Deduplicate findings
|
||||
unique_findings = self._deduplicate_findings(findings)
|
||||
|
||||
logger.info(
|
||||
f"[ParallelFollowup] Review complete: {len(unique_findings)} findings, "
|
||||
f"{len(resolved_ids)} resolved, {len(unresolved_ids)} unresolved"
|
||||
)
|
||||
|
||||
# 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
|
||||
summary = self._generate_summary(
|
||||
verdict=verdict,
|
||||
verdict_reasoning=verdict_reasoning,
|
||||
resolved_count=len(resolved_ids),
|
||||
unresolved_count=len(unresolved_ids),
|
||||
new_count=len(new_finding_ids),
|
||||
agents_invoked=final_agents,
|
||||
dismissed_false_positive_count=dismissed_count,
|
||||
confirmed_valid_count=confirmed_count,
|
||||
needs_human_review_count=needs_human_count,
|
||||
)
|
||||
|
||||
# Map verdict to overall_status
|
||||
if verdict == MergeVerdict.BLOCKED:
|
||||
overall_status = "request_changes"
|
||||
elif verdict == MergeVerdict.NEEDS_REVISION:
|
||||
overall_status = "request_changes"
|
||||
elif verdict == MergeVerdict.MERGE_WITH_CHANGES:
|
||||
overall_status = "comment"
|
||||
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}")
|
||||
|
||||
result = PRReviewResult(
|
||||
pr_number=context.pr_number,
|
||||
repo=self.config.repo,
|
||||
success=True,
|
||||
findings=unique_findings,
|
||||
summary=summary,
|
||||
overall_status=overall_status,
|
||||
verdict=verdict,
|
||||
verdict_reasoning=verdict_reasoning,
|
||||
blockers=blockers,
|
||||
reviewed_commit_sha=context.current_commit_sha,
|
||||
is_followup_review=True,
|
||||
previous_review_id=context.previous_review.review_id
|
||||
or context.previous_review.pr_number,
|
||||
resolved_findings=resolved_ids,
|
||||
unresolved_findings=unresolved_ids,
|
||||
new_findings_since_last_review=new_finding_ids,
|
||||
)
|
||||
|
||||
self._report_progress(
|
||||
"analyzed",
|
||||
60,
|
||||
"Follow-up analysis complete",
|
||||
pr_number=context.pr_number,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[ParallelFollowup] Review failed: {e}", exc_info=True)
|
||||
print(f"[ParallelFollowup] Error: {e}", flush=True)
|
||||
|
||||
return PRReviewResult(
|
||||
pr_number=context.pr_number,
|
||||
repo=self.config.repo,
|
||||
success=False,
|
||||
findings=[],
|
||||
summary=f"Follow-up review failed: {e}",
|
||||
overall_status="comment",
|
||||
verdict=MergeVerdict.NEEDS_REVISION,
|
||||
verdict_reasoning=f"Review failed: {e}",
|
||||
blockers=[str(e)],
|
||||
is_followup_review=True,
|
||||
reviewed_commit_sha=context.current_commit_sha,
|
||||
)
|
||||
|
||||
def _parse_structured_output(
|
||||
self, data: dict, context: FollowupReviewContext
|
||||
) -> dict:
|
||||
"""Parse structured output from ParallelFollowupResponse."""
|
||||
try:
|
||||
# Validate with Pydantic
|
||||
response = ParallelFollowupResponse.model_validate(data)
|
||||
|
||||
# Log agents from structured output
|
||||
agents_from_output = response.agents_invoked or []
|
||||
if agents_from_output:
|
||||
print(
|
||||
f"[ParallelFollowup] Specialist agents invoked: {', '.join(agents_from_output)}",
|
||||
flush=True,
|
||||
)
|
||||
for agent in agents_from_output:
|
||||
print(f"[Agent:{agent}] Analysis complete", flush=True)
|
||||
|
||||
findings = []
|
||||
resolved_ids = []
|
||||
unresolved_ids = []
|
||||
new_finding_ids = []
|
||||
|
||||
# Process resolution verifications
|
||||
# First, build a map of finding validations (from finding-validator agent)
|
||||
validation_map = {}
|
||||
dismissed_ids = []
|
||||
for fv in response.finding_validations:
|
||||
validation_map[fv.finding_id] = fv
|
||||
if fv.validation_status == "dismissed_false_positive":
|
||||
dismissed_ids.append(fv.finding_id)
|
||||
print(
|
||||
f"[ParallelFollowup] Finding {fv.finding_id} DISMISSED as false positive: {fv.explanation[:100]}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
for rv in response.resolution_verifications:
|
||||
if rv.status == "resolved":
|
||||
resolved_ids.append(rv.finding_id)
|
||||
elif rv.status in ("unresolved", "partially_resolved", "cant_verify"):
|
||||
# Check if finding was validated and dismissed as false positive
|
||||
if rv.finding_id in dismissed_ids:
|
||||
# Finding-validator determined this was a false positive - skip it
|
||||
print(
|
||||
f"[ParallelFollowup] Skipping {rv.finding_id} - dismissed as false positive by finding-validator",
|
||||
flush=True,
|
||||
)
|
||||
resolved_ids.append(
|
||||
rv.finding_id
|
||||
) # Count as resolved (false positive)
|
||||
continue
|
||||
|
||||
# Include "cant_verify" as unresolved - if we can't verify, assume not fixed
|
||||
unresolved_ids.append(rv.finding_id)
|
||||
# Add unresolved as a finding
|
||||
if rv.status in ("unresolved", "cant_verify"):
|
||||
# Find original finding
|
||||
original = next(
|
||||
(
|
||||
f
|
||||
for f in context.previous_review.findings
|
||||
if f.id == rv.finding_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if original:
|
||||
# Check if we have validation evidence
|
||||
validation = validation_map.get(rv.finding_id)
|
||||
validation_status = None
|
||||
validation_evidence = None
|
||||
validation_confidence = None
|
||||
validation_explanation = None
|
||||
|
||||
if validation:
|
||||
validation_status = validation.validation_status
|
||||
validation_evidence = validation.code_evidence
|
||||
validation_confidence = validation.confidence
|
||||
validation_explanation = validation.explanation
|
||||
|
||||
findings.append(
|
||||
PRReviewFinding(
|
||||
id=rv.finding_id,
|
||||
severity=original.severity,
|
||||
category=original.category,
|
||||
title=f"[UNRESOLVED] {original.title}",
|
||||
description=f"{original.description}\n\nResolution note: {rv.evidence}",
|
||||
file=original.file,
|
||||
line=original.line,
|
||||
suggested_fix=original.suggested_fix,
|
||||
fixable=original.fixable,
|
||||
validation_status=validation_status,
|
||||
validation_evidence=validation_evidence,
|
||||
validation_confidence=validation_confidence,
|
||||
validation_explanation=validation_explanation,
|
||||
)
|
||||
)
|
||||
|
||||
# Process new findings
|
||||
for nf in response.new_findings:
|
||||
finding_id = nf.id or self._generate_finding_id(
|
||||
nf.file, nf.line, nf.title
|
||||
)
|
||||
new_finding_ids.append(finding_id)
|
||||
findings.append(
|
||||
PRReviewFinding(
|
||||
id=finding_id,
|
||||
severity=_map_severity(nf.severity),
|
||||
category=map_category(nf.category),
|
||||
title=nf.title,
|
||||
description=nf.description,
|
||||
file=nf.file,
|
||||
line=nf.line,
|
||||
suggested_fix=nf.suggested_fix,
|
||||
fixable=nf.fixable,
|
||||
)
|
||||
)
|
||||
|
||||
# Process comment findings
|
||||
for cf in response.comment_findings:
|
||||
finding_id = cf.id or self._generate_finding_id(
|
||||
cf.file, cf.line, cf.title
|
||||
)
|
||||
new_finding_ids.append(finding_id)
|
||||
findings.append(
|
||||
PRReviewFinding(
|
||||
id=finding_id,
|
||||
severity=_map_severity(cf.severity),
|
||||
category=map_category(cf.category),
|
||||
title=f"[FROM COMMENTS] {cf.title}",
|
||||
description=cf.description,
|
||||
file=cf.file,
|
||||
line=cf.line,
|
||||
suggested_fix=cf.suggested_fix,
|
||||
fixable=cf.fixable,
|
||||
)
|
||||
)
|
||||
|
||||
# Map verdict
|
||||
verdict_map = {
|
||||
"READY_TO_MERGE": MergeVerdict.READY_TO_MERGE,
|
||||
"MERGE_WITH_CHANGES": MergeVerdict.MERGE_WITH_CHANGES,
|
||||
"NEEDS_REVISION": MergeVerdict.NEEDS_REVISION,
|
||||
"BLOCKED": MergeVerdict.BLOCKED,
|
||||
}
|
||||
verdict = verdict_map.get(response.verdict, MergeVerdict.NEEDS_REVISION)
|
||||
|
||||
# Count validation results
|
||||
confirmed_valid_count = sum(
|
||||
1
|
||||
for fv in response.finding_validations
|
||||
if fv.validation_status == "confirmed_valid"
|
||||
)
|
||||
needs_human_count = sum(
|
||||
1
|
||||
for fv in response.finding_validations
|
||||
if fv.validation_status == "needs_human_review"
|
||||
)
|
||||
|
||||
# Log findings summary for verification
|
||||
print(
|
||||
f"[ParallelFollowup] Parsed {len(findings)} findings, "
|
||||
f"{len(resolved_ids)} resolved, {len(unresolved_ids)} unresolved, "
|
||||
f"{len(new_finding_ids)} new",
|
||||
flush=True,
|
||||
)
|
||||
if dismissed_ids:
|
||||
print(
|
||||
f"[ParallelFollowup] Validation: {len(dismissed_ids)} findings dismissed as false positives, "
|
||||
f"{confirmed_valid_count} confirmed valid, {needs_human_count} need human review",
|
||||
flush=True,
|
||||
)
|
||||
if findings:
|
||||
print("[ParallelFollowup] Findings summary:", flush=True)
|
||||
for i, f in enumerate(findings, 1):
|
||||
validation_note = ""
|
||||
if f.validation_status == "confirmed_valid":
|
||||
validation_note = " [VALIDATED]"
|
||||
elif f.validation_status == "needs_human_review":
|
||||
validation_note = " [NEEDS HUMAN REVIEW]"
|
||||
print(
|
||||
f" [{f.severity.value.upper()}] {i}. {f.title} ({f.file}:{f.line}){validation_note}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
return {
|
||||
"findings": findings,
|
||||
"resolved_ids": resolved_ids,
|
||||
"unresolved_ids": unresolved_ids,
|
||||
"new_finding_ids": new_finding_ids,
|
||||
"dismissed_false_positive_ids": dismissed_ids,
|
||||
"confirmed_valid_count": confirmed_valid_count,
|
||||
"needs_human_review_count": needs_human_count,
|
||||
"verdict": verdict,
|
||||
"verdict_reasoning": response.verdict_reasoning,
|
||||
"agents_invoked": agents_from_output,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[ParallelFollowup] Failed to parse structured output: {e}")
|
||||
return self._create_empty_result()
|
||||
|
||||
def _parse_text_output(self, text: str, context: FollowupReviewContext) -> dict:
|
||||
"""Parse text output when structured output fails."""
|
||||
logger.warning("[ParallelFollowup] Falling back to text parsing")
|
||||
|
||||
# Simple heuristic parsing
|
||||
findings = []
|
||||
|
||||
# Look for verdict keywords
|
||||
text_lower = text.lower()
|
||||
if "ready to merge" in text_lower or "approve" in text_lower:
|
||||
verdict = MergeVerdict.READY_TO_MERGE
|
||||
elif "blocked" in text_lower or "critical" in text_lower:
|
||||
verdict = MergeVerdict.BLOCKED
|
||||
elif "needs revision" in text_lower or "request changes" in text_lower:
|
||||
verdict = MergeVerdict.NEEDS_REVISION
|
||||
else:
|
||||
verdict = MergeVerdict.MERGE_WITH_CHANGES
|
||||
|
||||
return {
|
||||
"findings": findings,
|
||||
"resolved_ids": [],
|
||||
"unresolved_ids": [],
|
||||
"new_finding_ids": [],
|
||||
"verdict": verdict,
|
||||
"verdict_reasoning": text[:500] if text else "Unable to parse response",
|
||||
}
|
||||
|
||||
def _create_empty_result(self) -> dict:
|
||||
"""Create empty result structure."""
|
||||
return {
|
||||
"findings": [],
|
||||
"resolved_ids": [],
|
||||
"unresolved_ids": [],
|
||||
"new_finding_ids": [],
|
||||
"verdict": MergeVerdict.NEEDS_REVISION,
|
||||
"verdict_reasoning": "Unable to parse review results",
|
||||
}
|
||||
|
||||
def _generate_finding_id(self, file: str, line: int, title: str) -> str:
|
||||
"""Generate a unique finding ID."""
|
||||
content = f"{file}:{line}:{title}"
|
||||
return f"FU-{hashlib.md5(content.encode(), usedforsecurity=False).hexdigest()[:8].upper()}"
|
||||
|
||||
def _deduplicate_findings(
|
||||
self, findings: list[PRReviewFinding]
|
||||
) -> list[PRReviewFinding]:
|
||||
"""Remove duplicate findings."""
|
||||
seen = set()
|
||||
unique = []
|
||||
for f in findings:
|
||||
key = (f.file, f.line, f.title.lower().strip())
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique.append(f)
|
||||
return unique
|
||||
|
||||
def _generate_summary(
|
||||
self,
|
||||
verdict: MergeVerdict,
|
||||
verdict_reasoning: str,
|
||||
resolved_count: int,
|
||||
unresolved_count: int,
|
||||
new_count: int,
|
||||
agents_invoked: list[str],
|
||||
dismissed_false_positive_count: int = 0,
|
||||
confirmed_valid_count: int = 0,
|
||||
needs_human_review_count: int = 0,
|
||||
) -> str:
|
||||
"""Generate a human-readable summary of the follow-up review."""
|
||||
status_emoji = {
|
||||
MergeVerdict.READY_TO_MERGE: "✅",
|
||||
MergeVerdict.MERGE_WITH_CHANGES: "⚠️",
|
||||
MergeVerdict.NEEDS_REVISION: "🔄",
|
||||
MergeVerdict.BLOCKED: "🚫",
|
||||
}
|
||||
|
||||
emoji = status_emoji.get(verdict, "📝")
|
||||
agents_str = (
|
||||
", ".join(agents_invoked) if agents_invoked else "orchestrator only"
|
||||
)
|
||||
|
||||
# Build validation section if there are validation results
|
||||
validation_section = ""
|
||||
if (
|
||||
dismissed_false_positive_count > 0
|
||||
or confirmed_valid_count > 0
|
||||
or needs_human_review_count > 0
|
||||
):
|
||||
validation_section = f"""
|
||||
### Finding Validation
|
||||
- 🔍 **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
|
||||
"""
|
||||
|
||||
summary = f"""## {emoji} Follow-up Review: {verdict.value.replace("_", " ").title()}
|
||||
|
||||
### 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}
|
||||
### Verdict
|
||||
{verdict_reasoning}
|
||||
|
||||
### Review Process
|
||||
Agents invoked: {agents_str}
|
||||
|
||||
---
|
||||
*This is an AI-generated follow-up review using parallel specialist analysis with finding validation.*
|
||||
"""
|
||||
return summary
|
||||
File diff suppressed because it is too large
Load Diff
@@ -277,22 +277,19 @@ class PRReviewEngine:
|
||||
Returns:
|
||||
Tuple of (findings, structural_issues, ai_triages, quick_scan_summary)
|
||||
"""
|
||||
# Use parallel orchestrator with SDK subagents if enabled
|
||||
if self.config.use_parallel_orchestrator:
|
||||
print(
|
||||
"[AI] Using parallel orchestrator PR review (SDK subagents)...",
|
||||
flush=True,
|
||||
)
|
||||
# Use orchestrating agent if enabled
|
||||
if self.config.use_orchestrator_review:
|
||||
print("[AI] Using orchestrating PR review agent (Opus 4.5)...", flush=True)
|
||||
self._report_progress(
|
||||
"orchestrating",
|
||||
10,
|
||||
"Starting parallel orchestrator review...",
|
||||
"Starting orchestrating review...",
|
||||
pr_number=context.pr_number,
|
||||
)
|
||||
|
||||
from .parallel_orchestrator_reviewer import ParallelOrchestratorReviewer
|
||||
from .orchestrator_reviewer import OrchestratorReviewer
|
||||
|
||||
orchestrator = ParallelOrchestratorReviewer(
|
||||
orchestrator = OrchestratorReviewer(
|
||||
project_dir=self.project_dir,
|
||||
github_dir=self.github_dir,
|
||||
config=self.config,
|
||||
@@ -302,16 +299,22 @@ class PRReviewEngine:
|
||||
result = await orchestrator.review(context)
|
||||
|
||||
print(
|
||||
f"[PR Review Engine] Parallel orchestrator returned {len(result.findings)} findings",
|
||||
f"[PR Review Engine] Orchestrator returned {len(result.findings)} findings",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Convert PRReviewResult to expected format
|
||||
# Orchestrator doesn't use structural_issues or ai_triages
|
||||
quick_scan_summary = {
|
||||
"verdict": result.verdict.value if result.verdict else "unknown",
|
||||
"findings_count": len(result.findings),
|
||||
"strategy": "parallel_orchestrator",
|
||||
"strategy": "orchestrating_agent",
|
||||
}
|
||||
|
||||
print(
|
||||
f"[PR Review Engine] Returning tuple with {len(result.findings)} findings",
|
||||
flush=True,
|
||||
)
|
||||
return (result.findings, [], [], quick_scan_summary)
|
||||
|
||||
# Fall back to multi-pass review
|
||||
@@ -473,7 +476,7 @@ class PRReviewEngine:
|
||||
/ "pr_structural.md"
|
||||
)
|
||||
if prompt_file.exists():
|
||||
prompt = prompt_file.read_text(encoding="utf-8")
|
||||
prompt = prompt_file.read_text()
|
||||
else:
|
||||
prompt = self.prompt_manager.get_review_pass_prompt(ReviewPass.STRUCTURAL)
|
||||
|
||||
@@ -524,7 +527,7 @@ class PRReviewEngine:
|
||||
/ "pr_ai_triage.md"
|
||||
)
|
||||
if prompt_file.exists():
|
||||
prompt = prompt_file.read_text(encoding="utf-8")
|
||||
prompt = prompt_file.read_text()
|
||||
else:
|
||||
prompt = self.prompt_manager.get_review_pass_prompt(
|
||||
ReviewPass.AI_COMMENT_TRIAGE
|
||||
|
||||
@@ -268,7 +268,7 @@ Output JSON array of structural issues:
|
||||
```
|
||||
""",
|
||||
ReviewPass.AI_COMMENT_TRIAGE: """
|
||||
You are triaging comments from other AI code review tools (CodeRabbit, Gemini Code Assist, Cursor, Greptile, etc).
|
||||
You are triaging comments from other AI code review tools (CodeRabbit, Cursor, Greptile, etc).
|
||||
|
||||
For each AI comment, determine:
|
||||
- CRITICAL: Genuine issue that must be addressed before merge
|
||||
@@ -298,7 +298,7 @@ Output JSON array:
|
||||
"""Get the main PR review prompt."""
|
||||
prompt_file = self.prompts_dir / "pr_reviewer.md"
|
||||
if prompt_file.exists():
|
||||
return prompt_file.read_text(encoding="utf-8")
|
||||
return prompt_file.read_text()
|
||||
return self._get_default_pr_review_prompt()
|
||||
|
||||
def _get_default_pr_review_prompt(self) -> str:
|
||||
@@ -338,7 +338,7 @@ Be specific and actionable. Focus on significant issues, not nitpicks.
|
||||
"""Get the follow-up PR review prompt."""
|
||||
prompt_file = self.prompts_dir / "pr_followup.md"
|
||||
if prompt_file.exists():
|
||||
return prompt_file.read_text(encoding="utf-8")
|
||||
return prompt_file.read_text()
|
||||
return self._get_default_followup_review_prompt()
|
||||
|
||||
def _get_default_followup_review_prompt(self) -> str:
|
||||
@@ -380,7 +380,7 @@ Output JSON:
|
||||
"""Get the issue triage prompt."""
|
||||
prompt_file = self.prompts_dir / "issue_triager.md"
|
||||
if prompt_file.exists():
|
||||
return prompt_file.read_text(encoding="utf-8")
|
||||
return prompt_file.read_text()
|
||||
return self._get_default_triage_prompt()
|
||||
|
||||
def _get_default_triage_prompt(self) -> str:
|
||||
|
||||
@@ -1,703 +0,0 @@
|
||||
"""
|
||||
Pydantic Models for Structured AI Outputs
|
||||
==========================================
|
||||
|
||||
These models define JSON schemas for Claude Agent SDK structured outputs.
|
||||
Used to guarantee valid, validated JSON from AI responses in PR reviews.
|
||||
|
||||
Usage:
|
||||
from claude_agent_sdk import query
|
||||
from .pydantic_models import FollowupReviewResponse
|
||||
|
||||
async for message in query(
|
||||
prompt="...",
|
||||
options={
|
||||
"output_format": {
|
||||
"type": "json_schema",
|
||||
"schema": FollowupReviewResponse.model_json_schema()
|
||||
}
|
||||
}
|
||||
):
|
||||
if hasattr(message, 'structured_output'):
|
||||
result = FollowupReviewResponse.model_validate(message.structured_output)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
# =============================================================================
|
||||
# Common Finding Types
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class BaseFinding(BaseModel):
|
||||
"""Base class for all finding types."""
|
||||
|
||||
id: str = Field(description="Unique identifier for this finding")
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
title: str = Field(description="Brief issue title (max 80 chars)")
|
||||
description: str = Field(description="Detailed explanation of the issue")
|
||||
file: str = Field(description="File path where issue was found")
|
||||
line: int = Field(0, description="Line number of the issue")
|
||||
suggested_fix: str | None = Field(None, description="How to fix this issue")
|
||||
fixable: bool = Field(False, description="Whether this can be auto-fixed")
|
||||
|
||||
|
||||
class SecurityFinding(BaseFinding):
|
||||
"""A security vulnerability finding."""
|
||||
|
||||
category: Literal["security"] = Field(
|
||||
default="security", description="Always 'security' for security findings"
|
||||
)
|
||||
|
||||
|
||||
class QualityFinding(BaseFinding):
|
||||
"""A code quality or redundancy finding."""
|
||||
|
||||
category: Literal[
|
||||
"redundancy", "quality", "test", "performance", "pattern", "docs"
|
||||
] = Field(description="Issue category")
|
||||
redundant_with: str | None = Field(
|
||||
None, description="Reference to duplicate code (file:line) if redundant"
|
||||
)
|
||||
|
||||
|
||||
class DeepAnalysisFinding(BaseFinding):
|
||||
"""A finding from deep analysis with verification info."""
|
||||
|
||||
category: Literal[
|
||||
"verification_failed",
|
||||
"redundancy",
|
||||
"quality",
|
||||
"pattern",
|
||||
"performance",
|
||||
"logic",
|
||||
] = Field(description="Issue category")
|
||||
confidence: float = Field(
|
||||
0.85, ge=0.0, le=1.0, description="AI's confidence in this finding (0.0-1.0)"
|
||||
)
|
||||
verification_note: str | None = Field(
|
||||
None, description="What evidence is missing or couldn't be verified"
|
||||
)
|
||||
|
||||
|
||||
class StructuralIssue(BaseModel):
|
||||
"""A structural issue with the PR."""
|
||||
|
||||
id: str = Field(description="Unique identifier")
|
||||
issue_type: Literal[
|
||||
"feature_creep", "scope_creep", "architecture_violation", "poor_structure"
|
||||
] = Field(description="Type of structural issue")
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity"
|
||||
)
|
||||
title: str = Field(description="Brief issue title")
|
||||
description: str = Field(description="Detailed explanation")
|
||||
impact: str = Field(description="Why this matters")
|
||||
suggestion: str = Field(description="How to fix")
|
||||
|
||||
|
||||
class AICommentTriage(BaseModel):
|
||||
"""Triage result for an AI tool comment."""
|
||||
|
||||
comment_id: int = Field(description="GitHub comment ID")
|
||||
tool_name: str = Field(
|
||||
description="AI tool name (CodeRabbit, Cursor, Greptile, etc.)"
|
||||
)
|
||||
verdict: Literal[
|
||||
"critical", "important", "nice_to_have", "trivial", "false_positive"
|
||||
] = Field(description="Verdict on the comment")
|
||||
reasoning: str = Field(description="Why this verdict was chosen")
|
||||
response_comment: str | None = Field(
|
||||
None, description="Optional comment to post in reply"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Follow-up Review Response
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class FindingResolution(BaseModel):
|
||||
"""Resolution status for a previous finding."""
|
||||
|
||||
finding_id: str = Field(description="ID of the previous finding")
|
||||
status: Literal["resolved", "unresolved"] = Field(description="Resolution status")
|
||||
resolution_notes: str | None = Field(
|
||||
None, description="Notes on how it was resolved"
|
||||
)
|
||||
|
||||
|
||||
class FollowupFinding(BaseModel):
|
||||
"""A new finding from follow-up review (simpler than initial review)."""
|
||||
|
||||
id: str = Field(description="Unique identifier for this finding")
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
category: Literal["security", "quality", "logic", "test", "docs"] = Field(
|
||||
description="Issue category"
|
||||
)
|
||||
title: str = Field(description="Brief issue title")
|
||||
description: str = Field(description="Detailed explanation of the issue")
|
||||
file: str = Field(description="File path where issue was found")
|
||||
line: int = Field(0, description="Line number of the issue")
|
||||
suggested_fix: str | None = Field(None, description="How to fix this issue")
|
||||
fixable: bool = Field(False, description="Whether this can be auto-fixed")
|
||||
|
||||
|
||||
class FollowupReviewResponse(BaseModel):
|
||||
"""Complete response schema for follow-up PR review."""
|
||||
|
||||
finding_resolutions: list[FindingResolution] = Field(
|
||||
default_factory=list, description="Status of each previous finding"
|
||||
)
|
||||
new_findings: list[FollowupFinding] = Field(
|
||||
default_factory=list,
|
||||
description="New issues found in changes since last review",
|
||||
)
|
||||
comment_findings: list[FollowupFinding] = Field(
|
||||
default_factory=list, description="Issues found in contributor comments"
|
||||
)
|
||||
verdict: Literal[
|
||||
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
|
||||
] = Field(description="Overall merge verdict")
|
||||
verdict_reasoning: str = Field(description="Explanation for the verdict")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Initial Review Responses (Multi-Pass)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class QuickScanResult(BaseModel):
|
||||
"""Result from the quick scan pass."""
|
||||
|
||||
purpose: str = Field(description="Brief description of what the PR claims to do")
|
||||
actual_changes: str = Field(
|
||||
description="Brief description of what the code actually does"
|
||||
)
|
||||
purpose_match: bool = Field(
|
||||
description="Whether actual changes match the claimed purpose"
|
||||
)
|
||||
purpose_match_note: str | None = Field(
|
||||
None, description="Explanation if purpose doesn't match actual changes"
|
||||
)
|
||||
risk_areas: list[str] = Field(
|
||||
default_factory=list, description="Areas needing careful review"
|
||||
)
|
||||
red_flags: list[str] = Field(
|
||||
default_factory=list, description="Obvious issues or concerns"
|
||||
)
|
||||
requires_deep_verification: bool = Field(
|
||||
description="Whether deep verification is needed"
|
||||
)
|
||||
complexity: Literal["low", "medium", "high"] = Field(description="PR complexity")
|
||||
|
||||
|
||||
class SecurityPassResult(BaseModel):
|
||||
"""Result from the security pass - array of security findings."""
|
||||
|
||||
findings: list[SecurityFinding] = Field(
|
||||
default_factory=list, description="Security vulnerabilities found"
|
||||
)
|
||||
|
||||
|
||||
class QualityPassResult(BaseModel):
|
||||
"""Result from the quality pass - array of quality findings."""
|
||||
|
||||
findings: list[QualityFinding] = Field(
|
||||
default_factory=list, description="Quality and redundancy issues found"
|
||||
)
|
||||
|
||||
|
||||
class DeepAnalysisResult(BaseModel):
|
||||
"""Result from the deep analysis pass."""
|
||||
|
||||
findings: list[DeepAnalysisFinding] = Field(
|
||||
default_factory=list,
|
||||
description="Deep analysis findings with verification info",
|
||||
)
|
||||
|
||||
|
||||
class StructuralPassResult(BaseModel):
|
||||
"""Result from the structural pass."""
|
||||
|
||||
issues: list[StructuralIssue] = Field(
|
||||
default_factory=list, description="Structural issues found"
|
||||
)
|
||||
verdict: Literal[
|
||||
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
|
||||
] = Field(description="Structural verdict")
|
||||
verdict_reasoning: str = Field(description="Explanation for the verdict")
|
||||
|
||||
|
||||
class AICommentTriageResult(BaseModel):
|
||||
"""Result from AI comment triage pass."""
|
||||
|
||||
triages: list[AICommentTriage] = Field(
|
||||
default_factory=list, description="Triage results for each AI comment"
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Issue Triage Response
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class IssueTriageResponse(BaseModel):
|
||||
"""Response for issue triage."""
|
||||
|
||||
category: Literal[
|
||||
"bug",
|
||||
"feature",
|
||||
"documentation",
|
||||
"question",
|
||||
"duplicate",
|
||||
"spam",
|
||||
"feature_creep",
|
||||
] = Field(description="Issue category")
|
||||
confidence: float = Field(
|
||||
ge=0.0, le=1.0, description="Confidence in the categorization (0.0-1.0)"
|
||||
)
|
||||
priority: Literal["high", "medium", "low"] = Field(description="Issue priority")
|
||||
labels_to_add: list[str] = Field(
|
||||
default_factory=list, description="Labels to add to the issue"
|
||||
)
|
||||
labels_to_remove: list[str] = Field(
|
||||
default_factory=list, description="Labels to remove from the issue"
|
||||
)
|
||||
is_duplicate: bool = Field(False, description="Whether this is a duplicate issue")
|
||||
duplicate_of: int | None = Field(
|
||||
None, description="Issue number this duplicates (if duplicate)"
|
||||
)
|
||||
is_spam: bool = Field(False, description="Whether this is spam")
|
||||
is_feature_creep: bool = Field(
|
||||
False, description="Whether this bundles multiple unrelated features"
|
||||
)
|
||||
suggested_breakdown: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Suggested breakdown if feature creep detected",
|
||||
)
|
||||
comment: str | None = Field(None, description="Optional bot comment to post")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Orchestrator Review Response
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class OrchestratorFinding(BaseModel):
|
||||
"""A finding from the orchestrator review."""
|
||||
|
||||
file: str = Field(description="File path where issue was found")
|
||||
line: int = Field(0, description="Line number of the issue")
|
||||
title: str = Field(description="Brief issue title")
|
||||
description: str = Field(description="Detailed explanation of the issue")
|
||||
category: Literal[
|
||||
"security",
|
||||
"quality",
|
||||
"style",
|
||||
"docs",
|
||||
"redundancy",
|
||||
"verification_failed",
|
||||
"pattern",
|
||||
"performance",
|
||||
"logic",
|
||||
"test",
|
||||
] = Field(description="Issue category")
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
suggestion: str | None = Field(None, description="How to fix this issue")
|
||||
confidence: float = Field(
|
||||
0.85,
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
description="Confidence (0.0-1.0 or 0-100, normalized to 0.0-1.0)",
|
||||
)
|
||||
|
||||
@field_validator("confidence", mode="before")
|
||||
@classmethod
|
||||
def normalize_confidence(cls, v: int | float) -> float:
|
||||
"""Normalize confidence to 0.0-1.0 range (accepts 0-100 or 0.0-1.0)."""
|
||||
if v > 1:
|
||||
return v / 100.0
|
||||
return float(v)
|
||||
|
||||
|
||||
class OrchestratorReviewResponse(BaseModel):
|
||||
"""Complete response schema for orchestrator PR review."""
|
||||
|
||||
verdict: Literal[
|
||||
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
|
||||
] = Field(description="Overall merge verdict")
|
||||
verdict_reasoning: str = Field(description="Explanation for the verdict")
|
||||
findings: list[OrchestratorFinding] = Field(
|
||||
default_factory=list, description="Issues found during review"
|
||||
)
|
||||
summary: str = Field(description="Brief summary of the review")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Parallel Orchestrator Review Response (SDK Subagents)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class LogicFinding(BaseFinding):
|
||||
"""A logic/correctness finding from the logic review agent."""
|
||||
|
||||
category: Literal["logic"] = Field(
|
||||
default="logic", description="Always 'logic' for logic findings"
|
||||
)
|
||||
confidence: float = Field(
|
||||
0.85, ge=0.0, le=1.0, description="Confidence in this finding (0.0-1.0)"
|
||||
)
|
||||
example_input: str | None = Field(
|
||||
None, description="Concrete input that triggers the bug"
|
||||
)
|
||||
actual_output: str | None = Field(None, description="What the buggy code produces")
|
||||
expected_output: str | None = Field(
|
||||
None, description="What the code should produce"
|
||||
)
|
||||
|
||||
@field_validator("confidence", mode="before")
|
||||
@classmethod
|
||||
def normalize_confidence(cls, v: int | float) -> float:
|
||||
"""Normalize confidence to 0.0-1.0 range."""
|
||||
if v > 1:
|
||||
return v / 100.0
|
||||
return float(v)
|
||||
|
||||
|
||||
class CodebaseFitFinding(BaseFinding):
|
||||
"""A codebase fit finding from the codebase fit review agent."""
|
||||
|
||||
category: Literal["codebase_fit"] = Field(
|
||||
default="codebase_fit", description="Always 'codebase_fit' for fit findings"
|
||||
)
|
||||
confidence: float = Field(
|
||||
0.85, ge=0.0, le=1.0, description="Confidence in this finding (0.0-1.0)"
|
||||
)
|
||||
existing_code: str | None = Field(
|
||||
None, description="Reference to existing code that should be used instead"
|
||||
)
|
||||
codebase_pattern: str | None = Field(
|
||||
None, description="Description of the established pattern being violated"
|
||||
)
|
||||
|
||||
@field_validator("confidence", mode="before")
|
||||
@classmethod
|
||||
def normalize_confidence(cls, v: int | float) -> float:
|
||||
"""Normalize confidence to 0.0-1.0 range."""
|
||||
if v > 1:
|
||||
return v / 100.0
|
||||
return float(v)
|
||||
|
||||
|
||||
class ParallelOrchestratorFinding(BaseModel):
|
||||
"""A finding from the parallel orchestrator with source agent tracking."""
|
||||
|
||||
id: str = Field(description="Unique identifier for this finding")
|
||||
file: str = Field(description="File path where issue was found")
|
||||
line: int = Field(0, description="Line number of the issue")
|
||||
end_line: int | None = Field(None, description="End line for multi-line issues")
|
||||
title: str = Field(description="Brief issue title (max 80 chars)")
|
||||
description: str = Field(description="Detailed explanation of the issue")
|
||||
category: Literal[
|
||||
"security",
|
||||
"quality",
|
||||
"logic",
|
||||
"codebase_fit",
|
||||
"test",
|
||||
"docs",
|
||||
"redundancy",
|
||||
"pattern",
|
||||
"performance",
|
||||
] = Field(description="Issue category")
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
confidence: float = Field(
|
||||
0.85, ge=0.0, le=1.0, description="Confidence in this finding (0.0-1.0)"
|
||||
)
|
||||
suggested_fix: str | None = Field(None, description="How to fix this issue")
|
||||
fixable: bool = Field(False, description="Whether this can be auto-fixed")
|
||||
source_agents: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Which agents reported this finding",
|
||||
)
|
||||
cross_validated: bool = Field(
|
||||
False, description="Whether multiple agents agreed on this finding"
|
||||
)
|
||||
|
||||
@field_validator("confidence", mode="before")
|
||||
@classmethod
|
||||
def normalize_confidence(cls, v: int | float) -> float:
|
||||
"""Normalize confidence to 0.0-1.0 range."""
|
||||
if v > 1:
|
||||
return v / 100.0
|
||||
return float(v)
|
||||
|
||||
|
||||
class AgentAgreement(BaseModel):
|
||||
"""Tracks agreement between agents on findings."""
|
||||
|
||||
agreed_findings: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Finding IDs that multiple agents agreed on",
|
||||
)
|
||||
conflicting_findings: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Finding IDs where agents disagreed",
|
||||
)
|
||||
resolution_notes: str | None = Field(
|
||||
None, description="Notes on how conflicts were resolved"
|
||||
)
|
||||
|
||||
|
||||
class ParallelOrchestratorResponse(BaseModel):
|
||||
"""Complete response schema for parallel orchestrator PR review."""
|
||||
|
||||
analysis_summary: str = Field(
|
||||
description="Brief summary of what was analyzed and why agents were chosen"
|
||||
)
|
||||
agents_invoked: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of agent names that were invoked",
|
||||
)
|
||||
findings: list[ParallelOrchestratorFinding] = Field(
|
||||
default_factory=list, description="All findings from synthesis"
|
||||
)
|
||||
agent_agreement: AgentAgreement = Field(
|
||||
default_factory=AgentAgreement,
|
||||
description="Information about agent agreement on findings",
|
||||
)
|
||||
verdict: Literal["APPROVE", "COMMENT", "NEEDS_REVISION", "BLOCKED"] = Field(
|
||||
description="Overall PR verdict"
|
||||
)
|
||||
verdict_reasoning: str = Field(description="Explanation for the verdict")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Parallel Follow-up Review Response (SDK Subagents for Follow-up)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class ResolutionVerification(BaseModel):
|
||||
"""AI-verified resolution status for a previous finding."""
|
||||
|
||||
finding_id: str = Field(description="ID of the previous finding")
|
||||
status: Literal["resolved", "partially_resolved", "unresolved", "cant_verify"] = (
|
||||
Field(description="Resolution status after AI verification")
|
||||
)
|
||||
confidence: float = Field(
|
||||
0.85, ge=0.0, le=1.0, description="Confidence in the resolution status"
|
||||
)
|
||||
evidence: str = Field(description="What evidence supports this resolution status")
|
||||
resolution_notes: str | None = Field(
|
||||
None, description="Detailed notes on how the issue was addressed"
|
||||
)
|
||||
|
||||
@field_validator("confidence", mode="before")
|
||||
@classmethod
|
||||
def normalize_confidence(cls, v: int | float) -> float:
|
||||
"""Normalize confidence to 0.0-1.0 range."""
|
||||
if v > 1:
|
||||
return v / 100.0
|
||||
return float(v)
|
||||
|
||||
|
||||
class ParallelFollowupFinding(BaseModel):
|
||||
"""A finding from parallel follow-up review with source agent tracking."""
|
||||
|
||||
id: str = Field(description="Unique identifier for this finding")
|
||||
file: str = Field(description="File path where issue was found")
|
||||
line: int = Field(0, description="Line number of the issue")
|
||||
end_line: int | None = Field(None, description="End line for multi-line issues")
|
||||
title: str = Field(description="Brief issue title (max 80 chars)")
|
||||
description: str = Field(description="Detailed explanation of the issue")
|
||||
category: Literal[
|
||||
"security",
|
||||
"quality",
|
||||
"logic",
|
||||
"test",
|
||||
"docs",
|
||||
"regression",
|
||||
"incomplete_fix",
|
||||
] = Field(description="Issue category")
|
||||
severity: Literal["critical", "high", "medium", "low"] = Field(
|
||||
description="Issue severity level"
|
||||
)
|
||||
confidence: float = Field(
|
||||
0.85, ge=0.0, le=1.0, description="Confidence in this finding (0.0-1.0)"
|
||||
)
|
||||
suggested_fix: str | None = Field(None, description="How to fix this issue")
|
||||
fixable: bool = Field(False, description="Whether this can be auto-fixed")
|
||||
source_agent: str = Field(
|
||||
description="Which agent reported this finding (resolution/newcode/comment)"
|
||||
)
|
||||
related_to_previous: str | None = Field(
|
||||
None, description="ID of related previous finding if this is a regression"
|
||||
)
|
||||
|
||||
@field_validator("confidence", mode="before")
|
||||
@classmethod
|
||||
def normalize_confidence(cls, v: int | float) -> float:
|
||||
"""Normalize confidence to 0.0-1.0 range."""
|
||||
if v > 1:
|
||||
return v / 100.0
|
||||
return float(v)
|
||||
|
||||
|
||||
class CommentAnalysis(BaseModel):
|
||||
"""Analysis of a contributor or AI comment."""
|
||||
|
||||
comment_id: str = Field(description="Identifier for the comment")
|
||||
author: str = Field(description="Comment author")
|
||||
is_ai_bot: bool = Field(description="Whether this is from an AI tool")
|
||||
requires_response: bool = Field(description="Whether this comment needs a response")
|
||||
sentiment: Literal["question", "concern", "suggestion", "praise", "neutral"] = (
|
||||
Field(description="Comment sentiment/type")
|
||||
)
|
||||
summary: str = Field(description="Brief summary of the comment")
|
||||
action_needed: str | None = Field(None, description="What action is needed if any")
|
||||
|
||||
|
||||
class ParallelFollowupResponse(BaseModel):
|
||||
"""Complete response schema for parallel follow-up PR review."""
|
||||
|
||||
# Analysis metadata
|
||||
analysis_summary: str = Field(
|
||||
description="Brief summary of what was analyzed in this follow-up"
|
||||
)
|
||||
agents_invoked: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of agent names that were invoked",
|
||||
)
|
||||
commits_analyzed: int = Field(0, description="Number of new commits analyzed")
|
||||
files_changed: int = Field(
|
||||
0, description="Number of files changed since last review"
|
||||
)
|
||||
|
||||
# Resolution verification (from resolution-verifier agent)
|
||||
resolution_verifications: list[ResolutionVerification] = Field(
|
||||
default_factory=list,
|
||||
description="AI-verified resolution status for each previous finding",
|
||||
)
|
||||
|
||||
# Finding validations (from finding-validator agent)
|
||||
finding_validations: list[FindingValidationResult] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"Re-investigation results for unresolved findings. "
|
||||
"Validates whether findings are real issues or false positives."
|
||||
),
|
||||
)
|
||||
|
||||
# New findings (from new-code-reviewer agent)
|
||||
new_findings: list[ParallelFollowupFinding] = Field(
|
||||
default_factory=list,
|
||||
description="New issues found in changes since last review",
|
||||
)
|
||||
|
||||
# Comment analysis (from comment-analyzer agent)
|
||||
comment_analyses: list[CommentAnalysis] = Field(
|
||||
default_factory=list,
|
||||
description="Analysis of contributor and AI comments",
|
||||
)
|
||||
comment_findings: list[ParallelFollowupFinding] = Field(
|
||||
default_factory=list,
|
||||
description="Issues identified from comment analysis",
|
||||
)
|
||||
|
||||
# Agent agreement tracking
|
||||
agent_agreement: AgentAgreement = Field(
|
||||
default_factory=AgentAgreement,
|
||||
description="Information about agent agreement on findings",
|
||||
)
|
||||
|
||||
# Verdict
|
||||
verdict: Literal[
|
||||
"READY_TO_MERGE", "MERGE_WITH_CHANGES", "NEEDS_REVISION", "BLOCKED"
|
||||
] = Field(description="Overall merge verdict")
|
||||
verdict_reasoning: str = Field(description="Explanation for the verdict")
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Finding Validation Response (Re-investigation of unresolved findings)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class FindingValidationResult(BaseModel):
|
||||
"""
|
||||
Result of re-investigating an unresolved finding to validate it's actually real.
|
||||
|
||||
The finding-validator agent uses this to report whether a previous finding
|
||||
is a genuine issue or a false positive that should be dismissed.
|
||||
"""
|
||||
|
||||
finding_id: str = Field(description="ID of the finding being validated")
|
||||
validation_status: Literal[
|
||||
"confirmed_valid", "dismissed_false_positive", "needs_human_review"
|
||||
] = Field(
|
||||
description=(
|
||||
"Validation result: "
|
||||
"confirmed_valid = issue IS real, keep as unresolved; "
|
||||
"dismissed_false_positive = original finding was incorrect, remove; "
|
||||
"needs_human_review = cannot determine with confidence"
|
||||
)
|
||||
)
|
||||
code_evidence: str = Field(
|
||||
min_length=1,
|
||||
description=(
|
||||
"REQUIRED: Exact code snippet examined from the file. "
|
||||
"Must be actual code, not a description."
|
||||
),
|
||||
)
|
||||
line_range: tuple[int, int] = Field(
|
||||
description="Start and end line numbers of the examined code"
|
||||
)
|
||||
explanation: str = Field(
|
||||
min_length=20,
|
||||
description=(
|
||||
"Detailed explanation of why the finding is valid/invalid. "
|
||||
"Must reference specific code and explain the reasoning."
|
||||
),
|
||||
)
|
||||
confidence: float = Field(
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
description=(
|
||||
"Confidence in the validation result (0.0-1.0). "
|
||||
"Must be >= 0.80 to dismiss as false positive, >= 0.70 to confirm valid."
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator("confidence", mode="before")
|
||||
@classmethod
|
||||
def normalize_confidence(cls, v: int | float) -> float:
|
||||
"""Normalize confidence to 0.0-1.0 range (accepts 0-100 or 0.0-1.0)."""
|
||||
if v > 1:
|
||||
return v / 100.0
|
||||
return float(v)
|
||||
|
||||
|
||||
class FindingValidationResponse(BaseModel):
|
||||
"""Complete response from the finding-validator agent."""
|
||||
|
||||
validations: list[FindingValidationResult] = Field(
|
||||
default_factory=list,
|
||||
description="Validation results for each finding investigated",
|
||||
)
|
||||
summary: str = Field(
|
||||
description=(
|
||||
"Brief summary of validation results: how many confirmed, "
|
||||
"how many dismissed, how many need human review"
|
||||
)
|
||||
)
|
||||
@@ -18,20 +18,50 @@ try:
|
||||
from ...analysis.test_discovery import TestDiscovery
|
||||
from ...core.client import create_client
|
||||
from ..context_gatherer import PRContext
|
||||
from ..models import PRReviewFinding, ReviewSeverity
|
||||
from .category_utils import map_category
|
||||
from ..models import PRReviewFinding, ReviewCategory, ReviewSeverity
|
||||
except (ImportError, ValueError, SystemError):
|
||||
from analysis.test_discovery import TestDiscovery
|
||||
from category_utils import map_category
|
||||
from context_gatherer import PRContext
|
||||
from core.client import create_client
|
||||
from models import PRReviewFinding, ReviewSeverity
|
||||
from models import PRReviewFinding, ReviewCategory, ReviewSeverity
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Use shared category mapping from category_utils
|
||||
_map_category = map_category
|
||||
# Map AI-generated category names to valid ReviewCategory enum values
|
||||
_CATEGORY_MAPPING = {
|
||||
# Direct matches
|
||||
"security": ReviewCategory.SECURITY,
|
||||
"quality": ReviewCategory.QUALITY,
|
||||
"style": ReviewCategory.STYLE,
|
||||
"test": ReviewCategory.TEST,
|
||||
"docs": ReviewCategory.DOCS,
|
||||
"pattern": ReviewCategory.PATTERN,
|
||||
"performance": ReviewCategory.PERFORMANCE,
|
||||
"verification_failed": ReviewCategory.VERIFICATION_FAILED,
|
||||
"redundancy": ReviewCategory.REDUNDANCY,
|
||||
# AI-generated alternatives
|
||||
"correctness": ReviewCategory.QUALITY,
|
||||
"consistency": ReviewCategory.PATTERN,
|
||||
"testing": ReviewCategory.TEST,
|
||||
"documentation": ReviewCategory.DOCS,
|
||||
"bug": ReviewCategory.QUALITY,
|
||||
"logic": ReviewCategory.QUALITY,
|
||||
"error_handling": ReviewCategory.QUALITY,
|
||||
"maintainability": ReviewCategory.QUALITY,
|
||||
"readability": ReviewCategory.STYLE,
|
||||
"best_practices": ReviewCategory.PATTERN,
|
||||
"architecture": ReviewCategory.PATTERN,
|
||||
"complexity": ReviewCategory.QUALITY,
|
||||
"dead_code": ReviewCategory.REDUNDANCY,
|
||||
"unused": ReviewCategory.REDUNDANCY,
|
||||
}
|
||||
|
||||
|
||||
def _map_category(category_str: str) -> ReviewCategory:
|
||||
"""Map an AI-generated category string to a valid ReviewCategory enum."""
|
||||
normalized = category_str.lower().strip().replace("-", "_")
|
||||
return _CATEGORY_MAPPING.get(normalized, ReviewCategory.QUALITY)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -106,7 +136,7 @@ async def spawn_security_review(
|
||||
/ "pr_security_agent.md"
|
||||
)
|
||||
if prompt_file.exists():
|
||||
base_prompt = prompt_file.read_text(encoding="utf-8")
|
||||
base_prompt = prompt_file.read_text()
|
||||
else:
|
||||
logger.warning("Security agent prompt not found, using fallback")
|
||||
base_prompt = _get_fallback_security_prompt()
|
||||
@@ -192,7 +222,7 @@ async def spawn_quality_review(
|
||||
/ "pr_quality_agent.md"
|
||||
)
|
||||
if prompt_file.exists():
|
||||
base_prompt = prompt_file.read_text(encoding="utf-8")
|
||||
base_prompt = prompt_file.read_text()
|
||||
else:
|
||||
logger.warning("Quality agent prompt not found, using fallback")
|
||||
base_prompt = _get_fallback_quality_prompt()
|
||||
@@ -474,7 +504,7 @@ async def get_file_content(
|
||||
try:
|
||||
full_path = project_dir / file_path
|
||||
if full_path.exists():
|
||||
return full_path.read_text(encoding="utf-8")
|
||||
return full_path.read_text()
|
||||
return ""
|
||||
except Exception as e:
|
||||
logger.error(f"[Orchestrator] Failed to read {file_path}: {e}")
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
"""
|
||||
SDK Stream Processing Utilities
|
||||
================================
|
||||
|
||||
Shared utilities for processing Claude Agent SDK response streams.
|
||||
|
||||
This module extracts common SDK message processing patterns used across
|
||||
parallel orchestrator and follow-up reviewers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Check if debug mode is enabled
|
||||
DEBUG_MODE = os.environ.get("DEBUG", "").lower() in ("true", "1", "yes")
|
||||
|
||||
|
||||
async def process_sdk_stream(
|
||||
client: Any,
|
||||
on_thinking: Callable[[str], None] | None = None,
|
||||
on_tool_use: Callable[[str, str, dict[str, Any]], None] | None = None,
|
||||
on_tool_result: Callable[[str, bool, Any], None] | None = None,
|
||||
on_text: Callable[[str], None] | None = None,
|
||||
on_structured_output: Callable[[dict[str, Any]], None] | None = None,
|
||||
context_name: str = "SDK",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Process SDK response stream with customizable callbacks.
|
||||
|
||||
This function handles the common pattern of:
|
||||
- Tracking thinking blocks
|
||||
- Tracking tool invocations (especially Task/subagent calls)
|
||||
- Tracking tool results
|
||||
- Collecting text output
|
||||
- Extracting structured output
|
||||
|
||||
Args:
|
||||
client: Claude SDK client with receive_response() method
|
||||
on_thinking: Callback for thinking blocks - receives thinking text
|
||||
on_tool_use: Callback for tool invocations - receives (tool_name, tool_id, tool_input)
|
||||
on_tool_result: Callback for tool results - receives (tool_id, is_error, result_content)
|
||||
on_text: Callback for text output - receives text string
|
||||
on_structured_output: Callback for structured output - receives dict
|
||||
context_name: Name for logging (e.g., "ParallelOrchestrator", "ParallelFollowup")
|
||||
|
||||
Returns:
|
||||
Dictionary with:
|
||||
- result_text: Accumulated text output
|
||||
- structured_output: Final structured output (if any)
|
||||
- agents_invoked: List of agent names invoked via Task tool
|
||||
- msg_count: Total message count
|
||||
- subagent_tool_ids: Mapping of tool_id -> agent_name
|
||||
- error: Error message if stream processing failed (None on success)
|
||||
"""
|
||||
result_text = ""
|
||||
structured_output = None
|
||||
agents_invoked = []
|
||||
msg_count = 0
|
||||
stream_error = None
|
||||
# Track subagent tool IDs to log their results
|
||||
subagent_tool_ids: dict[str, str] = {} # tool_id -> agent_name
|
||||
|
||||
print(f"[{context_name}] Processing SDK stream...", flush=True)
|
||||
if DEBUG_MODE:
|
||||
print(f"[DEBUG {context_name}] Awaiting response stream...", flush=True)
|
||||
|
||||
try:
|
||||
async for msg in client.receive_response():
|
||||
try:
|
||||
msg_type = type(msg).__name__
|
||||
msg_count += 1
|
||||
|
||||
if DEBUG_MODE:
|
||||
# Log every message type for visibility
|
||||
msg_details = ""
|
||||
if hasattr(msg, "type"):
|
||||
msg_details = f" (type={msg.type})"
|
||||
print(
|
||||
f"[DEBUG {context_name}] Message #{msg_count}: {msg_type}{msg_details}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Track thinking blocks
|
||||
if msg_type == "ThinkingBlock" or (
|
||||
hasattr(msg, "type") and msg.type == "thinking"
|
||||
):
|
||||
thinking_text = getattr(msg, "thinking", "") or getattr(
|
||||
msg, "text", ""
|
||||
)
|
||||
if thinking_text:
|
||||
print(
|
||||
f"[{context_name}] AI thinking: {len(thinking_text)} chars",
|
||||
flush=True,
|
||||
)
|
||||
if DEBUG_MODE:
|
||||
# Show first 200 chars of thinking
|
||||
preview = thinking_text[:200].replace("\n", " ")
|
||||
print(
|
||||
f"[DEBUG {context_name}] Thinking preview: {preview}...",
|
||||
flush=True,
|
||||
)
|
||||
# Invoke callback
|
||||
if on_thinking:
|
||||
on_thinking(thinking_text)
|
||||
|
||||
# Track subagent invocations (Task tool calls)
|
||||
if msg_type == "ToolUseBlock" or (
|
||||
hasattr(msg, "type") and msg.type == "tool_use"
|
||||
):
|
||||
tool_name = getattr(msg, "name", "")
|
||||
tool_id = getattr(msg, "id", "unknown")
|
||||
tool_input = getattr(msg, "input", {})
|
||||
|
||||
if DEBUG_MODE:
|
||||
print(
|
||||
f"[DEBUG {context_name}] Tool call: {tool_name} (id={tool_id})",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
if tool_name == "Task":
|
||||
# Extract which agent was invoked
|
||||
agent_name = tool_input.get("subagent_type", "unknown")
|
||||
agents_invoked.append(agent_name)
|
||||
# Track this tool ID to log its result later
|
||||
subagent_tool_ids[tool_id] = agent_name
|
||||
print(
|
||||
f"[{context_name}] Invoked agent: {agent_name}", flush=True
|
||||
)
|
||||
elif tool_name == "StructuredOutput":
|
||||
if tool_input:
|
||||
# Warn if overwriting existing structured output
|
||||
if structured_output is not None:
|
||||
logger.warning(
|
||||
f"[{context_name}] Multiple StructuredOutput blocks received, "
|
||||
f"overwriting previous output"
|
||||
)
|
||||
structured_output = tool_input
|
||||
print(
|
||||
f"[{context_name}] Received structured output",
|
||||
flush=True,
|
||||
)
|
||||
# Invoke callback
|
||||
if on_structured_output:
|
||||
on_structured_output(tool_input)
|
||||
elif DEBUG_MODE:
|
||||
# Log other tool calls in debug mode
|
||||
print(
|
||||
f"[DEBUG {context_name}] Other tool: {tool_name}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Invoke callback for all tool uses
|
||||
if on_tool_use:
|
||||
on_tool_use(tool_name, tool_id, tool_input)
|
||||
|
||||
# Track tool results
|
||||
if msg_type == "ToolResultBlock" or (
|
||||
hasattr(msg, "type") and msg.type == "tool_result"
|
||||
):
|
||||
tool_id = getattr(msg, "tool_use_id", "unknown")
|
||||
is_error = getattr(msg, "is_error", False)
|
||||
result_content = getattr(msg, "content", "")
|
||||
|
||||
# Handle list of content blocks
|
||||
if isinstance(result_content, list):
|
||||
result_content = " ".join(
|
||||
str(getattr(c, "text", c)) for c in result_content
|
||||
)
|
||||
|
||||
# Check if this is a subagent result
|
||||
if tool_id in subagent_tool_ids:
|
||||
agent_name = subagent_tool_ids[tool_id]
|
||||
status = "ERROR" if is_error else "complete"
|
||||
result_preview = (
|
||||
str(result_content)[:600].replace("\n", " ").strip()
|
||||
)
|
||||
print(
|
||||
f"[Agent:{agent_name}] {status}: {result_preview}{'...' if len(str(result_content)) > 600 else ''}",
|
||||
flush=True,
|
||||
)
|
||||
elif DEBUG_MODE:
|
||||
status = "ERROR" if is_error else "OK"
|
||||
print(
|
||||
f"[DEBUG {context_name}] Tool result: {tool_id} [{status}]",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Invoke callback
|
||||
if on_tool_result:
|
||||
on_tool_result(tool_id, is_error, result_content)
|
||||
|
||||
# Collect text output and check for tool uses in content blocks
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
block_type = type(block).__name__
|
||||
|
||||
# Check for tool use blocks within content
|
||||
if (
|
||||
block_type == "ToolUseBlock"
|
||||
or getattr(block, "type", "") == "tool_use"
|
||||
):
|
||||
tool_name = getattr(block, "name", "")
|
||||
tool_id = getattr(block, "id", "unknown")
|
||||
tool_input = getattr(block, "input", {})
|
||||
|
||||
if tool_name == "Task":
|
||||
agent_name = tool_input.get("subagent_type", "unknown")
|
||||
if agent_name not in agents_invoked:
|
||||
agents_invoked.append(agent_name)
|
||||
subagent_tool_ids[tool_id] = agent_name
|
||||
print(
|
||||
f"[{context_name}] Invoking agent: {agent_name}",
|
||||
flush=True,
|
||||
)
|
||||
elif tool_name == "StructuredOutput":
|
||||
if tool_input:
|
||||
# Warn if overwriting existing structured output
|
||||
if structured_output is not None:
|
||||
logger.warning(
|
||||
f"[{context_name}] Multiple StructuredOutput blocks received, "
|
||||
f"overwriting previous output"
|
||||
)
|
||||
structured_output = tool_input
|
||||
# Invoke callback
|
||||
if on_structured_output:
|
||||
on_structured_output(tool_input)
|
||||
|
||||
# Invoke callback
|
||||
if on_tool_use:
|
||||
on_tool_use(tool_name, tool_id, tool_input)
|
||||
|
||||
# Collect text
|
||||
if hasattr(block, "text"):
|
||||
result_text += block.text
|
||||
# Always print text content preview (not just in DEBUG_MODE)
|
||||
text_preview = block.text[:500].replace("\n", " ").strip()
|
||||
if text_preview:
|
||||
print(
|
||||
f"[{context_name}] AI response: {text_preview}{'...' if len(block.text) > 500 else ''}",
|
||||
flush=True,
|
||||
)
|
||||
# Invoke callback
|
||||
if on_text:
|
||||
on_text(block.text)
|
||||
|
||||
# Check for StructuredOutput in content (legacy check)
|
||||
if getattr(block, "name", "") == "StructuredOutput":
|
||||
structured_data = getattr(block, "input", None)
|
||||
if structured_data:
|
||||
# Warn if overwriting existing structured output
|
||||
if structured_output is not None:
|
||||
logger.warning(
|
||||
f"[{context_name}] Multiple StructuredOutput blocks received, "
|
||||
f"overwriting previous output"
|
||||
)
|
||||
structured_output = structured_data
|
||||
# Invoke callback
|
||||
if on_structured_output:
|
||||
on_structured_output(structured_data)
|
||||
|
||||
# Check for structured_output attribute
|
||||
if hasattr(msg, "structured_output") and msg.structured_output:
|
||||
# Warn if overwriting existing structured output
|
||||
if structured_output is not None:
|
||||
logger.warning(
|
||||
f"[{context_name}] Multiple StructuredOutput blocks received, "
|
||||
f"overwriting previous output"
|
||||
)
|
||||
structured_output = msg.structured_output
|
||||
# Invoke callback
|
||||
if on_structured_output:
|
||||
on_structured_output(msg.structured_output)
|
||||
|
||||
# Check for tool results in UserMessage (subagent results come back here)
|
||||
if msg_type == "UserMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
block_type = type(block).__name__
|
||||
# Check for tool result blocks
|
||||
if (
|
||||
block_type == "ToolResultBlock"
|
||||
or getattr(block, "type", "") == "tool_result"
|
||||
):
|
||||
tool_id = getattr(block, "tool_use_id", "unknown")
|
||||
is_error = getattr(block, "is_error", False)
|
||||
result_content = getattr(block, "content", "")
|
||||
|
||||
# Handle list of content blocks
|
||||
if isinstance(result_content, list):
|
||||
result_content = " ".join(
|
||||
str(getattr(c, "text", c)) for c in result_content
|
||||
)
|
||||
|
||||
# Check if this is a subagent result
|
||||
if tool_id in subagent_tool_ids:
|
||||
agent_name = subagent_tool_ids[tool_id]
|
||||
status = "ERROR" if is_error else "complete"
|
||||
result_preview = (
|
||||
str(result_content)[:600].replace("\n", " ").strip()
|
||||
)
|
||||
print(
|
||||
f"[Agent:{agent_name}] {status}: {result_preview}{'...' if len(str(result_content)) > 600 else ''}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Invoke callback
|
||||
if on_tool_result:
|
||||
on_tool_result(tool_id, is_error, result_content)
|
||||
|
||||
except (AttributeError, TypeError, KeyError) as msg_error:
|
||||
# Log individual message processing errors but continue
|
||||
logger.warning(
|
||||
f"[{context_name}] Error processing message #{msg_count}: {msg_error}"
|
||||
)
|
||||
if DEBUG_MODE:
|
||||
print(
|
||||
f"[DEBUG {context_name}] Message processing error: {msg_error}",
|
||||
flush=True,
|
||||
)
|
||||
# Continue processing subsequent messages
|
||||
|
||||
except Exception as e:
|
||||
# Log stream-level errors
|
||||
stream_error = str(e)
|
||||
logger.error(f"[{context_name}] SDK stream processing failed: {e}")
|
||||
print(f"[{context_name}] ERROR: Stream processing failed: {e}", flush=True)
|
||||
|
||||
if DEBUG_MODE:
|
||||
print(
|
||||
f"[DEBUG {context_name}] Session ended. Total messages: {msg_count}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
print(f"[{context_name}] Session ended. Total messages: {msg_count}", flush=True)
|
||||
|
||||
return {
|
||||
"result_text": result_text,
|
||||
"structured_output": structured_output,
|
||||
"agents_invoked": agents_invoked,
|
||||
"msg_count": msg_count,
|
||||
"subagent_tool_ids": subagent_tool_ids,
|
||||
"error": stream_error,
|
||||
}
|
||||
@@ -6,18 +6,11 @@ Tests the BotDetector class to ensure it correctly prevents infinite loops.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Use direct file import to avoid package import issues
|
||||
_github_dir = Path(__file__).parent
|
||||
if str(_github_dir) not in sys.path:
|
||||
sys.path.insert(0, str(_github_dir))
|
||||
|
||||
from bot_detection import BotDetectionState, BotDetector
|
||||
|
||||
|
||||
@@ -132,15 +125,13 @@ class TestBotDetection:
|
||||
|
||||
def test_get_last_commit_sha(self, mock_bot_detector):
|
||||
"""Test extracting last commit SHA."""
|
||||
# GitHub API returns commits in chronological order (oldest first, newest last)
|
||||
# So commits[-1] is the LATEST commit
|
||||
commits = [
|
||||
{"oid": "abc123"}, # Oldest commit
|
||||
{"oid": "def456"}, # Latest commit
|
||||
{"oid": "abc123"},
|
||||
{"oid": "def456"},
|
||||
]
|
||||
|
||||
sha = mock_bot_detector.get_last_commit_sha(commits)
|
||||
assert sha == "def456" # Should return the LAST (latest) commit
|
||||
assert sha == "abc123"
|
||||
|
||||
# Test with sha field instead of oid
|
||||
commits_with_sha = [{"sha": "xyz789"}]
|
||||
@@ -152,16 +143,13 @@ class TestBotDetection:
|
||||
|
||||
|
||||
class TestCoolingOff:
|
||||
"""Test cooling off period.
|
||||
|
||||
Note: COOLING_OFF_MINUTES is currently set to 1 minute for testing large PRs.
|
||||
"""
|
||||
"""Test cooling off period."""
|
||||
|
||||
def test_within_cooling_off(self, mock_bot_detector):
|
||||
"""Test PR within cooling off period."""
|
||||
# Set last review to 30 seconds ago (within 1 minute cooling off)
|
||||
half_min_ago = datetime.now() - timedelta(seconds=30)
|
||||
mock_bot_detector.state.last_review_times["123"] = half_min_ago.isoformat()
|
||||
# Set last review to 5 minutes ago
|
||||
five_min_ago = datetime.now() - timedelta(minutes=5)
|
||||
mock_bot_detector.state.last_review_times["123"] = five_min_ago.isoformat()
|
||||
|
||||
is_cooling, reason = mock_bot_detector.is_within_cooling_off(123)
|
||||
|
||||
@@ -170,9 +158,9 @@ class TestCoolingOff:
|
||||
|
||||
def test_outside_cooling_off(self, mock_bot_detector):
|
||||
"""Test PR outside cooling off period."""
|
||||
# Set last review to 2 minutes ago (outside 1 minute cooling off)
|
||||
two_min_ago = datetime.now() - timedelta(minutes=2)
|
||||
mock_bot_detector.state.last_review_times["123"] = two_min_ago.isoformat()
|
||||
# Set last review to 15 minutes ago
|
||||
fifteen_min_ago = datetime.now() - timedelta(minutes=15)
|
||||
mock_bot_detector.state.last_review_times["123"] = fifteen_min_ago.isoformat()
|
||||
|
||||
is_cooling, reason = mock_bot_detector.is_within_cooling_off(123)
|
||||
|
||||
@@ -241,16 +229,11 @@ class TestShouldSkipReview:
|
||||
assert "bot user" in reason
|
||||
|
||||
def test_skip_bot_commit(self, mock_bot_detector):
|
||||
"""Test skipping PR with bot commit as the latest commit."""
|
||||
"""Test skipping PR with bot commit."""
|
||||
pr_data = {"author": {"login": "alice"}}
|
||||
# GitHub API returns commits in chronological order (oldest first, newest last)
|
||||
# So commits[-1] is the LATEST commit - which is the bot commit
|
||||
commits = [
|
||||
{"author": {"login": "alice"}, "oid": "abc123"}, # Oldest commit (by alice)
|
||||
{
|
||||
"author": {"login": "test-bot"},
|
||||
"oid": "def456",
|
||||
}, # Latest commit (by bot)
|
||||
{"author": {"login": "test-bot"}, "oid": "abc123"}, # Latest is bot
|
||||
{"author": {"login": "alice"}, "oid": "def456"},
|
||||
]
|
||||
|
||||
should_skip, reason = mock_bot_detector.should_skip_pr_review(
|
||||
@@ -264,9 +247,9 @@ class TestShouldSkipReview:
|
||||
|
||||
def test_skip_cooling_off(self, mock_bot_detector):
|
||||
"""Test skipping during cooling off period."""
|
||||
# Set last review to 30 seconds ago (within 1 minute cooling off)
|
||||
half_min_ago = datetime.now() - timedelta(seconds=30)
|
||||
mock_bot_detector.state.last_review_times["123"] = half_min_ago.isoformat()
|
||||
# Set last review to 5 minutes ago
|
||||
five_min_ago = datetime.now() - timedelta(minutes=5)
|
||||
mock_bot_detector.state.last_review_times["123"] = five_min_ago.isoformat()
|
||||
|
||||
pr_data = {"author": {"login": "alice"}}
|
||||
commits = [{"author": {"login": "alice"}, "oid": "abc123"}]
|
||||
@@ -366,7 +349,7 @@ class TestStateManagement:
|
||||
assert stats["review_own_prs"] is False
|
||||
assert stats["total_prs_tracked"] == 2
|
||||
assert stats["total_reviews_performed"] == 3
|
||||
assert stats["cooling_off_minutes"] == 1 # Currently set to 1 for testing
|
||||
assert stats["cooling_off_minutes"] == 10
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user