410 lines
16 KiB
Python
410 lines
16 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", {})
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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)
|