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