"""Read/write repository for the per-project Jira knowledge index. Each project's index lives under ``//issues/`` as one JSON file per canonical issue. The manifest (sync state) sits beside it at ``//manifest.json`` and is managed by ``domain.jira_knowledge.sync_state``. All writes use atomic JSON persistence so a crash mid-sync cannot leave a half-written document that later reads as valid but incomplete data. """ from __future__ import annotations import json from pathlib import Path from typing import Dict, List, Optional from ...domain.jira_knowledge.canonical_issue import CanonicalJiraIssue def _safe_project_dir(project_id: str) -> str: """Sanitize a project id into a filesystem-safe directory name.""" return "".join(c if (c.isalnum() or c in "-_") else "_" for c in project_id) def _issue_filename(knowledge_id: str) -> str: """Deterministic filename for a canonical issue. ``knowledge_id`` has the form ``PROJECT_KEY/ISSUE-KEY``; we replace the slash with ``--`` so it is safe on all filesystems while remaining human-readable when an operator inspects the index directly. """ return knowledge_id.replace("/", "--").replace("\\", "--") + ".json" class JiraKnowledgeIndex: """Thread-safe read/write access to one project's Jira knowledge index. The index root defaults to ``~/.cowork_local/jira_kb`` but can be overridden via constructor argument or the ``JIRA_KB_INDEX_ROOT`` environment variable for testing. """ def __init__(self, index_root: Optional[Path] = None) -> None: if index_root is not None: self._root = Path(index_root) else: import os env = os.environ.get("JIRA_KB_INDEX_ROOT", "").strip() if env: self._root = Path(env) else: from ...config import CONFIG_DIR self._root = CONFIG_DIR / "jira_kb" def project_dir(self, project_id: str) -> Path: """The issues directory for one project (created on first write).""" return self._root / _safe_project_dir(project_id) / "issues" def upsert(self, issue: CanonicalJiraIssue) -> None: """Insert or update a single canonical issue in the index. Uses atomic write so concurrent readers never see a partial document. """ directory = self.project_dir(issue.project_id) directory.mkdir(parents=True, exist_ok=True) path = directory / _issue_filename(issue.knowledge_id) from ...infrastructure.persistence.json.atomic_write import write_json write_json(path, { "knowledge_id": issue.knowledge_id, "project_id": issue.project_id, "title": issue.title, "content": issue.content, "metadata": issue.metadata, "provenance": { "system": issue.provenance.system, "issue_key": issue.provenance.issue_key, "project_key": issue.provenance.project_key, "source_url": issue.provenance.source_url, "source_updated": issue.provenance.source_updated, "issue_type": issue.provenance.issue_type, "status": issue.provenance.status, }, "ingested_at": issue.ingested_at, }) def delete(self, project_id: str, knowledge_id: str) -> bool: """Remove a single issue from the index (tombstone semantics). Returns True if the file existed and was removed, False otherwise. Never raises on missing files. """ path = self.project_dir(project_id) / _issue_filename(knowledge_id) try: path.unlink() return True except OSError: return False def load(self, project_id: str, knowledge_id: str) -> Optional[CanonicalJiraIssue]: """Load one canonical issue from disk, or None if absent/corrupt.""" path = self.project_dir(project_id) / _issue_filename(knowledge_id) if not path.exists(): return None try: data = json.loads(path.read_text(encoding="utf-8")) return _dict_to_canonical(data) except (OSError, json.JSONDecodeError, TypeError, KeyError): return None def list_all(self, project_id: str) -> List[CanonicalJiraIssue]: """Every indexed issue for a project, best-effort. Corrupt or unreadable files are silently skipped — one bad document must not prevent the rest of the index from being searchable. """ directory = self.project_dir(project_id) if not directory.is_dir(): return [] results: List[CanonicalJiraIssue] = [] for path in sorted(directory.glob("*.json")): try: data = json.loads(path.read_text(encoding="utf-8")) results.append(_dict_to_canonical(data)) except (OSError, json.JSONDecodeError, TypeError, KeyError): continue return results def count(self, project_id: str) -> int: """Number of indexed issues for a project (fast, no parsing).""" directory = self.project_dir(project_id) if not directory.is_dir(): return 0 return sum(1 for _ in directory.glob("*.json")) def clear(self, project_id: str) -> int: """Remove all indexed issues for a project. Returns the count deleted.""" directory = self.project_dir(project_id) if not directory.is_dir(): return 0 count = 0 for path in directory.glob("*.json"): try: path.unlink() count += 1 except OSError: continue return count def _dict_to_canonical(data: dict) -> CanonicalJiraIssue: """Reconstruct a ``CanonicalJiraIssue`` from its persisted dict form.""" from ...domain.jira_knowledge.canonical_issue import JiraProvenance prov_data = data.get("provenance") or {} return CanonicalJiraIssue( knowledge_id=str(data["knowledge_id"]), project_id=str(data["project_id"]), title=str(data.get("title", "")), content=str(data.get("content", "")), metadata=dict(data.get("metadata") or {}), provenance=JiraProvenance( system=str(prov_data.get("system", "jira")), issue_key=str(prov_data.get("issue_key", "")), project_key=str(prov_data.get("project_key", "")), source_url=str(prov_data.get("source_url", "")), source_updated=str(prov_data.get("source_updated", "")), issue_type=str(prov_data.get("issue_type", "")), status=str(prov_data.get("status", "")), ), ingested_at=str(data.get("ingested_at", "")), ) __all__ = ["JiraKnowledgeIndex"]