claude-dev-env 1.28.1 → 1.29.0
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.
- package/agents/caveman.md +74 -0
- package/hooks/blocking/code_rules_enforcer.py +82 -7
- package/hooks/blocking/code_rules_path_utils.py +31 -0
- package/hooks/blocking/es_exe_path_rewriter.py +159 -0
- package/hooks/blocking/hedging_language_blocker.py +12 -2
- package/hooks/blocking/test_code_rules_enforcer.py +148 -0
- package/hooks/blocking/test_code_rules_enforcer_config_path.py +123 -0
- package/hooks/blocking/test_code_rules_enforcer_magic_allowlist.py +1 -1
- package/hooks/blocking/test_code_rules_path_utils.py +52 -0
- package/hooks/blocking/test_es_exe_path_rewriter.py +369 -0
- package/hooks/blocking/test_hedging_language_blocker.py +7 -6
- package/hooks/config/dynamic_stderr_handler.py +22 -0
- package/hooks/config/path_rewriter_constants.py +13 -0
- package/hooks/config/project_paths_reader.py +78 -0
- package/hooks/config/setup_project_paths_constants.py +41 -0
- package/hooks/config/test_dynamic_stderr_handler.py +48 -0
- package/hooks/config/test_messages.py +5 -1
- package/hooks/config/test_path_rewriter_constants.py +57 -0
- package/hooks/config/test_project_paths_reader.py +149 -0
- package/hooks/config/test_setup_project_paths_constants.py +74 -0
- package/hooks/git-hooks/test_config.py +1 -0
- package/hooks/git-hooks/test_gate_utils.py +1 -0
- package/hooks/git-hooks/test_pre_commit.py +1 -0
- package/hooks/git-hooks/test_pre_push.py +1 -0
- package/hooks/hooks.json +10 -0
- package/hooks/session/test_untracked_repo_detector.py +192 -0
- package/hooks/session/untracked_repo_detector.py +103 -0
- package/hooks/validators/exempt_paths.py +17 -14
- package/hooks/validators/test_exempt_paths.py +65 -0
- package/hooks/validators/test_git_checks.py +17 -17
- package/package.json +1 -1
- package/scripts/config/__init__.py +1 -0
- package/scripts/config/groq_bugteam_config.py +118 -0
- package/scripts/config/test_groq_bugteam_config.py +72 -0
- package/scripts/groq_bugteam.README.md +129 -0
- package/scripts/groq_bugteam.py +586 -0
- package/scripts/setup_project_paths.py +347 -0
- package/scripts/test_groq_bugteam.py +391 -0
- package/scripts/test_setup_project_paths.py +532 -0
- package/scripts/test_setup_project_paths_config.py +6 -0
- package/skills/bugteam/CONSTRAINTS.md +1 -1
- package/skills/bugteam/PROMPTS.md +1 -1
- package/skills/bugteam/SKILL.md +5 -5
- package/skills/bugteam/SKILL_EVALS.md +5 -5
- package/skills/bugteam/reference/audit-and-teammates.md +3 -3
- package/skills/bugteam/reference/audit-contract.md +159 -0
- package/skills/bugteam/reference/team-setup.md +2 -2
- package/skills/bugteam/scripts/bugteam_preflight.py +66 -0
- package/skills/bugteam/scripts/test_bugteam_preflight.py +189 -0
- package/skills/copilot-review/SKILL.md +145 -0
- package/skills/findbugs/SKILL.md +14 -22
- package/skills/qbug/SKILL.md +56 -13
- package/skills/qbug/test_qbug_skill_audit_schema.py +156 -0
- package/skills/qbug/test_qbug_skill_post_fix_audit.py +103 -0
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Groq-powered PR bug auditor and auto-fixer.
|
|
3
|
+
|
|
4
|
+
Single-pass adaptation of the ``bugteam`` skill that replaces the multi-agent
|
|
5
|
+
orchestration with direct calls to Groq's chat completions API. No orchestrated
|
|
6
|
+
team, no 10-loop convergence: one audit call, one fix call, one commit and
|
|
7
|
+
push per PR.
|
|
8
|
+
|
|
9
|
+
Stateless and PII-free. All GitHub identifiers arrive on stdin as JSON;
|
|
10
|
+
``GROQ_API_KEY`` is read from the environment. Output is JSON on stdout.
|
|
11
|
+
|
|
12
|
+
Pipeline (per invocation):
|
|
13
|
+
1. Read PR metadata, unified diff, file contents from stdin.
|
|
14
|
+
2. Call Groq with the audit prompt. Parse findings as JSON.
|
|
15
|
+
3. For each finding, call Groq with the fix prompt. Parse a file patch.
|
|
16
|
+
4. Write patched files to the worktree, stage, commit, push.
|
|
17
|
+
5. Emit JSON: findings, fix outcomes, commit sha, review body.
|
|
18
|
+
|
|
19
|
+
The caller is responsible for PR review posting -- this script emits a
|
|
20
|
+
``review_body`` string but does not talk to the GitHub API.
|
|
21
|
+
|
|
22
|
+
Stdin schema::
|
|
23
|
+
|
|
24
|
+
{
|
|
25
|
+
"pr_number": int,
|
|
26
|
+
"owner": str,
|
|
27
|
+
"repo": str,
|
|
28
|
+
"base_ref": str,
|
|
29
|
+
"head_ref": str,
|
|
30
|
+
"diff": str, # unified diff text
|
|
31
|
+
"files_content": {path: str}, # current content of each file in diff
|
|
32
|
+
"worktree_path": str, # absolute path to a worktree on head_ref
|
|
33
|
+
"apply_fixes": bool # default true
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
Stdout schema::
|
|
37
|
+
|
|
38
|
+
{
|
|
39
|
+
"findings": [ {severity, category, file, line, title, description}, ... ],
|
|
40
|
+
"fix_outcomes": [ {finding_index, status, reason?}, ... ],
|
|
41
|
+
"commit_sha": str,
|
|
42
|
+
"review_body": str,
|
|
43
|
+
"audit_model": str,
|
|
44
|
+
"fix_model": str,
|
|
45
|
+
"error": str # only on hard failure
|
|
46
|
+
}
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import json
|
|
52
|
+
import os
|
|
53
|
+
import re
|
|
54
|
+
import subprocess
|
|
55
|
+
import sys
|
|
56
|
+
import time
|
|
57
|
+
import urllib.error
|
|
58
|
+
import urllib.request
|
|
59
|
+
from dataclasses import dataclass
|
|
60
|
+
|
|
61
|
+
from config.groq_bugteam_config import (
|
|
62
|
+
AUDIT_SYSTEM_PROMPT,
|
|
63
|
+
FIX_SYSTEM_PROMPT,
|
|
64
|
+
GROQ_API_ENDPOINT,
|
|
65
|
+
GROQ_AUDIT_MAX_COMPLETION_TOKENS,
|
|
66
|
+
GROQ_AUDIT_TEMPERATURE,
|
|
67
|
+
GROQ_FALLBACK_MODEL,
|
|
68
|
+
GROQ_FIX_MAX_COMPLETION_TOKENS,
|
|
69
|
+
GROQ_FIX_TEMPERATURE,
|
|
70
|
+
GROQ_PRIMARY_MODEL,
|
|
71
|
+
GROQ_REQUEST_TIMEOUT_SECONDS,
|
|
72
|
+
GROQ_RETRY_BACKOFF_SECONDS,
|
|
73
|
+
JSON_INDENT_SPACES,
|
|
74
|
+
MAXIMUM_DIFF_CHARACTERS,
|
|
75
|
+
MAXIMUM_FILE_CONTENT_CHARACTERS,
|
|
76
|
+
MAXIMUM_FINDINGS_PER_PR,
|
|
77
|
+
NO_FINDINGS_REVIEW_BODY,
|
|
78
|
+
PIPELINE_FAILURE_EXIT_CODE,
|
|
79
|
+
REVIEW_BODY_HEADER_TEMPLATE,
|
|
80
|
+
TEXT_CLAMP_HEAD_PARTS,
|
|
81
|
+
TEXT_CLAMP_TOTAL_PARTS,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class GroqCallResult:
|
|
86
|
+
content: str
|
|
87
|
+
model: str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def is_recoverable_http_error(error: urllib.error.HTTPError) -> bool:
|
|
91
|
+
return error.code in (408, 429, 500, 502, 503, 504)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def should_skip_to_next_model(error: urllib.error.HTTPError) -> bool:
|
|
95
|
+
return error.code == 413
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def clamp_text(text: str, max_characters: int) -> str:
|
|
99
|
+
if len(text) <= max_characters:
|
|
100
|
+
return text
|
|
101
|
+
head_length = max_characters * TEXT_CLAMP_HEAD_PARTS // TEXT_CLAMP_TOTAL_PARTS
|
|
102
|
+
tail_length = max_characters - head_length
|
|
103
|
+
head = text[:head_length]
|
|
104
|
+
tail = text[-tail_length:]
|
|
105
|
+
truncated_count = len(text) - max_characters
|
|
106
|
+
return f"{head}\n\n... [truncated {truncated_count} chars] ...\n\n{tail}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def post_to_groq(
|
|
110
|
+
api_key: str,
|
|
111
|
+
model: str,
|
|
112
|
+
messages: list,
|
|
113
|
+
temperature: float,
|
|
114
|
+
max_completion_tokens: int,
|
|
115
|
+
) -> str:
|
|
116
|
+
payload = json.dumps(
|
|
117
|
+
{
|
|
118
|
+
"model": model,
|
|
119
|
+
"messages": messages,
|
|
120
|
+
"response_format": {"type": "json_object"},
|
|
121
|
+
"temperature": temperature,
|
|
122
|
+
"max_completion_tokens": max_completion_tokens,
|
|
123
|
+
}
|
|
124
|
+
).encode("utf-8")
|
|
125
|
+
request = urllib.request.Request(
|
|
126
|
+
GROQ_API_ENDPOINT,
|
|
127
|
+
data=payload,
|
|
128
|
+
headers={
|
|
129
|
+
"Authorization": f"Bearer {api_key}",
|
|
130
|
+
"Content-Type": "application/json",
|
|
131
|
+
"User-Agent": "groq-bugteam/1.0",
|
|
132
|
+
},
|
|
133
|
+
method="POST",
|
|
134
|
+
)
|
|
135
|
+
with urllib.request.urlopen(
|
|
136
|
+
request, timeout=GROQ_REQUEST_TIMEOUT_SECONDS
|
|
137
|
+
) as open_connection:
|
|
138
|
+
raw_response_bytes = open_connection.read()
|
|
139
|
+
parsed = json.loads(raw_response_bytes.decode("utf-8"))
|
|
140
|
+
return parsed["choices"][0]["message"]["content"]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def call_groq_with_fallback(
|
|
144
|
+
api_key: str, messages: list, temperature: float, max_completion_tokens: int
|
|
145
|
+
) -> GroqCallResult:
|
|
146
|
+
last_error: Exception | None = None
|
|
147
|
+
for model in (GROQ_PRIMARY_MODEL, GROQ_FALLBACK_MODEL):
|
|
148
|
+
for attempt_index, backoff_seconds in enumerate(
|
|
149
|
+
(0, *GROQ_RETRY_BACKOFF_SECONDS)
|
|
150
|
+
):
|
|
151
|
+
if backoff_seconds:
|
|
152
|
+
time.sleep(backoff_seconds)
|
|
153
|
+
try:
|
|
154
|
+
content = post_to_groq(
|
|
155
|
+
api_key, model, messages, temperature, max_completion_tokens
|
|
156
|
+
)
|
|
157
|
+
return GroqCallResult(content=content, model=model)
|
|
158
|
+
except urllib.error.HTTPError as http_error:
|
|
159
|
+
last_error = http_error
|
|
160
|
+
if should_skip_to_next_model(http_error):
|
|
161
|
+
break
|
|
162
|
+
if not is_recoverable_http_error(http_error):
|
|
163
|
+
break
|
|
164
|
+
except (
|
|
165
|
+
urllib.error.URLError,
|
|
166
|
+
TimeoutError,
|
|
167
|
+
json.JSONDecodeError,
|
|
168
|
+
) as transport_error:
|
|
169
|
+
last_error = transport_error
|
|
170
|
+
raise RuntimeError(f"Groq request failed after fallbacks: {last_error}")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def parse_json_object(raw_text: str) -> dict:
|
|
174
|
+
try:
|
|
175
|
+
return json.loads(raw_text)
|
|
176
|
+
except json.JSONDecodeError:
|
|
177
|
+
pass
|
|
178
|
+
match = re.search(r"\{[\s\S]*\}", raw_text)
|
|
179
|
+
if not match:
|
|
180
|
+
raise ValueError("Groq response did not contain a JSON object")
|
|
181
|
+
return json.loads(match.group(0))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def normalize_findings(raw_findings: list, files_content: dict) -> list:
|
|
185
|
+
normalized = []
|
|
186
|
+
for each_raw in raw_findings:
|
|
187
|
+
file_path = str(each_raw.get("file", "")).strip()
|
|
188
|
+
if not file_path or file_path not in files_content:
|
|
189
|
+
continue
|
|
190
|
+
try:
|
|
191
|
+
line_number = int(each_raw.get("line", 0))
|
|
192
|
+
except (TypeError, ValueError):
|
|
193
|
+
line_number = 0
|
|
194
|
+
severity = str(each_raw.get("severity", "P2")).upper()
|
|
195
|
+
if severity not in ("P0", "P1", "P2"):
|
|
196
|
+
severity = "P2"
|
|
197
|
+
category = str(each_raw.get("category", "J")).upper()[:1]
|
|
198
|
+
normalized.append(
|
|
199
|
+
{
|
|
200
|
+
"severity": severity,
|
|
201
|
+
"category": category,
|
|
202
|
+
"file": file_path,
|
|
203
|
+
"line": line_number,
|
|
204
|
+
"title": str(each_raw.get("title", "")).strip()[:200],
|
|
205
|
+
"description": str(each_raw.get("description", "")).strip(),
|
|
206
|
+
}
|
|
207
|
+
)
|
|
208
|
+
return normalized
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def run_audit(api_key: str, diff_text: str, files_content: dict) -> tuple:
|
|
212
|
+
clamped_diff = clamp_text(diff_text, MAXIMUM_DIFF_CHARACTERS)
|
|
213
|
+
files_block_parts = []
|
|
214
|
+
for each_path, each_content in files_content.items():
|
|
215
|
+
clamped_content = clamp_text(each_content, MAXIMUM_FILE_CONTENT_CHARACTERS)
|
|
216
|
+
files_block_parts.append(f"--- FILE: {each_path} ---\n{clamped_content}")
|
|
217
|
+
user_message = (
|
|
218
|
+
"Audit the following pull request diff.\n\n"
|
|
219
|
+
"<diff>\n"
|
|
220
|
+
f"{clamped_diff}\n"
|
|
221
|
+
"</diff>\n\n"
|
|
222
|
+
"<files_post_change>\n"
|
|
223
|
+
+ "\n\n".join(files_block_parts)
|
|
224
|
+
+ "\n</files_post_change>\n"
|
|
225
|
+
)
|
|
226
|
+
groq_result = call_groq_with_fallback(
|
|
227
|
+
api_key,
|
|
228
|
+
messages=[
|
|
229
|
+
{"role": "system", "content": AUDIT_SYSTEM_PROMPT},
|
|
230
|
+
{"role": "user", "content": user_message},
|
|
231
|
+
],
|
|
232
|
+
temperature=GROQ_AUDIT_TEMPERATURE,
|
|
233
|
+
max_completion_tokens=GROQ_AUDIT_MAX_COMPLETION_TOKENS,
|
|
234
|
+
)
|
|
235
|
+
parsed_content = parse_json_object(groq_result.content)
|
|
236
|
+
raw_findings = parsed_content.get("findings", [])[:MAXIMUM_FINDINGS_PER_PR]
|
|
237
|
+
return normalize_findings(raw_findings, files_content), groq_result.model
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def should_write_fixed_file(
|
|
241
|
+
applied_indexes: set, updated_content: str, current_content: str
|
|
242
|
+
) -> bool:
|
|
243
|
+
if not applied_indexes:
|
|
244
|
+
return False
|
|
245
|
+
return updated_content != current_content
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def is_safe_relative_path(each_path: str) -> bool:
|
|
249
|
+
if os.path.isabs(each_path):
|
|
250
|
+
return False
|
|
251
|
+
normalized = os.path.normpath(each_path)
|
|
252
|
+
if normalized.startswith(".." + os.sep) or normalized == "..":
|
|
253
|
+
return False
|
|
254
|
+
parts = normalized.replace("\\", "/").split("/")
|
|
255
|
+
if ".." in parts:
|
|
256
|
+
return False
|
|
257
|
+
return True
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def decode_subprocess_stderr(stderr_value) -> str:
|
|
261
|
+
if stderr_value is None:
|
|
262
|
+
return ""
|
|
263
|
+
if isinstance(stderr_value, bytes):
|
|
264
|
+
return stderr_value.decode("utf-8", "replace")
|
|
265
|
+
return str(stderr_value)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def build_fix_user_message(file_path: str, current_content: str, findings_block: str) -> str:
|
|
269
|
+
trailing_separator = "" if current_content.endswith("\n") else "\n"
|
|
270
|
+
return (
|
|
271
|
+
f"Fix the findings listed below in file `{file_path}`.\n\n"
|
|
272
|
+
"<findings>\n"
|
|
273
|
+
f"{findings_block}\n"
|
|
274
|
+
"</findings>\n\n"
|
|
275
|
+
"<current_file_contents>\n"
|
|
276
|
+
f"{current_content}"
|
|
277
|
+
f"{trailing_separator}</current_file_contents>\n"
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def preserve_trailing_newline(original: str, updated: str) -> str:
|
|
282
|
+
original_ends_with_newline = original.endswith("\n")
|
|
283
|
+
updated_ends_with_newline = updated.endswith("\n")
|
|
284
|
+
if original_ends_with_newline and not updated_ends_with_newline:
|
|
285
|
+
return updated + "\n"
|
|
286
|
+
if not original_ends_with_newline and updated_ends_with_newline:
|
|
287
|
+
return updated.rstrip("\n")
|
|
288
|
+
return updated
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def group_findings_by_file(findings: list) -> dict:
|
|
292
|
+
grouped: dict = {}
|
|
293
|
+
for each_index, each_finding in enumerate(findings):
|
|
294
|
+
grouped.setdefault(each_finding["file"], []).append((each_index, each_finding))
|
|
295
|
+
return grouped
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def generate_fix_for_file(
|
|
299
|
+
api_key: str,
|
|
300
|
+
file_path: str,
|
|
301
|
+
current_content: str,
|
|
302
|
+
findings_for_file: list,
|
|
303
|
+
) -> tuple:
|
|
304
|
+
findings_block = json.dumps(
|
|
305
|
+
[
|
|
306
|
+
{
|
|
307
|
+
"finding_index": each_global_index,
|
|
308
|
+
"severity": each_finding["severity"],
|
|
309
|
+
"category": each_finding["category"],
|
|
310
|
+
"line": each_finding["line"],
|
|
311
|
+
"title": each_finding["title"],
|
|
312
|
+
"description": each_finding["description"],
|
|
313
|
+
}
|
|
314
|
+
for each_global_index, each_finding in findings_for_file
|
|
315
|
+
],
|
|
316
|
+
indent=JSON_INDENT_SPACES,
|
|
317
|
+
)
|
|
318
|
+
user_message = build_fix_user_message(file_path, current_content, findings_block)
|
|
319
|
+
groq_result = call_groq_with_fallback(
|
|
320
|
+
api_key,
|
|
321
|
+
messages=[
|
|
322
|
+
{"role": "system", "content": FIX_SYSTEM_PROMPT},
|
|
323
|
+
{"role": "user", "content": user_message},
|
|
324
|
+
],
|
|
325
|
+
temperature=GROQ_FIX_TEMPERATURE,
|
|
326
|
+
max_completion_tokens=GROQ_FIX_MAX_COMPLETION_TOKENS,
|
|
327
|
+
)
|
|
328
|
+
return parse_json_object(groq_result.content), groq_result.model
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def apply_fixes_and_commit(
|
|
332
|
+
worktree_path: str,
|
|
333
|
+
fixes: dict,
|
|
334
|
+
commit_message: str,
|
|
335
|
+
) -> str:
|
|
336
|
+
if not fixes:
|
|
337
|
+
return ""
|
|
338
|
+
worktree_root = os.path.realpath(worktree_path)
|
|
339
|
+
for each_path, each_new_content in fixes.items():
|
|
340
|
+
if not is_safe_relative_path(each_path):
|
|
341
|
+
raise ValueError(
|
|
342
|
+
f"Refusing to write unsafe path from Groq response: {each_path!r}"
|
|
343
|
+
)
|
|
344
|
+
absolute_path = os.path.join(worktree_root, each_path)
|
|
345
|
+
resolved_path = os.path.realpath(absolute_path)
|
|
346
|
+
if (
|
|
347
|
+
resolved_path != worktree_root
|
|
348
|
+
and not resolved_path.startswith(worktree_root + os.sep)
|
|
349
|
+
):
|
|
350
|
+
raise ValueError(
|
|
351
|
+
f"Refusing to write path that escapes worktree: {each_path!r}"
|
|
352
|
+
)
|
|
353
|
+
parent_directory = os.path.dirname(absolute_path)
|
|
354
|
+
if parent_directory:
|
|
355
|
+
os.makedirs(parent_directory, exist_ok=True)
|
|
356
|
+
with open(absolute_path, "w", encoding="utf-8", newline="\n") as fix_handle:
|
|
357
|
+
fix_handle.write(each_new_content)
|
|
358
|
+
changed_paths = list(fixes.keys())
|
|
359
|
+
subprocess.run(
|
|
360
|
+
["git", "-C", worktree_path, "add", "--", *changed_paths],
|
|
361
|
+
check=True,
|
|
362
|
+
capture_output=True,
|
|
363
|
+
)
|
|
364
|
+
status_result = subprocess.run(
|
|
365
|
+
["git", "-C", worktree_path, "status", "--porcelain"],
|
|
366
|
+
check=True,
|
|
367
|
+
capture_output=True,
|
|
368
|
+
text=True,
|
|
369
|
+
)
|
|
370
|
+
if not status_result.stdout.strip():
|
|
371
|
+
return ""
|
|
372
|
+
subprocess.run(
|
|
373
|
+
["git", "-C", worktree_path, "commit", "-m", commit_message],
|
|
374
|
+
check=True,
|
|
375
|
+
capture_output=True,
|
|
376
|
+
)
|
|
377
|
+
rev_parse_result = subprocess.run(
|
|
378
|
+
["git", "-C", worktree_path, "rev-parse", "HEAD"],
|
|
379
|
+
check=True,
|
|
380
|
+
capture_output=True,
|
|
381
|
+
text=True,
|
|
382
|
+
)
|
|
383
|
+
return rev_parse_result.stdout.strip()
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def push_current_branch(worktree_path: str, head_ref: str) -> None:
|
|
387
|
+
subprocess.run(
|
|
388
|
+
["git", "-C", worktree_path, "push", "origin", f"HEAD:{head_ref}"],
|
|
389
|
+
check=True,
|
|
390
|
+
capture_output=True,
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def build_review_body(
|
|
395
|
+
findings: list, audit_model: str, commit_sha: str, fix_outcomes: list
|
|
396
|
+
) -> str:
|
|
397
|
+
if not findings:
|
|
398
|
+
return NO_FINDINGS_REVIEW_BODY.format(model=audit_model)
|
|
399
|
+
severity_counts = {"P0": 0, "P1": 0, "P2": 0}
|
|
400
|
+
for each_finding in findings:
|
|
401
|
+
severity_counts[each_finding["severity"]] += 1
|
|
402
|
+
header = REVIEW_BODY_HEADER_TEMPLATE.format(
|
|
403
|
+
p0=severity_counts["P0"], p1=severity_counts["P1"], p2=severity_counts["P2"]
|
|
404
|
+
)
|
|
405
|
+
lines = [header, ""]
|
|
406
|
+
if commit_sha:
|
|
407
|
+
lines.append(f"Auto-fix commit: `{commit_sha[:7]}`")
|
|
408
|
+
lines.append("")
|
|
409
|
+
lines.append(f"Audit model: `{audit_model}`")
|
|
410
|
+
lines.append("")
|
|
411
|
+
lines.append("### Findings")
|
|
412
|
+
lines.append("")
|
|
413
|
+
for each_index, each_finding in enumerate(findings):
|
|
414
|
+
status_for_finding = next(
|
|
415
|
+
(
|
|
416
|
+
each_outcome
|
|
417
|
+
for each_outcome in fix_outcomes
|
|
418
|
+
if each_outcome["finding_index"] == each_index
|
|
419
|
+
),
|
|
420
|
+
None,
|
|
421
|
+
)
|
|
422
|
+
status_label = "not attempted"
|
|
423
|
+
if status_for_finding:
|
|
424
|
+
status_label = status_for_finding["status"]
|
|
425
|
+
if status_for_finding.get("reason"):
|
|
426
|
+
status_label = f"{status_label}: {status_for_finding['reason']}"
|
|
427
|
+
lines.append(
|
|
428
|
+
f"- **[{each_finding['severity']} / {each_finding['category']}] "
|
|
429
|
+
f"{each_finding['title']}** — `{each_finding['file']}:{each_finding['line']}` "
|
|
430
|
+
f"— _{status_label}_"
|
|
431
|
+
)
|
|
432
|
+
lines.append(f" {each_finding['description']}")
|
|
433
|
+
return "\n".join(lines)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def run_pipeline(input_data: dict) -> dict:
|
|
437
|
+
api_key = os.environ.get("GROQ_API_KEY", "").strip()
|
|
438
|
+
if not api_key:
|
|
439
|
+
return {"error": "GROQ_API_KEY not set in environment"}
|
|
440
|
+
|
|
441
|
+
diff_text = input_data.get("diff", "")
|
|
442
|
+
files_content = input_data.get("files_content", {})
|
|
443
|
+
worktree_path = input_data.get("worktree_path", "")
|
|
444
|
+
head_ref = input_data.get("head_ref", "")
|
|
445
|
+
pr_number = input_data.get("pr_number", 0)
|
|
446
|
+
apply_fixes_requested = bool(input_data.get("apply_fixes", True))
|
|
447
|
+
|
|
448
|
+
if not diff_text.strip():
|
|
449
|
+
return {"error": "diff is empty; nothing to audit"}
|
|
450
|
+
if apply_fixes_requested and (not worktree_path or not head_ref):
|
|
451
|
+
return {"error": "apply_fixes requires worktree_path and head_ref"}
|
|
452
|
+
|
|
453
|
+
findings, audit_model = run_audit(api_key, diff_text, files_content)
|
|
454
|
+
|
|
455
|
+
fix_outcomes: list = []
|
|
456
|
+
files_to_write: dict = {}
|
|
457
|
+
fix_model = ""
|
|
458
|
+
|
|
459
|
+
if findings and apply_fixes_requested:
|
|
460
|
+
grouped = group_findings_by_file(findings)
|
|
461
|
+
for each_file_path, each_findings_for_file in grouped.items():
|
|
462
|
+
current_content = files_content.get(each_file_path, "")
|
|
463
|
+
try:
|
|
464
|
+
fix_result, fix_model = generate_fix_for_file(
|
|
465
|
+
api_key, each_file_path, current_content, each_findings_for_file
|
|
466
|
+
)
|
|
467
|
+
except Exception as fix_error:
|
|
468
|
+
for each_global_index, _each_finding in each_findings_for_file:
|
|
469
|
+
fix_outcomes.append(
|
|
470
|
+
{
|
|
471
|
+
"finding_index": each_global_index,
|
|
472
|
+
"status": "fix_call_failed",
|
|
473
|
+
"reason": str(fix_error)[:200],
|
|
474
|
+
}
|
|
475
|
+
)
|
|
476
|
+
continue
|
|
477
|
+
raw_updated_content = fix_result.get("updated_content", current_content)
|
|
478
|
+
applied_indexes = set(fix_result.get("applied_finding_indexes", []))
|
|
479
|
+
skipped_entries = {
|
|
480
|
+
each_skipped["finding_index"]: each_skipped.get("reason", "")
|
|
481
|
+
for each_skipped in fix_result.get("skipped", [])
|
|
482
|
+
}
|
|
483
|
+
updated_content = preserve_trailing_newline(current_content, raw_updated_content)
|
|
484
|
+
content_changed = updated_content != current_content
|
|
485
|
+
if should_write_fixed_file(applied_indexes, updated_content, current_content):
|
|
486
|
+
files_to_write[each_file_path] = updated_content
|
|
487
|
+
for each_global_index, _each_finding in each_findings_for_file:
|
|
488
|
+
if each_global_index in applied_indexes and content_changed:
|
|
489
|
+
fix_outcomes.append(
|
|
490
|
+
{"finding_index": each_global_index, "status": "fixed"}
|
|
491
|
+
)
|
|
492
|
+
elif each_global_index in applied_indexes:
|
|
493
|
+
fix_outcomes.append(
|
|
494
|
+
{
|
|
495
|
+
"finding_index": each_global_index,
|
|
496
|
+
"status": "skipped",
|
|
497
|
+
"reason": "model claimed fix applied but file content is unchanged",
|
|
498
|
+
}
|
|
499
|
+
)
|
|
500
|
+
elif each_global_index in skipped_entries:
|
|
501
|
+
fix_outcomes.append(
|
|
502
|
+
{
|
|
503
|
+
"finding_index": each_global_index,
|
|
504
|
+
"status": "skipped",
|
|
505
|
+
"reason": skipped_entries[each_global_index][:200],
|
|
506
|
+
}
|
|
507
|
+
)
|
|
508
|
+
else:
|
|
509
|
+
fix_outcomes.append(
|
|
510
|
+
{"finding_index": each_global_index, "status": "not_addressed"}
|
|
511
|
+
)
|
|
512
|
+
|
|
513
|
+
commit_sha = ""
|
|
514
|
+
if files_to_write and apply_fixes_requested:
|
|
515
|
+
applied_count = sum(
|
|
516
|
+
1 for each_outcome in fix_outcomes if each_outcome["status"] == "fixed"
|
|
517
|
+
)
|
|
518
|
+
commit_message = (
|
|
519
|
+
f"fix(groq-bugteam): auto-fix audit findings for PR #{pr_number}\n\n"
|
|
520
|
+
f"Addressed {applied_count} of {len(findings)} findings from groq-bugteam audit."
|
|
521
|
+
)
|
|
522
|
+
try:
|
|
523
|
+
commit_sha = apply_fixes_and_commit(
|
|
524
|
+
worktree_path, files_to_write, commit_message
|
|
525
|
+
)
|
|
526
|
+
if commit_sha:
|
|
527
|
+
push_current_branch(worktree_path, head_ref)
|
|
528
|
+
except subprocess.CalledProcessError as git_error:
|
|
529
|
+
stderr_preview = decode_subprocess_stderr(git_error.stderr)[:500]
|
|
530
|
+
return {
|
|
531
|
+
"findings": findings,
|
|
532
|
+
"fix_outcomes": fix_outcomes,
|
|
533
|
+
"commit_sha": "",
|
|
534
|
+
"review_body": build_review_body(
|
|
535
|
+
findings, audit_model, "", fix_outcomes
|
|
536
|
+
),
|
|
537
|
+
"audit_model": audit_model,
|
|
538
|
+
"fix_model": fix_model,
|
|
539
|
+
"error": f"git operation failed: {stderr_preview}",
|
|
540
|
+
}
|
|
541
|
+
except ValueError as unsafe_path_error:
|
|
542
|
+
return {
|
|
543
|
+
"findings": findings,
|
|
544
|
+
"fix_outcomes": fix_outcomes,
|
|
545
|
+
"commit_sha": "",
|
|
546
|
+
"review_body": build_review_body(
|
|
547
|
+
findings, audit_model, "", fix_outcomes
|
|
548
|
+
),
|
|
549
|
+
"audit_model": audit_model,
|
|
550
|
+
"fix_model": fix_model,
|
|
551
|
+
"error": f"unsafe fix rejected: {unsafe_path_error}",
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
review_body = build_review_body(findings, audit_model, commit_sha, fix_outcomes)
|
|
555
|
+
|
|
556
|
+
return {
|
|
557
|
+
"findings": findings,
|
|
558
|
+
"fix_outcomes": fix_outcomes,
|
|
559
|
+
"commit_sha": commit_sha,
|
|
560
|
+
"review_body": review_body,
|
|
561
|
+
"audit_model": audit_model,
|
|
562
|
+
"fix_model": fix_model,
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def main() -> None:
|
|
567
|
+
try:
|
|
568
|
+
stdin_text = sys.stdin.read()
|
|
569
|
+
input_data = json.loads(stdin_text)
|
|
570
|
+
except (json.JSONDecodeError, ValueError) as parse_error:
|
|
571
|
+
json.dump({"error": f"stdin is not valid JSON: {parse_error}"}, sys.stdout)
|
|
572
|
+
sys.exit(1)
|
|
573
|
+
|
|
574
|
+
try:
|
|
575
|
+
pipeline_outcome = run_pipeline(input_data)
|
|
576
|
+
except Exception as pipeline_error:
|
|
577
|
+
pipeline_outcome = {"error": f"pipeline failed: {pipeline_error}"}
|
|
578
|
+
|
|
579
|
+
json.dump(pipeline_outcome, sys.stdout, indent=JSON_INDENT_SPACES)
|
|
580
|
+
sys.stdout.write("\n")
|
|
581
|
+
if "error" in pipeline_outcome:
|
|
582
|
+
sys.exit(PIPELINE_FAILURE_EXIT_CODE)
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
if __name__ == "__main__":
|
|
586
|
+
main()
|