8from pathlib
import Path
11MAX_DIFF_FILES_CHARS = 1600
12MAX_DIFF_PATCH_CHARS = 3200
14DEFAULT_MODEL =
"openai/gpt-oss-20b"
17 "openai/gpt-oss-120b",
19 "llama-3.3-70b-versatile",
21 "meta-llama/llama-4-scout-17b-16e-instruct",
22 "llama-3.1-8b-instant",
26def read_text(path: Path, default: str =
"") -> str:
28 return path.read_text(encoding=
"utf-8", errors=
"replace")
29 except FileNotFoundError:
34 proc = subprocess.run(
42 if proc.returncode != 0:
44 return proc.stdout.strip()
47def trim(text: str, limit: int) -> str:
48 if len(text) <= limit:
50 return text[: limit - 20] +
"\n...[truncated]"
53def parse_changed_paths(changed_files: str) -> list[str]:
55 for line
in changed_files.splitlines():
59 parts = raw.split(
"\t")
61 candidate = parts[-1].strip()
63 candidate = raw.split()[-1]
65 paths.append(candidate)
69def classify_path(path: str) -> str:
70 p = path.strip().lower()
71 if p.startswith(
"src/")
or p.startswith(
"include/"):
73 if p.startswith(
"tests/"):
75 if p.startswith(
".github/workflows/"):
77 if p.startswith(
"utility_scripts/"):
79 if p.startswith(
"documentation/")
or p.startswith(
"docs/")
or p.endswith(
"doxygenconfig"):
81 if p.startswith(
"dist/"):
86def infer_primary_focus(changed_paths: list[str]) -> str:
99 scores = {k: 0
for k
in weights}
100 for path
in changed_paths:
101 scores[classify_path(path)] += weights[classify_path(path)]
103 return max(scores, key=
lambda k: scores[k])
106def summarize_focus_label(focus: str) -> str:
108 "core":
"core emulator logic",
109 "tests":
"test coverage and verification",
110 "scripts":
"developer/automation scripts",
111 "workflow":
"CI workflow behavior",
112 "docs":
"documentation",
113 "dist":
"prebuilt/reference assets",
114 "other":
"project configuration",
115 "unknown":
"repository updates",
117 return mapping.get(focus,
"repository updates")
120def build_fallback_summary(commits_text: str, changed_files: str, changed_paths: list[str]) -> str:
121 focus = infer_primary_focus(changed_paths)
122 focus_label = summarize_focus_label(focus)
123 top_paths =
"\n".join(f
"- `{p}`" for p
in changed_paths[:8])
or "- No changed files detected"
127 f
"This push primarily updates {focus_label}.",
130 commits_text
or "- No commit messages were detected in the push payload.",
132 "Risks / Follow-ups",
133 "- Review changed files and test impact for this push.",
134 "- Verify CI output if behavior changes are expected.",
137 f
"Likely intent: improve {focus_label} while keeping the branch synchronized.",
139 "Changed Files (Top)",
142 return "\n".join(lines)
145def fetch_available_models(api_key: str):
146 req = urllib.request.Request(
147 "https://api.groq.com/openai/v1/models",
149 "Authorization": f
"Bearer {api_key}",
150 "Content-Type":
"application/json",
154 with urllib.request.urlopen(req, timeout=15)
as resp:
155 payload = json.loads(resp.read().decode(
"utf-8", errors=
"replace"))
156 data = payload.get(
"data", [])
157 return [item.get(
"id",
"")
for item
in data
if item.get(
"id")]
160def choose_model(api_key: str, requested_model: str) -> str:
162 available = fetch_available_models(api_key)
164 return requested_model
or DEFAULT_MODEL
166 if requested_model
and requested_model
in available:
167 return requested_model
169 for model_id
in MODEL_PRIORITY:
170 if model_id
in available:
173 return requested_model
or DEFAULT_MODEL
176def call_model(api_key: str, model: str, prompt: str) -> str:
177 openai_module = __import__(
"openai")
178 client = openai_module.OpenAI(
180 base_url=
"https://api.groq.com/openai/v1",
182 response = client.responses.create(input=prompt, model=model)
183 return (response.output_text
or "").strip()
198 "IMPORTANT RETRY MODE: Your previous draft referenced areas not grounded in changed files. "
199 "In this retry, keep claims tightly scoped to changed files and diff."
203You are an engineering release assistant for repository `{repo}` on branch `{ref_name}`.
205Task: produce a concise push summary grounded in the provided commit messages and git diff context.
207Grounding rules (strict-with-light-inference):
2081. You may infer high-level intent, but every concrete claim must be supported by changed files or diff.
2092. Do NOT mention components/files that are not present in Changed files summary or Diff excerpt.
2103. If `.github/workflows/` is absent from changed files, do not discuss CI/workflow changes.
2114. Prioritize the likely primary focus of this push: `{summarize_focus_label(focus)}`.
2125. If context is insufficient for specifics, say so briefly instead of guessing.
215- Use Markdown without code fences.
216- 4 sections exactly, with these headings in order:
221- Keep total length around 90-170 words.
228Changed files summary:
236def has_ungrounded_workflow_reference(summary_text: str, changed_paths: list[str]) -> bool:
237 has_workflow_changes = any(p.lower().startswith(
".github/workflows/")
for p
in changed_paths)
238 if has_workflow_changes:
241 lower = summary_text.lower()
242 suspicious_tokens = [
245 "discord notification",
247 "test-init-response-protocol.yml",
251 return any(token
in lower
for token
in suspicious_tokens)
255 event_path = Path(os.environ.get(
"EVENT_PATH",
""))
256 repo = os.environ.get(
"REPO",
"unknown")
257 ref_name = os.environ.get(
"REF_NAME",
"unknown")
258 groq_api_key = os.environ.get(
"GROQ_API_KEY",
"")
259 requested_model = os.environ.get(
"GROQ_MODEL",
"")
260 step_summary_path = os.environ.get(
"GITHUB_STEP_SUMMARY",
"")
263 if event_path.exists():
264 event = json.loads(read_text(event_path,
"{}")
or "{}")
266 commits = event.get(
"commits", [])
267 before = event.get(
"before",
"")
268 after = event.get(
"after",
"")
271 for commit
in commits[:MAX_COMMITS]:
272 cid = (commit.get(
"id")
or "")[:7]
273 message = (commit.get(
"message")
or "").splitlines()[0].strip()
274 author = (commit.get(
"author")
or {}).get(
"name",
"unknown")
275 commit_lines.append(f
"- {cid} {message} ({author})")
277 if len(commits) > MAX_COMMITS:
278 commit_lines.append(f
"- ... and {len(commits) - MAX_COMMITS} more commit(s)")
280 commits_text =
"\n".join(commit_lines)
285 if before
and after
and before !=
"0" * 40:
286 changed_files = run_git([
"diff",
"--name-status", before, after])
287 diff_patch = run_git([
"diff",
"--unified=1",
"--no-color", before, after])
289 if not changed_files:
290 changed_files = run_git([
"show",
"--name-status",
"--pretty=format:",
"HEAD"])
292 diff_patch = run_git([
"show",
"--unified=1",
"--no-color",
"--pretty=format:",
"HEAD"])
294 changed_files = trim(changed_files, MAX_DIFF_FILES_CHARS)
295 diff_patch = trim(diff_patch, MAX_DIFF_PATCH_CHARS)
297 changed_paths = parse_changed_paths(changed_files)
298 focus = infer_primary_focus(changed_paths)
300 selected_model = requested_model
or DEFAULT_MODEL
305 selected_model = choose_model(groq_api_key, requested_model)
306 prompt = build_prompt(
309 commits_text=commits_text,
310 changed_files=changed_files,
311 diff_patch=diff_patch,
315 summary_text = call_model(groq_api_key, selected_model, prompt)
317 if has_ungrounded_workflow_reference(summary_text, changed_paths):
318 retry_prompt = build_prompt(
321 commits_text=commits_text,
322 changed_files=changed_files,
323 diff_patch=diff_patch,
327 summary_text = call_model(groq_api_key, selected_model, retry_prompt)
328 except Exception
as exc:
331 f
"AI summarization failed ({type(exc).__name__}); using fallback summary."
334 if (
not summary_text)
or has_ungrounded_workflow_reference(summary_text, changed_paths):
335 summary_text = build_fallback_summary(commits_text, changed_files, changed_paths)
337 summary_with_meta = f
"_Model used: `{selected_model}`_\n\n{summary_text}".strip()
339 Path(
"ai_push_summary.txt").write_text(summary_with_meta +
"\n", encoding=
"utf-8")
340 Path(
"ai_push_summary.json").write_text(
343 "summary": summary_text,
344 "model": selected_model,
346 "changed_paths": changed_paths,
354 if step_summary_path:
355 with open(step_summary_path,
"a", encoding=
"utf-8")
as fh:
356 fh.write(
"\n## AI Push Summary\n\n")
357 fh.write(summary_with_meta)
361if __name__ ==
"__main__":