redmine integrated and fixed

This commit is contained in:
ValueOn AG 2026-04-21 21:30:11 +02:00
parent dc0346904f
commit 908be0511b
20 changed files with 1058 additions and 18 deletions

View file

@ -188,6 +188,15 @@ class ConnectorTicketsRedmine(TicketBase):
raise RedmineApiError(status, raw, "GET", f"/projects/{self._projectId}.json") raise RedmineApiError(status, raw, "GET", f"/projects/{self._projectId}.json")
return body.get("project", {}) 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 # Issues -- read
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -10,6 +10,7 @@ from .sharepoint import SHAREPOINT_NODES
from .clickup import CLICKUP_NODES from .clickup import CLICKUP_NODES
from .file import FILE_NODES from .file import FILE_NODES
from .trustee import TRUSTEE_NODES from .trustee import TRUSTEE_NODES
from .redmine import REDMINE_NODES
from .data import DATA_NODES from .data import DATA_NODES
from .context import CONTEXT_NODES from .context import CONTEXT_NODES
@ -23,6 +24,7 @@ STATIC_NODE_TYPES = (
+ CLICKUP_NODES + CLICKUP_NODES
+ FILE_NODES + FILE_NODES
+ TRUSTEE_NODES + TRUSTEE_NODES
+ REDMINE_NODES
+ DATA_NODES + DATA_NODES
+ CONTEXT_NODES + CONTEXT_NODES
) )

View file

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

View file

@ -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}) 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}) 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}) 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}) 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}) 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)", 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 parentId: Optional[int] = None
fixedVersionId: Optional[int] = None fixedVersionId: Optional[int] = None
fixedVersionName: Optional[str] = None fixedVersionName: Optional[str] = None
categoryId: Optional[int] = None
categoryName: Optional[str] = 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)
@ -361,6 +370,10 @@ class RedmineFieldSchemaDto(BaseModel):
statuses: List[RedmineFieldChoiceDto] = Field(default_factory=list) statuses: List[RedmineFieldChoiceDto] = Field(default_factory=list)
priorities: List[RedmineFieldChoiceDto] = Field(default_factory=list) priorities: List[RedmineFieldChoiceDto] = Field(default_factory=list)
users: 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) customFields: List[RedmineCustomFieldSchemaDto] = Field(default_factory=list)
rootTrackerName: str = "Userstory" rootTrackerName: str = "Userstory"
rootTrackerId: Optional[int] = Field(default=None, description="Resolved id of the configured rootTrackerName, or None if no matching tracker exists") 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 total: int = 0
class RedmineThroughputBucket(BaseModel):
bucketKey: str
label: str
created: int = 0
closed: int = 0
class RedmineAssigneeBucket(BaseModel): class RedmineAssigneeBucket(BaseModel):
assignedToId: Optional[int] = None assignedToId: Optional[int] = None
name: str = "(nicht zugewiesen)" name: str = "(nicht zugewiesen)"
@ -412,6 +418,27 @@ class RedmineAgingBucket(BaseModel):
count: int = 0 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): class RedmineStatsDto(BaseModel):
"""All sections needed by the Statistics page in one round-trip.""" """All sections needed by the Statistics page in one round-trip."""
@ -420,6 +447,8 @@ class RedmineStatsDto(BaseModel):
dateTo: Optional[str] = None dateTo: Optional[str] = None
bucket: str = "week" bucket: str = "week"
trackerIds: List[int] = Field(default_factory=list) trackerIds: List[int] = Field(default_factory=list)
categoryIds: List[int] = Field(default_factory=list)
statusFilter: str = "*"
kpis: RedmineStatsKpis = Field(default_factory=RedmineStatsKpis) kpis: RedmineStatsKpis = Field(default_factory=RedmineStatsKpis)
statusByTracker: List[RedmineStatusByTrackerEntry] = Field(default_factory=list) statusByTracker: List[RedmineStatusByTrackerEntry] = Field(default_factory=list)

View file

