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"
|
||||
+121
-431
@@ -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,13 +288,23 @@ 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
|
||||
|
||||
- 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: |
|
||||
@@ -414,28 +315,16 @@ jobs:
|
||||
flatpak install -y --user flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
|
||||
flatpak install -y --user flathub org.electronjs.Electron2.BaseApp//25.08
|
||||
|
||||
- name: Cache pip wheel cache
|
||||
uses: actions/cache@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,9 +332,6 @@ jobs:
|
||||
cd apps/frontend && npm run package:linux -- --config.extraMetadata.version="$VERSION"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
SENTRY_TRACES_SAMPLE_RATE: ${{ secrets.SENTRY_TRACES_SAMPLE_RATE }}
|
||||
SENTRY_PROFILES_SAMPLE_RATE: ${{ secrets.SENTRY_PROFILES_SAMPLE_RATE }}
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -457,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:
|
||||
@@ -512,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)
|
||||
@@ -542,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
|
||||
@@ -654,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);
|
||||
|
||||
+374
-418
@@ -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,12 +231,27 @@ 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: 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: |
|
||||
@@ -352,36 +261,21 @@ jobs:
|
||||
flatpak install -y --user flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
|
||||
flatpak install -y --user flathub org.electronjs.Electron2.BaseApp//25.08
|
||||
|
||||
- name: Cache pip wheel cache
|
||||
uses: actions/cache@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: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -391,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
|
||||
@@ -447,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
|
||||
@@ -529,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: |
|
||||
@@ -542,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') }}
|
||||
@@ -623,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
|
||||
@@ -652,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,328 +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`.
|
||||
**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
|
||||
@@ -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
|
||||
|
||||
@@ -64,4 +64,3 @@ tests/
|
||||
|
||||
# Auto Claude data directory
|
||||
.auto-claude/
|
||||
/gitlab-integration-tests/
|
||||
|
||||
@@ -1,421 +0,0 @@
|
||||
# Token Encryption Investigation
|
||||
|
||||
## Issue Summary
|
||||
|
||||
Auto-Claude users are experiencing API 401 errors ("Invalid bearer token") because the Python backend is passing encrypted tokens (with `enc:` prefix) directly to the Claude Agent SDK without decryption. Standalone Claude Code terminals work correctly because they decrypt these tokens before use.
|
||||
|
||||
**Key insight from user thehaffk:** "python cant unencrypt claude token and it launches session with CLAUDE_CODE_OAUTH_TOKEN=enc:djEwtxMGISt3tQ..."
|
||||
|
||||
## Token Storage Format
|
||||
|
||||
### Encrypted Token Format
|
||||
|
||||
Claude Code CLI stores OAuth tokens in an encrypted format with the prefix `enc:`:
|
||||
|
||||
```text
|
||||
enc:djEwtxMGISt3tQ...
|
||||
```
|
||||
|
||||
This format is used when tokens are stored in:
|
||||
- **macOS**: Keychain (service: "Claude Code-credentials")
|
||||
- **Linux**: Secret Service API (DBus, via secretstorage library)
|
||||
- **Windows**: Credential Manager / .credentials.json files
|
||||
|
||||
### Decrypted Token Format
|
||||
|
||||
Valid Claude OAuth tokens have the format:
|
||||
```text
|
||||
sk-ant-oat01-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
||||
```
|
||||
|
||||
## Current Token Flow (BROKEN)
|
||||
|
||||
1. **Token Storage**: Claude Code CLI stores encrypted token with `enc:` prefix in system keychain
|
||||
2. **Token Retrieval**: `apps/backend/core/auth.py::get_auth_token()` retrieves token from:
|
||||
- Environment variable `CLAUDE_CODE_OAUTH_TOKEN`
|
||||
- OR system keychain via `get_token_from_keychain()`
|
||||
3. **❌ NO DECRYPTION**: Token is returned as-is with `enc:` prefix intact
|
||||
4. **SDK Initialization**: Encrypted token passed to Claude Agent SDK
|
||||
5. **API Call Fails**: SDK sends encrypted token to API → 401 error
|
||||
|
||||
### Proof of Broken Flow
|
||||
|
||||
Test in `apps/backend`:
|
||||
```python
|
||||
import os
|
||||
os.environ['CLAUDE_CODE_OAUTH_TOKEN'] = 'enc:test123'
|
||||
|
||||
from core.auth import get_auth_token
|
||||
token = get_auth_token()
|
||||
print(f"Token: {token}") # Output: "enc:test123"
|
||||
print(f"Encrypted: {token.startswith('enc:')}") # Output: True
|
||||
```
|
||||
|
||||
## How Standalone Claude Code CLI Handles Tokens
|
||||
|
||||
### Current Understanding
|
||||
|
||||
1. **Token Detection**: CLI checks if token starts with `enc:` prefix
|
||||
2. **Decryption**: If encrypted, CLI decrypts using platform-specific keyring access
|
||||
3. **Authentication**: Decrypted `sk-ant-oat01-` token is used for API calls
|
||||
|
||||
### Missing Documentation
|
||||
|
||||
Web search for "Claude Code CLI encrypted token enc: prefix decryption" found:
|
||||
- Token storage formats (JSON with accessToken, refreshToken, expiresAt)
|
||||
- Security issues (tokens exposed in debug logs before v2.1.0)
|
||||
- Keychain access patterns for macOS/Linux/Windows
|
||||
|
||||
**❌ NOT FOUND**: Specific documentation on how Claude Code CLI decrypts `enc:` tokens
|
||||
|
||||
Sources:
|
||||
- [Claude Code CLI over SSH on macOS: Fixing Keychain Access](https://phoenixtrap.com/2025/10/26/claude-code-cli-over-ssh-on-macos-fixing-keychain-access/)
|
||||
- [Identity and Access Management - Claude Code Docs](https://code.claude.com/docs/en/iam)
|
||||
- [Claude Code sessions should be encrypted | yoav.blog](https://yoav.blog/2026/01/09/claude-code-sessions-should-be-encrypted/)
|
||||
|
||||
## Decryption Approach Options
|
||||
|
||||
### Option 1: Claude Agent SDK Built-in Decryption
|
||||
|
||||
**Status**: NEEDS VERIFICATION
|
||||
|
||||
The Claude Agent SDK (`claude-agent-sdk>=0.1.19`) may handle decryption internally if:
|
||||
- Token is passed to SDK still encrypted
|
||||
- SDK detects `enc:` prefix
|
||||
- SDK has access to system keyring for decryption
|
||||
|
||||
**Action Required**: Check if SDK has decryption capabilities by examining:
|
||||
- SDK source code or documentation
|
||||
- Whether SDK expects encrypted vs decrypted tokens
|
||||
- If SDK requires specific environment variables for decryption
|
||||
|
||||
### Option 2: Python Backend Decryption (Recommended)
|
||||
|
||||
**Approach**: Implement decryption in `apps/backend/core/auth.py` before passing to SDK
|
||||
|
||||
**Implementation Pattern**:
|
||||
```python
|
||||
def get_auth_token() -> str | None:
|
||||
"""Get authentication token (decrypted if necessary)."""
|
||||
token = _retrieve_token_from_sources() # From env or keychain
|
||||
|
||||
if token and token.startswith("enc:"):
|
||||
# Decrypt the token
|
||||
token = decrypt_token(token)
|
||||
|
||||
return token
|
||||
|
||||
def decrypt_token(encrypted_token: str) -> str:
|
||||
"""
|
||||
Decrypt Claude Code encrypted token.
|
||||
|
||||
Args:
|
||||
encrypted_token: Token with 'enc:' prefix
|
||||
|
||||
Returns:
|
||||
Decrypted token in format 'sk-ant-oat01-...'
|
||||
"""
|
||||
# Remove 'enc:' prefix
|
||||
encrypted_data = encrypted_token[4:]
|
||||
|
||||
# TODO: Implement decryption logic
|
||||
# Questions to answer:
|
||||
# 1. What encryption algorithm does Claude Code use?
|
||||
# 2. Where is the decryption key stored?
|
||||
# 3. Is the decryption key platform-specific (per-user)?
|
||||
# 4. Can we reuse Claude Code's decryption mechanism?
|
||||
|
||||
raise NotImplementedError("Token decryption not yet implemented")
|
||||
```
|
||||
|
||||
### Option 3: Call Claude Code CLI for Decryption
|
||||
|
||||
**Approach**: Use the Claude Code CLI binary to decrypt tokens
|
||||
|
||||
```python
|
||||
def decrypt_token(encrypted_token: str) -> str:
|
||||
"""Decrypt token by invoking Claude Code CLI."""
|
||||
# Find claude binary
|
||||
claude_path = shutil.which("claude") or "~/.local/bin/claude"
|
||||
|
||||
# Use CLI command to get decrypted token
|
||||
# (if such a command exists - needs research)
|
||||
result = subprocess.run(
|
||||
[claude_path, "auth", "decrypt", encrypted_token],
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
return result.stdout.strip()
|
||||
```
|
||||
|
||||
**Issues**:
|
||||
- Requires Claude Code CLI to be installed
|
||||
- No documented CLI command for token decryption
|
||||
- Adds external dependency
|
||||
|
||||
## Required Investigation Steps
|
||||
|
||||
### 1. Verify SDK Decryption Capabilities
|
||||
|
||||
**Task**: Check if `claude-agent-sdk` handles `enc:` tokens automatically
|
||||
|
||||
**Method**:
|
||||
```bash
|
||||
# In environment with SDK installed
|
||||
python3 << 'EOF'
|
||||
import os
|
||||
os.environ['CLAUDE_CODE_OAUTH_TOKEN'] = 'enc:...' # Real encrypted token
|
||||
|
||||
from claude_agent_sdk import Client
|
||||
# Try creating client - does it decrypt internally?
|
||||
client = Client()
|
||||
# Check if authentication works
|
||||
EOF
|
||||
```
|
||||
|
||||
### 2. Reverse Engineer Claude Code CLI Decryption
|
||||
|
||||
**Task**: Understand how Claude CLI decrypts tokens
|
||||
|
||||
**Method**:
|
||||
- Examine Claude CLI binary (if possible)
|
||||
- Trace system calls when CLI runs (strace on Linux, dtruss on macOS)
|
||||
- Check if CLI accesses specific keychain entries for decryption keys
|
||||
- Look for encryption/decryption libraries used by CLI
|
||||
|
||||
### 3. Find Decryption Key Storage
|
||||
|
||||
**Task**: Locate where decryption keys are stored
|
||||
|
||||
**Hypothesis**: Decryption key stored in:
|
||||
- macOS: Keychain (separate entry from encrypted token)
|
||||
- Linux: Secret Service API
|
||||
- Windows: Credential Manager
|
||||
|
||||
**Verification**:
|
||||
```bash
|
||||
# macOS: List all keychain entries
|
||||
security find-generic-password -a "$(whoami)" | grep -i claude
|
||||
|
||||
# Linux: Use secretstorage to list all items
|
||||
python3 -c "import secretstorage; ..."
|
||||
```
|
||||
|
||||
## Recommended Decryption Approach for Python Backend
|
||||
|
||||
Based on investigation so far, the recommended approach is:
|
||||
|
||||
1. **Detect encrypted tokens**: Check for `enc:` prefix in `get_auth_token()`
|
||||
2. **Decrypt before use**: Implement `decrypt_token()` function
|
||||
3. **Platform-specific decryption**: Use appropriate keyring library:
|
||||
- macOS: Use `subprocess` with `/usr/bin/security` to access decryption key
|
||||
- Linux: Use `secretstorage` library to access Secret Service API
|
||||
- Windows: Access Credential Manager or credentials.json
|
||||
4. **Backward compatibility**: Support both encrypted and plaintext tokens
|
||||
5. **Error handling**: Provide clear error messages if decryption fails
|
||||
|
||||
## Complete Token Flow Trace (Frontend → Backend)
|
||||
|
||||
### 1. Token Retrieval (Frontend)
|
||||
|
||||
**File**: `apps/frontend/src/main/services/profile-service.ts`
|
||||
|
||||
The frontend retrieves the OAuth token from the system keychain but **does not decrypt it**. When no API profile is active (OAuth mode), the frontend returns an empty environment object, which means it relies on:
|
||||
- The token already being in the environment as `CLAUDE_CODE_OAUTH_TOKEN`
|
||||
- OR the Python backend retrieving it from the keychain
|
||||
|
||||
**Key Code**:
|
||||
```typescript
|
||||
// Line 223: Returns empty object in OAuth mode, allowing
|
||||
// CLAUDE_CODE_OAUTH_TOKEN to be used from system keychain
|
||||
```
|
||||
|
||||
### 2. Environment Variable Passing (Frontend → PTY)
|
||||
|
||||
**File**: `apps/frontend/src/main/terminal/pty-manager.ts`
|
||||
|
||||
The PTY manager spawns the terminal shell with environment variables, including `CLAUDE_CODE_OAUTH_TOKEN`:
|
||||
|
||||
**Key Code** (Lines 149-152):
|
||||
```typescript
|
||||
// Remove ANTHROPIC_API_KEY to ensure Claude Code uses OAuth tokens
|
||||
// (CLAUDE_CODE_OAUTH_TOKEN from profileEnv) instead of API keys
|
||||
const { DEBUG: _DEBUG, ANTHROPIC_API_KEY: _ANTHROPIC_API_KEY, ...cleanEnv } = process.env;
|
||||
```
|
||||
|
||||
**Important**: The frontend passes through whatever token value exists in the environment - it does NOT check for `enc:` prefix or decrypt it.
|
||||
|
||||
### 3. Token Retrieval (Backend)
|
||||
|
||||
**File**: `apps/backend/core/auth.py`
|
||||
|
||||
#### 3.1. get_auth_token()
|
||||
|
||||
This function retrieves the token from multiple sources:
|
||||
|
||||
```python
|
||||
def get_auth_token() -> str | None:
|
||||
# First check environment variables
|
||||
for var in AUTH_TOKEN_ENV_VARS: # CLAUDE_CODE_OAUTH_TOKEN, ANTHROPIC_AUTH_TOKEN
|
||||
token = os.environ.get(var)
|
||||
if token:
|
||||
return token # ❌ Returns immediately without checking for enc: prefix
|
||||
|
||||
# Fallback to system credential store
|
||||
return get_token_from_keychain() # ❌ Also returns without decryption
|
||||
```
|
||||
|
||||
**Issue**: Returns token as-is with `enc:` prefix intact.
|
||||
|
||||
#### 3.2. require_auth_token()
|
||||
|
||||
This function calls `get_auth_token()` and raises an error if no token is found:
|
||||
|
||||
```python
|
||||
def require_auth_token() -> str:
|
||||
token = get_auth_token() # ❌ Gets encrypted token
|
||||
if not token:
|
||||
raise ValueError("No OAuth token found...")
|
||||
return token # ❌ Returns encrypted token
|
||||
```
|
||||
|
||||
**Issue**: No decryption step between retrieval and return.
|
||||
|
||||
#### 3.3. ensure_claude_code_oauth_token()
|
||||
|
||||
This function ensures the environment variable is set:
|
||||
|
||||
```python
|
||||
def ensure_claude_code_oauth_token() -> None:
|
||||
if os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"):
|
||||
return
|
||||
|
||||
token = get_auth_token() # ❌ Gets encrypted token
|
||||
if token:
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = token # ❌ Sets encrypted token
|
||||
```
|
||||
|
||||
**Issue**: Propagates encrypted token to environment variable.
|
||||
|
||||
### 4. Token Usage in SDK Client Creation (Backend)
|
||||
|
||||
#### 4.1. Full Client Creation
|
||||
|
||||
**File**: `apps/backend/core/client.py` (see `create_client()` function)
|
||||
|
||||
```python
|
||||
def create_client(...):
|
||||
oauth_token = require_auth_token() # ❌ Gets encrypted token
|
||||
# Ensure SDK can access it via its expected env var
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token # ❌ Sets encrypted token
|
||||
```
|
||||
|
||||
**Issue**: Encrypted token is passed to the Claude Agent SDK, which expects a decrypted `sk-ant-oat01-` token.
|
||||
|
||||
#### 4.2. Simple Client Creation
|
||||
|
||||
**File**: `apps/backend/core/simple_client.py` (see `create_simple_client()` function)
|
||||
|
||||
```python
|
||||
def create_simple_client(...):
|
||||
# Get authentication
|
||||
oauth_token = require_auth_token() # ❌ Gets encrypted token
|
||||
import os
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token # ❌ Sets encrypted token
|
||||
```
|
||||
|
||||
**Issue**: Same problem - encrypted token passed to SDK.
|
||||
|
||||
#### 4.3. Other Usages
|
||||
|
||||
**Files**:
|
||||
- `apps/backend/core/workspace.py` (Line 1966) - AI merge operations
|
||||
- `apps/backend/runners/insights_runner.py` - Insights analysis
|
||||
- `apps/backend/runners/github/batch_issues.py` - GitHub batch operations
|
||||
- `apps/backend/integrations/linear/updater.py` - Linear integration
|
||||
- `apps/backend/commit_message.py` - Commit message generation
|
||||
- `apps/backend/analysis/insight_extractor.py` - Code insights
|
||||
- `apps/backend/merge/ai_resolver/claude_client.py` - Merge resolution
|
||||
|
||||
**All follow the same pattern**: Call `ensure_claude_code_oauth_token()` → encrypted token in environment → SDK receives encrypted token → API 401 error.
|
||||
|
||||
### 5. Where Decryption Should Be Inserted
|
||||
|
||||
Based on the flow analysis, decryption should be added at **the earliest point of token retrieval** to avoid duplicating decryption logic:
|
||||
|
||||
**RECOMMENDED INSERTION POINT**: `apps/backend/core/auth.py::get_auth_token()`
|
||||
|
||||
```python
|
||||
def get_auth_token() -> str | None:
|
||||
# First check environment variables
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
token = os.environ.get(var)
|
||||
if token:
|
||||
# ✅ INSERT DECRYPTION HERE
|
||||
if token.startswith("enc:"):
|
||||
token = decrypt_token(token)
|
||||
return token
|
||||
|
||||
# Fallback to system credential store
|
||||
token = get_token_from_keychain()
|
||||
# ✅ ALSO DECRYPT KEYCHAIN TOKENS
|
||||
if token and token.startswith("enc:"):
|
||||
token = decrypt_token(token)
|
||||
return token
|
||||
```
|
||||
|
||||
**Benefits of this approach**:
|
||||
1. Single location for decryption logic
|
||||
2. All downstream functions automatically get decrypted tokens
|
||||
3. Backward compatible (plaintext tokens pass through unchanged)
|
||||
4. Consistent behavior across all token sources (env vars and keychain)
|
||||
|
||||
**Alternative insertion points** (NOT recommended):
|
||||
- `require_auth_token()` - Would need similar logic in `get_auth_token()` for non-required usage
|
||||
- `create_client()` - Would need duplication in `create_simple_client()` and all other clients
|
||||
- `ensure_claude_code_oauth_token()` - Would miss direct `get_auth_token()` calls
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. ✅ Document current token flow and identify issue (THIS FILE - COMPLETED)
|
||||
2. ✅ Trace token flow from frontend to backend (THIS FILE - COMPLETED)
|
||||
3. ✅ Identify where decryption should be inserted (THIS FILE - COMPLETED)
|
||||
4. ⏳ Verify if Claude Agent SDK handles decryption internally
|
||||
5. ⏳ Reverse engineer or document Claude Code CLI decryption mechanism
|
||||
6. ⏳ Implement `decrypt_token()` function in `apps/backend/core/auth.py`
|
||||
7. ⏳ Add encryption detection and auto-decryption to `get_auth_token()`
|
||||
8. ⏳ Test with real encrypted tokens on macOS and Linux
|
||||
9. ⏳ Add comprehensive error handling for decryption failures
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **What encryption algorithm does Claude Code use for `enc:` tokens?**
|
||||
- Possible: AES-256, ChaCha20, or similar
|
||||
- Key derivation method?
|
||||
|
||||
2. **Where is the decryption key stored?**
|
||||
- Same keychain entry as encrypted token?
|
||||
- Separate keychain entry?
|
||||
- Derived from system/user credentials?
|
||||
|
||||
3. **Does Claude Agent SDK expect encrypted or decrypted tokens?**
|
||||
- If it expects decrypted: we must decrypt before passing
|
||||
- If it handles encryption: we may be missing SDK configuration
|
||||
|
||||
4. **Is there a Claude Code CLI command to decrypt tokens?**
|
||||
- `claude auth decrypt <token>`?
|
||||
- `claude auth get-token`?
|
||||
- No documented command found in research
|
||||
|
||||
5. **Can we reuse Claude Code's decryption mechanism?**
|
||||
- Import decryption functions from CLI?
|
||||
- Call CLI as subprocess?
|
||||
- Implement decryption ourselves?
|
||||
|
||||
## References
|
||||
|
||||
- Issue: [GitHub #1223: API Error 401](https://github.com/AndyMik90/Auto-Claude/issues/1223)
|
||||
- Current auth implementation: `apps/backend/core/auth.py`
|
||||
- SDK client initialization: `apps/backend/core/client.py`
|
||||
- Requirements: `apps/backend/requirements.txt` (includes `secretstorage>=3.3.3` for Linux)
|
||||
@@ -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.5"
|
||||
__version__ = "2.7.2-beta.10"
|
||||
__author__ = "Auto Claude Team"
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
"""
|
||||
GitLab Test Fixtures
|
||||
====================
|
||||
|
||||
Mock data and fixtures for GitLab integration tests.
|
||||
"""
|
||||
|
||||
# Sample GitLab MR data
|
||||
SAMPLE_MR_DATA = {
|
||||
"iid": 123,
|
||||
"id": 12345,
|
||||
"title": "Add user authentication feature",
|
||||
"description": "Implement OAuth2 login with Google and GitHub providers",
|
||||
"author": {
|
||||
"id": 1,
|
||||
"username": "john_doe",
|
||||
"name": "John Doe",
|
||||
"email": "john@example.com",
|
||||
},
|
||||
"source_branch": "feature/oauth-auth",
|
||||
"target_branch": "main",
|
||||
"state": "opened",
|
||||
"draft": False,
|
||||
"merge_status": "can_be_merged",
|
||||
"web_url": "https://gitlab.com/group/project/-/merge_requests/123",
|
||||
"created_at": "2025-01-14T10:00:00.000Z",
|
||||
"updated_at": "2025-01-14T12:00:00.000Z",
|
||||
"labels": ["feature", "authentication"],
|
||||
"assignees": [],
|
||||
}
|
||||
|
||||
SAMPLE_MR_CHANGES = {
|
||||
"id": 12345,
|
||||
"iid": 123,
|
||||
"project_id": 1,
|
||||
"title": "Add user authentication feature",
|
||||
"description": "Implement OAuth2 login",
|
||||
"state": "opened",
|
||||
"created_at": "2025-01-14T10:00:00.000Z",
|
||||
"updated_at": "2025-01-14T12:00:00.000Z",
|
||||
"merge_status": "can_be_merged",
|
||||
"additions": 150,
|
||||
"deletions": 20,
|
||||
"changed_files_count": 5,
|
||||
"changes": [
|
||||
{
|
||||
"old_path": "src/auth/__init__.py",
|
||||
"new_path": "src/auth/__init__.py",
|
||||
"diff": "@@ -0,0 +1,5 @@\n+from .oauth import OAuthHandler\n+from .providers import GoogleProvider, GitHubProvider",
|
||||
"new_file": False,
|
||||
"renamed_file": False,
|
||||
"deleted_file": False,
|
||||
},
|
||||
{
|
||||
"old_path": "src/auth/oauth.py",
|
||||
"new_path": "src/auth/oauth.py",
|
||||
"diff": "@@ -0,0 +1,50 @@\n+class OAuthHandler:\n+ def handle_callback(self, request):\n+ pass",
|
||||
"new_file": True,
|
||||
"renamed_file": False,
|
||||
"deleted_file": False,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
SAMPLE_MR_COMMITS = [
|
||||
{
|
||||
"id": "abc123def456",
|
||||
"short_id": "abc123de",
|
||||
"title": "Add OAuth handler",
|
||||
"message": "Add OAuth handler",
|
||||
"author_name": "John Doe",
|
||||
"author_email": "john@example.com",
|
||||
"authored_date": "2025-01-14T10:00:00.000Z",
|
||||
"created_at": "2025-01-14T10:00:00.000Z",
|
||||
},
|
||||
{
|
||||
"id": "def456ghi789",
|
||||
"short_id": "def456gh",
|
||||
"title": "Add Google provider",
|
||||
"message": "Add Google provider",
|
||||
"author_name": "John Doe",
|
||||
"author_email": "john@example.com",
|
||||
"authored_date": "2025-01-14T11:00:00.000Z",
|
||||
"created_at": "2025-01-14T11:00:00.000Z",
|
||||
},
|
||||
]
|
||||
|
||||
# Sample GitLab issue data
|
||||
SAMPLE_ISSUE_DATA = {
|
||||
"iid": 42,
|
||||
"id": 42,
|
||||
"title": "Bug: Login button not working",
|
||||
"description": "Clicking the login button does nothing",
|
||||
"author": {
|
||||
"id": 2,
|
||||
"username": "jane_smith",
|
||||
"name": "Jane Smith",
|
||||
"email": "jane@example.com",
|
||||
},
|
||||
"state": "opened",
|
||||
"labels": ["bug", "urgent"],
|
||||
"assignees": [],
|
||||
"milestone": None,
|
||||
"web_url": "https://gitlab.com/group/project/-/issues/42",
|
||||
"created_at": "2025-01-14T09:00:00.000Z",
|
||||
"updated_at": "2025-01-14T09:30:00.000Z",
|
||||
}
|
||||
|
||||
# Sample GitLab pipeline data
|
||||
SAMPLE_PIPELINE_DATA = {
|
||||
"id": 1001,
|
||||
"iid": 1,
|
||||
"project_id": 1,
|
||||
"ref": "feature/oauth-auth",
|
||||
"sha": "abc123def456",
|
||||
"status": "success",
|
||||
"source": "merge_request_event",
|
||||
"created_at": "2025-01-14T10:30:00.000Z",
|
||||
"updated_at": "2025-01-14T10:35:00.000Z",
|
||||
"finished_at": "2025-01-14T10:35:00.000Z",
|
||||
"duration": 300,
|
||||
"web_url": "https://gitlab.com/group/project/-/pipelines/1001",
|
||||
}
|
||||
|
||||
SAMPLE_PIPELINE_JOBS = [
|
||||
{
|
||||
"id": 5001,
|
||||
"name": "test",
|
||||
"stage": "test",
|
||||
"status": "success",
|
||||
"started_at": "2025-01-14T10:31:00.000Z",
|
||||
"finished_at": "2025-01-14T10:34:00.000Z",
|
||||
"duration": 180,
|
||||
"allow_failure": False,
|
||||
},
|
||||
{
|
||||
"id": 5002,
|
||||
"name": "lint",
|
||||
"stage": "test",
|
||||
"status": "success",
|
||||
"started_at": "2025-01-14T10:31:00.000Z",
|
||||
"finished_at": "2025-01-14T10:32:00.000Z",
|
||||
"duration": 60,
|
||||
"allow_failure": False,
|
||||
},
|
||||
]
|
||||
|
||||
# Sample GitLab discussion/note data
|
||||
SAMPLE_MR_DISCUSSIONS = [
|
||||
{
|
||||
"id": "d1",
|
||||
"notes": [
|
||||
{
|
||||
"id": 1001,
|
||||
"type": "DiscussionNote",
|
||||
"author": {"username": "coderabbit[bot]"},
|
||||
"body": "Consider adding error handling for OAuth failures",
|
||||
"created_at": "2025-01-14T11:00:00.000Z",
|
||||
"system": False,
|
||||
"resolvable": True,
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
SAMPLE_MR_NOTES = [
|
||||
{
|
||||
"id": 2001,
|
||||
"type": "DiscussionNote",
|
||||
"author": {"username": "reviewer_user"},
|
||||
"body": "LGTM, just one comment",
|
||||
"created_at": "2025-01-14T12:00:00.000Z",
|
||||
"system": False,
|
||||
}
|
||||
]
|
||||
|
||||
# Mock GitLab config
|
||||
MOCK_GITLAB_CONFIG = {
|
||||
"token": "glpat-test-token-12345",
|
||||
"project": "group/project",
|
||||
"instance_url": "https://gitlab.example.com",
|
||||
}
|
||||
|
||||
|
||||
def create_mock_client(project_dir=None):
|
||||
"""Create a mock GitLab client for testing.
|
||||
|
||||
Args:
|
||||
project_dir: Optional project directory path (uses temp dir if None)
|
||||
|
||||
Returns:
|
||||
Configured GitLabClient instance
|
||||
"""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from runners.gitlab.glab_client import GitLabClient, GitLabConfig
|
||||
|
||||
if project_dir is None:
|
||||
project_dir = Path(tempfile.mkdtemp())
|
||||
else:
|
||||
project_dir = Path(project_dir)
|
||||
|
||||
config = GitLabConfig(**MOCK_GITLAB_CONFIG)
|
||||
return GitLabClient(project_dir=project_dir, config=config)
|
||||
|
||||
|
||||
def mock_mr_data(**overrides):
|
||||
"""Create mock MR data with optional overrides."""
|
||||
import copy
|
||||
|
||||
data = copy.deepcopy(SAMPLE_MR_DATA)
|
||||
|
||||
# Handle special case for author override
|
||||
if "author" in overrides:
|
||||
author_value = overrides.pop("author")
|
||||
if isinstance(author_value, str):
|
||||
# If author is a string, update the username field
|
||||
data["author"]["username"] = author_value
|
||||
else:
|
||||
# Otherwise, merge the author dict
|
||||
data["author"].update(author_value)
|
||||
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
def mock_mr_changes(**overrides):
|
||||
"""Create mock MR changes with optional overrides."""
|
||||
data = SAMPLE_MR_CHANGES.copy()
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
def mock_issue_data(**overrides):
|
||||
"""Create mock issue data with optional overrides."""
|
||||
data = SAMPLE_ISSUE_DATA.copy()
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
def mock_pipeline_data(**overrides):
|
||||
"""Create mock pipeline data with optional overrides."""
|
||||
data = SAMPLE_PIPELINE_DATA.copy()
|
||||
data.update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
def mock_pipeline_jobs(**overrides):
|
||||
"""Create mock pipeline jobs with optional overrides."""
|
||||
data = SAMPLE_PIPELINE_JOBS.copy()
|
||||
if overrides:
|
||||
data[0].update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
def mock_mr_commits(**overrides):
|
||||
"""Create mock MR commits with optional overrides."""
|
||||
import copy
|
||||
|
||||
data = copy.deepcopy(SAMPLE_MR_COMMITS)
|
||||
if overrides and data:
|
||||
data[0].update(overrides)
|
||||
return data
|
||||
|
||||
|
||||
def get_mock_diff() -> str:
|
||||
"""Get a mock diff string for testing."""
|
||||
return """diff --git a/src/auth/oauth.py b/src/auth/oauth.py
|
||||
new file mode 100644
|
||||
index 0000000..abc1234
|
||||
--- /dev/null
|
||||
+++ b/src/auth/oauth.py
|
||||
@@ -0,0 +1,50 @@
|
||||
+class OAuthHandler:
|
||||
+ def handle_callback(self, request):
|
||||
+ pass
|
||||
diff --git a/src/auth/providers.py b/src/auth/providers.py
|
||||
new file mode 100644
|
||||
index 0000000..def5678
|
||||
--- /dev/null
|
||||
+++ b/src/auth/providers.py
|
||||
@@ -0,0 +1,30 @@
|
||||
+class GoogleProvider:
|
||||
+ pass
|
||||
+
|
||||
+class GitHubProvider:
|
||||
+ pass
|
||||
"""
|
||||
@@ -1,391 +0,0 @@
|
||||
"""
|
||||
Tests for GitLab Auto-fix Processor
|
||||
======================================
|
||||
|
||||
Tests for auto-fix workflow, permission verification, and state management.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from runners.gitlab.autofix_processor import AutoFixProcessor
|
||||
from runners.gitlab.models import AutoFixState, AutoFixStatus, GitLabRunnerConfig
|
||||
from runners.gitlab.permissions import GitLabPermissionChecker
|
||||
except ImportError:
|
||||
from models import AutoFixState, AutoFixStatus, GitLabRunnerConfig
|
||||
from runners.gitlab.autofix_processor import AutoFixProcessor
|
||||
from runners.gitlab.permissions import GitLabPermissionChecker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Create a mock GitLab config."""
|
||||
config = MagicMock(spec=GitLabRunnerConfig)
|
||||
config.project = "namespace/test-project"
|
||||
config.instance_url = "https://gitlab.example.com"
|
||||
config.auto_fix_enabled = True
|
||||
config.auto_fix_labels = ["auto-fix", "autofix"]
|
||||
config.token = "test-token"
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_permission_checker():
|
||||
"""Create a mock permission checker."""
|
||||
checker = MagicMock(spec=GitLabPermissionChecker)
|
||||
checker.verify_automation_trigger = AsyncMock()
|
||||
return checker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_gitlab_dir(tmp_path):
|
||||
"""Create a temporary GitLab directory."""
|
||||
gitlab_dir = tmp_path / ".auto-claude" / "gitlab"
|
||||
gitlab_dir.mkdir(parents=True, exist_ok=True)
|
||||
return gitlab_dir
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def processor(mock_config, mock_permission_checker, tmp_path, tmp_gitlab_dir):
|
||||
"""Create an AutoFixProcessor instance."""
|
||||
return AutoFixProcessor(
|
||||
gitlab_dir=tmp_gitlab_dir,
|
||||
config=mock_config,
|
||||
permission_checker=mock_permission_checker,
|
||||
progress_callback=None,
|
||||
)
|
||||
|
||||
|
||||
class TestProcessIssue:
|
||||
"""Tests for issue processing."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_issue_success(
|
||||
self, processor, mock_permission_checker, tmp_gitlab_dir
|
||||
):
|
||||
"""Test successful issue processing."""
|
||||
issue = {
|
||||
"iid": 123,
|
||||
"title": "Fix this bug",
|
||||
"description": "Please fix",
|
||||
"labels": ["auto-fix"],
|
||||
}
|
||||
|
||||
mock_permission_checker.verify_automation_trigger.return_value = MagicMock(
|
||||
allowed=True,
|
||||
username="developer",
|
||||
role="MAINTAINER",
|
||||
)
|
||||
|
||||
result = await processor.process_issue(
|
||||
issue_iid=123,
|
||||
issue=issue,
|
||||
trigger_label="auto-fix",
|
||||
)
|
||||
|
||||
assert result.issue_iid == 123
|
||||
assert result.status == AutoFixStatus.CREATING_SPEC
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_issue_permission_denied(
|
||||
self, processor, mock_permission_checker, tmp_gitlab_dir
|
||||
):
|
||||
"""Test issue processing with permission denied."""
|
||||
issue = {
|
||||
"iid": 456,
|
||||
"title": "Unauthorized fix",
|
||||
"labels": ["auto-fix"],
|
||||
}
|
||||
|
||||
mock_permission_checker.verify_automation_trigger.return_value = MagicMock(
|
||||
allowed=False,
|
||||
username="outsider",
|
||||
role="NONE",
|
||||
reason="Not a maintainer",
|
||||
)
|
||||
|
||||
with pytest.raises(PermissionError):
|
||||
await processor.process_issue(
|
||||
issue_iid=456,
|
||||
issue=issue,
|
||||
trigger_label="auto-fix",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_issue_in_progress(
|
||||
self, processor, mock_permission_checker, tmp_gitlab_dir
|
||||
):
|
||||
"""Test that in-progress issues are not reprocessed."""
|
||||
issue = {
|
||||
"iid": 789,
|
||||
"title": "Already processing",
|
||||
"labels": ["auto-fix"],
|
||||
}
|
||||
|
||||
# Create existing state in progress
|
||||
existing_state = AutoFixState(
|
||||
issue_iid=789,
|
||||
issue_url="https://gitlab.example.com/issue/789",
|
||||
project="namespace/test-project",
|
||||
status=AutoFixStatus.ANALYZING,
|
||||
)
|
||||
await existing_state.save(tmp_gitlab_dir)
|
||||
|
||||
result = await processor.process_issue(
|
||||
issue_iid=789,
|
||||
issue=issue,
|
||||
trigger_label="auto-fix",
|
||||
)
|
||||
|
||||
# Should return the existing state
|
||||
assert result.status == AutoFixStatus.ANALYZING
|
||||
|
||||
|
||||
class TestCheckLabeledIssues:
|
||||
"""Tests for checking labeled issues."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_labeled_issues_finds_new(
|
||||
self, processor, mock_permission_checker
|
||||
):
|
||||
"""Test finding new labeled issues."""
|
||||
all_issues = [
|
||||
{
|
||||
"iid": 1,
|
||||
"title": "Has auto-fix label",
|
||||
"labels": ["auto-fix"],
|
||||
},
|
||||
{
|
||||
"iid": 2,
|
||||
"title": "Has autofix label",
|
||||
"labels": ["autofix"],
|
||||
},
|
||||
{
|
||||
"iid": 3,
|
||||
"title": "No label",
|
||||
"labels": [],
|
||||
},
|
||||
]
|
||||
|
||||
# Permission checks pass
|
||||
mock_permission_checker.verify_automation_trigger.return_value = MagicMock(
|
||||
allowed=True
|
||||
)
|
||||
|
||||
result = await processor.check_labeled_issues(
|
||||
all_issues, verify_permissions=True
|
||||
)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["issue_iid"] == 1
|
||||
assert result[1]["issue_iid"] == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_labeled_issues_filters_in_queue(
|
||||
self, processor, mock_permission_checker, tmp_gitlab_dir
|
||||
):
|
||||
"""Test that issues already in queue are filtered out."""
|
||||
# Create existing state for issue 1
|
||||
existing_state = AutoFixState(
|
||||
issue_iid=1,
|
||||
issue_url="https://gitlab.example.com/issue/1",
|
||||
project="namespace/test-project",
|
||||
status=AutoFixStatus.ANALYZING,
|
||||
)
|
||||
await existing_state.save(tmp_gitlab_dir)
|
||||
|
||||
all_issues = [
|
||||
{
|
||||
"iid": 1,
|
||||
"title": "Already in queue",
|
||||
"labels": ["auto-fix"],
|
||||
},
|
||||
{
|
||||
"iid": 2,
|
||||
"title": "New issue",
|
||||
"labels": ["auto-fix"],
|
||||
},
|
||||
]
|
||||
|
||||
mock_permission_checker.verify_automation_trigger.return_value = MagicMock(
|
||||
allowed=True
|
||||
)
|
||||
|
||||
result = await processor.check_labeled_issues(
|
||||
all_issues, verify_permissions=True
|
||||
)
|
||||
|
||||
# Should only return issue 2 (issue 1 is already in queue)
|
||||
assert len(result) == 1
|
||||
assert result[0]["issue_iid"] == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_labeled_issues_permission_filtering(
|
||||
self, processor, mock_permission_checker
|
||||
):
|
||||
"""Test that unauthorized issues are filtered out."""
|
||||
all_issues = [
|
||||
{
|
||||
"iid": 1,
|
||||
"title": "Authorized issue",
|
||||
"labels": ["auto-fix"],
|
||||
},
|
||||
{
|
||||
"iid": 2,
|
||||
"title": "Unauthorized issue",
|
||||
"labels": ["auto-fix"],
|
||||
},
|
||||
]
|
||||
|
||||
def make_permission_result(issue_iid, trigger_label):
|
||||
if issue_iid == 1:
|
||||
return MagicMock(allowed=True)
|
||||
else:
|
||||
return MagicMock(allowed=False, reason="Not authorized")
|
||||
|
||||
mock_permission_checker.verify_automation_trigger.side_effect = (
|
||||
make_permission_result
|
||||
)
|
||||
|
||||
result = await processor.check_labeled_issues(
|
||||
all_issues, verify_permissions=True
|
||||
)
|
||||
|
||||
# Should only return issue 1
|
||||
assert len(result) == 1
|
||||
assert result[0]["issue_iid"] == 1
|
||||
|
||||
|
||||
class TestGetQueue:
|
||||
"""Tests for getting auto-fix queue."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_queue_empty(self, processor, tmp_gitlab_dir):
|
||||
"""Test getting queue when empty."""
|
||||
queue = await processor.get_queue()
|
||||
|
||||
assert queue == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_queue_with_items(self, processor, tmp_gitlab_dir):
|
||||
"""Test getting queue with items."""
|
||||
# Create some states
|
||||
for i in [1, 2, 3]:
|
||||
state = AutoFixState(
|
||||
issue_iid=i,
|
||||
issue_url=f"https://gitlab.example.com/issue/{i}",
|
||||
project="namespace/test-project",
|
||||
status=AutoFixStatus.ANALYZING,
|
||||
)
|
||||
await state.save(tmp_gitlab_dir)
|
||||
|
||||
queue = await processor.get_queue()
|
||||
|
||||
assert len(queue) == 3
|
||||
|
||||
|
||||
class TestAutoFixState:
|
||||
"""Tests for AutoFixState model."""
|
||||
|
||||
def test_state_creation(self, tmp_gitlab_dir):
|
||||
"""Test creating and saving state."""
|
||||
state = AutoFixState(
|
||||
issue_iid=123,
|
||||
issue_url="https://gitlab.example.com/issue/123",
|
||||
project="namespace/test-project",
|
||||
status=AutoFixStatus.PENDING,
|
||||
)
|
||||
|
||||
assert state.issue_iid == 123
|
||||
assert state.status == AutoFixStatus.PENDING
|
||||
|
||||
def test_state_save_and_load(self, tmp_gitlab_dir):
|
||||
"""Test saving and loading state."""
|
||||
state = AutoFixState(
|
||||
issue_iid=456,
|
||||
issue_url="https://gitlab.example.com/issue/456",
|
||||
project="namespace/test-project",
|
||||
status=AutoFixStatus.BUILDING,
|
||||
)
|
||||
|
||||
# Save state
|
||||
import asyncio
|
||||
|
||||
asyncio.run(state.save(tmp_gitlab_dir))
|
||||
|
||||
# Load state
|
||||
loaded = AutoFixState.load(tmp_gitlab_dir, 456)
|
||||
|
||||
assert loaded is not None
|
||||
assert loaded.issue_iid == 456
|
||||
assert loaded.status == AutoFixStatus.BUILDING
|
||||
|
||||
def test_state_transition_validation(self, tmp_gitlab_dir):
|
||||
"""Test that invalid state transitions are rejected."""
|
||||
state = AutoFixState(
|
||||
issue_iid=789,
|
||||
issue_url="https://gitlab.example.com/issue/789",
|
||||
project="namespace/test-project",
|
||||
status=AutoFixStatus.PENDING,
|
||||
)
|
||||
|
||||
# Valid transition
|
||||
state.update_status(AutoFixStatus.ANALYZING) # Should work
|
||||
|
||||
# Invalid transition
|
||||
with pytest.raises(ValueError):
|
||||
state.update_status(AutoFixStatus.COMPLETED) # Can't skip to completed
|
||||
|
||||
|
||||
class TestProgressReporting:
|
||||
"""Tests for progress callback handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_progress_reported_during_processing(
|
||||
self, mock_config, tmp_path, tmp_gitlab_dir
|
||||
):
|
||||
"""Test that progress callback is stored on the processor."""
|
||||
progress_calls = []
|
||||
|
||||
def progress_callback(progress):
|
||||
progress_calls.append(progress)
|
||||
|
||||
processor = AutoFixProcessor(
|
||||
gitlab_dir=tmp_gitlab_dir,
|
||||
config=mock_config,
|
||||
permission_checker=MagicMock(),
|
||||
progress_callback=progress_callback,
|
||||
)
|
||||
|
||||
# Verify the callback is stored
|
||||
assert processor.progress_callback is not None
|
||||
assert processor.progress_callback == progress_callback
|
||||
|
||||
# Test that calling the callback works
|
||||
processor.progress_callback({"status": "test"})
|
||||
|
||||
assert len(progress_calls) == 1
|
||||
assert progress_calls[0] == {"status": "test"}
|
||||
|
||||
|
||||
class TestURLConstruction:
|
||||
"""Tests for URL construction."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_issue_url_construction(self, processor, mock_config):
|
||||
"""Test that issue URLs are constructed correctly."""
|
||||
issue = {"iid": 123}
|
||||
|
||||
state = await processor.process_issue(
|
||||
issue_iid=123,
|
||||
issue=issue,
|
||||
trigger_label=None,
|
||||
)
|
||||
|
||||
assert (
|
||||
state.issue_url
|
||||
== "https://gitlab.example.com/namespace/test-project/-/issues/123"
|
||||
)
|
||||
@@ -1,451 +0,0 @@
|
||||
"""
|
||||
Tests for GitLab Batch Issues
|
||||
================================
|
||||
|
||||
Tests for issue batching, similarity detection, and batch processing.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from runners.gitlab.batch_issues import (
|
||||
ClaudeGitlabBatchAnalyzer,
|
||||
GitlabBatchStatus,
|
||||
GitlabIssueBatch,
|
||||
GitlabIssueBatcher,
|
||||
GitlabIssueBatchItem,
|
||||
format_batch_summary,
|
||||
)
|
||||
from runners.gitlab.glab_client import GitLabConfig
|
||||
except ImportError:
|
||||
from glab_client import GitLabConfig
|
||||
from runners.gitlab.batch_issues import (
|
||||
ClaudeGitlabBatchAnalyzer,
|
||||
GitlabBatchStatus,
|
||||
GitlabIssueBatch,
|
||||
GitlabIssueBatcher,
|
||||
GitlabIssueBatchItem,
|
||||
format_batch_summary,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Create a mock GitLab config."""
|
||||
config = MagicMock(spec=GitLabConfig)
|
||||
config.project = "namespace/test-project"
|
||||
config.instance_url = "https://gitlab.example.com"
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_issues():
|
||||
"""Sample issues for batching."""
|
||||
return [
|
||||
{
|
||||
"iid": 1,
|
||||
"title": "Login bug",
|
||||
"description": "Cannot login with special characters",
|
||||
"labels": ["bug", "auth"],
|
||||
},
|
||||
{
|
||||
"iid": 2,
|
||||
"title": "Signup bug",
|
||||
"description": "Cannot signup with special characters",
|
||||
"labels": ["bug", "auth"],
|
||||
},
|
||||
{
|
||||
"iid": 3,
|
||||
"title": "UI bug",
|
||||
"description": "Button alignment issue",
|
||||
"labels": ["bug", "ui"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class TestBatchAnalyzer:
|
||||
"""Tests for Claude-based batch analyzer."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_single_issue(self, mock_config, tmp_path):
|
||||
"""Test analyzing a single issue."""
|
||||
analyzer = ClaudeGitlabBatchAnalyzer(project_dir=tmp_path)
|
||||
|
||||
issues = [{"iid": 1, "title": "Single issue"}]
|
||||
|
||||
with patch.object(analyzer, "_fallback_batches") as mock_fallback:
|
||||
mock_fallback.return_value = [
|
||||
{
|
||||
"issue_iids": [1],
|
||||
"theme": "Single issue",
|
||||
"reasoning": "Single issue in group",
|
||||
"confidence": 1.0,
|
||||
}
|
||||
]
|
||||
|
||||
result = await analyzer.analyze_and_batch_issues(issues)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["issue_iids"] == [1]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_empty_list(self, mock_config, tmp_path):
|
||||
"""Test analyzing empty issue list."""
|
||||
analyzer = ClaudeGitlabBatchAnalyzer(project_dir=tmp_path)
|
||||
|
||||
result = await analyzer.analyze_and_batch_issues([])
|
||||
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_json_response(self, mock_config, tmp_path):
|
||||
"""Test JSON parsing from Claude response."""
|
||||
analyzer = ClaudeGitlabBatchAnalyzer(project_dir=tmp_path)
|
||||
|
||||
# Valid JSON
|
||||
json_str = '{"batches": [{"issue_iids": [1, 2]}]}'
|
||||
result = analyzer._parse_json_response(json_str)
|
||||
|
||||
assert "batches" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_json_from_markdown(self, mock_config, tmp_path):
|
||||
"""Test extracting JSON from markdown code blocks."""
|
||||
analyzer = ClaudeGitlabBatchAnalyzer(project_dir=tmp_path)
|
||||
|
||||
# JSON in markdown code block
|
||||
response = '```json\n{"batches": [{"issue_iids": [1, 2]}]}\n```'
|
||||
result = analyzer._parse_json_response(response)
|
||||
|
||||
assert "batches" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_batches(self, mock_config, tmp_path):
|
||||
"""Test fallback batching when Claude is unavailable."""
|
||||
analyzer = ClaudeGitlabBatchAnalyzer(project_dir=tmp_path)
|
||||
|
||||
issues = [
|
||||
{"iid": 1, "title": "Issue 1"},
|
||||
{"iid": 2, "title": "Issue 2"},
|
||||
]
|
||||
|
||||
result = analyzer._fallback_batches(issues)
|
||||
|
||||
assert len(result) == 2
|
||||
assert all("confidence" in r for r in result)
|
||||
|
||||
|
||||
class TestIssueBatchItem:
|
||||
"""Tests for IssueBatchItem model."""
|
||||
|
||||
def test_batch_item_to_dict(self):
|
||||
"""Test converting batch item to dict."""
|
||||
item = GitlabIssueBatchItem(
|
||||
issue_iid=123,
|
||||
title="Test Issue",
|
||||
body="Description",
|
||||
labels=["bug"],
|
||||
similarity_to_primary=0.8,
|
||||
)
|
||||
|
||||
result = item.to_dict()
|
||||
|
||||
assert result["issue_iid"] == 123
|
||||
assert result["similarity_to_primary"] == 0.8
|
||||
|
||||
def test_batch_item_from_dict(self):
|
||||
"""Test creating batch item from dict."""
|
||||
data = {
|
||||
"issue_iid": 456,
|
||||
"title": "Test",
|
||||
"body": "Desc",
|
||||
"labels": ["feature"],
|
||||
"similarity_to_primary": 1.0,
|
||||
}
|
||||
|
||||
result = GitlabIssueBatchItem.from_dict(data)
|
||||
|
||||
assert result.issue_iid == 456
|
||||
|
||||
|
||||
class TestIssueBatch:
|
||||
"""Tests for IssueBatch model."""
|
||||
|
||||
def test_batch_creation(self):
|
||||
"""Test creating a batch."""
|
||||
issues = [
|
||||
GitlabIssueBatchItem(
|
||||
issue_iid=1,
|
||||
title="Issue 1",
|
||||
body="",
|
||||
),
|
||||
GitlabIssueBatchItem(
|
||||
issue_iid=2,
|
||||
title="Issue 2",
|
||||
body="",
|
||||
),
|
||||
]
|
||||
|
||||
batch = GitlabIssueBatch(
|
||||
batch_id="batch-1-2",
|
||||
project="namespace/test-project",
|
||||
primary_issue=1,
|
||||
issues=issues,
|
||||
theme="Authentication issues",
|
||||
)
|
||||
|
||||
assert batch.batch_id == "batch-1-2"
|
||||
assert batch.primary_issue == 1
|
||||
assert len(batch.issues) == 2
|
||||
|
||||
def test_batch_to_dict(self):
|
||||
"""Test converting batch to dict."""
|
||||
batch = GitlabIssueBatch(
|
||||
batch_id="batch-1",
|
||||
project="namespace/project",
|
||||
primary_issue=1,
|
||||
issues=[],
|
||||
status=GitlabBatchStatus.PENDING,
|
||||
)
|
||||
|
||||
result = batch.to_dict()
|
||||
|
||||
assert result["batch_id"] == "batch-1"
|
||||
assert result["status"] == "pending"
|
||||
|
||||
def test_batch_from_dict(self):
|
||||
"""Test creating batch from dict."""
|
||||
data = {
|
||||
"batch_id": "batch-1",
|
||||
"project": "namespace/project",
|
||||
"primary_issue": 1,
|
||||
"issues": [],
|
||||
"status": "pending",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
result = GitlabIssueBatch.from_dict(data)
|
||||
|
||||
assert result.batch_id == "batch-1"
|
||||
assert result.status == GitlabBatchStatus.PENDING
|
||||
|
||||
|
||||
class TestIssueBatcher:
|
||||
"""Tests for IssueBatcher class."""
|
||||
|
||||
def test_batcher_initialization(self, mock_config, tmp_path):
|
||||
"""Test batcher initialization."""
|
||||
batcher = GitlabIssueBatcher(
|
||||
gitlab_dir=tmp_path / ".auto-claude" / "gitlab",
|
||||
project="namespace/project",
|
||||
project_dir=tmp_path,
|
||||
)
|
||||
|
||||
assert batcher.project == "namespace/project"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_batches(self, mock_config, tmp_path, sample_issues):
|
||||
"""Test creating batches from issues."""
|
||||
batcher = GitlabIssueBatcher(
|
||||
gitlab_dir=tmp_path / ".auto-claude" / "gitlab",
|
||||
project="namespace/project",
|
||||
project_dir=tmp_path,
|
||||
)
|
||||
|
||||
# Patch the analyzer's analyze_and_batch_issues method
|
||||
with patch.object(batcher.analyzer, "analyze_and_batch_issues") as mock_analyze:
|
||||
mock_analyze.return_value = [
|
||||
{
|
||||
"issue_iids": [1, 2],
|
||||
"theme": "Auth issues",
|
||||
"confidence": 0.85,
|
||||
},
|
||||
{
|
||||
"issue_iids": [3],
|
||||
"theme": "UI bug",
|
||||
"confidence": 0.9,
|
||||
},
|
||||
]
|
||||
|
||||
batches = await batcher.create_batches(sample_issues)
|
||||
|
||||
assert len(batches) == 2
|
||||
assert batches[0].theme == "Auth issues"
|
||||
assert batches[1].theme == "UI bug"
|
||||
|
||||
def test_generate_batch_id(self, mock_config, tmp_path):
|
||||
"""Test batch ID generation."""
|
||||
batcher = GitlabIssueBatcher(
|
||||
gitlab_dir=tmp_path / ".auto-claude" / "gitlab",
|
||||
project="namespace/project",
|
||||
project_dir=tmp_path,
|
||||
)
|
||||
|
||||
batch_id = batcher._generate_batch_id([1, 2, 3])
|
||||
|
||||
assert batch_id == "batch-1-2-3"
|
||||
|
||||
def test_save_and_load_batch(self, mock_config, tmp_path):
|
||||
"""Test saving and loading batches."""
|
||||
batcher = GitlabIssueBatcher(
|
||||
gitlab_dir=tmp_path / ".auto-claude" / "gitlab",
|
||||
project="namespace/project",
|
||||
project_dir=tmp_path,
|
||||
)
|
||||
|
||||
batch = GitlabIssueBatch(
|
||||
batch_id="batch-123",
|
||||
project="namespace/project",
|
||||
primary_issue=123,
|
||||
issues=[],
|
||||
)
|
||||
|
||||
# Save
|
||||
batcher.save_batch(batch)
|
||||
|
||||
# Load
|
||||
loaded = batcher.load_batch(tmp_path / ".auto-claude" / "gitlab", "batch-123")
|
||||
|
||||
assert loaded is not None
|
||||
assert loaded.batch_id == "batch-123"
|
||||
|
||||
def test_list_batches(self, mock_config, tmp_path):
|
||||
"""Test listing all batches."""
|
||||
batcher = GitlabIssueBatcher(
|
||||
gitlab_dir=tmp_path / ".auto-claude" / "gitlab",
|
||||
project="namespace/project",
|
||||
project_dir=tmp_path,
|
||||
)
|
||||
|
||||
# Create a couple of batches
|
||||
batch1 = GitlabIssueBatch(
|
||||
batch_id="batch-1",
|
||||
project="namespace/project",
|
||||
primary_issue=1,
|
||||
issues=[],
|
||||
status=GitlabBatchStatus.PENDING,
|
||||
)
|
||||
batch2 = GitlabIssueBatch(
|
||||
batch_id="batch-2",
|
||||
project="namespace/project",
|
||||
primary_issue=2,
|
||||
issues=[],
|
||||
status=GitlabBatchStatus.COMPLETED,
|
||||
)
|
||||
|
||||
batcher.save_batch(batch1)
|
||||
batcher.save_batch(batch2)
|
||||
|
||||
# List
|
||||
batches = batcher.list_batches()
|
||||
|
||||
assert len(batches) == 2
|
||||
# Should be sorted by created_at descending
|
||||
assert batches[0].batch_id == "batch-2"
|
||||
assert batches[1].batch_id == "batch-1"
|
||||
|
||||
|
||||
class TestBatchStatus:
|
||||
"""Tests for BatchStatus enum."""
|
||||
|
||||
def test_status_values(self):
|
||||
"""Test all status values exist."""
|
||||
expected_statuses = [
|
||||
GitlabBatchStatus.PENDING,
|
||||
GitlabBatchStatus.ANALYZING,
|
||||
GitlabBatchStatus.CREATING_SPEC,
|
||||
GitlabBatchStatus.BUILDING,
|
||||
GitlabBatchStatus.QA_REVIEW,
|
||||
GitlabBatchStatus.MR_CREATED,
|
||||
GitlabBatchStatus.COMPLETED,
|
||||
GitlabBatchStatus.FAILED,
|
||||
]
|
||||
|
||||
for status in expected_statuses:
|
||||
assert status.value in [
|
||||
"pending",
|
||||
"analyzing",
|
||||
"creating_spec",
|
||||
"building",
|
||||
"qa_review",
|
||||
"mr_created",
|
||||
"completed",
|
||||
"failed",
|
||||
]
|
||||
|
||||
|
||||
class TestBatchSummaryFormatting:
|
||||
"""Tests for batch summary formatting."""
|
||||
|
||||
def test_format_batch_summary(self):
|
||||
"""Test formatting a batch summary."""
|
||||
batch = GitlabIssueBatch(
|
||||
batch_id="batch-auth-issues",
|
||||
project="namespace/project",
|
||||
primary_issue=1,
|
||||
issues=[
|
||||
GitlabIssueBatchItem(
|
||||
issue_iid=1,
|
||||
title="Login bug",
|
||||
body="",
|
||||
),
|
||||
GitlabIssueBatchItem(
|
||||
issue_iid=2,
|
||||
title="Signup bug",
|
||||
body="",
|
||||
),
|
||||
],
|
||||
common_themes=["Authentication issues"],
|
||||
status=GitlabBatchStatus.PENDING,
|
||||
)
|
||||
|
||||
summary = format_batch_summary(batch)
|
||||
|
||||
assert "batch-auth-issues" in summary
|
||||
assert "!1" in summary
|
||||
assert "!2" in summary
|
||||
assert "Authentication issues" in summary
|
||||
|
||||
|
||||
class TestSimilarityThreshold:
|
||||
"""Tests for similarity threshold handling."""
|
||||
|
||||
def test_threshold_filtering(self, mock_config, tmp_path):
|
||||
"""Test that similarity threshold is respected."""
|
||||
batcher = GitlabIssueBatcher(
|
||||
gitlab_dir=tmp_path / ".auto-claude" / "gitlab",
|
||||
project="namespace/project",
|
||||
project_dir=tmp_path,
|
||||
similarity_threshold=0.8, # High threshold
|
||||
)
|
||||
|
||||
assert batcher.similarity_threshold == 0.8
|
||||
|
||||
|
||||
class TestBatchSizeLimits:
|
||||
"""Tests for batch size limits."""
|
||||
|
||||
def test_max_batch_size(self, mock_config, tmp_path):
|
||||
"""Test that max batch size is enforced."""
|
||||
batcher = GitlabIssueBatcher(
|
||||
gitlab_dir=tmp_path / ".auto-claude" / "gitlab",
|
||||
project="namespace/project",
|
||||
project_dir=tmp_path,
|
||||
max_batch_size=3,
|
||||
)
|
||||
|
||||
assert batcher.max_batch_size == 3
|
||||
|
||||
def test_min_batch_size(self, mock_config, tmp_path):
|
||||
"""Test min batch size setting."""
|
||||
batcher = GitlabIssueBatcher(
|
||||
gitlab_dir=tmp_path / ".auto-claude" / "gitlab",
|
||||
project="namespace/project",
|
||||
project_dir=tmp_path,
|
||||
min_batch_size=2,
|
||||
)
|
||||
|
||||
assert batcher.min_batch_size == 2
|
||||
@@ -1,253 +0,0 @@
|
||||
"""
|
||||
GitLab Bot Detection Tests
|
||||
==========================
|
||||
|
||||
Tests for bot detection to prevent infinite review loops.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from __tests__.fixtures.gitlab import (
|
||||
MOCK_GITLAB_CONFIG,
|
||||
mock_mr_data,
|
||||
)
|
||||
|
||||
|
||||
class TestBotDetector:
|
||||
"""Test bot detection prevents infinite loops."""
|
||||
|
||||
@pytest.fixture
|
||||
def detector(self, tmp_path):
|
||||
"""Create a BotDetector instance for testing."""
|
||||
from runners.gitlab.bot_detection import BotDetector
|
||||
|
||||
return BotDetector(
|
||||
state_dir=tmp_path,
|
||||
bot_username="auto-claude-bot",
|
||||
review_own_mrs=False,
|
||||
)
|
||||
|
||||
def test_bot_detection_init(self, detector):
|
||||
"""Test detector initializes correctly."""
|
||||
assert detector.bot_username == "auto-claude-bot"
|
||||
assert detector.review_own_mrs is False
|
||||
assert detector.state.reviewed_commits == {}
|
||||
|
||||
def test_is_bot_mr_self_authored(self, detector):
|
||||
"""Test MR authored by bot is detected."""
|
||||
mr_data = mock_mr_data(author="auto-claude-bot")
|
||||
|
||||
assert detector.is_bot_mr(mr_data) is True
|
||||
|
||||
def test_is_bot_mr_pattern_match(self, detector):
|
||||
"""Test MR with bot pattern in username is detected."""
|
||||
mr_data = mock_mr_data(author="coderabbit[bot]")
|
||||
|
||||
assert detector.is_bot_mr(mr_data) is True
|
||||
|
||||
def test_is_bot_mr_human_authored(self, detector):
|
||||
"""Test MR authored by human is not detected as bot."""
|
||||
mr_data = mock_mr_data(author="john_doe")
|
||||
|
||||
assert detector.is_bot_mr(mr_data) is False
|
||||
|
||||
def test_is_bot_commit_self_authored(self, detector):
|
||||
"""Test commit by bot is detected."""
|
||||
commit = {
|
||||
"author": {"username": "auto-claude-bot"},
|
||||
"message": "Fix issue",
|
||||
}
|
||||
|
||||
assert detector.is_bot_commit(commit) is True
|
||||
|
||||
def test_is_bot_commit_ai_coauthored(self, detector):
|
||||
"""Test commit with AI co-authorship is detected."""
|
||||
commit = {
|
||||
"author": {"username": "human"},
|
||||
"message": "Co-authored-by: claude <no-reply>",
|
||||
}
|
||||
|
||||
assert detector.is_bot_commit(commit) is True
|
||||
|
||||
def test_is_bot_commit_human(self, detector):
|
||||
"""Test human commit is not detected as bot."""
|
||||
commit = {
|
||||
"author": {"username": "john_doe"},
|
||||
"message": "Fix bug",
|
||||
}
|
||||
|
||||
assert detector.is_bot_commit(commit) is False
|
||||
|
||||
def test_should_skip_mr_bot_authored(self, detector):
|
||||
"""Test should skip MR when bot authored."""
|
||||
mr_data = mock_mr_data(author="auto-claude-bot")
|
||||
commits = []
|
||||
|
||||
should_skip, reason = detector.should_skip_mr_review(123, mr_data, commits)
|
||||
|
||||
assert should_skip is True
|
||||
assert "auto-claude-bot" in reason.lower()
|
||||
|
||||
def test_should_skip_mr_in_cooling_off(self, detector):
|
||||
"""Test should skip MR when in cooling off period."""
|
||||
# First, mark as reviewed
|
||||
detector.mark_reviewed(123, "abc123")
|
||||
|
||||
# Immediately try to review again
|
||||
mr_data = mock_mr_data()
|
||||
commits = [{"id": "abc123", "sha": "abc123"}]
|
||||
|
||||
should_skip, reason = detector.should_skip_mr_review(123, mr_data, commits)
|
||||
|
||||
assert should_skip is True
|
||||
assert "cooling" in reason.lower()
|
||||
|
||||
def test_should_skip_mr_already_reviewed(self, detector):
|
||||
"""Test should skip MR when commit already reviewed."""
|
||||
# Mark as reviewed
|
||||
detector.mark_reviewed(123, "abc123")
|
||||
|
||||
# Try to review same commit
|
||||
mr_data = mock_mr_data()
|
||||
commits = [{"id": "abc123", "sha": "abc123"}]
|
||||
|
||||
# Wait past cooling off (manually update time)
|
||||
detector.state.last_review_times["123"] = (
|
||||
datetime.now(timezone.utc) - timedelta(minutes=10)
|
||||
).isoformat()
|
||||
|
||||
should_skip, reason = detector.should_skip_mr_review(123, mr_data, commits)
|
||||
|
||||
assert should_skip is True
|
||||
assert "already reviewed" in reason.lower()
|
||||
|
||||
def test_should_not_skip_safe_mr(self, detector):
|
||||
"""Test should not skip when MR is safe to review."""
|
||||
mr_data = mock_mr_data()
|
||||
commits = [{"id": "new123", "sha": "new123"}]
|
||||
|
||||
should_skip, reason = detector.should_skip_mr_review(456, mr_data, commits)
|
||||
|
||||
assert should_skip is False
|
||||
assert reason == ""
|
||||
|
||||
def test_mark_reviewed(self, detector):
|
||||
"""Test marking MR as reviewed."""
|
||||
detector.mark_reviewed(123, "abc123")
|
||||
|
||||
assert "123" in detector.state.reviewed_commits
|
||||
assert "abc123" in detector.state.reviewed_commits["123"]
|
||||
assert "123" in detector.state.last_review_times
|
||||
|
||||
def test_mark_reviewed_multiple_commits(self, detector):
|
||||
"""Test marking multiple commits for same MR."""
|
||||
detector.mark_reviewed(123, "commit1")
|
||||
detector.mark_reviewed(123, "commit2")
|
||||
detector.mark_reviewed(123, "commit3")
|
||||
|
||||
assert len(detector.state.reviewed_commits["123"]) == 3
|
||||
|
||||
def test_clear_mr_state(self, detector):
|
||||
"""Test clearing MR state."""
|
||||
detector.mark_reviewed(123, "abc123")
|
||||
detector.clear_mr_state(123)
|
||||
|
||||
assert "123" not in detector.state.reviewed_commits
|
||||
assert "123" not in detector.state.last_review_times
|
||||
|
||||
def test_get_stats(self, detector):
|
||||
"""Test getting detector statistics."""
|
||||
detector.mark_reviewed(123, "abc123")
|
||||
detector.mark_reviewed(124, "def456")
|
||||
|
||||
stats = detector.get_stats()
|
||||
|
||||
assert stats["bot_username"] == "auto-claude-bot"
|
||||
assert stats["total_mrs_tracked"] == 2
|
||||
assert stats["total_reviews_performed"] == 2
|
||||
|
||||
def test_cleanup_stale_mrs(self, detector):
|
||||
"""Test cleanup of old MR state."""
|
||||
# Add an old MR (manually set old timestamp)
|
||||
old_time = (datetime.now(timezone.utc) - timedelta(days=40)).isoformat()
|
||||
detector.state.last_review_times["999"] = old_time
|
||||
detector.state.reviewed_commits["999"] = ["old123"]
|
||||
|
||||
# Add a recent MR
|
||||
detector.mark_reviewed(123, "abc123")
|
||||
|
||||
cleaned = detector.cleanup_stale_mrs(max_age_days=30)
|
||||
|
||||
assert cleaned == 1
|
||||
assert "999" not in detector.state.reviewed_commits
|
||||
assert "123" in detector.state.reviewed_commits
|
||||
|
||||
def test_state_persistence(self, tmp_path):
|
||||
"""Test state is saved and loaded correctly."""
|
||||
from runners.gitlab.bot_detection import BotDetector
|
||||
|
||||
# Create detector and mark as reviewed
|
||||
detector1 = BotDetector(
|
||||
state_dir=tmp_path,
|
||||
bot_username="test-bot",
|
||||
)
|
||||
detector1.mark_reviewed(123, "abc123")
|
||||
|
||||
# Create new detector instance (should load state)
|
||||
detector2 = BotDetector(
|
||||
state_dir=tmp_path,
|
||||
bot_username="test-bot",
|
||||
)
|
||||
|
||||
assert "123" in detector2.state.reviewed_commits
|
||||
assert "abc123" in detector2.state.reviewed_commits["123"]
|
||||
|
||||
|
||||
class TestBotDetectionState:
|
||||
"""Test BotDetectionState model."""
|
||||
|
||||
def test_to_dict(self):
|
||||
"""Test converting state to dictionary."""
|
||||
from runners.gitlab.bot_detection import BotDetectionState
|
||||
|
||||
state = BotDetectionState(
|
||||
reviewed_commits={"123": ["abc123", "def456"]},
|
||||
last_review_times={"123": "2025-01-14T10:00:00"},
|
||||
)
|
||||
|
||||
data = state.to_dict()
|
||||
|
||||
assert data["reviewed_commits"]["123"] == ["abc123", "def456"]
|
||||
|
||||
def test_from_dict(self):
|
||||
"""Test loading state from dictionary."""
|
||||
from runners.gitlab.bot_detection import BotDetectionState
|
||||
|
||||
data = {
|
||||
"reviewed_commits": {"123": ["abc123"]},
|
||||
"last_review_times": {"123": "2025-01-14T10:00:00"},
|
||||
}
|
||||
|
||||
state = BotDetectionState.from_dict(data)
|
||||
|
||||
assert state.reviewed_commits["123"] == ["abc123"]
|
||||
assert state.last_review_times["123"] == "2025-01-14T10:00:00"
|
||||
|
||||
def test_save_and_load(self, tmp_path):
|
||||
"""Test saving and loading state from disk."""
|
||||
from runners.gitlab.bot_detection import BotDetectionState
|
||||
|
||||
state = BotDetectionState(
|
||||
reviewed_commits={"123": ["abc123"]},
|
||||
last_review_times={"123": "2025-01-14T10:00:00"},
|
||||
)
|
||||
|
||||
state.save(tmp_path)
|
||||
|
||||
loaded = BotDetectionState.load(tmp_path)
|
||||
|
||||
assert loaded.reviewed_commits["123"] == ["abc123"]
|
||||
@@ -1,263 +0,0 @@
|
||||
"""
|
||||
Tests for GitLab Branch Operations
|
||||
====================================
|
||||
|
||||
Tests for branch listing, creation, deletion, and comparison.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from runners.gitlab.glab_client import GitLabClient, GitLabConfig
|
||||
except ImportError:
|
||||
from glab_client import GitLabClient, GitLabConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Create a mock GitLab config."""
|
||||
return GitLabConfig(
|
||||
token="test-token",
|
||||
project="namespace/test-project",
|
||||
instance_url="https://gitlab.example.com",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_config, tmp_path):
|
||||
"""Create a GitLab client instance."""
|
||||
return GitLabClient(
|
||||
project_dir=tmp_path,
|
||||
config=mock_config,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_branches():
|
||||
"""Sample branch data."""
|
||||
return [
|
||||
{
|
||||
"name": "main",
|
||||
"merged": False,
|
||||
"protected": True,
|
||||
"default": True,
|
||||
"developers_can_push": False,
|
||||
"developers_can_merge": False,
|
||||
"commit": {
|
||||
"id": "abc123def456",
|
||||
"short_id": "abc123d",
|
||||
"title": "Stable branch",
|
||||
},
|
||||
"web_url": "https://gitlab.example.com/namespace/test-project/-/tree/main",
|
||||
},
|
||||
{
|
||||
"name": "develop",
|
||||
"merged": False,
|
||||
"protected": False,
|
||||
"default": False,
|
||||
"developers_can_push": True,
|
||||
"developers_can_merge": True,
|
||||
"commit": {
|
||||
"id": "def456abc123",
|
||||
"short_id": "def456a",
|
||||
"title": "Development branch",
|
||||
},
|
||||
"web_url": "https://gitlab.example.com/namespace/test-project/-/tree/develop",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class TestListBranches:
|
||||
"""Tests for list_branches method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_all_branches(self, client, sample_branches):
|
||||
"""Test listing all branches."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = sample_branches
|
||||
|
||||
result = client.list_branches()
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["name"] == "main"
|
||||
assert result[1]["name"] == "develop"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_branches_with_search(self, client, sample_branches):
|
||||
"""Test listing branches with search filter."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = [sample_branches[0]] # Only main
|
||||
|
||||
result = client.list_branches(search="main")
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "main"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_branches_async(self, client, sample_branches):
|
||||
"""Test async variant of list_branches."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = sample_branches
|
||||
|
||||
result = await client.list_branches_async()
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
class TestGetBranch:
|
||||
"""Tests for get_branch method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_existing_branch(self, client, sample_branches):
|
||||
"""Test getting an existing branch."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = sample_branches[0]
|
||||
|
||||
result = client.get_branch("main")
|
||||
|
||||
assert result["name"] == "main"
|
||||
assert result["protected"] is True
|
||||
assert result["commit"]["id"] == "abc123def456"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_branch_async(self, client, sample_branches):
|
||||
"""Test async variant of get_branch."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = sample_branches[0]
|
||||
|
||||
result = await client.get_branch_async("main")
|
||||
|
||||
assert result["name"] == "main"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nonexistent_branch(self, client):
|
||||
"""Test getting a branch that doesn't exist."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.side_effect = Exception("404 Not Found")
|
||||
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
client.get_branch("nonexistent")
|
||||
|
||||
|
||||
class TestCreateBranch:
|
||||
"""Tests for create_branch method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_branch_from_ref(self, client):
|
||||
"""Test creating a branch from another branch."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"name": "feature-branch",
|
||||
"commit": {"id": "new123"},
|
||||
"protected": False,
|
||||
}
|
||||
|
||||
result = client.create_branch(
|
||||
branch_name="feature-branch",
|
||||
ref="main",
|
||||
)
|
||||
|
||||
assert result["name"] == "feature-branch"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_branch_from_commit(self, client):
|
||||
"""Test creating a branch from a commit SHA."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"name": "fix-branch",
|
||||
"commit": {"id": "fix123"},
|
||||
}
|
||||
|
||||
result = client.create_branch(
|
||||
branch_name="fix-branch",
|
||||
ref="abc123def",
|
||||
)
|
||||
|
||||
assert result["name"] == "fix-branch"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_branch_async(self, client):
|
||||
"""Test async variant of create_branch."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {"name": "feature", "commit": {}}
|
||||
|
||||
result = await client.create_branch_async("feature", "main")
|
||||
|
||||
assert result["name"] == "feature"
|
||||
|
||||
|
||||
class TestDeleteBranch:
|
||||
"""Tests for delete_branch method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_existing_branch(self, client):
|
||||
"""Test deleting an existing branch."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = None # 204 No Content
|
||||
|
||||
result = client.delete_branch("feature-branch")
|
||||
|
||||
# Should not raise on success
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_branch_async(self, client):
|
||||
"""Test async variant of delete_branch."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = None
|
||||
|
||||
result = await client.delete_branch_async("old-branch")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestCompareBranches:
|
||||
"""Tests for compare_branches method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compare_branches_basic(self, client):
|
||||
"""Test comparing two branches."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"diff": "@@ -1,1 +1,1 @@",
|
||||
"commits": [{"id": "abc123"}],
|
||||
"compare_same_ref": False,
|
||||
}
|
||||
|
||||
result = client.compare_branches("main", "feature")
|
||||
|
||||
assert "diff" in result
|
||||
assert result["compare_same_ref"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compare_branches_async(self, client):
|
||||
"""Test async variant of compare_branches."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"diff": "@@ -1,1 +1,1 @@",
|
||||
}
|
||||
|
||||
result = await client.compare_branches_async("main", "feature")
|
||||
|
||||
assert "diff" in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compare_same_branch(self, client):
|
||||
"""Test comparing a branch to itself."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"diff": "",
|
||||
"compare_same_ref": True,
|
||||
}
|
||||
|
||||
result = client.compare_branches("main", "main")
|
||||
|
||||
assert result["compare_same_ref"] is True
|
||||
@@ -1,376 +0,0 @@
|
||||
"""
|
||||
GitLab CI Checker Tests
|
||||
========================
|
||||
|
||||
Tests for CI/CD pipeline status checking.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from __tests__.fixtures.gitlab import (
|
||||
MOCK_GITLAB_CONFIG,
|
||||
mock_mr_data,
|
||||
mock_pipeline_data,
|
||||
mock_pipeline_jobs,
|
||||
)
|
||||
|
||||
|
||||
class TestCIChecker:
|
||||
"""Test CI/CD pipeline checking functionality."""
|
||||
|
||||
@pytest.fixture
|
||||
def checker(self, tmp_path):
|
||||
"""Create a CIChecker instance for testing."""
|
||||
from runners.gitlab.glab_client import GitLabConfig
|
||||
from runners.gitlab.services.ci_checker import CIChecker
|
||||
|
||||
config = GitLabConfig(
|
||||
token="test-token",
|
||||
project="group/project",
|
||||
instance_url="https://gitlab.example.com",
|
||||
)
|
||||
|
||||
with patch("runners.gitlab.services.ci_checker.GitLabClient"):
|
||||
return CIChecker(
|
||||
project_dir=tmp_path,
|
||||
config=config,
|
||||
)
|
||||
|
||||
def test_init(self, checker):
|
||||
"""Test checker initializes correctly."""
|
||||
assert checker.client is not None
|
||||
|
||||
def test_check_mr_pipeline_success(self, checker):
|
||||
"""Test checking MR with successful pipeline."""
|
||||
pipeline_data = mock_pipeline_data(status="success")
|
||||
|
||||
async def mock_get_pipelines(mr_iid):
|
||||
return [pipeline_data]
|
||||
|
||||
async def mock_get_pipeline_status(pipeline_id):
|
||||
return pipeline_data
|
||||
|
||||
async def mock_get_pipeline_jobs(pipeline_id):
|
||||
return mock_pipeline_jobs()
|
||||
|
||||
# Setup async mocks
|
||||
import asyncio
|
||||
|
||||
async def test():
|
||||
with patch.object(
|
||||
checker.client, "get_mr_pipelines_async", mock_get_pipelines
|
||||
):
|
||||
with patch.object(
|
||||
checker.client,
|
||||
"get_pipeline_status_async",
|
||||
mock_get_pipeline_status,
|
||||
):
|
||||
with patch.object(
|
||||
checker.client,
|
||||
"get_pipeline_jobs_async",
|
||||
mock_get_pipeline_jobs,
|
||||
):
|
||||
pipeline = await checker.check_mr_pipeline(123)
|
||||
|
||||
assert pipeline is not None
|
||||
assert pipeline.pipeline_id == 1001
|
||||
assert pipeline.status.value == "success"
|
||||
assert pipeline.has_failures is False
|
||||
|
||||
asyncio.run(test())
|
||||
|
||||
def test_check_mr_pipeline_failed(self, checker):
|
||||
"""Test checking MR with failed pipeline."""
|
||||
pipeline_data = mock_pipeline_data(status="failed")
|
||||
jobs_data = mock_pipeline_jobs()
|
||||
jobs_data[0]["status"] = "failed"
|
||||
|
||||
import asyncio
|
||||
|
||||
async def test():
|
||||
async def mock_get_pipelines(mr_iid):
|
||||
return [pipeline_data]
|
||||
|
||||
async def mock_get_pipeline_status(pipeline_id):
|
||||
return pipeline_data
|
||||
|
||||
async def mock_get_pipeline_jobs(pipeline_id):
|
||||
return jobs_data
|
||||
|
||||
with patch.object(
|
||||
checker.client, "get_mr_pipelines_async", mock_get_pipelines
|
||||
):
|
||||
with patch.object(
|
||||
checker.client,
|
||||
"get_pipeline_status_async",
|
||||
mock_get_pipeline_status,
|
||||
):
|
||||
with patch.object(
|
||||
checker.client,
|
||||
"get_pipeline_jobs_async",
|
||||
mock_get_pipeline_jobs,
|
||||
):
|
||||
pipeline = await checker.check_mr_pipeline(123)
|
||||
|
||||
assert pipeline.has_failures is True
|
||||
assert pipeline.is_blocking is True
|
||||
|
||||
asyncio.run(test())
|
||||
|
||||
def test_check_mr_pipeline_no_pipeline(self, checker):
|
||||
"""Test checking MR with no pipeline."""
|
||||
import asyncio
|
||||
|
||||
async def test():
|
||||
async def mock_get_pipelines(mr_iid):
|
||||
return []
|
||||
|
||||
with patch.object(
|
||||
checker.client, "get_mr_pipelines_async", mock_get_pipelines
|
||||
):
|
||||
pipeline = await checker.check_mr_pipeline(123)
|
||||
|
||||
assert pipeline is None
|
||||
|
||||
asyncio.run(test())
|
||||
|
||||
def test_get_blocking_reason_success(self, checker):
|
||||
"""Test getting blocking reason for successful pipeline."""
|
||||
from runners.gitlab.services.ci_checker import PipelineInfo, PipelineStatus
|
||||
|
||||
pipeline = PipelineInfo(
|
||||
pipeline_id=1001,
|
||||
status=PipelineStatus.SUCCESS,
|
||||
ref="main",
|
||||
sha="abc123",
|
||||
created_at="2025-01-14T10:00:00",
|
||||
updated_at="2025-01-14T10:05:00",
|
||||
failed_jobs=[],
|
||||
)
|
||||
|
||||
reason = checker.get_blocking_reason(pipeline)
|
||||
|
||||
assert reason == ""
|
||||
|
||||
def test_get_blocking_reason_failed(self, checker):
|
||||
"""Test getting blocking reason for failed pipeline."""
|
||||
from runners.gitlab.services.ci_checker import (
|
||||
JobStatus,
|
||||
PipelineInfo,
|
||||
PipelineStatus,
|
||||
)
|
||||
|
||||
pipeline = PipelineInfo(
|
||||
pipeline_id=1001,
|
||||
status=PipelineStatus.FAILED,
|
||||
ref="main",
|
||||
sha="abc123",
|
||||
created_at="2025-01-14T10:00:00",
|
||||
updated_at="2025-01-14T10:05:00",
|
||||
failed_jobs=[
|
||||
JobStatus(
|
||||
name="test",
|
||||
status="failed",
|
||||
stage="test",
|
||||
failure_reason="AssertionError",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
reason = checker.get_blocking_reason(pipeline)
|
||||
|
||||
assert "failed" in reason.lower()
|
||||
|
||||
def test_format_pipeline_summary(self, checker):
|
||||
"""Test formatting pipeline summary."""
|
||||
from runners.gitlab.services.ci_checker import (
|
||||
JobStatus,
|
||||
PipelineInfo,
|
||||
PipelineStatus,
|
||||
)
|
||||
|
||||
pipeline = PipelineInfo(
|
||||
pipeline_id=1001,
|
||||
status=PipelineStatus.SUCCESS,
|
||||
ref="main",
|
||||
sha="abc123",
|
||||
created_at="2025-01-14T10:00:00",
|
||||
updated_at="2025-01-14T10:05:00",
|
||||
duration=300,
|
||||
jobs=[
|
||||
JobStatus(
|
||||
name="test",
|
||||
status="success",
|
||||
stage="test",
|
||||
),
|
||||
JobStatus(
|
||||
name="lint",
|
||||
status="success",
|
||||
stage="lint",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
summary = checker.format_pipeline_summary(pipeline)
|
||||
|
||||
assert "Pipeline #1001" in summary
|
||||
assert "SUCCESS" in summary
|
||||
assert "2 total" in summary
|
||||
|
||||
def test_security_scan_detection(self, checker):
|
||||
"""Test detection of security scan failures."""
|
||||
from runners.gitlab.services.ci_checker import JobStatus
|
||||
|
||||
jobs = [
|
||||
JobStatus(
|
||||
name="sast",
|
||||
status="failed",
|
||||
stage="test",
|
||||
failure_reason="Vulnerability found",
|
||||
),
|
||||
JobStatus(
|
||||
name="secret_detection",
|
||||
status="failed",
|
||||
stage="test",
|
||||
failure_reason="Secret leaked",
|
||||
),
|
||||
JobStatus(
|
||||
name="test",
|
||||
status="success",
|
||||
stage="test",
|
||||
),
|
||||
]
|
||||
|
||||
issues = checker._check_security_scans(jobs)
|
||||
|
||||
assert len(issues) == 2
|
||||
assert any(i["type"] == "Static Application Security Testing" for i in issues)
|
||||
assert any(i["type"] == "Secret Detection" for i in issues)
|
||||
|
||||
|
||||
class TestPipelineStatus:
|
||||
"""Test PipelineStatus enum."""
|
||||
|
||||
def test_status_values(self):
|
||||
"""Test all status values exist."""
|
||||
from runners.gitlab.services.ci_checker import PipelineStatus
|
||||
|
||||
assert PipelineStatus.PENDING.value == "pending"
|
||||
assert PipelineStatus.RUNNING.value == "running"
|
||||
assert PipelineStatus.SUCCESS.value == "success"
|
||||
assert PipelineStatus.FAILED.value == "failed"
|
||||
assert PipelineStatus.CANCELED.value == "canceled"
|
||||
|
||||
|
||||
class TestJobStatus:
|
||||
"""Test JobStatus model."""
|
||||
|
||||
def test_job_status_creation(self):
|
||||
"""Test creating JobStatus."""
|
||||
from runners.gitlab.services.ci_checker import JobStatus
|
||||
|
||||
job = JobStatus(
|
||||
name="test",
|
||||
status="success",
|
||||
stage="test",
|
||||
started_at="2025-01-14T10:00:00",
|
||||
finished_at="2025-01-14T10:01:00",
|
||||
duration=60,
|
||||
)
|
||||
|
||||
assert job.name == "test"
|
||||
assert job.status == "success"
|
||||
assert job.duration == 60
|
||||
|
||||
|
||||
class TestPipelineInfo:
|
||||
"""Test PipelineInfo model."""
|
||||
|
||||
def test_pipeline_info_creation(self):
|
||||
"""Test creating PipelineInfo."""
|
||||
from runners.gitlab.services.ci_checker import PipelineInfo, PipelineStatus
|
||||
|
||||
pipeline = PipelineInfo(
|
||||
pipeline_id=1001,
|
||||
status=PipelineStatus.SUCCESS,
|
||||
ref="main",
|
||||
sha="abc123",
|
||||
created_at="2025-01-14T10:00:00",
|
||||
updated_at="2025-01-14T10:05:00",
|
||||
)
|
||||
|
||||
assert pipeline.pipeline_id == 1001
|
||||
assert pipeline.has_failures is False
|
||||
assert pipeline.is_blocking is False
|
||||
|
||||
def test_has_failures_property(self):
|
||||
"""Test has_failures property."""
|
||||
from runners.gitlab.services.ci_checker import (
|
||||
JobStatus,
|
||||
PipelineInfo,
|
||||
PipelineStatus,
|
||||
)
|
||||
|
||||
pipeline = PipelineInfo(
|
||||
pipeline_id=1001,
|
||||
status=PipelineStatus.FAILED,
|
||||
ref="main",
|
||||
sha="abc123",
|
||||
created_at="2025-01-14T10:00:00",
|
||||
updated_at="2025-01-14T10:05:00",
|
||||
failed_jobs=[
|
||||
JobStatus(name="test", status="failed", stage="test"),
|
||||
],
|
||||
)
|
||||
|
||||
assert pipeline.has_failures is True
|
||||
assert len(pipeline.failed_jobs) == 1
|
||||
|
||||
def test_is_blocking_success(self):
|
||||
"""Test is_blocking for successful pipeline."""
|
||||
from runners.gitlab.services.ci_checker import PipelineInfo, PipelineStatus
|
||||
|
||||
pipeline = PipelineInfo(
|
||||
pipeline_id=1001,
|
||||
status=PipelineStatus.SUCCESS,
|
||||
ref="main",
|
||||
sha="abc123",
|
||||
created_at="2025-01-14T10:00:00",
|
||||
updated_at="2025-01-14T10:05:00",
|
||||
)
|
||||
|
||||
assert pipeline.is_blocking is False
|
||||
|
||||
def test_is_blocking_failed(self):
|
||||
"""Test is_blocking for failed pipeline."""
|
||||
from runners.gitlab.services.ci_checker import PipelineInfo, PipelineStatus
|
||||
|
||||
pipeline = PipelineInfo(
|
||||
pipeline_id=1001,
|
||||
status=PipelineStatus.FAILED,
|
||||
ref="main",
|
||||
sha="abc123",
|
||||
created_at="2025-01-14T10:00:00",
|
||||
updated_at="2025-01-14T10:05:00",
|
||||
)
|
||||
|
||||
assert pipeline.is_blocking is True
|
||||
|
||||
def test_is_blocking_running(self):
|
||||
"""Test is_blocking for running pipeline."""
|
||||
from runners.gitlab.services.ci_checker import PipelineInfo, PipelineStatus
|
||||
|
||||
pipeline = PipelineInfo(
|
||||
pipeline_id=1001,
|
||||
status=PipelineStatus.RUNNING,
|
||||
ref="main",
|
||||
sha="abc123",
|
||||
created_at="2025-01-14T10:00:00",
|
||||
updated_at="2025-01-14T10:05:00",
|
||||
)
|
||||
|
||||
# Running with no failed jobs is not blocking
|
||||
assert pipeline.is_blocking is False
|
||||
@@ -1,322 +0,0 @@
|
||||
"""
|
||||
Tests for GitLab Client Error Handling
|
||||
=======================================
|
||||
|
||||
Tests for enhanced retry logic, rate limiting, and error handling.
|
||||
"""
|
||||
|
||||
import socket
|
||||
import urllib.error
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from runners.gitlab.glab_client import GitLabClient, GitLabConfig
|
||||
except ImportError:
|
||||
from glab_client import GitLabClient, GitLabConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Create a mock GitLab config."""
|
||||
return GitLabConfig(
|
||||
token="test-token",
|
||||
project="namespace/test-project",
|
||||
instance_url="https://gitlab.example.com",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_config, tmp_path):
|
||||
"""Create a GitLab client instance."""
|
||||
return GitLabClient(
|
||||
project_dir=tmp_path,
|
||||
config=mock_config,
|
||||
default_timeout=5.0,
|
||||
)
|
||||
|
||||
|
||||
def _create_mock_response(
|
||||
status=200, content=b'{"id": 123}', content_type="application/json", headers=None
|
||||
):
|
||||
"""Helper to create a mock HTTP response."""
|
||||
mock_resp = Mock()
|
||||
mock_resp.status = status
|
||||
mock_resp.read = lambda: content
|
||||
# Use a real dict for headers to properly support .get() method
|
||||
headers_dict = {"Content-Type": content_type}
|
||||
if headers:
|
||||
headers_dict.update(headers)
|
||||
mock_resp.headers = headers_dict
|
||||
# Support context manager protocol
|
||||
mock_resp.__enter__ = Mock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = Mock(return_value=False)
|
||||
return mock_resp
|
||||
|
||||
|
||||
class TestRetryLogic:
|
||||
"""Tests for retry logic on transient failures."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_on_429_rate_limit(self, client):
|
||||
"""Test retry on HTTP 429 rate limit."""
|
||||
call_count = 0
|
||||
|
||||
def mock_urlopen(request, timeout=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
# First call: rate limited
|
||||
error = urllib.error.HTTPError(
|
||||
url="https://example.com",
|
||||
code=429,
|
||||
msg="Rate limited",
|
||||
hdrs={"Retry-After": "1"},
|
||||
fp=None,
|
||||
)
|
||||
error.read = lambda: b""
|
||||
raise error
|
||||
# Second call: success
|
||||
return _create_mock_response()
|
||||
|
||||
with patch("urllib.request.urlopen", mock_urlopen):
|
||||
result = client._fetch("/projects/namespace%2Fproject")
|
||||
|
||||
assert call_count == 2 # Retried once
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_on_500_server_error(self, client):
|
||||
"""Test retry on HTTP 500 server error."""
|
||||
call_count = 0
|
||||
|
||||
def mock_urlopen(request, timeout=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 2:
|
||||
error = urllib.error.HTTPError(
|
||||
url="https://example.com",
|
||||
code=500,
|
||||
msg="Internal server error",
|
||||
hdrs={},
|
||||
fp=None,
|
||||
)
|
||||
error.read = lambda: b""
|
||||
raise error
|
||||
return _create_mock_response()
|
||||
|
||||
with patch("urllib.request.urlopen", mock_urlopen):
|
||||
result = client._fetch("/projects/namespace%2Fproject")
|
||||
|
||||
assert call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_on_502_bad_gateway(self, client):
|
||||
"""Test retry on HTTP 502 bad gateway."""
|
||||
call_count = 0
|
||||
|
||||
def mock_urlopen(request, timeout=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
error = urllib.error.HTTPError(
|
||||
url="https://example.com",
|
||||
code=502,
|
||||
msg="Bad gateway",
|
||||
hdrs={},
|
||||
fp=None,
|
||||
)
|
||||
error.read = lambda: b""
|
||||
raise error
|
||||
return _create_mock_response()
|
||||
|
||||
with patch("urllib.request.urlopen", mock_urlopen):
|
||||
result = client._fetch("/projects/namespace%2Fproject")
|
||||
|
||||
assert call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_on_socket_timeout(self, client):
|
||||
"""Test retry on socket timeout."""
|
||||
call_count = 0
|
||||
|
||||
def mock_urlopen(request, timeout=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
raise TimeoutError("Connection timed out")
|
||||
return _create_mock_response()
|
||||
|
||||
with patch("urllib.request.urlopen", mock_urlopen):
|
||||
result = client._fetch("/projects/namespace%2Fproject")
|
||||
|
||||
assert call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_on_connection_reset(self, client):
|
||||
"""Test retry on connection reset."""
|
||||
call_count = 0
|
||||
|
||||
def mock_urlopen(request, timeout=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
raise ConnectionResetError("Connection reset")
|
||||
return _create_mock_response()
|
||||
|
||||
with patch("urllib.request.urlopen", mock_urlopen):
|
||||
result = client._fetch("/projects/namespace%2Fproject")
|
||||
|
||||
assert call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_retry_on_404_not_found(self, client):
|
||||
"""Test that 404 errors are not retried."""
|
||||
call_count = 0
|
||||
|
||||
def mock_urlopen(request, timeout=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
error = urllib.error.HTTPError(
|
||||
url="https://example.com",
|
||||
code=404,
|
||||
msg="Not found",
|
||||
hdrs={},
|
||||
fp=None,
|
||||
)
|
||||
error.read = lambda: b""
|
||||
raise error
|
||||
|
||||
with patch("urllib.request.urlopen", mock_urlopen):
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
client._fetch("/projects/namespace%2Fproject")
|
||||
|
||||
assert call_count == 1 # No retry
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_retries_exceeded(self, client):
|
||||
"""Test that max retries limit is respected."""
|
||||
call_count = 0
|
||||
|
||||
def mock_urlopen(request, timeout=None):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
# Always fail
|
||||
raise urllib.error.HTTPError(
|
||||
url="https://example.com",
|
||||
code=500,
|
||||
msg="Server error",
|
||||
hdrs={},
|
||||
fp=None,
|
||||
)
|
||||
|
||||
with patch("urllib.request.urlopen", mock_urlopen):
|
||||
with pytest.raises(Exception, match="GitLab API error"):
|
||||
client._fetch("/projects/namespace%2Fproject", max_retries=2)
|
||||
|
||||
# With max_retries=2, the loop runs range(2) = [0, 1], so 2 attempts total
|
||||
assert call_count == 2
|
||||
|
||||
|
||||
class TestRateLimiting:
|
||||
"""Tests for rate limit handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retry_after_header_parsing(self, client):
|
||||
"""Test parsing Retry-After header."""
|
||||
import time
|
||||
|
||||
def mock_urlopen(request, timeout=None):
|
||||
error = urllib.error.HTTPError(
|
||||
url="https://example.com",
|
||||
code=429,
|
||||
msg="Rate limited",
|
||||
hdrs={"Retry-After": "2"},
|
||||
fp=None,
|
||||
)
|
||||
error.read = lambda: b""
|
||||
raise error
|
||||
|
||||
with patch("urllib.request.urlopen", mock_urlopen):
|
||||
with patch("time.sleep") as mock_sleep:
|
||||
# Should fail after retries
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
client._fetch("/projects/namespace%2Fproject")
|
||||
|
||||
# Check that sleep was called with Retry-After value
|
||||
mock_sleep.assert_called_with(2)
|
||||
|
||||
|
||||
class TestErrorMessages:
|
||||
"""Tests for helpful error messages."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitlab_error_message_included(self, client):
|
||||
"""Test that GitLab error messages are included in exceptions."""
|
||||
|
||||
def mock_urlopen(request, timeout=None):
|
||||
error = urllib.error.HTTPError(
|
||||
url="https://example.com",
|
||||
code=400,
|
||||
msg="Bad request",
|
||||
hdrs={},
|
||||
fp=None,
|
||||
)
|
||||
error.read = lambda: b'{"message": "Invalid branch name"}'
|
||||
raise error
|
||||
|
||||
with patch("urllib.request.urlopen", mock_urlopen):
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
client._fetch("/projects/namespace%2Fproject")
|
||||
|
||||
# Error message should include GitLab's message
|
||||
assert "Invalid branch name" in str(exc_info.value)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_endpoint_raises(self, client):
|
||||
"""Test that invalid endpoints are rejected."""
|
||||
with pytest.raises(
|
||||
ValueError, match="does not match known GitLab API patterns"
|
||||
):
|
||||
client._fetch("/invalid/endpoint")
|
||||
|
||||
|
||||
class TestResponseSizeLimits:
|
||||
"""Tests for response size limits."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_large_response_rejected(self, client):
|
||||
"""Test that overly large responses are rejected."""
|
||||
|
||||
def mock_urlopen(request, timeout=None):
|
||||
# Use application/json to trigger size check (status < 400)
|
||||
return _create_mock_response(
|
||||
content=b"Large response",
|
||||
content_type="application/json",
|
||||
headers={"Content-Length": str(20 * 1024 * 1024)}, # 20MB
|
||||
)
|
||||
|
||||
with patch("urllib.request.urlopen", mock_urlopen):
|
||||
with pytest.raises(ValueError, match="Response too large"):
|
||||
client._fetch("/projects/namespace%2Fproject")
|
||||
|
||||
|
||||
class TestContentTypeHandling:
|
||||
"""Tests for Content-Type validation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_json_response_handling(self, client):
|
||||
"""Test handling of non-JSON responses on success."""
|
||||
|
||||
def mock_urlopen(request, timeout=None):
|
||||
mock_resp = _create_mock_response(
|
||||
content=b"Plain text response", content_type="text/plain"
|
||||
)
|
||||
return mock_resp
|
||||
|
||||
with patch("urllib.request.urlopen", mock_urlopen):
|
||||
result = client._fetch("/projects/namespace%2Fproject")
|
||||
|
||||
# Should return raw response for non-JSON on success
|
||||
assert result == "Plain text response"
|
||||
@@ -1,361 +0,0 @@
|
||||
"""
|
||||
Tests for GitLab Client API Extensions
|
||||
=========================================
|
||||
|
||||
Tests for new CRUD endpoints, branch operations, file operations, and webhooks.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Try imports with fallback for different environments
|
||||
try:
|
||||
from runners.gitlab.glab_client import (
|
||||
GitLabClient,
|
||||
GitLabConfig,
|
||||
encode_project_path,
|
||||
)
|
||||
except ImportError:
|
||||
from glab_client import GitLabClient, GitLabConfig, encode_project_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Create a mock GitLab config."""
|
||||
return GitLabConfig(
|
||||
token="test-token",
|
||||
project="namespace/test-project",
|
||||
instance_url="https://gitlab.example.com",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_config, tmp_path):
|
||||
"""Create a GitLab client instance."""
|
||||
return GitLabClient(
|
||||
project_dir=tmp_path,
|
||||
config=mock_config,
|
||||
)
|
||||
|
||||
|
||||
class TestMRExtensions:
|
||||
"""Tests for MR CRUD operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_mr(self, client):
|
||||
"""Test creating a merge request."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"iid": 123,
|
||||
"title": "Test MR",
|
||||
"source_branch": "feature",
|
||||
"target_branch": "main",
|
||||
}
|
||||
|
||||
result = client.create_mr(
|
||||
source_branch="feature",
|
||||
target_branch="main",
|
||||
title="Test MR",
|
||||
description="Test description",
|
||||
)
|
||||
|
||||
assert mock_fetch.called
|
||||
assert result["iid"] == 123
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_mrs_filters(self, client):
|
||||
"""Test listing MRs with filters."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = [
|
||||
{"iid": 1, "title": "MR 1"},
|
||||
{"iid": 2, "title": "MR 2"},
|
||||
]
|
||||
|
||||
result = client.list_mrs(state="opened", labels=["bug"])
|
||||
|
||||
assert mock_fetch.called
|
||||
assert len(result) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_mr(self, client):
|
||||
"""Test updating a merge request."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {"iid": 123, "title": "Updated"}
|
||||
|
||||
result = client.update_mr(
|
||||
mr_iid=123,
|
||||
title="Updated",
|
||||
labels={"bug": True, "feature": False},
|
||||
)
|
||||
|
||||
assert mock_fetch.called
|
||||
|
||||
|
||||
class TestBranchOperations:
|
||||
"""Tests for branch management operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_branches(self, client):
|
||||
"""Test listing branches."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = [
|
||||
{"name": "main", "commit": {"id": "abc123"}},
|
||||
{"name": "develop", "commit": {"id": "def456"}},
|
||||
]
|
||||
|
||||
result = client.list_branches()
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["name"] == "main"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_branch(self, client):
|
||||
"""Test getting a specific branch."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"name": "main",
|
||||
"commit": {"id": "abc123"},
|
||||
"protected": True,
|
||||
}
|
||||
|
||||
result = client.get_branch("main")
|
||||
|
||||
assert result["name"] == "main"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_branch(self, client):
|
||||
"""Test creating a new branch."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"name": "feature-branch",
|
||||
"commit": {"id": "abc123"},
|
||||
}
|
||||
|
||||
result = client.create_branch(
|
||||
branch_name="feature-branch",
|
||||
ref="main",
|
||||
)
|
||||
|
||||
assert result["name"] == "feature-branch"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_branch(self, client):
|
||||
"""Test deleting a branch."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = None # 204 No Content
|
||||
|
||||
result = client.delete_branch("feature-branch")
|
||||
|
||||
# Should not raise on success
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_compare_branches(self, client):
|
||||
"""Test comparing two branches."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"diff": "@@ -1,1 +1,1 @@",
|
||||
"commits": [{"id": "abc123"}],
|
||||
}
|
||||
|
||||
result = client.compare_branches("main", "feature")
|
||||
|
||||
assert "diff" in result
|
||||
|
||||
|
||||
class TestFileOperations:
|
||||
"""Tests for file operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_file_contents(self, client):
|
||||
"""Test getting file contents."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"file_name": "test.py",
|
||||
"content": "ZGVmIHRlc3Q=", # base64
|
||||
"encoding": "base64",
|
||||
}
|
||||
|
||||
result = client.get_file_contents("test.py", ref="main")
|
||||
|
||||
assert result["file_name"] == "test.py"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_file(self, client):
|
||||
"""Test creating a new file."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"file_path": "new_file.py",
|
||||
"branch": "main",
|
||||
}
|
||||
|
||||
result = client.create_file(
|
||||
file_path="new_file.py",
|
||||
content="print('hello')",
|
||||
commit_message="Add new file",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
assert result["file_path"] == "new_file.py"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_file(self, client):
|
||||
"""Test updating an existing file."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"file_path": "existing.py",
|
||||
"branch": "main",
|
||||
}
|
||||
|
||||
result = client.update_file(
|
||||
file_path="existing.py",
|
||||
content="updated content",
|
||||
commit_message="Update file",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
assert result["file_path"] == "existing.py"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_file(self, client):
|
||||
"""Test deleting a file."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = None # 204 No Content
|
||||
|
||||
result = client.delete_file(
|
||||
file_path="old.py",
|
||||
commit_message="Remove old file",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestWebhookOperations:
|
||||
"""Tests for webhook management."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_webhooks(self, client):
|
||||
"""Test listing webhooks."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = [
|
||||
{"id": 1, "url": "https://example.com/hook"},
|
||||
{"id": 2, "url": "https://example.com/another"},
|
||||
]
|
||||
|
||||
result = client.list_webhooks()
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_webhook(self, client):
|
||||
"""Test getting a specific webhook."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"id": 1,
|
||||
"url": "https://example.com/hook",
|
||||
"push_events": True,
|
||||
}
|
||||
|
||||
result = client.get_webhook(1)
|
||||
|
||||
assert result["id"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_webhook(self, client):
|
||||
"""Test creating a webhook."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"id": 1,
|
||||
"url": "https://example.com/hook",
|
||||
}
|
||||
|
||||
result = client.create_webhook(
|
||||
url="https://example.com/hook",
|
||||
push_events=True,
|
||||
merge_request_events=True,
|
||||
)
|
||||
|
||||
assert result["id"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_webhook(self, client):
|
||||
"""Test updating a webhook."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"id": 1,
|
||||
"url": "https://example.com/hook-updated",
|
||||
}
|
||||
|
||||
result = client.update_webhook(
|
||||
hook_id=1,
|
||||
url="https://example.com/hook-updated",
|
||||
)
|
||||
|
||||
assert result["url"] == "https://example.com/hook-updated"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_webhook(self, client):
|
||||
"""Test deleting a webhook."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = None # 204 No Content
|
||||
|
||||
result = client.delete_webhook(1)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestAsyncMethods:
|
||||
"""Tests for async method variants."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_mr_async(self, client):
|
||||
"""Test async variant of create_mr."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"iid": 123,
|
||||
"title": "Test MR",
|
||||
}
|
||||
|
||||
result = await client.create_mr_async(
|
||||
source_branch="feature",
|
||||
target_branch="main",
|
||||
title="Test MR",
|
||||
)
|
||||
|
||||
assert result["iid"] == 123
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_branches_async(self, client):
|
||||
"""Test async variant of list_branches."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = [
|
||||
{"name": "main"},
|
||||
]
|
||||
|
||||
result = await client.list_branches_async()
|
||||
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
class TestEncoding:
|
||||
"""Tests for URL encoding."""
|
||||
|
||||
def test_encode_project_path_simple(self):
|
||||
"""Test encoding simple project path."""
|
||||
result = encode_project_path("namespace/project")
|
||||
assert result == "namespace%2Fproject"
|
||||
|
||||
def test_encode_project_path_with_dots(self):
|
||||
"""Test encoding project path with dots."""
|
||||
result = encode_project_path("group.name/project")
|
||||
assert "group.name%2Fproject" in result or "group%2Ename%2Fproject" in result
|
||||
|
||||
def test_encode_project_path_with_slashes(self):
|
||||
"""Test encoding project path with nested groups."""
|
||||
result = encode_project_path("group/subgroup/project")
|
||||
assert result == "group%2Fsubgroup%2Fproject"
|
||||
@@ -1,444 +0,0 @@
|
||||
"""
|
||||
Unit Tests for GitLab MR Context Gatherer Enhancements
|
||||
======================================================
|
||||
|
||||
Tests for enhanced context gathering including monorepo detection,
|
||||
related files finding, and AI bot comment detection.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Try imports with fallback for different environments
|
||||
try:
|
||||
from runners.gitlab.services.context_gatherer import (
|
||||
CONFIG_FILE_NAMES,
|
||||
GITLAB_AI_BOT_PATTERNS,
|
||||
MRContextGatherer,
|
||||
)
|
||||
except ImportError:
|
||||
from runners.gitlab.context_gatherer import (
|
||||
CONFIG_FILE_NAMES,
|
||||
GITLAB_AI_BOT_PATTERNS,
|
||||
MRContextGatherer,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
"""Create a mock GitLab client."""
|
||||
client = MagicMock()
|
||||
client.get_mr_async = AsyncMock()
|
||||
client.get_mr_changes_async = AsyncMock()
|
||||
client.get_mr_commits_async = AsyncMock()
|
||||
client.get_mr_notes_async = AsyncMock()
|
||||
client.get_mr_pipeline_async = AsyncMock()
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_mr_data():
|
||||
"""Sample MR data from GitLab API."""
|
||||
return {
|
||||
"iid": 123,
|
||||
"title": "Add new feature",
|
||||
"description": "This adds a cool feature",
|
||||
"author": {"username": "developer"},
|
||||
"source_branch": "feature-branch",
|
||||
"target_branch": "main",
|
||||
"state": "opened",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_changes_data():
|
||||
"""Sample MR changes data."""
|
||||
return {
|
||||
"changes": [
|
||||
{
|
||||
"new_path": "src/utils/helpers.py",
|
||||
"old_path": "src/utils/helpers.py",
|
||||
"diff": "@@ -1,1 +1,2 @@\n def helper():\n+ return True",
|
||||
"new_file": False,
|
||||
"deleted_file": False,
|
||||
"renamed_file": False,
|
||||
},
|
||||
],
|
||||
"additions": 10,
|
||||
"deletions": 5,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_commits():
|
||||
"""Sample commit data."""
|
||||
return [
|
||||
{
|
||||
"id": "abc123",
|
||||
"short_id": "abc123",
|
||||
"title": "Add feature",
|
||||
"message": "Add feature",
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_project_dir(tmp_path):
|
||||
"""Create a temporary project directory with structure."""
|
||||
# Create monorepo structure
|
||||
(tmp_path / "apps").mkdir()
|
||||
(tmp_path / "apps" / "backend").mkdir()
|
||||
(tmp_path / "apps" / "frontend").mkdir()
|
||||
(tmp_path / "packages").mkdir()
|
||||
(tmp_path / "packages" / "shared").mkdir()
|
||||
|
||||
# Create config files
|
||||
(tmp_path / "package.json").write_text(
|
||||
'{"workspaces": ["apps/*", "packages/*"]}', encoding="utf-8"
|
||||
)
|
||||
(tmp_path / "tsconfig.json").write_text(
|
||||
'{"compilerOptions": {"paths": {"@/*": ["src/*"]}}}', encoding="utf-8"
|
||||
)
|
||||
(tmp_path / ".gitlab-ci.yml").write_text("stages:\n - test", encoding="utf-8")
|
||||
|
||||
# Create source files
|
||||
(tmp_path / "src").mkdir()
|
||||
(tmp_path / "src" / "utils").mkdir()
|
||||
(tmp_path / "src" / "utils" / "helpers.py").write_text(
|
||||
"def helper():\n return True", encoding="utf-8"
|
||||
)
|
||||
|
||||
# Create test files
|
||||
(tmp_path / "tests").mkdir()
|
||||
(tmp_path / "tests" / "test_helpers.py").write_text(
|
||||
"def test_helper():\n assert True", encoding="utf-8"
|
||||
)
|
||||
|
||||
return tmp_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gatherer(tmp_project_dir):
|
||||
"""Create a context gatherer instance."""
|
||||
return MRContextGatherer(
|
||||
project_dir=tmp_project_dir,
|
||||
mr_iid=123,
|
||||
config=MagicMock(project="namespace/project", token="test-token"),
|
||||
)
|
||||
|
||||
|
||||
class TestAIBotPatterns:
|
||||
"""Test AI bot pattern detection."""
|
||||
|
||||
def test_gitlab_ai_bot_patterns_comprehensive(self):
|
||||
"""Test that AI bot patterns include major tools."""
|
||||
# Check for known AI tools
|
||||
assert "coderabbit" in GITLAB_AI_BOT_PATTERNS
|
||||
assert "greptile" in GITLAB_AI_BOT_PATTERNS
|
||||
assert "cursor" in GITLAB_AI_BOT_PATTERNS
|
||||
assert "sourcery-ai" in GITLAB_AI_BOT_PATTERNS
|
||||
assert "codium" in GITLAB_AI_BOT_PATTERNS
|
||||
|
||||
def test_config_file_names_include_gitlab_ci(self):
|
||||
"""Test that GitLab CI config is included."""
|
||||
assert ".gitlab-ci.yml" in CONFIG_FILE_NAMES
|
||||
|
||||
|
||||
class TestRepoStructureDetection:
|
||||
"""Test monorepo and project structure detection."""
|
||||
|
||||
def test_detect_monorepo_apps(self, gatherer, tmp_project_dir):
|
||||
"""Test detection of apps/ directory."""
|
||||
structure = gatherer._detect_repo_structure()
|
||||
|
||||
assert "Monorepo Apps" in structure
|
||||
assert "backend" in structure
|
||||
assert "frontend" in structure
|
||||
|
||||
def test_detect_monorepo_packages(self, gatherer, tmp_project_dir):
|
||||
"""Test detection of packages/ directory."""
|
||||
structure = gatherer._detect_repo_structure()
|
||||
|
||||
assert "Packages" in structure
|
||||
assert "shared" in structure
|
||||
|
||||
def test_detect_workspaces(self, gatherer, tmp_project_dir):
|
||||
"""Test detection of npm workspaces."""
|
||||
structure = gatherer._detect_repo_structure()
|
||||
|
||||
assert "Workspaces" in structure
|
||||
|
||||
def test_detect_gitlab_ci(self, gatherer, tmp_project_dir):
|
||||
"""Test detection of GitLab CI config."""
|
||||
structure = gatherer._detect_repo_structure()
|
||||
|
||||
assert "GitLab CI" in structure
|
||||
|
||||
def test_detect_standard_repo(self, tmp_path):
|
||||
"""Test detection of standard repo without monorepo structure."""
|
||||
gatherer = MRContextGatherer(
|
||||
project_dir=tmp_path,
|
||||
mr_iid=123,
|
||||
config=MagicMock(project="namespace/project"),
|
||||
)
|
||||
|
||||
structure = gatherer._detect_repo_structure()
|
||||
|
||||
assert "Standard single-package repository" in structure
|
||||
|
||||
|
||||
class TestRelatedFilesFinding:
|
||||
"""Test finding related files for context."""
|
||||
|
||||
def test_find_test_files(self, gatherer, tmp_project_dir):
|
||||
"""Test finding test files for a source file."""
|
||||
source_path = Path("src/utils/helpers.py")
|
||||
tests = gatherer._find_test_files(source_path)
|
||||
|
||||
# Should find the test file we created
|
||||
assert "tests/test_helpers.py" in tests
|
||||
|
||||
def test_find_config_files(self, gatherer, tmp_project_dir):
|
||||
"""Test finding config files in directory."""
|
||||
directory = Path(tmp_project_dir)
|
||||
configs = gatherer._find_config_files(directory)
|
||||
|
||||
# Should find config files in root
|
||||
assert "package.json" in configs
|
||||
assert "tsconfig.json" in configs
|
||||
assert ".gitlab-ci.yml" in configs
|
||||
|
||||
def test_find_type_definitions(self, gatherer, tmp_project_dir):
|
||||
"""Test finding TypeScript type definition files."""
|
||||
# Create a TypeScript file
|
||||
(tmp_project_dir / "src" / "types.ts").write_text(
|
||||
"export type Foo = string;", encoding="utf-8"
|
||||
)
|
||||
(tmp_project_dir / "src" / "types.d.ts").write_text(
|
||||
"export type Bar = number;", encoding="utf-8"
|
||||
)
|
||||
|
||||
source_path = Path("src/types.ts")
|
||||
type_defs = gatherer._find_type_definitions(source_path)
|
||||
|
||||
assert "src/types.d.ts" in type_defs
|
||||
|
||||
def test_find_dependents_limits_generic_names(self, gatherer, tmp_project_dir):
|
||||
"""Test that generic names are skipped in dependent finding."""
|
||||
# Generic names should be skipped to avoid too many matches
|
||||
for stem in ["index", "main", "app", "utils", "helpers", "types", "constants"]:
|
||||
result = gatherer._find_dependents(f"src/{stem}.py")
|
||||
assert result == set() # Should skip generic names
|
||||
|
||||
def test_prioritize_related_files(self, gatherer):
|
||||
"""Test prioritization of related files."""
|
||||
files = {
|
||||
"tests/test_utils.py", # Test file - highest priority
|
||||
"src/utils.d.ts", # Type definition - high priority
|
||||
"tsconfig.json", # Config - medium priority
|
||||
"src/random.py", # Other - low priority
|
||||
}
|
||||
|
||||
prioritized = gatherer._prioritize_related_files(files, limit=10)
|
||||
|
||||
# Test files should come first
|
||||
assert prioritized[0] == "tests/test_utils.py"
|
||||
assert "src/utils.d.ts" in prioritized[1:3] # Type files next
|
||||
assert "tsconfig.json" in prioritized # Configs included
|
||||
|
||||
|
||||
class TestJSONLoading:
|
||||
"""Test JSON loading with comment handling."""
|
||||
|
||||
def test_load_json_safe_standard(self, gatherer, tmp_project_dir):
|
||||
"""Test loading standard JSON without comments."""
|
||||
(tmp_project_dir / "standard.json").write_text(
|
||||
'{"key": "value"}', encoding="utf-8"
|
||||
)
|
||||
|
||||
result = gatherer._load_json_safe("standard.json")
|
||||
|
||||
assert result == {"key": "value"}
|
||||
|
||||
def test_load_json_safe_with_comments(self, gatherer, tmp_project_dir):
|
||||
"""Test loading JSON with tsconfig-style comments."""
|
||||
(tmp_project_dir / "with-comments.json").write_text(
|
||||
"{\n"
|
||||
" // Single-line comment\n"
|
||||
' "key": "value",\n'
|
||||
" /* Multi-line\n"
|
||||
" comment */\n"
|
||||
' "key2": "value2"\n'
|
||||
"}",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = gatherer._load_json_safe("with-comments.json")
|
||||
|
||||
assert result == {"key": "value", "key2": "value2"}
|
||||
|
||||
def test_load_json_safe_nonexistent(self, gatherer, tmp_project_dir):
|
||||
"""Test loading non-existent JSON file."""
|
||||
result = gatherer._load_json_safe("nonexistent.json")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_load_tsconfig_paths(self, gatherer, tmp_project_dir):
|
||||
"""Test loading tsconfig paths."""
|
||||
result = gatherer._load_tsconfig_paths()
|
||||
|
||||
assert result is not None
|
||||
assert "@/*" in result
|
||||
assert "src/*" in result["@/*"]
|
||||
|
||||
|
||||
class TestStaticMethods:
|
||||
"""Test static utility methods."""
|
||||
|
||||
def test_find_related_files_for_root(self, tmp_project_dir):
|
||||
"""Test static method for finding related files."""
|
||||
changed_files = [
|
||||
{"new_path": "src/utils/helpers.py", "old_path": "src/utils/helpers.py"},
|
||||
]
|
||||
|
||||
related = MRContextGatherer.find_related_files_for_root(
|
||||
changed_files=changed_files,
|
||||
project_root=tmp_project_dir,
|
||||
)
|
||||
|
||||
# Should find test file
|
||||
assert "tests/test_helpers.py" in related
|
||||
# Should not include the changed file itself
|
||||
assert "src/utils/helpers.py" not in related
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGatherIntegration:
|
||||
"""Test the full gather method integration."""
|
||||
|
||||
async def test_gather_with_enhancements(
|
||||
self, gatherer, mock_client, sample_mr_data, sample_changes_data, sample_commits
|
||||
):
|
||||
"""Test that gather includes repo structure and related files."""
|
||||
# Setup mock responses
|
||||
mock_client.get_mr_async.return_value = sample_mr_data
|
||||
mock_client.get_mr_changes_async.return_value = sample_changes_data
|
||||
mock_client.get_mr_commits_async.return_value = sample_commits
|
||||
mock_client.get_mr_notes_async.return_value = []
|
||||
mock_client.get_mr_pipeline_async.return_value = {
|
||||
"id": 456,
|
||||
"status": "success",
|
||||
}
|
||||
|
||||
result = await gatherer.gather()
|
||||
|
||||
# Verify enhanced fields are populated
|
||||
assert result.mr_iid == 123
|
||||
assert result.repo_structure != ""
|
||||
assert (
|
||||
"Monorepo" in result.repo_structure or "Standard" in result.repo_structure
|
||||
)
|
||||
assert isinstance(result.related_files, list)
|
||||
assert result.ci_status == "success"
|
||||
assert result.ci_pipeline_id == 456
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gather_handles_missing_ci(
|
||||
self, gatherer, mock_client, sample_mr_data, sample_changes_data, sample_commits
|
||||
):
|
||||
"""Test that gather handles missing CI pipeline gracefully."""
|
||||
mock_client.get_mr_async.return_value = sample_mr_data
|
||||
mock_client.get_mr_changes_async.return_value = sample_changes_data
|
||||
mock_client.get_mr_commits_async.return_value = sample_commits
|
||||
mock_client.get_mr_notes_async.return_value = []
|
||||
mock_client.get_mr_pipeline_async.return_value = None
|
||||
|
||||
result = await gatherer.gather()
|
||||
|
||||
# Should not fail, CI fields should be None
|
||||
assert result.ci_status is None
|
||||
assert result.ci_pipeline_id is None
|
||||
|
||||
|
||||
class TestAIBotCommentDetection:
|
||||
"""Test AI bot comment detection and parsing."""
|
||||
|
||||
def test_parse_ai_comment_known_tool(self, gatherer):
|
||||
"""Test parsing comment from known AI tool."""
|
||||
note = {
|
||||
"id": 1,
|
||||
"author": {"username": "coderabbit[bot]"},
|
||||
"body": "Consider using async/await here",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
result = gatherer._parse_ai_comment(note)
|
||||
|
||||
assert result is not None
|
||||
assert result.tool_name == "CodeRabbit"
|
||||
assert result.author == "coderabbit[bot]"
|
||||
|
||||
def test_parse_ai_comment_unknown_user(self, gatherer):
|
||||
"""Test parsing comment from unknown user."""
|
||||
note = {
|
||||
"id": 1,
|
||||
"author": {"username": "developer"},
|
||||
"body": "Just a regular comment",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
result = gatherer._parse_ai_comment(note)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_parse_ai_comment_no_author(self, gatherer):
|
||||
"""Test parsing comment with no author."""
|
||||
note = {
|
||||
"id": 1,
|
||||
"body": "Anonymous comment",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
}
|
||||
|
||||
result = gatherer._parse_ai_comment(note)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestValidation:
|
||||
"""Test input validation functions."""
|
||||
|
||||
def test_validate_git_ref_valid(self):
|
||||
"""Test validation of valid git refs."""
|
||||
from runners.gitlab.services.context_gatherer import _validate_git_ref
|
||||
|
||||
assert _validate_git_ref("main") is True
|
||||
assert _validate_git_ref("feature-branch") is True
|
||||
assert _validate_git_ref("feature/branch-123") is True
|
||||
assert _validate_git_ref("abc123def456") is True
|
||||
|
||||
def test_validate_git_ref_invalid(self):
|
||||
"""Test validation rejects invalid git refs."""
|
||||
from runners.gitlab.services.context_gatherer import _validate_git_ref
|
||||
|
||||
assert _validate_git_ref("") is False # Empty
|
||||
assert _validate_git_ref("a" * 300) is False # Too long
|
||||
assert _validate_git_ref("branch;rm -rf") is False # Invalid chars
|
||||
|
||||
def test_validate_file_path_valid(self):
|
||||
"""Test validation of valid file paths."""
|
||||
from runners.gitlab.services.context_gatherer import _validate_file_path
|
||||
|
||||
assert _validate_file_path("src/file.py") is True
|
||||
assert _validate_file_path("src/utils/helpers.ts") is True
|
||||
assert _validate_file_path("src/config.json") is True
|
||||
|
||||
def test_validate_file_path_invalid(self):
|
||||
"""Test validation rejects invalid file paths."""
|
||||
from runners.gitlab.services.context_gatherer import _validate_file_path
|
||||
|
||||
assert _validate_file_path("") is False # Empty
|
||||
assert _validate_file_path("../etc/passwd") is False # Path traversal
|
||||
assert _validate_file_path("/etc/passwd") is False # Absolute path
|
||||
assert _validate_file_path("a" * 1100) is False # Too long
|
||||
@@ -1,422 +0,0 @@
|
||||
"""
|
||||
GitLab File Lock Tests
|
||||
=======================
|
||||
|
||||
Tests for file locking utilities for concurrent safety.
|
||||
"""
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestFileLock:
|
||||
"""Test FileLock for concurrent-safe operations."""
|
||||
|
||||
@pytest.fixture
|
||||
def lock_file(self, tmp_path):
|
||||
"""Create a temporary lock file path."""
|
||||
return tmp_path / "test.lock"
|
||||
|
||||
def test_acquire_lock(self, lock_file):
|
||||
"""Test acquiring a lock."""
|
||||
from runners.gitlab.utils.file_lock import FileLock
|
||||
|
||||
with FileLock(lock_file, timeout=5.0):
|
||||
# Lock is held here
|
||||
assert lock_file.exists()
|
||||
|
||||
def test_lock_release(self, lock_file):
|
||||
"""Test lock is released after context."""
|
||||
from runners.gitlab.utils.file_lock import FileLock
|
||||
|
||||
with FileLock(lock_file, timeout=5.0):
|
||||
pass
|
||||
|
||||
# Lock file should be cleaned up
|
||||
assert not lock_file.exists()
|
||||
|
||||
def test_lock_timeout(self, lock_file):
|
||||
"""Test lock timeout when held by another process."""
|
||||
from runners.gitlab.utils.file_lock import FileLock, FileLockTimeout
|
||||
|
||||
# Hold lock in separate thread
|
||||
def hold_lock():
|
||||
with FileLock(lock_file, timeout=5.0):
|
||||
time.sleep(0.5)
|
||||
|
||||
thread = threading.Thread(target=hold_lock)
|
||||
thread.start()
|
||||
|
||||
# Wait a bit for lock to be acquired
|
||||
time.sleep(0.1)
|
||||
|
||||
# Try to acquire with short timeout
|
||||
with pytest.raises(FileLockTimeout):
|
||||
FileLock(lock_file, timeout=0.1).acquire()
|
||||
|
||||
thread.join()
|
||||
|
||||
def test_exclusive_lock(self, lock_file):
|
||||
"""Test exclusive lock prevents concurrent writes."""
|
||||
from runners.gitlab.utils.file_lock import FileLock
|
||||
|
||||
results = []
|
||||
|
||||
def try_write(value):
|
||||
try:
|
||||
with FileLock(lock_file, timeout=1.0, exclusive=True):
|
||||
with open(
|
||||
lock_file.with_suffix(".txt"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write(str(value))
|
||||
results.append(value)
|
||||
except Exception:
|
||||
results.append(None)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=try_write, args=(1,)),
|
||||
threading.Thread(target=try_write, args=(2,)),
|
||||
]
|
||||
|
||||
for t in threads:
|
||||
t.start()
|
||||
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# Only one should have succeeded
|
||||
successful = [r for r in results if r is not None]
|
||||
assert len(successful) == 1
|
||||
|
||||
def test_lock_cleanup_on_error(self, lock_file):
|
||||
"""Test lock is cleaned up even on error."""
|
||||
from runners.gitlab.utils.file_lock import FileLock
|
||||
|
||||
try:
|
||||
with FileLock(lock_file, timeout=5.0):
|
||||
raise ValueError("Simulated error")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Lock should be cleaned up despite error
|
||||
assert not lock_file.exists()
|
||||
|
||||
|
||||
class TestAtomicWrite:
|
||||
"""Test atomic_write for safe file writes."""
|
||||
|
||||
@pytest.fixture
|
||||
def target_file(self, tmp_path):
|
||||
"""Create a temporary target file."""
|
||||
return tmp_path / "target.txt"
|
||||
|
||||
def test_atomic_write_creates_file(self, target_file):
|
||||
"""Test atomic write creates target file."""
|
||||
from runners.gitlab.utils.file_lock import atomic_write
|
||||
|
||||
with atomic_write(target_file) as f:
|
||||
f.write("test content")
|
||||
|
||||
assert target_file.exists()
|
||||
assert target_file.read_text(encoding="utf-8") == "test content"
|
||||
|
||||
def test_atomic_write_preserves_on_error(self, target_file):
|
||||
"""Test atomic write doesn't corrupt on error."""
|
||||
from runners.gitlab.utils.file_lock import atomic_write
|
||||
|
||||
# Create initial content
|
||||
target_file.write_text("original content", encoding="utf-8")
|
||||
|
||||
try:
|
||||
with atomic_write(target_file) as f:
|
||||
f.write("new content")
|
||||
raise ValueError("Simulated error")
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Original content should be preserved
|
||||
assert target_file.read_text(encoding="utf-8") == "original content"
|
||||
|
||||
def test_atomic_write_context_manager(self, target_file):
|
||||
"""Test atomic write context manager."""
|
||||
from runners.gitlab.utils.file_lock import atomic_write
|
||||
|
||||
with atomic_write(target_file) as f:
|
||||
f.write("line 1\n")
|
||||
f.write("line 2\n")
|
||||
|
||||
content = target_file.read_text(encoding="utf-8")
|
||||
assert "line 1" in content
|
||||
assert "line 2" in content
|
||||
|
||||
|
||||
class TestLockedJsonOperations:
|
||||
"""Test locked JSON operations."""
|
||||
|
||||
@pytest.fixture
|
||||
def data_file(self, tmp_path):
|
||||
"""Create a temporary data file."""
|
||||
return tmp_path / "data.json"
|
||||
|
||||
def test_locked_json_write(self, data_file):
|
||||
"""Test writing JSON with file locking."""
|
||||
from runners.gitlab.utils.file_lock import locked_json_write
|
||||
|
||||
data = {"key": "value", "number": 42}
|
||||
|
||||
locked_json_write(data_file, data)
|
||||
|
||||
assert data_file.exists()
|
||||
with open(data_file, encoding="utf-8") as f:
|
||||
loaded = json.load(f)
|
||||
assert loaded == data
|
||||
|
||||
def test_locked_json_read(self, data_file):
|
||||
"""Test reading JSON with file locking."""
|
||||
from runners.gitlab.utils.file_lock import locked_json_read, locked_json_write
|
||||
|
||||
data = {"key": "value", "nested": {"item": 1}}
|
||||
locked_json_write(data_file, data)
|
||||
|
||||
loaded = locked_json_read(data_file)
|
||||
|
||||
assert loaded == data
|
||||
|
||||
def test_locked_json_update(self, data_file):
|
||||
"""Test updating JSON with file locking."""
|
||||
from runners.gitlab.utils.file_lock import (
|
||||
locked_json_read,
|
||||
locked_json_update,
|
||||
locked_json_write,
|
||||
)
|
||||
|
||||
initial = {"key": "value"}
|
||||
locked_json_write(data_file, initial)
|
||||
|
||||
def update_fn(data):
|
||||
data["new_key"] = "new_value"
|
||||
return data
|
||||
|
||||
locked_json_update(data_file, update_fn)
|
||||
|
||||
loaded = locked_json_read(data_file)
|
||||
assert loaded["key"] == "value"
|
||||
assert loaded["new_key"] == "new_value"
|
||||
|
||||
def test_locked_json_read_missing_file(self, tmp_path):
|
||||
"""Test reading missing JSON file returns None."""
|
||||
from runners.gitlab.utils.file_lock import locked_json_read
|
||||
|
||||
result = locked_json_read(tmp_path / "nonexistent.json")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_concurrent_json_writes(self, tmp_path):
|
||||
"""Test concurrent JSON writes are safe."""
|
||||
from runners.gitlab.utils.file_lock import (
|
||||
locked_json_read,
|
||||
locked_json_update,
|
||||
locked_json_write,
|
||||
)
|
||||
|
||||
data_file = tmp_path / "concurrent.json"
|
||||
|
||||
# Initialize
|
||||
locked_json_write(data_file, {"counter": 0})
|
||||
|
||||
results = []
|
||||
|
||||
def increment():
|
||||
def updater(data):
|
||||
data["counter"] += 1
|
||||
return data
|
||||
|
||||
locked_json_update(data_file, updater)
|
||||
result = locked_json_read(data_file)
|
||||
results.append(result["counter"])
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=increment),
|
||||
threading.Thread(target=increment),
|
||||
threading.Thread(target=increment),
|
||||
]
|
||||
|
||||
for t in threads:
|
||||
t.start()
|
||||
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# Final value should be 3
|
||||
final = locked_json_read(data_file)
|
||||
assert final["counter"] == 3
|
||||
|
||||
|
||||
class TestLockedReadWrite:
|
||||
"""Test general locked read/write operations."""
|
||||
|
||||
@pytest.fixture
|
||||
def data_file(self, tmp_path):
|
||||
"""Create a temporary data file."""
|
||||
return tmp_path / "data.txt"
|
||||
|
||||
def test_locked_write(self, data_file):
|
||||
"""Test writing with lock."""
|
||||
from runners.gitlab.utils.file_lock import locked_write
|
||||
|
||||
with locked_write(data_file) as f:
|
||||
f.write("test content")
|
||||
|
||||
assert data_file.read_text(encoding="utf-8") == "test content"
|
||||
|
||||
def test_locked_read(self, data_file):
|
||||
"""Test reading with lock."""
|
||||
from runners.gitlab.utils.file_lock import locked_read, locked_write
|
||||
|
||||
with locked_write(data_file) as f:
|
||||
f.write("read test")
|
||||
|
||||
with locked_read(data_file) as f:
|
||||
content = f.read()
|
||||
|
||||
assert content == "read test"
|
||||
|
||||
def test_locked_write_file_lock(self, data_file):
|
||||
"""Test locked_write with custom FileLock."""
|
||||
from runners.gitlab.utils.file_lock import FileLock, locked_write
|
||||
|
||||
with FileLock(data_file, timeout=5.0):
|
||||
with locked_write(data_file, lock=None) as f:
|
||||
f.write("custom lock")
|
||||
|
||||
assert data_file.read_text(encoding="utf-8") == "custom lock"
|
||||
|
||||
|
||||
class TestFileLockError:
|
||||
"""Test FileLockError exceptions."""
|
||||
|
||||
def test_file_lock_error(self):
|
||||
"""Test FileLockError is raised correctly."""
|
||||
from runners.gitlab.utils.file_lock import FileLockError
|
||||
|
||||
error = FileLockError("Custom error message")
|
||||
assert str(error) == "Custom error message"
|
||||
|
||||
def test_file_lock_timeout(self):
|
||||
"""Test FileLockTimeout is raised correctly."""
|
||||
from runners.gitlab.utils.file_lock import FileLockTimeout
|
||||
|
||||
error = FileLockTimeout("Timeout message")
|
||||
assert "Timeout" in str(error)
|
||||
|
||||
|
||||
class TestConcurrentSafety:
|
||||
"""Test concurrent safety scenarios."""
|
||||
|
||||
def test_multiple_readers(self, tmp_path):
|
||||
"""Test multiple readers can access file concurrently."""
|
||||
from runners.gitlab.utils.file_lock import locked_json_read, locked_json_write
|
||||
|
||||
data_file = tmp_path / "readers.json"
|
||||
locked_json_write(data_file, {"value": 42})
|
||||
|
||||
results = []
|
||||
|
||||
def read_value():
|
||||
data = locked_json_read(data_file)
|
||||
results.append(data["value"])
|
||||
|
||||
threads = [threading.Thread(target=read_value) for _ in range(5)]
|
||||
|
||||
for t in threads:
|
||||
t.start()
|
||||
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
assert len(results) == 5
|
||||
assert all(r == 42 for r in results)
|
||||
|
||||
def test_writers_exclusive(self, tmp_path):
|
||||
"""Test writers have exclusive access."""
|
||||
from runners.gitlab.utils.file_lock import (
|
||||
locked_json_read,
|
||||
locked_json_update,
|
||||
locked_json_write,
|
||||
)
|
||||
|
||||
data_file = tmp_path / "writers.json"
|
||||
locked_json_write(data_file, {"counter": 0})
|
||||
|
||||
results = []
|
||||
|
||||
def increment():
|
||||
def updater(data):
|
||||
data["counter"] += 1
|
||||
return data
|
||||
|
||||
locked_json_update(data_file, updater)
|
||||
result = locked_json_read(data_file)
|
||||
results.append(result["counter"])
|
||||
|
||||
threads = [threading.Thread(target=increment) for _ in range(10)]
|
||||
|
||||
for t in threads:
|
||||
t.start()
|
||||
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# All increments should be applied
|
||||
final = locked_json_read(data_file)
|
||||
assert final["counter"] == 10
|
||||
assert len(results) == 10
|
||||
|
||||
def test_reader_writer_conflict(self, tmp_path):
|
||||
"""Test readers and writers don't conflict."""
|
||||
from runners.gitlab.utils.file_lock import (
|
||||
locked_json_read,
|
||||
locked_json_update,
|
||||
locked_json_write,
|
||||
)
|
||||
|
||||
data_file = tmp_path / "rw.json"
|
||||
locked_json_write(data_file, {"reads": 0, "writes": 0})
|
||||
|
||||
read_results = []
|
||||
|
||||
def reader():
|
||||
for _ in range(10):
|
||||
data = locked_json_read(data_file)
|
||||
read_results.append(data["reads"])
|
||||
|
||||
def writer():
|
||||
for _ in range(5):
|
||||
|
||||
def updater(data):
|
||||
data["writes"] += 1
|
||||
return data
|
||||
|
||||
locked_json_update(data_file, updater)
|
||||
|
||||
threads = [
|
||||
threading.Thread(target=reader),
|
||||
threading.Thread(target=writer),
|
||||
]
|
||||
|
||||
for t in threads:
|
||||
t.start()
|
||||
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# All operations should complete
|
||||
final = locked_json_read(data_file)
|
||||
assert final["writes"] == 5
|
||||
assert len(read_results) == 10
|
||||
@@ -1,281 +0,0 @@
|
||||
"""
|
||||
Tests for GitLab File Operations
|
||||
===================================
|
||||
|
||||
Tests for file content retrieval, creation, updating, and deletion.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from runners.gitlab.glab_client import GitLabClient, GitLabConfig
|
||||
except ImportError:
|
||||
from glab_client import GitLabClient, GitLabConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Create a mock GitLab config."""
|
||||
return GitLabConfig(
|
||||
token="test-token",
|
||||
project="namespace/test-project",
|
||||
instance_url="https://gitlab.example.com",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_config, tmp_path):
|
||||
"""Create a GitLab client instance."""
|
||||
return GitLabClient(
|
||||
project_dir=tmp_path,
|
||||
config=mock_config,
|
||||
)
|
||||
|
||||
|
||||
class TestGetFileContents:
|
||||
"""Tests for get_file_contents method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_file_contents_current_version(self, client):
|
||||
"""Test getting file contents from current HEAD."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"file_name": "test.py",
|
||||
"file_path": "src/test.py",
|
||||
"size": 100,
|
||||
"encoding": "base64",
|
||||
"content": "cHJpbnQoJ2hlbGxvJyk=", # base64 for "print('hello')"
|
||||
"content_sha256": "abc123",
|
||||
"ref": "main",
|
||||
}
|
||||
|
||||
result = client.get_file_contents("src/test.py")
|
||||
|
||||
assert result["file_name"] == "test.py"
|
||||
assert result["encoding"] == "base64"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_file_contents_with_ref(self, client):
|
||||
"""Test getting file contents from specific ref."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"file_name": "config.json",
|
||||
"ref": "develop",
|
||||
"content": "eyJjb25maWciOiB0cnVlfQ==",
|
||||
}
|
||||
|
||||
result = client.get_file_contents("config.json", ref="develop")
|
||||
|
||||
assert result["ref"] == "develop"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_file_contents_async(self, client):
|
||||
"""Test async variant of get_file_contents."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"file_name": "test.py",
|
||||
"content": "dGVzdA==",
|
||||
}
|
||||
|
||||
result = await client.get_file_contents_async("test.py")
|
||||
|
||||
assert result["file_name"] == "test.py"
|
||||
|
||||
|
||||
class TestCreateFile:
|
||||
"""Tests for create_file method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_new_file(self, client):
|
||||
"""Test creating a new file."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"file_path": "new_file.py",
|
||||
"branch": "main",
|
||||
"commit_id": "abc123",
|
||||
}
|
||||
|
||||
result = client.create_file(
|
||||
file_path="new_file.py",
|
||||
content="print('hello world')",
|
||||
commit_message="Add new file",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
assert result["file_path"] == "new_file.py"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_file_with_author(self, client):
|
||||
"""Test creating a file with author information."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"file_path": "authored.py",
|
||||
"commit_id": "def456",
|
||||
}
|
||||
|
||||
result = client.create_file(
|
||||
file_path="authored.py",
|
||||
content="# Author: John Doe",
|
||||
commit_message="Add file",
|
||||
branch="main",
|
||||
author_name="John Doe",
|
||||
author_email="john@example.com",
|
||||
)
|
||||
|
||||
assert result["commit_id"] == "def456"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_file_async(self, client):
|
||||
"""Test async variant of create_file."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {"file_path": "async.py"}
|
||||
|
||||
result = await client.create_file_async(
|
||||
file_path="async.py",
|
||||
content="content",
|
||||
commit_message="Add",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
assert result["file_path"] == "async.py"
|
||||
|
||||
|
||||
class TestUpdateFile:
|
||||
"""Tests for update_file method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_existing_file(self, client):
|
||||
"""Test updating an existing file."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"file_path": "existing.py",
|
||||
"branch": "main",
|
||||
"commit_id": "ghi789",
|
||||
}
|
||||
|
||||
result = client.update_file(
|
||||
file_path="existing.py",
|
||||
content="updated content",
|
||||
commit_message="Update file",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
assert result["commit_id"] == "ghi789"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_file_with_author(self, client):
|
||||
"""Test updating file with author info."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"file_path": "update.py",
|
||||
"commit_id": "jkl012",
|
||||
}
|
||||
|
||||
result = client.update_file(
|
||||
file_path="update.py",
|
||||
content="new content",
|
||||
commit_message="Modify file",
|
||||
branch="develop",
|
||||
author_name="Jane Doe",
|
||||
author_email="jane@example.com",
|
||||
)
|
||||
|
||||
assert result["commit_id"] == "jkl012"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_file_async(self, client):
|
||||
"""Test async variant of update_file."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {"file_path": "update.py"}
|
||||
|
||||
result = await client.update_file_async(
|
||||
file_path="update.py",
|
||||
content="new content",
|
||||
commit_message="Update",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
assert result["file_path"] == "update.py"
|
||||
|
||||
|
||||
class TestDeleteFile:
|
||||
"""Tests for delete_file method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_file(self, client):
|
||||
"""Test deleting a file."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"file_path": "old.py",
|
||||
"branch": "main",
|
||||
"commit_id": "mno345",
|
||||
}
|
||||
|
||||
result = client.delete_file(
|
||||
file_path="old.py",
|
||||
commit_message="Remove old file",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
assert result["commit_id"] == "mno345"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_file_async(self, client):
|
||||
"""Test async variant of delete_file."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {"file_path": "delete.py"}
|
||||
|
||||
result = await client.delete_file_async(
|
||||
file_path="delete.py",
|
||||
commit_message="Delete",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
assert result["file_path"] == "delete.py"
|
||||
|
||||
|
||||
class TestFileOperationErrors:
|
||||
"""Tests for file operation error handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nonexistent_file(self, client):
|
||||
"""Test getting a file that doesn't exist."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.side_effect = Exception("404 File Not Found")
|
||||
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
client.get_file_contents("nonexistent.py")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_file_already_exists(self, client):
|
||||
"""Test creating a file that already exists."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.side_effect = Exception("400 File already exists")
|
||||
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
client.create_file(
|
||||
file_path="existing.py",
|
||||
content="content",
|
||||
commit_message="Add",
|
||||
branch="main",
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_nonexistent_file(self, client):
|
||||
"""Test deleting a file that doesn't exist."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.side_effect = Exception("404 File Not Found")
|
||||
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
client.delete_file(
|
||||
file_path="nonexistent.py",
|
||||
commit_message="Delete",
|
||||
branch="main",
|
||||
)
|
||||
@@ -1,455 +0,0 @@
|
||||
"""
|
||||
Unit Tests for GitLab Follow-up MR Reviewer
|
||||
============================================
|
||||
|
||||
Tests for FollowupReviewer class.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from runners.gitlab.models import (
|
||||
AutoFixState,
|
||||
AutoFixStatus,
|
||||
MergeVerdict,
|
||||
MRReviewFinding,
|
||||
MRReviewResult,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
)
|
||||
from runners.gitlab.services.followup_reviewer import FollowupReviewer
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
"""Create a mock GitLab client."""
|
||||
client = MagicMock()
|
||||
client.get_mr_async = AsyncMock()
|
||||
client.get_mr_notes_async = AsyncMock()
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_previous_review():
|
||||
"""Create a sample previous review result."""
|
||||
return MRReviewResult(
|
||||
mr_iid=123,
|
||||
project="namespace/project",
|
||||
success=True,
|
||||
findings=[
|
||||
MRReviewFinding(
|
||||
id="finding-1",
|
||||
severity=ReviewSeverity.HIGH,
|
||||
category=ReviewCategory.SECURITY,
|
||||
title="SQL Injection vulnerability",
|
||||
description="User input not sanitized",
|
||||
file="src/api/users.py",
|
||||
line=42,
|
||||
suggested_fix="Use parameterized queries",
|
||||
fixable=True,
|
||||
),
|
||||
MRReviewFinding(
|
||||
id="finding-2",
|
||||
severity=ReviewSeverity.MEDIUM,
|
||||
category=ReviewCategory.QUALITY,
|
||||
title="Missing error handling",
|
||||
description="No try-except around file I/O",
|
||||
file="src/utils/file.py",
|
||||
line=15,
|
||||
suggested_fix="Add error handling",
|
||||
fixable=True,
|
||||
),
|
||||
],
|
||||
summary="Found 2 issues",
|
||||
overall_status="request_changes",
|
||||
verdict=MergeVerdict.NEEDS_REVISION,
|
||||
verdict_reasoning="High severity issues must be resolved",
|
||||
reviewed_commit_sha="abc123def456",
|
||||
reviewed_file_blobs={"src/api/users.py": "blob1", "src/utils/file.py": "blob2"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reviewer(sample_previous_review):
|
||||
"""Create a FollowupReviewer instance."""
|
||||
return FollowupReviewer(
|
||||
project_dir="/tmp/project",
|
||||
gitlab_dir="/tmp/project/.auto-claude/gitlab",
|
||||
config=MagicMock(project="namespace/project"),
|
||||
progress_callback=None,
|
||||
use_ai=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_review_followup_finding_resolved(
|
||||
reviewer, mock_client, sample_previous_review
|
||||
):
|
||||
"""Test that resolved findings are detected."""
|
||||
from runners.gitlab.models import FollowupMRContext
|
||||
|
||||
# Create context where one finding was resolved
|
||||
context = FollowupMRContext(
|
||||
mr_iid=123,
|
||||
previous_review=sample_previous_review,
|
||||
previous_commit_sha="abc123def456",
|
||||
current_commit_sha="def456abc123",
|
||||
commits_since_review=[
|
||||
{"id": "commit1", "message": "Fix SQL injection"},
|
||||
],
|
||||
files_changed_since_review=["src/api/users.py"],
|
||||
diff_since_review="diff --git a/src/api/users.py b/src/api/users.py\n"
|
||||
"@@ -40,7 +40,7 @@\n"
|
||||
"- query = f\"SELECT * FROM users WHERE name='{name}'\"\n"
|
||||
'+ query = "SELECT * FROM users WHERE name=%s"\n'
|
||||
" cursor.execute(query, (name,))",
|
||||
)
|
||||
|
||||
mock_client.get_mr_notes_async.return_value = []
|
||||
|
||||
result = await reviewer.review_followup(context, mock_client)
|
||||
|
||||
assert result.mr_iid == 123
|
||||
assert len(result.resolved_findings) > 0
|
||||
assert len(result.unresolved_findings) < 2 # At least one resolved
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_review_followup_finding_unresolved(
|
||||
reviewer, mock_client, sample_previous_review
|
||||
):
|
||||
"""Test that unresolved findings are tracked."""
|
||||
from runners.gitlab.models import FollowupMRContext
|
||||
|
||||
# Create context where findings were not addressed
|
||||
context = FollowupMRContext(
|
||||
mr_iid=123,
|
||||
previous_review=sample_previous_review,
|
||||
previous_commit_sha="abc123def456",
|
||||
current_commit_sha="def456abc123",
|
||||
commits_since_review=[
|
||||
{"id": "commit1", "message": "Update docs"},
|
||||
],
|
||||
files_changed_since_review=["README.md"],
|
||||
diff_since_review="diff --git a/README.md b/README.md\n+ # Updated docs",
|
||||
)
|
||||
|
||||
mock_client.get_mr_notes_async.return_value = []
|
||||
|
||||
result = await reviewer.review_followup(context, mock_client)
|
||||
|
||||
assert result.mr_iid == 123
|
||||
assert len(result.unresolved_findings) == 2 # Both still unresolved
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_review_followup_new_findings(
|
||||
reviewer, mock_client, sample_previous_review
|
||||
):
|
||||
"""Test that new issues are detected."""
|
||||
from runners.gitlab.models import FollowupMRContext
|
||||
|
||||
# Create context with TODO comment in diff
|
||||
context = FollowupMRContext(
|
||||
mr_iid=123,
|
||||
previous_review=sample_previous_review,
|
||||
previous_commit_sha="abc123def456",
|
||||
current_commit_sha="def456abc123",
|
||||
commits_since_review=[
|
||||
{"id": "commit1", "message": "Add feature"},
|
||||
],
|
||||
files_changed_since_review=["src/feature.py"],
|
||||
diff_since_review="diff --git a/src/feature.py b/src/feature.py\n"
|
||||
"--- a/src/feature.py\n"
|
||||
"+++ b/src/feature.py\n"
|
||||
"@@ -0,0 +1,3 @@\n"
|
||||
"+ # TODO: implement error handling\n"
|
||||
"+ def feature():\n"
|
||||
"+ pass",
|
||||
)
|
||||
|
||||
mock_client.get_mr_notes_async.return_value = []
|
||||
|
||||
result = await reviewer.review_followup(context, mock_client)
|
||||
|
||||
# Should detect TODO as new finding
|
||||
assert any(
|
||||
f.id.startswith("followup-todo-") and "todo" in f.title.lower()
|
||||
for f in result.findings
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_determine_verdict_critical_blocks(reviewer, sample_previous_review):
|
||||
"""Test that critical issues block merge."""
|
||||
new_findings = [
|
||||
MRReviewFinding(
|
||||
id="new-1",
|
||||
severity=ReviewSeverity.CRITICAL,
|
||||
category=ReviewCategory.SECURITY,
|
||||
title="Critical security issue",
|
||||
description="Must fix",
|
||||
file="src/file.py",
|
||||
line=1,
|
||||
)
|
||||
]
|
||||
|
||||
verdict = reviewer._determine_verdict(
|
||||
unresolved=[],
|
||||
new_findings=new_findings,
|
||||
mr_iid=123,
|
||||
)
|
||||
|
||||
assert verdict == MergeVerdict.BLOCKED
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_determine_verdict_high_needs_revision(reviewer, sample_previous_review):
|
||||
"""Test that high issues require revision."""
|
||||
new_findings = [
|
||||
MRReviewFinding(
|
||||
id="new-1",
|
||||
severity=ReviewSeverity.HIGH,
|
||||
category=ReviewCategory.SECURITY,
|
||||
title="High severity issue",
|
||||
description="Should fix",
|
||||
file="src/file.py",
|
||||
line=1,
|
||||
)
|
||||
]
|
||||
|
||||
verdict = reviewer._determine_verdict(
|
||||
unresolved=[],
|
||||
new_findings=new_findings,
|
||||
mr_iid=123,
|
||||
)
|
||||
|
||||
assert verdict == MergeVerdict.NEEDS_REVISION
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_determine_verdict_medium_merge_with_changes(
|
||||
reviewer, sample_previous_review
|
||||
):
|
||||
"""Test that medium issues suggest merge with changes."""
|
||||
new_findings = [
|
||||
MRReviewFinding(
|
||||
id="new-1",
|
||||
severity=ReviewSeverity.MEDIUM,
|
||||
category=ReviewCategory.QUALITY,
|
||||
title="Medium issue",
|
||||
description="Nice to fix",
|
||||
file="src/file.py",
|
||||
line=1,
|
||||
)
|
||||
]
|
||||
|
||||
verdict = reviewer._determine_verdict(
|
||||
unresolved=[],
|
||||
new_findings=new_findings,
|
||||
mr_iid=123,
|
||||
)
|
||||
|
||||
assert verdict == MergeVerdict.MERGE_WITH_CHANGES
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_determine_verdict_ready_to_merge(reviewer, sample_previous_review):
|
||||
"""Test that low or no issues allow merge."""
|
||||
new_findings = [
|
||||
MRReviewFinding(
|
||||
id="new-1",
|
||||
severity=ReviewSeverity.LOW,
|
||||
category=ReviewCategory.STYLE,
|
||||
title="Style issue",
|
||||
description="Optional fix",
|
||||
file="src/file.py",
|
||||
line=1,
|
||||
)
|
||||
]
|
||||
|
||||
verdict = reviewer._determine_verdict(
|
||||
unresolved=[],
|
||||
new_findings=new_findings,
|
||||
mr_iid=123,
|
||||
)
|
||||
|
||||
assert verdict == MergeVerdict.READY_TO_MERGE
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_determine_verdict_all_clear(reviewer, sample_previous_review):
|
||||
"""Test that no issues allows merge."""
|
||||
verdict = reviewer._determine_verdict(
|
||||
unresolved=[],
|
||||
new_findings=[],
|
||||
mr_iid=123,
|
||||
)
|
||||
|
||||
assert verdict == MergeVerdict.READY_TO_MERGE
|
||||
|
||||
|
||||
def test_is_finding_addressed_file_changed(reviewer, sample_previous_review):
|
||||
"""Test finding detection when file is changed in the diff region."""
|
||||
diff = (
|
||||
"diff --git a/src/api/users.py b/src/api/users.py\n"
|
||||
"@@ -40,7 +40,7 @@\n"
|
||||
"- query = f\"SELECT * FROM users WHERE name='{name}'\"\n"
|
||||
'+ query = "SELECT * FROM users WHERE name=%s"\n'
|
||||
" cursor.execute(query, (name,))"
|
||||
)
|
||||
|
||||
finding = sample_previous_review.findings[0] # Line 42 in users.py
|
||||
|
||||
result = reviewer._is_finding_addressed(diff, finding)
|
||||
|
||||
assert result is True # Line 42 is in the changed range (40-47)
|
||||
|
||||
|
||||
def test_is_finding_addressed_file_not_changed(reviewer, sample_previous_review):
|
||||
"""Test finding detection when file is not in diff."""
|
||||
diff = "diff --git a/README.md b/README.md\n+ # Updated docs"
|
||||
|
||||
finding = sample_previous_review.findings[0] # users.py
|
||||
|
||||
result = reviewer._is_finding_addressed(diff, finding)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_is_finding_addressed_line_not_in_range(reviewer, sample_previous_review):
|
||||
"""Test finding detection when line is outside changed range."""
|
||||
diff = (
|
||||
"diff --git a/src/api/users.py b/src/api/users.py\n"
|
||||
"@@ -1,7 +1,7 @@\n"
|
||||
" def hello():\n"
|
||||
"- print('hello')\n"
|
||||
"+ print('HELLO')\n"
|
||||
)
|
||||
|
||||
finding = sample_previous_review.findings[0] # Line 42, not in range 1-8
|
||||
|
||||
result = reviewer._is_finding_addressed(diff, finding)
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
def test_is_finding_addressed_test_pattern_added(reviewer, sample_previous_review):
|
||||
"""Test finding detection for test category when tests are added."""
|
||||
diff = (
|
||||
"diff --git a/tests/test_users.py b/tests/test_users.py\n"
|
||||
"+ def test_sql_injection():\n"
|
||||
"+ assert True"
|
||||
)
|
||||
|
||||
test_finding = MRReviewFinding(
|
||||
id="test-1",
|
||||
severity=ReviewSeverity.MEDIUM,
|
||||
category=ReviewCategory.TEST,
|
||||
title="Missing tests",
|
||||
description="Add tests for users module",
|
||||
file="tests/test_users.py",
|
||||
line=1,
|
||||
)
|
||||
|
||||
result = reviewer._is_finding_addressed(diff, test_finding)
|
||||
|
||||
assert result is True # Pattern matches "+ def test_"
|
||||
|
||||
|
||||
def test_is_finding_addressed_doc_pattern_added(reviewer, sample_previous_review):
|
||||
"""Test finding detection for documentation category when docs are added."""
|
||||
diff = (
|
||||
"diff --git a/src/api/users.py b/src/api/users.py\n"
|
||||
'+ """\n'
|
||||
"+ User API module.\n"
|
||||
'+ """'
|
||||
)
|
||||
|
||||
doc_finding = MRReviewFinding(
|
||||
id="doc-1",
|
||||
severity=ReviewSeverity.LOW,
|
||||
category=ReviewCategory.DOCS,
|
||||
title="Missing docstring",
|
||||
description="Add module docstring",
|
||||
file="src/api/users.py",
|
||||
line=1,
|
||||
)
|
||||
|
||||
result = reviewer._is_finding_addressed(diff, doc_finding)
|
||||
|
||||
assert result is True # Pattern matches '+"""'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_review_comment_question_detection(
|
||||
reviewer, mock_client, sample_previous_review
|
||||
):
|
||||
"""Test that questions in comments are detected."""
|
||||
from runners.gitlab.models import FollowupMRContext
|
||||
|
||||
context = FollowupMRContext(
|
||||
mr_iid=123,
|
||||
previous_review=sample_previous_review,
|
||||
previous_commit_sha="abc123def456",
|
||||
current_commit_sha="def456abc123",
|
||||
commits_since_review=[{"id": "commit1"}],
|
||||
files_changed_since_review=[],
|
||||
diff_since_review="",
|
||||
)
|
||||
|
||||
mock_client.get_mr_notes_async.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"commit_id": "commit1",
|
||||
"author": {"username": "contributor"},
|
||||
"body": "Should we add error handling here?",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
]
|
||||
|
||||
result = await reviewer.review_followup(context, mock_client)
|
||||
|
||||
# Should detect the question
|
||||
assert any("question" in f.title.lower() for f in result.findings)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_review_comment_filters_by_commit(
|
||||
reviewer, mock_client, sample_previous_review
|
||||
):
|
||||
"""Test that only comments from new commits are reviewed."""
|
||||
from runners.gitlab.models import FollowupMRContext
|
||||
|
||||
context = FollowupMRContext(
|
||||
mr_iid=123,
|
||||
previous_review=sample_previous_review,
|
||||
previous_commit_sha="abc123def456",
|
||||
current_commit_sha="def456abc123",
|
||||
commits_since_review=[{"id": "commit1"}],
|
||||
files_changed_since_review=[],
|
||||
diff_since_review="",
|
||||
)
|
||||
|
||||
mock_client.get_mr_notes_async.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"commit_id": "commit1", # New commit
|
||||
"author": {"username": "contributor"},
|
||||
"body": "Should we add error handling?",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"commit_id": "old-commit", # Old commit, should be ignored
|
||||
"author": {"username": "contributor"},
|
||||
"body": "Another question?",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
},
|
||||
]
|
||||
|
||||
result = await reviewer.review_followup(context, mock_client)
|
||||
|
||||
# Should only have one finding from the new commit
|
||||
question_findings = [f for f in result.findings if "question" in f.title.lower()]
|
||||
assert len(question_findings) == 1
|
||||
@@ -1,566 +0,0 @@
|
||||
"""
|
||||
GitLab MR E2E Tests
|
||||
===================
|
||||
|
||||
End-to-end tests for MR review lifecycle.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from __tests__.fixtures.gitlab import (
|
||||
MOCK_GITLAB_CONFIG,
|
||||
mock_mr_changes,
|
||||
mock_mr_commits,
|
||||
mock_mr_data,
|
||||
mock_pipeline_data,
|
||||
mock_pipeline_jobs,
|
||||
)
|
||||
|
||||
|
||||
class TestMREndToEnd:
|
||||
"""End-to-end MR review lifecycle tests."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_orchestrator(self, tmp_path):
|
||||
"""Create a mock orchestrator for testing."""
|
||||
from runners.gitlab.models import GitLabRunnerConfig
|
||||
from runners.gitlab.orchestrator import GitLabOrchestrator
|
||||
|
||||
config = GitLabRunnerConfig(
|
||||
token="test-token",
|
||||
project="group/project",
|
||||
instance_url="https://gitlab.example.com",
|
||||
model="claude-sonnet-4-20250514",
|
||||
)
|
||||
|
||||
with patch("runners.gitlab.orchestrator.GitLabClient"):
|
||||
orchestrator = GitLabOrchestrator(
|
||||
project_dir=tmp_path,
|
||||
config=config,
|
||||
enable_bot_detection=False,
|
||||
enable_ci_checking=False,
|
||||
)
|
||||
return orchestrator
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_mr_review_lifecycle(self, mock_orchestrator):
|
||||
"""Test complete MR review from start to finish."""
|
||||
# Mock MR data
|
||||
mock_orchestrator.client.get_mr_async.return_value = mock_mr_data()
|
||||
mock_orchestrator.client.get_mr_commits_async.return_value = mock_mr_commits()
|
||||
mock_orchestrator.client.get_mr_changes_async.return_value = mock_mr_changes()
|
||||
|
||||
# Mock review engine
|
||||
with patch(
|
||||
"runners.gitlab.services.context_gatherer.MRContextGatherer"
|
||||
) as mock_gatherer:
|
||||
from runners.gitlab.models import (
|
||||
MergeVerdict,
|
||||
MRContext,
|
||||
MRReviewFinding,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
)
|
||||
|
||||
mock_gatherer.return_value.gather.return_value = MRContext(
|
||||
mr_iid=123,
|
||||
title="Add feature",
|
||||
description="Implementation",
|
||||
author="john_doe",
|
||||
source_branch="feature",
|
||||
target_branch="main",
|
||||
state="opened",
|
||||
changed_files=[],
|
||||
diff="",
|
||||
commits=[],
|
||||
)
|
||||
|
||||
# Mock review engine to return findings
|
||||
with patch("runners.gitlab.services.MRReviewEngine") as mock_engine:
|
||||
findings = [
|
||||
MRReviewFinding(
|
||||
id="find-1",
|
||||
severity=ReviewSeverity.MEDIUM,
|
||||
category=ReviewCategory.QUALITY,
|
||||
title="Code style",
|
||||
description="Fix formatting",
|
||||
file="file.py",
|
||||
line=10,
|
||||
)
|
||||
]
|
||||
|
||||
mock_engine.return_value.run_review.return_value = (
|
||||
findings,
|
||||
MergeVerdict.MERGE_WITH_CHANGES,
|
||||
"Consider the suggestions",
|
||||
[],
|
||||
)
|
||||
|
||||
result = await mock_orchestrator.review_mr(123)
|
||||
|
||||
assert result.success is True
|
||||
assert result.mr_iid == 123
|
||||
assert len(result.findings) == 1
|
||||
assert result.verdict == MergeVerdict.MERGE_WITH_CHANGES
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mr_review_with_ci_failure(self, mock_orchestrator):
|
||||
"""Test MR review blocked by CI failure."""
|
||||
from runners.gitlab.services.ci_checker import PipelineInfo, PipelineStatus
|
||||
|
||||
# Setup CI failure
|
||||
with patch("runners.gitlab.orchestrator.MRContextGatherer"):
|
||||
with patch("runners.gitlab.services.ci_checker.CIChecker") as mock_checker:
|
||||
pipeline_info = PipelineInfo(
|
||||
pipeline_id=1001,
|
||||
status=PipelineStatus.FAILED,
|
||||
ref="feature",
|
||||
sha="abc123",
|
||||
created_at="2025-01-14T10:00:00",
|
||||
updated_at="2025-01-14T10:05:00",
|
||||
failed_jobs=[
|
||||
Mock(
|
||||
status="failed",
|
||||
name="test",
|
||||
stage="test",
|
||||
failure_reason="Assert failed",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
mock_checker.return_value.check_mr_pipeline.return_value = pipeline_info
|
||||
mock_checker.return_value.get_blocking_reason.return_value = (
|
||||
"Test job failed"
|
||||
)
|
||||
mock_checker.return_value.format_pipeline_summary.return_value = (
|
||||
"CI Failed"
|
||||
)
|
||||
|
||||
mock_orchestrator.client.get_mr_async.return_value = mock_mr_data()
|
||||
mock_orchestrator.client.get_mr_commits_async.return_value = []
|
||||
|
||||
with patch("runners.gitlab.services.MRReviewEngine") as mock_engine:
|
||||
from runners.gitlab.models import MergeVerdict
|
||||
|
||||
mock_engine.return_value.run_review.return_value = (
|
||||
[],
|
||||
MergeVerdict.READY_TO_MERGE,
|
||||
"Looks good",
|
||||
[],
|
||||
)
|
||||
|
||||
result = await mock_orchestrator.review_mr(123)
|
||||
|
||||
assert result.ci_status == "failed"
|
||||
assert result.ci_pipeline_id == 1001
|
||||
assert "CI" in result.summary
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_followup_review_lifecycle(self, mock_orchestrator):
|
||||
"""Test follow-up review after initial review."""
|
||||
from runners.gitlab.models import MergeVerdict, MRReviewResult
|
||||
|
||||
# Create initial review
|
||||
initial_review = MRReviewResult(
|
||||
mr_iid=123,
|
||||
project="group/project",
|
||||
success=True,
|
||||
findings=[
|
||||
Mock(id="find-1", title="Fix bug"),
|
||||
Mock(id="find-2", title="Add tests"),
|
||||
],
|
||||
reviewed_commit_sha="abc123",
|
||||
verdict=MergeVerdict.NEEDS_REVISION,
|
||||
verdict_reasoning="Issues found",
|
||||
blockers=["find-1"],
|
||||
)
|
||||
|
||||
# Save initial review
|
||||
initial_review.save(mock_orchestrator.gitlab_dir)
|
||||
|
||||
# Mock new commits
|
||||
new_commits = mock_mr_commits() + [
|
||||
{
|
||||
"id": "new456",
|
||||
"sha": "new456",
|
||||
"message": "Fix the issues",
|
||||
}
|
||||
]
|
||||
|
||||
mock_orchestrator.client.get_mr_async.return_value = mock_mr_data()
|
||||
mock_orchestrator.client.get_mr_commits_async.return_value = new_commits
|
||||
|
||||
# Mock follow-up review
|
||||
with patch("runners.gitlab.orchestrator.MRContextGatherer"):
|
||||
with patch("runners.gitlab.services.MRReviewEngine") as mock_engine:
|
||||
mock_engine.return_value.run_review.return_value = (
|
||||
[], # No new findings
|
||||
MergeVerdict.READY_TO_MERGE,
|
||||
"All fixed",
|
||||
[],
|
||||
)
|
||||
|
||||
result = await mock_orchestrator.followup_review_mr(123)
|
||||
|
||||
assert result.is_followup_review is True
|
||||
assert result.reviewed_commit_sha == "new456"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bot_detection_skips_review(self, tmp_path):
|
||||
"""Test bot detection skips bot-authored MRs."""
|
||||
from runners.gitlab.models import GitLabRunnerConfig
|
||||
from runners.gitlab.orchestrator import GitLabOrchestrator
|
||||
|
||||
config = GitLabRunnerConfig(
|
||||
token="test-token",
|
||||
project="group/project",
|
||||
)
|
||||
|
||||
with patch("runners.gitlab.orchestrator.GitLabClient"):
|
||||
orchestrator = GitLabOrchestrator(
|
||||
project_dir=tmp_path,
|
||||
config=config,
|
||||
bot_username="auto-claude-bot",
|
||||
)
|
||||
|
||||
# Bot-authored MR
|
||||
bot_mr = mock_mr_data(author="auto-claude-bot")
|
||||
orchestrator.client.get_mr_async.return_value = bot_mr
|
||||
orchestrator.client.get_mr_commits_async.return_value = []
|
||||
|
||||
result = await orchestrator.review_mr(123)
|
||||
|
||||
assert result.success is False
|
||||
assert "bot" in result.error.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cooling_off_prevents_re_review(self, tmp_path):
|
||||
"""Test cooling off period prevents immediate re-review."""
|
||||
from runners.gitlab.models import GitLabRunnerConfig
|
||||
from runners.gitlab.orchestrator import GitLabOrchestrator
|
||||
|
||||
config = GitLabRunnerConfig(
|
||||
token="test-token",
|
||||
project="group/project",
|
||||
)
|
||||
|
||||
with patch("runners.gitlab.orchestrator.GitLabClient"):
|
||||
orchestrator = GitLabOrchestrator(
|
||||
project_dir=tmp_path,
|
||||
config=config,
|
||||
)
|
||||
|
||||
# First review
|
||||
orchestrator.client.get_mr_async.return_value = mock_mr_data()
|
||||
orchestrator.client.get_mr_commits_async.return_value = mock_mr_commits()
|
||||
|
||||
with patch("runners.gitlab.orchestrator.MRContextGatherer"):
|
||||
with patch("runners.gitlab.services.MRReviewEngine") as mock_engine:
|
||||
from runners.gitlab.models import MergeVerdict
|
||||
|
||||
mock_engine.return_value.run_review.return_value = (
|
||||
[],
|
||||
MergeVerdict.READY_TO_MERGE,
|
||||
"Good",
|
||||
[],
|
||||
)
|
||||
|
||||
result1 = await orchestrator.review_mr(123)
|
||||
|
||||
assert result1.success is True
|
||||
|
||||
# Immediate second review should be skipped
|
||||
result2 = await orchestrator.review_mr(123)
|
||||
|
||||
assert result2.success is False
|
||||
assert "cooling" in result2.error.lower()
|
||||
|
||||
|
||||
class TestMRReviewEngineIntegration:
|
||||
"""Test MR review engine integration."""
|
||||
|
||||
@pytest.fixture
|
||||
def engine(self, tmp_path):
|
||||
"""Create review engine for testing."""
|
||||
from runners.gitlab.models import GitLabRunnerConfig
|
||||
from runners.gitlab.services.mr_review_engine import MRReviewEngine
|
||||
|
||||
config = GitLabRunnerConfig(
|
||||
token="test-token",
|
||||
project="group/project",
|
||||
)
|
||||
|
||||
gitlab_dir = tmp_path / ".auto-claude" / "gitlab"
|
||||
gitlab_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return MRReviewEngine(
|
||||
project_dir=tmp_path,
|
||||
gitlab_dir=gitlab_dir,
|
||||
config=config,
|
||||
)
|
||||
|
||||
def test_engine_initialization(self, engine):
|
||||
"""Test engine initializes correctly."""
|
||||
assert engine.project_dir
|
||||
assert engine.gitlab_dir
|
||||
assert engine.config
|
||||
|
||||
def test_generate_summary(self, engine):
|
||||
"""Test summary generation."""
|
||||
from runners.gitlab.models import (
|
||||
MergeVerdict,
|
||||
MRReviewFinding,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
)
|
||||
|
||||
findings = [
|
||||
MRReviewFinding(
|
||||
id="find-1",
|
||||
severity=ReviewSeverity.CRITICAL,
|
||||
category=ReviewCategory.SECURITY,
|
||||
title="SQL injection",
|
||||
description="Vulnerability",
|
||||
file="file.py",
|
||||
line=10,
|
||||
),
|
||||
MRReviewFinding(
|
||||
id="find-2",
|
||||
severity=ReviewSeverity.LOW,
|
||||
category=ReviewCategory.STYLE,
|
||||
title="Formatting",
|
||||
description="Style issue",
|
||||
file="file.py",
|
||||
line=20,
|
||||
),
|
||||
]
|
||||
|
||||
summary = engine.generate_summary(
|
||||
findings=findings,
|
||||
verdict=MergeVerdict.BLOCKED,
|
||||
verdict_reasoning="Critical security issue",
|
||||
blockers=["SQL injection"],
|
||||
)
|
||||
|
||||
assert "BLOCKED" in summary
|
||||
assert "SQL injection" in summary
|
||||
assert "Critical" in summary
|
||||
|
||||
|
||||
class TestMRContextGatherer:
|
||||
"""Test MR context gatherer."""
|
||||
|
||||
@pytest.fixture
|
||||
def gatherer(self, tmp_path):
|
||||
"""Create context gatherer for testing."""
|
||||
from runners.gitlab.glab_client import GitLabConfig
|
||||
from runners.gitlab.services.context_gatherer import MRContextGatherer
|
||||
|
||||
config = GitLabConfig(
|
||||
token="test-token",
|
||||
project="group/project",
|
||||
instance_url="https://gitlab.example.com",
|
||||
)
|
||||
|
||||
with patch("runners.gitlab.services.context_gatherer.GitLabClient"):
|
||||
return MRContextGatherer(
|
||||
project_dir=tmp_path,
|
||||
mr_iid=123,
|
||||
config=config,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gather_context(self, gatherer):
|
||||
"""Test gathering MR context."""
|
||||
from runners.gitlab.models import MRContext
|
||||
|
||||
# Mock client responses
|
||||
gatherer.client.get_mr_async.return_value = mock_mr_data()
|
||||
gatherer.client.get_mr_changes_async.return_value = mock_mr_changes()
|
||||
gatherer.client.get_mr_commits_async.return_value = mock_mr_commits()
|
||||
gatherer.client.get_mr_notes_async.return_value = []
|
||||
|
||||
context = await gatherer.gather()
|
||||
|
||||
assert isinstance(context, MRContext)
|
||||
assert context.mr_iid == 123
|
||||
assert context.title == "Add user authentication feature"
|
||||
assert context.author == "john_doe"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gather_ai_bot_comments(self, gatherer):
|
||||
"""Test gathering AI bot comments."""
|
||||
# Mock AI bot comments
|
||||
ai_notes = [
|
||||
{
|
||||
"id": 1001,
|
||||
"author": {"username": "coderabbit[bot]"},
|
||||
"body": "Consider adding error handling",
|
||||
"created_at": "2025-01-14T10:00:00",
|
||||
},
|
||||
{
|
||||
"id": 1002,
|
||||
"author": {"username": "human_user"},
|
||||
"body": "Regular comment",
|
||||
"created_at": "2025-01-14T11:00:00",
|
||||
},
|
||||
]
|
||||
|
||||
gatherer.client.get_mr_notes_async.return_value = ai_notes
|
||||
|
||||
# First call should parse comments
|
||||
from runners.gitlab.services.context_gatherer import AIBotComment
|
||||
|
||||
# Note: _fetch_ai_bot_comments is called internally during gather()
|
||||
gatherer.client.get_mr_async.return_value = mock_mr_data()
|
||||
gatherer.client.get_mr_changes_async.return_value = mock_mr_changes()
|
||||
gatherer.client.get_mr_commits_async.return_value = mock_mr_commits()
|
||||
|
||||
context = await gatherer.gather()
|
||||
|
||||
# Verify AI bot comments were detected (context would have them if implemented)
|
||||
assert context.mr_iid == 123
|
||||
|
||||
|
||||
class TestFollowupContextGatherer:
|
||||
"""Test follow-up context gatherer."""
|
||||
|
||||
@pytest.fixture
|
||||
def previous_review(self):
|
||||
"""Create a previous review for testing."""
|
||||
from runners.gitlab.models import MergeVerdict, MRReviewResult
|
||||
|
||||
return MRReviewResult(
|
||||
mr_iid=123,
|
||||
project="group/project",
|
||||
success=True,
|
||||
findings=[
|
||||
Mock(id="find-1", title="Bug"),
|
||||
],
|
||||
reviewed_commit_sha="abc123",
|
||||
verdict=MergeVerdict.NEEDS_REVISION,
|
||||
verdict_reasoning="Issues found",
|
||||
blockers=[],
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def gatherer(self, tmp_path, previous_review):
|
||||
"""Create follow-up context gatherer."""
|
||||
from runners.gitlab.glab_client import GitLabConfig
|
||||
from runners.gitlab.services.context_gatherer import FollowupMRContextGatherer
|
||||
|
||||
config = GitLabConfig(
|
||||
token="test-token",
|
||||
project="group/project",
|
||||
instance_url="https://gitlab.example.com",
|
||||
)
|
||||
|
||||
with patch("runners.gitlab.services.context_gatherer.GitLabClient"):
|
||||
return FollowupMRContextGatherer(
|
||||
project_dir=tmp_path,
|
||||
mr_iid=123,
|
||||
previous_review=previous_review,
|
||||
config=config,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_gather_followup_context(self, gatherer):
|
||||
"""Test gathering follow-up context."""
|
||||
from runners.gitlab.models import FollowupMRContext
|
||||
|
||||
# Mock new commits since previous review
|
||||
new_commits = [
|
||||
{
|
||||
"id": "new456",
|
||||
"sha": "new456",
|
||||
"message": "Fix bug",
|
||||
}
|
||||
]
|
||||
|
||||
gatherer.client.get_mr_async.return_value = mock_mr_data()
|
||||
gatherer.client.get_mr_commits_async.return_value = new_commits
|
||||
gatherer.client.get_mr_changes_async.return_value = mock_mr_changes()
|
||||
|
||||
context = await gatherer.gather()
|
||||
|
||||
assert isinstance(context, FollowupMRContext)
|
||||
assert context.mr_iid == 123
|
||||
assert context.previous_commit_sha == "abc123"
|
||||
assert context.current_commit_sha == "new456"
|
||||
assert len(context.commits_since_review) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_new_commits(self, gatherer):
|
||||
"""Test follow-up when no new commits."""
|
||||
from runners.gitlab.models import FollowupMRContext
|
||||
|
||||
# Same commits as previous review
|
||||
gatherer.client.get_mr_async.return_value = mock_mr_data()
|
||||
gatherer.client.get_mr_commits_async.return_value = mock_mr_commits()
|
||||
gatherer.client.get_mr_changes_async.return_value = mock_mr_changes()
|
||||
|
||||
context = await gatherer.gather()
|
||||
|
||||
assert context.current_commit_sha == "abc123" # Same as previous
|
||||
|
||||
|
||||
class TestAIBotComment:
|
||||
"""Test AI bot comment detection."""
|
||||
|
||||
def test_parse_coderabbit_comment(self):
|
||||
"""Test parsing CodeRabbit comment."""
|
||||
from runners.gitlab.services.context_gatherer import AIBotComment
|
||||
|
||||
note = {
|
||||
"id": 1001,
|
||||
"author": {"username": "coderabbit[bot]"},
|
||||
"body": "Add error handling",
|
||||
"created_at": "2025-01-14T10:00:00",
|
||||
}
|
||||
|
||||
from runners.gitlab.services.context_gatherer import MRContextGatherer
|
||||
|
||||
gatherer_class = MRContextGatherer.__class__
|
||||
|
||||
comment = gatherer_class._parse_ai_comment(None, note)
|
||||
|
||||
assert comment is not None
|
||||
assert comment.tool_name == "CodeRabbit"
|
||||
assert comment.comment_id == 1001
|
||||
|
||||
def test_parse_human_comment(self):
|
||||
"""Test human comment is not detected as AI."""
|
||||
from runners.gitlab.services.context_gatherer import MRContextGatherer
|
||||
|
||||
note = {
|
||||
"id": 1002,
|
||||
"author": {"username": "john_doe"},
|
||||
"body": "Regular comment",
|
||||
"created_at": "2025-01-14T10:00:00",
|
||||
}
|
||||
|
||||
comment = MRContextGatherer._parse_ai_comment(None, note)
|
||||
|
||||
assert comment is None
|
||||
|
||||
def test_parse_greptile_comment(self):
|
||||
"""Test parsing Greptile comment."""
|
||||
from runners.gitlab.services.context_gatherer import AIBotComment
|
||||
|
||||
note = {
|
||||
"id": 1003,
|
||||
"author": {"username": "greptile[bot]"},
|
||||
"body": "Consider this",
|
||||
"created_at": "2025-01-14T10:00:00",
|
||||
}
|
||||
|
||||
from runners.gitlab.services.context_gatherer import MRContextGatherer
|
||||
|
||||
comment = MRContextGatherer._parse_ai_comment(None, note)
|
||||
|
||||
assert comment is not None
|
||||
assert comment.tool_name == "Greptile"
|
||||
@@ -1,514 +0,0 @@
|
||||
"""
|
||||
GitLab MR Review Tests
|
||||
======================
|
||||
|
||||
Tests for MR review models, findings, verdicts.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from __tests__.fixtures.gitlab import (
|
||||
MOCK_GITLAB_CONFIG,
|
||||
mock_issue_data,
|
||||
mock_mr_data,
|
||||
)
|
||||
|
||||
|
||||
class TestMRReviewFinding:
|
||||
"""Test MRReviewFinding model."""
|
||||
|
||||
def test_finding_creation(self):
|
||||
"""Test creating a review finding."""
|
||||
from runners.gitlab.models import (
|
||||
MRReviewFinding,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
)
|
||||
|
||||
finding = MRReviewFinding(
|
||||
id="find-1",
|
||||
severity=ReviewSeverity.HIGH,
|
||||
category=ReviewCategory.SECURITY,
|
||||
title="SQL injection vulnerability",
|
||||
description="User input not sanitized in query",
|
||||
file="src/auth.py",
|
||||
line=42,
|
||||
end_line=45,
|
||||
suggested_fix="Use parameterized query",
|
||||
fixable=True,
|
||||
)
|
||||
|
||||
assert finding.id == "find-1"
|
||||
assert finding.severity == ReviewSeverity.HIGH
|
||||
assert finding.category == ReviewCategory.SECURITY
|
||||
assert finding.file == "src/auth.py"
|
||||
assert finding.line == 42
|
||||
assert finding.fixable is True
|
||||
|
||||
def test_finding_to_dict(self):
|
||||
"""Test converting finding to dictionary."""
|
||||
from runners.gitlab.models import (
|
||||
MRReviewFinding,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
)
|
||||
|
||||
finding = MRReviewFinding(
|
||||
id="find-1",
|
||||
severity=ReviewSeverity.HIGH,
|
||||
category=ReviewCategory.SECURITY,
|
||||
title="SQL injection",
|
||||
description="Vulnerability",
|
||||
file="src/auth.py",
|
||||
line=42,
|
||||
)
|
||||
|
||||
data = finding.to_dict()
|
||||
|
||||
assert data["id"] == "find-1"
|
||||
assert data["severity"] == "high"
|
||||
assert data["category"] == "security"
|
||||
|
||||
def test_finding_from_dict(self):
|
||||
"""Test loading finding from dictionary."""
|
||||
from runners.gitlab.models import MRReviewFinding
|
||||
|
||||
data = {
|
||||
"id": "find-1",
|
||||
"severity": "high",
|
||||
"category": "security",
|
||||
"title": "SQL injection",
|
||||
"description": "Vulnerability",
|
||||
"file": "src/auth.py",
|
||||
"line": 42,
|
||||
"end_line": 45,
|
||||
"suggested_fix": "Fix it",
|
||||
"fixable": True,
|
||||
}
|
||||
|
||||
finding = MRReviewFinding.from_dict(data)
|
||||
|
||||
assert finding.id == "find-1"
|
||||
assert finding.severity.value == "high"
|
||||
assert finding.line == 42
|
||||
|
||||
def test_finding_with_evidence_code(self):
|
||||
"""Test finding with evidence code."""
|
||||
from runners.gitlab.models import (
|
||||
MRReviewFinding,
|
||||
ReviewCategory,
|
||||
ReviewPass,
|
||||
ReviewSeverity,
|
||||
)
|
||||
|
||||
finding = MRReviewFinding(
|
||||
id="find-1",
|
||||
severity=ReviewSeverity.CRITICAL,
|
||||
category=ReviewCategory.SECURITY,
|
||||
title="Command injection",
|
||||
description="User input in subprocess",
|
||||
file="src/exec.py",
|
||||
line=10,
|
||||
evidence_code="subprocess.call(user_input, shell=True)",
|
||||
found_by_pass=ReviewPass.SECURITY,
|
||||
)
|
||||
|
||||
assert finding.evidence_code == "subprocess.call(user_input, shell=True)"
|
||||
assert finding.found_by_pass == ReviewPass.SECURITY
|
||||
|
||||
|
||||
class TestStructuralIssue:
|
||||
"""Test StructuralIssue model."""
|
||||
|
||||
def test_structural_issue_creation(self):
|
||||
"""Test creating a structural issue."""
|
||||
from runners.gitlab.models import ReviewSeverity, StructuralIssue
|
||||
|
||||
issue = StructuralIssue(
|
||||
id="struct-1",
|
||||
type="feature_creep",
|
||||
title="Additional features added",
|
||||
description="MR includes features beyond original scope",
|
||||
severity=ReviewSeverity.MEDIUM,
|
||||
files_affected=["src/auth.py", "src/users.py"],
|
||||
)
|
||||
|
||||
assert issue.id == "struct-1"
|
||||
assert issue.type == "feature_creep"
|
||||
assert issue.files_affected == ["src/auth.py", "src/users.py"]
|
||||
|
||||
def test_structural_issue_to_dict(self):
|
||||
"""Test converting structural issue to dictionary."""
|
||||
from runners.gitlab.models import StructuralIssue
|
||||
|
||||
issue = StructuralIssue(
|
||||
id="struct-1",
|
||||
type="scope_change",
|
||||
title="Scope increased",
|
||||
description="MR scope changed significantly",
|
||||
files_affected=["file1.py"],
|
||||
)
|
||||
|
||||
data = issue.to_dict()
|
||||
|
||||
assert data["id"] == "struct-1"
|
||||
assert data["type"] == "scope_change"
|
||||
|
||||
def test_structural_issue_from_dict(self):
|
||||
"""Test loading structural issue from dictionary."""
|
||||
from runners.gitlab.models import StructuralIssue
|
||||
|
||||
data = {
|
||||
"id": "struct-1",
|
||||
"type": "feature_creep",
|
||||
"title": "Extra features",
|
||||
"description": "Beyond scope",
|
||||
"severity": "medium",
|
||||
"files_affected": ["file.py"],
|
||||
}
|
||||
|
||||
issue = StructuralIssue.from_dict(data)
|
||||
|
||||
assert issue.type == "feature_creep"
|
||||
|
||||
|
||||
class TestAICommentTriage:
|
||||
"""Test AICommentTriage model."""
|
||||
|
||||
def test_triage_creation(self):
|
||||
"""Test creating AI comment triage."""
|
||||
from runners.gitlab.models import AICommentTriage
|
||||
|
||||
triage = AICommentTriage(
|
||||
comment_id=1001,
|
||||
tool_name="CodeRabbit",
|
||||
original_comment="Consider adding error handling",
|
||||
triage_result="valid",
|
||||
reasoning="Good point about error handling",
|
||||
file="src/auth.py",
|
||||
line=50,
|
||||
created_at="2025-01-14T10:00:00",
|
||||
)
|
||||
|
||||
assert triage.comment_id == 1001
|
||||
assert triage.tool_name == "CodeRabbit"
|
||||
assert triage.triage_result == "valid"
|
||||
|
||||
def test_triage_to_dict(self):
|
||||
"""Test converting triage to dictionary."""
|
||||
from runners.gitlab.models import AICommentTriage
|
||||
|
||||
triage = AICommentTriage(
|
||||
comment_id=1001,
|
||||
tool_name="CodeRabbit",
|
||||
original_comment="Add tests",
|
||||
triage_result="false_positive",
|
||||
reasoning="Tests already exist",
|
||||
)
|
||||
|
||||
data = triage.to_dict()
|
||||
|
||||
assert data["comment_id"] == 1001
|
||||
assert data["triage_result"] == "false_positive"
|
||||
|
||||
def test_triage_from_dict(self):
|
||||
"""Test loading triage from dictionary."""
|
||||
from runners.gitlab.models import AICommentTriage
|
||||
|
||||
data = {
|
||||
"comment_id": 1001,
|
||||
"tool_name": "Cursor",
|
||||
"original_comment": "Fix bug",
|
||||
"triage_result": "questionable",
|
||||
"reasoning": "Unclear if bug exists",
|
||||
"file": "file.py",
|
||||
"line": 10,
|
||||
}
|
||||
|
||||
triage = AICommentTriage.from_dict(data)
|
||||
|
||||
assert triage.tool_name == "Cursor"
|
||||
assert triage.triage_result == "questionable"
|
||||
|
||||
|
||||
class TestMRReviewResult:
|
||||
"""Test MRReviewResult model."""
|
||||
|
||||
def test_result_creation(self):
|
||||
"""Test creating review result."""
|
||||
from runners.gitlab.models import (
|
||||
MergeVerdict,
|
||||
MRReviewFinding,
|
||||
MRReviewResult,
|
||||
ReviewCategory,
|
||||
ReviewSeverity,
|
||||
)
|
||||
|
||||
findings = [
|
||||
MRReviewFinding(
|
||||
id="find-1",
|
||||
severity=ReviewSeverity.HIGH,
|
||||
category=ReviewCategory.SECURITY,
|
||||
title="Bug",
|
||||
description="Issue",
|
||||
file="file.py",
|
||||
line=1,
|
||||
)
|
||||
]
|
||||
|
||||
result = MRReviewResult(
|
||||
mr_iid=123,
|
||||
project="group/project",
|
||||
success=True,
|
||||
findings=findings,
|
||||
summary="Review complete",
|
||||
overall_status="approve",
|
||||
verdict=MergeVerdict.READY_TO_MERGE,
|
||||
verdict_reasoning="No issues found",
|
||||
blockers=[],
|
||||
)
|
||||
|
||||
assert result.mr_iid == 123
|
||||
assert result.findings == findings
|
||||
assert result.verdict == MergeVerdict.READY_TO_MERGE
|
||||
|
||||
def test_result_with_structural_issues(self):
|
||||
"""Test result with structural issues."""
|
||||
from runners.gitlab.models import (
|
||||
MergeVerdict,
|
||||
MRReviewResult,
|
||||
StructuralIssue,
|
||||
)
|
||||
|
||||
structural_issues = [
|
||||
StructuralIssue(
|
||||
id="struct-1",
|
||||
type="feature_creep",
|
||||
title="Extra features",
|
||||
description="Beyond scope",
|
||||
)
|
||||
]
|
||||
|
||||
result = MRReviewResult(
|
||||
mr_iid=123,
|
||||
project="group/project",
|
||||
success=True,
|
||||
structural_issues=structural_issues,
|
||||
verdict=MergeVerdict.MERGE_WITH_CHANGES,
|
||||
verdict_reasoning="Feature creep detected",
|
||||
blockers=[],
|
||||
)
|
||||
|
||||
assert len(result.structural_issues) == 1
|
||||
assert result.verdict == MergeVerdict.MERGE_WITH_CHANGES
|
||||
|
||||
def test_result_with_ai_triages(self):
|
||||
"""Test result with AI comment triages."""
|
||||
from runners.gitlab.models import (
|
||||
AICommentTriage,
|
||||
MergeVerdict,
|
||||
MRReviewResult,
|
||||
)
|
||||
|
||||
ai_triages = [
|
||||
AICommentTriage(
|
||||
comment_id=1001,
|
||||
tool_name="CodeRabbit",
|
||||
original_comment="Fix bug",
|
||||
triage_result="valid",
|
||||
reasoning="Correct",
|
||||
)
|
||||
]
|
||||
|
||||
result = MRReviewResult(
|
||||
mr_iid=123,
|
||||
project="group/project",
|
||||
success=True,
|
||||
ai_triages=ai_triages,
|
||||
verdict=MergeVerdict.READY_TO_MERGE,
|
||||
verdict_reasoning="All good",
|
||||
blockers=[],
|
||||
)
|
||||
|
||||
assert len(result.ai_triages) == 1
|
||||
|
||||
def test_result_with_ci_status(self):
|
||||
"""Test result with CI/CD status."""
|
||||
from runners.gitlab.models import MergeVerdict, MRReviewResult
|
||||
|
||||
result = MRReviewResult(
|
||||
mr_iid=123,
|
||||
project="group/project",
|
||||
success=True,
|
||||
ci_status="failed",
|
||||
ci_pipeline_id=1001,
|
||||
verdict=MergeVerdict.BLOCKED,
|
||||
verdict_reasoning="CI failed",
|
||||
blockers=["CI Pipeline Failed"],
|
||||
)
|
||||
|
||||
assert result.ci_status == "failed"
|
||||
assert result.ci_pipeline_id == 1001
|
||||
assert result.verdict == MergeVerdict.BLOCKED
|
||||
|
||||
def test_result_to_dict(self):
|
||||
"""Test converting result to dictionary."""
|
||||
from runners.gitlab.models import MergeVerdict, MRReviewResult
|
||||
|
||||
result = MRReviewResult(
|
||||
mr_iid=123,
|
||||
project="group/project",
|
||||
success=True,
|
||||
verdict=MergeVerdict.READY_TO_MERGE,
|
||||
verdict_reasoning="Good",
|
||||
blockers=[],
|
||||
)
|
||||
|
||||
data = result.to_dict()
|
||||
|
||||
assert data["mr_iid"] == 123
|
||||
assert data["verdict"] == "ready_to_merge"
|
||||
|
||||
def test_result_from_dict(self):
|
||||
"""Test loading result from dictionary."""
|
||||
from runners.gitlab.models import MergeVerdict, MRReviewResult
|
||||
|
||||
data = {
|
||||
"mr_iid": 123,
|
||||
"project": "group/project",
|
||||
"success": True,
|
||||
"findings": [],
|
||||
"summary": "Review",
|
||||
"overall_status": "approve",
|
||||
"verdict": "ready_to_merge",
|
||||
"verdict_reasoning": "Good",
|
||||
"blockers": [],
|
||||
}
|
||||
|
||||
result = MRReviewResult.from_dict(data)
|
||||
|
||||
assert result.mr_iid == 123
|
||||
assert result.verdict == MergeVerdict.READY_TO_MERGE
|
||||
|
||||
def test_result_save_and_load(self, tmp_path):
|
||||
"""Test saving and loading result from disk."""
|
||||
from runners.gitlab.models import MergeVerdict, MRReviewResult
|
||||
|
||||
result = MRReviewResult(
|
||||
mr_iid=123,
|
||||
project="group/project",
|
||||
success=True,
|
||||
verdict=MergeVerdict.READY_TO_MERGE,
|
||||
verdict_reasoning="Good",
|
||||
blockers=[],
|
||||
)
|
||||
|
||||
result.save(tmp_path)
|
||||
|
||||
loaded = MRReviewResult.load(tmp_path, 123)
|
||||
|
||||
assert loaded is not None
|
||||
assert loaded.mr_iid == 123
|
||||
|
||||
def test_followup_review_fields(self):
|
||||
"""Test follow-up review fields."""
|
||||
from runners.gitlab.models import MergeVerdict, MRReviewResult
|
||||
|
||||
result = MRReviewResult(
|
||||
mr_iid=123,
|
||||
project="group/project",
|
||||
success=True,
|
||||
is_followup_review=True,
|
||||
reviewed_commit_sha="abc123",
|
||||
resolved_findings=["find-1"],
|
||||
unresolved_findings=["find-2"],
|
||||
new_findings_since_last_review=["find-3"],
|
||||
verdict=MergeVerdict.READY_TO_MERGE,
|
||||
verdict_reasoning="Good",
|
||||
blockers=[],
|
||||
)
|
||||
|
||||
assert result.is_followup_review is True
|
||||
assert result.reviewed_commit_sha == "abc123"
|
||||
assert len(result.resolved_findings) == 1
|
||||
|
||||
|
||||
class TestReviewPass:
|
||||
"""Test ReviewPass enum."""
|
||||
|
||||
def test_all_passes_defined(self):
|
||||
"""Test all review passes are defined."""
|
||||
from runners.gitlab.models import ReviewPass
|
||||
|
||||
assert ReviewPass.QUICK_SCAN
|
||||
assert ReviewPass.SECURITY
|
||||
assert ReviewPass.QUALITY
|
||||
assert ReviewPass.DEEP_ANALYSIS
|
||||
assert ReviewPass.STRUCTURAL
|
||||
assert ReviewPass.AI_COMMENT_TRIAGE
|
||||
|
||||
def test_pass_values(self):
|
||||
"""Test pass enum values."""
|
||||
from runners.gitlab.models import ReviewPass
|
||||
|
||||
assert ReviewPass.QUICK_SCAN.value == "quick_scan"
|
||||
assert ReviewPass.SECURITY.value == "security"
|
||||
assert ReviewPass.QUALITY.value == "quality"
|
||||
assert ReviewPass.DEEP_ANALYSIS.value == "deep_analysis"
|
||||
assert ReviewPass.STRUCTURAL.value == "structural"
|
||||
assert ReviewPass.AI_COMMENT_TRIAGE.value == "ai_comment_triage"
|
||||
|
||||
|
||||
class TestMergeVerdict:
|
||||
"""Test MergeVerdict enum."""
|
||||
|
||||
def test_all_verdicts_defined(self):
|
||||
"""Test all verdicts are defined."""
|
||||
from runners.gitlab.models import MergeVerdict
|
||||
|
||||
assert MergeVerdict.READY_TO_MERGE
|
||||
assert MergeVerdict.MERGE_WITH_CHANGES
|
||||
assert MergeVerdict.NEEDS_REVISION
|
||||
assert MergeVerdict.BLOCKED
|
||||
|
||||
def test_verdict_values(self):
|
||||
"""Test verdict enum values."""
|
||||
from runners.gitlab.models import MergeVerdict
|
||||
|
||||
assert MergeVerdict.READY_TO_MERGE.value == "ready_to_merge"
|
||||
assert MergeVerdict.MERGE_WITH_CHANGES.value == "merge_with_changes"
|
||||
assert MergeVerdict.NEEDS_REVISION.value == "needs_revision"
|
||||
assert MergeVerdict.BLOCKED.value == "blocked"
|
||||
|
||||
|
||||
class TestReviewSeverity:
|
||||
"""Test ReviewSeverity enum."""
|
||||
|
||||
def test_all_severities(self):
|
||||
"""Test all severity levels."""
|
||||
from runners.gitlab.models import ReviewSeverity
|
||||
|
||||
assert ReviewSeverity.CRITICAL
|
||||
assert ReviewSeverity.HIGH
|
||||
assert ReviewSeverity.MEDIUM
|
||||
assert ReviewSeverity.LOW
|
||||
|
||||
|
||||
class TestReviewCategory:
|
||||
"""Test ReviewCategory enum."""
|
||||
|
||||
def test_all_categories(self):
|
||||
"""Test all categories."""
|
||||
from runners.gitlab.models import ReviewCategory
|
||||
|
||||
assert ReviewCategory.SECURITY
|
||||
assert ReviewCategory.QUALITY
|
||||
assert ReviewCategory.STYLE
|
||||
assert ReviewCategory.TEST
|
||||
assert ReviewCategory.DOCS
|
||||
assert ReviewCategory.PATTERN
|
||||
assert ReviewCategory.PERFORMANCE
|
||||
@@ -1,381 +0,0 @@
|
||||
"""
|
||||
Unit Tests for GitLab Permission System
|
||||
========================================
|
||||
|
||||
Tests for GitLabPermissionChecker and permission verification.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from runners.gitlab.permissions import (
|
||||
GitLabPermissionChecker,
|
||||
GitLabRole,
|
||||
PermissionCheckResult,
|
||||
)
|
||||
from runners.gitlab.permissions import PermissionError as GitLabPermissionError
|
||||
|
||||
|
||||
class MockGitLabClient:
|
||||
"""Mock GitLab API client for testing."""
|
||||
|
||||
def __init__(self):
|
||||
self._fetch_async = AsyncMock()
|
||||
self.get_project_members_async = AsyncMock(return_value=[])
|
||||
|
||||
def config(self):
|
||||
"""Return mock config."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.project = "namespace/project"
|
||||
return mock_config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_glab_client():
|
||||
"""Create a mock GitLab client."""
|
||||
client = MockGitLabClient()
|
||||
client.config = MagicMock()
|
||||
client.config.project = "namespace/test-project"
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def permission_checker(mock_glab_client):
|
||||
"""Create a permission checker instance."""
|
||||
return GitLabPermissionChecker(
|
||||
glab_client=mock_glab_client,
|
||||
project="namespace/test-project",
|
||||
allowed_roles=["OWNER", "MAINTAINER"],
|
||||
allow_external_contributors=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_token_scopes_success(permission_checker, mock_glab_client):
|
||||
"""Test successful token scope verification."""
|
||||
mock_glab_client._fetch_async.return_value = {
|
||||
"id": 123,
|
||||
"name": "test-project",
|
||||
"path_with_namespace": "namespace/test-project",
|
||||
}
|
||||
|
||||
# Should not raise
|
||||
await permission_checker.verify_token_scopes()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_token_scopes_project_not_found(
|
||||
permission_checker, mock_glab_client
|
||||
):
|
||||
"""Test project not found raises GitLabPermissionError."""
|
||||
mock_glab_client._fetch_async.return_value = None
|
||||
|
||||
with pytest.raises(GitLabPermissionError, match="Cannot access project"):
|
||||
await permission_checker.verify_token_scopes()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_label_adder_success(permission_checker, mock_glab_client):
|
||||
"""Test successfully finding who added a label."""
|
||||
mock_glab_client.get_project_members_async.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"user": {"username": "alice"},
|
||||
"action": "add",
|
||||
"label": {"name": "auto-fix"},
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"user": {"username": "bob"},
|
||||
"action": "remove",
|
||||
"label": {"name": "auto-fix"},
|
||||
},
|
||||
]
|
||||
|
||||
username, role = await permission_checker.check_label_adder(123, "auto-fix")
|
||||
|
||||
assert username == "alice"
|
||||
assert role in [
|
||||
"OWNER",
|
||||
"MAINTAINER",
|
||||
"DEVELOPER",
|
||||
"REPORTER",
|
||||
"GUEST",
|
||||
"NONE",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_label_adder_label_not_found(permission_checker, mock_glab_client):
|
||||
"""Test label not found raises GitLabPermissionError."""
|
||||
mock_glab_client.get_project_members_async.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"user": {"username": "alice"},
|
||||
"action": "add",
|
||||
"label": {"name": "bug"},
|
||||
},
|
||||
]
|
||||
|
||||
with pytest.raises(GitLabPermissionError, match="not found in issue"):
|
||||
await permission_checker.check_label_adder(123, "auto-fix")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_label_adder_no_username(permission_checker, mock_glab_client):
|
||||
"""Test label event without username raises GitLabPermissionError."""
|
||||
mock_glab_client.get_project_members_async.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"action": "add",
|
||||
"label": {"name": "auto-fix"},
|
||||
},
|
||||
]
|
||||
|
||||
with pytest.raises(GitLabPermissionError, match="Could not determine who added"):
|
||||
await permission_checker.check_label_adder(123, "auto-fix")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_role_project_member(permission_checker, mock_glab_client):
|
||||
"""Test getting role for project member."""
|
||||
mock_glab_client.get_project_members_async.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"username": "alice",
|
||||
"access_level": 40, # MAINTAINER
|
||||
},
|
||||
]
|
||||
|
||||
role = await permission_checker.get_user_role("alice")
|
||||
|
||||
assert role == "MAINTAINER"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_role_owner_via_namespace(permission_checker, mock_glab_client):
|
||||
"""Test getting OWNER role via namespace ownership."""
|
||||
# Not a direct member
|
||||
mock_glab_client._fetch_async.side_effect = [
|
||||
[], # No project members
|
||||
{ # Project info
|
||||
"id": 123,
|
||||
"namespace": {
|
||||
"full_path": "namespace",
|
||||
"owner_id": 999,
|
||||
},
|
||||
},
|
||||
[ # User info matches owner
|
||||
{
|
||||
"id": 999,
|
||||
"username": "alice",
|
||||
},
|
||||
],
|
||||
]
|
||||
|
||||
role = await permission_checker.get_user_role("alice")
|
||||
|
||||
assert role == "OWNER"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_role_no_relationship(permission_checker, mock_glab_client):
|
||||
"""Test getting role for user with no relationship."""
|
||||
mock_glab_client._fetch_async.side_effect = [
|
||||
[], # No project members
|
||||
{ # Project info
|
||||
"id": 123,
|
||||
"namespace": {
|
||||
"full_path": "namespace",
|
||||
"owner_id": 999,
|
||||
},
|
||||
},
|
||||
[ # User doesn't match owner
|
||||
{
|
||||
"id": 111,
|
||||
"username": "alice",
|
||||
},
|
||||
],
|
||||
]
|
||||
|
||||
role = await permission_checker.get_user_role("alice")
|
||||
|
||||
assert role == "NONE"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_role_uses_cache(permission_checker, mock_glab_client):
|
||||
"""Test that role results are cached."""
|
||||
mock_glab_client.get_project_members_async.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"username": "alice",
|
||||
"access_level": 40,
|
||||
},
|
||||
]
|
||||
|
||||
# First call
|
||||
role1 = await permission_checker.get_user_role("alice")
|
||||
# Second call should use cache
|
||||
role2 = await permission_checker.get_user_role("alice")
|
||||
|
||||
assert role1 == role2 == "MAINTAINER"
|
||||
# Should only call API once
|
||||
assert mock_glab_client.get_project_members_async.call_count == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_allowed_for_autofix_allowed(permission_checker, mock_glab_client):
|
||||
"""Test user is allowed for auto-fix."""
|
||||
mock_glab_client.get_project_members_async.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"username": "alice",
|
||||
"access_level": 40, # MAINTAINER
|
||||
},
|
||||
]
|
||||
|
||||
result = await permission_checker.is_allowed_for_autofix("alice")
|
||||
|
||||
assert result.allowed is True
|
||||
assert result.username == "alice"
|
||||
assert result.role == "MAINTAINER"
|
||||
assert result.reason is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_allowed_for_autofix_denied(permission_checker, mock_glab_client):
|
||||
"""Test user is denied for auto-fix."""
|
||||
mock_glab_client.get_project_members_async.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"username": "bob",
|
||||
"access_level": 20, # REPORTER (not in allowed roles)
|
||||
},
|
||||
]
|
||||
|
||||
result = await permission_checker.is_allowed_for_autofix("bob")
|
||||
|
||||
assert result.allowed is False
|
||||
assert result.username == "bob"
|
||||
assert result.role == "REPORTER"
|
||||
assert "not in allowed roles" in result.reason
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_automation_trigger_allowed(permission_checker, mock_glab_client):
|
||||
"""Test complete verification succeeds for allowed user."""
|
||||
mock_glab_client._fetch_async.side_effect = [
|
||||
# Label events
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"user": {"username": "alice"},
|
||||
"action": "add",
|
||||
"label": {"name": "auto-fix"},
|
||||
},
|
||||
],
|
||||
# User role check
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"username": "alice",
|
||||
"access_level": 40,
|
||||
},
|
||||
],
|
||||
]
|
||||
|
||||
result = await permission_checker.verify_automation_trigger(123, "auto-fix")
|
||||
|
||||
assert result.allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verify_automation_trigger_denied_logs_warning(
|
||||
permission_checker, mock_glab_client, caplog
|
||||
):
|
||||
"""Test denial is logged with full context."""
|
||||
mock_glab_client._fetch_async.side_effect = [
|
||||
# Label events
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"user": {"username": "bob"},
|
||||
"action": "add",
|
||||
"label": {"name": "auto-fix"},
|
||||
},
|
||||
],
|
||||
# User role check
|
||||
[
|
||||
{
|
||||
"id": 1,
|
||||
"username": "bob",
|
||||
"access_level": 20, # REPORTER
|
||||
},
|
||||
],
|
||||
]
|
||||
|
||||
result = await permission_checker.verify_automation_trigger(123, "auto-fix")
|
||||
|
||||
assert result.allowed is False
|
||||
|
||||
|
||||
def test_log_permission_denial(permission_checker, caplog):
|
||||
"""Test permission denial logging includes full context."""
|
||||
with caplog.at_level(logging.INFO):
|
||||
permission_checker.log_permission_denial(
|
||||
action="auto-fix",
|
||||
username="bob",
|
||||
role="REPORTER",
|
||||
issue_iid=123,
|
||||
)
|
||||
|
||||
# Check that the log contains all relevant info
|
||||
assert len(caplog.records) > 0
|
||||
log_message = caplog.records[0].message
|
||||
assert "auto-fix" in log_message
|
||||
assert "bob" in log_message
|
||||
assert "REPORTER" in log_message
|
||||
assert "123" in log_message
|
||||
|
||||
|
||||
def test_access_levels():
|
||||
"""Test access level constants are correct."""
|
||||
assert GitLabPermissionChecker.ACCESS_LEVELS["GUEST"] == 10
|
||||
assert GitLabPermissionChecker.ACCESS_LEVELS["REPORTER"] == 20
|
||||
assert GitLabPermissionChecker.ACCESS_LEVELS["DEVELOPER"] == 30
|
||||
assert GitLabPermissionChecker.ACCESS_LEVELS["MAINTAINER"] == 40
|
||||
assert GitLabPermissionChecker.ACCESS_LEVELS["OWNER"] == 50
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_role_developer(permission_checker, mock_glab_client):
|
||||
"""Test getting DEVELOPER role."""
|
||||
mock_glab_client.get_project_members_async.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"username": "dev",
|
||||
"access_level": 30,
|
||||
},
|
||||
]
|
||||
|
||||
role = await permission_checker.get_user_role("dev")
|
||||
|
||||
assert role == "DEVELOPER"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_role_guest(permission_checker, mock_glab_client):
|
||||
"""Test getting GUEST role."""
|
||||
mock_glab_client.get_project_members_async.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"username": "guest",
|
||||
"access_level": 10,
|
||||
},
|
||||
]
|
||||
|
||||
role = await permission_checker.get_user_role("guest")
|
||||
|
||||
assert role == "GUEST"
|
||||
@@ -1,249 +0,0 @@
|
||||
"""
|
||||
GitLab Provider Tests
|
||||
=====================
|
||||
|
||||
Tests for GitLabProvider implementation of the GitProvider protocol.
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from __tests__.fixtures.gitlab import (
|
||||
MOCK_GITLAB_CONFIG,
|
||||
mock_issue_data,
|
||||
mock_mr_data,
|
||||
mock_pipeline_data,
|
||||
)
|
||||
|
||||
# Mock ProviderType enum since GitHub runners aren't available in this branch
|
||||
# Note: GitLabProvider defines its own ProviderType when GitHub runners aren't available,
|
||||
# so we just use the string value for comparison
|
||||
GITLAB_PROVIDER_VALUE = "gitlab" # GitHub protocol uses lowercase
|
||||
|
||||
# Tests for GitLabProvider
|
||||
|
||||
|
||||
class TestGitLabProvider:
|
||||
"""Test GitLabProvider implements GitProvider protocol correctly."""
|
||||
|
||||
@pytest.fixture
|
||||
def provider(self, tmp_path):
|
||||
"""Create a GitLabProvider instance for testing."""
|
||||
from runners.gitlab.providers.gitlab_provider import GitLabProvider
|
||||
|
||||
with patch(
|
||||
"runners.gitlab.providers.gitlab_provider.GitLabClient"
|
||||
) as mock_client:
|
||||
provider = GitLabProvider(
|
||||
_repo="group/project",
|
||||
_token="test-token",
|
||||
_instance_url="https://gitlab.example.com",
|
||||
_project_dir=tmp_path,
|
||||
_glab_client=mock_client.return_value,
|
||||
)
|
||||
return provider
|
||||
|
||||
def test_provider_type_property(self, provider):
|
||||
"""Test provider type is GitLab."""
|
||||
# Compare the value since ProviderType may be defined in different modules
|
||||
assert provider.provider_type.value == GITLAB_PROVIDER_VALUE
|
||||
|
||||
def test_repo_property(self, provider):
|
||||
"""Test repo property returns the repository."""
|
||||
assert provider.repo == "group/project"
|
||||
|
||||
def test_fetch_pr(self, provider):
|
||||
"""Test fetching a single MR."""
|
||||
# Mock client responses
|
||||
provider._glab_client.get_mr.return_value = mock_mr_data()
|
||||
provider._glab_client.get_mr_changes.return_value = {
|
||||
"changes": [
|
||||
{
|
||||
"diff": "@@ -0,0 +1,10 @@\n+new line",
|
||||
"new_path": "test.py",
|
||||
"old_path": "test.py",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
# Fetch MR
|
||||
pr = await_if_needed(provider.fetch_pr(123))
|
||||
|
||||
assert pr.number == 123
|
||||
assert pr.title == "Add user authentication feature"
|
||||
assert pr.author == "john_doe"
|
||||
assert pr.state == "opened"
|
||||
assert pr.source_branch == "feature/oauth-auth"
|
||||
assert pr.target_branch == "main"
|
||||
assert pr.provider.name == "GITLAB"
|
||||
|
||||
def test_fetch_prs_with_filters(self, provider):
|
||||
"""Test fetching multiple MRs with filters."""
|
||||
provider._glab_client._fetch.return_value = [
|
||||
mock_mr_data(iid=100),
|
||||
mock_mr_data(iid=101, state="closed"),
|
||||
]
|
||||
|
||||
prs = await_if_needed(provider.fetch_prs())
|
||||
|
||||
assert len(prs) == 2
|
||||
|
||||
def test_fetch_pr_diff(self, provider):
|
||||
"""Test fetching MR diff."""
|
||||
expected_diff = "diff content here"
|
||||
provider._glab_client.get_mr_diff.return_value = expected_diff
|
||||
|
||||
diff = await_if_needed(provider.fetch_pr_diff(123))
|
||||
|
||||
assert diff == expected_diff
|
||||
|
||||
def test_fetch_issue(self, provider):
|
||||
"""Test fetching a single issue."""
|
||||
from __tests__.fixtures.gitlab import SAMPLE_ISSUE_DATA
|
||||
|
||||
provider._glab_client._fetch.return_value = SAMPLE_ISSUE_DATA
|
||||
|
||||
issue = await_if_needed(provider.fetch_issue(42))
|
||||
|
||||
assert issue.number == 42
|
||||
assert issue.title == "Bug: Login button not working"
|
||||
assert issue.author == "jane_smith"
|
||||
assert issue.state == "opened"
|
||||
|
||||
def test_fetch_issues_with_filters(self, provider):
|
||||
"""Test fetching issues with filters."""
|
||||
provider._glab_client._fetch.return_value = [
|
||||
mock_issue_data(iid=10),
|
||||
mock_issue_data(iid=11),
|
||||
]
|
||||
|
||||
issues = await_if_needed(provider.fetch_issues())
|
||||
|
||||
assert len(issues) == 2
|
||||
|
||||
def test_post_review(self, provider):
|
||||
"""Test posting a review to an MR."""
|
||||
# Import ReviewData from GitHub protocol (which GitLabProvider uses)
|
||||
from runners.github.providers.protocol import ReviewData
|
||||
|
||||
provider._glab_client.post_mr_note.return_value = {"id": 999}
|
||||
provider._glab_client._fetch.return_value = {} # approve MR response
|
||||
|
||||
review = ReviewData(
|
||||
pr_number=123,
|
||||
body="LGTM with minor suggestions",
|
||||
event="approve",
|
||||
)
|
||||
|
||||
note_id = await_if_needed(provider.post_review(123, review))
|
||||
|
||||
assert note_id == 999
|
||||
provider._glab_client.post_mr_note.assert_called_once()
|
||||
|
||||
def test_merge_pr(self, provider):
|
||||
"""Test merging an MR."""
|
||||
provider._glab_client.merge_mr.return_value = {"status": "success"}
|
||||
|
||||
result = await_if_needed(provider.merge_pr(123, merge_method="merge"))
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_close_pr(self, provider):
|
||||
"""Test closing an MR."""
|
||||
provider._glab_client._fetch.return_value = {}
|
||||
|
||||
result = await_if_needed(
|
||||
provider.close_pr(123, comment="Closing as not needed")
|
||||
)
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_create_label(self, provider):
|
||||
"""Test creating a label."""
|
||||
# Use LabelData from the provider's fallback protocol
|
||||
from runners.gitlab.providers.gitlab_provider import (
|
||||
LabelData as GitLabLabelData,
|
||||
)
|
||||
|
||||
# Create an alias for readability
|
||||
LabelData = GitLabLabelData
|
||||
|
||||
provider._glab_client._fetch.return_value = {}
|
||||
|
||||
label = LabelData(
|
||||
name="bug",
|
||||
color="#ff0000",
|
||||
description="Bug report",
|
||||
)
|
||||
|
||||
await_if_needed(provider.create_label(label))
|
||||
|
||||
# Verify call was made (checking that it didn't raise)
|
||||
provider._glab_client._fetch.assert_called()
|
||||
|
||||
def test_list_labels(self, provider):
|
||||
"""Test listing labels."""
|
||||
provider._glab_client._fetch.return_value = [
|
||||
{"name": "bug", "color": "ff0000", "description": "Bug"},
|
||||
{"name": "feature", "color": "00ff00", "description": "Feature"},
|
||||
]
|
||||
|
||||
labels = await_if_needed(provider.list_labels())
|
||||
|
||||
assert len(labels) == 2
|
||||
assert labels[0].name == "bug"
|
||||
assert labels[0].color == "#ff0000"
|
||||
|
||||
def test_get_repository_info(self, provider):
|
||||
"""Test getting repository info."""
|
||||
provider._glab_client._fetch.return_value = {
|
||||
"name": "project",
|
||||
"path_with_namespace": "group/project",
|
||||
"default_branch": "main",
|
||||
}
|
||||
|
||||
info = await_if_needed(provider.get_repository_info())
|
||||
|
||||
assert info["default_branch"] == "main"
|
||||
|
||||
def test_get_default_branch(self, provider):
|
||||
"""Test getting default branch."""
|
||||
provider._glab_client._fetch.return_value = {
|
||||
"default_branch": "main",
|
||||
}
|
||||
|
||||
branch = await_if_needed(provider.get_default_branch())
|
||||
|
||||
assert branch == "main"
|
||||
|
||||
def test_api_get(self, provider):
|
||||
"""Test low-level API GET."""
|
||||
provider._glab_client._fetch.return_value = {"data": "value"}
|
||||
|
||||
result = await_if_needed(provider.api_get("/projects/1"))
|
||||
|
||||
assert result["data"] == "value"
|
||||
|
||||
def test_api_post(self, provider):
|
||||
"""Test low-level API POST."""
|
||||
provider._glab_client._fetch.return_value = {"id": 123}
|
||||
|
||||
result = await_if_needed(
|
||||
provider.api_post("/projects/1/notes", {"body": "test"})
|
||||
)
|
||||
|
||||
assert result["id"] == 123
|
||||
|
||||
|
||||
def await_if_needed(coro_or_result):
|
||||
"""Helper to await async functions if needed."""
|
||||
import asyncio
|
||||
|
||||
if hasattr(coro_or_result, "__await__"):
|
||||
return asyncio.run(coro_or_result)
|
||||
return coro_or_result
|
||||
@@ -1,519 +0,0 @@
|
||||
"""
|
||||
GitLab Rate Limiter Tests
|
||||
=========================
|
||||
|
||||
Tests for token bucket rate limiting.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestTokenBucket:
|
||||
"""Test TokenBucket for rate limiting."""
|
||||
|
||||
def test_token_bucket_initialization(self):
|
||||
"""Test token bucket initializes correctly."""
|
||||
from runners.gitlab.utils.rate_limiter import TokenBucket
|
||||
|
||||
bucket = TokenBucket(capacity=10, refill_rate=5.0)
|
||||
|
||||
assert bucket.capacity == 10
|
||||
assert bucket.refill_rate == 5.0
|
||||
assert bucket.tokens == 10
|
||||
|
||||
def test_token_bucket_consume_success(self):
|
||||
"""Test consuming tokens when available."""
|
||||
from runners.gitlab.utils.rate_limiter import TokenBucket
|
||||
|
||||
bucket = TokenBucket(capacity=10, refill_rate=5.0)
|
||||
|
||||
success = bucket.consume(1)
|
||||
|
||||
assert success is True
|
||||
assert bucket.tokens == 9
|
||||
|
||||
def test_token_bucket_consume_multiple(self):
|
||||
"""Test consuming multiple tokens."""
|
||||
from runners.gitlab.utils.rate_limiter import TokenBucket
|
||||
|
||||
bucket = TokenBucket(capacity=10, refill_rate=5.0)
|
||||
|
||||
success = bucket.consume(5)
|
||||
|
||||
assert success is True
|
||||
assert bucket.tokens == 5
|
||||
|
||||
def test_token_bucket_consume_insufficient(self):
|
||||
"""Test consuming when insufficient tokens."""
|
||||
from runners.gitlab.utils.rate_limiter import TokenBucket
|
||||
|
||||
bucket = TokenBucket(capacity=10, refill_rate=5.0)
|
||||
|
||||
# Consume more than available
|
||||
success = bucket.consume(15)
|
||||
|
||||
assert success is False
|
||||
assert bucket.tokens == 10 # Should not change
|
||||
|
||||
def test_token_bucket_refill(self):
|
||||
"""Test token refill over time."""
|
||||
from runners.gitlab.utils.rate_limiter import TokenBucket
|
||||
|
||||
bucket = TokenBucket(capacity=10, refill_rate=10.0)
|
||||
|
||||
# Consume all tokens
|
||||
bucket.consume(10)
|
||||
assert bucket.tokens == 0
|
||||
|
||||
# Wait for refill (0.1 seconds at 10 tokens/sec = 1 token)
|
||||
time.sleep(0.11)
|
||||
|
||||
# Check refill
|
||||
available = bucket.tokens
|
||||
assert available >= 1
|
||||
|
||||
def test_token_bucket_refill_cap(self):
|
||||
"""Test tokens don't exceed capacity."""
|
||||
from runners.gitlab.utils.rate_limiter import TokenBucket
|
||||
|
||||
bucket = TokenBucket(capacity=10, refill_rate=100.0)
|
||||
|
||||
# Wait long time for refill
|
||||
time.sleep(0.2)
|
||||
|
||||
# Should not exceed capacity
|
||||
assert bucket.tokens <= 10
|
||||
|
||||
def test_token_bucket_wait_for_token(self):
|
||||
"""Test waiting for token availability."""
|
||||
from runners.gitlab.utils.rate_limiter import TokenBucket
|
||||
|
||||
bucket = TokenBucket(capacity=5, refill_rate=10.0)
|
||||
|
||||
# Consume all
|
||||
bucket.consume(5)
|
||||
|
||||
# Should wait for refill
|
||||
start = time.time()
|
||||
bucket.consume(1, wait=True)
|
||||
elapsed = time.time() - start
|
||||
|
||||
# Should have waited at least 0.1 seconds
|
||||
assert elapsed >= 0.1
|
||||
|
||||
def test_token_bucket_wait_with_tokens(self):
|
||||
"""Test wait returns immediately when tokens available."""
|
||||
from runners.gitlab.utils.rate_limiter import TokenBucket
|
||||
|
||||
bucket = TokenBucket(capacity=10, refill_rate=5.0)
|
||||
|
||||
start = time.time()
|
||||
bucket.consume(1, wait=True)
|
||||
elapsed = time.time() - start
|
||||
|
||||
# Should be immediate
|
||||
assert elapsed < 0.01
|
||||
|
||||
def test_token_bucket_get_available(self):
|
||||
"""Test getting available token count."""
|
||||
from runners.gitlab.utils.rate_limiter import TokenBucket
|
||||
|
||||
bucket = TokenBucket(capacity=10, refill_rate=5.0)
|
||||
|
||||
assert bucket.get_available() == 10
|
||||
|
||||
bucket.consume(3)
|
||||
assert bucket.get_available() == 7
|
||||
|
||||
def test_token_bucket_reset(self):
|
||||
"""Test resetting token bucket."""
|
||||
from runners.gitlab.utils.rate_limiter import TokenBucket
|
||||
|
||||
bucket = TokenBucket(capacity=10, refill_rate=5.0)
|
||||
|
||||
bucket.consume(5)
|
||||
assert bucket.tokens == 5
|
||||
|
||||
bucket.reset()
|
||||
assert bucket.tokens == 10
|
||||
|
||||
|
||||
class TestRateLimiter:
|
||||
"""Test RateLimiter for API rate limiting."""
|
||||
|
||||
@pytest.fixture
|
||||
def limiter(self):
|
||||
"""Create a rate limiter for testing."""
|
||||
from runners.gitlab.utils.rate_limiter import RateLimiter
|
||||
|
||||
return RateLimiter(
|
||||
requests_per_minute=60,
|
||||
burst_size=10,
|
||||
)
|
||||
|
||||
def test_rate_limiter_initialization(self):
|
||||
"""Test rate limiter initializes correctly."""
|
||||
from runners.gitlab.utils.rate_limiter import RateLimiter
|
||||
|
||||
limiter = RateLimiter(
|
||||
requests_per_minute=60,
|
||||
burst_size=10,
|
||||
)
|
||||
|
||||
assert limiter.requests_per_minute == 60
|
||||
assert limiter.burst_size == 10
|
||||
|
||||
def test_acquire_request(self, limiter):
|
||||
"""Test acquiring a request slot."""
|
||||
success = limiter.acquire()
|
||||
|
||||
assert success is True
|
||||
|
||||
def test_acquire_burst(self, limiter):
|
||||
"""Test burst requests."""
|
||||
# Should be able to make burst_size requests immediately
|
||||
for _ in range(10):
|
||||
success = limiter.acquire()
|
||||
assert success is True
|
||||
|
||||
def test_acquire_exceeds_burst(self, limiter):
|
||||
"""Test exceeding burst limit."""
|
||||
# Consume burst capacity
|
||||
for _ in range(10):
|
||||
limiter.acquire()
|
||||
|
||||
# Next request should fail
|
||||
success = limiter.acquire()
|
||||
assert success is False
|
||||
|
||||
def test_acquire_with_wait(self, limiter):
|
||||
"""Test acquire with wait option."""
|
||||
# Consume burst
|
||||
for _ in range(10):
|
||||
limiter.acquire()
|
||||
|
||||
# Should wait for refill
|
||||
start = time.time()
|
||||
success = limiter.acquire(wait=True)
|
||||
elapsed = time.time() - start
|
||||
|
||||
assert success is True
|
||||
# At 60 req/min, 1 request = 1 second
|
||||
assert elapsed >= 0.9
|
||||
|
||||
def test_get_wait_time(self, limiter):
|
||||
"""Test getting wait time."""
|
||||
# No wait needed initially
|
||||
wait_time = limiter.get_wait_time()
|
||||
assert wait_time == 0
|
||||
|
||||
# Consume burst
|
||||
for _ in range(10):
|
||||
limiter.acquire()
|
||||
|
||||
# Should need to wait
|
||||
wait_time = limiter.get_wait_time()
|
||||
assert wait_time > 0
|
||||
|
||||
def test_reset(self, limiter):
|
||||
"""Test resetting rate limiter."""
|
||||
# Consume some capacity
|
||||
for _ in range(5):
|
||||
limiter.acquire()
|
||||
|
||||
limiter.reset()
|
||||
|
||||
# Should have full capacity
|
||||
success = limiter.acquire()
|
||||
assert success is True
|
||||
|
||||
def test_rate_limiter_state_tracking(self, limiter):
|
||||
"""Test rate limiter tracks request state."""
|
||||
from runners.gitlab.utils.rate_limiter import RateLimiterState
|
||||
|
||||
state = limiter.get_state()
|
||||
|
||||
assert isinstance(state, RateLimiterState)
|
||||
assert state.available_tokens >= 0
|
||||
assert state.available_tokens <= limiter.burst_size
|
||||
|
||||
def test_concurrent_requests(self, limiter):
|
||||
"""Test concurrent request handling."""
|
||||
import threading
|
||||
|
||||
results = []
|
||||
|
||||
def make_request():
|
||||
success = limiter.acquire(wait=True)
|
||||
results.append(success)
|
||||
|
||||
threads = [threading.Thread(target=make_request) for _ in range(15)]
|
||||
|
||||
for t in threads:
|
||||
t.start()
|
||||
|
||||
for t in threads:
|
||||
t.join()
|
||||
|
||||
# All requests should succeed (some wait for refill)
|
||||
assert all(results)
|
||||
|
||||
def test_rate_limiter_persistence(self, limiter, tmp_path):
|
||||
"""Test saving and loading rate limiter state."""
|
||||
state_file = tmp_path / "rate_limiter_state.json"
|
||||
|
||||
# Consume some tokens
|
||||
for _ in range(5):
|
||||
limiter.acquire()
|
||||
|
||||
# Save state
|
||||
limiter.save_state(state_file)
|
||||
|
||||
# Create new limiter and load state
|
||||
from runners.gitlab.utils.rate_limiter import RateLimiter
|
||||
|
||||
new_limiter = RateLimiter(
|
||||
requests_per_minute=60,
|
||||
burst_size=10,
|
||||
)
|
||||
new_limiter.load_state(state_file)
|
||||
|
||||
# Should have same state
|
||||
original_state = limiter.get_state()
|
||||
loaded_state = new_limiter.get_state()
|
||||
|
||||
assert abs(original_state.available_tokens - loaded_state.available_tokens) < 1
|
||||
|
||||
|
||||
class TestRateLimiterIntegration:
|
||||
"""Integration tests for rate limiting with API calls."""
|
||||
|
||||
def test_rate_limiter_with_api_client(self):
|
||||
"""Test rate limiter integrates with API client."""
|
||||
from runners.gitlab.utils.rate_limiter import RateLimiter
|
||||
|
||||
limiter = RateLimiter(
|
||||
requests_per_minute=60,
|
||||
burst_size=5,
|
||||
)
|
||||
|
||||
call_count = 0
|
||||
|
||||
def mock_api_call():
|
||||
nonlocal call_count
|
||||
if limiter.acquire(wait=True):
|
||||
call_count += 1
|
||||
return {"data": "success"}
|
||||
return {"error": "rate limited"}
|
||||
|
||||
# Make several calls
|
||||
results = [mock_api_call() for _ in range(8)]
|
||||
|
||||
# Should have made all calls successfully (some waited)
|
||||
assert call_count == 8
|
||||
assert all(r.get("data") for r in results)
|
||||
|
||||
def test_rate_limiter_respects_backoff(self):
|
||||
"""Test rate limiter handles backoff correctly."""
|
||||
from runners.gitlab.utils.rate_limiter import RateLimiter
|
||||
|
||||
limiter = RateLimiter(
|
||||
requests_per_minute=30, # 0.5 req/sec
|
||||
burst_size=3,
|
||||
)
|
||||
|
||||
times = []
|
||||
|
||||
def track_time():
|
||||
times.append(time.time())
|
||||
return limiter.acquire(wait=True)
|
||||
|
||||
# Make burst + 1 requests
|
||||
for _ in range(4):
|
||||
track_time()
|
||||
|
||||
# First 3 should be immediate (burst)
|
||||
# 4th should have waited
|
||||
burst_duration = times[2] - times[0]
|
||||
wait_duration = times[3] - times[2]
|
||||
|
||||
# 4th request should have taken longer
|
||||
assert wait_duration > burst_duration
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_rate_limiting(self):
|
||||
"""Test rate limiting with async operations."""
|
||||
from runners.gitlab.utils.rate_limiter import RateLimiter
|
||||
|
||||
limiter = RateLimiter(
|
||||
requests_per_minute=60,
|
||||
burst_size=5,
|
||||
)
|
||||
|
||||
async def make_request(i):
|
||||
if limiter.acquire(wait=True):
|
||||
await asyncio.sleep(0.01) # Simulate API call
|
||||
return f"request-{i}"
|
||||
return "rate-limited"
|
||||
|
||||
results = await asyncio.gather(*[make_request(i) for i in range(8)])
|
||||
|
||||
# All should succeed
|
||||
assert len(results) == 8
|
||||
assert all("rate-limited" not in r for r in results)
|
||||
|
||||
|
||||
class TestRateLimiterState:
|
||||
"""Test RateLimiterState model."""
|
||||
|
||||
def test_state_creation(self):
|
||||
"""Test creating state object."""
|
||||
from runners.gitlab.utils.rate_limiter import RateLimiterState
|
||||
|
||||
state = RateLimiterState(
|
||||
available_tokens=5.0,
|
||||
last_refill_time=1234567890.0,
|
||||
)
|
||||
|
||||
assert state.available_tokens == 5.0
|
||||
assert state.last_refill_time == 1234567890.0
|
||||
|
||||
def test_state_to_dict(self):
|
||||
"""Test converting state to dict."""
|
||||
from runners.gitlab.utils.rate_limiter import RateLimiterState
|
||||
|
||||
state = RateLimiterState(
|
||||
available_tokens=7.5,
|
||||
last_refill_time=1234567890.0,
|
||||
)
|
||||
|
||||
data = state.to_dict()
|
||||
|
||||
assert data["available_tokens"] == 7.5
|
||||
assert data["last_refill_time"] == 1234567890.0
|
||||
|
||||
def test_state_from_dict(self):
|
||||
"""Test loading state from dict."""
|
||||
from runners.gitlab.utils.rate_limiter import RateLimiterState
|
||||
|
||||
data = {
|
||||
"available_tokens": 8.0,
|
||||
"last_refill_time": 1234567890.0,
|
||||
}
|
||||
|
||||
state = RateLimiterState.from_dict(data)
|
||||
|
||||
assert state.available_tokens == 8.0
|
||||
assert state.last_refill_time == 1234567890.0
|
||||
|
||||
|
||||
class TestRateLimiterDecorators:
|
||||
"""Test rate limiter decorators."""
|
||||
|
||||
def test_rate_limit_decorator(self):
|
||||
"""Test rate limit decorator for functions."""
|
||||
from runners.gitlab.utils.rate_limiter import rate_limit
|
||||
|
||||
limiter = type(
|
||||
"MockLimiter",
|
||||
(),
|
||||
{
|
||||
"acquire": lambda wait=True: True,
|
||||
},
|
||||
)()
|
||||
|
||||
@rate_limit(limiter)
|
||||
def api_function():
|
||||
return "success"
|
||||
|
||||
result = api_function()
|
||||
assert result == "success"
|
||||
|
||||
def test_rate_limit_decorator_with_wait(self):
|
||||
"""Test rate limit decorator respects wait parameter."""
|
||||
from runners.gitlab.utils.rate_limiter import rate_limit
|
||||
|
||||
call_count = 0
|
||||
|
||||
class MockLimiter:
|
||||
def acquire(self, wait=True):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return call_count <= 3 # Fail after 3 calls
|
||||
|
||||
limiter = MockLimiter()
|
||||
|
||||
@rate_limit(limiter, wait=True)
|
||||
def api_function():
|
||||
return "success"
|
||||
|
||||
# First 3 succeed
|
||||
for _ in range(3):
|
||||
result = api_function()
|
||||
assert result == "success"
|
||||
|
||||
# 4th should fail (would wait but our mock returns False)
|
||||
result = api_function()
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestAdaptiveRateLimiting:
|
||||
"""Test adaptive rate limiting based on responses."""
|
||||
|
||||
def test_adaptive_backoff_on_429(self):
|
||||
"""Test adaptive backoff on rate limit errors."""
|
||||
from runners.gitlab.utils.rate_limiter import AdaptiveRateLimiter
|
||||
|
||||
limiter = AdaptiveRateLimiter(
|
||||
requests_per_minute=60,
|
||||
burst_size=10,
|
||||
)
|
||||
|
||||
# Simulate rate limit response
|
||||
limiter.handle_response(status_code=429)
|
||||
|
||||
# Should reduce rate
|
||||
state = limiter.get_state()
|
||||
assert state.adaptive_factor < 1.0
|
||||
|
||||
def test_adaptive_recovery_on_success(self):
|
||||
"""Test adaptive recovery on successful requests."""
|
||||
from runners.gitlab.utils.rate_limiter import AdaptiveRateLimiter
|
||||
|
||||
limiter = AdaptiveRateLimiter(
|
||||
requests_per_minute=60,
|
||||
burst_size=10,
|
||||
)
|
||||
|
||||
# Trigger backoff
|
||||
limiter.handle_response(status_code=429)
|
||||
|
||||
# Recover with successful requests
|
||||
for _ in range(10):
|
||||
limiter.handle_response(status_code=200)
|
||||
|
||||
# Should recover rate
|
||||
state = limiter.get_state()
|
||||
assert state.adaptive_factor >= 0.9
|
||||
|
||||
def test_adaptive_minimum_rate(self):
|
||||
"""Test adaptive rate has minimum floor."""
|
||||
from runners.gitlab.utils.rate_limiter import AdaptiveRateLimiter
|
||||
|
||||
limiter = AdaptiveRateLimiter(
|
||||
requests_per_minute=60,
|
||||
burst_size=10,
|
||||
min_adaptive_factor=0.1,
|
||||
)
|
||||
|
||||
# Trigger many backoffs
|
||||
for _ in range(100):
|
||||
limiter.handle_response(status_code=429)
|
||||
|
||||
# Should not go below minimum
|
||||
state = limiter.get_state()
|
||||
assert state.adaptive_factor >= 0.1
|
||||
@@ -1,292 +0,0 @@
|
||||
"""
|
||||
Tests for GitLab Triage Engine
|
||||
=================================
|
||||
|
||||
Tests for AI-driven issue triage and categorization.
|
||||
"""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from runners.gitlab.glab_client import GitLabConfig
|
||||
from runners.gitlab.models import TriageCategory, TriageResult
|
||||
from runners.gitlab.services.triage_engine import TriageEngine
|
||||
except ImportError:
|
||||
from glab_client import GitLabConfig
|
||||
from models import TriageCategory, TriageResult
|
||||
from runners.gitlab.triage_engine import TriageEngine
|
||||
|
||||
|
||||
# Mock response parser for testing
|
||||
def parse_findings_from_response(response: str) -> dict:
|
||||
"""Mock parser for testing triage engine."""
|
||||
import json
|
||||
import re
|
||||
|
||||
# Try to extract JSON from markdown code blocks
|
||||
json_match = re.search(r"```(?:json)?\s*\n(.*?)\n```", response, re.DOTALL)
|
||||
if json_match:
|
||||
response = json_match.group(1)
|
||||
|
||||
try:
|
||||
return json.loads(response)
|
||||
except json.JSONDecodeError:
|
||||
return {"category": "bug", "confidence": 0.5}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Create a mock GitLab config."""
|
||||
try:
|
||||
from runners.gitlab.models import GitLabRunnerConfig
|
||||
|
||||
return GitLabRunnerConfig(
|
||||
token="test-token",
|
||||
project="namespace/test-project",
|
||||
instance_url="https://gitlab.example.com",
|
||||
model="claude-sonnet-4-5-20250929",
|
||||
)
|
||||
except ImportError:
|
||||
# Fallback to simple config with model attribute
|
||||
config = GitLabConfig(
|
||||
token="test-token",
|
||||
project="namespace/test-project",
|
||||
instance_url="https://gitlab.example.com",
|
||||
)
|
||||
config.model = "claude-sonnet-4-5-20250929"
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_issue():
|
||||
"""Sample issue data."""
|
||||
return {
|
||||
"iid": 123,
|
||||
"title": "Fix authentication bug",
|
||||
"description": "Users cannot log in when using special characters in password",
|
||||
"labels": ["bug", "critical"],
|
||||
"author": {"username": "reporter"},
|
||||
"state": "opened",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine(mock_config, tmp_path):
|
||||
"""Create a triage engine instance."""
|
||||
return TriageEngine(
|
||||
project_dir=tmp_path,
|
||||
gitlab_dir=tmp_path / ".auto-claude" / "gitlab",
|
||||
config=mock_config,
|
||||
)
|
||||
|
||||
|
||||
class TestTriageEngineBasic:
|
||||
"""Tests for triage engine initialization and basic operations."""
|
||||
|
||||
def test_engine_initialization(self, engine):
|
||||
"""Test that engine initializes correctly."""
|
||||
assert engine is not None
|
||||
assert engine.project_dir is not None
|
||||
|
||||
def test_supported_categories(self, engine):
|
||||
"""Test that engine supports all required categories."""
|
||||
expected_categories = {
|
||||
TriageCategory.BUG,
|
||||
TriageCategory.FEATURE,
|
||||
TriageCategory.DUPLICATE,
|
||||
TriageCategory.QUESTION,
|
||||
TriageCategory.SPAM,
|
||||
TriageCategory.INVALID,
|
||||
TriageCategory.WONTFIX,
|
||||
}
|
||||
|
||||
# Engine should handle all categories
|
||||
for category in expected_categories:
|
||||
assert category in TriageCategory
|
||||
|
||||
|
||||
class ResponseParserTests:
|
||||
"""Tests for response parsing utilities."""
|
||||
|
||||
def test_parse_findings_valid_json(self, engine):
|
||||
"""Test parsing valid JSON response with findings."""
|
||||
response = """```json
|
||||
{
|
||||
"category": "bug",
|
||||
"confidence": 0.9,
|
||||
"duplicate_of": null,
|
||||
"reasoning": "Clear bug report with reproduction steps",
|
||||
"suggested_labels": ["bug", "critical"]
|
||||
}
|
||||
```"""
|
||||
|
||||
result = parse_findings_from_response(response)
|
||||
|
||||
assert result["category"] == "bug"
|
||||
assert result["confidence"] == 0.9
|
||||
|
||||
def test_parse_findings_with_duplicate(self, engine):
|
||||
"""Test parsing response with duplicate reference."""
|
||||
response = """```json
|
||||
{
|
||||
"category": "duplicate",
|
||||
"confidence": 0.95,
|
||||
"duplicate_of": 42,
|
||||
"reasoning": "Same as issue #42",
|
||||
"suggested_labels": ["duplicate"]
|
||||
}
|
||||
```"""
|
||||
|
||||
result = parse_findings_from_response(response)
|
||||
|
||||
assert result["category"] == "duplicate"
|
||||
assert result["duplicate_of"] == 42
|
||||
|
||||
def test_parse_findings_with_question(self, engine):
|
||||
"""Test parsing response for question-type issue."""
|
||||
response = """```json
|
||||
{
|
||||
"category": "question",
|
||||
"confidence": 0.8,
|
||||
"reasoning": "User is asking for help, not reporting a bug",
|
||||
"suggested_response": "Please provide more details"
|
||||
}
|
||||
```"""
|
||||
|
||||
result = parse_findings_from_response(response)
|
||||
|
||||
assert result["category"] == "question"
|
||||
assert "suggested_response" in result
|
||||
|
||||
def test_parse_findings_markdown_only(self, engine):
|
||||
"""Test parsing response without JSON code blocks."""
|
||||
response = """{"category": "feature", "confidence": 0.7}"""
|
||||
|
||||
result = parse_findings_from_response(response)
|
||||
|
||||
assert result["category"] == "feature"
|
||||
|
||||
def test_parse_findings_invalid_json(self, engine):
|
||||
"""Test parsing invalid JSON response."""
|
||||
response = "This is not valid JSON at all"
|
||||
|
||||
result = parse_findings_from_response(response)
|
||||
|
||||
# Should return defaults for invalid response
|
||||
assert "category" in result
|
||||
|
||||
|
||||
class TestTriageCategorization:
|
||||
"""Tests for issue categorization."""
|
||||
|
||||
def test_triage_categories_exist(self):
|
||||
"""Test that all triage categories are defined."""
|
||||
expected_categories = {
|
||||
TriageCategory.BUG,
|
||||
TriageCategory.FEATURE,
|
||||
TriageCategory.DUPLICATE,
|
||||
TriageCategory.QUESTION,
|
||||
TriageCategory.SPAM,
|
||||
TriageCategory.INVALID,
|
||||
TriageCategory.WONTFIX,
|
||||
}
|
||||
# Verify categories exist
|
||||
assert TriageCategory.BUG in expected_categories
|
||||
assert TriageCategory.FEATURE in expected_categories
|
||||
|
||||
|
||||
class TestTriageContextBuilding:
|
||||
"""Tests for context building."""
|
||||
|
||||
def test_build_triage_context_basic(self, engine, sample_issue):
|
||||
"""Test building basic triage context."""
|
||||
context = engine.build_triage_context(sample_issue, [])
|
||||
|
||||
assert "Issue #123" in context
|
||||
assert "Fix authentication bug" in context
|
||||
# The description contains "Users cannot log in" not "Cannot login"
|
||||
assert "Users cannot log in" in context
|
||||
|
||||
def test_build_triage_context_with_duplicates(self, engine):
|
||||
"""Test building context with potential duplicates."""
|
||||
issue = {
|
||||
"iid": 1,
|
||||
"title": "Login bug",
|
||||
"description": "Cannot login",
|
||||
"author": {"username": "user1"},
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"labels": ["bug"],
|
||||
}
|
||||
|
||||
all_issues = [
|
||||
issue,
|
||||
{
|
||||
"iid": 2,
|
||||
"title": "Login issue",
|
||||
"description": "Login not working",
|
||||
"author": {"username": "user2"},
|
||||
"created_at": "2024-01-02T00:00:00Z",
|
||||
"labels": [],
|
||||
},
|
||||
]
|
||||
|
||||
context = engine.build_triage_context(issue, all_issues)
|
||||
|
||||
# Should include potential duplicates section
|
||||
assert "Potential Duplicates" in context
|
||||
assert "#2" in context
|
||||
|
||||
def test_build_triage_context_no_duplicates(self, engine, sample_issue):
|
||||
"""Test building context without duplicates."""
|
||||
context = engine.build_triage_context(sample_issue, [])
|
||||
|
||||
# Should NOT include duplicates section
|
||||
assert "Potential Duplicates" not in context
|
||||
|
||||
|
||||
class TestTriageErrors:
|
||||
"""Tests for error handling in triage."""
|
||||
|
||||
def test_triage_result_default_values(self):
|
||||
"""Test TriageResult can be created with default values."""
|
||||
result = TriageResult(
|
||||
issue_iid=1,
|
||||
project="test/project",
|
||||
category=TriageCategory.FEATURE,
|
||||
confidence=0.0,
|
||||
)
|
||||
assert result.issue_iid == 1
|
||||
assert result.category == TriageCategory.FEATURE
|
||||
assert result.confidence == 0.0
|
||||
|
||||
|
||||
class TestTriageResult:
|
||||
"""Tests for TriageResult model."""
|
||||
|
||||
def test_triage_result_creation(self):
|
||||
"""Test creating a triage result."""
|
||||
result = TriageResult(
|
||||
issue_iid=123,
|
||||
project="namespace/project",
|
||||
category=TriageCategory.BUG,
|
||||
confidence=0.9,
|
||||
)
|
||||
|
||||
assert result.issue_iid == 123
|
||||
assert result.category == TriageCategory.BUG
|
||||
assert result.confidence == 0.9
|
||||
|
||||
def test_triage_result_with_duplicate(self):
|
||||
"""Test creating a triage result with duplicate reference."""
|
||||
result = TriageResult(
|
||||
issue_iid=456,
|
||||
project="namespace/project",
|
||||
category=TriageCategory.DUPLICATE,
|
||||
confidence=0.95,
|
||||
duplicate_of=123,
|
||||
)
|
||||
|
||||
assert result.duplicate_of == 123
|
||||
assert result.category == TriageCategory.DUPLICATE
|
||||
@@ -1,400 +0,0 @@
|
||||
"""
|
||||
Tests for GitLab TypedDict Definitions
|
||||
========================================
|
||||
|
||||
Tests for type definitions and TypedDict usage.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from runners.gitlab.types import (
|
||||
GitLabCommit,
|
||||
GitLabIssue,
|
||||
GitLabLabel,
|
||||
GitLabMR,
|
||||
GitLabPipeline,
|
||||
GitLabUser,
|
||||
)
|
||||
except ImportError:
|
||||
from runners.gitlab.types import (
|
||||
GitLabCommit,
|
||||
GitLabIssue,
|
||||
GitLabLabel,
|
||||
GitLabMR,
|
||||
GitLabPipeline,
|
||||
GitLabUser,
|
||||
)
|
||||
|
||||
|
||||
class TestGitLabUserTypedDict:
|
||||
"""Tests for GitLabUser TypedDict."""
|
||||
|
||||
def test_user_dict_structure(self):
|
||||
"""Test that user dict conforms to expected structure."""
|
||||
user: GitLabUser = {
|
||||
"id": 123,
|
||||
"username": "testuser",
|
||||
"name": "Test User",
|
||||
"email": "test@example.com",
|
||||
"avatar_url": "https://example.com/avatar.png",
|
||||
"web_url": "https://gitlab.example.com/testuser",
|
||||
}
|
||||
|
||||
assert user["id"] == 123
|
||||
assert user["username"] == "testuser"
|
||||
|
||||
def test_user_dict_optional_fields(self):
|
||||
"""Test user dict with optional fields omitted."""
|
||||
user: GitLabUser = {
|
||||
"id": 456,
|
||||
"username": "minimal",
|
||||
"name": "Minimal User",
|
||||
}
|
||||
|
||||
assert user["id"] == 456
|
||||
# Should work without email, avatar_url, web_url
|
||||
|
||||
|
||||
class TestGitLabLabelTypedDict:
|
||||
"""Tests for GitLabLabel TypedDict."""
|
||||
|
||||
def test_label_dict_structure(self):
|
||||
"""Test that label dict conforms to expected structure."""
|
||||
label: GitLabLabel = {
|
||||
"id": 1,
|
||||
"name": "bug",
|
||||
"color": "#FF0000",
|
||||
"description": "Bug report",
|
||||
}
|
||||
|
||||
assert label["name"] == "bug"
|
||||
assert label["color"] == "#FF0000"
|
||||
|
||||
def test_label_dict_optional_description(self):
|
||||
"""Test label dict without description."""
|
||||
label: GitLabLabel = {
|
||||
"id": 2,
|
||||
"name": "enhancement",
|
||||
"color": "#00FF00",
|
||||
}
|
||||
|
||||
assert label["name"] == "enhancement"
|
||||
|
||||
|
||||
class TestGitLabMRTypedDict:
|
||||
"""Tests for GitLabMR TypedDict."""
|
||||
|
||||
def test_mr_dict_structure(self):
|
||||
"""Test that MR dict conforms to expected structure."""
|
||||
mr: GitLabMR = {
|
||||
"iid": 123,
|
||||
"id": 456,
|
||||
"title": "Test MR",
|
||||
"description": "Test description",
|
||||
"state": "opened",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T01:00:00Z",
|
||||
"merged_at": None,
|
||||
"author": {
|
||||
"id": 1,
|
||||
"username": "author",
|
||||
"name": "Author",
|
||||
},
|
||||
"assignees": [],
|
||||
"reviewers": [],
|
||||
"source_branch": "feature",
|
||||
"target_branch": "main",
|
||||
"web_url": "https://gitlab.example.com/merge_requests/123",
|
||||
}
|
||||
|
||||
assert mr["iid"] == 123
|
||||
assert mr["state"] == "opened"
|
||||
|
||||
def test_mr_dict_with_merge_status(self):
|
||||
"""Test MR dict with merge status."""
|
||||
mr: GitLabMR = {
|
||||
"iid": 456,
|
||||
"id": 789,
|
||||
"title": "Merged MR",
|
||||
"state": "merged",
|
||||
"merged_at": "2024-01-02T00:00:00Z",
|
||||
"author": {"id": 1, "username": "dev"},
|
||||
"assignees": [],
|
||||
"reviewers": [],
|
||||
"diff_refs": {
|
||||
"base_sha": "abc123",
|
||||
"head_sha": "def456",
|
||||
"start_sha": "abc123",
|
||||
"head_commit": {"id": "def456"},
|
||||
},
|
||||
"labels": [],
|
||||
}
|
||||
|
||||
assert mr["state"] == "merged"
|
||||
assert mr["merged_at"] is not None
|
||||
|
||||
|
||||
class TestGitLabIssueTypedDict:
|
||||
"""Tests for GitLabIssue TypedDict."""
|
||||
|
||||
def test_issue_dict_structure(self):
|
||||
"""Test that issue dict conforms to expected structure."""
|
||||
issue: GitLabIssue = {
|
||||
"iid": 123,
|
||||
"id": 456,
|
||||
"title": "Test Issue",
|
||||
"description": "Test description",
|
||||
"state": "opened",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T01:00:00Z",
|
||||
"closed_at": None,
|
||||
"author": {
|
||||
"id": 1,
|
||||
"username": "reporter",
|
||||
"name": "Reporter",
|
||||
},
|
||||
"assignees": [],
|
||||
"labels": [],
|
||||
"web_url": "https://gitlab.example.com/issues/123",
|
||||
}
|
||||
|
||||
assert issue["iid"] == 123
|
||||
assert issue["state"] == "opened"
|
||||
|
||||
def test_issue_dict_with_labels(self):
|
||||
"""Test issue dict with labels."""
|
||||
issue: GitLabIssue = {
|
||||
"iid": 789,
|
||||
"id": 101,
|
||||
"title": "Labeled Issue",
|
||||
"labels": [
|
||||
{
|
||||
"id": 1,
|
||||
"name": "bug",
|
||||
"color": "#FF0000",
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"name": "critical",
|
||||
"color": "#00FF00",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
assert len(issue["labels"]) == 2
|
||||
assert issue["labels"][0]["name"] == "bug"
|
||||
|
||||
|
||||
class TestGitLabCommitTypedDict:
|
||||
"""Tests for GitLabCommit TypedDict."""
|
||||
|
||||
def test_commit_dict_structure(self):
|
||||
"""Test that commit dict conforms to expected structure."""
|
||||
commit: GitLabCommit = {
|
||||
"id": "abc123def456",
|
||||
"short_id": "abc123",
|
||||
"title": "Test commit",
|
||||
"message": "Test commit message",
|
||||
"author_name": "Developer",
|
||||
"author_email": "dev@example.com",
|
||||
"authored_date": "2024-01-01T00:00:00Z",
|
||||
"committed_date": "2024-01-01T00:00:01Z",
|
||||
"web_url": "https://gitlab.example.com/commit/abc123",
|
||||
}
|
||||
|
||||
assert commit["id"] == "abc123def456"
|
||||
assert commit["short_id"] == "abc123"
|
||||
assert commit["author_name"] == "Developer"
|
||||
|
||||
|
||||
class TestGitLabPipelineTypedDict:
|
||||
"""Tests for GitLabPipeline TypedDict."""
|
||||
|
||||
def test_pipeline_dict_structure(self):
|
||||
"""Test that pipeline dict conforms to expected structure."""
|
||||
pipeline: GitLabPipeline = {
|
||||
"id": 123,
|
||||
"iid": 456,
|
||||
"project_id": 789,
|
||||
"sha": "abc123",
|
||||
"ref": "main",
|
||||
"status": "success",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T01:00:00Z",
|
||||
"finished_at": "2024-01-01T02:00:00Z",
|
||||
"duration": 120,
|
||||
"web_url": "https://gitlab.example.com/pipelines/123",
|
||||
}
|
||||
|
||||
assert pipeline["id"] == 123
|
||||
assert pipeline["status"] == "success"
|
||||
assert pipeline["duration"] == 120
|
||||
|
||||
def test_pipeline_dict_optional_fields(self):
|
||||
"""Test pipeline dict with optional fields omitted."""
|
||||
pipeline: GitLabPipeline = {
|
||||
"id": 456,
|
||||
"iid": 789,
|
||||
"project_id": 101,
|
||||
"sha": "def456",
|
||||
"ref": "develop",
|
||||
"status": "running",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T01:00:00Z",
|
||||
"finished_at": None,
|
||||
"duration": None,
|
||||
}
|
||||
|
||||
assert pipeline["status"] == "running"
|
||||
assert pipeline["finished_at"] is None
|
||||
|
||||
|
||||
class TestTotalFalseBehavior:
|
||||
"""Tests for total=False behavior in TypedDict (all fields optional)."""
|
||||
|
||||
def test_mr_minimal_dict(self):
|
||||
"""Test creating MR with minimal required fields."""
|
||||
# In practice, GitLab API always returns certain fields
|
||||
# But TypedDict with total=False allows flexibility
|
||||
mr: GitLabMR = {
|
||||
"iid": 123,
|
||||
"id": 456,
|
||||
"title": "Minimal MR",
|
||||
"state": "opened",
|
||||
}
|
||||
|
||||
assert mr["iid"] == 123
|
||||
|
||||
def test_issue_minimal_dict(self):
|
||||
"""Test creating issue with minimal required fields."""
|
||||
issue: GitLabIssue = {
|
||||
"iid": 456,
|
||||
"id": 789,
|
||||
"title": "Minimal Issue",
|
||||
"state": "opened",
|
||||
}
|
||||
|
||||
assert issue["iid"] == 456
|
||||
|
||||
|
||||
class TestNestedTypedDicts:
|
||||
"""Tests for nested TypedDict structures."""
|
||||
|
||||
def test_mr_with_nested_user(self):
|
||||
"""Test MR with nested user objects."""
|
||||
mr: GitLabMR = {
|
||||
"iid": 123,
|
||||
"id": 456,
|
||||
"title": "MR with author",
|
||||
"state": "opened",
|
||||
"author": {
|
||||
"id": 1,
|
||||
"username": "dev",
|
||||
"name": "Developer",
|
||||
},
|
||||
"assignees": [
|
||||
{
|
||||
"id": 2,
|
||||
"username": "assignee1",
|
||||
"name": "Assignee One",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
assert mr["author"]["username"] == "dev"
|
||||
assert len(mr["assignees"]) == 1
|
||||
|
||||
def test_issue_with_nested_labels(self):
|
||||
"""Test issue with nested label objects."""
|
||||
issue: GitLabIssue = {
|
||||
"iid": 123,
|
||||
"id": 456,
|
||||
"title": "Issue with labels",
|
||||
"state": "opened",
|
||||
"labels": [
|
||||
{"id": 1, "name": "bug", "color": "#FF0000"},
|
||||
{"id": 2, "name": "critical", "color": "#00FF00"},
|
||||
],
|
||||
}
|
||||
|
||||
assert issue["labels"][0]["name"] == "bug"
|
||||
assert len(issue["labels"]) == 2
|
||||
|
||||
|
||||
class TestTypeCompatibility:
|
||||
"""Tests for type compatibility and validation."""
|
||||
|
||||
def test_mr_type_accepts_all_states(self):
|
||||
"""Test that MR type accepts all valid GitLab MR states."""
|
||||
valid_states = ["opened", "closed", "locked", "merged"]
|
||||
|
||||
for state in valid_states:
|
||||
mr: GitLabMR = {
|
||||
"iid": 1,
|
||||
"id": 1,
|
||||
"title": f"MR in {state} state",
|
||||
"state": state,
|
||||
}
|
||||
assert mr["state"] == state
|
||||
|
||||
def test_pipeline_type_accepts_all_statuses(self):
|
||||
"""Test that pipeline type accepts all valid GitLab pipeline statuses."""
|
||||
valid_statuses = [
|
||||
"pending",
|
||||
"running",
|
||||
"success",
|
||||
"failed",
|
||||
"canceled",
|
||||
"skipped",
|
||||
"manual",
|
||||
"scheduled",
|
||||
]
|
||||
|
||||
for status in valid_statuses:
|
||||
pipeline: GitLabPipeline = {
|
||||
"id": 1,
|
||||
"iid": 1,
|
||||
"project_id": 1,
|
||||
"sha": "abc",
|
||||
"ref": "main",
|
||||
"status": status,
|
||||
}
|
||||
assert pipeline["status"] == status
|
||||
|
||||
|
||||
class TestDocumentation:
|
||||
"""Tests that types are self-documenting."""
|
||||
|
||||
def test_user_fields_are_documented(self):
|
||||
"""Test that user fields match documentation."""
|
||||
# GitLabUser should have: id, username, name, email, avatar_url, web_url
|
||||
user: GitLabUser = {
|
||||
"id": 1,
|
||||
"username": "test",
|
||||
"name": "Test",
|
||||
"email": "test@example.com",
|
||||
"avatar_url": "https://example.com/avatar.png",
|
||||
"web_url": "https://gitlab.example.com/test",
|
||||
}
|
||||
|
||||
# Verify expected fields exist
|
||||
expected_fields = ["id", "username", "name", "email", "avatar_url", "web_url"]
|
||||
for field in expected_fields:
|
||||
assert field in user
|
||||
|
||||
def test_mr_fields_are_documented(self):
|
||||
"""Test that MR fields match documentation."""
|
||||
# Key MR fields
|
||||
mr: GitLabMR = {
|
||||
"iid": 123,
|
||||
"id": 456,
|
||||
"title": "Test",
|
||||
"state": "opened",
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"updated_at": "2024-01-01T01:00:00Z",
|
||||
}
|
||||
|
||||
expected_fields = ["iid", "id", "title", "state", "created_at", "updated_at"]
|
||||
for field in expected_fields:
|
||||
assert field in mr
|
||||
@@ -1,318 +0,0 @@
|
||||
"""
|
||||
Tests for GitLab Webhook Operations
|
||||
======================================
|
||||
|
||||
Tests for webhook listing, creation, updating, and deletion.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from runners.gitlab.glab_client import GitLabClient, GitLabConfig
|
||||
except ImportError:
|
||||
from glab_client import GitLabClient, GitLabConfig
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Create a mock GitLab config."""
|
||||
return GitLabConfig(
|
||||
token="test-token",
|
||||
project="namespace/test-project",
|
||||
instance_url="https://gitlab.example.com",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(mock_config, tmp_path):
|
||||
"""Create a GitLab client instance."""
|
||||
return GitLabClient(
|
||||
project_dir=tmp_path,
|
||||
config=mock_config,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_webhooks():
|
||||
"""Sample webhook data."""
|
||||
return [
|
||||
{
|
||||
"id": 1,
|
||||
"url": "https://example.com/webhook",
|
||||
"project_id": 123,
|
||||
"push_events": True,
|
||||
"issues_events": False,
|
||||
"merge_requests_events": True,
|
||||
"wiki_page_events": False,
|
||||
"repository_update_events": False,
|
||||
"tag_push_events": False,
|
||||
"note_events": False,
|
||||
"confidential_note_events": False,
|
||||
"job_events": False,
|
||||
"pipeline_events": False,
|
||||
"deployment_events": False,
|
||||
"release_events": False,
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"url": "https://hooks.example.com/another",
|
||||
"project_id": 123,
|
||||
"push_events": False,
|
||||
"issues_events": True,
|
||||
"merge_requests_events": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
class TestListWebhooks:
|
||||
"""Tests for list_webhooks method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_all_webhooks(self, client, sample_webhooks):
|
||||
"""Test listing all webhooks."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = sample_webhooks
|
||||
|
||||
result = client.list_webhooks()
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["id"] == 1
|
||||
assert result[0]["url"] == "https://example.com/webhook"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_webhooks_empty(self, client):
|
||||
"""Test listing webhooks when none exist."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = []
|
||||
|
||||
result = client.list_webhooks()
|
||||
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_webhooks_async(self, client, sample_webhooks):
|
||||
"""Test async variant of list_webhooks."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = sample_webhooks
|
||||
|
||||
result = await client.list_webhooks_async()
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
|
||||
class TestGetWebhook:
|
||||
"""Tests for get_webhook method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_existing_webhook(self, client, sample_webhooks):
|
||||
"""Test getting an existing webhook."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = sample_webhooks[0]
|
||||
|
||||
result = client.get_webhook(1)
|
||||
|
||||
assert result["id"] == 1
|
||||
assert result["url"] == "https://example.com/webhook"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_webhook_async(self, client, sample_webhooks):
|
||||
"""Test async variant of get_webhook."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = sample_webhooks[0]
|
||||
|
||||
result = await client.get_webhook_async(1)
|
||||
|
||||
assert result["id"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_nonexistent_webhook(self, client):
|
||||
"""Test getting a webhook that doesn't exist."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.side_effect = Exception("404 Not Found")
|
||||
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
client.get_webhook(999)
|
||||
|
||||
|
||||
class TestCreateWebhook:
|
||||
"""Tests for create_webhook method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_webhook_basic(self, client):
|
||||
"""Test creating a webhook with basic settings."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"id": 3,
|
||||
"url": "https://example.com/new-hook",
|
||||
}
|
||||
|
||||
result = client.create_webhook(
|
||||
url="https://example.com/new-hook",
|
||||
)
|
||||
|
||||
assert result["id"] == 3
|
||||
assert result["url"] == "https://example.com/new-hook"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_webhook_with_events(self, client):
|
||||
"""Test creating a webhook with specific events."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"id": 4,
|
||||
"url": "https://example.com/push-hook",
|
||||
"push_events": True,
|
||||
"issues_events": True,
|
||||
}
|
||||
|
||||
result = client.create_webhook(
|
||||
url="https://example.com/push-hook",
|
||||
push_events=True,
|
||||
issues_events=True,
|
||||
)
|
||||
|
||||
assert result["push_events"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_webhook_with_all_events(self, client):
|
||||
"""Test creating a webhook that listens to all events."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {"id": 5}
|
||||
|
||||
result = client.create_webhook(
|
||||
url="https://example.com/all-events",
|
||||
push_events=True,
|
||||
merge_request_events=True,
|
||||
issues_events=True,
|
||||
note_events=True,
|
||||
job_events=True,
|
||||
pipeline_events=True,
|
||||
wiki_page_events=True,
|
||||
)
|
||||
|
||||
assert result["id"] == 5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_webhook_async(self, client):
|
||||
"""Test async variant of create_webhook."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {"id": 6}
|
||||
|
||||
result = await client.create_webhook_async(
|
||||
url="https://example.com/async-hook",
|
||||
)
|
||||
|
||||
assert result["id"] == 6
|
||||
|
||||
|
||||
class TestUpdateWebhook:
|
||||
"""Tests for update_webhook method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_webhook_url(self, client):
|
||||
"""Test updating webhook URL."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"id": 1,
|
||||
"url": "https://example.com/updated-url",
|
||||
}
|
||||
|
||||
result = client.update_webhook(
|
||||
hook_id=1,
|
||||
url="https://example.com/updated-url",
|
||||
)
|
||||
|
||||
assert result["url"] == "https://example.com/updated-url"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_webhook_events(self, client):
|
||||
"""Test updating webhook events."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {
|
||||
"id": 1,
|
||||
"push_events": False, # Disabled
|
||||
"issues_events": True, # Enabled
|
||||
}
|
||||
|
||||
result = client.update_webhook(
|
||||
hook_id=1,
|
||||
push_events=False,
|
||||
issues_events=True,
|
||||
)
|
||||
|
||||
assert result["push_events"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_webhook_async(self, client):
|
||||
"""Test async variant of update_webhook."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = {"id": 1, "url": "new"}
|
||||
|
||||
result = await client.update_webhook_async(
|
||||
hook_id=1,
|
||||
url="new",
|
||||
)
|
||||
|
||||
assert result["url"] == "new"
|
||||
|
||||
|
||||
class TestDeleteWebhook:
|
||||
"""Tests for delete_webhook method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_webhook(self, client):
|
||||
"""Test deleting a webhook."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = None # 204 No Content
|
||||
|
||||
result = client.delete_webhook(1)
|
||||
|
||||
# Should not raise on success
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_webhook_async(self, client):
|
||||
"""Test async variant of delete_webhook."""
|
||||
# Patch _fetch instead of _fetch_async since _fetch_async calls _fetch
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.return_value = None
|
||||
|
||||
result = await client.delete_webhook_async(2)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestWebhookErrors:
|
||||
"""Tests for webhook error handling."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_invalid_webhook_id(self, client):
|
||||
"""Test getting webhook with invalid ID."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.side_effect = Exception("404 Not Found")
|
||||
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
client.get_webhook(0)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_webhook_invalid_url(self, client):
|
||||
"""Test creating webhook with invalid URL."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.side_effect = Exception("400 Invalid URL")
|
||||
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
client.create_webhook(url="not-a-url")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_nonexistent_webhook(self, client):
|
||||
"""Test deleting webhook that doesn't exist."""
|
||||
with patch.object(client, "_fetch") as mock_fetch:
|
||||
mock_fetch.side_effect = Exception("404 Not Found")
|
||||
|
||||
with pytest.raises(Exception): # noqa: B017
|
||||
client.delete_webhook(999)
|
||||
@@ -1,733 +0,0 @@
|
||||
"""
|
||||
GitLab Client Tests
|
||||
===================
|
||||
|
||||
Tests for GitLab client timeout, retry, and async operations.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from requests.exceptions import ConnectionError, Timeout
|
||||
|
||||
|
||||
class TestGitLabClient:
|
||||
"""Test GitLab client basic operations."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
"""Create a GitLab client for testing."""
|
||||
from __tests__.fixtures.gitlab import create_mock_client
|
||||
|
||||
return create_mock_client()
|
||||
|
||||
def test_client_initialization(self, client):
|
||||
"""Test client initializes correctly."""
|
||||
assert client.config.token == "glpat-test-token-12345"
|
||||
assert client.config.project == "group/project"
|
||||
assert client.config.instance_url == "https://gitlab.example.com"
|
||||
assert client.default_timeout == 30.0
|
||||
|
||||
def test_client_custom_timeout(self):
|
||||
"""Test client with custom timeout."""
|
||||
from __tests__.fixtures.gitlab import create_mock_client
|
||||
|
||||
client = create_mock_client()
|
||||
assert client.default_timeout == 30.0 # Uses default
|
||||
|
||||
def test_client_custom_retries(self):
|
||||
"""Test client with custom retry count."""
|
||||
from __tests__.fixtures.gitlab import create_mock_client
|
||||
|
||||
client = create_mock_client()
|
||||
# Uses default max_retries of 3
|
||||
assert client.default_timeout == 30.0
|
||||
|
||||
def test_build_url(self, client):
|
||||
"""Test URL building."""
|
||||
url = client._api_url("/projects/group%2Fproject/merge_requests")
|
||||
|
||||
assert "group%2Fproject" in url
|
||||
assert "merge_requests" in url
|
||||
assert "/api/v4/" in url
|
||||
|
||||
def test_build_url_with_params(self, client):
|
||||
"""Test URL building with query parameters."""
|
||||
from urllib.parse import parse_qs, urlencode, urlparse
|
||||
|
||||
base_url = client._api_url("/projects/group%2Fproject/merge_requests")
|
||||
query_string = urlencode({"state": "opened", "per_page": 50}, doseq=True)
|
||||
full_url = f"{base_url}?{query_string}"
|
||||
|
||||
parsed = urlparse(full_url)
|
||||
params = parse_qs(parsed.query)
|
||||
|
||||
assert "state=opened" in full_url or params.get("state") == ["opened"]
|
||||
assert "per_page=50" in full_url or params.get("per_page") == ["50"]
|
||||
|
||||
|
||||
class TestGitLabClientRetry:
|
||||
"""Test GitLab client retry logic."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
"""Create a GitLab client for testing."""
|
||||
import dataclasses
|
||||
|
||||
from __tests__.fixtures.gitlab import create_mock_client
|
||||
|
||||
client = create_mock_client()
|
||||
return client
|
||||
|
||||
def test_retry_on_timeout(self, client):
|
||||
"""Test retry on timeout exception."""
|
||||
from socket import timeout
|
||||
|
||||
call_count = 0
|
||||
|
||||
def mock_urlopen_side_effect(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 3:
|
||||
raise TimeoutError("Request timed out")
|
||||
# Return successful response
|
||||
mock_resp = Mock()
|
||||
mock_resp.read.return_value = b'{"iid": 123}'
|
||||
mock_resp.headers = {"Content-Type": "application/json"}
|
||||
mock_resp.status = 200
|
||||
mock_resp.__enter__ = Mock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = Mock(return_value=False)
|
||||
return mock_resp
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=mock_urlopen_side_effect):
|
||||
result = client.get_mr(123)
|
||||
|
||||
assert call_count == 3 # Initial + 2 retries
|
||||
assert result["iid"] == 123
|
||||
|
||||
def test_retry_on_connection_error(self, client):
|
||||
"""Test retry on connection error."""
|
||||
from urllib.error import URLError
|
||||
|
||||
call_count = 0
|
||||
|
||||
def mock_urlopen_side_effect(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 2:
|
||||
raise URLError("Connection failed")
|
||||
# Return successful response
|
||||
mock_resp = Mock()
|
||||
mock_resp.read.return_value = b'{"iid": 123}'
|
||||
mock_resp.headers = {"Content-Type": "application/json"}
|
||||
mock_resp.status = 200
|
||||
mock_resp.__enter__ = Mock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = Mock(return_value=False)
|
||||
return mock_resp
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=mock_urlopen_side_effect):
|
||||
result = client.get_mr(123)
|
||||
|
||||
assert call_count == 2 # Initial + 1 retry
|
||||
assert result["iid"] == 123
|
||||
|
||||
def test_retry_exhausted(self, client):
|
||||
"""Test failure after retry exhaustion."""
|
||||
from urllib.error import URLError
|
||||
|
||||
def mock_urlopen_side_effect(*args, **kwargs):
|
||||
raise URLError("Request timed out")
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=mock_urlopen_side_effect):
|
||||
with pytest.raises(Exception, match="GitLab API network error"):
|
||||
client.get_mr(123)
|
||||
|
||||
def test_retry_with_backoff(self, client):
|
||||
"""Test retry uses exponential backoff."""
|
||||
import time
|
||||
from socket import timeout
|
||||
|
||||
call_times = []
|
||||
|
||||
def mock_urlopen_side_effect(*args, **kwargs):
|
||||
call_times.append(time.time())
|
||||
if len(call_times) < 3:
|
||||
raise TimeoutError("Request timed out")
|
||||
# Return successful response
|
||||
mock_resp = Mock()
|
||||
mock_resp.read.return_value = b'{"iid": 123}'
|
||||
mock_resp.headers = {"Content-Type": "application/json"}
|
||||
mock_resp.status = 200
|
||||
mock_resp.__enter__ = Mock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = Mock(return_value=False)
|
||||
return mock_resp
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=mock_urlopen_side_effect):
|
||||
client.get_mr(123)
|
||||
|
||||
# Check delays between retries increase (exponential backoff)
|
||||
if len(call_times) > 2:
|
||||
delay1 = call_times[1] - call_times[0]
|
||||
delay2 = call_times[2] - call_times[1]
|
||||
# Second delay should be longer (exponential backoff)
|
||||
assert delay2 > delay1
|
||||
|
||||
def test_no_retry_on_client_error(self, client):
|
||||
"""Test no retry on 4xx client errors."""
|
||||
from urllib.error import HTTPError
|
||||
|
||||
call_count = 0
|
||||
|
||||
def mock_urlopen_side_effect(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
# 404 should not be retried (not in RETRYABLE_STATUS_CODES)
|
||||
raise HTTPError("url", 404, "Not Found", {}, None)
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=mock_urlopen_side_effect):
|
||||
with pytest.raises(Exception, match="GitLab API error"):
|
||||
client.get_mr(123)
|
||||
|
||||
# Should only be called once (no retry for 4xx)
|
||||
assert call_count == 1
|
||||
|
||||
def test_retry_on_server_error(self, client):
|
||||
"""Test retry on 5xx server errors."""
|
||||
from urllib.error import HTTPError
|
||||
|
||||
call_count = 0
|
||||
|
||||
def mock_urlopen_side_effect(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count < 2:
|
||||
raise HTTPError(None, 503, "Service Unavailable", {}, None)
|
||||
# Return successful response
|
||||
mock_resp = Mock()
|
||||
mock_resp.read.return_value = b'{"iid": 123}'
|
||||
mock_resp.headers = {"Content-Type": "application/json"}
|
||||
mock_resp.status = 200
|
||||
mock_resp.__enter__ = Mock(return_value=mock_resp)
|
||||
mock_resp.__exit__ = Mock(return_value=False)
|
||||
return mock_resp
|
||||
|
||||
with patch("urllib.request.urlopen", side_effect=mock_urlopen_side_effect):
|
||||
result = client.get_mr(123)
|
||||
|
||||
assert call_count == 2
|
||||
assert result["iid"] == 123
|
||||
|
||||
|
||||
class TestGitLabClientAsync:
|
||||
"""Test GitLab client async operations."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
"""Create a GitLab client for testing."""
|
||||
from __tests__.fixtures.gitlab import create_mock_client
|
||||
|
||||
return create_mock_client()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_mr_async(self, client):
|
||||
"""Test async get MR."""
|
||||
mock_data = {
|
||||
"iid": 123,
|
||||
"title": "Test MR",
|
||||
"state": "opened",
|
||||
}
|
||||
|
||||
with patch.object(client, "_fetch_async", return_value=mock_data):
|
||||
result = await client.get_mr_async(123)
|
||||
|
||||
assert result["iid"] == 123
|
||||
assert result["title"] == "Test MR"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_mr_changes_async(self, client):
|
||||
"""Test async get MR changes."""
|
||||
mock_data = {
|
||||
"changes": [
|
||||
{
|
||||
"old_path": "file.py",
|
||||
"new_path": "file.py",
|
||||
"diff": "@@ -1,1 +1,2 @@",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with patch.object(client, "_fetch_async", return_value=mock_data):
|
||||
result = await client.get_mr_changes_async(123)
|
||||
|
||||
assert len(result["changes"]) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_mr_commits_async(self, client):
|
||||
"""Test async get MR commits."""
|
||||
mock_data = [
|
||||
{"id": "abc123", "message": "Commit 1"},
|
||||
{"id": "def456", "message": "Commit 2"},
|
||||
]
|
||||
|
||||
with patch.object(client, "_fetch_async", return_value=mock_data):
|
||||
result = await client.get_mr_commits_async(123)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["id"] == "abc123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_mr_notes_async(self, client):
|
||||
"""Test async get MR notes."""
|
||||
mock_data = [
|
||||
{"id": 1001, "body": "Comment 1"},
|
||||
{"id": 1002, "body": "Comment 2"},
|
||||
]
|
||||
|
||||
with patch.object(client, "_fetch_async", return_value=mock_data):
|
||||
result = await client.get_mr_notes_async(123)
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_mr_pipelines_async(self, client):
|
||||
"""Test async get MR pipelines."""
|
||||
mock_data = [
|
||||
{"id": 1001, "status": "success"},
|
||||
{"id": 1002, "status": "failed"},
|
||||
]
|
||||
|
||||
with patch.object(client, "_fetch_async", return_value=mock_data):
|
||||
result = await client.get_mr_pipelines_async(123)
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_issue_async(self, client):
|
||||
"""Test async get issue."""
|
||||
mock_data = {
|
||||
"iid": 456,
|
||||
"title": "Test Issue",
|
||||
"state": "opened",
|
||||
}
|
||||
|
||||
with patch.object(client, "_fetch_async", return_value=mock_data):
|
||||
result = await client.get_issue_async(456)
|
||||
|
||||
assert result["iid"] == 456
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pipeline_async(self, client):
|
||||
"""Test async get pipeline."""
|
||||
mock_data = {
|
||||
"id": 1001,
|
||||
"status": "running",
|
||||
"ref": "main",
|
||||
}
|
||||
|
||||
with patch.object(client, "_fetch_async", return_value=mock_data):
|
||||
result = await client.get_pipeline_status_async(1001)
|
||||
|
||||
assert result["id"] == 1001
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_pipeline_jobs_async(self, client):
|
||||
"""Test async get pipeline jobs."""
|
||||
mock_data = [
|
||||
{"id": 2001, "name": "test", "status": "success"},
|
||||
{"id": 2002, "name": "build", "status": "failed"},
|
||||
]
|
||||
|
||||
with patch.object(client, "_fetch_async", return_value=mock_data):
|
||||
result = await client.get_pipeline_jobs_async(1001)
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_async_requests(self, client):
|
||||
"""Test concurrent async requests."""
|
||||
|
||||
async def fetch_mr(iid):
|
||||
return await client.get_mr_async(iid)
|
||||
|
||||
mock_data = {
|
||||
"iid": 123,
|
||||
"title": "Test MR",
|
||||
}
|
||||
|
||||
with patch.object(client, "_fetch_async", return_value=mock_data):
|
||||
results = await asyncio.gather(
|
||||
fetch_mr(123),
|
||||
fetch_mr(456),
|
||||
fetch_mr(789),
|
||||
)
|
||||
|
||||
assert len(results) == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_error_handling(self, client):
|
||||
"""Test async error handling."""
|
||||
with patch.object(client, "_fetch_async", side_effect=Exception("API Error")):
|
||||
with pytest.raises(Exception, match="API Error"):
|
||||
await client.get_mr_async(123)
|
||||
|
||||
|
||||
class TestGitLabClientAPI:
|
||||
"""Test GitLab client API methods."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
"""Create a GitLab client for testing."""
|
||||
from __tests__.fixtures.gitlab import create_mock_client
|
||||
|
||||
return create_mock_client()
|
||||
|
||||
def test_get_mr(self, client):
|
||||
"""Test getting MR details."""
|
||||
mock_response = {
|
||||
"iid": 123,
|
||||
"title": "Test MR",
|
||||
"description": "Test description",
|
||||
"state": "opened",
|
||||
"author": {"username": "john_doe"},
|
||||
}
|
||||
|
||||
with patch.object(client, "_fetch", return_value=mock_response):
|
||||
result = client.get_mr(123)
|
||||
|
||||
assert result["iid"] == 123
|
||||
assert result["title"] == "Test MR"
|
||||
|
||||
def test_get_mr_changes(self, client):
|
||||
"""Test getting MR changes."""
|
||||
mock_response = {
|
||||
"changes": [
|
||||
{
|
||||
"old_path": "src/file.py",
|
||||
"new_path": "src/file.py",
|
||||
"diff": "@@ -1,1 +1,2 @@",
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with patch.object(client, "_fetch", return_value=mock_response):
|
||||
result = client.get_mr_changes(123)
|
||||
|
||||
assert len(result["changes"]) == 1
|
||||
|
||||
def test_get_mr_commits(self, client):
|
||||
"""Test getting MR commits."""
|
||||
mock_response = [
|
||||
{"id": "abc123", "message": "First commit"},
|
||||
{"id": "def456", "message": "Second commit"},
|
||||
]
|
||||
|
||||
with patch.object(client, "_fetch", return_value=mock_response):
|
||||
result = client.get_mr_commits(123)
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
def test_get_mr_notes(self, client):
|
||||
"""Test getting MR discussion notes."""
|
||||
mock_response = [
|
||||
{"id": 1001, "body": "Review comment", "author": {"username": "reviewer"}},
|
||||
]
|
||||
|
||||
with patch.object(client, "_fetch", return_value=mock_response):
|
||||
result = client.get_mr_notes(123)
|
||||
|
||||
assert len(result) == 1
|
||||
|
||||
def test_post_mr_note(self, client):
|
||||
"""Test posting note to MR."""
|
||||
mock_response = {"id": 1002, "body": "New comment"}
|
||||
|
||||
with patch.object(client, "_fetch", return_value=mock_response):
|
||||
result = client.post_mr_note(123, "New comment")
|
||||
|
||||
assert result["id"] == 1002
|
||||
|
||||
def test_get_mr_pipelines(self, client):
|
||||
"""Test getting MR pipelines."""
|
||||
mock_response = [
|
||||
{"id": 1001, "status": "success", "ref": "feature"},
|
||||
]
|
||||
|
||||
with patch.object(client, "_fetch", return_value=mock_response):
|
||||
result = client.get_mr_pipelines(123)
|
||||
|
||||
assert len(result) == 1
|
||||
|
||||
def test_get_pipeline(self, client):
|
||||
"""Test getting pipeline details."""
|
||||
mock_response = {
|
||||
"id": 1001,
|
||||
"status": "success",
|
||||
"ref": "main",
|
||||
"sha": "abc123",
|
||||
}
|
||||
|
||||
with patch.object(client, "_fetch", return_value=mock_response):
|
||||
result = client.get_pipeline_status(1001)
|
||||
|
||||
assert result["id"] == 1001
|
||||
|
||||
def test_get_pipeline_jobs(self, client):
|
||||
"""Test getting pipeline jobs."""
|
||||
mock_response = [
|
||||
{"id": 2001, "name": "test", "stage": "test", "status": "passed"},
|
||||
{"id": 2002, "name": "build", "stage": "build", "status": "failed"},
|
||||
]
|
||||
|
||||
with patch.object(client, "_fetch", return_value=mock_response):
|
||||
result = client.get_pipeline_jobs(1001)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[1]["status"] == "failed"
|
||||
|
||||
def test_get_issue(self, client):
|
||||
"""Test getting issue details."""
|
||||
mock_response = {
|
||||
"iid": 456,
|
||||
"title": "Test Issue",
|
||||
"description": "Issue description",
|
||||
"state": "opened",
|
||||
}
|
||||
|
||||
with patch.object(client, "_fetch", return_value=mock_response):
|
||||
result = client.get_issue(456)
|
||||
|
||||
assert result["iid"] == 456
|
||||
|
||||
def test_list_issues(self, client):
|
||||
"""Test listing issues."""
|
||||
mock_response = [
|
||||
{"iid": 456, "title": "Issue 1"},
|
||||
{"iid": 457, "title": "Issue 2"},
|
||||
]
|
||||
|
||||
with patch.object(client, "_fetch", return_value=mock_response):
|
||||
result = client.list_issues(state="opened")
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
def test_post_issue_note(self, client):
|
||||
"""Test posting note to issue."""
|
||||
mock_response = {"id": 2001, "body": "Issue comment"}
|
||||
|
||||
with patch.object(client, "_fetch", return_value=mock_response):
|
||||
result = client.post_issue_note(456, "Issue comment")
|
||||
|
||||
assert result["id"] == 2001
|
||||
|
||||
def test_get_file(self, client):
|
||||
"""Test getting file from repository."""
|
||||
mock_response = {
|
||||
"file_name": "README.md",
|
||||
"content": "SGVsbG8gV29ybGQ=", # Base64 encoded
|
||||
"encoding": "base64",
|
||||
}
|
||||
|
||||
with patch.object(client, "_fetch", return_value=mock_response):
|
||||
result = client.get_file_contents("README.md", ref="main")
|
||||
|
||||
assert result["file_name"] == "README.md"
|
||||
|
||||
def test_list_projects(self, client):
|
||||
"""Test listing projects - removed in new API."""
|
||||
# This method was removed from the new GitLabClient API
|
||||
# Projects are now specified via the config
|
||||
assert client.config.project is not None
|
||||
|
||||
|
||||
class TestGitLabClientAuth:
|
||||
"""Test GitLab client authentication."""
|
||||
|
||||
def test_token_in_headers(self):
|
||||
"""Test token is included in request headers."""
|
||||
import dataclasses
|
||||
|
||||
from __tests__.fixtures.gitlab import create_mock_client
|
||||
|
||||
client = create_mock_client()
|
||||
client.config = dataclasses.replace(client.config, token="test-token-12345")
|
||||
|
||||
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||
# Mock response object with proper attributes
|
||||
mock_response = Mock()
|
||||
mock_response.read.return_value = b'{"iid": 123}'
|
||||
mock_response.headers = {"Content-Type": "application/json"}
|
||||
mock_response.status = 200
|
||||
mock_response.__enter__ = Mock(return_value=mock_response)
|
||||
mock_response.__exit__ = Mock(return_value=False)
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
client.get_mr(123)
|
||||
|
||||
# Check that urlopen was called
|
||||
assert mock_urlopen.called
|
||||
|
||||
# Get the request object that was passed to urlopen
|
||||
call_args = mock_urlopen.call_args[0]
|
||||
request = call_args[0]
|
||||
|
||||
# Check the PRIVATE-TOKEN header (case-insensitive check)
|
||||
assert (
|
||||
"PRIVATE-TOKEN" in request.headers or "Private-token" in request.headers
|
||||
)
|
||||
# Use get() with case-insensitive fallback
|
||||
token_value = request.headers.get(
|
||||
"PRIVATE-TOKEN", request.headers.get("Private-token")
|
||||
)
|
||||
assert token_value == "test-token-12345"
|
||||
|
||||
def test_custom_instance_url(self):
|
||||
"""Test custom instance URL."""
|
||||
import dataclasses
|
||||
|
||||
from __tests__.fixtures.gitlab import create_mock_client
|
||||
|
||||
client = create_mock_client()
|
||||
client.config = dataclasses.replace(
|
||||
client.config, instance_url="https://gitlab.custom.com"
|
||||
)
|
||||
|
||||
with patch("urllib.request.urlopen") as mock_urlopen:
|
||||
# Mock response object with proper attributes
|
||||
mock_response = Mock()
|
||||
mock_response.read.return_value = b'{"iid": 123}'
|
||||
mock_response.headers = {"Content-Type": "application/json"}
|
||||
mock_response.status = 200
|
||||
mock_response.__enter__ = Mock(return_value=mock_response)
|
||||
mock_response.__exit__ = Mock(return_value=False)
|
||||
mock_urlopen.return_value = mock_response
|
||||
|
||||
client.get_mr(123)
|
||||
|
||||
# Check that urlopen was called with correct URL
|
||||
call_args = mock_urlopen.call_args[0]
|
||||
request = call_args[0]
|
||||
|
||||
assert "gitlab.custom.com" in request.full_url
|
||||
|
||||
|
||||
class TestGitLabClientConfig:
|
||||
"""Test GitLab configuration model."""
|
||||
|
||||
def test_config_creation(self):
|
||||
"""Test creating GitLab config."""
|
||||
from runners.gitlab.glab_client import GitLabConfig
|
||||
|
||||
config = GitLabConfig(
|
||||
token="test-token",
|
||||
project="group/project",
|
||||
instance_url="https://gitlab.example.com",
|
||||
)
|
||||
|
||||
assert config.token == "test-token"
|
||||
assert config.project == "group/project"
|
||||
|
||||
def test_config_defaults(self):
|
||||
"""Test config has sensible defaults."""
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from runners.gitlab.glab_client import GitLabClient, GitLabConfig
|
||||
|
||||
project_dir = Path(tempfile.mkdtemp())
|
||||
config = GitLabConfig(
|
||||
token="test-token",
|
||||
project="group/project",
|
||||
instance_url="https://gitlab.com",
|
||||
)
|
||||
|
||||
client = GitLabClient(project_dir=project_dir, config=config)
|
||||
|
||||
assert client.config.instance_url == "https://gitlab.com"
|
||||
assert client.default_timeout == 30.0
|
||||
|
||||
def test_config_to_dict(self):
|
||||
"""Test converting config to dict using dataclasses."""
|
||||
import dataclasses
|
||||
|
||||
from runners.gitlab.glab_client import GitLabConfig
|
||||
|
||||
config = GitLabConfig(
|
||||
token="test-token",
|
||||
project="group/project",
|
||||
instance_url="https://gitlab.com",
|
||||
)
|
||||
|
||||
data = dataclasses.asdict(config)
|
||||
|
||||
assert data["token"] == "test-token"
|
||||
assert data["project"] == "group/project"
|
||||
|
||||
def test_config_from_dict(self):
|
||||
"""Test loading config from dict using dataclasses."""
|
||||
import dataclasses
|
||||
|
||||
from runners.gitlab.glab_client import GitLabConfig
|
||||
|
||||
data = {
|
||||
"token": "test-token",
|
||||
"project": "group/project",
|
||||
"instance_url": "https://gitlab.example.com",
|
||||
}
|
||||
|
||||
config = GitLabConfig(**data)
|
||||
|
||||
assert config.token == "test-token"
|
||||
assert config.instance_url == "https://gitlab.example.com"
|
||||
|
||||
|
||||
class TestGitLabClientErrorHandling:
|
||||
"""Test GitLab client error handling."""
|
||||
|
||||
@pytest.fixture
|
||||
def client(self):
|
||||
"""Create a GitLab client for testing."""
|
||||
from __tests__.fixtures.gitlab import create_mock_client
|
||||
|
||||
return create_mock_client()
|
||||
|
||||
def test_http_404_handling(self, client):
|
||||
"""Test 404 error handling."""
|
||||
from urllib.error import HTTPError
|
||||
|
||||
def mock_request(*args, **kwargs):
|
||||
raise HTTPError(None, 404, "404 Not Found", {}, None)
|
||||
|
||||
with patch.object(client, "_fetch", mock_request):
|
||||
with pytest.raises(HTTPError):
|
||||
client.get_mr(99999)
|
||||
|
||||
def test_http_403_handling(self, client):
|
||||
"""Test 403 forbidden error handling."""
|
||||
from urllib.error import HTTPError
|
||||
|
||||
def mock_request(*args, **kwargs):
|
||||
raise HTTPError(None, 403, "403 Forbidden", {}, None)
|
||||
|
||||
with patch.object(client, "_fetch", mock_request):
|
||||
with pytest.raises(HTTPError):
|
||||
client.get_mr(123)
|
||||
|
||||
def test_network_error_handling(self, client):
|
||||
"""Test network error handling."""
|
||||
with patch.object(
|
||||
client, "_fetch", side_effect=ConnectionError("Network error")
|
||||
):
|
||||
with pytest.raises(ConnectionError):
|
||||
client.get_mr(123)
|
||||
|
||||
def test_timeout_handling(self, client):
|
||||
"""Test timeout handling."""
|
||||
from socket import timeout
|
||||
|
||||
with patch.object(
|
||||
client, "_fetch", side_effect=TimeoutError("Request timed out")
|
||||
):
|
||||
with pytest.raises(timeout):
|
||||
client.get_mr(123)
|
||||
@@ -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]
|
||||
|
||||
+10
-127
@@ -7,7 +7,6 @@ Main autonomous agent loop that runs the coder agent to implement subtasks.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from core.client import create_client
|
||||
@@ -38,7 +37,6 @@ from prompt_generator import (
|
||||
)
|
||||
from prompts import is_first_run
|
||||
from recovery import RecoveryManager
|
||||
from security.constants import PROJECT_DIR_ENV_VAR
|
||||
from task_logger import (
|
||||
LogPhase,
|
||||
get_task_logger,
|
||||
@@ -64,7 +62,7 @@ from .utils import (
|
||||
get_commit_count,
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_spec_to_source,
|
||||
sync_plan_to_source,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -92,10 +90,6 @@ async def run_autonomous_agent(
|
||||
verbose: Whether to show detailed output
|
||||
source_spec_dir: Original spec directory in main project (for syncing from worktree)
|
||||
"""
|
||||
# Set environment variable for security hooks to find the correct project directory
|
||||
# This is needed because os.getcwd() may return the wrong directory in worktree mode
|
||||
os.environ[PROJECT_DIR_ENV_VAR] = str(project_dir.resolve())
|
||||
|
||||
# Initialize recovery manager (handles memory persistence)
|
||||
recovery_manager = RecoveryManager(spec_dir, project_dir)
|
||||
|
||||
@@ -136,25 +130,6 @@ async def run_autonomous_agent(
|
||||
# Track which phase we're in for logging
|
||||
current_log_phase = LogPhase.CODING
|
||||
is_planning_phase = False
|
||||
planning_retry_context: str | None = None
|
||||
planning_validation_failures = 0
|
||||
max_planning_validation_retries = 3
|
||||
|
||||
def _validate_and_fix_implementation_plan() -> tuple[bool, list[str]]:
|
||||
from spec.validate_pkg import SpecValidator, auto_fix_plan
|
||||
|
||||
spec_validator = SpecValidator(spec_dir)
|
||||
result = spec_validator.validate_implementation_plan()
|
||||
if result.valid:
|
||||
return True, []
|
||||
|
||||
fixed = auto_fix_plan(spec_dir)
|
||||
if fixed:
|
||||
result = spec_validator.validate_implementation_plan()
|
||||
if result.valid:
|
||||
return True, []
|
||||
|
||||
return False, result.errors
|
||||
|
||||
if first_run:
|
||||
print_status(
|
||||
@@ -226,7 +201,7 @@ async def run_autonomous_agent(
|
||||
print(" PAUSED BY HUMAN")
|
||||
print("=" * 70)
|
||||
|
||||
pause_content = pause_file.read_text(encoding="utf-8").strip()
|
||||
pause_content = pause_file.read_text().strip()
|
||||
if pause_content:
|
||||
print(f"\nMessage: {pause_content}")
|
||||
|
||||
@@ -242,8 +217,8 @@ async def run_autonomous_agent(
|
||||
print("To continue, run the script again without --max-iterations")
|
||||
break
|
||||
|
||||
# Get the next subtask to work on (planner sessions shouldn't bind to a subtask)
|
||||
next_subtask = None if first_run else get_next_subtask(spec_dir)
|
||||
# Get the next subtask to work on
|
||||
next_subtask = get_next_subtask(spec_dir)
|
||||
subtask_id = next_subtask.get("id") if next_subtask else None
|
||||
phase_name = next_subtask.get("phase_name") if next_subtask else None
|
||||
|
||||
@@ -282,35 +257,16 @@ async def run_autonomous_agent(
|
||||
phase_thinking_budget = get_phase_thinking_budget(spec_dir, current_phase)
|
||||
|
||||
# Create client (fresh context) with phase-specific model and thinking
|
||||
# Use appropriate agent_type for correct tool permissions and thinking budget
|
||||
client = create_client(
|
||||
project_dir,
|
||||
spec_dir,
|
||||
phase_model,
|
||||
agent_type="planner" if first_run else "coder",
|
||||
max_thinking_tokens=phase_thinking_budget,
|
||||
)
|
||||
|
||||
# Generate appropriate prompt
|
||||
if first_run:
|
||||
prompt = generate_planner_prompt(spec_dir, project_dir)
|
||||
if planning_retry_context:
|
||||
prompt += "\n\n" + planning_retry_context
|
||||
|
||||
# Retrieve Graphiti memory context for planning phase
|
||||
# This gives the planner knowledge of previous patterns, gotchas, and insights
|
||||
planner_context = await get_graphiti_context(
|
||||
spec_dir,
|
||||
project_dir,
|
||||
{
|
||||
"description": "Planning implementation for new feature",
|
||||
"id": "planner",
|
||||
},
|
||||
)
|
||||
if planner_context:
|
||||
prompt += "\n\n" + planner_context
|
||||
print_status("Graphiti memory context loaded for planner", "success")
|
||||
|
||||
first_run = False
|
||||
current_log_phase = LogPhase.PLANNING
|
||||
|
||||
@@ -319,9 +275,7 @@ async def run_autonomous_agent(
|
||||
task_logger.set_session(iteration)
|
||||
else:
|
||||
# Switch to coding phase after planning
|
||||
just_transitioned_from_planning = False
|
||||
if is_planning_phase:
|
||||
just_transitioned_from_planning = True
|
||||
is_planning_phase = False
|
||||
current_log_phase = LogPhase.CODING
|
||||
emit_phase(ExecutionPhase.CODING, "Starting implementation")
|
||||
@@ -334,41 +288,10 @@ async def run_autonomous_agent(
|
||||
task_logger.start_phase(
|
||||
LogPhase.CODING, "Starting implementation..."
|
||||
)
|
||||
# In worktree mode, the UI prefers planning logs from the main spec dir.
|
||||
# Ensure the planning->coding transition is immediately reflected there.
|
||||
if sync_spec_to_source(spec_dir, source_spec_dir):
|
||||
print_status("Phase transition synced to main project", "success")
|
||||
|
||||
if not next_subtask:
|
||||
# FIX for Issue #495: Race condition after planning phase
|
||||
# The implementation_plan.json may not be fully flushed to disk yet,
|
||||
# or there may be a brief delay before subtasks become available.
|
||||
# Retry with exponential backoff before giving up.
|
||||
if just_transitioned_from_planning:
|
||||
print_status(
|
||||
"Waiting for implementation plan to be ready...", "progress"
|
||||
)
|
||||
for retry_attempt in range(3):
|
||||
delay = (retry_attempt + 1) * 2 # 2s, 4s, 6s
|
||||
await asyncio.sleep(delay)
|
||||
next_subtask = get_next_subtask(spec_dir)
|
||||
if next_subtask:
|
||||
# Update subtask_id and phase_name after successful retry
|
||||
subtask_id = next_subtask.get("id")
|
||||
phase_name = next_subtask.get("phase_name")
|
||||
print_status(
|
||||
f"Found subtask {subtask_id} after {delay}s delay",
|
||||
"success",
|
||||
)
|
||||
break
|
||||
print_status(
|
||||
f"Retry {retry_attempt + 1}/3: No subtask found yet...",
|
||||
"warning",
|
||||
)
|
||||
|
||||
if not next_subtask:
|
||||
print("No pending subtasks found - build may be complete!")
|
||||
break
|
||||
print("No pending subtasks found - build may be complete!")
|
||||
break
|
||||
|
||||
# Get attempt count for recovery context
|
||||
attempt_count = recovery_manager.get_attempt_count(subtask_id)
|
||||
@@ -423,46 +346,8 @@ async def run_autonomous_agent(
|
||||
client, prompt, spec_dir, verbose, phase=current_log_phase
|
||||
)
|
||||
|
||||
plan_validated = False
|
||||
if is_planning_phase and status != "error":
|
||||
valid, errors = _validate_and_fix_implementation_plan()
|
||||
if valid:
|
||||
plan_validated = True
|
||||
planning_retry_context = None
|
||||
else:
|
||||
planning_validation_failures += 1
|
||||
if planning_validation_failures >= max_planning_validation_retries:
|
||||
print_status(
|
||||
"implementation_plan.json validation failed too many times",
|
||||
"error",
|
||||
)
|
||||
for err in errors:
|
||||
print(f" - {err}")
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
return
|
||||
|
||||
print_status(
|
||||
"implementation_plan.json invalid - retrying planner", "warning"
|
||||
)
|
||||
for err in errors:
|
||||
print(f" - {err}")
|
||||
|
||||
planning_retry_context = (
|
||||
"## IMPLEMENTATION PLAN VALIDATION ERRORS\n\n"
|
||||
"The previous `implementation_plan.json` is INVALID.\n"
|
||||
"You MUST rewrite it to match the required schema:\n"
|
||||
"- Top-level: `feature`, `workflow_type`, `phases`\n"
|
||||
"- Each phase: `id` (or `phase`) and `name`, and `subtasks`\n"
|
||||
"- Each subtask: `id`, `description`, `status` (use `pending` for not started)\n\n"
|
||||
"Validation errors:\n" + "\n".join(f"- {e}" for e in errors)
|
||||
)
|
||||
# Stay in planning mode for the next iteration
|
||||
first_run = True
|
||||
status = "continue"
|
||||
|
||||
# === POST-SESSION PROCESSING (100% reliable) ===
|
||||
# Only run post-session processing for coding sessions.
|
||||
if subtask_id and current_log_phase == LogPhase.CODING:
|
||||
if subtask_id and not first_run:
|
||||
linear_is_enabled = (
|
||||
linear_task is not None and linear_task.task_id is not None
|
||||
)
|
||||
@@ -500,9 +385,9 @@ async def run_autonomous_agent(
|
||||
attempt_count=attempt_count,
|
||||
)
|
||||
print_status("Linear notified of stuck subtask", "info")
|
||||
elif plan_validated and source_spec_dir:
|
||||
elif is_planning_phase and source_spec_dir:
|
||||
# After planning phase, sync the newly created implementation plan back to source
|
||||
if sync_spec_to_source(spec_dir, source_spec_dir):
|
||||
if sync_plan_to_source(spec_dir, source_spec_dir):
|
||||
print_status("Implementation plan synced to main project", "success")
|
||||
|
||||
# Handle session status
|
||||
@@ -534,9 +419,7 @@ async def run_autonomous_agent(
|
||||
print_progress_summary(spec_dir)
|
||||
|
||||
# Update state back to building
|
||||
status_manager.update(
|
||||
state=BuildState.PLANNING if is_planning_phase else BuildState.BUILDING
|
||||
)
|
||||
status_manager.update(state=BuildState.BUILDING)
|
||||
|
||||
# Show next subtask info
|
||||
next_subtask = get_next_subtask(spec_dir)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ async def run_followup_planner(
|
||||
if pending_subtasks:
|
||||
# Reset the plan status to in_progress (in case planner didn't)
|
||||
plan.reset_for_followup()
|
||||
await plan.async_save(plan_file)
|
||||
plan.save(plan_file)
|
||||
|
||||
print()
|
||||
content = [
|
||||
|
||||
@@ -21,7 +21,6 @@ from progress import (
|
||||
is_build_complete,
|
||||
)
|
||||
from recovery import RecoveryManager
|
||||
from security.tool_input_validator import get_safe_tool_input
|
||||
from task_logger import (
|
||||
LogEntryType,
|
||||
LogPhase,
|
||||
@@ -40,7 +39,7 @@ from .utils import (
|
||||
get_commit_count,
|
||||
get_latest_commit,
|
||||
load_implementation_plan,
|
||||
sync_spec_to_source,
|
||||
sync_plan_to_source,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -82,7 +81,7 @@ async def post_session_processing(
|
||||
print(muted("--- Post-Session Processing ---"))
|
||||
|
||||
# Sync implementation plan back to source (for worktree mode)
|
||||
if sync_spec_to_source(spec_dir, source_spec_dir):
|
||||
if sync_plan_to_source(spec_dir, source_spec_dir):
|
||||
print_status("Implementation plan synced to main project", "success")
|
||||
|
||||
# Check if implementation plan was updated
|
||||
@@ -387,43 +386,41 @@ async def run_agent_session(
|
||||
)
|
||||
elif block_type == "ToolUseBlock" and hasattr(block, "name"):
|
||||
tool_name = block.name
|
||||
tool_input_display = None
|
||||
tool_input = None
|
||||
tool_count += 1
|
||||
|
||||
# Safely extract tool input (handles None, non-dict, etc.)
|
||||
inp = get_safe_tool_input(block)
|
||||
|
||||
# Extract meaningful tool input for display
|
||||
if inp:
|
||||
if "pattern" in inp:
|
||||
tool_input_display = f"pattern: {inp['pattern']}"
|
||||
elif "file_path" in inp:
|
||||
fp = inp["file_path"]
|
||||
if len(fp) > 50:
|
||||
fp = "..." + fp[-47:]
|
||||
tool_input_display = fp
|
||||
elif "command" in inp:
|
||||
cmd = inp["command"]
|
||||
if len(cmd) > 50:
|
||||
cmd = cmd[:47] + "..."
|
||||
tool_input_display = cmd
|
||||
elif "path" in inp:
|
||||
tool_input_display = inp["path"]
|
||||
if hasattr(block, "input") and block.input:
|
||||
inp = block.input
|
||||
if isinstance(inp, dict):
|
||||
if "pattern" in inp:
|
||||
tool_input = f"pattern: {inp['pattern']}"
|
||||
elif "file_path" in inp:
|
||||
fp = inp["file_path"]
|
||||
if len(fp) > 50:
|
||||
fp = "..." + fp[-47:]
|
||||
tool_input = fp
|
||||
elif "command" in inp:
|
||||
cmd = inp["command"]
|
||||
if len(cmd) > 50:
|
||||
cmd = cmd[:47] + "..."
|
||||
tool_input = cmd
|
||||
elif "path" in inp:
|
||||
tool_input = inp["path"]
|
||||
|
||||
debug(
|
||||
"session",
|
||||
f"Tool call #{tool_count}: {tool_name}",
|
||||
tool_input=tool_input_display,
|
||||
full_input=str(inp)[:500] if inp else None,
|
||||
tool_input=tool_input,
|
||||
full_input=str(block.input)[:500]
|
||||
if hasattr(block, "input")
|
||||
else None,
|
||||
)
|
||||
|
||||
# Log tool start (handles printing too)
|
||||
if task_logger:
|
||||
task_logger.tool_start(
|
||||
tool_name,
|
||||
tool_input_display,
|
||||
phase,
|
||||
print_to_console=True,
|
||||
tool_name, tool_input, phase, print_to_console=True
|
||||
)
|
||||
else:
|
||||
print(f"\n[Tool: {tool_name}]", flush=True)
|
||||
@@ -445,9 +442,8 @@ async def run_agent_session(
|
||||
result_content = getattr(block, "content", "")
|
||||
is_error = getattr(block, "is_error", False)
|
||||
|
||||
# Check if this is an error (not just content containing "blocked")
|
||||
if is_error and "blocked" in str(result_content).lower():
|
||||
# Actual blocked command by security hook
|
||||
# Check if command was blocked by security hook
|
||||
if "blocked" in str(result_content).lower():
|
||||
debug_error(
|
||||
"session",
|
||||
f"Tool BLOCKED: {current_tool}",
|
||||
|
||||
@@ -216,9 +216,8 @@ AGENT_CONFIGS = {
|
||||
# QA PHASES (Read + test + browser + Graphiti memory)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"qa_reviewer": {
|
||||
# Read + Write/Edit (for QA reports and plan updates) + Bash (for tests)
|
||||
# Note: Reviewer writes to spec directory only (qa_report.md, implementation_plan.json)
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
|
||||
# Read-only + Bash (for running tests) - reviewer should NOT edit code
|
||||
"tools": BASE_READ_TOOLS + ["Bash"] + WEB_TOOLS,
|
||||
"mcp_servers": ["context7", "graphiti", "auto-claude", "browser"],
|
||||
"mcp_servers_optional": ["linear"], # For updating issue status
|
||||
"auto_claude_tools": [
|
||||
@@ -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
|
||||
|
||||
@@ -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,53 +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",
|
||||
}
|
||||
|
||||
# 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()
|
||||
|
||||
return qa_session
|
||||
|
||||
|
||||
def create_qa_tools(spec_dir: Path, project_dir: Path) -> list:
|
||||
"""
|
||||
Create QA management tools.
|
||||
@@ -140,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": [
|
||||
@@ -157,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,7 +87,6 @@ def handle_build_command(
|
||||
debug_success,
|
||||
)
|
||||
from phase_config import get_phase_model
|
||||
from prompts_pkg.prompts import get_base_branch_from_metadata
|
||||
from qa_loop import run_qa_validation_loop, should_run_qa
|
||||
|
||||
from .utils import print_banner, validate_environment
|
||||
@@ -195,14 +194,6 @@ def handle_build_command(
|
||||
auto_continue=auto_continue,
|
||||
)
|
||||
|
||||
# If base_branch not provided via CLI, try to read from task_metadata.json
|
||||
# This ensures the backend uses the branch configured in the frontend
|
||||
if base_branch is None:
|
||||
metadata_branch = get_base_branch_from_metadata(spec_dir)
|
||||
if metadata_branch:
|
||||
base_branch = metadata_branch
|
||||
debug("run.py", f"Using base branch from task metadata: {base_branch}")
|
||||
|
||||
if workspace_mode == WorkspaceMode.ISOLATED:
|
||||
# Keep reference to original spec directory for syncing progress back
|
||||
source_spec_dir = spec_dir
|
||||
@@ -283,7 +274,7 @@ def handle_build_command(
|
||||
|
||||
# Sync implementation plan to main project after QA
|
||||
# This ensures the main project has the latest status (human_review)
|
||||
if sync_spec_to_source(spec_dir, source_spec_dir):
|
||||
if sync_plan_to_source(spec_dir, source_spec_dir):
|
||||
debug_info(
|
||||
"run.py", "Implementation plan synced to main project after QA"
|
||||
)
|
||||
@@ -421,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,9 +352,7 @@ def handle_cleanup_worktrees_command(project_dir: Path) -> None:
|
||||
cleanup_all_worktrees(project_dir, confirm=True)
|
||||
|
||||
|
||||
def _check_git_merge_conflicts(
|
||||
project_dir: Path, spec_name: str, base_branch: str | None = None
|
||||
) -> dict:
|
||||
def _check_git_merge_conflicts(project_dir: Path, spec_name: str) -> dict:
|
||||
"""
|
||||
Check for git-level merge conflicts WITHOUT modifying the working directory.
|
||||
|
||||
@@ -548,7 +362,6 @@ def _check_git_merge_conflicts(
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_name: Name of the spec
|
||||
base_branch: Branch the task was created from (default: auto-detect)
|
||||
|
||||
Returns:
|
||||
Dictionary with git conflict information:
|
||||
@@ -567,25 +380,21 @@ def _check_git_merge_conflicts(
|
||||
"has_conflicts": False,
|
||||
"conflicting_files": [],
|
||||
"needs_rebase": False,
|
||||
"base_branch": base_branch or "main",
|
||||
"base_branch": "main",
|
||||
"spec_branch": spec_branch,
|
||||
"commits_behind": 0,
|
||||
}
|
||||
|
||||
try:
|
||||
# Use provided base_branch, or detect from current HEAD
|
||||
if not base_branch:
|
||||
base_result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if base_result.returncode == 0:
|
||||
result["base_branch"] = base_result.stdout.strip()
|
||||
else:
|
||||
result["base_branch"] = base_branch
|
||||
debug(MODULE, f"Using provided base branch: {base_branch}")
|
||||
# Get the current branch (base branch)
|
||||
base_result = subprocess.run(
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if base_result.returncode == 0:
|
||||
result["base_branch"] = base_result.stdout.strip()
|
||||
|
||||
# Get the merge base commit
|
||||
merge_base_result = subprocess.run(
|
||||
@@ -744,6 +553,7 @@ def handle_merge_preview_command(
|
||||
spec_name=spec_name,
|
||||
)
|
||||
|
||||
from merge import MergeOrchestrator
|
||||
from workspace import get_existing_build_worktree
|
||||
|
||||
worktree_path = get_existing_build_worktree(project_dir, spec_name)
|
||||
@@ -770,32 +580,16 @@ def handle_merge_preview_command(
|
||||
}
|
||||
|
||||
try:
|
||||
# First, check for git-level conflicts (diverged branches)
|
||||
git_conflicts = _check_git_merge_conflicts(project_dir, spec_name)
|
||||
|
||||
# Determine the task's source branch (where the task was created from)
|
||||
# Priority:
|
||||
# 1. Provided base_branch (from task metadata)
|
||||
# 2. Detect from worktree's git history (find which branch it diverged from)
|
||||
# 3. Fall back to default branch detection (main/master)
|
||||
# Use provided base_branch (from task metadata), or fall back to detected default
|
||||
task_source_branch = base_branch
|
||||
if not task_source_branch:
|
||||
# Try to detect from worktree's git history
|
||||
task_source_branch = _detect_worktree_base_branch(
|
||||
project_dir, worktree_path, spec_name
|
||||
)
|
||||
if not task_source_branch:
|
||||
# Fall back to auto-detecting main/master
|
||||
# Auto-detect the default branch (main/master) that worktrees are typically created from
|
||||
task_source_branch = _detect_default_branch(project_dir)
|
||||
|
||||
debug(
|
||||
MODULE,
|
||||
f"Using task source branch: {task_source_branch}",
|
||||
provided=base_branch is not None,
|
||||
)
|
||||
|
||||
# Check for git-level conflicts (diverged branches) using the task's source branch
|
||||
git_conflicts = _check_git_merge_conflicts(
|
||||
project_dir, spec_name, base_branch=task_source_branch
|
||||
)
|
||||
|
||||
# Get actual changed files from git diff (this is the authoritative count)
|
||||
all_changed_files = _get_changed_files_from_git(
|
||||
worktree_path, task_source_branch
|
||||
@@ -806,39 +600,49 @@ def handle_merge_preview_command(
|
||||
changed_files=all_changed_files[:10], # Log first 10
|
||||
)
|
||||
|
||||
# OPTIMIZATION: Skip expensive refresh_from_git() and preview_merge() calls
|
||||
# For merge-preview, we only need to detect:
|
||||
# 1. Git conflicts (task vs base branch) - already calculated in _check_git_merge_conflicts()
|
||||
# 2. Parallel task conflicts (this task vs other active tasks)
|
||||
#
|
||||
# For parallel task detection, we just check if this task's files overlap
|
||||
# with files OTHER tasks have already recorded - no need to re-process all files.
|
||||
debug(MODULE, "Initializing MergeOrchestrator for preview...")
|
||||
|
||||
debug(MODULE, "Checking for parallel task conflicts (lightweight)...")
|
||||
|
||||
# Check for parallel task conflicts by looking at existing evolution data
|
||||
parallel_conflicts = _detect_parallel_task_conflicts(
|
||||
project_dir, spec_name, all_changed_files
|
||||
# Initialize the orchestrator
|
||||
orchestrator = MergeOrchestrator(
|
||||
project_dir,
|
||||
enable_ai=False, # Don't use AI for preview
|
||||
dry_run=True, # Don't write anything
|
||||
)
|
||||
|
||||
# Refresh evolution data from the worktree
|
||||
# Compare against the task's source branch (where the task was created from)
|
||||
debug(
|
||||
MODULE,
|
||||
f"Parallel task conflicts detected: {len(parallel_conflicts)}",
|
||||
conflicts=parallel_conflicts[:5] if parallel_conflicts else [],
|
||||
f"Refreshing evolution data from worktree: {worktree_path}",
|
||||
task_source_branch=task_source_branch,
|
||||
)
|
||||
orchestrator.evolution_tracker.refresh_from_git(
|
||||
spec_name, worktree_path, target_branch=task_source_branch
|
||||
)
|
||||
|
||||
# Build conflict list - start with parallel task conflicts
|
||||
# Get merge preview (semantic conflicts between parallel tasks)
|
||||
debug(MODULE, "Generating merge preview...")
|
||||
preview = orchestrator.preview_merge([spec_name])
|
||||
|
||||
# Transform semantic conflicts to UI-friendly format
|
||||
conflicts = []
|
||||
for pc in parallel_conflicts:
|
||||
for c in preview.get("conflicts", []):
|
||||
debug_verbose(
|
||||
MODULE,
|
||||
"Processing semantic conflict",
|
||||
file=c.get("file", ""),
|
||||
severity=c.get("severity", "unknown"),
|
||||
)
|
||||
conflicts.append(
|
||||
{
|
||||
"file": pc["file"],
|
||||
"location": "file-level",
|
||||
"tasks": pc["tasks"],
|
||||
"severity": "medium",
|
||||
"canAutoMerge": False,
|
||||
"strategy": None,
|
||||
"reason": f"File modified by multiple active tasks: {', '.join(pc['tasks'])}",
|
||||
"type": "parallel",
|
||||
"file": c.get("file", ""),
|
||||
"location": c.get("location", ""),
|
||||
"tasks": c.get("tasks", []),
|
||||
"severity": c.get("severity", "unknown"),
|
||||
"canAutoMerge": c.get("can_auto_merge", False),
|
||||
"strategy": c.get("strategy"),
|
||||
"reason": c.get("reason", ""),
|
||||
"type": "semantic",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -865,14 +669,13 @@ def handle_merge_preview_command(
|
||||
}
|
||||
)
|
||||
|
||||
summary = preview.get("summary", {})
|
||||
# Count only non-lock-file conflicts
|
||||
git_conflict_count = len(git_conflicts.get("conflicting_files", [])) - len(
|
||||
lock_files_excluded
|
||||
)
|
||||
# Calculate totals from our conflict lists (git conflicts + parallel conflicts)
|
||||
parallel_conflict_count = len(parallel_conflicts)
|
||||
total_conflicts = git_conflict_count + parallel_conflict_count
|
||||
conflict_files = git_conflict_count + parallel_conflict_count
|
||||
total_conflicts = summary.get("total_conflicts", 0) + git_conflict_count
|
||||
conflict_files = summary.get("conflict_files", 0) + git_conflict_count
|
||||
|
||||
# Filter lock files from the git conflicts list for the response
|
||||
non_lock_conflicting_files = [
|
||||
@@ -958,7 +761,7 @@ def handle_merge_preview_command(
|
||||
"totalFiles": total_files_from_git,
|
||||
"conflictFiles": conflict_files,
|
||||
"totalConflicts": total_conflicts,
|
||||
"autoMergeable": 0, # Not tracking auto-merge in lightweight mode
|
||||
"autoMergeable": summary.get("auto_mergeable", 0),
|
||||
"hasGitConflicts": git_conflicts["has_conflicts"]
|
||||
and len(non_lock_conflicting_files) > 0,
|
||||
# Include path-mapped AI merge count for UI display
|
||||
@@ -973,9 +776,10 @@ def handle_merge_preview_command(
|
||||
"Merge preview complete",
|
||||
total_files=result["summary"]["totalFiles"],
|
||||
total_files_source="git_diff",
|
||||
semantic_tracked_files=summary.get("total_files", 0),
|
||||
total_conflicts=result["summary"]["totalConflicts"],
|
||||
has_git_conflicts=git_conflicts["has_conflicts"],
|
||||
parallel_conflicts=parallel_conflict_count,
|
||||
auto_mergeable=result["summary"]["autoMergeable"],
|
||||
path_mapped_ai_merges=len(path_mapped_ai_merges),
|
||||
total_renames=len(path_mappings),
|
||||
)
|
||||
@@ -1001,220 +805,3 @@ def handle_merge_preview_command(
|
||||
"pathMappedAIMergeCount": 0,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def handle_create_pr_command(
|
||||
project_dir: Path,
|
||||
spec_name: str,
|
||||
target_branch: str | None = None,
|
||||
title: str | None = None,
|
||||
draft: bool = False,
|
||||
) -> CreatePRResult:
|
||||
"""
|
||||
Handle the --create-pr command: push branch and create a GitHub PR.
|
||||
|
||||
Args:
|
||||
project_dir: Path to the project directory
|
||||
spec_name: Name of the spec (e.g., "001-feature-name")
|
||||
target_branch: Target branch for PR (defaults to base branch)
|
||||
title: Custom PR title (defaults to spec name)
|
||||
draft: Whether to create as draft PR
|
||||
|
||||
Returns:
|
||||
CreatePRResult with success status, pr_url, and any errors
|
||||
"""
|
||||
from core.worktree import WorktreeManager
|
||||
|
||||
print_banner()
|
||||
print("\n" + "=" * 70)
|
||||
print(" CREATE PULL REQUEST")
|
||||
print("=" * 70)
|
||||
|
||||
# Check if worktree exists
|
||||
worktree_path = get_existing_build_worktree(project_dir, spec_name)
|
||||
if not worktree_path:
|
||||
print(f"\n{icon(Icons.ERROR)} No build found for spec: {spec_name}")
|
||||
print("\nA completed build worktree is required to create a PR.")
|
||||
print("Run your build first, then use --create-pr.")
|
||||
error_result: CreatePRResult = {
|
||||
"success": False,
|
||||
"error": "No build found for this spec",
|
||||
}
|
||||
return error_result
|
||||
|
||||
# Create worktree manager
|
||||
manager = WorktreeManager(project_dir, base_branch=target_branch)
|
||||
|
||||
print(f"\n{icon(Icons.BRANCH)} Pushing branch and creating PR...")
|
||||
print(f" Spec: {spec_name}")
|
||||
print(f" Target: {target_branch or manager.base_branch}")
|
||||
if title:
|
||||
print(f" Title: {title}")
|
||||
if draft:
|
||||
print(" Mode: Draft PR")
|
||||
|
||||
# Push and create PR with exception handling for clean JSON output
|
||||
try:
|
||||
raw_result = manager.push_and_create_pr(
|
||||
spec_name=spec_name,
|
||||
target_branch=target_branch,
|
||||
title=title,
|
||||
draft=draft,
|
||||
)
|
||||
except Exception as e:
|
||||
debug_error(MODULE, f"Exception during PR creation: {e}")
|
||||
error_result: CreatePRResult = {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"message": "Failed to create PR",
|
||||
}
|
||||
print(f"\n{icon(Icons.ERROR)} Failed to create PR: {e}")
|
||||
print(json.dumps(error_result))
|
||||
return error_result
|
||||
|
||||
# Convert PushAndCreatePRResult to CreatePRResult
|
||||
result: CreatePRResult = {
|
||||
"success": raw_result.get("success", False),
|
||||
"pr_url": raw_result.get("pr_url"),
|
||||
"already_exists": raw_result.get("already_exists", False),
|
||||
"error": raw_result.get("error"),
|
||||
"message": raw_result.get("message"),
|
||||
"pushed": raw_result.get("pushed", False),
|
||||
"remote": raw_result.get("remote", ""),
|
||||
"branch": raw_result.get("branch", ""),
|
||||
}
|
||||
|
||||
if result.get("success"):
|
||||
pr_url = result.get("pr_url")
|
||||
already_exists = result.get("already_exists", False)
|
||||
|
||||
if already_exists:
|
||||
print(f"\n{icon(Icons.SUCCESS)} PR already exists!")
|
||||
else:
|
||||
print(f"\n{icon(Icons.SUCCESS)} PR created successfully!")
|
||||
|
||||
if pr_url:
|
||||
print(f"\n{icon(Icons.LINK)} {pr_url}")
|
||||
else:
|
||||
print(f"\n{icon(Icons.INFO)} Check GitHub for the PR URL")
|
||||
|
||||
print("\nNext steps:")
|
||||
print(" 1. Review the PR on GitHub")
|
||||
print(" 2. Request reviews from your team")
|
||||
print(" 3. Merge when approved")
|
||||
|
||||
# Output JSON for frontend parsing
|
||||
print(json.dumps(result))
|
||||
return result
|
||||
else:
|
||||
error = result.get("error", "Unknown error")
|
||||
print(f"\n{icon(Icons.ERROR)} Failed to create PR: {error}")
|
||||
# Output JSON for frontend parsing
|
||||
print(json.dumps(result))
|
||||
return result
|
||||
|
||||
|
||||
def cleanup_old_worktrees_command(
|
||||
project_dir: Path, days: int = 30, dry_run: bool = False
|
||||
) -> dict:
|
||||
"""
|
||||
Clean up old worktrees that haven't been modified in the specified number of days.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
days: Number of days threshold (default: 30)
|
||||
dry_run: If True, only show what would be removed (default: False)
|
||||
|
||||
Returns:
|
||||
Dictionary with cleanup results
|
||||
"""
|
||||
try:
|
||||
manager = WorktreeManager(project_dir)
|
||||
|
||||
removed, failed = manager.cleanup_old_worktrees(
|
||||
days_threshold=days, dry_run=dry_run
|
||||
)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"removed": removed,
|
||||
"failed": failed,
|
||||
"dry_run": dry_run,
|
||||
"days_threshold": days,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"removed": [],
|
||||
"failed": [],
|
||||
}
|
||||
|
||||
|
||||
def worktree_summary_command(project_dir: Path) -> dict:
|
||||
"""
|
||||
Get a summary of all worktrees with age information.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
|
||||
Returns:
|
||||
Dictionary with worktree summary data
|
||||
"""
|
||||
try:
|
||||
manager = WorktreeManager(project_dir)
|
||||
|
||||
# Print to console for CLI usage
|
||||
manager.print_worktree_summary()
|
||||
|
||||
# Also return data for programmatic access
|
||||
worktrees = manager.list_all_worktrees()
|
||||
warning = manager.get_worktree_count_warning()
|
||||
|
||||
# Categorize by age
|
||||
recent = []
|
||||
week_old = []
|
||||
month_old = []
|
||||
very_old = []
|
||||
unknown_age = []
|
||||
|
||||
for info in worktrees:
|
||||
data = {
|
||||
"spec_name": info.spec_name,
|
||||
"days_since_last_commit": info.days_since_last_commit,
|
||||
"commit_count": info.commit_count,
|
||||
}
|
||||
|
||||
if info.days_since_last_commit is None:
|
||||
unknown_age.append(data)
|
||||
elif info.days_since_last_commit < 7:
|
||||
recent.append(data)
|
||||
elif info.days_since_last_commit < 30:
|
||||
week_old.append(data)
|
||||
elif info.days_since_last_commit < 90:
|
||||
month_old.append(data)
|
||||
else:
|
||||
very_old.append(data)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"total_worktrees": len(worktrees),
|
||||
"categories": {
|
||||
"recent": recent,
|
||||
"week_old": week_old,
|
||||
"month_old": month_old,
|
||||
"very_old": very_old,
|
||||
"unknown_age": unknown_age,
|
||||
},
|
||||
"warning": warning,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"total_worktrees": 0,
|
||||
"categories": {},
|
||||
"warning": None,
|
||||
}
|
||||
|
||||
@@ -231,9 +231,7 @@ async def _call_claude(prompt: str) -> str:
|
||||
msg_type = type(msg).__name__
|
||||
if msg_type == "AssistantMessage" and hasattr(msg, "content"):
|
||||
for block in msg.content:
|
||||
# Must check block type - only TextBlock has .text attribute
|
||||
block_type = type(block).__name__
|
||||
if block_type == "TextBlock" and hasattr(block, "text"):
|
||||
if hasattr(block, "text"):
|
||||
response_text += block.text
|
||||
|
||||
logger.info(f"Generated commit message: {len(response_text)} chars")
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
+35
-787
@@ -7,29 +7,9 @@ for custom API endpoints.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import platform
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from core.platform import (
|
||||
is_linux,
|
||||
is_macos,
|
||||
is_windows,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Optional import for Linux secret-service support
|
||||
# secretstorage provides access to the Freedesktop.org Secret Service API via DBus
|
||||
if TYPE_CHECKING:
|
||||
import secretstorage
|
||||
else:
|
||||
try:
|
||||
import secretstorage # type: ignore[import-untyped]
|
||||
except ImportError:
|
||||
secretstorage = None # type: ignore[assignment]
|
||||
|
||||
# Priority order for auth token resolution
|
||||
# NOTE: We intentionally do NOT fall back to ANTHROPIC_API_KEY.
|
||||
@@ -43,309 +23,15 @@ AUTH_TOKEN_ENV_VARS = [
|
||||
# Environment variables to pass through to SDK subprocess
|
||||
# NOTE: ANTHROPIC_API_KEY is intentionally excluded to prevent silent API billing
|
||||
SDK_ENV_VARS = [
|
||||
# API endpoint configuration
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
# Model overrides (from API Profile custom model mappings)
|
||||
"ANTHROPIC_MODEL",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
# SDK behavior configuration
|
||||
"NO_PROXY",
|
||||
"DISABLE_TELEMETRY",
|
||||
"DISABLE_COST_WARNINGS",
|
||||
"API_TIMEOUT_MS",
|
||||
# Windows-specific: Git Bash path for Claude Code CLI
|
||||
"CLAUDE_CODE_GIT_BASH_PATH",
|
||||
# Claude CLI path override (allows frontend to pass detected CLI path to SDK)
|
||||
"CLAUDE_CLI_PATH",
|
||||
# Profile's custom config directory (for multi-profile token storage)
|
||||
"CLAUDE_CONFIG_DIR",
|
||||
]
|
||||
|
||||
|
||||
def is_encrypted_token(token: str | None) -> bool:
|
||||
"""
|
||||
Check if a token is encrypted (has "enc:" prefix).
|
||||
|
||||
Args:
|
||||
token: Token string to check (can be None)
|
||||
|
||||
Returns:
|
||||
True if token starts with "enc:", False otherwise
|
||||
"""
|
||||
return bool(token and token.startswith("enc:"))
|
||||
|
||||
|
||||
def validate_token_not_encrypted(token: str) -> None:
|
||||
"""
|
||||
Validate that a token is not in encrypted format.
|
||||
|
||||
This function should be called before passing a token to the Claude Agent SDK
|
||||
to ensure proper error messages when decryption has failed.
|
||||
|
||||
Args:
|
||||
token: Token string to validate
|
||||
|
||||
Raises:
|
||||
ValueError: If token is in encrypted format (enc:...)
|
||||
"""
|
||||
if is_encrypted_token(token):
|
||||
raise ValueError(
|
||||
"Authentication token is in encrypted format and cannot be used.\n\n"
|
||||
"The token decryption process failed or was not attempted.\n\n"
|
||||
"To fix this issue:\n"
|
||||
" 1. Re-authenticate with Claude Code CLI: claude setup-token\n"
|
||||
" 2. Or set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in your .env file\n\n"
|
||||
"Note: Encrypted tokens require the Claude Code CLI to be installed\n"
|
||||
"and properly configured with system keychain access."
|
||||
)
|
||||
|
||||
|
||||
def decrypt_token(encrypted_token: str) -> str:
|
||||
"""
|
||||
Decrypt Claude Code encrypted token.
|
||||
|
||||
NOTE: This implementation currently relies on the system keychain (macOS Keychain,
|
||||
Linux Secret Service, Windows Credential Manager) to provide already-decrypted tokens.
|
||||
Encrypted tokens in the CLAUDE_CODE_OAUTH_TOKEN environment variable are NOT supported
|
||||
and will fail with NotImplementedError.
|
||||
|
||||
For encrypted token support, users should:
|
||||
1. Run: claude setup-token (stores decrypted token in system keychain)
|
||||
2. Or set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in .env file
|
||||
|
||||
Claude Code CLI stores OAuth tokens in encrypted format with "enc:" prefix.
|
||||
This function attempts to decrypt the token using platform-specific methods.
|
||||
|
||||
Cross-platform token decryption approaches:
|
||||
- macOS: Token stored in Keychain with encryption key
|
||||
- Linux: Token stored in Secret Service API with encryption key
|
||||
- Windows: Token stored in Credential Manager or .credentials.json
|
||||
|
||||
Args:
|
||||
encrypted_token: Token with 'enc:' prefix from Claude Code CLI
|
||||
|
||||
Returns:
|
||||
Decrypted token in format 'sk-ant-oat01-...'
|
||||
|
||||
Raises:
|
||||
ValueError: If token format is invalid or decryption fails
|
||||
"""
|
||||
# Validate encrypted token format
|
||||
if not isinstance(encrypted_token, str):
|
||||
raise ValueError(
|
||||
f"Invalid token type. Expected string, got: {type(encrypted_token).__name__}"
|
||||
)
|
||||
|
||||
if not encrypted_token.startswith("enc:"):
|
||||
raise ValueError(
|
||||
"Invalid encrypted token format. Token must start with 'enc:' prefix."
|
||||
)
|
||||
|
||||
# Remove 'enc:' prefix to get encrypted data
|
||||
encrypted_data = encrypted_token[4:]
|
||||
|
||||
if not encrypted_data:
|
||||
raise ValueError("Empty encrypted token data after 'enc:' prefix")
|
||||
|
||||
# Basic validation of encrypted data format
|
||||
# Encrypted data should be a reasonable length (at least 10 chars)
|
||||
if len(encrypted_data) < 10:
|
||||
raise ValueError(
|
||||
"Encrypted token data is too short. The token may be corrupted."
|
||||
)
|
||||
|
||||
# Check for obviously invalid characters that suggest corruption
|
||||
# Accepts both standard base64 (+/) and URL-safe base64 (-_) to be permissive
|
||||
if not all(c.isalnum() or c in "+-_/=" for c in encrypted_data):
|
||||
raise ValueError(
|
||||
"Encrypted token contains invalid characters. "
|
||||
"Expected base64-encoded data. The token may be corrupted."
|
||||
)
|
||||
|
||||
# Attempt platform-specific decryption
|
||||
try:
|
||||
if is_macos():
|
||||
return _decrypt_token_macos(encrypted_data)
|
||||
elif is_linux():
|
||||
return _decrypt_token_linux(encrypted_data)
|
||||
elif is_windows():
|
||||
return _decrypt_token_windows(encrypted_data)
|
||||
else:
|
||||
raise ValueError("Unsupported platform for token decryption")
|
||||
|
||||
except NotImplementedError as e:
|
||||
# Decryption not implemented - log warning and provide guidance
|
||||
logger.warning(
|
||||
"Token decryption failed: %s. Users must use plaintext tokens.", str(e)
|
||||
)
|
||||
raise ValueError(
|
||||
f"Encrypted token decryption is not yet implemented: {str(e)}\n\n"
|
||||
"To fix this issue:\n"
|
||||
" 1. Set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token (without 'enc:' prefix)\n"
|
||||
" 2. Or re-authenticate with: claude setup-token"
|
||||
)
|
||||
except ValueError:
|
||||
# Re-raise ValueError as-is (already has good error message)
|
||||
raise
|
||||
except FileNotFoundError as e:
|
||||
# File-related errors (missing credentials file, missing binary)
|
||||
raise ValueError(
|
||||
f"Failed to decrypt token - required file not found: {str(e)}\n\n"
|
||||
"To fix this issue:\n"
|
||||
" 1. Re-authenticate with Claude Code CLI: claude setup-token\n"
|
||||
" 2. Or set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in your .env file"
|
||||
)
|
||||
except PermissionError as e:
|
||||
# Permission errors (can't access keychain, credential manager, etc.)
|
||||
raise ValueError(
|
||||
f"Failed to decrypt token - permission denied: {str(e)}\n\n"
|
||||
"To fix this issue:\n"
|
||||
" 1. Grant keychain/credential manager access to this application\n"
|
||||
" 2. Or set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in your .env file"
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
# Timeout during decryption process
|
||||
raise ValueError(
|
||||
"Failed to decrypt token - operation timed out.\n\n"
|
||||
"This may indicate a problem with system keychain access.\n\n"
|
||||
"To fix this issue:\n"
|
||||
" 1. Re-authenticate with Claude Code CLI: claude setup-token\n"
|
||||
" 2. Or set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in your .env file"
|
||||
)
|
||||
except Exception as e:
|
||||
# Catch-all for other errors - provide helpful error message
|
||||
error_type = type(e).__name__
|
||||
raise ValueError(
|
||||
f"Failed to decrypt token ({error_type}): {str(e)}\n\n"
|
||||
"To fix this issue:\n"
|
||||
" 1. Re-authenticate with Claude Code CLI: claude setup-token\n"
|
||||
" 2. Or set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in your .env file\n\n"
|
||||
"Note: Encrypted tokens (enc:...) require the Claude Code CLI to be installed\n"
|
||||
"and properly configured with system keychain access."
|
||||
)
|
||||
|
||||
|
||||
def _decrypt_token_macos(encrypted_data: str) -> str:
|
||||
"""
|
||||
Decrypt token on macOS using Keychain.
|
||||
|
||||
Args:
|
||||
encrypted_data: Encrypted token data (without 'enc:' prefix)
|
||||
|
||||
Returns:
|
||||
Decrypted token
|
||||
|
||||
Raises:
|
||||
ValueError: If decryption fails or Claude CLI not available
|
||||
"""
|
||||
# Verify Claude CLI is installed (required for future decryption implementation)
|
||||
if not shutil.which("claude"):
|
||||
raise ValueError(
|
||||
"Claude Code CLI not found. Please install it from https://code.claude.com"
|
||||
)
|
||||
|
||||
# The Claude Code CLI handles token decryption internally when it runs
|
||||
# We can trigger this by running a simple command that requires authentication
|
||||
# and capturing the decrypted token from the environment it sets up
|
||||
#
|
||||
# However, there's no direct CLI command to decrypt tokens.
|
||||
# The SDK should handle this automatically when it receives encrypted tokens.
|
||||
raise NotImplementedError(
|
||||
"Encrypted tokens in environment variables are not supported. "
|
||||
"Please use one of these options:\n"
|
||||
" 1. Run 'claude setup-token' to store token in system keychain\n"
|
||||
" 2. Set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in .env file\n\n"
|
||||
"Note: This requires Claude Agent SDK >= 0.1.19"
|
||||
)
|
||||
|
||||
|
||||
def _decrypt_token_linux(encrypted_data: str) -> str:
|
||||
"""
|
||||
Decrypt token on Linux using Secret Service API.
|
||||
|
||||
Args:
|
||||
encrypted_data: Encrypted token data (without 'enc:' prefix)
|
||||
|
||||
Returns:
|
||||
Decrypted token
|
||||
|
||||
Raises:
|
||||
ValueError: If decryption fails or dependencies not available
|
||||
"""
|
||||
# Linux token decryption requires secretstorage library
|
||||
if secretstorage is None:
|
||||
raise ValueError(
|
||||
"secretstorage library not found. Install it with: pip install secretstorage"
|
||||
)
|
||||
|
||||
# Similar to macOS, the actual decryption mechanism isn't publicly documented
|
||||
# The Claude Agent SDK should handle this automatically
|
||||
raise NotImplementedError(
|
||||
"Encrypted tokens in environment variables are not supported. "
|
||||
"Please use one of these options:\n"
|
||||
" 1. Run 'claude setup-token' to store token in system keychain\n"
|
||||
" 2. Set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in .env file\n\n"
|
||||
"Note: This requires Claude Agent SDK >= 0.1.19"
|
||||
)
|
||||
|
||||
|
||||
def _decrypt_token_windows(encrypted_data: str) -> str:
|
||||
"""
|
||||
Decrypt token on Windows using Credential Manager.
|
||||
|
||||
Args:
|
||||
encrypted_data: Encrypted token data (without 'enc:' prefix)
|
||||
|
||||
Returns:
|
||||
Decrypted token
|
||||
|
||||
Raises:
|
||||
ValueError: If decryption fails
|
||||
"""
|
||||
# Windows token decryption from Credential Manager or .credentials.json
|
||||
# The Claude Agent SDK should handle this automatically
|
||||
raise NotImplementedError(
|
||||
"Encrypted tokens in environment variables are not supported. "
|
||||
"Please use one of these options:\n"
|
||||
" 1. Run 'claude setup-token' to store token in system keychain\n"
|
||||
" 2. Set CLAUDE_CODE_OAUTH_TOKEN to a plaintext token in .env file\n\n"
|
||||
"Note: This requires Claude Agent SDK >= 0.1.19"
|
||||
)
|
||||
|
||||
|
||||
def _try_decrypt_token(token: str | None) -> str | None:
|
||||
"""
|
||||
Attempt to decrypt an encrypted token, returning original if decryption fails.
|
||||
|
||||
This helper centralizes the decrypt-or-return-as-is logic used when resolving
|
||||
tokens from various sources (env vars, config dir, keychain).
|
||||
|
||||
Args:
|
||||
token: Token string (may be encrypted with "enc:" prefix, plaintext, or None)
|
||||
|
||||
Returns:
|
||||
- Decrypted token if successfully decrypted
|
||||
- Original token if decryption fails (allows client validation to report error)
|
||||
- Original token if not encrypted
|
||||
- None if token is None
|
||||
"""
|
||||
if not token:
|
||||
return None
|
||||
|
||||
if is_encrypted_token(token):
|
||||
try:
|
||||
return decrypt_token(token)
|
||||
except ValueError:
|
||||
# Decryption failed - return encrypted token so client validation
|
||||
# (validate_token_not_encrypted) can provide specific error message.
|
||||
return token
|
||||
|
||||
return token
|
||||
|
||||
|
||||
def get_token_from_keychain() -> str | None:
|
||||
"""
|
||||
Get authentication token from system credential store.
|
||||
@@ -353,18 +39,20 @@ def get_token_from_keychain() -> str | None:
|
||||
Reads Claude Code credentials from:
|
||||
- macOS: Keychain
|
||||
- Windows: Credential Manager
|
||||
- Linux: Secret Service API (via dbus/secretstorage)
|
||||
- Linux: Not yet supported (use env var)
|
||||
|
||||
Returns:
|
||||
Token string if found, None otherwise
|
||||
"""
|
||||
if is_macos():
|
||||
system = platform.system()
|
||||
|
||||
if system == "Darwin":
|
||||
return _get_token_from_macos_keychain()
|
||||
elif is_windows():
|
||||
elif system == "Windows":
|
||||
return _get_token_from_windows_credential_files()
|
||||
else:
|
||||
# Linux: use secret-service API via DBus
|
||||
return _get_token_from_linux_secret_service()
|
||||
# Linux: secret-service not yet implemented
|
||||
return None
|
||||
|
||||
|
||||
def _get_token_from_macos_keychain() -> str | None:
|
||||
@@ -434,216 +122,59 @@ def _get_token_from_windows_credential_files() -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _get_token_from_linux_secret_service() -> str | None:
|
||||
"""Get token from Linux Secret Service API via DBus.
|
||||
|
||||
Claude Code on Linux stores credentials in the Secret Service API
|
||||
using the 'org.freedesktop.secrets' collection. This implementation
|
||||
uses the secretstorage library which communicates via DBus.
|
||||
|
||||
The credential is stored with:
|
||||
- Label: "Claude Code-credentials"
|
||||
- Attributes: {application: "claude-code"}
|
||||
|
||||
Returns:
|
||||
Token string if found, None otherwise
|
||||
def get_auth_token() -> str | None:
|
||||
"""
|
||||
if secretstorage is None:
|
||||
# secretstorage not installed, fall back to env var
|
||||
return None
|
||||
|
||||
try:
|
||||
# Get the default collection (typically "login" keyring)
|
||||
# secretstorage handles DBus communication internally
|
||||
try:
|
||||
collection = secretstorage.get_default_collection(None)
|
||||
except (
|
||||
AttributeError,
|
||||
secretstorage.exceptions.SecretServiceNotAvailableException,
|
||||
):
|
||||
# DBus not available or secret-service not running
|
||||
return None
|
||||
|
||||
if collection.is_locked():
|
||||
# Try to unlock the collection (may prompt user for password)
|
||||
try:
|
||||
collection.unlock()
|
||||
except secretstorage.exceptions.SecretStorageException:
|
||||
# User cancelled or unlock failed
|
||||
return None
|
||||
|
||||
# Search for items with our application attribute
|
||||
items = collection.search_items({"application": "claude-code"})
|
||||
|
||||
for item in items:
|
||||
# Check if this is the Claude Code credentials item
|
||||
label = item.get_label()
|
||||
# Use exact match for "Claude Code-credentials" to avoid false positives
|
||||
if label == "Claude Code-credentials":
|
||||
# Get the secret (stored as JSON string)
|
||||
secret = item.get_secret()
|
||||
if not secret:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Explicitly decode bytes to string if needed
|
||||
if isinstance(secret, bytes):
|
||||
secret = secret.decode("utf-8")
|
||||
data = json.loads(secret)
|
||||
token = data.get("claudeAiOauth", {}).get("accessToken")
|
||||
|
||||
if token and token.startswith("sk-ant-oat01-"):
|
||||
return token
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
except (
|
||||
secretstorage.exceptions.SecretStorageException,
|
||||
json.JSONDecodeError,
|
||||
KeyError,
|
||||
AttributeError,
|
||||
TypeError,
|
||||
):
|
||||
# Any error with secret-service, fall back to env var
|
||||
return None
|
||||
|
||||
|
||||
def _get_token_from_config_dir(config_dir: str) -> str | None:
|
||||
"""
|
||||
Read token from a custom config directory's credentials file.
|
||||
|
||||
Claude Code stores credentials in .credentials.json within the config directory.
|
||||
This function reads from a profile's custom configDir instead of the default location.
|
||||
|
||||
Args:
|
||||
config_dir: Path to the config directory (e.g., ~/.auto-claude/profiles/work)
|
||||
|
||||
Returns:
|
||||
Token string if found, None otherwise
|
||||
"""
|
||||
# Expand ~ if present
|
||||
expanded_dir = os.path.expanduser(config_dir)
|
||||
|
||||
# Claude stores credentials in these files within the config dir
|
||||
cred_files = [
|
||||
os.path.join(expanded_dir, ".credentials.json"),
|
||||
os.path.join(expanded_dir, "credentials.json"),
|
||||
]
|
||||
|
||||
for cred_path in cred_files:
|
||||
if os.path.exists(cred_path):
|
||||
try:
|
||||
with open(cred_path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Try both credential structures
|
||||
oauth_data = data.get("claudeAiOauth") or data.get("oauthAccount") or {}
|
||||
token = oauth_data.get("accessToken")
|
||||
|
||||
# Accept both plaintext tokens (sk-ant-oat01-) and encrypted tokens (enc:)
|
||||
if token and (
|
||||
token.startswith("sk-ant-oat01-") or token.startswith("enc:")
|
||||
):
|
||||
logger.debug(f"Found token in {cred_path}")
|
||||
return token
|
||||
except (json.JSONDecodeError, KeyError, Exception) as e:
|
||||
logger.debug(f"Failed to read {cred_path}: {e}")
|
||||
continue
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_auth_token(config_dir: str | None = None) -> str | None:
|
||||
"""
|
||||
Get authentication token from environment variables or credential store.
|
||||
|
||||
Args:
|
||||
config_dir: Optional custom config directory (profile's configDir).
|
||||
If provided, reads credentials from this directory.
|
||||
If None, checks CLAUDE_CONFIG_DIR env var, then uses default locations.
|
||||
Get authentication token from environment variables or system credential store.
|
||||
|
||||
Checks multiple sources in priority order:
|
||||
1. CLAUDE_CODE_OAUTH_TOKEN (env var)
|
||||
2. ANTHROPIC_AUTH_TOKEN (CCR/proxy env var for enterprise setups)
|
||||
3. Custom config directory (config_dir param or CLAUDE_CONFIG_DIR env var)
|
||||
4. System credential store (macOS Keychain, Windows Credential Manager, Linux Secret Service)
|
||||
3. System credential store (macOS Keychain, Windows Credential Manager)
|
||||
|
||||
NOTE: ANTHROPIC_API_KEY is intentionally NOT supported to prevent
|
||||
silent billing to user's API credits when OAuth is misconfigured.
|
||||
|
||||
If the token has an "enc:" prefix (encrypted format), it will be automatically
|
||||
decrypted before being returned.
|
||||
|
||||
Returns:
|
||||
Token string if found, None otherwise
|
||||
"""
|
||||
# First check environment variables (highest priority)
|
||||
# First check environment variables
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
token = os.environ.get(var)
|
||||
if token:
|
||||
return _try_decrypt_token(token)
|
||||
return token
|
||||
|
||||
# Check CLAUDE_CONFIG_DIR environment variable (profile's custom config directory)
|
||||
env_config_dir = os.environ.get("CLAUDE_CONFIG_DIR")
|
||||
effective_config_dir = config_dir or env_config_dir
|
||||
|
||||
# If a custom config directory is specified, read from there
|
||||
if effective_config_dir:
|
||||
token = _get_token_from_config_dir(effective_config_dir)
|
||||
if token:
|
||||
return _try_decrypt_token(token)
|
||||
|
||||
# Fallback to system credential store (default locations)
|
||||
return _try_decrypt_token(get_token_from_keychain())
|
||||
# Fallback to system credential store
|
||||
return get_token_from_keychain()
|
||||
|
||||
|
||||
def get_auth_token_source(config_dir: str | None = None) -> str | None:
|
||||
"""
|
||||
Get the name of the source that provided the auth token.
|
||||
|
||||
Args:
|
||||
config_dir: Optional custom config directory (profile's configDir).
|
||||
If provided, checks this directory for credentials.
|
||||
If None, checks CLAUDE_CONFIG_DIR env var.
|
||||
"""
|
||||
def get_auth_token_source() -> str | None:
|
||||
"""Get the name of the source that provided the auth token."""
|
||||
# Check environment variables first
|
||||
for var in AUTH_TOKEN_ENV_VARS:
|
||||
if os.environ.get(var):
|
||||
return var
|
||||
|
||||
# Check if token came from custom config directory (profile's configDir)
|
||||
env_config_dir = os.environ.get("CLAUDE_CONFIG_DIR")
|
||||
effective_config_dir = config_dir or env_config_dir
|
||||
if effective_config_dir and _get_token_from_config_dir(effective_config_dir):
|
||||
return "CLAUDE_CONFIG_DIR"
|
||||
|
||||
# Check if token came from system credential store
|
||||
if get_token_from_keychain():
|
||||
if is_macos():
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
return "macOS Keychain"
|
||||
elif is_windows():
|
||||
elif system == "Windows":
|
||||
return "Windows Credential Files"
|
||||
else:
|
||||
return "Linux Secret Service"
|
||||
return "System Credential Store"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def require_auth_token(config_dir: str | None = None) -> str:
|
||||
def require_auth_token() -> str:
|
||||
"""
|
||||
Get authentication token or raise ValueError.
|
||||
|
||||
Args:
|
||||
config_dir: Optional custom config directory (profile's configDir).
|
||||
If provided, reads credentials from this directory.
|
||||
If None, checks CLAUDE_CONFIG_DIR env var, then uses default locations.
|
||||
|
||||
Raises:
|
||||
ValueError: If no auth token is found in any supported source
|
||||
"""
|
||||
token = get_auth_token(config_dir)
|
||||
token = get_auth_token()
|
||||
if not token:
|
||||
error_msg = (
|
||||
"No OAuth token found.\n\n"
|
||||
@@ -651,117 +182,32 @@ def require_auth_token(config_dir: str | None = None) -> str:
|
||||
"Direct API keys (ANTHROPIC_API_KEY) are not supported.\n\n"
|
||||
)
|
||||
# Provide platform-specific guidance
|
||||
if is_macos():
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
error_msg += (
|
||||
"To authenticate:\n"
|
||||
" 1. Run: claude\n"
|
||||
" 2. Type: /login\n"
|
||||
" 3. Press Enter to open browser\n"
|
||||
" 4. Complete OAuth login in browser\n\n"
|
||||
"The token will be saved to macOS Keychain automatically."
|
||||
" 1. Run: claude setup-token\n"
|
||||
" 2. The token will be saved to macOS Keychain automatically\n\n"
|
||||
"Or set CLAUDE_CODE_OAUTH_TOKEN in your .env file."
|
||||
)
|
||||
elif is_windows():
|
||||
elif system == "Windows":
|
||||
error_msg += (
|
||||
"To authenticate:\n"
|
||||
" 1. Run: claude\n"
|
||||
" 2. Type: /login\n"
|
||||
" 3. Press Enter to open browser\n"
|
||||
" 4. Complete OAuth login in browser\n\n"
|
||||
"The token will be saved to Windows Credential Manager."
|
||||
" 1. Run: claude setup-token\n"
|
||||
" 2. The token should be saved to Windows Credential Manager\n\n"
|
||||
"If auto-detection fails, set CLAUDE_CODE_OAUTH_TOKEN in your .env file.\n"
|
||||
"Check: %LOCALAPPDATA%\\Claude\\credentials.json"
|
||||
)
|
||||
else:
|
||||
# Linux
|
||||
error_msg += (
|
||||
"To authenticate:\n"
|
||||
" 1. Run: claude\n"
|
||||
" 2. Type: /login\n"
|
||||
" 3. Press Enter to open browser\n"
|
||||
" 4. Complete OAuth login in browser\n\n"
|
||||
"Or set CLAUDE_CODE_OAUTH_TOKEN in your .env file."
|
||||
" 1. Run: claude setup-token\n"
|
||||
" 2. Set CLAUDE_CODE_OAUTH_TOKEN in your .env file"
|
||||
)
|
||||
raise ValueError(error_msg)
|
||||
return token
|
||||
|
||||
|
||||
def _find_git_bash_path() -> str | None:
|
||||
"""
|
||||
Find git-bash (bash.exe) path on Windows.
|
||||
|
||||
Uses 'where git' to find git.exe, then derives bash.exe location from it.
|
||||
Git for Windows installs bash.exe in the 'bin' directory alongside git.exe
|
||||
or in the parent 'bin' directory when git.exe is in 'cmd'.
|
||||
|
||||
Returns:
|
||||
Full path to bash.exe if found, None otherwise
|
||||
"""
|
||||
if not is_windows():
|
||||
return None
|
||||
|
||||
# If already set in environment, use that
|
||||
existing = os.environ.get("CLAUDE_CODE_GIT_BASH_PATH")
|
||||
if existing and os.path.exists(existing):
|
||||
return existing
|
||||
|
||||
git_path = None
|
||||
|
||||
# Method 1: Use 'where' command to find git.exe
|
||||
try:
|
||||
# Use where.exe explicitly for reliability
|
||||
result = subprocess.run(
|
||||
["where.exe", "git"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
shell=False,
|
||||
)
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
git_paths = result.stdout.strip().splitlines()
|
||||
if git_paths:
|
||||
git_path = git_paths[0].strip()
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError, subprocess.SubprocessError):
|
||||
# Intentionally suppress errors - best-effort detection with fallback to common paths
|
||||
pass
|
||||
|
||||
# Method 2: Check common installation paths if 'where' didn't work
|
||||
if not git_path:
|
||||
common_git_paths = [
|
||||
os.path.expandvars(r"%PROGRAMFILES%\Git\cmd\git.exe"),
|
||||
os.path.expandvars(r"%PROGRAMFILES%\Git\bin\git.exe"),
|
||||
os.path.expandvars(r"%PROGRAMFILES(X86)%\Git\cmd\git.exe"),
|
||||
os.path.expandvars(r"%LOCALAPPDATA%\Programs\Git\cmd\git.exe"),
|
||||
]
|
||||
for path in common_git_paths:
|
||||
if os.path.exists(path):
|
||||
git_path = path
|
||||
break
|
||||
|
||||
if not git_path:
|
||||
return None
|
||||
|
||||
# Derive bash.exe location from git.exe location
|
||||
# Git for Windows structure:
|
||||
# C:\...\Git\cmd\git.exe -> bash.exe is at C:\...\Git\bin\bash.exe
|
||||
# C:\...\Git\bin\git.exe -> bash.exe is at C:\...\Git\bin\bash.exe
|
||||
# C:\...\Git\mingw64\bin\git.exe -> bash.exe is at C:\...\Git\bin\bash.exe
|
||||
git_dir = os.path.dirname(git_path)
|
||||
git_parent = os.path.dirname(git_dir)
|
||||
git_grandparent = os.path.dirname(git_parent)
|
||||
|
||||
# Check common bash.exe locations relative to git installation
|
||||
possible_bash_paths = [
|
||||
os.path.join(git_parent, "bin", "bash.exe"), # cmd -> bin
|
||||
os.path.join(git_dir, "bash.exe"), # If git.exe is in bin
|
||||
os.path.join(git_grandparent, "bin", "bash.exe"), # mingw64/bin -> bin
|
||||
]
|
||||
|
||||
for bash_path in possible_bash_paths:
|
||||
if os.path.exists(bash_path):
|
||||
return bash_path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def get_sdk_env_vars() -> dict[str, str]:
|
||||
"""
|
||||
Get environment variables to pass to SDK.
|
||||
@@ -769,8 +215,6 @@ def get_sdk_env_vars() -> dict[str, str]:
|
||||
Collects relevant env vars (ANTHROPIC_BASE_URL, etc.) that should
|
||||
be passed through to the claude-agent-sdk subprocess.
|
||||
|
||||
On Windows, auto-detects CLAUDE_CODE_GIT_BASH_PATH if not already set.
|
||||
|
||||
Returns:
|
||||
Dict of env var name -> value for non-empty vars
|
||||
"""
|
||||
@@ -779,24 +223,6 @@ def get_sdk_env_vars() -> dict[str, str]:
|
||||
value = os.environ.get(var)
|
||||
if value:
|
||||
env[var] = value
|
||||
|
||||
# On Windows, auto-detect git-bash path if not already set
|
||||
# Claude Code CLI requires bash.exe to run on Windows
|
||||
if is_windows() and "CLAUDE_CODE_GIT_BASH_PATH" not in env:
|
||||
bash_path = _find_git_bash_path()
|
||||
if bash_path:
|
||||
env["CLAUDE_CODE_GIT_BASH_PATH"] = bash_path
|
||||
|
||||
# Explicitly unset PYTHONPATH in SDK subprocess environment to prevent
|
||||
# pollution of agent subprocess environments. This fixes ACS-251 where
|
||||
# external projects with different Python versions would fail due to
|
||||
# inheriting Auto-Claude's PYTHONPATH (which points to Python 3.12 packages).
|
||||
#
|
||||
# The SDK merges os.environ with the env dict we provide, so setting
|
||||
# PYTHONPATH to an empty string here overrides any inherited value.
|
||||
# The empty string ensures Python doesn't add any extra paths to sys.path.
|
||||
env["PYTHONPATH"] = ""
|
||||
|
||||
return env
|
||||
|
||||
|
||||
@@ -813,181 +239,3 @@ def ensure_claude_code_oauth_token() -> None:
|
||||
token = get_auth_token()
|
||||
if token:
|
||||
os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = token
|
||||
|
||||
|
||||
def trigger_login() -> bool:
|
||||
"""
|
||||
Trigger Claude Code OAuth login flow.
|
||||
|
||||
Opens the Claude Code CLI and sends /login command to initiate
|
||||
browser-based OAuth authentication. The token is automatically
|
||||
saved to the system credential store (macOS Keychain, Windows
|
||||
Credential Manager).
|
||||
|
||||
Returns:
|
||||
True if login was successful, False otherwise
|
||||
"""
|
||||
if is_macos():
|
||||
return _trigger_login_macos()
|
||||
elif is_windows():
|
||||
return _trigger_login_windows()
|
||||
else:
|
||||
# Linux: fall back to manual instructions
|
||||
print("\nTo authenticate, run 'claude' and type '/login'")
|
||||
return False
|
||||
|
||||
|
||||
def _trigger_login_macos() -> bool:
|
||||
"""Trigger login on macOS using expect."""
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
# Check if expect is available
|
||||
if not shutil.which("expect"):
|
||||
print("\nTo authenticate, run 'claude' and type '/login'")
|
||||
return False
|
||||
|
||||
# Create expect script
|
||||
expect_script = """#!/usr/bin/expect -f
|
||||
set timeout 120
|
||||
spawn claude
|
||||
expect {
|
||||
-re ".*" {
|
||||
send "/login\\r"
|
||||
expect {
|
||||
"Press Enter" {
|
||||
send "\\r"
|
||||
}
|
||||
-re ".*login.*" {
|
||||
send "\\r"
|
||||
}
|
||||
timeout {
|
||||
send "\\r"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
# Keep running until user completes login or exits
|
||||
interact
|
||||
"""
|
||||
|
||||
# Use TemporaryDirectory context manager for automatic cleanup
|
||||
# This prevents information leakage about authentication activity
|
||||
# Directory created with mode 0o700 (owner read/write/execute only)
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
# Ensure directory has owner-only permissions
|
||||
os.chmod(temp_dir, 0o700)
|
||||
|
||||
# Write expect script to temp file in our private directory
|
||||
script_path = os.path.join(temp_dir, "login.exp")
|
||||
with open(script_path, "w", encoding="utf-8") as f:
|
||||
f.write(expect_script)
|
||||
|
||||
# Set script permissions to owner-only (0o700)
|
||||
os.chmod(script_path, 0o700)
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("CLAUDE CODE LOGIN")
|
||||
print("=" * 60)
|
||||
print("\nOpening Claude Code for authentication...")
|
||||
print("A browser window will open for OAuth login.")
|
||||
print("After completing login in the browser, press Ctrl+C to exit.\n")
|
||||
|
||||
# Run expect script
|
||||
subprocess.run(
|
||||
["expect", script_path],
|
||||
timeout=300, # 5 minute timeout
|
||||
)
|
||||
|
||||
# Verify token was saved
|
||||
token = get_token_from_keychain()
|
||||
if token:
|
||||
print("\n✓ Login successful! Token saved to macOS Keychain.")
|
||||
return True
|
||||
else:
|
||||
print(
|
||||
"\n✗ Login may not have completed. Try running 'claude' and type '/login'"
|
||||
)
|
||||
return False
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
print("\nLogin timed out. Try running 'claude' manually and type '/login'")
|
||||
return False
|
||||
except KeyboardInterrupt:
|
||||
# User pressed Ctrl+C - check if login completed
|
||||
token = get_token_from_keychain()
|
||||
if token:
|
||||
print("\n✓ Login successful! Token saved to macOS Keychain.")
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"\nLogin failed: {e}")
|
||||
print("Try running 'claude' manually and type '/login'")
|
||||
return False
|
||||
|
||||
|
||||
def _trigger_login_windows() -> bool:
|
||||
"""Trigger login on Windows."""
|
||||
# Windows doesn't have expect by default, so we use a simpler approach
|
||||
# that just launches claude and tells the user what to type
|
||||
print("\n" + "=" * 60)
|
||||
print("CLAUDE CODE LOGIN")
|
||||
print("=" * 60)
|
||||
print("\nLaunching Claude Code...")
|
||||
print("Please type '/login' and press Enter.")
|
||||
print("A browser window will open for OAuth login.\n")
|
||||
|
||||
try:
|
||||
# Launch claude interactively
|
||||
subprocess.run(["claude"], timeout=300)
|
||||
|
||||
# Verify token was saved
|
||||
token = _get_token_from_windows_credential_files()
|
||||
if token:
|
||||
print("\n✓ Login successful!")
|
||||
return True
|
||||
else:
|
||||
print("\n✗ Login may not have completed.")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"\nLogin failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def ensure_authenticated() -> str:
|
||||
"""
|
||||
Ensure the user is authenticated, prompting for login if needed.
|
||||
|
||||
Checks for existing token and triggers login flow if not found.
|
||||
|
||||
Returns:
|
||||
The authentication token
|
||||
|
||||
Raises:
|
||||
ValueError: If authentication fails after login attempt
|
||||
"""
|
||||
# First check if already authenticated
|
||||
token = get_auth_token()
|
||||
if token:
|
||||
return token
|
||||
|
||||
# No token found - trigger login
|
||||
print("\nNo OAuth token found. Starting login flow...")
|
||||
|
||||
if trigger_login():
|
||||
# Re-check for token after login
|
||||
token = get_auth_token()
|
||||
if token:
|
||||
return token
|
||||
|
||||
# Login failed or was cancelled
|
||||
raise ValueError(
|
||||
"Authentication required.\n\n"
|
||||
"To authenticate:\n"
|
||||
" 1. Run: claude\n"
|
||||
" 2. Type: /login\n"
|
||||
" 3. Press Enter to open browser\n"
|
||||
" 4. Complete OAuth login in browser"
|
||||
)
|
||||
|
||||
+5
-188
@@ -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,11 +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 (
|
||||
get_sdk_env_vars,
|
||||
require_auth_token,
|
||||
validate_token_not_encrypted,
|
||||
)
|
||||
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
|
||||
@@ -490,26 +380,13 @@ def create_client(
|
||||
(see security.py for ALLOWED_COMMANDS)
|
||||
4. Tool filtering - Each agent type only sees relevant tools (prevents misuse)
|
||||
"""
|
||||
# Get OAuth token - Claude CLI handles token lifecycle internally
|
||||
oauth_token = require_auth_token()
|
||||
|
||||
# Validate token is not encrypted before passing to SDK
|
||||
# Encrypted tokens (enc:...) should have been decrypted by require_auth_token()
|
||||
# If we still have an encrypted token here, it means decryption failed or was skipped
|
||||
validate_token_not_encrypted(oauth_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()
|
||||
|
||||
# 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", "")
|
||||
@@ -519,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)
|
||||
@@ -560,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": {
|
||||
@@ -624,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(*)",
|
||||
@@ -657,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")
|
||||
@@ -796,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,
|
||||
@@ -811,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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user