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
| Step | Command | What it does |
|---|---|---|
| 1 | gh issue list --json ... | Fetches issues as JSON with fields: number, title, date, labels, URL |
| 2 | jq -r ... | Filters: date < cutoff AND has at least one of specified labels |
| 3 | while read ... | Processes each matching issue |
| 4 | gh issue delete | Deletes 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 stringany(...)- 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
| Type | Limit | How to stay under |
|---|---|---|
| Authenticated | 5000 requests/hour | sleep 1 between deletions |
| Unauthenticated | 60 requests/hour | Always 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