feat: FlowDeck v0.2.0 — Kanban Gitea intégré

- Dashboard projets Gitea (user + orgs)
- Board Kanban 5 colonnes avec drag & drop (SortableJS)
- Sync bidirectionnelle colonnes ↔ labels Gitea
- Filtres milestone/label/assignee
- Notes Markdown par projet
- API REST (/api/health, /api/move, /api/col-mapping…)
- Colonnes custom, WIP limits
- Thème clair/sombre
- Cache Gitea TTL
- Docker + docker-compose
- Tests pytest
- Docs: README, ARCHITECTURE, CHANGELOG, ROADMAP
This commit is contained in:
2026-07-08 08:41:31 -04:00
parent 90f090c785
commit 336c7d8dfe
31 changed files with 1682 additions and 2 deletions
+1
View File
@@ -0,0 +1 @@
"""FlowDeck — Application package."""
+41
View File
@@ -0,0 +1,41 @@
"""FlowDeck — Configuration via pydantic-settings."""
from __future__ import annotations
from pathlib import Path
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env", env_file_encoding="utf-8", extra="ignore"
)
# Gitea
gitea_url: str = "https://git.dracodev.net"
gitea_token: str = "change-me"
gitea_oauth_client_id: str = ""
gitea_oauth_client_secret: str = ""
gitea_webhook_secret: str = ""
# App
app_secret_key: str = "change-me-to-random"
app_host: str = "0.0.0.0"
app_port: int = 8080
log_level: str = "INFO"
default_lang: str = "fr"
# Database
database_url: str = "sqlite:////data/flowdeck.db"
# Sync
sync_interval: int = 60
gitea_cache_ttl: int = 30
@property
def db_path(self) -> Path:
if self.database_url.startswith("sqlite:///"):
return Path(self.database_url.replace("sqlite:///", "/"))
return Path("/data/flowdeck.db")
settings = Settings()
+79
View File
@@ -0,0 +1,79 @@
"""FlowDeck — Database layer (SQLite with WAL mode)."""
from __future__ import annotations
import sqlite3
from contextlib import contextmanager
from pathlib import Path
def get_db_path() -> Path:
from app.config import settings
return settings.db_path
def init_db():
"""Create tables if they don't exist."""
db_path = get_db_path()
db_path.parent.mkdir(parents=True, exist_ok=True)
with get_conn() as conn:
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
conn.executescript("""
CREATE TABLE IF NOT EXISTS boards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_owner TEXT NOT NULL,
project_name TEXT NOT NULL,
columns_json TEXT NOT NULL DEFAULT '["Backlog","À faire","En cours","Révision","Terminé"]',
wip_limits_json TEXT DEFAULT '{}',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(project_owner, project_name)
);
CREATE TABLE IF NOT EXISTS cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
board_id INTEGER NOT NULL REFERENCES boards(id),
gitea_issue_id INTEGER,
column_name TEXT NOT NULL DEFAULT 'Backlog',
position INTEGER NOT NULL DEFAULT 0,
priority TEXT DEFAULT 'Medium',
due_date TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_owner TEXT NOT NULL,
project_name TEXT NOT NULL,
title TEXT NOT NULL DEFAULT 'Notes',
content TEXT NOT NULL DEFAULT '',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(project_owner, project_name, title)
);
CREATE TABLE IF NOT EXISTS col_mapping (
id INTEGER PRIMARY KEY AUTOINCREMENT,
board_id INTEGER NOT NULL REFERENCES boards(id),
column_name TEXT NOT NULL,
gitea_label TEXT NOT NULL,
close_issue BOOLEAN NOT NULL DEFAULT 0,
UNIQUE(board_id, column_name)
);
""")
conn.commit()
@contextmanager
def get_conn():
"""Yield a SQLite connection (WAL mode, foreign keys on)."""
db_path = get_db_path()
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
try:
yield conn
finally:
conn.close()
+54
View File
@@ -0,0 +1,54 @@
"""FlowDeck — Kanban léger intégré à Gitea."""
from __future__ import annotations
import logging
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.db import init_db
from app.routers import dashboard, board, notes, api
# ── logging ──
logging.basicConfig(
level=getattr(logging, settings.log_level.upper(), logging.INFO),
format="%(asctime)s [%(levelname)s] %(message)s",
)
logger = logging.getLogger(__name__)
# ── app ──
app = FastAPI(
title="FlowDeck",
version="0.2.0",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
)
# ponytail: CORS for LAN access
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# ── routers ──
app.include_router(dashboard.router)
app.include_router(board.router)
app.include_router(notes.router)
app.include_router(api.router)
# ── static ──
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.on_event("startup")
async def startup():
init_db()
logger.info("FlowDeck started on port %d", settings.app_port)
+1
View File
@@ -0,0 +1 @@
"""FlowDeck — Models."""
+1
View File
@@ -0,0 +1 @@
"""FlowDeck — Routers."""
+232
View File
@@ -0,0 +1,232 @@
"""FlowDeck — API REST v1."""
from __future__ import annotations
import json
import logging
from datetime import datetime
from fastapi import APIRouter, HTTPException, Query
from app.config import settings
from app.db import get_conn
from app.services.gitea_client import gitea
logger = logging.getLogger(__name__)
router = APIRouter(tags=["api"], prefix="/api")
@router.get("/health")
async def health():
"""Health check: DB + Gitea connectivity."""
db_ok = False
gitea_ok = False
try:
with get_conn() as conn:
conn.execute("SELECT 1")
db_ok = True
except Exception:
pass
try:
await gitea.get_user_repos(page=1, limit=1)
gitea_ok = True
except Exception:
pass
return {
"status": "ok" if (db_ok and gitea_ok) else "degraded",
"version": "0.2.0",
"db": db_ok,
"gitea": gitea_ok,
}
@router.get("/stats")
async def stats():
"""Global stats for dashboard."""
with get_conn() as conn:
board_count = conn.execute("SELECT COUNT(*) as c FROM boards").fetchone()["c"]
card_count = conn.execute("SELECT COUNT(*) as c FROM cards").fetchone()["c"]
note_count = conn.execute("SELECT COUNT(*) as c FROM notes").fetchone()["c"]
return {
"boards": board_count,
"cards": card_count,
"notes": note_count,
}
@router.get("/projects")
async def projects(search: str = Query(default=""), show_archived: bool = Query(default=False)):
"""List Gitea projects (JSON)."""
try:
repos = await gitea.get_user_repos(page=1, limit=50)
orgs = await gitea.get_user_orgs()
for org in orgs:
try:
org_repos = await gitea.get_org_repos(org["username"])
for r in org_repos:
r["_org"] = org["username"]
repos.extend(org_repos)
except Exception:
pass
if search:
q = search.lower()
repos = [r for r in repos if q in r.get("full_name", "").lower() or q in (r.get("description") or "").lower()]
if not show_archived:
repos = [r for r in repos if not r.get("archived", False)]
repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True)
except Exception:
repos = []
return {"projects": repos}
@router.post("/move")
async def move_card(
owner: str = Query(...),
repo: str = Query(...),
issue_id: int = Query(...),
column: str = Query(...),
):
"""Move a card to a column. Updates DB position + Gitea labels if mapped."""
with get_conn() as conn:
board = conn.execute(
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
(owner, repo),
).fetchone()
if not board:
raise HTTPException(status_code=404, detail="Board not found")
board_id = board["id"]
# Upsert card
existing = conn.execute(
"SELECT id FROM cards WHERE board_id=? AND gitea_issue_id=?",
(board_id, issue_id),
).fetchone()
if existing:
conn.execute(
"UPDATE cards SET column_name=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
(column, existing["id"]),
)
else:
conn.execute(
"INSERT INTO cards (board_id, gitea_issue_id, column_name, position) VALUES (?, ?, ?, 0)",
(board_id, issue_id, column),
)
# Check label mapping for sync to Gitea
mapping = conn.execute(
"SELECT gitea_label, close_issue FROM col_mapping WHERE board_id=? AND column_name=?",
(board_id, column),
).fetchone()
conn.commit()
# Sync to Gitea
if mapping:
try:
if mapping["close_issue"]:
await gitea.close_issue(owner, repo, issue_id)
else:
# Get current labels, add mapped label
issue = await gitea.get_issue(owner, repo, issue_id)
current_labels = [lbl["name"] for lbl in issue.get("labels", [])]
# Remove other status labels, add new one
status_labels = await _get_status_labels(owner, repo, board_id)
filtered = [l for l in current_labels if l not in status_labels]
filtered.append(mapping["gitea_label"])
await gitea.update_issue_labels(owner, repo, issue_id, filtered)
except Exception as e:
logger.warning("Gitea sync failed: %s", e)
return {"status": "ok", "issue_id": issue_id, "column": column}
async def _get_status_labels(owner: str, repo: str, board_id: int) -> list[str]:
"""Get all status labels configured for this board."""
with get_conn() as conn:
rows = conn.execute(
"SELECT gitea_label FROM col_mapping WHERE board_id=?",
(board_id,),
).fetchall()
return [r["gitea_label"] for r in rows]
@router.post("/col-mapping")
async def set_col_mapping(
owner: str = Query(...),
repo: str = Query(...),
column: str = Query(...),
gitea_label: str = Query(...),
close_issue: bool = Query(default=False),
):
"""Set column-to-label mapping."""
with get_conn() as conn:
board = conn.execute(
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
(owner, repo),
).fetchone()
if not board:
raise HTTPException(status_code=404, detail="Board not found")
board_id = board["id"]
conn.execute(
"""INSERT INTO col_mapping (board_id, column_name, gitea_label, close_issue)
VALUES (?, ?, ?, ?)
ON CONFLICT(board_id, column_name)
DO UPDATE SET gitea_label=excluded.gitea_label, close_issue=excluded.close_issue""",
(board_id, column, gitea_label, int(close_issue)),
)
conn.commit()
return {"status": "ok", "column": column, "label": gitea_label}
@router.get("/board-config/{owner}/{repo}")
async def get_board_config(owner: str, repo: str):
"""Get board columns and WIP limits."""
with get_conn() as conn:
board = conn.execute(
"SELECT * FROM boards WHERE project_owner=? AND project_name=?",
(owner, repo),
).fetchone()
if not board:
return {"columns": ["Backlog", "À faire", "En cours", "Révision", "Terminé"], "wip_limits": {}}
mappings = conn.execute(
"SELECT * FROM col_mapping WHERE board_id=?", (board["id"],)
).fetchall()
return {
"columns": json.loads(board["columns_json"]),
"wip_limits": json.loads(board["wip_limits_json"] or "{}"),
"col_mappings": [dict(m) for m in mappings],
}
@router.post("/board-config/{owner}/{repo}")
async def update_board_config(
owner: str,
repo: str,
columns: str = Query(default=""),
wip_limits: str = Query(default=""),
):
"""Update board columns and WIP limits."""
with get_conn() as conn:
board = conn.execute(
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
(owner, repo),
).fetchone()
if not board:
conn.execute(
"INSERT INTO boards (project_owner, project_name, columns_json, wip_limits_json) VALUES (?, ?, ?, ?)",
(owner, repo, columns, wip_limits),
)
else:
conn.execute(
"UPDATE boards SET columns_json=?, wip_limits_json=? WHERE id=?",
(columns, wip_limits, board["id"]),
)
conn.commit()
return {"status": "ok"}
+142
View File
@@ -0,0 +1,142 @@
"""FlowDeck — Board Kanban."""
from __future__ import annotations
import json
import logging
from datetime import datetime
from fastapi import APIRouter, Request, HTTPException
from fastapi.responses import HTMLResponse
from app.db import get_conn
from app.services.gitea_client import gitea
logger = logging.getLogger(__name__)
router = APIRouter(tags=["board"], prefix="/board")
def _ensure_board(owner: str, repo: str) -> dict:
"""Get or create a board row, return columns config."""
with get_conn() as conn:
row = conn.execute(
"SELECT id, columns_json FROM boards WHERE project_owner=? AND project_name=?",
(owner, repo),
).fetchone()
if not row:
cur = conn.execute(
"INSERT INTO boards (project_owner, project_name) VALUES (?, ?)",
(owner, repo),
)
conn.commit()
return {"id": cur.lastrowid, "columns": ["Backlog", "À faire", "En cours", "Révision", "Terminé"]}
return {"id": row["id"], "columns": json.loads(row["columns_json"])}
def _get_cards(board_id: int) -> list[dict]:
"""Get all cards for a board, ordered by column + position."""
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM cards WHERE board_id=? ORDER BY column_name, position",
(board_id,),
).fetchall()
return [dict(r) for r in rows]
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
async def board(
request: Request,
owner: str,
repo: str,
milestone: str = "",
label: str = "",
assignee: str = "",
):
"""Kanban board for a Gitea project."""
try:
# Get issues
issues = await gitea.get_issues(owner, repo, state="all")
# Get labels
labels = await gitea.get_labels(owner, repo)
# Get milestones
milestones = await gitea.get_milestones(owner, repo)
# Filter by milestone
if milestone:
issues = [i for i in issues if i.get("milestone", {}).get("title") == milestone]
# Filter by label
if label:
issues = [i for i in issues if any(lbl["name"] == label for lbl in i.get("labels", []))]
# Filter by assignee
if assignee:
issues = [i for i in issues if i.get("assignee", {}).get("login") == assignee]
# Separate PRs from issues (ponytail: PRs are also in /issues endpoint)
prs = [i for i in issues if "pull_request" in i]
issues_only = [i for i in issues if "pull_request" not in i]
# Get board config
board_info = _ensure_board(owner, repo)
columns = board_info["columns"]
cards = _get_cards(board_info["id"])
# Map issues to columns
column_map: dict[str, list[dict]] = {col: [] for col in columns}
card_by_issue = {c["gitea_issue_id"]: c for c in cards if c["gitea_issue_id"]}
for issue in issues_only:
# Determine column from issue state/labels first, then from DB position
col = _issue_column(issue, columns, board_info["id"])
issue["_col"] = col
issue["_card"] = card_by_issue.get(issue["number"])
column_map[col].append(issue)
except Exception as e:
logger.error("Board error: %s", e)
return HTMLResponse(f"<p class='error'>Erreur: {e}</p>", status_code=500)
from jinja2 import Environment, FileSystemLoader
from datetime import datetime
from app.config import settings
env = Environment(loader=FileSystemLoader("app/templates"))
template = env.get_template("board.html")
return template.render(
request=request,
owner=owner,
repo=repo,
columns=columns,
column_map=column_map,
labels=labels,
milestones=milestones,
current_milestone=milestone,
current_label=label,
current_assignee=assignee,
prs=prs,
settings=settings,
now=datetime.now().isoformat()[:10],
)
def _issue_column(issue: dict, columns: list[str], board_id: int) -> str:
"""Determine which column an issue belongs to."""
# ponytail: closed = Terminé, else check label mapping
if issue.get("state") == "closed":
return "Terminé" if "Terminé" in columns else columns[-1]
# Check label-to-column mapping in DB
issue_labels = [lbl["name"] for lbl in issue.get("labels", [])]
with get_conn() as conn:
for lbl in issue_labels:
row = conn.execute(
"SELECT column_name FROM col_mapping WHERE board_id=? AND gitea_label=?",
(board_id, lbl),
).fetchone()
if row and row["column_name"] in columns:
return row["column_name"]
# Default: Backlog
return columns[0] if columns else "Backlog"
+60
View File
@@ -0,0 +1,60 @@
"""FlowDeck — Dashboard: liste des projets Gitea."""
from __future__ import annotations
import logging
from fastapi import APIRouter, Request, HTTPException, Query
from fastapi.responses import HTMLResponse
from app.services.gitea_client import gitea
logger = logging.getLogger(__name__)
router = APIRouter(tags=["dashboard"])
@router.get("/", response_class=HTMLResponse)
async def dashboard(
request: Request,
search: str = Query(default=""),
show_archived: bool = Query(default=False),
):
"""Dashboard: liste tous les projets Gitea (user + orgs)."""
try:
# User repos
repos = await gitea.get_user_repos(page=1, limit=50)
# Org repos
orgs = await gitea.get_user_orgs()
for org in orgs:
try:
org_repos = await gitea.get_org_repos(org["username"])
for r in org_repos:
r["_org"] = org["username"]
repos.extend(org_repos)
except Exception:
logger.debug("Skip org %s", org.get("username"))
# Filter
if search:
q = search.lower()
repos = [r for r in repos if q in r.get("full_name", "").lower() or q in (r.get("description") or "").lower()]
if not show_archived:
repos = [r for r in repos if not r.get("archived", False)]
# Sort by updated_at desc
repos.sort(key=lambda r: r.get("updated_at", ""), reverse=True)
except Exception as e:
logger.error("Dashboard error: %s", e)
repos = []
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
template = env.get_template("dashboard.html")
return template.render(
request=request,
repos=repos,
search=search,
show_archived=show_archived,
)
+48
View File
@@ -0,0 +1,48 @@
"""FlowDeck — Notes markdown par projet."""
from __future__ import annotations
import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from app.db import get_conn
logger = logging.getLogger(__name__)
router = APIRouter(tags=["notes"], prefix="/notes")
@router.get("/{owner}/{repo}", response_class=HTMLResponse)
async def get_notes(request: Request, owner: str, repo: str):
with get_conn() as conn:
row = conn.execute(
"SELECT content FROM notes WHERE project_owner=? AND project_name=? AND title='Notes'",
(owner, repo),
).fetchone()
content = row["content"] if row else ""
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
template = env.get_template("notes.html")
return template.render(request=request, owner=owner, repo=repo, content=content)
@router.post("/{owner}/{repo}", response_class=HTMLResponse)
async def save_notes(request: Request, owner: str, repo: str):
form = await request.form()
content = form.get("content", "")
with get_conn() as conn:
conn.execute(
"""INSERT INTO notes (project_owner, project_name, title, content, updated_at)
VALUES (?, ?, 'Notes', ?, CURRENT_TIMESTAMP)
ON CONFLICT(project_owner, project_name, title)
DO UPDATE SET content=excluded.content, updated_at=CURRENT_TIMESTAMP""",
(owner, repo, content),
)
conn.commit()
from jinja2 import Environment, FileSystemLoader
env = Environment(loader=FileSystemLoader("app/templates"))
template = env.get_template("notes.html")
return template.render(request=request, owner=owner, repo=repo, content=content)
+1
View File
@@ -0,0 +1 @@
"""FlowDeck — Schemas."""
+1
View File
@@ -0,0 +1 @@
"""FlowDeck — Services."""
+208
View File
@@ -0,0 +1,208 @@
"""FlowDeck — Gitea API client with caching."""
from __future__ import annotations
import logging
from datetime import datetime, timedelta
from typing import Any
import httpx
from app.config import settings
logger = logging.getLogger(__name__)
class GiteaClient:
"""Async Gitea API v1 client with simple TTL cache."""
def __init__(self) -> None:
self._base = f"{settings.gitea_url}/api/v1"
self._headers = {"Authorization": f"token {settings.gitea_token}"}
self._cache: dict[str, tuple[datetime, Any]] = {}
self._ttl = timedelta(seconds=settings.gitea_cache_ttl)
# ── cache helper ──
def _cached(self, key: str) -> Any | None:
entry = self._cache.get(key)
if entry and entry[0] > datetime.now():
return entry[1]
return None
def _set_cache(self, key: str, value: Any) -> None:
self._cache[key] = (datetime.now() + self._ttl, value)
# ── repos ──
async def get_user_repos(self, page: int = 1, limit: int = 30) -> list[dict]:
"""List user repos (sorted by updated desc)."""
cache_key = f"repos:{page}:{limit}"
cached = self._cached(cache_key)
if cached:
return cached
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
f"{self._base}/user/repos",
headers=self._headers,
params={"page": page, "limit": limit, "sort": "updated", "order": "desc"},
)
resp.raise_for_status()
data = resp.json()
self._set_cache(cache_key, data)
return data
async def get_org_repos(self, org: str, page: int = 1, limit: int = 20) -> list[dict]:
"""List repos for an org."""
cache_key = f"org_repos:{org}:{page}:{limit}"
cached = self._cached(cache_key)
if cached:
return cached
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
f"{self._base}/orgs/{org}/repos",
headers=self._headers,
params={"page": page, "limit": limit},
)
resp.raise_for_status()
data = resp.json()
self._set_cache(cache_key, data)
return data
async def get_user_orgs(self) -> list[dict]:
"""List user orgs."""
cache_key = "user_orgs"
cached = self._cached(cache_key)
if cached:
return cached
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{self._base}/user/orgs", headers=self._headers
)
resp.raise_for_status()
data = resp.json()
self._set_cache(cache_key, data)
return data
# ── issues ──
async def get_issues(
self, owner: str, repo: str, state: str = "all", page: int = 1, limit: int = 50
) -> list[dict]:
"""List issues for a repo."""
cache_key = f"issues:{owner}:{repo}:{state}:{page}:{limit}"
cached = self._cached(cache_key)
if cached:
return cached
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/issues",
headers=self._headers,
params={"state": state, "page": page, "limit": limit},
)
resp.raise_for_status()
data = resp.json()
self._set_cache(cache_key, data)
return data
async def get_issue(self, owner: str, repo: str, issue_id: int) -> dict:
"""Get a single issue."""
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
headers=self._headers,
)
resp.raise_for_status()
return resp.json()
async def update_issue_labels(
self, owner: str, repo: str, issue_id: int, labels: list[str]
) -> dict:
"""Replace issue labels."""
self._invalidate_issue_cache(owner, repo)
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.put(
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
headers=self._headers,
json={"labels": labels},
)
resp.raise_for_status()
return resp.json()
async def close_issue(self, owner: str, repo: str, issue_id: int) -> dict:
"""Close an issue (move to Terminé)."""
self._invalidate_issue_cache(owner, repo)
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.patch(
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
headers=self._headers,
json={"state": "closed"},
)
resp.raise_for_status()
return resp.json()
async def reopen_issue(self, owner: str, repo: str, issue_id: int) -> dict:
"""Reopen an issue."""
self._invalidate_issue_cache(owner, repo)
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.patch(
f"{self._base}/repos/{owner}/{repo}/issues/{issue_id}",
headers=self._headers,
json={"state": "open"},
)
resp.raise_for_status()
return resp.json()
def _invalidate_issue_cache(self, owner: str, repo: str) -> None:
prefix = f"issues:{owner}:{repo}:"
to_delete = [k for k in self._cache if k.startswith(prefix)]
for k in to_delete:
del self._cache[k]
# ── labels ──
async def get_labels(self, owner: str, repo: str) -> list[dict]:
"""List repo labels."""
cache_key = f"labels:{owner}:{repo}"
cached = self._cached(cache_key)
if cached:
return cached
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/labels",
headers=self._headers,
)
resp.raise_for_status()
data = resp.json()
self._set_cache(cache_key, data)
return data
# ── milestones ──
async def get_milestones(
self, owner: str, repo: str, state: str = "open"
) -> list[dict]:
"""List repo milestones."""
cache_key = f"milestones:{owner}:{repo}:{state}"
cached = self._cached(cache_key)
if cached:
return cached
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.get(
f"{self._base}/repos/{owner}/{repo}/milestones",
headers=self._headers,
params={"state": state},
)
resp.raise_for_status()
data = resp.json()
self._set_cache(cache_key, data)
return data
# ── singleton ──
gitea = GiteaClient()
+29
View File
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlowDeck</title>
<link rel="stylesheet" href="/static/css/app.css">
<script src="https://unpkg.com/[email protected]" defer></script>
<script src="https://unpkg.com/[email protected]" defer></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/Sortable.min.js" defer></script>
</head>
<body>
<header class="app-header">
<div class="header-left">
<a href="/" class="logo">
<span class="logo-icon">▦</span>
<span class="logo-text">FlowDeck</span>
</a>
</div>
<div class="header-right">
<button class="theme-toggle" onclick="toggleTheme()" title="Thème clair/sombre">🌓</button>
</div>
</header>
<main class="main-content">
{% block content %}{% endblock %}
</main>
<script src="/static/js/app.js" defer></script>
</body>
</html>
+141
View File
@@ -0,0 +1,141 @@
{% extends "base.html" %}
{% block content %}
<div class="board"
x-data="{
owner: '{{ owner }}',
repo: '{{ repo }}',
milestone: '{{ current_milestone }}',
label: '{{ current_label }}',
assignee: '{{ current_assignee }}'
}">
<!-- Header -->
<div class="board-header">
<div class="board-breadcrumb">
<a href="/" class="back-link">← Projets</a>
<span class="separator">/</span>
<span class="repo-owner">{{ owner }}</span>
<span class="separator">/</span>
<strong>{{ repo }}</strong>
</div>
<div class="board-actions">
<a href="/notes/{{ owner }}/{{ repo }}" class="btn btn-sm">📝 Notes</a>
<a href="{{ settings.gitea_url }}/{{ owner }}/{{ repo }}/issues/new" target="_blank" class="btn btn-sm btn-primary">+ Issue</a>
</div>
</div>
<!-- Filters -->
<div class="board-filters">
<select x-model="milestone" @change="window.location='?milestone='+milestone+'&label='+label+'&assignee='+assignee" class="filter-select">
<option value="">Tous les milestones</option>
{% for m in milestones %}
<option value="{{ m.title }}" {% if m.title == current_milestone %}selected{% endif %}>{{ m.title }}</option>
{% endfor %}
</select>
<select x-model="label" @change="window.location='?milestone='+milestone+'&label='+label+'&assignee='+assignee" class="filter-select">
<option value="">Tous les labels</option>
{% for lbl in labels %}
<option value="{{ lbl.name }}" {% if lbl.name == current_label %}selected{% endif %}>🏷 {{ lbl.name }}</option>
{% endfor %}
</select>
</div>
<!-- Columns -->
<div class="board-columns"
hx-trigger="cardMoved from:body"
hx-get="/board/{{ owner }}/{{ repo }}?milestone={{ current_milestone }}&label={{ current_label }}&assignee={{ current_assignee }}"
hx-target=".board-columns"
hx-swap="outerHTML">
{% for col in columns %}
<div class="board-column"
data-column="{{ col }}"
id="col-{{ loop.index }}">
<div class="column-header">
<span class="column-title">{{ col }}</span>
<span class="column-count">{{ column_map[col]|length }}</span>
</div>
<div class="column-cards sortable"
data-column="{{ col }}"
data-owner="{{ owner }}"
data-repo="{{ repo }}">
{% for issue in column_map[col] %}
<div class="card"
data-issue-id="{{ issue.number }}"
data-column="{{ col }}"
draggable="true">
<div class="card-labels">
{% for lbl in issue.labels %}
<span class="card-label" style="background:#{{ lbl.color or '666' }}33; color:#{{ lbl.color or 'ccc' }}">{{ lbl.name }}</span>
{% endfor %}
</div>
<div class="card-title">
<a href="{{ issue.html_url }}" target="_blank">#{{ issue.number }}</a>
{{ issue.title[:80] }}{% if issue.title|length > 80 %}...{% endif %}
</div>
<div class="card-meta">
{% if issue.assignee %}
<span class="card-assignee" title="{{ issue.assignee.login }}">
<img src="{{ issue.assignee.avatar_url }}" alt="" width="16" height="16" class="avatar">
{{ issue.assignee.login }}
</span>
{% endif %}
{% if issue.milestone %}
<span class="card-milestone">📅 {{ issue.milestone.title }}</span>
{% endif %}
</div>
{% if issue._card and issue._card.priority %}
<div class="card-priority priority-{{ issue._card.priority|lower }}">{{ issue._card.priority }}</div>
{% endif %}
{% if issue._card and issue._card.due_date %}
<div class="card-due {% if issue._card.due_date < now %}overdue{% endif %}">
⏰ {{ issue._card.due_date[:10] }}
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
</div>
<script>
// ponytail: initialize SortableJS on each column
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('.sortable').forEach(el => {
new Sortable(el, {
group: 'kanban',
animation: 150,
ghostClass: 'card-ghost',
onEnd: async function(evt) {
const card = evt.item;
const issueId = card.dataset.issueId;
const newColumn = evt.to.dataset.column;
const owner = evt.to.dataset.owner;
const repo = evt.to.dataset.repo;
// Optimistic: move card in DOM is already done
// Persist to backend
try {
await fetch(`/api/move?owner=${owner}&repo=${repo}&issue_id=${issueId}&column=${encodeURIComponent(newColumn)}`, {
method: 'POST'
});
// Update column counts
document.querySelectorAll('.board-column').forEach(col => {
const count = col.querySelectorAll('.card').length;
const badge = col.querySelector('.column-count');
if (badge) badge.textContent = count;
});
} catch (e) {
console.error('Move failed:', e);
// ponytail: reload on error
window.location.reload();
}
}
});
});
});
</script>
{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends "base.html" %}
{% block content %}
<div class="dashboard" x-data="{search: '{{ search }}', showArchived: {{ 'true' if show_archived else 'false' }}}">
<div class="dashboard-header">
<h1>Projets</h1>
<div class="dashboard-controls">
<input
type="text"
x-model="search"
placeholder="Rechercher un projet..."
class="search-input"
hx-get="/?show_archived=${showArchived}"
hx-trigger="keyup changed delay:300ms"
hx-target=".project-list"
hx-include="[name='search'], [name='show_archived']"
name="search"
autocomplete="off"
>
<label class="toggle-label">
<input type="checkbox" name="show_archived" x-model="showArchived"
hx-get="/"
hx-trigger="change"
hx-target=".project-list"
hx-include="[name='search'], [name='show_archived']">
<span>Archivés</span>
</label>
</div>
</div>
<div class="stats-bar">
<span>{{ repos|length }} projet(s)</span>
</div>
<div class="project-list">
{% if repos %}
{% for repo in repos %}
<a href="/board/{{ repo.owner.login }}/{{ repo.name }}" class="project-card">
<div class="project-card-header">
<span class="project-org">{{ repo._org or repo.owner.login }}</span>
<span class="project-name">{{ repo.name }}</span>
{% if repo.archived %}<span class="badge archived">archivé</span>{% endif %}
{% if repo.private %}<span class="badge private">privé</span>{% endif %}
</div>
{% if repo.description %}
<p class="project-desc">{{ repo.description[:120] }}{% if repo.description|length > 120 %}...{% endif %}</p>
{% endif %}
<div class="project-meta">
{% if repo.language %}<span class="meta-lang">{{ repo.language }}</span>{% endif %}
<span class="meta-stars">⭐ {{ repo.stars_count or 0 }}</span>
<span class="meta-updated">Mis à jour {{ repo.updated_at[:10] if repo.updated_at else '' }}</span>
</div>
</a>
{% endfor %}
{% else %}
<p class="empty">Aucun projet trouvé.</p>
{% endif %}
</div>
</div>
{% endblock %}
+30
View File
@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block content %}
<div class="notes-page">
<div class="board-header">
<div class="board-breadcrumb">
<a href="/" class="back-link">← Projets</a>
<span class="separator">/</span>
<a href="/board/{{ owner }}/{{ repo }}" class="back-link">{{ owner }}/{{ repo }}</a>
<span class="separator">/</span>
<strong>Notes</strong>
</div>
</div>
<div class="notes-editor">
<form hx-post="/notes/{{ owner }}/{{ repo }}" hx-target=".notes-editor" hx-swap="outerHTML">
<textarea name="content" class="notes-textarea" placeholder="Notes markdown pour ce projet...">{{ content }}</textarea>
<div class="notes-actions">
<button type="submit" class="btn btn-primary">💾 Sauvegarder</button>
</div>
</form>
{% if content %}
<div class="notes-preview">
<h3>Aperçu</h3>
<div class="markdown-body">{{ content }}</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}