From 123e8de97f96ce653e403bd9ca68b3eef6c58048 Mon Sep 17 00:00:00 2001 From: Andy Dalton Date: Wed, 15 Jul 2026 15:58:54 -0400 Subject: [PATCH 1/7] Add render_report.py to offload triage report template fill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The triage report phase previously had the AI read a 1644-line HTML template, replace 9 placeholders, and write the result — burning ~20K output tokens on pure string substitution. A Python script now handles this deterministically. The AI still generates the executive summary and release risk assessment (which require judgment), writes them to ai-synthesis.json, then invokes the script for the mechanical template fill and validation. All placeholder values are escaped for their template context: HTML text nodes use html.escape(), the JS string literal uses JS-safe encoding, and JSON values embedded in (via \/) and "}] + 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, not KeyError.""" + template = "{PROJECT_KEY} and {ISSUES_JSON}" + html = render_report.render(template, {"PROJECT_KEY": "EDM"}) + self.assertIn("EDM", html) + self.assertIn("{ISSUES_JSON}", html) + + +class TestValidateNoUnreplaced(unittest.TestCase): + def test_clean_output(self) -> None: + self.assertEqual(render_report.validate_no_unreplaced("

hello

"), []) + + def test_detects_remaining_placeholder(self) -> None: + result = render_report.validate_no_unreplaced("

{PROJECT_KEY}

") + self.assertEqual(result, ["PROJECT_KEY"]) + + def test_ignores_javascript_braces(self) -> None: + self.assertEqual(render_report.validate_no_unreplaced("var x = {};"), []) + + def test_ignores_unknown_brace_patterns(self) -> None: + self.assertEqual( + render_report.validate_no_unreplaced("var counts = {};"), + [], + ) + + def test_catches_leftover_placeholder(self) -> None: + """Verify detection of a known placeholder still present in output.""" + html_with_leftover = "

Done

{PROJECT_KEY}" + remaining = render_report.validate_no_unreplaced(html_with_leftover) + self.assertEqual(remaining, ["PROJECT_KEY"]) + + +# --------------------------------------------------------------------------- +# 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..bd6eecd 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 +`../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`, 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. -### 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 +#### 2a. 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 +#### 2b. 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,58 @@ 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 (`../scripts/render_report.py` relative to +this skill file). The script replaces all template placeholders, validates +the output, and writes the final HTML file. Resolve the script and +template paths relative to the triage workflow directory — the `--analyzed`, +`--ai-input`, and `--output` paths are relative to the project root (CWD). + +```bash +python3 {triage_workflow_dir}/scripts/render_report.py \ + --analyzed .artifacts/triage/{PROJECT}/analyzed.json \ + --template {triage_workflow_dir}/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: +Where `{triage_workflow_dir}` is the triage workflow's installed location +(e.g., resolve the `triage` symlink under the workflows install directory). -- 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 +If the script exits with a non-zero code, report the error to the user: +- 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 +201,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 From f2aa7b3ae0b5a078bb34c7122b067664a1c2468b Mon Sep 17 00:00:00 2001 From: Andy Dalton Date: Thu, 16 Jul 2026 10:21:27 -0400 Subject: [PATCH 2/7] Address CodeRabbit review feedback on render_report.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Track missing placeholders during render() instead of scanning the final output, preventing false positives when issue data contains placeholder-shaped text like {PROJECT_KEY} - Remove validate_no_unreplaced() — its logic is now integrated into render(), which returns (html, missing) instead of just html - Make issues.json reference explicit in report.md Allowed Tools and Step 1, fixing the undeclared-input inconsistency Assisted-by: Claude Opus 4.6 (1M) --- triage/scripts/render_report.py | 35 +++++++++-------- triage/scripts/test_render_report.py | 59 +++++++++++++++++----------- triage/skills/report.md | 4 +- 3 files changed, 55 insertions(+), 43 deletions(-) diff --git a/triage/scripts/render_report.py b/triage/scripts/render_report.py index 1b485a7..ef4bf7d 100644 --- a/triage/scripts/render_report.py +++ b/triage/scripts/render_report.py @@ -161,28 +161,30 @@ def build_replacements( } -def render(template: str, replacements: dict[str, str]) -> str: - """Replace all placeholder tokens in the template. +def render(template: str, replacements: dict[str, str]) -> tuple[str, list[str]]: + """Replace placeholder tokens in the template, returning the result + and any placeholders that had no corresponding replacement value. Uses a single regex pass to replace all known placeholders at once, avoiding accidental double-replacement when a replacement value happens to contain a placeholder-shaped string. - Placeholders missing from *replacements* are left in place so that - ``validate_no_unreplaced`` can report them cleanly, rather than - raising an unhandled ``KeyError``. + Missing placeholders are tracked during rendering rather than by + scanning the final output, so replacement values that happen to + contain placeholder-shaped text (e.g., a Jira summary containing + ``{PROJECT_KEY}``) are never flagged as unreplaced. """ + missing: list[str] = [] def _sub(match: re.Match) -> str: name = match.group(1) - return replacements.get(name, match.group(0)) + if name in replacements: + return replacements[name] + missing.append(name) + return match.group(0) - return _PLACEHOLDER_RE.sub(_sub, template) - - -def validate_no_unreplaced(html: str) -> list[str]: - """Return a list of any known placeholder tokens still present.""" - return _PLACEHOLDER_RE.findall(html) + html = _PLACEHOLDER_RE.sub(_sub, template) + return html, missing def parse_args(argv: list[str] | None = None) -> argparse.Namespace: @@ -246,13 +248,12 @@ def main(argv: list[str] | None = None) -> int: project_key=args.project_key, ) - html = render(template, replacements) + html, missing = render(template, replacements) - remaining = validate_no_unreplaced(html) - if remaining: + if missing: print( - f"Error: {len(remaining)} unreplaced placeholder(s): " - f"{', '.join(sorted(set(remaining)))}", + f"Error: {len(missing)} unreplaced placeholder(s): " + f"{', '.join(sorted(set(missing)))}", file=sys.stderr, ) return 2 diff --git a/triage/scripts/test_render_report.py b/triage/scripts/test_render_report.py index 5b4b1b0..1579478 100644 --- a/triage/scripts/test_render_report.py +++ b/triage/scripts/test_render_report.py @@ -244,7 +244,7 @@ def test_all_placeholders_replaced(self) -> None: ai_input=SAMPLE_AI_INPUT, jira_url="https://issues.redhat.com", ) - html = render_report.render(MINIMAL_TEMPLATE, replacements) + html, _ = render_report.render(MINIMAL_TEMPLATE, replacements) for name in render_report.ALL_PLACEHOLDERS: self.assertNotIn( @@ -259,7 +259,7 @@ def test_values_appear_in_output(self) -> None: ai_input=SAMPLE_AI_INPUT, jira_url="https://issues.redhat.com", ) - html = render_report.render(MINIMAL_TEMPLATE, replacements) + html, _ = render_report.render(MINIMAL_TEMPLATE, replacements) self.assertIn("EDM", html) self.assertIn("issues.redhat.com", html) @@ -273,7 +273,7 @@ def test_no_double_replacement(self) -> None: 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) + html, _ = render_report.render(template, replacements) self.assertIn("VALUE_WITH_{ISSUES_JSON}_INSIDE", html) def test_javascript_braces_untouched(self) -> None: @@ -284,39 +284,50 @@ def test_javascript_braces_untouched(self) -> None: ai_input=SAMPLE_AI_INPUT, jira_url="https://x.com", ) - html = render_report.render(template, replacements) + 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, not KeyError.""" + """A placeholder with no matching key is left intact and reported.""" template = "{PROJECT_KEY} and {ISSUES_JSON}" - html = render_report.render(template, {"PROJECT_KEY": "EDM"}) + html, missing = render_report.render(template, {"PROJECT_KEY": "EDM"}) self.assertIn("EDM", html) self.assertIn("{ISSUES_JSON}", html) + self.assertEqual(missing, ["ISSUES_JSON"]) -class TestValidateNoUnreplaced(unittest.TestCase): - def test_clean_output(self) -> None: - self.assertEqual(render_report.validate_no_unreplaced("

hello

"), []) +class TestRenderMissingDetection(unittest.TestCase): + """Verify that render() reports missing placeholders without being + fooled by placeholder-shaped text in replacement values.""" - def test_detects_remaining_placeholder(self) -> None: - result = render_report.validate_no_unreplaced("

{PROJECT_KEY}

") - self.assertEqual(result, ["PROJECT_KEY"]) + 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_ignores_javascript_braces(self) -> None: - self.assertEqual(render_report.validate_no_unreplaced("var x = {};"), []) + def test_detects_missing_placeholder(self) -> None: + _, missing = render_report.render("{PROJECT_KEY}", {}) + self.assertEqual(missing, ["PROJECT_KEY"]) - def test_ignores_unknown_brace_patterns(self) -> None: - self.assertEqual( - render_report.validate_no_unreplaced("var counts = {};"), - [], - ) + def test_javascript_braces_not_flagged(self) -> None: + _, missing = render_report.render("var x = {};", {}) + self.assertEqual(missing, []) - def test_catches_leftover_placeholder(self) -> None: - """Verify detection of a known placeholder still present in output.""" - html_with_leftover = "

Done

{PROJECT_KEY}" - remaining = render_report.validate_no_unreplaced(html_with_leftover) - self.assertEqual(remaining, ["PROJECT_KEY"]) + 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, []) # --------------------------------------------------------------------------- diff --git a/triage/skills/report.md b/triage/skills/report.md index bd6eecd..51cbc99 100644 --- a/triage/skills/report.md +++ b/triage/skills/report.md @@ -15,7 +15,7 @@ the script to produce the final HTML. ## Allowed Tools - **Jira MCP:** none — this phase works entirely from local artifact data -- **Local:** read `analyzed.json`, write `ai-synthesis.json`, run `render_report.py`, read script output +- **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 @@ -34,7 +34,7 @@ Read the analyzed issues from `.artifacts/triage/{PROJECT}/analyzed.json`. 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 2: Synthesize Executive Summary & Release Risk Assessment From 0581b09f6fda71bde0be8fff44ae9059081eea79 Mon Sep 17 00:00:00 2001 From: Andy Dalton Date: Thu, 16 Jul 2026 10:33:09 -0400 Subject: [PATCH 3/7] Move unit tests from lint.yaml to their own test.yaml workflow Unit tests aren't linting. Split them into a separate workflow so they show up as a distinct "Unit Tests" check on PRs instead of being buried inside "Validate Structure." Assisted-by: Claude Opus 4.6 (1M) --- .github/workflows/lint.yaml | 2 -- .github/workflows/test.yaml | 24 ++++++++++++++++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/test.yaml diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index 4475dcd..94e97a4 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -21,8 +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 - - run: python3 -m unittest discover -s triage/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 From f84da695e3e4398ddd3e7311d28c5f4a859d8625 Mon Sep 17 00:00:00 2001 From: Andy Dalton Date: Thu, 16 Jul 2026 10:34:26 -0400 Subject: [PATCH 4/7] Rename local html variable to rendered to avoid shadowing import Assisted-by: Claude Opus 4.6 (1M) --- triage/scripts/render_report.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/triage/scripts/render_report.py b/triage/scripts/render_report.py index ef4bf7d..82006f5 100644 --- a/triage/scripts/render_report.py +++ b/triage/scripts/render_report.py @@ -183,8 +183,8 @@ def _sub(match: re.Match) -> str: missing.append(name) return match.group(0) - html = _PLACEHOLDER_RE.sub(_sub, template) - return html, missing + rendered = _PLACEHOLDER_RE.sub(_sub, template) + return rendered, missing def parse_args(argv: list[str] | None = None) -> argparse.Namespace: @@ -248,7 +248,7 @@ def main(argv: list[str] | None = None) -> int: project_key=args.project_key, ) - html, missing = render(template, replacements) + rendered, missing = render(template, replacements) if missing: print( @@ -259,7 +259,7 @@ def main(argv: list[str] | None = None) -> int: return 2 args.output.parent.mkdir(parents=True, exist_ok=True) - args.output.write_text(html, encoding="utf-8") + args.output.write_text(rendered, encoding="utf-8") project_key = replacements["PROJECT_KEY"] total = replacements["TOTAL_ISSUES"] From ec0a04182d034c6ba8aa8a5c05830bd4338795bc Mon Sep 17 00:00:00 2001 From: Andy Dalton Date: Thu, 16 Jul 2026 12:02:21 -0400 Subject: [PATCH 5/7] Use {AI_WORKFLOWS_ROOT} pattern for script paths in report.md Align with the convention established in PR #79 and _shared/recipes/capture-provenance-event.md: use {AI_WORKFLOWS_ROOT} instead of {triage_workflow_dir} or relative ../scripts/ paths. Also update the Report row in guidelines.md to reflect that the phase now runs render_report.py and writes ai-synthesis.json. Assisted-by: Claude Opus 4.6 (1M) --- triage/guidelines.md | 2 +- triage/skills/report.md | 22 +++++++++++----------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/triage/guidelines.md b/triage/guidelines.md index 4bad58b..86c9939 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 `scripts/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/skills/report.md b/triage/skills/report.md index 51cbc99..5c3286b 100644 --- a/triage/skills/report.md +++ b/triage/skills/report.md @@ -8,7 +8,7 @@ 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 -`../scripts/render_report.py`. Your role is to locate the inputs, +`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. @@ -147,24 +147,24 @@ Format: corresponding report section hides automatically — these are valid values, not errors. -Then run the rendering script (`../scripts/render_report.py` relative to -this skill file). The script replaces all template placeholders, validates -the output, and writes the final HTML file. Resolve the script and -template paths relative to the triage workflow directory — the `--analyzed`, -`--ai-input`, and `--output` paths are relative to the project root (CWD). +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 {triage_workflow_dir}/scripts/render_report.py \ +python3 "{AI_WORKFLOWS_ROOT}/triage/scripts/render_report.py" \ --analyzed .artifacts/triage/{PROJECT}/analyzed.json \ - --template {triage_workflow_dir}/templates/report.html \ + --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 ``` -Where `{triage_workflow_dir}` is the triage workflow's installed location -(e.g., resolve the `triage` symlink under the workflows install directory). - If the script exits with a non-zero code, report the error to the user: - Exit 1: a required input file is missing or contains invalid JSON - Exit 2: unreplaced placeholders remain in the output (indicates a From 61ffcbdc43d1c1dc2f42039867d68e4e9bc87262 Mon Sep 17 00:00:00 2001 From: Andy Dalton Date: Thu, 16 Jul 2026 14:37:45 -0400 Subject: [PATCH 6/7] Fix path references and quoting in report.md and guidelines.md - Drop directory prefix from render_report.py in guidelines.md tools table (it's an allowlist, not an invocation command) - Use relative path ../../_shared/recipes/ for the provenance recipe reference (valid from triage/skills/) - Quote {JIRA_BASE_URL} in the bash example to prevent shell interpretation of URL characters Assisted-by: Claude Opus 4.6 (1M) --- triage/guidelines.md | 2 +- triage/skills/report.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/triage/guidelines.md b/triage/guidelines.md index 86c9939..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 `issues.json` (for Jira URL); run `scripts/render_report.py`; write `ai-synthesis.json`, `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/skills/report.md b/triage/skills/report.md index 5c3286b..6d46f01 100644 --- a/triage/skills/report.md +++ b/triage/skills/report.md @@ -152,7 +152,7 @@ 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 +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). @@ -160,7 +160,7 @@ canonical resolution instructions). The `--analyzed`, `--ai-input`, and 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} \ + --jira-url "{JIRA_BASE_URL}" \ --ai-input .artifacts/triage/{PROJECT}/ai-synthesis.json \ --output .artifacts/triage/{PROJECT}/report.html ``` From 815889c4cc4f4c5024f886e8d6b0ec2c86d919d9 Mon Sep 17 00:00:00 2001 From: Andy Dalton Date: Thu, 16 Jul 2026 16:01:34 -0400 Subject: [PATCH 7/7] Fix step numbering and add abort on renderer failure in report.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove 2a/2b sub-step labels from Executive Summary and Release Risk Assessment subsections — they are mandatory sequential parts of Step 2, not conditional branches - Add explicit stop instruction when render_report.py exits non-zero, preventing the workflow from presenting a missing or invalid report Assisted-by: Claude Opus 4.6 (1M) --- triage/skills/report.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/triage/skills/report.md b/triage/skills/report.md index 6d46f01..dd97037 100644 --- a/triage/skills/report.md +++ b/triage/skills/report.md @@ -40,7 +40,7 @@ The base URL should already be known from the `/scan` phase (extracted from `sel 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. -#### 2a. Executive Summary +#### Executive Summary Produce an `executiveSummary` array — 3–5 bullet-point strings giving stakeholders a 30-second health assessment. @@ -66,7 +66,7 @@ Example: ] ``` -#### 2b. Release Risk Assessment +#### Release Risk Assessment Produce a `releaseRisk` object that answers: "Based on the bug backlog alone, what is the risk of shipping now?" @@ -165,7 +165,9 @@ python3 "{AI_WORKFLOWS_ROOT}/triage/scripts/render_report.py" \ --output .artifacts/triage/{PROJECT}/report.html ``` -If the script exits with a non-zero code, report the error to the user: +If the script exits with a non-zero code, report the error to the user +and **stop** — do not proceed to Step 4: + - 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)