Skip to content

Instantly share code, notes, and snippets.

@stuft2
Last active August 15, 2025 18:35
Show Gist options
  • Select an option

  • Save stuft2/6e6badc793b2b19444321991c435d524 to your computer and use it in GitHub Desktop.

Select an option

Save stuft2/6e6badc793b2b19444321991c435d524 to your computer and use it in GitHub Desktop.
Nuke all GitHub Notification Subscriptions

Prerequisites

  1. Must run in posix terminal
  2. Install gh-cli
  3. Install jq

Setup

Run gh auth login first, if needed. This implicitly uses your cached token; private repos require a token with repo.

Copy the script to a file and then change permissions of the file to executable.

chmod +x ghunsub.sh

How to use

# Preview what it will do (no changes), verbose log written alongside:
./ghunsub.sh --dry-run

# Do the thing, sleeping 0.2s between each DELETE to be gentle on rate limits:
./ghunsub.sh --sleep 0.2

# Only unsubscribe threads (keep repo watches):
./ghunsub.sh --only-threads

# Only unwatch repos:
./ghunsub.sh --only-repos

# Tweak retries and log file path:
./ghunsub.sh --retries 6 --log my-run.log

Note

  • If you hit abuse/secondary rate limits, the script will automatically retry with exponential backoff (2s → 4s → 8s … capped at 32s).
  • The log includes every action and any stderr from gh so you can see why something failed.
  • --sleep is optional; it can help on very large accounts.

Script

#!/usr/bin/env bash
set -euo pipefail

# --- Config defaults (override via flags) ---
DRY_RUN=0
SLEEP_SECS=0
ONLY_THREADS=0
ONLY_REPOS=0
MAX_RETRIES=5
LOG_FILE="gh-unsub-$(date +%Y%m%d-%H%M%S).log"

# --- Logging helpers ---
log() { printf '[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" | tee -a "$LOG_FILE" >&2; }
log_ok() { log "$*"; }
log_err() { log "$*"; }
log_info() { log "ℹ️  $*"; }

usage() {
  cat <<'USAGE'
Unsubscribe from ALL GitHub notification threads and unwatch ALL repos.

Usage:
  gh-unsub-all.sh [options]

Options:
  -n, --dry-run          Show what would happen; do not make changes
  -s, --sleep SECS       Sleep between DELETE calls (default: 0)
  -t, --only-threads     Only unsubscribe threads (skip repos)
  -r, --only-repos       Only unwatch repos (skip threads)
  -R, --retries N        Max retries for failed calls (default: 5)
  -l, --log FILE         Log file path (default: auto-named)
  -h, --help             Show this help

Requires:
  - GitHub CLI: gh
  - jq

Scopes:
  - Threads: notifications
  - Repos (if any private watched): repo
USAGE
}

# --- Parse args ---
while [[ $# -gt 0 ]]; do
  case "$1" in
    -n|--dry-run) DRY_RUN=1; shift ;;
    -s|--sleep) SLEEP_SECS="${2:-}"; shift 2 ;;
    -t|--only-threads) ONLY_THREADS=1; shift ;;
    -r|--only-repos) ONLY_REPOS=1; shift ;;
    -R|--retries) MAX_RETRIES="${2:-}"; shift 2 ;;
    -l|--log) LOG_FILE="${2:-}"; shift 2 ;;
    -h|--help) usage; exit 0 ;;
    *) log_err "Unknown arg: $1"; usage; exit 1 ;;
  esac
done

# --- Pre-flight checks ---
command -v gh >/dev/null 2>&1 || { log_err "gh not found. Install GitHub CLI."; exit 1; }
command -v jq >/dev/null 2>&1 || { log_err "jq not found. Install jq."; exit 1; }
gh auth status >/dev/null 2>&1 || { log_err "gh is not authenticated. Run: gh auth login"; exit 1; }

log_info "Logging to: $LOG_FILE"
log_info "Dry run: $DRY_RUN, Sleep: ${SLEEP_SECS}s, Retries: $MAX_RETRIES"
if [[ "$ONLY_THREADS" -eq 1 && "$ONLY_REPOS" -eq 1 ]]; then
  log_err "Cannot use --only-threads and --only-repos together."; exit 1
fi

