boomtick-cli 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- boomtick_cli-0.2.1.dist-info/METADATA +20 -0
- boomtick_cli-0.2.1.dist-info/RECORD +103 -0
- boomtick_cli-0.2.1.dist-info/WHEEL +5 -0
- boomtick_cli-0.2.1.dist-info/entry_points.txt +3 -0
- boomtick_cli-0.2.1.dist-info/top_level.txt +1 -0
- dev_tools/__init__.py +1 -0
- dev_tools/cli-schema.json +2028 -0
- dev_tools/cli.py +1542 -0
- dev_tools/config.py +235 -0
- dev_tools/constants.py +14 -0
- dev_tools/daemon.py +146 -0
- dev_tools/dist/config.js +108 -0
- dev_tools/dist/index.js +10 -0
- dev_tools/dist/lib/error_utils.js +8 -0
- dev_tools/dist/lib/git.js +31 -0
- dev_tools/dist/lib/result.js +21 -0
- dev_tools/dist/lib/shell.js +91 -0
- dev_tools/dist/lib/shell.test.js +10 -0
- dev_tools/dist/lib/td-cli.js +27 -0
- dev_tools/dist/lib/test-utils.js +20 -0
- dev_tools/dist/mcp/definitions.js +242 -0
- dev_tools/dist/mcp/server.js +317 -0
- dev_tools/dist/mcp/tools.js +18 -0
- dev_tools/dist/tools/contract.js +2069 -0
- dev_tools/dist/tools/ddgs.search.js +50 -0
- dev_tools/dist/tools/ddgs.search.test.js +67 -0
- dev_tools/dist/tools/ddgs_search.py +23 -0
- dev_tools/dist/tools/github.checkout_branch.js +20 -0
- dev_tools/dist/tools/github.comment_triage_summary.js +25 -0
- dev_tools/dist/tools/github.create_issue.js +28 -0
- dev_tools/dist/tools/github.create_issue.test.js +54 -0
- dev_tools/dist/tools/github.create_pull_request.js +44 -0
- dev_tools/dist/tools/github.get_merge_conflict_files.js +26 -0
- dev_tools/dist/tools/github.get_pr.js +18 -0
- dev_tools/dist/tools/github.get_pr_diff.js +24 -0
- dev_tools/dist/tools/github.issue_comment.js +24 -0
- dev_tools/dist/tools/github.issue_update.js +29 -0
- dev_tools/dist/tools/github.issue_view.js +28 -0
- dev_tools/dist/tools/github.open_replacement_pr.js +41 -0
- dev_tools/dist/tools/github.search_open_prs.js +31 -0
- dev_tools/dist/tools/github.search_open_prs.test.js +51 -0
- dev_tools/dist/tools/jules/cancel-session.js +20 -0
- dev_tools/dist/tools/jules/create-session.js +20 -0
- dev_tools/dist/tools/jules/create-session.test.js +99 -0
- dev_tools/dist/tools/jules/get-messages.js +19 -0
- dev_tools/dist/tools/jules/get-messages.test.js +45 -0
- dev_tools/dist/tools/jules/get-pr.js +14 -0
- dev_tools/dist/tools/jules/get-pr.test.js +37 -0
- dev_tools/dist/tools/jules/get-session.js +10 -0
- dev_tools/dist/tools/jules/list-sessions.js +43 -0
- dev_tools/dist/tools/jules/send-message.js +22 -0
- dev_tools/dist/tools/jules/send-message.test.js +56 -0
- dev_tools/dist/tools/jules/shared.js +26 -0
- dev_tools/dist/tools/jules/trigger-feedback.js +16 -0
- dev_tools/dist/tools/jules/trigger-feedback.test.js +42 -0
- dev_tools/dist/tools/repo.commit_patch.js +33 -0
- dev_tools/dist/tools/repo.create_branch.js +21 -0
- dev_tools/dist/tools/repo.create_branch.test.js +42 -0
- dev_tools/dist/tools/repo.create_repair_branch.js +38 -0
- dev_tools/dist/tools/repo.get_changed_files.js +21 -0
- dev_tools/dist/tools/repo.get_command_schema.js +27 -0
- dev_tools/dist/tools/repo.get_package_scripts.js +35 -0
- dev_tools/dist/tools/repo.get_package_scripts.test.js +39 -0
- dev_tools/dist/tools/repo.get_route_map.js +37 -0
- dev_tools/dist/tools/repo.logs.js +16 -0
- dev_tools/dist/tools/repo.read_agent_context.js +20 -0
- dev_tools/dist/tools/repo.read_ci_logs.js +18 -0
- dev_tools/dist/tools/repo.run_lighthouse.js +37 -0
- dev_tools/dist/tools/repo.run_playwright.js +29 -0
- dev_tools/dist/tools/repo.run_tests.js +43 -0
- dev_tools/dist/tools/types.js +1 -0
- dev_tools/get_ai_context.py +63 -0
- dev_tools/handlers/__init__.py +1 -0
- dev_tools/handlers/command_handler.py +69 -0
- dev_tools/mcp_server.py +36 -0
- dev_tools/models.py +354 -0
- dev_tools/orchestrator.py +2841 -0
- dev_tools/pr_overlap.py +139 -0
- dev_tools/resources/__init__.py +1 -0
- dev_tools/resources/build-repo-context.py +182 -0
- dev_tools/resources/prompt_constants.json +5 -0
- dev_tools/resources/review_template.md +41 -0
- dev_tools/resources/ux-audit.config.json +15 -0
- dev_tools/resources/visual_guidelines.json +3 -0
- dev_tools/review_read_pass.py +232 -0
- dev_tools/schema_gen.py +55 -0
- dev_tools/schema_utils.py +125 -0
- dev_tools/scope_check.py +85 -0
- dev_tools/services/__init__.py +1 -0
- dev_tools/services/ai_service.py +816 -0
- dev_tools/services/dependency_graph.py +123 -0
- dev_tools/services/github.py +783 -0
- dev_tools/services/jules.py +181 -0
- dev_tools/services/repair_service.py +199 -0
- dev_tools/services/vector_store.py +82 -0
- dev_tools/services/vision_service.py +91 -0
- dev_tools/td_cli.py +28 -0
- dev_tools/utils/__init__.py +1035 -0
- dev_tools/utils/git.py +68 -0
- dev_tools/ux_report.py +213 -0
- dev_tools/verify_infra.py +91 -0
- dev_tools/verify_versions.py +191 -0
- dev_tools/version_utils.py +175 -0
|
@@ -0,0 +1,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
|
+
}
|