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 )
3532AGGREGATE_CHECK_SUFFIX = "required-status-check"
36- MAX_LOGS_PER_JOB_FAMILY = 3
3733
3834
3935def 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-
262102def 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
296137After 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
379214if __name__ == "__main__" :
380- sys .exit (main ())
215+ sys .exit (main ())
0 commit comments