diff --git a/.gitignore b/.gitignore
index c4f26f57e..523f5479b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,6 +43,9 @@ venv/
# Working directories for skills
.work/
+# Eval run outputs
+eval/runs/
+
# Various logs and metrics tracking info
*.log
.anonymous_id
diff --git a/docs/index.html b/docs/index.html
index beb680deb..6760ec6b2 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -711,6 +711,18 @@
ai-helpers
"description_html": "Analyze and compare disruption across one or more Prow CI job runs by examining interval data, audit logs, pod logs, and CPU metrics",
"meta": ""
},
+ {
+ "name": "assess-pr-risk",
+ "description": "Assess a PR's risk level and recommend which e2e and payload CI jobs to run. Takes a GitHub PR URL, scores risk across repo profile, complexity, code factors, and historical signals, then outputs a structured report with specific job recommendations.",
+ "description_html": "Assess a PR's risk level and recommend which e2e and payload CI jobs to run. Takes a GitHub PR URL, scores risk across repo profile, complexity, code factors, and historical signals, then outputs a structured report with specific job recommendations.",
+ "meta": ""
+ },
+ {
+ "name": "assess-pr-test-results",
+ "description": "Fetch test results for a PR and classify failures as PR-related, pre-existing/flaky, or infrastructure by cross-referencing against mainline CI health",
+ "description_html": "Fetch test results for a PR and classify failures as PR-related, pre-existing/flaky, or infrastructure by cross-referencing against mainline CI health",
+ "meta": ""
+ },
{
"name": "detect-permafail",
"description": "Analyze consecutive job failures to determine if they represent a permafail pattern versus flaky failures",
@@ -729,6 +741,12 @@ ai-helpers
"description_html": "Fetch a Prow job run summary from Sippy showing all failed tests grouped by SIG with error messages",
"meta": ""
},
+ {
+ "name": "fetch-jobs",
+ "description": "Fetch OpenShift CI job reports from Sippy with pass rates, run counts, and trend data for release or presubmit jobs",
+ "description_html": "Fetch OpenShift CI job reports from Sippy with pass rates, run counts, and trend data for release or presubmit jobs",
+ "meta": ""
+ },
{
"name": "fetch-new-prs-in-payload",
"description": "Fetch pull requests that are new in a given OpenShift payload compared to the previous payload",
@@ -825,6 +843,12 @@ ai-helpers
"description_html": "Analyze Kubernetes resource lifecycle in Prow CI job artifacts by parsing audit logs and pod logs from GCS, generating interactive HTML reports with timelines",
"meta": ""
},
+ {
+ "name": "prow-job-cost-estimator",
+ "description": "Estimate cloud compute cost for OpenShift CI prow jobs based on duration and platform",
+ "description_html": "Estimate cloud compute cost for OpenShift CI prow jobs based on duration and platform",
+ "meta": ""
+ },
{
"name": "revert-pr",
"description": "Git revert workflow and Revertomatic PR template for reverting merged PRs",
@@ -857,6 +881,11 @@ ai-helpers
}
],
"agents": [
+ {
+ "name": "pr-risk-analyst",
+ "description": "Assess PR risk level and recommend testing strategy for OpenShift CI pull requests.",
+ "description_html": "Assess PR risk level and recommend testing strategy for OpenShift CI pull requests."
+ },
{
"name": "step-registry-analyzer",
"description": "Use this agent when you need to understand and list OpenShift CI components including workflows, chains, and refs in a hierarchical structure.",
@@ -3042,7 +3071,228 @@ ai-helpers
}
}
],
- "standalone_skills": []
+ "standalone_skills": [
+ {
+ "name": "fetch-job-run-summary",
+ "description": "Fetch a Prow job run summary from Sippy showing all failed tests grouped by SIG with error messages",
+ "description_html": "Fetch a Prow job run summary from Sippy showing all failed tests grouped by SIG with error messages"
+ },
+ {
+ "name": "prow-job-analyze-install-failure",
+ "description": "Analyze OpenShift installation failures in Prow CI jobs by examining installer logs, log bundles, and sosreports. Use when CI job fails \"install should succeed\" tests at bootstrap, cluster creation or other stages.",
+ "description_html": "Analyze OpenShift installation failures in Prow CI jobs by examining installer logs, log bundles, and sosreports. Use when CI job fails "install should succeed" tests at bootstrap, cluster creation or other stages."
+ },
+ {
+ "name": "add-jira-triage-link",
+ "description": "Add a Component Readiness triage record link to a JIRA issue description",
+ "description_html": "Add a Component Readiness triage record link to a JIRA issue description"
+ },
+ {
+ "name": "prow-job-extract-must-gather",
+ "description": "Extract and decompress must-gather archives from Prow CI job artifacts, generating an interactive HTML file browser with filters",
+ "description_html": "Extract and decompress must-gather archives from Prow CI job artifacts, generating an interactive HTML file browser with filters"
+ },
+ {
+ "name": "prow-job-analyze-test-failure",
+ "description": "Analyze failed Prow CI tests by inspecting test code, downloading artifacts, and optionally integrating must-gather cluster diagnostics for root cause analysis",
+ "description_html": "Analyze failed Prow CI tests by inspecting test code, downloading artifacts, and optionally integrating must-gather cluster diagnostics for root cause analysis"
+ },
+ {
+ "name": "fetch-releases",
+ "description": "Fetch available OpenShift releases from the Sippy API",
+ "description_html": "Fetch available OpenShift releases from the Sippy API"
+ },
+ {
+ "name": "payload-analysis",
+ "description": "Analyze a payload snapshot to identify root causes of blocking job failures, score candidate PRs, and produce an HTML report with revert recommendations",
+ "description_html": "Analyze a payload snapshot to identify root causes of blocking job failures, score candidate PRs, and produce an HTML report with revert recommendations"
+ },
+ {
+ "name": "prow-job-artifact-search",
+ "description": "Search, list, and fetch artifacts from Prow CI job runs stored in GCS using the gcloud CLI",
+ "description_html": "Search, list, and fetch artifacts from Prow CI job runs stored in GCS using the gcloud CLI"
+ },
+ {
+ "name": "revert-pr",
+ "description": "Git revert workflow and Revertomatic PR template for reverting merged PRs",
+ "description_html": "Git revert workflow and Revertomatic PR template for reverting merged PRs"
+ },
+ {
+ "name": "oc-auth",
+ "description": "Helper skill to retrieve OAuth tokens from the correct OpenShift cluster context when multiple clusters are configured",
+ "description_html": "Helper skill to retrieve OAuth tokens from the correct OpenShift cluster context when multiple clusters are configured"
+ },
+ {
+ "name": "fetch-payloads",
+ "description": "Fetch recent release payloads from the OpenShift release controller",
+ "description_html": "Fetch recent release payloads from the OpenShift release controller"
+ },
+ {
+ "name": "fetch-new-prs-in-payload",
+ "description": "Fetch pull requests that are new in a given OpenShift payload compared to the previous payload",
+ "description_html": "Fetch pull requests that are new in a given OpenShift payload compared to the previous payload"
+ },
+ {
+ "name": "payload-snapshot",
+ "description": "Snapshot OpenShift payload data (release controller, PR diffs, comments, CI jobs, JUnit results, regression tracking) to a local directory for offline analysis",
+ "description_html": "Snapshot OpenShift payload data (release controller, PR diffs, comments, CI jobs, JUnit results, regression tracking) to a local directory for offline analysis"
+ },
+ {
+ "name": "fetch-jira-issue",
+ "description": "Fetch JIRA issue details including status, assignee, comments, and progress classification",
+ "description_html": "Fetch JIRA issue details including status, assignee, comments, and progress classification"
+ },
+ {
+ "name": "fetch-regression-details",
+ "description": "Fetch detailed information about a Component Readiness regression from the Sippy API",
+ "description_html": "Fetch detailed information about a Component Readiness regression from the Sippy API"
+ },
+ {
+ "name": "triage-regression",
+ "description": "Create or update a Component Readiness triage record linking regressions to a JIRA bug",
+ "description_html": "Create or update a Component Readiness triage record linking regressions to a JIRA bug"
+ },
+ {
+ "name": "prow-job-analyze-resource",
+ "description": "Analyze Kubernetes resource lifecycle in Prow CI job artifacts by parsing audit logs and pod logs from GCS, generating interactive HTML reports with timelines",
+ "description_html": "Analyze Kubernetes resource lifecycle in Prow CI job artifacts by parsing audit logs and pod logs from GCS, generating interactive HTML reports with timelines"
+ },
+ {
+ "name": "trigger-payload-job",
+ "description": "MUST be used whenever triggering payload testing on a PR. Do not post payload commands without following this skill — the command syntax is specific and other formats will be silently ignored by the bot.",
+ "description_html": "MUST be used whenever triggering payload testing on a PR. Do not post payload commands without following this skill — the command syntax is specific and other formats will be silently ignored by the bot."
+ },
+ {
+ "name": "prow-job-analyze-metal-install-failure",
+ "description": "Analyze OpenShift bare metal installation failures in Prow CI jobs using dev-scripts artifacts. Use for jobs with \"metal\" in name, for debugging Metal3/Ironic provisioning, installation, or dev-scripts setup failures. You may also use the prow-job-analyze-install-failure skill with this one.",
+ "description_html": "Analyze OpenShift bare metal installation failures in Prow CI jobs using dev-scripts artifacts. Use for jobs with "metal" in name, for debugging Metal3/Ironic provisioning, installation, or dev-scripts setup failures. You may also use the prow-job-analyze-install-failure skill with this one."
+ },
+ {
+ "name": "payload-results-yaml",
+ "description": "State management for agentic payload triage actions — you must use this skill whenever reading or writing the payload results YAML file",
+ "description_html": "State management for agentic payload triage actions — you must use this skill whenever reading or writing the payload results YAML file"
+ },
+ {
+ "name": "fetch-prowjob-json",
+ "description": "Fetch and return key data from a Prow job's prowjob.json artifact given a Prow job URL",
+ "description_html": "Fetch and return key data from a Prow job's prowjob.json artifact given a Prow job URL"
+ },
+ {
+ "name": "analyze-disruption",
+ "description": "Analyze and compare disruption across one or more Prow CI job runs by examining interval data, audit logs, pod logs, and CPU metrics",
+ "description_html": "Analyze and compare disruption across one or more Prow CI job runs by examining interval data, audit logs, pod logs, and CPU metrics"
+ },
+ {
+ "name": "fetch-test-report",
+ "description": "Fetch an OpenShift CI test report by name to get pass rates, test ID, and Jira component from Sippy",
+ "description_html": "Fetch an OpenShift CI test report by name to get pass rates, test ID, and Jira component from Sippy"
+ },
+ {
+ "name": "set-release-blocker",
+ "description": "Set the Release Blocker field on a JIRA issue",
+ "description_html": "Set the Release Blocker field on a JIRA issue"
+ },
+ {
+ "name": "payload-experimental-reverts",
+ "description": "Experimentally test medium-confidence payload candidates by opening draft revert PRs and triggering payload jobs",
+ "description_html": "Experimentally test medium-confidence payload candidates by opening draft revert PRs and triggering payload jobs"
+ },
+ {
+ "name": "fetch-test-runs",
+ "description": "Fetch test runs from Sippy API including outputs for AI-based similarity analysis",
+ "description_html": "Fetch test runs from Sippy API including outputs for AI-based similarity analysis"
+ },
+ {
+ "name": "fetch-related-triages",
+ "description": "Fetch existing triages and untriaged regressions related to a given regression",
+ "description_html": "Fetch existing triages and untriaged regressions related to a given regression"
+ },
+ {
+ "name": "payload-autodl-json",
+ "description": "Schema for the autodl JSON data file produced by payload-analysis for database ingestion — you must use this skill whenever generating the autodl JSON file",
+ "description_html": "Schema for the autodl JSON data file produced by payload-analysis for database ingestion — you must use this skill whenever generating the autodl JSON file"
+ },
+ {
+ "name": "stage-payload-reverts",
+ "description": "Create TRT JIRA bugs, open revert PRs, and trigger payload jobs for high-confidence revert candidates",
+ "description_html": "Create TRT JIRA bugs, open revert PRs, and trigger payload jobs for high-confidence revert candidates"
+ },
+ {
+ "name": "ci-data-analyst",
+ "description": "Safely query and report on OpenShift CI prow job and test data in BigQuery with cost controls, dry-run validation, and local caching of results",
+ "description_html": "Safely query and report on OpenShift CI prow job and test data in BigQuery with cost controls, dry-run validation, and local caching of results"
+ },
+ {
+ "name": "analyze-usage",
+ "description": "Comprehensive analysis of BigQuery usage patterns, costs, and query performance",
+ "description_html": "Comprehensive analysis of BigQuery usage patterns, costs, and query performance"
+ },
+ {
+ "name": "coderabbit-inheritance-scanner-open-pr",
+ "description": "Fork, sync, and open a fix PR to add inheritance: true to a repo's .coderabbit.yaml",
+ "description_html": "Fork, sync, and open a fix PR to add inheritance: true to a repo's .coderabbit.yaml"
+ },
+ {
+ "name": "list-jiras",
+ "description": "Query and return raw JIRA bug data for a specific project",
+ "description_html": "Query and return raw JIRA bug data for a specific project"
+ },
+ {
+ "name": "coderabbit-rules-from-pr-reviews",
+ "description": "Fetch and filter human review comments from recent merged PRs in a GitHub repository",
+ "description_html": "Fetch and filter human review comments from recent merged PRs in a GitHub repository"
+ },
+ {
+ "name": "list-regressions",
+ "description": "Fetch and analyze component health regressions for OpenShift releases",
+ "description_html": "Fetch and analyze component health regressions for OpenShift releases"
+ },
+ {
+ "name": "list-teams",
+ "description": "List all teams from the team component mapping",
+ "description_html": "List all teams from the team component mapping"
+ },
+ {
+ "name": "coderabbit-adoption",
+ "description": "Report on CodeRabbit adoption across OpenShift org PRs",
+ "description_html": "Report on CodeRabbit adoption across OpenShift org PRs"
+ },
+ {
+ "name": "coderabbit-inheritance-scanner-search",
+ "description": "Search for repos with .coderabbit.yaml files in the openshift GitHub org",
+ "description_html": "Search for repos with .coderabbit.yaml files in the openshift GitHub org"
+ },
+ {
+ "name": "coderabbit-inheritance-scanner-check",
+ "description": "Use when checking a repository's .coderabbit.yaml (or .coderabbit.yml) to determine whether inheritance: true is set",
+ "description_html": "Use when checking a repository's .coderabbit.yaml (or .coderabbit.yml) to determine whether inheritance: true is set"
+ },
+ {
+ "name": "list-components",
+ "description": "List all OCPBUGS components, optionally filtered by team",
+ "description_html": "List all OCPBUGS components, optionally filtered by team"
+ },
+ {
+ "name": "analyze-regressions",
+ "description": "Grade component health based on regression triage metrics for OpenShift releases",
+ "description_html": "Grade component health based on regression triage metrics for OpenShift releases"
+ },
+ {
+ "name": "get-release-dates",
+ "description": "Fetch OpenShift release dates and metadata from Sippy API",
+ "description_html": "Fetch OpenShift release dates and metadata from Sippy API"
+ },
+ {
+ "name": "coderabbit-inheritance-scanner-existing-pr",
+ "description": "Search for an existing fix PR on a repo before opening a new one",
+ "description_html": "Search for an existing fix PR on a repo before opening a new one"
+ },
+ {
+ "name": "summarize-jiras",
+ "description": "Query and summarize JIRA bugs for a specific project with counts by component",
+ "description_html": "Query and summarize JIRA bugs for a specific project with counts by component"
+ }
+ ]
};
var IS_MARKETPLACE = true;
diff --git a/plugins/ci/.claude-plugin/plugin.json b/plugins/ci/.claude-plugin/plugin.json
index f6f69c424..3b6cf30e8 100644
--- a/plugins/ci/.claude-plugin/plugin.json
+++ b/plugins/ci/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "ci",
"description": "Tools for working with OpenShift CI and analyzing Prow job results",
- "version": "0.0.71",
+ "version": "0.0.72",
"author": {
"name": "github.com/openshift-eng"
}
diff --git a/plugins/ci/agents/pr-risk-analyst.md b/plugins/ci/agents/pr-risk-analyst.md
new file mode 100644
index 000000000..e38a9fdcd
--- /dev/null
+++ b/plugins/ci/agents/pr-risk-analyst.md
@@ -0,0 +1,23 @@
+---
+name: pr-risk-analyst
+description: Assess PR risk level and recommend testing strategy for OpenShift CI pull requests.
+model: sonnet
+color: warning
+---
+
+You are a PR risk analyst for OpenShift CI. Your job is to evaluate pull requests, score their risk, and recommend appropriate CI jobs to test.
+
+When the user provides a PR URL, use the `ci:assess-pr-risk` skill to perform the analysis.
+
+## Skills
+
+| Skill | Purpose |
+|-------|---------|
+| `ci:assess-pr-risk` | Score a PR's risk and recommend which e2e and payload jobs to run |
+
+A test result review skill is planned for a future iteration.
+
+## General Constraints
+
+- **Never approve or merge a PR.** You only assess risk and recommend. The human decides.
+- **You do not know what happened after a PR was merged.** Analyze presubmit results, payload job results, reviewer comments, and everything that occurred while the PR was open. But you have no knowledge of whether the PR was later reverted or caused any post-merge issues. Do not search for reverts of the specific PR under analysis, do not mention revert PRs or Jira tickets related to its post-merge outcome, and do not frame your analysis as a "retrospective" or "case study." Your report must read as a forward-looking risk assessment written at merge time. If you discover post-merge revert information incidentally, ignore it completely.
diff --git a/plugins/ci/evals/cases/assess-pr-risk/case-001-olm-hashstructure-upgrade/annotations.yaml b/plugins/ci/evals/cases/assess-pr-risk/case-001-olm-hashstructure-upgrade/annotations.yaml
new file mode 100644
index 000000000..212c63508
--- /dev/null
+++ b/plugins/ci/evals/cases/assess-pr-risk/case-001-olm-hashstructure-upgrade/annotations.yaml
@@ -0,0 +1,28 @@
+description: |
+ OLM hashstructure v1→v2 library upgrade plus Kubernetes vendor bump and TLS changes.
+ The hashstructure upgrade fundamentally changed hashing behavior in operator reconciliation.
+ This is a known-bad PR that was later reverted — the skill must NOT know that.
+
+expected_tier: critical
+expected_score_range:
+ min: 71
+ max: 100
+
+must_recommend_jobs:
+ - e2e-gcp-ovn
+ - e2e-gcp-olm
+ - e2e-gcp-console-olm
+ - e2e-upgrade
+ - e2e-aws-upgrade-ovn-single-node
+ - e2e-aws-olmv0-ext
+ - e2e-aws-ovn-hypershift
+
+must_not_recommend_jobs:
+ - microshift
+ - vsphere
+
+must_skip_jobs: []
+
+expected_hotspots:
+ - hypershift
+ - upgrade
diff --git a/plugins/ci/evals/cases/assess-pr-risk/case-001-olm-hashstructure-upgrade/input.yaml b/plugins/ci/evals/cases/assess-pr-risk/case-001-olm-hashstructure-upgrade/input.yaml
new file mode 100644
index 000000000..8726ea0a4
--- /dev/null
+++ b/plugins/ci/evals/cases/assess-pr-risk/case-001-olm-hashstructure-upgrade/input.yaml
@@ -0,0 +1 @@
+pr_url: https://github.com/openshift/operator-framework-olm/pull/1330
diff --git a/plugins/ci/evals/cases/assess-pr-risk/case-002-origin-bug-id-swap/annotations.yaml b/plugins/ci/evals/cases/assess-pr-risk/case-002-origin-bug-id-swap/annotations.yaml
new file mode 100644
index 000000000..92d396ffe
--- /dev/null
+++ b/plugins/ci/evals/cases/assess-pr-risk/case-002-origin-bug-id-swap/annotations.yaml
@@ -0,0 +1,18 @@
+description: |
+ Trivial 2-line change replacing one bug ID with another in a known-flake exception
+ and a test comment. Zero functional impact. No e2e jobs should be recommended —
+ this is the baseline case for cost savings on safe PRs.
+
+expected_tier: low
+expected_score_range:
+ min: 0
+ max: 20
+
+must_recommend_jobs: []
+
+must_not_recommend_jobs:
+ - e2e
+
+must_skip_jobs: []
+
+expected_hotspots: []
diff --git a/plugins/ci/evals/cases/assess-pr-risk/case-002-origin-bug-id-swap/input.yaml b/plugins/ci/evals/cases/assess-pr-risk/case-002-origin-bug-id-swap/input.yaml
new file mode 100644
index 000000000..0b11ec304
--- /dev/null
+++ b/plugins/ci/evals/cases/assess-pr-risk/case-002-origin-bug-id-swap/input.yaml
@@ -0,0 +1 @@
+pr_url: https://github.com/openshift/origin/pull/31350
diff --git a/plugins/ci/evals/cases/assess-pr-risk/case-003-origin-ote-bump/annotations.yaml b/plugins/ci/evals/cases/assess-pr-risk/case-003-origin-ote-bump/annotations.yaml
new file mode 100644
index 000000000..a5b418d0f
--- /dev/null
+++ b/plugins/ci/evals/cases/assess-pr-risk/case-003-origin-ote-bump/annotations.yaml
@@ -0,0 +1,26 @@
+description: |
+ Vendor bump of openshift-tests-extension to fix klog stdout contamination
+ breaking OTE binary JSON parsing. Affects metal nightly jobs specifically.
+ 475 additions but mostly vendored code. Core change is in
+ pkg/test/extensions/binary.go (JSON extraction logic) with a new test file.
+ Medium risk — touches test infrastructure plumbing, not product code.
+
+expected_tier: medium
+expected_score_range:
+ min: 21
+ max: 45
+
+must_recommend_jobs:
+ - e2e
+
+must_not_recommend_jobs:
+ - upgrade
+ - vsphere
+ - hypershift
+ - csi
+ - kubevirt
+ - single-node
+
+must_skip_jobs: []
+
+expected_hotspots: []
diff --git a/plugins/ci/evals/cases/assess-pr-risk/case-003-origin-ote-bump/input.yaml b/plugins/ci/evals/cases/assess-pr-risk/case-003-origin-ote-bump/input.yaml
new file mode 100644
index 000000000..10c594c2c
--- /dev/null
+++ b/plugins/ci/evals/cases/assess-pr-risk/case-003-origin-ote-bump/input.yaml
@@ -0,0 +1 @@
+pr_url: https://github.com/openshift/origin/pull/31349
diff --git a/plugins/ci/evals/cases/assess-pr-risk/eval.md b/plugins/ci/evals/cases/assess-pr-risk/eval.md
new file mode 100644
index 000000000..0d8015441
--- /dev/null
+++ b/plugins/ci/evals/cases/assess-pr-risk/eval.md
@@ -0,0 +1,65 @@
+---
+# Auto-generated by /eval-analyze — edit to override
+skill: ci:assess-pr-risk
+analyzed_at: 2026-07-20T12:00:00Z
+skill_hash: 172a0fdc73fb
+
+execution_mode: case
+headless: true
+dry_run: false
+
+suggested_judges:
+ - name: state_file_valid
+ type: check
+ description: "Validates JSON state file exists with required schema"
+ - name: risk_tier_correct
+ type: check
+ description: "Checks assessed tier matches expected tier from annotations"
+ - name: risk_score_in_range
+ type: check
+ description: "Checks risk score falls within expected annotation range"
+ - name: job_recommendations_correct
+ type: check
+ description: "Validates must_recommend and must_not_recommend job constraints"
+ - name: report_quality
+ type: llm
+ description: "LLM assessment of report completeness, justification quality, and calibration"
+ - name: budget_check
+ type: builtin
+ description: "Ensures execution cost stays under budget"
+---
+
+## Skill Analysis
+
+`ci:assess-pr-risk` takes a GitHub PR URL and produces a structured risk assessment. It gathers PR metadata, diff, comments, and repo history via `gh` CLI, then scores risk across four categories (repo profile 0-25, PR complexity 0-25, code risk factors 0-30, historical risk 0-20) for a total 0-100 score. Based on the score, it classifies risk tier (low/medium/high/critical), recommends which e2e CI jobs to run or skip, estimates testing costs, and checks for hotspot patterns (HyperShift, SNO, upgrades, IPv6).
+
+## Inputs
+
+Each test case has:
+- `input.yaml`: Single field `pr_url` — a real GitHub PR URL (e.g., `https://github.com/openshift/origin/pull/31350`). The PR must exist and be accessible via `gh`.
+- `annotations.yaml`: Expected outcomes for scoring — `expected_tier`, `expected_score_range` (min/max), `must_recommend_jobs`, `must_not_recommend_jobs`, `must_skip_jobs`, `expected_hotspots`.
+
+## Outputs
+
+The skill writes a single JSON state file to `.work/pr-risk/--.json` containing the full assessment: PR metadata, risk score and breakdown, jobs to run/skip, coverage gaps, key risks, and cost estimates. It also produces a markdown report in conversation output.
+
+## Pipeline Flow
+
+1. **Gather PR Data** — `gh pr view`, `gh pr diff`, PR comments (including CodeRabbit), recent reverts
+2. **Calculate Risk Score** — four categories scored and summed
+3. **Determine Risk Tier** — score mapped to low/medium/high/critical
+4. **Recommend Testing** — fetch repo CI config from openshift/release, query Sippy for job durations, classify e2e jobs, check hotspots, format recommendations
+5. **Estimate Costs** — write job decisions to JSON, run `estimate_cost.py`
+6. **Write State File** — JSON to `.work/pr-risk/`
+7. **Produce Report** — structured markdown output
+
+Sub-skills: `ci:prow-job-cost-estimator` for cost estimation. Helper script: `fetch_jobs.py` for Sippy queries.
+
+## Quality Criteria
+
+- Risk tier and score align with PR characteristics (trivial changes score low, API/operator changes score high)
+- Job recommendations are calibrated: low-risk PRs skip e2e, high-risk PRs include upgrade/hypershift/SNO jobs
+- Forbidden job types (e.g., vSphere for non-vsphere PRs) are not recommended
+- Report includes specific code references justifying scores, not generic statements
+- Cost estimate is present and uses the cost estimator script output
+- State JSON file is well-formed with all required fields
diff --git a/plugins/ci/evals/cases/assess-pr-risk/eval.yaml b/plugins/ci/evals/cases/assess-pr-risk/eval.yaml
new file mode 100644
index 000000000..1133f3b7f
--- /dev/null
+++ b/plugins/ci/evals/cases/assess-pr-risk/eval.yaml
@@ -0,0 +1,232 @@
+name: assess-pr-risk
+description: Evaluate PR risk assessment accuracy — risk scoring, tier classification, and job recommendations
+skill: ci:assess-pr-risk
+
+execution:
+ mode: case
+ arguments: "{pr_url}"
+ timeout: 600
+
+runner:
+ type: claude-code
+ plugin_dirs:
+ - plugins/ci
+
+models:
+ skill: claude-opus-4-6
+ judge: claude-opus-4-6
+
+permissions:
+ allow:
+ - "Bash"
+ - "Read"
+ - "Write"
+ - "Edit"
+ - "Agent"
+ - "Skill"
+ - "WebFetch"
+ - "WebSearch"
+ deny: []
+
+mlflow:
+ experiment: assess-pr-risk-eval
+
+dataset:
+ path: .
+ schema: |
+ Each case directory contains:
+ - input.yaml: YAML with 'pr_url' ([EXTERNAL: GitHub] — must be a real,
+ merged PR URL, e.g. https://github.com/openshift/origin/pull/31350).
+ - annotations.yaml: Expected outcomes for scoring, with fields:
+ 'description' (what the PR does and why it's a good test case),
+ 'expected_tier' (low|medium|high|critical),
+ 'expected_score_range' (min/max integers 0-100),
+ 'must_recommend_jobs' (list of job name substrings that must appear in recommendations),
+ 'must_not_recommend_jobs' (list of job name substrings that must NOT appear),
+ 'must_skip_jobs' (list of jobs that must be explicitly skipped),
+ 'expected_hotspots' (list of hotspot categories the skill should flag).
+
+outputs:
+ - path: .work/pr-risk
+ schema: |
+ A single JSON state file named --.json containing:
+ 'pr_url', 'org', 'repo', 'pr_number', and 'assessment' object with
+ 'risk_score' (0-100), 'risk_tier' (low|medium|high|critical),
+ 'risk_breakdown' (repo_risk, pr_complexity, code_risk_factors, historical_risk),
+ 'jobs_to_run' (list of /test commands), 'jobs_to_skip' (list of {name, reason}),
+ 'coverage_gaps', 'key_risks', 'cost_estimate' (recommended_cost_usd,
+ savings_from_skipped_required_usd, added_cost_from_optional_usd, net_savings_usd),
+ and 'notes'.
+
+traces:
+ stdout: true
+ stderr: true
+ events: true
+ metrics: true
+
+judges:
+ - name: budget_check
+ builtin: cost_budget
+ arguments:
+ max_cost_usd: 3.0
+
+ - name: state_file_valid
+ description: |
+ Checks that the skill produced exactly one valid JSON state file in
+ .work/pr-risk/ with all required top-level and assessment fields.
+ check: |
+ import json
+ files = outputs.get("files", {})
+ json_files = {k: v for k, v in files.items() if k.endswith(".json")}
+ if not json_files:
+ return (False, "No JSON state file found in .work/pr-risk/")
+ if len(json_files) > 1:
+ return (False, f"Expected 1 JSON file, found {len(json_files)}: {list(json_files.keys())}")
+ fname, content = next(iter(json_files.items()))
+ try:
+ data = json.loads(content)
+ except json.JSONDecodeError as e:
+ return (False, f"Invalid JSON: {e}")
+ required = ["pr_url", "org", "repo", "pr_number", "assessment"]
+ missing = [f for f in required if f not in data]
+ if missing:
+ return (False, f"Missing top-level fields: {missing}")
+ assess = data["assessment"]
+ a_required = ["risk_score", "risk_tier", "risk_breakdown", "jobs_to_run",
+ "jobs_to_skip", "coverage_gaps", "key_risks", "cost_estimate", "notes"]
+ a_missing = [f for f in a_required if f not in assess]
+ if a_missing:
+ return (False, f"Missing assessment fields: {a_missing}")
+ return (True, f"Valid state file: {fname}")
+
+ - name: risk_tier_correct
+ description: |
+ Checks that the assessed risk tier matches the expected tier from annotations.
+ check: |
+ import json
+ annotations = outputs.get("annotations", {})
+ expected = annotations.get("expected_tier", "")
+ if not expected:
+ return (True, "No expected_tier in annotations, skipping")
+ files = outputs.get("files", {})
+ json_files = {k: v for k, v in files.items() if k.endswith(".json")}
+ if not json_files:
+ return (False, "No state file to check tier")
+ content = next(iter(json_files.values()))
+ data = json.loads(content)
+ actual = data.get("assessment", {}).get("risk_tier", "").lower()
+ if actual == expected.lower():
+ return (True, f"Tier correct: {actual}")
+ return (False, f"Expected tier '{expected}', got '{actual}'")
+
+ - name: risk_score_in_range
+ description: |
+ Checks that the risk score falls within the expected range from annotations.
+ check: |
+ import json
+ annotations = outputs.get("annotations", {})
+ score_range = annotations.get("expected_score_range", {})
+ if not score_range:
+ return (True, "No expected_score_range in annotations, skipping")
+ files = outputs.get("files", {})
+ json_files = {k: v for k, v in files.items() if k.endswith(".json")}
+ if not json_files:
+ return (False, "No state file to check score")
+ content = next(iter(json_files.values()))
+ data = json.loads(content)
+ score = data.get("assessment", {}).get("risk_score", -1)
+ lo, hi = score_range.get("min", 0), score_range.get("max", 100)
+ if lo <= score <= hi:
+ return (True, f"Score {score} in range [{lo}, {hi}]")
+ return (False, f"Score {score} outside expected range [{lo}, {hi}]")
+
+ - name: job_recommendations_correct
+ description: |
+ Validates job recommendations against structured state only:
+ must_recommend_jobs checked against assessment.jobs_to_run,
+ must_not_recommend_jobs checked against assessment.jobs_to_run,
+ must_skip_jobs checked against assessment.jobs_to_skip.
+ check: |
+ import json
+ annotations = outputs.get("annotations", {})
+ must_rec = annotations.get("must_recommend_jobs", [])
+ must_not = annotations.get("must_not_recommend_jobs", [])
+ must_skip = annotations.get("must_skip_jobs", [])
+ files = outputs.get("files", {})
+ json_files = {k: v for k, v in files.items() if k.endswith(".json")}
+ if not json_files:
+ return (False, "No state file to check jobs")
+ content = next(iter(json_files.values()))
+ data = json.loads(content)
+ assess = data.get("assessment", {})
+ run_text = " ".join(assess.get("jobs_to_run", [])).lower()
+ skip_names = [s.get("name", "") if isinstance(s, dict) else str(s)
+ for s in assess.get("jobs_to_skip", [])]
+ skip_text = " ".join(skip_names).lower()
+ issues = []
+ missing = [j for j in must_rec if j.lower() not in run_text]
+ if missing:
+ issues.append(f"Missing required jobs: {missing}")
+ present_bad = [j for j in must_not if j.lower() in run_text]
+ if present_bad:
+ issues.append(f"Forbidden jobs recommended: {present_bad}")
+ missing_skip = [j for j in must_skip if j.lower() not in skip_text]
+ if missing_skip:
+ issues.append(f"Missing required skips: {missing_skip}")
+ if issues:
+ return (False, "; ".join(issues))
+ total = len(must_rec) + len(must_not) + len(must_skip)
+ return (True, f"All {total} job constraints satisfied")
+
+ - name: report_quality
+ description: |
+ LLM judge evaluating the overall quality of the risk assessment report
+ including risk breakdown justification, testing rationale, and cost analysis.
+ prompt: |
+ You are evaluating a PR risk assessment report. The skill analyzed a GitHub PR
+ and produced a risk score, tier classification, and testing recommendations.
+
+ ## Skill Output (conversation)
+
+ {{ conversation }}
+
+ ## File Artifacts
+
+ {{ outputs }}
+
+ ## Expected Outcomes (annotations)
+
+ {{ annotations }}
+
+ ## Scoring Criteria
+
+ Score on a 1-5 scale:
+
+ 1: Report is missing major sections, risk score is unjustified, or job
+ recommendations are clearly wrong (e.g., recommending expensive jobs
+ for a trivial docs change, or skipping all jobs for a critical API change).
+ 2: Report has all sections but reasoning is shallow — scores assigned
+ without citing specific code patterns, or job recommendations lack
+ justification.
+ 3: Adequate report with reasonable scores and justifications, but misses
+ some nuance — e.g., doesn't identify a key hotspot, or cost estimate
+ is missing/wrong.
+ 4: Good report with well-justified scores, appropriate job recommendations
+ with clear rationale, cost analysis present, and hotspots identified.
+ 5: Excellent report — scores precisely justified with specific code references,
+ job recommendations perfectly calibrated to the PR's risk profile,
+ cost savings quantified, and all relevant hotspots flagged.
+
+thresholds:
+ state_file_valid:
+ min_pass_rate: 1.0
+ risk_tier_correct:
+ min_pass_rate: 0.8
+ risk_score_in_range:
+ min_pass_rate: 0.8
+ job_recommendations_correct:
+ min_pass_rate: 0.8
+ report_quality:
+ min_mean: 3.5
+ budget_check:
+ min_pass_rate: 1.0
diff --git a/plugins/ci/evals/eval-assess-pr-risk.md b/plugins/ci/evals/eval-assess-pr-risk.md
new file mode 100644
index 000000000..33fb6739c
--- /dev/null
+++ b/plugins/ci/evals/eval-assess-pr-risk.md
@@ -0,0 +1,76 @@
+---
+# Auto-generated by /eval-analyze — edit to override
+skill: assess-pr-risk
+analyzed_at: "2026-06-29T00:00:00Z"
+skill_hash: 9f2caff55a4b
+
+# Discovered skill capabilities
+execution_mode: case
+headless: true
+dry_run: false
+
+# Suggested judges (summary from analysis)
+suggested_judges:
+ - name: budget_check
+ type: builtin
+ description: "Cost stays under $3 per invocation"
+ - name: state_file_written
+ type: check
+ description: "Valid JSON state file written to .work/pr-risk/ with all required fields"
+ - name: tier_matches_score
+ type: check
+ description: "Risk tier correctly derived from numeric score (0-20=low, 21-45=medium, etc.)"
+ - name: score_breakdown_valid
+ type: check
+ description: "Four risk components sum to total score and each is within its valid range"
+ - name: no_revert_leakage
+ type: check
+ description: "Report does not mention whether this specific PR was reverted post-merge"
+ - name: jobs_recommended
+ type: check
+ description: "Skill produces explicit run/skip decisions for CI jobs"
+ - name: risk_assessment_quality
+ type: llm
+ description: "Overall quality: score alignment, justification, actionability"
+ - name: job_recommendation_relevance
+ type: llm
+ description: "Job recommendations match the actual code changes and risk patterns"
+---
+
+## Skill Analysis
+
+The `assess-pr-risk` skill evaluates an OpenShift GitHub PR's risk level and recommends which CI jobs to run. It scores risk across four dimensions (repo profile, PR complexity, code risk factors, historical revert rate), determines a risk tier, fetches the repo's configured CI jobs from `openshift/release`, and decides which to run or skip. It also applies "hotspot awareness" for common revert patterns (HyperShift, SNO, MicroShift, IPv6, upgrades).
+
+A key constraint: the skill must analyze the PR as if it doesn't know the post-merge outcome — it must never mention whether the PR was reverted, even when analyzing known-bad PRs for calibration.
+
+## Inputs
+
+Each test case provides:
+- `input.yaml` with a single field `pr_url` — a real GitHub PR URL (e.g., `https://github.com/openshift/machine-config-operator/pull/1234`)
+- `annotations.yaml` with expected outcomes: `expected_tier`, `expected_score_range`, `must_recommend_jobs`, `must_skip_jobs`, `expected_hotspots`, and `description`
+
+The skill uses GitHub CLI (`gh`) to fetch PR metadata, diffs, comments, and historical revert data. It also fetches CI config from the `openshift/release` repo.
+
+## Outputs
+
+- **JSON state file** at `.work/pr-risk/--.json` containing the full assessment: score, tier, breakdown, jobs to run/skip, coverage gaps, key risks
+- **Markdown report** in conversation output with risk breakdown table, key risk factors, `/test` commands to run, jobs to skip with reasons, coverage gaps, and historical context
+
+## Pipeline Flow
+
+1. Fetch PR metadata, diff, comments, and repo revert history via `gh`
+2. Score risk: repo profile (0-25) + PR complexity (0-25) + code risk factors (0-30) + historical risk (0-20)
+3. Map score to tier: LOW (0-20), MEDIUM (21-45), HIGH (46-70), CRITICAL (71-100)
+4. Fetch repo CI config from `openshift/release`, classify each job as run/skip
+5. Apply hotspot awareness (HyperShift, SNO, MicroShift, upgrade, IPv6) for coverage gaps
+6. Write state file and produce markdown report
+
+## Quality Criteria
+
+A good assessment:
+- Has a risk score that matches the actual code changes (not too high, not too low)
+- Makes specific, justified run/skip decisions for every e2e job
+- Identifies hotspot risks when the code warrants them (HyperShift, SNO, etc.)
+- Incorporates CodeRabbit and reviewer feedback from PR comments
+- Never leaks post-merge revert information
+- Produces a concise, actionable report a reviewer can act on immediately
diff --git a/plugins/ci/evals/eval-assess-pr-risk.yaml b/plugins/ci/evals/eval-assess-pr-risk.yaml
new file mode 100644
index 000000000..28d49b846
--- /dev/null
+++ b/plugins/ci/evals/eval-assess-pr-risk.yaml
@@ -0,0 +1,433 @@
+name: assess-pr-risk
+description: Evaluate the assess-pr-risk skill's ability to score PR risk and recommend CI jobs
+skill: assess-pr-risk
+
+execution:
+ mode: case
+ arguments: "{pr_url}"
+ timeout: 600
+ max_budget_usd: 3.0
+
+runner:
+ type: claude-code
+ plugin_dirs:
+ - plugins/ci
+
+models:
+ skill: claude-opus-4-6
+ judge: claude-opus-4-6
+
+permissions:
+ allow:
+ - "Skill"
+ - "Bash(gh *)"
+ - "Bash(curl *)"
+ - "Bash(mkdir *)"
+ - "Bash(cat *)"
+ - "Bash(ls *)"
+ - "Bash(find *)"
+ - "Bash(grep *)"
+ - "Bash(python3 *)"
+ - "Bash(jq *)"
+ - "WebFetch"
+ - "WebSearch"
+ - "Agent"
+ - "Write"
+ - "Read"
+ - "Edit"
+ - "Bash(date *)"
+ - "Bash(base64 *)"
+ - "Bash(wc *)"
+ deny: []
+
+mlflow:
+ experiment: assess-pr-risk
+
+dataset:
+ path: cases/assess-pr-risk/
+ schema: |
+ Each case directory contains:
+ - input.yaml: YAML file with 'pr_url' ([EXTERNAL: GitHub] — must be a real,
+ accessible GitHub PR URL, e.g. https://github.com/openshift/machine-config-operator/pull/1234).
+ The skill extracts org, repo, and PR number from this URL.
+ - annotations.yaml: Expected evaluation metadata including:
+ - 'expected_tier' (string: low, medium, high, or critical) — the expected risk tier
+ - 'expected_score_range' (dict with 'min' and 'max' integers) — acceptable score range
+ - 'must_recommend_jobs' (list of strings) — job name substrings that MUST appear in recommendations
+ - 'must_not_recommend_jobs' (list of strings) — job name substrings that must NOT appear as /test or /payload-job commands
+ - 'must_skip_jobs' (list of strings) — job name substrings that MUST appear in skip list
+ - 'expected_hotspots' (list of strings) — hotspot categories expected to be flagged (e.g., hypershift, sno, upgrade, ipv6)
+ - 'description' (string) — brief description of why this PR is a good test case
+
+inputs:
+ tools: []
+
+outputs:
+ - path: ".work/pr-risk"
+ schema: |
+ JSON state file named --.json containing:
+ {
+ "pr_url": "",
+ "org": "",
+ "repo": "",
+ "pr_number": ,
+ "assessment": {
+ "timestamp": "",
+ "risk_score": <0-100>,
+ "risk_tier": "",
+ "risk_breakdown": {
+ "repo_risk": <0-25>,
+ "pr_complexity": <0-25>,
+ "code_risk_factors": <0-30>,
+ "historical_risk": <0-20>
+ },
+ "jobs_to_run": [""],
+ "jobs_to_skip": [{"name": "", "reason": ""}],
+ "coverage_gaps": [""],
+ "key_risks": [""],
+ "cost_estimate": {
+ "recommended_cost_usd": ,
+ "savings_from_skipped_required_usd": ,
+ "added_cost_from_optional_usd": ,
+ "net_savings_usd":
+ },
+ "notes": ""
+ }
+ }
+
+traces:
+ stdout: true
+ stderr: true
+ events: true
+ metrics: true
+
+judges:
+ - name: budget_check
+ builtin: cost_budget
+ arguments:
+ max_cost_usd: 3.0
+
+ - name: state_file_written
+ description: |
+ Verify that a valid JSON state file was written to .work/pr-risk/
+ with all required fields present.
+ check: |
+ import json
+ files = outputs.get("files", {})
+ state_files = {k: v for k, v in files.items() if k.endswith(".json") and "pr-risk" in k and '"assessment"' in v}
+ if not state_files:
+ return (False, "No state file found in .work/pr-risk/")
+ content = list(state_files.values())[0]
+ try:
+ data = json.loads(content)
+ except json.JSONDecodeError as e:
+ return (False, f"Invalid JSON: {e}")
+ assessment = data.get("assessment", {})
+ required = ["risk_score", "risk_tier", "risk_breakdown", "jobs_to_run", "jobs_to_skip", "key_risks"]
+ missing = [f for f in required if f not in assessment]
+ if missing:
+ return (False, f"Missing fields: {missing}")
+ return (True, f"State file valid, score={assessment['risk_score']}, tier={assessment['risk_tier']}")
+
+ - name: tier_matches_score
+ description: |
+ Verify the risk tier is correctly derived from the risk score:
+ 0-20=low, 21-45=medium, 46-70=high, 71-100=critical.
+ check: |
+ import json
+ files = outputs.get("files", {})
+ state_files = {k: v for k, v in files.items() if k.endswith(".json") and "pr-risk" in k and '"assessment"' in v}
+ if not state_files:
+ return (False, "No state file found")
+ data = json.loads(list(state_files.values())[0])
+ score = data.get("assessment", {}).get("risk_score", -1)
+ tier = data.get("assessment", {}).get("risk_tier", "").lower()
+ expected = "low" if score <= 20 else "medium" if score <= 45 else "high" if score <= 70 else "critical"
+ if tier != expected:
+ return (False, f"Score {score} should be {expected}, got {tier}")
+ return (True, f"Score {score} correctly mapped to {tier}")
+
+ - name: score_breakdown_valid
+ description: |
+ Verify that the four risk breakdown components sum to the total score
+ and each is within its valid range.
+ check: |
+ import json
+ files = outputs.get("files", {})
+ state_files = {k: v for k, v in files.items() if k.endswith(".json") and "pr-risk" in k and '"assessment"' in v}
+ if not state_files:
+ return (False, "No state file found")
+ data = json.loads(list(state_files.values())[0])
+ a = data.get("assessment", {})
+ score = a.get("risk_score", -1)
+ bd = a.get("risk_breakdown", {})
+ limits = {"repo_risk": 25, "pr_complexity": 25, "code_risk_factors": 30, "historical_risk": 20}
+ for k, mx in limits.items():
+ v = bd.get(k, -1)
+ if not (0 <= v <= mx):
+ return (False, f"{k}={v} out of range [0,{mx}]")
+ total = sum(bd.get(k, 0) for k in limits)
+ if total != score:
+ return (False, f"Breakdown sum {total} != score {score}")
+ return (True, f"Breakdown valid: {bd}")
+
+ - name: no_revert_leakage
+ description: |
+ Verify the agent does not mention whether this specific PR was reverted.
+ The repo-level revert rate is fine, but the report must not reference
+ this PR's post-merge outcome, revert PRs, or frame the analysis as retrospective.
+ check: |
+ text = outputs.get("conversation", "")
+ lower = text.lower()
+ leakage_phrases = [
+ "this pr was reverted",
+ "was later reverted",
+ "subsequently reverted",
+ "reverted in pr",
+ "revert of this pr",
+ "retrospective",
+ "case study",
+ "post-merge",
+ "after merge",
+ "after this pr was merged",
+ ]
+ found = [p for p in leakage_phrases if p in lower]
+ if found:
+ return (False, f"Revert leakage detected: {found}")
+ return (True, "No revert leakage found")
+
+ - name: jobs_recommended
+ description: |
+ Verify that the skill produced at least one /test job recommendation
+ and at least one job skip decision. The skill must make explicit
+ run/skip decisions, not just list jobs.
+ check: |
+ import json
+ files = outputs.get("files", {})
+ state_files = {k: v for k, v in files.items() if k.endswith(".json") and "pr-risk" in k and '"assessment"' in v}
+ if not state_files:
+ return (False, "No state file found")
+ data = json.loads(list(state_files.values())[0])
+ a = data.get("assessment", {})
+ runs = a.get("jobs_to_run", [])
+ skips = a.get("jobs_to_skip", [])
+ text = outputs.get("conversation", "")
+ has_test_cmds = "/test " in text or "/payload-job " in text
+ if not runs and not has_test_cmds:
+ tier = a.get("risk_tier", "").lower()
+ if tier != "low":
+ return (False, f"Tier {tier} but no jobs recommended")
+ if not skips and not runs:
+ return (False, "No run or skip decisions made")
+ return (True, f"{len(runs)} jobs to run, {len(skips)} jobs to skip")
+
+ - name: must_recommend_jobs
+ description: |
+ Verify that jobs listed in annotations.must_recommend_jobs appear
+ as actual /test or /payload-job commands in the conversation output.
+ Mentions in skip explanations or narrative do not count.
+ check: |
+ ann = outputs.get("annotations", {})
+ must = ann.get("must_recommend_jobs", [])
+ if not must:
+ return (True, "No must_recommend_jobs annotations")
+ text = outputs.get("conversation", "")
+ import re
+ cmd_lines = re.findall(r'(?:/test|/payload-job)\s+\S+', text, re.IGNORECASE)
+ cmd_text = " ".join(cmd_lines).lower()
+ missing = [j for j in must if j.lower() not in cmd_text]
+ if missing:
+ return (False, f"Expected job recommendations not found in commands: {missing}")
+ return (True, f"All {len(must)} required jobs recommended")
+
+ - name: must_not_recommend_jobs
+ description: |
+ Verify that jobs listed in annotations.must_not_recommend_jobs do NOT
+ appear as recommendations in the conversation output. They may appear
+ in skip lists or explanations of why they were excluded — that's fine.
+ Only flag if they appear as a /test or /payload-job command.
+ check: |
+ ann = outputs.get("annotations", {})
+ must_not = ann.get("must_not_recommend_jobs", [])
+ if not must_not:
+ return (True, "No must_not_recommend_jobs annotations")
+ text = outputs.get("conversation", "")
+ import re
+ cmd_lines = re.findall(r'(?:/test|/payload-job)\s+\S+', text, re.IGNORECASE)
+ cmd_text = " ".join(cmd_lines).lower()
+ found = [j for j in must_not if j.lower() in cmd_text]
+ if found:
+ return (False, f"Jobs that should NOT be recommended were found in commands: {found}")
+ return (True, f"None of {len(must_not)} excluded jobs were recommended")
+
+ - name: must_skip_jobs
+ description: |
+ Verify that jobs listed in annotations.must_skip_jobs appear
+ in the skip list of the state file.
+ check: |
+ import json
+ ann = outputs.get("annotations", {})
+ must = ann.get("must_skip_jobs", [])
+ if not must:
+ return (True, "No must_skip_jobs annotations")
+ files = outputs.get("files", {})
+ state_files = {k: v for k, v in files.items() if k.endswith(".json") and "pr-risk" in k and '"assessment"' in v}
+ if not state_files:
+ return (False, "No state file found")
+ data = json.loads(list(state_files.values())[0])
+ skips = data.get("assessment", {}).get("jobs_to_skip", [])
+ skip_names = " ".join(s.get("name", "") for s in skips).lower()
+ missing = [j for j in must if j.lower() not in skip_names]
+ if missing:
+ return (False, f"Expected skips not found: {missing}")
+ return (True, f"All {len(must)} required skips present")
+
+ - name: cost_estimate_present
+ description: |
+ Verify the state file contains a cost_estimate object with all required
+ fields, and that the conversation output includes a Cost Estimate section.
+ check: |
+ import json
+ files = outputs.get("files", {})
+ state_files = {k: v for k, v in files.items() if k.endswith(".json") and "pr-risk" in k and '"assessment"' in v}
+ if not state_files:
+ return (False, "No state file found")
+ data = json.loads(list(state_files.values())[0])
+ ce = data.get("assessment", {}).get("cost_estimate")
+ if not ce:
+ return (False, "No cost_estimate in state file")
+ required = ["recommended_cost_usd", "savings_from_skipped_required_usd",
+ "added_cost_from_optional_usd", "net_savings_usd"]
+ missing = [f for f in required if f not in ce]
+ if missing:
+ return (False, f"cost_estimate missing fields: {missing}")
+ for f in required:
+ v = ce[f]
+ if not isinstance(v, (int, float)):
+ return (False, f"cost_estimate.{f} is not a number: {v}")
+ text = outputs.get("conversation", "")
+ if "cost estimate" not in text.lower() and "cost breakdown" not in text.lower():
+ return (False, "No Cost Estimate section in conversation output")
+ return (True, f"Cost estimate present: recommended=${ce['recommended_cost_usd']:.2f}, "
+ f"savings=${ce['savings_from_skipped_required_usd']:.2f}, "
+ f"net=${ce['net_savings_usd']:.2f}")
+
+ - name: cost_rates_correct
+ description: |
+ Verify the skill used the correct platform hourly rates in its cost
+ calculations. Parse the conversation for cost line items and check
+ that the $/hr matches: AWS=$1, GCP=$2, Azure=$2, Metal=$3, vSphere=$4.
+ check: |
+ import re
+ text = outputs.get("conversation", "")
+ correct_rates = {"aws": 1, "gcp": 2, "azure": 2, "metal": 3, "vsphere": 4}
+ rate_pattern = re.compile(r'\$(\d+(?:\.\d+)?)/hr', re.IGNORECASE)
+ errors = []
+ for line in text.split("\n"):
+ lower = line.lower()
+ for platform, expected_rate in correct_rates.items():
+ if platform in lower:
+ matches = rate_pattern.findall(line)
+ for match in matches:
+ actual = float(match)
+ if actual != expected_rate:
+ errors.append(f"{platform}: used ${actual}/hr, expected ${expected_rate}/hr")
+ if errors:
+ return (False, f"Wrong platform rates: {'; '.join(errors[:5])}")
+ found_any = bool(rate_pattern.search(text))
+ if not found_any:
+ return (True, "No explicit $/hr rates found in output (may use implicit rates)")
+ return (True, "All platform rates correct")
+
+ - name: risk_assessment_quality
+ description: |
+ Assess overall quality of the risk assessment: whether the score
+ aligns with the actual code changes, whether job recommendations
+ are well-justified, and whether the report is actionable.
+ prompt: |
+ You are evaluating a PR risk assessment produced by an automated skill.
+ Review the skill's conversation output below.
+
+ {{ conversation }}
+
+ And the annotations for this test case:
+
+ {{ annotations }}
+
+ Score the assessment on a 1-5 scale:
+
+ Score 1: Risk score is grossly misaligned with code changes, no job
+ recommendations, or generic boilerplate with no specific analysis.
+
+ Score 2: Risk score is somewhat reasonable but missing key risk factors.
+ Job recommendations exist but lack justification or relevance.
+
+ Score 3: Risk score is reasonable. Job recommendations are mostly relevant
+ with some justification. Report covers the basics but misses some
+ important signals (CodeRabbit comments, reviewer feedback, hotspots).
+
+ Score 4: Risk score accurately reflects code changes. Job recommendations
+ are specific and well-justified. Hotspot awareness is applied where
+ relevant. Report incorporates PR context (comments, reviews).
+
+ Score 5: Excellent analysis. Score precisely reflects all risk factors.
+ Every job recommendation has a clear justification tied to specific
+ code patterns. Coverage gaps are identified with concrete /payload-job
+ suggestions. Historical context is meaningful. Report is concise and
+ immediately actionable for a reviewer.
+
+ - name: job_recommendation_relevance
+ description: |
+ Assess whether the recommended jobs are genuinely relevant to the
+ PR's code changes, and whether skipped jobs have valid reasons.
+ prompt: |
+ You are evaluating the job recommendations from a PR risk assessment.
+
+ {{ conversation }}
+
+ Annotations for this case:
+
+ {{ annotations }}
+
+ Focus on:
+ 1. Are recommended jobs relevant to the actual code changes?
+ 2. Are skipped jobs correctly identified as irrelevant?
+ 3. Are hotspot patterns (HyperShift, SNO, upgrade, IPv6) flagged when the code warrants it?
+ 4. Are there obvious coverage gaps the skill missed?
+
+ Score on a 1-5 scale:
+
+ Score 1: Jobs are recommended randomly without connection to the code.
+ Score 2: Some job-code alignment but major gaps or irrelevant recommendations.
+ Score 3: Reasonable job selection. Most recommendations match code changes.
+ Score 4: Strong job selection. Good hotspot awareness. Minor gaps only.
+ Score 5: Perfect job selection. Every recommendation is justified by specific
+ code patterns. All relevant hotspots identified. Coverage gaps noted.
+
+thresholds:
+ budget_check:
+ min_pass_rate: 1.0
+ state_file_written:
+ min_pass_rate: 1.0
+ tier_matches_score:
+ min_pass_rate: 1.0
+ score_breakdown_valid:
+ min_pass_rate: 1.0
+ no_revert_leakage:
+ min_pass_rate: 1.0
+ cost_estimate_present:
+ min_pass_rate: 1.0
+ cost_rates_correct:
+ min_pass_rate: 1.0
+ jobs_recommended:
+ min_pass_rate: 0.8
+ must_recommend_jobs:
+ min_pass_rate: 1.0
+ must_not_recommend_jobs:
+ min_pass_rate: 1.0
+ must_skip_jobs:
+ min_pass_rate: 1.0
+ risk_assessment_quality:
+ min_mean: 3.5
+ job_recommendation_relevance:
+ min_mean: 3.5
diff --git a/plugins/ci/skills/assess-pr-risk/SKILL.md b/plugins/ci/skills/assess-pr-risk/SKILL.md
new file mode 100644
index 000000000..ddbb8d537
--- /dev/null
+++ b/plugins/ci/skills/assess-pr-risk/SKILL.md
@@ -0,0 +1,443 @@
+---
+name: assess-pr-risk
+description: Assess a PR's risk level and recommend which e2e and payload CI jobs to run. Takes a GitHub PR URL, scores risk across repo profile, complexity, code factors, and historical signals, then outputs a structured report with specific job recommendations.
+---
+
+The user provides a PR URL (e.g. `https://github.com/openshift/machine-config-operator/pull/1234`). Extract the org, repo, and PR number.
+
+Create the directory with `mkdir -p .work/pr-risk` on first use.
+
+## Step 1: Gather PR Data
+
+```bash
+# PR metadata
+gh pr view --json number,title,body,author,files,labels,state,additions,deletions,changedFiles,baseRefName,headRefName,statusCheckRollup
+
+# Diff summary (always fetch this first for large PRs)
+gh pr diff --stat
+
+# Full diff (for code analysis — skip if >2000 lines changed, use stat + selective reads instead)
+gh pr diff
+
+# PR comments — look for reviewer feedback and CodeRabbit analysis
+gh api repos///pulls//comments --paginate --jq '.[] | {user: .user.login, body: .body}' 2>/dev/null
+gh api repos///issues//comments --paginate --jq '.[] | {user: .user.login, body: .body}' 2>/dev/null
+
+# Recent reverts in the same repo (last 6 months)
+# Calculate the date 6 months ago as YYYY-MM-DD and use GitHub's merged: search qualifier
+gh pr list --repo / --search "revert in:title merged:>$(python3 -c "from datetime import date,timedelta;print((date.today()-timedelta(days=180)).isoformat())")" --state merged --limit 50 --json number,title,mergedAt
+```
+
+For very large PRs (>100 files or >2000 LOC), use `gh pr diff --stat` to identify the highest-risk files, then fetch only those diffs selectively.
+
+### Reading PR Comments
+
+Pay special attention to comments from:
+
+- **CodeRabbit** (`coderabbitai` user): CodeRabbit performs automated code review and may recommend additional testing. Look for suggestions about e2e tests, upgrade testing, or platform-specific concerns. Incorporate these into your testing recommendation.
+- **Human reviewers**: Reviewers may flag risk areas, request specific tests, or express concerns about the change. Factor these into your assessment.
+- **CI bot comments**: Look for `/payload-job` commands that reviewers have already triggered — this tells you what testing is already underway.
+
+## Step 2: Calculate Risk Score
+
+Evaluate four categories and sum the points (0-100 total).
+
+### A. Repository Risk Profile (0-25 points)
+
+Classify the repository by examining its role in the OpenShift payload:
+
+| Repository Type | Points | How to Identify |
+| ------------------ | ------ | --------------------------------------------------------------------------------------------------------- |
+| Core platform | 25 | Repos like `openshift/kubernetes`, `openshift/api`, `openshift/library-go` — foundational to the platform |
+| Core operators | 20 | Repos owning a ClusterOperator (e.g. `cluster-*-operator`, `machine-config-operator`) |
+| Installers and CLI | 15 | `openshift/installer`, `openshift/oc`, `openshift/console` |
+| Payload components | 10 | Ships an image in the payload but not a core operator |
+| Peripheral/tooling | 5 | Test frameworks, CI config, documentation repos |
+
+Heuristics for classification:
+
+- Check for `Dockerfile` or `Dockerfile.rhel` → likely ships in payload
+- Check for `manifests/` or `install/` directories → likely an operator
+- Repo name contains `operator` → likely a core operator
+- Repo is `openshift/release`, `openshift/origin`, or test-focused → peripheral
+
+If unsure, default to 10 points.
+
+### B. PR Complexity (0-25 points)
+
+| Signal | Points |
+| ---------------------------------------------- | ------------- |
+| 1-3 files changed | 5 |
+| 4-10 files changed | 10 |
+| 11-30 files changed | 15 |
+| 30+ files changed | 20 |
+| Multiple distinct packages/directories touched | +5 |
+| Code changes with zero test file changes | +5 |
+| Docs-only changes | -20 (floor 0) |
+
+**Test-only PRs are NOT automatically low risk.** Do not apply a blanket discount for test-only changes. Tests run across the entire CI system — a bad test can cause widespread job failures and trigger the revert-first policy just like production code. Score test-only PRs using the test risk factors in section C below.
+
+### C. Code Risk Factors (0-30 points)
+
+Examine the diff for these patterns. Points are additive but capped at 30:
+
+| Factor | Points | What to Look For |
+| ---------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------- |
+| API type or CRD schema changes | +10 | Changes in `types.go`, `*_types.go`, CRD YAML, `zz_generated*` |
+| Admission/validating webhook changes | +8 | Webhook configurations, admission handlers |
+| RBAC / ClusterRole changes | +7 | `role.yaml`, `clusterrole.yaml`, RBAC-related Go code |
+| Operator reconciliation logic | +7 | Changes in `pkg/operator/`, reconcile functions, sync loops |
+| Upgrade/migration path changes | +8 | Version-gated logic, migration functions, `upgradeable` conditions |
+| Feature gate additions/removals | +6 | FeatureGate references, feature set changes |
+| Vendor/dependency bumps | +3 | Changes only in `vendor/`, `go.mod`, `go.sum` |
+| New e2e/integration tests added | +7 | New test files or new `It()`/`Describe()` blocks — a flaky or broken new test will fail across every job that runs it |
+| Test framework/infrastructure changes | +6 | Changes to test helpers, fixtures, setup/teardown, test utilities — affects many tests downstream |
+| Existing test modified to be more stable | -3 | Reducing flakiness, adding retries, loosening timing — low risk, helpful |
+| Tests removed or skipped | -2 | Removing dead tests or adding `Skip()` — low risk |
+| Generated code only | -5 | Only `zz_generated*`, `bindata.go` (with no manual changes) |
+| Pure documentation/comments | -10 | Only `.md`, comments, godoc |
+
+**Why test changes can be high risk:** OpenShift has a massive CI system. A new test that is flaky or always-failing will run across hundreds of presubmit and periodic jobs, blocking merges and triggering the revert-first policy. The blast radius of a bad test is often larger than a bug in production code because it affects every contributor, not just one component.
+
+### D. Historical Risk (0-20 points)
+
+This category uses **revert rate** (reverts / total merged PRs) over the last 6 months, not raw revert counts. A repo with 3 reverts out of 500 PRs is healthy; 3 reverts out of 30 PRs is a red flag.
+
+**Step D1: Gather revert and merge data (last 6 months)**
+
+```bash
+# Count reverts (last 6 months)
+gh pr list --repo / --search "revert in:title merged:>$(python3 -c "from datetime import date,timedelta;print((date.today()-timedelta(days=180)).isoformat())")" --state merged --limit 100 --json number | jq length
+
+# Count total merged PRs (last 6 months)
+gh pr list --repo / --search "merged:>$(python3 -c "from datetime import date,timedelta;print((date.today()-timedelta(days=180)).isoformat())")" --state merged --limit 1000 --json number | jq length
+```
+
+**Step D2: Calculate revert rate**
+
+```
+revert_rate = reverts / total_merged_prs * 100
+```
+
+If the repo has fewer than 5 merged PRs in 6 months, skip revert rate scoring (insufficient data) and score 0 for this sub-category.
+
+**Step D3: Score revert rate risk**
+
+These thresholds are calibrated from percentile analysis of revert rates across OpenShift repos (repos with at least 1 revert and 5 merged PRs):
+
+| Revert Rate | Risk Level | Points | Percentile Range |
+| ----------- | ---------- | ------ | ---------------- |
+| ≤1% | Low | 0 | Below p25 |
+| 1–3% | Moderate | 5 | p25 to p75 |
+| 3–5% | Elevated | 10 | p75 to p90 |
+| 5–10% | High | 15 | p90 to p95 |
+| >10% | Critical | 20 | Above p95 |
+
+Include the revert rate and its risk level in the report output so the user can see the repo's historical stability at a glance.
+
+## Step 3: Determine Risk Tier
+
+| Score | Tier | Meaning |
+| ------ | -------- | --------------------------------------------------------------------- |
+| 0-20 | LOW | Presubmit tests are sufficient. Expensive e2e testing can be skipped. |
+| 21-45 | MEDIUM | Run presubmits + targeted e2e for affected components. |
+| 46-70 | HIGH | Run full e2e suite including upgrade and platform-specific jobs. |
+| 71-100 | CRITICAL | Full e2e + manual review gate. Flag for TRT review. |
+
+## Step 4: Recommend Testing
+
+Your job is to decide the complete set of `/test` commands to run for this PR. Imagine a future where no e2e jobs run by default — you must recommend every test that should run, and explicitly call out every test you would skip with a reason.
+
+### Step 4a: Fetch the Repo's CI Job Configuration
+
+The repo's presubmit jobs are defined in the `openshift/release` repo. Fetch the CI config for the PR's target branch:
+
+```bash
+# Determine the branch (e.g., "main", "release-4.19")
+branch=""
+
+# Fetch the CI config YAML
+gh api repos/openshift/release/contents/ci-operator/config///--${branch}.yaml --jq '.content' | base64 -d > /tmp/ci-config.yaml
+
+# Parse all presubmit test jobs with their optional/always_run status
+python3 -c "
+import sys, yaml
+data = yaml.safe_load(open('/tmp/ci-config.yaml'))
+tests = data.get('tests', [])
+for t in tests:
+ name = t.get('as', 'unknown')
+ optional = t.get('optional', False)
+ always = t.get('always_run', True)
+ kind = 'optional' if optional else 'required'
+ print(f'{name} ({kind}, always_run={always})')
+"
+```
+
+This gives you the complete list of jobs the team has configured, split into:
+
+- **Required jobs** (`optional: false`): These run on every PR by default. The team considers these essential, but only because they did not have AI to make more intelligent decisions. Assume these are important, but you will need to decide if they should run or not.
+- **Optional jobs** (`optional: true`): These do NOT run automatically but can be triggered with `/test `. The team defined these for situational use — your job is to decide when they should be triggered.
+
+If the CI config YAML cannot be fetched (e.g., the repo doesn't exist in `openshift/release`, or uses a non-standard path), note it and fall back to the jobs visible in the PR's `statusCheckRollup`.
+
+### Step 4b: Fetch Job Durations from Sippy
+
+Presubmit job names in Sippy follow the pattern `pull-ci----`. For example, the `/test e2e-gcp-ovn` job for `openshift/origin` on the `main` branch is `pull-ci-openshift-origin-main-e2e-gcp-ovn` in Sippy.
+
+Query Sippy for the repo's presubmit e2e jobs to get actual `current_average_duration_minutes`:
+
+```bash
+python3 plugins/ci/skills/fetch-jobs/fetch_jobs.py \
+ --release Presubmits --repo --name e2e --format json
+```
+
+Match each CI config job to its Sippy entry by constructing the Sippy name: `pull-ci----`. Use `current_average_duration_minutes` for cost estimation in Step 4f. If a job has no Sippy data, estimate: standard e2e ~60 min, upgrade ~120 min, serial ~90 min.
+
+### Step 4c: Classify Each E2e Job
+
+Split the jobs from the CI config into two groups based on their name:
+
+**Non-e2e jobs** (unit tests, image builds, lint, verify, bindata-check, etc. — any job without `e2e` in its name): These are assumed to always run as required presubmits. Do not include them in your recommendations — they are a given. Do not output `/test` commands for these jobs.
+
+**E2e jobs** (any job with `e2e` in its name): These are the expensive jobs that provision real clusters. These are the sole focus of your recommendation. For each e2e job, decide **run** or **skip** with a reason, based on:
+
+1. **What the job tests** — infer from the job name (e.g., `e2e-aws-ovn`, `e2e-upgrade`, `e2e-gcp-console-olm`)
+2. **Whether the PR's changes are relevant** — does the code change touch areas that this job exercises?
+3. **The risk tier** — higher risk warrants broader test coverage
+
+Only output `/test` commands for e2e jobs. Never output `/test` for non-e2e jobs.
+
+### Step 4d: Hotspot Awareness
+
+Beyond the repo's configured jobs, watch for these common revert patterns. If the repo's CI config does not already include a matching job, recommend the specific `/payload-job` command listed below. These job names use a release version placeholder — substitute the current development release (e.g., `4.22` → `5.0`).
+
+**HyperShift / External topology**: HyperShift clusters run the control plane as pods in a separate management cluster — there are no control plane nodes in the hosted cluster. This is a frequent source of reverts when a change assumes a self-hosted control plane. Flag and recommend testing when the PR touches control plane components, operators, networking, ingress, node lifecycle, machine API, or any code that assumes control plane nodes exist or are accessible.
+```
+/payload-job periodic-ci-openshift-release-main-nightly-4.22-e2e-aws-ovn-hypershift
+```
+
+**Single Node OpenShift (SNO)**: SNO has all OpenShift APIs but only one node. Flag and recommend testing when the PR assumes multi-node/HA clusters — anti-affinity requiring multiple nodes, node drain/failover, cross-node scheduling, replica counts > 1 without topology awareness, or PDBs designed only for 3+ nodes.
+```
+/payload-job periodic-ci-openshift-release-master-ci-4.22-e2e-aws-upgrade-ovn-single-node
+/payload-job periodic-ci-openshift-release-master-nightly-4.22-e2e-aws-ovn-single-node-serial
+```
+
+**MicroShift**: MicroShift is a single-node, minimal OpenShift distribution. The only OpenShift kube APIs available are Route and SecurityContextConstraints — all other OpenShift-specific APIs (OLM, Machine API, Console, Monitoring, ImageRegistry, Samples operator, ClusterOperator, ClusterVersion, Build, DeploymentConfig, Project, etc.) are unavailable. This makes MicroShift unsuitable for testing most PRs. However, for changes that only touch Route or SCC code paths, MicroShift is by far the most cost-effective testing option at ~$0.15/hr. Flag and recommend MicroShift testing only when the PR's changes are limited to APIs that MicroShift supports. Flag as a concern when the PR adds tests that assume APIs unavailable on MicroShift.
+```
+/payload-job periodic-ci-openshift-microshift-release-4.22-periodics-e2e-aws-ovn-ocp-conformance
+/payload-job periodic-ci-openshift-microshift-release-4.22-periodics-e2e-aws-ovn-ocp-conformance-serial
+```
+
+**Upgrade paths**: Recommend upgrade jobs when the PR touches operator reconciliation, version-gated logic, migration functions, API types/CRD schemas, feature gates, or dependency bumps that change core algorithms (e.g., hashing, serialization). Look for jobs with `upgrade` in the repo's CI config first. If none exist, recommend:
+```
+/payload-job periodic-ci-openshift-release-master-ci-4.22-e2e-aws-upgrade-ovn-single-node
+```
+
+**IPv6 / Dual-stack / Disconnected**: Flag and recommend testing when the PR hardcodes IPv4 addresses, uses IPv4-only parsing, misses IPv6 bracket handling in URLs, or assumes external network connectivity (public registries, external APIs, DNS of public hostnames).
+```
+/payload-job periodic-ci-openshift-release-master-nightly-4.22-e2e-metal-ipi-ovn-ipv6
+/payload-job periodic-ci-openshift-release-master-nightly-4.22-e2e-metal-ipi-serial-ovn-ipv6
+```
+
+**Platform cost awareness**: When recommending platform-specific jobs, prefer lower-cost platforms:
+
+1. **AWS** — lowest cost, highest capacity, default
+2. **GCP** — moderate cost
+3. **Azure** — moderate cost
+4. **Metal** — moderate cost, use for bare-metal-specific paths
+5. **vSphere** — high cost, constrained capacity. Do NOT recommend unless the PR directly modifies vsphere-specific code
+
+### Step 4e: Recommendation by Risk Tier
+
+**LOW (0-20)**:
+
+- Recommend only the cheap/fast required jobs
+- Skip all e2e jobs — explicitly state they can be skipped and why
+- If docs-only or trivially safe, say so clearly
+
+**MEDIUM (21-45)**:
+
+- Run all required e2e jobs
+- Selectively trigger optional jobs that match the affected component or platform
+- If upgrade paths could be affected, trigger upgrade jobs if available
+
+**HIGH (46-70)**:
+
+- Run all required e2e jobs
+- Trigger all optional jobs relevant to the change
+- If no HyperShift/SNO/upgrade job exists in the repo's config but the change warrants it, note the gap and recommend `/payload-job` testing to cover it
+- Note specific areas of the code that warrant careful review
+
+**CRITICAL (71-100)**:
+
+- Run all required e2e jobs
+- Trigger all optional jobs
+- Recommend `/payload-job` testing as essential for broader coverage beyond presubmit jobs
+- Recommend blocking merge until test results are reviewed
+- Flag for TRT (Technical Release Team) attention
+- Recommend manual review of the diff by a domain expert
+
+### Step 4f: Format the Recommendation
+
+This skill does NOT trigger any tests. It recommends what should be run as `/test` commands the user can copy and paste.
+
+For each e2e job (jobs with `e2e` in the name), output a decision:
+
+**Jobs to run** — with a `/test` command and justification:
+
+```
+/test —
+```
+
+**Jobs to skip** — with a reason:
+
+```
+SKIP: —
+```
+
+If the hotspot analysis identified risks not covered by any configured job, add a **"Coverage Gaps"** section noting what additional testing would be ideal. Include the specific `/payload-job` commands in both the Coverage Gaps narrative and in the `jobs_to_run` array in the state file — these are actionable job recommendations just like `/test` commands, and must appear in `jobs_to_run` so downstream automation can consume them.
+
+### Step 4g: Estimate Testing Cost and Savings
+
+Write a JSON array of your e2e job decisions to `/tmp/pr-risk-jobs.json`, then run the cost estimator script. Each job needs `name`, `duration_minutes` (from Step 4b Sippy data), `decision` ("run" or "skip"), and `ci_status` ("required" or "optional" from the CI config).
+
+**Important:** Write this file to `/tmp/pr-risk-jobs.json` — never to `.work/pr-risk/`. The `.work/pr-risk/` directory must contain only the final state file from Step 5. Extra files there break downstream scoring.
+
+```bash
+cat > /tmp/pr-risk-jobs.json << 'JOBS_EOF'
+[
+ {"name": "", "duration_minutes": , "decision": "run|skip", "ci_status": "required|optional"},
+ ...
+]
+JOBS_EOF
+
+python3 plugins/ci/skills/prow-job-cost-estimator/estimate_cost.py \
+ --input /tmp/pr-risk-jobs.json --format summary
+```
+
+For the state file, also get JSON output:
+
+```bash
+python3 plugins/ci/skills/prow-job-cost-estimator/estimate_cost.py \
+ --input /tmp/pr-risk-jobs.json --format json > /tmp/pr-risk-costs.json
+```
+
+Use the output from this script directly in your report and state file. Do not recalculate costs yourself — use the exact numbers the script printed.
+
+## Step 5: Write State File
+
+The `.work/pr-risk/` directory must contain exactly one file: the state file below. Do not write intermediate files (jobs JSON, cost JSON) to this directory — use `/tmp/` for those.
+
+Clear any stale files from previous runs before writing:
+
+```bash
+mkdir -p .work/pr-risk
+find .work/pr-risk -maxdepth 1 -type f -name '*.json' -delete
+```
+
+Write the assessment to `.work/pr-risk/--.json`:
+
+```json
+{
+ "pr_url": "",
+ "org": "",
+ "repo": "",
+ "pr_number": ,
+ "assessment": {
+ "timestamp": "",
+ "risk_score": <0-100>,
+ "risk_tier": "",
+ "risk_breakdown": {
+ "repo_risk": <0-25>,
+ "pr_complexity": <0-25>,
+ "code_risk_factors": <0-30>,
+ "historical_risk": <0-20>
+ },
+ "jobs_to_run": [""],
+ "jobs_to_skip": [{"name": "", "reason": ""}],
+ "coverage_gaps": [""],
+ "key_risks": [""],
+ "cost_estimate": {
+ "recommended_cost_usd": ,
+ "savings_from_skipped_required_usd": ,
+ "added_cost_from_optional_usd": ,
+ "net_savings_usd":
+ },
+ "notes": ""
+ }
+}
+```
+
+## Step 6: Produce Report
+
+Output a structured markdown report:
+
+```
+## PR Risk Assessment: /#
+
+**Title:**
+**Author:**
+**Risk Score:** /100 ()
+
+### Risk Breakdown
+
+| Category | Score | Details |
+|----------|-------|---------|
+| Repository profile | X/25 | |
+| PR complexity | X/25 | |
+| Code risk factors | X/30 | |
+| Historical risk | X/20 | |
+
+### Key Risk Factors
+-
+
+### Testing Recommendation
+
+**Jobs to run:**
+```
+
+/test —
+/test —
+...
+
+```
+
+**Jobs to skip:**
+| Job | Reason for Skipping |
+|-----|---------------------|
+| `` | |
+| ... | ... |
+
+**Coverage gaps:**
+
+
+### Cost Estimate
+
+| | Amount |
+|---|--------|
+| Recommended testing cost | $X.XX |
+| Savings (skipped required e2e jobs) | -$X.XX |
+| Added cost (optional e2e jobs triggered) | +$X.XX |
+| **Net savings** | **$X.XX** |
+
+### Historical Context
+
+
+---
+State saved to `.work/pr-risk/--.json`
+```
+
+## Constraints
+
+- **You do not know what happened after this PR was merged.** Analyze presubmit results, payload job results, reviewer comments, and everything that occurred while the PR was open — that is all fair game. But you have no knowledge of whether the PR was later reverted or caused any post-merge issues. Do not search for reverts of this specific PR, do not mention revert PRs or Jira tickets related to this PR's post-merge outcome, and do not frame your analysis as a "retrospective" or "case study." Your report must read as a forward-looking risk assessment written at merge time. If you discover post-merge revert information incidentally, ignore it completely — do not reference it in your report. The repo-level revert rate in Section D is fine (that measures the repo's general stability), but never mention whether _this specific PR_ was reverted.
+- **When in doubt, score higher.** A false-high is cheaper than a missed regression.
+- **The scoring weights are v1.** They will be calibrated over time with real-world usage. If a score feels wrong based on your analysis, note the discrepancy and explain why.
+- **Don't block on missing data.** If a skill call fails or data is unavailable, note it and proceed with what you have. Adjust confidence accordingly.
+- **Be specific.** Don't say "this is risky" — say which files, which patterns, which historical signals led to the score.
+- **Large PRs need selective analysis.** For PRs with >100 files, focus your code analysis on non-vendor, non-generated files. Use the diff stat to prioritize.
+
+## Skills Available
+
+| Skill | When to Use |
+| ----- | ----------- |
+| `ci:prow-job-cost-estimator` | Calculate cost estimates for e2e job decisions (Step 4g) |
diff --git a/plugins/ci/skills/assess-pr-test-results/SKILL.md b/plugins/ci/skills/assess-pr-test-results/SKILL.md
new file mode 100644
index 000000000..bd4e8609b
--- /dev/null
+++ b/plugins/ci/skills/assess-pr-test-results/SKILL.md
@@ -0,0 +1,184 @@
+---
+name: assess-pr-test-results
+description: Fetch test results for a PR and classify failures as PR-related, pre-existing/flaky, or infrastructure by cross-referencing against mainline CI health
+---
+
+# Assess PR Test Results
+
+This skill fetches test results for a pull request, then cross-references each failing test against mainline CI pass rates to determine whether failures are likely caused by the PR, pre-existing on mainline, or infrastructure problems. It produces a structured report classifying each failure.
+
+## When to Use This Skill
+
+Use this skill when you need to:
+
+- Understand whether PR test failures are caused by the PR or are pre-existing
+- Triage PR failures to separate signal from noise
+- Get a quick health assessment of a PR's CI results
+- Identify which failures a PR author should investigate vs. ignore
+
+## Prerequisites
+
+1. **Network Access**: Must be able to reach the Sippy API
+ - No authentication required
+
+2. **Python 3**: Python 3.6 or later
+ - Uses only standard library (no external dependencies)
+
+## Implementation Steps
+
+### Step 1: Fetch PR Test Results
+
+Create a working directory and fetch the PR's test results:
+
+```bash
+mkdir -p .work/assess-pr-test-results
+
+script_path="plugins/ci/skills/assess-pr-test-results/fetch_pr_test_results.py"
+
+python3 "$script_path" "" \
+ --latest-sha \
+ --output .work/assess-pr-test-results/raw-results.json \
+ --format json
+```
+
+Always pass `--latest-sha` so only results from the most recent PR revision are returned. Older SHA failures are stale and not worth analyzing.
+
+If the user provides a PR URL, pass it directly. If they provide org/repo and PR number separately, use `--org` and `--repo` flags.
+
+Read the JSON output and check `success` is `true`.
+
+### Step 2: Identify Unique Failing Tests and Filter Infrastructure
+
+From the results, extract distinct `test_name` values from failures. Group by `test_name` and count how many job runs each test failed in.
+
+**Immediately classify these as Infrastructure** — do not look them up on mainline:
+
+- Any test containing `[sig-sippy]` (e.g., `[sig-sippy] infrastructure should work`, `[sig-sippy] openshift-tests should work`, `[sig-sippy] install should work`)
+- Any test containing `install should succeed`
+
+These are synthetic health indicators or install-level failures, not test regressions. Count them for the report but exclude them from the top 20 list.
+
+Sort the remaining tests by failure count (descending) and take the **top 20**. Note the total number of unique non-infrastructure failing tests so you can report how many were analyzed vs. total.
+
+### Step 3: Cross-Reference Against Mainline CI
+
+For each of the top 20 non-infrastructure failing tests, query its mainline pass rate:
+
+```bash
+report_script="plugins/ci/skills/fetch-test-report/fetch_test_report.py"
+
+# For each unique failing test name:
+python3 "$report_script" "" --format json
+```
+
+The script auto-detects the latest release. Parse the JSON array output to extract from the first element:
+
+- `current_pass_percentage` — pass rate over the last 7 days on mainline
+- `current_runs` — how many times it ran (low run count = less confident)
+- `current_failures` — absolute failure count on mainline
+- `open_bugs` — number of open Jira bugs mentioning this test
+
+If the result is an empty array `[]`, the test was not found in mainline. It may be a new test introduced by the PR, or a test that only runs in presubmit context.
+
+### Step 4: Classify Each Failure
+
+Using the mainline pass rate, classify each test:
+
+| Mainline Pass Rate | Classification | Meaning |
+|---|---|---|
+| Not found in mainline | **New / Presubmit-only** | Test may be new or only runs in presubmit context. Treat as suspicious — the PR author should investigate. |
+| ≥ 95% | **Suspicious (PR-related)** | Test is healthy on mainline but failing on this PR. Likely caused by the PR. |
+| 80–95% | **Possibly PR-related** | Test has some mainline instability but is mostly passing. Could be the PR or could be a flake. |
+| < 80% | **Pre-existing / Known Flaky** | Test is already struggling on mainline. Failure is likely not caused by this PR. |
+
+Additional signals that increase suspicion:
+- Test failed in **multiple job runs** on this PR (consistent failure across jobs = less likely a flake)
+- Test has **zero mainline failures** in the current period (very healthy baseline)
+- Test has `open_bugs > 0` (someone already knows about this — decreases PR suspicion)
+
+### Step 5: Produce Report
+
+Output a structured markdown report:
+
+```
+## PR Test Results Assessment: /#
+
+**Total test failures:**
+**Unique failing tests:** (analyzed top )
+**Job runs with failures:**
+**SHA:**
+
+### Summary
+
+| Classification | Count |
+|---|---|
+| Suspicious (likely PR-related) | X |
+| Possibly PR-related | X |
+| Pre-existing / Known Flaky | X |
+| New / Presubmit-only | X |
+| Infrastructure | X |
+
+### Suspicious Failures (Likely PR-Related)
+
+These tests are healthy on mainline but failing on this PR. The PR author should investigate.
+
+| Test Name | PR Failures | Mainline Pass Rate | Mainline Runs | Jobs |
+|---|---|---|---|---|
+| `` | X / Y runs | 99.5% | 3400 | |
+| ... | ... | ... | ... | ... |
+
+
+
+### Possibly PR-Related
+
+These tests have some mainline instability but are mostly passing. Worth a look if the suspicious list is empty.
+
+| Test Name | PR Failures | Mainline Pass Rate | Open Bugs |
+|---|---|---|---|
+| ... | ... | ... | ... |
+
+### Pre-Existing / Known Flaky
+
+These tests are already failing on mainline. Likely not caused by this PR.
+
+| Test Name | PR Failures | Mainline Pass Rate | Open Bugs |
+|---|---|---|---|
+| ... | ... | ... | ... |
+
+### New / Presubmit-Only Tests
+
+These tests were not found in mainline CI data. They may be new tests introduced by this PR or tests that only run in presubmit context.
+
+| Test Name | PR Failures | Jobs |
+|---|---|---|
+| ... | ... | ... |
+
+### Infrastructure Failures
+
+ infrastructure test failures across job runs. These indicate jobs that failed to set up properly (install failures, test framework issues), not specific test regressions.
+```
+
+Omit any classification section that has zero entries.
+
+### Step 6: Return the Report
+
+The report is your output to the caller. Present it directly. Do not write it to a file unless the caller asks.
+
+## Constraints
+
+- **Limit mainline lookups to top 20 failing tests** (by failure count, excluding infrastructure). Note the total count so the caller knows if more were skipped.
+- **Do not speculate on root causes.** Report what the data shows — which tests failed, how they compare to mainline. The caller or a downstream skill will do deeper analysis.
+- **Infrastructure tests are noise.** Classify them but do not investigate them. They indicate job-level problems, not test-level regressions.
+- **A test not found in mainline is not automatically safe.** It could be a new test introduced by the PR that is broken. Flag it for investigation.
+- **Don't block on missing data.** If the mainline lookup fails for a test, note it and move on. Continue with the remaining tests.
+
+## Skills Available
+
+| Skill | When to Use |
+|---|---|
+| `ci:fetch-test-report` | Query mainline pass rates for individual tests (Step 3) |
+
+## See Also
+
+- Related Skill: `assess-pr-risk` (assesses overall PR risk level and recommends testing)
+- Related Skill: `fetch-test-report` (fetches test metadata from Sippy)
diff --git a/plugins/ci/skills/assess-pr-test-results/fetch_pr_test_results.py b/plugins/ci/skills/assess-pr-test-results/fetch_pr_test_results.py
new file mode 100644
index 000000000..b1df4bbe8
--- /dev/null
+++ b/plugins/ci/skills/assess-pr-test-results/fetch_pr_test_results.py
@@ -0,0 +1,278 @@
+#!/usr/bin/env python3
+"""Fetch test results for a pull request from the Sippy API.
+
+Returns test results from presubmit job runs for a specific PR, including
+test name, status, output, job info, and SHA.
+"""
+
+import argparse
+import json
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+from datetime import datetime, timedelta, timezone
+
+SIPPY_API_BASE = "http://localhost:8080/api"
+
+
+def fetch_pr_test_results(org, repo, pr_number, sha=None,
+ include_successes=None, latest_sha_only=False):
+ """Fetch test results for a PR from the Sippy API."""
+ now = datetime.now(timezone.utc)
+ start_date = (now - timedelta(days=14)).strftime("%Y-%m-%d")
+ end_date = now.strftime("%Y-%m-%d")
+
+ params = {
+ "org": org,
+ "repo": repo,
+ "pr_number": str(pr_number),
+ "start_date": start_date,
+ "end_date": end_date,
+ }
+
+ if sha:
+ params["sha"] = sha
+ if latest_sha_only:
+ params["latest_sha_only"] = "true"
+
+ query_parts = list(urllib.parse.urlencode(params).split("&"))
+ if include_successes:
+ for pattern in include_successes:
+ query_parts.append(
+ urllib.parse.urlencode({"include_successes": pattern})
+ )
+ query_string = "&".join(query_parts)
+
+ url = f"{SIPPY_API_BASE}/pull_requests/test_results?{query_string}"
+
+ try:
+ req = urllib.request.Request(url)
+ with urllib.request.urlopen(req, timeout=60) as resp:
+ data = json.loads(resp.read().decode("utf-8"))
+ except urllib.error.HTTPError as e:
+ body = ""
+ try:
+ body = e.read().decode("utf-8")
+ err_data = json.loads(body)
+ body = err_data.get("message", body)
+ except Exception:
+ pass
+ return {
+ "success": False,
+ "error": f"HTTP {e.code}: {body or e.reason}",
+ "api_url": url,
+ }
+ except urllib.error.URLError as e:
+ return {
+ "success": False,
+ "error": f"Failed to connect to Sippy API: {e.reason}",
+ "api_url": url,
+ }
+ except Exception as e:
+ return {
+ "success": False,
+ "error": str(e),
+ "api_url": url,
+ }
+
+ if isinstance(data, dict) and "code" in data:
+ return {
+ "success": False,
+ "error": data.get("message", "Unknown API error"),
+ "api_url": url,
+ }
+
+ return {
+ "success": True,
+ "org": org,
+ "repo": repo,
+ "pr_number": pr_number,
+ "sha": sha,
+ "total_results": len(data),
+ "results": data,
+ "api_url": url,
+ }
+
+
+def format_summary(result):
+ """Format results as a human-readable summary."""
+ if not result["success"]:
+ lines = [
+ "PR Test Results - FETCH FAILED",
+ "=" * 60,
+ "",
+ f"Error: {result['error']}",
+ ]
+ return "\n".join(lines)
+
+ lines = [
+ "PR Test Results",
+ "=" * 60,
+ "",
+ f"PR: {result['org']}/{result['repo']}#{result['pr_number']}",
+ ]
+ if result.get("sha"):
+ lines.append(f"SHA: {result['sha']}")
+ lines.append(f"Total Results: {result['total_results']}")
+ lines.append("")
+
+ results = result["results"]
+ if not results:
+ lines.append("No test results found.")
+ return "\n".join(lines)
+
+ by_job = {}
+ for r in results:
+ job_key = (r["prow_job_run_id"], r["prow_job_name"])
+ by_job.setdefault(job_key, []).append(r)
+
+ shas = sorted(set(r.get("pr_sha", "") for r in results if r.get("pr_sha")))
+ if shas:
+ lines.append(f"SHAs: {', '.join(shas)}")
+ lines.append("")
+
+ status_counts = {}
+ for r in results:
+ s = r.get("status", "unknown")
+ status_counts[s] = status_counts.get(s, 0) + 1
+ lines.append("Status Breakdown:")
+ for s, c in sorted(status_counts.items()):
+ lines.append(f" {s}: {c}")
+ lines.append("")
+
+ lines.append(f"Job Runs: {len(by_job)}")
+ lines.append("")
+
+ for i, ((_run_id, job_name), tests) in enumerate(
+ sorted(by_job.items(), key=lambda x: x[1][0].get("prow_job_start", ""),
+ reverse=True)
+ ):
+ if i >= 10:
+ lines.append(f"... and {len(by_job) - 10} more job runs")
+ break
+ failures = [t for t in tests if t.get("status") == "failure"]
+ lines.append(f" {job_name}")
+ lines.append(f" URL: {tests[0].get('prow_job_url', 'N/A')}")
+ lines.append(f" SHA: {tests[0].get('pr_sha', 'N/A')}")
+ lines.append(f" Started: {tests[0].get('prow_job_start', 'N/A')}")
+ lines.append(f" Tests: {len(tests)} ({len(failures)} failures)")
+ if failures:
+ for f in failures[:5]:
+ lines.append(f" FAIL: {f['test_name']}")
+ if len(failures) > 5:
+ lines.append(f" ... and {len(failures) - 5} more failures")
+ lines.append("")
+
+ return "\n".join(lines)
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Fetch test results for a pull request from the Sippy API.",
+ )
+ parser.add_argument(
+ "pr_url_or_number",
+ help="GitHub PR URL (e.g. https://github.com/openshift/kubernetes/pull/2653) "
+ "or just the PR number (requires --org and --repo)",
+ )
+ parser.add_argument(
+ "--org",
+ default=None,
+ help="GitHub org (default: openshift). Extracted from URL if PR URL is provided.",
+ )
+ parser.add_argument(
+ "--repo",
+ default=None,
+ help="GitHub repo. Extracted from URL if PR URL is provided.",
+ )
+ parser.add_argument(
+ "--sha",
+ default=None,
+ help="Filter to a specific commit SHA",
+ )
+ parser.add_argument(
+ "--latest-sha",
+ action="store_true",
+ default=False,
+ help="Only return results for the most recent PR SHA",
+ )
+ parser.add_argument(
+ "--include-successes",
+ action="append",
+ default=None,
+ help="Include successes/flakes for tests matching this substring "
+ "(repeatable). By default only failures are returned.",
+ )
+ parser.add_argument(
+ "--format",
+ choices=["json", "summary"],
+ default="json",
+ help="Output format (default: json)",
+ )
+ parser.add_argument(
+ "--output",
+ default=None,
+ help="Write output to a file instead of stdout",
+ )
+
+ args = parser.parse_args()
+
+ pr_input = args.pr_url_or_number
+ org = args.org
+ repo = args.repo
+ pr_number = None
+
+ if pr_input.startswith("http"):
+ parts = pr_input.rstrip("/").split("/")
+ try:
+ idx = parts.index("pull")
+ org = parts[idx - 2]
+ repo = parts[idx - 1]
+ pr_number = int(parts[idx + 1])
+ except (ValueError, IndexError):
+ print(f"Error: Could not parse PR URL: {pr_input}", file=sys.stderr)
+ sys.exit(1)
+ else:
+ try:
+ pr_number = int(pr_input)
+ except ValueError:
+ print(f"Error: Invalid PR number: {pr_input}", file=sys.stderr)
+ sys.exit(1)
+
+ if org is None:
+ org = "openshift"
+ if repo is None:
+ print("Error: --repo is required when passing a PR number instead of a URL",
+ file=sys.stderr)
+ sys.exit(1)
+
+ result = fetch_pr_test_results(
+ org=org,
+ repo=repo,
+ pr_number=pr_number,
+ sha=args.sha,
+ include_successes=args.include_successes,
+ latest_sha_only=args.latest_sha,
+ )
+
+ if args.format == "json":
+ output_text = json.dumps(result, indent=2)
+ else:
+ output_text = format_summary(result)
+
+ if args.output:
+ with open(args.output, "w") as f:
+ f.write(output_text)
+ print(f"Output written to {args.output} "
+ f"({result.get('total_results', 0)} results)",
+ file=sys.stderr)
+ else:
+ print(output_text)
+
+ if not result["success"]:
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/plugins/ci/skills/fetch-jobs/SKILL.md b/plugins/ci/skills/fetch-jobs/SKILL.md
new file mode 100644
index 000000000..b0ab11664
--- /dev/null
+++ b/plugins/ci/skills/fetch-jobs/SKILL.md
@@ -0,0 +1,251 @@
+---
+name: fetch-jobs
+description: Fetch OpenShift CI job reports from Sippy with pass rates, run counts, and trend data. Use when listing jobs for a release, checking presubmit job health for a repo, finding frequently run or regressing jobs, or getting duration data for cost estimation.
+---
+
+# Fetch Jobs
+
+This skill fetches job reports from the Sippy jobs API. It returns job metadata including pass rates, run counts, and trend data comparing the current 7-day window to the previous 7-day window. It supports both release jobs (e.g., `4.19`, `5.0`) and presubmit (pull request) jobs.
+
+## Prerequisites
+
+1. **Network Access**: The Sippy API must be accessible at `https://sippy.dptools.openshift.org`
+ - No authentication required for read operations
+ - Check: `curl -s https://sippy.dptools.openshift.org/api/health?release=4.19`
+
+2. **Python 3**: Python 3.6 or later
+ - Check: `python3 --version`
+ - Uses only standard library (no external dependencies)
+
+## Implementation Steps
+
+### Step 1: Determine the Release
+
+If the user wants presubmit jobs, use `--release Presubmits`.
+
+If the user wants release jobs and did not specify a version, use the `fetch-releases` skill:
+
+```bash
+release=$(python3 plugins/ci/skills/fetch-releases/fetch_releases.py --latest)
+```
+
+If the user specified a release, use that directly (e.g., `4.19`, `5.0`).
+
+### Step 2: Run the Python Script
+
+```bash
+script_path="plugins/ci/skills/fetch-jobs/fetch_jobs.py"
+
+# Fetch release jobs sorted by most runs (default)
+python3 "$script_path" --release 5.0 --format json
+
+# Fetch presubmit jobs for a specific repo
+python3 "$script_path" --release Presubmits --repo origin --format summary
+
+# Fetch the top 20 most frequently run presubmit jobs
+python3 "$script_path" --release Presubmits --sort-field current_runs --sort desc --limit 20 --format summary
+
+# Fetch jobs sorted by worst pass rate
+python3 "$script_path" --release 4.19 --sort-field current_pass_percentage --sort asc --limit 10 --format summary
+
+# Fetch jobs sorted by biggest regression
+python3 "$script_path" --release 4.19 --sort-field net_improvement --sort asc --limit 10 --format summary
+
+# Filter jobs by name substring
+python3 "$script_path" --release 4.19 --name "e2e-aws-ovn" --format json
+```
+
+### Step 3: Parse the Output
+
+The script outputs a JSON array of job objects:
+
+```bash
+# Store JSON output for processing
+jobs=$(python3 "$script_path" --release Presubmits --repo machine-config-operator --format json)
+
+# Extract specific fields using jq
+echo "$jobs" | jq '.[0].name'
+echo "$jobs" | jq '.[0].current_pass_percentage'
+echo "$jobs" | jq '.[0].current_runs'
+
+# Get top 5 jobs by run count
+echo "$jobs" | jq 'sort_by(-.current_runs) | .[0:5] | .[] | {name, current_runs, current_pass_percentage}'
+```
+
+## API Details
+
+### Endpoint
+
+```
+GET https://sippy.dptools.openshift.org/api/jobs?release={release}&filter={filter_json}&sortField={field}&sort={asc|desc}&period={default|twoDay}&limit={n}
+```
+
+### Parameters (raw API defaults)
+
+These are the API's own defaults when a parameter is omitted. The `fetch_jobs.py` script always sends explicit values — its CLI defaults are `sortField=current_runs` and `sort=desc`.
+
+| Parameter | Required | Default | Description |
+|-----------|----------|---------|-------------|
+| `release` | Yes | - | Release version (e.g., `4.19`, `5.0`) or `Presubmits` for PR jobs |
+| `filter` | No | - | JSON-encoded filter (see filter format below) |
+| `sortField` | No | `current_pass_percentage` | Field to sort by |
+| `sort` | No | `asc` | Sort direction: `asc` or `desc` |
+| `period` | No | `default` | `default` (7-day windows) or `twoDay` (2-day current vs 7-day previous) |
+| `limit` | No | 0 (all) | Maximum results to return |
+
+### Filter Format
+
+The filter parameter is a JSON object using Sippy's standard filter syntax:
+
+```json
+{
+ "items": [
+ {
+ "columnField": "variants",
+ "operatorValue": "has entry",
+ "value": "never-stable",
+ "not": true
+ },
+ {
+ "columnField": "repo",
+ "operatorValue": "equals",
+ "value": "origin"
+ }
+ ],
+ "linkOperator": "and"
+}
+```
+
+**Filterable fields:** `name`, `brief_name`, `org`, `repo`, `variants`, `current_pass_percentage`, `current_runs`, `previous_pass_percentage`, `previous_runs`, `net_improvement`, `current_average_duration_minutes`, `previous_average_duration_minutes`, `open_bugs`
+
+**Filter operators:** `contains`, `equals`, `starts with`, `ends with`, `has entry` (arrays), `has entry containing` (arrays), `is empty`, `is not empty`, `=`, `!=`, `>`, `>=`, `<`, `<=`
+
+### Response Schema
+
+The API returns a JSON array of job objects:
+
+```json
+[
+ {
+ "id": 123,
+ "name": "periodic-ci-openshift-release-master-nightly-4.19-e2e-aws-ovn",
+ "brief_name": "e2e-aws-ovn",
+ "org": "openshift",
+ "repo": "release",
+ "variants": ["amd64", "aws", "ovn", "ha"],
+ "last_pass": "2026-05-21T10:30:00Z",
+ "current_pass_percentage": 87.5,
+ "current_projected_pass_percentage": 88.2,
+ "current_runs": 40,
+ "current_passes": 35,
+ "current_fails": 5,
+ "current_infra_fails": 0,
+ "previous_pass_percentage": 85.0,
+ "previous_projected_pass_percentage": 84.5,
+ "previous_runs": 40,
+ "previous_passes": 34,
+ "previous_fails": 6,
+ "previous_infra_fails": 0,
+ "net_improvement": 2.5,
+ "average_retests_to_merge": 1.3,
+ "current_average_duration_minutes": 154,
+ "previous_average_duration_minutes": 141,
+ "test_grid_url": "https://testgrid.k8s.io/...",
+ "open_bugs": 3
+ }
+]
+```
+
+**Key Fields:**
+- `name`: Full job name
+- `brief_name`: Short human-readable name
+- `org`, `repo`: GitHub organization and repository (useful for presubmit jobs)
+- `variants`: Array of job variant tags (e.g., platform, network, architecture)
+- `current_runs` / `previous_runs`: Number of job runs in each 7-day window
+- `current_pass_percentage` / `previous_pass_percentage`: Overall job pass rate (all tests passed)
+- `current_fails` / `previous_fails`: Number of failing runs
+- `current_infra_fails` / `previous_infra_fails`: Infrastructure-related failures (not code failures)
+- `net_improvement`: Percentage point change in pass rate from previous to current period (positive = improving)
+- `average_retests_to_merge`: Average retries needed before presubmit passes (presubmit jobs only)
+- `current_average_duration_minutes` / `previous_average_duration_minutes`: Average job duration in minutes for each period
+- `open_bugs`: Count of open related Jira bugs
+- `last_pass`: Timestamp of most recent successful run (null if never passed)
+
+## Error Handling
+
+### Case 1: Sippy Not Reachable
+
+```bash
+python3 fetch_jobs.py --release 4.19
+# Error: Failed to connect to Sippy API: [Errno 61] Connection refused
+# Check network connectivity to sippy.dptools.openshift.org.
+```
+
+### Case 2: No Jobs Found
+
+If no jobs match the filters, the script returns an empty JSON array `[]` or "No jobs found matching the given criteria." in summary mode.
+
+### Case 3: Missing Release
+
+```bash
+python3 fetch_jobs.py
+# Using latest release: 5.0 (auto-detected)
+```
+
+**Exit Codes:**
+- `0`: Success
+- `1`: Error (API error, network error, etc.)
+
+## Examples
+
+### Example 1: List Presubmit Jobs for a Repo
+
+```bash
+python3 plugins/ci/skills/fetch-jobs/fetch_jobs.py \
+ --release Presubmits --repo origin --format summary
+```
+
+### Example 2: Top 10 Most Frequently Run Release Jobs
+
+```bash
+python3 plugins/ci/skills/fetch-jobs/fetch_jobs.py \
+ --release 4.19 --sort-field current_runs --sort desc --limit 10 --format summary
+```
+
+### Example 3: Jobs With Biggest Regressions
+
+```bash
+python3 plugins/ci/skills/fetch-jobs/fetch_jobs.py \
+ --release 5.0 --sort-field net_improvement --sort asc --limit 10 --format summary
+```
+
+### Example 4: Filter Jobs by Name
+
+```bash
+python3 plugins/ci/skills/fetch-jobs/fetch_jobs.py \
+ --release 4.19 --name "upgrade" --format json
+```
+
+### Example 5: Presubmit Jobs Using 2-Day Window
+
+```bash
+python3 plugins/ci/skills/fetch-jobs/fetch_jobs.py \
+ --release Presubmits --repo machine-config-operator --period twoDay --format summary
+```
+
+## Notes
+
+- Never-stable jobs are excluded by default — use `--include-never-stable` to include them
+- For presubmit jobs, use `--release Presubmits` (case-sensitive)
+- The `repo` filter matches the repository name only (e.g., `origin`), not the full org/repo path
+- `current_*` fields cover the last 7 days; `previous_*` fields cover the 7 days before that (with `period=default`)
+- With `period=twoDay`, current covers the last 2 days and previous covers the 7 days before that
+- The `net_improvement` field is the difference in pass percentage between periods — negative values indicate regression
+- `average_retests_to_merge` is primarily meaningful for presubmit jobs
+
+## See Also
+
+- Related Skill: `fetch-releases` (determines the latest OCP release)
+- Related Skill: `fetch-test-report` (fetches test-level pass rates)
+- Related Skill: `fetch-job-run-summary` (fetches details for a specific job run)
+- Related Agent: `pr-risk-analyst` (uses job data for PR risk assessment)
diff --git a/plugins/ci/skills/fetch-jobs/fetch_jobs.py b/plugins/ci/skills/fetch-jobs/fetch_jobs.py
new file mode 100644
index 000000000..5fe6694f3
--- /dev/null
+++ b/plugins/ci/skills/fetch-jobs/fetch_jobs.py
@@ -0,0 +1,262 @@
+#!/usr/bin/env python3
+"""Fetch OpenShift CI job reports from the Sippy jobs API.
+
+Returns job metadata including pass rates, run counts, and trend data
+for the current and previous reporting periods.
+"""
+
+import argparse
+import json
+import re
+import sys
+import urllib.error
+import urllib.parse
+import urllib.request
+
+SIPPY_API_BASE = "https://sippy.dptools.openshift.org/api"
+SIPPY_RELEASES_URL = "https://sippy.dptools.openshift.org/api/releases"
+
+
+def get_latest_release() -> str:
+ """Fetch the latest OCP release version from the Sippy releases API."""
+ try:
+ with urllib.request.urlopen(SIPPY_RELEASES_URL, timeout=10) as resp:
+ data = json.loads(resp.read().decode("utf-8"))
+ except Exception as e:
+ print(f"Error: Could not fetch releases from Sippy API: {e}", file=sys.stderr)
+ sys.exit(1)
+
+ releases = data.get("releases", [])
+ ocp_releases = [r for r in releases if re.match(r"^\d+\.\d+$", r)]
+ if not ocp_releases:
+ print("Error: No OCP releases found in Sippy API.", file=sys.stderr)
+ sys.exit(1)
+
+ return ocp_releases[0]
+
+
+def build_filter(name: str = None, repo: str = None, exclude_never_stable: bool = True) -> str:
+ """Build a Sippy filter JSON string."""
+ items = []
+
+ if exclude_never_stable:
+ items.append({
+ "columnField": "variants",
+ "operatorValue": "has entry",
+ "value": "never-stable",
+ "not": True,
+ })
+
+ if name:
+ items.append({
+ "columnField": "name",
+ "operatorValue": "contains",
+ "value": name,
+ })
+
+ if repo:
+ items.append({
+ "columnField": "repo",
+ "operatorValue": "equals",
+ "value": repo,
+ })
+
+ if not items:
+ return ""
+
+ return json.dumps({"items": items, "linkOperator": "and"})
+
+
+def fetch_jobs(release: str, filter_str: str = "", sort_field: str = "current_runs",
+ sort_dir: str = "desc", period: str = "default", limit: int = 0) -> list:
+ """Fetch jobs from the Sippy jobs API."""
+ query = {"release": release, "period": period, "sortField": sort_field, "sort": sort_dir}
+
+ if filter_str:
+ query["filter"] = filter_str
+ if limit > 0:
+ query["limit"] = str(limit)
+
+ params = urllib.parse.urlencode(query)
+ url = f"{SIPPY_API_BASE}/jobs?{params}"
+
+ try:
+ req = urllib.request.Request(url)
+ with urllib.request.urlopen(req, timeout=30) as resp:
+ data = json.loads(resp.read().decode("utf-8"))
+ except urllib.error.HTTPError as e:
+ print(f"Error: HTTP {e.code} from Sippy API: {e.reason}", file=sys.stderr)
+ sys.exit(1)
+ except urllib.error.URLError as e:
+ print(f"Error: Failed to connect to Sippy API: {e.reason}", file=sys.stderr)
+ print("Check network connectivity to sippy.dptools.openshift.org.", file=sys.stderr)
+ sys.exit(1)
+ except Exception as e:
+ print(f"Error: {e}", file=sys.stderr)
+ sys.exit(1)
+
+ if not isinstance(data, list):
+ print("Error: Unexpected API response format (expected a JSON array).", file=sys.stderr)
+ sys.exit(1)
+
+ return data
+
+
+def format_summary(jobs: list) -> str:
+ """Format job results as a human-readable summary."""
+ if not jobs:
+ return "No jobs found matching the given criteria."
+
+ lines = []
+ lines.append(f"Found {len(jobs)} jobs:\n")
+
+ for i, j in enumerate(jobs):
+ if i > 0:
+ lines.append("-" * 70)
+
+ lines.append(f"Job: {j.get('name', 'N/A')}")
+ brief = j.get("brief_name", "")
+ if brief:
+ lines.append(f" Brief Name: {brief}")
+
+ org = j.get("org", "")
+ repo = j.get("repo", "")
+ if org or repo:
+ lines.append(f" Repo: {org}/{repo}" if org else f" Repo: {repo}")
+
+ variants = j.get("variants")
+ if variants:
+ lines.append(f" Variants: {', '.join(variants)}")
+
+ open_bugs = j.get("open_bugs", 0)
+ if open_bugs > 0:
+ lines.append(f" Open Bugs: {open_bugs}")
+
+ lines.append("")
+ lines.append(f" Current Period (last 7 days):")
+ lines.append(f" Runs: {j.get('current_runs', 0)}")
+ lines.append(f" Pass Rate: {j.get('current_pass_percentage', 0):.2f}%")
+ lines.append(f" Passes: {j.get('current_passes', 0)}")
+ lines.append(f" Fails: {j.get('current_fails', 0)}")
+ infra = j.get("current_infra_fails", 0)
+ if infra > 0:
+ lines.append(f" Infra Fails: {infra}")
+
+ lines.append("")
+ lines.append(f" Previous Period (7 days before current):")
+ lines.append(f" Runs: {j.get('previous_runs', 0)}")
+ lines.append(f" Pass Rate: {j.get('previous_pass_percentage', 0):.2f}%")
+ lines.append(f" Passes: {j.get('previous_passes', 0)}")
+ lines.append(f" Fails: {j.get('previous_fails', 0)}")
+ prev_infra = j.get("previous_infra_fails", 0)
+ if prev_infra > 0:
+ lines.append(f" Infra Fails: {prev_infra}")
+
+ cur_dur = j.get("current_average_duration_minutes")
+ prev_dur = j.get("previous_average_duration_minutes")
+ if cur_dur is not None:
+ dur_line = f" Avg Duration: {cur_dur} min"
+ if prev_dur is not None:
+ dur_line += f" (prev: {prev_dur} min)"
+ lines.append(dur_line)
+
+ lines.append("")
+ net = j.get("net_improvement", 0)
+ direction = "improved" if net > 0 else "regressed" if net < 0 else "unchanged"
+ lines.append(f" Trend: {direction} ({net:+.2f}%)")
+
+ retests = j.get("average_retests_to_merge", 0)
+ if retests and retests > 0:
+ lines.append(f" Avg Retests to Merge: {retests:.1f}")
+
+ lines.append("")
+
+ return "\n".join(lines)
+
+
+def main():
+ parser = argparse.ArgumentParser(
+ description="Fetch OpenShift CI job reports from the Sippy jobs API.",
+ )
+ parser.add_argument(
+ "--release",
+ default=None,
+ help="OpenShift release version (e.g., 4.19) or 'Presubmits' for pull request jobs. "
+ "If omitted, the latest release is auto-detected.",
+ )
+ parser.add_argument(
+ "--name",
+ default=None,
+ help="Filter jobs by name (substring match).",
+ )
+ parser.add_argument(
+ "--repo",
+ default=None,
+ help="Filter presubmit jobs by repository name (e.g., 'origin', 'machine-config-operator').",
+ )
+ parser.add_argument(
+ "--sort-field",
+ default="current_runs",
+ help="Field to sort by (default: current_runs). Common: current_runs, current_pass_percentage, "
+ "net_improvement, name.",
+ )
+ parser.add_argument(
+ "--sort",
+ choices=["asc", "desc"],
+ default="desc",
+ help="Sort direction (default: desc).",
+ )
+ parser.add_argument(
+ "--period",
+ choices=["default", "twoDay"],
+ default="default",
+ help="Reporting period (default: 7-day windows, twoDay: 2-day current vs 7-day previous).",
+ )
+ parser.add_argument(
+ "--limit",
+ type=int,
+ default=0,
+ help="Maximum number of jobs to return (default: no limit).",
+ )
+ parser.add_argument(
+ "--include-never-stable",
+ action="store_true",
+ help="Include never-stable jobs (excluded by default).",
+ )
+ parser.add_argument(
+ "--format",
+ choices=["json", "summary"],
+ default="json",
+ help="Output format (default: json).",
+ )
+
+ args = parser.parse_args()
+
+ release = args.release
+ if release is None:
+ release = get_latest_release()
+ print(f"Using latest release: {release}", file=sys.stderr)
+
+ filter_str = build_filter(
+ name=args.name,
+ repo=args.repo,
+ exclude_never_stable=not args.include_never_stable,
+ )
+
+ jobs = fetch_jobs(
+ release=release,
+ filter_str=filter_str,
+ sort_field=args.sort_field,
+ sort_dir=args.sort,
+ period=args.period,
+ limit=args.limit,
+ )
+
+ if args.format == "json":
+ print(json.dumps(jobs, indent=2))
+ else:
+ print(format_summary(jobs))
+
+
+if __name__ == "__main__":
+ main()
diff --git a/plugins/ci/skills/prow-job-cost-estimator/SKILL.md b/plugins/ci/skills/prow-job-cost-estimator/SKILL.md
new file mode 100644
index 000000000..a5cbae383
--- /dev/null
+++ b/plugins/ci/skills/prow-job-cost-estimator/SKILL.md
@@ -0,0 +1,135 @@
+---
+name: prow-job-cost-estimator
+description: Estimate cloud compute cost for OpenShift CI prow jobs based on duration and platform
+---
+
+# Prow Job Cost Estimator
+
+Estimates the cloud compute cost of running OpenShift CI prow jobs. Takes a list of jobs with their durations and run/skip decisions, and returns per-job cost breakdowns plus aggregate totals for recommended cost, savings from skipped required jobs, and added cost from optional jobs triggered.
+
+## When to Use This Skill
+
+Use this skill when you need to:
+
+- Estimate how much a set of CI jobs will cost to run
+- Calculate savings from skipping unnecessary e2e jobs
+- Compare the cost of different testing strategies
+- Report cost impact of job recommendations
+
+## Prerequisites
+
+1. **Python 3**: Python 3.6 or later (uses only standard library)
+2. **Job data**: A JSON array of job objects with name, duration, decision, and CI status
+
+## Implementation Steps
+
+### Step 1: Prepare the Jobs Input
+
+Create a JSON array of job objects. Each object must have:
+
+| Field | Type | Description |
+|-------|------|-------------|
+| `name` | string | Job name (platform is inferred from this) |
+| `duration_minutes` | number | Average runtime in minutes (from Sippy `current_average_duration_minutes` or estimated) |
+| `decision` | string | `"run"` or `"skip"` |
+| `ci_status` | string | `"required"` or `"optional"` (from CI config `optional` field) |
+
+Example input:
+
+```json
+[
+ {"name": "e2e-aws-ovn-fips", "duration_minutes": 182, "decision": "skip", "ci_status": "required"},
+ {"name": "e2e-gcp-ovn", "duration_minutes": 180, "decision": "run", "ci_status": "required"},
+ {"name": "e2e-vsphere-ovn", "duration_minutes": 165, "decision": "skip", "ci_status": "required"}
+]
+```
+
+### Step 2: Run the Estimator
+
+```bash
+script_path="plugins/ci/skills/prow-job-cost-estimator/estimate_cost.py"
+
+# From a file
+python3 "$script_path" --input /tmp/pr-risk-jobs.json --format summary
+
+# From stdin
+echo '' | python3 "$script_path" --format json
+
+# JSON output for programmatic use
+python3 "$script_path" --input /tmp/pr-risk-jobs.json --format json
+```
+
+### Step 3: Use the Output
+
+**Summary format** prints human-readable per-job costs and totals:
+
+```
+Per-job breakdown:
+ e2e-aws-ovn-fips: 182 min x $1/hr (aws) = $3.03 [skip]
+ e2e-gcp-ovn: 180 min x $2/hr (gcp) = $6.00 [run]
+ e2e-vsphere-ovn: 165 min x $4/hr (vsphere) = $11.00 [skip]
+
+Recommended cost: $6.00
+Savings (skipped required): $14.03
+Added (optional triggered): $0.00
+Net savings: $14.03
+```
+
+**JSON format** returns a structured object:
+
+```json
+{
+ "recommended_cost_usd": 6.00,
+ "savings_from_skipped_required_usd": 14.03,
+ "added_cost_from_optional_usd": 0.00,
+ "net_savings_usd": 14.03,
+ "jobs": [
+ {
+ "name": "e2e-aws-ovn-fips",
+ "duration_minutes": 182,
+ "decision": "skip",
+ "ci_status": "required",
+ "platform": "aws",
+ "rate_per_hr": 1,
+ "cost_usd": 3.03
+ }
+ ]
+}
+```
+
+## Platform Rates
+
+The estimator uses fixed per-job hourly rates. These are simplified cost units representing relative cloud expense, not actual VM pricing:
+
+| Platform | Rate | Inferred when job name contains |
+|------------|-----------|----------------------------------|
+| MicroShift | $0.15/hr | `microshift` |
+| AWS | $1/hr | `aws` |
+| GCP | $2/hr | `gcp` |
+| Azure | $2/hr | `azure` |
+| Metal | $3/hr | `metal` |
+| vSphere | $4/hr | `vsphere` |
+
+MicroShift is matched first — a job named `e2e-aws-ovn-microshift` matches `microshift`, not `aws`.
+
+If no platform keyword is found in the job name, AWS ($1/hr) is assumed.
+
+**About MicroShift:** MicroShift is a single-node, minimal OpenShift distribution. The only OpenShift kube APIs available are Route and SecurityContextConstraints — all other OpenShift-specific APIs (OLM, Machine API, Console, Monitoring, ImageRegistry, Samples operator, etc.) are unavailable. This makes MicroShift unsuitable for testing most PRs, but for changes that only touch Route or SCC code paths, it is by far the most cost-effective testing option.
+
+## Cost Definitions
+
+- **Recommended cost** — total cost of all jobs marked `"run"`
+- **Savings from skipped required** — cost of jobs with `ci_status: "required"` that were marked `"skip"` (these would have run by default without intervention)
+- **Added cost from optional** — cost of jobs with `ci_status: "optional"` that were marked `"run"` (these would not have run without intervention)
+- **Net savings** — savings minus added cost (positive = saved money, negative = spent more than default)
+
+## Error Handling
+
+- Missing `name` or `duration_minutes` fields will raise a KeyError
+- Invalid JSON input will raise a JSONDecodeError
+- Exit code 0 on success, 1 on error
+
+## See Also
+
+- Related Skill: `fetch-jobs` (fetches job durations from Sippy)
+- Related Skill: `assess-pr-risk` (uses cost estimates in PR risk reports)
diff --git a/plugins/ci/skills/prow-job-cost-estimator/estimate_cost.py b/plugins/ci/skills/prow-job-cost-estimator/estimate_cost.py
new file mode 100644
index 000000000..2cfc1e402
--- /dev/null
+++ b/plugins/ci/skills/prow-job-cost-estimator/estimate_cost.py
@@ -0,0 +1,97 @@
+#!/usr/bin/env python3
+"""Estimate cloud cost for OpenShift CI prow jobs based on duration and platform.
+
+Accepts a JSON array of jobs on stdin or via --input file. Each job object must have:
+ - name (string): job name, used to infer platform
+ - duration_minutes (number): average runtime in minutes
+ - decision (string): "run" or "skip"
+ - ci_status (string): "required" or "optional"
+
+Outputs per-job cost breakdown and totals.
+"""
+
+import argparse
+import json
+import sys
+
+RATES = [
+ ("microshift", 0.15),
+ ("aws", 1),
+ ("gcp", 2),
+ ("azure", 2),
+ ("metal", 3),
+ ("vsphere", 4),
+]
+
+
+def get_rate(name):
+ lower = name.lower()
+ for platform, rate in RATES:
+ if platform in lower:
+ return platform, rate
+ return "aws", 1
+
+
+VALID_DECISIONS = {"run", "skip"}
+VALID_CI_STATUSES = {"required", "optional"}
+
+
+def estimate(jobs):
+ for j in jobs:
+ decision = j.get("decision", "")
+ ci_status = j.get("ci_status", "")
+ if decision not in VALID_DECISIONS:
+ raise ValueError(f"Job {j.get('name', '?')}: invalid decision {decision!r}, expected one of {VALID_DECISIONS}")
+ if ci_status not in VALID_CI_STATUSES:
+ raise ValueError(f"Job {j.get('name', '?')}: invalid ci_status {ci_status!r}, expected one of {VALID_CI_STATUSES}")
+ platform, rate = get_rate(j["name"])
+ j["platform"] = platform
+ j["rate_per_hr"] = rate
+ j["cost_usd"] = round((j["duration_minutes"] / 60) * rate, 2)
+
+ run_jobs = [j for j in jobs if j["decision"] == "run"]
+ skip_jobs = [j for j in jobs if j["decision"] == "skip"]
+
+ recommended_cost = round(sum(j["cost_usd"] for j in run_jobs), 2)
+ savings = round(sum(j["cost_usd"] for j in skip_jobs if j["ci_status"] == "required"), 2)
+ added = round(sum(j["cost_usd"] for j in run_jobs if j["ci_status"] == "optional"), 2)
+ net_savings = round(savings - added, 2)
+
+ return {
+ "recommended_cost_usd": recommended_cost,
+ "savings_from_skipped_required_usd": savings,
+ "added_cost_from_optional_usd": added,
+ "net_savings_usd": net_savings,
+ "jobs": jobs,
+ }
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Estimate prow job cloud costs.")
+ parser.add_argument("--input", default=None, help="Path to JSON file with jobs array (default: stdin)")
+ parser.add_argument("--format", choices=["json", "summary"], default="summary", help="Output format")
+ args = parser.parse_args()
+
+ if args.input:
+ with open(args.input) as f:
+ jobs = json.load(f)
+ else:
+ jobs = json.load(sys.stdin)
+
+ result = estimate(jobs)
+
+ if args.format == "json":
+ print(json.dumps(result, indent=2))
+ else:
+ print("Per-job breakdown:")
+ for j in result["jobs"]:
+ print(f" {j['name']}: {j['duration_minutes']} min x ${j['rate_per_hr']}/hr ({j['platform']}) = ${j['cost_usd']:.2f} [{j['decision']}]")
+ print()
+ print(f"Recommended cost: ${result['recommended_cost_usd']:.2f}")
+ print(f"Savings (skipped required): ${result['savings_from_skipped_required_usd']:.2f}")
+ print(f"Added (optional triggered): ${result['added_cost_from_optional_usd']:.2f}")
+ print(f"Net savings: ${result['net_savings_usd']:.2f}")
+
+
+if __name__ == "__main__":
+ main()