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
|
## Features
|
||||||
|
|
||||||
- 📋 **Dashboard** — tous tes projets Gitea en un coup d'œil (actifs / inactifs / archivés — le vrai drapeau Gitea)
|
- 📋 **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
|
- 🎯 **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
|
- 🔗 **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
|
- ➕ **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
|
- 🗂️ **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`)
|
- 📰 **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
|
- ⏳ **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`)
|
- 🔍 **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é
|
- 📊 **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
|
- 🤖 **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 | `/` | Dashboard tous les projets |
|
||||||
| GET | `/board/{owner}/{repo}` | Kanban board |
|
| 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) |
|
| GET | `/activity` | Fil d'activité (journal d'événements, tous projets) |
|
||||||
| POST | `/api/move` | Déplacer une issue (ordre persisté, synchro Gitea) |
|
| POST | `/api/move` | Déplacer une issue (ordre persisté, synchro Gitea) |
|
||||||
| POST | `/api/issues` | Créer une issue Gitea depuis le board |
|
| POST | `/api/issues` | Créer une issue Gitea depuis le board |
|
||||||
|
|||||||
+140
-4
@@ -64,6 +64,7 @@ COLUMN_LABELS = {
|
|||||||
"backlog": "📥 Backlog",
|
"backlog": "📥 Backlog",
|
||||||
"todo": "📋 À faire",
|
"todo": "📋 À faire",
|
||||||
"in_progress": "🚧 En cours",
|
"in_progress": "🚧 En cours",
|
||||||
|
"qa": "🧪 QA",
|
||||||
"review": "🔍 Révision",
|
"review": "🔍 Révision",
|
||||||
"done": "✅ Terminé",
|
"done": "✅ Terminé",
|
||||||
}
|
}
|
||||||
@@ -72,11 +73,16 @@ COLUMN_COLORS = {
|
|||||||
"backlog": "#6b7280",
|
"backlog": "#6b7280",
|
||||||
"todo": "#3b82f6",
|
"todo": "#3b82f6",
|
||||||
"in_progress": "#f59e0b",
|
"in_progress": "#f59e0b",
|
||||||
|
"qa": "#06b6d4",
|
||||||
"review": "#8b5cf6",
|
"review": "#8b5cf6",
|
||||||
"done": "#10b981",
|
"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 = {
|
EVENT_ICONS = {
|
||||||
"created": "➕",
|
"created": "➕",
|
||||||
@@ -353,6 +359,44 @@ def _merge_issues_with_board(
|
|||||||
return columns
|
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:
|
def _split_full_name(repo_full_name: str) -> tuple[str, str] | None:
|
||||||
owner, sep, repo = repo_full_name.partition("/")
|
owner, sep, repo = repo_full_name.partition("/")
|
||||||
if not sep or not owner or not repo or "/" in repo:
|
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"]:
|
for number in changes["closed_numbers"]:
|
||||||
log_event(full_name, number, "closed", to_column="done", source="gitea")
|
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),
|
# Milestone view: filter the board to one milestone (sprint-style),
|
||||||
# with its deadline and completion progress.
|
# with its deadline and completion progress.
|
||||||
milestones: list[dict] = []
|
milestones: list[dict] = []
|
||||||
@@ -530,6 +577,7 @@ async def _collect_focus_groups() -> list[dict]:
|
|||||||
logger.warning("focus: repo fetch failed: %s", result)
|
logger.warning("focus: repo fetch failed: %s", result)
|
||||||
continue
|
continue
|
||||||
full_name, open_issues = result
|
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))
|
columns = _merge_issues_with_board(open_issues, get_board_state(full_name))
|
||||||
cards = [
|
cards = [
|
||||||
{"issue": issue, "column": col}
|
{"issue": issue, "column": col}
|
||||||
@@ -578,6 +626,96 @@ def _group_issue_results(issues: list[dict]) -> list[dict]:
|
|||||||
return groups
|
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)
|
@app.get("/search", response_class=HTMLResponse)
|
||||||
async def search_page(request: Request, q: str = ""):
|
async def search_page(request: Request, q: str = ""):
|
||||||
"""Global issue search across every repo (Gitea search API)."""
|
"""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:
|
if settings.sync_labels and from_column != to_column:
|
||||||
try:
|
try:
|
||||||
desired = (
|
desired = (
|
||||||
f"kanban:{to_column}"
|
f"kanban:{to_column}" if to_column in LABELLED_COLUMNS else None
|
||||||
if to_column in ("todo", "in_progress", "review")
|
|
||||||
else None
|
|
||||||
)
|
)
|
||||||
issue = await gitea.get_issue(owner, repo, issue_number)
|
issue = await gitea.get_issue(owner, repo, issue_number)
|
||||||
current = [
|
current = [
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ import sqlite3
|
|||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from typing import Iterator
|
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:
|
def db_path() -> str:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||||
<a href="/activity" class="nav-pill">📰 Activité</a>
|
<a href="/activity" class="nav-pill">📰 Activité</a>
|
||||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||||
|
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||||
{% if repo_info.html_url %}
|
{% if repo_info.html_url %}
|
||||||
<a href="{{ repo_info.html_url }}" target="_blank" class="nav-pill">🔗 Gitea ↗</a>
|
<a href="{{ repo_info.html_url }}" target="_blank" class="nav-pill">🔗 Gitea ↗</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -72,7 +73,7 @@
|
|||||||
|
|
||||||
<main class="board-container">
|
<main class="board-container">
|
||||||
<div class="kanban-board">
|
<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"
|
<div class="kanban-column"
|
||||||
data-column="{{ col }}">
|
data-column="{{ col }}">
|
||||||
<div class="column-header" style="border-top: 3px solid {{ column_colors[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="/activity" class="nav-pill">📰 Activité</a>
|
||||||
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
<a href="/briefing" class="nav-pill">📊 Briefing</a>
|
||||||
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
<a href="/search" class="nav-pill">🔍 Recherche</a>
|
||||||
|
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -84,7 +85,7 @@
|
|||||||
<span>{{ repo.open_issues_count or 0 }} issues ouvertes</span>
|
<span>{{ repo.open_issues_count or 0 }} issues ouvertes</span>
|
||||||
{% if full in stats %}
|
{% if full in stats %}
|
||||||
<div class="mini-progress">
|
<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) %}
|
{% set n = stats[full].get(col, 0) %}
|
||||||
{% if n > 0 %}
|
{% if n > 0 %}
|
||||||
<span class="chip" style="background: {{ COLUMN_COLORS[col] }}20; color: {{ COLUMN_COLORS[col] }}">
|
<span class="chip" style="background: {{ COLUMN_COLORS[col] }}20; color: {{ COLUMN_COLORS[col] }}">
|
||||||
|
|||||||
Reference in New Issue
Block a user