Skip to content

Commit c59bcaa

Browse files
committed
Implement AsyncReview CLI for GitHub PR and Issue reviews with output formatting
1 parent 54acc58 commit c59bcaa

10 files changed

Lines changed: 805 additions & 14 deletions

File tree

README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ https://github.com/user-attachments/assets/e17951e6-d73d-4cc0-8199-66c7e02f049f
2626

2727
# Or standard pip
2828
pip install -e .
29+
30+
# Pre-cache Deno dependencies (speeds up first run)
31+
deno cache npm:pyodide/pyodide.js
2932
```
3033

3134
2. **Install Frontend (web)**
@@ -64,6 +67,49 @@ Open `http://localhost:3000` in your browser.
6467

6568
You can also use the tool directly from the terminal:
6669

70+
### `cr` - Local Codebase Review
6771
- **Interactive Q&A**: `cr ask`
6872
- **One-shot Review**: `cr review -q "What does this repo do?"`
6973
- **Help**: `cr --help`
74+
75+
### `asyncreview` - GitHub PR/Issue Review (New!)
76+
77+
Review GitHub PRs and Issues directly from the command line:
78+
79+
```bash
80+
# Review a PR
81+
asyncreview review --url https://github.com/org/repo/pull/123 -q "Any security concerns?"
82+
83+
# Review with markdown output (great for docs/skills)
84+
asyncreview review --url https://github.com/org/repo/pull/123 \
85+
-q "Summarize the changes" \
86+
--output markdown
87+
88+
# Quiet mode for scripting (no progress bars)
89+
asyncreview review --url https://github.com/org/repo/pull/123 \
90+
-q "What does this PR do?" \
91+
--quiet --output json
92+
93+
# Use a specific model
94+
asyncreview review --url https://github.com/org/repo/pull/123 \
95+
-q "Deep code review" \
96+
--model gemini-3.0-pro-preview
97+
```
98+
99+
**Options:**
100+
- `--url, -u`: GitHub PR or Issue URL (required)
101+
- `--question, -q`: Question to ask (required)
102+
- `--output, -o`: Output format: `text`, `markdown`, `json` (default: text)
103+
- `--quiet`: Suppress progress output
104+
- `--model, -m`: Model override (default: from .env)
105+
106+
## Troubleshooting
107+
108+
### Deno/Pyodide Issues
109+
If you see errors like `Could not find npm:pyodide`, run:
110+
```bash
111+
deno cache npm:pyodide/pyodide.js
112+
```
113+
114+
### Slow First Run
115+
The first run may take longer as Deno downloads and compiles pyodide (~50MB). Subsequent runs are instant.

