From c947f3ac3ca845614f4a88d0803857d980d9fdad Mon Sep 17 00:00:00 2001 From: Ilia Mogilevsky Date: Mon, 1 Jun 2026 17:57:17 +1000 Subject: [PATCH 1/2] feat: add scheduled PR review digest workflow Posts a digest of unreviewed open PRs across the pactflow org to Slack twice daily (8:30am and 2:30pm AEST), with weekend-aware staleness filtering. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .github/workflows/pr-review-digest.yml | 96 ++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 .github/workflows/pr-review-digest.yml diff --git a/.github/workflows/pr-review-digest.yml b/.github/workflows/pr-review-digest.yml new file mode 100644 index 0000000..2fb12a6 --- /dev/null +++ b/.github/workflows/pr-review-digest.yml @@ -0,0 +1,96 @@ +name: PR Review Digest + +# +# Requirements: +# ${{ secrets.SMARTBEAR_SLACK_WEBHOOK_URL }} (org secret — already exists) +# ${{ secrets.GH_PAT }} (PAT with repo + read:org scopes) +# +# Posts a digest of open, non-draft pactflow org PRs with no reviews (or +# pending review requests) that have been open for at least 1 business day. +# Runs weekdays at 8:30am and 2:30pm AEST (UTC+10). Silent if nothing to report. +# + +on: + schedule: + - cron: '30 22 * * 0-4' # 8:30am AEST Mon–Fri (Sun–Thu UTC) + - cron: '30 4 * * 1-5' # 2:30pm AEST Mon–Fri + workflow_dispatch: + +jobs: + digest: + runs-on: ubuntu-latest + steps: + - name: Find unreviewed PRs and notify Slack + env: + GH_TOKEN: ${{ secrets.GH_PAT }} + SLACK_WEBHOOK_URL: ${{ secrets.SMARTBEAR_SLACK_WEBHOOK_URL }} + run: | + set -euo pipefail + + # Weekend-aware cutoff: Monday runs look back 72h (covers Friday PRs) + DOW=$(date -u +%u) # 1=Mon ... 7=Sun + if [ "$DOW" -eq 1 ]; then + CUTOFF=$(date -u -d '72 hours ago' +%Y-%m-%dT%H:%M:%SZ) + else + CUTOFF=$(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ) + fi + + # Query 1: no reviews submitted + NO_REVIEW=$(gh search prs \ + --owner pactflow \ + --state open \ + --draft=false \ + --review=none \ + --json number,title,url,repository,createdAt,author \ + --limit 100) + + # Query 2: review requested but not yet submitted + REVIEW_REQUIRED=$(gh search prs \ + --owner pactflow \ + --state open \ + --draft=false \ + --review=required \ + --json number,title,url,repository,createdAt,author \ + --limit 100) + + # Merge, deduplicate by url, filter by cutoff + PRS=$(echo "$NO_REVIEW $REVIEW_REQUIRED" | jq -s ' + [ add | unique_by(.url)[] | + select(.createdAt < "'"$CUTOFF"'") ] | + sort_by(.createdAt) + ') + + COUNT=$(echo "$PRS" | jq 'length') + if [ "$COUNT" -eq 0 ]; then + echo "No unreviewed PRs older than cutoff — skipping Slack notification." + exit 0 + fi + + # Build Slack Block Kit payload + NOW=$(date -u +%s) + BLOCKS=$(echo "$PRS" | jq --argjson now "$NOW" ' + [ + { + "type": "header", + "text": { "type": "plain_text", "text": "Unreviewed PRs — pactflow org", "emoji": true } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ( + map( + (.createdAt | sub("\\.[0-9]+Z$"; "Z") | strptime("%Y-%m-%dT%H:%M:%SZ") | mktime) as $created | + (($now - $created) / 86400 | floor) as $days | + "• <\(.url)|\(.title)> — `\(.repository.name)` — @\(.author.login) — \($days)d ago" + ) | join("\n") + ) + } + } + ] + ') + + PAYLOAD=$(jq -n --argjson blocks "$BLOCKS" '{"blocks": $blocks}') + curl -s -X POST "$SLACK_WEBHOOK_URL" \ + -H 'Content-Type: application/json' \ + -d "$PAYLOAD" From 349cd32e06e9f63c4f7527f74f1a257232f31813 Mon Sep 17 00:00:00 2001 From: Ilia Mogilevsky Date: Tue, 2 Jun 2026 08:42:55 +1000 Subject: [PATCH 2/2] refactor: switch to GraphQL, drop cutoff, filter to unassigned human PRs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace two gh search prs REST calls with a single GraphQL query - Use __typename == "User" for reliable bot detection (catches renovate-bot, dependabot, github-actions without fragile login pattern matching) - Filter reviewRequests.totalCount == 0 to exclude PRs already assigned a reviewer - Remove age cutoff — surface all qualifying PRs regardless of age - Replace GH_PAT with short-lived GitHub App token (actions/create-github-app-token) Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .github/workflows/pr-review-digest.yml | 82 ++++++++++++++------------ 1 file changed, 44 insertions(+), 38 deletions(-) diff --git a/.github/workflows/pr-review-digest.yml b/.github/workflows/pr-review-digest.yml index 2fb12a6..b74d001 100644 --- a/.github/workflows/pr-review-digest.yml +++ b/.github/workflows/pr-review-digest.yml @@ -3,10 +3,13 @@ name: PR Review Digest # # Requirements: # ${{ secrets.SMARTBEAR_SLACK_WEBHOOK_URL }} (org secret — already exists) -# ${{ secrets.GH_PAT }} (PAT with repo + read:org scopes) +# ${{ secrets.PACTFLOW_CI_BOT_APP_ID }} (org secret — already exists) +# ${{ secrets.PACTFLOW_CI_BOT_PRIVATE_KEY }} (org secret — already exists) # -# Posts a digest of open, non-draft pactflow org PRs with no reviews (or -# pending review requests) that have been open for at least 1 business day. +# Posts a digest of open, non-draft, human-authored pactflow org PRs that: +# - have no reviewer assigned +# - have all CI checks passing +# - do not carry the "DO NOT MERGE" label # Runs weekdays at 8:30am and 2:30pm AEST (UTC+10). Silent if nothing to report. # @@ -20,49 +23,52 @@ jobs: digest: runs-on: ubuntu-latest steps: + - name: Generate GitHub App token + id: app-token + uses: actions/create-github-app-token@v1 + with: + app-id: ${{ secrets.PACTFLOW_CI_BOT_APP_ID }} + private-key: ${{ secrets.PACTFLOW_CI_BOT_PRIVATE_KEY }} + owner: pactflow + - name: Find unreviewed PRs and notify Slack env: - GH_TOKEN: ${{ secrets.GH_PAT }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} SLACK_WEBHOOK_URL: ${{ secrets.SMARTBEAR_SLACK_WEBHOOK_URL }} run: | set -euo pipefail - # Weekend-aware cutoff: Monday runs look back 72h (covers Friday PRs) - DOW=$(date -u +%u) # 1=Mon ... 7=Sun - if [ "$DOW" -eq 1 ]; then - CUTOFF=$(date -u -d '72 hours ago' +%Y-%m-%dT%H:%M:%SZ) - else - CUTOFF=$(date -u -d '24 hours ago' +%Y-%m-%dT%H:%M:%SZ) - fi - - # Query 1: no reviews submitted - NO_REVIEW=$(gh search prs \ - --owner pactflow \ - --state open \ - --draft=false \ - --review=none \ - --json number,title,url,repository,createdAt,author \ - --limit 100) - - # Query 2: review requested but not yet submitted - REVIEW_REQUIRED=$(gh search prs \ - --owner pactflow \ - --state open \ - --draft=false \ - --review=required \ - --json number,title,url,repository,createdAt,author \ - --limit 100) - - # Merge, deduplicate by url, filter by cutoff - PRS=$(echo "$NO_REVIEW $REVIEW_REQUIRED" | jq -s ' - [ add | unique_by(.url)[] | - select(.createdAt < "'"$CUTOFF"'") ] | - sort_by(.createdAt) + # Single GraphQL query: open, non-draft, checks passing, no reviewer assigned. + # Uses __typename to distinguish human Users from Bots (catches renovate-bot, + # dependabot, github-actions etc. without a fragile login-pattern list). + PRS=$(gh api graphql -f query=' + { + search(query: "org:pactflow is:pr is:open draft:false review:none status:success", type: ISSUE, first: 100) { + nodes { + ... on PullRequest { + title + url + createdAt + author { login __typename } + labels(first: 10) { nodes { name } } + reviewRequests(first: 1) { totalCount } + repository { name } + } + } + } + }' --jq ' + [.data.search.nodes[] | + select( + (.author.__typename == "User") and + (.labels.nodes | map(.name) | any(. == "DO NOT MERGE") | not) and + (.reviewRequests.totalCount == 0) + ) + ] | sort_by(.createdAt) ') COUNT=$(echo "$PRS" | jq 'length') if [ "$COUNT" -eq 0 ]; then - echo "No unreviewed PRs older than cutoff — skipping Slack notification." + echo "No PRs awaiting a reviewer — skipping Slack notification." exit 0 fi @@ -72,7 +78,7 @@ jobs: [ { "type": "header", - "text": { "type": "plain_text", "text": "Unreviewed PRs — pactflow org", "emoji": true } + "text": { "type": "plain_text", "text": "PRs awaiting a reviewer — pactflow org", "emoji": true } }, { "type": "section", @@ -82,7 +88,7 @@ jobs: map( (.createdAt | sub("\\.[0-9]+Z$"; "Z") | strptime("%Y-%m-%dT%H:%M:%SZ") | mktime) as $created | (($now - $created) / 86400 | floor) as $days | - "• <\(.url)|\(.title)> — `\(.repository.name)` — @\(.author.login) — \($days)d ago" + "• <\(.url)|\(.title)> — `\(.repository.name)` — @\(.author.login) — :white_check_mark: \($days)d ago" ) | join("\n") ) }