pullwise 0.3.0__tar.gz → 0.4.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.3.0
3
+ Version: 0.4.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
@@ -120,6 +120,8 @@ pr-pilot describe --markdown pr_description.md
120
120
  | `pr-pilot reviewers` | Suggest reviewers based on git blame of changed files |
121
121
  | `pr-pilot standup` | Generate a daily standup update from your recent commits |
122
122
  | `pr-pilot todos` | Scan for TODO/FIXME comments and create GitHub issues from them |
123
+ | `pr-pilot commit` | Generate a Conventional Commit message from staged changes |
124
+ | `pr-pilot release` | Full release: changelog + git tag + GitHub release in one command |
123
125
 
124
126
  ## Usage
125
127
 
@@ -140,6 +142,20 @@ pr-pilot changelog
140
142
  pr-pilot changelog --output CHANGELOG.md
141
143
  ```
142
144
 
145
+ ```bash
146
+ # Generate a commit message from staged changes
147
+ pr-pilot commit
148
+
149
+ # Run git commit directly with the generated message
150
+ pr-pilot commit --commit
151
+
152
+ # Full release: bump version + write changelog + create GitHub release
153
+ pr-pilot release --repo owner/repo
154
+
155
+ # Preview release without publishing
156
+ pr-pilot release --repo owner/repo --dry-run
157
+ ```
158
+
143
159
  ```bash
144
160
  # Suggest reviewers for your current branch
145
161
  pr-pilot reviewers --base main
@@ -100,6 +100,8 @@ pr-pilot describe --markdown pr_description.md
100
100
  | `pr-pilot reviewers` | Suggest reviewers based on git blame of changed files |
101
101
  | `pr-pilot standup` | Generate a daily standup update from your recent commits |
102
102
  | `pr-pilot todos` | Scan for TODO/FIXME comments and create GitHub issues from them |
103
+ | `pr-pilot commit` | Generate a Conventional Commit message from staged changes |
104
+ | `pr-pilot release` | Full release: changelog + git tag + GitHub release in one command |
103
105
 
104
106
  ## Usage
105
107
 
@@ -120,6 +122,20 @@ pr-pilot changelog
120
122
  pr-pilot changelog --output CHANGELOG.md
121
123
  ```
122
124
 
125
+ ```bash
126
+ # Generate a commit message from staged changes
127
+ pr-pilot commit
128
+
129
+ # Run git commit directly with the generated message
130
+ pr-pilot commit --commit
131
+
132
+ # Full release: bump version + write changelog + create GitHub release
133
+ pr-pilot release --repo owner/repo
134
+
135
+ # Preview release without publishing
136
+ pr-pilot release --repo owner/repo --dry-run
137
+ ```
138
+
123
139
  ```bash
124
140
  # Suggest reviewers for your current branch
125
141
  pr-pilot reviewers --base main
@@ -0,0 +1 @@
1
+ __version__ = "0.4.0"
@@ -11,6 +11,8 @@ from .templates import (
11
11
  REVIEWER_SYSTEM, REVIEWER_USER, REVIEWER_COMMENT_HEADER, REVIEWER_COMMENT_TEMPLATE,
12
12
  STANDUP_SYSTEM, STANDUP_USER,
13
13
  ISSUE_SYSTEM, ISSUE_USER,
14
+ COMMIT_SYSTEM, COMMIT_USER,
15
+ RELEASE_NOTES_SYSTEM, RELEASE_NOTES_USER,
14
16
  )
15
17
 
16
18
  _MAX_DIFF_CHARS = 24_000 # stay well within context limits
@@ -390,3 +392,159 @@ def create_issues_from_todos(
390
392
  labels=data.get("labels", ["technical-debt"]),
391
393
  ))
392
394
  return issues
