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.
- boomtick_cli-0.2.1.dist-info/METADATA +20 -0
- boomtick_cli-0.2.1.dist-info/RECORD +103 -0
- boomtick_cli-0.2.1.dist-info/WHEEL +5 -0
- boomtick_cli-0.2.1.dist-info/entry_points.txt +3 -0
- boomtick_cli-0.2.1.dist-info/top_level.txt +1 -0
- dev_tools/__init__.py +1 -0
- dev_tools/cli-schema.json +2028 -0
- dev_tools/cli.py +1542 -0
- dev_tools/config.py +235 -0
- dev_tools/constants.py +14 -0
- dev_tools/daemon.py +146 -0
- dev_tools/dist/config.js +108 -0
- dev_tools/dist/index.js +10 -0
- dev_tools/dist/lib/error_utils.js +8 -0
- dev_tools/dist/lib/git.js +31 -0
- dev_tools/dist/lib/result.js +21 -0
- dev_tools/dist/lib/shell.js +91 -0
- dev_tools/dist/lib/shell.test.js +10 -0
- dev_tools/dist/lib/td-cli.js +27 -0
- dev_tools/dist/lib/test-utils.js +20 -0
- dev_tools/dist/mcp/definitions.js +242 -0
- dev_tools/dist/mcp/server.js +317 -0
- dev_tools/dist/mcp/tools.js +18 -0
- dev_tools/dist/tools/contract.js +2069 -0
- dev_tools/dist/tools/ddgs.search.js +50 -0
- dev_tools/dist/tools/ddgs.search.test.js +67 -0
- dev_tools/dist/tools/ddgs_search.py +23 -0
- dev_tools/dist/tools/github.checkout_branch.js +20 -0
- dev_tools/dist/tools/github.comment_triage_summary.js +25 -0
- dev_tools/dist/tools/github.create_issue.js +28 -0
- dev_tools/dist/tools/github.create_issue.test.js +54 -0
- dev_tools/dist/tools/github.create_pull_request.js +44 -0
- dev_tools/dist/tools/github.get_merge_conflict_files.js +26 -0
- dev_tools/dist/tools/github.get_pr.js +18 -0
- dev_tools/dist/tools/github.get_pr_diff.js +24 -0
- dev_tools/dist/tools/github.issue_comment.js +24 -0
- dev_tools/dist/tools/github.issue_update.js +29 -0
- dev_tools/dist/tools/github.issue_view.js +28 -0
- dev_tools/dist/tools/github.open_replacement_pr.js +41 -0
- dev_tools/dist/tools/github.search_open_prs.js +31 -0
- dev_tools/dist/tools/github.search_open_prs.test.js +51 -0
- dev_tools/dist/tools/jules/cancel-session.js +20 -0
- dev_tools/dist/tools/jules/create-session.js +20 -0
- dev_tools/dist/tools/jules/create-session.test.js +99 -0
- dev_tools/dist/tools/jules/get-messages.js +19 -0
- dev_tools/dist/tools/jules/get-messages.test.js +45 -0
- dev_tools/dist/tools/jules/get-pr.js +14 -0
- dev_tools/dist/tools/jules/get-pr.test.js +37 -0
- dev_tools/dist/tools/jules/get-session.js +10 -0
- dev_tools/dist/tools/jules/list-sessions.js +43 -0
- dev_tools/dist/tools/jules/send-message.js +22 -0
- dev_tools/dist/tools/jules/send-message.test.js +56 -0
- dev_tools/dist/tools/jules/shared.js +26 -0
- dev_tools/dist/tools/jules/trigger-feedback.js +16 -0
- dev_tools/dist/tools/jules/trigger-feedback.test.js +42 -0
- dev_tools/dist/tools/repo.commit_patch.js +33 -0
- dev_tools/dist/tools/repo.create_branch.js +21 -0
- dev_tools/dist/tools/repo.create_branch.test.js +42 -0
- dev_tools/dist/tools/repo.create_repair_branch.js +38 -0
- dev_tools/dist/tools/repo.get_changed_files.js +21 -0
- dev_tools/dist/tools/repo.get_command_schema.js +27 -0
- dev_tools/dist/tools/repo.get_package_scripts.js +35 -0
- dev_tools/dist/tools/repo.get_package_scripts.test.js +39 -0
- dev_tools/dist/tools/repo.get_route_map.js +37 -0
- dev_tools/dist/tools/repo.logs.js +16 -0
- dev_tools/dist/tools/repo.read_agent_context.js +20 -0
- dev_tools/dist/tools/repo.read_ci_logs.js +18 -0
- dev_tools/dist/tools/repo.run_lighthouse.js +37 -0
- dev_tools/dist/tools/repo.run_playwright.js +29 -0
- dev_tools/dist/tools/repo.run_tests.js +43 -0
- dev_tools/dist/tools/types.js +1 -0
- dev_tools/get_ai_context.py +63 -0
- dev_tools/handlers/__init__.py +1 -0
- dev_tools/handlers/command_handler.py +69 -0
- dev_tools/mcp_server.py +36 -0
- dev_tools/models.py +354 -0
- dev_tools/orchestrator.py +2841 -0
- dev_tools/pr_overlap.py +139 -0
- dev_tools/resources/__init__.py +1 -0
- dev_tools/resources/build-repo-context.py +182 -0
- dev_tools/resources/prompt_constants.json +5 -0
- dev_tools/resources/review_template.md +41 -0
- dev_tools/resources/ux-audit.config.json +15 -0
- dev_tools/resources/visual_guidelines.json +3 -0
- dev_tools/review_read_pass.py +232 -0
- dev_tools/schema_gen.py +55 -0
- dev_tools/schema_utils.py +125 -0
- dev_tools/scope_check.py +85 -0
- dev_tools/services/__init__.py +1 -0
- dev_tools/services/ai_service.py +816 -0
- dev_tools/services/dependency_graph.py +123 -0
- dev_tools/services/github.py +783 -0
- dev_tools/services/jules.py +181 -0
- dev_tools/services/repair_service.py +199 -0
- dev_tools/services/vector_store.py +82 -0
- dev_tools/services/vision_service.py +91 -0
- dev_tools/td_cli.py +28 -0
- dev_tools/utils/__init__.py +1035 -0
- dev_tools/utils/git.py +68 -0
- dev_tools/ux_report.py +213 -0
- dev_tools/verify_infra.py +91 -0
- dev_tools/verify_versions.py +191 -0
- 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])
|