From 85c102918c2f51a1ed00f7cc5140bad17f0ee8f4 Mon Sep 17 00:00:00 2001 From: Sebastian Graef Date: Thu, 30 Apr 2026 11:05:03 +1000 Subject: [PATCH] Add Bash and PowerShell scripts for monitoring GitHub API rate limits --- _in progress/gh-ratelimit | 184 ++++++++++++++++++++++ _in progress/gh-ratelimit.ps1 | 289 ++++++++++++++++++++++++++++++++++ 2 files changed, 473 insertions(+) create mode 100644 _in progress/gh-ratelimit create mode 100644 _in progress/gh-ratelimit.ps1 diff --git a/_in progress/gh-ratelimit b/_in progress/gh-ratelimit new file mode 100644 index 0000000..a53132c --- /dev/null +++ b/_in progress/gh-ratelimit @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# gh-ratelimit - GitHub API rate-limit monitor for Bash and POSIX-style shells. +# +# What it does: +# Queries GitHub's /rate_limit endpoint through GitHub CLI and renders the +# current API buckets sorted by pressure so the tightest buckets appear first. +# +# Requirements: +# - gh (GitHub CLI) +# - jq +# - an authenticated gh session +# +# Setup: +# macOS: brew install gh jq +# Ubuntu: sudo apt-get install gh jq +# Then authenticate once: +# gh auth login +# +# Notes: +# - This script uses gh for both API access and auth context. +# - Bash version requires jq for parsing and pretty-printing JSON. +# - GitHub's /rate_limit endpoint is exempt from rate limiting, so polling it is safe. +# +# Usage: +# gh-ratelimit +# gh-ratelimit -w +# gh-ratelimit -w -i 5 +# gh-ratelimit -j +# gh-ratelimit -q +# gh-ratelimit -h +# +# Options: +# -w, --watch Refresh continuously. +# -i, --interval Watch refresh interval in seconds. Default: 10. +# -j, --json Print the raw GitHub API response as formatted JSON. +# -q, --quiet Only show buckets that are not full and have a non-zero limit. +# -h, --help Show this help text. +# +# Examples: +# gh-ratelimit # one-time snapshot +# gh-ratelimit -q # only show buckets under pressure +# gh-ratelimit -w -i 60 # refresh every 60 seconds +# gh-ratelimit -j | jq . # inspect raw payload + +set -euo pipefail + +WATCH=0 +INTERVAL=10 +JSON=0 +QUIET=0 + +show_usage() { + awk 'NR == 1 { next } /^#/ { sub(/^# ?/, ""); print; next } { exit }' "$0" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -w|--watch) WATCH=1 ;; + -i|--interval) INTERVAL="${2:-10}"; shift ;; + -j|--json) JSON=1 ;; + -q|--quiet) QUIET=1 ;; + -h|--help) + show_usage + exit 0 + ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac + shift +done + +command -v gh >/dev/null || { echo "gh not installed" >&2; exit 1; } +command -v jq >/dev/null || { echo "jq not installed" >&2; exit 1; } + +if ! gh auth status >/dev/null 2>&1; then + echo "gh is installed but not authenticated. Run: gh auth login" >&2 + exit 1 +fi + +# ANSI colours (skip if not a TTY) +if [[ -t 1 ]]; then + C_RESET=$'\033[0m'; C_DIM=$'\033[2m'; C_BOLD=$'\033[1m' + C_RED=$'\033[31m'; C_YEL=$'\033[33m'; C_GRN=$'\033[32m'; C_CYN=$'\033[36m' +else + C_RESET=; C_DIM=; C_BOLD=; C_RED=; C_YEL=; C_GRN=; C_CYN= +fi + +bar() { + # bar + local rem="$1" lim="$2" width="${3:-24}" + if (( lim <= 0 )); then printf '%*s' "$width" ''; return; fi + local filled=$(( rem * width / lim )) + (( filled < 0 )) && filled=0 + (( filled > width )) && filled=$width + local empty=$(( width - filled )) + local colour="$C_GRN" + local pct=$(( rem * 100 / lim )) + if (( pct < 10 )); then colour="$C_RED" + elif (( pct < 33 )); then colour="$C_YEL" + fi + printf '%s' "$colour" + if (( filled > 0 )); then + printf '█%.0s' $(seq 1 "$filled") + fi + printf '%s' "$C_DIM" + if (( empty > 0 )); then + printf '░%.0s' $(seq 1 "$empty") + fi + printf '%s' "$C_RESET" +} + +human_reset() { + # Seconds-from-now → e.g. "in 31m12s" or "passed" + local target="$1" now diff m s + now=$(date +%s) + diff=$(( target - now )) + if (( diff <= 0 )); then echo "now"; return; fi + m=$(( diff / 60 )); s=$(( diff % 60 )) + if (( m > 0 )); then printf 'in %dm%02ds' "$m" "$s" + else printf 'in %ds' "$s" + fi +} + +render() { + local payload + if ! payload=$(gh api rate_limit 2>/dev/null); then + echo "${C_RED}gh api failed - are you authenticated? (gh auth status)${C_RESET}" >&2 + return 1 + fi + + if (( JSON )); then + echo "$payload" | jq . + return 0 + fi + + # Header + local user host now + user=$(gh api user --jq .login 2>/dev/null || echo '?') + host=$(gh auth status 2>&1 | awk -F'[ ()]+' '/Logged in to/{print $5; exit}' || echo 'github.com') + now=$(date '+%H:%M:%S') + printf '%s%sGitHub rate limits%s user=%s%s%s host=%s %s%s%s\n' \ + "$C_BOLD" "$C_CYN" "$C_RESET" "$C_BOLD" "$user" "$C_RESET" "$host" "$C_DIM" "$now" "$C_RESET" + echo + + # Sort: most-pressured (lowest %) first + local jq_filter=' + .resources + | to_entries + | map({ + key: .key, + rem: .value.remaining, + lim: .value.limit, + used: .value.used, + reset: .value.reset, + pct: (if .value.limit > 0 then (.value.remaining * 100 / .value.limit) else 100 end) + }) + | sort_by(.pct) + | .[] + | [.key, (.rem|tostring), (.lim|tostring), (.used|tostring), (.reset|tostring), ((.pct|floor)|tostring)] + | @tsv' + + local key rem lim used reset pct + while IFS=$'\t' read -r key rem lim used reset pct; do + if (( QUIET )) && (( rem == lim || lim == 0 )); then continue; fi + printf '%s%-26s%s %5d/%-5d %s %3d%% resets %s\n' \ + "$C_BOLD" "$key" "$C_RESET" \ + "$rem" "$lim" \ + "$(bar "$rem" "$lim" 24)" \ + "$pct" \ + "$(human_reset "$reset")" + done < <(echo "$payload" | jq -r "$jq_filter") +} + +if (( WATCH )); then + trap 'tput cnorm 2>/dev/null; exit 0' INT TERM + tput civis 2>/dev/null || true + while true; do + clear + render || true + printf '\n%srefresh every %ss - ctrl-c to exit%s\n' "$C_DIM" "$INTERVAL" "$C_RESET" + sleep "$INTERVAL" + done +else + render +fi diff --git a/_in progress/gh-ratelimit.ps1 b/_in progress/gh-ratelimit.ps1 new file mode 100644 index 0000000..f120758 --- /dev/null +++ b/_in progress/gh-ratelimit.ps1 @@ -0,0 +1,289 @@ +#!/usr/bin/env pwsh + +<# +.SYNOPSIS +Displays GitHub API rate-limit buckets using GitHub CLI. + +.DESCRIPTION +Queries GitHub's /rate_limit endpoint through GitHub CLI and renders the +response as either formatted JSON or a terminal-friendly table sorted by the +most constrained buckets first. + +This PowerShell version only requires GitHub CLI. Unlike the Bash version, it +does not require jq because JSON parsing is handled natively by PowerShell. + +.REQUIREMENTS +- GitHub CLI: gh +- An authenticated gh session + +.SETUP +Install GitHub CLI, then authenticate once: + + gh auth login + +Examples: +- macOS: brew install gh +- Windows: winget install --id GitHub.cli +- Linux: see https://cli.github.com/ for distro-specific packages + +This script polls GitHub's /rate_limit endpoint, which is exempt from rate +limiting, so watch mode is safe to use. + +.PARAMETER Watch +Refresh continuously until interrupted. + +.PARAMETER Interval +Refresh interval in seconds for watch mode. Default is 10. + +.PARAMETER Json +Print the raw API response as formatted JSON. + +.PARAMETER Quiet +Only show buckets whose remaining value is below the limit and whose limit is +greater than zero. + +.PARAMETER Help +Show usage information. + +.EXAMPLE +pwsh -File ./gh-ratelimit.ps1 + +.EXAMPLE +pwsh -File ./gh-ratelimit.ps1 -Watch -Interval 60 + +.EXAMPLE +pwsh -File ./gh-ratelimit.ps1 -Quiet + +.EXAMPLE +pwsh -File ./gh-ratelimit.ps1 -Json +#> + +param( + [Alias('w')] + [switch]$Watch, + + [Alias('i')] + [ValidateRange(1, 86400)] + [int]$Interval = 10, + + [Alias('j')] + [switch]$Json, + + [Alias('q')] + [switch]$Quiet, + + [Alias('h')] + [switch]$Help +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Show-Usage { + @' +gh-ratelimit - GitHub API rate-limit monitor + +Usage: + gh-ratelimit.ps1 # snapshot + gh-ratelimit.ps1 -Watch # watch mode (refresh every 10s) + gh-ratelimit.ps1 -Watch -Interval 5 + gh-ratelimit.ps1 -Json # raw JSON + gh-ratelimit.ps1 -Quiet # only buckets with limit > 0 and remaining < limit + gh-ratelimit.ps1 -Help + +Short flags: + -w -i 5 -j -q -h + +Requirements: + - gh (GitHub CLI) + - an authenticated gh session + +Setup: + 1. Install GitHub CLI. + macOS: brew install gh + Windows: winget install --id GitHub.cli + Linux: https://cli.github.com/ + 2. Authenticate once: + gh auth login + +Notes: + - This PowerShell version does not require jq. + - It uses gh for API calls and auth context. + - /rate_limit itself is exempt from rate limiting, so polling it is safe. + +Examples: + gh-ratelimit.ps1 + gh-ratelimit.ps1 -Quiet + gh-ratelimit.ps1 -Watch -Interval 60 + gh-ratelimit.ps1 -Json +'@ +} + +function Test-CommandExists { + param([Parameter(Mandatory = $true)][string]$Name) + + return $null -ne (Get-Command -Name $Name -ErrorAction SilentlyContinue) +} + +function Get-Style { + param([Parameter(Mandatory = $true)][string]$Code) + + if ([Console]::IsOutputRedirected -or $env:TERM -eq 'dumb') { + return '' + } + + return [char]27 + '[' + $Code + 'm' +} + +$C_RESET = Get-Style '0' +$C_DIM = Get-Style '2' +$C_BOLD = Get-Style '1' +$C_RED = Get-Style '31' +$C_YEL = Get-Style '33' +$C_GRN = Get-Style '32' +$C_CYN = Get-Style '36' + +function Get-Bar { + param( + [Parameter(Mandatory = $true)][int]$Remaining, + [Parameter(Mandatory = $true)][int]$Limit, + [int]$Width = 24 + ) + + if ($Limit -le 0) { + return ' ' * $Width + } + + $filled = [math]::Floor(($Remaining * $Width) / $Limit) + if ($filled -lt 0) { $filled = 0 } + if ($filled -gt $Width) { $filled = $Width } + $empty = $Width - $filled + $pct = [math]::Floor(($Remaining * 100) / $Limit) + + $colour = $C_GRN + if ($pct -lt 10) { + $colour = $C_RED + } elseif ($pct -lt 33) { + $colour = $C_YEL + } + + $filledText = if ($filled -gt 0) { '█' * $filled } else { '' } + $emptyText = if ($empty -gt 0) { '░' * $empty } else { '' } + + return "$colour$filledText$C_DIM$emptyText$C_RESET" +} + +function Get-HumanReset { + param([Parameter(Mandatory = $true)][long]$Target) + + $now = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + $diff = $Target - $now + if ($diff -le 0) { + return 'now' + } + + $minutes = [math]::Floor($diff / 60) + $seconds = $diff % 60 + if ($minutes -gt 0) { + return ('in {0}m{1:00}s' -f $minutes, $seconds) + } + + return ('in {0}s' -f $seconds) +} + +function Get-HostName { + try { + $status = gh auth status 2>&1 | Out-String + $match = [regex]::Match($status, 'Logged in to\s+([^\s]+)') + if ($match.Success) { + return $match.Groups[1].Value + } + } catch { + } + + return 'github.com' +} + +function Render { + $payloadText = gh api rate_limit 2>$null + if (-not $payloadText) { + throw 'gh api failed - are you authenticated? (gh auth status)' + } + + if ($Json) { + $payloadText | ConvertFrom-Json | ConvertTo-Json -Depth 8 + return + } + + $payload = $payloadText | ConvertFrom-Json + + $user = '?' + try { + $user = (gh api user --jq .login 2>$null).Trim() + if (-not $user) { + $user = '?' + } + } catch { + } + + $hostName = Get-HostName + $now = Get-Date -Format 'HH:mm:ss' + Write-Output ("{0}{1}GitHub rate limits{2} user={3}{4}{5} host={6} {7}{8}{2}" -f $C_BOLD, $C_CYN, $C_RESET, $C_BOLD, $user, $C_RESET, $hostName, $C_DIM, $now) + Write-Output '' + + $entries = foreach ($property in $payload.resources.PSObject.Properties) { + $value = $property.Value + $pct = if ($value.limit -gt 0) { [math]::Floor(($value.remaining * 100) / $value.limit) } else { 100 } + [pscustomobject]@{ + Key = $property.Name + Remaining = [int]$value.remaining + Limit = [int]$value.limit + Used = [int]$value.used + Reset = [long]$value.reset + Pct = [int]$pct + } + } + + foreach ($entry in ($entries | Sort-Object Pct, Key)) { + if ($Quiet -and (($entry.Remaining -eq $entry.Limit) -or ($entry.Limit -eq 0))) { + continue + } + + $line = "{0}{1,-26}{2} {3,5}/{4,-5} {5} {6,3}% resets {7}" -f ` + $C_BOLD, $entry.Key, $C_RESET, $entry.Remaining, $entry.Limit, (Get-Bar -Remaining $entry.Remaining -Limit $entry.Limit -Width 24), $entry.Pct, (Get-HumanReset -Target $entry.Reset) + Write-Output $line + } +} + +if ($Help) { + Show-Usage + exit 0 +} + +if (-not (Test-CommandExists -Name 'gh')) { + Write-Error 'gh not installed' + exit 1 +} + +try { + gh auth status *> $null +} catch { + Write-Error 'gh is installed but not authenticated. Run: gh auth login' + exit 1 +} + +if ($Watch) { + while ($true) { + Clear-Host + try { + Render + } catch { + Write-Error $_.Exception.Message + } + Write-Output '' + Write-Output ("{0}refresh every {1}s - ctrl-c to exit{2}" -f $C_DIM, $Interval, $C_RESET) + Start-Sleep -Seconds $Interval + } +} else { + Render +}