Skip to content

Commit 16fd418

Browse files
committed
Reduce Spotless fix to target update
Keep the upstream/main simplification of fix_ci.py and apply only the intended spotlessApply target change.
1 parent 4297f9a commit 16fd418

1 file changed

Lines changed: 31 additions & 196 deletions

File tree

.github/scripts/pr-triage/fix_ci.py

Lines changed: 31 additions & 196 deletions
Original file line numberDiff line numberDiff line change
@@ -18,22 +18,18 @@
1818
download_actions_job_log,
1919
extract_job_id,
2020
gh_json,
21-
gradlew_cmd,
2221
invoke_copilot,
2322
make_temp_dir,
2423
progress,
2524
push,
26-
run,
2725
run_pr_workflow,
2826
status_porcelain,
2927
untracked_files,
3028
write_json,
3129
)
3230

3331

34-
ERROR_PATTERN = re.compile(r"error:|Task .*FAILED|FAILURE: Build failed|\[ERROR\]|markdownlint", re.IGNORECASE)
3532
AGGREGATE_CHECK_SUFFIX = "required-status-check"
36-
MAX_LOGS_PER_JOB_FAMILY = 3
3733

3834

3935
def parse_args() -> argparse.Namespace:
@@ -59,206 +55,50 @@ def job_family(name: str) -> str:
5955
return re.sub(r"\s+\([^)]*\)$", "", name).strip()
6056

6157

62-
def matrix_tokens(name: str) -> set[str]:
63-
match = re.search(r"\(([^)]*)\)\s*$", name)
64-
if not match:
65-
return set()
66-
return {token.strip().lower() for token in match.group(1).split(",") if token.strip()}
67-
68-
69-
def selected_log_indexes(checks: list[dict[str, Any]], summary: Summary) -> set[int]:
70-
progress(f"Selecting representative logs for {len(checks)} failing checks")
71-
families: dict[str, list[int]] = {}
72-
for index, check in enumerate(checks):
73-
name = check.get("name") or "unknown"
74-
families.setdefault(job_family(name), []).append(index)
75-
76-
selected: set[int] = set()
77-
for family, indexes in families.items():
78-
downloadable = [index for index in indexes if extract_job_id(checks[index]) is not None]
79-
if len(downloadable) <= MAX_LOGS_PER_JOB_FAMILY:
80-
selected.update(downloadable)
81-
continue
82-
83-
picked = [downloadable[0]]
84-
seen_tokens = set(matrix_tokens(checks[downloadable[0]].get("name") or ""))
85-
remaining = downloadable[1:]
86-
while remaining and len(picked) < MAX_LOGS_PER_JOB_FAMILY:
87-
best_index = max(
88-
remaining,
89-
key=lambda index: len(matrix_tokens(checks[index].get("name") or "") - seen_tokens),
90-
)
91-
picked.append(best_index)
92-
seen_tokens.update(matrix_tokens(checks[best_index].get("name") or ""))
93-
remaining.remove(best_index)
94-
95-
selected.update(picked)
96-
summary.notes.append(
97-
f"Downloaded {len(picked)} representative logs for {family}; "
98-
f"skipped {len(downloadable) - len(picked)} sibling logs"
99-
)
100-
return selected
101-
102-
103-
def extract_snippet(log_text: str, max_lines: int = 160) -> str:
104-
lines = log_text.splitlines()
105-
selected: list[str] = []
106-
for index, line in enumerate(lines):
107-
if ERROR_PATTERN.search(line):
108-
start = max(0, index - 2)
109-
end = min(len(lines), index + 21)
110-
selected.extend(lines[start:end])
111-
selected.append("---")
112-
if len(selected) >= max_lines:
113-
break
114-
if selected:
115-
return "\n".join(selected[:max_lines]) + "\n"
116-
return "\n".join(lines[-max_lines:]) + "\n"
117-
118-
119-
def write_ci_bundle(
120-
pr: int, checks: list[dict[str, Any]], directory: Path, summary: Summary
121-
) -> tuple[list[dict[str, Any]], Path]:
58+
def write_ci_bundle(pr: int, checks: list[dict[str, Any]], directory: Path, summary: Summary) -> list[dict[str, Any]]:
12259
progress(f"Preparing CI failure bundle in {directory}")
12360
repo = detect_repo(summary)
12461
owner, repo_name = repo.split("/", 1)
125-
download_indexes = selected_log_indexes(checks, summary)
12662
logs_dir = directory / "logs"
127-
snippets_dir = directory / "snippets"
12863
logs_dir.mkdir(parents=True, exist_ok=True)
129-
snippets_dir.mkdir(parents=True, exist_ok=True)
13064

