rust-diff-analyzer 2.0.1

Semantic analyzer for Rust PR diffs that distinguishes production code from test code
Documentation
name: 'Rust PR Diff Analyzer'
description: 'Analyze and limit PR size by distinguishing production code from test code using Rust AST analysis'
author: 'RAprogramm'

branding:
  icon: 'code'
  color: 'orange'

inputs:
  max_prod_units:
    description: 'Maximum number of production units allowed'
    required: false
    default: '30'
  max_weighted_score:
    description: 'Maximum weighted score allowed'
    required: false
    default: '100'
  max_prod_lines:
    description: 'Maximum production lines added'
    required: false
    default: ''
  fail_on_exceed:
    description: 'Fail the action if limits are exceeded'
    required: false
    default: 'true'
  output_format:
    description: 'Output format (github, json, human)'
    required: false
    default: 'github'
  config_file:
    description: 'Path to configuration file'
    required: false
    default: ''
  post_comment:
    description: 'Post analysis as PR comment'
    required: false
    default: 'false'
  update_comment:
    description: 'Update existing comment instead of creating new'
    required: false
    default: 'true'

outputs:
  prod_functions_changed:
    description: 'Number of production functions changed'
    value: ${{ steps.analyze.outputs.prod_functions_changed }}
  prod_structs_changed:
    description: 'Number of production structs changed'
    value: ${{ steps.analyze.outputs.prod_structs_changed }}
  prod_other_changed:
    description: 'Number of other production units changed'
    value: ${{ steps.analyze.outputs.prod_other_changed }}
  test_units_changed:
    description: 'Number of test units changed'
    value: ${{ steps.analyze.outputs.test_units_changed }}
  prod_lines_added:
    description: 'Lines added in production code'
    value: ${{ steps.analyze.outputs.prod_lines_added }}
  prod_lines_removed:
    description: 'Lines removed from production code'
    value: ${{ steps.analyze.outputs.prod_lines_removed }}
  test_lines_added:
    description: 'Lines added in test code'
    value: ${{ steps.analyze.outputs.test_lines_added }}
  test_lines_removed:
    description: 'Lines removed from test code'
    value: ${{ steps.analyze.outputs.test_lines_removed }}
  weighted_score:
    description: 'Weighted score of changes'
    value: ${{ steps.analyze.outputs.weighted_score }}
  exceeds_limit:
    description: 'Whether limits were exceeded'
    value: ${{ steps.analyze.outputs.exceeds_limit }}

