Compare commits

...
3 Commits
Author SHA1 Message Date
Spark 3964323abd Panneau Spark V3 : console en surimpression pleine page (0.29.0)
CI / test (push) Successful in 2m11s
2026-10-23 04:10:35 +00:00
Spark 4230ac1052 Panneau Spark : bouton « Console ↗ » vers la console du service (0.28.1)
CI / test (push) Successful in 2m6s
2026-10-22 21:18:49 +00:00
Spark 3809462cc7 0.28.0 — Émission d'événements vers le journal du service d'agents (Lot 2)
CI / test (push) Successful in 2m1s
- app/agent_journal.py : enveloppe ACC-0.2 signée HMAC-SHA256
  (clé AGENT_EVENEMENTS_CLE, URL AGENT_EVENEMENTS_URL), envoi
  en arrière-plan, timeout 2 s, inactif sans clé, échecs avalés
- main.py : _emettre_changement_colonne accolée aux log_event de
  déplacement (interface, étiquettes forge, webhook, réconciliation,
  PR fusionnée, branche) — kanbante.card.moved pour tout changement
  de colonne, kanbante.card.review_requested à l'entrée en Révision
- config : AGENT_EVENEMENTS_URL/_CLE documentés et lus à l'appel
- 247 tests verts (5 nouveaux : signature revérifiable, inactivité
  sans clé, moved, review_requested, panne du récepteur)
