From a6d2ed9eaeba94c08432b4031e16ba73a0fd09c4 Mon Sep 17 00:00:00 2001 From: Spark Date: Sun, 11 Oct 2026 10:05:51 -0400 Subject: [PATCH] =?UTF-8?q?feat(palier-1):=20journal=20d'=C3=A9v=C3=A9neme?= =?UTF-8?q?nts,=20d=C3=A9tail=20d'issue,=20vieillissement,=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/config.py | 22 +++++- app/gitea_client.py | 23 ++++++ app/main.py | 179 +++++++++++++++++++++++++++++++++++++++++++- app/models.py | 152 ++++++++++++++++++++++++++++++++++++- 4 files changed, 372 insertions(+), 4 deletions(-) diff --git a/app/config.py b/app/config.py index 7a22afa..25bd331 100644 --- a/app/config.py +++ b/app/config.py @@ -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), ) diff --git a/app/gitea_client.py b/app/gitea_client.py index 9194a55..4db55be 100644 --- a/app/gitea_client.py +++ b/app/gitea_client.py @@ -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: diff --git a/app/main.py b/app/main.py index 0b12701..0af7536 100644 --- a/app/main.py +++ b/app/main.py @@ -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, diff --git a/app/models.py b/app/models.py index aa3a51d..72cb6d9 100644 --- a/app/models.py +++ b/app/models.py @@ -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(