diff --git a/app/models.py b/app/models.py index e9891dc..25f17a1 100644 --- a/app/models.py +++ b/app/models.py @@ -294,7 +294,7 @@ def set_kv(key: str, value: str) -> None: # ── Event journal ────────────────────────────────────────── -EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented") +EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented", "rule") def log_event( diff --git a/app/rules.py b/app/rules.py new file mode 100644 index 0000000..ee4ada5 --- /dev/null +++ b/app/rules.py @@ -0,0 +1,111 @@ +"""Automation rules (roadmap #8): small declarative IFTTT for the board. + +Rules live in KANBANTE_RULES (JSON array) — deliberately boring: + + [{"name": "Relance révision", + "when": {"column": "review", "min_age_days": 3}, + "then": {"notify": true}}, + {"name": "Urgent qui vieillit", + "when": {"column": "in_progress", "min_age_days": 7}, + "then": {"add_label": "urgent"}}] + +Conditions: column, min_age_days (time in the current column), +label (present on the card), wip_over (column holds MORE cards +than N — then every card of the column matches). +Actions: add_label, notify (ntfy), move_top (of its column). + +Guardrail, by design: there is NO close/delete/move action — a +rule can never close an issue or move a card between columns. +""" + +import json + +from app.models import KANBAN_COLUMNS + +CONDITION_KEYS = {"column", "min_age_days", "label", "wip_over"} +ACTION_KEYS = {"add_label", "notify", "move_top"} + + +def parse_rules(raw: str) -> tuple[list[dict], list[str]]: + """Validate KANBANTE_RULES; returns (rules, human errors).""" + if not raw or not raw.strip(): + return [], [] + try: + data = json.loads(raw) + except ValueError as exc: + return [], [f"JSON invalide : {exc}"] + if not isinstance(data, list): + return [], ["KANBANTE_RULES doit être un tableau JSON de règles"] + rules: list[dict] = [] + errors: list[str] = [] + for i, item in enumerate(data): + label = f"règle #{i + 1}" + if not isinstance(item, dict): + errors.append(f"{label} : pas un objet") + continue + name = str(item.get("name") or label) + when = item.get("when") + then = item.get("then") + if not isinstance(when, dict) or not isinstance(then, dict): + errors.append(f"{name} : 'when' et 'then' (objets) sont requis") + continue + bad_when = set(when) - CONDITION_KEYS + bad_then = set(then) - ACTION_KEYS + if bad_when: + errors.append(f"{name} : conditions inconnues {sorted(bad_when)}") + continue + if bad_then: + errors.append( + f"{name} : actions inconnues {sorted(bad_then)} " + "(fermer/déplacer une carte par règle est interdit en v1)" + ) + continue + if "column" in when and when["column"] not in KANBAN_COLUMNS: + errors.append(f"{name} : colonne inconnue « {when['column']} »") + continue + if "min_age_days" in when and ( + not isinstance(when["min_age_days"], int) or when["min_age_days"] < 0 + ): + errors.append(f"{name} : min_age_days doit être un entier ≥ 0") + continue + if "wip_over" in when and ( + not isinstance(when["wip_over"], int) or when["wip_over"] < 1 + ): + errors.append(f"{name} : wip_over doit être un entier ≥ 1") + continue + if "add_label" in then and ( + not isinstance(then["add_label"], str) or not then["add_label"].strip() + ): + errors.append(f"{name} : add_label doit être un nom d'étiquette") + continue + if not then: + errors.append(f"{name} : aucune action") + continue + rules.append({"name": name, "when": when, "then": then}) + return rules, errors + + +def matching_cards( + rule: dict, + columns: dict[str, list[dict]], + ages: dict[int, dict], +) -> list[tuple[dict, str]]: + """Cards of `columns` (merged board) matched by the rule.""" + when = rule["when"] + targets = [when["column"]] if "column" in when else list(columns) + matches: list[tuple[dict, str]] = [] + for column in targets: + cards = columns.get(column, []) + if "wip_over" in when and len(cards) <= when["wip_over"]: + continue + for issue in cards: + if "label" in when and when["label"] not in [ + (lab or {}).get("name") for lab in issue.get("labels") or [] + ]: + continue + if "min_age_days" in when: + age = ages.get(issue["number"]) or {} + if (age.get("days") or 0) < when["min_age_days"]: + continue + matches.append((issue, column)) + return matches diff --git a/app/templates/rules.html b/app/templates/rules.html new file mode 100644 index 0000000..3730eac --- /dev/null +++ b/app/templates/rules.html @@ -0,0 +1,97 @@ + + + + + + + Règles d'automatisation — Kanbante + + + +
+ + +
+ +
+
+

⚙️ Règles d'automatisation

+

Des réflexes déclaratifs, évalués toutes les 15 minutes par le planificateur (et à la demande ci-dessous). Chaque action est journalisée comme événement rule. Garde-fou v1 : une règle ne peut ni fermer ni déplacer une carte entre colonnes — seulement étiqueter, notifier (ntfy) et remonter en tête de colonne.

+ {% if not configured %} +

Aucune règle configurée. Définis KANBANTE_RULES en JSON, par exemple :

+
[{"name": "Relance révision",
+  "when": {"column": "review", "min_age_days": 3},
+  "then": {"notify": true}},
+ {"name": "Urgent qui vieillit",
+  "when": {"column": "in_progress", "min_age_days": 7},
+  "then": {"add_label": "urgent"}}]
+

Conditions : column, min_age_days (temps dans la colonne), label (présente), wip_over (colonne au-delà de N cartes). Actions : add_label, notify, move_top.

+ {% endif %} + {% for err in errors %} +

⚠️ {{ err }}

+ {% endfor %} +

+

+
+ + {% if rules %} +
+

📜 Règles actives ({{ rules|length }})

+ +
+ {% endif %} + + {% if last_run %} +
+

🕐 Dernière évaluation — {{ last_run.at }}

+ {% if last_run.actions %} + + {% else %} +

Aucune action déclenchée.

+ {% endif %} +
+ {% endif %} +
+ + {% include "_palette.html" %} + + + +