pullwise 0.3.0__tar.gz → 0.5.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.5.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,11 @@ 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 |
125
+ | `pr-pilot docs` | Generate docstrings for functions in changed files |
126
+ | `pr-pilot branch` | Suggest a git branch name from a plain-English task description |
127
+ | `pr-pilot explain` | Explain what a file or function does in plain English |
123
128
 
124
129
  ## Usage
125
130
 
@@ -140,6 +145,40 @@ pr-pilot changelog
140
145
  pr-pilot changelog --output CHANGELOG.md
141
146
  ```
142
147
 
148
+ ```bash
149
+ # Generate docstrings for all functions changed vs main
150
+ pr-pilot docs
151
+
152
+ # Document a specific file
153
+ pr-pilot docs src/auth.py
154
+
155
+ # Suggest a branch name from a task description
156
+ pr-pilot branch "add rate limiting to upload endpoint"
157
+
158
+ # Create the branch immediately
159
+ pr-pilot branch "fix login timeout on mobile" --checkout
160
+
161
+ # Explain what a file does
162
+ pr-pilot explain src/auth.py
163
+
164
+ # Explain a specific function
165
+ pr-pilot explain src/auth.py --function authenticate
166
+ ```
167
+
168
+ ```bash
169
+ # Generate a commit message from staged changes
170
+ pr-pilot commit
171
+
172
+ # Run git commit directly with the generated message
173
+ pr-pilot commit --commit
174
+
175
+ # Full release: bump version + write changelog + create GitHub release
176
+ pr-pilot release --repo owner/repo
177
+
178
+ # Preview release without publishing
179
+ pr-pilot release --repo owner/repo --dry-run
180
+ ```
181
+
143
182
  ```bash
144
183
  # Suggest reviewers for your current branch
145
184
  pr-pilot reviewers --base main
@@ -100,6 +100,11 @@ 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 |
105
+ | `pr-pilot docs` | Generate docstrings for functions in changed files |
106
+ | `pr-pilot branch` | Suggest a git branch name from a plain-English task description |
107
+ | `pr-pilot explain` | Explain what a file or function does in plain English |
103
108
 
104
109
  ## Usage
105
110
 
@@ -120,6 +125,40 @@ pr-pilot changelog
120
125
  pr-pilot changelog --output CHANGELOG.md
121
126
  ```
122
127
 
128
+ ```bash
129
+ # Generate docstrings for all functions changed vs main
130
+ pr-pilot docs
131
+
132
+ # Document a specific file
133
+ pr-pilot docs src/auth.py
134
+
135
+ # Suggest a branch name from a task description
136
+ pr-pilot branch "add rate limiting to upload endpoint"
137
+
138
+ # Create the branch immediately
139
+ pr-pilot branch "fix login timeout on mobile" --checkout
140
+
141
+ # Explain what a file does
142
+ pr-pilot explain src/auth.py
143
+
144
+ # Explain a specific function
145
+ pr-pilot explain src/auth.py --function authenticate
146
+ ```
147
+
148
+ ```bash
149
+ # Generate a commit message from staged changes
150
+ pr-pilot commit
151
+
152
+ # Run git commit directly with the generated message
153
+ pr-pilot commit --commit
154
+
155
+ # Full release: bump version + write changelog + create GitHub release
156
+ pr-pilot release --repo owner/repo
157
+
158
+ # Preview release without publishing
159
+ pr-pilot release --repo owner/repo --dry-run
160
+ ```
161
+
123
162
  ```bash
124
163
  # Suggest reviewers for your current branch
125
164
  pr-pilot reviewers --base main
@@ -0,0 +1 @@
1
+ __version__ = "0.5.0"
@@ -11,6 +11,11 @@ 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,
16
+ DOCSTRING_SYSTEM, DOCSTRING_USER,
17
+ BRANCH_SYSTEM, BRANCH_USER,
18
+ EXPLAIN_SYSTEM, EXPLAIN_USER,
14
19
  )
15
20
 
16
21
  _MAX_DIFF_CHARS = 24_000 # stay well within context limits
@@ -390,3 +395,326 @@ def create_issues_from_todos(
390
395
  labels=data.get("labels", ["technical-debt"]),
391
396
  ))
392
397
  return issues
