redmine integrated and fixed
This commit is contained in:
parent
dc0346904f
commit
908be0511b
20 changed files with 1058 additions and 18 deletions
|
|
@ -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
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
||||||
170
modules/features/graphicalEditor/nodeDefinitions/redmine.py
Normal file
170
modules/features/graphicalEditor/nodeDefinitions/redmine.py
Normal 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",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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}},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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=[
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
7
modules/workflows/methods/methodRedmine/__init__.py
Normal file
7
modules/workflows/methods/methodRedmine/__init__.py
Normal 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"]
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Copyright (c) 2026 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
38
modules/workflows/methods/methodRedmine/actions/_shared.py
Normal file
38
modules/workflows/methods/methodRedmine/actions/_shared.py
Normal 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
|
||||||
|
|
@ -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
|
||||||
64
modules/workflows/methods/methodRedmine/actions/getStats.py
Normal file
64
modules/workflows/methods/methodRedmine/actions/getStats.py
Normal 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)})
|
||||||
|
|
@ -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],
|
||||||
|
})
|
||||||
|
|
@ -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)})
|
||||||
35
modules/workflows/methods/methodRedmine/actions/runSync.py
Normal file
35
modules/workflows/methods/methodRedmine/actions/runSync.py
Normal 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)})
|
||||||
|
|
@ -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
|
||||||
253
modules/workflows/methods/methodRedmine/methodRedmine.py
Normal file
253
modules/workflows/methods/methodRedmine/methodRedmine.py
Normal 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__)
|
||||||
Loading…
Reference in a new issue