From e9c39f8e316fbe4b60acbd004ad320b7b7a12077 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Thu, 7 May 2026 11:01:37 +0200
Subject: [PATCH] fixes redmine
---
modules/features/redmine/datamodelRedmine.py | 2 +
modules/features/redmine/serviceRedmine.py | 1 +
.../features/redmine/serviceRedmineSync.py | 1 +
.../services/serviceAgent/sandboxExecutor.py | 27 ++++++-
.../methodRedmine/actions/listRelations.py | 73 +++++++++++++++++++
.../methodRedmine/actions/listTickets.py | 18 +++--
.../methods/methodRedmine/methodRedmine.py | 35 +++++++++
7 files changed, 149 insertions(+), 8 deletions(-)
create mode 100644 modules/workflows/methods/methodRedmine/actions/listRelations.py
diff --git a/modules/features/redmine/datamodelRedmine.py b/modules/features/redmine/datamodelRedmine.py
index 61555826..e33ee407 100644
--- a/modules/features/redmine/datamodelRedmine.py
+++ b/modules/features/redmine/datamodelRedmine.py
@@ -223,6 +223,7 @@ class RedmineTicketMirror(PowerOnModel):
fixedVersionName: Optional[str] = Field(default=None, json_schema_extra={"label": "Zielversion", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
categoryId: Optional[int] = Field(default=None, json_schema_extra={"label": "Kategorie-ID", "frontend_type": "number", "frontend_readonly": True, "frontend_required": False})
categoryName: Optional[str] = Field(default=None, json_schema_extra={"label": "Kategorie", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
+ doneRatio: Optional[int] = Field(default=None, description="Redmine % done (0-100)", json_schema_extra={"label": "% erledigt", "frontend_type": "number", "frontend_readonly": True, "frontend_required": False})
closedOnTs: Optional[float] = Field(
default=None,
description="Best-effort UTC epoch when the ticket transitioned to a closed status. Approximated as updatedOnTs for closed tickets at sync time; used by Stats to render the open-vs-total snapshot chart.",
@@ -338,6 +339,7 @@ class RedmineTicketDto(BaseModel):
fixedVersionName: Optional[str] = None
categoryId: Optional[int] = None
categoryName: Optional[str] = None
+ doneRatio: Optional[int] = None
createdOn: Optional[str] = None
updatedOn: Optional[str] = None
customFields: List[RedmineCustomFieldValueDto] = Field(default_factory=list)
diff --git a/modules/features/redmine/serviceRedmine.py b/modules/features/redmine/serviceRedmine.py
index f0cfbfb4..2aea0918 100644
--- a/modules/features/redmine/serviceRedmine.py
+++ b/modules/features/redmine/serviceRedmine.py
@@ -222,6 +222,7 @@ def _mirroredRowToDto(
fixedVersionName=row.get("fixedVersionName"),
categoryId=row.get("categoryId"),
categoryName=row.get("categoryName"),
+ doneRatio=row.get("doneRatio"),
createdOn=row.get("createdOn"),
updatedOn=row.get("updatedOn"),
customFields=[
diff --git a/modules/features/redmine/serviceRedmineSync.py b/modules/features/redmine/serviceRedmineSync.py
index 2fd269d1..32cd5a09 100644
--- a/modules/features/redmine/serviceRedmineSync.py
+++ b/modules/features/redmine/serviceRedmineSync.py
@@ -402,6 +402,7 @@ def _ticketRecordFromIssue(
"fixedVersionName": fixed_version.get("name"),
"categoryId": category.get("id"),
"categoryName": category.get("name"),
+ "doneRatio": issue.get("done_ratio"),
"createdOn": created_on,
"updatedOn": updated_on,
"createdOnTs": _parseRedmineDateToEpoch(created_on),
diff --git a/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py b/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py
index e4671a70..c2e16506 100644
--- a/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py
+++ b/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py
@@ -17,7 +17,7 @@ SANDBOX_ALLOWED_MODULES = {
}
_PYTHON_BLOCKED_BUILTINS = {
- "open", "exec", "eval", "compile", "__import__", "globals", "locals",
+ "exec", "eval", "compile", "__import__", "globals", "locals",
"getattr", "setattr", "delattr", "breakpoint", "exit", "quit",
"input", "memoryview",
}
@@ -73,6 +73,29 @@ def _buildRestrictedGlobals() -> Dict[str, Any]:
return {"__builtins__": safeBuiltins}
+class _VirtualFS:
+ """In-memory virtual filesystem for sandbox open() calls."""
+
+ def __init__(self):
+ self.files: Dict[str, str] = {}
+
+ def open(self, name, mode="r", **_kwargs):
+ if "r" in mode and "+" not in mode:
+ if name not in self.files:
+ raise FileNotFoundError(f"Virtual file '{name}' not found")
+ buf = io.StringIO(self.files[name])
+ buf.name = name
+ return buf
+ buf = io.StringIO()
+ buf.name = name
+ realClose = buf.close
+ def _flushingClose():
+ self.files[name] = buf.getvalue()
+ realClose()
+ buf.close = _flushingClose
+ return buf
+
+
def _makeReadFile(services):
"""Create a readFile(fileId) closure bound to the current services context."""
def readFile(fileId: str) -> str:
@@ -92,6 +115,8 @@ async def executePython(code: str, *, services=None) -> Dict[str, Any]:
def _run():
restrictedGlobals = _buildRestrictedGlobals()
+ vfs = _VirtualFS()
+ restrictedGlobals["__builtins__"]["open"] = vfs.open
if services:
restrictedGlobals["__builtins__"]["readFile"] = _makeReadFile(services)
capturedOutput = io.StringIO()
diff --git a/modules/workflows/methods/methodRedmine/actions/listRelations.py b/modules/workflows/methods/methodRedmine/actions/listRelations.py
new file mode 100644
index 00000000..90f44594
--- /dev/null
+++ b/modules/workflows/methods/methodRedmine/actions/listRelations.py
@@ -0,0 +1,73 @@
+# Copyright (c) 2026 Patrick Motsch
+# All rights reserved.
+"""Workflow action: list Redmine relations from the mirror."""
+
+import logging
+from typing import Any, Dict, List, Optional
+
+from modules.datamodels.datamodelChat import ActionResult
+from modules.features.redmine.interfaceFeatureRedmine import getInterface
+
+from ._shared import resolveInstanceContext
+
+logger = logging.getLogger(__name__)
+
+
+async def listRelationsAction(self, parameters: Dict[str, Any]) -> ActionResult:
+ """List all mirrored relations, optionally filtered by issueId or relationType."""
+ try:
+ user, mandateId, featureInstanceId = resolveInstanceContext(self.services, parameters)
+ except ValueError as exc:
+ return ActionResult.isFailure(error=str(exc))
+
+ iface = getInterface(user, mandateId=mandateId, featureInstanceId=featureInstanceId)
+ rows = iface.listMirroredRelations(featureInstanceId)
+
+ issueId: Optional[int] = None
+ rawIssueId = parameters.get("issueId")
+ if rawIssueId not in (None, ""):
+ try:
+ issueId = int(rawIssueId)
+ except (TypeError, ValueError):
+ return ActionResult.isFailure(error="issueId must be an int")
+
+ relationType = parameters.get("relationType") or None
+
+ if issueId is not None:
+ rows = [
+ r for r in rows
+ if int(r.get("issueId") or 0) == issueId
+ or int(r.get("issueToId") or 0) == issueId
+ ]
+ if relationType:
+ rows = [r for r in rows if r.get("relationType") == relationType]
+
+ limit = 1000
+ try:
+ limit = max(1, min(5000, int(parameters.get("limit") or 1000)))
+ except (TypeError, ValueError):
+ limit = 1000
+
+ offset = 0
+ try:
+ offset = max(0, int(parameters.get("offset") or 0))
+ except (TypeError, ValueError):
+ offset = 0
+
+ page = rows[offset:offset + limit]
+ return ActionResult.isSuccess(data={
+ "count": len(page),
+ "totalMatched": len(rows),
+ "offset": offset,
+ "hasMore": (offset + limit) < len(rows),
+ "relations": [
+ {
+ "redmineRelationId": r.get("redmineRelationId"),
+ "issueId": r.get("issueId"),
+ "issueToId": r.get("issueToId"),
+ "relationType": r.get("relationType"),
+ "delay": r.get("delay"),
+ }
+ for r in page
+ ],
+ })
diff --git a/modules/workflows/methods/methodRedmine/actions/listTickets.py b/modules/workflows/methods/methodRedmine/actions/listTickets.py
index d1867b86..8573237a 100644
--- a/modules/workflows/methods/methodRedmine/actions/listTickets.py
+++ b/modules/workflows/methods/methodRedmine/actions/listTickets.py
@@ -64,19 +64,23 @@ async def listTicketsAction(self, parameters: Dict[str, Any]) -> ActionResult:
logger.exception("redmine.listTickets failed")
return ActionResult.isFailure(error=f"List tickets failed: {exc}")
- # AI-friendly pagination: always capped so we don't accidentally feed a
- # 20k-ticket dump into a context window. Callers that need more must
- # paginate via filters.
limit = 100
try:
limit = max(1, min(500, int(parameters.get("limit") or 100)))
except (TypeError, ValueError):
limit = 100
- truncated = tickets[:limit]
+ offset = 0
+ try:
+ offset = max(0, int(parameters.get("offset") or 0))
+ except (TypeError, ValueError):
+ offset = 0
+
+ page = tickets[offset:offset + limit]
return ActionResult.isSuccess(data={
- "count": len(truncated),
+ "count": len(page),
"totalMatched": len(tickets),
- "truncated": len(tickets) > limit,
- "tickets": [ticketToDict(t) for t in truncated],
+ "offset": offset,
+ "hasMore": (offset + limit) < len(tickets),
+ "tickets": [ticketToDict(t) for t in page],
})
diff --git a/modules/workflows/methods/methodRedmine/methodRedmine.py b/modules/workflows/methods/methodRedmine/methodRedmine.py
index 6c40c951..700375cd 100644
--- a/modules/workflows/methods/methodRedmine/methodRedmine.py
+++ b/modules/workflows/methods/methodRedmine/methodRedmine.py
@@ -22,6 +22,7 @@ from modules.workflows.methods.methodBase import MethodBase
from .actions.createTicket import createTicketAction
from .actions.getStats import getStatsAction
+from .actions.listRelations import listRelationsAction
from .actions.listTickets import listTicketsAction
from .actions.readTicket import readTicket
from .actions.runSync import runSyncAction
@@ -90,9 +91,42 @@ class MethodRedmine(MethodBase):
name="limit", type="int", frontendType=FrontendType.TEXT,
required=False, description="Max tickets in the result (1-500, default 100).",
),
+ "offset": WorkflowActionParameter(
+ name="offset", type="int", frontendType=FrontendType.TEXT,
+ required=False, description="Skip first N matched tickets (for pagination, default 0).",
+ ),
},
execute=listTicketsAction.__get__(self, self.__class__),
),
+ "listRelations": WorkflowActionDefinition(
+ actionId="redmine.listRelations",
+ description="List all mirrored relations. Optional filters: issueId, relationType. Returns issueId<->issueToId pairs with relationType.",
+ dynamicMode=False,
+ outputType="RedmineRelationList",
+ parameters={
+ "featureInstanceId": WorkflowActionParameter(
+ name="featureInstanceId", type="FeatureInstanceRef", frontendType=FrontendType.TEXT,
+ required=True, description="Redmine feature instance",
+ ),
+ "issueId": WorkflowActionParameter(
+ name="issueId", type="int", frontendType=FrontendType.TEXT,
+ required=False, description="Filter relations involving this Redmine issue id (as source or target).",
+ ),
+ "relationType": WorkflowActionParameter(
+ name="relationType", type="str", frontendType=FrontendType.TEXT,
+ required=False, description="Filter by relation type: relates | precedes | follows | blocks | blocked | duplicates | duplicated | copied_to | copied_from.",
+ ),
+ "limit": WorkflowActionParameter(
+ name="limit", type="int", frontendType=FrontendType.TEXT,
+ required=False, description="Max relations in the result (1-5000, default 1000).",
+ ),
+ "offset": WorkflowActionParameter(
+ name="offset", type="int", frontendType=FrontendType.TEXT,
+ required=False, description="Skip first N relations (for pagination, default 0).",
+ ),
+ },
+ execute=listRelationsAction.__get__(self, self.__class__),
+ ),
"createTicket": WorkflowActionDefinition(
actionId="redmine.createTicket",
description="Create a new Redmine ticket. Requires subject and trackerId.",
@@ -253,6 +287,7 @@ class MethodRedmine(MethodBase):
# rather than through the action dict also work.
self.readTicket = readTicket.__get__(self, self.__class__)
self.listTickets = listTicketsAction.__get__(self, self.__class__)
+ self.listRelations = listRelationsAction.__get__(self, self.__class__)
self.createTicket = createTicketAction.__get__(self, self.__class__)
self.updateTicket = updateTicketAction.__get__(self, self.__class__)
self.getStats = getStatsAction.__get__(self, self.__class__)