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