Repository navigation
Daily org status report via GitHub Actions #169
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
4be9237
Add daily org status GitHub Action
don-petry d2b7990
Fix review findings: pinned actions, timeout, owner loop, jq scope, r…
don-petry a25b187
Enhance report: hyperlinks on all items, Linked PR column on issues, …
don-petry b5492fb
fix: add --ignore-scripts to npm install to satisfy SonarCloud securi…
don-petry eb36188
fix: address CodeRabbit and Copilot review comments
don-petry 760ddcf
fix: remove --ignore-scripts (postinstall required), NOSONAR annotati…
don-petry File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| 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 | ||
|
|
||
| permissions: {} # no default permissions; each job grants only what it needs | ||
|
|
||
| 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@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 | ||
|
|
||
| - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 | ||
| with: | ||
| node-version: '20' | ||
|
|
||
| - name: Install Claude Code CLI | ||
| # 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: | ||
| 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 | ||
| [ -s /tmp/report.md ] || { echo "Report is empty — aborting"; exit 1; } | ||
|
|
||
| - 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 | ||
|
|
||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| - 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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,319 @@ | ||
| #!/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) | ||
| 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 | ||
|
|
||
| # ── Repo Discovery ──────────────────────────────────────────────────────────── | ||
| echo "::group::Discovering repos" >&2 | ||
| 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="" 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:20){nodes{name}} | ||
| reviewDecision | ||
| statusCheckRollup{state} | ||
| reviews(last:20){nodes{state}} | ||
| closingIssuesReferences(first:10){nodes{number}} | ||
| } | ||
| } | ||
| } | ||
| }' \ | ||
| -f owner="$owner" -f repo="$repo" 2>/dev/null) \ | ||
| || result='{"data":{"repository":{"pullRequests":{"pageInfo":{"hasNextPage":false},"nodes":[]}}}}' | ||
|
|
||
|
don-petry marked this conversation as resolved.
|
||
| 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 // ""') | ||
|
|
||
| all_nodes=$(jq -n --argjson a "$all_nodes" --argjson b "$nodes" '$a + $b') | ||
| [ "$has_next" = "true" ] && [ -n "$end_cursor" ] || 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")), | ||
| closingIssues: [.closingIssuesReferences.nodes[].number], | ||
| 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 ' | ||
| sort_by(.repo) | 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)') | ||
|
don-petry marked this conversation as resolved.
|
||
|
|
||
| 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}} | ||
| ] | sort_by(.key) | 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" \ | ||
| --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 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) | ||
| })') | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| echo "::endgroup::" >&2 | ||
|
|
||
| # ── Issues ──────────────────────────────────────────────────────────────────── | ||
| 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,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 | ||
| 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,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 | ||
| 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 | ||
| 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:20){nodes{name}} | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
| }' 2>/dev/null | jq '[ | ||
| .data.organization.repositories.nodes[] | ||
| | select(.hasDiscussionsEnabled and .discussions.totalCount > 0) | ||
| | . as $r | ||
| | { | ||
| repo: ("petry-projects/" + .name), | ||
| discussions: [ | ||
| .discussions.nodes[] | . + { | ||
| url: ("https://github.com/petry-projects/" + $r.name + "/discussions/" + (.number|tostring)) | ||
| } | ||
| ] | ||
| } | ||
| ]') || 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, includes url field) | ||
| $(echo "$NEEDS_REVIEW_PRS" | jq -c '.') | ||
|
|
||
| ### 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) | ||
| $(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 '[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 '.') | ||
|
|
||
| ### Open Discussions (each discussion has url field) | ||
| $(echo "$DISCUSSIONS" | jq -c '.') | ||
|
|
||
| --- | ||
|
|
||
| ## REPORT FORMAT | ||
|
|
||
| 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): | ||
| | 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 | | ||
| - 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 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 | 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 8 Days\` | ||
| 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. | ||
|
|
||
| --- | ||
|
|
||
| OUTPUT CONTRACT | ||
| - Dates: YYYY-MM-DD | ||
| - Section headers include total counts: \`## Open Issues (47 total)\` | ||
| - Empty sections show _none_, never omit them | ||
| - Every item with a url must be rendered as a markdown hyperlink | ||
| PROMPT | ||
|
|
||
| # ── Generate Report ─────────────────────────────────────────────────────────── | ||
| # --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 --allowedTools "" < "$DATA_DIR/prompt.txt" 2>/dev/null | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.