diff --git a/CHANGELOG.md b/CHANGELOG.md index e0e4bee..3998c0e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 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) diff --git a/app/agent_journal.py b/app/agent_journal.py new file mode 100644 index 0000000..cbc318a --- /dev/null +++ b/app/agent_journal.py @@ -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 diff --git a/app/config.py b/app/config.py index d1aac4f..5ad5f1e 100644 --- a/app/config.py +++ b/app/config.py @@ -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.28.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", ""), ) diff --git a/app/main.py b/app/main.py index 7867778..9c0c99d 100644 --- a/app/main.py +++ b/app/main.py @@ -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 ) diff --git a/pyproject.toml b/pyproject.toml index 5835967..dd839bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kanbante" -version = "0.27.0" +version = "0.28.0" description = "Kanban project manager integrated with Gitea" requires-python = ">=3.11" dependencies = [ diff --git a/tests/test_agent_journal.py b/tests/test_agent_journal.py new file mode 100644 index 0000000..9a06b46 --- /dev/null +++ b/tests/test_agent_journal.py @@ -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()