diff --git a/.claude/commands/pr-review.md b/.claude/commands/pr-review.md new file mode 100644 index 00000000..b48fec4f --- /dev/null +++ b/.claude/commands/pr-review.md @@ -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 --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 --repo ` to fetch the PR diff +- Use the `fetch_unresolved_comments` skill to get existing unresolved review comments: + ``` + /skill fetch_unresolved_comments + ``` +- 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!" diff --git a/.claude/skills/fetch-unresolved-comments/SKILL.md b/.claude/skills/fetch-unresolved-comments/SKILL.md new file mode 100644 index 00000000..3700e9cc --- /dev/null +++ b/.claude/skills/fetch-unresolved-comments/SKILL.md @@ -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 + ``` + +3. **Script options**: + + - `--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" + } + ] + } + ] + } +} +``` diff --git a/.claude/skills/fetch-unresolved-comments/fetch_unresolved_comments.py b/.claude/skills/fetch-unresolved-comments/fetch_unresolved_comments.py new file mode 100755 index 00000000..5da47d1d --- /dev/null +++ b/.claude/skills/fetch-unresolved-comments/fetch_unresolved_comments.py @@ -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 +""" # 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()