From 7eef8bb604a0cfe12b3e6c4e3f33246eb04486d5 Mon Sep 17 00:00:00 2001 From: Spark Date: Sun, 11 Oct 2026 14:21:11 -0400 Subject: [PATCH] =?UTF-8?q?feat(rules):=20r=C3=A8gles=20d'automatisation?= =?UTF-8?q?=20d=C3=A9claratives=20(issue=20#8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KANBANTE_RULES (JSON validé): conditions colonne/âge/étiquette/ WIP -> add_label / notify (garde quotidienne) / move_top; évaluation 15 min + POST /api/rules/run; actions journalisées (événement rule); page /rules. Aucune fermeture possible. --- app/models.py | 2 +- app/rules.py | 111 +++++++++++++++++++++++++++++++++++++++ app/templates/rules.html | 97 ++++++++++++++++++++++++++++++++++ 3 files changed, 209 insertions(+), 1 deletion(-) create mode 100644 app/rules.py create mode 100644 app/templates/rules.html 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 }})

+
    + {% for rule in rules %} +
  • {{ rule.name }} — quand + {% for k, v in rule.when.items() %}{{ k }} = {{ v }}{% if not loop.last %}, {% endif %}{% endfor %} + → {% for k, v in rule.then.items() %}{{ k }}{% if v is not boolean %} = {{ v }}{% endif %}{% if not loop.last %}, {% endif %}{% endfor %}
  • + {% endfor %} +
+
+ {% endif %} + + {% if last_run %} +
+

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

+ {% if last_run.actions %} +
    + {% for action in last_run.actions %} +
  • {{ action.rule }} : {{ action.repo }} #{{ action.number }} ({{ action.column }}) — {{ action.did|join(', ') }}
  • + {% endfor %} +
+ {% else %} +

Aucune action déclenchée.

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