@papi-ai/skills 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +53 -0
- package/bin/install.mjs +104 -0
- package/lib/manifest.d.ts +33 -0
- package/lib/manifest.mjs +102 -0
- package/manifest.json +52 -0
- package/package.json +47 -0
- package/skills/check-mcp/SKILL.md +40 -0
- package/skills/deployment-completeness-audit/SKILL.md +255 -0
- package/skills/papi-advanced/SKILL.md +28 -0
- package/skills/papi-build/SKILL.md +52 -0
- package/skills/papi-idea/SKILL.md +37 -0
- package/skills/papi-plan/SKILL.md +164 -0
- package/skills/papi-strategy/SKILL.md +28 -0
- package/skills/playwright-skill/API_REFERENCE.md +653 -0
- package/skills/playwright-skill/EXAMPLES.md +166 -0
- package/skills/playwright-skill/SKILL.md +147 -0
- package/skills/playwright-skill/lib/helpers.js +441 -0
- package/skills/playwright-skill/package.json +26 -0
- package/skills/playwright-skill/run.js +228 -0
- package/skills/pr-reviewer/SKILL.md +443 -0
- package/skills/pr-reviewer/references/gh_cli_guide.md +368 -0
- package/skills/pr-reviewer/references/review_criteria.md +345 -0
- package/skills/pr-reviewer/references/scenarios.md +71 -0
- package/skills/pr-reviewer/references/troubleshooting.md +55 -0
- package/skills/pr-reviewer/scripts/add_inline_comment.py +163 -0
- package/skills/pr-reviewer/scripts/fetch_pr_data.py +327 -0
- package/skills/pr-reviewer/scripts/generate_review_files.py +480 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# Common Review Scenarios
|
|
2
|
+
|
|
3
|
+
Detailed workflows for specific review use cases.
|
|
4
|
+
|
|
5
|
+
## Scenario 1: Quick Review Request
|
|
6
|
+
|
|
7
|
+
**Trigger**: User provides PR URL and requests review.
|
|
8
|
+
|
|
9
|
+
**Workflow**:
|
|
10
|
+
1. Run `fetch_pr_data.py` to collect data
|
|
11
|
+
2. Read `SUMMARY.txt` and `metadata.json`
|
|
12
|
+
3. Scan `diff.patch` for obvious issues
|
|
13
|
+
4. Apply critical criteria (security, bugs, tests)
|
|
14
|
+
5. Create findings JSON with analysis
|
|
15
|
+
6. Run `generate_review_files.py` to create review files
|
|
16
|
+
7. Direct user to review `pr/review.md` and `pr/human.md`
|
|
17
|
+
8. Remind user to use `/show` to edit, then `/send` or `/send-decline`
|
|
18
|
+
|
|
19
|
+
## Scenario 2: Thorough Review with Inline Comments
|
|
20
|
+
|
|
21
|
+
**Trigger**: User requests comprehensive review with inline comments.
|
|
22
|
+
|
|
23
|
+
**Workflow**:
|
|
24
|
+
1. Run `fetch_pr_data.py` with cloning enabled
|
|
25
|
+
2. Read all collected files (metadata, diff, comments, commits)
|
|
26
|
+
3. Apply full `review_criteria.md` checklist
|
|
27
|
+
4. Identify critical issues, important issues, and nits
|
|
28
|
+
5. Create findings JSON with `inline_comments` array
|
|
29
|
+
6. Run `generate_review_files.py` to create all files
|
|
30
|
+
7. Direct user to:
|
|
31
|
+
- Review `pr/review.md` for detailed analysis
|
|
32
|
+
- Edit `pr/human.md` if needed
|
|
33
|
+
- Check `pr/inline.md` for proposed comments
|
|
34
|
+
- Use `/show` to open in VS Code
|
|
35
|
+
- Use `/send` or `/send-decline` when ready
|
|
36
|
+
- Optionally post inline comments from `pr/inline.md`
|
|
37
|
+
|
|
38
|
+
## Scenario 3: Security-Focused Review
|
|
39
|
+
|
|
40
|
+
**Trigger**: User requests security-specific review.
|
|
41
|
+
|
|
42
|
+
**Workflow**:
|
|
43
|
+
1. Fetch PR data
|
|
44
|
+
2. Focus on `review_criteria.md` Section 5 (Security)
|
|
45
|
+
3. Check for: SQL injection, XSS, CSRF, secrets exposure
|
|
46
|
+
4. Examine dependencies in metadata
|
|
47
|
+
5. Review authentication/authorization changes
|
|
48
|
+
6. Report security findings with severity ratings
|
|
49
|
+
|
|
50
|
+
## Scenario 4: Review with Related Tickets
|
|
51
|
+
|
|
52
|
+
**Trigger**: User requests review against linked JIRA/GitHub ticket.
|
|
53
|
+
|
|
54
|
+
**Workflow**:
|
|
55
|
+
1. Fetch PR data (captures ticket references)
|
|
56
|
+
2. Read `related_issues.json`
|
|
57
|
+
3. Compare PR changes against ticket requirements
|
|
58
|
+
4. Verify all acceptance criteria met
|
|
59
|
+
5. Note any missing functionality
|
|
60
|
+
6. Suggest additional tests if needed
|
|
61
|
+
|
|
62
|
+
## Scenario 5: Large PR Review (>400 lines)
|
|
63
|
+
|
|
64
|
+
**Trigger**: PR contains more than 400 lines of changes.
|
|
65
|
+
|
|
66
|
+
**Workflow**:
|
|
67
|
+
1. Suggest splitting into smaller PRs if feasible
|
|
68
|
+
2. Review in logical chunks by file or feature
|
|
69
|
+
3. Focus on architecture and design first
|
|
70
|
+
4. Document structural concerns before line-level issues
|
|
71
|
+
5. Prioritize security and correctness over style
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Troubleshooting Guide
|
|
2
|
+
|
|
3
|
+
Common issues and solutions for the PR Reviewer skill.
|
|
4
|
+
|
|
5
|
+
## gh CLI Not Found
|
|
6
|
+
|
|
7
|
+
Install GitHub CLI: https://cli.github.com/
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
# macOS
|
|
11
|
+
brew install gh
|
|
12
|
+
|
|
13
|
+
# Linux
|
|
14
|
+
sudo apt install gh # or yum, dnf, etc.
|
|
15
|
+
|
|
16
|
+
# Authenticate
|
|
17
|
+
gh auth login
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Permission Denied Errors
|
|
21
|
+
|
|
22
|
+
Check authentication:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
gh auth status
|
|
26
|
+
gh auth refresh -s repo
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Invalid PR URL
|
|
30
|
+
|
|
31
|
+
Ensure URL format: `https://github.com/owner/repo/pull/NUMBER`
|
|
32
|
+
|
|
33
|
+
## Line Number Mismatch in Diff
|
|
34
|
+
|
|
35
|
+
Inline comment line numbers are **relative to the diff**, not absolute file positions.
|
|
36
|
+
Use `gh pr diff <number>` to see diff line numbers.
|
|
37
|
+
|
|
38
|
+
## Rate Limit Errors
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# Check rate limit
|
|
42
|
+
gh api /rate_limit
|
|
43
|
+
|
|
44
|
+
# Authenticated users get higher limits
|
|
45
|
+
gh auth login
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Common Error Patterns
|
|
49
|
+
|
|
50
|
+
| Error | Cause | Solution |
|
|
51
|
+
|-------|-------|----------|
|
|
52
|
+
| 401 Unauthorized | Token expired | Run `gh auth refresh` |
|
|
53
|
+
| 403 Forbidden | Missing scope | Run `gh auth refresh -s repo` |
|
|
54
|
+
| 404 Not Found | Private repo access | Verify repo permissions |
|
|
55
|
+
| 422 Unprocessable | Invalid request | Check command arguments |
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Add inline code review comments to a GitHub PR.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python add_inline_comment.py <owner> <repo> <pr_number> <commit_id> <file_path> <line> <comment> [--side RIGHT|LEFT]
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
python add_inline_comment.py owner repo 123 abc123def "src/main.py" 42 "Consider refactoring this logic"
|
|
10
|
+
python add_inline_comment.py owner repo 123 abc123def "src/main.py" 42 "Check edge cases" --side LEFT
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def add_inline_comment(
|
|
21
|
+
owner: str,
|
|
22
|
+
repo: str,
|
|
23
|
+
pr_number: str,
|
|
24
|
+
commit_id: str,
|
|
25
|
+
path: str,
|
|
26
|
+
line: int,
|
|
27
|
+
body: str,
|
|
28
|
+
side: str = "RIGHT",
|
|
29
|
+
start_line: Optional[int] = None,
|
|
30
|
+
start_side: Optional[str] = None
|
|
31
|
+
) -> dict:
|
|
32
|
+
"""
|
|
33
|
+
Add an inline comment to a PR using gh CLI.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
owner: Repository owner
|
|
37
|
+
repo: Repository name
|
|
38
|
+
pr_number: Pull request number
|
|
39
|
+
commit_id: SHA of the commit to comment on
|
|
40
|
+
path: File path relative to repo root
|
|
41
|
+
line: Line number in the diff
|
|
42
|
+
body: Comment text
|
|
43
|
+
side: "RIGHT" (new version) or "LEFT" (old version)
|
|
44
|
+
start_line: For multi-line comments, the starting line
|
|
45
|
+
start_side: For multi-line comments, the starting side
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
API response as dict
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
RuntimeError: If gh command fails
|
|
52
|
+
"""
|
|
53
|
+
# Build the API request body
|
|
54
|
+
request_body = {
|
|
55
|
+
"body": body,
|
|
56
|
+
"commit_id": commit_id,
|
|
57
|
+
"path": path,
|
|
58
|
+
"side": side,
|
|
59
|
+
"line": line
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Add multi-line comment fields if provided
|
|
63
|
+
if start_line is not None:
|
|
64
|
+
request_body["start_line"] = start_line
|
|
65
|
+
if start_side is not None:
|
|
66
|
+
request_body["start_side"] = start_side
|
|
67
|
+
|
|
68
|
+
# Convert to JSON string for gh CLI
|
|
69
|
+
request_json = json.dumps(request_body)
|
|
70
|
+
|
|
71
|
+
# Build gh api command
|
|
72
|
+
cmd = [
|
|
73
|
+
'gh', 'api',
|
|
74
|
+
'-X', 'POST',
|
|
75
|
+
'-H', 'Accept: application/vnd.github+json',
|
|
76
|
+
f'/repos/{owner}/{repo}/pulls/{pr_number}/comments',
|
|
77
|
+
'--input', '-'
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
result = subprocess.run(
|
|
82
|
+
cmd,
|
|
83
|
+
input=request_json,
|
|
84
|
+
capture_output=True,
|
|
85
|
+
text=True,
|
|
86
|
+
check=True
|
|
87
|
+
)
|
|
88
|
+
return json.loads(result.stdout)
|
|
89
|
+
except subprocess.CalledProcessError as e:
|
|
90
|
+
raise RuntimeError(f"Failed to add comment: {e.stderr}")
|
|
91
|
+
except FileNotFoundError:
|
|
92
|
+
raise RuntimeError("gh CLI not found. Please install: https://cli.github.com/")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_latest_commit(owner: str, repo: str, pr_number: str) -> str:
|
|
96
|
+
"""Get the latest commit SHA for a PR."""
|
|
97
|
+
try:
|
|
98
|
+
result = subprocess.run([
|
|
99
|
+
'gh', 'api',
|
|
100
|
+
f'/repos/{owner}/{repo}/pulls/{pr_number}/commits',
|
|
101
|
+
'--jq', '.[-1].sha'
|
|
102
|
+
], capture_output=True, text=True, check=True)
|
|
103
|
+
return result.stdout.strip()
|
|
104
|
+
except subprocess.CalledProcessError as e:
|
|
105
|
+
raise RuntimeError(f"Failed to get commits: {e.stderr}")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def main():
|
|
109
|
+
parser = argparse.ArgumentParser(
|
|
110
|
+
description='Add inline code review comment to GitHub PR',
|
|
111
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
112
|
+
epilog=__doc__
|
|
113
|
+
)
|
|
114
|
+
parser.add_argument('owner', help='Repository owner')
|
|
115
|
+
parser.add_argument('repo', help='Repository name')
|
|
116
|
+
parser.add_argument('pr_number', help='Pull request number')
|
|
117
|
+
parser.add_argument('commit_id', help='Commit SHA (use "latest" to auto-fetch)')
|
|
118
|
+
parser.add_argument('path', help='File path relative to repo root')
|
|
119
|
+
parser.add_argument('line', type=int, help='Line number in the diff')
|
|
120
|
+
parser.add_argument('body', help='Comment text')
|
|
121
|
+
parser.add_argument('--side', choices=['RIGHT', 'LEFT'], default='RIGHT',
|
|
122
|
+
help='Side of the diff (RIGHT=new, LEFT=old)')
|
|
123
|
+
parser.add_argument('--start-line', type=int,
|
|
124
|
+
help='Starting line for multi-line comment')
|
|
125
|
+
parser.add_argument('--start-side', choices=['RIGHT', 'LEFT'],
|
|
126
|
+
help='Starting side for multi-line comment')
|
|
127
|
+
|
|
128
|
+
args = parser.parse_args()
|
|
129
|
+
|
|
130
|
+
try:
|
|
131
|
+
# Get latest commit if requested
|
|
132
|
+
commit_id = args.commit_id
|
|
133
|
+
if commit_id.lower() == 'latest':
|
|
134
|
+
print(f"Fetching latest commit for PR #{args.pr_number}...")
|
|
135
|
+
commit_id = get_latest_commit(args.owner, args.repo, args.pr_number)
|
|
136
|
+
print(f"Latest commit: {commit_id}")
|
|
137
|
+
|
|
138
|
+
# Add the inline comment
|
|
139
|
+
print(f"Adding comment to {args.path}:{args.line}...")
|
|
140
|
+
response = add_inline_comment(
|
|
141
|
+
owner=args.owner,
|
|
142
|
+
repo=args.repo,
|
|
143
|
+
pr_number=args.pr_number,
|
|
144
|
+
commit_id=commit_id,
|
|
145
|
+
path=args.path,
|
|
146
|
+
line=args.line,
|
|
147
|
+
body=args.body,
|
|
148
|
+
side=args.side,
|
|
149
|
+
start_line=args.start_line,
|
|
150
|
+
start_side=args.start_side
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
print(f"\n✅ Comment added successfully!")
|
|
154
|
+
print(f"Comment ID: {response.get('id')}")
|
|
155
|
+
print(f"URL: {response.get('html_url')}")
|
|
156
|
+
|
|
157
|
+
except Exception as e:
|
|
158
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
159
|
+
sys.exit(1)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
if __name__ == '__main__':
|
|
163
|
+
main()
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Fetch GitHub PR data using gh CLI and organize it for review.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python fetch_pr_data.py <pr_url> [--output-dir <dir>]
|
|
7
|
+
|
|
8
|
+
Example:
|
|
9
|
+
python fetch_pr_data.py https://github.com/owner/repo/pull/123
|
|
10
|
+
python fetch_pr_data.py https://github.com/owner/repo/pull/123 --output-dir /tmp/custom
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import subprocess
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Dict, List, Optional, Tuple
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_pr_url(pr_url: str) -> Tuple[str, str, str]:
|
|
24
|
+
"""
|
|
25
|
+
Parse GitHub PR URL to extract owner, repo, and PR number.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
pr_url: GitHub PR URL (e.g., https://github.com/owner/repo/pull/123)
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
Tuple of (owner, repo, pr_number)
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
ValueError: If URL format is invalid
|
|
35
|
+
"""
|
|
36
|
+
pattern = r'github\.com/([^/]+)/([^/]+)/pull/(\d+)'
|
|
37
|
+
match = re.search(pattern, pr_url)
|
|
38
|
+
|
|
39
|
+
if not match:
|
|
40
|
+
raise ValueError(f"Invalid GitHub PR URL: {pr_url}")
|
|
41
|
+
|
|
42
|
+
return match.group(1), match.group(2), match.group(3)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def run_gh_command(args: List[str]) -> str:
|
|
46
|
+
"""
|
|
47
|
+
Run gh CLI command and return output.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
args: Command arguments to pass to gh
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Command output as string
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
RuntimeError: If gh command fails
|
|
57
|
+
"""
|
|
58
|
+
try:
|
|
59
|
+
result = subprocess.run(
|
|
60
|
+
['gh'] + args,
|
|
61
|
+
capture_output=True,
|
|
62
|
+
text=True,
|
|
63
|
+
check=True
|
|
64
|
+
)
|
|
65
|
+
return result.stdout
|
|
66
|
+
except subprocess.CalledProcessError as e:
|
|
67
|
+
raise RuntimeError(f"gh command failed: {e.stderr}")
|
|
68
|
+
except FileNotFoundError:
|
|
69
|
+
raise RuntimeError("gh CLI not found. Please install: https://cli.github.com/")
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def fetch_pr_metadata(owner: str, repo: str, pr_number: str) -> Dict:
|
|
73
|
+
"""Fetch PR metadata using gh pr view."""
|
|
74
|
+
repo_spec = f"{owner}/{repo}"
|
|
75
|
+
output = run_gh_command([
|
|
76
|
+
'pr', 'view', pr_number,
|
|
77
|
+
'--repo', repo_spec,
|
|
78
|
+
'--json', 'number,title,body,state,author,headRefName,baseRefName,commits,reviews,comments,files,labels,assignees,milestone,createdAt,updatedAt,mergedAt,closedAt,url,isDraft'
|
|
79
|
+
])
|
|
80
|
+
return json.loads(output)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def fetch_pr_diff(owner: str, repo: str, pr_number: str) -> str:
|
|
84
|
+
"""Fetch PR diff using gh pr diff."""
|
|
85
|
+
repo_spec = f"{owner}/{repo}"
|
|
86
|
+
return run_gh_command(['pr', 'diff', pr_number, '--repo', repo_spec])
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def fetch_pr_comments(owner: str, repo: str, pr_number: str) -> List[Dict]:
|
|
90
|
+
"""Fetch PR review comments."""
|
|
91
|
+
repo_spec = f"{owner}/{repo}"
|
|
92
|
+
output = run_gh_command([
|
|
93
|
+
'api',
|
|
94
|
+
f'/repos/{owner}/{repo}/pulls/{pr_number}/comments',
|
|
95
|
+
'--paginate'
|
|
96
|
+
])
|
|
97
|
+
return json.loads(output)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def fetch_commits(owner: str, repo: str, pr_number: str) -> List[Dict]:
|
|
101
|
+
"""Fetch commit details for the PR."""
|
|
102
|
+
repo_spec = f"{owner}/{repo}"
|
|
103
|
+
output = run_gh_command([
|
|
104
|
+
'api',
|
|
105
|
+
f'/repos/{owner}/{repo}/pulls/{pr_number}/commits',
|
|
106
|
+
'--paginate'
|
|
107
|
+
])
|
|
108
|
+
return json.loads(output)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def extract_ticket_numbers(text: str) -> List[str]:
|
|
112
|
+
"""
|
|
113
|
+
Extract ticket/issue numbers from text.
|
|
114
|
+
Looks for patterns like: JIRA-123, #123, PROJ-456, etc.
|
|
115
|
+
"""
|
|
116
|
+
patterns = [
|
|
117
|
+
r'#(\d+)', # GitHub issues: #123
|
|
118
|
+
r'([A-Z]+-\d+)', # JIRA style: PROJ-123
|
|
119
|
+
r'([A-Z]{2,}-\d+)', # Generic ticket: ABC-123
|
|
120
|
+
]
|
|
121
|
+
|
|
122
|
+
tickets = []
|
|
123
|
+
for pattern in patterns:
|
|
124
|
+
matches = re.findall(pattern, text)
|
|
125
|
+
tickets.extend(matches)
|
|
126
|
+
|
|
127
|
+
return list(set(tickets)) # Remove duplicates
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def fetch_github_issue(owner: str, repo: str, issue_number: str) -> Optional[Dict]:
|
|
131
|
+
"""Fetch GitHub issue details if it exists."""
|
|
132
|
+
try:
|
|
133
|
+
output = run_gh_command([
|
|
134
|
+
'api',
|
|
135
|
+
f'/repos/{owner}/{repo}/issues/{issue_number.lstrip("#")}'
|
|
136
|
+
])
|
|
137
|
+
return json.loads(output)
|
|
138
|
+
except RuntimeError:
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def setup_pr_review_dir(base_dir: str, repo: str, pr_number: str) -> Path:
|
|
143
|
+
"""Create and return the PR review directory."""
|
|
144
|
+
pr_review_dir = Path(base_dir) / 'PRs' / repo / pr_number
|
|
145
|
+
pr_review_dir.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
return pr_review_dir
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def clone_pr_branch(owner: str, repo: str, branch: str, target_dir: Path) -> None:
|
|
150
|
+
"""Clone the PR source branch into target directory."""
|
|
151
|
+
repo_url = f"https://github.com/{owner}/{repo}.git"
|
|
152
|
+
clone_dir = target_dir / "source"
|
|
153
|
+
|
|
154
|
+
if clone_dir.exists():
|
|
155
|
+
print(f"Repository already cloned at {clone_dir}, pulling latest...")
|
|
156
|
+
subprocess.run(['git', '-C', str(clone_dir), 'pull'], check=True)
|
|
157
|
+
else:
|
|
158
|
+
print(f"Cloning {repo_url} branch {branch}...")
|
|
159
|
+
subprocess.run([
|
|
160
|
+
'git', 'clone',
|
|
161
|
+
'--branch', branch,
|
|
162
|
+
'--single-branch',
|
|
163
|
+
repo_url,
|
|
164
|
+
str(clone_dir)
|
|
165
|
+
], check=True)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def get_branch_diff(clone_dir: Path, base_branch: str, head_branch: str) -> str:
|
|
169
|
+
"""Get git diff between base and head branches."""
|
|
170
|
+
# Fetch base branch if not already present
|
|
171
|
+
subprocess.run([
|
|
172
|
+
'git', '-C', str(clone_dir),
|
|
173
|
+
'fetch', 'origin', f'{base_branch}:{base_branch}'
|
|
174
|
+
], check=False) # Don't fail if branch exists
|
|
175
|
+
|
|
176
|
+
result = subprocess.run([
|
|
177
|
+
'git', '-C', str(clone_dir),
|
|
178
|
+
'diff', f'origin/{base_branch}...{head_branch}'
|
|
179
|
+
], capture_output=True, text=True, check=True)
|
|
180
|
+
|
|
181
|
+
return result.stdout
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def save_data(pr_review_dir: Path, data: Dict) -> None:
|
|
185
|
+
"""Save all fetched data to JSON files in the PR review directory."""
|
|
186
|
+
for filename, content in data.items():
|
|
187
|
+
filepath = pr_review_dir / filename
|
|
188
|
+
|
|
189
|
+
if filename.endswith('.json'):
|
|
190
|
+
with open(filepath, 'w') as f:
|
|
191
|
+
json.dump(content, f, indent=2)
|
|
192
|
+
else:
|
|
193
|
+
with open(filepath, 'w') as f:
|
|
194
|
+
f.write(content)
|
|
195
|
+
|
|
196
|
+
print(f"Saved: {filepath}")
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def main():
|
|
200
|
+
parser = argparse.ArgumentParser(
|
|
201
|
+
description='Fetch GitHub PR data for code review',
|
|
202
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
203
|
+
epilog=__doc__
|
|
204
|
+
)
|
|
205
|
+
parser.add_argument('pr_url', help='GitHub PR URL')
|
|
206
|
+
parser.add_argument('--output-dir', default='/tmp',
|
|
207
|
+
help='Base output directory (default: /tmp)')
|
|
208
|
+
parser.add_argument('--no-clone', action='store_true',
|
|
209
|
+
help='Skip cloning the repository')
|
|
210
|
+
|
|
211
|
+
args = parser.parse_args()
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
# Parse PR URL
|
|
215
|
+
owner, repo, pr_number = parse_pr_url(args.pr_url)
|
|
216
|
+
print(f"Fetching PR #{pr_number} from {owner}/{repo}...")
|
|
217
|
+
|
|
218
|
+
# Setup review directory
|
|
219
|
+
pr_review_dir = setup_pr_review_dir(args.output_dir, repo, pr_number)
|
|
220
|
+
print(f"PR review directory: {pr_review_dir}")
|
|
221
|
+
|
|
222
|
+
# Fetch PR metadata
|
|
223
|
+
print("Fetching PR metadata...")
|
|
224
|
+
metadata = fetch_pr_metadata(owner, repo, pr_number)
|
|
225
|
+
|
|
226
|
+
# Fetch PR diff
|
|
227
|
+
print("Fetching PR diff...")
|
|
228
|
+
diff = fetch_pr_diff(owner, repo, pr_number)
|
|
229
|
+
|
|
230
|
+
# Fetch comments
|
|
231
|
+
print("Fetching PR comments...")
|
|
232
|
+
comments = fetch_pr_comments(owner, repo, pr_number)
|
|
233
|
+
|
|
234
|
+
# Fetch commits
|
|
235
|
+
print("Fetching commit history...")
|
|
236
|
+
commits = fetch_commits(owner, repo, pr_number)
|
|
237
|
+
|
|
238
|
+
# Extract and fetch ticket information
|
|
239
|
+
print("Extracting ticket references...")
|
|
240
|
+
all_text = f"{metadata.get('title', '')} {metadata.get('body', '')}"
|
|
241
|
+
for commit in commits:
|
|
242
|
+
all_text += f" {commit.get('commit', {}).get('message', '')}"
|
|
243
|
+
|
|
244
|
+
ticket_numbers = extract_ticket_numbers(all_text)
|
|
245
|
+
related_issues = {}
|
|
246
|
+
|
|
247
|
+
for ticket in ticket_numbers:
|
|
248
|
+
if ticket.startswith('#') or ticket.isdigit():
|
|
249
|
+
issue_num = ticket.lstrip('#')
|
|
250
|
+
print(f"Fetching GitHub issue #{issue_num}...")
|
|
251
|
+
issue = fetch_github_issue(owner, repo, issue_num)
|
|
252
|
+
if issue:
|
|
253
|
+
related_issues[ticket] = issue
|
|
254
|
+
|
|
255
|
+
# Clone repository and get diff (optional)
|
|
256
|
+
git_diff = ""
|
|
257
|
+
if not args.no_clone:
|
|
258
|
+
try:
|
|
259
|
+
print("Cloning repository...")
|
|
260
|
+
clone_pr_branch(owner, repo, metadata['headRefName'], pr_review_dir)
|
|
261
|
+
|
|
262
|
+
print("Generating git diff...")
|
|
263
|
+
clone_dir = pr_review_dir / "source"
|
|
264
|
+
git_diff = get_branch_diff(
|
|
265
|
+
clone_dir,
|
|
266
|
+
metadata['baseRefName'],
|
|
267
|
+
metadata['headRefName']
|
|
268
|
+
)
|
|
269
|
+
except Exception as e:
|
|
270
|
+
print(f"Warning: Could not clone repository: {e}")
|
|
271
|
+
|
|
272
|
+
# Save all data
|
|
273
|
+
print("\nSaving data...")
|
|
274
|
+
data = {
|
|
275
|
+
'metadata.json': metadata,
|
|
276
|
+
'diff.patch': diff,
|
|
277
|
+
'comments.json': comments,
|
|
278
|
+
'commits.json': commits,
|
|
279
|
+
'related_issues.json': related_issues,
|
|
280
|
+
'ticket_numbers.json': ticket_numbers,
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if git_diff:
|
|
284
|
+
data['git_diff.patch'] = git_diff
|
|
285
|
+
|
|
286
|
+
save_data(pr_review_dir, data)
|
|
287
|
+
|
|
288
|
+
# Create summary file
|
|
289
|
+
summary = f"""PR Review Summary
|
|
290
|
+
==================
|
|
291
|
+
|
|
292
|
+
Repository: {owner}/{repo}
|
|
293
|
+
PR Number: #{pr_number}
|
|
294
|
+
Title: {metadata.get('title', 'N/A')}
|
|
295
|
+
Author: {metadata.get('author', {}).get('login', 'N/A')}
|
|
296
|
+
State: {metadata.get('state', 'N/A')}
|
|
297
|
+
Draft: {metadata.get('isDraft', False)}
|
|
298
|
+
|
|
299
|
+
Branches:
|
|
300
|
+
Source: {metadata.get('headRefName', 'N/A')}
|
|
301
|
+
Target: {metadata.get('baseRefName', 'N/A')}
|
|
302
|
+
|
|
303
|
+
Files Changed: {len(metadata.get('files', []))}
|
|
304
|
+
Commits: {len(commits)}
|
|
305
|
+
Comments: {len(comments)}
|
|
306
|
+
Reviews: {len(metadata.get('reviews', []))}
|
|
307
|
+
|
|
308
|
+
Related Tickets:
|
|
309
|
+
{chr(10).join(f" - {ticket}" for ticket in ticket_numbers) if ticket_numbers else " None found"}
|
|
310
|
+
|
|
311
|
+
Review Directory: {pr_review_dir}
|
|
312
|
+
"""
|
|
313
|
+
|
|
314
|
+
summary_file = pr_review_dir / 'SUMMARY.txt'
|
|
315
|
+
with open(summary_file, 'w') as f:
|
|
316
|
+
f.write(summary)
|
|
317
|
+
|
|
318
|
+
print(f"\n{summary}")
|
|
319
|
+
print(f"\nAll data saved to: {pr_review_dir}")
|
|
320
|
+
|
|
321
|
+
except Exception as e:
|
|
322
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
323
|
+
sys.exit(1)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
if __name__ == '__main__':
|
|
327
|
+
main()
|