Skip to content

Instantly share code, notes, and snippets.

@cderv
Last active June 1, 2026 11:46
Show Gist options
  • Select an option

  • Save cderv/592e06ccb3d6d260f165290de647cc99 to your computer and use it in GitHub Desktop.

Select an option

Save cderv/592e06ccb3d6d260f165290de647cc99 to your computer and use it in GitHub Desktop.
gh-subscribe — subscribe/list/unsubscribe GitHub issue notifications via GraphQL updateSubscription

gh-subscribe

Subscribe / unsubscribe / list notification state for GitHub issues (and PRs) from the command line.

GitHub's REST API can't subscribe you to an arbitrary issue — only the GraphQL updateSubscription mutation can (an Issue is a Subscribable). This is a thin, portable wrapper around it. Subscriptions are private (per-user, invisible to others). Handy for following upstream bug reports across the tools you depend on.

Install

# 1. Clone this gist (any machine with gh)
gh gist clone 592e06ccb3d6d260f165290de647cc99 ~/gh-subscribe
chmod +x ~/gh-subscribe/gh-subscribe.sh

# 2. Grant the one OAuth scope it needs (once)
gh auth refresh -h github.com -s notifications

# 3. (optional) Register the `gh subscribe` alias
gh alias import ~/gh-subscribe/gh-aliases.yml --clobber

It uses your active gh account (gh auth status).

Use

~/gh-subscribe/gh-subscribe.sh cli/cli 1234 5678   # subscribe
~/gh-subscribe/gh-subscribe.sh --list  cli/cli 1234   # show state
~/gh-subscribe/gh-subscribe.sh --unsub cli/cli 1234   # unsubscribe

# with the alias:
gh subscribe cli/cli 1234

Idempotent. Validates inputs, distinguishes "not found" from real API/auth/network errors, and never feeds a null id into the mutation.

# gh CLI aliases — portable bundle.
# Assumes you cloned this gist to ~/gh-subscribe (see README). Adjust the path
# if you cloned elsewhere. Import with:
# gh alias import ~/gh-subscribe/gh-aliases.yml --clobber
subscribe: '!~/gh-subscribe/gh-subscribe.sh "$@"'
#!/bin/bash
# gh-subscribe.sh — subscribe/unsubscribe/list GitHub issue (or PR) notifications.
#
# WHY: the GitHub REST API can't subscribe you to an arbitrary issue; the GraphQL
# `updateSubscription` mutation can (an Issue is a Subscribable). This wraps it so
# you don't re-derive the mutation each time. Handy for tracking upstream bug
# reports you care about across the tools you depend on.
#
# IDENTITY: uses your *active* gh account (whatever `gh auth status` shows / your
# GH_CONFIG_DIR). Portable, repo-less — drop it on any machine with gh.
#
# REQUIRES: the `notifications` OAuth scope. Add it once with:
# gh auth refresh -h github.com -s notifications
# Without it you'll get INSUFFICIENT_SCOPES. Subscriptions are PRIVATE
# (viewerSubscription is per-user, invisible to others).
#
# Usage:
# ./gh-subscribe.sh <owner/repo> <issue#> [issue# ...] # subscribe
# ./gh-subscribe.sh --list <owner/repo> <issue#> [...] # show state
# ./gh-subscribe.sh --unsub <owner/repo> <issue#> [...] # unsubscribe
#
# Examples:
# ./gh-subscribe.sh cli/cli 1234
# ./gh-subscribe.sh --list cli/cli 1234 5678
set -uo pipefail
STATE="SUBSCRIBED"
MODE="set"
case "${1:-}" in
--list) MODE="list"; shift ;;
--unsub) STATE="UNSUBSCRIBED"; shift ;;
-h|--help|"") grep -E '^#( |$)' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
esac
REPO="${1:-}"; shift || true
OWNER="${REPO%%/*}"; NAME="${REPO##*/}"
# Require exactly one '/', both sides non-empty (rejects bareword, owner/, /repo,
# and 3-segment owner/x/repo which would silently drop the middle).
if [[ "$REPO" != */* || "$REPO" == */*/* ]] || [ -z "$OWNER" ] || [ -z "$NAME" ] || [ $# -eq 0 ]; then
echo "usage: $0 [--list|--unsub] <owner/repo> <issue#> [issue# ...]" >&2
exit 2
fi
# Resolve node id + current state for one issue number. Echoes
# "id<TAB>title<TAB>state" on a real hit; empty on a non-existent issue (jq
# `select(.)` drops a null issue); tabs/newlines in the title are squashed so
# the field split stays aligned. Returns gh's exit status so the caller can
# tell a genuine API/auth/network failure apart from a clean "not found".
resolve() { # arg: issue number
gh api graphql \
-f query='query($o:String!,$n:String!,$num:Int!){repository(owner:$o,name:$n){issue(number:$num){id title viewerSubscription}}}' \
-f o="$OWNER" -f n="$NAME" -F num="$1" \
--jq '.data.repository.issue | select(.) | "\(.id)\t\((.title // "")[0:60] | gsub("[\t\n]";" "))\t\(.viewerSubscription)"'
}
ERRF=$(mktemp)
trap 'rm -f "$ERRF"' EXIT
rc=0
for num in "$@"; do
if ! [[ "$num" =~ ^[0-9]+$ ]]; then
printf '#%-6s ✗ not a positive issue number\n' "$num"; rc=1; continue
fi
if ! row=$(resolve "$num" 2>"$ERRF"); then
printf '#%-6s ✗ gh error: %s\n' "$num" "$(head -1 "$ERRF")"; rc=1; continue
fi
if [ -z "$row" ]; then
printf '#%-6s ✗ not found in %s\n' "$num" "$REPO"; rc=1; continue
fi
id="${row%%$'\t'*}"; rest="${row#*$'\t'}"; title="${rest%%$'\t'*}"; cur="${rest##*$'\t'}"
if [ "$MODE" = "list" ]; then
printf '#%-6s [%s] %s\n' "$num" "$cur" "$title"
continue
fi
new=$(gh api graphql \
-f query='mutation($id:ID!,$s:SubscriptionState!){updateSubscription(input:{subscribableId:$id,state:$s}){subscribable{viewerSubscription}}}' \
-f id="$id" -f s="$STATE" \
--jq '.data.updateSubscription.subscribable.viewerSubscription' 2>&1)
if [ "$new" = "$STATE" ]; then
printf '#%-6s ✓ %s %s\n' "$num" "$new" "$title"
else
printf '#%-6s ✗ failed: %s\n' "$num" "$(printf '%s' "$new" | head -1)"; rc=1
fi
done
exit "$rc"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment