From f5b5d5b8503d3fd4e3b0a470614ec29d0f511548 Mon Sep 17 00:00:00 2001 From: notgitika Date: Tue, 14 Jul 2026 18:26:02 -0400 Subject: [PATCH 1/2] ci: auto-assign a round-robin reviewer to new PRs New PRs currently land in a shared queue with no named reviewer, so many sit unreviewed. This adds a workflow that requests one reviewer per new non-draft PR, round-robin over the existing AUTHORIZED_USERS roster (pr.number % pool), skipping PRs that already have a reviewer and never assigning the author. Follows the metadata-only pull_request_target pattern from pr-size.yml (needed so fork PRs can be assigned; safe because no PR code is checked out). Also adds a dry-run-by-default sweep script to assign reviewers to the existing backlog of unreviewed PRs. --- .github/scripts/assign-reviewers-backlog.sh | 79 +++++++++++++++++++++ .github/workflows/pr-assign-reviewer.yml | 62 ++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100755 .github/scripts/assign-reviewers-backlog.sh create mode 100644 .github/workflows/pr-assign-reviewer.yml diff --git a/.github/scripts/assign-reviewers-backlog.sh b/.github/scripts/assign-reviewers-backlog.sh new file mode 100755 index 000000000..d20445f6f --- /dev/null +++ b/.github/scripts/assign-reviewers-backlog.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +# One-time sweep: round-robin a reviewer onto every open, non-draft PR that has +# no reviewer requested yet. Mirrors .github/workflows/pr-assign-reviewer.yml +# (same round-robin `pr.number % pool` rule, same skip rules) so a PR gets the +# SAME reviewer whether it's swept now or assigned on open. +# +# The reviewer pool is the repo's AUTHORIZED_USERS roster (comma-separated +# usernames) — pass it in, since secret VALUES aren't readable via the API: +# export AUTHORIZED_USERS="alice,bob,carol" (or pass as $1) +# +# DRY-RUN BY DEFAULT: prints the plan and changes nothing. Pass --apply to act. +# +# Usage: +# AUTHORIZED_USERS="a,b,c" ./assign-reviewers-backlog.sh # dry run +# AUTHORIZED_USERS="a,b,c" ./assign-reviewers-backlog.sh --apply # act +# ./assign-reviewers-backlog.sh "a,b,c" # list as $1, dry run +# ./assign-reviewers-backlog.sh "a,b,c" --apply +set -euo pipefail + +REPO="${REPO:-aws/agentcore-cli}" + +# Roster: first non-flag arg overrides the env var. +LIST="${AUTHORIZED_USERS:-}" +APPLY=false +for a in "$@"; do + case "$a" in + --apply) APPLY=true ;; + *) LIST="$a" ;; + esac +done + +# Split comma-separated roster into a sorted POOL array (portable; no mapfile/bash4). +POOL=() +OLDIFS="$IFS"; IFS=',' +for u in $LIST; do + u="$(echo "$u" | tr -d '[:space:]')" + [ -n "$u" ] && POOL+=("$u") +done +IFS="$OLDIFS" +if [ "${#POOL[@]}" -eq 0 ]; then + echo "error: no reviewers. Pass AUTHORIZED_USERS=\"a,b,c\" (env) or as the first argument." >&2 + exit 1 +fi +# Sort for a stable round-robin order (matches the workflow's .sort()). +IFS=$'\n' POOL=($(printf '%s\n' "${POOL[@]}" | sort)); unset IFS +echo "Pool: ${#POOL[@]} reviewers" + +# Open, non-draft PRs with no reviewer requested (individual or team). +PRS=$(gh pr list -R "$REPO" --state open --limit 500 \ + --json number,isDraft,author,reviewRequests \ + --jq '.[] | select(.isDraft==false) | select((.reviewRequests|length)==0) | "\(.number) \(.author.login)"') + +if [ -z "$PRS" ]; then + echo "No unreviewed non-draft PRs found. Nothing to do." + exit 0 +fi + +planned=0 +while read -r num author; do + [ -z "$num" ] && continue + # Eligible pool excludes the PR author (can't review own PR). + ELIG=(); for m in "${POOL[@]}"; do [ "$m" != "$author" ] && ELIG+=("$m"); done + if [ "${#ELIG[@]}" -eq 0 ]; then + echo "PR #$num: no eligible reviewer (author-only pool), skip"; continue + fi + reviewer="${ELIG[$(( num % ${#ELIG[@]} ))]}" + planned=$((planned+1)) + if $APPLY; then + gh api --method POST "repos/$REPO/pulls/$num/requested_reviewers" \ + -f "reviewers[]=$reviewer" >/dev/null \ + && echo "PR #$num (by $author) -> requested $reviewer" + else + echo "PR #$num (by $author) -> WOULD request $reviewer" + fi +done <<< "$PRS" + +echo "---" +echo "$planned PR(s) $($APPLY && echo assigned || echo "would be assigned")." +$APPLY || echo "Dry run. Re-run with --apply to request these reviewers." diff --git a/.github/workflows/pr-assign-reviewer.yml b/.github/workflows/pr-assign-reviewer.yml new file mode 100644 index 000000000..d34abdd69 --- /dev/null +++ b/.github/workflows/pr-assign-reviewer.yml @@ -0,0 +1,62 @@ +name: Assign PR Reviewer (round-robin) + +# Auto-assigns one reviewer to each new PR, so every PR has a named, accountable +# reviewer instead of sitting in a shared queue. The reviewer pool is the repo's +# existing AUTHORIZED_USERS list (the same vetted maintainer roster that gates the +# tarball / e2e / agent workflows), so there is no separate list to maintain. +# +# Uses pull_request_target (same rationale as pr-size.yml): a fork PR's default +# token is read-only and cannot request reviewers, so we need the base-repo token. +# Safe because this workflow ONLY reads PR metadata and assigns a reviewer — it +# never checks out or executes untrusted PR code. +on: + pull_request_target: + types: [opened, ready_for_review] + branches: [main, feat/**] + +jobs: + assign-reviewer: + # Skip drafts — assign when a PR is actually ready for review. + if: github.event.pull_request.draft == false + runs-on: codebuild-agentcore-e2e-${{ github.run_id }}-${{ github.run_attempt }} + permissions: + pull-requests: write + steps: + - name: Assign a round-robin reviewer from AUTHORIZED_USERS + uses: actions/github-script@v9 + env: + AUTHORIZED_USERS: ${{ secrets.AUTHORIZED_USERS }} + with: + script: | + const pr = context.payload.pull_request; + const { owner, repo } = context.repo; + const author = pr.user.login; + + // Don't re-assign if a reviewer was already requested (e.g. manual, or a re-run). + if ((pr.requested_reviewers?.length ?? 0) > 0 || (pr.requested_teams?.length ?? 0) > 0) { + console.log('Reviewer(s) already requested; leaving as-is.'); + return; + } + + // Pool = the repo's existing comma-separated maintainer roster, sorted for a + // stable round-robin order, with the PR author removed (can't review own PR). + const pool = (process.env.AUTHORIZED_USERS || '') + .split(',').map(s => s.trim()).filter(Boolean) + .filter(login => login !== author) + .sort(); + if (pool.length === 0) { + console.log('No eligible reviewers in AUTHORIZED_USERS (empty, or author-only).'); + return; + } + + // Stateless round-robin: PR number modulo pool size. Deterministic, no state + // file to keep in sync, and spreads load evenly as PR numbers increase. + const reviewer = pool[pr.number % pool.length]; + console.log(`Requesting review from ${reviewer} (pool of ${pool.length}, author ${author}).`); + + await github.rest.pulls.requestReviewers({ + owner, + repo, + pull_number: pr.number, + reviewers: [reviewer], + }); From 75455cba1803946eb8f36eea0c8a1dd1b26d2168 Mon Sep 17 00:00:00 2001 From: notgitika Date: Wed, 15 Jul 2026 11:50:39 -0400 Subject: [PATCH 2/2] ci: limit reviewer assignment to external PRs --- .github/scripts/assign-reviewers-backlog.sh | 79 --------------------- .github/workflows/pr-assign-reviewer.yml | 35 +++++---- 2 files changed, 20 insertions(+), 94 deletions(-) delete mode 100755 .github/scripts/assign-reviewers-backlog.sh diff --git a/.github/scripts/assign-reviewers-backlog.sh b/.github/scripts/assign-reviewers-backlog.sh deleted file mode 100755 index d20445f6f..000000000 --- a/.github/scripts/assign-reviewers-backlog.sh +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env bash -# One-time sweep: round-robin a reviewer onto every open, non-draft PR that has -# no reviewer requested yet. Mirrors .github/workflows/pr-assign-reviewer.yml -# (same round-robin `pr.number % pool` rule, same skip rules) so a PR gets the -# SAME reviewer whether it's swept now or assigned on open. -# -# The reviewer pool is the repo's AUTHORIZED_USERS roster (comma-separated -# usernames) — pass it in, since secret VALUES aren't readable via the API: -# export AUTHORIZED_USERS="alice,bob,carol" (or pass as $1) -# -# DRY-RUN BY DEFAULT: prints the plan and changes nothing. Pass --apply to act. -# -# Usage: -# AUTHORIZED_USERS="a,b,c" ./assign-reviewers-backlog.sh # dry run -# AUTHORIZED_USERS="a,b,c" ./assign-reviewers-backlog.sh --apply # act -# ./assign-reviewers-backlog.sh "a,b,c" # list as $1, dry run -# ./assign-reviewers-backlog.sh "a,b,c" --apply -set -euo pipefail - -REPO="${REPO:-aws/agentcore-cli}" - -# Roster: first non-flag arg overrides the env var. -LIST="${AUTHORIZED_USERS:-}" -APPLY=false -for a in "$@"; do - case "$a" in - --apply) APPLY=true ;; - *) LIST="$a" ;; - esac -done - -# Split comma-separated roster into a sorted POOL array (portable; no mapfile/bash4). -POOL=() -OLDIFS="$IFS"; IFS=',' -for u in $LIST; do - u="$(echo "$u" | tr -d '[:space:]')" - [ -n "$u" ] && POOL+=("$u") -done -IFS="$OLDIFS" -if [ "${#POOL[@]}" -eq 0 ]; then - echo "error: no reviewers. Pass AUTHORIZED_USERS=\"a,b,c\" (env) or as the first argument." >&2 - exit 1 -fi -# Sort for a stable round-robin order (matches the workflow's .sort()). -IFS=$'\n' POOL=($(printf '%s\n' "${POOL[@]}" | sort)); unset IFS -echo "Pool: ${#POOL[@]} reviewers" - -# Open, non-draft PRs with no reviewer requested (individual or team). -PRS=$(gh pr list -R "$REPO" --state open --limit 500 \ - --json number,isDraft,author,reviewRequests \ - --jq '.[] | select(.isDraft==false) | select((.reviewRequests|length)==0) | "\(.number) \(.author.login)"') - -if [ -z "$PRS" ]; then - echo "No unreviewed non-draft PRs found. Nothing to do." - exit 0 -fi - -planned=0 -while read -r num author; do - [ -z "$num" ] && continue - # Eligible pool excludes the PR author (can't review own PR). - ELIG=(); for m in "${POOL[@]}"; do [ "$m" != "$author" ] && ELIG+=("$m"); done - if [ "${#ELIG[@]}" -eq 0 ]; then - echo "PR #$num: no eligible reviewer (author-only pool), skip"; continue - fi - reviewer="${ELIG[$(( num % ${#ELIG[@]} ))]}" - planned=$((planned+1)) - if $APPLY; then - gh api --method POST "repos/$REPO/pulls/$num/requested_reviewers" \ - -f "reviewers[]=$reviewer" >/dev/null \ - && echo "PR #$num (by $author) -> requested $reviewer" - else - echo "PR #$num (by $author) -> WOULD request $reviewer" - fi -done <<< "$PRS" - -echo "---" -echo "$planned PR(s) $($APPLY && echo assigned || echo "would be assigned")." -$APPLY || echo "Dry run. Re-run with --apply to request these reviewers." diff --git a/.github/workflows/pr-assign-reviewer.yml b/.github/workflows/pr-assign-reviewer.yml index d34abdd69..228b9046a 100644 --- a/.github/workflows/pr-assign-reviewer.yml +++ b/.github/workflows/pr-assign-reviewer.yml @@ -1,9 +1,9 @@ name: Assign PR Reviewer (round-robin) -# Auto-assigns one reviewer to each new PR, so every PR has a named, accountable -# reviewer instead of sitting in a shared queue. The reviewer pool is the repo's -# existing AUTHORIZED_USERS list (the same vetted maintainer roster that gates the -# tarball / e2e / agent workflows), so there is no separate list to maintain. +# Auto-assigns one reviewer to each new external PR, so community contributions +# have a named, accountable reviewer instead of sitting in a shared queue. PRs +# authored by someone in AUTHORIZED_USERS are left unassigned. That same list is +# the reviewer pool, so there is no separate roster to maintain. # # Uses pull_request_target (same rationale as pr-size.yml): a fork PR's default # token is read-only and cannot request reviewers, so we need the base-repo token. @@ -31,21 +31,26 @@ jobs: const pr = context.payload.pull_request; const { owner, repo } = context.repo; const author = pr.user.login; + const pool = (process.env.AUTHORIZED_USERS || '') + .split(',') + .map(login => login.trim()) + .filter(Boolean) + .sort(); - // Don't re-assign if a reviewer was already requested (e.g. manual, or a re-run). - if ((pr.requested_reviewers?.length ?? 0) > 0 || (pr.requested_teams?.length ?? 0) > 0) { - console.log('Reviewer(s) already requested; leaving as-is.'); + if (pool.length === 0) { + console.log('No reviewers configured in AUTHORIZED_USERS.'); return; } - // Pool = the repo's existing comma-separated maintainer roster, sorted for a - // stable round-robin order, with the PR author removed (can't review own PR). - const pool = (process.env.AUTHORIZED_USERS || '') - .split(',').map(s => s.trim()).filter(Boolean) - .filter(login => login !== author) - .sort(); - if (pool.length === 0) { - console.log('No eligible reviewers in AUTHORIZED_USERS (empty, or author-only).'); + // Only external contributions need automatic reviewer assignment. + if (pool.some(login => login.toLowerCase() === author.toLowerCase())) { + console.log(`${author} is in AUTHORIZED_USERS; leaving the PR unassigned.`); + return; + } + + // Don't re-assign if a reviewer was already requested (e.g. manual, or a re-run). + if ((pr.requested_reviewers?.length ?? 0) > 0 || (pr.requested_teams?.length ?? 0) > 0) { + console.log('Reviewer(s) already requested; leaving as-is.'); return; }