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,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}")