13165
bundle_checks: list[dict[str, Any]] = []
132-
for index, check in enumerate(checks):
66+
seen_families: set[str] = set()
67+
for check in checks:
13368
name = check.get("name") or "unknown"
13469
job_id = extract_job_id(check)
13570
summary.failures.append(f"{name} ({job_id or 'no job id'})")
136-
entry: dict[str, Any] = {
137-
"name": name,
138-
"family": job_family(name),
139-
"job_id": job_id,
140-
"details_url": check.get("detailsUrl") or check.get("details_url"),
141-
"database_id": check.get("databaseId") or check.get("database_id"),
142-
"log_sampled": index in download_indexes,
143-
}
144-
if job_id is not None and index in download_indexes:
145-
progress(f"Sampling log for failed job: {name}")
71+
entry: dict[str, Any] = {"name": name, "job_id": job_id}
72+
family = job_family(name)
73+
if job_id is None:
74+
pass
75+
elif family in seen_families:
76+
progress(f"Skipping sibling log for failed job: {name}")
77+
entry["log_note"] = "log download skipped; covered by representative sibling job"
78+
else:
79+
seen_families.add(family)
80+
progress(f"Downloading log for failed job: {name}")
14681
log_path = logs_dir / f"{job_id}.log"
147-
snippet_path = snippets_dir / f"{job_id}-errors.txt"
14882
download_actions_job_log(owner, repo_name, job_id, log_path, summary)
149-
snippet_path.write_text(
150-
extract_snippet(log_path.read_text(encoding="utf-8", errors="replace")),
151-
encoding="utf-8",
152-
)
15383
entry["log"] = str(log_path)
154-
entry["snippet"] = str(snippet_path)
155-
elif job_id is not None:
156-
progress(f"Skipping sibling log for failed job: {name}")
157-
entry["log_note"] = "log download skipped; covered by representative sibling job"
15884
bundle_checks.append(entry)
15985

16086
write_json(directory / "summary.json", {"repo": repo, "pr": pr, "failed_checks": bundle_checks})
161-
plan = directory / "ci-plan.md"
162-
plan.write_text(render_ci_plan(pr, bundle_checks), encoding="utf-8")
16387
summary.temp_dir = str(directory)
164-
return bundle_checks, plan
88+
return bundle_checks
16589

16690

167-
def render_ci_plan(pr: int, checks: list[dict[str, Any]]) -> str:
168-
lines = [f"# CI Failure Analysis Plan for PR #{pr}", "", "## Failed Jobs", ""]
91+
def render_failed_jobs(checks: list[dict[str, Any]]) -> str:
92+
lines: list[str] = []
16993
for check in checks:
17094
lines.append(f"- {check['name']} (job ID: {check.get('job_id')})")
171-
if check.get("snippet"):
172-
lines.append(f" - Snippet: {check['snippet']}")
17395
if check.get("log"):
174-
lines.append(f" - Full log: {check['log']}")
96+
lines.append(f" - Log: {check['log']}")
17597
if check.get("log_note"):
17698
lines.append(f" - {check['log_note']}")
177-
lines.extend(["", "## Notes", "", "- Python downloaded logs before Copilot handoff.", ""])
17899
return "\n".join(lines)
179100

180101

