boomtick-cli 0.2.1__py3-none-any.whl

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.
Files changed (103) hide show
  1. boomtick_cli-0.2.1.dist-info/METADATA +20 -0
  2. boomtick_cli-0.2.1.dist-info/RECORD +103 -0
  3. boomtick_cli-0.2.1.dist-info/WHEEL +5 -0
  4. boomtick_cli-0.2.1.dist-info/entry_points.txt +3 -0
  5. boomtick_cli-0.2.1.dist-info/top_level.txt +1 -0
  6. dev_tools/__init__.py +1 -0
  7. dev_tools/cli-schema.json +2028 -0
  8. dev_tools/cli.py +1542 -0
  9. dev_tools/config.py +235 -0
  10. dev_tools/constants.py +14 -0
  11. dev_tools/daemon.py +146 -0
  12. dev_tools/dist/config.js +108 -0
  13. dev_tools/dist/index.js +10 -0
  14. dev_tools/dist/lib/error_utils.js +8 -0
  15. dev_tools/dist/lib/git.js +31 -0
  16. dev_tools/dist/lib/result.js +21 -0
  17. dev_tools/dist/lib/shell.js +91 -0
  18. dev_tools/dist/lib/shell.test.js +10 -0
  19. dev_tools/dist/lib/td-cli.js +27 -0
  20. dev_tools/dist/lib/test-utils.js +20 -0
  21. dev_tools/dist/mcp/definitions.js +242 -0
  22. dev_tools/dist/mcp/server.js +317 -0
  23. dev_tools/dist/mcp/tools.js +18 -0
  24. dev_tools/dist/tools/contract.js +2069 -0
  25. dev_tools/dist/tools/ddgs.search.js +50 -0
  26. dev_tools/dist/tools/ddgs.search.test.js +67 -0
  27. dev_tools/dist/tools/ddgs_search.py +23 -0
  28. dev_tools/dist/tools/github.checkout_branch.js +20 -0
  29. dev_tools/dist/tools/github.comment_triage_summary.js +25 -0
  30. dev_tools/dist/tools/github.create_issue.js +28 -0
  31. dev_tools/dist/tools/github.create_issue.test.js +54 -0
  32. dev_tools/dist/tools/github.create_pull_request.js +44 -0
  33. dev_tools/dist/tools/github.get_merge_conflict_files.js +26 -0
  34. dev_tools/dist/tools/github.get_pr.js +18 -0
  35. dev_tools/dist/tools/github.get_pr_diff.js +24 -0
  36. dev_tools/dist/tools/github.issue_comment.js +24 -0
  37. dev_tools/dist/tools/github.issue_update.js +29 -0
  38. dev_tools/dist/tools/github.issue_view.js +28 -0
  39. dev_tools/dist/tools/github.open_replacement_pr.js +41 -0
  40. dev_tools/dist/tools/github.search_open_prs.js +31 -0
  41. dev_tools/dist/tools/github.search_open_prs.test.js +51 -0
  42. dev_tools/dist/tools/jules/cancel-session.js +20 -0
  43. dev_tools/dist/tools/jules/create-session.js +20 -0
  44. dev_tools/dist/tools/jules/create-session.test.js +99 -0
  45. dev_tools/dist/tools/jules/get-messages.js +19 -0
  46. dev_tools/dist/tools/jules/get-messages.test.js +45 -0
  47. dev_tools/dist/tools/jules/get-pr.js +14 -0
  48. dev_tools/dist/tools/jules/get-pr.test.js +37 -0
  49. dev_tools/dist/tools/jules/get-session.js +10 -0
  50. dev_tools/dist/tools/jules/list-sessions.js +43 -0
  51. dev_tools/dist/tools/jules/send-message.js +22 -0
  52. dev_tools/dist/tools/jules/send-message.test.js +56 -0
  53. dev_tools/dist/tools/jules/shared.js +26 -0
  54. dev_tools/dist/tools/jules/trigger-feedback.js +16 -0
  55. dev_tools/dist/tools/jules/trigger-feedback.test.js +42 -0
  56. dev_tools/dist/tools/repo.commit_patch.js +33 -0
  57. dev_tools/dist/tools/repo.create_branch.js +21 -0
  58. dev_tools/dist/tools/repo.create_branch.test.js +42 -0
  59. dev_tools/dist/tools/repo.create_repair_branch.js +38 -0
  60. dev_tools/dist/tools/repo.get_changed_files.js +21 -0
  61. dev_tools/dist/tools/repo.get_command_schema.js +27 -0
  62. dev_tools/dist/tools/repo.get_package_scripts.js +35 -0
  63. dev_tools/dist/tools/repo.get_package_scripts.test.js +39 -0
  64. dev_tools/dist/tools/repo.get_route_map.js +37 -0
  65. dev_tools/dist/tools/repo.logs.js +16 -0
  66. dev_tools/dist/tools/repo.read_agent_context.js +20 -0
  67. dev_tools/dist/tools/repo.read_ci_logs.js +18 -0
  68. dev_tools/dist/tools/repo.run_lighthouse.js +37 -0
  69. dev_tools/dist/tools/repo.run_playwright.js +29 -0
  70. dev_tools/dist/tools/repo.run_tests.js +43 -0
  71. dev_tools/dist/tools/types.js +1 -0
  72. dev_tools/get_ai_context.py +63 -0
  73. dev_tools/handlers/__init__.py +1 -0
  74. dev_tools/handlers/command_handler.py +69 -0
  75. dev_tools/mcp_server.py +36 -0
  76. dev_tools/models.py +354 -0
  77. dev_tools/orchestrator.py +2841 -0
  78. dev_tools/pr_overlap.py +139 -0
  79. dev_tools/resources/__init__.py +1 -0
  80. dev_tools/resources/build-repo-context.py +182 -0
  81. dev_tools/resources/prompt_constants.json +5 -0
  82. dev_tools/resources/review_template.md +41 -0
  83. dev_tools/resources/ux-audit.config.json +15 -0
  84. dev_tools/resources/visual_guidelines.json +3 -0
  85. dev_tools/review_read_pass.py +232 -0
  86. dev_tools/schema_gen.py +55 -0
  87. dev_tools/schema_utils.py +125 -0
  88. dev_tools/scope_check.py +85 -0
  89. dev_tools/services/__init__.py +1 -0
  90. dev_tools/services/ai_service.py +816 -0
  91. dev_tools/services/dependency_graph.py +123 -0
  92. dev_tools/services/github.py +783 -0
  93. dev_tools/services/jules.py +181 -0
  94. dev_tools/services/repair_service.py +199 -0
  95. dev_tools/services/vector_store.py +82 -0
  96. dev_tools/services/vision_service.py +91 -0
  97. dev_tools/td_cli.py +28 -0
  98. dev_tools/utils/__init__.py +1035 -0
  99. dev_tools/utils/git.py +68 -0
  100. dev_tools/ux_report.py +213 -0
  101. dev_tools/verify_infra.py +91 -0
  102. dev_tools/verify_versions.py +191 -0
  103. dev_tools/version_utils.py +175 -0
