diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json
index fae8fcf..3d1f67c 100644
--- a/.claude-plugin/marketplace.json
+++ b/.claude-plugin/marketplace.json
@@ -5,14 +5,14 @@
},
"metadata": {
"description": "Orchestrator skill for RHDH plugin development - onboard, update, and maintain plugins in the Extensions Catalog",
- "version": "0.4.0"
+ "version": "0.5.0"
},
"plugins": [
{
"name": "rhdh",
"source": "./",
"description": "Skills for RHDH plugin lifecycle management",
- "version": "0.4.0",
+ "version": "0.5.0",
"strict": true
}
]
diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json
index 342541c..758136d 100644
--- a/.claude-plugin/plugin.json
+++ b/.claude-plugin/plugin.json
@@ -1,7 +1,7 @@
{
"name": "rhdh",
"description": "All-in-one toolkit for Red Hat Developer Hub (RHDH). Covers plugin development, overlay management, environment setup, version compatibility, CI/CD, and RHDH ecosystem navigation.",
- "version": "0.4.0",
+ "version": "0.5.0",
"author": {
"name": "RHDH Store Manager"
},
diff --git a/.gitignore b/.gitignore
index 452ee38..aacf93c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -27,6 +27,9 @@ htmlcov/
.DS_Store
Thumbs.db
+# OAuth credentials
+client_secret*.json
+
# uv
.python-version
diff --git a/pyproject.toml b/pyproject.toml
index e6f9153..bad9a86 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "rhdh-skill"
-version = "0.4.0"
+version = "0.5.0"
description = "Claude Code skill for RHDH plugin development"
readme = "README.md"
license = "Apache-2.0"
diff --git a/skills/rhdh-release/SKILL.md b/skills/rhdh-release/SKILL.md
new file mode 100644
index 0000000..2d23d93
--- /dev/null
+++ b/skills/rhdh-release/SKILL.md
@@ -0,0 +1,128 @@
+---
+name: rhdh-release
+description: |
+ Manages RHDH releases — dates, status tracking, team coordination,
+ freeze announcements, blocker bugs, CVEs, and release notes. Trigger on
+ "release dates", "release status", "feature freeze", "code freeze",
+ "blocker bugs", "CVEs", "release notes", "team breakdown", or any
+ RHDH release management question.
+compatibility: "acli on PATH. Python 3 + gog CLI for Google Sheets/Docs."
+---
+
+
+
+
+Always use `parse_issues.py --enrich` for team counts — never count manually. The Team custom field cannot be queried via JQL. Use `acli jira workitem search --json | python ~/.claude/skills/rhdh-jira/scripts/parse_issues.py --enrich` and filter by team in the output.
+
+
+
+Include Jira search links for traceability in all outputs. Build links by URL-encoding the JQL: `https://issues.redhat.com/issues/?jql=`.
+
+
+
+Always wrap Slack messages in triple-backtick code blocks (` ```slack `) for easy copy-paste. Tell the user they can copy-paste this directly into Slack.
+
+
+
+When analyzing release health, check these risk indicators:
+1. Blocker bugs near freeze dates — query with `priority = Blocker`
+2. High open issue count per team — use team breakdown to identify overloaded teams
+3. Missing release notes — query issues with empty Release Note Type
+4. Critical CVEs — query vulnerabilities with CVE in summary
+5. EPICs not in Dev Complete — check epic status
+
+Always provide: specific issue counts with Jira links, team-level breakdown if applicable, actionable recommendations (retriage, escalate, extend timeline), and impact assessment.
+
+
+
+For team coordination:
+1. Retrieve team info from Google Sheets to get leads and Slack handles
+2. Include team leads' Slack handles in all team communications
+3. Provide Jira links scoped to each team's issues
+4. Highlight teams at risk (high open counts, blockers)
+5. Suggest follow-up actions per team
+
+
+
+Never read `.jira-token` into context. Always use shell substitution: `"$(cat "$TOKEN_FILE")"`.
+
+
+
+
+
+
+## RHDH Release Management
+
+What would you like to do?
+
+### Release Information
+
+1. **Release dates** — Current release dates and key milestones
+2. **Future release dates** — Upcoming release dates from schedule spreadsheet
+3. **Release status** — Active release status by issue type
+4. **Teams** — Teams and leads directory
+
+### Release Tracking
+
+5. **Team breakdown** — Issues by engineering team for a release
+6. **Blocker bugs** — Open blocker bugs for a release
+7. **EPICs** — Engineering EPICs not yet complete
+8. **CVEs** — CVE/vulnerability list for a release
+9. **Release notes** — Outstanding release notes (missing Release Note Type)
+
+### Announcements
+
+10. **Feature Freeze update** — Generate Feature Freeze status update for Slack
+11. **Feature Freeze announcement** — Generate Feature Freeze milestone announcement
+12. **Code Freeze update** — Generate Code Freeze status update for Slack
+13. **Code Freeze announcement** — Generate Code Freeze milestone announcement
+
+**Wait for response before proceeding.**
+
+
+
+
+
+**Preferred:** Run the `release` CLI first (`python scripts/release.py --json `). If the CLI fails, fall back to the workflow's manual steps.
+
+| Response | CLI Command | Workflow (fallback) |
+|----------|-------------|---------------------|
+| 1, "release dates", "key dates", "freeze dates", "milestone dates" | `python scripts/release.py --json dates` | `workflows/release-dates.md` |
+| 2, "future releases", "upcoming releases", "release roadmap", "future dates" | `python scripts/release.py --json future-dates VERSION` | `workflows/future-release-dates.md` |
+| 3, "release status", "active releases", "release health", "release overview" | `python scripts/release.py --json status VERSION` | `workflows/release-status.md` |
+| 4, "teams", "team leads", "team list", "team contacts", "team directory" | `python scripts/release.py --json teams` | `workflows/teams-and-leads.md` |
+| 5, "team breakdown", "issues by team", "team workload", "team counts" | `python scripts/release.py --json team-breakdown VERSION` | `workflows/issues-by-team.md` |
+| 6, "blocker bugs", "blockers", "critical issues", "blocking issues" | `python scripts/release.py --json blockers VERSION` | `workflows/blocker-bugs.md` |
+| 7, "epics", "engineering epics", "open epics", "active epics" | `python scripts/release.py --json epics VERSION` | `workflows/engineering-epics.md` |
+| 8, "cves", "vulnerabilities", "security issues", "security bugs" | `python scripts/release.py --json cves VERSION` | `workflows/cves.md` |
+| 9, "release notes", "missing release notes", "release note gaps" | `python scripts/release.py --json notes VERSION` | `workflows/release-notes.md` |
+| 10, "feature freeze update", "feature freeze status", "feature freeze progress" | `python scripts/release.py --json slack feature-freeze-update VERSION` | `workflows/announce-feature-freeze-update.md` |
+| 11, "feature freeze announcement", "announce feature freeze", "feature freeze reached" | `python scripts/release.py --json slack feature-freeze VERSION` | `workflows/announce-feature-freeze.md` |
+| 12, "code freeze update", "code freeze status", "code freeze progress" | `python scripts/release.py --json slack code-freeze-update VERSION` | `workflows/announce-code-freeze-update.md` |
+| 13, "code freeze announcement", "announce code freeze", "code freeze reached" | `python scripts/release.py --json slack code-freeze VERSION` | `workflows/announce-code-freeze.md` |
+
+
+
+
+
+| Reference | Purpose | Load when |
+|-----------|---------|-----------|
+| `references/jql-release.md` | 13 release-specific JQL templates | Any Jira query for release data |
+| `references/slack-templates.md` | 4 Slack announcement templates | Generating freeze announcements |
+| `references/config.md` | GDrive IDs, project keys, dashboards, gog setup | Looking up config values or links |
+| `gog docs cat 13OkypJ3u_7Jq6kEhKhjEFwHQ12oPFDKXVzFjYW4XLdk` | Release process (live Google Doc) | Release process questions, onboarding |
+| `../../rhdh-jira/references/auth.md` | Jira auth setup | Jira prerequisite fails |
+| `../../rhdh-jira/references/acli-commands.md` | acli command reference | Building acli commands |
+
+
+
+
+
+**Run before any workflow:**
+
+| Requirement | Check | Fix |
+|-------------|-------|-----|
+| **Jira CLI** | `acli jira workitem search --jql "project=RHIDP" --count` succeeds | Load `../../rhdh-jira/SKILL.md` Prerequisites |
+| **gog CLI** (for Google Sheets/Docs) | `gog sheets metadata 1vQXfvID72qwqvLb17eyGOvnZXrZG7NBzTGv6RP9wvyM --json` succeeds | Install gog and run `gog auth add ` |
+
+
diff --git a/skills/rhdh-release/references/config.md b/skills/rhdh-release/references/config.md
new file mode 100644
index 0000000..02e8842
--- /dev/null
+++ b/skills/rhdh-release/references/config.md
@@ -0,0 +1,27 @@
+# Release Manager Configuration
+
+Static configuration values for the RHDH Release Manager skill.
+
+## JQL Scope
+
+| Key | Value |
+|-----|-------|
+| `jira_default_base_jql` | `project IN (RHIDP, RHDHBugs, RHDHPLAN, RHDHSUPP) AND status != closed` |
+
+## Google Drive Resources
+
+| Key | Value | Description |
+|-----|-------|-------------|
+| `team_mapping_gdrive_id` | `1vQXfvID72qwqvLb17eyGOvnZXrZG7NBzTGv6RP9wvyM` | RHDH Team Mapping spreadsheet (sheet: "Team") |
+| `release_schedule_gdrive_id` | `1knVzlMW0l0X4c7gkoiuaGql1zuFgEGwHHBsj-ygUTnc` | RHDH Release Schedule spreadsheet |
+| `release_process_doc_id` | `13OkypJ3u_7Jq6kEhKhjEFwHQ12oPFDKXVzFjYW4XLdk` | Release process Google Doc |
+
+## gog CLI Setup
+
+Google Sheets and Docs access uses the [gog CLI](https://gogcli.sh).
+
+1. Install: `brew install gogcli` (requires Homebrew; `brew trust openclaw/tap` if prompted)
+2. Get OAuth credentials: request `client_secret.json` from
+3. Import credentials: `gog auth credentials client_secret.json`
+4. Authenticate: `gog auth add --services sheets,docs,drive`
+5. Verify: `gog sheets metadata 1vQXfvID72qwqvLb17eyGOvnZXrZG7NBzTGv6RP9wvyM --json`
diff --git a/skills/rhdh-release/references/jql-release.md b/skills/rhdh-release/references/jql-release.md
new file mode 100644
index 0000000..afc92c3
--- /dev/null
+++ b/skills/rhdh-release/references/jql-release.md
@@ -0,0 +1,158 @@
+# JQL Release Queries
+
+Release-specific JQL templates for RHDH release management. All queries tested against `redhat.atlassian.net`.
+
+For general Jira queries, boards, and sprints, see `../../rhdh-jira/references/jql-patterns.md`.
+
+## active_release
+
+Find all active release features in RHDHPlan.
+
+```jql
+project=rhdhplan AND issuetype=feature AND component=release AND status != closed
+```
+
+- **Placeholders:** none
+- **Notes:** Returns release tracking issues with key dates in description. Use `acli jira workitem view KEY --json` on each result to extract dates.
+
+## open_issues
+
+Find all open issues for a specific release version.
+
+```jql
+project IN (RHIDP, RHDHBugs, RHDHPLAN, RHDHSUPP) AND fixVersion = "{{RELEASE_VERSION}}" and status != closed
+```
+
+- **Placeholders:** `{{RELEASE_VERSION}}` — e.g., `1.9.0`
+- **Example:** `... AND fixVersion = "1.9.0" and status != closed`
+- **Notes:** Base query for all open issues in a release.
+
+## open_issues_by_type
+
+Find open issues for a release filtered by issue type.
+
+```jql
+project IN (RHIDP, RHDHBugs, RHDHPLAN, RHDHSUPP) AND fixVersion = "{{RELEASE_VERSION}}" AND status != closed AND issuetype = "{{ISSUE_TYPE}}"
+```
+
+- **Placeholders:** `{{RELEASE_VERSION}}`, `{{ISSUE_TYPE}}`
+- **Example:** `... AND fixVersion = "1.9.0" AND status != closed AND issuetype = "Bug"`
+- **Notes:** Valid issue types: Feature, Epic, Story, Task, Sub-task, Bug, Vulnerability, Weakness.
+
+## epics
+
+Find open EPICs not in Dev Complete or Release Pending.
+
+```jql
+project IN (RHIDP) AND fixVersion = "{{RELEASE_VERSION}}" and issuetype = epic and status not in (closed, "Release Pending", "Dev Complete")
+```
+
+- **Placeholders:** `{{RELEASE_VERSION}}`
+- **Example:** `... AND fixVersion = "1.9.0" and issuetype = epic and status not in (closed, "Release Pending", "Dev Complete")`
+- **Notes:** Identifies EPICs that need attention before release.
+
+## cves
+
+Find all CVE issues (vulnerabilities and weaknesses).
+
+```jql
+project IN (RHIDP, rhdhbugs) AND fixVersion = "{{RELEASE_VERSION}}" and issuetype in (weakness, Vulnerability, bug) and summary ~ "CVE*"
+```
+
+- **Placeholders:** `{{RELEASE_VERSION}}`
+- **Example:** `... AND fixVersion = "1.9.0" and issuetype in (weakness, Vulnerability, bug) and summary ~ "CVE*"`
+- **Notes:** Critical for security tracking before release.
+
+## feature_demos
+
+Find features tagged for demonstration.
+
+```jql
+project in (RHDHPlan, RHIDP) AND issuetype = feature AND labels = demo AND fixVersion = "{{RELEASE_VERSION}}" AND status != closed
+```
+
+- **Placeholders:** `{{RELEASE_VERSION}}`
+- **Example:** `... AND labels = demo AND fixVersion = "1.9.0" AND status != closed`
+- **Notes:** Features that need demo preparation.
+
+## feature_subtasks
+
+Find feature subtasks for acceptance criteria verification.
+
+```jql
+project in (RHDHPlan) AND issuetype = sub-task AND fixVersion = "{{RELEASE_VERSION}}" AND status != closed
+```
+
+- **Placeholders:** `{{RELEASE_VERSION}}`
+- **Example:** `... AND fixVersion = "1.9.0" AND status != closed`
+- **Notes:** Tracks feature verification and demo creation tasks.
+
+## test_day_features
+
+Find features designated for Test Day.
+
+```jql
+Project in (RHDHPlan, rhidp) AND issuetype = feature AND labels = rhdh-testday AND fixVersion = "{{RELEASE_VERSION}}" AND status != closed
+```
+
+- **Placeholders:** `{{RELEASE_VERSION}}`
+- **Example:** `... AND labels = rhdh-testday AND fixVersion = "1.9.0" AND status != closed`
+- **Notes:** Features ready for Test Day validation.
+
+## features_added_to_release
+
+Find features added to release in last 14 days.
+
+```jql
+project in (RHDHPlan, rhidp) AND issuetype = feature AND fixVersion = "{{RELEASE_VERSION}}" AND fixversion changed after -14d
+```
+
+- **Placeholders:** `{{RELEASE_VERSION}}`
+- **Example:** `... AND fixVersion = "1.9.0" AND fixversion changed after -14d`
+- **Notes:** Tracks scope changes to release.
+
+## release_notes
+
+Find issues missing Release Note Type field.
+
+```jql
+project in (RHIDP, "Red Hat Developer Hub Bugs", "RHDH Support", rhdhplan) and issuetype in (Feature, bug) and "Release Note Type" is EMPTY and fixVersion = "{{RELEASE_VERSION}}"
+```
+
+- **Placeholders:** `{{RELEASE_VERSION}}`
+- **Example:** `... AND "Release Note Type" is EMPTY and fixVersion = "1.9.0"`
+- **Notes:** Critical for documentation — must be filled before release.
+
+## blockers
+
+Find open blocker bugs for a release.
+
+```jql
+project IN (RHIDP, RHDHBugs, RHDHPLAN, RHDHSUPP) AND fixVersion = "{{RELEASE_VERSION}}" AND status != closed AND issuetype = bug AND priority = Blocker
+```
+
+- **Placeholders:** `{{RELEASE_VERSION}}`
+- **Example:** `... AND fixVersion = "1.9.0" AND status != closed AND issuetype = bug AND priority = Blocker`
+- **Notes:** Critical path items that must be resolved before release.
+
+## feature_freeze_issues
+
+Find feature work outstanding at Feature Freeze.
+
+```jql
+project IN (RHIDP, RHDHBugs, RHDHPLAN, RHDHSUPP) AND fixVersion = "{{RELEASE_VERSION}}" and resolution is EMPTY AND component not in (AI, Build, Certification, "Continuous Improvement", Documentation, Knowledge, Performance, Quality, Quickstart, Release, "RHDH Local", Security, Segment, Serviceability, Support, "Team Operations", "Test Framework", "Test Infrastructure", "Upstream & Community", UX) AND Type not in (Bug, Vulnerability, sub-task) AND status not in ("Dev Complete", "Release Pending", Done, Closed) AND (labels is EMPTY OR labels != stretch-goal)
+```
+
+- **Placeholders:** `{{RELEASE_VERSION}}`
+- **Notes:** Excludes infrastructure/ops components and bugs. Use for Feature Freeze announcements. The component exclusion list filters out non-feature work that shouldn't block Feature Freeze.
+
+## code_freeze_issues
+
+Find all issues outstanding at Code Freeze.
+
+```jql
+project IN (RHIDP, RHDHBugs, RHDHPLAN, RHDHSUPP) AND fixVersion = "{{RELEASE_VERSION}}" and status != closed
+```
+
+- **Placeholders:** `{{RELEASE_VERSION}}`
+- **Notes:** All open work. Same as `open_issues` — used for Code Freeze announcements.
diff --git a/skills/rhdh-release/references/slack-templates.md b/skills/rhdh-release/references/slack-templates.md
new file mode 100644
index 0000000..85c7e7a
--- /dev/null
+++ b/skills/rhdh-release/references/slack-templates.md
@@ -0,0 +1,118 @@
+# Slack Announcement Templates
+
+Templates for release milestone announcements. Each template uses `{{PLACEHOLDER}}` syntax for values that must be filled from Jira and Google Sheets data.
+
+**Important:** Always wrap the final Slack message in a triple-backtick code block (` ```slack `) so the user can copy-paste directly into Slack.
+
+---
+
+## Feature Freeze Update
+
+- **Milestone:** Feature Freeze
+- **When to send:** Before Feature Freeze date
+- **Data requirements:**
+ 1. Feature Freeze date from release issue (use `active_release` query + `acli jira workitem view`)
+ 2. Active engineering teams from spreadsheet (Category=Engineering, Status=Active)
+ 3. Outstanding Release Notes count (use `release_notes` query with `--count`)
+ 4. Team issue counts (use `feature_freeze_issues` query filtered by team)
+
+```slack
+:announcement: *RHDH {{RELEASE_VERSION}} [Feature Freeze](https://docs.google.com/document/d/1IjMH985f3XUhXl_6drfUKopLxTBoY0VMJ2Zpr_62K2g/edit?tab=t.0#bookmark=id.5a1n60q199qh) Update* :announcement:
+
+Feature Freeze is coming up and its target date is *{{FEATURE_FREEZE_DATE}}*. To check on the Feature Freeze status, you can use the [RHDH Release Tracking dashboard](https://issues.redhat.com/secure/Dashboard.jspa?selectPageId=12363303) and set fixversion to the current release.
+
+Here's what's outstanding for Feature Freeze. Please review and share if there are any risks to meet this milestone.
+
+• *{{TEAM_NAME}}* - [{{ISSUE_COUNT}}]({{JIRA_LINK}}) @{{LEAD_SLACK}}
+(repeat for each active engineering team)
+
+There are [{{OUTSTANDING_RELEASE_NOTES_ISSUE_COUNT}}]({{RELEASE_NOTES_JIRA_LINK}}) outstanding Release Notes. Please review and update Features and bugs.
+
+cc @rhdh-release
+```
+
+---
+
+## Feature Freeze Announcement
+
+- **Milestone:** Feature Freeze
+- **When to send:** On Feature Freeze date
+- **Data requirements:**
+ 1. Open EPICs count (use `epics` query with `--count`)
+ 2. CVE issues count (use `cves` query with `--count`)
+ 3. Outstanding Release Notes count (use `release_notes` query with `--count`)
+
+```slack
+:rotating_light: *RHDH {{RELEASE_VERSION}} [Feature Freeze](https://docs.google.com/document/d/1IjMH985f3XUhXl_6drfUKopLxTBoY0VMJ2Zpr_62K2g/edit?tab=t.0#bookmark=id.5a1n60q199qh)* :rotating_light:
+
+Its Feature Freeze! To see the latest status use the [RHDH Release Tracking dashboard](https://issues.redhat.com/secure/Dashboard.jspa?selectPageId=12363303) and set fixversion to the current release.
+:one: The release branch is created, and any work intended for the release must be cherry-picked into this branch.
+:two: Release and test pipelines are being set up for the release branch.
+:three: The Test Plan is approved, and any required manual testing is identified.
+:four: [{{EPIC_ISSUE_COUNT}}]({{JIRA_LINK}}) Engineering EPICs that are outstanding.
+:five: [{{CVE_ISSUE_COUNT}}]({{JIRA_LINK}}) CVEs on target to be fixed before code freeze.
+:six: [{{OUTSTANDING_RELEASE_NOTES_ISSUE_COUNT}}]({{JIRA_LINK}}) Release Notes to be updated before Release Notes date. Refer to [Release Notes Dashboard](https://issues.redhat.com/secure/Dashboard.jspa?selectPageId=12382090) for more details.
+:seven: Reminder to start verifying Features and creating Feature Demos.
+
+Please adhere to these rules so we can keep the release stable and on track. Let me know if you have any questions.
+
+Thanks for your support.
+cc @rhdh-release
+```
+
+---
+
+## Code Freeze Update
+
+- **Milestone:** Code Freeze
+- **When to send:** Before Code Freeze date
+- **Data requirements:**
+ 1. Code Freeze date from release issue (use `active_release` query + `acli jira workitem view`)
+ 2. Active engineering teams from spreadsheet (Category=Engineering, Status=Active)
+ 3. Outstanding Release Notes count (use `release_notes` query with `--count`)
+ 4. Feature Subtasks count (use `feature_subtasks` query with `--count`)
+ 5. Team issue counts (use `code_freeze_issues` query filtered by team)
+
+```slack
+:announcement: *RHDH {{RELEASE_VERSION}} [Code Freeze](https://docs.google.com/document/d/1IjMH985f3XUhXl_6drfUKopLxTBoY0VMJ2Zpr_62K2g/edit?tab=t.0#bookmark=id.ecpldu1g74vj) Update* :announcement:
+
+Code Freeze is coming up and its target date is *{{CODE_FREEZE_DATE}}*. To check on the Code Freeze status, you can use the [RHDH Release Tracking dashboard](https://issues.redhat.com/secure/Dashboard.jspa?selectPageId=12363303) and set fixversion to the current release.
+
+:one: Here's what's outstanding for Code Freeze. Please review and share if there are any risks to meet this milestone or retriage to future release if applicable.
+
+• *{{TEAM_NAME}}* - [{{TEAM_ISSUE_COUNT}}]({{JIRA_LINK}}) @{{LEAD_SLACK}}
+(repeat for each active engineering team)
+
+:two: There are [{{OUTSTANDING_RELEASE_NOTES_ISSUE_COUNT}}]({{RELEASE_NOTES_JIRA_LINK}}) outstanding Release Notes. Please review and update Features and bugs.
+:three: [{{FEATURE_SUBTASK_ISSUE_COUNT}}]({{FEATURE_SUBTASK_JIRA_LINK}}) Feature Subtasks outstanding to verifying Features Acceptance Criteria and creating Feature Demos if needed.
+cc @rhdh-release
+```
+
+---
+
+## Code Freeze Announcement
+
+- **Milestone:** Code Freeze
+- **When to send:** On Code Freeze date
+- **Data requirements:**
+ 1. Blocker bugs (use `open_issues` query + `priority = blocker` filter — detailed)
+ 2. Feature Demos count (use `feature_demos` query with `--count`)
+ 3. Test Day Features count (use `test_day_features` query with `--count`)
+ 4. Open issue count (use `open_issues` query with `--count`)
+
+```slack
+:rotating_light: Heads up @rhdh-core - Its {{RELEASE_VERSION}} [Code Freeze](https://docs.google.com/document/d/1IjMH985f3XUhXl_6drfUKopLxTBoY0VMJ2Zpr_62K2g/edit?tab=t.0#bookmark=id.ecpldu1g74vj) :rotating_light:
+:one: No cherry-picks into the release {{RELEASE_VERSION}} branch are allowed without explicit approval from both the @rhdh-release-manager
+:two: [{{BLOCKER_BUG_ISSUE_COUNT}}]({{JIRA_LINK}}) Blocker bugs outstanding.
+:three: Regarding CVEs: Only critical severity CVEs will be considered for inclusion before GA, and these will follow the same approval process (Release Manager). All other CVEs will be handled in the next z stream release
+:four: Review and update [Release Notes and Known Issues](https://issues.redhat.com/secure/Dashboard.jspa?selectPageId=12382090#)
+:five: [Feature Demos](https://docs.google.com/document/d/1IjMH985f3XUhXl_6drfUKopLxTBoY0VMJ2Zpr_62K2g/edit?tab=t.0#bookmark=id.l8izl2mswrfb): [{{FEATURE_DEMO_ISSUE_COUNT}}]({{JIRA_LINK}}) Features are tagged for demos. Add your demos and update the RHDH Release Features Slide in the {{RELEASE_VERSION}} [folder](https://drive.google.com/drive/folders/1QKf2hgOxCo6cmWkJ0b78o1Byx8uxgK_E?q=title:%3C1.9.0%3E)
+:six: [{{TEST_DAY_FEATURE_ISSUE_COUNT}}]({{JIRA_LINK}}) Features are tagged for Testday. Please review they are ready for Testday.
+:seven: [{{OPEN_ISSUE_COUNT}}]({{JIRA_LINK}}) issues set to {{RELEASE_VERSION}} and not closed. Please review and move to the next release as appropriate and can be fixed in main branch ONLY.
+:eight: Release Candidate: Once the release candidate is available then will proceed with the Test plan.
+
+Please adhere to these rules so we can keep the release stable and on track. Let me know if you have any questions.
+
+Thanks for your support!
+@rhdh-release
+```
diff --git a/skills/rhdh-release/scripts/formatters.py b/skills/rhdh-release/scripts/formatters.py
new file mode 100644
index 0000000..ffe752f
--- /dev/null
+++ b/skills/rhdh-release/scripts/formatters.py
@@ -0,0 +1,314 @@
+"""Output formatting for rhdh CLI.
+
+Auto-detects output format based on context:
+- TTY (terminal) → Human-readable with colors
+- Not TTY (piped/Claude) → JSON for machine parsing
+
+Override with --human or --json flags.
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+from dataclasses import dataclass, field
+from typing import TYPE_CHECKING, Any
+
+if TYPE_CHECKING:
+ from collections.abc import Callable
+
+
+def detect_output_mode() -> str:
+ """Detect whether to use human or JSON output.
+
+ Returns:
+ "human" if stdout is a TTY, "json" otherwise
+ """
+ return "human" if sys.stdout.isatty() else "json"
+
+
+# ANSI color codes
+RED = "\033[0;31m"
+GREEN = "\033[0;32m"
+YELLOW = "\033[1;33m"
+BLUE = "\033[0;34m"
+BOLD = "\033[1m"
+NC = "\033[0m" # No Color
+
+
+@dataclass
+class OutputFormatter:
+ """Formats CLI output as JSON or human-readable.
+
+ In human mode, commands use log_ok/log_warn/log_fail for inline output,
+ then call success() just for next_steps. In JSON mode, success() outputs
+ the full structured response.
+
+ Attributes:
+ mode: "human" for colored text, "json" for machine-parseable output
+ verbose: Include debug information
+ """
+
+ mode: str = "auto" # "auto", "human", or "json"
+ verbose: bool = False
+ _debug_info: dict[str, Any] = field(default_factory=dict)
+ _has_human_output: bool = field(default=False, repr=False)
+
+ def __post_init__(self):
+ if self.mode == "auto":
+ self.mode = detect_output_mode()
+
+ @property
+ def is_human(self) -> bool:
+ return self.mode == "human"
+
+ def add_debug(self, key: str, value: Any) -> None:
+ """Add debug information (included if verbose=True)."""
+ self._debug_info[key] = value
+
+ # =========================================================================
+ # Success Output
+ # =========================================================================
+
+ def success(
+ self,
+ data: dict[str, Any],
+ next_steps: list[str] | None = None,
+ ) -> None:
+ """Output a success response."""
+ if self.is_human:
+ self._render_human_success(data, next_steps)
+ else:
+ self._render_json_success(data, next_steps)
+
+ def _render_json_success(
+ self,
+ data: dict[str, Any],
+ next_steps: list[str] | None,
+ ) -> None:
+ """Render success as JSON."""
+ response = {
+ "success": True,
+ "data": data,
+ }
+ if next_steps:
+ response["next_steps"] = next_steps
+ if self.verbose and self._debug_info:
+ response["debug"] = self._debug_info
+ print(json.dumps(response, indent=2, default=str))
+
+ def _render_human_success(
+ self,
+ data: dict[str, Any],
+ next_steps: list[str] | None,
+ ) -> None:
+ """Render success as human-readable text.
+
+ If log_* methods were used, skip data rendering (already shown inline).
+ Only render next_steps.
+ """
+ if not self._has_human_output:
+ # No inline output was done, render the data
+ self._render_data(data)
+
+ if next_steps:
+ print(f"\n{BOLD}Next steps:{NC}")
+ for step in next_steps:
+ print(f" {BLUE}{step}{NC}")
+
+ def _render_data(self, data: dict[str, Any], indent: int = 0) -> None:
+ """Recursively render data in human-readable format."""
+ prefix = " " * indent
+
+ for key, value in data.items():
+ if key == "checks" and isinstance(value, list):
+ self._render_checks(value, prefix)
+ elif key == "items" and isinstance(value, list):
+ self._render_items(value, prefix)
+ elif isinstance(value, dict):
+ print(f"{prefix}{BOLD}{key}:{NC}")
+ self._render_data(value, indent + 1)
+ elif isinstance(value, list):
+ if value:
+ print(f"{prefix}{BOLD}{key}:{NC}")
+ for item in value:
+ if isinstance(item, dict):
+ self._render_data(item, indent + 1)
+ print()
+ else:
+ print(f"{prefix} - {item}")
+ elif isinstance(value, bool):
+ icon = f"{GREEN}✓{NC}" if value else f"{RED}✗{NC}"
+ print(f"{prefix}{icon} {key}")
+ else:
+ print(f"{prefix}{key}: {value}")
+
+ def _render_checks(self, checks: list[dict], prefix: str) -> None:
+ """Render a list of check results."""
+ for check in checks:
+ status = check.get("status", "unknown")
+ name = check.get("name", "unknown")
+ message = check.get("message", "")
+
+ if status == "pass":
+ icon = f"{GREEN}✓{NC}"
+ elif status == "warn":
+ icon = f"{YELLOW}⚠{NC}"
+ else:
+ icon = f"{RED}✗{NC}"
+
+ if message:
+ print(f"{prefix}{icon} {name}: {message}")
+ else:
+ print(f"{prefix}{icon} {name}")
+
+ def _render_items(self, items: list[dict], prefix: str) -> None:
+ """Render a list of items (workspaces, etc.)."""
+ for item in items:
+ name = item.get("name", "unknown")
+ detail = item.get("detail", "")
+ print(f"{prefix} {BLUE}{name:<30}{NC} {detail}")
+
+ # =========================================================================
+ # Error Output
+ # =========================================================================
+
+ def error(
+ self,
+ code: str,
+ message: str,
+ next_steps: list[str] | None = None,
+ ) -> None:
+ """Output an error response."""
+ if self.is_human:
+ self._render_human_error(code, message, next_steps)
+ else:
+ self._render_json_error(code, message, next_steps)
+
+ def _render_json_error(
+ self,
+ code: str,
+ message: str,
+ next_steps: list[str] | None,
+ ) -> None:
+ """Render error as JSON."""
+ response = {
+ "success": False,
+ "error": {
+ "code": code,
+ "message": message,
+ },
+ }
+ if next_steps:
+ response["next_steps"] = next_steps
+ if self.verbose and self._debug_info:
+ response["debug"] = self._debug_info
+ print(json.dumps(response, indent=2))
+
+ def _render_human_error(
+ self,
+ code: str,
+ message: str,
+ next_steps: list[str] | None,
+ ) -> None:
+ """Render error as human-readable text."""
+ print(f"{RED}Error [{code}]:{NC} {message}", file=sys.stderr)
+
+ if next_steps:
+ print(f"\n{BOLD}To fix:{NC}")
+ for step in next_steps:
+ print(f" - {step}")
+
+ # =========================================================================
+ # Convenience Methods (human-style logging)
+ # =========================================================================
+
+ def header(self, text: str) -> None:
+ """Print a section header (human mode only, ignored in JSON)."""
+ if self.is_human:
+ print(f"\n{BOLD}{text}{NC}")
+ self._has_human_output = True
+
+ def log_ok(self, message: str) -> None:
+ """Log success message (human mode only)."""
+ if self.is_human:
+ print(f" {GREEN}✓{NC} {message}")
+ self._has_human_output = True
+
+ def log_warn(self, message: str) -> None:
+ """Log warning message (human mode only)."""
+ if self.is_human:
+ print(f" {YELLOW}⚠{NC} {message}")
+ self._has_human_output = True
+
+ def log_fail(self, message: str) -> None:
+ """Log failure message (human mode only)."""
+ if self.is_human:
+ print(f" {RED}✗{NC} {message}")
+ self._has_human_output = True
+
+ def log_info(self, message: str) -> None:
+ """Log info message (human mode only)."""
+ if self.is_human:
+ print(f" {BLUE}→{NC} {message}")
+ self._has_human_output = True
+
+ # =========================================================================
+ # Rendering Methods (human mode only, ignored in JSON)
+ # =========================================================================
+
+ def render_list(
+ self,
+ items: list[dict],
+ format_fn: Callable[[dict], str],
+ *,
+ summary: str | None = None,
+ ) -> None:
+ """Render a list of items (human mode only, ignored in JSON).
+
+ Args:
+ items: List of item dicts to render
+ format_fn: Function that takes an item dict and returns formatted string
+ summary: Optional summary line (e.g., "Total: 5 items")
+ """
+ if not self.is_human:
+ return
+
+ print()
+ for item in items:
+ print(f" {format_fn(item)}")
+ if summary:
+ print()
+ print(f" {summary}")
+ self._has_human_output = True
+
+ def render_banner(
+ self,
+ message: str,
+ call_to_action: str | None = None,
+ style: str = "warn",
+ ) -> None:
+ """Render a call-to-action banner (human mode only, ignored in JSON).
+
+ Args:
+ message: Main message text
+ call_to_action: Optional command to show
+ style: "warn" (yellow) or "info" (blue)
+ """
+ if not self.is_human:
+ return
+
+ color = YELLOW if style == "warn" else BLUE
+ print()
+ print(f"{color}{message}{NC}")
+ if call_to_action:
+ print(f" {call_to_action}")
+ self._has_human_output = True
+
+ def render_raw(self, content: str) -> None:
+ """Render raw content (human mode only, ignored in JSON)."""
+ if not self.is_human:
+ return
+
+ print(content)
+ self._has_human_output = True
diff --git a/skills/rhdh-release/scripts/jql.py b/skills/rhdh-release/scripts/jql.py
new file mode 100644
index 0000000..38aed60
--- /dev/null
+++ b/skills/rhdh-release/scripts/jql.py
@@ -0,0 +1,106 @@
+"""Parse JQL templates from references/jql-release.md.
+
+Reads the markdown file at runtime so the CLI and agent share one source of truth.
+Supports placeholder rendering ({{RELEASE_VERSION}}, {{ISSUE_TYPE}}) and
+URL-encoded Jira search links.
+"""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+from urllib.parse import quote
+
+JIRA_SEARCH_BASE = "https://issues.redhat.com/issues/?jql="
+
+_REFERENCES_DIR = Path(__file__).resolve().parent.parent / "references"
+_JQL_FILE = _REFERENCES_DIR / "jql-release.md"
+
+_TEMPLATE_CACHE: dict[str, str] | None = None
+
+
+def _parse_jql_file(path: Path | None = None) -> dict[str, str]:
+ """Parse ## headings and ```jql code blocks from jql-release.md."""
+ path = path or _JQL_FILE
+ text = path.read_text()
+ templates: dict[str, str] = {}
+ current_name: str | None = None
+ jql_lines: list[str] | None = None
+
+ for line in text.splitlines():
+ heading = re.match(r"^##\s+(\S+)", line)
+ if heading:
+ current_name = heading.group(1)
+ continue
+
+ if current_name and line.strip() == "```jql":
+ jql_lines = []
+ continue
+
+ if current_name and jql_lines is not None and line.strip() == "```":
+ templates[current_name] = " ".join(jql_lines).strip()
+ current_name = None
+ jql_lines = None
+ continue
+
+ if jql_lines is not None:
+ jql_lines.append(line.strip())
+
+ return templates
+
+
+def load_templates(path: Path | None = None) -> dict[str, str]:
+ """Load and cache JQL templates from jql-release.md."""
+ global _TEMPLATE_CACHE
+ if path is not None:
+ return _parse_jql_file(path)
+ if _TEMPLATE_CACHE is None:
+ _TEMPLATE_CACHE = _parse_jql_file()
+ return _TEMPLATE_CACHE
+
+
+def get_template(name: str, path: Path | None = None) -> str:
+ """Get a single JQL template by name. Raises KeyError if not found."""
+ templates = load_templates(path)
+ if name not in templates:
+ available = ", ".join(sorted(templates))
+ raise KeyError(f"Unknown JQL template '{name}'. Available: {available}")
+ return templates[name]
+
+
+def render(
+ name: str,
+ *,
+ version: str | None = None,
+ issue_type: str | None = None,
+ path: Path | None = None,
+) -> str:
+ """Render a JQL template with placeholder substitution."""
+ jql = get_template(name, path)
+ if version is not None:
+ jql = jql.replace("{{RELEASE_VERSION}}", version)
+ if issue_type is not None:
+ jql = jql.replace("{{ISSUE_TYPE}}", issue_type)
+ return jql
+
+
+def jira_url(jql: str) -> str:
+ """Build a Jira search URL from a JQL string."""
+ return JIRA_SEARCH_BASE + quote(jql, safe="")
+
+
+def render_with_url(
+ name: str,
+ *,
+ version: str | None = None,
+ issue_type: str | None = None,
+ path: Path | None = None,
+) -> tuple[str, str]:
+ """Render a JQL template and return (jql, jira_url)."""
+ jql = render(name, version=version, issue_type=issue_type, path=path)
+ return jql, jira_url(jql)
+
+
+def list_templates(path: Path | None = None) -> list[str]:
+ """Return sorted list of available template names."""
+ return sorted(load_templates(path))
diff --git a/skills/rhdh-release/scripts/release.py b/skills/rhdh-release/scripts/release.py
new file mode 100755
index 0000000..6fafd4c
--- /dev/null
+++ b/skills/rhdh-release/scripts/release.py
@@ -0,0 +1,1132 @@
+#!/usr/bin/env python3
+"""RHDH Release CLI — deterministic data gathering for release management.
+
+Gathers facts from Jira, Google Sheets, and local config. The agent routes
+to this CLI first, then adds judgment (flag risks, suggest actions).
+
+Usage:
+ python scripts/release.py status 1.9.0
+ python scripts/release.py status 1.9.0 --json
+ python scripts/release.py check
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import shutil
+import subprocess
+import sys
+from datetime import datetime
+from pathlib import Path
+
+_scripts_dir = Path(__file__).resolve().parent
+if str(_scripts_dir) not in sys.path:
+ sys.path.insert(0, str(_scripts_dir))
+
+import jql as jql_mod # noqa: E402
+import slack_templates as slack_mod # noqa: E402
+from formatters import OutputFormatter # noqa: E402
+
+JIRA_BASE = "https://issues.redhat.com"
+SCHEDULE_SHEET_ID = "1knVzlMW0l0X4c7gkoiuaGql1zuFgEGwHHBsj-ygUTnc"
+TEAM_SHEET_ID = "1vQXfvID72qwqvLb17eyGOvnZXrZG7NBzTGv6RP9wvyM"
+
+
+def _find_parse_issues() -> Path | None:
+ """Discover parse_issues.py from installed rhdh-jira skill or sibling directory."""
+ candidates = [
+ Path.home() / ".claude/skills/rhdh-jira/scripts/parse_issues.py",
+ Path(__file__).resolve().parent / "../../rhdh-jira/scripts/parse_issues.py",
+ ]
+ for p in candidates:
+ if p.exists():
+ return p
+ return None
+
+
+ISSUE_TYPES = ["Feature", "Epic", "Story", "Task", "Sub-task", "Bug", "Vulnerability", "Weakness"]
+
+
+def _normalize_team_name(name: str) -> str:
+ """Normalize team name for comparison: strip 'RHDH ' prefix, lowercase."""
+ n = name.strip()
+ if n.lower().startswith("rhdh "):
+ n = n[5:]
+ return n.lower()
+
+
+# ---------------------------------------------------------------------------
+# Google Sheets helpers (via gog CLI)
+# ---------------------------------------------------------------------------
+
+
+def _gog_sheets_get(sheet_id: str, range_name: str) -> list[list[str]]:
+ """Fetch sheet values via gog sheets get --json --results-only."""
+ result = subprocess.run(
+ ["gog", "sheets", "get", sheet_id, range_name, "--json", "--results-only"],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ raise RuntimeError(f"gog sheets get failed: {result.stderr.strip()}")
+ return json.loads(result.stdout)
+
+
+def _gog_sheets_tabs(sheet_id: str) -> list[str]:
+ """Fetch tab names via gog sheets metadata."""
+ result = subprocess.run(
+ ["gog", "sheets", "metadata", sheet_id, "--json"],
+ capture_output=True,
+ text=True,
+ )
+ if result.returncode != 0:
+ raise RuntimeError(f"gog sheets metadata failed: {result.stderr.strip()}")
+ meta = json.loads(result.stdout)
+ return [s["properties"]["title"] for s in meta.get("sheets", [])]
+
+
+# ---------------------------------------------------------------------------
+# Team mapping (from Google Sheets)
+# ---------------------------------------------------------------------------
+
+
+def _parse_teams(
+ rows: list[list[str]], category_filter: str | None = None, include_all: bool = False
+) -> list[dict]:
+ if not rows:
+ return []
+
+ header = [h.strip().lower() for h in rows[0]]
+ col = {}
+ for name in (
+ "category",
+ "team name",
+ "team id",
+ "description",
+ "status",
+ "leads",
+ "slack handles",
+ "cloud id",
+ ):
+ for i, h in enumerate(header):
+ if h == name:
+ col[name] = i
+ break
+
+ teams = []
+ for row in rows[1:]:
+
+ def cell(name: str) -> str:
+ idx = col.get(name)
+ if idx is None or idx >= len(row):
+ return ""
+ return row[idx].strip()
+
+ status = cell("status")
+ if not include_all and status.lower() != "active":
+ continue
+
+ category = cell("category")
+ if category_filter and category.lower() != category_filter.lower():
+ continue
+
+ team_id: int | str = cell("team id")
+ try:
+ team_id = int(team_id)
+ except (ValueError, TypeError):
+ pass
+
+ slack_handles = cell("slack handles")
+ slack_list = (
+ [s.strip() for s in slack_handles.split(",") if s.strip()] if slack_handles else []
+ )
+
+ teams.append(
+ {
+ "category": category,
+ "team_name": cell("team name"),
+ "team_id": team_id,
+ "description": cell("description"),
+ "status": status,
+ "leads": cell("leads"),
+ "slack_handles": slack_list,
+ "cloud_id": cell("cloud id"),
+ }
+ )
+
+ return teams
+
+
+# ---------------------------------------------------------------------------
+# Schedule parsing (from Google Sheets)
+# ---------------------------------------------------------------------------
+
+
+def _normalize_version(v: str) -> str:
+ """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: str) -> str | None:
+ """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
+
+
+def _row_date(cells: list[str]) -> str | None:
+ """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_schedule_tab(tabs: list[str]) -> str | None:
+ """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 = [t for t in tabs if "schedule" in t.lower()]
+ return fallback[0] if fallback else None
+
+
+def _find_milestones(rows: list[list[str]], version: str) -> dict[str, str | None]:
+ """Search sheet rows for RHDH version milestones.
+
+ Strategy:
+ 1. Find the GA row for the target version.
+ 2. Walk backwards to find Code Freeze and Feature Freeze rows.
+ """
+ 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 "],
+ }
+
+ 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: dict[str, str | None] = {"ga_date": ga_date} if ga_date else {}
+
+ found: dict[str, str] = {}
+ for i in range(ga_index - 1, -1, -1):
+ cells = [str(c) for c in rows[i]]
+ row_text = " " + " ".join(cells).lower() + " "
+
+ 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
+
+ milestones.update(found)
+ return milestones
+
+
+def _fetch_schedule(sheet_id: str, version: str) -> dict:
+ """Fetch milestones for a version from a Google Sheets schedule.
+
+ Returns dict with version, tab, feature_freeze, code_freeze, ga_date.
+ On error, returns dict with 'error' key.
+ """
+ try:
+ tabs = _gog_sheets_tabs(sheet_id)
+ except RuntimeError as e:
+ return {"error": str(e)}
+
+ tab = _find_schedule_tab(tabs)
+ if not tab:
+ return {"error": "no_schedule_tab_found", "tabs": tabs, "spreadsheet_id": sheet_id}
+
+ try:
+ rows = _gog_sheets_get(sheet_id, tab)
+ except RuntimeError as e:
+ return {"error": str(e)}
+
+ milestones = _find_milestones(rows, version)
+
+ ver = _normalize_version(version)
+ if not milestones.get("code_freeze") and not milestones.get("ga_date"):
+ return {
+ "error": "version_not_found",
+ "version": ver,
+ "tab": tab,
+ "spreadsheet_id": sheet_id,
+ "hint": "Check that the version string matches the sheet exactly",
+ }
+
+ return {
+ "version": ver,
+ "tab": tab,
+ "feature_freeze": milestones.get("feature_freeze"),
+ "code_freeze": milestones.get("code_freeze"),
+ "ga_date": milestones.get("ga_date"),
+ }
+
+
+# ---------------------------------------------------------------------------
+# Helpers
+# ---------------------------------------------------------------------------
+
+
+def _run(
+ cmd: list[str], *, check: bool = True, capture: bool = True
+) -> subprocess.CompletedProcess:
+ """Run a subprocess, capturing output."""
+ return subprocess.run(
+ cmd,
+ capture_output=capture,
+ text=True,
+ check=check,
+ )
+
+
+def _parse_acli_count(output: str) -> int:
+ """Parse acli --count output like '✓ Number of work items in the search: 42'."""
+ for line in reversed(output.strip().splitlines()):
+ m = re.search(r"(\d+)\s*$", line)
+ if m:
+ return int(m.group(1))
+ raise ValueError(f"Could not parse count from acli output: {output!r}")
+
+
+def _acli_count(jql: str, fmt: OutputFormatter) -> int:
+ """Run acli --count and return the parsed integer."""
+ result = _run(["acli", "jira", "workitem", "search", "--jql", jql, "--count"])
+ if fmt.verbose:
+ fmt.add_debug("acli_cmd", f"acli jira workitem search --jql {jql!r} --count")
+ return _parse_acli_count(result.stdout)
+
+
+def _acli_json_enriched(
+ jql: str,
+ *,
+ select: str = "key,summary,status,assignee,priority,team",
+ limit: int = 1000,
+) -> list[dict]:
+ """Run acli --json | parse_issues.py --enrich and return parsed list."""
+ parse_issues = _find_parse_issues()
+ if parse_issues is None:
+ raise RuntimeError(
+ "parse_issues.py not found. Install the rhdh-jira skill: npx skills add rhdh-jira"
+ )
+ acli = subprocess.Popen(
+ ["acli", "jira", "workitem", "search", "--jql", jql, "--json", "--limit", str(limit)],
+ stdout=subprocess.PIPE,
+ text=True,
+ )
+ parse = subprocess.Popen(
+ [sys.executable, str(parse_issues), "--enrich", "-s", select, "--json"],
+ stdin=acli.stdout,
+ stdout=subprocess.PIPE,
+ text=True,
+ )
+ if acli.stdout:
+ acli.stdout.close()
+ stdout, _ = parse.communicate()
+ acli.wait()
+ if parse.returncode != 0:
+ raise RuntimeError(f"parse_issues.py failed (exit {parse.returncode})")
+ issues = json.loads(stdout)
+ if len(issues) >= limit:
+ print(f"WARNING: Results may be truncated at limit={limit}", file=sys.stderr)
+ return issues
+
+
+def _acli_view_json(issue_key: str) -> dict:
+ """Fetch a single Jira issue as JSON."""
+ result = _run(["acli", "jira", "workitem", "view", issue_key, "--json"])
+ return json.loads(result.stdout)
+
+
+def _fetch_teams(category: str | None = None) -> list[dict]:
+ """Fetch team mapping from Google Sheets via gog."""
+ rows = _gog_sheets_get(TEAM_SHEET_ID, "Team")
+ if not rows:
+ raise RuntimeError("Team sheet is empty")
+ return _parse_teams(rows, category_filter=category)
+
+
+# ---------------------------------------------------------------------------
+# Subcommands
+# ---------------------------------------------------------------------------
+
+
+def cmd_check(_args: argparse.Namespace, fmt: OutputFormatter) -> None:
+ """Verify prerequisites: acli, .jira-token, gog, gog-auth."""
+ checks = []
+
+ acli_path = shutil.which("acli")
+ checks.append(
+ {
+ "name": "acli",
+ "status": "pass" if acli_path else "fail",
+ "message": acli_path or "not found on PATH",
+ }
+ )
+
+ token_file = Path.home() / ".jira-token"
+ checks.append(
+ {
+ "name": ".jira-token",
+ "status": "pass" if token_file.exists() else "warn",
+ "message": str(token_file)
+ if token_file.exists()
+ else "missing (optional — acli may authenticate via other methods)",
+ }
+ )
+
+ gog_path = shutil.which("gog")
+ checks.append(
+ {
+ "name": "gog",
+ "status": "pass" if gog_path else "warn",
+ "message": gog_path or "not found (needed for Google Sheets/Docs)",
+ }
+ )
+
+ gog_auth_ok = False
+ if gog_path:
+ try:
+ result = _run(
+ ["gog", "sheets", "metadata", TEAM_SHEET_ID, "--json"],
+ check=False,
+ )
+ gog_auth_ok = result.returncode == 0
+ except Exception:
+ gog_auth_ok = False
+ checks.append(
+ {
+ "name": "gog-auth",
+ "status": "pass" if gog_auth_ok else "warn",
+ "message": "authenticated" if gog_auth_ok else "run: gog auth add ",
+ }
+ )
+
+ if acli_path:
+ try:
+ result = _run(
+ ["acli", "jira", "workitem", "search", "--jql", "project=RHIDP", "--count"],
+ check=False,
+ )
+ jira_ok = result.returncode == 0
+ except Exception:
+ jira_ok = False
+ checks.append(
+ {
+ "name": "jira-connectivity",
+ "status": "pass" if jira_ok else "fail",
+ "message": "connected" if jira_ok else "acli cannot reach Jira",
+ }
+ )
+
+ all_pass = all(c["status"] == "pass" for c in checks)
+ has_fail = any(c["status"] == "fail" for c in checks)
+
+ for c in checks:
+ if c["status"] == "pass":
+ fmt.log_ok(f"{c['name']}: {c['message']}")
+ elif c["status"] == "warn":
+ fmt.log_warn(f"{c['name']}: {c['message']}")
+ else:
+ fmt.log_fail(f"{c['name']}: {c['message']}")
+
+ next_steps = []
+ if has_fail:
+ next_steps.append("Fix failing checks before running other commands")
+ if not all_pass:
+ next_steps.append("See: references/config.md for setup instructions")
+
+ fmt.success({"checks": checks, "all_pass": all_pass}, next_steps=next_steps or None)
+ if has_fail:
+ sys.exit(1)
+
+
+def cmd_dates(_args: argparse.Namespace, fmt: OutputFormatter) -> None:
+ """Retrieve active release dates from Jira."""
+ jql = jql_mod.render("active_release")
+ result = _run(["acli", "jira", "workitem", "search", "--jql", jql, "--json"])
+ issues = json.loads(result.stdout)
+
+ releases = []
+ for issue in issues:
+ key = issue.get("key", "")
+ summary = issue.get("fields", {}).get("summary", "")
+
+ detail = _acli_view_json(key)
+ desc = ""
+ desc_field = detail.get("fields", {}).get("description", {})
+ if isinstance(desc_field, dict):
+ desc = json.dumps(desc_field)
+ elif isinstance(desc_field, str):
+ desc = desc_field
+
+ dates = {}
+ for label in ["Feature Freeze", "Code Freeze", "Doc Freeze", "Go/No Go", "GA Announce"]:
+ m = re.search(rf"{re.escape(label)}[:\s]*(\d{{4}}-\d{{2}}-\d{{2}})", desc)
+ dates[label.lower().replace(" ", "_").replace("/", "_")] = m.group(1) if m else "TBD"
+
+ version_m = re.search(r"(\d+\.\d+(?:\.\d+)?)", summary)
+ if not version_m:
+ continue
+ version = version_m.group(1)
+
+ releases.append(
+ {
+ "version": version,
+ "issue_key": key,
+ "issue_url": f"{JIRA_BASE}/browse/{key}",
+ **dates,
+ }
+ )
+
+ fmt.header("Active Release Dates")
+ for r in releases:
+ fmt.log_info(f"RHDH {r['version']} ({r['issue_key']})")
+ for dk in ["feature_freeze", "code_freeze", "doc_freeze", "go_no_go", "ga_announce"]:
+ label = dk.replace("_", " ").title()
+ fmt.log_ok(f" {label}: {r[dk]}") if r[dk] != "TBD" else fmt.log_warn(f" {label}: TBD")
+
+ fmt.success({"releases": releases})
+
+
+def cmd_future_dates(args: argparse.Namespace, fmt: OutputFormatter) -> None:
+ """Retrieve future release dates from Google Sheets schedule."""
+ version = args.version
+
+ schedule = _fetch_schedule(SCHEDULE_SHEET_ID, version)
+ if "error" in schedule:
+ fmt.error(
+ "SCHEDULE_ERROR",
+ str(schedule["error"]),
+ next_steps=[
+ schedule.get("hint", "Check gog auth: gog auth add "),
+ ],
+ )
+ sys.exit(1)
+
+ fmt.header(f"RHDH {version} Schedule")
+ for key in ["feature_freeze", "code_freeze", "ga_date"]:
+ label = key.replace("_", " ").title()
+ val = schedule.get(key, "N/A")
+ fmt.log_info(f"{label}: {val}")
+
+ schedule["schedule_url"] = f"https://docs.google.com/spreadsheets/d/{SCHEDULE_SHEET_ID}/edit"
+ fmt.success({"schedule": schedule})
+
+
+def cmd_status(args: argparse.Namespace, fmt: OutputFormatter) -> None:
+ """Show open issue counts by type for a release version."""
+ version = args.version
+ rows = []
+
+ fmt.header(f"RHDH {version} — Release Status")
+
+ total = 0
+ for issue_type in ISSUE_TYPES:
+ jql, url = jql_mod.render_with_url(
+ "open_issues_by_type", version=version, issue_type=issue_type
+ )
+ count = _acli_count(jql, fmt)
+ total += count
+ rows.append(
+ {
+ "issue_type": issue_type,
+ "count": count,
+ "jira_url": url,
+ }
+ )
+ fmt.log_info(f"{issue_type:<15} {count:>5} {url}")
+
+ _, total_url = jql_mod.render_with_url("open_issues", version=version)
+ fmt.log_info(f"{'Total':<15} {total:>5} {total_url}")
+
+ recently_jql, recently_url = jql_mod.render_with_url(
+ "features_added_to_release", version=version
+ )
+ recently_count = _acli_count(recently_jql, fmt)
+
+ fmt.success(
+ {
+ "version": version,
+ "issue_counts": rows,
+ "total": total,
+ "total_jira_url": total_url,
+ "recently_added_features": recently_count,
+ "recently_added_url": recently_url,
+ }
+ )
+
+
+def cmd_teams(args: argparse.Namespace, fmt: OutputFormatter) -> None:
+ """List teams and leads from Google Sheets."""
+ teams = _fetch_teams(category=args.category)
+
+ fmt.header("RHDH Teams")
+ for t in teams:
+ slack = ", ".join(t.get("slack_handles", []))
+ fmt.log_info(f"{t['team_name']:<25} {t.get('leads', ''):<20} {slack}")
+
+ fmt.success(
+ {
+ "teams": teams,
+ "count": len(teams),
+ "source_url": "https://docs.google.com/spreadsheets/d/1vQXfvID72qwqvLb17eyGOvnZXrZG7NBzTGv6RP9wvyM/edit",
+ }
+ )
+
+
+def cmd_team_breakdown(args: argparse.Namespace, fmt: OutputFormatter) -> None:
+ """Per-team issue counts for a release (requires parse_issues.py --enrich)."""
+ version = args.version
+ jql = jql_mod.render("open_issues", version=version)
+ issues = _acli_json_enriched(jql, select="key,summary,status,team")
+
+ teams = _fetch_teams(category="Engineering")
+
+ counts: dict[str, int] = {}
+ for issue in issues:
+ team = issue.get("team", "Unassigned") or "Unassigned"
+ norm = _normalize_team_name(team)
+ counts[norm] = counts.get(norm, 0) + 1
+
+ rows = []
+ for t in teams:
+ name = t["team_name"]
+ norm = _normalize_team_name(name)
+ count = counts.pop(norm, 0)
+ rows.append(
+ {
+ "team_name": name,
+ "team_id": t.get("team_id"),
+ "count": count,
+ "leads": t.get("leads", ""),
+ "slack_handles": t.get("slack_handles", []),
+ }
+ )
+
+ matched_norms = {_normalize_team_name(t["team_name"]) for t in teams}
+ for norm_name, count in sorted(counts.items()):
+ if norm_name not in matched_norms:
+ rows.append({"team_name": norm_name, "count": count})
+
+ fmt.header(f"RHDH {version} — Issues by Team")
+ for r in rows:
+ fmt.log_info(f"{r['team_name']:<25} {r['count']:>5}")
+
+ _, total_url = jql_mod.render_with_url("open_issues", version=version)
+ fmt.success(
+ {
+ "version": version,
+ "team_breakdown": rows,
+ "total": sum(r["count"] for r in rows),
+ "total_jira_url": total_url,
+ }
+ )
+
+
+def cmd_blockers(args: argparse.Namespace, fmt: OutputFormatter) -> None:
+ """List open blocker bugs for a release."""
+ version = args.version
+ jql, url = jql_mod.render_with_url("blockers", version=version)
+ issues = _acli_json_enriched(jql, select="key,summary,status,assignee,priority,team")
+ count = len(issues)
+
+ fmt.header(f"RHDH {version} — Blocker Bugs")
+ for issue in issues:
+ fmt.log_info(
+ f"[{issue['key']}]({JIRA_BASE}/browse/{issue['key']}) "
+ f"{issue.get('summary', '')[:60]} — {issue.get('assignee', 'Unassigned')}"
+ )
+ fmt.success(
+ {
+ "version": version,
+ "blockers": issues,
+ "count": count,
+ "jira_url": url,
+ }
+ )
+
+
+def cmd_epics(args: argparse.Namespace, fmt: OutputFormatter) -> None:
+ """List outstanding Engineering EPICs for a release."""
+ version = args.version
+ jql, url = jql_mod.render_with_url("epics", version=version)
+ issues = _acli_json_enriched(jql, select="key,summary,status,assignee")
+ count = len(issues)
+
+ fmt.header(f"RHDH {version} — Outstanding EPICs")
+ for issue in issues:
+ fmt.log_info(
+ f"[{issue['key']}]({JIRA_BASE}/browse/{issue['key']}) "
+ f"{issue.get('summary', '')[:60]} — {issue.get('status', '')}"
+ )
+
+ fmt.success(
+ {
+ "version": version,
+ "epics": issues,
+ "count": count,
+ "jira_url": url,
+ }
+ )
+
+
+def cmd_cves(args: argparse.Namespace, fmt: OutputFormatter) -> None:
+ """List CVE issues for a release."""
+ version = args.version
+ jql, url = jql_mod.render_with_url("cves", version=version)
+ issues = _acli_json_enriched(jql, select="key,summary,status,priority,assignee,issuetype")
+ count = len(issues)
+
+ fmt.header(f"RHDH {version} — CVEs")
+ for issue in issues:
+ fmt.log_info(
+ f"[{issue['key']}]({JIRA_BASE}/browse/{issue['key']}) "
+ f"{issue.get('summary', '')[:60]} — {issue.get('priority', '')}"
+ )
+
+ fmt.success(
+ {
+ "version": version,
+ "cves": issues,
+ "count": count,
+ "jira_url": url,
+ }
+ )
+
+
+def cmd_notes(args: argparse.Namespace, fmt: OutputFormatter) -> None:
+ """Count issues missing Release Note Type."""
+ version = args.version
+ jql, url = jql_mod.render_with_url("release_notes", version=version)
+ count = _acli_count(jql, fmt)
+
+ dashboard_url = "https://issues.redhat.com/secure/Dashboard.jspa?selectPageId=12382090"
+
+ fmt.header(f"RHDH {version} — Release Notes")
+ fmt.log_info(f"Outstanding: {count} issues missing Release Note Type")
+ fmt.log_info(f"Dashboard: {dashboard_url}")
+
+ fmt.success(
+ {
+ "version": version,
+ "outstanding_count": count,
+ "jira_url": url,
+ "dashboard_url": dashboard_url,
+ }
+ )
+
+
+# ---------------------------------------------------------------------------
+# Slack subcommands
+# ---------------------------------------------------------------------------
+
+
+def _get_freeze_date(version: str, date_key: str) -> str:
+ """Get a freeze date from active release issues. Returns date or 'TBD'."""
+ jql = jql_mod.render("active_release")
+ result = _run(["acli", "jira", "workitem", "search", "--jql", jql, "--json"])
+ issues = json.loads(result.stdout)
+
+ for issue in issues:
+ summary = issue.get("fields", {}).get("summary", "")
+ if version in summary:
+ detail = _acli_view_json(issue["key"])
+ desc_field = detail.get("fields", {}).get("description", {})
+ desc = json.dumps(desc_field) if isinstance(desc_field, dict) else str(desc_field or "")
+ m = re.search(rf"{re.escape(date_key)}[:\s]*(\d{{4}}-\d{{2}}-\d{{2}})", desc)
+ if m:
+ return m.group(1)
+ return "TBD"
+
+
+def cmd_slack_feature_freeze_update(args: argparse.Namespace, fmt: OutputFormatter) -> None:
+ """Generate Feature Freeze Update Slack message."""
+ version = args.version
+
+ ff_date = _get_freeze_date(version, "Feature Freeze")
+ teams = _fetch_teams(category="Engineering")
+
+ rn_jql, rn_url = jql_mod.render_with_url("release_notes", version=version)
+ rn_count = _acli_count(rn_jql, fmt)
+
+ ff_jql = jql_mod.render("feature_freeze_issues", version=version)
+ issues = _acli_json_enriched(ff_jql, select="key,summary,status,team")
+
+ team_counts: dict[str, int] = {}
+ for issue in issues:
+ team = issue.get("team", "Unassigned") or "Unassigned"
+ norm = _normalize_team_name(team)
+ team_counts[norm] = team_counts.get(norm, 0) + 1
+
+ team_lines = []
+ for t in teams:
+ name = t["team_name"]
+ norm = _normalize_team_name(name)
+ count = team_counts.get(norm, 0)
+ slack_handles = t.get("slack_handles", [])
+ lead_slack = slack_handles[0] if slack_handles else t.get("leads", "")
+ team_lines.append(
+ {
+ "TEAM_NAME": name,
+ "ISSUE_COUNT": str(count),
+ "JIRA_LINK": jql_mod.jira_url(ff_jql),
+ "LEAD_SLACK": lead_slack,
+ }
+ )
+
+ template = slack_mod.get_template("feature_freeze_update")
+ template = slack_mod.fill_placeholders(
+ template,
+ {
+ "RELEASE_VERSION": version,
+ "FEATURE_FREEZE_DATE": ff_date,
+ "OUTSTANDING_RELEASE_NOTES_ISSUE_COUNT": str(rn_count),
+ "RELEASE_NOTES_JIRA_LINK": rn_url,
+ },
+ )
+ message = slack_mod.expand_team_lines(template, team_lines)
+
+ fmt.render_raw(f"```slack\n{message}\n```")
+ fmt.success(
+ {
+ "version": version,
+ "feature_freeze_date": ff_date,
+ "outstanding_release_notes": rn_count,
+ "team_counts": {t["TEAM_NAME"]: int(t["ISSUE_COUNT"]) for t in team_lines},
+ "slack_message": message,
+ }
+ )
+
+
+def cmd_slack_feature_freeze(args: argparse.Namespace, fmt: OutputFormatter) -> None:
+ """Generate Feature Freeze Announcement Slack message."""
+ version = args.version
+
+ epics_jql, epics_url = jql_mod.render_with_url("epics", version=version)
+ epics_count = _acli_count(epics_jql, fmt)
+
+ cves_jql, cves_url = jql_mod.render_with_url("cves", version=version)
+ cves_count = _acli_count(cves_jql, fmt)
+
+ rn_jql, rn_url = jql_mod.render_with_url("release_notes", version=version)
+ rn_count = _acli_count(rn_jql, fmt)
+
+ template = slack_mod.get_template("feature_freeze")
+
+ lines = template.splitlines()
+ filled: list[str] = []
+ for line in lines:
+ if "{{EPIC_ISSUE_COUNT}}" in line:
+ line = line.replace("{{EPIC_ISSUE_COUNT}}", str(epics_count))
+ line = line.replace("{{JIRA_LINK}}", epics_url)
+ elif "{{CVE_ISSUE_COUNT}}" in line:
+ line = line.replace("{{CVE_ISSUE_COUNT}}", str(cves_count))
+ line = line.replace("{{JIRA_LINK}}", cves_url)
+ elif "{{OUTSTANDING_RELEASE_NOTES_ISSUE_COUNT}}" in line:
+ line = line.replace("{{OUTSTANDING_RELEASE_NOTES_ISSUE_COUNT}}", str(rn_count))
+ line = line.replace("{{JIRA_LINK}}", rn_url)
+ line = line.replace("{{RELEASE_VERSION}}", version)
+ filled.append(line)
+ message = "\n".join(filled)
+
+ fmt.render_raw(f"```slack\n{message}\n```")
+ fmt.success(
+ {
+ "version": version,
+ "epics_count": epics_count,
+ "cves_count": cves_count,
+ "outstanding_release_notes": rn_count,
+ "slack_message": message,
+ }
+ )
+
+
+def cmd_slack_code_freeze_update(args: argparse.Namespace, fmt: OutputFormatter) -> None:
+ """Generate Code Freeze Update Slack message."""
+ version = args.version
+
+ cf_date = _get_freeze_date(version, "Code Freeze")
+ teams = _fetch_teams(category="Engineering")
+
+ rn_jql, rn_url = jql_mod.render_with_url("release_notes", version=version)
+ rn_count = _acli_count(rn_jql, fmt)
+
+ fs_jql, fs_url = jql_mod.render_with_url("feature_subtasks", version=version)
+ fs_count = _acli_count(fs_jql, fmt)
+
+ cf_jql = jql_mod.render("code_freeze_issues", version=version)
+ issues = _acli_json_enriched(cf_jql, select="key,summary,status,team")
+
+ team_counts: dict[str, int] = {}
+ for issue in issues:
+ team = issue.get("team", "Unassigned") or "Unassigned"
+ norm = _normalize_team_name(team)
+ team_counts[norm] = team_counts.get(norm, 0) + 1
+
+ team_lines = []
+ for t in teams:
+ name = t["team_name"]
+ norm = _normalize_team_name(name)
+ count = team_counts.get(norm, 0)
+ slack_handles = t.get("slack_handles", [])
+ lead_slack = slack_handles[0] if slack_handles else t.get("leads", "")
+ team_lines.append(
+ {
+ "TEAM_NAME": name,
+ "TEAM_ISSUE_COUNT": str(count),
+ "JIRA_LINK": jql_mod.jira_url(cf_jql),
+ "LEAD_SLACK": lead_slack,
+ }
+ )
+
+ template = slack_mod.get_template("code_freeze_update")
+ template = slack_mod.fill_placeholders(
+ template,
+ {
+ "RELEASE_VERSION": version,
+ "CODE_FREEZE_DATE": cf_date,
+ "OUTSTANDING_RELEASE_NOTES_ISSUE_COUNT": str(rn_count),
+ "RELEASE_NOTES_JIRA_LINK": rn_url,
+ "FEATURE_SUBTASK_ISSUE_COUNT": str(fs_count),
+ "FEATURE_SUBTASK_JIRA_LINK": fs_url,
+ },
+ )
+ message = slack_mod.expand_team_lines(template, team_lines)
+
+ fmt.render_raw(f"```slack\n{message}\n```")
+ fmt.success(
+ {
+ "version": version,
+ "code_freeze_date": cf_date,
+ "outstanding_release_notes": rn_count,
+ "feature_subtasks": fs_count,
+ "team_counts": {t["TEAM_NAME"]: int(t["TEAM_ISSUE_COUNT"]) for t in team_lines},
+ "slack_message": message,
+ }
+ )
+
+
+def cmd_slack_code_freeze(args: argparse.Namespace, fmt: OutputFormatter) -> None:
+ """Generate Code Freeze Announcement Slack message."""
+ version = args.version
+
+ blocker_jql, blocker_url = jql_mod.render_with_url("blockers", version=version)
+ blocker_count = _acli_count(blocker_jql, fmt)
+
+ demos_jql, demos_url = jql_mod.render_with_url("feature_demos", version=version)
+ demos_count = _acli_count(demos_jql, fmt)
+
+ testday_jql, testday_url = jql_mod.render_with_url("test_day_features", version=version)
+ testday_count = _acli_count(testday_jql, fmt)
+
+ open_jql, open_url = jql_mod.render_with_url("open_issues", version=version)
+ open_count = _acli_count(open_jql, fmt)
+
+ template = slack_mod.get_template("code_freeze")
+
+ lines = template.splitlines()
+ filled: list[str] = []
+ for line in lines:
+ if "{{BLOCKER_BUG_ISSUE_COUNT}}" in line:
+ line = line.replace("{{BLOCKER_BUG_ISSUE_COUNT}}", str(blocker_count))
+ line = line.replace("{{JIRA_LINK}}", blocker_url)
+ elif "{{FEATURE_DEMO_ISSUE_COUNT}}" in line:
+ line = line.replace("{{FEATURE_DEMO_ISSUE_COUNT}}", str(demos_count))
+ line = line.replace("{{JIRA_LINK}}", demos_url)
+ elif "{{TEST_DAY_FEATURE_ISSUE_COUNT}}" in line:
+ line = line.replace("{{TEST_DAY_FEATURE_ISSUE_COUNT}}", str(testday_count))
+ line = line.replace("{{JIRA_LINK}}", testday_url)
+ elif "{{OPEN_ISSUE_COUNT}}" in line:
+ line = line.replace("{{OPEN_ISSUE_COUNT}}", str(open_count))
+ line = line.replace("{{JIRA_LINK}}", open_url)
+ line = line.replace("{{RELEASE_VERSION}}", version)
+ filled.append(line)
+ message = "\n".join(filled)
+
+ fmt.render_raw(f"```slack\n{message}\n```")
+ fmt.success(
+ {
+ "version": version,
+ "blocker_bugs": blocker_count,
+ "feature_demos": demos_count,
+ "test_day_features": testday_count,
+ "open_issues": open_count,
+ "slack_message": message,
+ }
+ )
+
+
+# ---------------------------------------------------------------------------
+# CLI entrypoint
+# ---------------------------------------------------------------------------
+
+
+def build_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser(
+ prog="release",
+ description="RHDH Release CLI — deterministic data gathering for release management.",
+ )
+ parser.add_argument("--json", action="store_const", const="json", dest="output_mode")
+ parser.add_argument("--human", action="store_const", const="human", dest="output_mode")
+ parser.add_argument("--verbose", "-v", action="store_true")
+
+ sub = parser.add_subparsers(dest="command")
+
+ sub.add_parser("check", help="Verify prerequisites")
+
+ sub.add_parser("dates", help="Active release dates from Jira")
+
+ p = sub.add_parser("future-dates", help="Schedule from Google Sheets")
+ p.add_argument("version", help="Release version (e.g. 1.9.0)")
+
+ p = sub.add_parser("status", help="Issue counts by type")
+ p.add_argument("version", help="Release version (e.g. 1.9.0)")
+
+ p = sub.add_parser("teams", help="Team mapping from Google Sheets")
+ p.add_argument("--category", help="Filter by category (e.g. Engineering)")
+
+ p = sub.add_parser("team-breakdown", help="Per-team issue counts")
+ p.add_argument("version", help="Release version (e.g. 1.9.0)")
+
+ p = sub.add_parser("blockers", help="Blocker bug details")
+ p.add_argument("version", help="Release version (e.g. 1.9.0)")
+
+ p = sub.add_parser("epics", help="Outstanding EPICs")
+ p.add_argument("version", help="Release version (e.g. 1.9.0)")
+
+ p = sub.add_parser("cves", help="CVE list")
+ p.add_argument("version", help="Release version (e.g. 1.9.0)")
+
+ p = sub.add_parser("notes", help="Missing release notes count")
+ p.add_argument("version", help="Release version (e.g. 1.9.0)")
+
+ slack_parser = sub.add_parser("slack", help="Slack announcement templates")
+ slack_sub = slack_parser.add_subparsers(dest="slack_command")
+
+ p = slack_sub.add_parser("feature-freeze-update", help="Feature Freeze status update")
+ p.add_argument("version", help="Release version (e.g. 1.9.0)")
+
+ p = slack_sub.add_parser("feature-freeze", help="Feature Freeze announcement")
+ p.add_argument("version", help="Release version (e.g. 1.9.0)")
+
+ p = slack_sub.add_parser("code-freeze-update", help="Code Freeze status update")
+ p.add_argument("version", help="Release version (e.g. 1.9.0)")
+
+ p = slack_sub.add_parser("code-freeze", help="Code Freeze announcement")
+ p.add_argument("version", help="Release version (e.g. 1.9.0)")
+
+ return parser
+
+
+COMMANDS = {
+ "check": cmd_check,
+ "dates": cmd_dates,
+ "future-dates": cmd_future_dates,
+ "status": cmd_status,
+ "teams": cmd_teams,
+ "team-breakdown": cmd_team_breakdown,
+ "blockers": cmd_blockers,
+ "epics": cmd_epics,
+ "cves": cmd_cves,
+ "notes": cmd_notes,
+}
+
+SLACK_COMMANDS = {
+ "feature-freeze-update": cmd_slack_feature_freeze_update,
+ "feature-freeze": cmd_slack_feature_freeze,
+ "code-freeze-update": cmd_slack_code_freeze_update,
+ "code-freeze": cmd_slack_code_freeze,
+}
+
+
+def main(argv: list[str] | None = None) -> None:
+ parser = build_parser()
+ args = parser.parse_args(argv)
+
+ mode = args.output_mode or "auto"
+ fmt = OutputFormatter(mode=mode, verbose=args.verbose)
+
+ if args.command is None:
+ parser.print_help()
+ if fmt.is_human:
+ print("\nQuick start:")
+ print(" release check # verify prerequisites")
+ print(" release status 1.9.0 # issue counts by type")
+ print(" release dates # active release dates")
+ sys.exit(0)
+
+ if args.command == "slack":
+ if not args.slack_command:
+ fmt.error(
+ "MISSING_SUBCOMMAND",
+ "slack requires a subcommand: " + ", ".join(sorted(SLACK_COMMANDS)),
+ )
+ sys.exit(1)
+ handler = SLACK_COMMANDS.get(args.slack_command)
+ else:
+ handler = COMMANDS.get(args.command)
+
+ if not handler:
+ parser.print_help()
+ sys.exit(1)
+
+ try:
+ handler(args, fmt)
+ except subprocess.CalledProcessError as e:
+ fmt.error(
+ "COMMAND_FAILED",
+ f"{' '.join(e.cmd)} exited {e.returncode}: {(e.stderr or '').strip()}",
+ next_steps=["Run: python scripts/release.py check"],
+ )
+ sys.exit(1)
+ except RuntimeError as e:
+ fmt.error("RUNTIME_ERROR", str(e), next_steps=["Run: python scripts/release.py check"])
+ sys.exit(1)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/skills/rhdh-release/scripts/slack_templates.py b/skills/rhdh-release/scripts/slack_templates.py
new file mode 100644
index 0000000..cc577b3
--- /dev/null
+++ b/skills/rhdh-release/scripts/slack_templates.py
@@ -0,0 +1,117 @@
+"""Parse Slack announcement templates from references/slack-templates.md.
+
+Reads the markdown file at runtime so the CLI and agent share one source of truth.
+Supports placeholder filling ({{RELEASE_VERSION}}, {{FEATURE_FREEZE_DATE}}, etc.)
+and per-team line expansion.
+"""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+_REFERENCES_DIR = Path(__file__).resolve().parent.parent / "references"
+_SLACK_FILE = _REFERENCES_DIR / "slack-templates.md"
+
+TEMPLATE_KEYS = {
+ "Feature Freeze Update": "feature_freeze_update",
+ "Feature Freeze Announcement": "feature_freeze",
+ "Code Freeze Update": "code_freeze_update",
+ "Code Freeze Announcement": "code_freeze",
+}
+
+_TEMPLATE_CACHE: dict[str, str] | None = None
+
+
+def _parse_slack_file(path: Path | None = None) -> dict[str, str]:
+ """Parse ## headings and ```slack code blocks from slack-templates.md."""
+ path = path or _SLACK_FILE
+ text = path.read_text()
+ templates: dict[str, str] = {}
+ current_heading: str | None = None
+ slack_lines: list[str] | None = None
+
+ for line in text.splitlines():
+ heading = re.match(r"^##\s+(.+)$", line)
+ if heading:
+ current_heading = heading.group(1).strip()
+ continue
+
+ if current_heading and line.strip() == "```slack":
+ slack_lines = []
+ continue
+
+ if slack_lines is not None and line.strip() == "```":
+ key = TEMPLATE_KEYS.get(current_heading or "")
+ if key:
+ templates[key] = "\n".join(slack_lines)
+ current_heading = None
+ slack_lines = None
+ continue
+
+ if slack_lines is not None:
+ slack_lines.append(line)
+
+ return templates
+
+
+def load_templates(path: Path | None = None) -> dict[str, str]:
+ """Load and cache Slack templates from slack-templates.md."""
+ global _TEMPLATE_CACHE
+ if path is not None:
+ return _parse_slack_file(path)
+ if _TEMPLATE_CACHE is None:
+ _TEMPLATE_CACHE = _parse_slack_file()
+ return _TEMPLATE_CACHE
+
+
+def get_template(name: str, path: Path | None = None) -> str:
+ """Get a single Slack template by key name. Raises KeyError if not found."""
+ templates = load_templates(path)
+ if name not in templates:
+ available = ", ".join(sorted(templates))
+ raise KeyError(f"Unknown Slack template '{name}'. Available: {available}")
+ return templates[name]
+
+
+def fill_placeholders(template: str, values: dict[str, str]) -> str:
+ """Replace {{PLACEHOLDER}} tokens with values from the dict."""
+ result = template
+ for key, value in values.items():
+ result = result.replace("{{" + key + "}}", value)
+ return result
+
+
+def expand_team_lines(
+ template: str,
+ teams: list[dict[str, str]],
+) -> str:
+ """Expand the per-team repeat block in a template.
+
+ Looks for the pattern line containing {{TEAM_NAME}} and the
+ "(repeat for each ...)" comment, replaces them with one line per team.
+ """
+ lines = template.splitlines()
+ expanded: list[str] = []
+ i = 0
+ while i < len(lines):
+ line = lines[i]
+ if "{{TEAM_NAME}}" in line:
+ team_template = line
+ if i + 1 < len(lines) and "repeat for each" in lines[i + 1].lower():
+ i += 1
+ for team in teams:
+ team_line = team_template
+ for key, value in team.items():
+ team_line = team_line.replace("{{" + key.upper() + "}}", value)
+ expanded.append(team_line)
+ i += 1
+ continue
+ expanded.append(line)
+ i += 1
+ return "\n".join(expanded)
+
+
+def list_templates(path: Path | None = None) -> list[str]:
+ """Return sorted list of available template keys."""
+ return sorted(load_templates(path))
diff --git a/skills/rhdh-release/tests/check-gsheets.md b/skills/rhdh-release/tests/check-gsheets.md
new file mode 100644
index 0000000..708c77d
--- /dev/null
+++ b/skills/rhdh-release/tests/check-gsheets.md
@@ -0,0 +1,72 @@
+# Google Sheets Smoke Checks — rhdh-release
+
+Requires `gog` CLI with authenticated Google account. All checks are read-only.
+
+## How to run
+
+```
+read @skills/rhdh-release/tests/check-gsheets.md
+```
+
+Then follow the checks below, reporting PASS/FAIL for each.
+
+## Prerequisites
+
+```bash
+gog sheets metadata 1vQXfvID72qwqvLb17eyGOvnZXrZG7NBzTGv6RP9wvyM --json
+```
+
+If this fails, run `gog auth add ` and retry.
+
+## Checks
+
+### 1. Team mapping fetch
+
+```bash
+python3 skills/rhdh-release/scripts/release.py --json teams
+```
+
+- [ ] Exits 0
+- [ ] Returns valid JSON
+- [ ] JSON contains at least 3 teams
+- [ ] Each team has `team_name`, `leads`, and `category` fields
+
+### 2. Team mapping filtered by category
+
+```bash
+python3 skills/rhdh-release/scripts/release.py --json teams --category Engineering
+```
+
+- [ ] Returns fewer or equal teams compared to check 1
+- [ ] All returned teams have `category: Engineering`
+
+### 3. Release schedule access
+
+Verify the release schedule spreadsheet is accessible via gog:
+
+```bash
+gog sheets metadata 1knVzlMW0l0X4c7gkoiuaGql1zuFgEGwHHBsj-ygUTnc --json
+```
+
+- [ ] Returns valid JSON (exit 0)
+- [ ] Response contains sheet/tab metadata (not an auth error)
+
+Then verify schedule data can be fetched via the release CLI:
+
+```bash
+python3 skills/rhdh-release/scripts/release.py --json future-dates 1.10
+```
+
+- [ ] Returns valid JSON with `version`, `tab`, and date fields
+
+## Report format
+
+```
+Google Sheets Smoke Checks — rhdh-release
+===========================================
+ 1. Team mapping fetch: PASS/FAIL (N teams found)
+ 2. Team mapping active filter: PASS/FAIL (N active teams)
+ 3. Release schedule access: PASS/FAIL (details)
+
+Result: X/3 passed
+```
diff --git a/skills/rhdh-release/tests/check-jira.md b/skills/rhdh-release/tests/check-jira.md
new file mode 100644
index 0000000..4c3b6d1
--- /dev/null
+++ b/skills/rhdh-release/tests/check-jira.md
@@ -0,0 +1,84 @@
+# Jira Smoke Checks — rhdh-release
+
+Requires `acli` on PATH with valid Jira credentials. All checks are read-only.
+
+## How to run
+
+```
+read @skills/rhdh-release/tests/check-jira.md
+```
+
+Then follow the checks below, reporting PASS/FAIL for each.
+
+## Prerequisites
+
+Run the Jira prerequisite from SKILL.md first:
+
+```bash
+acli jira workitem search --jql "project=RHIDP" --count
+```
+
+If this fails, stop — Jira access is not configured.
+
+## Checks
+
+### 1. JQL syntax validation
+
+Read `skills/rhdh-release/references/jql-release.md`. For each of the 12 query templates, substitute a real release version (discover it from check 2 below) and run with `--count` to verify the JQL parses without error.
+
+Use the `active_release` query first (no placeholders) to discover the current release version:
+
+```bash
+acli jira workitem search --jql "project=rhdhplan AND issuetype=feature AND component=release AND status != closed" --count
+```
+
+- [ ] `active_release` — returns count (no placeholders needed)
+
+Then for each remaining query, substitute `{{RELEASE_VERSION}}` with the discovered version:
+
+- [ ] `open_issues` — returns count
+- [ ] `open_issues_by_type` — substitute `{{ISSUE_TYPE}}` with `Bug`, returns count
+- [ ] `epics` — returns count
+- [ ] `cves` — returns count
+- [ ] `feature_demos` — returns count
+- [ ] `feature_subtasks` — returns count
+- [ ] `test_day_features` — returns count
+- [ ] `features_added_to_release` — returns count
+- [ ] `release_notes` — returns count
+- [ ] `feature_freeze_issues` — returns count
+- [ ] `code_freeze_issues` — returns count
+
+### 2. parse_issues.py integration
+
+Run one query through the enrichment pipeline to verify `parse_issues.py` works:
+
+```bash
+acli jira workitem search --jql "project=rhdhplan AND issuetype=feature AND component=release AND status != closed" --json | python ~/.claude/skills/rhdh-jira/scripts/parse_issues.py --enrich -s key,summary,status
+```
+
+- [ ] Returns structured output with key, summary, status columns
+- [ ] No Python errors
+
+### 3. Team enrichment
+
+Run the open_issues query with team enrichment (use `--limit 5` to keep it fast):
+
+```bash
+acli jira workitem search --jql 'project IN (RHIDP, RHDHBugs, RHDHPLAN, RHDHSUPP) AND fixVersion = "{{RELEASE_VERSION}}" AND status != closed' --limit 5 --json | python ~/.claude/skills/rhdh-jira/scripts/parse_issues.py --enrich -s key,summary,status,team
+```
+
+- [ ] Output includes a `team` column
+- [ ] No Python errors
+
+## Report format
+
+```
+Jira Smoke Checks — rhdh-release
+==================================
+ 1. JQL syntax validation: PASS/FAIL (N/12 queries valid)
+ 2. parse_issues.py: PASS/FAIL (details)
+ 3. Team enrichment: PASS/FAIL (details)
+
+Release version tested: X.Y.Z
+Result: X/3 passed
+```
diff --git a/skills/rhdh-release/tests/check-structural.md b/skills/rhdh-release/tests/check-structural.md
new file mode 100644
index 0000000..fb62242
--- /dev/null
+++ b/skills/rhdh-release/tests/check-structural.md
@@ -0,0 +1,131 @@
+# Structural Checks — rhdh-release
+
+Read-only, no external dependencies. Verifies file integrity and cross-references.
+
+## How to run
+
+```
+read @skills/rhdh-release/tests/check-structural.md
+```
+
+Then follow the checks below, reporting PASS/FAIL for each.
+
+## Checks
+
+### 1. SKILL.md structure
+
+Read `skills/rhdh-release/SKILL.md` and verify:
+
+- [ ] YAML frontmatter has `name: rhdh-release`
+- [ ] YAML frontmatter has `description` (length > 20 chars)
+- [ ] Contains `` ... ``
+- [ ] Contains `` ... `` with numbered options 1–13
+- [ ] Contains `` ... `` with a markdown table
+- [ ] Contains `` ... ``
+- [ ] Contains `` ... ``
+
+### 2. Routing → workflow consistency
+
+Parse the `` table in SKILL.md. For each row that references `workflows/.md`:
+
+- [ ] The file `skills/rhdh-release/workflows/.md` exists
+
+List any broken references.
+
+### 3. Workflow file structure
+
+For every `.md` file in `skills/rhdh-release/workflows/`:
+
+- [ ] Contains `` or ``
+- [ ] Contains `` ... ``
+- [ ] Contains `` ... ``
+
+List any files missing required sections.
+
+### 4. Workflow count
+
+- [ ] Exactly 13 workflow files exist in `skills/rhdh-release/workflows/`
+
+### 5. JQL template coverage
+
+Read `skills/rhdh-release/references/jql-release.md`. Extract all `## ` headings.
+
+- [ ] Exactly 13 query templates exist
+
+Then grep all workflow files for references to each query name. Verify:
+
+- [ ] Every JQL query name is referenced by at least one workflow
+
+List any orphaned queries.
+
+### 6. Slack template coverage
+
+Read `skills/rhdh-release/references/slack-templates.md`. Extract all `## ` headings.
+
+- [ ] Exactly 4 Slack templates exist
+
+Then check that each template is referenced by a matching workflow file in `workflows/`:
+
+- [ ] `Feature Freeze Update` → `announce-feature-freeze-update.md`
+- [ ] `Feature Freeze Announcement` → `announce-feature-freeze.md`
+- [ ] `Code Freeze Update` → `announce-code-freeze-update.md`
+- [ ] `Code Freeze Announcement` → `announce-code-freeze.md`
+
+### 7. Reference file existence
+
+Verify all local files listed in `` exist:
+
+- [ ] `references/jql-release.md`
+- [ ] `references/slack-templates.md`
+- [ ] `references/config.md`
+
+Note: The release process reference uses `gog docs cat` (live Google Doc) — no local file to check.
+
+### 8. Config cross-references
+
+Read `skills/rhdh-release/references/config.md`:
+
+- [ ] Contains `jira_default_base_jql`
+- [ ] Contains `team_mapping_gdrive_id`
+- [ ] Contains `release_schedule_gdrive_id`
+- [ ] Contains `release_process_doc_id`
+- [ ] Contains gog CLI setup instructions
+
+### 9. Script files (no symlinks)
+
+- [ ] `scripts/formatters.py` exists and is a regular file (not a symlink)
+- [ ] No symlinks remain in `scripts/` (`find scripts/ -type l` returns empty)
+- [ ] Exactly 4 Python scripts: `release.py`, `formatters.py`, `jql.py`, `slack_templates.py`
+
+### 10. Release CLI existence
+
+- [ ] `scripts/release.py` exists and is executable
+- [ ] `scripts/jql.py` exists
+- [ ] `scripts/slack_templates.py` exists
+- [ ] `python scripts/release.py --help` exits 0
+
+### 11. Version consistency
+
+Read `pyproject.toml`, `.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json` and verify:
+
+- [ ] All three files contain the same version string
+
+## Report format
+
+```
+Structural Checks — rhdh-release
+=================================
+ 1. SKILL.md structure: PASS/FAIL (details)
+ 2. Routing → workflow: PASS/FAIL (details)
+ 3. Workflow file structure: PASS/FAIL (details)
+ 4. Workflow count: PASS/FAIL (N/13)
+ 5. JQL template coverage: PASS/FAIL (details)
+ 6. Slack template coverage: PASS/FAIL (details)
+ 7. Reference file existence: PASS/FAIL (details)
+ 8. Config cross-references: PASS/FAIL (details)
+ 9. Script symlinks: PASS/FAIL (details)
+10. Release CLI existence: PASS/FAIL (details)
+11. Version consistency: PASS/FAIL (version)
+
+Result: X/11 passed
+```
diff --git a/skills/rhdh-release/tests/demo-release-cli.md b/skills/rhdh-release/tests/demo-release-cli.md
new file mode 100644
index 0000000..eb3b020
--- /dev/null
+++ b/skills/rhdh-release/tests/demo-release-cli.md
@@ -0,0 +1,231 @@
+# Release CLI — Structural Demo
+
+Structural verification of the release CLI — confirms scripts exist, no symlinks remain, CLI parses, JQL templates load, and Slack templates parse. No live Jira or Google credentials required.
+
+## Check 9: Script files (no symlinks)
+
+```bash
+test -f scripts/formatters.py && test ! -L scripts/formatters.py && echo '✓ scripts/formatters.py is a regular file' || echo '✗ scripts/formatters.py missing or is a symlink'
+```
+
+```output
+✓ scripts/formatters.py is a regular file
+```
+
+```bash
+symlinks=$(find scripts/ -type l 2>/dev/null); test -z "$symlinks" && echo '✓ No symlinks in scripts/' || echo "✗ Symlinks found: $symlinks"
+```
+
+```output
+✓ No symlinks in scripts/
+```
+
+## Check 10: Release CLI existence
+
+```bash
+test -f scripts/release.py && test -x scripts/release.py && echo '✓ scripts/release.py exists and is executable' || echo '✗ scripts/release.py missing or not executable'
+```
+
+```output
+✓ scripts/release.py exists and is executable
+```
+
+```bash
+test -f scripts/jql.py && echo '✓ scripts/jql.py' || echo '✗ scripts/jql.py missing'; test -f scripts/slack_templates.py && echo '✓ scripts/slack_templates.py' || echo '✗ scripts/slack_templates.py missing'
+```
+
+```output
+✓ scripts/jql.py
+✓ scripts/slack_templates.py
+```
+
+```bash
+python scripts/release.py --help
+```
+
+```output
+usage: release [-h] [--json] [--human] [--verbose]
+ {check,dates,future-dates,status,teams,team-breakdown,blockers,epics,cves,notes,slack}
+ ...
+
+RHDH Release CLI — deterministic data gathering for release management.
+
+positional arguments:
+ {check,dates,future-dates,status,teams,team-breakdown,blockers,epics,cves,notes,slack}
+ check Verify prerequisites
+ dates Active release dates from Jira
+ future-dates Schedule from Google Sheets
+ status Issue counts by type
+ teams Team mapping from Google Sheets
+ team-breakdown Per-team issue counts
+ blockers Blocker bug details
+ epics Outstanding EPICs
+ cves CVE list
+ notes Missing release notes count
+ slack Slack announcement templates
+
+options:
+ -h, --help show this help message and exit
+ --json
+ --human
+ --verbose, -v
+```
+
+```bash
+python scripts/release.py slack --help
+```
+
+```output
+usage: release slack [-h]
+ {feature-freeze-update,feature-freeze,code-freeze-update,code-freeze}
+ ...
+
+positional arguments:
+ {feature-freeze-update,feature-freeze,code-freeze-update,code-freeze}
+ feature-freeze-update
+ Feature Freeze status update
+ feature-freeze Feature Freeze announcement
+ code-freeze-update Code Freeze status update
+ code-freeze Code Freeze announcement
+
+options:
+ -h, --help show this help message and exit
+```
+
+## JQL template parsing
+
+Verify that jql.py parses all 13 templates from `references/jql-release.md` and renders them with placeholders.
+
+```python3
+import sys; sys.path.insert(0, 'scripts')
+import jql
+templates = jql.list_templates()
+print(f'Templates loaded: {len(templates)}')
+for name in templates:
+ print(f' {name}')
+
+```
+
+```output
+Templates loaded: 13
+ active_release
+ blockers
+ code_freeze_issues
+ cves
+ epics
+ feature_demos
+ feature_freeze_issues
+ feature_subtasks
+ features_added_to_release
+ open_issues
+ open_issues_by_type
+ release_notes
+ test_day_features
+```
+
+```python3
+import sys; sys.path.insert(0, 'scripts')
+import jql
+rendered, url = jql.render_with_url('open_issues', version='1.9.0')
+print(f'JQL: {rendered}')
+print(f'URL: {url}')
+
+```
+
+```output
+JQL: project IN (RHIDP, RHDHBugs, RHDHPLAN, RHDHSUPP) AND fixVersion = "1.9.0" and status != closed
+URL: https://issues.redhat.com/issues/?jql=project%20IN%20%28RHIDP%2C%20RHDHBugs%2C%20RHDHPLAN%2C%20RHDHSUPP%29%20AND%20fixVersion%20%3D%20%221.9.0%22%20and%20status%20%21%3D%20closed
+```
+
+## Slack template parsing
+
+Verify that slack_templates.py parses all 4 templates from `references/slack-templates.md` and fills placeholders.
+
+```python3
+import sys; sys.path.insert(0, 'scripts')
+import slack_templates as st
+templates = st.list_templates()
+print(f'Templates loaded: {len(templates)}')
+for name in templates:
+ print(f' {name}')
+
+```
+
+```output
+Templates loaded: 4
+ code_freeze
+ code_freeze_update
+ feature_freeze
+ feature_freeze_update
+```
+
+```python3
+import sys; sys.path.insert(0, 'scripts')
+import slack_templates as st
+template = st.get_template('feature_freeze')
+filled = st.fill_placeholders(template, {
+ 'RELEASE_VERSION': '1.9.0',
+ 'EPIC_ISSUE_COUNT': '5',
+ 'CVE_ISSUE_COUNT': '3',
+ 'OUTSTANDING_RELEASE_NOTES_ISSUE_COUNT': '12',
+})
+# Show first 3 lines
+for line in filled.splitlines()[:3]:
+ print(line)
+print('...')
+remaining = [p for p in ['EPIC_ISSUE_COUNT', 'CVE_ISSUE_COUNT', 'OUTSTANDING_RELEASE_NOTES_ISSUE_COUNT', 'RELEASE_VERSION'] if '{{' + p + '}}' in filled]
+print(f'Unfilled placeholders: {remaining if remaining else "none"}')
+
+```
+
+```output
+:rotating_light: *RHDH 1.9.0 [Feature Freeze](https://docs.google.com/document/d/1IjMH985f3XUhXl_6drfUKopLxTBoY0VMJ2Zpr_62K2g/edit?tab=t.0#bookmark=id.5a1n60q199qh)* :rotating_light:
+
+Its Feature Freeze! To see the latest status use the [RHDH Release Tracking dashboard](https://issues.redhat.com/secure/Dashboard.jspa?selectPageId=12363303) and set fixversion to the current release.
+...
+Unfilled placeholders: none
+```
+
+## Unit tests
+
+```bash
+uv run pytest ../../tests/unit/test_release_cli.py --tb=short -q 2>&1
+```
+
+```output
+.................................... [100%]
+36 passed in 0.06s
+```
+
+## Workflow CLI integration
+
+Verify all 13 workflows have been updated with CLI-first Step 1.
+
+```bash
+total=$(ls workflows/*.md | wc -l | tr -d ' '); with_cli=$(grep -l 'Step 1: Run CLI' workflows/*.md | wc -l | tr -d ' '); echo "Workflows with CLI step: $with_cli/$total"
+```
+
+```output
+Workflows with CLI step: 13/13
+```
+
+```bash
+grep -h 'release.py' workflows/*.md | sed 's/^[ ]*//' | sort -u
+```
+
+```output
+python scripts/release.py --json blockers {{RELEASE_VERSION}}
+python scripts/release.py --json cves {{RELEASE_VERSION}}
+python scripts/release.py --json dates
+python scripts/release.py --json epics {{RELEASE_VERSION}}
+python scripts/release.py --json future-dates {{RELEASE_VERSION}}
+python scripts/release.py --json notes {{RELEASE_VERSION}}
+python scripts/release.py --json slack code-freeze {{RELEASE_VERSION}}
+python scripts/release.py --json slack code-freeze-update {{RELEASE_VERSION}}
+python scripts/release.py --json slack feature-freeze {{RELEASE_VERSION}}
+python scripts/release.py --json slack feature-freeze-update {{RELEASE_VERSION}}
+python scripts/release.py --json status {{RELEASE_VERSION}}
+python scripts/release.py --json team-breakdown {{RELEASE_VERSION}}
+python scripts/release.py --json teams
+python scripts/release.py --json teams --category Engineering
+```
diff --git a/skills/rhdh-release/workflows/announce-code-freeze-update.md b/skills/rhdh-release/workflows/announce-code-freeze-update.md
new file mode 100644
index 0000000..5c1835e
--- /dev/null
+++ b/skills/rhdh-release/workflows/announce-code-freeze-update.md
@@ -0,0 +1,102 @@
+# Workflow: Announce Code Freeze Update
+
+Generate a Slack message announcing Code Freeze status update.
+
+
+
+| Requirement | Check |
+|-------------|-------|
+| **Jira** | `python ~/.claude/skills/rhdh-jira/scripts/setup.py --json` → `"overall": "pass"` |
+| **gog CLI** | `gog sheets metadata 1vQXfvID72qwqvLb17eyGOvnZXrZG7NBzTGv6RP9wvyM --json` succeeds |
+
+
+
+
+
+## Step 1: Run CLI
+
+```bash
+python scripts/release.py --json slack code-freeze-update {{RELEASE_VERSION}}
+```
+
+If the CLI succeeds, use its `slack_message` field directly (it's the filled template). If it fails, follow the manual steps below.
+
+## Step 2 (fallback): Get Code Freeze date
+
+Run the `release-dates` workflow or fetch directly:
+
+```bash
+acli jira workitem search --jql "project=rhdhplan AND issuetype=feature AND component=release AND status != closed" --json
+```
+
+Then for the matching release issue:
+
+```bash
+acli jira workitem view {{RELEASE_ISSUE_KEY}} --json
+```
+
+Extract the Code Freeze date from the description.
+
+## Step 3 (fallback): Get active engineering teams
+
+```bash
+gog sheets get 1vQXfvID72qwqvLb17eyGOvnZXrZG7NBzTGv6RP9wvyM Team --json --results-only
+```
+
+Filter to category "Engineering" and status "Active".
+
+## Step 4 (fallback): Get outstanding release notes count
+
+```bash
+acli jira workitem search --jql 'project in (RHIDP, "Red Hat Developer Hub Bugs", "RHDH Support", rhdhplan) and issuetype in (Feature, bug) and "Release Note Type" is EMPTY and fixVersion = "{{RELEASE_VERSION}}"' --count
+```
+
+## Step 5 (fallback): Get feature subtasks count
+
+Use the `feature_subtasks` JQL from `references/jql-release.md`:
+
+```bash
+acli jira workitem search --jql 'project in (RHDHPlan) AND issuetype = sub-task AND fixVersion = "{{RELEASE_VERSION}}" AND status != closed' --count
+```
+
+## Step 6 (fallback): Get per-team issue counts
+
+Use the `code_freeze_issues` JQL (all open issues), then filter by team:
+
+```bash
+acli jira workitem search --jql 'project IN (RHIDP, RHDHBugs, RHDHPLAN, RHDHSUPP) AND fixVersion = "{{RELEASE_VERSION}}" AND status != closed' --limit 200 --json | python ~/.claude/skills/rhdh-jira/scripts/parse_issues.py --enrich -s key,summary,status,team
+```
+
+Group results by team and count per team.
+
+## Step 7 (fallback): Fill template and output
+
+Load the **Code Freeze Update** template from `references/slack-templates.md`.
+
+Fill all placeholders:
+
+- `{{RELEASE_VERSION}}` — the release version
+- `{{CODE_FREEZE_DATE}}` — from Step 1
+- `{{TEAM_NAME}}`, `{{TEAM_ISSUE_COUNT}}`, `{{JIRA_LINK}}`, `{{LEAD_SLACK}}` — repeated per team from Steps 2 and 5
+- `{{OUTSTANDING_RELEASE_NOTES_ISSUE_COUNT}}` — from Step 3
+- `{{FEATURE_SUBTASK_ISSUE_COUNT}}` — from Step 4
+
+**Output the filled template in a triple-backtick code block** for copy-paste into Slack.
+
+
+
+
+
+- Always use `parse_issues.py --enrich` for team counts — never count manually.
+- The Code Freeze Update uses ALL open issues for team breakdown (unlike Feature Freeze Update which excludes infra/ops).
+
+
+
+
+
+- [ ] Slack message in triple-backtick code block
+- [ ] All team lines filled with count, Jira link, and lead Slack handle
+- [ ] Release notes and feature subtask counts filled
+- [ ] Code Freeze date filled
+
+
diff --git a/skills/rhdh-release/workflows/announce-code-freeze.md b/skills/rhdh-release/workflows/announce-code-freeze.md
new file mode 100644
index 0000000..06439f0
--- /dev/null
+++ b/skills/rhdh-release/workflows/announce-code-freeze.md
@@ -0,0 +1,82 @@
+# Workflow: Announce Code Freeze
+
+Generate a Slack message announcing that Code Freeze milestone has been reached.
+
+
+
+| Requirement | Check |
+|-------------|-------|
+| **Jira** | `python ~/.claude/skills/rhdh-jira/scripts/setup.py --json` → `"overall": "pass"` |
+
+
+
+
+
+## Step 1: Run CLI
+
+```bash
+python scripts/release.py --json slack code-freeze {{RELEASE_VERSION}}
+```
+
+If the CLI succeeds, use its `slack_message` field directly (it's the filled template). If it fails, follow the manual steps below.
+
+## Step 2 (fallback): Get blocker bugs
+
+Use the blocker bugs workflow query:
+
+```bash
+acli jira workitem search --jql 'project IN (RHIDP, RHDHBugs, RHDHPLAN, RHDHSUPP) AND fixVersion = "{{RELEASE_VERSION}}" AND status != closed AND issuetype = bug AND priority = Blocker' --count
+```
+
+## Step 3 (fallback): Get feature demos count
+
+Use the `feature_demos` JQL from `references/jql-release.md`:
+
+```bash
+acli jira workitem search --jql 'project in (RHDHPlan, RHIDP) AND issuetype = feature AND labels = demo AND fixVersion = "{{RELEASE_VERSION}}" AND status != closed' --count
+```
+
+## Step 4 (fallback): Get test day features count
+
+Use the `test_day_features` JQL:
+
+```bash
+acli jira workitem search --jql 'Project in (RHDHPlan, rhidp) AND issuetype = feature AND labels = rhdh-testday AND fixVersion = "{{RELEASE_VERSION}}" AND status != closed' --count
+```
+
+## Step 5 (fallback): Get total open issues count
+
+```bash
+acli jira workitem search --jql 'project IN (RHIDP, RHDHBugs, RHDHPLAN, RHDHSUPP) AND fixVersion = "{{RELEASE_VERSION}}" AND status != closed' --count
+```
+
+## Step 6 (fallback): Fill template and output
+
+Load the **Code Freeze Announcement** template from `references/slack-templates.md`.
+
+Fill all placeholders:
+
+- `{{RELEASE_VERSION}}` — the release version
+- `{{BLOCKER_BUG_ISSUE_COUNT}}` — from Step 1
+- `{{FEATURE_DEMO_ISSUE_COUNT}}` — from Step 2
+- `{{TEST_DAY_FEATURE_ISSUE_COUNT}}` — from Step 3
+- `{{OPEN_ISSUE_COUNT}}` — from Step 4
+- `{{JIRA_LINK}}` — URL-encoded Jira search link for each count
+
+**Output the filled template in a triple-backtick code block** for copy-paste into Slack.
+
+
+
+
+
+- This is the milestone announcement (sent ON the Code Freeze date), not the update (sent BEFORE).
+- After Code Freeze: no cherry-picks without explicit RM approval, only critical CVEs considered for GA.
+
+
+
+
+
+- [ ] Slack message in triple-backtick code block
+- [ ] Blocker bugs, feature demos, test day features, and open issue counts filled with Jira links
+
+
diff --git a/skills/rhdh-release/workflows/announce-feature-freeze-update.md b/skills/rhdh-release/workflows/announce-feature-freeze-update.md
new file mode 100644
index 0000000..39c14e6
--- /dev/null
+++ b/skills/rhdh-release/workflows/announce-feature-freeze-update.md
@@ -0,0 +1,96 @@
+# Workflow: Announce Feature Freeze Update
+
+Generate a Slack message announcing Feature Freeze status update.
+
+
+
+| Requirement | Check |
+|-------------|-------|
+| **Jira** | `python ~/.claude/skills/rhdh-jira/scripts/setup.py --json` → `"overall": "pass"` |
+| **gog CLI** | `gog sheets metadata 1vQXfvID72qwqvLb17eyGOvnZXrZG7NBzTGv6RP9wvyM --json` succeeds |
+
+
+
+
+
+## Step 1: Run CLI
+
+```bash
+python scripts/release.py --json slack feature-freeze-update {{RELEASE_VERSION}}
+```
+
+If the CLI succeeds, use its `slack_message` field directly (it's the filled template). If it fails, follow the manual steps below.
+
+## Step 2 (fallback): Get Feature Freeze date
+
+Run the `release-dates` workflow or fetch directly:
+
+```bash
+acli jira workitem search --jql "project=rhdhplan AND issuetype=feature AND component=release AND status != closed" --json
+```
+
+Then for the matching release issue:
+
+```bash
+acli jira workitem view {{RELEASE_ISSUE_KEY}} --json
+```
+
+Extract the Feature Freeze date from the description.
+
+## Step 3 (fallback): Get active engineering teams
+
+```bash
+gog sheets get 1vQXfvID72qwqvLb17eyGOvnZXrZG7NBzTGv6RP9wvyM Team --json --results-only
+```
+
+Filter to category "Engineering" and status "Active".
+
+## Step 4 (fallback): Get outstanding release notes count
+
+Use the `release_notes` JQL:
+
+```bash
+acli jira workitem search --jql 'project in (RHIDP, "Red Hat Developer Hub Bugs", "RHDH Support", rhdhplan) and issuetype in (Feature, bug) and "Release Note Type" is EMPTY and fixVersion = "{{RELEASE_VERSION}}"' --count
+```
+
+## Step 5 (fallback): Get per-team issue counts
+
+Use the `feature_freeze_issues` JQL as the base, then filter by team using `parse_issues.py`:
+
+```bash
+acli jira workitem search --jql 'project IN (RHIDP, RHDHBugs, RHDHPLAN, RHDHSUPP) AND fixVersion = "{{RELEASE_VERSION}}" and resolution is EMPTY AND component not in (AI, Build, Certification, "Continuous Improvement", Documentation, Knowledge, Performance, Quality, Quickstart, Release, "RHDH Local", Security, Segment, Serviceability, Support, "Team Operations", "Test Framework", "Test Infrastructure", "Upstream & Community", UX) AND Type not in (Bug, Vulnerability, sub-task) AND status not in ("Dev Complete", "Release Pending", Done, Closed) AND (labels is EMPTY OR labels != stretch-goal)' --limit 200 --json | python ~/.claude/skills/rhdh-jira/scripts/parse_issues.py --enrich -s key,summary,status,team
+```
+
+Group results by team and count per team.
+
+## Step 6 (fallback): Fill template and output
+
+Load the **Feature Freeze Update** template from `references/slack-templates.md`.
+
+Fill all placeholders:
+
+- `{{RELEASE_VERSION}}` — the release version
+- `{{FEATURE_FREEZE_DATE}}` — from Step 1
+- `{{TEAM_NAME}}`, `{{ISSUE_COUNT}}`, `{{JIRA_LINK}}`, `{{LEAD_SLACK}}` — repeated per team from Steps 2 and 4
+- `{{OUTSTANDING_RELEASE_NOTES_ISSUE_COUNT}}` — from Step 3
+
+**Output the filled template in a triple-backtick code block** for copy-paste into Slack.
+
+
+
+
+
+- Always use `parse_issues.py --enrich` for team counts — never count manually.
+- Build Jira search links by URL-encoding the JQL scoped to each team.
+- The `feature_freeze_issues` JQL excludes infrastructure/ops components and bugs — this is intentional for Feature Freeze tracking.
+
+
+
+
+
+- [ ] Slack message in triple-backtick code block
+- [ ] All team lines filled with count, Jira link, and lead Slack handle
+- [ ] Release notes count filled
+- [ ] Feature Freeze date filled
+
+
diff --git a/skills/rhdh-release/workflows/announce-feature-freeze.md b/skills/rhdh-release/workflows/announce-feature-freeze.md
new file mode 100644
index 0000000..1105bc0
--- /dev/null
+++ b/skills/rhdh-release/workflows/announce-feature-freeze.md
@@ -0,0 +1,75 @@
+# Workflow: Announce Feature Freeze
+
+Generate a Slack message announcing that Feature Freeze milestone has been reached.
+
+
+
+| Requirement | Check |
+|-------------|-------|
+| **Jira** | `python ~/.claude/skills/rhdh-jira/scripts/setup.py --json` → `"overall": "pass"` |
+
+
+
+
+
+## Step 1: Run CLI
+
+```bash
+python scripts/release.py --json slack feature-freeze {{RELEASE_VERSION}}
+```
+
+If the CLI succeeds, use its `slack_message` field directly (it's the filled template). If it fails, follow the manual steps below.
+
+## Step 2 (fallback): Get open EPICs count
+
+Use the `epics` JQL from `references/jql-release.md`:
+
+```bash
+acli jira workitem search --jql 'project IN (RHIDP) AND fixVersion = "{{RELEASE_VERSION}}" and issuetype = epic and status not in (closed, "Release Pending", "Dev Complete")' --count
+```
+
+## Step 3 (fallback): Get CVE count
+
+Use the `cves` JQL:
+
+```bash
+acli jira workitem search --jql 'project IN (RHIDP, rhdhbugs) AND fixVersion = "{{RELEASE_VERSION}}" and issuetype in (weakness, Vulnerability, bug) and summary ~ "CVE*"' --count
+```
+
+## Step 4 (fallback): Get outstanding release notes count
+
+Use the `release_notes` JQL:
+
+```bash
+acli jira workitem search --jql 'project in (RHIDP, "Red Hat Developer Hub Bugs", "RHDH Support", rhdhplan) and issuetype in (Feature, bug) and "Release Note Type" is EMPTY and fixVersion = "{{RELEASE_VERSION}}"' --count
+```
+
+## Step 5 (fallback): Fill template and output
+
+Load the **Feature Freeze Announcement** template from `references/slack-templates.md`.
+
+Fill all placeholders:
+
+- `{{RELEASE_VERSION}}` — the release version
+- `{{EPIC_ISSUE_COUNT}}` — from Step 1
+- `{{CVE_ISSUE_COUNT}}` — from Step 2
+- `{{OUTSTANDING_RELEASE_NOTES_ISSUE_COUNT}}` — from Step 3
+- `{{JIRA_LINK}}` — URL-encoded Jira search link for each count
+
+**Output the filled template in a triple-backtick code block** for copy-paste into Slack.
+
+
+
+
+
+- This is the milestone announcement (sent ON the Feature Freeze date), not the update (sent BEFORE).
+- Include Jira search links for all counts so recipients can drill down.
+
+
+
+
+
+- [ ] Slack message in triple-backtick code block
+- [ ] EPICs, CVEs, and release notes counts filled with Jira links
+
+
diff --git a/skills/rhdh-release/workflows/blocker-bugs.md b/skills/rhdh-release/workflows/blocker-bugs.md
new file mode 100644
index 0000000..e06813f
--- /dev/null
+++ b/skills/rhdh-release/workflows/blocker-bugs.md
@@ -0,0 +1,55 @@
+# Workflow: Retrieve Blocker Bugs
+
+Compile all open blocker bugs for a release.
+
+
+
+| Requirement | Check |
+|-------------|-------|
+| **Jira** | `python ~/.claude/skills/rhdh-jira/scripts/setup.py --json` → `"overall": "pass"` |
+
+
+
+
+
+## Step 1: Run CLI
+
+```bash
+python scripts/release.py --json blockers {{RELEASE_VERSION}}
+```
+
+If the CLI succeeds, use its output directly. If it fails, follow the manual steps below.
+
+## Step 2 (fallback): Query blocker bugs
+
+Use the `open_issues` JQL from `references/jql-release.md` with a priority filter:
+
+```bash
+acli jira workitem search --jql 'project IN (RHIDP, RHDHBugs, RHDHPLAN, RHDHSUPP) AND fixVersion = "{{RELEASE_VERSION}}" AND status != closed AND issuetype = bug AND priority = Blocker' --json | python ~/.claude/skills/rhdh-jira/scripts/parse_issues.py --enrich
+```
+
+## Step 3 (fallback): Format output
+
+Present full details for each blocker:
+
+| Key | Summary | Status | Priority | Assignee | Team |
+|-----|---------|--------|----------|----------|------|
+| [{{KEY}}](https://issues.redhat.com/browse/{{KEY}}) | {{SUMMARY}} | {{STATUS}} | Blocker | {{ASSIGNEE}} | {{TEAM}} |
+
+**Total:** {{COUNT}} blocker bugs — [View in Jira](https://issues.redhat.com/issues/?jql=...)
+
+
+
+
+
+- If no release version is specified, ask the user. Default to the latest active release from the `active_release` query.
+- Include the full Jira link for each issue for quick access.
+
+
+
+
+
+- [ ] Each blocker listed with key, summary, status, and assignee
+- [ ] Total count with Jira search link
+
+
diff --git a/skills/rhdh-release/workflows/cves.md b/skills/rhdh-release/workflows/cves.md
new file mode 100644
index 0000000..08680bb
--- /dev/null
+++ b/skills/rhdh-release/workflows/cves.md
@@ -0,0 +1,61 @@
+# Workflow: Retrieve List of CVEs
+
+Compile all CVE issues for a release.
+
+
+
+| Requirement | Check |
+|-------------|-------|
+| **Jira** | `python ~/.claude/skills/rhdh-jira/scripts/setup.py --json` → `"overall": "pass"` |
+
+
+
+
+
+## Step 1: Run CLI
+
+```bash
+python scripts/release.py --json cves {{RELEASE_VERSION}}
+```
+
+If the CLI succeeds, use its output directly. If it fails, follow the manual steps below.
+
+## Step 2 (fallback): Query CVE issues
+
+Use the `cves` JQL from `references/jql-release.md`:
+
+```bash
+acli jira workitem search --jql 'project IN (RHIDP, rhdhbugs) AND fixVersion = "{{RELEASE_VERSION}}" and issuetype in (weakness, Vulnerability, bug) and summary ~ "CVE*"' --json | python ~/.claude/skills/rhdh-jira/scripts/parse_issues.py --enrich
+```
+
+## Step 3 (fallback): Get count
+
+```bash
+acli jira workitem search --jql 'project IN (RHIDP, rhdhbugs) AND fixVersion = "{{RELEASE_VERSION}}" and issuetype in (weakness, Vulnerability, bug) and summary ~ "CVE*"' --count
+```
+
+## Step 4 (fallback): Format output
+
+Present full details for each CVE:
+
+| Key | Summary | Type | Status | Priority | Assignee |
+|-----|---------|------|--------|----------|----------|
+| [{{KEY}}](https://issues.redhat.com/browse/{{KEY}}) | {{SUMMARY}} | {{TYPE}} | {{STATUS}} | {{PRIORITY}} | {{ASSIGNEE}} |
+
+**Total:** {{COUNT}} CVEs — [View in Jira](https://issues.redhat.com/issues/?jql=...)
+
+
+
+
+
+- CVEs are critical for security tracking — after Code Freeze, only critical severity CVEs are considered for inclusion before GA.
+- If no release version is specified, ask the user.
+
+
+
+
+
+- [ ] Each CVE listed with key, summary, severity, and status
+- [ ] Total count with Jira search link
+
+
diff --git a/skills/rhdh-release/workflows/engineering-epics.md b/skills/rhdh-release/workflows/engineering-epics.md
new file mode 100644
index 0000000..2731cfb
--- /dev/null
+++ b/skills/rhdh-release/workflows/engineering-epics.md
@@ -0,0 +1,61 @@
+# Workflow: Retrieve Engineering EPICs
+
+Compile all open Engineering EPICs not in Dev Complete or Release Pending.
+
+
+
+| Requirement | Check |
+|-------------|-------|
+| **Jira** | `python ~/.claude/skills/rhdh-jira/scripts/setup.py --json` → `"overall": "pass"` |
+
+
+
+
+
+## Step 1: Run CLI
+
+```bash
+python scripts/release.py --json epics {{RELEASE_VERSION}}
+```
+
+If the CLI succeeds, use its output directly. If it fails, follow the manual steps below.
+
+## Step 2 (fallback): Count outstanding EPICs
+
+Use the `epics` JQL from `references/jql-release.md`:
+
+```bash
+acli jira workitem search --jql 'project IN (RHIDP) AND fixVersion = "{{RELEASE_VERSION}}" and issuetype = epic and status not in (closed, "Release Pending", "Dev Complete")' --count
+```
+
+## Step 3 (fallback): Get EPIC details (if needed)
+
+```bash
+acli jira workitem search --jql 'project IN (RHIDP) AND fixVersion = "{{RELEASE_VERSION}}" and issuetype = epic and status not in (closed, "Release Pending", "Dev Complete")' --json | python ~/.claude/skills/rhdh-jira/scripts/parse_issues.py --enrich -s key,summary,status,assignee
+```
+
+## Step 4 (fallback): Format output
+
+**{{COUNT}} Engineering EPICs outstanding** — [View in Jira](https://issues.redhat.com/issues/?jql=...)
+
+If detailed output requested:
+
+| Key | Summary | Status | Assignee |
+|-----|---------|--------|----------|
+| [{{KEY}}](https://issues.redhat.com/browse/{{KEY}}) | {{SUMMARY}} | {{STATUS}} | {{ASSIGNEE}} |
+
+
+
+
+
+- EPICs in "Dev Complete" or "Release Pending" are excluded — they're considered done for release tracking purposes.
+- If no release version is specified, ask the user.
+
+
+
+
+
+- [ ] Table with key, summary, status, and assignee per EPIC
+- [ ] Only EPICs not in Dev Complete / Release Pending / Closed
+
+
diff --git a/skills/rhdh-release/workflows/future-release-dates.md b/skills/rhdh-release/workflows/future-release-dates.md
new file mode 100644
index 0000000..077c9b6
--- /dev/null
+++ b/skills/rhdh-release/workflows/future-release-dates.md
@@ -0,0 +1,68 @@
+# Workflow: Retrieve Future Release and Key Dates
+
+Table of future release versions with critical dates from the RHDH release schedule spreadsheet.
+
+
+
+| Requirement | Check |
+|-------------|-------|
+| **gog CLI** | `gog sheets metadata 1knVzlMW0l0X4c7gkoiuaGql1zuFgEGwHHBsj-ygUTnc --json` succeeds |
+
+If gog check fails: run `gog auth add `.
+
+
+
+
+
+## Step 1: Run CLI
+
+```bash
+python scripts/release.py --json future-dates {{RELEASE_VERSION}}
+```
+
+If the CLI succeeds, use its output directly. If it fails, follow the manual steps below.
+
+## Step 2 (fallback): Fetch schedule from Google Sheets via gog
+
+First, find the schedule tab:
+
+```bash
+gog sheets metadata 1knVzlMW0l0X4c7gkoiuaGql1zuFgEGwHHBsj-ygUTnc --json
+```
+
+Look for a tab containing the current year and "schedule" in the name.
+
+Then fetch the tab contents:
+
+```bash
+gog sheets get 1knVzlMW0l0X4c7gkoiuaGql1zuFgEGwHHBsj-ygUTnc "{{TAB_NAME}}" --json --results-only
+```
+
+Search the rows for the target version's GA row, then walk backward to find Feature Freeze and Code Freeze dates.
+
+If a specific version is not given, ask the user which version they want, or fetch for each known active release (from the `release-dates` workflow).
+
+## Step 3 (fallback): Format output
+
+Present as a table:
+
+| Release | Feature Freeze | Code Freeze | GA Date | Source |
+|---------|---------------|-------------|---------|--------|
+| {{VERSION}} | {{DATE}} | {{DATE}} | {{DATE}} | [Schedule Sheet](https://docs.google.com/spreadsheets/d/1knVzlMW0l0X4c7gkoiuaGql1zuFgEGwHHBsj-ygUTnc/edit) |
+
+
+
+
+
+- The script currently reads only the first matching schedule tab (by year).
+- If `{"error": "version_not_found"}`: ask the user for the exact version string as it appears in the sheet.
+- If `{"error": "spreadsheet_not_found"}`: ask the user to share the sheet URL.
+
+
+
+
+
+- [ ] Table with future release dates from the schedule spreadsheet
+- [ ] Dates include at least GA target per release
+
+
diff --git a/skills/rhdh-release/workflows/issues-by-team.md b/skills/rhdh-release/workflows/issues-by-team.md
new file mode 100644
index 0000000..dee8043
--- /dev/null
+++ b/skills/rhdh-release/workflows/issues-by-team.md
@@ -0,0 +1,74 @@
+# Workflow: Retrieve Issues by Engineering Teams
+
+Compile open issues for a release broken down by engineering team.
+
+
+
+| Requirement | Check |
+|-------------|-------|
+| **Jira** | `python ~/.claude/skills/rhdh-jira/scripts/setup.py --json` → `"overall": "pass"` |
+| **gog CLI** | `gog sheets metadata 1vQXfvID72qwqvLb17eyGOvnZXrZG7NBzTGv6RP9wvyM --json` succeeds |
+
+
+
+
+
+## Step 1: Run CLI
+
+```bash
+python scripts/release.py --json team-breakdown {{RELEASE_VERSION}}
+```
+
+If the CLI succeeds, use its output directly. If it fails, follow the manual steps below.
+
+## Step 2 (fallback): Get active engineering teams
+
+```bash
+gog sheets get 1vQXfvID72qwqvLb17eyGOvnZXrZG7NBzTGv6RP9wvyM Team --json --results-only
+```
+
+Filter to category "Engineering" and status "Active". This gives team names and `team_id` values.
+
+## Step 3 (fallback): Query issues and filter by team
+
+Fetch all open issues for the release, enriched with team data:
+
+```bash
+acli jira workitem search --jql 'project IN (RHIDP, RHDHBugs, RHDHPLAN, RHDHSUPP) AND fixVersion = "{{RELEASE_VERSION}}" AND status != closed' --limit 200 --json | python ~/.claude/skills/rhdh-jira/scripts/parse_issues.py --enrich -s key,summary,status,team
+```
+
+To filter to a specific team by team ID:
+
+```bash
+acli jira workitem search --jql 'project IN (RHIDP, RHDHBugs, RHDHPLAN, RHDHSUPP) AND fixVersion = "{{RELEASE_VERSION}}" AND status != closed' --limit 200 --json | python ~/.claude/skills/rhdh-jira/scripts/parse_issues.py --enrich -f team_id={{TEAM_ID}} -s key,summary,status
+```
+
+**Important:** Always use `parse_issues.py --enrich` for team counts — the Team field is a custom field that cannot be queried via JQL directly.
+
+## Step 4 (fallback): Build per-team counts
+
+For each active engineering team, count the matching issues and build a Jira search link.
+
+## Step 5 (fallback): Format output
+
+| Team | Team ID | Issue Count | Lead | Jira Link |
+|------|---------|-------------|------|-----------|
+| {{TEAM_NAME}} | {{TEAM_ID}} | {{COUNT}} | @{{LEAD_SLACK}} | [View](https://issues.redhat.com/issues/?jql=...) |
+
+
+
+
+
+- The Team custom field **cannot** be used in JQL. Always use `parse_issues.py --enrich` to filter by team.
+- Use `--limit 200` or `--paginate` to get all results — default page size is 30.
+- For announcement workflows, use the specific freeze-scoped JQL (e.g., `feature_freeze_issues`) as the base query instead of `open_issues`.
+
+
+
+
+
+- [ ] Per-team issue counts from `parse_issues.py --enrich`
+- [ ] Jira search link per team
+- [ ] Total count across all teams
+
+
diff --git a/skills/rhdh-release/workflows/release-dates.md b/skills/rhdh-release/workflows/release-dates.md
new file mode 100644
index 0000000..4326c2a
--- /dev/null
+++ b/skills/rhdh-release/workflows/release-dates.md
@@ -0,0 +1,72 @@
+# Workflow: Retrieve Release and Key Dates
+
+Table of release versions with five critical dates: Feature Freeze, Code Freeze, Doc Freeze, Go/No Go, GA Announce.
+
+
+
+| Requirement | Check |
+|-------------|-------|
+| **Jira** | `python ~/.claude/skills/rhdh-jira/scripts/setup.py --json` → `"overall": "pass"` |
+
+If Jira check fails: load `~/.claude/skills/rhdh-jira/SKILL.md` and follow its Prerequisites section.
+
+
+
+
+
+## Step 1: Run CLI
+
+```bash
+python scripts/release.py --json dates
+```
+
+If the CLI succeeds, use its output directly. If it fails, follow the manual steps below.
+
+## Step 2 (fallback): Find active release issues
+
+Use the `active_release` JQL from `references/jql-release.md`:
+
+```bash
+acli jira workitem search --jql "project=rhdhplan AND issuetype=feature AND component=release AND status != closed" --json
+```
+
+## Step 3 (fallback): Extract dates from each release issue
+
+For each release issue returned, fetch full details:
+
+```bash
+acli jira workitem view {{ISSUE_KEY}} --json
+```
+
+Extract from the description:
+
+- Feature Freeze date
+- Code Freeze date
+- Doc Freeze date
+- Go/No Go date
+- GA Announce date
+
+## Step 4 (fallback): Format output
+
+Present as a table:
+
+| Release | Feature Freeze | Code Freeze | Doc Freeze | Go/No Go | GA Announce | Source |
+|---------|---------------|-------------|------------|----------|-------------|--------|
+| {{VERSION}} | {{DATE}} | {{DATE}} | {{DATE}} | {{DATE}} | {{DATE}} | [{{ISSUE_KEY}}](https://issues.redhat.com/browse/{{ISSUE_KEY}}) |
+
+
+
+
+
+- Dates are embedded in the Jira issue description, not in custom fields — parse the description text.
+- Some releases may have dates marked as TBD.
+- Include the Jira issue link for traceability.
+
+
+
+
+
+- [ ] Table with one row per active release
+- [ ] Each row has all five dates (or TBD) and a Jira source link
+
+
diff --git a/skills/rhdh-release/workflows/release-notes.md b/skills/rhdh-release/workflows/release-notes.md
new file mode 100644
index 0000000..28a8a4f
--- /dev/null
+++ b/skills/rhdh-release/workflows/release-notes.md
@@ -0,0 +1,58 @@
+# Workflow: Retrieve Outstanding Release Notes
+
+Compile features and bugs missing Release Note Type field.
+
+
+
+| Requirement | Check |
+|-------------|-------|
+| **Jira** | `python ~/.claude/skills/rhdh-jira/scripts/setup.py --json` → `"overall": "pass"` |
+
+
+
+
+
+## Step 1: Run CLI
+
+```bash
+python scripts/release.py --json notes {{RELEASE_VERSION}}
+```
+
+If the CLI succeeds, use its output directly. If it fails, follow the manual steps below.
+
+## Step 2 (fallback): Count issues missing Release Note Type
+
+Use the `release_notes` JQL from `references/jql-release.md`:
+
+```bash
+acli jira workitem search --jql 'project in (RHIDP, "Red Hat Developer Hub Bugs", "RHDH Support", rhdhplan) and issuetype in (Feature, bug) and "Release Note Type" is EMPTY and fixVersion = "{{RELEASE_VERSION}}"' --count
+```
+
+## Step 3 (fallback): Get details (if needed)
+
+```bash
+acli jira workitem search --jql 'project in (RHIDP, "Red Hat Developer Hub Bugs", "RHDH Support", rhdhplan) and issuetype in (Feature, bug) and "Release Note Type" is EMPTY and fixVersion = "{{RELEASE_VERSION}}"' --json | python ~/.claude/skills/rhdh-jira/scripts/parse_issues.py --enrich -s key,summary,status,issuetype
+```
+
+## Step 4 (fallback): Format output
+
+**{{COUNT}} issues missing Release Note Type** — [View in Jira](https://issues.redhat.com/issues/?jql=...)
+
+Also link to the [Release Notes Dashboard](https://issues.redhat.com/secure/Dashboard.jspa?selectPageId=12382090) for full details.
+
+
+
+
+
+- Release Notes must be filled before release — this is a documentation blocker.
+- Refer to [RHDH Release Notes Process](https://docs.google.com/document/d/1KFMkRVTkbDIhyZviZcuVn9UfJp64lKmokzT4ftMrj4w/edit) for the full process.
+
+
+
+
+
+- [ ] Count of issues missing Release Note Type
+- [ ] Jira search link to the outstanding items
+- [ ] Link to Release Notes Dashboard
+
+
diff --git a/skills/rhdh-release/workflows/release-status.md b/skills/rhdh-release/workflows/release-status.md
new file mode 100644
index 0000000..48bbce4
--- /dev/null
+++ b/skills/rhdh-release/workflows/release-status.md
@@ -0,0 +1,90 @@
+# Workflow: Retrieve Active Release Status by Issue Type
+
+Compile status of all active releases with open issue counts by type.
+
+
+
+| Requirement | Check |
+|-------------|-------|
+| **Jira** | `python ~/.claude/skills/rhdh-jira/scripts/setup.py --json` → `"overall": "pass"` |
+
+
+
+
+
+## Step 1: Run CLI
+
+```bash
+python scripts/release.py --json status {{RELEASE_VERSION}}
+```
+
+If the CLI succeeds, use its output directly. If it fails, follow the manual steps below.
+
+## Step 2 (fallback): Find active releases
+
+Use the `active_release` JQL from `references/jql-release.md`:
+
+```bash
+acli jira workitem search --jql "project=rhdhplan AND issuetype=feature AND component=release AND status != closed" --json
+```
+
+Extract the release versions from the results (from `fixVersions` or issue summary).
+
+## Step 3 (fallback): Count issues by type for each release
+
+For each release version, query issue counts per type using the `open_issues_by_type` JQL:
+
+```bash
+acli jira workitem search --jql 'project IN (RHIDP, RHDHBugs, RHDHPLAN, RHDHSUPP) AND fixVersion = "{{RELEASE_VERSION}}" AND status != closed AND issuetype = "Feature"' --count
+acli jira workitem search --jql 'project IN (RHIDP, RHDHBugs, RHDHPLAN, RHDHSUPP) AND fixVersion = "{{RELEASE_VERSION}}" AND status != closed AND issuetype = "Epic"' --count
+acli jira workitem search --jql 'project IN (RHIDP, RHDHBugs, RHDHPLAN, RHDHSUPP) AND fixVersion = "{{RELEASE_VERSION}}" AND status != closed AND issuetype = "Story"' --count
+acli jira workitem search --jql 'project IN (RHIDP, RHDHBugs, RHDHPLAN, RHDHSUPP) AND fixVersion = "{{RELEASE_VERSION}}" AND status != closed AND issuetype = "Task"' --count
+acli jira workitem search --jql 'project IN (RHIDP, RHDHBugs, RHDHPLAN, RHDHSUPP) AND fixVersion = "{{RELEASE_VERSION}}" AND status != closed AND issuetype = "Sub-task"' --count
+acli jira workitem search --jql 'project IN (RHIDP, RHDHBugs, RHDHPLAN, RHDHSUPP) AND fixVersion = "{{RELEASE_VERSION}}" AND status != closed AND issuetype = "Bug"' --count
+acli jira workitem search --jql 'project IN (RHIDP, RHDHBugs, RHDHPLAN, RHDHSUPP) AND fixVersion = "{{RELEASE_VERSION}}" AND status != closed AND issuetype = "Vulnerability"' --count
+acli jira workitem search --jql 'project IN (RHIDP, RHDHBugs, RHDHPLAN, RHDHSUPP) AND fixVersion = "{{RELEASE_VERSION}}" AND status != closed AND issuetype = "Weakness"' --count
+```
+
+## Step 4 (fallback): Get total open issue count
+
+```bash
+acli jira workitem search --jql 'project IN (RHIDP, RHDHBugs, RHDHPLAN, RHDHSUPP) AND fixVersion = "{{RELEASE_VERSION}}" AND status != closed' --count
+```
+
+## Step 5 (fallback): Format output
+
+For each release version, present a table:
+
+### RHDH {{RELEASE_VERSION}}
+
+| Issue Type | Count | Jira Link |
+|-----------|-------|-----------|
+| Feature | {{COUNT}} | [View](https://issues.redhat.com/issues/?jql=...) |
+| Epic | {{COUNT}} | [View](https://issues.redhat.com/issues/?jql=...) |
+| Story | {{COUNT}} | [View](https://issues.redhat.com/issues/?jql=...) |
+| Task | {{COUNT}} | [View](https://issues.redhat.com/issues/?jql=...) |
+| Sub-task | {{COUNT}} | [View](https://issues.redhat.com/issues/?jql=...) |
+| Bug | {{COUNT}} | [View](https://issues.redhat.com/issues/?jql=...) |
+| Vulnerability | {{COUNT}} | [View](https://issues.redhat.com/issues/?jql=...) |
+| Weakness | {{COUNT}} | [View](https://issues.redhat.com/issues/?jql=...) |
+| **Total** | **{{TOTAL}}** | [View](https://issues.redhat.com/issues/?jql=...) |
+
+Include Jira search links by URL-encoding the JQL.
+
+
+
+
+
+- Use `--count` for efficiency — don't fetch full issue data just for counts.
+- URL-encode the JQL when building Jira search links: `https://issues.redhat.com/issues/?jql=`.
+- Optionally include scope changes using the `features_added_to_release` JQL from `references/jql-release.md` to flag recent additions (last 14 days).
+
+
+
+
+
+- [ ] One table per active release with counts for each issue type
+- [ ] Total count per release with Jira search link
+- [ ] All counts use `--count` (no full issue fetch)
+
+
diff --git a/skills/rhdh-release/workflows/teams-and-leads.md b/skills/rhdh-release/workflows/teams-and-leads.md
new file mode 100644
index 0000000..847150b
--- /dev/null
+++ b/skills/rhdh-release/workflows/teams-and-leads.md
@@ -0,0 +1,63 @@
+# Workflow: Retrieve Teams and Leads
+
+Structured list of all active RHDH teams with leads and Slack handles.
+
+
+
+| Requirement | Check |
+|-------------|-------|
+| **gog CLI** | `gog sheets metadata 1vQXfvID72qwqvLb17eyGOvnZXrZG7NBzTGv6RP9wvyM --json` succeeds |
+
+If gog check fails: run `gog auth add `.
+
+
+
+
+
+## Step 1: Run CLI
+
+```bash
+python scripts/release.py --json teams
+```
+
+To filter by category:
+
+```bash
+python scripts/release.py --json teams --category Engineering
+```
+
+If the CLI succeeds, use its output directly. If it fails, follow the manual steps below.
+
+## Step 2 (fallback): Fetch team data via gog
+
+```bash
+gog sheets get 1vQXfvID72qwqvLb17eyGOvnZXrZG7NBzTGv6RP9wvyM Team --json --results-only
+```
+
+Filter the JSON output to active teams only (status column = "Active"). To filter by category, match the category column.
+
+## Step 3 (fallback): Format output
+
+Present as a table:
+
+| Category | Team Name | Team ID | Leads | Slack Handles | Status |
+|----------|-----------|---------|-------|---------------|--------|
+| {{CATEGORY}} | {{TEAM_NAME}} | {{TEAM_ID}} | {{LEADS}} | {{SLACK_HANDLES}} | {{STATUS}} |
+
+Include link to source: [RHDH Team Mapping](https://docs.google.com/spreadsheets/d/1vQXfvID72qwqvLb17eyGOvnZXrZG7NBzTGv6RP9wvyM/edit)
+
+
+
+
+
+- By default only active teams are returned. The CLI filters by `status = Active` in the Google Sheet.
+- The `team_id` is the numeric ID used by `parse_issues.py --enrich` for team-based filtering — the Team custom field cannot be queried via JQL directly.
+
+
+
+
+
+- [ ] Table with team name, lead, category, and team ID
+- [ ] Only active teams shown by default
+
+
diff --git a/skills/rhdh/SKILL.md b/skills/rhdh/SKILL.md
index 6e66c2c..381a956 100644
--- a/skills/rhdh/SKILL.md
+++ b/skills/rhdh/SKILL.md
@@ -123,10 +123,16 @@ What would you like to do?
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
+### Release Management Tasks
+
+*For RHDH release tracking, status, announcements*
+
+10. **Release management** — Release dates, status, team breakdown, freeze announcements, blocker bugs, CVEs, release notes
+
### General Tasks
-10. **Check environment** — Run doctor, configure paths
-11. **View/search activity** — Review worklog, todos
+11. **Check environment** — Run doctor, configure paths
+12. **View/search activity** — Review worklog, todos
**Wait for response before proceeding.**
@@ -180,12 +186,20 @@ What would you like to do?
**To route:** Read `../rhdh-test-plan-review/SKILL.md` and follow its intake process.
+### Release Management Routes
+
+| Response | Skill |
+|----------|-------|
+| 10, "release", "release manager", "release dates", "release status", "feature freeze", "code freeze", "blocker bugs", "CVEs", "release notes", "team breakdown", "freeze announcement" | Route to `@rhdh-release` skill |
+
+**To route:** Read `../rhdh-release/SKILL.md` and follow its intake process.
+
### General Routes
| Response | Action |
|----------|--------|
-| 10, "doctor", "setup", "config" | Use CLI commands below |
-| 11, "log", "todo", "activity" | Use tracking commands below |
+| 11, "doctor", "setup", "config" | Use CLI commands below |
+| 12, "log", "todo", "activity" | Use tracking commands below |
@@ -325,6 +339,7 @@ Todos must be **self-contained**—a new session should understand the task with
| rhdh-local | Enable/disable/test plugins in local RHDH | `../rhdh-local/SKILL.md` |
| rhdh-pr-review | PR code review and live cluster testing | `../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` |
+| rhdh-release | Release dates, status tracking, team coordination, freeze announcements, blocker bugs, CVEs, release notes | `../rhdh-release/SKILL.md` |
### Shared References
diff --git a/tests/unit/test_release_cli.py b/tests/unit/test_release_cli.py
new file mode 100644
index 0000000..8b43e8b
--- /dev/null
+++ b/tests/unit/test_release_cli.py
@@ -0,0 +1,402 @@
+"""Unit tests for skills/rhdh-release/scripts/ — jql.py, slack_templates.py, release.py."""
+
+import sys
+from pathlib import Path
+from urllib.parse import quote
+
+PROJECT_ROOT = Path(__file__).resolve().parents[2]
+_RELEASE_SCRIPTS = PROJECT_ROOT / "skills" / "rhdh-release" / "scripts"
+if str(_RELEASE_SCRIPTS) not in sys.path:
+ sys.path.insert(0, str(_RELEASE_SCRIPTS))
+
+import jql # noqa: E402
+import release # noqa: E402
+import slack_templates # noqa: E402
+
+# =========================================================================
+# jql.py
+# =========================================================================
+
+
+class TestJqlLoadTemplates:
+ def test_loads_13_templates(self):
+ templates = jql.load_templates()
+ assert len(templates) == 13
+
+ def test_known_template_names(self):
+ names = jql.list_templates()
+ assert "active_release" in names
+ assert "open_issues" in names
+ assert "open_issues_by_type" in names
+ assert "epics" in names
+ assert "cves" in names
+ assert "feature_demos" in names
+ assert "feature_subtasks" in names
+ assert "test_day_features" in names
+ assert "features_added_to_release" in names
+ assert "release_notes" in names
+ assert "blockers" in names
+ assert "feature_freeze_issues" in names
+ assert "code_freeze_issues" in names
+
+
+class TestJqlGetTemplate:
+ def test_get_existing(self):
+ tpl = jql.get_template("active_release")
+ assert "rhdhplan" in tpl.lower()
+
+ def test_get_nonexistent_raises(self):
+ try:
+ jql.get_template("nonexistent_query")
+ assert False, "Expected KeyError"
+ except KeyError as e:
+ assert "nonexistent_query" in str(e)
+
+
+class TestJqlRender:
+ def test_render_version(self):
+ rendered = jql.render("open_issues", version="1.9.0")
+ assert '"1.9.0"' in rendered
+ assert "{{RELEASE_VERSION}}" not in rendered
+
+ def test_render_version_and_type(self):
+ rendered = jql.render("open_issues_by_type", version="1.9.0", issue_type="Bug")
+ assert '"1.9.0"' in rendered
+ assert '"Bug"' in rendered
+ assert "{{RELEASE_VERSION}}" not in rendered
+ assert "{{ISSUE_TYPE}}" not in rendered
+
+ def test_render_no_substitution(self):
+ rendered = jql.render("active_release")
+ assert "{{" not in rendered
+
+
+class TestJqlUrl:
+ def test_url_encoding(self):
+ jql_str = "project = RHIDP AND status != closed"
+ url = jql.jira_url(jql_str)
+ assert url.startswith("https://issues.redhat.com/issues/?jql=")
+ assert "%20" in url or "+" in url
+ assert "project" not in url.split("jql=")[1].split("%")[0] or quote(jql_str, safe="") in url
+
+ def test_url_encodes_special_chars(self):
+ jql_str = 'fixVersion = "1.9.0" AND issuetype IN (Bug, Feature)'
+ url = jql.jira_url(jql_str)
+ encoded_part = url.split("jql=")[1]
+ assert " " not in encoded_part
+ assert '"' not in encoded_part
+ assert "(" not in encoded_part
+
+ def test_render_with_url(self):
+ rendered, url = jql.render_with_url("open_issues", version="1.9.0")
+ assert '"1.9.0"' in rendered
+ assert url.startswith("https://issues.redhat.com/issues/?jql=")
+ assert quote(rendered, safe="") in url
+
+
+# =========================================================================
+# slack_templates.py
+# =========================================================================
+
+
+class TestSlackLoadTemplates:
+ def test_loads_4_templates(self):
+ templates = slack_templates.load_templates()
+ assert len(templates) == 4
+
+ def test_known_template_keys(self):
+ keys = slack_templates.list_templates()
+ assert "feature_freeze_update" in keys
+ assert "feature_freeze" in keys
+ assert "code_freeze_update" in keys
+ assert "code_freeze" in keys
+
+
+class TestSlackGetTemplate:
+ def test_get_existing(self):
+ tpl = slack_templates.get_template("feature_freeze")
+ assert "{{RELEASE_VERSION}}" in tpl
+
+ def test_get_nonexistent_raises(self):
+ try:
+ slack_templates.get_template("nonexistent")
+ assert False, "Expected KeyError"
+ except KeyError as e:
+ assert "nonexistent" in str(e)
+
+
+class TestSlackFillPlaceholders:
+ def test_basic_fill(self):
+ template = "Hello {{NAME}}, version {{VERSION}}"
+ result = slack_templates.fill_placeholders(
+ template,
+ {
+ "NAME": "World",
+ "VERSION": "1.0",
+ },
+ )
+ assert result == "Hello World, version 1.0"
+
+ def test_no_match_preserved(self):
+ template = "{{UNKNOWN}} stays"
+ result = slack_templates.fill_placeholders(template, {"OTHER": "val"})
+ assert "{{UNKNOWN}}" in result
+
+
+class TestSlackExpandTeamLines:
+ def test_expands_team_block(self):
+ template = (
+ "Header\n"
+ "• *{{TEAM_NAME}}* - [{{ISSUE_COUNT}}](url)\n"
+ "(repeat for each active engineering team)\n"
+ "Footer"
+ )
+ teams = [
+ {"TEAM_NAME": "Alpha", "ISSUE_COUNT": "5"},
+ {"TEAM_NAME": "Beta", "ISSUE_COUNT": "3"},
+ ]
+ result = slack_templates.expand_team_lines(template, teams)
+ assert "• *Alpha* - [5](url)" in result
+ assert "• *Beta* - [3](url)" in result
+ assert "(repeat for each" not in result
+ assert "Footer" in result
+
+
+# =========================================================================
+# release.py — CLI parsing
+# =========================================================================
+
+
+class TestReleaseParser:
+ def test_no_args_exits_zero(self):
+ try:
+ release.main([])
+ except SystemExit as e:
+ assert e.code == 0
+
+ def test_check_subcommand(self):
+ parser = release.build_parser()
+ args = parser.parse_args(["check"])
+ assert args.command == "check"
+
+ def test_status_subcommand(self):
+ parser = release.build_parser()
+ args = parser.parse_args(["status", "1.9.0"])
+ assert args.command == "status"
+ assert args.version == "1.9.0"
+
+ def test_slack_subcommand(self):
+ parser = release.build_parser()
+ args = parser.parse_args(["slack", "feature-freeze", "1.9.0"])
+ assert args.command == "slack"
+ assert args.slack_command == "feature-freeze"
+ assert args.version == "1.9.0"
+
+ def test_global_json_flag(self):
+ parser = release.build_parser()
+ args = parser.parse_args(["--json", "status", "1.9.0"])
+ assert args.output_mode == "json"
+
+ def test_global_human_flag(self):
+ parser = release.build_parser()
+ args = parser.parse_args(["--human", "status", "1.9.0"])
+ assert args.output_mode == "human"
+
+ def test_verbose_flag(self):
+ parser = release.build_parser()
+ args = parser.parse_args(["--verbose", "check"])
+ assert args.verbose is True
+
+ def test_teams_category(self):
+ parser = release.build_parser()
+ args = parser.parse_args(["teams", "--category", "Engineering"])
+ assert args.command == "teams"
+ assert args.category == "Engineering"
+
+ def test_all_subcommands_parse(self):
+ parser = release.build_parser()
+ for cmd in ["check", "dates", "slack"]:
+ args = parser.parse_args([cmd])
+ assert args.command == cmd
+ for cmd in [
+ "future-dates",
+ "status",
+ "team-breakdown",
+ "blockers",
+ "epics",
+ "cves",
+ "notes",
+ ]:
+ args = parser.parse_args([cmd, "1.0.0"])
+ assert args.command == cmd
+ args = parser.parse_args(["teams"])
+ assert args.command == "teams"
+
+ def test_all_slack_subcommands_parse(self):
+ parser = release.build_parser()
+ for cmd in ["feature-freeze-update", "feature-freeze", "code-freeze-update", "code-freeze"]:
+ args = parser.parse_args(["slack", cmd, "1.0.0"])
+ assert args.slack_command == cmd
+
+
+class TestParseAcliCount:
+ def test_standard_output(self):
+ output = "✓ Number of work items in the search: 42"
+ assert release._parse_acli_count(output) == 42
+
+ def test_multiline_with_noise(self):
+ output = "Connecting...\nSearching...\n✓ Number of work items in the search: 128\n"
+ assert release._parse_acli_count(output) == 128
+
+ def test_zero_count(self):
+ output = "✓ Number of work items in the search: 0"
+ assert release._parse_acli_count(output) == 0
+
+ def test_large_count(self):
+ output = "✓ Number of work items in the search: 12086"
+ assert release._parse_acli_count(output) == 12086
+
+ def test_no_count_raises(self):
+ try:
+ release._parse_acli_count("No numbers here")
+ assert False, "Expected ValueError"
+ except ValueError:
+ pass
+
+
+class TestCommandMapping:
+ def test_all_commands_mapped(self):
+ expected_commands = {
+ "check",
+ "dates",
+ "future-dates",
+ "status",
+ "teams",
+ "team-breakdown",
+ "blockers",
+ "epics",
+ "cves",
+ "notes",
+ }
+ assert expected_commands == set(release.COMMANDS.keys())
+
+ def test_all_slack_commands_mapped(self):
+ expected = {
+ "feature-freeze-update",
+ "feature-freeze",
+ "code-freeze-update",
+ "code-freeze",
+ }
+ assert expected == set(release.SLACK_COMMANDS.keys())
+
+
+# =========================================================================
+# release.py — _find_parse_issues discovery
+# =========================================================================
+
+
+class TestFindParseIssues:
+ def test_returns_path_or_none(self):
+ result = release._find_parse_issues()
+ assert result is None or isinstance(result, Path)
+
+ def test_sibling_path_resolves(self):
+ sibling = (
+ Path(__file__).resolve().parent.parent.parent
+ / "skills"
+ / "rhdh-jira"
+ / "scripts"
+ / "parse_issues.py"
+ )
+ result = release._find_parse_issues()
+ if sibling.exists():
+ assert result is not None
+ assert result.exists()
+
+
+# =========================================================================
+# release.py — schedule parsing (inlined from schedule.py)
+# =========================================================================
+
+
+class TestNormalizeTeamName:
+ def test_strips_rhdh_prefix(self):
+ assert release._normalize_team_name("RHDH AI") == "ai"
+
+ def test_case_insensitive_prefix(self):
+ assert release._normalize_team_name("rhdh Cope") == "cope"
+
+ def test_no_prefix(self):
+ assert release._normalize_team_name("AI") == "ai"
+
+ def test_whitespace_stripped(self):
+ assert release._normalize_team_name(" RHDH AI ") == "ai"
+
+ def test_exact_match_lowered(self):
+ assert release._normalize_team_name("Cope") == "cope"
+
+
+class TestNormalizeVersion:
+ def test_simple_version(self):
+ assert release._normalize_version("1.9.0") == "1.9"
+
+ def test_rhdh_prefix(self):
+ assert release._normalize_version("RHDH 1.6") == "1.6"
+
+ def test_dash_prefix(self):
+ assert release._normalize_version("rhdh-1.6") == "1.6"
+
+ def test_v_prefix(self):
+ assert release._normalize_version("v1.6") == "1.6"
+
+
+class TestParseDate:
+ def test_iso_format(self):
+ assert release._parse_date("2025-06-15") == "2025-06-15"
+
+ def test_us_format(self):
+ assert release._parse_date("06/15/2025") == "2025-06-15"
+
+ def test_long_format(self):
+ assert release._parse_date("June 15, 2025") == "2025-06-15"
+
+ def test_unparseable(self):
+ assert release._parse_date("not a date") is None
+
+
+class TestFindScheduleTab:
+ def test_finds_current_year(self):
+ from datetime import datetime
+
+ year = str(datetime.now().year)
+ tabs = [f"RHDH {year} schedule", "Other", "Archive"]
+ assert release._find_schedule_tab(tabs) == f"RHDH {year} schedule"
+
+ def test_fallback_to_schedule(self):
+ tabs = ["Other", "Schedule", "Archive"]
+ assert release._find_schedule_tab(tabs) == "Schedule"
+
+ def test_no_match(self):
+ tabs = ["Sheet1", "Sheet2"]
+ assert release._find_schedule_tab(tabs) is None
+
+
+class TestFindMilestones:
+ def test_finds_ga_and_freezes(self):
+ rows = [
+ ["Date", "Event", "Version"],
+ ["2025-05-01", "Feature Freeze", "RHDH 1.9"],
+ ["2025-05-15", "Code Freeze", "RHDH 1.9"],
+ ["2025-06-01", "GA Announce", "RHDH 1.9"],
+ ]
+ result = release._find_milestones(rows, "1.9")
+ assert result.get("ga_date") == "2025-06-01"
+ assert result.get("code_freeze") == "2025-05-15"
+ assert result.get("feature_freeze") == "2025-05-01"
+
+ def test_version_not_found(self):
+ rows = [
+ ["Date", "Event"],
+ ["2025-06-01", "GA Announce RHDH 1.8"],
+ ]
+ assert release._find_milestones(rows, "2.0") == {}
diff --git a/uv.lock b/uv.lock
index c9618a9..8d77fb0 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,5 +1,5 @@
version = 1
-revision = 1
+revision = 3
requires-python = ">=3.9"
resolution-markers = [
"python_full_version >= '3.10'",
@@ -10,9 +10,9 @@ resolution-markers = [
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
@@ -22,9 +22,9 @@ source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371 }
+sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740 },
+ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
@@ -34,9 +34,9 @@ source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.10'",
]
-sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 }
+sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 },
+ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" },
]
[[package]]
@@ -46,36 +46,36 @@ source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.10'",
]
-sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 },
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "packaging"
version = "26.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416 }
+sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366 },
+ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 },
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 },
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
]
[[package]]
@@ -94,9 +94,9 @@ dependencies = [
{ name = "pygments", marker = "python_full_version < '3.10'" },
{ name = "tomli", marker = "python_full_version < '3.10'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618 }
+sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750 },
+ { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
]
[[package]]
@@ -115,87 +115,87 @@ dependencies = [
{ name = "pygments", marker = "python_full_version >= '3.10'" },
{ name = "tomli", marker = "python_full_version == '3.10.*'" },
]
-sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901 }
+sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801 },
+ { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 }
+sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227 },
- { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019 },
- { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646 },
- { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793 },
- { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293 },
- { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872 },
- { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828 },
- { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415 },
- { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561 },
- { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 },
- { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 },
- { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 },
- { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114 },
- { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638 },
- { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463 },
- { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986 },
- { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 },
- { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 },
- { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 },
- { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 },
- { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 },
- { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 },
- { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 },
- { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 },
- { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 },
- { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 },
- { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 },
- { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 },
- { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 },
- { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 },
- { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 },
- { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 },
- { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 },
- { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 },
- { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 },
- { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 },
- { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 },
- { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 },
- { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 },
- { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 },
- { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 },
- { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 },
- { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 },
- { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 },
- { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 },
- { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 },
- { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 },
- { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 },
- { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 },
- { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 },
- { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 },
- { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 },
- { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 },
- { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 },
- { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 },
- { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 },
- { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450 },
- { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319 },
- { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631 },
- { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795 },
- { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767 },
- { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982 },
- { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677 },
- { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592 },
- { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777 },
+ { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
+ { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
+ { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
+ { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
+ { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
+ { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
+ { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
+ { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
+ { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
+ { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
+ { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
+ { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
+ { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
+ { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
+ { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
+ { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
+ { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
+ { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
+ { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
+ { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
+ { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
+ { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
+ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
+ { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
+ { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
+ { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
+ { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
+ { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
+ { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
+ { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
+ { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
+ { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
+ { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
+ { url = "https://files.pythonhosted.org/packages/9f/62/67fc8e68a75f738c9200422bf65693fb79a4cd0dc5b23310e5202e978090/pyyaml-6.0.3-cp39-cp39-macosx_10_13_x86_64.whl", hash = "sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da", size = 184450, upload-time = "2025-09-25T21:33:00.618Z" },
+ { url = "https://files.pythonhosted.org/packages/ae/92/861f152ce87c452b11b9d0977952259aa7df792d71c1053365cc7b09cc08/pyyaml-6.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917", size = 174319, upload-time = "2025-09-25T21:33:02.086Z" },
+ { url = "https://files.pythonhosted.org/packages/d0/cd/f0cfc8c74f8a030017a2b9c771b7f47e5dd702c3e28e5b2071374bda2948/pyyaml-6.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9", size = 737631, upload-time = "2025-09-25T21:33:03.25Z" },
+ { url = "https://files.pythonhosted.org/packages/ef/b2/18f2bd28cd2055a79a46c9b0895c0b3d987ce40ee471cecf58a1a0199805/pyyaml-6.0.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5", size = 836795, upload-time = "2025-09-25T21:33:05.014Z" },
+ { url = "https://files.pythonhosted.org/packages/73/b9/793686b2d54b531203c160ef12bec60228a0109c79bae6c1277961026770/pyyaml-6.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a", size = 750767, upload-time = "2025-09-25T21:33:06.398Z" },
+ { url = "https://files.pythonhosted.org/packages/a9/86/a137b39a611def2ed78b0e66ce2fe13ee701a07c07aebe55c340ed2a050e/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926", size = 727982, upload-time = "2025-09-25T21:33:08.708Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/62/71c27c94f457cf4418ef8ccc71735324c549f7e3ea9d34aba50874563561/pyyaml-6.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7", size = 755677, upload-time = "2025-09-25T21:33:09.876Z" },
+ { url = "https://files.pythonhosted.org/packages/29/3d/6f5e0d58bd924fb0d06c3a6bad00effbdae2de5adb5cda5648006ffbd8d3/pyyaml-6.0.3-cp39-cp39-win32.whl", hash = "sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0", size = 142592, upload-time = "2025-09-25T21:33:10.983Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/0c/25113e0b5e103d7f1490c0e947e303fe4a696c10b501dea7a9f49d4e876c/pyyaml-6.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007", size = 158777, upload-time = "2025-09-25T21:33:15.55Z" },
]
[[package]]
name = "rhdh-skill"
-version = "0.4.0"
+version = "0.5.0"
source = { virtual = "." }
[package.optional-dependencies]
@@ -218,86 +218,86 @@ provides-extras = ["dev"]
name = "ruff"
version = "0.15.10"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728 }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/d9/aa3f7d59a10ef6b14fe3431706f854dbf03c5976be614a9796d36326810c/ruff-0.15.10.tar.gz", hash = "sha256:d1f86e67ebfdef88e00faefa1552b5e510e1d35f3be7d423dc7e84e63788c94e", size = 4631728, upload-time = "2026-04-09T14:06:09.884Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362 },
- { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122 },
- { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005 },
- { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450 },
- { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597 },
- { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645 },
- { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289 },
- { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266 },
- { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418 },
- { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416 },
- { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053 },
- { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302 },
- { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074 },
- { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051 },
- { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964 },
- { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044 },
- { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607 },
+ { url = "https://files.pythonhosted.org/packages/eb/00/a1c2fdc9939b2c03691edbda290afcd297f1f389196172826b03d6b6a595/ruff-0.15.10-py3-none-linux_armv6l.whl", hash = "sha256:0744e31482f8f7d0d10a11fcbf897af272fefdfcb10f5af907b18c2813ff4d5f", size = 10563362, upload-time = "2026-04-09T14:06:21.189Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/15/006990029aea0bebe9d33c73c3e28c80c391ebdba408d1b08496f00d422d/ruff-0.15.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b1e7c16ea0ff5a53b7c2df52d947e685973049be1cdfe2b59a9c43601897b22e", size = 10951122, upload-time = "2026-04-09T14:06:02.236Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/c0/4ac978fe874d0618c7da647862afe697b281c2806f13ce904ad652fa87e4/ruff-0.15.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93cc06a19e5155b4441dd72808fdf84290d84ad8a39ca3b0f994363ade4cebb1", size = 10314005, upload-time = "2026-04-09T14:06:00.026Z" },
+ { url = "https://files.pythonhosted.org/packages/da/73/c209138a5c98c0d321266372fc4e33ad43d506d7e5dd817dd89b60a8548f/ruff-0.15.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:83e1dd04312997c99ea6965df66a14fb4f03ba978564574ffc68b0d61fd3989e", size = 10643450, upload-time = "2026-04-09T14:05:42.137Z" },
+ { url = "https://files.pythonhosted.org/packages/ec/76/0deec355d8ec10709653635b1f90856735302cb8e149acfdf6f82a5feb70/ruff-0.15.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8154d43684e4333360fedd11aaa40b1b08a4e37d8ffa9d95fee6fa5b37b6fab1", size = 10379597, upload-time = "2026-04-09T14:05:49.984Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/be/86bba8fc8798c081e28a4b3bb6d143ccad3fd5f6f024f02002b8f08a9fa3/ruff-0.15.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ab88715f3a6deb6bde6c227f3a123410bec7b855c3ae331b4c006189e895cef", size = 11146645, upload-time = "2026-04-09T14:06:12.246Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/89/140025e65911b281c57be1d385ba1d932c2366ca88ae6663685aed8d4881/ruff-0.15.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a768ff5969b4f44c349d48edf4ab4f91eddb27fd9d77799598e130fb628aa158", size = 12030289, upload-time = "2026-04-09T14:06:04.776Z" },
+ { url = "https://files.pythonhosted.org/packages/88/de/ddacca9545a5e01332567db01d44bd8cf725f2db3b3d61a80550b48308ea/ruff-0.15.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ee3ef42dab7078bda5ff6a1bcba8539e9857deb447132ad5566a038674540d0", size = 11496266, upload-time = "2026-04-09T14:05:55.485Z" },
+ { url = "https://files.pythonhosted.org/packages/bc/bb/7ddb00a83760ff4a83c4e2fc231fd63937cc7317c10c82f583302e0f6586/ruff-0.15.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51cb8cc943e891ba99989dd92d61e29b1d231e14811db9be6440ecf25d5c1609", size = 11256418, upload-time = "2026-04-09T14:05:57.69Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/8d/55de0d35aacf6cd50b6ee91ee0f291672080021896543776f4170fc5c454/ruff-0.15.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:e59c9bdc056a320fb9ea1700a8d591718b8faf78af065484e801258d3a76bc3f", size = 11288416, upload-time = "2026-04-09T14:05:44.695Z" },
+ { url = "https://files.pythonhosted.org/packages/68/cf/9438b1a27426ec46a80e0a718093c7f958ef72f43eb3111862949ead3cc1/ruff-0.15.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:136c00ca2f47b0018b073f28cb5c1506642a830ea941a60354b0e8bc8076b151", size = 10621053, upload-time = "2026-04-09T14:05:52.782Z" },
+ { url = "https://files.pythonhosted.org/packages/4c/50/e29be6e2c135e9cd4cb15fbade49d6a2717e009dff3766dd080fcb82e251/ruff-0.15.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8b80a2f3c9c8a950d6237f2ca12b206bccff626139be9fa005f14feb881a1ae8", size = 10378302, upload-time = "2026-04-09T14:06:14.361Z" },
+ { url = "https://files.pythonhosted.org/packages/18/2f/e0b36a6f99c51bb89f3a30239bc7bf97e87a37ae80aa2d6542d6e5150364/ruff-0.15.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:e3e53c588164dc025b671c9df2462429d60357ea91af7e92e9d56c565a9f1b07", size = 10850074, upload-time = "2026-04-09T14:06:16.581Z" },
+ { url = "https://files.pythonhosted.org/packages/11/08/874da392558ce087a0f9b709dc6ec0d60cbc694c1c772dab8d5f31efe8cb/ruff-0.15.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b0c52744cf9f143a393e284125d2576140b68264a93c6716464e129a3e9adb48", size = 11358051, upload-time = "2026-04-09T14:06:18.948Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/46/602938f030adfa043e67112b73821024dc79f3ab4df5474c25fa4c1d2d14/ruff-0.15.10-py3-none-win32.whl", hash = "sha256:d4272e87e801e9a27a2e8df7b21011c909d9ddd82f4f3281d269b6ba19789ca5", size = 10588964, upload-time = "2026-04-09T14:06:07.14Z" },
+ { url = "https://files.pythonhosted.org/packages/25/b6/261225b875d7a13b33a6d02508c39c28450b2041bb01d0f7f1a83d569512/ruff-0.15.10-py3-none-win_amd64.whl", hash = "sha256:28cb32d53203242d403d819fd6983152489b12e4a3ae44993543d6fe62ab42ed", size = 11745044, upload-time = "2026-04-09T14:05:39.473Z" },
+ { url = "https://files.pythonhosted.org/packages/58/ed/dea90a65b7d9e69888890fb14c90d7f51bf0c1e82ad800aeb0160e4bacfd/ruff-0.15.10-py3-none-win_arm64.whl", hash = "sha256:601d1610a9e1f1c2165a4f561eeaa2e2ea1e97f3287c5aa258d3dab8b57c6188", size = 11035607, upload-time = "2026-04-09T14:05:47.593Z" },
]
[[package]]
name = "tomli"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477 }
+sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663 },
- { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469 },
- { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039 },
- { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007 },
- { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875 },
- { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271 },
- { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770 },
- { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626 },
- { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842 },
- { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894 },
- { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053 },
- { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481 },
- { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720 },
- { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014 },
- { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820 },
- { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712 },
- { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296 },
- { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553 },
- { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915 },
- { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038 },
- { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245 },
- { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335 },
- { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962 },
- { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396 },
- { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530 },
- { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227 },
- { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748 },
- { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725 },
- { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901 },
- { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375 },
- { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639 },
- { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897 },
- { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697 },
- { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567 },
- { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556 },
- { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014 },
- { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339 },
- { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490 },
- { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398 },
- { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515 },
- { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806 },
- { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340 },
- { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106 },
- { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504 },
- { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561 },
- { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477 },
+ { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" },
+ { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" },
+ { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" },
+ { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" },
+ { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" },
+ { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" },
+ { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" },
+ { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" },
+ { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" },
+ { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" },
+ { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" },
+ { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" },
+ { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" },
+ { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" },
+ { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" },
+ { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" },
+ { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" },
+ { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" },
+ { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" },
+ { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" },
+ { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" },
+ { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" },
+ { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" },
+ { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" },
+ { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" },
+ { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" },
+ { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" },
+ { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" },
+ { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
- { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 },
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]