173 lines
6.7 KiB
Python
173 lines
6.7 KiB
Python
"""Read/write repository for the per-project Jira knowledge index.
|
|
|
|
Each project's index lives under ``<index_root>/<project_id>/issues/`` as one
|
|
JSON file per canonical issue. The manifest (sync state) sits beside it at
|
|
``<index_root>/<project_id>/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"] |