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

View file

@ -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=[

View file

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

View file

@ -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()

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

View file

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