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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pullwise
3
- Version: 0.2.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
- # pr-pilot ✈️
21
+ # PullWise ✈️
23
22
 
24
23
  [![PyPI](https://img.shields.io/pypi/v/pullwise)](https://pypi.org/project/pullwise/)
25
24
  [![Downloads](https://img.shields.io/pypi/dm/pullwise)](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
- # pr-pilot ✈️
1
+ # PullWise ✈️
2
2
 
3
3
  [![PyPI](https://img.shields.io/pypi/v/pullwise)](https://pypi.org/project/pullwise/)
4
4
  [![Downloads](https://img.shields.io/pypi/dm/pullwise)](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 describe_pr, suggest_labels, review_pr, review_pr_as_comment, generate_changelog
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.2.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
- # pr-pilot ✈️
21
+ # PullWise ✈️
23
22
 
24
23
  [![PyPI](https://img.shields.io/pypi/v/pullwise)](https://pypi.org/project/pullwise/)
25
24
  [![Downloads](https://img.shields.io/pypi/dm/pullwise)](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.2.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