Long-running projects accumulate outdated issues: bugs for old versions, irrelevant features, test tickets. Manual deletion takes significant time. The script automatically finds and deletes (or marks) old issues by specified labels.

๐Ÿ’ก Script uses dry-run by default - shows what would be deleted without actual deletion.


๐Ÿ“ฆ Script: delete-issues.sh

Full code

#!/bin/bash
# Delete old GitHub Issues by labels and date
# Usage: ./delete-issues.sh [--execute]

set -euo pipefail

# === CONFIG ===
REPO="owner/repo"                              # Repository in owner/repo format
LABELS='label1,label2,label3'                  # Labels to filter (comma-separated)
CUTOFF="2025-12-31T23:59:59Z"                 # Delete issues created BEFORE this date
DRY_RUN=true                                   # true = preview only, false = actually delete

# Parse arguments
if [[ "${1:-}" == "--execute" ]]; then
    DRY_RUN=false
    echo "โš ๏ธ  Mode: ACTUAL DELETION"
else
    echo "โ„น๏ธ  Mode: DRY RUN (nothing will be deleted)"
fi

echo "๐Ÿ” Searching issues in $REPO with labels: $LABELS, created before $CUTOFF"
echo "---"

# Fetch and filter issues
gh issue list --repo "$REPO" --state all --limit 1000 \
  --json number,title,createdAt,labels,url | \
  jq -r --arg labels "$LABELS" --arg cutoff "$CUTOFF" '
    ($labels | split(",")) as $label_array |
    .[] |
    select(.createdAt < $cutoff) |
    select(.labels | map(.name) | any(. as $l | $label_array | index($l))) |
    "\(.number)|\(.title)|\(.url)"
  ' | \
  while IFS='|' read -r number title url; do
    if [[ "$DRY_RUN" == "true" ]]; then
      echo "[DRY RUN] ##$number - $title"
      echo "          $url"
    else
      echo "๐Ÿ—‘  Deleting ##$number - $title"
      gh issue delete "$REPO" "$number" --yes
      sleep 1  # Small pause to avoid rate limits
    fi
  done

echo "---"
echo "โœ… Done"

Install dependencies

# GitHub CLI
# Windows (winget):
winget install GitHub.cli

# Linux (Ubuntu/Debian):
sudo apt install gh

# macOS (Homebrew):
brew install gh

# Authenticate
gh auth login

# jq (JSON processor)
# Windows (winget):
winget install jq.jq

# Linux:
sudo apt install jq

# macOS:
brew install jq

Run

# Make script executable
chmod +x delete-issues.sh

# DRY RUN (safe mode - preview only)
./delete-issues.sh

# ACTUAL DELETION (add --execute flag)
./delete-issues.sh --execute

๐Ÿ” How it works

Step-by-step breakdown

StepCommandWhat it does
1gh issue list --json ...Fetches issues as JSON with fields: number, title, date, labels, URL
2jq -r ...Filters: date < cutoff AND has at least one of specified labels
3while read ...Processes each matching issue
4gh issue deleteDeletes issue (only if DRY_RUN=false)

jq filter logic

($labels | split(",")) as $label_array |  # Split label string into array
.[] |                                      # For each issue
select(.createdAt < $cutoff) |            # Only if created before cutoff
select(
  .labels | map(.name) |                  # Get list of label names
  any(. as $l | $label_array | index($l)) # Check if any matches our labels
) |
"\(.number)|\(.title)|\(.url)"            # Format output

Rationale:

  • split(",") - allows specifying labels as a single string
  • any(...) - checks for any of specified labels (logical OR)
  • Pipe-delimited output - reliable way to pass titles with spaces

โš™๏ธ Adapt for your use case

Example 1: Delete old bugs

REPO="myorg/myproject"
LABELS='bug,critical'
CUTOFF="2024-01-01T00:00:00Z"  # Delete bugs before 2024

Example 2: Close instead of delete

Replace deletion block with close:

# Instead of gh issue delete:
echo "๐Ÿ”’ Closing ##$number - $title"
gh issue edit "$REPO" "$number" --state closed

Example 3: Export before delete (backup)

# Add before deletion:
echo "๐Ÿ’พ Exporting ##$number"
gh issue view "$REPO" "$number" --json title,body,comments >> backup-issues.jsonl

๐Ÿ›ก Safety

Mandatory checks before running

# 1. Verify selected issues are actually the ones needed
./delete-issues.sh | grep -c "\[DRY RUN\]"   # Count matches

# 2. Ensure no critical issues in list
./delete-issues.sh | grep -i "critical\|important"

# 3. Check permissions
gh api /repos/$REPO | jq '.permissions.admin'  # Should be true

GitHub API rate limits

TypeLimitHow to stay under
Authenticated5000 requests/hoursleep 1 between deletions
Unauthenticated60 requests/hourAlways use gh auth login

Check remaining quota:

gh api rate_limit | jq '.resources.core'

๐Ÿ“Š Alternatives and extensions

Preview only (no deletion)

gh issue list --repo "$REPO" --state all --limit 1000 \
  --json createdAt,labels | \
  jq -r --arg labels "$LABELS" --arg cutoff "$CUTOFF" '
    ($labels|split(",")) as $label_array |
    [.[] | select(.createdAt < $cutoff) |
     select(.labels | map(.name) | any(. as $l | $label_array | index($l)))] |
    length
  '

Delete by author + labels

select(.author.login == "bot-user") |
select(.labels | map(.name) | any(. as $l | $label_array | index($l)))

Export to CSV before delete

# Add to jq output:
"\(.number),\"\(.title)\",\(.createdAt),\(.url)"

โš ๏ธ Typical issues

# "gh: command not found"
โ†’ Install GitHub CLI: https://cli.github.com

# "jq: error: syntax error"
โ†’ Check quotes: use single quotes for jq, double for bash

# "HTTP 404: Not Found"
โ†’ Verify repo name: must be owner/repo
โ†’ Ensure token has issues:read permission

# "rate limit exceeded"
โ†’ Wait for reset or add delay: sleep 2

# Incorrect deletion
โ†’ Always run without --execute first
โ†’ Backup first: gh issue list --json ... > backup.json