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:
+21
-1
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user