cli/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""AsyncReview CLI - Review GitHub PRs and Issues from the command line."""
2+
3+
__version__ = "0.1.0"

cli/github_fetcher.py

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
1+
"""GitHub URL parsing and content fetching for PR code review."""
2+
3+
import re
4+
from typing import Literal
5+
6+
import httpx
7+
8+
# Import config from cr package
9+
from cr.config import GITHUB_TOKEN, GITHUB_API_BASE
10+
11+
12+
UrlType = Literal["pr", "issue"]
13+
14+
15+
def parse_github_url(url: str) -> tuple[str, str, int, UrlType]:
16+
"""Parse a GitHub URL into (owner, repo, number, type).
17+
18+
Args:
19+
url: GitHub Issue or PR URL
20+
21+
Returns:
22+
Tuple of (owner, repo, number, type)
23+
24+
Raises:
25+
ValueError: If URL format is invalid
26+
27+
Examples:
28+
>>> parse_github_url("https://github.com/vercel-labs/json-render/pull/35")
29+
('vercel-labs', 'json-render', 35, 'pr')
30+
>>> parse_github_url("https://github.com/AsyncFuncAI/AsyncReview/issues/1")
31+
('AsyncFuncAI', 'AsyncReview', 1, 'issue')
32+
"""
33+
# Try PR URL first (primary use case)
34+
pr_pattern = r"github\.com/([^/]+)/([^/]+)/pull/(\d+)"
35+
pr_match = re.search(pr_pattern, url)
36+
if pr_match:
37+
return pr_match.group(1), pr_match.group(2), int(pr_match.group(3)), "pr"
38+
39+
# Try Issue URL
40+
issue_pattern = r"github\.com/([^/]+)/([^/]+)/issues/(\d+)"
41+
issue_match = re.search(issue_pattern, url)
42+
if issue_match:
43+
return issue_match.group(1), issue_match.group(2), int(issue_match.group(3)), "issue"
44+
45+
raise ValueError(
46+
f"Invalid GitHub URL: {url}\n"
47+
"Expected format: https://github.com/owner/repo/pull/123 or .../issues/123"
48+
)
49+
50+
51+
def _get_headers() -> dict[str, str]:
52+
"""Get HTTP headers for GitHub API requests."""
53+
headers = {
54+
"Accept": "application/vnd.github.v3+json",
55+
"User-Agent": "asyncreview-cli",
56+
}
57+
if GITHUB_TOKEN:
58+
headers["Authorization"] = f"token {GITHUB_TOKEN}"
59+
return headers
60+
61+
62+
async def fetch_pr(owner: str, repo: str, number: int) -> dict:
63+
"""Fetch PR with full code review context.
64+
65+
Returns dict with:
66+
- metadata: title, body, author, state, etc.
67+
- files: list of changed files with patches
68+
- commits: commit history
69+
- comments: PR discussion comments
70+
"""
71+
async with httpx.AsyncClient() as client:
72+
# Fetch PR metadata
73+
pr_resp = await client.get(
74+
f"{GITHUB_API_BASE}/repos/{owner}/{repo}/pulls/{number}",
75+
headers=_get_headers(),
76+
timeout=30.0,
77+
)
78+
pr_resp.raise_for_status()
79+
pr_data = pr_resp.json()
80+
81+
# Fetch changed files with patches
82+
files_resp = await client.get(
83+
f"{GITHUB_API_BASE}/repos/{owner}/{repo}/pulls/{number}/files",
84+
headers=_get_headers(),
85+
params={"per_page": 100},
86+
timeout=30.0,
87+
)
88+
files_resp.raise_for_status()
89+
files_data = files_resp.json()
90+
91+
# Fetch commits
92+
commits_resp = await client.get(
93+
f"{GITHUB_API_BASE}/repos/{owner}/{repo}/pulls/{number}/commits",
94+
headers=_get_headers(),
95+
params={"per_page": 100},
96+
timeout=30.0,
97+
)
98+
commits_list = []
99+
if commits_resp.status_code == 200:
100+
commits_data = commits_resp.json()
101+
commits_list = [
102+
{
103+
"sha": c["sha"][:7],
104+
"message": c["commit"]["message"].split("\n")[0], # First line only
105+
"author": c["commit"]["author"]["name"],
106+
}
107+
for c in commits_data
108+
]
109+
110+
# Fetch PR comments
111+
comments_resp = await client.get(
112+
f"{GITHUB_API_BASE}/repos/{owner}/{repo}/issues/{number}/comments",
113+
headers=_get_headers(),
114+
params={"per_page": 50},
115+
timeout=30.0,
116+
)
117+
comments_list = []
118+
if comments_resp.status_code == 200:
119+
comments_data = comments_resp.json()
120+
comments_list = [
121+
{
122+
"author": c["user"]["login"],
123+
"body": c["body"],
124+
}
125+
for c in comments_data
126+
]
127+
128+
# Build structured result
129+
files = [
130+
{
131+
"path": f["filename"],
132+
"status": f.get("status", "modified"),
133+
"additions": f.get("additions", 0),
134+
"deletions": f.get("deletions", 0),
135+
"patch": f.get("patch", ""),
136+
}
137+
for f in files_data
138+
]
139+
140+
return {
141+
"type": "pr",
142+
"owner": owner,
143+
"repo": repo,
144+
"number": number,
145+
"title": pr_data.get("title", ""),
146+
"body": pr_data.get("body") or "",
147+
"author": pr_data["user"]["login"],
148+
"state": pr_data.get("state", "open"),
149+
"base_branch": pr_data["base"]["ref"],
150+
"head_branch": pr_data["head"]["ref"],
151+
"files": files,
152+
"commits": commits_list,
153+
"comments": comments_list,
154+
"additions": pr_data.get("additions", 0),
155+
"deletions": pr_data.get("deletions", 0),
156+
"changed_files_count": pr_data.get("changed_files", 0),
157+
}
158+
159+
160+
async def fetch_issue(owner: str, repo: str, number: int) -> dict:
161+
"""Fetch issue content and comments (secondary use case)."""
162+
async with httpx.AsyncClient() as client:
163+
# Fetch issue metadata
164+
issue_resp = await client.get(
165+
f"{GITHUB_API_BASE}/repos/{owner}/{repo}/issues/{number}",
166+
headers=_get_headers(),
167+
timeout=30.0,
168+
)
169+
issue_resp.raise_for_status()
170+
issue_data = issue_resp.json()
171+
172+
# Fetch comments
173+
comments_resp = await client.get(
174+
f"{GITHUB_API_BASE}/repos/{owner}/{repo}/issues/{number}/comments",
175+
headers=_get_headers(),
176+
params={"per_page": 50},
177+
timeout=30.0,
178+
)
179+
comments_list = []
180+
if comments_resp.status_code == 200:
181+
comments_data = comments_resp.json()
182+
comments_list = [
183+
{
184+
"author": c["user"]["login"],
185+
"body": c["body"],
186+
}
187+
for c in comments_data
188+
]
189+
190+
return {
191+
"type": "issue",
192+
"owner": owner,
193+
"repo": repo,
194+
"number": number,
195+
"title": issue_data.get("title", ""),
196+
"body": issue_data.get("body") or "",
197+
"author": issue_data["user"]["login"],
198+
"state": issue_data.get("state", "open"),
199+
"labels": [l["name"] for l in issue_data.get("labels", [])],
200+
"comments": comments_list,
201+
}
202+
203+
204+
def build_pr_context(data: dict) -> str:
205+
"""Build a structured text representation of a PR for RLM input.
206+
207+
Optimized for code review - includes full diff patches.
208+
"""
209+
lines = [
210+
f"# Pull Request: {data['title']}",
211+
f"",
212+
f"**Repository:** {data['owner']}/{data['repo']}",
213+
f"**Author:** {data['author']}",
214+
f"**Branch:** {data['head_branch']}{data['base_branch']}",
215+
f"**Changes:** +{data['additions']} -{data['deletions']} across {data['changed_files_count']} files",
216+
f"",
217+
]
218+
219+
# PR description
220+
if data["body"]:
221+
lines.extend([
222+
"## Description",
223+
"",
224+
data["body"],
225+
"",
226+
])
227+
228+
# Commits
229+
if data["commits"]:
230+
lines.extend([
231+
"## Commits",
232+
"",
233+
])
234+
for commit in data["commits"]:
235+
lines.append(f"- `{commit['sha']}` {commit['message']} ({commit['author']})")
236+
lines.append("")
237+
238+
# Changed files with patches
239+
lines.extend([
240+
"## Changed Files",
241+
"",
242+
])
243+
244+
for file in data["files"]:
245+
status_icon = {"added": "+", "removed": "-", "modified": "~"}.get(file["status"], "~")
246+
lines.append(f"### [{status_icon}] {file['path']}")
247+
lines.append(f"*+{file['additions']} -{file['deletions']}*")
248+
lines.append("")
249+
250+
if file["patch"]:
251+
lines.append("```diff")
252+
lines.append(file["patch"])
253+
lines.append("```")
254+
lines.append("")
255+
256+
# Comments/Discussion
257+
if data["comments"]:
258+
lines.extend([
259+
"## Discussion",
260+
"",
261+
])
262+
for comment in data["comments"]:
263+
lines.append(f"**{comment['author']}:**")
264+
lines.append(comment["body"])
265+
lines.append("")
266+
267+
return "\n".join(lines)
268+
269+
270+
def build_issue_context(data: dict) -> str:
271+
"""Build a text representation of an issue for RLM input."""
272+
lines = [
273+
f"# Issue: {data['title']}",
274+
f"",
275+
f"**Repository:** {data['owner']}/{data['repo']}",
276+
f"**Author:** {data['author']}",
277+
f"**State:** {data['state']}",
278+
]
279+
280+
if data["labels"]:
281+
lines.append(f"**Labels:** {', '.join(data['labels'])}")
282+
283+
lines.append("")
284+
285+
# Issue body
286+
if data["body"]:
287+
lines.extend([
288+
"## Description",
289+
"",
290+
data["body"],
291+
"",
292+
])
293+
294+
# Comments
295+
if data["comments"]:
296+
lines.extend([
297+
"## Discussion",
298+
"",
299+
])
300+
for comment in data["comments"]:
301+
lines.append(f"**{comment['author']}:**")
302+
lines.append(comment["body"])
303+
lines.append("")
304+
305+
return "\n".join(lines)
306+
307+
308+
def build_review_context(data: dict) -> str:
309+
"""Build a structured text representation for RLM input.
310+
311+
Dispatches to PR or Issue context builder based on type.
312+
"""
313+
if data["type"] == "pr":
314+
return build_pr_context(data)
315+
else:
316+
return build_issue_context(data)

0 commit comments

Comments
 (0)