feat(rules): règles d'automatisation déclaratives (issue #8)
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.
This commit is contained in:
+1
-1
@@ -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(
|
||||
|
||||
+111
@@ -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
|
||||
@@ -0,0 +1,97 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Règles d'automatisation — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<a href="/" class="logo">📋 Kanbante</a>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/today" class="nav-pill">🌅 Aujourd'hui</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/forecast" class="nav-pill">🔮 Prévisions</a>
|
||||
<a href="/rules" class="nav-pill active">⚙️ Règles</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
<h1>⚙️ Règles d'automatisation</h1>
|
||||
<p>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 <code>rule</code>. <strong>Garde-fou v1 : une règle ne peut ni fermer ni déplacer une carte entre colonnes</strong> — seulement étiqueter, notifier (ntfy) et remonter en tête de colonne.</p>
|
||||
{% if not configured %}
|
||||
<p>Aucune règle configurée. Définis <code>KANBANTE_RULES</code> en JSON, par exemple :</p>
|
||||
<pre class="briefing-text">[{"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"}}]</pre>
|
||||
<p class="notes-hint">Conditions : <code>column</code>, <code>min_age_days</code> (temps dans la colonne), <code>label</code> (présente), <code>wip_over</code> (colonne au-delà de N cartes). Actions : <code>add_label</code>, <code>notify</code>, <code>move_top</code>.</p>
|
||||
{% endif %}
|
||||
{% for err in errors %}
|
||||
<p class="chip-warn">⚠️ {{ err }}</p>
|
||||
{% endfor %}
|
||||
<p><button type="button" class="btn-save btn-inline" id="rules-run">▶️ Évaluer maintenant</button>
|
||||
<span id="rules-run-result" class="notes-hint"></span></p>
|
||||
</section>
|
||||
|
||||
{% if rules %}
|
||||
<section class="briefing-card">
|
||||
<h2>📜 Règles actives ({{ rules|length }})</h2>
|
||||
<ul class="forecast-milestones">
|
||||
{% for rule in rules %}
|
||||
<li><strong>{{ rule.name }}</strong> — quand
|
||||
{% for k, v in rule.when.items() %}<code>{{ k }} = {{ v }}</code>{% if not loop.last %}, {% endif %}{% endfor %}
|
||||
→ {% for k, v in rule.then.items() %}<code>{{ k }}{% if v is not boolean %} = {{ v }}{% endif %}</code>{% if not loop.last %}, {% endif %}{% endfor %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if last_run %}
|
||||
<section class="briefing-card">
|
||||
<h2>🕐 Dernière évaluation — {{ last_run.at }}</h2>
|
||||
{% if last_run.actions %}
|
||||
<ul class="forecast-milestones">
|
||||
{% for action in last_run.actions %}
|
||||
<li><strong>{{ action.rule }}</strong> : {{ action.repo }} #{{ action.number }} ({{ action.column }}) — {{ action.did|join(', ') }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p>Aucune action déclenchée.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
</main>
|
||||
<footer>
|
||||
<span>Kanbante v{{ version }} · l'automatisation maison, près des données, jamais destructive</span>
|
||||
</footer>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
<script>
|
||||
document.getElementById('rules-run').addEventListener('click', async () => {
|
||||
const out = document.getElementById('rules-run-result');
|
||||
out.textContent = 'Évaluation en cours…';
|
||||
const csrf = document.querySelector('meta[name="csrf-token"]').content;
|
||||
const data = new FormData();
|
||||
data.append('csrf_token', csrf);
|
||||
try {
|
||||
const r = await fetch('/api/rules/run', { method: 'POST', body: data });
|
||||
const body = await r.json();
|
||||
if (!r.ok || !body.ok) throw new Error((body && body.error) || ('HTTP ' + r.status));
|
||||
out.textContent = '✔ ' + body.summary.actions.length + ' action(s) — rechargement…';
|
||||
setTimeout(() => location.reload(), 700);
|
||||
} catch (e) {
|
||||
out.textContent = '❌ ' + e.message;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user