395
+
396
+
397
+ # ── Commit message generator ──────────────────────────────────────────────────
398
+
399
+ @dataclass
400
+ class CommitMessage:
401
+ subject: str
402
+ body: str | None
403
+ breaking: bool
404
+ footer: str | None
405
+
406
+ def format(self) -> str:
407
+ parts = [self.subject]
408
+ if self.body:
409
+ parts.append("")
410
+ parts.append(self.body)
411
+ if self.footer:
412
+ parts.append("")
413
+ parts.append(self.footer)
414
+ return "\n".join(parts)
415
+
416
+
417
+ def _get_staged_diff() -> str:
418
+ diff = _git("diff", "--cached")
419
+ if len(diff) > _MAX_DIFF_CHARS:
420
+ diff = diff[:_MAX_DIFF_CHARS] + "\n\n[diff truncated]"
421
+ return diff
422
+
423
+
424
+ def generate_commit_message(api_key: str, model: str = _MODEL) -> CommitMessage:
425
+ client = OpenAI(api_key=api_key)
426
+ diff = _get_staged_diff()
427
+ if not diff:
428
+ raise ValueError("No staged changes found. Use 'git add' first.")
429
+ resp = client.chat.completions.create(
430
+ model=model,
431
+ max_tokens=512,
432
+ messages=[
433
+ {"role": "system", "content": COMMIT_SYSTEM},
434
+ {"role": "user", "content": COMMIT_USER.format(diff=diff)},
435
+ ],
436
+ )
437
+ raw = (resp.choices[0].message.content or "{}").strip()
438
+ if raw.startswith("```"):
439
+ raw = "\n".join(raw.splitlines()[1:])
440
+ if raw.endswith("```"):
441
+ raw = "\n".join(raw.splitlines()[:-1])
442
+ data = json.loads(raw.strip())
443
+ return CommitMessage(
444
+ subject=data.get("subject", "chore: update"),
445
+ body=data.get("body") or None,
446
+ breaking=data.get("breaking", False),
447
+ footer=data.get("footer") or None,
448
+ )
449
+
450
+
451
+ # ── Full release workflow ─────────────────────────────────────────────────────
452
+
453
+ @dataclass
454
+ class ReleaseInfo:
455
+ version: str
456
+ tag: str
457
+ name: str
458
+ body: str
459
+ prerelease: bool
460
+
461
+
462
+ def _generate_release_notes(
463
+ api_key: str, version: str, changelog_md: str, model: str = _MODEL
464
+ ) -> tuple[str, str]:
465
+ """Return (release_name, release_body)."""
466
+ client = OpenAI(api_key=api_key)
467
+ resp = client.chat.completions.create(
468
+ model=model,
469
+ max_tokens=1024,
470
+ messages=[
471
+ {"role": "system", "content": RELEASE_NOTES_SYSTEM},
472
+ {"role": "user", "content": RELEASE_NOTES_USER.format(
473
+ version=version, changelog_md=changelog_md
474
+ )},
475
+ ],
476
+ )
477
+ raw = (resp.choices[0].message.content or "{}").strip()
478
+ if raw.startswith("```"):
479
+ raw = "\n".join(raw.splitlines()[1:])
480
+ if raw.endswith("```"):
481
+ raw = "\n".join(raw.splitlines()[:-1])
482
+ data = json.loads(raw.strip())
483
+ return data.get("name", f"v{version}"), data.get("body", changelog_md)
484
+
485
+
486
+ def run_release(
487
+ api_key: str,
488
+ repo: str,
489
+ changelog_path: str = "CHANGELOG.md",
490
+ model: str = _MODEL,
491
+ dry_run: bool = False,
492
+ ) -> ReleaseInfo:
493
+ """Full release: generate changelog → bump version → create GitHub release."""
494
+ import datetime
495
+ from .github_client import _api
496
+
497
+ # 1. Generate changelog entry
498
+ entry, new_version = generate_changelog(api_key, model=model)
499
+ today = datetime.date.today().isoformat()
500
+ changelog_md = entry.to_markdown(new_version, today)
501
+
502
+ # 2. Write CHANGELOG.md
503
+ import pathlib
504
+ p = pathlib.Path(changelog_path)
505
+ if p.exists():
506
+ existing = p.read_text()
507
+ if existing.startswith("# "):
508
+ header, rest = existing.split("\n", 1)
509
+ new_content = f"{header}\n\n{changelog_md}\n{rest}"
510
+ else:
511
+ new_content = f"{changelog_md}\n\n{existing}"
512
+ else:
513
+ new_content = f"# Changelog\n\n{changelog_md}\n"
514
+
515
+ if not dry_run:
516
+ p.write_text(new_content)
517
+
518
+ # 3. Generate release notes
519
+ release_name, release_body = _generate_release_notes(
520
+ api_key, new_version, changelog_md, model=model
521
+ )
522
+ tag = f"v{new_version}"
523
+
524
+ if not dry_run:
525
+ # 4. Commit changelog
526
+ import subprocess
527
+ subprocess.run(["git", "add", changelog_path], check=True)
528
+ subprocess.run(
529
+ ["git", "commit", "-m", f"chore: release {tag}"],
530
+ check=True
531
+ )
532
+ # 5. Create git tag
533
+ subprocess.run(["git", "tag", tag], check=True)
534
+ subprocess.run(["git", "push"], check=True)
535
+ subprocess.run(["git", "push", "--tags"], check=True)
536
+ # 6. Create GitHub release
537
+ _api("POST", f"/repos/{repo}/releases", {
538
+ "tag_name": tag,
539
+ "name": release_name,
540
+ "body": release_body,
541
+ "prerelease": False,
542
+ })
543
+
544
+ return ReleaseInfo(
545
+ version=new_version,
546
+ tag=tag,
547
+ name=release_name,
548
+ body=release_body,
549
+ prerelease=False,
550
+ )
@@ -6,6 +6,7 @@ import sys
6
6
  from .analyzer import (
7
7
  describe_pr, suggest_labels, review_pr, review_pr_as_comment,
8
8
  generate_changelog, suggest_reviewers, generate_standup, create_issues_from_todos,
9
+ generate_commit_message, run_release,
9
10
  )