181-
def maybe_apply_deterministic_fixes(bundle_dir: Path, plan_path: Path, summary: Summary) -> list[str]:
182-
text = plan_path.read_text(encoding="utf-8")
183-
for snippet in (bundle_dir / "snippets").glob("*.txt"):
184-
text += "\n" + snippet.read_text(encoding="utf-8", errors="replace")
185-
186-
text = text.lower()
187-
fix_kinds = []
188-
if "spotless" in text:
189-
run(gradlew_cmd("spotlessApply"), summary)
190-
summary.notes.append("Applied deterministic spotless fix based on CI logs")
191-
fix_kinds.append("spotless")
192-
if "fossa" in text or "generatefossaconfiguration" in text or ".fossa.yml" in text:
193-
run(gradlew_cmd("generateFossaConfiguration"), summary)
194-
summary.notes.append("Applied deterministic FOSSA configuration fix based on CI logs")
195-
fix_kinds.append("fossa")
196-
return fix_kinds
197-
198-
199-
def ci_fix_commit_message(checks: list[dict[str, Any]], changed_paths: list[str]) -> list[str]:
200-
families = sorted({job_family(check.get("name") or "unknown") for check in checks})
201-
if len(families) == 1:
202-
subject = f"Fix CI failure in {families[0]}"
203-
else:
204-
subject = f"Fix CI failures in {len(families)} job families"
205-
body_lines = ["Failed jobs:"]
206-
body_lines.extend(f"- {family}" for family in families[:8])
207-
if len(families) > 8:
208-
body_lines.append(f"- ... and {len(families) - 8} more")
209-
body_lines.append("")
210-
body_lines.append("Changed files:")
211-
body_lines.extend(f"- {path}" for path in changed_paths[:12])
212-
if len(changed_paths) > 12:
213-
body_lines.append(f"- ... and {len(changed_paths) - 12} more")
214-
return [subject, "\n".join(body_lines)]
215-
216-
217-
def append_deterministic_commit_details(body_lines: list[str], checks: list[dict[str, Any]], changed_paths: list[str], file_heading: str) -> None:
218-
body_lines.extend(["", "Failed jobs:"])
219-
body_lines.extend(f"- {family}" for family in sorted({job_family(check.get("name") or "unknown") for check in checks})[:8])
220-
body_lines.append("")
221-
body_lines.append(file_heading)
222-
body_lines.extend(f"- {path}" for path in changed_paths[:12])
223-
if len(changed_paths) > 12:
224-
body_lines.append(f"- ... and {len(changed_paths) - 12} more")
225-
226-
227-
def deterministic_ci_fix_commit_message(fix_kinds: list[str], checks: list[dict[str, Any]], changed_paths: list[str]) -> list[str]:
228-
if fix_kinds == ["spotless"]:
229-
subject = "Apply spotless formatting"
230-
body_lines = [
231-
"CI reported Spotless formatting violations.",
232-
"",
233-
"Ran:",
234-
"- ./gradlew spotlessApply",
235-
]
236-
append_deterministic_commit_details(body_lines, checks, changed_paths, "Formatted files:")
237-
return [subject, "\n".join(body_lines)]
238-
if fix_kinds == ["fossa"]:
239-
subject = "Regenerate FOSSA configuration"
240-
body_lines = [
241-
"CI reported that the FOSSA configuration was out of date.",
242-
"",
243-
"Ran:",
244-
"- ./gradlew generateFossaConfiguration",
245-
]
246-
append_deterministic_commit_details(body_lines, checks, changed_paths, "Updated files:")
247-
return [subject, "\n".join(body_lines)]
248-
if set(fix_kinds) == {"spotless", "fossa"}:
249-
subject = "Apply deterministic CI fixes"
250-
body_lines = [
251-
"CI reported deterministic Spotless and FOSSA configuration failures.",
252-
"",
253-
"Ran:",
254-
"- ./gradlew spotlessApply",
255-
"- ./gradlew generateFossaConfiguration",
256-
]
257-
append_deterministic_commit_details(body_lines, checks, changed_paths, "Updated files:")
258-
return [subject, "\n".join(body_lines)]
259-
return ci_fix_commit_message(checks, changed_paths)
260-
261-
262102
def read_commit_message(path: Path) -> list[str]:
263103
if not path.exists():
264104
raise RuntimeError(f"Copilot did not write a commit message to {path}")
@@ -286,12 +126,13 @@ def read_prompt_improvement(path: Path, summary: Summary) -> None:
286126
summary.notes.append(line)
287127

288128

