# Copyright (c) 2026 Patrick Motsch # All rights reserved. """Redmine REST connector. Async / aiohttp port of the SSS pilot client (``pamocreate/projects/valueon/sss/project_mars/redmine-sync/code/_redmineClient.py``) plus the read-side helpers required by ``serviceRedmine`` and ``serviceRedmineStats``. Auth: ``X-Redmine-API-Key`` header. The key is *never* logged. Idempotency / safety: - ``DELETE /issues/{id}`` is often forbidden in Redmine (HTTP 403). ``deleteIssue`` returns ``False`` instead of raising in that case so the higher layer can fall back to status-based archival. - A small ``_throttleSeconds`` delay (default 150 ms) is awaited after every write call to keep the SSS server happy. """ from __future__ import annotations import asyncio import logging from typing import Any, Dict, List, Optional, Tuple from urllib.parse import urlencode import aiohttp from modules.datamodels.datamodelTickets import TicketBase, TicketFieldAttribute logger = logging.getLogger(__name__) class RedmineApiError(RuntimeError): """Raised when the Redmine API returns a non-success status.""" def __init__(self, status: int, body: str, method: str, path: str): self.status = status self.body = body self.method = method self.path = path super().__init__(f"Redmine {method} {path} failed: HTTP {status} {body[:300]}") class ConnectorTicketsRedmine(TicketBase): """Async Redmine connector. One instance per (baseUrl, apiKey, projectId).""" def __init__( self, *, baseUrl: str, apiKey: str, projectId: str, throttleSeconds: float = 0.15, timeoutSeconds: float = 30.0, ) -> None: if not baseUrl: raise ValueError("Redmine baseUrl is required") if not apiKey: raise ValueError("Redmine apiKey is required") self._baseUrl = baseUrl.rstrip("/") self._apiKey = apiKey self._projectId = str(projectId) if projectId is not None else "" self._throttleSeconds = max(0.0, float(throttleSeconds)) self._timeoutSeconds = float(timeoutSeconds) # ------------------------------------------------------------------ # Low-level # ------------------------------------------------------------------ def _headers(self) -> Dict[str, str]: return { "X-Redmine-API-Key": self._apiKey, "Content-Type": "application/json", "Accept": "application/json", } async def _call( self, method: str, path: str, *, payload: Optional[Dict[str, Any]] = None, params: Optional[Dict[str, Any]] = None, ) -> Tuple[int, Optional[Dict[str, Any]], str]: """Single REST call. Returns ``(status, json_or_none, raw_body)``. Does *not* raise -- the caller decides whether a non-2xx is fatal (e.g. 403 on DELETE is expected and handled). """ url = f"{self._baseUrl}{path}" if params: url = f"{url}?{urlencode(params)}" timeout = aiohttp.ClientTimeout(total=self._timeoutSeconds) try: async with aiohttp.ClientSession(timeout=timeout) as session: async with session.request(method, url, headers=self._headers(), json=payload) as resp: raw = await resp.text() parsed: Optional[Dict[str, Any]] = None if raw: try: parsed = await resp.json(content_type=None) except Exception: parsed = None return resp.status, parsed, raw except aiohttp.ClientError as e: logger.warning(f"Redmine {method} {path} client error: {e}") return -1, None, f"ClientError: {e}" except asyncio.TimeoutError: logger.warning(f"Redmine {method} {path} timeout after {self._timeoutSeconds}s") return -1, None, "Timeout" @staticmethod def _isOk(status: int) -> bool: return 200 <= status < 300 async def _gentle(self) -> None: if self._throttleSeconds > 0: await asyncio.sleep(self._throttleSeconds) # ------------------------------------------------------------------ # Identity / health # ------------------------------------------------------------------ async def whoAmI(self) -> Dict[str, Any]: status, body, raw = await self._call("GET", "/users/current.json") if not self._isOk(status) or not body: raise RedmineApiError(status, raw, "GET", "/users/current.json") return body.get("user", {}) # ------------------------------------------------------------------ # Project meta -- trackers, statuses, priorities, custom fields, users # ------------------------------------------------------------------ async def getTrackers(self) -> List[Dict[str, Any]]: status, body, raw = await self._call("GET", "/trackers.json") if not self._isOk(status) or not body: raise RedmineApiError(status, raw, "GET", "/trackers.json") return body.get("trackers", []) or [] async def getStatuses(self) -> List[Dict[str, Any]]: status, body, raw = await self._call("GET", "/issue_statuses.json") if not self._isOk(status) or not body: raise RedmineApiError(status, raw, "GET", "/issue_statuses.json") return body.get("issue_statuses", []) or [] async def getPriorities(self) -> List[Dict[str, Any]]: status, body, raw = await self._call( "GET", "/enumerations/issue_priorities.json" ) if not self._isOk(status) or not body: return [] return body.get("issue_priorities", []) or [] async def getCustomFields(self) -> List[Dict[str, Any]]: """Requires admin privileges in Redmine. Returns ``[]`` if forbidden.""" status, body, raw = await self._call("GET", "/custom_fields.json") if status == 403 or status == 401: logger.info("Redmine /custom_fields.json forbidden -- using per-issue field discovery") return [] if not self._isOk(status) or not body: raise RedmineApiError(status, raw, "GET", "/custom_fields.json") return body.get("custom_fields", []) or [] async def getProjectUsers(self) -> List[Dict[str, Any]]: status, body, raw = await self._call( "GET", f"/projects/{self._projectId}/memberships.json", params={"limit": 100} ) if not self._isOk(status) or not body: return [] members = body.get("memberships", []) or [] users: List[Dict[str, Any]] = [] seen: set[int] = set() for m in members: user = m.get("user") if not user: continue uid = user.get("id") if uid in seen: continue seen.add(uid) users.append(user) return users async def getProjectInfo(self) -> Dict[str, Any]: status, body, raw = await self._call("GET", f"/projects/{self._projectId}.json") if not self._isOk(status) or not body: raise RedmineApiError(status, raw, "GET", f"/projects/{self._projectId}.json") return body.get("project", {}) # ------------------------------------------------------------------ # Issues -- read # ------------------------------------------------------------------ async def getIssue( self, issueId: int, *, includeRelations: bool = True, includeChildren: bool = False ) -> Dict[str, Any]: includes = ["custom_fields", "journals"] if includeRelations: includes.append("relations") if includeChildren: includes.append("children") params = {"include": ",".join(includes)} status, body, raw = await self._call("GET", f"/issues/{issueId}.json", params=params) if not self._isOk(status) or not body: raise RedmineApiError(status, raw, "GET", f"/issues/{issueId}.json") return body.get("issue", {}) async def listIssues( self, *, trackerId: Optional[int] = None, statusId: Optional[str] = "*", updatedOnFrom: Optional[str] = None, updatedOnTo: Optional[str] = None, createdOnFrom: Optional[str] = None, createdOnTo: Optional[str] = None, assignedToId: Optional[int] = None, subjectContains: Optional[str] = None, limit: int = 100, offset: int = 0, include: Optional[List[str]] = None, ) -> Dict[str, Any]: """Single-page list. Returns the raw envelope ``{issues, total_count, offset, limit}``.""" params: Dict[str, Any] = { "project_id": self._projectId, "limit": str(limit), "offset": str(offset), } if statusId is not None: params["status_id"] = str(statusId) if trackerId is not None: params["tracker_id"] = str(trackerId) if assignedToId is not None: params["assigned_to_id"] = str(assignedToId) if subjectContains: params["subject"] = f"~{subjectContains}" if updatedOnFrom and updatedOnTo: params["updated_on"] = f"><{updatedOnFrom}|{updatedOnTo}" elif updatedOnFrom: params["updated_on"] = f">={updatedOnFrom}" elif updatedOnTo: params["updated_on"] = f"<={updatedOnTo}" if createdOnFrom and createdOnTo: params["created_on"] = f"><{createdOnFrom}|{createdOnTo}" elif createdOnFrom: params["created_on"] = f">={createdOnFrom}" elif createdOnTo: params["created_on"] = f"<={createdOnTo}" if include: params["include"] = ",".join(include) status, body, raw = await self._call("GET", "/issues.json", params=params) if not self._isOk(status) or not body: raise RedmineApiError(status, raw, "GET", "/issues.json") return body async def listAllIssues( self, *, trackerId: Optional[int] = None, statusId: Optional[str] = "*", updatedOnFrom: Optional[str] = None, updatedOnTo: Optional[str] = None, createdOnFrom: Optional[str] = None, createdOnTo: Optional[str] = None, assignedToId: Optional[int] = None, pageSize: int = 100, maxPages: int = 50, include: Optional[List[str]] = None, ) -> List[Dict[str, Any]]: """Paginate ``listIssues`` and return all matching raw issues.""" all_issues: List[Dict[str, Any]] = [] offset = 0 for _page in range(maxPages): envelope = await self.listIssues( trackerId=trackerId, statusId=statusId, updatedOnFrom=updatedOnFrom, updatedOnTo=updatedOnTo, createdOnFrom=createdOnFrom, createdOnTo=createdOnTo, assignedToId=assignedToId, limit=pageSize, offset=offset, include=include, ) page_issues = envelope.get("issues", []) or [] all_issues.extend(page_issues) total = int(envelope.get("total_count") or 0) offset += len(page_issues) if not page_issues or offset >= total: break return all_issues async def listRelations(self, issueId: int) -> List[Dict[str, Any]]: issue = await self.getIssue(issueId, includeRelations=True) return issue.get("relations", []) or [] # ------------------------------------------------------------------ # Issues -- write # ------------------------------------------------------------------ async def createIssue(self, fields: Dict[str, Any]) -> Dict[str, Any]: body_in = {"issue": dict(fields)} body_in["issue"].setdefault("project_id", self._projectId) status, body, raw = await self._call("POST", "/issues.json", payload=body_in) await self._gentle() if not self._isOk(status) or not body: raise RedmineApiError(status, raw, "POST", "/issues.json") return body.get("issue", {}) async def updateIssue( self, issueId: int, fields: Dict[str, Any], *, notes: Optional[str] = None ) -> bool: body_in: Dict[str, Any] = {"issue": dict(fields)} if notes: body_in["issue"]["notes"] = notes status, body, raw = await self._call("PUT", f"/issues/{issueId}.json", payload=body_in) await self._gentle() if status == 204: return True if not self._isOk(status): raise RedmineApiError(status, raw, "PUT", f"/issues/{issueId}.json") return True async def deleteIssue(self, issueId: int) -> bool: """Returns ``False`` if Redmine forbids deletion (HTTP 403/401).""" status, body, raw = await self._call("DELETE", f"/issues/{issueId}.json") await self._gentle() if status in (200, 204): return True if status in (401, 403): logger.info(f"Redmine DELETE issue {issueId} forbidden ({status}) -- caller should fall back") return False raise RedmineApiError(status, raw, "DELETE", f"/issues/{issueId}.json") # ------------------------------------------------------------------ # Relations -- write # ------------------------------------------------------------------ async def addRelation( self, fromId: int, toId: int, *, relationType: str = "relates", delay: Optional[int] = None ) -> Dict[str, Any]: rel: Dict[str, Any] = {"issue_to_id": toId, "relation_type": relationType} if delay is not None: rel["delay"] = int(delay) status, body, raw = await self._call( "POST", f"/issues/{fromId}/relations.json", payload={"relation": rel} ) await self._gentle() if not self._isOk(status) or not body: raise RedmineApiError(status, raw, "POST", f"/issues/{fromId}/relations.json") return body.get("relation", {}) async def deleteRelation(self, relationId: int) -> bool: status, body, raw = await self._call("DELETE", f"/relations/{relationId}.json") await self._gentle() if status in (200, 204): return True if status in (401, 403): return False raise RedmineApiError(status, raw, "DELETE", f"/relations/{relationId}.json") # ------------------------------------------------------------------ # TicketBase compliance (used by AI-tool path) # ------------------------------------------------------------------ async def readAttributes(self) -> List[TicketFieldAttribute]: """Static base attributes + project custom fields (best-effort).""" attrs: List[TicketFieldAttribute] = [ TicketFieldAttribute(fieldName="Subject", field="subject"), TicketFieldAttribute(fieldName="Description", field="description"), TicketFieldAttribute(fieldName="Tracker", field="tracker_id"), TicketFieldAttribute(fieldName="Status", field="status_id"), TicketFieldAttribute(fieldName="Priority", field="priority_id"), TicketFieldAttribute(fieldName="Assignee", field="assigned_to_id"), TicketFieldAttribute(fieldName="Parent", field="parent_issue_id"), TicketFieldAttribute(fieldName="Target Version", field="fixed_version_id"), ] try: cfs = await self.getCustomFields() except Exception: cfs = [] for cf in cfs: try: attrs.append( TicketFieldAttribute( fieldName=str(cf.get("name", f"cf_{cf.get('id')}")), field=f"cf_{cf.get('id')}", ) ) except Exception: continue return attrs async def readTasks(self, *, limit: int = 0) -> List[Dict[str, Any]]: if limit and limit > 0: envelope = await self.listIssues(limit=limit) return envelope.get("issues", []) or [] return await self.listAllIssues() async def writeTasks(self, tasklist: List[Dict[str, Any]]) -> None: for task in tasklist: issue_id = task.get("id") fields = {k: v for k, v in task.items() if k != "id"} if issue_id: await self.updateIssue(int(issue_id), fields) else: await self.createIssue(fields)