|
name: "Pull Request Labeler" |
|
|
|
on: |
|
schedule: |
|
# Reconcile recently updated PRs promptly, including unapproved forks and |
|
# conflicted PRs for which pull_request workflows do not run. |
|
- cron: "7,22,37,52 * * * *" |
|
# Reconcile one stable shard of all open PRs each hour to recover from |
|
# delayed or missed scheduled runs. |
|
- cron: "12 * * * *" |
|
workflow_dispatch: |
|
inputs: |
|
pr_number: |
|
description: "Open pull request number to reconcile" |
|
required: true |
|
type: string |
|
|
|
permissions: {} |
|
|
|
concurrency: |
|
group: pull-request-labeler |
|
cancel-in-progress: false |
|
|
|
jobs: |
|
triage: |
|
if: github.ref_name == github.event.repository.default_branch |
|
runs-on: ubuntu-latest |
|
timeout-minutes: 30 |
|
permissions: |
|
contents: read |
|
pull-requests: write |
|
steps: |
|
- uses: actions/checkout@v5 |
|
with: |
|
persist-credentials: false |
|
sparse-checkout: .github/labeler.yml |
|
sparse-checkout-cone-mode: false |
|
|
|
- name: Collect pull requests to reconcile |
|
id: collect |
|
env: |
|
GH_TOKEN: ${{ github.token }} |
|
REPO: ${{ github.repository }} |
|
EVENT_NAME: ${{ github.event_name }} |
|
SCHEDULE: ${{ github.event.schedule }} |
|
REQUESTED_PR: ${{ inputs.pr_number }} |
|
run: | |
|
set -euo pipefail |
|
|
|
if [ "$EVENT_NAME" = "workflow_dispatch" ]; then |
|
if [[ ! "$REQUESTED_PR" =~ ^[1-9][0-9]*$ ]]; then |
|
echo "Invalid pull request number: $REQUESTED_PR" |
|
exit 1 |
|
fi |
|
|
|
pr_json=$(gh api "repos/$REPO/pulls/$REQUESTED_PR") |
|
candidates=$(jq -c '[{ |
|
number: .number, |
|
head_sha: .head.sha |
|
}]' <<<"$pr_json") |
|
else |
|
pulls_json=$(gh api --paginate \ |
|
"repos/$REPO/pulls?state=open&sort=updated&direction=desc&per_page=100" | |
|
jq -cs 'add') |
|
|
|
if [ "$SCHEDULE" = "12 * * * *" ]; then |
|
shard=$(( ($(date -u +%s) / 3600) % 6 )) |
|
candidates=$(jq -c --argjson shard "$shard" \ |
|
'[.[] | select((.number % 6) == $shard) | { |
|
number: .number, |
|
head_sha: .head.sha |
|
}]' <<<"$pulls_json") |
|
else |
|
cutoff=$(date -u -d "1 hour ago" "+%Y-%m-%dT%H:%M:%SZ") |
|
# Hourly shards reconcile any candidates beyond this API budget. |
|
candidates=$(jq -c --arg cutoff "$cutoff" \ |
|
'[.[] | select(.updated_at >= $cutoff) | { |
|
number: .number, |
|
head_sha: .head.sha |
|
}][0:100]' <<<"$pulls_json") |
|
fi |
|
fi |
|
|
|
echo "Collected $(jq 'length' <<<"$candidates") pull request(s)." |
|
{ |
|
echo "candidates<<EOF" |
|
echo "$candidates" |
|
echo "EOF" |
|
} >> "$GITHUB_OUTPUT" |
|
|
|
- name: Validate pull request state |
|
id: validate |
|
env: |
|
GH_TOKEN: ${{ github.token }} |
|
REPO: ${{ github.repository }} |
|
CANDIDATES: ${{ steps.collect.outputs.candidates }} |
|
run: | |
|
set -euo pipefail |
|
|
|
valid_numbers=() |
|
while IFS=$'\t' read -r pr_number expected_sha; do |
|
if [[ ! "$pr_number" =~ ^[1-9][0-9]*$ ]] || |
|
[[ ! "$expected_sha" =~ ^[0-9a-f]{40}$ ]]; then |
|
echo "Skipping malformed pull request candidate." |
|
continue |
|
fi |
|
|
|
if ! pr_json=$(gh api "repos/$REPO/pulls/$pr_number"); then |
|
echo "Pull request #$pr_number could not be fetched; skipping." |
|
continue |
|
fi |
|
|
|
if ! jq -e \ |
|
--arg repo "$REPO" \ |
|
--arg sha "$expected_sha" \ |
|
'.state == "open" and |
|
.base.repo.full_name == $repo and |
|
.head.sha == $sha and |
|
(.head.repo.full_name | type == "string")' \ |
|
>/dev/null <<<"$pr_json"; then |
|
echo "Pull request #$pr_number changed or is no longer open; skipping." |
|
continue |
|
fi |
|
|
|
valid_numbers+=("$pr_number") |
|
done < <(jq -r '.[] | [.number, .head_sha] | @tsv' <<<"$CANDIDATES") |
|
|
|
if [ "${#valid_numbers[@]}" -eq 0 ]; then |
|
echo "has_prs=false" >> "$GITHUB_OUTPUT" |
|
exit 0 |
|
fi |
|
|
|
{ |
|
echo "has_prs=true" |
|
echo "pr_numbers<<EOF" |
|
printf '%s\n' "${valid_numbers[@]}" |
|
echo "EOF" |
|
} >> "$GITHUB_OUTPUT" |
|
|
|
- uses: actions/labeler@v4 |
|
if: steps.validate.outputs.has_prs == 'true' |
|
with: |
|
repo-token: "${{ github.token }}" |
|
pr-number: ${{ steps.validate.outputs.pr_numbers }} |