| 1 | name: Auto-close harvested PRs |
| 2 | |
| 3 | # When a commit on main contains a "Harvested from PR #N" line in its |
| 4 | # message, close PR #N with a templated thank-you that links back to |
| 5 | # the merged commit. Solves the long-standing problem where contributor |
| 6 | # PRs whose code lands via maintainer cherry-pick stay open and |
| 7 | # `CONFLICTING` forever, even though their fix is credited in the |
| 8 | # CHANGELOG. |
| 9 | # |
| 10 | # The expected commit-message convention is documented in |
| 11 | # CONTRIBUTING.md. Two patterns are recognised: |
| 12 | # |
| 13 | # * `Harvested from PR #1234 by @username` (preferred) |
| 14 | # * `harvested from #1234` (case-insensitive fallback) |
| 15 | # |
| 16 | # The first match's PR number is closed; multiple PRs can be closed |
| 17 | # per commit by repeating the line. The match runs on the commit |
| 18 | # body only, not on the subject line, so the subject can describe |
| 19 | # the change naturally without baking a number into it. |
| 20 | |
| 21 | on: |
| 22 | push: |
| 23 | branches: [main] |
| 24 | |
| 25 | permissions: |
| 26 | contents: read |
| 27 | pull-requests: write |
| 28 | issues: write |
| 29 | |
| 30 | # Only one auto-close run at a time so two near-simultaneous main |
| 31 | # pushes can't both try to close the same PR (the second would just |
| 32 | # fail with "Pull request is already closed", harmless but noisy). |
| 33 | concurrency: |
| 34 | group: auto-close-harvested |
| 35 | cancel-in-progress: false |
| 36 | |
| 37 | jobs: |
| 38 | close: |
| 39 | runs-on: ubuntu-latest |
| 40 | steps: |
| 41 | - uses: actions/checkout@v7 |
| 42 | with: |
| 43 | # We need at least the commits that this push introduced. |
| 44 | # fetch-depth: 0 is the simplest correct option; the |
| 45 | # alternative (fetching just `before..after`) is fragile |
| 46 | # when force-pushes happen. |
| 47 | fetch-depth: 0 |
| 48 | |
| 49 | - name: Close PRs referenced by harvested-from lines |
| 50 | env: |
| 51 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 52 | BEFORE_SHA: ${{ github.event.before }} |
| 53 | AFTER_SHA: ${{ github.event.after }} |
| 54 | shell: bash |
| 55 | run: | |
| 56 | set -euo pipefail |
| 57 | |
| 58 | # The first push to a fresh branch has BEFORE_SHA = 0000…0000. |
| 59 | # In that case fall back to the latest commit only — we don't |
| 60 | # want to scan the entire history. |
| 61 | if [[ "${BEFORE_SHA}" == "0000000000000000000000000000000000000000" || -z "${BEFORE_SHA:-}" ]]; then |
| 62 | RANGE="${AFTER_SHA}" |
| 63 | RANGE_ARGS=("-1" "${AFTER_SHA}") |
| 64 | else |
| 65 | RANGE="${BEFORE_SHA}..${AFTER_SHA}" |
| 66 | RANGE_ARGS=("${RANGE}") |
| 67 | fi |
| 68 | echo "Scanning commit range: ${RANGE}" |
| 69 | |
| 70 | # `git log --format=%H%n%B%n--END--` separates commits with a |
| 71 | # sentinel so multi-line bodies don't get mangled. |
| 72 | mapfile -t commits < <(git log "${RANGE_ARGS[@]}" --format="%H") |
| 73 | |
| 74 | if [[ ${#commits[@]} -eq 0 ]]; then |
| 75 | echo "No commits in range; nothing to do." |
| 76 | exit 0 |
| 77 | fi |
| 78 | |
| 79 | declare -A processed_prs=() |
| 80 | |
| 81 | for sha in "${commits[@]}"; do |
| 82 | body="$(git log -1 --format=%B "${sha}")" |
| 83 | # Two patterns, both case-insensitive on the keyword: |
| 84 | # "Harvested from PR #1234 by @username" (preferred form) |
| 85 | # "harvested from #1234" (short form) |
| 86 | mapfile -t pr_numbers < <( |
| 87 | printf '%s\n' "${body}" \ |
| 88 | | grep -oiE 'harvested from (pr )?#[0-9]+' \ |
| 89 | | grep -oE '#[0-9]+' \ |
| 90 | | tr -d '#' \ |
| 91 | | sort -u || true |
| 92 | ) |
| 93 | |
| 94 | if [[ ${#pr_numbers[@]} -eq 0 ]]; then |
| 95 | continue |
| 96 | fi |
| 97 | |
| 98 | short_sha="${sha:0:12}" |
| 99 | subject="$(git log -1 --format=%s "${sha}")" |
| 100 | |
| 101 | for pr in "${pr_numbers[@]}"; do |
| 102 | key="${pr}-${sha}" |
| 103 | if [[ -n "${processed_prs[${key}]:-}" ]]; then |
| 104 | continue |
| 105 | fi |
| 106 | processed_prs[${key}]=1 |
| 107 | |
| 108 | # Idempotency: skip if the PR is already closed. |
| 109 | state="$(gh pr view "${pr}" --json state --jq .state 2>/dev/null || echo "MISSING")" |
| 110 | if [[ "${state}" == "CLOSED" || "${state}" == "MERGED" ]]; then |
| 111 | echo "PR #${pr} is already ${state}; skipping." |
| 112 | continue |
| 113 | fi |
| 114 | if [[ "${state}" == "MISSING" ]]; then |
| 115 | echo "::warning::PR #${pr} not found or inaccessible; skipping." |
| 116 | continue |
| 117 | fi |
| 118 | |
| 119 | author="$(gh pr view "${pr}" --json author --jq '.author.login' 2>/dev/null || echo "")" |
| 120 | greeting="Hi" |
| 121 | if [[ -n "${author}" ]]; then |
| 122 | greeting="Thanks @${author}" |
| 123 | fi |
| 124 | |
| 125 | # NOTE: this block intentionally avoids `<<EOF` heredocs. |
| 126 | # YAML's `|` block scalar requires consistent indentation, |
| 127 | # but heredoc bodies have to start at column 0 — those two |
| 128 | # constraints can't coexist in the same file. We assemble |
| 129 | # the body with `printf` + `\n` so every line of the |
| 130 | # message lives at the same indent as the surrounding |
| 131 | # shell code. |
| 132 | commit_url="https://github.com/${GITHUB_REPOSITORY}/commit/${sha}" |
| 133 | contributing_url="https://github.com/${GITHUB_REPOSITORY}/blob/main/CONTRIBUTING.md" |
| 134 | body_text="$(printf '%s\n' \ |
| 135 | "${greeting} — your contribution landed in [\`${short_sha}\`](${commit_url}) on \`main\`:" \ |
| 136 | "" \ |
| 137 | "> ${subject}" \ |
| 138 | "" \ |
| 139 | "Closing this PR now that the code is on \`main\`. Credit lives in the commit message and (where applicable) the \`CHANGELOG.md\` entry for the next release. Apologies for not closing this at the time of the merge — the auto-close workflow is new in v0.8.31." \ |
| 140 | "" \ |
| 141 | "If you want to land more work and would prefer your future PRs merge cleanly without a harvest step, the [\`CONTRIBUTING.md\`](${contributing_url}) doc has a short note on what makes a contribution mergeable as-is." \ |
| 142 | )" |
| 143 | |
| 144 | echo "Closing PR #${pr} (harvested in ${short_sha})" |
| 145 | if ! gh pr close "${pr}" \ |
| 146 | --repo "${GITHUB_REPOSITORY}" \ |
| 147 | --comment "${body_text}"; then |
| 148 | echo "::warning::Failed to close PR #${pr}; continuing" |
| 149 | fi |
| 150 | done |
| 151 | done |
| 152 | |
| 153 | echo "Auto-close pass complete." |
| 154 |