Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 086429cb49 | |||
| d9fb8f29d5 | |||
| d0b0b3df0d | |||
| 937a60f8bd | |||
| 7a51cbd5e5 | |||
| 26beefe39d | |||
| 8416f3076a | |||
| 217249c8a3 | |||
| 8bb3df917e | |||
| 5106c6e9b2 | |||
| 3ff612742f | |||
| 7f19c2e1f3 | |||
| d98e28305d | |||
| 0b874d4b33 | |||
| 50dd10788a | |||
| f1cc5a09f2 | |||
| b005fa5c86 | |||
| d79f2da411 | |||
| 5ac566e2a2 | |||
| f49d4817b1 | |||
| 1e1d7d9b68 | |||
| e74a3dffd2 | |||
| 6ac8250b5b | |||
| a2cee6941f | |||
| d4cad80a73 | |||
| 596e95137b | |||
| d42041c5b5 | |||
| a3f87540c6 | |||
| f843811292 | |||
| 5e8c53080f | |||
| 348de6dfe7 | |||
| 0f7d6e0530 | |||
| 5ccdb6abc5 | |||
| 6ec8549f63 | |||
| 53527293cd | |||
| 02bef954f3 | |||
| f168bdc3ac | |||
| e3eec68aab | |||
| 407a0bee5e | |||
| 8f766ad16e | |||
| ced2ad479f | |||
| 05f5d3038b | |||
| 6951251b33 | |||
| 30e7536b63 | |||
| 220faf0fb4 | |||
| f96c6301f4 | |||
| ebd8340d82 | |||
| df779530e7 | |||
| 0adaddacaa | |||
| 91f7051dda |
@@ -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"
|
||||
+120
-453
@@ -55,58 +55,44 @@ jobs:
|
||||
git push origin "v$VERSION"
|
||||
echo "Created tag v$VERSION"
|
||||
|
||||
- name: Create tag only (dry run)
|
||||
if: ${{ github.event.inputs.dry_run == 'true' }}
|
||||
run: |
|
||||
VERSION="${{ github.event.inputs.version }}"
|
||||
echo "DRY RUN: Would create tag v$VERSION"
|
||||
|
||||
# Intel build on Intel runner for native compilation
|
||||
build-macos-intel:
|
||||
needs: create-tag
|
||||
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
|
||||
|
||||
- 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
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/Library/Caches/pip
|
||||
key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
pip-wheel-${{ runner.os }}-x64-
|
||||
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-rust-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-rust-
|
||||
python-bundle-${{ runner.os }}-x64-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
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 +102,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
|
||||
@@ -135,51 +132,45 @@ jobs:
|
||||
path: |
|
||||
apps/frontend/dist/*.dmg
|
||||
apps/frontend/dist/*.zip
|
||||
apps/frontend/dist/*.yml
|
||||
|
||||
# Apple Silicon build on ARM64 runner for native compilation
|
||||
build-macos-arm64:
|
||||
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 +180,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
|
||||
@@ -208,53 +210,45 @@ jobs:
|
||||
path: |
|
||||
apps/frontend/dist/*.dmg
|
||||
apps/frontend/dist/*.zip
|
||||
apps/frontend/dist/*.yml
|
||||
|
||||
build-windows:
|
||||
needs: create-tag
|
||||
runs-on: windows-latest
|
||||
permissions:
|
||||
id-token: write # Required for OIDC authentication with Azure
|
||||
contents: read
|
||||
env:
|
||||
# Job-level env so AZURE_CLIENT_ID is available for step-level if conditions
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
# Use tag for real releases, develop branch for dry runs
|
||||
ref: ${{ github.event.inputs.dry_run == 'true' && 'develop' || format('v{0}', needs.create-tag.outputs.version) }}
|
||||
|
||||
- name: Setup 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 +257,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
|
||||
@@ -386,7 +266,6 @@ jobs:
|
||||
name: windows-builds
|
||||
path: |
|
||||
apps/frontend/dist/*.exe
|
||||
apps/frontend/dist/*.yml
|
||||
|
||||
build-linux:
|
||||
needs: create-tag
|
||||
@@ -397,45 +276,34 @@ jobs:
|
||||
# Use tag for real releases, develop branch for dry runs
|
||||
ref: ${{ github.event.inputs.dry_run == 'true' && 'develop' || format('v{0}', needs.create-tag.outputs.version) }}
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
node-version: '24'
|
||||
|
||||
- name: Setup Node.js and install dependencies
|
||||
uses: ./.github/actions/setup-node-frontend
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache
|
||||
run: echo "dir=$(npm config get cache)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Flatpak
|
||||
run: |
|
||||
set -e
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y flatpak flatpak-builder
|
||||
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak install -y --user flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
|
||||
flatpak install -y --user flathub org.electronjs.Electron2.BaseApp//25.08
|
||||
|
||||
- name: Cache pip wheel cache
|
||||
uses: actions/cache@v5
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: pip-wheel-${{ runner.os }}-x64-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
restore-keys: |
|
||||
pip-wheel-${{ runner.os }}-x64-
|
||||
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 Linux
|
||||
run: |
|
||||
@@ -443,9 +311,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
|
||||
@@ -454,53 +319,9 @@ jobs:
|
||||
path: |
|
||||
apps/frontend/dist/*.AppImage
|
||||
apps/frontend/dist/*.deb
|
||||
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,114 +333,25 @@ 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" \) -exec cp {} release-assets/ \;
|
||||
|
||||
# Validate that at least one artifact was copied
|
||||
artifact_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" -o -name "*.flatpak" \) | wc -l)
|
||||
artifact_count=$(find release-assets -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \) | wc -l)
|
||||
if [ "$artifact_count" -eq 0 ]; then
|
||||
echo "::error::No build artifacts found! Expected .dmg, .zip, .exe, .AppImage, .deb, or .flatpak files."
|
||||
echo "::error::No build artifacts found! Expected .dmg, .zip, .exe, .AppImage, or .deb files."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
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 +386,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
|
||||
find dist -type f \( -name "*.dmg" -o -name "*.zip" -o -name "*.exe" -o -name "*.AppImage" -o -name "*.deb" \) >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
echo "### 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
|
||||
|
||||
|
||||
+56
-112
@@ -1,105 +1,64 @@
|
||||
# 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 }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
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
|
||||
if: matrix.python-version != '3.12'
|
||||
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 +66,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,44 +3,24 @@ 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
|
||||
- name: Install ruff
|
||||
run: pip install ruff==0.14.10
|
||||
run: pip install ruff
|
||||
|
||||
- name: Run ruff check
|
||||
run: ruff check apps/backend/ --output-format=github
|
||||
@@ -48,42 +28,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, Quality DCO, 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)',
|
||||
// Quality DCO workflow (quality-dco.yml) - 1 check
|
||||
'Quality DCO / DCO Check (pull_request)',
|
||||
// Quality Commit Lint workflow (quality-commit-lint.yml) - 1 check
|
||||
'Quality Commit Lint / Conventional Commits (pull_request)'
|
||||
];
|
||||
|
||||
const statusLabels = {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
name: Quality Commit Lint
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
types: [opened, edited, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: Conventional Commits
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Validate PR title
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
retries: 3
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
const title = pr.title;
|
||||
// Sanitize title for safe markdown interpolation (prevent injection)
|
||||
const sanitizedTitle = title.replace(/`/g, "'").replace(/\[/g, '\\[').replace(/\]/g, '\\]');
|
||||
|
||||
console.log(`::group::PR #${pr.number} - Validating PR title`);
|
||||
console.log(`Title: ${title}`);
|
||||
|
||||
// Conventional Commits pattern for PR title
|
||||
// type(scope)?: description (max 100 chars)
|
||||
// Optional ! for breaking changes: feat!: or feat(scope)!:
|
||||
// Scope allows: letters, numbers, hyphens, underscores, slashes, dots
|
||||
const pattern = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-zA-Z0-9_\-\/\.]+\))?!?: .{1,100}$/;
|
||||
|
||||
const isValid = pattern.test(title);
|
||||
console.log(`Valid: ${isValid}`);
|
||||
console.log('::endgroup::');
|
||||
|
||||
if (!isValid) {
|
||||
// Log helpful error message to console (visible in workflow logs)
|
||||
console.log('');
|
||||
console.log('❌ PR title does not follow Conventional Commits format');
|
||||
console.log('');
|
||||
console.log('Expected format: type(scope): description');
|
||||
console.log('');
|
||||
console.log('Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert');
|
||||
console.log('');
|
||||
console.log('Examples of valid PR titles:');
|
||||
console.log(' ✓ feat(auth): add OAuth2 login support');
|
||||
console.log(' ✓ fix(api): handle null response correctly');
|
||||
console.log(' ✓ docs: update README installation steps');
|
||||
console.log(' ✓ chore: update dependencies');
|
||||
console.log('');
|
||||
console.log(`Your title: "${title}"`);
|
||||
console.log('');
|
||||
console.log('Suggested fix for your title:');
|
||||
// Try to suggest a fix based on the title
|
||||
const lowerTitle = title.toLowerCase();
|
||||
const placeholder = '[add description here]';
|
||||
if (lowerTitle.includes('fix') || lowerTitle.includes('bug')) {
|
||||
const cleaned = title.replace(/^(fix(ed|es|ing)?|bug)[:\s]*/i, '').trim();
|
||||
console.log(` → fix: ${cleaned || placeholder}`);
|
||||
} else if (lowerTitle.includes('add') || lowerTitle.includes('new') || lowerTitle.includes('feature')) {
|
||||
const cleaned = title.replace(/^(add(ed|s|ing)?|new|features?)[:\s]*/i, '').trim();
|
||||
console.log(` → feat: ${cleaned || placeholder}`);
|
||||
} else if (lowerTitle.includes('update') || lowerTitle.includes('change')) {
|
||||
const cleaned = title.replace(/^(update[ds]?|chang(ed|es|ing)?)[:\s]*/i, '').trim();
|
||||
console.log(` → chore: ${cleaned || placeholder}`);
|
||||
} else if (lowerTitle.includes('doc') || lowerTitle.includes('readme')) {
|
||||
const cleaned = title.replace(/^(docs?|readme)[:\s]*/i, '').trim();
|
||||
console.log(` → docs: ${cleaned || placeholder}`);
|
||||
} else {
|
||||
console.log(` → feat: ${title}`);
|
||||
console.log(` → fix: ${title}`);
|
||||
console.log(` → chore: ${title}`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
let errorMsg = '## ❌ PR Title Validation Failed\n\n';
|
||||
errorMsg += `Your PR title does not follow [Conventional Commits](https://www.conventionalcommits.org/) format:\n\n`;
|
||||
errorMsg += `> \`${sanitizedTitle}\`\n\n`;
|
||||
errorMsg += '### Expected Format\n\n';
|
||||
errorMsg += '```\ntype(scope): description\n```\n\n';
|
||||
errorMsg += '| Type | Description |\n';
|
||||
errorMsg += '|------|-------------|\n';
|
||||
errorMsg += '| `feat` | New feature |\n';
|
||||
errorMsg += '| `fix` | Bug fix |\n';
|
||||
errorMsg += '| `docs` | Documentation only |\n';
|
||||
errorMsg += '| `style` | Code style (formatting, etc.) |\n';
|
||||
errorMsg += '| `refactor` | Code refactoring |\n';
|
||||
errorMsg += '| `perf` | Performance improvement |\n';
|
||||
errorMsg += '| `test` | Adding/updating tests |\n';
|
||||
errorMsg += '| `build` | Build system changes |\n';
|
||||
errorMsg += '| `ci` | CI/CD changes |\n';
|
||||
errorMsg += '| `chore` | Maintenance tasks |\n';
|
||||
errorMsg += '| `revert` | Reverting changes |\n\n';
|
||||
errorMsg += '### Examples\n\n';
|
||||
errorMsg += '```\nfeat(auth): add OAuth2 login support\nfix(api/users): handle null response correctly\nfix(package.json): update dependencies\ndocs: update README installation steps\nci: add automated release workflow\n```\n\n';
|
||||
errorMsg += '### How to Fix\n\n';
|
||||
errorMsg += 'Edit your PR title to follow the format above.\n';
|
||||
|
||||
core.summary.addRaw(errorMsg);
|
||||
await core.summary.write();
|
||||
|
||||
core.setFailed('PR title does not follow Conventional Commits format');
|
||||
} else {
|
||||
console.log(`✅ PR title follows Conventional Commits format`);
|
||||
|
||||
core.summary
|
||||
.addHeading('✅ PR Title Valid', 3)
|
||||
.addRaw(`PR title follows Conventional Commits format: \`${sanitizedTitle}\``);
|
||||
await core.summary.write();
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
name: Quality DCO
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: DCO Check
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check DCO Sign-off
|
||||
uses: dcoapp/dco-check@v1
|
||||
with:
|
||||
require-signoff: true
|
||||
|
||||
- name: DCO Help on Failure
|
||||
if: failure()
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const helpMsg = `## ❌ DCO Sign-off Required
|
||||
|
||||
This project requires all commits to be signed off with the Developer Certificate of Origin (DCO).
|
||||
|
||||
### How to Fix
|
||||
|
||||
**Option 1: Sign off your last commit**
|
||||
\`\`\`bash
|
||||
git commit --amend --signoff
|
||||
git push --force-with-lease
|
||||
\`\`\`
|
||||
|
||||
**Option 2: Sign off all commits in this PR**
|
||||
\`\`\`bash
|
||||
git rebase HEAD~N --signoff # Replace N with number of commits
|
||||
git push --force-with-lease
|
||||
\`\`\`
|
||||
|
||||
**Option 3: Configure git to always sign off**
|
||||
\`\`\`bash
|
||||
git config --global format.signoff true
|
||||
\`\`\`
|
||||
|
||||
### What is DCO?
|
||||
|
||||
The [Developer Certificate of Origin](https://developercertificate.org/) is a lightweight way for contributors to certify that they wrote or have the right to submit the code they are contributing.
|
||||
|
||||
By signing off, you agree to the DCO terms:
|
||||
- The contribution was created by you
|
||||
- You have the right to submit it under the project's license
|
||||
- You understand the contribution is public and recorded
|
||||
|
||||
### Sign-off Format
|
||||
|
||||
Your commit message should end with:
|
||||
\`\`\`
|
||||
Signed-off-by: Your Name <your.email@example.com>
|
||||
\`\`\`
|
||||
|
||||
This line is automatically added when you use \`git commit -s\` or \`git commit --signoff\`.
|
||||
`;
|
||||
|
||||
core.summary.addRaw(helpMsg);
|
||||
await core.summary.write();
|
||||
@@ -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);
|
||||
|
||||
+293
-439
@@ -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,42 @@ 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: 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
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
path: ~/Library/Caches/pip
|
||||
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
|
||||
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-rust-${{ hashFiles('apps/backend/requirements.txt') }}
|
||||
key: python-bundle-${{ runner.os }}-x64-3.12.8
|
||||
restore-keys: |
|
||||
python-bundle-${{ runner.os }}-x64-3.12.8-rust-
|
||||
python-bundle-${{ runner.os }}-x64-
|
||||
|
||||
- name: Build application
|
||||
run: cd apps/frontend && npm run build
|
||||
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 +60,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 +90,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 +137,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 +167,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 +221,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,51 +228,43 @@ 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 Flatpak
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y flatpak flatpak-builder
|
||||
flatpak remote-add --user --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
|
||||
flatpak install -y --user flathub org.freedesktop.Platform//25.08 org.freedesktop.Sdk//25.08
|
||||
flatpak install -y --user flathub org.electronjs.Electron2.BaseApp//25.08
|
||||
|
||||
- name: Cache pip wheel cache
|
||||
uses: actions/cache@v5
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
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
|
||||
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 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
|
||||
@@ -390,54 +273,9 @@ jobs:
|
||||
path: |
|
||||
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 +285,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" \) -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" \) | wc -l)
|
||||
if [ "$artifact_count" -eq 0 ]; then
|
||||
echo "::error::No build artifacts found! Expected .dmg, .zip, .exe, .AppImage, or .deb 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 +310,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}; 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 +461,23 @@ jobs:
|
||||
cat release-assets/checksums.sha256 >> $GITHUB_STEP_SUMMARY
|
||||
echo "\`\`\`" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
- name: Extract changelog from CHANGELOG.md
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
- name: Generate changelog
|
||||
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
|
||||
id: changelog
|
||||
run: |
|
||||
# Extract version from tag (v2.7.2 -> 2.7.2)
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
CHANGELOG_FILE="CHANGELOG.md"
|
||||
|
||||
echo "📋 Extracting release notes for version $VERSION from CHANGELOG.md..."
|
||||
|
||||
if [ ! -f "$CHANGELOG_FILE" ]; then
|
||||
echo "::warning::CHANGELOG.md not found, using minimal release notes"
|
||||
echo "body=Release v$VERSION" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Extract changelog section for this version
|
||||
# Looks for "## X.Y.Z" header and captures until next "## " or "---"
|
||||
CHANGELOG_CONTENT=$(awk -v ver="$VERSION" '
|
||||
BEGIN { found=0; content="" }
|
||||
/^## / {
|
||||
if (found) exit
|
||||
# Match version at start of header (e.g., "## 2.7.3 -" or "## 2.7.3")
|
||||
if ($2 == ver || $2 ~ "^"ver"[[:space:]]*-") {
|
||||
found=1
|
||||
next
|
||||
}
|
||||
}
|
||||
/^---$/ { if (found) exit }
|
||||
found { content = content $0 "\n" }
|
||||
END {
|
||||
if (!found) {
|
||||
print "NOT_FOUND"
|
||||
exit 0
|
||||
}
|
||||
# Trim leading/trailing whitespace
|
||||
gsub(/^[[:space:]]+|[[:space:]]+$/, "", content)
|
||||
print content
|
||||
}
|
||||
' "$CHANGELOG_FILE")
|
||||
|
||||
if [ "$CHANGELOG_CONTENT" = "NOT_FOUND" ] || [ -z "$CHANGELOG_CONTENT" ]; then
|
||||
echo "::warning::Version $VERSION not found in CHANGELOG.md, using minimal release notes"
|
||||
REPO="${{ github.repository }}"
|
||||
CHANGELOG_CONTENT="Release v$VERSION"$'\n\n'"See [CHANGELOG.md](https://github.com/${REPO}/blob/main/CHANGELOG.md) for details."
|
||||
fi
|
||||
|
||||
echo "✅ Extracted changelog content"
|
||||
|
||||
# Save to file first (more reliable for multiline)
|
||||
echo "$CHANGELOG_CONTENT" > changelog-body.md
|
||||
|
||||
# Use file-based output for multiline content
|
||||
{
|
||||
echo "body<<CHANGELOG_EOF"
|
||||
cat changelog-body.md
|
||||
echo "CHANGELOG_EOF"
|
||||
} >> $GITHUB_OUTPUT
|
||||
uses: release-drafter/release-drafter@v6
|
||||
with:
|
||||
config-name: release-drafter.yml
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create Release
|
||||
if: ${{ github.event_name == 'push' }}
|
||||
if: ${{ github.event_name == 'push' || (github.event_name == 'workflow_dispatch' && inputs.dry_run != true) }}
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
body: |
|
||||
${{ steps.changelog.outputs.body }}
|
||||
|
||||
---
|
||||
|
||||
**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,46 +488,35 @@ 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
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: |
|
||||
# Extract version from tag (v2.7.2 -> 2.7.2)
|
||||
VERSION=${GITHUB_REF_NAME#v}
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
# Detect if this is a prerelease (contains - after version, e.g., 2.7.2-beta.10)
|
||||
if [[ "$VERSION" == *-* ]]; then
|
||||
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||
echo "Detected PRERELEASE: $VERSION"
|
||||
else
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
echo "Detected STABLE release: $VERSION"
|
||||
fi
|
||||
echo "Updating README to version: $VERSION"
|
||||
|
||||
- name: Update README.md
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
IS_PRERELEASE="${{ steps.version.outputs.is_prerelease }}"
|
||||
|
||||
if [ "$IS_PRERELEASE" = "true" ]; then
|
||||
python3 scripts/update-readme.py "$VERSION" --prerelease
|
||||
else
|
||||
python3 scripts/update-readme.py "$VERSION"
|
||||
fi
|
||||
# Update version badge: version-X.Y.Z-blue
|
||||
sed -i "s/version-[0-9]*\.[0-9]*\.[0-9]*-blue/version-${VERSION}-blue/g" README.md
|
||||
|
||||
echo "--- Verifying update ---"
|
||||
grep -E "(stable-|beta-|version-)[0-9]" README.md | head -5
|
||||
# Update download links: Auto-Claude-X.Y.Z
|
||||
sed -i "s/Auto-Claude-[0-9]*\.[0-9]*\.[0-9]*/Auto-Claude-${VERSION}/g" README.md
|
||||
|
||||
echo "README.md updated to version $VERSION"
|
||||
grep -E "(version-|Auto-Claude-)" README.md | head -10
|
||||
|
||||
- name: Commit and push README update
|
||||
run: |
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
with:
|
||||
stale-issue-message: |
|
||||
This issue has been inactive for 60 days. It will be closed in 14 days if there's no activity.
|
||||
|
||||
|
||||
- If this is still relevant, please comment or update the issue
|
||||
- If you're working on this, add the `in-progress` label
|
||||
close-issue-message: 'Closed due to inactivity. Feel free to reopen if still relevant.'
|
||||
|
||||
@@ -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
|
||||
|
||||
+4
-12
@@ -1,16 +1,12 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Commit message validation
|
||||
# Enforces conventional commit format: type(scope)!?: description
|
||||
# Enforces conventional commit format: type(scope): description
|
||||
#
|
||||
# Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
|
||||
# Scope allows: letters, numbers, hyphens, underscores, slashes, dots
|
||||
# Optional ! for breaking changes
|
||||
# Examples:
|
||||
# feat(tasks): add drag and drop support
|
||||
# fix(terminal): resolve scroll position issue
|
||||
# feat!: breaking change without scope
|
||||
# feat(api)!: breaking change with scope
|
||||
# docs: update README with setup instructions
|
||||
# chore: update dependencies
|
||||
|
||||
@@ -18,10 +14,8 @@ commit_msg_file=$1
|
||||
commit_msg=$(cat "$commit_msg_file")
|
||||
|
||||
# Regex for conventional commits
|
||||
# Format: type(optional-scope)!?: description
|
||||
# Scope allows: letters, numbers, hyphens, underscores, slashes, dots (consistent with GitHub workflow)
|
||||
# Optional ! for breaking changes: feat!: or feat(scope)!:
|
||||
pattern="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-zA-Z0-9_/.-]+\))?!?: .{1,100}$"
|
||||
# Format: type(optional-scope): description
|
||||
pattern="^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([a-z0-9-]+\))?: .{1,100}$"
|
||||
|
||||
# Allow merge commits
|
||||
if echo "$commit_msg" | grep -qE "^Merge "; then
|
||||
@@ -42,7 +36,7 @@ if ! echo "$first_line" | grep -qE "$pattern"; then
|
||||
echo ""
|
||||
echo "Your message: $first_line"
|
||||
echo ""
|
||||
echo "Expected format: type(scope)!?: description"
|
||||
echo "Expected format: type(scope): description"
|
||||
echo ""
|
||||
echo "Valid types:"
|
||||
echo " feat - A new feature"
|
||||
@@ -60,8 +54,6 @@ if ! echo "$first_line" | grep -qE "$pattern"; then
|
||||
echo "Examples:"
|
||||
echo " feat(tasks): add drag and drop support"
|
||||
echo " fix(terminal): resolve scroll position issue"
|
||||
echo " feat!: breaking change without scope"
|
||||
echo " feat(api)!: breaking change with scope"
|
||||
echo " docs: update README"
|
||||
echo " chore: update dependencies"
|
||||
echo ""
|
||||
|
||||
+61
-214
@@ -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,12 @@ if git diff --cached --name-only | grep -q "^package.json$"; then
|
||||
echo " Updated apps/backend/__init__.py to $VERSION"
|
||||
fi
|
||||
|
||||
# Sync to README.md - section-aware updates (stable vs beta)
|
||||
# Sync to README.md
|
||||
if [ -f "README.md" ]; then
|
||||
# Escape hyphens for shields.io badge format (shields.io uses -- for literal hyphens)
|
||||
ESCAPED_VERSION=$(echo "$VERSION" | sed 's/-/--/g')
|
||||
|
||||
# Detect if this is a prerelease (contains - after base version, e.g., 2.7.2-beta.10)
|
||||
if echo "$VERSION" | grep -q '-'; then
|
||||
# PRERELEASE: Update only beta sections
|
||||
echo " Detected PRERELEASE version: $VERSION"
|
||||
|
||||
# Update beta version badge (orange)
|
||||
sed -i.bak "s/beta-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-orange/beta-$ESCAPED_VERSION-orange/g" README.md
|
||||
|
||||
# Update beta version badge link (within BETA_VERSION_BADGE section)
|
||||
sed -i.bak '/<!-- BETA_VERSION_BADGE -->/,/<!-- BETA_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
|
||||
|
||||
# Update beta download links (within BETA_DOWNLOADS section only)
|
||||
# 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
|
||||
sed -i.bak "s/version-[0-9]*\.[0-9]*\.[0-9]*-blue/version-$VERSION-blue/g" README.md
|
||||
# Update download links
|
||||
sed -i.bak "s/Auto-Claude-[0-9]*\.[0-9]*\.[0-9]*/Auto-Claude-$VERSION/g" README.md
|
||||
rm -f README.md.bak
|
||||
git add README.md
|
||||
echo " Updated README.md to $VERSION"
|
||||
@@ -154,68 +70,48 @@ 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 +122,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..."
|
||||
|
||||
# Detect if we're in a worktree and check if dependencies are available
|
||||
IS_WORKTREE=false
|
||||
DEPS_AVAILABLE=true
|
||||
|
||||
if [ -f ".git" ]; then
|
||||
# .git is a file (not directory) in worktrees
|
||||
IS_WORKTREE=true
|
||||
echo "Detected git worktree environment"
|
||||
cd apps/frontend
|
||||
|
||||
# Run lint-staged (handles staged .ts/.tsx files)
|
||||
npm exec lint-staged
|
||||
|
||||
# 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!"
|
||||
|
||||
+22
-136
@@ -1,95 +1,29 @@
|
||||
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
|
||||
name: Version Sync
|
||||
entry: bash
|
||||
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
|
||||
|
||||
# Sync to apps/frontend/package.json
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const p = require('./apps/frontend/package.json');
|
||||
const v = process.argv[1];
|
||||
if (p.version !== v) {
|
||||
p.version = v;
|
||||
fs.writeFileSync('./apps/frontend/package.json', JSON.stringify(p, null, 2) + '\n');
|
||||
}
|
||||
" "$VERSION"
|
||||
|
||||
# Sync to apps/backend/__init__.py
|
||||
sed -i.bak "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" apps/backend/__init__.py && rm -f apps/backend/__init__.py.bak
|
||||
|
||||
# Sync to README.md - section-aware updates (stable vs beta)
|
||||
ESCAPED_VERSION=$(echo "$VERSION" | sed 's/-/--/g')
|
||||
|
||||
# Detect if this is a prerelease (contains - after base version)
|
||||
if echo "$VERSION" | grep -q '-'; then
|
||||
# PRERELEASE: Update only beta sections
|
||||
echo " Detected PRERELEASE version: $VERSION"
|
||||
|
||||
# Update beta version badge (orange)
|
||||
sed -i.bak "s/beta-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-orange/beta-$ESCAPED_VERSION-orange/g" README.md
|
||||
|
||||
# Update beta version badge link
|
||||
sed -i.bak '/<!-- BETA_VERSION_BADGE -->/,/<!-- BETA_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
|
||||
|
||||
# Update beta download links (within BETA_DOWNLOADS section only)
|
||||
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb" "linux-x86_64.flatpak"; do
|
||||
sed -i.bak '/<!-- BETA_DOWNLOADS -->/,/<!-- BETA_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
|
||||
done
|
||||
else
|
||||
# STABLE: Update stable sections and top badge
|
||||
echo " Detected STABLE version: $VERSION"
|
||||
|
||||
# Update top version badge (blue)
|
||||
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s/version-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/version-'"$ESCAPED_VERSION"'-blue/g' README.md
|
||||
sed -i.bak '/<!-- TOP_VERSION_BADGE -->/,/<!-- TOP_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
|
||||
|
||||
# Update stable version badge (blue)
|
||||
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s/stable-[0-9]*\.[0-9]*\.[0-9]*\(--[a-z]*\.[0-9]*\)*-blue/stable-'"$ESCAPED_VERSION"'-blue/g' README.md
|
||||
sed -i.bak '/<!-- STABLE_VERSION_BADGE -->/,/<!-- STABLE_VERSION_BADGE_END -->/s|releases/tag/v[0-9.a-z-]*)|releases/tag/v'"$VERSION"')|g' README.md
|
||||
|
||||
# Update stable download links (within STABLE_DOWNLOADS section only)
|
||||
for SUFFIX in "win32-x64.exe" "darwin-arm64.dmg" "darwin-x64.dmg" "linux-x86_64.AppImage" "linux-amd64.deb"; do
|
||||
sed -i.bak '/<!-- STABLE_DOWNLOADS -->/,/<!-- STABLE_DOWNLOADS_END -->/{s|Auto-Claude-[0-9.a-z-]*-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v[^/]*/Auto-Claude-[^)]*-'"$SUFFIX"')|Auto-Claude-'"$VERSION"'-'"$SUFFIX"'](https://github.com/AndyMik90/Auto-Claude/releases/download/v'"$VERSION"'/Auto-Claude-'"$VERSION"'-'"$SUFFIX"')|g}' README.md
|
||||
done
|
||||
fi
|
||||
rm -f README.md.bak
|
||||
|
||||
# Stage changes
|
||||
git add apps/frontend/package.json apps/backend/__init__.py README.md 2>/dev/null || true
|
||||
fi
|
||||
entry: bash -c '
|
||||
VERSION=$(node -p "require(\"./package.json\").version");
|
||||
if [ -n "$VERSION" ]; then
|
||||
# Sync to apps/frontend/package.json
|
||||
node -e "const fs=require(\"fs\");const p=require(\"./apps/frontend/package.json\");if(p.version!==\"$VERSION\"){p.version=\"$VERSION\";fs.writeFileSync(\"./apps/frontend/package.json\",JSON.stringify(p,null,2)+\"\n\");}";
|
||||
# Sync to apps/backend/__init__.py
|
||||
sed -i.bak "s/__version__ = \"[^\"]*\"/__version__ = \"$VERSION\"/" apps/backend/__init__.py && rm -f apps/backend/__init__.py.bak;
|
||||
# Sync to README.md
|
||||
sed -i.bak "s/version-[0-9]*\.[0-9]*\.[0-9]*-blue/version-$VERSION-blue/g" README.md;
|
||||
sed -i.bak "s/Auto-Claude-[0-9]*\.[0-9]*\.[0-9]*/Auto-Claude-$VERSION/g" README.md && rm -f README.md.bak;
|
||||
git add apps/frontend/package.json apps/backend/__init__.py README.md 2>/dev/null || true;
|
||||
fi
|
||||
'
|
||||
language: system
|
||||
files: ^package\.json$
|
||||
pass_filenames: false
|
||||
|
||||
# Python 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
|
||||
rev: v0.8.3
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix]
|
||||
@@ -99,83 +33,35 @@ 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
|
||||
name: Python Tests
|
||||
entry: bash
|
||||
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"
|
||||
elif [ -f ".venv/Scripts/pytest.exe" ]; then
|
||||
PYTEST_CMD=".venv/Scripts/pytest.exe"
|
||||
else
|
||||
PYTEST_CMD="python -m pytest"
|
||||
fi
|
||||
PYTHONPATH=. $PYTEST_CMD \
|
||||
../../tests/ \
|
||||
-v \
|
||||
--tb=short \
|
||||
-x \
|
||||
-m "not slow and not integration" \
|
||||
--ignore=../../tests/test_graphiti.py \
|
||||
--ignore=../../tests/test_merge_file_tracker.py \
|
||||
--ignore=../../tests/test_service_orchestrator.py \
|
||||
--ignore=../../tests/test_worktree.py \
|
||||
--ignore=../../tests/test_workspace.py
|
||||
entry: bash -c 'cd apps/backend && PYTHONPATH=. python -m pytest ../../tests/ -v --tb=short -x -m "not slow and not integration" --ignore=../../tests/test_graphiti.py --ignore=../../tests/test_merge_file_tracker.py --ignore=../../tests/test_service_orchestrator.py --ignore=../../tests/test_worktree.py --ignore=../../tests/test_workspace.py'
|
||||
language: system
|
||||
files: ^(apps/backend/.*\.py$|tests/.*\.py$)
|
||||
pass_filenames: false
|
||||
|
||||
# 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
|
||||
|
||||
# General checks
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
|
||||
-1336
File diff suppressed because it is too large
Load Diff
@@ -1,76 +0,0 @@
|
||||
# Auto Claude Individual Contributor License Agreement
|
||||
|
||||
Thank you for your interest in contributing to Auto Claude. This Contributor License Agreement ("Agreement") documents the rights granted by contributors to the Project.
|
||||
|
||||
By signing this Agreement, you accept and agree to the following terms and conditions for your present and future Contributions submitted to the Project.
|
||||
|
||||
## 1. Definitions
|
||||
|
||||
**"You" (or "Your")** means the individual who submits a Contribution to the Project.
|
||||
|
||||
**"Contribution"** means any original work of authorship, including any modifications or additions to an existing work, that is intentionally submitted by You to the Project for inclusion in, or documentation of, the Project. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Project or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Project for the purpose of discussing and improving the Project.
|
||||
|
||||
**"Project"** means Auto Claude, a multi-agent autonomous coding framework, currently available at https://github.com/AndyMik90/Auto-Claude.
|
||||
|
||||
**"Project Owner"** means Andre Mikalsen and any designated successors or assignees.
|
||||
|
||||
## 2. Grant of Copyright License
|
||||
|
||||
Subject to the terms and conditions of this Agreement, You hereby grant to the Project Owner and to recipients of software distributed by the Project Owner a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to:
|
||||
|
||||
- Reproduce, prepare derivative works of, publicly display, publicly perform, and distribute Your Contributions and such derivative works
|
||||
- Sublicense any or all of the foregoing rights to third parties
|
||||
|
||||
## 3. Grant of Patent License
|
||||
|
||||
Subject to the terms and conditions of this Agreement, You hereby grant to the Project Owner and to recipients of software distributed by the Project Owner a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer Your Contributions, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Project to which such Contribution(s) was submitted.
|
||||
|
||||
## 4. Future Licensing Flexibility
|
||||
|
||||
You understand and agree that the Project Owner may, in the future, license the Project, including Your Contributions, under additional licenses beyond the current GNU Affero General Public License version 3.0 (AGPL-3.0). Such additional licenses may include commercial or enterprise licenses.
|
||||
|
||||
This provision ensures the Project has proper licensing flexibility should such licensing options be introduced in the future. The open source version of the Project will continue to be available under AGPL-3.0.
|
||||
|
||||
## 5. Representations
|
||||
|
||||
You represent that:
|
||||
|
||||
(a) You are legally entitled to grant the above licenses. If your employer(s) has rights to intellectual property that you create that includes your Contributions, you represent that you have received permission to make Contributions on behalf of that employer, or that your employer has waived such rights for your Contributions to the Project.
|
||||
|
||||
(b) Each of Your Contributions is Your original creation. You represent that Your Contribution submissions include complete details of any third-party license or other restriction (including, but not limited to, related patents and trademarks) of which you are personally aware and which are associated with any part of Your Contributions.
|
||||
|
||||
(c) Your Contribution does not violate any third-party rights, including but not limited to intellectual property rights, privacy rights, or contractual obligations.
|
||||
|
||||
## 6. Support and Warranty Disclaimer
|
||||
|
||||
You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all.
|
||||
|
||||
UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING, YOU PROVIDE YOUR CONTRIBUTIONS ON AN "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, OR FITNESS FOR A PARTICULAR PURPOSE.
|
||||
|
||||
## 7. No Obligation to Use
|
||||
|
||||
You understand that the decision to include Your Contribution in any project or source repository is entirely at the discretion of the Project Owner, and this Agreement does not guarantee that Your Contributions will be included in any product.
|
||||
|
||||
## 8. Contributor Rights
|
||||
|
||||
You retain full copyright ownership of Your Contributions. Nothing in this Agreement shall be interpreted to prohibit you from licensing Your Contributions under different terms to third parties or from using Your Contributions for any other purpose.
|
||||
|
||||
## 9. Notification
|
||||
|
||||
You agree to notify the Project Owner of any facts or circumstances of which you become aware that would make these representations inaccurate in any respect.
|
||||
|
||||
---
|
||||
|
||||
## How to Sign
|
||||
|
||||
To sign this CLA, comment on your Pull Request with:
|
||||
|
||||
```
|
||||
I have read the CLA Document and I hereby sign the CLA
|
||||
```
|
||||
|
||||
Your signature will be recorded automatically.
|
||||
|
||||
---
|
||||
|
||||
*This CLA is based on the Apache Software Foundation Individual Contributor License Agreement v2.0.*
|
||||
@@ -1,328 +1,481 @@
|
||||
# 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.
|
||||
### Security Model
|
||||
|
||||
**Platform modules:** `apps/frontend/src/main/platform/` and `apps/backend/core/platform/`
|
||||
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)
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `isWindows()` / `isMacOS()` / `isLinux()` | OS detection |
|
||||
| `getPathDelimiter()` | `;` (Win) or `:` (Unix) |
|
||||
| `findExecutable(name)` | Cross-platform executable lookup |
|
||||
| `requiresShell(command)` | `.cmd/.bat` shell detection (Win) |
|
||||
Security profile cached in `.auto-claude-security.json`.
|
||||
|
||||
Never hardcode paths. Use `findExecutable()` and `joinPaths()`. See [ARCHITECTURE.md](shared_docs/ARCHITECTURE.md#cross-platform-development) for extended guide.
|
||||
### Claude Agent SDK Integration
|
||||
|
||||
## E2E Testing (Electron MCP)
|
||||
**CRITICAL: Auto Claude uses the Claude Agent SDK for ALL AI interactions. Never use the Anthropic API directly.**
|
||||
|
||||
QA agents can interact with the running Electron app via Chrome DevTools Protocol:
|
||||
**Client Location:** `apps/backend/core/client.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`
|
||||
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
|
||||
|
||||
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.
|
||||
**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
-275
@@ -2,41 +2,19 @@
|
||||
|
||||
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)
|
||||
@@ -45,30 +23,10 @@ AI-assisted PRs go through the same review process as any other contribution. Tr
|
||||
- [Pull Request Targets](#pull-request-targets)
|
||||
- [Release Process](#release-process-maintainers)
|
||||
- [Commit Messages](#commit-messages)
|
||||
- [PR Hygiene](#pr-hygiene)
|
||||
- [Pull Request Process](#pull-request-process)
|
||||
- [Issue Reporting](#issue-reporting)
|
||||
- [Architecture Overview](#architecture-overview)
|
||||
|
||||
## Contributor License Agreement (CLA)
|
||||
|
||||
All contributors must sign our Contributor License Agreement (CLA) before contributions can be accepted.
|
||||
|
||||
### Why We Require a CLA
|
||||
|
||||
Auto Claude is currently licensed under AGPL-3.0. The CLA ensures the project has proper licensing flexibility should we introduce additional licensing options (such as commercial/enterprise licenses) in the future.
|
||||
|
||||
You retain full copyright ownership of your contributions.
|
||||
|
||||
### How to Sign
|
||||
|
||||
1. Open a Pull Request
|
||||
2. The CLA bot will automatically comment with instructions
|
||||
3. Comment on the PR with: `I have read the CLA Document and I hereby sign the CLA`
|
||||
4. Done - you only need to sign once, and it applies to all future contributions
|
||||
|
||||
Read the full CLA here: [CLA.md](CLA.md)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before contributing, ensure you have the following installed:
|
||||
@@ -77,7 +35,6 @@ Before contributing, ensure you have the following installed:
|
||||
- **Node.js 24+** - For the Electron frontend
|
||||
- **npm 10+** - Package manager for the frontend (comes with Node.js)
|
||||
- **uv** (recommended) or **pip** - Python package manager
|
||||
- **CMake** - Required for building native dependencies (e.g., LadybugDB)
|
||||
- **Git** - Version control
|
||||
|
||||
### Installing Python 3.12
|
||||
@@ -97,56 +54,6 @@ brew install python@3.12
|
||||
sudo apt install python3.12 python3.12-venv
|
||||
```
|
||||
|
||||
**Linux (Fedora):**
|
||||
```bash
|
||||
sudo dnf install python3.12
|
||||
```
|
||||
|
||||
### Installing Node.js 24+
|
||||
|
||||
**Windows:**
|
||||
```bash
|
||||
winget install OpenJS.NodeJS.LTS
|
||||
```
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
brew install node@24
|
||||
```
|
||||
|
||||
**Linux (Ubuntu/Debian):**
|
||||
```bash
|
||||
curl -fsSL https://deb.nodesource.com/setup_24.x | sudo -E bash -
|
||||
sudo apt install -y nodejs
|
||||
```
|
||||
|
||||
**Linux (Fedora):**
|
||||
```bash
|
||||
sudo dnf install nodejs npm
|
||||
```
|
||||
|
||||
### Installing CMake
|
||||
|
||||
**Windows:**
|
||||
```bash
|
||||
winget install Kitware.CMake
|
||||
```
|
||||
|
||||
**macOS:**
|
||||
```bash
|
||||
brew install cmake
|
||||
```
|
||||
|
||||
**Linux (Ubuntu/Debian):**
|
||||
```bash
|
||||
sudo apt install cmake
|
||||
```
|
||||
|
||||
**Linux (Fedora):**
|
||||
```bash
|
||||
sudo dnf install cmake
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
The fastest way to get started:
|
||||
@@ -171,40 +78,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 +285,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 +357,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 +388,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
|
||||
|
||||
```
|
||||
@@ -760,41 +596,6 @@ git commit -m "WIP"
|
||||
- **body**: Detailed explanation if needed (wrap at 72 chars)
|
||||
- **footer**: Reference issues, breaking changes
|
||||
|
||||
### PR Hygiene
|
||||
|
||||
**Rebasing:**
|
||||
- **Rebase onto develop** before opening a PR and before merge to maintain linear history
|
||||
- Use `git fetch origin && git rebase origin/develop` to sync your branch
|
||||
- Use `--force-with-lease` when force-pushing rebased branches (safer than `--force`)
|
||||
- Notify reviewers after force-pushing during active review
|
||||
- **Exception:** Never rebase after PR is approved and others have reviewed specific commits
|
||||
|
||||
**Commit organization:**
|
||||
- **Squash fixup commits** (typos, "oops", review feedback) into their parent commits
|
||||
- **Keep logically distinct changes** as separate commits that could be reverted independently
|
||||
- Each commit should compile and pass tests independently
|
||||
- No "WIP", "fix tests", or "lint" commits in final PR - squash these
|
||||
|
||||
**Before requesting review:**
|
||||
```bash
|
||||
# Ensure up-to-date with develop
|
||||
git fetch origin && git rebase origin/develop
|
||||
|
||||
# Clean up commit history (squash fixups, reword messages)
|
||||
git rebase -i origin/develop
|
||||
|
||||
# Force push with safety check
|
||||
git push --force-with-lease
|
||||
|
||||
# Verify everything works
|
||||
npm run test:backend
|
||||
cd apps/frontend && npm test && npm run lint && npm run typecheck
|
||||
```
|
||||
|
||||
**PR size:**
|
||||
- Keep PRs small (<400 lines changed ideally)
|
||||
- Split large features into stacked PRs if possible
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. **Fork the repository** and create your branch from `develop` (not main!)
|
||||
|
||||
@@ -4,50 +4,24 @@
|
||||
|
||||

|
||||
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/latest)
|
||||
[](./agpl-3.0.txt)
|
||||
[](https://discord.gg/KCXaPBr4Dj)
|
||||
[](https://www.youtube.com/@AndreMikalsen)
|
||||
[](https://github.com/AndyMik90/Auto-Claude/actions)
|
||||
|
||||
---
|
||||
|
||||
## Download
|
||||
|
||||
### Stable Release
|
||||
Get the latest pre-built release for your platform:
|
||||
|
||||
<!-- STABLE_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.5)
|
||||
<!-- 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) |
|
||||
<!-- STABLE_DOWNLOADS_END -->
|
||||
|
||||
### Beta Release
|
||||
|
||||
> ⚠️ Beta releases may contain bugs and breaking changes. [View all releases](https://github.com/AndyMik90/Auto-Claude/releases)
|
||||
|
||||
<!-- BETA_VERSION_BADGE -->
|
||||
[](https://github.com/AndyMik90/Auto-Claude/releases/tag/v2.7.2-beta.10)
|
||||
<!-- BETA_VERSION_BADGE_END -->
|
||||
|
||||
<!-- BETA_DOWNLOADS -->
|
||||
| Platform | Download |
|
||||
|----------|----------|
|
||||
| **Windows** | [Auto-Claude-2.7.2-beta.10-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-win32-x64.exe) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.2-beta.10-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-darwin-arm64.dmg) |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.2-beta.10-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-darwin-x64.dmg) |
|
||||
| **Linux** | [Auto-Claude-2.7.2-beta.10-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-linux-x86_64.AppImage) |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.2-beta.10-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-linux-amd64.deb) |
|
||||
| **Linux (Flatpak)** | [Auto-Claude-2.7.2-beta.10-linux-x86_64.flatpak](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2-beta.10/Auto-Claude-2.7.2-beta.10-linux-x86_64.flatpak) |
|
||||
<!-- BETA_DOWNLOADS_END -->
|
||||
| Platform | Download | Notes |
|
||||
|----------|----------|-------|
|
||||
| **Windows** | [Auto-Claude-2.7.2.exe](https://github.com/AndyMik90/Auto-Claude/releases/latest) | Installer (NSIS) |
|
||||
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.2-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/latest) | M1/M2/M3 Macs |
|
||||
| **macOS (Intel)** | [Auto-Claude-2.7.2-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/latest) | Intel Macs |
|
||||
| **Linux** | [Auto-Claude-2.7.2.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/latest) | Universal |
|
||||
| **Linux (Debian)** | [Auto-Claude-2.7.2.deb](https://github.com/AndyMik90/Auto-Claude/releases/latest) | Ubuntu/Debian |
|
||||
|
||||
> All releases include SHA256 checksums and VirusTotal scan results for security verification.
|
||||
|
||||
@@ -58,6 +32,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
|
||||
|
||||
---
|
||||
|
||||
@@ -81,8 +56,6 @@
|
||||
| **Self-Validating QA** | Built-in quality assurance loop catches issues before you review |
|
||||
| **AI-Powered Merge** | Automatic conflict resolution when integrating back to main |
|
||||
| **Memory Layer** | Agents retain insights across sessions for smarter builds |
|
||||
| **GitHub/GitLab Integration** | Import issues, investigate with AI, create merge requests |
|
||||
| **Linear Integration** | Sync tasks with Linear for team progress tracking |
|
||||
| **Cross-Platform** | Native desktop apps for Windows, macOS, and Linux |
|
||||
| **Auto-Updates** | App updates automatically when new versions are released |
|
||||
|
||||
@@ -146,11 +119,47 @@ 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 |
|
||||
|
||||
---
|
||||
|
||||
## 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+
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for detailed development setup.
|
||||
|
||||
---
|
||||
|
||||
@@ -180,7 +189,6 @@ All releases are:
|
||||
| `npm run package:mac` | Package for macOS |
|
||||
| `npm run package:win` | Package for Windows |
|
||||
| `npm run package:linux` | Package for Linux |
|
||||
| `npm run package:flatpak` | Package as Flatpak (see [guides/linux.md](guides/linux.md)) |
|
||||
| `npm run lint` | Run linter |
|
||||
| `npm test` | Run frontend tests |
|
||||
| `npm run test:backend` | Run backend tests |
|
||||
@@ -212,11 +220,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
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
# Auto Claude uses Claude Code OAuth authentication.
|
||||
# Direct API keys (ANTHROPIC_API_KEY) are NOT supported to prevent silent billing.
|
||||
#
|
||||
# Option 1: Run `claude setup-token` to save token to system keychain (recommended)
|
||||
# (macOS: Keychain, Windows: Credential Manager, Linux: secret-service)
|
||||
# Option 1: Run `claude setup-token` to save token to macOS Keychain (recommended)
|
||||
# Option 2: Set the token explicitly:
|
||||
# CLAUDE_CODE_OAUTH_TOKEN=your-oauth-token-here
|
||||
#
|
||||
@@ -76,38 +75,6 @@
|
||||
# Pre-configured Project ID (OPTIONAL - will create project if not set)
|
||||
# LINEAR_PROJECT_ID=
|
||||
|
||||
# =============================================================================
|
||||
# GITLAB INTEGRATION (OPTIONAL)
|
||||
# =============================================================================
|
||||
# Enable GitLab integration for issue tracking and merge requests.
|
||||
# Supports both GitLab.com and self-hosted GitLab instances.
|
||||
#
|
||||
# Authentication Options (choose one):
|
||||
#
|
||||
# Option 1: glab CLI OAuth (Recommended)
|
||||
# Install glab CLI: https://gitlab.com/gitlab-org/cli#installation
|
||||
# Then run: glab auth login
|
||||
# This opens your browser for OAuth authentication. Once complete,
|
||||
# Auto Claude will automatically use your glab credentials (no env vars needed).
|
||||
# For self-hosted: glab auth login --hostname gitlab.example.com
|
||||
#
|
||||
# Option 2: Personal Access Token
|
||||
# Set GITLAB_TOKEN below. Token auth is used if set, otherwise falls back to glab CLI.
|
||||
|
||||
# GitLab Instance URL (OPTIONAL - defaults to gitlab.com)
|
||||
# For self-hosted: GITLAB_INSTANCE_URL=https://gitlab.example.com
|
||||
# GITLAB_INSTANCE_URL=https://gitlab.com
|
||||
|
||||
# GitLab Personal Access Token (OPTIONAL - only needed if not using glab CLI)
|
||||
# Required scope: api (covers issues, merge requests, releases, project info)
|
||||
# Optional scope: write_repository (only if creating new GitLab projects from local repos)
|
||||
# Get from: https://gitlab.com/-/user_settings/personal_access_tokens
|
||||
# GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# GitLab Project (OPTIONAL - format: group/project or numeric ID)
|
||||
# If not set, will auto-detect from git remote
|
||||
# GITLAB_PROJECT=mygroup/myproject
|
||||
|
||||
# =============================================================================
|
||||
# UI SETTINGS (OPTIONAL)
|
||||
# =============================================================================
|
||||
@@ -269,9 +236,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 +335,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"
|
||||
__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]
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Custom MCP Tools for Auto-Claude Agents
|
||||
========================================
|
||||
|
||||
DEPRECATED: This module is now a compatibility shim.
|
||||
Please import from the tools_pkg package instead:
|
||||
|
||||
from agents.tools_pkg import create_auto_claude_mcp_server, get_allowed_tools
|
||||
|
||||
This file remains for backward compatibility with existing imports.
|
||||
All functionality has been moved to the tools_pkg package for better
|
||||
organization and maintainability.
|
||||
"""
|
||||
|
||||
# Import everything from the package to maintain backward compatibility
|
||||
# Use try/except to handle both relative and absolute imports
|
||||
try:
|
||||
from .tools_pkg import (
|
||||
ELECTRON_TOOLS,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
create_auto_claude_mcp_server,
|
||||
get_allowed_tools,
|
||||
is_electron_mcp_enabled,
|
||||
is_tools_available,
|
||||
)
|
||||
except ImportError:
|
||||
# Fallback for direct execution - import from tools_pkg directly
|
||||
from tools_pkg import (
|
||||
ELECTRON_TOOLS,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
create_auto_claude_mcp_server,
|
||||
get_allowed_tools,
|
||||
is_electron_mcp_enabled,
|
||||
is_tools_available,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Main API
|
||||
"create_auto_claude_mcp_server",
|
||||
"get_allowed_tools",
|
||||
"is_tools_available",
|
||||
# Tool name constants
|
||||
"TOOL_UPDATE_SUBTASK_STATUS",
|
||||
"TOOL_GET_BUILD_PROGRESS",
|
||||
"TOOL_RECORD_DISCOVERY",
|
||||
"TOOL_RECORD_GOTCHA",
|
||||
"TOOL_GET_SESSION_CONTEXT",
|
||||
"TOOL_UPDATE_QA_STATUS",
|
||||
# Electron MCP
|
||||
"ELECTRON_TOOLS",
|
||||
"is_electron_mcp_enabled",
|
||||
]
|
||||
+12
-136
@@ -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
|
||||
@@ -19,7 +18,6 @@ from linear_updater import (
|
||||
linear_task_stuck,
|
||||
)
|
||||
from phase_config import get_phase_model, get_phase_thinking_budget
|
||||
from phase_event import ExecutionPhase, emit_phase
|
||||
from progress import (
|
||||
count_subtasks,
|
||||
count_subtasks_detailed,
|
||||
@@ -38,7 +36,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 +61,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 +89,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 +129,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(
|
||||
@@ -172,7 +146,6 @@ async def run_autonomous_agent(
|
||||
|
||||
# Update status for planning phase
|
||||
status_manager.update(state=BuildState.PLANNING)
|
||||
emit_phase(ExecutionPhase.PLANNING, "Creating implementation plan")
|
||||
is_planning_phase = True
|
||||
current_log_phase = LogPhase.PLANNING
|
||||
|
||||
@@ -200,9 +173,6 @@ async def run_autonomous_agent(
|
||||
if task_logger:
|
||||
task_logger.start_phase(LogPhase.CODING, "Continuing implementation...")
|
||||
|
||||
# Emit phase event when continuing build
|
||||
emit_phase(ExecutionPhase.CODING, "Continuing implementation")
|
||||
|
||||
# Show human intervention hint
|
||||
content = [
|
||||
bold("INTERACTIVE CONTROLS"),
|
||||
@@ -226,7 +196,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 +212,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 +252,16 @@ async def run_autonomous_agent(
|
||||
phase_thinking_budget = get_phase_thinking_budget(spec_dir, current_phase)
|
||||
|
||||
# Create client (fresh context) with phase-specific model and thinking
|
||||
# Use appropriate agent_type for correct tool permissions and thinking budget
|
||||
client = create_client(
|
||||
project_dir,
|
||||
spec_dir,
|
||||
phase_model,
|
||||
agent_type="planner" if first_run else "coder",
|
||||
max_thinking_tokens=phase_thinking_budget,
|
||||
)
|
||||
|
||||
# Generate appropriate prompt
|
||||
if first_run:
|
||||
prompt = generate_planner_prompt(spec_dir, project_dir)
|
||||
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,12 +270,9 @@ 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")
|
||||
if task_logger:
|
||||
task_logger.end_phase(
|
||||
LogPhase.PLANNING,
|
||||
@@ -334,41 +282,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 +340,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,18 +379,17 @@ 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
|
||||
if status == "complete":
|
||||
# Don't emit COMPLETE here - subtasks are done but QA hasn't run yet
|
||||
# QA loop will emit COMPLETE after actual approval
|
||||
print_build_complete_banner(spec_dir)
|
||||
status_manager.update(state=BuildState.COMPLETE)
|
||||
|
||||
# End coding phase in task logger
|
||||
if task_logger:
|
||||
task_logger.end_phase(
|
||||
LogPhase.CODING,
|
||||
@@ -519,6 +397,7 @@ async def run_autonomous_agent(
|
||||
message="All subtasks completed successfully",
|
||||
)
|
||||
|
||||
# Notify Linear that build is complete (moving to QA)
|
||||
if linear_task and linear_task.task_id:
|
||||
await linear_build_complete(spec_dir)
|
||||
print_status("Linear notified: build complete, ready for QA", "success")
|
||||
@@ -534,9 +413,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)
|
||||
@@ -555,7 +432,6 @@ async def run_autonomous_agent(
|
||||
await asyncio.sleep(AUTO_CONTINUE_DELAY_SECONDS)
|
||||
|
||||
elif status == "error":
|
||||
emit_phase(ExecutionPhase.FAILED, "Session encountered an error")
|
||||
print_status("Session encountered an error", "error")
|
||||
print(muted("Will retry with a fresh session..."))
|
||||
status_manager.update(state=BuildState.ERROR)
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -9,8 +9,7 @@ import logging
|
||||
from pathlib import Path
|
||||
|
||||
from core.client import create_client
|
||||
from phase_config import get_phase_model, get_phase_thinking_budget
|
||||
from phase_event import ExecutionPhase, emit_phase
|
||||
from phase_config import get_phase_thinking_budget
|
||||
from task_logger import (
|
||||
LogPhase,
|
||||
get_task_logger,
|
||||
@@ -68,7 +67,6 @@ async def run_followup_planner(
|
||||
# Initialize status manager for ccstatusline
|
||||
status_manager = StatusManager(project_dir)
|
||||
status_manager.set_active(spec_dir.name, BuildState.PLANNING)
|
||||
emit_phase(ExecutionPhase.PLANNING, "Follow-up planning")
|
||||
|
||||
# Initialize task logger for persistent logging
|
||||
task_logger = get_task_logger(spec_dir)
|
||||
@@ -91,14 +89,12 @@ async def run_followup_planner(
|
||||
task_logger.start_phase(LogPhase.PLANNING, "Starting follow-up planning...")
|
||||
task_logger.set_session(1)
|
||||
|
||||
# Create client with phase-specific model and thinking budget
|
||||
# Respects task_metadata.json configuration when no CLI override
|
||||
planning_model = get_phase_model(spec_dir, "planning", model)
|
||||
# Create client (fresh context) with planning phase thinking budget
|
||||
planning_thinking_budget = get_phase_thinking_budget(spec_dir, "planning")
|
||||
client = create_client(
|
||||
project_dir,
|
||||
spec_dir,
|
||||
planning_model,
|
||||
model,
|
||||
max_thinking_tokens=planning_thinking_budget,
|
||||
)
|
||||
|
||||
@@ -141,7 +137,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}",
|
||||
|
||||
@@ -30,32 +30,16 @@ Usage:
|
||||
"""
|
||||
|
||||
from .models import (
|
||||
# Agent configuration registry
|
||||
AGENT_CONFIGS,
|
||||
# Base tools
|
||||
BASE_READ_TOOLS,
|
||||
BASE_WRITE_TOOLS,
|
||||
# MCP tool lists
|
||||
CONTEXT7_TOOLS,
|
||||
ELECTRON_TOOLS,
|
||||
GRAPHITI_MCP_TOOLS,
|
||||
LINEAR_TOOLS,
|
||||
PUPPETEER_TOOLS,
|
||||
# Auto-Claude tool names
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
WEB_TOOLS,
|
||||
# Config functions
|
||||
get_agent_config,
|
||||
get_default_thinking_level,
|
||||
get_required_mcp_servers,
|
||||
is_electron_mcp_enabled,
|
||||
)
|
||||
from .permissions import get_all_agent_types, get_allowed_tools
|
||||
from .permissions import get_allowed_tools
|
||||
from .registry import create_auto_claude_mcp_server, is_tools_available
|
||||
|
||||
__all__ = [
|
||||
@@ -63,29 +47,14 @@ __all__ = [
|
||||
"create_auto_claude_mcp_server",
|
||||
"get_allowed_tools",
|
||||
"is_tools_available",
|
||||
# Agent configuration registry
|
||||
"AGENT_CONFIGS",
|
||||
"get_agent_config",
|
||||
"get_required_mcp_servers",
|
||||
"get_default_thinking_level",
|
||||
"get_all_agent_types",
|
||||
# Base tool lists
|
||||
"BASE_READ_TOOLS",
|
||||
"BASE_WRITE_TOOLS",
|
||||
"WEB_TOOLS",
|
||||
# MCP tool lists
|
||||
"CONTEXT7_TOOLS",
|
||||
"LINEAR_TOOLS",
|
||||
"GRAPHITI_MCP_TOOLS",
|
||||
"ELECTRON_TOOLS",
|
||||
"PUPPETEER_TOOLS",
|
||||
# Auto-Claude tool name constants
|
||||
# Tool name constants
|
||||
"TOOL_UPDATE_SUBTASK_STATUS",
|
||||
"TOOL_GET_BUILD_PROGRESS",
|
||||
"TOOL_RECORD_DISCOVERY",
|
||||
"TOOL_RECORD_GOTCHA",
|
||||
"TOOL_GET_SESSION_CONTEXT",
|
||||
"TOOL_UPDATE_QA_STATUS",
|
||||
# Config
|
||||
# Electron MCP
|
||||
"ELECTRON_TOOLS",
|
||||
"is_electron_mcp_enabled",
|
||||
]
|
||||
|
||||
@@ -3,32 +3,12 @@ Tool Models and Constants
|
||||
==========================
|
||||
|
||||
Defines tool name constants and configuration for auto-claude MCP tools.
|
||||
|
||||
This module is the single source of truth for all tool definitions used by
|
||||
the Claude Agent SDK client. Tool lists are organized by category:
|
||||
|
||||
- Base tools: Core file operations (Read, Write, Edit, etc.)
|
||||
- Web tools: Documentation and research (WebFetch, WebSearch)
|
||||
- MCP tools: External integrations (Context7, Linear, Graphiti, etc.)
|
||||
- Auto-Claude tools: Custom build management tools
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# =============================================================================
|
||||
# Base Tools (Built-in Claude Code tools)
|
||||
# =============================================================================
|
||||
|
||||
# Core file operation tools
|
||||
BASE_READ_TOOLS = ["Read", "Glob", "Grep"]
|
||||
BASE_WRITE_TOOLS = ["Write", "Edit", "Bash"]
|
||||
|
||||
# Web tools for documentation lookup and research
|
||||
# Always available to all agents for accessing external information
|
||||
WEB_TOOLS = ["WebFetch", "WebSearch"]
|
||||
|
||||
# =============================================================================
|
||||
# Auto-Claude MCP Tools (Custom build management)
|
||||
# Tool Name Constants
|
||||
# =============================================================================
|
||||
|
||||
# Auto-Claude MCP tool names (prefixed with mcp__auto-claude__)
|
||||
@@ -39,54 +19,8 @@ TOOL_RECORD_GOTCHA = "mcp__auto-claude__record_gotcha"
|
||||
TOOL_GET_SESSION_CONTEXT = "mcp__auto-claude__get_session_context"
|
||||
TOOL_UPDATE_QA_STATUS = "mcp__auto-claude__update_qa_status"
|
||||
|
||||
# =============================================================================
|
||||
# External MCP Tools
|
||||
# =============================================================================
|
||||
|
||||
# Context7 MCP tools for documentation lookup (always enabled)
|
||||
CONTEXT7_TOOLS = [
|
||||
"mcp__context7__resolve-library-id",
|
||||
"mcp__context7__get-library-docs",
|
||||
]
|
||||
|
||||
# Linear MCP tools for project management (when LINEAR_API_KEY is set)
|
||||
LINEAR_TOOLS = [
|
||||
"mcp__linear-server__list_teams",
|
||||
"mcp__linear-server__get_team",
|
||||
"mcp__linear-server__list_projects",
|
||||
"mcp__linear-server__get_project",
|
||||
"mcp__linear-server__create_project",
|
||||
"mcp__linear-server__update_project",
|
||||
"mcp__linear-server__list_issues",
|
||||
"mcp__linear-server__get_issue",
|
||||
"mcp__linear-server__create_issue",
|
||||
"mcp__linear-server__update_issue",
|
||||
"mcp__linear-server__list_comments",
|
||||
"mcp__linear-server__create_comment",
|
||||
"mcp__linear-server__list_issue_statuses",
|
||||
"mcp__linear-server__list_issue_labels",
|
||||
"mcp__linear-server__list_users",
|
||||
"mcp__linear-server__get_user",
|
||||
]
|
||||
|
||||
# Graphiti MCP tools for knowledge graph memory (when GRAPHITI_MCP_URL is set)
|
||||
# See: https://github.com/getzep/graphiti
|
||||
GRAPHITI_MCP_TOOLS = [
|
||||
"mcp__graphiti-memory__search_nodes", # Search entity summaries
|
||||
"mcp__graphiti-memory__search_facts", # Search relationships between entities
|
||||
"mcp__graphiti-memory__add_episode", # Add data to knowledge graph
|
||||
"mcp__graphiti-memory__get_episodes", # Retrieve recent episodes
|
||||
"mcp__graphiti-memory__get_entity_edge", # Get specific entity/relationship
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Browser Automation MCP Tools (QA agents only)
|
||||
# =============================================================================
|
||||
|
||||
# Puppeteer MCP tools for web browser automation
|
||||
# Used for web frontend validation (non-Electron web apps)
|
||||
# NOTE: Screenshots must be compressed (1280x720, quality 60, JPEG) to stay under
|
||||
# Claude SDK's 1MB JSON message buffer limit. See GitHub issue #74.
|
||||
PUPPETEER_TOOLS = [
|
||||
"mcp__puppeteer__puppeteer_connect_active_tab",
|
||||
"mcp__puppeteer__puppeteer_navigate",
|
||||
@@ -102,7 +36,6 @@ PUPPETEER_TOOLS = [
|
||||
# Uses electron-mcp-server to connect to Electron apps via Chrome DevTools Protocol.
|
||||
# Electron app must be started with --remote-debugging-port=9222 (or ELECTRON_DEBUG_PORT).
|
||||
# These tools are only available to QA agents (qa_reviewer, qa_fixer), not Coder/Planner.
|
||||
# NOTE: Screenshots must be compressed to stay under Claude SDK's 1MB JSON message buffer limit.
|
||||
ELECTRON_TOOLS = [
|
||||
"mcp__electron__get_electron_window_info", # Get info about running Electron windows
|
||||
"mcp__electron__take_screenshot", # Capture screenshot of Electron window
|
||||
@@ -110,6 +43,10 @@ ELECTRON_TOOLS = [
|
||||
"mcp__electron__read_electron_logs", # Read console logs from Electron app
|
||||
]
|
||||
|
||||
# Base tools available to all agents
|
||||
BASE_READ_TOOLS = ["Read", "Glob", "Grep"]
|
||||
BASE_WRITE_TOOLS = ["Write", "Edit", "Bash"]
|
||||
|
||||
# =============================================================================
|
||||
# Configuration
|
||||
# =============================================================================
|
||||
@@ -124,389 +61,3 @@ def is_electron_mcp_enabled() -> bool:
|
||||
via Chrome DevTools Protocol on the configured debug port.
|
||||
"""
|
||||
return os.environ.get("ELECTRON_MCP_ENABLED", "").lower() == "true"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Agent Configuration Registry
|
||||
# =============================================================================
|
||||
# Single source of truth for phase → tools → MCP servers mapping.
|
||||
# This enables phase-aware tool control and context window optimization.
|
||||
|
||||
AGENT_CONFIGS = {
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# SPEC CREATION PHASES (Minimal tools, fast startup)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"spec_gatherer": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [], # No MCP needed - just reads project
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"spec_researcher": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7"], # Needs docs lookup
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"spec_writer": {
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
|
||||
"mcp_servers": [], # Just writes spec.md
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"spec_critic": {
|
||||
"tools": BASE_READ_TOOLS,
|
||||
"mcp_servers": [], # Self-critique, no external tools
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "ultrathink",
|
||||
},
|
||||
"spec_discovery": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"spec_context": {
|
||||
"tools": BASE_READ_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"spec_validation": {
|
||||
"tools": BASE_READ_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"spec_compaction": {
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# BUILD PHASES (Full tools + Graphiti memory)
|
||||
# Note: "linear" is conditional on project setting "update_linear_with_tasks"
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"planner": {
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7", "graphiti", "auto-claude"],
|
||||
"mcp_servers_optional": ["linear"], # Only if project setting enabled
|
||||
"auto_claude_tools": [
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"coder": {
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7", "graphiti", "auto-claude"],
|
||||
"mcp_servers_optional": ["linear"],
|
||||
"auto_claude_tools": [
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
],
|
||||
"thinking_default": "none", # Coding doesn't use extended thinking
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# QA PHASES (Read + test + browser + Graphiti memory)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"qa_reviewer": {
|
||||
# Read + Write/Edit (for QA reports and plan updates) + Bash (for tests)
|
||||
# Note: Reviewer writes to spec directory only (qa_report.md, implementation_plan.json)
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7", "graphiti", "auto-claude", "browser"],
|
||||
"mcp_servers_optional": ["linear"], # For updating issue status
|
||||
"auto_claude_tools": [
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"qa_fixer": {
|
||||
"tools": BASE_READ_TOOLS + BASE_WRITE_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7", "graphiti", "auto-claude", "browser"],
|
||||
"mcp_servers_optional": ["linear"],
|
||||
"auto_claude_tools": [
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# UTILITY PHASES (Minimal, no MCP)
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"insights": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
# 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",
|
||||
},
|
||||
"merge_resolver": {
|
||||
"tools": [], # Text-only analysis
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low",
|
||||
},
|
||||
"commit_message": {
|
||||
"tools": [],
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low",
|
||||
},
|
||||
"pr_reviewer": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS, # Read-only
|
||||
"mcp_servers": ["context7"],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"pr_orchestrator_parallel": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS, # Read-only for parallel PR orchestrator
|
||||
"mcp_servers": ["context7"],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"pr_followup_parallel": {
|
||||
"tools": BASE_READ_TOOLS
|
||||
+ WEB_TOOLS, # Read-only for parallel followup reviewer
|
||||
"mcp_servers": ["context7"],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ANALYSIS PHASES
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"analysis": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7"],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "medium",
|
||||
},
|
||||
"batch_analysis": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low",
|
||||
},
|
||||
"batch_validation": {
|
||||
"tools": BASE_READ_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "low",
|
||||
},
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
# ROADMAP & IDEATION
|
||||
# ═══════════════════════════════════════════════════════════════════════
|
||||
"roadmap_discovery": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7"],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"competitor_analysis": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": ["context7"], # WebSearch for competitor research
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
"ideation": {
|
||||
"tools": BASE_READ_TOOLS + WEB_TOOLS,
|
||||
"mcp_servers": [],
|
||||
"auto_claude_tools": [],
|
||||
"thinking_default": "high",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Agent Config Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def get_agent_config(agent_type: str) -> dict:
|
||||
"""
|
||||
Get full configuration for an agent type.
|
||||
|
||||
Args:
|
||||
agent_type: The agent type identifier (e.g., 'coder', 'planner', 'qa_reviewer')
|
||||
|
||||
Returns:
|
||||
Configuration dict containing tools, mcp_servers, auto_claude_tools, thinking_default
|
||||
|
||||
Raises:
|
||||
ValueError: If agent_type is not found in AGENT_CONFIGS (strict mode)
|
||||
"""
|
||||
if agent_type not in AGENT_CONFIGS:
|
||||
raise ValueError(
|
||||
f"Unknown agent type: '{agent_type}'. "
|
||||
f"Valid types: {sorted(AGENT_CONFIGS.keys())}"
|
||||
)
|
||||
return AGENT_CONFIGS[agent_type]
|
||||
|
||||
|
||||
def _map_mcp_server_name(
|
||||
name: str, custom_server_ids: list[str] | None = None
|
||||
) -> str | None:
|
||||
"""
|
||||
Map user-friendly MCP server names to internal identifiers.
|
||||
Also accepts custom server IDs directly.
|
||||
|
||||
Args:
|
||||
name: User-provided MCP server name
|
||||
custom_server_ids: List of custom server IDs to accept as-is
|
||||
|
||||
Returns:
|
||||
Internal server identifier or None if not recognized
|
||||
"""
|
||||
if not name:
|
||||
return None
|
||||
mappings = {
|
||||
"context7": "context7",
|
||||
"graphiti-memory": "graphiti",
|
||||
"graphiti": "graphiti",
|
||||
"linear": "linear",
|
||||
"electron": "electron",
|
||||
"puppeteer": "puppeteer",
|
||||
"auto-claude": "auto-claude",
|
||||
}
|
||||
# Check if it's a known mapping
|
||||
mapped = mappings.get(name.lower().strip())
|
||||
if mapped:
|
||||
return mapped
|
||||
# Check if it's a custom server ID (accept as-is)
|
||||
if custom_server_ids and name in custom_server_ids:
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def get_required_mcp_servers(
|
||||
agent_type: str,
|
||||
project_capabilities: dict | None = None,
|
||||
linear_enabled: bool = False,
|
||||
mcp_config: dict | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get MCP servers required for this agent type.
|
||||
|
||||
Handles dynamic server selection:
|
||||
- "browser" → electron (if is_electron) or puppeteer (if is_web_frontend)
|
||||
- "linear" → only if in mcp_servers_optional AND linear_enabled is True
|
||||
- "graphiti" → only if GRAPHITI_MCP_URL is set
|
||||
- Respects per-project MCP config overrides from .auto-claude/.env
|
||||
- Applies per-agent ADD/REMOVE overrides from AGENT_MCP_<agent>_ADD/REMOVE
|
||||
|
||||
Args:
|
||||
agent_type: The agent type identifier
|
||||
project_capabilities: Dict from detect_project_capabilities() or None
|
||||
linear_enabled: Whether Linear integration is enabled for this project
|
||||
mcp_config: Per-project MCP server toggles from .auto-claude/.env
|
||||
Keys: CONTEXT7_ENABLED, LINEAR_MCP_ENABLED, ELECTRON_MCP_ENABLED,
|
||||
PUPPETEER_MCP_ENABLED, AGENT_MCP_<agent>_ADD/REMOVE
|
||||
|
||||
Returns:
|
||||
List of MCP server names to start
|
||||
"""
|
||||
config = get_agent_config(agent_type)
|
||||
servers = list(config.get("mcp_servers", []))
|
||||
|
||||
# Load per-project config (or use defaults)
|
||||
if mcp_config is None:
|
||||
mcp_config = {}
|
||||
|
||||
# Filter context7 if explicitly disabled by project config
|
||||
if "context7" in servers:
|
||||
context7_enabled = mcp_config.get("CONTEXT7_ENABLED", "true")
|
||||
if str(context7_enabled).lower() == "false":
|
||||
servers = [s for s in servers if s != "context7"]
|
||||
|
||||
# Handle optional servers (e.g., Linear if project setting enabled)
|
||||
optional = config.get("mcp_servers_optional", [])
|
||||
if "linear" in optional and linear_enabled:
|
||||
# Also check per-project LINEAR_MCP_ENABLED override
|
||||
linear_mcp_enabled = mcp_config.get("LINEAR_MCP_ENABLED", "true")
|
||||
if str(linear_mcp_enabled).lower() != "false":
|
||||
servers.append("linear")
|
||||
|
||||
# Handle dynamic "browser" → electron/puppeteer based on project type and config
|
||||
if "browser" in servers:
|
||||
servers = [s for s in servers if s != "browser"]
|
||||
if project_capabilities:
|
||||
is_electron = project_capabilities.get("is_electron", False)
|
||||
is_web_frontend = project_capabilities.get("is_web_frontend", False)
|
||||
|
||||
# Check per-project overrides (default false for both)
|
||||
electron_enabled = mcp_config.get("ELECTRON_MCP_ENABLED", "false")
|
||||
puppeteer_enabled = mcp_config.get("PUPPETEER_MCP_ENABLED", "false")
|
||||
|
||||
# Electron: enabled by project config OR global env var
|
||||
if is_electron and (
|
||||
str(electron_enabled).lower() == "true" or is_electron_mcp_enabled()
|
||||
):
|
||||
servers.append("electron")
|
||||
# Puppeteer: enabled by project config (no global env var)
|
||||
elif is_web_frontend and not is_electron:
|
||||
if str(puppeteer_enabled).lower() == "true":
|
||||
servers.append("puppeteer")
|
||||
|
||||
# Filter graphiti if not enabled
|
||||
if "graphiti" in servers:
|
||||
if not os.environ.get("GRAPHITI_MCP_URL"):
|
||||
servers = [s for s in servers if s != "graphiti"]
|
||||
|
||||
# ========== Apply per-agent MCP overrides ==========
|
||||
# Format: AGENT_MCP_<agent_type>_ADD=server1,server2
|
||||
# AGENT_MCP_<agent_type>_REMOVE=server1,server2
|
||||
add_key = f"AGENT_MCP_{agent_type}_ADD"
|
||||
remove_key = f"AGENT_MCP_{agent_type}_REMOVE"
|
||||
|
||||
# Extract custom server IDs for mapping (allows custom servers to be recognized)
|
||||
custom_servers = mcp_config.get("CUSTOM_MCP_SERVERS", [])
|
||||
custom_server_ids = [s.get("id") for s in custom_servers if s.get("id")]
|
||||
|
||||
# Process additions
|
||||
if add_key in mcp_config:
|
||||
additions = [
|
||||
s.strip() for s in str(mcp_config[add_key]).split(",") if s.strip()
|
||||
]
|
||||
for server in additions:
|
||||
mapped = _map_mcp_server_name(server, custom_server_ids)
|
||||
if mapped and mapped not in servers:
|
||||
servers.append(mapped)
|
||||
|
||||
# Process removals (but never remove auto-claude)
|
||||
if remove_key in mcp_config:
|
||||
removals = [
|
||||
s.strip() for s in str(mcp_config[remove_key]).split(",") if s.strip()
|
||||
]
|
||||
for server in removals:
|
||||
mapped = _map_mcp_server_name(server, custom_server_ids)
|
||||
if mapped and mapped != "auto-claude": # auto-claude cannot be removed
|
||||
servers = [s for s in servers if s != mapped]
|
||||
|
||||
return servers
|
||||
|
||||
|
||||
def get_default_thinking_level(agent_type: str) -> str:
|
||||
"""
|
||||
Get default thinking level string for agent type.
|
||||
|
||||
This returns the thinking level name (e.g., 'medium', 'high'), not the token budget.
|
||||
To convert to tokens, use phase_config.get_thinking_budget(level).
|
||||
|
||||
Args:
|
||||
agent_type: The agent type identifier
|
||||
|
||||
Returns:
|
||||
Thinking level string (none, low, medium, high, ultrathink)
|
||||
"""
|
||||
config = get_agent_config(agent_type)
|
||||
return config.get("thinking_default", "medium")
|
||||
|
||||
@@ -8,30 +8,26 @@ pollution and accidental misuse.
|
||||
Supports dynamic tool filtering based on project capabilities to optimize
|
||||
context window usage. For example, Electron tools are only included for
|
||||
Electron projects, not for Next.js or CLI projects.
|
||||
|
||||
This module now uses AGENT_CONFIGS from models.py as the single source of truth
|
||||
for tool permissions. The get_allowed_tools() function remains the primary API
|
||||
for backwards compatibility.
|
||||
"""
|
||||
|
||||
from .models import (
|
||||
AGENT_CONFIGS,
|
||||
CONTEXT7_TOOLS,
|
||||
BASE_READ_TOOLS,
|
||||
BASE_WRITE_TOOLS,
|
||||
ELECTRON_TOOLS,
|
||||
GRAPHITI_MCP_TOOLS,
|
||||
LINEAR_TOOLS,
|
||||
PUPPETEER_TOOLS,
|
||||
get_agent_config,
|
||||
get_required_mcp_servers,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
is_electron_mcp_enabled,
|
||||
)
|
||||
from .registry import is_tools_available
|
||||
|
||||
|
||||
def get_allowed_tools(
|
||||
agent_type: str,
|
||||
project_capabilities: dict | None = None,
|
||||
linear_enabled: bool = False,
|
||||
mcp_config: dict | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Get the list of allowed tools for a specific agent type.
|
||||
@@ -39,82 +35,113 @@ def get_allowed_tools(
|
||||
This ensures each agent only sees tools relevant to their role,
|
||||
preventing context pollution and accidental misuse.
|
||||
|
||||
Uses AGENT_CONFIGS as the single source of truth for tool permissions.
|
||||
Dynamic MCP tools are added based on project capabilities and required servers.
|
||||
When project_capabilities is provided, MCP tools are filtered based on
|
||||
the project type. For example:
|
||||
- Electron projects get Electron MCP tools
|
||||
- Web frontends (non-Electron) get Puppeteer MCP tools
|
||||
- CLI projects get neither
|
||||
|
||||
Args:
|
||||
agent_type: Agent type identifier (e.g., 'coder', 'planner', 'qa_reviewer')
|
||||
agent_type: One of 'planner', 'coder', 'qa_reviewer', 'qa_fixer'
|
||||
project_capabilities: Optional dict from detect_project_capabilities()
|
||||
containing flags like is_electron, is_web_frontend, etc.
|
||||
linear_enabled: Whether Linear integration is enabled for this project
|
||||
mcp_config: Per-project MCP server toggles from .auto-claude/.env
|
||||
|
||||
Returns:
|
||||
List of allowed tool names
|
||||
|
||||
Raises:
|
||||
ValueError: If agent_type is not found in AGENT_CONFIGS
|
||||
"""
|
||||
# Get agent configuration (raises ValueError if unknown type)
|
||||
config = get_agent_config(agent_type)
|
||||
# Auto-claude tool mappings by agent type
|
||||
tool_mappings = {
|
||||
"planner": {
|
||||
"base": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
|
||||
"auto_claude": [
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
],
|
||||
},
|
||||
"coder": {
|
||||
"base": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
|
||||
"auto_claude": [
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_RECORD_DISCOVERY,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
],
|
||||
},
|
||||
"qa_reviewer": {
|
||||
"base": BASE_READ_TOOLS + ["Bash"], # Can run tests but not edit
|
||||
"auto_claude": [
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_GET_SESSION_CONTEXT,
|
||||
],
|
||||
},
|
||||
"pr_reviewer": {
|
||||
# PR reviewers can ONLY read - no bash, no edits, no writes
|
||||
# This prevents the agent from switching branches or making changes
|
||||
"base": BASE_READ_TOOLS,
|
||||
"auto_claude": [], # No auto-claude tools needed for PR review
|
||||
},
|
||||
"qa_fixer": {
|
||||
"base": BASE_READ_TOOLS + BASE_WRITE_TOOLS,
|
||||
"auto_claude": [
|
||||
TOOL_UPDATE_SUBTASK_STATUS,
|
||||
TOOL_GET_BUILD_PROGRESS,
|
||||
TOOL_UPDATE_QA_STATUS,
|
||||
TOOL_RECORD_GOTCHA,
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
# Start with base tools from config
|
||||
tools = list(config.get("tools", []))
|
||||
if agent_type not in tool_mappings:
|
||||
# Default to coder tools
|
||||
agent_type = "coder"
|
||||
|
||||
# Get required MCP servers for this agent
|
||||
required_servers = get_required_mcp_servers(
|
||||
agent_type,
|
||||
project_capabilities,
|
||||
linear_enabled,
|
||||
mcp_config,
|
||||
)
|
||||
mapping = tool_mappings[agent_type]
|
||||
tools = mapping["base"] + mapping["auto_claude"]
|
||||
|
||||
# Add auto-claude tools ONLY if the MCP server is available
|
||||
# This prevents allowing tools that won't work because the server isn't running
|
||||
if "auto-claude" in required_servers and is_tools_available():
|
||||
tools.extend(config.get("auto_claude_tools", []))
|
||||
|
||||
# Add MCP tool names based on required servers
|
||||
tools.extend(_get_mcp_tools_for_servers(required_servers))
|
||||
# Add MCP tools for QA agents only, based on project capabilities
|
||||
if agent_type in ("qa_reviewer", "qa_fixer"):
|
||||
tools.extend(_get_qa_mcp_tools(project_capabilities))
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
def _get_mcp_tools_for_servers(servers: list[str]) -> list[str]:
|
||||
def _get_qa_mcp_tools(project_capabilities: dict | None) -> list[str]:
|
||||
"""
|
||||
Get the list of MCP tools for a list of required servers.
|
||||
Get the list of MCP tools for QA agents based on project capabilities.
|
||||
|
||||
Maps server names to their corresponding tool lists.
|
||||
This function determines which MCP tools to include based on:
|
||||
1. Project type detection (Electron, web frontend, etc.)
|
||||
2. Environment variables (ELECTRON_MCP_ENABLED)
|
||||
|
||||
Args:
|
||||
servers: List of MCP server names (e.g., ['context7', 'linear', 'electron'])
|
||||
project_capabilities: Dict from detect_project_capabilities() or None
|
||||
|
||||
Returns:
|
||||
List of MCP tool names for all specified servers
|
||||
List of MCP tool names to include
|
||||
"""
|
||||
tools = []
|
||||
|
||||
for server in servers:
|
||||
if server == "context7":
|
||||
tools.extend(CONTEXT7_TOOLS)
|
||||
elif server == "linear":
|
||||
tools.extend(LINEAR_TOOLS)
|
||||
elif server == "graphiti":
|
||||
tools.extend(GRAPHITI_MCP_TOOLS)
|
||||
elif server == "electron":
|
||||
# If no capabilities provided, fall back to legacy behavior
|
||||
# (check env var only)
|
||||
if project_capabilities is None:
|
||||
if is_electron_mcp_enabled():
|
||||
tools.extend(ELECTRON_TOOLS)
|
||||
elif server == "puppeteer":
|
||||
tools.extend(PUPPETEER_TOOLS)
|
||||
# auto-claude tools are already added via config["auto_claude_tools"]
|
||||
return tools
|
||||
|
||||
# Project-capability-based tool selection
|
||||
is_electron = project_capabilities.get("is_electron", False)
|
||||
is_web_frontend = project_capabilities.get("is_web_frontend", False)
|
||||
|
||||
# Electron projects get Electron MCP tools (if enabled)
|
||||
if is_electron and is_electron_mcp_enabled():
|
||||
tools.extend(ELECTRON_TOOLS)
|
||||
|
||||
# Web frontends (non-Electron) get Puppeteer tools
|
||||
# Puppeteer is always available, no env var check needed
|
||||
if is_web_frontend and not is_electron:
|
||||
tools.extend(PUPPETEER_TOOLS)
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
def get_all_agent_types() -> list[str]:
|
||||
"""
|
||||
Get all registered agent types.
|
||||
|
||||
Returns:
|
||||
Sorted list of all agent type identifiers
|
||||
"""
|
||||
return sorted(AGENT_CONFIGS.keys())
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -75,15 +75,6 @@ class FrameworkAnalyzer(BaseAnalyzer):
|
||||
content = self._read_file("Cargo.toml")
|
||||
self._detect_rust_framework(content)
|
||||
|
||||
# Swift/iOS detection (check BEFORE Ruby - iOS projects often have Gemfile for CocoaPods/Fastlane)
|
||||
elif self._exists("Package.swift") or any(self.path.glob("*.xcodeproj")):
|
||||
self.analysis["language"] = "Swift"
|
||||
if self._exists("Package.swift"):
|
||||
self.analysis["package_manager"] = "Swift Package Manager"
|
||||
else:
|
||||
self.analysis["package_manager"] = "Xcode"
|
||||
self._detect_swift_framework()
|
||||
|
||||
# Ruby detection
|
||||
elif self._exists("Gemfile"):
|
||||
self.analysis["language"] = "Ruby"
|
||||
@@ -299,115 +290,12 @@ class FrameworkAnalyzer(BaseAnalyzer):
|
||||
if "sidekiq" in content.lower():
|
||||
self.analysis["task_queue"] = "Sidekiq"
|
||||
|
||||
def _detect_swift_framework(self) -> None:
|
||||
"""Detect Swift/iOS framework and dependencies."""
|
||||
try:
|
||||
# Scan Swift files for imports, excluding hidden/vendor dirs
|
||||
swift_files = []
|
||||
for swift_file in self.path.rglob("*.swift"):
|
||||
# Skip hidden directories, node_modules, .worktrees, etc.
|
||||
if any(
|
||||
part.startswith(".") or part in ("node_modules", "Pods", "Carthage")
|
||||
for part in swift_file.parts
|
||||
):
|
||||
continue
|
||||
swift_files.append(swift_file)
|
||||
if len(swift_files) >= 50: # Limit for performance
|
||||
break
|
||||
|
||||
imports = set()
|
||||
for swift_file in swift_files:
|
||||
try:
|
||||
content = swift_file.read_text(encoding="utf-8", errors="ignore")
|
||||
for line in content.split("\n"):
|
||||
line = line.strip()
|
||||
if line.startswith("import "):
|
||||
module = line.replace("import ", "").split()[0]
|
||||
imports.add(module)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# Detect UI framework
|
||||
if "SwiftUI" in imports:
|
||||
self.analysis["framework"] = "SwiftUI"
|
||||
self.analysis["type"] = "mobile"
|
||||
elif "UIKit" in imports:
|
||||
self.analysis["framework"] = "UIKit"
|
||||
self.analysis["type"] = "mobile"
|
||||
elif "AppKit" in imports:
|
||||
self.analysis["framework"] = "AppKit"
|
||||
self.analysis["type"] = "desktop"
|
||||
|
||||
# Detect iOS/Apple frameworks
|
||||
apple_frameworks = []
|
||||
framework_map = {
|
||||
"Combine": "Combine",
|
||||
"CoreData": "CoreData",
|
||||
"MapKit": "MapKit",
|
||||
"WidgetKit": "WidgetKit",
|
||||
"CoreLocation": "CoreLocation",
|
||||
"StoreKit": "StoreKit",
|
||||
"CloudKit": "CloudKit",
|
||||
"ActivityKit": "ActivityKit",
|
||||
"UserNotifications": "UserNotifications",
|
||||
}
|
||||
for key, name in framework_map.items():
|
||||
if key in imports:
|
||||
apple_frameworks.append(name)
|
||||
|
||||
if apple_frameworks:
|
||||
self.analysis["apple_frameworks"] = apple_frameworks
|
||||
|
||||
# Detect SPM dependencies from Package.swift or xcodeproj
|
||||
dependencies = self._detect_spm_dependencies()
|
||||
if dependencies:
|
||||
self.analysis["spm_dependencies"] = dependencies
|
||||
except Exception:
|
||||
# Silently fail if Swift detection has issues
|
||||
pass
|
||||
|
||||
def _detect_spm_dependencies(self) -> list[str]:
|
||||
"""Detect Swift Package Manager dependencies."""
|
||||
dependencies = []
|
||||
|
||||
# Try Package.swift first
|
||||
if self._exists("Package.swift"):
|
||||
content = self._read_file("Package.swift")
|
||||
# Look for .package(url: "...", patterns
|
||||
import re
|
||||
|
||||
urls = re.findall(r'\.package\s*\([^)]*url:\s*"([^"]+)"', content)
|
||||
for url in urls:
|
||||
# Extract package name from URL
|
||||
name = url.rstrip("/").split("/")[-1].replace(".git", "")
|
||||
if name:
|
||||
dependencies.append(name)
|
||||
|
||||
# Also check xcodeproj for XCRemoteSwiftPackageReference
|
||||
for xcodeproj in self.path.glob("*.xcodeproj"):
|
||||
pbxproj = xcodeproj / "project.pbxproj"
|
||||
if pbxproj.exists():
|
||||
try:
|
||||
content = pbxproj.read_text(encoding="utf-8", errors="ignore")
|
||||
import re
|
||||
|
||||
# Match repositoryURL patterns
|
||||
urls = re.findall(r'repositoryURL\s*=\s*"([^"]+)"', content)
|
||||
for url in urls:
|
||||
name = url.rstrip("/").split("/")[-1].replace(".git", "")
|
||||
if name and name not in dependencies:
|
||||
dependencies.append(name)
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
return dependencies
|
||||
|
||||
def _detect_node_package_manager(self) -> str:
|
||||
"""Detect Node.js package manager."""
|
||||
if self._exists("pnpm-lock.yaml"):
|
||||
return "pnpm"
|
||||
elif self._exists("yarn.lock"):
|
||||
return "yarn"
|
||||
elif self._exists("bun.lockb") or self._exists("bun.lock"):
|
||||
elif self._exists("bun.lockb"):
|
||||
return "bun"
|
||||
return "npm"
|
||||
|
||||
@@ -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.
|
||||
@@ -368,19 +366,19 @@ async def run_insight_extraction(
|
||||
cwd = str(project_dir.resolve()) if project_dir else os.getcwd()
|
||||
|
||||
try:
|
||||
# Use simple_client for insight extraction
|
||||
from pathlib import Path
|
||||
|
||||
from core.simple_client import create_simple_client
|
||||
|
||||
client = create_simple_client(
|
||||
agent_type="insights",
|
||||
model=model,
|
||||
system_prompt=(
|
||||
"You are an expert code analyst. You extract structured insights from coding sessions. "
|
||||
"Always respond with valid JSON only, no markdown formatting or explanations."
|
||||
),
|
||||
cwd=Path(cwd) if cwd else None,
|
||||
# Create a minimal SDK client for insight extraction
|
||||
# No tools needed - just text generation
|
||||
client = ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model=model,
|
||||
system_prompt=(
|
||||
"You are an expert code analyst. You extract structured insights from coding sessions. "
|
||||
"Always respond with valid JSON only, no markdown formatting or explanations."
|
||||
),
|
||||
allowed_tools=[], # No tools needed for extraction
|
||||
max_turns=1, # Single turn extraction
|
||||
cwd=cwd,
|
||||
)
|
||||
)
|
||||
|
||||
# Use async context manager
|
||||
@@ -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,6 @@ Commands for creating and managing multiple tasks from batch files.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from ui import highlight, print_status
|
||||
@@ -31,7 +29,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 +79,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 +143,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:
|
||||
@@ -186,7 +184,7 @@ def handle_batch_cleanup_command(project_dir: str, dry_run: bool = True) -> bool
|
||||
True if successful
|
||||
"""
|
||||
specs_dir = Path(project_dir) / ".auto-claude" / "specs"
|
||||
worktrees_dir = Path(project_dir) / ".auto-claude" / "worktrees" / "tasks"
|
||||
worktrees_dir = Path(project_dir) / ".worktrees"
|
||||
|
||||
if not specs_dir.exists():
|
||||
print_status("No specs directory found", "info")
|
||||
@@ -211,56 +209,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" └─ .worktrees/{spec_name}/")
|
||||
print()
|
||||
print("Run with --no-dry-run to actually delete")
|
||||
else:
|
||||
# Actually delete specs and worktrees
|
||||
deleted_count = 0
|
||||
for spec_name in completed:
|
||||
spec_path = specs_dir / spec_name
|
||||
wt_path = worktrees_dir / spec_name
|
||||
|
||||
# Remove worktree first (if exists)
|
||||
if wt_path.exists():
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "worktree", "remove", "--force", str(wt_path)],
|
||||
cwd=project_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
print_status(f"Removed worktree: {spec_name}", "success")
|
||||
else:
|
||||
# Fallback: remove directory manually if git fails
|
||||
shutil.rmtree(wt_path, ignore_errors=True)
|
||||
print_status(
|
||||
f"Removed worktree directory: {spec_name}", "success"
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
# Timeout: fall back to manual removal
|
||||
shutil.rmtree(wt_path, ignore_errors=True)
|
||||
print_status(
|
||||
f"Worktree removal timed out, removed directory: {spec_name}",
|
||||
"warning",
|
||||
)
|
||||
except Exception as e:
|
||||
print_status(
|
||||
f"Failed to remove worktree {spec_name}: {e}", "warning"
|
||||
)
|
||||
|
||||
# Remove spec directory
|
||||
if spec_path.exists():
|
||||
try:
|
||||
shutil.rmtree(spec_path)
|
||||
print_status(f"Removed spec: {spec_name}", "success")
|
||||
deleted_count += 1
|
||||
except Exception as e:
|
||||
print_status(f"Failed to remove spec {spec_name}: {e}", "error")
|
||||
|
||||
print()
|
||||
print_status(f"Cleaned up {deleted_count} spec(s)", "info")
|
||||
|
||||
return True
|
||||
|
||||
@@ -79,7 +79,7 @@ def handle_build_command(
|
||||
base_branch: Base branch for worktree creation (default: current branch)
|
||||
"""
|
||||
# Lazy imports to avoid loading heavy modules
|
||||
from agent import run_autonomous_agent, sync_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",
|
||||
|
||||
+27
-82
@@ -15,6 +15,10 @@ _PARENT_DIR = Path(__file__).parent.parent
|
||||
if str(_PARENT_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(_PARENT_DIR))
|
||||
|
||||
from ui import (
|
||||
Icons,
|
||||
icon,
|
||||
)
|
||||
|
||||
from .batch_commands import (
|
||||
handle_batch_cleanup_command,
|
||||
@@ -38,7 +42,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 +77,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 +157,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(
|
||||
@@ -222,6 +201,13 @@ Environment Variables:
|
||||
help="Show human review/approval status for a spec",
|
||||
)
|
||||
|
||||
# Dev mode (deprecated)
|
||||
parser.add_argument(
|
||||
"--dev",
|
||||
action="store_true",
|
||||
help="[Deprecated] No longer has any effect - kept for compatibility",
|
||||
)
|
||||
|
||||
# Non-interactive mode (for UI/automation)
|
||||
parser.add_argument(
|
||||
"--auto-continue",
|
||||
@@ -288,28 +274,6 @@ def main() -> None:
|
||||
# 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,14 +287,19 @@ def _run_cli() -> None:
|
||||
project_dir = get_project_dir(args.project_dir)
|
||||
debug("run.py", f"Using project directory: {project_dir}")
|
||||
|
||||
# Get model from CLI arg or env var (None if not explicitly set)
|
||||
# This allows get_phase_model() to fall back to task_metadata.json
|
||||
model = args.model or os.environ.get("AUTO_BUILD_MODEL")
|
||||
# Get model (with env var fallback)
|
||||
model = args.model or os.environ.get("AUTO_BUILD_MODEL", DEFAULT_MODEL)
|
||||
|
||||
# Note: --dev flag is deprecated but kept for API compatibility
|
||||
if args.dev:
|
||||
print(
|
||||
f"\n{icon(Icons.GEAR)} Note: --dev flag is deprecated. All specs now use .auto-claude/specs/\n"
|
||||
)
|
||||
|
||||
# Handle --list command
|
||||
if args.list:
|
||||
print_banner()
|
||||
print_specs_list(project_dir)
|
||||
print_specs_list(project_dir, args.dev)
|
||||
return
|
||||
|
||||
# Handle --list-worktrees command
|
||||
@@ -368,27 +337,18 @@ def _run_cli() -> None:
|
||||
sys.exit(1)
|
||||
|
||||
# Find the spec
|
||||
debug("run.py", "Finding spec", spec_identifier=args.spec)
|
||||
spec_dir = find_spec(project_dir, args.spec)
|
||||
debug("run.py", "Finding spec", spec_identifier=args.spec, dev_mode=args.dev)
|
||||
spec_dir = find_spec(project_dir, args.spec, args.dev)
|
||||
if not spec_dir:
|
||||
debug_error("run.py", "Spec not found", spec=args.spec)
|
||||
print_banner()
|
||||
print(f"\nError: Spec '{args.spec}' not found")
|
||||
print("\nAvailable specs:")
|
||||
print_specs_list(project_dir)
|
||||
print_specs_list(project_dir, args.dev)
|
||||
sys.exit(1)
|
||||
|
||||
debug_success("run.py", "Spec found", spec_dir=str(spec_dir))
|
||||
|
||||
# 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 +381,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)
|
||||
|
||||
@@ -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()
|
||||
@@ -19,17 +19,18 @@ from workspace import get_existing_build_worktree
|
||||
from .utils import get_specs_dir
|
||||
|
||||
|
||||
def list_specs(project_dir: Path) -> list[dict]:
|
||||
def list_specs(project_dir: Path, dev_mode: bool = False) -> list[dict]:
|
||||
"""
|
||||
List all specs in the project.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
dev_mode: If True, use dev/auto-claude/specs/
|
||||
|
||||
Returns:
|
||||
List of spec info dicts with keys: number, name, path, status, progress
|
||||
"""
|
||||
specs_dir = get_specs_dir(project_dir)
|
||||
specs_dir = get_specs_dir(project_dir, dev_mode)
|
||||
specs = []
|
||||
|
||||
if not specs_dir.exists():
|
||||
@@ -92,16 +93,19 @@ def list_specs(project_dir: Path) -> list[dict]:
|
||||
return specs
|
||||
|
||||
|
||||
def print_specs_list(project_dir: Path, auto_create: bool = True) -> None:
|
||||
def print_specs_list(
|
||||
project_dir: Path, dev_mode: bool = False, auto_create: bool = True
|
||||
) -> None:
|
||||
"""Print a formatted list of all specs.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
dev_mode: If True, use dev/auto-claude/specs/
|
||||
auto_create: If True and no specs exist, automatically launch spec creation
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
specs = list_specs(project_dir)
|
||||
specs = list_specs(project_dir, dev_mode)
|
||||
|
||||
if not specs:
|
||||
print("\nNo specs found.")
|
||||
|
||||
+10
-71
@@ -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:
|
||||
@@ -96,18 +54,21 @@ def setup_environment() -> Path:
|
||||
return script_dir
|
||||
|
||||
|
||||
def find_spec(project_dir: Path, spec_identifier: str) -> Path | None:
|
||||
def find_spec(
|
||||
project_dir: Path, spec_identifier: str, dev_mode: bool = False
|
||||
) -> Path | None:
|
||||
"""
|
||||
Find a spec by number or full name.
|
||||
|
||||
Args:
|
||||
project_dir: Project root directory
|
||||
spec_identifier: Either "001" or "001-feature-name"
|
||||
dev_mode: If True, use dev/auto-claude/specs/
|
||||
|
||||
Returns:
|
||||
Path to spec folder, or None if not found
|
||||
"""
|
||||
specs_dir = get_specs_dir(project_dir)
|
||||
specs_dir = get_specs_dir(project_dir, dev_mode)
|
||||
|
||||
if specs_dir.exists():
|
||||
# Try exact match first
|
||||
@@ -124,7 +85,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 +118,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 +165,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 +215,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,
|
||||
}
|
||||
|
||||
@@ -186,15 +186,9 @@ Fixes #N (if applicable)"""
|
||||
return prompt
|
||||
|
||||
|
||||
async def _call_claude(prompt: str) -> str:
|
||||
"""Call Claude for commit message generation.
|
||||
|
||||
Reads model/thinking settings from environment variables:
|
||||
- UTILITY_MODEL_ID: Full model ID (e.g., "claude-haiku-4-5-20251001")
|
||||
- UTILITY_THINKING_BUDGET: Thinking budget tokens (e.g., "1024")
|
||||
"""
|
||||
async def _call_claude_haiku(prompt: str) -> str:
|
||||
"""Call Claude Haiku with low thinking for fast commit message generation."""
|
||||
from core.auth import ensure_claude_code_oauth_token, get_auth_token
|
||||
from core.model_config import get_utility_model_config
|
||||
|
||||
if not get_auth_token():
|
||||
logger.warning("No authentication token found")
|
||||
@@ -203,23 +197,19 @@ async def _call_claude(prompt: str) -> str:
|
||||
ensure_claude_code_oauth_token()
|
||||
|
||||
try:
|
||||
from core.simple_client import create_simple_client
|
||||
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
|
||||
except ImportError:
|
||||
logger.warning("core.simple_client not available")
|
||||
logger.warning("claude_agent_sdk not installed")
|
||||
return ""
|
||||
|
||||
# Get model settings from environment (passed from frontend)
|
||||
model, thinking_budget = get_utility_model_config()
|
||||
|
||||
logger.info(
|
||||
f"Commit message using model={model}, thinking_budget={thinking_budget}"
|
||||
)
|
||||
|
||||
client = create_simple_client(
|
||||
agent_type="commit_message",
|
||||
model=model,
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
max_thinking_tokens=thinking_budget,
|
||||
client = ClaudeSDKClient(
|
||||
options=ClaudeAgentOptions(
|
||||
model="claude-haiku-4-5-20251001",
|
||||
system_prompt=SYSTEM_PROMPT,
|
||||
allowed_tools=[],
|
||||
max_turns=1,
|
||||
max_thinking_tokens=1024, # Low thinking for speed
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -231,9 +221,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")
|
||||
@@ -299,9 +287,11 @@ def generate_commit_message_sync(
|
||||
import concurrent.futures
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
result = pool.submit(lambda: asyncio.run(_call_claude(prompt))).result()
|
||||
result = pool.submit(
|
||||
lambda: asyncio.run(_call_claude_haiku(prompt))
|
||||
).result()
|
||||
else:
|
||||
result = asyncio.run(_call_claude(prompt))
|
||||
result = asyncio.run(_call_claude_haiku(prompt))
|
||||
|
||||
if result:
|
||||
return result
|
||||
@@ -363,7 +353,7 @@ async def generate_commit_message(
|
||||
|
||||
# Call Claude
|
||||
try:
|
||||
result = await _call_claude(prompt)
|
||||
result = await _call_claude_haiku(prompt)
|
||||
if result:
|
||||
return result
|
||||
except Exception as e:
|
||||
|
||||
@@ -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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user