10
11
 
11
12
  _RESET = "\033[0m"
@@ -103,6 +104,71 @@ def cmd_changelog(args: argparse.Namespace) -> None:
103
104
  print()
104
105
 
105
106
 
107
+ def cmd_commit(args: argparse.Namespace) -> None:
108
+ print(f"\n {_DIM}Analyzing staged changes...{_RESET}\n")
109
+ msg = generate_commit_message(_key(), model=args.model)
110
+
111
+ print(f"{_BOLD}Commit message:{_RESET}\n")
112
+ print(f" {_GREEN}{msg.subject}{_RESET}")
113
+ if msg.body:
114
+ print()
115
+ for line in msg.body.splitlines():
116
+ print(f" {line}")
117
+ if msg.footer:
118
+ print(f"\n {_YELLOW}{msg.footer}{_RESET}")
119
+ if msg.breaking:
120
+ print(f"\n {_YELLOW}⚠ Breaking change{_RESET}")
121
+
122
+ if args.commit:
123
+ import subprocess
124
+ full = msg.format()
125
+ result = subprocess.run(["git", "commit", "-m", full])
126
+ if result.returncode == 0:
127
+ print(f"\n {_GREEN}✓{_RESET} Committed.")
128
+ else:
129
+ print(f"\n Commit failed — copy the message above and run git commit manually.")
130
+ elif args.copy:
131
+ try:
132
+ import subprocess
133
+ subprocess.run(["pbcopy"], input=msg.format().encode(), check=True)
134
+ print(f"\n {_GREEN}✓{_RESET} Copied to clipboard")
135
+ except Exception:
136
+ print(f"\n {_DIM}(--copy requires macOS pbcopy){_RESET}")
137
+ print()
138
+
139
+
140
+ def cmd_release(args: argparse.Namespace) -> None:
141
+ repo = args.repo or os.environ.get("GITHUB_REPOSITORY", "")
142
+ if not repo:
143
+ print("pr-pilot release: --repo is required (or set GITHUB_REPOSITORY)", file=sys.stderr)
144
+ sys.exit(1)
145
+
146
+ if args.dry_run:
147
+ print(f"\n {_DIM}Dry run — nothing will be committed or published{_RESET}\n")
148
+ else:
149
+ print(f"\n {_DIM}Running full release workflow for {repo}...{_RESET}\n")
150
+
151
+ release = run_release(
152
+ _key(),
153
+ repo=repo,
154
+ changelog_path=args.changelog,
155
+ model=args.model,
156
+ dry_run=args.dry_run,
157
+ )
158
+
159
+ print(f" {_BOLD}Version:{_RESET} {_GREEN}{release.tag}{_RESET}")
160
+ print(f" {_BOLD}Name:{_RESET} {release.name}\n")
161
+ print(release.body)
162
+
163
+ if args.dry_run:
164
+ print(f"\n {_YELLOW}Dry run complete — no changes made.{_RESET}")
165
+ else:
166
+ print(f"\n {_GREEN}✓{_RESET} CHANGELOG.md updated")
167
+ print(f" {_GREEN}✓{_RESET} Committed and tagged {release.tag}")
168
+ print(f" {_GREEN}✓{_RESET} GitHub release created")
169
+ print()
170
+
171
+
106
172
  def cmd_reviewers(args: argparse.Namespace) -> None:
