fixes redmine
This commit is contained in:
parent
cfd303792f
commit
e9c39f8e31
7 changed files with 149 additions and 8 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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=[
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
],
|
||||
})
|
||||
|
|
@ -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],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
|
|
|||
Loading…
Reference in a new issue