@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.
@@ -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()