From 908be0511b0a5c25a85ec69751c8a8e084869129 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 21 Apr 2026 21:30:11 +0200
Subject: [PATCH] redmine integrated and fixed
---
modules/connectors/connectorTicketsRedmine.py | 9 +
.../nodeDefinitions/__init__.py | 2 +
.../nodeDefinitions/redmine.py | 170 ++++++++++++
modules/features/redmine/datamodelRedmine.py | 43 ++-
modules/features/redmine/mainRedmine.py | 14 +-
.../features/redmine/routeFeatureRedmine.py | 4 +
modules/features/redmine/serviceRedmine.py | 8 +
.../features/redmine/serviceRedmineStats.py | 128 ++++++++-
.../redmine/serviceRedmineStatsCache.py | 28 +-
.../features/redmine/serviceRedmineSync.py | 10 +-
.../methods/methodRedmine/__init__.py | 7 +
.../methods/methodRedmine/actions/__init__.py | 2 +
.../methods/methodRedmine/actions/_shared.py | 38 +++
.../methodRedmine/actions/createTicket.py | 65 +++++
.../methods/methodRedmine/actions/getStats.py | 64 +++++
.../methodRedmine/actions/listTickets.py | 82 ++++++
.../methodRedmine/actions/readTicket.py | 46 ++++
.../methods/methodRedmine/actions/runSync.py | 35 +++
.../methodRedmine/actions/updateTicket.py | 68 +++++
.../methods/methodRedmine/methodRedmine.py | 253 ++++++++++++++++++
20 files changed, 1058 insertions(+), 18 deletions(-)
create mode 100644 modules/features/graphicalEditor/nodeDefinitions/redmine.py
create mode 100644 modules/workflows/methods/methodRedmine/__init__.py
create mode 100644 modules/workflows/methods/methodRedmine/actions/__init__.py
create mode 100644 modules/workflows/methods/methodRedmine/actions/_shared.py
create mode 100644 modules/workflows/methods/methodRedmine/actions/createTicket.py
create mode 100644 modules/workflows/methods/methodRedmine/actions/getStats.py
create mode 100644 modules/workflows/methods/methodRedmine/actions/listTickets.py
create mode 100644 modules/workflows/methods/methodRedmine/actions/readTicket.py
create mode 100644 modules/workflows/methods/methodRedmine/actions/runSync.py
create mode 100644 modules/workflows/methods/methodRedmine/actions/updateTicket.py
create mode 100644 modules/workflows/methods/methodRedmine/methodRedmine.py
diff --git a/modules/connectors/connectorTicketsRedmine.py b/modules/connectors/connectorTicketsRedmine.py
index dfdf3dff..9caff47d 100644
--- a/modules/connectors/connectorTicketsRedmine.py
+++ b/modules/connectors/connectorTicketsRedmine.py
@@ -188,6 +188,15 @@ class ConnectorTicketsRedmine(TicketBase):
raise RedmineApiError(status, raw, "GET", f"/projects/{self._projectId}.json")
return body.get("project", {})
+ async def getIssueCategories(self) -> List[Dict[str, Any]]:
+ """Per-project issue categories. Returns ``[]`` if the endpoint
+ is forbidden or the project has no categories defined."""
+ path = f"/projects/{self._projectId}/issue_categories.json"
+ status, body, raw = await self._call("GET", path)
+ if status in (401, 403, 404) or not self._isOk(status) or not body:
+ return []
+ return body.get("issue_categories", []) or []
+
# ------------------------------------------------------------------
# Issues -- read
# ------------------------------------------------------------------
diff --git a/modules/features/graphicalEditor/nodeDefinitions/__init__.py b/modules/features/graphicalEditor/nodeDefinitions/__init__.py
index 6f97137d..31895a44 100644
--- a/modules/features/graphicalEditor/nodeDefinitions/__init__.py
+++ b/modules/features/graphicalEditor/nodeDefinitions/__init__.py
@@ -10,6 +10,7 @@ from .sharepoint import SHAREPOINT_NODES
from .clickup import CLICKUP_NODES
from .file import FILE_NODES
from .trustee import TRUSTEE_NODES
+from .redmine import REDMINE_NODES
from .data import DATA_NODES
from .context import CONTEXT_NODES
@@ -23,6 +24,7 @@ STATIC_NODE_TYPES = (
+ CLICKUP_NODES
+ FILE_NODES
+ TRUSTEE_NODES
+ + REDMINE_NODES
+ DATA_NODES
+ CONTEXT_NODES
)
diff --git a/modules/features/graphicalEditor/nodeDefinitions/redmine.py b/modules/features/graphicalEditor/nodeDefinitions/redmine.py
new file mode 100644
index 00000000..55a6e7c7
--- /dev/null
+++ b/modules/features/graphicalEditor/nodeDefinitions/redmine.py
@@ -0,0 +1,170 @@
+# Copyright (c) 2026 Patrick Motsch
+# All rights reserved.
+"""Redmine node definitions - map to MethodRedmine actions."""
+
+from modules.shared.i18nRegistry import t
+
+REDMINE_NODES = [
+ {
+ "id": "redmine.readTicket",
+ "category": "redmine",
+ "label": t("Ticket lesen"),
+ "description": t("Einzelnes Redmine-Ticket aus dem Mirror laden."),
+ "parameters": [
+ {"name": "featureInstanceId", "type": "string", "required": True, "frontendType": "hidden",
+ "description": t("Redmine Feature-Instanz-ID")},
+ {"name": "ticketId", "type": "number", "required": True, "frontendType": "number",
+ "description": t("Redmine-Ticket-ID")},
+ ],
+ "inputs": 1,
+ "outputs": 1,
+ "inputPorts": {0: {"accepts": ["Transit"]}},
+ "outputPorts": {0: {"schema": "ActionResult"}},
+ "meta": {"icon": "mdi-ticket-outline", "color": "#4A6FA5", "usesAi": False},
+ "_method": "redmine",
+ "_action": "readTicket",
+ },
+ {
+ "id": "redmine.listTickets",
+ "category": "redmine",
+ "label": t("Tickets auflisten"),
+ "description": t("Tickets aus dem lokalen Mirror mit Filtern (Tracker, Status, Zeitraum, Zuweisung)."),
+ "parameters": [
+ {"name": "featureInstanceId", "type": "string", "required": True, "frontendType": "hidden",
+ "description": t("Redmine Feature-Instanz-ID")},
+ {"name": "trackerIds", "type": "string", "required": False, "frontendType": "text",
+ "description": t("Tracker-IDs (Komma-separiert)"), "default": ""},
+ {"name": "status", "type": "string", "required": False, "frontendType": "text",
+ "description": t("Status-Filter: open | closed | *"), "default": "*"},
+ {"name": "dateFrom", "type": "string", "required": False, "frontendType": "date",
+ "description": t("Zeitraum ab (ISO-Datum)"), "default": ""},
+ {"name": "dateTo", "type": "string", "required": False, "frontendType": "date",
+ "description": t("Zeitraum bis (ISO-Datum)"), "default": ""},
+ {"name": "assignedToId", "type": "number", "required": False, "frontendType": "number",
+ "description": t("Nur Tickets dieses Benutzers (ID)")},
+ {"name": "limit", "type": "number", "required": False, "frontendType": "number",
+ "description": t("Max. Anzahl Tickets (1-500)"), "default": 100},
+ ],
+ "inputs": 1,
+ "outputs": 1,
+ "inputPorts": {0: {"accepts": ["Transit"]}},
+ "outputPorts": {0: {"schema": "ActionResult"}},
+ "meta": {"icon": "mdi-format-list-bulleted", "color": "#4A6FA5", "usesAi": False},
+ "_method": "redmine",
+ "_action": "listTickets",
+ },
+ {
+ "id": "redmine.createTicket",
+ "category": "redmine",
+ "label": t("Ticket erstellen"),
+ "description": t("Neues Ticket in Redmine anlegen. Mirror wird sofort aktualisiert."),
+ "parameters": [
+ {"name": "featureInstanceId", "type": "string", "required": True, "frontendType": "hidden",
+ "description": t("Redmine Feature-Instanz-ID")},
+ {"name": "subject", "type": "string", "required": True, "frontendType": "text",
+ "description": t("Ticket-Titel")},
+ {"name": "trackerId", "type": "number", "required": True, "frontendType": "number",
+ "description": t("Tracker-ID (Userstory, Feature, Task, ...)")},
+ {"name": "description", "type": "string", "required": False, "frontendType": "textarea",
+ "description": t("Ticket-Beschreibung"), "default": ""},
+ {"name": "statusId", "type": "number", "required": False, "frontendType": "number",
+ "description": t("Status-ID (optional)")},
+ {"name": "priorityId", "type": "number", "required": False, "frontendType": "number",
+ "description": t("Prioritaet-ID (optional)")},
+ {"name": "assignedToId", "type": "number", "required": False, "frontendType": "number",
+ "description": t("Zugewiesene Benutzer-ID (optional)")},
+ {"name": "parentIssueId", "type": "number", "required": False, "frontendType": "number",
+ "description": t("Uebergeordnetes Ticket (optional)")},
+ {"name": "customFields", "type": "string", "required": False, "frontendType": "textarea",
+ "description": t("Custom Fields als JSON {id: value}"), "default": ""},
+ ],
+ "inputs": 1,
+ "outputs": 1,
+ "inputPorts": {0: {"accepts": ["Transit"]}},
+ "outputPorts": {0: {"schema": "ActionResult"}},
+ "meta": {"icon": "mdi-ticket-plus-outline", "color": "#4A6FA5", "usesAi": False},
+ "_method": "redmine",
+ "_action": "createTicket",
+ },
+ {
+ "id": "redmine.updateTicket",
+ "category": "redmine",
+ "label": t("Ticket bearbeiten"),
+ "description": t("Felder eines Redmine-Tickets aktualisieren. Nur gesetzte Felder werden uebertragen."),
+ "parameters": [
+ {"name": "featureInstanceId", "type": "string", "required": True, "frontendType": "hidden",
+ "description": t("Redmine Feature-Instanz-ID")},
+ {"name": "ticketId", "type": "number", "required": True, "frontendType": "number",
+ "description": t("Ticket-ID")},
+ {"name": "subject", "type": "string", "required": False, "frontendType": "text",
+ "description": t("Neuer Titel")},
+ {"name": "description", "type": "string", "required": False, "frontendType": "textarea",
+ "description": t("Neue Beschreibung")},
+ {"name": "trackerId", "type": "number", "required": False, "frontendType": "number",
+ "description": t("Neuer Tracker")},
+ {"name": "statusId", "type": "number", "required": False, "frontendType": "number",
+ "description": t("Neuer Status")},
+ {"name": "priorityId", "type": "number", "required": False, "frontendType": "number",
+ "description": t("Neue Prioritaet")},
+ {"name": "assignedToId", "type": "number", "required": False, "frontendType": "number",
+ "description": t("Neue Zuweisung")},
+ {"name": "parentIssueId", "type": "number", "required": False, "frontendType": "number",
+ "description": t("Neues Parent-Ticket")},
+ {"name": "notes", "type": "string", "required": False, "frontendType": "textarea",
+ "description": t("Kommentar (Journal-Eintrag)"), "default": ""},
+ {"name": "customFields", "type": "string", "required": False, "frontendType": "textarea",
+ "description": t("Custom Fields als JSON {id: value}"), "default": ""},
+ ],
+ "inputs": 1,
+ "outputs": 1,
+ "inputPorts": {0: {"accepts": ["Transit"]}},
+ "outputPorts": {0: {"schema": "ActionResult"}},
+ "meta": {"icon": "mdi-ticket-confirmation-outline", "color": "#4A6FA5", "usesAi": False},
+ "_method": "redmine",
+ "_action": "updateTicket",
+ },
+ {
+ "id": "redmine.getStats",
+ "category": "redmine",
+ "label": t("Statistik laden"),
+ "description": t("Aggregierte Kennzahlen (KPIs, Durchsatz, Status-Verteilung, Backlog) aus dem Mirror."),
+ "parameters": [
+ {"name": "featureInstanceId", "type": "string", "required": True, "frontendType": "hidden",
+ "description": t("Redmine Feature-Instanz-ID")},
+ {"name": "dateFrom", "type": "string", "required": False, "frontendType": "date",
+ "description": t("Zeitraum ab")},
+ {"name": "dateTo", "type": "string", "required": False, "frontendType": "date",
+ "description": t("Zeitraum bis")},
+ {"name": "bucket", "type": "string", "required": False, "frontendType": "text",
+ "description": t("Bucket: day | week | month"), "default": "week"},
+ {"name": "trackerIds", "type": "string", "required": False, "frontendType": "text",
+ "description": t("Tracker-IDs (Komma-separiert)"), "default": ""},
+ ],
+ "inputs": 1,
+ "outputs": 1,
+ "inputPorts": {0: {"accepts": ["Transit"]}},
+ "outputPorts": {0: {"schema": "ActionResult"}},
+ "meta": {"icon": "mdi-chart-bar", "color": "#4A6FA5", "usesAi": False},
+ "_method": "redmine",
+ "_action": "getStats",
+ },
+ {
+ "id": "redmine.runSync",
+ "category": "redmine",
+ "label": t("Mirror synchronisieren"),
+ "description": t("Tickets und Beziehungen aus Redmine in den lokalen Mirror uebernehmen."),
+ "parameters": [
+ {"name": "featureInstanceId", "type": "string", "required": True, "frontendType": "hidden",
+ "description": t("Redmine Feature-Instanz-ID")},
+ {"name": "force", "type": "boolean", "required": False, "frontendType": "checkbox",
+ "description": t("Vollsync erzwingen (ignoriert lastSyncAt)"), "default": False},
+ ],
+ "inputs": 1,
+ "outputs": 1,
+ "inputPorts": {0: {"accepts": ["Transit"]}},
+ "outputPorts": {0: {"schema": "ActionResult"}},
+ "meta": {"icon": "mdi-database-sync", "color": "#4A6FA5", "usesAi": False},
+ "_method": "redmine",
+ "_action": "runSync",
+ },
+]
diff --git a/modules/features/redmine/datamodelRedmine.py b/modules/features/redmine/datamodelRedmine.py
index 0e8a3302..b5e72cc3 100644
--- a/modules/features/redmine/datamodelRedmine.py
+++ b/modules/features/redmine/datamodelRedmine.py
@@ -221,6 +221,13 @@ class RedmineTicketMirror(PowerOnModel):
parentId: Optional[int] = Field(default=None, json_schema_extra={"label": "Parent-ID", "frontend_type": "number", "frontend_readonly": True, "frontend_required": False})
fixedVersionId: Optional[int] = Field(default=None, json_schema_extra={"label": "Zielversion-ID", "frontend_type": "number", "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})
+ categoryName: Optional[str] = Field(default=None, json_schema_extra={"label": "Kategorie", "frontend_type": "text", "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.",
+ json_schema_extra={"label": "closedOn (epoch)", "frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "frontend_hidden": True},
+ )
createdOn: Optional[str] = Field(default=None, json_schema_extra={"label": "Erstellt am (Redmine)", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
updatedOn: Optional[str] = Field(default=None, json_schema_extra={"label": "Geaendert am (Redmine)", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
createdOnTs: Optional[float] = Field(default=None, description="UTC epoch parsed from createdOn (for SQL filtering)",
@@ -329,6 +336,8 @@ class RedmineTicketDto(BaseModel):
parentId: Optional[int] = None
fixedVersionId: Optional[int] = None
fixedVersionName: Optional[str] = None
+ categoryId: Optional[int] = None
+ categoryName: Optional[str] = None
createdOn: Optional[str] = None
updatedOn: Optional[str] = None
customFields: List[RedmineCustomFieldValueDto] = Field(default_factory=list)
@@ -361,6 +370,10 @@ class RedmineFieldSchemaDto(BaseModel):
statuses: List[RedmineFieldChoiceDto] = Field(default_factory=list)
priorities: List[RedmineFieldChoiceDto] = Field(default_factory=list)
users: List[RedmineFieldChoiceDto] = Field(default_factory=list)
+ categories: List[RedmineFieldChoiceDto] = Field(
+ default_factory=list,
+ description="Per-project Redmine issue categories. Empty if the project has none defined or if the API key is not allowed to list them.",
+ )
customFields: List[RedmineCustomFieldSchemaDto] = Field(default_factory=list)
rootTrackerName: str = "Userstory"
rootTrackerId: Optional[int] = Field(default=None, description="Resolved id of the configured rootTrackerName, or None if no matching tracker exists")
@@ -386,13 +399,6 @@ class RedmineStatusByTrackerEntry(BaseModel):
total: int = 0
-class RedmineThroughputBucket(BaseModel):
- bucketKey: str
- label: str
- created: int = 0
- closed: int = 0
-
-
class RedmineAssigneeBucket(BaseModel):
assignedToId: Optional[int] = None
name: str = "(nicht zugewiesen)"
@@ -412,6 +418,27 @@ class RedmineAgingBucket(BaseModel):
count: int = 0
+class RedmineThroughputBucket(BaseModel):
+ """Per-bucket snapshot used by the Stats page.
+
+ ``created`` / ``closed`` keep the per-bucket flow numbers (still useful
+ for callers that want raw deltas), while ``cumTotal`` / ``cumOpen``
+ expose the cumulative snapshot the UI actually plots:
+
+ - ``cumTotal`` = number of tickets that exist as of the END of this
+ bucket (= count of tickets created on or before bucket end).
+ - ``cumOpen`` = of those, how many are still open at bucket end (i.e.
+ not yet closed).
+ """
+
+ bucketKey: str
+ label: str
+ created: int = 0
+ closed: int = 0
+ cumTotal: int = 0
+ cumOpen: int = 0
+
+
class RedmineStatsDto(BaseModel):
"""All sections needed by the Statistics page in one round-trip."""
@@ -420,6 +447,8 @@ class RedmineStatsDto(BaseModel):
dateTo: Optional[str] = None
bucket: str = "week"
trackerIds: List[int] = Field(default_factory=list)
+ categoryIds: List[int] = Field(default_factory=list)
+ statusFilter: str = "*"
kpis: RedmineStatsKpis = Field(default_factory=RedmineStatsKpis)
statusByTracker: List[RedmineStatusByTrackerEntry] = Field(default_factory=list)
diff --git a/modules/features/redmine/mainRedmine.py b/modules/features/redmine/mainRedmine.py
index ed60d7b0..ba2225b4 100644
--- a/modules/features/redmine/mainRedmine.py
+++ b/modules/features/redmine/mainRedmine.py
@@ -11,18 +11,24 @@ from __future__ import annotations
import logging
from typing import Any, Dict, List
+from modules.shared.i18nRegistry import t
+
logger = logging.getLogger(__name__)
FEATURE_CODE = "redmine"
-FEATURE_LABEL = "Redmine"
+FEATURE_LABEL = t("Redmine", context="UI")
FEATURE_ICON = "mdi-bug-outline"
+# Wrapping labels in t() at import time registers the keys with the i18n
+# catalog immediately, so the AI translator picks them up on the next sweep.
+# Without this, brand-new labels like "Ticket-Browser" stay untranslated and
+# render as ``[Ticket-Browser]`` in non-de UIs.
UI_OBJECTS: List[Dict[str, Any]] = [
- {"objectKey": "ui.feature.redmine.stats", "label": "Statistik", "meta": {"area": "stats", "isDefault": True}},
- {"objectKey": "ui.feature.redmine.browser", "label": "Ticket-Browser", "meta": {"area": "browser"}},
- {"objectKey": "ui.feature.redmine.settings", "label": "Einstellungen", "meta": {"area": "settings", "admin_only": True}},
+ {"objectKey": "ui.feature.redmine.stats", "label": t("Statistik", context="UI"), "meta": {"area": "stats", "isDefault": True}},
+ {"objectKey": "ui.feature.redmine.browser", "label": t("Ticket-Browser", context="UI"), "meta": {"area": "browser"}},
+ {"objectKey": "ui.feature.redmine.settings", "label": t("Einstellungen", context="UI"), "meta": {"area": "settings", "admin_only": True}},
]
diff --git a/modules/features/redmine/routeFeatureRedmine.py b/modules/features/redmine/routeFeatureRedmine.py
index ad968001..ff4f1391 100644
--- a/modules/features/redmine/routeFeatureRedmine.py
+++ b/modules/features/redmine/routeFeatureRedmine.py
@@ -459,6 +459,8 @@ async def getStats(
dateTo: Optional[str] = Query(default=None, description="ISO date YYYY-MM-DD"),
bucket: str = Query(default="week", regex="^(day|week|month)$"),
trackerIds: Optional[List[int]] = Query(default=None),
+ categoryIds: Optional[List[int]] = Query(default=None, description="Filter by Redmine issue categories"),
+ statusFilter: str = Query(default="*", regex="^(\\*|open|closed)$", description="Restrict to open/closed/all tickets"),
context: RequestContext = Depends(getRequestContext),
) -> RedmineStatsDto:
mandateId = _validateInstanceAccess(instanceId, context)
@@ -471,6 +473,8 @@ async def getStats(
dateTo=dateTo,
bucket=bucket,
trackerIds=trackerIds,
+ categoryIds=categoryIds,
+ statusFilter=statusFilter,
)
except RedmineNotConfiguredError as e:
raise HTTPException(status_code=409, detail=str(e))
diff --git a/modules/features/redmine/serviceRedmine.py b/modules/features/redmine/serviceRedmine.py
index 5b37d00a..e244bd84 100644
--- a/modules/features/redmine/serviceRedmine.py
+++ b/modules/features/redmine/serviceRedmine.py
@@ -106,6 +106,7 @@ async def getProjectMeta(
priorities_raw = await connector.getPriorities()
custom_fields_raw = await connector.getCustomFields()
users_raw = await connector.getProjectUsers()
+ categories_raw = await connector.getIssueCategories()
schema_cache: Dict[str, Any] = {
"projectName": project_info.get("name", ""),
@@ -120,6 +121,7 @@ async def getProjectMeta(
],
"priorities": [{"id": p.get("id"), "name": p.get("name")} for p in priorities_raw],
"users": [{"id": u.get("id"), "name": u.get("name")} for u in users_raw],
+ "categories": [{"id": c.get("id"), "name": c.get("name")} for c in categories_raw if c.get("id") is not None],
"customFields": [
{
"id": cf.get("id"),
@@ -174,6 +176,7 @@ def _schemaFromCache(
statuses=[RedmineFieldChoiceDto(**s) for s in cache.get("statuses") or []],
priorities=[RedmineFieldChoiceDto(**p) for p in cache.get("priorities") or []],
users=[RedmineFieldChoiceDto(**u) for u in cache.get("users") or []],
+ categories=[RedmineFieldChoiceDto(**c) for c in cache.get("categories") or []],
customFields=[
RedmineCustomFieldSchemaDto(
id=cf.get("id"),
@@ -217,6 +220,8 @@ def _mirroredRowToDto(
parentId=row.get("parentId"),
fixedVersionId=row.get("fixedVersionId"),
fixedVersionName=row.get("fixedVersionName"),
+ categoryId=row.get("categoryId"),
+ categoryName=row.get("categoryName"),
createdOn=row.get("createdOn"),
updatedOn=row.get("updatedOn"),
customFields=[
@@ -533,6 +538,7 @@ def _liveIssueToDto(
assigned = issue.get("assigned_to") or {}
author = issue.get("author") or {}
fixed_version = issue.get("fixed_version") or {}
+ category = issue.get("category") or {}
status_id = status.get("id")
return RedmineTicketDto(
id=int(issue.get("id")),
@@ -552,6 +558,8 @@ def _liveIssueToDto(
parentId=(issue.get("parent") or {}).get("id"),
fixedVersionId=fixed_version.get("id"),
fixedVersionName=fixed_version.get("name"),
+ categoryId=category.get("id"),
+ categoryName=category.get("name"),
createdOn=issue.get("created_on"),
updatedOn=issue.get("updated_on"),
customFields=[
diff --git a/modules/features/redmine/serviceRedmineStats.py b/modules/features/redmine/serviceRedmineStats.py
index 5f385df7..2cfed27c 100644
--- a/modules/features/redmine/serviceRedmineStats.py
+++ b/modules/features/redmine/serviceRedmineStats.py
@@ -20,6 +20,7 @@ The whole result is cached in :mod:`serviceRedmineStatsCache` keyed by
from __future__ import annotations
+import bisect
import datetime as _dt
import logging
from collections import Counter, defaultdict
@@ -55,15 +56,27 @@ async def getStats(
dateTo: Optional[str] = None,
bucket: str = "week",
trackerIds: Optional[List[int]] = None,
+ categoryIds: Optional[List[int]] = None,
+ statusFilter: str = "*",
) -> RedmineStatsDto:
"""Compute (or fetch from cache) the full statistics payload."""
bucket_norm = (bucket or "week").lower()
if bucket_norm not in {"day", "week", "month"}:
bucket_norm = "week"
tracker_ids_norm: List[int] = sorted({int(t) for t in trackerIds or []})
+ category_ids_norm: List[int] = sorted({int(c) for c in categoryIds or []})
+ status_norm = (statusFilter or "*").lower()
+ if status_norm not in {"*", "open", "closed"}:
+ status_norm = "*"
cache = _getStatsCache()
- cache_key = cache.buildKey(featureInstanceId, dateFrom, dateTo, bucket_norm, tracker_ids_norm)
+ # Cache key now includes the new dimensions so different filter combos
+ # don't collide. ``_freeze`` (in the cache module) hashes lists/sets
+ # for us, so we can pass them directly as extra dimensions.
+ cache_key = cache.buildKey(
+ featureInstanceId, dateFrom, dateTo, bucket_norm, tracker_ids_norm,
+ category_ids_norm, status_norm,
+ )
cached = cache.get(cache_key)
if cached is not None:
return cached
@@ -83,8 +96,11 @@ async def getStats(
mandateId,
featureInstanceId,
trackerIds=tracker_ids_norm or None,
- statusFilter="*",
+ statusFilter=status_norm,
)
+ if category_ids_norm:
+ cat_set = set(category_ids_norm)
+ tickets = [t for t in tickets if t.categoryId in cat_set]
stats = _aggregate(
tickets,
@@ -94,6 +110,8 @@ async def getStats(
dateTo=dateTo,
bucket=bucket_norm,
trackerIdsFilter=tracker_ids_norm,
+ categoryIdsFilter=category_ids_norm,
+ statusFilter=status_norm,
instanceId=featureInstanceId,
)
@@ -114,6 +132,8 @@ def _aggregate(
dateTo: Optional[str],
bucket: str,
trackerIdsFilter: List[int],
+ categoryIdsFilter: List[int],
+ statusFilter: str,
instanceId: str,
) -> RedmineStatsDto:
period_from = _parseIsoDate(dateFrom)
@@ -132,6 +152,8 @@ def _aggregate(
dateTo=dateTo,
bucket=bucket,
trackerIds=trackerIdsFilter,
+ categoryIds=categoryIdsFilter,
+ statusFilter=statusFilter,
kpis=kpis,
statusByTracker=status_by_tracker,
throughput=throughput,
@@ -242,9 +264,17 @@ def _throughput(
periodTo: Optional[_dt.datetime],
bucket: str,
) -> List[RedmineThroughputBucket]:
+ """Build per-bucket snapshots: how many tickets exist at the END of
+ each bucket, and how many of those are still open at that point.
+
+ ``created`` / ``closed`` keep the raw delta numbers so callers (and
+ AI tools) that want the flow can still see them. The UI line chart
+ plots ``cumTotal`` and ``cumOpen``.
+ """
if not tickets:
return []
+ # If no period is set, span the lifetime of the data.
if periodFrom is None or periodTo is None:
all_dates: List[_dt.datetime] = []
for t in tickets:
@@ -257,6 +287,7 @@ def _throughput(
periodFrom = periodFrom or min(all_dates)
periodTo = periodTo or max(all_dates)
+ # 1) Per-bucket flow counters (created / closed) within the period.
created_counter: Counter = Counter()
closed_counter: Counter = Counter()
for t in tickets:
@@ -268,22 +299,109 @@ def _throughput(
if u and _inPeriod(u, periodFrom, periodTo):
closed_counter[_bucketKey(u, bucket)] += 1
- keys: List[str] = sorted(set(created_counter) | set(closed_counter))
- if not keys:
+ # 2) Build the contiguous list of bucket keys spanning [from, to] so
+ # the line chart has a stable x-axis even for empty intervals.
+ bucket_keys = _bucketKeysBetween(periodFrom, periodTo, bucket)
+ if not bucket_keys:
return []
+
+ # 3) Snapshot counts: total = #created with createdOn <= bucket end;
+ # open = total - #closed with closedTs <= bucket end. We compute
+ # against ALL tickets (not just the period-windowed counters) so
+ # pre-period tickets are correctly counted in the snapshot.
+ created_dates: List[_dt.datetime] = []
+ closed_dates: List[_dt.datetime] = []
+ for t in tickets:
+ c = _parseIsoDate(t.createdOn)
+ if c:
+ created_dates.append(c)
+ if t.isClosed:
+ u = _parseIsoDate(t.updatedOn)
+ if u:
+ closed_dates.append(u)
+ created_dates.sort()
+ closed_dates.sort()
+
out: List[RedmineThroughputBucket] = []
- for key in keys:
+ for key in bucket_keys:
+ edge = _bucketEnd(key, bucket)
+ cum_total = _countLE(created_dates, edge)
+ cum_closed = _countLE(closed_dates, edge)
+ cum_open = max(0, cum_total - cum_closed)
out.append(
RedmineThroughputBucket(
bucketKey=key,
label=_bucketLabel(key, bucket),
created=int(created_counter.get(key, 0)),
closed=int(closed_counter.get(key, 0)),
+ cumTotal=int(cum_total),
+ cumOpen=int(cum_open),
)
)
return out
+def _countLE(sortedDates: List[_dt.datetime], edge: _dt.datetime) -> int:
+ """Binary search: how many entries in ``sortedDates`` are <= ``edge``."""
+ return bisect.bisect_right(sortedDates, edge)
+
+
+def _bucketKeysBetween(
+ fromD: _dt.datetime, toD: _dt.datetime, bucket: str
+) -> List[str]:
+ """Inclusive list of bucket keys covering ``[fromD, toD]``."""
+ if toD < fromD:
+ return []
+ keys: List[str] = []
+ seen: set[str] = set()
+ cursor = fromD
+ safety = 0
+ step = (
+ _dt.timedelta(days=1) if bucket == "day"
+ else _dt.timedelta(days=7) if bucket == "week"
+ else _dt.timedelta(days=27) # month: walk in <31d steps so we never skip
+ )
+ while cursor <= toD and safety < 5000:
+ k = _bucketKey(cursor, bucket)
+ if k not in seen:
+ seen.add(k)
+ keys.append(k)
+ cursor += step
+ safety += 1
+ # Guarantee the toD bucket is included (loop's last cursor may be < toD
+ # if step doesn't divide the interval cleanly, esp. for months).
+ last_key = _bucketKey(toD, bucket)
+ if last_key not in seen:
+ keys.append(last_key)
+ keys.sort()
+ return keys
+
+
+def _bucketEnd(key: str, bucket: str) -> _dt.datetime:
+ """Last-instant timestamp covered by the given bucket key."""
+ if bucket == "day":
+ d = _dt.datetime.strptime(key, "%Y-%m-%d")
+ return d.replace(hour=23, minute=59, second=59)
+ if bucket == "month":
+ d = _dt.datetime.strptime(key, "%Y-%m")
+ # First of next month minus one second.
+ if d.month == 12:
+ nxt = d.replace(year=d.year + 1, month=1)
+ else:
+ nxt = d.replace(month=d.month + 1)
+ return nxt - _dt.timedelta(seconds=1)
+ # week: ISO format ``YYYY-Www``. End = Sunday 23:59:59 of that week.
+ try:
+ year_str, week_str = key.split("-W")
+ year = int(year_str)
+ week = int(week_str)
+ # ``%G-%V-%u`` parses ISO year/week/day; %u=1 is Monday.
+ monday = _dt.datetime.strptime(f"{year}-{week:02d}-1", "%G-%V-%u")
+ return monday + _dt.timedelta(days=6, hours=23, minutes=59, seconds=59)
+ except Exception:
+ return _utcNow()
+
+
def _topAssignees(
tickets: List[RedmineTicketDto], *, limit: int = 10
) -> List[RedmineAssigneeBucket]:
diff --git a/modules/features/redmine/serviceRedmineStatsCache.py b/modules/features/redmine/serviceRedmineStatsCache.py
index 18a81ead..46ad9372 100644
--- a/modules/features/redmine/serviceRedmineStatsCache.py
+++ b/modules/features/redmine/serviceRedmineStatsCache.py
@@ -21,13 +21,29 @@ from typing import Any, Dict, Iterable, Optional, Tuple
_DEFAULT_TTL_SECONDS = 90.0
+def _freeze(value: Any) -> Any:
+ """Make ``value`` hashable so it can live in a tuple cache key.
+
+ Lists / sets become sorted tuples; dicts become sorted item tuples;
+ everything else is returned untouched.
+ """
+ if isinstance(value, (list, set, tuple)):
+ try:
+ return tuple(sorted(value))
+ except TypeError:
+ return tuple(value)
+ if isinstance(value, dict):
+ return tuple(sorted(value.items()))
+ return value
+
+
@dataclass
class _CacheEntry:
value: Any
expiresAt: float
-CacheKey = Tuple[str, Optional[str], Optional[str], str, Tuple[int, ...]]
+CacheKey = Tuple[str, Optional[str], Optional[str], str, Tuple[int, ...], Tuple[Any, ...]]
class RedmineStatsCache:
@@ -48,13 +64,23 @@ class RedmineStatsCache:
dateTo: Optional[str],
bucket: str,
trackerIds: Iterable[int],
+ *extraDims: Any,
) -> CacheKey:
+ """Build a cache key for the given query.
+
+ ``extraDims`` is an open-ended tail so callers can add more filter
+ dimensions (e.g. ``categoryIds``, ``statusFilter``) without forcing
+ a signature break here. Pass them as already-canonicalised values
+ (sorted lists, normalised strings, ...) so the same query always
+ produces the same key.
+ """
return (
str(featureInstanceId),
dateFrom or None,
dateTo or None,
(bucket or "week").lower(),
tuple(sorted(int(t) for t in trackerIds or [])),
+ tuple(_freeze(d) for d in extraDims),
)
def get(self, key: CacheKey) -> Optional[Any]:
diff --git a/modules/features/redmine/serviceRedmineSync.py b/modules/features/redmine/serviceRedmineSync.py
index 2d5ba2ab..6d086ac0 100644
--- a/modules/features/redmine/serviceRedmineSync.py
+++ b/modules/features/redmine/serviceRedmineSync.py
@@ -282,8 +282,10 @@ def _ticketRecordFromIssue(
author = issue.get("author") or {}
parent = issue.get("parent") or {}
fixed_version = issue.get("fixed_version") or {}
+ category = issue.get("category") or {}
created_on = issue.get("created_on")
updated_on = issue.get("updated_on")
+ updated_ts = _parseRedmineDateToEpoch(updated_on)
return {
"featureInstanceId": featureInstanceId,
@@ -305,10 +307,16 @@ def _ticketRecordFromIssue(
"parentId": parent.get("id"),
"fixedVersionId": fixed_version.get("id"),
"fixedVersionName": fixed_version.get("name"),
+ "categoryId": category.get("id"),
+ "categoryName": category.get("name"),
"createdOn": created_on,
"updatedOn": updated_on,
"createdOnTs": _parseRedmineDateToEpoch(created_on),
- "updatedOnTs": _parseRedmineDateToEpoch(updated_on),
+ "updatedOnTs": updated_ts,
+ # Approximation: Redmine doesn't expose a dedicated "closed_on"
+ # timestamp via the issue endpoint. For closed tickets the last
+ # updatedOn is the best stable proxy without scanning journals.
+ "closedOnTs": updated_ts if bool(isClosed) else None,
"customFields": list(issue.get("custom_fields") or []),
"raw": issue,
"syncedAt": nowEpoch,
diff --git a/modules/workflows/methods/methodRedmine/__init__.py b/modules/workflows/methods/methodRedmine/__init__.py
new file mode 100644
index 00000000..d141dd48
--- /dev/null
+++ b/modules/workflows/methods/methodRedmine/__init__.py
@@ -0,0 +1,7 @@
+# Copyright (c) 2026 Patrick Motsch
+# All rights reserved.
+"""Redmine workflow method: read / list / create / update / stats / sync."""
+
+from .methodRedmine import MethodRedmine
+
+__all__ = ["MethodRedmine"]
diff --git a/modules/workflows/methods/methodRedmine/actions/__init__.py b/modules/workflows/methods/methodRedmine/actions/__init__.py
new file mode 100644
index 00000000..746291ab
--- /dev/null
+++ b/modules/workflows/methods/methodRedmine/actions/__init__.py
@@ -0,0 +1,2 @@
+# Copyright (c) 2026 Patrick Motsch
+# All rights reserved.
diff --git a/modules/workflows/methods/methodRedmine/actions/_shared.py b/modules/workflows/methods/methodRedmine/actions/_shared.py
new file mode 100644
index 00000000..b7c585d3
--- /dev/null
+++ b/modules/workflows/methods/methodRedmine/actions/_shared.py
@@ -0,0 +1,38 @@
+# Copyright (c) 2026 Patrick Motsch
+# All rights reserved.
+"""Shared helpers for Redmine workflow actions.
+
+Keeps each action file focused on the business logic -- parameter
+resolution, services lookup and ActionResult shaping live here.
+"""
+
+from typing import Any, Dict, Optional, Tuple
+
+
+def resolveInstanceContext(services, parameters: Dict[str, Any]) -> Tuple[Any, Optional[str], str]:
+ """Resolve ``(user, mandateId, featureInstanceId)`` for a workflow action.
+
+ The workflow runtime wires up ``services.user`` / ``services.mandateId``
+ / ``services.featureInstanceId``. The action may override the instance
+ explicitly via ``parameters['featureInstanceId']`` so that the same
+ workflow template can be reused against different Redmine instances.
+ """
+ featureInstanceId = parameters.get("featureInstanceId") or getattr(
+ services, "featureInstanceId", None
+ )
+ if not featureInstanceId:
+ raise ValueError("featureInstanceId is required")
+ mandateId = getattr(services, "mandateId", None)
+ user = getattr(services, "user", None)
+ if user is None:
+ raise ValueError("services.user is not available")
+ return user, mandateId, str(featureInstanceId)
+
+
+def ticketToDict(ticket) -> Dict[str, Any]:
+ """Compact dict representation for AI consumption -- strips ``raw``."""
+ if ticket is None:
+ return {}
+ payload = ticket.model_dump(exclude_none=True)
+ payload.pop("raw", None)
+ return payload
diff --git a/modules/workflows/methods/methodRedmine/actions/createTicket.py b/modules/workflows/methods/methodRedmine/actions/createTicket.py
new file mode 100644
index 00000000..499d21fb
--- /dev/null
+++ b/modules/workflows/methods/methodRedmine/actions/createTicket.py
@@ -0,0 +1,65 @@
+# Copyright (c) 2026 Patrick Motsch
+# All rights reserved.
+"""Workflow action: create a new Redmine ticket."""
+
+import logging
+from typing import Any, Dict
+
+from modules.datamodels.datamodelChat import ActionResult
+from modules.features.redmine.datamodelRedmine import RedmineTicketCreateRequest
+from modules.features.redmine.serviceRedmine import createTicket
+
+from ._shared import resolveInstanceContext, ticketToDict
+
+logger = logging.getLogger(__name__)
+
+
+async def createTicketAction(self, parameters: Dict[str, Any]) -> ActionResult:
+ """Create a Redmine ticket. ``subject`` and ``trackerId`` are required."""
+ try:
+ user, mandateId, featureInstanceId = resolveInstanceContext(self.services, parameters)
+ except ValueError as exc:
+ return ActionResult.isFailure(error=str(exc))
+
+ subject = parameters.get("subject")
+ trackerId = parameters.get("trackerId")
+ if not subject:
+ return ActionResult.isFailure(error="subject is required")
+ try:
+ trackerId_int = int(trackerId) if trackerId is not None else None
+ except (TypeError, ValueError):
+ return ActionResult.isFailure(error=f"trackerId must be an int, got {trackerId!r}")
+ if trackerId_int is None:
+ return ActionResult.isFailure(error="trackerId is required")
+
+ try:
+ payload = RedmineTicketCreateRequest(
+ subject=subject,
+ trackerId=trackerId_int,
+ description=parameters.get("description") or "",
+ statusId=_optInt(parameters.get("statusId")),
+ priorityId=_optInt(parameters.get("priorityId")),
+ assignedToId=_optInt(parameters.get("assignedToId")),
+ parentIssueId=_optInt(parameters.get("parentIssueId")),
+ fixedVersionId=_optInt(parameters.get("fixedVersionId")),
+ customFields=parameters.get("customFields") or None,
+ )
+ except Exception as exc:
+ return ActionResult.isFailure(error=f"Invalid create body: {exc}")
+
+ try:
+ created = await createTicket(user, mandateId, featureInstanceId, payload)
+ except Exception as exc:
+ logger.exception("redmine.createTicket failed")
+ return ActionResult.isFailure(error=f"Create ticket failed: {exc}")
+
+ return ActionResult.isSuccess(data={"ticket": ticketToDict(created)})
+
+
+def _optInt(value: Any):
+ if value is None or value == "":
+ return None
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ return None
diff --git a/modules/workflows/methods/methodRedmine/actions/getStats.py b/modules/workflows/methods/methodRedmine/actions/getStats.py
new file mode 100644
index 00000000..e939bf53
--- /dev/null
+++ b/modules/workflows/methods/methodRedmine/actions/getStats.py
@@ -0,0 +1,64 @@
+# Copyright (c) 2026 Patrick Motsch
+# All rights reserved.
+"""Workflow action: fetch aggregated Redmine statistics from the mirror."""
+
+import logging
+from typing import Any, Dict, List, Optional
+
+from modules.datamodels.datamodelChat import ActionResult
+from modules.features.redmine.serviceRedmineStats import getStats
+
+from ._shared import resolveInstanceContext
+
+logger = logging.getLogger(__name__)
+
+
+def _normalizeIntList(value: Any) -> Optional[List[int]]:
+ """Accept ``None | int | "1,2,3" | [..]`` and return a list of ints."""
+ if value is None or value == "":
+ return None
+ if isinstance(value, int):
+ return [value]
+ if isinstance(value, str):
+ value = [v.strip() for v in value.split(",") if v.strip()]
+ if isinstance(value, list):
+ ids: List[int] = []
+ for v in value:
+ try:
+ ids.append(int(v))
+ except (TypeError, ValueError):
+ continue
+ return ids or None
+ return None
+
+
+async def getStatsAction(self, parameters: Dict[str, Any]) -> ActionResult:
+ """Return the same DTO as the ``/stats`` endpoint, cached per filter."""
+ try:
+ user, mandateId, featureInstanceId = resolveInstanceContext(self.services, parameters)
+ except ValueError as exc:
+ return ActionResult.isFailure(error=str(exc))
+
+ bucket = (parameters.get("bucket") or "week").lower()
+ if bucket not in {"day", "week", "month"}:
+ bucket = "week"
+
+ status_filter = (parameters.get("statusFilter") or "*").lower()
+ if status_filter not in {"*", "open", "closed"}:
+ status_filter = "*"
+
+ try:
+ stats = await getStats(
+ user, mandateId, featureInstanceId,
+ dateFrom=parameters.get("dateFrom") or None,
+ dateTo=parameters.get("dateTo") or None,
+ bucket=bucket,
+ trackerIds=_normalizeIntList(parameters.get("trackerIds")),
+ categoryIds=_normalizeIntList(parameters.get("categoryIds")),
+ statusFilter=status_filter,
+ )
+ except Exception as exc:
+ logger.exception("redmine.getStats failed")
+ return ActionResult.isFailure(error=f"Stats failed: {exc}")
+
+ return ActionResult.isSuccess(data={"stats": stats.model_dump(exclude_none=True)})
diff --git a/modules/workflows/methods/methodRedmine/actions/listTickets.py b/modules/workflows/methods/methodRedmine/actions/listTickets.py
new file mode 100644
index 00000000..d1867b86
--- /dev/null
+++ b/modules/workflows/methods/methodRedmine/actions/listTickets.py
@@ -0,0 +1,82 @@
+# Copyright (c) 2026 Patrick Motsch
+# All rights reserved.
+"""Workflow action: list Redmine tickets from the mirror with filters."""
+
+import logging
+from typing import Any, Dict, List, Optional
+
+from modules.datamodels.datamodelChat import ActionResult
+from modules.features.redmine.serviceRedmine import listTickets
+
+from ._shared import resolveInstanceContext, ticketToDict
+
+logger = logging.getLogger(__name__)
+
+
+def _normalizeTrackerIds(value: Any) -> Optional[List[int]]:
+ if value is None or value == "":
+ return None
+ if isinstance(value, int):
+ return [value]
+ if isinstance(value, str):
+ value = [v.strip() for v in value.split(",") if v.strip()]
+ if isinstance(value, list):
+ ids: List[int] = []
+ for v in value:
+ try:
+ ids.append(int(v))
+ except (TypeError, ValueError):
+ continue
+ return ids or None
+ return None
+
+
+async def listTicketsAction(self, parameters: Dict[str, Any]) -> ActionResult:
+ """List Redmine tickets from the local mirror."""
+ try:
+ user, mandateId, featureInstanceId = resolveInstanceContext(self.services, parameters)
+ except ValueError as exc:
+ return ActionResult.isFailure(error=str(exc))
+
+ trackerIds = _normalizeTrackerIds(parameters.get("trackerIds"))
+ statusFilter = (parameters.get("status") or "*").lower()
+ if statusFilter not in {"*", "open", "closed"}:
+ statusFilter = "*"
+ updatedFrom = parameters.get("dateFrom") or None
+ updatedTo = parameters.get("dateTo") or None
+ assignedToId: Optional[int] = None
+ if parameters.get("assignedToId") not in (None, ""):
+ try:
+ assignedToId = int(parameters["assignedToId"])
+ except (TypeError, ValueError):
+ return ActionResult.isFailure(error="assignedToId must be an int")
+
+ try:
+ tickets = listTickets(
+ user, mandateId, featureInstanceId,
+ trackerIds=trackerIds,
+ statusFilter=statusFilter,
+ updatedOnFrom=updatedFrom,
+ updatedOnTo=updatedTo,
+ assignedToId=assignedToId,
+ )
+ except Exception as exc:
+ 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]
+ return ActionResult.isSuccess(data={
+ "count": len(truncated),
+ "totalMatched": len(tickets),
+ "truncated": len(tickets) > limit,
+ "tickets": [ticketToDict(t) for t in truncated],
+ })
diff --git a/modules/workflows/methods/methodRedmine/actions/readTicket.py b/modules/workflows/methods/methodRedmine/actions/readTicket.py
new file mode 100644
index 00000000..69ea4459
--- /dev/null
+++ b/modules/workflows/methods/methodRedmine/actions/readTicket.py
@@ -0,0 +1,46 @@
+# Copyright (c) 2026 Patrick Motsch
+# All rights reserved.
+"""Workflow action: read a single Redmine ticket from the mirror.
+
+Returns ``ActionResult.data`` with a single ``ticket`` key so downstream
+nodes (e.g. ``ai.prompt``) can reference the ticket fields through
+``DataRef``s.
+"""
+
+import logging
+from typing import Any, Dict
+
+from modules.datamodels.datamodelChat import ActionResult
+from modules.features.redmine.serviceRedmine import getTicket
+
+from ._shared import resolveInstanceContext, ticketToDict
+
+logger = logging.getLogger(__name__)
+
+
+async def readTicket(self, parameters: Dict[str, Any]) -> ActionResult:
+ """Read ``parameters['ticketId']`` from the local Redmine mirror."""
+ try:
+ user, mandateId, featureInstanceId = resolveInstanceContext(self.services, parameters)
+ except ValueError as exc:
+ return ActionResult.isFailure(error=str(exc))
+
+ raw_id = parameters.get("ticketId") or parameters.get("issueId")
+ if raw_id is None:
+ return ActionResult.isFailure(error="ticketId is required")
+ try:
+ ticketId = int(raw_id)
+ except (TypeError, ValueError):
+ return ActionResult.isFailure(error=f"ticketId must be an int, got {raw_id!r}")
+
+ try:
+ ticket = getTicket(user, mandateId, featureInstanceId, ticketId, includeRaw=False)
+ except Exception as exc:
+ logger.exception("redmine.readTicket failed for ticket %s", ticketId)
+ return ActionResult.isFailure(error=f"Read ticket failed: {exc}")
+
+ if ticket is None:
+ return ActionResult.isFailure(
+ error=f"Ticket #{ticketId} not found in mirror. Run redmine.runSync first?",
+ )
+ return ActionResult.isSuccess(data={"ticket": ticketToDict(ticket)})
diff --git a/modules/workflows/methods/methodRedmine/actions/runSync.py b/modules/workflows/methods/methodRedmine/actions/runSync.py
new file mode 100644
index 00000000..64a9bff9
--- /dev/null
+++ b/modules/workflows/methods/methodRedmine/actions/runSync.py
@@ -0,0 +1,35 @@
+# Copyright (c) 2026 Patrick Motsch
+# All rights reserved.
+"""Workflow action: trigger an incremental (or full) Redmine mirror sync."""
+
+import logging
+from typing import Any, Dict
+
+from modules.datamodels.datamodelChat import ActionResult
+from modules.features.redmine.serviceRedmineSync import runSync as runMirrorSync
+
+from ._shared import resolveInstanceContext
+
+logger = logging.getLogger(__name__)
+
+
+async def runSyncAction(self, parameters: Dict[str, Any]) -> ActionResult:
+ """Pull ticket and relation updates into the local mirror.
+
+ Set ``force=True`` to ignore ``lastSyncAt`` and re-sync every issue
+ (expensive -- only use for initial seed or recovery).
+ """
+ try:
+ user, mandateId, featureInstanceId = resolveInstanceContext(self.services, parameters)
+ except ValueError as exc:
+ return ActionResult.isFailure(error=str(exc))
+
+ force = bool(parameters.get("force") or False)
+
+ try:
+ result = await runMirrorSync(user, mandateId, featureInstanceId, force=force)
+ except Exception as exc:
+ logger.exception("redmine.runSync failed")
+ return ActionResult.isFailure(error=f"Sync failed: {exc}")
+
+ return ActionResult.isSuccess(data={"sync": result.model_dump(exclude_none=True)})
diff --git a/modules/workflows/methods/methodRedmine/actions/updateTicket.py b/modules/workflows/methods/methodRedmine/actions/updateTicket.py
new file mode 100644
index 00000000..4e396093
--- /dev/null
+++ b/modules/workflows/methods/methodRedmine/actions/updateTicket.py
@@ -0,0 +1,68 @@
+# Copyright (c) 2026 Patrick Motsch
+# All rights reserved.
+"""Workflow action: update a single Redmine ticket and refresh the mirror."""
+
+import logging
+from typing import Any, Dict
+
+from modules.datamodels.datamodelChat import ActionResult
+from modules.features.redmine.datamodelRedmine import RedmineTicketUpdateRequest
+from modules.features.redmine.serviceRedmine import updateTicket
+
+from ._shared import resolveInstanceContext, ticketToDict
+
+logger = logging.getLogger(__name__)
+
+
+async def updateTicketAction(self, parameters: Dict[str, Any]) -> ActionResult:
+ """Update ``parameters['ticketId']`` with the given fields.
+
+ Only fields that are not ``None`` (and different from the current
+ Redmine state, enforced by the service) are sent to Redmine. An
+ optional ``notes`` string is appended as a journal entry.
+ """
+ try:
+ user, mandateId, featureInstanceId = resolveInstanceContext(self.services, parameters)
+ except ValueError as exc:
+ return ActionResult.isFailure(error=str(exc))
+
+ raw_id = parameters.get("ticketId") or parameters.get("issueId")
+ if raw_id is None:
+ return ActionResult.isFailure(error="ticketId is required")
+ try:
+ ticketId = int(raw_id)
+ except (TypeError, ValueError):
+ return ActionResult.isFailure(error=f"ticketId must be an int, got {raw_id!r}")
+
+ try:
+ update = RedmineTicketUpdateRequest(
+ subject=parameters.get("subject"),
+ description=parameters.get("description"),
+ trackerId=_optInt(parameters.get("trackerId")),
+ statusId=_optInt(parameters.get("statusId")),
+ priorityId=_optInt(parameters.get("priorityId")),
+ assignedToId=_optInt(parameters.get("assignedToId")),
+ parentIssueId=_optInt(parameters.get("parentIssueId")),
+ fixedVersionId=_optInt(parameters.get("fixedVersionId")),
+ notes=parameters.get("notes"),
+ customFields=parameters.get("customFields") or None,
+ )
+ except Exception as exc:
+ return ActionResult.isFailure(error=f"Invalid update body: {exc}")
+
+ try:
+ updated = await updateTicket(user, mandateId, featureInstanceId, ticketId, update)
+ except Exception as exc:
+ logger.exception("redmine.updateTicket failed for ticket %s", ticketId)
+ return ActionResult.isFailure(error=f"Update ticket failed: {exc}")
+
+ return ActionResult.isSuccess(data={"ticket": ticketToDict(updated)})
+
+
+def _optInt(value: Any):
+ if value is None or value == "":
+ return None
+ try:
+ return int(value)
+ except (TypeError, ValueError):
+ return None
diff --git a/modules/workflows/methods/methodRedmine/methodRedmine.py b/modules/workflows/methods/methodRedmine/methodRedmine.py
new file mode 100644
index 00000000..0dd8f461
--- /dev/null
+++ b/modules/workflows/methods/methodRedmine/methodRedmine.py
@@ -0,0 +1,253 @@
+# Copyright (c) 2026 Patrick Motsch
+# All rights reserved.
+"""Redmine workflow method.
+
+Exposes read/write/stats/sync actions against a configured Redmine
+feature instance. All reads go through the local mirror; writes update
+Redmine and then the mirror (see ``serviceRedmine``).
+
+This module is auto-discovered by ``methodDiscovery.py`` (any package
+under ``modules.workflows.methods.method*`` with a ``MethodBase``
+subclass is picked up). No manual registration needed.
+"""
+
+import logging
+
+from modules.datamodels.datamodelWorkflowActions import (
+ WorkflowActionDefinition,
+ WorkflowActionParameter,
+)
+from modules.shared.frontendTypes import FrontendType
+from modules.workflows.methods.methodBase import MethodBase
+
+from .actions.createTicket import createTicketAction
+from .actions.getStats import getStatsAction
+from .actions.listTickets import listTicketsAction
+from .actions.readTicket import readTicket
+from .actions.runSync import runSyncAction
+from .actions.updateTicket import updateTicketAction
+
+logger = logging.getLogger(__name__)
+
+
+class MethodRedmine(MethodBase):
+ """Redmine read/write/stats/sync actions for the workflow runtime."""
+
+ def __init__(self, services):
+ super().__init__(services)
+ self.name = "redmine"
+ self.description = "Redmine ticketing: read, list, create, update, stats, sync."
+
+ self._actions = {
+ "readTicket": WorkflowActionDefinition(
+ actionId="redmine.readTicket",
+ description="Read a single Redmine ticket from the local mirror by ticketId.",
+ dynamicMode=False,
+ parameters={
+ "featureInstanceId": WorkflowActionParameter(
+ name="featureInstanceId", type="str", frontendType=FrontendType.TEXT,
+ required=True, description="Redmine feature instance ID",
+ ),
+ "ticketId": WorkflowActionParameter(
+ name="ticketId", type="int", frontendType=FrontendType.TEXT,
+ required=True, description="Redmine issue id to read",
+ ),
+ },
+ execute=readTicket.__get__(self, self.__class__),
+ ),
+ "listTickets": WorkflowActionDefinition(
+ actionId="redmine.listTickets",
+ description="List tickets from the mirror with optional filters (tracker, status, period, assignee).",
+ dynamicMode=False,
+ parameters={
+ "featureInstanceId": WorkflowActionParameter(
+ name="featureInstanceId", type="str", frontendType=FrontendType.TEXT,
+ required=True, description="Redmine feature instance ID",
+ ),
+ "trackerIds": WorkflowActionParameter(
+ name="trackerIds", type="list", frontendType=FrontendType.JSON,
+ required=False, description="Restrict to these tracker ids (list of int or comma-separated string).",
+ ),
+ "status": WorkflowActionParameter(
+ name="status", type="str", frontendType=FrontendType.TEXT,
+ required=False, description="'open' | 'closed' | '*' (default '*').",
+ ),
+ "dateFrom": WorkflowActionParameter(
+ name="dateFrom", type="str", frontendType=FrontendType.TEXT,
+ required=False, description="ISO date -- filter by 'updated_on >= dateFrom'.",
+ ),
+ "dateTo": WorkflowActionParameter(
+ name="dateTo", type="str", frontendType=FrontendType.TEXT,
+ required=False, description="ISO date -- filter by 'updated_on <= dateTo'.",
+ ),
+ "assignedToId": WorkflowActionParameter(
+ name="assignedToId", type="int", frontendType=FrontendType.TEXT,
+ required=False, description="Only tickets assigned to this Redmine user id.",
+ ),
+ "limit": WorkflowActionParameter(
+ name="limit", type="int", frontendType=FrontendType.TEXT,
+ required=False, description="Max tickets in the result (1-500, default 100).",
+ ),
+ },
+ execute=listTicketsAction.__get__(self, self.__class__),
+ ),
+ "createTicket": WorkflowActionDefinition(
+ actionId="redmine.createTicket",
+ description="Create a new Redmine ticket. Requires subject and trackerId.",
+ dynamicMode=False,
+ parameters={
+ "featureInstanceId": WorkflowActionParameter(
+ name="featureInstanceId", type="str", frontendType=FrontendType.TEXT,
+ required=True, description="Redmine feature instance ID",
+ ),
+ "subject": WorkflowActionParameter(
+ name="subject", type="str", frontendType=FrontendType.TEXT,
+ required=True, description="Ticket title.",
+ ),
+ "trackerId": WorkflowActionParameter(
+ name="trackerId", type="int", frontendType=FrontendType.TEXT,
+ required=True, description="Tracker id (Userstory, Feature, Task ...).",
+ ),
+ "description": WorkflowActionParameter(
+ name="description", type="str", frontendType=FrontendType.TEXTAREA,
+ required=False, description="Markdown/Textile description body.",
+ ),
+ "statusId": WorkflowActionParameter(
+ name="statusId", type="int", frontendType=FrontendType.TEXT,
+ required=False, description="Status id (optional, Redmine default otherwise).",
+ ),
+ "priorityId": WorkflowActionParameter(
+ name="priorityId", type="int", frontendType=FrontendType.TEXT,
+ required=False, description="Priority id.",
+ ),
+ "assignedToId": WorkflowActionParameter(
+ name="assignedToId", type="int", frontendType=FrontendType.TEXT,
+ required=False, description="Assignee user id.",
+ ),
+ "parentIssueId": WorkflowActionParameter(
+ name="parentIssueId", type="int", frontendType=FrontendType.TEXT,
+ required=False, description="Parent issue id (tree parent, not relation).",
+ ),
+ "fixedVersionId": WorkflowActionParameter(
+ name="fixedVersionId", type="int", frontendType=FrontendType.TEXT,
+ required=False, description="Target/fixed version id.",
+ ),
+ "customFields": WorkflowActionParameter(
+ name="customFields", type="dict", frontendType=FrontendType.JSON,
+ required=False, description="Custom fields as {customFieldId: value}.",
+ ),
+ },
+ execute=createTicketAction.__get__(self, self.__class__),
+ ),
+ "updateTicket": WorkflowActionDefinition(
+ actionId="redmine.updateTicket",
+ description="Update a Redmine ticket. Only provided fields are sent.",
+ dynamicMode=False,
+ parameters={
+ "featureInstanceId": WorkflowActionParameter(
+ name="featureInstanceId", type="str", frontendType=FrontendType.TEXT,
+ required=True, description="Redmine feature instance ID",
+ ),
+ "ticketId": WorkflowActionParameter(
+ name="ticketId", type="int", frontendType=FrontendType.TEXT,
+ required=True, description="Redmine issue id to update",
+ ),
+ "subject": WorkflowActionParameter(
+ name="subject", type="str", frontendType=FrontendType.TEXT,
+ required=False, description="New title.",
+ ),
+ "description": WorkflowActionParameter(
+ name="description", type="str", frontendType=FrontendType.TEXTAREA,
+ required=False, description="New description.",
+ ),
+ "trackerId": WorkflowActionParameter(
+ name="trackerId", type="int", frontendType=FrontendType.TEXT,
+ required=False, description="Change tracker.",
+ ),
+ "statusId": WorkflowActionParameter(
+ name="statusId", type="int", frontendType=FrontendType.TEXT,
+ required=False, description="Change status.",
+ ),
+ "priorityId": WorkflowActionParameter(
+ name="priorityId", type="int", frontendType=FrontendType.TEXT,
+ required=False, description="Change priority.",
+ ),
+ "assignedToId": WorkflowActionParameter(
+ name="assignedToId", type="int", frontendType=FrontendType.TEXT,
+ required=False, description="Change assignee.",
+ ),
+ "parentIssueId": WorkflowActionParameter(
+ name="parentIssueId", type="int", frontendType=FrontendType.TEXT,
+ required=False, description="Change parent issue.",
+ ),
+ "fixedVersionId": WorkflowActionParameter(
+ name="fixedVersionId", type="int", frontendType=FrontendType.TEXT,
+ required=False, description="Change fixed version.",
+ ),
+ "notes": WorkflowActionParameter(
+ name="notes", type="str", frontendType=FrontendType.TEXTAREA,
+ required=False, description="Journal entry (comment) added to the ticket.",
+ ),
+ "customFields": WorkflowActionParameter(
+ name="customFields", type="dict", frontendType=FrontendType.JSON,
+ required=False, description="Custom fields as {customFieldId: value}.",
+ ),
+ },
+ execute=updateTicketAction.__get__(self, self.__class__),
+ ),
+ "getStats": WorkflowActionDefinition(
+ actionId="redmine.getStats",
+ description="Aggregated stats (KPIs, throughput, status distribution, backlog) from the mirror.",
+ dynamicMode=False,
+ parameters={
+ "featureInstanceId": WorkflowActionParameter(
+ name="featureInstanceId", type="str", frontendType=FrontendType.TEXT,
+ required=True, description="Redmine feature instance ID",
+ ),
+ "dateFrom": WorkflowActionParameter(
+ name="dateFrom", type="str", frontendType=FrontendType.TEXT,
+ required=False, description="ISO date -- lower bound for 'created_in_period' / 'closed_in_period'.",
+ ),
+ "dateTo": WorkflowActionParameter(
+ name="dateTo", type="str", frontendType=FrontendType.TEXT,
+ required=False, description="ISO date -- upper bound.",
+ ),
+ "bucket": WorkflowActionParameter(
+ name="bucket", type="str", frontendType=FrontendType.TEXT,
+ required=False, description="'day' | 'week' | 'month' (default 'week').",
+ ),
+ "trackerIds": WorkflowActionParameter(
+ name="trackerIds", type="list", frontendType=FrontendType.JSON,
+ required=False, description="Restrict to these tracker ids.",
+ ),
+ },
+ execute=getStatsAction.__get__(self, self.__class__),
+ ),
+ "runSync": WorkflowActionDefinition(
+ actionId="redmine.runSync",
+ description="Sync Redmine tickets and relations into the local mirror (incremental by default).",
+ dynamicMode=False,
+ parameters={
+ "featureInstanceId": WorkflowActionParameter(
+ name="featureInstanceId", type="str", frontendType=FrontendType.TEXT,
+ required=True, description="Redmine feature instance ID",
+ ),
+ "force": WorkflowActionParameter(
+ name="force", type="bool", frontendType=FrontendType.CHECKBOX,
+ required=False, description="True -> ignore lastSyncAt and pull every issue.",
+ ),
+ },
+ execute=runSyncAction.__get__(self, self.__class__),
+ ),
+ }
+ self._validateActions()
+
+ # Expose the callables directly on the instance too so workflow
+ # engines that resolve by attribute (``method.actionName(...)``)
+ # rather than through the action dict also work.
+ self.readTicket = readTicket.__get__(self, self.__class__)
+ self.listTickets = listTicketsAction.__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__)
+ self.runSync = runSyncAction.__get__(self, self.__class__)