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
+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,