107
173
  from .github_client import upsert_comment
108
174
  from .templates import REVIEWER_COMMENT_HEADER
@@ -246,6 +312,23 @@ def main() -> None:
246
312
  p_rev.add_argument("--model", default="gpt-4o", help="OpenAI model to use")
247
313
  p_rev.set_defaults(func=cmd_review)
248
314
 
315
+ # --- commit ---
316
+ p_commit = sub.add_parser("commit", help="Generate a conventional commit message from staged changes")
317
+ p_commit.add_argument("--model", default="gpt-4o")
318
+ p_commit.add_argument("--commit", action="store_true", help="Run git commit with the generated message")
319
+ p_commit.add_argument("--copy", action="store_true", help="Copy message to clipboard (macOS)")
320
+ p_commit.set_defaults(func=cmd_commit)
321
+
322
+ # --- release ---
323
+ p_release = sub.add_parser("release", help="Full release: changelog + git tag + GitHub release")
324
+ p_release.add_argument("--repo", default=None, help="GitHub repo slug (owner/repo)")
325
+ p_release.add_argument("--model", default="gpt-4o")
326
+ p_release.add_argument("--changelog", default="CHANGELOG.md", metavar="FILE",
327
+ help="Path to CHANGELOG.md (default: CHANGELOG.md)")
328
+ p_release.add_argument("--dry-run", action="store_true",
329
+ help="Preview release without committing or publishing")
330
+ p_release.set_defaults(func=cmd_release)
331
+
249
332
  # --- reviewers ---
250
333
  p_rev2 = sub.add_parser("reviewers", help="Suggest reviewers based on git blame of changed files")
251
334
  p_rev2.add_argument("--base", default="main")
@@ -175,3 +175,57 @@ Comment: {comment}
175
175
  Surrounding code:
176
176
  {context}
