From d901d5adb6650917f40d4df3b515bd97bf3bb243 Mon Sep 17 00:00:00 2001 From: readwrightexecute Date: Wed, 14 Oct 2026 13:20:03 +0000 Subject: [PATCH 1/3] Shape Jira epic export --- webapp/app/main.py | 10 +- webapp/app/wizard/flow.py | 202 ++++++++++++++++++++----- webapp/app/wizard/prompts/system.md.j2 | 2 +- webapp/app/wizard/sections.yaml | 52 ++++--- webapp/tests/test_download.py | 6 +- webapp/tests/test_flow_parse.py | 123 +++++++++++---- webapp/tests/test_jira_export.py | 17 +++ 7 files changed, 316 insertions(+), 96 deletions(-) diff --git a/webapp/app/main.py b/webapp/app/main.py index 7ff2678..f43d76e 100644 --- a/webapp/app/main.py +++ b/webapp/app/main.py @@ -1033,11 +1033,8 @@ def _jira_unauthorized() -> HTMLResponse: def _jira_summary(s) -> str: - """A concise issue summary derived from the idea.""" - idea = " ".join((s.idea or "").split()) - if not idea: - return "Incipit mega-prompt" - return f"Incipit brief: {idea[:120]}" + """A concise, outcome-based Epic summary.""" + return flow.jira_epic_summary(s) @app.get("/api/jira/projects", response_class=HTMLResponse) @@ -1083,7 +1080,8 @@ async def jira_export(request: Request, sid: str = Form(...), cloud_id = entry.meta.get("cloud_id", "") site_url = entry.meta.get("site_url", "") md = flow.assemble_final(s) - adf = markdown_adf.to_adf(md) + jira_projection = flow.assemble_jira_export(s) + adf = markdown_adf.to_adf(jira_projection) summary = _jira_summary(s) budget = config.JIRA_EXPORT_TIMEOUT_MS / 1000 diff --git a/webapp/app/wizard/flow.py b/webapp/app/wizard/flow.py index b64566d..39b0f68 100644 --- a/webapp/app/wizard/flow.py +++ b/webapp/app/wizard/flow.py @@ -858,6 +858,10 @@ def _plain_blocks(content: str) -> list[str]: return [line.strip(" -*\t") for line in content.splitlines() if line.strip()] +def _clean_label_value(text: str) -> str: + return re.sub(r"\s+", " ", (text or "").strip(" -*\t")).strip() + + _EMPH = r"(?:\*\*|__|\*|_|`)?" _FR_RE = re.compile( rf"^(?:[-*]\s*)?(?:\d+[.)]\s*)?{_EMPH}(FR-?\d+){_EMPH}\s*[:.)-]\s*{_EMPH}(.+)$", @@ -886,6 +890,79 @@ def _criteria_for_requirement(requirement_id: str, criteria: list[str]) -> list[ return matched +def _functional_requirements(s: Session) -> list[tuple[str, str]]: + req_section = _matching_section(s, "functional_requirements", "functional requirements", + "requirements") + return _labeled_blocks(req_section.content, _FR_RE) if req_section else [] + + +def _acceptance_criteria(s: Session) -> list[str]: + ac_section = _matching_section(s, "acceptance_criteria", "acceptance criteria") + criteria = [ + f"{label}: {text}" for label, text in _labeled_blocks(ac_section.content, _AC_RE) + ] if ac_section else [] + if ac_section and not criteria: + criteria = _plain_blocks(ac_section.content) + return criteria + + +def _labeled_value(content: str, label: str) -> str: + prefix = f"{label.lower()}:" + for raw_line in content.splitlines(): + line = re.sub(r"^[-*]\s+", "", raw_line.strip()) + line = line.replace("**", "").replace("__", "").strip() + if line.lower().startswith(prefix): + return _clean_label_value(line.split(":", 1)[1]) + return "" + + +_GENERIC_EPIC_TITLES = { + "incipit mega-prompt", + "incipit megaprompt", + "mega-prompt", + "megaprompt", + "implementation brief", + "jira epic", +} + + +def _is_generic_epic_summary(value: str) -> bool: + cleaned = _clean_label_value(value).lower().strip(" .") + return not cleaned or cleaned in _GENERIC_EPIC_TITLES + + +def _fallback_epic_summary(s: Session) -> str: + idea = _clean_label_value(s.idea) + if not idea: + return "Implement scoped product outcome" + # Keep Jira summaries compact and title-like, but do not add Incipit branding. + return idea[:120].rstrip(" .") + + +def jira_epic_summary(s: Session) -> str: + """Return the separately parseable Jira Epic title, falling back safely.""" + overview = _matching_section(s, "goals", "overview", "background") + if overview: + for label in ("Epic Summary", "Summary"): + value = _labeled_value(overview.content, label) + if value and not _is_generic_epic_summary(value): + return value[:255] + return _fallback_epic_summary(s)[:255] + + +def _epic_outcome(s: Session) -> str: + overview = _matching_section(s, "goals", "overview", "background") + if overview: + for label in ("Outcome", "Planning Summary"): + value = _labeled_value(overview.content, label) + if value: + return value + bullets = _plain_blocks(overview.content) + if bullets: + return bullets[0] + return _fallback_epic_summary(s) + + @dataclass(frozen=True) class JiraStoryDraft: key: str @@ -896,15 +973,8 @@ class JiraStoryDraft: def jira_story_drafts(s: Session) -> list[JiraStoryDraft]: """Extract the child stories that belong under the Jira Epic export.""" - req_section = _matching_section(s, "functional_requirements", "functional requirements", - "requirements") - ac_section = _matching_section(s, "acceptance_criteria", "acceptance criteria") - requirements = _labeled_blocks(req_section.content, _FR_RE) if req_section else [] - criteria = [ - f"{label}: {text}" for label, text in _labeled_blocks(ac_section.content, _AC_RE) - ] if ac_section else [] - if ac_section and not criteria: - criteria = _plain_blocks(ac_section.content) + requirements = _functional_requirements(s) + criteria = _acceptance_criteria(s) if requirements: return [ @@ -928,15 +998,8 @@ def jira_story_drafts(s: Session) -> list[JiraStoryDraft]: def jira_unmatched_acceptance_criteria(s: Session) -> list[str]: - req_section = _matching_section(s, "functional_requirements", "functional requirements", - "requirements") - ac_section = _matching_section(s, "acceptance_criteria", "acceptance criteria") - requirements = _labeled_blocks(req_section.content, _FR_RE) if req_section else [] - criteria = [ - f"{label}: {text}" for label, text in _labeled_blocks(ac_section.content, _AC_RE) - ] if ac_section else [] - if ac_section and not criteria: - criteria = _plain_blocks(ac_section.content) + requirements = _functional_requirements(s) + criteria = _acceptance_criteria(s) if not requirements: return [] requirement_ids = {_normalize_req_id(req_id) for req_id, _ in requirements} @@ -946,42 +1009,103 @@ def jira_unmatched_acceptance_criteria(s: Session) -> list[str]: ] +def jira_uncovered_functional_requirements(s: Session) -> list[str]: + requirements = _functional_requirements(s) + criteria = _acceptance_criteria(s) + if not requirements or not criteria: + return [req_id for req_id, _ in requirements] + return [ + req_id for req_id, _ in requirements + if not _criteria_for_requirement(req_id, criteria) + ] + + +def jira_acceptance_criteria_reference_errors(s: Session) -> list[str]: + requirements = _functional_requirements(s) + if not requirements: + return [] + known = {_normalize_req_id(req_id) for req_id, _ in requirements} + errors: list[str] = [] + for criterion in _acceptance_criteria(s): + unknown = sorted(_referenced_req_ids(criterion) - known) + if unknown: + errors.append(f"{criterion} references unknown {', '.join(unknown)}") + return errors + + +def jira_export_validation_notes(s: Session) -> list[str]: + """Deterministic checks for the Jira projection; warnings, not blockers.""" + notes: list[str] = [] + overview = _matching_section(s, "goals", "overview", "background") + explicit_summary = _labeled_value(overview.content, "Epic Summary") if overview else "" + if _is_generic_epic_summary(explicit_summary): + notes.append("Epic Summary is missing or generic; Jira will use a fallback from the idea.") + stories = jira_story_drafts(s) + if not stories: + notes.append("No child stories could be derived.") + for req_id in jira_uncovered_functional_requirements(s): + notes.append(f"{_normalize_req_id(req_id)} has no mapped acceptance criteria.") + notes.extend(jira_acceptance_criteria_reference_errors(s)) + for story in stories: + if len(story.summary) > 220: + notes.append(f"{_normalize_req_id(story.key)} may be too large for a story-sized Jira issue.") + return notes + + def assemble_jira_export(s: Session) -> str: - """Build a generic Jira-friendly markdown export without calling Jira.""" + """Build the Jira-specific Epic projection without calling Jira.""" + stories = jira_story_drafts(s) + functional_requirements = _functional_requirements(s) + out_of_scope = _matching_section(s, "out_of_scope", "out of scope") + tech = _matching_section(s, "tech_constraints", "tech constraints", "stack") + assumptions = [ + line for line in _plain_blocks(tech.content) + if "[ASSUMPTION]" in line.upper() + ] if tech else [] + scope_items = [f"{_normalize_req_id(req_id)}: {text}" for req_id, text in functional_requirements] + success_metrics = _acceptance_criteria(s) + validation_notes = jira_export_validation_notes(s) parts = [ "# Jira Export", "", - "Markdown-only planning export. Create or edit Jira issues manually using the sections below.", + "Jira-specific Epic projection. Attach the full Implementation Brief as `mega-prompt.md`.", "", "## Epic", "", - f"**Summary:** {s.idea}", - f"**Project type:** {s.project_type}", - f"**Stakes:** {s.stakes}", - f"**Form factor:** {s.form_factor}", + f"Summary: {jira_epic_summary(s)}", + f"Outcome: {_epic_outcome(s)}", + "MVP Scope:", ] + parts += [f"- {item}" for item in (scope_items or ["Use the child stories below as the MVP scope."])] + parts += ["", "Out of Scope:"] + parts += [f"- {item}" for item in ( + _plain_blocks(out_of_scope.content) if out_of_scope and out_of_scope.content else + ["No explicit out-of-scope items were captured."] + )] + parts += ["", "Success Metrics:"] + parts += [f"- {item}" for item in (success_metrics or ["No explicit success metrics were captured."])] + parts += ["", "Dependencies / Assumptions:"] if s.project_type == "existing" and s.repo_url: - parts.append(f"**Existing repository:** {s.repo_url}") - parts += ["", "## Stories", ""] + parts.append(f"- Existing repository: {s.repo_url}") + parts += [f"- {item}" for item in (assumptions or ["No explicit dependencies or assumptions were captured."])] + parts += ["", "## Child Stories", ""] - for story in jira_story_drafts(s): + for story in stories: parts += [ - f"### Story {story.key}", - "", - f"**Summary:** {story.summary}", - "", - "**Description:**", - story.description, - "", + f"- Story summary: {_normalize_req_id(story.key)}: {story.summary}", + f" - User value: {story.description}", + " - Acceptance criteria:", ] if story.acceptance_criteria: - parts += ["**Acceptance Criteria:**", ""] - parts += [f"- {criterion}" for criterion in story.acceptance_criteria] - parts.append("") + parts += [f" - {criterion}" for criterion in story.acceptance_criteria] + else: + parts.append(" - [NEEDS AC COVERAGE] Add criteria before implementation.") remaining_criteria = jira_unmatched_acceptance_criteria(s) if remaining_criteria: - parts += ["### Unmatched Acceptance Criteria", ""] + parts += ["", "## Unmatched Acceptance Criteria", ""] parts += [f"- {criterion}" for criterion in remaining_criteria] - parts.append("") + if validation_notes: + parts += ["", "## Validation Notes", ""] + parts += [f"- {note}" for note in validation_notes] return "\n".join(parts).rstrip() + "\n" diff --git a/webapp/app/wizard/prompts/system.md.j2 b/webapp/app/wizard/prompts/system.md.j2 index 969f822..f4ae69a 100644 --- a/webapp/app/wizard/prompts/system.md.j2 +++ b/webapp/app/wizard/prompts/system.md.j2 @@ -1 +1 @@ -You are a senior product manager helping turn a rough software idea into a precise, structured specification. You write tight, concrete, testable specs — never marketing fluff. You follow output format instructions exactly: no preamble, no closing remarks, no markdown code fences around the whole answer. When input text contains the two-character sequence "\n", treat it as a line break. +You are a senior product manager helping turn a rough software idea into a precise, structured specification. You write tight, concrete, testable specs — never marketing fluff. You follow output format instructions exactly: no preamble, no closing remarks, no markdown code fences around the whole answer. Do not introduce numeric thresholds, vendors, protocols, compliance claims, deployment choices, or resource targets as facts unless they come from the idea, repository context, or clarifications; otherwise mark them "[ASSUMPTION]". When input text contains the two-character sequence "\n", treat it as a line break. diff --git a/webapp/app/wizard/sections.yaml b/webapp/app/wizard/sections.yaml index 98d18cc..29e7746 100644 --- a/webapp/app/wizard/sections.yaml +++ b/webapp/app/wizard/sections.yaml @@ -4,28 +4,37 @@ sections: - id: goals title: Goals & Background instruction: > - Write a short "Goals & Background" section: 3-6 bullet points capturing the - desired outcomes, followed by one short paragraph of background context - explaining why this is being built. Capability language only — no - implementation details. + Write a short "Goals & Background" section. Start with exactly two + parseable lines: "Epic Summary: " + and "Outcome: ". The Epic Summary must + be specific to this work and must not be generic text such as "Incipit + mega-prompt" or "Implementation Brief". Then add a "Planning Summary:" + Markdown bullet list with 3-5 bullets capturing desired outcomes, followed + by one short background paragraph explaining why this is being built. + Capability language only — no implementation details. - id: functional_requirements title: Functional Requirements instruction: > - Write the functional requirements as a numbered list FR1, FR2, ... Each - requirement must be a single testable statement of WHAT the system does - (e.g. "FR3: Users can export the result as a markdown file"). Cover the + Write the functional requirements as a Markdown list with stable IDs FR1, + FR2, ... Each item must use SHALL and be a single story-sized, testable + statement of WHAT the system does (e.g. "- FR3: The system SHALL let users + export the result as a markdown file"). Child Jira stories are derived + from these FRs, so do not bundle unrelated behavior into one FR. Cover the core happy path first, then secondary behaviors. 5-12 requirements depending on stakes. Do not include performance/quality attributes here. - id: non_functional_requirements title: Non-Functional Requirements instruction: > - Write the non-functional requirements as a numbered list NFR1, NFR2, ... - covering only the attributes that matter for these stakes: performance, - security/auth, reliability, resource constraints, compatibility. Keep it - to 3-7 items. Each must be concrete and checkable (e.g. "NFR2: First page - load under 2s on LAN"), not aspirational fluff. + Write the non-functional requirements as a Markdown list with stable IDs + NFR1, NFR2, ... Each item must use SHALL and cover only attributes that + matter for these stakes: performance, security/auth, reliability, resource + constraints, compatibility. Keep it to 3-7 items. Each must be concrete + and checkable, not aspirational fluff. Do not invent numeric thresholds, + protocols, vendors, compliance claims, deployment choices, or resource + targets unless they came from the idea, repository context, or + clarifications; otherwise mark the item "[ASSUMPTION]". - id: tech_constraints title: Tech Constraints & Stack @@ -34,17 +43,22 @@ sections: the technology choices and constraints a coding agent must respect — language/runtime, frameworks, storage, deployment target, existing systems to integrate with, and anything explicitly ruled out. Derive choices from - the user's answers; where the user expressed no preference, choose the - simplest mainstream option and mark it "[ASSUMPTION]". + the user's answers, repository context, or prior sections. Do not present + vendors, protocols, compliance requirements, deployment targets, or + infrastructure choices as facts unless they came from those sources; when + a choice is necessary but not known, choose the simplest mainstream option + and mark it "[ASSUMPTION]". - id: acceptance_criteria title: Acceptance Criteria instruction: > - Write the acceptance criteria as a numbered list. Each criterion is a - concrete, observable check in Given/When/Then form or "Done when ..." - form, mapped to the functional requirements by citing the relevant FR id - (for example, "AC-FR3: ..."). Include at least one criterion for - error/edge behavior. 5-10 criteria. + Write the acceptance criteria as a Markdown list with stable IDs AC1, AC2, + ... Each criterion is a concrete, observable check in Given/When/Then form + or "Done when ..." form. Each AC must cite covered FR IDs in square + brackets immediately after the label, e.g. "- AC1: [FR2, FR5] Given ...". + Every FR must have at least one AC covering it, and each AC must reference + only FR IDs that exist in the Functional Requirements section. Include at + least one criterion for error/edge behavior. 5-12 criteria. - id: out_of_scope title: Out of Scope diff --git a/webapp/tests/test_download.py b/webapp/tests/test_download.py index 8f3686c..da33f39 100644 --- a/webapp/tests/test_download.py +++ b/webapp/tests/test_download.py @@ -38,7 +38,7 @@ def test_download_md_still_returns_flat_implementation_brief(): assert "FR1: Users can create todos." in body assert "# Jira Export" not in body assert "## Epic" not in body - assert "## Stories" not in body + assert "## Child Stories" not in body def test_download_jira_md_returns_jira_export(): @@ -69,8 +69,8 @@ def test_download_jira_md_returns_jira_export(): assert response.headers["content-disposition"] == 'attachment; filename="jira-export.md"' assert body.startswith("# Jira Export") assert "## Epic" in body - assert "## Stories" in body - assert "### Story FR1" in body + assert "## Child Stories" in body + assert "Story summary: FR1" in body assert "Users can create todos." in body assert "AC-FR1: Given a todo" in body assert "# Implementation Brief" not in body diff --git a/webapp/tests/test_flow_parse.py b/webapp/tests/test_flow_parse.py index d1e40f8..8c16151 100644 --- a/webapp/tests/test_flow_parse.py +++ b/webapp/tests/test_flow_parse.py @@ -420,8 +420,8 @@ def test_assemble_jira_export_groups_functional_requirements_into_stories(): title="Functional Requirements", instruction="", content=( - "FR1: Partners can submit onboarding applications.\n" - "FR-2: Staff can approve or reject applications.\n" + "- FR1: The system SHALL let partners submit onboarding applications.\n" + "- FR-2: The system SHALL let staff approve or reject applications.\n" ), ), Section( @@ -429,11 +429,11 @@ def test_assemble_jira_export_groups_functional_requirements_into_stories(): title="Acceptance Criteria", instruction="", content=( - "AC-FR1: Given a partner application, when it is submitted, " + "- AC1: [FR1] Given a partner application, when it is submitted, " "then staff can review it.\n" - "AC-FR-2: Given a pending application, when staff approves it, " + "- AC2: [FR-2] Given a pending application, when staff approves it, " "then the partner is activated.\n" - "AC-EDGE: The export preserves criteria that do not map to a " + "- AC3: The export preserves criteria that do not map to a " "functional requirement.\n" ), ), @@ -445,22 +445,21 @@ def test_assemble_jira_export_groups_functional_requirements_into_stories(): assert out.startswith("# Jira Export") assert "## Epic" in out assert "Launch partner onboarding" in out - assert "**Project type:** existing" in out - assert "**Existing repository:** https://github.com/acme/portal" in out - assert "## Stories" in out - assert "### Story FR1" in out - assert "Partners can submit onboarding applications." in out - assert "AC-FR1: Given a partner application" in out - assert "### Story FR-2" in out - assert "Staff can approve or reject applications." in out - assert "AC-FR-2: Given a pending application" in out - assert "### Unmatched Acceptance Criteria" in out - assert "AC-EDGE: The export preserves criteria" in out - - assert out.index("### Story FR1") < out.index("AC-FR1:") - assert out.index("AC-FR1:") < out.index("### Story FR-2") - assert out.index("### Story FR-2") < out.index("AC-FR-2:") - assert out.index("AC-FR-2:") < out.index("### Unmatched Acceptance Criteria") + assert "Summary: Launch partner onboarding" in out + assert "- Existing repository: https://github.com/acme/portal" in out + assert "## Child Stories" in out + assert "- Story summary: FR1: The system SHALL let partners submit onboarding applications." in out + assert "AC1: [FR1] Given a partner application" in out + assert "- Story summary: FR2: The system SHALL let staff approve or reject applications." in out + assert "AC2: [FR-2] Given a pending application" in out + assert "## Unmatched Acceptance Criteria" in out + assert "AC3: The export preserves criteria" in out + + stories = out.split("## Child Stories", 1)[1] + assert stories.index("Story summary: FR1") < stories.index("AC1:") + assert stories.index("AC1:") < stories.index("Story summary: FR2") + assert stories.index("Story summary: FR2") < stories.index("AC2:") + assert stories.index("AC2:") < stories.index("## Unmatched Acceptance Criteria") def test_assemble_jira_export_parses_bold_markdown_requirement_and_criteria_labels(): @@ -493,11 +492,10 @@ def test_assemble_jira_export_parses_bold_markdown_requirement_and_criteria_labe out = flow.assemble_jira_export(s) - assert "### Story FR1" in out - assert "Users can configure billing alert thresholds." in out + assert "## Child Stories" in out + assert "Story summary: FR1: Users can configure billing alert thresholds." in out assert "AC-FR1: Given a threshold" in out - assert "### Story FR-2" in out - assert "Users can preview who will receive an alert." in out + assert "Story summary: FR2: Users can preview who will receive an alert." in out assert "AC-FR-2: Given recipients" in out assert "**FR1:**" not in out assert "**AC-FR1:**" not in out @@ -529,10 +527,79 @@ def test_assemble_jira_export_uses_single_fallback_story_without_requirements(): assert out.startswith("# Jira Export") assert "## Epic" in out - assert "## Stories" in out - assert "### Story 1" in out + assert "## Child Stories" in out + assert "- Story summary: 1: Improve account audit logging" in out assert "Improve account audit logging" in out assert "Add durable audit events for account mutations." in out assert "AC1: Audit events include actor, target account, and timestamp." in out assert "### Story FR" not in out - assert "### Unmatched Acceptance Criteria" not in out + assert "## Unmatched Acceptance Criteria" not in out + + +def test_jira_epic_summary_prefers_parseable_non_generic_title(): + s = _session( + idea="Incipit mega-prompt", + project_type="new", + stakes="serious", + form_factor="web app", + sections=[ + Section( + id="goals", + title="Goals & Background", + instruction="", + content=( + "Epic Summary: Export implementation briefs as Jira-ready epics\n" + "Outcome: Teams can create scoped Jira work from a brief." + ), + ) + ], + ) + + assert flow.jira_epic_summary(s) == "Export implementation briefs as Jira-ready epics" + + +def test_jira_epic_summary_parses_bolded_label(): + s = _session( + idea="Fallback", + project_type="new", + stakes="serious", + form_factor="web app", + sections=[ + Section( + id="goals", + title="Goals & Background", + instruction="", + content="**Epic Summary:** Create readable Jira epics from Incipit briefs", + ) + ], + ) + + assert flow.jira_epic_summary(s) == "Create readable Jira epics from Incipit briefs" + + +def test_jira_export_validation_notes_cover_fr_ac_reference_problems(): + s = _session( + idea="Improve billing alerts", + project_type="new", + stakes="internal", + form_factor="API/service", + sections=[ + Section( + id="functional_requirements", + title="Functional Requirements", + instruction="", + content="- FR1: The system SHALL send billing alerts.\n- FR2: The system SHALL preview recipients.", + ), + Section( + id="acceptance_criteria", + title="Acceptance Criteria", + instruction="", + content="- AC1: [FR1, FR9] Given a bill, when due, then an alert is sent.", + ), + ], + ) + + notes = flow.jira_export_validation_notes(s) + + assert "FR2 has no mapped acceptance criteria." in notes + assert any("references unknown FR9" in note for note in notes) diff --git a/webapp/tests/test_jira_export.py b/webapp/tests/test_jira_export.py index 8f2b646..3a0b6e3 100644 --- a/webapp/tests/test_jira_export.py +++ b/webapp/tests/test_jira_export.py @@ -209,11 +209,28 @@ def test_export_creates_issue_attaches_and_confirms(monkeypatch, caplog): assert story["fields"]["issuetype"]["name"] == "Story" assert story["fields"]["parent"]["key"] == "ABC-42" assert story["fields"]["description"]["type"] == "doc" + assert epic["fields"]["summary"] == "A todo app" + epic_description = json.dumps(epic["fields"]["description"]) + assert "Jira-specific Epic projection" in epic_description + assert "Child Stories" in epic_description + assert "Project idea (verbatim from the author)" not in epic_description # Export was audited with the issue key. assert "jira_export" in caplog.text assert "ABC-42" in caplog.text +def test_jira_summary_prefers_explicit_epic_summary(): + s = _final_session() + s.sections = [state.Section( + id="goals", + title="Goals & Background", + instruction="", + content="Epic Summary: Ship Jira-ready Epic export\nOutcome: Jira receives scoped planning artifacts.", + )] + + assert main._jira_summary(s) == "Ship Jira-ready Epic export" + + @respx.mock def test_export_reports_attachment_failure_but_succeeds(monkeypatch): respx.post(f"{_REST}/issue").mock( From 2aedba624c4aedb47d0b9d1d1fff9c1a2cd73eea Mon Sep 17 00:00:00 2001 From: readwrightexecute Date: Wed, 14 Oct 2026 13:35:39 +0000 Subject: [PATCH 2/3] Align Jira projection with BMAD ticket shape --- webapp/app/wizard/flow.py | 77 +++++++++++++++++++++++++-------- webapp/tests/test_download.py | 4 +- webapp/tests/test_flow_parse.py | 40 +++++++++++++---- 3 files changed, 92 insertions(+), 29 deletions(-) diff --git a/webapp/app/wizard/flow.py b/webapp/app/wizard/flow.py index 39b0f68..0b7ae7a 100644 --- a/webapp/app/wizard/flow.py +++ b/webapp/app/wizard/flow.py @@ -867,6 +867,10 @@ def _clean_label_value(text: str) -> str: rf"^(?:[-*]\s*)?(?:\d+[.)]\s*)?{_EMPH}(FR-?\d+){_EMPH}\s*[:.)-]\s*{_EMPH}(.+)$", re.IGNORECASE, ) +_NFR_RE = re.compile( + rf"^(?:[-*]\s*)?(?:\d+[.)]\s*)?{_EMPH}(NFR-?\d+){_EMPH}\s*[:.)-]\s*{_EMPH}(.+)$", + re.IGNORECASE, +) _AC_RE = re.compile( rf"^(?:[-*]\s*)?(?:\d+[.)]\s*)?{_EMPH}((?:AC|Acceptance\s*Criterion)[-\w]*){_EMPH}\s*[:.)-]\s*{_EMPH}(.+)$", re.IGNORECASE, @@ -896,6 +900,13 @@ def _functional_requirements(s: Session) -> list[tuple[str, str]]: return _labeled_blocks(req_section.content, _FR_RE) if req_section else [] +def _non_functional_requirements(s: Session) -> list[tuple[str, str]]: + req_section = _matching_section(s, "non_functional_requirements", + "non-functional requirements", + "non functional requirements") + return _labeled_blocks(req_section.content, _NFR_RE) if req_section else [] + + def _acceptance_criteria(s: Session) -> list[str]: ac_section = _matching_section(s, "acceptance_criteria", "acceptance criteria") criteria = [ @@ -988,7 +999,7 @@ def jira_story_drafts(s: Session) -> list[JiraStoryDraft]: ] overview = _matching_section(s, "goals", "overview", "background") - description = overview.content if overview and overview.content else assemble_final(s) + description = _epic_outcome(s) return [JiraStoryDraft( key="1", summary=s.idea, @@ -1056,6 +1067,7 @@ def assemble_jira_export(s: Session) -> str: """Build the Jira-specific Epic projection without calling Jira.""" stories = jira_story_drafts(s) functional_requirements = _functional_requirements(s) + non_functional_requirements = _non_functional_requirements(s) out_of_scope = _matching_section(s, "out_of_scope", "out of scope") tech = _matching_section(s, "tech_constraints", "tech constraints", "stack") assumptions = [ @@ -1063,49 +1075,76 @@ def assemble_jira_export(s: Session) -> str: if "[ASSUMPTION]" in line.upper() ] if tech else [] scope_items = [f"{_normalize_req_id(req_id)}: {text}" for req_id, text in functional_requirements] - success_metrics = _acceptance_criteria(s) + quality_items = [ + f"{_normalize_req_id(req_id)}: {text}" for req_id, text in non_functional_requirements + ] + done_when = _acceptance_criteria(s) validation_notes = jira_export_validation_notes(s) parts = [ "# Jira Export", "", - "Jira-specific Epic projection. Attach the full Implementation Brief as `mega-prompt.md`.", + "Jira-specific Epic projection adapted from BMAD ticket envelope/story conventions. " + "Attach the full Implementation Brief as `mega-prompt.md`.", "", "## Epic", "", f"Summary: {jira_epic_summary(s)}", + f"Description: {_epic_outcome(s)}", f"Outcome: {_epic_outcome(s)}", - "MVP Scope:", + "", + "Requirements:", ] parts += [f"- {item}" for item in (scope_items or ["Use the child stories below as the MVP scope."])] - parts += ["", "Out of Scope:"] + parts += [f"- {item}" for item in quality_items] + parts += ["", "Done when:"] + parts += [f"- {item}" for item in (done_when or ["No explicit acceptance criteria were captured."])] + parts += ["", "Boundaries:"] parts += [f"- {item}" for item in ( _plain_blocks(out_of_scope.content) if out_of_scope and out_of_scope.content else - ["No explicit out-of-scope items were captured."] + ["No explicit boundaries were captured."] )] - parts += ["", "Success Metrics:"] - parts += [f"- {item}" for item in (success_metrics or ["No explicit success metrics were captured."])] - parts += ["", "Dependencies / Assumptions:"] + parts += ["", "References:"] + parts.append("- attachment — mega-prompt.md, full Implementation Brief") if s.project_type == "existing" and s.repo_url: - parts.append(f"- Existing repository: {s.repo_url}") - parts += [f"- {item}" for item in (assumptions or ["No explicit dependencies or assumptions were captured."])] + parts.append(f"- existing repository — {s.repo_url}") + parts += ["", "Notes:"] + parts += [f"- Assumption: {item}" for item in assumptions] + parts += [f"- Validation: {note}" for note in validation_notes] + if not assumptions and not validation_notes: + parts.append("- No notes captured.") parts += ["", "## Child Stories", ""] for story in stories: + normalized_key = _normalize_req_id(story.key) parts += [ - f"- Story summary: {_normalize_req_id(story.key)}: {story.summary}", - f" - User value: {story.description}", - " - Acceptance criteria:", + f"### Story {normalized_key}", + "", + f"Title: {story.summary}", + f"Description: {story.description}", + f"Covers: [{normalized_key}]", + "Verify: Acceptance criteria pass for the covered requirement.", + "", + "Acceptance Criteria:", ] if story.acceptance_criteria: - parts += [f" - {criterion}" for criterion in story.acceptance_criteria] + parts += [f"- {criterion}" for criterion in story.acceptance_criteria] else: - parts.append(" - [NEEDS AC COVERAGE] Add criteria before implementation.") + parts.append("- [NEEDS AC COVERAGE] Add criteria before implementation.") + parts += [ + "", + "Boundaries:", + "- Must not change: behavior outside the covered requirement unless the Epic says so.", + "", + "References:", + f"- parent — Epic projection, Requirements {normalized_key}", + "", + "Notes:", + "- No story-local notes captured.", + "", + ] remaining_criteria = jira_unmatched_acceptance_criteria(s) if remaining_criteria: parts += ["", "## Unmatched Acceptance Criteria", ""] parts += [f"- {criterion}" for criterion in remaining_criteria] - if validation_notes: - parts += ["", "## Validation Notes", ""] - parts += [f"- {note}" for note in validation_notes] return "\n".join(parts).rstrip() + "\n" diff --git a/webapp/tests/test_download.py b/webapp/tests/test_download.py index da33f39..be55867 100644 --- a/webapp/tests/test_download.py +++ b/webapp/tests/test_download.py @@ -70,7 +70,9 @@ def test_download_jira_md_returns_jira_export(): assert body.startswith("# Jira Export") assert "## Epic" in body assert "## Child Stories" in body - assert "Story summary: FR1" in body + assert "### Story FR1" in body + assert "Title: Users can create todos." in body + assert "Covers: [FR1]" in body assert "Users can create todos." in body assert "AC-FR1: Given a todo" in body assert "# Implementation Brief" not in body diff --git a/webapp/tests/test_flow_parse.py b/webapp/tests/test_flow_parse.py index 8c16151..ec60a61 100644 --- a/webapp/tests/test_flow_parse.py +++ b/webapp/tests/test_flow_parse.py @@ -437,6 +437,12 @@ def test_assemble_jira_export_groups_functional_requirements_into_stories(): "functional requirement.\n" ), ), + Section( + id="non_functional_requirements", + title="Non-Functional Requirements", + instruction="", + content="- NFR1: The system SHALL mark invented latency targets as [ASSUMPTION].", + ), ], ) @@ -444,21 +450,34 @@ def test_assemble_jira_export_groups_functional_requirements_into_stories(): assert out.startswith("# Jira Export") assert "## Epic" in out + assert "BMAD ticket envelope/story conventions" in out assert "Launch partner onboarding" in out assert "Summary: Launch partner onboarding" in out - assert "- Existing repository: https://github.com/acme/portal" in out + assert "Description: Launch partner onboarding" in out + assert "Requirements:" in out + assert "- NFR1: The system SHALL mark invented latency targets as [ASSUMPTION]." in out + assert "Done when:" in out + assert "Boundaries:" in out + assert "References:" in out + assert "Notes:" in out + assert "- existing repository — https://github.com/acme/portal" in out assert "## Child Stories" in out - assert "- Story summary: FR1: The system SHALL let partners submit onboarding applications." in out + assert "### Story FR1" in out + assert "Title: The system SHALL let partners submit onboarding applications." in out + assert "Covers: [FR1]" in out + assert "Verify: Acceptance criteria pass for the covered requirement." in out assert "AC1: [FR1] Given a partner application" in out - assert "- Story summary: FR2: The system SHALL let staff approve or reject applications." in out + assert "### Story FR2" in out + assert "Title: The system SHALL let staff approve or reject applications." in out + assert "Covers: [FR2]" in out assert "AC2: [FR-2] Given a pending application" in out assert "## Unmatched Acceptance Criteria" in out assert "AC3: The export preserves criteria" in out stories = out.split("## Child Stories", 1)[1] - assert stories.index("Story summary: FR1") < stories.index("AC1:") - assert stories.index("AC1:") < stories.index("Story summary: FR2") - assert stories.index("Story summary: FR2") < stories.index("AC2:") + assert stories.index("### Story FR1") < stories.index("AC1:") + assert stories.index("AC1:") < stories.index("### Story FR2") + assert stories.index("### Story FR2") < stories.index("AC2:") assert stories.index("AC2:") < stories.index("## Unmatched Acceptance Criteria") @@ -493,9 +512,11 @@ def test_assemble_jira_export_parses_bold_markdown_requirement_and_criteria_labe out = flow.assemble_jira_export(s) assert "## Child Stories" in out - assert "Story summary: FR1: Users can configure billing alert thresholds." in out + assert "### Story FR1" in out + assert "Title: Users can configure billing alert thresholds." in out assert "AC-FR1: Given a threshold" in out - assert "Story summary: FR2: Users can preview who will receive an alert." in out + assert "### Story FR2" in out + assert "Title: Users can preview who will receive an alert." in out assert "AC-FR-2: Given recipients" in out assert "**FR1:**" not in out assert "**AC-FR1:**" not in out @@ -528,7 +549,8 @@ def test_assemble_jira_export_uses_single_fallback_story_without_requirements(): assert out.startswith("# Jira Export") assert "## Epic" in out assert "## Child Stories" in out - assert "- Story summary: 1: Improve account audit logging" in out + assert "### Story 1" in out + assert "Title: Improve account audit logging" in out assert "Improve account audit logging" in out assert "Add durable audit events for account mutations." in out assert "AC1: Audit events include actor, target account, and timestamp." in out From 3bf86a07cb6a98901056ebfb242c07b689f9e210 Mon Sep 17 00:00:00 2001 From: readwrightexecute Date: Wed, 14 Oct 2026 14:02:08 +0000 Subject: [PATCH 3/3] Fix Jira epic outcome fallback and story label layout The _epic_outcome plain-block fallback could return the 'Epic Summary:' line itself when no Outcome line parsed, leaking the Epic title into the Epic Description/Outcome. Skip label lines in the fallback, and give the Epic and child-story fields in the projection their own paragraphs so the markdown->ADF conversion keeps them separate. Generated-By: looper 0.13.1 (runner=fixer, agent=pi) --- webapp/app/wizard/flow.py | 21 ++++++-- webapp/tests/test_flow_parse.py | 95 +++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 5 deletions(-) diff --git a/webapp/app/wizard/flow.py b/webapp/app/wizard/flow.py index 0b7ae7a..18fd152 100644 --- a/webapp/app/wizard/flow.py +++ b/webapp/app/wizard/flow.py @@ -961,6 +961,11 @@ def jira_epic_summary(s: Session) -> str: return _fallback_epic_summary(s)[:255] +_GOALS_LABEL_RE = re.compile( + r"^(?:epic summary|summary|outcome|planning summary|background)\s*:", re.I +) + + def _epic_outcome(s: Session) -> str: overview = _matching_section(s, "goals", "overview", "background") if overview: @@ -968,9 +973,12 @@ def _epic_outcome(s: Session) -> str: value = _labeled_value(overview.content, label) if value: return value - bullets = _plain_blocks(overview.content) - if bullets: - return bullets[0] + # Fall back to the first substantive block, skipping label lines so + # "Epic Summary: ..." cannot leak back in as the outcome text. + blocks = [b for b in _plain_blocks(overview.content) + if not _GOALS_LABEL_RE.match(b)] + if blocks: + return blocks[0] return _fallback_epic_summary(s) @@ -1090,6 +1098,7 @@ def assemble_jira_export(s: Session) -> str: "", f"Summary: {jira_epic_summary(s)}", f"Description: {_epic_outcome(s)}", + "", f"Outcome: {_epic_outcome(s)}", "", "Requirements:", @@ -1120,12 +1129,14 @@ def assemble_jira_export(s: Session) -> str: f"### Story {normalized_key}", "", f"Title: {story.summary}", - f"Description: {story.description}", + "", f"Covers: [{normalized_key}]", "Verify: Acceptance criteria pass for the covered requirement.", "", - "Acceptance Criteria:", ] + if story.description: + parts += ["Description:", story.description, ""] + parts += ["Acceptance Criteria:"] if story.acceptance_criteria: parts += [f"- {criterion}" for criterion in story.acceptance_criteria] else: diff --git a/webapp/tests/test_flow_parse.py b/webapp/tests/test_flow_parse.py index ec60a61..815b2ab 100644 --- a/webapp/tests/test_flow_parse.py +++ b/webapp/tests/test_flow_parse.py @@ -599,6 +599,101 @@ def test_jira_epic_summary_parses_bolded_label(): assert flow.jira_epic_summary(s) == "Create readable Jira epics from Incipit briefs" +def test_epic_outcome_fallback_skips_epic_summary_label(): + s = _session( + idea="Build a demo app", + project_type="new", + stakes="serious", + form_factor="web app", + sections=[ + Section( + id="goals", + title="Goals & Background", + instruction="", + content=( + "Epic Summary: Build a demo app\n" + "- Bullet one: the demo runs locally.\n" + "- Bullet two: the demo exports its results.\n" + ), + ) + ], + ) + + assert flow._epic_outcome(s) == "Bullet one: the demo runs locally." + + out = flow.assemble_jira_export(s) + assert "Description: Bullet one: the demo runs locally." in out + assert "Outcome: Bullet one: the demo runs locally." in out + assert "Description: Epic Summary:" not in out + assert "Outcome: Epic Summary:" not in out + + +def test_epic_outcome_fallback_returns_idea_when_goals_is_all_labels(): + s = _session( + idea="Build a demo app", + project_type="new", + stakes="serious", + form_factor="web app", + sections=[ + Section( + id="goals", + title="Goals & Background", + instruction="", + content="Epic Summary: Build a demo app\nOutcome:\n", + ) + ], + ) + + assert flow._epic_outcome(s) == "Build a demo app" + + +def test_assemble_jira_export_story_labels_do_not_collapse_in_adf(): + s = _session( + idea="Launch partner onboarding", + project_type="new", + stakes="serious", + form_factor="web app", + sections=[ + Section( + id="functional_requirements", + title="Functional Requirements", + instruction="", + content=( + "- FR1: The system SHALL let partners submit onboarding applications.\n" + "- FR2: The system SHALL let staff approve applications.\n" + ), + ), + Section( + id="acceptance_criteria", + title="Acceptance Criteria", + instruction="", + content="- AC1: [FR1] Given an application, then staff can review it.\n", + ), + ], + ) + + out = flow.assemble_jira_export(s) + story = out.split("### Story FR1", 1)[1].split("### Story FR2", 1)[0] + # Title and Covers stay their own paragraph; description is its own block. + assert "Title: The system SHALL let partners submit onboarding applications.\n\nCovers: [FR1]" in story + assert "Description:\nThe system SHALL let partners submit onboarding applications.\n\nAcceptance Criteria:" in story + + from app import markdown_adf + adf = markdown_adf.to_adf(out) + paragraphs = [n for n in adf["content"] if n["type"] == "paragraph"] + texts = ["".join(n.get("text", "") for n in p["content"]) for p in paragraphs] + # No run-on paragraph merging the Title line with the next label (the old + # layout collapsed into "Title: ... Covers: [FR1] Verify: ..."). + assert not any("Title: " in t and ("Covers:" in t or "Verify:" in t) + for t in texts) + # Covers and Verify keep one paragraph (they read as a single label pair). + assert "Covers: [FR1] Verify: Acceptance criteria pass for the covered requirement." in texts + # The FR text appears as a Description paragraph, not glued onto the Title line. + fr_text = "The system SHALL let partners submit onboarding applications." + assert "Description: " + fr_text in texts + assert fr_text not in texts + + def test_jira_export_validation_notes_cover_fr_ac_reference_problems(): s = _session( idea="Improve billing alerts",