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})
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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=[
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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")
|
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],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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__)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue