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:
@@ -0,0 +1 @@
|
||||
"""FlowDeck — Routers."""
|
||||
@@ -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"}
|
||||
@@ -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"
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user