"""FlowDeck — API REST v1.""" from __future__ import annotations import json import logging from datetime import datetime from typing import Optional from fastapi import APIRouter, HTTPException, Query, Request 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") # ── Simple rate limiter (in-memory, per-IP) ── _rate_limit_store: dict[str, tuple[float, int]] = {} def _check_rate_limit(request: Request) -> bool: """Simple sliding window rate limiter. Returns True if allowed.""" if not settings.rate_limit_enabled: return True ip = request.client.host if request.client else "unknown" now = datetime.utcnow().timestamp() window_start, count = _rate_limit_store.get(ip, (0, 0)) if now - window_start > 60: _rate_limit_store[ip] = (now, 1) return True if count >= settings.rate_limit_requests: return False _rate_limit_store[ip] = (window_start, count + 1) return True @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.3.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"] user_count = conn.execute("SELECT COUNT(*) as c FROM users").fetchone()["c"] return { "boards": board_count, "cards": card_count, "notes": note_count, "users": user_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) 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( request: Request, 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.""" if not _check_rate_limit(request): raise HTTPException(status_code=429, detail="Rate limit exceeded") 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"] 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), ) mapping = conn.execute( "SELECT gitea_label, close_issue FROM col_mapping WHERE board_id=? AND column_name=?", (board_id, column), ).fetchone() conn.commit() if mapping: try: if mapping["close_issue"]: await gitea.close_issue(owner, repo, issue_id) else: issue = await gitea.get_issue(owner, repo, issue_id) current_labels = [lbl["name"] for lbl in issue.get("labels", [])] 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]: 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): 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=""), ): 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"} # ── v0.5.0: Issue CRUD ── @router.post("/issues/{owner}/{repo}") async def create_issue( request: Request, owner: str, repo: str, title: str = Query(...), body: str = Query(default=""), labels: str = Query(default=""), milestone: str = Query(default=""), assignee: str = Query(default=""), ): """Create a new issue in Gitea and add card to board.""" if not _check_rate_limit(request): raise HTTPException(status_code=429, detail="Rate limit exceeded") label_ids = [int(l) for l in labels.split(",") if l.strip().isdigit()] if labels else None milestone_id = int(milestone) if milestone.strip().isdigit() else None issue = await gitea.create_issue( owner, repo, title, body, labels=label_ids, milestone=milestone_id, assignee=assignee, ) # Add card to local board with get_conn() as conn: board = conn.execute( "SELECT id FROM boards WHERE project_owner=? AND project_name=?", (owner, repo), ).fetchone() if board: conn.execute( "INSERT INTO cards (board_id, gitea_issue_id, column_name, position) VALUES (?, ?, 'Backlog', 0)", (board["id"], issue["number"]), ) conn.commit() return {"status": "ok", "issue": issue} @router.patch("/issues/{owner}/{repo}/{issue_id}") async def update_issue_api( request: Request, owner: str, repo: str, issue_id: int, title: str = Query(default=""), body: str = Query(default=""), state: str = Query(default=""), labels: str = Query(default=""), milestone: str = Query(default=""), assignee: str = Query(default=""), ): """Update an issue (partial update).""" if not _check_rate_limit(request): raise HTTPException(status_code=429, detail="Rate limit exceeded") kwargs = {} if title: kwargs["title"] = title if body: kwargs["body"] = body if state: kwargs["state"] = state if labels: kwargs["labels"] = labels.split(",") if milestone and milestone.strip().isdigit(): kwargs["milestone"] = int(milestone) if assignee: kwargs["assignees"] = [assignee] issue = await gitea.update_issue(owner, repo, issue_id, **kwargs) return {"status": "ok", "issue": issue} # ── v0.5.0: Card detail ── @router.get("/issues/{owner}/{repo}/{issue_id}") async def get_issue_detail(owner: str, repo: str, issue_id: int): """Get full issue details + comments.""" try: issue = await gitea.get_issue(owner, repo, issue_id) comments = await gitea.get_issue_comments(owner, repo, issue_id) except Exception as e: logger.warning("Failed to fetch issue %s/%s #%d: %s", owner, repo, issue_id, e) raise HTTPException(status_code=404, detail=f"Issue #{issue_id} not found") # Get checklists from local DB with get_conn() as conn: board = conn.execute( "SELECT id FROM boards WHERE project_owner=? AND project_name=?", (owner, repo), ).fetchone() board_id = board["id"] if board else None checklists = [] if board_id: rows = conn.execute( "SELECT * FROM checklists WHERE board_id=? AND gitea_issue_id=? ORDER BY position", (board_id, issue_id), ).fetchall() for cl in rows: items = conn.execute( "SELECT * FROM checklist_items WHERE checklist_id=? ORDER BY position", (cl["id"],), ).fetchall() checklists.append({ **dict(cl), "items": [dict(it) for it in items], }) card = conn.execute( "SELECT * FROM cards WHERE board_id=? AND gitea_issue_id=?", (board_id, issue_id), ).fetchone() if board_id else None return { "issue": issue, "comments": comments, "checklists": checklists, "card": dict(card) if card else None, } # ── v0.5.0: Checklists ── @router.post("/checklists/{owner}/{repo}/{issue_id}") async def create_checklist( owner: str, repo: str, issue_id: int, title: str = Query(default="Checklist"), ): """Create a new checklist for an issue.""" 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") cur = conn.execute( "INSERT INTO checklists (board_id, gitea_issue_id, title) VALUES (?, ?, ?)", (board["id"], issue_id, title), ) conn.commit() return {"status": "ok", "checklist_id": cur.lastrowid} @router.post("/checklist-items/{owner}/{repo}/{issue_id}/{checklist_id}") async def add_checklist_item( owner: str, repo: str, issue_id: int, checklist_id: int, content: str = Query(...), ): """Add an item to a checklist.""" with get_conn() as conn: cur = conn.execute( "INSERT INTO checklist_items (checklist_id, content) VALUES (?, ?)", (checklist_id, content), ) conn.commit() return {"status": "ok", "item_id": cur.lastrowid} @router.patch("/checklist-items/{item_id}") async def toggle_checklist_item( item_id: int, checked: bool = Query(default=False), content: str = Query(default=""), ): """Toggle or update a checklist item.""" with get_conn() as conn: if content: conn.execute( "UPDATE checklist_items SET checked=?, content=? WHERE id=?", (int(checked), content, item_id), ) else: conn.execute( "UPDATE checklist_items SET checked=? WHERE id=?", (int(checked), item_id), ) conn.commit() return {"status": "ok"} @router.delete("/checklist-items/{item_id}") async def delete_checklist_item(item_id: int): """Delete a checklist item.""" with get_conn() as conn: conn.execute("DELETE FROM checklist_items WHERE id=?", (item_id,)) conn.commit() return {"status": "ok"} @router.delete("/checklists/{checklist_id}") async def delete_checklist(checklist_id: int): """Delete a checklist and all its items.""" with get_conn() as conn: conn.execute("DELETE FROM checklist_items WHERE checklist_id=?", (checklist_id,)) conn.execute("DELETE FROM checklists WHERE id=?", (checklist_id,)) conn.commit() return {"status": "ok"} # ── v0.5.0: Card priority & due date ── @router.post("/card/{owner}/{repo}/{issue_id}") async def update_card( owner: str, repo: str, issue_id: int, priority: str = Query(default=""), due_date: str = Query(default=""), ): """Update card metadata (priority, due date).""" 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") updates = [] params = [] if priority: updates.append("priority=?") params.append(priority) if due_date: updates.append("due_date=?") params.append(due_date) if updates: updates.append("updated_at=CURRENT_TIMESTAMP") params.extend([board["id"], issue_id]) conn.execute( f"""UPDATE cards SET {', '.join(updates)} WHERE board_id=? AND gitea_issue_id=?""", params, ) conn.commit() return {"status": "ok"} # ── v0.5.0: Collaborators for assignee selector ── @router.get("/collaborators/{owner}/{repo}") async def get_collaborators(owner: str, repo: str): """Get repo collaborators (for assignee dropdown).""" try: collaborators = await gitea.get_collaborators(owner, repo) return {"collaborators": collaborators} except Exception as e: return {"collaborators": [], "error": str(e)}