# --- Retry wrapper for gh api ---
gh_api_delete() {
  local endpoint="$1"
  local attempt=1
  local backoff=2
  while :; do
    if [[ "$DRY_RUN" -eq 1 ]]; then
      log_info "[DRY-RUN] DELETE $endpoint"
      return 0
    fi

    # gh returns non-zero on HTTP error; capture stderr for logs
    if gh api -X DELETE -H "Accept: application/vnd.github+json" "$endpoint" >/dev/null 2>>"$LOG_FILE"; then
      return 0
    fi

    if (( attempt >= MAX_RETRIES )); then
      log_err "Failed DELETE $endpoint after $MAX_RETRIES attempts."
      return 1
    fi

    log_info "Retry $attempt/$MAX_RETRIES for $endpoint … sleeping ${backoff}s"
    sleep "$backoff"
    attempt=$((attempt+1))
    backoff=$((backoff*2))
    # Cap the backoff to something sane (e.g., 32s)
    if (( backoff > 32 )); then backoff=32; fi
  done
}

# --- Unsubscribe from all notification threads (POSIX) ---
unsubscribe_threads() {
  local processed=0 ok=0 fail=0
  log_info "Fetching notification threads (including read)…"

  local ids_tmp
  ids_tmp="$(mktemp)" || { log_err "mktemp failed"; return 1; }
  # shellcheck disable=SC2069  # we want stderr into the log file only
  if ! gh api -H "Accept: application/vnd.github+json" "/notifications?all=true" --paginate \
        | jq -r '.[]? | .id' >"$ids_tmp" 2>>"$LOG_FILE"; then
    log_err "Failed to list notifications."
    rm -f "$ids_tmp"
    return 1
  fi

  local total
  total=$(wc -l <"$ids_tmp" | tr -d ' ') || total=0
  if [ "$total" -eq 0 ]; then
    log_ok "No notification threads found. Nothing to unsubscribe."
    rm -f "$ids_tmp"
    return 0
  fi

  while IFS= read -r tid; do
    [ -z "$tid" ] && continue
    processed=$((processed+1))
    if gh_api_delete "/notifications/threads/${tid}/subscription"; then
      ok=$((ok+1))
      log_ok "Unsubscribed thread ${tid} (${processed}/${total})"
    else
      fail=$((fail+1))
      log_err "Failed to unsubscribe thread ${tid} (${processed}/${total}; failures: ${fail})"
    fi
    [ "$SLEEP_SECS" -gt 0 ] && sleep "$SLEEP_SECS"
  done <"$ids_tmp"

  rm -f "$ids_tmp"
  log_info "Threads processed: total=${total}, ok=${ok}, failed=${fail}"
  [ "$fail" -eq 0 ]
}

# --- Unwatch all repos (POSIX) ---
unwatch_repos() {
  local processed=0 ok=0 fail=0
  log_info "Fetching watched repositories…"

  local repos_tmp
  repos_tmp="$(mktemp)" || { log_err "mktemp failed"; return 1; }
  if ! gh api -H "Accept: application/vnd.github+json" "/user/subscriptions" --paginate \
        | jq -r '.[]? | .full_name' >"$repos_tmp" 2>>"$LOG_FILE"; then
    log_err "Failed to list watched repositories."
    rm -f "$repos_tmp"
    return 1
  fi

  local total
  total=$(wc -l <"$repos_tmp" | tr -d ' ') || total=0
  if [ "$total" -eq 0 ]; then
    log_ok "No watched repositories found. Nothing to unwatch."
    rm -f "$repos_tmp"
    return 0
  fi

  while IFS= read -r full; do
    [ -z "$full" ] && continue
    processed=$((processed+1))
    if gh_api_delete "/repos/${full}/subscription"; then
      ok=$((ok+1))
      log_ok "Unwatched ${full} (${processed}/${total})"
    else
      fail=$((fail+1))
      log_err "Failed to unwatch ${full} (${processed}/${total}; failures: ${fail})"
    fi
    [ "$SLEEP_SECS" -gt 0 ] && sleep "$SLEEP_SECS"
  done <"$repos_tmp"

  rm -f "$repos_tmp"
  log_info "Repos processed: total=${total}, ok=${ok}, failed=${fail}"
  [ "$fail" -eq 0 ]
}

# --- Orchestration ---
overall_rc=0

if [[ "$ONLY_REPOS" -ne 1 ]]; then
  if ! unsubscribe_threads; then overall_rc=1; fi
fi

if [[ "$ONLY_THREADS" -ne 1 ]]; then
  if ! unwatch_repos; then overall_rc=1; fi
fi

if [[ "$DRY_RUN" -eq 1 ]]; then
  log_ok "DRY-RUN complete. Review $LOG_FILE for the planned actions."
else
  if [[ "$overall_rc" -eq 0 ]]; then
    log_ok "All done without errors. See log: $LOG_FILE"
  else
    log_err "Completed with some errors. See log: $LOG_FILE"
  fi
fi

exit "$overall_rc"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment