-
Notifications
You must be signed in to change notification settings - Fork 25
feat: extract CI lifecycle and Prow job management skills from openshift/release #25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 19 commits
Commits
Show all changes
27 commits
Select commit
Hold shift + click to select a range
a386e38
feat: extract CI lifecycle and Prow job management skills from opensh…
zdrapela 3ed1915
fix: mark pre-commit hook as executable in git index
zdrapela 7e5a2b5
refactor: add shared Python modules for repo resolution and OCP lifec…
zdrapela 8531321
refactor: rewrite lifecycle-aks and lifecycle-eks scripts in Python
zdrapela 46a7f2c
refactor: rewrite K8s test config listing scripts in Python
zdrapela 80196a3
refactor: rewrite prow-ocp-jobs scripts in Python
zdrapela 4ff58cc
refactor: rewrite prow-ocp-pools scripts in Python
zdrapela be3431a
refactor: rewrite prow-ocp-coverage analysis script in Python
zdrapela 8784234
docs: update SKILL.md files to reference Python scripts
zdrapela 09752d2
refactor: remove bash/jq scripts replaced by Python equivalents
zdrapela 9a477cb
fix: use relative script paths per Agent Skills spec
zdrapela 1176606
fix: unify all scripts to use uv run for consistent invocation
zdrapela a4dd8c6
feat: add lifecycle-rhdh skill and split lifecycle-ocp into focused s…
zdrapela 13a5d61
refactor: unify Red Hat Product Life Cycles API access in redhat_life…
zdrapela 4e78ad1
feat: add lifecycle-redhat skill for generic Red Hat product lifecycl…
zdrapela 9796bc1
feat: add lifecycle-pg skill for PostgreSQL lifecycle across cloud pr…
zdrapela 7b87715
feat: add --json flag to AKS and EKS lifecycle scripts
zdrapela b14ec7d
refactor: split _shared/ flat modules into rhdh_lifecycle and rhdh_pr…
zdrapela d98e8f0
refactor: move pg_lifecycle into lifecycle-pg skill, remove dead code
zdrapela 385f50d
fix: address PR review comments on shared library code
zdrapela e987168
refactor: consolidate 14 skills into 2 with workflow routing
zdrapela bcdb0af
fix: remove redundant sys.path.insert from all scripts
zdrapela e489b60
fix: address skill-maker review findings
zdrapela 3822c23
refactor: make lifecycle the canonical owner of repo/YAML utilities
zdrapela 56e08bf
fix: address durandom review feedback
zdrapela 8a51130
refactor: deduplicate fetch_json, EOL filtering, and version sort
zdrapela d4b252e
refactor: extract generic utilities into utils.py modules
zdrapela File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Empty file.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| """RHDH lifecycle data from external APIs (Red Hat, endoflife.date).""" |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| #!/usr/bin/env python3 | ||
| """OCP lifecycle phase classification. | ||
|
|
||
| Classifies OCP versions from the Red Hat Product Life Cycles API into | ||
| lifecycle phases (Full Support, Maintenance, EUS, End of life). | ||
|
|
||
| Usage: | ||
| from rhdh_lifecycle.ocp import classify_ocp_versions | ||
| from rhdh_lifecycle.redhat import fetch_api | ||
| api_data = fetch_api("Red Hat OpenShift Container Platform") | ||
| versions = classify_ocp_versions(api_data, "2025-05-13") | ||
| """ | ||
|
|
||
| import re | ||
|
|
||
| from rhdh_lifecycle.redhat import _is_date, _to_date | ||
|
|
||
|
|
||
| def classify_ocp_versions(api_data, today): | ||
| """Classify OCP versions from the Red Hat Product Life Cycles API. | ||
|
|
||
| Args: | ||
| api_data: Raw API response dict (the full JSON response). | ||
| today: Date string in YYYY-MM-DD format. | ||
|
|
||
| Returns: | ||
| List of dicts with keys: version, ocp_supported, phase, | ||
| ga_date, end_of_support_date. Sorted by version. | ||
| """ | ||
| versions = api_data.get("data", [{}])[0].get("versions", []) | ||
|
|
||
| # Keep only clean X.Y version names, skip variants like "4.6 EUS" or "3" | ||
| versions = [v for v in versions if re.match(r"^\d+\.\d+$", v.get("name", ""))] | ||
|
|
||
| # Filter to OCP 4.x and above (future-proof for 5.x+) | ||
| versions = [v for v in versions if int(v["name"].split(".")[0]) >= 4] | ||
|
|
||
| phase_order = [ | ||
| "Extended update support Term 2", | ||
| "Extended update support", | ||
| "Maintenance support", | ||
| "Full support", | ||
| ] | ||
|
|
||
| results = [] | ||
| for ver in versions: | ||
| phases = ver.get("phases", []) | ||
|
|
||
| # Find latest end-of-support date across all support phases | ||
| end_dates = [] | ||
| for pname in phase_order: | ||
| for p in phases: | ||
| if p.get("name") == pname: | ||
| d = _to_date(p.get("end_date")) | ||
| if d: | ||
| end_dates.append(d) | ||
| end_of_support = max(end_dates) if end_dates else None | ||
|
|
||
| # GA date | ||
| ga_raw = None | ||
| for p in phases: | ||
| if p.get("name") == "General availability": | ||
| ga_raw = p.get("end_date", "N/A") | ||
| break | ||
|
|
||
| # Determine current phase | ||
| current_phase = "End of life" | ||
| for pname in phase_order: | ||
| for p in phases: | ||
| if p.get("name") != pname: | ||
| continue | ||
| start = _to_date(p.get("start_date")) | ||
| end_raw = p.get("end_date") | ||
| end = _to_date(end_raw) | ||
| if start and start <= today: | ||
| if end and end >= today: | ||
| current_phase = pname | ||
| break | ||
| elif not _is_date(end_raw) and end_raw not in ( | ||
| "N/A", | ||
| "", | ||
| None, | ||
| ): | ||
| # Non-date end value (e.g., "Ongoing") means still active | ||
| current_phase = pname | ||
| break | ||
| if current_phase != "End of life": | ||
| break | ||
|
|
||
| results.append( | ||
| { | ||
| "version": ver["name"], | ||
| "ocp_supported": current_phase != "End of life", | ||
| "phase": current_phase, | ||
| "ga_date": _to_date(ga_raw) if _is_date(ga_raw) else "N/A", | ||
| "end_of_support_date": end_of_support or "N/A", | ||
| } | ||
| ) | ||
|
|
||
| # Sort by version | ||
| results.sort(key=lambda v: [int(x) for x in v["version"].split(".")]) | ||
| return results | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,205 @@ | ||
| #!/usr/bin/env python3 | ||
| """Unified client for the Red Hat Product Life Cycles API. | ||
|
|
||
| Fetches lifecycle data for any Red Hat product (RHDH, OCP, RHBK, Quay, etc.) | ||
| and returns a consistent structure. Product-specific post-processing functions | ||
| handle cases like RHBK major version grouping or RHDH OCP compatibility. | ||
|
|
||
| Usage: | ||
| from rhdh_lifecycle.redhat import fetch_product_lifecycle | ||
|
|
||
| versions = fetch_product_lifecycle("rhbk") | ||
| versions = fetch_product_lifecycle("Red Hat Quay") | ||
| versions = fetch_product_lifecycle("ocp", filter_version="4.16") | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import re | ||
| import sys | ||
| import urllib.error | ||
| import urllib.request | ||
|
|
||
| LIFECYCLE_API_URL = "https://access.redhat.com/product-life-cycles/api/v1/products" | ||
|
|
||
| PRODUCT_ALIASES = { | ||
| "rhdh": "Red Hat Developer Hub", | ||
| "ocp": "Red Hat OpenShift Container Platform", | ||
| "rhbk": "Red Hat build of Keycloak", | ||
| "quay": "Red Hat Quay", | ||
| "rosa": "Red Hat OpenShift Service on AWS", | ||
| "osd": "Red Hat OpenShift Dedicated", | ||
| } | ||
|
|
||
|
|
||
| def _is_date(val): | ||
| """Return True if val looks like a YYYY-MM-DD date string.""" | ||
| if not val or not isinstance(val, str): | ||
| return False | ||
| return bool(re.match(r"^\d{4}-\d{2}-\d{2}", val)) | ||
|
|
||
|
|
||
| def _to_date(val): | ||
| """Extract YYYY-MM-DD from a date string, or None.""" | ||
| if _is_date(val): | ||
| return val[:10] | ||
| return None | ||
|
|
||
|
|
||
| def _phase_date(phases, phase_name): | ||
| """Extract the end_date for a named phase, formatted as YYYY-MM-DD or raw string.""" | ||
| for p in phases: | ||
| if p.get("name") == phase_name: | ||
| d = p.get("end_date", "N/A") | ||
| if d and isinstance(d, str) and _is_date(d): | ||
| return d[:10] | ||
| return str(d) if d else "N/A" | ||
| return "N/A" | ||
|
|
||
|
|
||
| def _ver_sort_key(version_str): | ||
| """Sort key for version strings like '4.16' or '26.2'.""" | ||
| try: | ||
| return [int(x) for x in version_str.split(".")] | ||
| except ValueError: | ||
| return [0] | ||
|
|
||
|
|
||
| def resolve_product_name(product): | ||
| """Resolve a product alias to the full API product name.""" | ||
| return PRODUCT_ALIASES.get(product.lower(), product) | ||
|
|
||
|
|
||
| def fetch_api(product_name): | ||
| """Fetch raw lifecycle data from the Red Hat Product Life Cycles API.""" | ||
| url = f"{LIFECYCLE_API_URL}?name={product_name.replace(' ', '+')}" | ||
| req = urllib.request.Request( | ||
| url, headers={"Accept": "application/json", "User-Agent": "rhdh-skill"} | ||
| ) | ||
| try: | ||
| with urllib.request.urlopen(req, timeout=30) as resp: | ||
| return json.loads(resp.read().decode("utf-8")) | ||
| except (urllib.error.URLError, OSError) as exc: | ||
| print(f"ERROR: Failed to fetch lifecycle data for {product_name}: {exc}", file=sys.stderr) | ||
| sys.exit(1) | ||
|
zdrapela marked this conversation as resolved.
Outdated
|
||
|
|
||
|
|
||
| def parse_versions(api_data, filter_version=None): | ||
| """Parse raw API response into a consistent list of version dicts. | ||
|
|
||
| Returns a list of dicts with keys: version, type, supported, ga_date, | ||
| end_date, phases (dict of phase_name -> end_date), extra (dict for | ||
| product-specific fields like ocp_versions). | ||
| """ | ||
| data_list = api_data.get("data", []) | ||
| if not data_list: | ||
| return [] | ||
| versions_raw = data_list[0].get("versions", []) | ||
|
|
||
| results = [] | ||
| for ver in versions_raw: | ||
| name = ver.get("name", "") | ||
| if filter_version and name != filter_version: | ||
| continue | ||
| vtype = ver.get("type", "") | ||
| raw_phases = ver.get("phases", []) | ||
|
|
||
| # Build phases dict | ||
| phases = {} | ||
| for p in raw_phases: | ||
| pname = p.get("name", "") | ||
| if pname: | ||
| phases[pname] = _phase_date(raw_phases, pname) | ||
|
|
||
| # GA date | ||
| ga_date = phases.get("General availability", "N/A") | ||
|
|
||
| # Latest end-of-support date across all non-GA phases | ||
| end_dates = [_to_date(d) for d in phases.values() if _to_date(d) and d != ga_date] | ||
| end_date = max(end_dates) if end_dates else "N/A" | ||
|
|
||
| # Product-specific extra fields | ||
| extra = {} | ||
| ocp_compat = ver.get("openshift_compatibility", "") | ||
| if ocp_compat: | ||
| extra["ocp_versions"] = [v.strip() for v in ocp_compat.split(",") if v.strip()] | ||
|
|
||
| results.append( | ||
| { | ||
| "version": name, | ||
| "type": vtype, | ||
| "supported": vtype != "End of life", | ||
| "ga_date": ga_date, | ||
| "end_date": end_date, | ||
| "phases": phases, | ||
| "extra": extra, | ||
| } | ||
| ) | ||
|
|
||
| results.sort(key=lambda v: _ver_sort_key(v["version"])) | ||
| return results | ||
|
|
||
|
|
||
| def fetch_product_lifecycle(product, filter_version=None): | ||
| """Fetch and parse lifecycle data for a Red Hat product. | ||
|
|
||
| Args: | ||
| product: Product alias ("rhbk", "quay", "rhdh", "ocp") or full name. | ||
| filter_version: Optional version string to filter to. | ||
|
|
||
| Returns: | ||
| List of version dicts with consistent shape. | ||
| """ | ||
| full_name = resolve_product_name(product) | ||
| api_data = fetch_api(full_name) | ||
| return parse_versions(api_data, filter_version) | ||
|
|
||
|
|
||
| def rhbk_major_versions(versions): | ||
| """Group RHBK minor versions into major version summaries. | ||
|
|
||
| A major version is "active" if at least one of its minor releases | ||
| is not end-of-life. | ||
|
|
||
| Returns: | ||
| List of dicts: {major_version, active, ga_date, end_date, minor_releases} | ||
| """ | ||
| groups = {} | ||
| for v in versions: | ||
| # Skip umbrella entries like "26.x" | ||
| if "x" in v["version"] or not re.match(r"^\d+\.\d+$", v["version"]): | ||
| continue | ||
| major = v["version"].split(".")[0] | ||
| if major not in groups: | ||
| groups[major] = { | ||
| "minor_releases": [], | ||
| "any_active": False, | ||
| "ga_dates": [], | ||
| "end_dates": [], | ||
| } | ||
| groups[major]["minor_releases"].append(v["version"]) | ||
| if v["supported"]: | ||
| groups[major]["any_active"] = True | ||
| if v["ga_date"] != "N/A": | ||
| groups[major]["ga_dates"].append(v["ga_date"]) | ||
| if v["end_date"] != "N/A": | ||
| groups[major]["end_dates"].append(v["end_date"]) | ||
|
|
||
| results = [] | ||
| for major, info in sorted(groups.items(), key=lambda x: int(x[0])): | ||
| results.append( | ||
| { | ||
| "major_version": major, | ||
| "active": info["any_active"], | ||
| "ga_date": min(info["ga_dates"]) if info["ga_dates"] else "N/A", | ||
| "end_date": max(info["end_dates"]) if info["end_dates"] else "N/A", | ||
| "minor_releases": sorted(info["minor_releases"], key=_ver_sort_key), | ||
| } | ||
| ) | ||
| return results | ||
|
|
||
|
|
||
| def list_known_products(): | ||
| """Return sorted list of known product aliases and their full names.""" | ||
| return sorted(PRODUCT_ALIASES.items()) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| #!/usr/bin/env python3 | ||
| """RHDH release lifecycle data -- wrapper around the generic Red Hat API client. | ||
|
|
||
| Usage: | ||
| from rhdh_lifecycle.rhdh import fetch_rhdh_lifecycle | ||
| versions = fetch_rhdh_lifecycle() | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from rhdh_lifecycle.redhat import fetch_api, fetch_product_lifecycle, parse_versions | ||
|
|
||
|
|
||
| def fetch_rhdh_lifecycle(filter_version=None): | ||
| """Fetch and parse RHDH lifecycle data.""" | ||
| versions = fetch_product_lifecycle("rhdh", filter_version) | ||
| # Flatten extra.ocp_versions into top-level for convenience | ||
| for v in versions: | ||
| v["ocp_versions"] = v.get("extra", {}).get("ocp_versions", []) | ||
| v["full_support_end"] = v.get("phases", {}).get("Full support", "N/A") | ||
| v["maintenance_end"] = v.get("phases", {}).get("Maintenance support", "N/A") | ||
| return versions | ||
|
|
||
|
|
||
| def fetch_lifecycle_api(product_name): | ||
| """Fetch raw API data. Delegates to redhat module.""" | ||
| return fetch_api(product_name) | ||
|
|
||
|
|
||
| def parse_rhdh_versions(api_data, filter_version=None): | ||
| """Parse RHDH versions from raw API data.""" | ||
| versions = parse_versions(api_data, filter_version) | ||
| for v in versions: | ||
| v["ocp_versions"] = v.get("extra", {}).get("ocp_versions", []) | ||
| v["full_support_end"] = v.get("phases", {}).get("Full support", "N/A") | ||
| v["maintenance_end"] = v.get("phases", {}).get("Maintenance support", "N/A") | ||
| return versions | ||
|
|
||
|
|
||
| def rhdh_supported_ocp_versions(rhdh_data): | ||
| """Return sorted list of OCP versions supported by any active RHDH release.""" | ||
| return sorted( | ||
| {ocp for v in rhdh_data if v["supported"] for ocp in v.get("ocp_versions", [])}, | ||
| key=lambda x: [int(n) for n in x.split(".")], | ||
|
zdrapela marked this conversation as resolved.
Outdated
|
||
| ) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| """Access openshift/release repo data (CI configs, cluster pools).""" |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.