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,816 @@
|
|
|
1
|
+
# pylint: disable=global-statement,import-outside-toplevel,invalid-name,line-too-long,missing-docstring,redefined-outer-name,reimported,too-many-branches,too-many-locals,too-many-return-statements,too-many-statements,unused-argument
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from collections import defaultdict
|
|
8
|
+
from typing import Any, Dict, List, Optional, Tuple, Type
|
|
9
|
+
|
|
10
|
+
import requests
|
|
11
|
+
from dev_tools.models import AIFullReview, AISynthesisReview
|
|
12
|
+
from dev_tools.review_read_pass import parse_diff_into_file_chunks
|
|
13
|
+
from dev_tools.services.dependency_graph import DependencyGraph
|
|
14
|
+
from dev_tools.services.vector_store import VectorStore
|
|
15
|
+
from dev_tools.utils import (
|
|
16
|
+
call_ai,
|
|
17
|
+
clean_llm_output,
|
|
18
|
+
ensure_dir,
|
|
19
|
+
get_ai_model,
|
|
20
|
+
get_ai_review_model,
|
|
21
|
+
get_gemini_model,
|
|
22
|
+
get_stack_versions,
|
|
23
|
+
is_ai_available,
|
|
24
|
+
log_error,
|
|
25
|
+
log_info,
|
|
26
|
+
log_warn,
|
|
27
|
+
)
|
|
28
|
+
from dev_tools.verify_versions import parse_diff, verify_changes
|
|
29
|
+
from pydantic import BaseModel, ValidationError
|
|
30
|
+
|
|
31
|
+
# Model used for per-file chunk review (code-aware, focused)
|
|
32
|
+
_REVIEW_MODEL = get_ai_review_model()
|
|
33
|
+
|
|
34
|
+
# Model used for final summary synthesis
|
|
35
|
+
_SYNTHESIS_MODEL = get_ai_model()
|
|
36
|
+
|
|
37
|
+
# Maximum retries for AI generation and parsing
|
|
38
|
+
_MAX_AI_RETRIES = 3
|
|
39
|
+
|
|
40
|
+
# Combined review schema
|
|
41
|
+
_REVIEW_SCHEMA = AIFullReview.model_json_schema()
|
|
42
|
+
|
|
43
|
+
# Synthesis review schema
|
|
44
|
+
_SYNTHESIS_SCHEMA = AISynthesisReview.model_json_schema()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def validate_with_model(data: Any, model_class: Type[BaseModel]) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
|
|
48
|
+
"""Validates data against a Pydantic model and returns (parsed_dict, error_message)."""
|
|
49
|
+
try:
|
|
50
|
+
# Handle cases where data might be double-encoded or stringified
|
|
51
|
+
if isinstance(data, str):
|
|
52
|
+
try:
|
|
53
|
+
data = json.loads(data)
|
|
54
|
+
if isinstance(data, str):
|
|
55
|
+
data = json.loads(data)
|
|
56
|
+
except json.JSONDecodeError:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
if not isinstance(data, dict):
|
|
60
|
+
return None, f"Expected dictionary for validation, got {type(data).__name__}"
|
|
61
|
+
|
|
62
|
+
parsed = model_class.model_validate(data)
|
|
63
|
+
return parsed.model_dump(), None
|
|
64
|
+
except ValidationError as e:
|
|
65
|
+
# Extract specific error details from Pydantic
|
|
66
|
+
errs = []
|
|
67
|
+
for err in e.errors():
|
|
68
|
+
loc = " -> ".join(str(x) for x in err.get("loc", []))
|
|
69
|
+
msg = err.get("msg")
|
|
70
|
+
errs.append(f"[{loc}]: {msg}")
|
|
71
|
+
return None, "Validation failed:\n " + "\n ".join(errs)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
_REVIEW_CONSTANTS_CACHE = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _get_review_prompt_constants() -> tuple[str, str, str]:
|
|
78
|
+
global _REVIEW_CONSTANTS_CACHE
|
|
79
|
+
if _REVIEW_CONSTANTS_CACHE is not None:
|
|
80
|
+
return _REVIEW_CONSTANTS_CACHE
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
import json
|
|
84
|
+
|
|
85
|
+
from dev_tools.utils import resolve_resource_path
|
|
86
|
+
|
|
87
|
+
resource_path = resolve_resource_path("prompt_constants.json")
|
|
88
|
+
with open(resource_path, "r", encoding="utf-8") as f:
|
|
89
|
+
data = json.load(f)
|
|
90
|
+
|
|
91
|
+
json_rules = data.get("STRICT_JSON_VERIFICATION", "")
|
|
92
|
+
snippet_rules = data.get("SNIPPET_AND_VERIFICATION_RULES", "")
|
|
93
|
+
common_rules = data.get("COMMON_REVIEW_GUIDELINES", "")
|
|
94
|
+
|
|
95
|
+
_REVIEW_CONSTANTS_CACHE = (json_rules, snippet_rules, common_rules)
|
|
96
|
+
return _REVIEW_CONSTANTS_CACHE
|
|
97
|
+
except Exception as e:
|
|
98
|
+
log_warn(f"Failed to load prompt_constants.json from resources: {e}")
|
|
99
|
+
# Default fallback values to prevent empty constraints from degrading review quality
|
|
100
|
+
default_json = "Strict JSON Verification:\n- Every finding MUST have an `id`, `file`, `issue`, and `status`."
|
|
101
|
+
default_snippet = "Snippet rules:\n- STRICT SNIPPET RULE: Quote exact line from diff."
|
|
102
|
+
default_common = "Review ONLY PR changes. Assume original code worked."
|
|
103
|
+
_REVIEW_CONSTANTS_CACHE = (default_json, default_snippet, default_common)
|
|
104
|
+
return _REVIEW_CONSTANTS_CACHE
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class AIClient:
|
|
108
|
+
def __init__(self, ai_model: Optional[str] = None):
|
|
109
|
+
self.ai_model = ai_model or get_ai_model()
|
|
110
|
+
self.gemini_api_key = os.environ.get("GEMINI_API_KEY")
|
|
111
|
+
|
|
112
|
+
self._dependency_graph: Optional[DependencyGraph] = None
|
|
113
|
+
self._vector_store: Optional[VectorStore] = None
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def dependency_graph(self) -> DependencyGraph:
|
|
117
|
+
if self._dependency_graph is None:
|
|
118
|
+
self._dependency_graph = DependencyGraph()
|
|
119
|
+
return self._dependency_graph
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def vector_store(self) -> VectorStore:
|
|
123
|
+
if self._vector_store is None:
|
|
124
|
+
self._vector_store = VectorStore()
|
|
125
|
+
return self._vector_store
|
|
126
|
+
|
|
127
|
+
def is_ai_available(self) -> bool:
|
|
128
|
+
return is_ai_available()
|
|
129
|
+
|
|
130
|
+
def call_ai(
|
|
131
|
+
self,
|
|
132
|
+
prompt: str,
|
|
133
|
+
model: Optional[str] = None,
|
|
134
|
+
max_retries: int = 3,
|
|
135
|
+
schema: Optional[Dict] = None,
|
|
136
|
+
) -> Optional[str]:
|
|
137
|
+
return call_ai(prompt, model=model or self.ai_model, max_retries=max_retries, schema=schema)
|
|
138
|
+
|
|
139
|
+
def call_gemini(self, prompt: str, schema: Optional[Dict] = None) -> Optional[str]:
|
|
140
|
+
if not self.gemini_api_key:
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
model_name = get_gemini_model()
|
|
144
|
+
url = f"https://generativelanguage.googleapis.com/v1beta/models/{model_name}:generateContent"
|
|
145
|
+
headers = {"Content-Type": "application/json", "x-goog-api-key": self.gemini_api_key}
|
|
146
|
+
|
|
147
|
+
payload = {"contents": [{"parts": [{"text": prompt}]}]}
|
|
148
|
+
|
|
149
|
+
if schema:
|
|
150
|
+
payload["generationConfig"] = {
|
|
151
|
+
"responseMimeType": "application/json",
|
|
152
|
+
"responseSchema": schema,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
response = requests.post(url, headers=headers, json=payload, timeout=30)
|
|
157
|
+
response.raise_for_status()
|
|
158
|
+
res_data = response.json()
|
|
159
|
+
if "candidates" in res_data and len(res_data["candidates"]) > 0:
|
|
160
|
+
content = res_data["candidates"][0]["content"]["parts"][0]["text"]
|
|
161
|
+
return content
|
|
162
|
+
return None
|
|
163
|
+
except Exception as e:
|
|
164
|
+
log_warn(f"Gemini API call failed: {e}")
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
def generate(self, prompt: str, schema: Optional[Dict] = None, model: Optional[str] = None) -> str:
|
|
168
|
+
if self.is_ai_available():
|
|
169
|
+
res = self.call_ai(prompt, model=model, schema=schema)
|
|
170
|
+
if res:
|
|
171
|
+
return res
|
|
172
|
+
|
|
173
|
+
res = self.call_gemini(prompt, schema=schema)
|
|
174
|
+
if res:
|
|
175
|
+
return res
|
|
176
|
+
|
|
177
|
+
raise EnvironmentError("No AI service available (GitHub Models, and Gemini failed or are unavailable).")
|
|
178
|
+
|
|
179
|
+
def clean_llm_output(self, text: str) -> str:
|
|
180
|
+
return clean_llm_output(text)
|
|
181
|
+
|
|
182
|
+
def resolve_file_conflicts(self, file_path: str) -> bool:
|
|
183
|
+
if not os.path.exists(file_path):
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
try:
|
|
187
|
+
with open(file_path, "r", encoding="utf-8") as f:
|
|
188
|
+
content = f.read()
|
|
189
|
+
|
|
190
|
+
if "<<<<<<<" not in content:
|
|
191
|
+
return True
|
|
192
|
+
|
|
193
|
+
# AI resolution mock mode
|
|
194
|
+
if os.environ.get("AI_RESOLVE_MOCK", "false").lower() == "true":
|
|
195
|
+
mock_pattern = r"<<<<<<<.*?\n(.*?)\n=======.*?\n>>>>>>>.*?\n"
|
|
196
|
+
resolved = re.sub(mock_pattern, r"\1\n", content, flags=re.DOTALL)
|
|
197
|
+
with open(file_path, "w", encoding="utf-8") as f:
|
|
198
|
+
f.write(resolved)
|
|
199
|
+
return True
|
|
200
|
+
|
|
201
|
+
prompt = f"Resolve the Git merge conflicts in this code. Output ONLY the clean, merged code without markers or explanation.\n\nFILE CONTENT:\n{content}\n\nREPAIRED CONTENT:\n"
|
|
202
|
+
|
|
203
|
+
raw_response = self.generate(prompt)
|
|
204
|
+
if not raw_response:
|
|
205
|
+
return False
|
|
206
|
+
|
|
207
|
+
resolved = self.clean_llm_output(raw_response)
|
|
208
|
+
|
|
209
|
+
if "<<<<<<<" in resolved:
|
|
210
|
+
return False
|
|
211
|
+
|
|
212
|
+
try:
|
|
213
|
+
# Files that define runtime/dependency versions
|
|
214
|
+
sensitive_files = [".nvmrc", ".node-version", "package.json", ".github/workflows/"]
|
|
215
|
+
if any(sf in file_path for sf in sensitive_files):
|
|
216
|
+
# Synthesize a diff representing the new content to validate it against HEAD versions.
|
|
217
|
+
# We treat lines in the new file as additions to ensure the validator
|
|
218
|
+
# catches any version mentioned in the file that might be a downgrade from HEAD.
|
|
219
|
+
diff_lines = [f"+++ b/{file_path}"]
|
|
220
|
+
for line in resolved.splitlines():
|
|
221
|
+
line = line.strip()
|
|
222
|
+
# We only care about lines that look like version assignments/usage
|
|
223
|
+
if any(kw in line for kw in ["node", "pnpm", "uses:", "@v", "packageManager"]):
|
|
224
|
+
diff_lines.append(f"+{line}")
|
|
225
|
+
|
|
226
|
+
if len(diff_lines) > 1:
|
|
227
|
+
# Re-use the validation logic on the synthesized diff
|
|
228
|
+
findings = verify_changes(parse_diff("\n".join(diff_lines)))
|
|
229
|
+
if any(f["severity"] == "error" for f in findings):
|
|
230
|
+
log_error(
|
|
231
|
+
f"AI-generated resolution for {file_path} contains version violations: {findings}"
|
|
232
|
+
)
|
|
233
|
+
# Re-try or block to prevent regression
|
|
234
|
+
return False
|
|
235
|
+
except Exception as e:
|
|
236
|
+
log_warn(f"Failed to post-process AI resolution for {file_path}: {e}")
|
|
237
|
+
|
|
238
|
+
from dev_tools.utils import safe_write_file
|
|
239
|
+
|
|
240
|
+
if not resolved.endswith("\n"):
|
|
241
|
+
resolved += "\n"
|
|
242
|
+
safe_write_file(file_path, resolved)
|
|
243
|
+
|
|
244
|
+
return True
|
|
245
|
+
except Exception:
|
|
246
|
+
return False
|
|
247
|
+
|
|
248
|
+
# ── Single-pass review pipeline ───────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
def generate_code_review(self, pr: Dict, diff: str) -> Dict:
|
|
251
|
+
"""
|
|
252
|
+
Single-pass AI review pipeline leveraging large context windows.
|
|
253
|
+
Skips images, lock files, generated files, and build artefacts.
|
|
254
|
+
"""
|
|
255
|
+
|
|
256
|
+
pr_num = pr.get("number", "unknown")
|
|
257
|
+
pr_title = pr.get("title", "")
|
|
258
|
+
checks = pr.get("checkResults", [])
|
|
259
|
+
checks_summary = (
|
|
260
|
+
"\n".join(f"- {c.get('name')}: {c.get('status')} ({c.get('conclusion', 'Pending')})" for c in checks)
|
|
261
|
+
if checks
|
|
262
|
+
else "No checks found."
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
ci_failures = [c for c in checks if c.get("conclusion") == "failure"]
|
|
266
|
+
has_ci_failures = bool(ci_failures)
|
|
267
|
+
failing_names = ", ".join(c.get("name", "?") for c in ci_failures) if ci_failures else "none"
|
|
268
|
+
|
|
269
|
+
# ── Diagnostics header ────────────────────────────────────────────────
|
|
270
|
+
ai_ok = self.is_ai_available()
|
|
271
|
+
chunks = parse_diff_into_file_chunks(diff)
|
|
272
|
+
skipped = [c for c in chunks if c["skip"]]
|
|
273
|
+
reviewable = [c for c in chunks if not c["skip"]]
|
|
274
|
+
|
|
275
|
+
_skipped_names = sorted(set(c["file"] for c in skipped))
|
|
276
|
+
_skipped_preview = ", ".join(_skipped_names[:5]) + ("..." if len(_skipped_names) > 5 else "")
|
|
277
|
+
|
|
278
|
+
log_info(f"""
|
|
279
|
+
{'='*60}
|
|
280
|
+
🔍 PR #{pr_num} – Unified Review Diagnostics
|
|
281
|
+
{'='*60}
|
|
282
|
+
AI available : {'✅ YES' if ai_ok else '❌ NO'}
|
|
283
|
+
Review model : {_REVIEW_MODEL}
|
|
284
|
+
Diff size : {len(diff):,} chars
|
|
285
|
+
CI failures : {failing_names}
|
|
286
|
+
|
|
287
|
+
📂 Files in diff : {len(chunks)} total
|
|
288
|
+
Reviewable : {len(reviewable)} chunks across {len(set(c['file'] for c in reviewable))} files
|
|
289
|
+
Skipped : {len(skipped)} ({_skipped_preview})
|
|
290
|
+
""")
|
|
291
|
+
|
|
292
|
+
file_reviews: List[Dict] = []
|
|
293
|
+
|
|
294
|
+
if not reviewable:
|
|
295
|
+
final = {
|
|
296
|
+
"reviewComment": f"Automated review of PR #{pr_num}.\n\nNo reviewable files found in the diff.",
|
|
297
|
+
"labels": [],
|
|
298
|
+
"recommendation": "Approved" if not has_ci_failures else "Not Approved",
|
|
299
|
+
}
|
|
300
|
+
self._write_review_file(pr_num, pr, final, chunks, [])
|
|
301
|
+
return final
|
|
302
|
+
|
|
303
|
+
# Combine diffs up to a reasonable limit (e.g. 100k chars for standard models)
|
|
304
|
+
MAX_COMBINED_CHARS = 100_000
|
|
305
|
+
combined_diff = ""
|
|
306
|
+
is_truncated = False
|
|
307
|
+
for chunk in reviewable:
|
|
308
|
+
if len(combined_diff) + len(chunk["diff_text"]) > MAX_COMBINED_CHARS:
|
|
309
|
+
combined_diff += "\n\n... (Diff truncated due to size limits)"
|
|
310
|
+
is_truncated = True
|
|
311
|
+
break
|
|
312
|
+
combined_diff += f"\n\n{chunk['diff_text']}"
|
|
313
|
+
|
|
314
|
+
truncation_note = ""
|
|
315
|
+
json_rules, snippet_rules, _COMMON_REVIEW_GUIDELINES = _get_review_prompt_constants()
|
|
316
|
+
if is_truncated:
|
|
317
|
+
truncation_note = "\nNOTE: This diff is TRUNCATED. If you need more context to be certain of an issue, state what you are missing instead of speculating.\n"
|
|
318
|
+
|
|
319
|
+
prompt = (
|
|
320
|
+
f"Review PR: {pr_title}. CI: {checks_summary}\n\n"
|
|
321
|
+
f"{_COMMON_REVIEW_GUIDELINES}\n\n"
|
|
322
|
+
"ORDER: Correctness, Security (new input/auth only), Crashes, Data Integrity, Performance, Maintainability.\n"
|
|
323
|
+
"SEVERITY: error (blocking, high confidence only), warn, info. Include 'confidence' (high/medium/low).\n"
|
|
324
|
+
f"Rules:\n"
|
|
325
|
+
f"- Flag ONLY real problems: bugs, type unsafety, broken logic, design rule violations.\n"
|
|
326
|
+
f"{snippet_rules}\n\n"
|
|
327
|
+
f"{json_rules}\n\n"
|
|
328
|
+
f'- Use severity "error" for blocking issues, "warn" for improvements, "info" for nits.\n'
|
|
329
|
+
f'- For file verdicts, set to "ok" (no issues), "needs_changes" (warn/info only), or "blocking" (any error).\n'
|
|
330
|
+
f"- Provide an overall `reviewComment` summarizing the review.\n"
|
|
331
|
+
f'- Suggest 1-3 `labels` (e.g. "needs-changes", "lgtm", "ci-failing").\n'
|
|
332
|
+
f'- Set overall `recommendation` to EXACTLY ONE of: "Approved", "Approved with Minor Changes", or "Not Approved".\n'
|
|
333
|
+
"OUTPUT: Valid JSON. Counterexamples required for errors.\n"
|
|
334
|
+
"JSON MUST be inside a <findings> tag.\n\n"
|
|
335
|
+
f"{truncation_note}Diff:\n{combined_diff}"
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
t0 = time.time()
|
|
339
|
+
print(
|
|
340
|
+
f"🤖 Requesting AI review for {len(reviewable)} chunks ...",
|
|
341
|
+
end="",
|
|
342
|
+
flush=True,
|
|
343
|
+
file=sys.stderr,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
raw = None
|
|
347
|
+
file_reviews = []
|
|
348
|
+
parsed = None
|
|
349
|
+
|
|
350
|
+
# Retry loop for generation and parsing
|
|
351
|
+
for attempt in range(_MAX_AI_RETRIES):
|
|
352
|
+
try:
|
|
353
|
+
raw = call_ai(prompt, model=_REVIEW_MODEL, schema=_REVIEW_SCHEMA, max_retries=1)
|
|
354
|
+
if not raw:
|
|
355
|
+
continue
|
|
356
|
+
|
|
357
|
+
cleaned = clean_llm_output(raw)
|
|
358
|
+
|
|
359
|
+
# Robust extraction fallback for mixed/malformed model output
|
|
360
|
+
candidate = cleaned
|
|
361
|
+
first_brace = candidate.find("{")
|
|
362
|
+
first_bracket = candidate.find("[")
|
|
363
|
+
last_brace = candidate.rfind("}")
|
|
364
|
+
last_bracket = candidate.rfind("]")
|
|
365
|
+
|
|
366
|
+
start = -1
|
|
367
|
+
end = -1
|
|
368
|
+
if first_brace != -1 and (first_bracket == -1 or first_brace < first_bracket):
|
|
369
|
+
start, end = first_brace, last_brace
|
|
370
|
+
elif first_bracket != -1:
|
|
371
|
+
start, end = first_bracket, last_bracket
|
|
372
|
+
|
|
373
|
+
if start != -1 and end != -1 and end > start:
|
|
374
|
+
json_candidate = candidate[start : end + 1]
|
|
375
|
+
try:
|
|
376
|
+
temp_parsed = json.loads(json_candidate)
|
|
377
|
+
cleaned = json.dumps(temp_parsed)
|
|
378
|
+
except json.JSONDecodeError:
|
|
379
|
+
pass
|
|
380
|
+
|
|
381
|
+
parsed, err = validate_with_model(cleaned, AIFullReview)
|
|
382
|
+
if err or parsed is None:
|
|
383
|
+
raise ValueError(err or "Validation failed")
|
|
384
|
+
|
|
385
|
+
if isinstance(parsed, dict):
|
|
386
|
+
break # Success
|
|
387
|
+
except Exception as e:
|
|
388
|
+
log_warn(f"AI review attempt {attempt+1} failed: {e}")
|
|
389
|
+
time.sleep(1)
|
|
390
|
+
|
|
391
|
+
elapsed = time.time() - t0
|
|
392
|
+
|
|
393
|
+
if not parsed:
|
|
394
|
+
print(f" ❌ failed to get valid response ({elapsed:.1f}s)", flush=True, file=sys.stderr)
|
|
395
|
+
final = {
|
|
396
|
+
"reviewComment": f"Automated review of PR #{pr_num}.\n\nFailed to get a parseable response from AI after retries.",
|
|
397
|
+
"labels": ["needs-changes"],
|
|
398
|
+
"recommendation": "Not Approved",
|
|
399
|
+
}
|
|
400
|
+
else:
|
|
401
|
+
file_reviews = parsed.get("file_reviews", [])
|
|
402
|
+
final = {
|
|
403
|
+
"reviewComment": str(parsed.get("reviewComment", f"Automated review of PR #{pr_num}.")),
|
|
404
|
+
"labels": list(parsed.get("labels", [])),
|
|
405
|
+
"recommendation": str(parsed.get("recommendation", "Unknown")),
|
|
406
|
+
}
|
|
407
|
+
print(f" ✅ done ({elapsed:.1f}s)", flush=True, file=sys.stderr)
|
|
408
|
+
|
|
409
|
+
# CI guard: never approve if checks are failing
|
|
410
|
+
if has_ci_failures and final.get("recommendation") == "Approved":
|
|
411
|
+
final["recommendation"] = "Not Approved"
|
|
412
|
+
final["reviewComment"] = f"CI checks are failing ({failing_names}). Recommendation downgraded.\n\n" + str(
|
|
413
|
+
final.get("reviewComment", "")
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
self._write_review_file(pr_num, pr, final, chunks, file_reviews)
|
|
417
|
+
return final
|
|
418
|
+
|
|
419
|
+
def _get_context_for_chunk(self, chunk: Dict) -> str:
|
|
420
|
+
"""Retrieves dependency and semantic context for a code chunk."""
|
|
421
|
+
filepath = str(chunk.get("file", ""))
|
|
422
|
+
context_parts = []
|
|
423
|
+
|
|
424
|
+
# 1. Dependency Context
|
|
425
|
+
deps = self.dependency_graph.get_dependencies(filepath)
|
|
426
|
+
dependents = self.dependency_graph.get_dependents(filepath)
|
|
427
|
+
if deps or dependents:
|
|
428
|
+
context_parts.append("### Dependency Context")
|
|
429
|
+
if deps:
|
|
430
|
+
context_parts.append(f"- Dependencies: {', '.join(deps[:10])}")
|
|
431
|
+
if dependents:
|
|
432
|
+
context_parts.append(f"- Impacted files (dependents): {', '.join(dependents[:10])}")
|
|
433
|
+
|
|
434
|
+
# 2. Semantic Context
|
|
435
|
+
try:
|
|
436
|
+
if not self.vector_store.is_available():
|
|
437
|
+
return "\n".join(context_parts)
|
|
438
|
+
|
|
439
|
+
diff_text = chunk.get("diff_text") or chunk.get("diff") or ""
|
|
440
|
+
if not diff_text:
|
|
441
|
+
return "\n".join(context_parts)
|
|
442
|
+
|
|
443
|
+
semantic_results = self.vector_store.query(diff_text, n_results=3)
|
|
444
|
+
if semantic_results:
|
|
445
|
+
context_parts.append("\n### Semantically Related Code")
|
|
446
|
+
for res in semantic_results:
|
|
447
|
+
path = res["metadata"].get("path", "unknown")
|
|
448
|
+
if path != filepath:
|
|
449
|
+
context_parts.append(f"#### From {path}:")
|
|
450
|
+
context_parts.append(f"```\n{res['document'][:500]}\n```")
|
|
451
|
+
except Exception as e:
|
|
452
|
+
print(f"Error searching vector store: {e}", file=sys.stderr) # Log failure if not indexed
|
|
453
|
+
|
|
454
|
+
return "\n".join(context_parts)
|
|
455
|
+
|
|
456
|
+
def _build_chunk_prompt(self, chunk: Dict, pr_title: str, checks_summary: str) -> str:
|
|
457
|
+
trunc_note = "\n(Note: diff was truncated to fit context window)" if chunk.get("truncated") else ""
|
|
458
|
+
|
|
459
|
+
context = self._get_context_for_chunk(chunk)
|
|
460
|
+
context_section = f"\n\n## Repository Context\n{context}" if context else ""
|
|
461
|
+
|
|
462
|
+
stack_versions = get_stack_versions(fetch_latest=True)
|
|
463
|
+
versions_block = "\n".join([f"- {k}: {v}" for k, v in stack_versions.items()])
|
|
464
|
+
|
|
465
|
+
json_rules, snippet_rules, _COMMON_REVIEW_GUIDELINES = _get_review_prompt_constants()
|
|
466
|
+
return (
|
|
467
|
+
f"Review {chunk['file']}. PR: {pr_title}. CI: {checks_summary}\n"
|
|
468
|
+
f"VERSIONS: {versions_block}\n{context_section}\n\n"
|
|
469
|
+
f"{_COMMON_REVIEW_GUIDELINES}\n\n"
|
|
470
|
+
f"Rules:\n"
|
|
471
|
+
f'- DO NOT suggest downgrading any versions listed in the "Current Stack Versions" section.\n'
|
|
472
|
+
f"- Flag ONLY real problems: bugs, type unsafety, broken logic, design rule violations.\n"
|
|
473
|
+
f"{snippet_rules}\n\n"
|
|
474
|
+
f"{json_rules}\n\n"
|
|
475
|
+
f'- Use severity "error" for blocking issues, "warn" for improvements, "info" for nits.\n'
|
|
476
|
+
f'- Set verdict to "ok" (no issues), "needs_changes" (warn/info only), or "blocking" (any error).\n'
|
|
477
|
+
"OUTPUT: Valid JSON. Counterexamples required for errors.\n"
|
|
478
|
+
"JSON MUST be inside a <findings> tag.\n\n"
|
|
479
|
+
f"Diff:{trunc_note}\n{chunk['diff_text']}"
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
def _write_progress_snapshot(
|
|
483
|
+
self,
|
|
484
|
+
pr_num: Any,
|
|
485
|
+
reviewable: List[Dict],
|
|
486
|
+
file_reviews: List[Dict],
|
|
487
|
+
completed: int,
|
|
488
|
+
cache_dir: str,
|
|
489
|
+
) -> None:
|
|
490
|
+
"""Write a live progress file after each chunk so intermediate results are visible."""
|
|
491
|
+
output_dir = ensure_dir("logs", "reviews")
|
|
492
|
+
progress_path = os.path.join(output_dir, f"pr-review-{pr_num}-progress.md")
|
|
493
|
+
|
|
494
|
+
total = len(reviewable)
|
|
495
|
+
pct = int(completed / total * 100) if total else 0
|
|
496
|
+
verdict_icon = {
|
|
497
|
+
"ok": "✅",
|
|
498
|
+
"needs_changes": "⚠️",
|
|
499
|
+
"blocking": "🚫",
|
|
500
|
+
"error": "❓",
|
|
501
|
+
"parse_error": "❓",
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
lines = [
|
|
505
|
+
f"# PR #{pr_num} – Review In Progress ({completed}/{total} files, {pct}%)",
|
|
506
|
+
"",
|
|
507
|
+
f"_Last updated: {time.strftime('%Y-%m-%d %H:%M:%S')}_",
|
|
508
|
+
"",
|
|
509
|
+
"## Completed Files",
|
|
510
|
+
"",
|
|
511
|
+
"| # | File | Added Lines | Issues | Verdict |",
|
|
512
|
+
"|---|---|---|---|---|",
|
|
513
|
+
]
|
|
514
|
+
|
|
515
|
+
for idx, fr in enumerate(file_reviews, 1):
|
|
516
|
+
chunk = next(
|
|
517
|
+
(c for c in reviewable if c["file"] == fr["file"] and c["chunk_index"] == fr.get("chunk_index", 0)),
|
|
518
|
+
{},
|
|
519
|
+
)
|
|
520
|
+
added = chunk.get("added_lines", "?")
|
|
521
|
+
issues = len(fr.get("issues", []))
|
|
522
|
+
v = fr.get("verdict", "?")
|
|
523
|
+
icon = verdict_icon.get(v, "❓")
|
|
524
|
+
lines.append(f"| {idx} | `{fr['file']}` | {added} | {issues} | {icon} {v} |")
|
|
525
|
+
|
|
526
|
+
# Pending files
|
|
527
|
+
completed_files = {fr["file"] for fr in file_reviews}
|
|
528
|
+
pending = [c for c in reviewable if c["file"] not in completed_files]
|
|
529
|
+
if pending:
|
|
530
|
+
lines += ["", "## Pending Files", ""]
|
|
531
|
+
for c in pending:
|
|
532
|
+
lines.append(f"- `{c['file']}` ({c['added_lines']} added lines)")
|
|
533
|
+
|
|
534
|
+
# Findings detail for completed files
|
|
535
|
+
lines += ["", "## Findings Detail", ""]
|
|
536
|
+
for fr in file_reviews:
|
|
537
|
+
issues = fr.get("issues", [])
|
|
538
|
+
v = fr.get("verdict", "?")
|
|
539
|
+
icon = verdict_icon.get(v, "❓")
|
|
540
|
+
lines.append(f"### {icon} `{fr['file']}` — {v}")
|
|
541
|
+
if issues:
|
|
542
|
+
for issue in issues:
|
|
543
|
+
sev = issue.get("severity", "?")
|
|
544
|
+
ln = issue.get("line", "?")
|
|
545
|
+
comment = issue.get("comment", "")
|
|
546
|
+
lines.append(f"- **[{sev}]** line {ln}: {comment}")
|
|
547
|
+
else:
|
|
548
|
+
err = fr.get("error") or fr.get("raw")
|
|
549
|
+
lines.append(f" _{err or 'No issues found.'}_")
|
|
550
|
+
lines.append("")
|
|
551
|
+
|
|
552
|
+
try:
|
|
553
|
+
with open(progress_path, "w", encoding="utf-8") as f:
|
|
554
|
+
f.write("\n".join(lines))
|
|
555
|
+
except Exception as e:
|
|
556
|
+
log_warn(f"Could not write progress file: {e}")
|
|
557
|
+
|
|
558
|
+
def _synthesize_review(
|
|
559
|
+
self,
|
|
560
|
+
file_reviews: List[Dict],
|
|
561
|
+
pr_num: Any,
|
|
562
|
+
pr_title: str,
|
|
563
|
+
has_ci_failures: bool,
|
|
564
|
+
ci_failures: List[Dict],
|
|
565
|
+
) -> Dict:
|
|
566
|
+
"""Call the lighter gpt-4o model to produce the final verdict from structured per-chunk data."""
|
|
567
|
+
total_issues = sum(len(fr.get("issues", [])) for fr in file_reviews if isinstance(fr, dict))
|
|
568
|
+
blocking_files = [
|
|
569
|
+
fr.get("file", "unknown") for fr in file_reviews if isinstance(fr, dict) and fr.get("verdict") == "blocking"
|
|
570
|
+
]
|
|
571
|
+
error_files = [
|
|
572
|
+
fr.get("file", "unknown")
|
|
573
|
+
for fr in file_reviews
|
|
574
|
+
if isinstance(fr, dict) and fr.get("verdict") in ("error", "parse_error")
|
|
575
|
+
]
|
|
576
|
+
needs_files = [
|
|
577
|
+
fr.get("file", "unknown")
|
|
578
|
+
for fr in file_reviews
|
|
579
|
+
if isinstance(fr, dict) and fr.get("verdict") == "needs_changes"
|
|
580
|
+
]
|
|
581
|
+
ok_files = [
|
|
582
|
+
fr.get("file", "unknown") for fr in file_reviews if isinstance(fr, dict) and fr.get("verdict") == "ok"
|
|
583
|
+
]
|
|
584
|
+
|
|
585
|
+
# Build a compact findings summary (keep prompt small)
|
|
586
|
+
findings_lines = []
|
|
587
|
+
for fr in file_reviews:
|
|
588
|
+
if not isinstance(fr, dict):
|
|
589
|
+
continue
|
|
590
|
+
for issue in fr.get("issues", []):
|
|
591
|
+
if not isinstance(issue, dict):
|
|
592
|
+
continue
|
|
593
|
+
findings_lines.append(
|
|
594
|
+
f' - {fr.get("file", "?")}:{issue.get("line", "?")} [{issue.get("severity", "?")}] {issue.get("comment", "")}'
|
|
595
|
+
)
|
|
596
|
+
findings_str = "\n".join(findings_lines[:60]) # cap at 60 lines
|
|
597
|
+
if len(findings_lines) > 60:
|
|
598
|
+
findings_str += f"\n ... ({len(findings_lines) - 60} more issues omitted)"
|
|
599
|
+
|
|
600
|
+
ci_note = ""
|
|
601
|
+
if has_ci_failures:
|
|
602
|
+
ci_note = (
|
|
603
|
+
f"\nCI FAILURES: {', '.join(c.get('name', '?') for c in ci_failures)} – must NOT recommend Approved.\n"
|
|
604
|
+
)
|
|
605
|
+
|
|
606
|
+
prompt = (
|
|
607
|
+
f'You are summarising a code review for PR #{pr_num} – "{pr_title}".\n'
|
|
608
|
+
f"{ci_note}\n"
|
|
609
|
+
f"Per-file results:\n"
|
|
610
|
+
f" Blocking : {blocking_files or 'none'}\n"
|
|
611
|
+
f" Needs changes: {needs_files or 'none'}\n"
|
|
612
|
+
f" OK : {ok_files or 'none'}\n"
|
|
613
|
+
f" Errors (could not review): {error_files or 'none'}\n"
|
|
614
|
+
f"Total issues found: {total_issues}\n\n"
|
|
615
|
+
f"Issue details:\n{findings_str if findings_str else ' (none)'}\n\n"
|
|
616
|
+
f"Format your response as a standard Markdown report followed by a metadata JSON block.\n\n"
|
|
617
|
+
f"The Markdown report should summarize the findings concisely.\n\n"
|
|
618
|
+
f"The JSON block at the bottom MUST follow this schema:\n"
|
|
619
|
+
f"{{\n"
|
|
620
|
+
f' "recommendation": "Approved | Approved with Minor Changes | Not Approved",\n'
|
|
621
|
+
f' "labels": ["lgtm", "needs-changes", ...],\n'
|
|
622
|
+
f' "reviewComment": "Concise summary for the body"\n'
|
|
623
|
+
f"}}\n"
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
raw = None
|
|
627
|
+
res = None
|
|
628
|
+
for attempt in range(_MAX_AI_RETRIES):
|
|
629
|
+
try:
|
|
630
|
+
# We don't use strict schema mode here because we want mixed markdown + json
|
|
631
|
+
raw = call_ai(prompt, model=_SYNTHESIS_MODEL, max_retries=1)
|
|
632
|
+
if not raw:
|
|
633
|
+
continue
|
|
634
|
+
|
|
635
|
+
cleaned = clean_llm_output(raw)
|
|
636
|
+
res, err = validate_with_model(cleaned, AISynthesisReview)
|
|
637
|
+
if err or res is None:
|
|
638
|
+
raise ValueError(err or "Validation failed")
|
|
639
|
+
|
|
640
|
+
if isinstance(res, dict):
|
|
641
|
+
break # Success
|
|
642
|
+
except Exception as e:
|
|
643
|
+
log_warn(f"Synthesis attempt {attempt+1} failed: {e}")
|
|
644
|
+
time.sleep(1)
|
|
645
|
+
|
|
646
|
+
if not res:
|
|
647
|
+
# Fallback: construct a minimal result from the structured data
|
|
648
|
+
if blocking_files:
|
|
649
|
+
rec = "Not Approved"
|
|
650
|
+
elif needs_files:
|
|
651
|
+
rec = "Approved with Minor Changes"
|
|
652
|
+
else:
|
|
653
|
+
rec = "Approved"
|
|
654
|
+
return {
|
|
655
|
+
"reviewComment": (
|
|
656
|
+
f"Automated review of PR #{pr_num}.\n\n"
|
|
657
|
+
f"Blocking files: {blocking_files or 'none'}\n"
|
|
658
|
+
f"Files needing changes: {needs_files or 'none'}\n"
|
|
659
|
+
f"Total issues: {total_issues}\n\n"
|
|
660
|
+
f"(AI service returned no response; verdict derived from per-file data.)"
|
|
661
|
+
),
|
|
662
|
+
"labels": ["needs-changes"] if rec != "Approved" else ["lgtm"],
|
|
663
|
+
"recommendation": rec,
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
return res
|
|
667
|
+
|
|
668
|
+
# ── Output file ───────────────────────────────────────────────────────────
|
|
669
|
+
|
|
670
|
+
def _write_review_file(
|
|
671
|
+
self,
|
|
672
|
+
pr_num: Any,
|
|
673
|
+
pr: Dict,
|
|
674
|
+
review: Dict,
|
|
675
|
+
chunks: List[Dict],
|
|
676
|
+
file_reviews: List[Dict],
|
|
677
|
+
) -> None:
|
|
678
|
+
"""Populate and persist the review template with AI-generated content."""
|
|
679
|
+
head_sha = pr.get("head", {}).get("sha", "unknown")
|
|
680
|
+
check_results = pr.get("checkResults", [])
|
|
681
|
+
failed_checks = [c.get("name") for c in check_results if c.get("conclusion") == "failure"]
|
|
682
|
+
detected_errors_raw = pr.get("structuredFailures", [])
|
|
683
|
+
|
|
684
|
+
failed_checks_str = "\n".join(f" - {c}" for c in failed_checks) if failed_checks else "_None_"
|
|
685
|
+
detected_errors_str = (
|
|
686
|
+
"\n".join(
|
|
687
|
+
f" - `{e.get('file', '?')}:{e.get('line', '?')}` {e.get('message', '')}" for e in detected_errors_raw
|
|
688
|
+
)
|
|
689
|
+
if detected_errors_raw
|
|
690
|
+
else "_None detected by parser._"
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
recommendation = review.get("recommendation", "Unknown")
|
|
694
|
+
review_comment = review.get("reviewComment", "")
|
|
695
|
+
labels = review.get("labels", [])
|
|
696
|
+
labels_str = ", ".join(labels) if labels else "_None_"
|
|
697
|
+
|
|
698
|
+
# Per-file findings table
|
|
699
|
+
file_data: Dict[str, Dict] = defaultdict(lambda: {"added_lines": 0, "issues": [], "verdict": "ok"})
|
|
700
|
+
for fr in file_reviews:
|
|
701
|
+
if not isinstance(fr, dict):
|
|
702
|
+
continue
|
|
703
|
+
f = fr.get("file")
|
|
704
|
+
if not f:
|
|
705
|
+
continue
|
|
706
|
+
issues = fr.get("issues", [])
|
|
707
|
+
if isinstance(issues, list):
|
|
708
|
+
file_data[f]["issues"].extend([i for i in issues if isinstance(i, dict)])
|
|
709
|
+
|
|
710
|
+
# worst verdict wins: blocking > needs_changes > ok
|
|
711
|
+
_rank = {"blocking": 3, "needs_changes": 2, "ok": 1, "error": 0, "parse_error": 0}
|
|
712
|
+
verdict = fr.get("verdict", "ok")
|
|
713
|
+
if not isinstance(verdict, str):
|
|
714
|
+
verdict = str(verdict)
|
|
715
|
+
if _rank.get(verdict, 0) > _rank.get(file_data[f]["verdict"], 0):
|
|
716
|
+
file_data[f]["verdict"] = verdict
|
|
717
|
+
for chunk in chunks:
|
|
718
|
+
if not chunk["skip"]:
|
|
719
|
+
file_data[chunk["file"]]["added_lines"] += chunk["added_lines"]
|
|
720
|
+
|
|
721
|
+
table_rows = []
|
|
722
|
+
for filepath, data in sorted(file_data.items()):
|
|
723
|
+
issue_count = len(data["issues"])
|
|
724
|
+
verdict_icon = {"ok": "✅", "needs_changes": "⚠️", "blocking": "🚫"}.get(data["verdict"], "❓")
|
|
725
|
+
table_rows.append(
|
|
726
|
+
f"| `{filepath}` | {data['added_lines']} | {issue_count} | {verdict_icon} {data['verdict']} |"
|
|
727
|
+
)
|
|
728
|
+
|
|
729
|
+
skipped_files = sorted(set(c["file"] for c in chunks if c["skip"]))
|
|
730
|
+
skipped_str = ", ".join(f"`{f}`" for f in skipped_files) if skipped_files else "_None_"
|
|
731
|
+
|
|
732
|
+
per_file_table = (
|
|
733
|
+
("| File | Lines Added | Issues | Verdict |\n" "|---|---|---|---|\n" + "\n".join(table_rows))
|
|
734
|
+
if table_rows
|
|
735
|
+
else "_No files reviewed._"
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
# Build inline comments JSON block
|
|
739
|
+
all_issues: List[Dict[str, Any]] = []
|
|
740
|
+
for fr in file_reviews:
|
|
741
|
+
if not isinstance(fr, dict):
|
|
742
|
+
continue
|
|
743
|
+
issues = fr.get("issues", [])
|
|
744
|
+
if not isinstance(issues, list):
|
|
745
|
+
continue
|
|
746
|
+
for issue in issues:
|
|
747
|
+
if not isinstance(issue, dict):
|
|
748
|
+
continue
|
|
749
|
+
conf = str(issue.get("confidence", "high")).upper()
|
|
750
|
+
# Support both 'issue' and 'comment' fields from the model
|
|
751
|
+
issue_description = str(issue.get("issue") or issue.get("comment") or "")
|
|
752
|
+
all_issues.append(
|
|
753
|
+
{
|
|
754
|
+
"path": str(fr.get("file", "unknown")),
|
|
755
|
+
"line": issue.get("line", 1),
|
|
756
|
+
"body": f"[{issue.get('severity', '?')}] (Confidence: {conf}) {issue_description}",
|
|
757
|
+
}
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
metadata_json = json.dumps(
|
|
761
|
+
{"recommendation": recommendation, "labels": labels, "comments": all_issues}, indent=2
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
content = f"""# PR Review: #{pr_num}
|
|
765
|
+
|
|
766
|
+
## Context
|
|
767
|
+
|
|
768
|
+
- **Last Commit Tracked (SHA):** {head_sha}
|
|
769
|
+
- **Labels:** {labels_str}
|
|
770
|
+
- **Recommendation:** {recommendation}
|
|
771
|
+
|
|
772
|
+
## Audit Checklist
|
|
773
|
+
|
|
774
|
+
For EVERY changed file, verify against these standards. Mark as `- [x]` when verified.
|
|
775
|
+
|
|
776
|
+
- [ ] Dead abstractions: No new class, context, or hook that a simpler primitive handles.
|
|
777
|
+
- [ ] Unnecessary indirection: No layer of wrapping where a direct function call suffices.
|
|
778
|
+
- [ ] Responsibility creep: Component does not take on state/logic belonging in parent/hook.
|
|
779
|
+
- [ ] Import bloat: No unnecessary `import React from 'react'` (React 17+).
|
|
780
|
+
- [ ] Token compliance: Uses established design tokens (no raw Tailwind values or inline styles).
|
|
781
|
+
- [ ] Audit ratio: If > 100 lines added, identified at least 10 lines to refactor/remove.
|
|
782
|
+
|
|
783
|
+
## Per-File Findings
|
|
784
|
+
|
|
785
|
+
{per_file_table}
|
|
786
|
+
|
|
787
|
+
_Skipped ({len(skipped_files)} files): {skipped_str}_
|
|
788
|
+
|
|
789
|
+
## CI Log Triage
|
|
790
|
+
|
|
791
|
+
(Populated if CI failures detected)
|
|
792
|
+
- **Failed Checks:**
|
|
793
|
+
{failed_checks_str}
|
|
794
|
+
- **Detected Errors:**
|
|
795
|
+
{detected_errors_str}
|
|
796
|
+
- **Root Cause Analysis:**
|
|
797
|
+
- **Remediation Steps:**
|
|
798
|
+
|
|
799
|
+
## AI Review Comment
|
|
800
|
+
|
|
801
|
+
{review_comment}
|
|
802
|
+
|
|
803
|
+
## Output JSON
|
|
804
|
+
|
|
805
|
+
Provide your metadata in the JSON block below. The review body is extracted from the Markdown content above.
|
|
806
|
+
DO NOT REMOVE THE BACKTICKS.
|
|
807
|
+
|
|
808
|
+
```json
|
|
809
|
+
{metadata_json}
|
|
810
|
+
```
|
|
811
|
+
"""
|
|
812
|
+
output_dir = ensure_dir("logs", "reviews")
|
|
813
|
+
output_path = os.path.join(output_dir, f"pr-review-{pr_num}.md")
|
|
814
|
+
with open(output_path, "w", encoding="utf-8") as f:
|
|
815
|
+
f.write(content)
|
|
816
|
+
log_info(f"📝 Review written to: {output_path}")
|