Files
flowdeck/app/routers/board.py
T
bruno 68f2b3f03b
CI / test (push) Failing after 4s
CI / lint (push) Failing after 4s
fix: issues with pull_request:null no longer classified as PRs
Gitea API returns 'pull_request': null for regular issues.
Old filter 'pull_request' in issue matched the key even when null,
sending ALL issues to the PRs section instead of the Kanban columns.
Fixed: i.get('pull_request') — null is falsy, real PR dict is truthy.

Also: labels/milestones dropdowns empty = repo has none configured in Gitea.
2026-07-08 10:14:28 -04:00

138 lines
5.1 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 = "",
fragment: bool = False,
):
"""Kanban board for a Gitea project."""
try:
issues = await gitea.get_issues(owner, repo, state="all")
labels_list = await gitea.get_labels(owner, repo)
milestones = await gitea.get_milestones(owner, repo)
if milestone:
issues = [i for i in issues if i.get("milestone", {}).get("title") == milestone]
if label:
issues = [i for i in issues if any(lbl["name"] == label for lbl in i.get("labels", []))]
if assignee:
issues = [i for i in issues if i.get("assignee", {}).get("login") == assignee]
prs = [i for i in issues if i.get("pull_request")]
issues_only = [i for i in issues if not i.get("pull_request")]
board_info = _ensure_board(owner, repo)
columns = board_info["columns"]
cards = _get_cards(board_info["id"])
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:
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:
from httpx import HTTPStatusError
if isinstance(e, HTTPStatusError) and e.response is not None and e.response.status_code == 404:
return HTMLResponse(
'<div style="padding:48px;text-align:center">'
f'<h2 style="color:#8b949e">Projet introuvable</h2>'
f'<p style="color:#8b949e">{owner}/{repo} n\'existe pas sur Gitea.</p>'
'<a href="/" class="btn btn-primary" style="margin-top:16px">Retour aux projets</a></div>',
status_code=404)
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
from app.auth.session import SessionManager
env = Environment(loader=FileSystemLoader("app/templates"))
now_str = datetime.now().isoformat()[:10]
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
ctx = dict(
request=request, owner=owner, repo=repo, columns=columns,
column_map=column_map, labels=labels_list, milestones=milestones,
current_milestone=milestone, current_label=label, current_assignee=assignee,
prs=prs, settings=settings, now=now_str, user=user,
)
if fragment:
template = env.get_template("board_fragment.html")
else:
template = env.get_template("board.html")
return template.render(**ctx)
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"