Skip to content

Commit d824ab3

Browse files
durandomclaude
andcommitted
feat: add release CLI for deterministic data gathering
Add `release.py` CLI with 14 subcommands that extract deterministic work (counting, URL-encoding, template filling) from agent workflows. The agent calls the CLI first and adds judgment on top. New scripts: - release.py — argparse CLI: check, dates, future-dates, status, teams, team-breakdown, blockers, epics, cves, notes, slack {feature-freeze-update, feature-freeze, code-freeze-update, code-freeze} - jql.py — parse 12 JQL templates from jql-release.md at runtime - slack_templates.py — parse 4 Slack templates from slack-templates.md - formatters.py — symlink to shared OutputFormatter Updated: - SKILL.md routing table: CLI command as primary, workflow as fallback - All 13 workflows: Step 1 Run CLI added, manual steps kept as fallback - check-structural.md: CLI existence check added (check 10) Tests: 34 new unit tests for jql parsing, slack templates, CLI arg parsing, acli count parsing. Showboat demo at tests/demo-release-cli.md. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0c6c9d6 commit d824ab3

21 files changed

Lines changed: 1833 additions & 65 deletions

skills/rhdh-release/SKILL.md

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -83,21 +83,23 @@ What would you like to do?
8383

8484
<routing>
8585

86-
| Response | Workflow |
87-
|----------|----------|
88-
| 1, "release dates", "key dates", "freeze dates", "milestone dates" | Read `workflows/release-dates.md` and follow it |
89-
| 2, "future releases", "upcoming releases", "release roadmap", "future dates" | Read `workflows/future-release-dates.md` and follow it |
90-
| 3, "release status", "active releases", "release health", "release overview" | Read `workflows/release-status.md` and follow it |
91-
| 4, "teams", "team leads", "team list", "team contacts", "team directory" | Read `workflows/teams-and-leads.md` and follow it |
92-
| 5, "team breakdown", "issues by team", "team workload", "team counts" | Read `workflows/issues-by-team.md` and follow it |
93-
| 6, "blocker bugs", "blockers", "critical issues", "blocking issues" | Read `workflows/blocker-bugs.md` and follow it |
94-
| 7, "epics", "engineering epics", "open epics", "active epics" | Read `workflows/engineering-epics.md` and follow it |
95-
| 8, "cves", "vulnerabilities", "security issues", "security bugs" | Read `workflows/cves.md` and follow it |
96-
| 9, "release notes", "missing release notes", "release note gaps" | Read `workflows/release-notes.md` and follow it |
97-
| 10, "feature freeze update", "feature freeze status", "feature freeze progress" | Read `workflows/announce-feature-freeze-update.md` and follow it |
98-
| 11, "feature freeze announcement", "announce feature freeze", "feature freeze reached" | Read `workflows/announce-feature-freeze.md` and follow it |
99-
| 12, "code freeze update", "code freeze status", "code freeze progress" | Read `workflows/announce-code-freeze-update.md` and follow it |
100-
| 13, "code freeze announcement", "announce code freeze", "code freeze reached" | Read `workflows/announce-code-freeze.md` and follow it |
86+
**Preferred:** Run the `release` CLI first (`python scripts/release.py <command> --json`). If the CLI fails, fall back to the workflow's manual steps.
87+
88+
| Response | CLI Command | Workflow (fallback) |
89+
|----------|-------------|---------------------|
90+
| 1, "release dates", "key dates", "freeze dates", "milestone dates" | `python scripts/release.py dates --json` | `workflows/release-dates.md` |
91+
| 2, "future releases", "upcoming releases", "release roadmap", "future dates" | `python scripts/release.py future-dates VERSION --json` | `workflows/future-release-dates.md` |
92+
| 3, "release status", "active releases", "release health", "release overview" | `python scripts/release.py status VERSION --json` | `workflows/release-status.md` |
93+
| 4, "teams", "team leads", "team list", "team contacts", "team directory" | `python scripts/release.py teams --json` | `workflows/teams-and-leads.md` |
94+
| 5, "team breakdown", "issues by team", "team workload", "team counts" | `python scripts/release.py team-breakdown VERSION --json` | `workflows/issues-by-team.md` |
95+
| 6, "blocker bugs", "blockers", "critical issues", "blocking issues" | `python scripts/release.py blockers VERSION --json` | `workflows/blocker-bugs.md` |
96+
| 7, "epics", "engineering epics", "open epics", "active epics" | `python scripts/release.py epics VERSION --json` | `workflows/engineering-epics.md` |
97+
| 8, "cves", "vulnerabilities", "security issues", "security bugs" | `python scripts/release.py cves VERSION --json` | `workflows/cves.md` |
98+
| 9, "release notes", "missing release notes", "release note gaps" | `python scripts/release.py notes VERSION --json` | `workflows/release-notes.md` |
99+
| 10, "feature freeze update", "feature freeze status", "feature freeze progress" | `python scripts/release.py slack feature-freeze-update VERSION --json` | `workflows/announce-feature-freeze-update.md` |
100+
| 11, "feature freeze announcement", "announce feature freeze", "feature freeze reached" | `python scripts/release.py slack feature-freeze VERSION --json` | `workflows/announce-feature-freeze.md` |
101+
| 12, "code freeze update", "code freeze status", "code freeze progress" | `python scripts/release.py slack code-freeze-update VERSION --json` | `workflows/announce-code-freeze-update.md` |
102+
| 13, "code freeze announcement", "announce code freeze", "code freeze reached" | `python scripts/release.py slack code-freeze VERSION --json` | `workflows/announce-code-freeze.md` |
101103

