Skip to content

TRT-2870: A/B/C test C (historical control): pre-#604 CI skills with current payload evals - #664

Closed
stbenjam wants to merge 12 commits into
openshift-eng:mainfrom
stbenjam:agent/payload-evals-pre-604-skills
Closed

TRT-2870: A/B/C test C (historical control): pre-#604 CI skills with current payload evals#664
stbenjam wants to merge 12 commits into
openshift-eng:mainfrom
stbenjam:agent/payload-evals-pre-604-skills

Conversation

@stbenjam

@stbenjam stbenjam commented Jul 31, 2026

Copy link
Copy Markdown
Member

These bugs and fixes were automatically generated by a payload-agent experiment to improve resilience and diagnostics for infrastructure failures. Please review the PR and either shepherd it to merge or close it. If the work is incorrect or unhelpful, a brief comment would help us improve. Thanks, and apologies if we missed the mark.

Experiment

This is group C, the historical-skill control for the payload-analysis evaluation. It is evaluation-only and is not intended to merge.

This makes the three groups:

Reference change: PR #604.

Failed payload analyses represented by the new cases

Validation

  • plugins/ci/evals/** is unchanged from group A commit 5ec4bab.
  • CI skills match pre-TRT-2613: feat(ci): prow-job-analysis skill + artifact-verified evals (supersedes #597) #604 commit e9065e6 aside from two whitespace-only normalizations.
  • python3 -m pytest -q plugins/ci/evals/test_eval_payload_analysis.py — passed.
  • Focused skillsaw lint — A+, 0 errors, 0 warnings.
  • Full repository lint — passed, 0 errors, 0 warnings.
  • CI plugin version bumped from 0.0.80 to 0.0.81.

Summary by CodeRabbit

  • New Features

    • Added tools for analyzing installation, bare-metal, and test failures.
    • Added Prow artifact search and must-gather extraction with interactive HTML reports.
    • Expanded payload-analysis evaluation coverage with point-in-time validation.
  • Improvements

    • Simplified payload snapshots and analysis workflows with clearer outputs and streamlined options.
    • Updated guidance to reference the new investigation tools.
  • Removed

    • Retired several legacy CI diagnosis, triage, symptom, and label-management tools.

@openshift-ci

openshift-ci Bot commented Jul 31, 2026

Copy link
Copy Markdown

[APPROVALNOTIFIER] This PR is APPROVED

This pull-request has been approved by: stbenjam

The full list of commands accepted by this bot can be found here.

The pull request process is described here

Details Needs approval from an approver in each of these files:

Approvers can indicate their approval by writing /approve in a comment
Approvers can cancel approval by writing /approve cancel in a comment

@openshift-ci openshift-ci Bot added the approved Indicates a PR has been approved by an approver from all required OWNERS files. label Jul 31, 2026
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Walkthrough

The PR updates the CI plugin to version 0.0.81, adds point-in-time payload-analysis evaluation controls, simplifies payload snapshots, replaces legacy analysis skills with specialized workflows, and adds artifact-search and must-gather extraction tools.

Changes

Payload analysis evaluation controls

Layer / File(s) Summary
Evaluation case metadata and scenarios
plugins/ci/evals/cases/payload-analysis/...
Adds payload timestamps, analysis cutoffs, candidate constraints, causal claims, and scenarios for cases 001–014 and 018–020.
Point-in-time judges and scoring
plugins/ci/evals/eval-payload-analysis.yaml, plugins/ci/evals/test_eval_payload_analysis.py
Adds bounded-trace, case-constraint, and point-in-time integrity judges with updated scoring thresholds and rubric tests.

Payload snapshot simplification

Layer / File(s) Summary
Snapshot collection and output
plugins/ci/skills/payload-snapshot/...
Simplifies Sippy and Release Controller selection, removes hybrid fallback and completeness metadata, and serializes blocking failures with reduced metadata.

CI analysis skill migration

Layer / File(s) Summary
Specialized analysis workflows
plugins/ci/skills/prow-job-analyze-install-failure/..., plugins/ci/skills/prow-job-analyze-metal-install-failure/..., plugins/ci/skills/prow-job-analyze-test-failure/...
Adds installation, bare-metal, and test-failure analysis workflows with artifact collection, diagnostics, and structured reports.
Artifact search
plugins/ci/skills/prow-job-artifact-search/..., plugins/ci/skills/*/SKILL.md
Adds a gcloud-backed artifact CLI and updates related skills to use specialized routing.

Must-gather extraction

Layer / File(s) Summary
Extraction and browser tools
plugins/ci/skills/prow-job-extract-must-gather/...
Adds recursive archive extraction, file statistics, metadata scanning, and an interactive HTML browser for must-gather contents.

Plugin metadata

Layer / File(s) Summary
CI plugin version
.claude-plugin/marketplace.json, plugins/ci/.claude-plugin/plugin.json
Updates the CI plugin version from 0.0.78 to 0.0.81.

Estimated code review effort: 5 (Critical) | ~120 minutes

Possibly related PRs

Suggested labels: do-not-merge/work-in-progress

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

@stbenjam

Copy link
Copy Markdown
Member Author

/test eval-payload-analysis

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py (1)

719-736: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

A failed JUnit listing or parse now writes an empty results.json.

_list_junit_files returns [] when the gcloud listing fails or times out at 30 s (line 755-760). _parse_junit_xml returns [] when the XML cannot be read (line 2358-2363). In both cases collect writes an empty results.json and returns True. Downstream, _build_failed_job_details sets test_failure_count to 0 (line 1471), and the regression tracker sees no failures. A collection failure is then indistinguishable from a job with no test failures.

Log a warning when the listing is empty for a failed job, and skip writing results.json when no JUnit file was read.

🐛 Proposed fix
         junit_files = self._list_junit_files()
+        if not junit_files:
+            _log(f"  Warning: no JUnit files found for {self.job.name}")
+            return False
+
+        parsed_any = False
         for gcs_uri in junit_files:
             filename = os.path.basename(gcs_uri)
             local_path = os.path.join(self.output_dir, filename)
             if not os.path.exists(local_path):
                 data = _run_gcloud_bytes(
                     ["gcloud", "storage", "cat", gcs_uri], timeout=60
                 )
                 if data:
                     with open(local_path, "wb") as f:
                         f.write(data)
 
             if os.path.exists(local_path):
                 results = _parse_junit_xml(local_path, source_name=filename)
+                parsed_any = True
                 all_results.extend(results)
 
+        if not parsed_any:
+            _log(f"  Warning: no JUnit XML read for {self.job.name}")
+            return False
+
         failures = _test_results_to_json(all_results)
         _write_json(self.output_path, failures)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py` around lines
719 - 736, Update the JUnit collection flow in collect: when _list_junit_files()
returns no files for a failed job, log a warning and avoid writing results.json;
also track whether any JUnit file was successfully read and skip _write_json
when none were read, including cases where _parse_junit_xml returns no results
due to unreadable XML. Preserve normal results writing when at least one JUnit
file is read.
plugins/ci/skills/payload-analysis/SKILL.md (2)

301-345: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Make revert eligibility enforce the stated exclusions.

Line 339 marks every score of 85 or higher as a revert candidate. Lines 341-344 also require exact timing and exclusion of infrastructure and other plausible causes. The mechanical rubric in lines 303-314 does not enforce those conditions.

Require explicit evidence that excludes infrastructure and alternative causes before a candidate becomes a revert recommendation. Otherwise, a high score can mandate a revert for a correlated but unproven PR.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/skills/payload-analysis/SKILL.md` around lines 301 - 345, Update
the “Propose Revert Candidates” rules to require explicit evidence that the
failure began immediately after the originating payload and that infrastructure,
RHCOS, and other plausible causes have been ruled out, in addition to a score of
at least 85 and a clear mapping to the PR. Keep high-scoring but unproven
candidates as non-revert recommendations, and preserve RHCOS RPM changes as
informational suspects rather than revert targets.

205-215: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Resolve the must-gather interaction contract.

The payload subagent prompt requires full must-gather analysis and prohibits user questions. The test-failure workflow requires a user confirmation before it downloads and analyzes must-gather data. This conflict causes either skipped diagnostics or an instruction violation.

  • plugins/ci/skills/payload-analysis/SKILL.md#L205-L215: pass an explicit noninteractive, pre-authorized must-gather option when full analysis is required.
  • plugins/ci/skills/prow-job-analyze-test-failure/SKILL.md#L486-L496: define and honor that option so payload-analysis can perform the required diagnostics without an interactive prompt.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/skills/payload-analysis/SKILL.md` around lines 205 - 215, Resolve
the must-gather authorization contract across
plugins/ci/skills/payload-analysis/SKILL.md lines 205-215 and
plugins/ci/skills/prow-job-analyze-test-failure/SKILL.md lines 486-496: update
the payload-analysis instructions to invoke the test-failure workflow with an
explicit noninteractive, pre-authorized must-gather option, and update the
test-failure skill to define and honor that option by skipping the confirmation
prompt while still performing full must-gather download and analysis; preserve
interactive confirmation when the option is absent.
🟡 Minor comments (10)
plugins/ci/skills/payload-snapshot/SKILL.md-70-71 (1)

70-71: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Document the retained --sippy option.

The CLI reference no longer lists --sippy, but payload_snapshot.py still defines it (line 2497). Line 208 of this file also still refers to Sippy-based changelogs. Users cannot discover a supported flag from the skill documentation. Restore a short --sippy entry in the CLI reference, or remove the flag from the script.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/skills/payload-snapshot/SKILL.md` around lines 70 - 71, Update the
CLI reference in the payload snapshot skill documentation to include a concise
entry for the existing --sippy option defined by payload_snapshot.py, including
its purpose and usage. Keep the current Sippy changelog behavior documented
consistently, without removing the supported flag from the script.
plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py-377-380 (1)

377-380: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Chain the re-raised exception.

Ruff flags B904 here. Add from None to mark the re-raise as intentional and keep the traceback clean.

♻️ Proposed change
         except ValueError:
             raise ValueError(
                 f"Tag {start_tag} not found in stream {self.stream_name}"
-            )
+            ) from None
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py` around lines
377 - 380, Update the ValueError re-raise in the surrounding tag lookup method
to explicitly suppress exception chaining by adding the intentional `from None`
cause. Keep the existing error message and handling unchanged.

Source: Linters/SAST tools

plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py-762-762 (1)

762-762: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Rename the loop variable to satisfy E741.

Ruff reports the ambiguous name l. Use line.

♻️ Proposed change
-        files = [l.strip() for l in output.strip().splitlines() if l.strip()]
+        files = [line.strip() for line in output.strip().splitlines()
+                 if line.strip()]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py` at line 762,
In the files comprehension, rename the loop variable from l to line and update
its strip() reference accordingly to satisfy Ruff E741.

Source: Linters/SAST tools

plugins/ci/evals/eval-payload-analysis.yaml-566-576 (1)

566-576: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Required-claim matching is fragile for multi-word phrases. The case_constraints judge compares each any_of entry as an exact lowercased substring against the concatenated raw HTML, YAML, and JSON output. Markup, newlines, or extra indentation between words defeat the match, and the judge runs at min_pass_rate: 1.0, so a correct analysis can fail the gate.

  • plugins/ci/evals/eval-payload-analysis.yaml#L566-L576: strip HTML tags and collapse whitespace in both searchable and each any_of phrase before the substring test.
  • plugins/ci/evals/cases/payload-analysis/case-020/annotations.yaml#L13-L18: shorten the sentence-shaped phrases in the first required claim to distinctive fragments, for example "externally unreachable" and "vip reachability".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/evals/eval-payload-analysis.yaml` around lines 566 - 576,
Normalize required-claim matching in the case_constraints judge by stripping
HTML tags and collapsing whitespace in both searchable and each any_of phrase
before the substring comparison; update
plugins/ci/evals/eval-payload-analysis.yaml lines 566-576 accordingly. In
plugins/ci/evals/cases/payload-analysis/case-020/annotations.yaml lines 13-18,
replace the first required claim’s sentence-shaped alternatives with distinctive
fragments such as “externally unreachable” and “vip reachability”.
plugins/ci/evals/eval-payload-analysis.yaml-537-555 (1)

