From 3aa92ed4af2b1a007a6cd7f4d1a4088eaeef3080 Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:37:25 +0000 Subject: [PATCH] feat: pull request policy shared by every repository Two rules enforced by a reusable workflow, called from each repository through pull_request_target so it runs the base branch's copy with write access: - Open-PR cap. An author keeps at most three open pull requests per repository. The oldest three stay; a newer one is closed on opening or reopening with a comment naming the ones that stay. Ranking by number (not a plain count) keeps a burst of simultaneous openings from closing all of them. - Review-request gate. A review request to a maintainer is removed until the author has reviewed their own pull request (one review of any state) and the description has no unchecked box. One comment per PR, updated in place. Exempt logins and the gated maintainer list are inputs with org defaults. Also a workflow template for the caller and a CONTRIBUTING section (en, ko). --- .github/workflows/pr-policy-here.yml | 14 ++ .github/workflows/pr-policy.yml | 148 +++++++++++++++++++ CONTRIBUTING.ko.md | 10 ++ CONTRIBUTING.md | 10 ++ workflow-templates/pr-policy.properties.json | 6 + workflow-templates/pr-policy.yml | 17 +++ 6 files changed, 205 insertions(+) create mode 100644 .github/workflows/pr-policy-here.yml create mode 100644 .github/workflows/pr-policy.yml create mode 100644 workflow-templates/pr-policy.properties.json create mode 100644 workflow-templates/pr-policy.yml diff --git a/.github/workflows/pr-policy-here.yml b/.github/workflows/pr-policy-here.yml new file mode 100644 index 0000000..a82282a --- /dev/null +++ b/.github/workflows/pr-policy-here.yml @@ -0,0 +1,14 @@ +# Pull request policy for this repository. The reusable workflow itself is .github/workflows/pr-policy.yml. + +name: PR policy + +on: + pull_request_target: + types: [opened, reopened, review_requested] + +permissions: + pull-requests: write + +jobs: + policy: + uses: redrob-labs/.github/.github/workflows/pr-policy.yml@main diff --git a/.github/workflows/pr-policy.yml b/.github/workflows/pr-policy.yml new file mode 100644 index 0000000..cc77061 --- /dev/null +++ b/.github/workflows/pr-policy.yml @@ -0,0 +1,148 @@ +# Pull request policy shared by every redrob-labs repository. +# +# Two rules, both enforced by closing or un-requesting rather than by failing a check, so they +# cannot be ignored: +# +# 1. Open-PR cap. An author keeps at most `cap` open pull requests per repository. The oldest +# `cap` stay; any newer one is closed with a comment the moment it is opened or reopened. +# 2. Review-request gate. A review may be requested from a maintainer in `gated_reviewers` only +# after the author has reviewed their own pull request (one review of any state on their own +# PR) and has no unchecked box left in the description. Otherwise the request is removed and +# one comment, updated in place, says what is missing. +# +# Logins in `exempt` are never limited or gated. +# +# Called from a repository through `pull_request_target`, so it runs the base branch's copy and +# the token can write to the base repository even for forks. Callers must grant +# `pull-requests: write`. + +name: PR policy + +on: + workflow_call: + inputs: + cap: + description: Maximum open pull requests per author in this repository + type: number + default: 3 + exempt: + description: Space-separated logins never limited or gated + type: string + default: "its-janghoon dependabot[bot]" + gated_reviewers: + description: Space-separated logins whose review may only be requested after a self-review + type: string + default: "its-janghoon" + +permissions: + pull-requests: write + +jobs: + policy: + name: Enforce + runs-on: ubuntu-latest + if: github.event_name == 'pull_request_target' + steps: + - name: Enforce the open-PR cap and the review-request gate + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + ACTION: ${{ github.event.action }} + PR: ${{ github.event.pull_request.number }} + AUTHOR: ${{ github.event.pull_request.user.login }} + REQUESTED: ${{ github.event.requested_reviewer.login }} + CAP: ${{ inputs.cap }} + EXEMPT: ${{ inputs.exempt }} + GATED: ${{ inputs.gated_reviewers }} + run: | + set -euo pipefail + + has() { local x; for x in $2; do [ "$x" = "$1" ] && return 0; done; return 1; } + + if has "$AUTHOR" "$EXEMPT"; then + echo "$AUTHOR is exempt; nothing to enforce." + exit 0 + fi + + case "$ACTION" in + opened|reopened) + # Rank this PR among the author's open PRs by number. The oldest $CAP keep their + # place; anything ranked at or past the cap is closed. Ranking (rather than a plain + # count) keeps a burst of simultaneous openings from closing all of them. + numbers=$(gh pr list -R "$REPO" --author "$AUTHOR" --state open --limit 200 \ + --json number --jq 'sort_by(.number)[].number') + mapfile -t open < <(printf '%s\n' "$numbers") + rank=-1; i=0 + for n in "${open[@]}"; do + [ "$n" = "$PR" ] && rank=$i + i=$((i + 1)) + done + echo "$AUTHOR has ${#open[@]} open PR(s); #$PR ranks $rank, cap $CAP." + if [ "$rank" -lt 0 ]; then + echo "::error::#$PR is not in the author's open list; refusing to guess." >&2 + exit 1 + fi + if [ "$rank" -lt "$CAP" ]; then + echo "Within the cap." + exit 0 + fi + keep=$(printf '#%s, ' "${open[@]:0:$CAP}"); keep=${keep%, } + msg=" + Closed by the pull request policy: an author keeps at most **$CAP** open pull requests in this repository, and you already have $keep open. + + Land or close one of those, then reopen this one. Folding related branches into one of the open pull requests is usually the faster route." + gh pr close -R "$REPO" "$PR" --comment "$msg" + ;; + + review_requested) + if [ -z "$REQUESTED" ]; then + echo "A team was requested, not a person; nothing to gate." + exit 0 + fi + if ! has "$REQUESTED" "$GATED"; then + echo "$REQUESTED is not a gated reviewer." + exit 0 + fi + + self_reviews=$(gh api --paginate "repos/$REPO/pulls/$PR/reviews" \ + --jq ".[] | select(.user.login == \"$AUTHOR\") | .id" | wc -l) + body=$(gh pr view -R "$REPO" "$PR" --json body --jq .body) + unchecked=$(printf '%s\n' "$body" | grep -c -E '^[[:space:]]*[-*] \[ \]' || true) + echo "self reviews: $self_reviews, unchecked boxes: $unchecked" + + missing="" + if [ "$self_reviews" -eq 0 ]; then + missing="$missing + - a review of your own pull request (**Files changed → Review changes → Comment**). Read the diff the way a reviewer would, and leave the notes you would want answered;" + fi + if [ "$unchecked" -gt 0 ]; then + missing="$missing + - $unchecked unchecked box(es) in the description. Tick each one you have actually done; delete the line if it does not apply." + fi + if [ -z "$missing" ]; then + echo "Self-review done; the request to $REQUESTED stands." + exit 0 + fi + + gh api -X DELETE "repos/$REPO/pulls/$PR/requested_reviewers" -f "reviewers[]=$REQUESTED" >/dev/null + echo "Removed the review request to $REQUESTED." + + marker='' + msg="$marker + Review request to @$REQUESTED removed by the pull request policy. Before asking a maintainer to review, finish your own review. Still missing: + $missing + + Request the review again once both are done." + existing=$(gh api --paginate "repos/$REPO/issues/$PR/comments" \ + --jq ".[] | select(.user.type == \"Bot\") | select(.body | startswith(\"$marker\")) | .id" | head -n1) + if [ -n "$existing" ]; then + gh api -X PATCH "repos/$REPO/issues/comments/$existing" -f body="$msg" >/dev/null + else + gh api -X POST "repos/$REPO/issues/$PR/comments" -f body="$msg" >/dev/null + fi + ;; + + *) + echo "No rule for action '$ACTION'." + ;; + esac diff --git a/CONTRIBUTING.ko.md b/CONTRIBUTING.ko.md index 9141683..754b457 100644 --- a/CONTRIBUTING.ko.md +++ b/CONTRIBUTING.ko.md @@ -116,6 +116,16 @@ diff가 아니라 빌드된 화면을 보고 검증합니다. 초록불 빌드 ## 리뷰와 머지 +다음 두 규칙은 모든 저장소의 워크플로가 강제합니다. 빨간불로 두고 넘어갈 수 있는 검사가 아닙니다. + +- **한 사람이 한 저장소에 동시에 열 수 있는 풀 리퀘스트는 3개까지입니다.** 오래된 3개가 남고, 그 + 뒤에 연 것은 여는 즉시 댓글과 함께 닫힙니다. 하나를 머지하거나 닫은 뒤 다시 여세요. 여러 + 브랜치가 한 덩어리라면 풀 리퀘스트도 하나입니다. +- **메인테이너에게 리뷰를 요청하기 전에 자기 풀 리퀘스트를 먼저 리뷰합니다.** 자기 풀 리퀘스트에 + 리뷰를 남기고(Files changed → Review changes → Comment) 설명의 체크박스를 모두 처리하기 전에는 + 메인테이너에게 보낸 리뷰 요청이 제거됩니다. 한 것은 체크하고, 해당 없는 줄은 지우세요. 그 다음 + 리뷰를 다시 요청합니다. + 풀 리퀘스트는 검사가 초록불이고 메인테이너가 머지해야 합니다. 머지 방식은 취향이 아닙니다. 다음 머지가 무엇을 할 수 있는지를 바꿉니다. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 253fa83..53165ba 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -119,6 +119,16 @@ has shipped an invisible label and a control that did nothing. ## Reviews and merging +Two rules are enforced by a workflow in every repository, not by a check you can leave red: + +- **At most three open pull requests per author, per repository.** The oldest three stay; a + newer one is closed on opening with a comment. Land or close one, then reopen. If several + branches belong together, they are one pull request. +- **Review your own pull request before asking a maintainer to.** A review request to a + maintainer is removed until you have left a review on your own pull request (Files changed → + Review changes → Comment) and no box in the description is left unchecked. Tick what you did; + delete a line that does not apply. Then request the review again. + A pull request needs its checks green and a maintainer merge. Merge style is not a preference, because it changes what the next merge can do: diff --git a/workflow-templates/pr-policy.properties.json b/workflow-templates/pr-policy.properties.json new file mode 100644 index 0000000..6ef2c3a --- /dev/null +++ b/workflow-templates/pr-policy.properties.json @@ -0,0 +1,6 @@ +{ + "name": "PR policy (open-PR cap and self-review gate)", + "description": "Closes a pull request beyond the author's cap of 3 open ones, and removes a review request to a maintainer until the author has reviewed their own pull request and ticked every box in the description.", + "iconName": "octicon shield-check", + "categories": ["Continuous integration"] +} diff --git a/workflow-templates/pr-policy.yml b/workflow-templates/pr-policy.yml new file mode 100644 index 0000000..29e543a --- /dev/null +++ b/workflow-templates/pr-policy.yml @@ -0,0 +1,17 @@ +# Pull request policy: at most 3 open pull requests per author, and a maintainer review may be +# requested only after the author has reviewed their own pull request. Rules and defaults live in +# redrob-labs/.github/.github/workflows/pr-policy.yml; pass `with:` to change them for this +# repository. + +name: PR policy + +on: + pull_request_target: + types: [opened, reopened, review_requested] + +permissions: + pull-requests: write + +jobs: + policy: + uses: redrob-labs/.github/.github/workflows/pr-policy.yml@main