239 lines
8.1 KiB
Python
239 lines
8.1 KiB
Python
"""Canonical Jira issue representation for Project Knowledge.
|
|
|
|
Normalizes raw Jira REST API JSON into a stable, source-agnostic document that
|
|
the retrieval layer can index and search without knowing Jira-specific field
|
|
names. Every normalized issue carries mandatory provenance so search results
|
|
can cite the exact Jira source.
|
|
|
|
Design constraints (from the production prompt):
|
|
- Stable knowledge identity derived from the Jira issue key.
|
|
- Project/tenant scope using Cowork's existing canonical model.
|
|
- Truthful source updated/revision semantics — no fake revisions.
|
|
- Handles empty description, long content, Jira markup, missing custom fields.
|
|
- Does not hardcode one customer's Jira schema into the global model.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
|
# Bounded content size to prevent a single issue from dominating the index or
|
|
# the retrieval context window. Matches the workspace provider's per-document cap.
|
|
_MAX_CONTENT_CHARS = 200_000
|
|
_MAX_DESCRIPTION_CHARS = 50_000
|
|
|
|
# Jira wiki markup / HTML patterns stripped during normalization.
|
|
_JIRA_LINK_PATTERN = re.compile(r"\[([^\]]+)\|([^\]]+)\]")
|
|
_HTML_TAG_PATTERN = re.compile(r"<[^>]+>")
|
|
_MULTI_SPACE_PATTERN = re.compile(r"[ \t]{2,}")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class JiraProvenance:
|
|
"""Mandatory source traceability for every canonical issue.
|
|
|
|
Every field is required so a search result can always answer: where did
|
|
this come from, which version, and when was it retrieved?
|
|
"""
|
|
system: str = "jira"
|
|
issue_key: str = ""
|
|
project_key: str = ""
|
|
source_url: str = ""
|
|
source_updated: str = ""
|
|
issue_type: str = ""
|
|
status: str = ""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CanonicalJiraIssue:
|
|
"""Source-agnostic document ready for indexing and retrieval.
|
|
|
|
The identity is ``<project_key>/<issue_key>`` — stable across syncs and
|
|
safe as a filename stem. Content is pre-normalized plain text; Jira markup
|
|
and HTML are stripped during construction.
|
|
"""
|
|
knowledge_id: str
|
|
project_id: str
|
|
title: str
|
|
content: str
|
|
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
provenance: JiraProvenance = field(default_factory=JiraProvenance)
|
|
ingested_at: str = ""
|
|
|
|
def chunk_text(self) -> str:
|
|
"""The searchable text: title + content, bounded."""
|
|
combined = f"{self.title}\n\n{self.content}".strip()
|
|
return combined[:_MAX_CONTENT_CHARS]
|
|
|
|
|
|
def _strip_jira_markup(text: str) -> str:
|
|
"""Remove Jira wiki markup links and HTML tags, collapse whitespace."""
|
|
if not text:
|
|
return ""
|
|
# Convert [label|url] → label
|
|
cleaned = _JIRA_LINK_PATTERN.sub(r"\1", text)
|
|
# Strip remaining HTML tags
|
|
cleaned = _HTML_TAG_PATTERN.sub(" ", cleaned)
|
|
# Collapse runs of whitespace
|
|
cleaned = _MULTI_SPACE_PATTERN.sub(" ", cleaned)
|
|
return cleaned.strip()
|
|
|
|
|
|
def _safe_str(value: Any, max_chars: int = 0) -> str:
|
|
"""Coerce a Jira field value to a bounded string."""
|
|
if value is None:
|
|
return ""
|
|
if isinstance(value, dict):
|
|
# ADF rich-text descriptions arrive as dicts; surface a placeholder.
|
|
return "(rich-text description — open in Jira)"
|
|
text = str(value).strip()
|
|
if max_chars > 0:
|
|
return text[:max_chars]
|
|
return text
|
|
|
|
|
|
def _build_source_url(base_url: str, issue_key: str) -> str:
|
|
"""Construct the browse URL for an issue key."""
|
|
base = (base_url or "").rstrip("/")
|
|
if not base or not issue_key:
|
|
return ""
|
|
return f"{base}/browse/{issue_key}"
|
|
|
|
|
|
def normalize_jira_issue(
|
|
raw: Dict[str, Any],
|
|
*,
|
|
project_id: str,
|
|
jira_base_url: str = "",
|
|
) -> CanonicalJiraIssue:
|
|
"""Turn a raw Jira REST API issue dict into a canonical knowledge document.
|
|
|
|
Args:
|
|
raw: The JSON object from ``/rest/api/2/issue/{key}``.
|
|
project_id: Cowork project identifier this issue belongs to.
|
|
jira_base_url: Base URL of the Jira instance (for provenance URLs).
|
|
|
|
Returns:
|
|
A frozen ``CanonicalJiraIssue`` with mandatory provenance.
|
|
|
|
Raises:
|
|
ValueError: When the raw payload lacks the minimum fields needed to
|
|
produce a stable identity (``key`` at the top level).
|
|
"""
|
|
if not isinstance(raw, dict):
|
|
raise ValueError("raw issue must be a dict")
|
|
|
|
issue_key = _safe_str(raw.get("key"))
|
|
if not issue_key:
|
|
raise ValueError("raw issue missing 'key'")
|
|
|
|
fields = raw.get("fields") or {}
|
|
if not isinstance(fields, dict):
|
|
fields = {}
|
|
|
|
summary = _safe_str(fields.get("summary"))
|
|
description_raw = fields.get("description")
|
|
description = _strip_jira_markup(_safe_str(description_raw, _MAX_DESCRIPTION_CHARS))
|
|
|
|
issue_type_obj = fields.get("issuetype") or {}
|
|
issue_type = _safe_str(issue_type_obj.get("name")) if isinstance(issue_type_obj, dict) else ""
|
|
|
|
status_obj = fields.get("status") or {}
|
|
status = _safe_str(status_obj.get("name")) if isinstance(status_obj, dict) else ""
|
|
|
|
labels = list(fields.get("labels") or [])
|
|
components = [
|
|
_safe_str(c.get("name"))
|
|
for c in (fields.get("components") or [])
|
|
if isinstance(c, dict)
|
|
]
|
|
|
|
# Acceptance criteria: check common custom field names and heading-based extraction.
|
|
acceptance = ""
|
|
for ac_field in ("customfield_10016", "acceptance_criteria", "customfield_10001"):
|
|
ac_val = fields.get(ac_field)
|
|
if ac_val and isinstance(ac_val, str) and ac_val.strip():
|
|
acceptance = _strip_jira_markup(ac_val)[:5000]
|
|
break
|
|
if not acceptance and description:
|
|
# Try extracting from a markdown-style heading in the description.
|
|
ac_match = re.search(
|
|
r"(?:^|\n)#{1,6}\s+(?:Acceptance Criteria|Tiêu chí hoàn thành|Tiêu chí chấp nhận)\s*\n(.*?)(?=\n#{1,6}\s|\Z)",
|
|
description,
|
|
re.IGNORECASE | re.DOTALL,
|
|
)
|
|
if ac_match:
|
|
acceptance = ac_match.group(1).strip()[:5000]
|
|
|
|
# Linked issues (outward links only, bounded).
|
|
linked: List[str] = []
|
|
for link_group in (fields.get("issuelinks") or [])[:20]:
|
|
if not isinstance(link_group, dict):
|
|
continue
|
|
outward = link_group.get("outwardIssue") or link_group.get("inwardIssue")
|
|
if isinstance(outward, dict) and outward.get("key"):
|
|
linked.append(str(outward["key"]))
|
|
|
|
updated = _safe_str(fields.get("updated"))
|
|
created = _safe_str(fields.get("created"))
|
|
|
|
# Project key from the issue itself (e.g. "ABX" from "ABX-123").
|
|
project_key = issue_key.rsplit("-", 1)[0] if "-" in issue_key else ""
|
|
|
|
# Build the searchable content block.
|
|
content_parts = []
|
|
if description:
|
|
content_parts.append(description)
|
|
if acceptance:
|
|
content_parts.append(f"Acceptance Criteria:\n{acceptance}")
|
|
if labels:
|
|
content_parts.append(f"Labels: {', '.join(labels)}")
|
|
if components:
|
|
content_parts.append(f"Components: {', '.join(components)}")
|
|
if linked:
|
|
content_parts.append(f"Linked Issues: {', '.join(linked[:10])}")
|
|
content = "\n\n".join(content_parts)[:_MAX_CONTENT_CHARS]
|
|
|
|
knowledge_id = f"{project_key}/{issue_key}" if project_key else issue_key
|
|
source_url = _build_source_url(jira_base_url, issue_key)
|
|
now = datetime.now(timezone.utc).isoformat()
|
|
|
|
metadata: Dict[str, Any] = {
|
|
"issue_type": issue_type,
|
|
"status": status,
|
|
"labels": labels,
|
|
"components": components,
|
|
"linked_issues": linked[:10],
|
|
"created": created,
|
|
"updated": updated,
|
|
}
|
|
if acceptance:
|
|
metadata["has_acceptance_criteria"] = True
|
|
|
|
return CanonicalJiraIssue(
|
|
knowledge_id=knowledge_id,
|
|
project_id=project_id,
|
|
title=summary or issue_key,
|
|
content=content,
|
|
metadata=metadata,
|
|
provenance=JiraProvenance(
|
|
system="jira",
|
|
issue_key=issue_key,
|
|
project_key=project_key,
|
|
source_url=source_url,
|
|
source_updated=updated,
|
|
issue_type=issue_type,
|
|
status=status,
|
|
),
|
|
ingested_at=now,
|
|
)
|
|
|
|
|
|
__all__ = [
|
|
"CanonicalJiraIssue",
|
|
"JiraProvenance",
|
|
"normalize_jira_issue",
|
|
] |