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__)