537-555: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Guard the confidence conversions against non-integer agent output.

confidence_score comes from the agent-generated results YAML. int() raises ValueError for values such as "95.0" or "95/100", and raises TypeError for None. The judge body has no exception handling around these conversions, so the judge raises instead of returning a failure verdict. The yaml_results_valid judge checks only that confidence_score is present, not that it is an integer. A malformed score therefore breaks the eval run instead of being scored as a failure.

🛡️ Proposed coercion helper
       failures = []
+
+      def as_confidence(value, default=0):
+          try:
+              return int(float(value))
+          except (TypeError, ValueError):
+              return default
 
       expected_causes = (
           (case_data.get("expected_candidates", []) or [])
           + (case_data.get("expected_ci_config_issues", []) or [])
       )
       for expected in expected_causes:
           url = str(expected.get("pr_url", "")).rstrip("/").lower()
           candidate = by_url.get(url)
           if candidate is None:
               failures.append(f"Missing expected candidate {url}")
               continue
-          minimum = int(expected.get("min_confidence", 0))
-          actual = int(candidate.get("confidence_score", 0))
+          minimum = as_confidence(expected.get("min_confidence", 0))
+          actual = as_confidence(candidate.get("confidence_score", 0))
           if actual < minimum:
               failures.append(
                   f"Expected candidate {url} scored {actual}, below {minimum}"
               )
 
       for url in case_data.get("forbidden_revert_candidates", []) or []:
           normalized = str(url).rstrip("/").lower()
           candidate = by_url.get(normalized)
           if candidate is None:
               continue
-          confidence = int(candidate.get("confidence_score", 0))
+          confidence = as_confidence(candidate.get("confidence_score", 0))
           eligible = candidate.get("revert_eligible") is True
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/evals/eval-payload-analysis.yaml` around lines 537 - 555, Guard
both confidence_score conversions in the judge body, including the
expected-candidate check and the forbidden_revert_candidates loop, against
malformed agent values such as decimal strings, fractions, and None. Reuse or
add a local coercion helper that returns a safe failure value for invalid scores
instead of raising, while preserving normal integer scoring and failure
reporting.
plugins/ci/evals/eval-payload-analysis.yaml-335-344 (1)

335-344: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Add the githubusercontent PR-diff host to the mutable-URL check.

is_github_url recognizes only github.com, api.github.com, and raw.githubusercontent.com. patch-diff.githubusercontent.com serves mutable PR diffs and patches, for example https://patch-diff.githubusercontent.com/raw/<owner>/<repo>/pull/<n>.diff. That host is not matched, so check_external_url does not flag it and the hard gate is bypassed. gist.github.com has the same gap.

🛡️ Proposed host additions
           return host in {
               "github.com",
               "api.github.com",
               "raw.githubusercontent.com",
+              "patch-diff.githubusercontent.com",
+              "gist.github.com",
+              "gist.githubusercontent.com",
           }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/evals/eval-payload-analysis.yaml` around lines 335 - 344, Update
is_github_url to recognize patch-diff.githubusercontent.com and gist.github.com
as GitHub hosts, preserving the existing URL parsing and false-on-invalid-URL
behavior so check_external_url applies the mutable-URL check to these domains.
plugins/ci/evals/eval-payload-analysis.yaml-307-333 (1)

307-333: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Fail explicitly when analysis_cutoff is missing or unparsable.

cutoff becomes None when case_data has no analysis_cutoff, when the value is empty, or when the value is not parsable. check_bound then verifies only that each bound is an absolute timestamp. It never compares the bound against the cutoff, so a lookup dated after the payload completion passes the gate. The judge threshold is min_pass_rate: 1.0, and this degradation is silent. An explicit failure makes a missing cutoff visible to the case author.

🛡️ Proposed guard
       cutoff = absolute_time(cutoff_text)
+      if cutoff is None:
+          return (
+              False,
+              "Case annotations lack a parsable analysis_cutoff; "
+              "point-in-time bounds cannot be enforced",
+          )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/evals/eval-payload-analysis.yaml` around lines 307 - 333, Validate
the analysis_cutoff immediately after computing cutoff in the surrounding
payload-analysis flow, and record an explicit violation when cutoff is missing
or unparsable instead of allowing check_bound to continue without a comparison.
Preserve the existing bound validation and cutoff-exceeded checks for valid
cutoffs, while ensuring invalid or empty analysis_cutoff values fail the case
visibly.
plugins/ci/skills/prow-job-extract-must-gather/generate_html_report.py-705-709 (1)

705-709: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

dict.get() fallback never triggers for None values.

file.get('symlink_path', f"logs/{file['path']}") only falls back to the default when the key is missing. create_txt_symlinks always sets symlink_path (to a path or to None), so when inline HTML generation fails for a small file, iframe_path becomes the literal string None instead of falling back to the raw file path. The inline viewer then tries to load data-iframe-path="None" and fails.

Use file.get('symlink_path') or f"logs/{file['path']}" instead, so a None value also falls back correctly (already reflected in the diff on the escaping comment above).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/skills/prow-job-extract-must-gather/generate_html_report.py`
around lines 705 - 709, Update the iframe_path assignment in the file-processing
loop to use the symlink_path value only when it is truthy, falling back to
f"logs/{file['path']}" when it is missing or None. Keep original_path and the
surrounding HTML generation behavior unchanged.
plugins/ci/skills/prow-job-extract-must-gather/generate_html_report.py-1277-1279 (1)

1277-1279: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Guard against a trailing slash in logs_dir.

os.path.dirname(logs_dir) does not strip a trailing slash first. If logs_dir ends with /, os.path.dirname() returns the same directory instead of its parent, and the report is written to the wrong location. Current documented invocations omit the trailing slash, but the assumption is fragile.

Normalize the path before computing the parent directory.

🐛 Proposed fix
     # Determine output path
-    output_dir = os.path.dirname(logs_dir)
+    output_dir = os.path.dirname(os.path.normpath(logs_dir))
     output_file = os.path.join(output_dir, 'must-gather-browser.html')
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/skills/prow-job-extract-must-gather/generate_html_report.py`
around lines 1277 - 1279, Normalize logs_dir to remove any trailing path
separator before passing it to os.path.dirname in the output-path construction,
ensuring output_file is placed in the parent directory even when logs_dir ends
with a slash.
plugins/ci/skills/prow-job-extract-must-gather/extract_archives.py-122-136 (1)

122-136: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Fix inflated file count on getsize failure.

total_files += 1 runs before os.path.getsize(file_path). If getsize raises, the bare except: pass discards the error, but total_files was already incremented while total_size was not. The final statistics then overcount files relative to size.

Move the increment after the successful size lookup, and catch OSError specifically instead of a bare except.

🐛 Proposed fix
     for root, dirs, files in os.walk(base_path):
         for filename in files:
             file_path = os.path.join(root, filename)
             try:
-                total_files += 1
-                total_size += os.path.getsize(file_path)
-            except:
-                pass
+                size = os.path.getsize(file_path)
+                total_files += 1
+                total_size += size
+            except OSError:
+                pass
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/skills/prow-job-extract-must-gather/extract_archives.py` around
lines 122 - 136, Update count_files_and_size so os.path.getsize(file_path)
succeeds before incrementing total_files, keeping the file count and total size
consistent when metadata lookup fails. Replace the bare except with an
OSError-specific handler, while preserving the existing traversal and return
behavior.

Source: Linters/SAST tools

🧹 Nitpick comments (7)
plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py (3)

2358-2363: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

Consider defusedxml for third-party JUnit XML.

ET.parse reads XML downloaded from CI artifact buckets. Ruff reports S314. If you want to remove the class of entity-expansion risks, parse with defusedxml.ElementTree. This adds a dependency, and the skill states that only the standard library is used, so treat it as optional.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py` around lines
2358 - 2363, Optionally update _parse_junit_xml to use
defusedxml.ElementTree.parse for JUnit XML obtained from external CI artifacts,
replacing the standard-library ET parser while preserving the existing exception
handling and empty-list fallback. Add the dependency only if the skill’s
standard-library-only constraint is intentionally relaxed.

Source: Linters/SAST tools


2165-2188: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Log gcloud stderr before returning None.

Both helpers discard stderr and the exit code. The preflight credential probe was also removed. An expired credential, a permission error, and a missing object now produce the same silent None, so the snapshot reports no JUnit data without any reason. Log stderr at warning level.

The Ruff S603 and ast-grep injection findings on these lines are false positives: the arguments are a list, shell=True is not used, and the values come from internal GCS paths.

♻️ Proposed change
 def _run_gcloud(args: list[str], timeout: int = 120) -> Optional[str]:
     """Run a gcloud CLI command, returning stdout or None on error."""
     try:
         result = subprocess.run(
             args, capture_output=True, text=True, timeout=timeout
         )
         if result.returncode != 0:
+            _log(f"  Warning: gcloud failed ({result.returncode}): "
+                 f"{result.stderr.strip()[:300]}")
             return None
         return result.stdout
-    except (subprocess.TimeoutExpired, FileNotFoundError):
+    except (subprocess.TimeoutExpired, FileNotFoundError) as e:
+        _log(f"  Warning: gcloud command failed: {e}")
         return None
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py` around lines
2165 - 2188, Update _run_gcloud and _run_gcloud_bytes to log each failed gcloud
invocation’s stderr at warning level before returning None, while preserving
their existing success and exception behavior. Use the captured stderr from each
subprocess result and retain the current safe list-based, non-shell invocation.

Source: Linters/SAST tools


289-292: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Log the failure instead of silently returning an empty changelog.

fetch_changelog swallows every exception. A transient Sippy error then looks identical to a payload with no PR changes, and the snapshot records an empty changelog. Narrow the exception and log it.

♻️ Proposed change
         url = f"{self.SIPPY_BASE}/payloads/diff?{params}"
         try:
             return fetch_json(url, timeout=60)
-        except Exception:
+        except (urllib.error.HTTPError, urllib.error.URLError,
+                json.JSONDecodeError, TimeoutError) as e:
+            _log(f"  Warning: Sippy changelog fetch failed for {tag_name}: {e}")
             return []
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py` around lines
289 - 292, Update fetch_changelog to catch only the expected request/fetch
exception rather than every Exception, log the failure with relevant error
details, and preserve returning an empty list for that handled failure. Do not
let unrelated exceptions be silently converted into an empty changelog.

Source: Linters/SAST tools

plugins/ci/evals/eval-payload-analysis.yaml (1)

78-80: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Document the remaining scored annotation fields in the schema.

The analysis_quality prompt scores required_claim, discriminating signal, and must_not_conclude at lines 644-645. case-020/annotations.yaml also defines distractors and key_evidence. The schema block lists only required_claims among these. Case authors read this schema as the contract, so add the missing fields to keep new cases consistent.

♻️ Proposed schema additions
       - 'required_claims': causal findings that must appear in the analysis;
         each entry supplies a name and a list of acceptable evidence phrases
+      - 'must_not_conclude': optional list of conclusions the analysis must
+        not reach
+      - 'distractors': optional list of plausible but incorrect signals
+      - 'key_evidence': optional list of fact/artifacts pairs establishing the
+        contemporaneous record
+      - 'discriminating_signal': optional free text describing the reasoning
+        that separates the true cause from the distractors
       - 'notes': free text context
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/evals/eval-payload-analysis.yaml` around lines 78 - 80, Update the
schema documentation in eval-payload-analysis.yaml to list all scored annotation
fields: required_claims, discriminating signal, must_not_conclude, distractors,
and key_evidence. Describe each field consistently with the existing
required_claims and notes entries so the schema reflects the contract used by
analysis_quality and case annotations.
plugins/ci/evals/cases/payload-analysis/case-020/annotations.yaml (1)

13-18: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider shorter evidence phrases for the first required claim.

