feat: Kanbante v0.1.0 — Kanban Git-integrated PM tool
- Dashboard with all Gitea projects (active/archived) - Kanban board per project (Backlog → À faire → En cours → Révision → Terminé) - Drag & drop issues between columns (SortableJS) - Project notes panel - Gitea API integration (read issues, repos) - SQLite local state for board positions - Dark theme design system - Docker-ready (Dockerfile + docker-compose.yml) - FastAPI + Jinja2 + HTMX + Alpine.js stack
This commit is contained in:
+192
@@ -0,0 +1,192 @@
|
||||
"""
|
||||
Kanbante — Kanban project manager integrated with Gitea.
|
||||
FastAPI backend serving HTML + JSON API.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request, Form
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.gitea_client import GiteaClient
|
||||
from app.models import (
|
||||
init_db,
|
||||
get_board_state,
|
||||
move_issue,
|
||||
set_notes,
|
||||
get_notes,
|
||||
KANBAN_COLUMNS,
|
||||
get_stats,
|
||||
)
|
||||
|
||||
from fastapi.templating import Jinja2Templates as _Jinja2Templates
|
||||
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
||||
|
||||
# ── App setup ──────────────────────────────────────────────
|
||||
BASE = Path(__file__).resolve().parent.parent
|
||||
app = FastAPI(title="Kanbante", version="0.1.0")
|
||||
app.mount("/static", StaticFiles(directory=str(BASE / "static")), name="static")
|
||||
gitea = GiteaClient()
|
||||
|
||||
# ── Templates (workaround Starlette 1.x + Jinja2 cache bug) ──
|
||||
_tpl_env = Environment(
|
||||
loader=FileSystemLoader(str(BASE / "app" / "templates")),
|
||||
autoescape=select_autoescape(["html"]),
|
||||
)
|
||||
|
||||
|
||||
def render_template(name: str, **context) -> HTMLResponse:
|
||||
"""Render a Jinja2 template directly (bypasses Starlette's buggy caching)."""
|
||||
template = _tpl_env.get_template(name)
|
||||
html = template.render(**context)
|
||||
return HTMLResponse(html)
|
||||
|
||||
# Column display names
|
||||
COLUMN_LABELS = {
|
||||
"backlog": "📥 Backlog",
|
||||
"todo": "📋 À faire",
|
||||
"in_progress": "🚧 En cours",
|
||||
"review": "🔍 Révision",
|
||||
"done": "✅ Terminé",
|
||||
}
|
||||
|
||||
COLUMN_COLORS = {
|
||||
"backlog": "#6b7280",
|
||||
"todo": "#3b82f6",
|
||||
"in_progress": "#f59e0b",
|
||||
"review": "#8b5cf6",
|
||||
"done": "#10b981",
|
||||
}
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
init_db()
|
||||
|
||||
|
||||
# ── Helpers ────────────────────────────────────────────────
|
||||
|
||||
def _is_active(repo: dict) -> bool:
|
||||
"""A repo is active if updated in 2026."""
|
||||
return repo.get("updated_at", "")[:4] == "2026"
|
||||
|
||||
|
||||
def _merge_issues_with_board(issues: list[dict], board: dict[str, list[dict]]) -> dict[str, list[dict]]:
|
||||
"""Merge Gitea issues into kanban columns based on board state."""
|
||||
columns = {col: [] for col in KANBAN_COLUMNS}
|
||||
|
||||
# Build a lookup of board state by issue number
|
||||
issue_positions: dict[int, str] = {}
|
||||
for col_name, cards in board.items():
|
||||
for card in cards:
|
||||
issue_positions[card["issue_number"]] = col_name
|
||||
|
||||
for issue in issues:
|
||||
num = issue["number"]
|
||||
col = issue_positions.get(num, "backlog")
|
||||
columns[col].append(issue)
|
||||
|
||||
return columns
|
||||
|
||||
|
||||
# ── Routes: Pages ─────────────────────────────────────────
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request):
|
||||
"""Main dashboard — all projects at a glance."""
|
||||
repos = gitea.search_repos()
|
||||
active = [r for r in repos if _is_active(r)]
|
||||
inactive = [r for r in repos if not _is_active(r)]
|
||||
|
||||
stats = get_stats()
|
||||
|
||||
ctx = {
|
||||
"active_repos": list(active),
|
||||
"inactive_repos": list(inactive),
|
||||
"stats": {k: dict(v) for k, v in stats.items()},
|
||||
"COLUMN_LABELS": dict(COLUMN_LABELS),
|
||||
"COLUMN_COLORS": dict(COLUMN_COLORS),
|
||||
}
|
||||
return render_template("dashboard.html", **ctx)
|
||||
|
||||
|
||||
@app.get("/board/{owner}/{repo}", response_class=HTMLResponse)
|
||||
async def board(request: Request, owner: str, repo: str):
|
||||
"""Kanban board for a specific repo."""
|
||||
full_name = f"{owner}/{repo}"
|
||||
issues = gitea.get_repo_issues(owner, repo, state="open")
|
||||
board_state = get_board_state(full_name)
|
||||
columns = _merge_issues_with_board(issues, board_state)
|
||||
|
||||
notes = get_notes(full_name) or ""
|
||||
|
||||
# Get repo info
|
||||
repo_info = {}
|
||||
try:
|
||||
import httpx
|
||||
client = gitea.client
|
||||
r = client.get(f"/repos/{owner}/{repo}")
|
||||
if r.status_code == 200:
|
||||
repo_info = r.json()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return render_template("board.html",
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
full_name=full_name,
|
||||
columns=columns,
|
||||
column_labels=COLUMN_LABELS,
|
||||
column_colors=COLUMN_COLORS,
|
||||
notes=notes,
|
||||
repo_info=repo_info,
|
||||
)
|
||||
|
||||
|
||||
# ── Routes: API ────────────────────────────────────────────
|
||||
|
||||
@app.post("/api/move")
|
||||
async def api_move(
|
||||
repo_full_name: str = Form(...),
|
||||
issue_number: int = Form(...),
|
||||
to_column: str = Form(...),
|
||||
):
|
||||
"""Move an issue to a new column."""
|
||||
if to_column not in KANBAN_COLUMNS:
|
||||
return {"ok": False, "error": "Invalid column"}
|
||||
move_issue(repo_full_name, issue_number, to_column)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/notes")
|
||||
async def api_notes(
|
||||
repo_full_name: str = Form(...),
|
||||
note: str = Form(""),
|
||||
):
|
||||
"""Save project notes."""
|
||||
set_notes(repo_full_name, note)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/stats")
|
||||
async def api_stats():
|
||||
"""Get project stats."""
|
||||
return get_stats()
|
||||
|
||||
|
||||
@app.get("/api/projects")
|
||||
async def api_projects():
|
||||
"""Get all projects (JSON)."""
|
||||
repos = gitea.search_repos()
|
||||
return {
|
||||
"active": [r for r in repos if _is_active(r)],
|
||||
"inactive": [r for r in repos if not _is_active(r)],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health():
|
||||
return {"status": "ok", "version": "0.1.0"}
|
||||
Reference in New Issue
Block a user