Skip to content

Commit 12c76e8

Browse files
committed
Add reusable ready-for-review label workflow
Removes the ready-for-review label once a PR is no longer awaiting review (closed/merged, draft, >=2 approvals, or >=2 change requests). Hosted here so every repository in the org can consume it via a thin caller workflow. Signed-off-by: Simon Davies <simongdavies@users.noreply.github.com>
0 parents  commit 12c76e8

1 file changed

Lines changed: 141 additions & 0 deletions

File tree

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
name: Manage ready-for-review label (reusable)
2+
3+
# Reusable workflow that REMOVES the "ready-for-review" label from a pull
4+
# request once it is no longer awaiting review. It never adds the label - adding
5+
# it is a manual / author action documented in docs/github-labels.md.
6+
#
7+
# The workflow is intentionally trigger-agnostic: on every call it re-reads the
8+
# pull request's current state and removes the label if it is present AND any of
9+
# the following hold:
10+
# * the pull request is closed or merged,
11+
# * the pull request is a draft,
12+
# * the pull request has >= approval-threshold approvals, or
13+
# * the pull request has >= changes-threshold change requests.
14+
#
15+
# It performs NO checkout of pull-request code and only ever reads PR metadata
16+
# and removes a label, so it is safe to invoke from a `pull_request_target`
17+
# trigger (no untrusted code is executed with the elevated token).
18+
#
19+
# This workflow is hosted in the organisation's `.github` repository so that
20+
# every repository can consume the same engine. To use it from a repository, add
21+
# a thin caller workflow that listens for the relevant events and references this
22+
# file at a pinned commit SHA, e.g.
23+
# uses: hyperlight-dev/.github/.github/workflows/manage-ready-for-review.yml@<sha>
24+
25+
on:
26+
workflow_call:
27+
inputs:
28+
pr-number:
29+
description: "Number of the pull request to evaluate."
30+
required: true
31+
type: number
32+
label:
33+
description: "Name of the label to manage."
34+
required: false
35+
type: string
36+
default: ready-for-review
37+
approval-threshold:
38+
description: "Remove the label once approvals reach (>=) this count."
39+
required: false
40+
type: number
41+
default: 2
42+
changes-threshold:
43+
description: "Remove the label once change requests reach (>=) this count."
44+
required: false
45+
type: number
46+
default: 2
47+
48+
permissions:
49+
contents: read
50+
pull-requests: write
51+
52+
jobs:
53+
manage-label:
54+
runs-on: ubuntu-latest
55+
steps:
56+
- name: Remove the label when the PR is no longer awaiting review
57+
env:
58+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
59+
REPO: ${{ github.repository }}
60+
PR_NUMBER: ${{ inputs.pr-number }}
61+
LABEL: ${{ inputs.label }}
62+
APPROVAL_THRESHOLD: ${{ inputs.approval-threshold }}
63+
CHANGES_THRESHOLD: ${{ inputs.changes-threshold }}
64+
run: |
65+
set -euo pipefail
66+
67+
owner="${REPO%/*}"
68+
name="${REPO#*/}"
69+
70+
# Fetch the label, draft flag, open/closed/merged state and the latest
71+
# *opinionated* review per reviewer in a single GraphQL call.
72+
# `latestOpinionatedReviews` returns each reviewer's most recent
73+
# approve / request-changes review only, so plain comments are ignored
74+
# and a reviewer can never be counted twice.
75+
query='query($owner: String!, $name: String!, $number: Int!) {
76+
repository(owner: $owner, name: $name) {
77+
pullRequest(number: $number) {
78+
state
79+
isDraft
80+
labels(first: 100) { nodes { name } }
81+
latestOpinionatedReviews(first: 100) { nodes { state } }
82+
}
83+
}
84+
}'
85+
86+
response="$(gh api graphql \
87+
-f query="$query" \
88+
-f owner="$owner" \
89+
-f name="$name" \
90+
-F number="$PR_NUMBER")"
91+
92+
state="$(jq -r '.data.repository.pullRequest.state' <<<"$response")"
93+
is_draft="$(jq -r '.data.repository.pullRequest.isDraft' <<<"$response")"
94+
has_label="$(jq --arg l "$LABEL" \
95+
'([(.data.repository.pullRequest.labels.nodes // [])[].name] | index($l)) != null' \
96+
<<<"$response")"
97+
approvals="$(jq \
98+
'[(.data.repository.pullRequest.latestOpinionatedReviews.nodes // [])[] | select(.state == "APPROVED")] | length' \
99+
<<<"$response")"
100+
changes="$(jq \
101+
'[(.data.repository.pullRequest.latestOpinionatedReviews.nodes // [])[] | select(.state == "CHANGES_REQUESTED")] | length' \
102+
<<<"$response")"
103+
104+
echo "PR #${PR_NUMBER}: state=${state} draft=${is_draft} approvals=${approvals} changes=${changes} has_label=${has_label}"
105+
106+
# Nothing to do if the label is not on the PR.
107+
if [[ "$has_label" != "true" ]]; then
108+
echo "Label '${LABEL}' is not present; nothing to do."
109+
exit 0
110+
fi
111+
112+
# Work out whether (and why) the label should be removed.
113+
reason=""
114+
if [[ "$state" != "OPEN" ]]; then
115+
reason="the pull request is ${state,,}"
116+
elif [[ "$is_draft" == "true" ]]; then
117+
reason="the pull request is a draft"
118+
elif (( approvals >= APPROVAL_THRESHOLD )); then
119+
reason="the pull request has ${approvals} approval(s) (threshold ${APPROVAL_THRESHOLD})"
120+
elif (( changes >= CHANGES_THRESHOLD )); then
121+
reason="the pull request has ${changes} change request(s) (threshold ${CHANGES_THRESHOLD})"
122+
fi
123+
124+
if [[ -z "$reason" ]]; then
125+
echo "No removal condition met; leaving '${LABEL}' in place."
126+
exit 0
127+
fi
128+
129+
echo "Removing '${LABEL}' because ${reason}."
130+
131+
# Remove the label. Tolerate a concurrent removal, which surfaces as an
132+
# HTTP 404 "Label does not exist" from the REST API.
133+
if ! gh api --method DELETE \
134+
"repos/${REPO}/issues/${PR_NUMBER}/labels/${LABEL}" --silent 2>delete_err.txt; then
135+
if grep -qi "does not exist" delete_err.txt; then
136+
echo "Label '${LABEL}' was already removed by a concurrent run."
137+
else
138+
cat delete_err.txt >&2
139+
exit 1
140+
fi
141+
fi

0 commit comments

Comments
 (0)