102104
</routing>
103105

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
../../rhdh/rhdh/formatters.py

skills/rhdh-release/scripts/jql.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""Parse JQL templates from references/jql-release.md.
2+
3+
Reads the markdown file at runtime so the CLI and agent share one source of truth.
4+
Supports placeholder rendering ({{RELEASE_VERSION}}, {{ISSUE_TYPE}}) and
5+
URL-encoded Jira search links.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import re
11+
from pathlib import Path
12+
from urllib.parse import quote
13+
14+
JIRA_SEARCH_BASE = "https://issues.redhat.com/issues/?jql="
15+
16+
_REFERENCES_DIR = Path(__file__).resolve().parent.parent / "references"
17+
_JQL_FILE = _REFERENCES_DIR / "jql-release.md"
18+
19+
_TEMPLATE_CACHE: dict[str, str] | None = None
20+
21+
22+
def _parse_jql_file(path: Path | None = None) -> dict[str, str]:
23+
"""Parse ## headings and ```jql code blocks from jql-release.md."""
24+
path = path or _JQL_FILE
25+
text = path.read_text()
26+
templates: dict[str, str] = {}
27+
current_name: str | None = None
28+
jql_lines: list[str] | None = None
29+
30+
for line in text.splitlines():
31+
heading = re.match(r"^##\s+(\S+)", line)
32+
if heading:
33+
current_name = heading.group(1)
34+
continue
35+
36+
if current_name and line.strip() == "```jql":
37+
jql_lines = []
38+
continue
39+
40+
if current_name and jql_lines is not None and line.strip() == "```":
41+
templates[current_name] = " ".join(jql_lines).strip()
42+
current_name = None
43+
jql_lines = None
44+
continue
45+
46+
if jql_lines is not None:
47+
jql_lines.append(line.strip())
48+
49+
return templates
50+
51+
52+
def load_templates(path: Path | None = None) -> dict[str, str]:
53+
"""Load and cache JQL templates from jql-release.md."""
54+
global _TEMPLATE_CACHE
55+
if path is not None:
56+
return _parse_jql_file(path)
57+
if _TEMPLATE_CACHE is None:
58+
_TEMPLATE_CACHE = _parse_jql_file()
59+
return _TEMPLATE_CACHE
60+
61+
62+
def get_template(name: str, path: Path | None = None) -> str:
63+
"""Get a single JQL template by name. Raises KeyError if not found."""
64+
templates = load_templates(path)
65+
if name not in templates:
66+
available = ", ".join(sorted(templates))
67+
raise KeyError(f"Unknown JQL template '{name}'. Available: {available}")
68+
return templates[name]
69+
70+
71+
def render(
72+
name: str,
73+
*,
74+
version: str | None = None,
75+
issue_type: str | None = None,
76+
path: Path | None = None,
77+
) -> str:
78+
"""Render a JQL template with placeholder substitution."""
79+
jql = get_template(name, path)
80+
if version is not None:
81+
jql = jql.replace("{{RELEASE_VERSION}}", version)
82+
if issue_type is not None:
83+
jql = jql.replace("{{ISSUE_TYPE}}", issue_type)
84+
return jql
85+
86+
87+
def jira_url(jql: str) -> str:
88+
"""Build a Jira search URL from a JQL string."""
89+
return JIRA_SEARCH_BASE + quote(jql, safe="")
90+
91+
92+
def render_with_url(
93+
name: str,
94+
*,
95+
version: str | None = None,
96+
issue_type: str | None = None,
97+
path: Path | None = None,
98+
) -> tuple[str, str]:
99+
"""Render a JQL template and return (jql, jira_url)."""
100+
jql = render(name, version=version, issue_type=issue_type, path=path)
101+
return jql, jira_url(jql)
102+
103+
104+
def list_templates(path: Path | None = None) -> list[str]:
105+
"""Return sorted list of available template names."""
106+
return sorted(load_templates(path))

0 commit comments

Comments
 (0)