Compare commits
112 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a0f538624f | |||
| 52a4fcc6d3 | |||
| fb6b7fc6d2 | |||
| 0f9c5b8403 | |||
| 5d8ede2331 | |||
| da31b68774 | |||
| 2effa53517 | |||
| c15bb31146 | |||
| 203a970ab5 | |||
| 3c0708b749 | |||
| 666794b5fc | |||
| ac8dfcac7b | |||
| 798ca79ddb | |||
| bdb0154977 | |||
| 515b73b553 | |||
| 88c7605985 | |||
| 62a7551571 | |||
| 717fba0440 | |||
| 0a571d3a2b | |||
| 2f662469e9 | |||
| e7e6b52128 | |||
| 230de5fc03 | |||
| 4bdf7a0c5b | |||
| a39ea49d4d | |||
| 9aef0dd0b8 | |||
| 05131217cf | |||
| 321c971289 | |||
| 98b12ed807 | |||
| aaa83131f4 | |||
| 68548e33c8 | |||
| 7751588e56 | |||
| 8b4ce58c56 | |||
| bc22064535 | |||
| db0cbea3f2 | |||
| 0ca2e3f697 | |||
| 2d3b7fb4b6 | |||
| 9bbdef0949 | |||
| 753dc8bbe3 | |||
| 20f20fa321 | |||
| eabe7c7d1b | |||
| 1fa7a9c769 | |||
| 7881b2d1e9 | |||
| 4e71361b2d | |||
| 4dcc5afae8 | |||
| e9782db044 | |||
| 40d04d7c7d | |||
| fef07c9517 | |||
| 9d43abedde | |||
| d51f45621b | |||
| 787667e98e | |||
| 9734b70b05 | |||
| fec6b9f339 | |||
| d3a63b09fd | |||
| 50e3111ae2 | |||
| 8a80b1d5c8 | |||
| cb6b216534 | |||
| 661e47c341 | |||
| e80ef79d12 | |||
| e1b0f743bf | |||
| 92c6f2786d | |||
| 1c14227324 | |||
| c0a02a453d | |||
| 086429cb49 | |||
| d9fb8f29d5 | |||
| d0b0b3df0d | |||
| 937a60f8bd | |||
| 7a51cbd5e5 | |||
| 26beefe39d | |||
| 8416f3076a | |||
| 217249c8a3 | |||
| 8bb3df917e | |||
| 5106c6e9b2 | |||
| 3ff612742f | |||
| 7f19c2e1f3 | |||
| d98e28305d | |||
| 0b874d4b33 | |||
| 50dd10788a | |||
| f1cc5a09f2 | |||
| b005fa5c86 | |||
| d79f2da411 | |||
| 5ac566e2a2 | |||
| f49d4817b1 | |||
| 1e1d7d9b68 | |||
| e74a3dffd2 | |||
| 6ac8250b5b | |||
| a2cee6941f | |||
| d4cad80a73 | |||
| 596e95137b | |||
| d42041c5b5 | |||
| a3f87540c6 | |||
| f843811292 | |||
| 5e8c53080f | |||
| 348de6dfe7 | |||
| 0f7d6e0530 | |||
| 5ccdb6abc5 | |||
| 6ec8549f63 | |||
| 53527293cd | |||
| 02bef954f3 | |||
| f168bdc3ac | |||
| e3eec68aab | |||
| 407a0bee5e | |||
| 8f766ad16e | |||
| ced2ad479f | |||
| 05f5d3038b | |||
| 6951251b33 | |||
| 30e7536b63 | |||
| 220faf0fb4 | |||
| f96c6301f4 | |||
| ebd8340d82 | |||
| df779530e7 | |||
| 0adaddacaa | |||
| 91f7051dda |
@@ -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/frontend 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/frontend 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/frontend 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/frontend
|
||||
# This handles: partial directories from npm workspaces, AND broken symlinks
|
||||
if [ -e "apps/frontend/node_modules" ] || [ -L "apps/frontend/node_modules" ]; then
|
||||
# Check if it's a valid symlink pointing to root node_modules
|
||||
if [ -L "apps/frontend/node_modules" ]; then
|
||||
target=$(readlink apps/frontend/node_modules 2>/dev/null || echo "")
|
||||
if [ "$target" = "../../node_modules" ] && [ -d "apps/frontend/node_modules" ]; then
|
||||
echo "Correct symlink already exists: apps/frontend/node_modules -> ../../node_modules"
|
||||
else
|
||||
echo "Removing incorrect/broken symlink (was: $target)..."
|
||||
rm -f "apps/frontend/node_modules"
|
||||
fi
|
||||
else
|
||||
echo "Removing partial node_modules directory created by npm workspaces..."
|
||||
rm -rf "apps/frontend/node_modules"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create link if it doesn't exist or was removed
|
||||
if [ ! -L "apps/frontend/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/frontend/node_modules")
|
||||
powershell -Command "New-Item -ItemType Junction -Path '$link_path' -Target '$abs_target'" > /dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "Created junction: apps/frontend/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/frontend/node_modules; then
|
||||
echo "Created symlink: apps/frontend/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/frontend/node_modules" ]; then
|
||||
echo "::error::apps/frontend/node_modules symlink was not created"
|
||||
exit 1
|
||||
fi
|
||||
# Verify the link resolves to a valid directory with content
|
||||
if ! ls apps/frontend/node_modules/electron >/dev/null 2>&1; then
|
||||
echo "::error::apps/frontend/node_modules does not resolve correctly (electron not found)"
|
||||
ls -la apps/frontend/ || true
|
||||
ls apps/frontend/node_modules 2>&1 | head -5 || true
|
||||
exit 1
|
||||
fi
|
||||
count=$(ls apps/frontend/node_modules 2>/dev/null | wc -l)
|
||||
echo "Verified: apps/frontend/node_modules resolves correctly ($count entries)"
|
||||
@@ -1,52 +0,0 @@
|
||||
name: 'Setup Python Backend'
|
||||
description: 'Set up Python with uv package manager and cached dependencies for the backend'
|
||||
|
||||
inputs:
|
||||
python-version:
|
||||
description: 'Python version to use'
|
||||
required: false
|
||||
default: '3.12'
|
||||
install-test-deps:
|
||||
description: 'Whether to install test dependencies'
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
outputs:
|
||||
cache-hit:
|
||||
description: 'Whether cache was hit'
|
||||
value: ${{ steps.cache.outputs.cache-hit }}
|
||||
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Set up Python ${{ inputs.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
|
||||
- name: Install uv package manager
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Cache uv dependencies
|
||||
id: cache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cache/uv
|
||||
~/AppData/Local/uv/cache
|
||||
~/Library/Caches/uv
|
||||
key: uv-${{ runner.os }}-${{ runner.arch }}-${{ inputs.python-version }}-${{ hashFiles('apps/backend/requirements.txt', 'tests/requirements-test.txt') }}
|
||||
restore-keys: |
|
||||
uv-${{ runner.os }}-${{ runner.arch }}-${{ inputs.python-version }}-
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: apps/backend
|
||||
shell: bash
|
||||
run: |
|
||||
uv venv
|
||||
uv pip install -r requirements.txt
|
||||
if [ "${{ inputs.install-test-deps }}" == "true" ]; then
|
||||
uv pip install -r ../../tests/requirements-test.txt
|
||||
fi
|
||||
@@ -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/frontend/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"
|
||||
+123
-436
@@ -65,48 +65,43 @@ 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 Python
|
||||
uses: actions/setup-python@v6
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
node-version: '24'
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
- 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@v5
|
||||
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@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: apps/frontend/python-runtime
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8-rust-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8-rust
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-rust-
|
||||
python-bundle-${{ runner.os }}-x64-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package macOS (Intel)
|
||||
run: |
|
||||
@@ -116,17 +111,28 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: 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
|
||||
@@ -141,45 +147,40 @@ jobs:
|
||||
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 Python
|
||||
uses: actions/setup-python@v6
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
node-version: '24'
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache
|
||||
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache pip wheel cache
|
||||
uses: actions/cache@v5
|
||||
- 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-
|
||||
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 bundled Python
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: apps/frontend/python-runtime
|
||||
key: python-bundle-${{ runner.os }}-arm64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
key: python-bundle-${{ runner.os }}-arm64-3.12.8
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-arm64-3.12.8-
|
||||
python-bundle-${{ runner.os }}-arm64-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package macOS (Apple Silicon)
|
||||
run: |
|
||||
@@ -189,17 +190,28 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: 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
|
||||
@@ -213,48 +225,41 @@ jobs:
|
||||
build-windows:
|
||||
needs: create-tag
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
id-token: write # Required for OIDC authentication with Azure
|
||||
contents: read
|
||||
env:
|
||||
# Job-level env so AZURE_CLIENT_ID is available for step-level if conditions
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Use tag for real releases, develop branch for dry runs
|
||||
ref: ${{ github.event.inputs.dry_run == 'true' && 'develop' || format('v{0}', needs.create-tag.outputs.version) }}
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
node-version: '24'
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache
|
||||
shell: bash
|
||||
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache pip wheel cache
|
||||
uses: actions/cache@v5
|
||||
- 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-
|
||||
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 bundled Python
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: apps/frontend/python-runtime
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-
|
||||
python-bundle-${{ runner.os }}-x64-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package Windows
|
||||
shell: bash
|
||||
@@ -263,122 +268,8 @@ jobs:
|
||||
cd apps/frontend && npm run package:win -- --config.extraMetadata.version="$VERSION"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Disable electron-builder's built-in signing (we use Azure Trusted Signing instead)
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: false
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Azure Login (OIDC)
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Sign Windows executable with Azure Trusted Signing
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
uses: azure/trusted-signing-action@v0.5.11
|
||||
with:
|
||||
endpoint: https://neu.codesigning.azure.net/
|
||||
trusted-signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
certificate-profile-name: ${{ secrets.AZURE_CERTIFICATE_PROFILE }}
|
||||
files-folder: apps/frontend/dist
|
||||
files-folder-filter: exe
|
||||
file-digest: SHA256
|
||||
timestamp-rfc3161: http://timestamp.acs.microsoft.com
|
||||
timestamp-digest: SHA256
|
||||
|
||||
- name: Verify Windows executable is signed
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
cd apps/frontend/dist
|
||||
$exeFile = Get-ChildItem -Filter "*.exe" | Select-Object -First 1
|
||||
if ($exeFile) {
|
||||
Write-Host "Verifying signature on $($exeFile.Name)..."
|
||||
$sig = Get-AuthenticodeSignature -FilePath $exeFile.FullName
|
||||
if ($sig.Status -ne 'Valid') {
|
||||
Write-Host "::error::Signature verification failed: $($sig.Status)"
|
||||
Write-Host "::error::Status Message: $($sig.StatusMessage)"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "✅ Signature verified successfully"
|
||||
Write-Host " Subject: $($sig.SignerCertificate.Subject)"
|
||||
Write-Host " Issuer: $($sig.SignerCertificate.Issuer)"
|
||||
Write-Host " Thumbprint: $($sig.SignerCertificate.Thumbprint)"
|
||||
} else {
|
||||
Write-Host "::error::No .exe file found to verify"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Regenerate checksums after signing
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
cd apps/frontend/dist
|
||||
|
||||
# Find the installer exe (electron-builder names it with "Setup" or just the app name)
|
||||
# electron-builder produces one installer exe per build
|
||||
$exeFiles = Get-ChildItem -Filter "*.exe"
|
||||
if ($exeFiles.Count -eq 0) {
|
||||
Write-Host "::error::No .exe files found in dist folder"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Found $($exeFiles.Count) exe file(s): $($exeFiles.Name -join ', ')"
|
||||
|
||||
$ymlFile = "latest.yml"
|
||||
if (-not (Test-Path $ymlFile)) {
|
||||
Write-Host "::error::$ymlFile not found - cannot update checksums"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$content = Get-Content $ymlFile -Raw
|
||||
$originalContent = $content
|
||||
|
||||
# Process each exe file and update its hash in latest.yml
|
||||
foreach ($exeFile in $exeFiles) {
|
||||
Write-Host "Processing $($exeFile.Name)..."
|
||||
|
||||
# Compute SHA512 hash and convert to base64 (electron-builder format)
|
||||
$bytes = [System.IO.File]::ReadAllBytes($exeFile.FullName)
|
||||
$sha512 = [System.Security.Cryptography.SHA512]::Create()
|
||||
$hashBytes = $sha512.ComputeHash($bytes)
|
||||
$hash = [System.Convert]::ToBase64String($hashBytes)
|
||||
$size = $exeFile.Length
|
||||
|
||||
Write-Host " Hash: $hash"
|
||||
Write-Host " Size: $size"
|
||||
}
|
||||
|
||||
# For electron-builder, latest.yml has a single file entry for the installer
|
||||
# Update the sha512 and size for the primary exe (first one, typically the installer)
|
||||
$primaryExe = $exeFiles | Select-Object -First 1
|
||||
$bytes = [System.IO.File]::ReadAllBytes($primaryExe.FullName)
|
||||
$sha512 = [System.Security.Cryptography.SHA512]::Create()
|
||||
$hashBytes = $sha512.ComputeHash($bytes)
|
||||
$hash = [System.Convert]::ToBase64String($hashBytes)
|
||||
$size = $primaryExe.Length
|
||||
|
||||
# Update sha512 hash (base64 pattern: alphanumeric, +, /, =)
|
||||
$content = $content -replace 'sha512: [A-Za-z0-9+/=]+', "sha512: $hash"
|
||||
# Update size
|
||||
$content = $content -replace 'size: \d+', "size: $size"
|
||||
|
||||
if ($content -eq $originalContent) {
|
||||
Write-Host "::error::Checksum replacement failed - content unchanged. Check if latest.yml format has changed."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Set-Content -Path $ymlFile -Value $content -NoNewline
|
||||
Write-Host "✅ Updated $ymlFile with new base64 hash and size for $($primaryExe.Name)"
|
||||
|
||||
- name: Skip signing notice
|
||||
if: env.AZURE_CLIENT_ID == ''
|
||||
run: echo "::warning::Windows signing skipped - AZURE_CLIENT_ID not configured. The .exe will be unsigned."
|
||||
CSC_LINK: ${{ secrets.WIN_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -397,45 +288,43 @@ jobs:
|
||||
# Use tag for real releases, develop branch for dry runs
|
||||
ref: ${{ github.event.inputs.dry_run == 'true' && 'develop' || format('v{0}', needs.create-tag.outputs.version) }}
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
node-version: '24'
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache
|
||||
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Flatpak and verification tools
|
||||
- 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 libarchive-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@v5
|
||||
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@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: apps/frontend/python-runtime
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-
|
||||
python-bundle-${{ runner.os }}-x64-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package Linux
|
||||
run: |
|
||||
@@ -443,12 +332,6 @@ jobs:
|
||||
cd apps/frontend && npm run package:linux -- --config.extraMetadata.version="$VERSION"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Verify Linux packages
|
||||
run: cd apps/frontend && npm run verify:linux
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -460,50 +343,8 @@ jobs:
|
||||
apps/frontend/dist/*.flatpak
|
||||
apps/frontend/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
|
||||
|
||||
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:
|
||||
@@ -515,28 +356,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)
|
||||
@@ -545,84 +372,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
|
||||
@@ -657,89 +409,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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+55
-108
@@ -1,38 +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: Reduced matrix (4 jobs vs 6), merged integration tests,
|
||||
# coverage on Linux only, path filters to skip on docs-only changes.
|
||||
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- 'tests/**'
|
||||
- 'package*.json'
|
||||
- 'requirements*.txt'
|
||||
- 'pyproject.toml'
|
||||
- 'tsconfig*.json'
|
||||
- 'biome.jsonc'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/actions/**'
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- 'tests/**'
|
||||
- 'package*.json'
|
||||
- 'requirements*.txt'
|
||||
- 'pyproject.toml'
|
||||
- 'tsconfig*.json'
|
||||
- 'biome.jsonc'
|
||||
- '.github/workflows/ci.yml'
|
||||
- '.github/actions/**'
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -43,63 +15,53 @@ permissions:
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
# --------------------------------------------------------------------------
|
||||
# Python Backend Tests - Optimized Matrix (4 jobs instead of 6)
|
||||
# --------------------------------------------------------------------------
|
||||
# Python tests
|
||||
test-python:
|
||||
name: test-python (${{ matrix.python-version }}, ${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# 3.12 on all OS for cross-platform coverage
|
||||
# 3.13 on Linux only for compatibility check (saves 2 jobs)
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
python-version: '3.12'
|
||||
- os: ubuntu-latest
|
||||
python-version: '3.13'
|
||||
- os: windows-latest
|
||||
python-version: '3.12'
|
||||
- os: macos-latest
|
||||
python-version: '3.12'
|
||||
python-version: ['3.12', '3.13']
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python backend
|
||||
uses: ./.github/actions/setup-python-backend
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
install-test-deps: 'true'
|
||||
|
||||
- name: Run all tests (including platform-specific)
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v4
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: apps/backend
|
||||
shell: bash
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}/apps/backend
|
||||
run: |
|
||||
if [ "$RUNNER_OS" == "Windows" ]; then
|
||||
source .venv/Scripts/activate
|
||||
else
|
||||
source .venv/bin/activate
|
||||
fi
|
||||
pytest ../../tests/ -v --tb=short -x
|
||||
uv venv
|
||||
uv pip install -r requirements.txt
|
||||
uv pip install -r ../../tests/requirements-test.txt
|
||||
|
||||
- name: Run coverage (Linux + Python 3.12 only)
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
|
||||
- name: Run tests
|
||||
working-directory: apps/backend
|
||||
shell: bash
|
||||
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=10
|
||||
pytest ../../tests/ -v --tb=short -x
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.python-version == '3.12'
|
||||
- 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
|
||||
@@ -107,59 +69,44 @@ jobs:
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Frontend Tests - All Platforms
|
||||
# --------------------------------------------------------------------------
|
||||
# Frontend lint, typecheck, test, and build
|
||||
test-frontend:
|
||||
name: test-frontend (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js frontend
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
ignore-scripts: 'true'
|
||||
node-version: '24'
|
||||
|
||||
- name: Run TypeScript type check
|
||||
- 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
|
||||
- name: Run tests
|
||||
working-directory: apps/frontend
|
||||
run: npm run test
|
||||
|
||||
- name: Build application
|
||||
- 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-python, test-frontend]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Check all CI jobs passed
|
||||
run: |
|
||||
echo "CI Job Results:"
|
||||
echo " test-python: ${{ needs.test-python.result }}"
|
||||
echo " test-frontend: ${{ needs.test-frontend.result }}"
|
||||
echo ""
|
||||
|
||||
if [[ "${{ needs.test-python.result }}" != "success" ]] || \
|
||||
[[ "${{ needs.test-frontend.result }}" != "success" ]]; then
|
||||
echo "❌ One or more CI jobs failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ All CI checks passed"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,42 +3,27 @@ name: Lint
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- 'tests/**'
|
||||
- '.github/workflows/lint.yml'
|
||||
- '.github/actions/**'
|
||||
- 'apps/frontend/biome.jsonc'
|
||||
- '.pre-commit-config.yaml'
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- 'tests/**'
|
||||
- '.github/workflows/lint.yml'
|
||||
- '.github/actions/**'
|
||||
- 'apps/frontend/biome.jsonc'
|
||||
- '.pre-commit-config.yaml'
|
||||
|
||||
concurrency:
|
||||
group: lint-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# Python linting (Ruff) - already fast, no changes needed
|
||||
# Python linting
|
||||
python:
|
||||
name: Python (Ruff)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
# Pin ruff version to match .pre-commit-config.yaml
|
||||
# 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
|
||||
|
||||
@@ -47,43 +32,3 @@ jobs:
|
||||
|
||||
- name: Run ruff format check
|
||||
run: ruff format apps/backend/ --check --diff
|
||||
|
||||
# TypeScript/JavaScript linting (Biome) - 15-25x faster than ESLint
|
||||
typescript:
|
||||
name: TypeScript (Biome)
|
||||
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
|
||||
with:
|
||||
version: 2.3.11
|
||||
|
||||
- name: Run Biome
|
||||
working-directory: apps/frontend
|
||||
# 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 .
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Gate Job - Single check for branch protection
|
||||
# --------------------------------------------------------------------------
|
||||
lint-complete:
|
||||
name: Lint Complete
|
||||
runs-on: ubuntu-latest
|
||||
needs: [python, typescript]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Check lint results
|
||||
run: |
|
||||
if [[ "${{ needs.python.result }}" != "success" ]] || \
|
||||
[[ "${{ needs.typescript.result }}" != "success" ]]; then
|
||||
echo "❌ Linting failed"
|
||||
echo " Python: ${{ needs.python.result }}"
|
||||
echo " TypeScript: ${{ needs.typescript.result }}"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ All linting passed"
|
||||
|
||||
@@ -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,299 +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/frontend/',
|
||||
backend: 'apps/backend/',
|
||||
ci: '.github/'
|
||||
}),
|
||||
|
||||
// Label definitions
|
||||
LABELS: Object.freeze({
|
||||
SIZE: ['size/XS', 'size/S', 'size/M', 'size/L', 'size/XL'],
|
||||
AREA: ['area/frontend', 'area/backend', 'area/fullstack', 'area/ci']
|
||||
}),
|
||||
|
||||
// Pagination
|
||||
MAX_FILES_PER_PAGE: 100
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// HELPER FUNCTIONS - Small, focused, single responsibility
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Safely parse conventional commit type from PR title
|
||||
* @param {string} title - PR title
|
||||
* @returns {{type: string|null, isBreaking: boolean}}
|
||||
*/
|
||||
function parseConventionalCommit(title) {
|
||||
if (!title || typeof title !== 'string') {
|
||||
return { type: null, isBreaking: false };
|
||||
}
|
||||
|
||||
// Limit input length to prevent ReDoS attacks
|
||||
const safeTitle = title.slice(0, 200);
|
||||
const match = safeTitle.match(/^(\w{1,20})(\([^)]{0,50}\))?(!)?:/);
|
||||
|
||||
if (!match) {
|
||||
return { type: null, isBreaking: false };
|
||||
}
|
||||
|
||||
return {
|
||||
type: match[1].toLowerCase(),
|
||||
isBreaking: match[3] === '!'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine size label based on lines changed
|
||||
* @param {number} totalLines - Total lines changed
|
||||
* @returns {string} Size label
|
||||
*/
|
||||
function determineSizeLabel(totalLines) {
|
||||
const { SIZE_THRESHOLDS } = CONFIG;
|
||||
|
||||
if (totalLines < SIZE_THRESHOLDS.XS) return 'size/XS';
|
||||
if (totalLines < SIZE_THRESHOLDS.S) return 'size/S';
|
||||
if (totalLines < SIZE_THRESHOLDS.M) return 'size/M';
|
||||
if (totalLines < SIZE_THRESHOLDS.L) return 'size/L';
|
||||
return 'size/XL';
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect areas affected by file changes
|
||||
* @param {Array} files - List of changed files
|
||||
* @returns {{frontend: boolean, backend: boolean, ci: boolean}}
|
||||
*/
|
||||
function detectAreas(files) {
|
||||
const areas = { frontend: false, backend: false, ci: false };
|
||||
const { AREA_PATHS } = CONFIG;
|
||||
|
||||
for (const file of files) {
|
||||
const path = file.filename || '';
|
||||
if (path.startsWith(AREA_PATHS.frontend)) areas.frontend = true;
|
||||
if (path.startsWith(AREA_PATHS.backend)) areas.backend = true;
|
||||
if (path.startsWith(AREA_PATHS.ci)) areas.ci = true;
|
||||
}
|
||||
|
||||
return areas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine area label based on detected areas
|
||||
* @param {{frontend: boolean, backend: boolean, ci: boolean}} areas
|
||||
* @returns {string|null} Area label or null
|
||||
*/
|
||||
function determineAreaLabel(areas) {
|
||||
if (areas.frontend && areas.backend) return 'area/fullstack';
|
||||
if (areas.frontend) return 'area/frontend';
|
||||
if (areas.backend) return 'area/backend';
|
||||
if (areas.ci) return 'area/ci';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove labels from PR (with error handling)
|
||||
* @param {Array} labels - Labels to remove
|
||||
* @param {number} prNumber - PR number
|
||||
*/
|
||||
async function removeLabels(labels, prNumber) {
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
await Promise.allSettled(labels.map(async (label) => {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: label
|
||||
});
|
||||
console.log(` ✓ Removed: ${label}`);
|
||||
} catch (e) {
|
||||
// 404 means label wasn't present - that's fine
|
||||
if (e.status !== 404) {
|
||||
console.log(` ⚠ Failed to remove ${label}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Add labels to PR (with error handling)
|
||||
* @param {Array} labels - Labels to add
|
||||
* @param {number} prNumber - PR number
|
||||
*/
|
||||
async function addLabels(labels, prNumber) {
|
||||
if (labels.length === 0) return;
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels
|
||||
});
|
||||
console.log(` ✓ Added: ${labels.join(', ')}`);
|
||||
} catch (e) {
|
||||
if (e.status === 404) {
|
||||
core.warning(`One or more labels do not exist. Create them in repository settings.`);
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch PR files with full pagination support
|
||||
* @param {number} prNumber - PR number
|
||||
* @returns {Array} List of all files (paginated)
|
||||
*/
|
||||
async function fetchPRFiles(prNumber) {
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
try {
|
||||
// Use paginate to fetch ALL files, not just first 100
|
||||
const files = await github.paginate(
|
||||
github.rest.pulls.listFiles,
|
||||
{ owner, repo, pull_number: prNumber, per_page: CONFIG.MAX_FILES_PER_PAGE }
|
||||
);
|
||||
return files;
|
||||
} catch (e) {
|
||||
console.log(` ⚠ Could not fetch files: ${e.message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// MAIN LOGIC - Orchestrates the labeling process
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const pr = context.payload.pull_request;
|
||||
const prNumber = pr.number;
|
||||
const title = pr.title || '';
|
||||
|
||||
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,195 @@
|
||||
name: PR Status Gate
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [CI, Lint, Quality Security, CLA Assistant, Quality Commit Lint]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
checks: read
|
||||
|
||||
jobs:
|
||||
update-status:
|
||||
name: Update PR Status
|
||||
runs-on: ubuntu-latest
|
||||
# Only run if this workflow_run is associated with a PR
|
||||
if: github.event.workflow_run.pull_requests[0] != null
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check all required checks and update 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.workflow_run.pull_requests[0].number;
|
||||
const headSha = context.payload.workflow_run.head_sha;
|
||||
const triggerWorkflow = context.payload.workflow_run.name;
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// REQUIRED CHECK RUNS - Job-level checks (not workflow-level)
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
// Format: "{Workflow Name} / {Job Name} (pull_request)"
|
||||
//
|
||||
// To find check names: Go to PR → Checks tab → copy exact name
|
||||
// To update: Edit this list when workflow jobs are added/renamed/removed
|
||||
//
|
||||
// Last validated: 2025-12-26
|
||||
// ═══════════════════════════════════════════════════════════════════════
|
||||
const requiredChecks = [
|
||||
// CI workflow (ci.yml) - 3 checks
|
||||
'CI / test-frontend (pull_request)',
|
||||
'CI / test-python (3.12) (pull_request)',
|
||||
'CI / test-python (3.13) (pull_request)',
|
||||
// Lint workflow (lint.yml) - 1 check
|
||||
'Lint / python (pull_request)',
|
||||
// Quality Security workflow (quality-security.yml) - 4 checks
|
||||
'Quality Security / CodeQL (javascript-typescript) (pull_request)',
|
||||
'Quality Security / CodeQL (python) (pull_request)',
|
||||
'Quality Security / Python Security (Bandit) (pull_request)',
|
||||
'Quality Security / Security Summary (pull_request)',
|
||||
// CLA Assistant workflow (cla.yml) - 1 check
|
||||
'CLA Assistant / CLA Check',
|
||||
// Quality Commit Lint workflow (quality-commit-lint.yml) - 1 check
|
||||
'Quality Commit Lint / Conventional Commits (pull_request)'
|
||||
];
|
||||
|
||||
const statusLabels = {
|
||||
checking: '🔄 Checking',
|
||||
passed: '✅ Ready for Review',
|
||||
failed: '❌ Checks Failed'
|
||||
};
|
||||
|
||||
console.log(`::group::PR #${prNumber} - Checking required checks`);
|
||||
console.log(`Triggered by: ${triggerWorkflow}`);
|
||||
console.log(`Head SHA: ${headSha}`);
|
||||
console.log(`Required checks: ${requiredChecks.length}`);
|
||||
console.log('');
|
||||
|
||||
// Fetch all check runs for this commit
|
||||
let allCheckRuns = [];
|
||||
try {
|
||||
const { data } = await github.rest.checks.listForRef({
|
||||
owner,
|
||||
repo,
|
||||
ref: headSha,
|
||||
per_page: 100
|
||||
});
|
||||
allCheckRuns = data.check_runs;
|
||||
console.log(`Found ${allCheckRuns.length} total check runs`);
|
||||
} catch (error) {
|
||||
// Add warning annotation so maintainers are alerted
|
||||
core.warning(`Failed to fetch check runs for PR #${prNumber}: ${error.message}. PR label may be outdated.`);
|
||||
console.log(`::error::Failed to fetch check runs: ${error.message}`);
|
||||
console.log('::endgroup::');
|
||||
return;
|
||||
}
|
||||
|
||||
let allComplete = true;
|
||||
let anyFailed = false;
|
||||
const results = [];
|
||||
|
||||
// Check each required check
|
||||
for (const checkName of requiredChecks) {
|
||||
const check = allCheckRuns.find(c => c.name === checkName);
|
||||
|
||||
if (!check) {
|
||||
results.push({ name: checkName, status: '⏳ Pending', complete: false });
|
||||
allComplete = false;
|
||||
} else if (check.status !== 'completed') {
|
||||
results.push({ name: checkName, status: '🔄 Running', complete: false });
|
||||
allComplete = false;
|
||||
} else if (check.conclusion === 'success') {
|
||||
results.push({ name: checkName, status: '✅ Passed', complete: true });
|
||||
} else if (check.conclusion === 'skipped') {
|
||||
// Skipped checks are treated as passed (e.g., path filters, conditional jobs)
|
||||
results.push({ name: checkName, status: '⏭️ Skipped', complete: true, skipped: true });
|
||||
} else {
|
||||
results.push({ name: checkName, status: '❌ Failed', complete: true, failed: true });
|
||||
anyFailed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Print results table
|
||||
console.log('');
|
||||
console.log('Check Status:');
|
||||
console.log('─'.repeat(70));
|
||||
for (const r of results) {
|
||||
const shortName = r.name.length > 55 ? r.name.substring(0, 52) + '...' : r.name;
|
||||
console.log(` ${r.status.padEnd(12)} ${shortName}`);
|
||||
}
|
||||
console.log('─'.repeat(70));
|
||||
console.log('::endgroup::');
|
||||
|
||||
// Only update label if all required checks are complete
|
||||
if (!allComplete) {
|
||||
const pending = results.filter(r => !r.complete).length;
|
||||
console.log(`⏳ ${pending}/${requiredChecks.length} checks still pending - keeping current label`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine final label
|
||||
const newLabel = anyFailed ? statusLabels.failed : statusLabels.passed;
|
||||
|
||||
console.log(`::group::Updating PR #${prNumber} label`);
|
||||
|
||||
// Remove old status labels
|
||||
for (const label of Object.values(statusLabels)) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: label
|
||||
});
|
||||
console.log(` ✓ Removed: ${label}`);
|
||||
} catch (e) {
|
||||
if (e.status !== 404) {
|
||||
console.log(` ⚠ Could not remove ${label}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add final status label
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
labels: [newLabel]
|
||||
});
|
||||
console.log(` ✓ Added: ${newLabel}`);
|
||||
} catch (e) {
|
||||
if (e.status === 404) {
|
||||
core.warning(`Label '${newLabel}' does not exist. Please create it in repository settings.`);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log('::endgroup::');
|
||||
|
||||
// Summary
|
||||
const passedCount = results.filter(r => r.status === '✅ Passed').length;
|
||||
const skippedCount = results.filter(r => r.skipped).length;
|
||||
const failedCount = results.filter(r => r.failed).length;
|
||||
|
||||
if (anyFailed) {
|
||||
console.log(`❌ PR #${prNumber} has ${failedCount} failing check(s)`);
|
||||
core.summary.addRaw(`## ❌ PR #${prNumber} - Checks Failed\n\n`);
|
||||
core.summary.addRaw(`**${failedCount}** of **${requiredChecks.length}** required checks failed.\n\n`);
|
||||
} else {
|
||||
const skippedNote = skippedCount > 0 ? ` (${skippedCount} skipped)` : '';
|
||||
const totalSuccessful = passedCount + skippedCount;
|
||||
console.log(`✅ PR #${prNumber} is ready for review (${totalSuccessful}/${requiredChecks.length} checks succeeded${skippedNote})`);
|
||||
core.summary.addRaw(`## ✅ PR #${prNumber} - Ready for Review\n\n`);
|
||||
core.summary.addRaw(`All **${requiredChecks.length}** required checks succeeded${skippedNote}.\n\n`);
|
||||
}
|
||||
|
||||
// Add results to summary
|
||||
core.summary.addTable([
|
||||
[{data: 'Check', header: true}, {data: 'Status', header: true}],
|
||||
...results.map(r => [r.name, r.status])
|
||||
]);
|
||||
await core.summary.write();
|
||||
@@ -1,10 +1,8 @@
|
||||
name: Prepare Release
|
||||
|
||||
# Triggers when code is pushed to main (e.g., merging develop → main)
|
||||
# If package.json version is newer than the latest tag:
|
||||
# 1. Validates CHANGELOG.md has an entry for this version (FAILS if missing)
|
||||
# 2. Extracts release notes from CHANGELOG.md
|
||||
# 3. Creates a new tag which triggers release.yml
|
||||
# If package.json version is newer than the latest tag, creates a new tag
|
||||
# which then triggers the release.yml workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -12,13 +10,6 @@ on:
|
||||
paths:
|
||||
- 'apps/frontend/package.json'
|
||||
- 'package.json'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
force:
|
||||
description: 'Force release even if version check fails (use with caution)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
jobs:
|
||||
check-and-tag:
|
||||
@@ -29,23 +20,10 @@ 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
|
||||
@@ -74,141 +52,23 @@ jobs:
|
||||
run: |
|
||||
PACKAGE_VERSION="${{ steps.package.outputs.version }}"
|
||||
LATEST_VERSION="${{ steps.latest_tag.outputs.version }}"
|
||||
FORCE="${{ github.event.inputs.force }}"
|
||||
|
||||
echo "Comparing: package=$PACKAGE_VERSION vs latest_tag=$LATEST_VERSION"
|
||||
|
||||
# Use npx semver for proper semantic version comparison
|
||||
# This correctly handles pre-release versions (2.7.3 > 2.7.3-beta.1)
|
||||
if npx -y semver "$PACKAGE_VERSION" -r ">$LATEST_VERSION" > /dev/null 2>&1; then
|
||||
# Use sort -V for version comparison
|
||||
HIGHER=$(printf '%s\n%s' "$PACKAGE_VERSION" "$LATEST_VERSION" | sort -V | tail -n1)
|
||||
|
||||
if [ "$HIGHER" = "$PACKAGE_VERSION" ] && [ "$PACKAGE_VERSION" != "$LATEST_VERSION" ]; then
|
||||
echo "should_release=true" >> $GITHUB_OUTPUT
|
||||
echo "new_version=$PACKAGE_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "✅ New release needed: v$PACKAGE_VERSION"
|
||||
elif [ "$FORCE" = "true" ]; then
|
||||
echo "should_release=true" >> $GITHUB_OUTPUT
|
||||
echo "new_version=$PACKAGE_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "⚠️ Force release enabled: v$PACKAGE_VERSION"
|
||||
else
|
||||
echo "should_release=false" >> $GITHUB_OUTPUT
|
||||
echo "⏭️ No release needed (package version not newer than latest tag)"
|
||||
fi
|
||||
|
||||
# CRITICAL: Validate CHANGELOG.md has entry for this version BEFORE creating tag
|
||||
- name: Validate and extract changelog
|
||||
if: steps.check.outputs.should_release == 'true'
|
||||
id: changelog
|
||||
run: |
|
||||
VERSION="${{ steps.check.outputs.new_version }}"
|
||||
CHANGELOG_FILE="CHANGELOG.md"
|
||||
|
||||
echo "🔍 Validating CHANGELOG.md for version $VERSION..."
|
||||
|
||||
if [ ! -f "$CHANGELOG_FILE" ]; then
|
||||
echo "::error::CHANGELOG.md not found! Please create CHANGELOG.md with release notes."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Extract changelog section for this version
|
||||
# Looks for "## X.Y.Z" header and captures until next "## " or "---" or end
|
||||
CHANGELOG_CONTENT=$(awk -v ver="$VERSION" '
|
||||
BEGIN { found=0; content="" }
|
||||
/^## / {
|
||||
if (found) exit
|
||||
# Match version at start of header (e.g., "## 2.7.3 -" or "## 2.7.3")
|
||||
if ($2 == ver || $2 ~ "^"ver"[[:space:]]*-") {
|
||||
found=1
|
||||
# Skip the header line itself, we will add our own
|
||||
next
|
||||
}
|
||||
}
|
||||
/^---$/ { if (found) exit }
|
||||
found { content = content $0 "\n" }
|
||||
END {
|
||||
if (!found) {
|
||||
print "NOT_FOUND"
|
||||
exit 1
|
||||
}
|
||||
# Trim leading/trailing whitespace
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", content)
|
||||
print content
|
||||
}
|
||||
' "$CHANGELOG_FILE")
|
||||
|
||||
if [ "$CHANGELOG_CONTENT" = "NOT_FOUND" ] || [ -z "$CHANGELOG_CONTENT" ]; then
|
||||
echo ""
|
||||
echo "::error::═══════════════════════════════════════════════════════════════════════"
|
||||
echo "::error:: CHANGELOG VALIDATION FAILED"
|
||||
echo "::error::═══════════════════════════════════════════════════════════════════════"
|
||||
echo "::error::"
|
||||
echo "::error:: Version $VERSION not found in CHANGELOG.md!"
|
||||
echo "::error::"
|
||||
echo "::error:: Before releasing, please update CHANGELOG.md with an entry like:"
|
||||
echo "::error::"
|
||||
echo "::error:: ## $VERSION - Your Release Title"
|
||||
echo "::error::"
|
||||
echo "::error:: ### ✨ New Features"
|
||||
echo "::error:: - Feature description"
|
||||
echo "::error::"
|
||||
echo "::error:: ### 🐛 Bug Fixes"
|
||||
echo "::error:: - Fix description"
|
||||
echo "::error::"
|
||||
echo "::error::═══════════════════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# Also add to job summary for visibility
|
||||
echo "## ❌ Release Blocked: Missing Changelog" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Version **$VERSION** was not found in CHANGELOG.md." >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### How to fix:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "1. Update CHANGELOG.md with release notes for version $VERSION" >> $GITHUB_STEP_SUMMARY
|
||||
echo "2. Commit and push the changes" >> $GITHUB_STEP_SUMMARY
|
||||
echo "3. The release will automatically retry" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### Expected format:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`markdown" >> $GITHUB_STEP_SUMMARY
|
||||
echo "## $VERSION - Release Title" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### ✨ New Features" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Feature description" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 🐛 Bug Fixes" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Fix description" >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Found changelog entry for version $VERSION"
|
||||
echo ""
|
||||
echo "--- Extracted Release Notes ---"
|
||||
echo "$CHANGELOG_CONTENT"
|
||||
echo "--- End Release Notes ---"
|
||||
|
||||
# Save changelog to file for artifact upload
|
||||
echo "$CHANGELOG_CONTENT" > changelog-extract.md
|
||||
|
||||
# Also save to output (for short changelogs)
|
||||
# Using heredoc for multiline output
|
||||
{
|
||||
echo "content<<CHANGELOG_EOF"
|
||||
echo "$CHANGELOG_CONTENT"
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> $GITHUB_OUTPUT
|
||||
|
||||
echo "changelog_valid=true" >> $GITHUB_OUTPUT
|
||||
|
||||
# Upload changelog as artifact for release.yml to use
|
||||
- name: Upload changelog artifact
|
||||
if: steps.check.outputs.should_release == 'true' && steps.changelog.outputs.changelog_valid == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: changelog-${{ steps.check.outputs.new_version }}
|
||||
path: changelog-extract.md
|
||||
retention-days: 1
|
||||
|
||||
- name: Create and push tag
|
||||
if: steps.check.outputs.should_release == 'true' && steps.changelog.outputs.changelog_valid == 'true'
|
||||
if: steps.check.outputs.should_release == 'true'
|
||||
run: |
|
||||
VERSION="${{ steps.check.outputs.new_version }}"
|
||||
TAG="v$VERSION"
|
||||
@@ -225,19 +85,17 @@ jobs:
|
||||
|
||||
- name: Summary
|
||||
run: |
|
||||
if [ "${{ steps.check.outputs.should_release }}" = "true" ] && [ "${{ steps.changelog.outputs.changelog_valid }}" = "true" ]; then
|
||||
if [ "${{ steps.check.outputs.should_release }}" = "true" ]; then
|
||||
echo "## 🚀 Release Triggered" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version:** v${{ steps.check.outputs.new_version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "✅ Changelog validated and extracted from CHANGELOG.md" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "The release workflow has been triggered and will:" >> $GITHUB_STEP_SUMMARY
|
||||
echo "1. Build binaries for all platforms" >> $GITHUB_STEP_SUMMARY
|
||||
echo "2. Use changelog from CHANGELOG.md" >> $GITHUB_STEP_SUMMARY
|
||||
echo "2. Generate changelog from PRs" >> $GITHUB_STEP_SUMMARY
|
||||
echo "3. Create GitHub release" >> $GITHUB_STEP_SUMMARY
|
||||
echo "4. Update README with new version" >> $GITHUB_STEP_SUMMARY
|
||||
elif [ "${{ steps.check.outputs.should_release }}" = "false" ]; then
|
||||
else
|
||||
echo "## ⏭️ No Release Needed" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Package version:** ${{ steps.package.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -1,29 +1,14 @@
|
||||
name: Quality Security
|
||||
|
||||
# CodeQL runs on all PRs, pushes to main, and weekly schedule
|
||||
# Note: CodeQL takes 20-30 min per language (40-60 min total)
|
||||
# Bandit is fast (5-10 min)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- 'tests/**'
|
||||
- 'pyproject.toml'
|
||||
- 'package.json'
|
||||
- '.github/workflows/quality-security.yml'
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
paths:
|
||||
- 'apps/**'
|
||||
- 'tests/**'
|
||||
- 'pyproject.toml'
|
||||
- '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
|
||||
@@ -60,7 +45,6 @@ jobs:
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
# Bandit runs on all PRs - it's fast (5-10 min)
|
||||
python-security:
|
||||
name: Python Security (Bandit)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -70,7 +54,7 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
@@ -81,6 +65,8 @@ jobs:
|
||||
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"
|
||||
@@ -89,11 +75,12 @@ jobs:
|
||||
echo "::endgroup::"
|
||||
|
||||
- name: Analyze Bandit results
|
||||
uses: actions/github-script@v8
|
||||
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;
|
||||
@@ -102,23 +89,38 @@ jobs:
|
||||
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(` 🔴 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::');
|
||||
|
||||
let summary = `## Python Security Scan (Bandit)\n\n`;
|
||||
// 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`;
|
||||
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`;
|
||||
@@ -132,15 +134,14 @@ jobs:
|
||||
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');
|
||||
console.log('✅ No high severity security issues found');
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Gate Job - Single check for branch protection
|
||||
# --------------------------------------------------------------------------
|
||||
# Summary job that waits for all security checks
|
||||
security-summary:
|
||||
name: Security Summary
|
||||
runs-on: ubuntu-latest
|
||||
@@ -149,7 +150,7 @@ jobs:
|
||||
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 }}';
|
||||
@@ -159,7 +160,7 @@ jobs:
|
||||
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);
|
||||
|
||||
+376
-423
@@ -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,45 +17,45 @@ 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 Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
- 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@v5
|
||||
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@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: apps/frontend/python-runtime
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8-rust-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8-rust
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-rust-
|
||||
python-bundle-${{ runner.os }}-x64-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package macOS (Intel)
|
||||
run: cd apps/frontend && npm run package:mac -- --x64
|
||||
@@ -69,17 +63,28 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: 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
|
||||
@@ -88,48 +93,46 @@ jobs:
|
||||
path: |
|
||||
apps/frontend/dist/*.dmg
|
||||
apps/frontend/dist/*.zip
|
||||
apps/frontend/dist/*.yml
|
||||
apps/frontend/dist/*.blockmap
|
||||
|
||||
# Apple Silicon build on ARM64 runner for native compilation
|
||||
build-macos-arm64:
|
||||
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 Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
|
||||
- name: Cache pip wheel cache
|
||||
uses: actions/cache@v5
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
path: ~/Library/Caches/pip
|
||||
key: pip-wheel-${{ runner.os }}-arm64-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
pip-wheel-${{ runner.os }}-arm64-
|
||||
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 bundled Python
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: apps/frontend/python-runtime
|
||||
key: python-bundle-${{ runner.os }}-arm64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
key: python-bundle-${{ runner.os }}-arm64-3.12.8
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-arm64-3.12.8-
|
||||
python-bundle-${{ runner.os }}-arm64-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package macOS (Apple Silicon)
|
||||
run: cd apps/frontend && npm run package:mac -- --arm64
|
||||
@@ -137,17 +140,28 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CSC_LINK: ${{ secrets.MAC_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CERTIFICATE_PASSWORD }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: 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
|
||||
@@ -156,171 +170,53 @@ jobs:
|
||||
path: |
|
||||
apps/frontend/dist/*.dmg
|
||||
apps/frontend/dist/*.zip
|
||||
apps/frontend/dist/*.yml
|
||||
apps/frontend/dist/*.blockmap
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
id-token: write # Required for OIDC authentication with Azure
|
||||
contents: read
|
||||
env:
|
||||
# Job-level env so AZURE_CLIENT_ID is available for step-level if conditions
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
|
||||
- name: Cache pip wheel cache
|
||||
uses: actions/cache@v5
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@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-
|
||||
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 bundled Python
|
||||
uses: actions/cache@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: apps/frontend/python-runtime
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-
|
||||
python-bundle-${{ runner.os }}-x64-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package Windows
|
||||
run: cd apps/frontend && npm run package:win
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Disable electron-builder's built-in signing (we use Azure Trusted Signing instead)
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: false
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Azure Login (OIDC)
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Sign Windows executable with Azure Trusted Signing
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
uses: azure/trusted-signing-action@v0.5.11
|
||||
with:
|
||||
endpoint: https://neu.codesigning.azure.net/
|
||||
trusted-signing-account-name: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
certificate-profile-name: ${{ secrets.AZURE_CERTIFICATE_PROFILE }}
|
||||
files-folder: apps/frontend/dist
|
||||
files-folder-filter: exe
|
||||
file-digest: SHA256
|
||||
timestamp-rfc3161: http://timestamp.acs.microsoft.com
|
||||
timestamp-digest: SHA256
|
||||
|
||||
- name: Verify Windows executable is signed
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
cd apps/frontend/dist
|
||||
$exeFile = Get-ChildItem -Filter "*.exe" | Select-Object -First 1
|
||||
if ($exeFile) {
|
||||
Write-Host "Verifying signature on $($exeFile.Name)..."
|
||||
$sig = Get-AuthenticodeSignature -FilePath $exeFile.FullName
|
||||
if ($sig.Status -ne 'Valid') {
|
||||
Write-Host "::error::Signature verification failed: $($sig.Status)"
|
||||
Write-Host "::error::Status Message: $($sig.StatusMessage)"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "✅ Signature verified successfully"
|
||||
Write-Host " Subject: $($sig.SignerCertificate.Subject)"
|
||||
Write-Host " Issuer: $($sig.SignerCertificate.Issuer)"
|
||||
Write-Host " Thumbprint: $($sig.SignerCertificate.Thumbprint)"
|
||||
} else {
|
||||
Write-Host "::error::No .exe file found to verify"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Regenerate checksums after signing
|
||||
if: env.AZURE_CLIENT_ID != ''
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
cd apps/frontend/dist
|
||||
|
||||
# Find the installer exe (electron-builder names it with "Setup" or just the app name)
|
||||
# electron-builder produces one installer exe per build
|
||||
$exeFiles = Get-ChildItem -Filter "*.exe"
|
||||
if ($exeFiles.Count -eq 0) {
|
||||
Write-Host "::error::No .exe files found in dist folder"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Found $($exeFiles.Count) exe file(s): $($exeFiles.Name -join ', ')"
|
||||
|
||||
$ymlFile = "latest.yml"
|
||||
if (-not (Test-Path $ymlFile)) {
|
||||
Write-Host "::error::$ymlFile not found - cannot update checksums"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$content = Get-Content $ymlFile -Raw
|
||||
$originalContent = $content
|
||||
|
||||
# Process each exe file and update its hash in latest.yml
|
||||
foreach ($exeFile in $exeFiles) {
|
||||
Write-Host "Processing $($exeFile.Name)..."
|
||||
|
||||
# Compute SHA512 hash and convert to base64 (electron-builder format)
|
||||
$bytes = [System.IO.File]::ReadAllBytes($exeFile.FullName)
|
||||
$sha512 = [System.Security.Cryptography.SHA512]::Create()
|
||||
$hashBytes = $sha512.ComputeHash($bytes)
|
||||
$hash = [System.Convert]::ToBase64String($hashBytes)
|
||||
$size = $exeFile.Length
|
||||
|
||||
Write-Host " Hash: $hash"
|
||||
Write-Host " Size: $size"
|
||||
}
|
||||
|
||||
# For electron-builder, latest.yml has a single file entry for the installer
|
||||
# Update the sha512 and size for the primary exe (first one, typically the installer)
|
||||
$primaryExe = $exeFiles | Select-Object -First 1
|
||||
$bytes = [System.IO.File]::ReadAllBytes($primaryExe.FullName)
|
||||
$sha512 = [System.Security.Cryptography.SHA512]::Create()
|
||||
$hashBytes = $sha512.ComputeHash($bytes)
|
||||
$hash = [System.Convert]::ToBase64String($hashBytes)
|
||||
$size = $primaryExe.Length
|
||||
|
||||
# Update sha512 hash (base64 pattern: alphanumeric, +, /, =)
|
||||
$content = $content -replace 'sha512: [A-Za-z0-9+/=]+', "sha512: $hash"
|
||||
# Update size
|
||||
$content = $content -replace 'size: \d+', "size: $size"
|
||||
|
||||
if ($content -eq $originalContent) {
|
||||
Write-Host "::error::Checksum replacement failed - content unchanged. Check if latest.yml format has changed."
|
||||
exit 1
|
||||
}
|
||||
|
||||
Set-Content -Path $ymlFile -Value $content -NoNewline
|
||||
Write-Host "✅ Updated $ymlFile with new base64 hash and size for $($primaryExe.Name)"
|
||||
|
||||
- name: Skip signing notice
|
||||
if: env.AZURE_CLIENT_ID == ''
|
||||
run: echo "::warning::Windows signing skipped - AZURE_CLIENT_ID not configured. The .exe will be unsigned."
|
||||
CSC_LINK: ${{ secrets.WIN_CERTIFICATE }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.WIN_CERTIFICATE_PASSWORD }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -328,8 +224,6 @@ jobs:
|
||||
name: windows-builds
|
||||
path: |
|
||||
apps/frontend/dist/*.exe
|
||||
apps/frontend/dist/*.yml
|
||||
apps/frontend/dist/*.blockmap
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -337,54 +231,51 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '24'
|
||||
|
||||
- name: Setup Flatpak and verification tools
|
||||
- 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 libarchive-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@v5
|
||||
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@v5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: apps/frontend/python-runtime
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-
|
||||
python-bundle-${{ runner.os }}-x64-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Package Linux
|
||||
run: cd apps/frontend && npm run package:linux
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Verify Linux packages
|
||||
run: cd apps/frontend && npm run verify:linux
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -394,53 +285,9 @@ jobs:
|
||||
apps/frontend/dist/*.AppImage
|
||||
apps/frontend/dist/*.deb
|
||||
apps/frontend/dist/*.flatpak
|
||||
apps/frontend/dist/*.yml
|
||||
apps/frontend/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
|
||||
|
||||
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
|
||||
@@ -450,80 +297,23 @@ 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" \) -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
|
||||
installer_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) | wc -l)
|
||||
if [ "$installer_count" -eq 0 ]; then
|
||||
echo "::error::No installer artifacts found! Expected .dmg, .zip, .exe, .AppImage, .deb, or .flatpak files."
|
||||
# Validate that at least one artifact was copied
|
||||
artifact_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) | wc -l)
|
||||
if [ "$artifact_count" -eq 0 ]; then
|
||||
echo "::error::No build artifacts found! Expected .dmg, .zip, .exe, .AppImage, .deb, or .flatpak files."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Found $installer_count binary artifact(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."
|
||||
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:"
|
||||
echo "Found $artifact_count artifact(s):"
|
||||
ls -la release-assets/
|
||||
|
||||
- name: Generate checksums
|
||||
@@ -532,6 +322,144 @@ jobs:
|
||||
sha256sum ./* > checksums.sha256
|
||||
cat checksums.sha256
|
||||
|
||||
- name: Scan with VirusTotal
|
||||
id: virustotal
|
||||
continue-on-error: true
|
||||
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
|
||||
env:
|
||||
VT_API_KEY: ${{ secrets.VIRUSTOTAL_API_KEY }}
|
||||
run: |
|
||||
if [ -z "$VT_API_KEY" ]; then
|
||||
echo "::warning::VIRUSTOTAL_API_KEY not configured, skipping scan"
|
||||
echo "vt_results=" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "## VirusTotal Scan Results" > vt_results.md
|
||||
echo "" >> vt_results.md
|
||||
|
||||
for file in release-assets/*.{exe,dmg,AppImage,deb,flatpak}; do
|
||||
[ -f "$file" ] || continue
|
||||
filename=$(basename "$file")
|
||||
filesize=$(stat -c%s "$file" 2>/dev/null || stat -f%z "$file")
|
||||
echo "Scanning $filename (${filesize} bytes)..."
|
||||
|
||||
# For files > 32MB, get a special upload URL first
|
||||
if [ "$filesize" -gt 33554432 ]; then
|
||||
echo " Large file detected, requesting upload URL..."
|
||||
upload_url_response=$(curl -s --request GET \
|
||||
--url "https://www.virustotal.com/api/v3/files/upload_url" \
|
||||
--header "x-apikey: $VT_API_KEY")
|
||||
|
||||
upload_url=$(echo "$upload_url_response" | jq -r '.data // empty')
|
||||
if [ -z "$upload_url" ]; then
|
||||
echo "::warning::Failed to get upload URL for large file $filename"
|
||||
echo "Response: $upload_url_response"
|
||||
echo "- $filename - ⚠️ Upload failed (large file)" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
api_url="$upload_url"
|
||||
else
|
||||
api_url="https://www.virustotal.com/api/v3/files"
|
||||
fi
|
||||
|
||||
# Upload file to VirusTotal
|
||||
response=$(curl -s --request POST \
|
||||
--url "$api_url" \
|
||||
--header "x-apikey: $VT_API_KEY" \
|
||||
--form "file=@$file")
|
||||
|
||||
# Check if response is valid JSON before parsing
|
||||
if ! echo "$response" | jq -e . >/dev/null 2>&1; then
|
||||
echo "::warning::VirusTotal returned invalid JSON for $filename"
|
||||
echo "Response (first 500 chars): ${response:0:500}"
|
||||
echo "- $filename - ⚠️ Scan failed (invalid response)" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
|
||||
# Check for API error response
|
||||
error_code=$(echo "$response" | jq -r '.error.code // empty')
|
||||
if [ -n "$error_code" ]; then
|
||||
error_msg=$(echo "$response" | jq -r '.error.message // "Unknown error"')
|
||||
echo "::warning::VirusTotal API error for $filename: $error_code - $error_msg"
|
||||
echo "- $filename - ⚠️ Scan failed ($error_code)" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
|
||||
# Extract analysis ID
|
||||
analysis_id=$(echo "$response" | jq -r '.data.id // empty')
|
||||
|
||||
if [ -z "$analysis_id" ]; then
|
||||
echo "::warning::Failed to upload $filename to VirusTotal"
|
||||
echo "Response: $response"
|
||||
echo "- $filename - ⚠️ Upload failed" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "Uploaded $filename, analysis ID: $analysis_id"
|
||||
|
||||
# Wait for analysis to complete (max 5 minutes per file)
|
||||
analysis=""
|
||||
for i in {1..30}; do
|
||||
sleep 10
|
||||
analysis=$(curl -s --request GET \
|
||||
--url "https://www.virustotal.com/api/v3/analyses/$analysis_id" \
|
||||
--header "x-apikey: $VT_API_KEY")
|
||||
|
||||
# Validate JSON response
|
||||
if ! echo "$analysis" | jq -e . >/dev/null 2>&1; then
|
||||
echo " Warning: Invalid JSON response on attempt $i, retrying..."
|
||||
continue
|
||||
fi
|
||||
|
||||
status=$(echo "$analysis" | jq -r '.data.attributes.status // "unknown"')
|
||||
echo " Status: $status (attempt $i/30)"
|
||||
|
||||
if [ "$status" = "completed" ]; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
# Final validation that we have valid analysis data
|
||||
if ! echo "$analysis" | jq -e '.data.attributes.stats' >/dev/null 2>&1; then
|
||||
echo "::warning::Could not get complete analysis for $filename, using local hash"
|
||||
file_hash=$(sha256sum "$file" | cut -d' ' -f1)
|
||||
echo "- [$filename](https://www.virustotal.com/gui/file/$file_hash) - ⚠️ Analysis incomplete" >> vt_results.md
|
||||
continue
|
||||
fi
|
||||
|
||||
# Get file hash for permanent URL
|
||||
file_hash=$(echo "$analysis" | jq -r '.meta.file_info.sha256 // empty')
|
||||
|
||||
if [ -z "$file_hash" ]; then
|
||||
# Fallback: calculate hash locally
|
||||
file_hash=$(sha256sum "$file" | cut -d' ' -f1)
|
||||
fi
|
||||
|
||||
# Get detection stats
|
||||
malicious=$(echo "$analysis" | jq -r '.data.attributes.stats.malicious // 0')
|
||||
suspicious=$(echo "$analysis" | jq -r '.data.attributes.stats.suspicious // 0')
|
||||
undetected=$(echo "$analysis" | jq -r '.data.attributes.stats.undetected // 0')
|
||||
|
||||
vt_url="https://www.virustotal.com/gui/file/$file_hash"
|
||||
|
||||
if [ "$malicious" -gt 0 ] || [ "$suspicious" -gt 0 ]; then
|
||||
echo "::warning::$filename has $malicious malicious and $suspicious suspicious detections (likely false positives)"
|
||||
echo "- [$filename]($vt_url) - ⚠️ **$malicious malicious, $suspicious suspicious** detections (review recommended)" >> vt_results.md
|
||||
else
|
||||
echo "$filename is clean ($undetected engines, 0 detections)"
|
||||
echo "- [$filename]($vt_url) - ✅ Clean ($undetected engines, 0 detections)" >> vt_results.md
|
||||
fi
|
||||
done
|
||||
|
||||
echo "" >> vt_results.md
|
||||
|
||||
# Save results for release notes
|
||||
cat vt_results.md
|
||||
echo "vt_results<<EOF" >> $GITHUB_OUTPUT
|
||||
cat vt_results.md >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Dry run summary
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run == true }}
|
||||
run: |
|
||||
@@ -545,77 +473,23 @@ jobs:
|
||||
cat release-assets/checksums.sha256 >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Extract changelog from CHANGELOG.md
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
- name: Generate changelog
|
||||
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
|
||||
id: changelog
|
||||
run: |
|
||||
# Extract version from tag (v2.7.2 -> 2.7.2)
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
CHANGELOG_FILE="CHANGELOG.md"
|
||||
|
||||
echo "📋 Extracting release notes for version $VERSION from CHANGELOG.md..."
|
||||
|
||||
if [ ! -f "$CHANGELOG_FILE" ]; then
|
||||
echo "::warning::CHANGELOG.md not found, using minimal release notes"
|
||||
echo "body=Release v$VERSION" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract changelog section for this version
|
||||
# Looks for "## X.Y.Z" header and captures until next "## " or "---"
|
||||
CHANGELOG_CONTENT=$(awk -v ver="$VERSION" '
|
||||
BEGIN { found=0; content="" }
|
||||
/^## / {
|
||||
if (found) exit
|
||||
# Match version at start of header (e.g., "## 2.7.3 -" or "## 2.7.3")
|
||||
if ($2 == ver || $2 ~ "^"ver"[[:space:]]*-") {
|
||||
found=1
|
||||
next
|
||||
}
|
||||
}
|
||||
/^---$/ { if (found) exit }
|
||||
found { content = content $0 "\n" }
|
||||
END {
|
||||
if (!found) {
|
||||
print "NOT_FOUND"
|
||||
exit 0
|
||||
}
|
||||
# Trim leading/trailing whitespace
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", content)
|
||||
print content
|
||||
}
|
||||
' "$CHANGELOG_FILE")
|
||||
|
||||
if [ "$CHANGELOG_CONTENT" = "NOT_FOUND" ] || [ -z "$CHANGELOG_CONTENT" ]; then
|
||||
echo "::warning::Version $VERSION not found in CHANGELOG.md, using minimal release notes"
|
||||
REPO="${{ github.repository }}"
|
||||
CHANGELOG_CONTENT="Release v$VERSION"$'\n\n'"See [CHANGELOG.md](https://github.com/${REPO}/blob/main/CHANGELOG.md) for details."
|
||||
fi
|
||||
|
||||
echo "✅ Extracted changelog content"
|
||||
|
||||
# Save to file first (more reliable for multiline)
|
||||
echo "$CHANGELOG_CONTENT" > changelog-body.md
|
||||
|
||||
# Use file-based output for multiline content
|
||||
{
|
||||
echo "body<<CHANGELOG_EOF"
|
||||
cat changelog-body.md
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> $GITHUB_OUTPUT
|
||||
uses: release-drafter/release-drafter@v6
|
||||
with:
|
||||
config-name: release-drafter.yml
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create Release
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
body: |
|
||||
${{ steps.changelog.outputs.body }}
|
||||
|
||||
---
|
||||
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md
|
||||
|
||||
_VirusTotal scan results will be added automatically after release._
|
||||
${{ steps.virustotal.outputs.vt_results }}
|
||||
files: release-assets/*
|
||||
draft: false
|
||||
prerelease: ${{ contains(github.ref, 'beta') || contains(github.ref, 'alpha') }}
|
||||
@@ -626,16 +500,14 @@ jobs:
|
||||
update-readme:
|
||||
needs: [create-release]
|
||||
runs-on: ubuntu-latest
|
||||
# Only update README on actual releases (tag push), not dry runs
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- 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
|
||||
@@ -655,14 +527,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
|
||||
python3 scripts/update-readme.py "$VERSION" --prerelease
|
||||
else
|
||||
python3 scripts/update-readme.py "$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
|
||||
+1
-12
@@ -14,7 +14,6 @@ Desktop.ini
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
/config.json
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
@@ -56,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
|
||||
@@ -109,8 +106,7 @@ dmypy.json
|
||||
# ===========================
|
||||
# Node.js (apps/frontend)
|
||||
# ===========================
|
||||
node_modules
|
||||
apps/frontend/node_modules
|
||||
node_modules/
|
||||
.npm
|
||||
.yarn/
|
||||
.pnp.*
|
||||
@@ -167,10 +163,3 @@ _bmad-output/
|
||||
.claude/
|
||||
/docs
|
||||
OPUS_ANALYSIS_AND_IDEAS.md
|
||||
/.github/agents
|
||||
|
||||
# Auto Claude generated files
|
||||
.security-key
|
||||
/shared_docs
|
||||
logs/security/
|
||||
Agents.md
|
||||
|
||||
+56
-207
@@ -1,55 +1,5 @@
|
||||
#!/bin/sh
|
||||
|
||||
# =============================================================================
|
||||
# GIT WORKTREE CONTEXT HANDLING
|
||||
# =============================================================================
|
||||
# When running in a worktree, we need to preserve git context to prevent HEAD
|
||||
# corruption. However, we must also CLEAR these variables when NOT in a worktree
|
||||
# to prevent cross-worktree contamination (files leaking between worktrees).
|
||||
#
|
||||
# The bug: If GIT_DIR/GIT_WORK_TREE are set from a previous worktree session
|
||||
# and this hook runs in the main repo (where .git is a directory, not a file),
|
||||
# git commands will target the wrong repository, causing files to appear as
|
||||
# untracked in the wrong location.
|
||||
#
|
||||
# Fix: Explicitly unset these variables when NOT in a worktree context.
|
||||
# =============================================================================
|
||||
|
||||
if [ -f ".git" ]; then
|
||||
# We're in a worktree (.git is a file pointing to the actual git dir)
|
||||
# Use -n with /p to only print lines that match the gitdir: prefix, head -1 for safety
|
||||
WORKTREE_GIT_DIR=$(sed -n 's/^gitdir: //p' .git | head -1)
|
||||
if [ -n "$WORKTREE_GIT_DIR" ] && [ -d "$WORKTREE_GIT_DIR" ]; then
|
||||
export GIT_DIR="$WORKTREE_GIT_DIR"
|
||||
export GIT_WORK_TREE="$(pwd)"
|
||||
else
|
||||
# .git file exists but is malformed or points to non-existent directory
|
||||
# CRITICAL: Clear any inherited GIT_DIR/GIT_WORK_TREE to prevent cross-worktree leakage
|
||||
unset GIT_DIR
|
||||
unset GIT_WORK_TREE
|
||||
fi
|
||||
else
|
||||
# We're in the main repo (.git is a directory)
|
||||
# CRITICAL: Clear any inherited GIT_DIR/GIT_WORK_TREE to prevent cross-worktree leakage
|
||||
unset GIT_DIR
|
||||
unset GIT_WORK_TREE
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# SAFETY CHECK: Detect and fix corrupted core.worktree configuration
|
||||
# =============================================================================
|
||||
# If core.worktree is set in the main repo's config (pointing to a worktree),
|
||||
# this indicates previous corruption. Fix it automatically.
|
||||
if [ ! -f ".git" ]; then
|
||||
CORE_WORKTREE=$(git config --get core.worktree 2>/dev/null || true)
|
||||
if [ -n "$CORE_WORKTREE" ]; then
|
||||
echo "Warning: Detected corrupted core.worktree setting, 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
|
||||
fi
|
||||
|
||||
echo "Running pre-commit checks..."
|
||||
|
||||
# =============================================================================
|
||||
@@ -86,46 +36,14 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
|
||||
echo " Updated apps/backend/__init__.py to $VERSION"
|
||||
fi
|
||||
|
||||
# Sync to README.md - section-aware updates (stable vs beta)
|
||||
# Sync to README.md
|
||||
if [ -f "README.md" ]; then
|
||||
# Escape hyphens for shields.io badge format (shields.io uses -- for literal hyphens)
|
||||
ESCAPED_VERSION=$(echo "$VERSION" | sed 's/-/--/g')
|
||||
|
||||
# Detect if this is a prerelease (contains - after base version, e.g., 2.7.2-beta.10)
|
||||
if echo "$VERSION" | grep -q '-'; then
|
||||
# PRERELEASE: Update only beta sections
|
||||
echo " Detected PRERELEASE version: $VERSION"
|
||||
|
||||
# Update beta version badge (orange)
|
||||
sed -i.bak "s/beta-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-orange/beta-$ESCAPED_VERSION-orange/g" README.md
|
||||
|
||||
# Update beta version badge link (within BETA_VERSION_BADGE section)
|
||||
sed -i.bak '/<!-- BETA_VERSION_BADGE -->/,/<!-- BETA_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
|
||||
|
||||
# Update beta download links (within BETA_DOWNLOADS section only)
|
||||
# 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
|
||||
done
|
||||
else
|
||||
# STABLE: Update stable sections and top badge
|
||||
echo " Detected STABLE version: $VERSION"
|
||||
|
||||
# Update top version badge (blue) - within TOP_VERSION_BADGE section
|
||||
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s/version-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/version-'"$ESCAPED_VERSION"'-blue/g' README.md
|
||||
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
|
||||
|
||||
# Update stable version badge (blue) - within STABLE_VERSION_BADGE section
|
||||
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s/stable-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/stable-'"$ESCAPED_VERSION"'-blue/g' README.md
|
||||
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
|
||||
|
||||
# Update stable download links (within STABLE_DOWNLOADS section only)
|
||||
# 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
|
||||
done
|
||||
fi
|
||||
|
||||
# Update version badge - match both stable (X.Y.Z) and prerelease (X.Y.Z-prerelease.N or X.Y.Z--prerelease.N)
|
||||
sed -i.bak "s/version-[0-9]*\.[0-9]*\.[0-9]*\(-\{1,2\}[a-z]*\.[0-9]*\)*-blue/version-$ESCAPED_VERSION-blue/g" README.md
|
||||
# Update download links - match both stable and prerelease versions
|
||||
sed -i.bak "s/Auto-Claude-[0-9]*\.[0-9]*\.[0-9]*\(-[a-z]*\.[0-9]*\)*/Auto-Claude-$VERSION/g" README.md
|
||||
rm -f README.md.bak
|
||||
git add README.md
|
||||
echo " Updated README.md to $VERSION"
|
||||
@@ -154,67 +72,47 @@ if git diff --cached --name-only | grep -q "^apps/backend/.*\.py$"; then
|
||||
fi
|
||||
|
||||
if [ -n "$RUFF" ]; then
|
||||
# Get only staged Python files in apps/backend (process only what's being committed)
|
||||
STAGED_PY_FILES=$(git diff --cached --name-only --diff-filter=ACM | grep "^apps/backend/.*\.py$" || true)
|
||||
|
||||
if [ -n "$STAGED_PY_FILES" ]; then
|
||||
# Run ruff linting (auto-fix) only on staged files
|
||||
echo "Running ruff lint on staged files..."
|
||||
echo "$STAGED_PY_FILES" | xargs $RUFF check --fix
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Ruff lint failed. Please fix Python linting errors before committing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run ruff format (auto-fix) only on staged files
|
||||
echo "Running ruff format on staged files..."
|
||||
echo "$STAGED_PY_FILES" | xargs $RUFF format
|
||||
|
||||
# Re-stage only the files that were originally staged (in case ruff modified them)
|
||||
echo "$STAGED_PY_FILES" | xargs git add
|
||||
# Run ruff linting (auto-fix)
|
||||
echo "Running ruff lint..."
|
||||
$RUFF check apps/backend/ --fix
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Ruff lint failed. Please fix Python linting errors before committing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run ruff format (auto-fix)
|
||||
echo "Running ruff format..."
|
||||
$RUFF format apps/backend/
|
||||
|
||||
# Stage any files that were auto-fixed by ruff (POSIX-compliant)
|
||||
find apps/backend -name "*.py" -type f -exec git add {} + 2>/dev/null || true
|
||||
else
|
||||
echo "Warning: ruff not found, skipping Python linting. Install with: uv pip install ruff"
|
||||
fi
|
||||
|
||||
# Run pytest (skip slow/integration tests and Windows-incompatible tests for pre-commit speed)
|
||||
# Use subshell to isolate directory changes and prevent worktree corruption
|
||||
echo "Running Python tests..."
|
||||
(
|
||||
cd apps/backend
|
||||
# Tests to skip: graphiti (external deps), merge_file_tracker/service_orchestrator/worktree/workspace (Windows path/git issues)
|
||||
# Also skip tests that require optional dependencies (pydantic structured outputs)
|
||||
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 --ignore=../../tests/test_finding_validation.py --ignore=../../tests/test_sdk_structured_output.py --ignore=../../tests/test_structured_outputs.py"
|
||||
|
||||
# Determine Python executable from venv
|
||||
VENV_PYTHON=""
|
||||
if [ -f ".venv/bin/python" ]; then
|
||||
VENV_PYTHON=".venv/bin/python"
|
||||
elif [ -f ".venv/Scripts/python.exe" ]; then
|
||||
VENV_PYTHON=".venv/Scripts/python.exe"
|
||||
fi
|
||||
|
||||
if [ -n "$VENV_PYTHON" ]; then
|
||||
# Check if pytest is installed in venv
|
||||
if $VENV_PYTHON -c "import pytest" 2>/dev/null; then
|
||||
PYTHONPATH=. $VENV_PYTHON -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
|
||||
else
|
||||
echo "Warning: pytest not installed in venv. Installing test dependencies..."
|
||||
$VENV_PYTHON -m pip install -q -r ../../tests/requirements-test.txt
|
||||
PYTHONPATH=. $VENV_PYTHON -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
|
||||
fi
|
||||
elif [ -d ".venv" ]; then
|
||||
echo "Warning: venv exists but Python not found in it, using system Python"
|
||||
PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
|
||||
cd apps/backend
|
||||
# Tests to skip: graphiti (external deps), merge_file_tracker/service_orchestrator/worktree/workspace (Windows path/git issues)
|
||||
IGNORE_TESTS="--ignore=../../tests/test_graphiti.py --ignore=../../tests/test_merge_file_tracker.py --ignore=../../tests/test_service_orchestrator.py --ignore=../../tests/test_worktree.py --ignore=../../tests/test_workspace.py"
|
||||
if [ -d ".venv" ]; then
|
||||
# Use venv if it exists
|
||||
if [ -f ".venv/bin/pytest" ]; then
|
||||
PYTHONPATH=. .venv/bin/pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
|
||||
elif [ -f ".venv/Scripts/pytest.exe" ]; then
|
||||
# Windows
|
||||
PYTHONPATH=. .venv/Scripts/pytest.exe ../../tests/ -v --tb=short -x -m "not slow and not integration" $IGNORE_TESTS
|
||||
else
|
||||
echo "Warning: No .venv found in apps/backend, using system Python"
|
||||
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
|
||||
@@ -226,86 +124,37 @@ fi
|
||||
# Check if there are staged files in apps/frontend
|
||||
if git diff --cached --name-only | grep -q "^apps/frontend/"; then
|
||||
echo "Frontend changes detected, running frontend checks..."
|
||||
cd apps/frontend
|
||||
|
||||
# Detect if we're in a worktree and check if dependencies are available
|
||||
IS_WORKTREE=false
|
||||
DEPS_AVAILABLE=true
|
||||
# Run lint-staged (handles staged .ts/.tsx files)
|
||||
npm exec lint-staged
|
||||
|
||||
if [ -f ".git" ]; then
|
||||
# .git is a file (not directory) in worktrees
|
||||
IS_WORKTREE=true
|
||||
echo "Detected git worktree environment"
|
||||
# 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
|
||||
|
||||
# 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/frontend/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/frontend/node_modules/@lydell/node-pty" ]; then
|
||||
DEPS_AVAILABLE=false
|
||||
# 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
|
||||
|
||||
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
|
||||
fi
|
||||
else
|
||||
# Dependencies available - run full frontend checks
|
||||
# Use subshell to isolate directory changes and prevent worktree corruption
|
||||
(
|
||||
cd apps/frontend
|
||||
|
||||
# Run lint-staged (handles staged .ts/.tsx files)
|
||||
npm exec lint-staged
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "lint-staged failed. Please fix linting errors before committing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run TypeScript type check
|
||||
echo "Running type check..."
|
||||
npm run typecheck
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Type check failed. Please fix TypeScript errors before committing."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run 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 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!"
|
||||
# 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!"
|
||||
|
||||
+12
-83
@@ -1,6 +1,5 @@
|
||||
repos:
|
||||
# Version sync - propagate root package.json version to all files
|
||||
# NOTE: Skip in worktrees - version sync modifies root files which don't exist in worktree
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: version-sync
|
||||
@@ -9,12 +8,6 @@ repos:
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
# Skip in worktrees - .git is a file pointing to main repo, not a directory
|
||||
# Version sync modifies root-level files that may not exist in worktree context
|
||||
if [ -f ".git" ]; then
|
||||
echo "Skipping version-sync in worktree (root files not accessible)"
|
||||
exit 0
|
||||
fi
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
if [ -n "$VERSION" ]; then
|
||||
|
||||
@@ -32,41 +25,14 @@ repos:
|
||||
# Sync to apps/backend/__init__.py
|
||||
sed -i.bak "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" apps/backend/__init__.py && rm -f apps/backend/__init__.py.bak
|
||||
|
||||
# Sync to README.md - section-aware updates (stable vs beta)
|
||||
# Sync to README.md - shields.io version badge (text and URL)
|
||||
ESCAPED_VERSION=$(echo "$VERSION" | sed 's/-/--/g')
|
||||
sed -i.bak -e "s/version-[0-9]*\.[0-9]*\.[0-9]*\(-\{1,2\}[a-z]*\.[0-9]*\)*-blue/version-$ESCAPED_VERSION-blue/g" -e "s|releases/tag/v[0-9.a-z-]*)|releases/tag/v$VERSION)|g" README.md
|
||||
|
||||
# Detect if this is a prerelease (contains - after base version)
|
||||
if echo "$VERSION" | grep -q '-'; then
|
||||
# PRERELEASE: Update only beta sections
|
||||
echo " Detected PRERELEASE version: $VERSION"
|
||||
|
||||
# Update beta version badge (orange)
|
||||
sed -i.bak "s/beta-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-orange/beta-$ESCAPED_VERSION-orange/g" README.md
|
||||
|
||||
# Update beta version badge link
|
||||
sed -i.bak '/<!-- BETA_VERSION_BADGE -->/,/<!-- BETA_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
|
||||
|
||||
# Update beta download links (within BETA_DOWNLOADS section only)
|
||||
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb" "linux-x86_64.flatpak"; do
|
||||
sed -i.bak '/<!-- BETA_DOWNLOADS -->/,/<!-- BETA_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
|
||||
done
|
||||
else
|
||||
# STABLE: Update stable sections and top badge
|
||||
echo " Detected STABLE version: $VERSION"
|
||||
|
||||
# Update top version badge (blue)
|
||||
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s/version-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/version-'"$ESCAPED_VERSION"'-blue/g' README.md
|
||||
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
|
||||
|
||||
# Update stable version badge (blue)
|
||||
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s/stable-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/stable-'"$ESCAPED_VERSION"'-blue/g' README.md
|
||||
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
|
||||
|
||||
# Update stable download links (within STABLE_DOWNLOADS section only)
|
||||
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb"; do
|
||||
sed -i.bak '/<!-- STABLE_DOWNLOADS -->/,/<!-- STABLE_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
|
||||
done
|
||||
fi
|
||||
# Sync to README.md - download links with correct filenames and URLs
|
||||
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb"; do
|
||||
sed -i.bak "s|Auto-Claude-[0-9.a-z-]*-${SUFFIX}](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-${SUFFIX})|Auto-Claude-${VERSION}-${SUFFIX}](https://github.com/AndyMik90/Auto-Claude/releases/download/v${VERSION}/Auto-Claude-${VERSION}-${SUFFIX})|g" README.md
|
||||
done
|
||||
rm -f README.md.bak
|
||||
|
||||
# Stage changes
|
||||
@@ -76,17 +42,6 @@ repos:
|
||||
files: ^package\.json$
|
||||
pass_filenames: false
|
||||
|
||||
# Python encoding check - prevent regression of UTF-8 encoding fixes (PR #782)
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: check-file-encoding
|
||||
name: Check file encoding parameters
|
||||
entry: python scripts/check_encoding.py
|
||||
language: system
|
||||
types: [python]
|
||||
files: ^apps/backend/
|
||||
description: Ensures all file operations specify encoding="utf-8"
|
||||
|
||||
# Python linting (apps/backend/)
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.14.10
|
||||
@@ -99,7 +54,6 @@ repos:
|
||||
|
||||
# Python tests (apps/backend/) - skip slow/integration tests for pre-commit speed
|
||||
# Tests to skip: graphiti (external deps), merge_file_tracker/service_orchestrator/worktree/workspace (Windows path/git issues)
|
||||
# NOTE: Skip this hook in worktrees (where .git is a file, not a directory)
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: pytest
|
||||
@@ -108,12 +62,6 @@ repos:
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
# Skip in worktrees - .git is a file pointing to main repo, not a directory
|
||||
# This prevents path resolution issues with ../../tests/ in worktree context
|
||||
if [ -f ".git" ]; then
|
||||
echo "Skipping pytest in worktree (path resolution would fail)"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/backend
|
||||
if [ -f ".venv/bin/pytest" ]; then
|
||||
PYTEST_CMD=".venv/bin/pytest"
|
||||
@@ -137,38 +85,19 @@ repos:
|
||||
files: ^(apps/backend/.*\.py$|tests/.*\.py$)
|
||||
pass_filenames: false
|
||||
|
||||
# Frontend linting (apps/frontend/) - Biome is 15-25x faster than ESLint
|
||||
# NOTE: These hooks check for worktree context to avoid npm/node_modules issues
|
||||
# Frontend linting (apps/frontend/)
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: biome
|
||||
name: Biome (lint + format)
|
||||
entry: bash
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
# Skip in worktrees if node_modules doesn't exist (Biome not installed)
|
||||
if [ -f ".git" ] && [ ! -d "apps/frontend/node_modules" ]; then
|
||||
echo "Skipping Biome in worktree (node_modules not found)"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/frontend && npx biome check --write --no-errors-on-unmatched .
|
||||
- id: eslint
|
||||
name: ESLint
|
||||
entry: bash -c 'cd apps/frontend && npm run lint'
|
||||
language: system
|
||||
files: ^apps/frontend/.*\.(ts|tsx|js|jsx|json)$
|
||||
files: ^apps/frontend/.*\.(ts|tsx|js|jsx)$
|
||||
pass_filenames: false
|
||||
|
||||
- id: typecheck
|
||||
name: TypeScript Check
|
||||
entry: bash
|
||||
args:
|
||||
- -c
|
||||
- |
|
||||
# Skip in worktrees if node_modules doesn't exist (dependencies not installed)
|
||||
if [ -f ".git" ] && [ ! -d "apps/frontend/node_modules" ]; then
|
||||
echo "Skipping TypeScript check in worktree (node_modules not found)"
|
||||
exit 0
|
||||
fi
|
||||
cd apps/frontend && npm run typecheck
|
||||
entry: bash -c 'cd apps/frontend && npm run typecheck'
|
||||
language: system
|
||||
files: ^apps/frontend/.*\.(ts|tsx)$
|
||||
pass_filenames: false
|
||||
|
||||
-1336
File diff suppressed because it is too large
Load Diff
@@ -1,339 +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 monorepo with a Python backend (CLI + agent logic) and an Electron/React frontend (desktop UI).
|
||||
## Project Overview
|
||||
|
||||
> **Deep-dive reference:** [ARCHITECTURE.md](shared_docs/ARCHITECTURE.md) | **Frontend contributing:** [apps/frontend/CONTRIBUTING.md](apps/frontend/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
|
||||
|
||||
**Claude Agent SDK only** — All AI interactions use `claude-agent-sdk`. NEVER use `anthropic.Anthropic()` directly. Always use `create_client()` from `core.client`.
|
||||
|
||||
**i18n required** — All frontend user-facing text MUST use `react-i18next` translation keys. Never hardcode strings in JSX/TSX. Add keys to both `en/*.json` and `fr/*.json`.
|
||||
|
||||
**Platform abstraction** — Never use `process.platform` directly. Import from `apps/frontend/src/main/platform/` or `apps/backend/core/platform/`. CI tests all three platforms.
|
||||
|
||||
**No time estimates** — Never provide duration predictions. Use priority-based ordering instead.
|
||||
|
||||
**PR target** — Always target the `develop` branch for PRs to AndyMik90/Auto-Claude, NOT `main`.
|
||||
|
||||
## Memory (claude-mem)
|
||||
|
||||
This project uses claude-mem for persistent memory across sessions. MCP search tools are available — use them proactively:
|
||||
|
||||
- **Before modifying code** — Search for past work on the same files/features: `search(query="<file or feature>")`. Check if there are known bugs, decisions, or patterns to follow.
|
||||
- **When debugging** — Search for prior encounters with the same error or symptom: `search(query="<error message>", type="bugfix")`.
|
||||
- **When making architectural decisions** — Check for past decisions: `search(query="<topic>", type="decision")`.
|
||||
- **When resuming work** — Use `timeline(anchor=<recent_id>)` to understand where things left off.
|
||||
|
||||
Follow the 3-layer workflow: `search` (cheap index) → `timeline` (context) → `get_observations` (full details only for relevant IDs). Never fetch full details without filtering first.
|
||||
**CRITICAL: All AI interactions use the Claude Agent SDK (`claude-agent-sdk` package), NOT the Anthropic API directly.**
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
autonomous-coding/
|
||||
├── apps/
|
||||
│ ├── backend/ # Python backend/CLI — ALL agent logic
|
||||
│ │ ├── core/ # client.py, auth.py, worktree.py, platform/
|
||||
│ │ ├── security/ # Command allowlisting, validators, hooks
|
||||
│ │ ├── agents/ # planner, coder, session management
|
||||
│ │ ├── qa/ # reviewer, fixer, loop, criteria
|
||||
│ │ ├── spec/ # Spec creation pipeline
|
||||
│ │ ├── cli/ # CLI commands (spec, build, workspace, QA)
|
||||
│ │ ├── context/ # Task context building, semantic search
|
||||
│ │ ├── runners/ # Standalone runners (spec, roadmap, insights, github)
|
||||
│ │ ├── services/ # Background services, recovery orchestration
|
||||
│ │ ├── integrations/ # graphiti/, linear, github
|
||||
│ │ ├── project/ # Project analysis, security profiles
|
||||
│ │ ├── merge/ # Intent-aware semantic merge for parallel agents
|
||||
│ │ └── prompts/ # Agent system prompts (.md)
|
||||
│ └── frontend/ # Electron desktop UI
|
||||
│ └── src/
|
||||
│ ├── main/ # Electron main process
|
||||
│ │ ├── 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/ # SDK 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
|
||||
├── tests/ # Backend test suite
|
||||
└── 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:
|
||||
# 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
|
||||
```
|
||||
|
||||
### Backend
|
||||
### Creating and Running Specs
|
||||
```bash
|
||||
cd apps/backend
|
||||
python spec_runner.py --interactive # Create spec interactively
|
||||
python spec_runner.py --task "description" # Create from task
|
||||
python run.py --spec 001 # Run autonomous build
|
||||
python run.py --spec 001 --qa # Run QA validation
|
||||
python run.py --spec 001 --merge # Merge completed build
|
||||
python run.py --list # List all specs
|
||||
|
||||
# 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
|
||||
```
|
||||
|
||||
### Frontend
|
||||
### Workspace Management
|
||||
```bash
|
||||
cd apps/frontend
|
||||
npm run dev # Dev mode (Electron + Vite HMR)
|
||||
npm run build # Production build
|
||||
npm run test # Vitest unit tests
|
||||
npm run test:watch # Vitest watch mode
|
||||
npm run lint # Biome check
|
||||
npm run lint:fix # Biome auto-fix
|
||||
npm run typecheck # TypeScript strict check
|
||||
npm run package # Package for distribution
|
||||
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 |
|
||||
|-------|---------|------|
|
||||
| Backend | `apps/backend/.venv/bin/pytest tests/ -v` | pytest |
|
||||
| Frontend unit | `cd apps/frontend && npm test` | Vitest |
|
||||
| Frontend E2E | `cd apps/frontend && npm run test:e2e` | Playwright |
|
||||
| All backend | `npm run test:backend` (from root) | pytest |
|
||||
# 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.
|
||||
|
||||
## Backend Development
|
||||
## Architecture
|
||||
|
||||
### Claude Agent SDK Usage
|
||||
### Core Pipeline
|
||||
|
||||
Client: `apps/backend/core/client.py` — `create_client()` returns a configured `ClaudeSDKClient` with security hooks, tool permissions, and MCP server integration.
|
||||
**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
|
||||
|
||||
Model and thinking level are user-configurable (via the Electron UI settings or CLI override). Use `phase_config.py` helpers to resolve the correct values:
|
||||
**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)
|
||||
|
||||
```python
|
||||
from core.client import create_client
|
||||
from phase_config import get_phase_model, get_phase_thinking_budget
|
||||
### Key Components (apps/backend/)
|
||||
|
||||
# Resolve model/thinking from user settings (Electron UI or CLI override)
|
||||
phase_model = get_phase_model(spec_dir, "coding", cli_model=None)
|
||||
phase_thinking = get_phase_thinking_budget(spec_dir, "coding", cli_thinking=None)
|
||||
**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)
|
||||
|
||||
client = create_client(
|
||||
project_dir=project_dir,
|
||||
spec_dir=spec_dir,
|
||||
model=phase_model,
|
||||
agent_type="coder", # planner | coder | qa_reviewer | qa_fixer
|
||||
max_thinking_tokens=phase_thinking,
|
||||
)
|
||||
**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
|
||||
|
||||
# Run agent session (uses context manager + run_agent_session helper)
|
||||
async with client:
|
||||
status, response = await run_agent_session(client, prompt, spec_dir)
|
||||
```
|
||||
**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
|
||||
|
||||
Working examples: `agents/planner.py`, `agents/coder.py`, `qa/reviewer.py`, `qa/fixer.py`, `spec/`
|
||||
**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/backend/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 in `integrations/graphiti/`. Configured through the Electron app's onboarding/settings UI (CLI users can alternatively set `GRAPHITI_ENABLED=true` in `.env`). 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, 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 and manages agent subprocess communication
|
||||
- **`agent-state.ts`** — Tracks running agent state and status
|
||||
- **`agent-events.ts`** — Agent lifecycle events and state transitions
|
||||
|
||||
### 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
|
||||
|
||||
### Backend
|
||||
- **Linting:** Ruff
|
||||
- **Testing:** pytest (`apps/backend/.venv/bin/pytest tests/ -v`)
|
||||
|
||||
## i18n Guidelines
|
||||
|
||||
All frontend UI text uses `react-i18next`. Translation files: `apps/frontend/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/frontend/src/main/platform/` and `apps/backend/core/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`
|
||||
|
||||
Never hardcode paths. Use `findExecutable()` and `joinPaths()`. 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. Set `ELECTRON_MCP_ENABLED=true` in `apps/backend/.env`
|
||||
3. Run QA: `python run.py --spec 001 --qa`
|
||||
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
|
||||
# CLI only
|
||||
cd apps/backend && python run.py --spec 001
|
||||
|
||||
# Desktop app
|
||||
npm start # Production build + run
|
||||
npm run dev # Development mode with HMR
|
||||
|
||||
# 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
|
||||
|
||||
+76
-168
@@ -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)
|
||||
@@ -171,40 +150,92 @@ npm start
|
||||
The project consists of two main components:
|
||||
|
||||
1. **Python Backend** (`apps/backend/`) - The core autonomous coding framework
|
||||
2. **Electron Frontend** (`apps/frontend/`) - Desktop UI
|
||||
2. **Electron Frontend** (`apps/frontend/`) - Optional desktop UI
|
||||
|
||||
From the repository root, two commands handle everything:
|
||||
### Python Backend
|
||||
|
||||
The recommended way is to use `npm run install:backend`, but you can also set up manually:
|
||||
|
||||
```bash
|
||||
# Install all dependencies (Python backend + Electron frontend)
|
||||
npm run install:all
|
||||
# Navigate to the backend directory
|
||||
cd apps/backend
|
||||
|
||||
# Start development mode (hot reload)
|
||||
# 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)
|
||||
```
|
||||
|
||||
### Electron Frontend
|
||||
|
||||
```bash
|
||||
# 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
|
||||
```
|
||||
|
||||
`npm run install:all` automatically:
|
||||
- Detects Python 3.12+ on your system
|
||||
- Creates a virtual environment (`apps/backend/.venv`)
|
||||
- Installs backend runtime and test dependencies
|
||||
- Copies `.env.example` to `.env` (if not already present)
|
||||
- Installs frontend npm dependencies
|
||||
## 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
|
||||
|
||||
After install, configure your credentials in `apps/backend/.env`:
|
||||
```bash
|
||||
# Get your Claude Code OAuth token
|
||||
claude setup-token
|
||||
git clone https://github.com/AndyMik90/Auto-Claude.git
|
||||
cd Auto-Claude/apps/backend
|
||||
|
||||
# Then edit apps/backend/.env with your token and any other provider keys
|
||||
# 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)
|
||||
```
|
||||
|
||||
### Other Useful Commands
|
||||
### Step 2: Run the Desktop UI
|
||||
|
||||
```bash
|
||||
npm start # Build and run production
|
||||
npm run build # Build frontend for production
|
||||
npm run package # Package for distribution
|
||||
npm run test:backend # Run Python tests
|
||||
cd ../frontend
|
||||
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Development mode (hot reload)
|
||||
npm run dev
|
||||
|
||||
# Or production build
|
||||
npm run build && npm run start
|
||||
```
|
||||
|
||||
<details>
|
||||
@@ -326,64 +357,6 @@ export default function(props) {
|
||||
- End files with a newline
|
||||
- Keep line length under 100 characters when practical
|
||||
|
||||
### File Encoding (Python)
|
||||
|
||||
**Always specify `encoding="utf-8"` for text file operations** to ensure Windows compatibility.
|
||||
|
||||
Windows Python defaults to `cp1252` encoding instead of UTF-8, causing errors with:
|
||||
- Emoji (🚀, ✅, ❌)
|
||||
- International characters (ñ, é, 中文, العربية)
|
||||
- Special symbols (™, ©, ®)
|
||||
|
||||
**DO:**
|
||||
|
||||
```python
|
||||
# Reading files
|
||||
with open(path, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
# Writing files
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
# Path methods
|
||||
from pathlib import Path
|
||||
content = Path(file).read_text(encoding="utf-8")
|
||||
Path(file).write_text(content, encoding="utf-8")
|
||||
|
||||
# JSON files - reading
|
||||
import json
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# JSON files - writing
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
```
|
||||
|
||||
**DON'T:**
|
||||
|
||||
```python
|
||||
# Wrong - platform-dependent encoding
|
||||
with open(path) as f:
|
||||
content = f.read()
|
||||
|
||||
# Wrong - Path methods without encoding
|
||||
content = Path(file).read_text()
|
||||
|
||||
# Wrong - encoding on json.dump (not open!)
|
||||
json.dump(data, f, encoding="utf-8") # ERROR
|
||||
```
|
||||
|
||||
**Binary files - NO encoding:**
|
||||
|
||||
```python
|
||||
with open(path, "rb") as f: # Correct
|
||||
data = f.read()
|
||||
```
|
||||
|
||||
Our pre-commit hooks automatically check for missing encoding parameters. See [PR #782](https://github.com/AndyMik90/Auto-Claude/pull/782) for the comprehensive encoding fix and [guides/windows-development.md](guides/windows-development.md) for Windows-specific development guidance.
|
||||
|
||||
## Testing
|
||||
|
||||
### Python Tests
|
||||
@@ -456,6 +429,7 @@ All pull requests and pushes to `main` trigger automated CI checks via GitHub Ac
|
||||
|----------|---------|----------------|
|
||||
| **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
|
||||
|
||||
@@ -486,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
|
||||
|
||||
```
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
|
||||

|
||||
|
||||
<!-- TOP_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.1)
|
||||
<!-- TOP_VERSION_BADGE_END -->
|
||||
[](./agpl-3.0.txt)
|
||||
[](https://discord.gg/KCXaPBr4Dj)
|
||||
[](https://www.youtube.com/@AndreMikalsen)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/actions)
|
||||
|
||||
---
|
||||
@@ -16,18 +18,17 @@
|
||||
### Stable Release
|
||||
|
||||
<!-- STABLE_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.5)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.1)
|
||||
<!-- STABLE_VERSION_BADGE_END -->
|
||||
|
||||
<!-- STABLE_DOWNLOADS -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **Windows** | [Auto-Claude-2.7.5-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.5-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.5-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.5-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.5-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.5-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.5/Auto-Claude-2.7.5-linux-x86_64.flatpak) |
|
||||
| **Windows** | [Auto-Claude-2.7.1-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.1-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.1-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.1-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.1-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-linux-amd64.deb) |
|
||||
<!-- STABLE_DOWNLOADS_END -->
|
||||
|
||||
### Beta Release
|
||||
@@ -35,18 +36,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.7.6-beta.2)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.2-beta.10)
|
||||
<!-- BETA_VERSION_BADGE_END -->
|
||||
|
||||
<!-- BETA_DOWNLOADS -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **Windows** | [Auto-Claude-2.7.6-beta.2-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.6-beta.2-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.6-beta.2-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.6-beta.2-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.6-beta.2-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.6-beta.2-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.6-beta.2/Auto-Claude-2.7.6-beta.2-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.
|
||||
@@ -58,6 +59,7 @@
|
||||
- **Claude Pro/Max subscription** - [Get one here](https://claude.ai/upgrade)
|
||||
- **Claude Code CLI** - `npm install -g @anthropic-ai/claude-code`
|
||||
- **Git repository** - Your project must be initialized as a git repo
|
||||
- **Python 3.12+** - Required for the backend and Memory Layer
|
||||
|
||||
---
|
||||
|
||||
@@ -146,11 +148,113 @@ See [guides/CLI-USAGE.md](guides/CLI-USAGE.md) for complete CLI documentation.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
## Configuration
|
||||
|
||||
Want to build from source or contribute? See [CONTRIBUTING.md](CONTRIBUTING.md) for complete development setup instructions.
|
||||
Create `apps/backend/.env` from the example:
|
||||
|
||||
For Linux-specific builds (Flatpak, AppImage), see [guides/linux.md](guides/linux.md).
|
||||
```bash
|
||||
cp apps/backend/.env.example apps/backend/.env
|
||||
```
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `CLAUDE_CODE_OAUTH_TOKEN` | Yes | OAuth token from `claude setup-token` |
|
||||
| `GRAPHITI_ENABLED` | No | Enable Memory Layer for cross-session context |
|
||||
| `AUTO_BUILD_MODEL` | No | Override the default Claude model |
|
||||
| `GITLAB_TOKEN` | No | GitLab Personal Access Token for GitLab integration |
|
||||
| `GITLAB_INSTANCE_URL` | No | GitLab instance URL (defaults to gitlab.com) |
|
||||
| `LINEAR_API_KEY` | No | Linear API key for task sync |
|
||||
|
||||
---
|
||||
|
||||
## Building from Source
|
||||
|
||||
For contributors and development:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone https://github.com/AndyMik90/Auto-Claude.git
|
||||
cd Auto-Claude
|
||||
|
||||
# Install all dependencies
|
||||
npm run install:all
|
||||
|
||||
# Run in development mode
|
||||
npm run dev
|
||||
|
||||
# Or build and run
|
||||
npm start
|
||||
```
|
||||
|
||||
**System requirements for building:**
|
||||
- Node.js 24+
|
||||
- Python 3.12+
|
||||
- npm 10+
|
||||
|
||||
**Installing dependencies by platform:**
|
||||
|
||||
<details>
|
||||
<summary><b>Windows</b></summary>
|
||||
|
||||
```bash
|
||||
winget install Python.Python.3.12
|
||||
winget install OpenJS.NodeJS.LTS
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>macOS</b></summary>
|
||||
|
||||
```bash
|
||||
brew install python@3.12 node@24
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Linux (Ubuntu/Debian)</b></summary>
|
||||
|
||||
```bash
|
||||
sudo apt install python3.12 python3.12-venv
|
||||
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
|
||||
sudo apt install -y nodejs
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Linux (Fedora)</b></summary>
|
||||
|
||||
```bash
|
||||
sudo dnf install python3.12 nodejs npm
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed development setup.
|
||||
|
||||
### Building Flatpak
|
||||
|
||||
To build the Flatpak package, you need additional dependencies:
|
||||
|
||||
```bash
|
||||
# Fedora/RHEL
|
||||
sudo dnf install flatpak-builder
|
||||
|
||||
# Ubuntu/Debian
|
||||
sudo apt install flatpak-builder
|
||||
|
||||
# Install required Flatpak runtimes
|
||||
flatpak install flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
|
||||
flatpak install flathub org.electronjs.Electron2.BaseApp//25.08
|
||||
|
||||
# Build the Flatpak
|
||||
cd apps/frontend
|
||||
npm run package:flatpak
|
||||
```
|
||||
|
||||
The Flatpak will be created in `apps/frontend/dist/`.
|
||||
|
||||
---
|
||||
|
||||
@@ -180,7 +284,7 @@ All releases are:
|
||||
| `npm run package:mac` | Package for macOS |
|
||||
| `npm run package:win` | Package for Windows |
|
||||
| `npm run package:linux` | Package for Linux |
|
||||
| `npm run package:flatpak` | Package as Flatpak (see [guides/linux.md](guides/linux.md)) |
|
||||
| `npm run package:flatpak` | Package as Flatpak |
|
||||
| `npm run lint` | Run linter |
|
||||
| `npm test` | Run frontend tests |
|
||||
| `npm run test:backend` | Run backend tests |
|
||||
@@ -212,11 +316,3 @@ We welcome contributions! Please read [CONTRIBUTING.md](CONTRIBUTING.md) for:
|
||||
Auto Claude is free to use. If you modify and distribute it, or run it as a service, your code must also be open source under AGPL-3.0.
|
||||
|
||||
Commercial licensing available for closed-source use cases.
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://github.com/AndyMik90/Auto-Claude/stargazers)
|
||||
|
||||
[](https://star-history.com/#AndyMik90/Auto-Claude&Date)
|
||||
|
||||
+24
-90
@@ -69,38 +69,9 @@ This will:
|
||||
- Update `apps/frontend/package.json`
|
||||
- Update `package.json` (root)
|
||||
- Update `apps/backend/__init__.py`
|
||||
- Check if `CHANGELOG.md` has an entry for the new version (warns if missing)
|
||||
- Create a commit with message `chore: bump version to X.Y.Z`
|
||||
|
||||
### Step 2: Update CHANGELOG.md (REQUIRED)
|
||||
|
||||
**IMPORTANT: The release will fail if CHANGELOG.md doesn't have an entry for the new version.**
|
||||
|
||||
Add release notes to `CHANGELOG.md` at the top of the file:
|
||||
|
||||
```markdown
|
||||
## 2.8.0 - Your Release Title
|
||||
|
||||
### ✨ New Features
|
||||
- Feature description
|
||||
|
||||
### 🛠️ Improvements
|
||||
- Improvement description
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- Fix description
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
Then amend the version bump commit:
|
||||
|
||||
```bash
|
||||
git add CHANGELOG.md
|
||||
git commit --amend --no-edit
|
||||
```
|
||||
|
||||
### Step 3: Push and Create PR
|
||||
### Step 2: Push and Create PR
|
||||
|
||||
```bash
|
||||
# Push your branch
|
||||
@@ -110,25 +81,24 @@ git push origin your-branch
|
||||
gh pr create --base main --title "Release v2.8.0"
|
||||
```
|
||||
|
||||
### Step 4: Merge to Main
|
||||
### Step 3: Merge to Main
|
||||
|
||||
Once the PR is approved and merged to `main`, GitHub Actions will automatically:
|
||||
|
||||
1. **Detect the version bump** (`prepare-release.yml`)
|
||||
2. **Validate CHANGELOG.md** has an entry for the new version (FAILS if missing)
|
||||
3. **Extract release notes** from CHANGELOG.md
|
||||
4. **Create a git tag** (e.g., `v2.8.0`)
|
||||
5. **Trigger the release workflow** (`release.yml`)
|
||||
6. **Build binaries** for all platforms:
|
||||
2. **Create a git tag** (e.g., `v2.8.0`)
|
||||
3. **Trigger the release workflow** (`release.yml`)
|
||||
4. **Build binaries** for all platforms:
|
||||
- macOS Intel (x64) - code signed & notarized
|
||||
- macOS Apple Silicon (arm64) - code signed & notarized
|
||||
- Windows (NSIS installer) - code signed
|
||||
- Linux (AppImage + .deb)
|
||||
7. **Scan binaries** with VirusTotal
|
||||
8. **Create GitHub release** with release notes from CHANGELOG.md
|
||||
9. **Update README** with new version badge and download links
|
||||
5. **Generate changelog** from merged PRs (using release-drafter)
|
||||
6. **Scan binaries** with VirusTotal
|
||||
7. **Create GitHub release** with all artifacts
|
||||
8. **Update README** with new version badge and download links
|
||||
|
||||
### Step 5: Verify
|
||||
### Step 4: Verify
|
||||
|
||||
After merging, check:
|
||||
- [GitHub Actions](https://github.com/AndyMik90/Auto-Claude/actions) - ensure all workflows pass
|
||||
@@ -143,49 +113,29 @@ We follow [Semantic Versioning](https://semver.org/):
|
||||
- **MINOR** (0.X.0): New features, backwards compatible
|
||||
- **PATCH** (0.0.X): Bug fixes, backwards compatible
|
||||
|
||||
## Changelog Management
|
||||
## Changelog Generation
|
||||
|
||||
Release notes are managed in `CHANGELOG.md` and used for GitHub releases.
|
||||
Changelogs are automatically generated from merged PRs using [Release Drafter](https://github.com/release-drafter/release-drafter).
|
||||
|
||||
### Changelog Format
|
||||
### PR Labels for Changelog Categories
|
||||
|
||||
Each version entry in `CHANGELOG.md` should follow this format:
|
||||
| Label | Category |
|
||||
|-------|----------|
|
||||
| `feature`, `enhancement` | New Features |
|
||||
| `bug`, `fix` | Bug Fixes |
|
||||
| `improvement`, `refactor` | Improvements |
|
||||
| `documentation` | Documentation |
|
||||
| (any other) | Other Changes |
|
||||
|
||||
```markdown
|
||||
## X.Y.Z - Release Title
|
||||
|
||||
### ✨ New Features
|
||||
- Feature description with context
|
||||
|
||||
### 🛠️ Improvements
|
||||
- Improvement description
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
- Fix description
|
||||
|
||||
---
|
||||
```
|
||||
|
||||
### Changelog Validation
|
||||
|
||||
The release workflow **validates** that `CHANGELOG.md` has an entry for the version being released:
|
||||
|
||||
- If the entry is **missing**, the release is **blocked** with a clear error message
|
||||
- If the entry **exists**, its content is used for the GitHub release notes
|
||||
|
||||
### Writing Good Release Notes
|
||||
|
||||
- **Be specific**: Instead of "Fixed bug", write "Fixed crash when opening large files"
|
||||
- **Group by impact**: Features first, then improvements, then fixes
|
||||
- **Credit contributors**: Mention contributors for significant changes
|
||||
- **Link issues**: Reference GitHub issues where relevant (e.g., "Fixes #123")
|
||||
**Tip:** Add appropriate labels to your PRs for better changelog organization.
|
||||
|
||||
## Workflows
|
||||
|
||||
| Workflow | Trigger | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `prepare-release.yml` | Push to `main` | Detects version bump, **validates CHANGELOG.md**, creates tag |
|
||||
| `release.yml` | Tag `v*` pushed | Builds binaries, extracts changelog, creates release |
|
||||
| `prepare-release.yml` | Push to `main` | Detects version bump, creates tag |
|
||||
| `release.yml` | Tag `v*` pushed | Builds binaries, creates release |
|
||||
| `validate-version.yml` | Tag `v*` pushed | Validates tag matches package.json |
|
||||
| `update-readme` (in release.yml) | After release | Updates README with new version |
|
||||
|
||||
## Troubleshooting
|
||||
@@ -203,22 +153,6 @@ The release workflow **validates** that `CHANGELOG.md` has an entry for the vers
|
||||
git diff HEAD~1 --name-only | grep package.json
|
||||
```
|
||||
|
||||
### Release blocked: Missing changelog entry
|
||||
|
||||
If you see "CHANGELOG VALIDATION FAILED" in the workflow:
|
||||
|
||||
1. The `prepare-release.yml` workflow validated that `CHANGELOG.md` doesn't have an entry for the new version
|
||||
2. **Fix**: Add an entry to `CHANGELOG.md` with the format `## X.Y.Z - Title`
|
||||
3. Commit and push the changelog update
|
||||
4. The workflow will automatically retry when the changes are pushed to `main`
|
||||
|
||||
```bash
|
||||
# Add changelog entry, then:
|
||||
git add CHANGELOG.md
|
||||
git commit -m "docs: add changelog for vX.Y.Z"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
### Build failed after tag was created
|
||||
|
||||
- The release won't be published if builds fail
|
||||
|
||||
@@ -269,9 +269,9 @@ GRAPHITI_ENABLED=true
|
||||
# OpenRouter Base URL (default: https://openrouter.ai/api/v1)
|
||||
# OPENROUTER_BASE_URL=https://openrouter.ai/api/v1
|
||||
|
||||
# OpenRouter LLM Model (default: anthropic/claude-sonnet-4)
|
||||
# Popular choices: anthropic/claude-sonnet-4, openai/gpt-4o, google/gemini-2.0-flash
|
||||
# OPENROUTER_LLM_MODEL=anthropic/claude-sonnet-4
|
||||
# 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
|
||||
@@ -368,5 +368,5 @@ GRAPHITI_ENABLED=true
|
||||
# GRAPHITI_LLM_PROVIDER=openrouter
|
||||
# GRAPHITI_EMBEDDER_PROVIDER=openrouter
|
||||
# OPENROUTER_API_KEY=sk-or-xxxxxxxx
|
||||
# OPENROUTER_LLM_MODEL=anthropic/claude-sonnet-4
|
||||
# OPENROUTER_LLM_MODEL=anthropic/claude-3.5-sonnet
|
||||
# OPENROUTER_EMBEDDING_MODEL=openai/text-embedding-3-small
|
||||
|
||||
@@ -17,14 +17,12 @@ python -m pip install -r requirements.txt
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Authenticate with Claude Code (token auto-saved to Keychain):
|
||||
```bash
|
||||
claude
|
||||
# Type: /login
|
||||
# Press Enter to open browser
|
||||
Set your Claude API token in `.env`:
|
||||
```
|
||||
CLAUDE_CODE_OAUTH_TOKEN=your-token-here
|
||||
```
|
||||
|
||||
Token is auto-detected from macOS Keychain / Windows Credential Manager.
|
||||
Get your token by running: `claude setup-token`
|
||||
|
||||
### 3. Run
|
||||
|
||||
|
||||
@@ -19,5 +19,5 @@ Quick Start:
|
||||
See README.md for full documentation.
|
||||
"""
|
||||
|
||||
__version__ = "2.7.6-beta.2"
|
||||
__version__ = "2.7.2-beta.10"
|
||||
__author__ = "Auto Claude Team"
|
||||
|
||||
@@ -26,7 +26,7 @@ auto-claude/agents/
|
||||
### `utils.py` (3.6 KB)
|
||||
- Git operations: `get_latest_commit()`, `get_commit_count()`
|
||||
- Plan management: `load_implementation_plan()`, `find_subtask_in_plan()`, `find_phase_for_subtask()`
|
||||
- Workspace sync: `sync_spec_to_source()`
|
||||
- Workspace sync: `sync_plan_to_source()`
|
||||
|
||||
### `memory.py` (13 KB)
|
||||
- Dual-layer memory system (Graphiti primary, file-based fallback)
|
||||
@@ -73,7 +73,7 @@ from agents import (
|
||||
# Utilities
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_spec_to_source,
|
||||
sync_plan_to_source,
|
||||
)
|
||||
```
|
||||
|
||||
|
||||
@@ -14,10 +14,6 @@ This module provides:
|
||||
Uses lazy imports to avoid circular dependencies.
|
||||
"""
|
||||
|
||||
# Explicit import required by CodeQL static analysis
|
||||
# (CodeQL doesn't recognize __getattr__ dynamic exports)
|
||||
from .utils import sync_spec_to_source
|
||||
|
||||
__all__ = [
|
||||
# Main API
|
||||
"run_autonomous_agent",
|
||||
@@ -36,7 +32,7 @@ __all__ = [
|
||||
"load_implementation_plan",
|
||||
"find_subtask_in_plan",
|
||||
"find_phase_for_subtask",
|
||||
"sync_spec_to_source",
|
||||
"sync_plan_to_source",
|
||||
# Constants
|
||||
"AUTO_CONTINUE_DELAY_SECONDS",
|
||||
"HUMAN_INTERVENTION_FILE",
|
||||
@@ -81,7 +77,7 @@ def __getattr__(name):
|
||||
"get_commit_count",
|
||||
"get_latest_commit",
|
||||
"load_implementation_plan",
|
||||
"sync_spec_to_source",
|
||||
"sync_plan_to_source",
|
||||
):
|
||||
from .utils import (
|
||||
find_phase_for_subtask,
|
||||
@@ -89,7 +85,7 @@ def __getattr__(name):
|
||||
get_commit_count,
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_spec_to_source,
|
||||
sync_plan_to_source,
|
||||
)
|
||||
|
||||
return locals()[name]
|
||||
|
||||
@@ -6,7 +6,6 @@ Shared imports, types, and constants used across agent modules.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
# Configure logging
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -14,83 +13,3 @@ logger = logging.getLogger(__name__)
|
||||
# Configuration constants
|
||||
AUTO_CONTINUE_DELAY_SECONDS = 3
|
||||
HUMAN_INTERVENTION_FILE = "PAUSE"
|
||||
|
||||
# Retry configuration for 400 tool concurrency errors
|
||||
MAX_CONCURRENCY_RETRIES = 5 # Maximum number of retries for tool concurrency errors
|
||||
INITIAL_RETRY_DELAY_SECONDS = (
|
||||
2 # Initial retry delay (doubles each retry: 2s, 4s, 8s, 16s, 32s)
|
||||
)
|
||||
MAX_RETRY_DELAY_SECONDS = 32 # Cap retry delay at 32 seconds
|
||||
|
||||
# Pause file constants for intelligent error recovery
|
||||
# These files signal pause/resume between frontend and backend
|
||||
RATE_LIMIT_PAUSE_FILE = "RATE_LIMIT_PAUSE" # Created when rate limited
|
||||
AUTH_FAILURE_PAUSE_FILE = "AUTH_PAUSE" # Created when auth fails
|
||||
RESUME_FILE = "RESUME" # Created by frontend to signal resume
|
||||
|
||||
# Maximum time to wait for rate limit reset (2 hours)
|
||||
# If reset time is beyond this, task should fail rather than wait indefinitely
|
||||
MAX_RATE_LIMIT_WAIT_SECONDS = 7200
|
||||
|
||||
# Wait intervals for pause/resume checking
|
||||
RATE_LIMIT_CHECK_INTERVAL_SECONDS = (
|
||||
30 # Check for RESUME file every 30 seconds during rate limit wait
|
||||
)
|
||||
AUTH_RESUME_CHECK_INTERVAL_SECONDS = 10 # Check for re-authentication every 10 seconds
|
||||
AUTH_RESUME_MAX_WAIT_SECONDS = 86400 # Maximum wait for re-authentication (24 hours)
|
||||
|
||||
|
||||
def sanitize_error_message(error_message: str, max_length: int = 500) -> str:
|
||||
"""
|
||||
Sanitize error messages to remove potentially sensitive information.
|
||||
|
||||
Redacts:
|
||||
- API keys (sk-..., key-...)
|
||||
- Bearer tokens
|
||||
- Token/secret values
|
||||
|
||||
Args:
|
||||
error_message: The raw error message to sanitize
|
||||
max_length: Maximum length to truncate to (default 500)
|
||||
|
||||
Returns:
|
||||
Sanitized and truncated error message
|
||||
"""
|
||||
if not error_message:
|
||||
return ""
|
||||
|
||||
# Redact patterns that look like API keys or tokens
|
||||
# Pattern: sk-... (OpenAI/Anthropic keys like sk-ant-api03-...)
|
||||
sanitized = re.sub(
|
||||
r"\bsk-[a-zA-Z0-9._\-]{20,}\b", "[REDACTED_API_KEY]", error_message
|
||||
)
|
||||
|
||||
# Pattern: key-... (generic API keys)
|
||||
sanitized = re.sub(r"\bkey-[a-zA-Z0-9._\-]{20,}\b", "[REDACTED_API_KEY]", sanitized)
|
||||
|
||||
# Pattern: Bearer ... (bearer tokens)
|
||||
sanitized = re.sub(
|
||||
r"\bBearer\s+[a-zA-Z0-9._\-]{20,}\b", "Bearer [REDACTED_TOKEN]", sanitized
|
||||
)
|
||||
|
||||
# Pattern: token= or token: followed by long strings
|
||||
sanitized = re.sub(
|
||||
r"(token[=:]\s*)[a-zA-Z0-9._\-]{20,}\b",
|
||||
r"\1[REDACTED_TOKEN]",
|
||||
sanitized,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Pattern: secret= or secret: followed by strings
|
||||
sanitized = re.sub(
|
||||
r"(secret[=:]\s*)[a-zA-Z0-9._\-]{20,}\b",
|
||||
r"\1[REDACTED_SECRET]",
|
||||
sanitized,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Truncate to max length
|
||||
if len(sanitized) > max_length:
|
||||
sanitized = sanitized[:max_length] + "..."
|
||||
|
||||
return sanitized
|
||||
|
||||
+499
-1100
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,6 @@ Handles session memory storage using dual-layer approach:
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from core.sentry import capture_exception
|
||||
from debug import (
|
||||
debug,
|
||||
debug_detailed,
|
||||
@@ -25,7 +24,6 @@ from graphiti_config import get_graphiti_status, is_graphiti_enabled
|
||||
# Import from parent memory package
|
||||
# Now safe since this module is named memory_manager (not memory)
|
||||
from memory import save_session_insights as save_file_based_memory
|
||||
from memory.graphiti_helpers import get_graphiti_memory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -115,15 +113,15 @@ async def get_graphiti_context(
|
||||
debug("memory", "Graphiti not enabled, skipping context retrieval")
|
||||
return None
|
||||
|
||||
memory = None
|
||||
try:
|
||||
# Use centralized helper for GraphitiMemory instantiation (async)
|
||||
memory = await get_graphiti_memory(spec_dir, project_dir)
|
||||
if memory is None:
|
||||
from graphiti_memory import GraphitiMemory
|
||||
|
||||
# Create memory manager
|
||||
memory = GraphitiMemory(spec_dir, project_dir)
|
||||
|
||||
if not memory.is_enabled:
|
||||
if is_debug_enabled():
|
||||
debug_warning(
|
||||
"memory", "GraphitiMemory not available for context retrieval"
|
||||
)
|
||||
debug_warning("memory", "GraphitiMemory.is_enabled=False")
|
||||
return None
|
||||
|
||||
# Build search query from subtask description
|
||||
@@ -132,6 +130,7 @@ async def get_graphiti_context(
|
||||
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
|
||||
@@ -147,26 +146,20 @@ async def get_graphiti_context(
|
||||
# Get relevant context
|
||||
context_items = await memory.get_relevant_context(query, num_results=5)
|
||||
|
||||
# Get patterns and gotchas specifically (THE FIX for learning loop!)
|
||||
# This retrieves PATTERN and GOTCHA episode types for cross-session learning
|
||||
patterns, gotchas = await memory.get_patterns_and_gotchas(
|
||||
query, num_results=3, min_score=0.5
|
||||
)
|
||||
|
||||
# Also get recent session history
|
||||
session_history = await memory.get_session_history(limit=3)
|
||||
|
||||
await memory.close()
|
||||
|
||||
if is_debug_enabled():
|
||||
debug(
|
||||
"memory",
|
||||
"Graphiti context retrieval complete",
|
||||
context_items_found=len(context_items) if context_items else 0,
|
||||
patterns_found=len(patterns) if patterns else 0,
|
||||
gotchas_found=len(gotchas) if gotchas else 0,
|
||||
session_history_found=len(session_history) if session_history else 0,
|
||||
)
|
||||
|
||||
if not context_items and not session_history and not patterns and not gotchas:
|
||||
if not context_items and not session_history:
|
||||
if is_debug_enabled():
|
||||
debug("memory", "No relevant context found in Graphiti")
|
||||
return None
|
||||
@@ -182,34 +175,6 @@ async def get_graphiti_context(
|
||||
item_type = item.get("type", "unknown")
|
||||
sections.append(f"- **[{item_type}]** {content}\n")
|
||||
|
||||
# Add patterns section (cross-session learning)
|
||||
if patterns:
|
||||
sections.append("### Learned Patterns\n")
|
||||
sections.append("_Patterns discovered in previous sessions:_\n")
|
||||
for p in patterns:
|
||||
pattern_text = p.get("pattern", "")
|
||||
applies_to = p.get("applies_to", "")
|
||||
if applies_to:
|
||||
sections.append(
|
||||
f"- **Pattern**: {pattern_text}\n _Applies to:_ {applies_to}\n"
|
||||
)
|
||||
else:
|
||||
sections.append(f"- **Pattern**: {pattern_text}\n")
|
||||
|
||||
# Add gotchas section (cross-session learning)
|
||||
if gotchas:
|
||||
sections.append("### Known Gotchas\n")
|
||||
sections.append("_Pitfalls to avoid:_\n")
|
||||
for g in gotchas:
|
||||
gotcha_text = g.get("gotcha", "")
|
||||
solution = g.get("solution", "")
|
||||
if solution:
|
||||
sections.append(
|
||||
f"- **Gotcha**: {gotcha_text}\n _Solution:_ {solution}\n"
|
||||
)
|
||||
else:
|
||||
sections.append(f"- **Gotcha**: {gotcha_text}\n")
|
||||
|
||||
if session_history:
|
||||
sections.append("### Recent Session Insights\n")
|
||||
for session in session_history[:2]: # Only show last 2
|
||||
@@ -228,29 +193,14 @@ async def get_graphiti_context(
|
||||
|
||||
return "\n".join(sections)
|
||||
|
||||
except ImportError:
|
||||
logger.debug("Graphiti packages not installed")
|
||||
if is_debug_enabled():
|
||||
debug_warning("memory", "Graphiti packages not installed")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get Graphiti context: {e}")
|
||||
if is_debug_enabled():
|
||||
debug_error("memory", "Graphiti context retrieval failed", error=str(e))
|
||||
# Capture exception to Sentry with full context
|
||||
capture_exception(
|
||||
e,
|
||||
operation="get_graphiti_context",
|
||||
subtask_id=subtask.get("id", "unknown"),
|
||||
subtask_desc=subtask.get("description", "")[:200],
|
||||
spec_dir=str(spec_dir),
|
||||
project_dir=str(project_dir),
|
||||
)
|
||||
return None
|
||||
finally:
|
||||
# Always close the memory connection (swallow exceptions to avoid overriding)
|
||||
if memory is not None:
|
||||
try:
|
||||
await memory.close()
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Failed to close Graphiti memory connection", exc_info=True
|
||||
)
|
||||
|
||||
|
||||
async def save_session_memory(
|
||||
@@ -335,19 +285,20 @@ async def save_session_memory(
|
||||
if is_debug_enabled():
|
||||
debug("memory", "Attempting PRIMARY storage: Graphiti")
|
||||
|
||||
memory = None
|
||||
try:
|
||||
# Use centralized helper for GraphitiMemory instantiation (async)
|
||||
memory = await get_graphiti_memory(spec_dir, project_dir)
|
||||
if memory is None:
|
||||
if is_debug_enabled():
|
||||
debug_warning("memory", "GraphitiMemory not available")
|
||||
debug(
|
||||
"memory",
|
||||
"get_graphiti_memory() returned None - this usually means Graphiti is disabled or provider config is invalid",
|
||||
)
|
||||
# Continue to file-based fallback
|
||||
if memory is not None and memory.is_enabled:
|
||||
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...")
|
||||
|
||||
@@ -364,6 +315,8 @@ async def save_session_memory(
|
||||
# Fallback to basic session insights
|
||||
result = await memory.save_session_insights(session_num, insights)
|
||||
|
||||
await memory.close()
|
||||
|
||||
if result:
|
||||
logger.info(
|
||||
f"Session {session_num} insights saved to Graphiti (primary)"
|
||||
@@ -384,43 +337,23 @@ async def save_session_memory(
|
||||
debug_warning(
|
||||
"memory", "Graphiti save returned False, using FALLBACK"
|
||||
)
|
||||
elif memory is None:
|
||||
if is_debug_enabled():
|
||||
debug_warning(
|
||||
"memory", "GraphitiMemory not available, using FALLBACK"
|
||||
)
|
||||
else:
|
||||
# memory is not None but memory.is_enabled is False
|
||||
logger.warning(
|
||||
"GraphitiMemory.is_enabled=False, falling back to file-based"
|
||||
"Graphiti memory not enabled, falling back to file-based"
|
||||
)
|
||||
if is_debug_enabled():
|
||||
debug_warning("memory", "GraphitiMemory disabled, using FALLBACK")
|
||||
debug_warning(
|
||||
"memory", "GraphitiMemory.is_enabled=False, using FALLBACK"
|
||||
)
|
||||
|
||||
except ImportError as e:
|
||||
logger.debug("Graphiti packages not installed, falling back to file-based")
|
||||
if is_debug_enabled():
|
||||
debug_warning("memory", "Graphiti packages not installed", error=str(e))
|
||||
except Exception as e:
|
||||
logger.warning(f"Graphiti save failed: {e}, falling back to file-based")
|
||||
if is_debug_enabled():
|
||||
debug_error("memory", "Graphiti save failed", error=str(e))
|
||||
# Capture exception to Sentry with full context
|
||||
capture_exception(
|
||||
e,
|
||||
operation="save_session_memory_graphiti",
|
||||
subtask_id=subtask_id,
|
||||
session_num=session_num,
|
||||
success=success,
|
||||
subtasks_completed=subtasks_completed,
|
||||
spec_dir=str(spec_dir),
|
||||
project_dir=str(project_dir),
|
||||
)
|
||||
finally:
|
||||
# Always close the memory connection (swallow exceptions to avoid overriding)
|
||||
if memory is not None:
|
||||
try:
|
||||
await memory.close()
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Failed to close Graphiti memory connection", exc_info=e
|
||||
)
|
||||
else:
|
||||
if is_debug_enabled():
|
||||
debug("memory", "Graphiti not enabled, skipping to FALLBACK")
|
||||
@@ -457,17 +390,6 @@ async def save_session_memory(
|
||||
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))
|
||||
# Capture exception to Sentry with full context
|
||||
capture_exception(
|
||||
e,
|
||||
operation="save_session_memory_file",
|
||||
subtask_id=subtask_id,
|
||||
session_num=session_num,
|
||||
success=success,
|
||||
subtasks_completed=subtasks_completed,
|
||||
spec_dir=str(spec_dir),
|
||||
project_dir=str(project_dir),
|
||||
)
|
||||
return False, "none"
|
||||
|
||||
|
||||
|
||||
+183
-183
@@ -1,183 +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, error_info = 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()
|
||||
await plan.async_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
|
||||
"""
|
||||
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
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
"""
|
||||
PR Template Filler Agent Module
|
||||
================================
|
||||
|
||||
Detects GitHub PR templates in a project and uses Claude to intelligently
|
||||
fill them based on code changes, spec context, commit history, and branch info.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from core.client import create_client
|
||||
from task_logger import LogPhase, get_task_logger
|
||||
|
||||
from .session import run_agent_session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Maximum diff size (in characters) before truncating to file-level summaries
|
||||
MAX_DIFF_CHARS = 30_000
|
||||
|
||||
|
||||
def detect_pr_template(project_dir: Path | str) -> str | None:
|
||||
"""
|
||||
Detect a GitHub PR template in the project.
|
||||
|
||||
Searches for:
|
||||
1. .github/PULL_REQUEST_TEMPLATE.md (single template)
|
||||
2. .github/PULL_REQUEST_TEMPLATE/ directory (picks the first .md file)
|
||||
|
||||
Args:
|
||||
project_dir: Root directory of the project
|
||||
|
||||
Returns:
|
||||
The template content as a string, or None if no template is found.
|
||||
"""
|
||||
project_dir = Path(project_dir)
|
||||
# Check for single template file
|
||||
single_template = project_dir / ".github" / "PULL_REQUEST_TEMPLATE.md"
|
||||
if single_template.is_file():
|
||||
try:
|
||||
content = single_template.read_text(encoding="utf-8")
|
||||
if content.strip():
|
||||
logger.info(f"Found PR template: {single_template}")
|
||||
return content
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read PR template {single_template}: {e}")
|
||||
|
||||
# Check for template directory (pick first .md file alphabetically)
|
||||
template_dir = project_dir / ".github" / "PULL_REQUEST_TEMPLATE"
|
||||
if template_dir.is_dir():
|
||||
try:
|
||||
md_files = sorted(template_dir.glob("*.md"))
|
||||
if md_files:
|
||||
content = md_files[0].read_text(encoding="utf-8")
|
||||
if content.strip():
|
||||
logger.info(f"Found PR template: {md_files[0]}")
|
||||
return content
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read PR template from {template_dir}: {e}")
|
||||
|
||||
logger.info("No GitHub PR template found in project")
|
||||
return None
|
||||
|
||||
|
||||
def _truncate_diff(diff_summary: str) -> str:
|
||||
"""
|
||||
Truncate a large diff to file-level summaries to stay within token limits.
|
||||
|
||||
If the diff is within MAX_DIFF_CHARS, return it unchanged.
|
||||
Otherwise, extract only file-level change summaries (e.g. file names
|
||||
with insertions/deletions counts) and discard line-level detail.
|
||||
|
||||
Args:
|
||||
diff_summary: The full diff summary text
|
||||
|
||||
Returns:
|
||||
The original or truncated diff summary.
|
||||
"""
|
||||
if len(diff_summary) <= MAX_DIFF_CHARS:
|
||||
return diff_summary
|
||||
|
||||
lines = diff_summary.splitlines()
|
||||
summary_lines: list[str] = []
|
||||
summary_lines.append("(Diff truncated to file-level summaries due to size)")
|
||||
summary_lines.append("")
|
||||
|
||||
for line in lines:
|
||||
# Keep file-level summary lines (stat lines, file headers, etc.)
|
||||
stripped = line.strip()
|
||||
if (
|
||||
stripped.startswith("diff --git")
|
||||
or stripped.startswith("---")
|
||||
or stripped.startswith("+++")
|
||||
or "file changed" in stripped.lower()
|
||||
or "files changed" in stripped.lower()
|
||||
or "insertion" in stripped.lower()
|
||||
or "deletion" in stripped.lower()
|
||||
or stripped.startswith("rename")
|
||||
or stripped.startswith("new file")
|
||||
or stripped.startswith("deleted file")
|
||||
or stripped.startswith("Binary files")
|
||||
):
|
||||
summary_lines.append(line)
|
||||
|
||||
# If we couldn't extract meaningful summaries, take the first chunk
|
||||
if len(summary_lines) <= 2:
|
||||
truncated = diff_summary[:MAX_DIFF_CHARS]
|
||||
return truncated + "\n\n(... diff truncated due to size)"
|
||||
|
||||
return "\n".join(summary_lines)
|
||||
|
||||
|
||||
def _strip_markdown_fences(content: str) -> str:
|
||||
"""
|
||||
Strip markdown code fences from the response if present.
|
||||
|
||||
The AI sometimes wraps the output in ```markdown ... ``` even when instructed
|
||||
not to. This ensures the PR body renders correctly on GitHub.
|
||||
|
||||
Args:
|
||||
content: The response content to clean
|
||||
|
||||
Returns:
|
||||
The content with markdown fences stripped.
|
||||
"""
|
||||
result = content
|
||||
|
||||
# Strip opening fence (```markdown or just ```)
|
||||
if result.startswith("```markdown"):
|
||||
result = result[len("```markdown") :].lstrip("\n")
|
||||
elif result.startswith("```md"):
|
||||
result = result[len("```md") :].lstrip("\n")
|
||||
elif result.startswith("```"):
|
||||
result = result[3:].lstrip("\n")
|
||||
|
||||
# Strip closing fence
|
||||
if result.endswith("```"):
|
||||
result = result[:-3].rstrip("\n")
|
||||
|
||||
return result.strip()
|
||||
|
||||
|
||||
def _build_prompt(
|
||||
template_content: str,
|
||||
diff_summary: str,
|
||||
spec_overview: str,
|
||||
commit_log: str,
|
||||
branch_name: str,
|
||||
target_branch: str,
|
||||
) -> str:
|
||||
"""
|
||||
Build the prompt for the PR template filler agent.
|
||||
|
||||
Combines the system prompt context variables into a single message
|
||||
that includes the template and all change context.
|
||||
|
||||
Args:
|
||||
template_content: The PR template markdown
|
||||
diff_summary: Git diff summary (possibly truncated)
|
||||
spec_overview: Spec.md content or summary
|
||||
commit_log: Git log of commits in the PR
|
||||
branch_name: Source branch name
|
||||
target_branch: Target branch name
|
||||
|
||||
Returns:
|
||||
The assembled prompt string.
|
||||
"""
|
||||
return f"""Fill out the following GitHub PR template using the provided context.
|
||||
Return ONLY the filled template markdown — no preamble, no explanation, no code fences.
|
||||
|
||||
## Checkbox Guidelines
|
||||
|
||||
IMPORTANT: Be accurate and honest about what has and hasn't been verified.
|
||||
|
||||
**Check these based on context (you can infer from the diff/spec):**
|
||||
- Base Branch targeting — check based on target_branch value
|
||||
- Type of Change (bug fix, feature, docs, refactor, test) — infer from diff and spec
|
||||
- Area (Frontend, Backend, Fullstack) — infer from changed file paths
|
||||
- Feature Toggle "N/A" — if the feature appears complete and not behind a flag
|
||||
- Breaking Changes "No" — if changes appear backward compatible
|
||||
|
||||
**Leave UNCHECKED (these require human verification you cannot perform):**
|
||||
- "I've tested my changes locally" — you have not tested anything
|
||||
- "All CI checks pass" — CI has not run yet
|
||||
- "Windows/macOS/Linux tested" — requires manual testing on each platform
|
||||
- "All existing tests pass" — CI has not run yet
|
||||
- "New features include test coverage" — unless test files are clearly visible in the diff
|
||||
- "Bug fixes include regression tests" — unless test files are clearly visible in the diff
|
||||
|
||||
**For platform/code quality checkboxes:**
|
||||
- "Used centralized platform/ module" — leave unchecked unless you can verify from the diff
|
||||
- "No hardcoded paths" — leave unchecked unless you can verify from the diff
|
||||
- "PR is small and focused (< 400 lines)" — check only if diff stats show < 400 lines changed
|
||||
|
||||
**For the "I've synced with develop branch" checkbox:**
|
||||
- Leave unchecked — you cannot verify the sync status
|
||||
|
||||
## PR Template
|
||||
|
||||
{template_content}
|
||||
|
||||
## Change Context
|
||||
|
||||
### Branch Information
|
||||
- **Source branch:** {branch_name}
|
||||
- **Target branch:** {target_branch}
|
||||
|
||||
### Git Diff Summary
|
||||
```
|
||||
{diff_summary}
|
||||
```
|
||||
|
||||
### Spec Overview
|
||||
{spec_overview}
|
||||
|
||||
### Commit History
|
||||
```
|
||||
{commit_log}
|
||||
```
|
||||
|
||||
Fill every section of the PR template. Follow the checkbox guidelines above carefully.
|
||||
Output ONLY the completed template — no code fences, no preamble."""
|
||||
|
||||
|
||||
def _load_spec_overview(spec_dir: Path) -> str:
|
||||
"""
|
||||
Load the spec.md content for context. Falls back to a brief note if unavailable.
|
||||
|
||||
Args:
|
||||
spec_dir: Directory containing the spec files
|
||||
|
||||
Returns:
|
||||
The spec content or a fallback message.
|
||||
"""
|
||||
spec_file = spec_dir / "spec.md"
|
||||
if spec_file.is_file():
|
||||
try:
|
||||
content = spec_file.read_text(encoding="utf-8")
|
||||
# Truncate very long specs to keep prompt manageable
|
||||
if len(content) > 8000:
|
||||
return content[:8000] + "\n\n(... spec truncated for brevity)"
|
||||
return content
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to read spec.md: {e}")
|
||||
return "(No spec overview available)"
|
||||
|
||||
|
||||
async def run_pr_template_filler(
|
||||
project_dir: Path,
|
||||
spec_dir: Path,
|
||||
model: str,
|
||||
thinking_budget: int | None = None,
|
||||
branch_name: str = "",
|
||||
target_branch: str = "develop",
|
||||
diff_summary: str = "",
|
||||
commit_log: str = "",
|
||||
verbose: bool = False,
|
||||
) -> str | None:
|
||||
"""
|
||||
Run the PR template filler agent to generate a filled PR body.
|
||||
|
||||
Detects the project's PR template, gathers change context, and invokes
|
||||
Claude to intelligently fill out the template sections.
|
||||
|
||||
Args:
|
||||
project_dir: Root directory of the project
|
||||
spec_dir: Directory containing the spec files
|
||||
model: Claude model to use
|
||||
thinking_budget: Max thinking tokens (None to disable extended thinking)
|
||||
branch_name: Source branch name for the PR
|
||||
target_branch: Target branch name for the PR
|
||||
diff_summary: Git diff summary of changes
|
||||
commit_log: Git log of commits included in the PR
|
||||
verbose: Whether to show detailed output
|
||||
|
||||
Returns:
|
||||
The filled template markdown string, or None if template detection fails
|
||||
or the agent encounters an error.
|
||||
"""
|
||||
# Detect PR template
|
||||
template_content = detect_pr_template(project_dir)
|
||||
if template_content is None:
|
||||
logger.info("No PR template detected — skipping template filler")
|
||||
return None
|
||||
|
||||
# Load spec overview
|
||||
spec_overview = _load_spec_overview(spec_dir)
|
||||
|
||||
# Truncate diff if too large
|
||||
truncated_diff = _truncate_diff(diff_summary)
|
||||
|
||||
# Build the prompt
|
||||
prompt = _build_prompt(
|
||||
template_content=template_content,
|
||||
diff_summary=truncated_diff,
|
||||
spec_overview=spec_overview,
|
||||
commit_log=commit_log,
|
||||
branch_name=branch_name,
|
||||
target_branch=target_branch,
|
||||
)
|
||||
|
||||
# Initialize task logger
|
||||
task_logger = get_task_logger(spec_dir)
|
||||
if task_logger:
|
||||
task_logger.start_phase(LogPhase.CODING, "PR template filling")
|
||||
|
||||
# Create client following the pattern from planner.py
|
||||
client = create_client(
|
||||
project_dir,
|
||||
spec_dir,
|
||||
model,
|
||||
agent_type="pr_template_filler",
|
||||
max_thinking_tokens=thinking_budget,
|
||||
)
|
||||
|
||||
try:
|
||||
async with client:
|
||||
status, response, _ = await run_agent_session(
|
||||
client, prompt, spec_dir, verbose, phase=LogPhase.CODING
|
||||
)
|
||||
|
||||
if task_logger:
|
||||
task_logger.end_phase(
|
||||
LogPhase.CODING,
|
||||
success=(status != "error"),
|
||||
message="PR template filling completed",
|
||||
)
|
||||
|
||||
if status == "error":
|
||||
logger.error("PR template filler agent returned an error")
|
||||
return None
|
||||
|
||||
# The agent should return only the filled template markdown
|
||||
if response and response.strip():
|
||||
result = _strip_markdown_fences(response.strip())
|
||||
logger.info("PR template filled successfully")
|
||||
return result
|
||||
|
||||
logger.warning("PR template filler returned empty response")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"PR template filler error: {e}")
|
||||
if task_logger:
|
||||
task_logger.log_error(f"PR template filler error: {e}", LogPhase.CODING)
|
||||
return None
|
||||
+550
-710
File diff suppressed because it is too large
Load Diff
@@ -216,9 +216,8 @@ AGENT_CONFIGS = {
|
||||
# QA PHASES (Read + test + browser + Graphiti memory)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"qa_reviewer": {
|
||||
# Read + Write/Edit (for QA reports and plan updates) + Bash (for tests)
|
||||
# Note: Reviewer writes to spec directory only (qa_report.md, implementation_plan.json)
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
|
||||
# Read-only + Bash (for running tests) - reviewer should NOT edit code
|
||||
"tools": BASE_READ_TOOLS + ["Bash"] + WEB_TOOLS,
|
||||
"mcp_servers": ["context7", "graphiti", "auto-claude", "browser"],
|
||||
"mcp_servers_optional": ["linear"], # For updating issue status
|
||||
"auto_claude_tools": [
|
||||
@@ -247,9 +246,7 @@ AGENT_CONFIGS = {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
# Note: Default to "none" because insight_extractor uses Haiku which doesn't support thinking
|
||||
# If using Sonnet/Opus models, override max_thinking_tokens in create_simple_client()
|
||||
"thinking_default": "none",
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"merge_resolver": {
|
||||
"tools": [], # Text-only analysis
|
||||
@@ -263,12 +260,6 @@ AGENT_CONFIGS = {
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low",
|
||||
},
|
||||
"pr_template_filler": {
|
||||
"tools": BASE_READ_TOOLS, # Read-only — reads diff, template, spec
|
||||
"mcp_servers": [], # No MCP needed, context passed via prompt
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low", # Fast utility task for structured fill-in
|
||||
},
|
||||
"pr_reviewer": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS, # Read-only
|
||||
"mcp_servers": ["context7"],
|
||||
@@ -276,30 +267,18 @@ AGENT_CONFIGS = {
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"pr_orchestrator_parallel": {
|
||||
# Read-only for parallel PR orchestrator
|
||||
# NOTE: Do NOT add "Task" here - the SDK auto-allows Task when agents are defined
|
||||
# via the --agents flag. Explicitly adding it interferes with agent registration.
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS, # Read-only for parallel PR orchestrator
|
||||
"mcp_servers": ["context7"],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"pr_followup_parallel": {
|
||||
# Read-only for parallel followup reviewer
|
||||
# NOTE: Do NOT add "Task" here - same reason as pr_orchestrator_parallel
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"tools": BASE_READ_TOOLS
|
||||
+ WEB_TOOLS, # Read-only for parallel followup reviewer
|
||||
"mcp_servers": ["context7"],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"pr_finding_validator": {
|
||||
# Standalone validator for re-checking findings against actual code
|
||||
# Called separately from orchestrator to validate findings with fresh context
|
||||
"tools": BASE_READ_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ANALYSIS PHASES
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -4,16 +4,9 @@ Session Memory Tools
|
||||
|
||||
Tools for recording and retrieving session memory, including discoveries,
|
||||
gotchas, and patterns.
|
||||
|
||||
Dual-storage approach:
|
||||
- File-based: Always available, works offline, spec-specific
|
||||
- LadybugDB: When Graphiti is enabled, also saves to graph database for
|
||||
cross-session retrieval and Memory UI display
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -26,110 +19,6 @@ except ImportError:
|
||||
SDK_TOOLS_AVAILABLE = False
|
||||
tool = None
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _save_to_graphiti_async(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
save_type: str,
|
||||
data: dict,
|
||||
) -> bool:
|
||||
"""
|
||||
Save data to Graphiti/LadybugDB (async implementation).
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory for GraphitiMemory initialization
|
||||
project_dir: Project root directory
|
||||
save_type: Type of save - 'discovery', 'gotcha', or 'pattern'
|
||||
data: Data to save
|
||||
|
||||
Returns:
|
||||
True if save succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Use centralized helper for GraphitiMemory instantiation
|
||||
# The helper handles enablement checks internally
|
||||
from memory.graphiti_helpers import get_graphiti_memory
|
||||
|
||||
memory = await get_graphiti_memory(spec_dir, project_dir)
|
||||
if memory is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
if save_type == "discovery":
|
||||
# Save as codebase discovery
|
||||
# Format: {file_path: description}
|
||||
result = await memory.save_codebase_discoveries(
|
||||
{data["file_path"]: data["description"]}
|
||||
)
|
||||
elif save_type == "gotcha":
|
||||
# Save as gotcha
|
||||
gotcha_text = data["gotcha"]
|
||||
if data.get("context"):
|
||||
gotcha_text += f" (Context: {data['context']})"
|
||||
result = await memory.save_gotcha(gotcha_text)
|
||||
elif save_type == "pattern":
|
||||
# Save as pattern
|
||||
result = await memory.save_pattern(data["pattern"])
|
||||
else:
|
||||
result = False
|
||||
return result
|
||||
finally:
|
||||
# Always close the memory connection (swallow exceptions to avoid overriding)
|
||||
try:
|
||||
await memory.close()
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Failed to close Graphiti memory connection", exc_info=True
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save to Graphiti: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def _save_to_graphiti_sync(
|
||||
spec_dir: Path,
|
||||
project_dir: Path,
|
||||
save_type: str,
|
||||
data: dict,
|
||||
) -> bool:
|
||||
"""
|
||||
Save data to Graphiti/LadybugDB (synchronous wrapper for sync contexts only).
|
||||
|
||||
NOTE: This should only be called from synchronous code. For async callers,
|
||||
use _save_to_graphiti_async() directly to ensure proper resource cleanup.
|
||||
|
||||
Args:
|
||||
spec_dir: Spec directory for GraphitiMemory initialization
|
||||
project_dir: Project root directory
|
||||
save_type: Type of save - 'discovery', 'gotcha', or 'pattern'
|
||||
data: Data to save
|
||||
|
||||
Returns:
|
||||
True if save succeeded, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Check if we're already in an async context
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
# We're in an async context - caller should use _save_to_graphiti_async
|
||||
# Log a warning and return False to avoid the resource leak bug
|
||||
logger.warning(
|
||||
"_save_to_graphiti_sync called from async context. "
|
||||
"Use _save_to_graphiti_async instead for proper cleanup."
|
||||
)
|
||||
return False
|
||||
except RuntimeError:
|
||||
# No running loop - safe to create one
|
||||
return asyncio.run(
|
||||
_save_to_graphiti_async(spec_dir, project_dir, save_type, data)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save to Graphiti: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
"""
|
||||
@@ -156,7 +45,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
{"file_path": str, "description": str, "category": str},
|
||||
)
|
||||
async def record_discovery(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Record a discovery to the codebase map (file + Graphiti)."""
|
||||
"""Record a discovery to the codebase map."""
|
||||
file_path = args["file_path"]
|
||||
description = args["description"]
|
||||
category = args.get("category", "general")
|
||||
@@ -165,13 +54,11 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
memory_dir.mkdir(exist_ok=True)
|
||||
|
||||
codebase_map_file = memory_dir / "codebase_map.json"
|
||||
saved_to_graphiti = False
|
||||
|
||||
try:
|
||||
# PRIMARY: Save to file-based storage (always works)
|
||||
# Load existing map or create new
|
||||
if codebase_map_file.exists():
|
||||
with open(codebase_map_file, encoding="utf-8") as f:
|
||||
with open(codebase_map_file) as f:
|
||||
codebase_map = json.load(f)
|
||||
else:
|
||||
codebase_map = {
|
||||
@@ -187,26 +74,14 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
}
|
||||
codebase_map["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
with open(codebase_map_file, "w", encoding="utf-8") as f:
|
||||
with open(codebase_map_file, "w") as f:
|
||||
json.dump(codebase_map, f, indent=2)
|
||||
|
||||
# SECONDARY: Also save to Graphiti/LadybugDB (for Memory UI)
|
||||
saved_to_graphiti = await _save_to_graphiti_async(
|
||||
spec_dir,
|
||||
project_dir,
|
||||
"discovery",
|
||||
{
|
||||
"file_path": file_path,
|
||||
"description": f"[{category}] {description}",
|
||||
},
|
||||
)
|
||||
|
||||
storage_note = " (also saved to memory graph)" if saved_to_graphiti else ""
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Recorded discovery for '{file_path}': {description}{storage_note}",
|
||||
"text": f"Recorded discovery for '{file_path}': {description}",
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -227,7 +102,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
{"gotcha": str, "context": str},
|
||||
)
|
||||
async def record_gotcha(args: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Record a gotcha to session memory (file + Graphiti)."""
|
||||
"""Record a gotcha to session memory."""
|
||||
gotcha = args["gotcha"]
|
||||
context = args.get("context", "")
|
||||
|
||||
@@ -235,10 +110,8 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
memory_dir.mkdir(exist_ok=True)
|
||||
|
||||
gotchas_file = memory_dir / "gotchas.md"
|
||||
saved_to_graphiti = False
|
||||
|
||||
try:
|
||||
# PRIMARY: Save to file-based storage (always works)
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
entry = f"\n## [{timestamp}]\n{gotcha}"
|
||||
@@ -246,27 +119,14 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
entry += f"\n\n_Context: {context}_"
|
||||
entry += "\n"
|
||||
|
||||
with open(gotchas_file, "a", encoding="utf-8") as f:
|
||||
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}"}
|
||||
]
|
||||
}
|
||||
return {"content": [{"type": "text", "text": f"Recorded gotcha: {gotcha}"}]}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
@@ -303,7 +163,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
codebase_map_file = memory_dir / "codebase_map.json"
|
||||
if codebase_map_file.exists():
|
||||
try:
|
||||
with open(codebase_map_file, encoding="utf-8") as f:
|
||||
with open(codebase_map_file) as f:
|
||||
codebase_map = json.load(f)
|
||||
|
||||
discoveries = codebase_map.get("discovered_files", {})
|
||||
@@ -319,7 +179,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
gotchas_file = memory_dir / "gotchas.md"
|
||||
if gotchas_file.exists():
|
||||
try:
|
||||
content = gotchas_file.read_text(encoding="utf-8")
|
||||
content = gotchas_file.read_text()
|
||||
if content.strip():
|
||||
result_parts.append("\n## Gotchas")
|
||||
# Take last 1000 chars to avoid too much context
|
||||
@@ -333,7 +193,7 @@ def create_memory_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
patterns_file = memory_dir / "patterns.md"
|
||||
if patterns_file.exists():
|
||||
try:
|
||||
content = patterns_file.read_text(encoding="utf-8")
|
||||
content = patterns_file.read_text()
|
||||
if content.strip():
|
||||
result_parts.append("\n## Patterns")
|
||||
result_parts.append(
|
||||
|
||||
@@ -57,7 +57,7 @@ def create_progress_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
}
|
||||
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
with open(plan_file) as f:
|
||||
plan = json.load(f)
|
||||
|
||||
stats = {
|
||||
|
||||
@@ -6,14 +6,10 @@ Tools for managing QA status and sign-off in implementation_plan.json.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from core.file_utils import write_json_atomic
|
||||
from spec.validate_pkg.auto_fix import auto_fix_plan
|
||||
|
||||
try:
|
||||
from claude_agent_sdk import tool
|
||||
|
||||
@@ -23,49 +19,6 @@ except ImportError:
|
||||
tool = None
|
||||
|
||||
|
||||
def _apply_qa_update(
|
||||
plan: dict[str, Any],
|
||||
status: str,
|
||||
issues: list[Any],
|
||||
tests_passed: dict[str, Any],
|
||||
) -> int:
|
||||
"""
|
||||
Apply QA update to the plan and return the new QA session number.
|
||||
|
||||
Args:
|
||||
plan: The implementation plan dict
|
||||
status: QA status (pending, in_review, approved, rejected, fixes_applied)
|
||||
issues: List of issues found
|
||||
tests_passed: Dict of test results
|
||||
|
||||
Returns:
|
||||
The new QA session number
|
||||
"""
|
||||
# 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",
|
||||
}
|
||||
|
||||
# NOTE: Do NOT write plan["status"] or plan["planStatus"] here.
|
||||
# The frontend XState task state machine owns status transitions.
|
||||
# Writing status here races with XState's persistPlanStatusAndReasonSync()
|
||||
# and can clobber the reviewReason field, causing tasks to appear "incomplete".
|
||||
|
||||
plan["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
return qa_session
|
||||
|
||||
|
||||
def create_qa_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
"""
|
||||
Create QA management tools.
|
||||
@@ -136,13 +89,37 @@ def create_qa_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
except json.JSONDecodeError:
|
||||
tests_passed = {}
|
||||
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
with open(plan_file) as f:
|
||||
plan = json.load(f)
|
||||
|
||||
qa_session = _apply_qa_update(plan, status, issues, tests_passed)
|
||||
# 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
|
||||
|
||||
# Use atomic write to prevent file corruption
|
||||
write_json_atomic(plan_file, plan, indent=2)
|
||||
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": [
|
||||
@@ -153,47 +130,6 @@ def create_qa_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
]
|
||||
}
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
# Attempt to auto-fix the plan and retry
|
||||
if auto_fix_plan(spec_dir):
|
||||
# Retry after fix
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
|
||||
qa_session = _apply_qa_update(plan, status, issues, tests_passed)
|
||||
write_json_atomic(plan_file, plan, indent=2)
|
||||
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Updated QA status to '{status}' (session {qa_session}) (after auto-fix)",
|
||||
}
|
||||
]
|
||||
}
|
||||
except Exception as retry_err:
|
||||
logging.warning(
|
||||
f"QA update retry failed after auto-fix: {retry_err} (original error: {e})"
|
||||
)
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Error: QA update failed after auto-fix: {retry_err} (original JSON error: {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 QA status: {e}"}]
|
||||
|
||||
@@ -6,14 +6,10 @@ Tools for managing subtask status in implementation_plan.json.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from core.file_utils import write_json_atomic
|
||||
from spec.validate_pkg.auto_fix import auto_fix_plan
|
||||
|
||||
try:
|
||||
from claude_agent_sdk import tool
|
||||
|
||||
@@ -23,43 +19,6 @@ except ImportError:
|
||||
tool = None
|
||||
|
||||
|
||||
def _update_subtask_in_plan(
|
||||
plan: dict[str, Any],
|
||||
subtask_id: str,
|
||||
status: str,
|
||||
notes: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Update a subtask in the plan.
|
||||
|
||||
Args:
|
||||
plan: The implementation plan dict
|
||||
subtask_id: ID of the subtask to update
|
||||
status: New status (pending, in_progress, completed, failed)
|
||||
notes: Optional notes to add
|
||||
|
||||
Returns:
|
||||
True if subtask was found and updated, False otherwise
|
||||
"""
|
||||
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 subtask_found:
|
||||
plan["last_updated"] = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
return subtask_found
|
||||
|
||||
|
||||
def create_subtask_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
"""
|
||||
Create subtask management tools.
|
||||
@@ -113,10 +72,22 @@ def create_subtask_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
}
|
||||
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
with open(plan_file) as f:
|
||||
plan = json.load(f)
|
||||
|
||||
subtask_found = _update_subtask_in_plan(plan, subtask_id, status, notes)
|
||||
# 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 {
|
||||
@@ -128,8 +99,11 @@ def create_subtask_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
]
|
||||
}
|
||||
|
||||
# Use atomic write to prevent file corruption
|
||||
write_json_atomic(plan_file, plan, indent=2)
|
||||
# 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": [
|
||||
@@ -141,49 +115,6 @@ def create_subtask_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
}
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
# Attempt to auto-fix the plan and retry
|
||||
if auto_fix_plan(spec_dir):
|
||||
# Retry after fix
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
|
||||
subtask_found = _update_subtask_in_plan(
|
||||
plan, subtask_id, status, notes
|
||||
)
|
||||
|
||||
if subtask_found:
|
||||
write_json_atomic(plan_file, plan, indent=2)
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Successfully updated subtask '{subtask_id}' to status '{status}' (after auto-fix)",
|
||||
}
|
||||
]
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Error: Subtask '{subtask_id}' not found in implementation plan (after auto-fix)",
|
||||
}
|
||||
]
|
||||
}
|
||||
except Exception as retry_err:
|
||||
logging.warning(
|
||||
f"Subtask update retry failed after auto-fix: {retry_err}"
|
||||
)
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": f"Error: Subtask update failed after auto-fix: {retry_err}",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
"content": [
|
||||
{
|
||||
|
||||
+40
-105
@@ -8,38 +8,40 @@ Helper functions for git operations, plan management, and file syncing.
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from core.git_executable import run_git
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_latest_commit(project_dir: Path) -> str | None:
|
||||
"""Get the hash of the latest git commit."""
|
||||
result = run_git(
|
||||
["rev-parse", "HEAD"],
|
||||
cwd=project_dir,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
return None
|
||||
except subprocess.CalledProcessError:
|
||||
return None
|
||||
|
||||
|
||||
def get_commit_count(project_dir: Path) -> int:
|
||||
"""Get the total number of commits."""
|
||||
result = run_git(
|
||||
["rev-list", "--count", "HEAD"],
|
||||
cwd=project_dir,
|
||||
timeout=10,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
try:
|
||||
return int(result.stdout.strip())
|
||||
except ValueError:
|
||||
return 0
|
||||
return 0
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "rev-list", "--count", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return int(result.stdout.strip())
|
||||
except (subprocess.CalledProcessError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def load_implementation_plan(spec_dir: Path) -> dict | None:
|
||||
@@ -48,9 +50,9 @@ def load_implementation_plan(spec_dir: Path) -> dict | None:
|
||||
if not plan_file.exists():
|
||||
return None
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
with open(plan_file) as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
@@ -72,32 +74,16 @@ def find_phase_for_subtask(plan: dict, subtask_id: str) -> dict | None:
|
||||
return None
|
||||
|
||||
|
||||
def sync_spec_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool:
|
||||
def sync_plan_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool:
|
||||
"""
|
||||
Sync ALL spec files from worktree back to source spec directory.
|
||||
Sync implementation_plan.json from worktree back to source spec directory.
|
||||
|
||||
When running in isolated mode (worktrees), the agent creates and updates
|
||||
many files inside the worktree's spec directory. This function syncs ALL
|
||||
of them back to the main project's spec directory.
|
||||
|
||||
IMPORTANT: Since .auto-claude/ is gitignored, this sync happens to the
|
||||
local filesystem regardless of what branch the user is on. The worktree
|
||||
may be on a different branch (e.g., auto-claude/093-task), but the sync
|
||||
target is always the main project's .auto-claude/specs/ directory.
|
||||
|
||||
Files synced (all files in spec directory):
|
||||
- implementation_plan.json - Task status and subtask completion
|
||||
- build-progress.txt - Session-by-session progress notes
|
||||
- task_logs.json - Execution logs
|
||||
- review_state.json - QA review state
|
||||
- critique_report.json - Spec critique findings
|
||||
- suggested_commit_message.txt - Commit suggestions
|
||||
- REGRESSION_TEST_REPORT.md - Test regression report
|
||||
- spec.md, context.json, etc. - Original spec files (for completeness)
|
||||
- memory/ directory - Codebase map, patterns, gotchas, session insights
|
||||
When running in isolated mode (worktrees), the agent updates the implementation
|
||||
plan inside the worktree. This function syncs those changes back to the main
|
||||
project's spec directory so the frontend/UI can see the progress.
|
||||
|
||||
Args:
|
||||
spec_dir: Current spec directory (inside worktree)
|
||||
spec_dir: Current spec directory (may be inside worktree)
|
||||
source_spec_dir: Original spec directory in main project (outside worktree)
|
||||
|
||||
Returns:
|
||||
@@ -114,68 +100,17 @@ def sync_spec_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool:
|
||||
if spec_dir_resolved == source_spec_dir_resolved:
|
||||
return False # Same directory, no sync needed
|
||||
|
||||
synced_any = False
|
||||
# Sync the implementation plan
|
||||
plan_file = spec_dir / "implementation_plan.json"
|
||||
if not plan_file.exists():
|
||||
return False
|
||||
|
||||
# Ensure source directory exists
|
||||
source_spec_dir.mkdir(parents=True, exist_ok=True)
|
||||
source_plan_file = source_spec_dir / "implementation_plan.json"
|
||||
|
||||
try:
|
||||
# Sync all files and directories from worktree spec to source spec
|
||||
for item in spec_dir.iterdir():
|
||||
# Skip symlinks to prevent path traversal attacks
|
||||
if item.is_symlink():
|
||||
logger.warning(f"Skipping symlink during sync: {item.name}")
|
||||
continue
|
||||
|
||||
source_item = source_spec_dir / item.name
|
||||
|
||||
if item.is_file():
|
||||
# Copy file (preserves timestamps)
|
||||
shutil.copy2(item, source_item)
|
||||
logger.debug(f"Synced {item.name} to source")
|
||||
synced_any = True
|
||||
|
||||
elif item.is_dir():
|
||||
# Recursively sync directory
|
||||
_sync_directory(item, source_item)
|
||||
synced_any = True
|
||||
|
||||
shutil.copy2(plan_file, source_plan_file)
|
||||
logger.debug(f"Synced implementation plan to source: {source_plan_file}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to sync spec directory to source: {e}")
|
||||
|
||||
return synced_any
|
||||
|
||||
|
||||
def _sync_directory(source_dir: Path, target_dir: Path) -> None:
|
||||
"""
|
||||
Recursively sync a directory from source to target.
|
||||
|
||||
Args:
|
||||
source_dir: Source directory (in worktree)
|
||||
target_dir: Target directory (in main project)
|
||||
"""
|
||||
# Create target directory if needed
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for item in source_dir.iterdir():
|
||||
# Skip symlinks to prevent path traversal attacks
|
||||
if item.is_symlink():
|
||||
logger.warning(
|
||||
f"Skipping symlink during sync: {source_dir.name}/{item.name}"
|
||||
)
|
||||
continue
|
||||
|
||||
target_item = target_dir / item.name
|
||||
|
||||
if item.is_file():
|
||||
shutil.copy2(item, target_item)
|
||||
logger.debug(f"Synced {source_dir.name}/{item.name} to source")
|
||||
elif item.is_dir():
|
||||
# Recurse into subdirectories
|
||||
_sync_directory(item, target_item)
|
||||
|
||||
|
||||
# Keep the old name as an alias for backward compatibility
|
||||
def sync_plan_to_source(spec_dir: Path, source_spec_dir: Path | None) -> bool:
|
||||
"""Alias for sync_spec_to_source for backward compatibility."""
|
||||
return sync_spec_to_source(spec_dir, source_spec_dir)
|
||||
logger.warning(f"Failed to sync implementation plan to source: {e}")
|
||||
return False
|
||||
|
||||
@@ -46,7 +46,7 @@ def analyze_project(project_dir: Path, output_file: Path | None = None) -> dict:
|
||||
|
||||
if output_file:
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
with open(output_file, "w") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
print(f"Project index saved to: {output_file}")
|
||||
|
||||
@@ -87,7 +87,7 @@ def analyze_service(
|
||||
|
||||
if output_file:
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
with open(output_file, "w") as f:
|
||||
json.dump(results, f, indent=2)
|
||||
print(f"Service analysis saved to: {output_file}")
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ class BaseAnalyzer:
|
||||
def _read_file(self, path: str) -> str:
|
||||
"""Read a file relative to the analyzer's path."""
|
||||
try:
|
||||
return (self.path / path).read_text(encoding="utf-8")
|
||||
return (self.path / path).read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return ""
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ class AuthDetector(BaseAnalyzer):
|
||||
|
||||
for py_file in all_py_files:
|
||||
try:
|
||||
content = py_file.read_text(encoding="utf-8")
|
||||
content = py_file.read_text()
|
||||
# Find custom decorators
|
||||
if (
|
||||
"@require" in content
|
||||
|
||||
@@ -52,7 +52,7 @@ class JobsDetector(BaseAnalyzer):
|
||||
tasks = []
|
||||
for task_file in celery_files:
|
||||
try:
|
||||
content = task_file.read_text(encoding="utf-8")
|
||||
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)
|
||||
|
||||
@@ -77,7 +77,7 @@ class MonitoringDetector(BaseAnalyzer):
|
||||
continue
|
||||
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
content = file_path.read_text()
|
||||
# Look for actual Prometheus imports or usage patterns
|
||||
prometheus_patterns = [
|
||||
"from prometheus_client import",
|
||||
|
||||
@@ -52,7 +52,7 @@ class DatabaseDetector(BaseAnalyzer):
|
||||
|
||||
for file_path in py_files:
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
content = file_path.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
@@ -119,7 +119,7 @@ class DatabaseDetector(BaseAnalyzer):
|
||||
|
||||
for file_path in model_files:
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
content = file_path.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
@@ -168,7 +168,7 @@ class DatabaseDetector(BaseAnalyzer):
|
||||
return models
|
||||
|
||||
try:
|
||||
content = schema_file.read_text(encoding="utf-8")
|
||||
content = schema_file.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return models
|
||||
|
||||
@@ -216,7 +216,7 @@ class DatabaseDetector(BaseAnalyzer):
|
||||
|
||||
for file_path in ts_files:
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
content = file_path.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
@@ -265,7 +265,7 @@ class DatabaseDetector(BaseAnalyzer):
|
||||
|
||||
for file_path in schema_files:
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
content = file_path.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
@@ -295,7 +295,7 @@ class DatabaseDetector(BaseAnalyzer):
|
||||
|
||||
for file_path in model_files:
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
content = file_path.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
|
||||
@@ -408,6 +408,6 @@ class FrameworkAnalyzer(BaseAnalyzer):
|
||||
return "pnpm"
|
||||
elif self._exists("yarn.lock"):
|
||||
return "yarn"
|
||||
elif self._exists("bun.lockb") or self._exists("bun.lock"):
|
||||
elif self._exists("bun.lockb"):
|
||||
return "bun"
|
||||
return "npm"
|
||||
|
||||
@@ -287,6 +287,6 @@ class ProjectAnalyzer:
|
||||
|
||||
def _read_file(self, path: str) -> str:
|
||||
try:
|
||||
return (self.project_dir / path).read_text(encoding="utf-8")
|
||||
return (self.project_dir / path).read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return ""
|
||||
|
||||
@@ -66,7 +66,7 @@ class RouteDetector(BaseAnalyzer):
|
||||
|
||||
for file_path in files_to_check:
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
content = file_path.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
@@ -130,7 +130,7 @@ class RouteDetector(BaseAnalyzer):
|
||||
|
||||
for file_path in files_to_check:
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
content = file_path.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
@@ -179,7 +179,7 @@ class RouteDetector(BaseAnalyzer):
|
||||
|
||||
for file_path in url_files:
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
content = file_path.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
@@ -218,7 +218,7 @@ class RouteDetector(BaseAnalyzer):
|
||||
files_to_check = js_files + ts_files
|
||||
for file_path in files_to_check:
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
content = file_path.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
@@ -283,7 +283,7 @@ class RouteDetector(BaseAnalyzer):
|
||||
route_path = re.sub(r"\[([^\]]+)\]", r":\1", route_path)
|
||||
|
||||
try:
|
||||
content = route_file.read_text(encoding="utf-8")
|
||||
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)",
|
||||
@@ -348,7 +348,7 @@ class RouteDetector(BaseAnalyzer):
|
||||
|
||||
for file_path in go_files:
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
content = file_path.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
@@ -384,7 +384,7 @@ class RouteDetector(BaseAnalyzer):
|
||||
|
||||
for file_path in rust_files:
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
content = file_path.read_text()
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
|
||||
|
||||
@@ -163,7 +163,7 @@ class CIDiscovery:
|
||||
)
|
||||
|
||||
try:
|
||||
content = wf_file.read_text(encoding="utf-8")
|
||||
content = wf_file.read_text()
|
||||
workflow_data = self._parse_yaml(content)
|
||||
|
||||
if not workflow_data:
|
||||
@@ -242,7 +242,7 @@ class CIDiscovery:
|
||||
)
|
||||
|
||||
try:
|
||||
content = config_file.read_text(encoding="utf-8")
|
||||
content = config_file.read_text()
|
||||
data = self._parse_yaml(content)
|
||||
|
||||
if not data:
|
||||
@@ -312,7 +312,7 @@ class CIDiscovery:
|
||||
)
|
||||
|
||||
try:
|
||||
content = config_file.read_text(encoding="utf-8")
|
||||
content = config_file.read_text()
|
||||
data = self._parse_yaml(content)
|
||||
|
||||
if not data:
|
||||
@@ -370,7 +370,7 @@ class CIDiscovery:
|
||||
)
|
||||
|
||||
try:
|
||||
content = jenkinsfile.read_text(encoding="utf-8")
|
||||
content = jenkinsfile.read_text()
|
||||
|
||||
# Extract sh commands using regex
|
||||
sh_pattern = re.compile(r'sh\s+[\'"]([^\'"]+)[\'"]')
|
||||
|
||||
@@ -33,9 +33,7 @@ except ImportError:
|
||||
from core.auth import ensure_claude_code_oauth_token, get_auth_token
|
||||
|
||||
# Default model for insight extraction (fast and cheap)
|
||||
# Note: Using Haiku 4.5 for fast, cheap extraction. Haiku does not support
|
||||
# extended thinking, so thinking_default is set to "none" in models.py
|
||||
DEFAULT_EXTRACTION_MODEL = "claude-haiku-4-5-20251001"
|
||||
DEFAULT_EXTRACTION_MODEL = "claude-3-5-haiku-latest"
|
||||
|
||||
# Maximum diff size to send to the LLM (avoid context limits)
|
||||
MAX_DIFF_CHARS = 15000
|
||||
@@ -237,7 +235,7 @@ def _get_subtask_description(spec_dir: Path, subtask_id: str) -> str:
|
||||
return f"Subtask: {subtask_id}"
|
||||
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
with open(plan_file) as f:
|
||||
plan = json.load(f)
|
||||
|
||||
# Search through phases for the subtask
|
||||
@@ -280,7 +278,7 @@ def _build_extraction_prompt(inputs: dict) -> str:
|
||||
prompt_file = Path(__file__).parent / "prompts" / "insight_extractor.md"
|
||||
|
||||
if prompt_file.exists():
|
||||
base_prompt = prompt_file.read_text(encoding="utf-8")
|
||||
base_prompt = prompt_file.read_text()
|
||||
else:
|
||||
# Fallback if prompt file missing
|
||||
base_prompt = """Extract structured insights from this coding session.
|
||||
@@ -389,40 +387,12 @@ async def run_insight_extraction(
|
||||
|
||||
# Collect the response
|
||||
response_text = ""
|
||||
message_count = 0
|
||||
text_blocks_found = 0
|
||||
|
||||
async for msg in client.receive_response():
|
||||
msg_type = type(msg).__name__
|
||||
message_count += 1
|
||||
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
# Must check block type - only TextBlock has .text attribute
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
text_blocks_found += 1
|
||||
if block.text: # Only add non-empty text
|
||||
response_text += block.text
|
||||
else:
|
||||
logger.debug(
|
||||
f"Found empty TextBlock in response (block #{text_blocks_found})"
|
||||
)
|
||||
|
||||
# Log response collection summary
|
||||
logger.debug(
|
||||
f"Insight extraction response: {message_count} messages, "
|
||||
f"{text_blocks_found} text blocks, {len(response_text)} chars collected"
|
||||
)
|
||||
|
||||
# Validate we received content before parsing
|
||||
if not response_text.strip():
|
||||
logger.warning(
|
||||
f"Insight extraction returned empty response. "
|
||||
f"Messages received: {message_count}, TextBlocks found: {text_blocks_found}. "
|
||||
f"This may indicate the AI model did not respond with text content."
|
||||
)
|
||||
return None
|
||||
if hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
|
||||
# Parse JSON from response
|
||||
return parse_insights(response_text)
|
||||
@@ -445,11 +415,6 @@ def parse_insights(response_text: str) -> dict | None:
|
||||
# Try to extract JSON from the response
|
||||
text = response_text.strip()
|
||||
|
||||
# Early validation - check for empty response
|
||||
if not text:
|
||||
logger.warning("Cannot parse insights: response text is empty")
|
||||
return None
|
||||
|
||||
# Handle markdown code blocks
|
||||
if text.startswith("```"):
|
||||
# Remove code block markers
|
||||
@@ -457,26 +422,17 @@ def parse_insights(response_text: str) -> dict | None:
|
||||
# Remove first line (```json or ```)
|
||||
if lines[0].startswith("```"):
|
||||
lines = lines[1:]
|
||||
# Remove last line if it's ```
|
||||
# Remove last line if it's ``
|
||||
if lines and lines[-1].strip() == "```":
|
||||
lines = lines[:-1]
|
||||
text = "\n".join(lines).strip()
|
||||
|
||||
# Check again after removing code blocks
|
||||
if not text:
|
||||
logger.warning(
|
||||
"Cannot parse insights: response contained only markdown code block markers with no content"
|
||||
)
|
||||
return None
|
||||
text = "\n".join(lines)
|
||||
|
||||
try:
|
||||
insights = json.loads(text)
|
||||
|
||||
# Validate structure
|
||||
if not isinstance(insights, dict):
|
||||
logger.warning(
|
||||
f"Insights is not a dict, got type: {type(insights).__name__}"
|
||||
)
|
||||
logger.warning("Insights is not a dict")
|
||||
return None
|
||||
|
||||
# Ensure required keys exist with defaults
|
||||
@@ -490,13 +446,7 @@ def parse_insights(response_text: str) -> dict | None:
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"Failed to parse insights JSON: {e}")
|
||||
# Show more context in the error message
|
||||
preview_length = min(500, len(text))
|
||||
logger.warning(
|
||||
f"Response text preview (first {preview_length} chars): {text[:preview_length]}"
|
||||
)
|
||||
if len(text) > preview_length:
|
||||
logger.warning(f"... (total length: {len(text)} chars)")
|
||||
logger.debug(f"Response text was: {text[:500]}")
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -275,7 +275,7 @@ class TestDiscovery:
|
||||
return "yarn"
|
||||
if (project_dir / "package-lock.json").exists():
|
||||
return "npm"
|
||||
if (project_dir / "bun.lockb").exists() or (project_dir / "bun.lock").exists():
|
||||
if (project_dir / "bun.lockb").exists():
|
||||
return "bun"
|
||||
if (project_dir / "uv.lock").exists():
|
||||
return "uv"
|
||||
@@ -302,7 +302,7 @@ class TestDiscovery:
|
||||
try:
|
||||
with open(package_json, encoding="utf-8") as f:
|
||||
pkg = json.load(f)
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return
|
||||
|
||||
deps = pkg.get("dependencies", {})
|
||||
@@ -398,7 +398,7 @@ class TestDiscovery:
|
||||
# Check pyproject.toml
|
||||
pyproject = project_dir / "pyproject.toml"
|
||||
if pyproject.exists():
|
||||
content = pyproject.read_text(encoding="utf-8")
|
||||
content = pyproject.read_text()
|
||||
|
||||
# Check for pytest
|
||||
if "pytest" in content:
|
||||
@@ -418,7 +418,7 @@ class TestDiscovery:
|
||||
# Check requirements.txt
|
||||
requirements = project_dir / "requirements.txt"
|
||||
if requirements.exists():
|
||||
content = requirements.read_text(encoding="utf-8").lower()
|
||||
content = requirements.read_text().lower()
|
||||
if "pytest" in content and not any(
|
||||
f.name == "pytest" for f in result.frameworks
|
||||
):
|
||||
@@ -496,7 +496,7 @@ class TestDiscovery:
|
||||
if not gemfile.exists():
|
||||
return
|
||||
|
||||
content = gemfile.read_text(encoding="utf-8").lower()
|
||||
content = gemfile.read_text().lower()
|
||||
|
||||
if "rspec" in content or (project_dir / ".rspec").exists():
|
||||
result.frameworks.append(
|
||||
|
||||
@@ -6,8 +6,7 @@ Commands for creating and managing multiple tasks from batch files.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from ui import highlight, print_status
|
||||
@@ -31,7 +30,7 @@ def handle_batch_create_command(batch_file: str, project_dir: str) -> bool:
|
||||
return False
|
||||
|
||||
try:
|
||||
with open(batch_path, encoding="utf-8") as f:
|
||||
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")
|
||||
@@ -81,7 +80,7 @@ def handle_batch_create_command(batch_file: str, project_dir: str) -> bool:
|
||||
}
|
||||
|
||||
req_file = spec_dir / "requirements.json"
|
||||
with open(req_file, "w", encoding="utf-8") as f:
|
||||
with open(req_file, "w") as f:
|
||||
json.dump(requirements, f, indent=2, default=str)
|
||||
|
||||
created_specs.append(
|
||||
@@ -145,7 +144,7 @@ def handle_batch_status_command(project_dir: str) -> bool:
|
||||
|
||||
if req_file.exists():
|
||||
try:
|
||||
with open(req_file, encoding="utf-8") as f:
|
||||
with open(req_file) as f:
|
||||
req = json.load(f)
|
||||
title = req.get("task_description", title)
|
||||
except json.JSONDecodeError:
|
||||
@@ -176,17 +175,23 @@ def handle_batch_status_command(project_dir: str) -> bool:
|
||||
|
||||
def handle_batch_cleanup_command(project_dir: str, dry_run: bool = True) -> bool:
|
||||
"""
|
||||
Clean up completed specs and worktrees.
|
||||
|
||||
Args:
|
||||
project_dir: Project directory
|
||||
dry_run: If True, show what would be deleted
|
||||
|
||||
Clean up completed spec directories and their associated worktree paths.
|
||||
|
||||
Finds spec directories under <project_dir>/.auto-claude/specs that contain a `qa_report.md` (treated as completed),
|
||||
and, when run in dry-run mode, prints the specs and corresponding worktree paths that would be removed.
|
||||
|
||||
Parameters:
|
||||
project_dir (str): Path to the project root.
|
||||
dry_run (bool): If True, print what would be removed instead of performing deletions.
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
True if the command completed.
|
||||
"""
|
||||
from core.config import get_worktree_base_path
|
||||
|
||||
specs_dir = Path(project_dir) / ".auto-claude" / "specs"
|
||||
worktrees_dir = Path(project_dir) / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktree_base_path = get_worktree_base_path(Path(project_dir))
|
||||
worktrees_dir = Path(project_dir) / worktree_base_path
|
||||
|
||||
if not specs_dir.exists():
|
||||
print_status("No specs directory found", "info")
|
||||
@@ -211,56 +216,8 @@ def handle_batch_cleanup_command(project_dir: str, dry_run: bool = True) -> bool
|
||||
print(f" - {spec_name}")
|
||||
wt_path = worktrees_dir / spec_name
|
||||
if wt_path.exists():
|
||||
print(f" └─ .auto-claude/worktrees/tasks/{spec_name}/")
|
||||
print(f" └─ {worktree_base_path}/{spec_name}/")
|
||||
print()
|
||||
print("Run with --no-dry-run to actually delete")
|
||||
else:
|
||||
# Actually delete specs and worktrees
|
||||
deleted_count = 0
|
||||
for spec_name in completed:
|
||||
spec_path = specs_dir / spec_name
|
||||
wt_path = worktrees_dir / spec_name
|
||||
|
||||
# Remove worktree first (if exists)
|
||||
if wt_path.exists():
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "worktree", "remove", "--force", str(wt_path)],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print_status(f"Removed worktree: {spec_name}", "success")
|
||||
else:
|
||||
# Fallback: remove directory manually if git fails
|
||||
shutil.rmtree(wt_path, ignore_errors=True)
|
||||
print_status(
|
||||
f"Removed worktree directory: {spec_name}", "success"
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
# Timeout: fall back to manual removal
|
||||
shutil.rmtree(wt_path, ignore_errors=True)
|
||||
print_status(
|
||||
f"Worktree removal timed out, removed directory: {spec_name}",
|
||||
"warning",
|
||||
)
|
||||
except Exception as e:
|
||||
print_status(
|
||||
f"Failed to remove worktree {spec_name}: {e}", "warning"
|
||||
)
|
||||
|
||||
# Remove spec directory
|
||||
if spec_path.exists():
|
||||
try:
|
||||
shutil.rmtree(spec_path)
|
||||
print_status(f"Removed spec: {spec_name}", "success")
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
print_status(f"Failed to remove spec {spec_name}: {e}", "error")
|
||||
|
||||
print()
|
||||
print_status(f"Cleaned up {deleted_count} spec(s)", "info")
|
||||
|
||||
return True
|
||||
return True
|
||||
@@ -79,7 +79,7 @@ def handle_build_command(
|
||||
base_branch: Base branch for worktree creation (default: current branch)
|
||||
"""
|
||||
# Lazy imports to avoid loading heavy modules
|
||||
from agent import run_autonomous_agent, sync_spec_to_source
|
||||
from agent import run_autonomous_agent, sync_plan_to_source
|
||||
from debug import (
|
||||
debug,
|
||||
debug_info,
|
||||
@@ -87,10 +87,6 @@ def handle_build_command(
|
||||
debug_success,
|
||||
)
|
||||
from phase_config import get_phase_model
|
||||
from prompts_pkg.prompts import (
|
||||
get_base_branch_from_metadata,
|
||||
get_use_local_branch_from_metadata,
|
||||
)
|
||||
from qa_loop import run_qa_validation_loop, should_run_qa
|
||||
|
||||
from .utils import print_banner, validate_environment
|
||||
@@ -198,17 +194,6 @@ def handle_build_command(
|
||||
auto_continue=auto_continue,
|
||||
)
|
||||
|
||||
# If base_branch not provided via CLI, try to read from task_metadata.json
|
||||
# This ensures the backend uses the branch configured in the frontend
|
||||
if base_branch is None:
|
||||
metadata_branch = get_base_branch_from_metadata(spec_dir)
|
||||
if metadata_branch:
|
||||
base_branch = metadata_branch
|
||||
debug("run.py", f"Using base branch from task metadata: {base_branch}")
|
||||
|
||||
# Check if user requested local branch (preserves gitignored files like .env)
|
||||
use_local_branch = get_use_local_branch_from_metadata(spec_dir)
|
||||
|
||||
if workspace_mode == WorkspaceMode.ISOLATED:
|
||||
# Keep reference to original spec directory for syncing progress back
|
||||
source_spec_dir = spec_dir
|
||||
@@ -219,7 +204,6 @@ def handle_build_command(
|
||||
workspace_mode,
|
||||
source_spec_dir=spec_dir,
|
||||
base_branch=base_branch,
|
||||
use_local_branch=use_local_branch,
|
||||
)
|
||||
# Use the localized spec directory (inside worktree) for AI access
|
||||
if localized_spec_dir:
|
||||
@@ -290,7 +274,7 @@ def handle_build_command(
|
||||
|
||||
# Sync implementation plan to main project after QA
|
||||
# This ensures the main project has the latest status (human_review)
|
||||
if sync_spec_to_source(spec_dir, source_spec_dir):
|
||||
if sync_plan_to_source(spec_dir, source_spec_dir):
|
||||
debug_info(
|
||||
"run.py", "Implementation plan synced to main project after QA"
|
||||
)
|
||||
@@ -428,7 +412,7 @@ def _handle_build_interrupt(
|
||||
if human_input:
|
||||
# Save to HUMAN_INPUT.md
|
||||
input_file = spec_dir / "HUMAN_INPUT.md"
|
||||
input_file.write_text(human_input, encoding="utf-8")
|
||||
input_file.write_text(human_input)
|
||||
|
||||
content = [
|
||||
success(f"{icon(Icons.SUCCESS)} INSTRUCTIONS SAVED"),
|
||||
|
||||
@@ -121,7 +121,7 @@ def collect_followup_task(spec_dir: Path, max_retries: int = 3) -> str | None:
|
||||
# Expand ~ and resolve path
|
||||
file_path = Path(file_path_str).expanduser().resolve()
|
||||
if file_path.exists():
|
||||
followup_task = file_path.read_text(encoding="utf-8").strip()
|
||||
followup_task = file_path.read_text().strip()
|
||||
if followup_task:
|
||||
print_status(
|
||||
f"Loaded {len(followup_task)} characters from file",
|
||||
@@ -195,7 +195,7 @@ def collect_followup_task(spec_dir: Path, max_retries: int = 3) -> str | None:
|
||||
|
||||
# Save to FOLLOWUP_REQUEST.md
|
||||
request_file = spec_dir / "FOLLOWUP_REQUEST.md"
|
||||
request_file.write_text(followup_task, encoding="utf-8")
|
||||
request_file.write_text(followup_task)
|
||||
|
||||
# Show confirmation
|
||||
content = [
|
||||
@@ -285,7 +285,7 @@ def handle_followup_command(
|
||||
# Check for prior follow-ups (for sequential follow-up context)
|
||||
prior_followup_count = 0
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
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)
|
||||
|
||||
@@ -149,7 +149,7 @@ def read_from_file() -> str | None:
|
||||
# Expand ~ and resolve path
|
||||
file_path = Path(file_path_input).expanduser().resolve()
|
||||
if file_path.exists():
|
||||
content = file_path.read_text(encoding="utf-8").strip()
|
||||
content = file_path.read_text().strip()
|
||||
if content:
|
||||
print_status(
|
||||
f"Loaded {len(content)} characters from file",
|
||||
|
||||
+18
-77
@@ -15,6 +15,8 @@ _PARENT_DIR = Path(__file__).parent.parent
|
||||
if str(_PARENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
from .batch_commands import (
|
||||
handle_batch_cleanup_command,
|
||||
@@ -38,7 +40,6 @@ from .utils import (
|
||||
)
|
||||
from .workspace_commands import (
|
||||
handle_cleanup_worktrees_command,
|
||||
handle_create_pr_command,
|
||||
handle_discard_command,
|
||||
handle_list_worktrees_command,
|
||||
handle_merge_command,
|
||||
@@ -74,12 +75,12 @@ Examples:
|
||||
python auto-claude/run.py --spec 001 --qa-status # Check QA validation status
|
||||
|
||||
Prerequisites:
|
||||
1. Authenticate: Run 'claude' and type '/login'
|
||||
2. Create a spec first: claude /spec
|
||||
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 (auto-detected from Keychain)
|
||||
Or authenticate via: claude → /login
|
||||
CLAUDE_CODE_OAUTH_TOKEN Your Claude Code OAuth token (required)
|
||||
Get it by running: claude setup-token
|
||||
AUTO_BUILD_MODEL Override default model (optional)
|
||||
""",
|
||||
)
|
||||
@@ -154,30 +155,6 @@ Environment Variables:
|
||||
action="store_true",
|
||||
help="Discard an existing build (requires confirmation)",
|
||||
)
|
||||
build_group.add_argument(
|
||||
"--create-pr",
|
||||
action="store_true",
|
||||
help="Push branch and create a GitHub Pull Request",
|
||||
)
|
||||
|
||||
# PR options
|
||||
parser.add_argument(
|
||||
"--pr-target",
|
||||
type=str,
|
||||
metavar="BRANCH",
|
||||
help="With --create-pr: target branch for PR (default: auto-detect)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pr-title",
|
||||
type=str,
|
||||
metavar="TITLE",
|
||||
help="With --create-pr: custom PR title (default: generated from spec name)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pr-draft",
|
||||
action="store_true",
|
||||
help="With --create-pr: create as draft PR",
|
||||
)
|
||||
|
||||
# Merge options
|
||||
parser.add_argument(
|
||||
@@ -284,32 +261,14 @@ Environment Variables:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Main CLI entry point."""
|
||||
"""
|
||||
Entry point for the CLI: sets up the environment, parses arguments, and dispatches the requested command.
|
||||
|
||||
This function initializes runtime environment and debugging, resolves the project directory (and loads a project-specific .auto-claude/.env file if present), determines the model choice from the CLI or the AUTO_BUILD_MODEL environment variable, and routes control to the appropriate handler based on parsed CLI flags (examples include listing specs, worktree management, batch operations, merge/preview/review/discard flows, QA and follow-up commands, or the normal build flow). Exits the process with a non-zero status when required by invalid input or failing command outcomes.
|
||||
"""
|
||||
# Set up environment first
|
||||
setup_environment()
|
||||
|
||||
# Initialize Sentry early to capture any startup errors
|
||||
from core.sentry import capture_exception, init_sentry
|
||||
|
||||
init_sentry(component="cli")
|
||||
|
||||
try:
|
||||
_run_cli()
|
||||
except KeyboardInterrupt:
|
||||
# Clean exit on Ctrl+C
|
||||
sys.exit(130)
|
||||
except Exception as e:
|
||||
# Capture unexpected errors to Sentry
|
||||
capture_exception(e)
|
||||
print(f"\nUnexpected error: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _run_cli() -> None:
|
||||
"""Run the CLI logic (extracted for error handling)."""
|
||||
# Import here to avoid import errors during startup
|
||||
from core.sentry import set_context
|
||||
|
||||
# Parse arguments
|
||||
args = parse_args()
|
||||
|
||||
@@ -323,6 +282,12 @@ def _run_cli() -> None:
|
||||
project_dir = get_project_dir(args.project_dir)
|
||||
debug("run.py", f"Using project directory: {project_dir}")
|
||||
|
||||
# Load project-specific .env file (overrides backend .env)
|
||||
project_env = project_dir / ".auto-claude" / ".env"
|
||||
if project_env.exists():
|
||||
load_dotenv(project_env, override=True)
|
||||
debug("run.py", f"Loaded project .env from: {project_env}")
|
||||
|
||||
# Get model from CLI arg or env var (None if not explicitly set)
|
||||
# This allows get_phase_model() to fall back to task_metadata.json
|
||||
model = args.model or os.environ.get("AUTO_BUILD_MODEL")
|
||||
@@ -380,15 +345,6 @@ def _run_cli() -> None:
|
||||
|
||||
debug_success("run.py", "Spec found", spec_dir=str(spec_dir))
|
||||
|
||||
# Set Sentry context for error tracking
|
||||
set_context(
|
||||
"spec",
|
||||
{
|
||||
"name": spec_dir.name,
|
||||
"project": str(project_dir),
|
||||
},
|
||||
)
|
||||
|
||||
# Handle build management commands
|
||||
if args.merge_preview:
|
||||
from cli.workspace_commands import handle_merge_preview_command
|
||||
@@ -421,21 +377,6 @@ def _run_cli() -> None:
|
||||
handle_discard_command(project_dir, spec_dir.name)
|
||||
return
|
||||
|
||||
if args.create_pr:
|
||||
# Pass args.pr_target directly - WorktreeManager._detect_base_branch
|
||||
# handles base branch detection internally when target_branch is None
|
||||
result = handle_create_pr_command(
|
||||
project_dir=project_dir,
|
||||
spec_name=spec_dir.name,
|
||||
target_branch=args.pr_target,
|
||||
title=args.pr_title,
|
||||
draft=args.pr_draft,
|
||||
)
|
||||
# JSON output is already printed by handle_create_pr_command
|
||||
if not result.get("success"):
|
||||
sys.exit(1)
|
||||
return
|
||||
|
||||
# Handle QA commands
|
||||
if args.qa_status:
|
||||
handle_qa_status_command(spec_dir)
|
||||
@@ -481,4 +422,4 @@ def _run_cli() -> None:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
main()
|
||||
@@ -1,217 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
JSON Recovery Utility
|
||||
=====================
|
||||
|
||||
Detects and repairs corrupted JSON files in specs directories.
|
||||
|
||||
Usage:
|
||||
python -m cli.recovery --project-dir /path/to/project --detect
|
||||
python -m cli.recovery --project-dir /path/to/project --spec-id 004-feature --delete
|
||||
python -m cli.recovery --project-dir /path/to/project --all --delete
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
from cli.utils import find_specs_dir
|
||||
|
||||
|
||||
def check_json_file(filepath: Path) -> tuple[bool, str | None]:
|
||||
"""
|
||||
Check if a JSON file is valid.
|
||||
|
||||
Returns:
|
||||
(is_valid, error_message)
|
||||
"""
|
||||
try:
|
||||
with open(filepath, encoding="utf-8") as f:
|
||||
json.load(f)
|
||||
return True, None
|
||||
except json.JSONDecodeError as e:
|
||||
return False, str(e)
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def detect_corrupted_files(specs_dir: Path) -> list[tuple[Path, str]]:
|
||||
"""
|
||||
Scan specs directory recursively for corrupted JSON files.
|
||||
|
||||
Returns:
|
||||
List of (filepath, error_message) tuples
|
||||
"""
|
||||
corrupted = []
|
||||
|
||||
if not specs_dir.exists():
|
||||
return corrupted
|
||||
|
||||
# Recursively scan for JSON files (includes nested files like memory/*.json)
|
||||
for json_file in specs_dir.rglob("*.json"):
|
||||
is_valid, error = check_json_file(json_file)
|
||||
if not is_valid:
|
||||
# Type narrowing: error is str when is_valid is False
|
||||
assert error is not None
|
||||
corrupted.append((json_file, error))
|
||||
|
||||
return corrupted
|
||||
|
||||
|
||||
def backup_corrupted_file(filepath: Path) -> bool:
|
||||
"""
|
||||
Backup a corrupted file by renaming it with a .corrupted suffix.
|
||||
|
||||
Args:
|
||||
filepath: Path to the corrupted file
|
||||
|
||||
Returns:
|
||||
True if backed up successfully, False otherwise
|
||||
"""
|
||||
try:
|
||||
# Create backup before deleting
|
||||
base_backup_path = filepath.with_suffix(f"{filepath.suffix}.corrupted")
|
||||
backup_path = base_backup_path
|
||||
|
||||
# Handle existing backup files by generating unique name with UUID
|
||||
if backup_path.exists():
|
||||
# Use UUID for unique naming to avoid races
|
||||
unique_suffix = uuid.uuid4().hex[:8]
|
||||
backup_path = filepath.with_suffix(
|
||||
f"{filepath.suffix}.corrupted.{unique_suffix}"
|
||||
)
|
||||
|
||||
filepath.rename(backup_path)
|
||||
print(f" [BACKUP] Moved corrupted file to: {backup_path}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" [ERROR] Failed to backup file: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Detect and repair corrupted JSON files in specs directories"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--project-dir",
|
||||
type=Path,
|
||||
default=Path.cwd(),
|
||||
help="Project directory (default: current directory)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--specs-dir",
|
||||
type=Path,
|
||||
help="Specs directory path (overrides auto-detection)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--detect",
|
||||
action="store_true",
|
||||
help="Detect corrupted JSON files",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--spec-id",
|
||||
type=str,
|
||||
help="Specific spec ID to fix (e.g., 004-feature)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--delete",
|
||||
action="store_true",
|
||||
help="Delete corrupted files (creates .corrupted backup)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="Fix all corrupted files (requires --delete)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate --all requires --delete
|
||||
if args.all and not args.delete:
|
||||
parser.error("--all requires --delete")
|
||||
|
||||
# Find specs directory
|
||||
if args.specs_dir:
|
||||
specs_dir = args.specs_dir
|
||||
else:
|
||||
specs_dir = find_specs_dir(args.project_dir)
|
||||
|
||||
print(f"[INFO] Scanning specs directory: {specs_dir}")
|
||||
|
||||
# Default to detect mode if no flags provided
|
||||
if not args.detect and not args.delete:
|
||||
args.detect = True
|
||||
|
||||
# Detect corrupted files (dry-run when detect-only, otherwise for deletion)
|
||||
corrupted = detect_corrupted_files(specs_dir)
|
||||
|
||||
# Detect-only mode: show results and exit
|
||||
if args.detect and not args.delete:
|
||||
if not corrupted:
|
||||
print("[OK] No corrupted JSON files found")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"\n[FOUND] {len(corrupted)} corrupted file(s):\n")
|
||||
for filepath, error in corrupted:
|
||||
print(f" - {filepath.relative_to(specs_dir.parent)}")
|
||||
print(f" Error: {error}")
|
||||
print()
|
||||
# Exit with error code when corrupted files are found
|
||||
sys.exit(1)
|
||||
|
||||
# Delete corrupted files
|
||||
if args.delete:
|
||||
if args.spec_id:
|
||||
# Delete specific spec
|
||||
spec_dir = (specs_dir / args.spec_id).resolve()
|
||||
specs_dir_resolved = specs_dir.resolve()
|
||||
# Validate path doesn't escape specs directory
|
||||
if not spec_dir.is_relative_to(specs_dir_resolved):
|
||||
print("[ERROR] Invalid spec ID: path traversal detected")
|
||||
sys.exit(1)
|
||||
|
||||
if not spec_dir.exists():
|
||||
print(f"[ERROR] Spec directory not found: {spec_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"[INFO] Processing spec: {args.spec_id}")
|
||||
has_failures = False
|
||||
for json_file in spec_dir.rglob("*.json"):
|
||||
is_valid, error = check_json_file(json_file)
|
||||
if not is_valid:
|
||||
print(f" [CORRUPTED] {json_file.name}")
|
||||
if not backup_corrupted_file(json_file):
|
||||
has_failures = True
|
||||
|
||||
if has_failures:
|
||||
sys.exit(1)
|
||||
|
||||
elif args.all:
|
||||
# Delete all corrupted files
|
||||
# Use the already-detected corrupted list, or re-scan if needed
|
||||
if not corrupted:
|
||||
corrupted = detect_corrupted_files(specs_dir)
|
||||
if not corrupted:
|
||||
print("[OK] No corrupted files to delete")
|
||||
sys.exit(0)
|
||||
|
||||
print(f"\n[INFO] Backing up {len(corrupted)} corrupted file(s):\n")
|
||||
has_failures = False
|
||||
for filepath, _ in corrupted:
|
||||
# backup_corrupted_file prints its own [BACKUP] message
|
||||
if not backup_corrupted_file(filepath):
|
||||
has_failures = True
|
||||
|
||||
if has_failures:
|
||||
sys.exit(1)
|
||||
|
||||
else:
|
||||
print("[ERROR] Must specify --spec-id or --all with --delete")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -15,50 +15,8 @@ if str(_PARENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
from core.auth import get_auth_token, get_auth_token_source
|
||||
from core.dependency_validator import validate_platform_dependencies
|
||||
|
||||
|
||||
def import_dotenv():
|
||||
"""
|
||||
Import and return load_dotenv with helpful error message if not installed.
|
||||
|
||||
This centralized function ensures consistent error messaging across all
|
||||
runner scripts when python-dotenv is not available.
|
||||
|
||||
Returns:
|
||||
The load_dotenv function
|
||||
|
||||
Raises:
|
||||
SystemExit: If dotenv cannot be imported, with helpful installation instructions.
|
||||
"""
|
||||
try:
|
||||
from dotenv import load_dotenv as _load_dotenv
|
||||
|
||||
return _load_dotenv
|
||||
except ImportError:
|
||||
sys.exit(
|
||||
"Error: Required Python package 'python-dotenv' is not installed.\n"
|
||||
"\n"
|
||||
"This usually means you're not using the virtual environment.\n"
|
||||
"\n"
|
||||
"To fix this:\n"
|
||||
"1. From the 'apps/backend/' directory, activate the venv:\n"
|
||||
" source .venv/bin/activate # Linux/macOS\n"
|
||||
" .venv\\Scripts\\activate # Windows\n"
|
||||
"\n"
|
||||
"2. Or install dependencies directly:\n"
|
||||
" pip install python-dotenv\n"
|
||||
" pip install -r requirements.txt\n"
|
||||
"\n"
|
||||
f"Current Python: {sys.executable}\n"
|
||||
)
|
||||
|
||||
|
||||
# Load .env with helpful error if dependencies not installed
|
||||
load_dotenv = import_dotenv()
|
||||
# NOTE: graphiti_config is imported lazily in validate_environment() to avoid
|
||||
# triggering graphiti_core -> real_ladybug -> pywintypes import chain before
|
||||
# platform dependency validation can run. See ACS-253.
|
||||
from dotenv import load_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
|
||||
@@ -70,8 +28,8 @@ from ui import (
|
||||
muted,
|
||||
)
|
||||
|
||||
# Configuration - uses shorthand that resolves via API Profile if configured
|
||||
DEFAULT_MODEL = "sonnet" # Changed from "opus" (fix #433)
|
||||
# Configuration
|
||||
DEFAULT_MODEL = "claude-opus-4-5-20251101"
|
||||
|
||||
|
||||
def setup_environment() -> Path:
|
||||
@@ -124,7 +82,7 @@ def find_spec(project_dir: Path, spec_identifier: str) -> Path | None:
|
||||
return spec_folder
|
||||
|
||||
# Check worktree specs (for merge-preview, merge, review, discard operations)
|
||||
worktree_base = project_dir / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktree_base = project_dir / ".worktrees"
|
||||
if worktree_base.exists():
|
||||
# Try exact match in worktree
|
||||
worktree_spec = (
|
||||
@@ -157,9 +115,6 @@ def validate_environment(spec_dir: Path) -> bool:
|
||||
Returns:
|
||||
True if valid, False otherwise (with error messages printed)
|
||||
"""
|
||||
# Validate platform-specific dependencies first (exits if missing)
|
||||
validate_platform_dependencies()
|
||||
|
||||
valid = True
|
||||
|
||||
# Check for OAuth token (API keys are not supported)
|
||||
@@ -207,9 +162,6 @@ def validate_environment(spec_dir: Path) -> bool:
|
||||
print("Linear integration: DISABLED (set LINEAR_API_KEY to enable)")
|
||||
|
||||
# Check Graphiti integration (optional but show status)
|
||||
# Lazy import to avoid triggering pywintypes import before validation (ACS-253)
|
||||
from graphiti_config import get_graphiti_status
|
||||
|
||||
graphiti_status = get_graphiti_status()
|
||||
if graphiti_status["available"]:
|
||||
print("Graphiti memory: ENABLED")
|
||||
@@ -260,19 +212,3 @@ def get_project_dir(provided_dir: Path | None) -> Path:
|
||||
project_dir = project_dir.parent.parent
|
||||
|
||||
return project_dir
|
||||
|
||||
|
||||
def find_specs_dir(project_dir: Path) -> Path:
|
||||
"""
|
||||
Find the specs directory for a project.
|
||||
|
||||
Returns the '.auto-claude/specs' directory path.
|
||||
The directory is guaranteed to exist (get_specs_dir calls init_auto_claude_dir).
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
|
||||
Returns:
|
||||
Path to specs directory (always returns a valid Path)
|
||||
"""
|
||||
return get_specs_dir(project_dir)
|
||||
|
||||
@@ -5,7 +5,6 @@ Workspace Commands
|
||||
CLI commands for workspace management (merge, review, discard, list, cleanup)
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -23,8 +22,6 @@ from core.workspace.git_utils import (
|
||||
get_merge_base,
|
||||
is_lock_file,
|
||||
)
|
||||
from core.worktree import PushAndCreatePRResult as CreatePRResult
|
||||
from core.worktree import WorktreeManager
|
||||
from debug import debug_warning
|
||||
from ui import (
|
||||
Icons,
|
||||
@@ -33,7 +30,6 @@ from ui import (
|
||||
from workspace import (
|
||||
cleanup_all_worktrees,
|
||||
discard_existing_build,
|
||||
get_existing_build_worktree,
|
||||
list_all_worktrees,
|
||||
merge_existing_build,
|
||||
review_existing_build,
|
||||
@@ -71,7 +67,6 @@ def _detect_default_branch(project_dir: Path) -> str:
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return env_branch
|
||||
@@ -83,7 +78,6 @@ def _detect_default_branch(project_dir: Path) -> str:
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return branch
|
||||
@@ -96,32 +90,18 @@ def _get_changed_files_from_git(
|
||||
worktree_path: Path, base_branch: str = "main"
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get list of files changed by the task (not files changed on base branch).
|
||||
|
||||
Uses merge-base to accurately identify only the files modified in the worktree,
|
||||
not files that changed on the base branch since the worktree was created.
|
||||
Get list of changed files from git diff between base branch and HEAD.
|
||||
|
||||
Args:
|
||||
worktree_path: Path to the worktree
|
||||
base_branch: Base branch to compare against (default: main)
|
||||
|
||||
Returns:
|
||||
List of changed file paths (task changes only)
|
||||
List of changed file paths
|
||||
"""
|
||||
try:
|
||||
# First, get the merge-base (the point where the worktree branched)
|
||||
merge_base_result = subprocess.run(
|
||||
["git", "merge-base", base_branch, "HEAD"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
merge_base = merge_base_result.stdout.strip()
|
||||
|
||||
# Use two-dot diff from merge-base to get only task's changes
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", f"{merge_base}..HEAD"],
|
||||
["git", "diff", "--name-only", f"{base_branch}...HEAD"],
|
||||
cwd=worktree_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -133,10 +113,10 @@ def _get_changed_files_from_git(
|
||||
# Log the failure before trying fallback
|
||||
debug_warning(
|
||||
"workspace_commands",
|
||||
f"git diff with merge-base failed: returncode={e.returncode}, "
|
||||
f"git diff (three-dot) failed: returncode={e.returncode}, "
|
||||
f"stderr={e.stderr.strip() if e.stderr else 'N/A'}",
|
||||
)
|
||||
# Fallback: try direct two-arg diff (less accurate but works)
|
||||
# Fallback: try without the three-dot notation
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", base_branch, "HEAD"],
|
||||
@@ -151,176 +131,12 @@ def _get_changed_files_from_git(
|
||||
# Log the failure before returning empty list
|
||||
debug_warning(
|
||||
"workspace_commands",
|
||||
f"git diff (fallback) failed: returncode={e.returncode}, "
|
||||
f"git diff (two-arg) failed: returncode={e.returncode}, "
|
||||
f"stderr={e.stderr.strip() if e.stderr else 'N/A'}",
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
def _detect_worktree_base_branch(
|
||||
project_dir: Path,
|
||||
worktree_path: Path,
|
||||
spec_name: str,
|
||||
) -> str | None:
|
||||
"""
|
||||
Detect which branch a worktree was created from.
|
||||
|
||||
Tries multiple strategies:
|
||||
1. Check worktree config file (.auto-claude/worktree-config.json)
|
||||
2. Find merge-base with known branches (develop, main, master)
|
||||
3. Return None if unable to detect
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
worktree_path: Path to the worktree
|
||||
spec_name: Name of the spec
|
||||
|
||||
Returns:
|
||||
The detected base branch name, or None if unable to detect
|
||||
"""
|
||||
# Strategy 1: Check for worktree config file
|
||||
config_path = worktree_path / ".auto-claude" / "worktree-config.json"
|
||||
if config_path.exists():
|
||||
try:
|
||||
config = json.loads(config_path.read_text(encoding="utf-8"))
|
||||
if config.get("base_branch"):
|
||||
debug(
|
||||
MODULE,
|
||||
f"Found base branch in worktree config: {config['base_branch']}",
|
||||
)
|
||||
return config["base_branch"]
|
||||
except Exception as e:
|
||||
debug_warning(MODULE, f"Failed to read worktree config: {e}")
|
||||
|
||||
# Strategy 2: Find which branch has the closest merge-base
|
||||
# Check common branches: develop, main, master
|
||||
spec_branch = f"auto-claude/{spec_name}"
|
||||
candidate_branches = ["develop", "main", "master"]
|
||||
|
||||
best_branch = None
|
||||
best_commits_behind = float("inf")
|
||||
|
||||
for branch in candidate_branches:
|
||||
try:
|
||||
# Check if branch exists
|
||||
check = subprocess.run(
|
||||
["git", "rev-parse", "--verify", branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if check.returncode != 0:
|
||||
continue
|
||||
|
||||
# Get merge base
|
||||
merge_base_result = subprocess.run(
|
||||
["git", "merge-base", branch, spec_branch],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if merge_base_result.returncode != 0:
|
||||
continue
|
||||
|
||||
merge_base = merge_base_result.stdout.strip()
|
||||
|
||||
# Count commits between merge-base and branch tip
|
||||
# The branch with fewer commits ahead is likely the one we branched from
|
||||
ahead_result = subprocess.run(
|
||||
["git", "rev-list", "--count", f"{merge_base}..{branch}"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if ahead_result.returncode == 0:
|
||||
commits_ahead = int(ahead_result.stdout.strip())
|
||||
debug(
|
||||
MODULE,
|
||||
f"Branch {branch} is {commits_ahead} commits ahead of merge-base",
|
||||
)
|
||||
if commits_ahead < best_commits_behind:
|
||||
best_commits_behind = commits_ahead
|
||||
best_branch = branch
|
||||
except Exception as e:
|
||||
debug_warning(MODULE, f"Error checking branch {branch}: {e}")
|
||||
continue
|
||||
|
||||
if best_branch:
|
||||
debug(
|
||||
MODULE,
|
||||
f"Detected base branch from git history: {best_branch} (commits ahead: {best_commits_behind})",
|
||||
)
|
||||
return best_branch
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _detect_parallel_task_conflicts(
|
||||
project_dir: Path,
|
||||
current_task_id: str,
|
||||
current_task_files: list[str],
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Detect potential conflicts between this task and other active tasks.
|
||||
|
||||
Uses existing evolution data to check if any of this task's files
|
||||
have been modified by other active tasks. This is a lightweight check
|
||||
that doesn't require re-processing all files.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
current_task_id: ID of the current task
|
||||
current_task_files: Files modified by this task (from git diff)
|
||||
|
||||
Returns:
|
||||
List of conflict dictionaries with 'file' and 'tasks' keys
|
||||
"""
|
||||
try:
|
||||
from merge import MergeOrchestrator
|
||||
|
||||
# Initialize orchestrator just to access evolution data
|
||||
orchestrator = MergeOrchestrator(
|
||||
project_dir,
|
||||
enable_ai=False,
|
||||
dry_run=True,
|
||||
)
|
||||
|
||||
# Get all active tasks from evolution data
|
||||
active_tasks = orchestrator.evolution_tracker.get_active_tasks()
|
||||
|
||||
# Remove current task from active tasks
|
||||
other_active_tasks = active_tasks - {current_task_id}
|
||||
|
||||
if not other_active_tasks:
|
||||
return []
|
||||
|
||||
# Convert current task files to a set for fast lookup
|
||||
current_files_set = set(current_task_files)
|
||||
|
||||
# Get files modified by other active tasks
|
||||
conflicts = []
|
||||
other_task_files = orchestrator.evolution_tracker.get_files_modified_by_tasks(
|
||||
list(other_active_tasks)
|
||||
)
|
||||
|
||||
# Find intersection - files modified by both this task and other tasks
|
||||
for file_path, tasks in other_task_files.items():
|
||||
if file_path in current_files_set:
|
||||
# This file was modified by both current task and other task(s)
|
||||
all_tasks = [current_task_id] + tasks
|
||||
conflicts.append({"file": file_path, "tasks": all_tasks})
|
||||
|
||||
return conflicts
|
||||
|
||||
except Exception as e:
|
||||
# If anything fails, just return empty - parallel task detection is optional
|
||||
debug_warning(
|
||||
"workspace_commands",
|
||||
f"Parallel task conflict detection failed: {e}",
|
||||
)
|
||||
return []
|
||||
|
||||
|
||||
# Import debug utilities
|
||||
try:
|
||||
from debug import (
|
||||
@@ -536,178 +352,7 @@ def handle_cleanup_worktrees_command(project_dir: Path) -> None:
|
||||
cleanup_all_worktrees(project_dir, confirm=True)
|
||||
|
||||
|
||||
def _detect_conflict_scenario(
|
||||
project_dir: Path,
|
||||
conflicting_files: list[str],
|
||||
spec_branch: str,
|
||||
base_branch: str,
|
||||
) -> dict:
|
||||
"""
|
||||
Analyze conflicting files to determine the conflict scenario.
|
||||
|
||||
This helps distinguish between:
|
||||
- 'already_merged': Task changes already identical in target branch
|
||||
- 'superseded': Target has newer version of same feature
|
||||
- 'diverged': Standard diverged branches (AI can resolve)
|
||||
- 'normal_conflict': Actual conflicting changes
|
||||
|
||||
Returns dict with:
|
||||
- scenario: 'already_merged' | 'superseded' | 'diverged' | 'normal_conflict'
|
||||
- already_merged_files: files identical in task and target
|
||||
- details: additional context
|
||||
"""
|
||||
if not conflicting_files:
|
||||
return {
|
||||
"scenario": "normal_conflict",
|
||||
"already_merged_files": [],
|
||||
"details": "No conflicting files to analyze",
|
||||
}
|
||||
|
||||
already_merged_files = []
|
||||
superseded_files = []
|
||||
diverged_files = []
|
||||
|
||||
try:
|
||||
# Get the merge-base commit
|
||||
merge_base_result = subprocess.run(
|
||||
["git", "merge-base", 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 for conflict scenario detection"
|
||||
)
|
||||
return {
|
||||
"scenario": "normal_conflict",
|
||||
"already_merged_files": [],
|
||||
"details": "Could not determine merge base",
|
||||
}
|
||||
|
||||
merge_base = merge_base_result.stdout.strip()
|
||||
|
||||
for file_path in conflicting_files:
|
||||
try:
|
||||
# Get content from spec branch (task's changes)
|
||||
spec_content_result = subprocess.run(
|
||||
["git", "show", f"{spec_branch}:{file_path}"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
# Get content from base branch (target)
|
||||
base_content_result = subprocess.run(
|
||||
["git", "show", f"{base_branch}:{file_path}"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
# Get content from merge-base (original state)
|
||||
merge_base_content_result = subprocess.run(
|
||||
["git", "show", f"{merge_base}:{file_path}"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Check file existence in each ref
|
||||
spec_exists = spec_content_result.returncode == 0
|
||||
base_exists = base_content_result.returncode == 0
|
||||
merge_base_exists = merge_base_content_result.returncode == 0
|
||||
|
||||
if spec_exists and base_exists:
|
||||
spec_content = spec_content_result.stdout
|
||||
base_content = base_content_result.stdout
|
||||
|
||||
# If contents are identical, the changes are already merged
|
||||
if spec_content == base_content:
|
||||
already_merged_files.append(file_path)
|
||||
debug(
|
||||
MODULE,
|
||||
f"File {file_path}: already merged (identical content)",
|
||||
)
|
||||
elif merge_base_exists:
|
||||
merge_base_content = merge_base_content_result.stdout
|
||||
# If base has changed from merge_base but spec matches merge_base,
|
||||
# the task's changes are superseded by newer changes
|
||||
if spec_content == merge_base_content:
|
||||
superseded_files.append(file_path)
|
||||
debug(
|
||||
MODULE,
|
||||
f"File {file_path}: superseded (base has newer changes)",
|
||||
)
|
||||
else:
|
||||
diverged_files.append(file_path)
|
||||
debug(
|
||||
MODULE,
|
||||
f"File {file_path}: diverged (both branches modified)",
|
||||
)
|
||||
else:
|
||||
diverged_files.append(file_path)
|
||||
else:
|
||||
diverged_files.append(file_path)
|
||||
|
||||
except Exception as e:
|
||||
debug_warning(
|
||||
MODULE, f"Error analyzing file {file_path} for scenario: {e}"
|
||||
)
|
||||
diverged_files.append(file_path)
|
||||
|
||||
# Determine overall scenario based on dominant pattern
|
||||
total_files = len(conflicting_files)
|
||||
|
||||
if len(already_merged_files) == total_files:
|
||||
scenario = "already_merged"
|
||||
details = "All conflicting files have identical content in both branches"
|
||||
elif len(already_merged_files) > total_files / 2:
|
||||
scenario = "already_merged"
|
||||
details = f"{len(already_merged_files)} of {total_files} files already have the same content"
|
||||
elif len(superseded_files) == total_files:
|
||||
scenario = "superseded"
|
||||
details = "All task changes have been superseded by newer changes in the target branch"
|
||||
elif len(superseded_files) > total_files / 2:
|
||||
scenario = "superseded"
|
||||
details = (
|
||||
f"{len(superseded_files)} of {total_files} files have been superseded"
|
||||
)
|
||||
elif diverged_files:
|
||||
scenario = "diverged"
|
||||
details = f"{len(diverged_files)} files have diverged and need AI merge"
|
||||
else:
|
||||
scenario = "normal_conflict"
|
||||
details = "Standard merge conflicts detected"
|
||||
|
||||
debug(
|
||||
MODULE,
|
||||
f"Conflict scenario: {scenario}",
|
||||
already_merged=len(already_merged_files),
|
||||
superseded=len(superseded_files),
|
||||
diverged=len(diverged_files),
|
||||
)
|
||||
|
||||
return {
|
||||
"scenario": scenario,
|
||||
"already_merged_files": already_merged_files,
|
||||
"superseded_files": superseded_files,
|
||||
"diverged_files": diverged_files,
|
||||
"details": details,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
debug_error(MODULE, f"Error detecting conflict scenario: {e}")
|
||||
return {
|
||||
"scenario": "normal_conflict",
|
||||
"already_merged_files": [],
|
||||
"superseded_files": [],
|
||||
"diverged_files": [],
|
||||
"details": f"Error during analysis: {e}",
|
||||
}
|
||||
|
||||
|
||||
def _check_git_merge_conflicts(
|
||||
project_dir: Path, spec_name: str, base_branch: str | None = None
|
||||
) -> dict:
|
||||
def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
|
||||
"""
|
||||
Check for git-level merge conflicts WITHOUT modifying the working directory.
|
||||
|
||||
@@ -717,7 +362,6 @@ def _check_git_merge_conflicts(
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_name: Name of the spec
|
||||
base_branch: Branch the task was created from (default: auto-detect)
|
||||
|
||||
Returns:
|
||||
Dictionary with git conflict information:
|
||||
@@ -736,25 +380,21 @@ def _check_git_merge_conflicts(
|
||||
"has_conflicts": False,
|
||||
"conflicting_files": [],
|
||||
"needs_rebase": False,
|
||||
"base_branch": base_branch or "main",
|
||||
"base_branch": "main",
|
||||
"spec_branch": spec_branch,
|
||||
"commits_behind": 0,
|
||||
}
|
||||
|
||||
try:
|
||||
# Use provided base_branch, or detect from current HEAD
|
||||
if not base_branch:
|
||||
base_result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if base_result.returncode == 0:
|
||||
result["base_branch"] = base_result.stdout.strip()
|
||||
else:
|
||||
result["base_branch"] = base_branch
|
||||
debug(MODULE, f"Using provided base branch: {base_branch}")
|
||||
# Get the current branch (base branch)
|
||||
base_result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if base_result.returncode == 0:
|
||||
result["base_branch"] = base_result.stdout.strip()
|
||||
|
||||
# Get the merge base commit
|
||||
merge_base_result = subprocess.run(
|
||||
@@ -913,6 +553,7 @@ def handle_merge_preview_command(
|
||||
spec_name=spec_name,
|
||||
)
|
||||
|
||||
from merge import MergeOrchestrator
|
||||
from workspace import get_existing_build_worktree
|
||||
|
||||
worktree_path = get_existing_build_worktree(project_dir, spec_name)
|
||||
@@ -939,32 +580,16 @@ def handle_merge_preview_command(
|
||||
}
|
||||
|
||||
try:
|
||||
# First, check for git-level conflicts (diverged branches)
|
||||
git_conflicts = _check_git_merge_conflicts(project_dir, spec_name)
|
||||
|
||||
# Determine the task's source branch (where the task was created from)
|
||||
# Priority:
|
||||
# 1. Provided base_branch (from task metadata)
|
||||
# 2. Detect from worktree's git history (find which branch it diverged from)
|
||||
# 3. Fall back to default branch detection (main/master)
|
||||
# Use provided base_branch (from task metadata), or fall back to detected default
|
||||
task_source_branch = base_branch
|
||||
if not task_source_branch:
|
||||
# Try to detect from worktree's git history
|
||||
task_source_branch = _detect_worktree_base_branch(
|
||||
project_dir, worktree_path, spec_name
|
||||
)
|
||||
if not task_source_branch:
|
||||
# Fall back to auto-detecting main/master
|
||||
# Auto-detect the default branch (main/master) that worktrees are typically created from
|
||||
task_source_branch = _detect_default_branch(project_dir)
|
||||
|
||||
debug(
|
||||
MODULE,
|
||||
f"Using task source branch: {task_source_branch}",
|
||||
provided=base_branch is not None,
|
||||
)
|
||||
|
||||
# Check for git-level conflicts (diverged branches) using the task's source branch
|
||||
git_conflicts = _check_git_merge_conflicts(
|
||||
project_dir, spec_name, base_branch=task_source_branch
|
||||
)
|
||||
|
||||
# Get actual changed files from git diff (this is the authoritative count)
|
||||
all_changed_files = _get_changed_files_from_git(
|
||||
worktree_path, task_source_branch
|
||||
@@ -975,39 +600,49 @@ def handle_merge_preview_command(
|
||||
changed_files=all_changed_files[:10], # Log first 10
|
||||
)
|
||||
|
||||
# OPTIMIZATION: Skip expensive refresh_from_git() and preview_merge() calls
|
||||
# For merge-preview, we only need to detect:
|
||||
# 1. Git conflicts (task vs base branch) - already calculated in _check_git_merge_conflicts()
|
||||
# 2. Parallel task conflicts (this task vs other active tasks)
|
||||
#
|
||||
# For parallel task detection, we just check if this task's files overlap
|
||||
# with files OTHER tasks have already recorded - no need to re-process all files.
|
||||
debug(MODULE, "Initializing MergeOrchestrator for preview...")
|
||||
|
||||
debug(MODULE, "Checking for parallel task conflicts (lightweight)...")
|
||||
|
||||
# Check for parallel task conflicts by looking at existing evolution data
|
||||
parallel_conflicts = _detect_parallel_task_conflicts(
|
||||
project_dir, spec_name, all_changed_files
|
||||
# Initialize the orchestrator
|
||||
orchestrator = MergeOrchestrator(
|
||||
project_dir,
|
||||
enable_ai=False, # Don't use AI for preview
|
||||
dry_run=True, # Don't write anything
|
||||
)
|
||||
|
||||
# Refresh evolution data from the worktree
|
||||
# Compare against the task's source branch (where the task was created from)
|
||||
debug(
|
||||
MODULE,
|
||||
f"Parallel task conflicts detected: {len(parallel_conflicts)}",
|
||||
conflicts=parallel_conflicts[:5] if parallel_conflicts else [],
|
||||
f"Refreshing evolution data from worktree: {worktree_path}",
|
||||
task_source_branch=task_source_branch,
|
||||
)
|
||||
orchestrator.evolution_tracker.refresh_from_git(
|
||||
spec_name, worktree_path, target_branch=task_source_branch
|
||||
)
|
||||
|
||||
# Build conflict list - start with parallel task conflicts
|
||||
# Get merge preview (semantic conflicts between parallel tasks)
|
||||
debug(MODULE, "Generating merge preview...")
|
||||
preview = orchestrator.preview_merge([spec_name])
|
||||
|
||||
# Transform semantic conflicts to UI-friendly format
|
||||
conflicts = []
|
||||
for pc in parallel_conflicts:
|
||||
for c in preview.get("conflicts", []):
|
||||
debug_verbose(
|
||||
MODULE,
|
||||
"Processing semantic conflict",
|
||||
file=c.get("file", ""),
|
||||
severity=c.get("severity", "unknown"),
|
||||
)
|
||||
conflicts.append(
|
||||
{
|
||||
"file": pc["file"],
|
||||
"location": "file-level",
|
||||
"tasks": pc["tasks"],
|
||||
"severity": "medium",
|
||||
"canAutoMerge": False,
|
||||
"strategy": None,
|
||||
"reason": f"File modified by multiple active tasks: {', '.join(pc['tasks'])}",
|
||||
"type": "parallel",
|
||||
"file": c.get("file", ""),
|
||||
"location": c.get("location", ""),
|
||||
"tasks": c.get("tasks", []),
|
||||
"severity": c.get("severity", "unknown"),
|
||||
"canAutoMerge": c.get("can_auto_merge", False),
|
||||
"strategy": c.get("strategy"),
|
||||
"reason": c.get("reason", ""),
|
||||
"type": "semantic",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1034,38 +669,19 @@ def handle_merge_preview_command(
|
||||
}
|
||||
)
|
||||
|
||||
summary = preview.get("summary", {})
|
||||
# Count only non-lock-file conflicts
|
||||
git_conflict_count = len(git_conflicts.get("conflicting_files", [])) - len(
|
||||
lock_files_excluded
|
||||
)
|
||||
# Calculate totals from our conflict lists (git conflicts + parallel conflicts)
|
||||
parallel_conflict_count = len(parallel_conflicts)
|
||||
total_conflicts = git_conflict_count + parallel_conflict_count
|
||||
conflict_files = git_conflict_count + parallel_conflict_count
|
||||
total_conflicts = summary.get("total_conflicts", 0) + git_conflict_count
|
||||
conflict_files = summary.get("conflict_files", 0) + git_conflict_count
|
||||
|
||||
# Filter lock files from the git conflicts list for the response
|
||||
non_lock_conflicting_files = [
|
||||
f for f in git_conflicts.get("conflicting_files", []) if not is_lock_file(f)
|
||||
]
|
||||
|
||||
# Detect conflict scenario (already_merged, superseded, diverged, normal_conflict)
|
||||
# This helps the UI show appropriate messaging and actions
|
||||
conflict_scenario = None
|
||||
if non_lock_conflicting_files:
|
||||
conflict_scenario = _detect_conflict_scenario(
|
||||
project_dir,
|
||||
non_lock_conflicting_files,
|
||||
git_conflicts["spec_branch"],
|
||||
git_conflicts["base_branch"],
|
||||
)
|
||||
debug(
|
||||
MODULE,
|
||||
f"Conflict scenario detected: {conflict_scenario.get('scenario')}",
|
||||
already_merged_files=len(
|
||||
conflict_scenario.get("already_merged_files", [])
|
||||
),
|
||||
)
|
||||
|
||||
# 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
|
||||
@@ -1139,23 +755,13 @@ def handle_merge_preview_command(
|
||||
# Path-mapped files that need AI merge due to renames
|
||||
"pathMappedAIMerges": path_mapped_ai_merges,
|
||||
"totalRenames": len(path_mappings),
|
||||
# Conflict scenario detection for better UX messaging
|
||||
"scenario": conflict_scenario.get("scenario")
|
||||
if conflict_scenario
|
||||
else None,
|
||||
"alreadyMergedFiles": conflict_scenario.get("already_merged_files", [])
|
||||
if conflict_scenario
|
||||
else [],
|
||||
"scenarioMessage": conflict_scenario.get("details")
|
||||
if conflict_scenario
|
||||
else None,
|
||||
},
|
||||
"summary": {
|
||||
# Use git diff count, not semantic tracker count
|
||||
"totalFiles": total_files_from_git,
|
||||
"conflictFiles": conflict_files,
|
||||
"totalConflicts": total_conflicts,
|
||||
"autoMergeable": 0, # Not tracking auto-merge in lightweight mode
|
||||
"autoMergeable": summary.get("auto_mergeable", 0),
|
||||
"hasGitConflicts": git_conflicts["has_conflicts"]
|
||||
and len(non_lock_conflicting_files) > 0,
|
||||
# Include path-mapped AI merge count for UI display
|
||||
@@ -1170,9 +776,10 @@ def handle_merge_preview_command(
|
||||
"Merge preview complete",
|
||||
total_files=result["summary"]["totalFiles"],
|
||||
total_files_source="git_diff",
|
||||
semantic_tracked_files=summary.get("total_files", 0),
|
||||
total_conflicts=result["summary"]["totalConflicts"],
|
||||
has_git_conflicts=git_conflicts["has_conflicts"],
|
||||
parallel_conflicts=parallel_conflict_count,
|
||||
auto_mergeable=result["summary"]["autoMergeable"],
|
||||
path_mapped_ai_merges=len(path_mapped_ai_merges),
|
||||
total_renames=len(path_mappings),
|
||||
)
|
||||
@@ -1198,220 +805,3 @@ def handle_merge_preview_command(
|
||||
"pathMappedAIMergeCount": 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def handle_create_pr_command(
|
||||
project_dir: Path,
|
||||
spec_name: str,
|
||||
target_branch: str | None = None,
|
||||
title: str | None = None,
|
||||
draft: bool = False,
|
||||
) -> CreatePRResult:
|
||||
"""
|
||||
Handle the --create-pr command: push branch and create a GitHub PR.
|
||||
|
||||
Args:
|
||||
project_dir: Path to the project directory
|
||||
spec_name: Name of the spec (e.g., "001-feature-name")
|
||||
target_branch: Target branch for PR (defaults to base branch)
|
||||
title: Custom PR title (defaults to spec name)
|
||||
draft: Whether to create as draft PR
|
||||
|
||||
Returns:
|
||||
CreatePRResult with success status, pr_url, and any errors
|
||||
"""
|
||||
from core.worktree import WorktreeManager
|
||||
|
||||
print_banner()
|
||||
print("\n" + "=" * 70)
|
||||
print(" CREATE PULL REQUEST")
|
||||
print("=" * 70)
|
||||
|
||||
# Check if worktree exists
|
||||
worktree_path = get_existing_build_worktree(project_dir, spec_name)
|
||||
if not worktree_path:
|
||||
print(f"\n{icon(Icons.ERROR)} No build found for spec: {spec_name}")
|
||||
print("\nA completed build worktree is required to create a PR.")
|
||||
print("Run your build first, then use --create-pr.")
|
||||
error_result: CreatePRResult = {
|
||||
"success": False,
|
||||
"error": "No build found for this spec",
|
||||
}
|
||||
return error_result
|
||||
|
||||
# Create worktree manager
|
||||
manager = WorktreeManager(project_dir, base_branch=target_branch)
|
||||
|
||||
print(f"\n{icon(Icons.BRANCH)} Pushing branch and creating PR...")
|
||||
print(f" Spec: {spec_name}")
|
||||
print(f" Target: {target_branch or manager.base_branch}")
|
||||
if title:
|
||||
print(f" Title: {title}")
|
||||
if draft:
|
||||
print(" Mode: Draft PR")
|
||||
|
||||
# Push and create PR with exception handling for clean JSON output
|
||||
try:
|
||||
raw_result = manager.push_and_create_pr(
|
||||
spec_name=spec_name,
|
||||
target_branch=target_branch,
|
||||
title=title,
|
||||
draft=draft,
|
||||
)
|
||||
except Exception as e:
|
||||
debug_error(MODULE, f"Exception during PR creation: {e}")
|
||||
error_result: CreatePRResult = {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": "Failed to create PR",
|
||||
}
|
||||
print(f"\n{icon(Icons.ERROR)} Failed to create PR: {e}")
|
||||
print(json.dumps(error_result))
|
||||
return error_result
|
||||
|
||||
# Convert PushAndCreatePRResult to CreatePRResult
|
||||
result: CreatePRResult = {
|
||||
"success": raw_result.get("success", False),
|
||||
"pr_url": raw_result.get("pr_url"),
|
||||
"already_exists": raw_result.get("already_exists", False),
|
||||
"error": raw_result.get("error"),
|
||||
"message": raw_result.get("message"),
|
||||
"pushed": raw_result.get("pushed", False),
|
||||
"remote": raw_result.get("remote", ""),
|
||||
"branch": raw_result.get("branch", ""),
|
||||
}
|
||||
|
||||
if result.get("success"):
|
||||
pr_url = result.get("pr_url")
|
||||
already_exists = result.get("already_exists", False)
|
||||
|
||||
if already_exists:
|
||||
print(f"\n{icon(Icons.SUCCESS)} PR already exists!")
|
||||
else:
|
||||
print(f"\n{icon(Icons.SUCCESS)} PR created successfully!")
|
||||
|
||||
if pr_url:
|
||||
print(f"\n{icon(Icons.LINK)} {pr_url}")
|
||||
else:
|
||||
print(f"\n{icon(Icons.INFO)} Check GitHub for the PR URL")
|
||||
|
||||
print("\nNext steps:")
|
||||
print(" 1. Review the PR on GitHub")
|
||||
print(" 2. Request reviews from your team")
|
||||
print(" 3. Merge when approved")
|
||||
|
||||
# Output JSON for frontend parsing
|
||||
print(json.dumps(result))
|
||||
return result
|
||||
else:
|
||||
error = result.get("error", "Unknown error")
|
||||
print(f"\n{icon(Icons.ERROR)} Failed to create PR: {error}")
|
||||
# Output JSON for frontend parsing
|
||||
print(json.dumps(result))
|
||||
return result
|
||||
|
||||
|
||||
def cleanup_old_worktrees_command(
|
||||
project_dir: Path, days: int = 30, dry_run: bool = False
|
||||
) -> dict:
|
||||
"""
|
||||
Clean up old worktrees that haven't been modified in the specified number of days.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
days: Number of days threshold (default: 30)
|
||||
dry_run: If True, only show what would be removed (default: False)
|
||||
|
||||
Returns:
|
||||
Dictionary with cleanup results
|
||||
"""
|
||||
try:
|
||||
manager = WorktreeManager(project_dir)
|
||||
|
||||
removed, failed = manager.cleanup_old_worktrees(
|
||||
days_threshold=days, dry_run=dry_run
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"removed": removed,
|
||||
"failed": failed,
|
||||
"dry_run": dry_run,
|
||||
"days_threshold": days,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"removed": [],
|
||||
"failed": [],
|
||||
}
|
||||
|
||||
|
||||
def worktree_summary_command(project_dir: Path) -> dict:
|
||||
"""
|
||||
Get a summary of all worktrees with age information.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
|
||||
Returns:
|
||||
Dictionary with worktree summary data
|
||||
"""
|
||||
try:
|
||||
manager = WorktreeManager(project_dir)
|
||||
|
||||
# Print to console for CLI usage
|
||||
manager.print_worktree_summary()
|
||||
|
||||
# Also return data for programmatic access
|
||||
worktrees = manager.list_all_worktrees()
|
||||
warning = manager.get_worktree_count_warning()
|
||||
|
||||
# Categorize by age
|
||||
recent = []
|
||||
week_old = []
|
||||
month_old = []
|
||||
very_old = []
|
||||
unknown_age = []
|
||||
|
||||
for info in worktrees:
|
||||
data = {
|
||||
"spec_name": info.spec_name,
|
||||
"days_since_last_commit": info.days_since_last_commit,
|
||||
"commit_count": info.commit_count,
|
||||
}
|
||||
|
||||
if info.days_since_last_commit is None:
|
||||
unknown_age.append(data)
|
||||
elif info.days_since_last_commit < 7:
|
||||
recent.append(data)
|
||||
elif info.days_since_last_commit < 30:
|
||||
week_old.append(data)
|
||||
elif info.days_since_last_commit < 90:
|
||||
month_old.append(data)
|
||||
else:
|
||||
very_old.append(data)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"total_worktrees": len(worktrees),
|
||||
"categories": {
|
||||
"recent": recent,
|
||||
"week_old": week_old,
|
||||
"month_old": month_old,
|
||||
"very_old": very_old,
|
||||
"unknown_age": unknown_age,
|
||||
},
|
||||
"warning": warning,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"total_worktrees": 0,
|
||||
"categories": {},
|
||||
"warning": None,
|
||||
}
|
||||
|
||||
@@ -231,9 +231,7 @@ async def _call_claude(prompt: str) -> str:
|
||||
msg_type = type(msg).__name__
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
# Must check block type - only TextBlock has .text attribute
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
if hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
|
||||
logger.info(f"Generated commit message: {len(response_text)} chars")
|
||||
|
||||
@@ -37,12 +37,8 @@ class ContextBuilder:
|
||||
"""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():
|
||||
try:
|
||||
with open(index_file, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
# Corrupted or legacy-encoded file, regenerate
|
||||
pass
|
||||
with open(index_file) as f:
|
||||
return json.load(f)
|
||||
|
||||
# Try to create one
|
||||
from analyzer import analyze_project
|
||||
@@ -234,9 +230,7 @@ class ContextBuilder:
|
||||
if context_file.exists():
|
||||
return {
|
||||
"source": "SERVICE_CONTEXT.md",
|
||||
"content": context_file.read_text(encoding="utf-8")[
|
||||
:2000
|
||||
], # First 2000 chars
|
||||
"content": context_file.read_text()[:2000], # First 2000 chars
|
||||
}
|
||||
|
||||
# Generate basic context from service info
|
||||
|
||||
@@ -72,7 +72,7 @@ def build_task_context(
|
||||
|
||||
if output_file:
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
with open(output_file, "w") as f:
|
||||
json.dump(result, f, indent=2)
|
||||
print(f"Task context saved to: {output_file}")
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ class PatternDiscoverer:
|
||||
for match in reference_files[:max_files]:
|
||||
try:
|
||||
file_path = self.project_dir / match.path
|
||||
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
||||
content = file_path.read_text(errors="ignore")
|
||||
|
||||
# Look for common patterns
|
||||
for keyword in keywords:
|
||||
|
||||
@@ -41,7 +41,7 @@ class CodeSearcher:
|
||||
|
||||
for file_path in self._iter_code_files(service_path):
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8", errors="ignore")
|
||||
content = file_path.read_text(errors="ignore")
|
||||
content_lower = content.lower()
|
||||
|
||||
# Score this file
|
||||
|
||||
@@ -41,7 +41,7 @@ def save_context(context: TaskContext, output_file: Path) -> None:
|
||||
output_file: Path to output JSON file
|
||||
"""
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_file, "w", encoding="utf-8") as f:
|
||||
with open(output_file, "w") as f:
|
||||
json.dump(serialize_context(context), f, indent=2)
|
||||
|
||||
|
||||
@@ -55,5 +55,5 @@ def load_context(input_file: Path) -> dict:
|
||||
Returns:
|
||||
Context dictionary
|
||||
"""
|
||||
with open(input_file, encoding="utf-8") as f:
|
||||
with open(input_file) as f:
|
||||
return json.load(f)
|
||||
|
||||
@@ -39,7 +39,7 @@ from agents import (
|
||||
run_followup_planner,
|
||||
save_session_memory,
|
||||
save_session_to_graphiti,
|
||||
sync_spec_to_source,
|
||||
sync_plan_to_source,
|
||||
)
|
||||
|
||||
# Ensure all exports are available at module level
|
||||
@@ -57,7 +57,7 @@ __all__ = [
|
||||
"load_implementation_plan",
|
||||
"find_subtask_in_plan",
|
||||
"find_phase_for_subtask",
|
||||
"sync_spec_to_source",
|
||||
"sync_plan_to_source",
|
||||
"AUTO_CONTINUE_DELAY_SECONDS",
|
||||
"HUMAN_INTERVENTION_FILE",
|
||||
]
|
||||
|
||||
+47
-998
File diff suppressed because it is too large
Load Diff
+10
-191
@@ -12,119 +12,13 @@ The client factory now uses AGENT_CONFIGS from agents/tools_pkg/models.py as the
|
||||
single source of truth for phase-aware tool and MCP server configuration.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from core.platform import (
|
||||
is_windows,
|
||||
validate_cli_path,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# =============================================================================
|
||||
# Project Index Cache
|
||||
# =============================================================================
|
||||
# Caches project index and capabilities to avoid reloading on every create_client() call.
|
||||
# This significantly reduces the time to create new agent sessions.
|
||||
|
||||
_PROJECT_INDEX_CACHE: dict[str, tuple[dict[str, Any], dict[str, bool], float]] = {}
|
||||
_CACHE_TTL_SECONDS = 300 # 5 minute TTL
|
||||
_CACHE_LOCK = threading.Lock() # Protects _PROJECT_INDEX_CACHE access
|
||||
|
||||
|
||||
def _get_cached_project_data(
|
||||
project_dir: Path,
|
||||
) -> tuple[dict[str, Any], dict[str, bool]]:
|
||||
"""
|
||||
Get project index and capabilities with caching.
|
||||
|
||||
Args:
|
||||
project_dir: Path to the project directory
|
||||
|
||||
Returns:
|
||||
Tuple of (project_index, project_capabilities)
|
||||
"""
|
||||
|
||||
key = str(project_dir.resolve())
|
||||
now = time.time()
|
||||
debug = os.environ.get("DEBUG", "").lower() in ("true", "1")
|
||||
|
||||
# Check cache with lock
|
||||
with _CACHE_LOCK:
|
||||
if key in _PROJECT_INDEX_CACHE:
|
||||
cached_index, cached_capabilities, cached_time = _PROJECT_INDEX_CACHE[key]
|
||||
cache_age = now - cached_time
|
||||
if cache_age < _CACHE_TTL_SECONDS:
|
||||
if debug:
|
||||
print(
|
||||
f"[ClientCache] Cache HIT for project index (age: {cache_age:.1f}s / TTL: {_CACHE_TTL_SECONDS}s)"
|
||||
)
|
||||
logger.debug(f"Using cached project index for {project_dir}")
|
||||
# Return deep copies to prevent callers from corrupting the cache
|
||||
return copy.deepcopy(cached_index), copy.deepcopy(cached_capabilities)
|
||||
elif debug:
|
||||
print(
|
||||
f"[ClientCache] Cache EXPIRED for project index (age: {cache_age:.1f}s > TTL: {_CACHE_TTL_SECONDS}s)"
|
||||
)
|
||||
|
||||
# Cache miss or expired - load fresh data (outside lock to avoid blocking)
|
||||
load_start = time.time()
|
||||
logger.debug(f"Loading project index for {project_dir}")
|
||||
project_index = load_project_index(project_dir)
|
||||
project_capabilities = detect_project_capabilities(project_index)
|
||||
|
||||
if debug:
|
||||
load_duration = (time.time() - load_start) * 1000
|
||||
print(
|
||||
f"[ClientCache] Cache MISS - loaded project index in {load_duration:.1f}ms"
|
||||
)
|
||||
|
||||
# Store in cache with lock - use double-checked locking pattern
|
||||
# Re-check if another thread populated the cache while we were loading
|
||||
with _CACHE_LOCK:
|
||||
if key in _PROJECT_INDEX_CACHE:
|
||||
cached_index, cached_capabilities, cached_time = _PROJECT_INDEX_CACHE[key]
|
||||
cache_age = time.time() - cached_time
|
||||
if cache_age < _CACHE_TTL_SECONDS:
|
||||
# Another thread already cached valid data while we were loading
|
||||
if debug:
|
||||
print(
|
||||
"[ClientCache] Cache was populated by another thread, using cached data"
|
||||
)
|
||||
# Return deep copies to prevent callers from corrupting the cache
|
||||
return copy.deepcopy(cached_index), copy.deepcopy(cached_capabilities)
|
||||
# Either no cache entry or it's expired - store our fresh data
|
||||
_PROJECT_INDEX_CACHE[key] = (project_index, project_capabilities, time.time())
|
||||
|
||||
# Return the freshly loaded data (no need to copy since it's not from cache)
|
||||
return project_index, project_capabilities
|
||||
|
||||
|
||||
def invalidate_project_cache(project_dir: Path | None = None) -> None:
|
||||
"""
|
||||
Invalidate the project index cache.
|
||||
|
||||
Args:
|
||||
project_dir: Specific project to invalidate, or None to clear all
|
||||
"""
|
||||
with _CACHE_LOCK:
|
||||
if project_dir is None:
|
||||
_PROJECT_INDEX_CACHE.clear()
|
||||
logger.debug("Cleared all project index cache entries")
|
||||
else:
|
||||
key = str(project_dir.resolve())
|
||||
if key in _PROJECT_INDEX_CACHE:
|
||||
del _PROJECT_INDEX_CACHE[key]
|
||||
logger.debug(f"Invalidated project index cache for {project_dir}")
|
||||
|
||||
|
||||
from agents.tools_pkg import (
|
||||
CONTEXT7_TOOLS,
|
||||
ELECTRON_TOOLS,
|
||||
@@ -138,10 +32,7 @@ from agents.tools_pkg import (
|
||||
)
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
from claude_agent_sdk.types import HookMatcher
|
||||
from core.auth import (
|
||||
configure_sdk_authentication,
|
||||
get_sdk_env_vars,
|
||||
)
|
||||
from core.auth import get_sdk_env_vars, require_auth_token
|
||||
from linear_updater import is_linear_enabled
|
||||
from prompts_pkg.project_context import detect_project_capabilities, load_project_index
|
||||
from security import bash_security_hook
|
||||
@@ -489,25 +380,13 @@ def create_client(
|
||||
(see security.py for ALLOWED_COMMANDS)
|
||||
4. Tool filtering - Each agent type only sees relevant tools (prevents misuse)
|
||||
"""
|
||||
# Collect env vars to pass to SDK (ANTHROPIC_BASE_URL, CLAUDE_CONFIG_DIR, etc.)
|
||||
oauth_token = require_auth_token()
|
||||
# Ensure SDK can access it via its expected env var
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
|
||||
|
||||
# Collect env vars to pass to SDK (ANTHROPIC_BASE_URL, etc.)
|
||||
sdk_env = get_sdk_env_vars()
|
||||
|
||||
# Get the config dir for profile-specific credential lookup
|
||||
# CLAUDE_CONFIG_DIR enables per-profile Keychain entries with SHA256-hashed service names
|
||||
config_dir = sdk_env.get("CLAUDE_CONFIG_DIR")
|
||||
|
||||
# Configure SDK authentication (OAuth or API profile mode)
|
||||
configure_sdk_authentication(config_dir)
|
||||
|
||||
if config_dir:
|
||||
logger.info(f"Using CLAUDE_CONFIG_DIR for profile: {config_dir}")
|
||||
|
||||
# Debug: Log git-bash path detection on Windows
|
||||
if "CLAUDE_CODE_GIT_BASH_PATH" in sdk_env:
|
||||
logger.info(f"Git Bash path found: {sdk_env['CLAUDE_CODE_GIT_BASH_PATH']}")
|
||||
elif is_windows():
|
||||
logger.warning("Git Bash path not detected on Windows!")
|
||||
|
||||
# Check if Linear integration is enabled
|
||||
linear_enabled = is_linear_enabled()
|
||||
linear_api_key = os.environ.get("LINEAR_API_KEY", "")
|
||||
@@ -517,8 +396,8 @@ def create_client(
|
||||
|
||||
# Load project capabilities for dynamic MCP tool selection
|
||||
# This enables context-aware tool injection based on project type
|
||||
# Uses caching to avoid reloading on every create_client() call
|
||||
project_index, project_capabilities = _get_cached_project_data(project_dir)
|
||||
project_index = load_project_index(project_dir)
|
||||
project_capabilities = detect_project_capabilities(project_index)
|
||||
|
||||
# Load per-project MCP configuration from .auto-claude/.env
|
||||
mcp_config = load_project_mcp_config(project_dir)
|
||||
@@ -558,48 +437,6 @@ def create_client(
|
||||
# cases where Claude uses absolute paths for file operations
|
||||
project_path_str = str(project_dir.resolve())
|
||||
spec_path_str = str(spec_dir.resolve())
|
||||
|
||||
# Detect if we're running in a worktree and get the original project directory
|
||||
# Worktrees are located in either:
|
||||
# - .auto-claude/worktrees/tasks/{spec-name}/ (new location)
|
||||
# - .worktrees/{spec-name}/ (legacy location)
|
||||
# When running in a worktree, we need to allow access to both the worktree
|
||||
# and the original project's .auto-claude/ directory for spec files
|
||||
original_project_permissions = []
|
||||
resolved_project_path = project_dir.resolve()
|
||||
|
||||
# Check for worktree paths and extract original project directory
|
||||
# This handles spec worktrees, PR review worktrees, and legacy worktrees
|
||||
# Note: Windows paths are normalized to forward slashes before comparison
|
||||
worktree_markers = [
|
||||
"/.auto-claude/worktrees/tasks/", # Spec/task worktrees
|
||||
"/.auto-claude/github/pr/worktrees/", # PR review worktrees
|
||||
"/.worktrees/", # Legacy worktree location
|
||||
]
|
||||
project_path_posix = str(resolved_project_path).replace("\\", "/")
|
||||
|
||||
for marker in worktree_markers:
|
||||
if marker in project_path_posix:
|
||||
# Extract the original project directory (parent of worktree location)
|
||||
# Use rsplit to get the rightmost occurrence (handles nested projects)
|
||||
original_project_str = project_path_posix.rsplit(marker, 1)[0]
|
||||
original_project_dir = Path(original_project_str)
|
||||
|
||||
# Grant permissions for relevant directories in the original project
|
||||
permission_ops = ["Read", "Write", "Edit", "Glob", "Grep"]
|
||||
dirs_to_permit = [
|
||||
original_project_dir / ".auto-claude",
|
||||
original_project_dir / ".worktrees", # Legacy support
|
||||
]
|
||||
|
||||
for dir_path in dirs_to_permit:
|
||||
if dir_path.exists():
|
||||
path_str = str(dir_path.resolve())
|
||||
original_project_permissions.extend(
|
||||
[f"{op}({path_str}/**)" for op in permission_ops]
|
||||
)
|
||||
break
|
||||
|
||||
security_settings = {
|
||||
"sandbox": {"enabled": True, "autoAllowBashIfSandboxed": True},
|
||||
"permissions": {
|
||||
@@ -622,9 +459,6 @@ def create_client(
|
||||
f"Read({spec_path_str}/**)",
|
||||
f"Write({spec_path_str}/**)",
|
||||
f"Edit({spec_path_str}/**)",
|
||||
# Allow original project's .auto-claude/ and .worktrees/ directories
|
||||
# when running in a worktree (fixes issue #385 - permission errors)
|
||||
*original_project_permissions,
|
||||
# Bash permission granted here, but actual commands are validated
|
||||
# by the bash_security_hook (see security.py for allowed commands)
|
||||
"Bash(*)",
|
||||
@@ -655,14 +489,12 @@ def create_client(
|
||||
|
||||
# Write settings to a file in the project directory
|
||||
settings_file = project_dir / ".claude_settings.json"
|
||||
with open(settings_file, "w", encoding="utf-8") as f:
|
||||
with open(settings_file, "w") as f:
|
||||
json.dump(security_settings, f, indent=2)
|
||||
|
||||
print(f"Security settings: {settings_file}")
|
||||
print(" - Sandbox enabled (OS-level bash isolation)")
|
||||
print(f" - Filesystem restricted to: {project_dir.resolve()}")
|
||||
if original_project_permissions:
|
||||
print(" - Worktree permissions: granted for original project directories")
|
||||
print(" - Bash commands restricted to allowlist")
|
||||
if max_thinking_tokens:
|
||||
print(f" - Extended thinking: {max_thinking_tokens:,} tokens")
|
||||
@@ -794,7 +626,7 @@ def create_client(
|
||||
print()
|
||||
|
||||
# Build options dict, conditionally including output_format
|
||||
options_kwargs: dict[str, Any] = {
|
||||
options_kwargs = {
|
||||
"model": model,
|
||||
"system_prompt": base_prompt,
|
||||
"allowed_tools": allowed_tools_list,
|
||||
@@ -809,21 +641,8 @@ def create_client(
|
||||
"settings": str(settings_file.resolve()),
|
||||
"env": sdk_env, # Pass ANTHROPIC_BASE_URL etc. to subprocess
|
||||
"max_thinking_tokens": max_thinking_tokens, # Extended thinking budget
|
||||
"max_buffer_size": 10
|
||||
* 1024
|
||||
* 1024, # 10MB buffer (default: 1MB) - fixes large tool results
|
||||
# Enable file checkpointing to track file read/write state across tool calls
|
||||
# This prevents "File has not been read yet" errors in recovery sessions
|
||||
"enable_file_checkpointing": True,
|
||||
}
|
||||
|
||||
# Optional: Allow CLI path override via environment variable
|
||||
# The SDK bundles its own CLI, but users can override if needed
|
||||
env_cli_path = os.environ.get("CLAUDE_CLI_PATH")
|
||||
if env_cli_path and validate_cli_path(env_cli_path):
|
||||
options_kwargs["cli_path"] = env_cli_path
|
||||
logger.info(f"Using CLAUDE_CLI_PATH override: {env_cli_path}")
|
||||
|
||||
# Add structured output format if specified
|
||||
# See: https://platform.claude.com/docs/en/agent-sdk/structured-outputs
|
||||
if output_format:
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
Core configuration for Auto Claude.
|
||||
|
||||
This module provides centralized configuration management for Auto Claude,
|
||||
including worktree path resolution and validation. It ensures consistent
|
||||
configuration access across the entire backend codebase.
|
||||
|
||||
Constants:
|
||||
WORKTREE_BASE_PATH_VAR (str): Environment variable name for custom worktree base path.
|
||||
DEFAULT_WORKTREE_PATH (str): Default worktree directory name relative to project root.
|
||||
|
||||
Example:
|
||||
>>> from core.config import get_worktree_base_path
|
||||
>>> from pathlib import Path
|
||||
>>>
|
||||
>>> # Get worktree path with validation
|
||||
>>> project_dir = Path("/path/to/project")
|
||||
>>> worktree_path = get_worktree_base_path(project_dir)
|
||||
>>> full_path = project_dir / worktree_path
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
# Environment variable names
|
||||
WORKTREE_BASE_PATH_VAR = "WORKTREE_BASE_PATH"
|
||||
"""str: Environment variable name for configuring custom worktree base path.
|
||||
|
||||
Users can set this environment variable in their project's .env file to specify
|
||||
a custom location for worktree directories, supporting both relative and absolute paths.
|
||||
"""
|
||||
|
||||
# Default values
|
||||
DEFAULT_WORKTREE_PATH = ".worktrees"
|
||||
"""str: Default worktree directory name.
|
||||
|
||||
This is the fallback value used when WORKTREE_BASE_PATH is not set or when
|
||||
validation fails (e.g., path points to .auto-claude/ or .git/ directories).
|
||||
"""
|
||||
|
||||
|
||||
def get_worktree_base_path(project_dir: Path | None = None) -> str:
|
||||
"""
|
||||
Determine the validated worktree base path from the WORKTREE_BASE_PATH environment variable or the default.
|
||||
|
||||
Parameters:
|
||||
project_dir (Path | None): Optional project root used to resolve relative paths and perform stricter validation. If omitted, only basic pattern checks are applied.
|
||||
|
||||
Returns:
|
||||
str: The configured worktree base path string, or DEFAULT_WORKTREE_PATH ('.worktrees') if the configured value is invalid or points inside the project's `.auto-claude` or `.git` directories.
|
||||
"""
|
||||
worktree_base_path = os.getenv(WORKTREE_BASE_PATH_VAR, DEFAULT_WORKTREE_PATH)
|
||||
|
||||
# If no project_dir provided, return as-is (basic validation only)
|
||||
if not project_dir:
|
||||
# Check for obviously dangerous patterns
|
||||
normalized = Path(worktree_base_path).as_posix()
|
||||
if ".auto-claude" in normalized or ".git" in normalized:
|
||||
return DEFAULT_WORKTREE_PATH
|
||||
return worktree_base_path
|
||||
|
||||
# Resolve the absolute path
|
||||
if Path(worktree_base_path).is_absolute():
|
||||
resolved = Path(worktree_base_path).resolve()
|
||||
else:
|
||||
resolved = (project_dir / worktree_base_path).resolve()
|
||||
|
||||
# Prevent paths inside .auto-claude/ or .git/
|
||||
auto_claude_dir = (project_dir / ".auto-claude").resolve()
|
||||
git_dir = (project_dir / ".git").resolve()
|
||||
|
||||
resolved_str = str(resolved)
|
||||
if resolved_str.startswith(str(auto_claude_dir)) or resolved_str.startswith(
|
||||
str(git_dir)
|
||||
):
|
||||
return DEFAULT_WORKTREE_PATH
|
||||
|
||||
return worktree_base_path
|
||||
@@ -110,7 +110,7 @@ def _write_log(message: str, to_file: bool = True) -> None:
|
||||
import re
|
||||
|
||||
clean_message = re.sub(r"\033\[[0-9;]*m", "", message)
|
||||
with open(log_file, "a", encoding="utf-8") as f:
|
||||
with open(log_file, "a") as f:
|
||||
f.write(clean_message + "\n")
|
||||
except Exception:
|
||||
pass # Silently fail file logging
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
"""
|
||||
Dependency Validator
|
||||
====================
|
||||
|
||||
Validates platform-specific dependencies are installed before running agents.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from core.platform import is_linux, is_windows
|
||||
|
||||
|
||||
def validate_platform_dependencies() -> None:
|
||||
"""
|
||||
Validate that platform-specific dependencies are installed.
|
||||
|
||||
Raises:
|
||||
SystemExit: If required platform-specific dependencies are missing,
|
||||
with helpful installation instructions.
|
||||
"""
|
||||
# Check Windows-specific dependencies (all Python versions per ACS-306)
|
||||
# pywin32 is required on all Python versions on Windows - MCP library unconditionally imports win32api
|
||||
if is_windows():
|
||||
try:
|
||||
import pywintypes # noqa: F401
|
||||
except ImportError:
|
||||
_exit_with_pywin32_error()
|
||||
|
||||
# Check Linux-specific dependencies (ACS-310)
|
||||
# Note: secretstorage is optional for app functionality (falls back to .env),
|
||||
# but we validate it to ensure proper OAuth token storage via keyring
|
||||
if is_linux():
|
||||
try:
|
||||
import secretstorage # noqa: F401
|
||||
except ImportError:
|
||||
_warn_missing_secretstorage()
|
||||
|
||||
|
||||
def _exit_with_pywin32_error() -> None:
|
||||
"""Exit with helpful error message for missing pywin32."""
|
||||
# Use sys.prefix to detect the virtual environment path
|
||||
# This works for venv and poetry environments
|
||||
# Check for common Windows activation scripts (activate, activate.bat, Activate.ps1)
|
||||
scripts_dir = Path(sys.prefix) / "Scripts"
|
||||
activation_candidates = [
|
||||
scripts_dir / "activate",
|
||||
scripts_dir / "activate.bat",
|
||||
scripts_dir / "Activate.ps1",
|
||||
]
|
||||
venv_activate = next((p for p in activation_candidates if p.exists()), None)
|
||||
|
||||
# Build activation step only if activate script exists
|
||||
activation_step = ""
|
||||
if venv_activate:
|
||||
activation_step = (
|
||||
"To fix this:\n"
|
||||
"1. Activate your virtual environment:\n"
|
||||
f" {venv_activate}\n"
|
||||
"\n"
|
||||
"2. Install pywin32:\n"
|
||||
" pip install pywin32>=306\n"
|
||||
"\n"
|
||||
" Or reinstall all dependencies:\n"
|
||||
" pip install -r requirements.txt\n"
|
||||
)
|
||||
else:
|
||||
# For system Python or environments without activate script
|
||||
activation_step = (
|
||||
"To fix this:\n"
|
||||
"Install pywin32:\n"
|
||||
" pip install pywin32>=306\n"
|
||||
"\n"
|
||||
" Or reinstall all dependencies:\n"
|
||||
" pip install -r requirements.txt\n"
|
||||
)
|
||||
|
||||
sys.exit(
|
||||
"Error: Required Windows dependency 'pywin32' is not installed.\n"
|
||||
"\n"
|
||||
"Auto Claude requires pywin32 on Windows for:\n"
|
||||
" - MCP library (win32api, win32con, win32job modules)\n"
|
||||
" - LadybugDB/Graphiti memory integration\n"
|
||||
"\n"
|
||||
f"{activation_step}"
|
||||
"\n"
|
||||
f"Current Python: {sys.executable}\n"
|
||||
)
|
||||
|
||||
|
||||
def _warn_missing_secretstorage() -> None:
|
||||
"""Emit warning message for missing secretstorage.
|
||||
|
||||
Note: This is a warning, not a hard error - the app will fall back to .env
|
||||
file storage for OAuth tokens. We warn users to ensure they understand the
|
||||
security implications.
|
||||
"""
|
||||
# Use sys.prefix to detect the virtual environment path
|
||||
venv_activate = Path(sys.prefix) / "bin" / "activate"
|
||||
# Only include activation instruction if venv script actually exists
|
||||
activation_prefix = (
|
||||
f"1. Activate your virtual environment:\n source {venv_activate}\n\n"
|
||||
if venv_activate.exists()
|
||||
else ""
|
||||
)
|
||||
# Adjust step number based on whether activation step is included
|
||||
install_step = (
|
||||
"2. Install secretstorage:\n"
|
||||
if activation_prefix
|
||||
else "Install secretstorage:\n"
|
||||
)
|
||||
|
||||
sys.stderr.write(
|
||||
"Warning: Linux dependency 'secretstorage' is not installed.\n"
|
||||
"\n"
|
||||
"Auto Claude can use secretstorage for secure OAuth token storage via\n"
|
||||
"the system keyring (gnome-keyring, kwallet, etc.). Without it, tokens\n"
|
||||
"will be stored in plaintext in your .env file.\n"
|
||||
"\n"
|
||||
"To enable keyring integration:\n"
|
||||
f"{activation_prefix}"
|
||||
f"{install_step}"
|
||||
" pip install 'secretstorage>=3.3.3'\n"
|
||||
"\n"
|
||||
" Or reinstall all dependencies:\n"
|
||||
" pip install -r requirements.txt\n"
|
||||
"\n"
|
||||
"Note: The app will continue to work, but OAuth tokens will be stored\n"
|
||||
"in your .env file instead of the system keyring.\n"
|
||||
"\n"
|
||||
f"Current Python: {sys.executable}\n"
|
||||
)
|
||||
sys.stderr.flush()
|
||||
# Continue execution - this is a warning, not a blocking error
|
||||
@@ -1,121 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Atomic File Write Utilities
|
||||
============================
|
||||
|
||||
Synchronous utilities for atomic file writes to prevent corruption.
|
||||
|
||||
Uses temp file + os.replace() pattern which is atomic on POSIX systems
|
||||
and atomic on Windows when source and destination are on the same volume.
|
||||
|
||||
Usage:
|
||||
from core.file_utils import write_json_atomic
|
||||
|
||||
write_json_atomic("/path/to/file.json", {"key": "value"})
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import IO, Any, Literal
|
||||
|
||||
|
||||
@contextmanager
|
||||
def atomic_write(
|
||||
filepath: str | Path,
|
||||
mode: Literal["w", "wb", "wt"] = "w",
|
||||
encoding: str | None = "utf-8",
|
||||
) -> Iterator[IO]:
|
||||
"""
|
||||
Atomic file write using temp file and rename.
|
||||
|
||||
Writes to .tmp file first, then atomically replaces target file
|
||||
using os.replace() which is atomic on POSIX systems and same-volume Windows.
|
||||
|
||||
Note: This function supports both text and binary modes. For binary modes
|
||||
(mode containing 'b'), encoding must be None.
|
||||
|
||||
Args:
|
||||
filepath: Target file path
|
||||
mode: File open mode (default: "w", text mode only)
|
||||
encoding: File encoding for text modes, None for binary (default: "utf-8")
|
||||
|
||||
Example:
|
||||
with atomic_write("/path/to/file.json") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
Yields:
|
||||
File handle to temp file
|
||||
"""
|
||||
filepath = Path(filepath)
|
||||
filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Binary modes require encoding=None
|
||||
actual_encoding = None if "b" in mode else encoding
|
||||
|
||||
# Create temp file in same directory for atomic rename
|
||||
fd, tmp_path = tempfile.mkstemp(
|
||||
dir=filepath.parent, prefix=f".{filepath.name}.tmp.", suffix=""
|
||||
)
|
||||
|
||||
# Open temp file with requested mode
|
||||
# If fdopen fails, close fd and clean up temp file
|
||||
try:
|
||||
f = os.fdopen(fd, mode, encoding=actual_encoding)
|
||||
except Exception:
|
||||
os.close(fd)
|
||||
os.unlink(tmp_path)
|
||||
raise
|
||||
|
||||
try:
|
||||
with f:
|
||||
yield f
|
||||
except Exception:
|
||||
# Clean up temp file on error (replace didn't happen yet)
|
||||
try:
|
||||
os.unlink(tmp_path)
|
||||
except Exception as cleanup_err:
|
||||
# Best-effort cleanup, ignore errors to not mask original exception
|
||||
# Log cleanup failure for debugging (orphaned temp files may accumulate)
|
||||
logging.warning(
|
||||
f"Failed to cleanup temp file {tmp_path}: {cleanup_err}",
|
||||
exc_info=True,
|
||||
)
|
||||
raise
|
||||
else:
|
||||
# Atomic replace - only runs if no exception was raised
|
||||
# If os.replace itself fails, do NOT clean up (may be partially renamed)
|
||||
os.replace(tmp_path, filepath)
|
||||
|
||||
|
||||
def write_json_atomic(
|
||||
filepath: str | Path,
|
||||
data: Any,
|
||||
indent: int = 2,
|
||||
ensure_ascii: bool = False,
|
||||
encoding: str = "utf-8",
|
||||
) -> None:
|
||||
"""
|
||||
Write JSON data to file atomically.
|
||||
|
||||
This function prevents file corruption by:
|
||||
1. Writing to a temporary file first
|
||||
2. Only replacing the target file if the write succeeds
|
||||
3. Using os.replace() for atomicity
|
||||
|
||||
Args:
|
||||
filepath: Target file path
|
||||
data: Data to serialize as JSON
|
||||
indent: JSON indentation (default: 2)
|
||||
ensure_ascii: Whether to escape non-ASCII characters (default: False)
|
||||
encoding: File encoding (default: "utf-8")
|
||||
|
||||
Example:
|
||||
write_json_atomic("/path/to/file.json", {"key": "value"})
|
||||
"""
|
||||
with atomic_write(filepath, "w", encoding=encoding) as f:
|
||||
json.dump(data, f, indent=indent, ensure_ascii=ensure_ascii)
|
||||
@@ -1,191 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
GitHub CLI Executable Finder
|
||||
============================
|
||||
|
||||
Utility to find the gh (GitHub CLI) executable, with platform-specific fallbacks.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
_cached_gh_path: str | None = None
|
||||
|
||||
|
||||
def invalidate_gh_cache() -> None:
|
||||
"""Invalidate the cached gh executable path.
|
||||
|
||||
Useful when gh may have been uninstalled, updated, or when
|
||||
GITHUB_CLI_PATH environment variable has changed.
|
||||
"""
|
||||
global _cached_gh_path
|
||||
_cached_gh_path = None
|
||||
|
||||
|
||||
def _verify_gh_executable(path: str) -> bool:
|
||||
"""Verify that a path is a valid gh executable by checking version.
|
||||
|
||||
Args:
|
||||
path: Path to the potential gh executable
|
||||
|
||||
Returns:
|
||||
True if the path points to a valid gh executable, False otherwise
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[path, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=5,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def _run_where_command() -> str | None:
|
||||
"""Run Windows 'where gh' command to find gh executable.
|
||||
|
||||
Returns:
|
||||
First path found, or None if command failed
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
"where gh",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=5,
|
||||
shell=True, # Required: 'where' command must be executed through shell on Windows
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
found_path = result.stdout.strip().split("\n")[0].strip()
|
||||
if (
|
||||
found_path
|
||||
and os.path.isfile(found_path)
|
||||
and _verify_gh_executable(found_path)
|
||||
):
|
||||
return found_path
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
# 'where' command failed or timed out - fall through to return None
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def get_gh_executable() -> str | None:
|
||||
"""Find the gh executable, with platform-specific fallbacks.
|
||||
|
||||
Returns the path to gh executable, or None if not found.
|
||||
|
||||
Priority order:
|
||||
1. GITHUB_CLI_PATH env var (user-configured path from frontend)
|
||||
2. shutil.which (if gh is in PATH)
|
||||
3. Homebrew paths on macOS
|
||||
4. Windows Program Files paths
|
||||
5. Windows 'where' command
|
||||
|
||||
Caches the result after first successful find. Use invalidate_gh_cache()
|
||||
to force re-detection (e.g., after gh installation/uninstallation).
|
||||
"""
|
||||
global _cached_gh_path
|
||||
|
||||
# Return cached result if available AND still exists
|
||||
if _cached_gh_path is not None and os.path.isfile(_cached_gh_path):
|
||||
return _cached_gh_path
|
||||
|
||||
_cached_gh_path = _find_gh_executable()
|
||||
return _cached_gh_path
|
||||
|
||||
|
||||
def _find_gh_executable() -> str | None:
|
||||
"""Internal function to find gh executable."""
|
||||
# 1. Check GITHUB_CLI_PATH env var (set by Electron frontend)
|
||||
env_path = os.environ.get("GITHUB_CLI_PATH")
|
||||
if env_path and os.path.isfile(env_path) and _verify_gh_executable(env_path):
|
||||
return env_path
|
||||
|
||||
# 2. Try shutil.which (works if gh is in PATH)
|
||||
gh_path = shutil.which("gh")
|
||||
if gh_path and _verify_gh_executable(gh_path):
|
||||
return gh_path
|
||||
|
||||
# 3. macOS-specific: check Homebrew paths
|
||||
if os.name != "nt": # Unix-like systems (macOS, Linux)
|
||||
homebrew_paths = [
|
||||
"/opt/homebrew/bin/gh", # Apple Silicon
|
||||
"/usr/local/bin/gh", # Intel Mac
|
||||
"/home/linuxbrew/.linuxbrew/bin/gh", # Linux Homebrew
|
||||
]
|
||||
for path in homebrew_paths:
|
||||
if os.path.isfile(path) and _verify_gh_executable(path):
|
||||
return path
|
||||
|
||||
# 4. Windows-specific: check Program Files paths
|
||||
if os.name == "nt":
|
||||
windows_paths = [
|
||||
os.path.expandvars(r"%PROGRAMFILES%\GitHub CLI\gh.exe"),
|
||||
os.path.expandvars(r"%PROGRAMFILES(X86)%\GitHub CLI\gh.exe"),
|
||||
os.path.expandvars(r"%LOCALAPPDATA%\Programs\GitHub CLI\gh.exe"),
|
||||
]
|
||||
for path in windows_paths:
|
||||
if os.path.isfile(path) and _verify_gh_executable(path):
|
||||
return path
|
||||
|
||||
# 5. Try 'where' command with shell=True (more reliable on Windows)
|
||||
return _run_where_command()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def run_gh(
|
||||
args: list[str],
|
||||
cwd: str | None = None,
|
||||
timeout: int = 60,
|
||||
input_data: str | None = None,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run a gh command with proper executable finding.
|
||||
|
||||
Args:
|
||||
args: gh command arguments (without 'gh' prefix)
|
||||
cwd: Working directory for the command
|
||||
timeout: Command timeout in seconds (default: 60)
|
||||
input_data: Optional string data to pass to stdin
|
||||
|
||||
Returns:
|
||||
CompletedProcess with command results.
|
||||
"""
|
||||
gh = get_gh_executable()
|
||||
if not gh:
|
||||
return subprocess.CompletedProcess(
|
||||
args=["gh"] + args,
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr="GitHub CLI (gh) not found. Install from https://cli.github.com/",
|
||||
)
|
||||
try:
|
||||
return subprocess.run(
|
||||
[gh] + args,
|
||||
cwd=cwd,
|
||||
input=input_data,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return subprocess.CompletedProcess(
|
||||
args=[gh] + args,
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr=f"Command timed out after {timeout} seconds",
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return subprocess.CompletedProcess(
|
||||
args=[gh] + args,
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr="GitHub CLI (gh) executable not found. Install from https://cli.github.com/",
|
||||
)
|
||||
@@ -1,198 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Git Executable Finder and Isolation
|
||||
====================================
|
||||
|
||||
Utility to find the git executable, with Windows-specific fallbacks.
|
||||
Also provides environment isolation to prevent pre-commit hooks and
|
||||
other git configurations from affecting worktree operations.
|
||||
|
||||
Separated into its own module to avoid circular imports.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
# Git environment variables that can interfere with worktree operations
|
||||
# when set by pre-commit hooks or other git configurations.
|
||||
# These must be cleared to prevent cross-worktree contamination.
|
||||
GIT_ENV_VARS_TO_CLEAR = [
|
||||
"GIT_DIR",
|
||||
"GIT_WORK_TREE",
|
||||
"GIT_INDEX_FILE",
|
||||
"GIT_OBJECT_DIRECTORY",
|
||||
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
|
||||
# Identity variables that could be set by hooks
|
||||
"GIT_AUTHOR_NAME",
|
||||
"GIT_AUTHOR_EMAIL",
|
||||
"GIT_AUTHOR_DATE",
|
||||
"GIT_COMMITTER_NAME",
|
||||
"GIT_COMMITTER_EMAIL",
|
||||
"GIT_COMMITTER_DATE",
|
||||
]
|
||||
|
||||
_cached_git_path: str | None = None
|
||||
|
||||
|
||||
def get_isolated_git_env(base_env: dict | None = None) -> dict:
|
||||
"""
|
||||
Create an isolated environment for git operations.
|
||||
|
||||
Clears git environment variables that may be set by pre-commit hooks
|
||||
or other git configurations, preventing cross-worktree contamination
|
||||
and ensuring git operations target the intended repository.
|
||||
|
||||
Args:
|
||||
base_env: Base environment dict to copy from. If None, uses os.environ.
|
||||
|
||||
Returns:
|
||||
Environment dict safe for git subprocess operations.
|
||||
"""
|
||||
env = dict(base_env) if base_env is not None else os.environ.copy()
|
||||
|
||||
for key in GIT_ENV_VARS_TO_CLEAR:
|
||||
env.pop(key, None)
|
||||
|
||||
# Disable user's pre-commit hooks during Auto-Claude managed git operations
|
||||
# to prevent double-hook execution and potential conflicts
|
||||
env["HUSKY"] = "0"
|
||||
|
||||
return env
|
||||
|
||||
|
||||
def get_git_executable() -> str:
|
||||
"""Find the git executable, with Windows-specific fallbacks.
|
||||
|
||||
Returns the path to git executable. On Windows, checks multiple sources:
|
||||
1. CLAUDE_CODE_GIT_BASH_PATH env var (set by Electron frontend)
|
||||
2. shutil.which (if git is in PATH)
|
||||
3. Common installation locations
|
||||
4. Windows 'where' command
|
||||
|
||||
Caches the result after first successful find.
|
||||
"""
|
||||
global _cached_git_path
|
||||
|
||||
# Return cached result if available
|
||||
if _cached_git_path is not None:
|
||||
return _cached_git_path
|
||||
|
||||
git_path = _find_git_executable()
|
||||
_cached_git_path = git_path
|
||||
return git_path
|
||||
|
||||
|
||||
def _find_git_executable() -> str:
|
||||
"""Internal function to find git executable."""
|
||||
# 1. Check CLAUDE_CODE_GIT_BASH_PATH (set by Electron frontend)
|
||||
# This env var points to bash.exe, we can derive git.exe from it
|
||||
bash_path = os.environ.get("CLAUDE_CODE_GIT_BASH_PATH")
|
||||
if bash_path:
|
||||
try:
|
||||
bash_path_obj = Path(bash_path)
|
||||
if bash_path_obj.exists():
|
||||
git_dir = bash_path_obj.parent.parent
|
||||
# Try cmd/git.exe first (preferred), then bin/git.exe
|
||||
for git_subpath in ["cmd/git.exe", "bin/git.exe"]:
|
||||
git_path = git_dir / git_subpath
|
||||
if git_path.is_file():
|
||||
return str(git_path)
|
||||
except (OSError, ValueError):
|
||||
pass # Invalid path or permission error - try next method
|
||||
|
||||
# 2. Try shutil.which (works if git is in PATH)
|
||||
git_path = shutil.which("git")
|
||||
if git_path:
|
||||
return git_path
|
||||
|
||||
# 3. Windows-specific: check common installation locations
|
||||
if os.name == "nt":
|
||||
common_paths = [
|
||||
os.path.expandvars(r"%PROGRAMFILES%\Git\cmd\git.exe"),
|
||||
os.path.expandvars(r"%PROGRAMFILES%\Git\bin\git.exe"),
|
||||
os.path.expandvars(r"%PROGRAMFILES(X86)%\Git\cmd\git.exe"),
|
||||
os.path.expandvars(r"%LOCALAPPDATA%\Programs\Git\cmd\git.exe"),
|
||||
r"C:\Program Files\Git\cmd\git.exe",
|
||||
r"C:\Program Files (x86)\Git\cmd\git.exe",
|
||||
]
|
||||
for path in common_paths:
|
||||
try:
|
||||
if os.path.isfile(path):
|
||||
return path
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# 4. Try 'where' command with shell=True (more reliable on Windows)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
"where git",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
shell=True,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
found_path = result.stdout.strip().split("\n")[0].strip()
|
||||
if found_path and os.path.isfile(found_path):
|
||||
return found_path
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
pass # 'where' command failed - fall through to default
|
||||
|
||||
# Default fallback - let subprocess handle it (may fail)
|
||||
return "git"
|
||||
|
||||
|
||||
def run_git(
|
||||
args: list[str],
|
||||
cwd: Path | str | None = None,
|
||||
timeout: int = 60,
|
||||
input_data: str | None = None,
|
||||
env: dict | None = None,
|
||||
isolate_env: bool = True,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run a git command with proper executable finding and environment isolation.
|
||||
|
||||
Args:
|
||||
args: Git command arguments (without 'git' prefix)
|
||||
cwd: Working directory for the command
|
||||
timeout: Command timeout in seconds (default: 60)
|
||||
input_data: Optional string data to pass to stdin
|
||||
env: Custom environment dict. If None and isolate_env=True, uses isolated env.
|
||||
isolate_env: If True (default), clears git env vars to prevent hook interference.
|
||||
|
||||
Returns:
|
||||
CompletedProcess with command results.
|
||||
"""
|
||||
git = get_git_executable()
|
||||
|
||||
if env is None and isolate_env:
|
||||
env = get_isolated_git_env()
|
||||
|
||||
try:
|
||||
return subprocess.run(
|
||||
[git] + args,
|
||||
cwd=cwd,
|
||||
input=input_data,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
env=env,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return subprocess.CompletedProcess(
|
||||
args=[git] + args,
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr=f"Command timed out after {timeout} seconds",
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return subprocess.CompletedProcess(
|
||||
args=[git] + args,
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr="Git executable not found. Please ensure git is installed and in PATH.",
|
||||
)
|
||||
@@ -1,115 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Git Provider Detection
|
||||
======================
|
||||
|
||||
Utility to detect git hosting provider (GitHub, GitLab, or unknown) from git remote URLs.
|
||||
Supports both SSH and HTTPS remote formats, and self-hosted GitLab instances.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from .git_executable import run_git
|
||||
|
||||
|
||||
def detect_git_provider(project_dir: str | Path, remote_name: str | None = None) -> str:
|
||||
"""Detect the git hosting provider from the git remote URL.
|
||||
|
||||
Args:
|
||||
project_dir: Path to the git repository
|
||||
remote_name: Name of the remote to check (defaults to "origin")
|
||||
|
||||
Returns:
|
||||
'github' if GitHub remote detected
|
||||
'gitlab' if GitLab remote detected (cloud or self-hosted)
|
||||
'unknown' if no remote or unsupported provider
|
||||
|
||||
Examples:
|
||||
>>> detect_git_provider('/path/to/repo')
|
||||
'github' # for git@github.com:user/repo.git
|
||||
'gitlab' # for git@gitlab.com:user/repo.git
|
||||
'gitlab' # for https://gitlab.company.com/user/repo.git
|
||||
'unknown' # for no remote or other providers
|
||||
"""
|
||||
try:
|
||||
# Get the remote URL (use specified remote or default to origin)
|
||||
remote = remote_name if remote_name else "origin"
|
||||
result = run_git(
|
||||
["remote", "get-url", remote],
|
||||
cwd=project_dir,
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
# If command failed or no output, return unknown
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
return "unknown"
|
||||
|
||||
remote_url = result.stdout.strip()
|
||||
|
||||
# Parse ssh:// URL format: ssh://[user@]host[:port]/path
|
||||
ssh_url_match = re.match(r"^ssh://(?:[^@]+@)?([^:/]+)(?::\d+)?/", remote_url)
|
||||
if ssh_url_match:
|
||||
hostname = ssh_url_match.group(1)
|
||||
return _classify_hostname(hostname)
|
||||
|
||||
# Parse HTTPS/HTTP format: https://host/path or http://host/path
|
||||
# Must check before scp-like format to avoid matching "https" as hostname
|
||||
https_match = re.match(r"^https?://([^/]+)/", remote_url)
|
||||
if https_match:
|
||||
hostname = https_match.group(1)
|
||||
return _classify_hostname(hostname)
|
||||
|
||||
# Parse scp-like format: [user@]host:path (any username, not just 'git')
|
||||
# This handles git@github.com:user/repo.git and similar formats
|
||||
scp_match = re.match(r"^(?:[^@]+@)?([^:]+):", remote_url)
|
||||
if scp_match:
|
||||
hostname = scp_match.group(1)
|
||||
# Exclude paths that look like Windows drives (e.g., C:)
|
||||
if len(hostname) > 1:
|
||||
return _classify_hostname(hostname)
|
||||
|
||||
# Unrecognized URL format
|
||||
return "unknown"
|
||||
|
||||
except Exception:
|
||||
# Any error (subprocess issues, etc.) -> unknown
|
||||
return "unknown"
|
||||
|
||||
|
||||
def _classify_hostname(hostname: str) -> str:
|
||||
"""Classify a hostname as github, gitlab, or unknown.
|
||||
|
||||
Args:
|
||||
hostname: The git remote hostname (e.g., 'github.com', 'gitlab.example.com')
|
||||
|
||||
Returns:
|
||||
'github', 'gitlab', or 'unknown'
|
||||
"""
|
||||
hostname_lower = hostname.lower()
|
||||
|
||||
# Check for GitHub (cloud and self-hosted/enterprise)
|
||||
# Match github.com, *.github.com, or domains where a segment is or starts with 'github'
|
||||
hostname_parts = hostname_lower.split(".")
|
||||
if (
|
||||
hostname_lower == "github.com"
|
||||
or hostname_lower.endswith(".github.com")
|
||||
or any(
|
||||
part == "github" or part.startswith("github-") for part in hostname_parts
|
||||
)
|
||||
):
|
||||
return "github"
|
||||
|
||||
# Check for GitLab (cloud and self-hosted)
|
||||
# Match gitlab.com, *.gitlab.com, or domains where a segment is or starts with 'gitlab'
|
||||
if (
|
||||
hostname_lower == "gitlab.com"
|
||||
or hostname_lower.endswith(".gitlab.com")
|
||||
or any(
|
||||
part == "gitlab" or part.startswith("gitlab-") for part in hostname_parts
|
||||
)
|
||||
):
|
||||
return "gitlab"
|
||||
|
||||
# Unknown provider
|
||||
return "unknown"
|
||||
@@ -1,192 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
GitLab CLI Executable Finder
|
||||
============================
|
||||
|
||||
Utility to find the glab (GitLab CLI) executable, with platform-specific fallbacks.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
_cached_glab_path: str | None = None
|
||||
|
||||
|
||||
def invalidate_glab_cache() -> None:
|
||||
"""Invalidate the cached glab executable path.
|
||||
|
||||
Useful when glab may have been uninstalled, updated, or when
|
||||
GITLAB_CLI_PATH environment variable has changed.
|
||||
"""
|
||||
global _cached_glab_path
|
||||
_cached_glab_path = None
|
||||
|
||||
|
||||
def _verify_glab_executable(path: str) -> bool:
|
||||
"""Verify that a path is a valid glab executable by checking version.
|
||||
|
||||
Args:
|
||||
path: Path to the potential glab executable
|
||||
|
||||
Returns:
|
||||
True if the path points to a valid glab executable, False otherwise
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[path, "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=5,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def _run_where_command() -> str | None:
|
||||
"""Run Windows 'where glab' command to find glab executable.
|
||||
|
||||
Returns:
|
||||
First path found, or None if command failed
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
"where glab",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=5,
|
||||
shell=True, # Required: 'where' command must be executed through shell on Windows
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
found_path = result.stdout.strip().split("\n")[0].strip()
|
||||
if (
|
||||
found_path
|
||||
and os.path.isfile(found_path)
|
||||
and _verify_glab_executable(found_path)
|
||||
):
|
||||
return found_path
|
||||
except (subprocess.TimeoutExpired, OSError):
|
||||
# 'where' command failed or timed out - fall through to return None
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def get_glab_executable() -> str | None:
|
||||
"""Find the glab executable, with platform-specific fallbacks.
|
||||
|
||||
Returns the path to glab executable, or None if not found.
|
||||
|
||||
Priority order:
|
||||
1. GITLAB_CLI_PATH env var (user-configured path from frontend)
|
||||
2. shutil.which (if glab is in PATH)
|
||||
3. Homebrew paths on macOS
|
||||
4. Windows Program Files paths
|
||||
5. Windows 'where' command
|
||||
|
||||
Caches the result after first successful find. Use invalidate_glab_cache()
|
||||
to force re-detection (e.g., after glab installation/uninstallation).
|
||||
"""
|
||||
global _cached_glab_path
|
||||
|
||||
# Return cached result if available AND still exists
|
||||
if _cached_glab_path is not None and os.path.isfile(_cached_glab_path):
|
||||
return _cached_glab_path
|
||||
|
||||
_cached_glab_path = _find_glab_executable()
|
||||
return _cached_glab_path
|
||||
|
||||
|
||||
def _find_glab_executable() -> str | None:
|
||||
"""Internal function to find glab executable."""
|
||||
# 1. Check GITLAB_CLI_PATH env var (set by Electron frontend)
|
||||
env_path = os.environ.get("GITLAB_CLI_PATH")
|
||||
if env_path and os.path.isfile(env_path) and _verify_glab_executable(env_path):
|
||||
return env_path
|
||||
|
||||
# 2. Try shutil.which (works if glab is in PATH)
|
||||
glab_path = shutil.which("glab")
|
||||
if glab_path and _verify_glab_executable(glab_path):
|
||||
return glab_path
|
||||
|
||||
# 3. macOS-specific: check Homebrew paths
|
||||
if os.name != "nt": # Unix-like systems (macOS, Linux)
|
||||
homebrew_paths = [
|
||||
"/opt/homebrew/bin/glab", # Apple Silicon
|
||||
"/usr/local/bin/glab", # Intel Mac
|
||||
"/home/linuxbrew/.linuxbrew/bin/glab", # Linux Homebrew
|
||||
]
|
||||
for path in homebrew_paths:
|
||||
if os.path.isfile(path) and _verify_glab_executable(path):
|
||||
return path
|
||||
|
||||
# 4. Windows-specific: check Program Files paths
|
||||
# glab uses Inno Setup with DefaultDirName={autopf}\glab
|
||||
if os.name == "nt":
|
||||
windows_paths = [
|
||||
os.path.expandvars(r"%PROGRAMFILES%\glab\glab.exe"),
|
||||
os.path.expandvars(r"%PROGRAMFILES(X86)%\glab\glab.exe"),
|
||||
os.path.expandvars(r"%LOCALAPPDATA%\Programs\glab\glab.exe"),
|
||||
]
|
||||
for path in windows_paths:
|
||||
if os.path.isfile(path) and _verify_glab_executable(path):
|
||||
return path
|
||||
|
||||
# 5. Try 'where' command with shell=True (more reliable on Windows)
|
||||
return _run_where_command()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def run_glab(
|
||||
args: list[str],
|
||||
cwd: str | None = None,
|
||||
timeout: int = 60,
|
||||
input_data: str | None = None,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run a glab command with proper executable finding.
|
||||
|
||||
Args:
|
||||
args: glab command arguments (without 'glab' prefix)
|
||||
cwd: Working directory for the command
|
||||
timeout: Command timeout in seconds (default: 60)
|
||||
input_data: Optional string data to pass to stdin
|
||||
|
||||
Returns:
|
||||
CompletedProcess with command results.
|
||||
"""
|
||||
glab = get_glab_executable()
|
||||
if not glab:
|
||||
return subprocess.CompletedProcess(
|
||||
args=["glab"] + args,
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr="GitLab CLI (glab) not found. Install from https://gitlab.com/gitlab-org/cli",
|
||||
)
|
||||
try:
|
||||
return subprocess.run(
|
||||
[glab] + args,
|
||||
cwd=cwd,
|
||||
input=input_data,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return subprocess.CompletedProcess(
|
||||
args=[glab] + args,
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr=f"Command timed out after {timeout} seconds",
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return subprocess.CompletedProcess(
|
||||
args=[glab] + args,
|
||||
returncode=-1,
|
||||
stdout="",
|
||||
stderr="GitLab CLI (glab) executable not found. Install from https://gitlab.com/gitlab-org/cli",
|
||||
)
|
||||
@@ -1,94 +0,0 @@
|
||||
"""
|
||||
I/O Utilities for Safe Console Output
|
||||
=====================================
|
||||
|
||||
Safe I/O operations for processes running as subprocesses.
|
||||
|
||||
When the backend runs as a subprocess of the Electron app, the parent
|
||||
process may close the pipe at any time (e.g., user closes the app,
|
||||
process killed, etc.). This module provides utilities to handle these
|
||||
cases gracefully.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Track if pipe is broken to avoid repeated failed writes
|
||||
_pipe_broken = False
|
||||
|
||||
|
||||
def safe_print(message: str, flush: bool = True) -> None:
|
||||
"""
|
||||
Print to stdout with BrokenPipeError handling.
|
||||
|
||||
When running as a subprocess (e.g., from Electron), the parent process
|
||||
may close the pipe at any time. This function gracefully handles that
|
||||
case instead of raising an exception.
|
||||
|
||||
Args:
|
||||
message: The message to print
|
||||
flush: Whether to flush stdout after printing (default True)
|
||||
"""
|
||||
global _pipe_broken
|
||||
|
||||
# Skip if we already know the pipe is broken
|
||||
if _pipe_broken:
|
||||
return
|
||||
|
||||
try:
|
||||
print(message, flush=flush)
|
||||
except BrokenPipeError:
|
||||
# Pipe closed by parent process - this is expected during shutdown
|
||||
_pipe_broken = True
|
||||
# Quietly close stdout to prevent further errors
|
||||
try:
|
||||
sys.stdout.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("Output pipe closed by parent process")
|
||||
except ValueError as e:
|
||||
# Handle writes to closed file (can happen after stdout.close())
|
||||
if "closed file" in str(e).lower():
|
||||
_pipe_broken = True
|
||||
logger.debug("Output stream closed")
|
||||
else:
|
||||
# Re-raise unexpected ValueErrors
|
||||
raise
|
||||
except OSError as e:
|
||||
# Handle other pipe-related errors (EPIPE, etc.)
|
||||
if e.errno == 32: # EPIPE - Broken pipe
|
||||
_pipe_broken = True
|
||||
try:
|
||||
sys.stdout.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.debug("Output pipe closed (EPIPE)")
|
||||
else:
|
||||
# Re-raise unexpected OS errors
|
||||
raise
|
||||
|
||||
|
||||
def is_pipe_broken() -> bool:
|
||||
"""Check if the output pipe has been closed."""
|
||||
return _pipe_broken
|
||||
|
||||
|
||||
def reset_pipe_state() -> None:
|
||||
"""
|
||||
Reset pipe broken state.
|
||||
|
||||
Useful for testing or when starting a new subprocess context where
|
||||
stdout has been reopened. Should only be called when stdout is known
|
||||
to be functional (e.g., in a fresh subprocess with a new stdout).
|
||||
|
||||
Warning:
|
||||
Calling this after stdout has been closed will result in safe_print()
|
||||
attempting to write to the closed stream. The ValueError will be
|
||||
caught and the pipe will be marked as broken again.
|
||||
"""
|
||||
global _pipe_broken
|
||||
_pipe_broken = False
|
||||
@@ -23,9 +23,6 @@ class ExecutionPhase(str, Enum):
|
||||
QA_FIXING = "qa_fixing"
|
||||
COMPLETE = "complete"
|
||||
FAILED = "failed"
|
||||
# Pause states for intelligent error recovery
|
||||
RATE_LIMIT_PAUSED = "rate_limit_paused"
|
||||
AUTH_FAILURE_PAUSED = "auth_failure_paused"
|
||||
|
||||
|
||||
def emit_phase(
|
||||
@@ -34,19 +31,8 @@ def emit_phase(
|
||||
*,
|
||||
progress: int | None = None,
|
||||
subtask: str | None = None,
|
||||
reset_timestamp: int | None = None,
|
||||
profile_id: str | None = None,
|
||||
) -> None:
|
||||
"""Emit structured phase event to stdout for frontend parsing.
|
||||
|
||||
Args:
|
||||
phase: The execution phase (e.g., PLANNING, CODING, RATE_LIMIT_PAUSED)
|
||||
message: Optional message describing the phase state
|
||||
progress: Optional progress percentage (0-100)
|
||||
subtask: Optional subtask identifier
|
||||
reset_timestamp: Optional Unix timestamp for rate limit reset time
|
||||
profile_id: Optional profile ID that triggered the pause
|
||||
"""
|
||||
"""Emit structured phase event to stdout for frontend parsing."""
|
||||
phase_value = phase.value if isinstance(phase, ExecutionPhase) else phase
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
@@ -62,18 +48,8 @@ def emit_phase(
|
||||
if subtask is not None:
|
||||
payload["subtask"] = subtask
|
||||
|
||||
if reset_timestamp is not None:
|
||||
payload["reset_timestamp"] = reset_timestamp
|
||||
|
||||
if profile_id is not None:
|
||||
payload["profile_id"] = profile_id
|
||||
|
||||
try:
|
||||
print(f"{PHASE_MARKER_PREFIX}{json.dumps(payload, default=str)}", flush=True)
|
||||
except (OSError, UnicodeEncodeError) as e:
|
||||
if _DEBUG:
|
||||
try:
|
||||
sys.stderr.write(f"[phase_event] emit failed: {e}\n")
|
||||
sys.stderr.flush()
|
||||
except (OSError, UnicodeEncodeError):
|
||||
pass # Truly silent on complete I/O failure
|
||||
print(f"[phase_event] emit failed: {e}", file=sys.stderr, flush=True)
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
"""
|
||||
Implementation Plan Normalization Utilities
|
||||
===========================================
|
||||
|
||||
Small helpers for normalizing common LLM/legacy field variants in
|
||||
implementation_plan.json without changing status semantics.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def normalize_subtask_aliases(subtask: dict[str, Any]) -> tuple[dict[str, Any], bool]:
|
||||
"""Normalize common subtask field aliases.
|
||||
|
||||
- If `id` is missing and `subtask_id` exists, copy it into `id` as a string.
|
||||
- If `description` is missing/empty and `title` is a non-empty string, copy it
|
||||
into `description`.
|
||||
"""
|
||||
|
||||
normalized = dict(subtask)
|
||||
changed = False
|
||||
|
||||
id_value = normalized.get("id")
|
||||
id_missing = (
|
||||
"id" not in normalized
|
||||
or id_value is None
|
||||
or (isinstance(id_value, str) and not id_value.strip())
|
||||
)
|
||||
if id_missing and "subtask_id" in normalized:
|
||||
subtask_id = normalized.get("subtask_id")
|
||||
if subtask_id is not None:
|
||||
subtask_id_str = str(subtask_id).strip()
|
||||
if subtask_id_str:
|
||||
normalized["id"] = subtask_id_str
|
||||
changed = True
|
||||
|
||||
description_value = normalized.get("description")
|
||||
description_missing = (
|
||||
"description" not in normalized
|
||||
or description_value is None
|
||||
or (isinstance(description_value, str) and not description_value.strip())
|
||||
)
|
||||
title = normalized.get("title")
|
||||
if description_missing and isinstance(title, str):
|
||||
title_str = title.strip()
|
||||
if title_str:
|
||||
normalized["description"] = title_str
|
||||
changed = True
|
||||
|
||||
return normalized, changed
|
||||
@@ -1,516 +0,0 @@
|
||||
"""
|
||||
Platform Abstraction Layer
|
||||
|
||||
Centralized platform-specific operations for the Python backend.
|
||||
All code that checks sys.platform or handles OS differences should use this module.
|
||||
|
||||
Design principles:
|
||||
- Single source of truth for platform detection
|
||||
- Feature detection over platform detection when possible
|
||||
- Clear, intention-revealing names
|
||||
- Immutable configurations where possible
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
|
||||
# ============================================================================
|
||||
# Type Definitions
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class OS(Enum):
|
||||
"""Supported operating systems."""
|
||||
|
||||
WINDOWS = "Windows"
|
||||
MACOS = "Darwin"
|
||||
LINUX = "Linux"
|
||||
|
||||
|
||||
class ShellType(Enum):
|
||||
"""Available shell types."""
|
||||
|
||||
POWERSHELL = "powershell"
|
||||
CMD = "cmd"
|
||||
BASH = "bash"
|
||||
ZSH = "zsh"
|
||||
FISH = "fish"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Platform Detection
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def get_current_os() -> OS:
|
||||
"""Get the current operating system.
|
||||
|
||||
Returns the OS enum for the current platform. For unsupported Unix-like
|
||||
systems (e.g., FreeBSD, SunOS), defaults to Linux for compatibility.
|
||||
"""
|
||||
system = platform.system()
|
||||
if system == "Windows":
|
||||
return OS.WINDOWS
|
||||
elif system == "Darwin":
|
||||
return OS.MACOS
|
||||
# Default to Linux for other Unix-like systems (FreeBSD, SunOS, etc.)
|
||||
return OS.LINUX
|
||||
|
||||
|
||||
def is_windows() -> bool:
|
||||
"""Check if running on Windows."""
|
||||
return platform.system() == "Windows"
|
||||
|
||||
|
||||
def is_macos() -> bool:
|
||||
"""Check if running on macOS."""
|
||||
return platform.system() == "Darwin"
|
||||
|
||||
|
||||
def is_linux() -> bool:
|
||||
"""Check if running on Linux."""
|
||||
return platform.system() == "Linux"
|
||||
|
||||
|
||||
def is_unix() -> bool:
|
||||
"""Check if running on a Unix-like system (macOS or Linux)."""
|
||||
return not is_windows()
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Path Configuration
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def get_path_delimiter() -> str:
|
||||
"""Get the PATH separator for environment variables."""
|
||||
return ";" if is_windows() else ":"
|
||||
|
||||
|
||||
def get_executable_extension() -> str:
|
||||
"""Get the default file extension for executables."""
|
||||
return ".exe" if is_windows() else ""
|
||||
|
||||
|
||||
def with_executable_extension(base_name: str) -> str:
|
||||
"""Add executable extension to a base name if needed."""
|
||||
if not base_name:
|
||||
return base_name
|
||||
|
||||
# Check if already has extension
|
||||
if os.path.splitext(base_name)[1]:
|
||||
return base_name
|
||||
|
||||
exe_ext = get_executable_extension()
|
||||
return f"{base_name}{exe_ext}" if exe_ext else base_name
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Binary Directories
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def get_binary_directories() -> dict[str, list[str]]:
|
||||
"""
|
||||
Get common binary directories for the current platform.
|
||||
|
||||
Returns:
|
||||
Dict with 'user' and 'system' keys containing lists of directories.
|
||||
"""
|
||||
home_dir = Path.home()
|
||||
|
||||
if is_windows():
|
||||
return {
|
||||
"user": [
|
||||
str(home_dir / "AppData" / "Local" / "Programs"),
|
||||
str(home_dir / "AppData" / "Roaming" / "npm"),
|
||||
str(home_dir / ".local" / "bin"),
|
||||
],
|
||||
"system": [
|
||||
os.environ.get("ProgramFiles", "C:\\Program Files"),
|
||||
os.environ.get("ProgramFiles(x86)", "C:\\Program Files (x86)"),
|
||||
os.path.join(os.environ.get("SystemRoot", "C:\\Windows"), "System32"),
|
||||
],
|
||||
}
|
||||
|
||||
if is_macos():
|
||||
return {
|
||||
"user": [
|
||||
str(home_dir / ".local" / "bin"),
|
||||
str(home_dir / "bin"),
|
||||
],
|
||||
"system": [
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
"/usr/bin",
|
||||
],
|
||||
}
|
||||
|
||||
# Linux
|
||||
return {
|
||||
"user": [
|
||||
str(home_dir / ".local" / "bin"),
|
||||
str(home_dir / "bin"),
|
||||
],
|
||||
"system": [
|
||||
"/usr/bin",
|
||||
"/usr/local/bin",
|
||||
"/snap/bin",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def get_homebrew_path() -> str | None:
|
||||
"""
|
||||
Get Homebrew binary directory (macOS only).
|
||||
|
||||
Returns:
|
||||
Homebrew bin path or None if not on macOS.
|
||||
"""
|
||||
if not is_macos():
|
||||
return None
|
||||
|
||||
homebrew_paths = [
|
||||
"/opt/homebrew/bin", # Apple Silicon
|
||||
"/usr/local/bin", # Intel
|
||||
]
|
||||
|
||||
for brew_path in homebrew_paths:
|
||||
if os.path.exists(brew_path):
|
||||
return brew_path
|
||||
|
||||
return homebrew_paths[0] # Default to Apple Silicon
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Tool Detection
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def find_executable(name: str, additional_paths: list[str] | None = None) -> str | None:
|
||||
"""
|
||||
Find an executable in standard locations.
|
||||
|
||||
Searches:
|
||||
1. System PATH
|
||||
2. Platform-specific binary directories
|
||||
3. Additional custom paths
|
||||
|
||||
Args:
|
||||
name: Name of the executable (without extension)
|
||||
additional_paths: Optional list of additional paths to search
|
||||
|
||||
Returns:
|
||||
Full path to executable if found, None otherwise
|
||||
"""
|
||||
# First check system PATH
|
||||
in_path = shutil.which(name)
|
||||
if in_path:
|
||||
return in_path
|
||||
|
||||
# Check with extension on Windows
|
||||
if is_windows():
|
||||
for ext in [".exe", ".cmd", ".bat"]:
|
||||
in_path = shutil.which(f"{name}{ext}")
|
||||
if in_path:
|
||||
return in_path
|
||||
|
||||
# Search in platform-specific directories
|
||||
bins = get_binary_directories()
|
||||
search_dirs = bins["user"] + bins["system"]
|
||||
|
||||
if additional_paths:
|
||||
search_dirs.extend(additional_paths)
|
||||
|
||||
for directory in search_dirs:
|
||||
if not os.path.isdir(directory):
|
||||
continue
|
||||
|
||||
# Try without extension
|
||||
exe_path = os.path.join(directory, with_executable_extension(name))
|
||||
if os.path.isfile(exe_path):
|
||||
return exe_path
|
||||
|
||||
# Try common extensions on Windows
|
||||
if is_windows():
|
||||
for ext in [".exe", ".cmd", ".bat"]:
|
||||
exe_path = os.path.join(directory, f"{name}{ext}")
|
||||
if os.path.isfile(exe_path):
|
||||
return exe_path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_claude_detection_paths() -> list[str]:
|
||||
"""
|
||||
Get platform-specific paths for Claude CLI detection.
|
||||
|
||||
Returns:
|
||||
List of possible Claude CLI executable paths.
|
||||
"""
|
||||
home_dir = Path.home()
|
||||
paths = []
|
||||
|
||||
if is_windows():
|
||||
paths.extend(
|
||||
[
|
||||
str(
|
||||
home_dir
|
||||
/ "AppData"
|
||||
/ "Local"
|
||||
/ "Programs"
|
||||
/ "claude"
|
||||
/ "claude.exe"
|
||||
),
|
||||
str(home_dir / "AppData" / "Roaming" / "npm" / "claude.cmd"),
|
||||
str(home_dir / ".local" / "bin" / "claude.exe"),
|
||||
r"C:\Program Files\Claude\claude.exe",
|
||||
r"C:\Program Files (x86)\Claude\claude.exe",
|
||||
]
|
||||
)
|
||||
else:
|
||||
paths.extend(
|
||||
[
|
||||
str(home_dir / ".local" / "bin" / "claude"),
|
||||
str(home_dir / "bin" / "claude"),
|
||||
]
|
||||
)
|
||||
|
||||
# Add Homebrew path on macOS
|
||||
if is_macos():
|
||||
brew_path = get_homebrew_path()
|
||||
if brew_path:
|
||||
paths.append(os.path.join(brew_path, "claude"))
|
||||
|
||||
return paths
|
||||
|
||||
|
||||
def get_claude_detection_paths_structured() -> dict[str, list[str] | str]:
|
||||
"""
|
||||
Get platform-specific paths for Claude CLI detection in structured format.
|
||||
|
||||
Returns a dict with categorized paths for different detection strategies:
|
||||
- 'homebrew': Homebrew installation paths (macOS)
|
||||
- 'platform': Platform-specific standard installation locations
|
||||
- 'nvm_versions_dir': NVM versions directory path for scanning Node installations
|
||||
|
||||
This structured format allows callers to implement custom detection logic
|
||||
for each category (e.g., iterating NVM version directories).
|
||||
|
||||
Returns:
|
||||
Dict with 'homebrew', 'platform', and 'nvm_versions_dir' keys
|
||||
"""
|
||||
home_dir = Path.home()
|
||||
|
||||
homebrew_paths = [
|
||||
"/opt/homebrew/bin/claude", # Apple Silicon
|
||||
"/usr/local/bin/claude", # Intel Mac
|
||||
]
|
||||
|
||||
if is_windows():
|
||||
platform_paths = [
|
||||
str(home_dir / "AppData/Local/Programs/claude/claude.exe"),
|
||||
str(home_dir / "AppData/Roaming/npm/claude.cmd"),
|
||||
str(home_dir / ".local/bin/claude.exe"),
|
||||
r"C:\Program Files\Claude\claude.exe",
|
||||
r"C:\Program Files (x86)\Claude\claude.exe",
|
||||
]
|
||||
else:
|
||||
platform_paths = [
|
||||
str(home_dir / ".local" / "bin" / "claude"),
|
||||
str(home_dir / "bin" / "claude"),
|
||||
]
|
||||
|
||||
nvm_versions_dir = str(home_dir / ".nvm" / "versions" / "node")
|
||||
|
||||
return {
|
||||
"homebrew": homebrew_paths,
|
||||
"platform": platform_paths,
|
||||
"nvm_versions_dir": nvm_versions_dir,
|
||||
}
|
||||
|
||||
|
||||
def get_python_commands() -> list[list[str]]:
|
||||
"""
|
||||
Get platform-specific Python command variations as argument sequences.
|
||||
|
||||
Returns command arguments as sequences so callers can pass each entry
|
||||
directly to subprocess.run(cmd) or use cmd[0] with shutil.which().
|
||||
|
||||
Returns:
|
||||
List of command argument lists to try, in order of preference.
|
||||
Each inner list contains the executable and any required arguments.
|
||||
|
||||
Example:
|
||||
for cmd in get_python_commands():
|
||||
if shutil.which(cmd[0]):
|
||||
subprocess.run(cmd + ["--version"])
|
||||
break
|
||||
"""
|
||||
if is_windows():
|
||||
return [["py", "-3"], ["python"], ["python3"], ["py"]]
|
||||
return [["python3"], ["python"]]
|
||||
|
||||
|
||||
def validate_cli_path(cli_path: str) -> bool:
|
||||
"""
|
||||
Validate that a CLI path is secure and executable.
|
||||
|
||||
Prevents command injection attacks by rejecting paths with shell metacharacters,
|
||||
directory traversal patterns, or environment variable expansion.
|
||||
|
||||
Args:
|
||||
cli_path: Path to validate
|
||||
|
||||
Returns:
|
||||
True if path is secure, False otherwise
|
||||
"""
|
||||
if not cli_path or not cli_path.strip():
|
||||
return False
|
||||
|
||||
# Security validation: reject paths with shell metacharacters or other dangerous patterns
|
||||
dangerous_patterns = [
|
||||
r'[;&|`${}[\]<>!"^]', # Shell metacharacters
|
||||
r"%[^%]+%", # Windows environment variable expansion
|
||||
r"\.\./", # Unix directory traversal
|
||||
r"\.\.\\", # Windows directory traversal
|
||||
r"[\r\n\x00]", # Newlines (command injection), null bytes (path truncation)
|
||||
]
|
||||
|
||||
for pattern in dangerous_patterns:
|
||||
if re.search(pattern, cli_path):
|
||||
return False
|
||||
|
||||
# On Windows, validate executable name additionally
|
||||
if is_windows():
|
||||
# Extract just the executable name
|
||||
exe_name = os.path.basename(cli_path)
|
||||
name_without_ext = os.path.splitext(exe_name)[0]
|
||||
|
||||
# Allow only alphanumeric, dots, hyphens, underscores in the name
|
||||
if not name_without_ext or not all(
|
||||
c.isalnum() or c in "._-" for c in name_without_ext
|
||||
):
|
||||
return False
|
||||
|
||||
# Check if path exists (if absolute)
|
||||
if os.path.isabs(cli_path):
|
||||
return os.path.isfile(cli_path)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Shell Execution
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def requires_shell(command: str) -> bool:
|
||||
"""
|
||||
Check if a command requires shell execution on Windows.
|
||||
|
||||
Windows needs shell execution for .cmd and .bat files.
|
||||
|
||||
Args:
|
||||
command: Command string to check
|
||||
|
||||
Returns:
|
||||
True if shell execution is required
|
||||
"""
|
||||
if not is_windows():
|
||||
return False
|
||||
|
||||
_, ext = os.path.splitext(command)
|
||||
return ext.lower() in {".cmd", ".bat", ".ps1"}
|
||||
|
||||
|
||||
def get_comspec_path() -> str:
|
||||
"""
|
||||
Get the path to cmd.exe on Windows.
|
||||
|
||||
Returns:
|
||||
Path to cmd.exe or default location.
|
||||
"""
|
||||
if is_windows():
|
||||
return os.environ.get(
|
||||
"ComSpec",
|
||||
os.path.join(
|
||||
os.environ.get("SystemRoot", "C:\\Windows"), "System32", "cmd.exe"
|
||||
),
|
||||
)
|
||||
return "/bin/sh"
|
||||
|
||||
|
||||
def build_windows_command(cli_path: str, args: list[str]) -> list[str]:
|
||||
"""
|
||||
Build a command array for Windows execution.
|
||||
|
||||
Handles .cmd/.bat files that require shell execution.
|
||||
|
||||
Args:
|
||||
cli_path: Path to the CLI executable
|
||||
args: Command arguments
|
||||
|
||||
Returns:
|
||||
Command array suitable for subprocess.run
|
||||
"""
|
||||
if is_windows() and cli_path.lower().endswith((".cmd", ".bat")):
|
||||
# Use cmd.exe to execute .cmd/.bat files
|
||||
cmd_exe = get_comspec_path()
|
||||
# Properly escape arguments for Windows command line
|
||||
escaped_args = subprocess.list2cmdline(args)
|
||||
return [cmd_exe, "/d", "/s", "/c", f'"{cli_path}" {escaped_args}']
|
||||
|
||||
return [cli_path] + args
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Environment Variables
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def get_env_var(name: str, default: str | None = None) -> str | None:
|
||||
"""
|
||||
Get environment variable value with case-insensitive support on Windows.
|
||||
|
||||
Args:
|
||||
name: Environment variable name
|
||||
default: Default value if not found
|
||||
|
||||
Returns:
|
||||
Environment variable value or default
|
||||
"""
|
||||
if is_windows():
|
||||
# Case-insensitive lookup on Windows
|
||||
for key, value in os.environ.items():
|
||||
if key.lower() == name.lower():
|
||||
return value
|
||||
return default
|
||||
|
||||
return os.environ.get(name, default)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Platform Description
|
||||
# ============================================================================
|
||||
|
||||
|
||||
def get_platform_description() -> str:
|
||||
"""
|
||||
Get a human-readable platform description.
|
||||
|
||||
Returns:
|
||||
String like "Windows (AMD64)" or "macOS (arm64)"
|
||||
"""
|
||||
os_name = {OS.WINDOWS: "Windows", OS.MACOS: "macOS", OS.LINUX: "Linux"}.get(
|
||||
get_current_os(), platform.system()
|
||||
)
|
||||
|
||||
arch = platform.machine()
|
||||
return f"{os_name} ({arch})"
|
||||
@@ -11,7 +11,6 @@ Enhanced with colored output, icons, and better visual formatting.
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from core.plan_normalization import normalize_subtask_aliases
|
||||
from ui import (
|
||||
Icons,
|
||||
bold,
|
||||
@@ -43,7 +42,7 @@ def count_subtasks(spec_dir: Path) -> tuple[int, int]:
|
||||
return 0, 0
|
||||
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
with open(plan_file) as f:
|
||||
plan = json.load(f)
|
||||
|
||||
total = 0
|
||||
@@ -56,7 +55,7 @@ def count_subtasks(spec_dir: Path) -> tuple[int, int]:
|
||||
completed += 1
|
||||
|
||||
return completed, total
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return 0, 0
|
||||
|
||||
|
||||
@@ -81,7 +80,7 @@ def count_subtasks_detailed(spec_dir: Path) -> dict:
|
||||
return result
|
||||
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
with open(plan_file) as f:
|
||||
plan = json.load(f)
|
||||
|
||||
for phase in plan.get("phases", []):
|
||||
@@ -94,7 +93,7 @@ def count_subtasks_detailed(spec_dir: Path) -> dict:
|
||||
result["pending"] += 1
|
||||
|
||||
return result
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return result
|
||||
|
||||
|
||||
@@ -182,7 +181,7 @@ def print_progress_summary(spec_dir: Path, show_next: bool = True) -> None:
|
||||
|
||||
# Phase summary
|
||||
try:
|
||||
with open(spec_dir / "implementation_plan.json", encoding="utf-8") as f:
|
||||
with open(spec_dir / "implementation_plan.json") as f:
|
||||
plan = json.load(f)
|
||||
|
||||
print("\nPhases:")
|
||||
@@ -230,8 +229,8 @@ def print_progress_summary(spec_dir: Path, show_next: bool = True) -> None:
|
||||
f" {icon(Icons.ARROW_RIGHT)} Next: {highlight(next_id)} - {next_desc}"
|
||||
)
|
||||
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
pass # Ignore corrupted/unreadable progress files
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
else:
|
||||
print()
|
||||
print_status("No implementation subtasks yet - planner needs to run", "pending")
|
||||
@@ -302,7 +301,7 @@ def get_plan_summary(spec_dir: Path) -> dict:
|
||||
}
|
||||
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
with open(plan_file) as f:
|
||||
plan = json.load(f)
|
||||
|
||||
summary = {
|
||||
@@ -355,7 +354,7 @@ def get_plan_summary(spec_dir: Path) -> dict:
|
||||
|
||||
return summary
|
||||
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return {
|
||||
"workflow_type": None,
|
||||
"total_phases": 0,
|
||||
@@ -376,11 +375,11 @@ def get_current_phase(spec_dir: Path) -> dict | None:
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
with open(plan_file) as f:
|
||||
plan = json.load(f)
|
||||
|
||||
for phase in plan.get("phases", []):
|
||||
subtasks = phase.get("subtasks", phase.get("chunks", []))
|
||||
subtasks = phase.get("subtasks", [])
|
||||
# Phase is current if it has incomplete subtasks and dependencies are met
|
||||
has_incomplete = any(s.get("status") != "completed" for s in subtasks)
|
||||
if has_incomplete:
|
||||
@@ -396,7 +395,7 @@ def get_current_phase(spec_dir: Path) -> dict | None:
|
||||
|
||||
return None
|
||||
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
@@ -416,39 +415,24 @@ def get_next_subtask(spec_dir: Path) -> dict | None:
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(plan_file, encoding="utf-8") as f:
|
||||
with open(plan_file) as f:
|
||||
plan = json.load(f)
|
||||
|
||||
phases = plan.get("phases", [])
|
||||
|
||||
# Build a map of phase completion
|
||||
phase_complete: dict[str, bool] = {}
|
||||
for i, phase in enumerate(phases):
|
||||
phase_id_value = phase.get("id")
|
||||
phase_id_raw = (
|
||||
phase_id_value if phase_id_value is not None else phase.get("phase")
|
||||
)
|
||||
phase_id_key = (
|
||||
str(phase_id_raw) if phase_id_raw is not None else f"unknown:{i}"
|
||||
)
|
||||
subtasks = phase.get("subtasks", phase.get("chunks", []))
|
||||
phase_complete[phase_id_key] = all(
|
||||
phase_complete = {}
|
||||
for phase in phases:
|
||||
phase_id = phase.get("id") or phase.get("phase")
|
||||
subtasks = phase.get("subtasks", [])
|
||||
phase_complete[phase_id] = all(
|
||||
s.get("status") == "completed" for s in subtasks
|
||||
)
|
||||
|
||||
# Find next available subtask
|
||||
for phase in phases:
|
||||
phase_id_value = phase.get("id")
|
||||
phase_id = (
|
||||
phase_id_value if phase_id_value is not None else phase.get("phase")
|
||||
)
|
||||
depends_on_raw = phase.get("depends_on", [])
|
||||
if isinstance(depends_on_raw, list):
|
||||
depends_on = [str(d) for d in depends_on_raw if d is not None]
|
||||
elif depends_on_raw is None:
|
||||
depends_on = []
|
||||
else:
|
||||
depends_on = [str(depends_on_raw)]
|
||||
phase_id = phase.get("id") or phase.get("phase")
|
||||
depends_on = phase.get("depends_on", [])
|
||||
|
||||
# Check if dependencies are satisfied
|
||||
deps_satisfied = all(phase_complete.get(dep, False) for dep in depends_on)
|
||||
@@ -456,21 +440,18 @@ def get_next_subtask(spec_dir: Path) -> dict | None:
|
||||
continue
|
||||
|
||||
# Find first pending subtask in this phase
|
||||
for subtask in phase.get("subtasks", phase.get("chunks", [])):
|
||||
status = subtask.get("status", "pending")
|
||||
if status in {"pending", "not_started", "not started"}:
|
||||
subtask_out, _changed = normalize_subtask_aliases(subtask)
|
||||
subtask_out["status"] = "pending"
|
||||
for subtask in phase.get("subtasks", []):
|
||||
if subtask.get("status") == "pending":
|
||||
return {
|
||||
**subtask_out,
|
||||
"phase_id": phase_id,
|
||||
"phase_name": phase.get("name"),
|
||||
"phase_num": phase.get("phase"),
|
||||
**subtask,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -1,417 +0,0 @@
|
||||
"""
|
||||
Sentry Error Tracking for Python Backend
|
||||
=========================================
|
||||
|
||||
Initializes Sentry for the Python backend with:
|
||||
- Privacy-preserving path masking (usernames removed)
|
||||
- Release tracking matching the Electron frontend
|
||||
- Environment variable configuration (same as frontend)
|
||||
|
||||
Configuration:
|
||||
- SENTRY_DSN: Required to enable Sentry (same as frontend)
|
||||
- SENTRY_TRACES_SAMPLE_RATE: Performance monitoring sample rate (0-1, default: 0.1)
|
||||
- SENTRY_ENVIRONMENT: Override environment (default: auto-detected)
|
||||
|
||||
Privacy Note:
|
||||
- Usernames are masked from all file paths
|
||||
- Project paths remain visible for debugging (this is expected)
|
||||
- No user identifiers are collected
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Track initialization state
|
||||
_sentry_initialized = False
|
||||
_sentry_enabled = False
|
||||
|
||||
# Production trace sample rate (10%)
|
||||
PRODUCTION_TRACE_SAMPLE_RATE = 0.1
|
||||
|
||||
|
||||
def _get_version() -> str:
|
||||
"""
|
||||
Get the application version.
|
||||
|
||||
Tries to read from package.json in the frontend directory,
|
||||
falling back to a default version.
|
||||
"""
|
||||
try:
|
||||
# Try to find package.json relative to this file
|
||||
backend_dir = Path(__file__).parent.parent
|
||||
frontend_dir = backend_dir.parent / "frontend"
|
||||
package_json = frontend_dir / "package.json"
|
||||
|
||||
if package_json.exists():
|
||||
import json
|
||||
|
||||
with open(package_json, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data.get("version", "0.0.0")
|
||||
except Exception as e:
|
||||
logger.debug(f"Version detection failed: {e}")
|
||||
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
def _mask_user_paths(text: str) -> str:
|
||||
"""
|
||||
Mask user-specific paths for privacy.
|
||||
|
||||
Replaces usernames in common OS path patterns:
|
||||
- macOS: /Users/username/... becomes /Users/***/...
|
||||
- Windows: C:\\Users\\username\\... becomes C:\\Users\\***\\...
|
||||
- Linux: /home/username/... becomes /home/***/...
|
||||
- WSL: /mnt/c/Users/username/... becomes /mnt/c/Users/***/...
|
||||
|
||||
Note: Project paths remain visible for debugging purposes.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
# macOS: /Users/username/...
|
||||
text = re.sub(r"/Users/[^/]+(?=/|$)", "/Users/***", text)
|
||||
|
||||
# Windows: C:\Users\username\...
|
||||
text = re.sub(
|
||||
r"[A-Za-z]:\\Users\\[^\\]+(?=\\|$)",
|
||||
lambda m: f"{m.group(0)[0]}:\\Users\\***",
|
||||
text,
|
||||
)
|
||||
|
||||
# Linux: /home/username/...
|
||||
text = re.sub(r"/home/[^/]+(?=/|$)", "/home/***", text)
|
||||
|
||||
# WSL: /mnt/c/Users/username/... (accessing Windows filesystem from WSL)
|
||||
text = re.sub(
|
||||
r"/mnt/[a-z]/Users/[^/]+(?=/|$)",
|
||||
lambda m: f"{m.group(0)[:6]}/Users/***",
|
||||
text,
|
||||
)
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def _mask_object_paths(obj: Any, _depth: int = 0) -> Any:
|
||||
"""
|
||||
Recursively mask paths in an object.
|
||||
|
||||
Args:
|
||||
obj: The object to mask paths in
|
||||
_depth: Current recursion depth (internal use)
|
||||
|
||||
Returns:
|
||||
Object with paths masked
|
||||
"""
|
||||
# Prevent stack overflow on deeply nested or circular structures
|
||||
if _depth > 50:
|
||||
return obj
|
||||
|
||||
if obj is None:
|
||||
return obj
|
||||
|
||||
if isinstance(obj, str):
|
||||
return _mask_user_paths(obj)
|
||||
|
||||
if isinstance(obj, list):
|
||||
return [_mask_object_paths(item, _depth + 1) for item in obj]
|
||||
|
||||
if isinstance(obj, dict):
|
||||
return {
|
||||
key: _mask_object_paths(value, _depth + 1) for key, value in obj.items()
|
||||
}
|
||||
|
||||
return obj
|
||||
|
||||
|
||||
def _before_send(event: dict, hint: dict) -> dict | None:
|
||||
"""
|
||||
Process event before sending to Sentry.
|
||||
|
||||
Applies privacy masking to all paths in the event.
|
||||
"""
|
||||
if not _sentry_enabled:
|
||||
return None
|
||||
|
||||
# Mask paths in exception stack traces
|
||||
if "exception" in event and "values" in event["exception"]:
|
||||
for exception in event["exception"]["values"]:
|
||||
if "stacktrace" in exception and "frames" in exception["stacktrace"]:
|
||||
for frame in exception["stacktrace"]["frames"]:
|
||||
if "filename" in frame:
|
||||
frame["filename"] = _mask_user_paths(frame["filename"])
|
||||
if "abs_path" in frame:
|
||||
frame["abs_path"] = _mask_user_paths(frame["abs_path"])
|
||||
if "value" in exception:
|
||||
exception["value"] = _mask_user_paths(exception["value"])
|
||||
|
||||
# Mask paths in breadcrumbs
|
||||
if "breadcrumbs" in event:
|
||||
for breadcrumb in event.get("breadcrumbs", {}).get("values", []):
|
||||
if "message" in breadcrumb:
|
||||
breadcrumb["message"] = _mask_user_paths(breadcrumb["message"])
|
||||
if "data" in breadcrumb:
|
||||
breadcrumb["data"] = _mask_object_paths(breadcrumb["data"])
|
||||
|
||||
# Mask paths in message
|
||||
if "message" in event:
|
||||
event["message"] = _mask_user_paths(event["message"])
|
||||
|
||||
# Mask paths in tags
|
||||
if "tags" in event:
|
||||
event["tags"] = _mask_object_paths(event["tags"])
|
||||
|
||||
# Mask paths in contexts
|
||||
if "contexts" in event:
|
||||
event["contexts"] = _mask_object_paths(event["contexts"])
|
||||
|
||||
# Mask paths in extra data
|
||||
if "extra" in event:
|
||||
event["extra"] = _mask_object_paths(event["extra"])
|
||||
|
||||
# Clear user info for privacy
|
||||
if "user" in event:
|
||||
event["user"] = {}
|
||||
|
||||
return event
|
||||
|
||||
|
||||
def init_sentry(
|
||||
component: str = "backend",
|
||||
force_enable: bool = False,
|
||||
) -> bool:
|
||||
"""
|
||||
Initialize Sentry for the Python backend.
|
||||
|
||||
Args:
|
||||
component: Component name for tagging (e.g., "backend", "github-runner")
|
||||
force_enable: Force enable even without packaged app detection
|
||||
|
||||
Returns:
|
||||
True if Sentry was initialized, False otherwise
|
||||
"""
|
||||
global _sentry_initialized, _sentry_enabled
|
||||
|
||||
if _sentry_initialized:
|
||||
return _sentry_enabled
|
||||
|
||||
_sentry_initialized = True
|
||||
|
||||
# Get DSN from environment variable
|
||||
dsn = os.environ.get("SENTRY_DSN", "")
|
||||
|
||||
if not dsn:
|
||||
logger.debug("[Sentry] No SENTRY_DSN configured - error reporting disabled")
|
||||
return False
|
||||
|
||||
# Check if we should enable Sentry
|
||||
# Enable if:
|
||||
# - Running from packaged app (detected by __compiled__ or frozen)
|
||||
# - SENTRY_DEV=true is set
|
||||
# - force_enable is True
|
||||
is_packaged = getattr(sys, "frozen", False) or hasattr(sys, "__compiled__")
|
||||
sentry_dev = os.environ.get("SENTRY_DEV", "").lower() in ("true", "1", "yes")
|
||||
should_enable = is_packaged or sentry_dev or force_enable
|
||||
|
||||
if not should_enable:
|
||||
logger.debug(
|
||||
"[Sentry] Development mode - error reporting disabled (set SENTRY_DEV=true to enable)"
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||
except ImportError:
|
||||
logger.warning("[Sentry] sentry-sdk not installed - error reporting disabled")
|
||||
return False
|
||||
|
||||
# Get configuration from environment variables
|
||||
version = _get_version()
|
||||
environment = os.environ.get(
|
||||
"SENTRY_ENVIRONMENT", "production" if is_packaged else "development"
|
||||
)
|
||||
|
||||
# Get sample rates
|
||||
traces_sample_rate = PRODUCTION_TRACE_SAMPLE_RATE
|
||||
try:
|
||||
env_rate = os.environ.get("SENTRY_TRACES_SAMPLE_RATE")
|
||||
if env_rate:
|
||||
parsed = float(env_rate)
|
||||
if 0 <= parsed <= 1:
|
||||
traces_sample_rate = parsed
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Configure logging integration to capture errors and warnings
|
||||
logging_integration = LoggingIntegration(
|
||||
level=logging.INFO, # Capture INFO and above as breadcrumbs
|
||||
event_level=logging.ERROR, # Send ERROR and above as events
|
||||
)
|
||||
|
||||
# Initialize Sentry with exception handling for malformed DSN
|
||||
try:
|
||||
sentry_sdk.init(
|
||||
dsn=dsn,
|
||||
environment=environment,
|
||||
release=f"auto-claude@{version}",
|
||||
traces_sample_rate=traces_sample_rate,
|
||||
before_send=_before_send,
|
||||
integrations=[logging_integration],
|
||||
# Don't send PII
|
||||
send_default_pii=False,
|
||||
)
|
||||
except Exception as e:
|
||||
# Handle malformed DSN (e.g., missing public key) gracefully
|
||||
# This prevents crashes when SENTRY_DSN is misconfigured
|
||||
logger.warning(
|
||||
f"[Sentry] Failed to initialize - invalid DSN configuration: {e}"
|
||||
)
|
||||
logger.debug(
|
||||
"[Sentry] DSN should be in format: https://PUBLIC_KEY@o123.ingest.sentry.io/PROJECT_ID"
|
||||
)
|
||||
return False
|
||||
|
||||
# Set component tag
|
||||
sentry_sdk.set_tag("component", component)
|
||||
|
||||
_sentry_enabled = True
|
||||
logger.info(
|
||||
f"[Sentry] Backend initialized (component: {component}, release: auto-claude@{version}, traces: {traces_sample_rate})"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def capture_exception(error: Exception, **kwargs) -> None:
|
||||
"""
|
||||
Capture an exception and send to Sentry.
|
||||
|
||||
Safe to call even if Sentry is not initialized.
|
||||
|
||||
Args:
|
||||
error: The exception to capture
|
||||
**kwargs: Additional context to attach to the event
|
||||
"""
|
||||
if not _sentry_enabled:
|
||||
logger.error(f"[Sentry] Not enabled, exception not captured: {error}")
|
||||
return
|
||||
|
||||
try:
|
||||
import sentry_sdk
|
||||
|
||||
with sentry_sdk.push_scope() as scope:
|
||||
for key, value in kwargs.items():
|
||||
# Apply defensive path masking for extra data
|
||||
masked_value = (
|
||||
_mask_object_paths(value)
|
||||
if isinstance(value, (str, dict, list))
|
||||
else value
|
||||
)
|
||||
scope.set_extra(key, masked_value)
|
||||
sentry_sdk.capture_exception(error)
|
||||
except ImportError:
|
||||
logger.error(f"[Sentry] SDK not installed, exception not captured: {error}")
|
||||
except Exception as e:
|
||||
logger.error(f"[Sentry] Failed to capture exception: {e}")
|
||||
|
||||
|
||||
def capture_message(message: str, level: str = "info", **kwargs) -> None:
|
||||
"""
|
||||
Capture a message and send to Sentry.
|
||||
|
||||
Safe to call even if Sentry is not initialized.
|
||||
|
||||
Args:
|
||||
message: The message to capture
|
||||
level: Log level (debug, info, warning, error, fatal)
|
||||
**kwargs: Additional context to attach to the event
|
||||
"""
|
||||
if not _sentry_enabled:
|
||||
return
|
||||
|
||||
try:
|
||||
import sentry_sdk
|
||||
|
||||
with sentry_sdk.push_scope() as scope:
|
||||
for key, value in kwargs.items():
|
||||
# Apply defensive path masking for extra data (same as capture_exception)
|
||||
masked_value = (
|
||||
_mask_object_paths(value)
|
||||
if isinstance(value, (str, dict, list))
|
||||
else value
|
||||
)
|
||||
scope.set_extra(key, masked_value)
|
||||
sentry_sdk.capture_message(message, level=level)
|
||||
except ImportError:
|
||||
logger.debug("[Sentry] SDK not installed")
|
||||
except Exception as e:
|
||||
logger.error(f"[Sentry] Failed to capture message: {e}")
|
||||
|
||||
|
||||
def set_context(name: str, data: dict) -> None:
|
||||
"""
|
||||
Set context data for subsequent events.
|
||||
|
||||
Safe to call even if Sentry is not initialized.
|
||||
|
||||
Args:
|
||||
name: Context name (e.g., "pr_review", "spec")
|
||||
data: Context data dictionary
|
||||
"""
|
||||
if not _sentry_enabled:
|
||||
return
|
||||
|
||||
try:
|
||||
import sentry_sdk
|
||||
|
||||
# Apply path masking to context data before sending to Sentry
|
||||
masked_data = _mask_object_paths(data)
|
||||
sentry_sdk.set_context(name, masked_data)
|
||||
except ImportError:
|
||||
logger.debug("[Sentry] SDK not installed")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to set context '{name}': {e}")
|
||||
|
||||
|
||||
def set_tag(key: str, value: str) -> None:
|
||||
"""
|
||||
Set a tag for subsequent events.
|
||||
|
||||
Safe to call even if Sentry is not initialized.
|
||||
|
||||
Args:
|
||||
key: Tag key
|
||||
value: Tag value
|
||||
"""
|
||||
if not _sentry_enabled:
|
||||
return
|
||||
|
||||
try:
|
||||
import sentry_sdk
|
||||
|
||||
# Apply path masking to tag value
|
||||
masked_value = _mask_user_paths(value) if isinstance(value, str) else value
|
||||
sentry_sdk.set_tag(key, masked_value)
|
||||
except ImportError:
|
||||
logger.debug("[Sentry] SDK not installed")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to set tag '{key}': {e}")
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
"""Check if Sentry is enabled."""
|
||||
return _sentry_enabled
|
||||
|
||||
|
||||
def is_initialized() -> bool:
|
||||
"""Check if Sentry initialization has been attempted."""
|
||||
return _sentry_initialized
|
||||
@@ -21,21 +21,13 @@ Example usage:
|
||||
client = create_simple_client(agent_type="insights", cwd=project_dir)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from agents.tools_pkg import get_agent_config, get_default_thinking_level
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
from core.auth import (
|
||||
configure_sdk_authentication,
|
||||
get_sdk_env_vars,
|
||||
)
|
||||
from core.platform import validate_cli_path
|
||||
from core.auth import get_sdk_env_vars, require_auth_token
|
||||
from phase_config import get_thinking_budget
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_simple_client(
|
||||
agent_type: str = "merge_resolver",
|
||||
@@ -72,16 +64,15 @@ def create_simple_client(
|
||||
Raises:
|
||||
ValueError: If agent_type is not found in AGENT_CONFIGS
|
||||
"""
|
||||
# Get environment variables for SDK (including CLAUDE_CONFIG_DIR if set)
|
||||
# Get authentication
|
||||
oauth_token = require_auth_token()
|
||||
import os
|
||||
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
|
||||
|
||||
# Get environment variables for SDK
|
||||
sdk_env = get_sdk_env_vars()
|
||||
|
||||
# Get the config dir for profile-specific credential lookup
|
||||
# CLAUDE_CONFIG_DIR enables per-profile Keychain entries with SHA256-hashed service names
|
||||
config_dir = sdk_env.get("CLAUDE_CONFIG_DIR")
|
||||
|
||||
# Configure SDK authentication (OAuth or API profile mode)
|
||||
configure_sdk_authentication(config_dir)
|
||||
|
||||
# Get agent configuration (raises ValueError if unknown type)
|
||||
config = get_agent_config(agent_type)
|
||||
|
||||
@@ -93,25 +84,14 @@ def create_simple_client(
|
||||
thinking_level = get_default_thinking_level(agent_type)
|
||||
max_thinking_tokens = get_thinking_budget(thinking_level)
|
||||
|
||||
# Build options dict
|
||||
# Note: SDK bundles its own CLI, so no cli_path detection needed
|
||||
options_kwargs = {
|
||||
"model": model,
|
||||
"system_prompt": system_prompt,
|
||||
"allowed_tools": allowed_tools,
|
||||
"max_turns": max_turns,
|
||||
"cwd": str(cwd.resolve()) if cwd else None,
|
||||
"env": sdk_env,
|
||||
}
|
||||
|
||||
# Only add max_thinking_tokens if not None (Haiku doesn't support extended thinking)
|
||||
if max_thinking_tokens is not None:
|
||||
options_kwargs["max_thinking_tokens"] = max_thinking_tokens
|
||||
|
||||
# Optional: Allow CLI path override via environment variable
|
||||
env_cli_path = os.environ.get("CLAUDE_CLI_PATH")
|
||||
if env_cli_path and validate_cli_path(env_cli_path):
|
||||
options_kwargs["cli_path"] = env_cli_path
|
||||
logger.info(f"Using CLAUDE_CLI_PATH override: {env_cli_path}")
|
||||
|
||||
return ClaudeSDKClient(options=ClaudeAgentOptions(**options_kwargs))
|
||||
return ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model=model,
|
||||
system_prompt=system_prompt,
|
||||
allowed_tools=allowed_tools,
|
||||
max_turns=max_turns,
|
||||
cwd=str(cwd.resolve()) if cwd else None,
|
||||
env=sdk_env,
|
||||
max_thinking_tokens=max_thinking_tokens,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
"""
|
||||
Task event protocol for frontend XState synchronization.
|
||||
|
||||
Protocol: __TASK_EVENT__:{...}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
TASK_EVENT_PREFIX = "__TASK_EVENT__:"
|
||||
_DEBUG = os.environ.get("DEBUG", "").lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaskEventContext:
|
||||
task_id: str
|
||||
spec_id: str
|
||||
project_id: str
|
||||
sequence_start: int = 0
|
||||
|
||||
|
||||
def _load_task_metadata(spec_dir: Path) -> dict:
|
||||
metadata_path = spec_dir / "task_metadata.json"
|
||||
if not metadata_path.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(metadata_path, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _load_last_sequence(spec_dir: Path) -> int:
|
||||
plan_path = spec_dir / "implementation_plan.json"
|
||||
if not plan_path.exists():
|
||||
return 0
|
||||
try:
|
||||
with open(plan_path, encoding="utf-8") as f:
|
||||
plan = json.load(f)
|
||||
last_event = plan.get("lastEvent") or {}
|
||||
seq = last_event.get("sequence")
|
||||
if isinstance(seq, int) and seq >= 0:
|
||||
return seq + 1
|
||||
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
def load_task_event_context(spec_dir: Path) -> TaskEventContext:
|
||||
metadata = _load_task_metadata(spec_dir)
|
||||
task_id = metadata.get("taskId") or metadata.get("task_id") or spec_dir.name
|
||||
spec_id = metadata.get("specId") or metadata.get("spec_id") or spec_dir.name
|
||||
project_id = metadata.get("projectId") or metadata.get("project_id") or ""
|
||||
sequence_start = _load_last_sequence(spec_dir)
|
||||
return TaskEventContext(
|
||||
task_id=str(task_id),
|
||||
spec_id=str(spec_id),
|
||||
project_id=str(project_id),
|
||||
sequence_start=sequence_start,
|
||||
)
|
||||
|
||||
|
||||
class TaskEventEmitter:
|
||||
def __init__(self, context: TaskEventContext) -> None:
|
||||
self._context = context
|
||||
self._sequence = context.sequence_start
|
||||
|
||||
@classmethod
|
||||
def from_spec_dir(cls, spec_dir: Path) -> TaskEventEmitter:
|
||||
return cls(load_task_event_context(spec_dir))
|
||||
|
||||
def emit(self, event_type: str, payload: dict | None = None) -> None:
|
||||
event = {
|
||||
"type": event_type,
|
||||
"taskId": self._context.task_id,
|
||||
"specId": self._context.spec_id,
|
||||
"projectId": self._context.project_id,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"eventId": str(uuid4()),
|
||||
"sequence": self._sequence,
|
||||
}
|
||||
if payload:
|
||||
event.update(payload)
|
||||
|
||||
try:
|
||||
print(f"{TASK_EVENT_PREFIX}{json.dumps(event, default=str)}", flush=True)
|
||||
self._sequence += 1
|
||||
except (OSError, UnicodeEncodeError) as e:
|
||||
if _DEBUG:
|
||||
try:
|
||||
sys.stderr.write(f"[task_event] emit failed: {e}\n")
|
||||
sys.stderr.flush()
|
||||
except (OSError, UnicodeEncodeError):
|
||||
pass # Silent on complete I/O failure
|
||||
+239
-801
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ Workspace Management Package
|
||||
=============================
|
||||
|
||||
Handles workspace isolation through Git worktrees, where each spec
|
||||
gets its own isolated worktree in .auto-claude/worktrees/tasks/{spec-name}/.
|
||||
gets its own isolated worktree in .worktrees/{spec-name}/.
|
||||
|
||||
This package provides:
|
||||
- Workspace setup and configuration
|
||||
@@ -28,10 +28,6 @@ _workspace_module = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_workspace_module)
|
||||
merge_existing_build = _workspace_module.merge_existing_build
|
||||
_run_parallel_merges = _workspace_module._run_parallel_merges
|
||||
AI_MERGE_SYSTEM_PROMPT = _workspace_module.AI_MERGE_SYSTEM_PROMPT
|
||||
_build_merge_prompt = _workspace_module._build_merge_prompt
|
||||
_check_git_conflicts = _workspace_module._check_git_conflicts
|
||||
_rebase_spec_branch = _workspace_module._rebase_spec_branch
|
||||
|
||||
# Models and Enums
|
||||
# Display Functions
|
||||
@@ -66,7 +62,6 @@ from .git_utils import (
|
||||
MAX_SYNTAX_FIX_RETRIES,
|
||||
MERGE_LOCK_TIMEOUT,
|
||||
_create_conflict_file_with_git,
|
||||
_get_binary_file_content_from_ref,
|
||||
_get_changed_files_from_branch,
|
||||
_get_file_content_from_ref,
|
||||
_is_binary_file,
|
||||
@@ -75,7 +70,6 @@ from .git_utils import (
|
||||
_is_process_running,
|
||||
_validate_merged_syntax,
|
||||
create_conflict_file_with_git,
|
||||
get_binary_file_content_from_ref,
|
||||
get_changed_files_from_branch,
|
||||
get_current_branch,
|
||||
get_existing_build_worktree,
|
||||
@@ -111,10 +105,6 @@ __all__ = [
|
||||
# Merge Operations (from workspace.py)
|
||||
"merge_existing_build",
|
||||
"_run_parallel_merges", # Private but used internally
|
||||
"AI_MERGE_SYSTEM_PROMPT", # System prompt for AI merge (ACS-194)
|
||||
"_build_merge_prompt", # Internal prompt builder (ACS-194)
|
||||
"_check_git_conflicts", # Internal git conflict detection (ACS-224)
|
||||
"_rebase_spec_branch", # Internal rebase function (ACS-224)
|
||||
# Models
|
||||
"WorkspaceMode",
|
||||
"WorkspaceChoice",
|
||||
@@ -127,7 +117,6 @@ __all__ = [
|
||||
"get_current_branch",
|
||||
"get_existing_build_worktree",
|
||||
"get_file_content_from_ref",
|
||||
"get_binary_file_content_from_ref",
|
||||
"get_changed_files_from_branch",
|
||||
"is_process_running",
|
||||
"is_binary_file",
|
||||
|
||||
@@ -149,14 +149,7 @@ def print_merge_success(
|
||||
|
||||
|
||||
def print_conflict_info(result: dict) -> None:
|
||||
"""Print information about conflicts that occurred during merge.
|
||||
|
||||
The conflicts can be either:
|
||||
- List of strings (file paths) - for git conflict markers
|
||||
- List of dicts with keys: file, reason, severity - for AI merge failures
|
||||
"""
|
||||
import shlex
|
||||
|
||||
"""Print information about conflicts that occurred during merge."""
|
||||
from ui import highlight, muted, warning
|
||||
|
||||
conflicts = result.get("conflicts", [])
|
||||
@@ -169,57 +162,12 @@ def print_conflict_info(result: dict) -> None:
|
||||
f" {len(conflicts)} file{'s' if len(conflicts) != 1 else ''} had conflicts:"
|
||||
)
|
||||
)
|
||||
|
||||
# Extract file paths from conflicts (handle both strings and dicts)
|
||||
file_paths: list[str] = []
|
||||
has_marker_conflicts = False
|
||||
has_ai_conflicts = False
|
||||
for conflict in conflicts:
|
||||
if isinstance(conflict, str):
|
||||
# Simple string - just the file path
|
||||
file_paths.append(conflict)
|
||||
print(f" {highlight(conflict)}")
|
||||
has_marker_conflicts = True
|
||||
elif isinstance(conflict, dict):
|
||||
# Dict with file, reason, severity keys
|
||||
file_path = conflict.get("file", "unknown")
|
||||
reason = conflict.get("reason", "")
|
||||
severity = conflict.get("severity", "medium")
|
||||
|
||||
# Add severity indicator
|
||||
severity_icon = ""
|
||||
if severity == "critical":
|
||||
severity_icon = "⛔"
|
||||
elif severity == "high":
|
||||
severity_icon = "🔴"
|
||||
elif severity == "medium":
|
||||
severity_icon = "🟡"
|
||||
|
||||
file_paths.append(file_path)
|
||||
# Only add space if icon is present (no trailing space when empty)
|
||||
icon_with_space = f" {severity_icon}" if severity_icon else ""
|
||||
print(f" {highlight(file_path)}{icon_with_space}")
|
||||
if reason:
|
||||
print(f" {muted(reason)}")
|
||||
has_ai_conflicts = True
|
||||
|
||||
for conflict_file in conflicts:
|
||||
print(f" {highlight(conflict_file)}")
|
||||
print()
|
||||
if has_marker_conflicts:
|
||||
print(
|
||||
muted(
|
||||
" Some files may contain conflict markers (<<<<<<< ======= >>>>>>>)."
|
||||
)
|
||||
)
|
||||
if has_ai_conflicts:
|
||||
print(
|
||||
muted(
|
||||
" Some files could not be auto-merged; review and resolve as needed."
|
||||
)
|
||||
)
|
||||
print(muted(" Then run:"))
|
||||
# Quote paths and dedupe while preserving order
|
||||
quoted = " ".join(shlex.quote(p) for p in dict.fromkeys(file_paths))
|
||||
print(f" git add {quoted}")
|
||||
print(muted(" These files have conflict markers (<<<<<<< ======= >>>>>>>)"))
|
||||
print(muted(" Review and resolve them, then run:"))
|
||||
print(f" git add {' '.join(conflicts)}")
|
||||
print(" git commit")
|
||||
print()
|
||||
|
||||
|
||||
@@ -169,15 +169,7 @@ def handle_workspace_choice(
|
||||
if staging_path:
|
||||
print(highlight(f" cd {staging_path}"))
|
||||
else:
|
||||
worktree_path = get_existing_build_worktree(project_dir, spec_name)
|
||||
if worktree_path:
|
||||
print(highlight(f" cd {worktree_path}"))
|
||||
else:
|
||||
print(
|
||||
highlight(
|
||||
f" cd {project_dir}/.auto-claude/worktrees/tasks/{spec_name}"
|
||||
)
|
||||
)
|
||||
print(highlight(f" cd {project_dir}/.worktrees/{spec_name}"))
|
||||
|
||||
# Show likely test/run commands
|
||||
if staging_path:
|
||||
@@ -240,15 +232,7 @@ def handle_workspace_choice(
|
||||
if staging_path:
|
||||
print(highlight(f" cd {staging_path}"))
|
||||
else:
|
||||
worktree_path = get_existing_build_worktree(project_dir, spec_name)
|
||||
if worktree_path:
|
||||
print(highlight(f" cd {worktree_path}"))
|
||||
else:
|
||||
print(
|
||||
highlight(
|
||||
f" cd {project_dir}/.auto-claude/worktrees/tasks/{spec_name}"
|
||||
)
|
||||
)
|
||||
print(highlight(f" cd {project_dir}/.worktrees/{spec_name}"))
|
||||
print()
|
||||
print("When you're ready to add it:")
|
||||
print(highlight(f" python auto-claude/run.py --spec {spec_name} --merge"))
|
||||
|
||||
@@ -10,45 +10,6 @@ import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from core.git_executable import get_git_executable, run_git
|
||||
|
||||
__all__ = [
|
||||
# Exported helpers
|
||||
"get_git_executable",
|
||||
"run_git",
|
||||
# Constants
|
||||
"MAX_FILE_LINES_FOR_AI",
|
||||
"MAX_PARALLEL_AI_MERGES",
|
||||
"LOCK_FILES",
|
||||
"BINARY_EXTENSIONS",
|
||||
"MERGE_LOCK_TIMEOUT",
|
||||
"MAX_SYNTAX_FIX_RETRIES",
|
||||
# Functions
|
||||
"detect_file_renames",
|
||||
"apply_path_mapping",
|
||||
"get_merge_base",
|
||||
"has_uncommitted_changes",
|
||||
"get_current_branch",
|
||||
"get_existing_build_worktree",
|
||||
"get_file_content_from_ref",
|
||||
"get_binary_file_content_from_ref",
|
||||
"get_changed_files_from_branch",
|
||||
"is_process_running",
|
||||
"is_binary_file",
|
||||
"is_lock_file",
|
||||
"validate_merged_syntax",
|
||||
"create_conflict_file_with_git",
|
||||
# Backward compat aliases
|
||||
"_is_process_running",
|
||||
"_is_binary_file",
|
||||
"_is_lock_file",
|
||||
"_validate_merged_syntax",
|
||||
"_get_file_content_from_ref",
|
||||
"_get_binary_file_content_from_ref",
|
||||
"_get_changed_files_from_branch",
|
||||
"_create_conflict_file_with_git",
|
||||
]
|
||||
|
||||
# Constants for merge limits
|
||||
MAX_FILE_LINES_FOR_AI = 5000 # Skip AI for files larger than this
|
||||
MAX_PARALLEL_AI_MERGES = 5 # Limit concurrent AI merge operations
|
||||
@@ -61,7 +22,6 @@ LOCK_FILES = {
|
||||
"pnpm-lock.yaml",
|
||||
"yarn.lock",
|
||||
"bun.lockb",
|
||||
"bun.lock",
|
||||
"Pipfile.lock",
|
||||
"poetry.lock",
|
||||
"uv.lock",
|
||||
@@ -72,7 +32,6 @@ LOCK_FILES = {
|
||||
}
|
||||
|
||||
BINARY_EXTENSIONS = {
|
||||
# Images
|
||||
".png",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
@@ -81,11 +40,6 @@ BINARY_EXTENSIONS = {
|
||||
".webp",
|
||||
".bmp",
|
||||
".svg",
|
||||
".tiff",
|
||||
".tif",
|
||||
".heic",
|
||||
".heif",
|
||||
# Documents
|
||||
".pdf",
|
||||
".doc",
|
||||
".docx",
|
||||
@@ -93,63 +47,32 @@ BINARY_EXTENSIONS = {
|
||||
".xlsx",
|
||||
".ppt",
|
||||
".pptx",
|
||||
# Archives
|
||||
".zip",
|
||||
".tar",
|
||||
".gz",
|
||||
".rar",
|
||||
".7z",
|
||||
".bz2",
|
||||
".xz",
|
||||
".zst",
|
||||
# Executables and libraries
|
||||
".exe",
|
||||
".dll",
|
||||
".so",
|
||||
".dylib",
|
||||
".bin",
|
||||
".msi",
|
||||
".app",
|
||||
# WebAssembly
|
||||
".wasm",
|
||||
# Audio
|
||||
".mp3",
|
||||
".wav",
|
||||
".ogg",
|
||||
".flac",
|
||||
".aac",
|
||||
".m4a",
|
||||
# Video
|
||||
".mp4",
|
||||
".wav",
|
||||
".avi",
|
||||
".mov",
|
||||
".mkv",
|
||||
".webm",
|
||||
".wmv",
|
||||
".flv",
|
||||
# Fonts
|
||||
".woff",
|
||||
".woff2",
|
||||
".ttf",
|
||||
".otf",
|
||||
".eot",
|
||||
# Compiled code
|
||||
".pyc",
|
||||
".pyo",
|
||||
".class",
|
||||
".o",
|
||||
".obj",
|
||||
# Data files
|
||||
".dat",
|
||||
".db",
|
||||
".sqlite",
|
||||
".sqlite3",
|
||||
# Other binary formats
|
||||
".cur",
|
||||
".ani",
|
||||
".pbm",
|
||||
".pgm",
|
||||
".ppm",
|
||||
}
|
||||
|
||||
# Merge lock timeout in seconds
|
||||
@@ -189,8 +112,9 @@ def detect_file_renames(
|
||||
# -M flag enables rename detection
|
||||
# --diff-filter=R shows only renames
|
||||
# --name-status shows status and file names
|
||||
result = run_git(
|
||||
result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"log",
|
||||
"--name-status",
|
||||
"-M",
|
||||
@@ -199,6 +123,8 @@ def detect_file_renames(
|
||||
f"{from_ref}..{to_ref}",
|
||||
],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
@@ -248,21 +174,39 @@ def get_merge_base(project_dir: Path, ref1: str, ref2: str) -> str | None:
|
||||
Returns:
|
||||
Merge-base commit hash, or None if not found
|
||||
"""
|
||||
result = run_git(["merge-base", ref1, ref2], cwd=project_dir)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "merge-base", ref1, ref2],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def has_uncommitted_changes(project_dir: Path) -> bool:
|
||||
"""Check if user has unsaved work."""
|
||||
result = run_git(["status", "--porcelain"], cwd=project_dir)
|
||||
result = subprocess.run(
|
||||
["git", "status", "--porcelain"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return bool(result.stdout.strip())
|
||||
|
||||
|
||||
def get_current_branch(project_dir: Path) -> str:
|
||||
"""Get the current branch name."""
|
||||
result = run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=project_dir)
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
@@ -277,16 +221,10 @@ def get_existing_build_worktree(project_dir: Path, spec_name: str) -> Path | Non
|
||||
Returns:
|
||||
Path to the worktree if it exists for this spec, None otherwise
|
||||
"""
|
||||
# New path first
|
||||
new_path = project_dir / ".auto-claude" / "worktrees" / "tasks" / spec_name
|
||||
if new_path.exists():
|
||||
return new_path
|
||||
|
||||
# Legacy fallback
|
||||
legacy_path = project_dir / ".worktrees" / spec_name
|
||||
if legacy_path.exists():
|
||||
return legacy_path
|
||||
|
||||
# Per-spec worktree path: .worktrees/{spec-name}/
|
||||
worktree_path = project_dir / ".worktrees" / spec_name
|
||||
if worktree_path.exists():
|
||||
return worktree_path
|
||||
return None
|
||||
|
||||
|
||||
@@ -294,29 +232,11 @@ def get_file_content_from_ref(
|
||||
project_dir: Path, ref: str, file_path: str
|
||||
) -> str | None:
|
||||
"""Get file content from a git ref (branch, commit, etc.)."""
|
||||
result = run_git(["show", f"{ref}:{file_path}"], cwd=project_dir)
|
||||
if result.returncode == 0:
|
||||
return result.stdout
|
||||
return None
|
||||
|
||||
|
||||
def get_binary_file_content_from_ref(
|
||||
project_dir: Path, ref: str, file_path: str
|
||||
) -> bytes | None:
|
||||
"""Get binary file content from a git ref (branch, commit, etc.).
|
||||
|
||||
Unlike get_file_content_from_ref, this returns raw bytes without
|
||||
text decoding, suitable for binary files like images, audio, etc.
|
||||
|
||||
Note: Uses subprocess directly with get_git_executable() since
|
||||
run_git() always returns text output.
|
||||
"""
|
||||
git = get_git_executable()
|
||||
result = subprocess.run(
|
||||
[git, "show", f"{ref}:{file_path}"],
|
||||
["git", "show", f"{ref}:{file_path}"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=False, # Return bytes, not text
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
return result.stdout
|
||||
@@ -341,9 +261,11 @@ def get_changed_files_from_branch(
|
||||
Returns:
|
||||
List of (file_path, status) tuples
|
||||
"""
|
||||
result = run_git(
|
||||
["diff", "--name-status", f"{base_branch}...{spec_branch}"],
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-status", f"{base_branch}...{spec_branch}"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
files = []
|
||||
@@ -360,23 +282,15 @@ def get_changed_files_from_branch(
|
||||
return files
|
||||
|
||||
|
||||
def _normalize_path(path: str) -> str:
|
||||
"""Normalize path separators to forward slashes for cross-platform comparison."""
|
||||
return path.replace("\\", "/")
|
||||
|
||||
|
||||
def _is_auto_claude_file(file_path: str) -> bool:
|
||||
"""Check if a file is in the .auto-claude or auto-claude/specs directory.
|
||||
|
||||
Handles both forward slashes (Unix/Git output) and backslashes (Windows).
|
||||
"""
|
||||
normalized = _normalize_path(file_path)
|
||||
"""Check if a file is in the .auto-claude or auto-claude/specs directory."""
|
||||
# These patterns cover the internal spec/build files that shouldn't be merged
|
||||
excluded_patterns = [
|
||||
".auto-claude/",
|
||||
"auto-claude/specs/",
|
||||
]
|
||||
for pattern in excluded_patterns:
|
||||
if normalized.startswith(pattern):
|
||||
if file_path.startswith(pattern):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -570,9 +484,11 @@ def create_conflict_file_with_git(
|
||||
try:
|
||||
# git merge-file <current> <base> <other>
|
||||
# Exit codes: 0 = clean merge, 1 = conflicts, >1 = error
|
||||
result = run_git(
|
||||
["merge-file", "-p", main_path, base_path, wt_path],
|
||||
result = subprocess.run(
|
||||
["git", "merge-file", "-p", main_path, base_path, wt_path],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
# Read the merged content
|
||||
@@ -599,6 +515,5 @@ _is_binary_file = is_binary_file
|
||||
_is_lock_file = is_lock_file
|
||||
_validate_merged_syntax = validate_merged_syntax
|
||||
_get_file_content_from_ref = get_file_content_from_ref
|
||||
_get_binary_file_content_from_ref = get_binary_file_content_from_ref
|
||||
_get_changed_files_from_branch = get_changed_files_from_branch
|
||||
_create_conflict_file_with_git = create_conflict_file_with_git
|
||||
|
||||
@@ -6,6 +6,7 @@ Workspace Models
|
||||
Data classes and enums for workspace management.
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
@@ -93,7 +94,7 @@ class MergeLock:
|
||||
os.close(fd)
|
||||
|
||||
# Write our PID to the lock file
|
||||
self.lock_file.write_text(str(os.getpid()), encoding="utf-8")
|
||||
self.lock_file.write_text(str(os.getpid()))
|
||||
self.acquired = True
|
||||
return self
|
||||
|
||||
@@ -101,7 +102,7 @@ class MergeLock:
|
||||
# Lock file exists - check if process is still running
|
||||
if self.lock_file.exists():
|
||||
try:
|
||||
pid = int(self.lock_file.read_text(encoding="utf-8").strip())
|
||||
pid = int(self.lock_file.read_text().strip())
|
||||
# Import locally to avoid circular dependency
|
||||
import os as _os
|
||||
|
||||
@@ -183,7 +184,7 @@ class SpecNumberLock:
|
||||
os.close(fd)
|
||||
|
||||
# Write our PID to the lock file
|
||||
self.lock_file.write_text(str(os.getpid()), encoding="utf-8")
|
||||
self.lock_file.write_text(str(os.getpid()))
|
||||
self.acquired = True
|
||||
return self
|
||||
|
||||
@@ -191,7 +192,7 @@ class SpecNumberLock:
|
||||
# Lock file exists - check if process is still running
|
||||
if self.lock_file.exists():
|
||||
try:
|
||||
pid = int(self.lock_file.read_text(encoding="utf-8").strip())
|
||||
pid = int(self.lock_file.read_text().strip())
|
||||
import os as _os
|
||||
|
||||
try:
|
||||
@@ -227,12 +228,15 @@ class SpecNumberLock:
|
||||
|
||||
def get_next_spec_number(self) -> int:
|
||||
"""
|
||||
Scan all spec locations and return the next available spec number.
|
||||
|
||||
Must be called while lock is held.
|
||||
|
||||
Compute the next global spec number by scanning the project's specs and all worktree specs.
|
||||
|
||||
Requires the spec-numbering lock to be held; caches the computed maximum for subsequent calls.
|
||||
|
||||
Returns:
|
||||
Next available spec number (global max + 1)
|
||||
int: The next available spec number (highest existing spec number + 1).
|
||||
|
||||
Raises:
|
||||
SpecNumberLockError: If the lock has not been acquired when called.
|
||||
"""
|
||||
if not self.acquired:
|
||||
raise SpecNumberLockError(
|
||||
@@ -249,7 +253,10 @@ class SpecNumberLock:
|
||||
max_number = max(max_number, self._scan_specs_dir(main_specs_dir))
|
||||
|
||||
# 2. Scan all worktree specs
|
||||
worktrees_dir = self.project_dir / ".auto-claude" / "worktrees" / "tasks"
|
||||
from core.config import get_worktree_base_path
|
||||
|
||||
worktree_base_path = get_worktree_base_path(self.project_dir)
|
||||
worktrees_dir = self.project_dir / worktree_base_path
|
||||
if worktrees_dir.exists():
|
||||
for worktree in worktrees_dir.iterdir():
|
||||
if worktree.is_dir():
|
||||
@@ -272,4 +279,4 @@ class SpecNumberLock:
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return max_num
|
||||
return max_num
|
||||
@@ -7,15 +7,12 @@ Functions for setting up and initializing workspaces.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from core.git_executable import run_git
|
||||
from merge import FileTimelineTracker
|
||||
from security.constants import ALLOWLIST_FILENAME, PROFILE_FILENAME
|
||||
from ui import (
|
||||
Icons,
|
||||
MenuOption,
|
||||
@@ -183,106 +180,6 @@ def copy_env_files_to_worktree(project_dir: Path, worktree_path: Path) -> list[s
|
||||
return copied
|
||||
|
||||
|
||||
def symlink_node_modules_to_worktree(
|
||||
project_dir: Path, worktree_path: Path
|
||||
) -> list[str]:
|
||||
"""
|
||||
Symlink node_modules directories from project root to worktree.
|
||||
|
||||
This ensures the worktree has access to dependencies for TypeScript checks
|
||||
and other tooling without requiring a separate npm install.
|
||||
|
||||
Works with npm workspace hoisting where dependencies are hoisted to root
|
||||
and workspace-specific dependencies remain in nested node_modules.
|
||||
|
||||
Args:
|
||||
project_dir: The main project directory
|
||||
worktree_path: Path to the worktree
|
||||
|
||||
Returns:
|
||||
List of symlinked paths (relative to worktree)
|
||||
"""
|
||||
symlinked = []
|
||||
|
||||
# Node modules locations to symlink for TypeScript and tooling support.
|
||||
# These are the standard locations for this monorepo structure.
|
||||
#
|
||||
# Design rationale:
|
||||
# - Hardcoded paths are intentional for simplicity and reliability
|
||||
# - Dynamic discovery (reading workspaces from package.json) would add complexity
|
||||
# and potential failure points without significant benefit
|
||||
# - This monorepo uses npm workspaces with hoisting, so dependencies are primarily
|
||||
# in root node_modules with workspace-specific deps in apps/frontend/node_modules
|
||||
#
|
||||
# To add new workspace locations:
|
||||
# 1. Add (source_rel, target_rel) tuple below
|
||||
# 2. Update the parallel TypeScript implementation in
|
||||
# apps/frontend/src/main/ipc-handlers/terminal/worktree-handlers.ts
|
||||
# 3. Update the pre-commit hook check in .husky/pre-commit if needed
|
||||
node_modules_locations = [
|
||||
("node_modules", "node_modules"),
|
||||
("apps/frontend/node_modules", "apps/frontend/node_modules"),
|
||||
]
|
||||
|
||||
for source_rel, target_rel in node_modules_locations:
|
||||
source_path = project_dir / source_rel
|
||||
target_path = worktree_path / target_rel
|
||||
|
||||
# Skip if source doesn't exist
|
||||
if not source_path.exists():
|
||||
debug(MODULE, f"Skipping {source_rel} - source does not exist")
|
||||
continue
|
||||
|
||||
# Skip if target already exists (don't overwrite existing node_modules)
|
||||
if target_path.exists():
|
||||
debug(MODULE, f"Skipping {target_rel} - target already exists")
|
||||
continue
|
||||
|
||||
# Also skip if target is a symlink (even if broken - exists() returns False for broken symlinks)
|
||||
if target_path.is_symlink():
|
||||
debug(
|
||||
MODULE,
|
||||
f"Skipping {target_rel} - symlink already exists (possibly broken)",
|
||||
)
|
||||
continue
|
||||
|
||||
# Ensure parent directory exists
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
try:
|
||||
if sys.platform == "win32":
|
||||
# On Windows, use junctions instead of symlinks (no admin rights required)
|
||||
# Junctions require absolute paths
|
||||
result = subprocess.run(
|
||||
["cmd", "/c", "mklink", "/J", str(target_path), str(source_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise OSError(result.stderr or "mklink /J failed")
|
||||
else:
|
||||
# On macOS/Linux, use relative symlinks for portability
|
||||
relative_source = os.path.relpath(source_path, target_path.parent)
|
||||
os.symlink(relative_source, target_path)
|
||||
symlinked.append(target_rel)
|
||||
debug(MODULE, f"Symlinked {target_rel} -> {source_path}")
|
||||
except OSError as e:
|
||||
# Symlink/junction creation can fail on some systems (e.g., FAT32 filesystem)
|
||||
# Log warning but don't fail - worktree is still usable, just without
|
||||
# TypeScript checking
|
||||
debug_warning(
|
||||
MODULE,
|
||||
f"Could not symlink {target_rel}: {e}. TypeScript checks may fail.",
|
||||
)
|
||||
# Warn user - pre-commit hooks may fail without dependencies
|
||||
print_status(
|
||||
f"Warning: Could not link {target_rel} - TypeScript checks may fail",
|
||||
"warning",
|
||||
)
|
||||
|
||||
return symlinked
|
||||
|
||||
|
||||
def copy_spec_to_worktree(
|
||||
source_spec_dir: Path,
|
||||
worktree_path: Path,
|
||||
@@ -325,7 +222,6 @@ def setup_workspace(
|
||||
mode: WorkspaceMode,
|
||||
source_spec_dir: Path | None = None,
|
||||
base_branch: str | None = None,
|
||||
use_local_branch: bool = False,
|
||||
) -> tuple[Path, WorktreeManager | None, Path | None]:
|
||||
"""
|
||||
Set up the workspace based on user's choice.
|
||||
@@ -338,7 +234,6 @@ def setup_workspace(
|
||||
mode: The workspace mode to use
|
||||
source_spec_dir: Optional source spec directory to copy to worktree
|
||||
base_branch: Base branch for worktree creation (default: current branch)
|
||||
use_local_branch: If True, use local branch directly instead of preferring origin/branch
|
||||
|
||||
Returns:
|
||||
Tuple of (working_directory, worktree_manager or None, localized_spec_dir or None)
|
||||
@@ -359,9 +254,7 @@ def setup_workspace(
|
||||
# Ensure timeline tracking hook is installed (once per session)
|
||||
ensure_timeline_hook_installed(project_dir)
|
||||
|
||||
manager = WorktreeManager(
|
||||
project_dir, base_branch=base_branch, use_local_branch=use_local_branch
|
||||
)
|
||||
manager = WorktreeManager(project_dir, base_branch=base_branch)
|
||||
manager.setup()
|
||||
|
||||
# Get or create worktree for THIS SPECIFIC SPEC
|
||||
@@ -374,68 +267,6 @@ def setup_workspace(
|
||||
f"Environment files copied: {', '.join(copied_env_files)}", "success"
|
||||
)
|
||||
|
||||
# Symlink node_modules to worktree for TypeScript and tooling support
|
||||
# This allows pre-commit hooks to run typecheck without npm install in worktree
|
||||
symlinked_modules = symlink_node_modules_to_worktree(
|
||||
project_dir, worktree_info.path
|
||||
)
|
||||
if symlinked_modules:
|
||||
print_status(f"Dependencies linked: {', '.join(symlinked_modules)}", "success")
|
||||
|
||||
# Copy security configuration files if they exist
|
||||
# Note: Unlike env files, security files always overwrite to ensure
|
||||
# the worktree uses the same security rules as the main project.
|
||||
# This prevents security bypasses through stale worktree configs.
|
||||
security_files = [
|
||||
ALLOWLIST_FILENAME,
|
||||
PROFILE_FILENAME,
|
||||
]
|
||||
security_files_copied = []
|
||||
|
||||
for filename in security_files:
|
||||
source_file = project_dir / filename
|
||||
if source_file.is_file():
|
||||
target_file = worktree_info.path / filename
|
||||
try:
|
||||
shutil.copy2(source_file, target_file)
|
||||
security_files_copied.append(filename)
|
||||
except (OSError, PermissionError) as e:
|
||||
debug_warning(MODULE, f"Failed to copy {filename}: {e}")
|
||||
print_status(
|
||||
f"Warning: Could not copy {filename} to worktree", "warning"
|
||||
)
|
||||
|
||||
if security_files_copied:
|
||||
print_status(
|
||||
f"Security config copied: {', '.join(security_files_copied)}", "success"
|
||||
)
|
||||
|
||||
# Mark the security profile as inherited from parent project
|
||||
# This prevents hash-based re-analysis which would produce a broken profile
|
||||
# (worktrees lack node_modules and other build artifacts needed for detection)
|
||||
if PROFILE_FILENAME in security_files_copied:
|
||||
profile_path = worktree_info.path / PROFILE_FILENAME
|
||||
try:
|
||||
with open(profile_path, encoding="utf-8") as f:
|
||||
profile_data = json.load(f)
|
||||
profile_data["inherited_from"] = str(project_dir.resolve())
|
||||
with open(profile_path, "w", encoding="utf-8") as f:
|
||||
json.dump(profile_data, f, indent=2)
|
||||
debug(
|
||||
MODULE, f"Marked security profile as inherited from {project_dir}"
|
||||
)
|
||||
except (OSError, json.JSONDecodeError) as e:
|
||||
debug_warning(MODULE, f"Failed to mark profile as inherited: {e}")
|
||||
|
||||
# Ensure .auto-claude/ is in the worktree's .gitignore
|
||||
# This is critical because the worktree inherits .gitignore from the base branch,
|
||||
# which may not have .auto-claude/ if that change wasn't committed/pushed.
|
||||
# Without this, spec files would be committed to the worktree's branch.
|
||||
from init import ensure_gitignore_entry
|
||||
|
||||
if ensure_gitignore_entry(worktree_info.path, ".auto-claude/"):
|
||||
debug(MODULE, "Added .auto-claude/ to worktree's .gitignore")
|
||||
|
||||
# Copy spec files to worktree if provided
|
||||
localized_spec_dir = None
|
||||
if source_spec_dir and source_spec_dir.exists():
|
||||
@@ -478,7 +309,7 @@ def ensure_timeline_hook_installed(project_dir: Path) -> None:
|
||||
|
||||
# Handle worktrees (where .git is a file, not directory)
|
||||
if git_dir.is_file():
|
||||
content = git_dir.read_text(encoding="utf-8").strip()
|
||||
content = git_dir.read_text().strip()
|
||||
if content.startswith("gitdir:"):
|
||||
git_dir = Path(content.split(":", 1)[1].strip())
|
||||
else:
|
||||
@@ -488,7 +319,7 @@ def ensure_timeline_hook_installed(project_dir: Path) -> None:
|
||||
|
||||
# Check if hook already installed
|
||||
if hook_path.exists():
|
||||
if "FileTimelineTracker" in hook_path.read_text(encoding="utf-8"):
|
||||
if "FileTimelineTracker" in hook_path.read_text():
|
||||
debug(MODULE, "FileTimelineTracker hook already installed")
|
||||
return
|
||||
|
||||
@@ -526,7 +357,7 @@ def initialize_timeline_tracking(
|
||||
if source_spec_dir:
|
||||
plan_path = source_spec_dir / "implementation_plan.json"
|
||||
if plan_path.exists():
|
||||
with open(plan_path, encoding="utf-8") as f:
|
||||
with open(plan_path) as f:
|
||||
plan = json.load(f)
|
||||
task_title = plan.get("title", spec_name)
|
||||
task_intent = plan.get("description", "")
|
||||
@@ -537,10 +368,11 @@ def initialize_timeline_tracking(
|
||||
files_to_modify.extend(subtask.get("files", []))
|
||||
|
||||
# Get the current branch point commit
|
||||
# Note: run_git() already handles capture_output and encoding internally
|
||||
result = run_git(
|
||||
["rev-parse", "HEAD"],
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
branch_point = result.stdout.strip() if result.returncode == 0 else None
|
||||
|
||||
|
||||
+154
-1543
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user