Compare commits

...
6 Commits
Author SHA1 Message Date
Spark 7852d860fe fix(0.31.1): compositeur de création refait, description en <details> natif
CI / test (push) Successful in 2m13s
- Carte compositeur : en-tête + colonne d'arrivée, champ
  pleine largeur, bouton primaire ; neutralise le
  display:flex imposé par .board-toolbar aux formulaires.
- 'Ajouter une description' devient un <details> natif :
  aucun JS requis, insensible au cache et aux erreurs de
  scripts (le bouton 0.31.0 pouvait rester inerte).
- Assets du board versionnés (?v=APP_VERSION) contre les
  mélanges de cache après redéploiement ; Ctrl+Entrée.
- Vérifié en Chromium réel sur le vrai board (captures
  contrôlées) ; 261 tests verts.
2026-10-23 09:48:17 -04:00
Spark d4cf8d1275 feat(0.31.0): description éditable, pièces jointes d'issues, clic carte fiable
CI / test (push) Successful in 2m10s
- Tiroir : édition du titre et de la description (PATCH
  forge, journal 'edited', EVENT_TYPES étendu), création
  avec description dépliable.
- Pièces jointes Gitea de bout en bout : client
  list/upload/delete/download (le téléchargement suit
  browser_download_url — l'endpoint asset ne sert que du
  JSON, attrapé au smoke réel), routes proxifiées avec
  CSRF et plafond 25 Mio, UI liste/envoi/suppression ;
  GitHub : supports_attachments=False, message propre.
- Clic carte en délégation d'événement : les écouteurs par
  carte mouraient au premier rafraîchissement live.
- 261 tests verts (7 nouveaux, palier 21) ; cycle réel
  complet validé sur une issue jetable du vrai Gitea
  (création, édition, envoi, téléchargement, suppression).
2026-10-23 09:27:38 -04:00
Spark 28322c12cf feat(0.30.0): colonnes personnalisables par espace + mapping étiquette ↔ colonne
CI / test (push) Successful in 2m17s
- Table board_columns par espace (clé immuable, nom/couleur/
  ordre libres, étiquette mappée + alias, drapeau Focus) ;
  définitions semées paresseusement depuis les valeurs
  historiques — sans personnalisation, comportement identique.
- Service app/board_columns.py : résolution, mapping
  étiquette→colonne (alias compris, ex. kanban_a_faire de
  Flowdeck), colonnes Focus/Wallboard.
- Board, API, adoption, miroirs d'étiquettes, déplacements,
  capture, santé, focus, wall, dashboard branchés sur les
  colonnes de l'espace ; validations dynamiques dans models
  (move_issue, set_column_order) et rules.
- Administration → Colonnes : ajouter (avant Terminé),
  renommer, réordonner, mapper, supprimer avec migration des
  cartes vers une colonne choisie, réinitialiser ; bouton
  🧱 Colonnes sur le board ; journal d'audit.
- Backlog et Terminé : colonnes système non supprimables.
- 254 tests verts (7 nouveaux, palier 20) ; smoke réel contre
  le vrai Gitea : création, affichage au board, suppression.
