feat(colonnes): colonne QA entre En cours et Révision + réconciliation par étiquettes

- KANBAN_COLUMNS gagne 'qa' (🧪, #06b6d4, étiquette kanban:qa);
  Focus inclut QA; dashboard et board affichent les 6 colonnes.
- _adopt_label_columns: une étiquette kanban:* unique qui diverge
  de l'état local fait adopter sa colonne (board + focus), avec
  événement journalisé source 'gitea'. C'est ce qui permet de
  piloter le board depuis Gitea même.
This commit is contained in:
Spark
2026-10-11 12:26:12 -04:00
parent b7ddc10971
commit a358c45d73
5 changed files with 151 additions and 10 deletions
+6 -3
View File
@@ -5,7 +5,7 @@
## Features
- 📋 **Dashboard** — tous tes projets Gitea en un coup d'œil (actifs / inactifs / archivés — le vrai drapeau Gitea)
- 🗂️ **Kanban Board** — colonnes Backlog → À faire → En cours → Révision → Terminé, ordre des cartes persisté
- 🗂️ **Kanban Board** — colonnes Backlog → À faire → En cours → **QA** → Révision → Terminé, ordre des cartes persisté
- 🎯 **Drag & Drop** — glisse les issues entre colonnes ; en cas d'échec, la carte est replacée
- 🔗 **Gitea Sync bidirectionnelle** — « Terminé » ferme l'issue dans Gitea, la ressortir la rouvre ; l'état local est réconcilié à chaque chargement
- ➕ **Création d'issues** depuis le board, filtres texte + étiquettes, cartes liées à Gitea
@@ -14,7 +14,9 @@
- 🗂️ **Panneau détail** — clic sur une carte : corps markdown, commentaires, et réponse postée dans Gitea sans quitter le board
- 📰 **Journal d'événements** — chaque mouvement est horodaté : fil d'activité (`/activity`), métriques de débit et de temps de cycle (`/api/metrics`)
- ⏳ **Vieillissement + limites WIP** — badge d'ancienneté par colonne et limites souples configurables
- 🎯 **Vue Focus** — tout ce qui est En cours / En révision, tous projets confondus (`/focus`)
- 🎯 **Vue Focus** — tout ce qui est En cours / en QA / En révision, tous projets confondus (`/focus`)
- 🖥️ **Wallboard** — mode TV plein écran (`/wall`) : rotation automatique entre les projets actifs, cartes en vol et compteurs en très grands caractères, rafraîchi en direct ; `?repo=Proprietaire/depot` fige un seul projet
- 🔀 **Réconciliation par étiquettes** — si l'étiquette `kanban:*` d'une issue change hors de Kanbante (dans Gitea, par un autre outil), le board adopte la colonne de l'étiquette au chargement et le journalise (source `gitea`). Une seule étiquette `kanban:*` fait foi ; aucune ou plusieurs = l'état local gagne
- 🔍 **Recherche globale + Mes issues** — chercher dans toutes les issues de tous les projets (`/search`), et voir ce qui vous est assigné (`/my`)
- 📊 **Briefing quotidien** — encours, vieillissement, milestones proches : page `/briefing` et envoi **ntfy** planifié
- 🤖 **Serveur MCP** — 7 outils en lecture seule (boards, focus, recherche, métriques) exposés en JSON-RPC sur `/mcp` pour vos agents
@@ -151,7 +153,8 @@ kanbante/
|---------|-------|-------------|
| GET | `/` | Dashboard tous les projets |
| GET | `/board/{owner}/{repo}` | Kanban board |
| GET | `/focus` | En cours + En révision, tous projets actifs |
| GET | `/focus` | En cours + QA + En révision, tous projets actifs |
| GET | `/wall` | Wallboard plein écran (rotation des projets actifs ; `?repo=` pour un seul projet) |
| GET | `/activity` | Fil d'activité (journal d'événements, tous projets) |
| POST | `/api/move` | Déplacer une issue (ordre persisté, synchro Gitea) |
| POST | `/api/issues` | Créer une issue Gitea depuis le board |
+140 -4
View File
@@ -64,6 +64,7 @@ COLUMN_LABELS = {
"backlog": "📥 Backlog",
"todo": "📋 À faire",
"in_progress": "🚧 En cours",
"qa": "🧪 QA",
"review": "🔍 Révision",
"done": "✅ Terminé",
}
@@ -72,11 +73,16 @@ COLUMN_COLORS = {
"backlog": "#6b7280",
"todo": "#3b82f6",
"in_progress": "#f59e0b",
"qa": "#06b6d4",
"review": "#8b5cf6",
"done": "#10b981",
}
FOCUS_COLUMNS = ["in_progress", "review"]
FOCUS_COLUMNS = ["in_progress", "qa", "review"]
# Columns mirrored to Gitea as kanban:<column> labels (backlog and
# done carry none: done is the closed state itself).
LABELLED_COLUMNS = ("todo", "in_progress", "qa", "review")
EVENT_ICONS = {
"created": "➕",
@@ -353,6 +359,44 @@ def _merge_issues_with_board(
return columns
def _adopt_label_columns(
full_name: str, open_issues: list[dict], board_state: dict
) -> None:
"""Adopt the column named by an issue's kanban:* label, if any.
Labels are the Gitea-visible mirror of columns (written on every
drag). If the label changed outside Kanbante — edited in Gitea,
or set by another tool — the board follows it and the move is
journaled (source "gitea"). Rules: exactly one kanban:* label
(zero or several = no opinion), target must be a labelled column,
and label synchronisation must be enabled.
"""
if not get_settings().sync_labels:
return
placement: dict[int, str] = {}
for col_name, cards in board_state.items():
for card in cards:
placement[card["issue_number"]] = col_name
for issue in open_issues:
names = [
str(label.get("name", "")) for label in issue.get("labels") or []
]
kanban = [n for n in names if n.startswith("kanban:")]
if len(kanban) != 1:
continue
target = kanban[0].split(":", 1)[1]
if target not in LABELLED_COLUMNS:
continue
current = placement.get(issue["number"], "backlog")
if current != target:
move_issue(full_name, issue["number"], target)
log_event(
full_name, issue["number"], "moved",
from_column=current, to_column=target, source="gitea",
)
placement[issue["number"]] = target
def _split_full_name(repo_full_name: str) -> tuple[str, str] | None:
owner, sep, repo = repo_full_name.partition("/")
if not sep or not owner or not repo or "/" in repo:
@@ -405,6 +449,9 @@ async def board(request: Request, owner: str, repo: str, milestone: str = ""):
for number in changes["closed_numbers"]:
log_event(full_name, number, "closed", to_column="done", source="gitea")
# A kanban:* label changed outside Kanbante wins over local state.
_adopt_label_columns(full_name, open_issues, get_board_state(full_name))
# Milestone view: filter the board to one milestone (sprint-style),
# with its deadline and completion progress.
milestones: list[dict] = []
@@ -530,6 +577,7 @@ async def _collect_focus_groups() -> list[dict]:
logger.warning("focus: repo fetch failed: %s", result)
continue
full_name, open_issues = result
_adopt_label_columns(full_name, open_issues, get_board_state(full_name))
columns = _merge_issues_with_board(open_issues, get_board_state(full_name))
cards = [
{"issue": issue, "column": col}
@@ -578,6 +626,96 @@ def _group_issue_results(issues: list[dict]) -> list[dict]:
return groups
WALL_COLUMNS = ["in_progress", "qa", "review", "todo"]
async def _collect_wall_data(only_repo: str | None) -> list[dict]:
"""Slides for the wallboard: one per active project.
Each slide carries the column counts, the cards in flight
(in progress / QA / review / todo, in flow order) with their age
in column, and how many cards were closed today (journal).
"""
if only_repo:
if _split_full_name(only_repo) is None:
return []
known = await gitea.search_repos()
match = [r for r in known if r.get("full_name") == only_repo]
if not match:
return []
repos = match
else:
all_repos = await gitea.search_repos()
active, _inactive, _archived = classify_repos(all_repos)
repos = active[:12]
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
closed_today: dict[str, int] = {}
for event in get_events(limit=500):
if event["event_type"] == "closed" and str(
event["created_at"]
).startswith(today):
closed_today[event["repo_full_name"]] = (
closed_today.get(event["repo_full_name"], 0) + 1
)
slides = []
for repo in repos:
full_name = repo["full_name"]
owner, _, name = full_name.partition("/")
try:
items = await gitea.get_repo_issues(owner, name, state="open")
except Exception as exc:
logger.debug("wall: issues failed for %s: %s", full_name, exc)
continue
open_issues = [i for i in items if _is_issue(i)]
_adopt_label_columns(full_name, open_issues, get_board_state(full_name))
board_state = get_board_state(full_name)
columns = _merge_issues_with_board(open_issues, board_state)
ages = _card_ages(full_name, open_issues, board_state)
aging_days = get_settings().aging_days
cards = []
for col in WALL_COLUMNS:
for issue in columns[col]:
age = ages.get(issue["number"]) or {}
days = age.get("days")
cards.append(
{
"number": issue["number"],
"title": issue.get("title", ""),
"column": col,
"column_label": COLUMN_LABELS[col],
"age_days": days,
"aging": days is not None and days >= aging_days,
}
)
slides.append(
{
"full_name": full_name,
"counts": {col: len(columns[col]) for col in KANBAN_COLUMNS},
"open_total": len(open_issues),
"cards": cards[:9],
"closed_today": closed_today.get(full_name, 0),
}
)
return slides
@app.get("/wall", response_class=HTMLResponse)
async def wall(request: Request, repo: str = ""):
"""Wallboard: full-screen, auto-rotating, live project display."""
slides = await _collect_wall_data(repo or None)
return render(
request,
"wall.html",
slides=slides,
single=bool(repo),
column_labels=COLUMN_LABELS,
column_colors=COLUMN_COLORS,
columns=KANBAN_COLUMNS,
)
@app.get("/search", response_class=HTMLResponse)
async def search_page(request: Request, q: str = ""):
"""Global issue search across every repo (Gitea search API)."""
@@ -970,9 +1108,7 @@ async def api_move(
if settings.sync_labels and from_column != to_column:
try:
desired = (
f"kanban:{to_column}"
if to_column in ("todo", "in_progress", "review")
else None
f"kanban:{to_column}" if to_column in LABELLED_COLUMNS else None
)
issue = await gitea.get_issue(owner, repo, issue_number)
current = [
+1 -1
View File
@@ -16,7 +16,7 @@ import sqlite3
from contextlib import contextmanager
from typing import Iterator
KANBAN_COLUMNS = ["backlog", "todo", "in_progress", "review", "done"]
KANBAN_COLUMNS = ["backlog", "todo", "in_progress", "qa", "review", "done"]
def db_path() -> str:
+2 -1
View File
@@ -23,6 +23,7 @@
<a href="/my" class="nav-pill">👤 Mes issues</a>
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
{% if repo_info.html_url %}
<a href="{{ repo_info.html_url }}" target="_blank" class="nav-pill">🔗 Gitea ↗</a>
{% endif %}
@@ -72,7 +73,7 @@
<main class="board-container">
<div class="kanban-board">
{% for col in ['backlog','todo','in_progress','review','done'] %}
{% for col in ['backlog','todo','in_progress','qa','review','done'] %}
<div class="kanban-column"
data-column="{{ col }}">
<div class="column-header" style="border-top: 3px solid {{ column_colors[col] }}">
+2 -1
View File
@@ -20,6 +20,7 @@
<a href="/activity" class="nav-pill">📰 Activité</a>
<a href="/briefing" class="nav-pill">📊 Briefing</a>
<a href="/search" class="nav-pill">🔍 Recherche</a>
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
</nav>
</header>
@@ -84,7 +85,7 @@
<span>{{ repo.open_issues_count or 0 }} issues ouvertes</span>
{% if full in stats %}
<div class="mini-progress">
{% for col in ['backlog','todo','in_progress','review','done'] %}
{% for col in ['backlog','todo','in_progress','qa','review','done'] %}
{% set n = stats[full].get(col, 0) %}
{% if n > 0 %}
<span class="chip" style="background: {{ COLUMN_COLORS[col] }}20; color: {{ COLUMN_COLORS[col] }}">