@ -11,18 +11,24 @@ from __future__ import annotations
import logging import logging
from typing import Any, Dict, List from typing import Any, Dict, List
from modules.shared.i18nRegistry import t
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
FEATURE_CODE = "redmine" FEATURE_CODE = "redmine"
FEATURE_LABEL = "Redmine" FEATURE_LABEL = t("Redmine", context="UI")
FEATURE_ICON = "mdi-bug-outline" 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]] = [ UI_OBJECTS: List[Dict[str, Any]] = [
{"objectKey": "ui.feature.redmine.stats", "label": "Statistik", "meta": {"area": "stats", "isDefault": True}}, {"objectKey": "ui.feature.redmine.stats", "label": t("Statistik", context="UI"), "meta": {"area": "stats", "isDefault": True}},
{"objectKey": "ui.feature.redmine.browser", "label": "Ticket-Browser", "meta": {"area": "browser"}}, {"objectKey": "ui.feature.redmine.browser", "label": t("Ticket-Browser", context="UI"), "meta": {"area": "browser"}},
{"objectKey": "ui.feature.redmine.settings", "label": "Einstellungen", "meta": {"area": "settings", "admin_only": True}}, {"objectKey": "ui.feature.redmine.settings", "label": t("Einstellungen", context="UI"), "meta": {"area": "settings", "admin_only": True}},
] ]

View file

@ -459,6 +459,8 @@ async def getStats(
dateTo: Optional[str] = Query(default=None, description="ISO date YYYY-MM-DD"), dateTo: Optional[str] = Query(default=None, description="ISO date YYYY-MM-DD"),
bucket: str = Query(default="week", regex="^(day|week|month)$"), bucket: str = Query(default="week", regex="^(day|week|month)$"),
trackerIds: Optional[List[int]] = Query(default=None), 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), context: RequestContext = Depends(getRequestContext),
) -> RedmineStatsDto: ) -> RedmineStatsDto:
mandateId = _validateInstanceAccess(instanceId, context) mandateId = _validateInstanceAccess(instanceId, context)
@ -471,6 +473,8 @@ async def getStats(
dateTo=dateTo, dateTo=dateTo,
bucket=bucket, bucket=bucket,
trackerIds=trackerIds, trackerIds=trackerIds,
categoryIds=categoryIds,
statusFilter=statusFilter,
) )
except RedmineNotConfiguredError as e: except RedmineNotConfiguredError as e:
raise HTTPException(status_code=409, detail=str(e)) raise HTTPException(status_code=409, detail=str(e))

View file

