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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pullwise
3
- Version: 0.4.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.4.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pullwise"
7
- version = "0.4.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" }
@@ -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