2026-10-22 18:40:00 +00:00
7 changed files with 581 additions and 3 deletions
+51
View File
@@ -1,5 +1,56 @@
# Changelog
## 0.29.0 — 2026-10-23
### Modifié — panneau Spark V3 : la console en surimpression pleine page
Le bouton « Console ↗ » du panneau Spark (demande de Bruno) ne part
plus en nouvel onglet : la console web du service d'agents
(https://agent.dracodev.net/) s'ouvre désormais **dans la même
page**, en surimpression qui recouvre tout l'écran avec la même
animation d'ouverture que la barre latérale (panneau vendangé
`draco-panneau.js`, recopié octet-identique dans les 4 apps ; les
styles de la surimpression sont injectés par le panneau lui-même).
La barre de la surimpression offre les deux sorties : « Mode
sidebar » referme la console et rouvre l'agent en barre latérale,
« Fermer l'agent » ferme la surimpression et le panneau. La
discussion affichée et l'app d'origine (`?disc=<id>&app=kanbante`)
passent comme avant à la console. Côté service d'agents : la
console autorise l'encadrement par les 4 apps (`frame-ancestors`,
service 0.20.0). Même livraison : TikTak 0.60.0, Flowdeck 7.81.0,
ObsiGate 2.77.0. **Redéploiement Docker requis** pour l'activer.
## 0.28.1 — 2026-10-22
### Ajouté — panneau Spark : bouton « Console ↗ »
Bouton « Console ↗ » dans la barre des discussions du panneau
Spark (demande de Bruno) : ouvre la console web du service
d'agents (https://agent.dracodev.net/) dans un nouvel onglet,
sur la discussion actuellement affichée (`?disc=<id>`, URL
construite au clic) et avec l'app d'origine (`?app=kanbante`,
déduite du sous-domaine). Panneau recopié octet-identique dans
les 4 apps (TikTak 0.59.1, Flowdeck 7.80.1, Kanbante 0.28.1,
ObsiGate 2.76.1). **Redéploiement Docker requis** pour l'activer.
## 0.28.0 — 2026-10-22
### Ajouté — émission d'événements vers le journal du service d'agents (Lot 2)
Kanbante émet désormais ses changements de colonne au journal
d'activité cross-app du service d'agents (contrat ACC-0.2) :
`kanbante.card.moved` pour tout déplacement réel d'une carte —
glisser-déposer de l'interface, adoption d'une étiquette
`kanban:*` posée dans la forge, fermeture/réouverture venue de la
forge (webhook ou réconciliation à l'affichage), fusion de PR,
création de branche — et `kanbante.card.review_requested` quand
une carte entre en colonne Révision. Enveloppe signée HMAC-SHA256
avec la clé propre à Kanbante (`AGENT_EVENEMENTS_CLE`, URL
réglable par `AGENT_EVENEMENTS_URL`) ; émission au mieux, en
arrière-plan : inactive sans clé, et une panne du service
d'agents n'affecte jamais un déplacement. Aucun changement de
comportement visible. Redéploiement Docker requis.
## 0.27.0 — 2026-10-22
### Ajouté — panneau Spark v2 (Lot 1)
+119
View File
@@ -0,0 +1,119 @@
"""Émission d'événements vers le journal du service d'agents (Lot 2).
Kanbante émet ses changements de colonne au journal d'activité
cross-app du service d'agents, au contrat ACC-0.2 : enveloppe JSON
``{id, type, occurred_at, actor, domain, source_app,
object{ref, label, url}}`` signée HMAC-SHA256 du corps brut
(en-tête ``X-Draco-Signature``), avec la clé propre à Kanbante
(``AGENT_EVENEMENTS_CLE`` ; le service la connaît sous le nom
d'app « kanbante » dans ``AGENT_EVENEMENTS_CLES``).
Inactif tant que la clé n'est pas posée. Jamais bloquant, jamais
d'échec remonté au métier : l'envoi part dans un fil d'arrière-plan,
timeout court, exceptions avalées avec un avertissement au journal
local. L'appel sortant suit le patron de la maison (httpx, voir
``briefing.send_ntfy``).
"""
import hashlib
import hmac
import json
import logging
import threading
import uuid
from datetime import datetime, timezone
import httpx
from app.config import get_settings
logger = logging.getLogger("kanbante")
SOURCE_APP = "kanbante"
DELAI_S = 2.0
def actif() -> bool:
"""True quand l'émission est configurée (clé propre posée)."""
return bool(get_settings().agent_evenements_cle)
def construire_enveloppe(
type_: str,
ref: str,
label: str,
url: str,
domain: str = "pro",
actor: str = "user",
) -> dict:
"""L'enveloppe ACC-0.2 : une référence et un libellé, jamais le
contenu de l'objet."""
return {
"id": uuid.uuid4().hex,
"type": type_,
"occurred_at": datetime.now(timezone.utc).isoformat(),
"actor": actor,
"domain": domain,
"source_app": SOURCE_APP,
"object": {"ref": ref, "label": label, "url": url},
}
def signer(corps: bytes, cle: str) -> str:
"""Signature ACC-0.2 : HMAC-SHA256 (hexadécimal) du corps brut."""
return hmac.new(cle.encode(), corps, hashlib.sha256).hexdigest()
def _poster(url: str, corps: bytes, signature: str) -> bool:
"""POST l'enveloppe signée ; False (et un avertissement) sur
tout échec — le récepteur en panne ne doit rien casser ici."""
try:
reponse = httpx.post(
url,
content=corps,
headers={
"Content-Type": "application/json",
"X-Draco-Signature": signature,
},
timeout=DELAI_S,
)
if reponse.status_code >= 300:
logger.warning(
"Journal agent : réponse %s de %s", reponse.status_code, url
)
return False
return True
except Exception as exc:
logger.warning("Journal agent injoignable (%s) : %s", url, exc)
return False
def emettre(
type_: str,
ref: str,
label: str,
url: str,
domain: str = "pro",
actor: str = "user",
) -> threading.Thread | None:
"""Émettre un événement, au mieux, depuis un fil d'arrière-plan.
Retourne le fil lancé (les tests peuvent le joindre), ou None
quand l'émission n'est pas configurée. Ne lève jamais.
"""
settings = get_settings()
cle = settings.agent_evenements_cle
if not cle:
return None
enveloppe = construire_enveloppe(
type_, ref, label, url, domain=domain, actor=actor
)
corps = json.dumps(enveloppe, ensure_ascii=False).encode("utf-8")
fil = threading.Thread(
target=_poster,
args=(settings.agent_evenements_url, corps, signer(corps, cle)),
name="kanbante-agent-journal",
daemon=True,
)
fil.start()
return fil
+17 -1
View File
@@ -96,6 +96,15 @@ Environment variables:
KANBANTE_ZOMBIE_DAYS Days without any activity after
which an open card is a "zombie"
on /health (issue #9). Default 30.
AGENT_EVENEMENTS_URL Journal endpoint of the agent
service (Lot 2, contract ACC-0.2)
receiving this app's signed events.
Default https://agent.dracodev.net/api/v1/evenements.
AGENT_EVENEMENTS_CLE Kanbante's own HMAC key signing the
events it emits to that journal (the
service knows it as the "kanbante"
entry of AGENT_EVENEMENTS_CLES).
Empty (default) = emission disabled.
"""
import os
@@ -104,7 +113,7 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.27.0"
APP_VERSION = "0.29.0"
def _env_bool(name: str, default: bool) -> bool:
@@ -180,6 +189,8 @@ class Settings:
sso_machine_key: str = ""
sso_url: str = "https://og.dracodev.net"
archive_suggest_days: int = 180
agent_evenements_url: str = "https://agent.dracodev.net/api/v1/evenements"
agent_evenements_cle: str = ""
@property
def auth_enabled(self) -> bool:
@@ -238,6 +249,11 @@ def _env_settings() -> Settings:
sso_machine_key=os.getenv("DRACO_SSO_MACHINE_KEY", ""),
sso_url=os.getenv("DRACO_SSO_URL", "https://og.dracodev.net").rstrip("/"),
archive_suggest_days=_env_int("KANBANTE_ARCHIVE_SUGGEST_DAYS", 180),
agent_evenements_url=os.getenv(
"AGENT_EVENEMENTS_URL",
"https://agent.dracodev.net/api/v1/evenements",
).rstrip("/"),
agent_evenements_cle=os.getenv("AGENT_EVENEMENTS_CLE", ""),
)
+69
View File
@@ -58,6 +58,7 @@ from app.briefing import (
send_ntfy,
)
from app.capture import ntfy_capture_loop, parse_capture_text
from app import agent_journal
from app import models
from app.models import (
KANBAN_COLUMNS,
@@ -2020,6 +2021,38 @@ def _merge_issues_with_board(
return columns
def _emettre_changement_colonne(
full_name: str,
number: int,
from_column: str | None,
to_column: str,
source: str,
title: str = "",
) -> None:
"""Émission Lot 2 : refléter un changement de colonne réel dans
le journal du service d'agents (contrat ACC-0.2, voir
app/agent_journal.py).
Appelée à côté des log_event qui consignent un déplacement —
jamais à leur place. ``kanbante.card.moved`` pour tout
changement de colonne, plus ``kanbante.card.review_requested``
quand la carte ENTRE en colonne Révision. Au mieux : sans clé
configurée, rien ne part ; en cas de panne du service, le
déplacement n'est jamais affecté.
"""
if not to_column or from_column == to_column:
return
actor = "user" if source == "ui" else f"agent:{source}"
ref = f"issue:{full_name}#{number}"
label = (title or "").strip() or ref
url = f"{get_settings().gitea_url}/{full_name}/issues/{number}"
agent_journal.emettre("kanbante.card.moved", ref, label, url, actor=actor)
if to_column == "review" and from_column != "review":
agent_journal.emettre(
"kanbante.card.review_requested", ref, label, url, actor=actor
)
def _adopt_label_columns(
full_name: str,
open_issues: list[dict],
@@ -2063,6 +2096,10 @@ def _adopt_label_columns(
from_column=current, to_column=target, source="gitea",
workspace_id=workspace_id, connection_id=connection_id,
)
_emettre_changement_colonne(
full_name, issue["number"], current, target, "gitea",
title=issue.get("title", ""),
)
placement[issue["number"]] = target
@@ -2392,6 +2429,9 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""):
full_name, number, "closed", to_column="done", source="gitea",
workspace_id=ws_id, connection_id=conn_id,
)
_emettre_changement_colonne(
full_name, number, None, "done", "gitea",
)
# A kanban:* label changed outside Kanbante wins over local state.
_adopt_label_columns(
@@ -2543,6 +2583,9 @@ def _board_columns_for(
full_name, number, "closed", to_column="done", source="gitea",
workspace_id=workspace_id, connection_id=connection_id,
)
_emettre_changement_colonne(
full_name, number, None, "done", "gitea",
)
_adopt_label_columns(
full_name, open_issues,
get_board_state(
@@ -4138,6 +4181,11 @@ def _apply_external_transition(
from_column="done", to_column=target_column, source="webhook",
workspace_id=workspace_id, connection_id=connection_id,
)
_emettre_changement_colonne(
full_name, number,
current if event_type == "closed" else "done",
target_column, "webhook",
)
return event_type
@@ -4207,6 +4255,10 @@ async def _apply_pr_merged(
to_column="done", source="webhook",
workspace_id=workspace_id, connection_id=connection_id,
)
_emettre_changement_colonne(
full_name, number, None, "done", "webhook",
title=issue.get("title", ""),
)
outcome = "closed"
results.append(f"#{number}:{outcome}")
return results
@@ -4823,6 +4875,19 @@ async def api_move(
from_column or None, to_column,
workspace_id=ws_id, connection_id=conn_id,
)
# Émission Lot 2 (journal du service d'agents) : le titre
# n'est relu que si l'émission est configurée, au mieux.
titre_carte = ""
if agent_journal.actif():
try:
detail = await client.get_issue(owner, repo, issue_number)
titre_carte = detail.get("title", "")
except Exception:
titre_carte = ""
_emettre_changement_colonne(
repo_full_name, issue_number, from_column or None, to_column,
"ui", title=titre_carte,
)
result: dict = {"ok": True, "gitea_sync": "skipped"}
settings = get_settings()
@@ -5156,6 +5221,10 @@ async def api_create_branch(
source="branch",
workspace_id=ws_id, connection_id=conn_id,
)
_emettre_changement_colonne(
full_name, number, current, "in_progress", "branch",
title=issue.get("title", ""),
)
await _sync_kanban_label(
owner, repo, number, "in_progress", client
)
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "kanbante"
version = "0.27.0"
version = "0.29.0"
description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11"
dependencies = [
+138 -1
View File
@@ -18,6 +18,16 @@
* Panneau v2 (Lot 1) : rendu Markdown, largeur réglable à la souris
* (persistée), thème clair/sombre aligné sur l'app hôte
* (html[data-theme], repli prefers-color-scheme, suivi en direct).
* Bouton « Console » (barre des discussions) : ouvre la console web
* du même service (agent.dracodev.net) DANS la page, en surimpression
* pleine page (iframe) qui naît à la géométrie du panneau puis
* s'étend à toute la fenêtre ; l'URL profonde (?disc= discussion
* courante, ?app= app d'origine déduite du sous-domaine) est celle
* du premier chargement — l'iframe est créé paresseusement et
* conservé, la console garde son état entre ouvertures. La barre
* d'outils de la surimpression offre « Mode sidebar » (retour au
* panneau seul, animation inverse) et « Fermer l'agent » (referme
* surimpression et panneau).
* États honnêtes : service injoignable / session non reconnue / LLM non
* configuré / exécuteur non configuré sont affichés tels quels. */
(function () {
@@ -44,6 +54,124 @@
catch (e) { return null; }
}
/* ── Console web (même service, surimpression pleine page) ─── */
function appHote() {
var h = (window.location.hostname || "").split(".")[0];
var correspondance = { tiktak: "tiktak", flowdeck: "flowdeck",
kanbante: "kanbante", og: "obsigate" };
return correspondance[h] || null;
}
function urlConsole() {
var params = [];
var id = convId || convStockee();
if (id) params.push("disc=" + encodeURIComponent(id));
var app = appHote();
if (app) params.push("app=" + encodeURIComponent(app));
return API + "/" + (params.length ? "?" + params.join("&") : "");
}
/* La surimpression vit entièrement dans ce fichier (DOM + styles
* injectés) : les apps hôtes n'ont que ce fichier à recopier. Son
* thème suit celui du panneau (data-draco-theme synchronisé par
* appliquerTheme) et sa palette reprend les mêmes valeurs que
* draco-panneau.css. L'iframe est créé au premier clic puis
* conservé : rouvrir la console ne le recharge pas. */
var surEl = null, surCadre = null, surOuverte = false;
var STYLES_CONSOLE =
".draco-console-sur{" +
"--p-fond:#fbfbfa;--p-surface:#ffffff;--p-bord:#e4e5e0;--p-texte:#242624;" +
"--p-chip:#f4f5f1;--p-chip-bord:#d6d8d1;--p-chip-survol:#e9ebe4;--p-accent:#7c5cd6;" +
"position:fixed;top:0;right:0;height:100dvh;width:388px;max-width:100vw;z-index:1000;" +
"background:var(--p-fond);color:var(--p-texte);" +
"box-shadow:-12px 0 36px rgb(0 0 0 / .22);" +
"display:flex;flex-direction:column;transition:width .3s ease;" +
"font:14px/1.5 system-ui,\"Segoe UI\",sans-serif}" +
".draco-console-sur[data-draco-theme=\"dark\"]{" +
"--p-fond:#17181a;--p-surface:#1f2023;--p-bord:#33353a;--p-texte:#eceeeb;" +
"--p-chip:#26272b;--p-chip-bord:#3a3c41;--p-chip-survol:#2e3036}" +
".draco-console-sur[hidden]{display:none}" +
".draco-console-barre{display:flex;align-items:center;gap:9px;flex:none;" +
"padding:9px 12px;border-bottom:1px solid var(--p-bord);background:var(--p-surface)}" +
".draco-console-barre .dc-titre{font-weight:700;font-size:14px;" +
"display:flex;align-items:center;gap:8px;white-space:nowrap}" +
".draco-console-barre .dc-etoile{color:var(--p-accent);font-size:17px}" +
".draco-console-barre .dc-actions{margin-left:auto;display:flex;gap:7px}" +
".draco-console-barre button{border:1px solid var(--p-chip-bord);" +
"background:var(--p-chip);color:var(--p-texte);border-radius:999px;" +
"padding:5px 12px;font:600 12.5px system-ui;cursor:pointer;white-space:nowrap}" +
".draco-console-barre button:hover{background:var(--p-chip-survol)}" +
".draco-console-cadre{flex:1;width:100%;border:0;display:block;background:var(--p-fond)}";
function assurerSurimpression() {
if (surEl) return;
if (!document.getElementById("draco-console-styles")) {
var st = document.createElement("style");
st.id = "draco-console-styles";
st.textContent = STYLES_CONSOLE;
document.head.appendChild(st);
}
surEl = document.createElement("div");
surEl.className = "draco-console-sur";
surEl.setAttribute("aria-label", "Console Spark");
surEl.hidden = true;
surEl.innerHTML =
'<div class="draco-console-barre">' +
' <span class="dc-titre"><span class="dc-etoile">✦</span> Console Spark</span>' +
' <span class="dc-actions">' +
' <button type="button" data-dc="sidebar" title="Réduire la console et revenir au panneau latéral">◧ Mode sidebar</button>' +
' <button type="button" data-dc="fermer" title="Fermer la console et le panneau Spark">✕ Fermer l\'agent</button>' +
' </span></div>' +
'<iframe class="draco-console-cadre" title="Console Spark" allow="microphone"></iframe>';
document.body.appendChild(surEl);
surCadre = surEl.querySelector("iframe");
surEl.querySelector('[data-dc="sidebar"]')
.addEventListener("click", modeSidebarConsole);
surEl.querySelector('[data-dc="fermer"]')
.addEventListener("click", fermerAgentConsole);
appliquerTheme();
}
function largeurPanneau() {
if (root) {
var w = root.getBoundingClientRect().width;
if (w > 0) return Math.round(w);
}
return 388;
}
function ouvrirConsole() {
assurerSurimpression();
if (!surCadre.getAttribute("src")) surCadre.src = urlConsole();
if (surOuverte) return;
surOuverte = true;
/* Naissance à la géométrie du panneau (ancrée à droite), puis
* extension à toute la page ; le double rAF laisse le temps de
* peindre la largeur de départ, sinon la transition ne joue pas. */
surEl.style.width = largeurPanneau() + "px";
surEl.hidden = false;
requestAnimationFrame(function () {
requestAnimationFrame(function () { surEl.style.width = "100%"; });
});
}
function retrecirConsole() {
if (!surEl || !surOuverte) return;
surOuverte = false;
var fini = false;
function terminer() {
if (fini) return;
fini = true;
surEl.removeEventListener("transitionend", surFin);
surEl.hidden = true; // l'iframe reste chargé, juste masqué
}
function surFin(ev) { if (ev.propertyName === "width") terminer(); }
surEl.addEventListener("transitionend", surFin);
setTimeout(terminer, 380); // filet si transitionend ne vient pas
surEl.style.width = largeurPanneau() + "px";
}
function modeSidebarConsole() { retrecirConsole(); }
function fermerAgentConsole() { fermer(); retrecirConsole(); }
/* ── Thème : suivre l'app hôte ─────────────────────────────── */
function themeHote() {
var t = document.documentElement.getAttribute("data-theme");
@@ -57,6 +185,7 @@
}
function appliquerTheme() {
if (root) root.setAttribute("data-draco-theme", themeHote());
if (surEl) surEl.setAttribute("data-draco-theme", themeHote());
}
function surveillerTheme() {
try {
@@ -122,6 +251,7 @@
' <span class="d-titre">Discussion principale</span>' +
' <button type="button" class="d-btn" data-disc="liste">☰ Discussions</button>' +
' <button type="button" class="d-btn" data-disc="nouvelle">+ Nouvelle</button>' +
' <button type="button" class="d-btn" data-disc="console" title="Ouvrir la console Spark (agent.dracodev.net) en pleine page par-dessus l\'application, sur cette discussion">Console ⤢</button>' +
'</div>' +
'<div class="draco-discussions" hidden></div>' +
'<div class="draco-panneau-outils">' +
@@ -145,6 +275,7 @@
root.querySelector(".fermer").addEventListener("click", fermer);
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion);
root.querySelector('[data-disc="console"]').addEventListener("click", ouvrirConsole);
root.querySelector('[data-outil="aujourdhui"]').addEventListener("click", chargerAujourdhui);
root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations);
root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions);
@@ -155,7 +286,13 @@
appliquerLargeur();
window.addEventListener("resize", appliquerLargeur);
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape" && root.classList.contains("open")) fermer();
if (ev.key !== "Escape") return;
/* Surimpression ouverte : Échap = retour au panneau seul
* (« Mode sidebar »). Quand le focus est dans l'iframe de la
* console (autre origine), l'événement ne remonte pas
* jusqu'ici : il ne peut donc pas fermer par surprise. */
if (surOuverte) { modeSidebarConsole(); return; }
if (root.classList.contains("open")) fermer();
});
}
+186
View File
@@ -0,0 +1,186 @@
"""Lot 2, vague 2 — émission d'événements de Kanbante vers le
journal du service d'agents (contrat ACC-0.2).
Couvre : l'enveloppe signée (HMAC recalculé côté test), l'inactivité
sans clé, l'émission sur déplacement de carte (``kanbante.card.moved``)
et sur entrée en Révision (``kanbante.card.review_requested``), et
l'absence totale d'effet d'une panne du récepteur sur le métier.
"""
import hashlib
import hmac
import json
import threading
import time
from datetime import datetime
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import pytest
from app import agent_journal, models
from tests.conftest import csrf_from
CLE = "cle-kanbante-de-test"
class Recepteur:
"""Faux service d'agents : capture corps bruts et signatures."""
def __init__(self) -> None:
self.recus: list[tuple[bytes, str]] = []
self._verrou = threading.Lock()
recus = self.recus
verrou = self._verrou
class Mains(BaseHTTPRequestHandler):
def log_message(self, *args):
pass
def do_POST(self):
corps = self.rfile.read(int(self.headers.get("Content-Length", 0)))
with verrou:
recus.append((corps, self.headers.get("X-Draco-Signature", "")))
self.send_response(201)
self.end_headers()
self._serveur = ThreadingHTTPServer(("127.0.0.1", 0), Mains)
threading.Thread(target=self._serveur.serve_forever, daemon=True).start()
self.url = f"http://127.0.0.1:{self._serveur.server_address[1]}/api/v1/evenements"
def enveloppes(self, type_: str | None = None, ref: str | None = None) -> list[dict]:
with self._verrou:
bruts = list(self.recus)
envs = [json.loads(corps) for corps, _ in bruts]
if type_ is not None:
envs = [e for e in envs if e["type"] == type_]
if ref is not None:
envs = [e for e in envs if e["object"]["ref"] == ref]
return envs
def attendre(self, type_: str, ref: str, timeout: float = 5.0) -> dict:
"""Poll jusqu'à l'arrivée de l'événement voulu (envoi en
arrière-plan : l'attente fait partie du contrat de test)."""
limite = time.monotonic() + timeout
while time.monotonic() < limite:
trouves = self.enveloppes(type_, ref)
if trouves:
return trouves[0]
time.sleep(0.05)
raise AssertionError(f"Événement {type_} pour {ref} jamais reçu")
@pytest.fixture
def recepteur():
return Recepteur()
@pytest.fixture
def journal_actif(monkeypatch, recepteur):
"""Émission configurée vers le faux récepteur local."""
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
monkeypatch.setenv("AGENT_EVENEMENTS_URL", recepteur.url)
return recepteur
def test_enveloppe_signee_verifiable(journal_actif):
fil = agent_journal.emettre(
"kanbante.card.moved",
"issue:Projets/demo#1",
"Tâche un",
"http://gitea.test/Projets/demo/issues/1",
actor="user",
)
assert fil is not None
fil.join(timeout=5)
env = journal_actif.attendre("kanbante.card.moved", "issue:Projets/demo#1")
corps, signature = journal_actif.recus[0]
# La signature se revérifie indépendamment, comme le fait le service.
assert signature == hmac.new(CLE.encode(), corps, hashlib.sha256).hexdigest()
assert json.loads(corps) == env
assert env["id"]
assert env["source_app"] == "kanbante"
assert env["domain"] == "pro"
assert env["actor"] == "user"
assert env["object"] == {
"ref": "issue:Projets/demo#1",
"label": "Tâche un",
"url": "http://gitea.test/Projets/demo/issues/1",
}
# occurred_at : ISO 8601 lisible par le service.
datetime.fromisoformat(env["occurred_at"])
def test_inactif_sans_cle(monkeypatch, recepteur):
monkeypatch.delenv("AGENT_EVENEMENTS_CLE", raising=False)
monkeypatch.setenv("AGENT_EVENEMENTS_URL", recepteur.url)
assert agent_journal.actif() is False
assert agent_journal.emettre(
"kanbante.card.moved", "issue:Projets/demo#1", "Tâche un",
"http://gitea.test/Projets/demo/issues/1",
) is None
time.sleep(0.3)
assert recepteur.recus == []
def _deplacer(client, numero: int, de: str, vers: str):
token = csrf_from(client)
r = client.post(
"/api/move",
data={
"repo_full_name": "Projets/demo",
"issue_number": numero,
"to_column": vers,
"from_column": de,
"csrf_token": token,
},
)
assert r.status_code == 200, r.text
assert r.json()["ok"] is True
return r
def test_deplacement_emet_moved(client, journal_actif):
_deplacer(client, 1, "backlog", "in_progress")
env = journal_actif.attendre("kanbante.card.moved", "issue:Projets/demo#1")
assert env["actor"] == "user"
assert env["object"]["label"] == "Tâche un"
assert env["object"]["url"] == "http://gitea.test/Projets/demo/issues/1"
# Un déplacement hors Révision n'émet pas de review_requested.
time.sleep(0.3)
assert journal_actif.enveloppes(
"kanbante.card.review_requested", "issue:Projets/demo#1"
) == []
def test_entree_en_revision_emet_review_requested(client, journal_actif):
_deplacer(client, 1, "backlog", "review")
journal_actif.attendre("kanbante.card.moved", "issue:Projets/demo#1")
env = journal_actif.attendre(
"kanbante.card.review_requested", "issue:Projets/demo#1"
)
assert env["object"]["label"] == "Tâche un"
assert env["actor"] == "user"
# Sortir de Révision ne redemande pas de révision.
_deplacer(client, 1, "review", "done")
journal_actif.attendre("kanbante.card.moved", "issue:Projets/demo#1")
time.sleep(0.3)
assert len(journal_actif.enveloppes(
"kanbante.card.review_requested", "issue:Projets/demo#1"
)) == 1
def test_panne_du_recepteur_sans_effet_sur_le_deplacement(client, monkeypatch):
# Récepteur mort (port 1 fermé) mais émission configurée.
monkeypatch.setenv("AGENT_EVENEMENTS_CLE", CLE)
monkeypatch.setenv("AGENT_EVENEMENTS_URL", "http://127.0.0.1:1/api/v1/evenements")
_deplacer(client, 1, "backlog", "in_progress")
state = models.get_board_state("Projets/demo")
assert [c["issue_number"] for c in state["in_progress"]] == [1]
# L'échec d'envoi, lui, reste silencieux et borné dans le temps.
fil = agent_journal.emettre(
"kanbante.card.moved", "issue:Projets/demo#1", "Tâche un",
"http://gitea.test/Projets/demo/issues/1",
)
assert fil is not None
fil.join(timeout=10)
assert not fil.is_alive()