diff --git a/.github/workflows/pr-branch-updater.yml b/.github/workflows/pr-branch-updater.yml new file mode 100644 index 0000000..a2eaed6 --- /dev/null +++ b/.github/workflows/pr-branch-updater.yml @@ -0,0 +1,72 @@ +# Reusable workflow — update PR branches that are behind their base branch. +# +# Usage (from a consumer repo): +# +# jobs: +# update: +# uses: kyverno/.github/.github/workflows/pr-branch-updater.yml@main +# secrets: +# GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} +# +# Inputs: +# base_branch — only update PRs targeting this branch. +# Defaults to the repository's default branch. +# +# The workflow iterates every non-draft open PR, fetches its full record to get +# a reliable mergeable_state (the bulk list endpoint returns "unknown"), and +# calls the update-branch API for each PR whose state is "behind". +# Per-PR failures are non-fatal: the job logs them and continues. +# The job exits non-zero only if every attempted update encountered an +# unexpected error (i.e. the total unexpected-failure count is > 0). + +name: PR Branch Auto-Updater + +on: + workflow_call: + inputs: + base_branch: + description: >- + Only update PRs targeting this branch. + Leave empty to use the repository default branch. + required: false + type: string + default: "" + secrets: + GH_TOKEN: + description: >- + GitHub token with contents:write and pull-requests:write permissions. + Falls back to the built-in github.token when not supplied. + required: false + +permissions: {} + +jobs: + update-pr-branches: + name: Update out-of-date PR branches + runs-on: ubuntu-latest + permissions: + contents: write + pull-requests: write + steps: + - name: Check out shared scripts + # Sparse-checkout only the scripts/ directory from kyverno/.github so + # the workspace stays clean. Pin to a tag/SHA for production stability. + uses: actions/checkout@v4 + with: + repository: kyverno/.github + ref: main + path: .kyverno-github + sparse-checkout: scripts + sparse-checkout-cone-mode: true + persist-credentials: false + + - name: Find and update behind PRs + env: + # Prefer the caller-supplied token; fall back to the built-in one. + GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }} + REPO: ${{ github.repository }} + # Use the caller's explicit base branch or the repo default branch. + # github.event.repository.default_branch is available on workflow_call. + TARGET_BASE: ${{ inputs.base_branch || github.event.repository.default_branch }} + GITHUB_SERVER_URL: ${{ github.server_url }} + run: bash .kyverno-github/scripts/update-pr-branches.sh diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..738cde7 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,37 @@ +name: Test Scripts + +on: + push: + branches: [main] + paths: + - scripts/** + - tests/** + pull_request: + paths: + - scripts/** + - tests/** + +permissions: + contents: read + +jobs: + shellcheck: + name: ShellCheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Run ShellCheck + run: shellcheck scripts/*.sh + + bats: + name: Unit tests (bats) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install bats + run: | + git clone --depth 1 --branch v1.11.0 \ + https://github.com/bats-core/bats-core.git /tmp/bats-core + sudo /tmp/bats-core/install.sh /usr/local + - name: Run tests + run: bats tests/update-pr-branches.bats diff --git a/scripts/update-pr-branches.sh b/scripts/update-pr-branches.sh new file mode 100644 index 0000000..e7230be --- /dev/null +++ b/scripts/update-pr-branches.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# update-pr-branches.sh — Find open PRs that are behind their base branch and +# trigger GitHub's built-in branch-update mechanism for each one. +# +# Required env vars: +# REPO — owner/name, e.g. "kyverno/kyverno" +# GH_TOKEN — token with contents:write + pull-requests:write +# (picked up automatically by the gh CLI) +# +# Optional env vars: +# TARGET_BASE — only update PRs targeting this branch; +# empty = update PRs regardless of base branch +# GITHUB_SERVER_URL — defaults to "https://github.com" (GHE support) +# +# Exit codes: +# 0 — success (even if some PRs were skipped) +# 1 — one or more unexpected API failures occurred + +set -euo pipefail + +: "${REPO:?REPO env var is required (e.g. owner/name)}" + +GITHUB_SERVER_URL="${GITHUB_SERVER_URL:-https://github.com}" + +# Derive GH_HOST for GitHub Enterprise compatibility. +GH_HOST="${GITHUB_SERVER_URL#https://}" +GH_HOST="${GH_HOST#http://}" +export GH_HOST + +echo "Repository : $REPO" +echo "Base branch: ${TARGET_BASE:-(any)}" +echo "" + +# ------------------------------------------------------------------------------ +# Step 1 — Fetch all non-draft open PRs (bulk endpoint only returns "unknown" +# for mergeable_state, so we only keep the minimum fields needed here). +# ------------------------------------------------------------------------------ +PR_LIST_FILE=$(mktemp) +gh api --paginate "repos/$REPO/pulls?state=open&per_page=100" \ + | jq -s 'add // [] | map(select(.draft == false)) | map({number, title, base_ref: .base.ref})' \ + > "$PR_LIST_FILE" + +TOTAL=$(jq length "$PR_LIST_FILE") +echo "Found $TOTAL non-draft open PRs" +echo "" + +if [ "$TOTAL" -eq 0 ]; then + echo "Nothing to do." + rm -f "$PR_LIST_FILE" + exit 0 +fi + +UPDATED=0 +SKIPPED=0 +FAILED=0 + +# ------------------------------------------------------------------------------ +# Step 2 — For each PR, re-fetch the full record to get reliable mergeable_state. +# ------------------------------------------------------------------------------ +while IFS= read -r pr_json; do + PR_NUMBER=$(echo "$pr_json" | jq -r '.number') + PR_BASE=$(echo "$pr_json" | jq -r '.base_ref') + + # Skip PRs that don't target the requested base branch. + if [ -n "${TARGET_BASE:-}" ] && [ "$PR_BASE" != "$TARGET_BASE" ]; then + echo " - PR #$PR_NUMBER — targeting '$PR_BASE', not '$TARGET_BASE', skipping" + SKIPPED=$((SKIPPED + 1)) + continue + fi + + # Fetch the full PR record; non-fatal on error. + DETAILS_FILE=$(mktemp) + if ! gh api \ + -H "Accept: application/vnd.github+json" \ + "repos/$REPO/pulls/$PR_NUMBER" \ + > "$DETAILS_FILE" 2>&1; then + echo " - PR #$PR_NUMBER — failed to fetch details, skipping" + rm -f "$DETAILS_FILE" + SKIPPED=$((SKIPPED + 1)) + continue + fi + + if ! jq -e . >/dev/null 2>&1 < "$DETAILS_FILE"; then + echo " - PR #$PR_NUMBER — invalid JSON from details fetch, skipping" + rm -f "$DETAILS_FILE" + SKIPPED=$((SKIPPED + 1)) + continue + fi + + MERGEABLE_STATE=$(jq -r '.mergeable_state // empty' "$DETAILS_FILE") + PR_STATE=$(jq -r '.state // empty' "$DETAILS_FILE") + PR_DRAFT=$(jq -r '.draft // false' "$DETAILS_FILE") + rm -f "$DETAILS_FILE" + + # Double-check still open + non-draft (may have changed since bulk fetch). + if [ "$PR_STATE" != "open" ]; then + echo " - PR #$PR_NUMBER — no longer open, skipping" + SKIPPED=$((SKIPPED + 1)) + continue + fi + + if [ "$PR_DRAFT" = "true" ]; then + echo " - PR #$PR_NUMBER — became a draft, skipping" + SKIPPED=$((SKIPPED + 1)) + continue + fi + + if [ "$MERGEABLE_STATE" != "behind" ]; then + echo " - PR #$PR_NUMBER — mergeable_state='${MERGEABLE_STATE:-unknown}', skipping" + SKIPPED=$((SKIPPED + 1)) + continue + fi + + # ---------------------------------------------------------------------------- + # Step 3 — Call GitHub's update-branch endpoint. + # Branch on HTTP status code (stable contract), not response message text. + # ---------------------------------------------------------------------------- + echo "Updating PR #$PR_NUMBER..." + + RESPONSE_FILE=$(mktemp) + gh api \ + --include \ + --method PUT \ + -H "Accept: application/vnd.github+json" \ + "repos/$REPO/pulls/$PR_NUMBER/update-branch" \ + > "$RESPONSE_FILE" 2>&1 || true + + STATUS_CODE=$(grep -m1 -E '^HTTP/' "$RESPONSE_FILE" | awk '{print $2}' || echo "") + RESPONSE_BODY=$(awk 'BEGIN{body=0} body{print} /^(\r)?$/{body=1}' "$RESPONSE_FILE") + + if echo "$RESPONSE_BODY" | jq -e . >/dev/null 2>&1; then + MSG=$(echo "$RESPONSE_BODY" | jq -r '.message // empty') + else + MSG=$(cat "$RESPONSE_FILE") + fi + rm -f "$RESPONSE_FILE" + + case "$STATUS_CODE" in + 202|204) + echo " ✓ PR #$PR_NUMBER — branch update scheduled (HTTP $STATUS_CODE)${MSG:+: $MSG}" + UPDATED=$((UPDATED + 1)) + ;; + 409|422) + # 409 = already up-to-date; 422 = fork / maintainer update not allowed. + echo " - PR #$PR_NUMBER — update not needed or not allowed (HTTP $STATUS_CODE)${MSG:+: $MSG}" + SKIPPED=$((SKIPPED + 1)) + ;; + *) + echo " ✗ PR #$PR_NUMBER — unexpected response (HTTP ${STATUS_CODE:-unknown})${MSG:+: $MSG}" + FAILED=$((FAILED + 1)) + ;; + esac + +done < <(jq -c '.[]' "$PR_LIST_FILE") + +rm -f "$PR_LIST_FILE" + +echo "" +echo "Summary: $UPDATED updated, $SKIPPED skipped, $FAILED failed (of $TOTAL non-draft open PRs)" + +[ "$FAILED" -eq 0 ] || exit 1 diff --git a/tests/update-pr-branches.bats b/tests/update-pr-branches.bats new file mode 100644 index 0000000..eac6754 --- /dev/null +++ b/tests/update-pr-branches.bats @@ -0,0 +1,192 @@ +#!/usr/bin/env bats +# tests/update-pr-branches.bats — Unit tests for scripts/update-pr-branches.sh +# +# Dependencies: bats-core (https://github.com/bats-core/bats-core) +# macOS: brew install bats-core +# Ubuntu: sudo apt-get install bats +# or: npm install -g bats +# +# Run locally from the repo root: +# bats tests/update-pr-branches.bats + +SCRIPT="$BATS_TEST_DIRNAME/../scripts/update-pr-branches.sh" + +# ------------------------------------------------------------------------------ +# setup / teardown +# ------------------------------------------------------------------------------ + +setup() { + # Create a temp bin dir and put a fake 'gh' in it. + FAKE_BIN=$(mktemp -d) + export PATH="$FAKE_BIN:$PATH" + + # Write the fake gh stub. Behavior is controlled by env vars: + # FAKE_GH_PAGINATE_RESPONSE — JSON returned for --paginate calls + # FAKE_GH_PR__RESPONSE — JSON returned for individual PR #N fetch + # FAKE_GH_PR__FAIL — set to 1 to make individual PR #N fetch fail + # FAKE_GH_UPDATE_STATUS — HTTP status code for update-branch calls (default 202) + # FAKE_GH_UPDATE_BODY — JSON body for update-branch calls + cat > "$FAKE_BIN/gh" << 'EOF' +#!/usr/bin/env bash +if [[ " $* " == *" --paginate "* ]]; then + echo "${FAKE_GH_PAGINATE_RESPONSE:-[]}" + exit 0 +fi + +if [[ " $* " == *" --method PUT "* ]]; then + STATUS="${FAKE_GH_UPDATE_STATUS:-202}" + BODY="${FAKE_GH_UPDATE_BODY:-{\"message\":\"Updating pull request branch.\"}}" + printf 'HTTP/1.1 %s OK\r\n\r\n%s\n' "$STATUS" "$BODY" + exit 0 +fi + +# Individual PR detail fetch — extract PR number from the URL argument. +URL="" +for arg in "$@"; do + case "$arg" in repos/*) URL="$arg" ;; esac +done +PR_NUM=$(echo "$URL" | grep -oE '/pulls/[0-9]+$' | grep -oE '[0-9]+') +FAIL_VAR="FAKE_GH_PR_${PR_NUM}_FAIL" +if [ "${!FAIL_VAR:-0}" = "1" ]; then + echo "API error" >&2 + exit 1 +fi +RESP_VAR="FAKE_GH_PR_${PR_NUM}_RESPONSE" +DEFAULT="{\"number\":$PR_NUM,\"state\":\"open\",\"draft\":false,\"mergeable_state\":\"clean\",\"base\":{\"ref\":\"main\"}}" +echo "${!RESP_VAR:-$DEFAULT}" +EOF + chmod +x "$FAKE_BIN/gh" + + # Default env vars consumed by the script. + export REPO="test-org/test-repo" + export TARGET_BASE="main" + export GITHUB_SERVER_URL="https://github.com" + # Unset GH_TOKEN so tests don't accidentally use a real token. + unset GH_TOKEN || true +} + +teardown() { + rm -rf "$FAKE_BIN" +} + +# ------------------------------------------------------------------------------ +# Tests +# ------------------------------------------------------------------------------ + +@test "exits 0 and reports nothing-to-do when there are no open PRs" { + export FAKE_GH_PAGINATE_RESPONSE='[]' + + run bash "$SCRIPT" + + [ "$status" -eq 0 ] + [[ "$output" == *"Found 0 non-draft open PRs"* ]] + [[ "$output" == *"Nothing to do"* ]] +} + +@test "skips PRs that are not behind (mergeable_state=clean)" { + export FAKE_GH_PAGINATE_RESPONSE='[{"number":10,"title":"feat","draft":false,"base":{"ref":"main"}}]' + export FAKE_GH_PR_10_RESPONSE='{"number":10,"state":"open","draft":false,"mergeable_state":"clean"}' + + run bash "$SCRIPT" + + [ "$status" -eq 0 ] + [[ "$output" == *"mergeable_state='clean', skipping"* ]] + [[ "$output" == *"0 updated"* ]] + [[ "$output" == *"0 failed"* ]] +} + +@test "updates a PR whose mergeable_state is behind and exits 0" { + export FAKE_GH_PAGINATE_RESPONSE='[{"number":42,"title":"fix","draft":false,"base":{"ref":"main"}}]' + export FAKE_GH_PR_42_RESPONSE='{"number":42,"state":"open","draft":false,"mergeable_state":"behind"}' + export FAKE_GH_UPDATE_STATUS="202" + export FAKE_GH_UPDATE_BODY='{"message":"Updating pull request branch."}' + + run bash "$SCRIPT" + + [ "$status" -eq 0 ] + [[ "$output" == *"Updating PR #42"* ]] + [[ "$output" == *"✓ PR #42"* ]] + [[ "$output" == *"1 updated"* ]] + [[ "$output" == *"0 failed"* ]] +} + +@test "skips a draft PR even if the bulk list returned it" { + # The bulk list filters drafts via jq, but the double-check re-fetch also + # guards against a PR becoming a draft between list and fetch. + export FAKE_GH_PAGINATE_RESPONSE='[{"number":7,"title":"wip","draft":false,"base":{"ref":"main"}}]' + export FAKE_GH_PR_7_RESPONSE='{"number":7,"state":"open","draft":true,"mergeable_state":"behind"}' + + run bash "$SCRIPT" + + [ "$status" -eq 0 ] + [[ "$output" == *"became a draft, skipping"* ]] + [[ "$output" == *"0 updated"* ]] +} + +@test "skips PR when individual detail fetch fails — non-fatal" { + export FAKE_GH_PAGINATE_RESPONSE='[{"number":99,"title":"bad","draft":false,"base":{"ref":"main"}}]' + export FAKE_GH_PR_99_FAIL=1 + + run bash "$SCRIPT" + + [ "$status" -eq 0 ] + [[ "$output" == *"failed to fetch details, skipping"* ]] + [[ "$output" == *"0 updated"* ]] + [[ "$output" == *"0 failed"* ]] +} + +@test "treats HTTP 409 from update-branch as a skip, not a failure" { + export FAKE_GH_PAGINATE_RESPONSE='[{"number":55,"title":"409","draft":false,"base":{"ref":"main"}}]' + export FAKE_GH_PR_55_RESPONSE='{"number":55,"state":"open","draft":false,"mergeable_state":"behind"}' + export FAKE_GH_UPDATE_STATUS="409" + export FAKE_GH_UPDATE_BODY='{"message":"Already up-to-date."}' + + run bash "$SCRIPT" + + [ "$status" -eq 0 ] + [[ "$output" == *"update not needed or not allowed (HTTP 409)"* ]] + [[ "$output" == *"0 failed"* ]] +} + +@test "counts unexpected HTTP status as a failure and exits 1" { + export FAKE_GH_PAGINATE_RESPONSE='[{"number":77,"title":"err","draft":false,"base":{"ref":"main"}}]' + export FAKE_GH_PR_77_RESPONSE='{"number":77,"state":"open","draft":false,"mergeable_state":"behind"}' + export FAKE_GH_UPDATE_STATUS="500" + export FAKE_GH_UPDATE_BODY='{"message":"Internal Server Error"}' + + run bash "$SCRIPT" + + [ "$status" -eq 1 ] + [[ "$output" == *"✗ PR #77"* ]] + [[ "$output" == *"1 failed"* ]] +} + +@test "skips PRs not targeting TARGET_BASE" { + export FAKE_GH_PAGINATE_RESPONSE='[{"number":11,"title":"feature","draft":false,"base":{"ref":"feature-branch"}},{"number":12,"title":"main PR","draft":false,"base":{"ref":"main"}}]' + export FAKE_GH_PR_12_RESPONSE='{"number":12,"state":"open","draft":false,"mergeable_state":"behind"}' + export TARGET_BASE="main" + + run bash "$SCRIPT" + + [ "$status" -eq 0 ] + [[ "$output" == *"targeting 'feature-branch', not 'main', skipping"* ]] + [[ "$output" == *"Updating PR #12"* ]] + [[ "$output" == *"1 updated"* ]] +} + +@test "updates multiple behind PRs in a single run" { + export FAKE_GH_PAGINATE_RESPONSE='[ + {"number":101,"title":"a","draft":false,"base":{"ref":"main"}}, + {"number":102,"title":"b","draft":false,"base":{"ref":"main"}} + ]' + export FAKE_GH_PR_101_RESPONSE='{"number":101,"state":"open","draft":false,"mergeable_state":"behind"}' + export FAKE_GH_PR_102_RESPONSE='{"number":102,"state":"open","draft":false,"mergeable_state":"behind"}' + export FAKE_GH_UPDATE_STATUS="202" + + run bash "$SCRIPT" + + [ "$status" -eq 0 ] + [[ "$output" == *"✓ PR #101"* ]] + [[ "$output" == *"✓ PR #102"* ]] + [[ "$output" == *"2 updated"* ]] +}