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")
|
||||
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
|
||||
# ------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
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})
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -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}},
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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=[
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
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