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