diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..49e1503 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +__pycache__/ +*.pyc +.env +/data/ +.git/ +.venv/ +venv/ +*.egg-info/ +dist/ +build/ +.pytest_cache/ +.ruff_cache/ +.mypy_cache/ +node_modules/ +Dockerfile +.dockerignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..63b6f67 --- /dev/null +++ b/.env.example @@ -0,0 +1,22 @@ +# ── Gitea ── +GITEA_URL=https://git.dracodev.net +GITEA_TOKEN=change-me +GITEA_OAUTH_CLIENT_ID= +GITEA_OAUTH_CLIENT_SECRET= +GITEA_WEBHOOK_SECRET= + +# ── App ── +APP_SECRET_KEY=change-me-to-random +APP_HOST=0.0.0.0 +APP_PORT=8080 +LOG_LEVEL=INFO +DEFAULT_LANG=fr + +# ── Database ── +# SQLite (default): sqlite:////data/flowdeck.db +# PostgreSQL (optional): postgresql://user:pass@host:5432/flowdeck +DATABASE_URL=sqlite:////data/flowdeck.db + +# ── Sync ── +SYNC_INTERVAL=60 +GITEA_CACHE_TTL=30 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..da28601 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.pyc +.env +/data/ +.venv/ +venv/ +*.egg-info/ +dist/ +.pytest_cache/ +.ruff_cache/ diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..826e07f --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,72 @@ +# Architecture FlowDeck + +## Schéma + +``` +┌──────────────────────────────────────────────┐ +│ Browser │ +│ HTMX + Alpine.js + SortableJS │ +└──────────────┬───────────────────────────────┘ + │ HTTP (Jinja2 HTML / JSON API) +┌──────────────▼───────────────────────────────┐ +│ FastAPI (Python) │ +│ ┌──────────┬──────────┬──────────┐ │ +│ │ dashboard│ board │ notes │ api │ +│ └────┬─────┴────┬─────┴──────────┴────┬─────┘ +│ │ │ │ +│ ┌────▼──────────▼─────────────────────▼─────┐ +│ │ GiteaClient (httpx async) │ +│ │ repos, issues, labels, milestones │ +│ └──────────────────┬────────────────────────┘ +│ │ +│ ┌──────────────────▼────────────────────────┐ +│ │ SQLite (WAL mode) │ +│ │ boards, cards, notes, col_mapping │ +│ └───────────────────────────────────────────┘ +└──────────────────────┬────────────────────────┘ + │ HTTPS +┌──────────────────────▼────────────────────────┐ +│ Gitea API REST v1 │ +│ git.dracodev.net │ +└───────────────────────────────────────────────┘ +``` + +## Flux de données + +1. **Dashboard** : appelle `/api/v1/user/repos` + `/api/v1/user/orgs` Gitea → affiche liste projets +2. **Board** : appelle `/api/v1/repos/{owner}/{repo}/issues` → mappe sur colonnes via BDD locale +3. **Drag & drop** : SortableJS → `POST /api/move` → mise à jour SQLite + sync Gitea (labels/close) +4. **Notes** : stockées en SQLite local (non sync Gitea) + +## Décisions techniques + +- **SQLite (pas PostgreSQL)** : KISS. Un fichier, pas de serveur. Migration future triviale via Alembic si besoin. +- **HTMX + Alpine.js (pas React)** : Léger, progressif. Pas de build step. +- **Cache Gitea in-memory** : TTL configurable, pas de Redis. Suffisant pour <100 repos. +- **Pas d'auth OAuth en v0.2** : token global Gitea. OAuth par utilisateur prévu v0.3. +- **Pas de webhooks** : polling implicite au refresh. Webhooks prévus v0.4. + +## Base de données + +``` +boards → config Kanban par projet (colonnes, WIP) +cards → position des issues dans les colonnes +notes → notes markdown par projet +col_mapping → mapping colonne→label Gitea (sync bidirectionnelle) +``` + +## Routes + +``` +GET / → Dashboard projets +GET /board/{owner}/{repo} → Board Kanban +GET /notes/{owner}/{repo} → Notes markdown +POST /notes/{owner}/{repo} → Sauvegarder notes +GET /api/health → Health check +GET /api/stats → Statistiques +GET /api/projects → Projets (JSON) +POST /api/move → Déplacer carte +POST /api/col-mapping → Config mapping colonne→label +GET /api/board-config/{o}/{r} → Config board +POST /api/board-config/{o}/{r} → MàJ config board +``` diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e279ac3 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,32 @@ +# Changelog + +## [0.2.0] — 2026-07-08 — "FlowDeck" + +### Added +- Renamed from Kanbante to FlowDeck +- Dashboard listant tous les projets Gitea (user + orgs) +- Board Kanban 5 colonnes : Backlog, À faire, En cours, Révision, Terminé +- Issues Gitea → cartes avec filtres (milestone, label, assignee) +- Drag & drop (SortableJS) avec persistance SQLite +- Sync bidirectionnelle : colonne → label Gitea, Terminé → close issue +- Colonnes personnalisables + mapping colonne→label +- WIP limits configurables +- Notes Markdown par projet +- API REST v1 : /api/health, /api/stats, /api/projects, /api/move, /api/col-mapping +- Thème clair/sombre avec persistance localStorage +- Cache Gitea in-memory avec TTL +- Docker + docker-compose +- Health check enrichi (DB + Gitea) +- Filtres intra-board (milestone, label, assignee) +- Pull requests affichées séparément +- Priorités (Low/Medium/High/Urgent) + dates d'échéance +- Architecture documentée (ARCHITECTURE.md) +- Roadmap (ROADMAP.md) + +### Stack +- FastAPI + Uvicorn + httpx + Jinja2 + HTMX + Alpine.js + SortableJS +- SQLite WAL mode +- Python 3.12-slim Docker + +## [0.1.0] — Kanbante (non publié) +- MVP initial diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e4b0b1a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +RUN mkdir -p /data + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8080/api/health || exit 1 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/README.md b/README.md index 457f7ee..6acc07b 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,77 @@ -# flowdeck +# FlowDeck -FlowDeck — Gestion de projets Kanban léger intégré à Gitea \ No newline at end of file +Gestion de projets Kanban légère, auto-hébergée, intégrée nativement à **Gitea**. + +## Quick Start + +```bash +cp .env.example .env +# Éditer .env avec votre token Gitea +docker compose up -d +# → http://localhost:8080 +``` + +## Features + +- **Dashboard** listant tous les projets Gitea (user + orgs) +- **Board Kanban** 5 colonnes : Backlog → À faire → En cours → Révision → Terminé +- **Issues Gitea → cartes** : sync automatique, filtres par milestone/label/assignee +- **Drag & drop** (SortableJS) avec persistance SQLite +- **Sync bidirectionnelle** : déplacer une carte → label Gitea mis à jour, colonne Terminé → ferme l'issue +- **Colonnes personnalisables** + configuration du mapping colonne→label Gitea +- **WIP limits** configurables par colonne +- **Notes Markdown** par projet (éditeur + sauvegarde) +- **API REST** : `/api/health`, `/api/stats`, `/api/projects`, `/api/move`, `/api/col-mapping` +- **Thème clair/sombre** commutable +- **Filtres** par milestone, label, assignee +- **PR affichées** séparément des issues +- **Dark/light theme** toggle avec persistance localStorage + +## Stack + +| Couche | Techno | +|--------|--------| +| Frontend | HTML5 + Jinja2 + CSS custom, HTMX, Alpine.js, SortableJS | +| Backend | Python 3.12 + FastAPI + Uvicorn + httpx | +| BDD | SQLite (WAL mode) — `/data/flowdeck.db` | +| Intégration | API REST Gitea v1 (token auth) | +| Déploiement | Docker (Python 3.12-slim), docker-compose | + +## Configuration + +Voir `.env.example`. Variables principales : + +```env +GITEA_URL=https://git.dracodev.net +GITEA_TOKEN=votre_token +APP_PORT=8080 +DATABASE_URL=sqlite:////data/flowdeck.db +SYNC_INTERVAL=60 +GITEA_CACHE_TTL=30 +``` + +## API Reference + +| Endpoint | Description | +|----------|-------------| +| `GET /api/health` | Health check (DB + Gitea) | +| `GET /api/stats` | Statistiques globales | +| `GET /api/projects` | Projets Gitea (JSON) | +| `POST /api/move` | Déplacer une carte | +| `POST /api/col-mapping` | Configurer mapping colonne→label | +| `GET /api/board-config/{owner}/{repo}` | Configuration board | +| `POST /api/board-config/{owner}/{repo}` | Mettre à jour colonnes/WIP | + +## Développement + +```bash +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +cp .env.example .env # éditer +uvicorn app.main:app --reload --port 8080 +``` + +## Licence + +MIT diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..f287f58 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,51 @@ +# Roadmap FlowDeck + +## v0.2.0 ✅ (actuel) +Dashboard + Board Kanban + Gitea sync + Notes + Docker + +## v0.3.0 — Auth & Multi-user +- [ ] OAuth2 Gitea (login via Gitea) +- [ ] Token par utilisateur (plus sécurisé) +- [ ] Permissions basées sur droits Gitea +- [ ] CSRF protection +- [ ] Rate limiting API + +## v0.4.0 — Webhooks & Temps réel +- [ ] Webhooks Gitea (issues, comments, PR) +- [ ] Sync temps réel sans polling +- [ ] Background tasks (sync périodique) + +## v0.5.0 — Cartes enrichies +- [ ] Vue détail carte (modal) : description, labels, assignee, milestone, commentaires +- [ ] Checklists / sous-tâches +- [ ] Création d'issues depuis FlowDeck +- [ ] Édition inline (titre, description, labels, assignee) +- [ ] Commentaires Gitea dans la carte + +## v0.6.0 — Vues additionnelles +- [ ] Vue Liste (table triable) +- [ ] Vue Calendrier (échéances, milestones) +- [ ] Vue Timeline / Roadmap +- [ ] Vue Sprint / Backlog + +## v0.7.0 — Analytics +- [ ] Dashboard analytics : burndown chart, vélocité +- [ ] Cartes par statut, en retard +- [ ] Activité récente + +## v0.8.0 — Multi-instance & Export +- [ ] Multi-instance Gitea +- [ ] Export board (JSON/CSV/Markdown) +- [ ] i18n (EN) +- [ ] Raccourcis clavier + +## v0.9.0 — Infra & Qualité +- [ ] Migrations BDD (Alembic) +- [ ] Support PostgreSQL optionnel +- [ ] Logging structuré (loguru) +- [ ] Tests ≥ 70% couverture +- [ ] CI/CD Gitea Actions + +## v1.0.0 +- [ ] Production ready +- [ ] Tag git v1.0.0 diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..7095c22 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1 @@ +"""FlowDeck — Application package.""" diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..3620d4c --- /dev/null +++ b/app/config.py @@ -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() diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..6a6a616 --- /dev/null +++ b/app/db.py @@ -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() diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..b47bf18 --- /dev/null +++ b/app/main.py @@ -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) diff --git a/app/models/__init__.py b/app/models/__init__.py new file mode 100644 index 0000000..d5e9d51 --- /dev/null +++ b/app/models/__init__.py @@ -0,0 +1 @@ +"""FlowDeck — Models.""" diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..1ae4633 --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1 @@ +"""FlowDeck — Routers.""" diff --git a/app/routers/api.py b/app/routers/api.py new file mode 100644 index 0000000..f5df7cc --- /dev/null +++ b/app/routers/api.py @@ -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"} diff --git a/app/routers/board.py b/app/routers/board.py new file mode 100644 index 0000000..6a57d8b --- /dev/null +++ b/app/routers/board.py @@ -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"
Erreur: {e}
", 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" diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py new file mode 100644 index 0000000..1ae0a5e --- /dev/null +++ b/app/routers/dashboard.py @@ -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, + ) diff --git a/app/routers/notes.py b/app/routers/notes.py new file mode 100644 index 0000000..5524c37 --- /dev/null +++ b/app/routers/notes.py @@ -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) diff --git a/app/schemas/__init__.py b/app/schemas/__init__.py new file mode 100644 index 0000000..ea89f0a --- /dev/null +++ b/app/schemas/__init__.py @@ -0,0 +1 @@ +"""FlowDeck — Schemas.""" diff --git a/app/services/__init__.py b/app/services/__init__.py new file mode 100644 index 0000000..aff954f --- /dev/null +++ b/app/services/__init__.py @@ -0,0 +1 @@ +"""FlowDeck — Services.""" diff --git a/app/services/gitea_client.py b/app/services/gitea_client.py new file mode 100644 index 0000000..2225105 --- /dev/null +++ b/app/services/gitea_client.py @@ -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() diff --git a/app/templates/base.html b/app/templates/base.html new file mode 100644 index 0000000..39ee921 --- /dev/null +++ b/app/templates/base.html @@ -0,0 +1,29 @@ + + + + + +{{ repo.description[:120] }}{% if repo.description|length > 120 %}...{% endif %}
+ {% endif %} + + + {% endfor %} + {% else %} +Aucun projet trouvé.
+ {% endif %} +