From 3506fea82d54ecf1fb1b2d6215c505cceccc9c3a Mon Sep 17 00:00:00 2001 From: nhamza Date: Sun, 12 Jul 2026 13:32:13 +0300 Subject: [PATCH] OCPEDGE-2781: Add /release-planning skill for planning risk visibility (KR 4.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Claude Code skill that assesses whether the team can deliver planned scope within remaining time. Runs a data-quality gate followed by 6 deterministic planning risk checks (capacity, timeline, assignment, bug load, sizing, composite progress), then delegates narrative recommendations to an LLM sub-agent. Outputs both markdown and DOCX reports. Architecture: all arithmetic is deterministic Python — the LLM only writes narrative recommendations around pre-computed numbers. MCP fetch → transforms → run-checks.py → sub-agent → assemble-report.py Key design decisions: - Deterministic checks in run-checks.py (testable, reproducible) - parent field (not Epic Link) for child-only issue fetching - Proportional velocity across features - DOCX rendered from structured data (not parsed from markdown) - Per-type done states matching Laws - Pencils down vs branch cut milestones (pd:) - MicroShift OCPBUGS components with prefix matching - SPOF + Unassigned merged as one composite signal Co-Authored-By: Claude Opus 4.6 (1M context) --- .claude-plugin/marketplace.json | 2 +- .claude/skills/edge-scrum-release-health | 1 + .claude/skills/edge-scrum-release-planning | 1 + plugins/edge-scrum/.claude-plugin/plugin.json | 2 +- plugins/edge-scrum/README.md | 32 + plugins/edge-scrum/bin/_jira_transforms.py | 30 +- plugins/edge-scrum/bin/assemble-report.py | 383 ++++++++++++ plugins/edge-scrum/bin/md-to-docx.py | 353 +++++++++++ plugins/edge-scrum/bin/run-checks.py | 588 ++++++++++++++++++ .../edge-scrum/bin/tests/test_md_to_docx.py | 152 +++++ .../edge-scrum/bin/tests/test_run_checks.py | 460 ++++++++++++++ .../bin/tests/test_transform_bugs.py | 155 +++++ .../bin/tests/test_transform_stories.py | 219 +++++++ plugins/edge-scrum/bin/transform-bugs.py | 115 ++++ plugins/edge-scrum/bin/transform-stories.py | 141 +++++ .../edge-scrum/references/Edge-Scrum-Laws.md | 2 + .../release-planning-report-template.md | 48 ++ .../skills/release-planning-analysis/SKILL.md | 78 +++ .../skills/release-planning/SKILL.md | 392 ++++++++++++ 19 files changed, 3147 insertions(+), 7 deletions(-) create mode 120000 .claude/skills/edge-scrum-release-health create mode 120000 .claude/skills/edge-scrum-release-planning create mode 100644 plugins/edge-scrum/bin/assemble-report.py create mode 100644 plugins/edge-scrum/bin/md-to-docx.py create mode 100644 plugins/edge-scrum/bin/run-checks.py create mode 100644 plugins/edge-scrum/bin/tests/test_md_to_docx.py create mode 100644 plugins/edge-scrum/bin/tests/test_run_checks.py create mode 100644 plugins/edge-scrum/bin/tests/test_transform_bugs.py create mode 100644 plugins/edge-scrum/bin/tests/test_transform_stories.py create mode 100644 plugins/edge-scrum/bin/transform-bugs.py create mode 100644 plugins/edge-scrum/bin/transform-stories.py create mode 100644 plugins/edge-scrum/references/release-planning-report-template.md create mode 100644 plugins/edge-scrum/skills/release-planning-analysis/SKILL.md create mode 100644 plugins/edge-scrum/skills/release-planning/SKILL.md diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index dd85de3a..3853c7d7 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -28,7 +28,7 @@ "name": "edge-scrum", "source": "./plugins/edge-scrum", "description": "Agents, skills, and workflows relevant to scrum process management for the OpenShift Edge Team", - "version": "1.0.0" + "version": "1.2.0" }, { "name": "git-commits", diff --git a/.claude/skills/edge-scrum-release-health b/.claude/skills/edge-scrum-release-health new file mode 120000 index 00000000..06e139d2 --- /dev/null +++ b/.claude/skills/edge-scrum-release-health @@ -0,0 +1 @@ +../../plugins/edge-scrum/skills/release-health \ No newline at end of file diff --git a/.claude/skills/edge-scrum-release-planning b/.claude/skills/edge-scrum-release-planning new file mode 120000 index 00000000..1da78462 --- /dev/null +++ b/.claude/skills/edge-scrum-release-planning @@ -0,0 +1 @@ +../../plugins/edge-scrum/skills/release-planning \ No newline at end of file diff --git a/plugins/edge-scrum/.claude-plugin/plugin.json b/plugins/edge-scrum/.claude-plugin/plugin.json index bdd24585..5883517d 100644 --- a/plugins/edge-scrum/.claude-plugin/plugin.json +++ b/plugins/edge-scrum/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "edge-scrum", "description": "Agents, skills, and workflows relevant to scrum process management for the OpenShift Edge Team", - "version": "1.1.0", + "version": "1.2.0", "author": { "name": "jeff-roche" }, "homepage": "https://github.com/openshift-eng/edge-tooling", "license": "Apache-2.0" diff --git a/plugins/edge-scrum/README.md b/plugins/edge-scrum/README.md index 82441e1d..a9e38b72 100644 --- a/plugins/edge-scrum/README.md +++ b/plugins/edge-scrum/README.md @@ -68,3 +68,35 @@ Analyzes the health of an OCP release cycle. Traverses the full Jira hierarchy Output is saved to `.reports/release_health_{version}_{YYYY-MM-DD}.md`. See [`skills/release-health/README.md`](skills/release-health/README.md) for full usage details. + +#### `release-planning` + +Assesses whether the team can deliver planned scope within remaining time. Runs a data-quality gate followed by six planning risk checks — capacity, timeline, assignment, bug load, sizing, and composite progress — to surface risks per person and per feature before they become execution problems. + +**Usage:** + +```shell +/release-planning [version] [sprint-range] [bc:branch-cut-sprint] [pd:pencils-down-sprint] [--component ] +``` + +| Example | Description | +|---------|-------------| +| `/release-planning` | Interactive mode — prompts for all parameters | +| `/release-planning 5.0 287-292 bc:292` | Analyze OCP 5.0, sprints 287–292, branch cut at 292 | +| `/release-planning 5.0 287-292 bc:292 pd:291` | Same, with pencils down at sprint 291 | +| `/release-planning 5.0 287-292 bc:292 --component TNA` | Filtered to TNA features only | + +**Pencils down** is when all feature code must be merged. **Branch cut** is when the release branch is created. Feature timeline risk is measured against pencils down. If `pd:` is omitted, it defaults to the branch cut sprint. + +**What it produces:** + +1. **Data Quality** — validates story-level breakdown before running checks +2. **Capacity** — per-person assigned SP vs remaining capacity +3. **Timeline** — per-feature remaining work vs time left +4. **Assignment** — unassigned work and single points of failure +5. **Bug Load** — unassigned Blocker/Critical bugs +6. **Sizing** — T-shirt size vs actual scope mismatches +7. **Composite Risk** — multi-signal risk assessment per feature (LOW/MEDIUM/HIGH) +8. **Recommendations** — actionable per-person, per-feature, and team-level actions + +Output is saved to `.reports/release_planning_{version}_{YYYY-MM-DD}.md` and `.reports/release_planning_{version}_{YYYY-MM-DD}.docx`. diff --git a/plugins/edge-scrum/bin/_jira_transforms.py b/plugins/edge-scrum/bin/_jira_transforms.py index f7d9ec8a..f5333a81 100755 --- a/plugins/edge-scrum/bin/_jira_transforms.py +++ b/plugins/edge-scrum/bin/_jira_transforms.py @@ -46,7 +46,7 @@ def extract_issue_type(issue): def extract_sp(issue, issue_type=None): - """Extract story points. Bugs always return 0.""" + """Extract story points. Bugs always return 0. Returns 0 on unparseable input.""" if issue_type is None: issue_type = extract_issue_type(issue) if issue_type == "Bug": @@ -54,10 +54,20 @@ def extract_sp(issue, issue_type=None): raw = issue.get("customfield_10028") if raw is None: return 0 - if isinstance(raw, dict): - val = raw.get("value") - return int(val) if val is not None else 0 - return int(float(raw)) # truncates fractional SP intentionally + try: + if isinstance(raw, bool): + return 0 + if isinstance(raw, dict): + val = raw.get("value") + if val is None: + return 0 + val = float(val) + else: + val = float(raw) + result = max(0, round(val)) + return result + except (ValueError, TypeError): + return 0 def extract_epic_key(issue): @@ -176,6 +186,16 @@ def format_date(d): return d.isoformat() +def safe_format_date(d, fallback): + """Format date, returning fallback on None or parse failure.""" + if d is None: + return fallback + try: + return format_date(d) + except (ValueError, TypeError, AttributeError): + return fallback + + def days_between(start, end, inclusive=True): """Calendar days between two dates.""" s = parse_date(start) diff --git a/plugins/edge-scrum/bin/assemble-report.py b/plugins/edge-scrum/bin/assemble-report.py new file mode 100644 index 00000000..56f3a49a --- /dev/null +++ b/plugins/edge-scrum/bin/assemble-report.py @@ -0,0 +1,383 @@ +#!/usr/bin/env python3 +"""Assemble release planning report from checks.json and recommendations.json. + +Produces both .md and .docx output. All tables are rendered from structured +data — no markdown parsing needed for the DOCX path. +""" + +import argparse +import json +import os +import re +import sys + +sys.path.insert(0, os.path.dirname(__file__)) +from _jira_transforms import load_json + +JIRA_BASE = "https://redhat.atlassian.net/browse" +JIRA_KEY_RE = re.compile(r"(?') + cell._element.get_or_add_tcPr().append(shading) + + def add_styled_table(headers, rows, risk_col_indices=None): + if risk_col_indices is None: + risk_col_indices = set() + table = doc.add_table(rows=1, cols=len(headers)) + table.alignment = WD_TABLE_ALIGNMENT.LEFT + table.autofit = True + + for i, h in enumerate(headers): + cell = table.rows[0].cells[i] + cell.text = "" + run = cell.paragraphs[0].add_run(h) + run.bold = True + run.font.size = Pt(10) + run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF) + set_shading(cell, "2C5F8A") + + for ri, row_data in enumerate(rows): + row_cells = table.add_row().cells + for ci, val in enumerate(row_data): + if ci >= len(headers): + break + cell = row_cells[ci] + cell.text = "" + text = str(val).replace("**", "") + run = cell.paragraphs[0].add_run(text) + run.font.size = Pt(10) + + if ci in risk_col_indices: + clean = text.strip().upper() + if clean in RISK_COLORS: + bg, fg = RISK_COLORS[clean] + set_shading(cell, bg) + run.font.color.rgb = fg + run.bold = True + elif ri % 2 == 1: + set_shading(cell, ALT_ROW_BG) + + tbl_pr = table._tbl.tblPr if table._tbl.tblPr is not None else parse_xml(f'') + borders = parse_xml( + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + ) + tbl_pr.append(borders) + doc.add_paragraph() + + def add_bullets(items): + for item in items: + p = doc.add_paragraph(style="List Bullet") + p.add_run(str(item)) + + meta = checks["meta"] + + doc.add_heading(f"Release Planning Risk Assessment: OCP {params['version']}", level=1) + + info_headers = ["", ""] + pencils_down = params.get("pencils_down", params["last_sprint"]) + info_rows = [ + ["Analysis Date", params["today"]], + ["Release Window", f"Sprint {params['first_sprint']} -- Sprint {params['last_sprint']}"], + ["Pencils Down", f"Sprint {pencils_down}"], + ["Sprints Remaining", f"{params.get('remaining_sprints', '?')} of {params.get('total_dev_sprints', '?')} dev sprints (to pencils down)"], + ["Component Filter", meta.get("component_filter", "none")], + ["Features Assessed", f"{meta['assessed_features']} / {meta['total_features']}"], + ["Data Quality Failures", str(meta["data_quality_failures"])], + ["Overall Risk", meta["overall_risk"]], + ] + add_styled_table(info_headers, info_rows, risk_col_indices={1}) + + doc.add_heading("Executive Summary", level=2) + doc.add_paragraph(recs.get("executive_summary", "")) + + doc.add_heading("Data Quality", level=2) + dq_rows = [[d["feature_key"], d["epic_count"], d["story_count"], f"{d['pointed_pct']}%", d["status"]] + for d in checks["data_quality"]] + add_styled_table(["Feature", "Epics", "Stories", "Pointed %", "Status"], dq_rows, risk_col_indices={4}) + + doc.add_heading("Capacity by Person", level=2) + cap_rows = [[c["display_name"], c["assigned_sp"], c["remaining_capacity"], c["overrun"], c["status"], + ", ".join(c["features"])] for c in checks["capacity"]] + add_styled_table(["Person", "Assigned SP", "Remaining Capacity", "Overrun", "Status", "Features"], cap_rows, risk_col_indices={4}) + + doc.add_heading("Feature Timeline Risk", level=2) + tl_rows = [[t["feature_key"], t["remaining_sp"], t["velocity_per_sprint"], t["sprints_needed"], + t["sprints_left"], t["gap"], t["risk"]] + for t in checks["timeline"] if t["risk"] != "N/A"] + add_styled_table(["Feature", "Remaining SP", "Velocity/Sprint", "Sprints Needed", "Sprints Left", "Gap", "Risk"], tl_rows, risk_col_indices={6}) + + doc.add_heading("Assignment Risks", level=2) + doc.add_heading("Single Points of Failure", level=3) + spof_rows = [[s["feature_key"], s["sole_contributor_display"]] for s in checks["assignment"]["spof"]] + add_styled_table(["Feature", "Sole Contributor"], spof_rows) + doc.add_heading("Unassigned Work", level=3) + ua_rows = [[u["feature_key"], u["count"], u["sp"]] for u in checks["assignment"]["unassigned"]] + add_styled_table(["Feature", "Unassigned Stories", "Unassigned SP"], ua_rows) + + doc.add_heading("Bug Load", level=2) + bc_rows = [[b["key"], b["priority"], b["component"], b["status"]] + for b in checks["bug_load"]["unassigned_blocker_critical"]] + add_styled_table(["Bug", "Priority", "Component", "Status"], bc_rows) + + doc.add_heading("Sizing Mismatches", level=2) + sz_rows = [[s["feature_key"], s["tshirt"], s["actual_sp"], s["epic_count"], + s["contributor_count"], s["assessment"]] for s in checks["sizing"]] + add_styled_table(["Feature", "T-Shirt", "Actual SP", "Epics", "Contributors", "Assessment"], sz_rows, risk_col_indices={5}) + + doc.add_heading("Composite Risk Assessment", level=2) + cr_rows = [[c["feature_key"], c["data_quality"], c["timeline"], c["capacity"], + c["assignment"], c["bugs"], c["sizing"], c["composite_risk"]] + for c in checks["composite"]] + add_styled_table(["Feature", "Data Quality", "Timeline", "Capacity", "Assignment", "Bugs", "Sizing", "Composite Risk"], + cr_rows, risk_col_indices={1, 2, 3, 4, 5, 6, 7}) + + doc.add_heading("Recommendations", level=2) + if recs.get("per_person"): + doc.add_heading("Per-Person Actions", level=3) + add_bullets(recs["per_person"]) + if recs.get("per_feature"): + doc.add_heading("Per-Feature Actions", level=3) + add_bullets(recs["per_feature"]) + if recs.get("team_level"): + doc.add_heading("Team-Level Actions", level=3) + add_bullets(recs["team_level"]) + + doc.save(output_path) + print(f"Wrote {output_path}", file=sys.stderr) + + +# --- Main --- + + +def main(): + parser = argparse.ArgumentParser(description="Assemble release planning report") + parser.add_argument("--checks", required=True, help="Path to checks.json") + parser.add_argument("--recommendations", required=True, help="Path to recommendations.json") + parser.add_argument("--template", required=True, help="Path to report template .md") + parser.add_argument("--version", required=True) + parser.add_argument("--today", required=True) + parser.add_argument("--first-sprint", required=True) + parser.add_argument("--last-sprint", required=True) + parser.add_argument("--pencils-down", default=None, help="Pencils down sprint (defaults to last sprint)") + parser.add_argument("--remaining-sprints", required=True) + parser.add_argument("--total-dev-sprints", required=True) + parser.add_argument("--output", required=True, help="Output base path (without extension)") + args = parser.parse_args() + + checks = load_json(args.checks) + recs = load_json(args.recommendations) + with open(args.template, "r") as f: + template = f.read() + + params = { + "version": args.version, + "today": args.today, + "first_sprint": args.first_sprint, + "last_sprint": args.last_sprint, + "pencils_down": args.pencils_down or args.last_sprint, + "remaining_sprints": args.remaining_sprints, + "total_dev_sprints": args.total_dev_sprints, + } + + md_output = args.output + ".md" + docx_output = args.output + ".docx" + + md_content = render_markdown(checks, recs, template, params) + parent = os.path.dirname(md_output) + if parent: + os.makedirs(parent, exist_ok=True) + with open(md_output, "w") as f: + f.write(md_content) + print(f"Wrote {md_output}", file=sys.stderr) + + render_docx(checks, recs, params, docx_output) + + +if __name__ == "__main__": + main() diff --git a/plugins/edge-scrum/bin/md-to-docx.py b/plugins/edge-scrum/bin/md-to-docx.py new file mode 100644 index 00000000..0fb5aebd --- /dev/null +++ b/plugins/edge-scrum/bin/md-to-docx.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +"""Convert a markdown release-planning report to a styled DOCX file.""" + +import argparse +import os +import re +import sys + +from xml.sax.saxutils import escape as xml_escape + +from docx import Document +from docx.shared import Inches, Pt, Cm, RGBColor +from docx.enum.text import WD_ALIGN_PARAGRAPH +from docx.enum.table import WD_TABLE_ALIGNMENT +from docx.oxml.ns import qn, nsdecls +from docx.oxml import parse_xml + + +# --- Colors --- + +DARK_BLUE = RGBColor(0x1B, 0x3A, 0x5C) +HEADING_BLUE = RGBColor(0x2C, 0x5F, 0x8A) +LINK_BLUE = RGBColor(0x05, 0x63, 0xC1) +TABLE_HEADER_BG = "D6E4F0" +TABLE_ALT_ROW_BG = "F2F2F2" +HIGH_RED_BG = "FADBD8" +MEDIUM_YELLOW_BG = "FEF9E7" +LOW_GREEN_BG = "D5F5E3" +HIGH_RED_TEXT = RGBColor(0xC0, 0x39, 0x2B) +MEDIUM_YELLOW_TEXT = RGBColor(0xB7, 0x95, 0x0B) +LOW_GREEN_TEXT = RGBColor(0x1E, 0x8E, 0x3E) + + +# --- Markdown Parsing --- + + +def parse_md_lines(text): + """Split markdown into semantic blocks.""" + lines = text.split("\n") + blocks = [] + i = 0 + while i < len(lines): + line = lines[i] + + if line.startswith("# "): + blocks.append(("h1", line[2:].strip())) + i += 1 + elif line.startswith("### "): + blocks.append(("h3", line[4:].strip())) + i += 1 + elif line.startswith("## "): + blocks.append(("h2", line[3:].strip())) + i += 1 + elif line.startswith("| ") or line.startswith("|"): + table_lines = [] + while i < len(lines) and (lines[i].startswith("|") or lines[i].strip().startswith("|")): + table_lines.append(lines[i]) + i += 1 + blocks.append(("table", table_lines)) + elif line.startswith("- "): + items = [] + while i < len(lines) and lines[i].startswith("- "): + items.append(lines[i][2:].strip()) + i += 1 + blocks.append(("bullet_list", items)) + elif line.startswith("> "): + blocks.append(("blockquote", line[2:].strip())) + i += 1 + elif line.strip() == "---": + blocks.append(("separator", "")) + i += 1 + elif line.strip() == "": + i += 1 + else: + blocks.append(("paragraph", line.strip())) + i += 1 + + return blocks + + +def parse_table(table_lines): + """Parse markdown table lines into header + rows.""" + if len(table_lines) < 2: + return [], [] + + def split_row(line, col_count=None): + stripped = line.strip().strip("|") + cells = re.split(r'(? col_count: + cells = cells[:col_count - 1] + [" | ".join(cells[col_count - 1:])] + return cells + + header = split_row(table_lines[0]) + col_count = len(header) + + rows = [] + for line in table_lines[1:]: + stripped = line.strip() + if stripped and not re.match(r"^\|[-:\s|]+\|$", stripped): + rows.append(split_row(line, col_count)) + + return header, rows + + +INLINE_RE = re.compile( + r"(\*\*(.+?)\*\*)" + r"|(\*(.+?)\*)" + r"|(\[([^\]]+)\]\(([^)]+)\))" +) + + +def parse_inline(text): + """Parse inline markdown formatting into segments.""" + segments = [] + last_end = 0 + + for m in INLINE_RE.finditer(text): + if m.start() > last_end: + segments.append(("text", text[last_end:m.start()])) + + if m.group(2): + segments.append(("bold", m.group(2))) + elif m.group(4): + segments.append(("italic", m.group(4))) + elif m.group(6): + segments.append(("link", m.group(6), m.group(7))) + + last_end = m.end() + + if last_end < len(text): + segments.append(("text", text[last_end:])) + + return segments if segments else [("text", text)] + + +def get_risk_level(text): + """Detect risk level from cell text.""" + upper = text.upper().strip() + if upper in ("HIGH", "🔴", "FAIL"): + return "high" + if upper in ("MEDIUM", "🟡", "WARN"): + return "medium" + if upper in ("LOW", "🟢", "PASS"): + return "low" + return None + + +# --- DOCX Building --- + + +def add_hyperlink(paragraph, url, text): + """Add a clickable hyperlink to a paragraph.""" + part = paragraph.part + r_id = part.relate_to(url, "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink", is_external=True) + + hyperlink = parse_xml(f'') + run_elem = parse_xml( + f'' + f'' + f'' + f'' + f'' + f'{xml_escape(text)}' + f'' + ) + hyperlink.append(run_elem) + paragraph._element.append(hyperlink) + + +def add_formatted_text(paragraph, text, font_size=None): + """Add text with inline formatting (bold, italic, links) to a paragraph.""" + segments = parse_inline(text) + for seg in segments: + if seg[0] == "bold": + run = paragraph.add_run(seg[1]) + run.bold = True + if font_size: + run.font.size = font_size + elif seg[0] == "italic": + run = paragraph.add_run(seg[1]) + run.italic = True + if font_size: + run.font.size = font_size + elif seg[0] == "link": + add_hyperlink(paragraph, seg[2], seg[1]) + else: + run = paragraph.add_run(seg[1]) + if font_size: + run.font.size = font_size + + +def set_cell_shading(cell, color_hex): + """Set background color on a table cell.""" + shading = parse_xml(f'') + cell._element.get_or_add_tcPr().append(shading) + + +def style_document(doc): + """Configure base document styles.""" + style = doc.styles["Normal"] + font = style.font + font.name = "Calibri" + font.size = Pt(11) + font.color.rgb = RGBColor(0x33, 0x33, 0x33) + + for level, size, color in [("Heading 1", 20, DARK_BLUE), ("Heading 2", 16, HEADING_BLUE), ("Heading 3", 13, HEADING_BLUE)]: + s = doc.styles[level] + s.font.name = "Calibri" + s.font.size = Pt(size) + s.font.color.rgb = color + s.font.bold = True + + sections = doc.sections + for section in sections: + section.top_margin = Cm(2.54) + section.bottom_margin = Cm(2.54) + section.left_margin = Cm(2.54) + section.right_margin = Cm(2.54) + + +def add_table(doc, header, rows): + """Add a styled table to the document.""" + if not header: + return + + col_count = len(header) + table = doc.add_table(rows=1, cols=col_count) + table.alignment = WD_TABLE_ALIGNMENT.LEFT + table.autofit = True + + hdr_cells = table.rows[0].cells + for i, text in enumerate(header): + if i < col_count: + hdr_cells[i].text = "" + p = hdr_cells[i].paragraphs[0] + run = p.add_run(text) + run.bold = True + run.font.size = Pt(10) + run.font.color.rgb = RGBColor(0xFF, 0xFF, 0xFF) + set_cell_shading(hdr_cells[i], "2C5F8A") + + for row_idx, row_data in enumerate(rows): + row_cells = table.add_row().cells + for i, text in enumerate(row_data): + if i < col_count: + row_cells[i].text = "" + p = row_cells[i].paragraphs[0] + add_formatted_text(p, text, font_size=Pt(10)) + + risk = get_risk_level(text) + if risk == "high": + set_cell_shading(row_cells[i], HIGH_RED_BG) + for run in p.runs: + run.font.color.rgb = HIGH_RED_TEXT + run.bold = True + elif risk == "medium": + set_cell_shading(row_cells[i], MEDIUM_YELLOW_BG) + for run in p.runs: + run.font.color.rgb = MEDIUM_YELLOW_TEXT + run.bold = True + elif risk == "low": + set_cell_shading(row_cells[i], LOW_GREEN_BG) + for run in p.runs: + run.font.color.rgb = LOW_GREEN_TEXT + + if not risk and row_idx % 2 == 1: + set_cell_shading(row_cells[i], TABLE_ALT_ROW_BG) + + tbl = table._tbl + tbl_pr = tbl.tblPr if tbl.tblPr is not None else parse_xml(f'') + borders = parse_xml( + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + ) + tbl_pr.append(borders) + + +def convert(md_path, docx_path): + """Convert a markdown report to DOCX.""" + with open(md_path, "r") as f: + text = f.read() + + blocks = parse_md_lines(text) + doc = Document() + style_document(doc) + + for block in blocks: + kind = block[0] + content = block[1] + + if kind == "h1": + doc.add_heading(content, level=1) + elif kind == "h2": + doc.add_heading(content, level=2) + elif kind == "h3": + doc.add_heading(content, level=3) + elif kind == "table": + header, rows = parse_table(content) + add_table(doc, header, rows) + doc.add_paragraph() + elif kind == "bullet_list": + for item in content: + p = doc.add_paragraph(style="List Bullet") + p.text = "" + add_formatted_text(p, item) + elif kind == "blockquote": + p = doc.add_paragraph() + p.paragraph_format.left_indent = Cm(1.27) + run = p.add_run(content) + run.italic = True + run.font.color.rgb = RGBColor(0x66, 0x66, 0x66) + elif kind == "separator": + p = doc.add_paragraph() + p.paragraph_format.space_before = Pt(6) + p.paragraph_format.space_after = Pt(6) + pPr = p._element.get_or_add_pPr() + pBdr = parse_xml( + f'' + f'' + f'' + ) + pPr.append(pBdr) + elif kind == "paragraph": + p = doc.add_paragraph() + add_formatted_text(p, content) + + doc.save(docx_path) + print(f"Wrote {docx_path}", file=sys.stderr) + + +def main(): + parser = argparse.ArgumentParser(description="Convert markdown report to styled DOCX") + parser.add_argument("input", help="Input markdown file path") + parser.add_argument("--output", "-o", help="Output DOCX file path (default: same name with .docx extension)") + args = parser.parse_args() + + if not os.path.exists(args.input): + print(f"Error: {args.input} not found", file=sys.stderr) + sys.exit(1) + + output = args.output or os.path.splitext(args.input)[0] + ".docx" + convert(args.input, output) + + +if __name__ == "__main__": + main() diff --git a/plugins/edge-scrum/bin/run-checks.py b/plugins/edge-scrum/bin/run-checks.py new file mode 100644 index 00000000..4ada9ab6 --- /dev/null +++ b/plugins/edge-scrum/bin/run-checks.py @@ -0,0 +1,588 @@ +#!/usr/bin/env python3 +"""Run data-quality gate and 6 planning risk checks. Deterministic — no LLM needed.""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(__file__)) +from _jira_transforms import load_json, write_output + +DONE_STATUSES_STORIES = {"Closed"} +DONE_STATUSES_OCPBUGS = {"Closed", "Verified"} +DONE_STATUSES_EPICS = {"Closed", "Dev Complete"} + +SIZE_TO_MAX_SPRINTS = {"XS": 2, "S": 3, "M": 4, "L": 4, "XL": 5} +DEFAULT_SP_TARGET = 8 + +COMPONENT_SHORT_NAMES = { + "tna": "Two Node with Arbiter", + "tnf": "Two Node Fencing", + "lvms": "Logical Volume Manager Storage", + "topolvm": "Logical Volume Manager Storage", + "microshift": "MicroShift", + "sno": "Installer / Single Node OpenShift", +} + +def is_microshift_component(comp): + """Match MicroShift and any subcomponent (MicroShift / Networking, etc.).""" + return comp == "MicroShift" or comp.startswith("MicroShift / ") + + +# --- Hierarchy --- + + +def build_hierarchy(features, epics, stories, bugs, component_filter): + """Build Feature -> Epics -> Stories tree. Returns list of enriched feature dicts.""" + epic_by_key = {e["key"]: e for e in epics.get("epics", [])} + feature_to_epics = epics.get("feature_to_epics", {}) + + stories_by_epic = {} + for s in stories.get("stories", []): + stories_by_epic.setdefault(s["epic_key"], []).append(s) + + cf_lower = component_filter.lower() if component_filter and component_filter != "none" else None + cf_full = COMPONENT_SHORT_NAMES.get(cf_lower, cf_lower) if cf_lower else None + is_microshift = cf_lower == "microshift" if cf_lower else False + def matches_component(comp): + if is_microshift: + return is_microshift_component(comp) + return comp == cf_full if cf_full else False + + result = [] + for f in features.get("features", []): + fkey = f["key"] + f_epics = [] + for ekey in feature_to_epics.get(fkey, []): + epic = epic_by_key.get(ekey) + if epic: + epic_stories = stories_by_epic.get(ekey, []) + f_epics.append({**epic, "stories": epic_stories}) + + if cf_lower: + has_match = False + for e in f_epics: + labels_lower = [lbl.lower() for lbl in e.get("labels", [])] + if cf_lower in labels_lower: + has_match = True + break + for s in e.get("stories", []): + s_comp = s.get("component", "") + if s_comp and matches_component(s_comp): + has_match = True + break + if is_microshift and s["key"].startswith("USHIFT-"): + has_match = True + break + if has_match: + break + if not has_match: + continue + + all_stories = [] + for e in f_epics: + all_stories.extend(e.get("stories", [])) + + result.append({ + "key": fkey, + "summary": f.get("summary", ""), + "status": f.get("status", ""), + "size": f.get("size", "Unsized"), + "sme": f.get("sme", "None"), + "type": f.get("type", "Feature"), + "epics": f_epics, + "all_stories": all_stories, + }) + + return result, bugs.get("bugs", []) + + +def is_story_done(story): + if story["key"].startswith("OCPBUGS-"): + return story["status"] in DONE_STATUSES_OCPBUGS + return story["status"] in DONE_STATUSES_STORIES + + +# --- Data Quality Gate --- + + +def run_data_quality_gate(features): + """Categorize each feature as PASS/WARN/FAIL.""" + results = [] + for f in features: + epic_count = len(f["epics"]) + all_stories = f["all_stories"] + non_bug_non_done = [s for s in all_stories if s["type"] != "Bug" and not is_story_done(s)] + non_bug_all = [s for s in all_stories if s["type"] != "Bug"] + story_count = len(non_bug_all) + + if epic_count == 0: + status = "FAIL" + reason = "no epics created" + elif story_count == 0: + status = "FAIL" + reason = "epics have no stories" + else: + pointed = [s for s in non_bug_non_done if s["sp"] > 0] + total_non_done = len(non_bug_non_done) + pointed_pct = (len(pointed) / total_non_done * 100) if total_non_done > 0 else 100 + if pointed_pct >= 50: + status = "PASS" + reason = "" + else: + status = "WARN" + reason = f"{100 - pointed_pct:.0f}% of stories lack story points" + + pointed_pct_val = 0 + if non_bug_non_done: + pointed_pct_val = round(len([s for s in non_bug_non_done if s["sp"] > 0]) / len(non_bug_non_done) * 100) + elif story_count > 0: + pointed_pct_val = 100 + + results.append({ + "feature_key": f["key"], + "summary": f["summary"], + "epic_count": epic_count, + "story_count": story_count, + "pointed_pct": pointed_pct_val, + "status": status, + "reason": reason, + }) + + return results + + +# --- Check 1: Capacity --- + + +def run_capacity_check(features, gate_results, roster, remaining_sprints): + """Per-person capacity check. Only considers stories under PASS/WARN features.""" + passed_keys = {g["feature_key"] for g in gate_results if g["status"] in ("PASS", "WARN")} + roster_map = {m["username"]: m for m in roster.get("members", [])} + + sp_by_person = {} + person_features = {} + person_display = {} + + for f in features: + if f["key"] not in passed_keys: + continue + for s in f["all_stories"]: + if is_story_done(s) or s["type"] == "Bug" or s["sp"] <= 0 or not s["assignee"]: + continue + sp_by_person[s["assignee"]] = sp_by_person.get(s["assignee"], 0) + s["sp"] + person_features.setdefault(s["assignee"], set()).add(f["key"]) + person_display[s["assignee"]] = s.get("assignee_display", s["assignee"]) + + results = [] + for person, assigned_sp in sorted(sp_by_person.items(), key=lambda x: -x[1]): + member = roster_map.get(person, {}) + sp_target = member.get("sp_target", DEFAULT_SP_TARGET) + capacity = sp_target * remaining_sprints + overrun = max(0, assigned_sp - capacity) + results.append({ + "person": person, + "display_name": person_display.get(person, person), + "assigned_sp": assigned_sp, + "sp_target": sp_target, + "remaining_capacity": capacity, + "overrun": overrun, + "status": "OVER" if overrun > 0 else "OK", + "features": sorted(person_features.get(person, set())), + "in_roster": person in roster_map, + }) + + return results + + +# --- Check 2: Timeline --- + + +def run_timeline_check(features, gate_results, roster, remaining_sprints): + """Per-feature timeline projection with proportional velocity.""" + passed_keys = {g["feature_key"] for g in gate_results if g["status"] in ("PASS", "WARN")} + roster_map = {m["username"]: m for m in roster.get("members", [])} + + global_sp = {} + for f in features: + for s in f["all_stories"]: + if is_story_done(s) or s["type"] == "Bug" or s["sp"] <= 0 or not s["assignee"]: + continue + global_sp[s["assignee"]] = global_sp.get(s["assignee"], 0) + s["sp"] + + results = [] + for f in features: + if f["key"] not in passed_keys: + results.append({ + "feature_key": f["key"], + "summary": f["summary"], + "remaining_sp": 0, + "velocity_per_sprint": 0, + "sprints_needed": 0, + "sprints_left": remaining_sprints, + "gap": 0, + "risk": "N/A", + }) + continue + + remaining_sp = 0 + feature_sp_by_person = {} + for s in f["all_stories"]: + if not is_story_done(s): + remaining_sp += s["sp"] + if is_story_done(s) or s["type"] == "Bug" or s["sp"] <= 0 or not s["assignee"]: + continue + feature_sp_by_person[s["assignee"]] = feature_sp_by_person.get(s["assignee"], 0) + s["sp"] + + velocity = 0.0 + for person, person_feature_sp in feature_sp_by_person.items(): + sp_target = roster_map.get(person, {}).get("sp_target", DEFAULT_SP_TARGET) + total_sp = global_sp.get(person, person_feature_sp) + fraction = person_feature_sp / total_sp if total_sp > 0 else 0 + velocity += sp_target * fraction + + if velocity <= 0: + sprints_needed = float("inf") if remaining_sp > 0 else 0 + else: + sprints_needed = remaining_sp / velocity + + gap = max(0, sprints_needed - remaining_sprints) if sprints_needed != float("inf") else remaining_sprints + risk = "NO_CONTRIBUTORS" if velocity <= 0 and remaining_sp > 0 else ( + "HIGH" if gap > 0 else "OK" + ) + + results.append({ + "feature_key": f["key"], + "summary": f["summary"], + "remaining_sp": remaining_sp, + "velocity_per_sprint": round(velocity, 1), + "sprints_needed": round(sprints_needed, 1) if sprints_needed != float("inf") else "inf", + "sprints_left": remaining_sprints, + "gap": round(gap, 1), + "risk": risk, + }) + + return results + + +# --- Check 3: Assignment --- + + +def run_assignment_check(features): + """SPOF detection and unassigned work. Non-bug stories only.""" + spof = [] + unassigned = [] + + for f in features: + non_done_non_bug = [s for s in f["all_stories"] if not is_story_done(s) and s["type"] != "Bug"] + contributors = set(s["assignee"] for s in non_done_non_bug if s["assignee"]) + unassigned_stories = [s for s in non_done_non_bug if not s["assignee"]] + + if len(contributors) == 1: + sole = list(contributors)[0] + display = next( + (s.get("assignee_display", sole) for s in non_done_non_bug if s["assignee"] == sole), + sole + ) + spof.append({ + "feature_key": f["key"], + "summary": f["summary"], + "sole_contributor": sole, + "sole_contributor_display": display, + }) + + if unassigned_stories: + unassigned_sp = sum(s["sp"] for s in unassigned_stories) + unassigned.append({ + "feature_key": f["key"], + "summary": f["summary"], + "count": len(unassigned_stories), + "sp": unassigned_sp, + }) + + return {"spof": spof, "unassigned": unassigned} + + +# --- Check 4: Bug Load --- + + +def run_bug_load_check(all_bugs, epic_stories, component_filter): + """Unassigned Blocker/Critical bugs.""" + bugs = list(all_bugs) + for s in epic_stories: + if s["type"] == "Bug": + bugs.append(s) + + cf_lower = component_filter.lower() if component_filter and component_filter != "none" else None + is_ms = cf_lower == "microshift" if cf_lower else False + cf_full = COMPONENT_SHORT_NAMES.get(cf_lower, cf_lower) if cf_lower else None + + if cf_lower: + def bug_matches(comp): + if is_ms: + return is_microshift_component(comp) + return comp == cf_full if cf_full else False + bugs = [b for b in bugs if bug_matches(b.get("component", ""))] + + unassigned_bc = [] + by_component = {} + + for b in bugs: + comp = b.get("component", "Unknown") + priority = b.get("priority", "Major") + by_component.setdefault(comp, {"total": 0, "blocker": 0, "critical": 0, "unassigned": 0}) + by_component[comp]["total"] += 1 + if priority == "Blocker": + by_component[comp]["blocker"] += 1 + elif priority == "Critical": + by_component[comp]["critical"] += 1 + + if priority in ("Blocker", "Critical") and not b.get("assignee"): + by_component[comp]["unassigned"] += 1 + unassigned_bc.append({ + "key": b["key"], + "summary": b.get("summary", ""), + "priority": priority, + "component": comp, + "status": b.get("status", ""), + }) + + return { + "unassigned_blocker_critical": unassigned_bc, + "by_component": by_component, + } + + +# --- Check 5: Sizing --- + + +def run_sizing_check(features, timeline_results): + """T-shirt size vs actual scope.""" + timeline_map = {t["feature_key"]: t for t in timeline_results} + + results = [] + for f in features: + size = f["size"] + total_sp = sum(s["sp"] for s in f["all_stories"] if not is_story_done(s)) + epic_count = len(f["epics"]) + contributors = set( + s["assignee"] for s in f["all_stories"] + if not is_story_done(s) and s["type"] != "Bug" and s["assignee"] + ) + + timeline = timeline_map.get(f["key"], {}) + sprints_needed = timeline.get("sprints_needed", 0) + if sprints_needed == "inf": + sprints_needed = 99 + + assessment = "OK" + if size == "Unsized": + assessment = "Unsized" + elif size in SIZE_TO_MAX_SPRINTS: + max_sprints = SIZE_TO_MAX_SPRINTS[size] + if isinstance(sprints_needed, (int, float)) and sprints_needed > max_sprints: + assessment = "Undersized" + elif size in ("XS", "S") and epic_count >= 4 and total_sp >= 50: + assessment = "Undersized" + else: + assessment = "Unknown size" + + if assessment != "OK": + results.append({ + "feature_key": f["key"], + "summary": f["summary"], + "tshirt": size, + "actual_sp": total_sp, + "epic_count": epic_count, + "contributor_count": len(contributors), + "sprints_needed": sprints_needed if sprints_needed != 99 else "N/A", + "assessment": assessment, + }) + + return results + + +# --- Check 6: Composite --- + + +def run_composite_check(features, gate_results, capacity_results, timeline_results, + assignment_results, sizing_results): + """Count signals per feature and assign composite risk.""" + gate_map = {g["feature_key"]: g["status"] for g in gate_results} + overloaded_people = {c["person"] for c in capacity_results if c["status"] == "OVER"} + timeline_risks = {t["feature_key"] for t in timeline_results if t["risk"] in ("HIGH", "NO_CONTRIBUTORS")} + spof_keys = {s["feature_key"] for s in assignment_results["spof"]} + unassigned_keys = {u["feature_key"] for u in assignment_results["unassigned"]} + assignment_risk_keys = spof_keys | unassigned_keys + sizing_keys = {s["feature_key"] for s in sizing_results} + + bug_keys_with_issues = set() + for f in features: + feature_bugs = [s for s in f["all_stories"] if s["type"] == "Bug" + and s.get("priority") in ("Blocker", "Critical") and not s.get("assignee")] + if feature_bugs: + bug_keys_with_issues.add(f["key"]) + + results = [] + for f in features: + fkey = f["key"] + gate_status = gate_map.get(fkey, "PASS") + signals = 0 + signal_details = {} + + if gate_status == "FAIL": + signals += 1 + signal_details["data_quality"] = "FAIL" + else: + signal_details["data_quality"] = gate_status + + if fkey in timeline_risks: + signals += 1 + signal_details["timeline"] = "HIGH" + else: + signal_details["timeline"] = "OK" + + feature_contributors = set( + s["assignee"] for s in f["all_stories"] + if not is_story_done(s) and s["type"] != "Bug" and s["assignee"] + ) + if feature_contributors & overloaded_people: + signals += 1 + signal_details["capacity"] = "HIGH" + else: + signal_details["capacity"] = "OK" + + if fkey in assignment_risk_keys: + signals += 1 + has_spof = fkey in spof_keys + has_unassigned = fkey in unassigned_keys + if has_spof and has_unassigned: + signal_details["assignment"] = "SPOF+Unassigned" + elif has_spof: + signal_details["assignment"] = "SPOF" + else: + signal_details["assignment"] = "Unassigned" + else: + signal_details["assignment"] = "OK" + + if fkey in bug_keys_with_issues: + signals += 1 + signal_details["bugs"] = "HIGH" + else: + signal_details["bugs"] = "OK" + + if fkey in sizing_keys: + signals += 1 + signal_details["sizing"] = "Mismatch" + else: + signal_details["sizing"] = "OK" + + if signals >= 3: + composite_risk = "HIGH" + elif signals == 2: + composite_risk = "MEDIUM" + else: + composite_risk = "LOW" + + results.append({ + "feature_key": fkey, + "summary": f["summary"], + "signal_count": signals, + "composite_risk": composite_risk, + **signal_details, + }) + + results.sort(key=lambda x: ({"HIGH": 0, "MEDIUM": 1, "LOW": 2}.get(x["composite_risk"], 3), -x["signal_count"])) + return results + + +# --- Main --- + + +def main(): + parser = argparse.ArgumentParser(description="Run planning risk checks") + parser.add_argument("--features", required=True) + parser.add_argument("--epics", required=True) + parser.add_argument("--stories", required=True) + parser.add_argument("--bugs", required=True) + parser.add_argument("--roster", required=True) + parser.add_argument("--remaining-sprints", type=int, required=True) + parser.add_argument("--component-filter", default="none") + parser.add_argument("--output", required=True) + args = parser.parse_args() + + features_data = load_json(args.features) + epics_data = load_json(args.epics) + stories_data = load_json(args.stories) + bugs_data = load_json(args.bugs) + roster = load_json(args.roster) + + if args.remaining_sprints == 0: + print("WARNING: 0 sprints remaining — running post-branch-cut.", file=sys.stderr) + + features, unlinked_bugs = build_hierarchy( + features_data, epics_data, stories_data, bugs_data, args.component_filter + ) + + all_epic_stories = [] + for f in features: + all_epic_stories.extend(f["all_stories"]) + + gate = run_data_quality_gate(features) + capacity = run_capacity_check(features, gate, roster, args.remaining_sprints) + timeline = run_timeline_check(features, gate, roster, args.remaining_sprints) + assignment = run_assignment_check(features) + bug_load = run_bug_load_check(unlinked_bugs, all_epic_stories, args.component_filter) + sizing = run_sizing_check(features, timeline) + composite = run_composite_check(features, gate, capacity, timeline, assignment, sizing) + + unknown_contributors = [c["person"] for c in capacity if not c["in_roster"]] + + stories_skipped = stories_data.get("skipped_issues", []) + bugs_skipped = bugs_data.get("skipped_issues", []) + + high_count = sum(1 for c in composite if c["composite_risk"] == "HIGH") + med_count = sum(1 for c in composite if c["composite_risk"] == "MEDIUM") + low_count = sum(1 for c in composite if c["composite_risk"] == "LOW") + assessed = sum(1 for g in gate if g["status"] in ("PASS", "WARN")) + unassessable = sum(1 for g in gate if g["status"] == "FAIL") + total_remaining = sum(t["remaining_sp"] for t in timeline if t["risk"] != "N/A") + total_capacity = sum(c["remaining_capacity"] for c in capacity if c["in_roster"]) + + overall = "HIGH" if high_count > 0 else ("MEDIUM" if med_count > 0 else "LOW") + + output = { + "meta": { + "total_features": len(features), + "assessed_features": assessed, + "unassessable_features": unassessable, + "high_risk_count": high_count, + "medium_risk_count": med_count, + "low_risk_count": low_count, + "overall_risk": overall, + "total_remaining_sp": total_remaining, + "total_capacity_sp": total_capacity, + "overloaded_people_count": sum(1 for c in capacity if c["status"] == "OVER"), + "spof_features_count": len(assignment["spof"]), + "unassigned_blocker_bugs": len(bug_load["unassigned_blocker_critical"]), + "data_quality_failures": unassessable, + "component_filter": args.component_filter, + "skipped_stories": len(stories_skipped), + "skipped_bugs": len(bugs_skipped), + }, + "data_quality": gate, + "capacity": capacity, + "timeline": timeline, + "assignment": assignment, + "bug_load": bug_load, + "sizing": sizing, + "composite": composite, + "unknown_contributors": unknown_contributors, + "skipped_issues": stories_skipped + bugs_skipped, + } + + write_output(output, args.output) + + +if __name__ == "__main__": + main() diff --git a/plugins/edge-scrum/bin/tests/test_md_to_docx.py b/plugins/edge-scrum/bin/tests/test_md_to_docx.py new file mode 100644 index 00000000..0b734a92 --- /dev/null +++ b/plugins/edge-scrum/bin/tests/test_md_to_docx.py @@ -0,0 +1,152 @@ +"""Tests for md-to-docx parsing logic.""" + +import sys +import os +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from importlib import import_module + +_mod = import_module("md-to-docx") +parse_md_lines = _mod.parse_md_lines +parse_table = _mod.parse_table +parse_inline = _mod.parse_inline +get_risk_level = _mod.get_risk_level + + +class TestParseMdLines(unittest.TestCase): + def test_heading_levels(self): + blocks = parse_md_lines("# Title\n## Section\n### Subsection") + assert blocks[0] == ("h1", "Title") + assert blocks[1] == ("h2", "Section") + assert blocks[2] == ("h3", "Subsection") + + def test_paragraph(self): + blocks = parse_md_lines("Some regular text here.") + assert blocks[0] == ("paragraph", "Some regular text here.") + + def test_separator(self): + blocks = parse_md_lines("---") + assert blocks[0] == ("separator", "") + + def test_bullet_list(self): + blocks = parse_md_lines("- Item one\n- Item two\n- Item three") + assert blocks[0] == ("bullet_list", ["Item one", "Item two", "Item three"]) + + def test_blockquote(self): + blocks = parse_md_lines("> Quoted text here") + assert blocks[0] == ("blockquote", "Quoted text here") + + def test_table_detected(self): + md = "| A | B |\n|---|---|\n| 1 | 2 |" + blocks = parse_md_lines(md) + assert blocks[0][0] == "table" + assert len(blocks[0][1]) == 3 + + def test_empty_lines_skipped(self): + blocks = parse_md_lines("\n\n\n") + assert blocks == [] + + def test_mixed_content(self): + md = "# Title\n\nSome text.\n\n- Bullet\n\n---" + blocks = parse_md_lines(md) + types = [b[0] for b in blocks] + assert types == ["h1", "paragraph", "bullet_list", "separator"] + + +class TestParseTable(unittest.TestCase): + def test_basic_table(self): + lines = ["| Name | Value |", "|---|---|", "| A | 1 |", "| B | 2 |"] + header, rows = parse_table(lines) + assert header == ["Name", "Value"] + assert rows == [["A", "1"], ["B", "2"]] + + def test_empty_table(self): + header, rows = parse_table([]) + assert header == [] + assert rows == [] + + def test_header_only(self): + lines = ["| Name | Value |", "|---|---|"] + header, rows = parse_table(lines) + assert header == ["Name", "Value"] + assert rows == [] + + def test_separator_row_filtered(self): + lines = ["| A | B |", "|---|---|", "| 1 | 2 |"] + header, rows = parse_table(lines) + assert len(rows) == 1 + + def test_whitespace_stripped(self): + lines = ["| Name | Value |", "|---|---|", "| A | 1 |"] + header, rows = parse_table(lines) + assert header == ["Name", "Value"] + assert rows == [["A", "1"]] + + +class TestParseInline(unittest.TestCase): + def test_plain_text(self): + segs = parse_inline("hello world") + assert segs == [("text", "hello world")] + + def test_bold(self): + segs = parse_inline("this is **bold** text") + assert ("bold", "bold") in segs + + def test_italic(self): + segs = parse_inline("this is *italic* text") + assert ("italic", "italic") in segs + + def test_link(self): + segs = parse_inline("see [OCPEDGE-123](https://example.com)") + found = [s for s in segs if s[0] == "link"] + assert len(found) == 1 + assert found[0][1] == "OCPEDGE-123" + assert found[0][2] == "https://example.com" + + def test_mixed_formatting(self): + segs = parse_inline("**bold** and *italic* and [link](url)") + types = [s[0] for s in segs] + assert "bold" in types + assert "italic" in types + assert "link" in types + + def test_no_formatting(self): + segs = parse_inline("plain") + assert segs == [("text", "plain")] + + +class TestGetRiskLevel(unittest.TestCase): + def test_high(self): + assert get_risk_level("HIGH") == "high" + assert get_risk_level("high") == "high" + + def test_medium(self): + assert get_risk_level("MEDIUM") == "medium" + + def test_low(self): + assert get_risk_level("LOW") == "low" + + def test_pass(self): + assert get_risk_level("PASS") == "low" + + def test_fail(self): + assert get_risk_level("FAIL") == "high" + + def test_warn(self): + assert get_risk_level("WARN") == "medium" + + def test_unknown(self): + assert get_risk_level("something else") is None + + def test_with_whitespace(self): + assert get_risk_level(" HIGH ") == "high" + + def test_emoji_risk(self): + assert get_risk_level("🔴") == "high" + assert get_risk_level("🟡") == "medium" + assert get_risk_level("🟢") == "low" + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/edge-scrum/bin/tests/test_run_checks.py b/plugins/edge-scrum/bin/tests/test_run_checks.py new file mode 100644 index 00000000..8dcd7e49 --- /dev/null +++ b/plugins/edge-scrum/bin/tests/test_run_checks.py @@ -0,0 +1,460 @@ +"""Tests for run-checks.py — deterministic planning risk checks.""" + +import sys +import os +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from importlib import import_module + +_mod = import_module("run-checks") +build_hierarchy = _mod.build_hierarchy +run_data_quality_gate = _mod.run_data_quality_gate +run_capacity_check = _mod.run_capacity_check +run_timeline_check = _mod.run_timeline_check +run_assignment_check = _mod.run_assignment_check +run_bug_load_check = _mod.run_bug_load_check +run_sizing_check = _mod.run_sizing_check +run_composite_check = _mod.run_composite_check +is_story_done = _mod.is_story_done + + +def _story(key="OCPEDGE-1", assignee="alice@redhat.com", sp=5, epic_key="EPIC-1", + status="To Do", issue_type="Story", display="Alice", priority="Major"): + return { + "key": key, "summary": f"Story {key}", "type": issue_type, "status": status, + "assignee": assignee, "assignee_display": display, "sp": sp, "epic_key": epic_key, + "flagged": False, "blocked_by": [], "stale": False, "labels": [], "priority": priority, + } + + +def _feature(key="OCPSTRAT-1", summary="Feature 1", size="M", epics=None, all_stories=None): + return { + "key": key, "summary": summary, "status": "In Progress", "size": size, + "sme": "None", "type": "Feature", + "epics": epics or [], "all_stories": all_stories or [], + } + + +def _roster(*members): + return {"members": [{"username": m[0], "display_name": m[1], "sp_target": m[2]} for m in members]} + + +class TestIsDone(unittest.TestCase): + def test_ocpedge_closed_is_done(self): + assert is_story_done({"key": "OCPEDGE-1", "status": "Closed"}) is True + + def test_ocpedge_done_is_not_done(self): + assert is_story_done({"key": "OCPEDGE-1", "status": "Done"}) is False + + def test_ocpbugs_verified_is_done(self): + assert is_story_done({"key": "OCPBUGS-1", "status": "Verified"}) is True + + def test_ocpbugs_done_is_not_done(self): + assert is_story_done({"key": "OCPBUGS-1", "status": "Done"}) is False + + +class TestDataQualityGate(unittest.TestCase): + def test_pass_with_pointed_stories(self): + f = _feature(epics=[{"key": "E-1", "stories": [_story(sp=5)]}], + all_stories=[_story(sp=5)]) + result = run_data_quality_gate([f]) + assert result[0]["status"] == "PASS" + + def test_fail_no_epics(self): + f = _feature(epics=[], all_stories=[]) + result = run_data_quality_gate([f]) + assert result[0]["status"] == "FAIL" + + def test_fail_no_stories(self): + f = _feature(epics=[{"key": "E-1", "stories": []}], all_stories=[]) + result = run_data_quality_gate([f]) + assert result[0]["status"] == "FAIL" + + def test_warn_low_pointed(self): + stories = [_story(f"S-{i}", sp=0) for i in range(8)] + [_story("S-9", sp=5)] + f = _feature(epics=[{"key": "E-1", "stories": stories}], all_stories=stories) + result = run_data_quality_gate([f]) + assert result[0]["status"] == "WARN" + + def test_bugs_dont_count_for_gate(self): + stories = [_story("B-1", issue_type="Bug", sp=0)] + f = _feature(epics=[{"key": "E-1", "stories": stories}], all_stories=stories) + result = run_data_quality_gate([f]) + assert result[0]["status"] == "FAIL" + + def test_closed_unpointed_excluded_from_pointed_pct(self): + stories = [ + _story("S-1", sp=5, status="To Do"), + _story("S-2", sp=0, status="Closed"), + ] + f = _feature(epics=[{"key": "E-1", "stories": stories}], all_stories=stories) + result = run_data_quality_gate([f]) + assert result[0]["status"] == "PASS" + assert result[0]["pointed_pct"] == 100 + + +class TestCapacityCheck(unittest.TestCase): + def test_over_capacity(self): + stories = [_story("S-1", "alice@x.com", sp=20)] + f = _feature(all_stories=stories) + gate = [{"feature_key": f["key"], "status": "PASS"}] + roster = _roster(("alice@x.com", "Alice", 8)) + result = run_capacity_check([f], gate, roster, 2) + assert result[0]["status"] == "OVER" + assert result[0]["overrun"] == 4 + + def test_under_capacity(self): + stories = [_story("S-1", "alice@x.com", sp=5)] + f = _feature(all_stories=stories) + gate = [{"feature_key": f["key"], "status": "PASS"}] + roster = _roster(("alice@x.com", "Alice", 8)) + result = run_capacity_check([f], gate, roster, 2) + assert result[0]["status"] == "OK" + + def test_excludes_fail_features(self): + stories = [_story("S-1", "alice@x.com", sp=100)] + f = _feature(all_stories=stories) + gate = [{"feature_key": f["key"], "status": "FAIL"}] + roster = _roster(("alice@x.com", "Alice", 8)) + result = run_capacity_check([f], gate, roster, 2) + assert len(result) == 0 + + def test_excludes_bugs_from_sp(self): + stories = [ + _story("S-1", "alice@x.com", sp=5), + _story("B-1", "alice@x.com", sp=0, issue_type="Bug"), + ] + f = _feature(all_stories=stories) + gate = [{"feature_key": f["key"], "status": "PASS"}] + roster = _roster(("alice@x.com", "Alice", 8)) + result = run_capacity_check([f], gate, roster, 2) + assert result[0]["assigned_sp"] == 5 + + def test_non_roster_flagged(self): + stories = [_story("S-1", "bot@x.com", sp=5)] + f = _feature(all_stories=stories) + gate = [{"feature_key": f["key"], "status": "PASS"}] + roster = _roster() + result = run_capacity_check([f], gate, roster, 2) + assert result[0]["in_roster"] is False + + +class TestTimelineCheck(unittest.TestCase): + def test_on_track(self): + stories = [_story("S-1", "alice@x.com", sp=8)] + f = _feature(all_stories=stories) + gate = [{"feature_key": f["key"], "status": "PASS"}] + roster = _roster(("alice@x.com", "Alice", 8)) + result = run_timeline_check([f], gate, roster, 3) + assert result[0]["risk"] == "OK" + + def test_behind_schedule(self): + stories = [_story("S-1", "alice@x.com", sp=30)] + f = _feature(all_stories=stories) + gate = [{"feature_key": f["key"], "status": "PASS"}] + roster = _roster(("alice@x.com", "Alice", 8)) + result = run_timeline_check([f], gate, roster, 1) + assert result[0]["risk"] == "HIGH" + assert result[0]["gap"] > 0 + + def test_proportional_velocity(self): + s1 = _story("S-1", "alice@x.com", sp=10, epic_key="E-1") + s2 = _story("S-2", "alice@x.com", sp=10, epic_key="E-2") + f1 = _feature("F-1", all_stories=[s1]) + f2 = _feature("F-2", all_stories=[s2]) + gate = [{"feature_key": "F-1", "status": "PASS"}, {"feature_key": "F-2", "status": "PASS"}] + roster = _roster(("alice@x.com", "Alice", 8)) + result = run_timeline_check([f1, f2], gate, roster, 3) + assert result[0]["velocity_per_sprint"] == 4.0 + assert result[1]["velocity_per_sprint"] == 4.0 + + def test_no_contributors(self): + stories = [_story("S-1", assignee=None, sp=10)] + f = _feature(all_stories=stories) + gate = [{"feature_key": f["key"], "status": "PASS"}] + result = run_timeline_check([f], gate, _roster(), 3) + assert result[0]["risk"] == "NO_CONTRIBUTORS" + + def test_zero_remaining_sprints(self): + stories = [_story("S-1", "alice@x.com", sp=10)] + f = _feature(all_stories=stories) + gate = [{"feature_key": f["key"], "status": "PASS"}] + roster = _roster(("alice@x.com", "Alice", 8)) + result = run_timeline_check([f], gate, roster, 0) + assert result[0]["risk"] == "HIGH" + + +class TestAssignmentCheck(unittest.TestCase): + def test_spof_detected(self): + stories = [_story("S-1", "alice@x.com"), _story("S-2", "alice@x.com")] + f = _feature(all_stories=stories) + result = run_assignment_check([f]) + assert len(result["spof"]) == 1 + + def test_no_spof_with_multiple_contributors(self): + stories = [_story("S-1", "alice@x.com"), _story("S-2", "bob@x.com")] + f = _feature(all_stories=stories) + result = run_assignment_check([f]) + assert len(result["spof"]) == 0 + + def test_unassigned_detected(self): + stories = [_story("S-1", assignee=None, sp=5)] + f = _feature(all_stories=stories) + result = run_assignment_check([f]) + assert len(result["unassigned"]) == 1 + assert result["unassigned"][0]["count"] == 1 + + def test_bugs_excluded_from_spof(self): + stories = [ + _story("S-1", "alice@x.com", sp=5), + _story("B-1", "bot@x.com", sp=0, issue_type="Bug"), + ] + f = _feature(all_stories=stories) + result = run_assignment_check([f]) + assert len(result["spof"]) == 1 + + def test_bugs_excluded_from_unassigned(self): + stories = [_story("B-1", assignee=None, sp=0, issue_type="Bug")] + f = _feature(all_stories=stories) + result = run_assignment_check([f]) + assert len(result["unassigned"]) == 0 + + +class TestCompositeCheck(unittest.TestCase): + def _run(self, features, gate_status="PASS", timeline_risk="OK", capacity_over=False, + has_spof=False, has_unassigned=False, has_sizing=False): + gate = [{"feature_key": f["key"], "status": gate_status} for f in features] + capacity = [{"person": "alice@x.com", "status": "OVER" if capacity_over else "OK"}] if capacity_over else [] + timeline = [{"feature_key": f["key"], "risk": timeline_risk} for f in features] + spof = [{"feature_key": feat["key"], "sole_contributor": "alice"} for feat in features] if has_spof else [] + unassigned_list = [{"feature_key": feat["key"], "count": 1, "sp": 5} for feat in features] if has_unassigned else [] + assignment = {"spof": spof, "unassigned": unassigned_list} + sizing = [{"feature_key": feat["key"]} for feat in features] if has_sizing else [] + return run_composite_check(features, gate, capacity, timeline, assignment, sizing) + + def test_low_no_signals(self): + f = _feature(all_stories=[_story()]) + result = self._run([f]) + assert result[0]["composite_risk"] == "LOW" + + def test_medium_two_signals(self): + f = _feature(all_stories=[_story()]) + result = self._run([f], has_spof=True, has_sizing=True) + assert result[0]["composite_risk"] == "MEDIUM" + + def test_high_three_signals(self): + f = _feature(all_stories=[_story()]) + result = self._run([f], timeline_risk="HIGH", has_spof=True, has_sizing=True) + assert result[0]["composite_risk"] == "HIGH" + + def test_spof_and_unassigned_merged_as_one_signal(self): + f = _feature(all_stories=[_story()]) + result = self._run([f], has_spof=True, has_unassigned=True) + assert result[0]["signal_count"] == 1 + assert result[0]["composite_risk"] == "LOW" + + def test_data_quality_fail_counts_as_signal(self): + f = _feature(all_stories=[_story()]) + result = self._run([f], gate_status="FAIL", has_sizing=True) + assert result[0]["signal_count"] == 2 + assert result[0]["composite_risk"] == "MEDIUM" + + def test_sorted_high_first(self): + f1 = _feature("F-1", all_stories=[_story("S-1")]) + f2 = _feature("F-2", all_stories=[_story("S-2")]) + gate = [{"feature_key": "F-1", "status": "PASS"}, {"feature_key": "F-2", "status": "PASS"}] + capacity = [] + timeline = [{"feature_key": "F-1", "risk": "OK"}, {"feature_key": "F-2", "risk": "HIGH"}] + assignment = {"spof": [{"feature_key": "F-2", "sole_contributor": "a"}], "unassigned": []} + sizing = [{"feature_key": "F-2"}] + result = run_composite_check([f1, f2], gate, capacity, timeline, assignment, sizing) + assert result[0]["feature_key"] == "F-2" + assert result[0]["composite_risk"] == "HIGH" + + +class TestBuildHierarchy(unittest.TestCase): + def test_basic_hierarchy(self): + features_data = {"features": [{"key": "F-1", "summary": "Feature 1", "status": "In Progress", + "size": "M", "sme": "None", "type": "Feature"}]} + epics_data = {"epics": [{"key": "E-1", "labels": []}], "feature_to_epics": {"F-1": ["E-1"]}} + stories_data = {"stories": [_story("S-1", epic_key="E-1")]} + bugs_data = {"bugs": []} + result, _bugs = build_hierarchy(features_data, epics_data, stories_data, bugs_data, "none") + assert len(result) == 1 + assert len(result[0]["epics"]) == 1 + assert len(result[0]["all_stories"]) == 1 + + def test_component_filter_by_label(self): + features_data = {"features": [ + {"key": "F-1", "summary": "F1", "status": "IP", "size": "M", "sme": "N", "type": "Feature"}, + {"key": "F-2", "summary": "F2", "status": "IP", "size": "M", "sme": "N", "type": "Feature"}, + ]} + epics_data = {"epics": [ + {"key": "E-1", "labels": ["tna"]}, + {"key": "E-2", "labels": ["lvms"]}, + ], "feature_to_epics": {"F-1": ["E-1"], "F-2": ["E-2"]}} + stories_data = {"stories": [_story("S-1", epic_key="E-1"), _story("S-2", epic_key="E-2")]} + bugs_data = {"bugs": []} + result, _ = build_hierarchy(features_data, epics_data, stories_data, bugs_data, "tna") + assert len(result) == 1 + assert result[0]["key"] == "F-1" + + def test_no_filter(self): + features_data = {"features": [{"key": "F-1", "summary": "F1", "status": "IP", "size": "M", "sme": "N", "type": "Feature"}]} + epics_data = {"epics": [], "feature_to_epics": {}} + stories_data = {"stories": []} + bugs_data = {"bugs": []} + result, _ = build_hierarchy(features_data, epics_data, stories_data, bugs_data, "none") + assert len(result) == 1 + + def _make_data(self, story_key="OCPEDGE-1", epic_labels=None, story_component=""): + s = _story(story_key, epic_key="E-1") + s["component"] = story_component + features_data = {"features": [{"key": "F-1", "summary": "F1", "status": "IP", "size": "M", "sme": "N", "type": "Feature"}]} + epics_data = {"epics": [{"key": "E-1", "labels": epic_labels or []}], "feature_to_epics": {"F-1": ["E-1"]}} + stories_data = {"stories": [s]} + bugs_data = {"bugs": []} + return features_data, epics_data, stories_data, bugs_data + + def test_microshift_filter_matches_microshift_component(self): + fd, ed, sd, bd = self._make_data(story_component="MicroShift") + result, _ = build_hierarchy(fd, ed, sd, bd, "microshift") + assert len(result) == 1 + + def test_microshift_filter_matches_subcomponent(self): + fd, ed, sd, bd = self._make_data(story_component="MicroShift / Networking") + result, _ = build_hierarchy(fd, ed, sd, bd, "microshift") + assert len(result) == 1 + + def test_microshift_filter_matches_future_subcomponent(self): + fd, ed, sd, bd = self._make_data(story_component="MicroShift / etcd") + result, _ = build_hierarchy(fd, ed, sd, bd, "microshift") + assert len(result) == 1 + + def test_microshift_filter_matches_ushift_key(self): + fd, ed, sd, bd = self._make_data(story_key="USHIFT-100") + result, _ = build_hierarchy(fd, ed, sd, bd, "microshift") + assert len(result) == 1 + + def test_sno_filter_does_not_match_ushift(self): + fd, ed, sd, bd = self._make_data(story_key="USHIFT-100") + result, _ = build_hierarchy(fd, ed, sd, bd, "sno") + assert len(result) == 0 + + def test_sno_filter_matches_sno_component(self): + fd, ed, sd, bd = self._make_data(story_component="Installer / Single Node OpenShift") + result, _ = build_hierarchy(fd, ed, sd, bd, "sno") + assert len(result) == 1 + + def test_tna_filter_does_not_match_microshift(self): + fd, ed, sd, bd = self._make_data(story_component="MicroShift") + result, _ = build_hierarchy(fd, ed, sd, bd, "tna") + assert len(result) == 0 + + def test_tna_filter_matches_label(self): + fd, ed, sd, bd = self._make_data(epic_labels=["tna"]) + result, _ = build_hierarchy(fd, ed, sd, bd, "tna") + assert len(result) == 1 + + +class TestBugLoadFilter(unittest.TestCase): + def _bug(self, key="OCPBUGS-1", component="MicroShift"): + return {"key": key, "summary": f"Bug {key}", "type": "Bug", "status": "NEW", + "priority": "Blocker", "assignee": None, "component": component} + + def test_microshift_filter_matches_all_subcomponents(self): + bugs = [self._bug("B-1", "MicroShift"), self._bug("B-2", "MicroShift / Networking"), + self._bug("B-3", "Two Node Fencing")] + result = run_bug_load_check(bugs, [], "microshift") + assert len(result["unassigned_blocker_critical"]) == 2 + + def test_microshift_filter_matches_future_subcomponent(self): + bugs = [self._bug("B-1", "MicroShift / etcd")] + result = run_bug_load_check(bugs, [], "microshift") + assert len(result["unassigned_blocker_critical"]) == 1 + + def test_sno_filter_excludes_microshift(self): + bugs = [self._bug("B-1", "MicroShift"), self._bug("B-2", "Installer / Single Node OpenShift")] + result = run_bug_load_check(bugs, [], "sno") + assert len(result["unassigned_blocker_critical"]) == 1 + assert result["unassigned_blocker_critical"][0]["key"] == "B-2" + + def test_tna_filter(self): + bugs = [self._bug("B-1", "Two Node with Arbiter"), self._bug("B-2", "MicroShift")] + result = run_bug_load_check(bugs, [], "tna") + assert len(result["unassigned_blocker_critical"]) == 1 + assert result["unassigned_blocker_critical"][0]["key"] == "B-1" + + +class TestBugLoadCheck(unittest.TestCase): + def _bug(self, key="OCPBUGS-1", priority="Blocker", assignee=None, component="Two Node Fencing"): + return {"key": key, "summary": f"Bug {key}", "type": "Bug", "status": "NEW", + "priority": priority, "assignee": assignee, "component": component} + + def test_unassigned_blocker_detected(self): + result = run_bug_load_check([self._bug()], [], "none") + assert len(result["unassigned_blocker_critical"]) == 1 + + def test_assigned_blocker_not_flagged(self): + result = run_bug_load_check([self._bug(assignee="alice@x.com")], [], "none") + assert len(result["unassigned_blocker_critical"]) == 0 + + def test_major_not_flagged(self): + result = run_bug_load_check([self._bug(priority="Major")], [], "none") + assert len(result["unassigned_blocker_critical"]) == 0 + + def test_component_filter(self): + bugs = [self._bug("B-1", component="Two Node Fencing"), self._bug("B-2", component="Two Node with Arbiter")] + result = run_bug_load_check(bugs, [], "tnf") + assert len(result["unassigned_blocker_critical"]) == 1 + + def test_epic_bugs_included(self): + epic_story_bug = _story("OCPBUGS-99", assignee=None, sp=0, issue_type="Bug", priority="Critical") + epic_story_bug["component"] = "Two Node Fencing" + result = run_bug_load_check([], [epic_story_bug], "none") + assert len(result["unassigned_blocker_critical"]) == 1 + + +class TestSizingCheck(unittest.TestCase): + def test_unsized_flagged(self): + f = _feature(size="Unsized", all_stories=[_story(sp=5)]) + result = run_sizing_check([f], [{"feature_key": f["key"], "sprints_needed": 1, "risk": "OK"}]) + assert result[0]["assessment"] == "Unsized" + + def test_correctly_sized_not_flagged(self): + f = _feature(size="M", all_stories=[_story(sp=5)], epics=[{"key": "E-1"}]) + result = run_sizing_check([f], [{"feature_key": f["key"], "sprints_needed": 2, "risk": "OK"}]) + assert len(result) == 0 + + def test_undersized_by_sprints(self): + f = _feature(size="XS", all_stories=[_story(sp=30)], epics=[{"key": "E-1"}]) + result = run_sizing_check([f], [{"feature_key": f["key"], "sprints_needed": 5, "risk": "HIGH"}]) + assert result[0]["assessment"] == "Undersized" + + +class TestCompositeCheckBugSignal(unittest.TestCase): + def test_bug_signal_fires_for_epic_linked_bugs(self): + bug_story = _story("OCPBUGS-1", assignee=None, sp=0, issue_type="Bug", priority="Blocker") + f = _feature(all_stories=[_story("S-1"), bug_story]) + gate = [{"feature_key": f["key"], "status": "PASS"}] + result = run_composite_check( + [f], gate, [], [{"feature_key": f["key"], "risk": "OK"}], + {"spof": [], "unassigned": []}, [] + ) + assert result[0]["bugs"] == "HIGH" + assert result[0]["signal_count"] == 1 + + def test_no_bug_signal_when_bugs_assigned(self): + bug_story = _story("OCPBUGS-1", assignee="alice@x.com", sp=0, issue_type="Bug", priority="Blocker") + f = _feature(all_stories=[_story("S-1"), bug_story]) + gate = [{"feature_key": f["key"], "status": "PASS"}] + result = run_composite_check( + [f], gate, [], [{"feature_key": f["key"], "risk": "OK"}], + {"spof": [], "unassigned": []}, [] + ) + assert result[0]["bugs"] == "OK" + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/edge-scrum/bin/tests/test_transform_bugs.py b/plugins/edge-scrum/bin/tests/test_transform_bugs.py new file mode 100644 index 00000000..98f95386 --- /dev/null +++ b/plugins/edge-scrum/bin/tests/test_transform_bugs.py @@ -0,0 +1,155 @@ +"""Tests for transform-bugs.""" + +import sys +import os +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from importlib import import_module + +_mod = import_module("transform-bugs") +transform_bug = _mod.transform_bug +compute_aggregates = _mod.compute_aggregates +extract_component = _mod.extract_component + +TODAY = "2026-07-09" + + +def _make_raw(key="OCPBUGS-1", summary="bug", status="NEW", priority="Major", + assignee_email=None, component=None, labels=None, updated=None): + raw = { + "key": key, + "summary": summary, + "status": {"name": status}, + "priority": {"name": priority}, + "labels": labels or [], + "updated": updated or TODAY, + "created": TODAY, + } + if assignee_email: + raw["assignee"] = {"email": assignee_email, "display_name": assignee_email.split("@")[0].title()} + if component: + raw["components"] = [{"name": component}] + return raw + + +class TestExtractComponent(unittest.TestCase): + def test_known_component(self): + raw = {"components": [{"name": "Two Node Fencing"}]} + assert extract_component(raw) == "Two Node Fencing" + + def test_unknown_component(self): + raw = {"components": [{"name": "Networking"}]} + assert extract_component(raw) == "Unknown" + + def test_no_components(self): + assert extract_component({}) == "Unknown" + + def test_empty_components_list(self): + raw = {"components": []} + assert extract_component(raw) == "Unknown" + + def test_multiple_components_picks_team_one(self): + raw = {"components": [{"name": "Networking"}, {"name": "Two Node with Arbiter"}]} + assert extract_component(raw) == "Two Node with Arbiter" + + def test_none_components(self): + raw = {"components": None} + assert extract_component(raw) == "Unknown" + + +class TestTransformBug(unittest.TestCase): + def test_basic_bug(self): + raw = _make_raw(key="OCPBUGS-100", summary="crash", status="NEW", + priority="Blocker", component="Two Node Fencing") + result = transform_bug(raw, TODAY) + assert result["key"] == "OCPBUGS-100" + assert result["priority"] == "Blocker" + assert result["component"] == "Two Node Fencing" + assert result["assignee"] is None + assert result["assignee_display"] == "Unassigned" + + def test_assigned_bug(self): + raw = _make_raw(assignee_email="alice@redhat.com") + result = transform_bug(raw, TODAY) + assert result["assignee"] == "alice@redhat.com" + assert result["assignee_display"] == "Alice" + + def test_stale_bug(self): + raw = _make_raw(updated="2026-06-01") + result = transform_bug(raw, TODAY) + assert result["stale"] is True + + def test_fresh_bug(self): + raw = _make_raw(updated=TODAY) + result = transform_bug(raw, TODAY) + assert result["stale"] is False + + def test_labels_preserved(self): + raw = _make_raw(labels=["cve", "regression"]) + result = transform_bug(raw, TODAY) + assert result["labels"] == ["cve", "regression"] + + +class TestComputeAggregates(unittest.TestCase): + def _bug(self, key="OCPBUGS-1", priority="Major", assignee=None, + component="Two Node Fencing"): + return { + "key": key, "priority": priority, "assignee": assignee, + "component": component, + } + + def test_empty_list(self): + agg = compute_aggregates([]) + assert agg["bugs_by_component"] == {} + assert agg["bugs_by_priority"] == {} + assert agg["unassigned_blocker_critical"] == [] + + def test_bugs_by_component(self): + bugs = [ + self._bug("B-1", component="Two Node Fencing"), + self._bug("B-2", component="Two Node Fencing"), + self._bug("B-3", component="Two Node with Arbiter"), + ] + agg = compute_aggregates(bugs) + assert agg["bugs_by_component"]["Two Node Fencing"] == ["B-1", "B-2"] + assert agg["bugs_by_component"]["Two Node with Arbiter"] == ["B-3"] + + def test_bugs_by_priority(self): + bugs = [ + self._bug("B-1", priority="Blocker"), + self._bug("B-2", priority="Critical"), + self._bug("B-3", priority="Major"), + ] + agg = compute_aggregates(bugs) + assert agg["bugs_by_priority"]["Blocker"] == ["B-1"] + assert agg["bugs_by_priority"]["Critical"] == ["B-2"] + assert agg["bugs_by_priority"]["Major"] == ["B-3"] + + def test_unassigned_blocker_critical(self): + bugs = [ + self._bug("B-1", priority="Blocker", assignee=None), + self._bug("B-2", priority="Critical", assignee=None), + self._bug("B-3", priority="Major", assignee=None), + self._bug("B-4", priority="Blocker", assignee="alice@redhat.com"), + ] + agg = compute_aggregates(bugs) + assert agg["unassigned_blocker_critical"] == ["B-1", "B-2"] + + def test_assigned_blocker_not_flagged(self): + bugs = [ + self._bug("B-1", priority="Blocker", assignee="alice@redhat.com"), + ] + agg = compute_aggregates(bugs) + assert agg["unassigned_blocker_critical"] == [] + + def test_unassigned_major_not_flagged(self): + bugs = [ + self._bug("B-1", priority="Major", assignee=None), + ] + agg = compute_aggregates(bugs) + assert agg["unassigned_blocker_critical"] == [] + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/edge-scrum/bin/tests/test_transform_stories.py b/plugins/edge-scrum/bin/tests/test_transform_stories.py new file mode 100644 index 00000000..f4eb52c8 --- /dev/null +++ b/plugins/edge-scrum/bin/tests/test_transform_stories.py @@ -0,0 +1,219 @@ +"""Tests for transform-stories.""" + +import sys +import os +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +from importlib import import_module + +_mod = import_module("transform-stories") +transform_story = _mod.transform_story +compute_aggregates = _mod.compute_aggregates + +TODAY = "2026-07-09" + + +def _make_raw(key="OCPEDGE-1", summary="test", status="To Do", assignee_email=None, + sp=None, epic_key=None, issue_type="Story", priority="Major", + labels=None, updated=None, flagged=None, issuelinks=None): + raw = { + "key": key, + "summary": summary, + "status": {"name": status}, + "issuetype": {"name": issue_type}, + "priority": {"name": priority}, + "labels": labels or [], + "updated": updated or TODAY, + "created": TODAY, + } + if assignee_email: + raw["assignee"] = {"email": assignee_email, "display_name": assignee_email.split("@")[0].title()} + if sp is not None: + raw["customfield_10028"] = sp + if epic_key: + raw["parent"] = {"key": epic_key} + if flagged: + raw["customfield_10021"] = [{"value": "Impediment"}] + if issuelinks: + raw["issuelinks"] = issuelinks + return raw + + +class TestTransformStory(unittest.TestCase): + def test_basic_story(self): + raw = _make_raw(key="OCPEDGE-100", summary="Do thing", status="In Progress", + assignee_email="alice@redhat.com", sp=5, epic_key="OCPEDGE-10") + result = transform_story(raw, TODAY) + assert result["key"] == "OCPEDGE-100" + assert result["type"] == "Story" + assert result["assignee"] == "alice@redhat.com" + assert result["assignee_display"] == "Alice" + assert result["sp"] == 5 + assert result["epic_key"] == "OCPEDGE-10" + + def test_bug_always_zero_sp(self): + raw = _make_raw(issue_type="Bug", sp=8) + result = transform_story(raw, TODAY) + assert result["sp"] == 0 + assert result["type"] == "Bug" + + def test_unassigned_story(self): + raw = _make_raw() + result = transform_story(raw, TODAY) + assert result["assignee"] is None + assert result["assignee_display"] == "Unassigned" + + def test_no_sp_defaults_to_zero(self): + raw = _make_raw() + result = transform_story(raw, TODAY) + assert result["sp"] == 0 + + def test_no_parent_key_defaults(self): + raw = _make_raw() + result = transform_story(raw, TODAY) + assert result["epic_key"] == "No Feature" + + def test_flagged_issue(self): + raw = _make_raw(flagged=True) + result = transform_story(raw, TODAY) + assert result["flagged"] is True + + def test_not_flagged_by_default(self): + raw = _make_raw() + result = transform_story(raw, TODAY) + assert result["flagged"] is False + + def test_stale_in_progress(self): + raw = _make_raw(status="In Progress", updated="2026-06-20") + result = transform_story(raw, TODAY) + assert result["stale"] is True + + def test_not_stale_if_recently_updated(self): + raw = _make_raw(status="In Progress", updated=TODAY) + result = transform_story(raw, TODAY) + assert result["stale"] is False + + def test_not_stale_if_not_in_progress(self): + raw = _make_raw(status="To Do", updated="2026-06-01") + result = transform_story(raw, TODAY) + assert result["stale"] is False + + def test_priority_extracted(self): + raw = _make_raw(priority="Blocker") + result = transform_story(raw, TODAY) + assert result["priority"] == "Blocker" + + def test_labels_preserved(self): + raw = _make_raw(labels=["edge", "tnf"]) + result = transform_story(raw, TODAY) + assert result["labels"] == ["edge", "tnf"] + + +class TestComputeAggregates(unittest.TestCase): + def _story(self, key="OCPEDGE-1", assignee=None, sp=0, epic="OCPEDGE-10", + status="To Do", issue_type="Story"): + return { + "key": key, "assignee": assignee, "sp": sp, "epic_key": epic, + "status": status, "type": issue_type, + } + + def test_empty_list(self): + agg = compute_aggregates([]) + assert agg["stories_by_epic"] == {} + assert agg["sp_by_assignee"] == {} + assert agg["unassigned_stories"] == [] + assert agg["unpointed_stories"] == [] + + def test_stories_grouped_by_epic(self): + stories = [ + self._story("A-1", epic="E-1"), + self._story("A-2", epic="E-1"), + self._story("A-3", epic="E-2"), + ] + agg = compute_aggregates(stories) + assert agg["stories_by_epic"]["E-1"] == ["A-1", "A-2"] + assert agg["stories_by_epic"]["E-2"] == ["A-3"] + + def test_sp_by_assignee_excludes_done(self): + stories = [ + self._story("A-1", assignee="alice@redhat.com", sp=5, status="To Do"), + self._story("A-2", assignee="alice@redhat.com", sp=3, status="Closed"), + ] + agg = compute_aggregates(stories) + assert agg["sp_by_assignee"]["alice@redhat.com"] == 5 + + def test_ocpbugs_verified_is_done(self): + stories = [ + self._story("OCPBUGS-1", assignee="alice@redhat.com", sp=0, status="Verified", issue_type="Bug"), + ] + agg = compute_aggregates(stories) + assert "OCPBUGS-1" not in agg.get("unassigned_stories", []) + + def test_sp_by_assignee_excludes_bugs(self): + stories = [ + self._story("A-1", assignee="alice@redhat.com", sp=5), + self._story("A-2", assignee="alice@redhat.com", sp=0, issue_type="Bug"), + ] + agg = compute_aggregates(stories) + assert agg["sp_by_assignee"]["alice@redhat.com"] == 5 + + def test_sp_by_assignee_excludes_zero_sp(self): + stories = [ + self._story("A-1", assignee="alice@redhat.com", sp=0), + ] + agg = compute_aggregates(stories) + assert "alice@redhat.com" not in agg["sp_by_assignee"] + + def test_unassigned_stories_only_non_done(self): + stories = [ + self._story("A-1", assignee=None, status="To Do"), + self._story("A-2", assignee=None, status="Closed"), + ] + agg = compute_aggregates(stories) + assert agg["unassigned_stories"] == ["A-1"] + + def test_unpointed_stories_exclude_bugs_and_done(self): + stories = [ + self._story("A-1", sp=0, status="To Do"), + self._story("A-2", sp=0, status="Closed"), + self._story("A-3", sp=0, issue_type="Bug", status="To Do"), + self._story("A-4", sp=5, status="To Do"), + ] + agg = compute_aggregates(stories) + assert agg["unpointed_stories"] == ["A-1"] + + def test_remaining_sp_by_epic(self): + stories = [ + self._story("A-1", sp=5, epic="E-1", status="To Do"), + self._story("A-2", sp=3, epic="E-1", status="Closed"), + self._story("A-3", sp=8, epic="E-1", status="In Progress"), + ] + agg = compute_aggregates(stories) + assert agg["sp_by_epic"]["E-1"] == 16 + assert agg["done_sp_by_epic"]["E-1"] == 3 + assert agg["remaining_sp_by_epic"]["E-1"] == 13 + + def test_stories_by_assignee(self): + stories = [ + self._story("A-1", assignee="alice@redhat.com"), + self._story("A-2", assignee="bob@redhat.com"), + self._story("A-3", assignee="alice@redhat.com"), + ] + agg = compute_aggregates(stories) + assert agg["stories_by_assignee"]["alice@redhat.com"] == ["A-1", "A-3"] + assert agg["stories_by_assignee"]["bob@redhat.com"] == ["A-2"] + + def test_multiple_assignees_capacity(self): + stories = [ + self._story("A-1", assignee="alice@redhat.com", sp=5), + self._story("A-2", assignee="bob@redhat.com", sp=8), + self._story("A-3", assignee="alice@redhat.com", sp=3), + ] + agg = compute_aggregates(stories) + assert agg["sp_by_assignee"]["alice@redhat.com"] == 8 + assert agg["sp_by_assignee"]["bob@redhat.com"] == 8 + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/edge-scrum/bin/transform-bugs.py b/plugins/edge-scrum/bin/transform-bugs.py new file mode 100644 index 00000000..54c81022 --- /dev/null +++ b/plugins/edge-scrum/bin/transform-bugs.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Transform raw Jira search results into bugs.json for release planning.""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(__file__)) +from _jira_transforms import ( + extract_assignee_username, + extract_display_name, + is_stale, + safe_format_date, + get_nested, + load_issues, + write_output, +) + +TEAM_COMPONENTS = { + "Installer / Single Node OpenShift", + "Two Node with Arbiter", + "Two Node Fencing", + "Logical Volume Manager Storage", + "Bandwidth Reduction", + "Topology Transition", + "MicroShift", + "MicroShift / Networking", + "MicroShift / Storage", +} + + +def extract_component(raw): + """Extract first team-relevant component from the components array.""" + components = raw.get("components") or [] + if isinstance(components, list): + for comp in components: + name = comp.get("name", "") if isinstance(comp, dict) else str(comp) + if name in TEAM_COMPONENTS: + return name + return "Unknown" + + +def transform_bug(raw, today): + status = get_nested(raw, "status", "name") or "" + priority = get_nested(raw, "priority", "name") or "Major" + updated = safe_format_date(raw.get("updated") or raw.get("created") or today, today) + assignee_raw = raw.get("assignee") + + return { + "key": raw.get("key", ""), + "summary": raw.get("summary", ""), + "status": status, + "priority": priority, + "assignee": extract_assignee_username(raw), + "assignee_display": extract_display_name(assignee_raw, fallback="Unassigned"), + "component": extract_component(raw), + "labels": raw.get("labels", []), + "stale": is_stale(updated, today), + } + + +def compute_aggregates(bugs): + bugs_by_component = {} + bugs_by_priority = {} + unassigned_blocker_critical = [] + + for bug in bugs: + key = bug["key"] + bugs_by_component.setdefault(bug["component"], []).append(key) + bugs_by_priority.setdefault(bug["priority"], []).append(key) + + if bug["priority"] in ("Blocker", "Critical") and bug["assignee"] is None: + unassigned_blocker_critical.append(key) + + return { + "bugs_by_component": bugs_by_component, + "bugs_by_priority": bugs_by_priority, + "unassigned_blocker_critical": unassigned_blocker_critical, + } + + +def main(): + parser = argparse.ArgumentParser( + description="Transform Jira search results into bugs.json" + ) + parser.add_argument("--input", nargs="+", required=True, help="Raw MCP response file(s)") + parser.add_argument("--output", required=True, help="Output path for bugs.json") + parser.add_argument("--today", required=True, help="Today's date YYYY-MM-DD") + args = parser.parse_args() + + raw_issues = load_issues(args.input) + bugs = [] + skipped_issues = [] + for raw in raw_issues: + try: + bugs.append(transform_bug(raw, args.today)) + except Exception as e: + key = raw.get("key", "unknown") + skipped_issues.append({"key": key, "error": str(e)}) + print(f"WARNING: skipped {key}: {e}", file=sys.stderr) + + aggregates = compute_aggregates(bugs) + + output = { + "total_bugs": len(bugs), + "skipped_issues": skipped_issues, + "bugs": bugs, + **aggregates, + } + + write_output(output, args.output) + + +if __name__ == "__main__": + main() diff --git a/plugins/edge-scrum/bin/transform-stories.py b/plugins/edge-scrum/bin/transform-stories.py new file mode 100644 index 00000000..a38b5e47 --- /dev/null +++ b/plugins/edge-scrum/bin/transform-stories.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Transform raw Jira search results into stories.json for release planning.""" + +import argparse +import sys +import os + +sys.path.insert(0, os.path.dirname(__file__)) +from _jira_transforms import ( + extract_issue_type, + extract_sp, + extract_parent_key, + extract_flagged, + extract_blocked_by, + extract_assignee_username, + extract_display_name, + is_stale, + safe_format_date, + get_nested, + load_issues, + write_output, + IN_PROGRESS_STATUSES, +) + +DONE_STATUSES_STORIES = {"Closed"} +DONE_STATUSES_OCPBUGS = {"Closed", "Verified"} + + +def transform_story(raw, today): + issue_type = extract_issue_type(raw) + status = get_nested(raw, "status", "name") or "" + updated = safe_format_date(raw.get("updated") or raw.get("created") or today, today) + priority = get_nested(raw, "priority", "name") or "Major" + assignee_raw = raw.get("assignee") + + return { + "key": raw.get("key", ""), + "summary": raw.get("summary", ""), + "type": issue_type, + "status": status, + "assignee": extract_assignee_username(raw), + "assignee_display": extract_display_name(assignee_raw, fallback="Unassigned"), + "sp": extract_sp(raw, issue_type), + "epic_key": extract_parent_key(raw), + "flagged": extract_flagged(raw), + "blocked_by": extract_blocked_by(raw), + "stale": status in IN_PROGRESS_STATUSES and is_stale(updated, today), + "labels": raw.get("labels", []), + "priority": priority, + } + + +def compute_aggregates(stories): + stories_by_epic = {} + stories_by_assignee = {} + sp_by_assignee = {} + sp_by_assignee_by_epic = {} + sp_by_epic = {} + done_sp_by_epic = {} + remaining_sp_by_epic = {} + unassigned_stories = [] + unpointed_stories = [] + + for story in stories: + key = story["key"] + epic = story["epic_key"] + assignee = story["assignee"] + sp = story["sp"] + done_set = DONE_STATUSES_OCPBUGS if story["key"].startswith("OCPBUGS-") else DONE_STATUSES_STORIES + is_done = story["status"] in done_set + is_bug = story["type"] == "Bug" + + stories_by_epic.setdefault(epic, []).append(key) + sp_by_epic[epic] = sp_by_epic.get(epic, 0) + sp + done_sp_by_epic[epic] = done_sp_by_epic.get(epic, 0) + (sp if is_done else 0) + + if assignee: + stories_by_assignee.setdefault(assignee, []).append(key) + if not is_done and not is_bug and sp > 0: + sp_by_assignee[assignee] = sp_by_assignee.get(assignee, 0) + sp + sp_by_assignee_by_epic.setdefault(epic, {}) + sp_by_assignee_by_epic[epic][assignee] = ( + sp_by_assignee_by_epic[epic].get(assignee, 0) + sp + ) + else: + if not is_done and not is_bug: + unassigned_stories.append(key) + + if sp == 0 and not is_bug and not is_done: + unpointed_stories.append(key) + + for epic in sp_by_epic: + remaining_sp_by_epic[epic] = sp_by_epic[epic] - done_sp_by_epic.get(epic, 0) + + return { + "stories_by_epic": stories_by_epic, + "stories_by_assignee": stories_by_assignee, + "sp_by_assignee": sp_by_assignee, + "sp_by_assignee_by_epic": sp_by_assignee_by_epic, + "sp_by_epic": sp_by_epic, + "done_sp_by_epic": done_sp_by_epic, + "remaining_sp_by_epic": remaining_sp_by_epic, + "unassigned_stories": unassigned_stories, + "unpointed_stories": unpointed_stories, + } + + +def main(): + parser = argparse.ArgumentParser( + description="Transform Jira search results into stories.json" + ) + parser.add_argument("--input", nargs="+", required=True, help="Raw MCP response file(s)") + parser.add_argument("--output", required=True, help="Output path for stories.json") + parser.add_argument("--today", required=True, help="Today's date YYYY-MM-DD") + args = parser.parse_args() + + raw_issues = load_issues(args.input) + stories = [] + skipped_issues = [] + for raw in raw_issues: + try: + stories.append(transform_story(raw, args.today)) + except Exception as e: + key = raw.get("key", "unknown") + skipped_issues.append({"key": key, "error": str(e)}) + print(f"WARNING: skipped {key}: {e}", file=sys.stderr) + + aggregates = compute_aggregates(stories) + + output = { + "total_stories": len(stories), + "skipped_issues": skipped_issues, + "stories": stories, + **aggregates, + } + + write_output(output, args.output) + + +if __name__ == "__main__": + main() diff --git a/plugins/edge-scrum/references/Edge-Scrum-Laws.md b/plugins/edge-scrum/references/Edge-Scrum-Laws.md index 454defdd..1c9c9fcd 100644 --- a/plugins/edge-scrum/references/Edge-Scrum-Laws.md +++ b/plugins/edge-scrum/references/Edge-Scrum-Laws.md @@ -11,6 +11,8 @@ Load only the law files your task requires. Do not read the entire `laws/` direc | Create Epic | `laws/01-jira-projects.md`, `laws/04-jira-epics.md`, `laws/06-jira-fields.md`, `laws/07-workflow-states.md`, `laws/13-roles.md`, `laws/14-agent-conventions.md` | | Release Health (orchestrator) | `laws/00-team-roster.md`, `laws/01-jira-projects.md`, `laws/05-jira-features.md`, `laws/06-jira-fields.md`, `laws/09-sprint-policies.md`, `laws/14-agent-conventions.md` | | Release Health Analysis | `laws/00-team-roster.md`, `laws/01-jira-projects.md`, `laws/03-jira-bugs.md`, `laws/04-jira-epics.md`, `laws/06-jira-fields.md`, `laws/07-workflow-states.md`, `laws/09-sprint-policies.md` | +| Release Planning (orchestrator) | `laws/00-team-roster.md`, `laws/01-jira-projects.md`, `laws/03-jira-bugs.md`, `laws/04-jira-epics.md`, `laws/05-jira-features.md`, `laws/06-jira-fields.md`, `laws/09-sprint-policies.md`, `laws/14-agent-conventions.md` | +| Release Planning Analysis | `laws/02-jira-stories.md`, `laws/04-jira-epics.md`, `laws/05-jira-features.md`, `laws/07-workflow-states.md`, `laws/09-sprint-policies.md` | | Sprint Health | `laws/00-team-roster.md`, `laws/06-jira-fields.md`, `laws/07-workflow-states.md`, `laws/09-sprint-policies.md` | | Bug Triage | `laws/01-jira-projects.md`, `laws/03-jira-bugs.md`, `laws/10-bug-triage.md`, `laws/11-work-prioritization.md` | | Epic Refinement | `laws/04-jira-epics.md`, `laws/12-epic-feature-refinement.md`, `laws/13-roles.md` | diff --git a/plugins/edge-scrum/references/release-planning-report-template.md b/plugins/edge-scrum/references/release-planning-report-template.md new file mode 100644 index 00000000..84337a2f --- /dev/null +++ b/plugins/edge-scrum/references/release-planning-report-template.md @@ -0,0 +1,48 @@ +# Release Planning Risk Assessment: OCP {VERSION} + +| | | +|---|---| +| **Analysis Date** | {TODAY} | +| **Release Window** | Sprint {FIRST} -- Sprint {LAST} | +| **Pencils Down** | Sprint {PENCILS_DOWN} | +| **Sprints Remaining** | {remaining_sprint_count} of {total_dev_sprints} dev sprints (to pencils down) | +| **Component Filter** | {component_filter} | +| **Features Assessed** | {assessed_features} / {total_features} | +| **Data Quality Failures** | {data_quality_failures} | +| **Overall Risk** | {overall_risk} | + +## Executive Summary + +{executive_recommendation} + +--- + +{DATA_QUALITY} + +--- + +{CAPACITY} + +--- + +{TIMELINE} + +--- + +{ASSIGNMENT} + +--- + +{BUG_LOAD} + +--- + +{SIZING} + +--- + +{PROGRESS} + +--- + +{RECOMMENDATIONS} diff --git a/plugins/edge-scrum/skills/release-planning-analysis/SKILL.md b/plugins/edge-scrum/skills/release-planning-analysis/SKILL.md new file mode 100644 index 00000000..276d5611 --- /dev/null +++ b/plugins/edge-scrum/skills/release-planning-analysis/SKILL.md @@ -0,0 +1,78 @@ +--- +name: release-planning-analysis +description: Write actionable recommendations for a release planning risk assessment — reads pre-computed check results and produces narrative per person and per feature +allowed-tools: Read, Write +user-invocable: false +--- + +# release-planning: Analysis + +## Purpose + +Read the pre-computed planning risk checks from `checks.json` and write actionable, natural-language recommendations. All arithmetic (capacity, timeline, composite scoring) has already been computed by `run-checks.py` — this agent only interprets the results and writes narrative. + +## When to Spawn + +The parent release-planning skill spawns this agent during Phase 5b, after `run-checks.py` has produced `{WORKDIR}/checks.json`. + +## Parameters + +Substituted by the parent before spawning: + +| Placeholder | Description | +|---|---| +| `{WORKDIR}` | Work directory path | +| `{VERSION}` | OCP release version (e.g., `5.0`) | + +## Instructions + +### Step 1: Read Data + +Read `{WORKDIR}/checks.json`. This file contains all computed check results: + +- `meta` — summary counts (total features, risk levels, overall risk) +- `data_quality` — per-feature gate status (PASS/WARN/FAIL) +- `capacity` — per-person assigned SP vs remaining capacity +- `timeline` — per-feature remaining work vs time left +- `assignment` — SPOFs and unassigned work +- `bug_load` — unassigned Blocker/Critical bugs +- `sizing` — T-shirt size mismatches +- `composite` — per-feature composite risk level +- `unknown_contributors` — people with work assigned who aren't in the team roster + +### Step 2: Write Recommendations + +Write `{WORKDIR}/recommendations.json` with this structure: + +```json +{ + "executive_summary": "<3-5 sentences covering: overall risk verdict, top 2-3 risks, most critical action>", + "per_person": [ + "" + ], + "per_feature": [ + "" + ], + "team_level": [ + "" + ] +} +``` + +### Writing Guidelines + +- **Be specific**: Reference actual numbers from `checks.json`. "Alice has 25 SP assigned but only 16 SP capacity" not "Alice is overloaded." +- **Be actionable**: Every recommendation should name a person, a feature, and a concrete action. "Move 9 SP from Alice to Bob" not "Rebalance load." +- **Prioritize HIGH composite risk features**: Address them first in per-feature recommendations. +- **Flag unknown contributors**: If `unknown_contributors` is non-empty, note that these people have work assigned but aren't in the team roster — their velocity assumptions may be wrong. +- **Data quality failures**: For features with FAIL status, recommend specific next steps (create stories, point existing stories) rather than just noting the failure. +- **Don't repeat numbers the report already shows**: The tables will have the data. Recommendations should interpret and advise, not restate. +- **Use bare Jira keys only**: Write `OCPSTRAT-2607` not `[OCPSTRAT-2607](url)`. The report assembly script adds Jira links automatically — pre-linking causes broken nested links. + +## Important Notes + +- This agent does NOT compute any numbers — all arithmetic is in `checks.json` +- This agent does NOT build hierarchies or read Jira data files +- This agent does NOT write markdown sections or sentinel blocks +- Output is a single JSON file with four keys +- Use natural language — be conversational and actionable diff --git a/plugins/edge-scrum/skills/release-planning/SKILL.md b/plugins/edge-scrum/skills/release-planning/SKILL.md new file mode 100644 index 00000000..a23fb9cb --- /dev/null +++ b/plugins/edge-scrum/skills/release-planning/SKILL.md @@ -0,0 +1,392 @@ +--- +name: release-planning +description: Use when assessing whether the team can deliver planned scope within remaining time — evaluates capacity, timeline, assignment, bug load, sizing, and progress risks per person and per feature to surface planning risks before they become execution problems +allowed-tools: Agent, AskUserQuestion, Write, Read, Glob, Bash, mcp__plugin_mcp-atlassian_mcp-atlassian__jira_get_sprints_from_board, mcp__plugin_mcp-atlassian_mcp-atlassian__jira_search +user-invocable: true +argument-hint: " bc: [pd:] [--component ]" +--- + +# Release Planning Risk Assessment + +You are orchestrating a release planning risk assessment for the OCPEDGE team. Data fetching runs inline using MCP tools and transform scripts. Analysis (data-quality gate + 6 checks) is delegated to a sub-agent. + +> **Before proceeding**: Read `plugins/edge-scrum/references/Edge-Scrum-Laws.md` to find which law files apply to release planning orchestration. For this skill, load: `laws/00-team-roster.md`, `laws/01-jira-projects.md`, `laws/03-jira-bugs.md`, `laws/04-jira-epics.md`, `laws/05-jira-features.md`, `laws/06-jira-fields.md`, `laws/09-sprint-policies.md`, `laws/14-agent-conventions.md`. The configuration below is derived from the Laws — when in doubt, defer to the law files. + +## Configuration + +```yaml +# Scrum Board (skill-specific; not in Laws) +board_id: "11479" +board_name: "OpenShift Edge Scrum" +sprint_prefix: ["OCPEDGE Sprint", "OpenShift Edge Sprint"] + +# Custom Field IDs (Red Hat Jira instance-specific) +fields: + story_points: customfield_10028 # Numeric; Stories/Tasks/Spikes + epic_link: customfield_10014 # Story → Epic relationship + parent_link: customfield_10018 # Epic → Feature/Initiative relationship + qa_contact: customfield_10470 # User picker; QA owner + flagged: customfield_10021 # Array; non-empty = impediment + sme: customfield_10475 # User picker; Subject Matter Expert + +# Component mapping (short name → OCPBUGS components) +components: + TNA: "Two Node with Arbiter" + TNF: "Two Node Fencing" + LVMS: "Logical Volume Manager Storage" + topolvm: "Logical Volume Manager Storage" + MicroShift: + - "MicroShift" + - "MicroShift / Networking" + - "MicroShift / Storage" + SNO: "Installer / Single Node OpenShift" +``` + +## Execution Model + +1. **Steps 0–1**: Load laws/roster, gather release parameters (main context) +2. **Phase 2**: Fetch sprints + features inline using MCP tools → transform scripts (main context) +3. **Phase 3**: Fetch epics + spikes inline using MCP tools → transform scripts (main context) +4. **Phase 4**: Fetch stories + bugs inline using MCP tools → transform scripts (main context) +5. **Phase 5a**: Run `run-checks.py` — deterministic data-quality gate + 6 checks → `checks.json` +6. **Phase 5b**: Delegate narrative to sub-agent — reads `checks.json`, writes `recommendations.json` +7. **Step 6**: Run `assemble-report.py` — produces both `.md` and `.docx` from structured data + +**Rules:** + +- Data fetching uses MCP tools directly in the main context +- MCP responses are large and get persisted to files automatically — note those file paths +- Transform scripts (`plugins/edge-scrum/bin/`) convert raw MCP data to structured JSON +- Use `check-page.py` to extract pagination info from persisted files +- The analysis sub-agent only needs `Read` and `Write` — it reads `checks.json` and writes `recommendations.json` +- Never embed raw Jira response data in the main context + +## User Arguments + +The user may provide arguments: `$ARGUMENTS` + +- Version number (e.g., `5.0`) → release version +- Sprint range (e.g., `287-292`) → first through last sprint +- Branch cut (e.g., `bc:292` or `branch-cut 292`) → last sprint; the release branch is created after this sprint +- Pencils down (e.g., `pd:291` or `pencils-down 291`) → last sprint where feature code must be merged. If not provided, defaults to same as branch cut +- `--component ` → filter to a specific component (TNA, TNF, LVMS, topolvm, MicroShift, SNO) +- No arguments → ask for all required inputs + +**Branch cut vs pencils down:** Pencils down is when all feature work must be code-complete. Branch cut is when the release branch is actually created. Feature timeline risk is measured against pencils down (the earlier deadline). Bug fixes can still land between pencils down and branch cut. + +--- + +## Usage Examples + +```shell +/release-planning 5.0 287-292 bc:292 +/release-planning 5.0 287-292 bc:292 pd:291 +/release-planning 5.0 287-292 bc:292 --component TNA +/release-planning +``` + +## Workflow + +### Step 0: Load Edge Scrum Laws and Roster (main context) + +Read both files and hold in working memory: + +1. Load these law files from `plugins/edge-scrum/references/laws/`: + - `00-team-roster.md` — team capacity and `.roster.json` structure + - `01-jira-projects.md` — Jira projects and OCPBUGS components + - `03-jira-bugs.md` — bug conventions + - `04-jira-epics.md` — epic conventions and sizing + - `05-jira-features.md` — feature/initiative conventions and sizing + - `06-jira-fields.md` — custom field IDs + - `09-sprint-policies.md` — sprint capacity rules + - `14-agent-conventions.md` — agent orchestration conventions + +2. `plugins/edge-scrum/.roster.json` — extract: + - **Team roster** — `username`, `display_name`, and `sp_target` per member + - **Roster size** — count of members + - If the file does not exist, stop and instruct the user to copy `.roster.json.example` to `.roster.json` and populate it. + +The Laws are authoritative. Where this skill and the Laws conflict, the Laws win. + +--- + +### Step 1: Gather Release Parameters (main context) + +Parse arguments. Use `AskUserQuestion` for any missing required values: + +1. **Release version** — e.g., `5.0` +2. **Sprint range** — first sprint number through last +3. **Branch cut sprint** — which sprint is the last sprint before the release branch is created +4. **Pencils down sprint** — optional; which sprint is the last sprint where feature code must be merged. Defaults to branch cut if not provided +5. **Component filter** — optional, from `--component` argument + +Compute and confirm: + +- `FIRST` = first sprint number +- `LAST` = last sprint number (branch cut) +- `PENCILS_DOWN` = pencils down sprint number (defaults to `LAST` if not provided) +- `TOTAL_SPRINTS` = LAST − FIRST + 1 +- `TOTAL_DEV_SPRINTS` = PENCILS_DOWN − FIRST (sprints available for feature work, excluding refinement sprint) +- `REMAINING_SPRINT_COUNT` = number of remaining sprints until pencils down (not branch cut). After reading `sprints.json`, count how many active + future sprints have sprint numbers ≤ `PENCILS_DOWN`. If pencils down equals branch cut, use `remaining_sprint_count` from `sprints.json` directly. +- `TODAY` = today's date (`YYYY-MM-DD`) +- `COMPONENT_FILTER` = mapped component name from the components table, or `none` + +Note: Both `TOTAL_DEV_SPRINTS` and `REMAINING_SPRINT_COUNT` are computed against pencils down, not branch cut. Feature timeline risk is measured against this deadline. The sprints between pencils down and branch cut are available for bug fixes only. + +Create the work directory: + +```bash +WORKDIR=/tmp/release-planning-$(date +%Y%m%d) && mkdir -p $WORKDIR && echo $WORKDIR +``` + +Record `WORKDIR` — substitute it into all agent prompts. + +--- + +### Phase 2: Sprint + Feature Collection (inline) + +Identical to release-health Phase 2 (standard mode JQL only). + +#### 2a — Fetch Sprints + +Call `jira_get_sprints_from_board` for board_id `"11479"` three times: + +- `state="active"` +- `state="closed"` — paginate using `page_token`; use `limit=50` +- `state="future"` + +After all pages are fetched, note all persisted file paths and run: + +```bash +python3 plugins/edge-scrum/bin/transform-sprints.py \ + --input \ + --output {WORKDIR}/sprints.json \ + --today {TODAY} \ + --first-sprint {FIRST} \ + --last-sprint {LAST} \ + --total-dev-sprints {TOTAL_DEV_SPRINTS} +``` + +#### 2b — Fetch Features + +Call `jira_search` with: + +- **JQL:** `project = OCPSTRAT AND issuetype in (Feature, Initiative) AND labels in ("ocpedge-plan", "microshift") AND "Target Version" = "openshift-{VERSION}" AND (resolution is EMPTY OR resolution not in (Duplicate, Obsolete)) ORDER BY Rank ASC` +- **Fields:** `key, summary, status, issuetype, priority, assignee, fixVersions, labels, description, issuelinks, customfield_10795, customfield_10470, customfield_10473, customfield_10475` +- **limit:** `50` + +Paginate using `page_token`. If zero results, use fallback JQL (set `fallback_used`): + +```jql +project = OCPSTRAT AND issuetype in (Feature, Initiative) AND labels in ("ocpedge-plan", "microshift") AND "Target Version" = "openshift-{VERSION}" AND status not in (Done, Closed) ORDER BY Rank ASC +``` + +After all pages fetched, run: + +```bash +python3 plugins/edge-scrum/bin/transform-features.py \ + --input \ + --output {WORKDIR}/features.json +``` + +Append `--fallback-used` if fallback JQL was used. + +#### 2c — Verify + +Read and check: + +- `{WORKDIR}/sprints.json` — if `"error"` key is present or `sprint_map` is empty, warn the user and stop +- `{WORKDIR}/features.json` — if `feature_keys` is empty, warn the user about scope and stop + +--- + +### Phase 3: Epic + Spike Collection (inline) + +Identical to release-health Phase 3. + +#### 3a — Fetch Epics + +Read `{WORKDIR}/features.json`. Extract `feature_keys_csv`. + +If `feature_keys` has more than 50 entries, split into batches of 50. For each batch, call `jira_search`: + +- **JQL:** `project in (OCPEDGE, USHIFT) AND "Parent Link" in ({feature_keys_batch_csv}) ORDER BY Rank ASC` +- **Fields:** `key, summary, status, assignee, labels, description, parent, customfield_10028, customfield_10018, customfield_10470, customfield_10473, customfield_10475` +- **limit:** `50` + +Paginate using `page_token`. After all pages fetched, run: + +```bash +python3 plugins/edge-scrum/bin/transform-epics.py \ + --input \ + --output {WORKDIR}/epics.json +``` + +#### 3b — Fetch Spikes + +Read `{WORKDIR}/sprints.json`. Extract `refinement_sprint_id`. + +Call `jira_search`: + +- **JQL:** `project in (OCPEDGE, USHIFT) AND issuetype = Spike AND sprint = {refinement_sprint_id}` +- **Fields:** `key, summary, status, assignee, issuelinks` +- **limit:** `50` + +Paginate using `page_token`. After all pages fetched, run: + +```bash +python3 plugins/edge-scrum/bin/transform-spikes.py \ + --input \ + --features-file {WORKDIR}/features.json \ + --epics-file {WORKDIR}/epics.json \ + --sprints-file {WORKDIR}/sprints.json \ + --output {WORKDIR}/spikes.json +``` + +#### 3c — Verify + +Read `{WORKDIR}/epics.json` and verify: `epic_keys` is a non-empty array, `feature_to_epics` is an object, and `epics` is an array. If any check fails, warn the user with a descriptive error and stop. + +--- + +### Phase 4: Story + Bug Collection (inline) + +This is the new data collection phase. It fetches story-level data needed for capacity, assignment, and bug load checks. + +#### 4a — Fetch Stories Under Epics + +Read `{WORKDIR}/epics.json`. Extract `epic_keys`. + +Split epic keys into batches of 20. For each batch, call `jira_search`: + +- **JQL:** `project in (OCPEDGE, USHIFT, OCPBUGS) AND parent in ({epic_keys_batch_csv}) ORDER BY priority ASC` +- **Fields:** `key, summary, status, issuetype, priority, assignee, labels, updated, parent, customfield_10028, customfield_10021, issuelinks` +- **limit:** `50` + +Paginate using `page_token`. After all pages fetched, note all persisted file paths and run: + +```bash +python3 plugins/edge-scrum/bin/transform-stories.py \ + --input \ + --output {WORKDIR}/stories.json \ + --today {TODAY} +``` + +#### 4b — Fetch Unlinked Bugs + +Call `jira_search` with: + +- **JQL:** `project = OCPBUGS AND component in ("Installer / Single Node OpenShift", "Two Node with Arbiter", "Two Node Fencing", "Logical Volume Manager Storage", "MicroShift", "MicroShift / Networking", "MicroShift / Storage") AND fixVersion in ("{VERSION}", "{VERSION}.0") AND "Epic Link" is EMPTY ORDER BY priority ASC` +- **Fields:** `key, summary, status, priority, assignee, components, labels, updated` +- **limit:** `50` + +Paginate using `page_token`. After all pages fetched, run: + +```bash +python3 plugins/edge-scrum/bin/transform-bugs.py \ + --input \ + --output {WORKDIR}/bugs.json \ + --today {TODAY} +``` + +#### 4c — Verify + +Read `{WORKDIR}/stories.json` and `{WORKDIR}/bugs.json`. Verify they parse correctly and contain expected top-level keys (`total_stories`, `stories`, `total_bugs`, `bugs`). An empty stories or bugs list is valid (not an error). + +--- + +### Pagination Protocol + +This Jira instance uses `page_token` pagination, NOT `start_at`. Follow this protocol for all paginated MCP calls: + +1. Make the first call without `page_token` +2. The response may be persisted to a file. Note the file path. +3. Run `check-page.py` to get pagination info: + + ```bash + python3 plugins/edge-scrum/bin/check-page.py + ``` + + Output: `{"issues_count": N, "has_more": bool, "next_page_token": "..."}` +4. If `has_more` is `true`: make the next call with `page_token` set to the `next_page_token` value. Repeat from step 2. +5. If `has_more` is `false`: pagination is complete. + +For small responses that fit in context (not persisted), write them to `{WORKDIR}/raw__.json` using `Write`, then run `check-page.py` on that file. + +--- + +### Phase 5a: Run Checks (deterministic) + +Run the planning risk checks script. This performs the data-quality gate and all 6 checks deterministically — no LLM needed: + +```bash +python3 plugins/edge-scrum/bin/run-checks.py \ + --features {WORKDIR}/features.json \ + --epics {WORKDIR}/epics.json \ + --stories {WORKDIR}/stories.json \ + --bugs {WORKDIR}/bugs.json \ + --roster plugins/edge-scrum/.roster.json \ + --remaining-sprints {REMAINING_SPRINT_COUNT} \ + --component-filter "{COMPONENT_FILTER}" \ + --output {WORKDIR}/checks.json +``` + +Verify `{WORKDIR}/checks.json` was written and contains a `meta` key. + +### Phase 5b: Recommendations (sub-agent) + +Read `plugins/edge-scrum/skills/release-planning-analysis/SKILL.md`. Substitute `{WORKDIR}` and `{VERSION}`, then spawn as a sub-agent. + +This agent reads `checks.json` (pre-computed numbers) and writes `{WORKDIR}/recommendations.json` with narrative recommendations. It does NOT compute any numbers — only interprets and advises. + +Verify `{WORKDIR}/recommendations.json` was written and contains `executive_summary`. + +--- + +### Step 6: Assemble Report (main context) + +Run the report assembly script. This renders both markdown and DOCX from structured data — no markdown parsing needed: + +```bash +python3 plugins/edge-scrum/bin/assemble-report.py \ + --checks {WORKDIR}/checks.json \ + --recommendations {WORKDIR}/recommendations.json \ + --template plugins/edge-scrum/references/release-planning-report-template.md \ + --version {VERSION} \ + --today {TODAY} \ + --first-sprint {FIRST} \ + --last-sprint {LAST} \ + --pencils-down {PENCILS_DOWN} \ + --remaining-sprints {REMAINING_SPRINT_COUNT} \ + --total-dev-sprints {TOTAL_DEV_SPRINTS} \ + --output .reports/release_planning_{VERSION}_{TODAY} +``` + +This produces both `.reports/release_planning_{VERSION}_{TODAY}.md` and `.reports/release_planning_{VERSION}_{TODAY}.docx` with styled tables, risk-level coloring, and Jira hyperlinks. + +Clean up: `test -n "{WORKDIR}" && [[ "{WORKDIR}" == /tmp/release-planning-* ]] && rm -rf -- "{WORKDIR}"` + +--- + +## Edge Cases + +- **No Features found**: Try fallback JQL (handled in Phase 2b); warn user to confirm scope; stop if still empty. +- **Feature with no Epics**: Flagged by data-quality gate as FAIL — "no epics created." +- **Epic with no Stories**: Flagged by data-quality gate as FAIL — "epic has no stories." +- **Component filter matches no features**: Warn user and exit cleanly — "No features found for component {COMPONENT_FILTER}." +- **stories.json empty**: Data-quality gate flags all features as FAIL — no numeric projections possible. +- **bugs.json empty**: Bug load check reports no issues — not an error. +- **Sprint data unavailable**: transform-sprints.py sets `"error"` in JSON; main context stops before Phase 3. +- **Version format varies** (`5.0` vs `5.0.0`): Bug JQL tries both `fixVersion in ("{VERSION}", "{VERSION}.0")`. +- **Unlinked bugs (no epic)**: Collected in Phase 4b, reported separately in Bug Load check. + +--- + +## Important Notes + +- **Read-only**: This skill does not modify any Jira data. +- **Transform scripts**: `plugins/edge-scrum/bin/` — reusable data transformation (no LLM needed) +- **Analysis sub-agent**: `plugins/edge-scrum/skills/release-planning-analysis/SKILL.md` — LLM-driven risk assessment +- **Work directory**: `{WORKDIR}` persists across phases within a run. Rerunning on the same day overwrites prior files. +- **Laws files**: Authoritative for all team conventions. Never hardcode roster, rules, or sizing in skill definitions. +- **Data-quality gate**: MUST run before capacity and timeline checks. Features without story-level breakdown are excluded from numeric projections.