#!/usr/bin/env python """ Hunt down zombie TODOs: TODO comments referencing closed GitHub/Linear issues. Run with: `pixi run python scripts/zombie_todos.py` Requires GITHUB_TOKEN (env or --github-token) and LINEAR_TOKEN (env or --linear-token). What this script does: - Scans all text files in the repo for TODO comments referencing GitHub issues (e.g. `TODO(#1234)`, `TODO(owner/repo#1234)`) or Linear issues (e.g. `TODO(RR-1234)`). - Checks the status of each referenced issue via the GitHub/Linear APIs. - Reports any TODOs that reference closed issues — these are "zombie TODOs". What to do with zombie TODOs (in rough order of likelihood): - The TODO is stale and should be removed (the work was done, the comment is outdated). - The TODO is a workaround for an issue that no longer exists and the workaround should be removed. - The TODO describes remaining work that needs a new ticket — update the reference. - The TODO is still valid and the issue needs to be re-opened. """ from __future__ import annotations import argparse import os import re import subprocess from concurrent.futures import ThreadPoolExecutor, as_completed from threading import Lock import requests from github import Github from gitignore_parser import parse_gitignore from tqdm import tqdm # --- parser = argparse.ArgumentParser(description="Hunt down zombie TODOs.") # To access private repositories, the token must either be a fine-grained # generated from inside the organization or a classic token with `repo` scope. parser.add_argument( "--github-token", dest="GITHUB_TOKEN", default=os.environ.get("GITHUB_TOKEN", None), help="Github token to fetch issues (required for API mode) (env: GITHUB_TOKEN)", ) parser.add_argument( "--linear-token", dest="LINEAR_TOKEN", default=os.environ.get("LINEAR_TOKEN", None), help="Linear API token to fetch issues (required for Linear API mode) (env: LINEAR_TOKEN)", ) parser.add_argument("--markdown", action="store_true", help="Format output as markdown checklist") parser.add_argument( "--max-workers", type=int, default=16, help="Maximum number of worker threads for parallel processing (default: 16)", ) args = parser.parse_args() if args.GITHUB_TOKEN is None: print("Warning: Without GITHUB_TOKEN can only check public repos.") if args.LINEAR_TOKEN is None: print("Warning: Without LINEAR_TOKEN cannot check Linear issues.") # --- GitHub API access --- # Initialize GitHub client github_client = None # Cache for issue status checks: (repo_owner, repo_name, issue_number) -> (is_closed, author) github_issue_cache: dict[tuple[str, str, int], tuple[bool, str]] = {} github_cache_hit = 0 # Cache hit count github_cache_miss = 0 # Cache miss count github_cache_lock = Lock() # Thread safety for the cache # --- Linear API access --- # Cache for Linear issue status checks: (issue_id) -> (is_closed, author) linear_issue_cache: dict[str, tuple[bool, str]] = {} linear_cache_hit = 0 # Cache hit count linear_cache_miss = 0 # Cache miss count linear_cache_lock = Lock() # Thread safety for the cache def init_github_client() -> None: global github_client github_client = Github(args.GITHUB_TOKEN) def check_issue_closed(repo_owner: str, repo_name: str, issue_number: int) -> tuple[bool, str]: """ Check if a specific issue is closed and get its author. Uses caching to avoid repeated API calls for the same issue. Returns: (is_closed, author) tuple """ global github_issue_cache, github_cache_hit, github_cache_miss cache_key = (repo_owner, repo_name, issue_number) # Check if we already have this result cached with github_cache_lock: if cache_key in github_issue_cache: github_cache_hit += 1 return github_issue_cache[cache_key] github_cache_miss += 1 # Fetch the result and cache it result = check_issue_closed_api(repo_owner, repo_name, issue_number) # Store in cache with github_cache_lock: github_issue_cache[cache_key] = result return result def check_issue_closed_api(repo_owner: str, repo_name: str, issue_number: int) -> tuple[bool, str]: """Check if an issue is closed using PyGithub.""" try: if github_client is None: print(f"Warning: GitHub client not initialized, skipping {repo_owner}/{repo_name}#{issue_number}") return False, "unknown" repo = github_client.get_repo(f"{repo_owner}/{repo_name}") issue = repo.get_issue(issue_number) is_closed = issue.state == "closed" author = issue.user.login return is_closed, author except Exception as e: print(f"Error fetching issue {repo_owner}/{repo_name}#{issue_number}: {e}") return False, "unknown" def check_linear_issue_closed_api(issue_id: str) -> tuple[bool, str]: """Check if a Linear issue is closed using GraphQL API.""" try: if args.LINEAR_TOKEN is None: print(f"Warning: Linear token not provided, skipping Linear issue {issue_id}") return False, "unknown" # Linear GraphQL API endpoint url = "https://api.linear.app/graphql" # GraphQL query to get issue details query = f"""{{ issue(id: "{issue_id}") {{ id, title, creator {{ name, email }}, state {{ name, type }} }} }}""" headers = { "Authorization": f"{args.LINEAR_TOKEN}", "Content-Type": "application/json", } response = requests.post(url, headers=headers, json={"query": query}, timeout=30) data = response.json() if "errors" in data: print(f"GraphQL errors for Linear issue {issue_id}: {data['errors']}") return False, "unknown" issue_data = data.get("data", {}).get("issue") if not issue_data: print(f"Linear issue {issue_id} not found") return False, "unknown" # Check if issue is closed (Done or Canceled states) state = issue_data.get("state", {}) state_name = state.get("name", "").lower() state_type = state.get("type", "").lower() # Linear issue is considered closed if state is "Done" or "Canceled" is_closed = state_name in ["done", "canceled"] or state_type == "completed" # Get author information creator = issue_data.get("creator", {}) author = creator.get("name") or creator.get("email") or "unknown" return is_closed, author except requests.exceptions.RequestException as e: print(f"Error fetching Linear issue {issue_id}: {e}") return False, "unknown" except Exception as e: print(f"Unexpected error fetching Linear issue {issue_id}: {e}") return False, "unknown" def check_linear_issue_closed(issue_id: str) -> tuple[bool, str]: """ Check if a Linear issue is closed (Done or Canceled) and get its author. Uses caching to avoid repeated API calls for the same issue. Returns: (is_closed, author) tuple """ global linear_issue_cache, linear_cache_hit, linear_cache_miss cache_key = issue_id # Check if we already have this result cached with linear_cache_lock: if cache_key in linear_issue_cache: linear_cache_hit += 1 return linear_issue_cache[cache_key] linear_cache_miss += 1 # Fetch the result and cache it result = check_linear_issue_closed_api(issue_id) # Store in cache with linear_cache_lock: linear_issue_cache[cache_key] = result return result # --- Git blame on a line --- def get_line_blame_info(file_path: str, line_number: int, repo_path: str = ".") -> str | None: """ Simpler version that uses regular git blame output. Args: file_path: Relative path to the file in the git repository line_number: Line number to get blame for (1-indexed) repo_path: Path to the git repository (default: current directory) Returns: Dictionary with blame information or None if error """ try: # Run git blame for specific line cmd = [ "git", "-C", repo_path, "blame", "-L", f"{line_number},{line_number}", "--date=iso", # ISO format for dates file_path, ] result = subprocess.run(cmd, capture_output=True, text=True, check=True) output = result.stdout.strip() if not output: return None # Parse the standard git blame output # Format: (