177
177
  """
178
+
179
+ # ── Commit message generator ──────────────────────────────────────────────────
180
+
181
+ COMMIT_SYSTEM = """\
182
+ You are an expert at writing git commit messages following the Conventional Commits
183
+ specification (https://www.conventionalcommits.org).
184
+
185
+ Given a staged diff, write a commit message.
186
+
187
+ Return a JSON object:
188
+ {
189
+ "subject": "type(scope): short description, max 72 chars, lowercase after colon",
190
+ "body": "optional longer explanation (2-4 sentences). null if the subject is self-explanatory.",
191
+ "breaking": true or false,
192
+ "footer": "BREAKING CHANGE: description" or null
193
+ }
194
+
195
+ Types: feat | fix | docs | style | refactor | perf | test | chore | ci | build
196
+ Rules:
197
+ - subject must be lowercase after the colon
198
+ - subject must NOT end with a period
199
+ - body wraps at 72 chars
200
+ - Return ONLY the JSON object, no markdown fences
201
+ """
202
+
203
+ COMMIT_USER = """\
204
+ Staged diff:
205
+ {diff}
206
+ """
207
+
208
+ # ── Full release workflow ─────────────────────────────────────────────────────
209
+
210
+ RELEASE_NOTES_SYSTEM = """\
211
+ You are a technical writer creating GitHub release notes for a software project.
212
+ Given a changelog entry and version info, write engaging release notes.
213
+
214
+ Return a JSON object:
215
+ {
216
+ "name": "release name (e.g. 'v1.2.0 — Dark Mode & Performance')",
217
+ "body": "markdown release notes: lead with a 1-2 sentence overview, then bullet sections for Added/Changed/Fixed. Max 400 words.",
218
+ "prerelease": false
219
+ }
220
+
221
+ Rules:
222
+ - Be specific about what changed. No filler phrases.
223
+ - Use emoji sparingly: one per section header max.
224
+ - Return ONLY the JSON object, no markdown fences
225
+ """
226
+
227
+ RELEASE_NOTES_USER = """\
228
+ Version: {version}
229
+ Changelog entry:
230
+ {changelog_md}
231
+ """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pullwise
3
- Version: 0.3.0
3
+ Version: 0.4.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
@@ -120,6 +120,8 @@ pr-pilot describe --markdown pr_description.md
120
120
  | `pr-pilot reviewers` | Suggest reviewers based on git blame of changed files |
121
121
  | `pr-pilot standup` | Generate a daily standup update from your recent commits |
122
122
  | `pr-pilot todos` | Scan for TODO/FIXME comments and create GitHub issues from them |
123
+ | `pr-pilot commit` | Generate a Conventional Commit message from staged changes |
124
+ | `pr-pilot release` | Full release: changelog + git tag + GitHub release in one command |
123
125
 
124
126
  ## Usage
125
127
 
@@ -140,6 +142,20 @@ pr-pilot changelog
140
142
  pr-pilot changelog --output CHANGELOG.md
141
143
  ```
142
144
 
145
+ ```bash
146
+ # Generate a commit message from staged changes
147
+ pr-pilot commit
148
+
149
+ # Run git commit directly with the generated message
150
+ pr-pilot commit --commit
151
+
152
+ # Full release: bump version + write changelog + create GitHub release
153
+ pr-pilot release --repo owner/repo
154
+
155
+ # Preview release without publishing
156
+ pr-pilot release --repo owner/repo --dry-run
157
+ ```
158
+
143
159
  ```bash
144
160
  # Suggest reviewers for your current branch
145
161
  pr-pilot reviewers --base main
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pullwise"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "AI-powered PR descriptions, labels, and code review using OpenAI"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -1,10 +1,12 @@
1
1
  from __future__ import annotations
2
2
  import json
3
+ import pytest
3
4
  from unittest.mock import MagicMock, patch
4
5
  from pr_pilot.analyzer import (
5
6
  describe_pr, suggest_labels, review_pr, PRDescription,
6
7
  review_pr_as_comment, generate_changelog, ChangelogEntry,
7
8
  suggest_reviewers, generate_standup, create_issues_from_todos, _bump_version,
9
+ generate_commit_message, CommitMessage, run_release,
8
10
  )
9
11
  from pr_pilot.templates import REVIEW_COMMENT_HEADER, REVIEWER_COMMENT_HEADER
10
12
 
@@ -238,3 +240,81 @@ def test_create_issues_from_todos(tmp_path):
238
240
  assert len(issues) == 1
239
241
  assert issues[0].title == "Fix slow foo()"
240
242
  assert "technical-debt" in issues[0].labels
243
+
244
+
245
+ # ── Commit message generator tests ───────────────────────────────────────────
246
+
247
+ @patch("pr_pilot.analyzer._get_staged_diff", return_value="+ def login(user, password):\n+ return auth(user)")
248
+ def test_generate_commit_message_basic(mock_diff):
249
+ payload = {
250
+ "subject": "feat(auth): add login function",
251
+ "body": "Implements basic login using the auth helper.",
252
+ "breaking": False,
253
+ "footer": None,
254
+ }
255
+ with patch("pr_pilot.analyzer.OpenAI") as MockOpenAI:
256
+ MockOpenAI.return_value.chat.completions.create.return_value = \
257
+ _mock_openai_response(json.dumps(payload))
258
+ msg = generate_commit_message("fake-key")
259
+ assert msg.subject == "feat(auth): add login function"
260
+ assert msg.breaking is False
261
+ assert msg.footer is None
262
+
263
+
264
+ @patch("pr_pilot.analyzer._get_staged_diff", return_value="")
265
+ def test_generate_commit_message_no_staged(mock_diff):
266
+ with pytest.raises(ValueError, match="No staged changes"):
267
+ generate_commit_message("fake-key")
268
+
269
+
270
+ def test_commit_message_format_full():
271
+ msg = CommitMessage(
272
+ subject="feat(api): add rate limiting",
273
+ body="Adds sliding window rate limiting to all endpoints.",
274
+ breaking=True,
275
+ footer="BREAKING CHANGE: Rate limit headers are now always present.",
276
+ )
277
+ formatted = msg.format()
278
+ assert "feat(api): add rate limiting" in formatted
279
+ assert "sliding window" in formatted
280
+ assert "BREAKING CHANGE" in formatted
281
+
282
+
283
+ def test_commit_message_format_subject_only():
284
+ msg = CommitMessage(subject="chore: update deps", body=None, breaking=False, footer=None)
285
+ assert msg.format() == "chore: update deps"
286
+
287
+
288
+ # ── Release workflow tests ────────────────────────────────────────────────────
289
+
290
+ @patch("pr_pilot.analyzer._get_commits_since_tag", return_value="abc Fix login\ndef Add dark mode")
291
+ @patch("pr_pilot.analyzer._get_current_version", return_value="1.0.0")
292
+ @patch("pr_pilot.analyzer.get_diff", return_value="+ new code")
293
+ @patch("pr_pilot.analyzer.get_commits", return_value="")
294
+ @patch("pr_pilot.analyzer.get_branch", return_value="main")
295
+ def test_run_release_dry_run(mock_branch, mock_commits, mock_diff, mock_ver, mock_ctag, tmp_path):
296
+ changelog_payload = {
297
+ "version": "minor", "highlights": "New features.",
298
+ "added": ["Dark mode"], "changed": [], "fixed": ["Login bug"],
299
+ "removed": [], "security": [],
300
+ }
301
+ release_payload = {
302
+ "name": "v1.1.0 — Dark Mode",
303
+ "body": "## What's new\n- Dark mode added\n- Login bug fixed",
304
+ "prerelease": False,
305
+ }
306
+ responses = [
307
+ _mock_openai_response(json.dumps(changelog_payload)),
308
+ _mock_openai_response(json.dumps(release_payload)),
309
+ ]
310
+ changelog_file = str(tmp_path / "CHANGELOG.md")
311
+ with patch("pr_pilot.analyzer.OpenAI") as MockOpenAI:
312
+ MockOpenAI.return_value.chat.completions.create.side_effect = responses
313
+ release = run_release("fake-key", repo="owner/repo",
314
+ changelog_path=changelog_file, dry_run=True)
315
+ assert release.version == "1.1.0"
316
+ assert release.tag == "v1.1.0"
317
+ assert "Dark Mode" in release.name
318
+ # dry run: changelog file should NOT be written
319
+ import pathlib
320
+ assert not pathlib.Path(changelog_file).exists()
@@ -1 +0,0 @@
1
- __version__ = "0.3.0"
File without changes