398
+
399
+
400
+ # ── Commit message generator ──────────────────────────────────────────────────
401
+
402
+ @dataclass
403
+ class CommitMessage:
404
+ subject: str
405
+ body: str | None
406
+ breaking: bool
407
+ footer: str | None
408
+
409
+ def format(self) -> str:
410
+ parts = [self.subject]
411
+ if self.body:
412
+ parts.append("")
413
+ parts.append(self.body)
414
+ if self.footer:
415
+ parts.append("")
416
+ parts.append(self.footer)
417
+ return "\n".join(parts)
418
+
419
+
420
+ def _get_staged_diff() -> str:
421
+ diff = _git("diff", "--cached")
422
+ if len(diff) > _MAX_DIFF_CHARS:
423
+ diff = diff[:_MAX_DIFF_CHARS] + "\n\n[diff truncated]"
424
+ return diff
425
+
426
+
427
+ def generate_commit_message(api_key: str, model: str = _MODEL) -> CommitMessage:
428
+ client = OpenAI(api_key=api_key)
429
+ diff = _get_staged_diff()
430
+ if not diff:
431
+ raise ValueError("No staged changes found. Use 'git add' first.")
432
+ resp = client.chat.completions.create(
433
+ model=model,
434
+ max_tokens=512,
435
+ messages=[
436
+ {"role": "system", "content": COMMIT_SYSTEM},
437
+ {"role": "user", "content": COMMIT_USER.format(diff=diff)},
438
+ ],
439
+ )
440
+ raw = (resp.choices[0].message.content or "{}").strip()
441
+ if raw.startswith("```"):
442
+ raw = "\n".join(raw.splitlines()[1:])
443
+ if raw.endswith("```"):
444
+ raw = "\n".join(raw.splitlines()[:-1])
445
+ data = json.loads(raw.strip())
446
+ return CommitMessage(
447
+ subject=data.get("subject", "chore: update"),
448
+ body=data.get("body") or None,
449
+ breaking=data.get("breaking", False),
450
+ footer=data.get("footer") or None,
451
+ )
452
+
453
+
454
+ # ── Full release workflow ─────────────────────────────────────────────────────
455
+
456
+ @dataclass
457
+ class ReleaseInfo:
458
+ version: str
459
+ tag: str
460
+ name: str
461
+ body: str
462
+ prerelease: bool
463
+
464
+
465
+ def _generate_release_notes(
466
+ api_key: str, version: str, changelog_md: str, model: str = _MODEL
467
+ ) -> tuple[str, str]:
468
+ """Return (release_name, release_body)."""
469
+ client = OpenAI(api_key=api_key)
470
+ resp = client.chat.completions.create(
471
+ model=model,
472
+ max_tokens=1024,
473
+ messages=[
474
+ {"role": "system", "content": RELEASE_NOTES_SYSTEM},
475
+ {"role": "user", "content": RELEASE_NOTES_USER.format(
476
+ version=version, changelog_md=changelog_md
477
+ )},
478
+ ],
479
+ )
480
+ raw = (resp.choices[0].message.content or "{}").strip()
481
+ if raw.startswith("```"):
482
+ raw = "\n".join(raw.splitlines()[1:])
483
+ if raw.endswith("```"):
484
+ raw = "\n".join(raw.splitlines()[:-1])
485
+ data = json.loads(raw.strip())
486
+ return data.get("name", f"v{version}"), data.get("body", changelog_md)
487
+
488
+
489
+ def run_release(
490
+ api_key: str,
491
+ repo: str,
492
+ changelog_path: str = "CHANGELOG.md",
493
+ model: str = _MODEL,
494
+ dry_run: bool = False,
495
+ ) -> ReleaseInfo:
496
+ """Full release: generate changelog → bump version → create GitHub release."""
497
+ import datetime
498
+ from .github_client import _api
499
+
500
+ # 1. Generate changelog entry
501
+ entry, new_version = generate_changelog(api_key, model=model)
502
+ today = datetime.date.today().isoformat()
503
+ changelog_md = entry.to_markdown(new_version, today)
504
+
505
+ # 2. Write CHANGELOG.md
506
+ import pathlib
507
+ p = pathlib.Path(changelog_path)
508
+ if p.exists():
509
+ existing = p.read_text()
510
+ if existing.startswith("# "):
511
+ header, rest = existing.split("\n", 1)
512
+ new_content = f"{header}\n\n{changelog_md}\n{rest}"
513
+ else:
514
+ new_content = f"{changelog_md}\n\n{existing}"
515
+ else:
516
+ new_content = f"# Changelog\n\n{changelog_md}\n"
517
+
518
+ if not dry_run:
519
+ p.write_text(new_content)
520
+
521
+ # 3. Generate release notes
522
+ release_name, release_body = _generate_release_notes(
523
+ api_key, new_version, changelog_md, model=model
524
+ )
525
+ tag = f"v{new_version}"
526
+
527
+ if not dry_run:
528
+ # 4. Commit changelog
529
+ import subprocess
530
+ subprocess.run(["git", "add", changelog_path], check=True)
531
+ subprocess.run(
532
+ ["git", "commit", "-m", f"chore: release {tag}"],
533
+ check=True
534
+ )
535
+ # 5. Create git tag
536
+ subprocess.run(["git", "tag", tag], check=True)
537
+ subprocess.run(["git", "push"], check=True)
538
+ subprocess.run(["git", "push", "--tags"], check=True)
539
+ # 6. Create GitHub release
540
+ _api("POST", f"/repos/{repo}/releases", {
541
+ "tag_name": tag,
542
+ "name": release_name,
543
+ "body": release_body,
544
+ "prerelease": False,
545
+ })
546
+
547
+ return ReleaseInfo(
548
+ version=new_version,
549
+ tag=tag,
550
+ name=release_name,
551
+ body=release_body,
552
+ prerelease=False,
553
+ )
554
+
555
+
556
+ # ── Docstring generator ───────────────────────────────────────────────────────
557
+
558
+ @dataclass
559
+ class DocstringResult:
560
+ language: str
561
+ function_name: str
562
+ docstring: str
563
+ placement: str # "above" | "inside"
564
+
565
+
566
+ def _detect_language(path: str) -> str:
567
+ ext = path.rsplit(".", 1)[-1].lower()
568
+ if ext == "py":
569
+ return "python"
570
+ if ext in {"ts", "tsx"}:
571
+ return "typescript"
572
+ return "javascript"
573
+
574
+
575
+ def _extract_functions(code: str, language: str) -> list[tuple[str, int]]:
576
+ """Return list of (function_source, start_line) for top-level functions."""
577
+ import re
578
+ lines = code.splitlines()
579
+ results = []
580
+ if language == "python":
581
+ pattern = re.compile(r"^(def |async def )")
582
+ i = 0
583
+ while i < len(lines):
584
+ if pattern.match(lines[i]):
585
+ start = i
586
+ # collect until next top-level def/class or EOF
587
+ j = i + 1
588
+ while j < len(lines) and (not lines[j] or lines[j][0] in " \t#"):
589
+ j += 1
590
+ results.append(("\n".join(lines[start:j]), start + 1))
591
+ i = j
592
+ else:
593
+ i += 1
594
+ else:
595
+ pattern = re.compile(r"^(export\s+)?(async\s+)?function\s+\w+|^\s*(const|let|var)\s+\w+\s*=\s*(async\s+)?\(")
596
+ for i, line in enumerate(lines):
597
+ if pattern.match(line):
598
+ end = min(i + 30, len(lines))
599
+ results.append(("\n".join(lines[i:end]), i + 1))
600
+ return results
601
+
602
+
603
+ def generate_docstrings(
604
+ api_key: str, file_path: str, model: str = _MODEL
605
+ ) -> list[DocstringResult]:
606
+ """Generate docstrings for all functions in a file changed in the diff."""
607
+ from pathlib import Path
608
+ client = OpenAI(api_key=api_key)
609
+ code = Path(file_path).read_text(errors="replace")
610
+ language = _detect_language(file_path)
611
+ functions = _extract_functions(code, language)
612
+ if not functions:
613
+ return []
614
+ results = []
615
+ for func_code, _lineno in functions[:10]: # cap at 10 per file
616
+ resp = client.chat.completions.create(
617
+ model=model,
618
+ max_tokens=512,
619
+ messages=[
620
+ {"role": "system", "content": DOCSTRING_SYSTEM},
621
+ {"role": "user", "content": DOCSTRING_USER.format(
622
+ language=language, code=func_code[:3000]
623
+ )},
624
+ ],
625
+ )
626
+ raw = (resp.choices[0].message.content or "{}").strip()
627
+ if raw.startswith("```"):
628
+ raw = "\n".join(raw.splitlines()[1:])
629
+ if raw.endswith("```"):
630
+ raw = "\n".join(raw.splitlines()[:-1])
631
+ try:
632
+ data = json.loads(raw.strip())
633
+ results.append(DocstringResult(
634
+ language=data.get("language", language),
635
+ function_name=data.get("function_name", "unknown"),
636
+ docstring=data.get("docstring", ""),
637
+ placement=data.get("placement", "inside"),
638
+ ))
639
+ except json.JSONDecodeError:
640
+ continue
641
+ return results
642
+
643
+
644
+ # ── Branch namer ──────────────────────────────────────────────────────────────
645
+
646
+ @dataclass
647
+ class BranchSuggestion:
648
+ suggestions: list[str]
649
+ recommended: int # index into suggestions
650
+
651
+ @property
652
+ def best(self) -> str:
653
+ return self.suggestions[self.recommended]
654
+
655
+
656
+ def suggest_branch(api_key: str, task: str, model: str = _MODEL) -> BranchSuggestion:
657
+ client = OpenAI(api_key=api_key)
658
+ resp = client.chat.completions.create(
659
+ model=model,
660
+ max_tokens=200,
661
+ messages=[
662
+ {"role": "system", "content": BRANCH_SYSTEM},
663
+ {"role": "user", "content": BRANCH_USER.format(task=task)},
664
+ ],
665
+ )
666
+ raw = (resp.choices[0].message.content or "{}").strip()
667
+ if raw.startswith("```"):
668
+ raw = "\n".join(raw.splitlines()[1:])
669
+ if raw.endswith("```"):
670
+ raw = "\n".join(raw.splitlines()[:-1])
671
+ data = json.loads(raw.strip())
672
+ suggestions = data.get("suggestions", [f"feat/{task[:40].lower().replace(' ', '-')}"])
673
+ recommended = data.get("recommended", 0)
674
+ return BranchSuggestion(suggestions=suggestions, recommended=recommended)
675
+
676
+
677
+ # ── Code explainer ────────────────────────────────────────────────────────────
678
+
679
+ def explain_code(
680
+ api_key: str,
681
+ file_path: str,
682
+ selector: str | None = None,
683
+ model: str = _MODEL,
684
+ ) -> str:
685
+ """Explain a file or specific function in plain English."""
686
+ from pathlib import Path
687
+ client = OpenAI(api_key=api_key)
688
+ code = Path(file_path).read_text(errors="replace")
689
+
690
+ # If selector given, try to extract just that function/class
691
+ if selector:
692
+ import re
693
+ pattern = re.compile(
694
+ rf"^(def |async def |class |\w+ = (async )?function )"
695
+ rf".*{re.escape(selector)}",
696
+ re.MULTILINE
697
+ )
698
+ m = pattern.search(code)
699
+ if m:
700
+ start = m.start()
701
+ # grab next ~60 lines
702
+ snippet = "\n".join(code[start:].splitlines()[:60])
703
+ code = snippet
704
+
705
+ if len(code) > _MAX_DIFF_CHARS:
706
+ code = code[:_MAX_DIFF_CHARS] + "\n\n[truncated]"
707
+
708
+ resp = client.chat.completions.create(
709
+ model=model,
710
+ max_tokens=512,
711
+ messages=[
712
+ {"role": "system", "content": EXPLAIN_SYSTEM},
713
+ {"role": "user", "content": EXPLAIN_USER.format(
714
+ file_path=file_path,
715
+ selector=f"Function/class: {selector}" if selector else "Whole file",
716
+ code=code,
717
+ )},
718
+ ],
719
+ )
720
+ return (resp.choices[0].message.content or "").strip()
@@ -6,6 +6,8 @@ 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,
10
+ generate_docstrings, suggest_branch, explain_code,
9
11
  )
