Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions .claude/commands/pr-review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
---
allowed-tools: Read, Skill, Bash, Grep, Glob
argument-hint: [extra_context]
description: Review a GitHub pull request and display all issues found in Claude Code
---

# Review Pull Request

Automatically review a GitHub pull request and display all found issues in Claude Code with detailed analysis and suggestions.

## Usage

```
/pr-review [extra_context]
```

## Arguments

- `extra_context` (optional): Additional instructions or filtering context (e.g., focus on specific issues or areas)

## Examples

```
/pr-review # Review all changes
/pr-review Please focus on security issues # Focus on security
/pr-review Only review Python files # Filter specific file types
/pr-review Check for performance issues # Focus on specific concern
```

## Instructions

### 1. Auto-detect PR context

- First check for environment variables:
- If `PR_NUMBER` and `GITHUB_REPOSITORY` are set, parse `GITHUB_REPOSITORY` as `owner/repo` and use `PR_NUMBER`
- Then use `gh pr view <PR_NUMBER> --repo <owner/repo> --json 'title,body'` to retrieve the PR title and description
- Otherwise:
- Use `gh pr view --json 'title,body,url,number'` to get PR info for the current branch
- Parse the output to extract owner, repo, PR number, title, and description
- If neither method works, inform the user that no PR was found and exit

### 2. Fetch PR Diff and Existing Comments

- Use `gh pr diff <PR_NUMBER> --repo <owner/repo>` to fetch the PR diff
- Use the `fetch_unresolved_comments` skill to get existing unresolved review comments:
```
/skill fetch_unresolved_comments <owner> <repo> <PR_NUMBER>
```
- Parse the returned comment data to identify lines that already have unresolved issues to avoid duplicate reports

### 3. Review Changed Lines

**Apply additional filtering** from user instructions if provided (e.g., focus on specific issues or areas)

Carefully examine **only the changed lines** (added or modified) in the diff for:

- Potential bugs and code quality issues
- Common mistakes

**Important**: Ignore unchanged/context lines and pre-existing code.

**Collect all issues** in a structured format before presenting them:

```
issues = [
{
"file": "path/to/file.py",
"line": 42,
"end_line": 45, // (optional, for multi-line issues)
"severity": "error|warning|info",
"category": "bug|style|performance|security",
"description": "Detailed issue description",
"suggestion": "Recommended fix",
"is_duplicate": false // true if similar issue already commented on this line
}
]
```

**Important**: Before adding an issue to the list, check the existing unresolved comments from step 2. If there's already an unresolved comment on the same line(s) or covering a similar concern, mark the issue as duplicate to avoid redundant reports.

### 4. Present Complete Analysis

After reviewing all changed files, display a comprehensive summary in Claude Code:

- **Group by file and severity** for clear organization
- **Display statistics**: Total issues, breakdown by severity and category
- **Use navigation-friendly format**: Include `file:line` references for easy IDE navigation
- **Show detailed descriptions** and suggested fixes for each issue

**Output format:**
```
## PR Review Results

**Total Issues Found: X (Y new, Z already commented)**
- 🔴 Errors: X
- 🟡 Warnings: X
- 🔵 Info: X

### path/to/file1.py:42-45
🔴 **[Bug]:** Description here
→ Suggestion: Fix recommendation

### path/to/file2.js:67
🟡 **[Style]:** Description here
→ Suggestion: Fix recommendation

### path/to/file3.py:23
🔴 **[Security]:** Description here
→ Suggestion: Fix recommendation
⚠️ *Similar issue already commented on this PR*
```

**Format Notes:**
- Use `file:line` or `file:line-endline` format for direct IDE navigation
- Mark duplicate/already-commented issues with ⚠️ warning
- Group issues by file, with each issue as a separate section for easy clicking

If **no issues found**, display: "✅ **No issues found** - All changes look good!"
88 changes: 88 additions & 0 deletions .claude/skills/fetch-unresolved-comments/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
---
name: fetch-unresolved-comments
description: Fetch unresolved PR review comments using GitHub GraphQL API, filtering out resolved and outdated feedback.
---

# Fetch Unresolved PR Review Comments

Uses GitHub's GraphQL API to fetch only unresolved review thread comments from a pull request.

## When to Use

- You need to get only unresolved review comments from a PR
- You want to filter out already-resolved and outdated feedback

## Instructions

1. **Parse PR information**:

- First check for environment variables:
- If `PR_NUMBER` and `GITHUB_REPOSITORY` are set, read them and parse `GITHUB_REPOSITORY` as `owner/repo` and use `PR_NUMBER` directly
- Otherwise:
- Use `gh pr view --json url -q '.url'` to get the current branch's PR URL and parse to extract owner, repo, and PR number

2. **Run the Python script**:

```bash
GITHUB_TOKEN=$(gh auth token) \
uv run python .claude/skills/fetch-unresolved-comments/fetch_unresolved_comments.py <owner> <repo> <pr_number>
```

3. **Script options**:

- `--token <token>`: Provide token explicitly (default: GITHUB_TOKEN or GH_TOKEN env var)

4. **Parse the JSON output**:
The script always outputs JSON with:
- `total`: Total number of unresolved comments across all threads
- `by_file`: Review threads grouped by file path (each thread contains multiple comments in a conversation)

## Example JSON Output

