pullwise 0.4.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.
- {pullwise-0.4.0 → pullwise-0.5.0}/PKG-INFO +24 -1
- {pullwise-0.4.0 → pullwise-0.5.0}/README.md +23 -0
- pullwise-0.5.0/pr_pilot/__init__.py +1 -0
- {pullwise-0.4.0 → pullwise-0.5.0}/pr_pilot/analyzer.py +170 -0
- {pullwise-0.4.0 → pullwise-0.5.0}/pr_pilot/cli.py +95 -0
- {pullwise-0.4.0 → pullwise-0.5.0}/pr_pilot/templates.py +77 -0
- {pullwise-0.4.0 → pullwise-0.5.0}/pullwise.egg-info/PKG-INFO +24 -1
- {pullwise-0.4.0 → pullwise-0.5.0}/pyproject.toml +1 -1
- {pullwise-0.4.0 → pullwise-0.5.0}/tests/test_analyzer.py +96 -1
- pullwise-0.4.0/pr_pilot/__init__.py +0 -1
- {pullwise-0.4.0 → pullwise-0.5.0}/pr_pilot/github_client.py +0 -0
- {pullwise-0.4.0 → pullwise-0.5.0}/pullwise.egg-info/SOURCES.txt +0 -0
- {pullwise-0.4.0 → pullwise-0.5.0}/pullwise.egg-info/dependency_links.txt +0 -0
- {pullwise-0.4.0 → pullwise-0.5.0}/pullwise.egg-info/entry_points.txt +0 -0
- {pullwise-0.4.0 → pullwise-0.5.0}/pullwise.egg-info/requires.txt +0 -0
- {pullwise-0.4.0 → pullwise-0.5.0}/pullwise.egg-info/top_level.txt +0 -0
- {pullwise-0.4.0 → pullwise-0.5.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.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
|
|
@@ -122,6 +122,9 @@ pr-pilot describe --markdown pr_description.md
|
|
|
122
122
|
| `pr-pilot todos` | Scan for TODO/FIXME comments and create GitHub issues from them |
|
|
123
123
|
| `pr-pilot commit` | Generate a Conventional Commit message from staged changes |
|
|
124
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 |
|
|
125
128
|
|
|
126
129
|
## Usage
|
|
127
130
|
|
|
@@ -142,6 +145,26 @@ pr-pilot changelog
|
|
|
142
145
|
pr-pilot changelog --output CHANGELOG.md
|
|
143
146
|
```
|
|
144
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
|
+
|
|
145
168
|
```bash
|
|
146
169
|
# Generate a commit message from staged changes
|
|
147
170
|
pr-pilot commit
|
|
@@ -102,6 +102,9 @@ pr-pilot describe --markdown pr_description.md
|
|
|
102
102
|
| `pr-pilot todos` | Scan for TODO/FIXME comments and create GitHub issues from them |
|
|
103
103
|
| `pr-pilot commit` | Generate a Conventional Commit message from staged changes |
|
|
104
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 |
|
|
105
108
|
|
|
106
109
|
## Usage
|
|
107
110
|
|
|
@@ -122,6 +125,26 @@ pr-pilot changelog
|
|
|
122
125
|
pr-pilot changelog --output CHANGELOG.md
|
|
123
126
|
```
|
|
124
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
|
+
|
|
125
148
|
```bash
|
|
126
149
|
# Generate a commit message from staged changes
|
|
127
150
|
pr-pilot commit
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.5.0"
|
|
@@ -13,6 +13,9 @@ from .templates import (
|
|
|
13
13
|
ISSUE_SYSTEM, ISSUE_USER,
|
|
14
14
|
COMMIT_SYSTEM, COMMIT_USER,
|
|
15
15
|
RELEASE_NOTES_SYSTEM, RELEASE_NOTES_USER,
|
|
16
|
+
DOCSTRING_SYSTEM, DOCSTRING_USER,
|
|
17
|
+
BRANCH_SYSTEM, BRANCH_USER,
|
|
18
|
+
EXPLAIN_SYSTEM, EXPLAIN_USER,
|
|
16
19
|
)
|
|
17
20
|
|
|
18
21
|
_MAX_DIFF_CHARS = 24_000 # stay well within context limits
|
|
@@ -548,3 +551,170 @@ def run_release(
|
|
|
548
551
|
body=release_body,
|
|
549
552
|
prerelease=False,
|
|
550
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()
|
|
@@ -7,6 +7,7 @@ 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
9
|
generate_commit_message, run_release,
|
|
10
|
+
generate_docstrings, suggest_branch, explain_code,
|
|
10
11
|
)
|
|
11
12
|
|
|
12
13
|
_RESET = "\033[0m"
|
|
@@ -169,6 +170,78 @@ def cmd_release(args: argparse.Namespace) -> None:
|
|
|
169
170
|
print()
|
|
170
171
|
|
|
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
|
+
|
|
172
245
|
def cmd_reviewers(args: argparse.Namespace) -> None:
|
|
173
246
|
from .github_client import upsert_comment
|
|
174
247
|
from .templates import REVIEWER_COMMENT_HEADER
|
|
@@ -312,6 +385,28 @@ def main() -> None:
|
|
|
312
385
|
p_rev.add_argument("--model", default="gpt-4o", help="OpenAI model to use")
|
|
313
386
|
p_rev.set_defaults(func=cmd_review)
|
|
314
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
|
+
|
|
315
410
|
# --- commit ---
|
|
316
411
|
p_commit = sub.add_parser("commit", help="Generate a conventional commit message from staged changes")
|
|
317
412
|
p_commit.add_argument("--model", default="gpt-4o")
|
|
@@ -229,3 +229,80 @@ Version: {version}
|
|
|
229
229
|
Changelog entry:
|
|
230
230
|
{changelog_md}
|
|
231
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
|
+
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
|
|
@@ -122,6 +122,9 @@ pr-pilot describe --markdown pr_description.md
|
|
|
122
122
|
| `pr-pilot todos` | Scan for TODO/FIXME comments and create GitHub issues from them |
|
|
123
123
|
| `pr-pilot commit` | Generate a Conventional Commit message from staged changes |
|
|
124
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 |
|
|
125
128
|
|
|
126
129
|
## Usage
|
|
127
130
|
|
|
@@ -142,6 +145,26 @@ pr-pilot changelog
|
|
|
142
145
|
pr-pilot changelog --output CHANGELOG.md
|
|
143
146
|
```
|
|
144
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
|
+
|
|
145
168
|
```bash
|
|
146
169
|
# Generate a commit message from staged changes
|
|
147
170
|
pr-pilot commit
|
|
@@ -7,6 +7,8 @@ from pr_pilot.analyzer import (
|
|
|
7
7
|
review_pr_as_comment, generate_changelog, ChangelogEntry,
|
|
8
8
|
suggest_reviewers, generate_standup, create_issues_from_todos, _bump_version,
|
|
9
9
|
generate_commit_message, CommitMessage, run_release,
|
|
10
|
+
generate_docstrings, suggest_branch, explain_code,
|
|
11
|
+
_extract_functions, _detect_language,
|
|
10
12
|
)
|
|
11
13
|
from pr_pilot.templates import REVIEW_COMMENT_HEADER, REVIEWER_COMMENT_HEADER
|
|
12
14
|
|
|
@@ -292,7 +294,7 @@ def test_commit_message_format_subject_only():
|
|
|
292
294
|
@patch("pr_pilot.analyzer.get_diff", return_value="+ new code")
|
|
293
295
|
@patch("pr_pilot.analyzer.get_commits", return_value="")
|
|
294
296
|
@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):
|
|
297
|
+
def test_run_release_dry_run(mock_branch, mock_commits, mock_diff, mock_ver, mock_ctag, tmp_path): # noqa: E501
|
|
296
298
|
changelog_payload = {
|
|
297
299
|
"version": "minor", "highlights": "New features.",
|
|
298
300
|
"added": ["Dark mode"], "changed": [], "fixed": ["Login bug"],
|
|
@@ -318,3 +320,96 @@ def test_run_release_dry_run(mock_branch, mock_commits, mock_diff, mock_ver, moc
|
|
|
318
320
|
# dry run: changelog file should NOT be written
|
|
319
321
|
import pathlib
|
|
320
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.4.0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|