fixes redmine

This commit is contained in:
ValueOn AG 2026-05-07 11:01:37 +02:00
parent cfd303792f
commit e9c39f8e31
7 changed files with 149 additions and 8 deletions

View file

@ -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}) 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}) 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}) 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( closedOnTs: Optional[float] = Field(
default=None, 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.", 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 fixedVersionName: Optional[str] = None
categoryId: Optional[int] = None categoryId: Optional[int] = None
categoryName: Optional[str] = None categoryName: Optional[str] = None
doneRatio: Optional[int] = None
createdOn: Optional[str] = None createdOn: Optional[str] = None
updatedOn: Optional[str] = None updatedOn: Optional[str] = None
customFields: List[RedmineCustomFieldValueDto] = Field(default_factory=list) customFields: List[RedmineCustomFieldValueDto] = Field(default_factory=list)

View file

@ -222,6 +222,7 @@ def _mirroredRowToDto(
fixedVersionName=row.get("fixedVersionName"), fixedVersionName=row.get("fixedVersionName"),
categoryId=row.get("categoryId"), categoryId=row.get("categoryId"),
categoryName=row.get("categoryName"), categoryName=row.get("categoryName"),
doneRatio=row.get("doneRatio"),
createdOn=row.get("createdOn"), createdOn=row.get("createdOn"),
updatedOn=row.get("updatedOn"), updatedOn=row.get("updatedOn"),
customFields=[ customFields=[

View file

@ -402,6 +402,7 @@ def _ticketRecordFromIssue(
"fixedVersionName": fixed_version.get("name"), "fixedVersionName": fixed_version.get("name"),
"categoryId": category.get("id"), "categoryId": category.get("id"),
"categoryName": category.get("name"), "categoryName": category.get("name"),
"doneRatio": issue.get("done_ratio"),
"createdOn": created_on, "createdOn": created_on,
"updatedOn": updated_on, "updatedOn": updated_on,
"createdOnTs": _parseRedmineDateToEpoch(created_on), "createdOnTs": _parseRedmineDateToEpoch(created_on),

View file

@ -17,7 +17,7 @@ SANDBOX_ALLOWED_MODULES = {
} }
_PYTHON_BLOCKED_BUILTINS = { _PYTHON_BLOCKED_BUILTINS = {
"open", "exec", "eval", "compile", "__import__", "globals", "locals", "exec", "eval", "compile", "__import__", "globals", "locals",
"getattr", "setattr", "delattr", "breakpoint", "exit", "quit", "getattr", "setattr", "delattr", "breakpoint", "exit", "quit",
"input", "memoryview", "input", "memoryview",
} }
@ -73,6 +73,29 @@ def _buildRestrictedGlobals() -> Dict[str, Any]:
return {"__builtins__": safeBuiltins} 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): def _makeReadFile(services):
"""Create a readFile(fileId) closure bound to the current services context.""" """Create a readFile(fileId) closure bound to the current services context."""
def readFile(fileId: str) -> str: def readFile(fileId: str) -> str:
@ -92,6 +115,8 @@ async def executePython(code: str, *, services=None) -> Dict[str, Any]:
def _run(): def _run():
restrictedGlobals = _buildRestrictedGlobals() restrictedGlobals = _buildRestrictedGlobals()
vfs = _VirtualFS()
restrictedGlobals["__builtins__"]["open"] = vfs.open
if services: if services:
restrictedGlobals["__builtins__"]["readFile"] = _makeReadFile(services) restrictedGlobals["__builtins__"]["readFile"] = _makeReadFile(services)
capturedOutput = io.StringIO() capturedOutput = io.StringIO()

View file

@ -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
],
})

View file

@ -64,19 +64,23 @@ async def listTicketsAction(self, parameters: Dict[str, Any]) -> ActionResult:
logger.exception("redmine.listTickets failed") logger.exception("redmine.listTickets failed")
return ActionResult.isFailure(error=f"List tickets failed: {exc}") 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 limit = 100
try: try:
limit = max(1, min(500, int(parameters.get("limit") or 100))) limit = max(1, min(500, int(parameters.get("limit") or 100)))
except (TypeError, ValueError): except (TypeError, ValueError):
limit = 100 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={ return ActionResult.isSuccess(data={
"count": len(truncated), "count": len(page),
"totalMatched": len(tickets), "totalMatched": len(tickets),
"truncated": len(tickets) > limit, "offset": offset,
"tickets": [ticketToDict(t) for t in truncated], "hasMore": (offset + limit) < len(tickets),
"tickets": [ticketToDict(t) for t in page],
}) })

View file

@ -22,6 +22,7 @@ from modules.workflows.methods.methodBase import MethodBase
from .actions.createTicket import createTicketAction from .actions.createTicket import createTicketAction
from .actions.getStats import getStatsAction from .actions.getStats import getStatsAction
from .actions.listRelations import listRelationsAction
from .actions.listTickets import listTicketsAction from .actions.listTickets import listTicketsAction
from .actions.readTicket import readTicket from .actions.readTicket import readTicket
from .actions.runSync import runSyncAction from .actions.runSync import runSyncAction
@ -90,9 +91,42 @@ class MethodRedmine(MethodBase):
name="limit", type="int", frontendType=FrontendType.TEXT, name="limit", type="int", frontendType=FrontendType.TEXT,
required=False, description="Max tickets in the result (1-500, default 100).", 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__), 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( "createTicket": WorkflowActionDefinition(
actionId="redmine.createTicket", actionId="redmine.createTicket",
description="Create a new Redmine ticket. Requires subject and trackerId.", 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. # rather than through the action dict also work.
self.readTicket = readTicket.__get__(self, self.__class__) self.readTicket = readTicket.__get__(self, self.__class__)
self.listTickets = listTicketsAction.__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.createTicket = createTicketAction.__get__(self, self.__class__)
self.updateTicket = updateTicketAction.__get__(self, self.__class__) self.updateTicket = updateTicketAction.__get__(self, self.__class__)
self.getStats = getStatsAction.__get__(self, self.__class__) self.getStats = getStatsAction.__get__(self, self.__class__)