feat(triage): mode triage — vider le backlog au clavier (issue #11)
/triage (sélecteur par taille de backlog) + /triage/{o}/{r}
(session plein cadre, file embarquée): 1-5 colonnes, X fermer,
L étiquettes, A s'assigner (nouvel endpoint assign-self +
client.set_issue_assignees, idempotent), Entrée suivante, Z annuler.
Progression + résumé de session; décisions via /api/move et les
API existantes. Entrées: board + palette.
This commit is contained in:
@@ -340,3 +340,15 @@ class GiteaClient:
|
||||
r.raise_for_status()
|
||||
self._cache_clear()
|
||||
return r.json()
|
||||
|
||||
async def set_issue_assignees(
|
||||
self, owner: str, repo: str, issue_number: int, logins: list[str]
|
||||
) -> dict:
|
||||
"""Replace the assignee set of an issue (triage "assign me")."""
|
||||
r = await self.client.patch(
|
||||
f"/repos/{owner}/{repo}/issues/{issue_number}",
|
||||
json={"assignees": logins},
|
||||
)
|
||||
r.raise_for_status()
|
||||
self._cache_clear()
|
||||
return r.json()
|
||||
|
||||
+102
@@ -20,6 +20,7 @@ import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timedelta, timezone
|
||||
@@ -686,6 +687,107 @@ async def _collect_focus_groups() -> list[dict]:
|
||||
return groups
|
||||
|
||||
|
||||
@app.post("/api/issues/{owner}/{repo}/{number}/assign-self")
|
||||
async def api_assign_self(
|
||||
request: Request,
|
||||
owner: str,
|
||||
repo: str,
|
||||
number: int,
|
||||
csrf_token: str = Form(""),
|
||||
):
|
||||
"""Assign the token's user to an issue (triage key A, issue #11)."""
|
||||
if not _csrf_valid(request, csrf_token):
|
||||
return _csrf_error()
|
||||
user = await gitea.get_user()
|
||||
login = user.get("login", "")
|
||||
if not login:
|
||||
return JSONResponse(
|
||||
{"ok": False, "error": "Could not resolve the current user"},
|
||||
status_code=502,
|
||||
)
|
||||
issue = await gitea.get_issue(owner, repo, number)
|
||||
current = [
|
||||
a.get("login", "")
|
||||
for a in (issue.get("assignees") or [])
|
||||
if a.get("login")
|
||||
]
|
||||
if issue.get("assignee") and issue["assignee"].get("login"):
|
||||
if issue["assignee"]["login"] not in current:
|
||||
current.append(issue["assignee"]["login"])
|
||||
if login not in current:
|
||||
current.append(login)
|
||||
await gitea.set_issue_assignees(owner, repo, number, current)
|
||||
await _publish_board_changed(f"{owner}/{repo}")
|
||||
return {"ok": True, "assignees": current}
|
||||
|
||||
|
||||
def _excerpt(body: str | None, limit: int = 280) -> str:
|
||||
"""Plain-text excerpt of an issue body for the triage card."""
|
||||
text = re.sub(r"[#>*`_~\-]{1,}", " ", body or "")
|
||||
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text)
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
return text[:limit] + ("…" if len(text) > limit else "")
|
||||
|
||||
|
||||
@app.get("/triage", response_class=HTMLResponse)
|
||||
async def triage_picker(request: Request):
|
||||
"""Triage mode (issue #11): pick a project by its backlog size."""
|
||||
repos = await gitea.search_repos()
|
||||
active, _inactive, _archived = classify_repos(repos)
|
||||
projects = []
|
||||
for repo_info in active:
|
||||
owner, _, name = repo_info["full_name"].partition("/")
|
||||
try:
|
||||
all_items = await gitea.get_repo_issues(owner, name, state="all")
|
||||
except Exception:
|
||||
continue
|
||||
columns, _display, _state = _board_columns_for(owner, name, all_items)
|
||||
backlog = len(columns["backlog"])
|
||||
if backlog:
|
||||
projects.append(
|
||||
{"full_name": repo_info["full_name"], "backlog": backlog}
|
||||
)
|
||||
projects.sort(key=lambda p: -p["backlog"])
|
||||
return render(request, "triage.html", projects=projects)
|
||||
|
||||
|
||||
@app.get("/triage/{owner}/{repo}", response_class=HTMLResponse)
|
||||
async def triage_session(request: Request, owner: str, repo: str):
|
||||
"""One backlog card at a time, decided from the keyboard."""
|
||||
full_name = f"{owner}/{repo}"
|
||||
all_items = await gitea.get_repo_issues(owner, repo, state="all")
|
||||
columns, display_issues, board_state = _board_columns_for(
|
||||
owner, repo, all_items
|
||||
)
|
||||
ages = _card_ages(full_name, display_issues, board_state)
|
||||
queue = [
|
||||
{
|
||||
"number": issue["number"],
|
||||
"title": issue.get("title", ""),
|
||||
"excerpt": _excerpt(issue.get("body")),
|
||||
"labels": [
|
||||
{"name": l.get("name", ""), "color": l.get("color", "")}
|
||||
for l in issue.get("labels") or []
|
||||
],
|
||||
"created_at": (issue.get("created_at") or "")[:10],
|
||||
"age_days": (ages.get(issue["number"]) or {}).get("days"),
|
||||
"html_url": issue.get("html_url", ""),
|
||||
"assignees": [
|
||||
a.get("login", "") for a in issue.get("assignees") or []
|
||||
],
|
||||
}
|
||||
for issue in columns["backlog"]
|
||||
]
|
||||
return render(
|
||||
request,
|
||||
"triage_session.html",
|
||||
full_name=full_name,
|
||||
queue=queue,
|
||||
queue_json=json.dumps(queue, ensure_ascii=False),
|
||||
column_labels=COLUMN_LABELS,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/focus", response_class=HTMLResponse)
|
||||
async def focus(request: Request):
|
||||
"""Cross-project view: everything in progress or in review."""
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="button" id="labels-btn" class="btn-sm">🏷️ Gérer les étiquettes</button>
|
||||
<a href="/triage/{{ full_name }}" class="btn-sm" style="text-decoration:none">📥 Triage du backlog</a>
|
||||
<form id="create-issue-form">
|
||||
<input type="text" id="new-issue-title" maxlength="255"
|
||||
placeholder="➕ Nouvelle issue (titre)..." required>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Triage — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<a href="/" class="logo">📋 Kanbante</a>
|
||||
<nav class="nav-pills">
|
||||
<a href="/" class="nav-pill">🏠 Projets</a>
|
||||
<a href="/focus" class="nav-pill">🎯 Focus</a>
|
||||
<a href="/my" class="nav-pill">👤 Mes issues</a>
|
||||
<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="/capture" class="nav-pill">⚡ Capture</a>
|
||||
<a href="/digest" class="nav-pill">🧠 Digest</a>
|
||||
<a href="/wall" class="nav-pill">🖥️ Wallboard</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
<h1>📥 Mode Triage</h1>
|
||||
<p>Le backlog se vide carte par carte, au clavier : une décision par carte, aucune souris requise. Choisis un projet pour commencer une session.</p>
|
||||
</section>
|
||||
{% if projects %}
|
||||
<div class="triage-projects">
|
||||
{% for p in projects %}
|
||||
<a class="triage-project" href="/triage/{{ p.full_name }}">
|
||||
<span class="triage-project-name">📂 {{ p.full_name }}</span>
|
||||
<span class="triage-project-count">{{ p.backlog }} carte{{ 's' if p.backlog > 1 }} au backlog</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="briefing-card"><p>🎉 Aucun backlog à trier : tous les projets actifs sont à zéro. Inbox zero, déjà.</p></div>
|
||||
{% endif %}
|
||||
</main>
|
||||
<footer>
|
||||
<span>Kanbante v{{ version }} · triage : 1-5 colonnes · L étiquettes · A s'assigner · X fermer · Entrée suivante · Z annuler</span>
|
||||
</footer>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="csrf-token" content="{{ csrf_token }}">
|
||||
<title>Triage {{ full_name }} — Kanbante</title>
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
<a href="/" class="logo">📋 Kanbante</a>
|
||||
<nav class="nav-pills">
|
||||
<a href="/board/{{ full_name }}" class="nav-pill">📋 Board</a>
|
||||
<a href="/triage" class="nav-pill">📥 Autre projet</a>
|
||||
<button type="button" class="nav-pill palette-trigger" title="Palette de commandes (Ctrl+K)">⌨️ Ctrl K</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main class="triage-main" data-repo="{{ full_name }}">
|
||||
<div class="triage-head">
|
||||
<h1>📥 Triage — {{ full_name }}</h1>
|
||||
<div class="triage-progress-wrap">
|
||||
<div class="triage-progress"><div class="triage-progress-bar" id="triage-bar"></div></div>
|
||||
<span class="triage-progress-label" id="triage-progress-label"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="triage-card" id="triage-card" aria-live="polite">
|
||||
<div class="triage-card-top">
|
||||
<span class="triage-card-num" id="tc-num"></span>
|
||||
<span class="triage-card-date" id="tc-date"></span>
|
||||
<a class="triage-card-link" id="tc-link" href="#" target="_blank" rel="noopener">Ouvrir dans Gitea ↗</a>
|
||||
</div>
|
||||
<h2 class="triage-card-title" id="tc-title"></h2>
|
||||
<div class="triage-card-labels" id="tc-labels"></div>
|
||||
<p class="triage-card-excerpt" id="tc-excerpt"></p>
|
||||
<p class="triage-card-assign" id="tc-assign"></p>
|
||||
</section>
|
||||
|
||||
<section class="triage-done" id="triage-done" hidden>
|
||||
<h2>🎉 Session terminée</h2>
|
||||
<ul class="triage-summary" id="triage-summary"></ul>
|
||||
<p>
|
||||
<a class="btn-save btn-inline" href="/board/{{ full_name }}">📋 Retour au board</a>
|
||||
<a class="btn-save btn-inline" href="/triage/{{ full_name }}">🔁 Nouvelle session</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="triage-status" id="triage-status" role="status"></div>
|
||||
|
||||
<section class="triage-keys" id="triage-keys">
|
||||
<button type="button" data-act="move" data-col="todo"><kbd>1</kbd> 📋 À faire</button>
|
||||
<button type="button" data-act="move" data-col="in_progress"><kbd>2</kbd> 🚧 En cours</button>
|
||||
<button type="button" data-act="move" data-col="qa"><kbd>3</kbd> 🧪 QA</button>
|
||||
<button type="button" data-act="move" data-col="review"><kbd>4</kbd> 👀 Révision</button>
|
||||
<button type="button" data-act="move" data-col="done"><kbd>5</kbd> ✅ Terminé</button>
|
||||
<button type="button" data-act="labels"><kbd>L</kbd> 🏷️ Étiquettes</button>
|
||||
<button type="button" data-act="assign"><kbd>A</kbd> 👤 M'assigner</button>
|
||||
<button type="button" data-act="close"><kbd>X</kbd> 🚫 Fermer</button>
|
||||
<button type="button" data-act="next"><kbd>Entrée</kbd> ⏭️ Suivante</button>
|
||||
<button type="button" data-act="undo"><kbd>Z</kbd> ↩️ Annuler</button>
|
||||
</section>
|
||||
|
||||
<div class="triage-labels" id="triage-labels" hidden>
|
||||
<div class="triage-labels-head">
|
||||
<strong>🏷️ Étiquettes de la carte</strong>
|
||||
<button type="button" class="btn-sm" id="triage-labels-close">✕ (Échap)</button>
|
||||
</div>
|
||||
<div class="triage-labels-list" id="triage-labels-list">Chargement…</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer>
|
||||
<span>Kanbante v{{ version }} · session de triage — les décisions partent dans Gitea immédiatement</span>
|
||||
</footer>
|
||||
<script id="triage-queue" type="application/json">{{ queue_json|safe }}</script>
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
<script src="/static/js/triage.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user