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,2841 @@
1
+ # pylint: disable=consider-using-enumerate,consider-using-get,consider-using-with,f-string-without-interpolation,import-outside-toplevel,invalid-name,line-too-long,missing-docstring,no-else-return,pointless-string-statement,raise-missing-from,too-many-arguments,too-many-branches,too-many-lines,too-many-locals,too-many-nested-blocks,too-many-positional-arguments,too-many-public-methods,too-many-statements,try-except-raise,unused-argument,unused-variable,wrong-import-position
2
+ import hashlib
3
+ import json
4
+ import os
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ import textwrap
11
+ from collections import defaultdict
12
+ from datetime import datetime, timezone
13
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
14
+
15
+ from dev_tools.services.github import GitHubClient
16
+ from dev_tools.services.jules import JulesClient
17
+ from dev_tools.ux_report import generate_report
18
+
19
+ if TYPE_CHECKING:
20
+ from dev_tools.services.ai_service import AIClient
21
+ from dev_tools.services.vision_service import VisionService
22
+
23
+ from dev_tools.config import get_config
24
+ from dev_tools.handlers.command_handler import CommandHandler
25
+ from dev_tools.models import IssueSummary, PRSummary
26
+ from dev_tools.utils import (
27
+ log_info,
28
+ CLIError,
29
+ clean_gha_logs,
30
+ escape_md,
31
+ extract_failing_info,
32
+ find_patterns_in_file,
33
+ get_any_count,
34
+ get_bundle_size,
35
+ get_gha_variable,
36
+ get_github_client,
37
+ get_or_create_log_dir,
38
+ get_repo_name,
39
+ log_error,
40
+ log_warn,
41
+ resolve_resource_path,
42
+ run_command,
43
+ run_git_commands,
44
+ sanitize_metadata,
45
+ sanitize_path,
46
+ set_gha_variable,
47
+ verify_ci_metrics,
48
+ verify_pr_scope,
49
+ walk_tsx,
50
+ )
51
+
52
+ PROJECT_CONFIG = get_config()
53
+ AUDIT_CHECK_DIRS = PROJECT_CONFIG.audit_check_dirs
54
+ SPEC_SECTIONS = PROJECT_CONFIG.spec_sections
55
+
56
+ # Pre-compute UI indicators for heuristic checks
57
+ UI_INDICATORS = PROJECT_CONFIG.ui_indicators
58
+
59
+
60
+ class Orchestrator:
61
+ # Command detection patterns with word boundaries to avoid false positives
62
+ _CMD_PATTERNS = {
63
+ "conflict_resolve": r"(?<!\w)@conflict-resolve\b",
64
+ "update_snapshots": r"(?<!\w)@update-snapshots\b",
65
+ "ai_fix": r"(?<!\w)/ai-fix\b",
66
+ "ai_review": r"(?<!\w)/ai-review\b",
67
+ "jules_fix_ci": r"(?<!\w)@jules-fix-ci\b",
68
+ }
69
+
70
+ def _extract_diff_hunks(self, diff_text: str) -> Dict[str, List[Tuple[int, int]]]:
71
+ """Extracts modified line ranges (hunks) from a git diff string."""
72
+ hunks = defaultdict(list)
73
+ current_file = None
74
+ for line in diff_text.splitlines():
75
+ if line.startswith("+++ b/"):
76
+ current_file = line[6:]
77
+ elif line.startswith("@@") and current_file:
78
+ m = re.search(r"\+(\d+),?(\d*)", line)
79
+ if m:
80
+ start = int(m.group(1))
81
+ count = int(m.group(2)) if m.group(2) else 1
82
+ hunks[current_file].append((start, start + count - 1))
83
+ return hunks
84
+
85
+ def __init__(self, no_cache: bool = False) -> None:
86
+ self._github: Optional[GitHubClient] = None
87
+ self._ai: Optional[AIClient] = None
88
+ self._jules: Optional[JulesClient] = None
89
+ self.no_cache = no_cache
90
+
91
+ @property
92
+ def github(self) -> GitHubClient:
93
+ if self._github is None:
94
+ self._github = GitHubClient(no_cache=self.no_cache)
95
+ return self._github
96
+
97
+ @property
98
+ def ai(self) -> "AIClient":
99
+ if self._ai is None:
100
+ from dev_tools.services.ai_service import AIClient
101
+
102
+ self._ai = AIClient()
103
+ return self._ai
104
+
105
+ @property
106
+ def jules(self) -> JulesClient:
107
+ if self._jules is None:
108
+ self._jules = JulesClient()
109
+ return self._jules
110
+
111
+ def initialize_jules(self, client: JulesClient) -> None:
112
+ self._jules = client
113
+
114
+ @property
115
+ def vision(self) -> "VisionService":
116
+ from dev_tools.services.vision_service import VisionService
117
+
118
+ return VisionService()
119
+
120
+ def _hash_content(self, content: str) -> str:
121
+ return hashlib.md5(content.encode("utf-8")).hexdigest()
122
+
123
+ def _cleanup_worktree(self, worktree_path: str) -> None:
124
+ """Robustly cleans up a git worktree and its directory."""
125
+ # Unregister and attempt to remove the worktree via git
126
+ run_command(["git", "worktree", "remove", "-f", worktree_path], check=False, log_on_error=False)
127
+
128
+ # Forcefully delete the directory if it still exists
129
+ if os.path.exists(worktree_path):
130
+ shutil.rmtree(worktree_path, ignore_errors=True)
131
+
132
+ # Prune stale worktree metadata
133
+ run_command(["git", "worktree", "prune"], check=False, log_on_error=False)
134
+
135
+ # Final safety check
136
+ if os.path.exists(worktree_path):
137
+ raise CLIError(f"Failed to clean up worktree directory: {worktree_path}")
138
+
139
+ def evaluate_pr_heuristics(self, pr: Dict[str, Any], diff: str, checks: Dict[str, Any]) -> str:
140
+ """Applies heuristic rules to a PR diff and checks, returning specific feedback."""
141
+ # Pre-process diff to get unique files for faster heuristic matching
142
+ files_in_diff = set()
143
+ for line in diff.splitlines():
144
+ if line.startswith("+++ b/"):
145
+ files_in_diff.add(line[6:])
146
+
147
+ is_ui = any(any(ind in f for ind in UI_INDICATORS) for f in files_in_diff)
148
+ is_python = any(f.endswith(".py") for f in files_in_diff)
149
+ is_infra = any(any(ind in f for ind in PROJECT_CONFIG.infra_file_paths) for f in files_in_diff)
150
+
151
+ # File Necessity Check (Temporary Files)
152
+ temp_files = []
153
+ for f in files_in_diff:
154
+ if any(re.search(pattern, f) for pattern in PROJECT_CONFIG.temp_file_patterns):
155
+ temp_files.append(f)
156
+
157
+ fails = [c["name"] for c in checks.get("check_runs", []) if c.get("conclusion") == "failure"]
158
+
159
+ feedback = f"### Specific Review for PR #{pr['number']}\n\n"
160
+
161
+ # What's working
162
+ feedback += "**What is working well:**\n"
163
+ feedback += f"- The scope is clearly defined in branch `{pr.get('head', {}).get('ref', 'unknown')}`.\n"
164
+ if not fails:
165
+ feedback += "- All CI checks appear to be passing.\n"
166
+
167
+ feedback += "\n**Specific Issues & Actionable Fixes:**\n"
168
+
169
+ if fails:
170
+ feedback += f"- **CI Failure:** The following checks are failing: {', '.join(fails)}. Please investigate the logs for these jobs.\n"
171
+ if "Build & E2E" in fails:
172
+ feedback += " - *Fix:* Ensure `pnpm run build` passes locally and all `playwright` tests succeed via `pnpm test:e2e`.\n"
173
+ elif "deploy" in fails:
174
+ feedback += " - *Fix:* Verify that the `dist` directory compiles correctly without TypeScript or Vite errors.\n"
175
+
176
+ if is_ui:
177
+ tailwind_indicators = PROJECT_CONFIG.tailwind_indicators
178
+ if any(ind in diff for ind in tailwind_indicators):
179
+ feedback += "- **Design System Anti-patterns:** The diff contains raw Tailwind classes (e.g. padding/margin utility classes, arbitrary values).\n"
180
+ feedback += " - *Fix:* Replace raw Tailwind layout classes with `Stack`, `Box`, or `Grid` primitives using design tokens (e.g., `gap={4}`, `paddingY={{ base: 4, md: 1.5 }}`). Verify by running `node boomtick-pkg/scripts/detect-antipatterns.mjs`.\n"
181
+
182
+ feedback += "- **Mobile UX Verification:** For any UI additions, ensure horizontal layout does not overflow a 390px viewport.\n"
183
+ feedback += " - *Fix:* If adding interactive elements, wrap them to enforce a minimum 48x48px touch target for accessibility.\n"
184
+
185
+ if is_python:
186
+ feedback += "- **Python Scripting:** Python changes detected.\n"
187
+ feedback += " - *Fix:* Ensure `python3 -m pytest tests/` passes. Update `test_td-cli` or equivalent test files if extending `dev-tools`.\n"
188
+
189
+ if is_infra:
190
+ feedback += PROJECT_CONFIG.infra_feedback
191
+
192
+ if temp_files:
193
+ feedback += PROJECT_CONFIG.temp_file_feedback
194
+ for tf in sorted(temp_files):
195
+ feedback += f" - `{tf}`\n"
196
+
197
+ if pr.get("mergeable") is False:
198
+ base_branch_name = PROJECT_CONFIG.base_branch_name
199
+ feedback += f"- **Merge Conflicts:** This PR has conflicts with the `{base_branch_name}` base branch.\n"
200
+ feedback += f" - *Fix:* Pull `{base_branch_name}` into your branch, resolve the conflicts (e.g., via `{PROJECT_CONFIG.cli_alias} gh conflicts`), and force push.\n"
201
+
202
+ if "overlap" in pr.get("title", "").lower() or "cli" in pr.get("title", "").lower():
203
+ feedback += "- **Overlap / Interdependency:** This PR touches dev-tools or overlap logic.\n"
204
+ feedback += f" - *Fix:* Ensure this is rebased against recent changes in the `{PROJECT_CONFIG.base_branch_name}` branch to avoid overlapping functionality.\n"
205
+
206
+ # Default if no specific issues caught by heuristics
207
+ if feedback.endswith("**Specific Issues & Actionable Fixes:**\n"):
208
+ feedback += "- Review the diff against `audit` guidelines. Ensure no console errors exist in the target components.\n"
209
+
210
+ return feedback
211
+
212
+ def review_pr(self, prNumber: int, **kwargs) -> Dict[str, Any]:
213
+ if "pr_number" in kwargs:
214
+ prNumber = kwargs["pr_number"]
215
+ """
216
+ Fetches a PR, its diff, and generates a code review using LocalAI/Gemini.
217
+ """
218
+ pr_details = self.github.fetch_pr_details(prNumber)
219
+ sha = pr_details.get("head", {}).get("sha")
220
+ check_runs = self.github.fetch_check_runs(sha)
221
+ pr_details["checkResults"] = check_runs
222
+
223
+ # Fetch logs for failing checks
224
+ failing_logs: Dict[str, str] = {}
225
+ structured_failures = []
226
+ for run in check_runs:
227
+ if run.get("conclusion") == "failure":
228
+ run_id = run.get("id")
229
+ findings: List[Dict[str, Any]] = []
230
+ if isinstance(run_id, int):
231
+ logs = self.github.fetch_check_run_logs(run_id, external_id=run.get("external_id"))
232
+ failing_logs[str(run.get("name", "unknown"))] = logs[-5000:] # Keep last 5k chars
233
+ findings = extract_failing_info(logs)
234
+ for f in findings:
235
+ structured_failures.append(
236
+ {
237
+ "check": run.get("name"),
238
+ "file": f["file"],
239
+ "line": f["line"],
240
+ "message": f["message"],
241
+ "type": f["type"],
242
+ }
243
+ )
244
+
245
+ pr_details["failingLogs"] = failing_logs
246
+ pr_details["structuredFailures"] = structured_failures
247
+
248
+ pr_diff = self.github.fetch_pr_diff(prNumber)
249
+ diff_hash = self._hash_content(pr_diff)
250
+ # Store cache in local logs directory to avoid /tmp Security Error
251
+ review_dir = get_or_create_log_dir("reviews")
252
+ cache_file = os.path.join(review_dir, f"review_cache_{prNumber}_{diff_hash}.json")
253
+ if os.path.exists(cache_file):
254
+ with open(cache_file, "r", encoding="utf-8") as review_file:
255
+ return json.load(review_file)
256
+ review_result = self.ai.generate_code_review(pr_details, pr_diff)
257
+ with open(cache_file, "w", encoding="utf-8") as review_file:
258
+ json.dump(review_result, review_file)
259
+ return review_result
260
+
261
+ def resolve_conflict(self, file_path: str) -> bool:
262
+ """
263
+ Detects merge conflicts via GitHubClient (implicit local git), analyzes logic with AI.
264
+ """
265
+ return self.ai.resolve_file_conflicts(file_path)
266
+
267
+ def analyze_file(self, file_path: str) -> str:
268
+ if not os.path.exists(file_path):
269
+ raise CLIError(f"File not found: {file_path}")
270
+
271
+ with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
272
+ content = f.read()
273
+ prompt = f"Analyze this file for bugs, style issues, and potential improvements:\n\n{content[:20000]}"
274
+ return self.ai.generate(prompt)
275
+
276
+ def find_conflict_files(self) -> List[str]:
277
+ """
278
+ Robustly finds files with git conflict markers, ignoring build artifacts and dependencies.
279
+ """
280
+ res = run_command(
281
+ [
282
+ "grep",
283
+ "-lrE",
284
+ "^<<<<<<<|^=======|^>>>>>>>",
285
+ ".",
286
+ "--exclude-dir=boomtick-pkg",
287
+ "--exclude-dir=node_modules",
288
+ "--exclude-dir=dist",
289
+ "--exclude-dir=.git",
290
+ "--exclude-dir=build",
291
+ "--exclude-dir=target",
292
+ "--exclude-dir=.venv",
293
+ ],
294
+ check=False,
295
+ log_on_error=False,
296
+ )
297
+ if isinstance(res, subprocess.CompletedProcess) and res.returncode == 0 and res.stdout:
298
+ return [f.strip() for f in res.stdout.splitlines() if f.strip()]
299
+ return []
300
+
301
+ def dispatch_jules_review(self, branch: str, prompt: str) -> Optional[Dict[str, Any]]:
302
+ """
303
+ Automates the creation of Jules sessions.
304
+ """
305
+ if not self.github.branch_exists(branch):
306
+ raise CLIError(f"Branch '{branch}' does not exist in the repository.")
307
+
308
+ # Prevent dispatching Jules for consolidation/aggregation tasks
309
+ if re.search(r"\b(consolidate|aggregate)\s+pr(s)?\b", prompt.lower()):
310
+ raise CLIError(
311
+ "PR consolidation and aggregation tasks should be performed directly using the 'gh aggregate' command instead of dispatching a Jules session."
312
+ )
313
+
314
+ repo_name = self.github.repo or ""
315
+ source_id = self.jules.discover_source_id(repo_name)
316
+ if not source_id:
317
+ raise CLIError(f"Could not find a Jules source mapping for repository: {repo_name}")
318
+
319
+ session = self.jules.create_session_from_source(source_id, branch, prompt)
320
+ return session
321
+
322
+ # --- Helper methods ported from td-cli ---
323
+
324
+ def get_env_or_gha(self, env_var: str) -> Optional[str]:
325
+ if env_var in os.environ:
326
+ return os.environ[env_var]
327
+ return get_gha_variable(env_var)
328
+
329
+ def resolve_baseline(self, file_path: Optional[str], env_var: str, fallback_value: int) -> int:
330
+ if file_path and os.path.exists(file_path):
331
+ with open(file_path, "r", encoding="utf-8") as f:
332
+ return int(f.read().strip() or fallback_value)
333
+ val = self.get_env_or_gha(env_var)
334
+ if val is not None and str(val).strip() != "":
335
+ return int(val)
336
+ return fallback_value
337
+
338
+ def get_audit_results(self, content: Optional[str] = None, targets: Optional[List[str]] = None) -> Dict[str, Any]:
339
+ cmd = ["node", "boomtick-pkg/scripts/detect-antipatterns.mjs", "--json"]
340
+ if targets:
341
+ cmd.extend(targets)
342
+ elif content is not None:
343
+ cmd.append("-")
344
+ res = run_command(cmd, check=False, input_str=content)
345
+ try:
346
+ stdout = res.stdout if isinstance(res, subprocess.CompletedProcess) else str(res)
347
+ return json.loads(stdout)
348
+ except json.JSONDecodeError:
349
+ return {"violations": {}, "config": {}}
350
+
351
+ def extract_code_blocks(self, text: str) -> List[str]:
352
+ return re.findall(r"```(?:tsx?|jsx?|html)?\n(.*?)```", text, re.DOTALL)
353
+
354
+ def get_pr_files(self, pr: Any) -> set[str]:
355
+ return {f.filename for f in pr.get_files()}
356
+
357
+ def detect_conflicts(self, target_pr_num: Optional[int] = None) -> Dict[Tuple[int, ...], List[str]]:
358
+ repo = get_github_client().get_repo(get_repo_name())
359
+ open_prs = list(repo.get_pulls(state="open"))
360
+ file_to_prs = defaultdict(list)
361
+ for pr in open_prs:
362
+ for f in self.get_pr_files(pr):
363
+ file_to_prs[f].append(pr.number)
364
+ conflicts = defaultdict(list)
365
+ for filename, prs in file_to_prs.items():
366
+ if len(prs) > 1 and (target_pr_num is None or target_pr_num in prs):
367
+ conflicts[tuple(sorted(prs))].append(filename)
368
+ return conflicts
369
+
370
+ def _has_spec_section(self, section_name: str, text: str) -> bool:
371
+ """Robustly checks for the presence of a markdown section or numbered list item."""
372
+ # Matches markdown headers (# Section Name) or numbered items (1. SECTION NAME)
373
+ header_pattern = rf"^\s*#+\s*{re.escape(section_name)}\b"
374
+ list_pattern = rf"^\s*\d+\.\s*{re.escape(section_name)}\b"
375
+ return bool(
376
+ re.search(header_pattern, text, re.IGNORECASE | re.MULTILINE)
377
+ or re.search(list_pattern, text, re.IGNORECASE | re.MULTILINE)
378
+ )
379
+
380
+ def _read_safe_file(self, file_path: str, max_size: int = 1024 * 1024) -> str:
381
+ """
382
+ Validates and reads a file from within the repository root.
383
+ """
384
+ # 1. Sanitize the path first
385
+ clean_path = sanitize_path(file_path)
386
+ if not clean_path:
387
+ raise CLIError("Security Error: Invalid or empty file path.")
388
+
389
+ abs_path = os.path.abspath(clean_path)
390
+ repo_root = os.path.abspath(os.getcwd())
391
+ try:
392
+ if os.path.commonpath([repo_root, abs_path]) != repo_root:
393
+ raise CLIError(f"Security Error: Path {file_path} is outside of repository root.")
394
+ except ValueError:
395
+ raise CLIError(f"Security Error: Path {file_path} is invalid or outside of repository root.")
396
+
397
+ if not os.path.exists(abs_path):
398
+ raise CLIError(f"File not found: {file_path}")
399
+
400
+ if os.path.getsize(abs_path) > max_size:
401
+ raise CLIError(f"File size exceeds limit of {max_size} bytes.")
402
+
403
+ with open(abs_path, "r", encoding="utf-8") as f:
404
+ return f.read()
405
+
406
+ def create_issue(self, title: str, body: str) -> Dict[str, Any]:
407
+ """
408
+ Creates a new GitHub issue.
409
+ """
410
+ res = self.github.create_issue(title, body)
411
+ return {"status": "success", "issue": IssueSummary(**res).model_dump()}
412
+
413
+ def get_issue_details(self, issueNumber: int) -> Dict[str, Any]:
414
+ """
415
+ Fetches details of a GitHub issue.
416
+ """
417
+ return self.github.fetch_issue_details(issueNumber)
418
+
419
+ def update_issue(
420
+ self,
421
+ issueNumber: int,
422
+ body: Optional[str] = None,
423
+ labels: Optional[List[str]] = None,
424
+ addLabels: Optional[List[str]] = None,
425
+ removeLabels: Optional[List[str]] = None,
426
+ state: Optional[str] = None,
427
+ **kwargs,
428
+ ) -> Dict[str, Any]:
429
+ if "issue_number" in kwargs and issueNumber is None:
430
+ issueNumber = kwargs["issue_number"]
431
+ """
432
+ Updates an issue's body, labels, and/or state.
433
+ """
434
+ res: Any = None
435
+ # Shim for backward compatibility with old snake_case names from tests or older callers
436
+ if "add_labels" in kwargs and addLabels is None:
437
+ addLabels = kwargs["add_labels"]
438
+ if "remove_labels" in kwargs and removeLabels is None:
439
+ removeLabels = kwargs["remove_labels"]
440
+ # Handle full label replacement first as it is mutually exclusive with incremental changes
441
+ if labels is not None:
442
+ res = self.github.update_issue(issueNumber, body=body, labels=labels, state=state)
443
+ else:
444
+ # Handle incremental label changes (can happen together)
445
+ if addLabels:
446
+ res = self.github.add_labels(issueNumber, addLabels)
447
+
448
+ if removeLabels:
449
+ for label in removeLabels:
450
+ self.github.remove_label(issueNumber, label)
451
+ # If we haven't updated yet (no add_labels, body, or state), fetch current state
452
+ if res is None and body is None and state is None:
453
+ res = self.github.fetch_issue_details(issueNumber)
454
+
455
+ # Handle body/state update if not already done via 'labels' PATCH
456
+ if body is not None or state is not None:
457
+ res = self.github.update_issue(issueNumber, body=body, state=state)
458
+
459
+ if res is None:
460
+ raise CLIError("Nothing to update. Provide body, labels, or state.")
461
+
462
+ return {"status": "success", "issue": IssueSummary(**res).model_dump()}
463
+
464
+ def post_comment(self, entity_number: int, body: Optional[str]) -> Dict[str, Any]:
465
+ """
466
+ Posts a comment to a Pull Request or Issue.
467
+ """
468
+ if body is None or not body.strip():
469
+ raise CLIError("Comment body cannot be empty.")
470
+ return self.github.create_issue_comment(entity_number, body)
471
+
472
+ def validate_issue(
473
+ self,
474
+ issueNumber: Optional[int] = None,
475
+ all_open: bool = False,
476
+ post_comments: bool = False,
477
+ dry_run: bool = True,
478
+ **kwargs: Any,
479
+ ) -> Dict[str, Any]:
480
+ if "issue_number" in kwargs and kwargs["issue_number"] is not None and issueNumber is None:
481
+ issueNumber = int(kwargs["issue_number"])
482
+ repo = get_github_client().get_repo(get_repo_name() or "")
483
+ issues: List[Any] = []
484
+ if all_open:
485
+ issues = list(repo.get_issues(state="open"))
486
+ elif issueNumber:
487
+ issues = [repo.get_issue(issueNumber)]
488
+ else:
489
+ raise CLIError("Provide --issue-number or --all-open")
490
+
491
+ results = []
492
+ total_findings = 0
493
+ audit_base = self.get_audit_results(content="")
494
+ config = audit_base.get("config", {})
495
+
496
+ for issue in issues:
497
+ findings = []
498
+ warnings = []
499
+ body = issue.body or ""
500
+ title = issue.title or ""
501
+
502
+ if not body.strip():
503
+ findings.append("Issue body is empty.")
504
+
505
+ for i, block in enumerate(self.extract_code_blocks(body)):
506
+ res = self.get_audit_results(content=block)
507
+ violations = res.get("violations", {}).get("stdin", [])
508
+ for v in violations:
509
+ val = v.get("value", "N/A")
510
+ findings.append(f"Code block {i+1}: {v['message']} (value: {val})")
511
+ for comp, path in config.get("existingComponents", {}).items():
512
+ if re.search(rf"(create|build|make|add|new)\s+.*{comp}", block, re.IGNORECASE):
513
+ warnings.append(f"Code block {i+1}: Suggests `{comp}` (exists at `{path}`)")
514
+ for comp, path in config.get("existingComponents", {}).items():
515
+ if re.search(rf"(create|build|make|add\s+a\s+new)\s+.*{comp}\b", body, re.IGNORECASE):
516
+ warnings.append(f"Issue suggests `{comp}` (exists at `{path}`)")
517
+ if re.match(r"^Draft.*:", title) and "```markdown" in body:
518
+ md_match = re.search(r"```markdown\n(.*?)\n```", body, re.DOTALL)
519
+ if md_match:
520
+ for field in config.get("requiredContentFields", []):
521
+ if not re.search(rf"^{field}:", md_match.group(1), re.MULTILINE):
522
+ findings.append(f"Missing frontmatter: `{field}`")
523
+ if not re.search(r"(acceptance criteria|definition of done|## done|verify|test)", body, re.IGNORECASE):
524
+ warnings.append("No acceptance criteria.")
525
+ if re.search(r"tailwind|className.*flex|className.*grid", body, re.IGNORECASE) and not re.search(
526
+ r"<Box|<Stack|<Grid|primitives|design.tokens", body, re.IGNORECASE
527
+ ):
528
+ warnings.append("Mentions Tailwind but not layout primitives.")
529
+
530
+ # Spec-Driven Issue Validation
531
+ missing_spec_sections = [s for s in SPEC_SECTIONS if not self._has_spec_section(s, body)]
532
+ if missing_spec_sections:
533
+ findings.append(f"Missing spec-driven sections: {', '.join(f'`{s}`' for s in missing_spec_sections)}")
534
+
535
+ issue_result = {
536
+ "number": issue.number,
537
+ "title": title,
538
+ "findings": findings,
539
+ "warnings": warnings,
540
+ }
541
+ results.append(issue_result)
542
+ total_findings += len(findings)
543
+ if post_comments and (findings or warnings):
544
+ comment = "## 🤖 Issue Quality Review\n\n"
545
+ if findings:
546
+ comment += "### ❌ Violations\n" + "\n".join(f"- {f}" for f in findings) + "\n\n"
547
+ if warnings:
548
+ comment += "### ⚠️ Warnings\n" + "\n".join(f"- {w}" for w in warnings) + "\n"
549
+ if not dry_run:
550
+ issue.create_comment(f"{comment}\n---\n*Generated by `{PROJECT_CONFIG.cli_alias} validate-issue`*")
551
+
552
+ return {
553
+ "status": "success" if total_findings == 0 else "error",
554
+ "issues": results,
555
+ "total_findings": total_findings,
556
+ }
557
+
558
+ def handle_detect_conflicts(self, pr_num: Optional[int] = None) -> List[Dict[str, Any]]:
559
+ conflicts = self.detect_conflicts(pr_num)
560
+ formatted = []
561
+ for pr_pair, files in conflicts.items():
562
+ formatted.append({"prs": list(pr_pair), "files": files})
563
+ return formatted
564
+
565
+ def handle_status_board(self, limit: int = 10) -> List[Dict[str, Any]]:
566
+ # Use our custom GitHubClient which implements disk caching
567
+ prs = self.github.list_pull_requests(state="open", limit=limit)
568
+ prs_data = []
569
+ for pr in prs:
570
+ branch = pr.get("headRefName") or ""
571
+ m = re.search(r"issue-(\d+)", branch)
572
+ issue = f"#{m.group(1)}" if m else "—"
573
+ status = "Draft" if pr.get("isDraft") else "Open"
574
+ prs_data.append({"branch": branch, "issue": issue, "status": status, "number": pr.get("number")})
575
+ return prs_data
576
+
577
+ def ratchet_any(
578
+ self, update: bool = False, baseline_file: Optional[str] = None, dry_run: bool = True
579
+ ) -> Dict[str, Any]:
580
+ current = get_any_count()
581
+ baseline = self.resolve_baseline(baseline_file, "ANY_COUNT_BASELINE", 0)
582
+ res = {
583
+ "current": current,
584
+ "baseline": baseline,
585
+ "status": "success" if current <= baseline else "error",
586
+ }
587
+ if current > baseline:
588
+ res["message"] = f"'any' count increased from {baseline} to {current}."
589
+ if update:
590
+ if not dry_run:
591
+ if baseline_file:
592
+ with open(baseline_file, "w", encoding="utf-8") as f:
593
+ f.write(str(current))
594
+ else:
595
+ set_gha_variable("ANY_COUNT_BASELINE", str(current))
596
+ res["updated"] = not dry_run
597
+ return res
598
+
599
+ def check_bundle_size(
600
+ self,
601
+ update: bool = False,
602
+ baseline_file: Optional[str] = None,
603
+ threshold: int = 50,
604
+ dry_run: bool = True,
605
+ ) -> Dict[str, Any]:
606
+ size = get_bundle_size()
607
+ baseline = self.resolve_baseline(baseline_file, "BUNDLE_BASELINE_KB", 3080)
608
+ threshold_kb = baseline + threshold
609
+ res = {
610
+ "size_kb": size,
611
+ "baseline_kb": baseline,
612
+ "threshold_kb": threshold_kb,
613
+ "status": "success" if size <= threshold_kb else "error",
614
+ }
615
+ if size > threshold_kb:
616
+ res["message"] = f"Bundle size exceeds threshold ({size}KB > {threshold_kb}KB)."
617
+ if update:
618
+ if not dry_run:
619
+ if baseline_file:
620
+ with open(baseline_file, "w", encoding="utf-8") as f:
621
+ f.write(str(size))
622
+ else:
623
+ set_gha_variable("BUNDLE_BASELINE_KB", str(size))
624
+ res["updated"] = not dry_run
625
+ return res
626
+
627
+ def migrate_tokens(
628
+ self,
629
+ find: Optional[str] = None,
630
+ migrate: Optional[Tuple[str, str]] = None,
631
+ dry_run: bool = True,
632
+ ) -> List[Dict[str, Any]]:
633
+ root_dir = "src"
634
+ matches = []
635
+ if find:
636
+ for filepath in walk_tsx(root_dir):
637
+ findings = find_patterns_in_file(filepath, [(re.escape(find), "Found")])
638
+ for ln, _, content in findings:
639
+ matches.append({"file": filepath, "line": ln, "content": content.strip()})
640
+ elif migrate:
641
+ old, new = migrate
642
+ for filepath in walk_tsx(root_dir):
643
+ with open(filepath, "r", encoding="utf-8") as f:
644
+ c = f.read()
645
+ if old in c:
646
+ matches.append({"file": filepath})
647
+ if not dry_run:
648
+ with open(filepath, "w", encoding="utf-8") as f:
649
+ f.write(c.replace(old, new))
650
+ return matches
651
+
652
+ def update_issues(self, dry_run: bool = True) -> List[Dict[str, Any]]:
653
+ repo = get_github_client().get_repo(get_repo_name())
654
+ updates = []
655
+ audit_base = self.get_audit_results(content="")
656
+ config = audit_base.get("config", {})
657
+ deprecated = config.get("deprecated", {})
658
+ for issue in repo.get_issues(state="open"):
659
+ body = issue.body or ""
660
+ findings = []
661
+ for old, new in deprecated.get("assets", {}).items():
662
+ if old in body:
663
+ findings.append(f"References deprecated name `{old}`. Use `{new}` instead.")
664
+ for old, new in deprecated.get("paths", {}).items():
665
+ if old in body:
666
+ findings.append(f"References deprecated path `{old}`. New location: `{new}`")
667
+ res = self.get_audit_results(content=body)
668
+ violations = res.get("violations", {}).get("stdin", [])
669
+ for v in violations:
670
+ findings.append(f"Contains banned pattern: {v['message']} (value: {v.get('value', 'N/A')})")
671
+ if findings:
672
+ updates.append({"number": issue.number, "findings": findings})
673
+ if not dry_run:
674
+ findings_list = "\n".join(f"- {f}" for f in findings)
675
+ cli_alias = PROJECT_CONFIG.cli_alias
676
+ issue.create_comment(
677
+ f"## 🤖 Automated Issue Update\n\n{findings_list}\n\n---\n*Generated by `{cli_alias} update-issues`*"
678
+ )
679
+ return updates
680
+
681
+ def audit_pr(
682
+ self,
683
+ prNumber: int,
684
+ fetch: bool = False,
685
+ audit: bool = False,
686
+ submit: bool = False,
687
+ cleanup: bool = False,
688
+ dry_run: bool = True,
689
+ event: Optional[str] = None,
690
+ **kwargs,
691
+ ) -> Dict[str, Any]:
692
+ if "pr_number" in kwargs:
693
+ prNumber = kwargs["pr_number"]
694
+ review_dir = get_or_create_log_dir("reviews")
695
+ ctx_path = os.path.join(review_dir, f"pr-context-{prNumber}.md")
696
+ rev_path = os.path.join(review_dir, f"pr-review-{prNumber}.md")
697
+ res = {"pr": prNumber, "files": {}}
698
+ if fetch:
699
+ repo = get_github_client().get_repo(get_repo_name())
700
+ pr = repo.get_pull(prNumber)
701
+ title = pr.title
702
+ author = pr.user.login
703
+ desc = pr.body or "_No description provided._"
704
+ context_lines = [
705
+ f"# PR Context: #{pr.number} — {title}",
706
+ f"**Author:** @{author}\n",
707
+ f"## Description\n{desc}\n",
708
+ "## CI Status",
709
+ ]
710
+
711
+ check_runs = self.github.fetch_check_runs(pr.head.sha)
712
+ failed_check_names = []
713
+ detected_errors = []
714
+ if check_runs:
715
+ for run in check_runs:
716
+ status_icon = (
717
+ "✅"
718
+ if run.get("conclusion") == "success"
719
+ else "❌" if run.get("conclusion") == "failure" else "⏳"
720
+ )
721
+ context_lines.append(
722
+ f"- {status_icon} **{run.get('name')}**: {run.get('status')} ({run.get('conclusion') or 'in_progress'})"
723
+ )
724
+ if run.get("conclusion") == "failure":
725
+ failed_check_names.append(run.get("name"))
726
+ run_id = run.get("id")
727
+ findings: List[Dict[str, Any]] = []
728
+ if isinstance(run_id, int):
729
+ logs = self.github.fetch_check_run_logs(run_id, external_id=run.get("external_id"))
730
+
731
+ # Structured failure analysis
732
+ findings = extract_failing_info(logs)
733
+ if findings:
734
+ context_lines.append(" **Failing Tests/Build Errors:**")
735
+ for f in findings:
736
+ error_msg = f"🔴 `{f['file']}:{f['line']}`: {f['message']} ({f['type']})"
737
+ context_lines.append(f" - {error_msg}")
738
+ detected_errors.append(error_msg)
739
+
740
+ # Extract a snippet of the logs (last 50 lines or search for 'error')
741
+ cleaned_logs = clean_gha_logs(logs)
742
+ log_lines = cleaned_logs.splitlines()
743
+ error_lines = [
744
+ l
745
+ for l in log_lines
746
+ if any(x in l.lower() for x in ["error", "fail", "ts", "vitest", "playwright", "🔴"])
747
+ ]
748
+ snippet = "\n".join(error_lines[-20:] if error_lines else log_lines[-30:])
749
+ context_lines.append(
750
+ f" <details><summary>Failure Logs Snippet</summary>\n\n ```\n {snippet}\n ```\n </details>"
751
+ )
752
+ else:
753
+ context_lines.append("_No check runs found._")
754
+
755
+ context_lines.extend(["\n## Files Changed"])
756
+ for f in pr.get_files():
757
+ context_lines.append(
758
+ f"- {'🟢' if f.status == 'added' else '🔴' if f.status == 'removed' else '🟡'} `{f.filename}`"
759
+ )
760
+ context_lines.append("\n## Diffs")
761
+ for f in pr.get_files():
762
+ context_lines.append(f"\n### `{f.filename}` ({f.status})")
763
+ patch = f.patch or "_No textual diff available._"
764
+ annotated = []
765
+ line_num = 0
766
+ if patch != "_No textual diff available._":
767
+ for line in patch.splitlines():
768
+ if line.startswith("@@"):
769
+ m = re.search(r"\+(\d+)", line)
770
+ line_num = int(m.group(1)) if m else line_num
771
+ annotated.append(line)
772
+ elif line.startswith("+"):
773
+ annotated.append(f"{line_num:4d} |{line}")
774
+ line_num += 1
775
+ elif line.startswith("-"):
776
+ annotated.append(f" |{line}")
777
+ else:
778
+ annotated.append(f"{line_num:4d} |{line}")
779
+ line_num += 1
780
+ context_lines.append(f"```diff\n" + "\n".join(annotated) + "\n```")
781
+ with open(ctx_path, "w", encoding="utf-8") as context_file:
782
+ context_file.write("\n".join(context_lines))
783
+
784
+ failed_checks_str = (
785
+ "\n".join(f"- {name}" for name in failed_check_names) if failed_check_names else "_None_"
786
+ )
787
+ errors_str = (
788
+ "\n".join(f"- {err}" for err in detected_errors) if detected_errors else "_None detected by parser._"
789
+ )
790
+
791
+ template_path = resolve_resource_path("review_template.md")
792
+ with open(template_path, "r", encoding="utf-8") as template_file:
793
+ template = template_file.read().format(
794
+ pr_num=prNumber,
795
+ head_sha=pr.head.sha,
796
+ failed_checks=failed_checks_str,
797
+ detected_errors=errors_str,
798
+ )
799
+
800
+ with open(rev_path, "w", encoding="utf-8") as review_file:
801
+ review_file.write(template)
802
+ res["files"]["context"] = ctx_path
803
+ res["files"]["review"] = rev_path
804
+ if audit:
805
+ if not os.path.exists(ctx_path):
806
+ raise CLIError(f"Context file missing: {ctx_path}")
807
+ with open(ctx_path, "r", encoding="utf-8") as context_file:
808
+ context = context_file.read()
809
+ changed_files = re.findall(r"### `([^`]+)`", context)
810
+ auto_findings = []
811
+ scope_warning = verify_pr_scope(changed_files)
812
+ if scope_warning:
813
+ auto_findings.append({"path": "PR SCOPE", "issue": scope_warning, "severity": "major"})
814
+ files_to_audit = [
815
+ f for f in changed_files if (f.endswith(".tsx") or f.endswith(".ts")) and os.path.exists(f)
816
+ ]
817
+ if files_to_audit:
818
+ audit_res = run_command(
819
+ ["node", "boomtick-pkg/scripts/detect-antipatterns.mjs", "--json"] + files_to_audit,
820
+ check=False,
821
+ )
822
+ output = audit_res.stdout if isinstance(audit_res, subprocess.CompletedProcess) else str(audit_res)
823
+ if output and "{" in output:
824
+ json_start = output.find("{")
825
+ json_end = output.rfind("}") + 1
826
+ try:
827
+ audit_data: Any = json.loads(output[json_start:json_end])
828
+ # Ensure audit_data is a dictionary, and recursively parse if it's a stringified JSON
829
+ if isinstance(audit_data, str):
830
+ try:
831
+ audit_data = json.loads(audit_data)
832
+ except json.JSONDecodeError:
833
+ audit_data = {}
834
+ except json.JSONDecodeError:
835
+ audit_data = {}
836
+
837
+ if isinstance(audit_data, dict):
838
+ violations_map = audit_data.get("violations", {})
839
+ if isinstance(violations_map, dict):
840
+ for filepath, violations in violations_map.items():
841
+ if isinstance(violations, list):
842
+ for v in violations:
843
+ if isinstance(v, dict):
844
+ auto_findings.append(
845
+ {
846
+ "path": str(filepath),
847
+ "issue": f"{v.get('pattern', 'N/A')}: {v.get('message', 'No message')} (value: {v.get('value', 'N/A')})",
848
+ "severity": str(v.get("severity", "minor")),
849
+ }
850
+ )
851
+ res["auto_findings"] = auto_findings
852
+ if submit:
853
+ self.github.submit_pr_review(prNumber, rev_path, cleanup=cleanup, dry_run=dry_run, event_override=event)
854
+ return res
855
+
856
+ def handle_comment_command(self, prNumber: int, command: str, comment_id: Optional[str] = None) -> Dict[str, Any]:
857
+ """
858
+ Delegates command handling to the CommandHandler.
859
+ """
860
+ handler = CommandHandler(self)
861
+ return handler.handle(prNumber, command, comment_id)
862
+
863
+ def parse_comment(self, body: str, author_association: str) -> Dict[str, Any]:
864
+ """
865
+ Parses a comment body and returns the intended actions.
866
+ Consolidates detection logic using regex patterns with word boundaries.
867
+ """
868
+ results = {k: bool(re.search(v, body)) for k, v in self._CMD_PATTERNS.items()}
869
+
870
+ return {
871
+ "conflict_resolve": results["conflict_resolve"],
872
+ "update_snapshots": results["update_snapshots"],
873
+ "ai_chatops": results["ai_fix"] or results["ai_review"],
874
+ "jules_fix_ci": results["jules_fix_ci"] and author_association in ["OWNER", "MEMBER", "COLLABORATOR"],
875
+ }
876
+
877
+ def runtime_check(self) -> Dict[str, str]:
878
+ """Ensures the runtime environment matches the contract."""
879
+ run_command(["corepack", "enable"], check=False)
880
+ run_command(["corepack", "prepare", "pnpm@10.28.2", "--activate"], check=False)
881
+
882
+ # Mirror boomtick-pkg/scripts/check-runtime.mjs logic in Python
883
+ try:
884
+ with open(".node-version", "r", encoding="utf-8") as f:
885
+ expected_node = f.read().strip().replace("v", "")
886
+ except FileNotFoundError:
887
+ try:
888
+ with open(".nvmrc", "r", encoding="utf-8") as f:
889
+ expected_node = f.read().strip().replace("v", "")
890
+ except FileNotFoundError:
891
+ expected_node = "24.16.0"
892
+
893
+ actual_node_res = run_command(["node", "-v"])
894
+ actual_node = str(actual_node_res).strip().replace("v", "")
895
+ is_ci = os.environ.get("CI") == "true"
896
+ is_jules = "jules" in os.environ.get("USER", "").lower() or os.environ.get("JULES_API_KEY")
897
+
898
+ expected_prefix = ".".join(expected_node.split(".")[:2]) + "."
899
+ node_matches = (actual_node.startswith(expected_prefix) or is_jules) if is_ci else actual_node == expected_node
900
+
901
+ if not node_matches and not is_jules:
902
+ log_error(f"Node version mismatch\nExpected: {expected_node}\nActual: {actual_node}")
903
+ raise CLIError("Node version mismatch. Do not switch versions manually.")
904
+
905
+ manifest_path = "package.json"
906
+ if not os.path.exists(manifest_path) and os.path.exists("workspace.json"):
907
+ manifest_path = "workspace.json"
908
+
909
+ if os.path.exists(manifest_path):
910
+ with open(manifest_path, "r", encoding="utf-8") as f:
911
+ pkg = json.load(f)
912
+ expected_pnpm = pkg.get("packageManager", "").replace("pnpm@", "") or "10.28.2"
913
+ else:
914
+ expected_pnpm = "10.28.2"
915
+
916
+ actual_pnpm_res = run_command(["pnpm", "--version"])
917
+ actual_pnpm = str(actual_pnpm_res).strip()
918
+
919
+ if not actual_pnpm or actual_pnpm != expected_pnpm:
920
+ log_error(f"pnpm version mismatch\nExpected: {expected_pnpm}\nActual: {actual_pnpm}")
921
+ raise CLIError(f"Run: corepack enable && corepack prepare pnpm@{expected_pnpm} --activate")
922
+
923
+ return {"node": actual_node, "pnpm": actual_pnpm}
924
+
925
+ def generate_ci_summary_report(self) -> str:
926
+ """Generates a markdown summary of CI metrics."""
927
+ metrics_res = verify_ci_metrics()
928
+
929
+ report = ["## 📊 CI Metrics Verification"]
930
+
931
+ if metrics_res["status"] == "error":
932
+ report.append(f"❌ **FAILED**: {metrics_res['message']}")
933
+ elif metrics_res["status"] == "warning":
934
+ report.append(f"⚠️ **WARNING**: {metrics_res['message']}")
935
+ else:
936
+ report.append("✅ **PASSED**: All metrics within limits.")
937
+
938
+ if "metrics" in metrics_res:
939
+ m = metrics_res["metrics"]
940
+ report.append("\n### AI Token Usage")
941
+ report.append(f"- **Input:** {m['inputTokens']} / {m['inputThreshold']}")
942
+ report.append(f"- **Output:** {m['outputTokens']} / {m['outputThreshold']}")
943
+ report.append(f"- **Total:** {m['totalTokens']} / {m['totalThreshold']}")
944
+
945
+ report.append("\n<details><summary>Raw Metrics JSON</summary>\n")
946
+ report.append("```json")
947
+ report.append(json.dumps(metrics_res, indent=2))
948
+ report.append("```\n</details>")
949
+
950
+ return "\n".join(report)
951
+
952
+ def pre_submit_checks(self) -> Dict[str, Any]:
953
+ results: Dict[str, Any] = {"steps": []}
954
+
955
+ # 1. Runtime Check (Fail Fast)
956
+ try:
957
+ self.runtime_check()
958
+ results["steps"].append({"name": "Runtime Check", "status": "success"})
959
+ except CLIError as e:
960
+ results["steps"].append({"name": "Runtime Check", "status": "failure", "error": str(e)})
961
+ raise e
962
+
963
+ # 2. Automated Validation Steps
964
+ steps = [
965
+ ("Anti-Pattern Audit", ["node", "boomtick-pkg/scripts/detect-antipatterns.mjs"]),
966
+ ("Version Downgrade Check", [PROJECT_CONFIG.cli_alias, "gh", "verify-versions"]),
967
+ ("TypeScript", ["pnpm", "run", "type-check"]),
968
+ ("Lint", ["pnpm", "run", "lint"]),
969
+ ]
970
+
971
+ for name, cmd in steps:
972
+ log_info(f"Running check: {name} ({' '.join(cmd)})")
973
+ try:
974
+ run_command(cmd)
975
+ results["steps"].append({"name": name, "status": "success"})
976
+ except CLIError as e:
977
+ results["steps"].append({"name": name, "status": "failure", "error": str(e)})
978
+ raise e
979
+ missing_vars = [
980
+ v for v in ["BUNDLE_BASELINE_KB", "ANY_COUNT_BASELINE"] if not (os.environ.get(v) or get_gha_variable(v))
981
+ ]
982
+ if missing_vars:
983
+ results["steps"].append(
984
+ {
985
+ "name": "Baseline Check",
986
+ "status": "warning",
987
+ "message": f"Missing GHA variables: {', '.join(missing_vars)}",
988
+ }
989
+ )
990
+ else:
991
+ results["steps"].append({"name": "Baseline Check", "status": "success"})
992
+ scope_warning = verify_pr_scope()
993
+ if scope_warning:
994
+ results["steps"].append({"name": "PR Scope Check", "status": "warning", "message": scope_warning})
995
+ conflicts = self.detect_conflicts()
996
+ results["conflicts"] = [{"prs": list(p), "files": f} for p, f in conflicts.items()]
997
+ return results
998
+
999
+ def repair_local(
1000
+ self, logs_path: Optional[str] = None, stdin: bool = False, worktree: bool = False
1001
+ ) -> Dict[str, Any]:
1002
+ logs_content = ""
1003
+ if stdin:
1004
+ logs_content = sys.stdin.read()
1005
+ elif logs_path:
1006
+ if os.path.exists(logs_path):
1007
+ with open(logs_path, "r", encoding="utf-8") as f:
1008
+ logs_content = f.read()
1009
+ else:
1010
+ raise CLIError(f"Log file not found: {logs_path}")
1011
+ else:
1012
+ res_lint = run_command(["pnpm", "run", "lint:ox"], check=False)
1013
+ res_tsc = run_command(["pnpm", "run", "type-check"], check=False)
1014
+ logs_content = res_lint.stdout + res_lint.stderr + "\n" + res_tsc.stdout + res_tsc.stderr
1015
+ if not logs_content.strip():
1016
+ return {"status": "success", "message": "No errors found."}
1017
+ original_cwd = os.getcwd()
1018
+ repair_script = os.path.abspath(os.path.join(original_cwd, "dev-tools", "repair.py"))
1019
+ worktree_path = None
1020
+ branch_name = None
1021
+ try:
1022
+ branch_name = f"repair/local-{datetime.now().strftime('%H%M%S')}"
1023
+ prefix = PROJECT_CONFIG.worktree_prefix
1024
+ # Create temporary worktree within repo root to avoid Security Error
1025
+ worktree_path = os.path.join(original_cwd, f"{prefix}{datetime.now().strftime('%H%M%S')}")
1026
+ os.makedirs(worktree_path, exist_ok=True)
1027
+ run_command(["git", "worktree", "add", "-b", branch_name, worktree_path, "HEAD"])
1028
+ os.chdir(worktree_path)
1029
+ if os.path.exists(os.path.join(original_cwd, "node_modules")):
1030
+ os.symlink(
1031
+ os.path.join(original_cwd, "node_modules"),
1032
+ os.path.join(worktree_path, "node_modules"),
1033
+ )
1034
+ # Create temporary log file within repo root logs/
1035
+ log_dir = get_or_create_log_dir("repair")
1036
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".log", delete=False, dir=log_dir) as tmp_log:
1037
+ tmp_log.write(logs_content)
1038
+ tmp_log_path = tmp_log.name
1039
+ cmd = [sys.executable, repair_script, tmp_log_path]
1040
+ proc = run_command(cmd, check=False)
1041
+ os.unlink(tmp_log_path)
1042
+ if proc.returncode == 0:
1043
+ return {
1044
+ "status": "success",
1045
+ "message": "Repair completed.",
1046
+ "worktree": worktree_path,
1047
+ "branch": branch_name,
1048
+ }
1049
+ else:
1050
+ return {"status": "error", "message": f"Repair failed with code {proc.returncode}"}
1051
+ finally:
1052
+ os.chdir(original_cwd)
1053
+
1054
+ def handle_audit_gate(self) -> Dict[str, Any]:
1055
+ current_count = int(run_command(["node", "boomtick-pkg/scripts/detect-antipatterns.mjs", "--count-only"]) or 0)
1056
+ baseline_count = self.resolve_baseline(None, "AUDIT_BASELINE", -1)
1057
+
1058
+ is_shallow = run_command(["git", "rev-parse", "--is-shallow-repository"], check=False).stdout.strip() == "true"
1059
+
1060
+ if baseline_count == -1 or is_shallow:
1061
+ val = self.get_env_or_gha("AUDIT_BASELINE")
1062
+ if val:
1063
+ return {
1064
+ "current": current_count,
1065
+ "baseline": int(val),
1066
+ "status": "success" if current_count <= int(val) else "error",
1067
+ }
1068
+
1069
+ if is_shallow:
1070
+ # In a shallow clone, git ls-tree/show on base branch will fail or be incomplete.
1071
+ log_warn("Shallow repository detected and no AUDIT_BASELINE variable found. Falling back to 0.")
1072
+ return {
1073
+ "current": current_count,
1074
+ "baseline": 0,
1075
+ "status": "success" if current_count <= 0 else "error",
1076
+ }
1077
+
1078
+ baseline_count = 0
1079
+ # Robust base branch discovery: try config, then origin/main, then main
1080
+ base_candidates = [PROJECT_CONFIG.base_branch, "origin/main", "main"]
1081
+ base_ref = None
1082
+ for cand in base_candidates:
1083
+ if (
1084
+ cand
1085
+ and run_command(["git", "rev-parse", "--verify", cand], check=False, log_on_error=False).returncode
1086
+ == 0
1087
+ ):
1088
+ base_ref = cand
1089
+ break
1090
+
1091
+ if not base_ref:
1092
+ log_warn("Could not determine base branch for audit baseline. Falling back to 0.")
1093
+ return {
1094
+ "current": current_count,
1095
+ "baseline": 0,
1096
+ "status": "success" if current_count <= 0 else "error",
1097
+ }
1098
+
1099
+ base_files = run_command(["git", "ls-tree", "-r", base_ref, "--name-only"]).splitlines()
1100
+ # Ensure AUDIT_CHECK_DIRS are handled as a list of prefixes
1101
+ relevant = [
1102
+ mf
1103
+ for mf in base_files
1104
+ if (mf.endswith(".tsx") or mf.endswith(".ts"))
1105
+ and any(mf == d or mf.startswith(d + "/") for d in AUDIT_CHECK_DIRS)
1106
+ ]
1107
+ for mf in relevant:
1108
+ res_show = run_command(["git", "show", f"{base_ref}:{mf}"], check=False, log_on_error=False)
1109
+ if res_show.returncode == 0:
1110
+ baseline_count += int(
1111
+ run_command(
1112
+ [
1113
+ "node",
1114
+ "boomtick-pkg/scripts/detect-antipatterns.mjs",
1115
+ "--count-only",
1116
+ "-",
1117
+ ],
1118
+ input_str=res_show.stdout,
1119
+ )
1120
+ or 0
1121
+ )
1122
+ return {
1123
+ "current": current_count,
1124
+ "baseline": baseline_count,
1125
+ "status": "success" if current_count <= baseline_count else "error",
1126
+ }
1127
+
1128
+ def fix_ci(
1129
+ self,
1130
+ prNumber: Optional[int] = None,
1131
+ issueNumber: Optional[int] = None,
1132
+ branch: Optional[str] = None,
1133
+ api_key: Optional[str] = None,
1134
+ dry_run: bool = True,
1135
+ **kwargs,
1136
+ ) -> Dict[str, Any]:
1137
+ """
1138
+ Initializes an autonomous repair session (Jules) to fix CI failures.
1139
+ Supports both Pull Request (prNumber) and Issue (issueNumber) contexts.
1140
+ If branch is not provided, it defaults to the current local branch or matches the PR/Issue.
1141
+ """
1142
+ if "pr_number" in kwargs and prNumber is None:
1143
+ prNumber = kwargs["pr_number"]
1144
+ if "issue_number" in kwargs and issueNumber is None:
1145
+ issueNumber = kwargs["issue_number"]
1146
+ repo_name = get_repo_name()
1147
+ g = get_github_client()
1148
+ repo = g.get_repo(repo_name)
1149
+
1150
+ pr = None
1151
+ issue_details = None
1152
+
1153
+ if prNumber:
1154
+ pr = repo.get_pull(int(prNumber))
1155
+ branch = pr.head.ref
1156
+ elif issueNumber:
1157
+ # issueNumber might be a PR or a regular issue
1158
+ try:
1159
+ # Try fetching as PR first as it's the common case for fix-ci
1160
+ pr = repo.get_pull(int(issueNumber))
1161
+ branch = pr.head.ref
1162
+ except Exception:
1163
+ issue_details = self.github.fetch_issue_details(int(issueNumber))
1164
+ if "pull_request" in issue_details:
1165
+ pr = repo.get_pull(int(issueNumber))
1166
+ branch = pr.head.ref
1167
+
1168
+ if not pr and not branch:
1169
+ branch = run_command(["git", "branch", "--show-current"]).strip()
1170
+ pulls = list(repo.get_pulls(state="open", head=f"{repo.owner.login}:{branch}"))
1171
+ pr = pulls[0] if pulls else None
1172
+
1173
+ if not pr and not issue_details:
1174
+ raise CLIError(f"Could not find PR or issue for branch {branch}")
1175
+
1176
+ if api_key:
1177
+ self.jules.api_key = api_key
1178
+
1179
+ failing_logs: List[str] = []
1180
+ structured_failures = []
1181
+
1182
+ target_sha = pr.head.sha if pr else None
1183
+ if not target_sha and branch:
1184
+ try:
1185
+ # Try to get the latest SHA for the branch if no PR exists (e.g. main branch failure)
1186
+ # pylint: disable=protected-access
1187
+ branch_info = self.github._request("GET", f"/repos/{self.github.repo}/branches/{branch}")
1188
+ target_sha = branch_info.get("commit", {}).get("sha")
1189
+ except Exception:
1190
+ pass
1191
+
1192
+ if target_sha:
1193
+ # Analyze failing check runs
1194
+ check_runs = self.github.fetch_check_runs(target_sha)
1195
+ for run in check_runs:
1196
+ if run.get("conclusion") == "failure":
1197
+ run_id = run.get("id")
1198
+ logs = ""
1199
+ if isinstance(run_id, int):
1200
+ logs = self.github.fetch_check_run_logs(run_id, external_id=run.get("external_id"))
1201
+
1202
+ # Clean logs and take a smart snippet
1203
+ cleaned_logs = clean_gha_logs(logs)
1204
+
1205
+ # Prioritize lines with error signatures
1206
+ important_lines = []
1207
+ for line in cleaned_logs.splitlines():
1208
+ if any(x in line.lower() for x in ["error", "fail", "ts", "vitest", "playwright", "🔴"]):
1209
+ important_lines.append(line)
1210
+
1211
+ if important_lines:
1212
+ snippet = "\n".join(important_lines[-30:]) # Keep last 30 important lines
1213
+ else:
1214
+ snippet = cleaned_logs[-2000:] # Fallback to tail of cleaned logs
1215
+
1216
+ failing_logs.append(f"Check Run: {run.get('name')}\nLogs:\n{snippet}")
1217
+
1218
+ findings = extract_failing_info(logs)
1219
+ for f in findings:
1220
+ structured_failures.append(
1221
+ f"File: {f['file']}, Line: {f['line']}, Error: {f['message']} ({f['type']})"
1222
+ )
1223
+
1224
+ base_branch = PROJECT_CONFIG.base_branch
1225
+ base_branch_name = PROJECT_CONFIG.base_branch_name
1226
+
1227
+ prompt = f"""# Agent Prompt: Self-Review, Fix, and Publish PR
1228
+
1229
+ You are a senior engineering agent reviewing your own branch before publishing.
1230
+
1231
+ Compare the current branch against `{base_branch_name}`, identify issues, fix them directly, validate the result, and open or update a pull request. Do not stop after giving recommendations.
1232
+
1233
+ ## Rules
1234
+
1235
+ - Do not ask for confirmation before making fixes.
1236
+ - Do not ask the user to run commands.
1237
+ - Do not stop until you have opened or updated a PR.
1238
+ - Do not make unrelated refactors.
1239
+ - Do not publish with known failing checks unless the failure is clearly unrelated and documented.
1240
+ - If local setup prevents a check from running, document the attempted command, the setup gap, and the follow-up needed.
1241
+
1242
+ ## Steps
1243
+
1244
+ 1. Check branch state with `git status`, `git branch --show-current`, `git remote -v`, and `git fetch origin {base_branch_name}`.
1245
+ 2. Review the full diff with `git diff {base_branch}...HEAD`, `git diff --stat {base_branch}...HEAD`, `git log --oneline {base_branch}..HEAD`, and `git diff --cached`.
1246
+ 3. Create a checklist covering correctness, edge cases, TypeScript/imports, dead code, UI/mobile behavior, accessibility, validation, repo hygiene, and PR description quality.
1247
+ 4. Fix the issues directly.
1248
+ 5. Validate using the repo scripts from `package.json`, such as lint, typecheck, test, and build.
1249
+ - For CI remediation, favor targeted testing (e.g., `pnpm run test:e2e:targeted -- <args>`) and represent failures using the structured schema described in `docs/agent/ci-remediation.md`.
1250
+ 6. If validation fails, fix the root cause and rerun the failing check. If the environment blocks a check, document the exact command and reason.
1251
+ 7. Final review with `git status`, `git diff {base_branch}...HEAD`, `git diff --stat {base_branch}...HEAD`, and a search for TODO/FIXME/debug leftovers.
1252
+ 8. Commit, push, and create or update the PR with a clear summary and validation notes.
1253
+
1254
+ ## Final response
1255
+
1256
+ Respond only after the PR is created or updated:
1257
+
1258
+ - PR link
1259
+ - Changes made
1260
+ - Self-review fixes
1261
+ - Validation results
1262
+ - Notes or documented limitations"""
1263
+
1264
+ if issue_details:
1265
+ # Wrap issue context in clear delimiters to mitigate prompt injection risk from untrusted issue content
1266
+ prompt += f"\n\n## External Issue Context (Untrusted)\n\n<issue-context>\nTitle: {issue_details.get('title')}\n\n{issue_details.get('body')}\n</issue-context>"
1267
+
1268
+ if structured_failures:
1269
+ prompt += "\n\n## CI Failure Analysis\n\nStructured Failure Analysis:\n- " + "\n- ".join(
1270
+ [str(f) for f in structured_failures]
1271
+ )
1272
+
1273
+ if failing_logs:
1274
+ prompt += "\n\nDetailed Failing Logs (Snippets):\n" + "\n---\n".join(failing_logs)
1275
+
1276
+ agent_name = "Jules"
1277
+ source_id = self.get_env_or_gha("JULES_SOURCE_ID") or self.jules.discover_source_id(repo_name or "")
1278
+ if not source_id:
1279
+ raise CLIError("JULES_SOURCE_ID missing and auto-discovery failed.")
1280
+ session_name = "dry-run-session"
1281
+ if not dry_run:
1282
+ res = self.jules.create_session_from_source(source_id, branch or "", prompt)
1283
+ if res and isinstance(res, dict):
1284
+ session_name = str(res.get("name", "unknown"))
1285
+ else:
1286
+ raise CLIError(f"{agent_name} API session creation failed")
1287
+ feedback = f"🤖 **{agent_name} is on it!**\n\nInitialized autonomous repair session (`{session_name}`) for branch `{branch}`."
1288
+ if not dry_run:
1289
+ if pr:
1290
+ pr.create_issue_comment(feedback)
1291
+ elif issueNumber:
1292
+ self.post_comment(int(issueNumber), feedback)
1293
+ return {"session": session_name, "branch": branch, "feedback": feedback, "agent_name": agent_name}
1294
+
1295
+ def manage_reviews(
1296
+ self,
1297
+ check_responses: bool = False,
1298
+ cleanup_comments: bool = False,
1299
+ dry_run: bool = True,
1300
+ limit: int = 10,
1301
+ ) -> List[Dict[str, Any]]:
1302
+ g = get_github_client()
1303
+ repo = g.get_repo(get_repo_name())
1304
+ login = g.get_user().login
1305
+ prs_data = []
1306
+ # PyGithub get_pulls handles per_page internally. Directly slice the paginated list.
1307
+ for pr in repo.get_pulls(state="open", sort="updated", direction="desc")[:limit]:
1308
+ last_review = next((r for r in pr.get_reviews().reversed if r.user.login == login), None)
1309
+ status = (
1310
+ "ACTION: Needs Review"
1311
+ if not last_review
1312
+ else (f"ACTION: Needs Re-Review" if last_review.commit_id != pr.head.sha else "STATE: Up-To-Date")
1313
+ )
1314
+ item = {"number": pr.number, "title": pr.title, "status": status, "unaddressed": []}
1315
+ if check_responses:
1316
+ our_coms = [c for c in pr.get_review_comments() if c.user.login == login]
1317
+ after_coms = [
1318
+ c
1319
+ for c in pr.get_review_comments()
1320
+ if c.user.login != login and any(c.in_reply_to_id == oc.id for oc in our_coms)
1321
+ ]
1322
+ if our_coms and not after_coms:
1323
+ item["unaddressed"] = [f"{c.path}:{c.position}" for c in our_coms]
1324
+ if cleanup_comments:
1325
+ for c in pr.get_issue_comments():
1326
+ if c.user.login == login and "<!-- td-review-manager-comment -->" in c.body:
1327
+ if not dry_run:
1328
+ c.delete()
1329
+ prs_data.append(item)
1330
+ return prs_data
1331
+
1332
+ def track_review(self, pr_num: int, status: str, auditor: str, dry_run: bool = True) -> Dict[str, Any]:
1333
+ tracking_file = "REVIEW_TRACKING.md"
1334
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
1335
+ content = (
1336
+ open(tracking_file, encoding="utf-8").read()
1337
+ if os.path.exists(tracking_file)
1338
+ else "# PR Review Tracking\n\n| PR | Status | Auditor | Last Updated |\n|----|--------|---------|--------------|\n"
1339
+ )
1340
+ lines = content.splitlines()
1341
+ new_lines = []
1342
+ found = False
1343
+ for line in lines:
1344
+ if line.startswith("|") and f"| #{pr_num} |" in line:
1345
+ new_lines.append(f"| #{pr_num} | {status} | {auditor} | {now} |")
1346
+ found = True
1347
+ else:
1348
+ new_lines.append(line)
1349
+ if not found:
1350
+ new_lines.append(f"| #{pr_num} | {status} | {auditor} | {now} |")
1351
+ if not dry_run:
1352
+ from dev_tools.utils import safe_write_file
1353
+
1354
+ safe_write_file(tracking_file, "\n".join(new_lines) + "\n")
1355
+ return {"pr": pr_num, "status": status, "updated": not dry_run}
1356
+
1357
+ def resolve_conflicts_headless(self) -> List[str]:
1358
+ files = self.find_conflict_files()
1359
+ resolved, failed = [], []
1360
+ for f in files:
1361
+ if self.resolve_conflict(f):
1362
+ resolved.append(f)
1363
+ else:
1364
+ failed.append(f)
1365
+ if failed:
1366
+ raise CLIError(f"Failed to resolve: {', '.join(failed)}")
1367
+ return resolved
1368
+
1369
+ def repair_context(
1370
+ self,
1371
+ log: Optional[str] = None,
1372
+ log_file: Optional[str] = None,
1373
+ prNumber: Optional[int] = None,
1374
+ ) -> List[str]:
1375
+ from dev_tools.services.repair_service import RepairService
1376
+
1377
+ pipeline = RepairService()
1378
+ prompts: List[str] = []
1379
+ if log:
1380
+ p_log = pipeline.generate_prompt(log)
1381
+ if p_log:
1382
+ prompts.append(p_log)
1383
+ elif log_file:
1384
+ with open(log_file, encoding="utf-8") as f:
1385
+ for line in f:
1386
+ p = pipeline.generate_prompt(line)
1387
+ if p:
1388
+ prompts.append(p)
1389
+ elif prNumber:
1390
+ repo_name = get_repo_name()
1391
+ g = get_github_client()
1392
+ repo = g.get_repo(repo_name)
1393
+ pr = repo.get_pull(prNumber)
1394
+ check_runs = self.github.fetch_check_runs(pr.head.sha)
1395
+ for run in check_runs:
1396
+ if run.get("conclusion") == "failure":
1397
+ run_id = run.get("id")
1398
+ if isinstance(run_id, int):
1399
+ logs = self.github.fetch_check_run_logs(run_id, external_id=run.get("external_id"))
1400
+ for line in logs.splitlines():
1401
+ p = pipeline.generate_prompt(line)
1402
+ if p:
1403
+ prompts.append(p)
1404
+ # Filter out None values to satisfy return type list[str]
1405
+ return [p for p in prompts if p is not None]
1406
+
1407
+ def run_ux_audit(
1408
+ self,
1409
+ route: Optional[str] = None,
1410
+ all_routes: bool = False,
1411
+ desktop: bool = False,
1412
+ mobile: bool = False,
1413
+ screenshots_only: bool = False,
1414
+ images_only: bool = False,
1415
+ contrast_only: bool = False,
1416
+ overflow_only: bool = False,
1417
+ ) -> Dict[str, Any]:
1418
+ """
1419
+ Runs the UX audit suite using Playwright.
1420
+ """
1421
+ # Ensure routes are discovered
1422
+ run_command(["pnpm", "exec", "tsx", "scripts/ux-discover-routes.ts"])
1423
+
1424
+ routes = ["/"]
1425
+ if all_routes:
1426
+ with open("artifacts/ux-audit/routes.json", "r", encoding="utf-8") as f:
1427
+ routes = json.load(f)["routes"]
1428
+ elif route:
1429
+ routes = [route]
1430
+
1431
+ viewports = []
1432
+ if desktop:
1433
+ viewports = ["desktop-1280", "desktop-1440"]
1434
+ elif mobile:
1435
+ viewports = ["mobile-375", "mobile-390", "mobile-430"]
1436
+
1437
+ flags = []
1438
+ if images_only:
1439
+ flags.append("--images-only")
1440
+ if overflow_only:
1441
+ flags.append("--overflow-only")
1442
+ if contrast_only:
1443
+ flags.append("--contrast-only")
1444
+
1445
+ results = []
1446
+ for r in routes:
1447
+ cmd = ["pnpm", "exec", "tsx", "scripts/ux-audit-runner.ts", r]
1448
+ if viewports:
1449
+ for vp in viewports:
1450
+ res = run_command(cmd + [vp] + flags, check=False)
1451
+ results.append(
1452
+ {
1453
+ "route": r,
1454
+ "viewport": vp,
1455
+ "status": "success" if res.returncode == 0 else "error",
1456
+ }
1457
+ )
1458
+ else:
1459
+ res = run_command(cmd + flags, check=False)
1460
+ results.append({"route": r, "status": "success" if res.returncode == 0 else "error"})
1461
+
1462
+ return {"status": "success", "results": results}
1463
+
1464
+ def run_lighthouse(self, route: Optional[str] = None) -> Dict[str, Any]:
1465
+ """
1466
+ Runs Lighthouse audits.
1467
+ """
1468
+ # Ensure routes are discovered
1469
+ run_command(["pnpm", "exec", "tsx", "scripts/ux-discover-routes.ts"])
1470
+
1471
+ cmd = ["pnpm", "exec", "tsx", "scripts/ux-lighthouse-runner.ts"]
1472
+ if route:
1473
+ # Note: Lighthouse runner might need updates to handle single route arg if desired,
1474
+ # but for now it uses routes.json.
1475
+ pass
1476
+
1477
+ res = run_command(cmd, check=False)
1478
+ return {"status": "success" if res.returncode == 0 else "error", "output": res.stdout}
1479
+
1480
+ def generate_ux_report(self) -> Dict[str, Any]:
1481
+ """
1482
+ Aggregates results into a Markdown report.
1483
+ """
1484
+ generate_report()
1485
+ return {"status": "success", "report": "artifacts/ux-audit/ux-audit-report.md"}
1486
+
1487
+ def _scan_workflows(self) -> List[str]:
1488
+ """Lists all YAML files in .github/workflows/."""
1489
+ workflow_dir = ".github/workflows"
1490
+ if not os.path.exists(workflow_dir):
1491
+ return []
1492
+ files = []
1493
+ for f in os.listdir(workflow_dir):
1494
+ if f.endswith(".yml") or f.endswith(".yaml"):
1495
+ files.append(os.path.join(workflow_dir, f))
1496
+ return sorted(files)
1497
+
1498
+ def _check_workflow_compliance(self, file_path: str) -> List[str]:
1499
+ """Parses a workflow file for compliance violations using a data-driven rule model."""
1500
+ violations = []
1501
+
1502
+ # Rule definition model: dictionaries with regex, message, and optional validator.
1503
+ # Regexes are designed to be robust against varying whitespace and formatting.
1504
+ rules: List[Dict[str, Any]] = [
1505
+ {
1506
+ "regex": r"node-version\s*:\s*['\"]?\d+",
1507
+ "message": "Hardcoded `node-version:`. Use `node-version-file: '.node-version'` instead.",
1508
+ },
1509
+ {
1510
+ "regex": r"\bnpm\s+(?:install|ci|run)\b",
1511
+ "message": "`npm` usage detected. Use `pnpm` exclusively.",
1512
+ },
1513
+ {
1514
+ "regex": r"actions/checkout\s*@\s*v(\d+)",
1515
+ "message": "Outdated `actions/checkout@v{ver}`. Use `@v4`.",
1516
+ "validator": lambda m: int(m.group(1)) < 4,
1517
+ },
1518
+ {
1519
+ "regex": r"actions/setup-node\s*@\s*v(\d+)",
1520
+ "message": "Outdated `actions/setup-node@v{ver}`. Use `@v4`.",
1521
+ "validator": lambda m: int(m.group(1)) < 4,
1522
+ },
1523
+ ]
1524
+
1525
+ try:
1526
+ with open(file_path, "r", encoding="utf-8") as f:
1527
+ content = f.read()
1528
+
1529
+ for rule in rules:
1530
+ # Use re.IGNORECASE for robustness against mixed casing in YAML
1531
+ regex = rule.get("regex")
1532
+ if not isinstance(regex, str):
1533
+ continue
1534
+ pattern = re.compile(regex, re.IGNORECASE)
1535
+ for match in pattern.finditer(content):
1536
+ validator = rule.get("validator")
1537
+ if validator is None or (callable(validator) and validator(match)):
1538
+ # Support dynamic version reporting if the regex has a group
1539
+ ver = match.group(1) if match.lastindex and match.lastindex >= 1 else ""
1540
+ msg = rule.get("message")
1541
+ if isinstance(msg, str):
1542
+ violations.append(msg.format(ver=ver))
1543
+
1544
+ except Exception as e:
1545
+ violations.append(f"Error parsing file: {e}")
1546
+
1547
+ return violations
1548
+
1549
+ def plan_workflow_audit(self, workflow: Optional[str] = None) -> Dict[str, Any]:
1550
+ """
1551
+ Builds a deterministic roadmap and status checklist for auditing GitHub workflows.
1552
+ """
1553
+ if workflow:
1554
+ # 1. Path Sanitization & Validation
1555
+ # Restrict to .github/workflows directory and ensure valid extensions
1556
+ workflow_path = os.path.normpath(workflow)
1557
+ if not (workflow_path.endswith(".yml") or workflow_path.endswith(".yaml")):
1558
+ raise CLIError(f"Invalid workflow file extension: {workflow}. Must be .yml or .yaml")
1559
+
1560
+ if not workflow_path.startswith(".github/workflows" + os.sep) and workflow_path != os.path.join(
1561
+ ".github", "workflows", os.path.basename(workflow_path)
1562
+ ):
1563
+ # Allow relative paths that point into the directory
1564
+ if not os.path.dirname(workflow_path) == os.path.join(".github", "workflows"):
1565
+ raise CLIError(f"Workflow file must reside in .github/workflows/: {workflow}")
1566
+
1567
+ if not os.path.exists(workflow_path):
1568
+ raise CLIError(f"Workflow file not found: {workflow_path}")
1569
+ files = [workflow_path]
1570
+ else:
1571
+ files = self._scan_workflows()
1572
+
1573
+ if not files:
1574
+ return {
1575
+ "status": "success",
1576
+ "message": "No workflows found to audit.",
1577
+ "files_count": 0,
1578
+ "status_file": ".boomtick/workflow-audit-status.md",
1579
+ }
1580
+
1581
+ # 1. Cache compliance checks to avoid redundant processing
1582
+ file_audit_results = {}
1583
+ for f_path in files:
1584
+ file_audit_results[f_path] = self._check_workflow_compliance(f_path)
1585
+
1586
+ # 2. Summary Checklist Generation (.boomtick/workflow-audit-status.md)
1587
+ status_path = os.path.join(".boomtick", "workflow-audit-status.md")
1588
+ os.makedirs(".boomtick", exist_ok=True)
1589
+ status_lines = [
1590
+ "# Workflow Audit Status",
1591
+ f"**Generated:** {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}",
1592
+ "\n## Compliance Checklist\n",
1593
+ ]
1594
+
1595
+ for f_path in files:
1596
+ name = os.path.basename(f_path)
1597
+ violations = file_audit_results[f_path]
1598
+ status = "✅" if not violations else "❌"
1599
+ status_lines.append(f"- [ ] {status} `{name}`: {len(violations)} violation(s)")
1600
+
1601
+ with open(status_path, "w", encoding="utf-8") as f:
1602
+ f.write("\n".join(status_lines) + "\n")
1603
+
1604
+ # 3. Individual Workflow Plan Generation
1605
+ plan_dir = get_or_create_log_dir("workflows")
1606
+ generated_plans = []
1607
+
1608
+ for f_path in files:
1609
+ name = os.path.basename(f_path)
1610
+ plan_path = os.path.join(plan_dir, f"workflow-plan-{name}.md")
1611
+ violations = file_audit_results[f_path]
1612
+
1613
+ with open(plan_path, "w", encoding="utf-8") as f:
1614
+ f.write(f"""# Workflow Audit Plan: {name}
1615
+
1616
+ ## File Path
1617
+ `{f_path}`
1618
+
1619
+ ## Compliance Status
1620
+ {"✅ All rules followed." if not violations else "❌ Non-compliant patterns found."}
1621
+
1622
+ ### Violations
1623
+ {"" if not violations else "\n".join(f"- {v}" for v in violations)}
1624
+
1625
+ ## Audit Instructions
1626
+
1627
+ Review `.github/workflows/` files to align them with `AGENTS.md` runtime policies and version pinning rules.
1628
+
1629
+ ### Step 1: Manual Review
1630
+ Verify if the regex patterns missed any semantic violations (e.g., complex shell scripts using forbidden tools).
1631
+
1632
+ ### Step 2: Version Alignment
1633
+ Ensure all GitHub Actions are pinned to their latest major versions (e.g. `actions/checkout@v4`).
1634
+
1635
+ ### Step 3: Runtime Policy Alignment
1636
+ Confirm `actions/setup-node` uses `node-version-file: '.node-version'`.
1637
+
1638
+ ### Step 4: Verification
1639
+ Run the workflow (if possible via `gh workflow run` or by pushing a test branch) to ensure the changes don't break the CI/CD pipeline.
1640
+
1641
+ ---
1642
+
1643
+ ## Remediation Suggestions
1644
+ - Replace `node-version: 24` (or other version) with `node-version-file: '.node-version'`.
1645
+ - Replace `npm install` with `pnpm install`.
1646
+ - Update `@v2` or `@v3` tags to `@v4` (checkout) or `@v4` (setup-node).
1647
+ """)
1648
+ generated_plans.append(plan_path)
1649
+
1650
+ return {
1651
+ "status": "success",
1652
+ "files_count": len(files),
1653
+ "status_file": status_path,
1654
+ "workflow_plans": generated_plans,
1655
+ }
1656
+
1657
+ def plan_issue_audit(
1658
+ self,
1659
+ issueNumbers: Optional[List[int]] = None,
1660
+ all_open: bool = False,
1661
+ limit: int = 100,
1662
+ **kwargs,
1663
+ ) -> Dict[str, Any]:
1664
+ """
1665
+ Builds a deterministic roadmap and status checklist for auditing open issues.
1666
+ """
1667
+ issues = []
1668
+ if "issue_numbers" in kwargs and issueNumbers is None:
1669
+ issueNumbers = kwargs["issue_numbers"]
1670
+ if "limit" in kwargs:
1671
+ limit = kwargs["limit"]
1672
+ if all_open:
1673
+ issues = self.github.list_issues(state="open", limit=limit)
1674
+ elif issueNumbers:
1675
+ for num in issueNumbers:
1676
+ issue = self.github.fetch_issue_details(num)
1677
+ # Normalize format to match list_issues
1678
+ issues.append(self.github.normalize_issue(issue))
1679
+ else:
1680
+ raise CLIError("Provide --issue or --all-open")
1681
+
1682
+ # 1. Summary Checklist Generation (.boomtick/issue-audit-status.md)
1683
+ status_path = os.path.join(".boomtick", "issue-audit-status.md")
1684
+ os.makedirs(".boomtick", exist_ok=True)
1685
+ status_lines = [
1686
+ "# Issue Audit Status",
1687
+ f"**Generated:** {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}",
1688
+ "\n## Open Issues Checklist\n",
1689
+ ]
1690
+ for issue in issues:
1691
+ status_lines.append(f"- [ ] #{issue['number']}: {issue['title']}")
1692
+
1693
+ with open(status_path, "w", encoding="utf-8") as f:
1694
+ f.write("\n".join(status_lines) + "\n")
1695
+
1696
+ # 2. Individual Workflow Plan Generation
1697
+ plan_dir = get_or_create_log_dir("workflows")
1698
+ generated_plans = []
1699
+
1700
+ for issue in issues:
1701
+ plan_path = os.path.join(plan_dir, f"workflow-plan-issue-{issue['number']}.md")
1702
+
1703
+ with open(plan_path, "w", encoding="utf-8") as f:
1704
+ f.write(f"""# Workflow Plan: Issue #{issue['number']}
1705
+
1706
+ ## Agent Instructions
1707
+ - **Environment Check**: Ensure Python dependencies and pnpm {PROJECT_CONFIG.pnpm_version} are available.
1708
+
1709
+ ## Issue Context
1710
+ - **Title:** {issue['title']}
1711
+ - **URL:** {issue['html_url']}
1712
+ - **Labels:** {', '.join(issue['labels']) if issue['labels'] else '_None_'}
1713
+
1714
+ ## Audit Instructions
1715
+
1716
+ Before auditing, read `docs/agent/issue-audit-rules.md`.
1717
+
1718
+ ### Step 1: Understand Intent
1719
+ Analyze the problem statement and goal in the issue description.
1720
+
1721
+ ### Step 2: Codebase Inspection
1722
+ Locate relevant files, components, or routes described in the issue.
1723
+
1724
+ ### Step 3: Verification
1725
+ Verify if the requested change is already implemented, partially addressed, or missing.
1726
+
1727
+ ### Step 4: Documentation & Closure Recommendation
1728
+ Follow the "Audit comment template" in `docs/agent/issue-audit-rules.md` to post your findings.
1729
+
1730
+ ---
1731
+
1732
+ ## Issue Body Excerpt
1733
+ ```markdown
1734
+ {issue['body'] or '_No description provided._'}
1735
+ ```
1736
+ """)
1737
+ generated_plans.append(plan_path)
1738
+
1739
+ return {
1740
+ "status": "success",
1741
+ "issues_count": len(issues),
1742
+ "status_file": status_path,
1743
+ "workflow_plans": generated_plans,
1744
+ }
1745
+
1746
+ def run_playwright(self, grep: Optional[str] = None, worktree_path: Optional[str] = None) -> Dict[str, Any]:
1747
+ """Runs Playwright tests and parses the JSON report."""
1748
+ playwright_args = ["playwright", "test", "--reporter=json"]
1749
+ if grep:
1750
+ playwright_args.extend(["--grep", grep])
1751
+
1752
+ res = run_command(["pnpm"] + playwright_args, cwd=worktree_path, check=False)
1753
+
1754
+ failed_tests = []
1755
+ try:
1756
+ if "{" in res.stdout:
1757
+ json_data = res.stdout[res.stdout.find("{") :]
1758
+ try:
1759
+ report = json.loads(json_data)
1760
+ for suite in report.get("suites", []):
1761
+ for spec in suite.get("specs", []):
1762
+ if not spec.get("ok"):
1763
+ error = "Unknown error"
1764
+ if (
1765
+ spec.get("tests")
1766
+ and spec["tests"][0].get("results")
1767
+ and spec["tests"][0]["results"][0].get("error")
1768
+ ):
1769
+ error = spec["tests"][0]["results"][0]["error"].get("message", "Unknown error")
1770
+
1771
+ failed_tests.append(
1772
+ {
1773
+ "title": spec.get("title"),
1774
+ "file": spec.get("file"),
1775
+ "error": error,
1776
+ }
1777
+ )
1778
+ except json.JSONDecodeError as e:
1779
+ log_error(f"Failed to parse Playwright JSON report: {e}\nRaw output: {res.stdout}")
1780
+ except Exception as e:
1781
+ log_error(f"Unexpected error parsing Playwright output: {e}")
1782
+
1783
+ return {
1784
+ "success": res.returncode == 0,
1785
+ "command": " ".join(["pnpm"] + playwright_args),
1786
+ "failedTests": failed_tests,
1787
+ }
1788
+
1789
+ def sync_pr(self, prNumber: int) -> Dict[str, Any]:
1790
+ """Reliably pull the latest remote PR state to local, overwriting messy rebases."""
1791
+ pr_data = self.github.fetch_pr_details(prNumber)
1792
+ head_ref = pr_data.get("head", {}).get("ref")
1793
+
1794
+ if not head_ref:
1795
+ raise CLIError(f"Could not determine head ref for PR #{prNumber}")
1796
+
1797
+ log_info(f"Fetching remote state for PR #{prNumber} ({head_ref})...")
1798
+ # Use pull ref to be robust against forks and ensure we get the exact commit
1799
+ run_command(["git", "fetch", "origin", f"pull/{prNumber}/head"])
1800
+
1801
+ current_branch = run_command(["git", "branch", "--show-current"]).strip()
1802
+ if current_branch != head_ref:
1803
+ log_info(f"Switching from {current_branch} to {head_ref}")
1804
+ run_command(["git", "checkout", "-B", head_ref, "FETCH_HEAD"])
1805
+ else:
1806
+ log_info(f"Hard resetting {head_ref} to remote state...")
1807
+ run_command(["git", "reset", "--hard", "FETCH_HEAD"])
1808
+
1809
+ return {
1810
+ "status": "success",
1811
+ "message": f"Successfully synced local branch `{head_ref}` with remote state of PR #{prNumber}.",
1812
+ "branch": head_ref,
1813
+ }
1814
+
1815
+ def get_ci_logs(self, prNumber: int, include_all: bool = False, **kwargs) -> Dict[str, Any]:
1816
+ if "pr_number" in kwargs:
1817
+ prNumber = kwargs["pr_number"]
1818
+ """Fetches CI logs for failing (or all) check runs in a PR."""
1819
+ # Get PR head SHA
1820
+ pr_data = self.github.fetch_pr_details(prNumber)
1821
+ head_sha = pr_data.get("head", {}).get("sha")
1822
+
1823
+ if not head_sha:
1824
+ raise CLIError(f"Could not determine head SHA for PR #{prNumber}")
1825
+
1826
+ # Get check runs
1827
+ checks = self.github.fetch_check_runs(head_sha)
1828
+ failed_checks = [c for c in checks if c.get("conclusion") == "failure"]
1829
+
1830
+ logs = {}
1831
+ # Get check suites to find workflow runs
1832
+ check_suites = self.github.fetch_check_suites(head_sha)
1833
+
1834
+ for suite in check_suites:
1835
+ runs = self.github.fetch_check_runs_for_suite(suite["id"])
1836
+ for run in runs:
1837
+ if include_all or run.get("conclusion") == "failure":
1838
+ run_id = run.get("id")
1839
+ if isinstance(run_id, int):
1840
+ log_content = self.github.fetch_check_run_logs(run_id, external_id=run.get("external_id"))
1841
+ logs[run["name"]] = log_content[:10000]
1842
+
1843
+ return {"checks": checks, "failedChecks": failed_checks, "logs": logs}
1844
+
1845
+ def stream_ci_logs(self, prNumber: int, grep: Optional[str] = None) -> str:
1846
+ """Fetches and combines all CI logs for the latest workflow run of a PR."""
1847
+ # Get PR head SHA
1848
+ pr_data = self.github.fetch_pr_details(prNumber)
1849
+ head_sha = pr_data.get("head", {}).get("sha")
1850
+
1851
+ if not head_sha:
1852
+ raise CLIError(f"Could not determine head SHA for PR #{prNumber}")
1853
+
1854
+ # Get all check runs for this SHA
1855
+ check_runs = self.github.fetch_check_runs(head_sha)
1856
+
1857
+ all_logs = []
1858
+ # Limit to latest 20 jobs to avoid extreme memory usage
1859
+ for run in check_runs[:20]:
1860
+ # Fetch logs via API to avoid terminal paging/buffering issues
1861
+ log_content = self.github.fetch_check_run_logs(run.get("id"), external_id=run.get("external_id"))
1862
+ header = f"--- LOGS FOR JOB: {run['name']} (ID: {run['id']}) ---"
1863
+ all_logs.append(header)
1864
+ # Truncate each log to 20k chars to balance detail vs memory
1865
+ all_logs.append(log_content[-20000:])
1866
+ all_logs.append("\n")
1867
+
1868
+ combined_logs = "\n".join(all_logs)
1869
+
1870
+ if grep:
1871
+ grep_pattern = grep.lower()
1872
+ lines = combined_logs.splitlines()
1873
+ filtered_lines = [line for line in lines if grep_pattern in line.lower()]
1874
+ return "\n".join(filtered_lines)
1875
+
1876
+ return combined_logs
1877
+
1878
+ def get_merge_conflicts(self, prNumber: int, base_branch: Optional[str] = None) -> Dict[str, Any]:
1879
+ """Detects merge conflicts for a PR against a base branch using a temporary worktree."""
1880
+ if base_branch is None:
1881
+ base_branch = PROJECT_CONFIG.base_branch_name
1882
+ # Get PR head ref
1883
+ pr_data = self.github.fetch_pr_details(prNumber)
1884
+ head_ref = pr_data.get("head", {}).get("ref")
1885
+
1886
+ if not head_ref:
1887
+ raise CLIError(f"Could not determine head ref for PR #{prNumber}")
1888
+
1889
+ # Ensure we have the latest
1890
+ run_command(["git", "fetch", "origin", head_ref])
1891
+ run_command(["git", "fetch", "origin", base_branch])
1892
+
1893
+ worktree_path = os.path.join(os.getcwd(), f"worktree-conflict-{prNumber}.tmp")
1894
+ self._cleanup_worktree(worktree_path)
1895
+
1896
+ run_command(["git", "worktree", "add", worktree_path, f"origin/{head_ref}"])
1897
+
1898
+ conflict_files = []
1899
+ command_log = ""
1900
+ try:
1901
+ merge_cmd = ["git", "merge", "--no-commit", "--no-ff", f"origin/{base_branch}"]
1902
+ res = run_command(merge_cmd, cwd=worktree_path, check=False)
1903
+ command_log = (res.stdout or "") + (res.stderr or "")
1904
+
1905
+ if res.returncode != 0:
1906
+ # Detect and retry unrelated histories
1907
+ if "unrelated histories" in command_log.lower():
1908
+ log_warn(
1909
+ f"Disjoint history detected for conflict check of PR #{prNumber}. Retrying with --allow-unrelated-histories"
1910
+ )
1911
+ res = run_command(merge_cmd + ["--allow-unrelated-histories"], cwd=worktree_path, check=False)
1912
+ command_log += "\n--- RETRY WITH --allow-unrelated-histories ---\n"
1913
+ command_log += (res.stdout or "") + (res.stderr or "")
1914
+
1915
+ if res.returncode != 0:
1916
+ res_diff = run_command(
1917
+ ["git", "diff", "--name-only", "--diff-filter=U"],
1918
+ cwd=worktree_path,
1919
+ check=False,
1920
+ )
1921
+ conflict_files = [f.strip() for f in res_diff.stdout.splitlines() if f.strip()]
1922
+ run_command(["git", "merge", "--abort"], cwd=worktree_path, check=False)
1923
+ finally:
1924
+ run_command(["git", "worktree", "remove", "-f", worktree_path], check=False)
1925
+ if os.path.exists(worktree_path):
1926
+ shutil.rmtree(worktree_path, ignore_errors=True)
1927
+
1928
+ return {
1929
+ "prNumber": prNumber,
1930
+ "baseBranch": base_branch,
1931
+ "headRef": head_ref,
1932
+ "conflictFiles": conflict_files,
1933
+ "commandLog": command_log,
1934
+ }
1935
+
1936
+ def get_pr_diff_shapen(self, prNumber: int) -> Dict[str, Any]:
1937
+ """Fetches PR diff, applies truncation and shapes file info."""
1938
+ # Get files list
1939
+ files = self.github.fetch_pr_files(prNumber)
1940
+
1941
+ # Get diff text
1942
+ diff_text = self.github.fetch_pr_diff(prNumber)
1943
+
1944
+ MAX_DIFF_SIZE = 50000
1945
+ truncated = False
1946
+ if len(diff_text) > MAX_DIFF_SIZE:
1947
+ diff_text = diff_text[:MAX_DIFF_SIZE] + "\n\n... [Diff truncated due to size] ..."
1948
+ truncated = True
1949
+
1950
+ return {
1951
+ "prNumber": prNumber,
1952
+ "files": [
1953
+ {
1954
+ "path": f.get("filename"),
1955
+ "status": f.get("status") or "modified",
1956
+ "additions": f.get("additions"),
1957
+ "deletions": f.get("deletions"),
1958
+ }
1959
+ for f in files
1960
+ ],
1961
+ "diffText": diff_text,
1962
+ "truncated": truncated,
1963
+ }
1964
+
1965
+ def list_prs(
1966
+ self,
1967
+ state: str = "open",
1968
+ limit: int = 100,
1969
+ includeDrafts: bool = True,
1970
+ labels: Optional[List[str]] = None,
1971
+ **kwargs,
1972
+ ) -> Dict[str, Any]:
1973
+ """Lists PRs with optional filtering."""
1974
+ if "include_drafts" in kwargs:
1975
+ includeDrafts = kwargs["include_drafts"]
1976
+ if "labels" in kwargs and labels is None:
1977
+ labels = kwargs["labels"]
1978
+ prs = self.github.list_pull_requests(state=state, limit=limit, labels=labels)
1979
+
1980
+ if not includeDrafts:
1981
+ prs = [pr for pr in prs if not pr.get("isDraft")]
1982
+
1983
+ return {"status": "success", "prs": [PRSummary(**pr).model_dump() for pr in prs]}
1984
+
1985
+ def get_pr_comments(self, prNumber: int, **kwargs) -> Dict[str, Any]:
1986
+ """Fetches and aggregates standard issue comments and inline review comments for a PR."""
1987
+ if "prNumber" in kwargs:
1988
+ prNumber = kwargs["prNumber"]
1989
+ pr = self.github.fetch_pr_details(prNumber)
1990
+ issue_comments = self.github.fetch_issue_comments(prNumber)
1991
+ review_comments = self.github.fetch_review_comments(prNumber)
1992
+
1993
+ return {
1994
+ "pr": {
1995
+ "number": pr.get("number"),
1996
+ "title": pr.get("title"),
1997
+ "state": pr.get("state"),
1998
+ "html_url": pr.get("html_url"),
1999
+ },
2000
+ "comments": [
2001
+ {
2002
+ "user": c.get("user", {}).get("login"),
2003
+ "body": c.get("body"),
2004
+ "created_at": c.get("created_at"),
2005
+ }
2006
+ for c in issue_comments
2007
+ ],
2008
+ "review_comments": [
2009
+ {
2010
+ "user": c.get("user", {}).get("login"),
2011
+ "path": c.get("path"),
2012
+ "line": c.get("line"),
2013
+ "body": c.get("body"),
2014
+ "created_at": c.get("created_at"),
2015
+ }
2016
+ for c in review_comments
2017
+ ],
2018
+ }
2019
+
2020
+ def get_pr_for_session(self, session: Dict[str, Any]) -> Optional[int]:
2021
+ """Optimized PR lookup for a session."""
2022
+ session_id = session.get("name", "").replace("sessions/", "")
2023
+
2024
+ # 1. Try metadata/outputs first if available (fastest)
2025
+ if session.get("outputs") and isinstance(session["outputs"], list):
2026
+ for output in session["outputs"]:
2027
+ if output.get("pullRequest") and output["pullRequest"].get("url"):
2028
+ match = re.search(r"/pull/(\d+)", output["pullRequest"]["url"])
2029
+ if match:
2030
+ return int(match.group(1))
2031
+
2032
+ # 2. Try branch name from sourceContext (fast - targeted search)
2033
+ branch = None
2034
+ if session.get("sourceContext"):
2035
+ branch = session["sourceContext"].get("githubRepoContext", {}).get("startingBranch")
2036
+
2037
+ if branch:
2038
+ # Use search API for targeted branch lookup
2039
+ prs = self.github.search_pull_requests(f"head:{branch} state:open", limit=1)
2040
+ if prs:
2041
+ return prs[0]["number"]
2042
+
2043
+ # 3. Fallback to session ID in body
2044
+ safe_id = f'"{session_id}"'
2045
+ prs = self.github.search_pull_requests(f"{safe_id} in:body,title state:open", limit=1)
2046
+ if prs:
2047
+ return prs[0]["number"]
2048
+
2049
+ return None
2050
+
2051
+ def trigger_jules_feedback(self, session_id: str) -> Dict[str, Any]:
2052
+ """Ports logic from trigger-feedback.ts to provide CI feedback to Jules."""
2053
+ session = self.jules.get_session(session_id)
2054
+ if not session:
2055
+ raise CLIError(f"Session {session_id} not found.")
2056
+
2057
+ prNumber = self.get_pr_for_session(session)
2058
+
2059
+ if not prNumber:
2060
+ return {
2061
+ "status": "no_pr_found",
2062
+ "message": "Could not associate session with an open PR.",
2063
+ }
2064
+
2065
+ pr_details = self.github.fetch_pr_details(prNumber)
2066
+ sha = pr_details.get("head", {}).get("sha")
2067
+ check_runs = self.github.fetch_check_runs(sha)
2068
+
2069
+ if not check_runs:
2070
+ return {"status": "no_checks", "message": "No CI checks found for this PR head."}
2071
+
2072
+ failed_checks = [
2073
+ run for run in check_runs if run.get("status") == "completed" and run.get("conclusion") == "failure"
2074
+ ]
2075
+ in_progress = any(run.get("status") != "completed" for run in check_runs)
2076
+
2077
+ if in_progress:
2078
+ return {"status": "in_progress", "message": "CI checks are still in progress."}
2079
+
2080
+ feedback = ""
2081
+ if failed_checks:
2082
+ feedback = "The CI pipeline reported failures. Here are the details:\n\n"
2083
+ for run in failed_checks:
2084
+ feedback += f"### Failed Check: {run['name']}\n"
2085
+ run_id = run.get("id")
2086
+ if not isinstance(run_id, int):
2087
+ continue
2088
+ logs = self.github.fetch_check_run_logs(run_id, external_id=run.get("external_id"))
2089
+ findings = extract_failing_info(logs)
2090
+ if findings:
2091
+ for f in findings:
2092
+ feedback += f"- File: `{f['file']}:{f['line']}` ({f['type']})\n Message: {f['message']}\n"
2093
+ else:
2094
+ # Clean logs and take a smart snippet as fallback
2095
+ cleaned_logs = clean_gha_logs(logs)
2096
+ feedback += f"```\n{cleaned_logs[-2000:]}\n```\n"
2097
+ feedback += "\n"
2098
+ else:
2099
+ feedback = "All checks passed successfully. You may proceed."
2100
+
2101
+ self.jules.send_message(session_id, feedback)
2102
+ return {"status": "success", "feedback": feedback}
2103
+
2104
+ def aggregate_prs(self, target_branch: str, prNumbers: List[int]) -> Dict[str, Any]:
2105
+ """
2106
+ Aggregates multiple PRs into a single target branch and creates a consolidated PR.
2107
+ """
2108
+ base_branch = PROJECT_CONFIG.base_branch_name
2109
+
2110
+ # 1. Isolation & Cleanliness
2111
+ run_git_commands(
2112
+ [
2113
+ ["git", "checkout", base_branch],
2114
+ ["git", "pull", "origin", base_branch],
2115
+ ["git", "checkout", "-b", target_branch],
2116
+ ]
2117
+ )
2118
+
2119
+ aggregate_body = ""
2120
+ successfully_merged = []
2121
+
2122
+ for pr_num in prNumbers:
2123
+ # 2. Sequential Extraction & Deterministic Sequence
2124
+ pr_data = self.github.fetch_pr_details(pr_num)
2125
+ head_ref = pr_data.get("head", {}).get("ref")
2126
+ title = pr_data.get("title")
2127
+ body = pr_data.get("body") or ""
2128
+
2129
+ if not head_ref:
2130
+ raise CLIError(f"Could not determine head ref for PR #{pr_num}")
2131
+
2132
+ # 2.5 Handle forks by using git fetch
2133
+ # This ensures the branch is available locally and handles forks correctly
2134
+ # and switch back to target branch.
2135
+ run_git_commands(
2136
+ [
2137
+ ["git", "fetch", "origin", f"pull/{pr_num}/head:{head_ref}"],
2138
+ ["git", "checkout", target_branch],
2139
+ ]
2140
+ )
2141
+
2142
+ # 3. Safety First: Attempt automated integration merge
2143
+ # Use 'ort' strategy implicitly by standard merge if git version supports it,
2144
+ # or just standard merge.
2145
+ merge_cmd = ["git", "merge", head_ref, "-m", f"Merging PR #{pr_num}: {title}"]
2146
+ res = run_command(merge_cmd, check=False)
2147
+
2148
+ if not isinstance(res, subprocess.CompletedProcess):
2149
+ raise CLIError("Merge execution failed")
2150
+
2151
+ if res.returncode != 0:
2152
+ # Detect "refusing to merge unrelated histories" and retry
2153
+ # Safely handle potential None values if capture failed
2154
+ merge_output = (res.stdout or "") + (res.stderr or "")
2155
+ if "unrelated histories" in merge_output.lower():
2156
+ log_warn(f"Disjoint history detected for PR #{pr_num}. Retrying with --allow-unrelated-histories")
2157
+ res = run_command(merge_cmd + ["--allow-unrelated-histories"], check=False)
2158
+ if not isinstance(res, subprocess.CompletedProcess):
2159
+ raise CLIError("Retry merge with --allow-unrelated-histories failed execution")
2160
+
2161
+ if res.returncode != 0:
2162
+ # Conflict encountered
2163
+ run_command(["git", "merge", "--abort"])
2164
+ error_msg = textwrap.dedent(f"""
2165
+ CRITICAL: Conflict in PR #{pr_num}. Restored stable state of {target_branch}.
2166
+
2167
+ Fallback Strategy:
2168
+ If native merging fails due to heavy conflicts or history divergence:
2169
+ 1. Generate a patch: `git diff {target_branch}...{head_ref} > pr_{pr_num}.patch`
2170
+ 2. Apply manually: `git apply pr_{pr_num}.patch` and resolve rejects.
2171
+ 3. Or retry merge with: `git merge {head_ref} --allow-unrelated-histories`
2172
+ """).strip()
2173
+ raise CLIError(error_msg, code=res.returncode)
2174
+
2175
+ # 4. Metadata Preservation
2176
+ successfully_merged.append(pr_num)
2177
+ aggregate_body += f"Closes #{pr_num}\n\n### Description from PR #{pr_num} ({title}):\n{body}\n\n---\n"
2178
+
2179
+ # Push the compiled branch
2180
+ run_command(["git", "push", "-u", "origin", target_branch])
2181
+
2182
+ # Create consolidated PR
2183
+ pr_title = f"Aggregated Feature: {target_branch}"
2184
+ pr_res = self.github.create_pull_request(pr_title, aggregate_body, target_branch, base_branch)
2185
+ pr_url = pr_res.get("html_url")
2186
+
2187
+ return {
2188
+ "status": "success",
2189
+ "branch": target_branch,
2190
+ "merged_prs": successfully_merged,
2191
+ "pr_url": pr_url,
2192
+ "message": f"Successfully aggregated {len(successfully_merged)} PRs into {target_branch}",
2193
+ }
2194
+
2195
+ def generate_review_workflow(self, prNumber: int, issueNumber: Optional[int] = None, **kwargs) -> Dict[str, Any]:
2196
+ if "prNumber" in kwargs:
2197
+ prNumber = kwargs["prNumber"]
2198
+ if "issue_number" in kwargs and issueNumber is None:
2199
+ issueNumber = kwargs["issue_number"]
2200
+ """Generates a deterministic review workflow plan for an agent."""
2201
+ # 1. Environment Validation
2202
+ env_res = self.runtime_check()
2203
+ env_output = f"Runtime OK: node {env_res['node']}, pnpm {env_res['pnpm']}"
2204
+
2205
+ # 2. Issue Validation
2206
+ issue_output = "No issue number provided."
2207
+ if issueNumber:
2208
+ res = self.validate_issue(issueNumber=issueNumber)
2209
+ issue_output = json.dumps(res, indent=2)
2210
+
2211
+ # 3. Conflict Detection
2212
+ conflicts = self.handle_detect_conflicts(pr_num=prNumber)
2213
+ conflict_output = json.dumps(conflicts, indent=2)
2214
+
2215
+ # 4. PR Context Generation
2216
+ audit_res = self.audit_pr(prNumber, fetch=True)
2217
+ pr_context_file = audit_res["files"]["context"]
2218
+
2219
+ pr_summary = ""
2220
+ ci_status = ""
2221
+ failure_logs = ""
2222
+
2223
+ if os.path.exists(pr_context_file):
2224
+ with open(pr_context_file, "r", encoding="utf-8") as f:
2225
+ pr_context_content = f.read()
2226
+
2227
+ summary_match = re.search(
2228
+ r"(# PR Context:.*?)(?=## CI Status|## Diff Stats)", pr_context_content, re.DOTALL
2229
+ )
2230
+ if summary_match:
2231
+ pr_summary = summary_match.group(1).strip()
2232
+
2233
+ ci_status_match = re.search(
2234
+ r"(## CI Status.*?)(?=## Diff Stats|## Failing Tests)",
2235
+ pr_context_content,
2236
+ re.DOTALL,
2237
+ )
2238
+ if ci_status_match:
2239
+ ci_status = ci_status_match.group(1).strip()
2240
+
2241
+ failure_logs_match = re.search(r"(## Failing Tests.*?)(?=## Diff Stats|$)", pr_context_content, re.DOTALL)
2242
+ if failure_logs_match:
2243
+ failure_logs = failure_logs_match.group(1).strip()
2244
+
2245
+ if not pr_summary:
2246
+ pr_summary = "See " + pr_context_file
2247
+ if not ci_status:
2248
+ ci_status = "See " + pr_context_file
2249
+ if not failure_logs:
2250
+ failure_logs = "See " + pr_context_file
2251
+
2252
+ # 5. Impact Analysis
2253
+ impact_output = "Not available."
2254
+ if os.path.exists("boomtick-pkg/scripts/impact-analysis.ts"):
2255
+ # Use check=False to swallow errors from impact analysis in the planning phase
2256
+ res = run_command(
2257
+ ["npx", "tsx", "boomtick-pkg/scripts/impact-analysis.ts"],
2258
+ check=False,
2259
+ log_on_error=False,
2260
+ )
2261
+ if isinstance(res, subprocess.CompletedProcess):
2262
+ impact_output = (res.stdout or "") + (res.stderr or "")
2263
+ if res.returncode != 0:
2264
+ impact_output = f"Impact analysis failed (exit {res.returncode}):\n{impact_output}"
2265
+
2266
+ # 6. Existing Review Data
2267
+ gemini_review = "None."
2268
+ if os.path.exists("artifacts/gemini-code-review.md"):
2269
+ with open("artifacts/gemini-code-review.md", "r", encoding="utf-8") as f:
2270
+ gemini_review = f.read()
2271
+
2272
+ github_models_review = "None."
2273
+ if os.path.exists("artifacts/github-models-code-review.md"):
2274
+ with open("artifacts/github-models-code-review.md", "r", encoding="utf-8") as f:
2275
+ github_models_review = f.read()
2276
+
2277
+ # Generate workflow plan
2278
+ plan_dir = get_or_create_log_dir("workflows")
2279
+ plan_path = os.path.join(plan_dir, f"workflow-plan-pr-{prNumber}.md")
2280
+
2281
+ with open(plan_path, "w", encoding="utf-8") as f:
2282
+ f.write(f"""# Workflow Plan: PR #{prNumber}
2283
+
2284
+ ## Agent Instructions
2285
+
2286
+ - **Environment Check**: Ensure Python dependencies and pnpm {PROJECT_CONFIG.pnpm_version} are available.
2287
+ - setup complete
2288
+ - validation complete
2289
+ - context collected (via `td agent plan-review --pr {prNumber}`)
2290
+ - diagnostics collected
2291
+
2292
+ **IMPORTANT: Context collection and audit are COMPLETE. Do NOT run --fetch or --audit again.**
2293
+ Agent must not repeat these steps. Redundant fetching (`--fetch`) or auditing (`--audit`) is already handled.
2294
+
2295
+ ---
2296
+
2297
+ ## Workflow State
2298
+
2299
+ [x] Environment Validation
2300
+ [x] Issue Validation
2301
+ [x] Conflict Detection
2302
+ [x] Context Collection & Audit
2303
+ [x] Impact Analysis
2304
+ [ ] Review Analysis
2305
+ [ ] Review Authoring
2306
+ [ ] Completion Verification
2307
+
2308
+ ---
2309
+
2310
+ ## Collected Context
2311
+
2312
+ ### Validation Output
2313
+ ```text
2314
+ {env_output}
2315
+ ```
2316
+
2317
+ ### Issue Validation Output
2318
+ ```text
2319
+ {issue_output}
2320
+ ```
2321
+
2322
+ ### Conflict Output
2323
+ ```text
2324
+ {conflict_output}
2325
+ ```
2326
+
2327
+ ### PR Summary
2328
+ Relevant excerpts from:
2329
+ `{pr_context_file}`
2330
+
2331
+ ```text
2332
+ {pr_summary}
2333
+ ```
2334
+
2335
+ ### CI Status
2336
+ Relevant excerpts:
2337
+ ```text
2338
+ {ci_status}
2339
+ ```
2340
+
2341
+ ### Failure Logs
2342
+ Relevant excerpts:
2343
+ ```text
2344
+ {failure_logs}
2345
+ ```
2346
+
2347
+ ### Impact Analysis
2348
+ Relevant excerpts:
2349
+ ```text
2350
+ {impact_output}
2351
+ ```
2352
+
2353
+ ### Existing AI Reviews
2354
+ **Gemini:**
2355
+ ```markdown
2356
+ {gemini_review}
2357
+ ```
2358
+
2359
+ **GitHub Models:**
2360
+ ```markdown
2361
+ {github_models_review}
2362
+ ```
2363
+
2364
+ ---
2365
+
2366
+ ## Allowed Files
2367
+
2368
+ Agent may read:
2369
+ `.agents/workflows/REVIEW_INSTRUCTIONS.md`
2370
+ `boomtick-pkg/cli/logs/reviews/pr-review-{prNumber}.md`
2371
+
2372
+ ---
2373
+
2374
+ ## Writable Files
2375
+
2376
+ Agent may modify:
2377
+ `boomtick-pkg/cli/logs/reviews/pr-review-{prNumber}.md`
2378
+
2379
+ ---
2380
+
2381
+ ## Merge & Conflict Guidance
2382
+
2383
+ If tasks require merging branches (e.g., during PR consolidation or rebase):
2384
+ - **Unrelated Histories**: If git fails with `fatal: refusing to merge unrelated histories`, use `git merge <branch> --allow-unrelated-histories`.
2385
+ - **Heavy Conflicts**: If standard merging is too complex, generate a patch using `git diff base...head` and apply it manually.
2386
+
2387
+ ---
2388
+
2389
+ ## Remaining Tasks
2390
+
2391
+ ### Step 1
2392
+ Review supplied evidence.
2393
+
2394
+ ### Step 2
2395
+ Populate review file.
2396
+
2397
+ ### Step 3
2398
+ Verify:
2399
+ - JSON valid
2400
+ - checklist complete
2401
+ - comments reference valid diff lines
2402
+
2403
+ ---
2404
+
2405
+ ## Completion Criteria
2406
+
2407
+ All checklist items resolved.
2408
+ No placeholders remain.
2409
+ No guessed line numbers.
2410
+ No invented findings.
2411
+ Every finding must reference supplied evidence.
2412
+
2413
+ ---
2414
+
2415
+ ## Final Output
2416
+
2417
+ Output exactly:
2418
+
2419
+ ```bash
2420
+ td gh audit-pr {prNumber} --submit --execute
2421
+ ```
2422
+
2423
+ Only after successful completion.
2424
+ """)
2425
+ return {"status": "success", "plan_path": plan_path}
2426
+
2427
+ def generate_aggregate_prs_workflow(self) -> Dict[str, Any]:
2428
+ """Generates a deterministic aggregation workflow plan for an agent."""
2429
+ # 1. Environment Validation
2430
+ env_res = self.runtime_check()
2431
+ env_output = f"Runtime OK: node {env_res['node']}, pnpm {env_res['pnpm']}"
2432
+
2433
+ # 2. Get Open PRs and Overlaps
2434
+ prs_output = "No data."
2435
+ # Re-implement minimal overlap logic without pickle
2436
+ repo = get_github_client().get_repo(get_repo_name())
2437
+ pulls = list(repo.get_pulls(state="open"))[:50]
2438
+
2439
+ file_to_prs = defaultdict(list)
2440
+ pr_titles = {}
2441
+ for pr in pulls:
2442
+ num = str(pr.number)
2443
+ pr_titles[num] = pr.title
2444
+ # Standardize file fetch to avoid visual snapshots
2445
+ files = {f.filename for f in pr.get_files() if not f.filename.startswith("tests/visual.spec.ts-snapshots/")}
2446
+ for f in files:
2447
+ file_to_prs[f].append(num)
2448
+
2449
+ overlap_groups = defaultdict(list)
2450
+ for file, prs in file_to_prs.items():
2451
+ if len(prs) > 1:
2452
+ overlap_groups[frozenset(prs)].append(file)
2453
+
2454
+ report = ["--- EXACT OVERLAP GROUPS ---"]
2455
+ for pr_set, overlap_files in sorted(overlap_groups.items(), key=lambda x: len(x[1]), reverse=True):
2456
+ pr_list = sorted(list(pr_set), key=int)
2457
+ report.append(f"PRs {', '.join(pr_list)} overlap on {len(overlap_files)} files:")
2458
+ for pr_num in pr_list:
2459
+ report.append(f" [{pr_num}] {pr_titles.get(pr_num)}")
2460
+
2461
+ prs_output = "\n".join(report)
2462
+
2463
+ # Generate workflow plan
2464
+ plan_dir = get_or_create_log_dir("workflows")
2465
+ plan_path = os.path.join(plan_dir, "workflow-plan-aggregate-prs.md")
2466
+
2467
+ with open(plan_path, "w", encoding="utf-8") as f:
2468
+ f.write(f"""# Workflow Plan: Aggregate PRs
2469
+
2470
+ ## Agent Instructions
2471
+
2472
+ - setup complete
2473
+ - validation complete
2474
+ - open PRs retrieved
2475
+
2476
+ Agent must not repeat these steps.
2477
+
2478
+ ---
2479
+
2480
+ ## Workflow State
2481
+
2482
+ [x] Environment Validation
2483
+ [x] Retrieve Open PRs
2484
+ [ ] Review Overlaps
2485
+ [ ] Consolidate/Abandon PRs
2486
+ [ ] Completion Verification
2487
+
2488
+ ---
2489
+
2490
+ ## Collected Context
2491
+
2492
+ ### Validation Output
2493
+ ```text
2494
+ {env_output}
2495
+ ```
2496
+
2497
+ ### Open PRs Output
2498
+ ```text
2499
+ {prs_output}
2500
+ ```
2501
+
2502
+ ---
2503
+
2504
+ ## Allowed Files
2505
+
2506
+ Agent may read:
2507
+ `.agents/workflows/REVIEW_INSTRUCTIONS.md`
2508
+
2509
+ ---
2510
+
2511
+ ## Writable Files
2512
+
2513
+ Agent may modify:
2514
+ (Any relevant branch or PR metadata using `td`)
2515
+
2516
+ ---
2517
+
2518
+ ## Remaining Tasks
2519
+
2520
+ ### Step 1
2521
+ Review the overlap output.
2522
+
2523
+ ### Step 2
2524
+ Use `td gh` commands to merge, close, or consolidate redundant pull requests.
2525
+
2526
+ ### Step 3
2527
+ Verify all related PRs have been appropriately tagged or closed.
2528
+
2529
+ ---
2530
+
2531
+ ## Completion Criteria
2532
+
2533
+ Overlapping functionality identified and resolved.
2534
+
2535
+ """)
2536
+ return {"status": "success", "plan_path": plan_path}
2537
+
2538
+ def resolve_pr_conflicts(
2539
+ self,
2540
+ prNumber: int,
2541
+ allow_unrelated: bool = False,
2542
+ strategy: Optional[str] = None,
2543
+ push: bool = False,
2544
+ continue_resolve: bool = False,
2545
+ ) -> Dict[str, Any]:
2546
+ """
2547
+ Sets up a worktree for a specific PR and attempts to merge the base branch.
2548
+ If continue_resolve is True, it finalizes an in-progress resolution.
2549
+ """
2550
+ original_cwd = os.getcwd()
2551
+ # Use a path that is clearly temporary and matches existing patterns for ignored files
2552
+ worktree_path = os.path.join(original_cwd, f"worktree-pr-{prNumber}.tmp")
2553
+ changed_dir = False
2554
+
2555
+ try:
2556
+ # 1. Fetch PR details early to fail fast
2557
+ pr_data = self.github.fetch_pr_details(prNumber)
2558
+ default_base = PROJECT_CONFIG.base_branch_name
2559
+ base_branch = pr_data.get("base", {}).get("ref", default_base)
2560
+ head_ref = pr_data.get("head", {}).get("ref")
2561
+
2562
+ if not head_ref:
2563
+ raise CLIError(f"Could not determine head ref for PR #{prNumber}")
2564
+
2565
+ if continue_resolve:
2566
+ if not os.path.exists(worktree_path):
2567
+ raise CLIError(f"No in-progress resolution found for PR #{prNumber} at {worktree_path}")
2568
+
2569
+ changed_dir = True
2570
+ os.chdir(worktree_path)
2571
+
2572
+ # Check for unmerged paths
2573
+ res_diff = run_command(["git", "diff", "--name-only", "--diff-filter=U"], check=False)
2574
+ unmerged = res_diff.stdout.strip()
2575
+ if unmerged:
2576
+ raise CLIError(
2577
+ f"Unresolved conflicts remain in PR #{prNumber}:\n{unmerged}\n\nPlease resolve them in {worktree_path} before continuing."
2578
+ )
2579
+
2580
+ # Stage all changes
2581
+ run_command(["git", "add", "."], check=True)
2582
+
2583
+ # Check if there is anything to commit
2584
+ status_res = run_command(["git", "status", "--porcelain"], check=False)
2585
+ if status_res.stdout.strip():
2586
+ commit_msg = f"Merge {base_branch} into PR #{prNumber}"
2587
+ run_command(["git", "commit", "-m", commit_msg], check=True)
2588
+
2589
+ # We reuse the push logic below
2590
+ res_code = 0
2591
+ else:
2592
+ # 2. Clean up existing worktree if present
2593
+ self._cleanup_worktree(worktree_path)
2594
+
2595
+ # 3. Fetch PR branch and create worktree directly on it
2596
+ run_command(["git", "fetch", "origin", f"+pull/{prNumber}/head:{head_ref}"], check=True)
2597
+ run_command(["git", "worktree", "add", worktree_path, head_ref], check=True)
2598
+
2599
+ # 4. Switch to worktree and perform git operations
2600
+ changed_dir = True
2601
+ os.chdir(worktree_path)
2602
+
2603
+ # Ensure origin/base_branch is up-to-date
2604
+ run_command(["git", "fetch", "origin", base_branch], check=True)
2605
+
2606
+ # Attempt merge from base branch.
2607
+ merge_cmd = [
2608
+ "git",
2609
+ "merge",
2610
+ f"origin/{base_branch}",
2611
+ "-m",
2612
+ f"Merge {base_branch} into PR #{prNumber}",
2613
+ ]
2614
+ if allow_unrelated:
2615
+ merge_cmd.append("--allow-unrelated-histories")
2616
+ if strategy in ["ours", "theirs"]:
2617
+ merge_cmd.extend(["-X", strategy])
2618
+
2619
+ res = run_command(merge_cmd, check=False)
2620
+ if not isinstance(res, subprocess.CompletedProcess):
2621
+ raise CLIError("Failed to execute git merge command")
2622
+
2623
+ if res.returncode != 0 and not allow_unrelated:
2624
+ # Detect and retry unrelated histories even if not explicitly requested
2625
+ merge_output = (res.stdout or "") + (res.stderr or "")
2626
+ if "unrelated histories" in merge_output.lower():
2627
+ log_warn(
2628
+ f"Disjoint history detected for PR #{prNumber}. Retrying with --allow-unrelated-histories"
2629
+ )
2630
+ res = run_command(merge_cmd + ["--allow-unrelated-histories"], check=False)
2631
+ if not isinstance(res, subprocess.CompletedProcess):
2632
+ raise CLIError("Retry merge with --allow-unrelated-histories failed execution")
2633
+
2634
+ res_code = res.returncode
2635
+
2636
+ if res_code == 0:
2637
+ message = f"✅ PR #{prNumber} merged successfully with {base_branch}."
2638
+ if not continue_resolve:
2639
+ message += f"\nPath: {worktree_path}"
2640
+
2641
+ status = "success"
2642
+
2643
+ # Always push if continue_resolve is True, or if push flag was set
2644
+ if push or continue_resolve:
2645
+ head_branch = pr_data.get("head", {}).get("ref")
2646
+ if not head_branch:
2647
+ raise CLIError(f"Cannot push: head branch is missing for PR #{prNumber}")
2648
+ try:
2649
+ # Use authenticated URL if token is available to avoid terminal prompts
2650
+ if self.github.token and self.github.repo:
2651
+ auth_url = f"https://x-access-token:{self.github.token}@github.com/{self.github.repo}.git"
2652
+ run_command(["git", "push", auth_url, f"HEAD:{head_branch}"], check=True)
2653
+ else:
2654
+ run_command(["git", "push", "origin", head_branch], check=True)
2655
+ message += f"\n🚀 Successfully pushed resolution to {head_branch}"
2656
+ except Exception as push_err:
2657
+ message += f"\n⚠️ Merge successful but push failed: {str(push_err)}"
2658
+ status = "partial_success"
2659
+
2660
+ if continue_resolve and status == "success":
2661
+ # Cleanup worktree on success
2662
+ os.chdir(original_cwd)
2663
+ changed_dir = False
2664
+ self._cleanup_worktree(worktree_path)
2665
+ message += "\n🧹 Temporary worktree cleaned up."
2666
+ else:
2667
+ message = f"⚠️ Conflicts detected in PR #{prNumber} when merging {base_branch}.\nAction Required: Resolve them manually in the worktree.\nCommand: cd {worktree_path}"
2668
+ status = "conflict"
2669
+
2670
+ return {
2671
+ "status": status,
2672
+ "message": message,
2673
+ "worktree_path": worktree_path,
2674
+ "prNumber": prNumber,
2675
+ "base_branch": base_branch,
2676
+ "head_branch": head_ref,
2677
+ }
2678
+ except CLIError:
2679
+ raise
2680
+ finally:
2681
+ if changed_dir:
2682
+ os.chdir(original_cwd)
2683
+
2684
+ def generate_aggregation_workflow(self, prNumbers: List[int], target_branch: str) -> Dict[str, Any]:
2685
+ """Generates a deterministic aggregation workflow plan for an agent."""
2686
+ # 1. Environment Validation
2687
+ env_res = self.runtime_check()
2688
+ env_output = f"Runtime OK: node {env_res['node']}, pnpm {env_res['pnpm']}"
2689
+
2690
+ # 2. Fetch PR Metadata and Overlaps
2691
+ pr_details = {}
2692
+ file_to_prs: Dict[str, set[int]] = defaultdict(set)
2693
+ pr_hunks = {}
2694
+
2695
+ for pr_num in prNumbers:
2696
+ details = self.github.fetch_pr_details(pr_num)
2697
+ pr_details[pr_num] = details
2698
+ files = self.github.fetch_pr_files(pr_num)
2699
+ diff = self.github.fetch_pr_diff(pr_num)
2700
+ pr_hunks[pr_num] = self._extract_diff_hunks(diff)
2701
+
2702
+ # Explicitly cast to str to satisfy type checkers
2703
+ pr_files = {str(f.get("filename")) for f in files if f.get("filename")}
2704
+ for filename_str in pr_files:
2705
+ file_to_prs[filename_str].add(pr_num)
2706
+
2707
+ overlapping_files: Dict[str, List[int]] = {
2708
+ f: sorted(list(prs)) for f, prs in file_to_prs.items() if len(prs) > 1
2709
+ }
2710
+
2711
+ # 3. Detect Structural Conflicts (Line-level overlaps)
2712
+ conflicts: List[Dict[str, Any]] = []
2713
+ for filename, prs in overlapping_files.items():
2714
+ for i in range(len(prs)):
2715
+ for j in range(i + 1, len(prs)):
2716
+ hunks1 = pr_hunks[prs[i]].get(filename, [])
2717
+ hunks2 = pr_hunks[prs[j]].get(filename, [])
2718
+ for s1, e1 in hunks1:
2719
+ for s2, e2 in hunks2:
2720
+ if max(s1, s2) <= min(e1, e2):
2721
+ conflicts.append(
2722
+ {
2723
+ "file": filename,
2724
+ "prs": [prs[i], prs[j]],
2725
+ "range": [max(s1, s2), min(e1, e2)],
2726
+ }
2727
+ )
2728
+
2729
+ # 4. Generate Markdown Files
2730
+ # Strict sanitization: allow only alphanumeric, underscores, and hyphens
2731
+ sanitized_target = sanitize_metadata(target_branch)
2732
+ workflow_plan_path = os.path.join(
2733
+ get_or_create_log_dir("workflows"), f"workflow-plan-aggregation-{sanitized_target}.md"
2734
+ )
2735
+ context_details_path = os.path.join(
2736
+ get_or_create_log_dir("reviews"), f"aggregation-context-{sanitized_target}.md"
2737
+ )
2738
+ plan_skeleton_path = os.path.join(get_or_create_log_dir("reviews"), f"aggregation-plan-{sanitized_target}.md")
2739
+
2740
+ # --- Workflow Plan Template ---
2741
+ with open(workflow_plan_path, "w", encoding="utf-8") as f:
2742
+ f.write(f"""# Reviewing Aggregation Planning Guide: {escape_md(target_branch)}
2743
+
2744
+ ## Agent Instructions
2745
+ - **Environment Check**: Ensure Python dependencies and pnpm {PROJECT_CONFIG.pnpm_version} are available.
2746
+ - setup complete
2747
+ - validation complete
2748
+ - context collected
2749
+ - overlaps identified
2750
+
2751
+ Agent must not repeat these steps.
2752
+
2753
+ ---
2754
+
2755
+ ## Workflow State
2756
+ [x] Environment Validation
2757
+ [x] PR Context Collection
2758
+ [x] Overlap Identification
2759
+ [ ] Conflict Resolution
2760
+ [ ] Integration Verification
2761
+ [ ] Final Aggregation
2762
+
2763
+ ---
2764
+
2765
+ ## Collected Context
2766
+ ### Validation Output
2767
+ ```text
2768
+ {env_output}
2769
+ ```
2770
+
2771
+ ### Overlap Summary
2772
+ Found {len(overlapping_files)} overlapping files and {len(conflicts)} structural conflicts across {len(prNumbers)} PRs.
2773
+
2774
+ ---
2775
+
2776
+ ## Remaining Tasks
2777
+ ### Step 1: Review Overlaps
2778
+ Examine the files listed in `aggregation-context-{sanitized_target}.md`.
2779
+
2780
+ ### Step 2: Resolve Conflicts
2781
+ Perform the merge and resolve any structural or semantic conflicts.
2782
+
2783
+ - **Edge Case: Unrelated Histories**: If you encounter `fatal: refusing to merge unrelated histories`, retry with `--allow-unrelated-histories`.
2784
+ - **Heavy Conflicts Fallback**: If standard merging creates excessive noise, use `git diff target...head > patch` to generate a clean patch and apply it manually.
2785
+
2786
+ ### Step 3: Verify
2787
+ Run the validation suite to ensure the aggregated branch is stable.
2788
+ """)
2789
+
2790
+ # --- Context Details Template ---
2791
+ with open(context_details_path, "w", encoding="utf-8") as f:
2792
+ f.write(f"# Aggregation Context Details: {escape_md(target_branch)}\n\n")
2793
+ f.write("## Targeted PRs\n")
2794
+ for pr_num in prNumbers:
2795
+ details = pr_details.get(pr_num, {})
2796
+ title = escape_md(details.get("title", ""))
2797
+ login = escape_md(details.get("user", {}).get("login", ""))
2798
+ f.write(f"- **PR #{pr_num}**: {title} (@{login})\n")
2799
+
2800
+ f.write("\n## Overlapping Files\n")
2801
+ if not overlapping_files:
2802
+ f.write("No overlapping files detected.\n")
2803
+ else:
2804
+ for filename, prs in sorted(overlapping_files.items()):
2805
+ f.write(f"- `{filename}`: Changed in PRs {', '.join(f'#{p}' for p in prs)}\n")
2806
+
2807
+ f.write("\n## Structural Conflicts (Line Overlaps)\n")
2808
+ if not conflicts:
2809
+ f.write("No direct line-level conflicts detected.\n")
2810
+ else:
2811
+ for c in conflicts:
2812
+ c_file = str(c.get("file", "unknown"))
2813
+ c_prs = c.get("prs", [])
2814
+ c_range = c.get("range", [0, 0])
2815
+ if len(c_prs) >= 2 and len(c_range) >= 2:
2816
+ f.write(
2817
+ f"- `{c_file}`: PR #{c_prs[0]} and PR #{c_prs[1]} overlap at lines {c_range[0]}-{c_range[1]}\n"
2818
+ )
2819
+
2820
+ # --- Plan Skeleton Template ---
2821
+ with open(plan_skeleton_path, "w", encoding="utf-8") as f:
2822
+ f.write(f"""# Aggregation Plan Skeleton: {escape_md(target_branch)}
2823
+
2824
+ ## Integration Steps
2825
+ 1. **Prepare Base**: Checkout the latest base branch.
2826
+ 2. **Sequential Merge**: Merge each PR branch into the target branch.
2827
+ 3. **Manual Resolution**: For each overlapping file, ensure logical consistency.
2828
+ 4. **Validation**: Run `pnpm run ci:local` or equivalent.
2829
+
2830
+ ## Completion Criteria
2831
+ - All PRs successfully integrated.
2832
+ - No merge markers remain in the codebase.
2833
+ - All tests pass in the aggregated branch.
2834
+ """)
2835
+
2836
+ return {
2837
+ "status": "success",
2838
+ "plan_path": workflow_plan_path,
2839
+ "context_path": context_details_path,
2840
+ "skeleton_path": plan_skeleton_path,
2841
+ }