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.
- {pullwise-0.3.0 → pullwise-0.5.0}/PKG-INFO +40 -1
- {pullwise-0.3.0 → pullwise-0.5.0}/README.md +39 -0
- pullwise-0.5.0/pr_pilot/__init__.py +1 -0
- {pullwise-0.3.0 → pullwise-0.5.0}/pr_pilot/analyzer.py +328 -0
- {pullwise-0.3.0 → pullwise-0.5.0}/pr_pilot/cli.py +178 -0
- {pullwise-0.3.0 → pullwise-0.5.0}/pr_pilot/templates.py +131 -0
- {pullwise-0.3.0 → pullwise-0.5.0}/pullwise.egg-info/PKG-INFO +40 -1
- {pullwise-0.3.0 → pullwise-0.5.0}/pyproject.toml +1 -1
- {pullwise-0.3.0 → pullwise-0.5.0}/tests/test_analyzer.py +175 -0
- pullwise-0.3.0/pr_pilot/__init__.py +0 -1
- {pullwise-0.3.0 → pullwise-0.5.0}/pr_pilot/github_client.py +0 -0
- {pullwise-0.3.0 → pullwise-0.5.0}/pullwise.egg-info/SOURCES.txt +0 -0
- {pullwise-0.3.0 → pullwise-0.5.0}/pullwise.egg-info/dependency_links.txt +0 -0
- {pullwise-0.3.0 → pullwise-0.5.0}/pullwise.egg-info/entry_points.txt +0 -0
- {pullwise-0.3.0 → pullwise-0.5.0}/pullwise.egg-info/requires.txt +0 -0
- {pullwise-0.3.0 → pullwise-0.5.0}/pullwise.egg-info/top_level.txt +0 -0
- {pullwise-0.3.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
|
|
@@ -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
|
+
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
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|