diff --git a/README.md b/README.md index b1cdf90..d978902 100644 --- a/README.md +++ b/README.md @@ -201,3 +201,44 @@ Requires: `gh` CLI (authenticated), `jq`, `bash` 4.0+. Add repos to `.claude-review-ignore` (one `owner/repo` per line) to skip them in audits. Useful for repos that should never have the review installed. + +--- + +## Bulk-install script (`smartwatermelon` only) + +`bulk-install-claude-review.sh` installs (or refreshes) the +`claude-blocking-review` caller workflow across all non-archived repos under +`smartwatermelon`. Workaround for the fact that GitHub's workflow-templates +picker is **organization-only** — `smartwatermelon` is a user account, so +the templates in `smartwatermelon/.github/workflow-templates/` never appear +in the "New workflow" picker for `smartwatermelon/*` repos. + +```bash +./bulk-install-claude-review.sh # dry-run (default) +./bulk-install-claude-review.sh --apply # open PRs +./bulk-install-claude-review.sh --only smartwatermelon/foo --apply +``` + +The script classifies each repo: + +| Class | Action | +|-------|--------| +| `CURRENT` | Already on the target version. No-op. | +| `STALE` | Different pin or floating tag. Opens a PR bumping the pin. | +| `MISSING` | No caller workflow at all. Opens a PR adding the canonical stub. | +| `CUSTOMIZED` | Has caller-side modifications (`paths-ignore`, `extra_instructions`, custom `model`/`timeout_minutes`, etc.). Skipped regardless of pin — flag for human review. | +| `LOCAL` | Uses a local-path reference (`./...`). Not bumpable; e.g. the `github-workflows` repo's own self-review. | + +Target version is derived dynamically from the `@v…` pin in +`smartwatermelon/.github/workflow-templates/claude-blocking-review.yml`, +so a PR bumping that template is the single trigger to roll a new version +across the fleet. + +PRs include `[skip-claude-review: bulk-install]` in the body so the +blocking-review workflow doesn't gate its own install/bump PR. + +For `nightowlstudiollc`, this script is intentionally not used — that org gets +the workflow-templates picker for new repos, and (planned) Repository Rulesets +for org-wide enforcement. + +Plan: `docs/plans/2026-04-30-bulk-install-smartwatermelon-fleet.md`. diff --git a/bulk-install-claude-review.sh b/bulk-install-claude-review.sh new file mode 100755 index 0000000..47a9ff5 --- /dev/null +++ b/bulk-install-claude-review.sh @@ -0,0 +1,453 @@ +#!/usr/bin/env bash +# bulk-install-claude-review.sh +# +# Bulk-installs (or refreshes) the claude-blocking-review caller workflow +# across all eligible repos under the smartwatermelon user account. +# +# Workaround for the fact that GitHub's workflow-templates picker is +# org-only — smartwatermelon is a user account, so workflow-templates +# in smartwatermelon/.github never appear in the picker UI for +# smartwatermelon/* repos. This script closes that gap by opening +# install/refresh PRs. +# +# Behavior: +# - DRY-RUN BY DEFAULT. Use --apply to actually open PRs. +# - Classifies each repo as MISSING / STALE / CURRENT / CUSTOMIZED. +# - Opens at most one PR per repo per invocation. +# - Idempotent: re-running with no changes produces no PRs. +# +# Source of truth for the canonical caller stub: +# smartwatermelon/.github/workflow-templates/claude-blocking-review.yml +# at HEAD. The script extracts the @vX.Y.Z pin from that file and uses +# it as the target version. +# +# Requirements: gh CLI (authenticated, repo + workflow scopes), jq, +# base64, bash 4.0+, GNU grep/sed via PATH (works fine on macOS with +# stock /usr/bin/grep). +# +# Usage: +# ./bulk-install-claude-review.sh [--dry-run|--apply] [--only owner/repo] [--verbose] + +set -uo pipefail + +if [[ "${BASH_VERSINFO[0]}" -lt 4 ]]; then + printf "Error: bash 4.0+ required (found %s). Run as: ./%s\n" \ + "${BASH_VERSION}" "${0##*/}" >&2 + exit 1 +fi + +# ── config ───────────────────────────────────────────────────────────────────── +TARGET_OWNER="smartwatermelon" +CANONICAL_REPO="smartwatermelon/.github" +CANONICAL_PATH="workflow-templates/claude-blocking-review.yml" +INSTALL_PATH=".github/workflows/claude-code-review.yml" +IGNORE_FILE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/.claude-review-ignore" +PLAN_LINK="https://github.com/smartwatermelon/github-workflows/blob/main/docs/plans/2026-04-30-bulk-install-smartwatermelon-fleet.md" + +# ── flag parsing ──────────────────────────────────────────────────────────────── +APPLY=false +ONLY="" +VERBOSE=false + +while [[ "$#" -gt 0 ]]; do + case "${1}" in + --dry-run) APPLY=false ;; + --apply) APPLY=true ;; + --only) + shift + ONLY="${1:-}" + if [[ -z "${ONLY}" || "${ONLY}" == --* ]]; then + printf "Error: --only requires a non-empty owner/repo argument\n" >&2 + exit 2 + fi + ;; + --verbose) VERBOSE=true ;; + -h | --help) + sed -n '2,/^$/p' "${BASH_SOURCE[0]}" | sed 's/^# \?//' + exit 0 + ;; + *) + printf "Error: unknown argument '%s'\n" "${1}" >&2 + exit 2 + ;; + esac + shift +done + +# ── formatting ────────────────────────────────────────────────────────────────── +ok() { printf " ✅ %s\n" "${*}"; } +fail() { printf " ❌ %s\n" "${*}"; } +warn() { printf " ⚠️ %s\n" "${*}"; } +info() { ${VERBOSE} && printf " ℹ %s\n" "${*}" || true; } +note() { printf " → %s\n" "${*}"; } + +# ── load ignore list ──────────────────────────────────────────────────────────── +declare -A IGNORED_REPOS=() +if [[ -f "${IGNORE_FILE}" ]]; then + while IFS= read -r line; do + line="${line%%#*}" + line="${line// /}" + [[ -z "${line}" ]] && continue + IGNORED_REPOS["${line}"]=1 + done <"${IGNORE_FILE}" +fi + +# ── fetch canonical stub & extract target version ───────────────────────────── +fetch_file() { + local full="${1}" path="${2}" + gh api "repos/${full}/contents/${path}" --jq '.content' 2>/dev/null \ + | tr -d '\n' | base64 -d 2>/dev/null +} + +CANONICAL_CONTENT="$(fetch_file "${CANONICAL_REPO}" "${CANONICAL_PATH}")" +if [[ -z "${CANONICAL_CONTENT}" ]]; then + fail "Could not fetch canonical stub from ${CANONICAL_REPO}/${CANONICAL_PATH}" + exit 3 +fi + +# strip_comments before grep so a commented-out @version (e.g. an +# example block) can't be picked up as the canonical pin. Same defense +# extract_pin uses for consumer files. +strip_comments() { echo "${1}" | grep -v '^[[:space:]]*#'; } + +TARGET_VERSION="$(strip_comments "${CANONICAL_CONTENT}" \ + | grep -m1 -oE 'claude-blocking-review\.yml@[A-Za-z0-9._/-]+' \ + | sed 's/^.*@//')" + +if [[ -z "${TARGET_VERSION}" ]]; then + fail "Canonical stub does not contain a @version pin — aborting" + exit 3 +fi + +# ── accumulators ───────────────────────────────────────────────────────────────── +declare -a REPOS_MISSING=() +declare -a REPOS_STALE=() +declare -a REPOS_CURRENT=() +declare -a REPOS_CUSTOMIZED=() +declare -a REPOS_LOCAL=() +declare -a REPOS_SKIPPED=() +declare -a REPOS_ERROR=() +declare -a PR_URLS=() + +# ── helpers ───────────────────────────────────────────────────────────────────── +# strip_comments is defined earlier (before TARGET_VERSION extraction) + +uses_blocking_review() { + strip_comments "${1}" | grep -q "claude-blocking-review\.yml" +} + +# Extract the @version pin from a workflow file (first match in non-comment lines). +# Restricted to characters valid in git refs/SHAs to avoid capturing trailing +# punctuation (commas, quotes) from YAML. +extract_pin() { + strip_comments "${1}" | grep -m1 -oE 'claude-blocking-review\.yml@[A-Za-z0-9._/-]+' \ + | sed 's/^.*@//' +} + +# Detect local-path caller (uses ./ rather than a tagged reference) +uses_local_path() { + strip_comments "${1}" | grep -qE 'uses:[[:space:]]*\./' +} + +# Detect customization: caller has any of these non-trivial extras +has_customization() { + echo "${1}" | grep -qE '^[[:space:]]*(paths-ignore|paths|extra_instructions|model|timeout_minutes|env):' \ + || echo "${1}" | grep -q '\[skip-claude-review:' +} + +# Find the workflow file referencing the blocking review (returns "path|sha") +find_caller_file() { + local full="${1}" + local files + files="$(gh api "repos/${full}/contents/.github/workflows" \ + --jq '.[] | "\(.name)|\(.sha)"' 2>/dev/null || echo "")" + while IFS='|' read -r name sha; do + [[ -z "${name}" ]] && continue + local content + content="$(fetch_file "${full}" ".github/workflows/${name}")" + if uses_blocking_review "${content}"; then + printf "%s|%s\n" ".github/workflows/${name}" "${sha}" + return 0 + fi + done <<<"${files}" + return 1 +} + +# ── PR-creation primitives ────────────────────────────────────────────────────── +# Open a PR adding/updating the install file. Args: full_repo, branch_name, +# commit_title, pr_title, pr_body, file_path, file_content, [existing_sha] +open_install_pr() { + local full="${1}" branch="${2}" commit_title="${3}" pr_title="${4}" pr_body="${5}" + local file_path="${6}" file_content="${7}" existing_sha="${8:-}" + + if ! ${APPLY}; then + note "[dry-run] Would open PR: ${full} | branch=${branch} | file=${file_path}" + return 0 + fi + + # Determine base branch + local default_branch + default_branch="$(gh api "repos/${full}" --jq '.default_branch' 2>/dev/null || echo "main")" + + # Get base SHA + local base_sha + base_sha="$(gh api "repos/${full}/git/refs/heads/${default_branch}" \ + --jq '.object.sha' 2>/dev/null)" + if [[ -z "${base_sha}" ]]; then + fail "Could not resolve base SHA for ${full}@${default_branch}" + return 1 + fi + + # Create branch + if ! gh api -X POST "repos/${full}/git/refs" \ + -f ref="refs/heads/${branch}" -f sha="${base_sha}" >/dev/null 2>&1; then + # Already exists? Fail loudly so we don't accidentally re-push. + fail "Branch ${branch} already exists on ${full} — aborting this repo" + return 1 + fi + + # PUT file contents on the new branch. + # `printf "%s\n"` re-adds the trailing newline that command substitution + # (the $() that captured CANONICAL_CONTENT / current_content earlier) + # strips from text files. Without this, every install/bump PR would + # produce a file failing yamllint's "no newline at end of file" rule. + local b64_content + b64_content="$(printf "%s\n" "${file_content}" | base64 | tr -d '\n')" + local put_payload + if [[ -n "${existing_sha}" ]]; then + put_payload="$(jq -n \ + --arg msg "${commit_title}" \ + --arg branch "${branch}" \ + --arg content "${b64_content}" \ + --arg sha "${existing_sha}" \ + '{message:$msg, branch:$branch, content:$content, sha:$sha}')" + else + put_payload="$(jq -n \ + --arg msg "${commit_title}" \ + --arg branch "${branch}" \ + --arg content "${b64_content}" \ + '{message:$msg, branch:$branch, content:$content}')" + fi + + if ! gh api -X PUT "repos/${full}/contents/${file_path}" \ + --input - <<<"${put_payload}" >/dev/null 2>&1; then + fail "Failed to PUT ${file_path} on ${full}@${branch}" + return 1 + fi + + # Open PR + local pr_url + pr_url="$(gh pr create --repo "${full}" \ + --base "${default_branch}" \ + --head "${branch}" \ + --title "${pr_title}" \ + --body "${pr_body}" 2>/dev/null)" + if [[ -z "${pr_url}" ]]; then + fail "Failed to open PR on ${full} (${branch} created and file pushed; PR creation failed)" + return 1 + fi + ok "Opened ${pr_url}" + PR_URLS+=("${pr_url}") +} + +pr_body_template() { + local action="${1}" + cat <; got '${ONLY}'" + exit 2 + fi + process_repo "${ONLY#"${TARGET_OWNER}/"}" +else + repos="$(gh repo list "${TARGET_OWNER}" --no-archived --json name --limit 300 \ + --jq '.[].name' 2>/dev/null || echo "")" + if [[ -z "${repos}" ]]; then + fail "Could not list repos for ${TARGET_OWNER}" + exit 3 + fi + while IFS= read -r repo; do + [[ -z "${repo}" ]] && continue + process_repo "${repo}" + done <<<"${repos}" +fi + +# ── final summary ─────────────────────────────────────────────────────────────── +printf "\n\n══════════════════════════════════════════════════════════\n" +printf " SUMMARY (%s)\n" "${mode}" +printf "══════════════════════════════════════════════════════════\n" +printf " CURRENT (no action): %d\n" "${#REPOS_CURRENT[@]}" +printf " CUSTOMIZED (skipped): %d\n" "${#REPOS_CUSTOMIZED[@]}" +printf " LOCAL (no pin): %d\n" "${#REPOS_LOCAL[@]}" +printf " SKIPPED (ignore): %d\n" "${#REPOS_SKIPPED[@]}" +printf " STALE (will bump): %d\n" "${#REPOS_STALE[@]}" +printf " MISSING (will add): %d\n" "${#REPOS_MISSING[@]}" +printf " ERROR: %d\n" "${#REPOS_ERROR[@]}" + +list_section() { + local title="${1}" + shift + local -a items=("${@}") + [[ "${#items[@]}" -eq 0 ]] && return + printf "\n %s:\n" "${title}" + for r in "${items[@]}"; do printf " - %s\n" "${r}"; done +} + +list_section "MISSING" "${REPOS_MISSING[@]+"${REPOS_MISSING[@]}"}" +list_section "STALE" "${REPOS_STALE[@]+"${REPOS_STALE[@]}"}" +list_section "CUSTOMIZED" "${REPOS_CUSTOMIZED[@]+"${REPOS_CUSTOMIZED[@]}"}" +list_section "ERROR" "${REPOS_ERROR[@]+"${REPOS_ERROR[@]}"}" + +if [[ "${#PR_URLS[@]}" -gt 0 ]]; then + printf "\n PRs opened:\n" + for u in "${PR_URLS[@]}"; do printf " %s\n" "${u}"; done +fi + +if ! ${APPLY} && [[ "${#REPOS_MISSING[@]}" -gt 0 || "${#REPOS_STALE[@]}" -gt 0 ]]; then + printf "\n Re-run with --apply to actually open PRs.\n" +fi + +printf "\n══════════════════════════════════════════════════════════\n\n" + +# Surface ERROR class to callers (CI, cron, automation). Non-zero exit +# ensures fetch failures don't go silent. CUSTOMIZED is intentionally +# not an error — it's a human-review signal. +if [[ "${#REPOS_ERROR[@]}" -gt 0 ]]; then + exit 1 +fi