The case_constraints judge matches each any_of entry as a plain lowercased substring across the HTML, YAML, and JSON outputs. Long sentence-shaped phrases such as "external vip reachability precedes" and "vips were externally unreachable" require near-exact wording. HTML rendering can insert markup or line breaks between those words. The judge threshold is min_pass_rate: 1.0, so a semantically correct report can still fail the gate. The shorter phrases in the other claims, for example "dns 429" and "osprovisioningtimedout", do not have this fragility.

♻️ Proposed shorter phrases
   - name: "GCP infrastructure trigger precedes fallout"
     any_of:
-      - "external vip reachability precedes"
-      - "vips were externally unreachable"
-      - "pre-upgrade network reachability loss"
+      - "externally unreachable"
+      - "vip reachability"
+      - "network reachability loss"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/evals/cases/payload-analysis/case-020/annotations.yaml` around
lines 13 - 18, Shorten the first required claim’s any_of evidence phrases in the
annotations for “GCP infrastructure trigger precedes fallout” to robust
lowercase substrings that preserve the intended reachability evidence while
avoiding sentence-shaped wording vulnerable to HTML markup or line breaks. Keep
the claim meaning and existing judge configuration unchanged.
plugins/ci/evals/test_eval_payload_analysis.py (1)

12-25: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider covering the new deterministic judges with tests.

This test asserts only on the revert_scoring_accuracy prompt text. The PR adds two deterministic judges with substantial Python logic in their check blocks: point_in_time_trace_hygiene and case_constraints. Both run at min_pass_rate: 1.0, so a logic defect in either one blocks every eval run. The check bodies are plain Python strings in the YAML, so a test can exec them against synthetic outputs dictionaries. Useful cases include an immutable commit URL, a git log call without --until, a bound that exceeds the cutoff, and a forbidden candidate at confidence 90.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/evals/test_eval_payload_analysis.py` around lines 12 - 25, Extend
ScoringPromptTest with coverage for the deterministic judges
point_in_time_trace_hygiene and case_constraints by loading and executing each
YAML check block against synthetic outputs dictionaries. Assert the expected
outcomes for an immutable commit URL, git log usage without --until, a bound
exceeding the cutoff, and a forbidden candidate at confidence 90, while
preserving the existing revert_scoring_accuracy prompt assertions.
plugins/ci/skills/prow-job-extract-must-gather/generate_html_report.py (1)

985-993: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Restrict inline HTML generation to text-oriented file types.

Every file under 1MB gets an inline HTML view, including certs, archives, and other binary types. Reading these as UTF-8 text with errors='replace' produces unreadable output while still paying the read/escape/write cost per file, across potentially thousands of files in a must-gather extraction.

Restrict this to text-oriented types (log, yaml, json, xml, config) using the file['type'] value already computed by scan_directory.

♻️ Proposed fix
+    TEXT_TYPES = {'log', 'yaml', 'json', 'xml', 'config'}
     for file in files:
-        if file['size'] < MAX_INLINE_SIZE:
+        if file['size'] < MAX_INLINE_SIZE and file['type'] in TEXT_TYPES:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/skills/prow-job-extract-must-gather/generate_html_report.py`
around lines 985 - 993, Update the inline HTML generation condition in the file
loop to require both a size below MAX_INLINE_SIZE and a text-oriented
file['type'] such as log, yaml, json, xml, or config. Reuse the type
classification produced by scan_directory and leave non-text files on the
existing non-inline path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@plugins/ci/evals/cases/payload-analysis/case-019/annotations.yaml`:
- Around line 57-60: Remove all outcomes referring to PRs `#2953` and `#2954` from
the key_evidence and notes entries in the payload-analysis annotation,
preserving evidence from before the 2026-07-25T16:05:26Z cutoff.

In `@plugins/ci/evals/eval-payload-analysis.yaml`:
- Around line 403-414: Extend the leakage checks alongside the existing Read
handling to also process Grep and Glob tool calls. Inspect each tool’s path,
pattern, and glob inputs for the same annotations.yaml, payload-analysis README,
and comments.json patterns, and append the existing violation message when
matched; preserve the current Read, WebFetch, and Bash checks.
- Line 7: Update the ci:payload-analysis skill’s argument handling to accept an
optional --as-of cutoff alongside the existing payload tag and --snapshot-dir
arguments. Parse and validate the cutoff, then constrain all payload analysis
evidence to data at or before that timestamp, while preserving current behavior
when --as-of is omitted.

In `@plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py`:
- Around line 418-424: Update _has_blocking_failures to require explicit success
for every blocking job, matching PayloadChain._all_blocking_passed: treat Error,
Aborted, Pending, and any other non-Succeeded state as failure, and treat an
empty runs result as not green. Preserve the existing Sippy fetch flow while
ensuring baseline detection only succeeds when blocking job data is present and
all blocking jobs have state Succeeded.

In `@plugins/ci/skills/prow-job-analyze-install-failure/SKILL.md`:
- Around line 93-97: Update the build_id extraction guidance in “Extract
build_id” to accept IDs followed by either a slash or the end of the URL, while
still requiring at least 10 consecutive digits and a valid path boundary before
the ID. Preserve the existing error message when no matching build ID is found.

In `@plugins/ci/skills/prow-job-analyze-test-failure/SKILL.md`:
- Around line 1248-1255: Update the completion workflow around the “Display
completion message” step to first persist the selected Branch A or Branch B
report to .work/prow-job-analyze-test-failure/{build_id}/analysis.md, creating
the required parent directory as needed. Only display the report path after the
write succeeds, while preserving the existing completion message and
report-selection behavior.
- Around line 643-648: Update the Pattern 2 (dual) management-cluster extraction
command to use the CI plugin path for extract_archives.py, matching the working
path used elsewhere in the document. Keep the archive and output-directory
arguments unchanged.
- Around line 760-781: Update the must-gather script discovery in the “Locate
must-gather-analyzer scripts” step to remove the find ~ fallback and avoid
executing analyzer files from arbitrary home-directory locations. Use only a
declared trusted plugin path or invoke the registered must-gather analysis
skill, while preserving the existing warning and continuation behavior when the
trusted scripts are unavailable.
- Around line 224-241: The installation-failure delegation uses the wrong
identifier form. In plugins/ci/skills/prow-job-analyze-test-failure/SKILL.md
lines 224-241, invoke the canonical ci:prow-job-analyze-install-failure skill,
or explicitly use /ci:analyze-prow-job-install-failure as a command. In
plugins/ci/skills/fetch-regression-details/SKILL.md line 472, label
/ci:analyze-prow-job-test-failure as a related command or replace it with the
canonical ci:prow-job-analyze-test-failure skill.

In `@plugins/ci/skills/prow-job-artifact-search/prow_job_artifact_search.py`:
- Around line 218-257: Update cmd_fetch to reject non-positive max_bytes values
and enforce the limit before downloading object contents. Use GCS metadata to
determine the object size, reject objects exceeding max_bytes before transfer,
and use a range-capable read limited to max_bytes for accepted objects while
preserving the existing response fields and cleanup behavior.
- Line 269: Align the Python version contract across the script and
documentation: update prow_job_artifact_search.py to avoid the Python 3.7-only
add_subparsers(required=True), capture_output=True, and text=True APIs with
Python 3.6-compatible equivalents, including explicit post-parse command
validation; update SKILL.md lines 31-32 to retain the documented Python 3.6+
minimum. Alternatively, raise the documented minimum in SKILL.md lines 31-32 to
Python 3.7 and keep all three APIs unchanged.

In `@plugins/ci/skills/prow-job-extract-must-gather/extract_archives.py`:
- Around line 21-30: Harden extract_tar_archive so every supported Python
version prevents tar path traversal for both top-level and nested archives. Use
tar.extractall’s filter='data' where available, and provide a Python
3-compatible fallback that rejects absolute paths, traversal outside extract_to,
links, and unsafe special files before extraction; do not leave the existing
unfiltered extractall path.

In `@plugins/ci/skills/prow-job-extract-must-gather/generate_html_report.py`:
- Around line 705-729: Escape all must-gather and CLI-derived values before
interpolating them into generated HTML. In the file-item loop, apply
html_module.escape to file['name'], file['path'], file['dir'],
file['top_level_dir'], symlink_path, and related metadata used in attributes or
text; apply the same escaping in the header metadata block for prowjob_name,
build_id, target, and gcsweb_url (including its href), and in the directory
filter for directory and display_name. Reuse the existing html_module.escape
pattern without changing the report’s displayed values or behavior.
- Around line 93-106: Update the top_level_dir derivation in the
report-generation logic so files directly under content/ or with no directory
separator receive a non-empty sentinel category instead of ''. Ensure that
sentinel is added to dir_counts and therefore appears in the directory filter
buttons and default active-filter set, while preserving existing
nested-directory categorization.

---

Outside diff comments:
In `@plugins/ci/skills/payload-analysis/SKILL.md`:
- Around line 301-345: Update the “Propose Revert Candidates” rules to require
explicit evidence that the failure began immediately after the originating
payload and that infrastructure, RHCOS, and other plausible causes have been
ruled out, in addition to a score of at least 85 and a clear mapping to the PR.
Keep high-scoring but unproven candidates as non-revert recommendations, and
preserve RHCOS RPM changes as informational suspects rather than revert targets.
- Around line 205-215: Resolve the must-gather authorization contract across
plugins/ci/skills/payload-analysis/SKILL.md lines 205-215 and
plugins/ci/skills/prow-job-analyze-test-failure/SKILL.md lines 486-496: update
the payload-analysis instructions to invoke the test-failure workflow with an
explicit noninteractive, pre-authorized must-gather option, and update the
test-failure skill to define and honor that option by skipping the confirmation
prompt while still performing full must-gather download and analysis; preserve
interactive confirmation when the option is absent.

In `@plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py`:
- Around line 719-736: Update the JUnit collection flow in collect: when
_list_junit_files() returns no files for a failed job, log a warning and avoid
writing results.json; also track whether any JUnit file was successfully read
and skip _write_json when none were read, including cases where _parse_junit_xml
returns no results due to unreadable XML. Preserve normal results writing when
at least one JUnit file is read.

---

Minor comments:
In `@plugins/ci/evals/eval-payload-analysis.yaml`:
- Around line 566-576: Normalize required-claim matching in the case_constraints
judge by stripping HTML tags and collapsing whitespace in both searchable and
each any_of phrase before the substring comparison; update
plugins/ci/evals/eval-payload-analysis.yaml lines 566-576 accordingly. In
plugins/ci/evals/cases/payload-analysis/case-020/annotations.yaml lines 13-18,
replace the first required claim’s sentence-shaped alternatives with distinctive
fragments such as “externally unreachable” and “vip reachability”.
- Around line 537-555: Guard both confidence_score conversions in the judge
body, including the expected-candidate check and the forbidden_revert_candidates
loop, against malformed agent values such as decimal strings, fractions, and
None. Reuse or add a local coercion helper that returns a safe failure value for
invalid scores instead of raising, while preserving normal integer scoring and
failure reporting.
- Around line 335-344: Update is_github_url to recognize
patch-diff.githubusercontent.com and gist.github.com as GitHub hosts, preserving
the existing URL parsing and false-on-invalid-URL behavior so check_external_url
applies the mutable-URL check to these domains.
- Around line 307-333: Validate the analysis_cutoff immediately after computing
cutoff in the surrounding payload-analysis flow, and record an explicit
violation when cutoff is missing or unparsable instead of allowing check_bound
to continue without a comparison. Preserve the existing bound validation and
cutoff-exceeded checks for valid cutoffs, while ensuring invalid or empty
analysis_cutoff values fail the case visibly.

In `@plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py`:
- Around line 377-380: Update the ValueError re-raise in the surrounding tag
lookup method to explicitly suppress exception chaining by adding the
intentional `from None` cause. Keep the existing error message and handling
unchanged.
- Line 762: In the files comprehension, rename the loop variable from l to line
and update its strip() reference accordingly to satisfy Ruff E741.

In `@plugins/ci/skills/payload-snapshot/SKILL.md`:
- Around line 70-71: Update the CLI reference in the payload snapshot skill
documentation to include a concise entry for the existing --sippy option defined
by payload_snapshot.py, including its purpose and usage. Keep the current Sippy
changelog behavior documented consistently, without removing the supported flag
from the script.