10
12
 
11
13
  _RESET = "\033[0m"
@@ -103,6 +105,143 @@ def cmd_changelog(args: argparse.Namespace) -> None:
103
105
  print()
104
106
 
105
107
 
108
+ def cmd_commit(args: argparse.Namespace) -> None:
109
+ print(f"\n {_DIM}Analyzing staged changes...{_RESET}\n")
110
+ msg = generate_commit_message(_key(), model=args.model)
111
+
112
+ print(f"{_BOLD}Commit message:{_RESET}\n")
113
+ print(f" {_GREEN}{msg.subject}{_RESET}")
114
+ if msg.body:
115
+ print()
116
+ for line in msg.body.splitlines():
117
+ print(f" {line}")
118
+ if msg.footer:
119
+ print(f"\n {_YELLOW}{msg.footer}{_RESET}")
120
+ if msg.breaking:
121
+ print(f"\n {_YELLOW}⚠ Breaking change{_RESET}")
122
+
123
+ if args.commit:
124
+ import subprocess
125
+ full = msg.format()
126
+ result = subprocess.run(["git", "commit", "-m", full])
127
+ if result.returncode == 0:
128
+ print(f"\n {_GREEN}✓{_RESET} Committed.")
129
+ else:
130
+ print(f"\n Commit failed — copy the message above and run git commit manually.")
131
+ elif args.copy:
132
+ try:
133
+ import subprocess
134
+ subprocess.run(["pbcopy"], input=msg.format().encode(), check=True)
135
+ print(f"\n {_GREEN}✓{_RESET} Copied to clipboard")
136
+ except Exception:
137
+ print(f"\n {_DIM}(--copy requires macOS pbcopy){_RESET}")
138
+ print()
139
+
140
+
141
+ def cmd_release(args: argparse.Namespace) -> None:
142
+ repo = args.repo or os.environ.get("GITHUB_REPOSITORY", "")
143
+ if not repo:
144
+ print("pr-pilot release: --repo is required (or set GITHUB_REPOSITORY)", file=sys.stderr)
145
+ sys.exit(1)
146
+
147
+ if args.dry_run:
148
+ print(f"\n {_DIM}Dry run — nothing will be committed or published{_RESET}\n")
149
+ else:
150
+ print(f"\n {_DIM}Running full release workflow for {repo}...{_RESET}\n")
151
+
152
+ release = run_release(
153
+ _key(),
154
+ repo=repo,
155
+ changelog_path=args.changelog,
156
+ model=args.model,
157
+ dry_run=args.dry_run,
158
+ )
159
+
160
+ print(f" {_BOLD}Version:{_RESET} {_GREEN}{release.tag}{_RESET}")
161
+ print(f" {_BOLD}Name:{_RESET} {release.name}\n")
162
+ print(release.body)
163
+
164
+ if args.dry_run:
165
+ print(f"\n {_YELLOW}Dry run complete — no changes made.{_RESET}")
166
+ else:
167
+ print(f"\n {_GREEN}✓{_RESET} CHANGELOG.md updated")
168
+ print(f" {_GREEN}✓{_RESET} Committed and tagged {release.tag}")
169
+ print(f" {_GREEN}✓{_RESET} GitHub release created")
170
+ print()
171
+
172
+
173
+ def cmd_docs(args: argparse.Namespace) -> None:
174
+ import os as _os
175
+ files = args.files
176
+ if not files:
177
+ # Default: Python/JS/TS files changed vs base
178
+ from .analyzer import _git
179
+ changed = _git("diff", f"{args.base}...HEAD", "--name-only").splitlines()
180
+ files = [f for f in changed
181
+ if f.endswith((".py", ".js", ".ts", ".tsx", ".jsx")) and _os.path.exists(f)]
182
+ if not files:
183
+ print(" No changed source files found. Pass file paths as arguments.")
184
+ return
185
+
186
+ for file_path in files:
187
+ print(f"\n {_DIM}Generating docstrings for {file_path}...{_RESET}\n")
188
+ results = generate_docstrings(_key(), file_path, model=args.model)
189
+ if not results:
190
+ print(f" No functions found in {file_path}")
191
+ continue
192
+ for r in results:
193
+ delim = '"""' if r.language == "python" else "/**"
194
+ delim_end = '"""' if r.language == "python" else " */"
195
+ print(f" {_BOLD}{r.function_name}{_RESET} {_DIM}({r.placement}){_RESET}")
196
+ print(f" {_DIM}{delim}{_RESET}")
197
+ for line in r.docstring.splitlines():
198
+ prefix = " " if r.language != "python" else " "
199
+ print(f" {prefix}{line}")
200
+ print(f" {_DIM}{delim_end}{_RESET}\n")
201
+ print()
202
+
203
+
204
+ def cmd_branch(args: argparse.Namespace) -> None:
205
+ task = " ".join(args.task)
206
+ if not task:
207
+ print("pr-pilot branch: provide a task description", file=sys.stderr)
208
+ sys.exit(1)
209
+ print(f"\n {_DIM}Generating branch names for: \"{task}\"{_RESET}\n")
210
+ suggestion = suggest_branch(_key(), task=task, model=args.model)
211
+ for i, name in enumerate(suggestion.suggestions):
212
+ marker = f"{_GREEN}★{_RESET}" if i == suggestion.recommended else " "
213
+ print(f" {marker} {_BOLD}{name}{_RESET}")
214
+
215
+ if args.checkout:
216
+ import subprocess
217
+ best = suggestion.best
218
+ result = subprocess.run(["git", "checkout", "-b", best])
219
+ if result.returncode == 0:
220
+ print(f"\n {_GREEN}✓{_RESET} Switched to new branch '{best}'")
221
+ else:
222
+ print(f"\n Could not create branch — it may already exist.")
223
+ elif args.copy:
224
+ try:
225
+ import subprocess
226
+ subprocess.run(["pbcopy"], input=suggestion.best.encode(), check=True)
227
+ print(f"\n {_GREEN}✓{_RESET} '{suggestion.best}' copied to clipboard")
228
+ except Exception:
229
+ pass
230
+ print()
231
+
232
+
233
+ def cmd_explain(args: argparse.Namespace) -> None:
234
+ import os as _os
235
+ if not _os.path.exists(args.file):
236
+ print(f"pr-pilot explain: file not found: {args.file}", file=sys.stderr)
237
+ sys.exit(1)
238
+ selector_label = f" → {args.function}" if args.function else ""
239
+ print(f"\n {_DIM}Explaining {args.file}{selector_label}...{_RESET}\n")
240
+ explanation = explain_code(_key(), args.file, selector=args.function, model=args.model)
241
+ print(explanation)
242
+ print()
243
+
244
+
106
245
  def cmd_reviewers(args: argparse.Namespace) -> None:
