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.
- {pullwise-0.1.0 → pullwise-0.2.0}/PKG-INFO +51 -2
- {pullwise-0.1.0 → pullwise-0.2.0}/README.md +50 -1
- pullwise-0.2.0/pr_pilot/__init__.py +1 -0
- pullwise-0.2.0/pr_pilot/analyzer.py +232 -0
- {pullwise-0.1.0 → pullwise-0.2.0}/pr_pilot/cli.py +64 -1
- {pullwise-0.1.0 → pullwise-0.2.0}/pr_pilot/github_client.py +11 -0
- {pullwise-0.1.0 → pullwise-0.2.0}/pr_pilot/templates.py +43 -0
- {pullwise-0.1.0 → pullwise-0.2.0}/pullwise.egg-info/PKG-INFO +51 -2
- {pullwise-0.1.0 → pullwise-0.2.0}/pyproject.toml +1 -1
- pullwise-0.2.0/tests/test_analyzer.py +162 -0
- pullwise-0.1.0/pr_pilot/__init__.py +0 -1
- pullwise-0.1.0/pr_pilot/analyzer.py +0 -123
- pullwise-0.1.0/tests/test_analyzer.py +0 -79
- {pullwise-0.1.0 → pullwise-0.2.0}/pullwise.egg-info/SOURCES.txt +0 -0
- {pullwise-0.1.0 → pullwise-0.2.0}/pullwise.egg-info/dependency_links.txt +0 -0
- {pullwise-0.1.0 → pullwise-0.2.0}/pullwise.egg-info/entry_points.txt +0 -0
- {pullwise-0.1.0 → pullwise-0.2.0}/pullwise.egg-info/requires.txt +0 -0
- {pullwise-0.1.0 → pullwise-0.2.0}/pullwise.egg-info/top_level.txt +0 -0
- {pullwise-0.1.0 → pullwise-0.2.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.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
|
+
[](https://pypi.org/project/pullwise/)
|
|
25
|
+
[](https://pypi.org/project/pullwise/)
|
|
26
|
+
[](https://github.com/albertusreza/pr-pilot/actions)
|
|
27
|
+
[](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
|
|
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
|
+
[](https://pypi.org/project/pullwise/)
|
|
4
|
+
[](https://pypi.org/project/pullwise/)
|
|
5
|
+
[](https://github.com/albertusreza/pr-pilot/actions)
|
|
6
|
+
[](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
|
|
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.
|
|
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
|
+
[](https://pypi.org/project/pullwise/)
|
|
25
|
+
[](https://pypi.org/project/pullwise/)
|
|
26
|
+
[](https://github.com/albertusreza/pr-pilot/actions)
|
|
27
|
+
[](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
|
|
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 |
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|