Skip to content

Dispatch PR triage

Dispatch PR triage #20

name: Dispatch PR triage
on:
schedule:
- cron: "23 * * * *"
workflow_dispatch:
permissions:
actions: write
pull-requests: read
concurrency:
group: ${{ github.workflow }}
jobs:
dispatch:
runs-on: ubuntu-latest
env:
# Runs started with workflow_dispatch skip gh-aw's daily AI credit
# guardrail, so this caps how many triage runs start in any 24 hours.
MAX_RUNS_PER_DAY: 20
MAX_ATTEMPTS_PER_PR: 2
steps:
- name: Start triage for pull requests from external contributors
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
run: |
set -euo pipefail
since=$(date -u -d '7 days ago' +%Y-%m-%d)
day_ago=$(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ)
gh run list --workflow pr-triage.lock.yml --created ">=${since}" --limit 1000 \
--json displayTitle,status,conclusion,createdAt > runs.json
started_today=$(jq --arg day_ago "$day_ago" '[.[] | select(.createdAt >= $day_ago)] | length' runs.json)
budget=$((MAX_RUNS_PER_DAY - started_today))
# A pull request is ready for triage when it's opened, or when a draft is
# marked ready for review. Both update it, so search by update time and
# keep pull requests that became ready within the run history's window.
# Only fork pull requests count: contributors without write access must
# use forks, and members with private org membership can look external
# to this token.
# shellcheck disable=SC2016
gh api graphql --paginate \
-f q="repo:${GH_REPO} is:pr is:open draft:false updated:>=${since}" \
-f query='
query($q: String!, $endCursor: String) {
search(query: $q, type: ISSUE, first: 100, after: $endCursor) {
pageInfo { hasNextPage endCursor }
nodes {
... on PullRequest {
number
createdAt
isCrossRepository
authorAssociation
author { __typename }
timelineItems(itemTypes: [READY_FOR_REVIEW_EVENT], last: 1) {
nodes { ... on ReadyForReviewEvent { createdAt } }
}
}
}
}
}' \
--jq '.data.search.nodes[]
| select(.isCrossRepository)
| select(.author.__typename != "Bot")
| select(.authorAssociation | IN("OWNER", "MEMBER", "COLLABORATOR") | not)
| {number, ready_at: ([.createdAt, .timelineItems.nodes[].createdAt] | max)}' \
> ready.json
# Triage runs are named "PR Triage #<number>". Skip pull requests whose
# triage since they became ready succeeded, is still queued or running,
# or failed too often.
jq -rs --slurpfile runs runs.json --arg since "$since" \
--argjson max_attempts "$MAX_ATTEMPTS_PER_PR" '
[$runs[0][] | {number: (.displayTitle | capture("#(?<n>[0-9]+)$").n | tonumber), status, conclusion, createdAt}] as $triages
| map(select(.ready_at >= $since)) | sort_by(.ready_at)[]
| . as $pr
| [$triages[] | select(.number == $pr.number and .createdAt >= $pr.ready_at)] as $attempts
| select(any($attempts[]; .status != "completed" or .conclusion == "success") | not)
| select(($attempts | length) < $max_attempts)
| .number' ready.json > candidates.txt
started=0
while read -r number; do
if [ "$budget" -le 0 ]; then
echo "Reached the limit of ${MAX_RUNS_PER_DAY} triage runs per day."
break
fi
gh workflow run pr-triage.lock.yml -f pr_number="$number"
echo "Started triage for #${number}"
started=$((started + 1))
budget=$((budget - 1))
done < candidates.txt
# Let the new runs show up in "gh run list" before the next dispatch run.
if [ "$started" -gt 0 ]; then
sleep 15
fi