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:
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
@@ -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] }}">
|
||||
|
||||
@@ -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] }}">
|
||||
|
||||
Reference in New Issue
Block a user