From a358c45d737e4e4b08172c9498947f6912e03bfb Mon Sep 17 00:00:00 2001 From: Spark Date: Sun, 11 Oct 2026 12:26:12 -0400 Subject: [PATCH] =?UTF-8?q?feat(colonnes):=20colonne=20QA=20entre=20En=20c?= =?UTF-8?q?ours=20et=20R=C3=A9vision=20+=20r=C3=A9conciliation=20par=20?= =?UTF-8?q?=C3=A9tiquettes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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. --- README.md | 9 ++- app/main.py | 144 ++++++++++++++++++++++++++++++++++- app/models.py | 2 +- app/templates/board.html | 3 +- app/templates/dashboard.html | 3 +- 5 files changed, 151 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 033b488..cf05cc2 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/app/main.py b/app/main.py index 4c4bc1a..18fe33a 100644 --- a/app/main.py +++ b/app/main.py @@ -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: 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 = [ diff --git a/app/models.py b/app/models.py index d1314da..62914dc 100644 --- a/app/models.py +++ b/app/models.py @@ -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: diff --git a/app/templates/board.html b/app/templates/board.html index 81ca7f4..1a20cbc 100644 --- a/app/templates/board.html +++ b/app/templates/board.html @@ -23,6 +23,7 @@ 👤 Mes issues 📰 Activité 🔍 Recherche + 🖥️ Wallboard {% if repo_info.html_url %} 🔗 Gitea ↗ {% endif %} @@ -72,7 +73,7 @@
- {% for col in ['backlog','todo','in_progress','review','done'] %} + {% for col in ['backlog','todo','in_progress','qa','review','done'] %}
diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 5c86109..0f0ff39 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -20,6 +20,7 @@ 📰 Activité 📊 Briefing 🔍 Recherche + 🖥️ Wallboard @@ -84,7 +85,7 @@ {{ repo.open_issues_count or 0 }} issues ouvertes {% if full in stats %}
- {% 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 %}