v0.3.0: OAuth2, CSRF, rate limiting, issue CRUD, webhooks, card detail, checklists
- 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:
+290
-10
@@ -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)}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""FlowDeck — Auth routes: login, callback, logout."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from fastapi import APIRouter, Request, Query
|
||||
from fastapi.responses import RedirectResponse, HTMLResponse
|
||||
|
||||
from app.auth.session import SessionManager
|
||||
from app.auth.oauth import gitea_oauth
|
||||
from app.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["auth"], prefix="/auth")
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login(request: Request):
|
||||
"""Redirect to Gitea OAuth2 authorize page."""
|
||||
if not gitea_oauth.enabled:
|
||||
# Fallback: use global token, create a fake session
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
user = conn.execute("SELECT * FROM users WHERE login='admin'").fetchone()
|
||||
if not user:
|
||||
conn.execute("INSERT INTO users (login, full_name, email, avatar_url) VALUES ('admin', 'Admin', '', '')")
|
||||
conn.commit()
|
||||
user = conn.execute("SELECT * FROM users WHERE login='admin'").fetchone()
|
||||
user_data = dict(user)
|
||||
session = SessionManager.create_session(user_data)
|
||||
response = RedirectResponse(url="/", status_code=302)
|
||||
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax")
|
||||
return response
|
||||
|
||||
state = secrets.token_hex(32)
|
||||
request.session["oauth_state"] = state
|
||||
auth_url = gitea_oauth.get_authorize_url(state)
|
||||
return RedirectResponse(url=auth_url, status_code=302)
|
||||
|
||||
|
||||
@router.get("/callback")
|
||||
async def callback(
|
||||
request: Request,
|
||||
code: str = Query(...),
|
||||
state: str = Query(...),
|
||||
):
|
||||
"""Handle OAuth2 callback from Gitea."""
|
||||
# Validate state
|
||||
expected_state = request.session.get("oauth_state", "")
|
||||
if not expected_state or state != expected_state:
|
||||
return HTMLResponse("<h1>Invalid state</h1>", status_code=400)
|
||||
|
||||
# Exchange code for token
|
||||
token_data = await gitea_oauth.exchange_code(code)
|
||||
if not token_data:
|
||||
return HTMLResponse("<h1>Token exchange failed</h1>", status_code=400)
|
||||
|
||||
access_token = token_data.get("access_token")
|
||||
if not access_token:
|
||||
return HTMLResponse("<h1>No access token</h1>", status_code=400)
|
||||
|
||||
# Get user info
|
||||
user = await gitea_oauth.get_user(access_token)
|
||||
if not user:
|
||||
return HTMLResponse("<h1>Failed to get user</h1>", status_code=400)
|
||||
|
||||
# Store user in DB
|
||||
from app.db import get_conn
|
||||
with get_conn() as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO users (login, full_name, email, avatar_url)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(login)
|
||||
DO UPDATE SET full_name=excluded.full_name, email=excluded.email, avatar_url=excluded.avatar_url""",
|
||||
(user["login"], user.get("full_name", ""), user.get("email", ""), user.get("avatar_url", "")),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Store token
|
||||
SessionManager.store_token(user["id"], access_token)
|
||||
|
||||
# Also store user in local DB
|
||||
with get_conn() as conn:
|
||||
db_user = conn.execute("SELECT * FROM users WHERE login=?", (user["login"],)).fetchone()
|
||||
user_data = dict(db_user) if db_user else user
|
||||
|
||||
# Create session
|
||||
session = SessionManager.create_session(user_data)
|
||||
response = RedirectResponse(url="/", status_code=302)
|
||||
response.set_cookie("flowdeck_session", session, httponly=True, max_age=86400 * 7, samesite="lax")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/logout")
|
||||
async def logout():
|
||||
"""Clear session and redirect to dashboard."""
|
||||
response = RedirectResponse(url="/", status_code=302)
|
||||
response.delete_cookie("flowdeck_session")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/user")
|
||||
async def current_user(request: Request):
|
||||
"""Return current user info as JSON."""
|
||||
from app.auth.session import get_current_user as gcu
|
||||
user = await gcu(request)
|
||||
if not user:
|
||||
return {"authenticated": False}
|
||||
return {"authenticated": True, "user": user}
|
||||
@@ -0,0 +1,179 @@
|
||||
"""FlowDeck — Webhook receiver for real-time Gitea sync."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hmac
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request, HTTPException
|
||||
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["webhooks"], prefix="/api/webhook")
|
||||
|
||||
|
||||
async def verify_signature(request: Request) -> bool:
|
||||
"""Verify Gitea webhook HMAC signature."""
|
||||
if not settings.gitea_webhook_secret:
|
||||
return True # No secret configured, skip verification
|
||||
|
||||
sig = request.headers.get("X-Gitea-Signature", "")
|
||||
if not sig:
|
||||
return False
|
||||
|
||||
body = await request.body()
|
||||
expected = hmac.new(
|
||||
settings.gitea_webhook_secret.encode(),
|
||||
body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
return hmac.compare_digest(sig, expected)
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def receive_webhook(request: Request):
|
||||
"""Receive and process Gitea webhook events."""
|
||||
if not await verify_signature(request):
|
||||
raise HTTPException(status_code=401, detail="Invalid signature")
|
||||
|
||||
event_type = request.headers.get("X-Gitea-Event", "")
|
||||
body = await request.json()
|
||||
|
||||
logger.info("Webhook received: %s", event_type)
|
||||
|
||||
if event_type == "issues":
|
||||
await _handle_issue_event(body)
|
||||
elif event_type == "pull_request":
|
||||
await _handle_pr_event(body)
|
||||
elif event_type == "repository":
|
||||
await _handle_repo_event(body)
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
async def _handle_issue_event(payload: dict):
|
||||
"""Handle issue webhook events."""
|
||||
action = payload.get("action", "")
|
||||
issue = payload.get("issue", {})
|
||||
repo = payload.get("repository", {})
|
||||
|
||||
owner = repo.get("owner", {}).get("login", "")
|
||||
repo_name = repo.get("name", "")
|
||||
issue_id = issue.get("number", 0)
|
||||
|
||||
if not all([owner, repo_name, issue_id]):
|
||||
return
|
||||
|
||||
with get_conn() as conn:
|
||||
board = conn.execute(
|
||||
"SELECT id FROM boards WHERE project_owner=? AND project_name=?",
|
||||
(owner, repo_name),
|
||||
).fetchone()
|
||||
|
||||
if not board:
|
||||
return
|
||||
board_id = board["id"]
|
||||
|
||||
if action in ("opened", "reopened"):
|
||||
# Add card to default column
|
||||
column = "Backlog"
|
||||
conn.execute(
|
||||
"""INSERT OR IGNORE INTO cards (board_id, gitea_issue_id, column_name, position)
|
||||
VALUES (?, ?, ?, 0)""",
|
||||
(board_id, issue_id, column),
|
||||
)
|
||||
|
||||
elif action == "closed":
|
||||
# Move to Terminé column
|
||||
conn.execute(
|
||||
"UPDATE cards SET column_name='Terminé', updated_at=CURRENT_TIMESTAMP WHERE board_id=? AND gitea_issue_id=?",
|
||||
(board_id, issue_id),
|
||||
)
|
||||
|
||||
elif action == "label_updated" or action == "labeled":
|
||||
# Check if any new label maps to a column
|
||||
labels = issue.get("labels", [])
|
||||
for lbl in labels:
|
||||
mapping = conn.execute(
|
||||
"SELECT column_name FROM col_mapping WHERE board_id=? AND gitea_label=?",
|
||||
(board_id, lbl.get("name", "")),
|
||||
).fetchone()
|
||||
if mapping:
|
||||
conn.execute(
|
||||
"UPDATE cards SET column_name=?, updated_at=CURRENT_TIMESTAMP WHERE board_id=? AND gitea_issue_id=?",
|
||||
(mapping["column_name"], board_id, issue_id),
|
||||
)
|
||||
break
|
||||
|
||||
elif action == "deleted":
|
||||
conn.execute(
|
||||
"DELETE FROM cards WHERE board_id=? AND gitea_issue_id=?",
|
||||
(board_id, issue_id),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
|
||||
logger.debug("Issue webhook processed: %s/%s #%d action=%s", owner, repo_name, issue_id, action)
|
||||
|
||||
|
||||
async def _handle_pr_event(payload: dict):
|
||||
"""Handle pull request webhook events."""
|
||||
# For now, just invalidate cache so board refreshes
|
||||
from app.services.gitea_client import gitea
|
||||
repo = payload.get("repository", {})
|
||||
owner = repo.get("owner", {}).get("login", "")
|
||||
repo_name = repo.get("name", "")
|
||||
if owner and repo_name:
|
||||
gitea._invalidate_issue_cache(owner, repo_name)
|
||||
|
||||
|
||||
async def _handle_repo_event(payload: dict):
|
||||
"""Handle repository events (create, delete, etc.)."""
|
||||
# Invalidate projects cache
|
||||
from app.services.gitea_client import gitea
|
||||
gitea._cache.clear()
|
||||
|
||||
|
||||
@router.post("/register/{owner}/{repo}")
|
||||
async def register_webhook(owner: str, repo: str, request: Request):
|
||||
"""Register a webhook for a specific Gitea repository."""
|
||||
from app.services.gitea_client import gitea
|
||||
|
||||
webhook_url = settings.webhook_base_url.rstrip("/") + "/api/webhook"
|
||||
if not webhook_url or not settings.gitea_webhook_secret:
|
||||
raise HTTPException(status_code=400, detail="Webhook URL or secret not configured")
|
||||
|
||||
try:
|
||||
webhooks = await gitea.list_webhooks(owner, repo)
|
||||
|
||||
# Check if already registered
|
||||
for wh in webhooks:
|
||||
if wh.get("url") == webhook_url:
|
||||
return {"status": "already_registered", "webhook": wh}
|
||||
|
||||
# Create webhook
|
||||
result = await gitea.create_webhook(owner, repo, webhook_url, settings.gitea_webhook_secret)
|
||||
return {"status": "ok", "webhook": result}
|
||||
except Exception as e:
|
||||
logger.error("Failed to register webhook: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.get("/status/{owner}/{repo}")
|
||||
async def webhook_status(owner: str, repo: str):
|
||||
"""Check webhook registration status."""
|
||||
from app.services.gitea_client import gitea
|
||||
|
||||
try:
|
||||
webhook_url = settings.webhook_base_url.rstrip("/") + "/api/webhook"
|
||||
webhooks = await gitea.list_webhooks(owner, repo)
|
||||
for wh in webhooks:
|
||||
if wh.get("url") == webhook_url:
|
||||
return {"registered": True, "webhook": wh}
|
||||
return {"registered": False}
|
||||
except Exception as e:
|
||||
return {"registered": False, "error": str(e)}
|
||||
Reference in New Issue
Block a user