pullwise 0.1.0__tar.gz → 0.2.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.1.0
3
+ Version: 0.2.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
@@ -21,6 +21,11 @@ Requires-Dist: pytest; extra == "dev"
21
21
 
22
22
  # pr-pilot ✈️
23
23
 
24
+ [![PyPI](https://img.shields.io/pypi/v/pullwise)](https://pypi.org/project/pullwise/)
25
+ [![Downloads](https://img.shields.io/pypi/dm/pullwise)](https://pypi.org/project/pullwise/)
26
+ [![CI](https://github.com/albertusreza/pr-pilot/actions/workflows/ci.yml/badge.svg)](https://github.com/albertusreza/pr-pilot/actions)
27
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
28
+
24
29
  **Stop writing PR descriptions.** Let AI do it.
25
30
 
26
31
  `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.
@@ -87,7 +92,7 @@ Add `OPENAI_API_KEY` to your repo secrets and you're done. Works with any langua
87
92
  ### As a CLI
88
93
 
89
94
  ```bash
90
- pip install pr-pilot
95
+ pip install pullwise
91
96
  export OPENAI_API_KEY=sk-...
92
97
 
93
98
  # Generate a description for your current branch
@@ -103,6 +108,50 @@ pr-pilot review
103
108
  pr-pilot describe --markdown pr_description.md
104
109
  ```
105
110
 
111
+ ## Features
112
+
113
+ | Command | What it does |
114
+ |---|---|
115
+ | `pr-pilot describe` | Generate a structured PR description from your diff |
116
+ | `pr-pilot review` | Get a senior-engineer-style code review in the terminal |
117
+ | `pr-pilot comment` | Post an AI review as a GitHub PR comment (updates on re-run, no spam) |
118
+ | `pr-pilot changelog` | Generate a `CHANGELOG.md` entry from commits since last tag |
119
+
120
+ ## Usage
121
+
122
+ ```bash
123
+ # Generate PR description (terminal)
124
+ pr-pilot describe --base main
125
+
126
+ # Get a quick code review in the terminal
127
+ pr-pilot review --base main
128
+
129
+ # Post review as a GitHub PR comment
130
+ pr-pilot comment --repo owner/repo --pr 42
131
+
132
+ # Generate CHANGELOG entry (print to stdout)
133
+ pr-pilot changelog
134
+
135
+ # Write directly into CHANGELOG.md
136
+ pr-pilot changelog --output CHANGELOG.md
137
+ ```
138
+
139
+ ### Auto-comment on every PR (GitHub Action)
140
+
141
+ ```yaml
142
+ # .github/workflows/pr-review.yml
143
+ - uses: actions/checkout@v4
144
+ with:
145
+ fetch-depth: 0
146
+ - run: pip install pullwise
147
+ - run: pr-pilot comment --repo ${{ github.repository }} --pr ${{ github.event.pull_request.number }}
148
+ env:
149
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
150
+ GITHUB_TOKEN: ${{ github.token }}
151
+ ```
152
+
153
+ The comment updates itself on every new push — no duplicate comments.
154
+
106
155
  ## Options
107
156
 
108
157
  | Input | Default | Description |
@@ -1,5 +1,10 @@
1
1
  # pr-pilot ✈️
2
2
 
3
+ [![PyPI](https://img.shields.io/pypi/v/pullwise)](https://pypi.org/project/pullwise/)
4
+ [![Downloads](https://img.shields.io/pypi/dm/pullwise)](https://pypi.org/project/pullwise/)
5
+ [![CI](https://github.com/albertusreza/pr-pilot/actions/workflows/ci.yml/badge.svg)](https://github.com/albertusreza/pr-pilot/actions)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
+
3
8
  **Stop writing PR descriptions.** Let AI do it.
4
9
 
5
10
  `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.
@@ -66,7 +71,7 @@ Add `OPENAI_API_KEY` to your repo secrets and you're done. Works with any langua
66
71
  ### As a CLI
67
72
 
68
73
  ```bash
69
- pip install pr-pilot
74
+ pip install pullwise
70
75
  export OPENAI_API_KEY=sk-...
71
76
 
72
77
  # Generate a description for your current branch
@@ -82,6 +87,50 @@ pr-pilot review
82
87
  pr-pilot describe --markdown pr_description.md
83
88
  ```
84
89
 
90
+ ## Features
91
+
92
+ | Command | What it does |
93
+ |---|---|
94
+ | `pr-pilot describe` | Generate a structured PR description from your diff |
95
+ | `pr-pilot review` | Get a senior-engineer-style code review in the terminal |
96
+ | `pr-pilot comment` | Post an AI review as a GitHub PR comment (updates on re-run, no spam) |
97
+ | `pr-pilot changelog` | Generate a `CHANGELOG.md` entry from commits since last tag |
98
+
99
+ ## Usage
100
+
101
+ ```bash
102
+ # Generate PR description (terminal)
103
+ pr-pilot describe --base main
104
+
105
+ # Get a quick code review in the terminal
106
+ pr-pilot review --base main
107
+
108
+ # Post review as a GitHub PR comment
109
+ pr-pilot comment --repo owner/repo --pr 42
110
+
111
+ # Generate CHANGELOG entry (print to stdout)
112
+ pr-pilot changelog
113
+
114
+ # Write directly into CHANGELOG.md
115
+ pr-pilot changelog --output CHANGELOG.md
116
+ ```
117
+
118
+ ### Auto-comment on every PR (GitHub Action)
119
+
120
+ ```yaml
121
+ # .github/workflows/pr-review.yml
122
+ - uses: actions/checkout@v4
123
+ with:
124
+ fetch-depth: 0
125
+ - run: pip install pullwise
126
+ - run: pr-pilot comment --repo ${{ github.repository }} --pr ${{ github.event.pull_request.number }}
127
+ env:
128
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
129
+ GITHUB_TOKEN: ${{ github.token }}
130
+ ```
131
+
132
+ The comment updates itself on every new push — no duplicate comments.
133
+
85
134
  ## Options
86
135
 
87
136
  | Input | Default | Description |
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -0,0 +1,232 @@
1
+ from __future__ import annotations
2
+ import json
3
+ import subprocess
4
+ from dataclasses import dataclass, field
5
+
6
+ from openai import OpenAI
7
+
8
+ from .templates import (
9
+ DESCRIBE_SYSTEM, DESCRIBE_USER, LABEL_SYSTEM, REVIEW_SYSTEM,
10
+ CHANGELOG_SYSTEM, CHANGELOG_USER, REVIEW_COMMENT_HEADER, REVIEW_COMMENT_TEMPLATE,
11
+ )
12
+
13
+ _MAX_DIFF_CHARS = 24_000 # stay well within context limits
14
+ _MODEL = "gpt-4o"
15
+
16
+
17
+ @dataclass
18
+ class PRDescription:
19
+ title: str
20
+ summary: str
21
+ changes: list[str]
22
+ breaking: bool
23
+ breaking_notes: str | None
24
+ test_plan: str
25
+ labels: list[str]
26
+
27
+ def to_markdown(self) -> str:
28
+ lines = [
29
+ f"## Summary\n{self.summary}\n",
30
+ "## Changes",
31
+ ]
32
+ for c in self.changes:
33
+ lines.append(f"- {c}")
34
+ if self.breaking:
35
+ lines.append(f"\n## ⚠️ Breaking Changes\n{self.breaking_notes}")
36
+ lines.append(f"\n## Test Plan\n{self.test_plan}")
37
+ return "\n".join(lines)
38
+
39
+
40
+ def _git(*args: str) -> str:
41
+ result = subprocess.run(["git"] + list(args), capture_output=True, text=True)
42
+ return result.stdout.strip()
43
+
44
+
45
+ def get_diff(base: str = "main") -> str:
46
+ diff = _git("diff", f"{base}...HEAD")
47
+ if len(diff) > _MAX_DIFF_CHARS:
48
+ diff = diff[:_MAX_DIFF_CHARS] + "\n\n[diff truncated — showing first 24k chars]"
49
+ return diff
50
+
51
+
52
+ def get_commits(base: str = "main") -> str:
53
+ return _git("log", f"{base}...HEAD", "--oneline", "--no-merges")
54
+
55
+
56
+ def get_branch() -> str:
57
+ return _git("rev-parse", "--abbrev-ref", "HEAD")
58
+
59
+
60
+ def describe_pr(api_key: str, base: str = "main", model: str = _MODEL) -> PRDescription:
61
+ client = OpenAI(api_key=api_key)
62
+ diff = get_diff(base)
63
+ commits = get_commits(base)
64
+ branch = get_branch()
65
+
66
+ if not diff and not commits:
67
+ raise ValueError(f"No changes found between '{branch}' and '{base}'")
68
+
69
+ user_msg = DESCRIBE_USER.format(
70
+ branch=branch, base=base, commits=commits or "(none)", diff=diff or "(empty)"
71
+ )
72
+ resp = client.chat.completions.create(
73
+ model=model,
74
+ max_tokens=1024,
75
+ messages=[
76
+ {"role": "system", "content": DESCRIBE_SYSTEM},
77
+ {"role": "user", "content": user_msg},
78
+ ],
79
+ )
80
+ raw = resp.choices[0].message.content or "{}"
81
+ raw = raw.strip()
82
+ if raw.startswith("```"):
83
+ raw = "\n".join(raw.splitlines()[1:])
84
+ if raw.endswith("```"):
85
+ raw = "\n".join(raw.splitlines()[:-1])
86
+
87
+ data = json.loads(raw.strip())
88
+ return PRDescription(
89
+ title=data.get("title", branch),
90
+ summary=data.get("summary", ""),
91
+ changes=data.get("changes", []),
92
+ breaking=data.get("breaking", False),
93
+ breaking_notes=data.get("breaking_notes"),
94
+ test_plan=data.get("test_plan", ""),
95
+ labels=data.get("labels", ["feature"]),
96
+ )
97
+
98
+
99
+ def suggest_labels(api_key: str, title: str, body: str, model: str = _MODEL) -> list[str]:
100
+ client = OpenAI(api_key=api_key)
101
+ resp = client.chat.completions.create(
102
+ model=model,
103
+ max_tokens=64,
104
+ messages=[
105
+ {"role": "system", "content": LABEL_SYSTEM},
106
+ {"role": "user", "content": f"Title: {title}\n\nBody: {body[:2000]}"},
107
+ ],
108
+ )
109
+ raw = resp.choices[0].message.content or "[]"
110
+ return json.loads(raw.strip())
111
+
112
+
113
+ def review_pr(api_key: str, base: str = "main", model: str = _MODEL) -> str:
114
+ client = OpenAI(api_key=api_key)
115
+ diff = get_diff(base)
116
+ if not diff:
117
+ return "No changes to review."
118
+ resp = client.chat.completions.create(
119
+ model=model,
120
+ max_tokens=1024,
121
+ messages=[
122
+ {"role": "system", "content": REVIEW_SYSTEM},
123
+ {"role": "user", "content": f"Diff:\n\n{diff}"},
124
+ ],
125
+ )
126
+ return (resp.choices[0].message.content or "").strip()
127
+
128
+
129
+ def review_pr_as_comment(api_key: str, base: str = "main", model: str = _MODEL) -> str:
130
+ """Return a GitHub-flavoured markdown comment with the AI review."""
131
+ body = review_pr(api_key, base=base, model=model)
132
+ return REVIEW_COMMENT_TEMPLATE.format(header=REVIEW_COMMENT_HEADER, body=body)
133
+
134
+
135
+ # ── Changelog ────────────────────────────────────────────────────────────────
136
+
137
+ @dataclass
138
+ class ChangelogEntry:
139
+ version_bump: str # "patch" | "minor" | "major"
140
+ highlights: str
141
+ added: list[str]
142
+ changed: list[str]
143
+ fixed: list[str]
144
+ removed: list[str]
145
+ security: list[str]
146
+
147
+ def to_markdown(self, new_version: str, date: str) -> str:
148
+ lines = [f"## [{new_version}] - {date}", f"\n_{self.highlights}_\n"]
149
+ sections = [
150
+ ("Added", self.added),
151
+ ("Changed", self.changed),
152
+ ("Fixed", self.fixed),
153
+ ("Removed", self.removed),
154
+ ("Security", self.security),
155
+ ]
156
+ for title, items in sections:
157
+ if items:
158
+ lines.append(f"\n### {title}")
159
+ for item in items:
160
+ lines.append(f"- {item}")
161
+ return "\n".join(lines)
162
+
163
+
164
+ def _get_current_version() -> str:
165
+ """Read version from pyproject.toml, setup.cfg, or package __init__."""
166
+ try:
167
+ tag = _git("describe", "--tags", "--abbrev=0")
168
+ return tag.lstrip("v") if tag else "0.0.0"
169
+ except Exception:
170
+ return "0.0.0"
171
+
172
+
173
+ def _get_commits_since_tag() -> str:
174
+ tag = _git("describe", "--tags", "--abbrev=0")
175
+ if tag:
176
+ return _git("log", f"{tag}...HEAD", "--oneline", "--no-merges")
177
+ return _git("log", "--oneline", "--no-merges", "-30")
178
+
179
+
180
+ def _bump_version(current: str, bump: str) -> str:
181
+ parts = current.split(".")
182
+ try:
183
+ major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2].split("-")[0])
184
+ except (IndexError, ValueError):
185
+ return "0.1.0"
186
+ if bump == "major":
187
+ return f"{major + 1}.0.0"
188
+ if bump == "minor":
189
+ return f"{major}.{minor + 1}.0"
190
+ return f"{major}.{minor}.{patch + 1}"
191
+
192
+
193
+ def generate_changelog(api_key: str, model: str = _MODEL) -> tuple[ChangelogEntry, str]:
194
+ """Return (ChangelogEntry, new_version_string)."""
195
+ import datetime
196
+ client = OpenAI(api_key=api_key)
197
+ current = _get_current_version()
198
+ commits = _get_commits_since_tag()
199
+ diff = get_diff(base="HEAD~10") if not commits else get_diff(base=f"v{current}" if current != "0.0.0" else "HEAD~10")
200
+
201
+ if not commits:
202
+ raise ValueError("No commits found since last tag. Nothing to changelog.")
203
+
204
+ user_msg = CHANGELOG_USER.format(
205
+ current_version=current, commits=commits, diff=diff[:12_000] or "(empty)"
206
+ )
207
+ resp = client.chat.completions.create(
208
+ model=model,
209
+ max_tokens=1024,
210
+ messages=[
211
+ {"role": "system", "content": CHANGELOG_SYSTEM},
212
+ {"role": "user", "content": user_msg},
213
+ ],
214
+ )
215
+ raw = (resp.choices[0].message.content or "{}").strip()
216
+ if raw.startswith("```"):
217
+ raw = "\n".join(raw.splitlines()[1:])
218
+ if raw.endswith("```"):
219
+ raw = "\n".join(raw.splitlines()[:-1])
220
+
221
+ data = json.loads(raw.strip())
222
+ entry = ChangelogEntry(
223
+ version_bump=data.get("version", "patch"),
224
+ highlights=data.get("highlights", ""),
225
+ added=data.get("added", []),
226
+ changed=data.get("changed", []),
227
+ fixed=data.get("fixed", []),
228
+ removed=data.get("removed", []),
229
+ security=data.get("security", []),
230
+ )
231
+ new_version = _bump_version(current, entry.version_bump)
232
+ return entry, new_version
@@ -3,7 +3,7 @@ import argparse
3
3
  import os
4
4
  import sys
5
5
 
6
- from .analyzer import describe_pr, suggest_labels, review_pr
6
+ from .analyzer import describe_pr, suggest_labels, review_pr, review_pr_as_comment, generate_changelog
7
7
 
8
8
  _RESET = "\033[0m"
9
9
  _BOLD = "\033[1m"
@@ -52,6 +52,54 @@ def cmd_review(args: argparse.Namespace) -> None:
52
52
  print()
53
53
 
54
54
 
55
+ def cmd_comment(args: argparse.Namespace) -> None:
56
+ """Post AI review as a PR comment on GitHub."""
57
+ from .github_client import upsert_comment
58
+ from .templates import REVIEW_COMMENT_HEADER
59
+
60
+ repo = args.repo or os.environ.get("GITHUB_REPOSITORY", "")
61
+ pr_num = args.pr or int(os.environ.get("PR_NUMBER", "0"))
62
+
63
+ if not repo or not pr_num:
64
+ print("pr-pilot comment: --repo and --pr are required (or set GITHUB_REPOSITORY / PR_NUMBER)", file=sys.stderr)
65
+ sys.exit(1)
66
+
67
+ print(f"\n {_DIM}Generating review for PR #{pr_num}...{_RESET}\n")
68
+ comment_body = review_pr_as_comment(_key(), base=args.base, model=args.model)
69
+ url = upsert_comment(repo, pr_num, comment_body, REVIEW_COMMENT_HEADER)
70
+ print(f" {_GREEN}✓{_RESET} Review posted: {url}\n")
71
+
72
+
73
+ def cmd_changelog(args: argparse.Namespace) -> None:
74
+ import datetime
75
+ print(f"\n {_DIM}Generating changelog from commits...{_RESET}\n")
76
+ entry, new_version = generate_changelog(_key(), model=args.model)
77
+ today = datetime.date.today().isoformat()
78
+ md = entry.to_markdown(new_version, today)
79
+
80
+ print(f"{_BOLD}Suggested version bump:{_RESET} {entry.version_bump} → {_GREEN}{new_version}{_RESET}")
81
+ print(f"{_BOLD}Highlights:{_RESET} {entry.highlights}\n")
82
+ print(md)
83
+
84
+ if args.output:
85
+ changelog_path = args.output
86
+ import pathlib
87
+ p = pathlib.Path(changelog_path)
88
+ if p.exists():
89
+ existing = p.read_text()
90
+ # Insert after the first line (# Changelog header) if present
91
+ if existing.startswith("# "):
92
+ header, rest = existing.split("\n", 1)
93
+ new_content = f"{header}\n\n{md}\n{rest}"
94
+ else:
95
+ new_content = f"{md}\n\n{existing}"
96
+ else:
97
+ new_content = f"# Changelog\n\n{md}\n"
98
+ p.write_text(new_content)
99
+ print(f"\n {_GREEN}✓{_RESET} Prepended to {changelog_path}")
100
+ print()
101
+
102
+
55
103
  def cmd_action(args: argparse.Namespace) -> None:
56
104
  """Run as a GitHub Action — reads env vars set by the Actions runner."""
57
105
  import json as _json
@@ -107,6 +155,21 @@ def main() -> None:
107
155
  p_rev.add_argument("--model", default="gpt-4o", help="OpenAI model to use")
108
156
  p_rev.set_defaults(func=cmd_review)
109
157
 
158
+ # --- comment ---
159
+ p_com = sub.add_parser("comment", help="Post an AI review as a GitHub PR comment")
160
+ p_com.add_argument("--base", default="main", help="Base branch to diff against (default: main)")
161
+ p_com.add_argument("--model", default="gpt-4o", help="OpenAI model to use")
162
+ p_com.add_argument("--repo", default=None, help="GitHub repo slug (e.g. owner/repo)")
163
+ p_com.add_argument("--pr", type=int, default=None, help="PR number")
164
+ p_com.set_defaults(func=cmd_comment)
165
+
166
+ # --- changelog ---
167
+ p_cl = sub.add_parser("changelog", help="Generate a CHANGELOG.md entry from commits since last tag")
168
+ p_cl.add_argument("--model", default="gpt-4o", help="OpenAI model to use")
169
+ p_cl.add_argument("--output", default=None, metavar="FILE",
170
+ help="Prepend entry to FILE (e.g. CHANGELOG.md). Prints to stdout if omitted.")
171
+ p_cl.set_defaults(func=cmd_changelog)
172
+
110
173
  # --- action (internal, called by entrypoint.sh) ---
111
174
  p_act = sub.add_parser("action", help="Run as a GitHub Action (internal)")
112
175
  p_act.add_argument("--model", default="gpt-4o")
@@ -78,3 +78,14 @@ def add_labels(repo: str, pr_number: int, labels: list[str]) -> None:
78
78
 
79
79
  def post_comment(repo: str, pr_number: int, body: str) -> None:
80
80
  _api("POST", f"/repos/{repo}/issues/{pr_number}/comments", {"body": body})
81
+
82
+
83
+ def upsert_comment(repo: str, pr_number: int, body: str, marker: str) -> str:
84
+ """Post or update a comment that contains `marker`. Returns the comment URL."""
85
+ comments = _api("GET", f"/repos/{repo}/issues/{pr_number}/comments?per_page=100")
86
+ for comment in comments:
87
+ if marker in (comment.get("body") or ""):
88
+ data = _api("PATCH", f"/repos/{repo}/issues/comments/{comment['id']}", {"body": body})
89
+ return data.get("html_url", "")
90
+ data = _api("POST", f"/repos/{repo}/issues/{pr_number}/comments", {"body": body})
91
+ return data.get("html_url", "")
@@ -48,3 +48,46 @@ Given a diff, summarize:
48
48
 
49
49
  Be direct and specific. No flattery. Format as plain markdown.
50
50
  """
51
+
52
+ REVIEW_COMMENT_HEADER = "<!-- pr-pilot-review -->"
53
+
54
+ REVIEW_COMMENT_TEMPLATE = """\
55
+ {header}
56
+ ### 🤖 pr-pilot review
57
+
58
+ {body}
59
+
60
+ ---
61
+ <sub>Generated by [pr-pilot](https://github.com/albertusreza/pr-pilot) · [pullwise](https://pypi.org/project/pullwise/) on PyPI</sub>
62
+ """
63
+
64
+ CHANGELOG_SYSTEM = """\
65
+ You are a technical writer generating a CHANGELOG entry for a software release.
66
+ Given git commits and a diff summary, write a well-structured CHANGELOG section.
67
+
68
+ Return a JSON object with exactly these keys:
69
+ {
70
+ "version": "suggested semver bump: patch | minor | major",
71
+ "highlights": "one-sentence release summary",
72
+ "added": ["list of new features added"],
73
+ "changed": ["list of changes to existing functionality"],
74
+ "fixed": ["list of bug fixes"],
75
+ "removed": ["list of removed features"],
76
+ "security": ["list of security fixes"]
77
+ }
78
+
79
+ Rules:
80
+ - Only include keys with at least one item (empty lists are OK, they'll be filtered)
81
+ - Follow Keep a Changelog format: https://keepachangelog.com
82
+ - Be specific and user-facing. Omit internal refactors unless they affect behavior.
83
+ - Return ONLY the JSON object, no markdown fences
84
+ """
85
+
86
+ CHANGELOG_USER = """\
87
+ Version to release from: {current_version}
88
+ Commits since last tag:
89
+ {commits}
90
+
91
+ Diff summary (may be truncated):
92
+ {diff}
93
+ """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pullwise
3
- Version: 0.1.0
3
+ Version: 0.2.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
@@ -21,6 +21,11 @@ Requires-Dist: pytest; extra == "dev"
21
21
 
22
22
  # pr-pilot ✈️
23
23
 
24
+ [![PyPI](https://img.shields.io/pypi/v/pullwise)](https://pypi.org/project/pullwise/)
25
+ [![Downloads](https://img.shields.io/pypi/dm/pullwise)](https://pypi.org/project/pullwise/)
26
+ [![CI](https://github.com/albertusreza/pr-pilot/actions/workflows/ci.yml/badge.svg)](https://github.com/albertusreza/pr-pilot/actions)
27
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
28
+
24
29
  **Stop writing PR descriptions.** Let AI do it.
25
30
 
26
31
  `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.
@@ -87,7 +92,7 @@ Add `OPENAI_API_KEY` to your repo secrets and you're done. Works with any langua
87
92
  ### As a CLI
88
93
 
89
94
  ```bash
90
- pip install pr-pilot
95
+ pip install pullwise
91
96
  export OPENAI_API_KEY=sk-...
92
97
 
93
98
  # Generate a description for your current branch
@@ -103,6 +108,50 @@ pr-pilot review
103
108
  pr-pilot describe --markdown pr_description.md
104
109
  ```
105
110
 
111
+ ## Features
112
+
113
+ | Command | What it does |
114
+ |---|---|
115
+ | `pr-pilot describe` | Generate a structured PR description from your diff |
116
+ | `pr-pilot review` | Get a senior-engineer-style code review in the terminal |
117
+ | `pr-pilot comment` | Post an AI review as a GitHub PR comment (updates on re-run, no spam) |
118
+ | `pr-pilot changelog` | Generate a `CHANGELOG.md` entry from commits since last tag |
119
+
120
+ ## Usage
121
+
122
+ ```bash
123
+ # Generate PR description (terminal)
124
+ pr-pilot describe --base main
125
+
126
+ # Get a quick code review in the terminal
127
+ pr-pilot review --base main
128
+
129
+ # Post review as a GitHub PR comment
130
+ pr-pilot comment --repo owner/repo --pr 42
131
+
132
+ # Generate CHANGELOG entry (print to stdout)
133
+ pr-pilot changelog
134
+
135
+ # Write directly into CHANGELOG.md
136
+ pr-pilot changelog --output CHANGELOG.md
137
+ ```
138
+
139
+ ### Auto-comment on every PR (GitHub Action)
140
+
141
+ ```yaml
142
+ # .github/workflows/pr-review.yml
143
+ - uses: actions/checkout@v4
144
+ with:
145
+ fetch-depth: 0
146
+ - run: pip install pullwise
147
+ - run: pr-pilot comment --repo ${{ github.repository }} --pr ${{ github.event.pull_request.number }}
148
+ env:
149
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
150
+ GITHUB_TOKEN: ${{ github.token }}
151
+ ```
152
+
153
+ The comment updates itself on every new push — no duplicate comments.
154
+
106
155
  ## Options
107
156
 
108
157
  | Input | Default | Description |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pullwise"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "AI-powered PR descriptions, labels, and code review using OpenAI"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -0,0 +1,162 @@
1
+ from __future__ import annotations
2
+ import json
3
+ from unittest.mock import MagicMock, patch
4
+ from pr_pilot.analyzer import (
5
+ describe_pr, suggest_labels, review_pr, PRDescription,
6
+ review_pr_as_comment, generate_changelog, ChangelogEntry,
7
+ )
8
+ from pr_pilot.templates import REVIEW_COMMENT_HEADER
9
+
10
+
11
+ def _mock_openai_response(content: str) -> MagicMock:
12
+ choice = MagicMock()
13
+ choice.message.content = content
14
+ resp = MagicMock()
15
+ resp.choices = [choice]
16
+ return resp
17
+
18
+
19
+ _SAMPLE_DESC = {
20
+ "title": "Add dark mode support",
21
+ "summary": "Implements a dark mode toggle that persists via localStorage.",
22
+ "changes": ["Add ThemeToggle component", "Add useTheme hook", "Update global CSS variables"],
23
+ "breaking": False,
24
+ "breaking_notes": None,
25
+ "test_plan": "Toggle dark mode, reload page — preference should persist.",
26
+ "labels": ["feature"],
27
+ }
28
+
29
+
30
+ @patch("pr_pilot.analyzer.get_diff", return_value="diff --git a/theme.css ...")
31
+ @patch("pr_pilot.analyzer.get_commits", return_value="abc123 Add dark mode")
32
+ @patch("pr_pilot.analyzer.get_branch", return_value="feat/dark-mode")
33
+ def test_describe_pr_basic(mock_branch, mock_commits, mock_diff):
34
+ with patch("pr_pilot.analyzer.OpenAI") as MockOpenAI:
35
+ MockOpenAI.return_value.chat.completions.create.return_value = \
36
+ _mock_openai_response(json.dumps(_SAMPLE_DESC))
37
+ desc = describe_pr("fake-key", base="main")
38
+ assert desc.title == "Add dark mode support"
39
+ assert desc.breaking is False
40
+ assert "feature" in desc.labels
41
+ assert len(desc.changes) == 3
42
+
43
+
44
+ @patch("pr_pilot.analyzer.get_diff", return_value="diff --git a/theme.css ...")
45
+ @patch("pr_pilot.analyzer.get_commits", return_value="abc123 Add dark mode")
46
+ @patch("pr_pilot.analyzer.get_branch", return_value="feat/dark-mode")
47
+ def test_describe_pr_strips_markdown_fence(mock_branch, mock_commits, mock_diff):
48
+ raw = f"```json\n{json.dumps(_SAMPLE_DESC)}\n```"
49
+ with patch("pr_pilot.analyzer.OpenAI") as MockOpenAI:
50
+ MockOpenAI.return_value.chat.completions.create.return_value = \
51
+ _mock_openai_response(raw)
52
+ desc = describe_pr("fake-key")
53
+ assert desc.title == "Add dark mode support"
54
+
55
+
56
+ def test_describe_pr_to_markdown():
57
+ desc = PRDescription(**_SAMPLE_DESC)
58
+ md = desc.to_markdown()
59
+ assert "## Summary" in md
60
+ assert "## Changes" in md
61
+ assert "## Test Plan" in md
62
+ assert "ThemeToggle" in md
63
+
64
+
65
+ def test_suggest_labels():
66
+ with patch("pr_pilot.analyzer.OpenAI") as MockOpenAI:
67
+ MockOpenAI.return_value.chat.completions.create.return_value = \
68
+ _mock_openai_response('["bug", "security"]')
69
+ labels = suggest_labels("fake-key", "Fix XSS in comment input", "sanitize user input")
70
+ assert "bug" in labels
71
+ assert "security" in labels
72
+
73
+
74
+ @patch("pr_pilot.analyzer.get_diff", return_value="- old line\n+ new line")
75
+ @patch("pr_pilot.analyzer.get_commits", return_value="")
76
+ @patch("pr_pilot.analyzer.get_branch", return_value="fix/xss")
77
+ def test_review_pr(mock_branch, mock_commits, mock_diff):
78
+ review_text = "This PR sanitizes user input to prevent XSS attacks."
79
+ with patch("pr_pilot.analyzer.OpenAI") as MockOpenAI:
80
+ MockOpenAI.return_value.chat.completions.create.return_value = \
81
+ _mock_openai_response(review_text)
82
+ result = review_pr("fake-key")
83
+ assert "XSS" in result
84
+
85
+
86
+ # ── Auto-comment tests ────────────────────────────────────────────────────────
87
+
88
+ @patch("pr_pilot.analyzer.get_diff", return_value="+ new feature code")
89
+ @patch("pr_pilot.analyzer.get_commits", return_value="")
90
+ @patch("pr_pilot.analyzer.get_branch", return_value="feat/x")
91
+ def test_review_pr_as_comment_contains_marker(mock_branch, mock_commits, mock_diff):
92
+ with patch("pr_pilot.analyzer.OpenAI") as MockOpenAI:
93
+ MockOpenAI.return_value.chat.completions.create.return_value = \
94
+ _mock_openai_response("Looks good overall.")
95
+ comment = review_pr_as_comment("fake-key")
96
+ assert REVIEW_COMMENT_HEADER in comment
97
+ assert "pr-pilot review" in comment
98
+ assert "Looks good overall." in comment
99
+
100
+
101
+ @patch("pr_pilot.analyzer.get_diff", return_value="")
102
+ @patch("pr_pilot.analyzer.get_commits", return_value="")
103
+ @patch("pr_pilot.analyzer.get_branch", return_value="main")
104
+ def test_review_pr_as_comment_no_diff(mock_branch, mock_commits, mock_diff):
105
+ with patch("pr_pilot.analyzer.OpenAI") as MockOpenAI:
106
+ MockOpenAI.return_value.chat.completions.create.return_value = \
107
+ _mock_openai_response("No changes to review.")
108
+ comment = review_pr_as_comment("fake-key")
109
+ assert "No changes to review." in comment
110
+
111
+
112
+ # ── Changelog tests ───────────────────────────────────────────────────────────
113
+
114
+ _SAMPLE_CHANGELOG = {
115
+ "version": "minor",
116
+ "highlights": "Adds dark mode and fixes login bug.",
117
+ "added": ["Dark mode toggle with localStorage persistence"],
118
+ "changed": ["Login flow now uses refresh tokens"],
119
+ "fixed": ["Session expiry on mobile Safari"],
120
+ "removed": [],
121
+ "security": [],
122
+ }
123
+
124
+
125
+ @patch("pr_pilot.analyzer.get_diff", return_value="diff --git a/theme.css ...")
126
+ @patch("pr_pilot.analyzer.get_commits", return_value="")
127
+ @patch("pr_pilot.analyzer.get_branch", return_value="main")
128
+ @patch("pr_pilot.analyzer._get_commits_since_tag", return_value="abc123 Add dark mode\ndef456 Fix login")
129
+ @patch("pr_pilot.analyzer._get_current_version", return_value="1.2.3")
130
+ def test_generate_changelog(mock_ver, mock_commits_tag, mock_branch, mock_commits, mock_diff):
131
+ with patch("pr_pilot.analyzer.OpenAI") as MockOpenAI:
132
+ MockOpenAI.return_value.chat.completions.create.return_value = \
133
+ _mock_openai_response(json.dumps(_SAMPLE_CHANGELOG))
134
+ entry, new_version = generate_changelog("fake-key")
135
+ assert entry.version_bump == "minor"
136
+ assert new_version == "1.3.0"
137
+ assert "Dark mode" in entry.added[0]
138
+ assert entry.security == []
139
+
140
+
141
+ def test_changelog_entry_to_markdown():
142
+ entry = ChangelogEntry(
143
+ version_bump="patch",
144
+ highlights="Bug fixes.",
145
+ added=[],
146
+ changed=[],
147
+ fixed=["Fix crash on startup"],
148
+ removed=[],
149
+ security=[],
150
+ )
151
+ md = entry.to_markdown("1.0.1", "2026-06-02")
152
+ assert "## [1.0.1] - 2026-06-02" in md
153
+ assert "### Fixed" in md
154
+ assert "Fix crash on startup" in md
155
+ assert "### Added" not in md # empty section should be omitted
156
+
157
+
158
+ def test_version_bump():
159
+ from pr_pilot.analyzer import _bump_version
160
+ assert _bump_version("1.2.3", "patch") == "1.2.4"
161
+ assert _bump_version("1.2.3", "minor") == "1.3.0"
162
+ assert _bump_version("1.2.3", "major") == "2.0.0"
@@ -1 +0,0 @@
1
- __version__ = "0.1.0"
@@ -1,123 +0,0 @@
1
- from __future__ import annotations
2
- import json
3
- import subprocess
4
- from dataclasses import dataclass, field
5
-
6
- from openai import OpenAI
7
-
8
- from .templates import DESCRIBE_SYSTEM, DESCRIBE_USER, LABEL_SYSTEM, REVIEW_SYSTEM
9
-
10
- _MAX_DIFF_CHARS = 24_000 # stay well within context limits
11
- _MODEL = "gpt-4o"
12
-
13
-
14
- @dataclass
15
- class PRDescription:
16
- title: str
17
- summary: str
18
- changes: list[str]
19
- breaking: bool
20
- breaking_notes: str | None
21
- test_plan: str
22
- labels: list[str]
23
-
24
- def to_markdown(self) -> str:
25
- lines = [
26
- f"## Summary\n{self.summary}\n",
27
- "## Changes",
28
- ]
29
- for c in self.changes:
30
- lines.append(f"- {c}")
31
- if self.breaking:
32
- lines.append(f"\n## ⚠️ Breaking Changes\n{self.breaking_notes}")
33
- lines.append(f"\n## Test Plan\n{self.test_plan}")
34
- return "\n".join(lines)
35
-
36
-
37
- def _git(*args: str) -> str:
38
- result = subprocess.run(["git"] + list(args), capture_output=True, text=True)
39
- return result.stdout.strip()
40
-
41
-
42
- def get_diff(base: str = "main") -> str:
43
- diff = _git("diff", f"{base}...HEAD")
44
- if len(diff) > _MAX_DIFF_CHARS:
45
- diff = diff[:_MAX_DIFF_CHARS] + "\n\n[diff truncated — showing first 24k chars]"
46
- return diff
47
-
48
-
49
- def get_commits(base: str = "main") -> str:
50
- return _git("log", f"{base}...HEAD", "--oneline", "--no-merges")
51
-
52
-
53
- def get_branch() -> str:
54
- return _git("rev-parse", "--abbrev-ref", "HEAD")
55
-
56
-
57
- def describe_pr(api_key: str, base: str = "main", model: str = _MODEL) -> PRDescription:
58
- client = OpenAI(api_key=api_key)
59
- diff = get_diff(base)
60
- commits = get_commits(base)
61
- branch = get_branch()
62
-
63
- if not diff and not commits:
64
- raise ValueError(f"No changes found between '{branch}' and '{base}'")
65
-
66
- user_msg = DESCRIBE_USER.format(
67
- branch=branch, base=base, commits=commits or "(none)", diff=diff or "(empty)"
68
- )
69
- resp = client.chat.completions.create(
70
- model=model,
71
- max_tokens=1024,
72
- messages=[
73
- {"role": "system", "content": DESCRIBE_SYSTEM},
74
- {"role": "user", "content": user_msg},
75
- ],
76
- )
77
- raw = resp.choices[0].message.content or "{}"
78
- raw = raw.strip()
79
- if raw.startswith("```"):
80
- raw = "\n".join(raw.splitlines()[1:])
81
- if raw.endswith("```"):
82
- raw = "\n".join(raw.splitlines()[:-1])
83
-
84
- data = json.loads(raw.strip())
85
- return PRDescription(
86
- title=data.get("title", branch),
87
- summary=data.get("summary", ""),
88
- changes=data.get("changes", []),
89
- breaking=data.get("breaking", False),
90
- breaking_notes=data.get("breaking_notes"),
91
- test_plan=data.get("test_plan", ""),
92
- labels=data.get("labels", ["feature"]),
93
- )
94
-
95
-
96
- def suggest_labels(api_key: str, title: str, body: str, model: str = _MODEL) -> list[str]:
97
- client = OpenAI(api_key=api_key)
98
- resp = client.chat.completions.create(
99
- model=model,
100
- max_tokens=64,
101
- messages=[
102
- {"role": "system", "content": LABEL_SYSTEM},
103
- {"role": "user", "content": f"Title: {title}\n\nBody: {body[:2000]}"},
104
- ],
105
- )
106
- raw = resp.choices[0].message.content or "[]"
107
- return json.loads(raw.strip())
108
-
109
-
110
- def review_pr(api_key: str, base: str = "main", model: str = _MODEL) -> str:
111
- client = OpenAI(api_key=api_key)
112
- diff = get_diff(base)
113
- if not diff:
114
- return "No changes to review."
115
- resp = client.chat.completions.create(
116
- model=model,
117
- max_tokens=1024,
118
- messages=[
119
- {"role": "system", "content": REVIEW_SYSTEM},
120
- {"role": "user", "content": f"Diff:\n\n{diff}"},
121
- ],
122
- )
123
- return (resp.choices[0].message.content or "").strip()
@@ -1,79 +0,0 @@
1
- from __future__ import annotations
2
- import json
3
- from unittest.mock import MagicMock, patch
4
- from pr_pilot.analyzer import describe_pr, suggest_labels, review_pr, PRDescription
5
-
6
-
7
- def _mock_openai_response(content: str) -> MagicMock:
8
- choice = MagicMock()
9
- choice.message.content = content
10
- resp = MagicMock()
11
- resp.choices = [choice]
12
- return resp
13
-
14
-
15
- _SAMPLE_DESC = {
16
- "title": "Add dark mode support",
17
- "summary": "Implements a dark mode toggle that persists via localStorage.",
18
- "changes": ["Add ThemeToggle component", "Add useTheme hook", "Update global CSS variables"],
19
- "breaking": False,
20
- "breaking_notes": None,
21
- "test_plan": "Toggle dark mode, reload page — preference should persist.",
22
- "labels": ["feature"],
23
- }
24
-
25
-
26
- @patch("pr_pilot.analyzer.get_diff", return_value="diff --git a/theme.css ...")
27
- @patch("pr_pilot.analyzer.get_commits", return_value="abc123 Add dark mode")
28
- @patch("pr_pilot.analyzer.get_branch", return_value="feat/dark-mode")
29
- def test_describe_pr_basic(mock_branch, mock_commits, mock_diff):
30
- with patch("pr_pilot.analyzer.OpenAI") as MockOpenAI:
31
- MockOpenAI.return_value.chat.completions.create.return_value = \
32
- _mock_openai_response(json.dumps(_SAMPLE_DESC))
33
- desc = describe_pr("fake-key", base="main")
34
- assert desc.title == "Add dark mode support"
35
- assert desc.breaking is False
36
- assert "feature" in desc.labels
37
- assert len(desc.changes) == 3
38
-
39
-
40
- @patch("pr_pilot.analyzer.get_diff", return_value="diff --git a/theme.css ...")
41
- @patch("pr_pilot.analyzer.get_commits", return_value="abc123 Add dark mode")
42
- @patch("pr_pilot.analyzer.get_branch", return_value="feat/dark-mode")
43
- def test_describe_pr_strips_markdown_fence(mock_branch, mock_commits, mock_diff):
44
- raw = f"```json\n{json.dumps(_SAMPLE_DESC)}\n```"
45
- with patch("pr_pilot.analyzer.OpenAI") as MockOpenAI:
46
- MockOpenAI.return_value.chat.completions.create.return_value = \
47
- _mock_openai_response(raw)
48
- desc = describe_pr("fake-key")
49
- assert desc.title == "Add dark mode support"
50
-
51
-
52
- def test_describe_pr_to_markdown():
53
- desc = PRDescription(**_SAMPLE_DESC)
54
- md = desc.to_markdown()
55
- assert "## Summary" in md
56
- assert "## Changes" in md
57
- assert "## Test Plan" in md
58
- assert "ThemeToggle" in md
59
-
60
-
61
- def test_suggest_labels():
62
- with patch("pr_pilot.analyzer.OpenAI") as MockOpenAI:
63
- MockOpenAI.return_value.chat.completions.create.return_value = \
64
- _mock_openai_response('["bug", "security"]')
65
- labels = suggest_labels("fake-key", "Fix XSS in comment input", "sanitize user input")
66
- assert "bug" in labels
67
- assert "security" in labels
68
-
69
-
70
- @patch("pr_pilot.analyzer.get_diff", return_value="- old line\n+ new line")
71
- @patch("pr_pilot.analyzer.get_commits", return_value="")
72
- @patch("pr_pilot.analyzer.get_branch", return_value="fix/xss")
73
- def test_review_pr(mock_branch, mock_commits, mock_diff):
74
- review_text = "This PR sanitizes user input to prevent XSS attacks."
75
- with patch("pr_pilot.analyzer.OpenAI") as MockOpenAI:
76
- MockOpenAI.return_value.chat.completions.create.return_value = \
77
- _mock_openai_response(review_text)
78
- result = review_pr("fake-key")
79
- assert "XSS" in result
File without changes