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