Compare commits
192 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5095bf93e7 | |||
| ddcff02df9 | |||
| 9a23bae190 | |||
| 6ed1be842c | |||
| 2dba38dea6 | |||
| 715ada4a2c | |||
| 2d9255a7b3 | |||
| 21ac8c312d | |||
| e6d065a8ba | |||
| 5e783908e3 | |||
| 639c795378 | |||
| 31519c2a10 | |||
| f406959094 | |||
| e3d72d648e | |||
| e9c859cc6c | |||
| 7fda36ad2e | |||
| 78b80bcaeb | |||
| 52bd0bff5f | |||
| 3b3231a962 | |||
| 724ad827bf | |||
| 2f321fb2aa | |||
| df57fbf8bc | |||
| 84bc52264f | |||
| 8a4b506671 | |||
| 574cd117b2 | |||
| 09aa4f4f71 | |||
| 78aceaed1e | |||
| 5005e56e46 | |||
| ec4441c1e3 | |||
| 97f34496b5 | |||
| 2c9fcbf498 | |||
| 3930b12c41 | |||
| e2937320cf | |||
| 81afc3d2cc | |||
| 63f4617354 | |||
| 35573fd5b0 | |||
| 7b4993e9db | |||
| c27135436d | |||
| 6fb2d48433 | |||
| 1e3e8bda1d | |||
| 8be0e6ff1a | |||
| 234d44f637 | |||
| 65f608986b | |||
| eeef8a3d4a | |||
| e1e8943051 | |||
| b72031241d | |||
| 46c41f8f51 | |||
| 39da81935b | |||
| f7b02e8795 | |||
| 4ec9db8c38 | |||
| 556f0b2129 | |||
| acdd7d9b1e | |||
| 13535f1bcf | |||
| 7177c7994d | |||
| f5be794346 | |||
| 14b3db56fa | |||
| 6c855905cb | |||
| 4a833048d1 | |||
| 5efc2c56f7 | |||
| 3086233f87 | |||
| d278963bf9 | |||
| 2880baf1b1 | |||
| 6ac3012ffa | |||
| effaa681a9 | |||
| 04de8c7848 | |||
| 6d4231edca | |||
| dedd07572d | |||
| 90dddc2879 | |||
| 90a203203f | |||
| 16a7fa4bf5 | |||
| c2148bb926 | |||
| 29e455058b | |||
| 7990dcb41d | |||
| f58c257824 | |||
| 30f7951a53 | |||
| 3db02c5d64 | |||
| 344ec65eed | |||
| 8d58dd6fe4 | |||
| 4da8cd66c6 | |||
| 8e5c11ac74 | |||
| 72106109a2 | |||
| 52a4fcc6d3 | |||
| fb6b7fc6d2 | |||
| 0f9c5b8403 | |||
| 5d8ede2331 | |||
| da31b68774 | |||
| 2effa53517 | |||
| c15bb31146 | |||
| 203a970ab5 | |||
| 3c0708b749 | |||
| 666794b5fc | |||
| ac8dfcac7b | |||
| 798ca79ddb | |||
| bdb0154977 | |||
| 515b73b553 | |||
| 88c7605985 | |||
| 62a7551571 | |||
| 717fba0440 | |||
| 0a571d3a2b | |||
| 2f662469e9 | |||
| e7e6b52128 | |||
| 230de5fc03 | |||
| 4bdf7a0c5b | |||
| a39ea49d4d | |||
| 9aef0dd0b8 | |||
| 05131217cf | |||
| 321c971289 | |||
| 98b12ed807 | |||
| aaa83131f4 | |||
| 68548e33c8 | |||
| 7751588e56 | |||
| 8b4ce58c56 | |||
| bc22064535 | |||
| db0cbea3f2 | |||
| 0ca2e3f697 | |||
| 2d3b7fb4b6 | |||
| 9bbdef0949 | |||
| 753dc8bbe3 | |||
| 20f20fa321 | |||
| eabe7c7d1b | |||
| 1fa7a9c769 | |||
| 7881b2d1e9 | |||
| 4e71361b2d | |||
| 4dcc5afae8 | |||
| e9782db044 | |||
| 40d04d7c7d | |||
| fef07c9517 | |||
| 9d43abedde | |||
| d51f45621b | |||
| 787667e98e | |||
| 9734b70b05 | |||
| fec6b9f339 | |||
| d3a63b09fd | |||
| 50e3111ae2 | |||
| 8a80b1d5c8 | |||
| cb6b216534 | |||
| 661e47c341 | |||
| e80ef79d12 | |||
| e1b0f743bf | |||
| 92c6f2786d | |||
| 1c14227324 | |||
| c0a02a453d | |||
| 086429cb49 | |||
| d9fb8f29d5 | |||
| d0b0b3df0d | |||
| 937a60f8bd | |||
| 7a51cbd5e5 | |||
| 26beefe39d | |||
| 8416f3076a | |||
| 217249c8a3 | |||
| 8bb3df917e | |||
| 5106c6e9b2 | |||
| 3ff612742f | |||
| 7f19c2e1f3 | |||
| d98e28305d | |||
| 0b874d4b33 | |||
| 50dd10788a | |||
| f1cc5a09f2 | |||
| b005fa5c86 | |||
| d79f2da411 | |||
| 5ac566e2a2 | |||
| f49d4817b1 | |||
| 1e1d7d9b68 | |||
| e74a3dffd2 | |||
| 6ac8250b5b | |||
| a2cee6941f | |||
| d4cad80a73 | |||
| 596e95137b | |||
| d42041c5b5 | |||
| a3f87540c6 | |||
| f843811292 | |||
| 5e8c53080f | |||
| 348de6dfe7 | |||
| 0f7d6e0530 | |||
| 5ccdb6abc5 | |||
| 6ec8549f63 | |||
| 53527293cd | |||
| 02bef954f3 | |||
| f168bdc3ac | |||
| e3eec68aab | |||
| 407a0bee5e | |||
| 8f766ad16e | |||
| ced2ad479f | |||
| 05f5d3038b | |||
| 6951251b33 | |||
| 30e7536b63 | |||
| 220faf0fb4 | |||
| f96c6301f4 | |||
| ebd8340d82 | |||
| df779530e7 | |||
| 0adaddacaa | |||
| 91f7051dda |
+8
-4
@@ -42,14 +42,18 @@ reviews:
|
||||
|
||||
# Path-specific review instructions
|
||||
path_instructions:
|
||||
- path: "apps/desktop/**/*.{ts,tsx}"
|
||||
- path: "apps/backend/**/*.py"
|
||||
instructions: |
|
||||
Focus on Python best practices, type hints, and async patterns.
|
||||
Check for proper error handling and security considerations.
|
||||
Verify compatibility with Python 3.12+.
|
||||
- path: "apps/frontend/**/*.{ts,tsx}"
|
||||
instructions: |
|
||||
Review React patterns and TypeScript type safety.
|
||||
Check for proper state management and component composition.
|
||||
Verify Vercel AI SDK v6 usage patterns and tool definitions.
|
||||
- path: "apps/desktop/**/*.test.{ts,tsx}"
|
||||
- path: "tests/**"
|
||||
instructions: |
|
||||
Ensure tests are comprehensive and follow Vitest conventions.
|
||||
Ensure tests are comprehensive and follow pytest conventions.
|
||||
Check for proper mocking and test isolation.
|
||||
|
||||
chat:
|
||||
|
||||
@@ -33,21 +33,6 @@ Follow conventional commits: `<type>: <subject>`
|
||||
|
||||
**Example:** `feat: add user authentication system`
|
||||
|
||||
## AI Disclosure
|
||||
|
||||
<!-- Check the box below if any part of this PR was written with AI assistance. -->
|
||||
|
||||
- [ ] This PR includes AI-generated code (Claude, Codex, Copilot, etc.)
|
||||
|
||||
<!-- If checked, please also fill in: -->
|
||||
**Tool(s) used:** <!-- e.g., Claude Code, GitHub Copilot, ChatGPT -->
|
||||
**Testing level:**
|
||||
- [ ] Untested -- AI output not yet verified
|
||||
- [ ] Lightly tested -- ran the app / spot-checked key paths
|
||||
- [ ] Fully tested -- all tests pass, manually verified behavior
|
||||
|
||||
- [ ] I understand what this PR does and how the underlying code works
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] I've synced with `develop` branch
|
||||
@@ -55,21 +40,9 @@ Follow conventional commits: `<type>: <subject>`
|
||||
- [ ] I've followed the code principles (SOLID, DRY, KISS)
|
||||
- [ ] My PR is small and focused (< 400 lines ideally)
|
||||
|
||||
## Platform Testing Checklist
|
||||
|
||||
**CRITICAL:** This project supports Windows, macOS, and Linux. Platform-specific bugs are a common source of breakage.
|
||||
|
||||
- [ ] **Windows tested** (either on Windows or via CI)
|
||||
- [ ] **macOS tested** (either on macOS or via CI)
|
||||
- [ ] **Linux tested** (CI covers this)
|
||||
- [ ] Used centralized `platform/` module instead of direct `process.platform` checks
|
||||
- [ ] No hardcoded paths (used `findExecutable()` or platform abstractions)
|
||||
|
||||
**If you only have access to one OS:** CI now tests on all platforms. Ensure all checks pass before submitting.
|
||||
|
||||
## CI/Testing Requirements
|
||||
|
||||
- [ ] All CI checks pass on **all platforms** (Windows, macOS, Linux)
|
||||
- [ ] All CI checks pass
|
||||
- [ ] All existing tests pass
|
||||
- [ ] New features include test coverage
|
||||
- [ ] Bug fixes include regression tests
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
name: 'Finalize macOS Notarization'
|
||||
description: 'Wait for Apple notarization to complete and staple tickets to DMG files'
|
||||
|
||||
inputs:
|
||||
apple-id:
|
||||
description: 'Apple ID for notarization'
|
||||
required: true
|
||||
apple-app-specific-password:
|
||||
description: 'Apple app-specific password'
|
||||
required: true
|
||||
apple-team-id:
|
||||
description: 'Apple Team ID'
|
||||
required: true
|
||||
intel-notarization-id:
|
||||
description: 'Notarization request ID for Intel build'
|
||||
required: false
|
||||
default: ''
|
||||
arm64-notarization-id:
|
||||
description: 'Notarization request ID for ARM64 build'
|
||||
required: false
|
||||
default: ''
|
||||
intel-dmg-file:
|
||||
description: 'Filename of the Intel DMG'
|
||||
required: false
|
||||
default: ''
|
||||
arm64-dmg-file:
|
||||
description: 'Filename of the ARM64 DMG'
|
||||
required: false
|
||||
default: ''
|
||||
intel-artifact-path:
|
||||
description: 'Path to Intel build artifacts'
|
||||
required: false
|
||||
default: 'intel'
|
||||
arm64-artifact-path:
|
||||
description: 'Path to ARM64 build artifacts'
|
||||
required: false
|
||||
default: 'arm64'
|
||||
timeout:
|
||||
description: 'Timeout in seconds for notarization wait'
|
||||
required: false
|
||||
default: '3600'
|
||||
|
||||
outputs:
|
||||
intel-stapled:
|
||||
description: 'Whether Intel DMG was successfully stapled'
|
||||
value: ${{ steps.staple.outputs.intel_stapled }}
|
||||
arm64-stapled:
|
||||
description: 'Whether ARM64 DMG was successfully stapled'
|
||||
value: ${{ steps.staple.outputs.arm64_stapled }}
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Wait for notarization and staple
|
||||
id: staple
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_ID: ${{ inputs.apple-id }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ inputs.apple-app-specific-password }}
|
||||
APPLE_TEAM_ID: ${{ inputs.apple-team-id }}
|
||||
INTEL_NOTARIZATION_ID: ${{ inputs.intel-notarization-id }}
|
||||
ARM64_NOTARIZATION_ID: ${{ inputs.arm64-notarization-id }}
|
||||
INTEL_DMG: ${{ inputs.intel-dmg-file }}
|
||||
ARM64_DMG: ${{ inputs.arm64-dmg-file }}
|
||||
INTEL_PATH: ${{ inputs.intel-artifact-path }}
|
||||
ARM64_PATH: ${{ inputs.arm64-artifact-path }}
|
||||
TIMEOUT: ${{ inputs.timeout }}
|
||||
run: |
|
||||
intel_stapled=false
|
||||
arm64_stapled=false
|
||||
|
||||
if [ -z "$APPLE_ID" ]; then
|
||||
echo "Skipping notarization wait: APPLE_ID not configured"
|
||||
echo "intel_stapled=false" >> "$GITHUB_OUTPUT"
|
||||
echo "arm64_stapled=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Warn if no notarization IDs provided (could indicate submission failure)
|
||||
if [ -z "$INTEL_NOTARIZATION_ID" ] && [ -z "$ARM64_NOTARIZATION_ID" ]; then
|
||||
echo "::warning::No notarization IDs provided - nothing to finalize. Check if notarization submission succeeded."
|
||||
echo "intel_stapled=false" >> "$GITHUB_OUTPUT"
|
||||
echo "arm64_stapled=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Wait for Intel notarization
|
||||
if [ -n "$INTEL_NOTARIZATION_ID" ]; then
|
||||
echo "Waiting for Intel notarization: $INTEL_NOTARIZATION_ID"
|
||||
if ! xcrun notarytool wait "$INTEL_NOTARIZATION_ID" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--timeout "$TIMEOUT"; then
|
||||
echo "::error::Intel notarization failed or timed out"
|
||||
exit 1
|
||||
fi
|
||||
# Verify notarization was accepted (not just processed)
|
||||
INTEL_STATUS=$(xcrun notarytool info "$INTEL_NOTARIZATION_ID" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--output-format json | jq -r '.status // "Unknown"')
|
||||
if [ "$INTEL_STATUS" != "Accepted" ]; then
|
||||
echo "::error::Intel notarization status is '$INTEL_STATUS', expected 'Accepted'"
|
||||
exit 1
|
||||
fi
|
||||
echo "Intel notarization status: $INTEL_STATUS"
|
||||
# Verify DMG file exists before stapling
|
||||
if [ ! -f "$INTEL_PATH/$INTEL_DMG" ]; then
|
||||
echo "::error::Intel DMG not found at $INTEL_PATH/$INTEL_DMG"
|
||||
exit 1
|
||||
fi
|
||||
echo "Stapling Intel DMG: $INTEL_PATH/$INTEL_DMG"
|
||||
if ! xcrun stapler staple "$INTEL_PATH/$INTEL_DMG"; then
|
||||
echo "::error::Failed to staple Intel DMG"
|
||||
exit 1
|
||||
fi
|
||||
echo "Successfully stapled Intel DMG"
|
||||
intel_stapled=true
|
||||
fi
|
||||
|
||||
# Wait for ARM64 notarization
|
||||
if [ -n "$ARM64_NOTARIZATION_ID" ]; then
|
||||
echo "Waiting for ARM64 notarization: $ARM64_NOTARIZATION_ID"
|
||||
if ! xcrun notarytool wait "$ARM64_NOTARIZATION_ID" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--timeout "$TIMEOUT"; then
|
||||
echo "::error::ARM64 notarization failed or timed out"
|
||||
exit 1
|
||||
fi
|
||||
# Verify notarization was accepted (not just processed)
|
||||
ARM64_STATUS=$(xcrun notarytool info "$ARM64_NOTARIZATION_ID" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--output-format json | jq -r '.status // "Unknown"')
|
||||
if [ "$ARM64_STATUS" != "Accepted" ]; then
|
||||
echo "::error::ARM64 notarization status is '$ARM64_STATUS', expected 'Accepted'"
|
||||
exit 1
|
||||
fi
|
||||
echo "ARM64 notarization status: $ARM64_STATUS"
|
||||
# Verify DMG file exists before stapling
|
||||
if [ ! -f "$ARM64_PATH/$ARM64_DMG" ]; then
|
||||
echo "::error::ARM64 DMG not found at $ARM64_PATH/$ARM64_DMG"
|
||||
exit 1
|
||||
fi
|
||||
echo "Stapling ARM64 DMG: $ARM64_PATH/$ARM64_DMG"
|
||||
if ! xcrun stapler staple "$ARM64_PATH/$ARM64_DMG"; then
|
||||
echo "::error::Failed to staple ARM64 DMG"
|
||||
exit 1
|
||||
fi
|
||||
echo "Successfully stapled ARM64 DMG"
|
||||
arm64_stapled=true
|
||||
fi
|
||||
|
||||
echo "intel_stapled=$intel_stapled" >> "$GITHUB_OUTPUT"
|
||||
echo "arm64_stapled=$arm64_stapled" >> "$GITHUB_OUTPUT"
|
||||
@@ -1,194 +0,0 @@
|
||||
name: 'Merge macOS Manifests'
|
||||
description: 'Merge Intel and ARM64 macOS manifests for electron-updater'
|
||||
|
||||
inputs:
|
||||
dist-path:
|
||||
description: 'Path to the dist directory containing build artifacts'
|
||||
required: false
|
||||
default: 'dist'
|
||||
output-path:
|
||||
description: 'Path to output the merged manifest'
|
||||
required: false
|
||||
default: 'release-assets'
|
||||
copy-other-manifests:
|
||||
description: 'Whether to copy Windows/Linux manifests as well'
|
||||
required: false
|
||||
default: 'true'
|
||||
yq-version:
|
||||
description: 'Version of yq to use for YAML merging'
|
||||
required: false
|
||||
default: 'v4.44.3'
|
||||
|
||||
outputs:
|
||||
merged:
|
||||
description: 'Whether manifests were merged (true) or single architecture used (false)'
|
||||
value: ${{ steps.merge.outputs.merged }}
|
||||
file-count:
|
||||
description: 'Number of files in the merged manifest'
|
||||
value: ${{ steps.validate.outputs.file_count }}
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Merge macOS manifests
|
||||
id: merge
|
||||
shell: bash
|
||||
env:
|
||||
# yq SHA256 checksum for v4.44.3 linux_amd64
|
||||
# When updating yq-version, update this checksum and the one in validate step
|
||||
YQ_SHA256: "a2c097180dd884a8d50c956ee16a9cec070f30a7947cf4ebf87d5f36213e9ed7"
|
||||
run: |
|
||||
echo "=== Merging macOS update manifests ==="
|
||||
|
||||
# Find all latest-mac.yml files from different build artifacts
|
||||
intel_manifest=$(find "${{ inputs.dist-path }}" -path "*/macos-intel-builds/latest-mac.yml" -type f 2>/dev/null | head -1)
|
||||
arm64_manifest=$(find "${{ inputs.dist-path }}" -path "*/macos-arm64-builds/latest-mac.yml" -type f 2>/dev/null | head -1)
|
||||
|
||||
echo "Intel manifest: ${intel_manifest:-not found}"
|
||||
echo "ARM64 manifest: ${arm64_manifest:-not found}"
|
||||
|
||||
mkdir -p "${{ inputs.output-path }}"
|
||||
|
||||
if [ -n "$intel_manifest" ] && [ -n "$arm64_manifest" ]; then
|
||||
echo "Both architectures found - merging manifests..."
|
||||
echo "merged=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Install yq for YAML merging (pinned version with checksum verification)
|
||||
YQ_VERSION="${{ inputs.yq-version }}"
|
||||
YQ_URL="https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64"
|
||||
|
||||
echo "Downloading yq ${YQ_VERSION}..."
|
||||
if ! wget -qO /tmp/yq "$YQ_URL"; then
|
||||
echo "::error::Failed to download yq ${YQ_VERSION}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Verify checksum
|
||||
echo "Verifying yq checksum..."
|
||||
ACTUAL_SHA256=$(sha256sum /tmp/yq | cut -d' ' -f1)
|
||||
if [ "$ACTUAL_SHA256" != "$YQ_SHA256" ]; then
|
||||
echo "::error::yq checksum verification failed!"
|
||||
echo "Expected: $YQ_SHA256"
|
||||
echo "Actual: $ACTUAL_SHA256"
|
||||
rm -f /tmp/yq
|
||||
exit 1
|
||||
fi
|
||||
echo "Checksum verified successfully"
|
||||
|
||||
sudo mv /tmp/yq /usr/local/bin/yq
|
||||
sudo chmod +x /usr/local/bin/yq
|
||||
echo "Installed yq version:"
|
||||
yq --version
|
||||
|
||||
# Merge the files arrays from both manifests using two-step approach
|
||||
# Step 1: Collect all files from both manifests into a temp file
|
||||
yq eval-all '[.files] | flatten' "$intel_manifest" "$arm64_manifest" > /tmp/merged-files.yml
|
||||
|
||||
# Step 2: Replace files array in first manifest with merged files
|
||||
yq eval '.files = load("/tmp/merged-files.yml")' "$intel_manifest" > "${{ inputs.output-path }}/latest-mac.yml"
|
||||
|
||||
echo "Merged manifest contents:"
|
||||
cat "${{ inputs.output-path }}/latest-mac.yml"
|
||||
|
||||
elif [ -n "$intel_manifest" ]; then
|
||||
echo "Only Intel manifest found - using as-is"
|
||||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
||||
cp "$intel_manifest" "${{ inputs.output-path }}/latest-mac.yml"
|
||||
elif [ -n "$arm64_manifest" ]; then
|
||||
echo "Only ARM64 manifest found - using as-is"
|
||||
echo "merged=false" >> "$GITHUB_OUTPUT"
|
||||
cp "$arm64_manifest" "${{ inputs.output-path }}/latest-mac.yml"
|
||||
else
|
||||
echo "::error::No macOS manifests found - this will cause auto-update to fail"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate merged manifest
|
||||
id: validate
|
||||
shell: bash
|
||||
env:
|
||||
# Single source of truth for yq checksum - must match merge step
|
||||
YQ_SHA256: "a2c097180dd884a8d50c956ee16a9cec070f30a7947cf4ebf87d5f36213e9ed7"
|
||||
run: |
|
||||
manifest_file="${{ inputs.output-path }}/latest-mac.yml"
|
||||
|
||||
echo "=== Validating merged manifest ==="
|
||||
|
||||
# Check file exists
|
||||
if [ ! -f "$manifest_file" ]; then
|
||||
echo "::error::Merged manifest file not found at $manifest_file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install yq if not already installed (for single-arch case)
|
||||
if ! command -v yq &> /dev/null; then
|
||||
YQ_VERSION="${{ inputs.yq-version }}"
|
||||
YQ_URL="https://github.com/mikefarah/yq/releases/download/${YQ_VERSION}/yq_linux_amd64"
|
||||
|
||||
echo "Downloading yq ${YQ_VERSION}..."
|
||||
wget -qO /tmp/yq "$YQ_URL"
|
||||
|
||||
# Verify checksum (YQ_SHA256 from env)
|
||||
ACTUAL_SHA256=$(sha256sum /tmp/yq | cut -d' ' -f1)
|
||||
if [ "$ACTUAL_SHA256" != "$YQ_SHA256" ]; then
|
||||
echo "::error::yq checksum verification failed!"
|
||||
echo "Expected: $YQ_SHA256"
|
||||
echo "Actual: $ACTUAL_SHA256"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sudo mv /tmp/yq /usr/local/bin/yq
|
||||
sudo chmod +x /usr/local/bin/yq
|
||||
fi
|
||||
|
||||
# Validate YAML is parseable
|
||||
if ! yq eval '.' "$manifest_file" > /dev/null 2>&1; then
|
||||
echo "::error::Merged manifest is not valid YAML"
|
||||
cat "$manifest_file"
|
||||
exit 1
|
||||
fi
|
||||
echo "YAML syntax is valid"
|
||||
|
||||
# Count files in manifest
|
||||
file_count=$(yq eval '.files | length' "$manifest_file")
|
||||
echo "file_count=$file_count" >> "$GITHUB_OUTPUT"
|
||||
echo "Manifest contains $file_count file entries"
|
||||
|
||||
# Validate file count
|
||||
if [ "$file_count" -eq 0 ]; then
|
||||
echo "::error::Merged manifest contains no files"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# If we merged both architectures, expect at least 2 files (one per arch)
|
||||
if [ "${{ steps.merge.outputs.merged }}" = "true" ] && [ "$file_count" -lt 2 ]; then
|
||||
echo "::warning::Merged manifest has fewer than 2 files - merge may have failed"
|
||||
fi
|
||||
|
||||
# Validate required fields exist
|
||||
if ! yq eval '.version' "$manifest_file" | grep -q .; then
|
||||
echo "::error::Manifest missing 'version' field"
|
||||
exit 1
|
||||
fi
|
||||
echo "Version field present: $(yq eval '.version' "$manifest_file")"
|
||||
|
||||
echo "Manifest validation passed"
|
||||
|
||||
- name: Copy other manifests
|
||||
if: inputs.copy-other-manifests == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
echo "=== Copying other update manifests ==="
|
||||
|
||||
# Copy other manifests (Windows, Linux) - these don't have the duplicate issue
|
||||
for manifest in latest.yml latest-linux.yml latest-linux-arm64.yml; do
|
||||
found=$(find "${{ inputs.dist-path }}" -name "$manifest" -type f 2>/dev/null | head -1)
|
||||
if [ -n "$found" ]; then
|
||||
echo "Copying $manifest"
|
||||
cp "$found" "${{ inputs.output-path }}/"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "=== Manifest files in ${{ inputs.output-path }} ==="
|
||||
ls -la "${{ inputs.output-path }}"/*.yml 2>/dev/null || echo "No manifest files found"
|
||||
@@ -1,126 +0,0 @@
|
||||
name: 'Setup Node.js Frontend'
|
||||
description: 'Set up Node.js with npm and cached dependencies for the frontend'
|
||||
|
||||
inputs:
|
||||
node-version:
|
||||
description: 'Node.js version to use'
|
||||
required: false
|
||||
default: '24'
|
||||
ignore-scripts:
|
||||
description: 'Whether to use --ignore-scripts flag during npm ci'
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
outputs:
|
||||
cache-hit:
|
||||
description: 'Whether npm cache was hit'
|
||||
value: ${{ steps.cache.outputs.cache-hit }}
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Setup Node.js ${{ inputs.node-version }}
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache-dir
|
||||
shell: bash
|
||||
run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache npm dependencies
|
||||
id: cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.npm-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
# Run npm ci from root to properly handle workspace dependencies.
|
||||
# With npm workspaces, the lock file is at root and dependencies are hoisted there.
|
||||
# Running npm ci in apps/desktop would fail to populate node_modules correctly.
|
||||
run: |
|
||||
if [ "${{ inputs.ignore-scripts }}" == "true" ]; then
|
||||
npm ci --ignore-scripts
|
||||
else
|
||||
npm ci
|
||||
fi
|
||||
|
||||
- name: Link node_modules for electron-builder
|
||||
shell: bash
|
||||
# electron-builder expects node_modules in apps/desktop for native module rebuilding.
|
||||
# With npm workspaces, packages are hoisted to root. Create a link so electron-builder
|
||||
# can find the modules during packaging and code signing.
|
||||
# Uses symlink on Unix, directory junction on Windows (works without admin privileges).
|
||||
#
|
||||
# IMPORTANT: npm workspaces may create a partial node_modules in apps/desktop for
|
||||
# packages that couldn't be hoisted. We must remove it and create a proper link to root.
|
||||
run: |
|
||||
# Verify npm ci succeeded
|
||||
if [ ! -d "node_modules" ]; then
|
||||
echo "::error::Root node_modules does not exist. npm ci may have failed."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Remove any existing node_modules in apps/desktop
|
||||
# This handles: partial directories from npm workspaces, AND broken symlinks
|
||||
if [ -e "apps/desktop/node_modules" ] || [ -L "apps/desktop/node_modules" ]; then
|
||||
# Check if it's a valid symlink pointing to root node_modules
|
||||
if [ -L "apps/desktop/node_modules" ]; then
|
||||
target=$(readlink apps/desktop/node_modules 2>/dev/null || echo "")
|
||||
if [ "$target" = "../../node_modules" ] && [ -d "apps/desktop/node_modules" ]; then
|
||||
echo "Correct symlink already exists: apps/desktop/node_modules -> ../../node_modules"
|
||||
else
|
||||
echo "Removing incorrect/broken symlink (was: $target)..."
|
||||
rm -f "apps/desktop/node_modules"
|
||||
fi
|
||||
else
|
||||
echo "Removing partial node_modules directory created by npm workspaces..."
|
||||
rm -rf "apps/desktop/node_modules"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create link if it doesn't exist or was removed
|
||||
if [ ! -L "apps/desktop/node_modules" ]; then
|
||||
if [ "$RUNNER_OS" == "Windows" ]; then
|
||||
# Use directory junction on Windows (works without admin privileges)
|
||||
# Use PowerShell's New-Item -ItemType Junction for reliable path handling
|
||||
abs_target=$(cygpath -w "$(pwd)/node_modules")
|
||||
link_path=$(cygpath -w "$(pwd)/apps/desktop/node_modules")
|
||||
powershell -Command "New-Item -ItemType Junction -Path '$link_path' -Target '$abs_target'" > /dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Created junction: apps/desktop/node_modules -> $abs_target"
|
||||
else
|
||||
echo "::error::Failed to create directory junction on Windows"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
# Use symlink on Unix (macOS/Linux)
|
||||
if ln -s ../../node_modules apps/desktop/node_modules; then
|
||||
echo "Created symlink: apps/desktop/node_modules -> ../../node_modules"
|
||||
else
|
||||
echo "::error::Failed to create symlink"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Final verification - the link must exist and resolve correctly
|
||||
# Note: On Windows, junctions don't show as symlinks (-L), so we check if the directory exists
|
||||
# and can be listed. On Unix, we also verify it's a symlink.
|
||||
if [ "$RUNNER_OS" != "Windows" ] && [ ! -L "apps/desktop/node_modules" ]; then
|
||||
echo "::error::apps/desktop/node_modules symlink was not created"
|
||||
exit 1
|
||||
fi
|
||||
# Verify the link resolves to a valid directory with content
|
||||
if ! ls apps/desktop/node_modules/electron >/dev/null 2>&1; then
|
||||
echo "::error::apps/desktop/node_modules does not resolve correctly (electron not found)"
|
||||
ls -la apps/desktop/ || true
|
||||
ls apps/desktop/node_modules 2>&1 | head -5 || true
|
||||
exit 1
|
||||
fi
|
||||
count=$(ls apps/desktop/node_modules 2>/dev/null | wc -l)
|
||||
echo "Verified: apps/desktop/node_modules resolves correctly ($count entries)"
|
||||
@@ -1,87 +0,0 @@
|
||||
name: 'Submit macOS Notarization'
|
||||
description: 'Submit a macOS DMG file for Apple notarization asynchronously'
|
||||
|
||||
inputs:
|
||||
apple-id:
|
||||
description: 'Apple ID for notarization'
|
||||
required: true
|
||||
apple-app-specific-password:
|
||||
description: 'Apple app-specific password'
|
||||
required: true
|
||||
apple-team-id:
|
||||
description: 'Apple Team ID'
|
||||
required: true
|
||||
dmg-path:
|
||||
description: 'Path to the dist directory containing the DMG file'
|
||||
required: false
|
||||
default: 'apps/desktop/dist'
|
||||
|
||||
outputs:
|
||||
notarization-id:
|
||||
description: 'The notarization request ID'
|
||||
value: ${{ steps.submit.outputs.notarization_id }}
|
||||
dmg-file:
|
||||
description: 'The DMG filename that was submitted'
|
||||
value: ${{ steps.submit.outputs.dmg_file }}
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Submit notarization (async)
|
||||
id: submit
|
||||
shell: bash
|
||||
env:
|
||||
APPLE_ID: ${{ inputs.apple-id }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ inputs.apple-app-specific-password }}
|
||||
APPLE_TEAM_ID: ${{ inputs.apple-team-id }}
|
||||
DMG_PATH: ${{ inputs.dmg-path }}
|
||||
run: |
|
||||
if [ -z "$APPLE_ID" ]; then
|
||||
echo "Skipping notarization: APPLE_ID not configured"
|
||||
echo "notarization_id=" >> "$GITHUB_OUTPUT"
|
||||
echo "dmg_file=" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Find the DMG file
|
||||
DMG_FILE=$(find "$DMG_PATH" -name "*.dmg" -type f | head -1)
|
||||
if [ -z "$DMG_FILE" ]; then
|
||||
echo "::error::No DMG file found in $DMG_PATH"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Submitting $DMG_FILE for notarization (async)..."
|
||||
|
||||
# Submit for notarization without waiting
|
||||
# Capture both stdout and exit code
|
||||
set +e
|
||||
RESULT=$(xcrun notarytool submit "$DMG_FILE" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--no-wait \
|
||||
--output-format json 2>&1)
|
||||
SUBMIT_EXIT_CODE=$?
|
||||
set -e
|
||||
|
||||
echo "$RESULT"
|
||||
|
||||
# Check if submission command itself failed (not just missing ID)
|
||||
if [ $SUBMIT_EXIT_CODE -ne 0 ]; then
|
||||
echo "::error::notarytool submit failed with exit code $SUBMIT_EXIT_CODE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract the notarization ID from JSON response
|
||||
# jq is always available on macOS runners
|
||||
NOTARIZATION_ID=$(echo "$RESULT" | jq -r '.id // empty' 2>/dev/null)
|
||||
|
||||
if [ -z "$NOTARIZATION_ID" ]; then
|
||||
echo "::error::Failed to get notarization ID from response"
|
||||
echo "Response was: $RESULT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Notarization submitted with ID: $NOTARIZATION_ID"
|
||||
echo "notarization_id=$NOTARIZATION_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "dmg_file=$(basename "$DMG_FILE")" >> "$GITHUB_OUTPUT"
|
||||
+13
-1
@@ -1,8 +1,20 @@
|
||||
version: 2
|
||||
updates:
|
||||
# Python dependencies
|
||||
- package-ecosystem: pip
|
||||
directory: /apps/backend
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- dependencies
|
||||
- python
|
||||
commit-message:
|
||||
prefix: "chore(deps)"
|
||||
|
||||
# npm dependencies
|
||||
- package-ecosystem: npm
|
||||
directory: /apps/desktop
|
||||
directory: /apps/frontend
|
||||
schedule:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
|
||||
+230
-404
@@ -65,262 +65,258 @@ jobs:
|
||||
build-macos-intel:
|
||||
needs: create-tag
|
||||
runs-on: macos-15-intel
|
||||
outputs:
|
||||
notarization_id: ${{ steps.notarize.outputs.notarization-id }}
|
||||
dmg_file: ${{ steps.notarize.outputs.dmg-file }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Use tag for real releases, develop branch for dry runs
|
||||
ref: ${{ github.event.inputs.dry_run == 'true' && 'develop' || format('v{0}', needs.create-tag.outputs.version) }}
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache
|
||||
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.npm-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
|
||||
- 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') }}
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-rust-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/desktop && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
run: cd apps/frontend && npm run build
|
||||
|
||||
- name: Package macOS (Intel)
|
||||
run: |
|
||||
VERSION="${{ needs.create-tag.outputs.version }}"
|
||||
cd apps/desktop && npm run package:mac -- --x64 --config.extraMetadata.version="$VERSION"
|
||||
cd apps/frontend && npm run package:mac -- --x64 --config.extraMetadata.version="$VERSION"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Submit notarization (async)
|
||||
id: notarize
|
||||
uses: ./.github/actions/submit-macos-notarization
|
||||
with:
|
||||
apple-id: ${{ secrets.APPLE_ID }}
|
||||
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
|
||||
- name: Notarize macOS Intel app
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
if [ -z "$APPLE_ID" ]; then
|
||||
echo "Skipping notarization: APPLE_ID not configured"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/frontend
|
||||
for dmg in dist/*.dmg; do
|
||||
echo "Notarizing $dmg..."
|
||||
xcrun notarytool submit "$dmg" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--wait
|
||||
xcrun stapler staple "$dmg"
|
||||
echo "Successfully notarized and stapled $dmg"
|
||||
done
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-intel-builds
|
||||
path: |
|
||||
apps/desktop/dist/*.dmg
|
||||
apps/desktop/dist/*.zip
|
||||
apps/desktop/dist/*.yml
|
||||
apps/frontend/dist/*.dmg
|
||||
apps/frontend/dist/*.zip
|
||||
apps/frontend/dist/*.yml
|
||||
|
||||
# Apple Silicon build on ARM64 runner for native compilation
|
||||
build-macos-arm64:
|
||||
needs: create-tag
|
||||
runs-on: macos-15
|
||||
outputs:
|
||||
notarization_id: ${{ steps.notarize.outputs.notarization-id }}
|
||||
dmg_file: ${{ steps.notarize.outputs.dmg-file }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Use tag for real releases, develop branch for dry runs
|
||||
ref: ${{ github.event.inputs.dry_run == 'true' && 'develop' || format('v{0}', needs.create-tag.outputs.version) }}
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache
|
||||
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.npm-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
|
||||
- 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') }}
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-arm64-3.12.8-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/desktop && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
run: cd apps/frontend && npm run build
|
||||
|
||||
- name: Package macOS (Apple Silicon)
|
||||
run: |
|
||||
VERSION="${{ needs.create-tag.outputs.version }}"
|
||||
cd apps/desktop && npm run package:mac -- --arm64 --config.extraMetadata.version="$VERSION"
|
||||
cd apps/frontend && npm run package:mac -- --arm64 --config.extraMetadata.version="$VERSION"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Submit notarization (async)
|
||||
id: notarize
|
||||
uses: ./.github/actions/submit-macos-notarization
|
||||
with:
|
||||
apple-id: ${{ secrets.APPLE_ID }}
|
||||
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
|
||||
- name: Notarize macOS ARM64 app
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
if [ -z "$APPLE_ID" ]; then
|
||||
echo "Skipping notarization: APPLE_ID not configured"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/frontend
|
||||
for dmg in dist/*.dmg; do
|
||||
echo "Notarizing $dmg..."
|
||||
xcrun notarytool submit "$dmg" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--wait
|
||||
xcrun stapler staple "$dmg"
|
||||
echo "Successfully notarized and stapled $dmg"
|
||||
done
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-arm64-builds
|
||||
path: |
|
||||
apps/desktop/dist/*.dmg
|
||||
apps/desktop/dist/*.zip
|
||||
apps/desktop/dist/*.yml
|
||||
apps/frontend/dist/*.dmg
|
||||
apps/frontend/dist/*.zip
|
||||
apps/frontend/dist/*.yml
|
||||
|
||||
build-windows:
|
||||
needs: create-tag
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
id-token: write # Required for OIDC authentication with Azure
|
||||
contents: read
|
||||
env:
|
||||
# Job-level env so AZURE_CLIENT_ID is available for step-level if conditions
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Use tag for real releases, develop branch for dry runs
|
||||
ref: ${{ github.event.inputs.dry_run == 'true' && 'develop' || format('v{0}', needs.create-tag.outputs.version) }}
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache
|
||||
shell: bash
|
||||
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.npm-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
|
||||
- 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') }}
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/desktop && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
run: cd apps/frontend && npm run build
|
||||
|
||||
- name: Package Windows
|
||||
shell: bash
|
||||
run: |
|
||||
VERSION="${{ needs.create-tag.outputs.version }}"
|
||||
cd apps/desktop && npm run package:win -- --config.extraMetadata.version="$VERSION"
|
||||
cd apps/frontend && npm run package:win -- --config.extraMetadata.version="$VERSION"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Disable electron-builder's built-in signing (we use Azure Trusted Signing instead)
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: false
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Azure Login (OIDC)
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Sign Windows executable with Azure Trusted Signing
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
uses: azure/trusted-signing-action@v0.5.11
|
||||
with:
|
||||
endpoint: https://neu.codesigning.azure.net/
|
||||
trusted-signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
certificate-profile-name: ${{ secrets.AZURE_CERTIFICATE_PROFILE }}
|
||||
files-folder: apps/desktop/dist
|
||||
files-folder-filter: exe
|
||||
file-digest: SHA256
|
||||
timestamp-rfc3161: http://timestamp.acs.microsoft.com
|
||||
timestamp-digest: SHA256
|
||||
|
||||
- name: Verify Windows executable is signed
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
cd apps/desktop/dist
|
||||
$exeFile = Get-ChildItem -Filter "*.exe" | Select-Object -First 1
|
||||
if ($exeFile) {
|
||||
Write-Host "Verifying signature on $($exeFile.Name)..."
|
||||
$sig = Get-AuthenticodeSignature -FilePath $exeFile.FullName
|
||||
if ($sig.Status -ne 'Valid') {
|
||||
Write-Host "::error::Signature verification failed: $($sig.Status)"
|
||||
Write-Host "::error::Status Message: $($sig.StatusMessage)"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "✅ Signature verified successfully"
|
||||
Write-Host " Subject: $($sig.SignerCertificate.Subject)"
|
||||
Write-Host " Issuer: $($sig.SignerCertificate.Issuer)"
|
||||
Write-Host " Thumbprint: $($sig.SignerCertificate.Thumbprint)"
|
||||
} else {
|
||||
Write-Host "::error::No .exe file found to verify"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Regenerate checksums after signing
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
cd apps/desktop/dist
|
||||
|
||||
# Find the installer exe (electron-builder names it with "Setup" or just the app name)
|
||||
# electron-builder produces one installer exe per build
|
||||
$exeFiles = Get-ChildItem -Filter "*.exe"
|
||||
if ($exeFiles.Count -eq 0) {
|
||||
Write-Host "::error::No .exe files found in dist folder"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Found $($exeFiles.Count) exe file(s): $($exeFiles.Name -join ', ')"
|
||||
|
||||
$ymlFile = "latest.yml"
|
||||
if (-not (Test-Path $ymlFile)) {
|
||||
Write-Host "::error::$ymlFile not found - cannot update checksums"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$content = Get-Content $ymlFile -Raw
|
||||
$originalContent = $content
|
||||
|
||||
# Process each exe file and update its hash in latest.yml
|
||||
foreach ($exeFile in $exeFiles) {
|
||||
Write-Host "Processing $($exeFile.Name)..."
|
||||
|
||||
# Compute SHA512 hash and convert to base64 (electron-builder format)
|
||||
$bytes = [System.IO.File]::ReadAllBytes($exeFile.FullName)
|
||||
$sha512 = [System.Security.Cryptography.SHA512]::Create()
|
||||
$hashBytes = $sha512.ComputeHash($bytes)
|
||||
$hash = [System.Convert]::ToBase64String($hashBytes)
|
||||
$size = $exeFile.Length
|
||||
|
||||
Write-Host " Hash: $hash"
|
||||
Write-Host " Size: $size"
|
||||
}
|
||||
|
||||
# For electron-builder, latest.yml has a single file entry for the installer
|
||||
# Update the sha512 and size for the primary exe (first one, typically the installer)
|
||||
$primaryExe = $exeFiles | Select-Object -First 1
|
||||
$bytes = [System.IO.File]::ReadAllBytes($primaryExe.FullName)
|
||||
$sha512 = [System.Security.Cryptography.SHA512]::Create()
|
||||
$hashBytes = $sha512.ComputeHash($bytes)
|
||||
$hash = [System.Convert]::ToBase64String($hashBytes)
|
||||
$size = $primaryExe.Length
|
||||
|
||||
# Update sha512 hash (base64 pattern: alphanumeric, +, /, =)
|
||||
$content = $content -replace 'sha512: [A-Za-z0-9+/=]+', "sha512: $hash"
|
||||
# Update size
|
||||
$content = $content -replace 'size: \d+', "size: $size"
|
||||
|
||||
if ($content -eq $originalContent) {
|
||||
Write-Host "::error::Checksum replacement failed - content unchanged. Check if latest.yml format has changed."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Set-Content -Path $ymlFile -Value $content -NoNewline
|
||||
Write-Host "✅ Updated $ymlFile with new base64 hash and size for $($primaryExe.Name)"
|
||||
|
||||
- name: Skip signing notice
|
||||
if: env.AZURE_CLIENT_ID == ''
|
||||
run: echo "::warning::Windows signing skipped - AZURE_CLIENT_ID not configured. The .exe will be unsigned."
|
||||
CSC_LINK: ${{ secrets.WIN_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-builds
|
||||
path: |
|
||||
apps/desktop/dist/*.exe
|
||||
apps/desktop/dist/*.yml
|
||||
apps/frontend/dist/*.exe
|
||||
apps/frontend/dist/*.yml
|
||||
|
||||
build-linux:
|
||||
needs: create-tag
|
||||
@@ -331,92 +327,76 @@ 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 Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Flatpak and verification tools
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache
|
||||
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.npm-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
|
||||
- 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 squashfs-tools
|
||||
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') }}
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/desktop && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
run: cd apps/frontend && npm run build
|
||||
|
||||
- name: Package Linux
|
||||
run: |
|
||||
VERSION="${{ needs.create-tag.outputs.version }}"
|
||||
cd apps/desktop && npm run package:linux -- --config.extraMetadata.version="$VERSION"
|
||||
cd apps/frontend && npm run package:linux -- --config.extraMetadata.version="$VERSION"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Verify Linux packages
|
||||
run: cd apps/desktop && npm run verify:linux
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-builds
|
||||
path: |
|
||||
apps/desktop/dist/*.AppImage
|
||||
apps/desktop/dist/*.deb
|
||||
apps/desktop/dist/*.flatpak
|
||||
apps/desktop/dist/*.yml
|
||||
|
||||
# Finalize macOS notarization (runs in parallel with Windows/Linux builds)
|
||||
finalize-notarization:
|
||||
needs: [build-macos-intel, build-macos-arm64]
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download Intel DMG
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: macos-intel-builds
|
||||
path: intel
|
||||
|
||||
- name: Download ARM64 DMG
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: macos-arm64-builds
|
||||
path: arm64
|
||||
|
||||
- name: Wait for notarization and staple
|
||||
uses: ./.github/actions/finalize-macos-notarization
|
||||
with:
|
||||
apple-id: ${{ secrets.APPLE_ID }}
|
||||
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
|
||||
intel-notarization-id: ${{ needs.build-macos-intel.outputs.notarization_id }}
|
||||
arm64-notarization-id: ${{ needs.build-macos-arm64.outputs.notarization_id }}
|
||||
intel-dmg-file: ${{ needs.build-macos-intel.outputs.dmg_file }}
|
||||
arm64-dmg-file: ${{ needs.build-macos-arm64.outputs.dmg_file }}
|
||||
|
||||
- name: Upload stapled Intel DMG
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-intel-stapled
|
||||
path: intel/*.dmg
|
||||
|
||||
- name: Upload stapled ARM64 DMG
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-arm64-stapled
|
||||
path: arm64/*.dmg
|
||||
apps/frontend/dist/*.AppImage
|
||||
apps/frontend/dist/*.deb
|
||||
apps/frontend/dist/*.flatpak
|
||||
apps/frontend/dist/*.yml
|
||||
|
||||
create-release:
|
||||
needs: [create-tag, finalize-notarization, build-windows, build-linux]
|
||||
needs: [create-tag, build-macos-intel, build-macos-arm64, build-windows, build-linux]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.inputs.dry_run != 'true' }}
|
||||
permissions:
|
||||
@@ -428,28 +408,14 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
|
||||
- name: Flatten binary artifacts
|
||||
- name: Flatten and validate artifacts
|
||||
run: |
|
||||
mkdir -p release-assets
|
||||
|
||||
# Copy stapled macOS DMGs (from finalize-notarization job)
|
||||
# Validate that stapled DMGs exist before copying
|
||||
if ! find dist/macos-intel-stapled dist/macos-arm64-stapled -type f -name "*.dmg" 2>/dev/null | grep -q .; then
|
||||
echo "::warning::No stapled DMGs found. Using un-stapled DMGs from build artifacts."
|
||||
find dist/macos-intel-builds dist/macos-arm64-builds -type f -name "*.dmg" -exec cp {} release-assets/ \; 2>/dev/null || true
|
||||
else
|
||||
find dist/macos-intel-stapled dist/macos-arm64-stapled -type f -name "*.dmg" -exec cp {} release-assets/ \; 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Copy other macOS artifacts (zip, yml, blockmap for delta updates) from original build
|
||||
find dist/macos-intel-builds dist/macos-arm64-builds -type f \( -name "*.zip" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
|
||||
|
||||
# Copy Windows and Linux artifacts (including blockmap for delta updates)
|
||||
find dist/windows-builds dist/linux-builds -type f \( -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
|
||||
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/ \;
|
||||
|
||||
# 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)
|
||||
@@ -458,84 +424,9 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Found $artifact_count binary artifact(s):"
|
||||
echo "Found $artifact_count artifact(s):"
|
||||
ls -la release-assets/
|
||||
|
||||
# Merge macOS manifests from Intel and ARM64 builds
|
||||
# See: https://github.com/electron-userland/electron-builder/issues/5592
|
||||
- name: Merge macOS manifests
|
||||
uses: ./.github/actions/merge-macos-manifests
|
||||
with:
|
||||
dist-path: dist
|
||||
output-path: release-assets
|
||||
copy-other-manifests: 'true'
|
||||
|
||||
- name: Rename and validate beta manifests
|
||||
run: |
|
||||
cd release-assets
|
||||
|
||||
echo "=== Current manifest files ==="
|
||||
ls -la *.yml 2>/dev/null || echo "No yml files found yet"
|
||||
|
||||
# electron-builder generates latest*.yml files by default
|
||||
# For beta channel, electron-updater expects beta*.yml files
|
||||
# Rename: latest.yml -> beta.yml, latest-mac.yml -> beta-mac.yml, latest-linux.yml -> beta-linux.yml
|
||||
|
||||
# Windows: latest.yml -> beta.yml
|
||||
if [ -f "latest.yml" ]; then
|
||||
echo "Renaming latest.yml -> beta.yml (Windows)"
|
||||
mv latest.yml beta.yml
|
||||
fi
|
||||
|
||||
# macOS: latest-mac.yml -> beta-mac.yml
|
||||
if [ -f "latest-mac.yml" ]; then
|
||||
echo "Renaming latest-mac.yml -> beta-mac.yml (macOS)"
|
||||
mv latest-mac.yml beta-mac.yml
|
||||
fi
|
||||
|
||||
# Linux: latest-linux.yml -> beta-linux.yml
|
||||
if [ -f "latest-linux.yml" ]; then
|
||||
echo "Renaming latest-linux.yml -> beta-linux.yml (Linux)"
|
||||
mv latest-linux.yml beta-linux.yml
|
||||
fi
|
||||
|
||||
# Linux ARM64: latest-linux-arm64.yml -> beta-linux-arm64.yml (if exists)
|
||||
if [ -f "latest-linux-arm64.yml" ]; then
|
||||
echo "Renaming latest-linux-arm64.yml -> beta-linux-arm64.yml (Linux ARM64)"
|
||||
mv latest-linux-arm64.yml beta-linux-arm64.yml
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "=== Beta manifest files after rename ==="
|
||||
ls -la *.yml 2>/dev/null || echo "No yml files found"
|
||||
|
||||
# Validate required beta manifests exist
|
||||
missing_manifests=""
|
||||
|
||||
if [ ! -f "beta-mac.yml" ]; then
|
||||
missing_manifests="$missing_manifests beta-mac.yml"
|
||||
fi
|
||||
|
||||
if [ ! -f "beta.yml" ]; then
|
||||
missing_manifests="$missing_manifests beta.yml"
|
||||
fi
|
||||
|
||||
if [ ! -f "beta-linux.yml" ]; then
|
||||
missing_manifests="$missing_manifests beta-linux.yml"
|
||||
fi
|
||||
|
||||
if [ -n "$missing_manifests" ]; then
|
||||
echo "::error::Missing required beta manifests:$missing_manifests"
|
||||
echo "::error::Auto-update will fail on affected platforms without these files!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "All required beta manifests present:"
|
||||
echo " - beta-mac.yml (macOS)"
|
||||
echo " - beta.yml (Windows)"
|
||||
echo " - beta-linux.yml (Linux)"
|
||||
|
||||
- name: Generate checksums
|
||||
run: |
|
||||
cd release-assets
|
||||
@@ -570,89 +461,24 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
dry-run-summary:
|
||||
needs: [create-tag, finalize-notarization, build-windows, build-linux]
|
||||
needs: [create-tag, build-macos-intel, build-macos-arm64, build-windows, build-linux]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.event.inputs.dry_run == 'true' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
|
||||
- name: Flatten binary artifacts
|
||||
run: |
|
||||
mkdir -p release-assets
|
||||
|
||||
# Copy stapled macOS DMGs (from finalize-notarization job)
|
||||
find dist/macos-intel-stapled dist/macos-arm64-stapled -type f -name "*.dmg" -exec cp {} release-assets/ \; 2>/dev/null || true
|
||||
|
||||
# Copy other macOS artifacts (zip, yml, blockmap for delta updates) from original build
|
||||
find dist/macos-intel-builds dist/macos-arm64-builds -type f \( -name "*.zip" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
|
||||
|
||||
# Copy Windows and Linux artifacts (including blockmap for delta updates)
|
||||
find dist/windows-builds dist/linux-builds -type f \( -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
|
||||
|
||||
# Merge macOS manifests (same logic as real release)
|
||||
- name: Merge macOS manifests
|
||||
uses: ./.github/actions/merge-macos-manifests
|
||||
with:
|
||||
dist-path: dist
|
||||
output-path: release-assets
|
||||
copy-other-manifests: 'true'
|
||||
|
||||
- name: Validate and rename beta manifests
|
||||
run: |
|
||||
cd release-assets
|
||||
|
||||
# Rename latest*.yml to beta*.yml
|
||||
[ -f "latest.yml" ] && mv latest.yml beta.yml
|
||||
[ -f "latest-mac.yml" ] && mv latest-mac.yml beta-mac.yml
|
||||
[ -f "latest-linux.yml" ] && mv latest-linux.yml beta-linux.yml
|
||||
[ -f "latest-linux-arm64.yml" ] && mv latest-linux-arm64.yml beta-linux-arm64.yml
|
||||
|
||||
# Validate required manifests
|
||||
missing=""
|
||||
[ ! -f "beta-mac.yml" ] && missing="$missing beta-mac.yml"
|
||||
[ ! -f "beta.yml" ] && missing="$missing beta.yml"
|
||||
[ ! -f "beta-linux.yml" ] && missing="$missing beta-linux.yml"
|
||||
|
||||
if [ -n "$missing" ]; then
|
||||
echo "::warning::DRY RUN: Missing required beta manifests:$missing"
|
||||
echo "MANIFEST_STATUS=FAILED" >> $GITHUB_ENV
|
||||
else
|
||||
echo "MANIFEST_STATUS=PASSED" >> $GITHUB_ENV
|
||||
|
||||
# Show merged manifest content for verification
|
||||
echo ""
|
||||
echo "=== beta-mac.yml content (should have both architectures) ==="
|
||||
cat beta-mac.yml
|
||||
fi
|
||||
|
||||
- name: Dry run summary
|
||||
run: |
|
||||
echo "## Beta Release Dry Run Complete" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version:** ${{ needs.create-tag.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
echo "### Build Artifacts" >> $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
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
echo "### Update Manifests (Required for Auto-Update)" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "$MANIFEST_STATUS" = "PASSED" ]; then
|
||||
echo "All required beta manifests present:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- beta-mac.yml (macOS)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- beta.yml (Windows)" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- beta-linux.yml (Linux)" >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "**WARNING: Missing required manifests! Auto-update will fail.**" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Check build logs for details." >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
echo "To create a real release, run this workflow again with dry_run unchecked." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -10,11 +10,11 @@ on:
|
||||
electron_version:
|
||||
description: 'Electron version to build for'
|
||||
required: false
|
||||
default: '40.0.0'
|
||||
default: '39.2.6'
|
||||
|
||||
env:
|
||||
# Default Electron version - update when upgrading Electron in package.json
|
||||
ELECTRON_VERSION: ${{ github.event.inputs.electron_version || '40.0.0' }}
|
||||
ELECTRON_VERSION: ${{ github.event.inputs.electron_version || '39.2.6' }}
|
||||
|
||||
jobs:
|
||||
build-windows:
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
uses: microsoft/setup-msbuild@v2
|
||||
|
||||
- name: Install node-pty and rebuild for Electron
|
||||
working-directory: apps/desktop
|
||||
working-directory: apps/frontend
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Install only node-pty
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
npx @electron/rebuild --version $env:ELECTRON_VERSION --module-dir node_modules/node-pty --arch ${{ matrix.arch }}
|
||||
|
||||
- name: Package prebuilt binaries
|
||||
working-directory: apps/desktop
|
||||
working-directory: apps/frontend
|
||||
shell: pwsh
|
||||
run: |
|
||||
$electronAbi = (npx electron-abi $env:ELECTRON_VERSION)
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
Get-ChildItem $prebuildDir
|
||||
|
||||
- name: Create archive
|
||||
working-directory: apps/desktop
|
||||
working-directory: apps/frontend
|
||||
shell: pwsh
|
||||
run: |
|
||||
$electronAbi = (npx electron-abi $env:ELECTRON_VERSION)
|
||||
@@ -93,14 +93,14 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: node-pty-win32-${{ matrix.arch }}
|
||||
path: apps/desktop/node-pty-*.zip
|
||||
path: apps/frontend/node-pty-*.zip
|
||||
retention-days: 90
|
||||
|
||||
- name: Upload to release
|
||||
if: github.event_name == 'release'
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: apps/desktop/node-pty-*.zip
|
||||
files: apps/frontend/node-pty-*.zip
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
|
||||
+83
-90
@@ -1,31 +1,10 @@
|
||||
# Cross-Platform CI Pipeline
|
||||
#
|
||||
# Tests on all target platforms (Linux, Windows, macOS) to catch
|
||||
# platform-specific bugs before they merge. ALL platforms must pass.
|
||||
#
|
||||
# Optimized: Frontend-only matrix, path filters to skip on docs-only changes.
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- 'package*.json'
|
||||
- 'tsconfig*.json'
|
||||
- 'biome.jsonc'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/actions/**'
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- 'package*.json'
|
||||
- 'tsconfig*.json'
|
||||
- 'biome.jsonc'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/actions/**'
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -34,86 +13,100 @@ concurrency:
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
# --------------------------------------------------------------------------
|
||||
# Frontend Tests - All Platforms
|
||||
# --------------------------------------------------------------------------
|
||||
test-frontend:
|
||||
name: test-frontend (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
# Python tests
|
||||
test-python:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
python-version: ['3.12', '3.13']
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js frontend
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
ignore-scripts: 'true'
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Run TypeScript type check
|
||||
working-directory: apps/desktop
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: apps/backend
|
||||
run: |
|
||||
uv venv
|
||||
uv pip install -r requirements.txt
|
||||
uv pip install -r ../../tests/requirements-test.txt
|
||||
|
||||
- name: Run tests
|
||||
working-directory: apps/backend
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}/apps/backend
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
pytest ../../tests/ -v --tb=short -x
|
||||
|
||||
- name: Run tests with coverage
|
||||
if: matrix.python-version == '3.12'
|
||||
working-directory: apps/backend
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}/apps/backend
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
pytest ../../tests/ -v --cov=. --cov-report=xml --cov-report=term-missing --cov-fail-under=20
|
||||
|
||||
- name: Upload coverage reports
|
||||
if: matrix.python-version == '3.12'
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
file: ./apps/backend/coverage.xml
|
||||
fail_ci_if_error: false
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
# Frontend lint, typecheck, test, and build
|
||||
test-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache
|
||||
run: echo "dir=$(npm config get cache)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.npm-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: apps/frontend
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Lint
|
||||
working-directory: apps/frontend
|
||||
run: npm run lint
|
||||
|
||||
- name: Type check
|
||||
working-directory: apps/frontend
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Run unit tests with coverage
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
working-directory: apps/desktop
|
||||
run: npm run test:coverage
|
||||
- name: Run tests
|
||||
working-directory: apps/frontend
|
||||
run: npm run test
|
||||
|
||||
- name: Run unit tests
|
||||
if: matrix.os != 'ubuntu-latest'
|
||||
working-directory: apps/desktop
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Run integration tests
|
||||
working-directory: apps/desktop
|
||||
run: npm run test:integration
|
||||
|
||||
- name: Upload coverage report
|
||||
if: matrix.os == 'ubuntu-latest' && always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-report
|
||||
path: apps/desktop/coverage/
|
||||
retention-days: 14
|
||||
|
||||
- name: Coverage PR comment
|
||||
if: matrix.os == 'ubuntu-latest' && github.event_name == 'pull_request'
|
||||
uses: davelosert/vitest-coverage-report-action@v2
|
||||
with:
|
||||
working-directory: apps/desktop
|
||||
json-summary-path: coverage/coverage-summary.json
|
||||
json-final-path: coverage/coverage-final.json
|
||||
|
||||
- name: Build application
|
||||
working-directory: apps/desktop
|
||||
- name: Build
|
||||
working-directory: apps/frontend
|
||||
run: npm run build
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Gate Job - Single check for branch protection
|
||||
# --------------------------------------------------------------------------
|
||||
ci-complete:
|
||||
name: CI Complete
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-frontend]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Check all CI jobs passed
|
||||
run: |
|
||||
echo "CI Job Results:"
|
||||
echo " test-frontend: ${{ needs.test-frontend.result }}"
|
||||
echo ""
|
||||
|
||||
if [[ "${{ needs.test-frontend.result }}" != "success" ]]; then
|
||||
echo "❌ One or more CI jobs failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ All CI checks passed"
|
||||
|
||||
@@ -3,7 +3,6 @@ name: Discord Release Notification
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
discord-notification:
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
# E2E Tests
|
||||
#
|
||||
# Runs Playwright E2E tests for the Electron desktop app on Linux.
|
||||
# Ubuntu-only since Electron E2E is platform-agnostic (Chromium renderer).
|
||||
# Non-blocking initially — separate from ci-complete gate while stabilizing.
|
||||
|
||||
name: E2E
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- '.github/workflows/e2e.yml'
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- '.github/workflows/e2e.yml'
|
||||
|
||||
concurrency:
|
||||
group: e2e-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js frontend
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: apps/desktop
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Build application
|
||||
working-directory: apps/desktop
|
||||
run: npm run build
|
||||
|
||||
- name: Run E2E tests
|
||||
working-directory: apps/desktop
|
||||
continue-on-error: true # Non-blocking while stabilizing — pre-existing __dirname ESM issue
|
||||
run: xvfb-run --auto-servernum npm run test:e2e
|
||||
|
||||
- name: Upload E2E report
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-report
|
||||
path: |
|
||||
apps/desktop/e2e/playwright-report/
|
||||
apps/desktop/e2e/test-results/
|
||||
retention-days: 14
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Add area label from form
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
|
||||
+13
-39
@@ -3,58 +3,32 @@ name: Lint
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'apps/desktop/**'
|
||||
- '.github/workflows/lint.yml'
|
||||
- '.github/actions/**'
|
||||
- 'apps/desktop/biome.jsonc'
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'apps/desktop/**'
|
||||
- '.github/workflows/lint.yml'
|
||||
- '.github/actions/**'
|
||||
- 'apps/desktop/biome.jsonc'
|
||||
|
||||
concurrency:
|
||||
group: lint-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# TypeScript/JavaScript linting (Biome) - 15-25x faster than ESLint
|
||||
typescript:
|
||||
name: TypeScript (Biome)
|
||||
# Python linting
|
||||
python:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Pin version to match package.json for consistent behavior
|
||||
- name: Setup Biome
|
||||
uses: biomejs/setup-biome@v2
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
version: 2.3.11
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Run Biome
|
||||
working-directory: apps/desktop
|
||||
# biome ci fails on errors by default; warnings are reported but don't block
|
||||
# Use --error-on-warnings when ready to enforce all rules
|
||||
run: biome ci .
|
||||
# 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
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Gate Job - Single check for branch protection
|
||||
# --------------------------------------------------------------------------
|
||||
lint-complete:
|
||||
name: Lint Complete
|
||||
runs-on: ubuntu-latest
|
||||
needs: [typescript]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Check lint results
|
||||
run: |
|
||||
if [[ "${{ needs.typescript.result }}" != "success" ]]; then
|
||||
echo "❌ Linting failed"
|
||||
echo " TypeScript: ${{ needs.typescript.result }}"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ All linting passed"
|
||||
- name: Run ruff check
|
||||
run: ruff check apps/backend/ --output-format=github
|
||||
|
||||
- name: Run ruff format check
|
||||
run: ruff format apps/backend/ --check --diff
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
name: PR Auto Label
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: pr-auto-label-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
name: Auto Label PR
|
||||
runs-on: ubuntu-latest
|
||||
# Don't run on fork PRs (they can't write labels)
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Auto-label PR
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const pr = context.payload.pull_request;
|
||||
const prNumber = pr.number;
|
||||
const title = pr.title;
|
||||
|
||||
console.log(`::group::PR #${prNumber} - Auto-labeling`);
|
||||
console.log(`Title: ${title}`);
|
||||
|
||||
const labelsToAdd = new Set();
|
||||
const labelsToRemove = new Set();
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// TYPE LABELS (from PR title - Conventional Commits)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
const typeMap = {
|
||||
'feat': 'feature',
|
||||
'fix': 'bug',
|
||||
'docs': 'documentation',
|
||||
'refactor': 'refactor',
|
||||
'test': 'test',
|
||||
'ci': 'ci',
|
||||
'chore': 'chore',
|
||||
'perf': 'performance',
|
||||
'style': 'style',
|
||||
'build': 'build'
|
||||
};
|
||||
|
||||
const typeMatch = title.match(/^(\w+)(\(.+?\))?(!)?:/);
|
||||
if (typeMatch) {
|
||||
const type = typeMatch[1].toLowerCase();
|
||||
const isBreaking = typeMatch[3] === '!';
|
||||
|
||||
if (typeMap[type]) {
|
||||
labelsToAdd.add(typeMap[type]);
|
||||
console.log(` 📝 Type: ${type} → ${typeMap[type]}`);
|
||||
}
|
||||
|
||||
if (isBreaking) {
|
||||
labelsToAdd.add('breaking-change');
|
||||
console.log(` ⚠️ Breaking change detected`);
|
||||
}
|
||||
} else {
|
||||
console.log(` ⚠️ No conventional commit prefix found in title`);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// AREA LABELS (from changed files)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
let files = [];
|
||||
try {
|
||||
const { data } = await github.rest.pulls.listFiles({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
per_page: 100
|
||||
});
|
||||
files = data;
|
||||
} catch (e) {
|
||||
console.log(` ⚠️ Could not fetch files: ${e.message}`);
|
||||
}
|
||||
|
||||
const areas = {
|
||||
frontend: false,
|
||||
backend: false,
|
||||
ci: false,
|
||||
docs: false,
|
||||
tests: false
|
||||
};
|
||||
|
||||
for (const file of files) {
|
||||
const path = file.filename;
|
||||
if (path.startsWith('apps/frontend/')) areas.frontend = true;
|
||||
if (path.startsWith('apps/backend/')) areas.backend = true;
|
||||
if (path.startsWith('.github/')) areas.ci = true;
|
||||
if (path.endsWith('.md') || path.startsWith('docs/')) areas.docs = true;
|
||||
if (path.startsWith('tests/') || path.includes('.test.') || path.includes('.spec.')) areas.tests = true;
|
||||
}
|
||||
|
||||
// Determine area label (mutually exclusive)
|
||||
const areaLabels = ['area/frontend', 'area/backend', 'area/fullstack', 'area/ci'];
|
||||
|
||||
if (areas.frontend && areas.backend) {
|
||||
labelsToAdd.add('area/fullstack');
|
||||
areaLabels.filter(l => l !== 'area/fullstack').forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📁 Area: fullstack (${files.length} files)`);
|
||||
} else if (areas.frontend) {
|
||||
labelsToAdd.add('area/frontend');
|
||||
areaLabels.filter(l => l !== 'area/frontend').forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📁 Area: frontend (${files.length} files)`);
|
||||
} else if (areas.backend) {
|
||||
labelsToAdd.add('area/backend');
|
||||
areaLabels.filter(l => l !== 'area/backend').forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📁 Area: backend (${files.length} files)`);
|
||||
} else if (areas.ci) {
|
||||
labelsToAdd.add('area/ci');
|
||||
areaLabels.filter(l => l !== 'area/ci').forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📁 Area: ci (${files.length} files)`);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// SIZE LABELS (from lines changed)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
const additions = pr.additions || 0;
|
||||
const deletions = pr.deletions || 0;
|
||||
const totalLines = additions + deletions;
|
||||
|
||||
const sizeLabels = ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'];
|
||||
let sizeLabel;
|
||||
|
||||
if (totalLines < 10) sizeLabel = 'size/XS';
|
||||
else if (totalLines < 100) sizeLabel = 'size/S';
|
||||
else if (totalLines < 500) sizeLabel = 'size/M';
|
||||
else if (totalLines < 1000) sizeLabel = 'size/L';
|
||||
else sizeLabel = 'size/XL';
|
||||
|
||||
labelsToAdd.add(sizeLabel);
|
||||
sizeLabels.filter(l => l !== sizeLabel).forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📏 Size: ${sizeLabel} (+${additions}/-${deletions} = ${totalLines} lines)`);
|
||||
|
||||
console.log('::endgroup::');
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// APPLY LABELS
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
console.log(`::group::Applying labels`);
|
||||
|
||||
// Remove old labels (in parallel)
|
||||
const removeArray = [...labelsToRemove].filter(l => !labelsToAdd.has(l));
|
||||
if (removeArray.length > 0) {
|
||||
const removePromises = removeArray.map(async (label) => {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: label
|
||||
});
|
||||
console.log(` ✓ Removed: ${label}`);
|
||||
} catch (e) {
|
||||
if (e.status !== 404) {
|
||||
console.log(` ⚠ Could not remove ${label}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
await Promise.all(removePromises);
|
||||
}
|
||||
|
||||
// Add new labels
|
||||
const addArray = [...labelsToAdd];
|
||||
if (addArray.length > 0) {
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: addArray
|
||||
});
|
||||
console.log(` ✓ Added: ${addArray.join(', ')}`);
|
||||
} catch (e) {
|
||||
// Some labels might not exist
|
||||
if (e.status === 404) {
|
||||
core.warning(`Some labels do not exist. Please create them in repository settings.`);
|
||||
// Try adding one by one
|
||||
for (const label of addArray) {
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: [label]
|
||||
});
|
||||
} catch (e2) {
|
||||
console.log(` ⚠ Label '${label}' does not exist`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('::endgroup::');
|
||||
|
||||
// Summary
|
||||
console.log(`✅ PR #${prNumber} labeled: ${addArray.join(', ')}`);
|
||||
|
||||
// Write job summary
|
||||
core.summary
|
||||
.addHeading(`PR #${prNumber} Auto-Labels`, 3)
|
||||
.addTable([
|
||||
[{data: 'Category', header: true}, {data: 'Label', header: true}],
|
||||
['Type', typeMatch ? typeMap[typeMatch[1].toLowerCase()] || 'none' : 'none'],
|
||||
['Area', areas.frontend && areas.backend ? 'fullstack' : areas.frontend ? 'frontend' : areas.backend ? 'backend' : 'other'],
|
||||
['Size', sizeLabel]
|
||||
])
|
||||
.addRaw(`\n**Files changed:** ${files.length}\n`)
|
||||
.addRaw(`**Lines:** +${additions} / -${deletions}\n`);
|
||||
await core.summary.write();
|
||||
@@ -1,295 +0,0 @@
|
||||
name: PR Labeler
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
concurrency:
|
||||
group: pr-labeler-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
name: Auto Label PR
|
||||
runs-on: ubuntu-latest
|
||||
# Security: Prevent fork PRs from modifying labels (they don't have write access)
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Label PR
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// CONFIGURATION - Single source of truth for all settings
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const CONFIG = {
|
||||
// Size thresholds (lines changed)
|
||||
SIZE_THRESHOLDS: {
|
||||
XS: 10,
|
||||
S: 100,
|
||||
M: 500,
|
||||
L: 1000
|
||||
},
|
||||
|
||||
// Conventional commit type mappings
|
||||
TYPE_MAP: Object.freeze({
|
||||
'feat': 'feature',
|
||||
'fix': 'bug',
|
||||
'docs': 'documentation',
|
||||
'refactor': 'refactor',
|
||||
'test': 'test',
|
||||
'ci': 'ci',
|
||||
'chore': 'chore',
|
||||
'perf': 'performance',
|
||||
'style': 'style',
|
||||
'build': 'build'
|
||||
}),
|
||||
|
||||
// Area detection paths
|
||||
AREA_PATHS: Object.freeze({
|
||||
frontend: 'apps/desktop/',
|
||||
ci: '.github/'
|
||||
}),
|
||||
|
||||
// Label definitions
|
||||
LABELS: Object.freeze({
|
||||
SIZE: ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'],
|
||||
AREA: ['area/frontend', 'area/ci']
|
||||
}),
|
||||
|
||||
// Pagination
|
||||
MAX_FILES_PER_PAGE: 100
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// HELPER FUNCTIONS - Small, focused, single responsibility
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Safely parse conventional commit type from PR title
|
||||
* @param {string} title - PR title
|
||||
* @returns {{type: string|null, isBreaking: boolean}}
|
||||
*/
|
||||
function parseConventionalCommit(title) {
|
||||
if (!title || typeof title !== 'string') {
|
||||
return { type: null, isBreaking: false };
|
||||
}
|
||||
|
||||
// Limit input length to prevent ReDoS attacks
|
||||
const safeTitle = title.slice(0, 200);
|
||||
const match = safeTitle.match(/^(\w{1,20})(\([^)]{0,50}\))?(!)?:/);
|
||||
|
||||
if (!match) {
|
||||
return { type: null, isBreaking: false };
|
||||
}
|
||||
|
||||
return {
|
||||
type: match[1].toLowerCase(),
|
||||
isBreaking: match[3] === '!'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine size label based on lines changed
|
||||
* @param {number} totalLines - Total lines changed
|
||||
* @returns {string} Size label
|
||||
*/
|
||||
function determineSizeLabel(totalLines) {
|
||||
const { SIZE_THRESHOLDS } = CONFIG;
|
||||
|
||||
if (totalLines < SIZE_THRESHOLDS.XS) return 'size/XS';
|
||||
if (totalLines < SIZE_THRESHOLDS.S) return 'size/S';
|
||||
if (totalLines < SIZE_THRESHOLDS.M) return 'size/M';
|
||||
if (totalLines < SIZE_THRESHOLDS.L) return 'size/L';
|
||||
return 'size/XL';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect areas affected by file changes
|
||||
* @param {Array} files - List of changed files
|
||||
* @returns {{frontend: boolean, ci: boolean}}
|
||||
*/
|
||||
function detectAreas(files) {
|
||||
const areas = { frontend: false, ci: false };
|
||||
const { AREA_PATHS } = CONFIG;
|
||||
|
||||
for (const file of files) {
|
||||
const path = file.filename || '';
|
||||
if (path.startsWith(AREA_PATHS.frontend)) areas.frontend = true;
|
||||
if (path.startsWith(AREA_PATHS.ci)) areas.ci = true;
|
||||
}
|
||||
|
||||
return areas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine area label based on detected areas
|
||||
* @param {{frontend: boolean, ci: boolean}} areas
|
||||
* @returns {string|null} Area label or null
|
||||
*/
|
||||
function determineAreaLabel(areas) {
|
||||
if (areas.frontend) return 'area/frontend';
|
||||
if (areas.ci) return 'area/ci';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove labels from PR (with error handling)
|
||||
* @param {Array} labels - Labels to remove
|
||||
* @param {number} prNumber - PR number
|
||||
*/
|
||||
async function removeLabels(labels, prNumber) {
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
await Promise.allSettled(labels.map(async (label) => {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: label
|
||||
});
|
||||
console.log(` ✓ Removed: ${label}`);
|
||||
} catch (e) {
|
||||
// 404 means label wasn't present - that's fine
|
||||
if (e.status !== 404) {
|
||||
console.log(` ⚠ Failed to remove ${label}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add labels to PR (with error handling)
|
||||
* @param {Array} labels - Labels to add
|
||||
* @param {number} prNumber - PR number
|
||||
*/
|
||||
async function addLabels(labels, prNumber) {
|
||||
if (labels.length === 0) return;
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels
|
||||
});
|
||||
console.log(` ✓ Added: ${labels.join(', ')}`);
|
||||
} catch (e) {
|
||||
if (e.status === 404) {
|
||||
core.warning(`One or more labels do not exist. Create them in repository settings.`);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch PR files with full pagination support
|
||||
* @param {number} prNumber - PR number
|
||||
* @returns {Array} List of all files (paginated)
|
||||
*/
|
||||
async function fetchPRFiles(prNumber) {
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
try {
|
||||
// Use paginate to fetch ALL files, not just first 100
|
||||
const files = await github.paginate(
|
||||
github.rest.pulls.listFiles,
|
||||
{ owner, repo, pull_number: prNumber, per_page: CONFIG.MAX_FILES_PER_PAGE }
|
||||
);
|
||||
return files;
|
||||
} catch (e) {
|
||||
console.log(` ⚠ Could not fetch files: ${e.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// MAIN LOGIC - Orchestrates the labeling process
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const pr = context.payload.pull_request;
|
||||
const prNumber = pr.number;
|
||||
const title = pr.title || '';
|
||||
|
||||
console.log(`::group::PR #${prNumber} - Auto-labeling`);
|
||||
console.log(`Title: ${title.slice(0, 100)}${title.length > 100 ? '...' : ''}`);
|
||||
console.log(`Action: ${context.payload.action}`);
|
||||
|
||||
const labelsToAdd = new Set();
|
||||
const labelsToRemove = new Set();
|
||||
|
||||
// 1. Parse conventional commit type
|
||||
const { type, isBreaking } = parseConventionalCommit(title);
|
||||
if (type && CONFIG.TYPE_MAP[type]) {
|
||||
labelsToAdd.add(CONFIG.TYPE_MAP[type]);
|
||||
console.log(` 📝 Type: ${type} → ${CONFIG.TYPE_MAP[type]}`);
|
||||
} else {
|
||||
console.log(` ℹ️ No conventional commit prefix detected`);
|
||||
}
|
||||
|
||||
if (isBreaking) {
|
||||
labelsToAdd.add('breaking-change');
|
||||
console.log(` ⚠️ Breaking change detected`);
|
||||
}
|
||||
|
||||
// 2. Detect areas from changed files
|
||||
const files = await fetchPRFiles(prNumber);
|
||||
const areas = detectAreas(files);
|
||||
const areaLabel = determineAreaLabel(areas);
|
||||
|
||||
if (areaLabel) {
|
||||
labelsToAdd.add(areaLabel);
|
||||
CONFIG.LABELS.AREA.filter(l => l !== areaLabel).forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📁 Area: ${areaLabel.replace('area/', '')}`);
|
||||
}
|
||||
|
||||
// 3. Calculate size label
|
||||
const totalLines = (pr.additions || 0) + (pr.deletions || 0);
|
||||
const sizeLabel = determineSizeLabel(totalLines);
|
||||
labelsToAdd.add(sizeLabel);
|
||||
CONFIG.LABELS.SIZE.filter(l => l !== sizeLabel).forEach(l => labelsToRemove.add(l));
|
||||
console.log(` 📏 Size: ${sizeLabel} (${totalLines} lines)`);
|
||||
|
||||
console.log('::endgroup::');
|
||||
|
||||
// 4. Apply label changes
|
||||
console.log(`::group::Applying labels`);
|
||||
|
||||
// Remove labels that should be replaced (exclude ones we're adding)
|
||||
const removeList = [...labelsToRemove].filter(l => !labelsToAdd.has(l));
|
||||
await removeLabels(removeList, prNumber);
|
||||
|
||||
// Add new labels
|
||||
await addLabels([...labelsToAdd], prNumber);
|
||||
|
||||
console.log('::endgroup::');
|
||||
console.log(`✅ PR #${prNumber} labeled successfully`);
|
||||
|
||||
// 5. Write job summary
|
||||
const summaryType = type ? CONFIG.TYPE_MAP[type] || 'unknown' : 'none';
|
||||
const summaryArea = areaLabel ? areaLabel.replace('area/', '') : 'other';
|
||||
|
||||
await core.summary
|
||||
.addHeading(`PR #${prNumber} Auto-Labels`, 3)
|
||||
.addTable([
|
||||
[{ data: 'Category', header: true }, { data: 'Label', header: true }],
|
||||
['Type', summaryType],
|
||||
['Area', summaryArea],
|
||||
['Size', sizeLabel]
|
||||
])
|
||||
.addRaw(`\n**Files:** ${files.length} | **Lines:** +${pr.additions || 0} / -${pr.deletions || 0}\n`)
|
||||
.write();
|
||||
@@ -0,0 +1,72 @@
|
||||
name: PR Status Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
# Cancel in-progress runs for the same PR
|
||||
concurrency:
|
||||
group: pr-status-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
mark-checking:
|
||||
name: Set Checking Status
|
||||
runs-on: ubuntu-latest
|
||||
# Don't run on fork PRs (they can't write labels)
|
||||
if: github.event.pull_request.head.repo.full_name == github.repository
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Update PR status label
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const statusLabels = ['🔄 Checking', '✅ Ready for Review', '❌ Checks Failed'];
|
||||
|
||||
console.log(`::group::PR #${prNumber} - Setting status to Checking`);
|
||||
|
||||
// Remove old status labels (parallel for speed)
|
||||
const removePromises = statusLabels.map(async (label) => {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: label
|
||||
});
|
||||
console.log(` ✓ Removed: ${label}`);
|
||||
} catch (e) {
|
||||
if (e.status !== 404) {
|
||||
console.log(` ⚠ Could not remove ${label}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(removePromises);
|
||||
|
||||
// Add checking label
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: ['🔄 Checking']
|
||||
});
|
||||
console.log(` ✓ Added: 🔄 Checking`);
|
||||
} catch (e) {
|
||||
// Label might not exist - create helpful error
|
||||
if (e.status === 404) {
|
||||
core.warning(`Label '🔄 Checking' does not exist. Please create it in repository settings.`);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log('::endgroup::');
|
||||
console.log(`✅ PR #${prNumber} marked as checking`);
|
||||
@@ -0,0 +1,578 @@
|
||||
name: PR Status Gate
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI, Lint, Quality Security]
|
||||
types: [completed]
|
||||
|
||||
issue_comment:
|
||||
types: [created, edited]
|
||||
|
||||
pull_request:
|
||||
types: [synchronize]
|
||||
|
||||
concurrency:
|
||||
group: pr-status-gate-${{ github.event.workflow_run.pull_requests[0].number || github.event.issue.number || github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
checks: read
|
||||
|
||||
env:
|
||||
# Shared configuration - single source of truth
|
||||
# Last validated: 2026-01-08
|
||||
REQUIRED_CHECKS: |
|
||||
CI / test-frontend
|
||||
CI / test-python (3.12)
|
||||
CI / test-python (3.13)
|
||||
Lint / python
|
||||
Quality Security / CodeQL (javascript-typescript)
|
||||
Quality Security / CodeQL (python)
|
||||
Quality Security / Python Security (Bandit)
|
||||
Quality Security / Security Summary
|
||||
|
||||
jobs:
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# JOB 1: CI STATUS (triggered by workflow_run)
|
||||
# Updates CI status labels when monitored workflows complete
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
update-ci-status:
|
||||
name: Update CI Status
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_run' && github.event.workflow_run.pull_requests[0] != null
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Check all required checks and update label
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
REQUIRED_CHECKS: ${{ env.REQUIRED_CHECKS }}
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
const STATUS_LABELS = Object.freeze({
|
||||
CHECKING: '🔄 Checking',
|
||||
PASSED: '✅ Ready for Review',
|
||||
FAILED: '❌ Checks Failed'
|
||||
});
|
||||
|
||||
const REQUIRED_CHECKS = process.env.REQUIRED_CHECKS
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
async function fetchCheckRuns(sha) {
|
||||
const { owner, repo } = context.repo;
|
||||
try {
|
||||
const { data } = await github.rest.checks.listForRef({
|
||||
owner, repo, ref: sha, per_page: 100
|
||||
});
|
||||
return data.check_runs;
|
||||
} catch (error) {
|
||||
core.warning(`Failed to fetch check runs: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function analyzeChecks(checkRuns) {
|
||||
const results = [];
|
||||
let allComplete = true;
|
||||
let anyFailed = false;
|
||||
|
||||
for (const checkName of REQUIRED_CHECKS) {
|
||||
const check = checkRuns.find(c => c.name === checkName);
|
||||
|
||||
if (!check) {
|
||||
results.push({ name: checkName, status: '⏳ Pending', complete: false });
|
||||
allComplete = false;
|
||||
} else if (check.status !== 'completed') {
|
||||
results.push({ name: checkName, status: '🔄 Running', complete: false });
|
||||
allComplete = false;
|
||||
} else if (check.conclusion === 'success') {
|
||||
results.push({ name: checkName, status: '✅ Passed', complete: true });
|
||||
} else if (check.conclusion === 'skipped') {
|
||||
results.push({ name: checkName, status: '⏭️ Skipped', complete: true, skipped: true });
|
||||
} else {
|
||||
results.push({ name: checkName, status: '❌ Failed', complete: true, failed: true });
|
||||
anyFailed = true;
|
||||
}
|
||||
}
|
||||
return { allComplete, anyFailed, results };
|
||||
}
|
||||
|
||||
async function updateStatusLabels(prNumber, newLabel) {
|
||||
const { owner, repo } = context.repo;
|
||||
const allLabels = Object.values(STATUS_LABELS);
|
||||
|
||||
for (const label of allLabels) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label });
|
||||
} catch (e) {
|
||||
// Only ignore 404 (label not present), surface other errors
|
||||
if (e && e.status === 404) {
|
||||
// Label wasn't present - this is expected
|
||||
} else if (e) {
|
||||
core.warning(`Failed to remove label '${label}': ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [newLabel] });
|
||||
} catch (e) {
|
||||
if (e && e.status === 404) {
|
||||
core.warning(`Label '${newLabel}' does not exist. Create it in repository settings.`);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main logic
|
||||
const prNumber = context.payload.workflow_run.pull_requests[0].number;
|
||||
const headSha = context.payload.workflow_run.head_sha;
|
||||
const triggerWorkflow = context.payload.workflow_run.name;
|
||||
|
||||
console.log(`PR #${prNumber} - Triggered by: ${triggerWorkflow}, SHA: ${headSha.slice(0, 8)}`);
|
||||
|
||||
const checkRuns = await fetchCheckRuns(headSha);
|
||||
if (!checkRuns) return;
|
||||
|
||||
console.log(`Found ${checkRuns.length} check runs`);
|
||||
const { allComplete, anyFailed, results } = analyzeChecks(checkRuns);
|
||||
|
||||
for (const r of results) {
|
||||
console.log(` ${r.status} ${r.name}`);
|
||||
}
|
||||
|
||||
if (!allComplete) {
|
||||
const pending = results.filter(r => !r.complete).length;
|
||||
console.log(`⏳ ${pending}/${REQUIRED_CHECKS.length} checks pending`);
|
||||
return;
|
||||
}
|
||||
|
||||
const newLabel = anyFailed ? STATUS_LABELS.FAILED : STATUS_LABELS.PASSED;
|
||||
await updateStatusLabels(prNumber, newLabel);
|
||||
|
||||
const passedCount = results.filter(r => r.status === '✅ Passed').length;
|
||||
const failedCount = results.filter(r => r.failed).length;
|
||||
|
||||
if (anyFailed) {
|
||||
console.log(`❌ PR #${prNumber}: ${failedCount} check(s) failed`);
|
||||
} else {
|
||||
console.log(`✅ PR #${prNumber}: Ready for review (${passedCount}/${REQUIRED_CHECKS.length} passed)`);
|
||||
}
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# JOB 2: /check-status COMMAND
|
||||
# Manual status check - anyone can trigger by commenting /check-status
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
check-status-command:
|
||||
name: Check Status Command
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '/check-status')
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Run status check and post report
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
REQUIRED_CHECKS: ${{ env.REQUIRED_CHECKS }}
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
const STATUS_LABELS = Object.freeze({
|
||||
CHECKING: '🔄 Checking',
|
||||
PASSED: '✅ Ready for Review',
|
||||
FAILED: '❌ Checks Failed'
|
||||
});
|
||||
|
||||
const REVIEW_LABELS = Object.freeze([
|
||||
'Missing AC Approval',
|
||||
'AC: Approved',
|
||||
'AC: Changes Requested',
|
||||
'AC: Blocked',
|
||||
'AC: Needs Re-review'
|
||||
]);
|
||||
|
||||
const REQUIRED_CHECKS = process.env.REQUIRED_CHECKS
|
||||
.split('\n')
|
||||
.map(s => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.issue.number;
|
||||
const requestedBy = context.payload.comment.user.login;
|
||||
|
||||
// Get PR details
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner, repo, pull_number: prNumber
|
||||
});
|
||||
const headSha = pr.head.sha;
|
||||
|
||||
console.log(`PR #${prNumber} - /check-status by @${requestedBy}, SHA: ${headSha.slice(0, 8)}`);
|
||||
|
||||
// Fetch check runs
|
||||
const { data: checksData } = await github.rest.checks.listForRef({
|
||||
owner, repo, ref: headSha, per_page: 100
|
||||
});
|
||||
|
||||
const checkRuns = checksData.check_runs;
|
||||
console.log(`Found ${checkRuns.length} check runs`);
|
||||
|
||||
// Analyze results
|
||||
const results = [];
|
||||
let allComplete = true;
|
||||
let anyFailed = false;
|
||||
|
||||
for (const checkName of REQUIRED_CHECKS) {
|
||||
const check = checkRuns.find(c => c.name === checkName);
|
||||
|
||||
if (!check) {
|
||||
results.push({ name: checkName, emoji: '⏳', complete: false });
|
||||
allComplete = false;
|
||||
} else if (check.status !== 'completed') {
|
||||
results.push({ name: checkName, emoji: '🔄', complete: false });
|
||||
allComplete = false;
|
||||
} else if (check.conclusion === 'success') {
|
||||
results.push({ name: checkName, emoji: '✅', complete: true });
|
||||
} else if (check.conclusion === 'skipped') {
|
||||
results.push({ name: checkName, emoji: '⏭️', complete: true, skipped: true });
|
||||
} else {
|
||||
results.push({ name: checkName, emoji: '❌', complete: true, failed: true });
|
||||
anyFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Get current labels
|
||||
const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner, repo, issue_number: prNumber
|
||||
});
|
||||
const labelNames = currentLabels.map(l => l.name);
|
||||
const currentStatusLabel = Object.values(STATUS_LABELS).find(l => labelNames.includes(l)) || 'None';
|
||||
const currentReviewLabel = REVIEW_LABELS.find(l => labelNames.includes(l)) || 'None';
|
||||
|
||||
// Update label if all checks complete
|
||||
let newStatusLabel = STATUS_LABELS.CHECKING;
|
||||
let statusChanged = false;
|
||||
|
||||
if (allComplete) {
|
||||
newStatusLabel = anyFailed ? STATUS_LABELS.FAILED : STATUS_LABELS.PASSED;
|
||||
|
||||
if (newStatusLabel !== currentStatusLabel) {
|
||||
statusChanged = true;
|
||||
for (const label of Object.values(STATUS_LABELS)) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label });
|
||||
} catch (e) {
|
||||
if (e && e.status !== 404) {
|
||||
core.warning(`Failed to remove label '${label}': ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [newStatusLabel] });
|
||||
} catch (e) {
|
||||
if (e && e.status === 404) {
|
||||
core.warning(`Label '${newStatusLabel}' does not exist`);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build status report
|
||||
const passedCount = results.filter(r => r.emoji === '✅').length;
|
||||
let statusEmoji = '🔄';
|
||||
if (allComplete && !anyFailed) statusEmoji = '✅';
|
||||
else if (allComplete && anyFailed) statusEmoji = '❌';
|
||||
|
||||
const checksTable = results.map(r => `| ${r.emoji} | ${r.name} |`).join('\n');
|
||||
|
||||
const lines = [
|
||||
`## ${statusEmoji} PR Status Report`,
|
||||
'',
|
||||
`| Label | Value |`,
|
||||
`|-------|-------|`,
|
||||
`| CI Status | ${newStatusLabel} |`,
|
||||
`| AC Review | ${currentReviewLabel} |`,
|
||||
''
|
||||
];
|
||||
|
||||
if (statusChanged) {
|
||||
lines.push(`> Status updated: \`${currentStatusLabel}\` → \`${newStatusLabel}\``);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
lines.push(`### CI Checks (${passedCount}/${REQUIRED_CHECKS.length} passed)`);
|
||||
lines.push('');
|
||||
lines.push('| Status | Check |');
|
||||
lines.push('|--------|-------|');
|
||||
lines.push(checksTable);
|
||||
lines.push('');
|
||||
lines.push('---');
|
||||
lines.push(`<sub>Triggered by \`/check-status\` from @${requestedBy}</sub>`);
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber, body: lines.join('\n')
|
||||
});
|
||||
|
||||
console.log(`✅ Posted status report to PR #${prNumber}`);
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# JOB 3: AUTO-CLAUDE REVIEW
|
||||
# Processes Auto-Claude review comments from trusted sources
|
||||
# Security: Only bots and collaborators can update labels
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
update-review-status:
|
||||
name: Update Review Status
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
!contains(github.event.comment.body, '/check-status')
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Check for Auto-Claude review
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
// Security configuration
|
||||
const TRUSTED_BOT_ACCOUNTS = Object.freeze([
|
||||
'github-actions[bot]',
|
||||
'github-actions',
|
||||
'auto-claude[bot]',
|
||||
'auto-claude',
|
||||
'AutoClaudeBot'
|
||||
]);
|
||||
|
||||
const TRUSTED_AUTHOR_ASSOCIATIONS = Object.freeze([
|
||||
'COLLABORATOR',
|
||||
'MEMBER',
|
||||
'OWNER'
|
||||
]);
|
||||
|
||||
const IDENTIFIER_PATTERNS = Object.freeze([
|
||||
'🤖 Auto Claude PR Review',
|
||||
'Auto Claude Review',
|
||||
'Auto-Claude Review'
|
||||
]);
|
||||
|
||||
// VERDICTS - matches backend outputs
|
||||
// BLOCKED maps to backend's rejection/blocking output
|
||||
const VERDICTS = Object.freeze({
|
||||
APPROVED: {
|
||||
patterns: ['Auto Claude Review - APPROVED', '✅ Auto Claude Review - APPROVED'],
|
||||
regex: /Merge Verdict:.*✅.*(?:APPROVED|READY TO MERGE)/i,
|
||||
label: 'AC: Approved'
|
||||
},
|
||||
CHANGES_REQUESTED: {
|
||||
patterns: ['NEEDS REVISION', 'Needs Revision'],
|
||||
regex: /Merge Verdict:.*🟠/,
|
||||
label: 'AC: Changes Requested'
|
||||
},
|
||||
BLOCKED: {
|
||||
patterns: ['BLOCKED'],
|
||||
regex: /Merge Verdict:.*🔴/,
|
||||
label: 'AC: Blocked'
|
||||
}
|
||||
});
|
||||
|
||||
const REVIEW_LABELS = Object.freeze([
|
||||
'Missing AC Approval',
|
||||
'AC: Approved',
|
||||
'AC: Changes Requested',
|
||||
'AC: Blocked',
|
||||
'AC: Needs Re-review',
|
||||
'AC: Reviewed'
|
||||
]);
|
||||
|
||||
// Helper functions
|
||||
function isTrustedBot(username) {
|
||||
return TRUSTED_BOT_ACCOUNTS.some(t => username.toLowerCase() === t.toLowerCase());
|
||||
}
|
||||
|
||||
function isTrustedAssociation(assoc) {
|
||||
return TRUSTED_AUTHOR_ASSOCIATIONS.includes(assoc);
|
||||
}
|
||||
|
||||
function isAutoClaudeComment(body) {
|
||||
return IDENTIFIER_PATTERNS.some(p => body.includes(p));
|
||||
}
|
||||
|
||||
function parseVerdict(body) {
|
||||
const safeBody = body.slice(0, 5000);
|
||||
for (const [key, config] of Object.entries(VERDICTS)) {
|
||||
const patternMatch = config.patterns.some(p => safeBody.includes(p));
|
||||
const regexMatch = config.regex && config.regex.test(safeBody);
|
||||
if (patternMatch || regexMatch) {
|
||||
return { verdict: key, label: config.label };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function updateReviewLabels(prNumber, newLabel) {
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
for (const label of REVIEW_LABELS) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number: prNumber, name: label });
|
||||
console.log(` Removed: ${label}`);
|
||||
} catch (e) {
|
||||
// Only ignore 404, surface other errors
|
||||
if (e && e.status === 404) {
|
||||
// Label wasn't present - expected
|
||||
} else if (e) {
|
||||
core.warning(`Failed to remove label '${label}': ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number: prNumber, labels: [newLabel] });
|
||||
console.log(` Added: ${newLabel}`);
|
||||
} catch (e) {
|
||||
if (e && e.status === 404) {
|
||||
core.warning(`Label '${newLabel}' does not exist. Create it in repository settings.`);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Main logic
|
||||
const prNumber = context.payload.issue.number;
|
||||
const comment = context.payload.comment;
|
||||
const commenter = comment.user.login;
|
||||
const authorAssociation = comment.author_association;
|
||||
const body = comment.body || '';
|
||||
|
||||
console.log(`PR #${prNumber} - Comment by: ${commenter} (${authorAssociation})`);
|
||||
|
||||
// Security checks
|
||||
const isBot = isTrustedBot(commenter);
|
||||
const isCollaborator = isTrustedAssociation(authorAssociation);
|
||||
const isACComment = isAutoClaudeComment(body);
|
||||
|
||||
console.log(` Trusted bot: ${isBot}, Collaborator: ${isCollaborator}, AC comment: ${isACComment}`);
|
||||
|
||||
if (!isBot && !isCollaborator) {
|
||||
console.log('Skipping: Not a trusted bot or collaborator');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isACComment) {
|
||||
console.log('Skipping: Not an Auto-Claude comment');
|
||||
return;
|
||||
}
|
||||
|
||||
const verdictResult = parseVerdict(body);
|
||||
if (!verdictResult) {
|
||||
console.log('Skipping: Could not parse verdict');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Verdict: ${verdictResult.verdict} → ${verdictResult.label}`);
|
||||
await updateReviewLabels(prNumber, verdictResult.label);
|
||||
console.log(`✅ PR #${prNumber} review status updated`);
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# JOB 4: RE-REVIEW ON PUSH
|
||||
# When new commits pushed after AC approval, require re-review
|
||||
# Note: Does NOT touch CI status labels - that's handled by update-ci-status
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
require-re-review:
|
||||
name: Require Re-review on Push
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' && github.event.action == 'synchronize'
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Check and reset AC approval if needed
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
retries: 3
|
||||
retry-exempt-status-codes: 400,401,403,404,422
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const pusher = context.payload.sender.login;
|
||||
|
||||
console.log(`PR #${prNumber} - New commits by: ${pusher}`);
|
||||
|
||||
// Get current labels
|
||||
const { data: labels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner, repo, issue_number: prNumber
|
||||
});
|
||||
const labelNames = labels.map(l => l.name);
|
||||
|
||||
// Check if PR was approved
|
||||
const wasApproved = labelNames.includes('AC: Approved');
|
||||
|
||||
if (!wasApproved) {
|
||||
console.log('PR was not AC-approved, no action needed');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('PR was AC-approved, resetting to require re-review');
|
||||
|
||||
// Remove AC: Approved
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner, repo, issue_number: prNumber, name: 'AC: Approved'
|
||||
});
|
||||
console.log(' Removed: AC: Approved');
|
||||
} catch (e) {
|
||||
if (e && e.status === 404) {
|
||||
// Label wasn't present - expected
|
||||
} else if (e) {
|
||||
core.warning(`Failed to remove 'AC: Approved': ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add AC: Needs Re-review
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: prNumber, labels: ['AC: Needs Re-review']
|
||||
});
|
||||
console.log(' Added: AC: Needs Re-review');
|
||||
} catch (e) {
|
||||
if (e && e.status === 404) {
|
||||
core.warning("Label 'AC: Needs Re-review' does not exist. Create it in repository settings.");
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
// Post notification comment (CI status is handled by update-ci-status job)
|
||||
const commentLines = [
|
||||
'## 🔄 Re-review Required',
|
||||
'',
|
||||
'New commits were pushed after Auto-Claude approval.',
|
||||
'',
|
||||
'| Previous | Current |',
|
||||
'|----------|---------|',
|
||||
'| `AC: Approved` | `AC: Needs Re-review` |',
|
||||
'',
|
||||
'Please run Auto-Claude review again or request a manual review.',
|
||||
'',
|
||||
'---',
|
||||
`<sub>Triggered by push from @${pusher}</sub>`
|
||||
];
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber, body: commentLines.join('\n')
|
||||
});
|
||||
|
||||
console.log(`✅ Posted re-review notification to PR #${prNumber}`);
|
||||
@@ -10,15 +10,8 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/desktop/package.json'
|
||||
- 'apps/frontend/package.json'
|
||||
- 'package.json'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: 'Force release even if version check fails (use with caution)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
check-and-tag:
|
||||
@@ -29,28 +22,15 @@ jobs:
|
||||
should_release: ${{ steps.check.outputs.should_release }}
|
||||
new_version: ${{ steps.check.outputs.new_version }}
|
||||
steps:
|
||||
# Fail fast with clear error if PAT_TOKEN is not configured
|
||||
- name: Validate PAT_TOKEN is configured
|
||||
run: |
|
||||
if [ -z "${{ secrets.PAT_TOKEN }}" ]; then
|
||||
echo "::error::PAT_TOKEN secret is not configured."
|
||||
echo "::error::This secret is required for automatic release triggering."
|
||||
echo "::error::See https://github.com/AndyMik90/Auto-Claude/pull/1043 for setup instructions."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# IMPORTANT: Use PAT_TOKEN instead of GITHUB_TOKEN
|
||||
# When GITHUB_TOKEN pushes a tag, it does NOT trigger other workflows (GitHub security feature)
|
||||
# PAT_TOKEN allows the tag push to trigger release.yml automatically
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.PAT_TOKEN }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Get package version
|
||||
id: package
|
||||
run: |
|
||||
VERSION=$(node -p "require('./apps/desktop/package.json').version")
|
||||
VERSION=$(node -p "require('./apps/frontend/package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Package version: $VERSION"
|
||||
|
||||
@@ -74,20 +54,16 @@ jobs:
|
||||
run: |
|
||||
PACKAGE_VERSION="${{ steps.package.outputs.version }}"
|
||||
LATEST_VERSION="${{ steps.latest_tag.outputs.version }}"
|
||||
FORCE="${{ github.event.inputs.force }}"
|
||||
|
||||
echo "Comparing: package=$PACKAGE_VERSION vs latest_tag=$LATEST_VERSION"
|
||||
|
||||
# Use npx semver for proper semantic version comparison
|
||||
# This correctly handles pre-release versions (2.7.3 > 2.7.3-beta.1)
|
||||
if npx -y semver "$PACKAGE_VERSION" -r ">$LATEST_VERSION" > /dev/null 2>&1; then
|
||||
# Use sort -V for version comparison
|
||||
HIGHER=$(printf '%s\n%s' "$PACKAGE_VERSION" "$LATEST_VERSION" | sort -V | tail -n1)
|
||||
|
||||
if [ "$HIGHER" = "$PACKAGE_VERSION" ] && [ "$PACKAGE_VERSION" != "$LATEST_VERSION" ]; then
|
||||
echo "should_release=true" >> $GITHUB_OUTPUT
|
||||
echo "new_version=$PACKAGE_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "✅ New release needed: v$PACKAGE_VERSION"
|
||||
elif [ "$FORCE" = "true" ]; then
|
||||
echo "should_release=true" >> $GITHUB_OUTPUT
|
||||
echo "new_version=$PACKAGE_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "⚠️ Force release enabled: v$PACKAGE_VERSION"
|
||||
else
|
||||
echo "should_release=false" >> $GITHUB_OUTPUT
|
||||
echo "⏭️ No release needed (package version not newer than latest tag)"
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
name: Quality Security
|
||||
|
||||
# CodeQL runs on all PRs, pushes to main, and weekly schedule
|
||||
# Note: CodeQL takes 20-30 min
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/desktop/**'
|
||||
- 'package.json'
|
||||
- '.github/workflows/quality-security.yml'
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'apps/desktop/**'
|
||||
- 'package.json'
|
||||
- '.github/workflows/quality-security.yml'
|
||||
schedule:
|
||||
- cron: '0 0 * * 1' # Weekly on Monday at midnight UTC
|
||||
|
||||
# Cancel in-progress runs for the same branch/PR
|
||||
concurrency:
|
||||
group: security-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
@@ -36,7 +26,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [javascript-typescript]
|
||||
language: [python, javascript-typescript]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -55,30 +45,128 @@ jobs:
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Gate Job - Single check for branch protection
|
||||
# --------------------------------------------------------------------------
|
||||
python-security:
|
||||
name: Python Security (Bandit)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Install Bandit
|
||||
run: pip install bandit
|
||||
|
||||
- name: Run Bandit security scan
|
||||
id: bandit
|
||||
run: |
|
||||
echo "::group::Running Bandit security scan"
|
||||
# Run Bandit; exit code 1 means issues found (expected), other codes are errors
|
||||
# Flags: -r=recursive, -ll=severity LOW+, -ii=confidence LOW+, -f=format, -o=output
|
||||
bandit -r apps/backend/ -ll -ii -f json -o bandit-report.json || BANDIT_EXIT=$?
|
||||
if [ "${BANDIT_EXIT:-0}" -gt 1 ]; then
|
||||
echo "::error::Bandit scan failed with exit code $BANDIT_EXIT"
|
||||
exit 1
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Analyze Bandit results
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
|
||||
// Check if report exists
|
||||
if (!fs.existsSync('bandit-report.json')) {
|
||||
core.setFailed('Bandit report not found - scan may have failed');
|
||||
return;
|
||||
}
|
||||
|
||||
const report = JSON.parse(fs.readFileSync('bandit-report.json', 'utf8'));
|
||||
const results = report.results || [];
|
||||
|
||||
// Categorize by severity
|
||||
const high = results.filter(r => r.issue_severity === 'HIGH');
|
||||
const medium = results.filter(r => r.issue_severity === 'MEDIUM');
|
||||
const low = results.filter(r => r.issue_severity === 'LOW');
|
||||
|
||||
console.log(`::group::Bandit Security Scan Results`);
|
||||
console.log(`Found ${results.length} issues:`);
|
||||
console.log(` 🔴 HIGH: ${high.length}`);
|
||||
console.log(` 🟡 MEDIUM: ${medium.length}`);
|
||||
console.log(` 🟢 LOW: ${low.length}`);
|
||||
console.log('');
|
||||
|
||||
// Print high severity issues
|
||||
if (high.length > 0) {
|
||||
console.log('High Severity Issues:');
|
||||
console.log('─'.repeat(60));
|
||||
for (const issue of high) {
|
||||
console.log(` ${issue.filename}:${issue.line_number}`);
|
||||
console.log(` ${issue.issue_text}`);
|
||||
console.log(` Test: ${issue.test_id} (${issue.test_name})`);
|
||||
console.log('');
|
||||
}
|
||||
}
|
||||
console.log('::endgroup::');
|
||||
|
||||
// Build summary
|
||||
let summary = `## 🔒 Python Security Scan (Bandit)\n\n`;
|
||||
summary += `| Severity | Count |\n`;
|
||||
summary += `|----------|-------|\n`;
|
||||
summary += `| 🔴 High | ${high.length} |\n`;
|
||||
summary += `| 🟡 Medium | ${medium.length} |\n`;
|
||||
summary += `| 🟢 Low | ${low.length} |\n\n`;
|
||||
|
||||
if (high.length > 0) {
|
||||
summary += `### High Severity Issues\n\n`;
|
||||
for (const issue of high) {
|
||||
summary += `- **${issue.filename}:${issue.line_number}**\n`;
|
||||
summary += ` - ${issue.issue_text}\n`;
|
||||
summary += ` - Test: \`${issue.test_id}\` (${issue.test_name})\n\n`;
|
||||
}
|
||||
}
|
||||
|
||||
core.summary.addRaw(summary);
|
||||
await core.summary.write();
|
||||
|
||||
// Fail if high severity issues found
|
||||
if (high.length > 0) {
|
||||
core.setFailed(`Found ${high.length} high severity security issue(s)`);
|
||||
} else {
|
||||
console.log('✅ No high severity security issues found');
|
||||
}
|
||||
|
||||
# Summary job that waits for all security checks
|
||||
security-summary:
|
||||
name: Security Summary
|
||||
runs-on: ubuntu-latest
|
||||
needs: [codeql]
|
||||
needs: [codeql, python-security]
|
||||
if: always()
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check security results
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const codeql = '${{ needs.codeql.result }}';
|
||||
const bandit = '${{ needs.python-security.result }}';
|
||||
|
||||
console.log('Security Check Results:');
|
||||
console.log(` CodeQL: ${codeql}`);
|
||||
console.log(` Bandit: ${bandit}`);
|
||||
|
||||
// Only 'failure' is a real failure; 'skipped' is acceptable (e.g., path filters, PR skipping CodeQL)
|
||||
// Only 'failure' is a real failure; 'skipped' is acceptable (e.g., path filters)
|
||||
const acceptable = ['success', 'skipped'];
|
||||
const codeqlOk = acceptable.includes(codeql);
|
||||
const banditOk = acceptable.includes(bandit);
|
||||
const allPassed = codeqlOk && banditOk;
|
||||
|
||||
if (codeqlOk) {
|
||||
if (allPassed) {
|
||||
console.log('\n✅ All security checks passed');
|
||||
core.summary.addRaw('## ✅ Security Checks Passed\n\nAll security scans completed successfully.');
|
||||
} else {
|
||||
|
||||
+467
-317
@@ -1,10 +1,4 @@
|
||||
name: Release
|
||||
# Triggers on version tags (v*) to build and publish releases
|
||||
#
|
||||
# IMPORTANT: If branch protection is enabled on 'main', the update-readme job
|
||||
# requires a PAT or GitHub App token with bypass permissions to push directly.
|
||||
# Currently uses GITHUB_TOKEN which works if "Allow GitHub Actions to create
|
||||
# and approve pull requests" is enabled OR branch protection is not configured.
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -15,7 +9,7 @@ on:
|
||||
dry_run:
|
||||
description: 'Test build without creating release'
|
||||
required: false
|
||||
default: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
@@ -23,337 +17,317 @@ jobs:
|
||||
# Note: macos-15-intel is the last Intel runner, supported until Fall 2027
|
||||
build-macos-intel:
|
||||
runs-on: macos-15-intel
|
||||
outputs:
|
||||
notarization_id: ${{ steps.notarize.outputs.notarization-id }}
|
||||
dmg_file: ${{ steps.notarize.outputs.dmg-file }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache
|
||||
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.npm-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
|
||||
- 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') }}
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-rust-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/desktop && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
run: cd apps/frontend && npm run build
|
||||
|
||||
- name: Package macOS (Intel)
|
||||
run: cd apps/desktop && npm run package:mac -- --x64
|
||||
run: cd apps/frontend && npm run package:mac -- --x64
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Submit notarization (async)
|
||||
id: notarize
|
||||
uses: ./.github/actions/submit-macos-notarization
|
||||
with:
|
||||
apple-id: ${{ secrets.APPLE_ID }}
|
||||
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
|
||||
- name: Notarize macOS Intel app
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
if [ -z "$APPLE_ID" ]; then
|
||||
echo "Skipping notarization: APPLE_ID not configured"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/frontend
|
||||
for dmg in dist/*.dmg; do
|
||||
echo "Notarizing $dmg..."
|
||||
xcrun notarytool submit "$dmg" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--wait
|
||||
xcrun stapler staple "$dmg"
|
||||
echo "Successfully notarized and stapled $dmg"
|
||||
done
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-intel-builds
|
||||
path: |
|
||||
apps/desktop/dist/*.dmg
|
||||
apps/desktop/dist/*.zip
|
||||
apps/desktop/dist/*.yml
|
||||
apps/desktop/dist/*.blockmap
|
||||
apps/frontend/dist/*.dmg
|
||||
apps/frontend/dist/*.zip
|
||||
apps/frontend/dist/*.yml
|
||||
apps/frontend/dist/*.blockmap
|
||||
|
||||
# Apple Silicon build on ARM64 runner for native compilation
|
||||
build-macos-arm64:
|
||||
runs-on: macos-15
|
||||
outputs:
|
||||
notarization_id: ${{ steps.notarize.outputs.notarization-id }}
|
||||
dmg_file: ${{ steps.notarize.outputs.dmg-file }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache
|
||||
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.npm-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
|
||||
- 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') }}
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-arm64-3.12.8-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/desktop && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
run: cd apps/frontend && npm run build
|
||||
|
||||
- name: Package macOS (Apple Silicon)
|
||||
run: cd apps/desktop && npm run package:mac -- --arm64
|
||||
run: cd apps/frontend && npm run package:mac -- --arm64
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Submit notarization (async)
|
||||
id: notarize
|
||||
uses: ./.github/actions/submit-macos-notarization
|
||||
with:
|
||||
apple-id: ${{ secrets.APPLE_ID }}
|
||||
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
|
||||
- name: Notarize macOS ARM64 app
|
||||
env:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: |
|
||||
if [ -z "$APPLE_ID" ]; then
|
||||
echo "Skipping notarization: APPLE_ID not configured"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/frontend
|
||||
for dmg in dist/*.dmg; do
|
||||
echo "Notarizing $dmg..."
|
||||
xcrun notarytool submit "$dmg" \
|
||||
--apple-id "$APPLE_ID" \
|
||||
--password "$APPLE_APP_SPECIFIC_PASSWORD" \
|
||||
--team-id "$APPLE_TEAM_ID" \
|
||||
--wait
|
||||
xcrun stapler staple "$dmg"
|
||||
echo "Successfully notarized and stapled $dmg"
|
||||
done
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-arm64-builds
|
||||
path: |
|
||||
apps/desktop/dist/*.dmg
|
||||
apps/desktop/dist/*.zip
|
||||
apps/desktop/dist/*.yml
|
||||
apps/desktop/dist/*.blockmap
|
||||
apps/frontend/dist/*.dmg
|
||||
apps/frontend/dist/*.zip
|
||||
apps/frontend/dist/*.yml
|
||||
apps/frontend/dist/*.blockmap
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
id-token: write # Required for OIDC authentication with Azure
|
||||
contents: read
|
||||
env:
|
||||
# Job-level env so AZURE_CLIENT_ID is available for step-level if conditions
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache
|
||||
shell: bash
|
||||
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.npm-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
|
||||
- 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') }}
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/desktop && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
run: cd apps/frontend && npm run build
|
||||
|
||||
- name: Package Windows
|
||||
run: cd apps/desktop && npm run package:win
|
||||
run: cd apps/frontend && npm run package:win
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Disable electron-builder's built-in signing (we use Azure Trusted Signing instead)
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: false
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Azure Login (OIDC)
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Sign Windows executable with Azure Trusted Signing
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
uses: azure/trusted-signing-action@v0.5.11
|
||||
with:
|
||||
endpoint: https://neu.codesigning.azure.net/
|
||||
trusted-signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
certificate-profile-name: ${{ secrets.AZURE_CERTIFICATE_PROFILE }}
|
||||
files-folder: apps/desktop/dist
|
||||
files-folder-filter: exe
|
||||
file-digest: SHA256
|
||||
timestamp-rfc3161: http://timestamp.acs.microsoft.com
|
||||
timestamp-digest: SHA256
|
||||
|
||||
- name: Verify Windows executable is signed
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
cd apps/desktop/dist
|
||||
$exeFile = Get-ChildItem -Filter "*.exe" | Select-Object -First 1
|
||||
if ($exeFile) {
|
||||
Write-Host "Verifying signature on $($exeFile.Name)..."
|
||||
$sig = Get-AuthenticodeSignature -FilePath $exeFile.FullName
|
||||
if ($sig.Status -ne 'Valid') {
|
||||
Write-Host "::error::Signature verification failed: $($sig.Status)"
|
||||
Write-Host "::error::Status Message: $($sig.StatusMessage)"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "✅ Signature verified successfully"
|
||||
Write-Host " Subject: $($sig.SignerCertificate.Subject)"
|
||||
Write-Host " Issuer: $($sig.SignerCertificate.Issuer)"
|
||||
Write-Host " Thumbprint: $($sig.SignerCertificate.Thumbprint)"
|
||||
} else {
|
||||
Write-Host "::error::No .exe file found to verify"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Regenerate checksums after signing
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
cd apps/desktop/dist
|
||||
|
||||
# Find the installer exe (electron-builder names it with "Setup" or just the app name)
|
||||
# electron-builder produces one installer exe per build
|
||||
$exeFiles = Get-ChildItem -Filter "*.exe"
|
||||
if ($exeFiles.Count -eq 0) {
|
||||
Write-Host "::error::No .exe files found in dist folder"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Found $($exeFiles.Count) exe file(s): $($exeFiles.Name -join ', ')"
|
||||
|
||||
$ymlFile = "latest.yml"
|
||||
if (-not (Test-Path $ymlFile)) {
|
||||
Write-Host "::error::$ymlFile not found - cannot update checksums"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$content = Get-Content $ymlFile -Raw
|
||||
$originalContent = $content
|
||||
|
||||
# Process each exe file and update its hash in latest.yml
|
||||
foreach ($exeFile in $exeFiles) {
|
||||
Write-Host "Processing $($exeFile.Name)..."
|
||||
|
||||
# Compute SHA512 hash and convert to base64 (electron-builder format)
|
||||
$bytes = [System.IO.File]::ReadAllBytes($exeFile.FullName)
|
||||
$sha512 = [System.Security.Cryptography.SHA512]::Create()
|
||||
$hashBytes = $sha512.ComputeHash($bytes)
|
||||
$hash = [System.Convert]::ToBase64String($hashBytes)
|
||||
$size = $exeFile.Length
|
||||
|
||||
Write-Host " Hash: $hash"
|
||||
Write-Host " Size: $size"
|
||||
}
|
||||
|
||||
# For electron-builder, latest.yml has a single file entry for the installer
|
||||
# Update the sha512 and size for the primary exe (first one, typically the installer)
|
||||
$primaryExe = $exeFiles | Select-Object -First 1
|
||||
$bytes = [System.IO.File]::ReadAllBytes($primaryExe.FullName)
|
||||
$sha512 = [System.Security.Cryptography.SHA512]::Create()
|
||||
$hashBytes = $sha512.ComputeHash($bytes)
|
||||
$hash = [System.Convert]::ToBase64String($hashBytes)
|
||||
$size = $primaryExe.Length
|
||||
|
||||
# Update sha512 hash (base64 pattern: alphanumeric, +, /, =)
|
||||
$content = $content -replace 'sha512: [A-Za-z0-9+/=]+', "sha512: $hash"
|
||||
# Update size
|
||||
$content = $content -replace 'size: \d+', "size: $size"
|
||||
|
||||
if ($content -eq $originalContent) {
|
||||
Write-Host "::error::Checksum replacement failed - content unchanged. Check if latest.yml format has changed."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Set-Content -Path $ymlFile -Value $content -NoNewline
|
||||
Write-Host "✅ Updated $ymlFile with new base64 hash and size for $($primaryExe.Name)"
|
||||
|
||||
- name: Skip signing notice
|
||||
if: env.AZURE_CLIENT_ID == ''
|
||||
run: echo "::warning::Windows signing skipped - AZURE_CLIENT_ID not configured. The .exe will be unsigned."
|
||||
CSC_LINK: ${{ secrets.WIN_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-builds
|
||||
path: |
|
||||
apps/desktop/dist/*.exe
|
||||
apps/desktop/dist/*.yml
|
||||
apps/desktop/dist/*.blockmap
|
||||
apps/frontend/dist/*.exe
|
||||
apps/frontend/dist/*.yml
|
||||
apps/frontend/dist/*.blockmap
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Flatpak and verification tools
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache
|
||||
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.npm-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-npm-
|
||||
|
||||
- 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 squashfs-tools
|
||||
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') }}
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/desktop && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
run: cd apps/frontend && npm run build
|
||||
|
||||
- name: Package Linux
|
||||
run: cd apps/desktop && npm run package:linux
|
||||
run: cd apps/frontend && npm run package:linux
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Verify Linux packages
|
||||
run: cd apps/desktop && npm run verify:linux
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-builds
|
||||
path: |
|
||||
apps/desktop/dist/*.AppImage
|
||||
apps/desktop/dist/*.deb
|
||||
apps/desktop/dist/*.flatpak
|
||||
apps/desktop/dist/*.yml
|
||||
apps/desktop/dist/*.blockmap
|
||||
|
||||
# Finalize macOS notarization (runs in parallel with Windows/Linux builds)
|
||||
finalize-notarization:
|
||||
needs: [build-macos-intel, build-macos-arm64]
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download Intel DMG
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: macos-intel-builds
|
||||
path: intel
|
||||
|
||||
- name: Download ARM64 DMG
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: macos-arm64-builds
|
||||
path: arm64
|
||||
|
||||
- name: Wait for notarization and staple
|
||||
uses: ./.github/actions/finalize-macos-notarization
|
||||
with:
|
||||
apple-id: ${{ secrets.APPLE_ID }}
|
||||
apple-app-specific-password: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
apple-team-id: ${{ secrets.APPLE_TEAM_ID }}
|
||||
intel-notarization-id: ${{ needs.build-macos-intel.outputs.notarization_id }}
|
||||
arm64-notarization-id: ${{ needs.build-macos-arm64.outputs.notarization_id }}
|
||||
intel-dmg-file: ${{ needs.build-macos-intel.outputs.dmg_file }}
|
||||
arm64-dmg-file: ${{ needs.build-macos-arm64.outputs.dmg_file }}
|
||||
|
||||
- name: Upload stapled Intel DMG
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-intel-stapled
|
||||
path: intel/*.dmg
|
||||
|
||||
- name: Upload stapled ARM64 DMG
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-arm64-stapled
|
||||
path: arm64/*.dmg
|
||||
apps/frontend/dist/*.AppImage
|
||||
apps/frontend/dist/*.deb
|
||||
apps/frontend/dist/*.flatpak
|
||||
apps/frontend/dist/*.yml
|
||||
apps/frontend/dist/*.blockmap
|
||||
|
||||
create-release:
|
||||
needs: [build-macos-intel, build-macos-arm64, finalize-notarization, build-windows, build-linux]
|
||||
needs: [build-macos-intel, build-macos-arm64, build-windows, build-linux]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -363,78 +337,35 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v7
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
|
||||
- name: Flatten binary artifacts
|
||||
- name: Flatten and validate artifacts
|
||||
run: |
|
||||
mkdir -p release-assets
|
||||
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \;
|
||||
|
||||
# Copy stapled macOS DMGs (from finalize-notarization job)
|
||||
# Validate that stapled DMGs exist before copying
|
||||
if ! find dist/macos-intel-stapled dist/macos-arm64-stapled -type f -name "*.dmg" 2>/dev/null | grep -q .; then
|
||||
echo "::warning::No stapled DMGs found. Using un-stapled DMGs from build artifacts."
|
||||
find dist/macos-intel-builds dist/macos-arm64-builds -type f -name "*.dmg" -exec cp {} release-assets/ \; 2>/dev/null || true
|
||||
else
|
||||
find dist/macos-intel-stapled dist/macos-arm64-stapled -type f -name "*.dmg" -exec cp {} release-assets/ \; 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Copy other macOS artifacts (zip, yml, blockmap) from original build
|
||||
find dist/macos-intel-builds dist/macos-arm64-builds -type f \( -name "*.zip" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
|
||||
|
||||
# Copy Windows and Linux artifacts
|
||||
find dist/windows-builds dist/linux-builds -type f \( -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" -o -name "*.yml" -o -name "*.blockmap" \) -exec cp {} release-assets/ \; 2>/dev/null || true
|
||||
|
||||
# Validate that installer files exist
|
||||
# Validate that installer files exist (not just manifests)
|
||||
installer_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) | wc -l)
|
||||
if [ "$installer_count" -eq 0 ]; then
|
||||
echo "::error::No installer artifacts found! Expected .dmg, .zip, .exe, .AppImage, .deb, or .flatpak files."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Found $installer_count binary artifact(s):"
|
||||
echo "Found $installer_count installer(s):"
|
||||
find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) -exec basename {} \;
|
||||
|
||||
# Merge macOS manifests from Intel and ARM64 builds
|
||||
# See: https://github.com/electron-userland/electron-builder/issues/5592
|
||||
- name: Merge macOS manifests
|
||||
uses: ./.github/actions/merge-macos-manifests
|
||||
with:
|
||||
dist-path: dist
|
||||
output-path: release-assets
|
||||
copy-other-manifests: 'true'
|
||||
|
||||
- name: Validate manifests
|
||||
run: |
|
||||
# Validate that electron-updater manifest files are present (required for auto-updates)
|
||||
yml_count=$(find release-assets -type f -name "*.yml" | wc -l)
|
||||
if [ "$yml_count" -eq 0 ]; then
|
||||
echo "::error::No update manifest (.yml) files found! Auto-update will not work."
|
||||
echo "::error::No update manifest (.yml) files found! Auto-update architecture detection will not work."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Found $yml_count manifest file(s):"
|
||||
find release-assets -type f -name "*.yml" -exec basename {} \;
|
||||
|
||||
# Validate required manifests exist
|
||||
missing=""
|
||||
[ ! -f "release-assets/latest-mac.yml" ] && missing="$missing latest-mac.yml"
|
||||
[ ! -f "release-assets/latest.yml" ] && missing="$missing latest.yml"
|
||||
[ ! -f "release-assets/latest-linux.yml" ] && missing="$missing latest-linux.yml"
|
||||
|
||||
if [ -n "$missing" ]; then
|
||||
echo "::error::Missing required manifests:$missing"
|
||||
echo "::error::Auto-update will fail on affected platforms!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "All required manifests present:"
|
||||
echo " - latest-mac.yml (macOS)"
|
||||
echo " - latest.yml (Windows)"
|
||||
echo " - latest-linux.yml (Linux)"
|
||||
|
||||
echo ""
|
||||
echo "All release assets:"
|
||||
ls -la release-assets/
|
||||
@@ -445,6 +376,144 @@ jobs:
|
||||
sha256sum ./* > checksums.sha256
|
||||
cat checksums.sha256
|
||||
|
||||
- name: Scan with VirusTotal
|
||||
id: virustotal
|
||||
continue-on-error: true
|
||||
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
|
||||
env:
|
||||
VT_API_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }}
|
||||
run: |
|
||||
if [ -z "$VT_API_KEY" ]; then
|
||||
echo "::warning::VIRUSTOTAL_API_KEY not configured, skipping scan"
|
||||
echo "vt_results=" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "## VirusTotal Scan Results" > vt_results.md
|
||||
echo "" >> vt_results.md
|
||||
|
||||
for file in release-assets/*.{exe,dmg,AppImage,deb,flatpak}; do
|
||||
[ -f "$file" ] || continue
|
||||
filename=$(basename "$file")
|
||||
filesize=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file")
|
||||
echo "Scanning $filename (${filesize} bytes)..."
|
||||
|
||||
# For files > 32MB, get a special upload URL first
|
||||
if [ "$filesize" -gt 33554432 ]; then
|
||||
echo " Large file detected, requesting upload URL..."
|
||||
upload_url_response=$(curl -s --request GET \
|
||||
--url "https://www.virustotal.com/api/v3/files/upload_url" \
|
||||
--header "x-apikey: $VT_API_KEY")
|
||||
|
||||
upload_url=$(echo "$upload_url_response" | jq -r '.data // empty')
|
||||
if [ -z "$upload_url" ]; then
|
||||
echo "::warning::Failed to get upload URL for large file $filename"
|
||||
echo "Response: $upload_url_response"
|
||||
echo "- $filename - ⚠️ Upload failed (large file)" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
api_url="$upload_url"
|
||||
else
|
||||
api_url="https://www.virustotal.com/api/v3/files"
|
||||
fi
|
||||
|
||||
# Upload file to VirusTotal
|
||||
response=$(curl -s --request POST \
|
||||
--url "$api_url" \
|
||||
--header "x-apikey: $VT_API_KEY" \
|
||||
--form "file=@$file")
|
||||
|
||||
# Check if response is valid JSON before parsing
|
||||
if ! echo "$response" | jq -e . >/dev/null 2>&1; then
|
||||
echo "::warning::VirusTotal returned invalid JSON for $filename"
|
||||
echo "Response (first 500 chars): ${response:0:500}"
|
||||
echo "- $filename - ⚠️ Scan failed (invalid response)" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check for API error response
|
||||
error_code=$(echo "$response" | jq -r '.error.code // empty')
|
||||
if [ -n "$error_code" ]; then
|
||||
error_msg=$(echo "$response" | jq -r '.error.message // "Unknown error"')
|
||||
echo "::warning::VirusTotal API error for $filename: $error_code - $error_msg"
|
||||
echo "- $filename - ⚠️ Scan failed ($error_code)" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
|
||||
# Extract analysis ID
|
||||
analysis_id=$(echo "$response" | jq -r '.data.id // empty')
|
||||
|
||||
if [ -z "$analysis_id" ]; then
|
||||
echo "::warning::Failed to upload $filename to VirusTotal"
|
||||
echo "Response: $response"
|
||||
echo "- $filename - ⚠️ Upload failed" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Uploaded $filename, analysis ID: $analysis_id"
|
||||
|
||||
# Wait for analysis to complete (max 5 minutes per file)
|
||||
analysis=""
|
||||
for i in {1..30}; do
|
||||
sleep 10
|
||||
analysis=$(curl -s --request GET \
|
||||
--url "https://www.virustotal.com/api/v3/analyses/$analysis_id" \
|
||||
--header "x-apikey: $VT_API_KEY")
|
||||
|
||||
# Validate JSON response
|
||||
if ! echo "$analysis" | jq -e . >/dev/null 2>&1; then
|
||||
echo " Warning: Invalid JSON response on attempt $i, retrying..."
|
||||
continue
|
||||
fi
|
||||
|
||||
status=$(echo "$analysis" | jq -r '.data.attributes.status // "unknown"')
|
||||
echo " Status: $status (attempt $i/30)"
|
||||
|
||||
if [ "$status" = "completed" ]; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Final validation that we have valid analysis data
|
||||
if ! echo "$analysis" | jq -e '.data.attributes.stats' >/dev/null 2>&1; then
|
||||
echo "::warning::Could not get complete analysis for $filename, using local hash"
|
||||
file_hash=$(sha256sum "$file" | cut -d' ' -f1)
|
||||
echo "- [$filename](https://www.virustotal.com/gui/file/$file_hash) - ⚠️ Analysis incomplete" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
|
||||
# Get file hash for permanent URL
|
||||
file_hash=$(echo "$analysis" | jq -r '.meta.file_info.sha256 // empty')
|
||||
|
||||
if [ -z "$file_hash" ]; then
|
||||
# Fallback: calculate hash locally
|
||||
file_hash=$(sha256sum "$file" | cut -d' ' -f1)
|
||||
fi
|
||||
|
||||
# Get detection stats
|
||||
malicious=$(echo "$analysis" | jq -r '.data.attributes.stats.malicious // 0')
|
||||
suspicious=$(echo "$analysis" | jq -r '.data.attributes.stats.suspicious // 0')
|
||||
undetected=$(echo "$analysis" | jq -r '.data.attributes.stats.undetected // 0')
|
||||
|
||||
vt_url="https://www.virustotal.com/gui/file/$file_hash"
|
||||
|
||||
if [ "$malicious" -gt 0 ] || [ "$suspicious" -gt 0 ]; then
|
||||
echo "::warning::$filename has $malicious malicious and $suspicious suspicious detections (likely false positives)"
|
||||
echo "- [$filename]($vt_url) - ⚠️ **$malicious malicious, $suspicious suspicious** detections (review recommended)" >> vt_results.md
|
||||
else
|
||||
echo "$filename is clean ($undetected engines, 0 detections)"
|
||||
echo "- [$filename]($vt_url) - ✅ Clean ($undetected engines, 0 detections)" >> vt_results.md
|
||||
fi
|
||||
done
|
||||
|
||||
echo "" >> vt_results.md
|
||||
|
||||
# Save results for release notes
|
||||
cat vt_results.md
|
||||
echo "vt_results<<EOF" >> $GITHUB_OUTPUT
|
||||
cat vt_results.md >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Dry run summary
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == true }}
|
||||
run: |
|
||||
@@ -501,8 +570,9 @@ jobs:
|
||||
|
||||
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."
|
||||
CHANGELOG_CONTENT="Release v$VERSION
|
||||
|
||||
See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details."
|
||||
fi
|
||||
|
||||
echo "✅ Extracted changelog content"
|
||||
@@ -526,14 +596,14 @@ jobs:
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md
|
||||
${{ steps.virustotal.outputs.vt_results }}
|
||||
|
||||
_VirusTotal scan results will be added automatically after release._
|
||||
**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') }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.PAT_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Update README with new version after successful release
|
||||
update-readme:
|
||||
@@ -547,8 +617,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
# Use PAT_TOKEN to bypass branch protection rules on main
|
||||
token: ${{ secrets.PAT_TOKEN }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract version and detect release type
|
||||
id: version
|
||||
@@ -568,14 +637,95 @@ jobs:
|
||||
|
||||
- name: Update README.md
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
IS_PRERELEASE="${{ steps.version.outputs.is_prerelease }}"
|
||||
python3 << 'EOF'
|
||||
import re
|
||||
import sys
|
||||
|
||||
if [ "$IS_PRERELEASE" = "true" ]; then
|
||||
node scripts/update-readme.mjs "$VERSION" --prerelease
|
||||
else
|
||||
node scripts/update-readme.mjs "$VERSION"
|
||||
fi
|
||||
version = "${{ steps.version.outputs.version }}"
|
||||
is_prerelease = "${{ steps.version.outputs.is_prerelease }}" == "true"
|
||||
|
||||
# Shields.io escapes hyphens as --
|
||||
version_badge = version.replace("-", "--")
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
name: Test Azure Auth
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test-auth:
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Azure Login (OIDC)
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Success
|
||||
run: echo "Azure authentication successful!"
|
||||
@@ -0,0 +1,63 @@
|
||||
name: Test on Tag
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
# Python tests
|
||||
test-python:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ['3.12', '3.13']
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: apps/backend
|
||||
run: |
|
||||
uv venv
|
||||
uv pip install -r requirements.txt
|
||||
uv pip install -r ../../tests/requirements-test.txt
|
||||
|
||||
- name: Run tests
|
||||
working-directory: apps/backend
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}/apps/backend
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
pytest ../../tests/ -v --tb=short
|
||||
|
||||
# Frontend tests
|
||||
test-frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: apps/frontend
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Run tests
|
||||
working-directory: apps/frontend
|
||||
run: npm run test
|
||||
@@ -0,0 +1,71 @@
|
||||
name: Validate Version
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
validate-version:
|
||||
name: Validate package.json version matches tag
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Extract version from tag
|
||||
id: tag_version
|
||||
run: |
|
||||
# Extract version from tag (e.g., v2.5.5 -> 2.5.5)
|
||||
TAG_VERSION=${GITHUB_REF#refs/tags/v}
|
||||
echo "version=$TAG_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Tag version: $TAG_VERSION"
|
||||
|
||||
- name: Extract version from package.json
|
||||
id: package_version
|
||||
run: |
|
||||
# Read version from package.json
|
||||
PACKAGE_VERSION=$(node -p "require('./apps/frontend/package.json').version")
|
||||
echo "version=$PACKAGE_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Package.json version: $PACKAGE_VERSION"
|
||||
|
||||
- name: Compare versions
|
||||
run: |
|
||||
TAG_VERSION="${{ steps.tag_version.outputs.version }}"
|
||||
PACKAGE_VERSION="${{ steps.package_version.outputs.version }}"
|
||||
|
||||
echo "=========================================="
|
||||
echo "Version Validation"
|
||||
echo "=========================================="
|
||||
echo "Git tag version: v$TAG_VERSION"
|
||||
echo "package.json version: $PACKAGE_VERSION"
|
||||
echo "=========================================="
|
||||
|
||||
if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then
|
||||
echo ""
|
||||
echo "❌ ERROR: Version mismatch detected!"
|
||||
echo ""
|
||||
echo "The version in package.json ($PACKAGE_VERSION) does not match"
|
||||
echo "the git tag version ($TAG_VERSION)."
|
||||
echo ""
|
||||
echo "To fix this:"
|
||||
echo " 1. Delete this tag: git tag -d v$TAG_VERSION"
|
||||
echo " 2. Update package.json version to $TAG_VERSION"
|
||||
echo " 3. Commit the change"
|
||||
echo " 4. Recreate the tag: git tag -a v$TAG_VERSION -m 'Release v$TAG_VERSION'"
|
||||
echo ""
|
||||
echo "Or use the automated script:"
|
||||
echo " node scripts/bump-version.js $TAG_VERSION"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "✅ SUCCESS: Versions match!"
|
||||
echo ""
|
||||
|
||||
- name: Version validation result
|
||||
if: success()
|
||||
run: |
|
||||
echo "::notice::Version validation passed - package.json version matches tag v${{ steps.tag_version.outputs.version }}"
|
||||
@@ -1,343 +0,0 @@
|
||||
name: VirusTotal Scan
|
||||
|
||||
# Runs AFTER release is published to avoid blocking release creation
|
||||
# VirusTotal scans can take 5+ minutes per file, which delays releases
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: 'Release tag to scan (e.g., v2.8.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
# Prevent TOCTOU race condition when updating release notes
|
||||
# If two runs target the same tag, queue them instead of running in parallel
|
||||
concurrency:
|
||||
group: virustotal-${{ github.event.inputs.tag || github.event.release.tag_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
scan:
|
||||
name: Scan release assets
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write # Required to update release notes
|
||||
steps:
|
||||
- name: Determine release tag
|
||||
id: tag
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Check for API key
|
||||
id: check-key
|
||||
env:
|
||||
VT_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }}
|
||||
run: |
|
||||
if [ -z "$VT_KEY" ]; then
|
||||
echo "::warning::VIRUSTOTAL_API_KEY not configured, skipping scan"
|
||||
echo "has_key=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "has_key=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Download release assets
|
||||
if: steps.check-key.outputs.has_key == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${{ steps.tag.outputs.tag }}"
|
||||
echo "Downloading assets for release $TAG..."
|
||||
|
||||
mkdir -p release-assets
|
||||
|
||||
# First verify the release exists
|
||||
if ! gh release view "$TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then
|
||||
echo "::error::Release $TAG not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Download assets, distinguishing between "no matching assets" and real errors
|
||||
set +e
|
||||
gh release download "$TAG" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--pattern "*.exe" \
|
||||
--pattern "*.dmg" \
|
||||
--pattern "*.AppImage" \
|
||||
--pattern "*.deb" \
|
||||
--pattern "*.flatpak" \
|
||||
--dir release-assets 2>&1
|
||||
exit_code=$?
|
||||
set -e
|
||||
|
||||
if [ $exit_code -ne 0 ]; then
|
||||
# Check if it's just "no assets matched" vs a real error
|
||||
asset_count=$(gh release view "$TAG" --repo "${{ github.repository }}" --json assets -q '.assets | length')
|
||||
if [ "$asset_count" -eq 0 ]; then
|
||||
echo "Release has no assets yet (this is OK for new releases)"
|
||||
else
|
||||
# Check if any scannable assets exist that should have been downloaded
|
||||
scannable_assets=$(gh release view "$TAG" --repo "${{ github.repository }}" --json assets \
|
||||
-q '.assets[].name | select(test("\\.(exe|dmg|AppImage|deb|flatpak)$"))' | wc -l)
|
||||
if [ "$scannable_assets" -gt 0 ]; then
|
||||
echo "::error::Download failed - $scannable_assets scannable asset(s) exist but download failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "No assets matched the patterns (exe, dmg, AppImage, deb, flatpak)"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Downloaded assets:"
|
||||
ls -la release-assets/ || echo "No assets found"
|
||||
|
||||
- name: Scan with VirusTotal
|
||||
if: steps.check-key.outputs.has_key == 'true'
|
||||
id: virustotal
|
||||
env:
|
||||
VT_API_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }}
|
||||
run: |
|
||||
echo "## VirusTotal Scan Results" > vt_results.md
|
||||
echo "" >> vt_results.md
|
||||
|
||||
# Check if there are any files to scan
|
||||
shopt -s nullglob
|
||||
files=(release-assets/*.{exe,dmg,AppImage,deb,flatpak})
|
||||
if [ ${#files[@]} -eq 0 ]; then
|
||||
echo "No scannable files found in release assets"
|
||||
echo "- No executable files found in release" >> vt_results.md
|
||||
echo "vt_results<<EOF" >> $GITHUB_OUTPUT
|
||||
cat vt_results.md >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
for file in "${files[@]}"; do
|
||||
[ -f "$file" ] || continue
|
||||
filename=$(basename "$file")
|
||||
filesize=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file")
|
||||
echo "Scanning $filename (${filesize} bytes)..."
|
||||
|
||||
# VirusTotal requires special upload URL for files > 32MB
|
||||
LARGE_FILE_THRESHOLD=33554432 # 32 MB in bytes
|
||||
if [ "$filesize" -gt "$LARGE_FILE_THRESHOLD" ]; then
|
||||
echo " Large file detected, requesting upload URL..."
|
||||
upload_http_response=$(curl -s -w '\n%{http_code}' --request GET \
|
||||
--url "https://www.virustotal.com/api/v3/files/upload_url" \
|
||||
--header "x-apikey: $VT_API_KEY")
|
||||
upload_http_code=$(echo "$upload_http_response" | tail -1)
|
||||
upload_url_response=$(echo "$upload_http_response" | sed '$d')
|
||||
|
||||
if [ "$upload_http_code" != "200" ]; then
|
||||
echo "::warning::Failed to get upload URL for large file $filename (HTTP $upload_http_code)"
|
||||
echo "- $filename - ⚠️ Upload failed (large file, HTTP $upload_http_code)" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
|
||||
upload_url=$(echo "$upload_url_response" | jq -r '.data // empty')
|
||||
if [ -z "$upload_url" ]; then
|
||||
echo "::warning::Failed to get upload URL for large file $filename"
|
||||
echo "Response: $upload_url_response"
|
||||
echo "- $filename - ⚠️ Upload failed (large file)" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
api_url="$upload_url"
|
||||
else
|
||||
api_url="https://www.virustotal.com/api/v3/files"
|
||||
fi
|
||||
|
||||
# Upload file to VirusTotal (capture HTTP status code)
|
||||
http_response=$(curl -s -w '\n%{http_code}' --request POST \
|
||||
--url "$api_url" \
|
||||
--header "x-apikey: $VT_API_KEY" \
|
||||
--form "file=@$file")
|
||||
http_code=$(echo "$http_response" | tail -1)
|
||||
response=$(echo "$http_response" | sed '$d')
|
||||
|
||||
# Check HTTP status code first
|
||||
if [ "$http_code" != "200" ]; then
|
||||
echo "::warning::VirusTotal returned HTTP $http_code for $filename"
|
||||
if [ "$http_code" = "429" ]; then
|
||||
echo "- $filename - ⚠️ Scan failed (rate limited)" >> vt_results.md
|
||||
elif [ "$http_code" = "403" ]; then
|
||||
echo "- $filename - ⚠️ Scan failed (forbidden - check API key)" >> vt_results.md
|
||||
else
|
||||
echo "- $filename - ⚠️ Scan failed (HTTP $http_code)" >> vt_results.md
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check if response is valid JSON before parsing
|
||||
if ! echo "$response" | jq -e . >/dev/null 2>&1; then
|
||||
echo "::warning::VirusTotal returned invalid JSON for $filename"
|
||||
echo "Response (first 500 chars): ${response:0:500}"
|
||||
echo "- $filename - ⚠️ Scan failed (invalid response)" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check for API error response
|
||||
error_code=$(echo "$response" | jq -r '.error.code // empty')
|
||||
if [ -n "$error_code" ]; then
|
||||
error_msg=$(echo "$response" | jq -r '.error.message // "Unknown error"')
|
||||
echo "::warning::VirusTotal API error for $filename: $error_code - $error_msg"
|
||||
echo "- $filename - ⚠️ Scan failed ($error_code)" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
|
||||
# Extract analysis ID
|
||||
analysis_id=$(echo "$response" | jq -r '.data.id // empty')
|
||||
|
||||
if [ -z "$analysis_id" ]; then
|
||||
echo "::warning::Failed to upload $filename to VirusTotal"
|
||||
echo "Response: $response"
|
||||
echo "- $filename - ⚠️ Upload failed" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Uploaded $filename, analysis ID: $analysis_id"
|
||||
|
||||
# Wait for analysis to complete (max 5 minutes per file)
|
||||
analysis=""
|
||||
for i in {1..30}; do
|
||||
sleep 10
|
||||
analysis_http_response=$(curl -s -w '\n%{http_code}' --request GET \
|
||||
--url "https://www.virustotal.com/api/v3/analyses/$analysis_id" \
|
||||
--header "x-apikey: $VT_API_KEY")
|
||||
analysis_http_code=$(echo "$analysis_http_response" | tail -1)
|
||||
analysis=$(echo "$analysis_http_response" | sed '$d')
|
||||
|
||||
# Check HTTP status code
|
||||
if [ "$analysis_http_code" != "200" ]; then
|
||||
echo " Warning: HTTP $analysis_http_code on attempt $i, retrying..."
|
||||
if [ "$analysis_http_code" = "429" ]; then
|
||||
echo " Rate limited, waiting longer..."
|
||||
sleep 30
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
|
||||
# Validate JSON response
|
||||
if ! echo "$analysis" | jq -e . >/dev/null 2>&1; then
|
||||
echo " Warning: Invalid JSON response on attempt $i, retrying..."
|
||||
continue
|
||||
fi
|
||||
|
||||
status=$(echo "$analysis" | jq -r '.data.attributes.status // "unknown"')
|
||||
echo " Status: $status (attempt $i/30)"
|
||||
|
||||
if [ "$status" = "completed" ]; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Handle analysis timeout - if loop completed without status=completed
|
||||
if [ "$status" != "completed" ]; then
|
||||
echo "::warning::Analysis timed out for $filename (status: $status after 5 minutes)"
|
||||
file_hash=$(sha256sum "$file" | cut -d' ' -f1)
|
||||
echo "- [$filename](https://www.virustotal.com/gui/file/$file_hash) - ⚠️ Analysis timed out" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
|
||||
# Final validation that we have valid analysis data
|
||||
if ! echo "$analysis" | jq -e '.data.attributes.stats' >/dev/null 2>&1; then
|
||||
echo "::warning::Could not get complete analysis for $filename, using local hash"
|
||||
file_hash=$(sha256sum "$file" | cut -d' ' -f1)
|
||||
echo "- [$filename](https://www.virustotal.com/gui/file/$file_hash) - ⚠️ Analysis incomplete" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
|
||||
# Get file hash for permanent URL
|
||||
file_hash=$(echo "$analysis" | jq -r '.meta.file_info.sha256 // empty')
|
||||
|
||||
if [ -z "$file_hash" ]; then
|
||||
# Fallback: calculate hash locally
|
||||
file_hash=$(sha256sum "$file" | cut -d' ' -f1)
|
||||
fi
|
||||
|
||||
# Get detection stats
|
||||
malicious=$(echo "$analysis" | jq -r '.data.attributes.stats.malicious // 0')
|
||||
suspicious=$(echo "$analysis" | jq -r '.data.attributes.stats.suspicious // 0')
|
||||
undetected=$(echo "$analysis" | jq -r '.data.attributes.stats.undetected // 0')
|
||||
|
||||
vt_url="https://www.virustotal.com/gui/file/$file_hash"
|
||||
|
||||
if [ "$malicious" -gt 0 ] || [ "$suspicious" -gt 0 ]; then
|
||||
echo "::warning::$filename has $malicious malicious and $suspicious suspicious detections (likely false positives)"
|
||||
echo "- [$filename]($vt_url) - ⚠️ **$malicious malicious, $suspicious suspicious** detections (review recommended)" >> vt_results.md
|
||||
else
|
||||
echo "$filename is clean ($undetected engines, 0 detections)"
|
||||
echo "- [$filename]($vt_url) - ✅ Clean ($undetected engines, 0 detections)" >> vt_results.md
|
||||
fi
|
||||
done
|
||||
|
||||
echo "" >> vt_results.md
|
||||
|
||||
# Save results for next step
|
||||
cat vt_results.md
|
||||
echo "vt_results<<EOF" >> $GITHUB_OUTPUT
|
||||
cat vt_results.md >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Update release notes with scan results
|
||||
if: steps.check-key.outputs.has_key == 'true' && steps.virustotal.outputs.vt_results != ''
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${{ steps.tag.outputs.tag }}"
|
||||
|
||||
# Get current release body with error checking
|
||||
if ! current_body=$(gh release view "$TAG" --repo "${{ github.repository }}" --json body -q '.body'); then
|
||||
echo "::error::Failed to fetch current release body for $TAG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Additional safeguard for empty body
|
||||
if [ -z "$current_body" ]; then
|
||||
echo "::warning::Release body is empty, this may indicate a problem"
|
||||
fi
|
||||
|
||||
# Check if VirusTotal results already exist in the body
|
||||
if echo "$current_body" | grep -q "## VirusTotal Scan Results"; then
|
||||
echo "VirusTotal results already in release notes, skipping update"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Use file-based approach to avoid shell expansion issues
|
||||
# First, write current body to file
|
||||
echo "$current_body" > release-body.md
|
||||
|
||||
# Remove placeholder text if present (portable sed approach)
|
||||
sed '/_VirusTotal scan results will be added automatically after release\./d' release-body.md > release-body.tmp && mv release-body.tmp release-body.md
|
||||
|
||||
# Append separator and VT results
|
||||
echo "" >> release-body.md
|
||||
echo "---" >> release-body.md
|
||||
echo "" >> release-body.md
|
||||
cat vt_results.md >> release-body.md
|
||||
|
||||
# Update release using --notes-file to avoid shell quoting issues
|
||||
gh release edit "$TAG" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--notes-file release-body.md
|
||||
|
||||
echo "✅ Updated release notes with VirusTotal scan results"
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo "## VirusTotal Scan Summary" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Release:** ${{ steps.tag.outputs.tag }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
if [ "${{ steps.check-key.outputs.has_key }}" = "false" ]; then
|
||||
echo "⚠️ Scan skipped: VIRUSTOTAL_API_KEY not configured" >> $GITHUB_STEP_SUMMARY
|
||||
elif [ -f vt_results.md ]; then
|
||||
cat vt_results.md >> $GITHUB_STEP_SUMMARY
|
||||
else
|
||||
echo "No scan results available" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
+47
-21
@@ -7,7 +7,6 @@
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
nul
|
||||
|
||||
# ===========================
|
||||
# Security - Environment & Secrets
|
||||
@@ -15,7 +14,6 @@ nul
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
/config.json
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
@@ -57,8 +55,6 @@ lerna-debug.log*
|
||||
# Auto Claude Generated
|
||||
# ===========================
|
||||
.auto-claude/
|
||||
.planning/
|
||||
.planning-archive/
|
||||
.auto-build-security.json
|
||||
.auto-claude-security.json
|
||||
.auto-claude-status
|
||||
@@ -66,10 +62,51 @@ lerna-debug.log*
|
||||
.update-metadata.json
|
||||
|
||||
# ===========================
|
||||
# Node.js (apps/desktop)
|
||||
# Python (apps/backend)
|
||||
# ===========================
|
||||
node_modules
|
||||
apps/desktop/node_modules
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
eggs/
|
||||
.eggs/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Virtual environments
|
||||
.venv/
|
||||
venv/
|
||||
ENV/
|
||||
env/
|
||||
.conda/
|
||||
|
||||
# Testing
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
|
||||
# Type checking
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
.pytype/
|
||||
.pyre/
|
||||
|
||||
# ===========================
|
||||
# Node.js (apps/frontend)
|
||||
# ===========================
|
||||
node_modules/
|
||||
.npm
|
||||
.yarn/
|
||||
.pnp.*
|
||||
@@ -78,6 +115,7 @@ apps/desktop/node_modules
|
||||
dist/
|
||||
out/
|
||||
*.tsbuildinfo
|
||||
apps/frontend/python-runtime/
|
||||
|
||||
# Cache
|
||||
.cache/
|
||||
@@ -89,8 +127,8 @@ out/
|
||||
# ===========================
|
||||
# Electron
|
||||
# ===========================
|
||||
apps/desktop/dist/
|
||||
apps/desktop/out/
|
||||
apps/frontend/dist/
|
||||
apps/frontend/out/
|
||||
*.asar
|
||||
*.blockmap
|
||||
*.snap
|
||||
@@ -110,12 +148,6 @@ test-results/
|
||||
playwright-report/
|
||||
playwright/.cache/
|
||||
|
||||
# ===========================
|
||||
# Python
|
||||
# ===========================
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# ===========================
|
||||
# Misc
|
||||
# ===========================
|
||||
@@ -132,9 +164,3 @@ _bmad-output/
|
||||
/docs
|
||||
OPUS_ANALYSIS_AND_IDEAS.md
|
||||
/.github/agents
|
||||
|
||||
# Auto Claude generated files
|
||||
.security-key
|
||||
/shared_docs
|
||||
logs/security/
|
||||
Agents.md
|
||||
|
||||
+117
-114
@@ -1,39 +1,5 @@
|
||||
#!/bin/sh
|
||||
|
||||
# =============================================================================
|
||||
# GIT WORKTREE ENVIRONMENT CLEANUP
|
||||
# =============================================================================
|
||||
# Git automatically sets GIT_DIR (and CWD to the working tree root) before
|
||||
# running hooks -- even in worktrees. We do NOT need to manually parse .git
|
||||
# files or export GIT_DIR/GIT_WORK_TREE.
|
||||
#
|
||||
# However, external tools (IDEs, agents, parent shells) may leave stale
|
||||
# GIT_DIR/GIT_WORK_TREE values in the environment. If these point to a
|
||||
# different repo or worktree, git commands in this hook would target the
|
||||
# wrong repository. Unsetting them lets git re-resolve the correct values
|
||||
# from the working directory.
|
||||
# =============================================================================
|
||||
unset GIT_DIR
|
||||
unset GIT_WORK_TREE
|
||||
|
||||
# =============================================================================
|
||||
# SAFETY CHECK: Detect and fix corrupted core.worktree configuration
|
||||
# =============================================================================
|
||||
# core.worktree lives in the SHARED .git/config (not per-worktree). If any
|
||||
# process accidentally writes it (e.g., running `git init` with a leaked
|
||||
# GIT_WORK_TREE), ALL repos and worktrees see the wrong working tree root,
|
||||
# causing files from one worktree to "leak" into others.
|
||||
#
|
||||
# This check runs from both main repo and worktree contexts since the config
|
||||
# is shared and corruption can happen from either.
|
||||
CORE_WORKTREE=$(git config --get core.worktree 2>/dev/null || true)
|
||||
if [ -n "$CORE_WORKTREE" ]; then
|
||||
echo "Warning: Detected corrupted core.worktree setting ('$CORE_WORKTREE'), removing it..."
|
||||
if ! git config --unset core.worktree 2>/dev/null; then
|
||||
echo "Warning: Failed to unset core.worktree. Manual intervention may be needed."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Running pre-commit checks..."
|
||||
|
||||
# =============================================================================
|
||||
@@ -48,18 +14,26 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
|
||||
if [ -n "$VERSION" ]; then
|
||||
# Sync to apps/desktop/package.json
|
||||
if [ -f "apps/desktop/package.json" ]; then
|
||||
# Sync to apps/frontend/package.json
|
||||
if [ -f "apps/frontend/package.json" ]; then
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = require('./apps/desktop/package.json');
|
||||
const pkg = require('./apps/frontend/package.json');
|
||||
if (pkg.version !== '$VERSION') {
|
||||
pkg.version = '$VERSION';
|
||||
fs.writeFileSync('./apps/desktop/package.json', JSON.stringify(pkg, null, 2) + '\n');
|
||||
console.log(' Updated apps/desktop/package.json to $VERSION');
|
||||
fs.writeFileSync('./apps/frontend/package.json', JSON.stringify(pkg, null, 2) + '\n');
|
||||
console.log(' Updated apps/frontend/package.json to $VERSION');
|
||||
}
|
||||
"
|
||||
git add apps/desktop/package.json
|
||||
git add apps/frontend/package.json
|
||||
fi
|
||||
|
||||
# Sync to apps/backend/__init__.py
|
||||
if [ -f "apps/backend/__init__.py" ]; then
|
||||
sed -i.bak "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" apps/backend/__init__.py
|
||||
rm -f apps/backend/__init__.py.bak
|
||||
git add apps/backend/__init__.py
|
||||
echo " Updated apps/backend/__init__.py to $VERSION"
|
||||
fi
|
||||
|
||||
# Sync to README.md - section-aware updates (stable vs beta)
|
||||
@@ -79,9 +53,8 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
|
||||
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)
|
||||
# Use perl for cross-platform compatibility (BSD sed doesn't support {block} syntax)
|
||||
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb" "linux-x86_64.flatpak"; do
|
||||
perl -i -pe 'if (/<!-- 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
|
||||
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
|
||||
@@ -96,9 +69,8 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
|
||||
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)
|
||||
# Use perl for cross-platform compatibility (BSD sed doesn't support {block} syntax)
|
||||
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb"; do
|
||||
perl -i -pe 'if (/<!-- 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
|
||||
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
|
||||
|
||||
@@ -111,86 +83,117 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DESKTOP APP CHECKS (TypeScript/React)
|
||||
# BACKEND CHECKS (Python) - Run first, before frontend
|
||||
# =============================================================================
|
||||
|
||||
# Check if there are staged files in apps/desktop
|
||||
if git diff --cached --name-only | grep -q "^apps/desktop/"; then
|
||||
echo "Desktop app changes detected, running checks..."
|
||||
# Check if there are staged Python files in apps/backend
|
||||
if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
|
||||
echo "Python changes detected, running backend checks..."
|
||||
|
||||
# Detect if we're in a worktree and check if dependencies are available
|
||||
IS_WORKTREE=false
|
||||
DEPS_AVAILABLE=true
|
||||
|
||||
if [ -f ".git" ]; then
|
||||
# .git is a file (not directory) in worktrees
|
||||
IS_WORKTREE=true
|
||||
echo "Detected git worktree environment"
|
||||
# Determine ruff command (venv or global)
|
||||
RUFF=""
|
||||
if [ -f "apps/backend/.venv/bin/ruff" ]; then
|
||||
RUFF="apps/backend/.venv/bin/ruff"
|
||||
elif [ -f "apps/backend/.venv/Scripts/ruff.exe" ]; then
|
||||
RUFF="apps/backend/.venv/Scripts/ruff.exe"
|
||||
elif command -v ruff >/dev/null 2>&1; then
|
||||
RUFF="ruff"
|
||||
fi
|
||||
|
||||
# Check if node_modules has actual dependencies by looking for a known package
|
||||
# @lydell/node-pty is required for terminal code and is a common source of TypeScript errors
|
||||
# It may be in root node_modules (hoisted) or apps/desktop/node_modules
|
||||
# Note: -d follows symlinks automatically, so this works for both real dirs and symlinks
|
||||
# We check for the full package path (@lydell/node-pty) rather than just the namespace
|
||||
# for precise detection - ensures the actual dependency is installed, not just any @lydell package
|
||||
if [ ! -d "node_modules/@lydell/node-pty" ] && [ ! -d "apps/desktop/node_modules/@lydell/node-pty" ]; then
|
||||
DEPS_AVAILABLE=false
|
||||
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 [ "$DEPS_AVAILABLE" = false ]; then
|
||||
if [ "$IS_WORKTREE" = true ]; then
|
||||
# In worktree without dependencies - warn but allow commit
|
||||
echo ""
|
||||
echo "⚠️ WARNING: node_modules not available in this worktree."
|
||||
echo " TypeScript and lint checks will be skipped."
|
||||
echo " This is expected for auto-claude worktrees."
|
||||
echo " Full validation will occur when PR is created/merged."
|
||||
echo ""
|
||||
else
|
||||
# Main repo without dependencies - this is an error
|
||||
echo "Error: node_modules not found. Run 'npm install' first."
|
||||
exit 1
|
||||
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
|
||||
fi
|
||||
else
|
||||
# Dependencies available - run full frontend checks
|
||||
# Use subshell to isolate directory changes and prevent worktree corruption
|
||||
(
|
||||
cd apps/desktop
|
||||
|
||||
# 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 (incremental: only rechecks changed files after first run)
|
||||
echo "Running type check..."
|
||||
NODE_OPTIONS="--max-old-space-size=2048" npm run typecheck
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Type check failed. Please fix TypeScript errors before committing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for vulnerabilities (only critical severity)
|
||||
# Note: Using critical level because electron-builder has a known high-severity
|
||||
# tar vulnerability (CVE-2026-23745) that cannot be fixed until electron-builder
|
||||
# releases an update with tar@7.x support. This is a build dependency, not runtime.
|
||||
echo "Checking for vulnerabilities..."
|
||||
npm audit --audit-level=critical
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Critical severity vulnerabilities found. Run 'npm audit fix' to resolve."
|
||||
exit 1
|
||||
fi
|
||||
)
|
||||
if [ $? -ne 0 ]; then
|
||||
exit 1
|
||||
fi
|
||||
echo "Frontend checks passed!"
|
||||
echo "Warning: ruff not found, skipping Python linting. Install with: uv pip install ruff"
|
||||
fi
|
||||
|
||||
# Run pytest (skip slow/integration tests and Windows-incompatible tests for pre-commit speed)
|
||||
echo "Running Python tests..."
|
||||
cd apps/backend
|
||||
# Tests to skip: graphiti (external deps), merge_file_tracker/service_orchestrator/worktree/workspace (Windows path/git issues)
|
||||
IGNORE_TESTS="--ignore=../../tests/test_graphiti.py --ignore=../../tests/test_merge_file_tracker.py --ignore=../../tests/test_service_orchestrator.py --ignore=../../tests/test_worktree.py --ignore=../../tests/test_workspace.py"
|
||||
if [ -d ".venv" ]; then
|
||||
# Use venv if it exists
|
||||
if [ -f ".venv/bin/pytest" ]; then
|
||||
PYTHONPATH=. .venv/bin/pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
|
||||
elif [ -f ".venv/Scripts/pytest.exe" ]; then
|
||||
# Windows
|
||||
PYTHONPATH=. .venv/Scripts/pytest.exe ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
|
||||
else
|
||||
PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
|
||||
fi
|
||||
else
|
||||
PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
|
||||
fi
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Python tests failed. Please fix failing tests before committing."
|
||||
exit 1
|
||||
fi
|
||||
cd ../..
|
||||
|
||||
echo "Backend checks passed!"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# FRONTEND CHECKS (TypeScript/React)
|
||||
# =============================================================================
|
||||
|
||||
# Check if there are staged files in apps/frontend
|
||||
if git diff --cached --name-only | grep -q "^apps/frontend/"; then
|
||||
echo "Frontend changes detected, running frontend checks..."
|
||||
cd apps/frontend
|
||||
|
||||
# Run lint-staged (handles staged .ts/.tsx files)
|
||||
npm exec lint-staged
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "lint-staged failed. Please fix linting errors before committing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run TypeScript type check
|
||||
echo "Running type check..."
|
||||
npm run typecheck
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Type check failed. Please fix TypeScript errors before committing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run linting
|
||||
echo "Running lint..."
|
||||
npm run lint
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Lint failed. Run 'npm run lint:fix' to auto-fix issues."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for vulnerabilities (only high severity)
|
||||
echo "Checking for vulnerabilities..."
|
||||
npm audit --audit-level=high
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "High severity vulnerabilities found. Run 'npm audit fix' to resolve."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd ../..
|
||||
echo "Frontend checks passed!"
|
||||
fi
|
||||
|
||||
echo "All pre-commit checks passed!"
|
||||
|
||||
+69
-16
@@ -18,17 +18,20 @@ repos:
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
if [ -n "$VERSION" ]; then
|
||||
|
||||
# Sync to apps/desktop/package.json
|
||||
# Sync to apps/frontend/package.json
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const p = require('./apps/desktop/package.json');
|
||||
const p = require('./apps/frontend/package.json');
|
||||
const v = process.argv[1];
|
||||
if (p.version !== v) {
|
||||
p.version = v;
|
||||
fs.writeFileSync('./apps/desktop/package.json', JSON.stringify(p, null, 2) + '\n');
|
||||
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')
|
||||
|
||||
@@ -67,30 +70,80 @@ repos:
|
||||
rm -f README.md.bak
|
||||
|
||||
# Stage changes
|
||||
git add apps/desktop/package.json README.md 2>/dev/null || true
|
||||
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
|
||||
|
||||
# Frontend linting (apps/desktop/) - Biome is 15-25x faster than ESLint
|
||||
# NOTE: These hooks check for worktree context to avoid npm/node_modules issues
|
||||
# Python linting (apps/backend/)
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.14.10
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
files: ^apps/backend/
|
||||
- id: ruff-format
|
||||
files: ^apps/backend/
|
||||
|
||||
# Python tests (apps/backend/) - skip slow/integration tests for pre-commit speed
|
||||
# Tests to skip: graphiti (external deps), merge_file_tracker/service_orchestrator/worktree/workspace (Windows path/git issues)
|
||||
# NOTE: Skip this hook in worktrees (where .git is a file, not a directory)
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: biome
|
||||
name: Biome (lint + format)
|
||||
- id: pytest
|
||||
name: Python Tests
|
||||
entry: bash
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
# Skip in worktrees if node_modules doesn't exist (Biome not installed)
|
||||
if [ -f ".git" ] && [ ! -d "apps/desktop/node_modules" ]; then
|
||||
echo "Skipping Biome in worktree (node_modules not found)"
|
||||
# Skip in worktrees - .git is a file pointing to main repo, not a directory
|
||||
# This prevents path resolution issues with ../../tests/ in worktree context
|
||||
if [ -f ".git" ]; then
|
||||
echo "Skipping pytest in worktree (path resolution would fail)"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/desktop && npx biome check --write --no-errors-on-unmatched .
|
||||
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
|
||||
language: system
|
||||
files: ^apps/desktop/.*\.(ts|tsx|js|jsx|json)$
|
||||
files: ^(apps/backend/.*\.py$|tests/.*\.py$)
|
||||
pass_filenames: false
|
||||
|
||||
# Frontend linting (apps/frontend/)
|
||||
# NOTE: These hooks check for worktree context to avoid npm/node_modules issues
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: eslint
|
||||
name: ESLint
|
||||
entry: bash
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
# Skip in worktrees if node_modules doesn't exist (dependencies not installed)
|
||||
if [ -f ".git" ] && [ ! -d "apps/frontend/node_modules" ]; then
|
||||
echo "Skipping ESLint in worktree (node_modules not found)"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/frontend && npm run lint
|
||||
language: system
|
||||
files: ^apps/frontend/.*\.(ts|tsx|js|jsx)$
|
||||
pass_filenames: false
|
||||
|
||||
- id: typecheck
|
||||
@@ -100,13 +153,13 @@ repos:
|
||||
- -c
|
||||
- |
|
||||
# Skip in worktrees if node_modules doesn't exist (dependencies not installed)
|
||||
if [ -f ".git" ] && [ ! -d "apps/desktop/node_modules" ]; then
|
||||
if [ -f ".git" ] && [ ! -d "apps/frontend/node_modules" ]; then
|
||||
echo "Skipping TypeScript check in worktree (node_modules not found)"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/desktop && npm run typecheck
|
||||
cd apps/frontend && npm run typecheck
|
||||
language: system
|
||||
files: ^apps/desktop/.*\.(ts|tsx)$
|
||||
files: ^apps/frontend/.*\.(ts|tsx)$
|
||||
pass_filenames: false
|
||||
|
||||
# General checks
|
||||
|
||||
+5
-1294
File diff suppressed because it is too large
Load Diff
@@ -1,359 +1,498 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code when working with this repository.
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
Auto Claude is an autonomous multi-agent coding framework that plans, builds, and validates software for you. It's a TypeScript-first Electron desktop application with a self-contained AI agent layer (Vercel AI SDK v6). A lightweight Python sidecar provides the optional Graphiti memory system.
|
||||
## Project Overview
|
||||
|
||||
> **Deep-dive reference:** [ARCHITECTURE.md](shared_docs/ARCHITECTURE.md) | **Frontend contributing:** [apps/desktop/CONTRIBUTING.md](apps/desktop/CONTRIBUTING.md)
|
||||
Auto Claude is a multi-agent autonomous coding framework that builds software through coordinated AI agent sessions. It uses the Claude Agent SDK to run agents in isolated workspaces with security controls.
|
||||
|
||||
## Product Overview
|
||||
|
||||
Auto Claude is a desktop application (+ CLI) where users describe a goal and AI agents autonomously handle planning, implementation, and QA validation. All work happens in isolated git worktrees so the main branch stays safe.
|
||||
|
||||
**Core workflow:** User creates a task → Spec creation pipeline assesses complexity and writes a specification → Planner agent breaks it into subtasks → Coder agent implements (can spawn parallel subagents) → QA reviewer validates → QA fixer resolves issues → User reviews and merges.
|
||||
|
||||
**Main features:**
|
||||
|
||||
- **Autonomous Tasks** — Multi-agent pipeline (planner, coder, QA) that builds features end-to-end
|
||||
- **Kanban Board** — Visual task management from planning through completion
|
||||
- **Agent Terminals** — Up to 12 parallel AI-powered terminals with task context injection
|
||||
- **Insights** — AI chat interface for exploring and understanding your codebase
|
||||
- **Roadmap** — AI-assisted feature planning with strategic roadmap generation
|
||||
- **Ideation** — Discover improvements, performance issues, and security vulnerabilities
|
||||
- **GitHub/GitLab Integration** — Import issues, AI-powered investigation, PR/MR review and creation
|
||||
- **Changelog** — Generate release notes from completed tasks
|
||||
- **Memory System** — Graphiti-based knowledge graph retains insights across sessions
|
||||
- **Isolated Workspaces** — Git worktree isolation for every build; AI-powered semantic merge
|
||||
- **Flexible Authentication** — Use a Claude Code subscription (OAuth) or API profiles with any Anthropic-compatible endpoint (e.g., Anthropic API, z.ai for GLM models)
|
||||
- **Multi-Account Swapping** — Register multiple Claude accounts; when one hits a rate limit, Auto Claude automatically switches to an available account
|
||||
- **Cross-Platform** — Native desktop app for Windows, macOS, and Linux with auto-updates
|
||||
|
||||
## Critical Rules
|
||||
|
||||
**Vercel AI SDK only** — All AI interactions use the Vercel AI SDK v6 (`ai` package) via the TypeScript agent layer in `apps/desktop/src/main/ai/`. NEVER use `@anthropic-ai/sdk` or `anthropic.Anthropic()` directly. Use `createProvider()` from `ai/providers/factory.ts` and `streamText()`/`generateText()` from the `ai` package. Provider-specific adapters (e.g., `@ai-sdk/anthropic`, `@ai-sdk/openai`) are managed through the provider registry.
|
||||
|
||||
**i18n required** — All frontend user-facing text uses `react-i18next` translation keys. Hardcoded strings in JSX/TSX break localization for non-English users. Add keys to both `en/*.json` and `fr/*.json`.
|
||||
|
||||
**Platform abstraction** — Never use `process.platform` directly. Import from `apps/desktop/src/main/platform/`. CI tests all three platforms.
|
||||
|
||||
**No time estimates** — Provide priority-based ordering instead of duration predictions.
|
||||
|
||||
**PR target** — Always target the `develop` branch for PRs, not `main`. Main is reserved for releases.
|
||||
|
||||
**No console.log in production code** — `console.log` output is invisible in bundled Electron apps. Use Sentry for error tracking in production; reserve `console.log` for development only.
|
||||
|
||||
## Work Approach: Orchestrator-First
|
||||
|
||||
You are an orchestrator. Your primary role is to understand what needs to be done, break it into workstreams, and delegate execution to agent teams. This keeps your context window focused on coordination and decision-making rather than filling up with implementation details.
|
||||
|
||||
<orchestrator_pattern>
|
||||
When given a task, follow this pattern:
|
||||
|
||||
1. **Investigate first** — Read the actual code before forming any hypothesis. Use targeted searches (Glob, Grep, Read) for simple lookups. For broader exploration, spawn an Explore agent.
|
||||
|
||||
2. **Plan the approach** — Identify what needs to change, which files are involved, and whether work can be parallelized. For multi-step tasks, create a task list to track workstreams.
|
||||
|
||||
3. **Delegate execution** — Spawn agent teams to do the implementation work. Each agent gets a clear, self-contained assignment with all the context it needs: relevant file paths, the specific change to make, and acceptance criteria. Run independent workstreams in parallel.
|
||||
|
||||
4. **Verify and integrate** — Review agent outputs, run tests, and ensure changes work together. Fix integration issues or spawn follow-up agents as needed.
|
||||
</orchestrator_pattern>
|
||||
|
||||
**When to delegate vs. do directly:**
|
||||
- Delegate: multi-file changes, research across the codebase, independent parallel workstreams, tasks that would consume significant context
|
||||
- Do directly: single-file edits, simple bug fixes, quick lookups, tasks where you already have the context
|
||||
|
||||
**Giving agents good assignments** — Each agent works with a fresh context. Include: the specific goal, relevant file paths, code patterns to follow, and what "done" looks like. Agents perform better with explicit, complete instructions than with vague references to "the current task."
|
||||
|
||||
**Minimal changes only** — Prefer the simplest approach (e.g., prompt-only changes, single guard clause) before suggesting multi-component solutions. If the user asks for X, implement X — don't bundle additional fixes they didn't request.
|
||||
|
||||
**Default to action** — When the user's intent implies making changes, implement them rather than only suggesting. If something is unclear, read the relevant code to fill in the gaps rather than asking. Only ask when genuine ambiguity remains about what the user wants.
|
||||
|
||||
## Context Management
|
||||
|
||||
Your context window will be automatically compacted as it approaches its limit, allowing you to continue working indefinitely. Do not stop tasks early due to context concerns — instead, persist progress and keep going.
|
||||
|
||||
**For long-running tasks:** Use git commits, task lists, and structured notes to track state. When context compacts, review git log and any progress files to re-orient. Focus on incremental progress — complete one component before moving to the next, and commit working states along the way.
|
||||
|
||||
**Parallel tool calls** — When reading multiple files, running independent searches, or executing unrelated commands, make all calls in parallel rather than sequentially. This significantly speeds up investigation and implementation.
|
||||
|
||||
## Known Gotchas
|
||||
|
||||
**Electron path resolution** — For bug fixes in the Electron app, check path resolution differences between dev and production builds (`app.isPackaged`, `process.resourcesPath`). Paths that work in dev often break when Electron is bundled for production — verify both contexts.
|
||||
|
||||
### Resetting PR Review State
|
||||
|
||||
To fully clear all PR review data so reviews run fresh, delete/reset these three things in `.auto-claude/github/`:
|
||||
|
||||
1. `rm .auto-claude/github/pr/logs_*.json` — review log files
|
||||
2. `rm .auto-claude/github/pr/review_*.json` — review result files
|
||||
3. Reset `pr/index.json` to `{"reviews": [], "last_updated": null}`
|
||||
4. Reset `bot_detection_state.json` to `{"reviewed_commits": {}}` — this is the gatekeeper; without clearing it, the bot detector skips already-seen commits
|
||||
**CRITICAL: All AI interactions use the Claude Agent SDK (`claude-agent-sdk` package), NOT the Anthropic API directly.**
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
autonomous-coding/
|
||||
├── apps/
|
||||
│ └── desktop/ # Electron desktop application (sole app)
|
||||
│ ├── prompts/ # Agent system prompts (.md)
|
||||
│ └── src/
|
||||
│ ├── main/ # Electron main process
|
||||
│ │ ├── ai/ # TypeScript AI agent layer (Vercel AI SDK v6)
|
||||
│ │ │ ├── providers/ # Multi-provider registry + factory (9+ providers)
|
||||
│ │ │ ├── tools/ # Builtin tools (Read, Write, Edit, Bash, Glob, Grep, etc.)
|
||||
│ │ │ ├── security/ # Bash validator, command parser, path containment
|
||||
│ │ │ ├── config/ # Agent configs (25+ types), phase config, model resolution
|
||||
│ │ │ ├── session/ # streamText() agent loop, error classification, progress
|
||||
│ │ │ ├── agent/ # Worker thread executor + bridge
|
||||
│ │ │ ├── orchestration/ # Build pipeline (planner → coder → QA)
|
||||
│ │ │ ├── runners/ # Utility runners (insights, roadmap, PR review, etc.)
|
||||
│ │ │ ├── mcp/ # MCP client integration
|
||||
│ │ │ ├── client/ # Client factory convenience constructors
|
||||
│ │ │ └── auth/ # Token resolution (reuses claude-profile/)
|
||||
│ │ ├── agent/ # Agent queue, process, state, events
|
||||
│ │ ├── claude-profile/ # Multi-profile credentials, token refresh, usage
|
||||
│ │ ├── terminal/ # PTY daemon, lifecycle, Claude integration
|
||||
│ │ ├── platform/ # Cross-platform abstraction
|
||||
│ │ ├── ipc-handlers/# 40+ handler modules by domain
|
||||
│ │ ├── services/ # Session recovery, profile service
|
||||
│ │ └── changelog/ # Changelog generation and formatting
|
||||
│ ├── preload/ # Electron preload scripts (electronAPI bridge)
|
||||
│ ├── renderer/ # React UI
|
||||
│ │ ├── components/ # UI components (onboarding, settings, task, terminal, github, etc.)
|
||||
│ │ ├── stores/ # 24+ Zustand state stores
|
||||
│ │ ├── contexts/ # React contexts (ViewStateContext)
|
||||
│ │ ├── hooks/ # Custom hooks (useIpc, useTerminal, etc.)
|
||||
│ │ ├── styles/ # CSS / Tailwind styles
|
||||
│ │ └── App.tsx # Root component
|
||||
│ ├── shared/ # Shared types, i18n, constants, utils
|
||||
│ │ ├── i18n/locales/# en/*.json, fr/*.json
|
||||
│ │ ├── constants/ # themes.ts, etc.
|
||||
│ │ ├── types/ # 19+ type definition files
|
||||
│ │ └── utils/ # ANSI sanitizer, shell escape, provider detection
|
||||
│ └── types/ # TypeScript type definitions
|
||||
├── guides/ # Documentation
|
||||
└── scripts/ # Build and utility scripts
|
||||
│ ├── backend/ # Python backend/CLI - ALL agent logic lives here
|
||||
│ │ ├── core/ # Client, auth, security
|
||||
│ │ ├── agents/ # Agent implementations
|
||||
│ │ ├── spec_agents/ # Spec creation agents
|
||||
│ │ ├── integrations/ # Graphiti, Linear, GitHub
|
||||
│ │ └── prompts/ # Agent system prompts
|
||||
│ └── frontend/ # Electron desktop UI
|
||||
├── guides/ # Documentation
|
||||
├── tests/ # Test suite
|
||||
└── scripts/ # Build and utility scripts
|
||||
```
|
||||
|
||||
## Commands Quick Reference
|
||||
**When working with AI/LLM code:**
|
||||
- Look in `apps/backend/core/client.py` for the Claude SDK client setup
|
||||
- Reference `apps/backend/agents/` for working agent implementations
|
||||
- Check `apps/backend/spec_agents/` for spec creation agent examples
|
||||
- NEVER use `anthropic.Anthropic()` directly - always use `create_client()` from `core.client`
|
||||
|
||||
**Frontend (Electron Desktop App):**
|
||||
- Built with Electron, React, TypeScript
|
||||
- AI agents can perform E2E testing using the Electron MCP server
|
||||
- When bug fixing or implementing features, use the Electron MCP server for automated testing
|
||||
- See "End-to-End Testing" section below for details
|
||||
|
||||
## Commands
|
||||
|
||||
### Setup
|
||||
|
||||
**Requirements:**
|
||||
- Python 3.12+ (required for backend)
|
||||
- Node.js (for frontend)
|
||||
|
||||
```bash
|
||||
npm run install:all # Install all dependencies from root
|
||||
# Or separately:
|
||||
cd apps/desktop && npm install
|
||||
# Install all dependencies from root
|
||||
npm run install:all
|
||||
|
||||
# Or install separately:
|
||||
# Backend (from apps/backend/)
|
||||
cd apps/backend && uv venv && uv pip install -r requirements.txt
|
||||
|
||||
# Frontend (from apps/frontend/)
|
||||
cd apps/frontend && npm install
|
||||
|
||||
# Set up OAuth token
|
||||
claude setup-token
|
||||
# Add to apps/backend/.env: CLAUDE_CODE_OAUTH_TOKEN=your-token
|
||||
```
|
||||
|
||||
### Creating and Running Specs
|
||||
```bash
|
||||
cd apps/backend
|
||||
|
||||
# Create a spec interactively
|
||||
python spec_runner.py --interactive
|
||||
|
||||
# Create spec from task description
|
||||
python spec_runner.py --task "Add user authentication"
|
||||
|
||||
# Force complexity level (simple/standard/complex)
|
||||
python spec_runner.py --task "Fix button" --complexity simple
|
||||
|
||||
# Run autonomous build
|
||||
python run.py --spec 001
|
||||
|
||||
# List all specs
|
||||
python run.py --list
|
||||
```
|
||||
|
||||
### Workspace Management
|
||||
```bash
|
||||
cd apps/backend
|
||||
|
||||
# Review changes in isolated worktree
|
||||
python run.py --spec 001 --review
|
||||
|
||||
# Merge completed build into project
|
||||
python run.py --spec 001 --merge
|
||||
|
||||
# Discard build
|
||||
python run.py --spec 001 --discard
|
||||
```
|
||||
|
||||
### QA Validation
|
||||
```bash
|
||||
cd apps/backend
|
||||
|
||||
# Run QA manually
|
||||
python run.py --spec 001 --qa
|
||||
|
||||
# Check QA status
|
||||
python run.py --spec 001 --qa-status
|
||||
```
|
||||
|
||||
### Testing
|
||||
```bash
|
||||
# Install test dependencies (required first time)
|
||||
cd apps/backend && uv pip install -r ../../tests/requirements-test.txt
|
||||
|
||||
| Stack | Command | Tool |
|
||||
|-------|---------|------|
|
||||
| Frontend unit | `cd apps/desktop && npm test` | Vitest |
|
||||
| Frontend E2E | `cd apps/desktop && npm run test:e2e` | Playwright |
|
||||
# Run all tests (use virtual environment pytest)
|
||||
apps/backend/.venv/bin/pytest tests/ -v
|
||||
|
||||
# Run single test file
|
||||
apps/backend/.venv/bin/pytest tests/test_security.py -v
|
||||
|
||||
# Run specific test
|
||||
apps/backend/.venv/bin/pytest tests/test_security.py::test_bash_command_validation -v
|
||||
|
||||
# Skip slow tests
|
||||
apps/backend/.venv/bin/pytest tests/ -m "not slow"
|
||||
|
||||
# Or from root
|
||||
npm run test:backend
|
||||
```
|
||||
|
||||
### Spec Validation
|
||||
```bash
|
||||
python apps/backend/validate_spec.py --spec-dir apps/backend/specs/001-feature --checkpoint all
|
||||
```
|
||||
|
||||
### Releases
|
||||
```bash
|
||||
node scripts/bump-version.js patch|minor|major # Bump version
|
||||
git push && gh pr create --base main # PR to main triggers release
|
||||
# 1. Bump version on your branch (creates commit, no tag)
|
||||
node scripts/bump-version.js patch # 2.8.0 -> 2.8.1
|
||||
node scripts/bump-version.js minor # 2.8.0 -> 2.9.0
|
||||
node scripts/bump-version.js major # 2.8.0 -> 3.0.0
|
||||
|
||||
# 2. Push and create PR to main
|
||||
git push origin your-branch
|
||||
gh pr create --base main
|
||||
|
||||
# 3. Merge PR → GitHub Actions automatically:
|
||||
# - Creates tag
|
||||
# - Builds all platforms
|
||||
# - Creates release with changelog
|
||||
# - Updates README
|
||||
```
|
||||
|
||||
See [RELEASE.md](RELEASE.md) for full release process.
|
||||
See [RELEASE.md](RELEASE.md) for detailed release process documentation.
|
||||
|
||||
## AI Agent Layer (`apps/desktop/src/main/ai/`)
|
||||
## Architecture
|
||||
|
||||
All AI agent logic lives in TypeScript using the Vercel AI SDK v6. This replaces the previous Python `claude-agent-sdk` integration.
|
||||
### Core Pipeline
|
||||
|
||||
### Architecture Overview
|
||||
**Spec Creation (spec_runner.py)** - Dynamic 3-8 phase pipeline based on task complexity:
|
||||
- SIMPLE (3 phases): Discovery → Quick Spec → Validate
|
||||
- STANDARD (6-7 phases): Discovery → Requirements → [Research] → Context → Spec → Plan → Validate
|
||||
- COMPLEX (8 phases): Full pipeline with Research and Self-Critique phases
|
||||
|
||||
- **Provider Layer** (`providers/`) — Multi-provider support via `createProviderRegistry()`. Supports Anthropic, OpenAI, Google, Bedrock, Azure, Mistral, Groq, xAI, and Ollama. Provider-specific transforms handle thinking token normalization and prompt caching.
|
||||
- **Session Runtime** (`session/`) — `runAgentSession()` uses `streamText()` with `stopWhen: stepCountIs(N)` for agentic tool-use loops. Includes error classification (429/401/400) and progress tracking.
|
||||
- **Worker Threads** (`agent/`) — Agent sessions run in `worker_threads` to avoid blocking the Electron main process. The `WorkerBridge` relays `postMessage()` events to the existing `AgentManagerEvents` interface.
|
||||
- **Build Orchestration** (`orchestration/`) — Full planner → coder → QA pipeline. Parallel subagent execution via `Promise.allSettled()`.
|
||||
- **Tools** (`tools/`) — 8 builtin tools (Read, Write, Edit, Bash, Glob, Grep, WebFetch, WebSearch) defined with Zod schemas via AI SDK `tool()`.
|
||||
- **Security** (`security/`) — Bash validator, command parser, and path containment ported from Python with identical allowlist behavior.
|
||||
- **Config** (`config/`) — `AGENT_CONFIGS` registry (25+ agent types), phase-aware model resolution, thinking budgets.
|
||||
**Implementation (run.py → agent.py)** - Multi-session build:
|
||||
1. Planner Agent creates subtask-based implementation plan
|
||||
2. Coder Agent implements subtasks (can spawn subagents for parallel work)
|
||||
3. QA Reviewer validates acceptance criteria (can perform E2E testing via Electron MCP for frontend changes)
|
||||
4. QA Fixer resolves issues in a loop (with E2E testing to verify fixes)
|
||||
|
||||
### Key Patterns
|
||||
### Key Components (apps/backend/)
|
||||
|
||||
```typescript
|
||||
// Agent session using streamText()
|
||||
import { streamText, stepCountIs } from 'ai';
|
||||
**Core Infrastructure:**
|
||||
- **core/client.py** - Claude Agent SDK client factory with security hooks and tool permissions
|
||||
- **core/security.py** - Dynamic command allowlisting based on detected project stack
|
||||
- **core/auth.py** - OAuth token management for Claude SDK authentication
|
||||
- **agents/** - Agent implementations (planner, coder, qa_reviewer, qa_fixer)
|
||||
- **spec_agents/** - Spec creation agents (gatherer, researcher, writer, critic)
|
||||
|
||||
const result = streamText({
|
||||
model: provider,
|
||||
system: systemPrompt,
|
||||
messages: conversationHistory,
|
||||
tools: toolRegistry.getToolsForAgent(agentType),
|
||||
stopWhen: stepCountIs(1000),
|
||||
onStepFinish: ({ toolCalls, text, usage }) => {
|
||||
progressTracker.update(toolCalls, text);
|
||||
},
|
||||
});
|
||||
**Memory & Context:**
|
||||
- **integrations/graphiti/** - Graphiti memory system (mandatory)
|
||||
- `queries_pkg/graphiti.py` - Main GraphitiMemory class
|
||||
- `queries_pkg/client.py` - LadybugDB client wrapper
|
||||
- `queries_pkg/queries.py` - Graph query operations
|
||||
- `queries_pkg/search.py` - Semantic search logic
|
||||
- `queries_pkg/schema.py` - Graph schema definitions
|
||||
- **graphiti_config.py** - Configuration and validation for Graphiti integration
|
||||
- **graphiti_providers.py** - Multi-provider factory (OpenAI, Anthropic, Azure, Ollama, Google AI)
|
||||
- **agents/memory_manager.py** - Session memory orchestration
|
||||
|
||||
// Tool definition with Zod schema
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
**Workspace & Security:**
|
||||
- **cli/worktree.py** - Git worktree isolation for safe feature development
|
||||
- **context/project_analyzer.py** - Project stack detection for dynamic tooling
|
||||
- **auto_claude_tools.py** - Custom MCP tools integration
|
||||
|
||||
const readTool = tool({
|
||||
description: 'Read a file from the filesystem',
|
||||
inputSchema: z.object({
|
||||
file_path: z.string(),
|
||||
offset: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
}),
|
||||
execute: async ({ file_path, offset, limit }) => { /* ... */ },
|
||||
});
|
||||
```
|
||||
**Integrations:**
|
||||
- **linear_updater.py** - Optional Linear integration for progress tracking
|
||||
- **runners/github/** - GitHub Issues & PRs automation
|
||||
- **Electron MCP** - E2E testing integration for QA agents (Chrome DevTools Protocol)
|
||||
- Enabled with `ELECTRON_MCP_ENABLED=true` in `.env`
|
||||
- Allows QA agents to interact with running Electron app
|
||||
- See "End-to-End Testing" section for details
|
||||
|
||||
### Agent Prompts (`apps/desktop/prompts/`)
|
||||
### Agent Prompts (apps/backend/prompts/)
|
||||
|
||||
| Prompt | Purpose |
|
||||
|--------|---------|
|
||||
| planner.md | Implementation plan with subtasks |
|
||||
| coder.md / coder_recovery.md | Subtask implementation / recovery |
|
||||
| qa_reviewer.md / qa_fixer.md | Acceptance validation / issue fixes |
|
||||
| spec_gatherer/researcher/writer/critic.md | Spec creation pipeline |
|
||||
| planner.md | Creates implementation plan with subtasks |
|
||||
| coder.md | Implements individual subtasks |
|
||||
| coder_recovery.md | Recovers from stuck/failed subtasks |
|
||||
| qa_reviewer.md | Validates acceptance criteria |
|
||||
| qa_fixer.md | Fixes QA-reported issues |
|
||||
| spec_gatherer.md | Collects user requirements |
|
||||
| spec_researcher.md | Validates external integrations |
|
||||
| spec_writer.md | Creates spec.md document |
|
||||
| spec_critic.md | Self-critique using ultrathink |
|
||||
| complexity_assessor.md | AI-based complexity assessment |
|
||||
|
||||
### Spec Directory Structure
|
||||
|
||||
Each spec in `.auto-claude/specs/XXX-name/` contains: `spec.md`, `requirements.json`, `context.json`, `implementation_plan.json`, `qa_report.md`, `QA_FIX_REQUEST.md`
|
||||
Each spec in `.auto-claude/specs/XXX-name/` contains:
|
||||
- `spec.md` - Feature specification
|
||||
- `requirements.json` - Structured user requirements
|
||||
- `context.json` - Discovered codebase context
|
||||
- `implementation_plan.json` - Subtask-based plan with status tracking
|
||||
- `qa_report.md` - QA validation results
|
||||
- `QA_FIX_REQUEST.md` - Issues to fix (when rejected)
|
||||
|
||||
### Memory System (Graphiti)
|
||||
### Branching & Worktree Strategy
|
||||
|
||||
Graph-based semantic memory accessed via a Python MCP sidecar (lives outside `apps/desktop/`). The AI layer connects to it via `createMCPClient` from `@ai-sdk/mcp`. Configured through the Electron app's onboarding/settings UI. See [ARCHITECTURE.md](shared_docs/ARCHITECTURE.md#memory-system) for details.
|
||||
Auto Claude uses git worktrees for isolated builds. All branches stay LOCAL until user explicitly pushes:
|
||||
|
||||
## Frontend Development
|
||||
|
||||
### Tech Stack
|
||||
|
||||
React 19, TypeScript (strict), Electron 39, Vercel AI SDK v6, Zustand 5, Tailwind CSS v4, Radix UI, xterm.js 6, Vite 7, Vitest 4, Biome 2, Motion (Framer Motion)
|
||||
|
||||
### Path Aliases (tsconfig.json)
|
||||
|
||||
| Alias | Maps to |
|
||||
|-------|---------|
|
||||
| `@/*` | `src/renderer/*` |
|
||||
| `@shared/*` | `src/shared/*` |
|
||||
| `@preload/*` | `src/preload/*` |
|
||||
| `@features/*` | `src/renderer/features/*` |
|
||||
| `@components/*` | `src/renderer/shared/components/*` |
|
||||
| `@hooks/*` | `src/renderer/shared/hooks/*` |
|
||||
| `@lib/*` | `src/renderer/shared/lib/*` |
|
||||
|
||||
### State Management (Zustand)
|
||||
|
||||
All state lives in `src/renderer/stores/`. Key stores:
|
||||
|
||||
- `project-store.ts` — Active project, project list
|
||||
- `task-store.ts` — Tasks/specs management
|
||||
- `terminal-store.ts` — Terminal sessions and state
|
||||
- `settings-store.ts` — User preferences
|
||||
- `github/issues-store.ts`, `github/pr-review-store.ts` — GitHub integration
|
||||
- `insights-store.ts`, `roadmap-store.ts`, `kanban-settings-store.ts`
|
||||
|
||||
Main process also has stores: `src/main/project-store.ts`, `src/main/terminal-session-store.ts`
|
||||
|
||||
### Styling
|
||||
|
||||
- **Tailwind CSS v4** with `@tailwindcss/postcss` plugin
|
||||
- **7 color themes** (Default, Dusk, Lime, Ocean, Retro, Neo + more) defined in `src/shared/constants/themes.ts`
|
||||
- Each theme has light/dark mode variants via CSS custom properties
|
||||
- Utility: `clsx` + `tailwind-merge` via `cn()` helper
|
||||
- Component variants: `class-variance-authority` (CVA)
|
||||
|
||||
### IPC Communication
|
||||
|
||||
Main ↔ Renderer communication via Electron IPC:
|
||||
- **Handlers:** `src/main/ipc-handlers/` — organized by domain (github, gitlab, ideation, context, etc.)
|
||||
- **Preload:** `src/preload/` — exposes safe APIs to renderer
|
||||
- Pattern: renderer calls via `window.electronAPI.*`, main handles in IPC handler modules
|
||||
|
||||
### Agent Management (`src/main/agent/`)
|
||||
|
||||
The frontend manages agent lifecycle end-to-end:
|
||||
- **`agent-queue.ts`** — Queue routing, prioritization, spec number locking
|
||||
- **`agent-process.ts`** — Spawns worker threads via `WorkerBridge` for agent execution
|
||||
- **`agent-state.ts`** — Tracks running agent state and status
|
||||
- **`agent-events.ts`** — Agent lifecycle events and state transitions (structured events from worker threads)
|
||||
|
||||
### Claude Profile System (`src/main/claude-profile/`)
|
||||
|
||||
Multi-profile credential management for switching between Claude accounts:
|
||||
- **`credential-utils.ts`** — OS credential storage (Keychain/Windows Credential Manager)
|
||||
- **`token-refresh.ts`** — OAuth token lifecycle and automatic refresh
|
||||
- **`usage-monitor.ts`** — API usage tracking and rate limiting per profile
|
||||
- **`profile-scorer.ts`** — Scores profiles by usage and availability
|
||||
|
||||
### Terminal System (`src/main/terminal/`)
|
||||
|
||||
Full PTY-based terminal integration:
|
||||
- **`pty-daemon.ts`** / **`pty-manager.ts`** — Background PTY process management
|
||||
- **`terminal-lifecycle.ts`** — Session creation, cleanup, event handling
|
||||
- **`claude-integration-handler.ts`** — Claude SDK integration within terminals
|
||||
- Renderer: xterm.js 6 with WebGL, fit, web-links, serialize addons. Store: `terminal-store.ts`
|
||||
|
||||
## Code Quality
|
||||
|
||||
### Frontend
|
||||
- **Linting:** Biome (`npm run lint` / `npm run lint:fix`)
|
||||
- **Type checking:** `npm run typecheck` (strict mode)
|
||||
- **Pre-commit:** Husky + lint-staged runs Biome on staged `.ts/.tsx/.js/.jsx/.json`
|
||||
- **Testing:** Vitest + React Testing Library + jsdom
|
||||
|
||||
|
||||
## i18n Guidelines
|
||||
|
||||
All frontend UI text uses `react-i18next`. Translation files: `apps/desktop/src/shared/i18n/locales/{en,fr}/*.json`
|
||||
|
||||
**Namespaces:** `common`, `navigation`, `settings`, `dialogs`, `tasks`, `errors`, `onboarding`, `welcome`
|
||||
|
||||
```tsx
|
||||
import { useTranslation } from 'react-i18next';
|
||||
const { t } = useTranslation(['navigation', 'common']);
|
||||
|
||||
<span>{t('navigation:items.githubPRs')}</span> // CORRECT
|
||||
<span>GitHub PRs</span> // WRONG
|
||||
|
||||
// With interpolation:
|
||||
<span>{t('errors:task.parseError', { error })}</span>
|
||||
```
|
||||
main (user's branch)
|
||||
└── auto-claude/{spec-name} ← spec branch (isolated worktree)
|
||||
```
|
||||
|
||||
When adding new UI text: add keys to ALL language files, use `namespace:section.key` format.
|
||||
**Key principles:**
|
||||
- ONE branch per spec (`auto-claude/{spec-name}`)
|
||||
- Parallel work uses subagents (agent decides when to spawn)
|
||||
- NO automatic pushes to GitHub - user controls when to push
|
||||
- User reviews in spec worktree (`.worktrees/{spec-name}/`)
|
||||
- Final merge: spec branch → main (after user approval)
|
||||
|
||||
## Cross-Platform
|
||||
**Workflow:**
|
||||
1. Build runs in isolated worktree on spec branch
|
||||
2. Agent implements subtasks (can spawn subagents for parallel work)
|
||||
3. User tests feature in `.worktrees/{spec-name}/`
|
||||
4. User runs `--merge` to add to their project
|
||||
5. User pushes to remote when ready
|
||||
|
||||
Supports Windows, macOS, Linux. CI tests all three.
|
||||
### Contributing to Upstream
|
||||
|
||||
**Platform modules:** `apps/desktop/src/main/platform/`
|
||||
**CRITICAL: When submitting PRs to AndyMik90/Auto-Claude, always target the `develop` branch, NOT `main`.**
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `isWindows()` / `isMacOS()` / `isLinux()` | OS detection |
|
||||
| `getPathDelimiter()` | `;` (Win) or `:` (Unix) |
|
||||
| `findExecutable(name)` | Cross-platform executable lookup |
|
||||
| `requiresShell(command)` | `.cmd/.bat` shell detection (Win) |
|
||||
**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`
|
||||
|
||||
Use `findExecutable()` and `joinPaths()` instead of hardcoded paths. See [ARCHITECTURE.md](shared_docs/ARCHITECTURE.md#cross-platform-development) for extended guide.
|
||||
**Verify before PR:**
|
||||
```bash
|
||||
# Ensure only your commits are included
|
||||
git log --oneline upstream/develop..HEAD
|
||||
```
|
||||
|
||||
## E2E Testing (Electron MCP)
|
||||
### Security Model
|
||||
|
||||
QA agents can interact with the running Electron app via Chrome DevTools Protocol:
|
||||
Three-layer defense:
|
||||
1. **OS Sandbox** - Bash command isolation
|
||||
2. **Filesystem Permissions** - Operations restricted to project directory
|
||||
3. **Command Allowlist** - Dynamic allowlist from project analysis (security.py + project_analyzer.py)
|
||||
|
||||
1. Start app: `npm run dev:debug` (debug mode for AI self-validation via Electron MCP)
|
||||
2. Enable Electron MCP in settings
|
||||
3. QA runs automatically through the TypeScript agent pipeline
|
||||
Security profile cached in `.auto-claude-security.json`.
|
||||
|
||||
Tools: `take_screenshot`, `click_by_text`, `fill_input`, `get_page_structure`, `send_keyboard_shortcut`, `eval`. See [ARCHITECTURE.md](shared_docs/ARCHITECTURE.md#end-to-end-testing) for full capabilities.
|
||||
### Claude Agent SDK Integration
|
||||
|
||||
**CRITICAL: Auto Claude uses the Claude Agent SDK for ALL AI interactions. Never use the Anthropic API directly.**
|
||||
|
||||
**Client Location:** `apps/backend/core/client.py`
|
||||
|
||||
The `create_client()` function creates a configured `ClaudeSDKClient` instance with:
|
||||
- Multi-layered security (sandbox, permissions, security hooks)
|
||||
- Agent-specific tool permissions (planner, coder, qa_reviewer, qa_fixer)
|
||||
- Dynamic MCP server integration based on project capabilities
|
||||
- Extended thinking token budget control
|
||||
|
||||
**Example usage in agents:**
|
||||
```python
|
||||
from core.client import create_client
|
||||
|
||||
# Create SDK client (NOT raw Anthropic API client)
|
||||
client = create_client(
|
||||
project_dir=project_dir,
|
||||
spec_dir=spec_dir,
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
agent_type="coder",
|
||||
max_thinking_tokens=None # or 5000/10000/16000
|
||||
)
|
||||
|
||||
# Run agent session
|
||||
response = client.create_agent_session(
|
||||
name="coder-agent-session",
|
||||
starting_message="Implement the authentication feature"
|
||||
)
|
||||
```
|
||||
|
||||
**Why use the SDK:**
|
||||
- Pre-configured security (sandbox, allowlists, hooks)
|
||||
- Automatic MCP server integration (Context7, Linear, Graphiti, Electron, Puppeteer)
|
||||
- Tool permissions based on agent role
|
||||
- Session management and recovery
|
||||
- Unified API across all agent types
|
||||
|
||||
**Where to find working examples:**
|
||||
- `apps/backend/agents/planner.py` - Planner agent
|
||||
- `apps/backend/agents/coder.py` - Coder agent
|
||||
- `apps/backend/agents/qa_reviewer.py` - QA reviewer
|
||||
- `apps/backend/agents/qa_fixer.py` - QA fixer
|
||||
- `apps/backend/spec_agents/` - Spec creation agents
|
||||
|
||||
### Memory System
|
||||
|
||||
**Graphiti Memory (Mandatory)** - `integrations/graphiti/`
|
||||
|
||||
Auto Claude uses Graphiti as its primary memory system with embedded LadybugDB (no Docker required):
|
||||
|
||||
- **Graph database with semantic search** - Knowledge graph for cross-session context
|
||||
- **Session insights** - Patterns, gotchas, discoveries automatically extracted
|
||||
- **Multi-provider support:**
|
||||
- LLM: OpenAI, Anthropic, Azure OpenAI, Ollama, Google AI (Gemini)
|
||||
- Embedders: OpenAI, Voyage AI, Azure OpenAI, Ollama, Google AI
|
||||
- **Modular architecture:** (`integrations/graphiti/queries_pkg/`)
|
||||
- `graphiti.py` - Main GraphitiMemory class
|
||||
- `client.py` - LadybugDB client wrapper
|
||||
- `queries.py` - Graph query operations
|
||||
- `search.py` - Semantic search logic
|
||||
- `schema.py` - Graph schema definitions
|
||||
|
||||
**Configuration:**
|
||||
- Set provider credentials in `apps/backend/.env` (see `.env.example`)
|
||||
- Required env vars: `GRAPHITI_ENABLED=true`, `ANTHROPIC_API_KEY` or other provider keys
|
||||
- Memory data stored in `.auto-claude/specs/XXX/graphiti/`
|
||||
|
||||
**Usage in agents:**
|
||||
```python
|
||||
from integrations.graphiti.memory import get_graphiti_memory
|
||||
|
||||
memory = get_graphiti_memory(spec_dir, project_dir)
|
||||
context = memory.get_context_for_session("Implementing feature X")
|
||||
memory.add_session_insight("Pattern: use React hooks for state")
|
||||
```
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Frontend Internationalization (i18n)
|
||||
|
||||
**CRITICAL: Always use i18n translation keys for all user-facing text in the frontend.**
|
||||
|
||||
The frontend uses `react-i18next` for internationalization. All labels, buttons, messages, and user-facing text MUST use translation keys.
|
||||
|
||||
**Translation file locations:**
|
||||
- `apps/frontend/src/shared/i18n/locales/en/*.json` - English translations
|
||||
- `apps/frontend/src/shared/i18n/locales/fr/*.json` - French translations
|
||||
|
||||
**Translation namespaces:**
|
||||
- `common.json` - Shared labels, buttons, common terms
|
||||
- `navigation.json` - Sidebar navigation items, sections
|
||||
- `settings.json` - Settings page content
|
||||
- `dialogs.json` - Dialog boxes and modals
|
||||
- `tasks.json` - Task/spec related content
|
||||
- `onboarding.json` - Onboarding wizard content
|
||||
- `welcome.json` - Welcome screen content
|
||||
|
||||
**Usage pattern:**
|
||||
```tsx
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
// In component
|
||||
const { t } = useTranslation(['navigation', 'common']);
|
||||
|
||||
// Use translation keys, NOT hardcoded strings
|
||||
<span>{t('navigation:items.githubPRs')}</span> // ✅ CORRECT
|
||||
<span>GitHub PRs</span> // ❌ WRONG
|
||||
```
|
||||
|
||||
**When adding new UI text:**
|
||||
1. Add the translation key to ALL language files (at minimum: `en/*.json` and `fr/*.json`)
|
||||
2. Use `namespace:section.key` format (e.g., `navigation:items.githubPRs`)
|
||||
3. Never use hardcoded strings in JSX/TSX files
|
||||
|
||||
### End-to-End Testing (Electron App)
|
||||
|
||||
**IMPORTANT: When bug fixing or implementing new features in the frontend, AI agents can perform automated E2E testing using the Electron MCP server.**
|
||||
|
||||
The Electron MCP server allows QA agents to interact with the running Electron app via Chrome DevTools Protocol:
|
||||
|
||||
**Setup:**
|
||||
1. Start the Electron app with remote debugging enabled:
|
||||
```bash
|
||||
npm run dev # Already configured with --remote-debugging-port=9222
|
||||
```
|
||||
|
||||
2. Enable Electron MCP in `apps/backend/.env`:
|
||||
```bash
|
||||
ELECTRON_MCP_ENABLED=true
|
||||
ELECTRON_DEBUG_PORT=9222 # Default port
|
||||
```
|
||||
|
||||
**Available Testing Capabilities:**
|
||||
|
||||
QA agents (`qa_reviewer` and `qa_fixer`) automatically get access to Electron MCP tools:
|
||||
|
||||
1. **Window Management**
|
||||
- `mcp__electron__get_electron_window_info` - Get info about running windows
|
||||
- `mcp__electron__take_screenshot` - Capture screenshots for visual verification
|
||||
|
||||
2. **UI Interaction**
|
||||
- `mcp__electron__send_command_to_electron` with commands:
|
||||
- `click_by_text` - Click buttons/links by visible text
|
||||
- `click_by_selector` - Click elements by CSS selector
|
||||
- `fill_input` - Fill form fields by placeholder or selector
|
||||
- `select_option` - Select dropdown options
|
||||
- `send_keyboard_shortcut` - Send keyboard shortcuts (Enter, Ctrl+N, etc.)
|
||||
- `navigate_to_hash` - Navigate to hash routes (#settings, #create, etc.)
|
||||
|
||||
3. **Page Inspection**
|
||||
- `get_page_structure` - Get organized overview of page elements
|
||||
- `debug_elements` - Get debugging info about buttons and forms
|
||||
- `verify_form_state` - Check form state and validation
|
||||
- `eval` - Execute custom JavaScript code
|
||||
|
||||
4. **Logging**
|
||||
- `mcp__electron__read_electron_logs` - Read console logs for debugging
|
||||
|
||||
**Example E2E Test Flow:**
|
||||
|
||||
```python
|
||||
# 1. Agent takes screenshot to see current state
|
||||
agent: "Take a screenshot to see the current UI"
|
||||
# Uses: mcp__electron__take_screenshot
|
||||
|
||||
# 2. Agent inspects page structure
|
||||
agent: "Get page structure to find available buttons"
|
||||
# Uses: mcp__electron__send_command_to_electron (command: "get_page_structure")
|
||||
|
||||
# 3. Agent clicks a button to navigate
|
||||
agent: "Click the 'Create New Spec' button"
|
||||
# Uses: mcp__electron__send_command_to_electron (command: "click_by_text", args: {text: "Create New Spec"})
|
||||
|
||||
# 4. Agent fills out a form
|
||||
agent: "Fill the task description field"
|
||||
# Uses: mcp__electron__send_command_to_electron (command: "fill_input", args: {placeholder: "Describe your task", value: "Add login feature"})
|
||||
|
||||
# 5. Agent submits and verifies
|
||||
agent: "Click Submit and verify success"
|
||||
# Uses: click_by_text → take_screenshot → verify result
|
||||
```
|
||||
|
||||
**When to Use E2E Testing:**
|
||||
|
||||
- **Bug Fixes**: Reproduce the bug, apply fix, verify it's resolved
|
||||
- **New Features**: Implement feature, test the UI flow end-to-end
|
||||
- **UI Changes**: Verify visual changes and interactions work correctly
|
||||
- **Form Validation**: Test form submission, validation, error handling
|
||||
|
||||
**Configuration in `core/client.py`:**
|
||||
|
||||
The client automatically enables Electron MCP tools for QA agents when:
|
||||
- Project is detected as Electron (`is_electron` capability)
|
||||
- `ELECTRON_MCP_ENABLED=true` is set
|
||||
- Agent type is `qa_reviewer` or `qa_fixer`
|
||||
|
||||
**Note:** Screenshots are automatically compressed (1280x720, quality 60, JPEG) to stay under Claude SDK's 1MB JSON message buffer limit.
|
||||
|
||||
## Running the Application
|
||||
|
||||
**As a standalone CLI tool**:
|
||||
```bash
|
||||
# Desktop app
|
||||
npm start # Production build + run
|
||||
npm run dev # Development mode with HMR
|
||||
npm run dev:debug # Debug mode with verbose output
|
||||
npm run dev:mcp # Electron MCP server for AI debugging
|
||||
|
||||
# Project data: .auto-claude/specs/ (gitignored)
|
||||
cd apps/backend
|
||||
python run.py --spec 001
|
||||
```
|
||||
|
||||
**With the Electron frontend**:
|
||||
```bash
|
||||
npm start # Build and run desktop app
|
||||
npm run dev # Run in development mode (includes --remote-debugging-port=9222 for E2E testing)
|
||||
```
|
||||
|
||||
**For E2E Testing with QA Agents:**
|
||||
1. Start the Electron app: `npm run dev`
|
||||
2. Enable Electron MCP in `apps/backend/.env`: `ELECTRON_MCP_ENABLED=true`
|
||||
3. Run QA: `python run.py --spec 001 --qa`
|
||||
4. QA agents will automatically interact with the running app for testing
|
||||
|
||||
**Project data storage:**
|
||||
- `.auto-claude/specs/` - Per-project data (specs, plans, QA reports, memory) - gitignored
|
||||
|
||||
@@ -1,348 +0,0 @@
|
||||
# Codex Rate Limit Monitoring — Full System Research
|
||||
|
||||
> Temporary research file. Delete after implementation.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Codex Usage API](#1-codex-usage-api)
|
||||
2. [Current System Architecture](#2-current-system-architecture)
|
||||
3. [Anthropic-Hardcoded Locations](#3-anthropic-hardcoded-locations)
|
||||
4. [Provider-Agnostic Parts (No Changes Needed)](#4-provider-agnostic-parts)
|
||||
5. [Implementation Plan](#5-implementation-plan)
|
||||
|
||||
---
|
||||
|
||||
## 1. Codex Usage API
|
||||
|
||||
**Sources:** OpenAI Codex source code (`github.com/openai/codex`, Rust codebase), CodexBar macOS app (`github.com/steipete/CodexBar`), Context7 Codex developer docs.
|
||||
|
||||
### 1.1 Active Polling Endpoint
|
||||
|
||||
```
|
||||
GET https://chatgpt.com/backend-api/wham/usage
|
||||
```
|
||||
|
||||
Fallback (when base URL doesn't contain `/backend-api`):
|
||||
```
|
||||
GET {base_url}/api/codex/usage
|
||||
```
|
||||
|
||||
**Required Headers:**
|
||||
```http
|
||||
Authorization: Bearer <access_token>
|
||||
ChatGPT-Account-Id: <account_id>
|
||||
Content-Type: application/json
|
||||
Accept: application/json
|
||||
```
|
||||
|
||||
- `access_token` — The OAuth access token from `auth.openai.com` (same token our `codex-oauth.ts` already obtains)
|
||||
- `account_id` — Account UUID from OAuth token data. Stored in `~/.codex/auth.json` under `tokens.account_id`. Optional per CodexBar ("when available") but may be required.
|
||||
|
||||
### 1.2 Response Schema
|
||||
|
||||
From `codex-rs/codex-backend-openapi-models/src/models/rate_limit_status_payload.rs`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plan_type": "plus",
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"primary_window": {
|
||||
"used_percent": 96,
|
||||
"limit_window_seconds": 18000,
|
||||
"reset_after_seconds": 673,
|
||||
"reset_at": 1730947200
|
||||
},
|
||||
"secondary_window": {
|
||||
"used_percent": 70,
|
||||
"limit_window_seconds": 604800,
|
||||
"reset_after_seconds": 43200,
|
||||
"reset_at": 1730980800
|
||||
}
|
||||
},
|
||||
"credits": {
|
||||
"has_credits": false,
|
||||
"unlimited": true,
|
||||
"balance": null
|
||||
},
|
||||
"additional_rate_limits": [
|
||||
{
|
||||
"limit_name": "codex_other",
|
||||
"metered_feature": "codex_other",
|
||||
"rate_limit": {
|
||||
"allowed": true,
|
||||
"limit_reached": false,
|
||||
"primary_window": {
|
||||
"used_percent": 70,
|
||||
"limit_window_seconds": 3600,
|
||||
"reset_after_seconds": 1800,
|
||||
"reset_at": 1730947200
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `primary_window` = 5h session (18000s). Maps to our `sessionPercent`.
|
||||
- `secondary_window` = Weekly (604800s = 7d). Maps to our `weeklyPercent`.
|
||||
- `reset_at` = Unix timestamp (seconds). Convert to ms for our `sessionResetTimestamp`/`weeklyResetTimestamp`.
|
||||
- `plan_type` values: `guest`, `free`, `go`, `plus`, `pro`, `free_workspace`, `team`, `business`, `education`, `quorum`, `k12`, `enterprise`, `edu`
|
||||
|
||||
### 1.3 Passive Headers (From API Responses)
|
||||
|
||||
Rate limit data is also returned in HTTP response headers on every `/v1/responses` call:
|
||||
|
||||
```
|
||||
x-codex-primary-used-percent → float (e.g., "25.0")
|
||||
x-codex-primary-window-minutes → integer (e.g., "300" for 5h)
|
||||
x-codex-primary-reset-at → unix timestamp seconds
|
||||
x-codex-secondary-used-percent → float (weekly)
|
||||
x-codex-secondary-window-minutes → integer
|
||||
x-codex-secondary-reset-at → unix timestamp seconds
|
||||
x-codex-credits-has-credits → "true" or "false"
|
||||
x-codex-credits-unlimited → "true" or "false"
|
||||
x-codex-credits-balance → decimal string e.g. "9.99"
|
||||
```
|
||||
|
||||
SSE event type `codex.rate_limits` also carries this data inline in streaming responses.
|
||||
|
||||
### 1.4 Token Details
|
||||
|
||||
Our `codex-oauth.ts` already uses the correct flow:
|
||||
- **Client ID:** `app_EMoamEEZ73f0CkXaXp7hrann` (same as Codex CLI)
|
||||
- **Auth endpoint:** `https://auth.openai.com/oauth/authorize`
|
||||
- **Token endpoint:** `https://auth.openai.com/oauth/token`
|
||||
- **Scopes:** `openid profile email offline_access`
|
||||
- **Refresh:** `POST https://auth.openai.com/oauth/token` with `grant_type=refresh_token`
|
||||
|
||||
**Missing:** `account_id` for the `ChatGPT-Account-Id` header. Options:
|
||||
1. Decode from the JWT access token
|
||||
2. Read from `~/.codex/auth.json` (`tokens.account_id`)
|
||||
3. Extract during OAuth token exchange (may be in response)
|
||||
4. Try without it first (optional per CodexBar docs)
|
||||
|
||||
---
|
||||
|
||||
## 2. Current System Architecture
|
||||
|
||||
### 2.1 Two Parallel Account Systems
|
||||
|
||||
The app has TWO account management systems that don't fully integrate:
|
||||
|
||||
**System A: Legacy Claude Profile Manager (Main Process)**
|
||||
- `claude-profile-manager.ts` — Manages OAuth profiles, rate limits, usage, auto-swap
|
||||
- `claude-profiles.json` — Stores profiles with `activeProfileId`, `accountPriorityOrder`
|
||||
- `usage-monitor.ts` — Polls Anthropic's `/api/oauth/usage` endpoint every 30s
|
||||
- `token-refresh.ts` — Refreshes tokens via `console.anthropic.com/v1/oauth/token`
|
||||
- `rate-limit-detector.ts` — Detects rate limits, triggers auto-swap
|
||||
- `profile-scorer.ts` — Scores profiles by availability for auto-swap
|
||||
- **100% Anthropic-specific.** Only knows about Anthropic OAuth tokens, Anthropic endpoints, Anthropic keychain format.
|
||||
|
||||
**System B: Multi-Provider Accounts (Renderer + Settings)**
|
||||
- `ProviderAccount[]` in `settings-store.ts` — All connected accounts (any provider)
|
||||
- `globalPriorityOrder: string[]` in AppSettings — Manual priority queue
|
||||
- `useActiveProvider()` hook — First account in priority order = active
|
||||
- **Provider-agnostic.** Works for all 10 providers. But has NO usage monitoring, NO auto-swap.
|
||||
|
||||
**The gap:** System A handles usage monitoring + auto-swap but only for Anthropic. System B handles multi-provider accounts but has no usage awareness.
|
||||
|
||||
### 2.2 Data Flow: Usage Polling
|
||||
|
||||
```
|
||||
UsageMonitor.start() → 30s interval
|
||||
↓
|
||||
checkUsageAndSwap()
|
||||
├─ determineActiveProfile() ← Hardcoded: defaults to anthropic baseUrl
|
||||
├─ getCredential() ← Hardcoded: reads from Anthropic keychain
|
||||
│ └─ ensureValidToken(configDir) ← Hardcoded: refreshes via Anthropic endpoint
|
||||
├─ fetchUsageViaAPI() ← Hardcoded: only allows anthropic/zai/zhipu domains
|
||||
│ ├─ getUsageEndpoint(provider) ← Only 3 providers configured
|
||||
│ ├─ Add anthropic-specific headers ← if (provider === 'anthropic') add beta headers
|
||||
│ └─ Parse response ← Provider-specific normalization
|
||||
├─ emit('usage-updated') → IPC 'claude:usageUpdated' → renderer
|
||||
├─ emit('all-profiles-usage-updated') → IPC 'claude:allProfilesUsageUpdated' → renderer
|
||||
└─ checkThresholdsExceeded()
|
||||
└─ performProactiveSwap() ← Only swaps Anthropic profiles
|
||||
```
|
||||
|
||||
### 2.3 Data Flow: Account Swapping
|
||||
|
||||
**Manual swap (UI):**
|
||||
```
|
||||
User clicks account in UsageIndicator popover
|
||||
→ handleSwapAccount(accountId)
|
||||
→ setQueueOrder([accountId, ...rest]) ← Reorders globalPriorityOrder
|
||||
→ requestUsageUpdate() ← Refreshes usage display
|
||||
```
|
||||
|
||||
**Automatic swap (rate limit hit):**
|
||||
```
|
||||
SDK operation fails with 429
|
||||
→ detectRateLimit(output) ← Pattern: "Limit reached · resets..."
|
||||
→ recordRateLimitEvent(profileId)
|
||||
→ getBestAvailableProfileEnv()
|
||||
→ profileManager.setActiveProfile() ← Only updates claude-profiles.json
|
||||
→ usageMonitor.getAllProfilesUsage() ← Refreshes UI
|
||||
← Returns new profile env vars
|
||||
```
|
||||
|
||||
**Problem:** Auto-swap updates `claude-profiles.json` but NOT `globalPriorityOrder`. The renderer's priority queue may be out of sync.
|
||||
|
||||
### 2.4 UI Components
|
||||
|
||||
| Component | What it shows | Provider-specific? |
|
||||
|---|---|---|
|
||||
| `AuthStatusIndicator` | Provider badge (OpenAI/Anthropic) + auth type label | Codex = green "Codex", Anthropic = orange "OAuth" |
|
||||
| `UsageIndicator` | Usage bars OR "Subscription" OR "Unlimited" | Anthropic OAuth = bars, Codex OAuth = "Subscription", API = "Unlimited" |
|
||||
| `ProviderAccountCard` | Account card in settings with usage bars | Shows usage bars only when `account.usage` populated (Anthropic only) |
|
||||
| `ProviderAccountsList` | All accounts grouped by provider | Generic, but re-auth routes differ per provider |
|
||||
| `AddAccountDialog` | OAuth flow + account creation | Different flows: Codex → `codexAuthLogin()`, Anthropic → `claudeAuthLoginSubprocess()` |
|
||||
| `ProviderSection` | Provider group with "Add" buttons | Button label: "Add Codex Subscription" vs "Add OAuth" |
|
||||
|
||||
### 2.5 Type Naming
|
||||
|
||||
Types use "Claude" prefix but are structurally generic:
|
||||
```typescript
|
||||
ClaudeUsageSnapshot → { sessionPercent, weeklyPercent, resetTimestamps, profileId, ... }
|
||||
ClaudeUsageData → { sessionUsagePercent, weeklyUsagePercent }
|
||||
ClaudeRateLimitEvent → { type, hitAt, resetAt }
|
||||
ProfileUsageSummary → { sessionPercent, weeklyPercent, availabilityScore, ... }
|
||||
AllProfilesUsage → { activeProfile, allProfiles[], fetchedAt }
|
||||
```
|
||||
|
||||
These types work perfectly for Codex data — same session/weekly model. No structural changes needed, just need to populate them.
|
||||
|
||||
---
|
||||
|
||||
## 3. Anthropic-Hardcoded Locations
|
||||
|
||||
### 3.1 CRITICAL — Must Change
|
||||
|
||||
| File | Line(s) | What's hardcoded | What to do |
|
||||
|---|---|---|---|
|
||||
| `usage-monitor.ts:45-49` | `ALLOWED_USAGE_API_DOMAINS` | Only `api.anthropic.com`, `api.z.ai`, `open.bigmodel.cn` | Add `chatgpt.com` |
|
||||
| `usage-monitor.ts:60-73` | `PROVIDER_USAGE_ENDPOINTS` | Only anthropic/zai/zhipu paths | Add `{ provider: 'openai', usagePath: '/wham/usage' }` |
|
||||
| `usage-monitor.ts:662,1069,1346,1359` | `baseUrl: 'https://api.anthropic.com'` | Hardcoded fallback for all OAuth profiles | Detect provider from account, use `chatgpt.com/backend-api` for Codex |
|
||||
| `usage-monitor.ts:1424` | `if (provider === 'anthropic')` adds beta headers | Anthropic-specific `anthropic-beta` header | Add `else if (provider === 'openai')` to add `ChatGPT-Account-Id` header |
|
||||
| `token-refresh.ts:31` | `ANTHROPIC_TOKEN_ENDPOINT = 'https://console.anthropic.com/v1/oauth/token'` | Only Anthropic refresh endpoint | Route to `auth.openai.com/oauth/token` for Codex |
|
||||
| `token-refresh.ts:37` | `CLAUDE_CODE_CLIENT_ID = '9d1c250a-...'` | Only Anthropic client ID | Use `app_EMoamEEZ73f0CkXaXp7hrann` for Codex |
|
||||
| `UsageIndicator.tsx:118` | `provider === 'anthropic' && authType === 'oauth'` | Only Anthropic gets usage bars | Add `\|\| provider === 'openai'` |
|
||||
|
||||
### 3.2 MODERATE — Should Change
|
||||
|
||||
| File | Line(s) | What's hardcoded | What to do |
|
||||
|---|---|---|---|
|
||||
| `usage-monitor.ts:1040-1072` | `determineActiveProfile()` | Returns `baseUrl: 'https://api.anthropic.com'` for all OAuth | Detect provider, return `chatgpt.com/backend-api` for Codex |
|
||||
| `credential-utils.ts` | Keychain service names | `"Claude Code-credentials"` | Codex tokens stored differently (file-based, not keychain) |
|
||||
| `usage-monitor.ts:1513` | `if (provider === 'zai' \|\| provider === 'zhipu')` | Provider-specific response unwrapping | Add Codex response parsing (different JSON structure) |
|
||||
| `rate-limit-detector.ts:14` | `RATE_LIMIT_PATTERN` | Claude-specific: `"Limit reached · resets..."` | Add Codex-specific patterns |
|
||||
| IPC channel names | `'claude:usageUpdated'`, `'claude:allProfilesUsageUpdated'` | "claude" prefix | Cosmetic — rename to `'usage:updated'` etc. (optional, low priority) |
|
||||
|
||||
### 3.3 LOW PRIORITY — Nice to Have
|
||||
|
||||
| Item | What | Why low priority |
|
||||
|---|---|---|
|
||||
| Type naming | `ClaudeUsageSnapshot` → `UsageSnapshot` | Structural refactor, types work as-is for Codex |
|
||||
| IPC method names | `requestUsageUpdate` returns `ClaudeUsageSnapshot` | Works fine, just naming |
|
||||
| `claudeProfileId` on `ProviderAccount` | Only used for Anthropic OAuth | Codex doesn't need it |
|
||||
|
||||
---
|
||||
|
||||
## 4. Provider-Agnostic Parts
|
||||
|
||||
These components already work for any provider and need NO changes:
|
||||
|
||||
| Component/Module | Why it's already generic |
|
||||
|---|---|
|
||||
| `profile-scorer.ts` | Scores by `billingModel`, usage thresholds, rate limit events — no provider checks |
|
||||
| `rate-limit-manager.ts` | Stores/checks rate limit events — pure data, no provider logic |
|
||||
| `operation-registry.ts` | Tracks running operations — no provider awareness |
|
||||
| `ProviderAccount` type | Has `provider` field, `billingModel`, `usage` — works for any provider |
|
||||
| `globalPriorityOrder` | Array of account IDs — provider-agnostic ordering |
|
||||
| `useActiveProvider()` hook | Returns first account in priority order — generic |
|
||||
| `ProviderAccountCard` | Shows usage bars when `account.usage` is populated — will work for Codex once data flows |
|
||||
| `AddAccountDialog` | Already has separate Codex OAuth flow |
|
||||
| `AuthStatusIndicator` | Already shows Codex-specific green badge |
|
||||
| All i18n keys | Codex-specific labels already exist |
|
||||
|
||||
---
|
||||
|
||||
## 5. Implementation Plan
|
||||
|
||||
### Phase 1: Codex Usage Fetcher (Core)
|
||||
|
||||
Create `apps/desktop/src/main/claude-profile/codex-usage-fetcher.ts`:
|
||||
|
||||
```typescript
|
||||
// Responsibilities:
|
||||
// 1. Read Codex OAuth token (from our codex-auth.json)
|
||||
// 2. Read account_id (from ~/.codex/auth.json or JWT decode)
|
||||
// 3. Call GET https://chatgpt.com/backend-api/wham/usage
|
||||
// 4. Parse response into ClaudeUsageSnapshot format
|
||||
// 5. Handle 401 → refresh token via codex-oauth.ts
|
||||
// 6. Handle 403 → mark as needsReauthentication
|
||||
```
|
||||
|
||||
**Key function:**
|
||||
```typescript
|
||||
async function fetchCodexUsage(accessToken: string, accountId?: string): Promise<ClaudeUsageSnapshot>
|
||||
```
|
||||
|
||||
### Phase 2: Wire into Usage Monitor
|
||||
|
||||
Modify `usage-monitor.ts`:
|
||||
|
||||
1. Add `chatgpt.com` to `ALLOWED_USAGE_API_DOMAINS`
|
||||
2. Add Codex to `PROVIDER_USAGE_ENDPOINTS`
|
||||
3. Update `determineActiveProfile()` to detect Codex accounts from `globalPriorityOrder`
|
||||
4. Update `getCredential()` to read Codex OAuth token (from `codex-auth.json`)
|
||||
5. Update `fetchUsageViaAPI()` to handle Codex response format
|
||||
6. Add Codex-specific headers (`ChatGPT-Account-Id`)
|
||||
7. Add Codex response parsing (different JSON structure than Anthropic)
|
||||
|
||||
### Phase 3: Token Refresh Routing
|
||||
|
||||
Modify `token-refresh.ts` or create parallel Codex path:
|
||||
|
||||
- When refreshing a Codex token, use `auth.openai.com/oauth/token` with Codex client ID
|
||||
- When refreshing an Anthropic token, use `console.anthropic.com/v1/oauth/token` with Claude client ID
|
||||
- Provider detection: check the account's `provider` field, or detect from token prefix
|
||||
|
||||
### Phase 4: UI Updates
|
||||
|
||||
1. `UsageIndicator.tsx:118` — Add `|| provider === 'openai'` to `hasUsageMonitoring`
|
||||
2. That's it — the rest of the UI already handles usage bars, reset times, multi-profile display generically
|
||||
|
||||
### Phase 5: Auto-Swap for Codex
|
||||
|
||||
1. Add Codex-specific rate limit patterns to `rate-limit-detector.ts`
|
||||
2. Codex returns `"codexErrorInfo": "UsageLimitExceeded"` on limit hit
|
||||
3. Auto-swap logic in `profile-scorer.ts` already works — it just needs usage data populated
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Comparison Table
|
||||
|
||||
| Aspect | Anthropic (Claude Code) | OpenAI (Codex) |
|
||||
|---|---|---|
|
||||
| **Usage endpoint** | `api.anthropic.com/api/oauth/usage` | `chatgpt.com/backend-api/wham/usage` |
|
||||
| **Auth header** | `Bearer <oauth_token>` | `Bearer <access_token>` + `ChatGPT-Account-Id` |
|
||||
| **Session window** | ~5h | Configurable (`limit_window_seconds`) |
|
||||
| **Weekly window** | 7 days | Configurable (`limit_window_seconds`) |
|
||||
| **Token source** | Keychain (`Claude Code-credentials`) | File (`codex-auth.json`) |
|
||||
| **Token refresh** | `console.anthropic.com/v1/oauth/token` | `auth.openai.com/oauth/token` |
|
||||
| **Client ID** | `9d1c250a-e61b-44d9-88ed-5944d1962f5e` | `app_EMoamEEZ73f0CkXaXp7hrann` |
|
||||
| **Passive tracking** | Not available | `x-codex-*` response headers |
|
||||
| **Rate limit error** | `"Limit reached · resets Dec 17..."` | `"codexErrorInfo": "UsageLimitExceeded"` |
|
||||
| **Profile isolation** | `~/.claude-profiles/{name}/` dirs | Single `codex-auth.json` file |
|
||||
| **Multi-account** | Multiple config dirs in keychain | Single file (no multi-account yet) |
|
||||
|
||||
## Appendix: Caveats
|
||||
|
||||
1. **Undocumented API** — `chatgpt.com/backend-api/wham/usage` is internal. The Codex CLI depends on it, so it's unlikely to break silently.
|
||||
2. **Account ID** — May be required. Test without it first. If needed, decode from JWT or read `~/.codex/auth.json`.
|
||||
3. **CORS** — Not an issue (Electron main process = Node.js).
|
||||
4. **Polling rate** — Unknown if OpenAI rate-limits `wham/usage`. Start conservatively (every 30-60s).
|
||||
5. **Multi-account Codex** — Codex CLI doesn't support multiple accounts. We store one token file. If user has multiple Codex accounts, they'd need to re-auth each time (unlike Anthropic which supports multiple config dirs).
|
||||
+228
-138
@@ -2,41 +2,20 @@
|
||||
|
||||
Thank you for your interest in contributing to Auto Claude! This document provides guidelines and instructions for contributing to the project.
|
||||
|
||||
## How to Contribute
|
||||
|
||||
| What you want to do | Where to start |
|
||||
|----------------------|----------------|
|
||||
| Bug fixes & small improvements | Open a PR directly |
|
||||
| New features / architecture changes | Start a [GitHub Discussion](https://github.com/AndyMik90/Auto-Claude/discussions) or ask in [Discord](https://discord.com/channels/1448614759996854284/1451298184612548779) first |
|
||||
| Questions & setup help | [Discord #setup-help](https://discord.com/channels/1448614759996854284/1451298184612548779) |
|
||||
|
||||
## AI-Assisted Contributions
|
||||
|
||||
PRs built with AI tools (Claude, Codex, Copilot, etc.) are welcome here -- given what this project does, it would be odd if they weren't.
|
||||
|
||||
That said, we've seen AI-generated PRs that introduce regressions because the contributor didn't verify what the code actually does. To keep quality high, we ask that AI-assisted PRs include the following:
|
||||
|
||||
- **Flag it** -- mention AI assistance in the PR description (the PR template has a section for this)
|
||||
- **State your testing level** -- untested, lightly tested, or fully tested
|
||||
- **Share context if you can** -- prompts or session logs help reviewers understand intent
|
||||
- **Confirm you understand the code** -- you should be able to describe what the PR does and how the underlying code works
|
||||
|
||||
AI-assisted PRs go through the same review process as any other contribution. Transparency just helps reviewers know where to look more carefully.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [How to Contribute](#how-to-contribute)
|
||||
- [AI-Assisted Contributions](#ai-assisted-contributions)
|
||||
- [Contributor License Agreement (CLA)](#contributor-license-agreement-cla)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Development Setup](#development-setup)
|
||||
- [Python Backend](#python-backend)
|
||||
- [Electron Frontend](#electron-frontend)
|
||||
- [Running from Source](#running-from-source)
|
||||
- [Pre-commit Hooks](#pre-commit-hooks)
|
||||
- [Code Style](#code-style)
|
||||
- [Testing](#testing)
|
||||
- [Continuous Integration](#continuous-integration)
|
||||
- [Git Workflow](#git-workflow)
|
||||
- [Working with Forks](#working-with-forks)
|
||||
- [Branch Overview](#branch-overview)
|
||||
- [Main Branches](#main-branches)
|
||||
- [Supporting Branches](#supporting-branches)
|
||||
@@ -73,11 +52,35 @@ Read the full CLA here: [CLA.md](CLA.md)
|
||||
|
||||
Before contributing, ensure you have the following installed:
|
||||
|
||||
- **Node.js 24+** - For the Electron desktop app
|
||||
- **npm 10+** - Package manager (comes with Node.js)
|
||||
- **CMake** - Required for building native dependencies (e.g., node-pty)
|
||||
- **Python 3.12+** - For the backend framework
|
||||
- **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
|
||||
|
||||
**Windows:**
|
||||
```bash
|
||||
winget install Python.Python.3.12
|
||||
```
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
brew install python@3.12
|
||||
```
|
||||
|
||||
**Linux (Ubuntu/Debian):**
|
||||
```bash
|
||||
sudo apt install python3.12 python3.12-venv
|
||||
```
|
||||
|
||||
**Linux (Fedora):**
|
||||
```bash
|
||||
sudo dnf install python3.12
|
||||
```
|
||||
|
||||
### Installing Node.js 24+
|
||||
|
||||
**Windows:**
|
||||
@@ -144,27 +147,95 @@ npm start
|
||||
|
||||
## Development Setup
|
||||
|
||||
The project is a single Electron desktop application in `apps/desktop/`. All AI agent logic lives in TypeScript using the Vercel AI SDK v6.
|
||||
The project consists of two main components:
|
||||
|
||||
From the repository root:
|
||||
1. **Python Backend** (`apps/backend/`) - The core autonomous coding framework
|
||||
2. **Electron Frontend** (`apps/frontend/`) - Optional desktop UI
|
||||
|
||||
### Python Backend
|
||||
|
||||
The recommended way is to use `npm run install:backend`, but you can also set up manually:
|
||||
|
||||
```bash
|
||||
# Install all dependencies
|
||||
npm run install:all
|
||||
# Navigate to the backend directory
|
||||
cd apps/backend
|
||||
|
||||
# Start development mode (hot reload)
|
||||
npm run dev
|
||||
# Create virtual environment
|
||||
# Windows:
|
||||
py -3.12 -m venv .venv
|
||||
.venv\Scripts\activate
|
||||
|
||||
# macOS/Linux:
|
||||
python3.12 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Install test dependencies
|
||||
pip install -r ../../tests/requirements-test.txt
|
||||
|
||||
# Set up environment
|
||||
cp .env.example .env
|
||||
# Edit .env and add your CLAUDE_CODE_OAUTH_TOKEN (get it via: claude setup-token)
|
||||
```
|
||||
|
||||
`npm run install:all` installs the npm dependencies for `apps/desktop/`.
|
||||
|
||||
### Other Useful Commands
|
||||
### Electron Frontend
|
||||
|
||||
```bash
|
||||
npm start # Build and run production
|
||||
npm run build # Build for production
|
||||
npm run package # Package for distribution
|
||||
npm test # Run frontend tests
|
||||
# Navigate to the frontend directory
|
||||
cd apps/frontend
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start development server
|
||||
npm run dev
|
||||
|
||||
# Build for production
|
||||
npm run build
|
||||
|
||||
# Package for distribution
|
||||
npm run package
|
||||
```
|
||||
|
||||
## Running from Source
|
||||
|
||||
If you want to run Auto Claude from source (for development or testing unreleased features), follow these steps:
|
||||
|
||||
### Step 1: Clone and Set Up
|
||||
|
||||
```bash
|
||||
git clone https://github.com/AndyMik90/Auto-Claude.git
|
||||
cd Auto-Claude/apps/backend
|
||||
|
||||
# Using uv (recommended)
|
||||
uv venv && uv pip install -r requirements.txt
|
||||
|
||||
# Or using standard Python
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate # On Windows: .venv\Scripts\activate
|
||||
pip install -r requirements.txt
|
||||
|
||||
# Set up environment
|
||||
cd apps/backend
|
||||
cp .env.example .env
|
||||
# Edit .env and add your CLAUDE_CODE_OAUTH_TOKEN (get it via: claude setup-token)
|
||||
```
|
||||
|
||||
### Step 2: Run the Desktop UI
|
||||
|
||||
```bash
|
||||
cd ../frontend
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Development mode (hot reload)
|
||||
npm run dev
|
||||
|
||||
# Or production build
|
||||
npm run build && npm run start
|
||||
```
|
||||
|
||||
<details>
|
||||
@@ -183,20 +254,28 @@ Auto Claude automatically downloads prebuilt binaries for Windows. If prebuilts
|
||||
|
||||
## Pre-commit Hooks
|
||||
|
||||
We use Husky + lint-staged to run Biome linting and formatting checks before each commit.
|
||||
We use [pre-commit](https://pre-commit.com/) to run linting and formatting checks before each commit. This ensures code quality and consistency across the project.
|
||||
|
||||
### Setup
|
||||
|
||||
Husky is installed automatically when you run `npm install` inside `apps/desktop/`.
|
||||
```bash
|
||||
# Install pre-commit
|
||||
pip install pre-commit
|
||||
|
||||
# Install the git hooks (run once after cloning)
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
### What Runs on Commit
|
||||
|
||||
When you commit, the following checks run automatically on staged files:
|
||||
When you commit, the following checks run automatically:
|
||||
|
||||
| Check | Scope | Description |
|
||||
|-------|-------|-------------|
|
||||
| **Biome** | `apps/desktop/` | TypeScript/React linter + formatter |
|
||||
| **typecheck** | `apps/desktop/` | TypeScript type checking |
|
||||
| **ruff** | `apps/backend/` | Python linter with auto-fix |
|
||||
| **ruff-format** | `apps/backend/` | Python code formatter |
|
||||
| **eslint** | `apps/frontend/` | TypeScript/React linter |
|
||||
| **typecheck** | `apps/frontend/` | TypeScript type checking |
|
||||
| **trailing-whitespace** | All files | Removes trailing whitespace |
|
||||
| **end-of-file-fixer** | All files | Ensures files end with newline |
|
||||
| **check-yaml** | All files | Validates YAML syntax |
|
||||
@@ -205,29 +284,55 @@ When you commit, the following checks run automatically on staged files:
|
||||
### Running Manually
|
||||
|
||||
```bash
|
||||
cd apps/desktop
|
||||
# Run all checks on all files
|
||||
pre-commit run --all-files
|
||||
|
||||
# Run linter (Biome)
|
||||
npm run lint
|
||||
# Run a specific hook
|
||||
pre-commit run ruff --all-files
|
||||
|
||||
# Auto-fix lint issues
|
||||
npm run lint:fix
|
||||
|
||||
# Run type checking
|
||||
npm run typecheck
|
||||
# Skip hooks temporarily (not recommended)
|
||||
git commit --no-verify -m "message"
|
||||
```
|
||||
|
||||
### If a Check Fails
|
||||
|
||||
1. **Biome auto-fixes**: Run `npm run lint:fix` in `apps/desktop/`. Stage the changes and commit again.
|
||||
2. **Type errors**: Resolve TypeScript type issues before committing.
|
||||
1. **Ruff auto-fixes**: Some issues are fixed automatically. Stage the changes and commit again.
|
||||
2. **ESLint errors**: Fix the reported issues in your code.
|
||||
3. **Type errors**: Resolve TypeScript type issues before committing.
|
||||
|
||||
## Code Style
|
||||
|
||||
### Python
|
||||
|
||||
- Follow PEP 8 style guidelines
|
||||
- Use type hints for function signatures
|
||||
- Use docstrings for public functions and classes
|
||||
- Keep functions focused and under 50 lines when possible
|
||||
- Use meaningful variable and function names
|
||||
|
||||
```python
|
||||
# Good
|
||||
def get_next_chunk(spec_dir: Path) -> dict | None:
|
||||
"""
|
||||
Find the next pending chunk in the implementation plan.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
|
||||
Returns:
|
||||
The next chunk dict or None if all chunks are complete
|
||||
"""
|
||||
...
|
||||
|
||||
# Avoid
|
||||
def gnc(sd):
|
||||
...
|
||||
```
|
||||
|
||||
### TypeScript/React
|
||||
|
||||
- Use TypeScript strict mode
|
||||
- Follow the existing component patterns in `apps/desktop/src/`
|
||||
- Follow the existing component patterns in `apps/frontend/src/`
|
||||
- Use functional components with hooks
|
||||
- Prefer named exports over default exports
|
||||
- Use the UI components from `src/renderer/components/ui/`
|
||||
@@ -254,10 +359,36 @@ export default function(props) {
|
||||
|
||||
## Testing
|
||||
|
||||
### Python Tests
|
||||
|
||||
```bash
|
||||
# Run all tests (from repository root)
|
||||
npm run test:backend
|
||||
|
||||
# Or manually with pytest
|
||||
cd apps/backend
|
||||
.venv/Scripts/pytest.exe ../tests -v # Windows
|
||||
.venv/bin/pytest ../tests -v # macOS/Linux
|
||||
|
||||
# Run a specific test file
|
||||
npm run test:backend -- tests/test_security.py -v
|
||||
|
||||
# Run a specific test
|
||||
npm run test:backend -- tests/test_security.py::test_bash_command_validation -v
|
||||
|
||||
# Skip slow tests
|
||||
npm run test:backend -- -m "not slow"
|
||||
|
||||
# Run with coverage
|
||||
pytest tests/ --cov=apps/backend --cov-report=html
|
||||
```
|
||||
|
||||
Test configuration is in `tests/pytest.ini`.
|
||||
|
||||
### Frontend Tests
|
||||
|
||||
```bash
|
||||
cd apps/desktop
|
||||
cd apps/frontend
|
||||
|
||||
# Run unit tests
|
||||
npm test
|
||||
@@ -296,22 +427,30 @@ All pull requests and pushes to `main` trigger automated CI checks via GitHub Ac
|
||||
|
||||
| Workflow | Trigger | What it checks |
|
||||
|----------|---------|----------------|
|
||||
| **CI** | Push to `main`, PRs | Frontend tests (all 3 platforms), TypeScript type check, build |
|
||||
| **Lint** | Push to `main`, PRs | Biome (TypeScript/React) |
|
||||
| **CI** | Push to `main`, PRs | Python tests (3.11 & 3.12), Frontend tests |
|
||||
| **Lint** | Push to `main`, PRs | Ruff (Python), ESLint + TypeScript (Frontend) |
|
||||
| **Test on Tag** | Version tags (`v*`) | Full test suite before release |
|
||||
|
||||
### PR Requirements
|
||||
|
||||
Before a PR can be merged:
|
||||
|
||||
1. All CI checks must pass (green checkmarks)
|
||||
2. Frontend tests pass on all three platforms (Ubuntu, Windows, macOS)
|
||||
3. Linting passes (no Biome errors)
|
||||
4. TypeScript type checking passes
|
||||
2. Python tests pass on both Python 3.11 and 3.12
|
||||
3. Frontend tests pass
|
||||
4. Linting passes (no ruff or eslint errors)
|
||||
5. TypeScript type checking passes
|
||||
|
||||
### Running CI Checks Locally
|
||||
|
||||
```bash
|
||||
cd apps/desktop
|
||||
# Python tests
|
||||
cd apps/backend
|
||||
source .venv/bin/activate
|
||||
pytest ../../tests/ -v
|
||||
|
||||
# Frontend tests
|
||||
cd apps/frontend
|
||||
npm test
|
||||
npm run lint
|
||||
npm run typecheck
|
||||
@@ -321,72 +460,6 @@ npm run typecheck
|
||||
|
||||
We use a **Git Flow** branching strategy to manage releases and parallel development.
|
||||
|
||||
### Working with Forks
|
||||
|
||||
When contributing to Auto Claude, you'll typically fork the repository first. Proper fork configuration is essential to avoid sync issues.
|
||||
|
||||
#### Initial Fork Setup
|
||||
|
||||
```bash
|
||||
# 1. Fork on GitHub (click the Fork button on the repo page)
|
||||
|
||||
# 2. Clone YOUR fork (not the original repo)
|
||||
git clone https://github.com/YOUR-USERNAME/Auto-Claude.git
|
||||
cd Auto-Claude
|
||||
|
||||
# 3. Verify your remotes point to YOUR fork
|
||||
git remote -v
|
||||
# Should show:
|
||||
# origin https://github.com/YOUR-USERNAME/Auto-Claude.git (fetch)
|
||||
# origin https://github.com/YOUR-USERNAME/Auto-Claude.git (push)
|
||||
|
||||
# 4. Add upstream remote to sync with the original repo
|
||||
git remote add upstream https://github.com/AndyMik90/Auto-Claude.git
|
||||
```
|
||||
|
||||
#### Keeping Your Fork Updated
|
||||
|
||||
```bash
|
||||
# Fetch latest changes from upstream
|
||||
git fetch upstream
|
||||
|
||||
# Sync your develop branch with upstream
|
||||
git checkout develop
|
||||
git merge upstream/develop
|
||||
git push origin develop
|
||||
```
|
||||
|
||||
#### Converting a Fork to Standalone
|
||||
|
||||
> ⚠️ **Common Issue:** After making a fork standalone (e.g., disconnecting from the original repo on GitHub), your local git configuration may still reference the original forked repository, causing push/pull issues.
|
||||
|
||||
If you convert your fork to a standalone repository:
|
||||
|
||||
```bash
|
||||
# 1. Update origin to point to your standalone repo
|
||||
git remote set-url origin https://github.com/YOUR-USERNAME/Your-Standalone-Repo.git
|
||||
|
||||
# 2. Remove the upstream remote (no longer applicable)
|
||||
git remote remove upstream
|
||||
|
||||
# 3. Verify your configuration
|
||||
git remote -v
|
||||
# Should only show your standalone repo as origin
|
||||
|
||||
# 4. Update your default branch tracking if needed
|
||||
git branch --set-upstream-to=origin/main main
|
||||
git branch --set-upstream-to=origin/develop develop
|
||||
```
|
||||
|
||||
#### Troubleshooting Fork Issues
|
||||
|
||||
| Problem | Cause | Solution |
|
||||
|---------|-------|----------|
|
||||
| `Permission denied` on push | Origin points to upstream repo | `git remote set-url origin <your-fork-url>` |
|
||||
| `Repository not found` | Fork was deleted or made standalone | Update remote URL to current repo location |
|
||||
| Can't push to develop | Local branch tracks wrong remote | `git branch --set-upstream-to=origin/develop` |
|
||||
| Commits show wrong author | Git config not set | `git config user.email "you@example.com"` |
|
||||
|
||||
### Branch Overview
|
||||
|
||||
```
|
||||
@@ -622,7 +695,8 @@ git rebase -i origin/develop
|
||||
git push --force-with-lease
|
||||
|
||||
# Verify everything works
|
||||
cd apps/desktop && npm test && npm run lint && npm run typecheck
|
||||
npm run test:backend
|
||||
cd apps/frontend && npm test && npm run lint && npm run typecheck
|
||||
```
|
||||
|
||||
**PR size:**
|
||||
@@ -643,7 +717,11 @@ cd apps/desktop && npm test && npm run lint && npm run typecheck
|
||||
|
||||
3. **Test thoroughly**:
|
||||
```bash
|
||||
cd apps/desktop && npm test && npm run lint && npm run typecheck
|
||||
# Python (from repository root)
|
||||
npm run test:backend
|
||||
|
||||
# Frontend
|
||||
cd apps/frontend && npm test && npm run lint && npm run typecheck
|
||||
```
|
||||
|
||||
4. **Update documentation** if your changes affect:
|
||||
@@ -681,7 +759,8 @@ When reporting a bug, include:
|
||||
1. **Clear title** describing the issue
|
||||
2. **Environment details**:
|
||||
- OS and version
|
||||
- Node.js version
|
||||
- Python version
|
||||
- Node.js version (for UI issues)
|
||||
- Auto Claude version
|
||||
3. **Steps to reproduce** the issue
|
||||
4. **Expected behavior** vs **actual behavior**
|
||||
@@ -699,14 +778,25 @@ When requesting a feature:
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Auto Claude is a single Electron desktop application in `apps/desktop/`.
|
||||
Auto Claude consists of two main parts:
|
||||
|
||||
### Electron Desktop (`apps/desktop/`)
|
||||
### Python Backend (`apps/backend/`)
|
||||
|
||||
- **AI Agent Layer** (`src/main/ai/`) - Vercel AI SDK v6 agent runtime, providers, tools, security, orchestration
|
||||
- **Main Process** (`src/main/`) - IPC handlers, agent queue, terminal management, claude-profile
|
||||
- **Renderer** (`src/renderer/`) - React UI components and Zustand stores
|
||||
- **Shared** (`src/shared/`) - Types, i18n locales, constants, utilities
|
||||
The core autonomous coding framework:
|
||||
|
||||
- **Entry Points**: `run.py` (build runner), `spec_runner.py` (spec creator)
|
||||
- **Agent System**: `agent.py`, `client.py`, `prompts/`
|
||||
- **Execution**: `coordinator.py` (parallel), `worktree.py` (isolation)
|
||||
- **Memory**: `memory.py` (file-based), `graphiti_memory.py` (graph-based)
|
||||
- **QA**: `qa_loop.py`, `prompts/qa_*.md`
|
||||
|
||||
### Electron Frontend (`apps/frontend/`)
|
||||
|
||||
Desktop interface:
|
||||
|
||||
- **Main Process**: `src/main/` - Electron main process, IPC handlers
|
||||
- **Renderer**: `src/renderer/` - React UI components
|
||||
- **Shared**: `src/shared/` - Types and utilities
|
||||
|
||||
For detailed architecture information, see [CLAUDE.md](CLAUDE.md).
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
# Aperant (formerly Auto Claude)
|
||||
# Auto Claude
|
||||
|
||||
**Autonomous multi-agent coding framework that plans, builds, and validates software for you.**
|
||||
|
||||

|
||||

|
||||
|
||||
[](./agpl-3.0.txt)
|
||||
[](https://discord.gg/KCXaPBr4Dj)
|
||||
[](https://www.youtube.com/@AndreMikalsen)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/actions)
|
||||
[](https://github.com/hesreallyhim/awesome-claude-code)
|
||||
|
||||
---
|
||||
|
||||
@@ -17,18 +16,17 @@
|
||||
### Stable Release
|
||||
|
||||
<!-- STABLE_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.6)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.2)
|
||||
<!-- STABLE_VERSION_BADGE_END -->
|
||||
|
||||
<!-- STABLE_DOWNLOADS -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **Windows** | [Auto-Claude-2.7.6-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6/Auto-Claude-2.7.6-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6/Auto-Claude-2.7.6-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.6-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6/Auto-Claude-2.7.6-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.6-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6/Auto-Claude-2.7.6-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.6-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6/Auto-Claude-2.7.6-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6/Auto-Claude-2.7.6-linux-x86_64.flatpak) |
|
||||
| **Windows** | [Auto-Claude-2.7.2-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.2-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.2-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.2-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.2-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-linux-amd64.deb) |
|
||||
<!-- STABLE_DOWNLOADS_END -->
|
||||
|
||||
### Beta Release
|
||||
@@ -36,18 +34,18 @@
|
||||
> ⚠️ 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.8.0-beta.5)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.2-beta.10)
|
||||
<!-- BETA_VERSION_BADGE_END -->
|
||||
|
||||
<!-- BETA_DOWNLOADS -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **Windows** | [Aperant-2.8.0-beta.5-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Aperant-2.8.0-beta.5-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Aperant-2.8.0-beta.5-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-darwin-x64.dmg) |
|
||||
| **Linux** | [Aperant-2.8.0-beta.5-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Aperant-2.8.0-beta.5-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Aperant-2.8.0-beta.5-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.8.0-beta.5/Aperant-2.8.0-beta.5-linux-x86_64.flatpak) |
|
||||
| **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 -->
|
||||
|
||||
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
|
||||
@@ -114,15 +112,39 @@ AI-assisted feature planning with competitor analysis and audience targeting.
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
Aperant/
|
||||
Auto-Claude/
|
||||
├── apps/
|
||||
│ └── desktop/ # Electron desktop application (TypeScript AI agent layer + UI)
|
||||
│ ├── backend/ # Python agents, specs, QA pipeline
|
||||
│ └── frontend/ # Electron desktop application
|
||||
├── guides/ # Additional documentation
|
||||
├── tests/ # Test suite
|
||||
└── scripts/ # Build utilities
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CLI Usage
|
||||
|
||||
For headless operation, CI/CD integration, or terminal-only workflows:
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
|
||||
# Create a spec interactively
|
||||
python spec_runner.py --interactive
|
||||
|
||||
# Run autonomous build
|
||||
python run.py --spec 001
|
||||
|
||||
# Review and merge
|
||||
python run.py --spec 001 --review
|
||||
python run.py --spec 001 --merge
|
||||
```
|
||||
|
||||
See [guides/CLI-USAGE.md](guides/CLI-USAGE.md) for complete CLI documentation.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
Want to build from source or contribute? See [CONTRIBUTING.md](CONTRIBUTING.md) for complete development setup instructions.
|
||||
@@ -133,7 +155,7 @@ For Linux-specific builds (Flatpak, AppImage), see [guides/linux.md](guides/linu
|
||||
|
||||
## Security
|
||||
|
||||
Aperant uses a three-layer security model:
|
||||
Auto Claude uses a three-layer security model:
|
||||
|
||||
1. **OS Sandbox** - Bash commands run in isolation
|
||||
2. **Filesystem Restrictions** - Operations limited to project directory
|
||||
@@ -150,7 +172,7 @@ All releases are:
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `npm run install:all` | Install all dependencies |
|
||||
| `npm run install:all` | Install backend and frontend dependencies |
|
||||
| `npm start` | Build and run the desktop app |
|
||||
| `npm run dev` | Run in development mode with hot reload |
|
||||
| `npm run package` | Package for current platform |
|
||||
@@ -160,6 +182,7 @@ All releases are:
|
||||
| `npm run package:flatpak` | Package as Flatpak (see [guides/linux.md](guides/linux.md)) |
|
||||
| `npm run lint` | Run linter |
|
||||
| `npm test` | Run frontend tests |
|
||||
| `npm run test:backend` | Run backend tests |
|
||||
|
||||
---
|
||||
|
||||
@@ -185,7 +208,7 @@ We welcome contributions! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for:
|
||||
|
||||
**AGPL-3.0** - GNU Affero General Public License v3.0
|
||||
|
||||
Aperant is free to use. If you modify and distribute it, or run it as a service, your code must also be open source under AGPL-3.0.
|
||||
Auto Claude is free to use. If you modify and distribute it, or run it as a service, your code must also be open source under AGPL-3.0.
|
||||
|
||||
Commercial licensing available for closed-source use cases.
|
||||
|
||||
|
||||
+4
-2
@@ -66,8 +66,9 @@ node scripts/bump-version.js 2.8.0 # Set specific version
|
||||
```
|
||||
|
||||
This will:
|
||||
- Update `apps/desktop/package.json`
|
||||
- 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`
|
||||
|
||||
@@ -185,6 +186,7 @@ The release workflow **validates** that `CHANGELOG.md` has an entry for the vers
|
||||
|----------|---------|---------|
|
||||
| `prepare-release.yml` | Push to `main` | Detects version bump, **validates CHANGELOG.md**, creates tag |
|
||||
| `release.yml` | Tag `v*` pushed | Builds binaries, extracts changelog, creates release |
|
||||
| `validate-version.yml` | Tag `v*` pushed | Validates tag matches package.json |
|
||||
| `update-readme` (in release.yml) | After release | Updates README with new version |
|
||||
|
||||
## Troubleshooting
|
||||
@@ -194,7 +196,7 @@ The release workflow **validates** that `CHANGELOG.md` has an entry for the vers
|
||||
1. Check if version in `package.json` is greater than latest tag:
|
||||
```bash
|
||||
git tag -l 'v*' --sort=-version:refname | head -1
|
||||
cat apps/desktop/package.json | grep version
|
||||
cat apps/frontend/package.json | grep version
|
||||
```
|
||||
|
||||
2. Ensure the merge commit touched `package.json`:
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
# Auto Claude Environment Variables
|
||||
# Copy this file to .env and fill in your values
|
||||
|
||||
# =============================================================================
|
||||
# AUTHENTICATION (REQUIRED)
|
||||
# =============================================================================
|
||||
# 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 2: Set the token explicitly:
|
||||
# CLAUDE_CODE_OAUTH_TOKEN=your-oauth-token-here
|
||||
#
|
||||
# For enterprise/proxy setups (CCR):
|
||||
# ANTHROPIC_AUTH_TOKEN=sk-zcf-x-ccr
|
||||
|
||||
# =============================================================================
|
||||
# CUSTOM API ENDPOINT (OPTIONAL)
|
||||
# =============================================================================
|
||||
# Override the default Anthropic API endpoint. Useful for:
|
||||
# - Local proxies (ccr, litellm)
|
||||
# - API gateways
|
||||
# - Self-hosted Claude instances
|
||||
#
|
||||
# ANTHROPIC_BASE_URL=http://127.0.0.1:3456
|
||||
#
|
||||
# Related settings (usually set together with ANTHROPIC_BASE_URL):
|
||||
# NO_PROXY=127.0.0.1
|
||||
# DISABLE_TELEMETRY=true
|
||||
# DISABLE_COST_WARNINGS=true
|
||||
# API_TIMEOUT_MS=600000
|
||||
|
||||
# Model override (OPTIONAL)
|
||||
# Default: claude-opus-4-5-20251101
|
||||
# AUTO_BUILD_MODEL=claude-opus-4-5-20251101
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GIT/WORKTREE SETTINGS (OPTIONAL)
|
||||
# =============================================================================
|
||||
# Configure how Auto Claude handles git worktrees for isolated builds.
|
||||
|
||||
# Default base branch for worktree creation (OPTIONAL)
|
||||
# If not set, Auto Claude will auto-detect main/master, or fall back to current branch.
|
||||
# Common values: main, master, develop
|
||||
# DEFAULT_BRANCH=main
|
||||
|
||||
# =============================================================================
|
||||
# DEBUG MODE (OPTIONAL)
|
||||
# =============================================================================
|
||||
# Enable debug logging for development and troubleshooting.
|
||||
# Shows detailed information about runner execution, agent calls, file operations.
|
||||
|
||||
# Enable debug mode (default: false)
|
||||
# DEBUG=true
|
||||
|
||||
# Debug log level: 1=basic, 2=detailed, 3=verbose (default: 1)
|
||||
# DEBUG_LEVEL=1
|
||||
|
||||
# Log to file instead of stdout (OPTIONAL)
|
||||
# DEBUG_LOG_FILE=auto-claude/debug.log
|
||||
|
||||
# =============================================================================
|
||||
# LINEAR INTEGRATION (OPTIONAL)
|
||||
# =============================================================================
|
||||
# Enable Linear integration for real-time progress tracking in Linear.
|
||||
# Get your API key from: https://linear.app/YOUR-TEAM/settings/api
|
||||
|
||||
# Linear API Key (OPTIONAL - enables Linear integration)
|
||||
# LINEAR_API_KEY=lin_api_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# Pre-configured Team ID (OPTIONAL - will auto-detect if not set)
|
||||
# LINEAR_TEAM_ID=
|
||||
|
||||
# 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)
|
||||
# =============================================================================
|
||||
# Enable fancy terminal UI with icons, colors, and interactive menus.
|
||||
# Set to "false" to use plain text output (useful for CI/CD or log files).
|
||||
|
||||
# Enable fancy UI (default: true)
|
||||
# ENABLE_FANCY_UI=true
|
||||
|
||||
# =============================================================================
|
||||
# ELECTRON MCP SERVER (OPTIONAL)
|
||||
# =============================================================================
|
||||
# Enable Electron MCP server for AI agents to interact with and validate
|
||||
# Electron desktop applications. This allows QA agents to capture screenshots,
|
||||
# inspect windows, and validate Electron apps during the review process.
|
||||
#
|
||||
# The electron-mcp-server connects via Chrome DevTools Protocol to an Electron
|
||||
# app running with remote debugging enabled.
|
||||
#
|
||||
# Prerequisites:
|
||||
# 1. Start your Electron app with remote debugging:
|
||||
# ./YourElectronApp --remote-debugging-port=9222
|
||||
#
|
||||
# 2. For auto-claude-ui specifically (use the MCP-enabled scripts):
|
||||
# cd auto-claude-ui
|
||||
# pnpm run dev:mcp # Development mode with MCP debugging
|
||||
# # OR for production build:
|
||||
# pnpm run start:mcp # Production mode with MCP debugging
|
||||
#
|
||||
# Note: Only QA agents (qa_reviewer, qa_fixer) receive Electron MCP tools.
|
||||
# Coder and Planner agents do NOT have access to these tools to minimize
|
||||
# context token usage and keep agents focused on their roles.
|
||||
#
|
||||
# See: https://github.com/anthropics/anthropic-quickstarts/tree/main/mcp-electron-demo
|
||||
|
||||
# Enable Electron MCP integration (default: false)
|
||||
# ELECTRON_MCP_ENABLED=true
|
||||
|
||||
# Chrome DevTools debugging port for Electron connection (default: 9222)
|
||||
# ELECTRON_DEBUG_PORT=9222
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI MEMORY INTEGRATION (REQUIRED)
|
||||
# =============================================================================
|
||||
# Graphiti-based persistent memory layer for cross-session context
|
||||
# retention. Uses LadybugDB as the embedded graph database.
|
||||
#
|
||||
# REQUIREMENTS:
|
||||
# - Python 3.12 or higher
|
||||
# - Install: pip install real_ladybug graphiti-core
|
||||
#
|
||||
# Supports multiple LLM and embedder providers:
|
||||
# - OpenAI (default)
|
||||
# - Anthropic (LLM only, use with Voyage for embeddings)
|
||||
# - Azure OpenAI
|
||||
# - Ollama (local, fully offline)
|
||||
# - Google AI (Gemini)
|
||||
|
||||
# Graphiti is enabled by default. Set to false to disable memory features.
|
||||
GRAPHITI_ENABLED=true
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI: Database Settings
|
||||
# =============================================================================
|
||||
# LadybugDB stores data in a local directory (no Docker required).
|
||||
|
||||
# Database name (default: auto_claude_memory)
|
||||
# GRAPHITI_DATABASE=auto_claude_memory
|
||||
|
||||
# Database storage path (default: ~/.auto-claude/memories)
|
||||
# GRAPHITI_DB_PATH=~/.auto-claude/memories
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI: Provider Selection
|
||||
# =============================================================================
|
||||
# Choose which providers to use for LLM and embeddings.
|
||||
# Default is "openai" for both.
|
||||
|
||||
# LLM provider: openai | anthropic | azure_openai | ollama | google | openrouter
|
||||
# GRAPHITI_LLM_PROVIDER=openai
|
||||
|
||||
# Embedder provider: openai | voyage | azure_openai | ollama | google | openrouter
|
||||
# GRAPHITI_EMBEDDER_PROVIDER=openai
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI: OpenAI Provider (Default)
|
||||
# =============================================================================
|
||||
# Use OpenAI for both LLM and embeddings. This is the simplest setup.
|
||||
# Required: OPENAI_API_KEY
|
||||
|
||||
# OpenAI API Key
|
||||
# OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# OpenAI Model for LLM (default: gpt-4o-mini)
|
||||
# OPENAI_MODEL=gpt-4o-mini
|
||||
|
||||
# OpenAI Model for embeddings (default: text-embedding-3-small)
|
||||
# Available: text-embedding-3-small (1536 dim), text-embedding-3-large (3072 dim)
|
||||
# OPENAI_EMBEDDING_MODEL=text-embedding-3-small
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI: Anthropic Provider (LLM only)
|
||||
# =============================================================================
|
||||
# Use Anthropic for LLM. Requires separate embedder (use Voyage or OpenAI).
|
||||
# Example: GRAPHITI_LLM_PROVIDER=anthropic, GRAPHITI_EMBEDDER_PROVIDER=voyage
|
||||
#
|
||||
# Required: ANTHROPIC_API_KEY
|
||||
|
||||
# Anthropic API Key
|
||||
# ANTHROPIC_API_KEY=sk-ant-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# Anthropic Model (default: claude-sonnet-4-5-latest)
|
||||
# GRAPHITI_ANTHROPIC_MODEL=claude-sonnet-4-5-latest
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI: Voyage AI Provider (Embeddings only)
|
||||
# =============================================================================
|
||||
# Use Voyage AI for embeddings. Commonly paired with Anthropic LLM.
|
||||
# Get API key from: https://www.voyageai.com/
|
||||
#
|
||||
# Required: VOYAGE_API_KEY
|
||||
|
||||
# Voyage AI API Key
|
||||
# VOYAGE_API_KEY=pa-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# Voyage Embedding Model (default: voyage-3)
|
||||
# Available: voyage-3 (1024 dim), voyage-3-lite (512 dim)
|
||||
# VOYAGE_EMBEDDING_MODEL=voyage-3
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI: Google AI Provider
|
||||
# =============================================================================
|
||||
# Use Google AI (Gemini) for both LLM and embeddings.
|
||||
# Get API key from: https://aistudio.google.com/apikey
|
||||
#
|
||||
# Required: GOOGLE_API_KEY
|
||||
|
||||
# Google AI API Key
|
||||
# GOOGLE_API_KEY=AIzaSyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# Google LLM Model (default: gemini-2.0-flash)
|
||||
# GOOGLE_LLM_MODEL=gemini-2.0-flash
|
||||
|
||||
# Google Embedding Model (default: text-embedding-004)
|
||||
# GOOGLE_EMBEDDING_MODEL=text-embedding-004
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI: OpenRouter Provider (Multi-provider aggregator)
|
||||
# =============================================================================
|
||||
# Use OpenRouter to access multiple LLM providers through a single API.
|
||||
# OpenRouter provides access to Anthropic, OpenAI, Google, and many other models.
|
||||
# Get API key from: https://openrouter.ai/keys
|
||||
#
|
||||
# Required: OPENROUTER_API_KEY
|
||||
|
||||
# OpenRouter API Key
|
||||
# OPENROUTER_API_KEY=sk-or-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# OpenRouter Base URL (default: https://openrouter.ai/api/v1)
|
||||
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
|
||||
# OpenRouter LLM Model (default: anthropic/claude-3.5-sonnet)
|
||||
# Popular choices: anthropic/claude-3.5-sonnet, openai/gpt-4o, google/gemini-2.0-flash
|
||||
# OPENROUTER_LLM_MODEL=anthropic/claude-3.5-sonnet
|
||||
|
||||
# OpenRouter Embedding Model (default: openai/text-embedding-3-small)
|
||||
# OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI: Azure OpenAI Provider
|
||||
# =============================================================================
|
||||
# Use Azure OpenAI for both LLM and embeddings.
|
||||
# Requires Azure OpenAI deployment with appropriate models.
|
||||
#
|
||||
# Required: AZURE_OPENAI_API_KEY, AZURE_OPENAI_BASE_URL
|
||||
|
||||
# Azure OpenAI API Key
|
||||
# AZURE_OPENAI_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# Azure OpenAI Base URL (your Azure endpoint)
|
||||
# AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com/openai/deployments/your-deployment
|
||||
|
||||
# Azure OpenAI Deployment Names
|
||||
# AZURE_OPENAI_LLM_DEPLOYMENT=gpt-4
|
||||
# AZURE_OPENAI_EMBEDDING_DEPLOYMENT=text-embedding-3-small
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI: Ollama Provider (Local/Offline)
|
||||
# =============================================================================
|
||||
# Use Ollama for fully offline operation. No API keys required.
|
||||
# Requires Ollama running locally with appropriate models pulled.
|
||||
#
|
||||
# Prerequisites:
|
||||
# 1. Install Ollama: https://ollama.ai/
|
||||
# 2. Pull models: ollama pull deepseek-r1:7b && ollama pull nomic-embed-text
|
||||
# 3. Start Ollama server (usually auto-starts)
|
||||
#
|
||||
# Required: OLLAMA_LLM_MODEL, OLLAMA_EMBEDDING_MODEL, OLLAMA_EMBEDDING_DIM
|
||||
|
||||
# Ollama Server URL (default: http://localhost:11434)
|
||||
# OLLAMA_BASE_URL=http://localhost:11434
|
||||
|
||||
# Ollama LLM Model
|
||||
# Popular choices: deepseek-r1:7b, llama3.2:3b, mistral:7b, phi3:medium
|
||||
# OLLAMA_LLM_MODEL=deepseek-r1:7b
|
||||
|
||||
# Ollama Embedding Model
|
||||
# Popular choices: nomic-embed-text (768 dim), mxbai-embed-large (1024 dim)
|
||||
# OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||
|
||||
# Ollama Embedding Dimension (REQUIRED for Ollama embeddings)
|
||||
# Must match your embedding model's output dimension
|
||||
# Common values: nomic-embed-text=768, mxbai-embed-large=1024, all-minilm=384
|
||||
# OLLAMA_EMBEDDING_DIM=768
|
||||
|
||||
# =============================================================================
|
||||
# GRAPHITI: Example Configurations
|
||||
# =============================================================================
|
||||
#
|
||||
# --- Example 1: OpenAI (simplest) ---
|
||||
# GRAPHITI_ENABLED=true
|
||||
# GRAPHITI_LLM_PROVIDER=openai
|
||||
# GRAPHITI_EMBEDDER_PROVIDER=openai
|
||||
# OPENAI_API_KEY=sk-xxxxxxxx
|
||||
#
|
||||
# --- Example 2: Anthropic + Voyage (high quality) ---
|
||||
# GRAPHITI_ENABLED=true
|
||||
# GRAPHITI_LLM_PROVIDER=anthropic
|
||||
# GRAPHITI_EMBEDDER_PROVIDER=voyage
|
||||
# ANTHROPIC_API_KEY=sk-ant-xxxxxxxx
|
||||
# VOYAGE_API_KEY=pa-xxxxxxxx
|
||||
#
|
||||
# --- Example 3: Ollama (fully offline) ---
|
||||
# GRAPHITI_ENABLED=true
|
||||
# GRAPHITI_LLM_PROVIDER=ollama
|
||||
# GRAPHITI_EMBEDDER_PROVIDER=ollama
|
||||
# OLLAMA_LLM_MODEL=deepseek-r1:7b
|
||||
# OLLAMA_EMBEDDING_MODEL=nomic-embed-text
|
||||
# OLLAMA_EMBEDDING_DIM=768
|
||||
#
|
||||
# --- Example 4: Azure OpenAI (enterprise) ---
|
||||
# GRAPHITI_ENABLED=true
|
||||
# GRAPHITI_LLM_PROVIDER=azure_openai
|
||||
# GRAPHITI_EMBEDDER_PROVIDER=azure_openai
|
||||
# AZURE_OPENAI_API_KEY=xxxxxxxx
|
||||
# AZURE_OPENAI_BASE_URL=https://your-resource.openai.azure.com/...
|
||||
# AZURE_OPENAI_LLM_DEPLOYMENT=gpt-4
|
||||
# AZURE_OPENAI_EMBEDDING_DEPLOYMENT=text-embedding-3-small
|
||||
#
|
||||
# --- Example 5: Google AI (Gemini) ---
|
||||
# GRAPHITI_ENABLED=true
|
||||
# GRAPHITI_LLM_PROVIDER=google
|
||||
# GRAPHITI_EMBEDDER_PROVIDER=google
|
||||
# GOOGLE_API_KEY=AIzaSyxxxxxxxx
|
||||
#
|
||||
# --- Example 6: OpenRouter (multi-provider aggregator) ---
|
||||
# GRAPHITI_ENABLED=true
|
||||
# GRAPHITI_LLM_PROVIDER=openrouter
|
||||
# GRAPHITI_EMBEDDER_PROVIDER=openrouter
|
||||
# OPENROUTER_API_KEY=sk-or-xxxxxxxx
|
||||
# OPENROUTER_LLM_MODEL=anthropic/claude-3.5-sonnet
|
||||
# OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small
|
||||
@@ -0,0 +1,66 @@
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Virtual environment
|
||||
.venv/
|
||||
.venv*/
|
||||
venv/
|
||||
env/
|
||||
|
||||
# Python cache
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
|
||||
# Puppeteer / Browser automation
|
||||
puppeteer_logs/
|
||||
puppeteer-*.log
|
||||
*.screenshot.png
|
||||
screenshots/
|
||||
.puppeteerrc.*
|
||||
chrome-profile/
|
||||
chromium-profile/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Git worktrees (used by parallel mode)
|
||||
.worktrees/
|
||||
|
||||
# Claude Code settings (project-specific)
|
||||
.claude_settings.json
|
||||
.auto-build-security.json
|
||||
|
||||
# Tests (development only)
|
||||
tests/
|
||||
|
||||
# Auto Claude data directory
|
||||
.auto-claude/
|
||||
@@ -0,0 +1,120 @@
|
||||
# Auto Claude Backend
|
||||
|
||||
Autonomous coding framework powered by Claude AI. Builds software features through coordinated multi-agent sessions.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### 1. Install
|
||||
|
||||
```bash
|
||||
cd apps/backend
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### 2. Configure
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Set your Claude API token in `.env`:
|
||||
```
|
||||
CLAUDE_CODE_OAUTH_TOKEN=your-token-here
|
||||
```
|
||||
|
||||
Get your token by running: `claude setup-token`
|
||||
|
||||
### 3. Run
|
||||
|
||||
```bash
|
||||
# List available specs
|
||||
python run.py --list
|
||||
|
||||
# Run a spec
|
||||
python run.py --spec 001
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- Claude API token
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `--list` | List all specs |
|
||||
| `--spec 001` | Run spec 001 |
|
||||
| `--spec 001 --isolated` | Run in isolated workspace |
|
||||
| `--spec 001 --direct` | Run directly in repo |
|
||||
| `--spec 001 --merge` | Merge completed build |
|
||||
| `--spec 001 --review` | Review build changes |
|
||||
| `--spec 001 --discard` | Discard build |
|
||||
| `--spec 001 --qa` | Run QA validation |
|
||||
| `--list-worktrees` | List all worktrees |
|
||||
| `--help` | Show all options |
|
||||
|
||||
## Configuration
|
||||
|
||||
Optional `.env` settings:
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `AUTO_BUILD_MODEL` | Override Claude model |
|
||||
| `DEBUG=true` | Enable debug logging |
|
||||
| `LINEAR_API_KEY` | Enable Linear integration |
|
||||
| `GRAPHITI_ENABLED=true` | Enable memory system |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"tree-sitter not available"** - Safe to ignore, uses regex fallback.
|
||||
|
||||
**Missing module errors** - Run `python -m pip install -r requirements.txt`
|
||||
|
||||
**Debug mode** - Set `DEBUG=true DEBUG_LEVEL=2` before running.
|
||||
|
||||
---
|
||||
|
||||
## For Developers
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
backend/
|
||||
├── agents/ # AI agent execution
|
||||
├── analysis/ # Code analysis
|
||||
├── cli/ # Command-line interface
|
||||
├── core/ # Core utilities
|
||||
├── integrations/ # External services (Linear, Graphiti)
|
||||
├── merge/ # Git merge handling
|
||||
├── project/ # Project detection
|
||||
├── prompts/ # Prompt templates
|
||||
├── qa/ # QA validation
|
||||
├── spec/ # Spec management
|
||||
└── ui/ # Terminal UI
|
||||
```
|
||||
|
||||
### Design Principles
|
||||
|
||||
- **SOLID** - Single responsibility, clean interfaces
|
||||
- **DRY** - Shared utilities in `core/`
|
||||
- **KISS** - Simple flat imports via facade modules
|
||||
|
||||
### Import Convention
|
||||
|
||||
```python
|
||||
# Use facade modules for clean imports
|
||||
from debug import debug, debug_error
|
||||
from progress import count_subtasks
|
||||
from workspace import setup_workspace
|
||||
```
|
||||
|
||||
### Adding Features
|
||||
|
||||
1. Create module in appropriate folder
|
||||
2. Export API in `__init__.py`
|
||||
3. Add facade module at root if commonly imported
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Auto Claude Backend - Autonomous Coding Framework
|
||||
==================================================
|
||||
|
||||
Multi-agent autonomous coding framework that builds software through
|
||||
coordinated AI agent sessions.
|
||||
|
||||
This package provides:
|
||||
- Autonomous agent execution for building features from specs
|
||||
- Workspace isolation via git worktrees
|
||||
- QA validation loops
|
||||
- Memory management (Graphiti + file-based)
|
||||
- Linear integration for project management
|
||||
|
||||
Quick Start:
|
||||
python run.py --spec 001 # Run a spec
|
||||
python run.py --list # List all specs
|
||||
|
||||
See README.md for full documentation.
|
||||
"""
|
||||
|
||||
__version__ = "2.7.2"
|
||||
__author__ = "Auto Claude Team"
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Backward compatibility shim - import from core.agent instead."""
|
||||
|
||||
from core.agent import * # noqa: F403
|
||||
@@ -0,0 +1,152 @@
|
||||
# Agents Module
|
||||
|
||||
Modular agent system for autonomous coding. This module refactors the original monolithic `agent.py` (1,446 lines) into focused, maintainable modules.
|
||||
|
||||
## Architecture
|
||||
|
||||
The agent system is now organized by concern:
|
||||
|
||||
```
|
||||
auto-claude/agents/
|
||||
├── __init__.py # Public API exports
|
||||
├── base.py # Shared constants and imports
|
||||
├── utils.py # Git operations and plan management
|
||||
├── memory.py # Memory management (Graphiti + file-based)
|
||||
├── session.py # Agent session execution
|
||||
├── planner.py # Follow-up planner logic
|
||||
└── coder.py # Main autonomous agent loop
|
||||
```
|
||||
|
||||
## Modules
|
||||
|
||||
### `base.py` (352 bytes)
|
||||
- Shared constants (`AUTO_CONTINUE_DELAY_SECONDS`, `HUMAN_INTERVENTION_FILE`)
|
||||
- Common imports and logging setup
|
||||
|
||||
### `utils.py` (3.6 KB)
|
||||
- Git operations: `get_latest_commit()`, `get_commit_count()`
|
||||
- Plan management: `load_implementation_plan()`, `find_subtask_in_plan()`, `find_phase_for_subtask()`
|
||||
- Workspace sync: `sync_spec_to_source()`
|
||||
|
||||
### `memory.py` (13 KB)
|
||||
- Dual-layer memory system (Graphiti primary, file-based fallback)
|
||||
- `debug_memory_system_status()` - Memory system diagnostics
|
||||
- `get_graphiti_context()` - Retrieve relevant context for subtasks
|
||||
- `save_session_memory()` - Save session insights to memory
|
||||
- `save_session_to_graphiti()` - Backwards compatibility wrapper
|
||||
|
||||
### `session.py` (17 KB)
|
||||
- `run_agent_session()` - Execute a single agent session
|
||||
- `post_session_processing()` - Process results and update memory
|
||||
- Session logging and tool tracking
|
||||
- Recovery manager integration
|
||||
|
||||
### `planner.py` (5.4 KB)
|
||||
- `run_followup_planner()` - Add new subtasks to completed specs
|
||||
- Follow-up planning workflow
|
||||
- Plan validation and status updates
|
||||
|
||||
### `coder.py` (16 KB)
|
||||
- `run_autonomous_agent()` - Main autonomous agent loop
|
||||
- Planning and coding phase management
|
||||
- Linear integration
|
||||
- Recovery and stuck subtask handling
|
||||
|
||||
## Public API
|
||||
|
||||
The `agents` module exports a clean public API:
|
||||
|
||||
```python
|
||||
from agents import (
|
||||
# Main functions
|
||||
run_autonomous_agent,
|
||||
run_followup_planner,
|
||||
|
||||
# Memory functions
|
||||
save_session_memory,
|
||||
get_graphiti_context,
|
||||
|
||||
# Session management
|
||||
run_agent_session,
|
||||
post_session_processing,
|
||||
|
||||
# Utilities
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_spec_to_source,
|
||||
)
|
||||
```
|
||||
|
||||
## Backwards Compatibility
|
||||
|
||||
The original `agent.py` is now a facade that re-exports everything from the `agents` module:
|
||||
|
||||
```python
|
||||
# Old code still works
|
||||
from agent import run_autonomous_agent, save_session_memory
|
||||
|
||||
# New code can use modular imports
|
||||
from agents.coder import run_autonomous_agent
|
||||
from agents.memory import save_session_memory
|
||||
```
|
||||
|
||||
All existing imports continue to work without changes.
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Separation of Concerns**: Each module has a clear, focused responsibility
|
||||
2. **Maintainability**: Easier to understand and modify individual components
|
||||
3. **Testability**: Modules can be tested in isolation
|
||||
4. **Backwards Compatible**: No breaking changes to existing code
|
||||
5. **Scalability**: Easy to add new agent types or features
|
||||
|
||||
## Module Dependencies
|
||||
|
||||
```
|
||||
coder.py
|
||||
├── session.py (run_agent_session, post_session_processing)
|
||||
├── memory.py (get_graphiti_context, debug_memory_system_status)
|
||||
└── utils.py (git operations, plan management)
|
||||
|
||||
session.py
|
||||
├── memory.py (save_session_memory)
|
||||
└── utils.py (git operations, plan management)
|
||||
|
||||
planner.py
|
||||
└── session.py (run_agent_session)
|
||||
|
||||
memory.py
|
||||
└── base.py (constants, logging)
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Run the verification script to test the refactoring:
|
||||
|
||||
```bash
|
||||
python3 auto-claude/agents/test_refactoring.py
|
||||
```
|
||||
|
||||
This verifies:
|
||||
- Module structure is correct
|
||||
- All imports work
|
||||
- Public API is accessible
|
||||
- Backwards compatibility is maintained
|
||||
|
||||
## Migration Guide
|
||||
|
||||
No migration needed! The refactoring maintains 100% backwards compatibility.
|
||||
|
||||
### For new code:
|
||||
```python
|
||||
# Use focused imports for clarity
|
||||
from agents.coder import run_autonomous_agent
|
||||
from agents.memory import save_session_memory, get_graphiti_context
|
||||
from agents.session import run_agent_session
|
||||
```
|
||||
|
||||
### For existing code:
|
||||
```python
|
||||
# Old imports continue to work
|
||||
from agent import run_autonomous_agent, save_session_memory
|
||||
```
|
||||
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Agents Module
|
||||
=============
|
||||
|
||||
Modular agent system for autonomous coding.
|
||||
|
||||
This module provides:
|
||||
- run_autonomous_agent: Main coder agent loop
|
||||
- run_followup_planner: Follow-up planner for completed specs
|
||||
- Memory management (Graphiti + file-based fallback)
|
||||
- Session management and post-processing
|
||||
- Utility functions for git and plan management
|
||||
|
||||
Uses lazy imports to avoid circular dependencies.
|
||||
"""
|
||||
|
||||
# Explicit import required by CodeQL static analysis
|
||||
# (CodeQL doesn't recognize __getattr__ dynamic exports)
|
||||
from .utils import sync_spec_to_source
|
||||
|
||||
__all__ = [
|
||||
# Main API
|
||||
"run_autonomous_agent",
|
||||
"run_followup_planner",
|
||||
# Memory
|
||||
"debug_memory_system_status",
|
||||
"get_graphiti_context",
|
||||
"save_session_memory",
|
||||
"save_session_to_graphiti",
|
||||
# Session
|
||||
"run_agent_session",
|
||||
"post_session_processing",
|
||||
# Utils
|
||||
"get_latest_commit",
|
||||
"get_commit_count",
|
||||
"load_implementation_plan",
|
||||
"find_subtask_in_plan",
|
||||
"find_phase_for_subtask",
|
||||
"sync_spec_to_source",
|
||||
# Constants
|
||||
"AUTO_CONTINUE_DELAY_SECONDS",
|
||||
"HUMAN_INTERVENTION_FILE",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazy imports to avoid circular dependencies."""
|
||||
if name in ("AUTO_CONTINUE_DELAY_SECONDS", "HUMAN_INTERVENTION_FILE"):
|
||||
from .base import AUTO_CONTINUE_DELAY_SECONDS, HUMAN_INTERVENTION_FILE
|
||||
|
||||
return locals()[name]
|
||||
elif name == "run_autonomous_agent":
|
||||
from .coder import run_autonomous_agent
|
||||
|
||||
return run_autonomous_agent
|
||||
elif name in (
|
||||
"debug_memory_system_status",
|
||||
"get_graphiti_context",
|
||||
"save_session_memory",
|
||||
"save_session_to_graphiti",
|
||||
):
|
||||
from .memory_manager import (
|
||||
debug_memory_system_status,
|
||||
get_graphiti_context,
|
||||
save_session_memory,
|
||||
save_session_to_graphiti,
|
||||
)
|
||||
|
||||
return locals()[name]
|
||||
elif name == "run_followup_planner":
|
||||
from .planner import run_followup_planner
|
||||
|
||||
return run_followup_planner
|
||||
elif name in ("post_session_processing", "run_agent_session"):
|
||||
from .session import post_session_processing, run_agent_session
|
||||
|
||||
return locals()[name]
|
||||
elif name in (
|
||||
"find_phase_for_subtask",
|
||||
"find_subtask_in_plan",
|
||||
"get_commit_count",
|
||||
"get_latest_commit",
|
||||
"load_implementation_plan",
|
||||
"sync_spec_to_source",
|
||||
):
|
||||
from .utils import (
|
||||
find_phase_for_subtask,
|
||||
find_subtask_in_plan,
|
||||
get_commit_count,
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_spec_to_source,
|
||||
)
|
||||
|
||||
return locals()[name]
|
||||
raise AttributeError(f"module 'agents' has no attribute '{name}'")
|
||||
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
Base Module for Agent System
|
||||
=============================
|
||||
|
||||
Shared imports, types, and constants used across agent modules.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Configuration constants
|
||||
AUTO_CONTINUE_DELAY_SECONDS = 3
|
||||
HUMAN_INTERVENTION_FILE = "PAUSE"
|
||||
@@ -0,0 +1,522 @@
|
||||
"""
|
||||
Coder Agent Module
|
||||
==================
|
||||
|
||||
Main autonomous agent loop that runs the coder agent to implement subtasks.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from core.client import create_client
|
||||
from linear_updater import (
|
||||
LinearTaskState,
|
||||
is_linear_enabled,
|
||||
linear_build_complete,
|
||||
linear_task_started,
|
||||
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,
|
||||
get_current_phase,
|
||||
get_next_subtask,
|
||||
is_build_complete,
|
||||
print_build_complete_banner,
|
||||
print_progress_summary,
|
||||
print_session_header,
|
||||
)
|
||||
from prompt_generator import (
|
||||
format_context_for_prompt,
|
||||
generate_planner_prompt,
|
||||
generate_subtask_prompt,
|
||||
load_subtask_context,
|
||||
)
|
||||
from prompts import is_first_run
|
||||
from recovery import RecoveryManager
|
||||
from security.constants import PROJECT_DIR_ENV_VAR
|
||||
from task_logger import (
|
||||
LogPhase,
|
||||
get_task_logger,
|
||||
)
|
||||
from ui import (
|
||||
BuildState,
|
||||
Icons,
|
||||
StatusManager,
|
||||
bold,
|
||||
box,
|
||||
highlight,
|
||||
icon,
|
||||
muted,
|
||||
print_key_value,
|
||||
print_status,
|
||||
)
|
||||
|
||||
from .base import AUTO_CONTINUE_DELAY_SECONDS, HUMAN_INTERVENTION_FILE
|
||||
from .memory_manager import debug_memory_system_status, get_graphiti_context
|
||||
from .session import post_session_processing, run_agent_session
|
||||
from .utils import (
|
||||
find_phase_for_subtask,
|
||||
get_commit_count,
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_spec_to_source,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def run_autonomous_agent(
|
||||
project_dir: Path,
|
||||
spec_dir: Path,
|
||||
model: str,
|
||||
max_iterations: int | None = None,
|
||||
verbose: bool = False,
|
||||
source_spec_dir: Path | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Run the autonomous agent loop with automatic memory management.
|
||||
|
||||
The agent can use subagents (via Task tool) for parallel execution if needed.
|
||||
This is decided by the agent itself based on the task complexity.
|
||||
|
||||
Args:
|
||||
project_dir: Root directory for the project
|
||||
spec_dir: Directory containing the spec (auto-claude/specs/001-name/)
|
||||
model: Claude model to use
|
||||
max_iterations: Maximum number of iterations (None for unlimited)
|
||||
verbose: Whether to show detailed output
|
||||
source_spec_dir: Original spec directory in main project (for syncing from worktree)
|
||||
"""
|
||||
# Set environment variable for security hooks to find the correct project directory
|
||||
# This is needed because os.getcwd() may return the wrong directory in worktree mode
|
||||
os.environ[PROJECT_DIR_ENV_VAR] = str(project_dir.resolve())
|
||||
|
||||
# Initialize recovery manager (handles memory persistence)
|
||||
recovery_manager = RecoveryManager(spec_dir, project_dir)
|
||||
|
||||
# Initialize status manager for ccstatusline
|
||||
status_manager = StatusManager(project_dir)
|
||||
status_manager.set_active(spec_dir.name, BuildState.BUILDING)
|
||||
|
||||
# Initialize task logger for persistent logging
|
||||
task_logger = get_task_logger(spec_dir)
|
||||
|
||||
# Debug: Print memory system status at startup
|
||||
debug_memory_system_status()
|
||||
|
||||
# Update initial subtask counts
|
||||
subtasks = count_subtasks_detailed(spec_dir)
|
||||
status_manager.update_subtasks(
|
||||
completed=subtasks["completed"],
|
||||
total=subtasks["total"],
|
||||
in_progress=subtasks["in_progress"],
|
||||
)
|
||||
|
||||
# Check Linear integration status
|
||||
linear_task = None
|
||||
if is_linear_enabled():
|
||||
linear_task = LinearTaskState.load(spec_dir)
|
||||
if linear_task and linear_task.task_id:
|
||||
print_status("Linear integration: ENABLED", "success")
|
||||
print_key_value("Task", linear_task.task_id)
|
||||
print_key_value("Status", linear_task.status)
|
||||
print()
|
||||
else:
|
||||
print_status("Linear enabled but no task created for this spec", "warning")
|
||||
print()
|
||||
|
||||
# Check if this is a fresh start or continuation
|
||||
first_run = is_first_run(spec_dir)
|
||||
|
||||
# Track which phase we're in for logging
|
||||
current_log_phase = LogPhase.CODING
|
||||
is_planning_phase = False
|
||||
|
||||
if first_run:
|
||||
print_status(
|
||||
"Fresh start - will use Planner Agent to create implementation plan", "info"
|
||||
)
|
||||
content = [
|
||||
bold(f"{icon(Icons.GEAR)} PLANNER SESSION"),
|
||||
"",
|
||||
f"Spec: {highlight(spec_dir.name)}",
|
||||
muted("The agent will analyze your spec and create a subtask-based plan."),
|
||||
]
|
||||
print()
|
||||
print(box(content, width=70, style="heavy"))
|
||||
print()
|
||||
|
||||
# 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
|
||||
|
||||
# Start planning phase in task logger
|
||||
if task_logger:
|
||||
task_logger.start_phase(
|
||||
LogPhase.PLANNING, "Starting implementation planning..."
|
||||
)
|
||||
|
||||
# Update Linear to "In Progress" when build starts
|
||||
if linear_task and linear_task.task_id:
|
||||
print_status("Updating Linear task to In Progress...", "progress")
|
||||
await linear_task_started(spec_dir)
|
||||
else:
|
||||
print(f"Continuing build: {highlight(spec_dir.name)}")
|
||||
print_progress_summary(spec_dir)
|
||||
|
||||
# Check if already complete
|
||||
if is_build_complete(spec_dir):
|
||||
print_build_complete_banner(spec_dir)
|
||||
status_manager.update(state=BuildState.COMPLETE)
|
||||
return
|
||||
|
||||
# Start/continue coding phase in task logger
|
||||
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"),
|
||||
"",
|
||||
f"Press {highlight('Ctrl+C')} once {icon(Icons.ARROW_RIGHT)} Pause and optionally add instructions",
|
||||
f"Press {highlight('Ctrl+C')} twice {icon(Icons.ARROW_RIGHT)} Exit immediately",
|
||||
]
|
||||
print(box(content, width=70, style="light"))
|
||||
print()
|
||||
|
||||
# Main loop
|
||||
iteration = 0
|
||||
|
||||
while True:
|
||||
iteration += 1
|
||||
|
||||
# Check for human intervention (PAUSE file)
|
||||
pause_file = spec_dir / HUMAN_INTERVENTION_FILE
|
||||
if pause_file.exists():
|
||||
print("\n" + "=" * 70)
|
||||
print(" PAUSED BY HUMAN")
|
||||
print("=" * 70)
|
||||
|
||||
pause_content = pause_file.read_text().strip()
|
||||
if pause_content:
|
||||
print(f"\nMessage: {pause_content}")
|
||||
|
||||
print("\nTo resume, delete the PAUSE file:")
|
||||
print(f" rm {pause_file}")
|
||||
print("\nThen run again:")
|
||||
print(f" python auto-claude/run.py --spec {spec_dir.name}")
|
||||
return
|
||||
|
||||
# Check max iterations
|
||||
if max_iterations and iteration > max_iterations:
|
||||
print(f"\nReached max iterations ({max_iterations})")
|
||||
print("To continue, run the script again without --max-iterations")
|
||||
break
|
||||
|
||||
# Get the next subtask to work on
|
||||
next_subtask = get_next_subtask(spec_dir)
|
||||
subtask_id = next_subtask.get("id") if next_subtask else None
|
||||
phase_name = next_subtask.get("phase_name") if next_subtask else None
|
||||
|
||||
# Update status for this session
|
||||
status_manager.update_session(iteration)
|
||||
if phase_name:
|
||||
current_phase = get_current_phase(spec_dir)
|
||||
if current_phase:
|
||||
status_manager.update_phase(
|
||||
current_phase.get("name", ""),
|
||||
current_phase.get("phase", 0),
|
||||
current_phase.get("total", 0),
|
||||
)
|
||||
status_manager.update_subtasks(in_progress=1)
|
||||
|
||||
# Print session header
|
||||
print_session_header(
|
||||
session_num=iteration,
|
||||
is_planner=first_run,
|
||||
subtask_id=subtask_id,
|
||||
subtask_desc=next_subtask.get("description") if next_subtask else None,
|
||||
phase_name=phase_name,
|
||||
attempt=recovery_manager.get_attempt_count(subtask_id) + 1
|
||||
if subtask_id
|
||||
else 1,
|
||||
)
|
||||
|
||||
# Capture state before session for post-processing
|
||||
commit_before = get_latest_commit(project_dir)
|
||||
commit_count_before = get_commit_count(project_dir)
|
||||
|
||||
# Get the phase-specific model and thinking level (respects task_metadata.json configuration)
|
||||
# first_run means we're in planning phase, otherwise coding phase
|
||||
current_phase = "planning" if first_run else "coding"
|
||||
phase_model = get_phase_model(spec_dir, current_phase, model)
|
||||
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
|
||||
|
||||
# Set session info in logger
|
||||
if task_logger:
|
||||
task_logger.set_session(iteration)
|
||||
else:
|
||||
# Switch to coding phase after planning
|
||||
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,
|
||||
success=True,
|
||||
message="Implementation plan created",
|
||||
)
|
||||
task_logger.start_phase(
|
||||
LogPhase.CODING, "Starting implementation..."
|
||||
)
|
||||
|
||||
if not next_subtask:
|
||||
print("No pending subtasks found - build may be complete!")
|
||||
break
|
||||
|
||||
# Get attempt count for recovery context
|
||||
attempt_count = recovery_manager.get_attempt_count(subtask_id)
|
||||
recovery_hints = (
|
||||
recovery_manager.get_recovery_hints(subtask_id)
|
||||
if attempt_count > 0
|
||||
else None
|
||||
)
|
||||
|
||||
# Find the phase for this subtask
|
||||
plan = load_implementation_plan(spec_dir)
|
||||
phase = find_phase_for_subtask(plan, subtask_id) if plan else {}
|
||||
|
||||
# Generate focused, minimal prompt for this subtask
|
||||
prompt = generate_subtask_prompt(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask=next_subtask,
|
||||
phase=phase or {},
|
||||
attempt_count=attempt_count,
|
||||
recovery_hints=recovery_hints,
|
||||
)
|
||||
|
||||
# Load and append relevant file context
|
||||
context = load_subtask_context(spec_dir, project_dir, next_subtask)
|
||||
if context.get("patterns") or context.get("files_to_modify"):
|
||||
prompt += "\n\n" + format_context_for_prompt(context)
|
||||
|
||||
# Retrieve and append Graphiti memory context (if enabled)
|
||||
graphiti_context = await get_graphiti_context(
|
||||
spec_dir, project_dir, next_subtask
|
||||
)
|
||||
if graphiti_context:
|
||||
prompt += "\n\n" + graphiti_context
|
||||
print_status("Graphiti memory context loaded", "success")
|
||||
|
||||
# Show what we're working on
|
||||
print(f"Working on: {highlight(subtask_id)}")
|
||||
print(f"Description: {next_subtask.get('description', 'No description')}")
|
||||
if attempt_count > 0:
|
||||
print_status(f"Previous attempts: {attempt_count}", "warning")
|
||||
print()
|
||||
|
||||
# Set subtask info in logger
|
||||
if task_logger and subtask_id:
|
||||
task_logger.set_subtask(subtask_id)
|
||||
task_logger.set_session(iteration)
|
||||
|
||||
# Run session with async context manager
|
||||
async with client:
|
||||
status, response = await run_agent_session(
|
||||
client, prompt, spec_dir, verbose, phase=current_log_phase
|
||||
)
|
||||
|
||||
# === POST-SESSION PROCESSING (100% reliable) ===
|
||||
if subtask_id and not first_run:
|
||||
linear_is_enabled = (
|
||||
linear_task is not None and linear_task.task_id is not None
|
||||
)
|
||||
success = await post_session_processing(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id=subtask_id,
|
||||
session_num=iteration,
|
||||
commit_before=commit_before,
|
||||
commit_count_before=commit_count_before,
|
||||
recovery_manager=recovery_manager,
|
||||
linear_enabled=linear_is_enabled,
|
||||
status_manager=status_manager,
|
||||
source_spec_dir=source_spec_dir,
|
||||
)
|
||||
|
||||
# Check for stuck subtasks
|
||||
attempt_count = recovery_manager.get_attempt_count(subtask_id)
|
||||
if not success and attempt_count >= 3:
|
||||
recovery_manager.mark_subtask_stuck(
|
||||
subtask_id, f"Failed after {attempt_count} attempts"
|
||||
)
|
||||
print()
|
||||
print_status(
|
||||
f"Subtask {subtask_id} marked as STUCK after {attempt_count} attempts",
|
||||
"error",
|
||||
)
|
||||
print(muted("Consider: manual intervention or skipping this subtask"))
|
||||
|
||||
# Record stuck subtask in Linear (if enabled)
|
||||
if linear_is_enabled:
|
||||
await linear_task_stuck(
|
||||
spec_dir=spec_dir,
|
||||
subtask_id=subtask_id,
|
||||
attempt_count=attempt_count,
|
||||
)
|
||||
print_status("Linear notified of stuck subtask", "info")
|
||||
elif is_planning_phase and source_spec_dir:
|
||||
# After planning phase, sync the newly created implementation plan back to source
|
||||
if sync_spec_to_source(spec_dir, source_spec_dir):
|
||||
print_status("Implementation plan synced to main project", "success")
|
||||
|
||||
# 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)
|
||||
|
||||
if task_logger:
|
||||
task_logger.end_phase(
|
||||
LogPhase.CODING,
|
||||
success=True,
|
||||
message="All subtasks completed successfully",
|
||||
)
|
||||
|
||||
if linear_task and linear_task.task_id:
|
||||
await linear_build_complete(spec_dir)
|
||||
print_status("Linear notified: build complete, ready for QA", "success")
|
||||
|
||||
break
|
||||
|
||||
elif status == "continue":
|
||||
print(
|
||||
muted(
|
||||
f"\nAgent will auto-continue in {AUTO_CONTINUE_DELAY_SECONDS}s..."
|
||||
)
|
||||
)
|
||||
print_progress_summary(spec_dir)
|
||||
|
||||
# Update state back to building
|
||||
status_manager.update(state=BuildState.BUILDING)
|
||||
|
||||
# Show next subtask info
|
||||
next_subtask = get_next_subtask(spec_dir)
|
||||
if next_subtask:
|
||||
subtask_id = next_subtask.get("id")
|
||||
print(
|
||||
f"\nNext: {highlight(subtask_id)} - {next_subtask.get('description')}"
|
||||
)
|
||||
|
||||
attempt_count = recovery_manager.get_attempt_count(subtask_id)
|
||||
if attempt_count > 0:
|
||||
print_status(
|
||||
f"WARNING: {attempt_count} previous attempt(s)", "warning"
|
||||
)
|
||||
|
||||
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)
|
||||
await asyncio.sleep(AUTO_CONTINUE_DELAY_SECONDS)
|
||||
|
||||
# Small delay between sessions
|
||||
if max_iterations is None or iteration < max_iterations:
|
||||
print("\nPreparing next session...\n")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# Final summary
|
||||
content = [
|
||||
bold(f"{icon(Icons.SESSION)} SESSION SUMMARY"),
|
||||
"",
|
||||
f"Project: {project_dir}",
|
||||
f"Spec: {highlight(spec_dir.name)}",
|
||||
f"Sessions completed: {iteration}",
|
||||
]
|
||||
print()
|
||||
print(box(content, width=70, style="heavy"))
|
||||
print_progress_summary(spec_dir)
|
||||
|
||||
# Show stuck subtasks if any
|
||||
stuck_subtasks = recovery_manager.get_stuck_subtasks()
|
||||
if stuck_subtasks:
|
||||
print()
|
||||
print_status("STUCK SUBTASKS (need manual intervention):", "error")
|
||||
for stuck in stuck_subtasks:
|
||||
print(f" {icon(Icons.ERROR)} {stuck['subtask_id']}: {stuck['reason']}")
|
||||
|
||||
# Instructions
|
||||
completed, total = count_subtasks(spec_dir)
|
||||
if completed < total:
|
||||
content = [
|
||||
bold(f"{icon(Icons.PLAY)} NEXT STEPS"),
|
||||
"",
|
||||
f"{total - completed} subtasks remaining.",
|
||||
f"Run again: {highlight(f'python auto-claude/run.py --spec {spec_dir.name}')}",
|
||||
]
|
||||
else:
|
||||
content = [
|
||||
bold(f"{icon(Icons.SUCCESS)} NEXT STEPS"),
|
||||
"",
|
||||
"All subtasks completed!",
|
||||
" 1. Review the auto-claude/* branch",
|
||||
" 2. Run manual tests",
|
||||
" 3. Merge to main",
|
||||
]
|
||||
|
||||
print()
|
||||
print(box(content, width=70, style="light"))
|
||||
print()
|
||||
|
||||
# Set final status
|
||||
if completed == total:
|
||||
status_manager.update(state=BuildState.COMPLETE)
|
||||
else:
|
||||
status_manager.update(state=BuildState.PAUSED)
|
||||
@@ -0,0 +1,452 @@
|
||||
"""
|
||||
Memory Management for Agent System
|
||||
===================================
|
||||
|
||||
Handles session memory storage using dual-layer approach:
|
||||
- PRIMARY: Graphiti (when enabled) - semantic search, cross-session context
|
||||
- FALLBACK: File-based memory - zero dependencies, always available
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from debug import (
|
||||
debug,
|
||||
debug_detailed,
|
||||
debug_error,
|
||||
debug_section,
|
||||
debug_success,
|
||||
debug_warning,
|
||||
is_debug_enabled,
|
||||
)
|
||||
from graphiti_config import get_graphiti_status, is_graphiti_enabled
|
||||
|
||||
# Import from parent memory package
|
||||
# Now safe since this module is named memory_manager (not memory)
|
||||
from memory import save_session_insights as save_file_based_memory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def debug_memory_system_status() -> None:
|
||||
"""
|
||||
Print memory system status for debugging.
|
||||
|
||||
Called at startup when DEBUG=true to show memory configuration.
|
||||
"""
|
||||
if not is_debug_enabled():
|
||||
return
|
||||
|
||||
debug_section("memory", "Memory System Status")
|
||||
|
||||
# Get Graphiti status
|
||||
graphiti_status = get_graphiti_status()
|
||||
|
||||
debug(
|
||||
"memory",
|
||||
"Memory system configuration",
|
||||
primary_system="Graphiti"
|
||||
if graphiti_status.get("available")
|
||||
else "File-based (fallback)",
|
||||
graphiti_enabled=graphiti_status.get("enabled"),
|
||||
graphiti_available=graphiti_status.get("available"),
|
||||
)
|
||||
|
||||
if graphiti_status.get("enabled"):
|
||||
debug_detailed(
|
||||
"memory",
|
||||
"Graphiti configuration",
|
||||
host=graphiti_status.get("host"),
|
||||
port=graphiti_status.get("port"),
|
||||
database=graphiti_status.get("database"),
|
||||
llm_provider=graphiti_status.get("llm_provider"),
|
||||
embedder_provider=graphiti_status.get("embedder_provider"),
|
||||
)
|
||||
|
||||
if not graphiti_status.get("available"):
|
||||
debug_warning(
|
||||
"memory",
|
||||
"Graphiti not available",
|
||||
reason=graphiti_status.get("reason"),
|
||||
errors=graphiti_status.get("errors"),
|
||||
)
|
||||
debug("memory", "Will use file-based memory as fallback")
|
||||
else:
|
||||
debug_success("memory", "Graphiti ready as PRIMARY memory system")
|
||||
else:
|
||||
debug(
|
||||
"memory",
|
||||
"Graphiti disabled, using file-based memory only",
|
||||
note="Set GRAPHITI_ENABLED=true to enable Graphiti",
|
||||
)
|
||||
|
||||
|
||||
async def get_graphiti_context(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
subtask: dict,
|
||||
) -> str | None:
|
||||
"""
|
||||
Retrieve relevant context from Graphiti for the current subtask.
|
||||
|
||||
This searches the knowledge graph for context relevant to the subtask's
|
||||
task description, returning past insights, patterns, and gotchas.
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory
|
||||
project_dir: Project root directory
|
||||
subtask: The current subtask being worked on
|
||||
|
||||
Returns:
|
||||
Formatted context string or None if unavailable
|
||||
"""
|
||||
if is_debug_enabled():
|
||||
debug(
|
||||
"memory",
|
||||
"Retrieving Graphiti context for subtask",
|
||||
subtask_id=subtask.get("id", "unknown"),
|
||||
subtask_desc=subtask.get("description", "")[:100],
|
||||
)
|
||||
|
||||
if not is_graphiti_enabled():
|
||||
if is_debug_enabled():
|
||||
debug("memory", "Graphiti not enabled, skipping context retrieval")
|
||||
return None
|
||||
|
||||
try:
|
||||
from graphiti_memory import GraphitiMemory
|
||||
|
||||
# Create memory manager
|
||||
memory = GraphitiMemory(spec_dir, project_dir)
|
||||
|
||||
if not memory.is_enabled:
|
||||
if is_debug_enabled():
|
||||
debug_warning("memory", "GraphitiMemory.is_enabled=False")
|
||||
return None
|
||||
|
||||
# Build search query from subtask description
|
||||
subtask_desc = subtask.get("description", "")
|
||||
subtask_id = subtask.get("id", "")
|
||||
query = f"{subtask_desc} {subtask_id}".strip()
|
||||
|
||||
if not query:
|
||||
await memory.close()
|
||||
if is_debug_enabled():
|
||||
debug_warning("memory", "Empty query, skipping context retrieval")
|
||||
return None
|
||||
|
||||
if is_debug_enabled():
|
||||
debug_detailed(
|
||||
"memory",
|
||||
"Searching Graphiti knowledge graph",
|
||||
query=query[:200],
|
||||
num_results=5,
|
||||
)
|
||||
|
||||
# Get relevant context
|
||||
context_items = await memory.get_relevant_context(query, num_results=5)
|
||||
|
||||
# Get patterns and gotchas specifically (THE FIX for learning loop!)
|
||||
# This retrieves PATTERN and GOTCHA episode types for cross-session learning
|
||||
patterns, gotchas = await memory.get_patterns_and_gotchas(
|
||||
query, num_results=3, min_score=0.5
|
||||
)
|
||||
|
||||
# Also get recent session history
|
||||
session_history = await memory.get_session_history(limit=3)
|
||||
|
||||
await memory.close()
|
||||
|
||||
if is_debug_enabled():
|
||||
debug(
|
||||
"memory",
|
||||
"Graphiti context retrieval complete",
|
||||
context_items_found=len(context_items) if context_items else 0,
|
||||
patterns_found=len(patterns) if patterns else 0,
|
||||
gotchas_found=len(gotchas) if gotchas else 0,
|
||||
session_history_found=len(session_history) if session_history else 0,
|
||||
)
|
||||
|
||||
if not context_items and not session_history and not patterns and not gotchas:
|
||||
if is_debug_enabled():
|
||||
debug("memory", "No relevant context found in Graphiti")
|
||||
return None
|
||||
|
||||
# Format the context
|
||||
sections = ["## Graphiti Memory Context\n"]
|
||||
sections.append("_Retrieved from knowledge graph for this subtask:_\n")
|
||||
|
||||
if context_items:
|
||||
sections.append("### Relevant Knowledge\n")
|
||||
for item in context_items:
|
||||
content = item.get("content", "")[:500] # Truncate
|
||||
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
|
||||
session_num = session.get("session_number", "?")
|
||||
recommendations = session.get("recommendations_for_next_session", [])
|
||||
if recommendations:
|
||||
sections.append(f"**Session {session_num} recommendations:**")
|
||||
for rec in recommendations[:3]: # Limit to 3
|
||||
sections.append(f"- {rec}")
|
||||
sections.append("")
|
||||
|
||||
if is_debug_enabled():
|
||||
debug_success(
|
||||
"memory", "Graphiti context formatted", total_sections=len(sections)
|
||||
)
|
||||
|
||||
return "\n".join(sections)
|
||||
|
||||
except ImportError:
|
||||
logger.debug("Graphiti packages not installed")
|
||||
if is_debug_enabled():
|
||||
debug_warning("memory", "Graphiti packages not installed")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get Graphiti context: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def save_session_memory(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
subtask_id: str,
|
||||
session_num: int,
|
||||
success: bool,
|
||||
subtasks_completed: list[str],
|
||||
discoveries: dict | None = None,
|
||||
) -> tuple[bool, str]:
|
||||
"""
|
||||
Save session insights to memory.
|
||||
|
||||
Memory Strategy:
|
||||
- PRIMARY: Graphiti (when enabled) - provides semantic search, cross-session context
|
||||
- FALLBACK: File-based (when Graphiti is disabled) - zero dependencies, always works
|
||||
|
||||
This is called after each session to persist learnings.
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory
|
||||
project_dir: Project root directory
|
||||
subtask_id: The subtask that was worked on
|
||||
session_num: Current session number
|
||||
success: Whether the subtask was completed successfully
|
||||
subtasks_completed: List of subtask IDs completed this session
|
||||
discoveries: Optional dict with file discoveries, patterns, gotchas
|
||||
|
||||
Returns:
|
||||
Tuple of (success, storage_type) where storage_type is "graphiti" or "file"
|
||||
"""
|
||||
# Debug: Log memory save start
|
||||
if is_debug_enabled():
|
||||
debug_section("memory", f"Saving Session {session_num} Memory")
|
||||
debug(
|
||||
"memory",
|
||||
"Memory save initiated",
|
||||
subtask_id=subtask_id,
|
||||
session_num=session_num,
|
||||
success=success,
|
||||
subtasks_completed=subtasks_completed,
|
||||
spec_dir=str(spec_dir),
|
||||
)
|
||||
|
||||
# Build insights structure (same format for both storage systems)
|
||||
insights = {
|
||||
"subtasks_completed": subtasks_completed,
|
||||
"discoveries": discoveries
|
||||
or {
|
||||
"files_understood": {},
|
||||
"patterns_found": [],
|
||||
"gotchas_encountered": [],
|
||||
},
|
||||
"what_worked": [f"Implemented subtask: {subtask_id}"] if success else [],
|
||||
"what_failed": [] if success else [f"Failed to complete subtask: {subtask_id}"],
|
||||
"recommendations_for_next_session": [],
|
||||
}
|
||||
|
||||
if is_debug_enabled():
|
||||
debug_detailed("memory", "Insights structure built", insights=insights)
|
||||
|
||||
# Check Graphiti status for debugging
|
||||
graphiti_enabled = is_graphiti_enabled()
|
||||
if is_debug_enabled():
|
||||
graphiti_status = get_graphiti_status()
|
||||
debug(
|
||||
"memory",
|
||||
"Graphiti status check",
|
||||
enabled=graphiti_status.get("enabled"),
|
||||
available=graphiti_status.get("available"),
|
||||
host=graphiti_status.get("host"),
|
||||
port=graphiti_status.get("port"),
|
||||
database=graphiti_status.get("database"),
|
||||
llm_provider=graphiti_status.get("llm_provider"),
|
||||
embedder_provider=graphiti_status.get("embedder_provider"),
|
||||
reason=graphiti_status.get("reason") or "OK",
|
||||
)
|
||||
|
||||
# PRIMARY: Try Graphiti if enabled
|
||||
if graphiti_enabled:
|
||||
if is_debug_enabled():
|
||||
debug("memory", "Attempting PRIMARY storage: Graphiti")
|
||||
|
||||
try:
|
||||
from graphiti_memory import GraphitiMemory
|
||||
|
||||
memory = GraphitiMemory(spec_dir, project_dir)
|
||||
|
||||
if is_debug_enabled():
|
||||
debug_detailed(
|
||||
"memory",
|
||||
"GraphitiMemory instance created",
|
||||
is_enabled=memory.is_enabled,
|
||||
group_id=getattr(memory, "group_id", "unknown"),
|
||||
)
|
||||
|
||||
if memory.is_enabled:
|
||||
if is_debug_enabled():
|
||||
debug("memory", "Saving to Graphiti...")
|
||||
|
||||
# Use structured insights if we have rich extracted data
|
||||
if discoveries and discoveries.get("file_insights"):
|
||||
# Rich insights from insight_extractor
|
||||
if is_debug_enabled():
|
||||
debug(
|
||||
"memory",
|
||||
"Using save_structured_insights (rich data available)",
|
||||
)
|
||||
result = await memory.save_structured_insights(discoveries)
|
||||
else:
|
||||
# Fallback to basic session insights
|
||||
result = await memory.save_session_insights(session_num, insights)
|
||||
|
||||
await memory.close()
|
||||
|
||||
if result:
|
||||
logger.info(
|
||||
f"Session {session_num} insights saved to Graphiti (primary)"
|
||||
)
|
||||
if is_debug_enabled():
|
||||
debug_success(
|
||||
"memory",
|
||||
f"Session {session_num} saved to Graphiti (PRIMARY)",
|
||||
storage_type="graphiti",
|
||||
subtasks_saved=len(subtasks_completed),
|
||||
)
|
||||
return True, "graphiti"
|
||||
else:
|
||||
logger.warning(
|
||||
"Graphiti save returned False, falling back to file-based"
|
||||
)
|
||||
if is_debug_enabled():
|
||||
debug_warning(
|
||||
"memory", "Graphiti save returned False, using FALLBACK"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Graphiti memory not enabled, falling back to file-based"
|
||||
)
|
||||
if is_debug_enabled():
|
||||
debug_warning(
|
||||
"memory", "GraphitiMemory.is_enabled=False, using FALLBACK"
|
||||
)
|
||||
|
||||
except ImportError as e:
|
||||
logger.debug("Graphiti packages not installed, falling back to file-based")
|
||||
if is_debug_enabled():
|
||||
debug_warning("memory", "Graphiti packages not installed", error=str(e))
|
||||
except Exception as e:
|
||||
logger.warning(f"Graphiti save failed: {e}, falling back to file-based")
|
||||
if is_debug_enabled():
|
||||
debug_error("memory", "Graphiti save failed", error=str(e))
|
||||
else:
|
||||
if is_debug_enabled():
|
||||
debug("memory", "Graphiti not enabled, skipping to FALLBACK")
|
||||
|
||||
# FALLBACK: File-based memory (when Graphiti is disabled or fails)
|
||||
if is_debug_enabled():
|
||||
debug("memory", "Attempting FALLBACK storage: File-based")
|
||||
|
||||
try:
|
||||
memory_dir = spec_dir / "memory" / "session_insights"
|
||||
if is_debug_enabled():
|
||||
debug_detailed(
|
||||
"memory",
|
||||
"File-based memory path",
|
||||
memory_dir=str(memory_dir),
|
||||
session_file=f"session_{session_num:03d}.json",
|
||||
)
|
||||
|
||||
save_file_based_memory(spec_dir, session_num, insights)
|
||||
logger.info(
|
||||
f"Session {session_num} insights saved to file-based memory (fallback)"
|
||||
)
|
||||
|
||||
if is_debug_enabled():
|
||||
debug_success(
|
||||
"memory",
|
||||
f"Session {session_num} saved to file-based (FALLBACK)",
|
||||
storage_type="file",
|
||||
file_path=str(memory_dir / f"session_{session_num:03d}.json"),
|
||||
subtasks_saved=len(subtasks_completed),
|
||||
)
|
||||
return True, "file"
|
||||
except Exception as e:
|
||||
logger.error(f"File-based memory save also failed: {e}")
|
||||
if is_debug_enabled():
|
||||
debug_error("memory", "File-based memory save FAILED", error=str(e))
|
||||
return False, "none"
|
||||
|
||||
|
||||
# Keep the old function name as an alias for backwards compatibility
|
||||
async def save_session_to_graphiti(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
subtask_id: str,
|
||||
session_num: int,
|
||||
success: bool,
|
||||
subtasks_completed: list[str],
|
||||
discoveries: dict | None = None,
|
||||
) -> bool:
|
||||
"""Backwards compatibility wrapper for save_session_memory."""
|
||||
result, _ = await save_session_memory(
|
||||
spec_dir,
|
||||
project_dir,
|
||||
subtask_id,
|
||||
session_num,
|
||||
success,
|
||||
subtasks_completed,
|
||||
discoveries,
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Planner Agent Module
|
||||
====================
|
||||
|
||||
Handles follow-up planner sessions for adding new subtasks to completed specs.
|
||||
"""
|
||||
|
||||
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 task_logger import (
|
||||
LogPhase,
|
||||
get_task_logger,
|
||||
)
|
||||
from ui import (
|
||||
BuildState,
|
||||
Icons,
|
||||
StatusManager,
|
||||
bold,
|
||||
box,
|
||||
highlight,
|
||||
icon,
|
||||
muted,
|
||||
print_status,
|
||||
)
|
||||
|
||||
from .session import run_agent_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def run_followup_planner(
|
||||
project_dir: Path,
|
||||
spec_dir: Path,
|
||||
model: str,
|
||||
verbose: bool = False,
|
||||
) -> bool:
|
||||
"""
|
||||
Run the follow-up planner to add new subtasks to a completed spec.
|
||||
|
||||
This is a simplified version of run_autonomous_agent that:
|
||||
1. Creates a client
|
||||
2. Loads the followup planner prompt
|
||||
3. Runs a single planning session
|
||||
4. Returns after the plan is updated (doesn't enter coding loop)
|
||||
|
||||
The planner agent will:
|
||||
- Read FOLLOWUP_REQUEST.md for the new task
|
||||
- Read the existing implementation_plan.json
|
||||
- Add new phase(s) with pending subtasks
|
||||
- Update the plan status back to in_progress
|
||||
|
||||
Args:
|
||||
project_dir: Root directory for the project
|
||||
spec_dir: Directory containing the completed spec
|
||||
model: Claude model to use
|
||||
verbose: Whether to show detailed output
|
||||
|
||||
Returns:
|
||||
bool: True if planning completed successfully
|
||||
"""
|
||||
from implementation_plan import ImplementationPlan
|
||||
from prompts import get_followup_planner_prompt
|
||||
|
||||
# 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)
|
||||
|
||||
# Show header
|
||||
content = [
|
||||
bold(f"{icon(Icons.GEAR)} FOLLOW-UP PLANNER SESSION"),
|
||||
"",
|
||||
f"Spec: {highlight(spec_dir.name)}",
|
||||
muted("Adding follow-up work to completed spec."),
|
||||
"",
|
||||
muted("The agent will read your FOLLOWUP_REQUEST.md and add new subtasks."),
|
||||
]
|
||||
print()
|
||||
print(box(content, width=70, style="heavy"))
|
||||
print()
|
||||
|
||||
# Start planning phase in task logger
|
||||
if task_logger:
|
||||
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)
|
||||
planning_thinking_budget = get_phase_thinking_budget(spec_dir, "planning")
|
||||
client = create_client(
|
||||
project_dir,
|
||||
spec_dir,
|
||||
planning_model,
|
||||
max_thinking_tokens=planning_thinking_budget,
|
||||
)
|
||||
|
||||
# Generate follow-up planner prompt
|
||||
prompt = get_followup_planner_prompt(spec_dir)
|
||||
|
||||
print_status("Running follow-up planner...", "progress")
|
||||
print()
|
||||
|
||||
try:
|
||||
# Run single planning session
|
||||
async with client:
|
||||
status, response = await run_agent_session(
|
||||
client, prompt, spec_dir, verbose, phase=LogPhase.PLANNING
|
||||
)
|
||||
|
||||
# End planning phase in task logger
|
||||
if task_logger:
|
||||
task_logger.end_phase(
|
||||
LogPhase.PLANNING,
|
||||
success=(status != "error"),
|
||||
message="Follow-up planning session completed",
|
||||
)
|
||||
|
||||
if status == "error":
|
||||
print()
|
||||
print_status("Follow-up planning failed", "error")
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
return False
|
||||
|
||||
# Verify the plan was updated (should have pending subtasks now)
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if plan_file.exists():
|
||||
plan = ImplementationPlan.load(plan_file)
|
||||
|
||||
# Check if there are any pending subtasks
|
||||
all_subtasks = [c for p in plan.phases for c in p.subtasks]
|
||||
pending_subtasks = [c for c in all_subtasks if c.status.value == "pending"]
|
||||
|
||||
if pending_subtasks:
|
||||
# Reset the plan status to in_progress (in case planner didn't)
|
||||
plan.reset_for_followup()
|
||||
plan.save(plan_file)
|
||||
|
||||
print()
|
||||
content = [
|
||||
bold(f"{icon(Icons.SUCCESS)} FOLLOW-UP PLANNING COMPLETE"),
|
||||
"",
|
||||
f"New pending subtasks: {highlight(str(len(pending_subtasks)))}",
|
||||
f"Total subtasks: {len(all_subtasks)}",
|
||||
"",
|
||||
muted("Next steps:"),
|
||||
f" Run: {highlight(f'python auto-claude/run.py --spec {spec_dir.name}')}",
|
||||
]
|
||||
print(box(content, width=70, style="heavy"))
|
||||
print()
|
||||
status_manager.update(state=BuildState.PAUSED)
|
||||
return True
|
||||
else:
|
||||
print()
|
||||
print_status(
|
||||
"Warning: No pending subtasks found after planning", "warning"
|
||||
)
|
||||
print(muted("The planner may not have added new subtasks."))
|
||||
print(muted("Check implementation_plan.json manually."))
|
||||
status_manager.update(state=BuildState.PAUSED)
|
||||
return False
|
||||
else:
|
||||
print()
|
||||
print_status(
|
||||
"Error: implementation_plan.json not found after planning", "error"
|
||||
)
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print()
|
||||
print_status(f"Follow-up planning error: {e}", "error")
|
||||
if task_logger:
|
||||
task_logger.log_error(f"Follow-up planning error: {e}", LogPhase.PLANNING)
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
return False
|
||||
@@ -0,0 +1,554 @@
|
||||
"""
|
||||
Agent Session Management
|
||||
========================
|
||||
|
||||
Handles running agent sessions and post-session processing including
|
||||
memory updates, recovery tracking, and Linear integration.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from claude_agent_sdk import ClaudeSDKClient
|
||||
from debug import debug, debug_detailed, debug_error, debug_section, debug_success
|
||||
from insight_extractor import extract_session_insights
|
||||
from linear_updater import (
|
||||
linear_subtask_completed,
|
||||
linear_subtask_failed,
|
||||
)
|
||||
from progress import (
|
||||
count_subtasks_detailed,
|
||||
is_build_complete,
|
||||
)
|
||||
from recovery import RecoveryManager
|
||||
from security.tool_input_validator import get_safe_tool_input
|
||||
from task_logger import (
|
||||
LogEntryType,
|
||||
LogPhase,
|
||||
get_task_logger,
|
||||
)
|
||||
from ui import (
|
||||
StatusManager,
|
||||
muted,
|
||||
print_key_value,
|
||||
print_status,
|
||||
)
|
||||
|
||||
from .memory_manager import save_session_memory
|
||||
from .utils import (
|
||||
find_subtask_in_plan,
|
||||
get_commit_count,
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_spec_to_source,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def post_session_processing(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
subtask_id: str,
|
||||
session_num: int,
|
||||
commit_before: str | None,
|
||||
commit_count_before: int,
|
||||
recovery_manager: RecoveryManager,
|
||||
linear_enabled: bool = False,
|
||||
status_manager: StatusManager | None = None,
|
||||
source_spec_dir: Path | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Process session results and update memory automatically.
|
||||
|
||||
This runs in Python (100% reliable) instead of relying on agent compliance.
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory containing memory/
|
||||
project_dir: Project root for git operations
|
||||
subtask_id: The subtask that was being worked on
|
||||
session_num: Current session number
|
||||
commit_before: Git commit hash before session
|
||||
commit_count_before: Number of commits before session
|
||||
recovery_manager: Recovery manager instance
|
||||
linear_enabled: Whether Linear integration is enabled
|
||||
status_manager: Optional status manager for ccstatusline
|
||||
source_spec_dir: Original spec directory (for syncing back from worktree)
|
||||
|
||||
Returns:
|
||||
True if subtask was completed successfully
|
||||
"""
|
||||
print()
|
||||
print(muted("--- Post-Session Processing ---"))
|
||||
|
||||
# Sync implementation plan back to source (for worktree mode)
|
||||
if sync_spec_to_source(spec_dir, source_spec_dir):
|
||||
print_status("Implementation plan synced to main project", "success")
|
||||
|
||||
# Check if implementation plan was updated
|
||||
plan = load_implementation_plan(spec_dir)
|
||||
if not plan:
|
||||
print(" Warning: Could not load implementation plan")
|
||||
return False
|
||||
|
||||
subtask = find_subtask_in_plan(plan, subtask_id)
|
||||
if not subtask:
|
||||
print(f" Warning: Subtask {subtask_id} not found in plan")
|
||||
return False
|
||||
|
||||
subtask_status = subtask.get("status", "pending")
|
||||
|
||||
# Check for new commits
|
||||
commit_after = get_latest_commit(project_dir)
|
||||
commit_count_after = get_commit_count(project_dir)
|
||||
new_commits = commit_count_after - commit_count_before
|
||||
|
||||
print_key_value("Subtask status", subtask_status)
|
||||
print_key_value("New commits", str(new_commits))
|
||||
|
||||
if subtask_status == "completed":
|
||||
# Success! Record the attempt and good commit
|
||||
print_status(f"Subtask {subtask_id} completed successfully", "success")
|
||||
|
||||
# Update status file
|
||||
if status_manager:
|
||||
subtasks = count_subtasks_detailed(spec_dir)
|
||||
status_manager.update_subtasks(
|
||||
completed=subtasks["completed"],
|
||||
total=subtasks["total"],
|
||||
in_progress=0,
|
||||
)
|
||||
|
||||
# Record successful attempt
|
||||
recovery_manager.record_attempt(
|
||||
subtask_id=subtask_id,
|
||||
session=session_num,
|
||||
success=True,
|
||||
approach=f"Implemented: {subtask.get('description', 'subtask')[:100]}",
|
||||
)
|
||||
|
||||
# Record good commit for rollback safety
|
||||
if commit_after and commit_after != commit_before:
|
||||
recovery_manager.record_good_commit(commit_after, subtask_id)
|
||||
print_status(f"Recorded good commit: {commit_after[:8]}", "success")
|
||||
|
||||
# Record Linear session result (if enabled)
|
||||
if linear_enabled:
|
||||
# Get progress counts for the comment
|
||||
subtasks_detail = count_subtasks_detailed(spec_dir)
|
||||
await linear_subtask_completed(
|
||||
spec_dir=spec_dir,
|
||||
subtask_id=subtask_id,
|
||||
completed_count=subtasks_detail["completed"],
|
||||
total_count=subtasks_detail["total"],
|
||||
)
|
||||
print_status("Linear progress recorded", "success")
|
||||
|
||||
# Extract rich insights from session (LLM-powered analysis)
|
||||
try:
|
||||
extracted_insights = await extract_session_insights(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id=subtask_id,
|
||||
session_num=session_num,
|
||||
commit_before=commit_before,
|
||||
commit_after=commit_after,
|
||||
success=True,
|
||||
recovery_manager=recovery_manager,
|
||||
)
|
||||
insight_count = len(extracted_insights.get("file_insights", []))
|
||||
pattern_count = len(extracted_insights.get("patterns_discovered", []))
|
||||
if insight_count > 0 or pattern_count > 0:
|
||||
print_status(
|
||||
f"Extracted {insight_count} file insights, {pattern_count} patterns",
|
||||
"success",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Insight extraction failed: {e}")
|
||||
extracted_insights = None
|
||||
|
||||
# Save session memory (Graphiti=primary, file-based=fallback)
|
||||
try:
|
||||
save_success, storage_type = await save_session_memory(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id=subtask_id,
|
||||
session_num=session_num,
|
||||
success=True,
|
||||
subtasks_completed=[subtask_id],
|
||||
discoveries=extracted_insights,
|
||||
)
|
||||
if save_success:
|
||||
if storage_type == "graphiti":
|
||||
print_status("Session saved to Graphiti memory", "success")
|
||||
else:
|
||||
print_status(
|
||||
"Session saved to file-based memory (fallback)", "info"
|
||||
)
|
||||
else:
|
||||
print_status("Failed to save session memory", "warning")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error saving session memory: {e}")
|
||||
print_status("Memory save failed", "warning")
|
||||
|
||||
return True
|
||||
|
||||
elif subtask_status == "in_progress":
|
||||
# Session ended without completion
|
||||
print_status(f"Subtask {subtask_id} still in progress", "warning")
|
||||
|
||||
recovery_manager.record_attempt(
|
||||
subtask_id=subtask_id,
|
||||
session=session_num,
|
||||
success=False,
|
||||
approach="Session ended with subtask in_progress",
|
||||
error="Subtask not marked as completed",
|
||||
)
|
||||
|
||||
# Still record commit if one was made (partial progress)
|
||||
if commit_after and commit_after != commit_before:
|
||||
recovery_manager.record_good_commit(commit_after, subtask_id)
|
||||
print_status(
|
||||
f"Recorded partial progress commit: {commit_after[:8]}", "info"
|
||||
)
|
||||
|
||||
# Record Linear session result (if enabled)
|
||||
if linear_enabled:
|
||||
attempt_count = recovery_manager.get_attempt_count(subtask_id)
|
||||
await linear_subtask_failed(
|
||||
spec_dir=spec_dir,
|
||||
subtask_id=subtask_id,
|
||||
attempt=attempt_count,
|
||||
error_summary="Session ended without completion",
|
||||
)
|
||||
|
||||
# Extract insights even from failed sessions (valuable for future attempts)
|
||||
try:
|
||||
extracted_insights = await extract_session_insights(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id=subtask_id,
|
||||
session_num=session_num,
|
||||
commit_before=commit_before,
|
||||
commit_after=commit_after,
|
||||
success=False,
|
||||
recovery_manager=recovery_manager,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Insight extraction failed for incomplete session: {e}")
|
||||
extracted_insights = None
|
||||
|
||||
# Save failed session memory (to track what didn't work)
|
||||
try:
|
||||
await save_session_memory(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id=subtask_id,
|
||||
session_num=session_num,
|
||||
success=False,
|
||||
subtasks_completed=[],
|
||||
discoveries=extracted_insights,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to save incomplete session memory: {e}")
|
||||
|
||||
return False
|
||||
|
||||
else:
|
||||
# Subtask still pending or failed
|
||||
print_status(
|
||||
f"Subtask {subtask_id} not completed (status: {subtask_status})", "error"
|
||||
)
|
||||
|
||||
recovery_manager.record_attempt(
|
||||
subtask_id=subtask_id,
|
||||
session=session_num,
|
||||
success=False,
|
||||
approach="Session ended without progress",
|
||||
error=f"Subtask status is {subtask_status}",
|
||||
)
|
||||
|
||||
# Record Linear session result (if enabled)
|
||||
if linear_enabled:
|
||||
attempt_count = recovery_manager.get_attempt_count(subtask_id)
|
||||
await linear_subtask_failed(
|
||||
spec_dir=spec_dir,
|
||||
subtask_id=subtask_id,
|
||||
attempt=attempt_count,
|
||||
error_summary=f"Subtask status: {subtask_status}",
|
||||
)
|
||||
|
||||
# Extract insights even from completely failed sessions
|
||||
try:
|
||||
extracted_insights = await extract_session_insights(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id=subtask_id,
|
||||
session_num=session_num,
|
||||
commit_before=commit_before,
|
||||
commit_after=commit_after,
|
||||
success=False,
|
||||
recovery_manager=recovery_manager,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Insight extraction failed for failed session: {e}")
|
||||
extracted_insights = None
|
||||
|
||||
# Save failed session memory (to track what didn't work)
|
||||
try:
|
||||
await save_session_memory(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id=subtask_id,
|
||||
session_num=session_num,
|
||||
success=False,
|
||||
subtasks_completed=[],
|
||||
discoveries=extracted_insights,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to save failed session memory: {e}")
|
||||
|
||||
return False
|
||||
|
||||
|
||||
async def run_agent_session(
|
||||
client: ClaudeSDKClient,
|
||||
message: str,
|
||||
spec_dir: Path,
|
||||
verbose: bool = False,
|
||||
phase: LogPhase = LogPhase.CODING,
|
||||
) -> tuple[str, str]:
|
||||
"""
|
||||
Run a single agent session using Claude Agent SDK.
|
||||
|
||||
Args:
|
||||
client: Claude SDK client
|
||||
message: The prompt to send
|
||||
spec_dir: Spec directory path
|
||||
verbose: Whether to show detailed output
|
||||
phase: Current execution phase for logging
|
||||
|
||||
Returns:
|
||||
(status, response_text) where status is:
|
||||
- "continue" if agent should continue working
|
||||
- "complete" if all subtasks complete
|
||||
- "error" if an error occurred
|
||||
"""
|
||||
debug_section("session", f"Agent Session - {phase.value}")
|
||||
debug(
|
||||
"session",
|
||||
"Starting agent session",
|
||||
spec_dir=str(spec_dir),
|
||||
phase=phase.value,
|
||||
prompt_length=len(message),
|
||||
prompt_preview=message[:200] + "..." if len(message) > 200 else message,
|
||||
)
|
||||
print("Sending prompt to Claude Agent SDK...\n")
|
||||
|
||||
# Get task logger for this spec
|
||||
task_logger = get_task_logger(spec_dir)
|
||||
current_tool = None
|
||||
message_count = 0
|
||||
tool_count = 0
|
||||
|
||||
try:
|
||||
# Send the query
|
||||
debug("session", "Sending query to Claude SDK...")
|
||||
await client.query(message)
|
||||
debug_success("session", "Query sent successfully")
|
||||
|
||||
# Collect response text and show tool use
|
||||
response_text = ""
|
||||
debug("session", "Starting to receive response stream...")
|
||||
async for msg in client.receive_response():
|
||||
msg_type = type(msg).__name__
|
||||
message_count += 1
|
||||
debug_detailed(
|
||||
"session",
|
||||
f"Received message #{message_count}",
|
||||
msg_type=msg_type,
|
||||
)
|
||||
|
||||
# Handle AssistantMessage (text and tool use)
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
block_type = type(block).__name__
|
||||
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
print(block.text, end="", flush=True)
|
||||
# Log text to task logger (persist without double-printing)
|
||||
if task_logger and block.text.strip():
|
||||
task_logger.log(
|
||||
block.text,
|
||||
LogEntryType.TEXT,
|
||||
phase,
|
||||
print_to_console=False,
|
||||
)
|
||||
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
||||
tool_name = block.name
|
||||
tool_input_display = 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"]
|
||||
|
||||
debug(
|
||||
"session",
|
||||
f"Tool call #{tool_count}: {tool_name}",
|
||||
tool_input=tool_input_display,
|
||||
full_input=str(inp)[:500] if inp 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,
|
||||
)
|
||||
else:
|
||||
print(f"\n[Tool: {tool_name}]", flush=True)
|
||||
|
||||
if verbose and hasattr(block, "input"):
|
||||
input_str = str(block.input)
|
||||
if len(input_str) > 300:
|
||||
print(f" Input: {input_str[:300]}...", flush=True)
|
||||
else:
|
||||
print(f" Input: {input_str}", flush=True)
|
||||
current_tool = tool_name
|
||||
|
||||
# Handle UserMessage (tool results)
|
||||
elif msg_type == "UserMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
block_type = type(block).__name__
|
||||
|
||||
if block_type == "ToolResultBlock":
|
||||
result_content = getattr(block, "content", "")
|
||||
is_error = getattr(block, "is_error", False)
|
||||
|
||||
# Check if this is an error (not just content containing "blocked")
|
||||
if is_error and "blocked" in str(result_content).lower():
|
||||
# Actual blocked command by security hook
|
||||
debug_error(
|
||||
"session",
|
||||
f"Tool BLOCKED: {current_tool}",
|
||||
result=str(result_content)[:300],
|
||||
)
|
||||
print(f" [BLOCKED] {result_content}", flush=True)
|
||||
if task_logger and current_tool:
|
||||
task_logger.tool_end(
|
||||
current_tool,
|
||||
success=False,
|
||||
result="BLOCKED",
|
||||
detail=str(result_content),
|
||||
phase=phase,
|
||||
)
|
||||
elif is_error:
|
||||
# Show errors (truncated)
|
||||
error_str = str(result_content)[:500]
|
||||
debug_error(
|
||||
"session",
|
||||
f"Tool error: {current_tool}",
|
||||
error=error_str[:200],
|
||||
)
|
||||
print(f" [Error] {error_str}", flush=True)
|
||||
if task_logger and current_tool:
|
||||
# Store full error in detail for expandable view
|
||||
task_logger.tool_end(
|
||||
current_tool,
|
||||
success=False,
|
||||
result=error_str[:100],
|
||||
detail=str(result_content),
|
||||
phase=phase,
|
||||
)
|
||||
else:
|
||||
# Tool succeeded
|
||||
debug_detailed(
|
||||
"session",
|
||||
f"Tool success: {current_tool}",
|
||||
result_length=len(str(result_content)),
|
||||
)
|
||||
if verbose:
|
||||
result_str = str(result_content)[:200]
|
||||
print(f" [Done] {result_str}", flush=True)
|
||||
else:
|
||||
print(" [Done]", flush=True)
|
||||
if task_logger and current_tool:
|
||||
# Store full result in detail for expandable view (only for certain tools)
|
||||
# Skip storing for very large outputs like Glob results
|
||||
detail_content = None
|
||||
if current_tool in (
|
||||
"Read",
|
||||
"Grep",
|
||||
"Bash",
|
||||
"Edit",
|
||||
"Write",
|
||||
):
|
||||
result_str = str(result_content)
|
||||
# Only store if not too large (detail truncation happens in logger)
|
||||
if (
|
||||
len(result_str) < 50000
|
||||
): # 50KB max before truncation
|
||||
detail_content = result_str
|
||||
task_logger.tool_end(
|
||||
current_tool,
|
||||
success=True,
|
||||
detail=detail_content,
|
||||
phase=phase,
|
||||
)
|
||||
|
||||
current_tool = None
|
||||
|
||||
print("\n" + "-" * 70 + "\n")
|
||||
|
||||
# Check if build is complete
|
||||
if is_build_complete(spec_dir):
|
||||
debug_success(
|
||||
"session",
|
||||
"Session completed - build is complete",
|
||||
message_count=message_count,
|
||||
tool_count=tool_count,
|
||||
response_length=len(response_text),
|
||||
)
|
||||
return "complete", response_text
|
||||
|
||||
debug_success(
|
||||
"session",
|
||||
"Session completed - continuing",
|
||||
message_count=message_count,
|
||||
tool_count=tool_count,
|
||||
response_length=len(response_text),
|
||||
)
|
||||
return "continue", response_text
|
||||
|
||||
except Exception as e:
|
||||
debug_error(
|
||||
"session",
|
||||
f"Session error: {e}",
|
||||
exception_type=type(e).__name__,
|
||||
message_count=message_count,
|
||||
tool_count=tool_count,
|
||||
)
|
||||
print(f"Error during agent session: {e}")
|
||||
if task_logger:
|
||||
task_logger.log_error(f"Session error: {e}", phase)
|
||||
return "error", str(e)
|
||||
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Verification script for agent module refactoring.
|
||||
|
||||
This script verifies that:
|
||||
1. All modules can be imported
|
||||
2. All public API functions are accessible
|
||||
3. Backwards compatibility is maintained
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent directory to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
|
||||
def test_imports():
|
||||
"""Test that all modules can be imported."""
|
||||
print("Testing module imports...")
|
||||
|
||||
# Test base module
|
||||
from agents import base
|
||||
|
||||
assert hasattr(base, "AUTO_CONTINUE_DELAY_SECONDS")
|
||||
assert hasattr(base, "HUMAN_INTERVENTION_FILE")
|
||||
print(" ✓ agents.base")
|
||||
|
||||
# Test utils module
|
||||
from agents import utils
|
||||
|
||||
assert hasattr(utils, "get_latest_commit")
|
||||
assert hasattr(utils, "load_implementation_plan")
|
||||
print(" ✓ agents.utils")
|
||||
|
||||
# Test memory module
|
||||
from agents import memory
|
||||
|
||||
assert hasattr(memory, "save_session_memory")
|
||||
assert hasattr(memory, "get_graphiti_context")
|
||||
print(" ✓ agents.memory")
|
||||
|
||||
# Test session module
|
||||
from agents import session
|
||||
|
||||
assert hasattr(session, "run_agent_session")
|
||||
assert hasattr(session, "post_session_processing")
|
||||
print(" ✓ agents.session")
|
||||
|
||||
# Test planner module
|
||||
from agents import planner
|
||||
|
||||
assert hasattr(planner, "run_followup_planner")
|
||||
print(" ✓ agents.planner")
|
||||
|
||||
# Test coder module
|
||||
from agents import coder
|
||||
|
||||
assert hasattr(coder, "run_autonomous_agent")
|
||||
print(" ✓ agents.coder")
|
||||
|
||||
print("\n✓ All module imports successful!\n")
|
||||
|
||||
|
||||
def test_public_api():
|
||||
"""Test that the public API is accessible."""
|
||||
print("Testing public API...")
|
||||
|
||||
# Test main agent module exports
|
||||
import agents
|
||||
|
||||
required_functions = [
|
||||
"run_autonomous_agent",
|
||||
"run_followup_planner",
|
||||
"save_session_memory",
|
||||
"get_graphiti_context",
|
||||
"run_agent_session",
|
||||
"post_session_processing",
|
||||
"get_latest_commit",
|
||||
"load_implementation_plan",
|
||||
]
|
||||
|
||||
for func_name in required_functions:
|
||||
assert hasattr(agents, func_name), f"Missing function: {func_name}"
|
||||
print(f" ✓ agents.{func_name}")
|
||||
|
||||
print("\n✓ All public API functions accessible!\n")
|
||||
|
||||
|
||||
def test_backwards_compatibility():
|
||||
"""Test that the old agent.py facade maintains backwards compatibility."""
|
||||
print("Testing backwards compatibility...")
|
||||
|
||||
# Test that agent.py can be imported
|
||||
import agent
|
||||
|
||||
required_functions = [
|
||||
"run_autonomous_agent",
|
||||
"run_followup_planner",
|
||||
"save_session_memory",
|
||||
"save_session_to_graphiti",
|
||||
"run_agent_session",
|
||||
"post_session_processing",
|
||||
]
|
||||
|
||||
for func_name in required_functions:
|
||||
assert hasattr(agent, func_name), (
|
||||
f"Missing function in agent module: {func_name}"
|
||||
)
|
||||
print(f" ✓ agent.{func_name}")
|
||||
|
||||
print("\n✓ Backwards compatibility maintained!\n")
|
||||
|
||||
|
||||
def test_module_structure():
|
||||
"""Test that the module structure is correct."""
|
||||
print("Testing module structure...")
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
agents_dir = Path(__file__).parent
|
||||
|
||||
required_files = [
|
||||
"__init__.py",
|
||||
"base.py",
|
||||
"utils.py",
|
||||
"memory.py",
|
||||
"session.py",
|
||||
"planner.py",
|
||||
"coder.py",
|
||||
]
|
||||
|
||||
for filename in required_files:
|
||||
filepath = agents_dir / filename
|
||||
assert filepath.exists(), f"Missing file: {filename}"
|
||||
print(f" ✓ agents/{filename}")
|
||||
|
||||
print("\n✓ Module structure correct!\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
test_module_structure()
|
||||
test_imports()
|
||||
test_public_api()
|
||||
test_backwards_compatibility()
|
||||
|
||||
print("=" * 60)
|
||||
print("✓ ALL TESTS PASSED - Refactoring verified!")
|
||||
print("=" * 60)
|
||||
|
||||
except AssertionError as e:
|
||||
print(f"\n✗ TEST FAILED: {e}")
|
||||
sys.exit(1)
|
||||
except ImportError as e:
|
||||
print(f"\n✗ IMPORT ERROR: {e}")
|
||||
print("Note: Some imports may fail due to missing dependencies.")
|
||||
print("This is expected in test environments.")
|
||||
sys.exit(0) # Don't fail on import errors (expected in test env)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Custom MCP Tools for Auto-Claude Agents
|
||||
========================================
|
||||
|
||||
This module provides custom MCP tools that agents can use for reliable
|
||||
operations on auto-claude data structures. These tools replace prompt-based
|
||||
JSON manipulation with guaranteed-correct operations.
|
||||
|
||||
Benefits:
|
||||
- 100% reliable JSON operations (no malformed output)
|
||||
- Reduced context usage (tool definitions << prompt instructions)
|
||||
- Type-safe with proper error handling
|
||||
- Each agent only sees tools relevant to their role via allowed_tools
|
||||
|
||||
Usage:
|
||||
from auto_claude_tools import create_auto_claude_mcp_server, get_allowed_tools
|
||||
|
||||
# Create the MCP server
|
||||
mcp_server = create_auto_claude_mcp_server(spec_dir, project_dir)
|
||||
|
||||
# Get allowed tools for a specific agent type
|
||||
allowed_tools = get_allowed_tools("coder")
|
||||
|
||||
# Use in ClaudeAgentOptions
|
||||
options = ClaudeAgentOptions(
|
||||
mcp_servers={"auto-claude": mcp_server},
|
||||
allowed_tools=allowed_tools,
|
||||
...
|
||||
)
|
||||
"""
|
||||
|
||||
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 .registry import create_auto_claude_mcp_server, is_tools_available
|
||||
|
||||
__all__ = [
|
||||
# Main API
|
||||
"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_UPDATE_SUBTASK_STATUS",
|
||||
"TOOL_GET_BUILD_PROGRESS",
|
||||
"TOOL_RECORD_DISCOVERY",
|
||||
"TOOL_RECORD_GOTCHA",
|
||||
"TOOL_GET_SESSION_CONTEXT",
|
||||
"TOOL_UPDATE_QA_STATUS",
|
||||
# Config
|
||||
"is_electron_mcp_enabled",
|
||||
]
|
||||
@@ -0,0 +1,510 @@
|
||||
"""
|
||||
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)
|
||||
# =============================================================================
|
||||
|
||||
# Auto-Claude MCP tool names (prefixed with mcp__auto-claude__)
|
||||
TOOL_UPDATE_SUBTASK_STATUS = "mcp__auto-claude__update_subtask_status"
|
||||
TOOL_GET_BUILD_PROGRESS = "mcp__auto-claude__get_build_progress"
|
||||
TOOL_RECORD_DISCOVERY = "mcp__auto-claude__record_discovery"
|
||||
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",
|
||||
"mcp__puppeteer__puppeteer_screenshot",
|
||||
"mcp__puppeteer__puppeteer_click",
|
||||
"mcp__puppeteer__puppeteer_fill",
|
||||
"mcp__puppeteer__puppeteer_select",
|
||||
"mcp__puppeteer__puppeteer_hover",
|
||||
"mcp__puppeteer__puppeteer_evaluate",
|
||||
]
|
||||
|
||||
# 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.
|
||||
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
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Configuration
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def is_electron_mcp_enabled() -> bool:
|
||||
"""
|
||||
Check if Electron MCP server integration is enabled.
|
||||
|
||||
Requires ELECTRON_MCP_ENABLED to be set to 'true'.
|
||||
When enabled, QA agents can use Electron MCP tools to connect to Electron apps
|
||||
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")
|
||||
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
Agent Tool Permissions
|
||||
======================
|
||||
|
||||
Manages which tools are allowed for each agent type to prevent context
|
||||
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,
|
||||
ELECTRON_TOOLS,
|
||||
GRAPHITI_MCP_TOOLS,
|
||||
LINEAR_TOOLS,
|
||||
PUPPETEER_TOOLS,
|
||||
get_agent_config,
|
||||
get_required_mcp_servers,
|
||||
)
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Args:
|
||||
agent_type: Agent type identifier (e.g., 'coder', 'planner', 'qa_reviewer')
|
||||
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)
|
||||
|
||||
# Start with base tools from config
|
||||
tools = list(config.get("tools", []))
|
||||
|
||||
# Get required MCP servers for this agent
|
||||
required_servers = get_required_mcp_servers(
|
||||
agent_type,
|
||||
project_capabilities,
|
||||
linear_enabled,
|
||||
mcp_config,
|
||||
)
|
||||
|
||||
# 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))
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
def _get_mcp_tools_for_servers(servers: list[str]) -> list[str]:
|
||||
"""
|
||||
Get the list of MCP tools for a list of required servers.
|
||||
|
||||
Maps server names to their corresponding tool lists.
|
||||
|
||||
Args:
|
||||
servers: List of MCP server names (e.g., ['context7', 'linear', 'electron'])
|
||||
|
||||
Returns:
|
||||
List of MCP tool names for all specified servers
|
||||
"""
|
||||
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":
|
||||
tools.extend(ELECTRON_TOOLS)
|
||||
elif server == "puppeteer":
|
||||
tools.extend(PUPPETEER_TOOLS)
|
||||
# auto-claude tools are already added via config["auto_claude_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())
|
||||
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
Tool Registry
|
||||
=============
|
||||
|
||||
Central registry for creating and managing auto-claude MCP tools.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from claude_agent_sdk import create_sdk_mcp_server
|
||||
|
||||
SDK_TOOLS_AVAILABLE = True
|
||||
except ImportError:
|
||||
SDK_TOOLS_AVAILABLE = False
|
||||
create_sdk_mcp_server = None
|
||||
|
||||
from .tools import (
|
||||
create_memory_tools,
|
||||
create_progress_tools,
|
||||
create_qa_tools,
|
||||
create_subtask_tools,
|
||||
)
|
||||
|
||||
|
||||
def create_all_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
"""
|
||||
Create all custom tools with the given spec and project directories.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
project_dir: Path to the project root
|
||||
|
||||
Returns:
|
||||
List of all tool functions
|
||||
"""
|
||||
if not SDK_TOOLS_AVAILABLE:
|
||||
return []
|
||||
|
||||
all_tools = []
|
||||
|
||||
# Create tools by category
|
||||
all_tools.extend(create_subtask_tools(spec_dir, project_dir))
|
||||
all_tools.extend(create_progress_tools(spec_dir, project_dir))
|
||||
all_tools.extend(create_memory_tools(spec_dir, project_dir))
|
||||
all_tools.extend(create_qa_tools(spec_dir, project_dir))
|
||||
|
||||
return all_tools
|
||||
|
||||
|
||||
def create_auto_claude_mcp_server(spec_dir: Path, project_dir: Path):
|
||||
"""
|
||||
Create an MCP server with auto-claude custom tools.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
project_dir: Path to the project root
|
||||
|
||||
Returns:
|
||||
MCP server instance, or None if SDK tools not available
|
||||
"""
|
||||
if not SDK_TOOLS_AVAILABLE:
|
||||
return None
|
||||
|
||||
tools = create_all_tools(spec_dir, project_dir)
|
||||
|
||||
return create_sdk_mcp_server(name="auto-claude", version="1.0.0", tools=tools)
|
||||
|
||||
|
||||
def is_tools_available() -> bool:
|
||||
"""Check if SDK tools functionality is available."""
|
||||
return SDK_TOOLS_AVAILABLE
|
||||
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Auto-Claude MCP Tools
|
||||
=====================
|
||||
|
||||
Individual tool implementations organized by functionality.
|
||||
"""
|
||||
|
||||
from .memory import create_memory_tools
|
||||
from .progress import create_progress_tools
|
||||
from .qa import create_qa_tools
|
||||
from .subtask import create_subtask_tools
|
||||
|
||||
__all__ = [
|
||||
"create_subtask_tools",
|
||||
"create_progress_tools",
|
||||
"create_memory_tools",
|
||||
"create_qa_tools",
|
||||
]
|
||||
@@ -0,0 +1,354 @@
|
||||
"""
|
||||
Session Memory Tools
|
||||
====================
|
||||
|
||||
Tools for recording and retrieving session memory, including discoveries,
|
||||
gotchas, and patterns.
|
||||
|
||||
Dual-storage approach:
|
||||
- File-based: Always available, works offline, spec-specific
|
||||
- LadybugDB: When Graphiti is enabled, also saves to graph database for
|
||||
cross-session retrieval and Memory UI display
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from claude_agent_sdk import tool
|
||||
|
||||
SDK_TOOLS_AVAILABLE = True
|
||||
except ImportError:
|
||||
SDK_TOOLS_AVAILABLE = False
|
||||
tool = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _save_to_graphiti_async(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
save_type: str,
|
||||
data: dict,
|
||||
) -> bool:
|
||||
"""
|
||||
Save data to Graphiti/LadybugDB (async implementation).
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory for GraphitiMemory initialization
|
||||
project_dir: Project root directory
|
||||
save_type: Type of save - 'discovery', 'gotcha', or 'pattern'
|
||||
data: Data to save
|
||||
|
||||
Returns:
|
||||
True if save succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Check if Graphiti is enabled
|
||||
from graphiti_config import is_graphiti_enabled
|
||||
|
||||
if not is_graphiti_enabled():
|
||||
return False
|
||||
|
||||
from integrations.graphiti.queries_pkg.graphiti import GraphitiMemory
|
||||
|
||||
memory = GraphitiMemory(spec_dir, project_dir)
|
||||
try:
|
||||
if save_type == "discovery":
|
||||
# Save as codebase discovery
|
||||
# Format: {file_path: description}
|
||||
result = await memory.save_codebase_discoveries(
|
||||
{data["file_path"]: data["description"]}
|
||||
)
|
||||
elif save_type == "gotcha":
|
||||
# Save as gotcha
|
||||
gotcha_text = data["gotcha"]
|
||||
if data.get("context"):
|
||||
gotcha_text += f" (Context: {data['context']})"
|
||||
result = await memory.save_gotcha(gotcha_text)
|
||||
elif save_type == "pattern":
|
||||
# Save as pattern
|
||||
result = await memory.save_pattern(data["pattern"])
|
||||
else:
|
||||
result = False
|
||||
return result
|
||||
finally:
|
||||
await memory.close()
|
||||
|
||||
except ImportError as e:
|
||||
logger.debug(f"Graphiti not available for memory tools: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save to Graphiti: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _save_to_graphiti_sync(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
save_type: str,
|
||||
data: dict,
|
||||
) -> bool:
|
||||
"""
|
||||
Save data to Graphiti/LadybugDB (synchronous wrapper for sync contexts only).
|
||||
|
||||
NOTE: This should only be called from synchronous code. For async callers,
|
||||
use _save_to_graphiti_async() directly to ensure proper resource cleanup.
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory for GraphitiMemory initialization
|
||||
project_dir: Project root directory
|
||||
save_type: Type of save - 'discovery', 'gotcha', or 'pattern'
|
||||
data: Data to save
|
||||
|
||||
Returns:
|
||||
True if save succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Check if we're already in an async context
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
# We're in an async context - caller should use _save_to_graphiti_async
|
||||
# Log a warning and return False to avoid the resource leak bug
|
||||
logger.warning(
|
||||
"_save_to_graphiti_sync called from async context. "
|
||||
"Use _save_to_graphiti_async instead for proper cleanup."
|
||||
)
|
||||
return False
|
||||
except RuntimeError:
|
||||
# No running loop - safe to create one
|
||||
return asyncio.run(
|
||||
_save_to_graphiti_async(spec_dir, project_dir, save_type, data)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save to Graphiti: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
"""
|
||||
Create session memory tools.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
project_dir: Path to the project root
|
||||
|
||||
Returns:
|
||||
List of memory tool functions
|
||||
"""
|
||||
if not SDK_TOOLS_AVAILABLE:
|
||||
return []
|
||||
|
||||
tools = []
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Tool: record_discovery
|
||||
# -------------------------------------------------------------------------
|
||||
@tool(
|
||||
"record_discovery",
|
||||
"Record a codebase discovery to session memory. Use this when you learn something important about the codebase.",
|
||||
{"file_path": str, "description": str, "category": str},
|
||||
)
|
||||
async def record_discovery(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Record a discovery to the codebase map (file + Graphiti)."""
|
||||
file_path = args["file_path"]
|
||||
description = args["description"]
|
||||
category = args.get("category", "general")
|
||||
|
||||
memory_dir = spec_dir / "memory"
|
||||
memory_dir.mkdir(exist_ok=True)
|
||||
|
||||
codebase_map_file = memory_dir / "codebase_map.json"
|
||||
saved_to_graphiti = False
|
||||
|
||||
try:
|
||||
# PRIMARY: Save to file-based storage (always works)
|
||||
# Load existing map or create new
|
||||
if codebase_map_file.exists():
|
||||
with open(codebase_map_file) as f:
|
||||
codebase_map = json.load(f)
|
||||
else:
|
||||
codebase_map = {
|
||||
"discovered_files": {},
|
||||
"last_updated": None,
|
||||
}
|
||||
|
||||
# Add or update the discovery
|
||||
codebase_map["discovered_files"][file_path] = {
|
||||
"description": description,
|
||||
"category": category,
|
||||
"discovered_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
codebase_map["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
with open(codebase_map_file, "w") as f:
|
||||
json.dump(codebase_map, f, indent=2)
|
||||
|
||||
# SECONDARY: Also save to Graphiti/LadybugDB (for Memory UI)
|
||||
saved_to_graphiti = await _save_to_graphiti_async(
|
||||
spec_dir,
|
||||
project_dir,
|
||||
"discovery",
|
||||
{
|
||||
"file_path": file_path,
|
||||
"description": f"[{category}] {description}",
|
||||
},
|
||||
)
|
||||
|
||||
storage_note = " (also saved to memory graph)" if saved_to_graphiti else ""
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Recorded discovery for '{file_path}': {description}{storage_note}",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"content": [{"type": "text", "text": f"Error recording discovery: {e}"}]
|
||||
}
|
||||
|
||||
tools.append(record_discovery)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Tool: record_gotcha
|
||||
# -------------------------------------------------------------------------
|
||||
@tool(
|
||||
"record_gotcha",
|
||||
"Record a gotcha or pitfall to avoid. Use this when you encounter something that future sessions should know.",
|
||||
{"gotcha": str, "context": str},
|
||||
)
|
||||
async def record_gotcha(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Record a gotcha to session memory (file + Graphiti)."""
|
||||
gotcha = args["gotcha"]
|
||||
context = args.get("context", "")
|
||||
|
||||
memory_dir = spec_dir / "memory"
|
||||
memory_dir.mkdir(exist_ok=True)
|
||||
|
||||
gotchas_file = memory_dir / "gotchas.md"
|
||||
saved_to_graphiti = False
|
||||
|
||||
try:
|
||||
# PRIMARY: Save to file-based storage (always works)
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
entry = f"\n## [{timestamp}]\n{gotcha}"
|
||||
if context:
|
||||
entry += f"\n\n_Context: {context}_"
|
||||
entry += "\n"
|
||||
|
||||
with open(gotchas_file, "a") as f:
|
||||
if not gotchas_file.exists() or gotchas_file.stat().st_size == 0:
|
||||
f.write(
|
||||
"# Gotchas & Pitfalls\n\nThings to watch out for in this codebase.\n"
|
||||
)
|
||||
f.write(entry)
|
||||
|
||||
# SECONDARY: Also save to Graphiti/LadybugDB (for Memory UI)
|
||||
saved_to_graphiti = await _save_to_graphiti_async(
|
||||
spec_dir,
|
||||
project_dir,
|
||||
"gotcha",
|
||||
{"gotcha": gotcha, "context": context},
|
||||
)
|
||||
|
||||
storage_note = " (also saved to memory graph)" if saved_to_graphiti else ""
|
||||
return {
|
||||
"content": [
|
||||
{"type": "text", "text": f"Recorded gotcha: {gotcha}{storage_note}"}
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"content": [{"type": "text", "text": f"Error recording gotcha: {e}"}]
|
||||
}
|
||||
|
||||
tools.append(record_gotcha)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Tool: get_session_context
|
||||
# -------------------------------------------------------------------------
|
||||
@tool(
|
||||
"get_session_context",
|
||||
"Get context from previous sessions including discoveries, gotchas, and patterns.",
|
||||
{},
|
||||
)
|
||||
async def get_session_context(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Get accumulated session context."""
|
||||
memory_dir = spec_dir / "memory"
|
||||
|
||||
if not memory_dir.exists():
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "No session memory found. This appears to be the first session.",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
result_parts = []
|
||||
|
||||
# Load codebase map
|
||||
codebase_map_file = memory_dir / "codebase_map.json"
|
||||
if codebase_map_file.exists():
|
||||
try:
|
||||
with open(codebase_map_file) as f:
|
||||
codebase_map = json.load(f)
|
||||
|
||||
discoveries = codebase_map.get("discovered_files", {})
|
||||
if discoveries:
|
||||
result_parts.append("## Codebase Discoveries")
|
||||
for path, info in list(discoveries.items())[:20]: # Limit to 20
|
||||
desc = info.get("description", "No description")
|
||||
result_parts.append(f"- `{path}`: {desc}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Load gotchas
|
||||
gotchas_file = memory_dir / "gotchas.md"
|
||||
if gotchas_file.exists():
|
||||
try:
|
||||
content = gotchas_file.read_text()
|
||||
if content.strip():
|
||||
result_parts.append("\n## Gotchas")
|
||||
# Take last 1000 chars to avoid too much context
|
||||
result_parts.append(
|
||||
content[-1000:] if len(content) > 1000 else content
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Load patterns
|
||||
patterns_file = memory_dir / "patterns.md"
|
||||
if patterns_file.exists():
|
||||
try:
|
||||
content = patterns_file.read_text()
|
||||
if content.strip():
|
||||
result_parts.append("\n## Patterns")
|
||||
result_parts.append(
|
||||
content[-1000:] if len(content) > 1000 else content
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not result_parts:
|
||||
return {
|
||||
"content": [
|
||||
{"type": "text", "text": "No session context available yet."}
|
||||
]
|
||||
}
|
||||
|
||||
return {"content": [{"type": "text", "text": "\n".join(result_parts)}]}
|
||||
|
||||
tools.append(get_session_context)
|
||||
|
||||
return tools
|
||||
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Build Progress Tools
|
||||
====================
|
||||
|
||||
Tools for tracking and reporting build progress.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from claude_agent_sdk import tool
|
||||
|
||||
SDK_TOOLS_AVAILABLE = True
|
||||
except ImportError:
|
||||
SDK_TOOLS_AVAILABLE = False
|
||||
tool = None
|
||||
|
||||
|
||||
def create_progress_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
"""
|
||||
Create build progress tracking tools.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
project_dir: Path to the project root
|
||||
|
||||
Returns:
|
||||
List of progress tool functions
|
||||
"""
|
||||
if not SDK_TOOLS_AVAILABLE:
|
||||
return []
|
||||
|
||||
tools = []
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Tool: get_build_progress
|
||||
# -------------------------------------------------------------------------
|
||||
@tool(
|
||||
"get_build_progress",
|
||||
"Get the current build progress including completed subtasks, pending subtasks, and next subtask to work on.",
|
||||
{},
|
||||
)
|
||||
async def get_build_progress(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Get current build progress."""
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
|
||||
if not plan_file.exists():
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "No implementation plan found. Run the planner first.",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
try:
|
||||
with open(plan_file) as f:
|
||||
plan = json.load(f)
|
||||
|
||||
stats = {
|
||||
"total": 0,
|
||||
"completed": 0,
|
||||
"in_progress": 0,
|
||||
"pending": 0,
|
||||
"failed": 0,
|
||||
}
|
||||
|
||||
phases_summary = []
|
||||
next_subtask = None
|
||||
|
||||
for phase in plan.get("phases", []):
|
||||
phase_id = phase.get("id") or phase.get("phase")
|
||||
phase_name = phase.get("name", phase_id)
|
||||
phase_subtasks = phase.get("subtasks", [])
|
||||
|
||||
phase_stats = {"completed": 0, "total": len(phase_subtasks)}
|
||||
|
||||
for subtask in phase_subtasks:
|
||||
stats["total"] += 1
|
||||
status = subtask.get("status", "pending")
|
||||
|
||||
if status == "completed":
|
||||
stats["completed"] += 1
|
||||
phase_stats["completed"] += 1
|
||||
elif status == "in_progress":
|
||||
stats["in_progress"] += 1
|
||||
elif status == "failed":
|
||||
stats["failed"] += 1
|
||||
else:
|
||||
stats["pending"] += 1
|
||||
# Track next subtask to work on
|
||||
if next_subtask is None:
|
||||
next_subtask = {
|
||||
"id": subtask.get("id"),
|
||||
"description": subtask.get("description"),
|
||||
"phase": phase_name,
|
||||
}
|
||||
|
||||
phases_summary.append(
|
||||
f" {phase_name}: {phase_stats['completed']}/{phase_stats['total']}"
|
||||
)
|
||||
|
||||
progress_pct = (
|
||||
(stats["completed"] / stats["total"] * 100) if stats["total"] > 0 else 0
|
||||
)
|
||||
|
||||
result = f"""Build Progress: {stats["completed"]}/{stats["total"]} subtasks ({progress_pct:.0f}%)
|
||||
|
||||
Status breakdown:
|
||||
Completed: {stats["completed"]}
|
||||
In Progress: {stats["in_progress"]}
|
||||
Pending: {stats["pending"]}
|
||||
Failed: {stats["failed"]}
|
||||
|
||||
Phases:
|
||||
{chr(10).join(phases_summary)}"""
|
||||
|
||||
if next_subtask:
|
||||
result += f"""
|
||||
|
||||
Next subtask to work on:
|
||||
ID: {next_subtask["id"]}
|
||||
Phase: {next_subtask["phase"]}
|
||||
Description: {next_subtask["description"]}"""
|
||||
elif stats["completed"] == stats["total"]:
|
||||
result += "\n\nAll subtasks completed! Build is ready for QA."
|
||||
|
||||
return {"content": [{"type": "text", "text": result}]}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"content": [
|
||||
{"type": "text", "text": f"Error reading build progress: {e}"}
|
||||
]
|
||||
}
|
||||
|
||||
tools.append(get_build_progress)
|
||||
|
||||
return tools
|
||||
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
QA Management Tools
|
||||
===================
|
||||
|
||||
Tools for managing QA status and sign-off in implementation_plan.json.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from claude_agent_sdk import tool
|
||||
|
||||
SDK_TOOLS_AVAILABLE = True
|
||||
except ImportError:
|
||||
SDK_TOOLS_AVAILABLE = False
|
||||
tool = None
|
||||
|
||||
|
||||
def create_qa_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
"""
|
||||
Create QA management tools.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
project_dir: Path to the project root
|
||||
|
||||
Returns:
|
||||
List of QA tool functions
|
||||
"""
|
||||
if not SDK_TOOLS_AVAILABLE:
|
||||
return []
|
||||
|
||||
tools = []
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Tool: update_qa_status
|
||||
# -------------------------------------------------------------------------
|
||||
@tool(
|
||||
"update_qa_status",
|
||||
"Update the QA sign-off status in implementation_plan.json. Use after QA review.",
|
||||
{"status": str, "issues": str, "tests_passed": str},
|
||||
)
|
||||
async def update_qa_status(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Update QA status in the implementation plan."""
|
||||
status = args["status"]
|
||||
issues_str = args.get("issues", "[]")
|
||||
tests_str = args.get("tests_passed", "{}")
|
||||
|
||||
valid_statuses = [
|
||||
"pending",
|
||||
"in_review",
|
||||
"approved",
|
||||
"rejected",
|
||||
"fixes_applied",
|
||||
]
|
||||
if status not in valid_statuses:
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Error: Invalid QA status '{status}'. Must be one of: {valid_statuses}",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if not plan_file.exists():
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Error: implementation_plan.json not found",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
try:
|
||||
# Parse issues and tests
|
||||
try:
|
||||
issues = json.loads(issues_str) if issues_str else []
|
||||
except json.JSONDecodeError:
|
||||
issues = [{"description": issues_str}] if issues_str else []
|
||||
|
||||
try:
|
||||
tests_passed = json.loads(tests_str) if tests_str else {}
|
||||
except json.JSONDecodeError:
|
||||
tests_passed = {}
|
||||
|
||||
with open(plan_file) as f:
|
||||
plan = json.load(f)
|
||||
|
||||
# Get current QA session number
|
||||
current_qa = plan.get("qa_signoff", {})
|
||||
qa_session = current_qa.get("qa_session", 0)
|
||||
if status in ["in_review", "rejected"]:
|
||||
qa_session += 1
|
||||
|
||||
plan["qa_signoff"] = {
|
||||
"status": status,
|
||||
"qa_session": qa_session,
|
||||
"issues_found": issues,
|
||||
"tests_passed": tests_passed,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"ready_for_qa_revalidation": status == "fixes_applied",
|
||||
}
|
||||
|
||||
# Update plan status to match QA result
|
||||
# This ensures the UI shows the correct column after QA
|
||||
if status == "approved":
|
||||
plan["status"] = "human_review"
|
||||
plan["planStatus"] = "review"
|
||||
elif status == "rejected":
|
||||
plan["status"] = "human_review"
|
||||
plan["planStatus"] = "review"
|
||||
|
||||
plan["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
with open(plan_file, "w") as f:
|
||||
json.dump(plan, f, indent=2)
|
||||
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Updated QA status to '{status}' (session {qa_session})",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"content": [{"type": "text", "text": f"Error updating QA status: {e}"}]
|
||||
}
|
||||
|
||||
tools.append(update_qa_status)
|
||||
|
||||
return tools
|
||||
@@ -0,0 +1,135 @@
|
||||
"""
|
||||
Subtask Management Tools
|
||||
========================
|
||||
|
||||
Tools for managing subtask status in implementation_plan.json.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from claude_agent_sdk import tool
|
||||
|
||||
SDK_TOOLS_AVAILABLE = True
|
||||
except ImportError:
|
||||
SDK_TOOLS_AVAILABLE = False
|
||||
tool = None
|
||||
|
||||
|
||||
def create_subtask_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
"""
|
||||
Create subtask management tools.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
project_dir: Path to the project root
|
||||
|
||||
Returns:
|
||||
List of subtask tool functions
|
||||
"""
|
||||
if not SDK_TOOLS_AVAILABLE:
|
||||
return []
|
||||
|
||||
tools = []
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Tool: update_subtask_status
|
||||
# -------------------------------------------------------------------------
|
||||
@tool(
|
||||
"update_subtask_status",
|
||||
"Update the status of a subtask in implementation_plan.json. Use this when completing or starting a subtask.",
|
||||
{"subtask_id": str, "status": str, "notes": str},
|
||||
)
|
||||
async def update_subtask_status(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Update subtask status in the implementation plan."""
|
||||
subtask_id = args["subtask_id"]
|
||||
status = args["status"]
|
||||
notes = args.get("notes", "")
|
||||
|
||||
valid_statuses = ["pending", "in_progress", "completed", "failed"]
|
||||
if status not in valid_statuses:
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Error: Invalid status '{status}'. Must be one of: {valid_statuses}",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if not plan_file.exists():
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Error: implementation_plan.json not found",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
try:
|
||||
with open(plan_file) as f:
|
||||
plan = json.load(f)
|
||||
|
||||
# Find and update the subtask
|
||||
subtask_found = False
|
||||
for phase in plan.get("phases", []):
|
||||
for subtask in phase.get("subtasks", []):
|
||||
if subtask.get("id") == subtask_id:
|
||||
subtask["status"] = status
|
||||
if notes:
|
||||
subtask["notes"] = notes
|
||||
subtask["updated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
subtask_found = True
|
||||
break
|
||||
if subtask_found:
|
||||
break
|
||||
|
||||
if not subtask_found:
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Error: Subtask '{subtask_id}' not found in implementation plan",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Update plan metadata
|
||||
plan["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
with open(plan_file, "w") as f:
|
||||
json.dump(plan, f, indent=2)
|
||||
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Successfully updated subtask '{subtask_id}' to status '{status}'",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Error: Invalid JSON in implementation_plan.json: {e}",
|
||||
}
|
||||
]
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"content": [
|
||||
{"type": "text", "text": f"Error updating subtask status: {e}"}
|
||||
]
|
||||
}
|
||||
|
||||
tools.append(update_subtask_status)
|
||||
|
||||
return tools
|
||||
@@ -0,0 +1,181 @@
|
||||
"""
|
||||
Utility Functions for Agent System
|
||||
===================================
|
||||
|
||||
Helper functions for git operations, plan management, and file syncing.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from core.git_executable import run_git
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_latest_commit(project_dir: Path) -> str | None:
|
||||
"""Get the hash of the latest git commit."""
|
||||
result = run_git(
|
||||
["rev-parse", "HEAD"],
|
||||
cwd=project_dir,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
return None
|
||||
|
||||
|
||||
def get_commit_count(project_dir: Path) -> int:
|
||||
"""Get the total number of commits."""
|
||||
result = run_git(
|
||||
["rev-list", "--count", "HEAD"],
|
||||
cwd=project_dir,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
try:
|
||||
return int(result.stdout.strip())
|
||||
except ValueError:
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def load_implementation_plan(spec_dir: Path) -> dict | None:
|
||||
"""Load the implementation plan JSON."""
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if not plan_file.exists():
|
||||
return None
|
||||
try:
|
||||
with open(plan_file) as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
def find_subtask_in_plan(plan: dict, subtask_id: str) -> dict | None:
|
||||
"""Find a subtask by ID in the plan."""
|
||||
for phase in plan.get("phases", []):
|
||||
for subtask in phase.get("subtasks", []):
|
||||
if subtask.get("id") == subtask_id:
|
||||
return subtask
|
||||
return None
|
||||
|
||||
|
||||
def find_phase_for_subtask(plan: dict, subtask_id: str) -> dict | None:
|
||||
"""Find the phase containing a subtask."""
|
||||
for phase in plan.get("phases", []):
|
||||
for subtask in phase.get("subtasks", []):
|
||||
if subtask.get("id") == subtask_id:
|
||||
return phase
|
||||
return None
|
||||
|
||||
|
||||
def sync_spec_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool:
|
||||
"""
|
||||
Sync ALL spec files from worktree back to source spec directory.
|
||||
|
||||
When running in isolated mode (worktrees), the agent creates and updates
|
||||
many files inside the worktree's spec directory. This function syncs ALL
|
||||
of them back to the main project's spec directory.
|
||||
|
||||
IMPORTANT: Since .auto-claude/ is gitignored, this sync happens to the
|
||||
local filesystem regardless of what branch the user is on. The worktree
|
||||
may be on a different branch (e.g., auto-claude/093-task), but the sync
|
||||
target is always the main project's .auto-claude/specs/ directory.
|
||||
|
||||
Files synced (all files in spec directory):
|
||||
- implementation_plan.json - Task status and subtask completion
|
||||
- build-progress.txt - Session-by-session progress notes
|
||||
- task_logs.json - Execution logs
|
||||
- review_state.json - QA review state
|
||||
- critique_report.json - Spec critique findings
|
||||
- suggested_commit_message.txt - Commit suggestions
|
||||
- REGRESSION_TEST_REPORT.md - Test regression report
|
||||
- spec.md, context.json, etc. - Original spec files (for completeness)
|
||||
- memory/ directory - Codebase map, patterns, gotchas, session insights
|
||||
|
||||
Args:
|
||||
spec_dir: Current spec directory (inside worktree)
|
||||
source_spec_dir: Original spec directory in main project (outside worktree)
|
||||
|
||||
Returns:
|
||||
True if sync was performed, False if not needed or failed
|
||||
"""
|
||||
# Skip if no source specified or same path (not in worktree mode)
|
||||
if not source_spec_dir:
|
||||
return False
|
||||
|
||||
# Resolve paths and check if they're different
|
||||
spec_dir_resolved = spec_dir.resolve()
|
||||
source_spec_dir_resolved = source_spec_dir.resolve()
|
||||
|
||||
if spec_dir_resolved == source_spec_dir_resolved:
|
||||
return False # Same directory, no sync needed
|
||||
|
||||
synced_any = False
|
||||
|
||||
# Ensure source directory exists
|
||||
source_spec_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
# Sync all files and directories from worktree spec to source spec
|
||||
for item in spec_dir.iterdir():
|
||||
# Skip symlinks to prevent path traversal attacks
|
||||
if item.is_symlink():
|
||||
logger.warning(f"Skipping symlink during sync: {item.name}")
|
||||
continue
|
||||
|
||||
source_item = source_spec_dir / item.name
|
||||
|
||||
if item.is_file():
|
||||
# Copy file (preserves timestamps)
|
||||
shutil.copy2(item, source_item)
|
||||
logger.debug(f"Synced {item.name} to source")
|
||||
synced_any = True
|
||||
|
||||
elif item.is_dir():
|
||||
# Recursively sync directory
|
||||
_sync_directory(item, source_item)
|
||||
synced_any = True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to sync spec directory to source: {e}")
|
||||
|
||||
return synced_any
|
||||
|
||||
|
||||
def _sync_directory(source_dir: Path, target_dir: Path) -> None:
|
||||
"""
|
||||
Recursively sync a directory from source to target.
|
||||
|
||||
Args:
|
||||
source_dir: Source directory (in worktree)
|
||||
target_dir: Target directory (in main project)
|
||||
"""
|
||||
# Create target directory if needed
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for item in source_dir.iterdir():
|
||||
# Skip symlinks to prevent path traversal attacks
|
||||
if item.is_symlink():
|
||||
logger.warning(
|
||||
f"Skipping symlink during sync: {source_dir.name}/{item.name}"
|
||||
)
|
||||
continue
|
||||
|
||||
target_item = target_dir / item.name
|
||||
|
||||
if item.is_file():
|
||||
shutil.copy2(item, target_item)
|
||||
logger.debug(f"Synced {source_dir.name}/{item.name} to source")
|
||||
elif item.is_dir():
|
||||
# Recurse into subdirectories
|
||||
_sync_directory(item, target_item)
|
||||
|
||||
|
||||
# Keep the old name as an alias for backward compatibility
|
||||
def sync_plan_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool:
|
||||
"""Alias for sync_spec_to_source for backward compatibility."""
|
||||
return sync_spec_to_source(spec_dir, source_spec_dir)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Analysis Module
|
||||
===============
|
||||
|
||||
Code analysis and project scanning tools.
|
||||
"""
|
||||
|
||||
# Import from analyzers subpackage (these are the modular analyzers)
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .analyzers import (
|
||||
ProjectAnalyzer as ModularProjectAnalyzer,
|
||||
)
|
||||
from .analyzers import (
|
||||
ServiceAnalyzer,
|
||||
analyze_project,
|
||||
analyze_service,
|
||||
)
|
||||
from .ci_discovery import CIDiscovery
|
||||
|
||||
# Import from analysis module root (these are other analysis tools)
|
||||
from .project_analyzer import ProjectAnalyzer
|
||||
from .risk_classifier import RiskClassifier
|
||||
from .security_scanner import SecurityScanner
|
||||
from .test_discovery import TestDiscovery
|
||||
|
||||
# insight_extractor is a module with functions, not a class, so don't import it here
|
||||
# Import it directly when needed: from analysis import insight_extractor
|
||||
|
||||
__all__ = [
|
||||
"ProjectAnalyzer",
|
||||
"ModularProjectAnalyzer",
|
||||
"ServiceAnalyzer",
|
||||
"analyze_project",
|
||||
"analyze_service",
|
||||
"RiskClassifier",
|
||||
"SecurityScanner",
|
||||
"CIDiscovery",
|
||||
"TestDiscovery",
|
||||
]
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Codebase Analyzer
|
||||
=================
|
||||
|
||||
Automatically detects project structure, frameworks, and services.
|
||||
Supports monorepos with multiple services.
|
||||
|
||||
Usage:
|
||||
# Index entire project (creates project_index.json)
|
||||
python auto-claude/analyzer.py --index
|
||||
|
||||
# Analyze specific service
|
||||
python auto-claude/analyzer.py --service backend
|
||||
|
||||
# Output to specific file
|
||||
python auto-claude/analyzer.py --index --output path/to/output.json
|
||||
|
||||
The analyzer will:
|
||||
1. Detect if this is a monorepo or single project
|
||||
2. Find all services/packages and analyze each separately
|
||||
3. Map interdependencies between services
|
||||
4. Identify infrastructure (Docker, CI/CD)
|
||||
5. Document conventions (linting, testing)
|
||||
|
||||
This module now serves as a facade to the modular analyzer system in the analyzers/ package.
|
||||
All actual implementation is in focused submodules for better maintainability.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# Import from the new modular structure
|
||||
from .analyzers import (
|
||||
ProjectAnalyzer,
|
||||
ServiceAnalyzer,
|
||||
analyze_project,
|
||||
analyze_service,
|
||||
)
|
||||
|
||||
# Re-export for backward compatibility
|
||||
__all__ = [
|
||||
"ServiceAnalyzer",
|
||||
"ProjectAnalyzer",
|
||||
"analyze_project",
|
||||
"analyze_service",
|
||||
]
|
||||
|
||||
|
||||
def main():
|
||||
"""CLI entry point."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Analyze project structure, frameworks, and services"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--project-dir",
|
||||
type=Path,
|
||||
default=Path.cwd(),
|
||||
help="Project directory to analyze (default: current directory)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--index",
|
||||
action="store_true",
|
||||
help="Create full project index (default behavior)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--service",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Analyze a specific service only",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Output file for JSON results",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--quiet",
|
||||
action="store_true",
|
||||
help="Only output JSON, no status messages",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Determine what to analyze
|
||||
if args.service:
|
||||
results = analyze_service(args.project_dir, args.service, args.output)
|
||||
else:
|
||||
results = analyze_project(args.project_dir, args.output)
|
||||
|
||||
# Print results
|
||||
if not args.quiet or not args.output:
|
||||
print(json.dumps(results, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
Analyzers Package
|
||||
=================
|
||||
|
||||
Modular analyzer system for detecting project structure, frameworks, and services.
|
||||
|
||||
Main exports:
|
||||
- ServiceAnalyzer: Analyzes a single service/package
|
||||
- ProjectAnalyzer: Analyzes entire projects (single or monorepo)
|
||||
- analyze_project: Convenience function for project analysis
|
||||
- analyze_service: Convenience function for service analysis
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .project_analyzer_module import ProjectAnalyzer
|
||||
from .service_analyzer import ServiceAnalyzer
|
||||
|
||||
# Re-export main classes
|
||||
__all__ = [
|
||||
"ServiceAnalyzer",
|
||||
"ProjectAnalyzer",
|
||||
"analyze_project",
|
||||
"analyze_service",
|
||||
]
|
||||
|
||||
|
||||
def analyze_project(project_dir: Path, output_file: Path | None = None) -> dict:
|
||||
"""
|
||||
Analyze a project and optionally save results.
|
||||
|
||||
Args:
|
||||
project_dir: Path to the project root
|
||||
output_file: Optional path to save JSON output
|
||||
|
||||
Returns:
|
||||
Project index as a dictionary
|
||||
"""
|
||||
import json
|
||||
|
||||
analyzer = ProjectAnalyzer(project_dir)
|
||||
results = analyzer.analyze()
|
||||
|
||||
if output_file:
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_file, "w") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
print(f"Project index saved to: {output_file}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def analyze_service(
|
||||
project_dir: Path, service_name: str, output_file: Path | None = None
|
||||
) -> dict:
|
||||
"""
|
||||
Analyze a specific service within a project.
|
||||
|
||||
Args:
|
||||
project_dir: Path to the project root
|
||||
service_name: Name of the service to analyze
|
||||
output_file: Optional path to save JSON output
|
||||
|
||||
Returns:
|
||||
Service analysis as a dictionary
|
||||
"""
|
||||
import json
|
||||
|
||||
# Find the service
|
||||
service_path = project_dir / service_name
|
||||
if not service_path.exists():
|
||||
# Check common locations
|
||||
for parent in ["packages", "apps", "services"]:
|
||||
candidate = project_dir / parent / service_name
|
||||
if candidate.exists():
|
||||
service_path = candidate
|
||||
break
|
||||
|
||||
if not service_path.exists():
|
||||
raise ValueError(f"Service '{service_name}' not found in {project_dir}")
|
||||
|
||||
analyzer = ServiceAnalyzer(service_path, service_name)
|
||||
results = analyzer.analyze()
|
||||
|
||||
if output_file:
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_file, "w") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
print(f"Service analysis saved to: {output_file}")
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
Base Analyzer Module
|
||||
====================
|
||||
|
||||
Provides common constants, utilities, and base functionality shared across all analyzers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
# Directories to skip during analysis
|
||||
SKIP_DIRS = {
|
||||
"node_modules",
|
||||
".git",
|
||||
"__pycache__",
|
||||
".venv",
|
||||
"venv",
|
||||
".env",
|
||||
"env",
|
||||
"dist",
|
||||
"build",
|
||||
".next",
|
||||
".nuxt",
|
||||
"target",
|
||||
"vendor",
|
||||
".idea",
|
||||
".vscode",
|
||||
".pytest_cache",
|
||||
".mypy_cache",
|
||||
"coverage",
|
||||
".coverage",
|
||||
"htmlcov",
|
||||
"eggs",
|
||||
"*.egg-info",
|
||||
".turbo",
|
||||
".cache",
|
||||
".worktrees", # Skip git worktrees directory
|
||||
".auto-claude", # Skip auto-claude metadata directory
|
||||
}
|
||||
|
||||
# Common service directory names
|
||||
SERVICE_INDICATORS = {
|
||||
"backend",
|
||||
"frontend",
|
||||
"api",
|
||||
"web",
|
||||
"app",
|
||||
"server",
|
||||
"client",
|
||||
"worker",
|
||||
"workers",
|
||||
"services",
|
||||
"packages",
|
||||
"apps",
|
||||
"libs",
|
||||
"scraper",
|
||||
"crawler",
|
||||
"proxy",
|
||||
"gateway",
|
||||
"admin",
|
||||
"dashboard",
|
||||
"mobile",
|
||||
"desktop",
|
||||
"cli",
|
||||
"sdk",
|
||||
"core",
|
||||
"shared",
|
||||
"common",
|
||||
}
|
||||
|
||||
# Files that indicate a service root
|
||||
SERVICE_ROOT_FILES = {
|
||||
"package.json",
|
||||
"requirements.txt",
|
||||
"pyproject.toml",
|
||||
"Cargo.toml",
|
||||
"go.mod",
|
||||
"Gemfile",
|
||||
"composer.json",
|
||||
"pom.xml",
|
||||
"build.gradle",
|
||||
"Makefile",
|
||||
"Dockerfile",
|
||||
}
|
||||
|
||||
|
||||
class BaseAnalyzer:
|
||||
"""Base class with common utilities for all analyzers."""
|
||||
|
||||
def __init__(self, path: Path):
|
||||
self.path = path.resolve()
|
||||
|
||||
def _exists(self, path: str) -> bool:
|
||||
"""Check if a file exists relative to the analyzer's path."""
|
||||
return (self.path / path).exists()
|
||||
|
||||
def _read_file(self, path: str) -> str:
|
||||
"""Read a file relative to the analyzer's path."""
|
||||
try:
|
||||
return (self.path / path).read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return ""
|
||||
|
||||
def _read_json(self, path: str) -> dict | None:
|
||||
"""Read and parse a JSON file relative to the analyzer's path."""
|
||||
content = self._read_file(path)
|
||||
if content:
|
||||
try:
|
||||
return json.loads(content)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
return None
|
||||
|
||||
def _infer_env_var_type(self, value: str) -> str:
|
||||
"""Infer the type of an environment variable from its value."""
|
||||
if not value:
|
||||
return "string"
|
||||
|
||||
# Boolean
|
||||
if value.lower() in ["true", "false", "1", "0", "yes", "no"]:
|
||||
return "boolean"
|
||||
|
||||
# Number
|
||||
if value.isdigit():
|
||||
return "number"
|
||||
|
||||
# URL
|
||||
if value.startswith(
|
||||
(
|
||||
"http://",
|
||||
"https://",
|
||||
"postgres://",
|
||||
"postgresql://",
|
||||
"mysql://",
|
||||
"mongodb://",
|
||||
"redis://",
|
||||
)
|
||||
):
|
||||
return "url"
|
||||
|
||||
# Email
|
||||
if "@" in value and "." in value:
|
||||
return "email"
|
||||
|
||||
# Path
|
||||
if "/" in value or "\\" in value:
|
||||
return "path"
|
||||
|
||||
return "string"
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
Context Analyzer Package
|
||||
=========================
|
||||
|
||||
Contains specialized detectors for comprehensive project context analysis.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .api_docs_detector import ApiDocsDetector
|
||||
from .auth_detector import AuthDetector
|
||||
from .env_detector import EnvironmentDetector
|
||||
from .jobs_detector import JobsDetector
|
||||
from .migrations_detector import MigrationsDetector
|
||||
from .monitoring_detector import MonitoringDetector
|
||||
from .services_detector import ServicesDetector
|
||||
|
||||
__all__ = [
|
||||
"ApiDocsDetector",
|
||||
"AuthDetector",
|
||||
"EnvironmentDetector",
|
||||
"JobsDetector",
|
||||
"MigrationsDetector",
|
||||
"MonitoringDetector",
|
||||
"ServicesDetector",
|
||||
]
|
||||
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
API Documentation Detector Module
|
||||
==================================
|
||||
|
||||
Detects API documentation tools and configurations:
|
||||
- OpenAPI/Swagger (FastAPI auto-generated, swagger-ui-express)
|
||||
- GraphQL playground
|
||||
- API documentation endpoints
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..base import BaseAnalyzer
|
||||
|
||||
|
||||
class ApiDocsDetector(BaseAnalyzer):
|
||||
"""Detects API documentation setup."""
|
||||
|
||||
def __init__(self, path: Path, analysis: dict[str, Any]):
|
||||
super().__init__(path)
|
||||
self.analysis = analysis
|
||||
|
||||
def detect(self) -> None:
|
||||
"""
|
||||
Detect API documentation setup.
|
||||
|
||||
Detects: OpenAPI/Swagger, GraphQL playground, API docs endpoints.
|
||||
"""
|
||||
docs_info = {}
|
||||
|
||||
# Detect OpenAPI/Swagger
|
||||
openapi_info = self._detect_fastapi() or self._detect_swagger_nodejs()
|
||||
if openapi_info:
|
||||
docs_info.update(openapi_info)
|
||||
|
||||
# Detect GraphQL
|
||||
graphql_info = self._detect_graphql()
|
||||
if graphql_info:
|
||||
docs_info["graphql"] = graphql_info
|
||||
|
||||
if docs_info:
|
||||
self.analysis["api_documentation"] = docs_info
|
||||
|
||||
def _detect_fastapi(self) -> dict[str, Any] | None:
|
||||
"""Detect FastAPI auto-generated OpenAPI docs."""
|
||||
if self.analysis.get("framework") != "FastAPI":
|
||||
return None
|
||||
|
||||
return {
|
||||
"type": "openapi",
|
||||
"auto_generated": True,
|
||||
"docs_url": "/docs",
|
||||
"redoc_url": "/redoc",
|
||||
"openapi_url": "/openapi.json",
|
||||
}
|
||||
|
||||
def _detect_swagger_nodejs(self) -> dict[str, Any] | None:
|
||||
"""Detect Swagger for Node.js projects."""
|
||||
if not self._exists("package.json"):
|
||||
return None
|
||||
|
||||
pkg = self._read_json("package.json")
|
||||
if not pkg:
|
||||
return None
|
||||
|
||||
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
|
||||
if "swagger-ui-express" in deps or "swagger-jsdoc" in deps:
|
||||
return {
|
||||
"type": "openapi",
|
||||
"library": "swagger-ui-express",
|
||||
"docs_url": "/api-docs",
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def _detect_graphql(self) -> dict[str, str] | None:
|
||||
"""Detect GraphQL API and playground."""
|
||||
if not self._exists("package.json"):
|
||||
return None
|
||||
|
||||
pkg = self._read_json("package.json")
|
||||
if not pkg:
|
||||
return None
|
||||
|
||||
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
|
||||
if "graphql" in deps or "apollo-server" in deps or "@apollo/server" in deps:
|
||||
return {
|
||||
"playground_url": "/graphql",
|
||||
"library": "apollo-server" if "apollo-server" in deps else "graphql",
|
||||
}
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
Authentication Patterns Detector Module
|
||||
========================================
|
||||
|
||||
Detects authentication and authorization patterns:
|
||||
- JWT authentication
|
||||
- OAuth providers
|
||||
- Session-based authentication
|
||||
- API key authentication
|
||||
- User models
|
||||
- Auth middleware and decorators
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..base import BaseAnalyzer
|
||||
|
||||
|
||||
class AuthDetector(BaseAnalyzer):
|
||||
"""Detects authentication and authorization patterns."""
|
||||
|
||||
JWT_LIBS = ["python-jose", "pyjwt", "jsonwebtoken", "jose"]
|
||||
OAUTH_LIBS = ["authlib", "passport", "next-auth", "@auth/core", "oauth2"]
|
||||
SESSION_LIBS = ["flask-login", "express-session", "django.contrib.auth"]
|
||||
|
||||
USER_MODEL_FILES = [
|
||||
"models/user.py",
|
||||
"models/User.py",
|
||||
"app/models/user.py",
|
||||
"models/user.ts",
|
||||
"models/User.ts",
|
||||
"src/models/user.ts",
|
||||
]
|
||||
|
||||
def __init__(self, path: Path, analysis: dict[str, Any]):
|
||||
super().__init__(path)
|
||||
self.analysis = analysis
|
||||
|
||||
def detect(self) -> None:
|
||||
"""
|
||||
Detect authentication and authorization patterns.
|
||||
|
||||
Detects: JWT, OAuth, session-based, API keys, user models, protected routes.
|
||||
"""
|
||||
auth_info = {
|
||||
"strategies": [],
|
||||
"libraries": [],
|
||||
"user_model": None,
|
||||
"middleware": [],
|
||||
}
|
||||
|
||||
# Get all dependencies
|
||||
all_deps = self._get_all_dependencies()
|
||||
|
||||
# Detect auth strategies and libraries
|
||||
self._detect_jwt(all_deps, auth_info)
|
||||
self._detect_oauth(all_deps, auth_info)
|
||||
self._detect_session(all_deps, auth_info)
|
||||
|
||||
# Find user model
|
||||
auth_info["user_model"] = self._find_user_model()
|
||||
|
||||
# Detect auth middleware/decorators
|
||||
auth_info["middleware"] = self._find_auth_middleware()
|
||||
|
||||
# Remove duplicates from strategies
|
||||
auth_info["strategies"] = list(set(auth_info["strategies"]))
|
||||
|
||||
if auth_info["strategies"] or auth_info["libraries"]:
|
||||
self.analysis["auth"] = auth_info
|
||||
|
||||
def _get_all_dependencies(self) -> set[str]:
|
||||
"""Extract all dependencies from Python and Node.js projects."""
|
||||
all_deps = set()
|
||||
|
||||
if self._exists("requirements.txt"):
|
||||
content = self._read_file("requirements.txt")
|
||||
all_deps.update(re.findall(r"^([a-zA-Z0-9_-]+)", content, re.MULTILINE))
|
||||
|
||||
pkg = self._read_json("package.json")
|
||||
if pkg:
|
||||
all_deps.update(pkg.get("dependencies", {}).keys())
|
||||
|
||||
return all_deps
|
||||
|
||||
def _detect_jwt(self, all_deps: set[str], auth_info: dict[str, Any]) -> None:
|
||||
"""Detect JWT authentication libraries."""
|
||||
for lib in self.JWT_LIBS:
|
||||
if lib in all_deps:
|
||||
auth_info["strategies"].append("jwt")
|
||||
auth_info["libraries"].append(lib)
|
||||
break
|
||||
|
||||
def _detect_oauth(self, all_deps: set[str], auth_info: dict[str, Any]) -> None:
|
||||
"""Detect OAuth authentication libraries."""
|
||||
for lib in self.OAUTH_LIBS:
|
||||
if lib in all_deps:
|
||||
auth_info["strategies"].append("oauth")
|
||||
auth_info["libraries"].append(lib)
|
||||
break
|
||||
|
||||
def _detect_session(self, all_deps: set[str], auth_info: dict[str, Any]) -> None:
|
||||
"""Detect session-based authentication libraries."""
|
||||
for lib in self.SESSION_LIBS:
|
||||
if lib in all_deps:
|
||||
auth_info["strategies"].append("session")
|
||||
auth_info["libraries"].append(lib)
|
||||
break
|
||||
|
||||
def _find_user_model(self) -> str | None:
|
||||
"""Find the user model file."""
|
||||
for model_file in self.USER_MODEL_FILES:
|
||||
if self._exists(model_file):
|
||||
return model_file
|
||||
return None
|
||||
|
||||
def _find_auth_middleware(self) -> list[str]:
|
||||
"""Detect auth middleware and decorators from Python files."""
|
||||
# Limit to first 20 files for performance
|
||||
all_py_files = list(self.path.glob("**/*.py"))[:20]
|
||||
auth_decorators = set()
|
||||
|
||||
for py_file in all_py_files:
|
||||
try:
|
||||
content = py_file.read_text()
|
||||
# Find custom decorators
|
||||
if (
|
||||
"@require" in content
|
||||
or "@login_required" in content
|
||||
or "@authenticate" in content
|
||||
):
|
||||
decorators = re.findall(r"@(\w*(?:require|auth|login)\w*)", content)
|
||||
auth_decorators.update(decorators)
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
return list(auth_decorators) if auth_decorators else []
|
||||
@@ -0,0 +1,223 @@
|
||||
"""
|
||||
Environment Variable Detector Module
|
||||
=====================================
|
||||
|
||||
Detects and analyzes environment variables from multiple sources:
|
||||
- .env files and variants
|
||||
- .env.example files
|
||||
- docker-compose.yml
|
||||
- Source code (os.getenv, process.env)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..base import BaseAnalyzer
|
||||
|
||||
|
||||
class EnvironmentDetector(BaseAnalyzer):
|
||||
"""Detects environment variables and their configurations."""
|
||||
|
||||
def __init__(self, path: Path, analysis: dict[str, Any]):
|
||||
super().__init__(path)
|
||||
self.analysis = analysis
|
||||
|
||||
def detect(self) -> None:
|
||||
"""
|
||||
Discover all environment variables from multiple sources.
|
||||
|
||||
Extracts from: .env files, docker-compose, example files.
|
||||
Categorizes as required/optional and detects sensitive data.
|
||||
"""
|
||||
env_vars = {}
|
||||
required_vars = set()
|
||||
optional_vars = set()
|
||||
|
||||
# Parse various sources
|
||||
self._parse_env_files(env_vars)
|
||||
self._parse_env_example(env_vars, required_vars)
|
||||
self._parse_docker_compose(env_vars)
|
||||
self._parse_code_references(env_vars, optional_vars)
|
||||
|
||||
# Mark required vs optional
|
||||
for key in env_vars:
|
||||
if "required" not in env_vars[key]:
|
||||
env_vars[key]["required"] = key in required_vars
|
||||
|
||||
if env_vars:
|
||||
self.analysis["environment"] = {
|
||||
"variables": env_vars,
|
||||
"required_count": len(required_vars),
|
||||
"optional_count": len(optional_vars),
|
||||
"detected_count": len(env_vars),
|
||||
}
|
||||
|
||||
def _parse_env_files(self, env_vars: dict[str, Any]) -> None:
|
||||
"""Parse .env files and variants."""
|
||||
env_files = [
|
||||
".env",
|
||||
".env.local",
|
||||
".env.development",
|
||||
".env.production",
|
||||
".env.dev",
|
||||
".env.prod",
|
||||
".env.test",
|
||||
".env.staging",
|
||||
"config/.env",
|
||||
"../.env",
|
||||
]
|
||||
|
||||
for env_file in env_files:
|
||||
content = self._read_file(env_file)
|
||||
if not content:
|
||||
continue
|
||||
|
||||
for line in content.split("\n"):
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
# Parse KEY=value or KEY="value" or KEY='value'
|
||||
match = re.match(r"^([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$", line)
|
||||
if match:
|
||||
key = match.group(1)
|
||||
value = match.group(2).strip().strip('"').strip("'")
|
||||
|
||||
# Detect if sensitive
|
||||
is_sensitive = self._is_sensitive_key(key)
|
||||
|
||||
# Detect type
|
||||
var_type = self._infer_env_var_type(value)
|
||||
|
||||
env_vars[key] = {
|
||||
"value": "<REDACTED>" if is_sensitive else value,
|
||||
"source": env_file,
|
||||
"type": var_type,
|
||||
"sensitive": is_sensitive,
|
||||
}
|
||||
|
||||
def _parse_env_example(
|
||||
self, env_vars: dict[str, Any], required_vars: set[str]
|
||||
) -> None:
|
||||
"""Parse .env.example to find required variables."""
|
||||
example_content = self._read_file(".env.example") or self._read_file(
|
||||
".env.sample"
|
||||
)
|
||||
if not example_content:
|
||||
return
|
||||
|
||||
for line in example_content.split("\n"):
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
|
||||
match = re.match(r"^([A-Z_][A-Z0-9_]*)\s*=", line)
|
||||
if match:
|
||||
key = match.group(1)
|
||||
required_vars.add(key)
|
||||
|
||||
if key not in env_vars:
|
||||
env_vars[key] = {
|
||||
"value": None,
|
||||
"source": ".env.example",
|
||||
"type": "string",
|
||||
"sensitive": self._is_sensitive_key(key),
|
||||
"required": True,
|
||||
}
|
||||
|
||||
def _parse_docker_compose(self, env_vars: dict[str, Any]) -> None:
|
||||
"""Parse docker-compose.yml environment section."""
|
||||
for compose_file in ["docker-compose.yml", "../docker-compose.yml"]:
|
||||
content = self._read_file(compose_file)
|
||||
if not content:
|
||||
continue
|
||||
|
||||
# Look for environment variables in docker-compose
|
||||
in_env_section = False
|
||||
for line in content.split("\n"):
|
||||
if "environment:" in line:
|
||||
in_env_section = True
|
||||
continue
|
||||
|
||||
if in_env_section:
|
||||
# Check if we left the environment section
|
||||
if line and not line.startswith((" ", "\t", "-")):
|
||||
in_env_section = False
|
||||
continue
|
||||
|
||||
# Parse - KEY=value or - KEY
|
||||
match = re.match(r"^\s*-\s*([A-Z_][A-Z0-9_]*)", line)
|
||||
if match:
|
||||
key = match.group(1)
|
||||
if key not in env_vars:
|
||||
env_vars[key] = {
|
||||
"value": None,
|
||||
"source": compose_file,
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
}
|
||||
|
||||
def _parse_code_references(
|
||||
self, env_vars: dict[str, Any], optional_vars: set[str]
|
||||
) -> None:
|
||||
"""Scan code for os.getenv() / process.env usage to find optional vars."""
|
||||
entry_files = [
|
||||
"app.py",
|
||||
"main.py",
|
||||
"config.py",
|
||||
"settings.py",
|
||||
"src/config.py",
|
||||
"src/settings.py",
|
||||
"index.js",
|
||||
"index.ts",
|
||||
"config.js",
|
||||
"config.ts",
|
||||
]
|
||||
|
||||
for entry_file in entry_files:
|
||||
content = self._read_file(entry_file)
|
||||
if not content:
|
||||
continue
|
||||
|
||||
# Python: os.getenv("VAR") or os.environ.get("VAR")
|
||||
python_patterns = [
|
||||
r'os\.getenv\(["\']([A-Z_][A-Z0-9_]*)["\']',
|
||||
r'os\.environ\.get\(["\']([A-Z_][A-Z0-9_]*)["\']',
|
||||
r'os\.environ\[["\']([A-Z_][A-Z0-9_]*)["\']',
|
||||
]
|
||||
|
||||
# JavaScript: process.env.VAR
|
||||
js_patterns = [
|
||||
r"process\.env\.([A-Z_][A-Z0-9_]*)",
|
||||
]
|
||||
|
||||
for pattern in python_patterns + js_patterns:
|
||||
matches = re.findall(pattern, content)
|
||||
for var_name in matches:
|
||||
if var_name not in env_vars:
|
||||
optional_vars.add(var_name)
|
||||
env_vars[var_name] = {
|
||||
"value": None,
|
||||
"source": f"code:{entry_file}",
|
||||
"type": "string",
|
||||
"sensitive": self._is_sensitive_key(var_name),
|
||||
"required": False,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _is_sensitive_key(key: str) -> bool:
|
||||
"""Determine if an environment variable key contains sensitive data."""
|
||||
sensitive_keywords = [
|
||||
"secret",
|
||||
"key",
|
||||
"password",
|
||||
"token",
|
||||
"api_key",
|
||||
"private",
|
||||
"credential",
|
||||
"auth",
|
||||
]
|
||||
return any(keyword in key.lower() for keyword in sensitive_keywords)
|
||||
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Background Jobs Detector Module
|
||||
================================
|
||||
|
||||
Detects background job and task queue systems:
|
||||
- Celery (Python)
|
||||
- BullMQ/Bull (Node.js)
|
||||
- Sidekiq (Ruby)
|
||||
- Scheduled tasks and cron jobs
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..base import BaseAnalyzer
|
||||
|
||||
|
||||
class JobsDetector(BaseAnalyzer):
|
||||
"""Detects background job and task queue systems."""
|
||||
|
||||
def __init__(self, path: Path, analysis: dict[str, Any]):
|
||||
super().__init__(path)
|
||||
self.analysis = analysis
|
||||
|
||||
def detect(self) -> None:
|
||||
"""
|
||||
Detect background job/task queue systems.
|
||||
|
||||
Detects: Celery, BullMQ, Sidekiq, cron jobs, scheduled tasks.
|
||||
"""
|
||||
jobs_info = None
|
||||
|
||||
# Try each job system in order
|
||||
jobs_info = (
|
||||
self._detect_celery() or self._detect_bullmq() or self._detect_sidekiq()
|
||||
)
|
||||
|
||||
if jobs_info:
|
||||
self.analysis["background_jobs"] = jobs_info
|
||||
|
||||
def _detect_celery(self) -> dict[str, Any] | None:
|
||||
"""Detect Celery (Python) task queue."""
|
||||
celery_files = list(self.path.glob("**/celery.py")) + list(
|
||||
self.path.glob("**/tasks.py")
|
||||
)
|
||||
if not celery_files:
|
||||
return None
|
||||
|
||||
tasks = []
|
||||
for task_file in celery_files:
|
||||
try:
|
||||
content = task_file.read_text()
|
||||
# Find @celery.task or @shared_task decorators
|
||||
task_pattern = r"@(?:celery\.task|shared_task|app\.task)\s*(?:\([^)]*\))?\s*def\s+(\w+)"
|
||||
task_matches = re.findall(task_pattern, content)
|
||||
|
||||
for task_name in task_matches:
|
||||
tasks.append(
|
||||
{
|
||||
"name": task_name,
|
||||
"file": str(task_file.relative_to(self.path)),
|
||||
}
|
||||
)
|
||||
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
if not tasks:
|
||||
return None
|
||||
|
||||
return {
|
||||
"system": "celery",
|
||||
"tasks": tasks,
|
||||
"total_tasks": len(tasks),
|
||||
"worker_command": "celery -A app worker",
|
||||
}
|
||||
|
||||
def _detect_bullmq(self) -> dict[str, Any] | None:
|
||||
"""Detect BullMQ/Bull (Node.js) task queue."""
|
||||
if not self._exists("package.json"):
|
||||
return None
|
||||
|
||||
pkg = self._read_json("package.json")
|
||||
if not pkg:
|
||||
return None
|
||||
|
||||
deps = pkg.get("dependencies", {})
|
||||
if "bullmq" in deps:
|
||||
return {
|
||||
"system": "bullmq",
|
||||
"tasks": [],
|
||||
"worker_command": "node worker.js",
|
||||
}
|
||||
elif "bull" in deps:
|
||||
return {
|
||||
"system": "bull",
|
||||
"tasks": [],
|
||||
"worker_command": "node worker.js",
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
def _detect_sidekiq(self) -> dict[str, Any] | None:
|
||||
"""Detect Sidekiq (Ruby) background jobs."""
|
||||
if not self._exists("Gemfile"):
|
||||
return None
|
||||
|
||||
gemfile = self._read_file("Gemfile")
|
||||
if "sidekiq" not in gemfile.lower():
|
||||
return None
|
||||
|
||||
return {
|
||||
"system": "sidekiq",
|
||||
"worker_command": "bundle exec sidekiq",
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Database Migrations Detector Module
|
||||
====================================
|
||||
|
||||
Detects database migration tools and configurations:
|
||||
- Alembic (Python)
|
||||
- Django migrations
|
||||
- Knex (Node.js)
|
||||
- TypeORM
|
||||
- Prisma
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..base import BaseAnalyzer
|
||||
|
||||
|
||||
class MigrationsDetector(BaseAnalyzer):
|
||||
"""Detects database migration setup and tools."""
|
||||
|
||||
def __init__(self, path: Path, analysis: dict[str, Any]):
|
||||
super().__init__(path)
|
||||
self.analysis = analysis
|
||||
|
||||
def detect(self) -> None:
|
||||
"""
|
||||
Detect database migration setup.
|
||||
|
||||
Detects: Alembic, Django migrations, Knex, TypeORM, Prisma migrations.
|
||||
"""
|
||||
migration_info = None
|
||||
|
||||
# Try each migration tool in order
|
||||
migration_info = (
|
||||
self._detect_alembic()
|
||||
or self._detect_django()
|
||||
or self._detect_knex()
|
||||
or self._detect_typeorm()
|
||||
or self._detect_prisma()
|
||||
)
|
||||
|
||||
if migration_info:
|
||||
self.analysis["migrations"] = migration_info
|
||||
|
||||
def _detect_alembic(self) -> dict[str, Any] | None:
|
||||
"""Detect Alembic (Python) migrations."""
|
||||
if not (self._exists("alembic.ini") or self._exists("alembic")):
|
||||
return None
|
||||
|
||||
return {
|
||||
"tool": "alembic",
|
||||
"directory": "alembic/versions"
|
||||
if self._exists("alembic/versions")
|
||||
else "alembic",
|
||||
"config_file": "alembic.ini",
|
||||
"commands": {
|
||||
"upgrade": "alembic upgrade head",
|
||||
"downgrade": "alembic downgrade -1",
|
||||
"create": "alembic revision --autogenerate -m 'message'",
|
||||
},
|
||||
}
|
||||
|
||||
def _detect_django(self) -> dict[str, Any] | None:
|
||||
"""Detect Django migrations."""
|
||||
if not self._exists("manage.py"):
|
||||
return None
|
||||
|
||||
migration_dirs = list(self.path.glob("**/migrations"))
|
||||
if not migration_dirs:
|
||||
return None
|
||||
|
||||
return {
|
||||
"tool": "django",
|
||||
"directories": [str(d.relative_to(self.path)) for d in migration_dirs],
|
||||
"commands": {
|
||||
"migrate": "python manage.py migrate",
|
||||
"makemigrations": "python manage.py makemigrations",
|
||||
},
|
||||
}
|
||||
|
||||
def _detect_knex(self) -> dict[str, Any] | None:
|
||||
"""Detect Knex (Node.js) migrations."""
|
||||
if not (self._exists("knexfile.js") or self._exists("knexfile.ts")):
|
||||
return None
|
||||
|
||||
return {
|
||||
"tool": "knex",
|
||||
"directory": "migrations",
|
||||
"config_file": "knexfile.js",
|
||||
"commands": {
|
||||
"migrate": "knex migrate:latest",
|
||||
"rollback": "knex migrate:rollback",
|
||||
"create": "knex migrate:make migration_name",
|
||||
},
|
||||
}
|
||||
|
||||
def _detect_typeorm(self) -> dict[str, Any] | None:
|
||||
"""Detect TypeORM migrations."""
|
||||
if not (self._exists("ormconfig.json") or self._exists("data-source.ts")):
|
||||
return None
|
||||
|
||||
return {
|
||||
"tool": "typeorm",
|
||||
"directory": "migrations",
|
||||
"commands": {
|
||||
"run": "typeorm migration:run",
|
||||
"revert": "typeorm migration:revert",
|
||||
"create": "typeorm migration:create",
|
||||
},
|
||||
}
|
||||
|
||||
def _detect_prisma(self) -> dict[str, Any] | None:
|
||||
"""Detect Prisma migrations."""
|
||||
if not self._exists("prisma/schema.prisma"):
|
||||
return None
|
||||
|
||||
return {
|
||||
"tool": "prisma",
|
||||
"directory": "prisma/migrations",
|
||||
"config_file": "prisma/schema.prisma",
|
||||
"commands": {
|
||||
"migrate": "prisma migrate deploy",
|
||||
"dev": "prisma migrate dev",
|
||||
"create": "prisma migrate dev --name migration_name",
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Monitoring Detector Module
|
||||
===========================
|
||||
|
||||
Detects monitoring and observability setup:
|
||||
- Health check endpoints
|
||||
- Prometheus metrics endpoints
|
||||
- APM tools (Sentry, Datadog, New Relic)
|
||||
- Logging infrastructure
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..base import BaseAnalyzer
|
||||
|
||||
|
||||
class MonitoringDetector(BaseAnalyzer):
|
||||
"""Detects monitoring and observability setup."""
|
||||
|
||||
def __init__(self, path: Path, analysis: dict[str, Any]):
|
||||
super().__init__(path)
|
||||
self.analysis = analysis
|
||||
|
||||
def detect(self) -> None:
|
||||
"""
|
||||
Detect monitoring and observability setup.
|
||||
|
||||
Detects: Health checks, metrics endpoints, APM tools, logging.
|
||||
"""
|
||||
monitoring_info = {}
|
||||
|
||||
# Detect health check endpoints from existing API analysis
|
||||
health_checks = self._detect_health_checks()
|
||||
if health_checks:
|
||||
monitoring_info["health_checks"] = health_checks
|
||||
|
||||
# Detect Prometheus metrics
|
||||
metrics_info = self._detect_prometheus()
|
||||
if metrics_info:
|
||||
monitoring_info.update(metrics_info)
|
||||
|
||||
# Reference APM tools from services analysis
|
||||
apm_tools = self._get_apm_tools()
|
||||
if apm_tools:
|
||||
monitoring_info["apm_tools"] = apm_tools
|
||||
|
||||
if monitoring_info:
|
||||
self.analysis["monitoring"] = monitoring_info
|
||||
|
||||
def _detect_health_checks(self) -> list[str] | None:
|
||||
"""Detect health check endpoints from API routes."""
|
||||
if "api" not in self.analysis:
|
||||
return None
|
||||
|
||||
routes = self.analysis["api"].get("routes", [])
|
||||
health_routes = [
|
||||
r["path"]
|
||||
for r in routes
|
||||
if "health" in r["path"].lower() or "ping" in r["path"].lower()
|
||||
]
|
||||
|
||||
return health_routes if health_routes else None
|
||||
|
||||
def _detect_prometheus(self) -> dict[str, str] | None:
|
||||
"""Detect Prometheus metrics endpoint."""
|
||||
# Look for actual Prometheus imports/usage, not just keywords
|
||||
all_files = (
|
||||
list(self.path.glob("**/*.py"))[:30] + list(self.path.glob("**/*.js"))[:30]
|
||||
)
|
||||
|
||||
for file_path in all_files:
|
||||
# Skip analyzer files to avoid self-detection
|
||||
if "analyzers" in str(file_path) or "analyzer.py" in str(file_path):
|
||||
continue
|
||||
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
# Look for actual Prometheus imports or usage patterns
|
||||
prometheus_patterns = [
|
||||
"from prometheus_client import",
|
||||
"import prometheus_client",
|
||||
"prometheus_client.",
|
||||
"@app.route('/metrics')", # Flask
|
||||
"app.get('/metrics'", # Express/Fastify
|
||||
"router.get('/metrics'", # Express Router
|
||||
]
|
||||
|
||||
if any(pattern in content for pattern in prometheus_patterns):
|
||||
return {
|
||||
"metrics_endpoint": "/metrics",
|
||||
"metrics_type": "prometheus",
|
||||
}
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def _get_apm_tools(self) -> list[str] | None:
|
||||
"""Get APM tools from existing services analysis."""
|
||||
if (
|
||||
"services" not in self.analysis
|
||||
or "monitoring" not in self.analysis["services"]
|
||||
):
|
||||
return None
|
||||
|
||||
return [s["type"] for s in self.analysis["services"]["monitoring"]]
|
||||
@@ -0,0 +1,215 @@
|
||||
"""
|
||||
External Services Detector Module
|
||||
==================================
|
||||
|
||||
Detects external service integrations based on dependencies:
|
||||
- Databases (PostgreSQL, MySQL, MongoDB, Redis, SQLite)
|
||||
- Cache services (Redis, Memcached)
|
||||
- Message queues (Celery, BullMQ, Kafka, RabbitMQ)
|
||||
- Email services (SendGrid, Mailgun, Postmark)
|
||||
- Payment processors (Stripe, PayPal, Square)
|
||||
- Storage services (AWS S3, Google Cloud Storage, Azure)
|
||||
- Auth providers (OAuth, JWT)
|
||||
- Monitoring tools (Sentry, Datadog, New Relic)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..base import BaseAnalyzer
|
||||
|
||||
|
||||
class ServicesDetector(BaseAnalyzer):
|
||||
"""Detects external service integrations."""
|
||||
|
||||
# Service indicator mappings
|
||||
DATABASE_INDICATORS = {
|
||||
"psycopg2": "postgresql",
|
||||
"psycopg2-binary": "postgresql",
|
||||
"pg": "postgresql",
|
||||
"mysql": "mysql",
|
||||
"mysql2": "mysql",
|
||||
"pymongo": "mongodb",
|
||||
"mongodb": "mongodb",
|
||||
"mongoose": "mongodb",
|
||||
"redis": "redis",
|
||||
"redis-py": "redis",
|
||||
"ioredis": "redis",
|
||||
"sqlite3": "sqlite",
|
||||
"better-sqlite3": "sqlite",
|
||||
}
|
||||
|
||||
CACHE_INDICATORS = ["redis", "memcached", "node-cache"]
|
||||
|
||||
QUEUE_INDICATORS = {
|
||||
"celery": "celery",
|
||||
"bullmq": "bullmq",
|
||||
"bull": "bull",
|
||||
"kafka-python": "kafka",
|
||||
"kafkajs": "kafka",
|
||||
"amqplib": "rabbitmq",
|
||||
"amqp": "rabbitmq",
|
||||
}
|
||||
|
||||
EMAIL_INDICATORS = {
|
||||
"sendgrid": "sendgrid",
|
||||
"@sendgrid/mail": "sendgrid",
|
||||
"nodemailer": "smtp",
|
||||
"mailgun": "mailgun",
|
||||
"postmark": "postmark",
|
||||
}
|
||||
|
||||
PAYMENT_INDICATORS = {
|
||||
"stripe": "stripe",
|
||||
"paypal": "paypal",
|
||||
"square": "square",
|
||||
"braintree": "braintree",
|
||||
}
|
||||
|
||||
STORAGE_INDICATORS = {
|
||||
"boto3": "aws_s3",
|
||||
"@aws-sdk/client-s3": "aws_s3",
|
||||
"aws-sdk": "aws_s3",
|
||||
"@google-cloud/storage": "google_cloud_storage",
|
||||
"azure-storage-blob": "azure_blob_storage",
|
||||
}
|
||||
|
||||
AUTH_INDICATORS = {
|
||||
"authlib": "oauth",
|
||||
"python-jose": "jwt",
|
||||
"pyjwt": "jwt",
|
||||
"jsonwebtoken": "jwt",
|
||||
"passport": "oauth",
|
||||
"next-auth": "oauth",
|
||||
"@auth/core": "oauth",
|
||||
}
|
||||
|
||||
MONITORING_INDICATORS = {
|
||||
"sentry-sdk": "sentry",
|
||||
"@sentry/node": "sentry",
|
||||
"datadog": "datadog",
|
||||
"newrelic": "new_relic",
|
||||
"loguru": "logging",
|
||||
"winston": "logging",
|
||||
"pino": "logging",
|
||||
}
|
||||
|
||||
def __init__(self, path: Path, analysis: dict[str, Any]):
|
||||
super().__init__(path)
|
||||
self.analysis = analysis
|
||||
|
||||
def detect(self) -> None:
|
||||
"""
|
||||
Detect external service integrations.
|
||||
|
||||
Detects: databases, cache, email, payments, storage, monitoring, etc.
|
||||
"""
|
||||
services = {
|
||||
"databases": [],
|
||||
"cache": [],
|
||||
"message_queues": [],
|
||||
"email": [],
|
||||
"payments": [],
|
||||
"storage": [],
|
||||
"auth_providers": [],
|
||||
"monitoring": [],
|
||||
}
|
||||
|
||||
# Get all dependencies
|
||||
all_deps = self._get_all_dependencies()
|
||||
|
||||
# Detect each service category
|
||||
self._detect_databases(all_deps, services["databases"])
|
||||
self._detect_cache(all_deps, services["cache"])
|
||||
self._detect_message_queues(all_deps, services["message_queues"])
|
||||
self._detect_email(all_deps, services["email"])
|
||||
self._detect_payments(all_deps, services["payments"])
|
||||
self._detect_storage(all_deps, services["storage"])
|
||||
self._detect_auth_providers(all_deps, services["auth_providers"])
|
||||
self._detect_monitoring(all_deps, services["monitoring"])
|
||||
|
||||
# Remove empty categories
|
||||
services = {k: v for k, v in services.items() if v}
|
||||
|
||||
if services:
|
||||
self.analysis["services"] = services
|
||||
|
||||
def _get_all_dependencies(self) -> set[str]:
|
||||
"""Extract all dependencies from Python and Node.js projects."""
|
||||
all_deps = set()
|
||||
|
||||
# Python dependencies
|
||||
if self._exists("requirements.txt"):
|
||||
content = self._read_file("requirements.txt")
|
||||
all_deps.update(re.findall(r"^([a-zA-Z0-9_-]+)", content, re.MULTILINE))
|
||||
|
||||
# Node.js dependencies
|
||||
pkg = self._read_json("package.json")
|
||||
if pkg:
|
||||
all_deps.update(pkg.get("dependencies", {}).keys())
|
||||
all_deps.update(pkg.get("devDependencies", {}).keys())
|
||||
|
||||
return all_deps
|
||||
|
||||
def _detect_databases(
|
||||
self, all_deps: set[str], databases: list[dict[str, str]]
|
||||
) -> None:
|
||||
"""Detect database clients."""
|
||||
for dep, db_type in self.DATABASE_INDICATORS.items():
|
||||
if dep in all_deps:
|
||||
databases.append({"type": db_type, "client": dep})
|
||||
|
||||
def _detect_cache(self, all_deps: set[str], cache: list[dict[str, str]]) -> None:
|
||||
"""Detect cache services."""
|
||||
for indicator in self.CACHE_INDICATORS:
|
||||
if indicator in all_deps:
|
||||
cache.append({"type": indicator})
|
||||
|
||||
def _detect_message_queues(
|
||||
self, all_deps: set[str], queues: list[dict[str, str]]
|
||||
) -> None:
|
||||
"""Detect message queue systems."""
|
||||
for dep, queue_type in self.QUEUE_INDICATORS.items():
|
||||
if dep in all_deps:
|
||||
queues.append({"type": queue_type, "client": dep})
|
||||
|
||||
def _detect_email(self, all_deps: set[str], email: list[dict[str, str]]) -> None:
|
||||
"""Detect email service providers."""
|
||||
for dep, email_type in self.EMAIL_INDICATORS.items():
|
||||
if dep in all_deps:
|
||||
email.append({"provider": email_type, "client": dep})
|
||||
|
||||
def _detect_payments(
|
||||
self, all_deps: set[str], payments: list[dict[str, str]]
|
||||
) -> None:
|
||||
"""Detect payment processors."""
|
||||
for dep, payment_type in self.PAYMENT_INDICATORS.items():
|
||||
if dep in all_deps:
|
||||
payments.append({"provider": payment_type, "client": dep})
|
||||
|
||||
def _detect_storage(
|
||||
self, all_deps: set[str], storage: list[dict[str, str]]
|
||||
) -> None:
|
||||
"""Detect storage services."""
|
||||
for dep, storage_type in self.STORAGE_INDICATORS.items():
|
||||
if dep in all_deps:
|
||||
storage.append({"provider": storage_type, "client": dep})
|
||||
|
||||
def _detect_auth_providers(
|
||||
self, all_deps: set[str], auth: list[dict[str, str]]
|
||||
) -> None:
|
||||
"""Detect authentication providers."""
|
||||
for dep, auth_type in self.AUTH_INDICATORS.items():
|
||||
if dep in all_deps:
|
||||
auth.append({"type": auth_type, "client": dep})
|
||||
|
||||
def _detect_monitoring(
|
||||
self, all_deps: set[str], monitoring: list[dict[str, str]]
|
||||
) -> None:
|
||||
"""Detect monitoring and observability tools."""
|
||||
for dep, monitoring_type in self.MONITORING_INDICATORS.items():
|
||||
if dep in all_deps:
|
||||
monitoring.append({"type": monitoring_type, "client": dep})
|
||||
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
Context Analyzer Module
|
||||
=======================
|
||||
|
||||
Orchestrates comprehensive project context analysis including:
|
||||
- Environment variables and configuration
|
||||
- External service integrations
|
||||
- Authentication patterns
|
||||
- Database migrations
|
||||
- Background jobs/task queues
|
||||
- API documentation
|
||||
- Monitoring and observability
|
||||
|
||||
This module delegates to specialized detectors for clean separation of concerns.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .base import BaseAnalyzer
|
||||
from .context import (
|
||||
ApiDocsDetector,
|
||||
AuthDetector,
|
||||
EnvironmentDetector,
|
||||
JobsDetector,
|
||||
MigrationsDetector,
|
||||
MonitoringDetector,
|
||||
ServicesDetector,
|
||||
)
|
||||
|
||||
|
||||
class ContextAnalyzer(BaseAnalyzer):
|
||||
"""Orchestrates project context and configuration analysis."""
|
||||
|
||||
def __init__(self, path: Path, analysis: dict[str, Any]):
|
||||
super().__init__(path)
|
||||
self.analysis = analysis
|
||||
|
||||
def detect_environment_variables(self) -> None:
|
||||
"""
|
||||
Discover all environment variables from multiple sources.
|
||||
|
||||
Delegates to EnvironmentDetector for actual detection logic.
|
||||
"""
|
||||
detector = EnvironmentDetector(self.path, self.analysis)
|
||||
detector.detect()
|
||||
|
||||
def detect_external_services(self) -> None:
|
||||
"""
|
||||
Detect external service integrations.
|
||||
|
||||
Delegates to ServicesDetector for actual detection logic.
|
||||
"""
|
||||
detector = ServicesDetector(self.path, self.analysis)
|
||||
detector.detect()
|
||||
|
||||
def detect_auth_patterns(self) -> None:
|
||||
"""
|
||||
Detect authentication and authorization patterns.
|
||||
|
||||
Delegates to AuthDetector for actual detection logic.
|
||||
"""
|
||||
detector = AuthDetector(self.path, self.analysis)
|
||||
detector.detect()
|
||||
|
||||
def detect_migrations(self) -> None:
|
||||
"""
|
||||
Detect database migration setup.
|
||||
|
||||
Delegates to MigrationsDetector for actual detection logic.
|
||||
"""
|
||||
detector = MigrationsDetector(self.path, self.analysis)
|
||||
detector.detect()
|
||||
|
||||
def detect_background_jobs(self) -> None:
|
||||
"""
|
||||
Detect background job/task queue systems.
|
||||
|
||||
Delegates to JobsDetector for actual detection logic.
|
||||
"""
|
||||
detector = JobsDetector(self.path, self.analysis)
|
||||
detector.detect()
|
||||
|
||||
def detect_api_documentation(self) -> None:
|
||||
"""
|
||||
Detect API documentation setup.
|
||||
|
||||
Delegates to ApiDocsDetector for actual detection logic.
|
||||
"""
|
||||
detector = ApiDocsDetector(self.path, self.analysis)
|
||||
detector.detect()
|
||||
|
||||
def detect_monitoring(self) -> None:
|
||||
"""
|
||||
Detect monitoring and observability setup.
|
||||
|
||||
Delegates to MonitoringDetector for actual detection logic.
|
||||
"""
|
||||
detector = MonitoringDetector(self.path, self.analysis)
|
||||
detector.detect()
|
||||
@@ -0,0 +1,316 @@
|
||||
"""
|
||||
Database Detector Module
|
||||
========================
|
||||
|
||||
Detects database models and schemas across different ORMs:
|
||||
- Python: SQLAlchemy, Django ORM
|
||||
- JavaScript/TypeScript: Prisma, TypeORM, Drizzle, Mongoose
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from .base import BaseAnalyzer
|
||||
|
||||
|
||||
class DatabaseDetector(BaseAnalyzer):
|
||||
"""Detects database models across multiple ORMs."""
|
||||
|
||||
def __init__(self, path: Path):
|
||||
super().__init__(path)
|
||||
|
||||
def detect_all_models(self) -> dict:
|
||||
"""Detect all database models across different ORMs."""
|
||||
models = {}
|
||||
|
||||
# Python SQLAlchemy
|
||||
models.update(self._detect_sqlalchemy_models())
|
||||
|
||||
# Python Django
|
||||
models.update(self._detect_django_models())
|
||||
|
||||
# Prisma schema
|
||||
models.update(self._detect_prisma_models())
|
||||
|
||||
# TypeORM entities
|
||||
models.update(self._detect_typeorm_models())
|
||||
|
||||
# Drizzle schema
|
||||
models.update(self._detect_drizzle_models())
|
||||
|
||||
# Mongoose models
|
||||
models.update(self._detect_mongoose_models())
|
||||
|
||||
return models
|
||||
|
||||
def _detect_sqlalchemy_models(self) -> dict:
|
||||
"""Detect SQLAlchemy models."""
|
||||
models = {}
|
||||
py_files = list(self.path.glob("**/*.py"))
|
||||
|
||||
for file_path in py_files:
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
# Find class definitions that inherit from Base or db.Model
|
||||
class_pattern = (
|
||||
r"class\s+(\w+)\([^)]*(?:Base|db\.Model|DeclarativeBase)[^)]*\):"
|
||||
)
|
||||
matches = re.finditer(class_pattern, content)
|
||||
|
||||
for match in matches:
|
||||
model_name = match.group(1)
|
||||
|
||||
# Extract table name if defined
|
||||
table_match = re.search(r'__tablename__\s*=\s*["\'](\w+)["\']', content)
|
||||
table_name = (
|
||||
table_match.group(1) if table_match else model_name.lower() + "s"
|
||||
)
|
||||
|
||||
# Extract columns
|
||||
fields = {}
|
||||
column_pattern = r"(\w+)\s*=\s*Column\((.*?)\)"
|
||||
column_matches = re.finditer(
|
||||
column_pattern, content[match.end() : match.end() + 2000]
|
||||
)
|
||||
|
||||
for col_match in column_matches:
|
||||
field_name = col_match.group(1)
|
||||
field_def = col_match.group(2)
|
||||
|
||||
# Detect field properties
|
||||
is_primary = "primary_key=True" in field_def
|
||||
is_unique = "unique=True" in field_def
|
||||
is_nullable = "nullable=False" not in field_def
|
||||
|
||||
# Extract type
|
||||
type_match = re.search(
|
||||
r"(Integer|String|Text|Boolean|DateTime|Float|JSON)", field_def
|
||||
)
|
||||
field_type = type_match.group(1) if type_match else "Unknown"
|
||||
|
||||
fields[field_name] = {
|
||||
"type": field_type,
|
||||
"primary_key": is_primary,
|
||||
"unique": is_unique,
|
||||
"nullable": is_nullable,
|
||||
}
|
||||
|
||||
if fields: # Only add if we found fields
|
||||
models[model_name] = {
|
||||
"table": table_name,
|
||||
"fields": fields,
|
||||
"file": str(file_path.relative_to(self.path)),
|
||||
"orm": "SQLAlchemy",
|
||||
}
|
||||
|
||||
return models
|
||||
|
||||
def _detect_django_models(self) -> dict:
|
||||
"""Detect Django models."""
|
||||
models = {}
|
||||
model_files = list(self.path.glob("**/models.py")) + list(
|
||||
self.path.glob("**/models/*.py")
|
||||
)
|
||||
|
||||
for file_path in model_files:
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
# Find class definitions that inherit from models.Model
|
||||
class_pattern = r"class\s+(\w+)\(models\.Model\):"
|
||||
matches = re.finditer(class_pattern, content)
|
||||
|
||||
for match in matches:
|
||||
model_name = match.group(1)
|
||||
table_name = model_name.lower()
|
||||
|
||||
# Extract fields
|
||||
fields = {}
|
||||
field_pattern = r"(\w+)\s*=\s*models\.(\w+Field)\((.*?)\)"
|
||||
field_matches = re.finditer(
|
||||
field_pattern, content[match.end() : match.end() + 2000]
|
||||
)
|
||||
|
||||
for field_match in field_matches:
|
||||
field_name = field_match.group(1)
|
||||
field_type = field_match.group(2)
|
||||
field_args = field_match.group(3)
|
||||
|
||||
fields[field_name] = {
|
||||
"type": field_type,
|
||||
"unique": "unique=True" in field_args,
|
||||
"nullable": "null=True" in field_args,
|
||||
}
|
||||
|
||||
if fields:
|
||||
models[model_name] = {
|
||||
"table": table_name,
|
||||
"fields": fields,
|
||||
"file": str(file_path.relative_to(self.path)),
|
||||
"orm": "Django",
|
||||
}
|
||||
|
||||
return models
|
||||
|
||||
def _detect_prisma_models(self) -> dict:
|
||||
"""Detect Prisma models from schema.prisma."""
|
||||
models = {}
|
||||
schema_file = self.path / "prisma" / "schema.prisma"
|
||||
|
||||
if not schema_file.exists():
|
||||
return models
|
||||
|
||||
try:
|
||||
content = schema_file.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return models
|
||||
|
||||
# Find model definitions
|
||||
model_pattern = r"model\s+(\w+)\s*\{([^}]+)\}"
|
||||
matches = re.finditer(model_pattern, content, re.MULTILINE)
|
||||
|
||||
for match in matches:
|
||||
model_name = match.group(1)
|
||||
model_body = match.group(2)
|
||||
|
||||
fields = {}
|
||||
# Parse fields: id Int @id @default(autoincrement())
|
||||
field_pattern = r"(\w+)\s+(\w+)([^/\n]*)"
|
||||
field_matches = re.finditer(field_pattern, model_body)
|
||||
|
||||
for field_match in field_matches:
|
||||
field_name = field_match.group(1)
|
||||
field_type = field_match.group(2)
|
||||
field_attrs = field_match.group(3)
|
||||
|
||||
fields[field_name] = {
|
||||
"type": field_type,
|
||||
"primary_key": "@id" in field_attrs,
|
||||
"unique": "@unique" in field_attrs,
|
||||
"nullable": "?" in field_type,
|
||||
}
|
||||
|
||||
if fields:
|
||||
models[model_name] = {
|
||||
"table": model_name.lower(),
|
||||
"fields": fields,
|
||||
"file": "prisma/schema.prisma",
|
||||
"orm": "Prisma",
|
||||
}
|
||||
|
||||
return models
|
||||
|
||||
def _detect_typeorm_models(self) -> dict:
|
||||
"""Detect TypeORM entities."""
|
||||
models = {}
|
||||
ts_files = list(self.path.glob("**/*.entity.ts")) + list(
|
||||
self.path.glob("**/entities/*.ts")
|
||||
)
|
||||
|
||||
for file_path in ts_files:
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
# Find @Entity() class declarations
|
||||
entity_pattern = r"@Entity\([^)]*\)\s*(?:export\s+)?class\s+(\w+)"
|
||||
matches = re.finditer(entity_pattern, content)
|
||||
|
||||
for match in matches:
|
||||
model_name = match.group(1)
|
||||
|
||||
# Extract columns
|
||||
fields = {}
|
||||
column_pattern = (
|
||||
r"@(PrimaryGeneratedColumn|Column)\(([^)]*)\)\s+(\w+):\s*(\w+)"
|
||||
)
|
||||
column_matches = re.finditer(column_pattern, content)
|
||||
|
||||
for col_match in column_matches:
|
||||
decorator = col_match.group(1)
|
||||
options = col_match.group(2)
|
||||
field_name = col_match.group(3)
|
||||
field_type = col_match.group(4)
|
||||
|
||||
fields[field_name] = {
|
||||
"type": field_type,
|
||||
"primary_key": decorator == "PrimaryGeneratedColumn",
|
||||
"unique": "unique: true" in options,
|
||||
}
|
||||
|
||||
if fields:
|
||||
models[model_name] = {
|
||||
"table": model_name.lower(),
|
||||
"fields": fields,
|
||||
"file": str(file_path.relative_to(self.path)),
|
||||
"orm": "TypeORM",
|
||||
}
|
||||
|
||||
return models
|
||||
|
||||
def _detect_drizzle_models(self) -> dict:
|
||||
"""Detect Drizzle ORM schemas."""
|
||||
models = {}
|
||||
schema_files = list(self.path.glob("**/schema.ts")) + list(
|
||||
self.path.glob("**/db/schema.ts")
|
||||
)
|
||||
|
||||
for file_path in schema_files:
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
# Find table definitions: export const users = pgTable('users', {...})
|
||||
table_pattern = r'export\s+const\s+(\w+)\s*=\s*(?:pg|mysql|sqlite)Table\(["\'](\w+)["\']'
|
||||
matches = re.finditer(table_pattern, content)
|
||||
|
||||
for match in matches:
|
||||
const_name = match.group(1)
|
||||
table_name = match.group(2)
|
||||
|
||||
models[const_name] = {
|
||||
"table": table_name,
|
||||
"fields": {}, # Would need more parsing for fields
|
||||
"file": str(file_path.relative_to(self.path)),
|
||||
"orm": "Drizzle",
|
||||
}
|
||||
|
||||
return models
|
||||
|
||||
def _detect_mongoose_models(self) -> dict:
|
||||
"""Detect Mongoose models."""
|
||||
models = {}
|
||||
model_files = list(self.path.glob("**/models/*.js")) + list(
|
||||
self.path.glob("**/models/*.ts")
|
||||
)
|
||||
|
||||
for file_path in model_files:
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
# Find mongoose.model() or new Schema()
|
||||
model_pattern = r'mongoose\.model\(["\'](\w+)["\']'
|
||||
matches = re.finditer(model_pattern, content)
|
||||
|
||||
for match in matches:
|
||||
model_name = match.group(1)
|
||||
|
||||
models[model_name] = {
|
||||
"table": model_name.lower(),
|
||||
"fields": {},
|
||||
"file": str(file_path.relative_to(self.path)),
|
||||
"orm": "Mongoose",
|
||||
}
|
||||
|
||||
return models
|
||||
@@ -0,0 +1,413 @@
|
||||
"""
|
||||
Framework Analyzer Module
|
||||
=========================
|
||||
|
||||
Detects programming languages, frameworks, and related technologies across different ecosystems.
|
||||
Supports Python, Node.js/TypeScript, Go, Rust, and Ruby frameworks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .base import BaseAnalyzer
|
||||
|
||||
|
||||
class FrameworkAnalyzer(BaseAnalyzer):
|
||||
"""Analyzes and detects programming languages and frameworks."""
|
||||
|
||||
def __init__(self, path: Path, analysis: dict[str, Any]):
|
||||
super().__init__(path)
|
||||
self.analysis = analysis
|
||||
|
||||
def detect_language_and_framework(self) -> None:
|
||||
"""Detect primary language and framework."""
|
||||
# Python detection
|
||||
if self._exists("requirements.txt"):
|
||||
self.analysis["language"] = "Python"
|
||||
self.analysis["package_manager"] = "pip"
|
||||
deps = self._read_file("requirements.txt")
|
||||
self._detect_python_framework(deps)
|
||||
|
||||
elif self._exists("pyproject.toml"):
|
||||
self.analysis["language"] = "Python"
|
||||
content = self._read_file("pyproject.toml")
|
||||
if "[tool.poetry]" in content:
|
||||
self.analysis["package_manager"] = "poetry"
|
||||
elif "[tool.uv]" in content:
|
||||
self.analysis["package_manager"] = "uv"
|
||||
else:
|
||||
self.analysis["package_manager"] = "pip"
|
||||
self._detect_python_framework(content)
|
||||
|
||||
elif self._exists("Pipfile"):
|
||||
self.analysis["language"] = "Python"
|
||||
self.analysis["package_manager"] = "pipenv"
|
||||
content = self._read_file("Pipfile")
|
||||
self._detect_python_framework(content)
|
||||
|
||||
# Node.js/TypeScript detection
|
||||
elif self._exists("package.json"):
|
||||
pkg = self._read_json("package.json")
|
||||
if pkg:
|
||||
# Check if TypeScript
|
||||
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
|
||||
if "typescript" in deps:
|
||||
self.analysis["language"] = "TypeScript"
|
||||
else:
|
||||
self.analysis["language"] = "JavaScript"
|
||||
|
||||
self.analysis["package_manager"] = self._detect_node_package_manager()
|
||||
self._detect_node_framework(pkg)
|
||||
|
||||
# Go detection
|
||||
elif self._exists("go.mod"):
|
||||
self.analysis["language"] = "Go"
|
||||
self.analysis["package_manager"] = "go mod"
|
||||
content = self._read_file("go.mod")
|
||||
self._detect_go_framework(content)
|
||||
|
||||
# Rust detection
|
||||
elif self._exists("Cargo.toml"):
|
||||
self.analysis["language"] = "Rust"
|
||||
self.analysis["package_manager"] = "cargo"
|
||||
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"
|
||||
self.analysis["package_manager"] = "bundler"
|
||||
content = self._read_file("Gemfile")
|
||||
self._detect_ruby_framework(content)
|
||||
|
||||
def _detect_python_framework(self, content: str) -> None:
|
||||
"""Detect Python framework."""
|
||||
from .port_detector import PortDetector
|
||||
|
||||
content_lower = content.lower()
|
||||
|
||||
# Web frameworks (with conventional defaults)
|
||||
frameworks = {
|
||||
"fastapi": {"name": "FastAPI", "type": "backend", "port": 8000},
|
||||
"flask": {"name": "Flask", "type": "backend", "port": 5000},
|
||||
"django": {"name": "Django", "type": "backend", "port": 8000},
|
||||
"starlette": {"name": "Starlette", "type": "backend", "port": 8000},
|
||||
"litestar": {"name": "Litestar", "type": "backend", "port": 8000},
|
||||
}
|
||||
|
||||
for key, info in frameworks.items():
|
||||
if key in content_lower:
|
||||
self.analysis["framework"] = info["name"]
|
||||
self.analysis["type"] = info["type"]
|
||||
# Try to detect actual port, fall back to default
|
||||
port_detector = PortDetector(self.path, self.analysis)
|
||||
detected_port = port_detector.detect_port_from_sources(info["port"])
|
||||
self.analysis["default_port"] = detected_port
|
||||
break
|
||||
|
||||
# Task queues
|
||||
if "celery" in content_lower:
|
||||
self.analysis["task_queue"] = "Celery"
|
||||
if not self.analysis.get("type"):
|
||||
self.analysis["type"] = "worker"
|
||||
elif "dramatiq" in content_lower:
|
||||
self.analysis["task_queue"] = "Dramatiq"
|
||||
elif "huey" in content_lower:
|
||||
self.analysis["task_queue"] = "Huey"
|
||||
|
||||
# ORM
|
||||
if "sqlalchemy" in content_lower:
|
||||
self.analysis["orm"] = "SQLAlchemy"
|
||||
elif "tortoise" in content_lower:
|
||||
self.analysis["orm"] = "Tortoise ORM"
|
||||
elif "prisma" in content_lower:
|
||||
self.analysis["orm"] = "Prisma"
|
||||
|
||||
def _detect_node_framework(self, pkg: dict) -> None:
|
||||
"""Detect Node.js/TypeScript framework."""
|
||||
from .port_detector import PortDetector
|
||||
|
||||
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
|
||||
deps_lower = {k.lower(): k for k in deps.keys()}
|
||||
|
||||
# Frontend frameworks
|
||||
frontend_frameworks = {
|
||||
"next": {"name": "Next.js", "type": "frontend", "port": 3000},
|
||||
"nuxt": {"name": "Nuxt", "type": "frontend", "port": 3000},
|
||||
"react": {"name": "React", "type": "frontend", "port": 3000},
|
||||
"vue": {"name": "Vue", "type": "frontend", "port": 5173},
|
||||
"svelte": {"name": "Svelte", "type": "frontend", "port": 5173},
|
||||
"@sveltejs/kit": {"name": "SvelteKit", "type": "frontend", "port": 5173},
|
||||
"angular": {"name": "Angular", "type": "frontend", "port": 4200},
|
||||
"@angular/core": {"name": "Angular", "type": "frontend", "port": 4200},
|
||||
"solid-js": {"name": "SolidJS", "type": "frontend", "port": 3000},
|
||||
"astro": {"name": "Astro", "type": "frontend", "port": 4321},
|
||||
}
|
||||
|
||||
# Backend frameworks
|
||||
backend_frameworks = {
|
||||
"express": {"name": "Express", "type": "backend", "port": 3000},
|
||||
"fastify": {"name": "Fastify", "type": "backend", "port": 3000},
|
||||
"koa": {"name": "Koa", "type": "backend", "port": 3000},
|
||||
"hono": {"name": "Hono", "type": "backend", "port": 3000},
|
||||
"elysia": {"name": "Elysia", "type": "backend", "port": 3000},
|
||||
"@nestjs/core": {"name": "NestJS", "type": "backend", "port": 3000},
|
||||
}
|
||||
|
||||
port_detector = PortDetector(self.path, self.analysis)
|
||||
|
||||
# Check frontend first (Next.js includes React, etc.)
|
||||
for key, info in frontend_frameworks.items():
|
||||
if key in deps_lower:
|
||||
self.analysis["framework"] = info["name"]
|
||||
self.analysis["type"] = info["type"]
|
||||
detected_port = port_detector.detect_port_from_sources(info["port"])
|
||||
self.analysis["default_port"] = detected_port
|
||||
break
|
||||
|
||||
# If no frontend, check backend
|
||||
if not self.analysis.get("framework"):
|
||||
for key, info in backend_frameworks.items():
|
||||
if key in deps_lower:
|
||||
self.analysis["framework"] = info["name"]
|
||||
self.analysis["type"] = info["type"]
|
||||
detected_port = port_detector.detect_port_from_sources(info["port"])
|
||||
self.analysis["default_port"] = detected_port
|
||||
break
|
||||
|
||||
# Build tool
|
||||
if "vite" in deps_lower:
|
||||
self.analysis["build_tool"] = "Vite"
|
||||
if not self.analysis.get("default_port"):
|
||||
detected_port = port_detector.detect_port_from_sources(5173)
|
||||
self.analysis["default_port"] = detected_port
|
||||
elif "webpack" in deps_lower:
|
||||
self.analysis["build_tool"] = "Webpack"
|
||||
elif "esbuild" in deps_lower:
|
||||
self.analysis["build_tool"] = "esbuild"
|
||||
elif "turbopack" in deps_lower:
|
||||
self.analysis["build_tool"] = "Turbopack"
|
||||
|
||||
# Styling
|
||||
if "tailwindcss" in deps_lower:
|
||||
self.analysis["styling"] = "Tailwind CSS"
|
||||
elif "styled-components" in deps_lower:
|
||||
self.analysis["styling"] = "styled-components"
|
||||
elif "@emotion/react" in deps_lower:
|
||||
self.analysis["styling"] = "Emotion"
|
||||
|
||||
# State management
|
||||
if "zustand" in deps_lower:
|
||||
self.analysis["state_management"] = "Zustand"
|
||||
elif "@reduxjs/toolkit" in deps_lower or "redux" in deps_lower:
|
||||
self.analysis["state_management"] = "Redux"
|
||||
elif "jotai" in deps_lower:
|
||||
self.analysis["state_management"] = "Jotai"
|
||||
elif "pinia" in deps_lower:
|
||||
self.analysis["state_management"] = "Pinia"
|
||||
|
||||
# Task queues
|
||||
if "bullmq" in deps_lower or "bull" in deps_lower:
|
||||
self.analysis["task_queue"] = "BullMQ"
|
||||
if not self.analysis.get("type"):
|
||||
self.analysis["type"] = "worker"
|
||||
|
||||
# ORM
|
||||
if "@prisma/client" in deps_lower or "prisma" in deps_lower:
|
||||
self.analysis["orm"] = "Prisma"
|
||||
elif "typeorm" in deps_lower:
|
||||
self.analysis["orm"] = "TypeORM"
|
||||
elif "drizzle-orm" in deps_lower:
|
||||
self.analysis["orm"] = "Drizzle"
|
||||
elif "mongoose" in deps_lower:
|
||||
self.analysis["orm"] = "Mongoose"
|
||||
|
||||
# Scripts
|
||||
scripts = pkg.get("scripts", {})
|
||||
if "dev" in scripts:
|
||||
self.analysis["dev_command"] = "npm run dev"
|
||||
elif "start" in scripts:
|
||||
self.analysis["dev_command"] = "npm run start"
|
||||
|
||||
def _detect_go_framework(self, content: str) -> None:
|
||||
"""Detect Go framework."""
|
||||
from .port_detector import PortDetector
|
||||
|
||||
frameworks = {
|
||||
"gin-gonic/gin": {"name": "Gin", "port": 8080},
|
||||
"labstack/echo": {"name": "Echo", "port": 8080},
|
||||
"gofiber/fiber": {"name": "Fiber", "port": 3000},
|
||||
"go-chi/chi": {"name": "Chi", "port": 8080},
|
||||
}
|
||||
|
||||
for key, info in frameworks.items():
|
||||
if key in content:
|
||||
self.analysis["framework"] = info["name"]
|
||||
self.analysis["type"] = "backend"
|
||||
port_detector = PortDetector(self.path, self.analysis)
|
||||
detected_port = port_detector.detect_port_from_sources(info["port"])
|
||||
self.analysis["default_port"] = detected_port
|
||||
break
|
||||
|
||||
def _detect_rust_framework(self, content: str) -> None:
|
||||
"""Detect Rust framework."""
|
||||
from .port_detector import PortDetector
|
||||
|
||||
frameworks = {
|
||||
"actix-web": {"name": "Actix Web", "port": 8080},
|
||||
"axum": {"name": "Axum", "port": 3000},
|
||||
"rocket": {"name": "Rocket", "port": 8000},
|
||||
}
|
||||
|
||||
for key, info in frameworks.items():
|
||||
if key in content:
|
||||
self.analysis["framework"] = info["name"]
|
||||
self.analysis["type"] = "backend"
|
||||
port_detector = PortDetector(self.path, self.analysis)
|
||||
detected_port = port_detector.detect_port_from_sources(info["port"])
|
||||
self.analysis["default_port"] = detected_port
|
||||
break
|
||||
|
||||
def _detect_ruby_framework(self, content: str) -> None:
|
||||
"""Detect Ruby framework."""
|
||||
from .port_detector import PortDetector
|
||||
|
||||
port_detector = PortDetector(self.path, self.analysis)
|
||||
|
||||
if "rails" in content.lower():
|
||||
self.analysis["framework"] = "Ruby on Rails"
|
||||
self.analysis["type"] = "backend"
|
||||
detected_port = port_detector.detect_port_from_sources(3000)
|
||||
self.analysis["default_port"] = detected_port
|
||||
elif "sinatra" in content.lower():
|
||||
self.analysis["framework"] = "Sinatra"
|
||||
self.analysis["type"] = "backend"
|
||||
detected_port = port_detector.detect_port_from_sources(4567)
|
||||
self.analysis["default_port"] = detected_port
|
||||
|
||||
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"):
|
||||
return "bun"
|
||||
return "npm"
|
||||
@@ -0,0 +1,337 @@
|
||||
"""
|
||||
Port Detector Module
|
||||
====================
|
||||
|
||||
Detects application ports from multiple sources including entry points,
|
||||
environment files, Docker Compose, configuration files, and scripts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .base import BaseAnalyzer
|
||||
|
||||
|
||||
class PortDetector(BaseAnalyzer):
|
||||
"""Detects application ports from various configuration sources."""
|
||||
|
||||
def __init__(self, path: Path, analysis: dict[str, Any]):
|
||||
super().__init__(path)
|
||||
self.analysis = analysis
|
||||
|
||||
def detect_port_from_sources(self, default_port: int) -> int:
|
||||
"""
|
||||
Robustly detect the actual port by checking multiple sources.
|
||||
|
||||
Checks in order of priority:
|
||||
1. Entry point files (app.py, main.py, etc.) for uvicorn.run(), app.run(), etc.
|
||||
2. Environment files (.env, .env.local, .env.development)
|
||||
3. Docker Compose port mappings
|
||||
4. Configuration files (config.py, settings.py, etc.)
|
||||
5. Package.json scripts (for Node.js)
|
||||
6. Makefile/shell scripts
|
||||
7. Falls back to default_port if nothing found
|
||||
|
||||
Args:
|
||||
default_port: The framework's conventional default port
|
||||
|
||||
Returns:
|
||||
Detected port or default_port if not found
|
||||
"""
|
||||
# 1. Check entry point files for explicit port definitions
|
||||
port = self._detect_port_in_entry_points()
|
||||
if port:
|
||||
return port
|
||||
|
||||
# 2. Check environment files
|
||||
port = self._detect_port_in_env_files()
|
||||
if port:
|
||||
return port
|
||||
|
||||
# 3. Check Docker Compose
|
||||
port = self._detect_port_in_docker_compose()
|
||||
if port:
|
||||
return port
|
||||
|
||||
# 4. Check configuration files
|
||||
port = self._detect_port_in_config_files()
|
||||
if port:
|
||||
return port
|
||||
|
||||
# 5. Check package.json scripts (for Node.js)
|
||||
if self.analysis.get("language") in ["JavaScript", "TypeScript"]:
|
||||
port = self._detect_port_in_package_scripts()
|
||||
if port:
|
||||
return port
|
||||
|
||||
# 6. Check Makefile/shell scripts
|
||||
port = self._detect_port_in_scripts()
|
||||
if port:
|
||||
return port
|
||||
|
||||
# Fall back to default
|
||||
return default_port
|
||||
|
||||
def _detect_port_in_entry_points(self) -> int | None:
|
||||
"""Detect port in entry point files."""
|
||||
entry_files = [
|
||||
"app.py",
|
||||
"main.py",
|
||||
"server.py",
|
||||
"__main__.py",
|
||||
"asgi.py",
|
||||
"wsgi.py",
|
||||
"src/app.py",
|
||||
"src/main.py",
|
||||
"src/server.py",
|
||||
"index.js",
|
||||
"index.ts",
|
||||
"server.js",
|
||||
"server.ts",
|
||||
"main.js",
|
||||
"main.ts",
|
||||
"src/index.js",
|
||||
"src/index.ts",
|
||||
"src/server.js",
|
||||
"src/server.ts",
|
||||
"main.go",
|
||||
"cmd/main.go",
|
||||
"src/main.rs",
|
||||
]
|
||||
|
||||
# Patterns to search for ports
|
||||
patterns = [
|
||||
# Python: uvicorn.run(app, host="0.0.0.0", port=8050)
|
||||
r"uvicorn\.run\([^)]*port\s*=\s*(\d+)",
|
||||
# Python: app.run(port=8050, host="0.0.0.0")
|
||||
r"\.run\([^)]*port\s*=\s*(\d+)",
|
||||
# Python: port = 8050 or PORT = 8050
|
||||
r"^\s*[Pp][Oo][Rr][Tt]\s*=\s*(\d+)",
|
||||
# Python: os.getenv("PORT", 8050) or os.environ.get("PORT", 8050)
|
||||
r'getenv\(\s*["\']PORT["\']\s*,\s*(\d+)',
|
||||
r'environ\.get\(\s*["\']PORT["\']\s*,\s*(\d+)',
|
||||
# JavaScript/TypeScript: app.listen(8050)
|
||||
r"\.listen\(\s*(\d+)",
|
||||
# JavaScript/TypeScript: const PORT = 8050 or let port = 8050
|
||||
r"(?:const|let|var)\s+[Pp][Oo][Rr][Tt]\s*=\s*(\d+)",
|
||||
# JavaScript/TypeScript: process.env.PORT || 8050
|
||||
r"process\.env\.PORT\s*\|\|\s*(\d+)",
|
||||
# JavaScript/TypeScript: Number(process.env.PORT) || 8050
|
||||
r"Number\(process\.env\.PORT\)\s*\|\|\s*(\d+)",
|
||||
# Go: :8050 or ":8050"
|
||||
r':\s*(\d+)(?:["\s]|$)',
|
||||
# Rust: .bind("127.0.0.1:8050")
|
||||
r'\.bind\(["\'][\d.]+:(\d+)',
|
||||
]
|
||||
|
||||
for entry_file in entry_files:
|
||||
content = self._read_file(entry_file)
|
||||
if not content:
|
||||
continue
|
||||
|
||||
for pattern in patterns:
|
||||
matches = re.findall(pattern, content, re.MULTILINE)
|
||||
if matches:
|
||||
# Return the first valid port found
|
||||
for match in matches:
|
||||
try:
|
||||
port = int(match)
|
||||
if 1000 <= port <= 65535: # Valid port range
|
||||
return port
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def _detect_port_in_env_files(self) -> int | None:
|
||||
"""Detect port in environment files."""
|
||||
env_files = [
|
||||
".env",
|
||||
".env.local",
|
||||
".env.development",
|
||||
".env.dev",
|
||||
"config/.env",
|
||||
"config/.env.local",
|
||||
"../.env",
|
||||
]
|
||||
|
||||
patterns = [
|
||||
r"^\s*PORT\s*=\s*(\d+)",
|
||||
r"^\s*API_PORT\s*=\s*(\d+)",
|
||||
r"^\s*SERVER_PORT\s*=\s*(\d+)",
|
||||
r"^\s*APP_PORT\s*=\s*(\d+)",
|
||||
]
|
||||
|
||||
for env_file in env_files:
|
||||
content = self._read_file(env_file)
|
||||
if not content:
|
||||
continue
|
||||
|
||||
for pattern in patterns:
|
||||
matches = re.findall(pattern, content, re.MULTILINE)
|
||||
if matches:
|
||||
try:
|
||||
port = int(matches[0])
|
||||
if 1000 <= port <= 65535:
|
||||
return port
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def _detect_port_in_docker_compose(self) -> int | None:
|
||||
"""Detect port from docker-compose.yml mappings."""
|
||||
compose_files = [
|
||||
"docker-compose.yml",
|
||||
"docker-compose.yaml",
|
||||
"../docker-compose.yml",
|
||||
"../docker-compose.yaml",
|
||||
]
|
||||
|
||||
service_name = self.path.name.lower()
|
||||
|
||||
for compose_file in compose_files:
|
||||
content = self._read_file(compose_file)
|
||||
if not content:
|
||||
continue
|
||||
|
||||
# Look for port mappings like "8050:8000" or "8050:8050"
|
||||
# Match the service name if possible
|
||||
pattern = r'^\s*-\s*["\']?(\d+):\d+["\']?'
|
||||
|
||||
in_service = False
|
||||
in_ports = False
|
||||
|
||||
for line in content.split("\n"):
|
||||
# Check if we're in the right service block
|
||||
if re.match(rf"^\s*{re.escape(service_name)}\s*:", line):
|
||||
in_service = True
|
||||
continue
|
||||
|
||||
# Check if we hit another service
|
||||
if (
|
||||
in_service
|
||||
and re.match(r"^\s*\w+\s*:", line)
|
||||
and "ports:" not in line
|
||||
):
|
||||
in_service = False
|
||||
in_ports = False
|
||||
continue
|
||||
|
||||
# Check if we're in the ports section
|
||||
if in_service and "ports:" in line:
|
||||
in_ports = True
|
||||
continue
|
||||
|
||||
# Extract port mapping
|
||||
if in_ports:
|
||||
match = re.match(pattern, line)
|
||||
if match:
|
||||
try:
|
||||
port = int(match.group(1))
|
||||
if 1000 <= port <= 65535:
|
||||
return port
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def _detect_port_in_config_files(self) -> int | None:
|
||||
"""Detect port in configuration files."""
|
||||
config_files = [
|
||||
"config.py",
|
||||
"settings.py",
|
||||
"config/settings.py",
|
||||
"src/config.py",
|
||||
"config.json",
|
||||
"settings.json",
|
||||
"config/config.json",
|
||||
"config.toml",
|
||||
"settings.toml",
|
||||
]
|
||||
|
||||
for config_file in config_files:
|
||||
content = self._read_file(config_file)
|
||||
if not content:
|
||||
continue
|
||||
|
||||
# Python config patterns
|
||||
patterns = [
|
||||
r"[Pp][Oo][Rr][Tt]\s*=\s*(\d+)",
|
||||
r'["\']port["\']\s*:\s*(\d+)',
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
matches = re.findall(pattern, content)
|
||||
if matches:
|
||||
try:
|
||||
port = int(matches[0])
|
||||
if 1000 <= port <= 65535:
|
||||
return port
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def _detect_port_in_package_scripts(self) -> int | None:
|
||||
"""Detect port in package.json scripts."""
|
||||
pkg = self._read_json("package.json")
|
||||
if not pkg:
|
||||
return None
|
||||
|
||||
scripts = pkg.get("scripts", {})
|
||||
|
||||
# Look for port specifications in scripts
|
||||
# e.g., "dev": "next dev -p 3001"
|
||||
# e.g., "start": "node server.js --port 8050"
|
||||
patterns = [
|
||||
r"-p\s+(\d+)",
|
||||
r"--port\s+(\d+)",
|
||||
r"PORT=(\d+)",
|
||||
]
|
||||
|
||||
for script in scripts.values():
|
||||
if not isinstance(script, str):
|
||||
continue
|
||||
|
||||
for pattern in patterns:
|
||||
matches = re.findall(pattern, script)
|
||||
if matches:
|
||||
try:
|
||||
port = int(matches[0])
|
||||
if 1000 <= port <= 65535:
|
||||
return port
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
def _detect_port_in_scripts(self) -> int | None:
|
||||
"""Detect port in Makefile or shell scripts."""
|
||||
script_files = ["Makefile", "start.sh", "run.sh", "dev.sh"]
|
||||
|
||||
patterns = [
|
||||
r"PORT=(\d+)",
|
||||
r"--port\s+(\d+)",
|
||||
r"-p\s+(\d+)",
|
||||
]
|
||||
|
||||
for script_file in script_files:
|
||||
content = self._read_file(script_file)
|
||||
if not content:
|
||||
continue
|
||||
|
||||
for pattern in patterns:
|
||||
matches = re.findall(pattern, content)
|
||||
if matches:
|
||||
try:
|
||||
port = int(matches[0])
|
||||
if 1000 <= port <= 65535:
|
||||
return port
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return None
|
||||
@@ -0,0 +1,292 @@
|
||||
"""
|
||||
Project Analyzer Module
|
||||
=======================
|
||||
|
||||
Analyzes entire projects, detecting monorepo structures, services, infrastructure, and conventions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .base import SERVICE_INDICATORS, SERVICE_ROOT_FILES, SKIP_DIRS
|
||||
from .service_analyzer import ServiceAnalyzer
|
||||
|
||||
|
||||
class ProjectAnalyzer:
|
||||
"""Analyzes an entire project, detecting monorepo structure and all services."""
|
||||
|
||||
def __init__(self, project_dir: Path):
|
||||
self.project_dir = project_dir.resolve()
|
||||
self.index = {
|
||||
"project_root": str(self.project_dir),
|
||||
"project_type": "single", # or "monorepo"
|
||||
"services": {},
|
||||
"infrastructure": {},
|
||||
"conventions": {},
|
||||
}
|
||||
|
||||
def analyze(self) -> dict[str, Any]:
|
||||
"""Run full project analysis."""
|
||||
self._detect_project_type()
|
||||
self._find_and_analyze_services()
|
||||
self._analyze_infrastructure()
|
||||
self._detect_conventions()
|
||||
self._map_dependencies()
|
||||
return self.index
|
||||
|
||||
def _detect_project_type(self) -> None:
|
||||
"""Detect if this is a monorepo or single project."""
|
||||
monorepo_indicators = [
|
||||
"pnpm-workspace.yaml",
|
||||
"lerna.json",
|
||||
"nx.json",
|
||||
"turbo.json",
|
||||
"rush.json",
|
||||
]
|
||||
|
||||
for indicator in monorepo_indicators:
|
||||
if (self.project_dir / indicator).exists():
|
||||
self.index["project_type"] = "monorepo"
|
||||
self.index["monorepo_tool"] = indicator.replace(".json", "").replace(
|
||||
".yaml", ""
|
||||
)
|
||||
return
|
||||
|
||||
# Check for packages/apps directories
|
||||
if (self.project_dir / "packages").exists() or (
|
||||
self.project_dir / "apps"
|
||||
).exists():
|
||||
self.index["project_type"] = "monorepo"
|
||||
return
|
||||
|
||||
# Check for multiple service directories
|
||||
service_dirs_found = 0
|
||||
for item in self.project_dir.iterdir():
|
||||
if not item.is_dir():
|
||||
continue
|
||||
if item.name in SKIP_DIRS or item.name.startswith("."):
|
||||
continue
|
||||
|
||||
# Check if this directory has service root files
|
||||
if any((item / f).exists() for f in SERVICE_ROOT_FILES):
|
||||
service_dirs_found += 1
|
||||
|
||||
# If we have 2+ directories with service root files, it's likely a monorepo
|
||||
if service_dirs_found >= 2:
|
||||
self.index["project_type"] = "monorepo"
|
||||
|
||||
def _find_and_analyze_services(self) -> None:
|
||||
"""Find all services and analyze each."""
|
||||
services = {}
|
||||
|
||||
if self.index["project_type"] == "monorepo":
|
||||
# Look for services in common locations
|
||||
service_locations = [
|
||||
self.project_dir,
|
||||
self.project_dir / "packages",
|
||||
self.project_dir / "apps",
|
||||
self.project_dir / "services",
|
||||
]
|
||||
|
||||
for location in service_locations:
|
||||
if not location.exists():
|
||||
continue
|
||||
|
||||
for item in location.iterdir():
|
||||
if not item.is_dir():
|
||||
continue
|
||||
if item.name in SKIP_DIRS:
|
||||
continue
|
||||
if item.name.startswith("."):
|
||||
continue
|
||||
|
||||
# Check if this looks like a service
|
||||
has_root_file = any((item / f).exists() for f in SERVICE_ROOT_FILES)
|
||||
is_service_name = item.name.lower() in SERVICE_INDICATORS
|
||||
|
||||
if has_root_file or (
|
||||
location == self.project_dir and is_service_name
|
||||
):
|
||||
analyzer = ServiceAnalyzer(item, item.name)
|
||||
service_info = analyzer.analyze()
|
||||
if service_info.get(
|
||||
"language"
|
||||
): # Only include if we detected something
|
||||
services[item.name] = service_info
|
||||
else:
|
||||
# Single project - analyze root
|
||||
analyzer = ServiceAnalyzer(self.project_dir, "main")
|
||||
service_info = analyzer.analyze()
|
||||
if service_info.get("language"):
|
||||
services["main"] = service_info
|
||||
|
||||
self.index["services"] = services
|
||||
|
||||
def _analyze_infrastructure(self) -> None:
|
||||
"""Analyze infrastructure configuration."""
|
||||
infra = {}
|
||||
|
||||
# Docker
|
||||
if (self.project_dir / "docker-compose.yml").exists():
|
||||
infra["docker_compose"] = "docker-compose.yml"
|
||||
compose_content = self._read_file("docker-compose.yml")
|
||||
infra["docker_services"] = self._parse_compose_services(compose_content)
|
||||
elif (self.project_dir / "docker-compose.yaml").exists():
|
||||
infra["docker_compose"] = "docker-compose.yaml"
|
||||
compose_content = self._read_file("docker-compose.yaml")
|
||||
infra["docker_services"] = self._parse_compose_services(compose_content)
|
||||
|
||||
if (self.project_dir / "Dockerfile").exists():
|
||||
infra["dockerfile"] = "Dockerfile"
|
||||
|
||||
# Docker directory
|
||||
docker_dir = self.project_dir / "docker"
|
||||
if docker_dir.exists():
|
||||
dockerfiles = list(docker_dir.glob("Dockerfile*")) + list(
|
||||
docker_dir.glob("*.Dockerfile")
|
||||
)
|
||||
if dockerfiles:
|
||||
infra["docker_directory"] = "docker/"
|
||||
infra["dockerfiles"] = [
|
||||
str(f.relative_to(self.project_dir)) for f in dockerfiles
|
||||
]
|
||||
|
||||
# CI/CD
|
||||
if (self.project_dir / ".github" / "workflows").exists():
|
||||
infra["ci"] = "GitHub Actions"
|
||||
workflows = list((self.project_dir / ".github" / "workflows").glob("*.yml"))
|
||||
infra["ci_workflows"] = [f.name for f in workflows]
|
||||
elif (self.project_dir / ".gitlab-ci.yml").exists():
|
||||
infra["ci"] = "GitLab CI"
|
||||
elif (self.project_dir / ".circleci").exists():
|
||||
infra["ci"] = "CircleCI"
|
||||
|
||||
# Deployment
|
||||
deployment_files = {
|
||||
"vercel.json": "Vercel",
|
||||
"netlify.toml": "Netlify",
|
||||
"fly.toml": "Fly.io",
|
||||
"render.yaml": "Render",
|
||||
"railway.json": "Railway",
|
||||
"Procfile": "Heroku",
|
||||
"app.yaml": "Google App Engine",
|
||||
"serverless.yml": "Serverless Framework",
|
||||
}
|
||||
|
||||
for file, platform in deployment_files.items():
|
||||
if (self.project_dir / file).exists():
|
||||
infra["deployment"] = platform
|
||||
break
|
||||
|
||||
self.index["infrastructure"] = infra
|
||||
|
||||
def _parse_compose_services(self, content: str) -> list[str]:
|
||||
"""Extract service names from docker-compose content."""
|
||||
services = []
|
||||
in_services = False
|
||||
for line in content.split("\n"):
|
||||
if line.strip() == "services:":
|
||||
in_services = True
|
||||
continue
|
||||
if in_services:
|
||||
# Service names are at 2-space indent
|
||||
if (
|
||||
line.startswith(" ")
|
||||
and not line.startswith(" ")
|
||||
and line.strip().endswith(":")
|
||||
):
|
||||
service_name = line.strip().rstrip(":")
|
||||
services.append(service_name)
|
||||
elif line and not line.startswith(" "):
|
||||
break # End of services section
|
||||
return services
|
||||
|
||||
def _detect_conventions(self) -> None:
|
||||
"""Detect project-wide conventions."""
|
||||
conventions = {}
|
||||
|
||||
# Python linting
|
||||
if (self.project_dir / "ruff.toml").exists() or self._has_in_pyproject("ruff"):
|
||||
conventions["python_linting"] = "Ruff"
|
||||
elif (self.project_dir / ".flake8").exists():
|
||||
conventions["python_linting"] = "Flake8"
|
||||
elif (self.project_dir / "pylintrc").exists():
|
||||
conventions["python_linting"] = "Pylint"
|
||||
|
||||
# Python formatting
|
||||
if (self.project_dir / "pyproject.toml").exists():
|
||||
content = self._read_file("pyproject.toml")
|
||||
if "[tool.black]" in content:
|
||||
conventions["python_formatting"] = "Black"
|
||||
|
||||
# JavaScript/TypeScript linting
|
||||
eslint_files = [
|
||||
".eslintrc",
|
||||
".eslintrc.js",
|
||||
".eslintrc.json",
|
||||
".eslintrc.yml",
|
||||
"eslint.config.js",
|
||||
]
|
||||
if any((self.project_dir / f).exists() for f in eslint_files):
|
||||
conventions["js_linting"] = "ESLint"
|
||||
|
||||
# Prettier
|
||||
prettier_files = [
|
||||
".prettierrc",
|
||||
".prettierrc.js",
|
||||
".prettierrc.json",
|
||||
"prettier.config.js",
|
||||
]
|
||||
if any((self.project_dir / f).exists() for f in prettier_files):
|
||||
conventions["formatting"] = "Prettier"
|
||||
|
||||
# TypeScript
|
||||
if (self.project_dir / "tsconfig.json").exists():
|
||||
conventions["typescript"] = True
|
||||
|
||||
# Git hooks
|
||||
if (self.project_dir / ".husky").exists():
|
||||
conventions["git_hooks"] = "Husky"
|
||||
elif (self.project_dir / ".pre-commit-config.yaml").exists():
|
||||
conventions["git_hooks"] = "pre-commit"
|
||||
|
||||
self.index["conventions"] = conventions
|
||||
|
||||
def _map_dependencies(self) -> None:
|
||||
"""Map dependencies between services."""
|
||||
services = self.index.get("services", {})
|
||||
|
||||
for service_name, service_info in services.items():
|
||||
consumes = []
|
||||
|
||||
# Check for API client patterns
|
||||
if service_info.get("type") == "frontend":
|
||||
# Frontend typically consumes backend
|
||||
for other_name, other_info in services.items():
|
||||
if other_info.get("type") == "backend":
|
||||
consumes.append(f"{other_name}.api")
|
||||
|
||||
# Check for shared libraries
|
||||
if service_info.get("dependencies"):
|
||||
deps = service_info["dependencies"]
|
||||
for other_name in services.keys():
|
||||
if other_name in deps or f"@{other_name}" in str(deps):
|
||||
consumes.append(other_name)
|
||||
|
||||
if consumes:
|
||||
service_info["consumes"] = consumes
|
||||
|
||||
def _has_in_pyproject(self, tool: str) -> bool:
|
||||
"""Check if a tool is configured in pyproject.toml."""
|
||||
if (self.project_dir / "pyproject.toml").exists():
|
||||
content = self._read_file("pyproject.toml")
|
||||
return f"[tool.{tool}]" in content
|
||||
return False
|
||||
|
||||
def _read_file(self, path: str) -> str:
|
||||
try:
|
||||
return (self.project_dir / path).read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return ""
|
||||
@@ -0,0 +1,418 @@
|
||||
"""
|
||||
Route Detector Module
|
||||
=====================
|
||||
|
||||
Detects API routes and endpoints across different frameworks:
|
||||
- Python: FastAPI, Flask, Django
|
||||
- Node.js: Express, Next.js
|
||||
- Go: Gin, Echo, Chi, Fiber
|
||||
- Rust: Axum, Actix
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from .base import BaseAnalyzer
|
||||
|
||||
|
||||
class RouteDetector(BaseAnalyzer):
|
||||
"""Detects API routes across multiple web frameworks."""
|
||||
|
||||
# Directories to exclude from route detection
|
||||
EXCLUDED_DIRS = {"node_modules", ".venv", "venv", "__pycache__", ".git"}
|
||||
|
||||
def __init__(self, path: Path):
|
||||
super().__init__(path)
|
||||
|
||||
def _should_include_file(self, file_path: Path) -> bool:
|
||||
"""Check if file should be included (not in excluded directories)."""
|
||||
return not any(part in self.EXCLUDED_DIRS for part in file_path.parts)
|
||||
|
||||
def detect_all_routes(self) -> list[dict]:
|
||||
"""Detect all API routes across different frameworks."""
|
||||
routes = []
|
||||
|
||||
# Python FastAPI
|
||||
routes.extend(self._detect_fastapi_routes())
|
||||
|
||||
# Python Flask
|
||||
routes.extend(self._detect_flask_routes())
|
||||
|
||||
# Python Django
|
||||
routes.extend(self._detect_django_routes())
|
||||
|
||||
# Node.js Express/Fastify/Koa
|
||||
routes.extend(self._detect_express_routes())
|
||||
|
||||
# Next.js (file-based routing)
|
||||
routes.extend(self._detect_nextjs_routes())
|
||||
|
||||
# Go Gin/Echo/Chi
|
||||
routes.extend(self._detect_go_routes())
|
||||
|
||||
# Rust Axum/Actix
|
||||
routes.extend(self._detect_rust_routes())
|
||||
|
||||
return routes
|
||||
|
||||
def _detect_fastapi_routes(self) -> list[dict]:
|
||||
"""Detect FastAPI routes."""
|
||||
routes = []
|
||||
files_to_check = [
|
||||
f for f in self.path.glob("**/*.py") if self._should_include_file(f)
|
||||
]
|
||||
|
||||
for file_path in files_to_check:
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
# Pattern: @app.get("/path") or @router.post("/path", dependencies=[...])
|
||||
patterns = [
|
||||
(
|
||||
r'@(?:app|router)\.(get|post|put|delete|patch)\(["\']([^"\']+)["\']',
|
||||
"decorator",
|
||||
),
|
||||
(
|
||||
r'@(?:app|router)\.api_route\(["\']([^"\']+)["\'][^)]*methods\s*=\s*\[([^\]]+)\]',
|
||||
"api_route",
|
||||
),
|
||||
]
|
||||
|
||||
for pattern, pattern_type in patterns:
|
||||
matches = re.finditer(pattern, content, re.MULTILINE)
|
||||
for match in matches:
|
||||
if pattern_type == "decorator":
|
||||
method = match.group(1).upper()
|
||||
path = match.group(2)
|
||||
methods = [method]
|
||||
else:
|
||||
path = match.group(1)
|
||||
methods_str = match.group(2)
|
||||
methods = [
|
||||
m.strip().strip('"').strip("'").upper()
|
||||
for m in methods_str.split(",")
|
||||
]
|
||||
|
||||
# Check if route requires auth (has Depends in the decorator)
|
||||
line_start = content.rfind("\n", 0, match.start()) + 1
|
||||
line_end = content.find("\n", match.end())
|
||||
route_definition = content[
|
||||
line_start : line_end if line_end != -1 else len(content)
|
||||
]
|
||||
|
||||
requires_auth = (
|
||||
"Depends" in route_definition
|
||||
or "require" in route_definition.lower()
|
||||
)
|
||||
|
||||
routes.append(
|
||||
{
|
||||
"path": path,
|
||||
"methods": methods,
|
||||
"file": str(file_path.relative_to(self.path)),
|
||||
"framework": "FastAPI",
|
||||
"requires_auth": requires_auth,
|
||||
}
|
||||
)
|
||||
|
||||
return routes
|
||||
|
||||
def _detect_flask_routes(self) -> list[dict]:
|
||||
"""Detect Flask routes."""
|
||||
routes = []
|
||||
files_to_check = [
|
||||
f for f in self.path.glob("**/*.py") if self._should_include_file(f)
|
||||
]
|
||||
|
||||
for file_path in files_to_check:
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
# Pattern: @app.route("/path", methods=["GET", "POST"])
|
||||
pattern = r'@(?:app|bp|blueprint)\.route\(["\']([^"\']+)["\'](?:[^)]*methods\s*=\s*\[([^\]]+)\])?'
|
||||
matches = re.finditer(pattern, content, re.MULTILINE)
|
||||
|
||||
for match in matches:
|
||||
path = match.group(1)
|
||||
methods_str = match.group(2)
|
||||
|
||||
if methods_str:
|
||||
methods = [
|
||||
m.strip().strip('"').strip("'").upper()
|
||||
for m in methods_str.split(",")
|
||||
]
|
||||
else:
|
||||
methods = ["GET"] # Flask default
|
||||
|
||||
# Check for @login_required decorator
|
||||
decorator_start = content.rfind("@", 0, match.start())
|
||||
decorator_section = content[decorator_start : match.end()]
|
||||
requires_auth = (
|
||||
"login_required" in decorator_section
|
||||
or "require" in decorator_section.lower()
|
||||
)
|
||||
|
||||
routes.append(
|
||||
{
|
||||
"path": path,
|
||||
"methods": methods,
|
||||
"file": str(file_path.relative_to(self.path)),
|
||||
"framework": "Flask",
|
||||
"requires_auth": requires_auth,
|
||||
}
|
||||
)
|
||||
|
||||
return routes
|
||||
|
||||
def _detect_django_routes(self) -> list[dict]:
|
||||
"""Detect Django routes from urls.py files."""
|
||||
routes = []
|
||||
url_files = [
|
||||
f for f in self.path.glob("**/urls.py") if self._should_include_file(f)
|
||||
]
|
||||
|
||||
for file_path in url_files:
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
# Pattern: path('users/<int:id>/', views.user_detail)
|
||||
patterns = [
|
||||
r'path\(["\']([^"\']+)["\']',
|
||||
r're_path\([r]?["\']([^"\']+)["\']',
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
matches = re.finditer(pattern, content)
|
||||
for match in matches:
|
||||
path = match.group(1)
|
||||
|
||||
routes.append(
|
||||
{
|
||||
"path": f"/{path}" if not path.startswith("/") else path,
|
||||
"methods": ["GET", "POST"], # Django allows both by default
|
||||
"file": str(file_path.relative_to(self.path)),
|
||||
"framework": "Django",
|
||||
"requires_auth": False, # Can't easily detect without middleware analysis
|
||||
}
|
||||
)
|
||||
|
||||
return routes
|
||||
|
||||
def _detect_express_routes(self) -> list[dict]:
|
||||
"""Detect Express/Fastify/Koa routes."""
|
||||
routes = []
|
||||
js_files = [
|
||||
f for f in self.path.glob("**/*.js") if self._should_include_file(f)
|
||||
]
|
||||
ts_files = [
|
||||
f for f in self.path.glob("**/*.ts") if self._should_include_file(f)
|
||||
]
|
||||
files_to_check = js_files + ts_files
|
||||
for file_path in files_to_check:
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
# Pattern: app.get('/path', handler) or router.post('/path', middleware, handler)
|
||||
pattern = (
|
||||
r'(?:app|router)\.(get|post|put|delete|patch|use)\(["\']([^"\']+)["\']'
|
||||
)
|
||||
matches = re.finditer(pattern, content)
|
||||
|
||||
for match in matches:
|
||||
method = match.group(1).upper()
|
||||
path = match.group(2)
|
||||
|
||||
if method == "USE":
|
||||
# .use() is middleware, might be a route prefix
|
||||
continue
|
||||
|
||||
# Check for auth middleware in the route definition
|
||||
line_start = content.rfind("\n", 0, match.start()) + 1
|
||||
line_end = content.find("\n", match.end())
|
||||
route_line = content[
|
||||
line_start : line_end if line_end != -1 else len(content)
|
||||
]
|
||||
|
||||
requires_auth = any(
|
||||
keyword in route_line.lower()
|
||||
for keyword in ["auth", "authenticate", "protect", "require"]
|
||||
)
|
||||
|
||||
routes.append(
|
||||
{
|
||||
"path": path,
|
||||
"methods": [method],
|
||||
"file": str(file_path.relative_to(self.path)),
|
||||
"framework": "Express",
|
||||
"requires_auth": requires_auth,
|
||||
}
|
||||
)
|
||||
|
||||
return routes
|
||||
|
||||
def _detect_nextjs_routes(self) -> list[dict]:
|
||||
"""Detect Next.js file-based routes."""
|
||||
routes = []
|
||||
|
||||
# Next.js App Router (app directory)
|
||||
app_dir = self.path / "app"
|
||||
if app_dir.exists():
|
||||
# Find all route.ts/js files
|
||||
route_files = [
|
||||
f
|
||||
for f in app_dir.glob("**/route.{ts,js,tsx,jsx}")
|
||||
if self._should_include_file(f)
|
||||
]
|
||||
for route_file in route_files:
|
||||
# Convert file path to route path
|
||||
# app/api/users/[id]/route.ts -> /api/users/:id
|
||||
relative_path = route_file.parent.relative_to(app_dir)
|
||||
route_path = "/" + str(relative_path).replace("\\", "/")
|
||||
|
||||
# Convert [id] to :id
|
||||
route_path = re.sub(r"\[([^\]]+)\]", r":\1", route_path)
|
||||
|
||||
try:
|
||||
content = route_file.read_text()
|
||||
# Detect exported methods: export async function GET(request)
|
||||
methods = re.findall(
|
||||
r"export\s+(?:async\s+)?function\s+(GET|POST|PUT|DELETE|PATCH)",
|
||||
content,
|
||||
)
|
||||
|
||||
if methods:
|
||||
routes.append(
|
||||
{
|
||||
"path": route_path,
|
||||
"methods": methods,
|
||||
"file": str(route_file.relative_to(self.path)),
|
||||
"framework": "Next.js",
|
||||
"requires_auth": "auth" in content.lower(),
|
||||
}
|
||||
)
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
# Next.js Pages Router (pages/api directory)
|
||||
pages_api = self.path / "pages" / "api"
|
||||
if pages_api.exists():
|
||||
api_files = [
|
||||
f
|
||||
for f in pages_api.glob("**/*.{ts,js,tsx,jsx}")
|
||||
if self._should_include_file(f)
|
||||
]
|
||||
for api_file in api_files:
|
||||
if api_file.name.startswith("_"):
|
||||
continue
|
||||
|
||||
# Convert file path to route
|
||||
relative_path = api_file.relative_to(pages_api)
|
||||
route_path = "/api/" + str(relative_path.with_suffix("")).replace(
|
||||
"\\", "/"
|
||||
)
|
||||
|
||||
# Convert [id] to :id
|
||||
route_path = re.sub(r"\[([^\]]+)\]", r":\1", route_path)
|
||||
|
||||
routes.append(
|
||||
{
|
||||
"path": route_path,
|
||||
"methods": [
|
||||
"GET",
|
||||
"POST",
|
||||
], # Next.js API routes handle all methods
|
||||
"file": str(api_file.relative_to(self.path)),
|
||||
"framework": "Next.js",
|
||||
"requires_auth": False,
|
||||
}
|
||||
)
|
||||
|
||||
return routes
|
||||
|
||||
def _detect_go_routes(self) -> list[dict]:
|
||||
"""Detect Go framework routes (Gin, Echo, Chi, Fiber)."""
|
||||
routes = []
|
||||
go_files = [
|
||||
f for f in self.path.glob("**/*.go") if self._should_include_file(f)
|
||||
]
|
||||
|
||||
for file_path in go_files:
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
# Gin: r.GET("/path", handler)
|
||||
# Echo: e.POST("/path", handler)
|
||||
# Chi: r.Get("/path", handler)
|
||||
# Fiber: app.Get("/path", handler)
|
||||
pattern = r'(?:r|e|app|router)\.(GET|POST|PUT|DELETE|PATCH|Get|Post|Put|Delete|Patch)\(["\']([^"\']+)["\']'
|
||||
matches = re.finditer(pattern, content)
|
||||
|
||||
for match in matches:
|
||||
method = match.group(1).upper()
|
||||
path = match.group(2)
|
||||
|
||||
routes.append(
|
||||
{
|
||||
"path": path,
|
||||
"methods": [method],
|
||||
"file": str(file_path.relative_to(self.path)),
|
||||
"framework": "Go",
|
||||
"requires_auth": False,
|
||||
}
|
||||
)
|
||||
|
||||
return routes
|
||||
|
||||
def _detect_rust_routes(self) -> list[dict]:
|
||||
"""Detect Rust framework routes (Axum, Actix)."""
|
||||
routes = []
|
||||
rust_files = [
|
||||
f for f in self.path.glob("**/*.rs") if self._should_include_file(f)
|
||||
]
|
||||
|
||||
for file_path in rust_files:
|
||||
try:
|
||||
content = file_path.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
# Axum: .route("/path", get(handler))
|
||||
# Actix: web::get().to(handler)
|
||||
patterns = [
|
||||
r'\.route\(["\']([^"\']+)["\'],\s*(get|post|put|delete|patch)',
|
||||
r"web::(get|post|put|delete|patch)\(\)",
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
matches = re.finditer(pattern, content)
|
||||
for match in matches:
|
||||
if len(match.groups()) == 2:
|
||||
path = match.group(1)
|
||||
method = match.group(2).upper()
|
||||
else:
|
||||
path = "/" # Can't determine path from web:: syntax
|
||||
method = match.group(1).upper()
|
||||
|
||||
routes.append(
|
||||
{
|
||||
"path": path,
|
||||
"methods": [method],
|
||||
"file": str(file_path.relative_to(self.path)),
|
||||
"framework": "Rust",
|
||||
"requires_auth": False,
|
||||
}
|
||||
)
|
||||
|
||||
return routes
|
||||
@@ -0,0 +1,313 @@
|
||||
"""
|
||||
Service Analyzer Module
|
||||
=======================
|
||||
|
||||
Main ServiceAnalyzer class that coordinates all analysis for a single service/package.
|
||||
Integrates framework detection, route analysis, database models, and context extraction.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .base import BaseAnalyzer
|
||||
from .context_analyzer import ContextAnalyzer
|
||||
from .database_detector import DatabaseDetector
|
||||
from .framework_analyzer import FrameworkAnalyzer
|
||||
from .route_detector import RouteDetector
|
||||
|
||||
|
||||
class ServiceAnalyzer(BaseAnalyzer):
|
||||
"""Analyzes a single service/package within a project."""
|
||||
|
||||
def __init__(self, service_path: Path, service_name: str):
|
||||
super().__init__(service_path)
|
||||
self.name = service_name
|
||||
self.analysis = {
|
||||
"name": service_name,
|
||||
"path": str(service_path),
|
||||
"language": None,
|
||||
"framework": None,
|
||||
"type": None, # backend, frontend, worker, library, etc.
|
||||
}
|
||||
|
||||
def analyze(self) -> dict[str, Any]:
|
||||
"""Run full analysis on this service."""
|
||||
self._detect_language_and_framework()
|
||||
self._detect_service_type()
|
||||
self._find_key_directories()
|
||||
self._find_entry_points()
|
||||
self._detect_dependencies()
|
||||
self._detect_testing()
|
||||
self._find_dockerfile()
|
||||
|
||||
# Comprehensive context extraction
|
||||
self._detect_environment_variables()
|
||||
self._detect_api_routes()
|
||||
self._detect_database_models()
|
||||
self._detect_external_services()
|
||||
self._detect_auth_patterns()
|
||||
self._detect_migrations()
|
||||
self._detect_background_jobs()
|
||||
self._detect_api_documentation()
|
||||
self._detect_monitoring()
|
||||
|
||||
return self.analysis
|
||||
|
||||
def _detect_language_and_framework(self) -> None:
|
||||
"""Detect primary language and framework."""
|
||||
framework_analyzer = FrameworkAnalyzer(self.path, self.analysis)
|
||||
framework_analyzer.detect_language_and_framework()
|
||||
|
||||
def _detect_service_type(self) -> None:
|
||||
"""Infer service type from name and content if not already set."""
|
||||
if self.analysis.get("type"):
|
||||
return
|
||||
|
||||
name_lower = self.name.lower()
|
||||
|
||||
# Infer from name
|
||||
if any(kw in name_lower for kw in ["frontend", "client", "web", "ui", "app"]):
|
||||
self.analysis["type"] = "frontend"
|
||||
elif any(kw in name_lower for kw in ["backend", "api", "server", "service"]):
|
||||
self.analysis["type"] = "backend"
|
||||
elif any(
|
||||
kw in name_lower for kw in ["worker", "job", "queue", "task", "celery"]
|
||||
):
|
||||
self.analysis["type"] = "worker"
|
||||
elif any(kw in name_lower for kw in ["scraper", "crawler", "spider"]):
|
||||
self.analysis["type"] = "scraper"
|
||||
elif any(kw in name_lower for kw in ["proxy", "gateway", "router"]):
|
||||
self.analysis["type"] = "proxy"
|
||||
elif any(
|
||||
kw in name_lower for kw in ["lib", "shared", "common", "core", "utils"]
|
||||
):
|
||||
self.analysis["type"] = "library"
|
||||
else:
|
||||
# Try to infer from language and content if name doesn't match
|
||||
language = self.analysis.get("language")
|
||||
|
||||
if language == "Python":
|
||||
# Check if it's a CLI tool, framework, or backend service
|
||||
has_run_py = (self.path / "run.py").exists()
|
||||
has_main_py = (self.path / "main.py").exists()
|
||||
has_main_module = (self.path / "__main__.py").exists()
|
||||
|
||||
# Check for agent/automation framework patterns
|
||||
has_agent_files = any(
|
||||
(self.path / f).exists()
|
||||
for f in ["agent.py", "agents", "runner.py", "runners"]
|
||||
)
|
||||
|
||||
if has_run_py or has_main_py or has_main_module or has_agent_files:
|
||||
# It's a backend tool/framework/CLI
|
||||
self.analysis["type"] = "backend"
|
||||
return
|
||||
|
||||
# Default to unknown if no clear indicators
|
||||
self.analysis["type"] = "unknown"
|
||||
|
||||
def _find_key_directories(self) -> None:
|
||||
"""Find important directories within this service."""
|
||||
key_dirs = {}
|
||||
|
||||
# Common directory patterns
|
||||
patterns = {
|
||||
"src": "Source code",
|
||||
"lib": "Library code",
|
||||
"app": "Application code",
|
||||
"api": "API endpoints",
|
||||
"routes": "Route handlers",
|
||||
"controllers": "Controllers",
|
||||
"models": "Data models",
|
||||
"schemas": "Schemas/DTOs",
|
||||
"services": "Business logic",
|
||||
"components": "UI components",
|
||||
"pages": "Page components",
|
||||
"views": "Views/templates",
|
||||
"hooks": "Custom hooks",
|
||||
"utils": "Utilities",
|
||||
"helpers": "Helper functions",
|
||||
"middleware": "Middleware",
|
||||
"tests": "Tests",
|
||||
"test": "Tests",
|
||||
"__tests__": "Tests",
|
||||
"config": "Configuration",
|
||||
"tasks": "Background tasks",
|
||||
"jobs": "Background jobs",
|
||||
"workers": "Worker processes",
|
||||
}
|
||||
|
||||
for dir_name, purpose in patterns.items():
|
||||
dir_path = self.path / dir_name
|
||||
if dir_path.exists() and dir_path.is_dir():
|
||||
key_dirs[dir_name] = {
|
||||
"path": str(dir_path.relative_to(self.path)),
|
||||
"purpose": purpose,
|
||||
}
|
||||
|
||||
if key_dirs:
|
||||
self.analysis["key_directories"] = key_dirs
|
||||
|
||||
def _find_entry_points(self) -> None:
|
||||
"""Find main entry point files."""
|
||||
entry_patterns = [
|
||||
"main.py",
|
||||
"app.py",
|
||||
"__main__.py",
|
||||
"server.py",
|
||||
"wsgi.py",
|
||||
"asgi.py",
|
||||
"index.ts",
|
||||
"index.js",
|
||||
"main.ts",
|
||||
"main.js",
|
||||
"server.ts",
|
||||
"server.js",
|
||||
"app.ts",
|
||||
"app.js",
|
||||
"src/index.ts",
|
||||
"src/index.js",
|
||||
"src/main.ts",
|
||||
"src/app.ts",
|
||||
"src/server.ts",
|
||||
"src/App.tsx",
|
||||
"src/App.jsx",
|
||||
"pages/_app.tsx",
|
||||
"pages/_app.js", # Next.js
|
||||
"main.go",
|
||||
"cmd/main.go",
|
||||
"src/main.rs",
|
||||
"src/lib.rs",
|
||||
]
|
||||
|
||||
for pattern in entry_patterns:
|
||||
if self._exists(pattern):
|
||||
self.analysis["entry_point"] = pattern
|
||||
break
|
||||
|
||||
def _detect_dependencies(self) -> None:
|
||||
"""Extract key dependencies."""
|
||||
if self._exists("package.json"):
|
||||
pkg = self._read_json("package.json")
|
||||
if pkg:
|
||||
deps = pkg.get("dependencies", {})
|
||||
dev_deps = pkg.get("devDependencies", {})
|
||||
self.analysis["dependencies"] = list(deps.keys())[:20] # Top 20
|
||||
self.analysis["dev_dependencies"] = list(dev_deps.keys())[:10]
|
||||
|
||||
elif self._exists("requirements.txt"):
|
||||
content = self._read_file("requirements.txt")
|
||||
deps = []
|
||||
for line in content.split("\n"):
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and not line.startswith("-"):
|
||||
match = re.match(r"^([a-zA-Z0-9_-]+)", line)
|
||||
if match:
|
||||
deps.append(match.group(1))
|
||||
self.analysis["dependencies"] = deps[:20]
|
||||
|
||||
def _detect_testing(self) -> None:
|
||||
"""Detect testing framework and configuration."""
|
||||
if self._exists("package.json"):
|
||||
pkg = self._read_json("package.json")
|
||||
if pkg:
|
||||
deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})}
|
||||
if "vitest" in deps:
|
||||
self.analysis["testing"] = "Vitest"
|
||||
elif "jest" in deps:
|
||||
self.analysis["testing"] = "Jest"
|
||||
if "@playwright/test" in deps:
|
||||
self.analysis["e2e_testing"] = "Playwright"
|
||||
elif "cypress" in deps:
|
||||
self.analysis["e2e_testing"] = "Cypress"
|
||||
|
||||
elif self._exists("pytest.ini") or self._exists("pyproject.toml"):
|
||||
self.analysis["testing"] = "pytest"
|
||||
|
||||
# Find test directory
|
||||
for test_dir in ["tests", "test", "__tests__", "spec"]:
|
||||
if self._exists(test_dir):
|
||||
self.analysis["test_directory"] = test_dir
|
||||
break
|
||||
|
||||
def _find_dockerfile(self) -> None:
|
||||
"""Find Dockerfile for this service."""
|
||||
dockerfile_patterns = [
|
||||
"Dockerfile",
|
||||
f"Dockerfile.{self.name}",
|
||||
f"docker/{self.name}.Dockerfile",
|
||||
f"docker/Dockerfile.{self.name}",
|
||||
"../docker/Dockerfile." + self.name,
|
||||
]
|
||||
|
||||
for pattern in dockerfile_patterns:
|
||||
if self._exists(pattern):
|
||||
self.analysis["dockerfile"] = pattern
|
||||
break
|
||||
|
||||
def _detect_environment_variables(self) -> None:
|
||||
"""Detect environment variables."""
|
||||
context = ContextAnalyzer(self.path, self.analysis)
|
||||
context.detect_environment_variables()
|
||||
|
||||
def _detect_api_routes(self) -> None:
|
||||
"""Detect API routes."""
|
||||
route_detector = RouteDetector(self.path)
|
||||
routes = route_detector.detect_all_routes()
|
||||
|
||||
if routes:
|
||||
self.analysis["api"] = {
|
||||
"routes": routes,
|
||||
"total_routes": len(routes),
|
||||
"methods": list(
|
||||
set(method for r in routes for method in r.get("methods", []))
|
||||
),
|
||||
"protected_routes": [
|
||||
r["path"] for r in routes if r.get("requires_auth")
|
||||
],
|
||||
}
|
||||
|
||||
def _detect_database_models(self) -> None:
|
||||
"""Detect database models."""
|
||||
db_detector = DatabaseDetector(self.path)
|
||||
models = db_detector.detect_all_models()
|
||||
|
||||
if models:
|
||||
self.analysis["database"] = {
|
||||
"models": models,
|
||||
"total_models": len(models),
|
||||
"model_names": list(models.keys()),
|
||||
}
|
||||
|
||||
def _detect_external_services(self) -> None:
|
||||
"""Detect external services."""
|
||||
context = ContextAnalyzer(self.path, self.analysis)
|
||||
context.detect_external_services()
|
||||
|
||||
def _detect_auth_patterns(self) -> None:
|
||||
"""Detect authentication patterns."""
|
||||
context = ContextAnalyzer(self.path, self.analysis)
|
||||
context.detect_auth_patterns()
|
||||
|
||||
def _detect_migrations(self) -> None:
|
||||
"""Detect database migrations."""
|
||||
context = ContextAnalyzer(self.path, self.analysis)
|
||||
context.detect_migrations()
|
||||
|
||||
def _detect_background_jobs(self) -> None:
|
||||
"""Detect background jobs."""
|
||||
context = ContextAnalyzer(self.path, self.analysis)
|
||||
context.detect_background_jobs()
|
||||
|
||||
def _detect_api_documentation(self) -> None:
|
||||
"""Detect API documentation."""
|
||||
context = ContextAnalyzer(self.path, self.analysis)
|
||||
context.detect_api_documentation()
|
||||
|
||||
def _detect_monitoring(self) -> None:
|
||||
"""Detect monitoring setup."""
|
||||
context = ContextAnalyzer(self.path, self.analysis)
|
||||
context.detect_monitoring()
|
||||
@@ -0,0 +1,589 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
CI Discovery Module
|
||||
===================
|
||||
|
||||
Parses CI/CD configuration files to extract test commands and workflows.
|
||||
Supports GitHub Actions, GitLab CI, CircleCI, and Jenkins.
|
||||
|
||||
The CI discovery results are used by:
|
||||
- QA Agent: To understand existing CI test patterns
|
||||
- Validation Strategy: To match CI commands
|
||||
- Planner: To align verification with CI
|
||||
|
||||
Usage:
|
||||
from ci_discovery import CIDiscovery
|
||||
|
||||
discovery = CIDiscovery()
|
||||
result = discovery.discover(project_dir)
|
||||
|
||||
if result:
|
||||
print(f"CI System: {result.ci_system}")
|
||||
print(f"Test Commands: {result.test_commands}")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Try to import yaml, fall back gracefully
|
||||
try:
|
||||
import yaml
|
||||
|
||||
HAS_YAML = True
|
||||
except ImportError:
|
||||
HAS_YAML = False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DATA CLASSES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class CIWorkflow:
|
||||
"""
|
||||
Represents a CI workflow or job.
|
||||
|
||||
Attributes:
|
||||
name: Name of the workflow/job
|
||||
trigger: What triggers this workflow (push, pull_request, etc.)
|
||||
steps: List of step names or commands
|
||||
test_related: Whether this appears to be test-related
|
||||
"""
|
||||
|
||||
name: str
|
||||
trigger: list[str] = field(default_factory=list)
|
||||
steps: list[str] = field(default_factory=list)
|
||||
test_related: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class CIConfig:
|
||||
"""
|
||||
Result of CI configuration discovery.
|
||||
|
||||
Attributes:
|
||||
ci_system: Name of CI system (github_actions, gitlab, circleci, jenkins)
|
||||
config_files: List of CI config files found
|
||||
test_commands: Extracted test commands by type
|
||||
coverage_command: Coverage command if found
|
||||
workflows: List of discovered workflows
|
||||
environment_variables: Environment variables used
|
||||
"""
|
||||
|
||||
ci_system: str
|
||||
config_files: list[str] = field(default_factory=list)
|
||||
test_commands: dict[str, str] = field(default_factory=dict)
|
||||
coverage_command: str | None = None
|
||||
workflows: list[CIWorkflow] = field(default_factory=list)
|
||||
environment_variables: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CI PARSERS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class CIDiscovery:
|
||||
"""
|
||||
Discovers CI/CD configurations in a project.
|
||||
|
||||
Analyzes:
|
||||
- GitHub Actions (.github/workflows/*.yml)
|
||||
- GitLab CI (.gitlab-ci.yml)
|
||||
- CircleCI (.circleci/config.yml)
|
||||
- Jenkins (Jenkinsfile)
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize CI discovery."""
|
||||
self._cache: dict[str, CIConfig | None] = {}
|
||||
|
||||
def discover(self, project_dir: Path) -> CIConfig | None:
|
||||
"""
|
||||
Discover CI configuration in the project.
|
||||
|
||||
Args:
|
||||
project_dir: Path to the project root
|
||||
|
||||
Returns:
|
||||
CIConfig if CI found, None otherwise
|
||||
"""
|
||||
project_dir = Path(project_dir)
|
||||
cache_key = str(project_dir.resolve())
|
||||
|
||||
if cache_key in self._cache:
|
||||
return self._cache[cache_key]
|
||||
|
||||
# Try each CI system
|
||||
result = None
|
||||
|
||||
# GitHub Actions
|
||||
github_workflows = project_dir / ".github" / "workflows"
|
||||
if github_workflows.exists():
|
||||
result = self._parse_github_actions(github_workflows)
|
||||
|
||||
# GitLab CI
|
||||
if not result:
|
||||
gitlab_ci = project_dir / ".gitlab-ci.yml"
|
||||
if gitlab_ci.exists():
|
||||
result = self._parse_gitlab_ci(gitlab_ci)
|
||||
|
||||
# CircleCI
|
||||
if not result:
|
||||
circleci = project_dir / ".circleci" / "config.yml"
|
||||
if circleci.exists():
|
||||
result = self._parse_circleci(circleci)
|
||||
|
||||
# Jenkins
|
||||
if not result:
|
||||
jenkinsfile = project_dir / "Jenkinsfile"
|
||||
if jenkinsfile.exists():
|
||||
result = self._parse_jenkinsfile(jenkinsfile)
|
||||
|
||||
self._cache[cache_key] = result
|
||||
return result
|
||||
|
||||
def _parse_github_actions(self, workflows_dir: Path) -> CIConfig:
|
||||
"""Parse GitHub Actions workflow files."""
|
||||
result = CIConfig(ci_system="github_actions")
|
||||
|
||||
workflow_files = list(workflows_dir.glob("*.yml")) + list(
|
||||
workflows_dir.glob("*.yaml")
|
||||
)
|
||||
|
||||
for wf_file in workflow_files:
|
||||
result.config_files.append(
|
||||
str(wf_file.relative_to(workflows_dir.parent.parent))
|
||||
)
|
||||
|
||||
try:
|
||||
content = wf_file.read_text()
|
||||
workflow_data = self._parse_yaml(content)
|
||||
|
||||
if not workflow_data:
|
||||
continue
|
||||
|
||||
# Get workflow name
|
||||
wf_name = workflow_data.get("name", wf_file.stem)
|
||||
|
||||
# Get triggers
|
||||
triggers = []
|
||||
on_trigger = workflow_data.get("on", {})
|
||||
if isinstance(on_trigger, str):
|
||||
triggers = [on_trigger]
|
||||
elif isinstance(on_trigger, list):
|
||||
triggers = on_trigger
|
||||
elif isinstance(on_trigger, dict):
|
||||
triggers = list(on_trigger.keys())
|
||||
|
||||
# Parse jobs
|
||||
jobs = workflow_data.get("jobs", {})
|
||||
for job_name, job_config in jobs.items():
|
||||
if not isinstance(job_config, dict):
|
||||
continue
|
||||
|
||||
steps = job_config.get("steps", [])
|
||||
step_commands = []
|
||||
test_related = False
|
||||
|
||||
for step in steps:
|
||||
if not isinstance(step, dict):
|
||||
continue
|
||||
|
||||
# Get step name or command
|
||||
step_name = step.get("name", "")
|
||||
run_cmd = step.get("run", "")
|
||||
uses = step.get("uses", "")
|
||||
|
||||
if step_name:
|
||||
step_commands.append(step_name)
|
||||
if run_cmd:
|
||||
step_commands.append(run_cmd)
|
||||
# Extract test commands
|
||||
self._extract_test_commands(run_cmd, result)
|
||||
if uses:
|
||||
step_commands.append(f"uses: {uses}")
|
||||
|
||||
# Check if test-related
|
||||
test_keywords = ["test", "pytest", "jest", "vitest", "coverage"]
|
||||
if any(kw in str(step).lower() for kw in test_keywords):
|
||||
test_related = True
|
||||
|
||||
result.workflows.append(
|
||||
CIWorkflow(
|
||||
name=f"{wf_name}/{job_name}",
|
||||
trigger=triggers,
|
||||
steps=step_commands,
|
||||
test_related=test_related,
|
||||
)
|
||||
)
|
||||
|
||||
# Extract environment variables
|
||||
env = workflow_data.get("env", {})
|
||||
if isinstance(env, dict):
|
||||
result.environment_variables.extend(env.keys())
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return result
|
||||
|
||||
def _parse_gitlab_ci(self, config_file: Path) -> CIConfig:
|
||||
"""Parse GitLab CI configuration."""
|
||||
result = CIConfig(
|
||||
ci_system="gitlab",
|
||||
config_files=[".gitlab-ci.yml"],
|
||||
)
|
||||
|
||||
try:
|
||||
content = config_file.read_text()
|
||||
data = self._parse_yaml(content)
|
||||
|
||||
if not data:
|
||||
return result
|
||||
|
||||
# Parse jobs (top-level keys that aren't special keywords)
|
||||
special_keys = {
|
||||
"stages",
|
||||
"variables",
|
||||
"image",
|
||||
"services",
|
||||
"before_script",
|
||||
"after_script",
|
||||
"cache",
|
||||
"include",
|
||||
"default",
|
||||
"workflow",
|
||||
}
|
||||
|
||||
for key, value in data.items():
|
||||
if key.startswith(".") or key in special_keys:
|
||||
continue
|
||||
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
|
||||
job_config = value
|
||||
script = job_config.get("script", [])
|
||||
if isinstance(script, str):
|
||||
script = [script]
|
||||
|
||||
test_related = any(
|
||||
kw in str(script).lower()
|
||||
for kw in ["test", "pytest", "jest", "vitest", "coverage"]
|
||||
)
|
||||
|
||||
result.workflows.append(
|
||||
CIWorkflow(
|
||||
name=key,
|
||||
trigger=job_config.get("only", [])
|
||||
or job_config.get("rules", []),
|
||||
steps=script,
|
||||
test_related=test_related,
|
||||
)
|
||||
)
|
||||
|
||||
# Extract test commands
|
||||
for cmd in script:
|
||||
if isinstance(cmd, str):
|
||||
self._extract_test_commands(cmd, result)
|
||||
|
||||
# Extract variables
|
||||
variables = data.get("variables", {})
|
||||
if isinstance(variables, dict):
|
||||
result.environment_variables.extend(variables.keys())
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
def _parse_circleci(self, config_file: Path) -> CIConfig:
|
||||
"""Parse CircleCI configuration."""
|
||||
result = CIConfig(
|
||||
ci_system="circleci",
|
||||
config_files=[".circleci/config.yml"],
|
||||
)
|
||||
|
||||
try:
|
||||
content = config_file.read_text()
|
||||
data = self._parse_yaml(content)
|
||||
|
||||
if not data:
|
||||
return result
|
||||
|
||||
# Parse jobs
|
||||
jobs = data.get("jobs", {})
|
||||
for job_name, job_config in jobs.items():
|
||||
if not isinstance(job_config, dict):
|
||||
continue
|
||||
|
||||
steps = job_config.get("steps", [])
|
||||
step_commands = []
|
||||
test_related = False
|
||||
|
||||
for step in steps:
|
||||
if isinstance(step, str):
|
||||
step_commands.append(step)
|
||||
elif isinstance(step, dict):
|
||||
if "run" in step:
|
||||
run = step["run"]
|
||||
if isinstance(run, str):
|
||||
step_commands.append(run)
|
||||
self._extract_test_commands(run, result)
|
||||
elif isinstance(run, dict):
|
||||
cmd = run.get("command", "")
|
||||
step_commands.append(cmd)
|
||||
self._extract_test_commands(cmd, result)
|
||||
|
||||
if any(
|
||||
kw in str(step).lower()
|
||||
for kw in ["test", "pytest", "jest", "coverage"]
|
||||
):
|
||||
test_related = True
|
||||
|
||||
result.workflows.append(
|
||||
CIWorkflow(
|
||||
name=job_name,
|
||||
trigger=[],
|
||||
steps=step_commands,
|
||||
test_related=test_related,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
def _parse_jenkinsfile(self, jenkinsfile: Path) -> CIConfig:
|
||||
"""Parse Jenkinsfile (basic extraction)."""
|
||||
result = CIConfig(
|
||||
ci_system="jenkins",
|
||||
config_files=["Jenkinsfile"],
|
||||
)
|
||||
|
||||
try:
|
||||
content = jenkinsfile.read_text()
|
||||
|
||||
# Extract sh commands using regex
|
||||
sh_pattern = re.compile(r'sh\s+[\'"]([^\'"]+)[\'"]')
|
||||
matches = sh_pattern.findall(content)
|
||||
|
||||
steps = []
|
||||
test_related = False
|
||||
|
||||
for cmd in matches:
|
||||
steps.append(cmd)
|
||||
self._extract_test_commands(cmd, result)
|
||||
|
||||
if any(
|
||||
kw in cmd.lower() for kw in ["test", "pytest", "jest", "coverage"]
|
||||
):
|
||||
test_related = True
|
||||
|
||||
# Extract stage names
|
||||
stage_pattern = re.compile(r'stage\s*\([\'"]([^\'"]+)[\'"]\)')
|
||||
stages = stage_pattern.findall(content)
|
||||
|
||||
for stage in stages:
|
||||
result.workflows.append(
|
||||
CIWorkflow(
|
||||
name=stage,
|
||||
trigger=[],
|
||||
steps=steps if "test" in stage.lower() else [],
|
||||
test_related="test" in stage.lower(),
|
||||
)
|
||||
)
|
||||
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
def _parse_yaml(self, content: str) -> dict | None:
|
||||
"""Parse YAML content, with fallback to basic parsing if yaml not available."""
|
||||
if HAS_YAML:
|
||||
try:
|
||||
return yaml.safe_load(content)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# Basic fallback for simple YAML (very limited)
|
||||
# This won't work for complex structures
|
||||
return None
|
||||
|
||||
def _extract_test_commands(self, cmd: str, result: CIConfig) -> None:
|
||||
"""Extract test commands from a command string."""
|
||||
cmd_lower = cmd.lower()
|
||||
|
||||
# Python pytest
|
||||
if "pytest" in cmd_lower:
|
||||
if "pytest" not in result.test_commands:
|
||||
result.test_commands["unit"] = cmd.strip()
|
||||
if "--cov" in cmd_lower:
|
||||
result.coverage_command = cmd.strip()
|
||||
|
||||
# Node.js test commands
|
||||
if (
|
||||
"npm test" in cmd_lower
|
||||
or "yarn test" in cmd_lower
|
||||
or "pnpm test" in cmd_lower
|
||||
):
|
||||
if "unit" not in result.test_commands:
|
||||
result.test_commands["unit"] = cmd.strip()
|
||||
|
||||
# Jest/Vitest
|
||||
if "jest" in cmd_lower or "vitest" in cmd_lower:
|
||||
if "unit" not in result.test_commands:
|
||||
result.test_commands["unit"] = cmd.strip()
|
||||
if "--coverage" in cmd_lower:
|
||||
result.coverage_command = cmd.strip()
|
||||
|
||||
# E2E testing
|
||||
if "playwright" in cmd_lower:
|
||||
result.test_commands["e2e"] = cmd.strip()
|
||||
if "cypress" in cmd_lower:
|
||||
result.test_commands["e2e"] = cmd.strip()
|
||||
|
||||
# Integration tests
|
||||
if "integration" in cmd_lower:
|
||||
result.test_commands["integration"] = cmd.strip()
|
||||
|
||||
# Go tests
|
||||
if "go test" in cmd_lower:
|
||||
if "unit" not in result.test_commands:
|
||||
result.test_commands["unit"] = cmd.strip()
|
||||
|
||||
# Rust tests
|
||||
if "cargo test" in cmd_lower:
|
||||
if "unit" not in result.test_commands:
|
||||
result.test_commands["unit"] = cmd.strip()
|
||||
|
||||
def to_dict(self, result: CIConfig) -> dict[str, Any]:
|
||||
"""Convert result to dictionary for JSON serialization."""
|
||||
return {
|
||||
"ci_system": result.ci_system,
|
||||
"config_files": result.config_files,
|
||||
"test_commands": result.test_commands,
|
||||
"coverage_command": result.coverage_command,
|
||||
"workflows": [
|
||||
{
|
||||
"name": w.name,
|
||||
"trigger": w.trigger,
|
||||
"steps": w.steps,
|
||||
"test_related": w.test_related,
|
||||
}
|
||||
for w in result.workflows
|
||||
],
|
||||
"environment_variables": result.environment_variables,
|
||||
}
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear the internal cache."""
|
||||
self._cache.clear()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CONVENIENCE FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def discover_ci(project_dir: Path) -> CIConfig | None:
|
||||
"""
|
||||
Convenience function to discover CI configuration.
|
||||
|
||||
Args:
|
||||
project_dir: Path to project root
|
||||
|
||||
Returns:
|
||||
CIConfig if found, None otherwise
|
||||
"""
|
||||
discovery = CIDiscovery()
|
||||
return discovery.discover(project_dir)
|
||||
|
||||
|
||||
def get_ci_test_commands(project_dir: Path) -> dict[str, str]:
|
||||
"""
|
||||
Get test commands from CI configuration.
|
||||
|
||||
Args:
|
||||
project_dir: Path to project root
|
||||
|
||||
Returns:
|
||||
Dictionary of test type to command
|
||||
"""
|
||||
discovery = CIDiscovery()
|
||||
result = discovery.discover(project_dir)
|
||||
if result:
|
||||
return result.test_commands
|
||||
return {}
|
||||
|
||||
|
||||
def get_ci_system(project_dir: Path) -> str | None:
|
||||
"""
|
||||
Get the CI system name if configured.
|
||||
|
||||
Args:
|
||||
project_dir: Path to project root
|
||||
|
||||
Returns:
|
||||
CI system name or None
|
||||
"""
|
||||
discovery = CIDiscovery()
|
||||
result = discovery.discover(project_dir)
|
||||
if result:
|
||||
return result.ci_system
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLI
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point for testing."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Discover CI configuration")
|
||||
parser.add_argument("project_dir", type=Path, help="Path to project root")
|
||||
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
discovery = CIDiscovery()
|
||||
result = discovery.discover(args.project_dir)
|
||||
|
||||
if not result:
|
||||
print("No CI configuration found")
|
||||
return
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(discovery.to_dict(result), indent=2))
|
||||
else:
|
||||
print(f"CI System: {result.ci_system}")
|
||||
print(f"Config Files: {', '.join(result.config_files)}")
|
||||
print("\nTest Commands:")
|
||||
for test_type, cmd in result.test_commands.items():
|
||||
print(f" {test_type}: {cmd}")
|
||||
if result.coverage_command:
|
||||
print(f"\nCoverage Command: {result.coverage_command}")
|
||||
print(f"\nWorkflows ({len(result.workflows)}):")
|
||||
for w in result.workflows:
|
||||
marker = "[TEST]" if w.test_related else ""
|
||||
print(f" - {w.name} {marker}")
|
||||
if w.trigger:
|
||||
print(f" Triggers: {', '.join(str(t) for t in w.trigger)}")
|
||||
if result.environment_variables:
|
||||
print(f"\nEnvironment Variables: {', '.join(result.environment_variables)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,641 @@
|
||||
"""
|
||||
Insight Extractor
|
||||
=================
|
||||
|
||||
Automatically extracts structured insights from completed coding sessions.
|
||||
Runs after each session to capture rich, actionable knowledge for Graphiti memory.
|
||||
|
||||
Uses the Claude Agent SDK (same as the rest of the system) for extraction.
|
||||
Falls back to generic insights if extraction fails (never blocks the build).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Check for Claude SDK availability
|
||||
try:
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
|
||||
SDK_AVAILABLE = True
|
||||
except ImportError:
|
||||
SDK_AVAILABLE = False
|
||||
ClaudeAgentOptions = None
|
||||
ClaudeSDKClient = None
|
||||
|
||||
from core.auth import ensure_claude_code_oauth_token, get_auth_token
|
||||
|
||||
# Default model for insight extraction (fast and cheap)
|
||||
DEFAULT_EXTRACTION_MODEL = "claude-3-5-haiku-latest"
|
||||
|
||||
# Maximum diff size to send to the LLM (avoid context limits)
|
||||
MAX_DIFF_CHARS = 15000
|
||||
|
||||
# Maximum attempt history entries to include
|
||||
MAX_ATTEMPTS_TO_INCLUDE = 3
|
||||
|
||||
|
||||
def is_extraction_enabled() -> bool:
|
||||
"""Check if insight extraction is enabled."""
|
||||
# Extraction requires Claude SDK and authentication token
|
||||
if not SDK_AVAILABLE:
|
||||
return False
|
||||
if not get_auth_token():
|
||||
return False
|
||||
enabled_str = os.environ.get("INSIGHT_EXTRACTION_ENABLED", "true").lower()
|
||||
return enabled_str in ("true", "1", "yes")
|
||||
|
||||
|
||||
def get_extraction_model() -> str:
|
||||
"""Get the model to use for insight extraction."""
|
||||
return os.environ.get("INSIGHT_EXTRACTOR_MODEL", DEFAULT_EXTRACTION_MODEL)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Git Helpers
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_session_diff(
|
||||
project_dir: Path,
|
||||
commit_before: str | None,
|
||||
commit_after: str | None,
|
||||
) -> str:
|
||||
"""
|
||||
Get the git diff between two commits.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
commit_before: Commit hash before session (or None)
|
||||
commit_after: Commit hash after session (or None)
|
||||
|
||||
Returns:
|
||||
Diff text (truncated if too large)
|
||||
"""
|
||||
if not commit_before or not commit_after:
|
||||
return "(No commits to diff)"
|
||||
|
||||
if commit_before == commit_after:
|
||||
return "(No changes - same commit)"
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff", commit_before, commit_after],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
diff = result.stdout
|
||||
|
||||
if len(diff) > MAX_DIFF_CHARS:
|
||||
# Truncate and add note
|
||||
diff = (
|
||||
diff[:MAX_DIFF_CHARS] + f"\n\n... (truncated, {len(diff)} chars total)"
|
||||
)
|
||||
|
||||
return diff if diff else "(Empty diff)"
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("Git diff timed out")
|
||||
return "(Git diff timed out)"
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get git diff: {e}")
|
||||
return f"(Failed to get diff: {e})"
|
||||
|
||||
|
||||
def get_changed_files(
|
||||
project_dir: Path,
|
||||
commit_before: str | None,
|
||||
commit_after: str | None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get list of files changed between two commits.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
commit_before: Commit hash before session
|
||||
commit_after: Commit hash after session
|
||||
|
||||
Returns:
|
||||
List of changed file paths
|
||||
"""
|
||||
if not commit_before or not commit_after or commit_before == commit_after:
|
||||
return []
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", commit_before, commit_after],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
files = [f.strip() for f in result.stdout.strip().split("\n") if f.strip()]
|
||||
return files
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get changed files: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def get_commit_messages(
|
||||
project_dir: Path,
|
||||
commit_before: str | None,
|
||||
commit_after: str | None,
|
||||
) -> str:
|
||||
"""Get commit messages between two commits."""
|
||||
if not commit_before or not commit_after or commit_before == commit_after:
|
||||
return "(No commits)"
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "log", "--oneline", f"{commit_before}..{commit_after}"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
return result.stdout.strip() if result.stdout.strip() else "(No commits)"
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get commit messages: {e}")
|
||||
return f"(Failed: {e})"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Input Gathering
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def gather_extraction_inputs(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
subtask_id: str,
|
||||
session_num: int,
|
||||
commit_before: str | None,
|
||||
commit_after: str | None,
|
||||
success: bool,
|
||||
recovery_manager: Any,
|
||||
) -> dict:
|
||||
"""
|
||||
Gather all inputs needed for insight extraction.
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory
|
||||
project_dir: Project root
|
||||
subtask_id: The subtask that was worked on
|
||||
session_num: Session number
|
||||
commit_before: Commit before session
|
||||
commit_after: Commit after session
|
||||
success: Whether session succeeded
|
||||
recovery_manager: Recovery manager with attempt history
|
||||
|
||||
Returns:
|
||||
Dict with all inputs for the extractor
|
||||
"""
|
||||
# Get subtask description from implementation plan
|
||||
subtask_description = _get_subtask_description(spec_dir, subtask_id)
|
||||
|
||||
# Get git diff
|
||||
diff = get_session_diff(project_dir, commit_before, commit_after)
|
||||
|
||||
# Get changed files
|
||||
changed_files = get_changed_files(project_dir, commit_before, commit_after)
|
||||
|
||||
# Get commit messages
|
||||
commit_messages = get_commit_messages(project_dir, commit_before, commit_after)
|
||||
|
||||
# Get attempt history
|
||||
attempt_history = _get_attempt_history(recovery_manager, subtask_id)
|
||||
|
||||
return {
|
||||
"subtask_id": subtask_id,
|
||||
"subtask_description": subtask_description,
|
||||
"session_num": session_num,
|
||||
"success": success,
|
||||
"diff": diff,
|
||||
"changed_files": changed_files,
|
||||
"commit_messages": commit_messages,
|
||||
"attempt_history": attempt_history,
|
||||
}
|
||||
|
||||
|
||||
def _get_subtask_description(spec_dir: Path, subtask_id: str) -> str:
|
||||
"""Get subtask description from implementation plan."""
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if not plan_file.exists():
|
||||
return f"Subtask: {subtask_id}"
|
||||
|
||||
try:
|
||||
with open(plan_file) as f:
|
||||
plan = json.load(f)
|
||||
|
||||
# Search through phases for the subtask
|
||||
for phase in plan.get("phases", []):
|
||||
for subtask in phase.get("subtasks", []):
|
||||
if subtask.get("id") == subtask_id:
|
||||
return subtask.get("description", f"Subtask: {subtask_id}")
|
||||
|
||||
return f"Subtask: {subtask_id}"
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load subtask description: {e}")
|
||||
return f"Subtask: {subtask_id}"
|
||||
|
||||
|
||||
def _get_attempt_history(recovery_manager: Any, subtask_id: str) -> list[dict]:
|
||||
"""Get previous attempt history for this subtask."""
|
||||
if not recovery_manager:
|
||||
return []
|
||||
|
||||
try:
|
||||
history = recovery_manager.get_subtask_history(subtask_id)
|
||||
attempts = history.get("attempts", [])
|
||||
|
||||
# Limit to recent attempts
|
||||
return attempts[-MAX_ATTEMPTS_TO_INCLUDE:]
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get attempt history: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# LLM Extraction
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _build_extraction_prompt(inputs: dict) -> str:
|
||||
"""Build the prompt for insight extraction."""
|
||||
prompt_file = Path(__file__).parent / "prompts" / "insight_extractor.md"
|
||||
|
||||
if prompt_file.exists():
|
||||
base_prompt = prompt_file.read_text()
|
||||
else:
|
||||
# Fallback if prompt file missing
|
||||
base_prompt = """Extract structured insights from this coding session.
|
||||
Output ONLY valid JSON with: file_insights, patterns_discovered, gotchas_discovered, approach_outcome, recommendations"""
|
||||
|
||||
# Build session context
|
||||
session_context = f"""
|
||||
---
|
||||
|
||||
## SESSION DATA
|
||||
|
||||
### Subtask
|
||||
- **ID**: {inputs["subtask_id"]}
|
||||
- **Description**: {inputs["subtask_description"]}
|
||||
- **Session Number**: {inputs["session_num"]}
|
||||
- **Outcome**: {"SUCCESS" if inputs["success"] else "FAILED"}
|
||||
|
||||
### Files Changed
|
||||
{chr(10).join(f"- {f}" for f in inputs["changed_files"]) if inputs["changed_files"] else "(No files changed)"}
|
||||
|
||||
### Commit Messages
|
||||
{inputs["commit_messages"]}
|
||||
|
||||
### Git Diff
|
||||
```diff
|
||||
{inputs["diff"]}
|
||||
```
|
||||
|
||||
### Previous Attempts
|
||||
{_format_attempt_history(inputs["attempt_history"])}
|
||||
|
||||
---
|
||||
|
||||
Now analyze this session and output ONLY the JSON object.
|
||||
"""
|
||||
|
||||
return base_prompt + session_context
|
||||
|
||||
|
||||
def _format_attempt_history(attempts: list[dict]) -> str:
|
||||
"""Format attempt history for the prompt."""
|
||||
if not attempts:
|
||||
return "(First attempt - no previous history)"
|
||||
|
||||
lines = []
|
||||
for i, attempt in enumerate(attempts, 1):
|
||||
success = "SUCCESS" if attempt.get("success") else "FAILED"
|
||||
approach = attempt.get("approach", "Unknown approach")
|
||||
error = attempt.get("error", "")
|
||||
lines.append(f"**Attempt {i}** ({success}): {approach}")
|
||||
if error:
|
||||
lines.append(f" Error: {error}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
async def run_insight_extraction(
|
||||
inputs: dict, project_dir: Path | None = None
|
||||
) -> dict | None:
|
||||
"""
|
||||
Run the insight extraction using Claude Agent SDK.
|
||||
|
||||
Args:
|
||||
inputs: Gathered session inputs
|
||||
project_dir: Project directory for SDK context (optional)
|
||||
|
||||
Returns:
|
||||
Extracted insights dict or None if failed
|
||||
"""
|
||||
if not SDK_AVAILABLE:
|
||||
logger.warning("Claude SDK not available, skipping insight extraction")
|
||||
return None
|
||||
|
||||
if not get_auth_token():
|
||||
logger.warning("No authentication token found, skipping insight extraction")
|
||||
return None
|
||||
|
||||
# Ensure SDK can find the token
|
||||
ensure_claude_code_oauth_token()
|
||||
|
||||
model = get_extraction_model()
|
||||
prompt = _build_extraction_prompt(inputs)
|
||||
|
||||
# Use current directory if project_dir not specified
|
||||
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,
|
||||
)
|
||||
|
||||
# Use async context manager
|
||||
async with client:
|
||||
await client.query(prompt)
|
||||
|
||||
# Collect the response
|
||||
response_text = ""
|
||||
message_count = 0
|
||||
text_blocks_found = 0
|
||||
|
||||
async for msg in client.receive_response():
|
||||
msg_type = type(msg).__name__
|
||||
message_count += 1
|
||||
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
# Must check block type - only TextBlock has .text attribute
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
text_blocks_found += 1
|
||||
if block.text: # Only add non-empty text
|
||||
response_text += block.text
|
||||
else:
|
||||
logger.debug(
|
||||
f"Found empty TextBlock in response (block #{text_blocks_found})"
|
||||
)
|
||||
|
||||
# Log response collection summary
|
||||
logger.debug(
|
||||
f"Insight extraction response: {message_count} messages, "
|
||||
f"{text_blocks_found} text blocks, {len(response_text)} chars collected"
|
||||
)
|
||||
|
||||
# Validate we received content before parsing
|
||||
if not response_text.strip():
|
||||
logger.warning(
|
||||
f"Insight extraction returned empty response. "
|
||||
f"Messages received: {message_count}, TextBlocks found: {text_blocks_found}. "
|
||||
f"This may indicate the AI model did not respond with text content."
|
||||
)
|
||||
return None
|
||||
|
||||
# Parse JSON from response
|
||||
return parse_insights(response_text)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Insight extraction failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def parse_insights(response_text: str) -> dict | None:
|
||||
"""
|
||||
Parse the LLM response into structured insights.
|
||||
|
||||
Args:
|
||||
response_text: Raw LLM response
|
||||
|
||||
Returns:
|
||||
Parsed insights dict or None if parsing failed
|
||||
"""
|
||||
# Try to extract JSON from the response
|
||||
text = response_text.strip()
|
||||
|
||||
# Early validation - check for empty response
|
||||
if not text:
|
||||
logger.warning("Cannot parse insights: response text is empty")
|
||||
return None
|
||||
|
||||
# Handle markdown code blocks
|
||||
if text.startswith("```"):
|
||||
# Remove code block markers
|
||||
lines = text.split("\n")
|
||||
# Remove first line (```json or ```)
|
||||
if lines[0].startswith("```"):
|
||||
lines = lines[1:]
|
||||
# Remove last line if it's ```
|
||||
if lines and lines[-1].strip() == "```":
|
||||
lines = lines[:-1]
|
||||
text = "\n".join(lines).strip()
|
||||
|
||||
# Check again after removing code blocks
|
||||
if not text:
|
||||
logger.warning(
|
||||
"Cannot parse insights: response contained only markdown code block markers with no content"
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
insights = json.loads(text)
|
||||
|
||||
# Validate structure
|
||||
if not isinstance(insights, dict):
|
||||
logger.warning(
|
||||
f"Insights is not a dict, got type: {type(insights).__name__}"
|
||||
)
|
||||
return None
|
||||
|
||||
# Ensure required keys exist with defaults
|
||||
insights.setdefault("file_insights", [])
|
||||
insights.setdefault("patterns_discovered", [])
|
||||
insights.setdefault("gotchas_discovered", [])
|
||||
insights.setdefault("approach_outcome", {})
|
||||
insights.setdefault("recommendations", [])
|
||||
|
||||
return insights
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Failed to parse insights JSON: {e}")
|
||||
# Show more context in the error message
|
||||
preview_length = min(500, len(text))
|
||||
logger.warning(
|
||||
f"Response text preview (first {preview_length} chars): {text[:preview_length]}"
|
||||
)
|
||||
if len(text) > preview_length:
|
||||
logger.warning(f"... (total length: {len(text)} chars)")
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Main Entry Point
|
||||
# =============================================================================
|
||||
|
||||
|
||||
async def extract_session_insights(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
subtask_id: str,
|
||||
session_num: int,
|
||||
commit_before: str | None,
|
||||
commit_after: str | None,
|
||||
success: bool,
|
||||
recovery_manager: Any,
|
||||
) -> dict:
|
||||
"""
|
||||
Extract insights from a completed coding session.
|
||||
|
||||
This is the main entry point called from post_session_processing().
|
||||
Falls back to generic insights if extraction fails.
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory
|
||||
project_dir: Project root
|
||||
subtask_id: Subtask that was worked on
|
||||
session_num: Session number
|
||||
commit_before: Commit before session
|
||||
commit_after: Commit after session
|
||||
success: Whether session succeeded
|
||||
recovery_manager: Recovery manager with attempt history
|
||||
|
||||
Returns:
|
||||
Insights dict (rich if extraction succeeded, generic if failed)
|
||||
"""
|
||||
# Check if extraction is enabled
|
||||
if not is_extraction_enabled():
|
||||
logger.info("Insight extraction disabled")
|
||||
return _get_generic_insights(subtask_id, success)
|
||||
|
||||
# Check for no changes
|
||||
if commit_before == commit_after:
|
||||
logger.info("No changes to extract insights from")
|
||||
return _get_generic_insights(subtask_id, success)
|
||||
|
||||
try:
|
||||
# Gather inputs
|
||||
inputs = gather_extraction_inputs(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
subtask_id=subtask_id,
|
||||
session_num=session_num,
|
||||
commit_before=commit_before,
|
||||
commit_after=commit_after,
|
||||
success=success,
|
||||
recovery_manager=recovery_manager,
|
||||
)
|
||||
|
||||
# Run extraction
|
||||
extracted = await run_insight_extraction(inputs, project_dir=project_dir)
|
||||
|
||||
if extracted:
|
||||
# Add metadata
|
||||
extracted["subtask_id"] = subtask_id
|
||||
extracted["session_num"] = session_num
|
||||
extracted["success"] = success
|
||||
extracted["changed_files"] = inputs["changed_files"]
|
||||
|
||||
logger.info(
|
||||
f"Extracted insights: {len(extracted.get('file_insights', []))} file insights, "
|
||||
f"{len(extracted.get('patterns_discovered', []))} patterns, "
|
||||
f"{len(extracted.get('gotchas_discovered', []))} gotchas"
|
||||
)
|
||||
return extracted
|
||||
else:
|
||||
logger.warning("Extraction returned no results, using generic insights")
|
||||
return _get_generic_insights(subtask_id, success)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Insight extraction failed: {e}, using generic insights")
|
||||
return _get_generic_insights(subtask_id, success)
|
||||
|
||||
|
||||
def _get_generic_insights(subtask_id: str, success: bool) -> dict:
|
||||
"""Return generic insights when extraction fails or is disabled."""
|
||||
return {
|
||||
"file_insights": [],
|
||||
"patterns_discovered": [],
|
||||
"gotchas_discovered": [],
|
||||
"approach_outcome": {
|
||||
"success": success,
|
||||
"approach_used": f"Implemented subtask: {subtask_id}",
|
||||
"why_it_worked": None,
|
||||
"why_it_failed": None,
|
||||
"alternatives_tried": [],
|
||||
},
|
||||
"recommendations": [],
|
||||
"subtask_id": subtask_id,
|
||||
"success": success,
|
||||
"changed_files": [],
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLI for Testing
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
import asyncio
|
||||
|
||||
parser = argparse.ArgumentParser(description="Test insight extraction")
|
||||
parser.add_argument("--spec-dir", type=Path, required=True, help="Spec directory")
|
||||
parser.add_argument(
|
||||
"--project-dir", type=Path, required=True, help="Project directory"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--commit-before", type=str, required=True, help="Commit before session"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--commit-after", type=str, required=True, help="Commit after session"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--subtask-id", type=str, default="test-subtask", help="Subtask ID"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
async def main():
|
||||
insights = await extract_session_insights(
|
||||
spec_dir=args.spec_dir,
|
||||
project_dir=args.project_dir,
|
||||
subtask_id=args.subtask_id,
|
||||
session_num=1,
|
||||
commit_before=args.commit_before,
|
||||
commit_after=args.commit_after,
|
||||
success=True,
|
||||
recovery_manager=None,
|
||||
)
|
||||
print(json.dumps(insights, indent=2))
|
||||
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Smart Project Analyzer for Dynamic Security Profiles
|
||||
=====================================================
|
||||
|
||||
FACADE MODULE: This module re-exports all functionality from the
|
||||
auto-claude/project/ package for backward compatibility.
|
||||
|
||||
The implementation has been refactored into focused modules:
|
||||
- project/command_registry.py - Command registries
|
||||
- project/models.py - Data structures
|
||||
- project/config_parser.py - Config file parsing
|
||||
- project/stack_detector.py - Stack detection
|
||||
- project/framework_detector.py - Framework detection
|
||||
- project/structure_analyzer.py - Project structure analysis
|
||||
- project/analyzer.py - Main orchestration
|
||||
|
||||
This file maintains the original API so existing imports continue to work.
|
||||
|
||||
This system:
|
||||
1. Detects languages, frameworks, databases, and infrastructure
|
||||
2. Parses package.json scripts, Makefile targets, pyproject.toml scripts
|
||||
3. Builds a tailored security profile for the specific project
|
||||
4. Caches the profile for subsequent runs
|
||||
5. Can re-analyze when project structure changes
|
||||
|
||||
The goal: Allow an AI developer to run any command that's legitimately
|
||||
needed for the detected tech stack, while blocking dangerous operations.
|
||||
"""
|
||||
|
||||
# Re-export all public API from the project module
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from project import (
|
||||
# Command registries
|
||||
BASE_COMMANDS,
|
||||
VALIDATED_COMMANDS,
|
||||
CustomScripts,
|
||||
# Main classes
|
||||
ProjectAnalyzer,
|
||||
SecurityProfile,
|
||||
TechnologyStack,
|
||||
# Utility functions
|
||||
get_or_create_profile,
|
||||
is_command_allowed,
|
||||
needs_validation,
|
||||
)
|
||||
|
||||
# Also re-export command registries for backward compatibility
|
||||
from project.command_registry import (
|
||||
CLOUD_COMMANDS,
|
||||
CODE_QUALITY_COMMANDS,
|
||||
DATABASE_COMMANDS,
|
||||
FRAMEWORK_COMMANDS,
|
||||
INFRASTRUCTURE_COMMANDS,
|
||||
LANGUAGE_COMMANDS,
|
||||
PACKAGE_MANAGER_COMMANDS,
|
||||
VERSION_MANAGER_COMMANDS,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Main classes
|
||||
"ProjectAnalyzer",
|
||||
"SecurityProfile",
|
||||
"TechnologyStack",
|
||||
"CustomScripts",
|
||||
# Utility functions
|
||||
"get_or_create_profile",
|
||||
"is_command_allowed",
|
||||
"needs_validation",
|
||||
# Base command sets
|
||||
"BASE_COMMANDS",
|
||||
"VALIDATED_COMMANDS",
|
||||
# Technology-specific command sets
|
||||
"LANGUAGE_COMMANDS",
|
||||
"PACKAGE_MANAGER_COMMANDS",
|
||||
"FRAMEWORK_COMMANDS",
|
||||
"DATABASE_COMMANDS",
|
||||
"INFRASTRUCTURE_COMMANDS",
|
||||
"CLOUD_COMMANDS",
|
||||
"CODE_QUALITY_COMMANDS",
|
||||
"VERSION_MANAGER_COMMANDS",
|
||||
]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLI for testing
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python project_analyzer.py <project_dir> [--force]")
|
||||
sys.exit(1)
|
||||
|
||||
project_dir = Path(sys.argv[1])
|
||||
force = "--force" in sys.argv
|
||||
|
||||
if not project_dir.exists():
|
||||
print(f"Error: {project_dir} does not exist")
|
||||
sys.exit(1)
|
||||
|
||||
profile = get_or_create_profile(project_dir, force_reanalyze=force)
|
||||
|
||||
print("\nAllowed commands:")
|
||||
for cmd in sorted(profile.get_all_allowed_commands()):
|
||||
print(f" {cmd}")
|
||||
@@ -0,0 +1,591 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Risk Classifier Module
|
||||
======================
|
||||
|
||||
Reads the AI-generated complexity_assessment.json and provides programmatic
|
||||
access to risk classification and validation recommendations.
|
||||
|
||||
This module serves as the bridge between the AI complexity assessor prompt
|
||||
and the rest of the validation system.
|
||||
|
||||
Usage:
|
||||
from risk_classifier import RiskClassifier
|
||||
|
||||
classifier = RiskClassifier()
|
||||
assessment = classifier.load_assessment(spec_dir)
|
||||
|
||||
if classifier.should_skip_validation(spec_dir):
|
||||
print("Validation can be skipped for this task")
|
||||
|
||||
test_types = classifier.get_required_test_types(spec_dir)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# =============================================================================
|
||||
# DATA CLASSES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScopeAnalysis:
|
||||
"""Analysis of task scope."""
|
||||
|
||||
estimated_files: int = 0
|
||||
estimated_services: int = 0
|
||||
is_cross_cutting: bool = False
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class IntegrationAnalysis:
|
||||
"""Analysis of external integrations."""
|
||||
|
||||
external_services: list[str] = field(default_factory=list)
|
||||
new_dependencies: list[str] = field(default_factory=list)
|
||||
research_needed: bool = False
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class InfrastructureAnalysis:
|
||||
"""Analysis of infrastructure requirements."""
|
||||
|
||||
docker_changes: bool = False
|
||||
database_changes: bool = False
|
||||
config_changes: bool = False
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class KnowledgeAnalysis:
|
||||
"""Analysis of knowledge requirements."""
|
||||
|
||||
patterns_exist: bool = True
|
||||
research_required: bool = False
|
||||
unfamiliar_tech: list[str] = field(default_factory=list)
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class RiskAnalysis:
|
||||
"""Analysis of task risk."""
|
||||
|
||||
level: str = "low" # low, medium, high
|
||||
concerns: list[str] = field(default_factory=list)
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComplexityAnalysis:
|
||||
"""Full complexity analysis from the AI assessor."""
|
||||
|
||||
scope: ScopeAnalysis = field(default_factory=ScopeAnalysis)
|
||||
integrations: IntegrationAnalysis = field(default_factory=IntegrationAnalysis)
|
||||
infrastructure: InfrastructureAnalysis = field(
|
||||
default_factory=InfrastructureAnalysis
|
||||
)
|
||||
knowledge: KnowledgeAnalysis = field(default_factory=KnowledgeAnalysis)
|
||||
risk: RiskAnalysis = field(default_factory=RiskAnalysis)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationRecommendations:
|
||||
"""Validation recommendations from the AI assessor."""
|
||||
|
||||
risk_level: str = "medium" # trivial, low, medium, high, critical
|
||||
skip_validation: bool = False
|
||||
minimal_mode: bool = False
|
||||
test_types_required: list[str] = field(default_factory=lambda: ["unit"])
|
||||
security_scan_required: bool = False
|
||||
staging_deployment_required: bool = False
|
||||
reasoning: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssessmentFlags:
|
||||
"""Flags indicating special requirements."""
|
||||
|
||||
needs_research: bool = False
|
||||
needs_self_critique: bool = False
|
||||
needs_infrastructure_setup: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class RiskAssessment:
|
||||
"""Complete risk assessment from complexity_assessment.json."""
|
||||
|
||||
complexity: str # simple, standard, complex
|
||||
workflow_type: str # feature, refactor, investigation, migration, simple
|
||||
confidence: float
|
||||
reasoning: str
|
||||
analysis: ComplexityAnalysis
|
||||
recommended_phases: list[str]
|
||||
flags: AssessmentFlags
|
||||
validation: ValidationRecommendations
|
||||
created_at: str | None = None
|
||||
|
||||
@property
|
||||
def risk_level(self) -> str:
|
||||
"""Get the risk level from validation recommendations."""
|
||||
return self.validation.risk_level
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# RISK CLASSIFIER
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class RiskClassifier:
|
||||
"""
|
||||
Reads AI-generated complexity_assessment.json and provides risk classification.
|
||||
|
||||
The complexity_assessment.json is generated by the AI complexity assessor
|
||||
agent using the complexity_assessor.md prompt. This module parses that output
|
||||
and provides programmatic access to the risk classification.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the risk classifier."""
|
||||
self._cache: dict[str, RiskAssessment] = {}
|
||||
|
||||
def load_assessment(self, spec_dir: Path) -> RiskAssessment | None:
|
||||
"""
|
||||
Load complexity_assessment.json from spec directory.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory containing complexity_assessment.json
|
||||
|
||||
Returns:
|
||||
RiskAssessment object if file exists and is valid, None otherwise
|
||||
"""
|
||||
spec_dir = Path(spec_dir)
|
||||
cache_key = str(spec_dir.resolve())
|
||||
|
||||
# Return cached result if available
|
||||
if cache_key in self._cache:
|
||||
return self._cache[cache_key]
|
||||
|
||||
assessment_file = spec_dir / "complexity_assessment.json"
|
||||
if not assessment_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(assessment_file, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
assessment = self._parse_assessment(data)
|
||||
self._cache[cache_key] = assessment
|
||||
return assessment
|
||||
|
||||
except (json.JSONDecodeError, KeyError, TypeError) as e:
|
||||
# Log error but don't crash - return None to allow fallback behavior
|
||||
print(f"Warning: Failed to parse complexity_assessment.json: {e}")
|
||||
return None
|
||||
|
||||
def _parse_assessment(self, data: dict[str, Any]) -> RiskAssessment:
|
||||
"""Parse raw JSON data into a RiskAssessment object."""
|
||||
# Parse analysis sections
|
||||
analysis_data = data.get("analysis", {})
|
||||
analysis = ComplexityAnalysis(
|
||||
scope=self._parse_scope(analysis_data.get("scope", {})),
|
||||
integrations=self._parse_integrations(
|
||||
analysis_data.get("integrations", {})
|
||||
),
|
||||
infrastructure=self._parse_infrastructure(
|
||||
analysis_data.get("infrastructure", {})
|
||||
),
|
||||
knowledge=self._parse_knowledge(analysis_data.get("knowledge", {})),
|
||||
risk=self._parse_risk(analysis_data.get("risk", {})),
|
||||
)
|
||||
|
||||
# Parse flags
|
||||
flags_data = data.get("flags", {})
|
||||
flags = AssessmentFlags(
|
||||
needs_research=flags_data.get("needs_research", False),
|
||||
needs_self_critique=flags_data.get("needs_self_critique", False),
|
||||
needs_infrastructure_setup=flags_data.get(
|
||||
"needs_infrastructure_setup", False
|
||||
),
|
||||
)
|
||||
|
||||
# Parse validation recommendations
|
||||
validation_data = data.get("validation_recommendations", {})
|
||||
validation = self._parse_validation_recommendations(validation_data, analysis)
|
||||
|
||||
return RiskAssessment(
|
||||
complexity=data.get("complexity", "standard"),
|
||||
workflow_type=data.get("workflow_type", "feature"),
|
||||
confidence=float(data.get("confidence", 0.5)),
|
||||
reasoning=data.get("reasoning", ""),
|
||||
analysis=analysis,
|
||||
recommended_phases=data.get("recommended_phases", []),
|
||||
flags=flags,
|
||||
validation=validation,
|
||||
created_at=data.get("created_at"),
|
||||
)
|
||||
|
||||
def _parse_scope(self, data: dict[str, Any]) -> ScopeAnalysis:
|
||||
"""Parse scope analysis section."""
|
||||
return ScopeAnalysis(
|
||||
estimated_files=int(data.get("estimated_files", 0)),
|
||||
estimated_services=int(data.get("estimated_services", 0)),
|
||||
is_cross_cutting=bool(data.get("is_cross_cutting", False)),
|
||||
notes=str(data.get("notes", "")),
|
||||
)
|
||||
|
||||
def _parse_integrations(self, data: dict[str, Any]) -> IntegrationAnalysis:
|
||||
"""Parse integrations analysis section."""
|
||||
return IntegrationAnalysis(
|
||||
external_services=list(data.get("external_services", [])),
|
||||
new_dependencies=list(data.get("new_dependencies", [])),
|
||||
research_needed=bool(data.get("research_needed", False)),
|
||||
notes=str(data.get("notes", "")),
|
||||
)
|
||||
|
||||
def _parse_infrastructure(self, data: dict[str, Any]) -> InfrastructureAnalysis:
|
||||
"""Parse infrastructure analysis section."""
|
||||
return InfrastructureAnalysis(
|
||||
docker_changes=bool(data.get("docker_changes", False)),
|
||||
database_changes=bool(data.get("database_changes", False)),
|
||||
config_changes=bool(data.get("config_changes", False)),
|
||||
notes=str(data.get("notes", "")),
|
||||
)
|
||||
|
||||
def _parse_knowledge(self, data: dict[str, Any]) -> KnowledgeAnalysis:
|
||||
"""Parse knowledge analysis section."""
|
||||
return KnowledgeAnalysis(
|
||||
patterns_exist=bool(data.get("patterns_exist", True)),
|
||||
research_required=bool(data.get("research_required", False)),
|
||||
unfamiliar_tech=list(data.get("unfamiliar_tech", [])),
|
||||
notes=str(data.get("notes", "")),
|
||||
)
|
||||
|
||||
def _parse_risk(self, data: dict[str, Any]) -> RiskAnalysis:
|
||||
"""Parse risk analysis section."""
|
||||
return RiskAnalysis(
|
||||
level=str(data.get("level", "low")),
|
||||
concerns=list(data.get("concerns", [])),
|
||||
notes=str(data.get("notes", "")),
|
||||
)
|
||||
|
||||
def _parse_validation_recommendations(
|
||||
self, data: dict[str, Any], analysis: ComplexityAnalysis
|
||||
) -> ValidationRecommendations:
|
||||
"""
|
||||
Parse validation recommendations section.
|
||||
|
||||
If validation_recommendations is not present in the JSON (older assessments),
|
||||
infer appropriate values from the analysis.
|
||||
"""
|
||||
if data:
|
||||
# New format with explicit validation recommendations
|
||||
return ValidationRecommendations(
|
||||
risk_level=str(data.get("risk_level", "medium")),
|
||||
skip_validation=bool(data.get("skip_validation", False)),
|
||||
minimal_mode=bool(data.get("minimal_mode", False)),
|
||||
test_types_required=list(data.get("test_types_required", ["unit"])),
|
||||
security_scan_required=bool(data.get("security_scan_required", False)),
|
||||
staging_deployment_required=bool(
|
||||
data.get("staging_deployment_required", False)
|
||||
),
|
||||
reasoning=str(data.get("reasoning", "")),
|
||||
)
|
||||
else:
|
||||
# Infer from analysis (backward compatibility)
|
||||
return self._infer_validation_recommendations(analysis)
|
||||
|
||||
def _infer_validation_recommendations(
|
||||
self, analysis: ComplexityAnalysis
|
||||
) -> ValidationRecommendations:
|
||||
"""
|
||||
Infer validation recommendations from analysis when not explicitly provided.
|
||||
|
||||
This provides backward compatibility with older complexity assessments
|
||||
that don't have the validation_recommendations section.
|
||||
"""
|
||||
risk_level = analysis.risk.level
|
||||
|
||||
# Map old risk levels to new ones
|
||||
risk_mapping = {
|
||||
"low": "low",
|
||||
"medium": "medium",
|
||||
"high": "high",
|
||||
}
|
||||
normalized_risk = risk_mapping.get(risk_level, "medium")
|
||||
|
||||
# Infer test types based on risk
|
||||
test_types_map = {
|
||||
"low": ["unit"],
|
||||
"medium": ["unit", "integration"],
|
||||
"high": ["unit", "integration", "e2e"],
|
||||
}
|
||||
test_types = test_types_map.get(normalized_risk, ["unit", "integration"])
|
||||
|
||||
# Security scan for high risk or security-related concerns
|
||||
security_keywords = [
|
||||
"security",
|
||||
"auth",
|
||||
"password",
|
||||
"credential",
|
||||
"token",
|
||||
"api key",
|
||||
]
|
||||
has_security_concerns = any(
|
||||
kw in str(analysis.risk.concerns).lower() for kw in security_keywords
|
||||
)
|
||||
security_scan_required = normalized_risk == "high" or has_security_concerns
|
||||
|
||||
# Staging for database or infrastructure changes
|
||||
staging_required = (
|
||||
analysis.infrastructure.database_changes
|
||||
and normalized_risk in ["medium", "high"]
|
||||
)
|
||||
|
||||
# Minimal mode for simple changes
|
||||
minimal_mode = (
|
||||
analysis.scope.estimated_files <= 2
|
||||
and analysis.scope.estimated_services <= 1
|
||||
and not analysis.integrations.external_services
|
||||
)
|
||||
|
||||
return ValidationRecommendations(
|
||||
risk_level=normalized_risk,
|
||||
skip_validation=False, # Never skip by inference
|
||||
minimal_mode=minimal_mode,
|
||||
test_types_required=test_types,
|
||||
security_scan_required=security_scan_required,
|
||||
staging_deployment_required=staging_required,
|
||||
reasoning="Inferred from complexity analysis (no explicit recommendations found)",
|
||||
)
|
||||
|
||||
def should_skip_validation(self, spec_dir: Path) -> bool:
|
||||
"""
|
||||
Quick check if validation can be skipped entirely.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
|
||||
Returns:
|
||||
True if validation can be skipped (trivial changes), False otherwise
|
||||
"""
|
||||
assessment = self.load_assessment(spec_dir)
|
||||
if not assessment:
|
||||
return False # When in doubt, don't skip
|
||||
|
||||
return assessment.validation.skip_validation
|
||||
|
||||
def should_use_minimal_mode(self, spec_dir: Path) -> bool:
|
||||
"""
|
||||
Check if minimal validation mode should be used.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
|
||||
Returns:
|
||||
True if minimal mode is recommended, False otherwise
|
||||
"""
|
||||
assessment = self.load_assessment(spec_dir)
|
||||
if not assessment:
|
||||
return False
|
||||
|
||||
return assessment.validation.minimal_mode
|
||||
|
||||
def get_required_test_types(self, spec_dir: Path) -> list[str]:
|
||||
"""
|
||||
Get list of required test types based on risk.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
|
||||
Returns:
|
||||
List of test types (e.g., ["unit", "integration", "e2e"])
|
||||
"""
|
||||
assessment = self.load_assessment(spec_dir)
|
||||
if not assessment:
|
||||
return ["unit"] # Default to unit tests
|
||||
|
||||
return assessment.validation.test_types_required
|
||||
|
||||
def requires_security_scan(self, spec_dir: Path) -> bool:
|
||||
"""
|
||||
Check if security scanning is required.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
|
||||
Returns:
|
||||
True if security scan is required, False otherwise
|
||||
"""
|
||||
assessment = self.load_assessment(spec_dir)
|
||||
if not assessment:
|
||||
return False
|
||||
|
||||
return assessment.validation.security_scan_required
|
||||
|
||||
def requires_staging_deployment(self, spec_dir: Path) -> bool:
|
||||
"""
|
||||
Check if staging deployment is required.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
|
||||
Returns:
|
||||
True if staging deployment is required, False otherwise
|
||||
"""
|
||||
assessment = self.load_assessment(spec_dir)
|
||||
if not assessment:
|
||||
return False
|
||||
|
||||
return assessment.validation.staging_deployment_required
|
||||
|
||||
def get_risk_level(self, spec_dir: Path) -> str:
|
||||
"""
|
||||
Get the risk level for the task.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
|
||||
Returns:
|
||||
Risk level string (trivial, low, medium, high, critical)
|
||||
"""
|
||||
assessment = self.load_assessment(spec_dir)
|
||||
if not assessment:
|
||||
return "medium" # Default to medium when unknown
|
||||
|
||||
return assessment.validation.risk_level
|
||||
|
||||
def get_complexity(self, spec_dir: Path) -> str:
|
||||
"""
|
||||
Get the complexity level for the task.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
|
||||
Returns:
|
||||
Complexity level string (simple, standard, complex)
|
||||
"""
|
||||
assessment = self.load_assessment(spec_dir)
|
||||
if not assessment:
|
||||
return "standard" # Default to standard when unknown
|
||||
|
||||
return assessment.complexity
|
||||
|
||||
def get_validation_summary(self, spec_dir: Path) -> dict[str, Any]:
|
||||
"""
|
||||
Get a summary of validation requirements.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
|
||||
Returns:
|
||||
Dictionary with validation summary
|
||||
"""
|
||||
assessment = self.load_assessment(spec_dir)
|
||||
if not assessment:
|
||||
return {
|
||||
"risk_level": "unknown",
|
||||
"complexity": "unknown",
|
||||
"skip_validation": False,
|
||||
"minimal_mode": False,
|
||||
"test_types": ["unit"],
|
||||
"security_scan": False,
|
||||
"staging_deployment": False,
|
||||
"confidence": 0.0,
|
||||
}
|
||||
|
||||
return {
|
||||
"risk_level": assessment.validation.risk_level,
|
||||
"complexity": assessment.complexity,
|
||||
"skip_validation": assessment.validation.skip_validation,
|
||||
"minimal_mode": assessment.validation.minimal_mode,
|
||||
"test_types": assessment.validation.test_types_required,
|
||||
"security_scan": assessment.validation.security_scan_required,
|
||||
"staging_deployment": assessment.validation.staging_deployment_required,
|
||||
"confidence": assessment.confidence,
|
||||
"reasoning": assessment.validation.reasoning,
|
||||
}
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear the internal cache of loaded assessments."""
|
||||
self._cache.clear()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CONVENIENCE FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def load_risk_assessment(spec_dir: Path) -> RiskAssessment | None:
|
||||
"""
|
||||
Convenience function to load a risk assessment.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
|
||||
Returns:
|
||||
RiskAssessment object or None
|
||||
"""
|
||||
classifier = RiskClassifier()
|
||||
return classifier.load_assessment(spec_dir)
|
||||
|
||||
|
||||
def get_validation_requirements(spec_dir: Path) -> dict[str, Any]:
|
||||
"""
|
||||
Convenience function to get validation requirements.
|
||||
|
||||
Args:
|
||||
spec_dir: Path to the spec directory
|
||||
|
||||
Returns:
|
||||
Dictionary with validation requirements
|
||||
"""
|
||||
classifier = RiskClassifier()
|
||||
return classifier.get_validation_summary(spec_dir)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLI
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point for testing."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Load and display risk assessment")
|
||||
parser.add_argument(
|
||||
"spec_dir",
|
||||
type=Path,
|
||||
help="Path to spec directory with complexity_assessment.json",
|
||||
)
|
||||
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
classifier = RiskClassifier()
|
||||
summary = classifier.get_validation_summary(args.spec_dir)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(summary, indent=2))
|
||||
else:
|
||||
print(f"Risk Level: {summary['risk_level']}")
|
||||
print(f"Complexity: {summary['complexity']}")
|
||||
print(f"Skip Validation: {summary['skip_validation']}")
|
||||
print(f"Minimal Mode: {summary['minimal_mode']}")
|
||||
print(f"Test Types: {', '.join(summary['test_types'])}")
|
||||
print(f"Security Scan: {summary['security_scan']}")
|
||||
print(f"Staging Deployment: {summary['staging_deployment']}")
|
||||
print(f"Confidence: {summary['confidence']:.2f}")
|
||||
if summary.get("reasoning"):
|
||||
print(f"Reasoning: {summary['reasoning']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,599 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Security Scanner Module
|
||||
=======================
|
||||
|
||||
Consolidates security scanning including secrets detection and SAST tools.
|
||||
This module integrates the existing scan_secrets.py and provides a unified
|
||||
interface for all security scanning.
|
||||
|
||||
The security scanner is used by:
|
||||
- QA Agent: To verify no secrets are committed
|
||||
- Validation Strategy: To run security scans for high-risk changes
|
||||
|
||||
Usage:
|
||||
from analysis.security_scanner import SecurityScanner
|
||||
|
||||
scanner = SecurityScanner()
|
||||
results = scanner.scan(project_dir, spec_dir)
|
||||
|
||||
if results.has_critical_issues:
|
||||
print("Security issues found - blocking QA approval")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Import the existing secrets scanner
|
||||
try:
|
||||
from security.scan_secrets import SecretMatch, get_all_tracked_files, scan_files
|
||||
|
||||
HAS_SECRETS_SCANNER = True
|
||||
except ImportError:
|
||||
HAS_SECRETS_SCANNER = False
|
||||
SecretMatch = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DATA CLASSES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class SecurityVulnerability:
|
||||
"""
|
||||
Represents a security vulnerability found during scanning.
|
||||
|
||||
Attributes:
|
||||
severity: Severity level (critical, high, medium, low, info)
|
||||
source: Which scanner found this (secrets, bandit, npm_audit, etc.)
|
||||
title: Short title of the vulnerability
|
||||
description: Detailed description
|
||||
file: File where vulnerability was found (if applicable)
|
||||
line: Line number (if applicable)
|
||||
cwe: CWE identifier if available
|
||||
"""
|
||||
|
||||
severity: str # critical, high, medium, low, info
|
||||
source: str # secrets, bandit, npm_audit, semgrep, etc.
|
||||
title: str
|
||||
description: str
|
||||
file: str | None = None
|
||||
line: int | None = None
|
||||
cwe: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SecurityScanResult:
|
||||
"""
|
||||
Result of a security scan.
|
||||
|
||||
Attributes:
|
||||
secrets: List of detected secrets
|
||||
vulnerabilities: List of security vulnerabilities
|
||||
scan_errors: List of errors during scanning
|
||||
has_critical_issues: Whether any critical issues were found
|
||||
should_block_qa: Whether these results should block QA approval
|
||||
"""
|
||||
|
||||
secrets: list[dict[str, Any]] = field(default_factory=list)
|
||||
vulnerabilities: list[SecurityVulnerability] = field(default_factory=list)
|
||||
scan_errors: list[str] = field(default_factory=list)
|
||||
has_critical_issues: bool = False
|
||||
should_block_qa: bool = False
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SECURITY SCANNER
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class SecurityScanner:
|
||||
"""
|
||||
Consolidates all security scanning operations.
|
||||
|
||||
Integrates:
|
||||
- scan_secrets.py for secrets detection
|
||||
- Bandit for Python SAST (if available)
|
||||
- npm audit for JavaScript vulnerabilities (if applicable)
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the security scanner."""
|
||||
self._bandit_available: bool | None = None
|
||||
self._npm_available: bool | None = None
|
||||
|
||||
def scan(
|
||||
self,
|
||||
project_dir: Path,
|
||||
spec_dir: Path | None = None,
|
||||
changed_files: list[str] | None = None,
|
||||
run_secrets: bool = True,
|
||||
run_sast: bool = True,
|
||||
run_dependency_audit: bool = True,
|
||||
) -> SecurityScanResult:
|
||||
"""
|
||||
Run all applicable security scans.
|
||||
|
||||
Args:
|
||||
project_dir: Path to the project root
|
||||
spec_dir: Path to the spec directory (for storing results)
|
||||
changed_files: Optional list of files to scan (if None, scans all)
|
||||
run_secrets: Whether to run secrets scanning
|
||||
run_sast: Whether to run SAST tools
|
||||
run_dependency_audit: Whether to run dependency audits
|
||||
|
||||
Returns:
|
||||
SecurityScanResult with all findings
|
||||
"""
|
||||
project_dir = Path(project_dir)
|
||||
result = SecurityScanResult()
|
||||
|
||||
# Run secrets scan
|
||||
if run_secrets:
|
||||
self._run_secrets_scan(project_dir, changed_files, result)
|
||||
|
||||
# Run SAST based on project type
|
||||
if run_sast:
|
||||
self._run_sast_scans(project_dir, result)
|
||||
|
||||
# Run dependency audits
|
||||
if run_dependency_audit:
|
||||
self._run_dependency_audits(project_dir, result)
|
||||
|
||||
# Determine if should block QA
|
||||
result.has_critical_issues = (
|
||||
any(v.severity in ["critical", "high"] for v in result.vulnerabilities)
|
||||
or len(result.secrets) > 0
|
||||
)
|
||||
|
||||
# Any secrets always block, critical vulnerabilities block
|
||||
result.should_block_qa = len(result.secrets) > 0 or any(
|
||||
v.severity == "critical" for v in result.vulnerabilities
|
||||
)
|
||||
|
||||
# Save results if spec_dir provided
|
||||
if spec_dir:
|
||||
self._save_results(spec_dir, result)
|
||||
|
||||
return result
|
||||
|
||||
def _run_secrets_scan(
|
||||
self,
|
||||
project_dir: Path,
|
||||
changed_files: list[str] | None,
|
||||
result: SecurityScanResult,
|
||||
) -> None:
|
||||
"""Run secrets scanning using scan_secrets.py."""
|
||||
if not HAS_SECRETS_SCANNER:
|
||||
result.scan_errors.append("scan_secrets module not available")
|
||||
return
|
||||
|
||||
try:
|
||||
# Get files to scan
|
||||
if changed_files:
|
||||
files_to_scan = changed_files
|
||||
else:
|
||||
files_to_scan = get_all_tracked_files()
|
||||
|
||||
# Run scan
|
||||
matches = scan_files(files_to_scan, project_dir)
|
||||
|
||||
# Convert matches to result format
|
||||
for match in matches:
|
||||
result.secrets.append(
|
||||
{
|
||||
"file": match.file_path,
|
||||
"line": match.line_number,
|
||||
"pattern": match.pattern_name,
|
||||
"matched_text": self._redact_secret(match.matched_text),
|
||||
}
|
||||
)
|
||||
|
||||
# Also add as vulnerability
|
||||
result.vulnerabilities.append(
|
||||
SecurityVulnerability(
|
||||
severity="critical",
|
||||
source="secrets",
|
||||
title=f"Potential secret: {match.pattern_name}",
|
||||
description=f"Found potential {match.pattern_name} in file",
|
||||
file=match.file_path,
|
||||
line=match.line_number,
|
||||
)
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
result.scan_errors.append(f"Secrets scan error: {str(e)}")
|
||||
|
||||
def _run_sast_scans(self, project_dir: Path, result: SecurityScanResult) -> None:
|
||||
"""Run SAST tools based on project type."""
|
||||
# Python SAST with Bandit
|
||||
if self._is_python_project(project_dir):
|
||||
self._run_bandit(project_dir, result)
|
||||
|
||||
# JavaScript/Node.js - npm audit
|
||||
# (handled in dependency audits for Node projects)
|
||||
|
||||
def _run_bandit(self, project_dir: Path, result: SecurityScanResult) -> None:
|
||||
"""Run Bandit security scanner for Python projects."""
|
||||
if not self._check_bandit_available():
|
||||
return
|
||||
|
||||
try:
|
||||
# Find Python source directories
|
||||
src_dirs = []
|
||||
for candidate in ["src", "app", project_dir.name, "."]:
|
||||
candidate_path = project_dir / candidate
|
||||
if (
|
||||
candidate_path.exists()
|
||||
and (candidate_path / "__init__.py").exists()
|
||||
):
|
||||
src_dirs.append(str(candidate_path))
|
||||
|
||||
if not src_dirs:
|
||||
# Try to find any Python files
|
||||
py_files = list(project_dir.glob("**/*.py"))
|
||||
if not py_files:
|
||||
return
|
||||
src_dirs = ["."]
|
||||
|
||||
# Run bandit
|
||||
cmd = [
|
||||
"bandit",
|
||||
"-r",
|
||||
*src_dirs,
|
||||
"-f",
|
||||
"json",
|
||||
"--exit-zero", # Don't fail on findings
|
||||
]
|
||||
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
if proc.stdout:
|
||||
try:
|
||||
bandit_output = json.loads(proc.stdout)
|
||||
for finding in bandit_output.get("results", []):
|
||||
severity = finding.get("issue_severity", "MEDIUM").lower()
|
||||
if severity == "high":
|
||||
severity = "high"
|
||||
elif severity == "medium":
|
||||
severity = "medium"
|
||||
else:
|
||||
severity = "low"
|
||||
|
||||
result.vulnerabilities.append(
|
||||
SecurityVulnerability(
|
||||
severity=severity,
|
||||
source="bandit",
|
||||
title=finding.get("issue_text", "Unknown issue"),
|
||||
description=finding.get("issue_text", ""),
|
||||
file=finding.get("filename"),
|
||||
line=finding.get("line_number"),
|
||||
cwe=finding.get("issue_cwe", {}).get("id"),
|
||||
)
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
result.scan_errors.append("Failed to parse Bandit output")
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
result.scan_errors.append("Bandit scan timed out")
|
||||
except FileNotFoundError:
|
||||
result.scan_errors.append("Bandit not found")
|
||||
except Exception as e:
|
||||
result.scan_errors.append(f"Bandit error: {str(e)}")
|
||||
|
||||
def _run_dependency_audits(
|
||||
self, project_dir: Path, result: SecurityScanResult
|
||||
) -> None:
|
||||
"""Run dependency vulnerability audits."""
|
||||
# npm audit for JavaScript projects
|
||||
if (project_dir / "package.json").exists():
|
||||
self._run_npm_audit(project_dir, result)
|
||||
|
||||
# pip-audit for Python projects (if available)
|
||||
if self._is_python_project(project_dir):
|
||||
self._run_pip_audit(project_dir, result)
|
||||
|
||||
def _run_npm_audit(self, project_dir: Path, result: SecurityScanResult) -> None:
|
||||
"""Run npm audit for JavaScript projects."""
|
||||
try:
|
||||
cmd = ["npm", "audit", "--json"]
|
||||
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
if proc.stdout:
|
||||
try:
|
||||
audit_output = json.loads(proc.stdout)
|
||||
|
||||
# npm audit v2+ format
|
||||
vulnerabilities = audit_output.get("vulnerabilities", {})
|
||||
for pkg_name, vuln_info in vulnerabilities.items():
|
||||
severity = vuln_info.get("severity", "moderate")
|
||||
if severity == "critical":
|
||||
severity = "critical"
|
||||
elif severity == "high":
|
||||
severity = "high"
|
||||
elif severity == "moderate":
|
||||
severity = "medium"
|
||||
else:
|
||||
severity = "low"
|
||||
|
||||
result.vulnerabilities.append(
|
||||
SecurityVulnerability(
|
||||
severity=severity,
|
||||
source="npm_audit",
|
||||
title=f"Vulnerable dependency: {pkg_name}",
|
||||
description=vuln_info.get("via", [{}])[0].get(
|
||||
"title", ""
|
||||
)
|
||||
if isinstance(vuln_info.get("via"), list)
|
||||
and vuln_info.get("via")
|
||||
else str(vuln_info.get("via", "")),
|
||||
file="package.json",
|
||||
)
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
pass # npm audit may return invalid JSON on no findings
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
result.scan_errors.append("npm audit timed out")
|
||||
except FileNotFoundError:
|
||||
pass # npm not available
|
||||
except Exception as e:
|
||||
result.scan_errors.append(f"npm audit error: {str(e)}")
|
||||
|
||||
def _run_pip_audit(self, project_dir: Path, result: SecurityScanResult) -> None:
|
||||
"""Run pip-audit for Python projects (if available)."""
|
||||
try:
|
||||
cmd = ["pip-audit", "--format", "json"]
|
||||
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
if proc.stdout:
|
||||
try:
|
||||
audit_output = json.loads(proc.stdout)
|
||||
for vuln in audit_output:
|
||||
severity = "high" if vuln.get("fix_versions") else "medium"
|
||||
|
||||
result.vulnerabilities.append(
|
||||
SecurityVulnerability(
|
||||
severity=severity,
|
||||
source="pip_audit",
|
||||
title=f"Vulnerable package: {vuln.get('name')}",
|
||||
description=vuln.get("description", ""),
|
||||
cwe=vuln.get("aliases", [""])[0]
|
||||
if vuln.get("aliases")
|
||||
else None,
|
||||
)
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
except FileNotFoundError:
|
||||
pass # pip-audit not available
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _is_python_project(self, project_dir: Path) -> bool:
|
||||
"""Check if this is a Python project."""
|
||||
indicators = [
|
||||
project_dir / "pyproject.toml",
|
||||
project_dir / "requirements.txt",
|
||||
project_dir / "setup.py",
|
||||
project_dir / "setup.cfg",
|
||||
]
|
||||
return any(p.exists() for p in indicators)
|
||||
|
||||
def _check_bandit_available(self) -> bool:
|
||||
"""Check if Bandit is available."""
|
||||
if self._bandit_available is None:
|
||||
try:
|
||||
subprocess.run(
|
||||
["bandit", "--version"],
|
||||
capture_output=True,
|
||||
timeout=5,
|
||||
)
|
||||
self._bandit_available = True
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
self._bandit_available = False
|
||||
return self._bandit_available
|
||||
|
||||
def _redact_secret(self, text: str) -> str:
|
||||
"""Redact a secret for safe logging."""
|
||||
if len(text) <= 8:
|
||||
return "*" * len(text)
|
||||
return text[:4] + "*" * (len(text) - 8) + text[-4:]
|
||||
|
||||
def _save_results(self, spec_dir: Path, result: SecurityScanResult) -> None:
|
||||
"""Save scan results to spec directory."""
|
||||
spec_dir = Path(spec_dir)
|
||||
spec_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
output_file = spec_dir / "security_scan_results.json"
|
||||
output_data = self.to_dict(result)
|
||||
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
json.dump(output_data, f, indent=2)
|
||||
|
||||
def to_dict(self, result: SecurityScanResult) -> dict[str, Any]:
|
||||
"""Convert result to dictionary for JSON serialization."""
|
||||
return {
|
||||
"secrets": result.secrets,
|
||||
"vulnerabilities": [
|
||||
{
|
||||
"severity": v.severity,
|
||||
"source": v.source,
|
||||
"title": v.title,
|
||||
"description": v.description,
|
||||
"file": v.file,
|
||||
"line": v.line,
|
||||
"cwe": v.cwe,
|
||||
}
|
||||
for v in result.vulnerabilities
|
||||
],
|
||||
"scan_errors": result.scan_errors,
|
||||
"has_critical_issues": result.has_critical_issues,
|
||||
"should_block_qa": result.should_block_qa,
|
||||
"summary": {
|
||||
"total_secrets": len(result.secrets),
|
||||
"total_vulnerabilities": len(result.vulnerabilities),
|
||||
"critical_count": sum(
|
||||
1 for v in result.vulnerabilities if v.severity == "critical"
|
||||
),
|
||||
"high_count": sum(
|
||||
1 for v in result.vulnerabilities if v.severity == "high"
|
||||
),
|
||||
"medium_count": sum(
|
||||
1 for v in result.vulnerabilities if v.severity == "medium"
|
||||
),
|
||||
"low_count": sum(
|
||||
1 for v in result.vulnerabilities if v.severity == "low"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CONVENIENCE FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def scan_for_security_issues(
|
||||
project_dir: Path,
|
||||
spec_dir: Path | None = None,
|
||||
changed_files: list[str] | None = None,
|
||||
) -> SecurityScanResult:
|
||||
"""
|
||||
Convenience function to run security scan.
|
||||
|
||||
Args:
|
||||
project_dir: Path to project root
|
||||
spec_dir: Optional spec directory to save results
|
||||
changed_files: Optional list of files to scan
|
||||
|
||||
Returns:
|
||||
SecurityScanResult with all findings
|
||||
"""
|
||||
scanner = SecurityScanner()
|
||||
return scanner.scan(project_dir, spec_dir, changed_files)
|
||||
|
||||
|
||||
def has_security_issues(project_dir: Path) -> bool:
|
||||
"""
|
||||
Quick check if project has security issues.
|
||||
|
||||
Args:
|
||||
project_dir: Path to project root
|
||||
|
||||
Returns:
|
||||
True if any critical/high issues found
|
||||
"""
|
||||
scanner = SecurityScanner()
|
||||
result = scanner.scan(project_dir, run_sast=False, run_dependency_audit=False)
|
||||
return result.has_critical_issues
|
||||
|
||||
|
||||
def scan_secrets_only(
|
||||
project_dir: Path,
|
||||
changed_files: list[str] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Scan only for secrets (quick scan).
|
||||
|
||||
Args:
|
||||
project_dir: Path to project root
|
||||
changed_files: Optional list of files to scan
|
||||
|
||||
Returns:
|
||||
List of detected secrets
|
||||
"""
|
||||
scanner = SecurityScanner()
|
||||
result = scanner.scan(
|
||||
project_dir,
|
||||
changed_files=changed_files,
|
||||
run_sast=False,
|
||||
run_dependency_audit=False,
|
||||
)
|
||||
return result.secrets
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLI
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point for testing."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Run security scans")
|
||||
parser.add_argument("project_dir", type=Path, help="Path to project root")
|
||||
parser.add_argument("--spec-dir", type=Path, help="Path to spec directory")
|
||||
parser.add_argument(
|
||||
"--secrets-only", action="store_true", help="Only scan for secrets"
|
||||
)
|
||||
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
scanner = SecurityScanner()
|
||||
result = scanner.scan(
|
||||
args.project_dir,
|
||||
spec_dir=args.spec_dir,
|
||||
run_sast=not args.secrets_only,
|
||||
run_dependency_audit=not args.secrets_only,
|
||||
)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(scanner.to_dict(result), indent=2))
|
||||
else:
|
||||
print(f"Secrets Found: {len(result.secrets)}")
|
||||
print(f"Vulnerabilities: {len(result.vulnerabilities)}")
|
||||
print(f"Has Critical Issues: {result.has_critical_issues}")
|
||||
print(f"Should Block QA: {result.should_block_qa}")
|
||||
|
||||
if result.secrets:
|
||||
print("\nSecrets Detected:")
|
||||
for secret in result.secrets:
|
||||
print(f" - {secret['pattern']} in {secret['file']}:{secret['line']}")
|
||||
|
||||
if result.vulnerabilities:
|
||||
print(f"\nVulnerabilities ({len(result.vulnerabilities)}):")
|
||||
for v in result.vulnerabilities:
|
||||
print(f" [{v.severity.upper()}] {v.title}")
|
||||
if v.file:
|
||||
print(f" File: {v.file}:{v.line or ''}")
|
||||
|
||||
if result.scan_errors:
|
||||
print(f"\nScan Errors ({len(result.scan_errors)}):")
|
||||
for error in result.scan_errors:
|
||||
print(f" - {error}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,690 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test Discovery Module
|
||||
=====================
|
||||
|
||||
Detects test frameworks, test commands, and test directories in a project.
|
||||
This module analyzes project configuration files to discover how tests
|
||||
should be run.
|
||||
|
||||
The test discovery results are used by:
|
||||
- QA Agent: To determine what test commands to run
|
||||
- Test Creator: To know what framework to use when creating tests
|
||||
- Planner: To include correct test commands in verification strategy
|
||||
|
||||
Usage:
|
||||
from test_discovery import TestDiscovery
|
||||
|
||||
discovery = TestDiscovery()
|
||||
result = discovery.discover(project_dir)
|
||||
|
||||
print(f"Test frameworks: {result['frameworks']}")
|
||||
print(f"Test command: {result['test_command']}")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# =============================================================================
|
||||
# DATA CLASSES
|
||||
# =============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestFramework:
|
||||
"""
|
||||
Represents a detected test framework.
|
||||
|
||||
Attributes:
|
||||
name: Name of the framework (e.g., "pytest", "jest", "vitest")
|
||||
type: Type of testing (unit, integration, e2e, all)
|
||||
command: Command to run tests
|
||||
config_file: Configuration file if found
|
||||
version: Version if detected
|
||||
coverage_command: Command for coverage if available
|
||||
"""
|
||||
|
||||
__test__ = False # Prevent pytest from collecting this as a test class
|
||||
|
||||
name: str
|
||||
type: str # unit, integration, e2e, all
|
||||
command: str
|
||||
config_file: str | None = None
|
||||
version: str | None = None
|
||||
coverage_command: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestDiscoveryResult:
|
||||
"""
|
||||
Result of test framework discovery.
|
||||
|
||||
Attributes:
|
||||
frameworks: List of detected test frameworks
|
||||
test_command: Primary test command to run
|
||||
test_directories: Discovered test directories
|
||||
package_manager: Detected package manager
|
||||
has_tests: Whether any test files were found
|
||||
coverage_command: Command for coverage if available
|
||||
"""
|
||||
|
||||
__test__ = False # Prevent pytest from collecting this as a test class
|
||||
|
||||
frameworks: list[TestFramework] = field(default_factory=list)
|
||||
test_command: str = ""
|
||||
test_directories: list[str] = field(default_factory=list)
|
||||
package_manager: str = ""
|
||||
has_tests: bool = False
|
||||
coverage_command: str | None = None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FRAMEWORK DETECTORS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
# Pattern-based framework detection
|
||||
FRAMEWORK_PATTERNS = {
|
||||
# JavaScript/TypeScript
|
||||
"jest": {
|
||||
"config_files": [
|
||||
"jest.config.js",
|
||||
"jest.config.ts",
|
||||
"jest.config.mjs",
|
||||
"jest.config.cjs",
|
||||
],
|
||||
"package_key": "jest",
|
||||
"type": "unit",
|
||||
"command": "npx jest",
|
||||
"coverage_command": "npx jest --coverage",
|
||||
},
|
||||
"vitest": {
|
||||
"config_files": ["vitest.config.js", "vitest.config.ts", "vitest.config.mjs"],
|
||||
"package_key": "vitest",
|
||||
"type": "unit",
|
||||
"command": "npx vitest run",
|
||||
"coverage_command": "npx vitest run --coverage",
|
||||
},
|
||||
"mocha": {
|
||||
"config_files": [
|
||||
".mocharc.js",
|
||||
".mocharc.json",
|
||||
".mocharc.yaml",
|
||||
".mocharc.yml",
|
||||
],
|
||||
"package_key": "mocha",
|
||||
"type": "unit",
|
||||
"command": "npx mocha",
|
||||
"coverage_command": "npx nyc mocha",
|
||||
},
|
||||
"playwright": {
|
||||
"config_files": ["playwright.config.js", "playwright.config.ts"],
|
||||
"package_key": "@playwright/test",
|
||||
"type": "e2e",
|
||||
"command": "npx playwright test",
|
||||
"coverage_command": None,
|
||||
},
|
||||
"cypress": {
|
||||
"config_files": ["cypress.config.js", "cypress.config.ts", "cypress.json"],
|
||||
"package_key": "cypress",
|
||||
"type": "e2e",
|
||||
"command": "npx cypress run",
|
||||
"coverage_command": None,
|
||||
},
|
||||
# Python
|
||||
"pytest": {
|
||||
"config_files": ["pytest.ini", "pyproject.toml", "setup.cfg", "conftest.py"],
|
||||
"pyproject_key": "pytest",
|
||||
"requirements_key": "pytest",
|
||||
"type": "all",
|
||||
"command": "pytest",
|
||||
"coverage_command": "pytest --cov",
|
||||
},
|
||||
"unittest": {
|
||||
"config_files": [],
|
||||
"type": "unit",
|
||||
"command": "python -m unittest discover",
|
||||
"coverage_command": "coverage run -m unittest discover",
|
||||
},
|
||||
# Rust
|
||||
"cargo_test": {
|
||||
"config_files": ["Cargo.toml"],
|
||||
"type": "all",
|
||||
"command": "cargo test",
|
||||
"coverage_command": "cargo tarpaulin",
|
||||
},
|
||||
# Go
|
||||
"go_test": {
|
||||
"config_files": ["go.mod"],
|
||||
"type": "all",
|
||||
"command": "go test ./...",
|
||||
"coverage_command": "go test -cover ./...",
|
||||
},
|
||||
# Ruby
|
||||
"rspec": {
|
||||
"config_files": [".rspec", "spec/spec_helper.rb"],
|
||||
"gemfile_key": "rspec",
|
||||
"type": "all",
|
||||
"command": "bundle exec rspec",
|
||||
"coverage_command": "bundle exec rspec --format documentation",
|
||||
},
|
||||
"minitest": {
|
||||
"config_files": [],
|
||||
"gemfile_key": "minitest",
|
||||
"type": "unit",
|
||||
"command": "bundle exec rake test",
|
||||
"coverage_command": None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TEST DISCOVERY
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestDiscovery:
|
||||
"""
|
||||
Discovers test frameworks and configurations in a project.
|
||||
|
||||
Analyzes:
|
||||
- Package files (package.json, pyproject.toml, Cargo.toml, etc.)
|
||||
- Configuration files (jest.config.js, pytest.ini, etc.)
|
||||
- Directory structure (tests/, spec/, __tests__/)
|
||||
"""
|
||||
|
||||
__test__ = False # Prevent pytest from collecting this as a test class
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the test discovery."""
|
||||
self._cache: dict[str, TestDiscoveryResult] = {}
|
||||
|
||||
def discover(self, project_dir: Path) -> TestDiscoveryResult:
|
||||
"""
|
||||
Discover test frameworks and configuration in the project.
|
||||
|
||||
Args:
|
||||
project_dir: Path to the project root
|
||||
|
||||
Returns:
|
||||
TestDiscoveryResult with detected frameworks and commands
|
||||
"""
|
||||
project_dir = Path(project_dir)
|
||||
cache_key = str(project_dir.resolve())
|
||||
|
||||
if cache_key in self._cache:
|
||||
return self._cache[cache_key]
|
||||
|
||||
result = TestDiscoveryResult()
|
||||
|
||||
# Detect package manager
|
||||
result.package_manager = self._detect_package_manager(project_dir)
|
||||
|
||||
# Discover frameworks based on project type
|
||||
if (project_dir / "package.json").exists():
|
||||
self._discover_js_frameworks(project_dir, result)
|
||||
|
||||
# Check for Python project indicators
|
||||
python_indicators = [
|
||||
project_dir / "pyproject.toml",
|
||||
project_dir / "requirements.txt",
|
||||
project_dir / "setup.py",
|
||||
project_dir / "pytest.ini",
|
||||
project_dir / "conftest.py",
|
||||
project_dir / "tests" / "conftest.py",
|
||||
]
|
||||
if any(p.exists() for p in python_indicators):
|
||||
self._discover_python_frameworks(project_dir, result)
|
||||
|
||||
if (project_dir / "Cargo.toml").exists():
|
||||
self._discover_rust_frameworks(project_dir, result)
|
||||
if (project_dir / "go.mod").exists():
|
||||
self._discover_go_frameworks(project_dir, result)
|
||||
if (project_dir / "Gemfile").exists():
|
||||
self._discover_ruby_frameworks(project_dir, result)
|
||||
|
||||
# Find test directories
|
||||
result.test_directories = self._find_test_directories(project_dir)
|
||||
|
||||
# Check if tests exist
|
||||
result.has_tests = self._has_test_files(project_dir, result.test_directories)
|
||||
|
||||
# Set primary test command
|
||||
if result.frameworks:
|
||||
result.test_command = result.frameworks[0].command
|
||||
|
||||
# Set coverage command from first framework that has one
|
||||
if not result.coverage_command:
|
||||
for framework in result.frameworks:
|
||||
if framework.coverage_command:
|
||||
result.coverage_command = framework.coverage_command
|
||||
break
|
||||
|
||||
self._cache[cache_key] = result
|
||||
return result
|
||||
|
||||
def _detect_package_manager(self, project_dir: Path) -> str:
|
||||
"""Detect the package manager used by the project."""
|
||||
if (project_dir / "pnpm-lock.yaml").exists():
|
||||
return "pnpm"
|
||||
if (project_dir / "yarn.lock").exists():
|
||||
return "yarn"
|
||||
if (project_dir / "package-lock.json").exists():
|
||||
return "npm"
|
||||
if (project_dir / "bun.lockb").exists() or (project_dir / "bun.lock").exists():
|
||||
return "bun"
|
||||
if (project_dir / "uv.lock").exists():
|
||||
return "uv"
|
||||
if (project_dir / "poetry.lock").exists():
|
||||
return "poetry"
|
||||
if (project_dir / "Pipfile.lock").exists():
|
||||
return "pipenv"
|
||||
if (project_dir / "Cargo.lock").exists():
|
||||
return "cargo"
|
||||
if (project_dir / "go.sum").exists():
|
||||
return "go"
|
||||
if (project_dir / "Gemfile.lock").exists():
|
||||
return "bundler"
|
||||
return ""
|
||||
|
||||
def _discover_js_frameworks(
|
||||
self, project_dir: Path, result: TestDiscoveryResult
|
||||
) -> None:
|
||||
"""Discover JavaScript/TypeScript test frameworks."""
|
||||
package_json = project_dir / "package.json"
|
||||
if not package_json.exists():
|
||||
return
|
||||
|
||||
try:
|
||||
with open(package_json, encoding="utf-8") as f:
|
||||
pkg = json.load(f)
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return
|
||||
|
||||
deps = pkg.get("dependencies", {})
|
||||
dev_deps = pkg.get("devDependencies", {})
|
||||
all_deps = {**deps, **dev_deps}
|
||||
scripts = pkg.get("scripts", {})
|
||||
|
||||
# Check for test frameworks in dependencies
|
||||
for name, pattern in FRAMEWORK_PATTERNS.items():
|
||||
if "package_key" not in pattern:
|
||||
continue
|
||||
|
||||
if pattern["package_key"] in all_deps:
|
||||
# Check for config file
|
||||
config_file = None
|
||||
for cf in pattern.get("config_files", []):
|
||||
if (project_dir / cf).exists():
|
||||
config_file = cf
|
||||
break
|
||||
|
||||
# Get version
|
||||
version = all_deps.get(pattern["package_key"], "")
|
||||
if version.startswith("^") or version.startswith("~"):
|
||||
version = version[1:]
|
||||
|
||||
# Determine command - prefer npm scripts if available
|
||||
command = pattern["command"]
|
||||
if "test" in scripts and pattern["package_key"] in scripts.get(
|
||||
"test", ""
|
||||
):
|
||||
command = f"{result.package_manager or 'npm'} test"
|
||||
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name=name,
|
||||
type=pattern["type"],
|
||||
command=command,
|
||||
config_file=config_file,
|
||||
version=version,
|
||||
coverage_command=pattern.get("coverage_command"),
|
||||
)
|
||||
)
|
||||
|
||||
# Check npm scripts for test commands
|
||||
if not result.frameworks and "test" in scripts:
|
||||
test_script = scripts["test"]
|
||||
if (
|
||||
test_script
|
||||
and test_script != 'echo "Error: no test specified" && exit 1'
|
||||
):
|
||||
# Try to infer framework from script
|
||||
framework_name = "npm_test"
|
||||
framework_type = "unit"
|
||||
|
||||
if "jest" in test_script:
|
||||
framework_name = "jest"
|
||||
elif "vitest" in test_script:
|
||||
framework_name = "vitest"
|
||||
elif "mocha" in test_script:
|
||||
framework_name = "mocha"
|
||||
elif "playwright" in test_script:
|
||||
framework_name = "playwright"
|
||||
framework_type = "e2e"
|
||||
elif "cypress" in test_script:
|
||||
framework_name = "cypress"
|
||||
framework_type = "e2e"
|
||||
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name=framework_name,
|
||||
type=framework_type,
|
||||
command=f"{result.package_manager or 'npm'} test",
|
||||
config_file=None,
|
||||
)
|
||||
)
|
||||
|
||||
def _discover_python_frameworks(
|
||||
self, project_dir: Path, result: TestDiscoveryResult
|
||||
) -> None:
|
||||
"""Discover Python test frameworks."""
|
||||
# Check for pytest.ini first (explicit pytest config)
|
||||
if (project_dir / "pytest.ini").exists():
|
||||
if not any(f.name == "pytest" for f in result.frameworks):
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name="pytest",
|
||||
type="all",
|
||||
command="pytest",
|
||||
config_file="pytest.ini",
|
||||
)
|
||||
)
|
||||
|
||||
# Check pyproject.toml
|
||||
pyproject = project_dir / "pyproject.toml"
|
||||
if pyproject.exists():
|
||||
content = pyproject.read_text()
|
||||
|
||||
# Check for pytest
|
||||
if "pytest" in content:
|
||||
if not any(f.name == "pytest" for f in result.frameworks):
|
||||
config_file = (
|
||||
"pyproject.toml" if "[tool.pytest" in content else None
|
||||
)
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name="pytest",
|
||||
type="all",
|
||||
command="pytest",
|
||||
config_file=config_file,
|
||||
)
|
||||
)
|
||||
|
||||
# Check requirements.txt
|
||||
requirements = project_dir / "requirements.txt"
|
||||
if requirements.exists():
|
||||
content = requirements.read_text().lower()
|
||||
if "pytest" in content and not any(
|
||||
f.name == "pytest" for f in result.frameworks
|
||||
):
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name="pytest",
|
||||
type="all",
|
||||
command="pytest",
|
||||
config_file=None,
|
||||
)
|
||||
)
|
||||
|
||||
# Check for conftest.py (pytest marker)
|
||||
conftest_root = project_dir / "conftest.py"
|
||||
conftest_tests = project_dir / "tests" / "conftest.py"
|
||||
if conftest_root.exists() or conftest_tests.exists():
|
||||
if not any(f.name == "pytest" for f in result.frameworks):
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name="pytest",
|
||||
type="all",
|
||||
command="pytest",
|
||||
config_file="conftest.py",
|
||||
)
|
||||
)
|
||||
|
||||
# Fall back to unittest if test files exist but no framework detected
|
||||
if not result.frameworks:
|
||||
test_dirs = self._find_test_directories(project_dir)
|
||||
if test_dirs:
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name="unittest",
|
||||
type="unit",
|
||||
command="python -m unittest discover",
|
||||
config_file=None,
|
||||
)
|
||||
)
|
||||
|
||||
def _discover_rust_frameworks(
|
||||
self, project_dir: Path, result: TestDiscoveryResult
|
||||
) -> None:
|
||||
"""Discover Rust test frameworks."""
|
||||
cargo_toml = project_dir / "Cargo.toml"
|
||||
if cargo_toml.exists():
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name="cargo_test",
|
||||
type="all",
|
||||
command="cargo test",
|
||||
config_file="Cargo.toml",
|
||||
)
|
||||
)
|
||||
|
||||
def _discover_go_frameworks(
|
||||
self, project_dir: Path, result: TestDiscoveryResult
|
||||
) -> None:
|
||||
"""Discover Go test frameworks."""
|
||||
go_mod = project_dir / "go.mod"
|
||||
if go_mod.exists():
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name="go_test",
|
||||
type="all",
|
||||
command="go test ./...",
|
||||
config_file="go.mod",
|
||||
)
|
||||
)
|
||||
|
||||
def _discover_ruby_frameworks(
|
||||
self, project_dir: Path, result: TestDiscoveryResult
|
||||
) -> None:
|
||||
"""Discover Ruby test frameworks."""
|
||||
gemfile = project_dir / "Gemfile"
|
||||
if not gemfile.exists():
|
||||
return
|
||||
|
||||
content = gemfile.read_text().lower()
|
||||
|
||||
if "rspec" in content or (project_dir / ".rspec").exists():
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name="rspec",
|
||||
type="all",
|
||||
command="bundle exec rspec",
|
||||
config_file=".rspec" if (project_dir / ".rspec").exists() else None,
|
||||
)
|
||||
)
|
||||
elif "minitest" in content:
|
||||
result.frameworks.append(
|
||||
TestFramework(
|
||||
name="minitest",
|
||||
type="unit",
|
||||
command="bundle exec rake test",
|
||||
config_file=None,
|
||||
)
|
||||
)
|
||||
|
||||
def _find_test_directories(self, project_dir: Path) -> list[str]:
|
||||
"""Find test directories in the project."""
|
||||
test_dir_patterns = [
|
||||
"tests",
|
||||
"test",
|
||||
"spec",
|
||||
"__tests__",
|
||||
"specs",
|
||||
"test_*",
|
||||
]
|
||||
|
||||
found_dirs = []
|
||||
for pattern in test_dir_patterns:
|
||||
if pattern.endswith("*"):
|
||||
# Glob pattern
|
||||
for d in project_dir.glob(pattern):
|
||||
if d.is_dir():
|
||||
found_dirs.append(str(d.relative_to(project_dir)))
|
||||
else:
|
||||
# Exact name
|
||||
test_dir = project_dir / pattern
|
||||
if test_dir.is_dir():
|
||||
found_dirs.append(pattern)
|
||||
|
||||
return found_dirs
|
||||
|
||||
def _has_test_files(self, project_dir: Path, test_directories: list[str]) -> bool:
|
||||
"""Check if any test files exist."""
|
||||
test_file_patterns = [
|
||||
"**/test_*.py",
|
||||
"**/*_test.py",
|
||||
"**/*.test.js",
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/*.spec.js",
|
||||
"**/*.spec.ts",
|
||||
"**/*.spec.tsx",
|
||||
"**/test_*.go",
|
||||
"**/*_test.go",
|
||||
"**/*_test.rs",
|
||||
"**/spec/**/*_spec.rb",
|
||||
]
|
||||
|
||||
# Check in test directories
|
||||
for test_dir in test_directories:
|
||||
test_path = project_dir / test_dir
|
||||
if test_path.exists():
|
||||
for pattern in test_file_patterns:
|
||||
if list(test_path.glob(pattern.replace("**/", ""))):
|
||||
return True
|
||||
|
||||
# Check project-wide
|
||||
for pattern in test_file_patterns:
|
||||
if list(project_dir.glob(pattern)):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def to_dict(self, result: TestDiscoveryResult) -> dict[str, Any]:
|
||||
"""Convert result to dictionary for JSON serialization."""
|
||||
return {
|
||||
"frameworks": [
|
||||
{
|
||||
"name": f.name,
|
||||
"type": f.type,
|
||||
"command": f.command,
|
||||
"config_file": f.config_file,
|
||||
"version": f.version,
|
||||
"coverage_command": f.coverage_command,
|
||||
}
|
||||
for f in result.frameworks
|
||||
],
|
||||
"test_command": result.test_command,
|
||||
"test_directories": result.test_directories,
|
||||
"package_manager": result.package_manager,
|
||||
"has_tests": result.has_tests,
|
||||
"coverage_command": result.coverage_command,
|
||||
}
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear the internal cache."""
|
||||
self._cache.clear()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CONVENIENCE FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def discover_tests(project_dir: Path) -> TestDiscoveryResult:
|
||||
"""
|
||||
Convenience function to discover tests in a project.
|
||||
|
||||
Args:
|
||||
project_dir: Path to project root
|
||||
|
||||
Returns:
|
||||
TestDiscoveryResult with detected frameworks
|
||||
"""
|
||||
discovery = TestDiscovery()
|
||||
return discovery.discover(project_dir)
|
||||
|
||||
|
||||
def get_test_command(project_dir: Path) -> str:
|
||||
"""
|
||||
Get the primary test command for a project.
|
||||
|
||||
Args:
|
||||
project_dir: Path to project root
|
||||
|
||||
Returns:
|
||||
Test command string, or empty string if not found
|
||||
"""
|
||||
discovery = TestDiscovery()
|
||||
result = discovery.discover(project_dir)
|
||||
return result.test_command
|
||||
|
||||
|
||||
def get_test_frameworks(project_dir: Path) -> list[str]:
|
||||
"""
|
||||
Get list of test framework names in a project.
|
||||
|
||||
Args:
|
||||
project_dir: Path to project root
|
||||
|
||||
Returns:
|
||||
List of framework names
|
||||
"""
|
||||
discovery = TestDiscovery()
|
||||
result = discovery.discover(project_dir)
|
||||
return [f.name for f in result.frameworks]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CLI
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point for testing."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Discover test frameworks")
|
||||
parser.add_argument("project_dir", type=Path, help="Path to project root")
|
||||
parser.add_argument("--json", action="store_true", help="Output as JSON")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
discovery = TestDiscovery()
|
||||
result = discovery.discover(args.project_dir)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(discovery.to_dict(result), indent=2))
|
||||
else:
|
||||
print(f"Package Manager: {result.package_manager or 'unknown'}")
|
||||
print(f"Has Tests: {result.has_tests}")
|
||||
print(f"Test Command: {result.test_command or 'none'}")
|
||||
print(f"Test Directories: {', '.join(result.test_directories) or 'none'}")
|
||||
print(f"Coverage Command: {result.coverage_command or 'none'}")
|
||||
print(f"\nFrameworks ({len(result.frameworks)}):")
|
||||
for f in result.frameworks:
|
||||
print(f" - {f.name} ({f.type})")
|
||||
print(f" Command: {f.command}")
|
||||
if f.config_file:
|
||||
print(f" Config: {f.config_file}")
|
||||
if f.version:
|
||||
print(f" Version: {f.version}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Analyzer facade module.
|
||||
|
||||
Provides backward compatibility for scripts that import from analyzer.py at the root.
|
||||
Actual implementation is in analysis/analyzer.py.
|
||||
"""
|
||||
|
||||
from analysis.analyzer import (
|
||||
ProjectAnalyzer,
|
||||
ServiceAnalyzer,
|
||||
analyze_project,
|
||||
analyze_service,
|
||||
main,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ServiceAnalyzer",
|
||||
"ProjectAnalyzer",
|
||||
"analyze_project",
|
||||
"analyze_service",
|
||||
"main",
|
||||
]
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Auto Claude tools module facade.
|
||||
|
||||
Provides MCP tools for agent operations.
|
||||
Re-exports from agents.tools_pkg for clean imports.
|
||||
"""
|
||||
|
||||
from agents.tools_pkg.models import ( # noqa: F401
|
||||
ELECTRON_TOOLS,
|
||||
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 agents.tools_pkg.permissions import get_allowed_tools # noqa: F401
|
||||
from agents.tools_pkg.registry import ( # noqa: F401
|
||||
create_auto_claude_mcp_server,
|
||||
is_tools_available,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"create_auto_claude_mcp_server",
|
||||
"get_allowed_tools",
|
||||
"is_tools_available",
|
||||
"TOOL_UPDATE_SUBTASK_STATUS",
|
||||
"TOOL_GET_BUILD_PROGRESS",
|
||||
"TOOL_RECORD_DISCOVERY",
|
||||
"TOOL_RECORD_GOTCHA",
|
||||
"TOOL_GET_SESSION_CONTEXT",
|
||||
"TOOL_UPDATE_QA_STATUS",
|
||||
"ELECTRON_TOOLS",
|
||||
"is_electron_mcp_enabled",
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Backward compatibility shim - import from analysis.ci_discovery instead."""
|
||||
|
||||
from analysis.ci_discovery import (
|
||||
HAS_YAML,
|
||||
CIConfig,
|
||||
CIDiscovery,
|
||||
CIWorkflow,
|
||||
discover_ci,
|
||||
get_ci_system,
|
||||
get_ci_test_commands,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CIConfig",
|
||||
"CIWorkflow",
|
||||
"CIDiscovery",
|
||||
"discover_ci",
|
||||
"get_ci_test_commands",
|
||||
"get_ci_system",
|
||||
"HAS_YAML",
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Auto Claude CLI Package
|
||||
=======================
|
||||
|
||||
Command-line interface for the Auto Claude autonomous coding framework.
|
||||
|
||||
This package provides a modular CLI structure:
|
||||
- main.py: Argument parsing and command routing
|
||||
- spec_commands.py: Spec listing and management
|
||||
- build_commands.py: Build execution and follow-up tasks
|
||||
- workspace_commands.py: Workspace management (merge, review, discard)
|
||||
- qa_commands.py: QA validation commands
|
||||
- utils.py: Shared utilities and configuration
|
||||
"""
|
||||
|
||||
from .main import main
|
||||
|
||||
__all__ = ["main"]
|
||||
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
Batch Task Management Commands
|
||||
==============================
|
||||
|
||||
Commands for creating and managing multiple tasks from batch files.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from ui import highlight, print_status
|
||||
|
||||
|
||||
def handle_batch_create_command(batch_file: str, project_dir: str) -> bool:
|
||||
"""
|
||||
Create multiple tasks from a batch JSON file.
|
||||
|
||||
Args:
|
||||
batch_file: Path to JSON file with task definitions
|
||||
project_dir: Project directory
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
batch_path = Path(batch_file)
|
||||
|
||||
if not batch_path.exists():
|
||||
print_status(f"Batch file not found: {batch_file}", "error")
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(batch_path) as f:
|
||||
batch_data = json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
print_status(f"Invalid JSON in batch file: {e}", "error")
|
||||
return False
|
||||
|
||||
tasks = batch_data.get("tasks", [])
|
||||
if not tasks:
|
||||
print_status("No tasks found in batch file", "warning")
|
||||
return False
|
||||
|
||||
print_status(f"Creating {len(tasks)} tasks from batch file", "info")
|
||||
print()
|
||||
|
||||
specs_dir = Path(project_dir) / ".auto-claude" / "specs"
|
||||
specs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Find next spec ID
|
||||
existing_specs = [d.name for d in specs_dir.iterdir() if d.is_dir()]
|
||||
next_id = (
|
||||
max([int(s.split("-")[0]) for s in existing_specs if s[0].isdigit()] or [0]) + 1
|
||||
)
|
||||
|
||||
created_specs = []
|
||||
|
||||
for idx, task in enumerate(tasks, 1):
|
||||
spec_id = f"{next_id:03d}"
|
||||
task_title = task.get("title", f"Task {idx}")
|
||||
task_slug = task_title.lower().replace(" ", "-")[:50]
|
||||
spec_name = f"{spec_id}-{task_slug}"
|
||||
spec_dir = specs_dir / spec_name
|
||||
spec_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Create requirements.json
|
||||
requirements = {
|
||||
"task_description": task.get("description", task_title),
|
||||
"description": task.get("description", task_title),
|
||||
"workflow_type": task.get("workflow_type", "feature"),
|
||||
"services_involved": task.get("services", ["frontend"]),
|
||||
"priority": task.get("priority", 5),
|
||||
"complexity_inferred": task.get("complexity", "standard"),
|
||||
"inferred_from": {},
|
||||
"created_at": Path(spec_dir).stat().st_mtime,
|
||||
"estimate": {
|
||||
"estimated_hours": task.get("estimated_hours", 4.0),
|
||||
"estimated_days": task.get("estimated_days", 0.5),
|
||||
},
|
||||
}
|
||||
|
||||
req_file = spec_dir / "requirements.json"
|
||||
with open(req_file, "w") as f:
|
||||
json.dump(requirements, f, indent=2, default=str)
|
||||
|
||||
created_specs.append(
|
||||
{
|
||||
"id": spec_id,
|
||||
"name": spec_name,
|
||||
"title": task_title,
|
||||
"status": "pending_spec_creation",
|
||||
}
|
||||
)
|
||||
|
||||
print_status(
|
||||
f"[{idx}/{len(tasks)}] Created {spec_id} - {task_title}", "success"
|
||||
)
|
||||
next_id += 1
|
||||
|
||||
print()
|
||||
print_status(f"Created {len(created_specs)} spec(s) successfully", "success")
|
||||
print()
|
||||
|
||||
# Show summary
|
||||
print(highlight("Next steps:"))
|
||||
print(" 1. Generate specs: spec_runner.py --continue <spec_id>")
|
||||
print(" 2. Approve specs and build them")
|
||||
print(" 3. Run: python run.py --spec <id> to execute")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def handle_batch_status_command(project_dir: str) -> bool:
|
||||
"""
|
||||
Show status of all specs in project.
|
||||
|
||||
Args:
|
||||
project_dir: Project directory
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
specs_dir = Path(project_dir) / ".auto-claude" / "specs"
|
||||
|
||||
if not specs_dir.exists():
|
||||
print_status("No specs found in project", "warning")
|
||||
return True
|
||||
|
||||
specs = sorted([d for d in specs_dir.iterdir() if d.is_dir()])
|
||||
|
||||
if not specs:
|
||||
print_status("No specs found", "warning")
|
||||
return True
|
||||
|
||||
print_status(f"Found {len(specs)} spec(s)", "info")
|
||||
print()
|
||||
|
||||
for spec_dir in specs:
|
||||
spec_name = spec_dir.name
|
||||
req_file = spec_dir / "requirements.json"
|
||||
|
||||
status = "unknown"
|
||||
title = spec_name
|
||||
|
||||
if req_file.exists():
|
||||
try:
|
||||
with open(req_file) as f:
|
||||
req = json.load(f)
|
||||
title = req.get("task_description", title)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Determine status
|
||||
if (spec_dir / "spec.md").exists():
|
||||
status = "spec_created"
|
||||
elif (spec_dir / "implementation_plan.json").exists():
|
||||
status = "building"
|
||||
elif (spec_dir / "qa_report.md").exists():
|
||||
status = "qa_approved"
|
||||
else:
|
||||
status = "pending_spec"
|
||||
|
||||
status_icon = {
|
||||
"pending_spec": "⏳",
|
||||
"spec_created": "📋",
|
||||
"building": "⚙️",
|
||||
"qa_approved": "✅",
|
||||
"unknown": "❓",
|
||||
}.get(status, "❓")
|
||||
|
||||
print(f"{status_icon} {spec_name:<40} {title}")
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def handle_batch_cleanup_command(project_dir: str, dry_run: bool = True) -> bool:
|
||||
"""
|
||||
Clean up completed specs and worktrees.
|
||||
|
||||
Args:
|
||||
project_dir: Project directory
|
||||
dry_run: If True, show what would be deleted
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
specs_dir = Path(project_dir) / ".auto-claude" / "specs"
|
||||
worktrees_dir = Path(project_dir) / ".auto-claude" / "worktrees" / "tasks"
|
||||
|
||||
if not specs_dir.exists():
|
||||
print_status("No specs directory found", "info")
|
||||
return True
|
||||
|
||||
# Find completed specs
|
||||
completed = []
|
||||
for spec_dir in specs_dir.iterdir():
|
||||
if spec_dir.is_dir() and (spec_dir / "qa_report.md").exists():
|
||||
completed.append(spec_dir.name)
|
||||
|
||||
if not completed:
|
||||
print_status("No completed specs to clean up", "info")
|
||||
return True
|
||||
|
||||
print_status(f"Found {len(completed)} completed spec(s)", "info")
|
||||
|
||||
if dry_run:
|
||||
print()
|
||||
print("Would remove:")
|
||||
for spec_name in completed:
|
||||
print(f" - {spec_name}")
|
||||
wt_path = worktrees_dir / spec_name
|
||||
if wt_path.exists():
|
||||
print(f" └─ .auto-claude/worktrees/tasks/{spec_name}/")
|
||||
print()
|
||||
print("Run with --no-dry-run to actually delete")
|
||||
else:
|
||||
# Actually delete specs and worktrees
|
||||
deleted_count = 0
|
||||
for spec_name in completed:
|
||||
spec_path = specs_dir / spec_name
|
||||
wt_path = worktrees_dir / spec_name
|
||||
|
||||
# Remove worktree first (if exists)
|
||||
if wt_path.exists():
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "worktree", "remove", "--force", str(wt_path)],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print_status(f"Removed worktree: {spec_name}", "success")
|
||||
else:
|
||||
# Fallback: remove directory manually if git fails
|
||||
shutil.rmtree(wt_path, ignore_errors=True)
|
||||
print_status(
|
||||
f"Removed worktree directory: {spec_name}", "success"
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
# Timeout: fall back to manual removal
|
||||
shutil.rmtree(wt_path, ignore_errors=True)
|
||||
print_status(
|
||||
f"Worktree removal timed out, removed directory: {spec_name}",
|
||||
"warning",
|
||||
)
|
||||
except Exception as e:
|
||||
print_status(
|
||||
f"Failed to remove worktree {spec_name}: {e}", "warning"
|
||||
)
|
||||
|
||||
# Remove spec directory
|
||||
if spec_path.exists():
|
||||
try:
|
||||
shutil.rmtree(spec_path)
|
||||
print_status(f"Removed spec: {spec_name}", "success")
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
print_status(f"Failed to remove spec {spec_name}: {e}", "error")
|
||||
|
||||
print()
|
||||
print_status(f"Cleaned up {deleted_count} spec(s)", "info")
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,471 @@
|
||||
"""
|
||||
Build Commands
|
||||
==============
|
||||
|
||||
CLI commands for building specs and handling the main build flow.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure parent directory is in path for imports (before other imports)
|
||||
_PARENT_DIR = Path(__file__).parent.parent
|
||||
if str(_PARENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
# Import only what we need at module level
|
||||
# Heavy imports are lazy-loaded in functions to avoid import errors
|
||||
from progress import print_paused_banner
|
||||
from review import ReviewState
|
||||
from ui import (
|
||||
BuildState,
|
||||
Icons,
|
||||
MenuOption,
|
||||
StatusManager,
|
||||
bold,
|
||||
box,
|
||||
highlight,
|
||||
icon,
|
||||
muted,
|
||||
print_status,
|
||||
select_menu,
|
||||
success,
|
||||
warning,
|
||||
)
|
||||
from workspace import (
|
||||
WorkspaceMode,
|
||||
check_existing_build,
|
||||
choose_workspace,
|
||||
finalize_workspace,
|
||||
get_existing_build_worktree,
|
||||
handle_workspace_choice,
|
||||
setup_workspace,
|
||||
)
|
||||
|
||||
from .input_handlers import (
|
||||
read_from_file,
|
||||
read_multiline_input,
|
||||
)
|
||||
|
||||
|
||||
def handle_build_command(
|
||||
project_dir: Path,
|
||||
spec_dir: Path,
|
||||
model: str,
|
||||
max_iterations: int | None,
|
||||
verbose: bool,
|
||||
force_isolated: bool,
|
||||
force_direct: bool,
|
||||
auto_continue: bool,
|
||||
skip_qa: bool,
|
||||
force_bypass_approval: bool,
|
||||
base_branch: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Handle the main build command.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_dir: Spec directory path
|
||||
model: Model to use (used as default; may be overridden by task_metadata.json)
|
||||
max_iterations: Maximum number of iterations (None for unlimited)
|
||||
verbose: Enable verbose output
|
||||
force_isolated: Force isolated workspace mode
|
||||
force_direct: Force direct workspace mode
|
||||
auto_continue: Auto-continue mode (non-interactive)
|
||||
skip_qa: Skip automatic QA validation
|
||||
force_bypass_approval: Force bypass approval check
|
||||
base_branch: Base branch for worktree creation (default: current branch)
|
||||
"""
|
||||
# Lazy imports to avoid loading heavy modules
|
||||
from agent import run_autonomous_agent, sync_spec_to_source
|
||||
from debug import (
|
||||
debug,
|
||||
debug_info,
|
||||
debug_section,
|
||||
debug_success,
|
||||
)
|
||||
from phase_config import get_phase_model
|
||||
from qa_loop import run_qa_validation_loop, should_run_qa
|
||||
|
||||
from .utils import print_banner, validate_environment
|
||||
|
||||
# Get the resolved model for the planning phase (first phase of build)
|
||||
# This respects task_metadata.json phase configuration from the UI
|
||||
planning_model = get_phase_model(spec_dir, "planning", model)
|
||||
coding_model = get_phase_model(spec_dir, "coding", model)
|
||||
qa_model = get_phase_model(spec_dir, "qa", model)
|
||||
|
||||
print_banner()
|
||||
print(f"\nProject directory: {project_dir}")
|
||||
print(f"Spec: {spec_dir.name}")
|
||||
# Show phase-specific models if they differ
|
||||
if planning_model != coding_model or coding_model != qa_model:
|
||||
print(
|
||||
f"Models: Planning={planning_model.split('-')[1] if '-' in planning_model else planning_model}, "
|
||||
f"Coding={coding_model.split('-')[1] if '-' in coding_model else coding_model}, "
|
||||
f"QA={qa_model.split('-')[1] if '-' in qa_model else qa_model}"
|
||||
)
|
||||
else:
|
||||
print(f"Model: {planning_model}")
|
||||
|
||||
if max_iterations:
|
||||
print(f"Max iterations: {max_iterations}")
|
||||
else:
|
||||
print("Max iterations: Unlimited (runs until all subtasks complete)")
|
||||
|
||||
print()
|
||||
|
||||
# Validate environment
|
||||
if not validate_environment(spec_dir):
|
||||
sys.exit(1)
|
||||
|
||||
# Check human review approval
|
||||
review_state = ReviewState.load(spec_dir)
|
||||
if not review_state.is_approval_valid(spec_dir):
|
||||
if force_bypass_approval:
|
||||
# User explicitly bypassed approval check
|
||||
print()
|
||||
print(
|
||||
warning(
|
||||
f"{icon(Icons.WARNING)} WARNING: Bypassing approval check with --force"
|
||||
)
|
||||
)
|
||||
print(muted("This spec has not been approved for building."))
|
||||
print()
|
||||
else:
|
||||
print()
|
||||
content = [
|
||||
bold(f"{icon(Icons.WARNING)} BUILD BLOCKED - REVIEW REQUIRED"),
|
||||
"",
|
||||
"This spec requires human approval before building.",
|
||||
]
|
||||
|
||||
if review_state.approved and not review_state.is_approval_valid(spec_dir):
|
||||
# Spec changed after approval
|
||||
content.append("")
|
||||
content.append(warning("The spec has been modified since approval."))
|
||||
content.append("Please re-review and re-approve.")
|
||||
|
||||
content.extend(
|
||||
[
|
||||
"",
|
||||
highlight("To review and approve:"),
|
||||
f" python auto-claude/review.py --spec-dir {spec_dir}",
|
||||
"",
|
||||
muted("Or use --force to bypass this check (not recommended)."),
|
||||
]
|
||||
)
|
||||
print(box(content, width=70, style="heavy"))
|
||||
print()
|
||||
sys.exit(1)
|
||||
else:
|
||||
debug_success(
|
||||
"run.py", "Review approval validated", approved_by=review_state.approved_by
|
||||
)
|
||||
|
||||
# Check for existing build
|
||||
if get_existing_build_worktree(project_dir, spec_dir.name):
|
||||
if auto_continue:
|
||||
# Non-interactive mode: auto-continue with existing build
|
||||
debug("run.py", "Auto-continue mode: continuing with existing build")
|
||||
print("Auto-continue: Resuming existing build...")
|
||||
else:
|
||||
continue_existing = check_existing_build(project_dir, spec_dir.name)
|
||||
if continue_existing:
|
||||
# Continue with existing worktree
|
||||
pass
|
||||
else:
|
||||
# User chose to start fresh or merged existing
|
||||
pass
|
||||
|
||||
# Choose workspace (skip for parallel mode - it always uses worktrees)
|
||||
working_dir = project_dir
|
||||
worktree_manager = None
|
||||
source_spec_dir = None # Track original spec dir for syncing back from worktree
|
||||
|
||||
# Let user choose workspace mode (or auto-select if --auto-continue)
|
||||
workspace_mode = choose_workspace(
|
||||
project_dir,
|
||||
spec_dir.name,
|
||||
force_isolated=force_isolated,
|
||||
force_direct=force_direct,
|
||||
auto_continue=auto_continue,
|
||||
)
|
||||
|
||||
if workspace_mode == WorkspaceMode.ISOLATED:
|
||||
# Keep reference to original spec directory for syncing progress back
|
||||
source_spec_dir = spec_dir
|
||||
|
||||
working_dir, worktree_manager, localized_spec_dir = setup_workspace(
|
||||
project_dir,
|
||||
spec_dir.name,
|
||||
workspace_mode,
|
||||
source_spec_dir=spec_dir,
|
||||
base_branch=base_branch,
|
||||
)
|
||||
# Use the localized spec directory (inside worktree) for AI access
|
||||
if localized_spec_dir:
|
||||
spec_dir = localized_spec_dir
|
||||
|
||||
# Run the autonomous agent
|
||||
debug_section("run.py", "Starting Build Execution")
|
||||
debug(
|
||||
"run.py",
|
||||
"Build configuration",
|
||||
model=model,
|
||||
workspace_mode=str(workspace_mode),
|
||||
working_dir=str(working_dir),
|
||||
spec_dir=str(spec_dir),
|
||||
)
|
||||
|
||||
try:
|
||||
debug("run.py", "Starting agent execution")
|
||||
|
||||
asyncio.run(
|
||||
run_autonomous_agent(
|
||||
project_dir=working_dir, # Use worktree if isolated
|
||||
spec_dir=spec_dir,
|
||||
model=model,
|
||||
max_iterations=max_iterations,
|
||||
verbose=verbose,
|
||||
source_spec_dir=source_spec_dir, # For syncing progress back to main project
|
||||
)
|
||||
)
|
||||
debug_success("run.py", "Agent execution completed")
|
||||
|
||||
# Run QA validation BEFORE finalization (while worktree still exists)
|
||||
# QA must sign off before the build is considered complete
|
||||
qa_approved = True # Default to approved if QA is skipped
|
||||
if not skip_qa and should_run_qa(spec_dir):
|
||||
print("\n" + "=" * 70)
|
||||
print(" SUBTASKS COMPLETE - STARTING QA VALIDATION")
|
||||
print("=" * 70)
|
||||
print("\nAll subtasks completed. Now running QA validation loop...")
|
||||
print("This ensures production-quality output before sign-off.\n")
|
||||
|
||||
try:
|
||||
qa_approved = asyncio.run(
|
||||
run_qa_validation_loop(
|
||||
project_dir=working_dir,
|
||||
spec_dir=spec_dir,
|
||||
model=model,
|
||||
verbose=verbose,
|
||||
)
|
||||
)
|
||||
|
||||
if qa_approved:
|
||||
print("\n" + "=" * 70)
|
||||
print(" ✅ QA VALIDATION PASSED")
|
||||
print("=" * 70)
|
||||
print("\nAll acceptance criteria verified.")
|
||||
print("The implementation is production-ready.\n")
|
||||
else:
|
||||
print("\n" + "=" * 70)
|
||||
print(" ⚠️ QA VALIDATION INCOMPLETE")
|
||||
print("=" * 70)
|
||||
print("\nSome issues require manual attention.")
|
||||
print(f"See: {spec_dir / 'qa_report.md'}")
|
||||
print(f"Or: {spec_dir / 'QA_FIX_REQUEST.md'}")
|
||||
print(
|
||||
f"\nResume QA: python auto-claude/run.py --spec {spec_dir.name} --qa\n"
|
||||
)
|
||||
|
||||
# Sync implementation plan to main project after QA
|
||||
# This ensures the main project has the latest status (human_review)
|
||||
if sync_spec_to_source(spec_dir, source_spec_dir):
|
||||
debug_info(
|
||||
"run.py", "Implementation plan synced to main project after QA"
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nQA validation paused.")
|
||||
print(f"Resume: python auto-claude/run.py --spec {spec_dir.name} --qa")
|
||||
qa_approved = False
|
||||
|
||||
# Post-build finalization (only for isolated sequential mode)
|
||||
# This happens AFTER QA validation so the worktree still exists
|
||||
if worktree_manager:
|
||||
choice = finalize_workspace(
|
||||
project_dir,
|
||||
spec_dir.name,
|
||||
worktree_manager,
|
||||
auto_continue=auto_continue,
|
||||
)
|
||||
handle_workspace_choice(
|
||||
choice, project_dir, spec_dir.name, worktree_manager
|
||||
)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
_handle_build_interrupt(
|
||||
spec_dir=spec_dir,
|
||||
project_dir=project_dir,
|
||||
worktree_manager=worktree_manager,
|
||||
working_dir=working_dir,
|
||||
model=model,
|
||||
max_iterations=max_iterations,
|
||||
verbose=verbose,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"\nFatal error: {e}")
|
||||
if verbose:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _handle_build_interrupt(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
worktree_manager,
|
||||
working_dir: Path,
|
||||
model: str,
|
||||
max_iterations: int | None,
|
||||
verbose: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Handle keyboard interrupt during build.
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory path
|
||||
project_dir: Project root directory
|
||||
worktree_manager: Worktree manager instance (if using isolated mode)
|
||||
working_dir: Current working directory
|
||||
model: Model being used
|
||||
max_iterations: Maximum iterations
|
||||
verbose: Verbose mode flag
|
||||
"""
|
||||
from agent import run_autonomous_agent
|
||||
|
||||
# Print paused banner
|
||||
print_paused_banner(spec_dir, spec_dir.name, has_worktree=bool(worktree_manager))
|
||||
|
||||
# Update status file
|
||||
status_manager = StatusManager(project_dir)
|
||||
status_manager.update(state=BuildState.PAUSED)
|
||||
|
||||
# Offer to add human input with enhanced menu
|
||||
try:
|
||||
options = [
|
||||
MenuOption(
|
||||
key="type",
|
||||
label="Type instructions",
|
||||
icon=Icons.EDIT,
|
||||
description="Enter guidance for the agent's next session",
|
||||
),
|
||||
MenuOption(
|
||||
key="paste",
|
||||
label="Paste from clipboard",
|
||||
icon=Icons.CLIPBOARD,
|
||||
description="Paste text you've copied (Cmd+V / Ctrl+Shift+V)",
|
||||
),
|
||||
MenuOption(
|
||||
key="file",
|
||||
label="Read from file",
|
||||
icon=Icons.DOCUMENT,
|
||||
description="Load instructions from a text file",
|
||||
),
|
||||
MenuOption(
|
||||
key="skip",
|
||||
label="Continue without instructions",
|
||||
icon=Icons.SKIP,
|
||||
description="Resume the build as-is",
|
||||
),
|
||||
MenuOption(
|
||||
key="quit",
|
||||
label="Quit",
|
||||
icon=Icons.DOOR,
|
||||
description="Exit without resuming",
|
||||
),
|
||||
]
|
||||
|
||||
choice = select_menu(
|
||||
title="What would you like to do?",
|
||||
options=options,
|
||||
subtitle="Progress saved. You can add instructions for the agent.",
|
||||
allow_quit=False, # We have explicit quit option
|
||||
)
|
||||
|
||||
if choice == "quit" or choice is None:
|
||||
print()
|
||||
print_status("Exiting...", "info")
|
||||
status_manager.set_inactive()
|
||||
sys.exit(0)
|
||||
|
||||
human_input = ""
|
||||
|
||||
if choice == "file":
|
||||
# Read from file
|
||||
human_input = read_from_file()
|
||||
if human_input is None:
|
||||
human_input = ""
|
||||
|
||||
elif choice in ["type", "paste"]:
|
||||
human_input = read_multiline_input("Enter/paste your instructions below.")
|
||||
if human_input is None:
|
||||
print()
|
||||
print_status("Exiting without saving instructions...", "warning")
|
||||
status_manager.set_inactive()
|
||||
sys.exit(0)
|
||||
|
||||
if human_input:
|
||||
# Save to HUMAN_INPUT.md
|
||||
input_file = spec_dir / "HUMAN_INPUT.md"
|
||||
input_file.write_text(human_input)
|
||||
|
||||
content = [
|
||||
success(f"{icon(Icons.SUCCESS)} INSTRUCTIONS SAVED"),
|
||||
"",
|
||||
f"Saved to: {highlight(str(input_file.name))}",
|
||||
"",
|
||||
muted(
|
||||
"The agent will read and follow these instructions when you resume."
|
||||
),
|
||||
]
|
||||
print()
|
||||
print(box(content, width=70, style="heavy"))
|
||||
elif choice != "skip":
|
||||
print()
|
||||
print_status("No instructions provided.", "info")
|
||||
|
||||
# If 'skip' was selected, actually resume the build
|
||||
if choice == "skip":
|
||||
print()
|
||||
print_status("Resuming build...", "info")
|
||||
status_manager.update(state=BuildState.RUNNING)
|
||||
asyncio.run(
|
||||
run_autonomous_agent(
|
||||
project_dir=working_dir,
|
||||
spec_dir=spec_dir,
|
||||
model=model,
|
||||
max_iterations=max_iterations,
|
||||
verbose=verbose,
|
||||
)
|
||||
)
|
||||
# Build completed or was interrupted again - exit
|
||||
sys.exit(0)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
# User pressed Ctrl+C again during input prompt - exit immediately
|
||||
print()
|
||||
print_status("Exiting...", "warning")
|
||||
status_manager = StatusManager(project_dir)
|
||||
status_manager.set_inactive()
|
||||
sys.exit(0)
|
||||
except EOFError:
|
||||
# stdin closed
|
||||
pass
|
||||
|
||||
# Resume instructions (shown when user provided instructions or chose file/type/paste)
|
||||
print()
|
||||
content = [
|
||||
bold(f"{icon(Icons.PLAY)} TO RESUME"),
|
||||
"",
|
||||
f"Run: {highlight(f'python auto-claude/run.py --spec {spec_dir.name}')}",
|
||||
]
|
||||
if worktree_manager:
|
||||
content.append("")
|
||||
content.append(muted("Your build is in a separate workspace and is safe."))
|
||||
print(box(content, width=70, style="light"))
|
||||
print()
|
||||
@@ -0,0 +1,375 @@
|
||||
"""
|
||||
Followup Commands
|
||||
=================
|
||||
|
||||
CLI commands for adding follow-up tasks to completed specs.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure parent directory is in path for imports (before other imports)
|
||||
_PARENT_DIR = Path(__file__).parent.parent
|
||||
if str(_PARENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
from progress import count_subtasks, is_build_complete
|
||||
from ui import (
|
||||
Icons,
|
||||
MenuOption,
|
||||
bold,
|
||||
box,
|
||||
error,
|
||||
highlight,
|
||||
icon,
|
||||
muted,
|
||||
print_status,
|
||||
select_menu,
|
||||
success,
|
||||
warning,
|
||||
)
|
||||
|
||||
|
||||
def collect_followup_task(spec_dir: Path, max_retries: int = 3) -> str | None:
|
||||
"""
|
||||
Collect a follow-up task description from the user.
|
||||
|
||||
Provides multiple input methods (type, paste, file) similar to the
|
||||
HUMAN_INPUT.md pattern used during build interrupts. Includes retry
|
||||
logic for empty input.
|
||||
|
||||
Args:
|
||||
spec_dir: The spec directory where FOLLOWUP_REQUEST.md will be saved
|
||||
max_retries: Maximum number of times to prompt on empty input (default: 3)
|
||||
|
||||
Returns:
|
||||
The collected task description, or None if cancelled
|
||||
"""
|
||||
retry_count = 0
|
||||
|
||||
while retry_count < max_retries:
|
||||
# Present options menu
|
||||
options = [
|
||||
MenuOption(
|
||||
key="type",
|
||||
label="Type follow-up task",
|
||||
icon=Icons.EDIT,
|
||||
description="Enter a description of additional work needed",
|
||||
),
|
||||
MenuOption(
|
||||
key="paste",
|
||||
label="Paste from clipboard",
|
||||
icon=Icons.CLIPBOARD,
|
||||
description="Paste text you've copied (Cmd+V / Ctrl+Shift+V)",
|
||||
),
|
||||
MenuOption(
|
||||
key="file",
|
||||
label="Read from file",
|
||||
icon=Icons.DOCUMENT,
|
||||
description="Load task description from a text file",
|
||||
),
|
||||
MenuOption(
|
||||
key="quit",
|
||||
label="Cancel",
|
||||
icon=Icons.DOOR,
|
||||
description="Exit without adding follow-up",
|
||||
),
|
||||
]
|
||||
|
||||
# Show retry message if this is a retry
|
||||
subtitle = "Describe the additional work you want to add to this spec."
|
||||
if retry_count > 0:
|
||||
subtitle = warning(
|
||||
f"Empty input received. Please try again. ({max_retries - retry_count} attempts remaining)"
|
||||
)
|
||||
|
||||
choice = select_menu(
|
||||
title="How would you like to provide your follow-up task?",
|
||||
options=options,
|
||||
subtitle=subtitle,
|
||||
allow_quit=False, # We have explicit quit option
|
||||
)
|
||||
|
||||
if choice == "quit" or choice is None:
|
||||
return None
|
||||
|
||||
followup_task = ""
|
||||
|
||||
if choice == "file":
|
||||
# Read from file
|
||||
print()
|
||||
print(
|
||||
f"{icon(Icons.DOCUMENT)} Enter the path to your task description file:"
|
||||
)
|
||||
try:
|
||||
file_path_str = input(f" {icon(Icons.POINTER)} ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
print_status("Cancelled.", "warning")
|
||||
return None
|
||||
|
||||
# Handle empty file path
|
||||
if not file_path_str:
|
||||
print()
|
||||
print_status("No file path provided.", "warning")
|
||||
retry_count += 1
|
||||
continue
|
||||
|
||||
try:
|
||||
# Expand ~ and resolve path
|
||||
file_path = Path(file_path_str).expanduser().resolve()
|
||||
if file_path.exists():
|
||||
followup_task = file_path.read_text().strip()
|
||||
if followup_task:
|
||||
print_status(
|
||||
f"Loaded {len(followup_task)} characters from file",
|
||||
"success",
|
||||
)
|
||||
else:
|
||||
print()
|
||||
print_status(
|
||||
"File is empty. Please provide a file with task description.",
|
||||
"error",
|
||||
)
|
||||
retry_count += 1
|
||||
continue
|
||||
else:
|
||||
print_status(f"File not found: {file_path}", "error")
|
||||
print(
|
||||
muted(" Check that the path is correct and the file exists.")
|
||||
)
|
||||
retry_count += 1
|
||||
continue
|
||||
except PermissionError:
|
||||
print_status(f"Permission denied: cannot read {file_path_str}", "error")
|
||||
print(muted(" Check file permissions and try again."))
|
||||
retry_count += 1
|
||||
continue
|
||||
except Exception as e:
|
||||
print_status(f"Error reading file: {e}", "error")
|
||||
retry_count += 1
|
||||
continue
|
||||
|
||||
elif choice in ["type", "paste"]:
|
||||
print()
|
||||
content = [
|
||||
"Enter/paste your follow-up task description below.",
|
||||
"",
|
||||
muted("Describe what additional work you want to add."),
|
||||
muted("The planner will create new subtasks based on this."),
|
||||
"",
|
||||
muted("Press Enter on an empty line when done."),
|
||||
]
|
||||
print(box(content, width=60, style="light"))
|
||||
print()
|
||||
|
||||
lines = []
|
||||
empty_count = 0
|
||||
while True:
|
||||
try:
|
||||
line = input()
|
||||
if line == "":
|
||||
empty_count += 1
|
||||
if empty_count >= 1: # Stop on first empty line
|
||||
break
|
||||
else:
|
||||
empty_count = 0
|
||||
lines.append(line)
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
print_status("Cancelled.", "warning")
|
||||
return None
|
||||
except EOFError:
|
||||
break
|
||||
|
||||
followup_task = "\n".join(lines).strip()
|
||||
|
||||
# Validate that we have content
|
||||
if not followup_task:
|
||||
print()
|
||||
print_status("No task description provided.", "warning")
|
||||
retry_count += 1
|
||||
continue
|
||||
|
||||
# Save to FOLLOWUP_REQUEST.md
|
||||
request_file = spec_dir / "FOLLOWUP_REQUEST.md"
|
||||
request_file.write_text(followup_task)
|
||||
|
||||
# Show confirmation
|
||||
content = [
|
||||
success(f"{icon(Icons.SUCCESS)} FOLLOW-UP TASK SAVED"),
|
||||
"",
|
||||
f"Saved to: {highlight(str(request_file.name))}",
|
||||
"",
|
||||
muted("The planner will create new subtasks based on this task."),
|
||||
]
|
||||
print()
|
||||
print(box(content, width=70, style="heavy"))
|
||||
|
||||
return followup_task
|
||||
|
||||
# Max retries exceeded
|
||||
print()
|
||||
print_status("Maximum retry attempts reached. Follow-up cancelled.", "error")
|
||||
return None
|
||||
|
||||
|
||||
def handle_followup_command(
|
||||
project_dir: Path,
|
||||
spec_dir: Path,
|
||||
model: str,
|
||||
verbose: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Handle the --followup command.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_dir: Spec directory path
|
||||
model: Model to use
|
||||
verbose: Enable verbose output
|
||||
"""
|
||||
# Lazy imports to avoid loading heavy modules
|
||||
from agent import run_followup_planner
|
||||
|
||||
from .utils import print_banner, validate_environment
|
||||
|
||||
print_banner()
|
||||
print(f"\nFollow-up request for: {spec_dir.name}")
|
||||
|
||||
# Check if implementation_plan.json exists
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if not plan_file.exists():
|
||||
print()
|
||||
print(error(f"{icon(Icons.ERROR)} No implementation plan found."))
|
||||
print()
|
||||
content = [
|
||||
"This spec has not been built yet.",
|
||||
"",
|
||||
"Follow-up tasks can only be added to specs that have been",
|
||||
"built at least once. Run a regular build first:",
|
||||
"",
|
||||
highlight(f" python auto-claude/run.py --spec {spec_dir.name}"),
|
||||
"",
|
||||
muted("After the build completes, you can add follow-up tasks."),
|
||||
]
|
||||
print(box(content, width=70, style="light"))
|
||||
sys.exit(1)
|
||||
|
||||
# Check if build is complete
|
||||
if not is_build_complete(spec_dir):
|
||||
completed, total = count_subtasks(spec_dir)
|
||||
pending = total - completed
|
||||
print()
|
||||
print(
|
||||
error(
|
||||
f"{icon(Icons.ERROR)} Build not complete ({completed}/{total} subtasks)."
|
||||
)
|
||||
)
|
||||
print()
|
||||
content = [
|
||||
f"There are still {pending} pending subtask(s) to complete.",
|
||||
"",
|
||||
"Follow-up tasks can only be added after all current subtasks",
|
||||
"are finished. Complete the current build first:",
|
||||
"",
|
||||
highlight(f" python auto-claude/run.py --spec {spec_dir.name}"),
|
||||
"",
|
||||
muted("The build will continue from where it left off."),
|
||||
]
|
||||
print(box(content, width=70, style="light"))
|
||||
sys.exit(1)
|
||||
|
||||
# Check for prior follow-ups (for sequential follow-up context)
|
||||
prior_followup_count = 0
|
||||
try:
|
||||
with open(plan_file) as f:
|
||||
plan_data = json.load(f)
|
||||
phases = plan_data.get("phases", [])
|
||||
# Count phases that look like follow-up phases (name contains "Follow" or high phase number)
|
||||
for phase in phases:
|
||||
phase_name = phase.get("name", "")
|
||||
if "follow" in phase_name.lower() or "followup" in phase_name.lower():
|
||||
prior_followup_count += 1
|
||||
except (json.JSONDecodeError, KeyError):
|
||||
pass # If plan parsing fails, just continue without prior count
|
||||
|
||||
# Build is complete - proceed to follow-up workflow
|
||||
print()
|
||||
if prior_followup_count > 0:
|
||||
print(
|
||||
success(
|
||||
f"{icon(Icons.SUCCESS)} Build is complete ({prior_followup_count} prior follow-up(s)). Ready for more follow-up tasks."
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(
|
||||
success(
|
||||
f"{icon(Icons.SUCCESS)} Build is complete. Ready for follow-up tasks."
|
||||
)
|
||||
)
|
||||
|
||||
# Collect follow-up task from user
|
||||
followup_task = collect_followup_task(spec_dir)
|
||||
|
||||
if followup_task is None:
|
||||
# User cancelled
|
||||
print()
|
||||
print_status("Follow-up cancelled.", "info")
|
||||
return
|
||||
|
||||
# Successfully collected follow-up task
|
||||
# The collect_followup_task() function already saved to FOLLOWUP_REQUEST.md
|
||||
# Now run the follow-up planner to add new subtasks
|
||||
print()
|
||||
|
||||
if not validate_environment(spec_dir):
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
success_result = asyncio.run(
|
||||
run_followup_planner(
|
||||
project_dir=project_dir,
|
||||
spec_dir=spec_dir,
|
||||
model=model,
|
||||
verbose=verbose,
|
||||
)
|
||||
)
|
||||
|
||||
if success_result:
|
||||
# Show next steps after successful planning
|
||||
content = [
|
||||
bold(f"{icon(Icons.SUCCESS)} FOLLOW-UP PLANNING COMPLETE"),
|
||||
"",
|
||||
"New subtasks have been added to your implementation plan.",
|
||||
"",
|
||||
highlight("To continue building:"),
|
||||
f" python auto-claude/run.py --spec {spec_dir.name}",
|
||||
]
|
||||
print(box(content, width=70, style="heavy"))
|
||||
else:
|
||||
# Planning didn't fully succeed
|
||||
content = [
|
||||
bold(f"{icon(Icons.WARNING)} FOLLOW-UP PLANNING INCOMPLETE"),
|
||||
"",
|
||||
"Check the implementation plan manually.",
|
||||
"",
|
||||
muted("You may need to run the follow-up again."),
|
||||
]
|
||||
print(box(content, width=70, style="light"))
|
||||
sys.exit(1)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nFollow-up planning paused.")
|
||||
print(f"To retry: python auto-claude/run.py --spec {spec_dir.name} --followup")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print()
|
||||
print(error(f"{icon(Icons.ERROR)} Follow-up planning error: {e}"))
|
||||
if verbose:
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
Input Handlers
|
||||
==============
|
||||
|
||||
Reusable user input collection utilities for CLI commands.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure parent directory is in path for imports (before other imports)
|
||||
_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,
|
||||
MenuOption,
|
||||
box,
|
||||
icon,
|
||||
muted,
|
||||
print_status,
|
||||
select_menu,
|
||||
)
|
||||
|
||||
|
||||
def collect_user_input_interactive(
|
||||
title: str,
|
||||
subtitle: str,
|
||||
prompt_text: str,
|
||||
allow_file: bool = True,
|
||||
allow_paste: bool = True,
|
||||
) -> str | None:
|
||||
"""
|
||||
Collect user input through an interactive menu.
|
||||
|
||||
Provides multiple input methods:
|
||||
- Type directly
|
||||
- Paste from clipboard
|
||||
- Read from file (optional)
|
||||
|
||||
Args:
|
||||
title: Menu title
|
||||
subtitle: Menu subtitle
|
||||
prompt_text: Text to display in the input box
|
||||
allow_file: Whether to allow file input (default: True)
|
||||
allow_paste: Whether to allow paste option (default: True)
|
||||
|
||||
Returns:
|
||||
The collected input string, or None if cancelled
|
||||
"""
|
||||
# Build options list
|
||||
options = [
|
||||
MenuOption(
|
||||
key="type",
|
||||
label="Type instructions",
|
||||
icon=Icons.EDIT,
|
||||
description="Enter text directly",
|
||||
),
|
||||
]
|
||||
|
||||
if allow_paste:
|
||||
options.append(
|
||||
MenuOption(
|
||||
key="paste",
|
||||
label="Paste from clipboard",
|
||||
icon=Icons.CLIPBOARD,
|
||||
description="Paste text you've copied (Cmd+V / Ctrl+Shift+V)",
|
||||
)
|
||||
)
|
||||
|
||||
if allow_file:
|
||||
options.append(
|
||||
MenuOption(
|
||||
key="file",
|
||||
label="Read from file",
|
||||
icon=Icons.DOCUMENT,
|
||||
description="Load text from a file",
|
||||
)
|
||||
)
|
||||
|
||||
options.extend(
|
||||
[
|
||||
MenuOption(
|
||||
key="skip",
|
||||
label="Continue without input",
|
||||
icon=Icons.SKIP,
|
||||
description="Skip this step",
|
||||
),
|
||||
MenuOption(
|
||||
key="quit",
|
||||
label="Quit",
|
||||
icon=Icons.DOOR,
|
||||
description="Exit",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
choice = select_menu(
|
||||
title=title,
|
||||
options=options,
|
||||
subtitle=subtitle,
|
||||
allow_quit=False, # We have explicit quit option
|
||||
)
|
||||
|
||||
if choice == "quit" or choice is None:
|
||||
return None
|
||||
|
||||
if choice == "skip":
|
||||
return ""
|
||||
|
||||
user_input = ""
|
||||
|
||||
if choice == "file":
|
||||
# Read from file
|
||||
user_input = read_from_file()
|
||||
if user_input is None:
|
||||
return None
|
||||
|
||||
elif choice in ["type", "paste"]:
|
||||
user_input = read_multiline_input(prompt_text)
|
||||
if user_input is None:
|
||||
return None
|
||||
|
||||
return user_input
|
||||
|
||||
|
||||
def read_from_file() -> str | None:
|
||||
"""
|
||||
Read text content from a file path provided by the user.
|
||||
|
||||
Returns:
|
||||
File contents as string, or None if cancelled/error
|
||||
"""
|
||||
print()
|
||||
print(f"{icon(Icons.DOCUMENT)} Enter the path to your file:")
|
||||
try:
|
||||
file_path_input = input(f" {icon(Icons.POINTER)} ").strip()
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
print_status("Cancelled.", "warning")
|
||||
return None
|
||||
|
||||
if not file_path_input:
|
||||
print_status("No file path provided.", "warning")
|
||||
return None
|
||||
|
||||
try:
|
||||
# Expand ~ and resolve path
|
||||
file_path = Path(file_path_input).expanduser().resolve()
|
||||
if file_path.exists():
|
||||
content = file_path.read_text().strip()
|
||||
if content:
|
||||
print_status(
|
||||
f"Loaded {len(content)} characters from file",
|
||||
"success",
|
||||
)
|
||||
return content
|
||||
else:
|
||||
print_status("File is empty.", "error")
|
||||
return None
|
||||
else:
|
||||
print_status(f"File not found: {file_path}", "error")
|
||||
return None
|
||||
except PermissionError:
|
||||
print_status(f"Permission denied: cannot read {file_path_input}", "error")
|
||||
return None
|
||||
except Exception as e:
|
||||
print_status(f"Error reading file: {e}", "error")
|
||||
return None
|
||||
|
||||
|
||||
def read_multiline_input(prompt_text: str) -> str | None:
|
||||
"""
|
||||
Read multi-line input from the user.
|
||||
|
||||
Args:
|
||||
prompt_text: Text to display in the prompt box
|
||||
|
||||
Returns:
|
||||
User input as string, or None if cancelled
|
||||
"""
|
||||
print()
|
||||
content = [
|
||||
prompt_text,
|
||||
muted("Press Enter on an empty line when done."),
|
||||
]
|
||||
print(box(content, width=60, style="light"))
|
||||
print()
|
||||
|
||||
lines = []
|
||||
empty_count = 0
|
||||
while True:
|
||||
try:
|
||||
line = input()
|
||||
if line == "":
|
||||
empty_count += 1
|
||||
if empty_count >= 1: # Stop on first empty line
|
||||
break
|
||||
else:
|
||||
empty_count = 0
|
||||
lines.append(line)
|
||||
except KeyboardInterrupt:
|
||||
print()
|
||||
print_status("Cancelled.", "warning")
|
||||
return None
|
||||
except EOFError:
|
||||
break
|
||||
|
||||
return "\n".join(lines).strip()
|
||||
@@ -0,0 +1,413 @@
|
||||
"""
|
||||
Auto Claude CLI - Main Entry Point
|
||||
===================================
|
||||
|
||||
Command-line interface for the Auto Claude autonomous coding framework.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure parent directory is in path for imports (before other imports)
|
||||
_PARENT_DIR = Path(__file__).parent.parent
|
||||
if str(_PARENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
|
||||
from .batch_commands import (
|
||||
handle_batch_cleanup_command,
|
||||
handle_batch_create_command,
|
||||
handle_batch_status_command,
|
||||
)
|
||||
from .build_commands import handle_build_command
|
||||
from .followup_commands import handle_followup_command
|
||||
from .qa_commands import (
|
||||
handle_qa_command,
|
||||
handle_qa_status_command,
|
||||
handle_review_status_command,
|
||||
)
|
||||
from .spec_commands import print_specs_list
|
||||
from .utils import (
|
||||
DEFAULT_MODEL,
|
||||
find_spec,
|
||||
get_project_dir,
|
||||
print_banner,
|
||||
setup_environment,
|
||||
)
|
||||
from .workspace_commands import (
|
||||
handle_cleanup_worktrees_command,
|
||||
handle_discard_command,
|
||||
handle_list_worktrees_command,
|
||||
handle_merge_command,
|
||||
handle_review_command,
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
"""Parse command line arguments."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Auto Claude Framework - Autonomous multi-session coding agent",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# List all specs
|
||||
python auto-claude/run.py --list
|
||||
|
||||
# Run a specific spec (by number or full name)
|
||||
python auto-claude/run.py --spec 001
|
||||
python auto-claude/run.py --spec 001-initial-app
|
||||
|
||||
# Workspace management (after build completes)
|
||||
python auto-claude/run.py --spec 001 --merge # Add build to your project
|
||||
python auto-claude/run.py --spec 001 --review # See what was built
|
||||
python auto-claude/run.py --spec 001 --discard # Delete build (with confirmation)
|
||||
|
||||
# Advanced options
|
||||
python auto-claude/run.py --spec 001 --direct # Skip workspace isolation
|
||||
python auto-claude/run.py --spec 001 --isolated # Force workspace isolation
|
||||
|
||||
# Status checks
|
||||
python auto-claude/run.py --spec 001 --review-status # Check human review status
|
||||
python auto-claude/run.py --spec 001 --qa-status # Check QA validation status
|
||||
|
||||
Prerequisites:
|
||||
1. Create a spec first: claude /spec
|
||||
2. Run 'claude setup-token' and set CLAUDE_CODE_OAUTH_TOKEN
|
||||
|
||||
Environment Variables:
|
||||
CLAUDE_CODE_OAUTH_TOKEN Your Claude Code OAuth token (required)
|
||||
Get it by running: claude setup-token
|
||||
AUTO_BUILD_MODEL Override default model (optional)
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--list",
|
||||
action="store_true",
|
||||
help="List all available specs and their status",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--spec",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Spec to run (e.g., '001' or '001-feature-name')",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--project-dir",
|
||||
type=Path,
|
||||
default=None,
|
||||
help="Project directory (default: current working directory)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--max-iterations",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Maximum number of agent sessions (default: unlimited)",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
type=str,
|
||||
default=None,
|
||||
help=f"Claude model to use (default: {DEFAULT_MODEL})",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
help="Enable verbose output",
|
||||
)
|
||||
|
||||
# Workspace options
|
||||
workspace_group = parser.add_mutually_exclusive_group()
|
||||
workspace_group.add_argument(
|
||||
"--isolated",
|
||||
action="store_true",
|
||||
help="Force building in isolated workspace (safer)",
|
||||
)
|
||||
workspace_group.add_argument(
|
||||
"--direct",
|
||||
action="store_true",
|
||||
help="Build directly in your project (no isolation)",
|
||||
)
|
||||
|
||||
# Build management commands
|
||||
build_group = parser.add_mutually_exclusive_group()
|
||||
build_group.add_argument(
|
||||
"--merge",
|
||||
action="store_true",
|
||||
help="Merge an existing build into your project",
|
||||
)
|
||||
build_group.add_argument(
|
||||
"--review",
|
||||
action="store_true",
|
||||
help="Review what an existing build contains",
|
||||
)
|
||||
build_group.add_argument(
|
||||
"--discard",
|
||||
action="store_true",
|
||||
help="Discard an existing build (requires confirmation)",
|
||||
)
|
||||
|
||||
# Merge options
|
||||
parser.add_argument(
|
||||
"--no-commit",
|
||||
action="store_true",
|
||||
help="With --merge: stage changes but don't commit (review in IDE first)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--merge-preview",
|
||||
action="store_true",
|
||||
help="Preview merge conflicts without actually merging (returns JSON)",
|
||||
)
|
||||
|
||||
# QA options
|
||||
parser.add_argument(
|
||||
"--qa",
|
||||
action="store_true",
|
||||
help="Run QA validation loop on a completed build",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--qa-status",
|
||||
action="store_true",
|
||||
help="Show QA validation status for a spec",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skip-qa",
|
||||
action="store_true",
|
||||
help="Skip automatic QA validation after build completes",
|
||||
)
|
||||
|
||||
# Follow-up options
|
||||
parser.add_argument(
|
||||
"--followup",
|
||||
action="store_true",
|
||||
help="Add follow-up tasks to a completed spec (extends existing implementation plan)",
|
||||
)
|
||||
|
||||
# Review options
|
||||
parser.add_argument(
|
||||
"--review-status",
|
||||
action="store_true",
|
||||
help="Show human review/approval status for a spec",
|
||||
)
|
||||
|
||||
# Non-interactive mode (for UI/automation)
|
||||
parser.add_argument(
|
||||
"--auto-continue",
|
||||
action="store_true",
|
||||
help="Non-interactive mode: auto-continue existing builds, skip prompts (for UI integration)",
|
||||
)
|
||||
|
||||
# Worktree management
|
||||
parser.add_argument(
|
||||
"--list-worktrees",
|
||||
action="store_true",
|
||||
help="List all spec worktrees and their status",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cleanup-worktrees",
|
||||
action="store_true",
|
||||
help="Remove all spec worktrees and their branches (with confirmation)",
|
||||
)
|
||||
|
||||
# Force bypass
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
action="store_true",
|
||||
help="Skip approval check and start build anyway (for debugging)",
|
||||
)
|
||||
|
||||
# Base branch for worktree creation
|
||||
parser.add_argument(
|
||||
"--base-branch",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Base branch for creating worktrees (default: auto-detect or current branch)",
|
||||
)
|
||||
|
||||
# Batch task management
|
||||
parser.add_argument(
|
||||
"--batch-create",
|
||||
type=str,
|
||||
default=None,
|
||||
metavar="FILE",
|
||||
help="Create multiple tasks from a batch JSON file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-status",
|
||||
action="store_true",
|
||||
help="Show status of all specs in the project",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-cleanup",
|
||||
action="store_true",
|
||||
help="Clean up completed specs (dry-run by default)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--no-dry-run",
|
||||
action="store_true",
|
||||
help="Actually delete files in cleanup (not just preview)",
|
||||
)
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main CLI entry point."""
|
||||
# Set up environment first
|
||||
setup_environment()
|
||||
|
||||
# Parse arguments
|
||||
args = parse_args()
|
||||
|
||||
# Import debug functions after environment setup
|
||||
from debug import debug, debug_error, debug_section, debug_success
|
||||
|
||||
debug_section("run.py", "Starting Auto-Build Framework")
|
||||
debug("run.py", "Arguments parsed", args=vars(args))
|
||||
|
||||
# Determine project directory
|
||||
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")
|
||||
|
||||
# Handle --list command
|
||||
if args.list:
|
||||
print_banner()
|
||||
print_specs_list(project_dir)
|
||||
return
|
||||
|
||||
# Handle --list-worktrees command
|
||||
if args.list_worktrees:
|
||||
handle_list_worktrees_command(project_dir)
|
||||
return
|
||||
|
||||
# Handle --cleanup-worktrees command
|
||||
if args.cleanup_worktrees:
|
||||
handle_cleanup_worktrees_command(project_dir)
|
||||
return
|
||||
|
||||
# Handle batch commands
|
||||
if args.batch_create:
|
||||
handle_batch_create_command(args.batch_create, str(project_dir))
|
||||
return
|
||||
|
||||
if args.batch_status:
|
||||
handle_batch_status_command(str(project_dir))
|
||||
return
|
||||
|
||||
if args.batch_cleanup:
|
||||
handle_batch_cleanup_command(str(project_dir), dry_run=not args.no_dry_run)
|
||||
return
|
||||
|
||||
# Require --spec if not listing
|
||||
if not args.spec:
|
||||
print_banner()
|
||||
print("\nError: --spec is required")
|
||||
print("\nUsage:")
|
||||
print(" python auto-claude/run.py --list # See all specs")
|
||||
print(" python auto-claude/run.py --spec 001 # Run a spec")
|
||||
print("\nCreate a new spec with:")
|
||||
print(" claude /spec")
|
||||
sys.exit(1)
|
||||
|
||||
# Find the spec
|
||||
debug("run.py", "Finding spec", spec_identifier=args.spec)
|
||||
spec_dir = find_spec(project_dir, args.spec)
|
||||
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)
|
||||
sys.exit(1)
|
||||
|
||||
debug_success("run.py", "Spec found", spec_dir=str(spec_dir))
|
||||
|
||||
# Handle build management commands
|
||||
if args.merge_preview:
|
||||
from cli.workspace_commands import handle_merge_preview_command
|
||||
|
||||
result = handle_merge_preview_command(
|
||||
project_dir, spec_dir.name, base_branch=args.base_branch
|
||||
)
|
||||
# Output as JSON for the UI to parse
|
||||
import json
|
||||
|
||||
print(json.dumps(result))
|
||||
return
|
||||
|
||||
if args.merge:
|
||||
success = handle_merge_command(
|
||||
project_dir,
|
||||
spec_dir.name,
|
||||
no_commit=args.no_commit,
|
||||
base_branch=args.base_branch,
|
||||
)
|
||||
if not success:
|
||||
sys.exit(1)
|
||||
return
|
||||
|
||||
if args.review:
|
||||
handle_review_command(project_dir, spec_dir.name)
|
||||
return
|
||||
|
||||
if args.discard:
|
||||
handle_discard_command(project_dir, spec_dir.name)
|
||||
return
|
||||
|
||||
# Handle QA commands
|
||||
if args.qa_status:
|
||||
handle_qa_status_command(spec_dir)
|
||||
return
|
||||
|
||||
if args.review_status:
|
||||
handle_review_status_command(spec_dir)
|
||||
return
|
||||
|
||||
if args.qa:
|
||||
handle_qa_command(
|
||||
project_dir=project_dir,
|
||||
spec_dir=spec_dir,
|
||||
model=model,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
return
|
||||
|
||||
# Handle --followup command
|
||||
if args.followup:
|
||||
handle_followup_command(
|
||||
project_dir=project_dir,
|
||||
spec_dir=spec_dir,
|
||||
model=model,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
return
|
||||
|
||||
# Normal build flow
|
||||
handle_build_command(
|
||||
project_dir=project_dir,
|
||||
spec_dir=spec_dir,
|
||||
model=model,
|
||||
max_iterations=args.max_iterations,
|
||||
verbose=args.verbose,
|
||||
force_isolated=args.isolated,
|
||||
force_direct=args.direct,
|
||||
auto_continue=args.auto_continue,
|
||||
skip_qa=args.skip_qa,
|
||||
force_bypass_approval=args.force,
|
||||
base_branch=args.base_branch,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
QA Commands
|
||||
===========
|
||||
|
||||
CLI commands for QA validation (run QA, check status)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure parent directory is in path for imports (before other imports)
|
||||
_PARENT_DIR = Path(__file__).parent.parent
|
||||
if str(_PARENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
from progress import count_subtasks
|
||||
from qa_loop import (
|
||||
is_qa_approved,
|
||||
print_qa_status,
|
||||
run_qa_validation_loop,
|
||||
should_run_qa,
|
||||
)
|
||||
from review import ReviewState, display_review_status
|
||||
from ui import (
|
||||
Icons,
|
||||
icon,
|
||||
info,
|
||||
success,
|
||||
warning,
|
||||
)
|
||||
|
||||
from .utils import print_banner, validate_environment
|
||||
|
||||
|
||||
def handle_qa_status_command(spec_dir: Path) -> None:
|
||||
"""
|
||||
Handle the --qa-status command.
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory path
|
||||
"""
|
||||
print_banner()
|
||||
print(f"\nSpec: {spec_dir.name}\n")
|
||||
print_qa_status(spec_dir)
|
||||
|
||||
|
||||
def handle_review_status_command(spec_dir: Path) -> None:
|
||||
"""
|
||||
Handle the --review-status command.
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory path
|
||||
"""
|
||||
print_banner()
|
||||
print(f"\nSpec: {spec_dir.name}\n")
|
||||
display_review_status(spec_dir)
|
||||
# Also show if approval is valid for build
|
||||
review_state = ReviewState.load(spec_dir)
|
||||
print()
|
||||
if review_state.is_approval_valid(spec_dir):
|
||||
print(success(f"{icon(Icons.SUCCESS)} Ready to build - approval is valid."))
|
||||
elif review_state.approved:
|
||||
print(
|
||||
warning(
|
||||
f"{icon(Icons.WARNING)} Spec changed since approval - re-review required."
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(info(f"{icon(Icons.INFO)} Review required before building."))
|
||||
print()
|
||||
|
||||
|
||||
def handle_qa_command(
|
||||
project_dir: Path,
|
||||
spec_dir: Path,
|
||||
model: str,
|
||||
verbose: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Handle the --qa command (run QA validation loop).
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_dir: Spec directory path
|
||||
model: Model to use for QA
|
||||
verbose: Enable verbose output
|
||||
"""
|
||||
print_banner()
|
||||
print(f"\nRunning QA validation for: {spec_dir.name}")
|
||||
if not validate_environment(spec_dir):
|
||||
sys.exit(1)
|
||||
|
||||
# Check if there's pending human feedback that needs to be processed
|
||||
# Human feedback takes priority over "already approved" status
|
||||
fix_request_file = spec_dir / "QA_FIX_REQUEST.md"
|
||||
has_human_feedback = fix_request_file.exists()
|
||||
|
||||
if not should_run_qa(spec_dir) and not has_human_feedback:
|
||||
if is_qa_approved(spec_dir):
|
||||
print("\n✅ Build already approved by QA.")
|
||||
else:
|
||||
completed, total = count_subtasks(spec_dir)
|
||||
print(f"\n❌ Build not complete ({completed}/{total} subtasks).")
|
||||
print("Complete all subtasks before running QA validation.")
|
||||
return
|
||||
|
||||
if has_human_feedback:
|
||||
print("\n📝 Human feedback detected - processing fix request...")
|
||||
|
||||
try:
|
||||
approved = asyncio.run(
|
||||
run_qa_validation_loop(
|
||||
project_dir=project_dir,
|
||||
spec_dir=spec_dir,
|
||||
model=model,
|
||||
verbose=verbose,
|
||||
)
|
||||
)
|
||||
if approved:
|
||||
print("\n✅ QA validation passed. Ready for merge.")
|
||||
else:
|
||||
print("\n❌ QA validation incomplete. See reports for details.")
|
||||
sys.exit(1)
|
||||
except KeyboardInterrupt:
|
||||
print("\n\nQA validation paused.")
|
||||
print(f"Resume with: python auto-claude/run.py --spec {spec_dir.name} --qa")
|
||||
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
Spec Commands
|
||||
=============
|
||||
|
||||
CLI commands for managing specs (listing, finding, etc.)
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure parent directory is in path for imports (before other imports)
|
||||
_PARENT_DIR = Path(__file__).parent.parent
|
||||
if str(_PARENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
from progress import count_subtasks
|
||||
from workspace import get_existing_build_worktree
|
||||
|
||||
from .utils import get_specs_dir
|
||||
|
||||
|
||||
def list_specs(project_dir: Path) -> list[dict]:
|
||||
"""
|
||||
List all specs in the project.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
|
||||
Returns:
|
||||
List of spec info dicts with keys: number, name, path, status, progress
|
||||
"""
|
||||
specs_dir = get_specs_dir(project_dir)
|
||||
specs = []
|
||||
|
||||
if not specs_dir.exists():
|
||||
return specs
|
||||
|
||||
for spec_folder in sorted(specs_dir.iterdir()):
|
||||
if not spec_folder.is_dir():
|
||||
continue
|
||||
|
||||
# Parse folder name (e.g., "001-initial-app")
|
||||
folder_name = spec_folder.name
|
||||
parts = folder_name.split("-", 1)
|
||||
if len(parts) != 2 or not parts[0].isdigit():
|
||||
continue
|
||||
|
||||
number = parts[0]
|
||||
name = parts[1]
|
||||
|
||||
# Check for spec.md
|
||||
spec_file = spec_folder / "spec.md"
|
||||
if not spec_file.exists():
|
||||
continue
|
||||
|
||||
# Check for existing build in worktree
|
||||
has_build = get_existing_build_worktree(project_dir, folder_name) is not None
|
||||
|
||||
# Check progress via implementation_plan.json
|
||||
plan_file = spec_folder / "implementation_plan.json"
|
||||
if plan_file.exists():
|
||||
completed, total = count_subtasks(spec_folder)
|
||||
if total > 0:
|
||||
if completed == total:
|
||||
status = "complete"
|
||||
else:
|
||||
status = "in_progress"
|
||||
progress = f"{completed}/{total}"
|
||||
else:
|
||||
status = "initialized"
|
||||
progress = "0/0"
|
||||
else:
|
||||
status = "pending"
|
||||
progress = "-"
|
||||
|
||||
# Add build indicator
|
||||
if has_build:
|
||||
status = f"{status} (has build)"
|
||||
|
||||
specs.append(
|
||||
{
|
||||
"number": number,
|
||||
"name": name,
|
||||
"folder": folder_name,
|
||||
"path": spec_folder,
|
||||
"status": status,
|
||||
"progress": progress,
|
||||
"has_build": has_build,
|
||||
}
|
||||
)
|
||||
|
||||
return specs
|
||||
|
||||
|
||||
def print_specs_list(project_dir: Path, auto_create: bool = True) -> None:
|
||||
"""Print a formatted list of all specs.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
auto_create: If True and no specs exist, automatically launch spec creation
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
specs = list_specs(project_dir)
|
||||
|
||||
if not specs:
|
||||
print("\nNo specs found.")
|
||||
|
||||
if auto_create:
|
||||
# Get the backend directory and find spec_runner.py
|
||||
backend_dir = Path(__file__).parent.parent
|
||||
spec_runner = backend_dir / "runners" / "spec_runner.py"
|
||||
|
||||
# Find Python executable - use current interpreter
|
||||
python_path = sys.executable
|
||||
|
||||
if spec_runner.exists() and python_path:
|
||||
# Quick prompt for task description
|
||||
print("\n" + "=" * 60)
|
||||
print(" QUICK START")
|
||||
print("=" * 60)
|
||||
print("\nWhat do you want to build?")
|
||||
print(
|
||||
"(Enter a brief description, or press Enter for interactive mode)\n"
|
||||
)
|
||||
|
||||
try:
|
||||
task = input("> ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print("\nCancelled.")
|
||||
return
|
||||
|
||||
if task:
|
||||
# Direct mode: create spec and start building
|
||||
print(f"\nStarting build for: {task}\n")
|
||||
subprocess.run(
|
||||
[
|
||||
python_path,
|
||||
str(spec_runner),
|
||||
"--task",
|
||||
task,
|
||||
"--complexity",
|
||||
"simple",
|
||||
"--auto-approve",
|
||||
],
|
||||
cwd=project_dir,
|
||||
)
|
||||
else:
|
||||
# Interactive mode
|
||||
print("\nLaunching interactive mode...\n")
|
||||
subprocess.run(
|
||||
[python_path, str(spec_runner), "--interactive"],
|
||||
cwd=project_dir,
|
||||
)
|
||||
return
|
||||
else:
|
||||
print("\nCreate your first spec:")
|
||||
print(" python runners/spec_runner.py --interactive")
|
||||
else:
|
||||
print("\nCreate your first spec:")
|
||||
print(" python runners/spec_runner.py --interactive")
|
||||
return
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print(" AVAILABLE SPECS")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
# Status symbols
|
||||
status_symbols = {
|
||||
"complete": "[OK]",
|
||||
"in_progress": "[..]",
|
||||
"initialized": "[--]",
|
||||
"pending": "[ ]",
|
||||
}
|
||||
|
||||
for spec in specs:
|
||||
# Get base status for symbol
|
||||
base_status = spec["status"].split(" ")[0]
|
||||
symbol = status_symbols.get(base_status, "[??]")
|
||||
|
||||
print(f" {symbol} {spec['folder']}")
|
||||
status_line = f" Status: {spec['status']} | Subtasks: {spec['progress']}"
|
||||
print(status_line)
|
||||
print()
|
||||
|
||||
print("-" * 70)
|
||||
print("\nTo run a spec:")
|
||||
print(" python auto-claude/run.py --spec 001")
|
||||
print(" python auto-claude/run.py --spec 001-feature-name")
|
||||
print()
|
||||
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
CLI Utilities
|
||||
==============
|
||||
|
||||
Shared utility functions for the Auto Claude CLI.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure parent directory is in path for imports (before other imports)
|
||||
_PARENT_DIR = Path(__file__).parent.parent
|
||||
if str(_PARENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
from core.auth import get_auth_token, get_auth_token_source
|
||||
|
||||
|
||||
def import_dotenv():
|
||||
"""
|
||||
Import and return load_dotenv with helpful error message if not installed.
|
||||
|
||||
This centralized function ensures consistent error messaging across all
|
||||
runner scripts when python-dotenv is not available.
|
||||
|
||||
Returns:
|
||||
The load_dotenv function
|
||||
|
||||
Raises:
|
||||
SystemExit: If dotenv cannot be imported, with helpful installation instructions.
|
||||
"""
|
||||
try:
|
||||
from dotenv import load_dotenv as _load_dotenv
|
||||
|
||||
return _load_dotenv
|
||||
except ImportError:
|
||||
sys.exit(
|
||||
"Error: Required Python package 'python-dotenv' is not installed.\n"
|
||||
"\n"
|
||||
"This usually means you're not using the virtual environment.\n"
|
||||
"\n"
|
||||
"To fix this:\n"
|
||||
"1. From the 'apps/backend/' directory, activate the venv:\n"
|
||||
" source .venv/bin/activate # Linux/macOS\n"
|
||||
" .venv\\Scripts\\activate # Windows\n"
|
||||
"\n"
|
||||
"2. Or install dependencies directly:\n"
|
||||
" pip install python-dotenv\n"
|
||||
" pip install -r requirements.txt\n"
|
||||
"\n"
|
||||
f"Current Python: {sys.executable}\n"
|
||||
)
|
||||
|
||||
|
||||
# Load .env with helpful error if dependencies not installed
|
||||
load_dotenv = import_dotenv()
|
||||
from graphiti_config import get_graphiti_status
|
||||
from linear_integration import LinearManager
|
||||
from linear_updater import is_linear_enabled
|
||||
from spec.pipeline import get_specs_dir
|
||||
from ui import (
|
||||
Icons,
|
||||
bold,
|
||||
box,
|
||||
icon,
|
||||
muted,
|
||||
)
|
||||
|
||||
# Configuration - uses shorthand that resolves via API Profile if configured
|
||||
DEFAULT_MODEL = "sonnet" # Changed from "opus" (fix #433)
|
||||
|
||||
|
||||
def setup_environment() -> Path:
|
||||
"""
|
||||
Set up the environment and return the script directory.
|
||||
|
||||
Returns:
|
||||
Path to the auto-claude directory
|
||||
"""
|
||||
# Add auto-claude directory to path for imports
|
||||
script_dir = Path(__file__).parent.parent.resolve()
|
||||
sys.path.insert(0, str(script_dir))
|
||||
|
||||
# Load .env file - check both auto-claude/ and dev/auto-claude/ locations
|
||||
env_file = script_dir / ".env"
|
||||
dev_env_file = script_dir.parent / "dev" / "auto-claude" / ".env"
|
||||
if env_file.exists():
|
||||
load_dotenv(env_file)
|
||||
elif dev_env_file.exists():
|
||||
load_dotenv(dev_env_file)
|
||||
|
||||
return script_dir
|
||||
|
||||
|
||||
def find_spec(project_dir: Path, spec_identifier: str) -> Path | None:
|
||||
"""
|
||||
Find a spec by number or full name.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_identifier: Either "001" or "001-feature-name"
|
||||
|
||||
Returns:
|
||||
Path to spec folder, or None if not found
|
||||
"""
|
||||
specs_dir = get_specs_dir(project_dir)
|
||||
|
||||
if specs_dir.exists():
|
||||
# Try exact match first
|
||||
exact_path = specs_dir / spec_identifier
|
||||
if exact_path.exists() and (exact_path / "spec.md").exists():
|
||||
return exact_path
|
||||
|
||||
# Try matching by number prefix
|
||||
for spec_folder in specs_dir.iterdir():
|
||||
if spec_folder.is_dir() and spec_folder.name.startswith(
|
||||
spec_identifier + "-"
|
||||
):
|
||||
if (spec_folder / "spec.md").exists():
|
||||
return spec_folder
|
||||
|
||||
# Check worktree specs (for merge-preview, merge, review, discard operations)
|
||||
worktree_base = project_dir / ".auto-claude" / "worktrees" / "tasks"
|
||||
if worktree_base.exists():
|
||||
# Try exact match in worktree
|
||||
worktree_spec = (
|
||||
worktree_base / spec_identifier / ".auto-claude" / "specs" / spec_identifier
|
||||
)
|
||||
if worktree_spec.exists() and (worktree_spec / "spec.md").exists():
|
||||
return worktree_spec
|
||||
|
||||
# Try matching by prefix in worktrees
|
||||
for worktree_dir in worktree_base.iterdir():
|
||||
if worktree_dir.is_dir() and worktree_dir.name.startswith(
|
||||
spec_identifier + "-"
|
||||
):
|
||||
spec_in_worktree = (
|
||||
worktree_dir / ".auto-claude" / "specs" / worktree_dir.name
|
||||
)
|
||||
if (
|
||||
spec_in_worktree.exists()
|
||||
and (spec_in_worktree / "spec.md").exists()
|
||||
):
|
||||
return spec_in_worktree
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def validate_environment(spec_dir: Path) -> bool:
|
||||
"""
|
||||
Validate that the environment is set up correctly.
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise (with error messages printed)
|
||||
"""
|
||||
valid = True
|
||||
|
||||
# Check for OAuth token (API keys are not supported)
|
||||
if not get_auth_token():
|
||||
print("Error: No OAuth token found")
|
||||
print("\nAuto Claude requires Claude Code OAuth authentication.")
|
||||
print("Direct API keys (ANTHROPIC_API_KEY) are not supported.")
|
||||
print("\nTo authenticate, run:")
|
||||
print(" claude setup-token")
|
||||
valid = False
|
||||
else:
|
||||
# Show which auth source is being used
|
||||
source = get_auth_token_source()
|
||||
if source:
|
||||
print(f"Auth: {source}")
|
||||
|
||||
# Show custom base URL if set
|
||||
base_url = os.environ.get("ANTHROPIC_BASE_URL")
|
||||
if base_url:
|
||||
print(f"API Endpoint: {base_url}")
|
||||
|
||||
# Check for spec.md in spec directory
|
||||
spec_file = spec_dir / "spec.md"
|
||||
if not spec_file.exists():
|
||||
print(f"\nError: spec.md not found in {spec_dir}")
|
||||
valid = False
|
||||
|
||||
# Check Linear integration (optional but show status)
|
||||
if is_linear_enabled():
|
||||
print("Linear integration: ENABLED")
|
||||
# Show Linear project status if initialized
|
||||
project_dir = (
|
||||
spec_dir.parent.parent
|
||||
) # auto-claude/specs/001-name -> project root
|
||||
linear_manager = LinearManager(spec_dir, project_dir)
|
||||
if linear_manager.is_initialized:
|
||||
summary = linear_manager.get_progress_summary()
|
||||
print(f" Project: {summary.get('project_name', 'Unknown')}")
|
||||
print(
|
||||
f" Issues: {summary.get('mapped_subtasks', 0)}/{summary.get('total_subtasks', 0)} mapped"
|
||||
)
|
||||
else:
|
||||
print(" Status: Will be initialized during planner session")
|
||||
else:
|
||||
print("Linear integration: DISABLED (set LINEAR_API_KEY to enable)")
|
||||
|
||||
# Check Graphiti integration (optional but show status)
|
||||
graphiti_status = get_graphiti_status()
|
||||
if graphiti_status["available"]:
|
||||
print("Graphiti memory: ENABLED")
|
||||
print(f" Database: {graphiti_status['database']}")
|
||||
if graphiti_status.get("db_path"):
|
||||
print(f" Path: {graphiti_status['db_path']}")
|
||||
elif graphiti_status["enabled"]:
|
||||
print(
|
||||
f"Graphiti memory: CONFIGURED but unavailable ({graphiti_status['reason']})"
|
||||
)
|
||||
else:
|
||||
print("Graphiti memory: DISABLED (set GRAPHITI_ENABLED=true to enable)")
|
||||
|
||||
print()
|
||||
return valid
|
||||
|
||||
|
||||
def print_banner() -> None:
|
||||
"""Print the Auto-Build banner."""
|
||||
content = [
|
||||
bold(f"{icon(Icons.LIGHTNING)} AUTO-BUILD FRAMEWORK"),
|
||||
"",
|
||||
"Autonomous Multi-Session Coding Agent",
|
||||
muted("Subtask-Based Implementation with Phase Dependencies"),
|
||||
]
|
||||
print()
|
||||
print(box(content, width=70, style="heavy"))
|
||||
|
||||
|
||||
def get_project_dir(provided_dir: Path | None) -> Path:
|
||||
"""
|
||||
Determine the project directory.
|
||||
|
||||
Args:
|
||||
provided_dir: User-provided project directory (or None)
|
||||
|
||||
Returns:
|
||||
Resolved project directory path
|
||||
"""
|
||||
if provided_dir:
|
||||
return provided_dir.resolve()
|
||||
|
||||
project_dir = Path.cwd()
|
||||
|
||||
# Auto-detect if running from within apps/backend directory (the source code)
|
||||
if project_dir.name == "backend" and (project_dir / "run.py").exists():
|
||||
# Running from within apps/backend/ source directory, go up 2 levels
|
||||
project_dir = project_dir.parent.parent
|
||||
|
||||
return project_dir
|
||||
@@ -0,0 +1,937 @@
|
||||
"""
|
||||
Workspace Commands
|
||||
==================
|
||||
|
||||
CLI commands for workspace management (merge, review, discard, list, cleanup)
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Ensure parent directory is in path for imports (before other imports)
|
||||
_PARENT_DIR = Path(__file__).parent.parent
|
||||
if str(_PARENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
from core.workspace.git_utils import (
|
||||
_is_auto_claude_file,
|
||||
apply_path_mapping,
|
||||
detect_file_renames,
|
||||
get_file_content_from_ref,
|
||||
get_merge_base,
|
||||
is_lock_file,
|
||||
)
|
||||
from core.worktree import WorktreeManager
|
||||
from debug import debug_warning
|
||||
from ui import (
|
||||
Icons,
|
||||
icon,
|
||||
)
|
||||
from workspace import (
|
||||
cleanup_all_worktrees,
|
||||
discard_existing_build,
|
||||
list_all_worktrees,
|
||||
merge_existing_build,
|
||||
review_existing_build,
|
||||
)
|
||||
|
||||
from .utils import print_banner
|
||||
|
||||
|
||||
def _detect_default_branch(project_dir: Path) -> str:
|
||||
"""
|
||||
Detect the default branch for the repository.
|
||||
|
||||
This matches the logic in WorktreeManager._detect_base_branch() to ensure
|
||||
we compare against the same branch that worktrees are created from.
|
||||
|
||||
Priority order:
|
||||
1. DEFAULT_BRANCH environment variable
|
||||
2. Auto-detect main/master (if they exist)
|
||||
3. Fall back to "main" as final default
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
|
||||
Returns:
|
||||
The detected default branch name
|
||||
"""
|
||||
import os
|
||||
|
||||
# 1. Check for DEFAULT_BRANCH env var
|
||||
env_branch = os.getenv("DEFAULT_BRANCH")
|
||||
if env_branch:
|
||||
# Verify the branch exists
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--verify", env_branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return env_branch
|
||||
|
||||
# 2. Auto-detect main/master
|
||||
for branch in ["main", "master"]:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--verify", branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return branch
|
||||
|
||||
# 3. Fall back to "main" as final default
|
||||
return "main"
|
||||
|
||||
|
||||
def _get_changed_files_from_git(
|
||||
worktree_path: Path, base_branch: str = "main"
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get list of files changed by the task (not files changed on base branch).
|
||||
|
||||
Uses merge-base to accurately identify only the files modified in the worktree,
|
||||
not files that changed on the base branch since the worktree was created.
|
||||
|
||||
Args:
|
||||
worktree_path: Path to the worktree
|
||||
base_branch: Base branch to compare against (default: main)
|
||||
|
||||
Returns:
|
||||
List of changed file paths (task changes only)
|
||||
"""
|
||||
try:
|
||||
# First, get the merge-base (the point where the worktree branched)
|
||||
merge_base_result = subprocess.run(
|
||||
["git", "merge-base", base_branch, "HEAD"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
merge_base = merge_base_result.stdout.strip()
|
||||
|
||||
# Use two-dot diff from merge-base to get only task's changes
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", f"{merge_base}..HEAD"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
files = [f.strip() for f in result.stdout.strip().split("\n") if f.strip()]
|
||||
return files
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Log the failure before trying fallback
|
||||
debug_warning(
|
||||
"workspace_commands",
|
||||
f"git diff with merge-base failed: returncode={e.returncode}, "
|
||||
f"stderr={e.stderr.strip() if e.stderr else 'N/A'}",
|
||||
)
|
||||
# Fallback: try direct two-arg diff (less accurate but works)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", base_branch, "HEAD"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
files = [f.strip() for f in result.stdout.strip().split("\n") if f.strip()]
|
||||
return files
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Log the failure before returning empty list
|
||||
debug_warning(
|
||||
"workspace_commands",
|
||||
f"git diff (fallback) failed: returncode={e.returncode}, "
|
||||
f"stderr={e.stderr.strip() if e.stderr else 'N/A'}",
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
# Import debug utilities
|
||||
try:
|
||||
from debug import (
|
||||
debug,
|
||||
debug_detailed,
|
||||
debug_error,
|
||||
debug_section,
|
||||
debug_success,
|
||||
debug_verbose,
|
||||
is_debug_enabled,
|
||||
)
|
||||
except ImportError:
|
||||
|
||||
def debug(*args, **kwargs):
|
||||
"""Fallback debug function when debug module is not available."""
|
||||
pass
|
||||
|
||||
def debug_detailed(*args, **kwargs):
|
||||
"""Fallback debug_detailed function when debug module is not available."""
|
||||
pass
|
||||
|
||||
def debug_verbose(*args, **kwargs):
|
||||
"""Fallback debug_verbose function when debug module is not available."""
|
||||
pass
|
||||
|
||||
def debug_success(*args, **kwargs):
|
||||
"""Fallback debug_success function when debug module is not available."""
|
||||
pass
|
||||
|
||||
def debug_error(*args, **kwargs):
|
||||
"""Fallback debug_error function when debug module is not available."""
|
||||
pass
|
||||
|
||||
def debug_section(*args, **kwargs):
|
||||
"""Fallback debug_section function when debug module is not available."""
|
||||
pass
|
||||
|
||||
def is_debug_enabled():
|
||||
"""Fallback is_debug_enabled function when debug module is not available."""
|
||||
return False
|
||||
|
||||
|
||||
MODULE = "cli.workspace_commands"
|
||||
|
||||
|
||||
def handle_merge_command(
|
||||
project_dir: Path,
|
||||
spec_name: str,
|
||||
no_commit: bool = False,
|
||||
base_branch: str | None = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Handle the --merge command.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_name: Name of the spec
|
||||
no_commit: If True, stage changes but don't commit
|
||||
base_branch: Branch to compare against (default: auto-detect)
|
||||
|
||||
Returns:
|
||||
True if merge succeeded, False otherwise
|
||||
"""
|
||||
success = merge_existing_build(
|
||||
project_dir, spec_name, no_commit=no_commit, base_branch=base_branch
|
||||
)
|
||||
|
||||
# Generate commit message suggestion if staging succeeded (no_commit mode)
|
||||
if success and no_commit:
|
||||
_generate_and_save_commit_message(project_dir, spec_name)
|
||||
|
||||
return success
|
||||
|
||||
|
||||
def _generate_and_save_commit_message(project_dir: Path, spec_name: str) -> None:
|
||||
"""
|
||||
Generate a commit message suggestion and save it for the UI.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_name: Name of the spec
|
||||
"""
|
||||
try:
|
||||
from commit_message import generate_commit_message_sync
|
||||
|
||||
# Get diff summary for context
|
||||
diff_summary = ""
|
||||
files_changed = []
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--staged", "--stat"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
diff_summary = result.stdout.strip()
|
||||
|
||||
# Get list of changed files
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--staged", "--name-only"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
files_changed = [
|
||||
f.strip() for f in result.stdout.strip().split("\n") if f.strip()
|
||||
]
|
||||
except Exception as e:
|
||||
debug_warning(MODULE, f"Could not get diff summary: {e}")
|
||||
|
||||
# Generate commit message
|
||||
debug(MODULE, "Generating commit message suggestion...")
|
||||
commit_message = generate_commit_message_sync(
|
||||
project_dir=project_dir,
|
||||
spec_name=spec_name,
|
||||
diff_summary=diff_summary,
|
||||
files_changed=files_changed,
|
||||
)
|
||||
|
||||
if commit_message:
|
||||
# Save to spec directory for UI to read
|
||||
spec_dir = project_dir / ".auto-claude" / "specs" / spec_name
|
||||
if not spec_dir.exists():
|
||||
spec_dir = project_dir / "auto-claude" / "specs" / spec_name
|
||||
|
||||
if spec_dir.exists():
|
||||
commit_msg_file = spec_dir / "suggested_commit_message.txt"
|
||||
commit_msg_file.write_text(commit_message, encoding="utf-8")
|
||||
debug_success(
|
||||
MODULE, f"Saved commit message suggestion to {commit_msg_file}"
|
||||
)
|
||||
else:
|
||||
debug_warning(MODULE, f"Spec directory not found: {spec_dir}")
|
||||
else:
|
||||
debug_warning(MODULE, "No commit message generated")
|
||||
|
||||
except ImportError:
|
||||
debug_warning(MODULE, "commit_message module not available")
|
||||
except Exception as e:
|
||||
debug_warning(MODULE, f"Failed to generate commit message: {e}")
|
||||
|
||||
|
||||
def handle_review_command(project_dir: Path, spec_name: str) -> None:
|
||||
"""
|
||||
Handle the --review command.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_name: Name of the spec
|
||||
"""
|
||||
review_existing_build(project_dir, spec_name)
|
||||
|
||||
|
||||
def handle_discard_command(project_dir: Path, spec_name: str) -> None:
|
||||
"""
|
||||
Handle the --discard command.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_name: Name of the spec
|
||||
"""
|
||||
discard_existing_build(project_dir, spec_name)
|
||||
|
||||
|
||||
def handle_list_worktrees_command(project_dir: Path) -> None:
|
||||
"""
|
||||
Handle the --list-worktrees command.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
"""
|
||||
print_banner()
|
||||
print("\n" + "=" * 70)
|
||||
print(" SPEC WORKTREES")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
worktrees = list_all_worktrees(project_dir)
|
||||
if not worktrees:
|
||||
print(" No worktrees found.")
|
||||
print()
|
||||
print(" Worktrees are created when you run a build in isolated mode.")
|
||||
else:
|
||||
for wt in worktrees:
|
||||
print(f" {icon(Icons.FOLDER)} {wt.spec_name}")
|
||||
print(f" Branch: {wt.branch}")
|
||||
print(f" Path: {wt.path}")
|
||||
print(f" Commits: {wt.commit_count}, Files: {wt.files_changed}")
|
||||
print()
|
||||
|
||||
print("-" * 70)
|
||||
print()
|
||||
print(" To merge: python auto-claude/run.py --spec <name> --merge")
|
||||
print(" To review: python auto-claude/run.py --spec <name> --review")
|
||||
print(" To discard: python auto-claude/run.py --spec <name> --discard")
|
||||
print()
|
||||
print(
|
||||
" To cleanup all worktrees: python auto-claude/run.py --cleanup-worktrees"
|
||||
)
|
||||
print()
|
||||
|
||||
|
||||
def handle_cleanup_worktrees_command(project_dir: Path) -> None:
|
||||
"""
|
||||
Handle the --cleanup-worktrees command.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
"""
|
||||
print_banner()
|
||||
cleanup_all_worktrees(project_dir, confirm=True)
|
||||
|
||||
|
||||
def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
|
||||
"""
|
||||
Check for git-level merge conflicts WITHOUT modifying the working directory.
|
||||
|
||||
Uses git merge-tree and git diff to detect conflicts in-memory,
|
||||
which avoids triggering Vite HMR or other file watchers.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_name: Name of the spec
|
||||
|
||||
Returns:
|
||||
Dictionary with git conflict information:
|
||||
- has_conflicts: bool
|
||||
- conflicting_files: list of file paths
|
||||
- needs_rebase: bool (if main has advanced)
|
||||
- base_branch: str
|
||||
- spec_branch: str
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
debug(MODULE, "Checking for git-level merge conflicts (non-destructive)...")
|
||||
|
||||
spec_branch = f"auto-claude/{spec_name}"
|
||||
result = {
|
||||
"has_conflicts": False,
|
||||
"conflicting_files": [],
|
||||
"needs_rebase": False,
|
||||
"base_branch": "main",
|
||||
"spec_branch": spec_branch,
|
||||
"commits_behind": 0,
|
||||
}
|
||||
|
||||
try:
|
||||
# Get the current branch (base branch)
|
||||
base_result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if base_result.returncode == 0:
|
||||
result["base_branch"] = base_result.stdout.strip()
|
||||
|
||||
# Get the merge base commit
|
||||
merge_base_result = subprocess.run(
|
||||
["git", "merge-base", result["base_branch"], spec_branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if merge_base_result.returncode != 0:
|
||||
debug_warning(MODULE, "Could not find merge base")
|
||||
return result
|
||||
|
||||
merge_base = merge_base_result.stdout.strip()
|
||||
|
||||
# Count commits main is ahead
|
||||
ahead_result = subprocess.run(
|
||||
["git", "rev-list", "--count", f"{merge_base}..{result['base_branch']}"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if ahead_result.returncode == 0:
|
||||
commits_behind = int(ahead_result.stdout.strip())
|
||||
result["commits_behind"] = commits_behind
|
||||
if commits_behind > 0:
|
||||
result["needs_rebase"] = True
|
||||
debug(
|
||||
MODULE, f"Main is {commits_behind} commits ahead of worktree base"
|
||||
)
|
||||
|
||||
# Use git merge-tree to check for conflicts WITHOUT touching working directory
|
||||
# This is a plumbing command that does a 3-way merge in memory
|
||||
# Note: --write-tree mode only accepts 2 branches (it auto-finds the merge base)
|
||||
merge_tree_result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"merge-tree",
|
||||
"--write-tree",
|
||||
"--no-messages",
|
||||
result["base_branch"], # Use branch names, not commit hashes
|
||||
spec_branch,
|
||||
],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# merge-tree returns exit code 1 if there are conflicts
|
||||
if merge_tree_result.returncode != 0:
|
||||
result["has_conflicts"] = True
|
||||
debug(MODULE, "Git merge-tree detected conflicts")
|
||||
|
||||
# Parse the output for conflicting files
|
||||
# merge-tree --write-tree outputs conflict info to stderr
|
||||
output = merge_tree_result.stdout + merge_tree_result.stderr
|
||||
for line in output.split("\n"):
|
||||
# Look for lines indicating conflicts
|
||||
if "CONFLICT" in line:
|
||||
# Extract file path from conflict message
|
||||
import re
|
||||
|
||||
match = re.search(
|
||||
r"(?:Merge conflict in|CONFLICT.*?:)\s*(.+?)(?:\s*$|\s+\()",
|
||||
line,
|
||||
)
|
||||
if match:
|
||||
file_path = match.group(1).strip()
|
||||
# Skip .auto-claude files - they should never be merged
|
||||
if (
|
||||
file_path
|
||||
and file_path not in result["conflicting_files"]
|
||||
and not _is_auto_claude_file(file_path)
|
||||
):
|
||||
result["conflicting_files"].append(file_path)
|
||||
|
||||
# Fallback: if we didn't parse conflicts, use diff to find files changed in both branches
|
||||
if not result["conflicting_files"]:
|
||||
# Files changed in main since merge-base
|
||||
main_files_result = subprocess.run(
|
||||
["git", "diff", "--name-only", merge_base, result["base_branch"]],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
main_files = (
|
||||
set(main_files_result.stdout.strip().split("\n"))
|
||||
if main_files_result.stdout.strip()
|
||||
else set()
|
||||
)
|
||||
|
||||
# Files changed in spec branch since merge-base
|
||||
spec_files_result = subprocess.run(
|
||||
["git", "diff", "--name-only", merge_base, spec_branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
spec_files = (
|
||||
set(spec_files_result.stdout.strip().split("\n"))
|
||||
if spec_files_result.stdout.strip()
|
||||
else set()
|
||||
)
|
||||
|
||||
# Files modified in both = potential conflicts
|
||||
# Filter out .auto-claude files - they should never be merged
|
||||
conflicting = main_files & spec_files
|
||||
result["conflicting_files"] = [
|
||||
f for f in conflicting if not _is_auto_claude_file(f)
|
||||
]
|
||||
debug(
|
||||
MODULE, f"Found {len(conflicting)} files modified in both branches"
|
||||
)
|
||||
|
||||
debug(MODULE, f"Conflicting files: {result['conflicting_files']}")
|
||||
else:
|
||||
debug_success(MODULE, "Git merge-tree: no conflicts detected")
|
||||
|
||||
except Exception as e:
|
||||
debug_error(MODULE, f"Error checking git conflicts: {e}")
|
||||
import traceback
|
||||
|
||||
debug_verbose(MODULE, "Exception traceback", traceback=traceback.format_exc())
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def handle_merge_preview_command(
|
||||
project_dir: Path,
|
||||
spec_name: str,
|
||||
base_branch: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Handle the --merge-preview command.
|
||||
|
||||
Returns a JSON-serializable preview of merge conflicts without
|
||||
actually performing the merge. This is used by the UI to show
|
||||
potential conflicts before the user clicks "Stage Changes".
|
||||
|
||||
This checks for TWO types of conflicts:
|
||||
1. Semantic conflicts: Multiple parallel tasks modifying the same code
|
||||
2. Git conflicts: Main branch has diverged from worktree branch
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_name: Name of the spec
|
||||
base_branch: Branch the task was created from (for comparison). If None, auto-detect.
|
||||
|
||||
Returns:
|
||||
Dictionary with preview information
|
||||
"""
|
||||
debug_section(MODULE, "Merge Preview Command")
|
||||
debug(
|
||||
MODULE,
|
||||
"handle_merge_preview_command() called",
|
||||
project_dir=str(project_dir),
|
||||
spec_name=spec_name,
|
||||
)
|
||||
|
||||
from merge import MergeOrchestrator
|
||||
from workspace import get_existing_build_worktree
|
||||
|
||||
worktree_path = get_existing_build_worktree(project_dir, spec_name)
|
||||
debug(
|
||||
MODULE,
|
||||
"Worktree lookup result",
|
||||
worktree_path=str(worktree_path) if worktree_path else None,
|
||||
)
|
||||
|
||||
if not worktree_path:
|
||||
debug_error(MODULE, f"No existing build found for '{spec_name}'")
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"No existing build found for '{spec_name}'",
|
||||
"files": [],
|
||||
"conflicts": [],
|
||||
"gitConflicts": None,
|
||||
"summary": {
|
||||
"totalFiles": 0,
|
||||
"conflictFiles": 0,
|
||||
"totalConflicts": 0,
|
||||
"autoMergeable": 0,
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
# First, check for git-level conflicts (diverged branches)
|
||||
git_conflicts = _check_git_merge_conflicts(project_dir, spec_name)
|
||||
|
||||
# Determine the task's source branch (where the task was created from)
|
||||
# Use provided base_branch (from task metadata), or fall back to detected default
|
||||
task_source_branch = base_branch
|
||||
if not task_source_branch:
|
||||
# Auto-detect the default branch (main/master) that worktrees are typically created from
|
||||
task_source_branch = _detect_default_branch(project_dir)
|
||||
|
||||
# Get actual changed files from git diff (this is the authoritative count)
|
||||
all_changed_files = _get_changed_files_from_git(
|
||||
worktree_path, task_source_branch
|
||||
)
|
||||
debug(
|
||||
MODULE,
|
||||
f"Git diff against '{task_source_branch}' shows {len(all_changed_files)} changed files",
|
||||
changed_files=all_changed_files[:10], # Log first 10
|
||||
)
|
||||
|
||||
# NOTE: We intentionally do NOT have a fast path here.
|
||||
# Even if commits_behind == 0 (main hasn't moved), we still need to:
|
||||
# 1. Call refresh_from_git() to update evolution data for this task
|
||||
# 2. Call preview_merge() to detect potential conflicts with OTHER parallel tasks
|
||||
# that may be tracked in the evolution data but haven't been merged yet.
|
||||
# Skipping semantic analysis when commits_behind == 0 would miss these conflicts.
|
||||
|
||||
debug(MODULE, "Initializing MergeOrchestrator for preview...")
|
||||
|
||||
# Initialize the orchestrator
|
||||
orchestrator = MergeOrchestrator(
|
||||
project_dir,
|
||||
enable_ai=False, # Don't use AI for preview
|
||||
dry_run=True, # Don't write anything
|
||||
)
|
||||
|
||||
# Refresh evolution data from the worktree
|
||||
# Compare against the task's source branch (where the task was created from)
|
||||
debug(
|
||||
MODULE,
|
||||
f"Refreshing evolution data from worktree: {worktree_path}",
|
||||
task_source_branch=task_source_branch,
|
||||
)
|
||||
orchestrator.evolution_tracker.refresh_from_git(
|
||||
spec_name, worktree_path, target_branch=task_source_branch
|
||||
)
|
||||
|
||||
# Get merge preview (semantic conflicts between parallel tasks)
|
||||
debug(MODULE, "Generating merge preview...")
|
||||
preview = orchestrator.preview_merge([spec_name])
|
||||
|
||||
# Transform semantic conflicts to UI-friendly format
|
||||
conflicts = []
|
||||
for c in preview.get("conflicts", []):
|
||||
debug_verbose(
|
||||
MODULE,
|
||||
"Processing semantic conflict",
|
||||
file=c.get("file", ""),
|
||||
severity=c.get("severity", "unknown"),
|
||||
)
|
||||
conflicts.append(
|
||||
{
|
||||
"file": c.get("file", ""),
|
||||
"location": c.get("location", ""),
|
||||
"tasks": c.get("tasks", []),
|
||||
"severity": c.get("severity", "unknown"),
|
||||
"canAutoMerge": c.get("can_auto_merge", False),
|
||||
"strategy": c.get("strategy"),
|
||||
"reason": c.get("reason", ""),
|
||||
"type": "semantic",
|
||||
}
|
||||
)
|
||||
|
||||
# Add git conflicts to the list (excluding lock files which are handled automatically)
|
||||
lock_files_excluded = []
|
||||
for file_path in git_conflicts.get("conflicting_files", []):
|
||||
if is_lock_file(file_path):
|
||||
# Lock files are auto-generated and should not go through AI merge
|
||||
# They will be handled automatically by taking the worktree version
|
||||
lock_files_excluded.append(file_path)
|
||||
debug(MODULE, f"Excluding lock file from conflicts: {file_path}")
|
||||
continue
|
||||
|
||||
conflicts.append(
|
||||
{
|
||||
"file": file_path,
|
||||
"location": "file-level",
|
||||
"tasks": [spec_name, git_conflicts["base_branch"]],
|
||||
"severity": "high",
|
||||
"canAutoMerge": False,
|
||||
"strategy": None,
|
||||
"reason": f"File modified in both {git_conflicts['base_branch']} and worktree since branch point",
|
||||
"type": "git",
|
||||
}
|
||||
)
|
||||
|
||||
summary = preview.get("summary", {})
|
||||
# Count only non-lock-file conflicts
|
||||
git_conflict_count = len(git_conflicts.get("conflicting_files", [])) - len(
|
||||
lock_files_excluded
|
||||
)
|
||||
total_conflicts = summary.get("total_conflicts", 0) + git_conflict_count
|
||||
conflict_files = summary.get("conflict_files", 0) + git_conflict_count
|
||||
|
||||
# Filter lock files from the git conflicts list for the response
|
||||
non_lock_conflicting_files = [
|
||||
f for f in git_conflicts.get("conflicting_files", []) if not is_lock_file(f)
|
||||
]
|
||||
|
||||
# Use git diff file count as the authoritative totalFiles count
|
||||
# The semantic tracker may not track all files (e.g., test files, config files)
|
||||
# but we want to show the user all files that will be merged
|
||||
total_files_from_git = len(all_changed_files)
|
||||
|
||||
# Detect files that need AI merge due to path mappings (file renames)
|
||||
# This happens when the target branch has renamed/moved files that the
|
||||
# worktree modified at their old locations
|
||||
path_mapped_ai_merges: list[dict] = []
|
||||
path_mappings: dict[str, str] = {}
|
||||
|
||||
if git_conflicts["needs_rebase"] and git_conflicts["commits_behind"] > 0:
|
||||
# Get the merge-base between the branches
|
||||
spec_branch = git_conflicts["spec_branch"]
|
||||
base_branch = git_conflicts["base_branch"]
|
||||
merge_base = get_merge_base(project_dir, spec_branch, base_branch)
|
||||
|
||||
if merge_base:
|
||||
# Detect file renames between merge-base and current base branch
|
||||
path_mappings = detect_file_renames(
|
||||
project_dir, merge_base, base_branch
|
||||
)
|
||||
|
||||
if path_mappings:
|
||||
debug(
|
||||
MODULE,
|
||||
f"Detected {len(path_mappings)} file rename(s) between merge-base and target",
|
||||
sample_mappings={
|
||||
k: v for k, v in list(path_mappings.items())[:3]
|
||||
},
|
||||
)
|
||||
|
||||
# Check which changed files have path mappings and need AI merge
|
||||
for file_path in all_changed_files:
|
||||
mapped_path = apply_path_mapping(file_path, path_mappings)
|
||||
if mapped_path != file_path:
|
||||
# File was renamed - check if both versions exist
|
||||
worktree_content = get_file_content_from_ref(
|
||||
project_dir, spec_branch, file_path
|
||||
)
|
||||
target_content = get_file_content_from_ref(
|
||||
project_dir, base_branch, mapped_path
|
||||
)
|
||||
|
||||
if worktree_content and target_content:
|
||||
path_mapped_ai_merges.append(
|
||||
{
|
||||
"oldPath": file_path,
|
||||
"newPath": mapped_path,
|
||||
"reason": "File was renamed/moved and modified in both branches",
|
||||
}
|
||||
)
|
||||
debug(
|
||||
MODULE,
|
||||
f"Path-mapped file needs AI merge: {file_path} -> {mapped_path}",
|
||||
)
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
# Use git diff files as the authoritative list of files to merge
|
||||
"files": all_changed_files,
|
||||
"conflicts": conflicts,
|
||||
"gitConflicts": {
|
||||
"hasConflicts": git_conflicts["has_conflicts"]
|
||||
and len(non_lock_conflicting_files) > 0,
|
||||
"conflictingFiles": non_lock_conflicting_files,
|
||||
"needsRebase": git_conflicts["needs_rebase"],
|
||||
"commitsBehind": git_conflicts["commits_behind"],
|
||||
"baseBranch": git_conflicts["base_branch"],
|
||||
"specBranch": git_conflicts["spec_branch"],
|
||||
# Path-mapped files that need AI merge due to renames
|
||||
"pathMappedAIMerges": path_mapped_ai_merges,
|
||||
"totalRenames": len(path_mappings),
|
||||
},
|
||||
"summary": {
|
||||
# Use git diff count, not semantic tracker count
|
||||
"totalFiles": total_files_from_git,
|
||||
"conflictFiles": conflict_files,
|
||||
"totalConflicts": total_conflicts,
|
||||
"autoMergeable": summary.get("auto_mergeable", 0),
|
||||
"hasGitConflicts": git_conflicts["has_conflicts"]
|
||||
and len(non_lock_conflicting_files) > 0,
|
||||
# Include path-mapped AI merge count for UI display
|
||||
"pathMappedAIMergeCount": len(path_mapped_ai_merges),
|
||||
},
|
||||
# Include lock files info so UI can optionally show them
|
||||
"lockFilesExcluded": lock_files_excluded,
|
||||
}
|
||||
|
||||
debug_success(
|
||||
MODULE,
|
||||
"Merge preview complete",
|
||||
total_files=result["summary"]["totalFiles"],
|
||||
total_files_source="git_diff",
|
||||
semantic_tracked_files=summary.get("total_files", 0),
|
||||
total_conflicts=result["summary"]["totalConflicts"],
|
||||
has_git_conflicts=git_conflicts["has_conflicts"],
|
||||
auto_mergeable=result["summary"]["autoMergeable"],
|
||||
path_mapped_ai_merges=len(path_mapped_ai_merges),
|
||||
total_renames=len(path_mappings),
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
debug_error(MODULE, "Merge preview failed", error=str(e))
|
||||
import traceback
|
||||
|
||||
debug_verbose(MODULE, "Exception traceback", traceback=traceback.format_exc())
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"files": [],
|
||||
"conflicts": [],
|
||||
"gitConflicts": None,
|
||||
"summary": {
|
||||
"totalFiles": 0,
|
||||
"conflictFiles": 0,
|
||||
"totalConflicts": 0,
|
||||
"autoMergeable": 0,
|
||||
"pathMappedAIMergeCount": 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def cleanup_old_worktrees_command(
|
||||
project_dir: Path, days: int = 30, dry_run: bool = False
|
||||
) -> dict:
|
||||
"""
|
||||
Clean up old worktrees that haven't been modified in the specified number of days.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
days: Number of days threshold (default: 30)
|
||||
dry_run: If True, only show what would be removed (default: False)
|
||||
|
||||
Returns:
|
||||
Dictionary with cleanup results
|
||||
"""
|
||||
try:
|
||||
manager = WorktreeManager(project_dir)
|
||||
|
||||
removed, failed = manager.cleanup_old_worktrees(
|
||||
days_threshold=days, dry_run=dry_run
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"removed": removed,
|
||||
"failed": failed,
|
||||
"dry_run": dry_run,
|
||||
"days_threshold": days,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"removed": [],
|
||||
"failed": [],
|
||||
}
|
||||
|
||||
|
||||
def worktree_summary_command(project_dir: Path) -> dict:
|
||||
"""
|
||||
Get a summary of all worktrees with age information.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
|
||||
Returns:
|
||||
Dictionary with worktree summary data
|
||||
"""
|
||||
try:
|
||||
manager = WorktreeManager(project_dir)
|
||||
|
||||
# Print to console for CLI usage
|
||||
manager.print_worktree_summary()
|
||||
|
||||
# Also return data for programmatic access
|
||||
worktrees = manager.list_all_worktrees()
|
||||
warning = manager.get_worktree_count_warning()
|
||||
|
||||
# Categorize by age
|
||||
recent = []
|
||||
week_old = []
|
||||
month_old = []
|
||||
very_old = []
|
||||
unknown_age = []
|
||||
|
||||
for info in worktrees:
|
||||
data = {
|
||||
"spec_name": info.spec_name,
|
||||
"days_since_last_commit": info.days_since_last_commit,
|
||||
"commit_count": info.commit_count,
|
||||
}
|
||||
|
||||
if info.days_since_last_commit is None:
|
||||
unknown_age.append(data)
|
||||
elif info.days_since_last_commit < 7:
|
||||
recent.append(data)
|
||||
elif info.days_since_last_commit < 30:
|
||||
week_old.append(data)
|
||||
elif info.days_since_last_commit < 90:
|
||||
month_old.append(data)
|
||||
else:
|
||||
very_old.append(data)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"total_worktrees": len(worktrees),
|
||||
"categories": {
|
||||
"recent": recent,
|
||||
"week_old": week_old,
|
||||
"month_old": month_old,
|
||||
"very_old": very_old,
|
||||
"unknown_age": unknown_age,
|
||||
},
|
||||
"warning": warning,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"total_worktrees": 0,
|
||||
"categories": {},
|
||||
"warning": None,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
Claude client module facade.
|
||||
|
||||
Provides Claude API client utilities.
|
||||
Uses lazy imports to avoid circular dependencies.
|
||||
"""
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
"""Lazy import to avoid circular imports with auto_claude_tools."""
|
||||
from core import client as _client
|
||||
|
||||
return getattr(_client, name)
|
||||
|
||||
|
||||
def create_client(*args, **kwargs):
|
||||
"""Create a Claude client instance."""
|
||||
from core.client import create_client as _create_client
|
||||
|
||||
return _create_client(*args, **kwargs)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"create_client",
|
||||
]
|
||||
@@ -0,0 +1,383 @@
|
||||
"""
|
||||
Commit Message Generator
|
||||
========================
|
||||
|
||||
Generates high-quality commit messages using Claude Haiku.
|
||||
|
||||
Features:
|
||||
- Conventional commits format (feat/fix/refactor/etc)
|
||||
- GitHub issue references (Fixes #123)
|
||||
- Context-aware descriptions from spec metadata
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Map task categories to conventional commit types
|
||||
CATEGORY_TO_COMMIT_TYPE = {
|
||||
"feature": "feat",
|
||||
"bug_fix": "fix",
|
||||
"bug": "fix",
|
||||
"refactoring": "refactor",
|
||||
"refactor": "refactor",
|
||||
"documentation": "docs",
|
||||
"docs": "docs",
|
||||
"testing": "test",
|
||||
"test": "test",
|
||||
"performance": "perf",
|
||||
"perf": "perf",
|
||||
"security": "security",
|
||||
"chore": "chore",
|
||||
"style": "style",
|
||||
"ci": "ci",
|
||||
"build": "build",
|
||||
}
|
||||
|
||||
SYSTEM_PROMPT = """You are a Git expert who writes clear, concise commit messages following conventional commits format.
|
||||
|
||||
Rules:
|
||||
1. First line: type(scope): description (max 72 chars total)
|
||||
2. Leave blank line after first line
|
||||
3. Body: 1-3 sentences explaining WHAT changed and WHY
|
||||
4. If GitHub issue number provided, end with "Fixes #N" on its own line
|
||||
5. Be specific about the changes, not generic
|
||||
6. Use imperative mood ("Add feature" not "Added feature")
|
||||
|
||||
Types: feat, fix, refactor, docs, test, perf, chore, style, ci, build
|
||||
|
||||
Example output:
|
||||
feat(auth): add OAuth2 login flow
|
||||
|
||||
Implement OAuth2 authentication with Google and GitHub providers.
|
||||
Add token refresh logic and secure storage.
|
||||
|
||||
Fixes #42"""
|
||||
|
||||
|
||||
def _get_spec_context(spec_dir: Path) -> dict:
|
||||
"""
|
||||
Extract context from spec files for commit message generation.
|
||||
|
||||
Returns dict with:
|
||||
- title: Feature/task title
|
||||
- category: Task category (feature, bug_fix, etc)
|
||||
- description: Brief description
|
||||
- github_issue: GitHub issue number if linked
|
||||
"""
|
||||
context = {
|
||||
"title": "",
|
||||
"category": "chore",
|
||||
"description": "",
|
||||
"github_issue": None,
|
||||
}
|
||||
|
||||
# Try to read spec.md for title
|
||||
spec_file = spec_dir / "spec.md"
|
||||
if spec_file.exists():
|
||||
try:
|
||||
content = spec_file.read_text(encoding="utf-8")
|
||||
# Extract title from first H1 or H2
|
||||
title_match = re.search(r"^#+ (.+)$", content, re.MULTILINE)
|
||||
if title_match:
|
||||
context["title"] = title_match.group(1).strip()
|
||||
|
||||
# Look for overview/description section
|
||||
overview_match = re.search(
|
||||
r"## Overview\s*\n(.+?)(?=\n##|\Z)", content, re.DOTALL
|
||||
)
|
||||
if overview_match:
|
||||
context["description"] = overview_match.group(1).strip()[:200]
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read spec.md: {e}")
|
||||
|
||||
# Try to read requirements.json for metadata
|
||||
req_file = spec_dir / "requirements.json"
|
||||
if req_file.exists():
|
||||
try:
|
||||
req_data = json.loads(req_file.read_text(encoding="utf-8"))
|
||||
if not context["title"] and req_data.get("feature"):
|
||||
context["title"] = req_data["feature"]
|
||||
if req_data.get("workflow_type"):
|
||||
context["category"] = req_data["workflow_type"]
|
||||
if req_data.get("task_description") and not context["description"]:
|
||||
context["description"] = req_data["task_description"][:200]
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read requirements.json: {e}")
|
||||
|
||||
# Try to read implementation_plan.json for GitHub issue
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if plan_file.exists():
|
||||
try:
|
||||
plan_data = json.loads(plan_file.read_text(encoding="utf-8"))
|
||||
# Check for GitHub metadata
|
||||
metadata = plan_data.get("metadata", {})
|
||||
if metadata.get("githubIssueNumber"):
|
||||
context["github_issue"] = metadata["githubIssueNumber"]
|
||||
# Fallback title
|
||||
if not context["title"]:
|
||||
context["title"] = plan_data.get("feature") or plan_data.get(
|
||||
"title", ""
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not read implementation_plan.json: {e}")
|
||||
|
||||
return context
|
||||
|
||||
|
||||
def _build_prompt(
|
||||
spec_context: dict,
|
||||
diff_summary: str,
|
||||
files_changed: list[str],
|
||||
) -> str:
|
||||
"""Build the prompt for Claude."""
|
||||
commit_type = CATEGORY_TO_COMMIT_TYPE.get(
|
||||
spec_context.get("category", "").lower(), "chore"
|
||||
)
|
||||
|
||||
github_ref = ""
|
||||
if spec_context.get("github_issue"):
|
||||
github_ref = f"\nGitHub Issue: #{spec_context['github_issue']} (include 'Fixes #{spec_context['github_issue']}' at the end)"
|
||||
|
||||
# Truncate file list if too long
|
||||
if len(files_changed) > 20:
|
||||
files_display = (
|
||||
"\n".join(files_changed[:20])
|
||||
+ f"\n... and {len(files_changed) - 20} more files"
|
||||
)
|
||||
else:
|
||||
files_display = (
|
||||
"\n".join(files_changed) if files_changed else "(no files listed)"
|
||||
)
|
||||
|
||||
prompt = f"""Generate a commit message for this change.
|
||||
|
||||
Task: {spec_context.get("title", "Unknown task")}
|
||||
Type: {commit_type}
|
||||
Files changed: {len(files_changed)}
|
||||
{github_ref}
|
||||
|
||||
Description: {spec_context.get("description", "No description available")}
|
||||
|
||||
Changed files:
|
||||
{files_display}
|
||||
|
||||
Diff summary:
|
||||
{diff_summary[:2000] if diff_summary else "(no diff available)"}
|
||||
|
||||
Generate ONLY the commit message, nothing else. Follow the format exactly:
|
||||
type(scope): short description
|
||||
|
||||
Body explaining changes.
|
||||
|
||||
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")
|
||||
"""
|
||||
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")
|
||||
return ""
|
||||
|
||||
ensure_claude_code_oauth_token()
|
||||
|
||||
try:
|
||||
from core.simple_client import create_simple_client
|
||||
except ImportError:
|
||||
logger.warning("core.simple_client not available")
|
||||
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,
|
||||
)
|
||||
|
||||
try:
|
||||
async with client:
|
||||
await client.query(prompt)
|
||||
|
||||
response_text = ""
|
||||
async for msg in client.receive_response():
|
||||
msg_type = type(msg).__name__
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
# Must check block type - only TextBlock has .text attribute
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
|
||||
logger.info(f"Generated commit message: {len(response_text)} chars")
|
||||
return response_text.strip()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Claude SDK call failed: {e}")
|
||||
print(f" [WARN] Commit message generation failed: {e}", file=sys.stderr)
|
||||
return ""
|
||||
|
||||
|
||||
def generate_commit_message_sync(
|
||||
project_dir: Path,
|
||||
spec_name: str,
|
||||
diff_summary: str = "",
|
||||
files_changed: list[str] | None = None,
|
||||
github_issue: int | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a commit message synchronously.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_name: Spec identifier (e.g., "001-add-feature")
|
||||
diff_summary: Git diff stat or summary
|
||||
files_changed: List of changed file paths
|
||||
github_issue: GitHub issue number if linked (overrides spec metadata)
|
||||
|
||||
Returns:
|
||||
Generated commit message or fallback message
|
||||
"""
|
||||
# Find spec directory
|
||||
spec_dir = project_dir / ".auto-claude" / "specs" / spec_name
|
||||
if not spec_dir.exists():
|
||||
# Try alternative location
|
||||
spec_dir = project_dir / "auto-claude" / "specs" / spec_name
|
||||
|
||||
# Get context from spec files
|
||||
spec_context = _get_spec_context(spec_dir) if spec_dir.exists() else {}
|
||||
|
||||
# Override with provided github_issue
|
||||
if github_issue:
|
||||
spec_context["github_issue"] = github_issue
|
||||
|
||||
# Build prompt
|
||||
prompt = _build_prompt(
|
||||
spec_context,
|
||||
diff_summary,
|
||||
files_changed or [],
|
||||
)
|
||||
|
||||
# Call Claude
|
||||
try:
|
||||
# Check if we're already in an async context
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
if loop and loop.is_running():
|
||||
# Already in an async context - run in a new thread
|
||||
# Use lambda to ensure coroutine is created inside the worker thread
|
||||
import concurrent.futures
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
result = pool.submit(lambda: asyncio.run(_call_claude(prompt))).result()
|
||||
else:
|
||||
result = asyncio.run(_call_claude(prompt))
|
||||
|
||||
if result:
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate commit message: {e}")
|
||||
|
||||
# Fallback message
|
||||
commit_type = CATEGORY_TO_COMMIT_TYPE.get(
|
||||
spec_context.get("category", "").lower(), "chore"
|
||||
)
|
||||
title = spec_context.get("title", spec_name)
|
||||
fallback = f"{commit_type}: {title}"
|
||||
|
||||
if github_issue or spec_context.get("github_issue"):
|
||||
issue_num = github_issue or spec_context.get("github_issue")
|
||||
fallback += f"\n\nFixes #{issue_num}"
|
||||
|
||||
return fallback
|
||||
|
||||
|
||||
async def generate_commit_message(
|
||||
project_dir: Path,
|
||||
spec_name: str,
|
||||
diff_summary: str = "",
|
||||
files_changed: list[str] | None = None,
|
||||
github_issue: int | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Generate a commit message asynchronously.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_name: Spec identifier (e.g., "001-add-feature")
|
||||
diff_summary: Git diff stat or summary
|
||||
files_changed: List of changed file paths
|
||||
github_issue: GitHub issue number if linked (overrides spec metadata)
|
||||
|
||||
Returns:
|
||||
Generated commit message or fallback message
|
||||
"""
|
||||
# Find spec directory
|
||||
spec_dir = project_dir / ".auto-claude" / "specs" / spec_name
|
||||
if not spec_dir.exists():
|
||||
spec_dir = project_dir / "auto-claude" / "specs" / spec_name
|
||||
|
||||
# Get context from spec files
|
||||
spec_context = _get_spec_context(spec_dir) if spec_dir.exists() else {}
|
||||
|
||||
# Override with provided github_issue
|
||||
if github_issue:
|
||||
spec_context["github_issue"] = github_issue
|
||||
|
||||
# Build prompt
|
||||
prompt = _build_prompt(
|
||||
spec_context,
|
||||
diff_summary,
|
||||
files_changed or [],
|
||||
)
|
||||
|
||||
# Call Claude
|
||||
try:
|
||||
result = await _call_claude(prompt)
|
||||
if result:
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate commit message: {e}")
|
||||
|
||||
# Fallback message
|
||||
commit_type = CATEGORY_TO_COMMIT_TYPE.get(
|
||||
spec_context.get("category", "").lower(), "chore"
|
||||
)
|
||||
title = spec_context.get("title", spec_name)
|
||||
fallback = f"{commit_type}: {title}"
|
||||
|
||||
if github_issue or spec_context.get("github_issue"):
|
||||
issue_num = github_issue or spec_context.get("github_issue")
|
||||
fallback += f"\n\nFixes #{issue_num}"
|
||||
|
||||
return fallback
|
||||
@@ -0,0 +1,37 @@
|
||||
"""
|
||||
Context Package
|
||||
===============
|
||||
|
||||
Task context building for autonomous coding.
|
||||
"""
|
||||
|
||||
from .builder import ContextBuilder
|
||||
from .categorizer import FileCategorizer
|
||||
from .graphiti_integration import fetch_graph_hints, is_graphiti_enabled
|
||||
from .keyword_extractor import KeywordExtractor
|
||||
from .models import FileMatch, TaskContext
|
||||
from .pattern_discovery import PatternDiscoverer
|
||||
from .search import CodeSearcher
|
||||
from .serialization import load_context, save_context, serialize_context
|
||||
from .service_matcher import ServiceMatcher
|
||||
|
||||
__all__ = [
|
||||
# Main builder
|
||||
"ContextBuilder",
|
||||
# Models
|
||||
"FileMatch",
|
||||
"TaskContext",
|
||||
# Components
|
||||
"CodeSearcher",
|
||||
"ServiceMatcher",
|
||||
"KeywordExtractor",
|
||||
"FileCategorizer",
|
||||
"PatternDiscoverer",
|
||||
# Graphiti integration
|
||||
"fetch_graph_hints",
|
||||
"is_graphiti_enabled",
|
||||
# Serialization
|
||||
"serialize_context",
|
||||
"save_context",
|
||||
"load_context",
|
||||
]
|
||||
@@ -0,0 +1,244 @@
|
||||
"""
|
||||
Context Builder
|
||||
===============
|
||||
|
||||
Main builder class that orchestrates context building for tasks.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
|
||||
from .categorizer import FileCategorizer
|
||||
from .graphiti_integration import fetch_graph_hints, is_graphiti_enabled
|
||||
from .keyword_extractor import KeywordExtractor
|
||||
from .models import FileMatch, TaskContext
|
||||
from .pattern_discovery import PatternDiscoverer
|
||||
from .search import CodeSearcher
|
||||
from .service_matcher import ServiceMatcher
|
||||
|
||||
|
||||
class ContextBuilder:
|
||||
"""Builds task-specific context by searching the codebase."""
|
||||
|
||||
def __init__(self, project_dir: Path, project_index: dict | None = None):
|
||||
self.project_dir = project_dir.resolve()
|
||||
self.project_index = project_index or self._load_project_index()
|
||||
|
||||
# Initialize components
|
||||
self.searcher = CodeSearcher(self.project_dir)
|
||||
self.service_matcher = ServiceMatcher(self.project_index)
|
||||
self.keyword_extractor = KeywordExtractor()
|
||||
self.categorizer = FileCategorizer()
|
||||
self.pattern_discoverer = PatternDiscoverer(self.project_dir)
|
||||
|
||||
def _load_project_index(self) -> dict:
|
||||
"""Load project index from file or create new one (.auto-claude is the installed instance)."""
|
||||
index_file = self.project_dir / ".auto-claude" / "project_index.json"
|
||||
if index_file.exists():
|
||||
with open(index_file) as f:
|
||||
return json.load(f)
|
||||
|
||||
# Try to create one
|
||||
from analyzer import analyze_project
|
||||
|
||||
return analyze_project(self.project_dir)
|
||||
|
||||
def build_context(
|
||||
self,
|
||||
task: str,
|
||||
services: list[str] | None = None,
|
||||
keywords: list[str] | None = None,
|
||||
include_graph_hints: bool = True,
|
||||
) -> TaskContext:
|
||||
"""
|
||||
Build context for a specific task.
|
||||
|
||||
Args:
|
||||
task: Description of the task
|
||||
services: List of service names to search (None = auto-detect)
|
||||
keywords: Additional keywords to search for
|
||||
include_graph_hints: Whether to include historical hints from Graphiti
|
||||
|
||||
Returns:
|
||||
TaskContext with relevant files and patterns
|
||||
"""
|
||||
# Auto-detect services if not specified
|
||||
if not services:
|
||||
services = self.service_matcher.suggest_services(task)
|
||||
|
||||
# Extract keywords from task if not provided
|
||||
if not keywords:
|
||||
keywords = self.keyword_extractor.extract_keywords(task)
|
||||
|
||||
# Search each service
|
||||
all_matches: list[FileMatch] = []
|
||||
service_contexts = {}
|
||||
|
||||
for service_name in services:
|
||||
service_info = self.project_index.get("services", {}).get(service_name)
|
||||
if not service_info:
|
||||
continue
|
||||
|
||||
service_path = Path(service_info.get("path", service_name))
|
||||
if not service_path.is_absolute():
|
||||
service_path = self.project_dir / service_path
|
||||
|
||||
# Search this service
|
||||
matches = self.searcher.search_service(service_path, service_name, keywords)
|
||||
all_matches.extend(matches)
|
||||
|
||||
# Load or generate service context
|
||||
service_contexts[service_name] = self._get_service_context(
|
||||
service_path, service_name, service_info
|
||||
)
|
||||
|
||||
# Categorize matches
|
||||
files_to_modify, files_to_reference = self.categorizer.categorize_matches(
|
||||
all_matches, task
|
||||
)
|
||||
|
||||
# Discover patterns from reference files
|
||||
patterns = self.pattern_discoverer.discover_patterns(
|
||||
files_to_reference, keywords
|
||||
)
|
||||
|
||||
# Get graph hints (synchronously wrap async call)
|
||||
graph_hints = []
|
||||
if include_graph_hints and is_graphiti_enabled():
|
||||
try:
|
||||
# Run the async function in a new event loop if necessary
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
# We're already in an async context - this shouldn't happen in CLI
|
||||
# but handle it gracefully
|
||||
graph_hints = []
|
||||
except RuntimeError:
|
||||
# No event loop running - create one
|
||||
graph_hints = asyncio.run(
|
||||
fetch_graph_hints(task, str(self.project_dir))
|
||||
)
|
||||
except Exception:
|
||||
# Graphiti is optional - fail gracefully
|
||||
graph_hints = []
|
||||
|
||||
return TaskContext(
|
||||
task_description=task,
|
||||
scoped_services=services,
|
||||
files_to_modify=[
|
||||
asdict(f) if isinstance(f, FileMatch) else f for f in files_to_modify
|
||||
],
|
||||
files_to_reference=[
|
||||
asdict(f) if isinstance(f, FileMatch) else f for f in files_to_reference
|
||||
],
|
||||
patterns_discovered=patterns,
|
||||
service_contexts=service_contexts,
|
||||
graph_hints=graph_hints,
|
||||
)
|
||||
|
||||
async def build_context_async(
|
||||
self,
|
||||
task: str,
|
||||
services: list[str] | None = None,
|
||||
keywords: list[str] | None = None,
|
||||
include_graph_hints: bool = True,
|
||||
) -> TaskContext:
|
||||
"""
|
||||
Build context for a specific task (async version).
|
||||
|
||||
This version is preferred when called from async code as it can
|
||||
properly await the graph hints retrieval.
|
||||
|
||||
Args:
|
||||
task: Description of the task
|
||||
services: List of service names to search (None = auto-detect)
|
||||
keywords: Additional keywords to search for
|
||||
include_graph_hints: Whether to include historical hints from Graphiti
|
||||
|
||||
Returns:
|
||||
TaskContext with relevant files and patterns
|
||||
"""
|
||||
# Auto-detect services if not specified
|
||||
if not services:
|
||||
services = self.service_matcher.suggest_services(task)
|
||||
|
||||
# Extract keywords from task if not provided
|
||||
if not keywords:
|
||||
keywords = self.keyword_extractor.extract_keywords(task)
|
||||
|
||||
# Search each service
|
||||
all_matches: list[FileMatch] = []
|
||||
service_contexts = {}
|
||||
|
||||
for service_name in services:
|
||||
service_info = self.project_index.get("services", {}).get(service_name)
|
||||
if not service_info:
|
||||
continue
|
||||
|
||||
service_path = Path(service_info.get("path", service_name))
|
||||
if not service_path.is_absolute():
|
||||
service_path = self.project_dir / service_path
|
||||
|
||||
# Search this service
|
||||
matches = self.searcher.search_service(service_path, service_name, keywords)
|
||||
all_matches.extend(matches)
|
||||
|
||||
# Load or generate service context
|
||||
service_contexts[service_name] = self._get_service_context(
|
||||
service_path, service_name, service_info
|
||||
)
|
||||
|
||||
# Categorize matches
|
||||
files_to_modify, files_to_reference = self.categorizer.categorize_matches(
|
||||
all_matches, task
|
||||
)
|
||||
|
||||
# Discover patterns from reference files
|
||||
patterns = self.pattern_discoverer.discover_patterns(
|
||||
files_to_reference, keywords
|
||||
)
|
||||
|
||||
# Get graph hints asynchronously
|
||||
graph_hints = []
|
||||
if include_graph_hints:
|
||||
graph_hints = await fetch_graph_hints(task, str(self.project_dir))
|
||||
|
||||
return TaskContext(
|
||||
task_description=task,
|
||||
scoped_services=services,
|
||||
files_to_modify=[
|
||||
asdict(f) if isinstance(f, FileMatch) else f for f in files_to_modify
|
||||
],
|
||||
files_to_reference=[
|
||||
asdict(f) if isinstance(f, FileMatch) else f for f in files_to_reference
|
||||
],
|
||||
patterns_discovered=patterns,
|
||||
service_contexts=service_contexts,
|
||||
graph_hints=graph_hints,
|
||||
)
|
||||
|
||||
def _get_service_context(
|
||||
self,
|
||||
service_path: Path,
|
||||
service_name: str,
|
||||
service_info: dict,
|
||||
) -> dict:
|
||||
"""Get or generate context for a service."""
|
||||
# Check for SERVICE_CONTEXT.md
|
||||
context_file = service_path / "SERVICE_CONTEXT.md"
|
||||
if context_file.exists():
|
||||
return {
|
||||
"source": "SERVICE_CONTEXT.md",
|
||||
"content": context_file.read_text()[:2000], # First 2000 chars
|
||||
}
|
||||
|
||||
# Generate basic context from service info
|
||||
return {
|
||||
"source": "generated",
|
||||
"language": service_info.get("language"),
|
||||
"framework": service_info.get("framework"),
|
||||
"type": service_info.get("type"),
|
||||
"entry_point": service_info.get("entry_point"),
|
||||
"key_directories": service_info.get("key_directories", {}),
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user