Skip to content

Commit a7f3543

Browse files
committed
feat(github-all-prs): add needs-review+needs-work commands
1 parent d40f7af commit a7f3543

2 files changed

Lines changed: 132 additions & 46 deletions

File tree

github-all-prs

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
#!/usr/bin/env python3
2+
import json
3+
import subprocess
4+
import concurrent.futures
5+
import typer
6+
from typing import Optional
7+
from datetime import datetime, timezone
8+
from dateutil.parser import isoparse
9+
10+
app = typer.Typer(help="List GitHub PRs needing review or work in Slack-compatible format.", no_args_is_help=False)
11+
12+
def run_gh(args: list[str]) -> Optional[str]:
13+
"""Execute a gh CLI command and return its stdout."""
14+
try:
15+
res = subprocess.run(["gh"] + args, capture_output=True, text=True, check=True)
16+
return res.stdout.strip()
17+
except subprocess.CalledProcessError:
18+
return None
19+
20+
def get_me() -> str:
21+
"""Return the current user's GitHub login."""
22+
return run_gh(["api", "user", "--jq", ".login"]) or "@me"
23+
24+
def parse_date(date_str: str) -> datetime:
25+
"""Parse ISO 8601 date string from GitHub."""
26+
if not date_str:
27+
return datetime.fromtimestamp(0, tz=timezone.utc)
28+
d = isoparse(date_str)
29+
if d.tzinfo is None:
30+
d = d.replace(tzinfo=timezone.utc)
31+
return d
32+
33+
def get_pr_details(url: str) -> dict:
34+
"""Fetch detailed PR status."""
35+
fields = "url,title,reviewDecision,statusCheckRollup,latestReviews,comments,commits"
36+
data_raw = run_gh(["pr", "view", url, "--json", fields])
37+
return json.loads(data_raw) if data_raw else {}
38+
39+
def needs_review_filter(pr: dict) -> Optional[str]:
40+
"""PR is ready for review by others (no changes requested)."""
41+
if pr.get("reviewDecision") != "CHANGES_REQUESTED":
42+
return f"* {pr['url']} {pr['title']}"
43+
return None
44+
45+
def needs_work_filter(pr: dict, me: str) -> Optional[str]:
46+
"""PR needs work by the author (CI failure or unanswered changes requested)."""
47+
reasons = []
48+
# Check CI failures
49+
checks = pr.get("statusCheckRollup", [])
50+
if any(c.get("conclusion") in ["FAILURE", "ACTION_REQUIRED", "TIMED_OUT", "CANCELLED", "ERROR"] for c in checks):
51+
reasons.append("CI failing")
52+
# Check Review Decision
53+
if pr.get("reviewDecision") == "CHANGES_REQUESTED":
54+
cr_reviews = [r for r in pr.get("latestReviews", []) if r.get("state") == "CHANGES_REQUESTED"]
55+
if cr_reviews:
56+
# Find latest Changes Requested review
57+
dates = [parse_date(r.get("updatedAt")) for r in cr_reviews if r.get("updatedAt")]
58+
if dates:
59+
latest_cr = max(dates)
60+
# Answered by comment?
61+
answered = False
62+
for c in pr.get("comments", []):
63+
if c.get("author", {}).get("login") == me:
64+
if parse_date(c.get("createdAt")) > latest_cr:
65+
answered = True
66+
break
67+
# Answered by commit?
68+
if not answered:
69+
for c in pr.get("commits", []):
70+
if parse_date(c.get("committedDate")) > latest_cr:
71+
answered = True
72+
break
73+
if not answered:
74+
reasons.append("Changes requested")
75+
else:
76+
reasons.append("Changes requested")
77+
if reasons:
78+
return f"* {pr['url']} {pr['title']} ({', '.join(reasons)})"
79+
return None
80+
81+
82+
def list_needs_review(limit: int, workers: int):
83+
"""Core logic for needs-review."""
84+
query = f"search prs --author @me --state open --checks success --sort updated --limit {limit} --json url,title,isDraft"
85+
raw_prs = run_gh(query.split())
86+
if not raw_prs:
87+
return
88+
prs = [p for p in json.loads(raw_prs) if not p["isDraft"]]
89+
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
90+
details = list(executor.map(lambda p: get_pr_details(p["url"]), prs))
91+
output = "\n".join(filter(None, [needs_review_filter(d) for d in details]))
92+
if output:
93+
typer.echo(output)
94+
95+
def list_needs_work(limit: int, workers: int):
96+
"""Core logic for needs-work."""
97+
me = get_me()
98+
query = f"search prs --author @me --state open --sort updated --limit {limit} --json url,title,isDraft"
99+
raw_prs = run_gh(query.split())
100+
if not raw_prs:
101+
return
102+
prs = [p for p in json.loads(raw_prs) if not p["isDraft"]]
103+
with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as executor:
104+
details = list(executor.map(lambda p: get_pr_details(p["url"]), prs))
105+
output = "\n".join(filter(None, [needs_work_filter(d, me) for d in details]))
106+
if output:
107+
typer.echo(output)
108+
109+
@app.command()
110+
def needs_review(
111+
limit: int = typer.Option(100, help="Maximum number of PRs to search."),
112+
workers: int = typer.Option(10, help="Number of parallel workers for status checks."),
113+
):
114+
"""Fetch and display open PRs by current user that are ready for review (CI success, no changes requested)."""
115+
list_needs_review(limit, workers)
116+
117+
@app.command()
118+
def needs_work(
119+
limit: int = typer.Option(100, help="Maximum number of PRs to search."),
120+
workers: int = typer.Option(10, help="Number of parallel workers for status checks."),
121+
):
122+
"""Fetch and display open PRs by current user that need work (CI failure or unanswered changes requested)."""
123+
list_needs_work(limit, workers)
124+
125+
@app.callback(invoke_without_command=True)
126+
def main(ctx: typer.Context):
127+
"""Default to needs-review if no command provided."""
128+
if ctx.invoked_subcommand is None:
129+
list_needs_review(100, 10)
130+
131+
if __name__ == "__main__":
132+
app()

github-all-prs-needing-review

Lines changed: 0 additions & 46 deletions
This file was deleted.

0 commit comments

Comments
 (0)