diff --git a/webapp/app/main.py b/webapp/app/main.py index 2e024ce..f43d76e 100644 --- a/webapp/app/main.py +++ b/webapp/app/main.py @@ -1033,7 +1033,7 @@ def _jira_unauthorized() -> HTMLResponse: def _jira_summary(s) -> str: - """A concise Epic summary derived from the generated brief.""" + """A concise, outcome-based Epic summary.""" return flow.jira_epic_summary(s) @@ -1080,8 +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) - jira_md = flow.assemble_jira_export(s) - adf = markdown_adf.to_adf(jira_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 1b36ad7..3de0f5f 100644 --- a/webapp/app/wizard/flow.py +++ b/webapp/app/wizard/flow.py @@ -859,6 +859,14 @@ 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: + text = re.sub(r"^#{1,6}\s*", "", text or "") + text = re.sub(r"^(?:[-*+]|\d+[.)])\s*", "", text) + text = text.replace("**", "").replace("__", "").replace("`", "") + return re.sub(r"\s+", " ", text.strip(" -*_\t")).strip() + + def _section_matches(sec: Section, *needles: str) -> bool: haystack = f"{sec.id} {sec.title}".lower().replace("-", "_") return any(needle in haystack for needle in needles) @@ -879,14 +887,14 @@ def _section_matches(sec: Section, *needles: str) -> bool: r"^(?:incipit\s+)?(?:mega[-\s]?prompt|brief|implementation\s+brief|jira\s+export)$", re.IGNORECASE, ) -# Bare structural headings that name the section, not the project. Matched -# against the normalized title (hashes, list markers, emphasis, and label -# prefixes already stripped) so decorated echoes like `**Project**` are caught. _JIRA_NONSPECIFIC_HEADING_RE = re.compile( r"#?(?:#+\s*)?(?:goals?(?:\s*(?:[&]\s*|and\s+|and\b)background)?|overview|background|summary|" r"introduction|intro|about|project|about\s+this\s+project)\s*$", re.IGNORECASE, ) +_GOALS_LABEL_RE = re.compile( + r"^(?:epic summary|summary|outcome|planning summary|background)\s*:", re.I +) def _jira_title_lines(content: str) -> list[str]: @@ -924,9 +932,6 @@ def _clean_jira_title(raw: str) -> str: title = " ".join(title.split()) if not title: return "" - # A bare structural heading such as `# Goals` names the section, not the - # project, so it must never become the Epic title. Checked on the - # normalized title so decorated echoes like `**Project**` are caught. if _JIRA_NONSPECIFIC_HEADING_RE.match(title): return "" if _JIRA_GENERIC_TITLE_RE.match(title): @@ -934,6 +939,15 @@ def _clean_jira_title(raw: str) -> str: return _truncate_jira_summary(title[:1].upper() + title[1:]) +def _is_generic_epic_summary(value: str) -> bool: + cleaned = _clean_jira_title(value) + return not cleaned + + +def _fallback_epic_summary(s: Session) -> str: + return _clean_jira_title(s.idea) or "Implementation brief" + + def jira_epic_summary(s: Session) -> str: """Derive a Jira Epic title from generated brief content without an LLM call.""" overview = _matching_section(s, "goals", "overview", "background") @@ -976,12 +990,54 @@ def _referenced_req_ids(text: str) -> set[str]: def _criteria_for_requirement(requirement_id: str, criteria: list[str]) -> list[str]: - matched: list[str] = [] normalized = _normalize_req_id(requirement_id) - for criterion in criteria: - if normalized in _referenced_req_ids(criterion): - matched.append(criterion) - return matched + return [criterion for criterion in criteria if normalized in _referenced_req_ids(criterion)] + + +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 _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 = [ + 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 = _clean_label_value(raw_line) + if line.lower().startswith(prefix): + return _clean_label_value(line.split(":", 1)[1]) + return "" + + +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 + 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) def _format_labeled_list(content: str, label_re: re.Pattern) -> str: @@ -1019,8 +1075,8 @@ def _goal_items(s: Session) -> list[str]: overview = _matching_section(s, "goals", "overview", "background") if overview is None: return [] - lines = _plain_blocks(overview.content) - return [line for line in lines if not _JIRA_TITLE_LABEL_RE.match(line)] + return [line for line in _plain_blocks(overview.content) + if not _JIRA_TITLE_LABEL_RE.match(line) and not _GOALS_LABEL_RE.match(line)] def _first_or_default(items: list[str], default: str) -> str: @@ -1036,7 +1092,6 @@ def _bullet_lines(items: list[str], *, fallback: str, limit: int | None = None) def assemble_planning_summary(s: Session) -> str: """Small human/Jira-oriented project summary embedded in the final brief.""" - goals = _goal_items(s) stories = jira_story_drafts(s) out_of_scope = _section_items(s, "out_of_scope", "out of scope") tech = _section_items(s, "tech_constraints", "tech constraints", "stack") @@ -1045,8 +1100,8 @@ def assemble_planning_summary(s: Session) -> str: parts = [ "## Planning Summary", "", - f"**Problem:** {_first_or_default(goals, s.idea or '[ASSUMPTION] Problem not captured.')}", - f"**Target user:** [ASSUMPTION] The primary user described by the idea and clarifications.", + f"**Problem:** {_epic_outcome(s) or s.idea or '[ASSUMPTION] Problem not captured.'}", + "**Target user:** [ASSUMPTION] The primary user described by the idea and clarifications.", "", "**MVP:**", *_bullet_lines([story.summary for story in stories], fallback=s.idea or "Define the MVP."), @@ -1073,15 +1128,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 [ @@ -1094,26 +1142,17 @@ def jira_story_drafts(s: Session) -> list[JiraStoryDraft]: for req_id, req_text in requirements ] - overview = _matching_section(s, "goals", "overview", "background") - description = "\n".join(_goal_items(s)) or (s.idea or "Define the story.") return [JiraStoryDraft( key="1", summary=s.idea, - description=description, + description=_epic_outcome(s), acceptance_criteria=criteria, )] 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} @@ -1123,14 +1162,53 @@ 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 jira_consistency_warnings(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 [] + """Compatibility warning wording for the planning-summary Jira projection.""" + requirements = _functional_requirements(s) + criteria = _acceptance_criteria(s) requirement_ids = {_normalize_req_id(req_id) for req_id, _ in requirements} warnings: list[str] = [] @@ -1160,68 +1238,107 @@ def jira_consistency_warnings(s: Session) -> list[str]: def assemble_jira_export(s: Session) -> str: - """Build a Jira-shaped planning projection without calling Jira.""" - goals = _goal_items(s) + """Build the Jira-specific Epic projection without calling Jira.""" stories = jira_story_drafts(s) - out_of_scope = _section_items(s, "out_of_scope", "out of scope") - nfrs = _section_items(s, "non_functional_requirements", "non functional requirements", - "non-functional requirements", label_re=_NFR_RE) - tech = _section_items(s, "tech_constraints", "tech constraints", "stack") - criteria = _section_items(s, "acceptance_criteria", "acceptance criteria", label_re=_AC_RE) + 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 = [ + 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] + 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) warnings = jira_consistency_warnings(s) + epic_outcome = _epic_outcome(s) parts = [ "# Jira Export", "", - "Jira-shaped planning projection. The full brief is attached separately.", + "Jira-specific Epic projection adapted from BMAD ticket envelope/story conventions. " + "Attach the full source brief as `mega-prompt.md`.", "", "## Epic", "", + f"Summary: {jira_epic_summary(s)}", f"**Summary:** {jira_epic_summary(s)}", - f"**Outcome:** {_first_or_default(goals, s.idea or 'Define the outcome.')}", - "", - "**MVP Scope:**", - *_bullet_lines([story.summary for story in stories], fallback=s.idea or "Define the MVP."), - "", - "**Out of Scope:**", - *_bullet_lines(out_of_scope, fallback="No explicit out-of-scope items captured."), - "", - "**Success Metrics / Acceptance Signals:**", - *_bullet_lines(criteria, fallback="No Acceptance Criteria captured.", limit=8), - "", - "**Quality / Non-Functional Requirements:**", - *_bullet_lines(nfrs, fallback="No Non-Functional Requirements captured.", limit=8), + f"Description: {epic_outcome}", "", - "**Dependencies / Assumptions:**", - *_bullet_lines(tech, fallback="[ASSUMPTION] No dependencies captured.", limit=8), + f"Outcome: {epic_outcome}", + f"**Outcome:** {epic_outcome}", "", + "Requirements:", + ] + parts += [f"- {item}" for item in (scope_items or ["Use the child stories below as the MVP scope."])] + parts += [f"- {item}" for item in quality_items] + parts += ["", "**MVP Scope:**"] + parts += [f"- {story.summary}" for story in stories] + 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 boundaries were captured."] + )] + parts += ["", "References:"] + parts.append("- attachment — mega-prompt.md, full source brief") + parts += [ f"**Project type:** {s.project_type}", f"**Stakes:** {s.stakes}", f"**Form factor:** {s.form_factor}", ] if s.project_type == "existing" and s.repo_url: + parts.append(f"- existing repository — {s.repo_url}") 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.") if warnings: parts += ["", "## Consistency Checks", ""] parts += [f"- {warning}" for warning in warnings] parts += ["", "## Child Stories", ""] for story in stories: + normalized_key = _normalize_req_id(story.key) parts += [ - f"### Story {story.key}", + f"### Story {normalized_key}", "", + f"Title: {story.summary}", f"- **Story summary:** {story.summary}", f"- **User value:** {story.description}", + "", + f"Covers: [{normalized_key}]", + "Verify: Acceptance criteria pass for the covered requirement.", + "", ] + if story.description: + parts += ["Description:", story.description, ""] + parts += ["Acceptance Criteria:"] if story.acceptance_criteria: - parts += ["- **Acceptance criteria:**"] - parts += [f" - {criterion}" for criterion in story.acceptance_criteria] + parts += [f"- {criterion}" for criterion in story.acceptance_criteria] else: - parts += ["- **Acceptance criteria:** No mapped Acceptance Criteria."] - parts.append("") + 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 += ["", "## Unmatched Acceptance Criteria", ""] parts += [f"- {criterion}" for criterion in remaining_criteria] - parts.append("") 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 5b0f6d0..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. Do not introduce numeric thresholds, vendors, protocols, compliance claims, or deployment choices as facts unless they came from the idea, repo context, or clarifications; otherwise mark them "[ASSUMPTION]". 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 324a08e..29e7746 100644 --- a/webapp/app/wizard/sections.yaml +++ b/webapp/app/wizard/sections.yaml @@ -4,36 +4,37 @@ sections: - id: goals title: Goals & Background instruction: > - Start with exactly one line: "Epic Summary: ". - The title must name the concrete project outcome, not "Incipit", - "mega-prompt", or other generic wording. Then write 3-6 Markdown 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 Markdown bullet list labeled FR1, - FR2, ... Each requirement must use SHALL language 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"). Each FR - should map cleanly to one Jira Story: avoid tiny UI-only steps and avoid - broad epics disguised as requirements. Cover the core happy path first, - then secondary behaviors. 5-12 requirements depending on stakes. Do not - include performance/quality attributes here. + 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 Markdown bullet list labeled - NFR1, NFR2, ... using SHALL language. Cover only the attributes that matter - for these stakes: performance, security/auth, reliability, resource + 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, - vendors, protocols, compliance claims, or deployment choices as facts - unless they came from the idea, repo context, or clarifications; otherwise - mark them "[ASSUMPTION]". + 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 @@ -42,20 +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]". Do not introduce - vendors, protocols, compliance claims, or deployment choices as facts unless - they came from the idea, repo context, or clarifications. + 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 Markdown bullet list labeled AC1, AC2, - ... Each criterion must cite the FR ids it validates in brackets, for - example "- AC1: [FR2, FR5] Given ...". Every FR must be covered by at - least one acceptance criterion. Each criterion is a concrete, observable - check in Given/When/Then form or "Done when ..." form. 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 b6908e3..7692646 100644 --- a/webapp/tests/test_download.py +++ b/webapp/tests/test_download.py @@ -72,7 +72,10 @@ def test_download_jira_md_returns_jira_export(): assert "## Epic" in body assert "## Child Stories" in body assert "### Story FR1" in body + assert "Title: Users can create todos." in body + assert "Covers: [FR1]" in body assert "- **Story summary:** Users can create todos." 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 8971871..46eb84a 100644 --- a/webapp/tests/test_flow_parse.py +++ b/webapp/tests/test_flow_parse.py @@ -642,8 +642,8 @@ def test_assemble_jira_export_groups_functional_requirements_into_stories(): title="Functional Requirements", instruction="", content=( - "FR1: The system SHALL let partners submit onboarding applications.\n" - "FR-2: The system SHALL let staff 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( @@ -651,14 +651,20 @@ def test_assemble_jira_export_groups_functional_requirements_into_stories(): title="Acceptance Criteria", instruction="", content=( - "AC1: [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" - "AC2: [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" ), ), + Section( + id="non_functional_requirements", + title="Non-Functional Requirements", + instruction="", + content="- NFR1: The system SHALL mark invented latency targets as [ASSUMPTION].", + ), ], ) @@ -666,26 +672,39 @@ def test_assemble_jira_export_groups_functional_requirements_into_stories(): assert out.startswith("# Jira Export") assert "## Epic" in out + assert "Jira-specific Epic projection" in out + assert "Summary: Launch partner onboarding" in out assert "**Summary:** Launch partner onboarding" in out - assert "**Outcome:** Launch partner onboarding" in out + assert "Description: Launch partner onboarding" in out + assert "Outcome: Launch partner onboarding" in out assert "**MVP Scope:**" in out + assert "Requirements:" in out + assert "Done when:" in out + assert "Boundaries:" in out + assert "References:" in out + assert "Notes:" in out assert "**Project type:** existing" in out + assert "- existing repository — https://github.com/acme/portal" in out assert "**Existing repository:** https://github.com/acme/portal" in out assert "## Child Stories" in out assert "### Story FR1" in out + assert "Title: The system SHALL let partners submit onboarding applications." in out assert "- **Story summary:** 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 FR-2" in out - assert "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 "AC-EDGE: The export preserves criteria" in out + assert "## Unmatched Acceptance Criteria" in out + assert "AC3: The export preserves criteria" in out - child_stories = out.split("## Child Stories", 1)[1] - assert child_stories.index("### Story FR1") < child_stories.index("AC1: [FR1]") - assert child_stories.index("AC1: [FR1]") < child_stories.index("### Story FR-2") - assert child_stories.index("### Story FR-2") < child_stories.index("AC2: [FR-2]") - assert child_stories.index("AC2: [FR-2]") < child_stories.index("### Unmatched Acceptance Criteria") + stories = out.split("## Child Stories", 1)[1] + 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") def test_assemble_jira_export_parses_bold_markdown_requirement_and_criteria_labels(): @@ -718,11 +737,12 @@ 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 FR1" in out - assert "Users can configure billing alert thresholds." in out + assert "Title: 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 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 @@ -756,12 +776,172 @@ def test_assemble_jira_export_uses_single_fallback_story_without_requirements(): assert "## Epic" in out assert "## Child Stories" 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 assert "No FR-labeled requirements found" 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_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] + assert "Title: The system SHALL let partners submit onboarding applications." in story + assert "Covers: [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] + assert not any("Title: " in t and ("Covers:" in t or "Verify:" in t) + for t in texts) + 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", + 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) def test_jira_consistency_warnings_cover_missing_and_unknown_fr_refs(): diff --git a/webapp/tests/test_jira_export.py b/webapp/tests/test_jira_export.py index 1d39f96..43ad680 100644 --- a/webapp/tests/test_jira_export.py +++ b/webapp/tests/test_jira_export.py @@ -216,11 +216,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(