Skip to content

Instantly share code, notes, and snippets.

@brunoamaral
Created April 12, 2026 12:58
Show Gist options
  • Select an option

  • Save brunoamaral/d5b4610c147cac701f57d082879085fe to your computer and use it in GitHub Desktop.

Select an option

Save brunoamaral/d5b4610c147cac701f57d082879085fe to your computer and use it in GitHub Desktop.
Removes the star status from stale repositories and adds some personal pruning for obsidian
#!/usr/bin/env python3
"""
Find GitHub starred repos with no pushes in the last N years.
Requires a GitHub personal access token with at least `read:user` scope.
Usage:
export GITHUB_TOKEN=ghp_...
python prune_stars.py
python prune_stars.py --years 2
python prune_stars.py --years 3 --output stale.csv
# Unstar all repos listed in a CSV (must have a 'repo' column):
python prune_stars.py --unstar stale.csv
python prune_stars.py --unstar stale.csv --dry-run
# Delete matching files from the Obsidian vault:
python prune_stars.py --prune-vault stale.csv
python prune_stars.py --prune-vault stale.csv --dry-run
# Author's Note
My workflow automatically syncs the GitHub stars with my Obsidian vault, but sometimes people move on or the software loses relevance.
This script is meant to be run manually every once in a while as an habit to look back at what is no longer useful to keep, and quickly delete it.
That's why you get a csv that you can call `cemetery-of-dead-stars.csv`.
"""
import os
import re
import sys
import csv
import argparse
from datetime import datetime, timezone, timedelta
from pathlib import Path
from urllib.request import urlopen, Request
from urllib.error import HTTPError
import json
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass # python-dotenv not installed; fall back to environment variables
API_BASE = "https://api.github.com"
PER_PAGE = 100
def fetch_starred(token: str) -> list[dict]:
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
repos = []
page = 1
while True:
url = f"{API_BASE}/user/starred?per_page={PER_PAGE}&page={page}"
req = Request(url, headers=headers)
try:
with urlopen(req) as resp:
batch = json.loads(resp.read().decode())
except HTTPError as e:
sys.exit(f"GitHub API error {e.code}: {e.reason}")
if not batch:
break
repos.extend(batch)
print(f" fetched page {page} ({len(repos)} repos so far)...", end="\r", file=sys.stderr)
page += 1
print(file=sys.stderr) # newline after progress
return repos
def parse_dt(s: str) -> datetime:
return datetime.fromisoformat(s.replace("Z", "+00:00"))
def unstar_from_csv(token: str, csv_path: str, dry_run: bool = False) -> None:
headers = {
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
}
with open(csv_path, newline="") as f:
reader = csv.DictReader(f)
if "repo" not in (reader.fieldnames or []):
sys.exit("CSV must have a 'repo' column.")
repos = [row["repo"].strip() for row in reader if row.get("repo", "").strip()]
print(f"Repos to unstar: {len(repos)}{' (dry run)' if dry_run else ''}")
ok = skipped = errors = 0
for full_name in repos:
if dry_run:
print(f" [dry-run] would unstar {full_name}")
skipped += 1
continue
url = f"{API_BASE}/user/starred/{full_name}"
req = Request(url, method="DELETE", headers=headers)
try:
with urlopen(req) as resp:
# 204 No Content = success
if resp.status == 204:
print(f" unstarred {full_name}")
ok += 1
else:
print(f" unexpected status {resp.status} for {full_name}", file=sys.stderr)
errors += 1
except HTTPError as e:
if e.code == 404:
print(f" not starred (skipped): {full_name}")
skipped += 1
else:
print(f" error {e.code} for {full_name}: {e.reason}", file=sys.stderr)
errors += 1
print(f"\nDone — unstarred: {ok}, skipped: {skipped}, errors: {errors}")
VAULT_STARS_DIR = Path("~/ExoCortex/Cortex/GitHub Stars").expanduser()
_FM_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
_KV_RE = re.compile(r"^([\w_]+):\s*(.*)$", re.MULTILINE)
def _parse_frontmatter(text: str) -> dict:
"""Return a flat dict of scalar frontmatter values."""
m = _FM_RE.match(text)
if not m:
return {}
return {k: v.strip() for k, v in _KV_RE.findall(m.group(1))}
def prune_vault_from_csv(csv_path: str, vault_dir: Path = VAULT_STARS_DIR, dry_run: bool = False) -> None:
try:
answer = input(f"Vault directory [{vault_dir}]: ").strip()
except (EOFError, KeyboardInterrupt):
print()
sys.exit("Aborted.")
if answer:
vault_dir = Path(answer).expanduser()
with open(csv_path, newline="") as f:
reader = csv.DictReader(f)
if "repo" not in (reader.fieldnames or []):
sys.exit("CSV must have a 'repo' column.")
targets = {row["repo"].strip() for row in reader if row.get("repo", "").strip()}
if not vault_dir.is_dir():
sys.exit(f"Vault directory not found: {vault_dir}")
print(f"Scanning {vault_dir} for {len(targets)} repos{' (dry run)' if dry_run else ''}...")
deleted = skipped = 0
for md_file in vault_dir.rglob("*.md"):
try:
text = md_file.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
fm = _parse_frontmatter(text)
if fm.get("kind") != "github-star":
continue
full_name = fm.get("full_name", "").strip()
if full_name not in targets:
continue
if dry_run:
print(f" [dry-run] would delete {md_file.name} ({full_name})")
skipped += 1
else:
md_file.unlink()
print(f" deleted {md_file.name} ({full_name})")
deleted += 1
if dry_run:
print(f"\nDone — would delete: {skipped}")
else:
print(f"\nDone — deleted: {deleted}")
def main():
parser = argparse.ArgumentParser(description="Find stale GitHub starred repos")
parser.add_argument("--years", type=float, default=3, help="Inactivity threshold in years (default: 3)")
parser.add_argument("--output", type=str, default=None, help="Optional CSV output file path")
parser.add_argument("--unstar", type=str, default=None, metavar="CSV_FILE",
help="Unstar all repos listed in this CSV file (must have a 'repo' column)")
parser.add_argument("--dry-run", action="store_true",
help="Preview changes without making them (works with --unstar and --prune-vault)")
parser.add_argument("--prune-vault", type=str, default=None, metavar="CSV_FILE",
help="Delete matching GitHub Stars notes from the Obsidian vault")
args = parser.parse_args()
if args.prune_vault and not args.unstar:
# vault pruning doesn't need a token
prune_vault_from_csv(args.prune_vault, dry_run=args.dry_run)
return
token = os.environ.get("GITHUB_TOKEN")
if not token:
sys.exit("Set GITHUB_TOKEN environment variable first.")
if args.unstar:
unstar_from_csv(token, args.unstar, dry_run=args.dry_run)
if args.prune_vault:
prune_vault_from_csv(args.prune_vault, dry_run=args.dry_run)
return
cutoff = datetime.now(timezone.utc) - timedelta(days=args.years * 365)
print(f"Fetching starred repos (cutoff: {cutoff.date()})...", file=sys.stderr)
all_repos = fetch_starred(token)
print(f"Total starred: {len(all_repos)}", file=sys.stderr)
stale = []
for r in all_repos:
pushed_at = parse_dt(r["pushed_at"]) if r.get("pushed_at") else None
if pushed_at is None or pushed_at < cutoff:
stale.append({
"repo": r["full_name"],
"pushed_at": pushed_at.date().isoformat() if pushed_at else "never",
"stars": r["stargazers_count"],
"archived": r["archived"],
"url": r["html_url"],
"description": (r.get("description") or "").replace("\n", " "),
})
# oldest first
stale.sort(key=lambda x: x["pushed_at"])
print(f"\nStale repos (no push in {args.years}+ years): {len(stale)}\n")
col_w = 45
print(f"{'Repo':<{col_w}} {'Last push':<12} {'Stars':>6} {'Archived'}")
print("-" * (col_w + 30))
for r in stale:
archived = "yes" if r["archived"] else ""
print(f"{r['repo']:<{col_w}} {r['pushed_at']:<12} {r['stars']:>6} {archived}")
if args.output:
fields = ["repo", "pushed_at", "stars", "archived", "url", "description"]
with open(args.output, "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=fields)
w.writeheader()
w.writerows(stale)
print(f"\nSaved to {args.output}")
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment