pullwise 0.1.0__tar.gz → 0.3.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.1.0
3
+ Version: 0.3.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
@@ -10,7 +10,6 @@ Keywords: github,pull-request,openai,ai,automation,devtools,cli,github-actions
10
10
  Classifier: Development Status :: 3 - Alpha
11
11
  Classifier: Environment :: Console
12
12
  Classifier: Intended Audience :: Developers
13
- Classifier: License :: OSI Approved :: MIT License
14
13
  Classifier: Programming Language :: Python :: 3
15
14
  Classifier: Topic :: Software Development :: Version Control :: Git
16
15
  Requires-Python: >=3.9
@@ -19,10 +18,17 @@ Requires-Dist: openai>=1.0.0
19
18
  Provides-Extra: dev
20
19
  Requires-Dist: pytest; extra == "dev"
21
20
 
22
- # pr-pilot ✈️
21
+ # PullWise ✈️
22
+
23
+ [![PyPI](https://img.shields.io/pypi/v/pullwise)](https://pypi.org/project/pullwise/)
24
+ [![Downloads](https://img.shields.io/pypi/dm/pullwise)](https://pypi.org/project/pullwise/)
25
+ [![CI](https://github.com/albertusreza/pr-pilot/actions/workflows/ci.yml/badge.svg)](https://github.com/albertusreza/pr-pilot/actions)
26
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
23
27
 
24
28
  **Stop writing PR descriptions.** Let AI do it.
25
29
 
30
+ > 📦 GitHub Action: [`albertusreza/pr-pilot`](https://github.com/marketplace/actions/pullwise) · PyPI: [`pullwise`](https://pypi.org/project/pullwise/)
31
+
26
32
  `pr-pilot` is a GitHub Action + CLI that analyzes your diff and commit history, then automatically writes a clear, structured PR description — with a summary, change list, test plan, and labels.
27
33
 
28
34
  ```yaml
@@ -87,7 +93,7 @@ Add `OPENAI_API_KEY` to your repo secrets and you're done. Works with any langua
87
93
  ### As a CLI
88
94
 
89
95
  ```bash
90
- pip install pr-pilot
96
+ pip install pullwise
91
97
  export OPENAI_API_KEY=sk-...
92
98
 
93
99
  # Generate a description for your current branch
@@ -103,6 +109,73 @@ pr-pilot review
103
109
  pr-pilot describe --markdown pr_description.md
104
110
  ```
105
111
 
112
+ ## Features
113
+
114
+ | Command | What it does |
115
+ |---|---|
116
+ | `pr-pilot describe` | Generate a structured PR description from your diff |
117
+ | `pr-pilot review` | Get a senior-engineer-style code review in the terminal |
118
+ | `pr-pilot comment` | Post an AI review as a GitHub PR comment (updates on re-run, no spam) |
119
+ | `pr-pilot changelog` | Generate a `CHANGELOG.md` entry from commits since last tag |
120
+ | `pr-pilot reviewers` | Suggest reviewers based on git blame of changed files |
121
+ | `pr-pilot standup` | Generate a daily standup update from your recent commits |
122
+ | `pr-pilot todos` | Scan for TODO/FIXME comments and create GitHub issues from them |
123
+
124
+ ## Usage
125
+
126
+ ```bash
127
+ # Generate PR description (terminal)
128
+ pr-pilot describe --base main
129
+
130
+ # Get a quick code review in the terminal
131
+ pr-pilot review --base main
132
+
133
+ # Post review as a GitHub PR comment
134
+ pr-pilot comment --repo owner/repo --pr 42
135
+
136
+ # Generate CHANGELOG entry (print to stdout)
137
+ pr-pilot changelog
138
+
139
+ # Write directly into CHANGELOG.md
140
+ pr-pilot changelog --output CHANGELOG.md
141
+ ```
142
+
143
+ ```bash
144
+ # Suggest reviewers for your current branch
145
+ pr-pilot reviewers --base main
146
+
147
+ # Post suggestion as a comment + assign on GitHub
148
+ pr-pilot reviewers --post --assign --repo owner/repo --pr 42
149
+
150
+ # Generate a standup from yesterday's commits
151
+ pr-pilot standup
152
+
153
+ # Generate from the last 3 days and copy to clipboard
154
+ pr-pilot standup --days 3 --copy
155
+
156
+ # Scan for TODO/FIXME and preview issues
157
+ pr-pilot todos
158
+
159
+ # Actually create GitHub issues from them
160
+ pr-pilot todos --create --repo owner/repo
161
+ ```
162
+
163
+ ### Auto-comment on every PR (GitHub Action)
164
+
165
+ ```yaml
166
+ # .github/workflows/pr-review.yml
167
+ - uses: actions/checkout@v4
168
+ with:
169
+ fetch-depth: 0
170
+ - run: pip install pullwise
171
+ - run: pr-pilot comment --repo ${{ github.repository }} --pr ${{ github.event.pull_request.number }}
172
+ env:
173
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
174
+ GITHUB_TOKEN: ${{ github.token }}
175
+ ```
176
+
177
+ The comment updates itself on every new push — no duplicate comments.
178
+
106
179
  ## Options
107
180
 
108
181
  | Input | Default | Description |
@@ -1,7 +1,14 @@
1
- # pr-pilot ✈️
1
+ # PullWise ✈️
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/pullwise)](https://pypi.org/project/pullwise/)
4
+ [![Downloads](https://img.shields.io/pypi/dm/pullwise)](https://pypi.org/project/pullwise/)
5
+ [![CI](https://github.com/albertusreza/pr-pilot/actions/workflows/ci.yml/badge.svg)](https://github.com/albertusreza/pr-pilot/actions)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
2
7
 
3
8
  **Stop writing PR descriptions.** Let AI do it.
4
9
 
10
+ > 📦 GitHub Action: [`albertusreza/pr-pilot`](https://github.com/marketplace/actions/pullwise) · PyPI: [`pullwise`](https://pypi.org/project/pullwise/)
11
+
5
12
  `pr-pilot` is a GitHub Action + CLI that analyzes your diff and commit history, then automatically writes a clear, structured PR description — with a summary, change list, test plan, and labels.
6
13
 
7
14
  ```yaml
@@ -66,7 +73,7 @@ Add `OPENAI_API_KEY` to your repo secrets and you're done. Works with any langua
66
73
  ### As a CLI
67
74
 
68
75
  ```bash
69
- pip install pr-pilot
76
+ pip install pullwise
70
77
  export OPENAI_API_KEY=sk-...
71
78
 
72
79
  # Generate a description for your current branch
@@ -82,6 +89,73 @@ pr-pilot review
82
89
  pr-pilot describe --markdown pr_description.md
83
90
  ```
84
91
 
92
+ ## Features
93
+
94
+ | Command | What it does |
95
+ |---|---|
96
+ | `pr-pilot describe` | Generate a structured PR description from your diff |
97
+ | `pr-pilot review` | Get a senior-engineer-style code review in the terminal |
98
+ | `pr-pilot comment` | Post an AI review as a GitHub PR comment (updates on re-run, no spam) |
99
+ | `pr-pilot changelog` | Generate a `CHANGELOG.md` entry from commits since last tag |
100
+ | `pr-pilot reviewers` | Suggest reviewers based on git blame of changed files |
101
+ | `pr-pilot standup` | Generate a daily standup update from your recent commits |
102
+ | `pr-pilot todos` | Scan for TODO/FIXME comments and create GitHub issues from them |
103
+
104
+ ## Usage
105
+
106
+ ```bash
107
+ # Generate PR description (terminal)
108
+ pr-pilot describe --base main
109
+
110
+ # Get a quick code review in the terminal
111
+ pr-pilot review --base main
112
+
113
+ # Post review as a GitHub PR comment
114
+ pr-pilot comment --repo owner/repo --pr 42
115
+
116
+ # Generate CHANGELOG entry (print to stdout)
117
+ pr-pilot changelog
118
+
119
+ # Write directly into CHANGELOG.md
120
+ pr-pilot changelog --output CHANGELOG.md
121
+ ```
122
+
123
+ ```bash
124
+ # Suggest reviewers for your current branch
125
+ pr-pilot reviewers --base main
126
+
127
+ # Post suggestion as a comment + assign on GitHub
128
+ pr-pilot reviewers --post --assign --repo owner/repo --pr 42
129
+
130
+ # Generate a standup from yesterday's commits
131
+ pr-pilot standup
132
+
133
+ # Generate from the last 3 days and copy to clipboard
134
+ pr-pilot standup --days 3 --copy
135
+
136
+ # Scan for TODO/FIXME and preview issues
137
+ pr-pilot todos
138
+
139
+ # Actually create GitHub issues from them
140
+ pr-pilot todos --create --repo owner/repo
141
+ ```
142
+
143
+ ### Auto-comment on every PR (GitHub Action)
144
+
145
+ ```yaml
146
+ # .github/workflows/pr-review.yml
147
+ - uses: actions/checkout@v4
148
+ with:
149
+ fetch-depth: 0
150
+ - run: pip install pullwise
151
+ - run: pr-pilot comment --repo ${{ github.repository }} --pr ${{ github.event.pull_request.number }}
152
+ env:
153
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
154
+ GITHUB_TOKEN: ${{ github.token }}
155
+ ```
156
+
157
+ The comment updates itself on every new push — no duplicate comments.
158
+
85
159
  ## Options
86
160
 
87
161
  | Input | Default | Description |
@@ -0,0 +1 @@
1
+ __version__ = "0.3.0"
@@ -0,0 +1,392 @@
1
+ from __future__ import annotations
2
+ import json
3
+ import subprocess
4
+ from dataclasses import dataclass, field
5
+
6
+ from openai import OpenAI
7
+
8
+ from .templates import (
9
+ DESCRIBE_SYSTEM, DESCRIBE_USER, LABEL_SYSTEM, REVIEW_SYSTEM,
10
+ CHANGELOG_SYSTEM, CHANGELOG_USER, REVIEW_COMMENT_HEADER, REVIEW_COMMENT_TEMPLATE,
11
+ REVIEWER_SYSTEM, REVIEWER_USER, REVIEWER_COMMENT_HEADER, REVIEWER_COMMENT_TEMPLATE,
12
+ STANDUP_SYSTEM, STANDUP_USER,
13
+ ISSUE_SYSTEM, ISSUE_USER,
14
+ )
15
+
16
+ _MAX_DIFF_CHARS = 24_000 # stay well within context limits
17
+ _MODEL = "gpt-4o"
18
+
19
+
20
+ @dataclass
21
+ class PRDescription:
22
+ title: str
23
+ summary: str
24
+ changes: list[str]
25
+ breaking: bool
26
+ breaking_notes: str | None
27
+ test_plan: str
28
+ labels: list[str]
29
+
30
+ def to_markdown(self) -> str:
31
+ lines = [
32
+ f"## Summary\n{self.summary}\n",
33
+ "## Changes",
34
+ ]
35
+ for c in self.changes:
36
+ lines.append(f"- {c}")
37
+ if self.breaking:
38
+ lines.append(f"\n## ⚠️ Breaking Changes\n{self.breaking_notes}")
39
+ lines.append(f"\n## Test Plan\n{self.test_plan}")
40
+ return "\n".join(lines)
41
+
42
+
43
+ def _git(*args: str) -> str:
44
+ result = subprocess.run(["git"] + list(args), capture_output=True, text=True)
45
+ return result.stdout.strip()
46
+
47
+
48
+ def get_diff(base: str = "main") -> str:
49
+ diff = _git("diff", f"{base}...HEAD")
50
+ if len(diff) > _MAX_DIFF_CHARS:
51
+ diff = diff[:_MAX_DIFF_CHARS] + "\n\n[diff truncated — showing first 24k chars]"
52
+ return diff
53
+
54
+
55
+ def get_commits(base: str = "main") -> str:
56
+ return _git("log", f"{base}...HEAD", "--oneline", "--no-merges")
57
+
58
+
59
+ def get_branch() -> str:
60
+ return _git("rev-parse", "--abbrev-ref", "HEAD")
61
+
62
+
63
+ def describe_pr(api_key: str, base: str = "main", model: str = _MODEL) -> PRDescription:
64
+ client = OpenAI(api_key=api_key)
65
+ diff = get_diff(base)
66
+ commits = get_commits(base)
67
+ branch = get_branch()
68
+
69
+ if not diff and not commits:
70
+ raise ValueError(f"No changes found between '{branch}' and '{base}'")
71
+
72
+ user_msg = DESCRIBE_USER.format(
73
+ branch=branch, base=base, commits=commits or "(none)", diff=diff or "(empty)"
74
+ )
75
+ resp = client.chat.completions.create(
76
+ model=model,
77
+ max_tokens=1024,
78
+ messages=[
79
+ {"role": "system", "content": DESCRIBE_SYSTEM},
80
+ {"role": "user", "content": user_msg},
81
+ ],
82
+ )
83
+ raw = resp.choices[0].message.content or "{}"
84
+ raw = raw.strip()
85
+ if raw.startswith("```"):
86
+ raw = "\n".join(raw.splitlines()[1:])
87
+ if raw.endswith("```"):
88
+ raw = "\n".join(raw.splitlines()[:-1])
89
+
90
+ data = json.loads(raw.strip())
91
+ return PRDescription(
92
+ title=data.get("title", branch),
93
+ summary=data.get("summary", ""),
94
+ changes=data.get("changes", []),
95
+ breaking=data.get("breaking", False),
96
+ breaking_notes=data.get("breaking_notes"),
97
+ test_plan=data.get("test_plan", ""),
98
+ labels=data.get("labels", ["feature"]),
99
+ )
100
+
101
+
102
+ def suggest_labels(api_key: str, title: str, body: str, model: str = _MODEL) -> list[str]:
103
+ client = OpenAI(api_key=api_key)
104
+ resp = client.chat.completions.create(
105
+ model=model,
106
+ max_tokens=64,
107
+ messages=[
108
+ {"role": "system", "content": LABEL_SYSTEM},
109
+ {"role": "user", "content": f"Title: {title}\n\nBody: {body[:2000]}"},
110
+ ],
111
+ )
112
+ raw = resp.choices[0].message.content or "[]"
113
+ return json.loads(raw.strip())
114
+
115
+
116
+ def review_pr(api_key: str, base: str = "main", model: str = _MODEL) -> str:
117
+ client = OpenAI(api_key=api_key)
118
+ diff = get_diff(base)
119
+ if not diff:
120
+ return "No changes to review."
121
+ resp = client.chat.completions.create(
122
+ model=model,
123
+ max_tokens=1024,
124
+ messages=[
125
+ {"role": "system", "content": REVIEW_SYSTEM},
126
+ {"role": "user", "content": f"Diff:\n\n{diff}"},
127
+ ],
128
+ )
129
+ return (resp.choices[0].message.content or "").strip()
130
+
131
+
132
+ def review_pr_as_comment(api_key: str, base: str = "main", model: str = _MODEL) -> str:
133
+ """Return a GitHub-flavoured markdown comment with the AI review."""
134
+ body = review_pr(api_key, base=base, model=model)
135
+ return REVIEW_COMMENT_TEMPLATE.format(header=REVIEW_COMMENT_HEADER, body=body)
136
+
137
+
138
+ # ── Changelog ────────────────────────────────────────────────────────────────
139
+
140
+ @dataclass
141
+ class ChangelogEntry:
142
+ version_bump: str # "patch" | "minor" | "major"
143
+ highlights: str
144
+ added: list[str]
145
+ changed: list[str]
146
+ fixed: list[str]
147
+ removed: list[str]
148
+ security: list[str]
149
+
150
+ def to_markdown(self, new_version: str, date: str) -> str:
151
+ lines = [f"## [{new_version}] - {date}", f"\n_{self.highlights}_\n"]
152
+ sections = [
153
+ ("Added", self.added),
154
+ ("Changed", self.changed),
155
+ ("Fixed", self.fixed),
156
+ ("Removed", self.removed),
157
+ ("Security", self.security),
158
+ ]
159
+ for title, items in sections:
160
+ if items:
161
+ lines.append(f"\n### {title}")
162
+ for item in items:
163
+ lines.append(f"- {item}")
164
+ return "\n".join(lines)
165
+
166
+
167
+ def _get_current_version() -> str:
168
+ """Read version from pyproject.toml, setup.cfg, or package __init__."""
169
+ try:
170
+ tag = _git("describe", "--tags", "--abbrev=0")
171
+ return tag.lstrip("v") if tag else "0.0.0"
172
+ except Exception:
173
+ return "0.0.0"
174
+
175
+
176
+ def _get_commits_since_tag() -> str:
177
+ tag = _git("describe", "--tags", "--abbrev=0")
178
+ if tag:
179
+ return _git("log", f"{tag}...HEAD", "--oneline", "--no-merges")
180
+ return _git("log", "--oneline", "--no-merges", "-30")
181
+
182
+
183
+ def _bump_version(current: str, bump: str) -> str:
184
+ parts = current.split(".")
185
+ try:
186
+ major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2].split("-")[0])
187
+ except (IndexError, ValueError):
188
+ return "0.1.0"
189
+ if bump == "major":
190
+ return f"{major + 1}.0.0"
191
+ if bump == "minor":
192
+ return f"{major}.{minor + 1}.0"
193
+ return f"{major}.{minor}.{patch + 1}"
194
+
195
+
196
+ def generate_changelog(api_key: str, model: str = _MODEL) -> tuple[ChangelogEntry, str]:
197
+ """Return (ChangelogEntry, new_version_string)."""
198
+ import datetime
199
+ client = OpenAI(api_key=api_key)
200
+ current = _get_current_version()
201
+ commits = _get_commits_since_tag()
202
+ diff = get_diff(base="HEAD~10") if not commits else get_diff(base=f"v{current}" if current != "0.0.0" else "HEAD~10")
203
+
204
+ if not commits:
205
+ raise ValueError("No commits found since last tag. Nothing to changelog.")
206
+
207
+ user_msg = CHANGELOG_USER.format(
208
+ current_version=current, commits=commits, diff=diff[:12_000] or "(empty)"
209
+ )
210
+ resp = client.chat.completions.create(
211
+ model=model,
212
+ max_tokens=1024,
213
+ messages=[
214
+ {"role": "system", "content": CHANGELOG_SYSTEM},
215
+ {"role": "user", "content": user_msg},
216
+ ],
217
+ )
218
+ raw = (resp.choices[0].message.content or "{}").strip()
219
+ if raw.startswith("```"):
220
+ raw = "\n".join(raw.splitlines()[1:])
221
+ if raw.endswith("```"):
222
+ raw = "\n".join(raw.splitlines()[:-1])
223
+
224
+ data = json.loads(raw.strip())
225
+ entry = ChangelogEntry(
226
+ version_bump=data.get("version", "patch"),
227
+ highlights=data.get("highlights", ""),
228
+ added=data.get("added", []),
229
+ changed=data.get("changed", []),
230
+ fixed=data.get("fixed", []),
231
+ removed=data.get("removed", []),
232
+ security=data.get("security", []),
233
+ )
234
+ new_version = _bump_version(current, entry.version_bump)
235
+ return entry, new_version
236
+
237
+
238
+ # ── Reviewer suggester ────────────────────────────────────────────────────────
239
+
240
+ @dataclass
241
+ class ReviewerSuggestion:
242
+ reviewers: list[str]
243
+ reasoning: str
244
+
245
+ def to_comment(self) -> str:
246
+ rows = "\n".join(f"| @{r} | {self.reasoning} |" for r in self.reviewers)
247
+ return REVIEWER_COMMENT_TEMPLATE.format(
248
+ header=REVIEWER_COMMENT_HEADER,
249
+ reasoning=self.reasoning,
250
+ rows=rows,
251
+ )
252
+
253
+
254
+ def _get_blame_summary(base: str = "main") -> tuple[str, str]:
255
+ """Return (author, blame_summary) for files changed vs base."""
256
+ author = _git("config", "user.name") or "unknown"
257
+ changed_files = _git("diff", f"{base}...HEAD", "--name-only").splitlines()
258
+ lines = []
259
+ for f in changed_files[:15]: # cap at 15 files
260
+ blame = _git("log", "--follow", "--format=%an", "-5", "--", f)
261
+ authors = ", ".join(dict.fromkeys(blame.splitlines())) # unique, ordered
262
+ lines.append(f" {f}: {authors or 'no history'}")
263
+ return author, "\n".join(lines)
264
+
265
+
266
+ def suggest_reviewers(api_key: str, base: str = "main", model: str = _MODEL) -> ReviewerSuggestion:
267
+ client = OpenAI(api_key=api_key)
268
+ author, blame_summary = _get_blame_summary(base)
269
+ if not blame_summary:
270
+ raise ValueError("No changed files found — nothing to base reviewer suggestion on.")
271
+ resp = client.chat.completions.create(
272
+ model=model,
273
+ max_tokens=256,
274
+ messages=[
275
+ {"role": "system", "content": REVIEWER_SYSTEM},
276
+ {"role": "user", "content": REVIEWER_USER.format(author=author, blame_summary=blame_summary)},
277
+ ],
278
+ )
279
+ raw = (resp.choices[0].message.content or "{}").strip()
280
+ if raw.startswith("```"):
281
+ raw = "\n".join(raw.splitlines()[1:])
282
+ if raw.endswith("```"):
283
+ raw = "\n".join(raw.splitlines()[:-1])
284
+ data = json.loads(raw.strip())
285
+ return ReviewerSuggestion(
286
+ reviewers=data.get("reviewers", []),
287
+ reasoning=data.get("reasoning", ""),
288
+ )
289
+
290
+
291
+ # ── Standup generator ─────────────────────────────────────────────────────────
292
+
293
+ def generate_standup(api_key: str, days: int = 1, model: str = _MODEL) -> str:
294
+ client = OpenAI(api_key=api_key)
295
+ author = _git("config", "user.name") or "developer"
296
+ since = f"{days} day ago" if days == 1 else f"{days} days ago"
297
+ commits = _git("log", f"--since={since}", "--oneline", "--no-merges",
298
+ f"--author={author}")
299
+ if not commits:
300
+ commits = _git("log", "--oneline", "--no-merges", "-10")
301
+ if not commits:
302
+ return "No recent commits found."
303
+ resp = client.chat.completions.create(
304
+ model=model,
305
+ max_tokens=256,
306
+ messages=[
307
+ {"role": "system", "content": STANDUP_SYSTEM},
308
+ {"role": "user", "content": STANDUP_USER.format(
309
+ author=author, days=days, commits=commits
310
+ )},
311
+ ],
312
+ )
313
+ return (resp.choices[0].message.content or "").strip()
314
+
315
+
316
+ # ── Issue creator (TODO/FIXME scanner) ───────────────────────────────────────
317
+
318
+ @dataclass
319
+ class TodoIssue:
320
+ file_path: str
321
+ line_number: int
322
+ comment: str
323
+ title: str
324
+ body: str
325
+ labels: list[str]
326
+
327
+
328
+ def _scan_todos(root: str = ".") -> list[tuple[str, int, str, str]]:
329
+ """Return list of (file, lineno, comment_text, context) for TODO/FIXME."""
330
+ import re
331
+ from pathlib import Path
332
+ pattern = re.compile(r'(TODO|FIXME|HACK|XXX)\s*:?\s*(.+)', re.IGNORECASE)
333
+ skip = {'.git', 'node_modules', '__pycache__', '.venv', 'venv', 'dist', 'build'}
334
+ results = []
335
+ for path in Path(root).rglob('*'):
336
+ if any(p in skip for p in path.parts):
337
+ continue
338
+ if not path.is_file():
339
+ continue
340
+ suffix = path.suffix.lower()
341
+ if suffix not in {'.py', '.js', '.ts', '.tsx', '.jsx', '.go', '.rb', '.java', '.md'}:
342
+ continue
343
+ try:
344
+ lines = path.read_text(errors='replace').splitlines()
345
+ except Exception:
346
+ continue
347
+ for i, line in enumerate(lines, 1):
348
+ m = pattern.search(line)
349
+ if m:
350
+ start = max(0, i - 3)
351
+ end = min(len(lines), i + 3)
352
+ context = '\n'.join(lines[start:end])
353
+ results.append((str(path), i, m.group(0).strip(), context))
354
+ return results
355
+
356
+
357
+ def create_issues_from_todos(
358
+ api_key: str, root: str = ".", model: str = _MODEL
359
+ ) -> list[TodoIssue]:
360
+ client = OpenAI(api_key=api_key)
361
+ todos = _scan_todos(root)
362
+ issues = []
363
+ for file_path, line_number, comment, context in todos:
364
+ resp = client.chat.completions.create(
365
+ model=model,
366
+ max_tokens=512,
367
+ messages=[
368
+ {"role": "system", "content": ISSUE_SYSTEM},
369
+ {"role": "user", "content": ISSUE_USER.format(
370
+ file_path=file_path, line_number=line_number,
371
+ comment=comment, context=context,
372
+ )},
373
+ ],
374
+ )
375
+ raw = (resp.choices[0].message.content or "{}").strip()
376
+ if raw.startswith("```"):
377
+ raw = "\n".join(raw.splitlines()[1:])
378
+ if raw.endswith("```"):
379
+ raw = "\n".join(raw.splitlines()[:-1])
380
+ try:
381
+ data = json.loads(raw.strip())
382
+ except json.JSONDecodeError:
383
+ continue
384
+ issues.append(TodoIssue(
385
+ file_path=file_path,
386
+ line_number=line_number,
387
+ comment=comment,
388
+ title=data.get("title", comment[:72]),
389
+ body=data.get("body", ""),
390
+ labels=data.get("labels", ["technical-debt"]),
391
+ ))
392
+ return issues