diff --git a/AGENTS.md b/AGENTS.md index 07c79bc..9477e96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ Published composite actions: - `git.hrafn.xyz/aether/vociferate/prepare@v1.0.1` - `git.hrafn.xyz/aether/vociferate/publish@v1.0.1` - `git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.1` +- `git.hrafn.xyz/aether/vociferate/decorate-pr@v1.0.1` ## Action Selection Matrix @@ -20,6 +21,7 @@ Use this when deciding which action to call: - Choose `prepare` when you need to update changelog/version files, commit, and push a release tag. - Choose `publish` when a tag already exists and you need to create or update release notes/assets. - Choose `coverage-badge` after tests have produced `coverage.out` and you need coverage artefacts uploaded. +- Choose `decorate-pr` to annotate pull requests with coverage information and unreleased changelog entries. - Choose root `vociferate` for direct recommend/prepare logic without commit/tag/push behavior. ## Preconditions @@ -139,6 +141,34 @@ jobs: artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} ``` +### 4. Decorate Pull Request With Coverage and Changes + +```yaml +jobs: + coverage: + runs-on: ubuntu-latest + if: github.event_name == 'pull_request' + env: + AWS_ACCESS_KEY_ID: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_KEY }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.ARTEFACT_BUCKET_WRITE_ACCESS_SECRET }} + AWS_DEFAULT_REGION: ${{ vars.ARTEFACT_BUCKET_REGION }} + AWS_EC2_METADATA_DISABLED: true + steps: + - uses: actions/checkout@v4 + - name: Run tests with coverage + run: go test -covermode=atomic -coverprofile=coverage.out ./... + - id: badge + uses: git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.0 + with: + artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }} + artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} + - name: Decorate PR + uses: git.hrafn.xyz/aether/vociferate/decorate-pr@v1.0.0 + with: + coverage-percentage: ${{ steps.badge.outputs.total }} + badge-url: ${{ steps.badge.outputs.badge-url }} +``` + ## Inputs And Outputs Cheatsheet ### prepare @@ -186,6 +216,24 @@ Primary outputs: - `report-url` - `badge-url` +### decorate-pr + +Required inputs: + +- `coverage-percentage` (0-100, typically from coverage-badge action) +- `badge-url` (SVG badge URL, typically from coverage-badge action) + +Useful optional inputs: + +- `changelog` (default `CHANGELOG.md`) +- `comment-title` (default `Vociferate Review`) +- `token` (defaults to workflow token) + +Primary outputs: + +- `comment-id` +- `comment-url` + ## Guardrails For Agents Use these rules to avoid common automation mistakes: diff --git a/README.md b/README.md index 59c6f03..64e2080 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ revision. ## Use In Other Repositories -Vociferate ships three composite actions covering release preparation, release publication, and coverage badge publishing. +Vociferate ships composite actions covering release preparation, release publication, coverage badge publishing, and pull request decoration. Release tags now exist; pin all action and reusable-workflow references to the same released tag (for example, `@v1.0.1`) instead of `@main`. For agentic coding partners, see [`AGENTS.md`](AGENTS.md) for a direct integration playbook, selection matrix, and copy-paste workflow patterns. @@ -131,6 +131,30 @@ Run your coverage tests first, then call the action to generate `coverage.html`, echo "Badge: ${{ steps.coverage.outputs.badge-url }}" ``` +### `decorate-pr` - annotate pull requests with coverage and changes + +Decorate pull requests with coverage badges, coverage percentages, and unreleased changelog entries. The action creates a new comment or updates an existing one on each run. + +```yaml +- name: Run tests with coverage + run: go test -covermode=atomic -coverprofile=coverage.out ./... + +- id: coverage + uses: git.hrafn.xyz/aether/vociferate/coverage-badge@v1.0.0 + with: + artefact-bucket-name: ${{ vars.ARTEFACT_BUCKET_NAME }} + artefact-bucket-endpoint: ${{ vars.ARTEFACT_BUCKET_ENDPONT }} + +- name: Decorate pull request + if: github.event_name == 'pull_request' + uses: git.hrafn.xyz/aether/vociferate/decorate-pr@v1.0.0 + with: + coverage-percentage: ${{ steps.coverage.outputs.total }} + badge-url: ${{ steps.coverage.outputs.badge-url }} +``` + +The action automatically finds existing vociferate comments by their marker and updates them instead of creating duplicates. This keeps PR timelines clean while keeping review information current. + ## Why The Name > **vociferate** _(verb)_: to cry out loudly or forcefully. diff --git a/decorate-pr/action.yml b/decorate-pr/action.yml new file mode 100644 index 0000000..ec5191c --- /dev/null +++ b/decorate-pr/action.yml @@ -0,0 +1,206 @@ +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: '' + +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 }} + +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: 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: 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 }} + run: | + set -euo pipefail + + # Start building the comment + tmp_file=$(mktemp) + + # Add title and coverage section + cat > "$tmp_file" << 'EOF' + +EOF + + printf '## %s\n\n' "$COMMENT_TITLE" >> "$tmp_file" + + # Coverage badge section + cat >> "$tmp_file" << EOF +### Coverage +![Coverage Badge]($BADGE_URL) + +**Coverage:** $COVERAGE_PCT% + +EOF + + # Changelog section + if [[ -n "$UNRELEASED" ]]; then + cat >> "$tmp_file" << 'EOF' +### Unreleased Changes + +EOF + printf '%s\n' "$UNRELEASED" >> "$tmp_file" + printf '\n' >> "$tmp_file" + fi + + # Add footer + cat >> "$tmp_file" << 'EOF' +--- +*This comment was automatically generated by [vociferate/decorate-pr](https://git.hrafn.xyz/aether/vociferate).* +EOF + + # Store as output using delimiter + delimiter="EOF_COMMENT" + printf '%s<<%s\n' "comment_body<<$delimiter" "$delimiter" >> "$GITHUB_OUTPUT" + cat "$tmp_file" >> "$GITHUB_OUTPUT" + printf '%s\n' "$delimiter" >> "$GITHUB_OUTPUT" + + rm -f "$tmp_file" + + - 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