- 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
143 lines
4.7 KiB
Python
143 lines
4.7 KiB
Python
"""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"
|