diff --git a/CHANGELOG.md b/CHANGELOG.md
index d4b07b4..d3ad534 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,43 @@
# Changelog
+## 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:`) 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
diff --git a/app/board_columns.py b/app/board_columns.py
new file mode 100644
index 0000000..c658fa0
--- /dev/null
+++ b/app/board_columns.py
@@ -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:``. 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))
diff --git a/app/config.py b/app/config.py
index f13ff15..20f4f52 100644
--- a/app/config.py
+++ b/app/config.py
@@ -113,7 +113,7 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
-APP_VERSION = "0.29.0"
+APP_VERSION = "0.30.0"
def _env_bool(name: str, default: bool) -> bool:
diff --git a/app/digest.py b/app/digest.py
index 17241e5..3f42977 100644
--- a/app/digest.py
+++ b/app/digest.py
@@ -143,7 +143,7 @@ async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None:
{
"protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {},
- "clientInfo": {"name": "kanbante", "version": "0.24.0"},
+ "clientInfo": {"name": "kanbante", "version": "0.30.0"},
},
req_id=1,
)
diff --git a/app/main.py b/app/main.py
index 9c0c99d..d4922dd 100644
--- a/app/main.py
+++ b/app/main.py
@@ -51,6 +51,7 @@ from app import forecast as forecast_service
from app import rules as rules_service
from app import sprint as sprint_service
from app import connections as connections_service
+from app import board_columns as bcols
from app.briefing import (
format_briefing_text,
format_standup_text,
@@ -59,6 +60,7 @@ from app.briefing import (
)
from app.capture import ntfy_capture_loop, parse_capture_text
from app import agent_journal
+from app import board_columns as bcols
from app import models
from app.models import (
KANBAN_COLUMNS,
@@ -814,7 +816,10 @@ async def archives_export(
]
for column in KANBAN_COLUMNS:
cards = board.get(column) or []
- lines.append(f"### {COLUMN_LABELS[column]} ({len(cards)})")
+ lines.append(
+ f"### {bcols.names(workspace['id']).get(column, column)} "
+ f"({len(cards)})"
+ )
for card in cards:
lines.append(f"- #{card['issue_number']}")
lines.append("")
@@ -853,6 +858,22 @@ async def archives_export(
WORKSPACE_COOKIE = "kanbante_workspace"
+def _ws_names(request: Request) -> dict[str, str]:
+ """Display names of the current workspace's columns (0.30.0)."""
+ workspace, _role = _current_workspace(request)
+ return bcols.names(workspace["id"] if workspace else 1)
+
+
+def _ws_colors(request: Request) -> dict[str, str]:
+ workspace, _role = _current_workspace(request)
+ return bcols.colors(workspace["id"] if workspace else 1)
+
+
+def _ws_keys(request: Request) -> list[str]:
+ workspace, _role = _current_workspace(request)
+ return bcols.keys(workspace["id"] if workspace else 1)
+
+
def _workspace_role_for(workspace_id: int, user: dict | None) -> str | None:
if user is None or user.get("id") is None:
return "admin" # mode env / auth désactivée : accès complet
@@ -1495,6 +1516,259 @@ async def admin_workspace_delete(
)
+# ── Administration : colonnes du board (0.30.0) ───────────
+
+def _column_key_from_name(name: str, taken: set[str]) -> str:
+ import unicodedata
+
+ folded = unicodedata.normalize("NFKD", name or "")
+ folded = folded.encode("ascii", "ignore").decode("ascii")
+ base = re.sub(r"[^a-z0-9]+", "_", folded.lower()).strip("_")
+ base = base or "colonne"
+ key, n = base, 2
+ while key in taken:
+ key = f"{base}_{n}"
+ n += 1
+ return key
+
+
+def _renumber_columns(workspace_id: int) -> None:
+ for position, column in enumerate(
+ models.list_board_columns(workspace_id)
+ ):
+ if column["position"] != position:
+ models.upsert_board_column(
+ workspace_id, {**column, "position": position}
+ )
+ bcols.invalidate(workspace_id)
+
+
+@app.get("/admin/columns")
+async def admin_columns(request: Request, msg: str = "", err: str = ""):
+ workspace, _role = _current_workspace(request)
+ if workspace is None or (
+ _can_admin_workspace(request, workspace["id"]) is None
+ ):
+ return _admin_denied(request)
+ columns = bcols.get_columns(workspace["id"])
+ counts = {
+ c["key"]: models.count_column_cards(workspace["id"], c["key"])
+ for c in columns
+ }
+ return render(
+ request, "admin_columns.html",
+ columns=columns, counts=counts, workspace=workspace,
+ msg=msg, err=err,
+ )
+
+
+@app.post("/admin/columns")
+async def admin_column_create(
+ request: Request,
+ name: str = Form(""),
+ color: str = Form("#7c5cfc"),
+ label: str = Form(""),
+ aliases: str = Form(""),
+ csrf_token: str = Form(""),
+):
+ workspace, _role = _current_workspace(request)
+ if workspace is None:
+ return _admin_denied(request)
+ admin = _can_admin_workspace(request, workspace["id"])
+ if admin is None:
+ return _admin_denied(request)
+ if not _csrf_valid(request, csrf_token):
+ return _csrf_error()
+ name = name.strip()
+ if not name:
+ return RedirectResponse(
+ "/admin/columns?err=Nom+requis", status_code=303
+ )
+ ws_id = workspace["id"]
+ columns = bcols.get_columns(ws_id)
+ key = _column_key_from_name(name, {c["key"] for c in columns})
+ # Insérée juste avant la colonne « done ».
+ done_pos = next(
+ (c["position"] for c in columns if c["kind"] == "done"),
+ len(columns),
+ )
+ for column in columns:
+ if column["position"] >= done_pos:
+ models.upsert_board_column(
+ ws_id, {**column, "position": column["position"] + 1}
+ )
+ models.upsert_board_column(
+ ws_id,
+ {
+ "key": key, "name": name,
+ "color": color if color.startswith("#") else "#7c5cfc",
+ "position": done_pos,
+ "label": label.strip() or f"kanban:{key}",
+ "aliases": aliases.strip(),
+ "kind": "flow", "in_focus": 1,
+ },
+ )
+ _renumber_columns(ws_id)
+ models.log_admin(
+ admin.get("id"), "column.create", f"{key} (espace {workspace['slug']})"
+ )
+ return RedirectResponse(
+ "/admin/columns?msg=Colonne+ajout%C3%A9e", status_code=303
+ )
+
+
+@app.post("/admin/columns/reset")
+async def admin_columns_reset(
+ request: Request, csrf_token: str = Form("")
+):
+ workspace, _role = _current_workspace(request)
+ if workspace is None:
+ return _admin_denied(request)
+ admin = _can_admin_workspace(request, workspace["id"])
+ if admin is None:
+ return _admin_denied(request)
+ if not _csrf_valid(request, csrf_token):
+ return _csrf_error()
+ models.clear_board_columns(workspace["id"])
+ bcols.invalidate(workspace["id"])
+ models.log_admin(
+ admin.get("id"), "column.reset", f"espace {workspace['slug']}"
+ )
+ return RedirectResponse(
+ "/admin/columns?msg=Colonnes+r%C3%A9initialis%C3%A9es+aux+valeurs+par+d%C3%A9faut",
+ status_code=303,
+ )
+
+
+@app.post("/admin/columns/{key}")
+async def admin_column_update(
+ request: Request,
+ key: str,
+ name: str = Form(""),
+ color: str = Form(""),
+ label: str = Form(""),
+ aliases: str = Form(""),
+ in_focus: str = Form(""),
+ csrf_token: str = Form(""),
+):
+ workspace, _role = _current_workspace(request)
+ if workspace is None:
+ return _admin_denied(request)
+ admin = _can_admin_workspace(request, workspace["id"])
+ if admin is None:
+ return _admin_denied(request)
+ if not _csrf_valid(request, csrf_token):
+ return _csrf_error()
+ ws_id = workspace["id"]
+ current = next(
+ (c for c in bcols.get_columns(ws_id) if c["key"] == key), None
+ )
+ if current is None:
+ return RedirectResponse(
+ "/admin/columns?err=Colonne+inconnue", status_code=303
+ )
+ updated = dict(current)
+ if name.strip():
+ updated["name"] = name.strip()
+ if color.startswith("#"):
+ updated["color"] = color
+ if current["kind"] == "flow":
+ updated["label"] = label.strip()
+ updated["aliases"] = aliases.strip()
+ updated["in_focus"] = 1 if in_focus else 0
+ models.upsert_board_column(ws_id, updated)
+ bcols.invalidate(ws_id)
+ models.log_admin(
+ admin.get("id"), "column.update", f"{key} (espace {workspace['slug']})"
+ )
+ return RedirectResponse(
+ "/admin/columns?msg=Colonne+mise+%C3%A0+jour", status_code=303
+ )
+
+
+@app.post("/admin/columns/{key}/move")
+async def admin_column_move(
+ request: Request,
+ key: str,
+ direction: str = Form("up"),
+ csrf_token: str = Form(""),
+):
+ workspace, _role = _current_workspace(request)
+ if workspace is None:
+ return _admin_denied(request)
+ admin = _can_admin_workspace(request, workspace["id"])
+ if admin is None:
+ return _admin_denied(request)
+ if not _csrf_valid(request, csrf_token):
+ return _csrf_error()
+ ws_id = workspace["id"]
+ columns = bcols.get_columns(ws_id)
+ index = next(
+ (i for i, c in enumerate(columns) if c["key"] == key), None
+ )
+ if index is None:
+ return RedirectResponse("/admin/columns", status_code=303)
+ swap = index - 1 if direction == "up" else index + 1
+ if 0 <= swap < len(columns):
+ other = columns[swap]
+ mine = columns[index]
+ models.upsert_board_column(
+ ws_id, {**mine, "position": other["position"]}
+ )
+ models.upsert_board_column(
+ ws_id, {**other, "position": mine["position"]}
+ )
+ _renumber_columns(ws_id)
+ models.log_admin(
+ admin.get("id"), "column.move",
+ f"{key} {direction} (espace {workspace['slug']})",
+ )
+ return RedirectResponse("/admin/columns", status_code=303)
+
+
+@app.post("/admin/columns/{key}/delete")
+async def admin_column_delete(
+ request: Request,
+ key: str,
+ target: str = Form(""),
+ csrf_token: str = Form(""),
+):
+ workspace, _role = _current_workspace(request)
+ if workspace is None:
+ return _admin_denied(request)
+ admin = _can_admin_workspace(request, workspace["id"])
+ if admin is None:
+ return _admin_denied(request)
+ if not _csrf_valid(request, csrf_token):
+ return _csrf_error()
+ ws_id = workspace["id"]
+ columns = bcols.get_columns(ws_id)
+ current = next((c for c in columns if c["key"] == key), None)
+ if current is None or current["kind"] != "flow":
+ return RedirectResponse(
+ "/admin/columns?err=Colonne+syst%C3%A8me+non+supprimable",
+ status_code=303,
+ )
+ if target not in {c["key"] for c in columns} or target == key:
+ return RedirectResponse(
+ "/admin/columns?err=Choisis+une+colonne+de+destination",
+ status_code=303,
+ )
+ moved = models.migrate_column_cards(ws_id, key, target)
+ models.delete_board_column(ws_id, key)
+ _renumber_columns(ws_id)
+ models.log_admin(
+ admin.get("id"), "column.delete",
+ f"{key} -> {target} ({moved} cartes, espace {workspace['slug']})",
+ )
+ from urllib.parse import quote as _q
+
+ return RedirectResponse(
+ f"/admin/columns?msg={_q(f'Colonne supprimée, {moved} carte(s) déplacée(s)')}",
+ status_code=303,
+ )
+
+
@app.get("/admin/settings")
async def admin_settings(request: Request, msg: str = "", err: str = ""):
admin = _require_admin(request)
@@ -1849,6 +2123,7 @@ _NAV_BY_TEMPLATE = {
"admin.html": "admin",
"admin_connections.html": "admin_connections",
"admin_workspaces.html": "admin_workspaces",
+ "admin_columns.html": "admin_columns",
"admin_settings.html": "admin_settings",
"archives.html": "archives",
}
@@ -2003,7 +2278,9 @@ def _is_issue(item: dict) -> bool:
def _merge_issues_with_board(
- issues: list[dict], board: dict[str, list[dict]]
+ issues: list[dict],
+ board: dict[str, list[dict]],
+ column_keys: list[str] | None = None,
) -> dict[str, list[dict]]:
"""Merge Gitea issues into kanban columns, honouring saved order."""
placement: dict[int, tuple[str, float]] = {}
@@ -2011,9 +2288,13 @@ def _merge_issues_with_board(
for card in cards:
placement[card["issue_number"]] = (col_name, card["position"])
- columns: dict[str, list[dict]] = {col: [] for col in KANBAN_COLUMNS}
+ columns: dict[str, list[dict]] = {
+ col: [] for col in (column_keys or KANBAN_COLUMNS)
+ }
for index, issue in enumerate(issues):
col, pos = placement.get(issue["number"], ("backlog", None))
+ if col not in columns:
+ col, pos = "backlog", None
issue["_sort"] = (0, pos, index) if pos is not None else (1, 0.0, index)
columns[col].append(issue)
for col in columns.values():
@@ -2075,16 +2356,15 @@ def _adopt_label_columns(
for col_name, cards in board_state.items():
for card in cards:
placement[card["issue_number"]] = col_name
+ lookup = bcols.label_lookup(workspace_id)
for issue in open_issues:
names = [
str(label.get("name", "")) for label in issue.get("labels") or []
]
- kanban = [n for n in names if n.startswith("kanban:")]
- if len(kanban) != 1:
- continue
- target = kanban[0].split(":", 1)[1]
- if target not in LABELLED_COLUMNS:
+ recognized = [lookup[n.lower()] for n in names if n.lower() in lookup]
+ if len(recognized) != 1:
continue
+ target = recognized[0]
current = placement.get(issue["number"], "backlog")
if current != target:
move_issue(
@@ -2288,7 +2568,7 @@ def _health_dot(health: dict) -> dict:
parts.append(f"{len(zombies)} zombie(s)")
if slowest:
parts.append(
- f"goulot : {COLUMN_LABELS[slowest['column']]}"
+ f"goulot : {bcols.names(health.get('_ws_id', 1)).get(slowest['column'], slowest['column'])}"
f" (médiane {median} j)"
)
return {
@@ -2382,8 +2662,9 @@ async def dashboard(request: Request):
inactive_listed=[r for r in inactive if not r.get("starred")],
pinned_repos=pinned,
stats=get_stats(),
- COLUMN_LABELS=COLUMN_LABELS,
- COLUMN_COLORS=COLUMN_COLORS,
+ COLUMN_LABELS=bcols.names(ws_id),
+ COLUMN_COLORS=bcols.colors(ws_id),
+ column_keys=bcols.keys(ws_id),
active_window_days=get_settings().active_window_days,
metrics=get_metrics(window_days=30),
multi_conn=len(models.workspace_connection_ids(ws_id)) > 1,
@@ -2516,7 +2797,9 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""):
board_state = get_board_state(
full_name, workspace_id=ws_id, connection_id=conn_id
)
- columns = _merge_issues_with_board(display_issues, board_state)
+ columns = _merge_issues_with_board(
+ display_issues, board_state, bcols.keys(ws_id)
+ )
await _annotate_code(owner, repo, columns, client)
labels: dict[str, dict] = {}
@@ -2538,8 +2821,9 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""):
repo=repo,
full_name=full_name,
columns=columns,
- column_labels=COLUMN_LABELS,
- column_colors=COLUMN_COLORS,
+ column_keys=bcols.keys(ws_id),
+ column_labels=bcols.names(ws_id),
+ column_colors=bcols.colors(ws_id),
labels=sorted(labels.values(), key=lambda l: l["name"].lower()),
notes=get_notes(
full_name, workspace_id=ws_id, connection_id=conn_id
@@ -2613,7 +2897,9 @@ def _board_columns_for(
)
display_issues = [i for i in display_issues if _in_ms(i)]
- columns = _merge_issues_with_board(display_issues, board_state)
+ columns = _merge_issues_with_board(
+ display_issues, board_state, bcols.keys(workspace_id)
+ )
return columns, display_issues, board_state
@@ -2642,7 +2928,7 @@ async def api_board(
)
card_template = _tpl_env.get_template("_card.html")
payload: dict[str, dict] = {}
- for col in KANBAN_COLUMNS:
+ for col in bcols.keys(ws_id):
cards = columns[col]
if cards:
html = "".join(
@@ -2738,10 +3024,11 @@ async def _collect_focus_groups(
get_board_state(
full_name, workspace_id=workspace_id, connection_id=cid
),
+ bcols.keys(workspace_id),
)
cards = [
{"issue": issue, "column": col}
- for col in FOCUS_COLUMNS
+ for col in bcols.focus_keys(workspace_id)
for issue in columns[col]
]
if cards:
@@ -2861,8 +3148,8 @@ async def project_page(request: Request, owner: str, repo: str):
events=events,
notes=get_notes(full_name, workspace_id=ws_id, connection_id=conn_id),
EVENT_ICONS=EVENT_ICONS,
- COLUMN_LABELS=COLUMN_LABELS,
- COLUMN_COLORS=COLUMN_COLORS,
+ COLUMN_LABELS=bcols.names(ws_id),
+ COLUMN_COLORS=bcols.colors(ws_id),
)
@@ -2932,7 +3219,7 @@ async def triage_session(request: Request, owner: str, repo: str):
full_name=full_name,
queue=queue,
queue_json=json.dumps(queue, ensure_ascii=False),
- column_labels=COLUMN_LABELS,
+ column_labels=bcols.names(ws_id),
)
@@ -2969,7 +3256,7 @@ async def _enrich_pick(
"title": issue.get("title", ""),
"state": issue.get("state", "open"),
"column": column,
- "column_label": COLUMN_LABELS[column],
+ "column_label": bcols.names(workspace_id).get(column, column),
"html_url": issue.get("html_url", ""),
}
@@ -3076,7 +3363,9 @@ async def today_page(request: Request):
"repo": group["full_name"],
"number": card["issue"]["number"],
"title": card["issue"].get("title", ""),
- "column_label": COLUMN_LABELS[card["column"]],
+ "column_label": bcols.names(ws_id).get(
+ card["column"], card["column"]
+ ),
}
)
candidates = candidates[:8]
@@ -3096,12 +3385,12 @@ async def today_page(request: Request):
carry_over=carry_over,
candidates=candidates,
recap=recap,
- column_labels=COLUMN_LABELS,
+ column_labels=bcols.names(ws_id),
)
async def _sprint_markdown(
- owner: str, repo: str, milestone: str, client=None
+ owner: str, repo: str, milestone: str, client=None, workspace_id: int = 1
):
"""Assemble the sprint story; (title, markdown) or None."""
full_name = f"{owner}/{repo}"
@@ -3188,7 +3477,7 @@ async def _sprint_markdown(
{
"number": issue["number"],
"title": issue.get("title", ""),
- "column_label": COLUMN_LABELS[col],
+ "column_label": bcols.names(workspace_id).get(col, col),
}
)
delivered.sort(key=lambda d: d["number"])
@@ -3233,7 +3522,9 @@ async def sprint_page(request: Request, owner: str, repo: str, milestone: str =
full_name = f"{owner}/{repo}"
client, conn_id, ws_id = await _resolve_board_scope(request, owner, repo)
result = (
- await _sprint_markdown(owner, repo, milestone, client)
+ await _sprint_markdown(
+ owner, repo, milestone, client, workspace_id=ws_id
+ )
if milestone
else None
)
@@ -3290,7 +3581,7 @@ async def _apply_rule_actions(
text = (
f"⚙️ Règle « {rule['name']} »\n"
f"{full_name} #{number} « {issue.get('title', '')} » — "
- f"{COLUMN_LABELS[column]}{age_txt}"
+ f"{bcols.names(1).get(column, column)}{age_txt}"
)
if await send_ntfy(get_settings(), text):
set_kv(guard, "1")
@@ -3447,20 +3738,23 @@ async def _project_health(
"number": number,
"title": issue.get("title", ""),
"column": column,
- "column_label": COLUMN_LABELS[column],
+ "column_label": bcols.names(workspace_id).get(
+ column, column
+ ),
"days_inactive": days,
"html_url": issue.get("html_url", ""),
}
)
zombies.sort(key=lambda z: -z["days_inactive"])
return {
+ "_ws_id": workspace_id,
"full_name": full_name,
"dwell": dwell,
"slowest": slowest,
"zombies": zombies,
"zombie_days": settings.zombie_days,
"open_count": len(display_issues),
- "column_labels": COLUMN_LABELS,
+ "column_labels": bcols.names(workspace_id),
}
@@ -3696,8 +3990,8 @@ async def focus(request: Request):
request,
"focus.html",
groups=await _collect_focus_groups(ws_repos, ws_id),
- column_labels=COLUMN_LABELS,
- column_colors=COLUMN_COLORS,
+ column_labels=bcols.names(ws_id),
+ column_colors=bcols.colors(ws_id),
)
@@ -3789,14 +4083,16 @@ async def _collect_wall_data(
board_state = get_board_state(
full_name, workspace_id=workspace_id, connection_id=cid
)
- columns = _merge_issues_with_board(open_issues, board_state)
+ columns = _merge_issues_with_board(
+ open_issues, board_state, bcols.keys(workspace_id)
+ )
ages = _card_ages(
full_name, open_issues, board_state,
workspace_id=workspace_id, connection_id=cid,
)
aging_days = get_settings().aging_days
cards = []
- for col in WALL_COLUMNS:
+ for col in bcols.wall_keys(workspace_id):
for issue in columns[col]:
age = ages.get(issue["number"]) or {}
days = age.get("days")
@@ -3805,14 +4101,18 @@ async def _collect_wall_data(
"number": issue["number"],
"title": issue.get("title", ""),
"column": col,
- "column_label": COLUMN_LABELS[col],
+ "column_label": bcols.names(workspace_id).get(
+ col, col
+ ),
"age_days": days,
"aging": days is not None and days >= aging_days,
}
)
return {
"full_name": full_name,
- "counts": {col: len(columns[col]) for col in KANBAN_COLUMNS},
+ "counts": {
+ col: len(columns[col]) for col in bcols.keys(workspace_id)
+ },
"open_total": len(open_issues),
"cards": cards[:9],
"cards_total": len(cards),
@@ -3835,9 +4135,9 @@ async def wall(request: Request, repo: str = ""):
"wall.html",
slides=slides,
single=bool(repo),
- column_labels=COLUMN_LABELS,
- column_colors=COLUMN_COLORS,
- columns=KANBAN_COLUMNS,
+ column_labels=bcols.names(ws_id),
+ column_colors=bcols.colors(ws_id),
+ columns=bcols.keys(ws_id),
)
@@ -3871,8 +4171,8 @@ async def search_page(request: Request, q: str = ""):
q=q,
groups=groups,
total=total,
- column_labels=COLUMN_LABELS,
- column_colors=COLUMN_COLORS,
+ column_labels=_ws_names(request),
+ column_colors=_ws_colors(request),
)
@@ -3916,8 +4216,8 @@ async def my_issues(request: Request):
user=user,
groups=groups,
total=total,
- column_labels=COLUMN_LABELS,
- column_colors=COLUMN_COLORS,
+ column_labels=_ws_names(request),
+ column_colors=_ws_colors(request),
)
@@ -3952,9 +4252,9 @@ async def _briefing_data() -> dict:
"number": issue["number"],
"title": issue["title"],
"days": ages[issue["number"]]["days"],
- "column_label": COLUMN_LABELS[
- column_of.get(issue["number"], "backlog")
- ],
+ "column_label": bcols.names(1).get(
+ column_of.get(issue["number"], "backlog"), "backlog"
+ ),
}
for issue in open_issues
if issue["number"] in ages
@@ -4439,14 +4739,11 @@ async def _do_capture(
title = (title or "").strip()
if not title or len(title) > 255:
raise ValueError("Title required (max 255 chars)")
- if column not in KANBAN_COLUMNS:
- raise ValueError("Invalid column")
parts = _split_full_name(repo_full_name)
if parts is None:
raise ValueError("Invalid repo (expected owner/name)")
owner, repo = parts
client, conn_id = await _client_for_repo(repo_full_name)
- issue = await client.create_issue(owner, repo, title, body or "")
# La carte vit dans le premier espace qui détient la connexion.
ws_id = next(
(
@@ -4456,6 +4753,9 @@ async def _do_capture(
),
1,
)
+ if column not in bcols.keys(ws_id):
+ raise ValueError("Invalid column")
+ issue = await client.create_issue(owner, repo, title, body or "")
move_issue(
repo_full_name, issue["number"], column,
workspace_id=ws_id, connection_id=conn_id,
@@ -4644,7 +4944,9 @@ async def _weekly_digest() -> dict:
"cards": [
{
"issue": card["issue"],
- "column": COLUMN_LABELS[card["column"]],
+ "column": bcols.names(1).get(
+ card["column"], card["column"]
+ ),
}
for card in group["cards"]
],
@@ -4809,7 +5111,7 @@ async def activity(request: Request):
"activity.html",
events=events,
event_icons=EVENT_ICONS,
- column_labels=COLUMN_LABELS,
+ column_labels=_ws_names(request),
)
@@ -4835,10 +5137,6 @@ async def api_move(
"""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
- if to_column not in KANBAN_COLUMNS:
- return JSONResponse(
- {"ok": False, "error": "Invalid column"}, status_code=400
- )
parts = _split_full_name(repo_full_name)
if parts is None:
return JSONResponse(
@@ -4846,12 +5144,16 @@ async def api_move(
)
owner, repo = parts
client, conn_id, ws_id = await _resolve_board_scope(request, owner, repo)
+ if to_column not in bcols.keys(ws_id):
+ return JSONResponse(
+ {"ok": False, "error": "Invalid column"}, status_code=400
+ )
move_issue(
repo_full_name, issue_number, to_column,
workspace_id=ws_id, connection_id=conn_id,
)
- if from_column in KANBAN_COLUMNS and from_column != to_column:
+ if from_column in bcols.keys(ws_id) and from_column != to_column:
remaining = [
n for n in _parse_order(from_order) if n != issue_number
]
@@ -4917,18 +5219,18 @@ async def api_move(
)
result["gitea_sync"] = "failed"
- # Mirror intermediate columns to Gitea labels (kanban:) so
- # the workflow state is visible in Gitea itself, not just here.
+ # Mirror the column on the issue's mapped label (0.30.0:
+ # configurable per workspace) so the workflow state is
+ # visible on the forge itself, not just here.
result["label_sync"] = "skipped"
if settings.sync_labels and from_column != to_column:
try:
- desired = (
- f"kanban:{to_column}" if to_column in LABELLED_COLUMNS else None
- )
+ desired = bcols.label_for(ws_id, to_column)
+ recognized = bcols.recognized_labels(ws_id)
issue = await client.get_issue(owner, repo, issue_number)
current = [
label for label in issue.get("labels") or []
- if str(label.get("name", "")).startswith("kanban:")
+ if str(label.get("name", "")).lower() in recognized
]
for label in current:
if label.get("name") != desired:
@@ -4937,7 +5239,8 @@ async def api_move(
)
if desired and all(l.get("name") != desired for l in current):
label = await client.ensure_label(
- owner, repo, desired, COLUMN_COLORS[to_column]
+ owner, repo, desired,
+ bcols.colors(ws_id).get(to_column, "#6b7280"),
)
await client.add_issue_label(owner, repo, issue_number, label["id"])
result["label_sync"] = "ok"
@@ -5097,20 +5400,20 @@ _BRANCH_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]*$")
async def _sync_kanban_label(
- owner: str, repo: str, number: int, column: str, client=None
+ owner: str, repo: str, number: int, column: str, client=None,
+ workspace_id: int = 1,
) -> None:
- """Mirror a column on the issue's kanban:* label (best effort)."""
+ """Mirror a column on the issue's mapped label (best effort)."""
if not get_settings().sync_labels:
return
client = client or gitea
try:
- desired = (
- f"kanban:{column}" if column in LABELLED_COLUMNS else None
- )
+ desired = bcols.label_for(workspace_id, column)
+ recognized = bcols.recognized_labels(workspace_id)
issue = await client.get_issue(owner, repo, number)
current = [
label for label in issue.get("labels") or []
- if str(label.get("name", "")).startswith("kanban:")
+ if str(label.get("name", "")).lower() in recognized
]
for label in current:
if label.get("name") != desired:
@@ -5119,7 +5422,8 @@ async def _sync_kanban_label(
)
if desired and all(l.get("name") != desired for l in current):
label = await client.ensure_label(
- owner, repo, desired, COLUMN_COLORS[column]
+ owner, repo, desired,
+ bcols.colors(workspace_id).get(column, "#6b7280"),
)
await client.add_issue_label(owner, repo, number, label["id"])
except Exception as exc:
@@ -5226,7 +5530,8 @@ async def api_create_branch(
title=issue.get("title", ""),
)
await _sync_kanban_label(
- owner, repo, number, "in_progress", client
+ owner, repo, number, "in_progress", client,
+ workspace_id=ws_id,
)
moved = True
await _publish_board_changed(full_name)
@@ -5873,7 +6178,7 @@ async def api_palette(q: str = ""):
"title": item.get("title", ""),
"state": item.get("state", "open"),
"column": column,
- "column_label": COLUMN_LABELS[column],
+ "column_label": bcols.names(1).get(column, column),
}
)
if len(issues) >= 8:
diff --git a/app/models.py b/app/models.py
index 09169ee..02c7bc9 100644
--- a/app/models.py
+++ b/app/models.py
@@ -154,6 +154,21 @@ def init_db() -> None:
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 (
workspace_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.
placeholders = ",".join("?" for _ in KANBAN_COLUMNS)
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,
)
@@ -340,7 +358,9 @@ def get_board_state(
(workspace_id, connection_id, repo_full_name),
).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:
column = row["column_name"]
if column not in columns:
@@ -364,7 +384,7 @@ def move_issue(
connection_id: int = 1,
) -> None:
"""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}")
with get_db() as conn:
row = conn.execute(
@@ -400,7 +420,7 @@ def set_column_order(
its index in the list. Used after a drag & drop so manual ordering
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}")
with get_db() as conn:
for index, issue_number in enumerate(ordered_issue_numbers):
@@ -793,11 +813,10 @@ def get_stats() -> dict:
projects: dict[str, dict[str, int]] = {}
for row in rows:
- if row["column_name"] not in KANBAN_COLUMNS:
- continue
name = row["repo_full_name"]
if name not in projects:
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"]
return projects
@@ -1253,3 +1272,114 @@ def list_admin_audit(limit: int = 100) -> list[dict]:
(limit,),
).fetchall()
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)
diff --git a/app/rules.py b/app/rules.py
index ee4ada5..d8f6f13 100644
--- a/app/rules.py
+++ b/app/rules.py
@@ -20,6 +20,7 @@ rule can never close an issue or move a card between columns.
import json
+from app import models
from app.models import KANBAN_COLUMNS
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)"
)
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']} »")
continue
if "min_age_days" in when and (
diff --git a/app/templates/_header.html b/app/templates/_header.html
index a3a5fac..ac96a8e 100644
--- a/app/templates/_header.html
+++ b/app/templates/_header.html
@@ -38,6 +38,7 @@
("admin", "/admin", "🛠️", "Vue d'ensemble"),
("admin_connections", "/admin/connections", "🔌", "Connexions"),
("admin_workspaces", "/admin/workspaces", "🧭", "Espaces"),
+ ("admin_columns", "/admin/columns", "🧱", "Colonnes"),
("admin_settings", "/admin/settings", "🎛️", "Paramètres"),
]),
] %}
diff --git a/app/templates/admin_columns.html b/app/templates/admin_columns.html
new file mode 100644
index 0000000..9af03c6
--- /dev/null
+++ b/app/templates/admin_columns.html
@@ -0,0 +1,99 @@
+
+
+
+
+
+
+ Colonnes — Administration — Kanbante
+
+
+
+ {% include "_header.html" %}
+
+
+
+
🧱 Colonnes du board
+
Espace : {{ workspace.name }} (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 mapping étiquette ↔ colonne. La clé d'une colonne ne change jamais (les cartes y sont rattachées) ; le nom affiché, lui, est libre.
+
+ {% if msg %}
✅ {{ msg }}
{% endif %}
+ {% if err %}
⚠️ {{ err }}
{% endif %}
+
+
+
Comment marche le mapping
+
+ Miroir : quand une carte entre dans une colonne, son étiquette mappée est posée sur l'issue de la forge (et les étiquettes mappées des autres colonnes sont retirées).
+ Adoption : si une issue porte exactement une é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.
+ Alias : étiquettes supplémentaires reconnues pour cette colonne, séparées par des virgules — par exemple les étiquettes Flowdeck kanban_a_faire sur la colonne À faire.
+ Focus : les colonnes cochées « Focus » alimentent la vue Focus du jour et le wallboard.
+
+
+
+ {% for c in columns %}
+
+
+
+ {{ c.name }}
+ {{ c.key }} · {{ counts[c.key] }} carte(s) dans cet espace
+ {% if c.kind == 'backlog' %}système — défaut des cartes neuves{% endif %}
+ {% if c.kind == 'done' %}système — issues fermées{% endif %}
+
+
+
+
+
+ {% if c.kind == 'flow' %}
+
+ {% endif %}
+
+
+ {% endfor %}
+
+
+
➕ Nouvelle colonne
+
+
+
+
+
↩️ Réinitialiser
+
Revenir aux six colonnes historiques de cet espace. Les cartes placées dans des colonnes personnalisées retomberont au Backlog à l'affichage.
+
+
+
+
+
diff --git a/app/templates/board.html b/app/templates/board.html
index b84f5cc..88a8fec 100644
--- a/app/templates/board.html
+++ b/app/templates/board.html
@@ -53,6 +53,7 @@
📥 Triage du backlog
+ {% if is_admin or workspace_role == 'admin' %}🧱 Colonnes{% endif %}