In `@plugins/ci/skills/prow-job-extract-must-gather/extract_archives.py`:
- Around line 122-136: Update count_files_and_size so os.path.getsize(file_path)
succeeds before incrementing total_files, keeping the file count and total size
consistent when metadata lookup fails. Replace the bare except with an
OSError-specific handler, while preserving the existing traversal and return
behavior.

In `@plugins/ci/skills/prow-job-extract-must-gather/generate_html_report.py`:
- Around line 705-709: Update the iframe_path assignment in the file-processing
loop to use the symlink_path value only when it is truthy, falling back to
f"logs/{file['path']}" when it is missing or None. Keep original_path and the
surrounding HTML generation behavior unchanged.
- Around line 1277-1279: Normalize logs_dir to remove any trailing path
separator before passing it to os.path.dirname in the output-path construction,
ensuring output_file is placed in the parent directory even when logs_dir ends
with a slash.

---

Nitpick comments:
In `@plugins/ci/evals/cases/payload-analysis/case-020/annotations.yaml`:
- Around line 13-18: Shorten the first required claim’s any_of evidence phrases
in the annotations for “GCP infrastructure trigger precedes fallout” to robust
lowercase substrings that preserve the intended reachability evidence while
avoiding sentence-shaped wording vulnerable to HTML markup or line breaks. Keep
the claim meaning and existing judge configuration unchanged.

In `@plugins/ci/evals/eval-payload-analysis.yaml`:
- Around line 78-80: Update the schema documentation in
eval-payload-analysis.yaml to list all scored annotation fields:
required_claims, discriminating signal, must_not_conclude, distractors, and
key_evidence. Describe each field consistently with the existing required_claims
and notes entries so the schema reflects the contract used by analysis_quality
and case annotations.

In `@plugins/ci/evals/test_eval_payload_analysis.py`:
- Around line 12-25: Extend ScoringPromptTest with coverage for the
deterministic judges point_in_time_trace_hygiene and case_constraints by loading
and executing each YAML check block against synthetic outputs dictionaries.
Assert the expected outcomes for an immutable commit URL, git log usage without
--until, a bound exceeding the cutoff, and a forbidden candidate at confidence
90, while preserving the existing revert_scoring_accuracy prompt assertions.

In `@plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py`:
- Around line 2358-2363: Optionally update _parse_junit_xml to use
defusedxml.ElementTree.parse for JUnit XML obtained from external CI artifacts,
replacing the standard-library ET parser while preserving the existing exception
handling and empty-list fallback. Add the dependency only if the skill’s
standard-library-only constraint is intentionally relaxed.
- Around line 2165-2188: Update _run_gcloud and _run_gcloud_bytes to log each
failed gcloud invocation’s stderr at warning level before returning None, while
preserving their existing success and exception behavior. Use the captured
stderr from each subprocess result and retain the current safe list-based,
non-shell invocation.
- Around line 289-292: Update fetch_changelog to catch only the expected
request/fetch exception rather than every Exception, log the failure with
relevant error details, and preserve returning an empty list for that handled
failure. Do not let unrelated exceptions be silently converted into an empty
changelog.

In `@plugins/ci/skills/prow-job-extract-must-gather/generate_html_report.py`:
- Around line 985-993: Update the inline HTML generation condition in the file
loop to require both a size below MAX_INLINE_SIZE and a text-oriented
file['type'] such as log, yaml, json, xml, or config. Reuse the type
classification produced by scan_directory and leave non-text files on the
existing non-inline path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 1e4fb4c4-395e-49d8-bc9d-d2675aacf120

📥 Commits

Reviewing files that changed from the base of the PR and between 824c4a3 and 81cba17.

📒 Files selected for processing (103)
  • .claude-plugin/marketplace.json
  • docs/index.html
  • plugins/ci/.claude-plugin/plugin.json
  • plugins/ci/evals/cases/payload-analysis/README.md
  • plugins/ci/evals/cases/payload-analysis/case-001/annotations.yaml
  • plugins/ci/evals/cases/payload-analysis/case-001/input.yaml
  • plugins/ci/evals/cases/payload-analysis/case-002/annotations.yaml
  • plugins/ci/evals/cases/payload-analysis/case-002/input.yaml
  • plugins/ci/evals/cases/payload-analysis/case-003-5.0-ci-cno-networkpolicy-revert/input.yaml
  • plugins/ci/evals/cases/payload-analysis/case-003/annotations.yaml
  • plugins/ci/evals/cases/payload-analysis/case-003/input.yaml
  • plugins/ci/evals/cases/payload-analysis/case-004/annotations.yaml
  • plugins/ci/evals/cases/payload-analysis/case-004/input.yaml
  • plugins/ci/evals/cases/payload-analysis/case-005/annotations.yaml
  • plugins/ci/evals/cases/payload-analysis/case-005/input.yaml
  • plugins/ci/evals/cases/payload-analysis/case-006/annotations.yaml
  • plugins/ci/evals/cases/payload-analysis/case-006/input.yaml
  • plugins/ci/evals/cases/payload-analysis/case-007/annotations.yaml
  • plugins/ci/evals/cases/payload-analysis/case-007/input.yaml
  • plugins/ci/evals/cases/payload-analysis/case-008/annotations.yaml
  • plugins/ci/evals/cases/payload-analysis/case-008/input.yaml
  • plugins/ci/evals/cases/payload-analysis/case-009-5.0-ci-hypershift-builder-fp/input.yaml
  • plugins/ci/evals/cases/payload-analysis/case-009/annotations.yaml
  • plugins/ci/evals/cases/payload-analysis/case-009/input.yaml
  • plugins/ci/evals/cases/payload-analysis/case-010-4.18-rejected-multiple-failures/annotations.yaml
  • plugins/ci/evals/cases/payload-analysis/case-010/annotations.yaml
  • plugins/ci/evals/cases/payload-analysis/case-010/input.yaml
  • plugins/ci/evals/cases/payload-analysis/case-011-5.0-ci-infra-only-no-candidates/input.yaml
  • plugins/ci/evals/cases/payload-analysis/case-011/annotations.yaml
  • plugins/ci/evals/cases/payload-analysis/case-011/input.yaml
  • plugins/ci/evals/cases/payload-analysis/case-012/annotations.yaml
  • plugins/ci/evals/cases/payload-analysis/case-012/input.yaml
  • plugins/ci/evals/cases/payload-analysis/case-013/annotations.yaml
  • plugins/ci/evals/cases/payload-analysis/case-013/input.yaml
  • plugins/ci/evals/cases/payload-analysis/case-014/annotations.yaml
  • plugins/ci/evals/cases/payload-analysis/case-014/input.yaml
  • plugins/ci/evals/cases/payload-analysis/case-018/annotations.yaml
  • plugins/ci/evals/cases/payload-analysis/case-018/input.yaml
  • plugins/ci/evals/cases/payload-analysis/case-019/annotations.yaml
  • plugins/ci/evals/cases/payload-analysis/case-019/input.yaml
  • plugins/ci/evals/cases/payload-analysis/case-020/annotations.yaml
  • plugins/ci/evals/cases/payload-analysis/case-020/input.yaml
  • plugins/ci/evals/eval-payload-analysis.yaml
  • plugins/ci/evals/test_eval_payload_analysis.py
  • plugins/ci/skills/analyze-disruption/SKILL.md
  • plugins/ci/skills/bulk-triage-regressions/SKILL.md
  • plugins/ci/skills/detect-permafail/SKILL.md
  • plugins/ci/skills/diagnose-job-run-symptoms/SKILL.md
  • plugins/ci/skills/diagnose-job-run-symptoms/diagnose_job_run.py
  • plugins/ci/skills/diagnose-job-run-symptoms/test_diagnose_job_run.py
  • plugins/ci/skills/fetch-job-run-summary/SKILL.md
  • plugins/ci/skills/fetch-prow-job-runs/SKILL.md
  • plugins/ci/skills/fetch-prow-job-runs/fetch_prow_job_runs.py
  • plugins/ci/skills/fetch-prow-job-runs/test_fetch_prow_job_runs.py
  • plugins/ci/skills/fetch-prowjob-json/SKILL.md
  • plugins/ci/skills/fetch-regression-details/SKILL.md
  • plugins/ci/skills/list-symptoms/SKILL.md
  • plugins/ci/skills/list-symptoms/list_symptoms.py
  • plugins/ci/skills/list-symptoms/test_list_symptoms.py
  • plugins/ci/skills/manage-labels/SKILL.md
  • plugins/ci/skills/manage-labels/manage_labels.py
  • plugins/ci/skills/manage-labels/test_manage_labels.py
  • plugins/ci/skills/manage-symptoms/SKILL.md
  • plugins/ci/skills/manage-symptoms/manage_symptoms.py
  • plugins/ci/skills/manage-symptoms/test_manage_symptoms.py
  • plugins/ci/skills/payload-analysis/SKILL.md
  • plugins/ci/skills/payload-autodl-json/SKILL.md
  • plugins/ci/skills/payload-snapshot/SKILL.md
  • plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py
  • plugins/ci/skills/payload-snapshot/scripts/test_collection_completeness.py
  • plugins/ci/skills/payload-snapshot/scripts/test_sippy_fallback.py
  • plugins/ci/skills/payload-snapshot/scripts/test_test_classification.py
  • plugins/ci/skills/prow-job-analysis/SKILL.md
  • plugins/ci/skills/prow-job-analysis/prow_job_artifact_search.py
  • plugins/ci/skills/prow-job-analysis/references/aggregated.md
  • plugins/ci/skills/prow-job-analysis/references/artifacts.md
  • plugins/ci/skills/prow-job-analysis/references/ci-infrastructure-changes.md
  • plugins/ci/skills/prow-job-analysis/references/cloud-provider-errors.md
  • plugins/ci/skills/prow-job-analysis/references/disruption.md
  • plugins/ci/skills/prow-job-analysis/references/flaky-test-identification.md
  • plugins/ci/skills/prow-job-analysis/references/hypershift.md
  • plugins/ci/skills/prow-job-analysis/references/install/general.md
  • plugins/ci/skills/prow-job-analysis/references/install/metal.md
  • plugins/ci/skills/prow-job-analysis/references/networking.md
  • plugins/ci/skills/prow-job-analysis/references/operating-system-changes.md
  • plugins/ci/skills/prow-job-analysis/references/resource-exhaustion.md
  • plugins/ci/skills/prow-job-analysis/references/test-extension-binaries.md
  • plugins/ci/skills/prow-job-analysis/references/test-failure.md
  • plugins/ci/skills/prow-job-analysis/references/upgrade.md
  • plugins/ci/skills/prow-job-analyze-install-failure/SKILL.md
  • plugins/ci/skills/prow-job-analyze-metal-install-failure/SKILL.md
  • plugins/ci/skills/prow-job-analyze-test-failure/README.md
  • plugins/ci/skills/prow-job-analyze-test-failure/SKILL.md
  • plugins/ci/skills/prow-job-artifact-search/SKILL.md
  • plugins/ci/skills/prow-job-artifact-search/prow_job_artifact_search.py
  • plugins/ci/skills/prow-job-extract-must-gather/CHANGELOG.md
  • plugins/ci/skills/prow-job-extract-must-gather/README.md
  • plugins/ci/skills/prow-job-extract-must-gather/SKILL.md
  • plugins/ci/skills/prow-job-extract-must-gather/extract_archives.py
  • plugins/ci/skills/prow-job-extract-must-gather/generate_html_report.py
  • plugins/ci/skills/reevaluate-job-runs/SKILL.md
  • plugins/ci/skills/reevaluate-job-runs/reevaluate_job_runs.py
  • plugins/ci/skills/reevaluate-job-runs/test_reevaluate_job_runs.py
