diff --git a/agents/fetch_issues.py b/agents/fetch_issues.py index e31975dbe..34af1e9e1 100644 --- a/agents/fetch_issues.py +++ b/agents/fetch_issues.py @@ -2,12 +2,12 @@ """Fetch all issues (open and closed) from a GitHub repository via GraphQL and store as structured JSON.""" import argparse +from datetime import datetime, timezone import json +from pathlib import Path import subprocess import sys import time -from datetime import datetime, timezone -from pathlib import Path GRAPHQL_QUERY = """ query($owner: String!, $repo: String!, $cursor: String, $states: [IssueState!]) { @@ -72,7 +72,9 @@ def gh_graphql(query: str, variables: dict) -> dict: payload = json.dumps({"query": query, "variables": clean_vars}) result = subprocess.run( ["gh", "api", "graphql", "--input", "-"], - input=payload, capture_output=True, text=True, + input=payload, + capture_output=True, + text=True, ) if result.returncode != 0: raise RuntimeError(f"gh api graphql failed: {result.stderr}") @@ -121,13 +123,15 @@ def transform_issue(raw: dict) -> dict: """Transform a raw GraphQL issue node into our clean structure.""" comments = [] for c in raw["comments"]["nodes"]: - comments.append({ - "author": c["author"]["login"] if c.get("author") else None, - "body": c["body"], - "created_at": c["createdAt"], - "updated_at": c["updatedAt"], - "reactions": transform_reactions(c.get("reactionGroups", [])), - }) + comments.append( + { + "author": c["author"]["login"] if c.get("author") else None, + "body": c["body"], + "created_at": c["createdAt"], + "updated_at": c["updatedAt"], + "reactions": transform_reactions(c.get("reactionGroups", [])), + } + ) timeline = [] for t in raw["timelineItems"]["nodes"]: @@ -145,7 +149,7 @@ def transform_issue(raw: dict) -> dict: "updated_at": raw["updatedAt"], "closed_at": raw["closedAt"], "assignees": [a["login"] for a in raw["assignees"]["nodes"]], - "labels": [l["name"] for l in raw["labels"]["nodes"]], + "labels": [label["name"] for label in raw["labels"]["nodes"]], "milestone": raw.get("milestone"), "reactions": transform_reactions(raw.get("reactionGroups", [])), "comment_count": raw["comments"]["totalCount"], @@ -168,12 +172,18 @@ def fetch_all_issues(owner: str, repo: str, states: list[str] | None = None) -> for attempt in range(max_retries): try: print(f"Fetching {label} issues page {page}...", file=sys.stderr) - data = gh_graphql(GRAPHQL_QUERY, { - "owner": owner, "repo": repo, "cursor": cursor, "states": states, - }) + data = gh_graphql( + GRAPHQL_QUERY, + { + "owner": owner, + "repo": repo, + "cursor": cursor, + "states": states, + }, + ) break except RuntimeError as e: - wait = min(2 ** attempt, 60) + wait = min(2**attempt, 60) print(f"Error on attempt {attempt + 1}: {e}", file=sys.stderr) if attempt < max_retries - 1: print(f"Retrying in {wait}s...", file=sys.stderr) @@ -215,8 +225,9 @@ def main(): parser.add_argument("--owner", default="bitsandbytes-foundation", help="Repository owner") parser.add_argument("--repo", default="bitsandbytes", help="Repository name") parser.add_argument("--open-only", action="store_true", help="Only fetch open issues") - parser.add_argument("-o", "--output", default=None, - help="Output JSON file path (default: _issues.json in script dir)") + parser.add_argument( + "-o", "--output", default=None, help="Output JSON file path (default: _issues.json in script dir)" + ) args = parser.parse_args() output_path = args.output or str(Path(__file__).parent / f"{args.repo}_issues.json") @@ -242,8 +253,7 @@ def main(): with open(output_path, "w") as f: json.dump(result, f, indent=2, ensure_ascii=False) - print(f"Wrote {len(open_issues)} open + {len(closed_issues)} closed issues to {output_path}", - file=sys.stderr) + print(f"Wrote {len(open_issues)} open + {len(closed_issues)} closed issues to {output_path}", file=sys.stderr) if __name__ == "__main__": diff --git a/agents/query_issues.py b/agents/query_issues.py index 96da4e971..4a39e9155 100644 --- a/agents/query_issues.py +++ b/agents/query_issues.py @@ -32,30 +32,131 @@ import argparse import json +from pathlib import Path import re import sys -from pathlib import Path DEFAULT_DATA = Path(__file__).parent / "bitsandbytes_issues.json" # Words too common to be useful for matching -STOPWORDS = frozenset({ - # General English stopwords - 'the', 'and', 'for', 'with', 'this', 'that', 'from', 'have', 'has', 'was', - 'are', 'but', 'not', 'you', 'all', 'can', 'had', 'one', 'our', 'out', 'were', - 'been', 'some', 'them', 'than', 'its', 'over', 'will', 'would', 'could', - 'should', 'into', 'also', 'just', 'more', 'when', 'what', 'which', 'their', - 'about', 'there', 'because', 'does', 'like', 'using', 'used', 'use', 'how', - 'please', 'help', 'thank', 'thanks', 'tried', 'trying', 'working', 'getting', - 'running', 'following', 'seems', 'able', 'want', 'need', 'any', 'here', 'then', - 'other', 'being', 'after', 'before', 'only', 'same', 'still', 'make', 'even', - 'most', 'such', 'take', 'come', 'each', 'those', 'very', 'well', - # Repo-specific: appear in majority of issues, not discriminative - 'bitsandbytes', 'issue', 'error', 'cuda', 'gpu', 'model', 'file', 'work', - 'install', 'pip', 'python', 'import', 'version', 'torch', 'support', - 'available', 'found', 'setup', 'failed', 'library', 'module', 'package', - 'system', 'run', 'load', 'bit', 'get', 'bug', 'report', 'info', -}) +STOPWORDS = frozenset( + { + # General English stopwords + "the", + "and", + "for", + "with", + "this", + "that", + "from", + "have", + "has", + "was", + "are", + "but", + "not", + "you", + "all", + "can", + "had", + "one", + "our", + "out", + "were", + "been", + "some", + "them", + "than", + "its", + "over", + "will", + "would", + "could", + "should", + "into", + "also", + "just", + "more", + "when", + "what", + "which", + "their", + "about", + "there", + "because", + "does", + "like", + "using", + "used", + "use", + "how", + "please", + "help", + "thank", + "thanks", + "tried", + "trying", + "working", + "getting", + "running", + "following", + "seems", + "able", + "want", + "need", + "any", + "here", + "then", + "other", + "being", + "after", + "before", + "only", + "same", + "still", + "make", + "even", + "most", + "such", + "take", + "come", + "each", + "those", + "very", + "well", + # Repo-specific: appear in majority of issues, not discriminative + "bitsandbytes", + "issue", + "error", + "cuda", + "gpu", + "model", + "file", + "work", + "install", + "pip", + "python", + "import", + "version", + "torch", + "support", + "available", + "found", + "setup", + "failed", + "library", + "module", + "package", + "system", + "run", + "load", + "bit", + "get", + "bug", + "report", + "info", + } +) def load_data(path: str) -> dict: @@ -64,30 +165,37 @@ def load_data(path: str) -> dict: def all_issues(data: dict) -> list[dict]: - return data['open_issues'] + data['closed_issues'] + return data["open_issues"] + data["closed_issues"] def format_compact(issue: dict) -> str: """One-line summary of an issue.""" - labels = ', '.join(issue['labels'][:3]) if issue['labels'] else '-' - thumbs = issue['reactions'].get('THUMBS_UP', 0) - return (f"#{issue['number']:<5d} {issue['state']:<6s} " - f"[{labels}] ({issue['comment_count']}c {thumbs}\u2191) " - f"{issue['title'][:80]}") + labels = ", ".join(issue["labels"][:3]) if issue["labels"] else "-" + thumbs = issue["reactions"].get("THUMBS_UP", 0) + return ( + f"#{issue['number']:<5d} {issue['state']:<6s} " + f"[{labels}] ({issue['comment_count']}c {thumbs}\u2191) " + f"{issue['title'][:80]}" + ) def format_list_line(issue: dict) -> str: """Compact one-line summary for list view, with date and key metadata.""" - labels = ', '.join(issue['labels'][:3]) if issue['labels'] else '-' - thumbs = issue['reactions'].get('THUMBS_UP', 0) - prs = [t for t in issue['timeline'] - if t['type'] == 'CrossReferencedEvent' - and t.get('source_type') == 'PullRequest' - and t.get('source_state') == 'OPEN'] + labels = ", ".join(issue["labels"][:3]) if issue["labels"] else "-" + thumbs = issue["reactions"].get("THUMBS_UP", 0) + prs = [ + t + for t in issue["timeline"] + if t["type"] == "CrossReferencedEvent" + and t.get("source_type") == "PullRequest" + and t.get("source_state") == "OPEN" + ] pr_marker = f" PR#{prs[0]['source_number']}" if prs else "" - return (f"#{issue['number']:<5d} {issue['updated_at'][:10]} " - f"[{labels}] {issue['comment_count']}c {thumbs}\u2191" - f"{pr_marker} {issue['title'][:70]}") + return ( + f"#{issue['number']:<5d} {issue['updated_at'][:10]} " + f"[{labels}] {issue['comment_count']}c {thumbs}\u2191" + f"{pr_marker} {issue['title'][:70]}" + ) def format_detail(issue: dict, brief: bool = False) -> str: @@ -99,23 +207,22 @@ def format_detail(issue: dict, brief: bool = False) -> str: f"Labels: {', '.join(issue['labels']) or 'none'}", f"Assignees: {', '.join(issue['assignees']) or 'none'}", ] - if issue['reactions']: - rxn = ' '.join(f"{k}:{v}" for k, v in issue['reactions'].items()) + if issue["reactions"]: + rxn = " ".join(f"{k}:{v}" for k, v in issue["reactions"].items()) lines.append(f"Reactions: {rxn}") lines.append(f"Comments: {issue['comment_count']}") # Cross-references (PRs and issues) - xrefs = [t for t in issue['timeline'] if t['type'] == 'CrossReferencedEvent'] + xrefs = [t for t in issue["timeline"] if t["type"] == "CrossReferencedEvent"] if xrefs: lines.append(f"Cross-references ({len(xrefs)}):") for x in xrefs[:15]: - lines.append(f" {x['source_type']} #{x['source_number']} " - f"[{x['source_state']}]: {x['source_title'][:60]}") + lines.append(f" {x['source_type']} #{x['source_number']} [{x['source_state']}]: {x['source_title'][:60]}") lines.append("") # Body - body = (issue['body'] or '').strip() + body = (issue["body"] or "").strip() if brief: if len(body) > 1000: body = body[:1000] + "\n... [truncated, use show without --brief for full]" @@ -126,22 +233,22 @@ def format_detail(issue: dict, brief: bool = False) -> str: lines.append(body) # Comments - if issue['comments']: + if issue["comments"]: lines.append("") lines.append(f"--- Comments ({issue['comment_count']}) ---") - comments = issue['comments'] + comments = issue["comments"] if brief: # In brief mode, show just first and last comment to_show = [] if comments: - to_show.append(('first', comments[0])) + to_show.append(("first", comments[0])) if len(comments) > 1: - to_show.append(('last', comments[-1])) + to_show.append(("last", comments[-1])) for label, c in to_show: - rxn = '' - if c['reactions']: - rxn = ' | ' + ' '.join(f"{k}:{v}" for k, v in c['reactions'].items()) - c_body = c['body'].replace('\n', ' ').strip()[:300] + rxn = "" + if c["reactions"]: + rxn = " | " + " ".join(f"{k}:{v}" for k, v in c["reactions"].items()) + c_body = c["body"].replace("\n", " ").strip()[:300] lines.append(f" [{label}] @{c['author'] or '?'} ({c['created_at'][:10]}){rxn}:") lines.append(f" {c_body}") if len(comments) > 2: @@ -149,19 +256,19 @@ def format_detail(issue: dict, brief: bool = False) -> str: else: # Full mode: show all comments for idx, c in enumerate(comments): - rxn = '' - if c['reactions']: - rxn = ' | ' + ' '.join(f"{k}:{v}" for k, v in c['reactions'].items()) - lines.append(f" [{idx+1}] @{c['author'] or '?'} ({c['created_at'][:10]}){rxn}:") - c_body = c['body'].strip() + rxn = "" + if c["reactions"]: + rxn = " | " + " ".join(f"{k}:{v}" for k, v in c["reactions"].items()) + lines.append(f" [{idx + 1}] @{c['author'] or '?'} ({c['created_at'][:10]}){rxn}:") + c_body = c["body"].strip() if len(c_body) > 2000: c_body = c_body[:2000] + "\n ... [comment truncated]" # Indent comment body - for line in c_body.split('\n'): + for line in c_body.split("\n"): lines.append(f" {line}") lines.append("") - return '\n'.join(lines) + return "\n".join(lines) def tokenize(text: str) -> set[str]: @@ -169,9 +276,9 @@ def tokenize(text: str) -> set[str]: if not text: return set() text = text.lower() - text = re.sub(r'```.*?```', '', text, flags=re.DOTALL) - text = re.sub(r'https?://\S+', '', text) - words = re.findall(r'[a-z][a-z0-9_.]+', text) + text = re.sub(r"```.*?```", "", text, flags=re.DOTALL) + text = re.sub(r"https?://\S+", "", text) + words = re.findall(r"[a-z][a-z0-9_.]+", text) return {w for w in words if len(w) > 2 and w not in STOPWORDS} @@ -185,48 +292,47 @@ def extract_signatures(text: str) -> set[str]: return set() sigs = set() # Specific Python error types (but not generic 'error') - for m in re.finditer(r'(\w+Error|\w+Exception)', text): + for m in re.finditer(r"(\w+Error|\w+Exception)", text): val = m.group(0).lower() - if val not in ('error', 'exception'): + if val not in ("error", "exception"): sigs.add(val) # Library/module paths - for m in re.finditer(r'(libcudart|libbitsandbytes|torch\.compile|bnb\.\w+|bitsandbytes\.\w+)', text): + for m in re.finditer(r"(libcudart|libbitsandbytes|torch\.compile|bnb\.\w+|bitsandbytes\.\w+)", text): sigs.add(m.group(0).lower()) # Quantization methods - for m in re.finditer(r'(nf4|fp4|int8|int4|qlora|lora|gptq|awq)', text, re.I): + for m in re.finditer(r"(nf4|fp4|int8|int4|qlora|lora|gptq|awq)", text, re.I): sigs.add(m.group(0).lower()) # Platforms (excluding 'cuda' — too common to be useful) - for m in re.finditer(r'(rocm|windows|macos|apple.?silicon|aarch64|arm64|xpu|ascend|gaudi)', text, re.I): + for m in re.finditer(r"(rocm|windows|macos|apple.?silicon|aarch64|arm64|xpu|ascend|gaudi)", text, re.I): sigs.add(m.group(0).lower()) # Specific component/feature terms - for m in re.finditer(r'(fsdp|deepspeed|triton|matmul|optimizer|quantiz\w+|dequantiz\w+|checkpoint)', text, re.I): + for m in re.finditer(r"(fsdp|deepspeed|triton|matmul|optimizer|quantiz\w+|dequantiz\w+|checkpoint)", text, re.I): sigs.add(m.group(0).lower()) return sigs -def find_related(target: dict, issues: list[dict], state_filter: str | None = None, - limit: int = 15) -> list[tuple]: +def find_related(target: dict, issues: list[dict], state_filter: str | None = None, limit: int = 15) -> list[tuple]: """Find issues related to target. Returns list of (score, issue, sig_overlap, token_overlap).""" - query_text = target['title'] + ' ' + (target['body'] or '')[:1000] + query_text = target["title"] + " " + (target["body"] or "")[:1000] query_tokens = tokenize(query_text) query_sigs = extract_signatures(query_text) - query_labels = set(target['labels']) + query_labels = set(target["labels"]) scored = [] for issue in issues: - if issue['number'] == target['number']: + if issue["number"] == target["number"]: continue - if state_filter and issue['state'] != state_filter: + if state_filter and issue["state"] != state_filter: continue - body_preview = (issue['body'] or '')[:200] - issue_text = issue['title'] + ' ' + body_preview + body_preview = (issue["body"] or "")[:200] + issue_text = issue["title"] + " " + body_preview issue_tokens = tokenize(issue_text) issue_sigs = extract_signatures(issue_text) sig_overlap = query_sigs & issue_sigs token_overlap = query_tokens & issue_tokens - label_overlap = query_labels & set(issue['labels']) + label_overlap = query_labels & set(issue["labels"]) score = len(sig_overlap) * 3 + len(token_overlap) + len(label_overlap) if score >= 3: @@ -243,47 +349,48 @@ def format_related_result(score, issue, sig_ol, tok_ol, verbose=False): matched = list(sig_ol) + list(tok_ol) lines.append(f" score={score} matched: {', '.join(sorted(matched)[:8])}") if verbose: - body_preview = (issue['body'] or '').replace('\n', ' ').strip()[:300] + body_preview = (issue["body"] or "").replace("\n", " ").strip()[:300] if body_preview: lines.append(f" Body: {body_preview}") # Show last comment (often contains resolution or key info) - if issue['comments']: - last = issue['comments'][-1] - last_body = last['body'].replace('\n', ' ').strip()[:200] + if issue["comments"]: + last = issue["comments"][-1] + last_body = last["body"].replace("\n", " ").strip()[:200] lines.append(f" Last comment @{last['author'] or '?'} ({last['created_at'][:10]}): {last_body}") lines.append("") - return '\n'.join(lines) + return "\n".join(lines) # ---- Commands ---- + def cmd_list(args, data): """List issues with compact one-line summaries.""" if args.state: - if args.state == 'open': - issues = list(data['open_issues']) + if args.state == "open": + issues = list(data["open_issues"]) else: - issues = list(data['closed_issues']) + issues = list(data["closed_issues"]) else: - issues = list(data['open_issues']) + issues = list(data["open_issues"]) if args.label: label_lower = args.label.lower() - issues = [i for i in issues if any(label_lower == l.lower() for l in i['labels'])] + issues = [i for i in issues if any(label_lower == lbl.lower() for lbl in i["labels"])] if args.unlabeled: - issues = [i for i in issues if not i['labels']] + issues = [i for i in issues if not i["labels"]] # Sort - sort_key = args.sort or 'updated' - if sort_key == 'updated': - issues.sort(key=lambda i: i['updated_at'], reverse=True) - elif sort_key == 'created': - issues.sort(key=lambda i: i['created_at'], reverse=True) - elif sort_key == 'comments': - issues.sort(key=lambda i: i['comment_count'], reverse=True) - elif sort_key == 'reactions': - issues.sort(key=lambda i: i['reactions'].get('THUMBS_UP', 0), reverse=True) + sort_key = args.sort or "updated" + if sort_key == "updated": + issues.sort(key=lambda i: i["updated_at"], reverse=True) + elif sort_key == "created": + issues.sort(key=lambda i: i["created_at"], reverse=True) + elif sort_key == "comments": + issues.sort(key=lambda i: i["comment_count"], reverse=True) + elif sort_key == "reactions": + issues.sort(key=lambda i: i["reactions"].get("THUMBS_UP", 0), reverse=True) n = args.limit or len(issues) for issue in issues[:n]: @@ -301,21 +408,21 @@ def cmd_search(args, data): if args.state: state = args.state.upper() - issues = [i for i in issues if i['state'] == state] + issues = [i for i in issues if i["state"] == state] if args.label: label_lower = args.label.lower() - issues = [i for i in issues if any(label_lower == l.lower() for l in i['labels'])] + issues = [i for i in issues if any(label_lower == lbl.lower() for lbl in i["labels"])] results = [] for issue in issues: - text = issue['title'].lower() + text = issue["title"].lower() if not args.title_only: - text += ' ' + (issue['body'] or '').lower()[:2000] + text += " " + (issue["body"] or "").lower()[:2000] if all(w in text for w in query_words): results.append(issue) - results.sort(key=lambda i: i['reactions'].get('THUMBS_UP', 0), reverse=True) + results.sort(key=lambda i: i["reactions"].get("THUMBS_UP", 0), reverse=True) n = args.limit or 20 for issue in results[:n]: print(format_compact(issue)) @@ -329,7 +436,7 @@ def cmd_search(args, data): def cmd_related(args, data): """Find issues related to a given issue number.""" issues = all_issues(data) - issue_map = {i['number']: i for i in issues} + issue_map = {i["number"]: i for i in issues} target = issue_map.get(args.number) if not target: @@ -339,7 +446,7 @@ def cmd_related(args, data): state_filter = args.state.upper() if args.state else None results = find_related(target, issues, state_filter, args.limit or 15) - query_sigs = extract_signatures(target['title'] + ' ' + (target['body'] or '')[:1000]) + query_sigs = extract_signatures(target["title"] + " " + (target["body"] or "")[:1000]) print(f"Issues related to #{target['number']}: {target['title'][:70]}") print(f" Signatures: {query_sigs or 'none'}") print() @@ -351,7 +458,7 @@ def cmd_related(args, data): def cmd_batch_related(args, data): """Find related issues for multiple issues at once.""" issues = all_issues(data) - issue_map = {i['number']: i for i in issues} + issue_map = {i["number"]: i for i in issues} state_filter = args.state.upper() if args.state else None limit_per = args.limit or 5 @@ -363,11 +470,10 @@ def cmd_batch_related(args, data): continue results = find_related(target, issues, state_filter, limit_per) - query_sigs = extract_signatures(target['title'] + ' ' + (target['body'] or '')[:1000]) + query_sigs = extract_signatures(target["title"] + " " + (target["body"] or "")[:1000]) print(f"=== #{target['number']}: {target['title'][:65]} ===") - print(f" Labels: {', '.join(target['labels']) or 'none'} " - f"Signatures: {query_sigs or 'none'}") + print(f" Labels: {', '.join(target['labels']) or 'none'} Signatures: {query_sigs or 'none'}") if results: for score, issue, sig_ol, tok_ol in results: @@ -380,7 +486,7 @@ def cmd_batch_related(args, data): def cmd_show(args, data): """Show full detail for one or more issues.""" issues = all_issues(data) - issue_map = {i['number']: i for i in issues} + issue_map = {i["number"]: i for i in issues} numbers = args.numbers for idx, number in enumerate(numbers): @@ -395,12 +501,12 @@ def cmd_show(args, data): def cmd_top(args, data): """List top issues by reaction count.""" - issues = data['open_issues'] + issues = data["open_issues"] if args.label: label_lower = args.label.lower() - issues = [i for i in issues if any(label_lower == l.lower() for l in i['labels'])] + issues = [i for i in issues if any(label_lower == lbl.lower() for lbl in i["labels"])] - issues = sorted(issues, key=lambda i: i['reactions'].get('THUMBS_UP', 0), reverse=True) + issues = sorted(issues, key=lambda i: i["reactions"].get("THUMBS_UP", 0), reverse=True) n = args.limit or 20 for issue in issues[:n]: print(format_compact(issue)) @@ -409,21 +515,22 @@ def cmd_top(args, data): def cmd_stats(args, data): """Show summary statistics.""" from collections import Counter + print(f"Repository: {data['repository']}") print(f"Fetched: {data['fetched_at'][:19]}") print(f"Open: {data['open_count']} Closed: {data['closed_count']}") print() label_counts = Counter() - for i in data['open_issues']: - for l in i['labels']: - label_counts[l] += 1 + for i in data["open_issues"]: + for lbl in i["labels"]: + label_counts[lbl] += 1 print("Open issue labels:") for label, count in label_counts.most_common(): print(f" {count:3d} {label}") - unlabeled = sum(1 for i in data['open_issues'] if not i['labels']) + unlabeled = sum(1 for i in data["open_issues"] if not i["labels"]) print(f" {unlabeled:3d} (unlabeled)") @@ -437,8 +544,9 @@ def main(): p_list.add_argument("--state", choices=["open", "closed"], help="Filter by state (default: open)") p_list.add_argument("--label", help="Filter by label name") p_list.add_argument("--unlabeled", action="store_true", help="Only show unlabeled issues") - p_list.add_argument("--sort", choices=["updated", "created", "comments", "reactions"], - help="Sort order (default: updated)") + p_list.add_argument( + "--sort", choices=["updated", "created", "comments", "reactions"], help="Sort order (default: updated)" + ) p_list.add_argument("--limit", type=int, help="Max results") # search @@ -454,22 +562,23 @@ def main(): p_related.add_argument("number", type=int, help="Issue number to find related issues for") p_related.add_argument("--state", choices=["open", "closed"], help="Only show open or closed") p_related.add_argument("--limit", type=int, help="Max results (default 15)") - p_related.add_argument("-v", "--verbose", action="store_true", - help="Show body preview and last comment for each result") + p_related.add_argument( + "-v", "--verbose", action="store_true", help="Show body preview and last comment for each result" + ) # batch-related p_batch = sub.add_parser("batch-related", help="Find related issues for multiple issues at once") p_batch.add_argument("numbers", type=int, nargs="+", help="Issue numbers") p_batch.add_argument("--state", choices=["open", "closed"], help="Only show open or closed") p_batch.add_argument("--limit", type=int, help="Max results per issue (default 5)") - p_batch.add_argument("-v", "--verbose", action="store_true", - help="Show body preview and last comment for each result") + p_batch.add_argument( + "-v", "--verbose", action="store_true", help="Show body preview and last comment for each result" + ) # show p_show = sub.add_parser("show", help="Show full issue detail (body + comments)") p_show.add_argument("numbers", type=int, nargs="+", help="Issue number(s)") - p_show.add_argument("--brief", action="store_true", - help="Truncated body, first+last comment only") + p_show.add_argument("--brief", action="store_true", help="Truncated body, first+last comment only") # top p_top = sub.add_parser("top", help="Top open issues by reactions") @@ -483,9 +592,13 @@ def main(): data = load_data(args.data) cmds = { - 'list': cmd_list, 'search': cmd_search, 'related': cmd_related, - 'batch-related': cmd_batch_related, 'show': cmd_show, - 'top': cmd_top, 'stats': cmd_stats, + "list": cmd_list, + "search": cmd_search, + "related": cmd_related, + "batch-related": cmd_batch_related, + "show": cmd_show, + "top": cmd_top, + "stats": cmd_stats, } cmds[args.command](args, data)