feat(palier-3): recherche globale, mes issues, briefing ntfy, MCP lecture
- client: search_issues (/repos/issues/search, type=issues, assigned_by), delete_label + forget_label_cache; models: kv_store - /search groupe par projet avec colonne Kanban; /my = issues ouvertes assignées à l'utilisateur du jeton (get_user) - briefing: _briefing_data (focus, âgées, milestones <= 7 j, activité journal), page /briefing, /api/briefing, envoi ntfy manuel + planificateur lifespan à KANBANTE_BRIEFING_TIME (kv briefing_last_sent, pas de doublon); app/briefing.py = format texte + envoi ntfy (en-têtes ASCII) - MCP: POST /mcp JSON-RPC (initialize, tools/list, tools/call, ping, notifications 202), 7 outils en lecture seule, GET /mcp de découverte; erreurs -32601/-32700, isError pour les outils - focus refactoré en _collect_focus_groups réutilisé par le briefing et l'outil MCP get_focus
This commit is contained in:
@@ -0,0 +1,71 @@
|
|||||||
|
"""Daily briefing: formatting and ntfy delivery.
|
||||||
|
|
||||||
|
The data itself is gathered in app.main (it needs the board helpers);
|
||||||
|
this module only turns it into a readable text and pushes it to an
|
||||||
|
ntfy topic. Kept dependency-light so it is unit-testable alone.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def ntfy_configured(settings: Settings) -> bool:
|
||||||
|
return bool(settings.ntfy_url and settings.ntfy_topic)
|
||||||
|
|
||||||
|
|
||||||
|
def format_briefing_text(data: dict) -> str:
|
||||||
|
"""Plain-text briefing (ntfy body, also shown on /briefing)."""
|
||||||
|
lines: list[str] = [f"📋 Kanbante — briefing du {data['date']}"]
|
||||||
|
activity = data["activity_24h"]
|
||||||
|
lines.append(
|
||||||
|
f"\n⚡ Dernières 24 h : {activity['closed']} fermée(s), "
|
||||||
|
f"{activity['created']} créée(s), {activity['moved']} déplacement(s)."
|
||||||
|
)
|
||||||
|
if activity.get("avg_cycle_days") is not None:
|
||||||
|
lines.append(f"⏱️ Temps de cycle moyen (30 j) : {activity['avg_cycle_days']} j.")
|
||||||
|
|
||||||
|
lines.append(f"\n🎯 En cours / en révision : {data['focus_total']} carte(s)")
|
||||||
|
for group in data["focus_groups"][:6]:
|
||||||
|
lines.append(f" • {group['full_name']} : {group['count']}")
|
||||||
|
for title in group["titles"][:3]:
|
||||||
|
lines.append(f" – {title}")
|
||||||
|
|
||||||
|
if data["aging"]:
|
||||||
|
lines.append("\n⏳ Cartes qui vieillissent :")
|
||||||
|
for card in data["aging"][:8]:
|
||||||
|
lines.append(
|
||||||
|
f" • {card['repo']} #{card['number']} « {card['title']} » "
|
||||||
|
f"— {card['days']} j en {card['column_label']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if data["milestones_due"]:
|
||||||
|
lines.append("\n📅 Milestones proches :")
|
||||||
|
for ms in data["milestones_due"][:5]:
|
||||||
|
lines.append(
|
||||||
|
f" • {ms['repo']} « {ms['title']} » — échéance "
|
||||||
|
f"{ms['due_on']} ({ms['open']} ouverte(s))"
|
||||||
|
)
|
||||||
|
|
||||||
|
if data["focus_total"] == 0 and not data["aging"]:
|
||||||
|
lines.append("\n🎉 Rien d'urgent : aucun encours, aucune carte vieillissante.")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
async def send_ntfy(settings: Settings, text: str) -> bool:
|
||||||
|
"""POST the briefing to the configured ntfy topic. True on 2xx."""
|
||||||
|
if not ntfy_configured(settings):
|
||||||
|
return False
|
||||||
|
url = f"{settings.ntfy_url.rstrip('/')}/{settings.ntfy_topic}"
|
||||||
|
headers = {
|
||||||
|
# ntfy headers must stay ASCII; the body carries the accents.
|
||||||
|
"Title": "Kanbante - briefing du jour",
|
||||||
|
"Tags": "clipboard,chart_with_upwards_trend",
|
||||||
|
}
|
||||||
|
if settings.ntfy_token:
|
||||||
|
headers["Authorization"] = f"Bearer {settings.ntfy_token}"
|
||||||
|
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||||
|
response = await client.post(url, content=text.encode("utf-8"), headers=headers)
|
||||||
|
return response.status_code < 300
|
||||||
+17
-1
@@ -28,6 +28,14 @@ Environment variables:
|
|||||||
X-Gitea-Signature HMAC is enforced.
|
X-Gitea-Signature HMAC is enforced.
|
||||||
KANBANTE_SYNC_LABELS "1" (default) = mirror intermediate
|
KANBANTE_SYNC_LABELS "1" (default) = mirror intermediate
|
||||||
columns to Gitea labels named kanban:<col>.
|
columns to Gitea labels named kanban:<col>.
|
||||||
|
KANBANTE_NTFY_URL Base URL of an ntfy server (e.g.
|
||||||
|
https://ntfy.sh or self-hosted).
|
||||||
|
KANBANTE_NTFY_TOPIC ntfy topic for the daily briefing.
|
||||||
|
KANBANTE_NTFY_TOKEN Optional Bearer token for ntfy.
|
||||||
|
KANBANTE_BRIEFING_TIME "HH:MM" (server local time) at which
|
||||||
|
the daily briefing is sent to ntfy.
|
||||||
|
Empty (default) = no automatic send,
|
||||||
|
the /briefing page stays available.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -36,7 +44,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
APP_VERSION = "0.4.0"
|
APP_VERSION = "0.5.0"
|
||||||
|
|
||||||
|
|
||||||
def _env_bool(name: str, default: bool) -> bool:
|
def _env_bool(name: str, default: bool) -> bool:
|
||||||
@@ -88,6 +96,10 @@ class Settings:
|
|||||||
aging_days: int = 7
|
aging_days: int = 7
|
||||||
webhook_secret: str = ""
|
webhook_secret: str = ""
|
||||||
sync_labels: bool = True
|
sync_labels: bool = True
|
||||||
|
ntfy_url: str = ""
|
||||||
|
ntfy_topic: str = ""
|
||||||
|
ntfy_token: str = ""
|
||||||
|
briefing_time: str = ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def auth_enabled(self) -> bool:
|
def auth_enabled(self) -> bool:
|
||||||
@@ -113,4 +125,8 @@ def get_settings() -> Settings:
|
|||||||
aging_days=_env_int("KANBANTE_AGING_DAYS", 7),
|
aging_days=_env_int("KANBANTE_AGING_DAYS", 7),
|
||||||
webhook_secret=os.getenv("KANBANTE_WEBHOOK_SECRET", ""),
|
webhook_secret=os.getenv("KANBANTE_WEBHOOK_SECRET", ""),
|
||||||
sync_labels=_env_bool("KANBANTE_SYNC_LABELS", True),
|
sync_labels=_env_bool("KANBANTE_SYNC_LABELS", True),
|
||||||
|
ntfy_url=os.getenv("KANBANTE_NTFY_URL", ""),
|
||||||
|
ntfy_topic=os.getenv("KANBANTE_NTFY_TOPIC", ""),
|
||||||
|
ntfy_token=os.getenv("KANBANTE_NTFY_TOKEN", ""),
|
||||||
|
briefing_time=os.getenv("KANBANTE_BRIEFING_TIME", ""),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -269,6 +269,37 @@ class GiteaClient:
|
|||||||
)
|
)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
|
|
||||||
|
async def delete_label(self, owner: str, repo: str, label_id: int) -> None:
|
||||||
|
"""Delete a label definition from the repo (all issues lose it)."""
|
||||||
|
r = await self.client.delete(f"/repos/{owner}/{repo}/labels/{label_id}")
|
||||||
|
r.raise_for_status()
|
||||||
|
self.forget_label_cache(owner, repo)
|
||||||
|
|
||||||
|
def forget_label_cache(self, owner: str, repo: str) -> None:
|
||||||
|
cache = self.__dict__.get("_label_cache")
|
||||||
|
if cache:
|
||||||
|
cache.pop(f"{owner}/{repo}", None)
|
||||||
|
|
||||||
|
# ── Issue search (global + assigned) ───────────────────
|
||||||
|
|
||||||
|
async def search_issues(
|
||||||
|
self,
|
||||||
|
q: str = "",
|
||||||
|
state: str = "open",
|
||||||
|
assigned_by: str | None = None,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Search issues across every repo visible to the token.
|
||||||
|
|
||||||
|
Uses Gitea's /repos/issues/search (type=issues excludes PRs).
|
||||||
|
Results carry their repository in issue["repository"].
|
||||||
|
"""
|
||||||
|
params: dict[str, Any] = {"state": state, "type": "issues"}
|
||||||
|
if q:
|
||||||
|
params["q"] = q
|
||||||
|
if assigned_by:
|
||||||
|
params["assigned_by"] = assigned_by
|
||||||
|
return await self._get_all_pages("/repos/issues/search", **params)
|
||||||
|
|
||||||
# ── Milestones ─────────────────────────────────────────
|
# ── Milestones ─────────────────────────────────────────
|
||||||
|
|
||||||
async def get_milestones(
|
async def get_milestones(
|
||||||
|
|||||||
+577
-5
@@ -34,11 +34,13 @@ from starlette.middleware.base import BaseHTTPMiddleware
|
|||||||
|
|
||||||
from app.config import APP_VERSION, get_settings
|
from app.config import APP_VERSION, get_settings
|
||||||
from app.gitea_client import GiteaClient
|
from app.gitea_client import GiteaClient
|
||||||
|
from app.briefing import format_briefing_text, ntfy_configured, send_ntfy
|
||||||
from app.models import (
|
from app.models import (
|
||||||
KANBAN_COLUMNS,
|
KANBAN_COLUMNS,
|
||||||
column_entry_times,
|
column_entry_times,
|
||||||
get_board_state,
|
get_board_state,
|
||||||
get_events,
|
get_events,
|
||||||
|
get_kv,
|
||||||
get_metrics,
|
get_metrics,
|
||||||
get_notes,
|
get_notes,
|
||||||
get_stats,
|
get_stats,
|
||||||
@@ -48,6 +50,7 @@ from app.models import (
|
|||||||
ping_db,
|
ping_db,
|
||||||
reconcile_board_state,
|
reconcile_board_state,
|
||||||
set_column_order,
|
set_column_order,
|
||||||
|
set_kv,
|
||||||
set_notes,
|
set_notes,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,6 +89,33 @@ EVENT_ICONS = {
|
|||||||
|
|
||||||
# ── App setup ──────────────────────────────────────────────
|
# ── App setup ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _briefing_scheduler() -> None:
|
||||||
|
"""Send the daily briefing to ntfy at KANBANTE_BRIEFING_TIME.
|
||||||
|
|
||||||
|
Checks once a minute; the kv store remembers the last day sent so
|
||||||
|
restarts never double-send.
|
||||||
|
"""
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
settings = get_settings()
|
||||||
|
if settings.briefing_time and ntfy_configured(settings):
|
||||||
|
now = datetime.now()
|
||||||
|
today = now.strftime("%Y-%m-%d")
|
||||||
|
if (
|
||||||
|
now.strftime("%H:%M") >= settings.briefing_time
|
||||||
|
and get_kv("briefing_last_sent") != today
|
||||||
|
):
|
||||||
|
data = await _briefing_data()
|
||||||
|
if await send_ntfy(settings, format_briefing_text(data)):
|
||||||
|
set_kv("briefing_last_sent", today)
|
||||||
|
logger.info("Daily briefing sent to ntfy (%s)", today)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # never let the scheduler die silently
|
||||||
|
logger.warning("Briefing scheduler error: %s", exc)
|
||||||
|
await asyncio.sleep(60)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
init_db()
|
init_db()
|
||||||
@@ -102,7 +132,9 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.warning(
|
logger.warning(
|
||||||
"GITEA_TOKEN is not set: only public Gitea content will be visible."
|
"GITEA_TOKEN is not set: only public Gitea content will be visible."
|
||||||
)
|
)
|
||||||
|
scheduler = asyncio.create_task(_briefing_scheduler())
|
||||||
yield
|
yield
|
||||||
|
scheduler.cancel()
|
||||||
await gitea.aclose()
|
await gitea.aclose()
|
||||||
|
|
||||||
|
|
||||||
@@ -471,9 +503,8 @@ def _card_ages(
|
|||||||
return ages
|
return ages
|
||||||
|
|
||||||
|
|
||||||
@app.get("/focus", response_class=HTMLResponse)
|
async def _collect_focus_groups() -> list[dict]:
|
||||||
async def focus(request: Request):
|
"""In-progress / in-review cards across all active repos."""
|
||||||
"""Cross-project view: everything in progress or in review."""
|
|
||||||
repos = await gitea.search_repos()
|
repos = await gitea.search_repos()
|
||||||
active, _inactive, _archived = classify_repos(repos)
|
active, _inactive, _archived = classify_repos(repos)
|
||||||
active = active[:100]
|
active = active[:100]
|
||||||
@@ -500,15 +531,232 @@ async def focus(request: Request):
|
|||||||
]
|
]
|
||||||
if cards:
|
if cards:
|
||||||
groups.append({"full_name": full_name, "cards": cards})
|
groups.append({"full_name": full_name, "cards": cards})
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/focus", response_class=HTMLResponse)
|
||||||
|
async def focus(request: Request):
|
||||||
|
"""Cross-project view: everything in progress or in review."""
|
||||||
return render(
|
return render(
|
||||||
request,
|
request,
|
||||||
"focus.html",
|
"focus.html",
|
||||||
groups=groups,
|
groups=await _collect_focus_groups(),
|
||||||
column_labels=COLUMN_LABELS,
|
column_labels=COLUMN_LABELS,
|
||||||
column_colors=COLUMN_COLORS,
|
column_colors=COLUMN_COLORS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _group_issue_results(issues: list[dict]) -> list[dict]:
|
||||||
|
"""Group search results by repo, annotated with the board column."""
|
||||||
|
by_repo: dict[str, list[dict]] = {}
|
||||||
|
for issue in issues:
|
||||||
|
full_name = (issue.get("repository") or {}).get("full_name", "?")
|
||||||
|
by_repo.setdefault(full_name, []).append(issue)
|
||||||
|
groups = []
|
||||||
|
for full_name in sorted(by_repo):
|
||||||
|
state = get_board_state(full_name) if "/" in full_name else {}
|
||||||
|
column_of = {
|
||||||
|
card["issue_number"]: column
|
||||||
|
for column, cards in state.items()
|
||||||
|
for card in cards
|
||||||
|
}
|
||||||
|
rows = []
|
||||||
|
for issue in by_repo[full_name]:
|
||||||
|
if issue.get("state") == "open":
|
||||||
|
column = column_of.get(issue["number"], "backlog")
|
||||||
|
else:
|
||||||
|
column = "done"
|
||||||
|
rows.append({"issue": issue, "column": column})
|
||||||
|
groups.append({"full_name": full_name, "rows": rows})
|
||||||
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/search", response_class=HTMLResponse)
|
||||||
|
async def search_page(request: Request, q: str = ""):
|
||||||
|
"""Global issue search across every repo (Gitea search API)."""
|
||||||
|
q = q.strip()
|
||||||
|
groups: list[dict] = []
|
||||||
|
total = 0
|
||||||
|
if q:
|
||||||
|
results = await gitea.search_issues(q=q, state="all")
|
||||||
|
total = len(results)
|
||||||
|
groups = _group_issue_results(results)
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"search.html",
|
||||||
|
q=q,
|
||||||
|
groups=groups,
|
||||||
|
total=total,
|
||||||
|
column_labels=COLUMN_LABELS,
|
||||||
|
column_colors=COLUMN_COLORS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/my", response_class=HTMLResponse)
|
||||||
|
async def my_issues(request: Request):
|
||||||
|
"""Open issues assigned to the Gitea token's owner, all repos."""
|
||||||
|
user = None
|
||||||
|
groups: list[dict] = []
|
||||||
|
total = 0
|
||||||
|
try:
|
||||||
|
user = await gitea.get_user()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("get_user failed for /my: %s", exc)
|
||||||
|
if user and user.get("login"):
|
||||||
|
results = await gitea.search_issues(
|
||||||
|
state="open", assigned_by=user["login"]
|
||||||
|
)
|
||||||
|
total = len(results)
|
||||||
|
groups = _group_issue_results(results)
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"my.html",
|
||||||
|
user=user,
|
||||||
|
groups=groups,
|
||||||
|
total=total,
|
||||||
|
column_labels=COLUMN_LABELS,
|
||||||
|
column_colors=COLUMN_COLORS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Briefing ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _briefing_data() -> dict:
|
||||||
|
"""Everything the daily briefing needs, gathered in one dict."""
|
||||||
|
settings = get_settings()
|
||||||
|
focus_groups = await _collect_focus_groups()
|
||||||
|
|
||||||
|
repos = await gitea.search_repos()
|
||||||
|
active, _i, _a = classify_repos(repos)
|
||||||
|
soon = datetime.now(timezone.utc) + timedelta(days=7)
|
||||||
|
aging: list[dict] = []
|
||||||
|
milestones_due: list[dict] = []
|
||||||
|
|
||||||
|
async def _per_repo(repo: dict):
|
||||||
|
full_name = repo["full_name"]
|
||||||
|
owner, _, name = full_name.partition("/")
|
||||||
|
items = await gitea.get_repo_issues(owner, name, state="open")
|
||||||
|
open_issues = [i for i in items if _is_issue(i)]
|
||||||
|
ages = _card_ages(full_name, open_issues, get_board_state(full_name))
|
||||||
|
board = get_board_state(full_name)
|
||||||
|
column_of = {
|
||||||
|
card["issue_number"]: column
|
||||||
|
for column, cards in board.items()
|
||||||
|
for card in cards
|
||||||
|
}
|
||||||
|
repo_aging = [
|
||||||
|
{
|
||||||
|
"repo": full_name,
|
||||||
|
"number": issue["number"],
|
||||||
|
"title": issue["title"],
|
||||||
|
"days": ages[issue["number"]]["days"],
|
||||||
|
"column_label": COLUMN_LABELS[
|
||||||
|
column_of.get(issue["number"], "backlog")
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for issue in open_issues
|
||||||
|
if issue["number"] in ages
|
||||||
|
and ages[issue["number"]]["days"] >= settings.aging_days
|
||||||
|
]
|
||||||
|
repo_milestones = []
|
||||||
|
try:
|
||||||
|
for ms in await gitea.get_milestones(owner, name):
|
||||||
|
due = _parse_datetime(ms.get("due_on"))
|
||||||
|
if due and due <= soon and (ms.get("open_issues") or 0) > 0:
|
||||||
|
repo_milestones.append(
|
||||||
|
{
|
||||||
|
"repo": full_name,
|
||||||
|
"title": ms.get("title", ""),
|
||||||
|
"due_on": str(ms["due_on"])[:10],
|
||||||
|
"open": ms.get("open_issues") or 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("briefing milestones failed for %s: %s", full_name, exc)
|
||||||
|
return repo_aging, repo_milestones
|
||||||
|
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*(_per_repo(r) for r in active[:100]), return_exceptions=True
|
||||||
|
)
|
||||||
|
for result in results:
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
logger.warning("briefing: repo fetch failed: %s", result)
|
||||||
|
continue
|
||||||
|
repo_aging, repo_milestones = result
|
||||||
|
aging.extend(repo_aging)
|
||||||
|
milestones_due.extend(repo_milestones)
|
||||||
|
aging.sort(key=lambda c: -c["days"])
|
||||||
|
|
||||||
|
metrics_24h = get_metrics(window_days=1)
|
||||||
|
metrics_30d = get_metrics(window_days=30)
|
||||||
|
return {
|
||||||
|
"date": datetime.now().strftime("%d/%m/%Y"),
|
||||||
|
"focus_total": sum(len(g["cards"]) for g in focus_groups),
|
||||||
|
"focus_groups": [
|
||||||
|
{
|
||||||
|
"full_name": g["full_name"],
|
||||||
|
"count": len(g["cards"]),
|
||||||
|
"titles": [c["issue"]["title"] for c in g["cards"]],
|
||||||
|
}
|
||||||
|
for g in focus_groups
|
||||||
|
],
|
||||||
|
"aging": aging[:10],
|
||||||
|
"milestones_due": milestones_due[:5],
|
||||||
|
"activity_24h": {
|
||||||
|
**metrics_24h,
|
||||||
|
"avg_cycle_days": metrics_30d["avg_cycle_days"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/briefing", response_class=HTMLResponse)
|
||||||
|
async def briefing_page(request: Request):
|
||||||
|
"""The daily briefing, readable on the web before it is pushed."""
|
||||||
|
settings = get_settings()
|
||||||
|
data = await _briefing_data()
|
||||||
|
return render(
|
||||||
|
request,
|
||||||
|
"briefing.html",
|
||||||
|
data=data,
|
||||||
|
text=format_briefing_text(data),
|
||||||
|
ntfy_configured=ntfy_configured(settings),
|
||||||
|
briefing_time=settings.briefing_time,
|
||||||
|
last_sent=get_kv("briefing_last_sent"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/briefing")
|
||||||
|
async def api_briefing():
|
||||||
|
data = await _briefing_data()
|
||||||
|
return {"ok": True, "briefing": data, "text": format_briefing_text(data)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/briefing/send")
|
||||||
|
async def api_briefing_send(request: Request, csrf_token: str = Form("")):
|
||||||
|
"""Send the briefing to the configured ntfy topic, right now."""
|
||||||
|
if not _csrf_valid(request, csrf_token):
|
||||||
|
return _csrf_error()
|
||||||
|
settings = get_settings()
|
||||||
|
if not ntfy_configured(settings):
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"ok": False,
|
||||||
|
"error": "ntfy is not configured "
|
||||||
|
"(KANBANTE_NTFY_URL / KANBANTE_NTFY_TOPIC)",
|
||||||
|
},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
data = await _briefing_data()
|
||||||
|
sent = await send_ntfy(settings, format_briefing_text(data))
|
||||||
|
if not sent:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": "ntfy server rejected or unreachable"},
|
||||||
|
status_code=502,
|
||||||
|
)
|
||||||
|
set_kv("briefing_last_sent", datetime.now().strftime("%Y-%m-%d"))
|
||||||
|
return {"ok": True, "sent": True}
|
||||||
|
|
||||||
|
|
||||||
# ── Webhooks + live stream ─────────────────────────────────
|
# ── Webhooks + live stream ─────────────────────────────────
|
||||||
|
|
||||||
def _apply_external_transition(
|
def _apply_external_transition(
|
||||||
@@ -802,7 +1050,7 @@ def _issue_payload(issue: dict) -> dict:
|
|||||||
"updated_at": issue.get("updated_at", ""),
|
"updated_at": issue.get("updated_at", ""),
|
||||||
"due_date": issue.get("due_date"),
|
"due_date": issue.get("due_date"),
|
||||||
"labels": [
|
"labels": [
|
||||||
{"name": l.get("name", ""), "color": l.get("color", "")}
|
{"id": l.get("id"), "name": l.get("name", ""), "color": l.get("color", "")}
|
||||||
for l in issue.get("labels") or []
|
for l in issue.get("labels") or []
|
||||||
],
|
],
|
||||||
"assignees": [
|
"assignees": [
|
||||||
@@ -889,6 +1137,330 @@ async def api_metrics(repo: str = "", window: int = 30):
|
|||||||
return {"ok": True, "metrics": get_metrics(repo or None, window)}
|
return {"ok": True, "metrics": get_metrics(repo or None, window)}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Label management ───────────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/api/repos/{owner}/{repo}/labels")
|
||||||
|
async def api_list_labels(owner: str, repo: str):
|
||||||
|
"""All label definitions of a repo."""
|
||||||
|
labels = await gitea.get_labels(owner, repo)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"labels": [
|
||||||
|
{"id": l.get("id"), "name": l.get("name", ""), "color": l.get("color", "")}
|
||||||
|
for l in labels
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/repos/{owner}/{repo}/labels")
|
||||||
|
async def api_create_label(
|
||||||
|
request: Request,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
name: str = Form(...),
|
||||||
|
color: str = Form("7c5cfc"),
|
||||||
|
csrf_token: str = Form(""),
|
||||||
|
):
|
||||||
|
"""Create a label definition in the repo."""
|
||||||
|
if not _csrf_valid(request, csrf_token):
|
||||||
|
return _csrf_error()
|
||||||
|
name = name.strip()
|
||||||
|
color = color.strip().lstrip("#")
|
||||||
|
if not name or len(name) > 50:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": "Label name must be 1-50 chars"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
if len(color) != 6 or any(c not in "0123456789abcdefABCDEF" for c in color):
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": "Color must be a 6-digit hex value"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
label = await gitea.create_label(owner, repo, name, color)
|
||||||
|
gitea.forget_label_cache(owner, repo)
|
||||||
|
return JSONResponse(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"label": {
|
||||||
|
"id": label.get("id"),
|
||||||
|
"name": label.get("name", name),
|
||||||
|
"color": label.get("color", color),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
status_code=201,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/repos/{owner}/{repo}/labels/{label_id}")
|
||||||
|
async def api_delete_label(
|
||||||
|
request: Request, owner: str, repo: str, label_id: int
|
||||||
|
):
|
||||||
|
"""Delete a label definition (CSRF via the X-CSRF-Token header)."""
|
||||||
|
if not _csrf_valid(request, ""):
|
||||||
|
return _csrf_error()
|
||||||
|
await gitea.delete_label(owner, repo, label_id)
|
||||||
|
return {"ok": True, "deleted": label_id}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/issues/{owner}/{repo}/{number}/labels")
|
||||||
|
async def api_toggle_issue_label(
|
||||||
|
request: Request,
|
||||||
|
owner: str,
|
||||||
|
repo: str,
|
||||||
|
number: int,
|
||||||
|
label_id: int = Form(...),
|
||||||
|
action: str = Form(...),
|
||||||
|
csrf_token: str = Form(""),
|
||||||
|
):
|
||||||
|
"""Add or remove one label on one issue; returns the new label set."""
|
||||||
|
if not _csrf_valid(request, csrf_token):
|
||||||
|
return _csrf_error()
|
||||||
|
if action not in ("add", "remove"):
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": "action must be 'add' or 'remove'"},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
if action == "add":
|
||||||
|
await gitea.add_issue_label(owner, repo, number, label_id)
|
||||||
|
else:
|
||||||
|
await gitea.remove_issue_label(owner, repo, number, label_id)
|
||||||
|
issue = await gitea.get_issue(owner, repo, number)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"labels": [
|
||||||
|
{"id": l.get("id"), "name": l.get("name", ""), "color": l.get("color", "")}
|
||||||
|
for l in issue.get("labels") or []
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── MCP server (read-only tools over JSON-RPC) ─────────────
|
||||||
|
|
||||||
|
MCP_TOOLS = [
|
||||||
|
{
|
||||||
|
"name": "list_projects",
|
||||||
|
"description": "List the projects (Gitea repos) Kanbante tracks, grouped by activity.",
|
||||||
|
"inputSchema": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_board",
|
||||||
|
"description": "Get the kanban board of one repo: columns with their cards.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"owner": {"type": "string"},
|
||||||
|
"repo": {"type": "string"},
|
||||||
|
},
|
||||||
|
"required": ["owner", "repo"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_issue",
|
||||||
|
"description": "Get one issue with its body and comments.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"owner": {"type": "string"},
|
||||||
|
"repo": {"type": "string"},
|
||||||
|
"number": {"type": "integer"},
|
||||||
|
},
|
||||||
|
"required": ["owner", "repo", "number"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_focus",
|
||||||
|
"description": "Cards in progress or in review across all active projects.",
|
||||||
|
"inputSchema": {"type": "object", "properties": {}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "search_issues",
|
||||||
|
"description": "Search issues by text across all repos.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"query": {"type": "string"},
|
||||||
|
"state": {"type": "string", "enum": ["open", "closed", "all"]},
|
||||||
|
},
|
||||||
|
"required": ["query"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_activity",
|
||||||
|
"description": "Latest entries of the board event journal.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"limit": {"type": "integer"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_metrics",
|
||||||
|
"description": "Throughput and cycle-time metrics from the journal.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"repo": {"type": "string"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def _mcp_call_tool(name: str, args: dict) -> str:
|
||||||
|
"""Execute one read-only MCP tool and return text content."""
|
||||||
|
if name == "list_projects":
|
||||||
|
repos = await gitea.search_repos()
|
||||||
|
active, inactive, archived = classify_repos(repos)
|
||||||
|
lines = [f"Active projects ({len(active)}):"]
|
||||||
|
lines += [
|
||||||
|
f"- {r['full_name']} ({r.get('open_issues_count') or 0} open issues)"
|
||||||
|
for r in active
|
||||||
|
]
|
||||||
|
lines.append(
|
||||||
|
f"Inactive: {len(inactive)} - Archived: {len(archived)}"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
if name == "get_board":
|
||||||
|
owner, repo = args["owner"], args["repo"]
|
||||||
|
items = await gitea.get_repo_issues(owner, repo, state="all")
|
||||||
|
issues = [i for i in items if _is_issue(i)]
|
||||||
|
open_issues = [i for i in issues if i.get("state") == "open"]
|
||||||
|
columns = _merge_issues_with_board(open_issues, get_board_state(f"{owner}/{repo}"))
|
||||||
|
return json.dumps(
|
||||||
|
{
|
||||||
|
column: [
|
||||||
|
{
|
||||||
|
"number": i["number"],
|
||||||
|
"title": i["title"],
|
||||||
|
"labels": [l.get("name") for l in i.get("labels") or []],
|
||||||
|
}
|
||||||
|
for i in cards
|
||||||
|
]
|
||||||
|
for column, cards in columns.items()
|
||||||
|
},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
if name == "get_issue":
|
||||||
|
detail = await api_issue_detail(args["owner"], args["repo"], int(args["number"]))
|
||||||
|
return json.dumps(detail, ensure_ascii=False, default=str)
|
||||||
|
if name == "get_focus":
|
||||||
|
groups = await _collect_focus_groups()
|
||||||
|
return json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"repo": g["full_name"],
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"number": c["issue"]["number"],
|
||||||
|
"title": c["issue"]["title"],
|
||||||
|
"column": c["column"],
|
||||||
|
}
|
||||||
|
for c in g["cards"]
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for g in groups
|
||||||
|
],
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
if name == "search_issues":
|
||||||
|
results = await gitea.search_issues(
|
||||||
|
q=args["query"], state=args.get("state", "open")
|
||||||
|
)
|
||||||
|
return json.dumps(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"repo": (i.get("repository") or {}).get("full_name"),
|
||||||
|
"number": i["number"],
|
||||||
|
"title": i["title"],
|
||||||
|
"state": i["state"],
|
||||||
|
}
|
||||||
|
for i in results
|
||||||
|
],
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
if name == "get_activity":
|
||||||
|
return json.dumps(
|
||||||
|
get_events(limit=int(args.get("limit", 50))), ensure_ascii=False
|
||||||
|
)
|
||||||
|
if name == "get_metrics":
|
||||||
|
return json.dumps(get_metrics(args.get("repo") or None), ensure_ascii=False)
|
||||||
|
raise ValueError(f"Unknown tool: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/mcp")
|
||||||
|
async def mcp_info():
|
||||||
|
"""Discoverability for the MCP endpoint (the protocol is POST)."""
|
||||||
|
return {
|
||||||
|
"name": "kanbante",
|
||||||
|
"version": APP_VERSION,
|
||||||
|
"transport": "streamable-http (JSON-RPC 2.0 over POST)",
|
||||||
|
"read_only": True,
|
||||||
|
"tools": [t["name"] for t in MCP_TOOLS],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/mcp")
|
||||||
|
async def mcp_endpoint(request: Request):
|
||||||
|
"""Minimal MCP server: initialize / tools/list / tools/call / ping.
|
||||||
|
|
||||||
|
Read-only by design (Bruno's rule: cross-platform reads first,
|
||||||
|
writes go through approval flows elsewhere).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
payload = await request.json()
|
||||||
|
except ValueError:
|
||||||
|
return JSONResponse(
|
||||||
|
{"jsonrpc": "2.0", "id": None,
|
||||||
|
"error": {"code": -32700, "message": "Parse error"}},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
if isinstance(payload, list): # batch: not supported by this server
|
||||||
|
return JSONResponse(
|
||||||
|
{"jsonrpc": "2.0", "id": None,
|
||||||
|
"error": {"code": -32600, "message": "Batch requests not supported"}},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
method = payload.get("method", "")
|
||||||
|
rpc_id = payload.get("id")
|
||||||
|
if rpc_id is None: # notification: acknowledge silently
|
||||||
|
return Response(status_code=202)
|
||||||
|
params = payload.get("params") or {}
|
||||||
|
|
||||||
|
def _result(result):
|
||||||
|
return {"jsonrpc": "2.0", "id": rpc_id, "result": result}
|
||||||
|
|
||||||
|
def _error(code, message):
|
||||||
|
return {"jsonrpc": "2.0", "id": rpc_id,
|
||||||
|
"error": {"code": code, "message": message}}
|
||||||
|
|
||||||
|
if method == "initialize":
|
||||||
|
return _result(
|
||||||
|
{
|
||||||
|
"protocolVersion": params.get("protocolVersion", "2024-11-05"),
|
||||||
|
"capabilities": {"tools": {}},
|
||||||
|
"serverInfo": {"name": "kanbante", "version": APP_VERSION},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if method == "ping":
|
||||||
|
return _result({})
|
||||||
|
if method == "tools/list":
|
||||||
|
return _result({"tools": MCP_TOOLS})
|
||||||
|
if method == "tools/call":
|
||||||
|
tool_name = params.get("name", "")
|
||||||
|
arguments = params.get("arguments") or {}
|
||||||
|
try:
|
||||||
|
text = await _mcp_call_tool(tool_name, arguments)
|
||||||
|
except KeyError as exc:
|
||||||
|
return _result(
|
||||||
|
{"content": [{"type": "text", "text": f"Missing argument: {exc}"}],
|
||||||
|
"isError": True}
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return _result(
|
||||||
|
{"content": [{"type": "text", "text": f"Tool error: {exc}"}],
|
||||||
|
"isError": True}
|
||||||
|
)
|
||||||
|
return _result({"content": [{"type": "text", "text": text}], "isError": False})
|
||||||
|
return _error(-32601, f"Method not found: {method}")
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/notes")
|
@app.post("/api/notes")
|
||||||
async def api_notes(
|
async def api_notes(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -79,6 +79,11 @@ def init_db() -> None:
|
|||||||
CREATE INDEX IF NOT EXISTS idx_board_repo
|
CREATE INDEX IF NOT EXISTS idx_board_repo
|
||||||
ON board_state(repo_full_name, column_name);
|
ON board_state(repo_full_name, column_name);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS kv_store (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_events_repo
|
CREATE INDEX IF NOT EXISTS idx_events_repo
|
||||||
ON board_events(repo_full_name, issue_number, created_at);
|
ON board_events(repo_full_name, issue_number, created_at);
|
||||||
"""
|
"""
|
||||||
@@ -234,6 +239,23 @@ def reconcile_board_state(
|
|||||||
return changed
|
return changed
|
||||||
|
|
||||||
|
|
||||||
|
def get_kv(key: str) -> str | None:
|
||||||
|
with get_db() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT value FROM kv_store WHERE key = ?", (key,)
|
||||||
|
).fetchone()
|
||||||
|
return row["value"] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def set_kv(key: str, value: str) -> None:
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO kv_store (key, value) VALUES (?, ?) "
|
||||||
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||||
|
(key, value),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ── Event journal ──────────────────────────────────────────
|
# ── Event journal ──────────────────────────────────────────
|
||||||
|
|
||||||
EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented")
|
EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented")
|
||||||
|
|||||||
Reference in New Issue
Block a user