289-
def copilot_prompt(pr: int, plan_path: Path, commit_message_path: Path, prompt_improvement_path: Path) -> str:
129+
def copilot_prompt(pr: int, checks: list[dict[str, Any]], commit_message_path: Path, prompt_improvement_path: Path) -> str:
290130
return f"""You are fixing failing CI in opentelemetry-java-instrumentation.
291131
292-
The PR branch (#{pr}) is already checked out. Read this CI bundle first:
132+
The PR branch (#{pr}) is already checked out. The following CI jobs are failing.
133+
Use the referenced log files as the source of truth:
293134
294-
{plan_path}
135+
{render_failed_jobs(checks)}
295136
296137
After fixing the issue, write a commit message to this exact file path:
297138
@@ -319,9 +160,9 @@ def copilot_prompt(pr: int, plan_path: Path, commit_message_path: Path, prompt_i
319160
- Do not push.
320161
- Do not rebase, merge, amend, or use force operations.
321162
- Edit only files needed to fix the failures listed in the CI bundle.
322-
- Use the downloaded log snippets and full logs as the source of truth.
163+
- Use the downloaded log files as the source of truth.
323164
- For deterministic formatting or generated-file failures (for example Spotless
324-
or FOSSA), run the corresponding Gradle task (for example `./gradlew spotless`
165+
or FOSSA), run the corresponding Gradle task (for example `./gradlew spotlessApply`
325166
or `./gradlew generateFossaConfiguration`) instead of editing files by hand.
326167
- If the failures are flaky or infrastructure-only, do not invent a code fix; leave the tree clean and explain why.
327168
- When done, print a concise summary of the files changed and validation commands run.
@@ -338,19 +179,17 @@ def body(summary: Summary) -> int:
338179
return 0
339180

340181
bundle_dir = make_temp_dir("otel-ci-fix", args.pr, args.keep_temp)
341-
bundle_checks, plan_path = write_ci_bundle(args.pr, checks, bundle_dir, summary)
342-
deterministic_fixes = maybe_apply_deterministic_fixes(bundle_dir, plan_path, summary)
182+
bundle_checks = write_ci_bundle(args.pr, checks, bundle_dir, summary)
343183

344-
if not deterministic_fixes and args.skip_copilot:
184+
if args.skip_copilot:
345185
summary.outcome = "downloaded CI logs; skipped Copilot handoff"
346186
return 0
347187

348188
commit_message_path = bundle_dir / "commit-message.txt"
349-
if not deterministic_fixes:
350-
prompt_improvement_path = bundle_dir / "prompt-improvement.md"
351-
response = invoke_copilot(copilot_prompt(args.pr, plan_path, commit_message_path, prompt_improvement_path), summary)
352-
(bundle_dir / "copilot-response.txt").write_text(response + "\n", encoding="utf-8")
353-
read_prompt_improvement(prompt_improvement_path, summary)
189+
prompt_improvement_path = bundle_dir / "prompt-improvement.md"
190+
response = invoke_copilot(copilot_prompt(args.pr, bundle_checks, commit_message_path, prompt_improvement_path), summary)
191+
(bundle_dir / "copilot-response.txt").write_text(response + "\n", encoding="utf-8")
192+
read_prompt_improvement(prompt_improvement_path, summary)
354193

355194
diff_check(summary)
356195
if untracked_files(summary):
@@ -360,11 +199,7 @@ def body(summary: Summary) -> int:
360199
summary.outcome = "no code changes needed"
361200
return 0
362201

363-
commit_message = (
364-
deterministic_ci_fix_commit_message(deterministic_fixes, bundle_checks, summary.changed_files)
365-
if deterministic_fixes
366-
else read_commit_message(commit_message_path)
367-
)
202+
commit_message = read_commit_message(commit_message_path)
368203
commit_all_tracked(commit_message, summary)
369204
if args.no_push:
370205
summary.push_result = "not pushed (--no-push)"
@@ -377,4 +212,4 @@ def body(summary: Summary) -> int:
377212

378213

379214
if __name__ == "__main__":
380-
sys.exit(main())
215+
sys.exit(main())

0 commit comments

Comments
 (0)