From 93843a0772b41969e9b1cb2413b35589575c0745 Mon Sep 17 00:00:00 2001 From: hyperpolymath <6759885+hyperpolymath@users.noreply.github.com> Date: Sat, 13 Jun 2026 21:28:06 +0100 Subject: [PATCH] ci: centralized roadmap-#35 sweeper (one workflow, one secret) Replaces the need to install a per-repo add-to-project workflow + secret in every repo. A single scheduled workflow here sweeps recently-touched issues & PRs from every owned repo (except son-shared: idaptik/burble/rattlescript/ vcl-ut) into the Hyperpolymath Master Roadmap (#35). - One secret (reuses the existing ADD_TO_PROJECT_PAT here); nothing to place in other repos. GITHUB_TOKEN can't read other repos, so cross-repo reads use the PAT. addProjectV2ItemById is idempotent, so overlapping windows are safe. - No external actions (pure gh) => no SHA-pin / allowlist friction. - Runs every 30 min + workflow_dispatch for on-demand. - LIMITATION (deliberate, to finish in a focused follow-up): with the PAT's current project+public_repo scope this covers PUBLIC repos only; private repos need the PAT widened to `repo` (still one secret, one place). The run logs the scanned-repo count so the gap is visible. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/roadmap-sync.yml | 86 ++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 .github/workflows/roadmap-sync.yml diff --git a/.github/workflows/roadmap-sync.yml b/.github/workflows/roadmap-sync.yml new file mode 100644 index 0000000..b069d6a --- /dev/null +++ b/.github/workflows/roadmap-sync.yml @@ -0,0 +1,86 @@ +# SPDX-License-Identifier: MPL-2.0 +# Centralized roadmap sweeper: adds recently-touched issues & PRs from every +# owned repo (except son-shared) into the Hyperpolymath Master Roadmap (#35). +# +# WHY centralized (one workflow, one secret) instead of a per-repo workflow: +# - the PAT secret lives in exactly ONE repo (this one), not ~250 copies; +# - one file to maintain, one place to rotate the token. +# GITHUB_TOKEN is scoped to this repo only and cannot read other repos, so all +# cross-repo reads use the classic PAT (ADD_TO_PROJECT_PAT: scopes project + +# public_repo). Private repos are therefore NOT covered yet — widening the PAT +# to `repo` (one secret, one place) is a deliberate later step. +name: Roadmap Sync (#35) + +on: + schedule: + - cron: '*/30 * * * *' # every 30 min (public repo → free minutes; adjust freely) + workflow_dispatch: # manual on-demand run + +permissions: {} # GITHUB_TOKEN unused; all work goes through the PAT + +concurrency: + group: roadmap-sync + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + timeout-minutes: 20 + env: + GH_TOKEN: ${{ secrets.ADD_TO_PROJECT_PAT }} + PROJECT_OWNER: hyperpolymath + PROJECT_NUMBER: '35' + # son-shared repos — excluded per estate boundaries (AGPL, son's work) + EXCLUDE: 'idaptik burble rattlescript vcl-ut' + WINDOW_MIN: '45' # look-back window (> cron interval for overlap) + steps: + - name: Sweep recent issues/PRs into roadmap #35 + run: | + set -euo pipefail + SINCE=$(date -u -d "${WINDOW_MIN} minutes ago" +%Y-%m-%dT%H:%M:%SZ) + echo "::group::Setup" + echo "Window since: $SINCE" + PID=$(gh api graphql \ + -f query='query($o:String!,$n:Int!){user(login:$o){projectV2(number:$n){id title}}}' \ + -f o="$PROJECT_OWNER" -F n="$PROJECT_NUMBER" --jq '.data.user.projectV2.id') + echo "Project node id: $PID" + echo "::endgroup::" + + # Owned, non-fork, non-archived repos the PAT can see (public with the + # current token scope). Excludes are skipped below. + mapfile -t REPOS < <(gh api --paginate \ + '/user/repos?affiliation=owner&per_page=100' \ + --jq '.[] | select(.fork==false and .archived==false) | .name') + echo "Visible owned repos: ${#REPOS[@]}" + + scanned=0; items=0; adds=0 + for r in "${REPOS[@]}"; do + skip=0 + for x in $EXCLUDE; do [ "$r" = "$x" ] && skip=1 && break; done + if [ "$skip" = "1" ]; then echo "skip (son-shared): $r"; continue; fi + scanned=$((scanned+1)) + + # The issues endpoint returns BOTH issues and PRs, filtered by + # updated_at >= since. node_id works for either content type. + NODES=$(gh api --paginate \ + "repos/$PROJECT_OWNER/$r/issues?state=open&since=$SINCE&per_page=100" \ + --jq '.[].node_id' 2>/dev/null || true) + for nid in $NODES; do + [ -z "$nid" ] && continue + items=$((items+1)) + # addProjectV2ItemById is idempotent: re-adding an existing item + # returns the existing item id, so overlapping windows are safe. + if gh api graphql \ + -f query='mutation($p:ID!,$c:ID!){addProjectV2ItemById(input:{projectId:$p,contentId:$c}){item{id}}}' \ + -f p="$PID" -f c="$nid" >/dev/null 2>&1; then + adds=$((adds+1)) + else + echo "::warning::could not add node $nid (from $r)" + fi + done + done + + echo "----------------------------------------" + echo "Repos scanned: $scanned (excluded: $EXCLUDE)" + echo "Recent items seen: $items | add-calls ok: $adds (idempotent)" + echo "NOTE: private repos are not covered until ADD_TO_PROJECT_PAT gains 'repo' scope."