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:
+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,
|
||||
|
||||
Reference in New Issue
Block a user