diff --git a/webapp/app/main.py b/webapp/app/main.py index 7ff2678..2e024ce 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 Epic summary derived from the generated brief.""" + 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_md = flow.assemble_jira_export(s) + adf = markdown_adf.to_adf(jira_md) 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..1b36ad7 100644 --- a/webapp/app/wizard/flow.py +++ b/webapp/app/wizard/flow.py @@ -822,8 +822,9 @@ def assemble_final(s: Session) -> str: parts.append(f"**Existing repository:** {s.repo_url} — integrate with the " "current codebase; prefer additive, non-breaking changes.") parts.append("") + parts += [assemble_planning_summary(s), ""] for sec in s.sections: - parts += [f"## {sec.title}", "", sec.content, ""] + parts += [f"## {sec.title}", "", _format_section_content(sec), ""] parts += [ENGINEERING_STANDARDS_SECTION, ""] return "\n".join(parts) @@ -858,11 +859,108 @@ def _plain_blocks(content: str) -> list[str]: return [line.strip(" -*\t") for line in content.splitlines() if line.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) + + +_JIRA_SUMMARY_MAX = 255 +_JIRA_TITLE_LABEL_RE = re.compile( + r"^(?:project\s+)?(?:epic\s+summary|title|name|summary|epic|idea)" + r"(?:\s+[:\-]\s*|\s*:\s*)", + re.IGNORECASE, +) +_JIRA_GENERIC_PREFIX_RE = re.compile( + r"^(?:incipit\s+)?(?:mega[-\s]prompt\b|implementation[-\s]+brief\b|brief\b)" + r"(?:\s+(?:for|about)|\s*:\s*|\s+[-<]\s+)?", + re.IGNORECASE, +) +_JIRA_GENERIC_TITLE_RE = re.compile( + 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, +) + + +def _jira_title_lines(content: str) -> list[str]: + """Explicit generated headings and title labels only, in order.""" + return [ + line.strip() for line in content.splitlines() if line.strip() + and (line.startswith("#") + or _JIRA_TITLE_LABEL_RE.match(line.strip().lstrip("-*+ \t"))) + ] + + +def _jira_first_line(content: str) -> str: + lines = [line.strip() for line in content.splitlines() if line.strip()] + return lines[0] if lines else "" + + +def _truncate_jira_summary(text: str) -> str: + if len(text) <= _JIRA_SUMMARY_MAX: + return text + shortened = text[:_JIRA_SUMMARY_MAX].rsplit(" ", 1)[0].rstrip(" .,:;-") + return shortened or text[:_JIRA_SUMMARY_MAX].rstrip() + + +def _clean_jira_title(raw: str) -> str: + title = " ".join((raw or "").split()) + if not title: + return "" + title = re.sub(r"^#{1,6}\s*", "", title) + title = re.sub(r"^(?:[-*+]|\d+[.)])\s*", "", title) + title = re.sub(r"^\[[ xX]\]\s*", "", title) + title = title.strip(" `*_\"'") + title = _JIRA_TITLE_LABEL_RE.sub("", title).strip() + title = _JIRA_GENERIC_PREFIX_RE.sub("", title).strip() + title = title.strip(" .,:;-<>`*_\"'") + 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): + return "" + return _truncate_jira_summary(title[:1].upper() + title[1:]) + + +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") + sources: list[str] = [] + if overview: + sources.extend(_jira_title_lines(overview.content)) + sources.append(s.idea) + for sec in s.sections: + if sec is not overview: + sources.append(_jira_first_line(sec.content)) + + for source in sources: + title = _clean_jira_title(source) + if title: + return title + return "Implementation brief" + + _EMPH = r"(?:\*\*|__|\*|_|`)?" _FR_RE = re.compile( 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, @@ -886,6 +984,85 @@ def _criteria_for_requirement(requirement_id: str, criteria: list[str]) -> list[ return matched +def _format_labeled_list(content: str, label_re: re.Pattern) -> str: + blocks = _labeled_blocks(content, label_re) + if blocks: + return "\n".join(f"- {label}: {text}" for label, text in blocks) + plain = _plain_blocks(content) + return "\n".join(f"- {line}" for line in plain) if plain else content + + +def _format_section_content(sec: Section) -> str: + if _section_matches(sec, "functional_requirements", "functional requirements"): + return _format_labeled_list(sec.content, _FR_RE) + if _section_matches(sec, "non_functional_requirements", "non functional requirements", + "non-functional requirements"): + return _format_labeled_list(sec.content, _NFR_RE) + if _section_matches(sec, "acceptance_criteria", "acceptance criteria"): + return _format_labeled_list(sec.content, _AC_RE) + return sec.content + + +def _section_items(s: Session, *needles: str, + label_re: re.Pattern | None = None) -> list[str]: + sec = _matching_section(s, *needles) + if sec is None: + return [] + if label_re is not None: + labeled = _labeled_blocks(sec.content, label_re) + if labeled: + return [f"{label}: {text}" for label, text in labeled] + return _plain_blocks(sec.content) + + +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)] + + +def _first_or_default(items: list[str], default: str) -> str: + return items[0] if items else default + + +def _bullet_lines(items: list[str], *, fallback: str, limit: int | None = None) -> list[str]: + selected = items[:limit] if limit is not None else items + if not selected: + selected = [fallback] + return [f"- {item}" for item in selected] + + +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") + criteria = _section_items(s, "acceptance_criteria", "acceptance criteria", label_re=_AC_RE) + + 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.", + "", + "**MVP:**", + *_bullet_lines([story.summary for story in stories], fallback=s.idea or "Define the MVP."), + "", + "**Non-goals:**", + *_bullet_lines(out_of_scope, fallback="No explicit non-goals captured."), + "", + "**Primary risks:**", + *_bullet_lines(tech[:3], fallback="[ASSUMPTION] No primary risks captured."), + "", + "**Open decisions:**", + *_bullet_lines(criteria[:3], fallback="No open decisions captured."), + ] + return "\n".join(parts) + + @dataclass(frozen=True) class JiraStoryDraft: key: str @@ -918,7 +1095,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 = "\n".join(_goal_items(s)) or (s.idea or "Define the story.") return [JiraStoryDraft( key="1", summary=s.idea, @@ -946,38 +1123,101 @@ def jira_unmatched_acceptance_criteria(s: Session) -> list[str]: ] +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 [] + requirement_ids = {_normalize_req_id(req_id) for req_id, _ in requirements} + warnings: list[str] = [] + + for req_id, _ in requirements: + if not _criteria_for_requirement(req_id, criteria): + warnings.append(f"{_normalize_req_id(req_id)} has no Acceptance Criteria coverage.") + + for criterion in criteria: + refs = _referenced_req_ids(criterion) + if not refs: + warnings.append(f"Acceptance Criterion lacks FR references: {criterion}") + unknown = refs - requirement_ids + if unknown: + warnings.append( + f"Acceptance Criterion references unknown FR ids {', '.join(sorted(unknown))}: " + f"{criterion}" + ) + + if jira_epic_summary(s) == "Implementation brief": + warnings.append("Jira Epic Summary is generic; add an explicit Epic Summary.") + if not requirements: + warnings.append("No FR-labeled requirements found; Jira export uses one fallback story.") + for story in jira_story_drafts(s): + if len(story.summary) > 180: + warnings.append(f"Story {story.key} may be too broad for Jira: {story.summary[:120]}...") + return warnings + + def assemble_jira_export(s: Session) -> str: - """Build a generic Jira-friendly markdown export without calling Jira.""" + """Build a Jira-shaped planning projection without calling Jira.""" + goals = _goal_items(s) + 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) + warnings = jira_consistency_warnings(s) parts = [ "# Jira Export", "", - "Markdown-only planning export. Create or edit Jira issues manually using the sections below.", + "Jira-shaped planning projection. The full brief is attached separately.", "", "## Epic", "", - f"**Summary:** {s.idea}", + 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), + "", + "**Dependencies / Assumptions:**", + *_bullet_lines(tech, fallback="[ASSUMPTION] No dependencies captured.", limit=8), + "", 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 += ["", "## Stories", ""] + if warnings: + parts += ["", "## Consistency Checks", ""] + parts += [f"- {warning}" for warning in warnings] + 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:** {story.summary}", + f"- **User value:** {story.description}", ] if story.acceptance_criteria: - parts += ["**Acceptance Criteria:**", ""] - parts += [f"- {criterion}" for criterion in story.acceptance_criteria] - parts.append("") + parts += ["- **Acceptance criteria:**"] + parts += [f" - {criterion}" for criterion in story.acceptance_criteria] + else: + parts += ["- **Acceptance criteria:** No mapped Acceptance Criteria."] + parts.append("") remaining_criteria = jira_unmatched_acceptance_criteria(s) if remaining_criteria: parts += ["### Unmatched Acceptance Criteria", ""] diff --git a/webapp/app/wizard/prompts/system.md.j2 b/webapp/app/wizard/prompts/system.md.j2 index 969f822..5b0f6d0 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, 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. diff --git a/webapp/app/wizard/sections.yaml b/webapp/app/wizard/sections.yaml index 98d18cc..324a08e 100644 --- a/webapp/app/wizard/sections.yaml +++ b/webapp/app/wizard/sections.yaml @@ -4,28 +4,36 @@ 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. + 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. - 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 - 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 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. - 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 bullet list labeled + NFR1, NFR2, ... using SHALL language. Cover 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, 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]". - id: tech_constraints title: Tech Constraints & Stack @@ -35,16 +43,19 @@ sections: 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]". + 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. - 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 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. - id: out_of_scope title: Out of Scope diff --git a/webapp/tests/test_download.py b/webapp/tests/test_download.py index 8f3686c..b6908e3 100644 --- a/webapp/tests/test_download.py +++ b/webapp/tests/test_download.py @@ -34,11 +34,12 @@ def test_download_md_still_returns_flat_implementation_brief(): assert response.media_type == "text/markdown" assert response.headers["content-disposition"] == 'attachment; filename="mega-prompt.md"' assert body.startswith("# Implementation Brief") + assert "## Planning Summary" in body assert "## Functional Requirements" in body - assert "FR1: Users can create todos." in body + 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,9 +70,9 @@ 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 "## Child Stories" in body assert "### Story FR1" in body - assert "Users can create todos." in body + assert "- **Story summary:** 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..8971871 100644 --- a/webapp/tests/test_flow_parse.py +++ b/webapp/tests/test_flow_parse.py @@ -327,6 +327,7 @@ def test_assemble_final_structure(): assert out.startswith("# Implementation Brief") assert "**Project idea (verbatim from the author):** A todo app" in out assert "**Form factor:** web app" in out + assert "## Planning Summary" in out assert "## Project Overview" in out assert "It tracks todos." in out assert "## Tech Constraints" in out @@ -405,6 +406,227 @@ def test_assemble_final_standards_section_relates_to_existing_repository(): assert "repository guidance" in out[standards_index:].lower() +def test_assemble_final_formats_requirement_sections_as_markdown_lists(): + s = _session( + idea="Improve billing alerts", + project_type="new", + stakes="serious", + form_factor="web app", + sections=[ + Section( + id="functional_requirements", + title="Functional Requirements", + instruction="", + content="FR1: The system SHALL save alert thresholds.\nFR2: The system SHALL preview recipients.", + ), + Section( + id="non_functional_requirements", + title="Non-Functional Requirements", + instruction="", + content="NFR1: The system SHALL mark invented latency as [ASSUMPTION].", + ), + Section( + id="acceptance_criteria", + title="Acceptance Criteria", + instruction="", + content="AC1: [FR1] Given a threshold, when saved, then it persists.", + ), + ], + ) + + out = flow.assemble_final(s) + + assert "## Functional Requirements\n\n- FR1: The system SHALL save alert thresholds." in out + assert "- FR2: The system SHALL preview recipients." in out + assert "## Non-Functional Requirements\n\n- NFR1: The system SHALL mark" in out + assert "## Acceptance Criteria\n\n- AC1: [FR1] Given a threshold" in out + + +# --- jira_epic_summary ------------------------------------------------------ + +def test_jira_epic_summary_prefers_generated_section_heading_over_generic_idea(): + s = _session( + idea="Incipit mega-prompt", + sections=[ + Section( + id="goals", + title="Goals & Background", + instruction="", + content="# Partner Onboarding Portal\n\n- Let partners apply online.", + ), + ], + ) + + assert flow.jira_epic_summary(s) == "Partner Onboarding Portal" + + +def test_jira_epic_summary_uses_labeled_generated_title(): + s = _session( + idea="Incipit brief", + sections=[ + Section( + id="goals", + title="Goals & Background", + instruction="", + content="Project title: Account Audit Logging\n\n- Capture durable audit events.", + ), + ], + ) + + assert flow.jira_epic_summary(s) == "Account Audit Logging" + + +def test_jira_epic_summary_prefers_idea_over_unlabeled_overview_first_line(): + s = _session( + idea="Ship dark mode", + sections=[ + Section( + id="overview", + title="Overview", + instruction="", + content="The app should support dark mode across all screens.\n\n- more", + ), + ], + ) + + assert flow.jira_epic_summary(s) == "Ship dark mode" + + +def test_jira_epic_summary_prefers_idea_over_bare_goals_heading(): + s = _session( + idea="Launch partner onboarding", + sections=[ + Section( + id="goals", + title="Goals & Background", + instruction="", + content="# Goals\n\nPartner onboarding stuff.", + ), + ], + ) + + assert flow.jira_epic_summary(s) == "Launch partner onboarding" + + +def test_jira_epic_summary_uses_explicit_epic_summary_line(): + s = _session( + idea="Incipit mega-prompt", + sections=[ + Section( + id="goals", + title="Goals & Background", + instruction="", + content="Epic Summary: Self-Service Billing Alerts\n\n- Reduce missed billing events.", + ), + ], + ) + + assert flow.jira_epic_summary(s) == "Self-Service Billing Alerts" + + +def test_jira_epic_summary_collapses_multiline_idea(): + s = _session( + idea="Improve billing alerts\n\nfor enterprise accounts", + sections=[], + ) + + assert flow.jira_epic_summary(s) == "Improve billing alerts for enterprise accounts" + + +def test_jira_epic_summary_falls_back_for_blank_and_generic_content(): + s = _session( + idea="\n\n", + sections=[ + Section( + id="goals", + title="Goals & Background", + instruction="", + content="## Incipit mega-prompt", + ), + ], + ) + + assert flow.jira_epic_summary(s) == "Implementation brief" + + +def test_jira_epic_summary_truncates_long_idea_to_jira_limit(): + s = _session( + idea="Launch " + "very-long-project-name " * 30, + sections=[], + ) + + summary = flow.jira_epic_summary(s) + + assert len(summary) <= 255 + assert summary.startswith("Launch very-long-project-name") + assert not summary.endswith(" ") + + +def test_jira_title_cleaning_preserves_hyphenated_first_words(): + """Label prefixes must not eat hyphenated compound words.""" + assert flow._clean_jira_title("summary-queue for order events") == \ + "Summary-queue for order events" + assert flow._clean_jira_title("Name-tag printer") == "Name-tag printer" + assert flow._clean_jira_title("Title-case editor") == "Title-case editor" + assert flow._clean_jira_title("Epic-games launcher") == "Epic-games launcher" + # Whole-word label prefixes still strip. + assert flow._clean_jira_title("Epic Summary: Self-Service Billing Alerts") == \ + "Self-Service Billing Alerts" + assert flow._clean_jira_title("Project title: Account Audit Logging") == \ + "Account Audit Logging" + + +def test_jira_title_cleaning_preserves_brief_word_prefixes(): + """`brief` must only match as a whole word, not as a word prefix.""" + assert flow._clean_jira_title("briefcase manager") == "Briefcase manager" + assert flow._clean_jira_title("Briefing service") == "Briefing service" + # Whole-word generic prefixes still strip. + assert flow._clean_jira_title("Incipit mega-prompt") == "" + assert flow._clean_jira_title("Incipit brief") == "" + assert flow._clean_jira_title("brief for payments") == "Payments" + + +def test_jira_title_cleaning_rejects_decorated_bare_headings(): + """Emphasis/hash-decorated section echoes are non-specific titles.""" + assert flow._clean_jira_title("### *Goals & Background*") == "" + assert flow._clean_jira_title("## Goals and Background") == "" + assert flow._clean_jira_title("**Project**") == "" + assert flow._clean_jira_title("# Goals") == "" + assert flow._clean_jira_title("Summary") == "" + + +def test_jira_epic_summary_prefers_idea_over_decorated_goals_heading(): + s = _session( + idea="Launch partner onboarding", + sections=[ + Section( + id="goals", + title="Goals & Background", + instruction="", + content="### *Goals & Background*\n\nPartner onboarding stuff.", + ), + ], + ) + + assert flow.jira_epic_summary(s) == "Launch partner onboarding" + + +def test_jira_epic_summary_prefers_idea_over_decorated_project_heading(): + s = _session( + idea="Ship dark mode", + sections=[ + Section( + id="goals", + title="Goals & Background", + instruction="", + content="**Project**\n\nThe app should support dark mode.", + ), + ], + ) + + assert flow.jira_epic_summary(s) == "Ship dark mode" + + # --- assemble_jira_export --------------------------------------------------- def test_assemble_jira_export_groups_functional_requirements_into_stories(): @@ -420,8 +642,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,9 +651,9 @@ 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 " "functional requirement.\n" @@ -444,23 +666,26 @@ 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 "**Summary:** Launch partner onboarding" in out + assert "**Outcome:** Launch partner onboarding" in out + assert "**MVP Scope:**" in out assert "**Project type:** existing" in out assert "**Existing repository:** https://github.com/acme/portal" in out - assert "## Stories" in out + assert "## Child 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 summary:** The system SHALL let partners submit onboarding applications." in out + assert "AC1: [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 "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 "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") + 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") def test_assemble_jira_export_parses_bold_markdown_requirement_and_criteria_labels(): @@ -529,10 +754,37 @@ 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 "## Child Stories" in out assert "### Story 1" 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 + + +def test_jira_consistency_warnings_cover_missing_and_unknown_fr_refs(): + s = _session( + idea="Improve billing alerts", + sections=[ + Section( + id="functional_requirements", + title="Functional Requirements", + instruction="", + content="FR1: The system SHALL save thresholds.\nFR2: The system SHALL send alerts.", + ), + Section( + id="acceptance_criteria", + title="Acceptance Criteria", + instruction="", + content="AC1: [FR1] Given a threshold, when saved, then it persists.\n" + "AC2: [FR9] Given a ghost requirement, when tested, then it fails.", + ), + ], + ) + + warnings = flow.jira_consistency_warnings(s) + + assert "FR2 has no Acceptance Criteria coverage." in warnings + assert any("unknown FR ids FR9" in warning for warning in warnings) diff --git a/webapp/tests/test_jira_export.py b/webapp/tests/test_jira_export.py index 8f2b646..1d39f96 100644 --- a/webapp/tests/test_jira_export.py +++ b/webapp/tests/test_jira_export.py @@ -205,7 +205,14 @@ def test_export_creates_issue_attaches_and_confirms(monkeypatch, caplog): epic = json.loads(issue_route.calls[0].request.content.decode()) story = json.loads(issue_route.calls[1].request.content.decode()) assert epic["fields"]["issuetype"]["name"] == "Epic" + assert epic["fields"]["summary"] == "A todo app" + assert not epic["fields"]["summary"].startswith("Incipit brief:") assert epic["fields"]["description"]["type"] == "doc" + epic_description = json.dumps(epic["fields"]["description"]) + assert "Jira Export" in epic_description + assert "Child Stories" in epic_description + assert "Implementation Brief" not in epic_description + assert "# Implementation Brief" in att_route.calls[0].request.content.decode("utf-8", "replace") assert story["fields"]["issuetype"]["name"] == "Story" assert story["fields"]["parent"]["key"] == "ABC-42" assert story["fields"]["description"]["type"] == "doc"