2026-10-23 08:38:02 -04:00
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
22 changed files with 2408 additions and 99 deletions
+152
View File
@@ -1,5 +1,157 @@
# Changelog # Changelog
## 0.31.1 — 2026-10-23
### Corrigé — compositeur de création refait
Retour de Bruno sur la 0.31.0 : la barre de création
n'était ni élégante ni professionnelle, et le bouton
« + Description » pouvait rester sans effet (écouteur
JavaScript non enregistré selon l'état du cache ou des
scripts de la page).
- **Compositeur en carte** : en-tête « Nouvelle issue »
avec la colonne d'arrivée (« arrive dans 📥 Backlog »,
nom personnalisé de l'espace respecté), champ titre
pleine largeur avec anneau de focus, bouton primaire
« Créer l'issue ».
- **Description en `<details>` natif** : « Ajouter une
description ▾ » déplie la zone texte sans aucun
JavaScript — impossible à casser par un script en
erreur ou un fichier en cache. Aide intégrée (Markdown,
Ctrl+Entrée pour créer, actif aussi depuis le titre).
- **Assets du board versionnés** (`?v=<version>` sur le
CSS et board.js du board) : un redéploiement invalide
le cache navigateur au lieu de servir un mélange
d'anciennes et nouvelles pièces.
- Vérifié en navigateur réel (Chromium) sur le vrai
board : captures fermé/ouvert contrôlées visuellement,
clic sur le libellé, zone visible et éditable.
## 0.31.0 — 2026-10-23
### Ajouté — description éditable, pièces jointes, clic fiable
Retours de Bruno : impossible d'ajouter une description
après création, aucun moyen de joindre des fichiers comme
dans Gitea, et clic sur les cartes capricieux.
- **Titre et description éditables** dans le tiroir de
détail (bouton « ✏️ Modifier le titre et la description ») :
l'enregistrement écrit directement dans l'issue de la
forge (PATCH), le journal consigne « edited ✏️ », et la
carte du board reflète le nouveau titre sans recharger.
- **Description dès la création** : le formulaire « Nouvelle
issue » gagne un bouton « + Description » qui déplie un
champ texte transmis à la création.
- **Pièces jointes d'issues** (Gitea) : dans le tiroir,
liste des fichiers (nom, taille, date), **ajout** par le
sélecteur de fichier (25 Mio max), **téléchargement**
proxifié par Kanbante (le jeton ne quitte jamais le
serveur ; suit le `browser_download_url` de Gitea, dont
l'endpoint d'asset ne renvoie que la fiche JSON — attrapé
au smoke réel), **suppression** avec confirmation. Sur
GitHub, l'API ne gère pas les pièces jointes d'issues :
l'écran l'explique et renvoie à l'interface web.
- **Clic sur les cartes fiabilisé** : l'ouverture du tiroir
passe en **délégation d'événement** — avant, l'écouteur
était posé carte par carte au chargement ; le premier
rafraîchissement temps réel remplaçait les fragments et
les cartes devenaient muettes. Le seuil anti-glisser
(6 px) et l'exclusion liens/boutons sont conservés, et
les cartes affichent un curseur pointeur.
## 0.30.0 — 2026-10-23
### Ajouté — colonnes personnalisables et mapping étiquette ↔ colonne
Demande de Bruno : pouvoir personnaliser, retirer et ajouter
des colonnes (Backlog, À faire, En cours, …) et configurer le
mapping label/colonne depuis l'interface web.
- **Colonnes par espace** (Administration → Colonnes, ou le
bouton 🧱 Colonnes sur un board) : ajouter une colonne
(insérée avant Terminé), la renommer (le nom affiché est
libre, émojis compris), changer sa couleur, la monter ou la
descendre, la supprimer — ses cartes sont alors déplacées
vers une colonne de destination choisie. Réinitialisation
aux six colonnes historiques en un clic. Chaque espace a SES
colonnes.
- **Clés immuables** : une colonne garde sa clé interne
(`en_developpement`) quoi qu'on renomme — les cartes, les
limites WIP et les règles y restent rattachés. Backlog et
Terminé sont des colonnes système (renommables, non
supprimables) : défaut des cartes neuves, et issues fermées.
- **Mapping étiquette ↔ colonne configurable** : chaque
colonne de flux a son **étiquette mappée** (défaut
`kanban:<clé>`) posée sur l'issue à chaque déplacement
(miroir), plus des **alias** reconnus à l'adoption — par
exemple déclarer `kanban_a_faire` (convention Flowdeck) en
alias de la colonne À faire fait adopter cette colonne par
le board. Adoption : exactement une étiquette reconnue,
comme avant.
- **Focus / Wallboard** : chaque colonne déclare si elle y
figure (case « Dans Focus / Wallboard ») ; les nouvelles
colonnes y sont par défaut.
- Sans personnalisation, tout est strictement identique à
avant (définitions semées paresseusement depuis les valeurs
historiques). Le triage au clavier garde ses touches
historiques (1 = À faire, 2 = En cours, …).
## 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 ## 0.27.0 — 2026-10-22
### Ajouté — panneau Spark v2 (Lot 1) ### 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
+136
View File
@@ -0,0 +1,136 @@
"""Colonnes de board personnalisables par espace (0.30.0).
Avant : six colonnes codées en dur (backlog, todo, in_progress,
qa, review, done) et un mapping fixe colonne → étiquette
``kanban:<colonne>``. Maintenant, chaque espace de travail
définit ses colonnes : nom d'affichage, couleur, ordre,
étiquette de forge mappée et alias reconnus. Les clés sont
immuables (les données board_state les référencent) ; backlog
et done restent des colonnes système (renommables, non
supprimables) parce qu'elles portent une sémantique : défaut
des cartes neuves, et état fermé des issues.
Sans définition propre, un espace retombe sur les défauts
historiques — comportement 0.24 strictement conservé.
"""
from __future__ import annotations
from app import models
DEFAULT_COLUMNS: list[dict] = [
{"key": "backlog", "name": "📥 Backlog", "color": "#6b7280",
"label": "", "aliases": "", "kind": "backlog", "in_focus": 0},
{"key": "todo", "name": "📋 À faire", "color": "#3b82f6",
"label": "kanban:todo", "aliases": "", "kind": "flow",
"in_focus": 0},
{"key": "in_progress", "name": "🚧 En cours", "color": "#f59e0b",
"label": "kanban:in_progress", "aliases": "", "kind": "flow",
"in_focus": 1},
{"key": "qa", "name": "🧪 QA", "color": "#06b6d4",
"label": "kanban:qa", "aliases": "", "kind": "flow",
"in_focus": 1},
{"key": "review", "name": "🔍 Révision", "color": "#8b5cf6",
"label": "kanban:review", "aliases": "", "kind": "flow",
"in_focus": 1},
{"key": "done", "name": "✅ Terminé", "color": "#10b981",
"label": "", "aliases": "", "kind": "done", "in_focus": 0},
]
_cache: dict[int, list[dict]] = {}
def invalidate(workspace_id: int | None = None) -> None:
if workspace_id is None:
_cache.clear()
else:
_cache.pop(workspace_id, None)
def ensure_columns(workspace_id: int) -> list[dict]:
"""Seed the historical defaults if the workspace has none."""
existing = models.list_board_columns(workspace_id)
if existing:
return existing
for position, column in enumerate(DEFAULT_COLUMNS):
models.upsert_board_column(
workspace_id, {**column, "position": position}
)
invalidate(workspace_id)
return models.list_board_columns(workspace_id)
def get_columns(workspace_id: int = 1) -> list[dict]:
"""Effective columns of a workspace (cached, seeded lazily)."""
if workspace_id not in _cache:
_cache[workspace_id] = ensure_columns(workspace_id)
return _cache[workspace_id]
def keys(workspace_id: int = 1) -> list[str]:
return [c["key"] for c in get_columns(workspace_id)]
def names(workspace_id: int = 1) -> dict[str, str]:
return {c["key"]: c["name"] for c in get_columns(workspace_id)}
def colors(workspace_id: int = 1) -> dict[str, str]:
return {c["key"]: c["color"] for c in get_columns(workspace_id)}
def flow_keys(workspace_id: int = 1) -> list[str]:
return [
c["key"] for c in get_columns(workspace_id) if c["kind"] == "flow"
]
def focus_keys(workspace_id: int = 1) -> list[str]:
return [
c["key"] for c in get_columns(workspace_id) if c.get("in_focus")
]
def wall_keys(workspace_id: int = 1) -> list[str]:
"""Wallboard cards: focus columns + the first flow column."""
columns = get_columns(workspace_id)
wanted = {c["key"] for c in columns if c.get("in_focus")}
flow = [c["key"] for c in columns if c["kind"] == "flow"]
if flow:
wanted.add(flow[0])
return [c["key"] for c in columns if c["key"] in wanted]
def label_for(workspace_id: int, key: str) -> str | None:
"""The forge label mirrored when a card enters this column."""
for column in get_columns(workspace_id):
if column["key"] == key and column["kind"] == "flow":
return column.get("label") or None
return None
def label_lookup(workspace_id: int = 1) -> dict[str, str]:
"""label name (lowercased) → column key, aliases included.
This is the configurable label/column mapping: the canonical
label of each flow column plus every alias declared for it
(e.g. Flowdeck's ``kanban_a_faire`` on the todo column).
"""
lookup: dict[str, str] = {}
for column in get_columns(workspace_id):
if column["kind"] != "flow":
continue
names = [column.get("label") or ""]
names += [
a.strip() for a in (column.get("aliases") or "").split(",")
]
for name in names:
name = name.strip().lower()
if name:
lookup[name] = column["key"]
return lookup
def recognized_labels(workspace_id: int = 1) -> set[str]:
"""Every label name (lowercased) owned by the mapping."""
return set(label_lookup(workspace_id))
+17 -1
View File
@@ -96,6 +96,15 @@ Environment variables:
KANBANTE_ZOMBIE_DAYS Days without any activity after KANBANTE_ZOMBIE_DAYS Days without any activity after
which an open card is a "zombie" which an open card is a "zombie"
on /health (issue #9). Default 30. 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 import os
@@ -104,7 +113,7 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.27.0" APP_VERSION = "0.31.1"
def _env_bool(name: str, default: bool) -> bool: def _env_bool(name: str, default: bool) -> bool:
@@ -180,6 +189,8 @@ class Settings:
sso_machine_key: str = "" sso_machine_key: str = ""
sso_url: str = "https://og.dracodev.net" sso_url: str = "https://og.dracodev.net"
archive_suggest_days: int = 180 archive_suggest_days: int = 180
agent_evenements_url: str = "https://agent.dracodev.net/api/v1/evenements"
agent_evenements_cle: str = ""
@property @property
def auth_enabled(self) -> bool: def auth_enabled(self) -> bool:
@@ -238,6 +249,11 @@ def _env_settings() -> Settings:
sso_machine_key=os.getenv("DRACO_SSO_MACHINE_KEY", ""), sso_machine_key=os.getenv("DRACO_SSO_MACHINE_KEY", ""),
sso_url=os.getenv("DRACO_SSO_URL", "https://og.dracodev.net").rstrip("/"), sso_url=os.getenv("DRACO_SSO_URL", "https://og.dracodev.net").rstrip("/"),
archive_suggest_days=_env_int("KANBANTE_ARCHIVE_SUGGEST_DAYS", 180), 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", ""),
) )
+1 -1
View File
@@ -143,7 +143,7 @@ async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None:
{ {
"protocolVersion": MCP_PROTOCOL_VERSION, "protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {}, "capabilities": {},
"clientInfo": {"name": "kanbante", "version": "0.24.0"}, "clientInfo": {"name": "kanbante", "version": "0.31.1"},
}, },
req_id=1, req_id=1,
) )
+82
View File
@@ -48,6 +48,7 @@ class GiteaClient:
""" """
kind = "gitea" kind = "gitea"
supports_attachments = True
api_suffix = "/api/v1" api_suffix = "/api/v1"
auth_scheme = "token" auth_scheme = "token"
@@ -503,6 +504,87 @@ class GiteaClient:
self._cache_clear() self._cache_clear()
return r.json() return r.json()
async def update_issue(
self,
owner: str,
repo: str,
number: int,
title: str | None = None,
body: str | None = None,
) -> dict:
"""Edit an issue's title and/or body (0.31.0)."""
payload: dict = {}
if title is not None:
payload["title"] = title
if body is not None:
payload["body"] = body
if not payload:
return await self.get_issue(owner, repo, number)
r = await self.client.patch(
f"/repos/{owner}/{repo}/issues/{number}", json=payload
)
r.raise_for_status()
self._cache_clear()
return r.json()
# ── Pièces jointes des issues (0.31.0) ────────────────
async def list_issue_assets(
self, owner: str, repo: str, number: int
) -> list[dict]:
"""Attachments of an issue (Gitea assets)."""
return await self._get(
f"/repos/{owner}/{repo}/issues/{number}/assets"
)
async def upload_issue_asset(
self,
owner: str,
repo: str,
number: int,
filename: str,
content: bytes,
content_type: str = "application/octet-stream",
) -> dict:
"""Attach a file to an issue; returns the asset record."""
r = await self.client.post(
f"/repos/{owner}/{repo}/issues/{number}/assets",
files={"attachment": (filename, content, content_type)},
)
r.raise_for_status()
return r.json()
async def delete_issue_asset(
self, owner: str, repo: str, number: int, asset_id: int
) -> None:
r = await self.client.delete(
f"/repos/{owner}/{repo}/issues/{number}/assets/{asset_id}"
)
r.raise_for_status()
async def download_issue_asset(
self, owner: str, repo: str, number: int, asset_id: int
) -> tuple[bytes, str]:
"""(content, content_type) of one attachment.
Gitea nuance (vérifiée en réel sur 1.27.3): GET on the
asset endpoint returns its JSON record, NOT the bytes —
the file itself lives at `browser_download_url`
(/attachments/<uuid>), fetched here with the connection's
token so private repos work too.
"""
record = await self._get(
f"/repos/{owner}/{repo}/issues/{number}/assets/{asset_id}"
)
url = record.get("browser_download_url") if isinstance(record, dict) else None
if not url:
raise ValueError("La forge ne fournit pas d'URL de téléchargement")
r = await self.client.get(url)
r.raise_for_status()
return r.content, r.headers.get(
"content-type", "application/octet-stream"
)
async def set_issue_state( async def set_issue_state(
self, owner: str, repo: str, issue_number: int, state: str self, owner: str, repo: str, issue_number: int, state: str
) -> dict: ) -> dict:
+4
View File
@@ -27,6 +27,10 @@ _LINK_NEXT = re.compile(r'<([^>]+)>;\s*rel="next"')
class GitHubClient(GiteaClient): class GitHubClient(GiteaClient):
kind = "github" kind = "github"
# L'API GitHub ne sait pas joindre de fichiers aux issues
# (réservé à son interface web) : les routes Kanbante
# répondent proprement « non pris en charge » (0.31.0).
supports_attachments = False
api_suffix = "" api_suffix = ""
auth_scheme = "Bearer" auth_scheme = "Bearer"
+647 -73
View File
File diff suppressed because it is too large Load Diff
+137 -7
View File
@@ -154,6 +154,21 @@ def init_db() -> None:
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );
-- ── Colonnes personnalisables par espace (0.30.0) ──
CREATE TABLE IF NOT EXISTS board_columns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER NOT NULL,
key TEXT NOT NULL,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#6b7280',
position INTEGER NOT NULL DEFAULT 0,
label TEXT NOT NULL DEFAULT '',
aliases TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL DEFAULT 'flow',
in_focus INTEGER NOT NULL DEFAULT 0,
UNIQUE(workspace_id, key)
);
CREATE TABLE IF NOT EXISTS workspace_connections ( CREATE TABLE IF NOT EXISTS workspace_connections (
workspace_id INTEGER NOT NULL, workspace_id INTEGER NOT NULL,
connection_id INTEGER NOT NULL, connection_id INTEGER NOT NULL,
@@ -219,7 +234,10 @@ def _migrate(conn: sqlite3.Connection) -> None:
# get_board_state() with a KeyError. Drop them once, here. # get_board_state() with a KeyError. Drop them once, here.
placeholders = ",".join("?" for _ in KANBAN_COLUMNS) placeholders = ",".join("?" for _ in KANBAN_COLUMNS)
conn.execute( conn.execute(
f"DELETE FROM board_state WHERE column_name NOT IN ({placeholders})", f"DELETE FROM board_state WHERE column_name NOT IN ({placeholders}) "
"AND NOT EXISTS (SELECT 1 FROM board_columns bc "
"WHERE bc.workspace_id = board_state.workspace_id "
"AND bc.key = board_state.column_name)",
KANBAN_COLUMNS, KANBAN_COLUMNS,
) )
@@ -340,7 +358,9 @@ def get_board_state(
(workspace_id, connection_id, repo_full_name), (workspace_id, connection_id, repo_full_name),
).fetchall() ).fetchall()
columns: dict[str, list[dict]] = {col: [] for col in KANBAN_COLUMNS} columns: dict[str, list[dict]] = {
col: [] for col in column_keys(workspace_id)
}
for row in rows: for row in rows:
column = row["column_name"] column = row["column_name"]
if column not in columns: if column not in columns:
@@ -364,7 +384,7 @@ def move_issue(
connection_id: int = 1, connection_id: int = 1,
) -> None: ) -> None:
"""Move an issue to a different column (appended at the end).""" """Move an issue to a different column (appended at the end)."""
if to_column not in KANBAN_COLUMNS: if to_column not in column_keys(workspace_id):
raise ValueError(f"Invalid column: {to_column}") raise ValueError(f"Invalid column: {to_column}")
with get_db() as conn: with get_db() as conn:
row = conn.execute( row = conn.execute(
@@ -400,7 +420,7 @@ def set_column_order(
its index in the list. Used after a drag & drop so manual ordering its index in the list. Used after a drag & drop so manual ordering
survives reloads. survives reloads.
""" """
if column_name not in KANBAN_COLUMNS: if column_name not in column_keys(workspace_id):
raise ValueError(f"Invalid column: {column_name}") raise ValueError(f"Invalid column: {column_name}")
with get_db() as conn: with get_db() as conn:
for index, issue_number in enumerate(ordered_issue_numbers): for index, issue_number in enumerate(ordered_issue_numbers):
@@ -583,7 +603,7 @@ def latest_snapshot_at() -> str | None:
# ── Event journal ────────────────────────────────────────── # ── Event journal ──────────────────────────────────────────
EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented", "rule") EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented", "rule", "edited")
def log_event( def log_event(
@@ -793,11 +813,10 @@ def get_stats() -> dict:
projects: dict[str, dict[str, int]] = {} projects: dict[str, dict[str, int]] = {}
for row in rows: for row in rows:
if row["column_name"] not in KANBAN_COLUMNS:
continue
name = row["repo_full_name"] name = row["repo_full_name"]
if name not in projects: if name not in projects:
projects[name] = {col: 0 for col in KANBAN_COLUMNS} projects[name] = {col: 0 for col in KANBAN_COLUMNS}
# Les colonnes personnalisées (0.30.0) s'ajoutent au vol.
projects[name][row["column_name"]] = row["cnt"] projects[name][row["column_name"]] = row["cnt"]
return projects return projects
@@ -1253,3 +1272,114 @@ def list_admin_audit(limit: int = 100) -> list[dict]:
(limit,), (limit,),
).fetchall() ).fetchall()
return [dict(r) for r in rows] return [dict(r) for r in rows]
# ── Colonnes de board personnalisées (0.30.0) ─────────────
# Définitions PAR ESPACE : clé immuable, nom/couleur libres,
# étiquette forge mappée (+ alias reconnus à l'adoption).
# Sans lignes pour un espace, column_keys retombe sur les
# défauts historiques — comportement 0.24 strictement conservé.
def list_board_columns(workspace_id: int) -> list[dict]:
with get_db() as conn:
rows = conn.execute(
"SELECT * FROM board_columns WHERE workspace_id = ? "
"ORDER BY position, id",
(workspace_id,),
).fetchall()
return [dict(r) for r in rows]
def column_keys(workspace_id: int = 1) -> list[str]:
"""Ordered column keys of a workspace (defaults if unset)."""
cols = list_board_columns(workspace_id)
if not cols:
return list(KANBAN_COLUMNS)
return [c["key"] for c in cols]
def upsert_board_column(workspace_id: int, column: dict) -> None:
with get_db() as conn:
conn.execute(
"""INSERT INTO board_columns
(workspace_id, key, name, color, position, label,
aliases, kind, in_focus)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(workspace_id, key) DO UPDATE SET
name = excluded.name, color = excluded.color,
position = excluded.position, label = excluded.label,
aliases = excluded.aliases, kind = excluded.kind,
in_focus = excluded.in_focus""",
(
workspace_id, column["key"], column["name"],
column.get("color", "#6b7280"),
int(column.get("position", 0)),
column.get("label", ""), column.get("aliases", ""),
column.get("kind", "flow"),
1 if column.get("in_focus") else 0,
),
)
def delete_board_column(workspace_id: int, key: str) -> None:
with get_db() as conn:
conn.execute(
"DELETE FROM board_columns WHERE workspace_id = ? AND key = ?",
(workspace_id, key),
)
def clear_board_columns(workspace_id: int) -> None:
with get_db() as conn:
conn.execute(
"DELETE FROM board_columns WHERE workspace_id = ?",
(workspace_id,),
)
def count_column_cards(workspace_id: int, key: str) -> int:
with get_db() as conn:
row = conn.execute(
"SELECT COUNT(*) AS n FROM board_state "
"WHERE workspace_id = ? AND column_name = ?",
(workspace_id, key),
).fetchone()
return int(row["n"]) if row else 0
def migrate_column_cards(
workspace_id: int, from_key: str, to_key: str
) -> int:
"""Move every card of a column to another (column deletion).
Positions are appended after the target column's current
maximum, per repository and connection.
"""
with get_db() as conn:
rows = conn.execute(
"SELECT connection_id, repo_full_name, issue_number "
"FROM board_state WHERE workspace_id = ? AND column_name = ? "
"ORDER BY position, issue_number",
(workspace_id, from_key),
).fetchall()
for row in rows:
nxt = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 AS p FROM board_state "
"WHERE workspace_id = ? AND connection_id = ? "
"AND repo_full_name = ? AND column_name = ?",
(
workspace_id, row["connection_id"],
row["repo_full_name"], to_key,
),
).fetchone()
conn.execute(
"UPDATE board_state SET column_name = ?, position = ?, "
"updated_at = datetime('now') WHERE workspace_id = ? "
"AND connection_id = ? AND repo_full_name = ? "
"AND issue_number = ?",
(
to_key, nxt["p"], workspace_id, row["connection_id"],
row["repo_full_name"], row["issue_number"],
),
)
return len(rows)
+4 -1
View File
@@ -20,6 +20,7 @@ rule can never close an issue or move a card between columns.
import json import json
from app import models
from app.models import KANBAN_COLUMNS from app.models import KANBAN_COLUMNS
CONDITION_KEYS = {"column", "min_age_days", "label", "wip_over"} CONDITION_KEYS = {"column", "min_age_days", "label", "wip_over"}
@@ -60,7 +61,9 @@ def parse_rules(raw: str) -> tuple[list[dict], list[str]]:
"(fermer/déplacer une carte par règle est interdit en v1)" "(fermer/déplacer une carte par règle est interdit en v1)"
) )
continue continue
if "column" in when and when["column"] not in KANBAN_COLUMNS: if "column" in when and when["column"] not in (
set(KANBAN_COLUMNS) | set(models.column_keys(1))
):
errors.append(f"{name} : colonne inconnue « {when['column']} »") errors.append(f"{name} : colonne inconnue « {when['column']} »")
continue continue
if "min_age_days" in when and ( if "min_age_days" in when and (
+1
View File
@@ -38,6 +38,7 @@
("admin", "/admin", "🛠️", "Vue d'ensemble"), ("admin", "/admin", "🛠️", "Vue d'ensemble"),
("admin_connections", "/admin/connections", "🔌", "Connexions"), ("admin_connections", "/admin/connections", "🔌", "Connexions"),
("admin_workspaces", "/admin/workspaces", "🧭", "Espaces"), ("admin_workspaces", "/admin/workspaces", "🧭", "Espaces"),
("admin_columns", "/admin/columns", "🧱", "Colonnes"),
("admin_settings", "/admin/settings", "🎛️", "Paramètres"), ("admin_settings", "/admin/settings", "🎛️", "Paramètres"),
]), ]),
] %} ] %}
+99
View File
@@ -0,0 +1,99 @@
<!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>Colonnes — Administration — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
<main class="focus-main">
<div class="page-head">
<h1>🧱 Colonnes du board</h1>
<p class="page-sub">Espace : <strong>{{ workspace.name }}</strong> (change d'espace par le sélecteur en haut pour régler un autre board). Les colonnes sont propres à chaque espace : nom, couleur, ordre et <strong>mapping étiquette ↔ colonne</strong>. La <strong>clé</strong> d'une colonne ne change jamais (les cartes y sont rattachées) ; le <strong>nom affiché</strong>, lui, est libre.</p>
</div>
{% if msg %}<p class="chip-ok">✅ {{ msg }}</p>{% endif %}
{% if err %}<p class="chip-warn">⚠️ {{ err }}</p>{% endif %}
<section class="briefing-card">
<h2>Comment marche le mapping</h2>
<p class="notes-hint">
<strong>Miroir</strong> : quand une carte entre dans une colonne, son <em>étiquette mappée</em> est posée sur l'issue de la forge (et les étiquettes mappées des autres colonnes sont retirées).<br>
<strong>Adoption</strong> : si une issue porte <em>exactement une</em> étiquette reconnue (étiquette mappée ou alias d'une colonne), le board adopte cette colonne — c'est ce qui permet à d'autres outils de piloter le board par étiquettes.<br>
<strong>Alias</strong> : étiquettes supplémentaires reconnues pour cette colonne, séparées par des virgules — par exemple les étiquettes Flowdeck <code>kanban_a_faire</code> sur la colonne À faire.<br>
<strong>Focus</strong> : les colonnes cochées « Focus » alimentent la vue Focus du jour et le wallboard.
</p>
</section>
{% for c in columns %}
<section class="briefing-card">
<h2>
<span class="ws-dot" style="background: {{ c.color }}"></span>
{{ c.name }}
<span class="muted"><code>{{ c.key }}</code> · {{ counts[c.key] }} carte(s) dans cet espace</span>
{% if c.kind == 'backlog' %}<span class="chip">système — défaut des cartes neuves</span>{% endif %}
{% if c.kind == 'done' %}<span class="chip">système — issues fermées</span>{% endif %}
</h2>
<form method="post" action="/admin/columns/{{ c.key }}" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Nom affiché <input type="text" name="name" value="{{ c.name }}"></label>
<label>Couleur <input type="color" name="color" value="{{ c.color }}"></label>
{% if c.kind == 'flow' %}
<label>Étiquette mappée <input type="text" name="label" value="{{ c.label }}" placeholder="kanban:{{ c.key }}"></label>
<label>Alias (virgules) <input type="text" name="aliases" value="{{ c.aliases }}" placeholder="kanban_a_faire, a-faire"></label>
<label><input type="checkbox" name="in_focus" value="1"{% if c.in_focus %} checked{% endif %}> Dans Focus / Wallboard</label>
{% endif %}
<button type="submit" class="btn-save btn-inline">Enregistrer</button>
</form>
<p class="admin-actions">
<form method="post" action="/admin/columns/{{ c.key }}/move" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="direction" value="up">
<button type="submit" class="btn-mini" title="Monter">⬆️</button>
</form>
<form method="post" action="/admin/columns/{{ c.key }}/move" class="inline-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="direction" value="down">
<button type="submit" class="btn-mini" title="Descendre">⬇️</button>
</form>
{% if c.kind == 'flow' %}
<form method="post" action="/admin/columns/{{ c.key }}/delete" class="inline-form" onsubmit="return confirm('Supprimer cette colonne ? Ses cartes iront dans la colonne choisie.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Déplacer ses cartes vers
<select name="target">
{% for other in columns %}{% if other.key != c.key %}<option value="{{ other.key }}">{{ other.name }}</option>{% endif %}{% endfor %}
</select>
</label>
<button type="submit" class="btn-mini btn-danger">Supprimer la colonne</button>
</form>
{% endif %}
</p>
</section>
{% endfor %}
<section class="briefing-card">
<h2>➕ Nouvelle colonne</h2>
<form method="post" action="/admin/columns" class="admin-form">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Nom affiché <input type="text" name="name" required placeholder="🛠️ En développement"></label>
<label>Couleur <input type="color" name="color" value="#7c5cfc"></label>
<label>Étiquette mappée <input type="text" name="label" placeholder="(défaut : kanban:<clé>)"></label>
<label>Alias (virgules) <input type="text" name="aliases" placeholder=""></label>
<button type="submit" class="btn-save btn-inline">Ajouter (avant Terminé)</button>
</form>
</section>
<section class="briefing-card">
<h2>↩️ Réinitialiser</h2>
<p class="notes-hint">Revenir aux six colonnes historiques de cet espace. Les cartes placées dans des colonnes personnalisées retomberont au Backlog à l'affichage.</p>
<form method="post" action="/admin/columns/reset" class="inline-form" onsubmit="return confirm('Réinitialiser les colonnes de cet espace aux valeurs par défaut ?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-mini btn-danger">Réinitialiser les colonnes</button>
</form>
</section>
</main>
</body>
</html>
+40 -7
View File
@@ -5,11 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}"> <meta name="csrf-token" content="{{ csrf_token }}">
<title>{{ repo }} — Kanbante</title> <title>{{ repo }} — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
<script src="/static/vendor/sortable.min.js"></script> <script src="/static/vendor/sortable.min.js"></script>
<script src="/static/vendor/marked.min.js"></script> <script src="/static/vendor/marked.min.js"></script>
<script src="/static/vendor/purify.min.js"></script> <script src="/static/vendor/purify.min.js"></script>
<script src="/static/js/board.js" defer></script> <script src="/static/js/board.js?v={{ version }}" defer></script>
</head> </head>
<body> <body>
{% include "_header.html" %} {% include "_header.html" %}
@@ -53,16 +53,31 @@
</select> </select>
<button type="button" id="labels-btn" class="btn-sm">🏷️ Gérer les étiquettes</button> <button type="button" id="labels-btn" class="btn-sm">🏷️ Gérer les étiquettes</button>
<a href="/triage/{{ full_name }}" class="btn-sm" style="text-decoration:none">📥 Triage du backlog</a> <a href="/triage/{{ full_name }}" class="btn-sm" style="text-decoration:none">📥 Triage du backlog</a>
<form id="create-issue-form"> {% if is_admin or workspace_role == 'admin' %}<a href="/admin/columns" class="btn-sm" style="text-decoration:none" title="Personnaliser les colonnes de cet espace">🧱 Colonnes</a>{% endif %}
<input type="text" id="new-issue-title" maxlength="255" <form id="create-issue-form" class="composer">
placeholder="➕ Nouvelle issue (titre)..." required> <div class="composer-head">
<button type="submit" class="btn-save btn-inline">Créer</button> <span class="composer-title">➕ Nouvelle issue</span>
<span class="composer-target">arrive dans
<strong>{{ column_labels.backlog }}</strong>
</span>
</div>
<div class="composer-main">
<input type="text" id="new-issue-title" maxlength="255"
placeholder="Titre de l'issue..." required>
<button type="submit" class="btn-save btn-inline">Créer l'issue</button>
</div>
<details class="composer-desc" id="new-issue-desc">
<summary><span class="composer-desc-label">Ajouter une description</span><span class="composer-chevron" aria-hidden="true">▾</span></summary>
<textarea id="new-issue-body" rows="5" maxlength="100000"
placeholder="Description (markdown)..."></textarea>
<p class="composer-hint">Markdown accepté · Ctrl+Entrée pour créer l'issue</p>
</details>
</form> </form>
</div> </div>
<main class="board-container"> <main class="board-container">
<div class="kanban-board"> <div class="kanban-board">
{% for col in ['backlog','todo','in_progress','qa','review','done'] %} {% for col in column_keys %}
<div class="kanban-column" <div class="kanban-column"
data-column="{{ col }}"> data-column="{{ col }}">
<div class="column-header" style="border-top: 3px solid {{ column_colors[col] }}"> <div class="column-header" style="border-top: 3px solid {{ column_colors[col] }}">
@@ -145,6 +160,24 @@
</div> </div>
<div id="detail-code" class="detail-code"></div> <div id="detail-code" class="detail-code"></div>
<div id="detail-body" class="detail-body"></div> <div id="detail-body" class="detail-body"></div>
<div class="detail-actions">
<button id="detail-edit-btn" class="btn-sm" type="button">✏️ Modifier le titre et la description</button>
</div>
<form id="detail-edit-form" hidden>
<input type="text" id="detail-title-input" maxlength="255" placeholder="Titre">
<textarea id="detail-body-input" rows="10" maxlength="100000"
placeholder="Description (markdown)..."></textarea>
<div class="detail-actions">
<button type="submit" class="btn-save btn-inline">💾 Enregistrer</button>
<button type="button" id="detail-edit-cancel" class="btn-sm">Annuler</button>
</div>
</form>
<h3 class="detail-comments-title">📎 Pièces jointes</h3>
<div id="detail-assets"></div>
<form id="asset-form" class="asset-form">
<input type="file" id="asset-file">
<button type="submit" class="btn-sm">📎 Joindre le fichier</button>
</form>
<h3 class="detail-comments-title">💬 Commentaires</h3> <h3 class="detail-comments-title">💬 Commentaires</h3>
<div id="detail-comments"></div> <div id="detail-comments"></div>
<form id="comment-form"> <form id="comment-form">
+1 -1
View File
@@ -146,7 +146,7 @@
{% endif %} {% endif %}
{% if full in stats %} {% if full in stats %}
<div class="mini-progress"> <div class="mini-progress">
{% for col in ['backlog','todo','in_progress','qa','review','done'] %} {% for col in column_keys %}
{% set n = stats[full].get(col, 0) %} {% set n = stats[full].get(col, 0) %}
{% if n > 0 %} {% if n > 0 %}
<span class="chip" style="background: {{ COLUMN_COLORS[col] }}20; color: {{ COLUMN_COLORS[col] }}"> <span class="chip" style="background: {{ COLUMN_COLORS[col] }}20; color: {{ COLUMN_COLORS[col] }}">
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "kanbante" name = "kanbante"
version = "0.27.0" version = "0.31.1"
description = "Kanban project manager integrated with Gitea" description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+31
View File
@@ -1683,3 +1683,34 @@ body.sidebar-collapsed .ws-switch { display: none; }
.conn-badge { font-size: 0.78em; padding: 1px 7px; border-radius: 999px; .conn-badge { font-size: 0.78em; padding: 1px 7px; border-radius: 999px;
border: 1px solid var(--border, #333); white-space: nowrap; } border: 1px solid var(--border, #333); white-space: nowrap; }
.seg-link { align-self: center; margin-left: 8px; font-size: 0.85em; text-decoration: none; } .seg-link { align-self: center; margin-left: 8px; font-size: 0.85em; text-decoration: none; }
/* ── Tiroir de détail : édition et pièces jointes (0.31.0) ── */
.kanban-card { cursor: pointer; }
.detail-actions { margin: 10px 0; }
#detail-edit-form input[type="text"] { width: 100%; margin-bottom: 8px; }
#detail-edit-form textarea { width: 100%; }
.asset-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--border, #333); }
.asset-row a { text-decoration: none; font-weight: 600; }
.asset-meta { color: var(--muted, #999); font-size: 0.85em; margin-right: auto; }
.asset-form { display: flex; gap: 8px; align-items: center; margin: 8px 0 4px; flex-wrap: wrap; }
/* ── Compositeur de nouvelle issue (0.31.1) ── */
/* La toolbar impose display:flex à ses formulaires : le
compositeur est une carte, en bloc et pleine largeur. */
.board-toolbar form.composer { display: block; flex: 1 1 100%; }
.composer { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; margin: 6px 0 10px; }
.composer-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; }
.composer-title { font-weight: 700; font-size: 0.95rem; }
.composer-target { color: var(--muted, #9aa3b5); font-size: 0.82rem; }
.composer-main { display: flex; gap: 8px; }
.composer-main input[type="text"] { flex: 1; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 9px 12px; color: inherit; font-size: 0.95rem; }
.composer-main input[type="text"]:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 92, 252, 0.25); }
.composer-desc { margin-top: 10px; }
.composer-desc summary { list-style: none; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; font-size: 0.88rem; color: var(--accent-2); padding: 4px 2px; user-select: none; }
.composer-desc summary::-webkit-details-marker { display: none; }
.composer-desc summary:hover .composer-desc-label { text-decoration: underline; }
.composer-chevron { transition: transform 0.15s ease; font-size: 0.75rem; }
.composer-desc[open ] .composer-chevron { transform: rotate(180deg); }
.composer-desc textarea { width: 100%; margin-top: 8px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; color: inherit; font-family: inherit; resize: vertical; }
.composer-desc textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 92, 252, 0.25); }
.composer-hint { color: var(--muted, #9aa3b5); font-size: 0.78rem; margin: 6px 0 0; }
+153 -6
View File
@@ -104,6 +104,10 @@ document.addEventListener('DOMContentLoaded', () => {
const formData = new FormData(); const formData = new FormData();
formData.append('repo_full_name', repoFullName); formData.append('repo_full_name', repoFullName);
formData.append('title', title); formData.append('title', title);
const bodyInput = document.getElementById('new-issue-body');
if (bodyInput && bodyInput.value.trim()) {
formData.append('body', bodyInput.value.trim());
}
formData.append('column', 'backlog'); formData.append('column', 'backlog');
formData.append('csrf_token', csrfToken); formData.append('csrf_token', csrfToken);
try { try {
@@ -118,6 +122,13 @@ document.addEventListener('DOMContentLoaded', () => {
} }
}); });
createForm?.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
createForm.requestSubmit();
}
});
// The "Glissez une carte ici" hint is rendered by the server only // The "Glissez une carte ici" hint is rendered by the server only
// for columns empty at page load. After a drag, the DOM is the // for columns empty at page load. After a drag, the DOM is the
// truth: remove the hint from columns that hold cards, restore it // truth: remove the hint from columns that hold cards, restore it
@@ -323,14 +334,17 @@ document.addEventListener('DOMContentLoaded', () => {
// ── Card detail drawer ───────────────────────────────── // ── Card detail drawer ─────────────────────────────────
// A click opens the panel; a drag must not. Track pointer travel. // A click opens the panel; a drag must not. Track pointer travel.
// DELEGATED on purpose (0.31.0): the live board swaps card
// fragments in place — listeners attached per card died at the
// first refresh, which made cards "hard to click". One listener
// on the document keeps working whatever the DOM becomes.
let downX = 0, downY = 0; let downX = 0, downY = 0;
document.addEventListener('pointerdown', (e) => { downX = e.clientX; downY = e.clientY; }); document.addEventListener('pointerdown', (e) => { downX = e.clientX; downY = e.clientY; });
document.querySelectorAll('.kanban-card').forEach(card => { document.addEventListener('click', (e) => {
card.addEventListener('click', (e) => { if (Math.hypot(e.clientX - downX, e.clientY - downY) > 6) return;
if (Math.hypot(e.clientX - downX, e.clientY - downY) > 6) return; if (e.target.closest('a') || e.target.closest('button')) return;
if (e.target.closest('a')) return; const card = e.target.closest('.kanban-card');
openDetail(card.dataset.repo, card.dataset.issue); if (card) openDetail(card.dataset.repo, card.dataset.issue);
});
}); });
const drawer = document.getElementById('card-detail'); const drawer = document.getElementById('card-detail');
@@ -355,6 +369,9 @@ document.addEventListener('DOMContentLoaded', () => {
overlay?.addEventListener('click', closeDetail); overlay?.addEventListener('click', closeDetail);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeDetail(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeDetail(); });
let currentComments = [];
let currentIssueData = null;
async function openDetail(repoFull, number) { async function openDetail(repoFull, number) {
const [owner, repo] = repoFull.split('/', 2); const [owner, repo] = repoFull.split('/', 2);
currentIssue = { repo: repoFull, number }; currentIssue = { repo: repoFull, number };
@@ -368,6 +385,8 @@ document.addEventListener('DOMContentLoaded', () => {
const r = await fetch(`/api/issues/${owner}/${repo}/${number}`); const r = await fetch(`/api/issues/${owner}/${repo}/${number}`);
const data = await r.json().catch(() => ({})); const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status)); if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
currentComments = data.comments || [];
currentIssueData = data.issue;
fillDetail(data.issue, data.comments); fillDetail(data.issue, data.comments);
loadCodeLinks(repoFull, number); loadCodeLinks(repoFull, number);
} catch (err) { } catch (err) {
@@ -432,6 +451,7 @@ document.addEventListener('DOMContentLoaded', () => {
} }
function fillDetail(issue, comments) { function fillDetail(issue, comments) {
currentIssueData = issue;
document.getElementById('detail-title').textContent = issue.title; document.getElementById('detail-title').textContent = issue.title;
const stateChip = document.getElementById('detail-state'); const stateChip = document.getElementById('detail-state');
stateChip.textContent = issue.state === 'closed' ? '✅ Fermée' : '🟢 Ouverte'; stateChip.textContent = issue.state === 'closed' ? '✅ Fermée' : '🟢 Ouverte';
@@ -457,6 +477,8 @@ document.addEventListener('DOMContentLoaded', () => {
renderLabelToggles(issue); renderLabelToggles(issue);
document.getElementById('detail-body').innerHTML = document.getElementById('detail-body').innerHTML =
issue.body ? renderMarkdown(issue.body) : '<p class="detail-empty">Aucune description.</p>'; issue.body ? renderMarkdown(issue.body) : '<p class="detail-empty">Aucune description.</p>';
renderAssets(issue);
setEditMode(false);
document.getElementById('detail-gitea-link').href = issue.html_url || '#'; document.getElementById('detail-gitea-link').href = issue.html_url || '#';
const box = document.getElementById('detail-comments'); const box = document.getElementById('detail-comments');
box.innerHTML = ''; box.innerHTML = '';
@@ -520,6 +542,131 @@ document.addEventListener('DOMContentLoaded', () => {
} }
} }
function setEditMode(on) {
const form = document.getElementById('detail-edit-form');
const btn = document.getElementById('detail-edit-btn');
const title = document.getElementById('detail-title');
const body = document.getElementById('detail-body');
if (!form || !btn) return;
form.hidden = !on;
btn.hidden = on;
if (title) title.hidden = on;
if (body) body.hidden = on;
if (on && currentIssueData) {
document.getElementById('detail-title-input').value = currentIssueData.title || '';
document.getElementById('detail-body-input').value = currentIssueData.body || '';
}
}
document.getElementById('detail-edit-btn')?.addEventListener('click', () => setEditMode(true));
document.getElementById('detail-edit-cancel')?.addEventListener('click', () => setEditMode(false));
document.getElementById('detail-edit-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentIssue) return;
const title = document.getElementById('detail-title-input').value.trim();
const body = document.getElementById('detail-body-input').value;
if (!title) { showToast('❌ Le titre ne peut pas être vide', true); return; }
const [owner, repo] = currentIssue.repo.split('/', 2);
const fd = new FormData();
fd.append('title', title);
fd.append('body', body);
fd.append('csrf_token', csrfToken);
try {
const r = await fetch(`/api/issues/${owner}/${repo}/${currentIssue.number}/update`,
{ method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
fillDetail(data.issue, currentComments);
const card = document.querySelector(`.kanban-card[data-issue="${currentIssue.number}"]`);
if (card) {
const t = card.querySelector('.card-title');
if (t) t.textContent = data.issue.title;
card.dataset.title = (data.issue.title || '').toLowerCase();
}
showToast('✅ Issue mise à jour');
} catch (err) { showToast('❌ ' + err.message, true); }
});
function humanSize(bytes) {
if (!bytes) return '0 o';
const units = ['o', 'Kio', 'Mio', 'Gio'];
let i = 0, v = bytes;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return v.toFixed(v >= 10 || i === 0 ? 0 : 1) + ' ' + units[i];
}
function renderAssets(issue) {
const box = document.getElementById('detail-assets');
const form = document.getElementById('asset-form');
if (!box) return;
box.innerHTML = '';
if (!issue.attachments_supported) {
box.innerHTML = '<p class="detail-empty">Non pris en charge par l\'API de cette forge — joignez les fichiers depuis son interface web.</p>';
if (form) form.hidden = true;
return;
}
if (form) form.hidden = false;
const assets = issue.assets || [];
if (!assets.length) {
box.innerHTML = '<p class="detail-empty">Aucune pièce jointe.</p>';
return;
}
const [owner, repo] = (currentIssue?.repo || '/').split('/', 2);
assets.forEach(a => {
const row = document.createElement('div');
row.className = 'asset-row';
const link = document.createElement('a');
link.href = `/api/issues/${owner}/${repo}/${currentIssue.number}/assets/${a.id}/download`;
link.textContent = '📎 ' + a.name;
link.title = 'Télécharger';
row.appendChild(link);
const meta = document.createElement('span');
meta.className = 'asset-meta';
meta.textContent = humanSize(a.size) + (a.created_at ? ' · ' + String(a.created_at).slice(0, 10) : '');
row.appendChild(meta);
const del = document.createElement('button');
del.type = 'button';
del.className = 'btn-mini btn-danger';
del.textContent = 'Supprimer';
del.addEventListener('click', async () => {
if (!confirm('Supprimer la pièce jointe « ' + a.name + ' » ?')) return;
const fd = new FormData();
fd.append('csrf_token', csrfToken);
try {
const r = await fetch(`/api/issues/${owner}/${repo}/${currentIssue.number}/assets/${a.id}/delete`,
{ method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
showToast('✅ Pièce jointe supprimée');
openDetail(currentIssue.repo, currentIssue.number);
} catch (err) { showToast('❌ ' + err.message, true); }
});
row.appendChild(del);
box.appendChild(row);
});
}
document.getElementById('asset-form')?.addEventListener('submit', async (e) => {
e.preventDefault();
if (!currentIssue) return;
const input = document.getElementById('asset-file');
const file = input?.files?.[0];
if (!file) { showToast('❌ Choisis un fichier d\'abord', true); return; }
const [owner, repo] = currentIssue.repo.split('/', 2);
const fd = new FormData();
fd.append('attachment', file);
fd.append('csrf_token', csrfToken);
try {
const r = await fetch(`/api/issues/${owner}/${repo}/${currentIssue.number}/assets`,
{ method: 'POST', body: fd });
const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
input.value = '';
showToast('✅ Fichier joint : ' + data.asset.name);
openDetail(currentIssue.repo, currentIssue.number);
} catch (err) { showToast('❌ ' + err.message, true); }
});
function commentNode(c) { function commentNode(c) {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'comment'; div.className = 'comment';
+138 -1
View File
@@ -18,6 +18,16 @@
* Panneau v2 (Lot 1) : rendu Markdown, largeur réglable à la souris * Panneau v2 (Lot 1) : rendu Markdown, largeur réglable à la souris
* (persistée), thème clair/sombre aligné sur l'app hôte * (persistée), thème clair/sombre aligné sur l'app hôte
* (html[data-theme], repli prefers-color-scheme, suivi en direct). * (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 * États honnêtes : service injoignable / session non reconnue / LLM non
* configuré / exécuteur non configuré sont affichés tels quels. */ * configuré / exécuteur non configuré sont affichés tels quels. */
(function () { (function () {
@@ -44,6 +54,124 @@
catch (e) { return null; } 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 ─────────────────────────────── */ /* ── Thème : suivre l'app hôte ─────────────────────────────── */
function themeHote() { function themeHote() {
var t = document.documentElement.getAttribute("data-theme"); var t = document.documentElement.getAttribute("data-theme");
@@ -57,6 +185,7 @@
} }
function appliquerTheme() { function appliquerTheme() {
if (root) root.setAttribute("data-draco-theme", themeHote()); if (root) root.setAttribute("data-draco-theme", themeHote());
if (surEl) surEl.setAttribute("data-draco-theme", themeHote());
} }
function surveillerTheme() { function surveillerTheme() {
try { try {
@@ -122,6 +251,7 @@
' <span class="d-titre">Discussion principale</span>' + ' <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="liste">☰ Discussions</button>' +
' <button type="button" class="d-btn" data-disc="nouvelle">+ Nouvelle</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>' +
'<div class="draco-discussions" hidden></div>' + '<div class="draco-discussions" hidden></div>' +
'<div class="draco-panneau-outils">' + '<div class="draco-panneau-outils">' +
@@ -145,6 +275,7 @@
root.querySelector(".fermer").addEventListener("click", fermer); root.querySelector(".fermer").addEventListener("click", fermer);
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir); root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion); 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="aujourdhui"]').addEventListener("click", chargerAujourdhui);
root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations); root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations);
root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions); root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions);
@@ -155,7 +286,13 @@
appliquerLargeur(); appliquerLargeur();
window.addEventListener("resize", appliquerLargeur); window.addEventListener("resize", appliquerLargeur);
document.addEventListener("keydown", function (ev) { 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();
}); });
} }
+46
View File
@@ -187,6 +187,52 @@ class FakeGitea:
"404", request=request, response=_httpx.Response(404, request=request) "404", request=request, response=_httpx.Response(404, request=request)
) )
supports_attachments = True
async def update_issue(self, owner, repo, number, title=None, body=None):
issue = await self.get_issue(owner, repo, number)
if title is not None:
issue["title"] = title
if body is not None:
issue["body"] = body
return issue
# Pièces jointes en mémoire : {(repo, numéro): [assets]}
@property
def _assets(self):
return self.__dict__.setdefault("assets_store", {})
async def list_issue_assets(self, owner, repo, number):
return list(self._assets.get((f"{owner}/{repo}", number), []))
async def upload_issue_asset(
self, owner, repo, number, filename, content, content_type="application/octet-stream"
):
store = self._assets.setdefault((f"{owner}/{repo}", number), [])
asset = {
"id": 700 + len(store) + number,
"name": filename,
"size": len(content),
"download_count": 0,
"created_at": "2026-10-23T10:00:00Z",
"_content": content,
"_type": content_type,
}
store.append(asset)
return {k: v for k, v in asset.items() if not k.startswith("_")}
async def delete_issue_asset(self, owner, repo, number, asset_id):
store = self._assets.get((f"{owner}/{repo}", number), [])
self._assets[(f"{owner}/{repo}", number)] = [
a for a in store if a["id"] != asset_id
]
async def download_issue_asset(self, owner, repo, number, asset_id):
for asset in self._assets.get((f"{owner}/{repo}", number), []):
if asset["id"] == asset_id:
return asset["_content"], asset["_type"]
raise AssertionError("asset introuvable dans le faux")
async def get_issue_comments(self, owner, repo, number): async def get_issue_comments(self, owner, repo, number):
return list(self.comments.get((f"{owner}/{repo}", number), [])) return list(self.comments.get((f"{owner}/{repo}", number), []))
+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()
+277
View File
@@ -0,0 +1,277 @@
"""Palier 20 (0.30.0): colonnes personnalisables par
espace et mapping étiquette ↔ colonne configurable."""
import pytest
from fastapi.testclient import TestClient
import app.main as main
from app import board_columns as bcols
from app import models
from tests.conftest import csrf_from
@pytest.fixture
def auth_client(tmp_path, monkeypatch, fake_gitea):
monkeypatch.setenv("KANBANTE_DB_PATH", str(tmp_path / "cols.db"))
monkeypatch.setenv("KANBANTE_AUTH_USER", "bruno")
monkeypatch.setenv("KANBANTE_AUTH_PASSWORD", "motdepasse")
with TestClient(main.app) as client:
client.get("/login")
token = client.cookies.get("kanbante_csrf") or ""
client.post(
"/login",
data={
"username": "bruno",
"password": "motdepasse",
"next": "/",
"csrf_token": token,
},
follow_redirects=False,
)
yield client
@pytest.fixture
def ws_cols():
"""Un espace dédié rattaché à la connexion héritée."""
ws_id = models.create_workspace("cols-test", "ColonnesTest", "#123456")
models.set_workspace_connections(ws_id, [1])
bcols.invalidate(ws_id)
yield ws_id
models.clear_board_columns(ws_id)
bcols.invalidate(ws_id)
models.delete_workspace(ws_id)
def _issue(number, title, labels=None):
return {
"number": number,
"title": title,
"body": "",
"state": "open",
"labels": labels or [],
"assignee": None,
"assignees": [],
"milestone": None,
"due_date": None,
"created_at": "2026-09-01T09:00:00Z",
"updated_at": "2026-09-02T09:00:00Z",
"html_url": f"http://gitea.test/Projets/demo/issues/{number}",
"pull_request": None,
}
# ── Défauts et service ────────────────────────────────────
def test_defaults_match_historical_columns(ws_cols):
columns = bcols.get_columns(ws_cols)
assert [c["key"] for c in columns] == [
"backlog", "todo", "in_progress", "qa", "review", "done",
]
assert bcols.names(ws_cols)["todo"] == "📋 À faire"
assert bcols.label_for(ws_cols, "review") == "kanban:review"
assert bcols.label_for(ws_cols, "backlog") is None
assert bcols.focus_keys(ws_cols) == ["in_progress", "qa", "review"]
assert bcols.wall_keys(ws_cols) == [
"todo", "in_progress", "qa", "review",
]
def test_custom_column_at_model_level(ws_cols):
columns = bcols.get_columns(ws_cols)
models.upsert_board_column(
ws_cols,
{
"key": "dev", "name": "🛠️ Dev", "color": "#123123",
"position": 4, "label": "etape:dev", "aliases": "",
"kind": "flow", "in_focus": 1,
},
)
bcols.invalidate(ws_cols)
assert "dev" in models.column_keys(ws_cols)
models.move_issue(
"Projets/demo", 5, "dev", workspace_id=ws_cols, connection_id=1
)
state = models.get_board_state(
"Projets/demo", workspace_id=ws_cols, connection_id=1
)
assert [c["issue_number"] for c in state["dev"]] == [5]
assert bcols.label_for(ws_cols, "dev") == "etape:dev"
assert "dev" in bcols.focus_keys(ws_cols)
# Un autre espace n'est pas affecté.
other = models.create_workspace("cols-temoin", "Témoin", "#000000")
try:
assert "dev" not in models.column_keys(other)
finally:
models.delete_workspace(other)
# ── Panneau d'administration ──────────────────────────────
def test_admin_columns_crud(auth_client, ws_cols):
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
page = auth_client.get("/admin/columns")
assert page.status_code == 200
assert "Colonnes du board" in page.text
token = csrf_from(auth_client)
r = auth_client.post(
"/admin/columns",
data={
"name": "🛠️ En développement",
"color": "#224466",
"label": "etape:dev",
"aliases": "en-dev",
"csrf_token": token,
},
follow_redirects=False,
)
assert r.status_code == 303
keys = bcols.keys(ws_cols)
assert "en_developpement" in keys
# Insérée avant « done ».
assert keys.index("en_developpement") == keys.index("done") - 1
# Renommage + mapping via le formulaire de la colonne.
token = csrf_from(auth_client)
auth_client.post(
"/admin/columns/en_developpement",
data={
"name": "🚀 Dev",
"color": "#224466",
"label": "etape:dev",
"aliases": "en-dev, dev",
"in_focus": "1",
"csrf_token": token,
},
follow_redirects=False,
)
assert bcols.names(ws_cols)["en_developpement"] == "🚀 Dev"
lookup = bcols.label_lookup(ws_cols)
assert lookup["etape:dev"] == "en_developpement"
assert lookup["en-dev"] == "en_developpement"
# Le board affiche la nouvelle colonne.
board = auth_client.get("/board/Projets/demo")
assert 'data-column="en_developpement"' in board.text
assert "🚀 Dev" in board.text
def test_admin_column_delete_migrates_cards(auth_client, ws_cols):
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
token = csrf_from(auth_client)
auth_client.post(
"/admin/columns",
data={
"name": "Attente client", "color": "#445566", "label": "",
"aliases": "", "csrf_token": token,
},
follow_redirects=False,
)
assert "attente_client" in bcols.keys(ws_cols)
models.move_issue(
"Projets/demo", 5, "attente_client",
workspace_id=ws_cols, connection_id=1,
)
token = csrf_from(auth_client)
r = auth_client.post(
"/admin/columns/attente_client/delete",
data={"target": "backlog", "csrf_token": token},
follow_redirects=False,
)
assert r.status_code == 303
assert "attente_client" not in bcols.keys(ws_cols)
state = models.get_board_state(
"Projets/demo", workspace_id=ws_cols, connection_id=1
)
assert any(c["issue_number"] == 5 for c in state["backlog"])
# Les colonnes système ne se suppriment pas.
token = csrf_from(auth_client)
auth_client.post(
"/admin/columns/backlog/delete",
data={"target": "todo", "csrf_token": token},
follow_redirects=False,
)
assert "backlog" in bcols.keys(ws_cols)
def test_admin_columns_reset(auth_client, ws_cols):
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
token = csrf_from(auth_client)
auth_client.post(
"/admin/columns",
data={
"name": "Temporaire", "color": "#445566", "label": "",
"aliases": "", "csrf_token": token,
},
follow_redirects=False,
)
assert "temporaire" in bcols.keys(ws_cols)
token = csrf_from(auth_client)
auth_client.post(
"/admin/columns/reset",
data={"csrf_token": token},
follow_redirects=False,
)
assert bcols.keys(ws_cols) == [
"backlog", "todo", "in_progress", "qa", "review", "done",
]
# ── Mapping : adoption par alias et miroir personnalisé ──
def test_label_alias_adoption(auth_client, ws_cols, fake_gitea):
"""L'étiquette Flowdeck kanban_a_faire, déclarée alias de la
colonne todo, fait adopter À faire par le board."""
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
token = csrf_from(auth_client)
auth_client.post(
"/admin/columns/todo",
data={
"name": "📋 À faire", "color": "#3b82f6",
"label": "kanban:todo", "aliases": "kanban_a_faire",
"csrf_token": token,
},
follow_redirects=False,
)
fake_gitea.issues["Projets/demo"].append(
_issue(42, "Carte Flowdeck", labels=[
{"id": 950, "name": "kanban_a_faire", "color": "aaaaaa"},
])
)
payload = auth_client.get("/api/board/Projets/demo").json()
assert "42" in payload["columns"]["todo"]["html"]
state = models.get_board_state(
"Projets/demo", workspace_id=ws_cols, connection_id=1
)
assert any(c["issue_number"] == 42 for c in state["todo"])
def test_custom_label_mirror_on_move(auth_client, ws_cols, fake_gitea):
"""Déplacer une carte pose l'étiquette mappée de SA colonne."""
auth_client.cookies.set("kanbante_workspace", str(ws_cols))
token = csrf_from(auth_client)
auth_client.post(
"/admin/columns",
data={
"name": "En développement", "color": "#224466",
"label": "etape:dev", "aliases": "", "csrf_token": token,
},
follow_redirects=False,
)
token = csrf_from(auth_client)
r = auth_client.post(
"/api/move",
data={
"repo_full_name": "Projets/demo",
"issue_number": "5",
"to_column": "en_developpement",
"csrf_token": token,
},
)
assert r.status_code == 200
issue = next(
i for i in fake_gitea.issues["Projets/demo"] if i["number"] == 5
)
names = [l["name"] for l in issue.get("labels") or []]
assert "etape:dev" in names
+136
View File
@@ -0,0 +1,136 @@
"""Palier 21 (0.31.0): description éditable, pièces jointes
d'issues, clic carte robuste (délégation côté JS)."""
from tests.conftest import csrf_from
def test_issue_update_title_and_body(client, fake_gitea):
token = csrf_from(client)
r = client.post(
"/api/issues/Projets/demo/1/update",
data={
"title": "Tâche un — précisée",
"body": "## Contexte\n\nLa description qui manquait.",
"csrf_token": token,
},
)
assert r.status_code == 200
payload = r.json()
assert payload["ok"] is True
assert payload["issue"]["body"].startswith("## Contexte")
issue = next(
i for i in fake_gitea.issues["Projets/demo"] if i["number"] == 1
)
assert issue["title"] == "Tâche un — précisée"
assert "description qui manquait" in issue["body"]
# Le journal consigne l'édition.
events = client.get("/api/events?repo=Projets/demo").json()["events"]
assert any(e["event_type"] == "edited" for e in events)
def test_issue_update_rejects_empty_title(client, fake_gitea):
token = csrf_from(client)
r = client.post(
"/api/issues/Projets/demo/1/update",
data={"title": " ", "body": "x", "csrf_token": token},
)
assert r.status_code == 400
def test_issue_detail_includes_assets_support(client, fake_gitea):
data = client.get("/api/issues/Projets/demo/1").json()
assert data["issue"]["attachments_supported"] is True
assert data["issue"]["assets"] == []
def test_asset_roundtrip(client, fake_gitea):
token = csrf_from(client)
r = client.post(
"/api/issues/Projets/demo/1/assets",
data={"csrf_token": token},
files={"attachment": ("notes.txt", b"bonjour kanbante", "text/plain")},
)
assert r.status_code == 201
asset = r.json()["asset"]
assert asset["name"] == "notes.txt"
assert asset["size"] == len(b"bonjour kanbante")
# Visible dans le détail.
data = client.get("/api/issues/Projets/demo/1").json()
assert [a["name"] for a in data["issue"]["assets"]] == ["notes.txt"]
# Téléchargement proxifié : contenu + disposition.
dl = client.get(
f"/api/issues/Projets/demo/1/assets/{asset['id']}/download"
)
assert dl.status_code == 200
assert dl.content == b"bonjour kanbante"
assert "notes.txt" in dl.headers["content-disposition"]
# Suppression.
token = csrf_from(client)
r = client.post(
f"/api/issues/Projets/demo/1/assets/{asset['id']}/delete",
data={"csrf_token": token},
)
assert r.status_code == 200
data = client.get("/api/issues/Projets/demo/1").json()
assert data["issue"]["assets"] == []
def test_asset_upload_rejects_empty_and_huge(client, fake_gitea):
token = csrf_from(client)
r = client.post(
"/api/issues/Projets/demo/1/assets",
data={"csrf_token": token},
files={"attachment": ("vide.txt", b"", "text/plain")},
)
assert r.status_code == 400
def test_board_page_has_editor_and_assets_ui(client, fake_gitea):
page = client.get("/board/Projets/demo")
assert page.status_code == 200
assert 'id="detail-edit-btn"' in page.text
assert 'id="detail-edit-form"' in page.text
assert 'id="detail-assets"' in page.text
assert 'id="asset-form"' in page.text
# Création avec description dépliable (0.31.1 : <details>
# natif plutôt qu'un bouton piloté en JS).
assert 'id="new-issue-body"' in page.text
assert 'id="new-issue-desc"' in page.text
assert 'class="composer"' in page.text
async def test_gitea_download_follows_browser_download_url():
"""Gitea renvoie la fiche JSON de l'asset, pas le binaire :
le téléchargement doit suivre browser_download_url (réel
1.27.3, attrapé au smoke de la 0.31.0)."""
import httpx
from app.gitea_client import GiteaClient
def handler(request):
url = str(request.url)
if url.endswith("/assets/42"):
return httpx.Response(200, json={
"id": 42, "name": "preuve.txt",
"browser_download_url": "https://gitea.test/attachments/uuid-1",
})
if url.endswith("/attachments/uuid-1"):
assert request.headers.get("authorization", "").startswith("token ")
return httpx.Response(
200, content=b"contenu reel",
headers={"content-type": "text/plain"},
)
return httpx.Response(404)
client = GiteaClient(
base_url="https://gitea.test", token="jeton" + "-factice",
cache_ttl=0, transport=httpx.MockTransport(handler),
)
content, content_type = await client.download_issue_asset(
"Projets", "demo", 1, 42
)
assert content == b"contenu reel"
assert content_type == "text/plain"