@@ -0,0 +1,783 @@
1
+ # pylint: disable=f-string-without-interpolation,import-outside-toplevel,invalid-name,line-too-long,missing-docstring,redefined-outer-name,reimported,subprocess-run-check,too-many-arguments,too-many-branches,too-many-locals,too-many-positional-arguments,too-many-public-methods,too-many-statements,try-except-raise
2
+ import json
3
+ import os
4
+ import re
5
+ import subprocess
6
+ from typing import Any, Dict, List, Optional
7
+ from urllib.parse import quote
8
+
9
+ import requests # type: ignore[import-untyped]
10
+ from dev_tools.constants import REVIEW_PLACEHOLDERS
11
+ from dev_tools.utils import CLIError, DiskCache, log_warn
12
+ from dev_tools.utils.git import GitUtility
13
+
14
+
15
+ class GitHubClient:
16
+ # --- Constants ---
17
+ ERROR_AUTO_PUSH_FAILED = "PR creation for '{head}' will likely fail because auto-push was unsuccessful."
18
+ BRANCH_NAME_PATTERN = re.compile(r"^[a-zA-Z0-9._/-]+$")
19
+
20
+ def __init__(self, token: Optional[str] = None, repo: Optional[str] = None, no_cache: bool = False):
21
+ from dev_tools.utils import get_github_token
22
+
23
+ self.token = token or get_github_token()
24
+ if not self.token:
25
+ raise ValueError("Missing GITHUB_TOKEN environment variable.")
26
+ self.repo = repo or os.environ.get("GITHUB_REPOSITORY") or os.environ.get("GH_REPO")
27
+ if not self.repo:
28
+ self.repo = self._detect_repo()
29
+ self.base_url = "https://api.github.com"
30
+ self._session = requests.Session()
31
+ self._session.headers.update(
32
+ {
33
+ "Authorization": f"Bearer {self.token}",
34
+ "Accept": "application/vnd.github.v3+json",
35
+ }
36
+ )
37
+ self._branch_cache : Dict[str, bool] = {}
38
+ self._cache = DiskCache(subdir="github", no_cache=no_cache)
39
+
40
+ def branch_exists(self, branch_name: str) -> bool:
41
+ """Checks if a branch exists in the repository, with caching."""
42
+ if branch_name in self._branch_cache:
43
+ return self._branch_cache[branch_name]
44
+
45
+ try:
46
+ self._request("GET", f"/repos/{self.repo}/branches/{branch_name}")
47
+ self._branch_cache[branch_name] = True
48
+ return True
49
+ except (requests.exceptions.RequestException, CLIError) as e:
50
+ # Fallback to status_code if 'code' attribute is missing (e.g. RequestException)
51
+ code = getattr(e, "code", None)
52
+ if code is None and hasattr(e, "response") and e.response is not None:
53
+ code = e.response.status_code
54
+
55
+ if code == 404:
56
+ self._branch_cache[branch_name] = False
57
+ return False
58
+ raise e
59
+
60
+ def invalidate_branch_cache(self, branch_name: str) -> None:
61
+ """Invalidates the branch existence cache for a specific branch."""
62
+ if branch_name in self._branch_cache:
63
+ del self._branch_cache[branch_name]
64
+
65
+ def _detect_repo(self) -> str:
66
+ try:
67
+ proc = subprocess.run(["git", "config", "--get", "remote.origin.url"], capture_output=True, text=True, check=False)
68
+ url = proc.stdout.strip()
69
+
70
+ match = re.search(r"[:/]([^/]+/[^/.]+)(\.git)?$", url)
71
+ return match.group(1) if match else url
72
+ except Exception:
73
+ return ""
74
+
75
+ def _request(
76
+ self,
77
+ method: str,
78
+ path: str,
79
+ json_data: Optional[Dict] = None,
80
+ params: Optional[Dict] = None,
81
+ is_text: bool = False,
82
+ accept: Optional[str] = None,
83
+ allow_redirects: bool = True,
84
+ ttl: int = 300,
85
+ timeout: int = 60,
86
+ ) -> Any:
87
+ url = f"{self.base_url}{path}"
88
+
89
+ # Cache key based on request details
90
+ cache_key = f"{method}:{path}:{json.dumps(params, sort_keys=True) if params else ''}:{accept}:{is_text}"
91
+
92
+ if method == "GET":
93
+ cached_val = self._cache.get(cache_key)
94
+ if cached_val is not None:
95
+ return cached_val
96
+
97
+ headers = {}
98
+ if accept:
99
+ headers["Accept"] = accept
100
+ elif is_text:
101
+ headers["Accept"] = "application/vnd.github.v3.diff"
102
+
103
+ try:
104
+ response = self._session.request(
105
+ method,
106
+ url,
107
+ headers=headers,
108
+ json=json_data,
109
+ params=params,
110
+ timeout=timeout,
111
+ allow_redirects=allow_redirects,
112
+ )
113
+ response.raise_for_status()
114
+
115
+ result = response.text if is_text else response.json()
116
+
117
+ if method == "GET":
118
+ self._cache.set(cache_key, result, ttl=ttl)
119
+
120
+ return result
121
+ except requests.exceptions.HTTPError as e:
122
+ self._parse_github_error(e)
123
+ raise
124
+ except requests.exceptions.RequestException as e:
125
+ # Provide context and a status code for consistency
126
+ status_code = 500
127
+ if e.response is not None:
128
+ status_code = e.response.status_code
129
+
130
+ raise CLIError(
131
+ f"GitHub Request failed: {method} {path} - {str(e)}",
132
+ code=status_code,
133
+ data={"method": method, "path": path}
134
+ ) from e
135
+
136
+ def _parse_github_error(self, e: requests.exceptions.HTTPError) -> None:
137
+ """Helper to extract detailed error message from GitHub API response."""
138
+ try:
139
+ if e.response is None:
140
+ return
141
+ error_data = e.response.json()
142
+ if not isinstance(error_data, dict):
143
+ return
144
+ github_message = error_data.get("message", "")
145
+
146
+ # Sanitize detailed errors to prevent information disclosure
147
+ if "errors" in error_data and isinstance(error_data["errors"], list):
148
+ sanitized_errors = []
149
+ for err in error_data["errors"]:
150
+ if isinstance(err, dict):
151
+ # Only include safe fields: 'message', 'field', 'resource', 'code'
152
+ # Cast to str and truncate to avoid leaking nested structures or massive data
153
+ safe_err = {
154
+ k: str(err[k])[:200]
155
+ for k in ["message", "field", "resource", "code"]
156
+ if k in err and err[k] is not None
157
+ }
158
+ sanitized_errors.append(safe_err)
159
+
160
+ if sanitized_errors:
161
+ github_message += f": {json.dumps(sanitized_errors)}"
162
+
163
+ if github_message:
164
+ raise CLIError(
165
+ f"GitHub API Error: {github_message}",
166
+ code=e.response.status_code,
167
+ data=error_data,
168
+ ) from e
169
+ except (ValueError, AttributeError):
170
+ pass
171
+
172
+ def fetch_pr_files(self, number: int) -> List[Dict[str, Any]]:
173
+ """Fetches the list of files changed in a PR."""
174
+ # PR files usually don't change once a commit is pushed, but we'll use a shorter TTL
175
+ return self._request("GET", f"/repos/{self.repo}/pulls/{number}/files", ttl=600)
176
+
177
+ def fetch_pr_details(self, number: int) -> Dict[str, Any]:
178
+ return self._request("GET", f"/repos/{self.repo}/pulls/{number}")
179
+
180
+ def fetch_pr_diff(self, number: int) -> str:
181
+ return self._request("GET", f"/repos/{self.repo}/pulls/{number}", is_text=True)
182
+
183
+ def fetch_prs_for_commit(self, commit_sha: str) -> List[Dict[str, Any]]:
184
+ return self._request("GET", f"/repos/{self.repo}/commits/{commit_sha}/pulls")
185
+
186
+ def fetch_check_runs(self, ref: str) -> List[Dict[str, Any]]:
187
+ data = self._request("GET", f"/repos/{self.repo}/commits/{ref}/check-runs")
188
+ return [
189
+ {
190
+ "id": run.get("id"),
191
+ "name": run.get("name"),
192
+ "status": run.get("status"),
193
+ "conclusion": run.get("conclusion"),
194
+ "url": run.get("html_url"),
195
+ "external_id": run.get("external_id"),
196
+ }
197
+ for run in data.get("check_runs", [])
198
+ ]
199
+
200
+ def fetch_check_suites(self, ref: str) -> List[Dict[str, Any]]:
201
+ data = self._request("GET", f"/repos/{self.repo}/commits/{ref}/check-suites")
202
+ return data.get("check_suites", [])
203
+
204
+ def fetch_check_runs_for_suite(self, suite_id: int) -> List[Dict[str, Any]]:
205
+ data = self._request("GET", f"/repos/{self.repo}/check-suites/{suite_id}/check-runs")
206
+ return data.get("check_runs", [])
207
+
208
+ def search_pull_requests(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
209
+ """General search for pull requests using the Search API."""
210
+ full_query = f"repo:{self.repo} is:pr {query}"
211
+ data = self._request("GET", "/search/issues", params={"q": full_query, "per_page": limit})
212
+ items = data.get("items", []) if isinstance(data, dict) else []
213
+
214
+ return [
215
+ {
216
+ "number": pr.get("number"),
217
+ "title": pr.get("title"),
218
+ "body": pr.get("body"),
219
+ "author": {"login": pr.get("user", {}).get("login")},
220
+ "isDraft": pr.get("draft"),
221
+ "updatedAt": pr.get("updated_at"),
222
+ "url": pr.get("html_url"),
223
+ }
224
+ for pr in items[:limit]
225
+ ]
226
+
227
+ def list_pull_requests(
228
+ self, state: str = "open", limit: int = 100, labels: Optional[List[str]] = None
229
+ ) -> List[Dict[str, Any]]:
230
+ """Lists pull requests with optional server-side label filtering or standard Pulls API."""
231
+ if labels:
232
+ # Use Search API for efficient server-side label filtering
233
+ query = f"state:{state}"
234
+ for label in labels:
235
+ query += f' label:"{label}"'
236
+ return self.search_pull_requests(query, limit=limit)
237
+
238
+ # Fallback to standard Pulls API if no labels, using internal pagination
239
+ prs: List[Dict[str, Any]] = []
240
+ page = 1
241
+ per_page = min(limit, 100)
242
+
243
+ while len(prs) < limit:
244
+ params = {"state": state, "per_page": per_page, "page": page}
245
+ data = self._request("GET", f"/repos/{self.repo}/pulls", params=params)
246
+
247
+ if not data:
248
+ break
249
+
250
+ for pr in data:
251
+ if labels:
252
+ # Depending on API endpoint (pulls vs search), labels might be strings or dicts
253
+ raw_labels = pr.get("labels", [])
254
+ pr_labels = [
255
+ l.get("name") if isinstance(l, dict) else (l if isinstance(l, str) else "") for l in raw_labels
256
+ ]
257
+ if not all(label in pr_labels for label in labels):
258
+ continue
259
+
260
+ # Map REST API response to GH CLI compatible format
261
+ prs.append(
262
+ {
263
+ "number": pr.get("number"),
264
+ "title": pr.get("title"),
265
+ "author": {"login": pr.get("user", {}).get("login")},
266
+ "headRefName": pr.get("head", {}).get("ref"),
267
+ "baseRefName": pr.get("base", {}).get("ref"),
268
+ "isDraft": pr.get("draft"),
269
+ "mergeStateStatus": pr.get("mergeable_state"),
270
+ "updatedAt": pr.get("updated_at"),
271
+ "url": pr.get("html_url"),
272
+ }
273
+ )
274
+ if len(prs) >= limit:
275
+ break
276
+
277
+ if len(data) < per_page:
278
+ break
279
+ page += 1
280
+
281
+ return prs
282
+
283
+ def create_pull_request(self, title: str, body: str, head: str, base: str, draft: bool = False) -> Dict[str, Any]:
284
+ """Creates a PR, automatically pushing the head branch if it doesn't exist on remote."""
285
+ # Security: Validate branch name to prevent injection
286
+ if not self.BRANCH_NAME_PATTERN.match(head):
287
+ raise CLIError(f"Invalid branch name: {head}", code=400)
288
+
289
+ if not self.branch_exists(head):
290
+ log_warn(f"Branch '{head}' not found on remote. Checking for local branch and pushing...")
291
+ git_util = GitUtility(token=self.token, repo=self.repo)
292
+
293
+ # Style: Explicitly check for local branch existence before pushing
294
+ if not git_util.branch_exists_locally(head):
295
+ log_warn(f"Local branch '{head}' also not found. PR creation will likely fail.")
296
+ elif git_util.push_branch(head):
297
+ # Invalidate branch cache
298
+ self.invalidate_branch_cache(head)
299
+ else:
300
+ log_warn(self.ERROR_AUTO_PUSH_FAILED.format(head=head))
301
+
302
+ data = {"title": title, "body": body, "head": head, "base": base, "draft": draft}
303
+ return self._request("POST", f"/repos/{self.repo}/pulls", json_data=data)
304
+
305
+ def _handle_missing_logs(self, identifier: Any, is_fallback: bool = False) -> str:
306
+ """Helper to log warning and return a warning string for missing logs."""
307
+ label = "check run" if is_fallback else "job"
308
+ log_warn(f"Logs for {label} {identifier} {'also ' if is_fallback else ''}not found (404).")
309
+ return f"WARNING: Logs not available for {identifier} (GitHub returned 404). They may have expired or the job is still pending."
310
+
311
+ def fetch_check_run_logs(self, check_run_id: int, external_id: Optional[str] = None) -> str:
312
+ """Fetches logs for a specific check run, using external_id (job_id) if available. Gracefully fallback to check_run_id if external_id 404s."""
313
+ # Ensure we have a valid ID and it's a string for the URL path
314
+ job_id = str(external_id) if external_id is not None else str(check_run_id)
315
+
316
+ # GitHub API returns a 302 redirect to a URL that expires after a few minutes
317
+ # Using 'application/vnd.github.v3.raw' ensures we get the plain text logs
318
+ # Large logs can take time to fetch
319
+ try:
320
+ return self._request(
321
+ "GET",
322
+ f"/repos/{self.repo}/actions/jobs/{job_id}/logs",
323
+ is_text=True,
324
+ accept="application/vnd.github.v3.raw",
325
+ allow_redirects=True,
326
+ timeout=300,
327
+ )
328
+ except (requests.exceptions.HTTPError, CLIError) as e:
329
+ code = getattr(e, "code", None)
330
+ if code is None and isinstance(e, requests.exceptions.HTTPError) and e.response is not None:
331
+ code = e.response.status_code
332
+
333
+ # Fallback only if the error was a 404 (permanent not found) or a potentially transient network error
334
+ # If it's a 500 or other permanent error, don't waste time retrying with the other ID
335
+ is_transient = code in [408, 429, 502, 503, 504]
336
+ if code == 404 or is_transient:
337
+ if external_id is not None:
338
+ # Fallback to query by raw check_run_id
339
+ log_warn(f"Logs for job {external_id} not found ({code}). Falling back to check run {check_run_id}...")
340
+ try:
341
+ return self._request(
342
+ "GET",
343
+ f"/repos/{self.repo}/actions/jobs/{check_run_id}/logs",
344
+ is_text=True,
345
+ accept="application/vnd.github.v3.raw",
346
+ timeout=300,
347
+ )
348
+ except (requests.exceptions.HTTPError, CLIError) as fallback_e:
349
+ fallback_code = getattr(fallback_e, "code", None)
350
+ if (
351
+ fallback_code is None
352
+ and isinstance(fallback_e, requests.exceptions.HTTPError)
353
+ and fallback_e.response is not None
354
+ ):
355
+ fallback_code = fallback_e.response.status_code
356
+
357
+ if fallback_code == 404:
358
+ return self._handle_missing_logs(check_run_id, is_fallback=True)
359
+ raise fallback_e
360
+ elif code == 404:
361
+ return self._handle_missing_logs(job_id)
362
+ raise
363
+
364
+ def create_issue_comment(self, number: int, body: str) -> Dict[str, Any]:
365
+ return self._request("POST", f"/repos/{self.repo}/issues/{number}/comments", json_data={"body": body})
366
+
367
+ def create_issue(self, title: str, body: str) -> Dict[str, Any]:
368
+ """Creates a new GitHub issue."""
369
+ return self._request("POST", f"/repos/{self.repo}/issues", json_data={"title": title, "body": body})
370
+
371
+ @staticmethod
372
+ def normalize_issue(issue: Dict[str, Any]) -> Dict[str, Any]:
373
+ """Normalizes issue dict to standard format."""
374
+ return {
375
+ "number": issue.get("number"),
376
+ "title": issue.get("title"),
377
+ "body": issue.get("body"),
378
+ "state": issue.get("state"),
379
+ "html_url": issue.get("html_url"),
380
+ "labels": [l.get("name") if isinstance(l, dict) else l for l in issue.get("labels", [])],
381
+ "updated_at": issue.get("updated_at"),
382
+ }
383
+
384
+ def fetch_issue_details(self, number: int) -> Dict[str, Any]:
385
+ """Fetches the details of a GitHub issue."""
386
+ return self._request("GET", f"/repos/{self.repo}/issues/{number}")
387
+
388
+ def list_issues(
389
+ self, state: str = "open", limit: int = 100, labels: Optional[List[str]] = None
390
+ ) -> List[Dict[str, Any]]:
391
+ """Lists issues (excluding pull requests) with optional filters."""
392
+ query = f"repo:{self.repo} is:issue state:{state}"
393
+ if labels:
394
+ for label in labels:
395
+ query += f' label:"{label}"'
396
+
397
+ data = self._request("GET", "/search/issues", params={"q": query, "per_page": limit})
398
+ items = data.get("items", []) if isinstance(data, dict) else []
399
+
400
+ return [self.normalize_issue(issue) for issue in items[:limit]]
401
+
402
+ def fetch_issue_comments(self, number: int) -> List[Dict[str, Any]]:
403
+ """Fetches the comments on an issue or pull request."""
404
+ return self._request("GET", f"/repos/{self.repo}/issues/{number}/comments")
405
+
406
+ def fetch_review_comments(self, number: int) -> List[Dict[str, Any]]:
407
+ """Fetches the review comments on a pull request."""
408
+ return self._request("GET", f"/repos/{self.repo}/pulls/{number}/comments")
409
+
410
+ def _get_diff_mapping(self, pr_number: int) -> Dict[str, Dict[int, int]]:
411
+ """
412
+ Parses the PR diff and returns a mapping of {filename: {new_line_number: diff_position}}.
413
+ Diff position is 0-indexed starting from the first '@@' hunk header in the file.
414
+ """
415
+ diff_text = self.fetch_pr_diff(pr_number)
416
+ mapping: Dict[str, Dict[int, int]] = {}
417
+ current_file = None
418
+ file_diff_pos = 0
419
+ new_line_num = 0
420
+ first_hunk_seen = False
421
+
422
+ for line in diff_text.splitlines():
423
+ if line.startswith("diff --git"):
424
+ current_file = None
425
+ first_hunk_seen = False
426
+ continue
427
+ if line.startswith("--- "):
428
+ continue
429
+ if line.startswith("+++ b/"):
430
+ current_file = line[6:].strip()
431
+ mapping[current_file] = {}
432
+ file_diff_pos = 0
433
+ continue
434
+ if line.startswith("@@ "):
435
+ if current_file is not None:
436
+ if not first_hunk_seen:
437
+ # First hunk of the file: hunk header is position 0
438
+ file_diff_pos = 0
439
+ first_hunk_seen = True
440
+ else:
441
+ # Subsequent hunk headers count as a line in the diff
442
+ file_diff_pos += 1
443
+
444
+ match = re.search(r"\+(\d+)", line)
445
+ if match:
446
+ new_line_num = int(match.group(1))
447
+ continue
448
+
449
+ if current_file is not None:
450
+ file_diff_pos += 1
451
+ if line.startswith("+"):
452
+ mapping[current_file][new_line_num] = file_diff_pos
453
+ new_line_num += 1
454
+ elif line.startswith("-"):
455
+ # Deletions increment position but don't have a line number in the new file
456
+ pass
457
+ elif line.startswith("\\"):
458
+ # "" increments position
459
+ file_diff_pos += 1
460
+ else:
461
+ # Context line
462
+ mapping[current_file][new_line_num] = file_diff_pos
463
+ new_line_num += 1
464
+ return mapping
465
+
466
+ def update_issue(
467
+ self,
468
+ number: int,
469
+ body: Optional[str] = None,
470
+ labels: Optional[List[str]] = None,
471
+ state: Optional[str] = None,
472
+ ) -> Dict[str, Any]:
473
+ """Updates a GitHub issue's body, labels, and/or state."""
474
+ data: Dict[str, Any] = {}
475
+ if body is not None:
476
+ data["body"] = body
477
+ if labels is not None:
478
+ data["labels"] = labels
479
+ if state is not None:
480
+ data["state"] = state
481
+ return self._request("PATCH", f"/repos/{self.repo}/issues/{number}", json_data=data)
482
+
483
+ def create_review(self, number: int, body: str, comments: List[Dict[str, Any]], event: str) -> Dict[str, Any]:
484
+ data = {"body": body, "event": event, "comments": comments}
485
+ return self._request("POST", f"/repos/{self.repo}/pulls/{number}/reviews", json_data=data)
486
+
487
+ def add_labels(self, number: int, labels: List[str]) -> List[Dict[str, Any]]:
488
+ """Adds labels to an issue or pull request."""
489
+ return self._request("POST", f"/repos/{self.repo}/issues/{number}/labels", json_data={"labels": labels})
490
+
491
+ def remove_label(self, number: int, label_name: str) -> None:
492
+ """Removes a label from an issue or pull request."""
493
+ encoded_label = quote(label_name)
494
+ return self._request("DELETE", f"/repos/{self.repo}/issues/{number}/labels/{encoded_label}")
495
+
496
+ @staticmethod
497
+ def validate_review_payload(payload: Dict[str, Any]):
498
+ """
499
+ Validates that the review payload is not just boilerplate or empty.
500
+ """
501
+ import re
502
+
503
+ from dev_tools.utils import CLIError
504
+
505
+ if not isinstance(payload, dict):
506
+ raise CLIError("Review rejected: Invalid payload format (expected dict).")
507
+
508
+ body = payload.get("body", "")
509
+ if not isinstance(body, str):
510
+ body = str(body)
511
+
512
+ recommendation = payload.get("recommendation", "")
513
+ if not isinstance(recommendation, str):
514
+ recommendation = str(recommendation)
515
+
516
+ comments = payload.get("comments", [])
517
+ if not isinstance(comments, list):
518
+ raise CLIError("Review rejected: 'comments' must be a list.")
519
+
520
+ # Check recommendation for placeholders (should be explicit)
521
+ for p in REVIEW_PLACEHOLDERS:
522
+ if re.search(p, recommendation, re.IGNORECASE):
523
+ raise CLIError(f"Review rejected: Recommendation contains boilerplate placeholder matching '{p}'")
524
+
525
+ # Check for real comments (not placeholders)
526
+ real_comments = []
527
+ for c in comments:
528
+ if not isinstance(c, dict):
529
+ continue
530
+
531
+ c_body = str(c.get("body", ""))
532
+ c_path = str(c.get("path", ""))
533
+
534
+ # Check if comment fields contain placeholders
535
+ is_placeholder = False
536
+ for p in REVIEW_PLACEHOLDERS:
537
+ if re.search(p, c_body, re.IGNORECASE) or re.search(p, c_path, re.IGNORECASE):
538
+ is_placeholder = True
539
+ break
540
+
541
+ if is_placeholder:
542
+ raise CLIError("Review rejected: Comment contains boilerplate placeholder.")
543
+
544
+ if c_body.strip() and not re.search(r"<filename\s*/?>", c_path, re.IGNORECASE):
545
+ real_comments.append(c)
546
+
547
+ # Check for empty/meaningless body
548
+ clean_body = body
549
+ clean_body = re.sub(r"^#+.*$", "", clean_body, flags=re.MULTILINE)
550
+ # Strip all placeholders for meaningful content check
551
+ for p in REVIEW_PLACEHOLDERS:
552
+ clean_body = re.sub(p, "", clean_body, flags=re.IGNORECASE | re.DOTALL)
553
+
554
+ clean_body = clean_body.strip()
555
+
556
+ if not clean_body and not real_comments:
557
+ raise CLIError("Review rejected: No meaningful content found in body or comments.")
558
+
559
+ def submit_pr_review(
560
+ self,
561
+ pr_number: int,
562
+ filepath: str,
563
+ cleanup: bool = False,
564
+ dry_run: bool = True,
565
+ event_override: Optional[str] = None,
566
+ is_json: bool = False,
567
+ ):
568
+ """
569
+ Submits a PR review from a markdown file containing a JSON payload.
570
+ The file should have standard Markdown at the top and a JSON block at the bottom for metadata.
571
+ """
572
+ import json
573
+ import re
574
+
575
+ from dev_tools.utils import CLIError, log_info, log_warn
576
+
577
+ if not os.path.exists(filepath):
578
+ raise CLIError(f"Review file missing: {filepath}")
579
+
580
+ with open(filepath, "r", encoding="utf-8") as f:
581
+ content = f.read()
582
+
583
+ # Find all JSON blocks and identify the metadata block (must contain 'recommendation', 'comments', or 'labels')
584
+ json_blocks = list(re.finditer(r"```json\n(.*?)\n```", content, re.DOTALL))
585
+ if not json_blocks:
586
+ raise CLIError("Could not find any JSON block in review document")
587
+
588
+ # Known keys used to distinguish the metadata block from other JSON blocks (like code samples)
589
+ METADATA_IDENTIFIER_KEYS = {"recommendation", "comments", "labels"}
590
+
591
+ payload = None
592
+ metadata_match = None
593
+ for match in reversed(json_blocks):
594
+ try:
595
+ candidate = json.loads(match.group(1))
596
+ if isinstance(candidate, dict) and any(k in candidate for k in METADATA_IDENTIFIER_KEYS):
597
+ payload = candidate
598
+ metadata_match = match
599
+ break
600
+ except json.JSONDecodeError:
601
+ continue
602
+
603
+ if not payload:
604
+ raise CLIError(
605
+ f"Could not find a valid JSON metadata block (expected keys: {', '.join(METADATA_IDENTIFIER_KEYS)})"
606
+ )
607
+
608
+ # Extract Markdown body (everything above the metadata JSON block)
609
+ if not metadata_match:
610
+ raise CLIError("Could not determine the start of the JSON block")
611
+ body = content[: metadata_match.start()].strip()
612
+ # Clean up the trailing "Output JSON" instructions if present
613
+ body = re.split(r"##\s+Output JSON", body, flags=re.IGNORECASE)[0].strip()
614
+
615
+ # Robustly strip placeholders from the markdown body as well
616
+ for p in REVIEW_PLACEHOLDERS:
617
+ body = re.sub(p, "", body, flags=re.IGNORECASE | re.DOTALL).strip()
618
+
619
+ if not body:
620
+ raise CLIError("Review body (Markdown section) is empty. Provide findings before the JSON block.")
621
+
622
+ # Combine extracted body (Markdown section) and payload body (JSON section)
623
+ json_body = payload.get("body", "").strip()
624
+
625
+ if json_body:
626
+ # Check if JSON body contains only placeholders
627
+ stripped_body = json_body
628
+ for p in REVIEW_PLACEHOLDERS:
629
+ stripped_body = re.sub(p, "", stripped_body, flags=re.IGNORECASE | re.DOTALL).strip()
630
+
631
+ if not stripped_body:
632
+ raise CLIError(
633
+ "Review rejected: JSON body contains only placeholders/boilerplate. "
634
+ "Ensure you provide actual feedback in the 'body' field of the JSON block."
635
+ )
636
+
637
+ payload["body"] = f"{body}\n\n{stripped_body}"
638
+ else:
639
+ payload["body"] = body
640
+
641
+ # Validate payload before proceeding
642
+ self.validate_review_payload(payload)
643
+
644
+ # Map comment lines to diff positions
645
+ try:
646
+ diff_mapping = self._get_diff_mapping(pr_number)
647
+ mapped_comments = []
648
+ unmapped_comments = []
649
+
650
+ for comment in payload.get("comments", []):
651
+ path = comment.get("path")
652
+ line = comment.get("line")
653
+
654
+ if path in diff_mapping and line in diff_mapping[path]:
655
+ comment["position"] = diff_mapping[path][line]
656
+ mapped_comments.append(comment)
657
+ else:
658
+ unmapped_comments.append(comment)
659
+
660
+ payload["comments"] = mapped_comments
661
+
662
+ if unmapped_comments:
663
+ extra_body = "\n\n### Additional Feedback (Lines not found in diff)\n"
664
+ for c in unmapped_comments:
665
+ extra_body += f"- **{c.get('path')}:{c.get('line')}**: {c.get('body')}\n"
666
+ payload["body"] = payload.get("body", "") + extra_body
667
+
668
+ except Exception as e:
669
+ log_warn(f"Failed to generate diff mapping for PR #{pr_number}: {e}")
670
+
671
+ pr_details = self.fetch_pr_details(pr_number)
672
+ check_runs = self.fetch_check_runs(pr_details.get("head", {}).get("sha"))
673
+ failing_checks = [str(run.get("name")) for run in check_runs if run.get("conclusion") == "failure"]
674
+
675
+ # Determine event based on recommendation field, then fallback to body analysis
676
+ recommendation = payload.get("recommendation", "")
677
+ if event_override:
678
+ event = event_override
679
+ elif recommendation == "Approved":
680
+ event = "APPROVE"
681
+ elif recommendation == "Approved with Minor Changes":
682
+ # Per code review, minor changes shouldn't automatically approve
683
+ event = "COMMENT"
684
+ elif recommendation == "Not Approved":
685
+ event = "REQUEST_CHANGES"
686
+ else:
687
+ event = (
688
+ "REQUEST_CHANGES"
689
+ if "Not Approved" in payload.get("body", "")
690
+ else "APPROVE" if "Approved" in payload.get("body", "") else "COMMENT"
691
+ )
692
+
693
+ if failing_checks and event == "APPROVE":
694
+ event = "COMMENT"
695
+ warning = f"> ⚠️ **BLOCKING CI FAILURE**: Approval overridden to COMMENT because the following checks are failing: {', '.join(failing_checks)}. Please resolve CI issues before approval.\n\n"
696
+ payload["body"] = warning + payload.get("body", "")
697
+
698
+ if not dry_run:
699
+
700
+ def try_create_review(review_body, review_comments, review_event):
701
+ try:
702
+ return self.create_review(pr_number, review_body, review_comments, review_event)
703
+ except (requests.exceptions.HTTPError, CLIError) as e:
704
+ code = getattr(e, "code", None)
705
+ if code is None and isinstance(e, requests.exceptions.HTTPError) and e.response is not None:
706
+ code = e.response.status_code
707
+
708
+ if code == 422:
709
+ error_data = getattr(e, "data", None)
710
+ if error_data is None and isinstance(e, requests.exceptions.HTTPError) and e.response is not None:
711
+ try:
712
+ error_data = e.response.json()
713
+ except Exception:
714
+ pass
715
+
716
+ error_msg = json.dumps(error_data) if error_data else (e.response.text if hasattr(e, "response") and e.response else str(e))
717
+
718
+ if "Can not approve your own pull request" in error_msg and review_event != "COMMENT":
719
+ log_warn("Cannot approve own PR. Retrying as COMMENT...")
720
+ return try_create_review(review_body, review_comments, "COMMENT")
721
+
722
+ # Handle individual comment failures if possible, or fallback to body
723
+ if review_comments:
724
+ log_warn(
725
+ f"Failed to post {len(review_comments)} inline comments. Retrying as body comments. Error: {error_msg[:200]}"
726
+ )
727
+ fallback_body = review_body
728
+ fallback_body += "\n\n### Inline Comments (Fallback due to line resolution errors)\n"
729
+ for comment in review_comments:
730
+ line_info = f":{comment.get('line')}" if comment.get("line") else ""
731
+ fallback_body += f"- **{comment.get('path')}{line_info}**: {comment.get('body')}\n"
732
+ return try_create_review(fallback_body, [], review_event)
733
+ raise e
734
+
735
+ try_create_review(payload.get("body", ""), payload.get("comments", []), event)
736
+
737
+ if event == "REQUEST_CHANGES":
738
+ labels = [l.get("name") if isinstance(l, dict) else l for l in pr_details.get("labels", [])]
739
+ if "needs-design-system-fix" not in labels and any(
740
+ k in payload.get("body", "").lower() for k in ["tailwind", "token"]
741
+ ):
742
+ self.add_labels(pr_number, ["needs-design-system-fix"])
743
+
744
+ if not is_json:
745
+ log_info(f"✅ Submitted {event} for PR #{pr_number}")
746
+
747
+ if cleanup:
748
+ if os.path.exists(filepath):
749
+ os.remove(filepath)
750
+ ctx = filepath.replace("pr-review-", "pr-context-")
751
+ if os.path.exists(ctx):
752
+ os.remove(ctx)
753
+ else:
754
+ if not is_json:
755
+ log_info(f"[DRY-RUN] Would submit {event} for PR #{pr_number}")
756
+
757
+ return {"status": "success", "event": event, "pr": pr_number}
758
+
759
+ def download_zipball(self, ref: str, dest: str = "repo.zip") -> None:
760
+ """A stateless download helper for the Orchestrator"""
761
+ from dev_tools.utils import sanitize_path
762
+
763
+ # Security: Validate input to prevent command/path injection
764
+ if not re.match(r"^[a-zA-Z0-9._/-]+$", ref):
765
+ raise CLIError(f"Invalid ref: {ref}", code=400)
766
+
767
+ safe_dest = sanitize_path(dest)
768
+ if not safe_dest.endswith(".zip"):
769
+ safe_dest += ".zip"
770
+
771
+ url = f"{self.base_url}/repos/{self.repo}/zipball/{ref}"
772
+ headers = {"Authorization": f"Bearer {self.token}"}
773
+ # Increased timeout for large downloads
774
+ response = requests.get(url, headers=headers, stream=True, timeout=300)
775
+ response.raise_for_status()
776
+
777
+ with open(safe_dest, "wb") as f:
778
+ for chunk in response.iter_content(chunk_size=8192):
779
+ f.write(chunk)
780
+
781
+ from dev_tools.utils import run_command
782
+ # unzip -o overwrite files without prompting
783
+ run_command(["unzip", "-o", safe_dest])