feat(forge): fondation multi-forges — connexions chiffrées, registre, client GitHub (P0/P1)

- Tables forge_connections/users/workspaces (+ membres, projets,
  états, audit) ; migration des anciennes bases avec portées
  (workspace_id, connection_id) par défaut (1, 1).
- Jetons chiffrés Fernet (KANBANTE_MASTER_KEY ou clé générée en
  base), jamais réaffichés.
- ForgeRegistry : connexion héritée créée depuis l'environnement,
  espace Principal, instantanés préfixés par connexion.
- GitHubClient complet (Bearer, pagination Link, étiquettes par
  nom, PATCH archived, branches via git/refs).
- Paramètres surchargeables en base (résolveur config, P3).
This commit is contained in:
Spark
2026-10-21 14:48:29 -04:00
parent f1a05279b6
commit 735d7cdfd0
11 changed files with 1403 additions and 79 deletions
+687 -57
View File
@@ -49,25 +49,31 @@ def init_db() -> None:
"""
CREATE TABLE IF NOT EXISTS board_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER NOT NULL DEFAULT 1,
connection_id INTEGER NOT NULL DEFAULT 1,
repo_full_name TEXT NOT NULL,
issue_number INTEGER NOT NULL,
column_name TEXT NOT NULL DEFAULT 'backlog',
position REAL NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(repo_full_name, issue_number)
UNIQUE(workspace_id, connection_id, repo_full_name, issue_number)
);
CREATE TABLE IF NOT EXISTS project_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER NOT NULL DEFAULT 1,
connection_id INTEGER NOT NULL DEFAULT 1,
repo_full_name TEXT NOT NULL,
note TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(repo_full_name)
UNIQUE(workspace_id, connection_id, repo_full_name)
);
CREATE TABLE IF NOT EXISTS board_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER NOT NULL DEFAULT 1,
connection_id INTEGER NOT NULL DEFAULT 1,
repo_full_name TEXT NOT NULL,
issue_number INTEGER NOT NULL,
event_type TEXT NOT NULL,
@@ -82,10 +88,12 @@ def init_db() -> None:
CREATE TABLE IF NOT EXISTS today_picks (
pick_date TEXT NOT NULL,
workspace_id INTEGER NOT NULL DEFAULT 1,
connection_id INTEGER NOT NULL DEFAULT 1,
repo_full_name TEXT NOT NULL,
issue_number INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (pick_date, repo_full_name, issue_number)
PRIMARY KEY (pick_date, workspace_id, connection_id, repo_full_name, issue_number)
);
CREATE TABLE IF NOT EXISTS kv_store (
key TEXT PRIMARY KEY,
@@ -98,6 +106,79 @@ def init_db() -> None:
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- ── Multi-forges, utilisateurs, espaces (0.24.0) ──
CREATE TABLE IF NOT EXISTS forge_connections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL DEFAULT 'gitea',
name TEXT NOT NULL,
base_url TEXT NOT NULL,
token_encrypted TEXT NOT NULL DEFAULT '',
account_login TEXT NOT NULL DEFAULT '',
webhook_secret TEXT NOT NULL DEFAULT '',
owner_user_id INTEGER,
enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
display_name TEXT NOT NULL DEFAULT '',
draco_sub TEXT UNIQUE,
active INTEGER NOT NULL DEFAULT 1,
must_change_password INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_login_at TEXT
);
CREATE TABLE IF NOT EXISTS workspaces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
color TEXT NOT NULL DEFAULT '#7c5cfc',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS workspace_connections (
workspace_id INTEGER NOT NULL,
connection_id INTEGER NOT NULL,
PRIMARY KEY (workspace_id, connection_id)
);
CREATE TABLE IF NOT EXISTS workspace_projects (
workspace_id INTEGER NOT NULL,
connection_id INTEGER NOT NULL,
repo_full_name TEXT NOT NULL,
PRIMARY KEY (workspace_id, connection_id, repo_full_name)
);
CREATE TABLE IF NOT EXISTS workspace_members (
workspace_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
role TEXT NOT NULL DEFAULT 'member',
PRIMARY KEY (workspace_id, user_id)
);
CREATE TABLE IF NOT EXISTS project_states (
workspace_id INTEGER NOT NULL,
connection_id INTEGER NOT NULL,
repo_full_name TEXT NOT NULL,
local_archived INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (workspace_id, connection_id, repo_full_name)
);
CREATE TABLE IF NOT EXISTS admin_audit (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
action TEXT NOT NULL,
details TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_events_repo
ON board_events(repo_full_name, issue_number, created_at);
"""
@@ -121,6 +202,7 @@ def _migrate(conn: sqlite3.Connection) -> None:
"UPDATE project_notes SET updated_at = created_at "
"WHERE updated_at IS NULL"
)
_migrate_v24(conn)
# Rows written under a column name that no longer exists used to crash
# get_board_state() with a KeyError. Drop them once, here.
placeholders = ",".join("?" for _ in KANBAN_COLUMNS)
@@ -130,6 +212,99 @@ def _migrate(conn: sqlite3.Connection) -> None:
)
def _migrate_v24(conn: sqlite3.Connection) -> None:
"""0.24.0: scope columns (workspace_id, connection_id).
board_state / project_notes / today_picks carry UNIQUE
constraints on repo_full_name alone — SQLite cannot alter a
constraint, so legacy tables are rebuilt with the scoped
uniqueness; existing rows land in scope (1, 1) — the legacy
connection and the Principal workspace.
"""
def _cols(table: str) -> list[str]:
return [r[1] for r in conn.execute(f"PRAGMA table_info({table})")]
if _cols("board_state") and "connection_id" not in _cols("board_state"):
conn.executescript(
"""
CREATE TABLE board_state_v24 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER NOT NULL DEFAULT 1,
connection_id INTEGER NOT NULL DEFAULT 1,
repo_full_name TEXT NOT NULL,
issue_number INTEGER NOT NULL,
column_name TEXT NOT NULL DEFAULT 'backlog',
position REAL NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(workspace_id, connection_id, repo_full_name, issue_number)
);
INSERT INTO board_state_v24
(id, workspace_id, connection_id, repo_full_name,
issue_number, column_name, position, updated_at)
SELECT id, 1, 1, repo_full_name, issue_number, column_name,
position, updated_at FROM board_state;
DROP TABLE board_state;
ALTER TABLE board_state_v24 RENAME TO board_state;
CREATE INDEX IF NOT EXISTS idx_board_repo
ON board_state(repo_full_name, column_name);
"""
)
if _cols("project_notes") and "connection_id" not in _cols("project_notes"):
conn.executescript(
"""
CREATE TABLE project_notes_v24 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
workspace_id INTEGER NOT NULL DEFAULT 1,
connection_id INTEGER NOT NULL DEFAULT 1,
repo_full_name TEXT NOT NULL,
note TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(workspace_id, connection_id, repo_full_name)
);
INSERT INTO project_notes_v24
(id, workspace_id, connection_id, repo_full_name, note,
created_at, updated_at)
SELECT id, 1, 1, repo_full_name, note, created_at, updated_at
FROM project_notes;
DROP TABLE project_notes;
ALTER TABLE project_notes_v24 RENAME TO project_notes;
"""
)
if _cols("today_picks") and "connection_id" not in _cols("today_picks"):
conn.executescript(
"""
CREATE TABLE today_picks_v24 (
pick_date TEXT NOT NULL,
workspace_id INTEGER NOT NULL DEFAULT 1,
connection_id INTEGER NOT NULL DEFAULT 1,
repo_full_name TEXT NOT NULL,
issue_number INTEGER NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (pick_date, workspace_id, connection_id,
repo_full_name, issue_number)
);
INSERT INTO today_picks_v24
(pick_date, workspace_id, connection_id, repo_full_name,
issue_number, created_at)
SELECT pick_date, 1, 1, repo_full_name, issue_number, created_at
FROM today_picks;
DROP TABLE today_picks;
ALTER TABLE today_picks_v24 RENAME TO today_picks;
"""
)
if _cols("board_events") and "connection_id" not in _cols("board_events"):
conn.execute(
"ALTER TABLE board_events ADD COLUMN workspace_id "
"INTEGER NOT NULL DEFAULT 1"
)
conn.execute(
"ALTER TABLE board_events ADD COLUMN connection_id "
"INTEGER NOT NULL DEFAULT 1"
)
def ping_db() -> bool:
"""Cheap check used by /api/readiness."""
try:
@@ -140,14 +315,17 @@ def ping_db() -> bool:
return False
def get_board_state(repo_full_name: str) -> dict[str, list[dict]]:
def get_board_state(
repo_full_name: str, workspace_id: int = 1, connection_id: int = 1
) -> dict[str, list[dict]]:
"""Return board columns with their issues, ordered by position."""
with get_db() as conn:
rows = conn.execute(
"SELECT issue_number, column_name, position, updated_at "
"FROM board_state WHERE repo_full_name = ? "
"FROM board_state WHERE workspace_id = ? AND connection_id = ? "
"AND repo_full_name = ? "
"ORDER BY position ASC, issue_number ASC",
(repo_full_name,),
(workspace_id, connection_id, repo_full_name),
).fetchall()
columns: dict[str, list[dict]] = {col: [] for col in KANBAN_COLUMNS}
@@ -166,30 +344,43 @@ def get_board_state(repo_full_name: str) -> dict[str, list[dict]]:
return columns
def move_issue(repo_full_name: str, issue_number: int, to_column: str) -> None:
def move_issue(
repo_full_name: str,
issue_number: int,
to_column: str,
workspace_id: int = 1,
connection_id: int = 1,
) -> None:
"""Move an issue to a different column (appended at the end)."""
if to_column not in KANBAN_COLUMNS:
raise ValueError(f"Invalid column: {to_column}")
with get_db() as conn:
row = conn.execute(
"SELECT COALESCE(MAX(position), -1) + 1 AS next_pos FROM board_state "
"WHERE repo_full_name = ? AND column_name = ?",
(repo_full_name, to_column),
"WHERE workspace_id = ? AND connection_id = ? "
"AND repo_full_name = ? AND column_name = ?",
(workspace_id, connection_id, repo_full_name, to_column),
).fetchone()
conn.execute(
"""INSERT INTO board_state
(repo_full_name, issue_number, column_name, position, updated_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(repo_full_name, issue_number)
(workspace_id, connection_id, repo_full_name, issue_number,
column_name, position, updated_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(workspace_id, connection_id, repo_full_name, issue_number)
DO UPDATE SET column_name = excluded.column_name,
position = excluded.position,
updated_at = excluded.updated_at""",
(repo_full_name, issue_number, to_column, row["next_pos"]),
(workspace_id, connection_id, repo_full_name, issue_number,
to_column, row["next_pos"]),
)
def set_column_order(
repo_full_name: str, column_name: str, ordered_issue_numbers: list[int]
repo_full_name: str,
column_name: str,
ordered_issue_numbers: list[int],
workspace_id: int = 1,
connection_id: int = 1,
) -> None:
"""Persist the exact order of the cards in one column.
@@ -203,18 +394,24 @@ def set_column_order(
for index, issue_number in enumerate(ordered_issue_numbers):
conn.execute(
"""INSERT INTO board_state
(repo_full_name, issue_number, column_name, position, updated_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(repo_full_name, issue_number)
(workspace_id, connection_id, repo_full_name,
issue_number, column_name, position, updated_at)
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(workspace_id, connection_id, repo_full_name, issue_number)
DO UPDATE SET column_name = excluded.column_name,
position = excluded.position,
updated_at = excluded.updated_at""",
(repo_full_name, issue_number, column_name, float(index)),
(workspace_id, connection_id, repo_full_name,
issue_number, column_name, float(index)),
)
def reconcile_board_state(
repo_full_name: str, open_numbers: set[int], closed_numbers: set[int]
repo_full_name: str,
open_numbers: set[int],
closed_numbers: set[int],
workspace_id: int = 1,
connection_id: int = 1,
) -> dict[str, int]:
"""Reconcile local board state with the truth in Gitea.
@@ -228,16 +425,18 @@ def reconcile_board_state(
with get_db() as conn:
rows = conn.execute(
"SELECT issue_number, column_name FROM board_state "
"WHERE repo_full_name = ?",
(repo_full_name,),
"WHERE workspace_id = ? AND connection_id = ? "
"AND repo_full_name = ?",
(workspace_id, connection_id, repo_full_name),
).fetchall()
for row in rows:
number = row["issue_number"]
if number not in known:
conn.execute(
"DELETE FROM board_state "
"WHERE repo_full_name = ? AND issue_number = ?",
(repo_full_name, number),
"WHERE workspace_id = ? AND connection_id = ? "
"AND repo_full_name = ? AND issue_number = ?",
(workspace_id, connection_id, repo_full_name, number),
)
changed["purged"] += 1
changed["purged_numbers"].append(number)
@@ -245,40 +444,69 @@ def reconcile_board_state(
conn.execute(
"UPDATE board_state SET column_name = 'done', "
"updated_at = datetime('now') "
"WHERE repo_full_name = ? AND issue_number = ?",
(repo_full_name, number),
"WHERE workspace_id = ? AND connection_id = ? "
"AND repo_full_name = ? AND issue_number = ?",
(workspace_id, connection_id, repo_full_name, number),
)
changed["closed_to_done"] += 1
changed["closed_numbers"].append(number)
return changed
def get_picks(pick_date: str) -> list[dict]:
def get_picks(
pick_date: str, workspace_id: int = 1, connection_id: int | None = None
) -> list[dict]:
"""The day's focus picks (issue #5), in pinning order."""
with get_db() as conn:
rows = conn.execute(
"SELECT repo_full_name, issue_number FROM today_picks "
"WHERE pick_date = ? ORDER BY created_at, repo_full_name, issue_number",
(pick_date,),
).fetchall()
if connection_id is None:
rows = conn.execute(
"SELECT connection_id, repo_full_name, issue_number "
"FROM today_picks WHERE pick_date = ? AND workspace_id = ? "
"ORDER BY created_at, repo_full_name, issue_number",
(pick_date, workspace_id),
).fetchall()
else:
rows = conn.execute(
"SELECT connection_id, repo_full_name, issue_number "
"FROM today_picks WHERE pick_date = ? AND workspace_id = ? "
"AND connection_id = ? "
"ORDER BY created_at, repo_full_name, issue_number",
(pick_date, workspace_id, connection_id),
).fetchall()
return [dict(row) for row in rows]
def add_pick(pick_date: str, repo_full_name: str, issue_number: int) -> None:
def add_pick(
pick_date: str,
repo_full_name: str,
issue_number: int,
workspace_id: int = 1,
connection_id: int = 1,
) -> None:
with get_db() as conn:
conn.execute(
"INSERT OR IGNORE INTO today_picks "
"(pick_date, repo_full_name, issue_number) VALUES (?, ?, ?)",
(pick_date, repo_full_name, issue_number),
"(pick_date, workspace_id, connection_id, repo_full_name, "
"issue_number) VALUES (?, ?, ?, ?, ?)",
(pick_date, workspace_id, connection_id, repo_full_name,
issue_number),
)
def remove_pick(pick_date: str, repo_full_name: str, issue_number: int) -> None:
def remove_pick(
pick_date: str,
repo_full_name: str,
issue_number: int,
workspace_id: int = 1,
connection_id: int = 1,
) -> None:
with get_db() as conn:
conn.execute(
"DELETE FROM today_picks WHERE pick_date = ? "
"AND workspace_id = ? AND connection_id = ? "
"AND repo_full_name = ? AND issue_number = ?",
(pick_date, repo_full_name, issue_number),
(pick_date, workspace_id, connection_id, repo_full_name,
issue_number),
)
@@ -354,6 +582,8 @@ def log_event(
to_column: str | None = None,
source: str = "ui",
created_at: str | None = None,
workspace_id: int = 1,
connection_id: int = 1,
) -> None:
"""Append one entry to the board's event journal.
@@ -365,19 +595,21 @@ def log_event(
with get_db() as conn:
if created_at:
conn.execute(
"INSERT INTO board_events (repo_full_name, issue_number, "
"event_type, from_column, to_column, source, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(repo_full_name, issue_number, event_type,
from_column, to_column, source, created_at),
"INSERT INTO board_events (workspace_id, connection_id, "
"repo_full_name, issue_number, event_type, from_column, "
"to_column, source, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
(workspace_id, connection_id, repo_full_name, issue_number,
event_type, from_column, to_column, source, created_at),
)
else:
conn.execute(
"INSERT INTO board_events (repo_full_name, issue_number, "
"event_type, from_column, to_column, source) "
"VALUES (?, ?, ?, ?, ?, ?)",
(repo_full_name, issue_number, event_type,
from_column, to_column, source),
"INSERT INTO board_events (workspace_id, connection_id, "
"repo_full_name, issue_number, event_type, from_column, "
"to_column, source) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(workspace_id, connection_id, repo_full_name, issue_number,
event_type, from_column, to_column, source),
)
@@ -400,7 +632,9 @@ def get_events(
return [dict(row) for row in rows]
def column_entry_times(repo_full_name: str) -> dict[int, str]:
def column_entry_times(
repo_full_name: str, workspace_id: int = 1, connection_id: int = 1
) -> dict[int, str]:
"""When each issue entered its current column, per the journal.
Latest event per issue, comments excluded (a comment does not move
@@ -410,9 +644,10 @@ def column_entry_times(repo_full_name: str) -> dict[int, str]:
with get_db() as conn:
rows = conn.execute(
"SELECT issue_number, MAX(created_at) AS entered_at "
"FROM board_events WHERE repo_full_name = ? "
"FROM board_events WHERE workspace_id = ? AND connection_id = ? "
"AND repo_full_name = ? "
"AND event_type != 'commented' GROUP BY issue_number",
(repo_full_name,),
(workspace_id, connection_id, repo_full_name),
).fetchall()
return {row["issue_number"]: row["entered_at"] for row in rows}
@@ -506,23 +741,32 @@ def get_metrics(repo_full_name: str | None = None, window_days: int = 30) -> dic
}
def set_notes(repo_full_name: str, note: str) -> None:
def set_notes(
repo_full_name: str,
note: str,
workspace_id: int = 1,
connection_id: int = 1,
) -> None:
with get_db() as conn:
conn.execute(
"""INSERT INTO project_notes (repo_full_name, note)
VALUES (?, ?)
ON CONFLICT(repo_full_name)
"""INSERT INTO project_notes
(workspace_id, connection_id, repo_full_name, note)
VALUES (?, ?, ?, ?)
ON CONFLICT(workspace_id, connection_id, repo_full_name)
DO UPDATE SET note = excluded.note,
updated_at = datetime('now')""",
(repo_full_name, note),
(workspace_id, connection_id, repo_full_name, note),
)
def get_notes(repo_full_name: str) -> str | None:
def get_notes(
repo_full_name: str, workspace_id: int = 1, connection_id: int = 1
) -> str | None:
with get_db() as conn:
row = conn.execute(
"SELECT note FROM project_notes WHERE repo_full_name = ?",
(repo_full_name,),
"SELECT note FROM project_notes WHERE workspace_id = ? "
"AND connection_id = ? AND repo_full_name = ?",
(workspace_id, connection_id, repo_full_name),
).fetchone()
return row["note"] if row else None
@@ -544,3 +788,389 @@ def get_stats() -> dict:
projects[name] = {col: 0 for col in KANBAN_COLUMNS}
projects[name][row["column_name"]] = row["cnt"]
return projects
# ── Connexions aux forges (0.24.0, P0) ────────────────────
def list_connections(include_disabled: bool = True) -> list[dict]:
query = "SELECT * FROM forge_connections"
if not include_disabled:
query += " WHERE enabled = 1"
query += " ORDER BY id"
with get_db() as conn:
return [dict(r) for r in conn.execute(query).fetchall()]
def get_connection(connection_id: int) -> dict | None:
with get_db() as conn:
row = conn.execute(
"SELECT * FROM forge_connections WHERE id = ?", (connection_id,)
).fetchone()
return dict(row) if row else None
def get_connection_by_slug(slug: str) -> dict | None:
with get_db() as conn:
row = conn.execute(
"SELECT * FROM forge_connections WHERE slug = ?", (slug,)
).fetchone()
return dict(row) if row else None
def create_connection(
slug: str,
kind: str,
name: str,
base_url: str,
token_encrypted: str = "",
account_login: str = "",
webhook_secret: str = "",
owner_user_id: int | None = None,
) -> int:
with get_db() as conn:
cur = conn.execute(
"INSERT INTO forge_connections (slug, kind, name, base_url, "
"token_encrypted, account_login, webhook_secret, owner_user_id) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(slug, kind, name, base_url, token_encrypted, account_login,
webhook_secret, owner_user_id),
)
return int(cur.lastrowid)
def update_connection(connection_id: int, **fields) -> None:
allowed = {
"slug", "kind", "name", "base_url", "token_encrypted",
"account_login", "webhook_secret", "enabled", "owner_user_id",
}
items = {k: v for k, v in fields.items() if k in allowed}
if not items:
return
assignments = ", ".join(f"{k} = ?" for k in items)
with get_db() as conn:
conn.execute(
f"UPDATE forge_connections SET {assignments} WHERE id = ?",
(*items.values(), connection_id),
)
def delete_connection(connection_id: int) -> None:
with get_db() as conn:
conn.execute(
"DELETE FROM forge_connections WHERE id = ?", (connection_id,)
)
conn.execute(
"DELETE FROM workspace_connections WHERE connection_id = ?",
(connection_id,),
)
conn.execute(
"DELETE FROM workspace_projects WHERE connection_id = ?",
(connection_id,),
)
# ── Utilisateurs (0.24.0, P2) ──────────────────────────────
def count_users() -> int:
with get_db() as conn:
return int(conn.execute("SELECT COUNT(*) FROM users").fetchone()[0])
def list_users() -> list[dict]:
with get_db() as conn:
return [
dict(r)
for r in conn.execute("SELECT * FROM users ORDER BY id").fetchall()
]
def get_user(user_id: int) -> dict | None:
with get_db() as conn:
row = conn.execute(
"SELECT * FROM users WHERE id = ?", (user_id,)
).fetchone()
return dict(row) if row else None
def get_user_by_username(username: str) -> dict | None:
with get_db() as conn:
row = conn.execute(
"SELECT * FROM users WHERE username = ?", (username,)
).fetchone()
return dict(row) if row else None
def get_user_by_draco(sub: str) -> dict | None:
with get_db() as conn:
row = conn.execute(
"SELECT * FROM users WHERE draco_sub = ?", (sub,)
).fetchone()
return dict(row) if row else None
def create_user(
username: str,
password_hash: str,
role: str = "member",
display_name: str = "",
must_change_password: int = 0,
) -> int:
with get_db() as conn:
cur = conn.execute(
"INSERT INTO users (username, password_hash, role, display_name, "
"must_change_password) VALUES (?, ?, ?, ?, ?)",
(username, password_hash, role, display_name, must_change_password),
)
return int(cur.lastrowid)
def update_user(user_id: int, **fields) -> None:
allowed = {
"password_hash", "role", "display_name", "draco_sub", "active",
"must_change_password", "last_login_at",
}
items = {k: v for k, v in fields.items() if k in allowed}
if not items:
return
assignments = ", ".join(f"{k} = ?" for k in items)
with get_db() as conn:
conn.execute(
f"UPDATE users SET {assignments} WHERE id = ?",
(*items.values(), user_id),
)
# ── Espaces de travail (0.24.0, P3) ────────────────────────
def list_workspaces() -> list[dict]:
with get_db() as conn:
return [
dict(r)
for r in conn.execute(
"SELECT * FROM workspaces ORDER BY id"
).fetchall()
]
def get_workspace(workspace_id: int) -> dict | None:
with get_db() as conn:
row = conn.execute(
"SELECT * FROM workspaces WHERE id = ?", (workspace_id,)
).fetchone()
return dict(row) if row else None
def create_workspace(slug: str, name: str, color: str = "#7c5cfc") -> int:
with get_db() as conn:
cur = conn.execute(
"INSERT INTO workspaces (slug, name, color) VALUES (?, ?, ?)",
(slug, name, color),
)
return int(cur.lastrowid)
def update_workspace(workspace_id: int, **fields) -> None:
allowed = {"slug", "name", "color"}
items = {k: v for k, v in fields.items() if k in allowed}
if not items:
return
assignments = ", ".join(f"{k} = ?" for k in items)
with get_db() as conn:
conn.execute(
f"UPDATE workspaces SET {assignments} WHERE id = ?",
(*items.values(), workspace_id),
)
def delete_workspace(workspace_id: int) -> None:
with get_db() as conn:
for table in (
"workspace_connections", "workspace_projects",
"workspace_members", "project_states",
):
conn.execute(
f"DELETE FROM {table} WHERE workspace_id = ?", (workspace_id,)
)
conn.execute("DELETE FROM workspaces WHERE id = ?", (workspace_id,))
def workspace_connection_ids(workspace_id: int) -> list[int]:
with get_db() as conn:
rows = conn.execute(
"SELECT connection_id FROM workspace_connections "
"WHERE workspace_id = ? ORDER BY connection_id",
(workspace_id,),
).fetchall()
return [r[0] for r in rows]
def set_workspace_connections(workspace_id: int, connection_ids: list[int]) -> None:
with get_db() as conn:
conn.execute(
"DELETE FROM workspace_connections WHERE workspace_id = ?",
(workspace_id,),
)
conn.executemany(
"INSERT OR IGNORE INTO workspace_connections "
"(workspace_id, connection_id) VALUES (?, ?)",
[(workspace_id, cid) for cid in connection_ids],
)
def workspace_project_selection(workspace_id: int) -> list[tuple[int, str]]:
"""Explicit project inclusions; empty means "all projects"."""
with get_db() as conn:
rows = conn.execute(
"SELECT connection_id, repo_full_name FROM workspace_projects "
"WHERE workspace_id = ? ORDER BY connection_id, repo_full_name",
(workspace_id,),
).fetchall()
return [(r[0], r[1]) for r in rows]
def set_workspace_projects(
workspace_id: int, selection: list[tuple[int, str]]
) -> None:
with get_db() as conn:
conn.execute(
"DELETE FROM workspace_projects WHERE workspace_id = ?",
(workspace_id,),
)
conn.executemany(
"INSERT OR IGNORE INTO workspace_projects "
"(workspace_id, connection_id, repo_full_name) VALUES (?, ?, ?)",
[(workspace_id, cid, repo) for cid, repo in selection],
)
def list_members(workspace_id: int) -> list[dict]:
with get_db() as conn:
rows = conn.execute(
"SELECT m.user_id, m.role, u.username, u.display_name "
"FROM workspace_members m JOIN users u ON u.id = m.user_id "
"WHERE m.workspace_id = ? ORDER BY u.username",
(workspace_id,),
).fetchall()
return [dict(r) for r in rows]
def set_member(workspace_id: int, user_id: int, role: str) -> None:
with get_db() as conn:
conn.execute(
"INSERT INTO workspace_members (workspace_id, user_id, role) "
"VALUES (?, ?, ?) ON CONFLICT(workspace_id, user_id) "
"DO UPDATE SET role = excluded.role",
(workspace_id, user_id, role),
)
def remove_member(workspace_id: int, user_id: int) -> None:
with get_db() as conn:
conn.execute(
"DELETE FROM workspace_members WHERE workspace_id = ? "
"AND user_id = ?",
(workspace_id, user_id),
)
def user_workspaces(user_id: int) -> list[dict]:
with get_db() as conn:
rows = conn.execute(
"SELECT w.*, m.role AS member_role FROM workspaces w "
"JOIN workspace_members m ON m.workspace_id = w.id "
"WHERE m.user_id = ? ORDER BY w.id",
(user_id,),
).fetchall()
return [dict(r) for r in rows]
def workspace_role(workspace_id: int, user_id: int) -> str | None:
with get_db() as conn:
row = conn.execute(
"SELECT role FROM workspace_members WHERE workspace_id = ? "
"AND user_id = ?",
(workspace_id, user_id),
).fetchone()
return row[0] if row else None
# ── États de projets / archives locales (0.24.0, P4) ──────
def get_project_state(
workspace_id: int, connection_id: int, repo_full_name: str
) -> dict | None:
with get_db() as conn:
row = conn.execute(
"SELECT * FROM project_states WHERE workspace_id = ? "
"AND connection_id = ? AND repo_full_name = ?",
(workspace_id, connection_id, repo_full_name),
).fetchone()
return dict(row) if row else None
def set_local_archived(
workspace_id: int,
connection_id: int,
repo_full_name: str,
archived: bool,
) -> None:
with get_db() as conn:
conn.execute(
"INSERT INTO project_states (workspace_id, connection_id, "
"repo_full_name, local_archived, updated_at) "
"VALUES (?, ?, ?, ?, datetime('now')) "
"ON CONFLICT(workspace_id, connection_id, repo_full_name) "
"DO UPDATE SET local_archived = excluded.local_archived, "
"updated_at = excluded.updated_at",
(workspace_id, connection_id, repo_full_name, 1 if archived else 0),
)
def list_local_archived(workspace_id: int) -> list[dict]:
with get_db() as conn:
rows = conn.execute(
"SELECT * FROM project_states WHERE workspace_id = ? "
"AND local_archived = 1 ORDER BY repo_full_name",
(workspace_id,),
).fetchall()
return [dict(r) for r in rows]
def purge_project_local_state(
workspace_id: int, connection_id: int, repo_full_name: str
) -> None:
"""Drop every local trace of a project in one workspace."""
with get_db() as conn:
for table in ("board_state", "project_notes", "today_picks"):
conn.execute(
f"DELETE FROM {table} WHERE workspace_id = ? "
"AND connection_id = ? AND repo_full_name = ?",
(workspace_id, connection_id, repo_full_name),
)
conn.execute(
"DELETE FROM project_states WHERE workspace_id = ? "
"AND connection_id = ? AND repo_full_name = ?",
(workspace_id, connection_id, repo_full_name),
)
# ── Journal d'administration (0.24.0, P2) ─────────────────
def log_admin(user_id: int | None, action: str, details: str = "") -> None:
with get_db() as conn:
conn.execute(
"INSERT INTO admin_audit (user_id, action, details) "
"VALUES (?, ?, ?)",
(user_id, action, details),
)
def list_admin_audit(limit: int = 100) -> list[dict]:
with get_db() as conn:
rows = conn.execute(
"SELECT a.*, u.username FROM admin_audit a "
"LEFT JOIN users u ON u.id = a.user_id "
"ORDER BY a.id DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(r) for r in rows]