Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
39b751df56 | ||
|
|
1cc27bd4fb | ||
|
|
0c280b3c7e | ||
|
|
395ab6e14c |
@@ -1,5 +1,29 @@
|
||||
# Changelog
|
||||
|
||||
## 0.7.0 — 2026-10-11
|
||||
|
||||
### Nouveauté
|
||||
- **Capture éclair** (issue #12 de la feuille de route) : trois
|
||||
entrées, un seul geste — transformer un message en issue placée
|
||||
sur le board (événement `created`, source `capture`) :
|
||||
- `POST /api/capture` : endpoint machine-à-machine protégé par un
|
||||
**jeton de capture dédié** (`KANBANTE_CAPTURE_TOKEN`, en-tête
|
||||
`X-Capture-Token` ou Bearer ; distinct du jeton Gitea, révocable
|
||||
seul ; désactivé en 503 tant qu'il n'est pas défini ; exempté
|
||||
d'auth Basic et de CSRF). JSON `{title, body, repo, column}` ou
|
||||
texte libre (première ligne = titre). Dépôt par défaut :
|
||||
`KANBANTE_CAPTURE_REPO`.
|
||||
- **Écoute ntfy** : si `KANBANTE_CAPTURE_NTFY_TOPIC` est défini,
|
||||
un abonné en tâche de fond (flux JSON du topic, reconnexion avec
|
||||
recul, curseur persisté dans `kv_store` — aucun rejeu au
|
||||
redémarrage) transforme chaque message en issue. Les messages
|
||||
tagués `kanbante` sont ignorés (pas d'auto-ingestion).
|
||||
- Page **`/capture`** : formulaire de saisie rapide, statut de
|
||||
configuration et exemples prêts à copier (curl, ntfy). Lien
|
||||
« ⚡ Capture » dans la navigation de toutes les pages.
|
||||
|
||||
12 tests ajoutés (110 au total).
|
||||
|
||||
## 0.6.0 — 2026-10-11
|
||||
|
||||
### Nouveautés
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
- ⏳ **Vieillissement + limites WIP** — badge d'ancienneté par colonne et limites souples configurables
|
||||
- 🎯 **Vue Focus** — tout ce qui est En cours / en QA / En révision, tous projets confondus (`/focus`)
|
||||
- 🖥️ **Wallboard** — mode TV plein écran (`/wall`) : rotation automatique entre les projets actifs, cartes en vol et compteurs en très grands caractères, rafraîchi en direct ; `?repo=Proprietaire/depot` fige un seul projet
|
||||
- ⚡ **Capture éclair** — page `/capture` (formulaire), endpoint `POST /api/capture` protégé par un jeton dédié, et écoute ntfy : un message publié sur le topic de capture devient une issue dans le dépôt par défaut
|
||||
- 🔀 **Réconciliation par étiquettes** — si l'étiquette `kanban:*` d'une issue change hors de Kanbante (dans Gitea, par un autre outil), le board adopte la colonne de l'étiquette au chargement et le journalise (source `gitea`). Une seule étiquette `kanban:*` fait foi ; aucune ou plusieurs = l'état local gagne
|
||||
- 🔍 **Recherche globale + Mes issues** — chercher dans toutes les issues de tous les projets (`/search`), et voir ce qui vous est assigné (`/my`)
|
||||
- 📊 **Briefing quotidien** — encours, vieillissement, milestones proches : page `/briefing` et envoi **ntfy** planifié
|
||||
@@ -112,6 +113,9 @@ sudo systemctl enable --now docker
|
||||
| `KANBANTE_NTFY_TOPIC` | *(vide)* | Topic ntfy du briefing (URL + topic requis pour activer l'envoi) |
|
||||
| `KANBANTE_NTFY_TOKEN` | *(vide)* | Jeton Bearer optionnel pour un topic protégé |
|
||||
| `KANBANTE_BRIEFING_TIME` | *(vide)* | Heure d'envoi quotidien du briefing, `HH:MM` heure du serveur ; vide = pas d'envoi automatique |
|
||||
| `KANBANTE_CAPTURE_REPO` | *(vide)* | Dépôt par défaut de la capture éclair (`Proprietaire/depot`) |
|
||||
| `KANBANTE_CAPTURE_TOKEN` | *(vide)* | Jeton dédié de `POST /api/capture` ; vide = endpoint désactivé (503) |
|
||||
| `KANBANTE_CAPTURE_NTFY_TOPIC` | *(vide)* | Topic ntfy écouté : chaque message devient une issue dans `KANBANTE_CAPTURE_REPO` |
|
||||
|
||||
## Sécurité
|
||||
|
||||
@@ -155,6 +159,8 @@ kanbante/
|
||||
| GET | `/board/{owner}/{repo}` | Kanban board |
|
||||
| GET | `/focus` | En cours + QA + En révision, tous projets actifs |
|
||||
| GET | `/wall` | Wallboard plein écran (rotation des projets actifs ; `?repo=` pour un seul projet) |
|
||||
| GET | `/capture` | Page de capture éclair (formulaire + statut + instructions) |
|
||||
| POST | `/api/capture` | Capture machine-à-machine (jeton `X-Capture-Token`, JSON ou texte : 1re ligne = titre) |
|
||||
| GET | `/activity` | Fil d'activité (journal d'événements, tous projets) |
|
||||
| POST | `/api/move` | Déplacer une issue (ordre persisté, synchro Gitea) |
|
||||
| POST | `/api/issues` | Créer une issue Gitea depuis le board |
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
"""Quick capture (roadmap #12): turn an external message into an issue.
|
||||
|
||||
Two entry points share the same parsing:
|
||||
- POST /api/capture (token-authenticated, in app.main) — curl, a
|
||||
shortcut, any automation;
|
||||
- an ntfy *listener*: messages published to a dedicated topic become
|
||||
issues in the default capture repo. Capture from a phone is then
|
||||
just "share to the ntfy app".
|
||||
|
||||
The listener is a background task with reconnect + dedupe: the last
|
||||
processed ntfy message id is kept (by the caller, in kv_store) and
|
||||
passed back as `since`, so a restart never re-ingests old messages.
|
||||
Messages tagged "kanbante" are skipped — Kanbante never ingests its
|
||||
own posts.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
logger = logging.getLogger("kanbante.capture")
|
||||
|
||||
MAX_TITLE_CHARS = 255
|
||||
|
||||
|
||||
def parse_capture_text(text: str) -> tuple[str, str]:
|
||||
"""First non-empty line = title, the rest = body."""
|
||||
lines = (text or "").strip().splitlines()
|
||||
lines = [line.rstrip() for line in lines]
|
||||
while lines and not lines[0].strip():
|
||||
lines.pop(0)
|
||||
if not lines:
|
||||
return "", ""
|
||||
title = lines[0].strip()[:MAX_TITLE_CHARS]
|
||||
body = "\n".join(lines[1:]).strip()
|
||||
return title, body
|
||||
|
||||
|
||||
def parse_ntfy_message(msg: dict) -> tuple[str, str] | None:
|
||||
"""Extract (title, body) from an ntfy message, or None to skip it."""
|
||||
if msg.get("event") not in (None, "message"):
|
||||
return None
|
||||
if "kanbante" in (msg.get("tags") or []):
|
||||
return None
|
||||
title = str(msg.get("title") or "").strip()
|
||||
message = str(msg.get("message") or "").strip()
|
||||
if title and message:
|
||||
return title[:MAX_TITLE_CHARS], message
|
||||
if message:
|
||||
return parse_capture_text(message)
|
||||
if title:
|
||||
return title[:MAX_TITLE_CHARS], ""
|
||||
return None
|
||||
|
||||
|
||||
async def ntfy_capture_loop(create_issue, get_last_id, set_last_id) -> None:
|
||||
"""Subscribe to the capture topic forever (reconnect with backoff).
|
||||
|
||||
create_issue(title, body) is an async callback; get_last_id /
|
||||
set_last_id persist the ntfy cursor. Returns quietly when the
|
||||
listener is no longer configured.
|
||||
"""
|
||||
seen: set[str] = set()
|
||||
while True:
|
||||
settings = get_settings()
|
||||
base = settings.ntfy_url.rstrip("/")
|
||||
topic = settings.capture_ntfy_topic
|
||||
if not base or not topic:
|
||||
return
|
||||
since = get_last_id() or "latest"
|
||||
headers = {}
|
||||
if settings.ntfy_token:
|
||||
headers["Authorization"] = f"Bearer {settings.ntfy_token}"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=None) as client:
|
||||
async with client.stream(
|
||||
"GET", f"{base}/{topic}/json?since={since}", headers=headers
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
logger.info(
|
||||
"ntfy capture listening on %s/%s (since=%s)",
|
||||
base, topic, since,
|
||||
)
|
||||
async for line in response.aiter_lines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
msg_id = str(msg.get("id") or "")
|
||||
if msg_id:
|
||||
if msg_id in seen:
|
||||
continue
|
||||
seen.add(msg_id)
|
||||
if len(seen) > 500:
|
||||
seen = set(list(seen)[-250:])
|
||||
set_last_id(msg_id)
|
||||
parsed = parse_ntfy_message(msg)
|
||||
if parsed is None:
|
||||
continue
|
||||
title, body = parsed
|
||||
try:
|
||||
await create_issue(title, body)
|
||||
except Exception:
|
||||
logger.exception("capture failed for %r", title)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"ntfy capture stream interrupted (%s); retrying in 10s", exc
|
||||
)
|
||||
await asyncio.sleep(10)
|
||||
+22
-1
@@ -36,6 +36,21 @@ Environment variables:
|
||||
the daily briefing is sent to ntfy.
|
||||
Empty (default) = no automatic send,
|
||||
the /briefing page stays available.
|
||||
KANBANTE_CAPTURE_REPO Default repo for quick capture
|
||||
(POST /api/capture, the /capture page
|
||||
and the ntfy capture listener), e.g.
|
||||
"Projets/kanbante". Empty = captures
|
||||
must name their repo explicitly.
|
||||
KANBANTE_CAPTURE_TOKEN Dedicated token for POST /api/capture
|
||||
(header X-Capture-Token or Bearer).
|
||||
Empty (default) = the endpoint is
|
||||
disabled (503). Distinct from the
|
||||
Gitea token; revocable on its own.
|
||||
KANBANTE_CAPTURE_NTFY_TOPIC ntfy topic listened to for captures:
|
||||
each message published there becomes
|
||||
an issue in KANBANTE_CAPTURE_REPO.
|
||||
Uses KANBANTE_NTFY_URL/_TOKEN as the
|
||||
server. Empty (default) = no listener.
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -44,7 +59,7 @@ from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
APP_VERSION = "0.6.0"
|
||||
APP_VERSION = "0.7.0"
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
@@ -100,6 +115,9 @@ class Settings:
|
||||
ntfy_topic: str = ""
|
||||
ntfy_token: str = ""
|
||||
briefing_time: str = ""
|
||||
capture_repo: str = ""
|
||||
capture_token: str = ""
|
||||
capture_ntfy_topic: str = ""
|
||||
|
||||
@property
|
||||
def auth_enabled(self) -> bool:
|
||||
@@ -129,4 +147,7 @@ def get_settings() -> Settings:
|
||||
ntfy_topic=os.getenv("KANBANTE_NTFY_TOPIC", ""),
|
||||
ntfy_token=os.getenv("KANBANTE_NTFY_TOKEN", ""),
|
||||
briefing_time=os.getenv("KANBANTE_BRIEFING_TIME", ""),
|
||||
capture_repo=os.getenv("KANBANTE_CAPTURE_REPO", ""),
|
||||
capture_token=os.getenv("KANBANTE_CAPTURE_TOKEN", ""),
|
||||
capture_ntfy_topic=os.getenv("KANBANTE_CAPTURE_NTFY_TOPIC", ""),
|
||||
)
|
||||
|
||||
+174
-1
@@ -35,6 +35,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from app.config import APP_VERSION, get_settings
|
||||
from app.gitea_client import GiteaClient
|
||||
from app.briefing import format_briefing_text, ntfy_configured, send_ntfy
|
||||
from app.capture import ntfy_capture_loop, parse_capture_text
|
||||
from app.models import (
|
||||
KANBAN_COLUMNS,
|
||||
column_entry_times,
|
||||
@@ -139,8 +140,30 @@ async def lifespan(app: FastAPI):
|
||||
"GITEA_TOKEN is not set: only public Gitea content will be visible."
|
||||
)
|
||||
scheduler = asyncio.create_task(_briefing_scheduler())
|
||||
capture_task = None
|
||||
if settings.capture_ntfy_topic and settings.ntfy_url:
|
||||
async def _capture_create(title: str, body: str) -> None:
|
||||
repo = get_settings().capture_repo
|
||||
if not repo:
|
||||
logger.warning("ntfy capture: no KANBANTE_CAPTURE_REPO, skipped")
|
||||
return
|
||||
await _do_capture(title, body, repo, "backlog")
|
||||
|
||||
capture_task = asyncio.create_task(
|
||||
ntfy_capture_loop(
|
||||
_capture_create,
|
||||
lambda: get_kv("capture_ntfy_last_id"),
|
||||
lambda value: set_kv("capture_ntfy_last_id", value),
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
"ntfy capture listener started (topic %s)",
|
||||
settings.capture_ntfy_topic,
|
||||
)
|
||||
yield
|
||||
scheduler.cancel()
|
||||
if capture_task is not None:
|
||||
capture_task.cancel()
|
||||
await gitea.aclose()
|
||||
|
||||
|
||||
@@ -162,7 +185,7 @@ class AuthMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
# /api/health: liveness for Docker. /webhooks/gitea: authenticated
|
||||
# by its HMAC signature, not by Basic auth (Gitea cannot log in).
|
||||
if request.url.path in ("/api/health", "/webhooks/gitea"):
|
||||
if request.url.path in ("/api/health", "/webhooks/gitea", "/api/capture"):
|
||||
return await call_next(request)
|
||||
settings = get_settings()
|
||||
if not settings.auth_enabled:
|
||||
@@ -1003,6 +1026,156 @@ async def sse_event_stream(request: Request):
|
||||
hub.unsubscribe(queue)
|
||||
|
||||
|
||||
async def _do_capture(
|
||||
title: str, body: str, repo_full_name: str, column: str = "backlog"
|
||||
) -> dict:
|
||||
"""Create a Gitea issue from a capture and place its card.
|
||||
|
||||
Shared by POST /api/capture, the /capture page and the ntfy
|
||||
listener. Raises ValueError for bad input (callers map to 400).
|
||||
"""
|
||||
title = (title or "").strip()
|
||||
if not title or len(title) > 255:
|
||||
raise ValueError("Title required (max 255 chars)")
|
||||
if column not in KANBAN_COLUMNS:
|
||||
raise ValueError("Invalid column")
|
||||
parts = _split_full_name(repo_full_name)
|
||||
if parts is None:
|
||||
raise ValueError("Invalid repo (expected owner/name)")
|
||||
owner, repo = parts
|
||||
issue = await gitea.create_issue(owner, repo, title, body or "")
|
||||
move_issue(repo_full_name, issue["number"], column)
|
||||
log_event(
|
||||
repo_full_name, issue["number"], "created",
|
||||
to_column=column, source="capture",
|
||||
)
|
||||
return {
|
||||
"number": issue["number"],
|
||||
"title": issue.get("title", title),
|
||||
"html_url": issue.get("html_url", ""),
|
||||
"repo_full_name": repo_full_name,
|
||||
"column": column,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/capture")
|
||||
async def api_capture(request: Request):
|
||||
"""Quick capture endpoint (roadmap #12).
|
||||
|
||||
Auth: dedicated capture token (X-Capture-Token header or
|
||||
Authorization: Bearer), distinct from the Gitea token. Exempt
|
||||
from Basic Auth and CSRF — it is a machine-to-machine endpoint.
|
||||
Disabled (503) until KANBANTE_CAPTURE_TOKEN is set.
|
||||
"""
|
||||
settings = get_settings()
|
||||
if not settings.capture_token:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Capture is not configured "
|
||||
"(set KANBANTE_CAPTURE_TOKEN)"},
|
||||
status_code=503,
|
||||
)
|
||||
provided = request.headers.get("x-capture-token", "")
|
||||
auth = request.headers.get("authorization", "")
|
||||
if not provided and auth.lower().startswith("bearer "):
|
||||
provided = auth[7:].strip()
|
||||
if not provided or not secrets.compare_digest(provided, settings.capture_token):
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Invalid capture token"}, status_code=401
|
||||
)
|
||||
|
||||
content_type = request.headers.get("content-type", "")
|
||||
title = body = text = repo = ""
|
||||
column = "backlog"
|
||||
if "application/json" in content_type:
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
payload = {}
|
||||
if isinstance(payload, dict):
|
||||
title = str(payload.get("title") or "")
|
||||
body = str(payload.get("body") or "")
|
||||
text = str(payload.get("text") or "")
|
||||
repo = str(payload.get("repo") or "")
|
||||
column = str(payload.get("column") or "backlog")
|
||||
elif "application/x-www-form-urlencoded" in content_type or (
|
||||
"multipart/form-data" in content_type
|
||||
):
|
||||
form = await request.form()
|
||||
title = str(form.get("title") or "")
|
||||
body = str(form.get("body") or "")
|
||||
text = str(form.get("text") or "")
|
||||
repo = str(form.get("repo") or "")
|
||||
column = str(form.get("column") or "backlog")
|
||||
else: # text/plain and friends: the whole body is the capture
|
||||
text = (await request.body()).decode("utf-8", errors="replace")
|
||||
|
||||
if not title and text:
|
||||
title, rest = parse_capture_text(text)
|
||||
body = body or rest
|
||||
repo = repo or settings.capture_repo
|
||||
if not repo:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "No repo given and KANBANTE_CAPTURE_REPO "
|
||||
"is not set"},
|
||||
status_code=400,
|
||||
)
|
||||
try:
|
||||
created = await _do_capture(title, body, repo, column)
|
||||
except ValueError as exc:
|
||||
return JSONResponse({"ok": False, "error": str(exc)}, status_code=400)
|
||||
return JSONResponse({"ok": True, "issue": created}, status_code=201)
|
||||
|
||||
|
||||
@app.get("/capture", response_class=HTMLResponse)
|
||||
async def capture_page(request: Request):
|
||||
"""Quick-capture page: form, status and usage instructions."""
|
||||
settings = get_settings()
|
||||
return render(
|
||||
request,
|
||||
"capture.html",
|
||||
capture_repo=settings.capture_repo,
|
||||
api_enabled=bool(settings.capture_token),
|
||||
ntfy_topic=settings.capture_ntfy_topic,
|
||||
ntfy_url=settings.ntfy_url,
|
||||
created=None,
|
||||
error=None,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/capture", response_class=HTMLResponse)
|
||||
async def capture_submit(
|
||||
request: Request,
|
||||
repo_full_name: str = Form(""),
|
||||
title: str = Form(""),
|
||||
body: str = Form(""),
|
||||
column: str = Form("backlog"),
|
||||
csrf_token: str = Form(""),
|
||||
):
|
||||
"""Web form capture (session + CSRF, like every other form)."""
|
||||
if not _csrf_valid(request, csrf_token):
|
||||
return _csrf_error()
|
||||
settings = get_settings()
|
||||
repo_full_name = repo_full_name.strip() or settings.capture_repo
|
||||
created, error = None, None
|
||||
if not repo_full_name:
|
||||
error = "Aucun dépôt : renseigne le dépôt ou définis KANBANTE_CAPTURE_REPO."
|
||||
else:
|
||||
try:
|
||||
created = await _do_capture(title, body, repo_full_name, column)
|
||||
except ValueError as exc:
|
||||
error = str(exc)
|
||||
return render(
|
||||
request,
|
||||
"capture.html",
|
||||
capture_repo=settings.capture_repo,
|
||||
api_enabled=bool(settings.capture_token),
|
||||
ntfy_topic=settings.capture_ntfy_topic,
|
||||
ntfy_url=settings.ntfy_url,
|
||||
created=created,
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/stream")
|
||||
async def live_stream(request: Request):
|
||||
"""Server-Sent Events: browsers reload softly on Gitea activity."""
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<a href="/activity" class="nav-pill active">📰 Activité</a>
|
||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
{% if repo_info.html_url %}
|
||||
<a href="{{ repo_info.html_url }}" target="_blank" class="nav-pill">🔗 Gitea ↗</a>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Capture éclair — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<a href="/" class="logo">📋 Kanbante</a>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill active">⚡ Capture</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
<h1>⚡ Capture éclair</h1>
|
||||
<p>Une idée, un bogue, une pensée — capturés en deux secondes, ils deviennent une issue dans le bon backlog.</p>
|
||||
</section>
|
||||
|
||||
{% if created %}
|
||||
<div class="briefing-card">
|
||||
<h2>✅ Capturé !</h2>
|
||||
<p>Issue <strong>#{{ created.number }}</strong> créée dans
|
||||
<strong>{{ created.repo_full_name }}</strong> (colonne {{ created.column }}) :
|
||||
{% if created.html_url %}<a href="{{ created.html_url }}" target="_blank">{{ created.title }}</a>{% else %}{{ created.title }}{% endif %}
|
||||
· <a href="/board/{{ created.repo_full_name }}">ouvrir le board</a></p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if error %}
|
||||
<div class="briefing-card">
|
||||
<h2>❌ Capture impossible</h2>
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="briefing-card">
|
||||
<h2>✍️ Saisie rapide</h2>
|
||||
<form method="post" action="/capture" class="capture-form">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label>Dépôt
|
||||
<input type="text" name="repo_full_name" value="{{ capture_repo }}" placeholder="Projets/kanbante" required>
|
||||
</label>
|
||||
<label>Titre de l'issue
|
||||
<input type="text" name="title" maxlength="255" placeholder="Ce qu'il faut retenir…" required autofocus>
|
||||
</label>
|
||||
<label>Détails (optionnel)
|
||||
<textarea name="body" rows="4" placeholder="Contexte, lien, capture d'écran à coller dans Gitea ensuite…"></textarea>
|
||||
</label>
|
||||
<label>Colonne
|
||||
<select name="column">
|
||||
<option value="backlog" selected>📥 Backlog</option>
|
||||
<option value="todo">📋 À faire</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" class="btn-save">⚡ Capturer</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="briefing-card">
|
||||
<h2>🔌 Depuis un script ou un raccourci</h2>
|
||||
{% if api_enabled %}
|
||||
<p>L'endpoint <code>POST /api/capture</code> est <strong>actif</strong> (jeton de capture configuré). Exemples :</p>
|
||||
<pre class="capture-pre">curl -X POST https://votre-kanbante/api/capture \
|
||||
-H "X-Capture-Token: VOTRE_JETON" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"text": "Idée : trier les cartes par échéance\n\nDétails ici…"}'</pre>
|
||||
<p>Texte libre accepté aussi (<code>Content-Type: text/plain</code>) : la première ligne devient le titre. Le dépôt vient de <code>KANBANTE_CAPTURE_REPO</code> ou du champ <code>repo</code>.</p>
|
||||
{% else %}
|
||||
<p>L'endpoint <code>POST /api/capture</code> est <strong>inactif</strong> : définis <code>KANBANTE_CAPTURE_TOKEN</code> (un jeton dédié, distinct de celui de Gitea) puis redémarre Kanbante.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="briefing-card">
|
||||
<h2>📱 Depuis le téléphone (ntfy)</h2>
|
||||
{% if ntfy_topic %}
|
||||
<p>Écoute active sur le topic <code>{{ ntfy_topic }}</code> : chaque message publié devient une issue dans <code>{{ capture_repo or "— (KANBANTE_CAPTURE_REPO manquant)" }}</code>. La première ligne fait le titre. Publie depuis l'application ntfy ou :</p>
|
||||
<pre class="capture-pre">curl -d "Idée : relire le rapport\n\nDétails…" {{ ntfy_url }}/{{ ntfy_topic }}</pre>
|
||||
<p>Les messages portant le tag <code>kanbante</code> sont ignorés (jamais d'auto-ingestion).</p>
|
||||
{% else %}
|
||||
<p>Inactif : définis <code>KANBANTE_CAPTURE_NTFY_TOPIC</code> (et <code>KANBANTE_CAPTURE_REPO</code>) puis redémarre Kanbante. Chaque message publié sur ce topic deviendra une issue — capture depuis l'application ntfy en deux touchers.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
<span>Kanbante v{{ version }} · capture éclair : formulaire web, API à jeton dédié, écoute ntfy</span>
|
||||
</footer>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -20,6 +20,7 @@
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<span class="badge">{{ groups|map(attribute='cards')|map('length')|sum }} cartes en cours</span>
|
||||
</nav>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<a href="/my" class="nav-pill active">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||
<a href="/search" class="nav-pill active">🔍 Recherche</a>
|
||||
<a href="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "kanbante"
|
||||
version = "0.6.0"
|
||||
version = "0.7.0"
|
||||
description = "Kanban project manager integrated with Gitea"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -967,3 +967,16 @@ footer {
|
||||
@media (max-width: 900px) {
|
||||
.wall-counts { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
/* ── Capture éclair (0.7.0) ── */
|
||||
.capture-form { display: flex; flex-direction: column; gap: 12px; }
|
||||
.capture-form label { display: flex; flex-direction: column; gap: 6px; font-weight: 600; }
|
||||
.capture-form input, .capture-form textarea, .capture-form select {
|
||||
padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
background: var(--bg-2); color: var(--text-1); font: inherit; font-weight: 400;
|
||||
}
|
||||
.capture-form .btn { align-self: flex-start; }
|
||||
.capture-pre {
|
||||
background: var(--bg-2); border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||
padding: 12px 14px; overflow-x: auto; font-size: 0.82rem; line-height: 1.5;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Capture éclair (feuille de route #12) : API à jeton, page web, ntfy."""
|
||||
|
||||
import pytest
|
||||
|
||||
from app import models
|
||||
from app.capture import parse_capture_text, parse_ntfy_message
|
||||
from tests.conftest import csrf_from
|
||||
|
||||
TOKEN = "jeton-de-capture-test"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def capture_env(monkeypatch):
|
||||
monkeypatch.setenv("KANBANTE_CAPTURE_TOKEN", TOKEN)
|
||||
monkeypatch.setenv("KANBANTE_CAPTURE_REPO", "Projets/demo")
|
||||
|
||||
|
||||
# ── Parsing ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_parse_capture_text_first_line_is_title():
|
||||
title, body = parse_capture_text("Idée géniale\n\nDétails\nsur deux lignes")
|
||||
assert title == "Idée géniale"
|
||||
assert body == "Détails\nsur deux lignes"
|
||||
|
||||
|
||||
def test_parse_capture_text_empty():
|
||||
assert parse_capture_text(" \n ") == ("", "")
|
||||
|
||||
|
||||
def test_parse_ntfy_message_variants():
|
||||
assert parse_ntfy_message(
|
||||
{"event": "message", "title": "Titre", "message": "Corps"}
|
||||
) == ("Titre", "Corps")
|
||||
assert parse_ntfy_message(
|
||||
{"event": "message", "message": "Ligne un\n\nLigne deux"}
|
||||
) == ("Ligne un", "Ligne deux")
|
||||
assert parse_ntfy_message({"event": "open"}) is None
|
||||
assert parse_ntfy_message(
|
||||
{"event": "message", "message": "auto", "tags": ["kanbante"]}
|
||||
) is None # never ingest our own posts
|
||||
|
||||
|
||||
# ── API ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_capture_api_disabled_without_token(client, fake_gitea):
|
||||
r = client.post("/api/capture", json={"text": "Idée"})
|
||||
assert r.status_code == 503
|
||||
|
||||
|
||||
def test_capture_api_rejects_wrong_token(client, fake_gitea, capture_env):
|
||||
r = client.post(
|
||||
"/api/capture",
|
||||
json={"text": "Idée"},
|
||||
headers={"X-Capture-Token": "mauvais"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
assert fake_gitea.created == []
|
||||
|
||||
|
||||
def test_capture_api_creates_issue_in_default_repo(client, fake_gitea, capture_env):
|
||||
r = client.post(
|
||||
"/api/capture",
|
||||
json={"text": "Idée éclair\n\nLe détail qui compte"},
|
||||
headers={"X-Capture-Token": TOKEN},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
data = r.json()
|
||||
assert data["ok"] is True
|
||||
number = data["issue"]["number"]
|
||||
assert fake_gitea.created == [("Projets", "demo", "Idée éclair")]
|
||||
state = models.get_board_state("Projets/demo")
|
||||
assert number in [c["issue_number"] for c in state["backlog"]]
|
||||
events = [
|
||||
e for e in models.get_events("Projets/demo")
|
||||
if e["issue_number"] == number
|
||||
]
|
||||
assert any(
|
||||
e["event_type"] == "created" and e["source"] == "capture" for e in events
|
||||
)
|
||||
|
||||
|
||||
def test_capture_api_text_plain_and_bearer(client, fake_gitea, capture_env):
|
||||
r = client.post(
|
||||
"/api/capture",
|
||||
content="Titre en texte brut\nCorps",
|
||||
headers={
|
||||
"Authorization": f"Bearer {TOKEN}",
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
assert fake_gitea.created == [("Projets", "demo", "Titre en texte brut")]
|
||||
|
||||
|
||||
def test_capture_api_explicit_repo_and_column(client, fake_gitea, capture_env):
|
||||
r = client.post(
|
||||
"/api/capture",
|
||||
json={"title": "Direct en todo", "repo": "Projets/demo", "column": "todo"},
|
||||
headers={"X-Capture-Token": TOKEN},
|
||||
)
|
||||
assert r.status_code == 201
|
||||
number = r.json()["issue"]["number"]
|
||||
state = models.get_board_state("Projets/demo")
|
||||
assert number in [c["issue_number"] for c in state["todo"]]
|
||||
|
||||
|
||||
def test_capture_api_validates_input(client, fake_gitea, capture_env):
|
||||
r = client.post(
|
||||
"/api/capture", json={"text": " "},
|
||||
headers={"X-Capture-Token": TOKEN},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
r = client.post(
|
||||
"/api/capture",
|
||||
json={"title": "X", "repo": "pas-de-slash"},
|
||||
headers={"X-Capture-Token": TOKEN},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_capture_api_no_repo_anywhere(client, fake_gitea, monkeypatch):
|
||||
monkeypatch.setenv("KANBANTE_CAPTURE_TOKEN", TOKEN)
|
||||
monkeypatch.delenv("KANBANTE_CAPTURE_REPO", raising=False)
|
||||
r = client.post(
|
||||
"/api/capture", json={"title": "Orpheline"},
|
||||
headers={"X-Capture-Token": TOKEN},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ── Page web ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_capture_page_renders(client, capture_env):
|
||||
r = client.get("/capture")
|
||||
assert r.status_code == 200
|
||||
assert "Capture éclair" in r.text
|
||||
assert "Projets/demo" in r.text
|
||||
|
||||
|
||||
def test_capture_web_form_creates_issue(client, fake_gitea, capture_env):
|
||||
token = csrf_from(client)
|
||||
r = client.post(
|
||||
"/capture",
|
||||
data={
|
||||
"repo_full_name": "Projets/demo", "title": "Via le formulaire",
|
||||
"body": "Détails", "column": "backlog", "csrf_token": token,
|
||||
},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert "Capturé" in r.text
|
||||
assert fake_gitea.created == [("Projets", "demo", "Via le formulaire")]
|
||||
Reference in New Issue
Block a user