gateway/modules/connectors/connectorTicketsRedmine.py
2026-04-21 21:30:11 +02:00

419 lines
17 KiB
Python

# 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", {})
async def getIssueCategories(self) -> List[Dict[str, Any]]:
"""Per-project issue categories. Returns ``[]`` if the endpoint
is forbidden or the project has no categories defined."""
path = f"/projects/{self._projectId}/issue_categories.json"
status, body, raw = await self._call("GET", path)
if status in (401, 403, 404) or not self._isOk(status) or not body:
return []
return body.get("issue_categories", []) or []
# ------------------------------------------------------------------
# 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)