update project knowledge function
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
"""Jira Project Knowledge application services.
|
||||
|
||||
This package orchestrates the synchronization of Jira issues into Cowork's
|
||||
canonical knowledge index and provides the target/credential resolution that
|
||||
the MCP provider layer needs at query time. It depends on the domain models
|
||||
(``domain.jira_knowledge``) and on shared infrastructure (secrets, telemetry,
|
||||
atomic persistence) but never on MCP or Qt directly.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .credential_resolver import JiraCredentialResolver
|
||||
from .index_repository import JiraKnowledgeIndex
|
||||
from .sync_service import JiraSyncService
|
||||
from .target_resolver import JiraTargetResolver
|
||||
|
||||
__all__ = [
|
||||
"JiraCredentialResolver",
|
||||
"JiraKnowledgeIndex",
|
||||
"JiraSyncService",
|
||||
"JiraTargetResolver",
|
||||
]
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Resolve Jira credentials for an identity without leaking them.
|
||||
|
||||
Credentials come from the existing ``SecretStore`` interface so tests can
|
||||
inject a fake and production uses the OS keyring. The resolver never caches
|
||||
credentials beyond the call scope and never includes them in error messages,
|
||||
logs, or MCP payloads.
|
||||
|
||||
Key naming convention:
|
||||
- Per-project: ``jira:<cowork_project_id>``
|
||||
- Global fallback: ``jira:default``
|
||||
|
||||
The email is stored alongside the token under the same key as a JSON pair
|
||||
``{"email": "...", "api_token": "..."}`` so one secret-store entry carries
|
||||
both values atomically.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from ...infrastructure.secrets.secret_store import SecretStore
|
||||
from ...mcp_servers.project_context.foundation import IdentityContext, ProviderError
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JiraCredentials:
|
||||
"""Immutable credential pair resolved for one call."""
|
||||
email: str
|
||||
api_token: str
|
||||
|
||||
|
||||
def _secret_key(cowork_project_id: str) -> str:
|
||||
return f"jira:{cowork_project_id}"
|
||||
|
||||
|
||||
_GLOBAL_KEY = "jira:default"
|
||||
|
||||
|
||||
class JiraCredentialResolver:
|
||||
"""Resolve ``(email, api_token)`` from the secret store for one identity.
|
||||
|
||||
Raises ``UNAVAILABLE`` when no credentials are configured — never returns
|
||||
empty strings that would cause a silent 401 at the HTTP layer.
|
||||
"""
|
||||
|
||||
def __init__(self, store: SecretStore) -> None:
|
||||
self._store = store
|
||||
|
||||
def resolve(self, identity: IdentityContext) -> JiraCredentials:
|
||||
"""Look up credentials by project-specific key, then global fallback.
|
||||
|
||||
Raises:
|
||||
ProviderError: When neither key exists or the stored value is
|
||||
malformed.
|
||||
"""
|
||||
raw = self._store.get(_secret_key(identity.project))
|
||||
if not raw:
|
||||
raw = self._store.get(_GLOBAL_KEY)
|
||||
if not raw:
|
||||
raise ProviderError(
|
||||
"UNAVAILABLE",
|
||||
"Jira credentials are not configured for this project.",
|
||||
retryable=False,
|
||||
)
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
raise ProviderError(
|
||||
"UNAVAILABLE",
|
||||
"Stored Jira credentials are malformed; re-enter them in Connectors.",
|
||||
retryable=False,
|
||||
)
|
||||
if not isinstance(parsed, dict):
|
||||
raise ProviderError(
|
||||
"UNAVAILABLE",
|
||||
"Stored Jira credentials are malformed; re-enter them in Connectors.",
|
||||
retryable=False,
|
||||
)
|
||||
email = str(parsed.get("email", "")).strip()
|
||||
api_token = str(parsed.get("api_token", "")).strip()
|
||||
if not email or not api_token:
|
||||
raise ProviderError(
|
||||
"UNAVAILABLE",
|
||||
"Stored Jira credentials are incomplete; re-enter them in Connectors.",
|
||||
retryable=False,
|
||||
)
|
||||
return JiraCredentials(email=email, api_token=api_token)
|
||||
|
||||
|
||||
__all__ = ["JiraCredentialResolver", "JiraCredentials"]
|
||||
@@ -0,0 +1,173 @@
|
||||
"""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"]
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Jira knowledge synchronization service.
|
||||
|
||||
Orchestrates full and incremental sync of Jira issues into the local
|
||||
canonical knowledge index. Reuses ``core.jira_tool`` for HTTP access and
|
||||
the existing atomic-write / telemetry infrastructure for persistence and
|
||||
observability.
|
||||
|
||||
Design invariants:
|
||||
- Bounded batches: each sync page fetches at most ``_BATCH_SIZE`` issues.
|
||||
- Idempotent upserts: re-syncing the same issue overwrites cleanly.
|
||||
- Partial failure tolerance: one malformed issue does not abort the batch.
|
||||
- Credential isolation: credentials are resolved per-call, never stored on
|
||||
the service instance.
|
||||
- Operational state: every sync updates the manifest with counts, timing,
|
||||
and error category so operators can inspect health without reading logs.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from ...domain.jira_knowledge.canonical_issue import CanonicalJiraIssue, normalize_jira_issue
|
||||
from ...domain.jira_knowledge.sync_state import SyncManifest, load_manifest, save_manifest
|
||||
from ...mcp_servers.project_context.foundation import ProviderError
|
||||
from .credential_resolver import JiraCredentialResolver, JiraCredentials
|
||||
from .index_repository import JiraKnowledgeIndex
|
||||
from .target_resolver import JiraTarget, JiraTargetResolver
|
||||
|
||||
_BATCH_SIZE = 50
|
||||
_MAX_PAGES_PER_SYNC = 200
|
||||
_JQL_FIELDS = (
|
||||
"summary,status,assignee,priority,description,labels,components,"
|
||||
"issuetype,updated,created,issuelinks"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SyncResult:
|
||||
"""Outcome of one sync run."""
|
||||
processed: int
|
||||
failed: int
|
||||
total_indexed: int
|
||||
cursor: str
|
||||
duration_seconds: float
|
||||
error_category: str = ""
|
||||
|
||||
|
||||
class JiraSyncService:
|
||||
"""Full and incremental sync of Jira issues into the knowledge index.
|
||||
|
||||
The service is stateless between calls — all operational state lives in
|
||||
the persisted manifest. This makes it safe to call from a scheduler,
|
||||
a manual trigger, or a test harness interchangeably.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
target_resolver: JiraTargetResolver,
|
||||
credential_resolver: JiraCredentialResolver,
|
||||
index: Optional[JiraKnowledgeIndex] = None,
|
||||
index_root: Optional[Any] = None,
|
||||
) -> None:
|
||||
self._target_resolver = target_resolver
|
||||
self._credential_resolver = credential_resolver
|
||||
self._index = index or JiraKnowledgeIndex(index_root=index_root)
|
||||
|
||||
def full_sync(self, identity: Any) -> SyncResult:
|
||||
"""Paginated full sync of all issues in the identity's Jira project.
|
||||
|
||||
Clears the existing index before importing so deleted/inaccessible
|
||||
issues are naturally removed. The manifest cursor is reset.
|
||||
"""
|
||||
return self._run_sync(identity, incremental=False)
|
||||
|
||||
def incremental_sync(self, identity: Any) -> SyncResult:
|
||||
"""Fetch only issues updated since the last successful sync cursor.
|
||||
|
||||
Falls back to full sync when no cursor exists (first run).
|
||||
"""
|
||||
return self._run_sync(identity, incremental=True)
|
||||
|
||||
def _run_sync(self, identity: Any, *, incremental: bool) -> SyncResult:
|
||||
start = time.monotonic()
|
||||
target = self._target_resolver.resolve(identity)
|
||||
creds = self._credential_resolver.resolve(identity)
|
||||
manifest = load_manifest(self._index._root, target.cowork_project_id)
|
||||
manifest.mark_attempt()
|
||||
save_manifest(self._index._root, manifest)
|
||||
|
||||
# Fall back to full sync when no cursor exists.
|
||||
if incremental and not manifest.sync_cursor:
|
||||
incremental = False
|
||||
|
||||
try:
|
||||
if not incremental:
|
||||
self._index.clear(target.cowork_project_id)
|
||||
|
||||
config = {
|
||||
"base_url": target.jira_base_url,
|
||||
"email": creds.email,
|
||||
"api_token": creds.api_token,
|
||||
}
|
||||
jql = f"project = {target.jira_project_key} ORDER BY updated ASC"
|
||||
if incremental and manifest.sync_cursor:
|
||||
jql = (
|
||||
f"project = {target.jira_project_key} "
|
||||
f"AND updated >= '{manifest.sync_cursor}' "
|
||||
f"ORDER BY updated ASC"
|
||||
)
|
||||
|
||||
processed, failed, latest_cursor = self._fetch_and_index(
|
||||
config=config,
|
||||
jql=jql,
|
||||
project_id=target.cowork_project_id,
|
||||
base_url=target.jira_base_url,
|
||||
)
|
||||
total_indexed = self._index.count(target.cowork_project_id)
|
||||
duration = time.monotonic() - start
|
||||
manifest.mark_success(
|
||||
processed=processed,
|
||||
failed=failed,
|
||||
cursor=latest_cursor or manifest.sync_cursor,
|
||||
duration=duration,
|
||||
total_indexed=total_indexed,
|
||||
)
|
||||
save_manifest(self._index._root, manifest)
|
||||
return SyncResult(
|
||||
processed=processed,
|
||||
failed=failed,
|
||||
total_indexed=total_indexed,
|
||||
cursor=latest_cursor or manifest.sync_cursor,
|
||||
duration_seconds=round(duration, 2),
|
||||
)
|
||||
except ProviderError as exc:
|
||||
duration = time.monotonic() - start
|
||||
manifest.mark_failure(category=exc.code, failed=0)
|
||||
save_manifest(self._index._root, manifest)
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
duration = time.monotonic() - start
|
||||
manifest.mark_failure(category="UNEXPECTED", failed=0)
|
||||
save_manifest(self._index._root, manifest)
|
||||
raise ProviderError(
|
||||
"SYNC_FAILED",
|
||||
f"Jira sync failed: {type(exc).__name__}",
|
||||
retryable=True,
|
||||
) from exc
|
||||
|
||||
def _fetch_and_index(
|
||||
self,
|
||||
*,
|
||||
config: Dict[str, str],
|
||||
jql: str,
|
||||
project_id: str,
|
||||
base_url: str,
|
||||
) -> Tuple[int, int, str]:
|
||||
"""Paginate through Jira search results, normalize and upsert each issue.
|
||||
|
||||
Returns ``(processed, failed, latest_updated_cursor)``.
|
||||
"""
|
||||
from ...core import jira_tool
|
||||
|
||||
processed = 0
|
||||
failed = 0
|
||||
latest_cursor = ""
|
||||
start_at = 0
|
||||
|
||||
for _ in range(_MAX_PAGES_PER_SYNC):
|
||||
try:
|
||||
data = jira_tool._get(
|
||||
config,
|
||||
"/rest/api/2/search",
|
||||
{
|
||||
"jql": jql,
|
||||
"startAt": start_at,
|
||||
"maxResults": _BATCH_SIZE,
|
||||
"fields": _JQL_FIELDS,
|
||||
},
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise ProviderError(
|
||||
"UPSTREAM_ERROR",
|
||||
"Failed to fetch issues from Jira.",
|
||||
retryable=True,
|
||||
) from exc
|
||||
|
||||
issues: List[dict] = data.get("issues") or []
|
||||
if not issues:
|
||||
break
|
||||
|
||||
for raw in issues:
|
||||
try:
|
||||
canonical = normalize_jira_issue(
|
||||
raw, project_id=project_id, jira_base_url=base_url,
|
||||
)
|
||||
self._index.upsert(canonical)
|
||||
processed += 1
|
||||
# Track the latest updated timestamp for incremental cursor.
|
||||
updated = canonical.provenance.source_updated
|
||||
if updated and updated > latest_cursor:
|
||||
latest_cursor = updated
|
||||
except Exception: # noqa: BLE001
|
||||
failed += 1
|
||||
continue
|
||||
|
||||
total = data.get("total", 0)
|
||||
start_at += len(issues)
|
||||
if start_at >= total:
|
||||
break
|
||||
|
||||
return processed, failed, latest_cursor
|
||||
|
||||
|
||||
__all__ = ["JiraSyncService", "SyncResult"]
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Resolve the approved Jira project binding for an identity.
|
||||
|
||||
The target resolver answers: "which Jira project key is this identity allowed
|
||||
to sync/search?" The answer comes from configuration, never from the caller's
|
||||
``project_id`` argument. This is the structural guarantee that prevents a
|
||||
caller-controlled value from redirecting queries to another project's data.
|
||||
|
||||
Configuration sources (checked in order):
|
||||
1. ``JIRA_KB_PROJECT_MAP`` environment variable (JSON dict mapping
|
||||
``org_unit/customer/project`` or bare ``project`` → Jira project key).
|
||||
2. ``jira_knowledge.projects`` section in the Cowork config file.
|
||||
3. Fallback: the identity's ``project`` field used as-is when it looks like a
|
||||
valid Jira project key (uppercase letters/digits with a hyphen).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from ...mcp_servers.project_context.foundation import IdentityContext, ProviderError
|
||||
|
||||
_JIRA_KEY_PATTERN = re.compile(r"^[A-Z][A-Z0-9]+$")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JiraTarget:
|
||||
"""Resolved Jira project binding for one identity."""
|
||||
jira_project_key: str
|
||||
jira_base_url: str
|
||||
cowork_project_id: str
|
||||
|
||||
|
||||
class JiraTargetResolver:
|
||||
"""Identity → approved Jira project binding.
|
||||
|
||||
Never trusts caller-supplied routing. If no binding exists for the
|
||||
identity, raises ``UNAVAILABLE`` so the provider layer can return a clean
|
||||
error instead of silently falling back to the wrong project.
|
||||
"""
|
||||
|
||||
def resolve(self, identity: IdentityContext) -> JiraTarget:
|
||||
"""Resolve the Jira target for ``identity``.
|
||||
|
||||
Raises:
|
||||
ProviderError: When no binding is configured or the identity's
|
||||
project is not mapped to an approved Jira project.
|
||||
"""
|
||||
base_url = self._resolve_base_url()
|
||||
if not base_url:
|
||||
raise ProviderError(
|
||||
"UNAVAILABLE",
|
||||
"Jira base URL is not configured for this environment.",
|
||||
retryable=False,
|
||||
)
|
||||
|
||||
project_key = self._resolve_project_key(identity)
|
||||
if not project_key:
|
||||
raise ProviderError(
|
||||
"UNAVAILABLE",
|
||||
f"No Jira project binding is configured for identity '{identity.project}'.",
|
||||
retryable=False,
|
||||
)
|
||||
|
||||
return JiraTarget(
|
||||
jira_project_key=project_key,
|
||||
jira_base_url=base_url,
|
||||
cowork_project_id=identity.project,
|
||||
)
|
||||
|
||||
def _resolve_base_url(self) -> str:
|
||||
"""Jira base URL from env or config."""
|
||||
env = os.environ.get("JIRA_KB_BASE_URL", "").strip().rstrip("/")
|
||||
if env:
|
||||
return env
|
||||
try:
|
||||
from ...infrastructure.config.json_config_repository import JsonConfigRepository
|
||||
cfg = JsonConfigRepository()
|
||||
jira_cfg = cfg.data.get("jira", {}) or {}
|
||||
url = str(jira_cfg.get("base_url", "") or "").strip().rstrip("/")
|
||||
return url
|
||||
except Exception: # noqa: BLE001
|
||||
return ""
|
||||
|
||||
def _resolve_project_key(self, identity: IdentityContext) -> str:
|
||||
"""Map the identity to its approved Jira project key."""
|
||||
# 1. Environment variable map (for CI / container deployments).
|
||||
env_map = self._load_env_map()
|
||||
identity_key = f"{identity.org_unit}/{identity.customer}/{identity.project}"
|
||||
key = env_map.get(identity_key) or env_map.get(identity.project)
|
||||
if key and _JIRA_KEY_PATTERN.match(key):
|
||||
return key
|
||||
|
||||
# 2. Config file map.
|
||||
cfg_map = self._load_config_map()
|
||||
key = cfg_map.get(identity_key) or cfg_map.get(identity.project)
|
||||
if key and _JIRA_KEY_PATTERN.match(key):
|
||||
return key
|
||||
|
||||
# 3. Fallback: identity.project itself if it looks like a Jira key.
|
||||
if _JIRA_KEY_PATTERN.match(identity.project):
|
||||
return identity.project
|
||||
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _load_env_map() -> dict[str, str]:
|
||||
raw = os.environ.get("JIRA_KB_PROJECT_MAP", "").strip()
|
||||
if not raw:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
if not isinstance(parsed, dict):
|
||||
return {}
|
||||
return {str(k): str(v) for k, v in parsed.items() if isinstance(k, str) and isinstance(v, str)}
|
||||
|
||||
@staticmethod
|
||||
def _load_config_map() -> dict[str, str]:
|
||||
try:
|
||||
from ...infrastructure.config.json_config_repository import JsonConfigRepository
|
||||
cfg = JsonConfigRepository()
|
||||
jk = cfg.data.get("jira_knowledge", {}) or {}
|
||||
projects = jk.get("projects", {}) or {}
|
||||
if isinstance(projects, dict):
|
||||
return {str(k): str(v) for k, v in projects.items()}
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
__all__ = ["JiraTarget", "JiraTargetResolver"]
|
||||
Reference in New Issue
Block a user