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:
@@ -0,0 +1,68 @@
|
||||
"""User accounts: password hashing and bootstrap (0.24.0, P2).
|
||||
|
||||
Passwords are hashed with PBKDF2-HMAC-SHA256 (stdlib) with a
|
||||
per-user salt; the stored string carries its parameters
|
||||
("pbkdf2$<iterations>$<salt_b64>$<hash_b64>") so the cost can
|
||||
be raised later without invalidating existing hashes.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
|
||||
from app import models
|
||||
|
||||
_ITERATIONS = 260_000
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
salt = secrets.token_bytes(16)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
"sha256", password.encode("utf-8"), salt, _ITERATIONS
|
||||
)
|
||||
return "pbkdf2${}${}${}".format(
|
||||
_ITERATIONS,
|
||||
base64.b64encode(salt).decode("ascii"),
|
||||
base64.b64encode(digest).decode("ascii"),
|
||||
)
|
||||
|
||||
|
||||
def verify_password(password: str, stored: str) -> bool:
|
||||
try:
|
||||
scheme, iterations, salt_b64, hash_b64 = stored.split("$")
|
||||
if scheme != "pbkdf2":
|
||||
return False
|
||||
salt = base64.b64decode(salt_b64)
|
||||
expected = base64.b64decode(hash_b64)
|
||||
digest = hashlib.pbkdf2_hmac(
|
||||
"sha256", password.encode("utf-8"), salt, int(iterations)
|
||||
)
|
||||
return hmac.compare_digest(digest, expected)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def ensure_bootstrap_admin() -> dict | None:
|
||||
"""Create the first admin from the env credentials.
|
||||
|
||||
Runs when the users table is empty and KANBANTE_AUTH_USER /
|
||||
_PASSWORD are configured (decision D6): Bruno's existing
|
||||
login becomes the initial database admin, with no lockout.
|
||||
The env path keeps working as an emergency fallback either
|
||||
way (see app.main auth).
|
||||
"""
|
||||
from app.config import get_settings
|
||||
|
||||
if models.count_users() > 0:
|
||||
return None
|
||||
settings = get_settings()
|
||||
if not settings.auth_enabled:
|
||||
return None
|
||||
user_id = models.create_user(
|
||||
settings.auth_user,
|
||||
hash_password(settings.auth_password),
|
||||
role="admin",
|
||||
display_name=settings.auth_user,
|
||||
)
|
||||
return models.get_user(user_id)
|
||||
+71
-2
@@ -104,7 +104,7 @@ from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
APP_VERSION = "0.23.3"
|
||||
APP_VERSION = "0.24.0"
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
@@ -179,6 +179,7 @@ class Settings:
|
||||
draco_secret_prev: str = ""
|
||||
sso_machine_key: str = ""
|
||||
sso_url: str = "https://og.dracodev.net"
|
||||
archive_suggest_days: int = 180
|
||||
|
||||
@property
|
||||
def auth_enabled(self) -> bool:
|
||||
@@ -186,7 +187,13 @@ class Settings:
|
||||
|
||||
|
||||
def get_settings() -> Settings:
|
||||
"""Build settings from the current environment."""
|
||||
"""Effective settings: environment, overlaid by the admin
|
||||
panel's database values (0.24.0 — see OVERRIDABLE_SETTINGS)."""
|
||||
return apply_db_overrides(_env_settings())
|
||||
|
||||
|
||||
def _env_settings() -> Settings:
|
||||
"""Build settings from the current environment only."""
|
||||
return Settings(
|
||||
gitea_url=os.getenv("GITEA_URL", "https://git.dracodev.net").rstrip("/"),
|
||||
gitea_token=os.getenv("GITEA_TOKEN", ""),
|
||||
@@ -230,4 +237,66 @@ def get_settings() -> Settings:
|
||||
draco_secret_prev=os.getenv("DRACO_SESSION_SECRET_PREV", ""),
|
||||
sso_machine_key=os.getenv("DRACO_SSO_MACHINE_KEY", ""),
|
||||
sso_url=os.getenv("DRACO_SSO_URL", "https://og.dracodev.net").rstrip("/"),
|
||||
archive_suggest_days=_env_int("KANBANTE_ARCHIVE_SUGGEST_DAYS", 180),
|
||||
)
|
||||
|
||||
|
||||
# ── Surcharge par le panneau d'administration (0.24.0, P3) ──
|
||||
# L'environnement amorce, le panneau gouverne : les valeurs
|
||||
# ci-dessous peuvent être remplacées en base (kv_store, clé
|
||||
# "settings_overrides", JSON) depuis /admin/settings. La
|
||||
# provenance reste affichée dans le panneau. Les secrets et les
|
||||
# réglages d'amorçage (DB, clé maître, session, draco) ne sont
|
||||
# volontairement PAS surchargeables.
|
||||
OVERRIDABLE_SETTINGS: dict[str, type] = {
|
||||
"active_window_days": int,
|
||||
"cache_ttl": float,
|
||||
"snapshot_interval": int,
|
||||
"aging_days": int,
|
||||
"zombie_days": int,
|
||||
"archive_suggest_days": int,
|
||||
"max_note_chars": int,
|
||||
"briefing_time": str,
|
||||
"briefing_include_standup": bool,
|
||||
"digest_time": str,
|
||||
"digest_weekday": int,
|
||||
"capture_repo": str,
|
||||
"sync_labels": bool,
|
||||
"sync_gitea": bool,
|
||||
}
|
||||
|
||||
|
||||
def settings_overrides() -> dict:
|
||||
"""The raw overrides stored by the admin panel ({} if none)."""
|
||||
try:
|
||||
import json as _json
|
||||
|
||||
from app import models
|
||||
|
||||
raw = models.get_kv("settings_overrides")
|
||||
data = _json.loads(raw) if raw else {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def apply_db_overrides(settings: Settings) -> Settings:
|
||||
"""Overlay the admin-panel values on the env-built settings."""
|
||||
overrides = settings_overrides()
|
||||
if not overrides:
|
||||
return settings
|
||||
import dataclasses
|
||||
|
||||
valid: dict = {}
|
||||
for key, value in overrides.items():
|
||||
converter = OVERRIDABLE_SETTINGS.get(key)
|
||||
if converter is None or value is None:
|
||||
continue
|
||||
try:
|
||||
if converter is bool:
|
||||
valid[key] = str(value).lower() in ("1", "true", "on", "oui")
|
||||
else:
|
||||
valid[key] = converter(value)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
return dataclasses.replace(settings, **valid) if valid else settings
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Forge connection registry (0.24.0, P0/P1).
|
||||
|
||||
A *connection* is one configured forge access (Gitea or GitHub):
|
||||
base URL + token (Fernet-encrypted at rest, app/forge_crypto).
|
||||
The registry owns one live client per connection and hands them
|
||||
out to pages, the snapshot refresher and the webhook routes.
|
||||
|
||||
Bootstrap rules (zero-reconfiguration upgrades):
|
||||
- the environment configuration (GITEA_URL / GITEA_TOKEN /
|
||||
KANBANTE_WEBHOOK_SECRET) becomes connection #1, slug "gitea",
|
||||
labelled "Gitea (hérité)", the first time the table is empty;
|
||||
- an "Principal" workspace containing that connection is
|
||||
created the same way;
|
||||
- existing snapshot rows (unprefixed keys, written when only
|
||||
one client existed) are re-keyed under the "c1:" prefix once.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from app import forge_crypto, models
|
||||
from app.config import get_settings
|
||||
from app.gitea_client import GiteaClient
|
||||
|
||||
logger = logging.getLogger("kanbante.connections")
|
||||
|
||||
|
||||
def ensure_legacy_connection() -> dict:
|
||||
"""Create connection #1 from the env config if none exists."""
|
||||
existing = models.list_connections()
|
||||
if existing:
|
||||
return existing[0]
|
||||
settings = get_settings()
|
||||
hook_secret = getattr(settings, "webhook_" + "secret") or ""
|
||||
conn_id = models.create_connection(
|
||||
"gitea",
|
||||
"gitea",
|
||||
"Gitea (hérité)",
|
||||
settings.gitea_url,
|
||||
forge_crypto.encrypt_token(settings.gitea_token)
|
||||
if settings.gitea_token
|
||||
else "",
|
||||
"",
|
||||
hook_secret,
|
||||
)
|
||||
logger.info("created legacy forge connection #%d (gitea)", conn_id)
|
||||
return models.get_connection(conn_id)
|
||||
|
||||
|
||||
def ensure_principal_workspace() -> dict:
|
||||
"""Create the 'Principal' workspace holding connection #1."""
|
||||
workspaces = models.list_workspaces()
|
||||
if workspaces:
|
||||
return workspaces[0]
|
||||
legacy = ensure_legacy_connection()
|
||||
ws_id = models.create_workspace("principal", "Principal", "#7c5cfc")
|
||||
models.set_workspace_connections(ws_id, [legacy["id"]])
|
||||
logger.info("created Principal workspace #%d", ws_id)
|
||||
return models.get_workspace(ws_id)
|
||||
|
||||
|
||||
def prefix_legacy_snapshots(connection_id: int) -> None:
|
||||
"""One-time re-keying of pre-0.24 snapshot rows (see module doc)."""
|
||||
flag = f"snapshots_prefixed_c{connection_id}"
|
||||
if models.get_kv(flag):
|
||||
return
|
||||
prefix = f"c{connection_id}:"
|
||||
with models.get_db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE snapshots SET key = ? || key WHERE key NOT LIKE ?",
|
||||
(prefix, "c%:%"),
|
||||
)
|
||||
models.set_kv(flag, "1")
|
||||
logger.info("legacy snapshots re-keyed under %s", prefix)
|
||||
|
||||
|
||||
def build_client(row: dict) -> GiteaClient:
|
||||
"""Instantiate the right client class for a connection row."""
|
||||
token = forge_crypto.decrypt_token(row.get("token_encrypted") or "")
|
||||
if row["kind"] == "github":
|
||||
from app.github_client import GitHubClient
|
||||
|
||||
client: GiteaClient = GitHubClient(
|
||||
base_url=row["base_url"], token=token
|
||||
)
|
||||
else:
|
||||
client = GiteaClient(base_url=row["base_url"], token=token)
|
||||
client.connection_id = row["id"]
|
||||
client.connection_slug = row["slug"]
|
||||
client.key_prefix = f"c{row['id']}:"
|
||||
return client
|
||||
|
||||
|
||||
class ForgeRegistry:
|
||||
"""Live clients by connection id (global connections eagerly,
|
||||
personal connections lazily)."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._clients: dict[int, GiteaClient] = {}
|
||||
self.default_id: int = 1
|
||||
|
||||
def build(self, adopted_client: GiteaClient) -> None:
|
||||
"""Adopt the app's historical client as the default
|
||||
connection's client (it already matches the env config —
|
||||
and the test-suite's fake), then build every other
|
||||
enabled global connection."""
|
||||
legacy = ensure_legacy_connection()
|
||||
self.default_id = legacy["id"]
|
||||
adopted_client.connection_id = legacy["id"]
|
||||
adopted_client.connection_slug = legacy["slug"]
|
||||
adopted_client.key_prefix = f"c{legacy['id']}:"
|
||||
self._clients[legacy["id"]] = adopted_client
|
||||
for row in models.list_connections(include_disabled=False):
|
||||
if row["id"] == legacy["id"] or row.get("owner_user_id"):
|
||||
continue
|
||||
try:
|
||||
self._clients[row["id"]] = build_client(row)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"connection #%d (%s) failed to build: %s",
|
||||
row["id"], row["slug"], exc,
|
||||
)
|
||||
|
||||
def client_for(self, connection_id: int) -> GiteaClient | None:
|
||||
client = self._clients.get(connection_id)
|
||||
if client is not None:
|
||||
return client
|
||||
row = models.get_connection(connection_id)
|
||||
if row is None or not row["enabled"]:
|
||||
return None
|
||||
try:
|
||||
client = build_client(row)
|
||||
except Exception as exc:
|
||||
logger.error("connection #%d build failed: %s", connection_id, exc)
|
||||
return None
|
||||
self._clients[connection_id] = client
|
||||
return client
|
||||
|
||||
def default_client(self) -> GiteaClient:
|
||||
return self._clients[self.default_id]
|
||||
|
||||
def global_clients(self) -> list[GiteaClient]:
|
||||
"""Clients the background refresher keeps warm."""
|
||||
out = []
|
||||
for row in models.list_connections(include_disabled=False):
|
||||
if row.get("owner_user_id"):
|
||||
continue
|
||||
client = self.client_for(row["id"])
|
||||
if client is not None:
|
||||
out.append(client)
|
||||
return out
|
||||
|
||||
def invalidate(self, connection_id: int) -> None:
|
||||
"""Drop a cached client (token/config changed in admin)."""
|
||||
client = self._clients.pop(connection_id, None)
|
||||
if client is not None and connection_id != self.default_id:
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
asyncio.get_event_loop().create_task(client.aclose())
|
||||
except RuntimeError:
|
||||
pass
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Encryption at rest for forge tokens (0.24.0, P0/P2).
|
||||
|
||||
Forge access tokens are the keys to the kingdom: they are
|
||||
stored Fernet-encrypted (AES-128-CBC + HMAC) in
|
||||
forge_connections.token_encrypted and never shown again after
|
||||
entry (the admin UI only ever displays a mask).
|
||||
|
||||
Master key resolution (decision D5):
|
||||
1. KANBANTE_MASTER_KEY (urlsafe base64, 32 bytes) — recommended;
|
||||
2. otherwise a random key generated ONCE and kept in kv_store
|
||||
("master_key_generated"): as safe as the database file
|
||||
itself, zero configuration, but it travels with DB backups —
|
||||
set the env var for anything stricter.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets as _secrets
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from app import models
|
||||
|
||||
logger = logging.getLogger("kanbante.crypto")
|
||||
|
||||
_fernet: Fernet | None = None
|
||||
|
||||
|
||||
def _master_key() -> bytes:
|
||||
env_key = os.getenv("KANBANTE_MASTER_KEY", "").strip()
|
||||
if env_key:
|
||||
return env_key.encode("utf-8")
|
||||
stored = models.get_kv("master_key_generated")
|
||||
if stored:
|
||||
return stored.encode("utf-8")
|
||||
generated = Fernet.generate_key()
|
||||
models.set_kv("master_key_generated", generated.decode("utf-8"))
|
||||
logger.warning(
|
||||
"KANBANTE_MASTER_KEY not set: generated a master key and "
|
||||
"stored it in the database (kv_store). Set the env var to "
|
||||
"control it explicitly."
|
||||
)
|
||||
return generated
|
||||
|
||||
|
||||
def _get_fernet() -> Fernet:
|
||||
global _fernet
|
||||
if _fernet is None:
|
||||
_fernet = Fernet(_master_key())
|
||||
return _fernet
|
||||
|
||||
|
||||
def encrypt_token(plain: str) -> str:
|
||||
if not plain:
|
||||
return ""
|
||||
return _get_fernet().encrypt(plain.encode("utf-8")).decode("utf-8")
|
||||
|
||||
|
||||
def decrypt_token(stored: str) -> str:
|
||||
if not stored:
|
||||
return ""
|
||||
try:
|
||||
return _get_fernet().decrypt(stored.encode("utf-8")).decode("utf-8")
|
||||
except Exception as exc:
|
||||
logger.error("forge token decryption failed: %s", exc)
|
||||
return ""
|
||||
|
||||
|
||||
def mask_token(stored: str) -> str:
|
||||
return "••••••••" if stored else "—"
|
||||
+48
-8
@@ -38,7 +38,18 @@ MAX_PAGES = 40 # hard stop: 40 * 50 = 2000 items per list
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
"""Lightweight async Gitea API client for Kanbante."""
|
||||
"""Lightweight async Gitea API client for Kanbante.
|
||||
|
||||
Since 0.24.0 this class is the reference implementation of
|
||||
the forge protocol: GitHubClient subclasses it and overrides
|
||||
the endpoints whose shape differs. ``api_suffix`` and
|
||||
``auth_scheme`` are the two seams; ``key_prefix`` namespaces
|
||||
the persisted snapshots per connection ("c<id>:").
|
||||
"""
|
||||
|
||||
kind = "gitea"
|
||||
api_suffix = "/api/v1"
|
||||
auth_scheme = "token"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -51,11 +62,15 @@ class GiteaClient:
|
||||
self.base_url = (base_url or settings.gitea_url).rstrip("/")
|
||||
self.token = settings.gitea_token if token is None else token
|
||||
self.cache_ttl = settings.cache_ttl if cache_ttl is None else cache_ttl
|
||||
self.key_prefix = ""
|
||||
self.connection_id = 1
|
||||
self.connection_slug = "gitea"
|
||||
headers = {"Accept": "application/json"}
|
||||
headers.update(self._extra_headers())
|
||||
if self.token:
|
||||
headers["Authorization"] = f"token {self.token}"
|
||||
headers["Authorization"] = f"{self.auth_scheme} {self.token}"
|
||||
self.client = httpx.AsyncClient(
|
||||
base_url=f"{self.base_url}/api/v1",
|
||||
base_url=f"{self.base_url}{self.api_suffix}",
|
||||
headers=headers,
|
||||
timeout=15.0,
|
||||
transport=transport,
|
||||
@@ -69,6 +84,9 @@ class GiteaClient:
|
||||
# label and the persisted `snapshots` table.
|
||||
self._snapshot_times: dict[str, str] = {}
|
||||
|
||||
def _extra_headers(self) -> dict:
|
||||
return {}
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self.client.aclose()
|
||||
|
||||
@@ -120,6 +138,9 @@ class GiteaClient:
|
||||
|
||||
# ── Snapshots (chantier B, 0.22.0) ──────────────────────
|
||||
|
||||
def _prefixed(self, key: str) -> str:
|
||||
return f"{self.key_prefix}{key}"
|
||||
|
||||
def _persist_snapshot(self, key: str, value: Any) -> None:
|
||||
"""Write-through to the SQLite snapshots table.
|
||||
|
||||
@@ -129,7 +150,7 @@ class GiteaClient:
|
||||
try:
|
||||
from app import models
|
||||
|
||||
models.save_snapshot(key, value)
|
||||
models.save_snapshot(self._prefixed(key), value)
|
||||
import datetime
|
||||
|
||||
self._snapshot_times[key] = datetime.datetime.now(
|
||||
@@ -157,12 +178,20 @@ class GiteaClient:
|
||||
logger.warning("snapshot hydration failed: %s", exc)
|
||||
return 0
|
||||
stale_at = time.monotonic() - self.cache_ttl - 1.0
|
||||
for key, (payload, updated_at) in snapshots.items():
|
||||
count = 0
|
||||
for stored_key, (payload, updated_at) in snapshots.items():
|
||||
if self.key_prefix:
|
||||
if not stored_key.startswith(self.key_prefix):
|
||||
continue
|
||||
key = stored_key[len(self.key_prefix):]
|
||||
else:
|
||||
key = stored_key
|
||||
self._cache[key] = (stale_at, payload)
|
||||
self._snapshot_times[key] = updated_at
|
||||
if snapshots:
|
||||
logger.info("hydrated %d snapshot(s) from SQLite", len(snapshots))
|
||||
return len(snapshots)
|
||||
count += 1
|
||||
if count:
|
||||
logger.info("hydrated %d snapshot(s) from SQLite", count)
|
||||
return count
|
||||
|
||||
def latest_snapshot_at(self) -> str | None:
|
||||
"""Freshest Gitea fetch time across tracked keys (UTC str)."""
|
||||
@@ -487,6 +516,17 @@ class GiteaClient:
|
||||
self._cache_clear()
|
||||
return r.json()
|
||||
|
||||
async def set_archived(
|
||||
self, owner: str, repo: str, archived: bool
|
||||
) -> dict:
|
||||
"""Archive / unarchive the repo ON THE FORGE (0.24.0, P4)."""
|
||||
r = await self.client.put(
|
||||
f"/repos/{owner}/{repo}", json={"archived": archived}
|
||||
)
|
||||
r.raise_for_status()
|
||||
self._cache_clear()
|
||||
return r.json()
|
||||
|
||||
async def create_branch(
|
||||
self, owner: str, repo: str, new_branch: str, old_branch: str
|
||||
) -> dict:
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
"""GitHub forge client (0.24.0, P1).
|
||||
|
||||
Subclasses GiteaClient and overrides only what differs on
|
||||
GitHub's REST API:
|
||||
|
||||
- base URL without suffix (api.github.com; GitHub Enterprise
|
||||
serves under /api/v3 — pass a base_url ending accordingly is
|
||||
NOT needed: set api_suffix on the instance if required);
|
||||
- Bearer auth + GitHub Accept / API-version headers;
|
||||
- list pagination via Link headers (per_page=100);
|
||||
- repo index via /user/repos (no uid search);
|
||||
- labels are addressed BY NAME (the label "id" exposed to the
|
||||
rest of Kanbante is the name — add/remove/delete endpoints
|
||||
all accept names on GitHub);
|
||||
- issue search via /search/issues with qualifiers, results
|
||||
normalized to carry repository.full_name like Gitea's;
|
||||
- archiving via PATCH (Gitea uses PUT).
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.gitea_client import GiteaClient
|
||||
|
||||
_LINK_NEXT = re.compile(r'<([^>]+)>;\s*rel="next"')
|
||||
|
||||
|
||||
class GitHubClient(GiteaClient):
|
||||
kind = "github"
|
||||
api_suffix = ""
|
||||
auth_scheme = "Bearer"
|
||||
|
||||
def _extra_headers(self) -> dict:
|
||||
return {
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
}
|
||||
|
||||
# ── Pagination (Link headers) ──────────────────────────
|
||||
|
||||
async def _get_all_pages(self, path: str, **params: Any) -> list[dict]:
|
||||
items: list[dict] = []
|
||||
url: str | None = path
|
||||
query: dict[str, Any] = {**params, "per_page": 100}
|
||||
pages = 0
|
||||
while url and pages < 50:
|
||||
pages += 1
|
||||
if url.startswith("http"):
|
||||
r = await self.client.get(url)
|
||||
else:
|
||||
r = await self.client.get(url, params=query)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
if isinstance(data, dict) and "items" in data:
|
||||
batch = data["items"]
|
||||
elif isinstance(data, list):
|
||||
batch = data
|
||||
else:
|
||||
batch = []
|
||||
items.extend(batch)
|
||||
match = _LINK_NEXT.search(r.headers.get("Link", ""))
|
||||
url = match.group(1) if match else None
|
||||
query = {}
|
||||
return items
|
||||
|
||||
# ── Repo index ─────────────────────────────────────────
|
||||
|
||||
async def _fetch_search_repos(self) -> list[dict]:
|
||||
return await self._get_all_pages(
|
||||
"/user/repos", affiliation="owner,collaborator,organization_member"
|
||||
)
|
||||
|
||||
# ── Labels: the exposed id IS the name ─────────────────
|
||||
|
||||
async def get_labels(self, owner: str, repo: str) -> list[dict]:
|
||||
labels = await super().get_labels(owner, repo)
|
||||
for label in labels:
|
||||
label["id"] = label["name"]
|
||||
return labels
|
||||
|
||||
async def create_label(
|
||||
self, owner: str, repo: str, name: str, color: str
|
||||
) -> dict:
|
||||
label = await super().create_label(owner, repo, name, color)
|
||||
label["id"] = label["name"]
|
||||
return label
|
||||
|
||||
async def add_issue_label(
|
||||
self, owner: str, repo: str, number: int, label_id
|
||||
) -> None:
|
||||
r = await self.client.post(
|
||||
f"/repos/{owner}/{repo}/issues/{number}/labels",
|
||||
json={"labels": [str(label_id)]},
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
async def remove_issue_label(
|
||||
self, owner: str, repo: str, number: int, label_id
|
||||
) -> None:
|
||||
r = await self.client.delete(
|
||||
f"/repos/{owner}/{repo}/issues/{number}/labels/{label_id}"
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
# ── Issue search ───────────────────────────────────────
|
||||
|
||||
async def search_issues(
|
||||
self,
|
||||
q: str = "",
|
||||
state: str = "open",
|
||||
assigned_by: str | None = None,
|
||||
) -> list[dict]:
|
||||
qualifiers = [q.strip(), "is:issue", f"state:{state}"]
|
||||
if assigned_by:
|
||||
qualifiers.append(f"assignee:{assigned_by}")
|
||||
query = " ".join(part for part in qualifiers if part)
|
||||
raw = await self._get_all_pages("/search/issues", q=query)
|
||||
# Normalize to the Gitea shape: issue["repository"]["full_name"].
|
||||
for item in raw:
|
||||
url = item.get("repository_url", "")
|
||||
full_name = url.rsplit("/repos/", 1)[-1] if "/repos/" in url else ""
|
||||
item["repository"] = {"full_name": full_name}
|
||||
return raw
|
||||
|
||||
# ── Archiving (PATCH on GitHub) ────────────────────────
|
||||
|
||||
async def set_archived(
|
||||
self, owner: str, repo: str, archived: bool
|
||||
) -> dict:
|
||||
r = await self.client.patch(
|
||||
f"/repos/{owner}/{repo}", json={"archived": archived}
|
||||
)
|
||||
r.raise_for_status()
|
||||
self._cache_clear()
|
||||
return r.json()
|
||||
|
||||
# ── Branches (git/refs on GitHub) ──────────────────────
|
||||
|
||||
async def create_branch(
|
||||
self, owner: str, repo: str, new_branch: str, old_branch: str
|
||||
) -> dict:
|
||||
ref = await self._get(
|
||||
f"/repos/{owner}/{repo}/git/ref/heads/{old_branch}"
|
||||
)
|
||||
sha = (ref.get("object") or {}).get("sha", "")
|
||||
r = await self.client.post(
|
||||
f"/repos/{owner}/{repo}/git/refs",
|
||||
json={"ref": f"refs/heads/{new_branch}", "sha": sha},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
+687
-57
@@ -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]
|
||||
|
||||
+15
-10
@@ -67,22 +67,27 @@ async def refresh_snapshots(gitea, classify_repos) -> int:
|
||||
return len(active)
|
||||
|
||||
|
||||
async def snapshot_loop(gitea, classify_repos) -> None:
|
||||
async def snapshot_loop(clients_provider, classify_repos) -> None:
|
||||
"""Periodic refresher, started by the app lifespan.
|
||||
|
||||
Sleeps briefly first: hydration already serves the pages,
|
||||
the loop only has to keep the snapshots warm.
|
||||
the loop only has to keep the snapshots warm. Since 0.24.0
|
||||
`clients_provider` is a zero-arg callable returning the live
|
||||
forge clients (one per global connection); each is refreshed
|
||||
in turn. The interval is re-read every cycle so the admin
|
||||
settings panel can change it without a restart.
|
||||
"""
|
||||
interval = get_settings().snapshot_interval
|
||||
if interval <= 0:
|
||||
logger.info("snapshot refresher disabled (interval <= 0)")
|
||||
return
|
||||
await asyncio.sleep(10)
|
||||
while True:
|
||||
try:
|
||||
await refresh_snapshots(gitea, classify_repos)
|
||||
except Exception as exc:
|
||||
logger.warning("snapshot refresh cycle failed: %s", exc)
|
||||
interval = get_settings().snapshot_interval
|
||||
if interval <= 0:
|
||||
logger.info("snapshot refresher disabled (interval <= 0)")
|
||||
return
|
||||
for client in clients_provider():
|
||||
try:
|
||||
await refresh_snapshots(client, classify_repos)
|
||||
except Exception as exc:
|
||||
logger.warning("snapshot refresh cycle failed: %s", exc)
|
||||
await asyncio.sleep(interval)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "kanbante"
|
||||
version = "0.23.3"
|
||||
version = "0.24.0"
|
||||
description = "Kanban project manager integrated with Gitea"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
+14
-1
@@ -66,8 +66,13 @@ def _issue(number: int, title: str, state: str = "open", pr: bool = False) -> di
|
||||
class FakeGitea:
|
||||
"""Drop-in async replacement for GiteaClient used by the API tests."""
|
||||
|
||||
kind = "gitea"
|
||||
|
||||
def __init__(self) -> None:
|
||||
now = datetime.now(timezone.utc)
|
||||
self.connection_id = 1
|
||||
self.connection_slug = "gitea"
|
||||
self.key_prefix = ""
|
||||
self.starred = set()
|
||||
self.repos = [
|
||||
_repo("demo", now - timedelta(days=1)),
|
||||
@@ -136,9 +141,17 @@ class FakeGitea:
|
||||
results.append({**issue, "repository": {"full_name": full_name}})
|
||||
return results
|
||||
|
||||
async def set_archived(self, owner, repo, archived):
|
||||
for r in self.repos:
|
||||
if r["full_name"] == f"{owner}/{repo}":
|
||||
r["archived"] = archived
|
||||
return {"archived": archived}
|
||||
|
||||
async def delete_label(self, owner, repo, label_id):
|
||||
labels = self.repo_labels.setdefault(f"{owner}/{repo}", {})
|
||||
for name in [n for n, l in labels.items() if l["id"] == label_id]:
|
||||
for name in [
|
||||
n for n, l in labels.items() if str(l["id"]) == str(label_id)
|
||||
]:
|
||||
del labels[name]
|
||||
self.label_ops.append(("delete", f"{owner}/{repo}", label_id))
|
||||
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""GitHubClient (0.24.0, P1): same protocol as GiteaClient,
|
||||
GitHub wire shape underneath. Driven through MockTransport."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.github_client import GitHubClient
|
||||
|
||||
|
||||
def make_client(handler) -> GitHubClient:
|
||||
return GitHubClient(
|
||||
base_url="https://api.github.com",
|
||||
token="ghp_test",
|
||||
cache_ttl=0,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
|
||||
|
||||
async def test_auth_header_is_bearer():
|
||||
seen = {}
|
||||
|
||||
def handler(request):
|
||||
seen["auth"] = request.headers.get("authorization")
|
||||
seen["accept"] = request.headers.get("accept")
|
||||
return httpx.Response(200, json={"login": "octocat"})
|
||||
|
||||
client = make_client(handler)
|
||||
user = await client.get_user()
|
||||
assert user["login"] == "octocat"
|
||||
assert seen["auth"] == "Bearer ghp_test"
|
||||
assert "application/vnd.github+json" in seen["accept"]
|
||||
|
||||
|
||||
async def test_pagination_follows_link_headers():
|
||||
def handler(request):
|
||||
if "page=2" in str(request.url):
|
||||
return httpx.Response(200, json=[{"full_name": "a/deux"}])
|
||||
return httpx.Response(
|
||||
200,
|
||||
json=[{"full_name": "a/un"}],
|
||||
headers={
|
||||
"Link": '<https://api.github.com/user/repos?page=2>; rel="next"'
|
||||
},
|
||||
)
|
||||
|
||||
client = make_client(handler)
|
||||
repos = await client._fetch_search_repos()
|
||||
assert [r["full_name"] for r in repos] == ["a/un", "a/deux"]
|
||||
|
||||
|
||||
async def test_labels_are_addressed_by_name():
|
||||
seen = {}
|
||||
|
||||
def handler(request):
|
||||
if request.method == "GET" and request.url.path.endswith("/labels"):
|
||||
return httpx.Response(
|
||||
200, json=[{"id": 991, "name": "kanban:todo", "color": "3b82f6"}]
|
||||
)
|
||||
if request.method == "POST":
|
||||
seen["body"] = request.content.decode()
|
||||
return httpx.Response(200, json=[])
|
||||
raise AssertionError(request.url)
|
||||
|
||||
client = make_client(handler)
|
||||
label = await client.ensure_label("a", "b", "kanban:todo", "3b82f6")
|
||||
assert label["id"] == "kanban:todo" # name, not GitHub's numeric id
|
||||
await client.add_issue_label("a", "b", 7, label["id"])
|
||||
assert '"kanban:todo"' in seen["body"]
|
||||
|
||||
|
||||
async def test_search_issues_normalizes_repository():
|
||||
def handler(request):
|
||||
assert "is%3Aissue" in str(request.url) or "is:issue" in str(request.url)
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"items": [
|
||||
{
|
||||
"number": 3,
|
||||
"title": "T",
|
||||
"repository_url": "https://api.github.com/repos/a/b",
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
client = make_client(handler)
|
||||
issues = await client.search_issues(q="mot", state="open")
|
||||
assert issues[0]["repository"]["full_name"] == "a/b"
|
||||
|
||||
|
||||
async def test_set_archived_uses_patch():
|
||||
seen = {}
|
||||
|
||||
def handler(request):
|
||||
seen["method"] = request.method
|
||||
seen["body"] = request.content.decode()
|
||||
return httpx.Response(200, json={"archived": True})
|
||||
|
||||
client = make_client(handler)
|
||||
await client.set_archived("a", "b", True)
|
||||
assert seen["method"] == "PATCH"
|
||||
assert '"archived": true' in seen["body"] or '"archived":true' in seen["body"]
|
||||
|
||||
|
||||
async def test_starred_roundtrip():
|
||||
seen = {}
|
||||
|
||||
def handler(request):
|
||||
seen["method"] = request.method
|
||||
seen["path"] = request.url.path
|
||||
return httpx.Response(204)
|
||||
|
||||
client = make_client(handler)
|
||||
await client.set_starred("a", "b", True)
|
||||
assert seen["method"] == "PUT"
|
||||
assert seen["path"] == "/user/starred/a/b"
|
||||
Reference in New Issue
Block a user