- A26 — champ `standalone` mort supprimé (0 lecteur) ; `sqlite:////data/…` ne produit plus de chemin UNC sous Windows ; `.env.example` ne promet plus PostgreSQL ; raise au boot si `APP_SECRET_KEY` vaut la valeur par défaut - A33 — rate limit : préfixes manquants (`/scim/v2/`, `/workspace/`, `/db/` + non-GET sur `/s/` et `/f/`), limite lue dans `settings.rate_limit_requests` (60 annoncés / 100 codés en dur), clé `X-Forwarded-For` seulement derrière un proxy local (anti-spoof), `_store` épuré (mémoire bornée) + test dédié - A34 — `_spawn()` : les 10 schedulers loggent leur exception et redémarrent après 10 s au lieu de mourir en silence ; 2 `logger.debug` de scheduler → warning - A35 — OpenAPI régénéré 439 → 511 chemins (`info.version 7.3.9`), README (était v6.7.0), compteur API_GUIDE, titre dupliqué retiré du ROADMAP ; le drift Python 3.12/3.13 reste noté (rebuild d'image à valider) - A36 — 4 dépendances mortes purgées de requirements.txt (aiosqlite, slowapi, loguru, packaging = 0 import) ; pyproject reste sans [project] : Docker et la CI installent requirements.txt, dupliquer les 22 deps créerait 2 sources - A43 — 15 `datetime.utcnow()` → `now(UTC).replace(tzinfo=None)` (format ISO naïf identique, warnings de dépréciation divisés : 2374 → 926) suite **1029/1029** · `ruff check app tests` OK · docs/ROADMAP/CHANGELOG/WORKLOAD à jour
681 lines
23 KiB
Python
681 lines
23 KiB
Python
"""FlowDeck — API REST v1."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from datetime import UTC, datetime
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
from fastapi.responses import HTMLResponse
|
|
|
|
from app.auth.session import SessionManager
|
|
from app.config import settings
|
|
from app.db import get_conn
|
|
from app.routers.board import STATUS_COLORS, STATUS_LABELS, _issue_column, _map_issue_to_card
|
|
from app.services.gitea_client import gitea
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# A17 : ce router legacy ne sert plus un anonymous. Deux exceptions assumées —
|
|
# le probe d'infra (Docker HEALTHCHECK / CI) et la remontée d'erreur client.
|
|
_API_PUBLIC_PATHS = {"/api/health", "/api/frontend-error"}
|
|
|
|
|
|
async def _require_session_or_bearer(request: Request) -> None:
|
|
"""Session de cookie **ou** Bearer d'API valide sur toutes les routes /api."""
|
|
if request.url.path in _API_PUBLIC_PATHS:
|
|
return
|
|
if SessionManager.decode_session(request.cookies.get("flowdeck_session", "")):
|
|
return
|
|
auth = request.headers.get("Authorization", "")
|
|
if auth.startswith("Bearer "):
|
|
from app.routers.public_api import verify_token
|
|
verify_token(auth)
|
|
return
|
|
raise HTTPException(401, "Authentication required")
|
|
|
|
|
|
router = APIRouter(tags=["api"], prefix="/api", dependencies=[Depends(_require_session_or_bearer)])
|
|
|
|
# ── 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.now(UTC).replace(tzinfo=None).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(request: Request):
|
|
"""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:
|
|
logger.exception("health")
|
|
try:
|
|
await gitea.get_user_repos(page=1, limit=1)
|
|
gitea_ok = True
|
|
except Exception:
|
|
logger.exception("health")
|
|
|
|
return {
|
|
"status": "ok" if (db_ok and gitea_ok) else "degraded",
|
|
"version": request.app.version,
|
|
"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.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()
|
|
|
|
labels = []
|
|
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_names = [name for name in current_labels if name not in status_labels]
|
|
filtered_names.append(mapping["gitea_label"])
|
|
|
|
# Resolve label names to IDs
|
|
all_labels = await gitea.get_labels(owner, repo)
|
|
name_to_id = {lbl["name"]: lbl["id"] for lbl in all_labels}
|
|
label_ids = [name_to_id[n] for n in filtered_names if n in name_to_id]
|
|
|
|
result = await gitea.update_issue_labels(owner, repo, issue_id, label_ids)
|
|
labels = [{"name": lbl["name"], "color": lbl.get("color", "666")} for lbl in result]
|
|
except Exception as e:
|
|
logger.warning("Gitea sync failed: %s", e)
|
|
|
|
return {"status": "ok", "issue_id": issue_id, "column": column, "labels": labels}
|
|
|
|
|
|
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.delete("/col-mapping")
|
|
async def delete_col_mapping(
|
|
owner: str = Query(...),
|
|
repo: str = Query(...),
|
|
column: str = Query(...),
|
|
):
|
|
"""Delete a 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(
|
|
"DELETE FROM col_mapping WHERE board_id=? AND column_name=?",
|
|
(board_id, column),
|
|
)
|
|
conn.commit()
|
|
|
|
return {"status": "ok", "column": column}
|
|
|
|
|
|
@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(lbl) for lbl in labels.split(",") if lbl.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 — determine column from label mapping
|
|
with get_conn() as conn:
|
|
board_row = conn.execute(
|
|
"SELECT id, columns_json FROM boards WHERE project_owner=? AND project_name=?",
|
|
(owner, repo),
|
|
).fetchone()
|
|
if board_row:
|
|
columns = json.loads(board_row["columns_json"])
|
|
col = _issue_column(issue, columns, board_row["id"])
|
|
conn.execute(
|
|
"INSERT INTO cards (board_id, gitea_issue_id, column_name, position) VALUES (?, ?, ?, 0)",
|
|
(board_row["id"], issue["number"], col),
|
|
)
|
|
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 = {}
|
|
label_ids = None
|
|
if title:
|
|
kwargs["title"] = title
|
|
if body:
|
|
kwargs["body"] = body
|
|
if state:
|
|
kwargs["state"] = state
|
|
if labels:
|
|
label_ids = [int(lbl) for lbl in labels.split(",") if lbl.strip().isdigit()]
|
|
if milestone and milestone.strip().isdigit():
|
|
kwargs["milestone"] = int(milestone)
|
|
if assignee:
|
|
kwargs["assignees"] = [assignee]
|
|
|
|
if kwargs:
|
|
await gitea.update_issue(owner, repo, issue_id, **kwargs)
|
|
if label_ids is not None:
|
|
await gitea.update_issue_labels(owner, repo, issue_id, label_ids)
|
|
|
|
# Re-fetch issue to get complete updated state for column recalculation
|
|
issue = await gitea.get_issue(owner, repo, issue_id)
|
|
|
|
# Recalculate card column based on updated labels
|
|
with get_conn() as conn:
|
|
board_row = conn.execute(
|
|
"SELECT id, columns_json FROM boards WHERE project_owner=? AND project_name=?",
|
|
(owner, repo),
|
|
).fetchone()
|
|
if board_row:
|
|
columns = json.loads(board_row["columns_json"])
|
|
col = _issue_column(issue, columns, board_row["id"])
|
|
conn.execute(
|
|
"UPDATE cards SET column_name=?, updated_at=CURRENT_TIMESTAMP WHERE board_id=? AND gitea_issue_id=?",
|
|
(col, board_row["id"], issue_id),
|
|
)
|
|
conn.commit()
|
|
|
|
return {"status": "ok", "issue": issue}
|
|
|
|
|
|
# ── v0.5.0: Card detail ──
|
|
|
|
@router.get("/issues/{owner}/{repo}/{issue_id}")
|
|
@router.get("/issues/{owner}/{repo}/{issue_id}/html")
|
|
async def get_issue_detail(owner: str, repo: str, issue_id: int, format: str = Query(default="json")):
|
|
"""Get full issue details + comments. Set format=html for HTML response."""
|
|
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") from e
|
|
|
|
# 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
|
|
|
|
# HTML format for modal
|
|
if format == "html":
|
|
card_data = _map_issue_to_card(issue, owner, repo)
|
|
ctx = {
|
|
"issue": {
|
|
"id": issue_id,
|
|
"title": issue.get("title", ""),
|
|
"body": issue.get("body", ""),
|
|
"icon": card_data.get("icon", "📄"),
|
|
"author": issue.get("user", {}).get("login", "unknown") if issue.get("user") else "unknown",
|
|
"created": issue.get("created_at", "")[:10] if issue.get("created_at") else "",
|
|
"assignee": (issue.get("assignee") or {}).get("login", ""),
|
|
"due_date": issue.get("due_date", "") or "",
|
|
"status": card_data.get("status", "todo"),
|
|
"status_color": STATUS_COLORS.get(card_data.get("status", ""), "var(--gray)"),
|
|
"status_label": STATUS_LABELS.get(card_data.get("status", ""), "To-do"),
|
|
"labels": issue.get("labels", []),
|
|
},
|
|
"comments": comments,
|
|
"checklists": checklists,
|
|
}
|
|
from app.templating import ENV
|
|
env = ENV
|
|
template = env.get_template("card_detail.html")
|
|
return HTMLResponse(template.render(**ctx))
|
|
|
|
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"}
|
|
|
|
|
|
# ── v1.0.0: User management ──
|
|
|
|
@router.get("/users/me")
|
|
async def get_my_profile(request: Request):
|
|
"""Get current user profile."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
return {"login": "guest", "full_name": "Guest", "email": ""}
|
|
with get_conn() as conn:
|
|
row = conn.execute(
|
|
"SELECT * FROM users WHERE login=?", (user.get("login", ""),)
|
|
).fetchone()
|
|
if row:
|
|
return dict(row)
|
|
return {"login": user.get("login", ""), "full_name": "", "email": ""}
|
|
|
|
|
|
@router.put("/users/me")
|
|
async def update_my_profile(request: Request, full_name: str = Query(default=""),
|
|
email: str = Query(default="")):
|
|
"""Update current user's local profile."""
|
|
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
|
|
if not user:
|
|
raise HTTPException(401, "Not authenticated")
|
|
login = user.get("login", "")
|
|
with get_conn() as conn:
|
|
conn.execute(
|
|
"UPDATE users SET full_name=?, email=? WHERE login=?",
|
|
(full_name, email, login),
|
|
)
|
|
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)}
|
|
|
|
|
|
# ── Frontend Error Capture ───────────────────────────────────
|
|
# Hermes diagnostique le frontend en appelant GET /api/frontend-errors
|
|
|
|
_frontend_errors: list[dict] = []
|
|
_MAX_STORED_ERRORS = 100
|
|
|
|
|
|
@router.post("/frontend-error")
|
|
async def capture_frontend_error(request: Request):
|
|
"""Reçoit les erreurs JS du navigateur. Appelé automatiquement par app.js."""
|
|
try:
|
|
body = await request.json()
|
|
except Exception:
|
|
return {"status": "ignored", "reason": "invalid json"}
|
|
|
|
msg = body.get("message", "")
|
|
err_type = body.get("type", "error")
|
|
source = body.get("source", "")
|
|
line = body.get("line", 0)
|
|
|
|
# Dédupliquer les erreurs identiques consécutives
|
|
if _frontend_errors and _frontend_errors[-1].get("message") == msg:
|
|
_frontend_errors[-1]["count"] = _frontend_errors[-1].get("count", 1) + 1
|
|
_frontend_errors[-1]["time"] = body.get("time", "")
|
|
else:
|
|
body["count"] = 1
|
|
_frontend_errors.append(body)
|
|
|
|
while len(_frontend_errors) > _MAX_STORED_ERRORS:
|
|
_frontend_errors.pop(0)
|
|
|
|
if err_type == "error":
|
|
logger.warning("Frontend JS error: %s (%s:%s)", msg, source, line)
|
|
else:
|
|
logger.warning("Frontend unhandled rejection: %s", msg)
|
|
|
|
return {"status": "ok"}
|
|
|
|
|
|
@router.get("/frontend-errors")
|
|
async def get_frontend_errors(request: Request, clear: bool = True):
|
|
"""Retourne les erreurs frontend récentes. Hermes appelle cette route."""
|
|
errors = list(_frontend_errors)
|
|
if clear:
|
|
_frontend_errors.clear()
|
|
return {
|
|
"errors": errors,
|
|
"count": len(errors),
|
|
"cleared": clear,
|
|
}
|