JiraTargetResolver._resolve_base_url() and _load_config_map() called JsonConfigRepository() without the required path argument, causing a TypeError that was silently caught by except Exception, returning empty strings. This made sync fail with 'Jira base URL is not configured for this environment' even when the user had saved valid credentials. Fix: import CONFIG_PATH from config module and pass it to both JsonConfigRepository() calls so they read the same config file that AppConfig writes to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
137 lines
5.0 KiB
Python
137 lines
5.0 KiB
Python
"""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 ...config import CONFIG_PATH
|
|
from ...infrastructure.config.json_config_repository import JsonConfigRepository
|
|
cfg = JsonConfigRepository(CONFIG_PATH)
|
|
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 ...config import CONFIG_PATH
|
|
from ...infrastructure.config.json_config_repository import JsonConfigRepository
|
|
cfg = JsonConfigRepository(CONFIG_PATH)
|
|
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"] |