@ -106,6 +106,7 @@ async def getProjectMeta(
priorities_raw = await connector.getPriorities() priorities_raw = await connector.getPriorities()
custom_fields_raw = await connector.getCustomFields() custom_fields_raw = await connector.getCustomFields()
users_raw = await connector.getProjectUsers() users_raw = await connector.getProjectUsers()
categories_raw = await connector.getIssueCategories()
schema_cache: Dict[str, Any] = { schema_cache: Dict[str, Any] = {
"projectName": project_info.get("name", ""), "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], "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], "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": [ "customFields": [
{ {
"id": cf.get("id"), "id": cf.get("id"),
@ -174,6 +176,7 @@ def _schemaFromCache(
statuses=[RedmineFieldChoiceDto(**s) for s in cache.get("statuses") or []], statuses=[RedmineFieldChoiceDto(**s) for s in cache.get("statuses") or []],
priorities=[RedmineFieldChoiceDto(**p) for p in cache.get("priorities") or []], priorities=[RedmineFieldChoiceDto(**p) for p in cache.get("priorities") or []],
users=[RedmineFieldChoiceDto(**u) for u in cache.get("users") or []], users=[RedmineFieldChoiceDto(**u) for u in cache.get("users") or []],
categories=[RedmineFieldChoiceDto(**c) for c in cache.get("categories") or []],
customFields=[ customFields=[
RedmineCustomFieldSchemaDto( RedmineCustomFieldSchemaDto(
id=cf.get("id"), id=cf.get("id"),
@ -217,6 +220,8 @@ def _mirroredRowToDto(
parentId=row.get("parentId"), parentId=row.get("parentId"),
fixedVersionId=row.get("fixedVersionId"), fixedVersionId=row.get("fixedVersionId"),
fixedVersionName=row.get("fixedVersionName"), fixedVersionName=row.get("fixedVersionName"),
categoryId=row.get("categoryId"),
categoryName=row.get("categoryName"),
createdOn=row.get("createdOn"), createdOn=row.get("createdOn"),
updatedOn=row.get("updatedOn"), updatedOn=row.get("updatedOn"),
customFields=[ customFields=[
@ -533,6 +538,7 @@ def _liveIssueToDto(
assigned = issue.get("assigned_to") or {} assigned = issue.get("assigned_to") or {}
author = issue.get("author") or {} author = issue.get("author") or {}
fixed_version = issue.get("fixed_version") or {} fixed_version = issue.get("fixed_version") or {}
category = issue.get("category") or {}
status_id = status.get("id") status_id = status.get("id")
return RedmineTicketDto( return RedmineTicketDto(
id=int(issue.get("id")), id=int(issue.get("id")),
@ -552,6 +558,8 @@ def _liveIssueToDto(
parentId=(issue.get("parent") or {}).get("id"), parentId=(issue.get("parent") or {}).get("id"),
fixedVersionId=fixed_version.get("id"), fixedVersionId=fixed_version.get("id"),
fixedVersionName=fixed_version.get("name"), fixedVersionName=fixed_version.get("name"),
categoryId=category.get("id"),
categoryName=category.get("name"),
createdOn=issue.get("created_on"), createdOn=issue.get("created_on"),
updatedOn=issue.get("updated_on"), updatedOn=issue.get("updated_on"),
customFields=[ customFields=[

View file

@ -20,6 +20,7 @@ The whole result is cached in :mod:`serviceRedmineStatsCache` keyed by
from __future__ import annotations from __future__ import annotations
import bisect
import datetime as _dt import datetime as _dt
import logging import logging
from collections import Counter, defaultdict from collections import Counter, defaultdict
@ -55,15 +56,27 @@ async def getStats(
dateTo: Optional[str] = None, dateTo: Optional[str] = None,
bucket: str = "week", bucket: str = "week",
trackerIds: Optional[List[int]] = None, trackerIds: Optional[List[int]] = None,
categoryIds: Optional[List[int]] = None,
statusFilter: str = "*",
) -> RedmineStatsDto: ) -> RedmineStatsDto:
"""Compute (or fetch from cache) the full statistics payload.""" """Compute (or fetch from cache) the full statistics payload."""
bucket_norm = (bucket or "week").lower() bucket_norm = (bucket or "week").lower()
if bucket_norm not in {"day", "week", "month"}: if bucket_norm not in {"day", "week", "month"}:
bucket_norm = "week" bucket_norm = "week"
tracker_ids_norm: List[int] = sorted({int(t) for t in trackerIds or []}) 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 = _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) cached = cache.get(cache_key)
if cached is not None: if cached is not None:
return cached return cached
@ -83,8 +96,11 @@ async def getStats(
mandateId, mandateId,
featureInstanceId, featureInstanceId,
trackerIds=tracker_ids_norm or None, 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( stats = _aggregate(
tickets, tickets,
@ -94,6 +110,8 @@ async def getStats(
dateTo=dateTo, dateTo=dateTo,
bucket=bucket_norm, bucket=bucket_norm,
trackerIdsFilter=tracker_ids_norm, trackerIdsFilter=tracker_ids_norm,
categoryIdsFilter=category_ids_norm,
statusFilter=status_norm,
instanceId=featureInstanceId, instanceId=featureInstanceId,
) )
@ -114,6 +132,8 @@ def _aggregate(
dateTo: Optional[str], dateTo: Optional[str],
bucket: str, bucket: str,
trackerIdsFilter: List[int], trackerIdsFilter: List[int],
categoryIdsFilter: List[int],
statusFilter: str,
instanceId: str, instanceId: str,
) -> RedmineStatsDto: ) -> RedmineStatsDto:
period_from = _parseIsoDate(dateFrom) period_from = _parseIsoDate(dateFrom)
@ -132,6 +152,8 @@ def _aggregate(
dateTo=dateTo, dateTo=dateTo,
bucket=bucket, bucket=bucket,
trackerIds=trackerIdsFilter, trackerIds=trackerIdsFilter,
categoryIds=categoryIdsFilter,
statusFilter=statusFilter,
kpis=kpis, kpis=kpis,
statusByTracker=status_by_tracker, statusByTracker=status_by_tracker,
throughput=throughput, throughput=throughput,
@ -242,9 +264,17 @@ def _throughput(
periodTo: Optional[_dt.datetime], periodTo: Optional[_dt.datetime],
bucket: str, bucket: str,
) -> List[RedmineThroughputBucket]: ) -> 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: if not tickets:
return [] return []
# If no period is set, span the lifetime of the data.
if periodFrom is None or periodTo is None: if periodFrom is None or periodTo is None:
all_dates: List[_dt.datetime] = [] all_dates: List[_dt.datetime] = []
for t in tickets: for t in tickets:
@ -257,6 +287,7 @@ def _throughput(
periodFrom = periodFrom or min(all_dates) periodFrom = periodFrom or min(all_dates)
periodTo = periodTo or max(all_dates) periodTo = periodTo or max(all_dates)
# 1) Per-bucket flow counters (created / closed) within the period.
created_counter: Counter = Counter() created_counter: Counter = Counter()
closed_counter: Counter = Counter() closed_counter: Counter = Counter()
for t in tickets: for t in tickets:
@ -268,22 +299,109 @@ def _throughput(
if u and _inPeriod(u, periodFrom, periodTo): if u and _inPeriod(u, periodFrom, periodTo):
closed_counter[_bucketKey(u, bucket)] += 1 closed_counter[_bucketKey(u, bucket)] += 1
keys: List[str] = sorted(set(created_counter) | set(closed_counter)) # 2) Build the contiguous list of bucket keys spanning [from, to] so
if not keys: # the line chart has a stable x-axis even for empty intervals.
bucket_keys = _bucketKeysBetween(periodFrom, periodTo, bucket)
if not bucket_keys:
return [] 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] = [] 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( out.append(
RedmineThroughputBucket( RedmineThroughputBucket(
bucketKey=key, bucketKey=key,
label=_bucketLabel(key, bucket), label=_bucketLabel(key, bucket),
created=int(created_counter.get(key, 0)), created=int(created_counter.get(key, 0)),
closed=int(closed_counter.get(key, 0)), closed=int(closed_counter.get(key, 0)),
cumTotal=int(cum_total),
cumOpen=int(cum_open),
) )
) )
return out 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( def _topAssignees(
tickets: List[RedmineTicketDto], *, limit: int = 10 tickets: List[RedmineTicketDto], *, limit: int = 10
) -> List[RedmineAssigneeBucket]: ) -> List[RedmineAssigneeBucket]:

View file

@ -21,13 +21,29 @@ from typing import Any, Dict, Iterable, Optional, Tuple
_DEFAULT_TTL_SECONDS = 90.0 _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 @dataclass
class _CacheEntry: class _CacheEntry:
value: Any value: Any
expiresAt: float 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: class RedmineStatsCache:
@ -48,13 +64,23 @@ class RedmineStatsCache:
dateTo: Optional[str], dateTo: Optional[str],
bucket: str, bucket: str,
trackerIds: Iterable[int], trackerIds: Iterable[int],
*extraDims: Any,
) -> CacheKey: ) -> 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 ( return (
str(featureInstanceId), str(featureInstanceId),
dateFrom or None, dateFrom or None,
dateTo or None, dateTo or None,
(bucket or "week").lower(), (bucket or "week").lower(),
tuple(sorted(int(t) for t in trackerIds or [])), tuple(sorted(int(t) for t in trackerIds or [])),
tuple(_freeze(d) for d in extraDims),
) )
def get(self, key: CacheKey) -> Optional[Any]: def get(self, key: CacheKey) -> Optional[Any]:

View file

@ -282,8 +282,10 @@ def _ticketRecordFromIssue(
author = issue.get("author") or {} author = issue.get("author") or {}
parent = issue.get("parent") or {} parent = issue.get("parent") or {}
fixed_version = issue.get("fixed_version") or {} fixed_version = issue.get("fixed_version") or {}
category = issue.get("category") or {}
created_on = issue.get("created_on") created_on = issue.get("created_on")
updated_on = issue.get("updated_on") updated_on = issue.get("updated_on")
updated_ts = _parseRedmineDateToEpoch(updated_on)
return { return {
"featureInstanceId": featureInstanceId, "featureInstanceId": featureInstanceId,
@ -305,10 +307,16 @@ def _ticketRecordFromIssue(
"parentId": parent.get("id"), "parentId": parent.get("id"),
"fixedVersionId": fixed_version.get("id"), "fixedVersionId": fixed_version.get("id"),
"fixedVersionName": fixed_version.get("name"), "fixedVersionName": fixed_version.get("name"),
"categoryId": category.get("id"),
"categoryName": category.get("name"),
"createdOn": created_on, "createdOn": created_on,
"updatedOn": updated_on, "updatedOn": updated_on,
"createdOnTs": _parseRedmineDateToEpoch(created_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 []), "customFields": list(issue.get("custom_fields") or []),
"raw": issue, "raw": issue,
"syncedAt": nowEpoch, "syncedAt": nowEpoch,

View file

@ -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"]

View file

@ -0,0 +1,2 @@
# Copyright (c) 2026 Patrick Motsch
# All rights reserved.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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