pullwise 0.1.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pullwise-0.1.0 → pullwise-0.3.0}/PKG-INFO +77 -4
- {pullwise-0.1.0 → pullwise-0.3.0}/README.md +76 -2
- pullwise-0.3.0/pr_pilot/__init__.py +1 -0
- pullwise-0.3.0/pr_pilot/analyzer.py +392 -0
- pullwise-0.3.0/pr_pilot/cli.py +299 -0
- {pullwise-0.1.0 → pullwise-0.3.0}/pr_pilot/github_client.py +11 -0
- pullwise-0.3.0/pr_pilot/templates.py +177 -0
- {pullwise-0.1.0 → pullwise-0.3.0}/pullwise.egg-info/PKG-INFO +77 -4
- {pullwise-0.1.0 → pullwise-0.3.0}/pyproject.toml +4 -2
- pullwise-0.3.0/tests/test_analyzer.py +240 -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/pr_pilot/cli.py +0 -120
- pullwise-0.1.0/pr_pilot/templates.py +0 -50
- pullwise-0.1.0/tests/test_analyzer.py +0 -79
- {pullwise-0.1.0 → pullwise-0.3.0}/pullwise.egg-info/SOURCES.txt +0 -0
- {pullwise-0.1.0 → pullwise-0.3.0}/pullwise.egg-info/dependency_links.txt +0 -0
- {pullwise-0.1.0 → pullwise-0.3.0}/pullwise.egg-info/entry_points.txt +0 -0
- {pullwise-0.1.0 → pullwise-0.3.0}/pullwise.egg-info/requires.txt +0 -0
- {pullwise-0.1.0 → pullwise-0.3.0}/pullwise.egg-info/top_level.txt +0 -0
- {pullwise-0.1.0 → pullwise-0.3.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pullwise
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: AI-powered PR descriptions, labels, and code review using OpenAI
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/albertusreza/pr-pilot
|
|
@@ -10,7 +10,6 @@ Keywords: github,pull-request,openai,ai,automation,devtools,cli,github-actions
|
|
|
10
10
|
Classifier: Development Status :: 3 - Alpha
|
|
11
11
|
Classifier: Environment :: Console
|
|
12
12
|
Classifier: Intended Audience :: Developers
|
|
13
|
-
Classifier: License :: OSI Approved :: MIT License
|
|
14
13
|
Classifier: Programming Language :: Python :: 3
|
|
15
14
|
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
16
15
|
Requires-Python: >=3.9
|
|
@@ -19,10 +18,17 @@ Requires-Dist: openai>=1.0.0
|
|
|
19
18
|
Provides-Extra: dev
|
|
20
19
|
Requires-Dist: pytest; extra == "dev"
|
|
21
20
|
|
|
22
|
-
#
|
|
21
|
+
# PullWise ✈️
|
|
22
|
+
|
|
23
|
+
[](https://pypi.org/project/pullwise/)
|
|
24
|
+
[](https://pypi.org/project/pullwise/)
|
|
25
|
+
[](https://github.com/albertusreza/pr-pilot/actions)
|
|
26
|
+
[](LICENSE)
|
|
23
27
|
|
|
24
28
|
**Stop writing PR descriptions.** Let AI do it.
|
|
25
29
|
|
|
30
|
+
> 📦 GitHub Action: [`albertusreza/pr-pilot`](https://github.com/marketplace/actions/pullwise) · PyPI: [`pullwise`](https://pypi.org/project/pullwise/)
|
|
31
|
+
|
|
26
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.
|
|
27
33
|
|
|
28
34
|
```yaml
|
|
@@ -87,7 +93,7 @@ Add `OPENAI_API_KEY` to your repo secrets and you're done. Works with any langua
|
|
|
87
93
|
### As a CLI
|
|
88
94
|
|
|
89
95
|
```bash
|
|
90
|
-
pip install
|
|
96
|
+
pip install pullwise
|
|
91
97
|
export OPENAI_API_KEY=sk-...
|
|
92
98
|
|
|
93
99
|
# Generate a description for your current branch
|
|
@@ -103,6 +109,73 @@ pr-pilot review
|
|
|
103
109
|
pr-pilot describe --markdown pr_description.md
|
|
104
110
|
```
|
|
105
111
|
|
|
112
|
+
## Features
|
|
113
|
+
|
|
114
|
+
| Command | What it does |
|
|
115
|
+
|---|---|
|
|
116
|
+
| `pr-pilot describe` | Generate a structured PR description from your diff |
|
|
117
|
+
| `pr-pilot review` | Get a senior-engineer-style code review in the terminal |
|
|
118
|
+
| `pr-pilot comment` | Post an AI review as a GitHub PR comment (updates on re-run, no spam) |
|
|
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 |
|
|
123
|
+
|
|
124
|
+
## Usage
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
# Generate PR description (terminal)
|
|
128
|
+
pr-pilot describe --base main
|
|
129
|
+
|
|
130
|
+
# Get a quick code review in the terminal
|
|
131
|
+
pr-pilot review --base main
|
|
132
|
+
|
|
133
|
+
# Post review as a GitHub PR comment
|
|
134
|
+
pr-pilot comment --repo owner/repo --pr 42
|
|
135
|
+
|
|
136
|
+
# Generate CHANGELOG entry (print to stdout)
|
|
137
|
+
pr-pilot changelog
|
|
138
|
+
|
|
139
|
+
# Write directly into CHANGELOG.md
|
|
140
|
+
pr-pilot changelog --output CHANGELOG.md
|
|
141
|
+
```
|
|
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
|
+
|
|
163
|
+
### Auto-comment on every PR (GitHub Action)
|
|
164
|
+
|
|
165
|
+
```yaml
|
|
166
|
+
# .github/workflows/pr-review.yml
|
|
167
|
+
- uses: actions/checkout@v4
|
|
168
|
+
with:
|
|
169
|
+
fetch-depth: 0
|
|
170
|
+
- run: pip install pullwise
|
|
171
|
+
- run: pr-pilot comment --repo ${{ github.repository }} --pr ${{ github.event.pull_request.number }}
|
|
172
|
+
env:
|
|
173
|
+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
174
|
+
GITHUB_TOKEN: ${{ github.token }}
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
The comment updates itself on every new push — no duplicate comments.
|
|
178
|
+
|
|
106
179
|
## Options
|
|
107
180
|
|
|
108
181
|
| Input | Default | Description |
|
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
#
|
|
1
|
+
# PullWise ✈️
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/pullwise/)
|
|
4
|
+
[](https://pypi.org/project/pullwise/)
|
|
5
|
+
[](https://github.com/albertusreza/pr-pilot/actions)
|
|
6
|
+
[](LICENSE)
|
|
2
7
|
|
|
3
8
|
**Stop writing PR descriptions.** Let AI do it.
|
|
4
9
|
|
|
10
|
+
> 📦 GitHub Action: [`albertusreza/pr-pilot`](https://github.com/marketplace/actions/pullwise) · PyPI: [`pullwise`](https://pypi.org/project/pullwise/)
|
|
11
|
+
|
|
5
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.
|
|
6
13
|
|
|
7
14
|
```yaml
|
|
@@ -66,7 +73,7 @@ Add `OPENAI_API_KEY` to your repo secrets and you're done. Works with any langua
|
|
|
66
73
|
### As a CLI
|
|
67
74
|
|
|
68
75
|
```bash
|
|
69
|
-
pip install
|
|
76
|
+
pip install pullwise
|
|
70
77
|
export OPENAI_API_KEY=sk-...
|
|
71
78
|
|
|
72
79
|
# Generate a description for your current branch
|
|
@@ -82,6 +89,73 @@ pr-pilot review
|
|
|
82
89
|
pr-pilot describe --markdown pr_description.md
|
|
83
90
|
```
|
|
84
91
|
|
|
92
|
+
## Features
|
|
93
|
+
|
|
94
|
+
| Command | What it does |
|
|
95
|
+
|---|---|
|
|
96
|
+
| `pr-pilot describe` | Generate a structured PR description from your diff |
|
|
97
|
+
| `pr-pilot review` | Get a senior-engineer-style code review in the terminal |
|
|
98
|
+
| `pr-pilot comment` | Post an AI review as a GitHub PR comment (updates on re-run, no spam) |
|
|
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 |
|
|
103
|
+
|
|
104
|
+
## Usage
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
# Generate PR description (terminal)
|
|
108
|
+
pr-pilot describe --base main
|
|
109
|
+
|
|
110
|
+
# Get a quick code review in the terminal
|
|
111
|
+
pr-pilot review --base main
|
|
112
|
+
|
|
113
|
+
# Post review as a GitHub PR comment
|
|
114
|
+
pr-pilot comment --repo owner/repo --pr 42
|
|
115
|
+
|
|
116
|
+
# Generate CHANGELOG entry (print to stdout)
|
|
117
|
+
pr-pilot changelog
|
|
118
|
+
|
|
119
|
+
# Write directly into CHANGELOG.md
|
|
120
|
+
pr-pilot changelog --output CHANGELOG.md
|
|
121
|
+
```
|
|
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
|
+
|
|
143
|
+
### Auto-comment on every PR (GitHub Action)
|
|
144
|
+
|
|
145
|
+
```yaml
|
|
146
|
+
# .github/workflows/pr-review.yml
|
|
147
|
+
- uses: actions/checkout@v4
|
|
148
|
+
with:
|
|
149
|
+
fetch-depth: 0
|
|
150
|
+
- run: pip install pullwise
|
|
151
|
+
- run: pr-pilot comment --repo ${{ github.repository }} --pr ${{ github.event.pull_request.number }}
|
|
152
|
+
env:
|
|
153
|
+
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
|
154
|
+
GITHUB_TOKEN: ${{ github.token }}
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
The comment updates itself on every new push — no duplicate comments.
|
|
158
|
+
|
|
85
159
|
## Options
|
|
86
160
|
|
|
87
161
|
| Input | Default | Description |
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.3.0"
|
|
@@ -0,0 +1,392 @@
|
|
|
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
|
+
REVIEWER_SYSTEM, REVIEWER_USER, REVIEWER_COMMENT_HEADER, REVIEWER_COMMENT_TEMPLATE,
|
|
12
|
+
STANDUP_SYSTEM, STANDUP_USER,
|
|
13
|
+
ISSUE_SYSTEM, ISSUE_USER,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
_MAX_DIFF_CHARS = 24_000 # stay well within context limits
|
|
17
|
+
_MODEL = "gpt-4o"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class PRDescription:
|
|
22
|
+
title: str
|
|
23
|
+
summary: str
|
|
24
|
+
changes: list[str]
|
|
25
|
+
breaking: bool
|
|
26
|
+
breaking_notes: str | None
|
|
27
|
+
test_plan: str
|
|
28
|
+
labels: list[str]
|
|
29
|
+
|
|
30
|
+
def to_markdown(self) -> str:
|
|
31
|
+
lines = [
|
|
32
|
+
f"## Summary\n{self.summary}\n",
|
|
33
|
+
"## Changes",
|
|
34
|
+
]
|
|
35
|
+
for c in self.changes:
|
|
36
|
+
lines.append(f"- {c}")
|
|
37
|
+
if self.breaking:
|
|
38
|
+
lines.append(f"\n## ⚠️ Breaking Changes\n{self.breaking_notes}")
|
|
39
|
+
lines.append(f"\n## Test Plan\n{self.test_plan}")
|
|
40
|
+
return "\n".join(lines)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _git(*args: str) -> str:
|
|
44
|
+
result = subprocess.run(["git"] + list(args), capture_output=True, text=True)
|
|
45
|
+
return result.stdout.strip()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_diff(base: str = "main") -> str:
|
|
49
|
+
diff = _git("diff", f"{base}...HEAD")
|
|
50
|
+
if len(diff) > _MAX_DIFF_CHARS:
|
|
51
|
+
diff = diff[:_MAX_DIFF_CHARS] + "\n\n[diff truncated — showing first 24k chars]"
|
|
52
|
+
return diff
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_commits(base: str = "main") -> str:
|
|
56
|
+
return _git("log", f"{base}...HEAD", "--oneline", "--no-merges")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def get_branch() -> str:
|
|
60
|
+
return _git("rev-parse", "--abbrev-ref", "HEAD")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def describe_pr(api_key: str, base: str = "main", model: str = _MODEL) -> PRDescription:
|
|
64
|
+
client = OpenAI(api_key=api_key)
|
|
65
|
+
diff = get_diff(base)
|
|
66
|
+
commits = get_commits(base)
|
|
67
|
+
branch = get_branch()
|
|
68
|
+
|
|
69
|
+
if not diff and not commits:
|
|
70
|
+
raise ValueError(f"No changes found between '{branch}' and '{base}'")
|
|
71
|
+
|
|
72
|
+
user_msg = DESCRIBE_USER.format(
|
|
73
|
+
branch=branch, base=base, commits=commits or "(none)", diff=diff or "(empty)"
|
|
74
|
+
)
|
|
75
|
+
resp = client.chat.completions.create(
|
|
76
|
+
model=model,
|
|
77
|
+
max_tokens=1024,
|
|
78
|
+
messages=[
|
|
79
|
+
{"role": "system", "content": DESCRIBE_SYSTEM},
|
|
80
|
+
{"role": "user", "content": user_msg},
|
|
81
|
+
],
|
|
82
|
+
)
|
|
83
|
+
raw = resp.choices[0].message.content or "{}"
|
|
84
|
+
raw = raw.strip()
|
|
85
|
+
if raw.startswith("```"):
|
|
86
|
+
raw = "\n".join(raw.splitlines()[1:])
|
|
87
|
+
if raw.endswith("```"):
|
|
88
|
+
raw = "\n".join(raw.splitlines()[:-1])
|
|
89
|
+
|
|
90
|
+
data = json.loads(raw.strip())
|
|
91
|
+
return PRDescription(
|
|
92
|
+
title=data.get("title", branch),
|
|
93
|
+
summary=data.get("summary", ""),
|
|
94
|
+
changes=data.get("changes", []),
|
|
95
|
+
breaking=data.get("breaking", False),
|
|
96
|
+
breaking_notes=data.get("breaking_notes"),
|
|
97
|
+
test_plan=data.get("test_plan", ""),
|
|
98
|
+
labels=data.get("labels", ["feature"]),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def suggest_labels(api_key: str, title: str, body: str, model: str = _MODEL) -> list[str]:
|
|
103
|
+
client = OpenAI(api_key=api_key)
|
|
104
|
+
resp = client.chat.completions.create(
|
|
105
|
+
model=model,
|
|
106
|
+
max_tokens=64,
|
|
107
|
+
messages=[
|
|
108
|
+
{"role": "system", "content": LABEL_SYSTEM},
|
|
109
|
+
{"role": "user", "content": f"Title: {title}\n\nBody: {body[:2000]}"},
|
|
110
|
+
],
|
|
111
|
+
)
|
|
112
|
+
raw = resp.choices[0].message.content or "[]"
|
|
113
|
+
return json.loads(raw.strip())
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def review_pr(api_key: str, base: str = "main", model: str = _MODEL) -> str:
|
|
117
|
+
client = OpenAI(api_key=api_key)
|
|
118
|
+
diff = get_diff(base)
|
|
119
|
+
if not diff:
|
|
120
|
+
return "No changes to review."
|
|
121
|
+
resp = client.chat.completions.create(
|
|
122
|
+
model=model,
|
|
123
|
+
max_tokens=1024,
|
|
124
|
+
messages=[
|
|
125
|
+
{"role": "system", "content": REVIEW_SYSTEM},
|
|
126
|
+
{"role": "user", "content": f"Diff:\n\n{diff}"},
|
|
127
|
+
],
|
|
128
|
+
)
|
|
129
|
+
return (resp.choices[0].message.content or "").strip()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def review_pr_as_comment(api_key: str, base: str = "main", model: str = _MODEL) -> str:
|
|
133
|
+
"""Return a GitHub-flavoured markdown comment with the AI review."""
|
|
134
|
+
body = review_pr(api_key, base=base, model=model)
|
|
135
|
+
return REVIEW_COMMENT_TEMPLATE.format(header=REVIEW_COMMENT_HEADER, body=body)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ── Changelog ────────────────────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class ChangelogEntry:
|
|
142
|
+
version_bump: str # "patch" | "minor" | "major"
|
|
143
|
+
highlights: str
|
|
144
|
+
added: list[str]
|
|
145
|
+
changed: list[str]
|
|
146
|
+
fixed: list[str]
|
|
147
|
+
removed: list[str]
|
|
148
|
+
security: list[str]
|
|
149
|
+
|
|
150
|
+
def to_markdown(self, new_version: str, date: str) -> str:
|
|
151
|
+
lines = [f"## [{new_version}] - {date}", f"\n_{self.highlights}_\n"]
|
|
152
|
+
sections = [
|
|
153
|
+
("Added", self.added),
|
|
154
|
+
("Changed", self.changed),
|
|
155
|
+
("Fixed", self.fixed),
|
|
156
|
+
("Removed", self.removed),
|
|
157
|
+
("Security", self.security),
|
|
158
|
+
]
|
|
159
|
+
for title, items in sections:
|
|
160
|
+
if items:
|
|
161
|
+
lines.append(f"\n### {title}")
|
|
162
|
+
for item in items:
|
|
163
|
+
lines.append(f"- {item}")
|
|
164
|
+
return "\n".join(lines)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _get_current_version() -> str:
|
|
168
|
+
"""Read version from pyproject.toml, setup.cfg, or package __init__."""
|
|
169
|
+
try:
|
|
170
|
+
tag = _git("describe", "--tags", "--abbrev=0")
|
|
171
|
+
return tag.lstrip("v") if tag else "0.0.0"
|
|
172
|
+
except Exception:
|
|
173
|
+
return "0.0.0"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _get_commits_since_tag() -> str:
|
|
177
|
+
tag = _git("describe", "--tags", "--abbrev=0")
|
|
178
|
+
if tag:
|
|
179
|
+
return _git("log", f"{tag}...HEAD", "--oneline", "--no-merges")
|
|
180
|
+
return _git("log", "--oneline", "--no-merges", "-30")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _bump_version(current: str, bump: str) -> str:
|
|
184
|
+
parts = current.split(".")
|
|
185
|
+
try:
|
|
186
|
+
major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2].split("-")[0])
|
|
187
|
+
except (IndexError, ValueError):
|
|
188
|
+
return "0.1.0"
|
|
189
|
+
if bump == "major":
|
|
190
|
+
return f"{major + 1}.0.0"
|
|
191
|
+
if bump == "minor":
|
|
192
|
+
return f"{major}.{minor + 1}.0"
|
|
193
|
+
return f"{major}.{minor}.{patch + 1}"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def generate_changelog(api_key: str, model: str = _MODEL) -> tuple[ChangelogEntry, str]:
|
|
197
|
+
"""Return (ChangelogEntry, new_version_string)."""
|
|
198
|
+
import datetime
|
|
199
|
+
client = OpenAI(api_key=api_key)
|
|
200
|
+
current = _get_current_version()
|
|
201
|
+
commits = _get_commits_since_tag()
|
|
202
|
+
diff = get_diff(base="HEAD~10") if not commits else get_diff(base=f"v{current}" if current != "0.0.0" else "HEAD~10")
|
|
203
|
+
|
|
204
|
+
if not commits:
|
|
205
|
+
raise ValueError("No commits found since last tag. Nothing to changelog.")
|
|
206
|
+
|
|
207
|
+
user_msg = CHANGELOG_USER.format(
|
|
208
|
+
current_version=current, commits=commits, diff=diff[:12_000] or "(empty)"
|
|
209
|
+
)
|
|
210
|
+
resp = client.chat.completions.create(
|
|
211
|
+
model=model,
|
|
212
|
+
max_tokens=1024,
|
|
213
|
+
messages=[
|
|
214
|
+
{"role": "system", "content": CHANGELOG_SYSTEM},
|
|
215
|
+
{"role": "user", "content": user_msg},
|
|
216
|
+
],
|
|
217
|
+
)
|
|
218
|
+
raw = (resp.choices[0].message.content or "{}").strip()
|
|
219
|
+
if raw.startswith("```"):
|
|
220
|
+
raw = "\n".join(raw.splitlines()[1:])
|
|
221
|
+
if raw.endswith("```"):
|
|
222
|
+
raw = "\n".join(raw.splitlines()[:-1])
|
|
223
|
+
|
|
224
|
+
data = json.loads(raw.strip())
|
|
225
|
+
entry = ChangelogEntry(
|
|
226
|
+
version_bump=data.get("version", "patch"),
|
|
227
|
+
highlights=data.get("highlights", ""),
|
|
228
|
+
added=data.get("added", []),
|
|
229
|
+
changed=data.get("changed", []),
|
|
230
|
+
fixed=data.get("fixed", []),
|
|
231
|
+
removed=data.get("removed", []),
|
|
232
|
+
security=data.get("security", []),
|
|
233
|
+
)
|
|
234
|
+
new_version = _bump_version(current, entry.version_bump)
|
|
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
|