name: Autonomous Code Scanner # Daily multi-scanner: formatting (P0), security (P1), type hints (P2), docs (P3). # Creates separate draft PRs per scan category. Budget-gated at $100/run. # Estimated cost: $5-60 per run depending on findings. on: # schedule: # - cron: '0 2 * * *' # 2 AM UTC daily — enable after cost validation workflow_dispatch: inputs: dry_run: description: 'Dry run (skip PR creation)' type: boolean default: false skip_expensive: description: 'Skip expensive scanners (P2/P3)' type: boolean default: false concurrency: group: autonomous-code-scanner cancel-in-progress: false jobs: # Job 1: Pre-Flight Security Check (10 min) security-check: name: Pre-Flight Security Check runs-on: ubuntu-latest timeout-minutes: 10 outputs: python_files_count: ${{ steps.generate-lists.outputs.python_count }} typescript_files_count: ${{ steps.generate-lists.outputs.typescript_count }} shell_files_count: ${{ steps.generate-lists.outputs.shell_count }} total_files: ${{ steps.generate-lists.outputs.total }} steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - name: Validate required secrets env: HAS_API_KEY: ${{ secrets.ANTHROPIC_API_KEY != '' }} run: | if [ "$HAS_API_KEY" != "true" ]; then echo "::error::ANTHROPIC_API_KEY not configured" exit 1 fi echo "Required secrets present" - name: Generate scannable file lists id: generate-lists run: | # Protected file patterns (ABSOLUTE BLOCK) PROTECTED_PATTERNS=( ".github/workflows/*" ".env*" "ods/installers/*" "ods/ods-cli" "ods/config/*" ) # Generate Python file list git ls-files 'ods/**/*.py' '.github/scripts/*.py' | while read -r file; do PROTECTED=false for pattern in "${PROTECTED_PATTERNS[@]}"; do if [[ "$file" == $pattern ]]; then PROTECTED=true break fi done if [ "$PROTECTED" = false ]; then echo "$file" fi done > /tmp/scannable_python.txt # Generate TypeScript file list (dashboard) git ls-files 'ods/extensions/services/dashboard/src/**/*.ts' \ 'ods/extensions/services/dashboard/src/**/*.tsx' | while read -r file; do echo "$file" done > /tmp/scannable_typescript.txt # Generate Shell file list git ls-files 'ods/scripts/*.sh' 'ods/tests/**/*.sh' | while read -r file; do PROTECTED=false for pattern in "${PROTECTED_PATTERNS[@]}"; do if [[ "$file" == $pattern ]]; then PROTECTED=true break fi done if [ "$PROTECTED" = false ]; then echo "$file" fi done > /tmp/scannable_shell.txt # Count files PYTHON_COUNT=$(wc -l < /tmp/scannable_python.txt | tr -d ' ') TS_COUNT=$(wc -l < /tmp/scannable_typescript.txt | tr -d ' ') SHELL_COUNT=$(wc -l < /tmp/scannable_shell.txt | tr -d ' ') TOTAL=$((PYTHON_COUNT + TS_COUNT + SHELL_COUNT)) echo "python_count=$PYTHON_COUNT" >> $GITHUB_OUTPUT echo "typescript_count=$TS_COUNT" >> $GITHUB_OUTPUT echo "shell_count=$SHELL_COUNT" >> $GITHUB_OUTPUT echo "total=$TOTAL" >> $GITHUB_OUTPUT echo "## Scannable Files" >> $GITHUB_STEP_SUMMARY echo "- **Python**: $PYTHON_COUNT files" >> $GITHUB_STEP_SUMMARY echo "- **TypeScript**: $TS_COUNT files" >> $GITHUB_STEP_SUMMARY echo "- **Shell**: $SHELL_COUNT files" >> $GITHUB_STEP_SUMMARY echo "- **Total**: $TOTAL files" >> $GITHUB_STEP_SUMMARY - name: Upload scannable file lists uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: scannable-files path: | /tmp/scannable_python.txt /tmp/scannable_typescript.txt /tmp/scannable_shell.txt retention-days: 7 # Job 2: Formatting Scanner (15 min, P0) scan-formatting: name: Formatting Scanner (P0) runs-on: ubuntu-latest needs: security-check timeout-minutes: 15 outputs: has_changes: ${{ steps.detect-changes.outputs.has_changes }} files_changed: ${{ steps.detect-changes.outputs.files_changed }} cost: ${{ steps.calculate-cost.outputs.cost }} steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.11' - name: Install Ruff run: pip install ruff - name: Download scannable files uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: scannable-files path: /tmp - name: Run Ruff format and check run: | echo "## Formatting Scan Results" >> $GITHUB_STEP_SUMMARY # Run ruff format on all scannable Python files TOTAL=0 FORMATTED=0 while IFS= read -r file; do if [ -f "$file" ]; then TOTAL=$((TOTAL + 1)) # Format file if ruff format "$file" 2>/dev/null; then if ! git diff --quiet "$file"; then FORMATTED=$((FORMATTED + 1)) fi fi # Fix linting issues ruff check "$file" --fix --quiet 2>/dev/null || true fi done < /tmp/scannable_python.txt echo "- **Files scanned**: $TOTAL" >> $GITHUB_STEP_SUMMARY echo "- **Files formatted**: $FORMATTED" >> $GITHUB_STEP_SUMMARY - name: Detect changes id: detect-changes run: | if git diff --quiet; then echo "has_changes=false" >> $GITHUB_OUTPUT echo "files_changed=0" >> $GITHUB_OUTPUT echo "No formatting changes needed" >> $GITHUB_STEP_SUMMARY else FILES_CHANGED=$(git diff --name-only | wc -l | tr -d ' ') echo "has_changes=true" >> $GITHUB_OUTPUT echo "files_changed=$FILES_CHANGED" >> $GITHUB_OUTPUT echo "### Changed Files" >> $GITHUB_STEP_SUMMARY git diff --stat >> $GITHUB_STEP_SUMMARY git diff --stat > /tmp/formatting-summary.txt fi - name: Create git patch if: steps.detect-changes.outputs.has_changes == 'true' run: | git diff > /tmp/formatting-changes.patch echo "Created patch file with $(wc -l < /tmp/formatting-changes.patch) lines" - name: Calculate cost id: calculate-cost run: | # Formatting is $0.50 (CI overhead only, zero API cost) echo "cost=0.50" >> $GITHUB_OUTPUT echo "**Cost**: \$0.50" >> $GITHUB_STEP_SUMMARY - name: Upload formatting summary and patch if: steps.detect-changes.outputs.has_changes == 'true' uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: formatting-changes path: | /tmp/formatting-summary.txt /tmp/formatting-changes.patch retention-days: 7 # Job 3: Security Scanner (30 min, P1) scan-security: name: Security Scanner (P1) runs-on: ubuntu-latest needs: security-check timeout-minutes: 30 outputs: has_findings: ${{ steps.analyze-findings.outputs.has_findings }} finding_count: ${{ steps.analyze-findings.outputs.finding_count }} high_severity_count: ${{ steps.analyze-findings.outputs.high_severity_count }} cost: ${{ steps.calculate-cost.outputs.cost }} steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.11' - name: Install Bandit run: pip install bandit[toml] - name: Download scannable files uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: scannable-files path: /tmp - name: Run Bandit security scan run: | echo "## Security Scan Results" >> $GITHUB_STEP_SUMMARY # Run Bandit on ODS Python files bandit -r ods/extensions/services/dashboard-api \ ods/scripts \ .github/scripts \ --format json \ --output /tmp/bandit-results.json \ --severity-level medium \ --confidence-level medium \ --exclude __pycache__ \ || true # Don't fail on findings # Count findings by severity if [ -f /tmp/bandit-results.json ]; then HIGH=$(jq '[.results[] | select(.issue_severity=="HIGH")] | length' /tmp/bandit-results.json) MEDIUM=$(jq '[.results[] | select(.issue_severity=="MEDIUM")] | length' /tmp/bandit-results.json) LOW=$(jq '[.results[] | select(.issue_severity=="LOW")] | length' /tmp/bandit-results.json) echo "- **High severity**: $HIGH" >> $GITHUB_STEP_SUMMARY echo "- **Medium severity**: $MEDIUM" >> $GITHUB_STEP_SUMMARY echo "- **Low severity**: $LOW" >> $GITHUB_STEP_SUMMARY fi - name: Filter high severity findings id: filter-findings run: | if [ -f /tmp/bandit-results.json ]; then # Extract top 20 high severity findings jq '[.results[] | select(.issue_severity=="HIGH")] | .[0:20]' \ /tmp/bandit-results.json > /tmp/high-severity.json HIGH_COUNT=$(jq '. | length' /tmp/high-severity.json) echo "high_count=$HIGH_COUNT" >> $GITHUB_OUTPUT else echo "high_count=0" >> $GITHUB_OUTPUT fi - name: Analyze findings id: analyze-findings run: | if [ -f /tmp/bandit-results.json ]; then TOTAL=$(jq '.results | length' /tmp/bandit-results.json) HIGH=$(jq '[.results[] | select(.issue_severity=="HIGH")] | length' /tmp/bandit-results.json) if [ "$TOTAL" -gt 0 ]; then echo "has_findings=true" >> $GITHUB_OUTPUT echo "finding_count=$TOTAL" >> $GITHUB_OUTPUT echo "high_severity_count=$HIGH" >> $GITHUB_OUTPUT else echo "has_findings=false" >> $GITHUB_OUTPUT echo "finding_count=0" >> $GITHUB_OUTPUT echo "high_severity_count=0" >> $GITHUB_OUTPUT fi else echo "has_findings=false" >> $GITHUB_OUTPUT echo "finding_count=0" >> $GITHUB_OUTPUT echo "high_severity_count=0" >> $GITHUB_OUTPUT fi - name: Calculate cost id: calculate-cost run: | # Bandit only — no API cost echo "cost=2.00" >> $GITHUB_OUTPUT echo "**Cost**: \$2.00" >> $GITHUB_STEP_SUMMARY - name: Upload security findings if: steps.analyze-findings.outputs.has_findings == 'true' uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: security-findings path: | /tmp/bandit-results.json /tmp/high-severity.json retention-days: 7 # Job 4: Cost Tracker (5 min) cost-tracker: name: Cost Tracker & Budget Gates runs-on: ubuntu-latest needs: [scan-formatting, scan-security] timeout-minutes: 5 outputs: total_cost: ${{ steps.calculate.outputs.total }} budget_remaining: ${{ steps.calculate.outputs.remaining }} budget_exceeded: ${{ steps.calculate.outputs.exceeded }} run_p2: ${{ steps.gates.outputs.run_p2 }} run_p3: ${{ steps.gates.outputs.run_p3 }} steps: - name: Calculate total cost id: calculate env: FORMATTING_COST: ${{ needs.scan-formatting.outputs.cost }} SECURITY_COST: ${{ needs.scan-security.outputs.cost }} run: | FORMATTING="${FORMATTING_COST:-0}" SECURITY="${SECURITY_COST:-0}" TOTAL=$(echo "$FORMATTING + $SECURITY" | bc) REMAINING=$(echo "100 - $TOTAL" | bc) echo "total=$TOTAL" >> $GITHUB_OUTPUT echo "remaining=$REMAINING" >> $GITHUB_OUTPUT # Check if budget exceeded if (( $(echo "$TOTAL > 90" | bc -l) )); then echo "exceeded=true" >> $GITHUB_OUTPUT else echo "exceeded=false" >> $GITHUB_OUTPUT fi echo "## Cost Summary" >> $GITHUB_STEP_SUMMARY echo "- **Formatting**: \$$FORMATTING" >> $GITHUB_STEP_SUMMARY echo "- **Security**: \$$SECURITY" >> $GITHUB_STEP_SUMMARY echo "- **Total so far**: \$$TOTAL" >> $GITHUB_STEP_SUMMARY echo "- **Budget remaining**: \$$REMAINING" >> $GITHUB_STEP_SUMMARY - name: Determine budget gates id: gates env: BUDGET_REMAINING: ${{ steps.calculate.outputs.remaining }} SKIP_EXPENSIVE_INPUT: ${{ github.event.inputs.skip_expensive || 'false' }} run: | REMAINING="${BUDGET_REMAINING:-0}" SKIP_EXPENSIVE="$SKIP_EXPENSIVE_INPUT" # P2 (Type Hints) runs if budget >$40 and not manually skipped if (( $(echo "$REMAINING > 40" | bc -l) )) && [ "$SKIP_EXPENSIVE" != "true" ]; then echo "run_p2=true" >> $GITHUB_OUTPUT echo "**P2 (Type Hints)**: Will run" >> $GITHUB_STEP_SUMMARY else echo "run_p2=false" >> $GITHUB_OUTPUT echo "**P2 (Type Hints)**: Skipped (budget: \$$REMAINING)" >> $GITHUB_STEP_SUMMARY fi # P3 (Documentation) runs if budget >$30 and not manually skipped if (( $(echo "$REMAINING > 30" | bc -l) )) && [ "$SKIP_EXPENSIVE" != "true" ]; then echo "run_p3=true" >> $GITHUB_OUTPUT echo "**P3 (Documentation)**: Will run" >> $GITHUB_STEP_SUMMARY else echo "run_p3=false" >> $GITHUB_OUTPUT echo "**P3 (Documentation)**: Skipped (budget: \$$REMAINING)" >> $GITHUB_STEP_SUMMARY fi # Job 5: Type Hints Scanner (25 min, P2, Conditional) scan-type-hints: name: Type Hints Scanner (P2) runs-on: ubuntu-latest needs: [security-check, cost-tracker] if: needs.cost-tracker.outputs.run_p2 == 'true' timeout-minutes: 25 outputs: has_suggestions: ${{ steps.analyze.outputs.has_suggestions }} has_changes: ${{ steps.detect-changes.outputs.has_changes }} functions_annotated: ${{ steps.analyze.outputs.functions_annotated }} cost: ${{ steps.calculate-cost.outputs.cost }} steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.11' - name: Download scannable files uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: scannable-files path: /tmp - name: Find functions without type hints id: find-functions run: | echo "## Type Hints Scan" >> $GITHUB_STEP_SUMMARY cat > /tmp/find_missing_hints.py << 'PYTHON' import ast import json from pathlib import Path def find_missing_hints(file_path): """Find functions without type hints.""" try: with open(file_path, 'r') as f: tree = ast.parse(f.read(), filename=file_path) except: return [] missing = [] for node in ast.walk(tree): if isinstance(node, ast.FunctionDef): if node.returns: continue if node.name.startswith('_'): continue missing.append({ 'file': str(file_path), 'function': node.name, 'line': node.lineno }) return missing results = [] with open('/tmp/scannable_python.txt', 'r') as f: files = [line.strip() for line in f if line.strip()] for file_path in files[:100]: missing = find_missing_hints(file_path) results.extend(missing[:50]) if len(results) >= 50: break with open('/tmp/missing-hints.json', 'w') as f: json.dump(results[:50], f, indent=2) print(f"Found {len(results)} functions without type hints") PYTHON python /tmp/find_missing_hints.py MISSING_COUNT=$(jq '. | length' /tmp/missing-hints.json) echo "missing_count=$MISSING_COUNT" >> $GITHUB_OUTPUT echo "- **Functions without hints**: $MISSING_COUNT" >> $GITHUB_STEP_SUMMARY - name: Install dependencies for type hints generation if: steps.find-functions.outputs.missing_count > 0 run: pip install anthropic httpx - name: Generate type hints id: generate if: steps.find-functions.outputs.missing_count > 0 env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} run: | echo "### Type Hints Generation" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "Generating type hints for ${{ steps.find-functions.outputs.missing_count }} functions..." >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY python .github/scripts/generate-type-hints.py \ /tmp/missing-hints.json \ /tmp/type-hints-suggestions.json | tee -a $GITHUB_STEP_SUMMARY # Cost is estimated from batch count; default if script doesn't report echo "type_hints_cost=35.00" >> $GITHUB_OUTPUT - name: Apply type hints to source files id: apply if: steps.find-functions.outputs.missing_count > 0 run: | echo "### Applying Type Hints" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY if [ -f /tmp/type-hints-suggestions.json ]; then python .github/scripts/apply-type-hints.py \ /tmp/type-hints-suggestions.json >> $GITHUB_STEP_SUMMARY else echo "No suggestions file found, skipping apply step" >> $GITHUB_STEP_SUMMARY fi - name: Detect type hint changes id: detect-changes run: | if git diff --quiet; then echo "has_changes=false" >> $GITHUB_OUTPUT echo "files_changed=0" >> $GITHUB_OUTPUT echo "No type hint changes applied" >> $GITHUB_STEP_SUMMARY else FILES_CHANGED=$(git diff --name-only | wc -l | tr -d ' ') echo "has_changes=true" >> $GITHUB_OUTPUT echo "files_changed=$FILES_CHANGED" >> $GITHUB_OUTPUT echo "### Changed Files" >> $GITHUB_STEP_SUMMARY git diff --stat >> $GITHUB_STEP_SUMMARY fi - name: Create type hints patch if: steps.detect-changes.outputs.has_changes == 'true' run: | git diff > /tmp/type-hints-changes.patch echo "Created patch file with $(wc -l < /tmp/type-hints-changes.patch) lines" git checkout . - name: Analyze results id: analyze run: | MISSING=${{ steps.find-functions.outputs.missing_count }} if [ "$MISSING" -gt 0 ]; then echo "has_suggestions=true" >> $GITHUB_OUTPUT echo "functions_annotated=$MISSING" >> $GITHUB_OUTPUT else echo "has_suggestions=false" >> $GITHUB_OUTPUT echo "functions_annotated=0" >> $GITHUB_OUTPUT fi - name: Calculate cost id: calculate-cost run: | COST=${{ steps.generate.outputs.type_hints_cost || '35.00' }} echo "cost=$COST" >> $GITHUB_OUTPUT echo "**Actual cost**: \$$COST" >> $GITHUB_STEP_SUMMARY - name: Upload type hints suggestions if: steps.analyze.outputs.has_suggestions == 'true' uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: type-hints-suggestions path: | /tmp/missing-hints.json /tmp/type-hints-suggestions.json /tmp/type-hints-changes.patch retention-days: 7 # Job 6: Documentation Scanner (20 min, P3, Conditional) scan-documentation: name: Documentation Scanner (P3) runs-on: ubuntu-latest needs: [security-check, cost-tracker] if: needs.cost-tracker.outputs.run_p3 == 'true' timeout-minutes: 20 outputs: has_suggestions: ${{ steps.analyze.outputs.has_suggestions }} has_changes: ${{ steps.detect-changes.outputs.has_changes }} functions_documented: ${{ steps.analyze.outputs.functions_documented }} cost: ${{ steps.calculate-cost.outputs.cost }} steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Set up Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: '3.11' - name: Download scannable files uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: scannable-files path: /tmp - name: Find functions without docstrings id: find-functions run: | echo "## Documentation Scan" >> $GITHUB_STEP_SUMMARY cat > /tmp/find_missing_docs.py << 'PYTHON' import ast import json from pathlib import Path def find_missing_docstrings(file_path): """Find functions without docstrings.""" try: with open(file_path, 'r') as f: tree = ast.parse(f.read(), filename=file_path) except: return [] missing = [] for node in ast.walk(tree): if isinstance(node, ast.FunctionDef): docstring = ast.get_docstring(node) if docstring: continue if node.name.startswith('_') and not node.name.startswith('__'): continue missing.append({ 'file': str(file_path), 'function': node.name, 'line': node.lineno }) return missing results = [] with open('/tmp/scannable_python.txt', 'r') as f: files = [line.strip() for line in f if line.strip()] for file_path in files[:80]: missing = find_missing_docstrings(file_path) results.extend(missing[:40]) if len(results) >= 40: break with open('/tmp/missing-docs.json', 'w') as f: json.dump(results[:40], f, indent=2) print(f"Found {len(results)} functions without docstrings") PYTHON python /tmp/find_missing_docs.py MISSING_COUNT=$(jq '. | length' /tmp/missing-docs.json) echo "missing_count=$MISSING_COUNT" >> $GITHUB_OUTPUT echo "- **Functions without docstrings**: $MISSING_COUNT" >> $GITHUB_STEP_SUMMARY - name: Install dependencies for docstring generation if: steps.find-functions.outputs.missing_count > 0 run: pip install anthropic httpx - name: Generate docstrings id: generate if: steps.find-functions.outputs.missing_count > 0 env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} run: | echo "### Docstring Generation" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "Generating docstrings for ${{ steps.find-functions.outputs.missing_count }} functions..." >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY python .github/scripts/generate-docstrings.py \ /tmp/missing-docs.json \ /tmp/documentation-suggestions.json | tee -a $GITHUB_STEP_SUMMARY # Cost is estimated from batch count; default if script doesn't report echo "docstring_cost=25.00" >> $GITHUB_OUTPUT - name: Apply docstrings to source files id: apply if: steps.find-functions.outputs.missing_count > 0 run: | echo "### Applying Docstrings" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY if [ -f /tmp/documentation-suggestions.json ]; then python .github/scripts/apply-docstrings.py \ /tmp/documentation-suggestions.json >> $GITHUB_STEP_SUMMARY else echo "No suggestions file found, skipping apply step" >> $GITHUB_STEP_SUMMARY fi - name: Detect documentation changes id: detect-changes run: | if git diff --quiet; then echo "has_changes=false" >> $GITHUB_OUTPUT echo "files_changed=0" >> $GITHUB_OUTPUT echo "No documentation changes applied" >> $GITHUB_STEP_SUMMARY else FILES_CHANGED=$(git diff --name-only | wc -l | tr -d ' ') echo "has_changes=true" >> $GITHUB_OUTPUT echo "files_changed=$FILES_CHANGED" >> $GITHUB_OUTPUT echo "### Changed Files" >> $GITHUB_STEP_SUMMARY git diff --stat >> $GITHUB_STEP_SUMMARY fi - name: Create documentation patch if: steps.detect-changes.outputs.has_changes == 'true' run: | git diff > /tmp/documentation-changes.patch echo "Created patch file with $(wc -l < /tmp/documentation-changes.patch) lines" git checkout . - name: Analyze results id: analyze run: | MISSING=${{ steps.find-functions.outputs.missing_count }} if [ "$MISSING" -gt 0 ]; then echo "has_suggestions=true" >> $GITHUB_OUTPUT echo "functions_documented=$MISSING" >> $GITHUB_OUTPUT else echo "has_suggestions=false" >> $GITHUB_OUTPUT echo "functions_documented=0" >> $GITHUB_OUTPUT fi - name: Calculate cost id: calculate-cost run: | COST=${{ steps.generate.outputs.docstring_cost || '25.00' }} echo "cost=$COST" >> $GITHUB_OUTPUT echo "**Actual cost**: \$$COST" >> $GITHUB_STEP_SUMMARY - name: Upload documentation suggestions if: steps.analyze.outputs.has_suggestions == 'true' uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 with: name: documentation-suggestions path: | /tmp/missing-docs.json /tmp/documentation-suggestions.json /tmp/documentation-changes.patch retention-days: 7 # Job 7: PR Creation (15 min) create-prs: name: Create Pull Requests runs-on: ubuntu-latest permissions: contents: write pull-requests: write issues: write needs: - security-check - scan-formatting - scan-security - cost-tracker - scan-type-hints - scan-documentation if: | always() && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') timeout-minutes: 15 outputs: pr_count: ${{ steps.final-summary.outputs.pr_count }} total_cost: ${{ steps.final-summary.outputs.total_cost }} remaining_budget: ${{ steps.final-summary.outputs.remaining_budget }} steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: token: ${{ secrets.GITHUB_TOKEN }} fetch-depth: 0 - name: Download all artifacts uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: /tmp/artifacts - name: Configure git run: | git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - name: Apply formatting patch if: needs.scan-formatting.outputs.has_changes == 'true' run: | echo "## Applying Formatting Patch" >> $GITHUB_STEP_SUMMARY if [ -f /tmp/artifacts/formatting-changes/formatting-changes.patch ]; then if git apply --check /tmp/artifacts/formatting-changes/formatting-changes.patch 2>/dev/null; then git apply /tmp/artifacts/formatting-changes/formatting-changes.patch echo "Formatting patch applied successfully" >> $GITHUB_STEP_SUMMARY git diff --stat >> $GITHUB_STEP_SUMMARY else echo "::error::Failed to apply formatting patch" echo "Formatting patch application failed" >> $GITHUB_STEP_SUMMARY exit 1 fi else echo "::warning::Formatting patch file not found" echo "Formatting patch file not found" >> $GITHUB_STEP_SUMMARY fi - name: Verify changes before PR if: | needs.scan-formatting.outputs.has_changes == 'true' || needs.scan-security.outputs.has_findings == 'true' || (needs.scan-type-hints.result == 'success' && needs.scan-type-hints.outputs.has_changes == 'true') || (needs.scan-documentation.result == 'success' && needs.scan-documentation.outputs.has_changes == 'true') env: HAS_FORMATTING_CHANGES: ${{ needs.scan-formatting.outputs.has_changes }} run: | echo "## Verifying Changes" >> $GITHUB_STEP_SUMMARY if [ "$HAS_FORMATTING_CHANGES" == "true" ]; then if git diff --quiet; then echo "::error::Expected formatting changes but working tree is clean" echo "No changes detected after patch apply" >> $GITHUB_STEP_SUMMARY exit 1 fi echo "Formatting changes detected, ready to create PR" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Changed files:**" >> $GITHUB_STEP_SUMMARY git diff --stat >> $GITHUB_STEP_SUMMARY else echo "Validation passed (no formatting changes)" >> $GITHUB_STEP_SUMMARY fi - name: Pre-PR validation id: validate run: | echo "## Pre-PR Validation" >> $GITHUB_STEP_SUMMARY # 1. Secret scanning SECRETS_FOUND=$(git diff | grep '^+' | grep -v '^+++' | grep -iE \ '(sk-[a-zA-Z0-9]{20,}|AKIA[A-Z0-9]{12,}|(api[_-]?key|password|secret|token)\s*[:=]\s*["\x27][a-zA-Z0-9].{8,}["\x27])' || true) if [ -n "$SECRETS_FOUND" ]; then echo "::error::Potential secret detected in diff" echo "$SECRETS_FOUND" | head -5 >> $GITHUB_STEP_SUMMARY echo "**Secret scan**: FAILED" >> $GITHUB_STEP_SUMMARY exit 1 fi echo "**Secret scan**: Passed" >> $GITHUB_STEP_SUMMARY # 2. Python syntax check if git diff --name-only | grep -q '\.py$'; then find . -name "*.py" -type f | while read -r file; do if ! python3 -m py_compile "$file" 2>/dev/null; then echo "::error::Syntax error in $file" echo "**Syntax check**: FAILED ($file)" >> $GITHUB_STEP_SUMMARY exit 1 fi done echo "**Syntax check**: Passed" >> $GITHUB_STEP_SUMMARY fi # 3. Diff size check if git diff --quiet; then LINES=0 else LINES=$(git diff --stat | tail -1 | awk '{print $4+$6}') fi echo "diff_lines=$LINES" >> $GITHUB_OUTPUT if [ "$LINES" -gt 5000 ]; then echo "**Diff size**: Large ($LINES lines - requires extra review)" >> $GITHUB_STEP_SUMMARY else echo "**Diff size**: $LINES lines" >> $GITHUB_STEP_SUMMARY fi - name: Create PR - Formatting id: pr-formatting if: | needs.scan-formatting.outputs.has_changes == 'true' && github.event.inputs.dry_run != 'true' uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: token: ${{ secrets.GITHUB_TOKEN }} branch: scanner/formatting-${{ github.run_id }} draft: true delete-branch: true title: "[Auto-Scan] Formatting & Linting Fixes" body: | ## Automated Formatting Fixes **Run ID**: ${{ github.run_id }} **Files changed**: ${{ needs.scan-formatting.outputs.files_changed }} **Tool**: Ruff (format + check --fix) **Cost**: $0.50 ### Changes This PR applies automated formatting and linting fixes using Ruff: - Code formatting (line length, indentation, etc.) - Auto-fixable linting issues (unused imports, etc.) ### Review Instructions 1. Review changed files for correctness 2. Run tests: `cd ods && make test` 3. Approve and merge if tests pass ### Safety - Protected files excluded from scanning - All changes are formatting-only (no logic changes) - Draft PR requires manual approval --- *Generated by Autonomous Code Scanner* - name: Label PR - Formatting if: steps.pr-formatting.outputs.pull-request-number env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ steps.pr-formatting.outputs.pull-request-number }} run: gh pr edit "$PR_NUMBER" --add-label "auto-formatting,ai-generated,needs-human-review" - name: Create PR - Security id: pr-security if: | needs.scan-security.outputs.has_findings == 'true' && github.event.inputs.dry_run != 'true' uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: token: ${{ secrets.GITHUB_TOKEN }} branch: scanner/security-${{ github.run_id }} draft: true delete-branch: true title: "[Auto-Scan] Security Findings - REQUIRES REVIEW" body: | ## Security Vulnerabilities Found **Run ID**: ${{ github.run_id }} **Findings**: ${{ needs.scan-security.outputs.finding_count }} **High severity**: ${{ needs.scan-security.outputs.high_severity_count }} **Cost**: ${{ needs.scan-security.outputs.cost }} ### CRITICAL: Manual Security Review Required This PR contains potential security vulnerabilities detected by Bandit. **DO NOT merge without:** 1. Manual review of each finding 2. Validation that fixes don't break functionality 3. Security testing of affected code paths 4. Second pair of eyes review ### Findings Summary See attached artifacts for detailed findings: - `bandit-results.json` - Full Bandit scan results - `high-severity.json` - High severity findings only --- *Generated by Autonomous Code Scanner* - name: Label PR - Security if: steps.pr-security.outputs.pull-request-number env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ steps.pr-security.outputs.pull-request-number }} run: gh pr edit "$PR_NUMBER" --add-label "auto-security,needs-human-review" - name: Reset checkout before type hints if: | needs.scan-type-hints.result == 'success' && needs.scan-type-hints.outputs.has_changes == 'true' run: git checkout -- . - name: Apply type hints patch if: | needs.scan-type-hints.result == 'success' && needs.scan-type-hints.outputs.has_changes == 'true' run: | echo "## Applying Type Hints Patch" >> $GITHUB_STEP_SUMMARY PATCH_FILE="/tmp/artifacts/type-hints-suggestions/type-hints-changes.patch" if [ -f "$PATCH_FILE" ]; then if git apply --check "$PATCH_FILE" 2>/dev/null; then git apply "$PATCH_FILE" echo "Type hints patch applied successfully" >> $GITHUB_STEP_SUMMARY git diff --stat >> $GITHUB_STEP_SUMMARY else echo "::warning::Type hints patch could not be applied cleanly" echo "Type hints patch application failed" >> $GITHUB_STEP_SUMMARY fi else echo "::warning::Type hints patch file not found" echo "Type hints patch file not found" >> $GITHUB_STEP_SUMMARY fi - name: Create PR - Type Hints id: pr-type-hints if: | needs.scan-type-hints.result == 'success' && needs.scan-type-hints.outputs.has_changes == 'true' && github.event.inputs.dry_run != 'true' uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: token: ${{ secrets.GITHUB_TOKEN }} branch: scanner/type-hints-${{ github.run_id }} draft: true delete-branch: true title: "[Auto-Scan] Type Hints Improvements" body: | ## Type Hints Improvements **Run ID**: ${{ github.run_id }} **Functions annotated**: ${{ needs.scan-type-hints.outputs.functions_annotated }} **Cost**: ${{ needs.scan-type-hints.outputs.cost }} ### Changes This PR adds type annotations to function signatures that were missing them. Types were generated by Claude Haiku 4.5 and applied via AST-based function lookup. - Parameter type annotations added - Return type annotations added - Required imports inserted (deduplicated against existing imports) - All modified files validated with `py_compile` ### Review Instructions 1. Verify type hints are accurate for each function 2. Run tests: `cd ods && make test` 3. Approve and merge if checks pass ### Safety - Protected files excluded from scanning - AST-based lookup ensures correct function targeting - Files that fail `py_compile` after modification are automatically reverted - Draft PR requires manual approval --- *Generated by Autonomous Code Scanner* - name: Label PR - Type Hints if: steps.pr-type-hints.outputs.pull-request-number env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ steps.pr-type-hints.outputs.pull-request-number }} run: gh pr edit "$PR_NUMBER" --add-label "auto-type-hints,ai-generated,needs-human-review" - name: Reset checkout before documentation if: | needs.scan-documentation.result == 'success' && needs.scan-documentation.outputs.has_changes == 'true' run: git checkout -- . - name: Apply documentation patch if: | needs.scan-documentation.result == 'success' && needs.scan-documentation.outputs.has_changes == 'true' run: | echo "## Applying Documentation Patch" >> $GITHUB_STEP_SUMMARY PATCH_FILE="/tmp/artifacts/documentation-suggestions/documentation-changes.patch" if [ -f "$PATCH_FILE" ]; then if git apply --check "$PATCH_FILE" 2>/dev/null; then git apply "$PATCH_FILE" echo "Documentation patch applied successfully" >> $GITHUB_STEP_SUMMARY git diff --stat >> $GITHUB_STEP_SUMMARY else echo "::warning::Documentation patch could not be applied cleanly" echo "Documentation patch application failed" >> $GITHUB_STEP_SUMMARY fi else echo "::warning::Documentation patch file not found" echo "Documentation patch file not found" >> $GITHUB_STEP_SUMMARY fi - name: Create PR - Documentation id: pr-documentation if: | needs.scan-documentation.result == 'success' && needs.scan-documentation.outputs.has_changes == 'true' && github.event.inputs.dry_run != 'true' uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: token: ${{ secrets.GITHUB_TOKEN }} branch: scanner/documentation-${{ github.run_id }} draft: true delete-branch: true title: "[Auto-Scan] Documentation Improvements" body: | ## Documentation Improvements **Run ID**: ${{ github.run_id }} **Functions documented**: ${{ needs.scan-documentation.outputs.functions_documented }} **Cost**: ${{ needs.scan-documentation.outputs.cost }} ### Changes This PR adds Google-style docstrings to functions that were missing documentation. Docstrings were generated by Claude Haiku 4.5 and inserted via AST-based function lookup. - Summary lines in imperative mood - Args/Returns/Raises sections where applicable - Functions with existing docstrings are skipped - All modified files validated with `py_compile` ### Review Instructions 1. Verify docstrings are accurate and helpful 2. Check formatting follows Google style 3. Run tests: `cd ods && make test` 4. Approve and merge if documentation is clear ### Safety - Protected files excluded from scanning - Functions with existing docstrings are never overwritten - Files that fail `py_compile` after modification are automatically reverted - Draft PR requires manual approval --- *Generated by Autonomous Code Scanner* - name: Label PR - Documentation if: steps.pr-documentation.outputs.pull-request-number env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ steps.pr-documentation.outputs.pull-request-number }} run: gh pr edit "$PR_NUMBER" --add-label "auto-documentation,ai-generated,needs-human-review" - name: Generate final summary id: final-summary if: always() env: RUN_ID: ${{ github.run_id }} HAS_FORMATTING: ${{ needs.scan-formatting.outputs.has_changes }} HAS_SECURITY: ${{ needs.scan-security.outputs.has_findings }} HAS_TYPE_HINTS: ${{ needs.scan-type-hints.outputs.has_changes }} HAS_DOCS: ${{ needs.scan-documentation.outputs.has_changes }} FORMATTING_COST: ${{ needs.scan-formatting.outputs.cost }} SECURITY_COST: ${{ needs.scan-security.outputs.cost }} TYPE_HINTS_COST: ${{ needs.scan-type-hints.outputs.cost }} DOCS_COST: ${{ needs.scan-documentation.outputs.cost }} IS_DRY_RUN: ${{ github.event.inputs.dry_run }} run: | echo "# Autonomous Code Scanner Results" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "**Scan date**: $(date -u '+%Y-%m-%d %H:%M UTC')" >> $GITHUB_STEP_SUMMARY echo "**Run ID**: $RUN_ID" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "## PRs Created" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY PR_COUNT=0 if [ "$HAS_FORMATTING" = "true" ]; then echo "- **Formatting**: Created (Branch: scanner/formatting-$RUN_ID)" >> $GITHUB_STEP_SUMMARY PR_COUNT=$((PR_COUNT + 1)) fi if [ "$HAS_SECURITY" = "true" ]; then echo "- **Security**: Created - NEEDS REVIEW (Branch: scanner/security-$RUN_ID)" >> $GITHUB_STEP_SUMMARY PR_COUNT=$((PR_COUNT + 1)) fi if [ "$HAS_TYPE_HINTS" = "true" ]; then echo "- **Type Hints**: Created (Branch: scanner/type-hints-$RUN_ID)" >> $GITHUB_STEP_SUMMARY PR_COUNT=$((PR_COUNT + 1)) fi if [ "$HAS_DOCS" = "true" ]; then echo "- **Documentation**: Created (Branch: scanner/documentation-$RUN_ID)" >> $GITHUB_STEP_SUMMARY PR_COUNT=$((PR_COUNT + 1)) fi echo "" >> $GITHUB_STEP_SUMMARY echo "**Total PRs**: $PR_COUNT" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "pr_count=$PR_COUNT" >> $GITHUB_OUTPUT echo "## Cost Summary" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY FORMATTING="${FORMATTING_COST:-0}" SECURITY="${SECURITY_COST:-0}" TYPES="${TYPE_HINTS_COST:-0}" DOCS="${DOCS_COST:-0}" TOTAL=$(echo "$FORMATTING + $SECURITY + $TYPES + $DOCS" | bc) REMAINING=$(echo "100 - $TOTAL" | bc) echo "total_cost=$TOTAL" >> $GITHUB_OUTPUT echo "remaining_budget=$REMAINING" >> $GITHUB_OUTPUT echo "| Scanner | Cost |" >> $GITHUB_STEP_SUMMARY echo "|---------|------|" >> $GITHUB_STEP_SUMMARY echo "| Formatting | \$$FORMATTING |" >> $GITHUB_STEP_SUMMARY echo "| Security | \$$SECURITY |" >> $GITHUB_STEP_SUMMARY echo "| Type Hints | \$$TYPES |" >> $GITHUB_STEP_SUMMARY echo "| Docs | \$$DOCS |" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "- **Total spend**: \$$TOTAL" >> $GITHUB_STEP_SUMMARY echo "- **Budget**: \$100" >> $GITHUB_STEP_SUMMARY echo "- **Remaining**: \$$REMAINING" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY if [ "$IS_DRY_RUN" = "true" ]; then echo "**Dry run mode**: No PRs were created" >> $GITHUB_STEP_SUMMARY fi - name: Create issue on workflow failure if: failure() uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; await github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, title: 'Autonomous Code Scanner Failed', labels: ['bug'], body: `## Workflow Failure Alert The Autonomous Code Scanner workflow failed during execution. **Run ID**: ${context.runId} **Triggered by**: ${context.eventName} **Timestamp**: ${new Date().toISOString()} ### Action Required 1. Review the [workflow logs](${runUrl}) for error details 2. Check if API keys are valid 3. Verify budget limits weren't exceeded [View Full Logs](${runUrl}) --- *This issue was automatically created by the Autonomous Code Scanner workflow.*` }); - name: Create issue for consecutive no-change runs if: | always() && steps.final-summary.outputs.pr_count == '0' uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | // Check if we've had 7+ consecutive no-change runs const runs = await github.rest.actions.listWorkflowRuns({ owner: context.repo.owner, repo: context.repo.repo, workflow_id: 'autonomous-code-scanner.yml', per_page: 7, status: 'completed' }); let consecutiveNoChange = 0; for (const run of runs.data.workflow_runs) { const jobs = await github.rest.actions.listJobsForWorkflowRun({ owner: context.repo.owner, repo: context.repo.repo, run_id: run.id }); const createPrJob = jobs.data.jobs.find(j => j.name === 'Create Pull Requests'); if (createPrJob && createPrJob.conclusion === 'success') { const hadChanges = createPrJob.steps.some(s => s.name.includes('Create PR') && s.conclusion === 'success' ); if (!hadChanges) { consecutiveNoChange++; } else { break; } } } if (consecutiveNoChange >= 7) { const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; const existingIssues = await github.rest.issues.listForRepo({ owner: context.repo.owner, repo: context.repo.repo, labels: 'scanner-no-changes', state: 'open' }); if (existingIssues.data.length === 0) { await github.rest.issues.create({ owner: context.repo.owner, repo: context.repo.repo, title: 'Autonomous Code Scanner: 7+ Consecutive No-Change Runs', labels: ['bug', 'scanner-no-changes'], body: `## Configuration Alert The Autonomous Code Scanner has completed **${consecutiveNoChange} consecutive runs** without creating any PRs. ### Possible Causes 1. **Codebase is clean**: All code meets quality standards 2. **Scanner configuration issue**: Scanners may not be detecting issues 3. **Protected files**: Too many files excluded from scanning 4. **Threshold too high**: Quality bars set too strict ### Recommended Actions 1. Manually run formatters/linters to verify scanner is working 2. Review protected file patterns in workflow 3. Check if scanners are running (not skipped due to budget) [View latest run](${runUrl}) --- *This issue was automatically created by the Autonomous Code Scanner workflow.*` }); } }