runs:
  using: 'composite'
  steps:
    - name: Get PR diff
      id: get_diff
      shell: bash
      env:
        EVENT_NAME: ${{ github.event_name }}
        BASE_REF: ${{ github.base_ref }}
        CONFIG_FILE: ${{ inputs.config_file }}
        WORKSPACE: ${{ github.workspace }}
      run: |
        if [ "$EVENT_NAME" = "pull_request" ]; then
          git fetch --no-tags origin "$BASE_REF"

          # Shallow checkouts have no merge base for the three-dot diff
          if ! git merge-base "origin/$BASE_REF" HEAD >/dev/null 2>&1; then
            if [ -f "$(git rev-parse --git-dir)/shallow" ]; then
              git fetch --unshallow --no-tags origin "$BASE_REF" || true
              git fetch --unshallow --no-tags origin || true
            fi
            git merge-base "origin/$BASE_REF" HEAD >/dev/null
          fi

          # Skip analysis entirely when every commit in the range comes from
          # an ignored author (e.g. a dependabot-only PR). Mixed PRs are
          # analyzed in full: a single range cut point cannot correctly
          # exclude interleaved bot commits.
          IGNORED_AUTHORS=""
          if [ -n "$CONFIG_FILE" ] && [ -f "$WORKSPACE/$CONFIG_FILE" ]; then
            IGNORED_AUTHORS=$(awk '
              /^[[:space:]]*\[[a-z]/ { in_section = ($0 ~ /^\[classification\]/) }
              in_section && /^[[:space:]]*ignored_authors[[:space:]]*=/ { capture = 1 }
              capture {
                line = $0
                sub(/#.*$/, "", line)
                buffer = buffer line
                if (line ~ /\][[:space:]]*$/) { capture = 0; print buffer; buffer = "" }
              }
            ' "$WORKSPACE/$CONFIG_FILE" | { grep -o '"[^"]*"' || true; } | tr -d '"')
          fi

          if [ -n "$IGNORED_AUTHORS" ]; then
            ALL_IGNORED=true
            HAS_COMMITS=false
            while read -r author; do
              [ -n "$author" ] || continue
              HAS_COMMITS=true
              ignored=false
              while read -r ignored_author; do
                [ -n "$ignored_author" ] || continue
                case "$author" in
                  *"$ignored_author"*) ignored=true ;;
                esac
              done <<< "$IGNORED_AUTHORS"
              if [ "$ignored" = "false" ]; then
                ALL_IGNORED=false
                break
              fi
            done < <(git log --format=%ae "origin/$BASE_REF..HEAD")

            if [ "$HAS_COMMITS" = "true" ] && [ "$ALL_IGNORED" = "true" ]; then
              echo "All commits are from ignored authors - skipping analysis"
              echo "SKIP_ANALYSIS=true" >> "$GITHUB_ENV"
              exit 0
            fi
          fi

          git diff "origin/$BASE_REF...HEAD" > /tmp/pr_diff.txt
        else
          if [ -f "$(git rev-parse --git-dir)/shallow" ]; then
            git fetch --deepen=1 --no-tags origin || true
          fi
          if git rev-parse --verify --quiet HEAD~1 >/dev/null; then
            git diff HEAD~1 > /tmp/pr_diff.txt
          else
            : > /tmp/pr_diff.txt
          fi
        fi

    - name: Setup Rust
      uses: actions-rust-lang/setup-rust-toolchain@v1
      with:
        toolchain: stable

    - name: Build analyzer
      shell: bash
      run: |
        cd ${{ github.action_path }}
        cargo build --release

    - name: Run analysis
      id: analyze
      shell: bash
      env:
        MAX_PROD_UNITS: ${{ inputs.max_prod_units }}
        MAX_WEIGHTED_SCORE: ${{ inputs.max_weighted_score }}
        MAX_PROD_LINES: ${{ inputs.max_prod_lines }}
        OUTPUT_FORMAT: ${{ inputs.output_format }}
        CONFIG_FILE: ${{ inputs.config_file }}
        ACTION_PATH: ${{ github.action_path }}
      run: |
        # Skip analysis if all commits are from ignored authors
        if [ "$SKIP_ANALYSIS" = "true" ]; then
          echo "Skipping analysis - all commits from ignored authors"
          echo "prod_functions_changed=0" >> "$GITHUB_OUTPUT"
          echo "prod_structs_changed=0" >> "$GITHUB_OUTPUT"
          echo "prod_other_changed=0" >> "$GITHUB_OUTPUT"
          echo "test_units_changed=0" >> "$GITHUB_OUTPUT"
          echo "prod_lines_added=0" >> "$GITHUB_OUTPUT"
          echo "prod_lines_removed=0" >> "$GITHUB_OUTPUT"
          echo "test_lines_added=0" >> "$GITHUB_OUTPUT"
          echo "test_lines_removed=0" >> "$GITHUB_OUTPUT"
          echo "weighted_score=0" >> "$GITHUB_OUTPUT"
          echo "exceeds_limit=false" >> "$GITHUB_OUTPUT"
          echo "EXCEEDS_LIMIT=false" >> "$GITHUB_ENV"
          exit 0
        fi

        ARGS=(--diff-file /tmp/pr_diff.txt)
        ARGS+=(--max-units "$MAX_PROD_UNITS")
        ARGS+=(--max-score "$MAX_WEIGHTED_SCORE")
        ARGS+=(--no-fail)

        if [ -n "$MAX_PROD_LINES" ]; then
          ARGS+=(--max-lines "$MAX_PROD_LINES")
        fi

        if [ -n "$CONFIG_FILE" ]; then
          ARGS+=(--config "$CONFIG_FILE")
        fi

        BINARY="$ACTION_PATH/target/release/rust-diff-analyzer"

        # Always run the github format once: it feeds step outputs and the
        # exceeds_limit detection regardless of the user-facing format.
        GITHUB_FORMAT_OUTPUT=$("$BINARY" "${ARGS[@]}" --format github)
        echo "$GITHUB_FORMAT_OUTPUT" >> "$GITHUB_OUTPUT"

        if [ "$OUTPUT_FORMAT" = "github" ]; then
          echo "$GITHUB_FORMAT_OUTPUT"
        else
          "$BINARY" "${ARGS[@]}" --format "$OUTPUT_FORMAT"
        fi

        if echo "$GITHUB_FORMAT_OUTPUT" | grep -q "exceeds_limit=true"; then
          echo "EXCEEDS_LIMIT=true" >> "$GITHUB_ENV"
        else
          echo "EXCEEDS_LIMIT=false" >> "$GITHUB_ENV"
        fi

    - name: Post PR comment
      if: inputs.post_comment == 'true' && github.event_name == 'pull_request'
      shell: bash
      env:
        GH_TOKEN: ${{ github.token }}
        MAX_PROD_UNITS: ${{ inputs.max_prod_units }}
        MAX_WEIGHTED_SCORE: ${{ inputs.max_weighted_score }}
        MAX_PROD_LINES: ${{ inputs.max_prod_lines }}
        CONFIG_FILE: ${{ inputs.config_file }}
        UPDATE_COMMENT: ${{ inputs.update_comment }}
        ACTION_PATH: ${{ github.action_path }}
        PR_NUMBER: ${{ github.event.pull_request.number }}
        REPOSITORY: ${{ github.repository }}
      run: |
        ARGS=(--diff-file /tmp/pr_diff.txt)
        ARGS+=(--max-units "$MAX_PROD_UNITS")
        ARGS+=(--max-score "$MAX_WEIGHTED_SCORE")
        ARGS+=(--format comment)
        ARGS+=(--no-fail)

        if [ -n "$MAX_PROD_LINES" ]; then
          ARGS+=(--max-lines "$MAX_PROD_LINES")
        fi

        if [ -n "$CONFIG_FILE" ]; then
          ARGS+=(--config "$CONFIG_FILE")
        fi

        COMMENT=$("$ACTION_PATH/target/release/rust-diff-analyzer" "${ARGS[@]}")
        MARKER="<!-- rust-diff-analyzer-comment -->"

        if [ "$UPDATE_COMMENT" = "true" ]; then
          EXISTING_IDS=$(gh api --paginate "repos/$REPOSITORY/issues/$PR_NUMBER/comments" \
            --jq ".[] | select(.body | contains(\"$MARKER\")) | .id")
          EXISTING=$(printf '%s\n' "$EXISTING_IDS" | sed -n '1p')

          if [ -n "$EXISTING" ]; then
            gh api "repos/$REPOSITORY/issues/comments/$EXISTING" \
              -X PATCH -f body="$COMMENT"
            echo "Updated existing comment"
          else
            gh pr comment "$PR_NUMBER" --body "$COMMENT"
            echo "Created new comment"
          fi
        else
          gh pr comment "$PR_NUMBER" --body "$COMMENT"
          echo "Created new comment"
        fi

    - name: Fail if limits exceeded
      if: env.EXCEEDS_LIMIT == 'true' && inputs.fail_on_exceed == 'true'
      shell: bash
      run: |
        echo "::error::Production code changes exceed configured limits"
        exit 1