diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 13dbb86..94e97a4 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -21,7 +21,6 @@ jobs: with: python-version: "3.12" - run: python3 skill-reviewer/scripts/pre-review-checks.py --all --repo-root . - - run: python3 -m unittest discover -s _shared/scripts -p 'test_*.py' -v skillsaw: name: Skillsaw Lint diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..2800af6 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,24 @@ +name: Test + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +jobs: + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - run: python3 -m unittest discover -s _shared/scripts -p 'test_*.py' -v + - run: python3 -m unittest discover -s triage/scripts -p 'test_*.py' -v diff --git a/triage/SKILL.md b/triage/SKILL.md index c59552c..4c82e25 100644 --- a/triage/SKILL.md +++ b/triage/SKILL.md @@ -1,6 +1,6 @@ --- name: triage -version: 0.1.0 +version: 0.2.0 description: >- Bulk-triage unresolved Jira bugs with AI-driven recommendations and an interactive HTML report. Scan also loads recently resolved bugs for regression diff --git a/triage/guidelines.md b/triage/guidelines.md index 4bad58b..f9c5147 100644 --- a/triage/guidelines.md +++ b/triage/guidelines.md @@ -39,7 +39,7 @@ Artifacts go in `.artifacts/triage/{project}/`. | Start | `jira_search` | `mkdir` (create artifact dir) | | Scan | `jira_search` | Write `issues.json` and `resolved.json` | | Analyze | none | Read `issues.json`, read `resolved.json` (if present), write `analyzed.json` | -| Report | none | Read `analyzed.json`, read `templates/report.html`, write `report.html` | +| Report | none | Read `analyzed.json`, read `issues.json` (for Jira URL); run `render_report.py`; write `ai-synthesis.json`, `report.html` | | Assess (`/assess`) | `jira_search` | Optionally read `issues.json`; no required artifact writes | Any tool not listed above is **prohibited** in that phase. If a phase needs data not available through its allowed tools, stop and ask the user. diff --git a/triage/scripts/render_report.py b/triage/scripts/render_report.py new file mode 100644 index 0000000..82006f5 --- /dev/null +++ b/triage/scripts/render_report.py @@ -0,0 +1,273 @@ +#!/usr/bin/env python3 +"""Render a triage report by filling an HTML template with analyzed data. + +Replaces placeholder tokens in the HTML template with data from the +analysis phase and AI-generated synthesis, producing a single self-contained +HTML file that can be opened in any browser. + +Usage: + render_report.py --analyzed PATH --template PATH --jira-url URL + --ai-input PATH --output PATH [--project-key KEY] + +Exit codes: + 0 — report rendered successfully + 1 — invalid or missing input (file not found, malformed JSON) + 2 — unreplaced placeholders remain in the output +""" + +from __future__ import annotations + +import argparse +import html +import json +import re +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +# Placeholders the template expects, grouped by how their replacement +# values are serialized into the HTML. +_STRING_PLACEHOLDERS = ("PROJECT_KEY", "REPORT_DATE", "TOTAL_ISSUES") +_URL_PLACEHOLDERS = ("JIRA_BASE_URL",) +_JSON_PLACEHOLDERS = ( + "ISSUES_JSON", + "CLUSTERS_JSON", + "KEY_RECOMMENDATIONS_JSON", + "EXECUTIVE_SUMMARY_JSON", + "RELEASE_RISK_JSON", +) +ALL_PLACEHOLDERS = _STRING_PLACEHOLDERS + _URL_PLACEHOLDERS + _JSON_PLACEHOLDERS + +# Matches the exact placeholder tokens used in the template. Must not +# match JavaScript's {} empty-object literals or CSS var(...) values. +_PLACEHOLDER_RE = re.compile( + r"\{(" + "|".join(re.escape(p) for p in ALL_PLACEHOLDERS) + r")\}" +) + + +def _read_json(path: Path, label: str) -> Any: + """Read and parse a JSON file, raising SystemExit on failure.""" + try: + text = path.read_text(encoding="utf-8") + except FileNotFoundError: + print(f"Error: {label} not found: {path}", file=sys.stderr) + raise SystemExit(1) + except OSError as exc: + print(f"Error: cannot read {label}: {exc}", file=sys.stderr) + raise SystemExit(1) + + try: + return json.loads(text) + except json.JSONDecodeError as exc: + print(f"Error: {label} is not valid JSON: {exc}", file=sys.stderr) + raise SystemExit(1) + + +def _read_text(path: Path, label: str) -> str: + """Read a text file, raising SystemExit on failure.""" + try: + return path.read_text(encoding="utf-8") + except FileNotFoundError: + print(f"Error: {label} not found: {path}", file=sys.stderr) + raise SystemExit(1) + except OSError as exc: + print(f"Error: cannot read {label}: {exc}", file=sys.stderr) + raise SystemExit(1) + + +def extract_project_key(issues: list[dict]) -> str | None: + """Derive the Jira project key from the first issue's key. + + Returns None if the issue list is empty or the key has no hyphen. + + >>> extract_project_key([{"key": "EDM-1234"}]) + 'EDM' + >>> extract_project_key([]) + """ + if not issues: + return None + key = issues[0].get("key", "") + if "-" in key: + return key.rsplit("-", 1)[0] + return None + + +def _json_for_script_block(data: Any) -> str: + """Serialize data as compact JSON safe for embedding in an HTML `` from closing + the block early. ``\\/`` is a valid JSON escape per RFC 8259. + * ``"}] + r = render_report.build_replacements( + analyzed={"issues": issues, "clusters": [], "keyRecommendations": []}, + ai_input=SAMPLE_AI_INPUT, + jira_url="https://x.com", + ) + self.assertNotIn("") + + def test_null_release_risk(self) -> None: + r = render_report.build_replacements( + analyzed=SAMPLE_ANALYZED, + ai_input={"executiveSummary": [], "releaseRisk": None}, + jira_url="https://x.com", + ) + self.assertEqual(r["RELEASE_RISK_JSON"], "null") + + def test_project_key_html_escaped(self) -> None: + """PROJECT_KEY is embedded in HTML text nodes and must be escaped.""" + r = render_report.build_replacements( + analyzed=SAMPLE_ANALYZED, + ai_input=SAMPLE_AI_INPUT, + jira_url="https://x.com", + project_key="", + ) + self.assertNotIn(" in the URL must not break the script block.""" + r = render_report.build_replacements( + analyzed=SAMPLE_ANALYZED, + ai_input=SAMPLE_AI_INPUT, + jira_url="https://x.com/", + ) + self.assertNotIn("", r["JIRA_BASE_URL"]) + + def test_empty_executive_summary(self) -> None: + r = render_report.build_replacements( + analyzed=SAMPLE_ANALYZED, + ai_input={"executiveSummary": [], "releaseRisk": None}, + jira_url="https://x.com", + ) + self.assertEqual(json.loads(r["EXECUTIVE_SUMMARY_JSON"]), []) + + +class TestRender(unittest.TestCase): + def test_all_placeholders_replaced(self) -> None: + replacements = render_report.build_replacements( + analyzed=SAMPLE_ANALYZED, + ai_input=SAMPLE_AI_INPUT, + jira_url="https://issues.redhat.com", + ) + html, _ = render_report.render(MINIMAL_TEMPLATE, replacements) + + for name in render_report.ALL_PLACEHOLDERS: + self.assertNotIn( + "{" + name + "}", + html, + f"placeholder {{{name}}} was not replaced", + ) + + def test_values_appear_in_output(self) -> None: + replacements = render_report.build_replacements( + analyzed=SAMPLE_ANALYZED, + ai_input=SAMPLE_AI_INPUT, + jira_url="https://issues.redhat.com", + ) + html, _ = render_report.render(MINIMAL_TEMPLATE, replacements) + + self.assertIn("EDM", html) + self.assertIn("issues.redhat.com", html) + self.assertIn("EDM-101", html) + self.assertIn("Bug one", html) + + def test_no_double_replacement(self) -> None: + """A replacement value containing placeholder-like text must not + be re-expanded.""" + template = "
{PROJECT_KEY}
" + replacements = {"PROJECT_KEY": "VALUE_WITH_{ISSUES_JSON}_INSIDE"} + # Only PROJECT_KEY is in the mapping; ISSUES_JSON should not + # cause a KeyError or secondary replacement. + html, _ = render_report.render(template, replacements) + self.assertIn("VALUE_WITH_{ISSUES_JSON}_INSIDE", html) + + def test_javascript_braces_untouched(self) -> None: + """JavaScript empty-object literals ({}) must survive rendering.""" + template = "var x = {}; var y = {PROJECT_KEY};" + replacements = render_report.build_replacements( + analyzed=SAMPLE_ANALYZED, + ai_input=SAMPLE_AI_INPUT, + jira_url="https://x.com", + ) + html, _ = render_report.render(template, replacements) + self.assertIn("var x = {};", html) + + def test_missing_key_leaves_placeholder(self) -> None: + """A placeholder with no matching key is left intact and reported.""" + template = "{PROJECT_KEY} and {ISSUES_JSON}" + html, missing = render_report.render(template, {"PROJECT_KEY": "EDM"}) + self.assertIn("EDM", html) + self.assertIn("{ISSUES_JSON}", html) + self.assertEqual(missing, ["ISSUES_JSON"]) + + +class TestRenderMissingDetection(unittest.TestCase): + """Verify that render() reports missing placeholders without being + fooled by placeholder-shaped text in replacement values.""" + + def test_all_present_returns_empty_missing(self) -> None: + replacements = render_report.build_replacements( + analyzed=SAMPLE_ANALYZED, + ai_input=SAMPLE_AI_INPUT, + jira_url="https://x.com", + ) + _, missing = render_report.render(MINIMAL_TEMPLATE, replacements) + self.assertEqual(missing, []) + + def test_detects_missing_placeholder(self) -> None: + _, missing = render_report.render("{PROJECT_KEY}", {}) + self.assertEqual(missing, ["PROJECT_KEY"]) + + def test_javascript_braces_not_flagged(self) -> None: + _, missing = render_report.render("var x = {};", {}) + self.assertEqual(missing, []) + + def test_placeholder_in_data_not_flagged(self) -> None: + """Issue data containing {PROJECT_KEY} must not trigger a false + positive — validation happens on the template, not the output.""" + issues = [{"key": "X-1", "summary": "Bug about {PROJECT_KEY}"}] + replacements = render_report.build_replacements( + analyzed={"issues": issues, "clusters": [], "keyRecommendations": []}, + ai_input=SAMPLE_AI_INPUT, + jira_url="https://x.com", + ) + _, missing = render_report.render(MINIMAL_TEMPLATE, replacements) + self.assertEqual(missing, []) + + +# --------------------------------------------------------------------------- +# Integration tests — file I/O through main() +# --------------------------------------------------------------------------- + + +class TestMain(unittest.TestCase): + def _run(self, tmpdir: Path, **overrides: str) -> int: + """Set up standard fixture files and run main(). + + Only creates default fixture files when no override is provided + for that input, preventing the default from clobbering a file + the test wrote at the same path. + """ + if "analyzed" not in overrides: + _write_json(tmpdir, "analyzed.json", SAMPLE_ANALYZED) + if "ai_input" not in overrides: + _write_json(tmpdir, "ai-input.json", SAMPLE_AI_INPUT) + if "template" not in overrides: + _write_text(tmpdir, "template.html", MINIMAL_TEMPLATE) + + analyzed_path = overrides.get("analyzed", str(tmpdir / "analyzed.json")) + ai_input_path = overrides.get("ai_input", str(tmpdir / "ai-input.json")) + template_path = overrides.get("template", str(tmpdir / "template.html")) + output_path = overrides.get("output", str(tmpdir / "output" / "report.html")) + + argv = [ + "--analyzed", analyzed_path, + "--template", template_path, + "--jira-url", overrides.get("jira_url", "https://issues.redhat.com"), + "--ai-input", ai_input_path, + "--output", output_path, + ] + if "project_key" in overrides: + argv.extend(["--project-key", overrides["project_key"]]) + + return render_report.main(argv) + + def test_success(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + rc = self._run(Path(tmpdir)) + self.assertEqual(rc, 0) + output = (Path(tmpdir) / "output" / "report.html").read_text() + self.assertIn("EDM", output) + self.assertIn("EDM-101", output) + + def test_creates_output_directory(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + deep_output = Path(tmpdir) / "a" / "b" / "c" / "report.html" + rc = self._run(Path(tmpdir), output=str(deep_output)) + self.assertEqual(rc, 0) + self.assertTrue(deep_output.exists()) + + def test_project_key_override(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + rc = self._run(Path(tmpdir), project_key="CUSTOM") + self.assertEqual(rc, 0) + output = (Path(tmpdir) / "output" / "report.html").read_text() + self.assertIn("CUSTOM", output) + + def test_missing_analyzed_file(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + with self.assertRaises(SystemExit) as ctx: + self._run(Path(tmpdir), analyzed="/nonexistent/analyzed.json") + self.assertEqual(ctx.exception.code, 1) + + def test_missing_template_file(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + with self.assertRaises(SystemExit) as ctx: + self._run(Path(tmpdir), template="/nonexistent/template.html") + self.assertEqual(ctx.exception.code, 1) + + def test_missing_ai_input_file(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + with self.assertRaises(SystemExit) as ctx: + self._run(Path(tmpdir), ai_input="/nonexistent/ai-input.json") + self.assertEqual(ctx.exception.code, 1) + + def test_malformed_json(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + bad = _write_text(Path(tmpdir), "bad.json", "not json at all") + with self.assertRaises(SystemExit) as ctx: + self._run(Path(tmpdir), analyzed=str(bad)) + self.assertEqual(ctx.exception.code, 1) + + def test_null_release_risk_renders_correctly(self) -> None: + with tempfile.TemporaryDirectory() as tmpdir: + ai_input = {"executiveSummary": [], "releaseRisk": None} + ai_path = _write_json(Path(tmpdir), "ai-input.json", ai_input) + rc = self._run(Path(tmpdir), ai_input=str(ai_path)) + self.assertEqual(rc, 0) + output = (Path(tmpdir) / "output" / "report.html").read_text() + self.assertIn("var R=null;", output) + + def test_renders_real_template(self) -> None: + """Render against the actual report.html template to catch + regressions in placeholder naming or template structure.""" + real_template = Path(__file__).resolve().parent.parent / "templates" / "report.html" + if not real_template.exists(): + self.skipTest("real template not found at expected path") + + with tempfile.TemporaryDirectory() as tmpdir: + analyzed_path = _write_json(Path(tmpdir), "analyzed.json", SAMPLE_ANALYZED) + ai_path = _write_json(Path(tmpdir), "ai-input.json", SAMPLE_AI_INPUT) + output_path = Path(tmpdir) / "report.html" + + rc = render_report.main([ + "--analyzed", str(analyzed_path), + "--template", str(real_template), + "--jira-url", "https://issues.redhat.com", + "--ai-input", str(ai_path), + "--output", str(output_path), + ]) + self.assertEqual(rc, 0) + html = output_path.read_text() + self.assertIn("", html) + self.assertIn("EDM-101", html) + self.assertNotIn("{PROJECT_KEY}", html) + self.assertNotIn("{JIRA_BASE_URL}", html) + + +if __name__ == "__main__": + unittest.main() diff --git a/triage/skills/report.md b/triage/skills/report.md index 1adcce5..dd97037 100644 --- a/triage/skills/report.md +++ b/triage/skills/report.md @@ -7,10 +7,15 @@ description: Generate a self-contained interactive HTML report from analyzed tri You are generating an interactive HTML report from the analyzed triage data. Your goal is to produce a **single HTML file** that can be opened in any browser — emailed or shared as-is with no additional data files. The template uses optional **Google Fonts** when online; offline, browsers fall back to system fonts. All CSS, JS, and issue data are inline or embedded. +Template rendering (placeholder replacement, validation) is handled by +`triage/scripts/render_report.py`. Your role is to locate the inputs, +synthesize the executive summary and release risk assessment, then invoke +the script to produce the final HTML. + ## Allowed Tools - **Jira MCP:** none — this phase works entirely from local artifact data -- **Local:** read `analyzed.json`, read `templates/report.html`, write `report.html` +- **Local:** read `analyzed.json`, read `issues.json` (for Jira base URL), write `ai-synthesis.json`, run `render_report.py`, read script output - **Prohibited:** all Jira tools (no MCP calls in this phase) ## Prerequisites @@ -23,25 +28,19 @@ If the file is missing, tell the user to run `/analyze` first. ## Process -### Step 1: Load Analyzed Data +### Step 1: Locate Inputs and Determine Jira Base URL Read the analyzed issues from `.artifacts/triage/{PROJECT}/analyzed.json`. -### Step 2: Read the HTML Template - -Read the template from `templates/report.html` (relative to the triage workflow root directory). CSS and JavaScript are inline; **Roboto** fonts may load from Google Fonts (optional). The data is embedded directly into the HTML as a JSON literal. - -### Step 3: Determine the Jira Base URL - The report links each issue key to its Jira page. To build these links, you need the Jira instance base URL (e.g. `https://mycompany.atlassian.net`). -The base URL should already be known from the `/scan` phase (extracted from `self` links in the `jira_search` response). Check if it was saved in `issues.json`. If not available, ask the user for their Jira instance URL. Do **not** call any Jira MCP tools in this phase. +The base URL should already be known from the `/scan` phase (extracted from `self` links in the `jira_search` response). Check if it was saved in `.artifacts/triage/{PROJECT}/issues.json` (the `jiraBaseUrl` field written by `/scan`). If not available, ask the user for their Jira instance URL. Do **not** call any Jira MCP tools in this phase. -### Step 4: Synthesize Executive Summary & Release Risk Assessment +### Step 2: Synthesize Executive Summary & Release Risk Assessment Using the complete `analyzed.json` data (issues, clusters, key recommendations, summary counts), generate two synthesis artifacts. These are produced here — during report generation — rather than during `/analyze`, because `/report` has the finalized dataset without context-window pressure. -#### 4a. Executive Summary +#### Executive Summary Produce an `executiveSummary` array — 3–5 bullet-point strings giving stakeholders a 30-second health assessment. @@ -67,9 +66,9 @@ Example: ] ``` -#### 4b. Release Risk Assessment +#### Release Risk Assessment -Produce a `releaseRiskAssessment` object that answers: "Based on the bug backlog alone, what is the risk of shipping now?" +Produce a `releaseRisk` object that answers: "Based on the bug backlog alone, what is the risk of shipping now?" **Important:** This covers **bug-backlog risk only** — not test coverage, feature completeness, or deployment readiness. @@ -120,47 +119,60 @@ Risk factor signals to evaluate (include only those present and material): Set to **null** when there is insufficient data (e.g. < 5 issues). -### Step 5: Populate the Template - -Replace the following placeholders in the template (the executive summary and release risk come from Step 4; all other data from `analyzed.json`): - -| Placeholder | Value | -|---|---| -| `{PROJECT_KEY}` | The Jira project key (e.g. `EDM`) | -| `{REPORT_DATE}` | Current date/time in ISO 8601 format | -| `{TOTAL_ISSUES}` | Total number of analyzed issues | -| `{JIRA_BASE_URL}` | The Jira instance base URL, without trailing slash | -| `{ISSUES_JSON}` | The full analyzed issues array serialized as JSON | -| `{CLUSTERS_JSON}` | The clusters array serialized as JSON | -| `{KEY_RECOMMENDATIONS_JSON}` | The key recommendations array serialized as JSON | -| `{EXECUTIVE_SUMMARY_JSON}` | The executive summary bullets array serialized as JSON | -| `{RELEASE_RISK_JSON}` | The release risk assessment object serialized as JSON (or literal `null`) | - -The `{ISSUES_JSON}` placeholder is replaced with the literal JSON array from `analyzed.json` (the `issues` field). The `{CLUSTERS_JSON}` placeholder is replaced with the `clusters` array. The `{EXECUTIVE_SUMMARY_JSON}` and `{RELEASE_RISK_JSON}` placeholders are replaced with the data generated in Step 4. This embeds all data directly in the HTML so the file is a single shareable artifact (no separate JSON files needed). - -Each issue object in the `issues` array must conform to the per-issue output schema defined in `analyze.md` (Per-Issue Output Schema section). +### Step 3: Write AI Synthesis and Render the Report -Each cluster object should include: `id`, `theme`, `issues` (array of keys), `suggestedLinkType`, `nextSteps` (array of strings). The report computes an **urgency score** per cluster at render time (sum of priority weights across member issues, using the best of Jira priority / `suggestedPriority` / `priorityMismatch.suggested`). Clusters are sorted from highest to lowest urgency score and the score is displayed on each card. +Write the executive summary and release risk assessment to: -The key recommendations array is a list of strings — actionable items for the team. - -The executive summary and release risk assessment are generated in Step 4 during report generation — not stored in `analyzed.json`. If either is empty or null, the corresponding report section hides automatically. - -### Step 6: Write the Report +``` +.artifacts/triage/{PROJECT}/ai-synthesis.json +``` -Write the populated HTML to: +Format: +```json +{ + "executiveSummary": [...], + "releaseRisk": { ... } or null +} ``` -.artifacts/triage/{PROJECT}/report.html + +**Data shape notes** (for understanding what the template JS expects): + +- Each issue object in `analyzed.json`'s `issues` array must conform to + the per-issue output schema defined in `analyze.md`. +- Each cluster object should include: `id`, `theme`, `issues` (array of + keys), `suggestedLinkType`, `nextSteps` (array of strings). The template + JS computes an urgency score per cluster at render time. +- If `executiveSummary` is empty or `releaseRisk` is `null`, the + corresponding report section hides automatically — these are valid + values, not errors. + +Then run the rendering script (`triage/scripts/render_report.py`). The +script replaces all template placeholders, validates the output, and +writes the final HTML file. + +Resolve `{AI_WORKFLOWS_ROOT}` as the git root of the ai-workflows +install (see `../../_shared/recipes/capture-provenance-event.md` for the +canonical resolution instructions). The `--analyzed`, `--ai-input`, and +`--output` paths are relative to the project root (CWD). + +```bash +python3 "{AI_WORKFLOWS_ROOT}/triage/scripts/render_report.py" \ + --analyzed .artifacts/triage/{PROJECT}/analyzed.json \ + --template "{AI_WORKFLOWS_ROOT}/triage/templates/report.html" \ + --jira-url "{JIRA_BASE_URL}" \ + --ai-input .artifacts/triage/{PROJECT}/ai-synthesis.json \ + --output .artifacts/triage/{PROJECT}/report.html ``` -Verify the output file is valid HTML by checking that: +If the script exits with a non-zero code, report the error to the user +and **stop** — do not proceed to Step 4: -- The `{ISSUES_JSON}` placeholder was replaced with actual JSON (not the literal string) -- The `{JIRA_BASE_URL}` placeholder was replaced with a real URL -- No other `{...}` placeholders remain in the output +- Exit 1: a required input file is missing or contains invalid JSON +- Exit 2: unreplaced placeholders remain in the output (indicates a + template/script mismatch) -### Step 7: Present Result +### Step 4: Present Result Tell the user where to find the report and what it contains: @@ -191,6 +203,7 @@ Features: ## Output +- `.artifacts/triage/{PROJECT}/ai-synthesis.json` — AI-generated executive summary and release risk - `.artifacts/triage/{PROJECT}/report.html` — single file with all data embedded (optional Google Fonts) - File path presented to the user