Long-running projects accumulate outdated issues: bugs for old versions, features that are no longer relevant, test tickets. Manual deletion is slow and tedious. This 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

Why this way:

  • split(",") - allows specifying labels as 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 you want
./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)"

โš ๏ธ Common 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

# Deleted wrong issues!
โ†’ Always run without --execute first
โ†’ Backup first: gh issue list --json ... > backup.json

๐Ÿ—‚ Pre-run checklist

  • Installed gh and jq, ran gh auth login
  • Verified repo permissions: gh api /repos/owner/repo
  • Tested in dry-run mode, reviewed issue list
  • Confirmed no critical issues in deletion list
  • Exported backup if needed: gh issue list --json ... > backup.json
  • Only then - run with --execute