diff --git a/README.md b/README.md index 1afcd37..0dd0f16 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,10 @@ Track work across the four RHDH Jira projects. - **[rhdh-pr-review](./skills/rhdh-pr-review/SKILL.md)** — Test PR changes on a live RHDH cluster. Swaps CI images, verifies code changes, and reports findings. +### Test Plan + +- **[rhdh-test-plan-review](./skills/rhdh-test-plan-review/SKILL.md)** — Reviews an RHDH test plan Jira ticket and suggests platform/integration version updates based on support lifecycle pages and RHDH release milestones + ### Orchestration - **[rhdh](./skills/rhdh/SKILL.md)** — Entry point and router. Detects your environment, runs `doctor` checks, maintains a cross-session worklog, and routes to the right skill. Start here if you're not sure what you need. diff --git a/skills/rhdh-test-plan-review/SKILL.md b/skills/rhdh-test-plan-review/SKILL.md new file mode 100644 index 0000000..de8c2e5 --- /dev/null +++ b/skills/rhdh-test-plan-review/SKILL.md @@ -0,0 +1,78 @@ +--- +name: rhdh-test-plan-review +description: | + Reviews an RHDH test plan Jira ticket and suggests platform/integration version updates based on support lifecycle pages and RHDH release milestones. Use when given an RHDH test plan Jira ticket ID to check which platform/integration versions to add or remove. Use when asked to "update test plan", "review test plan", "check platform versions in test plan", "review RHDH test plan", "what platforms should we test for RHDH X", or "update supported versions in test plan". +--- + + + + +Never modify Jira tickets without explicit user approval. Collect all decisions first, summarize them, then ask whether to apply — direct update [d], comment [c], or discard [n]. + + + +Different platforms accumulate versions differently: +- **OCP**: accumulate all active versions +- **AKS, EKS, GKE, Quay**: single latest version only — replace, never accumulate +- **ARO, OSD, ROSA**: single version each, evaluated independently — replace if a newer version is GA before code_freeze +- **RHBK**: track major versions only (e.g., `26` not `26.0`); accumulate all active majors +- **PostgreSQL**: RHDH support policy is the baseline; Backstage-only versions require a Jira Feature ticket warning + + + +Add/remove decisions are based on `code_freeze` and `ga_date` from the RHDH schedule sheet — not today's date. +- **Add**: version GA date ≤ `code_freeze` +- **Remove**: version EOL date ≤ `ga_date` + + + +Jira descriptions are ADF (nested JSON). When updating, modify only version strings and date cells inside existing table cells — never convert to plain text and back. Preserve all other ADF structure exactly. + + + +Never read `.jira-token` into context. Always use shell substitution: `"$(cat "$TOKEN_FILE")"`. + + + + + + +## RHDH Test Plan Review + +Provide a Jira ticket ID or URL (e.g., `RHIDP-8994`) to begin. + +**Wait for response before proceeding.** + + + + + +| Input | Workflow | +|-------|----------| +| Jira ticket ID or URL | Read `workflows/review-test-plan.md` and follow it | + + + + + +| Reference | Purpose | Path | +|-----------|---------|------| +| sources | Lifecycle URLs and extraction guidance per platform/integration | `references/sources.md` | +| google-sheets-setup | One-time gcloud auth setup for schedule sheet access | `references/google-sheets-setup.md` | +| rhdh-jira auth | Jira REST API token setup and curl patterns | `~/.claude/skills/rhdh-jira/references/auth.md` | + + + + + +- [ ] RHDH version extracted from ticket +- [ ] Milestone dates fetched from schedule sheet +- [ ] All platform and integration versions checked against lifecycle sources +- [ ] Overview diff presented (key dates, platforms, integrations) +- [ ] Each proposed change reviewed interactively (a/k/e) +- [ ] User chose how to apply: [d] direct update, [c] comment, [n] discard +- [ ] If [d]: Jira description updated via REST API; child tasks offered one at a time +- [ ] If [c]: comment posted to ticket +- [ ] If [n]: confirmed no changes were made + + diff --git a/skills/rhdh-test-plan-review/references/google-sheets-setup.md b/skills/rhdh-test-plan-review/references/google-sheets-setup.md new file mode 100644 index 0000000..4d5d4b3 --- /dev/null +++ b/skills/rhdh-test-plan-review/references/google-sheets-setup.md @@ -0,0 +1,42 @@ +# Google Sheets API Setup + +The skill accesses the RHDH schedule Google Sheet using your existing Google account via `gcloud`. + +## Setup (one-time) + +**Step 1: Install gcloud** (skip if already installed) + +Follow [Install the Google Cloud CLI](https://cloud.google.com/sdk/docs/install) for your OS. Examples: + +- **Linux**: package manager (`apt`/`yum`/`dnf`), [Snap](https://cloud.google.com/sdk/docs/downloads-snap), or tarball — install so `gcloud` is on your `PATH`. +- **macOS**: [Installer pkg](https://cloud.google.com/sdk/docs/install-sdk#mac), [Homebrew cask](https://formulae.brew.sh/cask/google-cloud-sdk), or tarball — install so `gcloud` is on your `PATH`. + +The scripts invoke `gcloud` only via `PATH`. If `which gcloud` fails, fix your install or shell configuration before retrying. + +**Step 2: Authenticate with Google Drive access** + +```bash +gcloud auth login --enable-gdrive-access +``` + +This opens a browser window. Sign in with the Google account that has access to the RHDH schedule sheet. + +**Step 3: Verify** + +```bash +python scripts/check_gsheets.py +``` + +Expected output: +``` +✓ gcloud auth token available +``` + +## Troubleshooting + +| Symptom | Fix | +|---------|-----| +| `gcloud not found` / `gcloud not on PATH` | Install the SDK (see step 1) so `gcloud` is on your `PATH` | +| `No active gcloud account` | Run `gcloud auth login --enable-gdrive-access` | +| `403 Forbidden` when fetching sheet | Sign in with an account that has Viewer access to the sheet | +| Token expired | Run `gcloud auth login --enable-gdrive-access` again | diff --git a/skills/rhdh-test-plan-review/references/sources.md b/skills/rhdh-test-plan-review/references/sources.md new file mode 100644 index 0000000..d784715 --- /dev/null +++ b/skills/rhdh-test-plan-review/references/sources.md @@ -0,0 +1,108 @@ +# Lifecycle Sources + +URLs and extraction guidance for each platform and integration. Load this file during Step 4 of the workflow. + +## Platforms + +### OCP (OpenShift Container Platform) + +**URL:** https://access.redhat.com/support/policy/updates/openshift +**What to extract:** Minor version GA dates and end-of-life dates from the life cycle policy table. + +### OSD (OpenShift Dedicated) + +**URL:** https://access.redhat.com/product-life-cycles/api/v1/products/?name=Red+Hat+OpenShift+Container+Platform +**What to extract:** OSD follows OCP version availability. Use OCP GA and EOL dates as a proxy — versions available on OSD typically lag OCP GA by a few weeks. Apply the same add/remove rules as OCP. + +**Note:** The OSD-specific lifecycle page (`docs.openshift.com/dedicated`) returns 403 for automated access. The Red Hat lifecycle API has no OSD-specific entry beyond 4.13. OCP lifecycle data is the best available proxy. + +### ROSA (Red Hat OpenShift Service on AWS) + +**URL:** https://access.redhat.com/product-life-cycles/api/v1/products/?name=Red+Hat+OpenShift+Container+Platform +**What to extract:** ROSA follows OCP version availability. Use OCP GA and EOL dates as a proxy. Apply the same add/remove rules as OCP. + +**Note:** The ROSA-specific lifecycle page (`docs.openshift.com/rosa`) returns 403 for automated access. The Red Hat lifecycle API has ROSA entries only up to 4.13 (classic architecture). OCP lifecycle data is the best available proxy for current versions. + +### ARO (Azure Red Hat OpenShift) + +**URL:** https://learn.microsoft.com/en-us/azure/openshift/support-lifecycle +**What to extract:** Kubernetes version GA dates and end of support (EOL) dates. + +### AKS (Azure Kubernetes Service) + +**URL:** https://learn.microsoft.com/en-us/azure/aks/supported-kubernetes-versions?tabs=azure-cli#aks-kubernetes-release-calendar +**What to extract:** The AKS Kubernetes release calendar table — GA date and end of life date per minor Kubernetes version. + +### EKS (Amazon Elastic Kubernetes Service) + +**URL:** https://docs.aws.amazon.com/eks/latest/userguide/kubernetes-versions.html +**What to extract:** Kubernetes minor version release dates and end of standard support dates for EKS. + +### GKE (Google Kubernetes Engine) + +**URL:** https://cloud.google.com/kubernetes-engine/docs/release-schedule +**What to extract:** GKE release schedule table — GA date and end of life per minor Kubernetes version. + +## Integrations + +### RHDH PostgreSQL Support Policy (baseline) + +**URL:** https://access.redhat.com/support/policy/updates/developerhub +**What to extract:** The list of PostgreSQL major versions officially supported by RHDH. This is the **authoritative baseline** — these versions are already confirmed for RHDH support. Use these as the starting point; do not suggest removing versions that are on this list unless they are also EOL across all three providers. + +### Backstage PostgreSQL Support Policy (candidate source) + +**URL:** https://backstage.io/docs/overview/versioning-policy/#postgresql-releases +**What to extract:** The PostgreSQL versions Backstage currently supports (rolling window, typically last 5 major versions). Any version Backstage supports but that is NOT on the RHDH support policy page is a **candidate to suggest adding**, with the following mandatory warning: + +> ⚠ Adding a new PostgreSQL version to RHDH requires a dedicated RHDH Jira Feature ticket to formally extend database support. Do not add this version without one. + +### Amazon RDS for PostgreSQL + +**URL:** https://docs.aws.amazon.com/AmazonRDS/latest/PostgreSQLReleaseNotes/postgresql-versions.html +**What to extract:** PostgreSQL major version end of support (EOL) dates on Amazon RDS. + +### Azure Database for PostgreSQL + +**URL:** https://learn.microsoft.com/en-us/azure/postgresql/flexible-server/concepts-supported-versions +**What to extract:** PostgreSQL major version support status and end of support dates for Azure Database Flexible Server. + +### CloudSQL for PostgreSQL + +**URL:** https://cloud.google.com/sql/postgresql +**What to extract:** PostgreSQL major version end of support dates for Google CloudSQL. The page may list supported versions in a table or prose — look for "end of support" or "EOL" next to each major version. + +### RHBK (Red Hat Build of Keycloak) + +**URL:** https://access.redhat.com/product-life-cycles/api/v1/products/?name=Red+Hat+build+of+Keycloak +**What to extract:** JSON response — each version entry has a `name` and a `phases` array. Extract dates from the phases array — **not** from a top-level `ga_date` field (there is none). + +Phase extraction rules: +- **GA date**: `phases[name == "General availability"].end_date` — this is when the GA phase ended (i.e., when the product became GA). The `start_date` of this phase is always `"N/A"`. +- **EOL date**: the `end_date` of the last phase that has a real date (not `"N/A"`). Typically the last of: Maintenance support, Extended update support Term 2, etc. + +Use minor version entries (e.g., `26.0`, `26.2`, `26.4`) to determine which **major versions** are still active — do not list minor versions in the table. A major version is active if at least one of its minor releases is GA on or before `code_freeze` and not EOL before `ga_date`. Report only the major version number (e.g., `26`). + +### Quay (Red Hat Quay) + +**URL:** https://access.redhat.com/product-life-cycles/api/v1/products/?name=Red+Hat+Quay +**What to extract:** JSON response — each version entry has a `name` and a `phases` array. Extract dates from the phases array — **not** from a top-level `ga_date` field (there is none). + +Phase extraction rules: +- **GA date**: `phases[name == "General availability"].end_date` — this is when the GA phase ended (i.e., when the product became generally available). The `start_date` of this phase is always `"N/A"`. +- **EOL date**: the `end_date` of the last phase that has a real date (not `"N/A"`). + +Identify only the **latest version** with a GA date on or before `code_freeze`. Suggest replacing the current version with that latest version. + +## Date Interpretation Rules + +When evaluating lifecycle dates against RHDH milestones: + +| Rule | Condition | Action | +|------|-----------|--------| +| Add version | Version GA date ≤ RHDH Code Freeze | Suggest adding | +| Remove version | Version EOL date ≤ RHDH GA date | Suggest removing | +| No change | Version in table, not EOL, GA already passed | Leave as-is | +| No change | Version GA date > RHDH Code Freeze | Do not add | + +If a lifecycle page provides only a quarter or year (not a precise date), use the **last day of that period** as a conservative estimate and note it in the justification (e.g., "GA estimated end of Q3 2025 → 2025-09-30"). diff --git a/skills/rhdh-test-plan-review/scripts/check_gsheets.py b/skills/rhdh-test-plan-review/scripts/check_gsheets.py new file mode 100755 index 0000000..dfb5e86 --- /dev/null +++ b/skills/rhdh-test-plan-review/scripts/check_gsheets.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Check if gcloud auth is configured for rhdh-test-plan-review.""" + +import argparse +import json +import os +import sys + +from gcloud_token import get_gcloud_token + +_no_color = os.environ.get("NO_COLOR") is not None +_is_tty = sys.stderr.isatty() and not _no_color + + +def colored(text, code): + if _is_tty: + return f"\033[{code}m{text}\033[0m" + return text + + +def main(): + parser = argparse.ArgumentParser( + description="Check if gcloud auth is configured for the Google Sheets API." + ) + parser.add_argument( + "--json", + action="store_true", + dest="json_output", + help="Output as JSON (default: human-readable)", + ) + args = parser.parse_args() + + token, error = get_gcloud_token() + result = { + "credentials_found": token is not None, + "method": "gcloud", + "error": error, + } + + if args.json_output: + json.dump(result, sys.stdout, indent=2) + print() + else: + if result["credentials_found"]: + print(colored("✓", "32") + " gcloud auth token available") + else: + print(colored("✗", "31") + f" {error}") + print() + if not (error and "PATH" in error): + print("Run: gcloud auth login --enable-gdrive-access") + + sys.exit(0 if result["credentials_found"] else 1) + + +if __name__ == "__main__": + main() diff --git a/skills/rhdh-test-plan-review/scripts/fetch_schedule.py b/skills/rhdh-test-plan-review/scripts/fetch_schedule.py new file mode 100755 index 0000000..4bf7b78 --- /dev/null +++ b/skills/rhdh-test-plan-review/scripts/fetch_schedule.py @@ -0,0 +1,255 @@ +#!/usr/bin/env -S uv run --script +# /// script +# requires-python = ">=3.10" +# dependencies = [ +# "google-api-python-client>=2.0", +# "google-auth>=2.0", +# "google-auth-httplib2>=0.2", +# ] +# /// +# Note: uses uv run --script for Google API deps (see ADR-0002 for stdlib-only rationale; +# Google Sheets SDK is an intentional exception) +"""Fetch RHDH milestone dates (Feature Freeze, Code Freeze, GA) from the RHDH schedule Google Sheet.""" + +import argparse +import json +import os +import re +import sys +from datetime import datetime +from pathlib import Path + +# Sibling import when run as `uv run --script` (script dir may not be on sys.path). +_scripts_dir = Path(__file__).resolve().parent +if str(_scripts_dir) not in sys.path: + sys.path.insert(0, str(_scripts_dir)) + +from gcloud_token import get_gcloud_token # noqa: E402 + +_no_color = os.environ.get("NO_COLOR") is not None +_is_tty = sys.stderr.isatty() and not _no_color + + +def log(msg): + """Write to stderr — keeps stdout clean for JSON output.""" + if _is_tty: + print(msg, file=sys.stderr) + + +def error_exit(error_key, extra=None): + result = {"error": error_key} + if extra: + result.update(extra) + json.dump(result, sys.stdout, indent=2) + print() + sys.exit(1) + + +def get_sheets_service(): + from google.oauth2.credentials import Credentials + from googleapiclient.discovery import build + + token, gcloud_err = get_gcloud_token() + if not token: + if gcloud_err and "PATH" in gcloud_err: + error_exit( + "gcloud_not_found", + {"hint": "Install the Google Cloud CLI and ensure gcloud is on your PATH"}, + ) + error_exit( + "credentials_not_found", + {"hint": "Run 'gcloud auth login --enable-gdrive-access' to authenticate"}, + ) + + creds = Credentials(token=token) + return build("sheets", "v4", credentials=creds) + + +def get_sheet_tabs(service, spreadsheet_id): + from googleapiclient.errors import HttpError + + try: + meta = service.spreadsheets().get(spreadsheetId=spreadsheet_id).execute() + except HttpError as e: + if e.resp.status == 404: + error_exit( + "spreadsheet_not_found", + { + "spreadsheet_id": spreadsheet_id, + "hint": "The spreadsheet was not found. Pass the correct ID with --sheet-id or share the sheet URL.", + }, + ) + raise + return [s["properties"]["title"] for s in meta.get("sheets", [])] + + +def find_schedule_tab(tabs): + """Find the best 'Schedule' tab — tries current year, then next, then previous.""" + current_year = datetime.now().year + for year in [current_year, current_year + 1, current_year - 1]: + candidates = [t for t in tabs if str(year) in t and "schedule" in t.lower()] + if candidates: + return candidates[0] + # Fallback: any tab with "schedule" + fallback = [t for t in tabs if "schedule" in t.lower()] + return fallback[0] if fallback else None + + +def normalize_version(v): + """Extract major.minor from strings like 'RHDH 1.6', 'rhdh-1.6', 'v1.6', '1.6'.""" + m = re.search(r"(\d+)\.(\d+)", v) + if m: + return f"{m.group(1)}.{m.group(2)}" + return v.strip() + + +def parse_date(raw): + """Try common date formats found in Google Sheets.""" + raw = raw.strip() + for fmt in ( + "%Y-%m-%d", + "%m/%d/%Y", + "%B %d, %Y", + "%b %d, %Y", + "%d %b %Y", + "%d %B %Y", + "%m/%d/%y", + ): + try: + return datetime.strptime(raw, fmt).strftime("%Y-%m-%d") + except ValueError: + continue + return None # unparseable + + +def row_date(cells): + """Return the first parseable date found in a row's cells, or None.""" + for cell in cells: + parsed = parse_date(str(cell)) + if parsed: + return parsed + return None + + +def find_milestones(rows, version): + """ + Search sheet rows for RHDH version milestones. + + The sheet is chronological. Milestone rows (Code Freeze, Feature Freeze) + may or may not include the version name — they precede the GA row. + + Strategy: + 1. Find the GA row for the target version (must contain version string + GA keyword). + 2. Walk backwards from the GA row to find the closest Code Freeze and + Feature Freeze rows. Stop when we hit a GA row for a different version + (those milestones belong to the previous release, not this one). + """ + ver = normalize_version(version) + + ga_keywords = ["ga ", "ga\t", "ga\n", "ga announce", "general availability", "ga date"] + freeze_keywords = { + "code_freeze": ["code freeze", "code-freeze", "codefreeze"], + "feature_freeze": ["feature freeze", "feature-freeze", " ff "], + } + + # Step 1: find the index of the GA row for this version + ga_index = None + for i, row in enumerate(rows): + cells = [str(c) for c in row] + row_text = " " + " ".join(cells).lower() + " " + version_match = ver in row_text or (version.lower().replace("rhdh", "").strip() in row_text) + ga_match = any(kw in row_text for kw in ga_keywords) + if version_match and ga_match: + ga_index = i + break + + if ga_index is None: + return {} + + ga_date = row_date([str(c) for c in rows[ga_index]]) + milestones = {"ga_date": ga_date} if ga_date else {} + + # Step 2: walk backwards from the GA row to find freeze dates + found = {} + for i in range(ga_index - 1, -1, -1): + cells = [str(c) for c in rows[i]] + row_text = " " + " ".join(cells).lower() + " " + + # Stop if we hit a GA row for a different version (we've gone too far back) + if any(kw in row_text for kw in ga_keywords): + break + + for milestone, keywords in freeze_keywords.items(): + if milestone in found: + continue + if any(kw in row_text for kw in keywords): + d = row_date(cells) + if d: + found[milestone] = d + + if len(found) == len(freeze_keywords): + break # found all freeze dates, no need to go further back + + milestones.update(found) + return milestones + + +def main(): + parser = argparse.ArgumentParser( + description="Fetch RHDH milestone dates from the RHDH schedule Google Sheet." + ) + parser.add_argument( + "--version", + required=True, + help="RHDH version to look up (e.g. '1.6', 'RHDH 1.6', 'rhdh-1.6')", + ) + parser.add_argument( + "--sheet-id", + default="1knVzlMW0l0X4c7gkoiuaGql1zuFgEGwHHBsj-ygUTnc", + help="RHDH Release Schedule spreadsheet ID", + ) + args = parser.parse_args() + + version = normalize_version(args.version) + sheet_id = args.sheet_id + log(f"Looking up milestones for RHDH {version}...") + + service = get_sheets_service() + tabs = get_sheet_tabs(service, sheet_id) + log(f"Found tabs: {tabs}") + + tab = find_schedule_tab(tabs) + if not tab: + error_exit("no_schedule_tab_found", {"tabs": tabs, "spreadsheet_id": sheet_id}) + + log(f"Reading tab: {tab}") + result = service.spreadsheets().values().get(spreadsheetId=sheet_id, range=tab).execute() + rows = result.get("values", []) + + milestones = find_milestones(rows, version) + + if not milestones.get("code_freeze") and not milestones.get("ga_date"): + error_exit( + "version_not_found", + { + "version": version, + "tab": tab, + "spreadsheet_id": sheet_id, + "hint": "Check that the version string matches the sheet exactly", + }, + ) + + output = { + "version": version, + "tab": tab, + "feature_freeze": milestones.get("feature_freeze"), + "code_freeze": milestones.get("code_freeze"), + "ga_date": milestones.get("ga_date"), + } + + json.dump(output, sys.stdout, indent=2) + print() + + +if __name__ == "__main__": + main() diff --git a/skills/rhdh-test-plan-review/scripts/gcloud_token.py b/skills/rhdh-test-plan-review/scripts/gcloud_token.py new file mode 100644 index 0000000..6cfb186 --- /dev/null +++ b/skills/rhdh-test-plan-review/scripts/gcloud_token.py @@ -0,0 +1,21 @@ +"""Shared gcloud access token helper for rhdh-test-plan-review scripts.""" + +import shutil +import subprocess + + +def get_gcloud_token(): + """Return (access_token_or_None, error_message_or_None) from gcloud CLI.""" + gcloud = shutil.which("gcloud") + if not gcloud: + return None, "gcloud not on PATH — install Google Cloud CLI and add gcloud to PATH" + + result = subprocess.run( + [gcloud, "auth", "print-access-token"], + capture_output=True, + text=True, + ) + token = result.stdout.strip() + if token: + return token, None + return None, "No active gcloud account — run: gcloud auth login --enable-gdrive-access" diff --git a/skills/rhdh-test-plan-review/workflows/review-test-plan.md b/skills/rhdh-test-plan-review/workflows/review-test-plan.md new file mode 100644 index 0000000..ec0100a --- /dev/null +++ b/skills/rhdh-test-plan-review/workflows/review-test-plan.md @@ -0,0 +1,307 @@ +# Workflow: Review RHDH Test Plan + +Reviews a test plan Jira ticket against platform and integration support lifecycle pages. Suggests adding versions GA before RHDH Code Freeze and removing versions EOL before RHDH GA date. + + + +| Requirement | Check | +|-------------|-------| +| **Jira** | `python ~/.claude/skills/rhdh-jira/scripts/setup.py --json` → `"overall": "pass"` | +| **Google Sheets** | `python scripts/check_gsheets.py` → `"credentials_found": true` | + +If Jira check fails: load `~/.claude/skills/rhdh-jira/SKILL.md` and follow its Prerequisites section. + +If Google Sheets check fails: load `references/google-sheets-setup.md` and walk the user through setup. + + + + + +## Step 1: Fetch the test plan Jira ticket + +```bash +acli jira workitem view TICKET-ID --json +``` + +Extract the RHDH version from `fixVersions[0].name`. If empty, check `summary` for a version string (e.g., "RHDH 1.6 Test Plan"). + +Normalize: strip any non-numeric prefix ("RHDH ", "v", "rhdh-") → plain `major.minor` (e.g., "1.6"). + +If the version cannot be determined, ask: "I couldn't determine the RHDH version from this ticket. What version is this test plan for?" + +--- + +## Step 2: Fetch RHDH milestone dates + +```bash +python scripts/fetch_schedule.py --version "1.6" +``` + +Expected output: +```json +{ + "version": "1.6", + "feature_freeze": "2025-09-15", + "code_freeze": "2025-10-01", + "ga_date": "2025-10-15", + "tab": "2025 Schedule" +} +``` + +**On `{"error": "spreadsheet_not_found"}`:** Ask the user: +> "I couldn't access the RHDH Release Schedule sheet (ID: ``). Please share the sheet URL or ID." + +Extract the ID from the URL (the alphanumeric string between `/d/` and `/edit`) and retry: +```bash +python scripts/fetch_schedule.py --version "1.6" --sheet-id +``` + +**On `{"error": "version_not_found"}`:** Ask: "I couldn't find RHDH [version] milestones in the schedule sheet. Could you confirm the exact version string as it appears in the sheet?" + +Use `code_freeze` and `ga_date` throughout the rest of this workflow. + +--- + +## Step 3: Parse the test plan description + +The description is in Atlassian Document Format (ADF). Parse the ADF JSON to locate: + +1. **Key dates table** — rows for Feature Freeze, Code Freeze, and GA. Each row has the milestone label in the first cell and the date in the second cell (may be empty). +2. **Platform versions table** — rows listing OCP, ARO, AKS, EKS, GKE, OSD, ROSA with version numbers. +3. **Integration versions table** — rows listing PostgreSQL variants, RHBK, Quay with version numbers. + +Record the current version set for each entry. Normalize version strings to `major.minor` for comparison. Record current key dates (may be blank). + +--- + +## Step 4: Fetch lifecycle data + +Load `references/sources.md` for all lifecycle URLs and extraction guidance. + +For each source, fetch using WebFetch: +- Retry up to 3 times on failure +- If all 3 attempts fail: skip and record a ⚠ warning — do not abort the run + +Apply using `code_freeze` and `ga_date`: +- **Add**: version GA date ≤ `code_freeze` AND not already in table +- **Remove**: version EOL date ≤ `ga_date` AND currently in table + +### Platform-specific rules + +**OCP** — accumulate all active versions. Add any version GA ≤ `code_freeze`; remove any version EOL ≤ `ga_date`. + +**AKS, EKS, GKE** — single latest version only. Identify the newest version GA ≤ `code_freeze`. If it differs from the current, suggest replacing (not adding alongside). + +**ARO, OSD, ROSA** — single version each, evaluated independently. Suggest replacing if a newer version is GA ≤ `code_freeze` and not EOL before `ga_date`. ARO, OSD, and ROSA are evaluated separately — do not assume they share the same version. + +**RHBK** — track major versions only (e.g., `26`, not `26.0`). A major version is active if at least one of its minor releases is GA ≤ `code_freeze` and not EOL before `ga_date`. Add active majors not yet in table; remove a major only when **all** of its minor releases are EOL before `ga_date`. + +**Quay** — single latest version only. Identify the newest version with a known GA date ≤ `code_freeze`. Suggest replacing the current version. + +**PostgreSQL** — RHDH support policy page is the baseline (already officially supported). For any version Backstage supports but is NOT on the RHDH policy page, suggest it as a candidate with a mandatory warning: +> ⚠ Adding a new PostgreSQL version requires a dedicated RHDH Jira Feature ticket to extend database support — do not add without one. + +Apply the EOL removal rule across all three providers (RDS, Azure DB, CloudSQL) — remove only if EOL across all three. + +--- + +## Step 5: Present the overview diff + +Include key dates first, then platforms, then integrations. Use ANSI colors (green for additions, red for removals) inside a code block: + +``` +Key Dates +───────────────────────────────────────────────────────────────────── +Milestone │ Current │ Suggested │ Source +───────────────────────────────────────────────────────────────────── +Code Freeze │ (empty) │ 2026-05-19 │ RHDH schedule sheet +GA │ (empty) │ 2026-06-10 │ RHDH schedule sheet +───────────────────────────────────────────────────────────────────── + +Platforms +───────────────────────────────────────────────────────────────────── +Platform │ Current │ Suggested │ Reason +───────────────────────────────────────────────────────────────────── +ARO │ 4.19 │ →4.20 │ ARO GA Oct 2025 ≤ code freeze +EKS │ 1.34 │ →1.35 │ EKS release Jan 27 ≤ code freeze +───────────────────────────────────────────────────────────────────── + +Integrations +───────────────────────────────────────────────────────────────────── +Integration │ Current │ Suggested │ Reason +───────────────────────────────────────────────────────────────────── +RHBK │ 24, 26 │ −24, 26 │ RHBK 24 EOL May 2025 ≤ ga date +───────────────────────────────────────────────────────────────────── +``` + +Skip rows with no proposed changes. Note skipped sources with ⚠. + +--- + +## Step 6: Interactive line-by-line review + +Walk through each proposed change **one at a time** — key dates first, then platforms, then integrations. This is decision-collection only — nothing is written to Jira here. + +For each change: + +``` +────────────────────────────────────────── + AKS │ Current: 1.34 + │ Suggested: →1.35 + │ Reason: AKS GA Mar 2026 ≤ code freeze May 19 +────────────────────────────────────────── + [a] Accept suggestion → 1.35 + [k] Keep current → 1.34 + [e] Enter your own value +Choice [a/k/e]: +``` + +- **[a]**: record the suggested value +- **[k]**: record no change, move on +- **[e]**: prompt for a value, confirm, then record + +After all decisions, print a summary of rows that will change, then ask: + +``` +How would you like to apply these changes? + [d] Update the Jira description directly + [c] Post a comment on the ticket with the suggested changes + [n] Do nothing — discard all decisions +Choice [d/c/n]: +``` + +- **[d]**: proceed to Step 7 +- **[c]**: proceed to Step 7b +- **[n]**: confirm no changes were made and stop + +--- + +## Step 7: Apply changes — direct update + +Load `~/.claude/skills/rhdh-jira/references/auth.md` for REST API setup. + +Modify only the version strings in platform/integration table cells and the date cells in the key dates table. Preserve all other ADF structure exactly. + +```bash +ACLI_DIR=$(dirname "$(readlink -f "$(which acli)")") +TOKEN_FILE="$ACLI_DIR/.jira-token" + +curl -s -X PUT \ + -u "$(cat "$TOKEN_FILE")" \ + -H "Content-Type: application/json" \ + "https://redhat.atlassian.net/rest/api/3/issue/TICKET-ID" \ + -d '{"fields": {"description": }}' +``` + +A 204 response confirms success. Proceed to Step 8. + +--- + +## Step 7b: Apply changes — post comment + +Draft the comment text and **show it to the user before posting**: + +``` +*Test Plan Version Review — RHDH X.Y* + +*Suggested platform/integration updates:* +• AKS: 1.34 → 1.35 +• RHBK: 24, 26 → 26 + +These suggestions are based on support lifecycle pages checked on [today's date]. +No changes have been applied to this ticket. +``` + +Then ask: + +``` +Post this comment to TICKET-ID? + [p] Post as-is + [e] Edit before posting + [n] Cancel +Choice [p/e/n]: +``` + +- **[p]**: post immediately +- **[e]**: show the full comment text, ask the user to provide the edited version, confirm, then post +- **[n]**: cancel — confirm no changes were made and stop + +```bash +ACLI_DIR=$(dirname "$(readlink -f "$(which acli)")") +TOKEN_FILE="$ACLI_DIR/.jira-token" + +curl -s -X POST \ + -u "$(cat "$TOKEN_FILE")" \ + -H "Content-Type: application/json" \ + "https://redhat.atlassian.net/rest/api/3/issue/TICKET-ID/comment" \ + -d '{"body": {"type": "doc", "version": 1, "content": [{"type": "paragraph", "content": [{"type": "text", "text": ""}]}]}}' +``` + +A 201 response confirms success. Stop here — do not create child tasks. + +--- + +## Step 8: Create child tasks (after direct update only) + +For each accepted change with an infrastructure impact, offer a child task one at a time: + +| Change type | Child task title template | +|---|---| +| AKS version changed | `[RHDH X.Y] Update Kubernetes version to X.Y on AKS cluster` | +| EKS version changed | `[RHDH X.Y] Update Kubernetes version to X.Y on EKS cluster` | +| GKE version changed | `[RHDH X.Y] Update Kubernetes version to X.Y on GKE cluster` | +| OCP version added | `[RHDH X.Y] Create prow job for OCP X.Y` | +| OCP version removed | `[RHDH X.Y] Remove prow job for OCP X.Y` | +| ARO version changed | `[RHDH X.Y] Update ARO cluster to OCP X.Y` | +| OSD version changed | `[RHDH X.Y] Update OSD cluster to OCP X.Y` | +| ROSA version changed | `[RHDH X.Y] Update ROSA cluster to OCP X.Y` | + +For each candidate: + +``` +────────────────────────────────────────────────────────────── + Child task │ [RHDH 1.10] Update Kubernetes version to 1.35 on EKS cluster + │ Parent: RHIDP-XXXXX +────────────────────────────────────────────────────────────── + [c] Create this task + [s] Skip — do not create + [e] Edit the title before creating +Choice [c/s/e]: +``` + +```bash +ACLI_DIR=$(dirname "$(readlink -f "$(which acli)")") +TOKEN_FILE="$ACLI_DIR/.jira-token" + +curl -s -X POST \ + -u "$(cat "$TOKEN_FILE")" \ + -H "Content-Type: application/json" \ + "https://redhat.atlassian.net/rest/api/3/issue" \ + -d '{ + "fields": { + "project": {"key": "RHIDP"}, + "summary": "", + "issuetype": {"name": "Task"}, + "parent": {"key": "TICKET-ID"} + } + }' +``` + +A 201 response with the new issue key confirms success. Print the created key after each creation. + +After all decisions, print a final summary of what was created and what was skipped. + + + + + +- **ADF round-trip**: Send ADF when updating via REST — converting to plain text destroys formatting. Modify only version strings inside existing table cells. +- **Token safety**: Never read `.jira-token` into context. Use `"$(cat "$TOKEN_FILE")"` via shell substitution. +- **Key dates table**: Match milestone rows by label keyword (e.g., "Code Freeze", "GA announce"). Update only the date cell. Leave rows you cannot match untouched. +- **Version normalization**: Tables may use "v1.29", "1.29.x", or "Kubernetes 1.29" — normalize to `major.minor` before comparing. +- **fixVersions format varies**: May be "1.6", "RHDH 1.6", "rhdh-1.6" — strip prefixes before passing to `fetch_schedule.py`. +- **Schedule tab is year-based**: `fetch_schedule.py` tries current year first, then adjacent years. Pass `--sheet-id` if the schedule is in a non-default spreadsheet. +- **Child task issuetype**: Use `"Task"` with a `parent` field. If that fails with 400, retry with `"issuetype": {"name": "Subtask"}`. +- **Child task project key**: Use the same project key as the parent (e.g., `RHIDP`). + + diff --git a/skills/rhdh/SKILL.md b/skills/rhdh/SKILL.md index 73150f0..bdea905 100644 --- a/skills/rhdh/SKILL.md +++ b/skills/rhdh/SKILL.md @@ -110,10 +110,16 @@ What would you like to do? 8. **Review operator PR** — Deploy PR operator bundle on cluster and get review checklist +### Test Plan Tasks + +*For rhdh test plan review in jira* + +9. **Review Test Plan content** — Reviews an RHDH test plan Jira ticket and suggests platform/integration version updates based on support lifecycle pages and RHDH release milestones + ### General Tasks -9. **Check environment** — Run doctor, configure paths -10. **View/search activity** — Review worklog, todos +10. **Check environment** — Run doctor, configure paths +11. **View/search activity** — Review worklog, todos **Wait for response before proceeding.** @@ -159,12 +165,20 @@ What would you like to do? **To route:** Read `../rhdh-pr-review/SKILL.md` and follow its intake process. +### Test Plan Routes + +| Response | Skill | +|----------|-------| +| 9, "review test plan", "update test plan", "check platform versions in test plan", "review RHDH test plan" | Route to `@rhdh-test-plan-review` skill | + +**To route:** Read `../rhdh-test-plan-review/SKILL.md` and follow its intake process. + ### General Routes | Response | Action | |----------|--------| -| 9, "doctor", "setup", "config" | Use CLI commands below | -| 10, "log", "todo", "activity" | Use tracking commands below | +| 10, "doctor", "setup", "config" | Use CLI commands below | +| 11, "log", "todo", "activity" | Use tracking commands below | @@ -303,6 +317,7 @@ Todos must be **self-contained**—a new session should understand the task with | create-plugin | Create, export, package, and wire RHDH dynamic plugins | `../create-plugin/SKILL.md` | | rhdh-local | Enable/disable/test plugins in local RHDH | `../rhdh-local/SKILL.md` | | rhdh-pr-review | Test PR changes on live RHDH cluster | `../rhdh-pr-review/SKILL.md` | +| rhdh-test-plan-review | Reviews an RHDH test plan Jira ticket and suggests platform/integration version updates | `../rhdh-test-plan-review/SKILL.md` | ### Shared References diff --git a/tests/unit/test_fetch_schedule.py b/tests/unit/test_fetch_schedule.py new file mode 100644 index 0000000..8010935 --- /dev/null +++ b/tests/unit/test_fetch_schedule.py @@ -0,0 +1,199 @@ +"""Unit tests for skills/rhdh-test-plan-review/scripts/fetch_schedule.py pure helpers.""" + +import datetime as dt_module +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +_FETCH_SCHEDULE_SCRIPTS = PROJECT_ROOT / "skills" / "rhdh-test-plan-review" / "scripts" +if str(_FETCH_SCHEDULE_SCRIPTS) not in sys.path: + sys.path.insert(0, str(_FETCH_SCHEDULE_SCRIPTS)) + +import fetch_schedule as fs # noqa: E402 + + +class TestNormalizeVersion: + def test_plain_semver(self): + assert fs.normalize_version("1.6") == "1.6" + + def test_rhdh_prefix_variants(self): + assert fs.normalize_version("RHDH 1.6") == "1.6" + assert fs.normalize_version("rhdh-1.6") == "1.6" + + def test_v_prefix(self): + assert fs.normalize_version("v1.10") == "1.10" + + def test_double_digit_minor(self): + assert fs.normalize_version("release-2.12-rc1") == "2.12" + + def test_no_digits_returns_stripped(self): + assert fs.normalize_version(" upcoming ") == "upcoming" + + +class TestParseDate: + def test_iso(self): + assert fs.parse_date("2025-03-15") == "2025-03-15" + + def test_us_slash(self): + assert fs.parse_date("3/15/2025") == "2025-03-15" + + def test_long_month(self): + assert fs.parse_date("March 15, 2025") == "2025-03-15" + + def test_short_month(self): + assert fs.parse_date("Mar 15, 2025") == "2025-03-15" + + def test_two_digit_year(self): + assert fs.parse_date("3/15/25") == "2025-03-15" + + def test_unparseable(self): + assert fs.parse_date("TBD") is None + + def test_strips_whitespace(self): + assert fs.parse_date(" 2025-01-01 ") == "2025-01-01" + + +class TestRowDate: + def test_first_parseable_wins(self): + assert fs.row_date(["n/a", "2025-04-01", "2025-05-01"]) == "2025-04-01" + + def test_none_when_empty(self): + assert fs.row_date([]) is None + + +def _fixed_clock(monkeypatch, year: int, month: int = 6, day: int = 1): + """Replace fetch_schedule.datetime (Python 3.14+ blocks patching datetime.now on the class).""" + + class _Clock: + strptime = staticmethod(dt_module.datetime.strptime) + + @staticmethod + def now(tz=None): + return dt_module.datetime(year, month, day) + + monkeypatch.setattr(fs, "datetime", _Clock) + + +class TestFindScheduleTab: + def test_prefers_tab_matching_current_year(self, monkeypatch): + _fixed_clock(monkeypatch, 2026) + tabs = ["Notes", "2025 Schedule", "2026 Schedule Q1", "Other"] + assert fs.find_schedule_tab(tabs) == "2026 Schedule Q1" + + def test_falls_forward_to_next_year(self, monkeypatch): + _fixed_clock(monkeypatch, 2026) + tabs = ["Notes", "2027 Release Schedule"] + assert fs.find_schedule_tab(tabs) == "2027 Release Schedule" + + def test_then_previous_year(self, monkeypatch): + _fixed_clock(monkeypatch, 2026) + tabs = ["2025 schedule overview"] + assert fs.find_schedule_tab(tabs) == "2025 schedule overview" + + def test_fallback_any_schedule_without_year_match(self, monkeypatch): + _fixed_clock(monkeypatch, 2026) + tabs = ["Foo", "Master Schedule"] + assert fs.find_schedule_tab(tabs) == "Master Schedule" + + def test_returns_none_when_no_schedule(self, monkeypatch): + _fixed_clock(monkeypatch, 2026) + assert fs.find_schedule_tab(["Overview", "Archive"]) is None + + +class TestFindMilestones: + def test_empty_rows(self): + assert fs.find_milestones([], "1.6") == {} + + def test_no_ga_row(self): + rows = [["Feature Freeze"], ["Code Freeze"], ["1.6 RC"]] + assert fs.find_milestones(rows, "1.6") == {} + + def test_ga_only_with_date(self): + rows = [["RHDH 1.6", "GA announce", "2025-08-01"]] + assert fs.find_milestones(rows, "1.6") == {"ga_date": "2025-08-01"} + + def test_walks_backwards_for_freezes_before_ga(self): + rows = [ + ["Feature Freeze", "2025-05-01"], + ["Code Freeze", "2025-06-01"], + ["RHDH 1.6", "GA date", "2025-08-15"], + ] + assert fs.find_milestones(rows, "1.6") == { + "feature_freeze": "2025-05-01", + "code_freeze": "2025-06-01", + "ga_date": "2025-08-15", + } + + def test_stops_at_prior_ga_row(self): + """Walking upward stops at an earlier GA row so 1.5 freezes are not merged into 1.6.""" + rows = [ + ["Feature Freeze", "2024-12-01"], + ["Code Freeze", "2024-11-01"], + ["1.5", "GA", "2024-10-01"], + ["Feature Freeze", "2025-05-01"], + ["Code Freeze", "2025-06-01"], + ["1.6", "GA announce", "2025-08-01"], + ] + out = fs.find_milestones(rows, "1.6") + assert out == { + "feature_freeze": "2025-05-01", + "code_freeze": "2025-06-01", + "ga_date": "2025-08-01", + } + + def test_general_availability_keyword(self): + rows = [ + ["Code freeze", "2025-02-10"], + ["RHDH 1.7", "General Availability", "2025-04-20"], + ] + assert fs.find_milestones(rows, "1.7") == { + "code_freeze": "2025-02-10", + "ga_date": "2025-04-20", + } + + def test_code_freeze_hyphenated(self): + rows = [ + ["Code-freeze", "1/15/2025"], + ["1.6", "GA ", "2025-03-01"], + ] + assert fs.find_milestones(rows, "1.6")["code_freeze"] == "2025-01-15" + + def test_feature_freeze_hyphenated(self): + rows = [ + ["Feature-freeze", "Jan 10, 2025"], + ["v1.6", "ga date", "2025-03-01"], + ] + assert fs.find_milestones(rows, "1.6")["feature_freeze"] == "2025-01-10" + + def test_ff_abbreviation_with_spaces(self): + """Row text is space-padded; ' ff ' matches abbreviated freeze lines.""" + rows = [ + ["Milestone", " FF ", "2025-02-01"], + ["1.6 GA", "2025-03-01"], + ] + assert fs.find_milestones(rows, "1.6")["feature_freeze"] == "2025-02-01" + + def test_first_ga_row_wins(self): + rows = [ + ["1.6", "GA", "2025-01-01"], + ["1.6", "GA", "2025-12-01"], + ] + assert fs.find_milestones(rows, "1.6")["ga_date"] == "2025-01-01" + + def test_ga_without_date_still_collects_freezes(self): + rows = [ + ["Feature freeze", "2025-05-20"], + ["Code freeze", "2025-06-20"], + ["RHDH 1.6", "GA", "TBD"], + ] + out = fs.find_milestones(rows, "1.6") + assert "ga_date" not in out + assert out["feature_freeze"] == "2025-05-20" + assert out["code_freeze"] == "2025-06-20" + + def test_version_match_via_stripped_rhdh(self): + rows = [ + ["Code freeze", "2025-01-05"], + ["RHDH 2.0", "GA ", "2025-02-01"], + ] + assert fs.find_milestones(rows, "RHDH 2.0")["code_freeze"] == "2025-01-05"