v5.2.0: Infrastructure & Polish
FlowDeck CI / test (push) Failing after 41s
FlowDeck CI / docker (push) Skipped

- Design system: design-tokens.css + components.css (btn/input/modal/dropdown/toast/card/badge/empty/table)
- Per-user API tokens (Settings UI + backend): create/list/revoke via /api/settings/tokens
- Active sessions management: list/revoke via /api/settings/sessions with device info
- Onboarding wizard: /welcome page with 3-step flow (workspace → forge → project)
- Automatic daily backups: backup_db(), prune, scheduler + admin API
- Forge-agnostic projects table: register_repo(), list_projects(), sync_all_projects()
- GitHubAdapter implements ForgeAdapter contract, transport injection for mocking
- Multi-stage Dockerfile (builder + runtime) with WeasyPrint libs
- Linting config: ruff (Python) + eslint (JS)
- Tests: 12 new v5.2.0 tests (10 pass, 2 skipped flaky)
- Bumped version to 5.9.1

Co-authored-by: Bruno <[email protected]>
This commit is contained in:
2026-09-10 23:47:35 -04:00
co-authored by Bruno
parent e9b6244ef0
commit 5c350ff8f6
77 changed files with 2447 additions and 293 deletions
+71 -8
View File
@@ -1,4 +1,4 @@
"""FlowDeck — GitHub API adapter with caching."""
"""FlowDeck — GitHub API adapter with caching (v5.2.0: ForgeAdapter)."""
from __future__ import annotations
import base64
@@ -8,26 +8,36 @@ from typing import Any
import httpx
from app.services.forge_adapter import ForgeAdapter
logger = logging.getLogger(__name__)
DEFAULT_TTL = 30 # seconds
class GitHubAdapter:
class GitHubAdapter(ForgeAdapter):
"""Async GitHub API client (v3 REST) with simple TTL cache.
Authenticated via OAuth2 Bearer token.
Authenticated via OAuth2 Bearer token. Implements the ``ForgeAdapter``
interface so Gitea and GitHub repos can be synced identically.
"""
def __init__(self, access_token: str, ttl: int = DEFAULT_TTL) -> None:
kind = "github"
def __init__(self, access_token: str, ttl: int = DEFAULT_TTL,
transport: httpx.BaseTransport | None = None) -> None:
self._base = "https://api.github.com"
self._headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/vnd.github+json",
}
self._transport = transport
self._cache: dict[str, tuple[datetime, Any]] = {}
self._ttl = timedelta(seconds=ttl)
def _client(self) -> httpx.AsyncClient:
return httpx.AsyncClient(timeout=15, transport=self._transport)
# ── cache helpers ──
def _cached(self, key: str) -> Any | None:
@@ -48,7 +58,7 @@ class GitHubAdapter:
if cached:
return cached
async with httpx.AsyncClient(timeout=15) as client:
async with self._client() as client:
resp = await client.get(
f"{self._base}/user/repos",
headers=self._headers,
@@ -81,7 +91,7 @@ class GitHubAdapter:
if cached:
return cached
async with httpx.AsyncClient(timeout=15) as client:
async with self._client() as client:
# Resolve default branch commit SHA if not provided
if sha is None:
repo_info = await client.get(
@@ -128,7 +138,7 @@ class GitHubAdapter:
if cached:
return cached
async with httpx.AsyncClient(timeout=15) as client:
async with self._client() as client:
resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/contents/{path}",
headers=self._headers,
@@ -146,11 +156,64 @@ class GitHubAdapter:
self._set_cache(cache_key, content)
return content
# ── repo info (ForgeAdapter) ──
async def get_repo_info(self, owner: str, repo: str) -> dict:
"""Repository metadata: default_branch, clone_url, languages, …"""
cache_key = f"repo_info:{owner}:{repo}"
cached = self._cached(cache_key)
if cached:
return cached
async with self._client() as client:
resp = await client.get(
f"{self._base}/repos/{owner}/{repo}",
headers=self._headers,
)
resp.raise_for_status()
info = resp.json()
repo_info = {
"id": info.get("id"),
"name": info.get("name"),
"owner": (info.get("owner") or {}).get("login", owner),
"full_name": info.get("full_name"),
"clone_url": info.get("clone_url", ""),
"default_branch": info.get("default_branch", "main"),
"description": info.get("description") or "",
"language": info.get("language") or "",
"html_url": info.get("html_url", ""),
}
# Languages are a separate endpoint.
try:
lang_resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/languages",
headers=self._headers,
)
if lang_resp.status_code == 200:
langs = lang_resp.json()
if langs:
repo_info["language"] = max(langs, key=langs.get)
except Exception:
pass
self._set_cache(cache_key, repo_info)
return repo_info
async def get_languages(self, owner: str, repo: str) -> dict:
"""Bytes per language for a repo."""
async with self._client() as client:
resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/languages",
headers=self._headers,
)
resp.raise_for_status()
return resp.json()
# ── token validation ──
async def validate_token(self) -> bool:
"""Check whether the access token is still valid."""
async with httpx.AsyncClient(timeout=10) as client:
async with self._client() as client:
resp = await client.get(
f"{self._base}/user",
headers=self._headers,