💤 Files with no reviewable changes (41)
  • plugins/ci/evals/cases/payload-analysis/case-003-5.0-ci-cno-networkpolicy-revert/input.yaml
  • plugins/ci/skills/prow-job-analysis/references/cloud-provider-errors.md
  • plugins/ci/skills/prow-job-analysis/references/test-extension-binaries.md
  • plugins/ci/skills/manage-symptoms/SKILL.md
  • plugins/ci/skills/prow-job-analysis/references/test-failure.md
  • plugins/ci/skills/diagnose-job-run-symptoms/diagnose_job_run.py
  • plugins/ci/skills/prow-job-analysis/references/aggregated.md
  • plugins/ci/skills/detect-permafail/SKILL.md
  • plugins/ci/skills/bulk-triage-regressions/SKILL.md
  • plugins/ci/skills/fetch-prow-job-runs/test_fetch_prow_job_runs.py
  • plugins/ci/skills/diagnose-job-run-symptoms/SKILL.md
  • plugins/ci/skills/manage-labels/manage_labels.py
  • plugins/ci/skills/list-symptoms/test_list_symptoms.py
  • plugins/ci/skills/prow-job-analysis/references/networking.md
  • plugins/ci/skills/prow-job-analysis/references/operating-system-changes.md
  • plugins/ci/skills/manage-labels/SKILL.md
  • plugins/ci/skills/manage-symptoms/manage_symptoms.py
  • plugins/ci/skills/list-symptoms/SKILL.md
  • plugins/ci/skills/prow-job-analysis/references/flaky-test-identification.md
  • plugins/ci/skills/prow-job-analysis/references/artifacts.md
  • plugins/ci/skills/payload-snapshot/scripts/test_sippy_fallback.py
  • plugins/ci/skills/diagnose-job-run-symptoms/test_diagnose_job_run.py
  • plugins/ci/skills/list-symptoms/list_symptoms.py
  • plugins/ci/evals/cases/payload-analysis/case-011-5.0-ci-infra-only-no-candidates/input.yaml
  • plugins/ci/skills/prow-job-analysis/references/upgrade.md
  • plugins/ci/skills/payload-snapshot/scripts/test_test_classification.py
  • plugins/ci/evals/cases/payload-analysis/case-010-4.18-rejected-multiple-failures/annotations.yaml
  • plugins/ci/skills/fetch-prow-job-runs/fetch_prow_job_runs.py
  • plugins/ci/skills/prow-job-analysis/references/disruption.md
  • plugins/ci/skills/manage-symptoms/test_manage_symptoms.py
  • plugins/ci/skills/prow-job-analysis/references/ci-infrastructure-changes.md
  • plugins/ci/skills/prow-job-analysis/references/install/general.md
  • plugins/ci/skills/prow-job-analysis/prow_job_artifact_search.py
  • plugins/ci/skills/payload-snapshot/scripts/test_collection_completeness.py
  • plugins/ci/evals/cases/payload-analysis/case-009-5.0-ci-hypershift-builder-fp/input.yaml
  • plugins/ci/skills/fetch-prow-job-runs/SKILL.md
  • plugins/ci/skills/prow-job-analysis/references/resource-exhaustion.md
  • plugins/ci/skills/prow-job-analysis/SKILL.md
  • plugins/ci/skills/prow-job-analysis/references/hypershift.md
  • plugins/ci/skills/prow-job-analysis/references/install/metal.md
  • plugins/ci/skills/manage-labels/test_manage_labels.py

Comment on lines +57 to +60
PR #2920 is a feature-gate promotion and PR #2923 adds a DevPreview BGP
API for bare metal; neither explains default AWS and Azure operator
intervals. Experimental revert jobs continued to fail frequently for both
PRs, directly falsifying the recommendations.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n -C 4 \
  'analysis_cutoff|`#2953`|`#2954`|experimental revert|later reverts|later outcomes|key_evidence' \
  plugins/ci/evals

Repository: openshift-eng/ai-helpers

Length of output: 48044


🏁 Script executed:

#!/bin/bash
set -euo pipefail

case_file="plugins/ci/evals/cases/payload-analysis/case-019/annotations.yaml"

printf '%s\n' '--- case annotation ---'
cat -n "$case_file" | sed -n '1,95p'

printf '%s\n' '--- related repository files ---'
git ls-files | rg 'case-019|2953|2954|payload-analysis-5\.0\.0-0\.ci-2026-07-25-102741|annotations\.yaml$' | sed -n '1,120p'

printf '%s\n' '--- GitHub PR timestamps and state ---'
for pr in 2953 2954; do
  curl -fsSL "https://api.github.com/repos/openshift/api/pulls/$pr" |
    jq -c '{number,state,created_at,updated_at,closed_at,merged_at,merge_commit_sha,head:.head.sha,base:.base.sha}'
done

Repository: openshift-eng/ai-helpers

Length of output: 12174


🏁 Script executed:

#!/bin/bash
set -euo pipefail

summary='https://gcsweb-ci.apps.ci.l2s4.p1.openshiftapps.com/gcs/test-platform-results/logs/periodic-ci-openshift-release-main-claude-payload-agent/2080964771632386048/artifacts/claude-payload-agent/openshift-claude-payload-agent/artifacts/payload-analysis-5.0.0-0.ci-2026-07-25-102741-summary.html'

printf '%s\n' '--- linked source analysis references ---'
curl -fsSL --max-time 20 "$summary" |
  sed 's/<[^>]*>/ /g' |
  tr -s '[:space:]' ' ' |
  fold -w 240 |
  rg -n -i -C 2 '2953|2954|revert|0/5|1/5|2/5|3/5|completed|timestamp' |
  sed -n '1,160p'

printf '%s\n' '--- public PR page metadata ---'
for pr in 2953 2954; do
  printf 'PR #%s\n' "$pr"
  curl -fsSL -L --max-time 20 -A 'Mozilla/5.0' \
    "https://github.com/openshift/api/pull/$pr" |
    rg -o 'datetime="[^"]+"|data-.*="[^"]+"' |
    rg 'created|updated|closed|merged|2026-07' |
    sed -n '1,30p' || true
done

Repository: openshift-eng/ai-helpers

Length of output: 243


🌐 Web query:

Find public timestamped evidence for openshift/api experimental revert PR #2953and PR#2954, including PR creation, closure, merge, and associated CI job completion times.

💡 Result:

There is no public evidence of experimental revert pull requests numbered #2953 or #2954 within the openshift/api repository [1][2]. The PR numbers #2953 and #2954 in the context of OpenShift-related repositories refer to different activities: 1. In the openshift/cluster-monitoring-operator repository, pull request #2953 is titled MON-4523: ClusterMonitoring prometheusConfig [3][4]. It was created on June 8, 2026, and merged on July 13, 2026 [3]. 2. In other repositories, such as openshift/operator-framework-olm, PRs #2953 and #2954 appear as part of unrelated updates [5][6]. 3. PR #2953 is also referenced in the openshift/node_exporter repository for a watchdog module update [7]. No evidence exists in the provided search results to link experimental revert PRs for openshift/api with these specific numbers [1][2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

summary='https://gcsweb-ci.apps.ci.l2s4.p1.openshiftapps.com/gcs/test-platform-results/logs/periodic-ci-openshift-release-main-claude-payload-agent/2080964771632386048/artifacts/claude-payload-agent/openshift-claude-payload-agent/artifacts/payload-analysis-5.0.0-0.ci-2026-07-25-102741-summary.html'

python3 - "$summary" <<'PY'
import html
import re
import sys
import urllib.request

url = sys.argv[1]
request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(request, timeout=20) as response:
    body = response.read().decode("utf-8", "replace")

text = html.unescape(re.sub(r"<[^>]*>", " ", body))
text = re.sub(r"\s+", " ", text)
terms = re.compile(r"2953|2954|revert|0/5|1/5|2/5|3/5|completed|timestamp", re.I)

matches = list(terms.finditer(text))
print(f"report_bytes={len(body)} matches={len(matches)}")
for match in matches:
    start = max(0, match.start() - 300)
    end = min(len(text), match.end() + 500)
    print(text[start:end])
    print("---")
PY

printf '%s\n' '--- GitHub PR page status ---'
for pr in 2953 2954; do
  printf 'PR #%s: ' "$pr"
  curl -sS -L --max-time 20 -A 'Mozilla/5.0' -o /tmp/api-pr-"$pr".html \
    -w '%{http_code}\n' "https://github.com/openshift/api/pull/$pr"
  rg -n -i -C 2 'experimental|revert|AWS|Azure|0/5|1/5|2/5|3/5|2026-07|not found' \
    /tmp/api-pr-"$pr".html | sed -n '1,80p' || true
done

Repository: openshift-eng/ai-helpers

Length of output: 2448


🏁 Script executed:

#!/bin/bash
set -euo pipefail

summary='https://gcsweb-ci.apps.ci.l2s4.p1.openshiftapps.com/gcs/test-platform-results/logs/periodic-ci-openshift-release-main-claude-payload-agent/2080964771632386048/artifacts/claude-payload-agent/openshift-claude-payload-agent/artifacts/payload-analysis-5.0.0-0.ci-2026-07-25-102741-summary.html'

curl -kfsSL --max-time 20 "$summary" |
python3 -c '
import html, re, sys
body = sys.stdin.read()
text = html.unescape(re.sub(r"<[^>]*>", " ", body))
text = re.sub(r"\s+", " ", text)
terms = re.compile(r"2953|2954|revert|0/5|1/5|2/5|3/5|completed|timestamp", re.I)
matches = list(terms.finditer(text))
print(f"report_chars={len(body)} matches={len(matches)}")
for match in matches:
    print(text[max(0, match.start()-300):min(len(text), match.end()+500)])
    print("---")
'

printf '%s\n' '--- GitHub PR page status ---'
for pr in 2953 2954; do
  printf 'PR #%s: ' "$pr"
  curl -sS -L --max-time 20 -A 'Mozilla/5.0' -o "/tmp/api-pr-$pr.html" \
    -w '%{http_code}\n' "https://github.com/openshift/api/pull/$pr" || true
  rg -n -i -C 2 'experimental|revert|AWS|Azure|0/5|1/5|2/5|3/5|2026-07|not found' \
    "/tmp/api-pr-$pr.html" | sed -n '1,80p' || true
done

Repository: openshift-eng/ai-helpers

Length of output: 50380


Remove post-cutoff revert outcomes from the oracle.

PRs #2953 and #2954 were created at 2026-07-25T18:13:21Z, after the 2026-07-25T16:05:26Z cutoff. Remove their outcomes from key_evidence and notes.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/evals/cases/payload-analysis/case-019/annotations.yaml` around
lines 57 - 60, Remove all outcomes referring to PRs `#2953` and `#2954` from the
key_evidence and notes entries in the payload-analysis annotation, preserving
evidence from before the 2026-07-25T16:05:26Z cutoff.

execution:
mode: case
arguments: "{payload_tag} --snapshot-dir $EVAL_SNAPSHOT_DIR/{payload_tag}"
arguments: "{payload_tag} --snapshot-dir $EVAL_SNAPSHOT_DIR/{payload_tag} --as-of {payload_completed_at}"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# 1) Find cases missing payload_completed_at. 2) Confirm --as-of is documented in the ci plugin.
set -uo pipefail

echo "== cases missing payload_completed_at =="
fd 'input.yaml' plugins/ci/evals/cases/payload-analysis | sort | while IFS= read -r f; do
  rg -q '^payload_completed_at:' "$f" || echo "MISSING: $f"
done

echo "== --as-of references in plugins/ci =="
rg -n -- '--as-of' plugins/ci

Repository: openshift-eng/ai-helpers

Length of output: 522


🏁 Script executed:

set -uo pipefail

echo "== payload-analysis eval definition =="
cat -n plugins/ci/evals/eval-payload-analysis.yaml

echo "== payload-analysis command and skill references =="
rg -n -i --glob '!plugins/ci/evals/eval-payload-analysis.yaml' \
  'payload.?analysis|as-of|snapshot-dir|payload_completed_at' .

echo "== candidate command files =="
fd -t f | rg -i 'payload|analysis|ci' | head -200

Repository: openshift-eng/ai-helpers

Length of output: 50380


🏁 Script executed:

set -uo pipefail

echo "== candidate payload-analysis files =="
fd -t f -i 'payload-analysis' plugins/ci | sort