107
246
  from .github_client import upsert_comment
108
247
  from .templates import REVIEWER_COMMENT_HEADER
@@ -246,6 +385,45 @@ def main() -> None:
246
385
  p_rev.add_argument("--model", default="gpt-4o", help="OpenAI model to use")
247
386
  p_rev.set_defaults(func=cmd_review)
248
387
 
388
+ # --- docs ---
389
+ p_docs = sub.add_parser("docs", help="Generate docstrings for functions in changed files")
390
+ p_docs.add_argument("files", nargs="*", help="Files to document (default: changed files vs base)")
391
+ p_docs.add_argument("--base", default="main")
392
+ p_docs.add_argument("--model", default="gpt-4o")
393
+ p_docs.set_defaults(func=cmd_docs)
394
+
395
+ # --- branch ---
396
+ p_branch = sub.add_parser("branch", help="Suggest a git branch name from a task description")
397
+ p_branch.add_argument("task", nargs="+", help="Plain-English task description")
398
+ p_branch.add_argument("--model", default="gpt-4o")
399
+ p_branch.add_argument("--checkout", action="store_true", help="Run git checkout -b with the best suggestion")
400
+ p_branch.add_argument("--copy", action="store_true", help="Copy best suggestion to clipboard (macOS)")
401
+ p_branch.set_defaults(func=cmd_branch)
402
+
403
+ # --- explain ---
404
+ p_explain = sub.add_parser("explain", help="Explain what a file or function does in plain English")
405
+ p_explain.add_argument("file", help="File to explain")
406
+ p_explain.add_argument("--function", "-f", default=None, help="Specific function or class to explain")
407
+ p_explain.add_argument("--model", default="gpt-4o")
408
+ p_explain.set_defaults(func=cmd_explain)
409
+
410
+ # --- commit ---
411
+ p_commit = sub.add_parser("commit", help="Generate a conventional commit message from staged changes")
412
+ p_commit.add_argument("--model", default="gpt-4o")
413
+ p_commit.add_argument("--commit", action="store_true", help="Run git commit with the generated message")
414
+ p_commit.add_argument("--copy", action="store_true", help="Copy message to clipboard (macOS)")
415
+ p_commit.set_defaults(func=cmd_commit)
416
+
417
+ # --- release ---
418
+ p_release = sub.add_parser("release", help="Full release: changelog + git tag + GitHub release")
419
+ p_release.add_argument("--repo", default=None, help="GitHub repo slug (owner/repo)")
420
+ p_release.add_argument("--model", default="gpt-4o")
421
+ p_release.add_argument("--changelog", default="CHANGELOG.md", metavar="FILE",
422
+ help="Path to CHANGELOG.md (default: CHANGELOG.md)")
423
+ p_release.add_argument("--dry-run", action="store_true",
424
+ help="Preview release without committing or publishing")
425
+ p_release.set_defaults(func=cmd_release)
426
+
249
427
  # --- reviewers ---
