v0.3.0: OAuth2, CSRF, rate limiting, issue CRUD, webhooks, card detail, checklists
CI / test (push) Failing after 5s
CI / lint (push) Failing after 4s

- OAuth2 Gitea (login/callback/logout) with fallback admin mode
- Session management with signed cookies (itsdangerous, 7 days)
- CSRF protection middleware on all POST/PUT/PATCH/DELETE
- Rate limiting in-memory (60 req/min per IP)
- Issue creation from FlowDeck (POST /api/issues/{owner}/{repo})
- Inline issue editing (PATCH /api/issues/{owner}/{repo}/{issue_id})
- Card detail modal (double-click): description, labels, comments
- Checklists with toggleable items (CRUD endpoints + UI)
- Priority + due date editable on cards
- Webhook receiver (POST /api/webhook) with HMAC-SHA256
- Auto-register/status webhooks for repos
- GiteaClient: create_issue, update_issue, get_comments, webhooks, collaborators
- New DB tables: users, user_tokens, checklists, checklist_items
- CSS: modal, forms, checklists, comments, PR section
This commit is contained in:
2026-07-08 09:23:57 -04:00
parent 87862b64b8
commit 5a124d1328
17 changed files with 1390 additions and 91 deletions
+290 -10
View File
@@ -4,8 +4,9 @@ from __future__ import annotations
import json
import logging
from datetime import datetime
from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from fastapi import APIRouter, HTTPException, Query, Request
from app.config import settings
from app.db import get_conn
@@ -14,6 +15,25 @@ 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():
@@ -34,7 +54,7 @@ async def health():
return {
"status": "ok" if (db_ok and gitea_ok) else "degraded",
"version": "0.2.0",
"version": "0.3.0",
"db": db_ok,
"gitea": gitea_ok,
}
@@ -47,11 +67,13 @@ async def stats():
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,
}
@@ -82,12 +104,16 @@ async def projects(search: str = Query(default=""), show_archived: bool = Query(
@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=?",
@@ -98,7 +124,6 @@ async def move_card(
board_id = board["id"]
# Upsert card
existing = conn.execute(
"SELECT id FROM cards WHERE board_id=? AND gitea_issue_id=?",
(board_id, issue_id),
@@ -115,7 +140,6 @@ async def move_card(
(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),
@@ -123,16 +147,13 @@ async def move_card(
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"])
@@ -144,7 +165,6 @@ async def move_card(
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=?",
@@ -185,7 +205,6 @@ async def set_col_mapping(
@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=?",
@@ -212,7 +231,6 @@ async def update_board_config(
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=?",
@@ -230,3 +248,265 @@ async def update_board_config(
)
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)}