pullwise 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {pullwise-0.2.0 → pullwise-0.3.0}/PKG-INFO +27 -3
- {pullwise-0.2.0 → pullwise-0.3.0}/README.md +26 -1
- pullwise-0.3.0/pr_pilot/__init__.py +1 -0
- {pullwise-0.2.0 → pullwise-0.3.0}/pr_pilot/analyzer.py +160 -0
- {pullwise-0.2.0 → pullwise-0.3.0}/pr_pilot/cli.py +117 -1
- {pullwise-0.2.0 → pullwise-0.3.0}/pr_pilot/templates.py +84 -0
- {pullwise-0.2.0 → pullwise-0.3.0}/pullwise.egg-info/PKG-INFO +27 -3
- {pullwise-0.2.0 → pullwise-0.3.0}/pyproject.toml +4 -2
- {pullwise-0.2.0 → pullwise-0.3.0}/tests/test_analyzer.py +80 -2
- pullwise-0.2.0/pr_pilot/__init__.py +0 -1
- {pullwise-0.2.0 → pullwise-0.3.0}/pr_pilot/github_client.py +0 -0
- {pullwise-0.2.0 → pullwise-0.3.0}/pullwise.egg-info/SOURCES.txt +0 -0
- {pullwise-0.2.0 → pullwise-0.3.0}/pullwise.egg-info/dependency_links.txt +0 -0
- {pullwise-0.2.0 → pullwise-0.3.0}/pullwise.egg-info/entry_points.txt +0 -0
- {pullwise-0.2.0 → pullwise-0.3.0}/pullwise.egg-info/requires.txt +0 -0
- {pullwise-0.2.0 → pullwise-0.3.0}/pullwise.egg-info/top_level.txt +0 -0
- {pullwise-0.2.0 → pullwise-0.3.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pullwise
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: AI-powered PR descriptions, labels, and code review using OpenAI
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/albertusreza/pr-pilot
|
|
@@ -10,7 +10,6 @@ Keywords: github,pull-request,openai,ai,automation,devtools,cli,github-actions
|
|
|
10
10
|
Classifier: Development Status :: 3 - Alpha
|
|
11
11
|
Classifier: Environment :: Console
|
|
12
12
|
Classifier: Intended Audience :: Developers
|
|
13
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
14
13
|
Classifier: Programming Language :: Python :: 3
|
|
15
14
|
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
16
15
|
Requires-Python: >=3.9
|
|
@@ -19,7 +18,7 @@ Requires-Dist: openai>=1.0.0
|
|
|
19
18
|
Provides-Extra: dev
|
|
20
19
|
Requires-Dist: pytest; extra == "dev"
|
|
21
20
|
|
|
22
|
-
#
|
|
21
|
+
# PullWise ✈️
|
|
23
22
|
|
|
24
23
|
[](https://pypi.org/project/pullwise/)
|
|
25
24
|
[](https://pypi.org/project/pullwise/)
|
|
@@ -28,6 +27,8 @@ Requires-Dist: pytest; extra == "dev"
|
|
|
28
27
|
|
|
29
28
|
**Stop writing PR descriptions.** Let AI do it.
|
|
30
29
|
|
|
30
|
+
> 📦 GitHub Action: [`albertusreza/pr-pilot`](https://github.com/marketplace/actions/pullwise) · PyPI: [`pullwise`](https://pypi.org/project/pullwise/)
|
|
31
|
+
|
|
31
32
|
`pr-pilot` is a GitHub Action + CLI that analyzes your diff and commit history, then automatically writes a clear, structured PR description — with a summary, change list, test plan, and labels.
|
|
32
33
|
|
|
33
34
|
```yaml
|
|
@@ -116,6 +117,9 @@ pr-pilot describe --markdown pr_description.md
|
|
|
116
117
|
| `pr-pilot review` | Get a senior-engineer-style code review in the terminal |
|
|
117
118
|
| `pr-pilot comment` | Post an AI review as a GitHub PR comment (updates on re-run, no spam) |
|
|
118
119
|
| `pr-pilot changelog` | Generate a `CHANGELOG.md` entry from commits since last tag |
|
|
120
|
+
| `pr-pilot reviewers` | Suggest reviewers based on git blame of changed files |
|
|
121
|
+
| `pr-pilot standup` | Generate a daily standup update from your recent commits |
|
|
122
|
+
| `pr-pilot todos` | Scan for TODO/FIXME comments and create GitHub issues from them |
|
|
119
123
|
|
|
120
124
|
## Usage
|
|
121
125
|
|
|
@@ -136,6 +140,26 @@ pr-pilot changelog
|
|
|
136
140
|
pr-pilot changelog --output CHANGELOG.md
|
|
137
141
|
```
|
|
138
142
|
|
|
143
|
+
```bash
|
|
144
|
+
# Suggest reviewers for your current branch
|
|
145
|
+
pr-pilot reviewers --base main
|
|
146
|
+
|
|
147
|
+
# Post suggestion as a comment + assign on GitHub
|
|
148
|
+
pr-pilot reviewers --post --assign --repo owner/repo --pr 42
|
|
149
|
+
|
|
150
|
+
# Generate a standup from yesterday's commits
|
|
151
|
+
pr-pilot standup
|
|
152
|
+
|
|
153
|
+
# Generate from the last 3 days and copy to clipboard
|
|
154
|
+
pr-pilot standup --days 3 --copy
|
|
155
|
+
|
|
156
|
+
# Scan for TODO/FIXME and preview issues
|
|
157
|
+
pr-pilot todos
|
|
158
|
+
|
|
159
|
+
# Actually create GitHub issues from them
|
|
160
|
+
pr-pilot todos --create --repo owner/repo
|
|
161
|
+
```
|
|
162
|
+
|
|
139
163
|
### Auto-comment on every PR (GitHub Action)
|
|
140
164
|
|
|
141
165
|
```yaml
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
#
|
|
1
|
+
# PullWise ✈️
|
|
2
2
|
|
|
3
3
|
[](https://pypi.org/project/pullwise/)
|
|
4
4
|
[](https://pypi.org/project/pullwise/)
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
**Stop writing PR descriptions.** Let AI do it.
|
|
9
9
|
|
|
10
|
+
> 📦 GitHub Action: [`albertusreza/pr-pilot`](https://github.com/marketplace/actions/pullwise) · PyPI: [`pullwise`](https://pypi.org/project/pullwise/)
|
|
11
|
+
|
|
10
12
|
`pr-pilot` is a GitHub Action + CLI that analyzes your diff and commit history, then automatically writes a clear, structured PR description — with a summary, change list, test plan, and labels.
|
|
11
13
|
|
|
12
14
|
```yaml
|
|
@@ -95,6 +97,9 @@ pr-pilot describe --markdown pr_description.md
|
|
|
95
97
|
| `pr-pilot review` | Get a senior-engineer-style code review in the terminal |
|
|
96
98
|
| `pr-pilot comment` | Post an AI review as a GitHub PR comment (updates on re-run, no spam) |
|
|
97
99
|
| `pr-pilot changelog` | Generate a `CHANGELOG.md` entry from commits since last tag |
|
|
100
|
+
| `pr-pilot reviewers` | Suggest reviewers based on git blame of changed files |
|
|
101
|
+
| `pr-pilot standup` | Generate a daily standup update from your recent commits |
|
|
102
|
+
| `pr-pilot todos` | Scan for TODO/FIXME comments and create GitHub issues from them |
|
|
98
103
|
|
|
99
104
|
## Usage
|
|
100
105
|
|
|
@@ -115,6 +120,26 @@ pr-pilot changelog
|
|
|
115
120
|
pr-pilot changelog --output CHANGELOG.md
|
|
116
121
|
```
|
|
117
122
|
|
|
123
|
+
```bash
|
|
124
|
+
# Suggest reviewers for your current branch
|
|
125
|
+
pr-pilot reviewers --base main
|
|
126
|
+
|
|
127
|
+
# Post suggestion as a comment + assign on GitHub
|
|
128
|
+
pr-pilot reviewers --post --assign --repo owner/repo --pr 42
|
|
129
|
+
|
|
130
|
+
# Generate a standup from yesterday's commits
|
|
131
|
+
pr-pilot standup
|
|
132
|
+
|
|
133
|
+
# Generate from the last 3 days and copy to clipboard
|
|
134
|
+
pr-pilot standup --days 3 --copy
|
|
135
|
+
|
|
136
|
+
# Scan for TODO/FIXME and preview issues
|
|
137
|
+
pr-pilot todos
|
|
138
|
+
|
|
139
|
+
# Actually create GitHub issues from them
|
|
140
|
+
pr-pilot todos --create --repo owner/repo
|
|
141
|
+
```
|
|
142
|
+
|
|
118
143
|
### Auto-comment on every PR (GitHub Action)
|
|
119
144
|
|
|
120
145
|
```yaml
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
|
@@ -8,6 +8,9 @@ from openai import OpenAI
|
|
|
8
8
|
from .templates import (
|
|
9
9
|
DESCRIBE_SYSTEM, DESCRIBE_USER, LABEL_SYSTEM, REVIEW_SYSTEM,
|
|
10
10
|
CHANGELOG_SYSTEM, CHANGELOG_USER, REVIEW_COMMENT_HEADER, REVIEW_COMMENT_TEMPLATE,
|
|
11
|
+
REVIEWER_SYSTEM, REVIEWER_USER, REVIEWER_COMMENT_HEADER, REVIEWER_COMMENT_TEMPLATE,
|
|
12
|
+
STANDUP_SYSTEM, STANDUP_USER,
|
|
13
|
+
ISSUE_SYSTEM, ISSUE_USER,
|
|
11
14
|
)
|
|
12
15
|
|
|
13
16
|
_MAX_DIFF_CHARS = 24_000 # stay well within context limits
|
|
@@ -230,3 +233,160 @@ def generate_changelog(api_key: str, model: str = _MODEL) -> tuple[ChangelogEntr
|
|
|
230
233
|
)
|
|
231
234
|
new_version = _bump_version(current, entry.version_bump)
|
|
232
235
|
return entry, new_version
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
# ── Reviewer suggester ────────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
@dataclass
|
|
241
|
+
class ReviewerSuggestion:
|
|
242
|
+
reviewers: list[str]
|
|
243
|
+
reasoning: str
|
|
244
|
+
|
|
245
|
+
def to_comment(self) -> str:
|
|
246
|
+
rows = "\n".join(f"| @{r} | {self.reasoning} |" for r in self.reviewers)
|
|
247
|
+
return REVIEWER_COMMENT_TEMPLATE.format(
|
|
248
|
+
header=REVIEWER_COMMENT_HEADER,
|
|
249
|
+
reasoning=self.reasoning,
|
|
250
|
+
rows=rows,
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _get_blame_summary(base: str = "main") -> tuple[str, str]:
|
|
255
|
+
"""Return (author, blame_summary) for files changed vs base."""
|
|
256
|
+
author = _git("config", "user.name") or "unknown"
|
|
257
|
+
changed_files = _git("diff", f"{base}...HEAD", "--name-only").splitlines()
|
|
258
|
+
lines = []
|
|
259
|
+
for f in changed_files[:15]: # cap at 15 files
|
|
260
|
+
blame = _git("log", "--follow", "--format=%an", "-5", "--", f)
|
|
261
|
+
authors = ", ".join(dict.fromkeys(blame.splitlines())) # unique, ordered
|
|
262
|
+
lines.append(f" {f}: {authors or 'no history'}")
|
|
263
|
+
return author, "\n".join(lines)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def suggest_reviewers(api_key: str, base: str = "main", model: str = _MODEL) -> ReviewerSuggestion:
|
|
267
|
+
client = OpenAI(api_key=api_key)
|
|
268
|
+
author, blame_summary = _get_blame_summary(base)
|
|
269
|
+
if not blame_summary:
|
|
270
|
+
raise ValueError("No changed files found — nothing to base reviewer suggestion on.")
|
|
271
|
+
resp = client.chat.completions.create(
|
|
272
|
+
model=model,
|
|
273
|
+
max_tokens=256,
|
|
274
|
+
messages=[
|
|
275
|
+
{"role": "system", "content": REVIEWER_SYSTEM},
|
|
276
|
+
{"role": "user", "content": REVIEWER_USER.format(author=author, blame_summary=blame_summary)},
|
|
277
|
+
],
|
|
278
|
+
)
|
|
279
|
+
raw = (resp.choices[0].message.content or "{}").strip()
|
|
280
|
+
if raw.startswith("```"):
|
|
281
|
+
raw = "\n".join(raw.splitlines()[1:])
|
|
282
|
+
if raw.endswith("```"):
|
|
283
|
+
raw = "\n".join(raw.splitlines()[:-1])
|
|
284
|
+
data = json.loads(raw.strip())
|
|
285
|
+
return ReviewerSuggestion(
|
|
286
|
+
reviewers=data.get("reviewers", []),
|
|
287
|
+
reasoning=data.get("reasoning", ""),
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# ── Standup generator ─────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
def generate_standup(api_key: str, days: int = 1, model: str = _MODEL) -> str:
|
|
294
|
+
client = OpenAI(api_key=api_key)
|
|
295
|
+
author = _git("config", "user.name") or "developer"
|
|
296
|
+
since = f"{days} day ago" if days == 1 else f"{days} days ago"
|
|
297
|
+
commits = _git("log", f"--since={since}", "--oneline", "--no-merges",
|
|
298
|
+
f"--author={author}")
|
|
299
|
+
if not commits:
|
|
300
|
+
commits = _git("log", "--oneline", "--no-merges", "-10")
|
|
301
|
+
if not commits:
|
|
302
|
+
return "No recent commits found."
|
|
303
|
+
resp = client.chat.completions.create(
|
|
304
|
+
model=model,
|
|
305
|
+
max_tokens=256,
|
|
306
|
+
messages=[
|
|
307
|
+
{"role": "system", "content": STANDUP_SYSTEM},
|
|
308
|
+
{"role": "user", "content": STANDUP_USER.format(
|
|
309
|
+
author=author, days=days, commits=commits
|
|
310
|
+
)},
|
|
311
|
+
],
|
|
312
|
+
)
|
|
313
|
+
return (resp.choices[0].message.content or "").strip()
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
# ── Issue creator (TODO/FIXME scanner) ───────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
@dataclass
|
|
319
|
+
class TodoIssue:
|
|
320
|
+
file_path: str
|
|
321
|
+
line_number: int
|
|
322
|
+
comment: str
|
|
323
|
+
title: str
|
|
324
|
+
body: str
|
|
325
|
+
labels: list[str]
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _scan_todos(root: str = ".") -> list[tuple[str, int, str, str]]:
|
|
329
|
+
"""Return list of (file, lineno, comment_text, context) for TODO/FIXME."""
|
|
330
|
+
import re
|
|
331
|
+
from pathlib import Path
|
|
332
|
+
pattern = re.compile(r'(TODO|FIXME|HACK|XXX)\s*:?\s*(.+)', re.IGNORECASE)
|
|
333
|
+
skip = {'.git', 'node_modules', '__pycache__', '.venv', 'venv', 'dist', 'build'}
|
|
334
|
+
results = []
|
|
335
|
+
for path in Path(root).rglob('*'):
|
|
336
|
+
if any(p in skip for p in path.parts):
|
|
337
|
+
continue
|
|
338
|
+
if not path.is_file():
|
|
339
|
+
continue
|
|
340
|
+
suffix = path.suffix.lower()
|
|
341
|
+
if suffix not in {'.py', '.js', '.ts', '.tsx', '.jsx', '.go', '.rb', '.java', '.md'}:
|
|
342
|
+
continue
|
|
343
|
+
try:
|
|
344
|
+
lines = path.read_text(errors='replace').splitlines()
|
|
345
|
+
except Exception:
|
|
346
|
+
continue
|
|
347
|
+
for i, line in enumerate(lines, 1):
|
|
348
|
+
m = pattern.search(line)
|
|
349
|
+
if m:
|
|
350
|
+
start = max(0, i - 3)
|
|
351
|
+
end = min(len(lines), i + 3)
|
|
352
|
+
context = '\n'.join(lines[start:end])
|
|
353
|
+
results.append((str(path), i, m.group(0).strip(), context))
|
|
354
|
+
return results
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def create_issues_from_todos(
|
|
358
|
+
api_key: str, root: str = ".", model: str = _MODEL
|
|
359
|
+
) -> list[TodoIssue]:
|
|
360
|
+
client = OpenAI(api_key=api_key)
|
|
361
|
+
todos = _scan_todos(root)
|
|
362
|
+
issues = []
|
|
363
|
+
for file_path, line_number, comment, context in todos:
|
|
364
|
+
resp = client.chat.completions.create(
|
|
365
|
+
model=model,
|
|
366
|
+
max_tokens=512,
|
|
367
|
+
messages=[
|
|
368
|
+
{"role": "system", "content": ISSUE_SYSTEM},
|
|
369
|
+
{"role": "user", "content": ISSUE_USER.format(
|
|
370
|
+
file_path=file_path, line_number=line_number,
|
|
371
|
+
comment=comment, context=context,
|
|
372
|
+
)},
|
|
373
|
+
],
|
|
374
|
+
)
|
|
375
|
+
raw = (resp.choices[0].message.content or "{}").strip()
|
|
376
|
+
if raw.startswith("```"):
|
|
377
|
+
raw = "\n".join(raw.splitlines()[1:])
|
|
378
|
+
if raw.endswith("```"):
|
|
379
|
+
raw = "\n".join(raw.splitlines()[:-1])
|
|
380
|
+
try:
|
|
381
|
+
data = json.loads(raw.strip())
|
|
382
|
+
except json.JSONDecodeError:
|
|
383
|
+
continue
|
|
384
|
+
issues.append(TodoIssue(
|
|
385
|
+
file_path=file_path,
|
|
386
|
+
line_number=line_number,
|
|
387
|
+
comment=comment,
|
|
388
|
+
title=data.get("title", comment[:72]),
|
|
389
|
+
body=data.get("body", ""),
|
|
390
|
+
labels=data.get("labels", ["technical-debt"]),
|
|
391
|
+
))
|
|
392
|
+
return issues
|
|
@@ -3,7 +3,10 @@ import argparse
|
|
|
3
3
|
import os
|
|
4
4
|
import sys
|
|
5
5
|
|
|
6
|
-
from .analyzer import
|
|
6
|
+
from .analyzer import (
|
|
7
|
+
describe_pr, suggest_labels, review_pr, review_pr_as_comment,
|
|
8
|
+
generate_changelog, suggest_reviewers, generate_standup, create_issues_from_todos,
|
|
9
|
+
)
|
|
7
10
|
|
|
8
11
|
_RESET = "\033[0m"
|
|
9
12
|
_BOLD = "\033[1m"
|
|
@@ -100,6 +103,91 @@ def cmd_changelog(args: argparse.Namespace) -> None:
|
|
|
100
103
|
print()
|
|
101
104
|
|
|
102
105
|
|
|
106
|
+
def cmd_reviewers(args: argparse.Namespace) -> None:
|
|
107
|
+
from .github_client import upsert_comment
|
|
108
|
+
from .templates import REVIEWER_COMMENT_HEADER
|
|
109
|
+
|
|
110
|
+
suggestion = suggest_reviewers(_key(), base=args.base, model=args.model)
|
|
111
|
+
|
|
112
|
+
print(f"\n {_BOLD}Suggested reviewers:{_RESET}")
|
|
113
|
+
for r in suggestion.reviewers:
|
|
114
|
+
print(f" {_GREEN}•{_RESET} @{r}")
|
|
115
|
+
print(f"\n {_DIM}{suggestion.reasoning}{_RESET}")
|
|
116
|
+
|
|
117
|
+
if args.post:
|
|
118
|
+
repo = args.repo or os.environ.get("GITHUB_REPOSITORY", "")
|
|
119
|
+
pr_num = args.pr or int(os.environ.get("PR_NUMBER", "0"))
|
|
120
|
+
if not repo or not pr_num:
|
|
121
|
+
print(" --post requires --repo and --pr (or GITHUB_REPOSITORY/PR_NUMBER)", file=sys.stderr)
|
|
122
|
+
sys.exit(1)
|
|
123
|
+
url = upsert_comment(repo, pr_num, suggestion.to_comment(), REVIEWER_COMMENT_HEADER)
|
|
124
|
+
print(f"\n {_GREEN}✓{_RESET} Comment posted: {url}")
|
|
125
|
+
|
|
126
|
+
if args.assign and suggestion.reviewers:
|
|
127
|
+
from .github_client import _api
|
|
128
|
+
_api("POST", f"/repos/{repo}/pulls/{pr_num}/requested_reviewers",
|
|
129
|
+
{"reviewers": suggestion.reviewers})
|
|
130
|
+
print(f" {_GREEN}✓{_RESET} Reviewers assigned: {', '.join(suggestion.reviewers)}")
|
|
131
|
+
print()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def cmd_standup(args: argparse.Namespace) -> None:
|
|
135
|
+
print(f"\n {_DIM}Generating standup from last {args.days} day(s) of commits...{_RESET}\n")
|
|
136
|
+
update = generate_standup(_key(), days=args.days, model=args.model)
|
|
137
|
+
print(update)
|
|
138
|
+
if args.copy:
|
|
139
|
+
try:
|
|
140
|
+
import subprocess
|
|
141
|
+
subprocess.run(["pbcopy"], input=update.encode(), check=True)
|
|
142
|
+
print(f"\n {_GREEN}✓{_RESET} Copied to clipboard")
|
|
143
|
+
except Exception:
|
|
144
|
+
print(f"\n {_DIM}(--copy requires macOS pbcopy){_RESET}")
|
|
145
|
+
print()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def cmd_todos(args: argparse.Namespace) -> None:
|
|
149
|
+
from .github_client import _api
|
|
150
|
+
|
|
151
|
+
print(f"\n {_DIM}Scanning for TODO/FIXME comments in {args.path}...{_RESET}\n")
|
|
152
|
+
issues = create_issues_from_todos(_key(), root=args.path, model=args.model)
|
|
153
|
+
|
|
154
|
+
if not issues:
|
|
155
|
+
print(" No TODO/FIXME comments found.")
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
print(f" Found {_BOLD}{len(issues)}{_RESET} item(s):\n")
|
|
159
|
+
for i, issue in enumerate(issues, 1):
|
|
160
|
+
print(f" {_BOLD}{i}.{_RESET} {issue.title}")
|
|
161
|
+
print(f" {_DIM}{issue.file_path}:{issue.line_number}{_RESET} "
|
|
162
|
+
f"{_CYAN}[{', '.join(issue.labels)}]{_RESET}")
|
|
163
|
+
|
|
164
|
+
if args.create:
|
|
165
|
+
repo = args.repo or os.environ.get("GITHUB_REPOSITORY", "")
|
|
166
|
+
if not repo:
|
|
167
|
+
print("\n --create requires --repo or GITHUB_REPOSITORY", file=sys.stderr)
|
|
168
|
+
sys.exit(1)
|
|
169
|
+
print()
|
|
170
|
+
created = 0
|
|
171
|
+
for issue in issues:
|
|
172
|
+
# Ensure labels exist
|
|
173
|
+
for label in issue.labels:
|
|
174
|
+
try:
|
|
175
|
+
_api("POST", f"/repos/{repo}/labels",
|
|
176
|
+
{"name": label, "color": "e4e669"})
|
|
177
|
+
except SystemExit:
|
|
178
|
+
pass
|
|
179
|
+
data = _api("POST", f"/repos/{repo}/issues", {
|
|
180
|
+
"title": issue.title,
|
|
181
|
+
"body": issue.body,
|
|
182
|
+
"labels": issue.labels,
|
|
183
|
+
})
|
|
184
|
+
print(f" {_GREEN}✓{_RESET} #{data['number']} {issue.title}")
|
|
185
|
+
print(f" {_DIM}{data['html_url']}{_RESET}")
|
|
186
|
+
created += 1
|
|
187
|
+
print(f"\n {created} issue(s) created.\n")
|
|
188
|
+
print()
|
|
189
|
+
|
|
190
|
+
|
|
103
191
|
def cmd_action(args: argparse.Namespace) -> None:
|
|
104
192
|
"""Run as a GitHub Action — reads env vars set by the Actions runner."""
|
|
105
193
|
import json as _json
|
|
@@ -110,6 +198,9 @@ def cmd_action(args: argparse.Namespace) -> None:
|
|
|
110
198
|
token = os.environ.get("GITHUB_TOKEN", "")
|
|
111
199
|
skip_labels = os.environ.get("SKIP_LABELS", "false").lower() == "true"
|
|
112
200
|
update_title = os.environ.get("UPDATE_TITLE", "false").lower() == "true"
|
|
201
|
+
# Allow action.yml to override the model via env var
|
|
202
|
+
if os.environ.get("MODEL"):
|
|
203
|
+
args.model = os.environ["MODEL"]
|
|
113
204
|
|
|
114
205
|
if not repo or not pr_num or not token:
|
|
115
206
|
print("pr-pilot action: GITHUB_REPOSITORY, PR_NUMBER, GITHUB_TOKEN must be set", file=sys.stderr)
|
|
@@ -155,6 +246,31 @@ def main() -> None:
|
|
|
155
246
|
p_rev.add_argument("--model", default="gpt-4o", help="OpenAI model to use")
|
|
156
247
|
p_rev.set_defaults(func=cmd_review)
|
|
157
248
|
|
|
249
|
+
# --- reviewers ---
|
|
250
|
+
p_rev2 = sub.add_parser("reviewers", help="Suggest reviewers based on git blame of changed files")
|
|
251
|
+
p_rev2.add_argument("--base", default="main")
|
|
252
|
+
p_rev2.add_argument("--model", default="gpt-4o")
|
|
253
|
+
p_rev2.add_argument("--post", action="store_true", help="Post suggestion as a PR comment")
|
|
254
|
+
p_rev2.add_argument("--assign", action="store_true", help="Also assign the reviewers on GitHub")
|
|
255
|
+
p_rev2.add_argument("--repo", default=None)
|
|
256
|
+
p_rev2.add_argument("--pr", type=int, default=None)
|
|
257
|
+
p_rev2.set_defaults(func=cmd_reviewers)
|
|
258
|
+
|
|
259
|
+
# --- standup ---
|
|
260
|
+
p_stand = sub.add_parser("standup", help="Generate a daily standup from recent commits")
|
|
261
|
+
p_stand.add_argument("--days", type=int, default=1, help="How many days back to look (default: 1)")
|
|
262
|
+
p_stand.add_argument("--model", default="gpt-4o")
|
|
263
|
+
p_stand.add_argument("--copy", action="store_true", help="Copy output to clipboard (macOS)")
|
|
264
|
+
p_stand.set_defaults(func=cmd_standup)
|
|
265
|
+
|
|
266
|
+
# --- todos ---
|
|
267
|
+
p_todos = sub.add_parser("todos", help="Scan for TODO/FIXME comments and create GitHub issues")
|
|
268
|
+
p_todos.add_argument("path", nargs="?", default=".", help="Directory to scan (default: .)")
|
|
269
|
+
p_todos.add_argument("--model", default="gpt-4o")
|
|
270
|
+
p_todos.add_argument("--create", action="store_true", help="Create GitHub issues from found TODOs")
|
|
271
|
+
p_todos.add_argument("--repo", default=None, help="GitHub repo slug (required with --create)")
|
|
272
|
+
p_todos.set_defaults(func=cmd_todos)
|
|
273
|
+
|
|
158
274
|
# --- comment ---
|
|
159
275
|
p_com = sub.add_parser("comment", help="Post an AI review as a GitHub PR comment")
|
|
160
276
|
p_com.add_argument("--base", default="main", help="Base branch to diff against (default: main)")
|
|
@@ -91,3 +91,87 @@ Commits since last tag:
|
|
|
91
91
|
Diff summary (may be truncated):
|
|
92
92
|
{diff}
|
|
93
93
|
"""
|
|
94
|
+
|
|
95
|
+
# ── Reviewer suggester ────────────────────────────────────────────────────────
|
|
96
|
+
|
|
97
|
+
REVIEWER_SYSTEM = """\
|
|
98
|
+
You are a code ownership expert. Given a list of changed files and their git blame
|
|
99
|
+
authors, suggest the best 1-3 reviewers for this pull request.
|
|
100
|
+
|
|
101
|
+
Return a JSON object:
|
|
102
|
+
{
|
|
103
|
+
"reviewers": ["username1", "username2"],
|
|
104
|
+
"reasoning": "one sentence explaining why these people are the best fit"
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
Rules:
|
|
108
|
+
- Prefer authors who have touched the changed files most recently and most often
|
|
109
|
+
- Exclude the PR author themselves
|
|
110
|
+
- Return ONLY the JSON object, no markdown fences
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
REVIEWER_USER = """\
|
|
114
|
+
PR author: {author}
|
|
115
|
+
Changed files and recent blame authors:
|
|
116
|
+
{blame_summary}
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
REVIEWER_COMMENT_HEADER = "<!-- pr-pilot-reviewers -->"
|
|
120
|
+
|
|
121
|
+
REVIEWER_COMMENT_TEMPLATE = """\
|
|
122
|
+
{header}
|
|
123
|
+
### 👀 Suggested reviewers
|
|
124
|
+
|
|
125
|
+
{reasoning}
|
|
126
|
+
|
|
127
|
+
| Reviewer | Why |
|
|
128
|
+
|---|---|
|
|
129
|
+
{rows}
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
<sub>Suggested by [PR Narrator](https://github.com/albertusreza/pr-pilot)</sub>
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
# ── Standup generator ─────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
STANDUP_SYSTEM = """\
|
|
138
|
+
You are a helpful assistant writing a daily standup update for a developer.
|
|
139
|
+
Given recent git commits, write a concise standup in this format:
|
|
140
|
+
|
|
141
|
+
**Yesterday:** what was done
|
|
142
|
+
**Today:** what's planned next (infer from context)
|
|
143
|
+
**Blockers:** none (unless commits mention an issue)
|
|
144
|
+
|
|
145
|
+
Keep it under 100 words. Sound like a real developer, not a press release.
|
|
146
|
+
No bullet points — write in plain sentences.
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
STANDUP_USER = """\
|
|
150
|
+
Developer: {author}
|
|
151
|
+
Recent commits (last {days} days):
|
|
152
|
+
{commits}
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
# ── Issue creator (TODO/FIXME scanner) ───────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
ISSUE_SYSTEM = """\
|
|
158
|
+
You are a developer assistant converting code annotations into GitHub issues.
|
|
159
|
+
Given a TODO or FIXME comment from source code, write a clear GitHub issue.
|
|
160
|
+
|
|
161
|
+
Return a JSON object:
|
|
162
|
+
{
|
|
163
|
+
"title": "concise issue title, max 72 chars, imperative",
|
|
164
|
+
"body": "markdown body: describe the problem, location in code, and suggested approach",
|
|
165
|
+
"labels": ["one or more of: bug, enhancement, technical-debt, documentation"]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
Return ONLY the JSON object, no markdown fences.
|
|
169
|
+
"""
|
|
170
|
+
|
|
171
|
+
ISSUE_USER = """\
|
|
172
|
+
File: {file_path}
|
|
173
|
+
Line: {line_number}
|
|
174
|
+
Comment: {comment}
|
|
175
|
+
Surrounding code:
|
|
176
|
+
{context}
|
|
177
|
+
"""
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pullwise
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: AI-powered PR descriptions, labels, and code review using OpenAI
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/albertusreza/pr-pilot
|
|
@@ -10,7 +10,6 @@ Keywords: github,pull-request,openai,ai,automation,devtools,cli,github-actions
|
|
|
10
10
|
Classifier: Development Status :: 3 - Alpha
|
|
11
11
|
Classifier: Environment :: Console
|
|
12
12
|
Classifier: Intended Audience :: Developers
|
|
13
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
14
13
|
Classifier: Programming Language :: Python :: 3
|
|
15
14
|
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
16
15
|
Requires-Python: >=3.9
|
|
@@ -19,7 +18,7 @@ Requires-Dist: openai>=1.0.0
|
|
|
19
18
|
Provides-Extra: dev
|
|
20
19
|
Requires-Dist: pytest; extra == "dev"
|
|
21
20
|
|
|
22
|
-
#
|
|
21
|
+
# PullWise ✈️
|
|
23
22
|
|
|
24
23
|
[](https://pypi.org/project/pullwise/)
|
|
25
24
|
[](https://pypi.org/project/pullwise/)
|
|
@@ -28,6 +27,8 @@ Requires-Dist: pytest; extra == "dev"
|
|
|
28
27
|
|
|
29
28
|
**Stop writing PR descriptions.** Let AI do it.
|
|
30
29
|
|
|
30
|
+
> 📦 GitHub Action: [`albertusreza/pr-pilot`](https://github.com/marketplace/actions/pullwise) · PyPI: [`pullwise`](https://pypi.org/project/pullwise/)
|
|
31
|
+
|
|
31
32
|
`pr-pilot` is a GitHub Action + CLI that analyzes your diff and commit history, then automatically writes a clear, structured PR description — with a summary, change list, test plan, and labels.
|
|
32
33
|
|
|
33
34
|
```yaml
|
|
@@ -116,6 +117,9 @@ pr-pilot describe --markdown pr_description.md
|
|
|
116
117
|
| `pr-pilot review` | Get a senior-engineer-style code review in the terminal |
|
|
117
118
|
| `pr-pilot comment` | Post an AI review as a GitHub PR comment (updates on re-run, no spam) |
|
|
118
119
|
| `pr-pilot changelog` | Generate a `CHANGELOG.md` entry from commits since last tag |
|
|
120
|
+
| `pr-pilot reviewers` | Suggest reviewers based on git blame of changed files |
|
|
121
|
+
| `pr-pilot standup` | Generate a daily standup update from your recent commits |
|
|
122
|
+
| `pr-pilot todos` | Scan for TODO/FIXME comments and create GitHub issues from them |
|
|
119
123
|
|
|
120
124
|
## Usage
|
|
121
125
|
|
|
@@ -136,6 +140,26 @@ pr-pilot changelog
|
|
|
136
140
|
pr-pilot changelog --output CHANGELOG.md
|
|
137
141
|
```
|
|
138
142
|
|
|
143
|
+
```bash
|
|
144
|
+
# Suggest reviewers for your current branch
|
|
145
|
+
pr-pilot reviewers --base main
|
|
146
|
+
|
|
147
|
+
# Post suggestion as a comment + assign on GitHub
|
|
148
|
+
pr-pilot reviewers --post --assign --repo owner/repo --pr 42
|
|
149
|
+
|
|
150
|
+
# Generate a standup from yesterday's commits
|
|
151
|
+
pr-pilot standup
|
|
152
|
+
|
|
153
|
+
# Generate from the last 3 days and copy to clipboard
|
|
154
|
+
pr-pilot standup --days 3 --copy
|
|
155
|
+
|
|
156
|
+
# Scan for TODO/FIXME and preview issues
|
|
157
|
+
pr-pilot todos
|
|
158
|
+
|
|
159
|
+
# Actually create GitHub issues from them
|
|
160
|
+
pr-pilot todos --create --repo owner/repo
|
|
161
|
+
```
|
|
162
|
+
|
|
139
163
|
### Auto-comment on every PR (GitHub Action)
|
|
140
164
|
|
|
141
165
|
```yaml
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pullwise"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "AI-powered PR descriptions, labels, and code review using OpenAI"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "MIT" }
|
|
@@ -14,7 +14,6 @@ classifiers = [
|
|
|
14
14
|
"Development Status :: 3 - Alpha",
|
|
15
15
|
"Environment :: Console",
|
|
16
16
|
"Intended Audience :: Developers",
|
|
17
|
-
"License :: OSI Approved :: MIT License",
|
|
18
17
|
"Programming Language :: Python :: 3",
|
|
19
18
|
"Topic :: Software Development :: Version Control :: Git",
|
|
20
19
|
]
|
|
@@ -37,3 +36,6 @@ Issues = "https://github.com/albertusreza/pr-pilot/issues"
|
|
|
37
36
|
[tool.setuptools.packages.find]
|
|
38
37
|
where = ["."]
|
|
39
38
|
include = ["pr_pilot*"]
|
|
39
|
+
|
|
40
|
+
[tool.setuptools]
|
|
41
|
+
license-files = []
|
|
@@ -4,8 +4,9 @@ from unittest.mock import MagicMock, patch
|
|
|
4
4
|
from pr_pilot.analyzer import (
|
|
5
5
|
describe_pr, suggest_labels, review_pr, PRDescription,
|
|
6
6
|
review_pr_as_comment, generate_changelog, ChangelogEntry,
|
|
7
|
+
suggest_reviewers, generate_standup, create_issues_from_todos, _bump_version,
|
|
7
8
|
)
|
|
8
|
-
from pr_pilot.templates import REVIEW_COMMENT_HEADER
|
|
9
|
+
from pr_pilot.templates import REVIEW_COMMENT_HEADER, REVIEWER_COMMENT_HEADER
|
|
9
10
|
|
|
10
11
|
|
|
11
12
|
def _mock_openai_response(content: str) -> MagicMock:
|
|
@@ -156,7 +157,84 @@ def test_changelog_entry_to_markdown():
|
|
|
156
157
|
|
|
157
158
|
|
|
158
159
|
def test_version_bump():
|
|
159
|
-
from pr_pilot.analyzer import _bump_version
|
|
160
160
|
assert _bump_version("1.2.3", "patch") == "1.2.4"
|
|
161
161
|
assert _bump_version("1.2.3", "minor") == "1.3.0"
|
|
162
162
|
assert _bump_version("1.2.3", "major") == "2.0.0"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ── Reviewer suggester tests ──────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
@patch("pr_pilot.analyzer._get_blame_summary", return_value=("alice", " src/auth.py: bob, carol\n src/api.py: bob"))
|
|
168
|
+
def test_suggest_reviewers_basic(mock_blame):
|
|
169
|
+
payload = {"reviewers": ["bob", "carol"], "reasoning": "They own auth.py and api.py"}
|
|
170
|
+
with patch("pr_pilot.analyzer.OpenAI") as MockOpenAI:
|
|
171
|
+
MockOpenAI.return_value.chat.completions.create.return_value = \
|
|
172
|
+
_mock_openai_response(json.dumps(payload))
|
|
173
|
+
result = suggest_reviewers("fake-key")
|
|
174
|
+
assert "bob" in result.reviewers
|
|
175
|
+
assert "carol" in result.reviewers
|
|
176
|
+
assert result.reasoning
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_reviewer_suggestion_to_comment():
|
|
180
|
+
from pr_pilot.analyzer import ReviewerSuggestion
|
|
181
|
+
s = ReviewerSuggestion(reviewers=["bob"], reasoning="Bob owns the changed files")
|
|
182
|
+
comment = s.to_comment()
|
|
183
|
+
assert REVIEWER_COMMENT_HEADER in comment
|
|
184
|
+
assert "@bob" in comment
|
|
185
|
+
assert "Bob owns" in comment
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ── Standup generator tests ───────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
@patch("pr_pilot.analyzer._git", side_effect=lambda *a: {
|
|
191
|
+
("config", "user.name"): "Alice",
|
|
192
|
+
}.get(a, "abc123 Fix login bug\ndef456 Add dark mode"))
|
|
193
|
+
def test_generate_standup(mock_git):
|
|
194
|
+
with patch("pr_pilot.analyzer.OpenAI") as MockOpenAI:
|
|
195
|
+
MockOpenAI.return_value.chat.completions.create.return_value = \
|
|
196
|
+
_mock_openai_response("**Yesterday:** Fixed login bug and added dark mode.\n**Today:** Writing tests.\n**Blockers:** None.")
|
|
197
|
+
result = generate_standup("fake-key", days=1)
|
|
198
|
+
assert "Yesterday" in result
|
|
199
|
+
assert "Today" in result
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
@patch("pr_pilot.analyzer._git", return_value="")
|
|
203
|
+
def test_generate_standup_no_commits(mock_git):
|
|
204
|
+
with patch("pr_pilot.analyzer.OpenAI") as MockOpenAI:
|
|
205
|
+
MockOpenAI.return_value.chat.completions.create.return_value = \
|
|
206
|
+
_mock_openai_response("")
|
|
207
|
+
result = generate_standup("fake-key", days=1)
|
|
208
|
+
assert result == "No recent commits found."
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ── TODO/FIXME issue creator tests ───────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
def test_scan_todos_finds_items(tmp_path):
|
|
214
|
+
from pr_pilot.analyzer import _scan_todos
|
|
215
|
+
(tmp_path / "app.py").write_text("x = 1\n# TODO: fix this properly\ny = 2\n")
|
|
216
|
+
results = _scan_todos(str(tmp_path))
|
|
217
|
+
assert len(results) == 1
|
|
218
|
+
assert "fix this properly" in results[0][2]
|
|
219
|
+
assert results[0][1] == 2 # line number
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def test_scan_todos_skips_node_modules(tmp_path):
|
|
223
|
+
from pr_pilot.analyzer import _scan_todos
|
|
224
|
+
nm = tmp_path / "node_modules" / "lib"
|
|
225
|
+
nm.mkdir(parents=True)
|
|
226
|
+
(nm / "index.js").write_text("// TODO: upstream bug\n")
|
|
227
|
+
results = _scan_todos(str(tmp_path))
|
|
228
|
+
assert results == []
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_create_issues_from_todos(tmp_path):
|
|
232
|
+
(tmp_path / "utils.py").write_text("def foo():\n # FIXME: this is slow\n pass\n")
|
|
233
|
+
payload = {"title": "Fix slow foo()", "body": "The function is slow.", "labels": ["technical-debt"]}
|
|
234
|
+
with patch("pr_pilot.analyzer.OpenAI") as MockOpenAI:
|
|
235
|
+
MockOpenAI.return_value.chat.completions.create.return_value = \
|
|
236
|
+
_mock_openai_response(json.dumps(payload))
|
|
237
|
+
issues = create_issues_from_todos("fake-key", root=str(tmp_path))
|
|
238
|
+
assert len(issues) == 1
|
|
239
|
+
assert issues[0].title == "Fix slow foo()"
|
|
240
|
+
assert "technical-debt" in issues[0].labels
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.2.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|