Created
April 12, 2026 12:58
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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