repo-scorecard.sh 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522
  1. #!/usr/bin/env bash
  2. # Scored, read-only repo-health scorecard — orchestrates the github-ops auditors.
  3. #
  4. # READ-ONLY. Only GET `gh api` calls plus calls to the read-only sibling scripts
  5. # (check-security-posture.sh, check-issues.sh). NEVER a -X PUT/PATCH/POST/DELETE.
  6. # It rolls five dimensions into one 0–100 score + letter grade per repo, and
  7. # (with --org) a fleet matrix + roll-up. The remediation pointers it prints are
  8. # TEXT for you to act on — this script applies nothing.
  9. #
  10. # Usage: repo-scorecard.sh [--repo OWNER/REPO | --remote NAME | --org OWNER]
  11. # [--min-score N] [--json] [-h|--help]
  12. # Input: argv only. Default repo = derived from the 'origin' remote of the cwd.
  13. # Output: stdout = the data product (human matrix, or --json envelope).
  14. # --json schema: claude-mods.github-ops.repo-scorecard/v1
  15. # Stderr: headers, progress, the review banner, skip notices, errors.
  16. # Exit: 0 all audited repos healthy (no gaps; or all >= --min-score)
  17. # 2 usage (bad/unknown flag, malformed OWNER/REPO, mutex selectors)
  18. # 5 gh not installed
  19. # 7 unavailable — non-github remote, gh unauthed/offline, timeout
  20. # (graceful, like the siblings; never a false "healthy")
  21. # 10 findings — gaps present, or a repo scored below --min-score
  22. #
  23. # SCORING MODEL (transparent rubric, documented so it is auditable):
  24. # Each dimension yields a status (ok / warn / gap / n/a) and earns a fraction
  25. # of its weight. n/a (couldn't read) earns ZERO and is never treated as ok.
  26. #
  27. # Dimension Weight ok(full) warn(half) gap(zero)
  28. # ───────── ────── ──────── ────────── ─────────
  29. # security 35 no gaps, low/medium gaps only high/critical gap
  30. # 0 open alerts OR any open alert
  31. # metadata 25 all 6 present 1–2 missing 3+ missing
  32. # release 15 >=1 release & releases exist but no releases at all
  33. # latest tag latest tag has no rel
  34. # has a release
  35. # issues 15 none external 1–3 external/stale 4+ external/stale
  36. # or stale
  37. # actions 10 latest run no runs found (warn) latest run = failure
  38. # succeeded
  39. # ─────
  40. # total weight = 100
  41. #
  42. # score = round( sum(weight_i * fraction_i) ), fraction in {1, 0.5, 0}.
  43. # n/a dimensions earn 0 of their weight (honest: an unreadable security
  44. # dimension can NEVER score full). Grade: A>=90 B>=75 C>=60 D>=40 F<40.
  45. # Security is weighted highest by design; a single open critical alert or a
  46. # high-severity gap zeroes 35 points and caps the grade hard.
  47. #
  48. # --min-score N: exit 10 if ANY audited repo scores below N (CI-gating knob),
  49. # independent of whether other gaps exist.
  50. #
  51. # Examples:
  52. # repo-scorecard.sh --repo 0xDarkMatter/flarecrawl
  53. # repo-scorecard.sh --org 0xDarkMatter
  54. # repo-scorecard.sh --repo OWNER/REPO --json | jq '.data[0].top_fixes'
  55. # repo-scorecard.sh --org OWNER --min-score 75 # CI gate: fail if any repo < 75
  56. set -uo pipefail
  57. EX_OK=0; EX_USAGE=2; EX_MISSING_DEP=5; EX_UNAVAILABLE=7; EX_FINDINGS=10
  58. GH_TIMEOUT="${GH_TIMEOUT:-20}" # seconds; bounds every network call
  59. HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  60. SEC="$HERE/check-security-posture.sh"
  61. ISS="$HERE/check-issues.sh"
  62. # Terminal design system (skills/_lib/term.sh). Framing prints to stderr, so detect
  63. # color on fd 2. Degrade to plain output if the shared lib isn't reachable.
  64. __lib="$(cd "$HERE/../../_lib" 2>/dev/null && pwd || true)"
  65. if [ -n "${__lib:-}" ] && [ -f "$__lib/term.sh" ]; then . "$__lib/term.sh"; term_init 2
  66. else
  67. term_panel_open() { printf '== %s %s ==\n' "${2:-}" "${3:-}"; }
  68. term_panel_close() { [ -n "${1:-}" ] && printf '%s\n' "$1"; }
  69. term_panel_vert() { :; }
  70. term_panel_line() { printf ' %s\n' "$*"; }
  71. term_section() { printf '%s (%s)\n' "${2:-}" "${3:-}"; }
  72. term_color() { shift; printf '%s' "$*"; }
  73. term_mark() { case "${1:-}" in ok) printf '+';; bad|gap) printf 'x';; warn) printf '!';; skip|na) printf '-';; unknown) printf '?';; *) printf '.';; esac; }
  74. term_health() { shift; printf '%s' "$*"; }
  75. term_pip_bar() { :; }
  76. TERM_ARROW="->"
  77. fi
  78. REPO=""; REMOTE="origin"; ORG=""; JSON=0; MIN_SCORE=""
  79. while [ $# -gt 0 ]; do
  80. case "$1" in
  81. --repo) REPO="${2:?--repo needs OWNER/REPO}"; shift 2 ;;
  82. --remote) REMOTE="${2:?--remote needs a name}"; shift 2 ;;
  83. --org) ORG="${2:?--org needs an OWNER}"; shift 2 ;;
  84. --min-score) MIN_SCORE="${2:?--min-score needs N}"; shift 2 ;;
  85. --json) JSON=1; shift ;;
  86. -h|--help) sed -n '2,57p' "$0" | sed 's/^# \{0,1\}//'; exit "$EX_OK" ;;
  87. *) echo "repo-scorecard: unknown argument: $1" >&2; exit "$EX_USAGE" ;;
  88. esac
  89. done
  90. skip() { echo "repo-scorecard: $1" >&2; exit "$EX_UNAVAILABLE"; }
  91. command -v gh >/dev/null 2>&1 || {
  92. echo "repo-scorecard: gh not installed (https://cli.github.com)" >&2
  93. exit "$EX_MISSING_DEP"
  94. }
  95. command -v jq >/dev/null 2>&1 || skip "jq not installed"
  96. # --min-score must be an integer if given.
  97. if [ -n "$MIN_SCORE" ] && ! printf '%s' "$MIN_SCORE" | grep -Eq '^[0-9]+$'; then
  98. echo "repo-scorecard: --min-score needs an integer, got '$MIN_SCORE'" >&2; exit "$EX_USAGE"
  99. fi
  100. runner() { if command -v timeout >/dev/null 2>&1; then timeout "$GH_TIMEOUT" "$@"; else "$@"; fi; }
  101. # Agent safety — never interpolate a fabricated path into a gh call.
  102. valid_repo() { printf '%s' "$1" | grep -Eq '^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$'; }
  103. valid_owner() { printf '%s' "$1" | grep -Eq '^[A-Za-z0-9._-]+$'; }
  104. # Weights (sum = 100).
  105. W_SECURITY=35; W_METADATA=25; W_RELEASE=15; W_ISSUES=15; W_ACTIONS=10
  106. # --------------------------------------------------------------------------
  107. # gh api wrapper that distinguishes "exists" from "404" from "couldn't read".
  108. # Echoes body on success; sets a global GHRC: 0 ok, 4 not-found, 7 unavailable.
  109. # --------------------------------------------------------------------------
  110. gh_get() { # path -> echoes body, sets GHRC
  111. local out
  112. out="$(runner gh api "$1" 2>/dev/null)"; local rc=$?
  113. if [ $rc -ne 0 ]; then
  114. # gh exits nonzero on 404 too; disambiguate via the error JSON if present.
  115. if printf '%s' "$out" | grep -q '"status": *"404"' 2>/dev/null; then GHRC=4; else GHRC=7; fi
  116. printf '%s' "$out"; return
  117. fi
  118. GHRC=0; printf '%s' "$out"
  119. }
  120. # Does a path exist in the repo? 0 yes, 1 no, 2 couldn't-read.
  121. content_exists() { # OWNER/REPO PATH
  122. if runner gh api "repos/$1/contents/$2" --silent >/dev/null 2>&1; then return 0; fi
  123. # --silent suppresses the body; re-probe to classify 404 vs auth/offline.
  124. local body; body="$(runner gh api "repos/$1/contents/$2" 2>&1)"
  125. printf '%s' "$body" | grep -q '404' && return 1
  126. return 2
  127. }
  128. # --------------------------------------------------------------------------
  129. # Score ONE repo. Echoes a compact JSON object; returns 0 healthy / 10 findings
  130. # / 7 unavailable (couldn't read the core repo object at all).
  131. # --------------------------------------------------------------------------
  132. score_repo() { # OWNER/REPO -> echoes JSON object; returns 0|10|7
  133. local R="$1" owner core vis
  134. owner="${R%%/*}"
  135. core="$(runner gh api "repos/$R" 2>/dev/null)" || return 7
  136. [ -n "$core" ] || return 7
  137. vis="$(printf '%s' "$core" | jq -r '.visibility // (if .private then "private" else "public" end)' | tr -d '\r')"
  138. local default_branch
  139. default_branch="$(printf '%s' "$core" | jq -r '.default_branch // "main"' | tr -d '\r')"
  140. # ---- DIMENSION: metadata (6 facets) -----------------------------------
  141. local md_desc md_home md_topics md_lic md_readme md_changelog md_missing=0 md_detail=""
  142. md_desc="$(printf '%s' "$core" | jq -r '.description // "" | length' | tr -d '\r')"
  143. md_home="$(printf '%s' "$core" | jq -r '.homepage // "" | length' | tr -d '\r')"
  144. # topics live on the core object as .topics (array).
  145. md_topics="$(printf '%s' "$core" | jq -r '(.topics // []) | length' | tr -d '\r')"
  146. [ "${md_desc:-0}" -gt 0 ] || { md_missing=$((md_missing+1)); md_detail="$md_detail description;"; }
  147. # homepage is optional — count it only as a soft facet (missing homepage does
  148. # NOT increment md_missing; it is informational). We track it for detail only.
  149. if [ "${md_home:-0}" -gt 0 ]; then md_home="set"; else md_home="unset"; fi
  150. if [ "${md_topics:-0}" -ge 3 ]; then :; else md_missing=$((md_missing+1)); md_detail="$md_detail topics<3;"; fi
  151. if content_exists "$R" "LICENSE"; then md_lic=1
  152. elif content_exists "$R" "LICENSE.md"; then md_lic=1
  153. else md_lic=0; md_missing=$((md_missing+1)); md_detail="$md_detail LICENSE;"; fi
  154. if content_exists "$R" "README.md"; then md_readme=1
  155. else md_readme=0; md_missing=$((md_missing+1)); md_detail="$md_detail README;"; fi
  156. if content_exists "$R" "CHANGELOG.md"; then md_changelog=1
  157. else md_changelog=0; md_missing=$((md_missing+1)); md_detail="$md_detail CHANGELOG;"; fi
  158. # 5 hard facets (description, topics>=3, LICENSE, README, CHANGELOG).
  159. local md_status md_frac
  160. if [ "$md_missing" -eq 0 ]; then md_status="ok"; md_frac="1"
  161. elif [ "$md_missing" -le 2 ]; then md_status="warn"; md_frac="0.5"
  162. else md_status="gap"; md_frac="0"; fi
  163. [ -n "$md_detail" ] || md_detail="all present"
  164. md_detail="${md_detail# }"
  165. # ---- DIMENSION: release ----------------------------------------------
  166. local rel_status rel_frac rel_detail rel_count latest_tag rel_for_tag
  167. rel_count="$(runner gh api "repos/$R/releases?per_page=1" --jq 'length' 2>/dev/null | tr -d '\r')"
  168. latest_tag="$(runner gh api "repos/$R/tags?per_page=1" --jq '.[0].name // ""' 2>/dev/null | tr -d '\r')"
  169. if [ -z "${rel_count:-}" ]; then
  170. rel_status="n/a"; rel_frac="0"; rel_detail="couldn't read releases"
  171. elif [ "$rel_count" -eq 0 ]; then
  172. rel_status="gap"; rel_frac="0"; rel_detail="no GitHub releases"
  173. else
  174. # >=1 release exists. Is the latest TAG backed by a release?
  175. if [ -n "$latest_tag" ]; then
  176. if runner gh api "repos/$R/releases/tags/$latest_tag" --silent >/dev/null 2>&1; then
  177. rel_status="ok"; rel_frac="1"; rel_detail="latest tag $latest_tag has a release"
  178. else
  179. rel_status="warn"; rel_frac="0.5"; rel_detail="latest tag $latest_tag has no release"
  180. fi
  181. else
  182. rel_status="ok"; rel_frac="1"; rel_detail="releases present (no tags listed)"
  183. fi
  184. fi
  185. # ---- DIMENSION: security (orchestrate the sibling) --------------------
  186. local sec_status sec_frac sec_detail sec_json sec_rc
  187. sec_json="$("$SEC" --repo "$R" --json 2>/dev/null)"; sec_rc=$?
  188. if [ "$sec_rc" -eq 7 ] || [ -z "$sec_json" ] || ! printf '%s' "$sec_json" | jq -e . >/dev/null 2>&1; then
  189. sec_status="n/a"; sec_frac="0"; sec_detail="security audit unavailable"
  190. local sec_gaps=-1 sec_alerts=-1 sec_maxsev="unknown"
  191. else
  192. # The single-repo envelope: {data:[features], meta:{gaps, open_alerts,...}}.
  193. local sec_gaps sec_alerts sec_maxsev
  194. sec_gaps="$(printf '%s' "$sec_json" | jq -r '.meta.gaps // 0')"
  195. sec_alerts="$(printf '%s' "$sec_json" | jq -r '.meta.open_alerts // 0')"
  196. # Max severity across (a) gap rows and (b) any open alert.
  197. sec_maxsev="$(printf '%s' "$sec_json" | jq -r '
  198. ([ .data[]
  199. | select(.applicable==true)
  200. | (if ((.open_alerts // 0) > 0) then .max_severity else empty end),
  201. (if (.state=="off" or .state=="unknown") then .severity else empty end) ]
  202. | map(select(. != null and . != "" and . != "none" and . != "note")
  203. | ascii_downcase)) as $s
  204. | (["critical","high","medium","low"] | map(select(. as $t | $s | index($t))) | .[0]) // "none"')"
  205. if [ "$sec_gaps" -eq 0 ] && [ "$sec_alerts" -eq 0 ]; then
  206. sec_status="ok"; sec_frac="1"; sec_detail="no gaps, 0 open alerts"
  207. elif [ "$sec_maxsev" = "critical" ] || [ "$sec_maxsev" = "high" ] || [ "$sec_alerts" -gt 0 ]; then
  208. sec_status="gap"; sec_frac="0"
  209. sec_detail="$sec_gaps gap(s), $sec_alerts open alert(s), max $sec_maxsev"
  210. else
  211. sec_status="warn"; sec_frac="0.5"
  212. sec_detail="$sec_gaps gap(s) (max $sec_maxsev), 0 open alerts"
  213. fi
  214. fi
  215. # ---- DIMENSION: issues (orchestrate the sibling) ----------------------
  216. local iss_status iss_frac iss_detail iss_json iss_rc iss_flagged=-1 iss_total=-1
  217. iss_json="$("$ISS" --repo "$R" --json 2>/dev/null)"; iss_rc=$?
  218. if [ "$iss_rc" -eq 7 ] || [ -z "$iss_json" ] || ! printf '%s' "$iss_json" | jq -e . >/dev/null 2>&1; then
  219. iss_status="n/a"; iss_frac="0"; iss_detail="issue audit unavailable"
  220. else
  221. iss_flagged="$(printf '%s' "$iss_json" | jq -r '.meta.flagged // 0')"
  222. iss_total="$(printf '%s' "$iss_json" | jq -r '.meta.total_open // 0')"
  223. if [ "$iss_flagged" -eq 0 ]; then
  224. iss_status="ok"; iss_frac="1"; iss_detail="$iss_total open, none external/stale"
  225. elif [ "$iss_flagged" -le 3 ]; then
  226. iss_status="warn"; iss_frac="0.5"; iss_detail="$iss_flagged external/stale of $iss_total open"
  227. else
  228. iss_status="gap"; iss_frac="0"; iss_detail="$iss_flagged external/stale of $iss_total open"
  229. fi
  230. fi
  231. # ---- DIMENSION: actions (single signal) -------------------------------
  232. local act_status act_frac act_detail act_json act_concl
  233. act_json="$(runner gh api "repos/$R/actions/runs?branch=$default_branch&per_page=1" 2>/dev/null)"
  234. if [ -z "$act_json" ] || ! printf '%s' "$act_json" | jq -e '.workflow_runs' >/dev/null 2>&1; then
  235. act_status="n/a"; act_frac="0"; act_detail="couldn't read workflow runs"
  236. else
  237. act_concl="$(printf '%s' "$act_json" | jq -r '.workflow_runs[0].conclusion // "none"' | tr -d '\r')"
  238. case "$act_concl" in
  239. success) act_status="ok"; act_frac="1"; act_detail="latest run on $default_branch: success" ;;
  240. none|null|"") act_status="warn"; act_frac="0.5"; act_detail="no workflow runs on $default_branch" ;;
  241. failure|timed_out|startup_failure)
  242. act_status="gap"; act_frac="0"; act_detail="latest run on $default_branch: $act_concl" ;;
  243. *) act_status="warn"; act_frac="0.5"; act_detail="latest run on $default_branch: $act_concl" ;;
  244. esac
  245. fi
  246. # ---- Roll up the score -------------------------------------------------
  247. local score
  248. score="$(awk -v ws=$W_SECURITY -v wm=$W_METADATA -v wr=$W_RELEASE -v wi=$W_ISSUES -v wa=$W_ACTIONS \
  249. -v fs="$sec_frac" -v fm="$md_frac" -v fr="$rel_frac" -v fi="$iss_frac" -v fa="$act_frac" \
  250. 'BEGIN{ printf "%d", int(ws*fs + wm*fm + wr*fr + wi*fi + wa*fa + 0.5) }')"
  251. local grade
  252. if [ "$score" -ge 90 ]; then grade="A"
  253. elif [ "$score" -ge 75 ]; then grade="B"
  254. elif [ "$score" -ge 60 ]; then grade="C"
  255. elif [ "$score" -ge 40 ]; then grade="D"
  256. else grade="F"; fi
  257. # ---- Top 3 fixes (highest-severity gaps first) -------------------------
  258. # Build a ranked list. Each entry: severity-rank \t status \t text.
  259. # rank 0 highest. Only surface dimensions that are gap/warn/n/a.
  260. local fixes="[]"
  261. addfix() { # rank status text
  262. fixes="$(jq -c --argjson r "$1" --arg st "$2" --arg t "$3" \
  263. '. + [{rank:$r, status:$st, fix:$t}]' <<<"$fixes")"
  264. }
  265. # security first (highest weight). Map maxsev to a rank.
  266. if [ "$sec_status" = "gap" ]; then
  267. addfix 0 gap "security: $sec_detail ${TERM_ARROW} check-security-posture.sh --repo $R --commands"
  268. elif [ "$sec_status" = "warn" ]; then
  269. addfix 3 warn "security: $sec_detail ${TERM_ARROW} check-security-posture.sh --repo $R --commands"
  270. elif [ "$sec_status" = "n/a" ]; then
  271. addfix 5 "n/a" "security: couldn't read ${TERM_ARROW} re-run check-security-posture.sh --repo $R"
  272. fi
  273. if [ "$md_status" = "gap" ]; then
  274. addfix 1 gap "metadata: missing ${md_detail} ${TERM_ARROW} set description / >=3 topics / add the missing file(s)"
  275. elif [ "$md_status" = "warn" ]; then
  276. addfix 4 warn "metadata: missing ${md_detail} ${TERM_ARROW} set description / >=3 topics / add the missing file(s)"
  277. fi
  278. if [ "$rel_status" = "gap" ]; then
  279. addfix 2 gap "release: $rel_detail ${TERM_ARROW} cut a GitHub release (github-ops mode update)"
  280. elif [ "$rel_status" = "warn" ]; then
  281. addfix 4 warn "release: $rel_detail ${TERM_ARROW} gh release create $latest_tag"
  282. fi
  283. if [ "$iss_status" = "gap" ]; then
  284. addfix 2 gap "issues: $iss_detail ${TERM_ARROW} check-issues.sh --repo $R"
  285. elif [ "$iss_status" = "warn" ]; then
  286. addfix 5 warn "issues: $iss_detail ${TERM_ARROW} check-issues.sh --repo $R"
  287. fi
  288. if [ "$act_status" = "gap" ]; then
  289. addfix 1 gap "actions: $act_detail ${TERM_ARROW} inspect the failing run (gh run list --repo $R)"
  290. elif [ "$act_status" = "warn" ]; then
  291. addfix 5 warn "actions: $act_detail"
  292. fi
  293. local top_fixes
  294. top_fixes="$(printf '%s' "$fixes" | jq -c 'sort_by(.rank) | [ .[] | .fix ] | .[0:3]')"
  295. # ---- Assemble the per-repo object -------------------------------------
  296. jq -c -n \
  297. --arg repo "$R" --arg vis "$vis" --argjson score "$score" --arg grade "$grade" \
  298. --arg md_st "$md_status" --arg md_d "$md_detail" \
  299. --arg rel_st "$rel_status" --arg rel_d "$rel_detail" \
  300. --arg sec_st "$sec_status" --arg sec_d "$sec_detail" \
  301. --argjson sec_gaps "${sec_gaps:-0}" --argjson sec_alerts "${sec_alerts:-0}" --arg sec_mx "${sec_maxsev:-none}" \
  302. --arg iss_st "$iss_status" --arg iss_d "$iss_detail" --argjson iss_fl "${iss_flagged:-0}" \
  303. --arg act_st "$act_status" --arg act_d "$act_detail" \
  304. --argjson topf "$top_fixes" \
  305. '{repo:$repo, visibility:$vis, score:$score, grade:$grade,
  306. dimensions:{
  307. metadata:{status:$md_st, detail:$md_d},
  308. release:{status:$rel_st, detail:$rel_d},
  309. security:{status:$sec_st, detail:$sec_d, gaps:(if $sec_gaps<0 then null else $sec_gaps end), open_alerts:(if $sec_alerts<0 then null else $sec_alerts end), max_severity:$sec_mx},
  310. issues:{status:$iss_st, detail:$iss_d, flagged:(if $iss_fl<0 then null else $iss_fl end)},
  311. actions:{status:$act_st, detail:$act_d}
  312. },
  313. top_fixes:$topf}'
  314. # Per-repo exit: findings if any dimension is gap, n/a, or warn? We count
  315. # gap/n/a as findings (real problems). warn does not by itself trip exit 10
  316. # unless --min-score applies. (n/a is a finding — never a clean pass.)
  317. if [ "$md_status" = "gap" ] || [ "$rel_status" = "gap" ] || [ "$sec_status" = "gap" ] || \
  318. [ "$iss_status" = "gap" ] || [ "$act_status" = "gap" ] || \
  319. [ "$md_status" = "n/a" ] || [ "$rel_status" = "n/a" ] || [ "$sec_status" = "n/a" ] || \
  320. [ "$iss_status" = "n/a" ] || [ "$act_status" = "n/a" ]; then
  321. return 10
  322. fi
  323. return 0
  324. }
  325. # Colored, ASCII-aware status glyph for a dimension (human card).
  326. mark() { case "$1" in
  327. ok) term_mark ok;; warn) term_mark warn;; gap) term_mark bad;; "n/a") term_mark na;; *) term_mark unknown;; esac; }
  328. # Human single-repo card (data to stdout; framing to stderr).
  329. print_card() { # repo_json
  330. local o="$1" repo vis score grade
  331. repo="$(jq -r '.repo' <<<"$o")"; vis="$(jq -r '.visibility' <<<"$o")"
  332. score="$(jq -r '.score' <<<"$o")"; grade="$(jq -r '.grade' <<<"$o")"
  333. local health
  334. case "$grade" in
  335. A|B) health="$(term_health healthy "grade $grade")" ;;
  336. C|D) health="$(term_health warning "grade $grade")" ;;
  337. *) health="$(term_health critical "grade $grade")" ;;
  338. esac
  339. {
  340. term_panel_open github-ops "REPO SCORECARD" "$repo $vis"
  341. term_panel_vert
  342. term_panel_line "SCORE $(term_pip_bar score "$score" 100) $score/100 GRADE $grade"
  343. term_panel_vert
  344. term_section "" "dimensions (weight)" 5
  345. local d name w
  346. for d in "security:w35" "metadata:w25" "release:w15" "issues:w15" "actions:w10"; do
  347. name="${d%%:*}"; w="${d##*:}"
  348. term_panel_line "$(printf '%-9s %s %s' "$name" "$(mark "$(jq -r ".dimensions.$name.status" <<<"$o")")" "$(jq -r ".dimensions.$name.detail" <<<"$o") $(term_color dim "($w)")")"
  349. done
  350. local nf; nf="$(jq -r '.top_fixes | length' <<<"$o")"
  351. if [ "$nf" -gt 0 ]; then
  352. term_panel_vert
  353. term_section "" "top fixes (highest-severity first)" "$nf"
  354. while IFS= read -r ln; do term_panel_line "$ln"; done < <(jq -r --arg b "$(term_mark warn)" '.top_fixes[] | "\($b) " + .' <<<"$o")
  355. fi
  356. term_panel_vert
  357. term_panel_close "$(term_color dim "weighted: security 35 metadata 25 release 15 issues 15 actions 10")" "$health"
  358. } >&2
  359. }
  360. # ==========================================================================
  361. # Mode dispatch
  362. # ==========================================================================
  363. # Mutually exclusive selectors.
  364. sel=0
  365. [ -n "$REPO" ] && sel=$((sel+1))
  366. [ -n "$ORG" ] && sel=$((sel+1))
  367. if [ "$sel" -gt 1 ]; then
  368. echo "repo-scorecard: --repo and --org are mutually exclusive" >&2; exit "$EX_USAGE"
  369. fi
  370. # ---- Fleet sweep ----------------------------------------------------------
  371. if [ -n "$ORG" ]; then
  372. valid_owner "$ORG" || { echo "repo-scorecard: invalid owner '$ORG'" >&2; exit "$EX_USAGE"; }
  373. echo "$(term_color dim "repo-scorecard: sweeping $ORG …")" >&2
  374. list="$(runner gh repo list "$ORG" --no-archived --limit 200 --json nameWithOwner,visibility 2>/dev/null)" \
  375. || skip "gh repo list failed for $ORG (not authed / offline / rate-limited?)"
  376. [ -n "$list" ] || skip "no repos returned for $ORG"
  377. mapfile -t repos < <(printf '%s' "$list" | jq -r '.[].nameWithOwner' | tr -d '\r')
  378. [ "${#repos[@]}" -gt 0 ] || skip "no non-archived repos for $ORG"
  379. human=0; [ "$JSON" -eq 0 ] && human=1
  380. [ "$human" -eq 1 ] && { term_panel_open github-ops "REPO SCORECARD" "$ORG fleet sweep" >&2; term_panel_vert >&2; }
  381. all="[]"; any_findings=0; swept=0; unread=0; below_min=0
  382. for r in "${repos[@]}"; do
  383. valid_repo "$r" || continue
  384. obj="$(score_repo "$r")"; rc=$?
  385. if [ "$rc" -eq 7 ] || [ -z "$obj" ] || ! printf '%s' "$obj" | jq -e . >/dev/null 2>&1; then
  386. unread=$((unread+1))
  387. [ "$human" -eq 1 ] && term_panel_line "$(term_mark unknown) $r — couldn't read (skipped)" >&2
  388. continue
  389. fi
  390. swept=$((swept+1))
  391. [ "$rc" -eq 10 ] && any_findings=1
  392. all="$(jq -c --argjson o "$obj" '. + [$o]' <<<"$all")"
  393. sc="$(jq -r '.score' <<<"$obj")"
  394. if [ -n "$MIN_SCORE" ] && [ "$sc" -lt "$MIN_SCORE" ]; then below_min=$((below_min+1)); fi
  395. if [ "$human" -eq 1 ]; then
  396. # matrix row: per-dimension colored marks + score + grade.
  397. m() { case "$(jq -r ".dimensions.$1.status" <<<"$obj")" in
  398. ok) term_mark ok;; warn) term_mark warn;; gap) term_mark bad;; "n/a") term_mark na;; *) printf ' ';; esac; }
  399. term_panel_line "$(printf '%-34s S:%s M:%s R:%s I:%s A:%s %3s %s' \
  400. "$r" "$(m security)" "$(m metadata)" "$(m release)" "$(m issues)" "$(m actions)" \
  401. "$sc" "$(jq -r '.grade' <<<"$obj")")" >&2
  402. fi
  403. done
  404. # Roll-up stats. The repo array can be large (a big org), so pipe it via STDIN
  405. # rather than --argjson on argv — argv has a length cap and a fleet sweep blows
  406. # past it (observed: jq "Argument list too long" at ~70 repos on MSYS).
  407. rollup="$(printf '%s' "$all" | jq -c --arg org "$ORG" \
  408. --argjson swept "$swept" --argjson unread "$unread" \
  409. '. as $data
  410. | ($data | map(.score)) as $scores
  411. | ($scores | length) as $n
  412. | { org:$org, repos_scored:$swept, repos_unreadable:$unread,
  413. avg_score: (if $n>0 then (($scores|add)/$n|floor) else null end),
  414. median_score: (if $n>0 then ($scores|sort|.[($n/2|floor)]) else null end),
  415. total_open_alerts: ([ $data[].dimensions.security.open_alerts // 0 ] | add),
  416. failing_by_dimension: {
  417. security: ([ $data[]|select(.dimensions.security.status=="gap") ]|length),
  418. metadata: ([ $data[]|select(.dimensions.metadata.status=="gap") ]|length),
  419. release: ([ $data[]|select(.dimensions.release.status=="gap") ]|length),
  420. issues: ([ $data[]|select(.dimensions.issues.status=="gap") ]|length),
  421. actions: ([ $data[]|select(.dimensions.actions.status=="gap") ]|length)
  422. },
  423. worst: ([ $data[] | {repo, score, grade} ] | sort_by(.score) | .[0:3])
  424. }')"
  425. if [ "$JSON" -eq 1 ]; then
  426. # Pipe the (large) data array via stdin; $rollup is small enough for --argjson.
  427. printf '%s' "$all" | jq -c --argjson roll "$rollup" \
  428. --argjson find "$any_findings" --argjson below "$below_min" \
  429. --arg minscore "${MIN_SCORE:-}" \
  430. '{data:., meta:($roll + {findings:($find==1 or $below>0), below_min:$below,
  431. min_score:(if $minscore=="" then null else ($minscore|tonumber) end),
  432. schema:"claude-mods.github-ops.repo-scorecard/v1"})}'
  433. else
  434. if [ "$any_findings" -eq 1 ] || [ "$below_min" -gt 0 ]; then health_roll="$(term_health warning "$swept scored")"
  435. else health_roll="$(term_health healthy "$swept scored")"; fi
  436. {
  437. term_panel_vert
  438. term_section "" "roll-up: $ORG" "$swept"
  439. while IFS= read -r ln; do term_panel_line "$ln"; done < <(printf '%s' "$rollup" | jq -r '
  440. "scored: \(.repos_scored) unreadable: \(.repos_unreadable)",
  441. "avg score: \(.avg_score) median: \(.median_score)",
  442. "total open security alerts (fleet): \(.total_open_alerts)",
  443. "gaps by dimension security:\(.failing_by_dimension.security) metadata:\(.failing_by_dimension.metadata) release:\(.failing_by_dimension.release) issues:\(.failing_by_dimension.issues) actions:\(.failing_by_dimension.actions)",
  444. "worst: " + ([ .worst[] | "\(.repo) (\(.score)/\(.grade))" ] | join(", "))')
  445. [ -n "$MIN_SCORE" ] && term_panel_line "below --min-score $MIN_SCORE: $below_min repo(s)"
  446. term_panel_line "$(term_color dim "legend:") $(term_mark ok) ok $(term_mark warn) warn $(term_mark bad) gap $(term_mark na) n/a $(term_color dim "S M R I A = the five dimensions")"
  447. term_panel_vert
  448. term_panel_close "" "$health_roll"
  449. } >&2
  450. fi
  451. { [ "$any_findings" -eq 1 ] || [ "$below_min" -gt 0 ]; } && exit "$EX_FINDINGS"
  452. exit "$EX_OK"
  453. fi
  454. # ---- Single repo ----------------------------------------------------------
  455. if [ -z "$REPO" ]; then
  456. url="$(git remote get-url "$REMOTE" 2>/dev/null)" || skip "no '$REMOTE' remote here"
  457. case "$url" in
  458. *github.com[:/]*)
  459. REPO="$(printf '%s' "$url" | tr -d '\r' | sed -E 's#^.*github\.com[:/]+##; s#\.git$##; s#/$##')" ;;
  460. *) skip "remote '$REMOTE' is not a github.com repo" ;;
  461. esac
  462. fi
  463. valid_repo "$REPO" || { echo "repo-scorecard: invalid OWNER/REPO '$REPO'" >&2; exit "$EX_USAGE"; }
  464. obj="$(score_repo "$REPO")"; rc=$?
  465. if [ "$rc" -eq 7 ] || [ -z "$obj" ] || ! printf '%s' "$obj" | jq -e . >/dev/null 2>&1; then
  466. skip "couldn't read $REPO (not authed / offline / not found / timeout)"
  467. fi
  468. score="$(jq -r '.score' <<<"$obj")"
  469. below=0
  470. if [ -n "$MIN_SCORE" ] && [ "$score" -lt "$MIN_SCORE" ]; then below=1; fi
  471. if [ "$JSON" -eq 1 ]; then
  472. jq -c --argjson find "$( [ "$rc" -eq 10 ] && echo 1 || echo 0 )" \
  473. --argjson below "$below" --arg minscore "${MIN_SCORE:-}" \
  474. '{data:[.], meta:{repo:.repo, visibility:.visibility, score:.score, grade:.grade,
  475. findings:($find==1 or $below>0), below_min:$below,
  476. min_score:(if $minscore=="" then null else ($minscore|tonumber) end),
  477. schema:"claude-mods.github-ops.repo-scorecard/v1"}}' <<<"$obj"
  478. else
  479. print_card "$obj"
  480. fi
  481. { [ "$rc" -eq 10 ] || [ "$below" -eq 1 ]; } && exit "$EX_FINDINGS"
  482. exit "$EX_OK"