echo "== command/skill declarations =="
rg -n -i --glob '*.md' \
  '(^#|^description:|arguments|--as-of|snapshot-dir|payload_completed_at|payload tag)' \
  plugins/ci/commands plugins/ci/skills | rg -i 'payload-analysis|as-of|snapshot-dir|payload_completed_at' | head -200

echo "== exact --as-of occurrences outside the eval =="
rg -n --glob '!plugins/ci/evals/eval-payload-analysis.yaml' -- '--as-of' plugins/ci || true

Repository: openshift-eng/ai-helpers

Length of output: 6975


🏁 Script executed:

set -uo pipefail

echo "== payload-analysis argument handling =="
cat -n plugins/ci/skills/payload-analysis/SKILL.md | sed -n '1,95p'

echo "== payload-analysis time and cutoff handling =="
rg -n -i 'as-of|cutoff|timestamp|completed_at|point-in-time|historical|before|after' \
  plugins/ci/skills/payload-analysis/SKILL.md

Repository: openshift-eng/ai-helpers

Length of output: 9533


Implement --as-of handling in ci:payload-analysis. The skill accepts only <payload-tag> [--snapshot-dir DIR] and has no cutoff logic. The eval passes an unhandled argument, so the analysis can use evidence after payload_completed_at.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/evals/eval-payload-analysis.yaml` at line 7, Update the
ci:payload-analysis skill’s argument handling to accept an optional --as-of
cutoff alongside the existing payload tag and --snapshot-dir arguments. Parse
and validate the cutoff, then constrain all payload analysis evidence to data at
or before that timestamp, while preserving current behavior when --as-of is
omitted.

Comment on lines +403 to +414
if name == "Read":
path = tool_input.get("file_path", "")
if (
path.endswith("annotations.yaml")
or "/evals/cases/payload-analysis/README.md" in path
or path.endswith("/comments.json")
):
violations.append(f"Read evaluation/future-leaking file: {path}")

if name == "WebFetch":
url = tool_input.get("url", "")
check_external_url(url, "WebFetch")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Extend the leakage check to the Grep and Glob tools.

The judge inspects only Read, WebFetch, and Bash tool calls. The agent can read expected answers with Grep by passing a case directory as path and an output mode that returns content. Glob can enumerate the case directories to locate those files. Both paths bypass this hard gate. Check the path, pattern, and glob inputs of those tools with the same file patterns.

🛡️ Proposed coverage extension
-              if name == "Read":
-                  path = tool_input.get("file_path", "")
-                  if (
-                      path.endswith("annotations.yaml")
-                      or "/evals/cases/payload-analysis/README.md" in path
-                      or path.endswith("/comments.json")
-                  ):
-                      violations.append(f"Read evaluation/future-leaking file: {path}")
+              leaking = re.compile(
+                  r"annotations\.yaml|"
+                  r"/evals/cases/payload-analysis/README\.md|"
+                  r"/comments\.json|"
+                  r"evals/cases/payload-analysis",
+                  re.I,
+              )
+
+              if name in {"Read", "Grep", "Glob", "NotebookRead"}:
+                  targets = [
+                      str(tool_input.get(key, ""))
+                      for key in ("file_path", "path", "glob", "pattern", "notebook_path")
+                  ]
+                  for target in targets:
+                      if target and leaking.search(target):
+                          violations.append(
+                              f"{name} accessed evaluation/future-leaking path: {target}"
+                          )
+                          break
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if name == "Read":
path = tool_input.get("file_path", "")
if (
path.endswith("annotations.yaml")
or "/evals/cases/payload-analysis/README.md" in path
or path.endswith("/comments.json")
):
violations.append(f"Read evaluation/future-leaking file: {path}")
if name == "WebFetch":
url = tool_input.get("url", "")
check_external_url(url, "WebFetch")
leaking = re.compile(
r"annotations\.yaml|"
r"/evals/cases/payload-analysis/README\.md|"
r"/comments\.json|"
r"evals/cases/payload-analysis",
re.I,
)
if name in {"Read", "Grep", "Glob", "NotebookRead"}:
targets = [
str(tool_input.get(key, ""))
for key in ("file_path", "path", "glob", "pattern", "notebook_path")
]
for target in targets:
if target and leaking.search(target):
violations.append(
f"{name} accessed evaluation/future-leaking path: {target}"
)
break
if name == "WebFetch":
url = tool_input.get("url", "")
check_external_url(url, "WebFetch")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/evals/eval-payload-analysis.yaml` around lines 403 - 414, Extend
the leakage checks alongside the existing Read handling to also process Grep and
Glob tool calls. Inspect each tool’s path, pattern, and glob inputs for the same
annotations.yaml, payload-analysis README, and comments.json patterns, and
append the existing violation message when matched; preserve the current Read,
WebFetch, and Bash checks.

