name: vociferate/decorate-pr description: > Decorate pull requests with coverage badges, unreleased changelog entries, and other useful review information. Updates existing comment or creates a new one if it doesn't exist. inputs: coverage-percentage: description: > Computed coverage percentage (0-100). Typically from coverage-badge action outputs. required: true badge-url: description: > Browser-facing URL for the coverage badge image (SVG). Typically from coverage-badge action outputs. required: true changelog: description: Path to changelog file relative to repository root. required: false default: CHANGELOG.md comment-title: description: > Title/identifier for the PR comment. Used to locate existing comment for updates. Defaults to 'Vociferate Review'. required: false default: 'Vociferate Review' token: description: > GitHub or Gitea token for posting PR comments. Defaults to the workflow token. required: false default: '' enable-changelog-gate: description: > Enable changelog gate validation. Validates that qualifying changes include entries in the Unreleased section of CHANGELOG. required: false default: 'false' changelog-gate-mode: description: > Gate mode: 'strict' fails the job if validation fails; 'soft' warns via PR comment only. required: false default: 'soft' changelog-gate-required-for: description: > Comma-separated types of changes that require changelog entries. Valid: code, behavior, security, workflow, tooling. required: false default: 'code,behavior,security,workflow,tooling' changelog-gate-allow-docs-only: description: > Allow PRs that only modify documentation files to skip changelog requirement. required: false default: 'true' changelog-gate-docs-globs: description: > Comma-separated glob patterns for files that are considered documentation (case-insensitive). required: false default: 'docs/**,**.md,**.txt,**.rst' changelog-gate-skip-labels: description: > Comma-separated PR labels that exempt a PR from changelog requirement. Example: skip-changelog. required: false default: '' outputs: comment-id: description: Numeric ID of the posted or updated PR comment. value: ${{ steps.post-comment.outputs.comment_id }} comment-url: description: URL to the posted or updated PR comment. value: ${{ steps.post-comment.outputs.comment_url }} gate-passed: description: > Whether changelog gate validation passed (true/false). Only set when gate is enabled. value: ${{ steps.changelog-gate.outputs.gate_passed }} docs-only: description: > Whether PR is docs-only (true/false). Only set when gate is enabled. value: ${{ steps.changelog-gate.outputs.docs_only }} unreleased-additions-count: description: > Number of lines added to Unreleased section in this PR. Only set when gate is enabled. value: ${{ steps.changelog-gate.outputs.unreleased_additions_count }} gate-failure-reason: description: > Human-readable reason for gate failure, if applicable. Only set when gate is enabled and failed. value: ${{ steps.changelog-gate.outputs.failure_reason }} runs: using: composite steps: - name: Validate PR context id: validate shell: bash env: GITHUB_EVENT_NAME: ${{ github.event_name }} PR_NUMBER: ${{ github.event.pull_request.number }} run: | set -euo pipefail if [[ "$GITHUB_EVENT_NAME" != "pull_request" ]]; then echo "This action only works on pull_request events" >&2 exit 1 fi if [[ -z "$PR_NUMBER" ]]; then echo "Could not determine PR number from context" >&2 exit 1 fi printf 'pr_number=%s\n' "$PR_NUMBER" >> "$GITHUB_OUTPUT" - name: Preflight comment API access id: preflight shell: bash env: AUTH_TOKEN: ${{ inputs.token != '' && inputs.token || github.token }} PR_NUMBER: ${{ steps.validate.outputs.pr_number }} SERVER_URL: ${{ github.server_url }} REPOSITORY: ${{ github.repository }} run: | set -euo pipefail if [[ -z "$AUTH_TOKEN" ]]; then echo "No token available for PR comment API calls. Set input token or provide workflow token." >&2 exit 1 fi api_url="${SERVER_URL}/api/v1" if [[ "$SERVER_URL" == *"github.com"* ]]; then api_url="https://api.github.com" fi comments_url="${api_url}/repos/${REPOSITORY}/issues/${PR_NUMBER}/comments" curl --fail-with-body -sS \ -H "Authorization: Bearer ${AUTH_TOKEN}" \ -H "Content-Type: application/json" \ "$comments_url" >/dev/null - name: Extract changelog unreleased entries id: extract-changelog shell: bash env: CHANGELOG: ${{ inputs.changelog }} run: | set -euo pipefail if [[ ! -f "$CHANGELOG" ]]; then printf 'unreleased_entries=%s\n' "" >> "$GITHUB_OUTPUT" exit 0 fi # Extract everything between [Unreleased] header and the next [X.Y.Z] header unreleased="$(awk ' /^## \[Unreleased\]/ { in_unreleased=1; next } /^## \[[0-9]+\.[0-9]+\.[0-9]+\]/ { if (in_unreleased) exit } in_unreleased && NF { print } ' "$CHANGELOG")" # Use a temporary file to handle multiline content tmp_file=$(mktemp) printf '%s' "$unreleased" > "$tmp_file" # Read it back and set as output delimiter="EOF_CHANGELOG" printf '%s<<%s\n' "unreleased_entries<<$delimiter" "$delimiter" >> "$GITHUB_OUTPUT" cat "$tmp_file" >> "$GITHUB_OUTPUT" printf '%s\n' "$delimiter" >> "$GITHUB_OUTPUT" rm -f "$tmp_file" - name: Validate changelog gate id: changelog-gate shell: bash env: ENABLE_GATE: ${{ inputs.enable-changelog-gate }} GATE_MODE: ${{ inputs.changelog-gate-mode }} REQUIRED_FOR: ${{ inputs.changelog-gate-required-for }} ALLOW_DOCS_ONLY: ${{ inputs.changelog-gate-allow-docs-only }} DOCS_GLOBS: ${{ inputs.changelog-gate-docs-globs }} SKIP_LABELS: ${{ inputs.changelog-gate-skip-labels }} CHANGELOG: ${{ inputs.changelog }} PR_NUMBER: ${{ steps.validate.outputs.pr_number }} SERVER_URL: ${{ github.server_url }} REPOSITORY: ${{ github.repository }} AUTH_TOKEN: ${{ inputs.token != '' && inputs.token || github.token }} run: | set -euo pipefail # If gate is disabled, skip if [[ "$ENABLE_GATE" != "true" ]]; then echo "gate_enabled=false" >> "$GITHUB_OUTPUT" exit 0 fi api_url="${SERVER_URL}/api/v1" if [[ "$SERVER_URL" == *"github.com"* ]]; then api_url="https://api.github.com" fi # Get PR labels pr_url="${api_url}/repos/${REPOSITORY}/pulls/${PR_NUMBER}" pr_data=$(curl -sS -H "Authorization: Bearer ${AUTH_TOKEN}" -H "Content-Type: application/json" "$pr_url") pr_labels=$(printf '%s' "$pr_data" | jq -r '.labels[].name' 2>/dev/null | tr '\n' ',' || echo "") # Check skip labels if [[ -n "$SKIP_LABELS" ]]; then IFS=',' read -ra skip_array <<< "$SKIP_LABELS" for skip_label in "${skip_array[@]}"; do skip_label="${skip_label// /}" if [[ ",$pr_labels," == *",$skip_label,"* ]]; then printf 'gate_passed=true\n' >> "$GITHUB_OUTPUT" printf 'docs_only=false\n' >> "$GITHUB_OUTPUT" printf 'unreleased_additions_count=0\n' >> "$GITHUB_OUTPUT" printf 'skip_reason=skip_label_detected\n' >> "$GITHUB_OUTPUT" exit 0 fi done fi # Get PR diff diff_url="${api_url}/repos/${REPOSITORY}/pulls/${PR_NUMBER}/files" diff_data=$(curl -sS -H "Authorization: Bearer ${AUTH_TOKEN}" -H "Content-Type: application/json" "$diff_url") # Determine if PR only modifies docs total_files=$(printf '%s' "$diff_data" | jq 'length' 2>/dev/null || echo 0) docs_only_true=true has_qualifying_changes=false if [[ "$total_files" -gt 0 ]]; then printf '%s' "$diff_data" | jq -r '.[].filename' 2>/dev/null | while read -r filename; do # Check if file matches docs globs is_doc=false IFS=',' read -ra glob_array <<< "$DOCS_GLOBS" for glob in "${glob_array[@]}"; do glob="${glob// /}" # Simple glob matching (case-insensitive) if [[ "${filename,,}" == ${glob,,} ]] || [[ "${filename,,}" == *"/${glob,,}" ]]; then is_doc=true break fi done # .md files are always docs unless they're actual code docs with changes if [[ "$filename" == *.md ]]; then is_doc=true fi if [[ "$is_doc" != "true" ]]; then docs_only_true=false fi done fi # Get changeset for changelog file changelog_diff="" if [[ -f "$CHANGELOG" ]]; then changelog_diff=$(printf '%s' "$diff_data" | jq -r ".[] | select(.filename == \"$CHANGELOG\") | .patch" 2>/dev/null || echo "") fi # Count additions to Unreleased section in changelog diff unreleased_additions=0 if [[ -n "$changelog_diff" ]]; then # Find lines added within Unreleased section in_unreleased=false while IFS= read -r line; do # Skip processing metadata lines [[ "$line" =~ ^(\+\+\+|---|@@) ]] && continue # Check if entering Unreleased section if [[ "$line" == "+## [Unreleased]"* ]] || [[ "$line" == " ## [Unreleased]"* ]]; then in_unreleased=true continue fi # Check if exiting Unreleased section if [[ "$line" == "+## ["* ]] || [[ "$line" == " ## ["* ]]; then if [[ "$line" != "+## [Unreleased]"* ]] && [[ "$line" != " ## [Unreleased]"* ]]; then in_unreleased=false fi continue fi # Count non-empty additions in Unreleased if $in_unreleased && [[ "$line" == "+"* ]] && [[ ! "$line" =~ ^(\+\+\+|---|@@) ]]; then content="${line:1}" # Remove the '+' prefix if [[ -n "${content// /}" ]]; then # Check if not just whitespace unreleased_additions=$((unreleased_additions + 1)) fi fi done <<< "$changelog_diff" fi # Evaluate gate gate_passed=true failure_reason="" if [[ "$docs_only_true" == "true" ]] && [[ "$ALLOW_DOCS_ONLY" == "true" ]]; then gate_passed=true docs_only=true else docs_only=false # Check if code requires changelog entry if [[ "$total_files" -gt 0 ]] && [[ "$unreleased_additions" -eq 0 ]]; then gate_passed=false failure_reason="Code changes detected but no entries added to Unreleased section of CHANGELOG.md" fi fi printf 'gate_enabled=true\n' >> "$GITHUB_OUTPUT" printf 'gate_passed=%s\n' "$gate_passed" >> "$GITHUB_OUTPUT" printf 'docs_only=%s\n' "$docs_only" >> "$GITHUB_OUTPUT" printf 'unreleased_additions_count=%s\n' "$unreleased_additions" >> "$GITHUB_OUTPUT" printf 'failure_reason=%s\n' "$failure_reason" >> "$GITHUB_OUTPUT" # Fail job if strict mode and gate failed if [[ "$GATE_MODE" == "strict" ]] && [[ "$gate_passed" != "true" ]]; then echo "$failure_reason" >&2 exit 1 fi - name: Build PR comment markdown id: build-comment shell: bash env: COMMENT_TITLE: ${{ inputs.comment-title }} COVERAGE_PCT: ${{ inputs.coverage-percentage }} BADGE_URL: ${{ inputs.badge-url }} UNRELEASED: ${{ steps.extract-changelog.outputs.unreleased_entries }} GATE_ENABLED: ${{ steps.changelog-gate.outputs.gate_enabled }} GATE_PASSED: ${{ steps.changelog-gate.outputs.gate_passed }} GATE_MODE: ${{ inputs.changelog-gate-mode }} DOCS_ONLY: ${{ steps.changelog-gate.outputs.docs_only }} ADDITIONS_COUNT: ${{ steps.changelog-gate.outputs.unreleased_additions_count }} FAILURE_REASON: ${{ steps.changelog-gate.outputs.failure_reason }} run: | set -euo pipefail bash "$GITHUB_ACTION_PATH/build-comment.sh" - name: Find and update/post PR comment id: post-comment shell: bash env: GITHUB_TOKEN: ${{ inputs.token != '' && inputs.token || github.token }} PR_NUMBER: ${{ steps.validate.outputs.pr_number }} COMMENT_BODY: ${{ steps.build-comment.outputs.comment_body }} SERVER_URL: ${{ github.server_url }} REPOSITORY: ${{ github.repository }} run: | set -euo pipefail API_URL="${SERVER_URL}/api/v1" if [[ "$SERVER_URL" == *"github.com"* ]]; then API_URL="https://api.github.com" fi # List existing comments to find vociferate comment comments_url="${API_URL}/repos/${REPOSITORY}/issues/${PR_NUMBER}/comments" response=$(curl -s -H "Authorization: Bearer ${GITHUB_TOKEN}" "$comments_url") # Find existing vociferate comment by checking for the marker existing_comment_id=$(printf '%s' "$response" | \ jq -r '.[] | select(.body | contains("vociferate-pr-review")) | .id' 2>/dev/null | \ head -1 || echo "") if [[ -n "$existing_comment_id" ]]; then # Update existing comment update_url="${API_URL}/repos/${REPOSITORY}/issues/comments/${existing_comment_id}" curl -s -X PATCH \ -H "Authorization: Bearer ${GITHUB_TOKEN}" \ -H "Content-Type: application/json" \ -d "$(printf '{"body": %s}' "$(printf '%s' "$COMMENT_BODY" | jq -Rs .)")" \ "$update_url" > /dev/null printf 'comment_id=%s\n' "$existing_comment_id" >> "$GITHUB_OUTPUT" printf 'comment_url=%s/repos/%s/issues/%s#issuecomment-%s\n' \ "$SERVER_URL" "$REPOSITORY" "$PR_NUMBER" "$existing_comment_id" >> "$GITHUB_OUTPUT" else # Create new comment create_url="${API_URL}/repos/${REPOSITORY}/issues/${PR_NUMBER}/comments" response=$(curl -s -X POST \ -H "Authorization: Bearer ${GITHUB_TOKEN}" \ -H "Content-Type: application/json" \ -d "$(printf '{"body": %s}' "$(printf '%s' "$COMMENT_BODY" | jq -Rs .)")" \ "$create_url") comment_id=$(printf '%s' "$response" | jq -r '.id' 2>/dev/null) printf 'comment_id=%s\n' "$comment_id" >> "$GITHUB_OUTPUT" printf 'comment_url=%s/repos/%s/issues/%s#issuecomment-%s\n' \ "$SERVER_URL" "$REPOSITORY" "$PR_NUMBER" "$comment_id" >> "$GITHUB_OUTPUT" fi