@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,480 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Generate structured review files from PR analysis.
4
+
5
+ Creates three review files:
6
+ - pr/review.md: Detailed review for internal use
7
+ - pr/human.md: Short, clean review for posting (no emojis, em-dashes, line numbers)
8
+ - pr/inline.md: List of inline comments with code snippets
9
+
10
+ Usage:
11
+ python generate_review_files.py <pr_review_dir> --findings <findings_json>
12
+
13
+ Example:
14
+ python generate_review_files.py /tmp/PRs/myrepo/123 --findings findings.json
15
+ """
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import sys
21
+ from pathlib import Path
22
+ from typing import Dict, List, Any
23
+
24
+
25
+ def create_pr_directory(pr_review_dir: Path) -> Path:
26
+ """Create the pr/ subdirectory for review files."""
27
+ pr_dir = pr_review_dir / "pr"
28
+ pr_dir.mkdir(parents=True, exist_ok=True)
29
+ return pr_dir
30
+
31
+
32
+ def load_findings(findings_file: str) -> Dict[str, Any]:
33
+ """
34
+ Load review findings from JSON file.
35
+
36
+ Expected structure:
37
+ {
38
+ "summary": "Overall assessment...",
39
+ "blockers": [{
40
+ "category": "Security",
41
+ "issue": "SQL injection vulnerability",
42
+ "file": "src/db/queries.py",
43
+ "line": 45,
44
+ "details": "Using string concatenation...",
45
+ "fix": "Use parameterized queries",
46
+ "code_snippet": "result = db.execute(...)"
47
+ }],
48
+ "important": [...],
49
+ "nits": [...],
50
+ "suggestions": [...],
51
+ "questions": [...],
52
+ "praise": [...],
53
+ "inline_comments": [{
54
+ "file": "src/app.py",
55
+ "line": 42,
56
+ "comment": "Consider edge case handling",
57
+ "code_snippet": "def process(data):\n return data.strip()",
58
+ "start_line": 41,
59
+ "end_line": 43
60
+ }]
61
+ }
62
+ """
63
+ with open(findings_file, 'r') as f:
64
+ return json.load(f)
65
+
66
+
67
+ def generate_detailed_review(findings: Dict[str, Any], metadata: Dict[str, Any]) -> str:
68
+ """Generate detailed review.md with full analysis."""
69
+
70
+ review = f"""# Pull Request Review - Detailed Analysis
71
+
72
+ ## PR Information
73
+
74
+ **Repository**: {metadata.get('repository', 'N/A')}
75
+ **PR Number**: #{metadata.get('number', 'N/A')}
76
+ **Title**: {metadata.get('title', 'N/A')}
77
+ **Author**: {metadata.get('author', 'N/A')}
78
+ **Branch**: {metadata.get('head_branch', 'N/A')} → {metadata.get('base_branch', 'N/A')}
79
+
80
+ ## Summary
81
+
82
+ {findings.get('summary', 'No summary provided')}
83
+
84
+ """
85
+
86
+ # Add blockers
87
+ blockers = findings.get('blockers', [])
88
+ if blockers:
89
+ review += "## 🔴 Critical Issues (Blockers)\n\n"
90
+ review += "**These MUST be fixed before merging.**\n\n"
91
+ for i, blocker in enumerate(blockers, 1):
92
+ review += f"### {i}. {blocker.get('category', 'Issue')}: {blocker.get('issue', 'Unknown')}\n\n"
93
+ if blocker.get('file'):
94
+ review += f"**File**: `{blocker['file']}"
95
+ if blocker.get('line'):
96
+ review += f":{blocker['line']}"
97
+ review += "`\n\n"
98
+ review += f"**Problem**: {blocker.get('details', 'No details')}\n\n"
99
+ if blocker.get('fix'):
100
+ review += f"**Solution**: {blocker['fix']}\n\n"
101
+ if blocker.get('code_snippet'):
102
+ review += f"**Current Code**:\n```\n{blocker['code_snippet']}\n```\n\n"
103
+ review += "---\n\n"
104
+
105
+ # Add important issues
106
+ important = findings.get('important', [])
107
+ if important:
108
+ review += "## 🟡 Important Issues\n\n"
109
+ review += "**Should be addressed before merging.**\n\n"
110
+ for i, issue in enumerate(important, 1):
111
+ review += f"### {i}. {issue.get('category', 'Issue')}: {issue.get('issue', 'Unknown')}\n\n"
112
+ if issue.get('file'):
113
+ review += f"**File**: `{issue['file']}"
114
+ if issue.get('line'):
115
+ review += f":{issue['line']}"
116
+ review += "`\n\n"
117
+ review += f"**Impact**: {issue.get('details', 'No details')}\n\n"
118
+ if issue.get('fix'):
119
+ review += f"**Suggestion**: {issue['fix']}\n\n"
120
+ if issue.get('code_snippet'):
121
+ review += f"**Code**:\n```\n{issue['code_snippet']}\n```\n\n"
122
+ review += "---\n\n"
123
+
124
+ # Add nits
125
+ nits = findings.get('nits', [])
126
+ if nits:
127
+ review += "## 🟢 Minor Issues (Nits)\n\n"
128
+ review += "**Nice to have, but not blocking.**\n\n"
129
+ for i, nit in enumerate(nits, 1):
130
+ review += f"{i}. **{nit.get('category', 'Style')}**: {nit.get('issue', 'Unknown')}\n"
131
+ if nit.get('file'):
132
+ review += f" - File: `{nit['file']}`\n"
133
+ if nit.get('details'):
134
+ review += f" - {nit['details']}\n"
135
+ review += "\n"
136
+
137
+ # Add suggestions
138
+ suggestions = findings.get('suggestions', [])
139
+ if suggestions:
140
+ review += "## 💡 Suggestions for Future\n\n"
141
+ for i, suggestion in enumerate(suggestions, 1):
142
+ review += f"{i}. {suggestion}\n"
143
+ review += "\n"
144
+
145
+ # Add questions
146
+ questions = findings.get('questions', [])
147
+ if questions:
148
+ review += "## ❓ Questions / Clarifications Needed\n\n"
149
+ for i, question in enumerate(questions, 1):
150
+ review += f"{i}. {question}\n"
151
+ review += "\n"
152
+
153
+ # Add praise
154
+ praise = findings.get('praise', [])
155
+ if praise:
156
+ review += "## ✅ Positive Notes\n\n"
157
+ for item in praise:
158
+ review += f"- {item}\n"
159
+ review += "\n"
160
+
161
+ # Add overall recommendation
162
+ review += "## Overall Recommendation\n\n"
163
+ if blockers:
164
+ review += "**Request Changes** - Critical issues must be addressed.\n"
165
+ elif important:
166
+ review += "**Request Changes** - Important issues should be fixed.\n"
167
+ else:
168
+ review += "**Approve** - Looks good! Minor nits can be addressed optionally.\n"
169
+
170
+ return review
171
+
172
+
173
+ def generate_human_review(findings: Dict[str, Any], metadata: Dict[str, Any]) -> str:
174
+ """
175
+ Generate short, clean human.md for posting.
176
+
177
+ Rules:
178
+ - No emojis
179
+ - No em dashes (use regular hyphens)
180
+ - No code line numbers
181
+ - Concise and professional
182
+ """
183
+
184
+ def clean_text(text: str) -> str:
185
+ """Remove em-dashes and replace with regular hyphens."""
186
+ if not text:
187
+ return text
188
+ # Replace em dash (—) with regular hyphen (-)
189
+ # Also replace en dash (–) with regular hyphen
190
+ return text.replace('—', '-').replace('–', '-')
191
+
192
+ title = clean_text(metadata.get('title', 'N/A'))
193
+ summary = clean_text(findings.get('summary', 'No summary provided'))
194
+
195
+ review = f"""# Code Review
196
+
197
+ **PR #{metadata.get('number', 'N/A')}**: {title}
198
+
199
+ ## Summary
200
+
201
+ {summary}
202
+
203
+ """
204
+
205
+ # Add blockers - no emojis
206
+ blockers = findings.get('blockers', [])
207
+ if blockers:
208
+ review += "## Critical Issues - Must Fix\n\n"
209
+ for i, blocker in enumerate(blockers, 1):
210
+ # No emojis, no em dashes, no line numbers
211
+ issue = clean_text(blocker.get('issue', 'Issue'))
212
+ details = clean_text(blocker.get('details', 'No details'))
213
+ fix = clean_text(blocker.get('fix', ''))
214
+
215
+ review += f"{i}. **{issue}**\n"
216
+ if blocker.get('file'):
217
+ # File path without line number
218
+ review += f" - File: `{blocker['file']}`\n"
219
+ review += f" - {details}\n"
220
+ if fix:
221
+ review += f" - Fix: {fix}\n"
222
+ review += "\n"
223
+
224
+ # Add important issues
225
+ important = findings.get('important', [])
226
+ if important:
227
+ review += "## Important Issues - Should Fix\n\n"
228
+ for i, issue_item in enumerate(important, 1):
229
+ issue = clean_text(issue_item.get('issue', 'Issue'))
230
+ details = clean_text(issue_item.get('details', 'No details'))
231
+ fix = clean_text(issue_item.get('fix', ''))
232
+
233
+ review += f"{i}. **{issue}**\n"
234
+ if issue_item.get('file'):
235
+ review += f" - File: `{issue_item['file']}`\n"
236
+ review += f" - {details}\n"
237
+ if fix:
238
+ review += f" - Suggestion: {fix}\n"
239
+ review += "\n"
240
+
241
+ # Add nits - keep brief
242
+ nits = findings.get('nits', [])
243
+ if nits and len(nits) <= 3: # Only include if few
244
+ review += "## Minor Issues\n\n"
245
+ for i, nit in enumerate(nits, 1):
246
+ issue = clean_text(nit.get('issue', 'Issue'))
247
+ review += f"{i}. {issue}"
248
+ if nit.get('file'):
249
+ review += f" in `{nit['file']}`"
250
+ review += "\n"
251
+ review += "\n"
252
+
253
+ # Add praise
254
+ praise = findings.get('praise', [])
255
+ if praise:
256
+ review += "## Positive Notes\n\n"
257
+ for item in praise:
258
+ clean_item = clean_text(item)
259
+ review += f"- {clean_item}\n"
260
+ review += "\n"
261
+
262
+ # Add overall recommendation - no emojis
263
+ if blockers:
264
+ review += "## Recommendation\n\nRequest changes - critical issues need to be addressed before merging.\n"
265
+ elif important:
266
+ review += "## Recommendation\n\nRequest changes - please address the important issues listed above.\n"
267
+ else:
268
+ review += "## Recommendation\n\nApprove - the code looks good. Minor items can be addressed optionally.\n"
269
+
270
+ return review
271
+
272
+
273
+ def generate_inline_comments_file(findings: Dict[str, Any]) -> str:
274
+ """
275
+ Generate inline.md with list of proposed inline comments.
276
+
277
+ Includes code snippets with line number headers.
278
+ """
279
+
280
+ inline_comments = findings.get('inline_comments', [])
281
+
282
+ if not inline_comments:
283
+ return "# Inline Comments\n\nNo inline comments proposed.\n"
284
+
285
+ content = "# Proposed Inline Comments\n\n"
286
+ content += f"**Total Comments**: {len(inline_comments)}\n\n"
287
+ content += "Review these before posting. Edit as needed.\n\n"
288
+ content += "---\n\n"
289
+
290
+ for i, comment in enumerate(inline_comments, 1):
291
+ content += f"## Comment {i}\n\n"
292
+ content += f"**File**: `{comment.get('file', 'unknown')}`\n"
293
+ content += f"**Line**: {comment.get('line', 'N/A')}\n"
294
+
295
+ if comment.get('start_line') and comment.get('end_line'):
296
+ content += f"**Range**: Lines {comment['start_line']}-{comment['end_line']}\n"
297
+
298
+ content += f"\n**Comment**:\n{comment.get('comment', 'No comment')}\n\n"
299
+
300
+ if comment.get('code_snippet'):
301
+ # Add line numbers in header
302
+ start = comment.get('start_line', comment.get('line', 1))
303
+ end = comment.get('end_line', comment.get('line', 1))
304
+
305
+ if start == end:
306
+ content += f"**Code (Line {start})**:\n"
307
+ else:
308
+ content += f"**Code (Lines {start}-{end})**:\n"
309
+
310
+ content += f"```\n{comment['code_snippet']}\n```\n\n"
311
+
312
+ # Add command to post this comment
313
+ owner = comment.get('owner', 'OWNER')
314
+ repo = comment.get('repo', 'REPO')
315
+ pr_num = comment.get('pr_number', 'PR_NUM')
316
+
317
+ content += "**Command to post**:\n```bash\n"
318
+ content += f"python scripts/add_inline_comment.py {owner} {repo} {pr_num} latest \\\n"
319
+ content += f" \"{comment.get('file', 'file.py')}\" {comment.get('line', 42)} \\\n"
320
+ content += f" \"{comment.get('comment', 'comment')}\"\n"
321
+ content += "```\n\n"
322
+ content += "---\n\n"
323
+
324
+ return content
325
+
326
+
327
+ def generate_claude_commands(pr_review_dir: Path, metadata: Dict[str, Any]):
328
+ """Generate .claude directory with custom slash commands."""
329
+
330
+ claude_dir = pr_review_dir / ".claude" / "commands"
331
+ claude_dir.mkdir(parents=True, exist_ok=True)
332
+
333
+ owner = metadata.get('owner', 'owner')
334
+ repo = metadata.get('repo', 'repo')
335
+ pr_number = metadata.get('number', '123')
336
+
337
+ # /send command - approve and post human.md
338
+ send_cmd = f"""Post the human-friendly review and approve the PR.
339
+
340
+ Steps:
341
+ 1. Read the file `pr/human.md` in the current directory
342
+ 2. Post the review content as a PR comment using:
343
+ `gh pr comment {pr_number} --repo {owner}/{repo} --body-file pr/human.md`
344
+ 3. Approve the PR using:
345
+ `gh pr review {pr_number} --repo {owner}/{repo} --approve`
346
+ 4. Confirm to the user that the review was posted and PR was approved
347
+ """
348
+
349
+ with open(claude_dir / "send.md", 'w') as f:
350
+ f.write(send_cmd)
351
+
352
+ # /send-decline command - request changes and post human.md
353
+ send_decline_cmd = f"""Post the human-friendly review and request changes on the PR.
354
+
355
+ Steps:
356
+ 1. Read the file `pr/human.md` in the current directory
357
+ 2. Post the review content as a PR comment using:
358
+ `gh pr comment {pr_number} --repo {owner}/{repo} --body-file pr/human.md`
359
+ 3. Request changes on the PR using:
360
+ `gh pr review {pr_number} --repo {owner}/{repo} --request-changes`
361
+ 4. Confirm to the user that the review was posted and changes were requested
362
+ """
363
+
364
+ with open(claude_dir / "send-decline.md", 'w') as f:
365
+ f.write(send_decline_cmd)
366
+
367
+ # /show command - open in VS Code
368
+ show_cmd = f"""Open the PR review directory in VS Code for editing.
369
+
370
+ Steps:
371
+ 1. Run `code .` to open the current directory in VS Code
372
+ 2. Tell the user they can now edit the review files:
373
+ - pr/review.md (detailed review)
374
+ - pr/human.md (short review for posting)
375
+ - pr/inline.md (inline comments)
376
+ 3. Remind them to use /send or /send-decline when ready to post
377
+ """
378
+
379
+ with open(claude_dir / "show.md", 'w') as f:
380
+ f.write(show_cmd)
381
+
382
+ print(f"✅ Created slash commands in {claude_dir}")
383
+ print(" - /send (approve and post)")
384
+ print(" - /send-decline (request changes and post)")
385
+ print(" - /show (open in VS Code)")
386
+
387
+
388
+ def main():
389
+ parser = argparse.ArgumentParser(
390
+ description='Generate structured review files from PR analysis',
391
+ formatter_class=argparse.RawDescriptionHelpFormatter,
392
+ epilog=__doc__
393
+ )
394
+ parser.add_argument('pr_review_dir', help='PR review directory path')
395
+ parser.add_argument('--findings', required=True, help='JSON file with review findings')
396
+ parser.add_argument('--metadata', help='JSON file with PR metadata (optional)')
397
+
398
+ args = parser.parse_args()
399
+
400
+ try:
401
+ # Load findings
402
+ findings = load_findings(args.findings)
403
+
404
+ # Load metadata if provided
405
+ metadata = {}
406
+ if args.metadata and os.path.exists(args.metadata):
407
+ with open(args.metadata, 'r') as f:
408
+ metadata = json.load(f)
409
+
410
+ # Extract metadata from findings if not provided
411
+ if not metadata:
412
+ metadata = findings.get('metadata', {})
413
+
414
+ # Create pr directory
415
+ pr_review_dir = Path(args.pr_review_dir)
416
+ pr_dir = create_pr_directory(pr_review_dir)
417
+
418
+ print(f"📝 Generating review files in {pr_dir}...")
419
+
420
+ # Generate detailed review
421
+ detailed_review = generate_detailed_review(findings, metadata)
422
+ review_file = pr_dir / "review.md"
423
+ with open(review_file, 'w') as f:
424
+ f.write(detailed_review)
425
+ print(f"✅ Created detailed review: {review_file}")
426
+
427
+ # Generate human-friendly review
428
+ human_review = generate_human_review(findings, metadata)
429
+ human_file = pr_dir / "human.md"
430
+ with open(human_file, 'w') as f:
431
+ f.write(human_review)
432
+ print(f"✅ Created human review: {human_file}")
433
+
434
+ # Generate inline comments file
435
+ inline_comments = generate_inline_comments_file(findings)
436
+ inline_file = pr_dir / "inline.md"
437
+ with open(inline_file, 'w') as f:
438
+ f.write(inline_comments)
439
+ print(f"✅ Created inline comments: {inline_file}")
440
+
441
+ # Generate Claude slash commands
442
+ generate_claude_commands(pr_review_dir, metadata)
443
+
444
+ # Create summary file
445
+ summary = f"""PR Review Files Generated
446
+ ========================
447
+
448
+ Directory: {pr_review_dir}
449
+
450
+ Files created:
451
+ - pr/review.md - Detailed analysis for your review
452
+ - pr/human.md - Clean version for posting (no emojis, no line numbers)
453
+ - pr/inline.md - Proposed inline comments with code snippets
454
+
455
+ Slash commands available:
456
+ - /send - Post human.md and approve PR
457
+ - /send-decline - Post human.md and request changes
458
+ - /show - Open directory in VS Code
459
+
460
+ Next steps:
461
+ 1. Review the files (use /show to open in VS Code)
462
+ 2. Edit as needed
463
+ 3. Use /send or /send-decline when ready to post
464
+
465
+ IMPORTANT: Nothing will be posted until you run /send or /send-decline
466
+ """
467
+
468
+ summary_file = pr_review_dir / "REVIEW_READY.txt"
469
+ with open(summary_file, 'w') as f:
470
+ f.write(summary)
471
+
472
+ print(f"\n{summary}")
473
+
474
+ except Exception as e:
475
+ print(f"Error: {e}", file=sys.stderr)
476
+ sys.exit(1)
477
+
478
+
479
+ if __name__ == '__main__':
480
+ main()