Files
2026-07-13 13:05:14 +08:00

522 lines
18 KiB
Python
Executable File

#!/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: <hash> (<author> <date> <time> <timezone> <line#>) <content>
pattern = r"^([0-9a-f]+)\s+\(([^)]+?)\s+(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\s+[+-]\d{4})\s+\d+\)\s+(.*)$"
match = re.match(pattern, output)
if match:
return f"({match.group(2).strip()} {match.group(3)})"
return None
except subprocess.CalledProcessError as e:
print(f"Git blame failed: {e.stderr}")
return None
except Exception as e:
print(f"Error: {e}")
return None
# --- Check files for zombie TODOs ---
repo_owner = "rerun-io"
repo_name = "rerun"
internal_issue_number_pattern = re.compile(r"TODO\((?:#(\d+))(?:,\s*(?:#(\d+)))*\)")
external_issue_pattern = re.compile(r"TODO\(([a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+)#(\d+)\)")
linear_issue_pattern = re.compile(r"TODO\((RR-\d+)\)")
def collect_external_repos_from_file(path: str) -> set[str]:
"""Scan a file and collect all external repository references."""
repos = set()
try:
with open(path, encoding="utf8") as f:
content = f.read()
matches = external_issue_pattern.findall(content)
for repo_key, _ in matches:
repos.add(repo_key)
except Exception as e:
print(f"Error reading {path}: {e}")
return repos
def collect_linear_issues_from_file(path: str) -> set[str]:
"""Scan a file and collect all Linear issue references."""
issues = set()
try:
with open(path, encoding="utf8") as f:
content = f.read()
matches = linear_issue_pattern.findall(content)
for issue_id in matches:
issues.add(issue_id)
except Exception as e:
print(f"Error reading {path}: {e}")
return issues
def get_line_output(owner: str, name: str, issue_num: int, path: str, i: int, line: str) -> tuple[bool, str]:
is_closed, author = check_issue_closed(owner, name, issue_num)
display = ""
if is_closed:
blame_info = get_line_blame_info(path, i + 1)
blame_string = f" {blame_info}" if blame_info is not None else ""
if args.markdown:
# Convert path to relative path for clean display
display_path = path.lstrip("./")
github_url = f"https://github.com/{owner}/{name}/blob/main/{display_path}#L{i + 1}"
display = (
f"* [ ] `{line.strip()}`\n"
f" * #{issue_num} (Issue author: @{author})\n"
f" * [`{display_path}#L{i}`]({github_url}){blame_string}\n"
)
else:
display = f"{path}:{i}: {line.strip()}\n"
return is_closed, display
def get_linear_line_output(issue_id: str, path: str, i: int, line: str) -> tuple[bool, str]:
is_closed, author = check_linear_issue_closed(issue_id)
display = ""
if is_closed:
blame_info = get_line_blame_info(path, i + 1)
blame_string = f" {blame_info}" if blame_info is not None else ""
if args.markdown:
# Convert path to relative path for clean display
display_path = path.lstrip("./")
github_url = f"https://github.com/{repo_owner}/{repo_name}/blob/main/{display_path}#L{i + 1}"
display = (
f"* [ ] `{line.strip()}`\n"
f" * {issue_id} (Issue author: {author})\n"
f" * [`{display_path}#L{i}`]({github_url}){blame_string}\n"
)
else:
display = f"{path}:{i}: {line.strip()}\n"
return is_closed, display
# Returns true if the file is OK.
def check_file(path: str) -> tuple[bool, str]:
ok = True
display = ""
with open(path, encoding="utf8") as f:
for i, line in enumerate(f.readlines()):
# Check for internal issue references (TODO(#1234))
internal_matches = internal_issue_number_pattern.search(line)
if internal_matches is not None:
for match in internal_matches.groups():
if match is not None:
issue_num = int(match)
is_closed, line_display = get_line_output(repo_owner, repo_name, issue_num, path, i, line)
display += line_display
ok &= is_closed
# Check for external issue references (TODO(owner/repo#1234))
external_matches = external_issue_pattern.search(line)
if external_matches is not None:
repo_key = external_matches.group(1)
issue_num = int(external_matches.group(2))
owner, name = repo_key.split("/")
is_closed, line_display = get_line_output(owner, name, issue_num, path, i, line)
display += line_display
ok &= is_closed
# Check for Linear issue references (TODO(ABC-123))
linear_matches = linear_issue_pattern.search(line)
if linear_matches is not None:
issue_id = linear_matches.group(1)
is_closed, line_display = get_linear_line_output(issue_id, path, i, line)
display += line_display
ok &= is_closed
return ok, display
def process_file(filepath: str) -> tuple[bool, str]:
"""Process a single file and return True if it's OK (no zombie TODOs found)."""
try:
return check_file(filepath)
except Exception as e:
print(f"Error processing {filepath}: {e}")
return True, "" # Don't fail the whole process for one file
# ---
def main() -> None:
# Initialize GitHub client
init_github_client()
script_dirpath = os.path.dirname(os.path.realpath(__file__))
root_dirpath = os.path.abspath(f"{script_dirpath}/..")
os.chdir(root_dirpath)
extensions = [
"c",
"cpp",
"fbs",
"h",
"hpp",
"html",
"js",
"md",
"py",
"rs",
"sh",
"toml",
"txt",
"wgsl",
"yml",
"cmake",
]
exclude_paths = {
"./CODE_STYLE.md",
"./scripts/lint.py",
"./scripts/zombie_todos.py",
}
should_ignore = parse_gitignore(".gitignore") # TODO(emilk): parse all .gitignore files, not just top-level
# Collect all files to process
files_to_process = []
print("Collecting files to process…")
for root, dirs, files in os.walk(".", topdown=True):
dirs[:] = [d for d in dirs if not should_ignore(d)]
for filename in files:
extension = filename.split(".")[-1]
if extension in extensions:
filepath = os.path.join(root, filename)
if should_ignore(filepath):
continue
if filepath.replace("\\", "/") not in exclude_paths:
files_to_process.append(filepath)
print(f"Processing {len(files_to_process)} files…")
# Process files in parallel using ThreadPoolExecutor
ok = True
completed_files = 0
max_workers = min(args.max_workers, len(files_to_process)) # Don't create more threads than files
print(f"Using {max_workers} worker threads…")
with ThreadPoolExecutor(max_workers=max_workers) as executor:
# Submit all file processing tasks
future_to_file = {executor.submit(process_file, filepath): filepath for filepath in files_to_process}
# Process completed tasks with progress bar
display = ""
with tqdm(total=len(files_to_process), desc="Processing files", unit="file") as pbar:
for future in as_completed(future_to_file):
filepath = future_to_file[future]
try:
file_ok, file_display = future.result()
ok &= file_ok
completed_files += 1
display += file_display
pbar.update(1)
except Exception as e:
print(f"Error processing {filepath}: {e}")
completed_files += 1
pbar.update(1)
# Don't fail the whole process for one file error
print(display)
# Print cache statistics
if github_issue_cache:
print("\nGitHub cache statistics:")
print(f" Total unique issues checked: {len(github_issue_cache)}")
closed_count = sum(1 for is_closed, _ in github_issue_cache.values() if is_closed)
print(f" Closed issues found: {closed_count}")
print(f" Open/unknown issues: {len(github_issue_cache) - closed_count}")
print(f" Cache hits: {github_cache_hit}, Cache misses: {github_cache_miss}")
if linear_issue_cache:
print("\nLinear cache statistics:")
print(f" Total unique issues checked: {len(linear_issue_cache)}")
closed_count = sum(1 for is_closed, _ in linear_issue_cache.values() if is_closed)
print(f" Closed issues found: {closed_count}")
print(f" Open/unknown issues: {len(linear_issue_cache) - closed_count}")
print(f" Cache hits: {linear_cache_hit}, Cache misses: {linear_cache_miss}")
if not ok:
raise ValueError("Clean your zombies!")
if __name__ == "__main__":
main()