check-issues.sh 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
  1. #!/usr/bin/env bash
  2. # Surface open GitHub issues you may not have seen — externally-authored and stale.
  3. #
  4. # Read-only (gh issue list). Built to flag the blind spot: issues filed by someone
  5. # other than the repo owner, and issues left untouched for a while. Designed to run
  6. # advisory at push-time without ever gating the push.
  7. #
  8. # Usage: check-issues.sh [--repo OWNER/REPO | --remote NAME] [--stale-days N]
  9. # [--limit N] [--advisory] [--json] [-h|--help]
  10. # Input: argv only. Default repo = derived from the 'origin' remote of the cwd.
  11. # Output: stdout = data (human summary, or --json envelope). Framing on stderr.
  12. # Stderr: headers, the advisory banner, skip notices, errors.
  13. # Exit: 0 nothing you're missing (no open issues, or all are yours and fresh)
  14. # 2 usage
  15. # 5 gh not installed (standalone mode; --advisory downgrades this to a skip)
  16. # 7 unavailable — not a GitHub remote, gh not authed, offline, rate-limited,
  17. # or the lookup timed out (ADVISORY signal; never a real failure)
  18. # 10 open external and/or stale issues present (the thing to look at)
  19. #
  20. # Examples:
  21. # check-issues.sh # origin of the cwd
  22. # check-issues.sh --repo 0xDarkMatter/flarecrawl
  23. # check-issues.sh --remote origin --stale-days 14
  24. # check-issues.sh --json | jq '.data[] | select(.external)'
  25. # check-issues.sh --advisory --remote origin # compact, silent when clean
  26. set -uo pipefail
  27. EX_OK=0; EX_USAGE=2; EX_MISSING_DEP=5; EX_UNAVAILABLE=7; EX_FINDINGS=10
  28. GH_TIMEOUT="${GH_TIMEOUT:-15}" # seconds; bounds the network call
  29. # Terminal design system (skills/_lib/term.sh). Framing prints to stderr, so detect
  30. # color on fd 2. Degrade to plain output if the shared lib isn't reachable.
  31. __lib="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../_lib" 2>/dev/null && pwd || true)"
  32. if [ -n "${__lib:-}" ] && [ -f "$__lib/term.sh" ]; then . "$__lib/term.sh"; term_init 2
  33. else
  34. term_header() { printf '%s\n' "${1:-}"; }
  35. term_color() { shift; printf '%s' "$*"; }
  36. term_mark() { case "${1:-}" in ok) printf '+';; bad|gap) printf 'x';; warn) printf '!';; skip|na) printf '-';; unknown) printf '?';; *) printf '.';; esac; }
  37. TERM_ARROW="->"
  38. fi
  39. REPO=""; REMOTE="origin"; STALE_DAYS=30; LIMIT=50; ADVISORY=0; JSON=0
  40. while [ $# -gt 0 ]; do
  41. case "$1" in
  42. --repo) REPO="${2:?--repo needs OWNER/REPO}"; shift 2 ;;
  43. --remote) REMOTE="${2:?--remote needs a name}"; shift 2 ;;
  44. --stale-days) STALE_DAYS="${2:?--stale-days needs N}"; shift 2 ;;
  45. --limit) LIMIT="${2:?--limit needs N}"; shift 2 ;;
  46. --advisory) ADVISORY=1; shift ;;
  47. --json) JSON=1; shift ;;
  48. -h|--help) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit "$EX_OK" ;;
  49. *) echo "check-issues: unknown argument: $1" >&2; exit "$EX_USAGE" ;;
  50. esac
  51. done
  52. # In advisory mode, ANY inability to check is a silent skip (never disturb a push).
  53. skip() { # message
  54. [ "$ADVISORY" -eq 1 ] || echo "check-issues: $1" >&2
  55. exit "$EX_UNAVAILABLE"
  56. }
  57. command -v gh >/dev/null 2>&1 || {
  58. [ "$ADVISORY" -eq 1 ] && exit "$EX_UNAVAILABLE"
  59. echo "check-issues: gh not installed (https://cli.github.com)" >&2
  60. exit "$EX_MISSING_DEP"
  61. }
  62. # Resolve OWNER/REPO from the remote if not given explicitly.
  63. if [ -z "$REPO" ]; then
  64. url="$(git remote get-url "$REMOTE" 2>/dev/null)" || skip "no '$REMOTE' remote here"
  65. case "$url" in
  66. *github.com[:/]*)
  67. # strip everything up to github.com<sep>, then a trailing .git and/or slash
  68. REPO="$(printf '%s' "$url" | sed -E 's#^.*github\.com[:/]+##; s#\.git$##; s#/$##')" ;;
  69. *) skip "remote '$REMOTE' is not a github.com repo" ;;
  70. esac
  71. fi
  72. OWNER="${REPO%%/*}"
  73. # Bounded, read-only lookup. Any failure (auth/offline/rate-limit/timeout) -> skip/7.
  74. runner() { if command -v timeout >/dev/null 2>&1; then timeout "$GH_TIMEOUT" "$@"; else "$@"; fi; }
  75. raw="$(runner gh issue list --repo "$REPO" --state open --limit "$LIMIT" \
  76. --json number,title,author,createdAt,updatedAt,labels 2>/dev/null)" \
  77. || skip "gh issue list failed for $REPO (not authed / offline / rate-limited?)"
  78. [ -n "$raw" ] || skip "empty response from gh for $REPO"
  79. # Classify with jq: external = author.login != owner; stale = updatedAt older than N days.
  80. command -v jq >/dev/null 2>&1 || skip "jq not installed"
  81. analysis="$(printf '%s' "$raw" | jq -c --arg owner "$OWNER" --argjson stale "$STALE_DAYS" '
  82. (now - ($stale * 86400)) as $cutoff
  83. | map(. + {
  84. external: (.author.login != $owner),
  85. stale: ((.updatedAt | sub("\\.[0-9]+";"") | strptime("%Y-%m-%dT%H:%M:%SZ") | mktime) < $cutoff)
  86. })
  87. | { total: length,
  88. flagged: map(select(.external or .stale)),
  89. }' 2>/dev/null)" || skip "could not parse gh output"
  90. total="$(printf '%s' "$analysis" | jq -r '.total')"
  91. flagged_n="$(printf '%s' "$analysis" | jq -r '.flagged | length')"
  92. if [ "$JSON" -eq 1 ]; then
  93. printf '%s' "$analysis" | jq -c --arg repo "$REPO" \
  94. '{data: .flagged, meta: {repo: $repo, total_open: .total, flagged: (.flagged|length), schema: "claude-mods.github-ops.check-issues/v1"}}'
  95. fi
  96. # Human / advisory output (stderr framing; the data above is the stdout product).
  97. if [ "$flagged_n" -eq 0 ]; then
  98. [ "$ADVISORY" -eq 1 ] || echo "check-issues: $REPO — $total open, none external or stale." >&2
  99. exit "$EX_OK"
  100. fi
  101. {
  102. term_header "OPEN ISSUES: $REPO" "$flagged_n of $total open flagged"
  103. printf '%s' "$analysis" | jq -r --arg m "$(term_mark warn)" '.flagged[]
  104. | " \($m) #\(.number) [\(if .external then "external" else "yours" end)\(if .stale then ",stale" else "" end)] by \(.author.login) \(.title)"'
  105. echo "$(term_color dim " ${TERM_ARROW} gh issue view <n> --repo $REPO (read-only; this never blocks a push)")"
  106. } >&2
  107. exit "$EX_FINDINGS"