Comment on lines +418 to 424
def _has_blocking_failures(self, tag_name: str) -> bool:
"""Check whether a payload has any failed blocking jobs."""
runs = self.sippy.fetch_job_runs(tag_name)
blocking = [r for r in runs if r.get("kind") == "Blocking"]
if not blocking:
tag_meta = self.sippy.find_tag(tag_name)
return bool(
tag_meta
and tag_meta.get("phase") == "Accepted"
and not tag_meta.get("forced", False)
and tag_meta.get("failed_job_names") == []
)
return all(
r.get("state") == "Succeeded" for r in blocking
return any(
r.get("kind") == "Blocking" and r.get("state") == "Failed"
for r in runs
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Baseline detection in Sippy mode accepts non-green payloads.

_has_blocking_failures only treats state == "Failed" as a failure. PayloadChain._all_blocking_passed (lines 393-408) instead requires every blocking job to be Succeeded. Two consequences follow in Sippy mode:

  • A payload whose blocking jobs are Error, Aborted, or Pending counts as green, so build stops there.
  • A payload with no returned job runs counts as green, so the chain stops at the first tag without Sippy data.

Both cases produce a false baseline. That value propagates to baseline_tag, chain_length, hours_since_baseline, and the streak and regression trackers. Require explicit success instead.

🐛 Proposed fix
-    def _has_blocking_failures(self, tag_name: str) -> bool:
-        """Check whether a payload has any failed blocking jobs."""
-        runs = self.sippy.fetch_job_runs(tag_name)
-        return any(
-            r.get("kind") == "Blocking" and r.get("state") == "Failed"
-            for r in runs
-        )
+    def _has_blocking_failures(self, tag_name: str) -> bool:
+        """Check whether any blocking job did not succeed.
+
+        Missing job-run data counts as a failure so the chain does not
+        stop on an unverified payload.
+        """
+        runs = self.sippy.fetch_job_runs(tag_name)
+        blocking = [r for r in runs if r.get("kind") == "Blocking"]
+        if not blocking:
+            return True
+        return any(r.get("state") != "Succeeded" for r in blocking)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def _has_blocking_failures(self, tag_name: str) -> bool:
"""Check whether a payload has any failed blocking jobs."""
runs = self.sippy.fetch_job_runs(tag_name)
blocking = [r for r in runs if r.get("kind") == "Blocking"]
if not blocking:
tag_meta = self.sippy.find_tag(tag_name)
return bool(
tag_meta
and tag_meta.get("phase") == "Accepted"
and not tag_meta.get("forced", False)
and tag_meta.get("failed_job_names") == []
)
return all(
r.get("state") == "Succeeded" for r in blocking
return any(
r.get("kind") == "Blocking" and r.get("state") == "Failed"
for r in runs
)
def _has_blocking_failures(self, tag_name: str) -> bool:
"""Check whether any blocking job did not succeed.
Missing job-run data counts as a failure so the chain does not
stop on an unverified payload.
"""
runs = self.sippy.fetch_job_runs(tag_name)
blocking = [r for r in runs if r.get("kind") == "Blocking"]
if not blocking:
return True
return any(r.get("state") != "Succeeded" for r in blocking)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/skills/payload-snapshot/scripts/payload_snapshot.py` around lines
418 - 424, Update _has_blocking_failures to require explicit success for every
blocking job, matching PayloadChain._all_blocking_passed: treat Error, Aborted,
Pending, and any other non-Succeeded state as failure, and treat an empty runs
result as not green. Preserve the existing Sippy fetch flow while ensuring
baseline detection only succeeds when blocking job data is present and all
blocking jobs have state Succeeded.

Comment on lines +93 to +97
2. **Extract build_id**
- Search for pattern `/(\d{10,})/` in the bucket path
- build_id must be at least 10 consecutive decimal digits
- Handle URLs with or without trailing slash
- If not found, error: "Could not find build ID (10+ digits) in URL"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Accept build IDs at the end of the URL.

The pattern /(\d{10,})/ requires a slash after the build ID. The valid example at line 35 ends with the build ID, so this workflow reports an invalid URL.

Use boundaries that accept either a slash or end of input after the ID, for example (?:^|/)(\d{10,})(?:/|$).

🧰 Tools
🪛 SkillSpector (2.4.4)

[warning] 124: [AS3] Skill Enumeration: Skill enumerates or reads other installed skills. Access to other skills' SKILL.md files or the skills directory reveals prompt instructions, capabilities, and secrets that should be invisible to peer skills.

Remediation: Remove all code or instructions that list or read other skills' files or directories. Skills should operate independently; cross-skill access is a privilege escalation.

(Agent Snooping (AS3))

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/skills/prow-job-analyze-install-failure/SKILL.md` around lines 93
- 97, Update the build_id extraction guidance in “Extract build_id” to accept
IDs followed by either a slash or the end of the URL, while still requiring at
least 10 consecutive digits and a valid path boundary before the ID. Preserve
the existing error message when no matching build ID is found.

Comment on lines +218 to +257
def cmd_fetch(prefix, filepath, max_bytes=DEFAULT_MAX_BYTES):
"""Fetch contents of a specific file from GCS."""
target = gcs_path(prefix, filepath)

# Download to a temp file, then read
import tempfile

with tempfile.NamedTemporaryFile(delete=False, suffix=".artifact") as tmp:
tmp_path = tmp.name

try:
_stdout, stderr, rc = run_gcloud(
["storage", "cp", target, tmp_path, "--no-user-output-enabled"],
timeout=60,
)

if rc != 0:
return {
"success": False,
"error": f"gcloud storage cp failed: {stderr.strip()}",
"path": target,
}

file_size = os.path.getsize(tmp_path)
truncated = file_size > max_bytes

with open(tmp_path, "r", errors="replace") as f:
content = f.read(max_bytes)

return {
"success": True,
"path": target,
"size_bytes": file_size,
"truncated": truncated,
"max_bytes": max_bytes,
"content": content,
}
finally:
if os.path.exists(tmp_path):
os.unlink(tmp_path)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Enforce --max-bytes before downloading the object.

gcloud storage cp downloads the complete artifact to disk before line 245 reads only max_bytes. A multi-gigabyte artifact can exhaust disk space or stall the workflow despite the documented 512 KB default. Negative values also bypass the intended read limit.

Reject non-positive limits. Use object metadata plus a range-capable read, or reject objects larger than the requested limit before transfer.

🧰 Tools
🪛 ast-grep (0.45.0)

[warning] 243-243: File path is request-/variable-derived; validate and normalize to prevent path traversal.
Context: open(tmp_path, "r", errors="replace")
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(open-filename-from-request)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/skills/prow-job-artifact-search/prow_job_artifact_search.py`
around lines 218 - 257, Update cmd_fetch to reject non-positive max_bytes values
and enforce the limit before downloading object contents. Use GCS metadata to
determine the object size, reject objects exceeding max_bytes before transfer,
and use a range-capable read limited to max_bytes for accepted objects while
preserving the existing response fields and cleanup behavior.

help="Prow job URL (https://prow.ci.openshift.org/view/gs/...)",
)

subparsers = parser.add_subparsers(dest="command", required=True)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- relevant files ---'
git ls-files 'plugins/ci/skills/prow-job-artifact-search/*'

printf '%s\n' '--- source outline ---'
if command -v ast-grep >/dev/null 2>&1; then
  ast-grep outline plugins/ci/skills/prow-job-artifact-search/prow_job_artifact_search.py
else
  wc -l plugins/ci/skills/prow-job-artifact-search/prow_job_artifact_search.py
fi

printf '%s\n' '--- parser and argument flow ---'
rg -n -C 12 'add_subparsers|parse_args|command|subparser|ArgumentParser' \
  plugins/ci/skills/prow-job-artifact-search/prow_job_artifact_search.py

printf '%s\n' '--- documented requirements ---'
cat -n plugins/ci/skills/prow-job-artifact-search/SKILL.md | sed -n '20,40p'

printf '%s\n' '--- version constraints and references ---'
rg -n -i -C 3 'python(3)?(\.|\s|$)|python_requires|requires-python|3\.6|3\.7|add_subparsers|required=True' \
  plugins/ci/skills/prow-job-artifact-search .github pyproject.toml setup.py setup.cfg tox.ini 2>/dev/null || true

printf '%s\n' '--- tests ---'
rg -n -i -C 3 'prow.?job.?artifact.?search|artifact.?search|parse_args|subparsers' \
  --glob '*test*' --glob '*.py' . 2>/dev/null | head -n 300 || true

Repository: openshift-eng/ai-helpers

Length of output: 41405


🌐 Web query:

site:docs.python.org/3.7/library/argparse.html add_subparsers required changed in version 3.7 site:docs.python.org/3.7/library/subprocess.html capture_output text

💡 Result:

In Python 3.7, two notable changes occurred regarding the argparse and subprocess modules: 1. argparse.add_subparsers: The required keyword argument was added in version 3.7 [1]. This parameter allows you to specify whether a subcommand must be provided, defaulting to False [1]. 2. subprocess: The capture_output and text parameters were added in version 3.7 [2]. - capture_output: When set to True, it instructs the subprocess to capture both stdout and stderr [2]. - text: This serves as a more readable alias for universal_newlines=True; when set to True, the returned streams are opened in text mode rather than binary mode [2].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- source imports and subprocess calls ---'
cat -n plugins/ci/skills/prow-job-artifact-search/prow_job_artifact_search.py | sed -n '1,135p'
rg -n -C 8 'subprocess\.run|capture_output|text=|universal_newlines' \
  plugins/ci/skills/prow-job-artifact-search/prow_job_artifact_search.py

printf '%s\n' '--- repository Python version declarations ---'
for f in .python-version pyproject.toml setup.py setup.cfg tox.ini requirements-dev.txt .github/workflows/test.yml; do
  if [ -f "$f" ]; then
    printf '\n--- %s ---\n' "$f"
    cat -n "$f" | sed -n '1,100p'
  fi
done

Repository: openshift-eng/ai-helpers

Length of output: 7334


🏁 Script executed:

#!/bin/bash
set -eu

python3 - <<'PY'
import ast
from pathlib import Path

path = Path("plugins/ci/skills/prow-job-artifact-search/prow_job_artifact_search.py")
tree = ast.parse(path.read_text())

for node in ast.walk(tree):
    if not isinstance(node, ast.Call):
        continue
    if isinstance(node.func, ast.Attribute) and node.func.attr == "add_subparsers":
        keywords = {kw.arg: ast.unparse(kw.value) for kw in node.keywords}
        print(f"add_subparsers line {node.lineno}: {keywords}")
    if isinstance(node.func, ast.Attribute) and node.func.attr == "run":
        if isinstance(node.func.value, ast.Name) and node.func.value.id == "subprocess":
            keywords = {kw.arg: ast.unparse(kw.value) for kw in node.keywords}
            print(f"subprocess.run line {node.lineno}: {keywords}")

parse_args_lines = [
    node.lineno for node in ast.walk(tree)
    if isinstance(node, ast.Call)
    and isinstance(node.func, ast.Attribute)
    and node.func.attr == "parse_args"
]
print(f"parse_args calls: {parse_args_lines}")
PY

Repository: openshift-eng/ai-helpers

Length of output: 411


Align the documented Python minimum with the script APIs.

SKILL.md declares Python 3.6+, but the script uses Python 3.7-only add_subparsers(required=True), capture_output=True, and text=True. Raise the documented minimum to Python 3.7, or replace all three APIs with Python 3.6-compatible code and add post-parse command validation.

📍 Affects 2 files
  • plugins/ci/skills/prow-job-artifact-search/prow_job_artifact_search.py#L269-L269 (this comment)
  • plugins/ci/skills/prow-job-artifact-search/SKILL.md#L31-L32
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/skills/prow-job-artifact-search/prow_job_artifact_search.py` at
line 269, Align the Python version contract across the script and documentation:
update prow_job_artifact_search.py to avoid the Python 3.7-only
add_subparsers(required=True), capture_output=True, and text=True APIs with
Python 3.6-compatible equivalents, including explicit post-parse command
validation; update SKILL.md lines 31-32 to retain the documented Python 3.6+
minimum. Alternatively, raise the documented minimum in SKILL.md lines 31-32 to
Python 3.7 and keep all three APIs unchanged.

Comment on lines +21 to +30
def extract_tar_archive(tar_path, extract_to):
"""Extract a tar archive (including .tar.gz and .tgz)."""
try:
print(f" Extracting: {tar_path}")
with tarfile.open(tar_path, 'r:*') as tar:
tar.extractall(path=extract_to)
return True
except Exception as e:
print(f" ERROR: Failed to extract {tar_path}: {e}", file=sys.stderr)
return False

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- target file ---'
sed -n '1,220p' plugins/ci/skills/prow-job-extract-must-gather/extract_archives.py

printf '%s\n' '--- Python version declarations and CI references ---'
rg -n -i --glob '!node_modules/**' --glob '!dist/**' \
  'python(3|:)?[[:space:]]*[0-9]+\.[0-9]+|requires-python|python-version|setup-python|PYTHON_VERSION|python_requires' \
  . | head -250

printf '%s\n' '--- extraction call sites and tests ---'
rg -n -C 3 'extract_tar_archive|extractall|must-gather|prow-job-extract-must-gather' \
  plugins/ci .github 2>/dev/null | head -300

Repository: openshift-eng/ai-helpers

Length of output: 43564


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- analogous tar extraction compatibility code ---'
sed -n '980,1060p' plugins/sosreport/skills/ovs-db-analysis/scripts/analyze_ovs_db.py

printf '%s\n' '--- skill documentation around prerequisites and execution ---'
sed -n '1,120p' plugins/ci/skills/prow-job-extract-must-gather/SKILL.md
sed -n '100,155p' plugins/ci/skills/prow-job-extract-must-gather/README.md

printf '%s\n' '--- standalone tarfile behavior probe ---'
python3 - <<'PY'
import io
import os
import sys
import tarfile
import tempfile
from pathlib import Path

print(f"python={sys.version.split()[0]}")
print(f"extractall_accepts_filter={'filter' in __import__('inspect').signature(tarfile.TarFile.extractall).parameters}")

with tempfile.TemporaryDirectory() as tmp:
    root = Path(tmp) / "root"
    root.mkdir()
    outside = Path(tmp) / "outside.txt"

    archive = io.BytesIO()
    with tarfile.open(fileobj=archive, mode="w") as tar:
        payload = b"outside"
        member = tarfile.TarInfo("../../outside.txt")
        member.size = len(payload)
        tar.addfile(member, io.BytesIO(payload))
    archive.seek(0)

    with tarfile.open(fileobj=archive, mode="r:") as tar:
        try:
            tar.extractall(path=root)
            result = "completed"
        except Exception as exc:
            result = f"{type(exc).__name__}: {exc}"

    print(f"unfiltered_result={result}")
    print(f"outside_exists={outside.exists()}")
    if outside.exists():
        print(f"outside_contents={outside.read_text()}")
PY

Repository: openshift-eng/ai-helpers

Length of output: 11153


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- analogous tar extraction compatibility code ---'
sed -n '980,1060p' plugins/sosreport/skills/ovs-db-analysis/scripts/analyze_ovs_db.py

printf '%s\n' '--- skill documentation around prerequisites and execution ---'
sed -n '1,120p' plugins/ci/skills/prow-job-extract-must-gather/SKILL.md
sed -n '100,155p' plugins/ci/skills/prow-job-extract-must-gather/README.md

printf '%s\n' '--- standalone tarfile behavior probe ---'
python3 - <<'PY'
import io
import sys
import tarfile
import tempfile
import inspect
from pathlib import Path

print(f"python={sys.version.split()[0]}")
print(f"extractall_accepts_filter={'filter' in inspect.signature(tarfile.TarFile.extractall).parameters}")

with tempfile.TemporaryDirectory() as tmp:
    root = Path(tmp) / "root"
    root.mkdir()
    outside = Path(tmp) / "outside.txt"

    archive = io.BytesIO()
    with tarfile.open(fileobj=archive, mode="w") as tar:
        payload = b"outside"
        member = tarfile.TarInfo("../../outside.txt")
        member.size = len(payload)
        tar.addfile(member, io.BytesIO(payload))
    archive.seek(0)

    with tarfile.open(fileobj=archive, mode="r:") as tar:
        try:
            tar.extractall(path=root)
            result = "completed"
        except Exception as exc:
            result = f"{type(exc).__name__}: {exc}"

    print(f"unfiltered_result={result}")
    print(f"outside_exists={outside.exists()}")
    if outside.exists():
        print(f"outside_contents={outside.read_text()}")
PY

Repository: openshift-eng/ai-helpers

Length of output: 11153


🏁 Script executed:

#!/bin/bash
set -eu

printf '%s\n' '--- exact runtime tarfile API and implementation ---'
python3 - <<'PY'
import inspect
import sys
import tarfile

print(f"python={sys.version}")
print(f"tarfile={tarfile.__file__}")
print(f"has_data_filter={hasattr(tarfile, 'data_filter')}")
print(f"extractall_signature={inspect.signature(tarfile.TarFile.extractall)}")
print("extractall_source:")
print(inspect.getsource(tarfile.TarFile.extractall))
PY

printf '%s\n' '--- corrected path-traversal probe ---'
python3 - <<'PY'
import io
import sys
import tarfile
import tempfile
from pathlib import Path

print(f"python={sys.version.split()[0]}")

with tempfile.TemporaryDirectory() as tmp:
    base = Path(tmp)
    root = base / "root"
    root.mkdir()
    targets = {
        "parent": base / "outside.txt",
        "absolute": Path("/tmp/coderabbit-tarfile-absolute-probe"),
    }

    archive = io.BytesIO()
    with tarfile.open(fileobj=archive, mode="w") as tar:
        for name in ("../outside.txt", "../../outside.txt", "/tmp/coderabbit-tarfile-absolute-probe"):
            payload = name.encode()
            member = tarfile.TarInfo(name)
            member.size = len(payload)
            tar.addfile(member, io.BytesIO(payload))
    archive.seek(0)

    with tarfile.open(fileobj=archive, mode="r:") as tar:
        try:
            tar.extractall(path=root)
            result = "completed"
        except Exception as exc:
            result = f"{type(exc).__name__}: {exc}"

    print(f"unfiltered_result={result}")
    for label, path in targets.items():
        print(f"{label}_exists={path.exists()}")
        if path.exists():
            path.unlink()
PY

printf '%s\n' '--- runtime image declarations ---'
sed -n '1,55p' images/Dockerfile
sed -n '1,55p' images/Dockerfile.nested-podman

Repository: openshift-eng/ai-helpers

Length of output: 8113


Prevent tar path traversal on all supported Python versions.

tar.extractall(path=extract_to) allows archive members to write outside extract_to. This affects the top-level and nested archives. The repository image installs Python 3.11, and the documentation requires only Python 3. Use filter='data' when supported. Otherwise, validate members and reject traversal, absolute paths, links, and unsafe special files, or require Python 3.12+.

🧰 Tools
🪛 ast-grep (0.45.0)

[error] 25-25: Calling extractall() on a zipfile.ZipFile or tarfile archive without validating member paths lets a crafted entry (e.g. "../../etc/passwd") write outside the destination directory (Zip Slip). Validate each member resolves inside the target directory, or pass a safe filter (tarfile: filter="data" / tarfile.data_filter).
Context: tar.extractall(path=extract_to)
Note: [CWE-22] Improper Limitation of a Pathname to a Restricted Directory ('Path Traversal').

(archive-extractall-path-traversal-python)

🪛 Ruff (0.16.0)

[error] 26-26: Uses of tarfile.extractall()

(S202)


[warning] 28-28: Do not catch blind exception: Exception

(BLE001)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/skills/prow-job-extract-must-gather/extract_archives.py` around
lines 21 - 30, Harden extract_tar_archive so every supported Python version
prevents tar path traversal for both top-level and nested archives. Use
tar.extractall’s filter='data' where available, and provide a Python
3-compatible fallback that rejects absolute paths, traversal outside extract_to,
links, and unsafe special files before extraction; do not leave the existing
unfiltered extractall path.

Comment on lines +93 to +106
# Get directory path (everything except filename)
dir_path = os.path.dirname(rel_path)

# Get top-level directory (first segment after content/)
top_level_dir = ''
if dir_path.startswith('content/'):
path_parts = dir_path.split('/', 2)
if len(path_parts) >= 2:
top_level_dir = path_parts[1]
dir_counts[top_level_dir] = dir_counts.get(top_level_dir, 0) + 1
elif '/' in dir_path:
# If not under content/, use first directory
top_level_dir = dir_path.split('/', 1)[0]
dir_counts[top_level_dir] = dir_counts.get(top_level_dir, 0) + 1

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Files with no subdirectory are silently hidden from the browser.

When dir_path has no further nesting under content/ (or has no / at all), top_level_dir stays '' and is never added to dir_counts. The directory filter buttons and the default active filter set are both derived from dir_counts.keys(), so files with top_level_dir == '' have no corresponding filter button and are excluded from the default (all-filters-active) view. These files become invisible in the browser with no visible indication.

Assign a sentinel value for files with no subdirectory instead of leaving top_level_dir unset, so they are represented by a real filter entry.

🐛 Proposed fix
                 # Get top-level directory (first segment after content/)
                 top_level_dir = ''
                 if dir_path.startswith('content/'):
                     path_parts = dir_path.split('/', 2)
                     if len(path_parts) >= 2:
                         top_level_dir = path_parts[1]
-                        dir_counts[top_level_dir] = dir_counts.get(top_level_dir, 0) + 1
                 elif '/' in dir_path:
                     # If not under content/, use first directory
                     top_level_dir = dir_path.split('/', 1)[0]
-                    dir_counts[top_level_dir] = dir_counts.get(top_level_dir, 0) + 1
+                else:
+                    # No further nesting: bucket under a visible sentinel
+                    top_level_dir = dir_path if dir_path else '(root)'
+                dir_counts[top_level_dir] = dir_counts.get(top_level_dir, 0) + 1
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Get directory path (everything except filename)
dir_path = os.path.dirname(rel_path)
# Get top-level directory (first segment after content/)
top_level_dir = ''
if dir_path.startswith('content/'):
path_parts = dir_path.split('/', 2)
if len(path_parts) >= 2:
top_level_dir = path_parts[1]
dir_counts[top_level_dir] = dir_counts.get(top_level_dir, 0) + 1
elif '/' in dir_path:
# If not under content/, use first directory
top_level_dir = dir_path.split('/', 1)[0]
dir_counts[top_level_dir] = dir_counts.get(top_level_dir, 0) + 1
# Get directory path (everything except filename)
dir_path = os.path.dirname(rel_path)
# Get top-level directory (first segment after content/)
top_level_dir = ''
if dir_path.startswith('content/'):
path_parts = dir_path.split('/', 2)
if len(path_parts) >= 2:
top_level_dir = path_parts[1]
elif '/' in dir_path:
# If not under content/, use first directory
top_level_dir = dir_path.split('/', 1)[0]
else:
# No further nesting: bucket under a visible sentinel
top_level_dir = dir_path if dir_path else '(root)'
dir_counts[top_level_dir] = dir_counts.get(top_level_dir, 0) + 1
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/skills/prow-job-extract-must-gather/generate_html_report.py`
around lines 93 - 106, Update the top_level_dir derivation in the
report-generation logic so files directly under content/ or with no directory
separator receive a non-empty sentinel category instead of ''. Ensure that
sentinel is added to dir_counts and therefore appears in the directory filter
buttons and default active-filter set, while preserving existing
nested-directory categorization.

Comment on lines +705 to +729
for file in files:
icon = get_file_icon(file['type'])
# Use symlink path for iframe if available, otherwise use original
iframe_path = file.get('symlink_path', f"logs/{file['path']}")
original_path = f"logs/{file['path']}"

html += f'''
<div class="file-item" data-type="{file['type']}" data-path="{file['path']}" data-name="{file['name'].lower()}" data-dir="{file['top_level_dir']}" data-size="{file['size']}">
<div class="file-icon">{icon}</div>
<div class="file-info">
<div class="file-name">
<a class="file-link" data-iframe-path="{iframe_path}" data-original-path="{original_path}" data-size="{file['size']}">{file['name']}</a>
<span class="external-link-icon" data-path="{original_path}" title="Open in new tab">
<svg viewBox="0 0 16 16">
<path d="M3.75 2A1.75 1.75 0 002 3.75v8.5c0 .966.784 1.75 1.75 1.75h8.5A1.75 1.75 0 0014 12.25v-3.5a.75.75 0 00-1.5 0v3.5a.25.25 0 01-.25.25h-8.5a.25.25 0 01-.25-.25v-8.5a.25.25 0 01.25-.25h3.5a.75.75 0 000-1.5h-3.5zM9.5 2.75a.75.75 0 01.75-.75h3.5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0V4.56L8.78 8.78a.75.75 0 01-1.06-1.06l4.22-4.22h-1.69a.75.75 0 01-.75-.75z"/>
</svg>
</span>
</div>
<div class="file-meta">
<span class="file-path">{file['dir']}</span>
<span class="file-size-badge">{file['size_human']}</span>
<span class="badge badge-{file['type']}">{file['type']}</span>
</div>
</div>
</div>'''

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Escape file metadata and CLI-derived strings before embedding in HTML.

file['name'], file['path'], file['dir'], and file['top_level_dir'] are interpolated directly into HTML attributes and text (lines 712-726) without escaping. The same problem exists for the header metadata prowjob_name, build_id, target, gcsweb_url (lines 629-636, with gcsweb_url unescaped inside an href attribute) and for the directory filter button's directory/display_name (lines 680-685).

These values originate from must-gather content or from a user-supplied Prow job URL. A name or URL containing ", <, or > can break out of an HTML attribute or inject markup/script into the generated report. create_txt_symlinks already uses html_module.escape() elsewhere in this file; apply the same escaping here.

🛡️ Proposed fix (file-item block; apply the same pattern to the header and directory filter)
     for file in files:
         icon = get_file_icon(file['type'])
         # Use symlink path for iframe if available, otherwise use original
-        iframe_path = file.get('symlink_path', f"logs/{file['path']}")
+        iframe_path = file.get('symlink_path') or f"logs/{file['path']}"
         original_path = f"logs/{file['path']}"
+        safe_name = html_module.escape(file['name'])
+        safe_path = html_module.escape(file['path'])
+        safe_dir = html_module.escape(file['dir'])
+        safe_top_level_dir = html_module.escape(file['top_level_dir'])

         html += f'''
-        <div class="file-item" data-type="{file['type']}" data-path="{file['path']}" data-name="{file['name'].lower()}" data-dir="{file['top_level_dir']}" data-size="{file['size']}">
+        <div class="file-item" data-type="{file['type']}" data-path="{safe_path}" data-name="{html_module.escape(file['name'].lower())}" data-dir="{safe_top_level_dir}" data-size="{file['size']}">
             <div class="file-icon">{icon}</div>
             <div class="file-info">
                 <div class="file-name">
-                    <a class="file-link" data-iframe-path="{iframe_path}" data-original-path="{original_path}" data-size="{file['size']}">{file['name']}</a>
+                    <a class="file-link" data-iframe-path="{html_module.escape(iframe_path)}" data-original-path="{html_module.escape(original_path)}" data-size="{file['size']}">{safe_name}</a>
                     <span class="external-link-icon" data-path="{original_path}" title="Open in new tab">
                 <div class="file-meta">
-                    <span class="file-path">{file['dir']}</span>
+                    <span class="file-path">{safe_dir}</span>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@plugins/ci/skills/prow-job-extract-must-gather/generate_html_report.py`
around lines 705 - 729, Escape all must-gather and CLI-derived values before
interpolating them into generated HTML. In the file-item loop, apply
html_module.escape to file['name'], file['path'], file['dir'],
file['top_level_dir'], symlink_path, and related metadata used in attributes or
text; apply the same escaping in the header metadata block for prowjob_name,
build_id, target, and gcsweb_url (including its href), and in the directory
filter for directory and display_name. Reuse the existing html_module.escape
pattern without changing the report’s displayed values or behavior.

@stbenjam stbenjam changed the title A/B/C test C (historical control): pre-#604 CI skills with current payload evals TRT-2870: A/B/C test C (historical control): pre-#604 CI skills with current payload evals Jul 31, 2026
@openshift-ci-robot openshift-ci-robot added the jira/valid-reference Indicates that this PR references a valid Jira ticket of any type. label Jul 31, 2026
@openshift-ci-robot

openshift-ci-robot commented Jul 31, 2026

Copy link
Copy Markdown

@stbenjam: This pull request references TRT-2870 which is a valid jira issue.

Warning: The referenced jira issue has an invalid target version for the target branch this PR targets: expected the story to target the "5.0.0" version, but no target version was set.

Details

In response to this:

These bugs and fixes were automatically generated by a payload-agent experiment to improve resilience and diagnostics for infrastructure failures. Please review the PR and either shepherd it to merge or close it. If the work is incorrect or unhelpful, a brief comment would help us improve. Thanks, and apologies if we missed the mark.

Experiment

This is group C, the historical-skill control for the payload-analysis evaluation. It is evaluation-only and is not intended to merge.

This makes the three groups:

Reference change: PR #604.

Failed payload analyses represented by the new cases

Validation

  • plugins/ci/evals/** is unchanged from group A commit 5ec4bab.
  • CI skills match pre-TRT-2613: feat(ci): prow-job-analysis skill + artifact-verified evals (supersedes #597) #604 commit e9065e6 aside from two whitespace-only normalizations.
  • python3 -m pytest -q plugins/ci/evals/test_eval_payload_analysis.py — passed.
  • Focused skillsaw lint — A+, 0 errors, 0 warnings.
  • Full repository lint — passed, 0 errors, 0 warnings.
  • CI plugin version bumped from 0.0.80 to 0.0.81.

Summary by CodeRabbit

  • New Features

  • Added tools for analyzing installation, bare-metal, and test failures.

  • Added Prow artifact search and must-gather extraction with interactive HTML reports.

  • Expanded payload-analysis evaluation coverage with point-in-time validation.

  • Improvements

  • Simplified payload snapshots and analysis workflows with clearer outputs and streamlined options.

  • Updated guidance to reference the new investigation tools.

  • Removed

  • Retired several legacy CI diagnosis, triage, symptom, and label-management tools.

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the openshift-eng/jira-lifecycle-plugin repository.

@openshift-ci openshift-ci Bot added the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Jul 31, 2026
@openshift-ci

openshift-ci Bot commented Jul 31, 2026

Copy link
Copy Markdown

PR needs rebase.

Details

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes-sigs/prow repository.

@stbenjam

Copy link
Copy Markdown
Member Author

Closing this experiment PR. The hard-case A/B/C evals are very expensive to run (~50% increase in payload agent eval cost) and Opus doesn't succeed on them regardless, so the signal-to-cost ratio isn't there right now.

Will revisit when better models are available that can handle these cases.

@stbenjam stbenjam closed this Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

approved Indicates a PR has been approved by an approver from all required OWNERS files. jira/valid-reference Indicates that this PR references a valid Jira ticket of any type. needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants