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:
2026-07-07 23:24:15 -04:00
commit 3732b5cb45
13 changed files with 1094 additions and 0 deletions
View File
+76
View File
@@ -0,0 +1,76 @@
"""
Gitea API client for Kanbante.
"""
import os
from typing import Any
import httpx
from dotenv import load_dotenv
load_dotenv(os.path.expanduser("~/workspace/.env"))
class GiteaClient:
"""Lightweight Gitea API client for Kanbante."""
def __init__(self) -> None:
self.base_url = os.getenv("GITEA_URL", "https://git.dracodev.net")
self.token = os.getenv("GITEA_TOKEN", "")
self.client = httpx.Client(
base_url=f"{self.base_url}/api/v1",
headers={
"Authorization": f"token {self.token}",
"Accept": "application/json",
},
timeout=15.0,
)
def _get(self, path: str, **params: Any) -> dict | list:
r = self.client.get(path, params=params)
r.raise_for_status()
return r.json()
def get_user_repos(self, username: str = "bruno") -> list[dict]:
"""Get all repos for a user."""
return self._get(f"/users/{username}/repos", limit=100)
def search_repos(self, uid: int = 1) -> list[dict]:
"""Search all repos visible to user."""
result = self._get("/repos/search", uid=uid, limit=200)
if isinstance(result, dict) and "data" in result:
return result["data"]
return result if isinstance(result, list) else []
def get_org_repos(self, org: str) -> list[dict]:
"""Get repos for an organization."""
return self._get(f"/orgs/{org}/repos", limit=50)
def get_repo_issues(
self, owner: str, repo: str, state: str = "all"
) -> list[dict]:
"""Get issues for a repo."""
return self._get(
f"/repos/{owner}/{repo}/issues",
state=state,
limit=100,
)
def get_all_issues(
self, repos: list[dict], state: str = "open"
) -> dict[str, list[dict]]:
"""Get issues for multiple repos, keyed by full_name."""
issues_by_repo: dict[str, list[dict]] = {}
for repo in repos:
full_name = repo["full_name"]
owner, name = full_name.split("/", 1)
try:
issues = self.get_repo_issues(owner, name, state=state)
issues_by_repo[full_name] = issues
except Exception:
issues_by_repo[full_name] = []
return issues_by_repo
def get_user(self) -> dict:
"""Get authenticated user info."""
return self._get("/user")
+192
View File
@@ -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"}
+119
View File
@@ -0,0 +1,119 @@
"""
SQLite models for Kanbante — lightweight local state.
"""
import sqlite3
import os
from datetime import datetime
DB_PATH = os.path.expanduser("~/.kanbante/kanbante.db")
KANBAN_COLUMNS = ["backlog", "todo", "in_progress", "review", "done"]
def get_db() -> sqlite3.Connection:
os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
return conn
def init_db() -> None:
conn = get_db()
conn.executescript("""
CREATE TABLE IF NOT EXISTS board_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_full_name TEXT NOT NULL,
issue_number INTEGER NOT NULL,
column_name TEXT NOT NULL DEFAULT 'backlog',
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(repo_full_name, issue_number)
);
CREATE TABLE IF NOT EXISTS project_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_full_name TEXT NOT NULL,
note TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(repo_full_name)
);
CREATE INDEX IF NOT EXISTS idx_board_repo
ON board_state(repo_full_name, column_name);
""")
conn.commit()
conn.close()
def get_board_state(repo_full_name: str) -> dict[str, list[dict]]:
"""Return board columns with their issues."""
conn = get_db()
rows = conn.execute(
"SELECT issue_number, column_name, updated_at FROM board_state WHERE repo_full_name = ?",
(repo_full_name,),
).fetchall()
conn.close()
columns: dict[str, list[dict]] = {col: [] for col in KANBAN_COLUMNS}
for row in rows:
columns[row["column_name"]].append(
{
"issue_number": row["issue_number"],
"updated_at": row["updated_at"],
}
)
return columns
def move_issue(repo_full_name: str, issue_number: int, to_column: str) -> None:
"""Move an issue to a different column."""
conn = get_db()
conn.execute(
"""INSERT INTO board_state (repo_full_name, issue_number, column_name, updated_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT(repo_full_name, issue_number)
DO UPDATE SET column_name = excluded.column_name, updated_at = excluded.updated_at""",
(repo_full_name, issue_number, to_column),
)
conn.commit()
conn.close()
def set_notes(repo_full_name: str, note: str) -> None:
conn = get_db()
conn.execute(
"""INSERT INTO project_notes (repo_full_name, note)
VALUES (?, ?)
ON CONFLICT(repo_full_name)
DO UPDATE SET note = excluded.note""",
(repo_full_name, note),
)
conn.commit()
conn.close()
def get_notes(repo_full_name: str) -> str | None:
conn = get_db()
row = conn.execute(
"SELECT note FROM project_notes WHERE repo_full_name = ?",
(repo_full_name,),
).fetchone()
conn.close()
return row["note"] if row else None
def get_stats() -> dict:
"""Get overall stats across all projects."""
conn = get_db()
rows = conn.execute(
"SELECT repo_full_name, column_name, COUNT(*) as cnt FROM board_state GROUP BY repo_full_name, column_name"
).fetchall()
conn.close()
projects: dict[str, dict[str, int]] = {}
for row in rows:
name = row["repo_full_name"]
if name not in projects:
projects[name] = {col: 0 for col in KANBAN_COLUMNS}
projects[name][row["column_name"]] = row["cnt"]
return projects
+84
View File
@@ -0,0 +1,84 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ repo }} — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
<script src="https://unpkg.com/[email protected]"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/Sortable.min.js"></script>
<script src="/static/js/board.js" defer></script>
</head>
<body>
<header>
<h1>
<a href="/">📋 Kanbante</a>
<span class="sep">/</span>
<span>{{ owner }}/</span><strong>{{ repo }}</strong>
</h1>
<nav>
{% if repo_info.html_url %}
<a href="{{ repo_info.html_url }}" target="_blank" class="btn-sm">🔗 Gitea</a>
{% endif %}
</nav>
</header>
<main class="board-container">
<div class="kanban-board">
{% for col in ['backlog','todo','in_progress','review','done'] %}
<div class="kanban-column"
data-column="{{ col }}">
<div class="column-header" style="border-top: 3px solid {{ column_colors[col] }}">
<span class="column-title">{{ column_labels[col] }}</span>
<span class="column-count">{{ columns[col]|length }}</span>
</div>
<div class="column-cards" data-column="{{ col }}">
{% for issue in columns[col] %}
<div class="kanban-card"
data-issue="{{ issue.number }}"
data-repo="{{ full_name }}"
draggable="true">
<div class="card-id">#{{ issue.number }}</div>
<div class="card-title">{{ issue.title }}</div>
{% if issue.labels %}
<div class="card-labels">
{% for label in issue.labels %}
<span class="label-tag" style="background: #{{ label.color }}30; color: #{{ label.color }}">
{{ label.name }}
</span>
{% endfor %}
</div>
{% endif %}
<div class="card-meta">
{% if issue.assignee %}
<img src="{{ issue.assignee.avatar_url }}" class="avatar" alt="{{ issue.assignee.login }}">
{% endif %}
<span class="card-date">{{ issue.created_at[:10] }}</span>
</div>
</div>
{% endfor %}
</div>
</div>
{% endfor %}
</div>
<!-- Notes panel -->
<aside class="notes-panel">
<h3>📝 Notes du projet</h3>
<form hx-post="/api/notes" hx-swap="none">
<input type="hidden" name="repo_full_name" value="{{ full_name }}">
<textarea name="note" rows="10"
placeholder="Notes, idées, liens...">{{ notes }}</textarea>
<button type="submit" class="btn-save">💾 Sauvegarder</button>
</form>
</aside>
</main>
<footer>
<span>{{ full_name }}</span>
{% if repo_info.updated_at %}
<span>Mis à jour {{ repo_info.updated_at[:10] }}</span>
{% endif %}
</footer>
</body>
</html>
+90
View File
@@ -0,0 +1,90 @@
<!DOCTYPE html>
<html lang="fr" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Kanbante — Dashboard</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📋</text></svg>">
<link rel="stylesheet" href="/static/css/style.css">
<script src="https://unpkg.com/[email protected]"></script>
<script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
</head>
<body>
<header>
<h1><a href="/">📋 <span class="gradient-text">Kanbante</span></a></h1>
<nav>
<span class="badge">{{ active_repos|length }} actifs</span>
<span class="badge secondary">{{ inactive_repos|length }} archivés</span>
</nav>
</header>
<main x-data="{ tab: 'active', search: '' }">
<!-- Tabs -->
<div class="tabs">
<button :class="{ active: tab === 'active' }" @click="tab = 'active'">
🟢 Actifs ({{ active_repos|length }})
</button>
<button :class="{ active: tab === 'inactive' }" @click="tab = 'inactive'">
⚪ Archivés ({{ inactive_repos|length }})
</button>
</div>
<!-- Search -->
<div class="search-bar">
<input type="text" placeholder="🔍 Filtrer les projets..." x-model="search">
</div>
<!-- Active Projects -->
<div x-show="tab === 'active'" class="project-grid">
{% for repo in active_repos %}
{% set full = repo.full_name %}
<div class="project-card"
x-show="search === '' || '{{ repo.name.lower() }}{{ repo.full_name.lower() }}'.includes(search.toLowerCase())">
<div class="card-header">
<span class="org-tag">{{ repo.owner.login }}</span>
<span class="date">{{ repo.updated_at[:10] }}</span>
</div>
<h3>
<a href="/board/{{ repo.owner.login }}/{{ repo.name }}">{{ repo.name }}</a>
</h3>
<p class="desc">{{ repo.description or '—' }}</p>
<div class="card-footer">
<span>{{ repo.open_issues_count or 0 }} issues</span>
{% if full in stats %}
<div class="mini-progress">
{% for col in ['backlog','todo','in_progress','review','done'] %}
{% set n = stats[full].get(col, 0) %}
{% if n > 0 %}
<span class="chip" style="background: {{ COLUMN_COLORS[col] }}20; color: {{ COLUMN_COLORS[col] }}">
{{ n }}
</span>
{% endif %}
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<!-- Inactive Projects -->
<div x-show="tab === 'inactive'" class="project-grid">
{% for repo in inactive_repos %}
<div class="project-card archived"
x-show="search === '' || '{{ repo.name.lower() }}'.includes(search.toLowerCase())">
<div class="card-header">
<span class="org-tag">{{ repo.owner.login }}</span>
<span class="date">{{ repo.updated_at[:10] }}</span>
</div>
<h3>{{ repo.name }}</h3>
<p class="desc">{{ repo.description or '—' }}</p>
</div>
{% endfor %}
</div>
</main>
<footer>
Kanbante v0.1 · {{ active_repos|length + inactive_repos|length }} projets sur Gitea
</footer>
</body>
</html>