feat(0.30.0): colonnes personnalisables par espace + mapping étiquette ↔ colonne
CI / test (push) Successful in 2m17s
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:
+136
-6
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user