250
428
  p_rev2 = sub.add_parser("reviewers", help="Suggest reviewers based on git blame of changed files")
251
429
  p_rev2.add_argument("--base", default="main")
@@ -175,3 +175,134 @@ 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
+ """
232
+
233
+ # ── Docstring generator ───────────────────────────────────────────────────────
234
+
235
+ DOCSTRING_SYSTEM = """\
236
+ You are an expert technical writer generating docstrings for source code functions.
237
+
238
+ Given a function's source code and language, write a concise, accurate docstring.
239
+
240
+ Return a JSON object:
241
+ {
242
+ "language": "python" | "javascript" | "typescript",
243
+ "function_name": "the function name",
244
+ "docstring": "the full docstring text to insert (without surrounding quotes/delimiters)",
245
+ "placement": "above" | "inside"
246
+ }
247
+
248
+ Rules:
249
+ - Python: use Google-style docstrings. placement = "inside" (first line after def)
250
+ - JS/TS: use JSDoc format. placement = "above" (/** ... */ block before the function)
251
+ - Include Args/Parameters, Returns, and Raises/Throws only if non-trivial
252
+ - Be concise — max 6 lines for simple functions
253
+ - Return ONLY the JSON object, no markdown fences
254
+ """
255
+
256
+ DOCSTRING_USER = """\
257
+ Language: {language}
258
+ Function:
259
+ {code}
260
+ """
261
+
262
+ # ── Branch namer ──────────────────────────────────────────────────────────────
263
+
264
+ BRANCH_SYSTEM = """\
265
+ You are a developer assistant suggesting clean git branch names.
266
+
267
+ Given a task description, return 3 branch name suggestions.
268
+
269
+ Return a JSON object:
270
+ {
271
+ "suggestions": [
272
+ "type/short-kebab-description",
273
+ "type/alternative-name",
274
+ "type/another-option"
275
+ ],
276
+ "recommended": 0
277
+ }
278
+
279
+ Types: feat | fix | chore | docs | refactor | test | hotfix
280
+ Rules:
281
+ - lowercase only
282
+ - use hyphens, no underscores or spaces
283
+ - max 50 chars total
284
+ - be specific, not generic (not "fix/bug" but "fix/login-timeout-safari")
285
+ - Return ONLY the JSON object, no markdown fences
286
+ """
287
+
288
+ BRANCH_USER = "Task: {task}"
289
+
290
+ # ── Code explainer ────────────────────────────────────────────────────────────
291
+
292
+ EXPLAIN_SYSTEM = """\
293
+ You are a senior engineer explaining code to a teammate.
294
+ Given source code (a file or function), explain:
295
+ 1. What it does — in 2-3 plain sentences
296
+ 2. Key design decisions or patterns used (if any)
297
+ 3. Any gotchas, assumptions, or side effects worth knowing
298
+
299
+ Be direct. Write like you're doing a quick code walkthrough over Slack, not writing docs.
300
+ No headers. No bullet points for the overview — save bullets for gotchas only.
301
+ """
302
+
303
+ EXPLAIN_USER = """\
304
+ File: {file_path}
305
+ {selector}
306
+ Code:
307
+ {code}
308
+ """
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pullwise
3
- Version: 0.3.0
3
+ Version: 0.5.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,11 @@ 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 |
125
+ | `pr-pilot docs` | Generate docstrings for functions in changed files |
126
+ | `pr-pilot branch` | Suggest a git branch name from a plain-English task description |
127
+ | `pr-pilot explain` | Explain what a file or function does in plain English |
123
128
 
124
129
  ## Usage
125
130
 
@@ -140,6 +145,40 @@ pr-pilot changelog
140
145
  pr-pilot changelog --output CHANGELOG.md
141
146
  ```
142
147
 
148
+ ```bash
149
+ # Generate docstrings for all functions changed vs main
150
+ pr-pilot docs
151
+
152
+ # Document a specific file
153
+ pr-pilot docs src/auth.py
154
+
155
+ # Suggest a branch name from a task description
156
+ pr-pilot branch "add rate limiting to upload endpoint"
157
+
158
+ # Create the branch immediately
159
+ pr-pilot branch "fix login timeout on mobile" --checkout
160
+
161
+ # Explain what a file does
162
+ pr-pilot explain src/auth.py
163
+
164
+ # Explain a specific function
165
+ pr-pilot explain src/auth.py --function authenticate
166
+ ```
167
+
168
+ ```bash
169
+ # Generate a commit message from staged changes
170
+ pr-pilot commit
171
+
172
+ # Run git commit directly with the generated message
173
+ pr-pilot commit --commit
174
+
175
+ # Full release: bump version + write changelog + create GitHub release
176
+ pr-pilot release --repo owner/repo
177
+
178
+ # Preview release without publishing
179
+ pr-pilot release --repo owner/repo --dry-run
180
+ ```
181
+
143
182
  ```bash
144
183
  # Suggest reviewers for your current branch
145
184
  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.5.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,14 @@
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,
10
+ generate_docstrings, suggest_branch, explain_code,
11
+ _extract_functions, _detect_language,
8
12
  )
9
13
  from pr_pilot.templates import REVIEW_COMMENT_HEADER, REVIEWER_COMMENT_HEADER
10
14
 
@@ -238,3 +242,174 @@ def test_create_issues_from_todos(tmp_path):
238
242
  assert len(issues) == 1
239
243
  assert issues[0].title == "Fix slow foo()"
240
244
  assert "technical-debt" in issues[0].labels
245
+
246
+
247
+ # ── Commit message generator tests ───────────────────────────────────────────
248
+
249
+ @patch("pr_pilot.analyzer._get_staged_diff", return_value="+ def login(user, password):\n+ return auth(user)")
250
+ def test_generate_commit_message_basic(mock_diff):
251
+ payload = {
252
+ "subject": "feat(auth): add login function",
253
+ "body": "Implements basic login using the auth helper.",
254
+ "breaking": False,
255
+ "footer": None,
256
+ }
257
+ with patch("pr_pilot.analyzer.OpenAI") as MockOpenAI:
258
+ MockOpenAI.return_value.chat.completions.create.return_value = \
259
+ _mock_openai_response(json.dumps(payload))
260
+ msg = generate_commit_message("fake-key")
261
+ assert msg.subject == "feat(auth): add login function"
262
+ assert msg.breaking is False
263
+ assert msg.footer is None
264
+
265
+
266
+ @patch("pr_pilot.analyzer._get_staged_diff", return_value="")
267
+ def test_generate_commit_message_no_staged(mock_diff):
268
+ with pytest.raises(ValueError, match="No staged changes"):
269
+ generate_commit_message("fake-key")
270
+
271
+
272
+ def test_commit_message_format_full():
273
+ msg = CommitMessage(
274
+ subject="feat(api): add rate limiting",
275
+ body="Adds sliding window rate limiting to all endpoints.",
276
+ breaking=True,
277
+ footer="BREAKING CHANGE: Rate limit headers are now always present.",
278
+ )
279
+ formatted = msg.format()
280
+ assert "feat(api): add rate limiting" in formatted
281
+ assert "sliding window" in formatted
282
+ assert "BREAKING CHANGE" in formatted
283
+
284
+
285
+ def test_commit_message_format_subject_only():
286
+ msg = CommitMessage(subject="chore: update deps", body=None, breaking=False, footer=None)
287
+ assert msg.format() == "chore: update deps"
288
+
289
+
290
+ # ── Release workflow tests ────────────────────────────────────────────────────
291
+
292
+ @patch("pr_pilot.analyzer._get_commits_since_tag", return_value="abc Fix login\ndef Add dark mode")
293
+ @patch("pr_pilot.analyzer._get_current_version", return_value="1.0.0")
294
+ @patch("pr_pilot.analyzer.get_diff", return_value="+ new code")
295
+ @patch("pr_pilot.analyzer.get_commits", return_value="")
296
+ @patch("pr_pilot.analyzer.get_branch", return_value="main")
297
+ def test_run_release_dry_run(mock_branch, mock_commits, mock_diff, mock_ver, mock_ctag, tmp_path): # noqa: E501
298
+ changelog_payload = {
299
+ "version": "minor", "highlights": "New features.",
300
+ "added": ["Dark mode"], "changed": [], "fixed": ["Login bug"],
301
+ "removed": [], "security": [],
302
+ }
303
+ release_payload = {
304
+ "name": "v1.1.0 — Dark Mode",
305
+ "body": "## What's new\n- Dark mode added\n- Login bug fixed",
306
+ "prerelease": False,
307
+ }
308
+ responses = [
309
+ _mock_openai_response(json.dumps(changelog_payload)),
310
+ _mock_openai_response(json.dumps(release_payload)),
311
+ ]
312
+ changelog_file = str(tmp_path / "CHANGELOG.md")
313
+ with patch("pr_pilot.analyzer.OpenAI") as MockOpenAI:
314
+ MockOpenAI.return_value.chat.completions.create.side_effect = responses
315
+ release = run_release("fake-key", repo="owner/repo",
316
+ changelog_path=changelog_file, dry_run=True)
317
+ assert release.version == "1.1.0"
318
+ assert release.tag == "v1.1.0"
319
+ assert "Dark Mode" in release.name
320
+ # dry run: changelog file should NOT be written
321
+ import pathlib
322
+ assert not pathlib.Path(changelog_file).exists()
323
+
324
+
325
+ # ── Docstring generator tests ─────────────────────────────────────────────────
326
+
327
+ def test_detect_language():
328
+ assert _detect_language("app.py") == "python"
329
+ assert _detect_language("index.ts") == "typescript"
330
+ assert _detect_language("utils.tsx") == "typescript"
331
+ assert _detect_language("main.js") == "javascript"
332
+
333
+
334
+ def test_extract_functions_python():
335
+ code = "x = 1\n\ndef foo(a, b):\n return a + b\n\ndef bar():\n pass\n"
336
+ funcs = _extract_functions(code, "python")
337
+ assert len(funcs) == 2
338
+ assert "def foo" in funcs[0][0]
339
+ assert funcs[0][1] == 3 # line number
340
+
341
+
342
+ def test_generate_docstrings(tmp_path):
343
+ py_file = tmp_path / "utils.py"
344
+ py_file.write_text("def add(a, b):\n return a + b\n")
345
+ payload = {
346
+ "language": "python",
347
+ "function_name": "add",
348
+ "docstring": "Add two numbers and return the result.",
349
+ "placement": "inside",
350
+ }
351
+ with patch("pr_pilot.analyzer.OpenAI") as MockOpenAI:
352
+ MockOpenAI.return_value.chat.completions.create.return_value = \
353
+ _mock_openai_response(json.dumps(payload))
354
+ results = generate_docstrings("fake-key", str(py_file))
355
+ assert len(results) == 1
356
+ assert results[0].function_name == "add"
357
+ assert "Add two numbers" in results[0].docstring
358
+ assert results[0].placement == "inside"
359
+
360
+
361
+ def test_generate_docstrings_no_functions(tmp_path):
362
+ py_file = tmp_path / "constants.py"
363
+ py_file.write_text("MAX = 100\nMIN = 0\n")
364
+ results = generate_docstrings("fake-key", str(py_file))
365
+ assert results == []
366
+
367
+
368
+ # ── Branch namer tests ────────────────────────────────────────────────────────
369
+
370
+ def test_suggest_branch_basic():
371
+ payload = {
372
+ "suggestions": ["feat/add-dark-mode", "feat/dark-mode-toggle", "feat/theme-switcher"],
373
+ "recommended": 0,
374
+ }
375
+ with patch("pr_pilot.analyzer.OpenAI") as MockOpenAI:
376
+ MockOpenAI.return_value.chat.completions.create.return_value = \
377
+ _mock_openai_response(json.dumps(payload))
378
+ result = suggest_branch("fake-key", task="add dark mode to settings page")
379
+ assert result.best == "feat/add-dark-mode"
380
+ assert len(result.suggestions) == 3
381
+ assert result.recommended == 0
382
+
383
+
384
+ def test_suggest_branch_recommended_index():
385
+ payload = {
386
+ "suggestions": ["fix/login-bug", "fix/auth-timeout", "fix/session-expiry"],
387
+ "recommended": 2,
388
+ }
389
+ with patch("pr_pilot.analyzer.OpenAI") as MockOpenAI:
390
+ MockOpenAI.return_value.chat.completions.create.return_value = \
391
+ _mock_openai_response(json.dumps(payload))
392
+ result = suggest_branch("fake-key", task="fix session expiry on mobile")
393
+ assert result.best == "fix/session-expiry"
394
+
395
+
396
+ # ── Code explainer tests ──────────────────────────────────────────────────────
397
+
398
+ def test_explain_code_file(tmp_path):
399
+ py_file = tmp_path / "auth.py"
400
+ py_file.write_text("def authenticate(user, password):\n return user == 'admin'\n")
401
+ with patch("pr_pilot.analyzer.OpenAI") as MockOpenAI:
402
+ MockOpenAI.return_value.chat.completions.create.return_value = \
403
+ _mock_openai_response("This file handles basic authentication by comparing credentials.")
404
+ result = explain_code("fake-key", str(py_file))
405
+ assert "authentication" in result.lower()
406
+
407
+
408
+ def test_explain_code_with_selector(tmp_path):
409
+ py_file = tmp_path / "utils.py"
410
+ py_file.write_text("def add(a, b):\n return a + b\n\ndef sub(a, b):\n return a - b\n")
411
+ with patch("pr_pilot.analyzer.OpenAI") as MockOpenAI:
412
+ MockOpenAI.return_value.chat.completions.create.return_value = \
413
+ _mock_openai_response("The add function takes two numbers and returns their sum.")
414
+ result = explain_code("fake-key", str(py_file), selector="add")
415
+ assert "add" in result.lower() or "sum" in result.lower()
@@ -1 +0,0 @@
1
- __version__ = "0.3.0"
File without changes