Files
cowork-local/application/jira_knowledge/target_resolver.py
T
thanhnvandClaude Opus 5 dd705c92b6 fix(jira-knowledge): pass CONFIG_PATH to JsonConfigRepository in target_resolver
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>
2026-09-08 10:43:39 +09:00

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"]