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.
This commit is contained in:
Spark
2026-10-23 08:38:02 -04:00
parent 3964323abd
commit 28322c12cf
13 changed files with 1073 additions and 83 deletions
+136 -6
View File
@@ -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)