feat(palier-1): journal d'événements, détail d'issue, vieillissement, WIP

- board_events append-only + log_event/get_events/column_entry_times/
  get_metrics (débit, temps de cycle); journalisation des moves,
  créations, fermetures/rouvertures (UI et réconciliation Gitea),
  commentaires; un reclassement intra-colonne ne journalise pas
- client Gitea: get_issue, get_issue_comments (paginés), create_comment
- API: GET /api/issues/{o}/{r}/{n} (404 propre), POST .../comments,
  GET /api/events, GET /api/metrics, page /activity
- config: KANBANTE_WIP_LIMITS (colonne:limite) et KANBANTE_AGING_DAYS;
  le board calcule le temps en colonne (journal, replis updated_at
  puis création de l'issue)
- reconcile_board_state retourne aussi les numéros fermés/purgés
This commit is contained in:
Spark
2026-10-11 10:05:51 -04:00
parent 2060ca949d
commit a6d2ed9eae
4 changed files with 372 additions and 4 deletions
+21 -1
View File
@@ -17,6 +17,12 @@ Environment variables:
KANBANTE_MAX_NOTE_CHARS Maximum size of a project note (default 20000).
KANBANTE_SYNC_GITEA "1" (default) = moving a card to/from "done"
closes / reopens the Gitea issue.
KANBANTE_WIP_LIMITS Comma list "column:limit" (e.g.
"in_progress:5,review:3"). Columns not
listed are unlimited. Soft limits: the UI
warns, moves are never blocked.
KANBANTE_AGING_DAYS Days in the same column before a card is
flagged as aging (default 7; "stale" at 2x).
"""
import os
@@ -25,7 +31,7 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.2.0"
APP_VERSION = "0.3.0"
def _env_bool(name: str, default: bool) -> bool:
@@ -51,6 +57,16 @@ def _env_float(name: str, default: float) -> float:
return default
def _env_wip_limits(name: str) -> dict[str, int]:
limits: dict[str, int] = {}
for chunk in os.getenv(name, "").split(","):
column, sep, value = chunk.partition(":")
column = column.strip()
if sep and column and value.strip().isdigit():
limits[column] = int(value.strip())
return limits
@dataclass(frozen=True)
class Settings:
gitea_url: str = "https://git.dracodev.net"
@@ -63,6 +79,8 @@ class Settings:
cache_ttl: float = 30.0
max_note_chars: int = 20_000
sync_gitea: bool = True
wip_limits: dict | None = None
aging_days: int = 7
@property
def auth_enabled(self) -> bool:
@@ -84,4 +102,6 @@ def get_settings() -> Settings:
cache_ttl=_env_float("KANBANTE_CACHE_TTL", 30.0),
max_note_chars=_env_int("KANBANTE_MAX_NOTE_CHARS", 20_000),
sync_gitea=_env_bool("KANBANTE_SYNC_GITEA", True),
wip_limits=_env_wip_limits("KANBANTE_WIP_LIMITS"),
aging_days=_env_int("KANBANTE_AGING_DAYS", 7),
)
+23
View File
@@ -198,6 +198,29 @@ class GiteaClient:
issues_by_repo[full_name] = []
return issues_by_repo
async def get_issue(self, owner: str, repo: str, number: int) -> dict:
"""One issue with body, labels, assignees, milestone."""
return await self._get(f"/repos/{owner}/{repo}/issues/{number}")
async def get_issue_comments(
self, owner: str, repo: str, number: int
) -> list[dict]:
"""All comments of an issue (paginated)."""
return await self._get_all_pages(
f"/repos/{owner}/{repo}/issues/{number}/comments"
)
async def create_comment(
self, owner: str, repo: str, number: int, body: str
) -> dict:
"""Post a comment on an issue and return it."""
r = await self.client.post(
f"/repos/{owner}/{repo}/issues/{number}/comments",
json={"body": body},
)
r.raise_for_status()
return r.json()
async def create_issue(
self, owner: str, repo: str, title: str, body: str = ""
) -> dict:
+177 -2
View File
@@ -33,10 +33,14 @@ from app.config import APP_VERSION, get_settings
from app.gitea_client import GiteaClient
from app.models import (
KANBAN_COLUMNS,
column_entry_times,
get_board_state,
get_events,
get_metrics,
get_notes,
get_stats,
init_db,
log_event,
move_issue,
ping_db,
reconcile_board_state,
@@ -68,6 +72,14 @@ COLUMN_COLORS = {
FOCUS_COLUMNS = ["in_progress", "review"]
EVENT_ICONS = {
"created": "➕",
"moved": "➡️",
"closed": "✅",
"reopened": "🔁",
"commented": "💬",
}
# ── App setup ──────────────────────────────────────────────
@@ -313,9 +325,12 @@ async def board(request: Request, owner: str, repo: str):
open_numbers = {i["number"] for i in open_issues}
# Keep local state truthful: closed in Gitea -> done, deleted -> purged.
reconcile_board_state(full_name, open_numbers, closed_numbers)
changes = reconcile_board_state(full_name, open_numbers, closed_numbers)
for number in changes["closed_numbers"]:
log_event(full_name, number, "closed", to_column="done", source="gitea")
columns = _merge_issues_with_board(open_issues, get_board_state(full_name))
board_state = get_board_state(full_name)
columns = _merge_issues_with_board(open_issues, board_state)
labels: dict[str, dict] = {}
for issue in open_issues:
@@ -328,6 +343,7 @@ async def board(request: Request, owner: str, repo: str):
except Exception as exc: # non-critical decoration
logger.debug("repo info failed for %s: %s", full_name, exc)
settings = get_settings()
return render(
request,
"board.html",
@@ -340,9 +356,41 @@ async def board(request: Request, owner: str, repo: str):
labels=sorted(labels.values(), key=lambda l: l["name"].lower()),
notes=get_notes(full_name) or "",
repo_info=repo_info,
ages=_card_ages(full_name, open_issues, board_state),
wip_limits=settings.wip_limits or {},
aging_days=settings.aging_days,
)
def _card_ages(
full_name: str, open_issues: list[dict], board: dict[str, list[dict]]
) -> dict[int, dict]:
"""Days each open card has spent in its current column.
Source of truth, in order: the event journal (latest entry),
board_state.updated_at (pre-journal data), the issue's own
creation date.
"""
entered = column_entry_times(full_name)
updated = {
card["issue_number"]: card["updated_at"]
for cards in board.values()
for card in cards
}
now = datetime.now(timezone.utc)
ages: dict[int, dict] = {}
for issue in open_issues:
number = issue["number"]
raw = entered.get(number) or updated.get(number) or issue.get("created_at")
since = _parse_datetime(raw)
if since is None:
continue
if since.tzinfo is None:
since = since.replace(tzinfo=timezone.utc)
ages[number] = {"days": max(0, (now - since).days), "since": raw}
return ages
@app.get("/focus", response_class=HTMLResponse)
async def focus(request: Request):
"""Cross-project view: everything in progress or in review."""
@@ -381,6 +429,19 @@ async def focus(request: Request):
)
@app.get("/activity", response_class=HTMLResponse)
async def activity(request: Request):
"""Activity feed: the latest entries of the event journal."""
events = get_events(limit=100)
return render(
request,
"activity.html",
events=events,
event_icons=EVENT_ICONS,
column_labels=COLUMN_LABELS,
)
# ── Routes: API ────────────────────────────────────────────
@app.post("/api/move")
@@ -427,6 +488,12 @@ async def api_move(
order.append(issue_number)
set_column_order(repo_full_name, to_column, order)
if from_column != to_column:
log_event(
repo_full_name, issue_number, "moved",
from_column or None, to_column,
)
result: dict = {"ok": True, "gitea_sync": "skipped"}
settings = get_settings()
if settings.sync_gitea:
@@ -434,9 +501,14 @@ async def api_move(
if to_column == "done":
await gitea.set_issue_state(owner, repo, issue_number, "closed")
result["gitea_sync"] = "closed"
log_event(repo_full_name, issue_number, "closed", to_column="done")
elif from_column == "done":
await gitea.set_issue_state(owner, repo, issue_number, "open")
result["gitea_sync"] = "reopened"
log_event(
repo_full_name, issue_number, "reopened",
from_column="done", to_column=to_column,
)
except httpx.HTTPError as exc:
logger.warning(
"Gitea write-back failed for %s#%s: %s",
@@ -478,6 +550,7 @@ async def api_create_issue(
owner, repo = parts
issue = await gitea.create_issue(owner, repo, title, body)
move_issue(repo_full_name, issue["number"], column)
log_event(repo_full_name, issue["number"], "created", to_column=column)
return JSONResponse(
{
"ok": True,
@@ -491,6 +564,108 @@ async def api_create_issue(
)
def _issue_payload(issue: dict) -> dict:
assignees = issue.get("assignees") or []
if not assignees and issue.get("assignee"):
assignees = [issue["assignee"]]
milestone = issue.get("milestone") or {}
return {
"number": issue.get("number"),
"title": issue.get("title", ""),
"body": issue.get("body") or "",
"state": issue.get("state", "open"),
"html_url": issue.get("html_url", ""),
"created_at": issue.get("created_at", ""),
"updated_at": issue.get("updated_at", ""),
"due_date": issue.get("due_date"),
"labels": [
{"name": l.get("name", ""), "color": l.get("color", "")}
for l in issue.get("labels") or []
],
"assignees": [
{"login": a.get("login", ""), "avatar_url": a.get("avatar_url", "")}
for a in assignees
],
"milestone": milestone.get("title"),
}
def _comment_payload(comment: dict) -> dict:
user = comment.get("user") or {}
return {
"id": comment.get("id"),
"body": comment.get("body") or "",
"created_at": comment.get("created_at", ""),
"updated_at": comment.get("updated_at", ""),
"html_url": comment.get("html_url", ""),
"user": {
"login": user.get("login", "?"),
"avatar_url": user.get("avatar_url", ""),
},
}
@app.get("/api/issues/{owner}/{repo}/{number}")
async def api_issue_detail(owner: str, repo: str, number: int):
"""Issue detail + comments for the card panel (proxied from Gitea)."""
try:
issue = await gitea.get_issue(owner, repo, number)
comments = await gitea.get_issue_comments(owner, repo, number)
except httpx.HTTPStatusError as exc:
if exc.response.status_code == 404:
return JSONResponse(
{"ok": False, "error": "Issue not found"}, status_code=404
)
raise
return {
"ok": True,
"issue": _issue_payload(issue),
"comments": [_comment_payload(c) for c in comments],
}
@app.post("/api/issues/{owner}/{repo}/{number}/comments")
async def api_issue_comment(
request: Request,
owner: str,
repo: str,
number: int,
body: str = Form(...),
csrf_token: str = Form(""),
):
"""Post a comment on the issue in Gitea."""
if not _csrf_valid(request, csrf_token):
return _csrf_error()
body = body.strip()
if not body:
return JSONResponse(
{"ok": False, "error": "Comment must not be empty"}, status_code=400
)
if len(body) > 10_000:
return JSONResponse(
{"ok": False, "error": "Comment too large (max 10000 chars)"},
status_code=413,
)
comment = await gitea.create_comment(owner, repo, number, body)
log_event(f"{owner}/{repo}", number, "commented")
return JSONResponse(
{"ok": True, "comment": _comment_payload(comment)}, status_code=201
)
@app.get("/api/events")
async def api_events(repo: str = "", limit: int = 100):
"""Latest journal entries (all repos, or one via ?repo=owner/name)."""
events = get_events(repo or None, max(1, min(limit, 500)))
return {"ok": True, "events": events}
@app.get("/api/metrics")
async def api_metrics(repo: str = "", window: int = 30):
"""Throughput / cycle-time metrics from the journal."""
return {"ok": True, "metrics": get_metrics(repo or None, window)}
@app.post("/api/notes")
async def api_notes(
request: Request,
+151 -1
View File
@@ -65,8 +65,22 @@ def init_db() -> None:
UNIQUE(repo_full_name)
);
CREATE TABLE IF NOT EXISTS board_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_full_name TEXT NOT NULL,
issue_number INTEGER NOT NULL,
event_type TEXT NOT NULL,
from_column TEXT,
to_column TEXT,
source TEXT NOT NULL DEFAULT 'ui',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_board_repo
ON board_state(repo_full_name, column_name);
CREATE INDEX IF NOT EXISTS idx_events_repo
ON board_events(repo_full_name, issue_number, created_at);
"""
)
_migrate(conn)
@@ -189,7 +203,8 @@ def reconcile_board_state(
- Rows for issues that no longer exist in Gitea are deleted.
Returns counters of what changed.
"""
changed = {"closed_to_done": 0, "purged": 0}
changed: dict = {"closed_to_done": 0, "purged": 0,
"closed_numbers": [], "purged_numbers": []}
known = open_numbers | closed_numbers
with get_db() as conn:
rows = conn.execute(
@@ -206,6 +221,7 @@ def reconcile_board_state(
(repo_full_name, number),
)
changed["purged"] += 1
changed["purged_numbers"].append(number)
elif number in closed_numbers and row["column_name"] != "done":
conn.execute(
"UPDATE board_state SET column_name = 'done', "
@@ -214,9 +230,143 @@ def reconcile_board_state(
(repo_full_name, number),
)
changed["closed_to_done"] += 1
changed["closed_numbers"].append(number)
return changed
# ── Event journal ──────────────────────────────────────────
EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented")
def log_event(
repo_full_name: str,
issue_number: int,
event_type: str,
from_column: str | None = None,
to_column: str | None = None,
source: str = "ui",
created_at: str | None = None,
) -> None:
"""Append one entry to the board's event journal.
The journal is append-only: it is the memory board_state lacks
(time-in-column, activity feed, throughput / cycle-time metrics).
"""
if event_type not in EVENT_TYPES:
raise ValueError(f"Invalid event type: {event_type}")
with get_db() as conn:
if created_at:
conn.execute(
"INSERT INTO board_events (repo_full_name, issue_number, "
"event_type, from_column, to_column, source, created_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(repo_full_name, issue_number, event_type,
from_column, to_column, source, created_at),
)
else:
conn.execute(
"INSERT INTO board_events (repo_full_name, issue_number, "
"event_type, from_column, to_column, source) "
"VALUES (?, ?, ?, ?, ?, ?)",
(repo_full_name, issue_number, event_type,
from_column, to_column, source),
)
def get_events(
repo_full_name: str | None = None, limit: int = 100
) -> list[dict]:
"""Most recent journal entries, newest first (one repo or all)."""
with get_db() as conn:
if repo_full_name:
rows = conn.execute(
"SELECT * FROM board_events WHERE repo_full_name = ? "
"ORDER BY id DESC LIMIT ?",
(repo_full_name, limit),
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM board_events ORDER BY id DESC LIMIT ?",
(limit,),
).fetchall()
return [dict(row) for row in rows]
def column_entry_times(repo_full_name: str) -> dict[int, str]:
"""When each issue entered its current column, per the journal.
Latest event per issue, comments excluded (a comment does not move
a card). Issues without journal entries are absent — callers fall
back to board_state.updated_at, then to the issue's creation date.
"""
with get_db() as conn:
rows = conn.execute(
"SELECT issue_number, MAX(created_at) AS entered_at "
"FROM board_events WHERE repo_full_name = ? "
"AND event_type != 'commented' GROUP BY issue_number",
(repo_full_name,),
).fetchall()
return {row["issue_number"]: row["entered_at"] for row in rows}
def get_metrics(repo_full_name: str | None = None, window_days: int = 30) -> dict:
"""Throughput and cycle time computed from the journal.
Cycle time = time from entering in_progress (or from creation, when
no in_progress entry exists) to the closing event, averaged over
issues closed inside the window.
"""
from datetime import datetime, timedelta, timezone
def _parse(value: str):
try:
return datetime.fromisoformat(value).replace(tzinfo=timezone.utc)
except (ValueError, TypeError):
return None
with get_db() as conn:
if repo_full_name:
rows = conn.execute(
"SELECT repo_full_name, issue_number, event_type, to_column, "
"created_at FROM board_events WHERE repo_full_name = ? "
"ORDER BY created_at, id",
(repo_full_name,),
).fetchall()
else:
rows = conn.execute(
"SELECT repo_full_name, issue_number, event_type, to_column, "
"created_at FROM board_events ORDER BY created_at, id"
).fetchall()
cutoff = datetime.now(timezone.utc) - timedelta(days=window_days)
counts: dict[str, int] = {}
starts: dict[tuple, datetime] = {}
cycles: list[float] = []
for row in rows:
at = _parse(row["created_at"])
if at is None:
continue
if at >= cutoff:
counts[row["event_type"]] = counts.get(row["event_type"], 0) + 1
key = (row["repo_full_name"], row["issue_number"])
if row["event_type"] == "created":
starts.setdefault(key, at)
elif row["event_type"] == "moved" and row["to_column"] == "in_progress":
starts[key] = at # cycle clock (re)starts on entering in_progress
elif row["event_type"] == "closed" and key in starts and at >= cutoff:
cycles.append((at - starts[key]).total_seconds() / 86400.0)
return {
"window_days": window_days,
"created": counts.get("created", 0),
"moved": counts.get("moved", 0),
"closed": counts.get("closed", 0),
"reopened": counts.get("reopened", 0),
"avg_cycle_days": round(sum(cycles) / len(cycles), 2) if cycles else None,
"cycle_samples": len(cycles),
}
def set_notes(repo_full_name: str, note: str) -> None:
with get_db() as conn:
conn.execute(