From 4be92372bfb228849d8440d99004bfa40adfd514 Mon Sep 17 00:00:00 2001 From: don-petry Date: Thu, 30 Apr 2026 20:23:41 -0500 Subject: [PATCH 1/6] Add daily org status GitHub Action Runs at 6am CDT via cron, collects PR/issue/merge/discussion data across petry-projects org and don-petry personal account, generates a formatted markdown report via Claude, and opens a GitHub issue labeled daily-report for daily review. --- .github/workflows/daily-org-status.yml | 45 +++++ scripts/org_status.sh | 260 +++++++++++++++++++++++++ 2 files changed, 305 insertions(+) create mode 100644 .github/workflows/daily-org-status.yml create mode 100644 scripts/org_status.sh diff --git a/.github/workflows/daily-org-status.yml b/.github/workflows/daily-org-status.yml new file mode 100644 index 000000000..14805bc20 --- /dev/null +++ b/.github/workflows/daily-org-status.yml @@ -0,0 +1,45 @@ +name: Daily Org Status + +on: + schedule: + - cron: '0 11 * * *' # 6:00 AM CDT (UTC-5) — shifts to 5 AM in winter CST + workflow_dispatch: # allow manual trigger from Actions tab + +jobs: + report: + name: Generate & post org status + runs-on: ubuntu-latest + permissions: + issues: write # create issue in this repo + contents: read + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install Claude Code CLI + run: npm install -g @anthropic-ai/claude-code + + - name: Generate org status report + id: report + env: + GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS }} + CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + run: | + chmod +x scripts/org_status.sh + bash scripts/org_status.sh > /tmp/report.md + echo "lines=$(wc -l < /tmp/report.md)" >> "$GITHUB_OUTPUT" + + - name: Create daily-report issue + env: + GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS }} + run: | + DATE=$(date -u +%Y-%m-%d) + gh issue create \ + --repo petry-projects/.github \ + --title "Org Status — $DATE" \ + --body-file /tmp/report.md \ + --label daily-report diff --git a/scripts/org_status.sh b/scripts/org_status.sh new file mode 100644 index 000000000..30b4d567e --- /dev/null +++ b/scripts/org_status.sh @@ -0,0 +1,260 @@ +#!/usr/bin/env bash +# Daily org status data collector — runs in GitHub Actions, outputs a markdown report to stdout. +set -euo pipefail + +TODAY=$(date -u +%Y-%m-%d) +SINCE=$(date -u -d '7 days ago' +%Y-%m-%d) +DATA_DIR=$(mktemp -d) +trap 'rm -rf "$DATA_DIR"' EXIT + +# ── Repo Discovery ──────────────────────────────────────────────────────────── +echo "::group::Discovering repos" >&2 +ORG_REPOS=$(gh repo list petry-projects --json name --limit 100 | jq -r '.[].name' | sort) +PERSONAL_REPOS=$(gh repo list don-petry --json name --limit 100 | jq -r '.[].name' | sort) +echo "petry-projects: $(echo "$ORG_REPOS" | wc -l | tr -d ' ') repos | don-petry: $(echo "$PERSONAL_REPOS" | wc -l | tr -d ' ') repos" >&2 +echo "::endgroup::" >&2 + +# ── PR Collection + Classification ─────────────────────────────────────────── +collect_classify_prs() { + local owner=$1 repo=$2 + local cursor="null" all_nodes='[]' + + while true; do + local result + result=$(gh api graphql \ + -f query='query($owner:String!,$repo:String!,$cursor:String){ + repository(owner:$owner,name:$repo){ + pullRequests(states:OPEN,first:100,after:$cursor){ + pageInfo{hasNextPage endCursor} + nodes{ + number title createdAt isDraft + labels(first:5){nodes{name}} + reviewDecision + statusCheckRollup{state} + reviews(last:5){nodes{state}} + } + } + } + }' \ + -f owner="$owner" -f repo="$repo" -f cursor="$cursor" 2>/dev/null) \ + || result='{"data":{"repository":{"pullRequests":{"pageInfo":{"hasNextPage":false},"nodes":[]}}}}' + + local nodes has_next end_cursor + nodes=$(echo "$result" | jq '.data.repository.pullRequests.nodes // []') + has_next=$(echo "$result" | jq -r '.data.repository.pullRequests.pageInfo.hasNextPage // false') + end_cursor=$(echo "$result" | jq -r '.data.repository.pullRequests.pageInfo.endCursor // "null"') + + all_nodes=$(jq -n --argjson a "$all_nodes" --argjson b "$nodes" '$a + $b') + [ "$has_next" = "true" ] || break + cursor="\"$end_cursor\"" + done + + echo "$all_nodes" | jq --arg owner "$owner" --arg repo "$repo" ' + map({ + repo: ($owner + "/" + $repo), + number: .number, + title: .title, + opened: (.createdAt | split("T")[0]), + url: ("https://github.com/" + $owner + "/" + $repo + "/pull/" + (.number|tostring)), + labels: [.labels.nodes[].name], + isDraft: .isDraft, + ci: (.statusCheckRollup.state // "null"), + review: (.reviewDecision // "null"), + approvals: ([.reviews.nodes[] | select(.state=="APPROVED")] | length), + needsHumanReview: ([.labels.nodes[].name] | any(. == "needs-human-review")), + isDepBump: (.title | test("^chore\\(deps")), + category: ( + if .isDraft then "Draft" + elif (.statusCheckRollup.state == "FAILURE" or .statusCheckRollup.state == "ERROR") then "CI Failing" + elif (.statusCheckRollup.state == "PENDING" or .statusCheckRollup.state == "EXPECTED") then "CI Pending" + elif .reviewDecision == "CHANGES_REQUESTED" then "Changes Requested" + elif .reviewDecision == "APPROVED" then "Approved" + elif .reviewDecision == "REVIEW_REQUIRED" then "Awaiting Review" + else "No CI / No Policy" + end + ) + })' +} + +echo "::group::Collecting PR data" >&2 +ALL_PRS='[]' +for repo in $ORG_REPOS; do + prs=$(collect_classify_prs "petry-projects" "$repo") + count=$(echo "$prs" | jq 'length') + [ "$count" -gt 0 ] && echo " petry-projects/$repo: $count open PRs" >&2 + ALL_PRS=$(jq -n --argjson a "$ALL_PRS" --argjson b "$prs" '$a + $b') +done +for repo in $PERSONAL_REPOS; do + prs=$(collect_classify_prs "don-petry" "$repo") + count=$(echo "$prs" | jq 'length') + [ "$count" -gt 0 ] && echo " don-petry/$repo: $count open PRs" >&2 + ALL_PRS=$(jq -n --argjson a "$ALL_PRS" --argjson b "$prs" '$a + $b') +done +echo "Total open PRs: $(echo "$ALL_PRS" | jq 'length')" >&2 +echo "::endgroup::" >&2 + +# Pre-aggregate PR counts by category per repo (keeps prompt size manageable) +PR_BY_REPO=$(echo "$ALL_PRS" | jq ' + group_by(.repo) | map({ + repo: .[0].repo, + total: length, + draft: ([.[] | select(.category=="Draft")] | length), + ci_failing: ([.[] | select(.category=="CI Failing")] | length), + ci_pending: ([.[] | select(.category=="CI Pending")] | length), + changes_requested: ([.[] | select(.category=="Changes Requested")] | length), + approved: ([.[] | select(.category=="Approved")] | length), + awaiting_review: ([.[] | select(.category=="Awaiting Review")] | length), + no_ci_policy: ([.[] | select(.category=="No CI / No Policy")] | length), + dep_bumps: ([.[] | select(.isDepBump)] | length) + }) | sort_by(-.total)') + +NEEDS_REVIEW_PRS=$(echo "$ALL_PRS" | jq '[.[] | select(.needsHumanReview)]') + +# ── Merge Activity ──────────────────────────────────────────────────────────── +echo "::group::Collecting merge activity" >&2 +ORG_MERGES=$(gh search prs --owner=petry-projects --merged --merged-at=">=$SINCE" \ + --json number,repository,closedAt --limit 1000 2>/dev/null || echo '[]') +PERSONAL_MERGES=$(gh search prs --owner=don-petry --merged --merged-at=">=$SINCE" \ + --json number,repository,closedAt --limit 1000 2>/dev/null || echo '[]') + +MERGE_DAILY=$(jq -n --arg since "$SINCE" --arg today "$TODAY" \ + --argjson org "$ORG_MERGES" --argjson personal "$PERSONAL_MERGES" ' + # Build 7-day date list + def dates: [range(7) | ($since | strptime("%Y-%m-%d") | mktime) + (. * 86400) | strftime("%Y-%m-%d")]; + dates | map({ + date: ., + org: ([$org[] | select(.closedAt[:10] == .)] | length), + personal: ([$personal[] | select(.closedAt[:10] == .)] | length) + })') +echo "::endgroup::" >&2 + +# ── Issues ──────────────────────────────────────────────────────────────────── +echo "::group::Collecting issues" >&2 +ISSUES_BY_REPO='[]' +for repo in $ORG_REPOS $PERSONAL_REPOS; do + owner=$(echo "$ORG_REPOS" | grep -qx "$repo" && echo "petry-projects" || echo "don-petry") + issues=$(gh issue list --repo "$owner/$repo" --state open \ + --json number,title,createdAt,labels --limit 1000 2>/dev/null || echo '[]') + count=$(echo "$issues" | jq 'length') + if [ "$count" -gt 0 ]; then + echo " $owner/$repo: $count open issues" >&2 + entry=$(echo "$issues" | jq --arg repo "$owner/$repo" '{repo: $repo, count: length, issues: .}') + ISSUES_BY_REPO=$(jq -n --argjson a "$ISSUES_BY_REPO" --argjson b "$entry" '$a + [$b]') + fi +done +echo "::endgroup::" >&2 + +# ── Discussions ─────────────────────────────────────────────────────────────── +echo "::group::Collecting discussions" >&2 +DISCUSSIONS=$(gh api graphql -f query=' +{ + organization(login:"petry-projects"){ + repositories(first:100){ + nodes{ + name hasDiscussionsEnabled + discussions(first:50,states:OPEN){ + totalCount + nodes{ + number title createdAt + comments{totalCount} + labels(first:5){nodes{name}} + } + } + } + } + } +}' 2>/dev/null | jq '[ + .data.organization.repositories.nodes[] + | select(.hasDiscussionsEnabled and .discussions.totalCount > 0) + | {repo: ("petry-projects/" + .name), discussions: .discussions.nodes} +]') || DISCUSSIONS='[]' +echo "::endgroup::" >&2 + +# ── Build Prompt ────────────────────────────────────────────────────────────── +cat > "$DATA_DIR/prompt.txt" << PROMPT +Generate a daily GitHub org status report for $TODAY. + +Use ONLY the data below. Output ONLY the markdown report — no preamble, no commentary. + +--- + +## DATA + +### PR Counts by Repo (pre-classified) +$(echo "$PR_BY_REPO" | jq -c '.') + +### PRs Needing Human Review (full detail) +$(echo "$NEEDS_REVIEW_PRS" | jq -c '.') + +### Merge Activity — Daily Counts (last 7 days, $SINCE to $TODAY) +$(echo "$MERGE_DAILY" | jq -c '.') + +### Org Merges Raw (for per-repo breakdown) +$(echo "$ORG_MERGES" | jq -c '[group_by(.repository.name) | .[] | {repo: .[0].repository.name, count: length}] | sort_by(-.count)') + +### Personal Merges Raw (for per-repo breakdown) +$(echo "$PERSONAL_MERGES" | jq -c '[group_by(.repository.name) | .[] | {repo: .[0].repository.name, count: length}] | sort_by(-.count)') + +### Open Issues by Repo +$(echo "$ISSUES_BY_REPO" | jq -c '.') + +### Open Discussions +$(echo "$DISCUSSIONS" | jq -c '.') + +--- + +## REPORT FORMAT + +Produce these sections in order: + +### \`## Open PRs — Why They're Unmerged (N total)\` +Org-wide blocker summary table (sum all repos): +| Category | Count | % of Total | +|---|---|---| +Rows in this order: Awaiting Review, CI Failing, CI Pending, Changes Requested, Approved, Draft, No CI / No Policy, **TOTAL** + +Per-repo breakdown table (omit repos with 0 total PRs): +| Repo | Total | Awaiting Review | CI Failing | CI Pending | Changes Req | Approved | No CI/Policy | Draft | +Add ⚠ next to repo name if CI Failing > 5 or Awaiting Review > 10. + +### \`## Open PRs — Needs Human Review\` +Full table for PRs with needsHumanReview == true: +| Repo | PR # | Opened | Title | CI | Approvals | +PR # as markdown link: [#N](url) +CI: PASS (SUCCESS) / FAIL (FAILURE or ERROR) / PENDING / N/A (null) +If none: _none_ + +### \`## Open PRs — Automation (Dependency Bumps)\` +Counts only per repo (dep_bumps > 0): +| Repo | # Dep PRs | +If none: _none_ + +### \`## Open Issues (N total)\` +For each repo with issues, show top 20 rows: +| Repo | # | Opened | Title | Labels | +Opened = createdAt date only (YYYY-MM-DD). +If issues count hits 1000, note "(truncated at 1000)" next to repo name. + +### \`## Open Discussions\` +| Repo | # | Opened | Title | Replies | +If none: _No open discussions found across petry-projects._ + +### \`## PR Merge Activity — Last 7 Days\` +Daily table (include zero rows): +| Date | petry-projects | don-petry | +Per-org repo breakdown sorted descending: +| Repo | Merges | +Grand total. Trend: Increasing if avg(last 3 days) > avg(first 3 days), Decreasing if opposite, Flat otherwise. + +--- + +OUTPUT CONTRACT +- Dates: YYYY-MM-DD +- Section headers include total counts: \`## Open Issues (47 total)\` +- Empty sections show _none_, never omit them +- Links: [#N](url) +PROMPT + +# ── Generate Report ─────────────────────────────────────────────────────────── +echo "Generating report with Claude..." >&2 +claude -p "$(cat "$DATA_DIR/prompt.txt")" --dangerously-skip-permissions 2>/dev/null From d2b799000899720ffce0e2afe0deb09539f48cb1 Mon Sep 17 00:00:00 2001 From: don-petry Date: Thu, 30 Apr 2026 20:59:34 -0500 Subject: [PATCH 2/6] Fix review findings: pinned actions, timeout, owner loop, jq scope, range(8), truncation - Pin actions/checkout and actions/setup-node to commit SHAs - Pin @anthropic-ai/claude-code to 2.1.123 - Add timeout-minutes: 30 to job - Remove unused 'lines' step output - Add issue body truncation guard (60k bytes) - Split issues loop into two separate owner loops (fixes collision bug) - Fix jq merge daily filter: capture date as $date before generator (scope bug) - Fix range(7) -> range(8) so today is included in merge daily table - Bump reviews(last:5) -> reviews(last:20) - Add --dangerously-skip-permissions comment explaining CI requirement - Cross-platform date: macOS -v vs Linux -d --- .github/workflows/daily-org-status.yml | 17 +++++++---- scripts/org_status.sh | 41 ++++++++++++++++++-------- 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/.github/workflows/daily-org-status.yml b/.github/workflows/daily-org-status.yml index 14805bc20..35ef8e00d 100644 --- a/.github/workflows/daily-org-status.yml +++ b/.github/workflows/daily-org-status.yml @@ -9,29 +9,36 @@ jobs: report: name: Generate & post org status runs-on: ubuntu-latest + timeout-minutes: 30 permissions: issues: write # create issue in this repo contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 with: node-version: '20' - name: Install Claude Code CLI - run: npm install -g @anthropic-ai/claude-code + run: npm install -g @anthropic-ai/claude-code@2.1.123 - name: Generate org status report - id: report env: GH_TOKEN: ${{ secrets.GH_PAT_WORKFLOWS }} CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} run: | chmod +x scripts/org_status.sh bash scripts/org_status.sh > /tmp/report.md - echo "lines=$(wc -l < /tmp/report.md)" >> "$GITHUB_OUTPUT" + + - name: Truncate report if near GitHub issue body limit + run: | + MAX_BYTES=60000 + if [ "$(wc -c < /tmp/report.md)" -gt "$MAX_BYTES" ]; then + truncated=$(head -c "$MAX_BYTES" /tmp/report.md) + printf '%s\n\n_Report truncated — exceeded GitHub issue body limit._\n' "$truncated" > /tmp/report.md + fi - name: Create daily-report issue env: diff --git a/scripts/org_status.sh b/scripts/org_status.sh index 30b4d567e..fe363cae6 100644 --- a/scripts/org_status.sh +++ b/scripts/org_status.sh @@ -3,7 +3,11 @@ set -euo pipefail TODAY=$(date -u +%Y-%m-%d) -SINCE=$(date -u -d '7 days ago' +%Y-%m-%d) +if [[ "$(uname)" == "Darwin" ]]; then + SINCE=$(date -u -v-7d +%Y-%m-%d) +else + SINCE=$(date -u -d '7 days ago' +%Y-%m-%d) +fi DATA_DIR=$(mktemp -d) trap 'rm -rf "$DATA_DIR"' EXIT @@ -31,7 +35,7 @@ collect_classify_prs() { labels(first:5){nodes{name}} reviewDecision statusCheckRollup{state} - reviews(last:5){nodes{state}} + reviews(last:20){nodes{state}} } } } @@ -119,26 +123,36 @@ PERSONAL_MERGES=$(gh search prs --owner=don-petry --merged --merged-at=">=$SINCE MERGE_DAILY=$(jq -n --arg since "$SINCE" --arg today "$TODAY" \ --argjson org "$ORG_MERGES" --argjson personal "$PERSONAL_MERGES" ' - # Build 7-day date list - def dates: [range(7) | ($since | strptime("%Y-%m-%d") | mktime) + (. * 86400) | strftime("%Y-%m-%d")]; - dates | map({ - date: ., - org: ([$org[] | select(.closedAt[:10] == .)] | length), - personal: ([$personal[] | select(.closedAt[:10] == .)] | length) + # Build 8-day date list (since through today inclusive) + def dates: [range(8) | ($since | strptime("%Y-%m-%d") | mktime) + (. * 86400) | strftime("%Y-%m-%d")]; + # Capture $date before entering the generator so . refers to the right scope + dates | map(. as $date | { + date: $date, + org: ([$org[] | select(.closedAt[:10] == $date)] | length), + personal: ([$personal[] | select(.closedAt[:10] == $date)] | length) })') echo "::endgroup::" >&2 # ── Issues ──────────────────────────────────────────────────────────────────── echo "::group::Collecting issues" >&2 ISSUES_BY_REPO='[]' -for repo in $ORG_REPOS $PERSONAL_REPOS; do - owner=$(echo "$ORG_REPOS" | grep -qx "$repo" && echo "petry-projects" || echo "don-petry") - issues=$(gh issue list --repo "$owner/$repo" --state open \ +for repo in $ORG_REPOS; do + issues=$(gh issue list --repo "petry-projects/$repo" --state open \ + --json number,title,createdAt,labels --limit 1000 2>/dev/null || echo '[]') + count=$(echo "$issues" | jq 'length') + if [ "$count" -gt 0 ]; then + echo " petry-projects/$repo: $count open issues" >&2 + entry=$(echo "$issues" | jq --arg repo "petry-projects/$repo" '{repo: $repo, count: length, issues: .}') + ISSUES_BY_REPO=$(jq -n --argjson a "$ISSUES_BY_REPO" --argjson b "$entry" '$a + [$b]') + fi +done +for repo in $PERSONAL_REPOS; do + issues=$(gh issue list --repo "don-petry/$repo" --state open \ --json number,title,createdAt,labels --limit 1000 2>/dev/null || echo '[]') count=$(echo "$issues" | jq 'length') if [ "$count" -gt 0 ]; then - echo " $owner/$repo: $count open issues" >&2 - entry=$(echo "$issues" | jq --arg repo "$owner/$repo" '{repo: $repo, count: length, issues: .}') + echo " don-petry/$repo: $count open issues" >&2 + entry=$(echo "$issues" | jq --arg repo "don-petry/$repo" '{repo: $repo, count: length, issues: .}') ISSUES_BY_REPO=$(jq -n --argjson a "$ISSUES_BY_REPO" --argjson b "$entry" '$a + [$b]') fi done @@ -256,5 +270,6 @@ OUTPUT CONTRACT PROMPT # ── Generate Report ─────────────────────────────────────────────────────────── +# --dangerously-skip-permissions: required in CI to bypass interactive tool-approval prompts echo "Generating report with Claude..." >&2 claude -p "$(cat "$DATA_DIR/prompt.txt")" --dangerously-skip-permissions 2>/dev/null From a25b187801a6663f61aba5089cce619e7ffad6ca Mon Sep 17 00:00:00 2001 From: don-petry Date: Thu, 30 Apr 2026 21:22:19 -0500 Subject: [PATCH 3/6] Enhance report: hyperlinks on all items, Linked PR column on issues, @don-petry mention MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add closingIssuesReferences to PR GraphQL query; build ISSUE_PR_MAP - Add url field to gh issue list and discussion GraphQL - Render every PR #, issue #, discussion # as a markdown hyperlink - Issues table gains Linked PR column (from ISSUE_PR_MAP, — if none) - Titles are also linked (not just issue/PR numbers) - Repo names in breakdown tables link to the repo - Report opens with @don-petry mention for GitHub notification --- scripts/org_status.sh | 64 +++++++++++++++++++++++++++++++++---------- 1 file changed, 49 insertions(+), 15 deletions(-) diff --git a/scripts/org_status.sh b/scripts/org_status.sh index fe363cae6..f5f64f080 100644 --- a/scripts/org_status.sh +++ b/scripts/org_status.sh @@ -36,6 +36,7 @@ collect_classify_prs() { reviewDecision statusCheckRollup{state} reviews(last:20){nodes{state}} + closingIssuesReferences(first:10){nodes{number}} } } } @@ -67,6 +68,7 @@ collect_classify_prs() { approvals: ([.reviews.nodes[] | select(.state=="APPROVED")] | length), needsHumanReview: ([.labels.nodes[].name] | any(. == "needs-human-review")), isDepBump: (.title | test("^chore\\(deps")), + closingIssues: [.closingIssuesReferences.nodes[].number], category: ( if .isDraft then "Draft" elif (.statusCheckRollup.state == "FAILURE" or .statusCheckRollup.state == "ERROR") then "CI Failing" @@ -114,6 +116,15 @@ PR_BY_REPO=$(echo "$ALL_PRS" | jq ' NEEDS_REVIEW_PRS=$(echo "$ALL_PRS" | jq '[.[] | select(.needsHumanReview)]') +# Map of "owner/repo#issue_number" -> [{pr_number, pr_url}] for issues that have a linked PR +ISSUE_PR_MAP=$(echo "$ALL_PRS" | jq ' + [.[] | . as $pr | ($pr.closingIssues // [])[] | + {key: ($pr.repo + "#" + (. | tostring)), value: {number: $pr.number, url: $pr.url}} + ] | group_by(.key) | map({ + key: .[0].key, + value: [.[] | .value] + }) | from_entries') + # ── Merge Activity ──────────────────────────────────────────────────────────── echo "::group::Collecting merge activity" >&2 ORG_MERGES=$(gh search prs --owner=petry-projects --merged --merged-at=">=$SINCE" \ @@ -138,7 +149,7 @@ echo "::group::Collecting issues" >&2 ISSUES_BY_REPO='[]' for repo in $ORG_REPOS; do issues=$(gh issue list --repo "petry-projects/$repo" --state open \ - --json number,title,createdAt,labels --limit 1000 2>/dev/null || echo '[]') + --json number,title,createdAt,labels,url --limit 1000 2>/dev/null || echo '[]') count=$(echo "$issues" | jq 'length') if [ "$count" -gt 0 ]; then echo " petry-projects/$repo: $count open issues" >&2 @@ -148,7 +159,7 @@ for repo in $ORG_REPOS; do done for repo in $PERSONAL_REPOS; do issues=$(gh issue list --repo "don-petry/$repo" --state open \ - --json number,title,createdAt,labels --limit 1000 2>/dev/null || echo '[]') + --json number,title,createdAt,labels,url --limit 1000 2>/dev/null || echo '[]') count=$(echo "$issues" | jq 'length') if [ "$count" -gt 0 ]; then echo " don-petry/$repo: $count open issues" >&2 @@ -180,7 +191,15 @@ DISCUSSIONS=$(gh api graphql -f query=' }' 2>/dev/null | jq '[ .data.organization.repositories.nodes[] | select(.hasDiscussionsEnabled and .discussions.totalCount > 0) - | {repo: ("petry-projects/" + .name), discussions: .discussions.nodes} + | . as $r + | { + repo: ("petry-projects/" + .name), + discussions: [ + .discussions.nodes[] | . + { + url: ("https://github.com/petry-projects/" + $r.name + "/discussions/" + (.number|tostring)) + } + ] + } ]') || DISCUSSIONS='[]' echo "::endgroup::" >&2 @@ -197,10 +216,13 @@ Use ONLY the data below. Output ONLY the markdown report — no preamble, no com ### PR Counts by Repo (pre-classified) $(echo "$PR_BY_REPO" | jq -c '.') -### PRs Needing Human Review (full detail) +### PRs Needing Human Review (full detail, includes url field) $(echo "$NEEDS_REVIEW_PRS" | jq -c '.') -### Merge Activity — Daily Counts (last 7 days, $SINCE to $TODAY) +### Issue → Linked PR Map (key: "owner/repo#issue_number", value: [{number, url}]) +$(echo "$ISSUE_PR_MAP" | jq -c '.') + +### Merge Activity — Daily Counts (last 8 days, $SINCE to $TODAY) $(echo "$MERGE_DAILY" | jq -c '.') ### Org Merges Raw (for per-repo breakdown) @@ -209,17 +231,20 @@ $(echo "$ORG_MERGES" | jq -c '[group_by(.repository.name) | .[] | {repo: .[0].re ### Personal Merges Raw (for per-repo breakdown) $(echo "$PERSONAL_MERGES" | jq -c '[group_by(.repository.name) | .[] | {repo: .[0].repository.name, count: length}] | sort_by(-.count)') -### Open Issues by Repo +### Open Issues by Repo (each issue has url field) $(echo "$ISSUES_BY_REPO" | jq -c '.') -### Open Discussions +### Open Discussions (each discussion has url field) $(echo "$DISCUSSIONS" | jq -c '.') --- ## REPORT FORMAT -Produce these sections in order: +Begin the report with this exact line (replace nothing): +@don-petry + +Then produce these sections in order: ### \`## Open PRs — Why They're Unmerged (N total)\` Org-wide blocker summary table (sum all repos): @@ -229,28 +254,36 @@ Rows in this order: Awaiting Review, CI Failing, CI Pending, Changes Requested, Per-repo breakdown table (omit repos with 0 total PRs): | Repo | Total | Awaiting Review | CI Failing | CI Pending | Changes Req | Approved | No CI/Policy | Draft | -Add ⚠ next to repo name if CI Failing > 5 or Awaiting Review > 10. +- Repo name as a link to the repo: [owner/repo](https://github.com/owner/repo) +- Add ⚠ next to repo name if CI Failing > 5 or Awaiting Review > 10 ### \`## Open PRs — Needs Human Review\` Full table for PRs with needsHumanReview == true: | Repo | PR # | Opened | Title | CI | Approvals | -PR # as markdown link: [#N](url) -CI: PASS (SUCCESS) / FAIL (FAILURE or ERROR) / PENDING / N/A (null) +- PR # as markdown link using url field: [#N](url) +- Title as markdown link using url field: [title](url) +- CI: PASS (SUCCESS) / FAIL (FAILURE or ERROR) / PENDING / N/A (null) If none: _none_ ### \`## Open PRs — Automation (Dependency Bumps)\` Counts only per repo (dep_bumps > 0): | Repo | # Dep PRs | +- Repo as link: [owner/repo](https://github.com/owner/repo) If none: _none_ ### \`## Open Issues (N total)\` For each repo with issues, show top 20 rows: -| Repo | # | Opened | Title | Labels | -Opened = createdAt date only (YYYY-MM-DD). -If issues count hits 1000, note "(truncated at 1000)" next to repo name. +| Repo | # | Opened | Title | Labels | Linked PR | +- # as markdown link using url field: [#N](url) +- Title as markdown link using url field: [title](url) +- Opened = createdAt date only (YYYY-MM-DD) +- Linked PR: look up "owner/repo#N" in the Issue→Linked PR Map; if found render as [#M](pr_url); if multiple, comma-separate; if none render — +- If issues count hits 1000, note "(truncated at 1000)" next to the repo name ### \`## Open Discussions\` | Repo | # | Opened | Title | Replies | +- # as markdown link using url field: [#N](url) +- Title as markdown link using url field: [title](url) If none: _No open discussions found across petry-projects._ ### \`## PR Merge Activity — Last 7 Days\` @@ -258,6 +291,7 @@ Daily table (include zero rows): | Date | petry-projects | don-petry | Per-org repo breakdown sorted descending: | Repo | Merges | +- Repo as link: [repo](https://github.com/owner/repo) Grand total. Trend: Increasing if avg(last 3 days) > avg(first 3 days), Decreasing if opposite, Flat otherwise. --- @@ -266,7 +300,7 @@ OUTPUT CONTRACT - Dates: YYYY-MM-DD - Section headers include total counts: \`## Open Issues (47 total)\` - Empty sections show _none_, never omit them -- Links: [#N](url) +- Every item with a url must be rendered as a markdown hyperlink PROMPT # ── Generate Report ─────────────────────────────────────────────────────────── From b5492fbb9283766a6140ff03807f50e36fc2372c Mon Sep 17 00:00:00 2001 From: don-petry Date: Fri, 1 May 2026 12:47:25 -0500 Subject: [PATCH 4/6] fix: add --ignore-scripts to npm install to satisfy SonarCloud security gate --- .github/workflows/daily-org-status.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/daily-org-status.yml b/.github/workflows/daily-org-status.yml index 35ef8e00d..e2ccf320a 100644 --- a/.github/workflows/daily-org-status.yml +++ b/.github/workflows/daily-org-status.yml @@ -22,7 +22,7 @@ jobs: node-version: '20' - name: Install Claude Code CLI - run: npm install -g @anthropic-ai/claude-code@2.1.123 + run: npm install -g --ignore-scripts @anthropic-ai/claude-code@2.1.123 - name: Generate org status report env: From eb3618845bd2022a91c3e58d67760af083641ce0 Mon Sep 17 00:00:00 2001 From: don-petry Date: Fri, 1 May 2026 20:44:58 -0500 Subject: [PATCH 5/6] fix: address CodeRabbit and Copilot review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Raise repo discovery limit from 100 to 1000 - Increase GraphQL labels page from 5 to 20 (prevent missing needs-human-review) - Fix ci/review fields to emit JSON null instead of string "null" - Add sort_by before group_by for PR and merge aggregations - Replace --dangerously-skip-permissions with --allowedTools "" (no tool access for formatting task) - Add top-level permissions: {} to workflow - Guard against empty report before creating issue - Fix merge activity section header: "Last 7 Days" → "Last 8 Days" to match 8-day data window --- .github/workflows/daily-org-status.yml | 3 ++ scripts/org_status.sh | 42 ++++++++++++++++---------- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/.github/workflows/daily-org-status.yml b/.github/workflows/daily-org-status.yml index e2ccf320a..b99d00e36 100644 --- a/.github/workflows/daily-org-status.yml +++ b/.github/workflows/daily-org-status.yml @@ -5,6 +5,8 @@ on: - cron: '0 11 * * *' # 6:00 AM CDT (UTC-5) — shifts to 5 AM in winter CST workflow_dispatch: # allow manual trigger from Actions tab +permissions: {} # no default permissions; each job grants only what it needs + jobs: report: name: Generate & post org status @@ -31,6 +33,7 @@ jobs: run: | chmod +x scripts/org_status.sh bash scripts/org_status.sh > /tmp/report.md + [ -s /tmp/report.md ] || { echo "Report is empty — aborting"; exit 1; } - name: Truncate report if near GitHub issue body limit run: | diff --git a/scripts/org_status.sh b/scripts/org_status.sh index f5f64f080..490ee23d8 100644 --- a/scripts/org_status.sh +++ b/scripts/org_status.sh @@ -13,26 +13,35 @@ trap 'rm -rf "$DATA_DIR"' EXIT # ── Repo Discovery ──────────────────────────────────────────────────────────── echo "::group::Discovering repos" >&2 -ORG_REPOS=$(gh repo list petry-projects --json name --limit 100 | jq -r '.[].name' | sort) -PERSONAL_REPOS=$(gh repo list don-petry --json name --limit 100 | jq -r '.[].name' | sort) +ORG_REPOS=$(gh repo list petry-projects --json name --limit 1000 | jq -r '.[].name' | sort) +PERSONAL_REPOS=$(gh repo list don-petry --json name --limit 1000 | jq -r '.[].name' | sort) echo "petry-projects: $(echo "$ORG_REPOS" | wc -l | tr -d ' ') repos | don-petry: $(echo "$PERSONAL_REPOS" | wc -l | tr -d ' ') repos" >&2 echo "::endgroup::" >&2 # ── PR Collection + Classification ─────────────────────────────────────────── collect_classify_prs() { local owner=$1 repo=$2 - local cursor="null" all_nodes='[]' + local cursor="" all_nodes='[]' + # cursor="" = first page (pass JSON null); cursor=VALUE = subsequent pages (pass as string) while true; do local result + local -a cursor_arg + if [ -z "$cursor" ]; then + cursor_arg=(-F cursor=null) # -F = JSON-typed: passes null, not the string "null" + else + cursor_arg=(-f "cursor=$cursor") + fi + result=$(gh api graphql \ + "${cursor_arg[@]}" \ -f query='query($owner:String!,$repo:String!,$cursor:String){ repository(owner:$owner,name:$repo){ pullRequests(states:OPEN,first:100,after:$cursor){ pageInfo{hasNextPage endCursor} nodes{ number title createdAt isDraft - labels(first:5){nodes{name}} + labels(first:20){nodes{name}} reviewDecision statusCheckRollup{state} reviews(last:20){nodes{state}} @@ -41,17 +50,17 @@ collect_classify_prs() { } } }' \ - -f owner="$owner" -f repo="$repo" -f cursor="$cursor" 2>/dev/null) \ + -f owner="$owner" -f repo="$repo" 2>/dev/null) \ || result='{"data":{"repository":{"pullRequests":{"pageInfo":{"hasNextPage":false},"nodes":[]}}}}' local nodes has_next end_cursor nodes=$(echo "$result" | jq '.data.repository.pullRequests.nodes // []') has_next=$(echo "$result" | jq -r '.data.repository.pullRequests.pageInfo.hasNextPage // false') - end_cursor=$(echo "$result" | jq -r '.data.repository.pullRequests.pageInfo.endCursor // "null"') + end_cursor=$(echo "$result" | jq -r '.data.repository.pullRequests.pageInfo.endCursor // ""') all_nodes=$(jq -n --argjson a "$all_nodes" --argjson b "$nodes" '$a + $b') - [ "$has_next" = "true" ] || break - cursor="\"$end_cursor\"" + [ "$has_next" = "true" ] && [ -n "$end_cursor" ] || break + cursor="$end_cursor" done echo "$all_nodes" | jq --arg owner "$owner" --arg repo "$repo" ' @@ -63,8 +72,8 @@ collect_classify_prs() { url: ("https://github.com/" + $owner + "/" + $repo + "/pull/" + (.number|tostring)), labels: [.labels.nodes[].name], isDraft: .isDraft, - ci: (.statusCheckRollup.state // "null"), - review: (.reviewDecision // "null"), + ci: (.statusCheckRollup.state // null), + review: (.reviewDecision // null), approvals: ([.reviews.nodes[] | select(.state=="APPROVED")] | length), needsHumanReview: ([.labels.nodes[].name] | any(. == "needs-human-review")), isDepBump: (.title | test("^chore\\(deps")), @@ -101,7 +110,7 @@ echo "::endgroup::" >&2 # Pre-aggregate PR counts by category per repo (keeps prompt size manageable) PR_BY_REPO=$(echo "$ALL_PRS" | jq ' - group_by(.repo) | map({ + sort_by(.repo) | group_by(.repo) | map({ repo: .[0].repo, total: length, draft: ([.[] | select(.category=="Draft")] | length), @@ -226,10 +235,10 @@ $(echo "$ISSUE_PR_MAP" | jq -c '.') $(echo "$MERGE_DAILY" | jq -c '.') ### Org Merges Raw (for per-repo breakdown) -$(echo "$ORG_MERGES" | jq -c '[group_by(.repository.name) | .[] | {repo: .[0].repository.name, count: length}] | sort_by(-.count)') +$(echo "$ORG_MERGES" | jq -c '[sort_by(.repository.name) | group_by(.repository.name) | .[] | {repo: .[0].repository.name, count: length}] | sort_by(-.count)') ### Personal Merges Raw (for per-repo breakdown) -$(echo "$PERSONAL_MERGES" | jq -c '[group_by(.repository.name) | .[] | {repo: .[0].repository.name, count: length}] | sort_by(-.count)') +$(echo "$PERSONAL_MERGES" | jq -c '[sort_by(.repository.name) | group_by(.repository.name) | .[] | {repo: .[0].repository.name, count: length}] | sort_by(-.count)') ### Open Issues by Repo (each issue has url field) $(echo "$ISSUES_BY_REPO" | jq -c '.') @@ -286,7 +295,7 @@ For each repo with issues, show top 20 rows: - Title as markdown link using url field: [title](url) If none: _No open discussions found across petry-projects._ -### \`## PR Merge Activity — Last 7 Days\` +### \`## PR Merge Activity — Last 8 Days\` Daily table (include zero rows): | Date | petry-projects | don-petry | Per-org repo breakdown sorted descending: @@ -304,6 +313,7 @@ OUTPUT CONTRACT PROMPT # ── Generate Report ─────────────────────────────────────────────────────────── -# --dangerously-skip-permissions: required in CI to bypass interactive tool-approval prompts +# --allowedTools "": disable all tool use so Claude can't act on untrusted PR/issue content +# Pipe prompt via stdin rather than a shell argument to avoid ARG_MAX (~1MB) with large orgs echo "Generating report with Claude..." >&2 -claude -p "$(cat "$DATA_DIR/prompt.txt")" --dangerously-skip-permissions 2>/dev/null +claude -p --allowedTools "" < "$DATA_DIR/prompt.txt" 2>/dev/null From 760ddcf84b428909c369b1d1701705e4d945f65e Mon Sep 17 00:00:00 2001 From: don-petry Date: Fri, 1 May 2026 20:57:29 -0500 Subject: [PATCH 6/6] fix: remove --ignore-scripts (postinstall required), NOSONAR annotation; fix remaining review comments - Remove --ignore-scripts from npm install: claude-code postinstall downloads the binary and is required for the CLI to function; add NOSONAR annotation to acknowledge the postinstall is the Anthropic binary fetcher, not arbitrary - Add sort_by(.key) before group_by(.key) in ISSUE_PR_MAP - Fix discussions query: labels(first:5) -> labels(first:20) --- .github/workflows/daily-org-status.yml | 3 ++- scripts/org_status.sh | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/daily-org-status.yml b/.github/workflows/daily-org-status.yml index b99d00e36..ba4669c1c 100644 --- a/.github/workflows/daily-org-status.yml +++ b/.github/workflows/daily-org-status.yml @@ -24,7 +24,8 @@ jobs: node-version: '20' - name: Install Claude Code CLI - run: npm install -g --ignore-scripts @anthropic-ai/claude-code@2.1.123 + # postinstall (node install.cjs) downloads the claude binary — required for the CLI to function + run: npm install -g @anthropic-ai/claude-code@2.1.123 # NOSONAR: postinstall is the Anthropic binary fetcher, not an arbitrary script - name: Generate org status report env: diff --git a/scripts/org_status.sh b/scripts/org_status.sh index 490ee23d8..7892a70f3 100644 --- a/scripts/org_status.sh +++ b/scripts/org_status.sh @@ -129,7 +129,7 @@ NEEDS_REVIEW_PRS=$(echo "$ALL_PRS" | jq '[.[] | select(.needsHumanReview)]') ISSUE_PR_MAP=$(echo "$ALL_PRS" | jq ' [.[] | . as $pr | ($pr.closingIssues // [])[] | {key: ($pr.repo + "#" + (. | tostring)), value: {number: $pr.number, url: $pr.url}} - ] | group_by(.key) | map({ + ] | sort_by(.key) | group_by(.key) | map({ key: .[0].key, value: [.[] | .value] }) | from_entries') @@ -191,7 +191,7 @@ DISCUSSIONS=$(gh api graphql -f query=' nodes{ number title createdAt comments{totalCount} - labels(first:5){nodes{name}} + labels(first:20){nodes{name}} } } }