```json
{
"total": 3,
"by_file": {
".github/workflows/resolve.yml": [
{
"thread_id": "PRRT_kwDOAL...",
"isOutdated": false,
"line": 40,
"startLine": null,
"diffHunk": "@@ -0,0 +1,245 @@\n+name: resolve...",
"comments": [
{
"id": 2437935275,
"body": "We can remove this once we get the key.",
"author": "harupy",
"createdAt": "2025-10-17T00:53:20Z"
},
{
"id": 2437935276,
"body": "Good catch, I'll update it.",
"author": "contributor",
"createdAt": "2025-10-17T01:10:15Z"
}
]
}
],
".gitignore": [
{
"thread_id": "PRRT_kwDOAL...",
"isOutdated": false,
"line": 133,
"startLine": null,
"diffHunk": "@@ -130,0 +133,2 @@\n+.claude/*",
"comments": [
{
"id": 2437935280,
"body": "Should we add this to .gitignore?",
"author": "reviewer",
"createdAt": "2025-10-17T01:15:42Z"
}
]
}
]
}
}
```
169 changes: 169 additions & 0 deletions .claude/skills/fetch-unresolved-comments/fetch_unresolved_comments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
"""Fetch unresolved PR review comments using GitHub GraphQL API.

Example usage:
GITHUB_TOKEN=$(gh auth token) uv run --no-project .claude/skills/fetch-unresolved-comments/fetch_unresolved_comments.py mlflow mlflow 18327
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
GITHUB_TOKEN=$(gh auth token) uv run --no-project .claude/skills/fetch-unresolved-comments/fetch_unresolved_comments.py mlflow mlflow 18327
GITHUB_TOKEN=$(gh auth token) uv run --no-project .claude/skills/fetch-unresolved-comments/fetch_unresolved_comments.py databricks databricks-ai-bridge 18327

just in case AI get misled by mlflow

""" # noqa: E501
# ruff: noqa: T201

import argparse
import json
import os
import sys
from typing import Any
from urllib.request import Request, urlopen


def fetch_unresolved_comments(owner: str, repo: str, pr_number: int, token: str) -> dict[str, Any]:
"""Fetch unresolved review threads from a PR using GraphQL."""

query = """
query($owner: String!, $repo: String!, $prNumber: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $prNumber) {
reviewThreads(first: 100) {
nodes {
id
isResolved
isOutdated
comments(first: 100) {
nodes {
id
databaseId
body
path
line
startLine
diffHunk
author {
login
}
createdAt
updatedAt
}
}
}
}
}
}
}
"""

variables = {
"owner": owner,
"repo": repo,
"prNumber": pr_number,
}

headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}

data = json.dumps({"query": query, "variables": variables}).encode("utf-8")
request = Request("https://api.github.com/graphql", data=data, headers=headers)

try:
with urlopen(request) as response:
return json.loads(response.read().decode("utf-8"))
except Exception as e:
print(f"Error fetching data: {e}", file=sys.stderr)
sys.exit(1)


def format_comments(data: dict[str, Any]) -> dict[str, Any]:
"""Format unresolved comments for easier consumption."""

try:
threads = data["data"]["repository"]["pullRequest"]["reviewThreads"]["nodes"]
except (KeyError, TypeError):
print("Error: Invalid response structure", file=sys.stderr)
print(json.dumps(data, indent=2), file=sys.stderr)
sys.exit(1)

by_file = {}
total_comments = 0

for thread in threads:
if not thread["isResolved"]:
comments = []
path = None
line = None
start_line = None
diff_hunk = None

for comment in thread["comments"]["nodes"]:
if path is None:
path = comment["path"]
line = comment["line"]
start_line = comment.get("startLine")
diff_hunk = comment.get("diffHunk")

comments.append(
{
"id": comment["databaseId"],
"body": comment["body"],
"author": comment["author"]["login"] if comment["author"] else "unknown",
"createdAt": comment["createdAt"],
}
)
total_comments += 1

if path:
if path not in by_file:
by_file[path] = []

by_file[path].append(
{
"thread_id": thread["id"],
"isOutdated": thread["isOutdated"],
"line": line,
"startLine": start_line,
"diffHunk": diff_hunk,
"comments": comments,
}
)

return {
"total": total_comments,
"by_file": by_file,
}


def main():
parser = argparse.ArgumentParser(
description="Fetch unresolved PR review comments using GitHub GraphQL API"
)
parser.add_argument("owner", help="Repository owner")
parser.add_argument("repo", help="Repository name")
parser.add_argument("pr_number", type=int, help="Pull request number")
parser.add_argument(
"--token",
default=os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN"),
help="GitHub token (default: GITHUB_TOKEN or GH_TOKEN env var)",
)

args = parser.parse_args()

if not args.token:
print(
"Error: GitHub token required (use --token or set GITHUB_TOKEN/GH_TOKEN)",
file=sys.stderr,
)
sys.exit(1)

data = fetch_unresolved_comments(args.owner, args.repo, args.pr_number, args.token)
formatted = format_comments(data)
formatted["by_file"] = {
path: [thread for thread in threads if not thread["isOutdated"]]
for path, threads in formatted["by_file"].items()
}
formatted["by_file"] = {k: v for k, v in formatted["by_file"].items() if v}
formatted["total"] = sum(
len(thread["comments"]) for threads in formatted["by_file"].values() for thread in threads
)

print(json.dumps(formatted, indent=2))


if __name__ == "__main__":
main()