From 8a32d080b58b2f24acbcc7d61b8371ad1ec4ff10 Mon Sep 17 00:00:00 2001 From: Spark Date: Sun, 11 Oct 2026 13:17:21 -0400 Subject: [PATCH] =?UTF-8?q?feat(triage):=20mode=20triage=20=E2=80=94=20vid?= =?UTF-8?q?er=20le=20backlog=20au=20clavier=20(issue=20#11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /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. --- app/gitea_client.py | 12 ++ app/main.py | 102 +++++++++++++ app/templates/board.html | 1 + app/templates/triage.html | 52 +++++++ app/templates/triage_session.html | 81 ++++++++++ static/css/style.css | 54 +++++++ static/js/palette.js | 1 + static/js/triage.js | 236 ++++++++++++++++++++++++++++++ 8 files changed, 539 insertions(+) create mode 100644 app/templates/triage.html create mode 100644 app/templates/triage_session.html create mode 100644 static/js/triage.js diff --git a/app/gitea_client.py b/app/gitea_client.py index f1f81b0..bcd4fc1 100644 --- a/app/gitea_client.py +++ b/app/gitea_client.py @@ -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() diff --git a/app/main.py b/app/main.py index 7d99d10..d5467a3 100644 --- a/app/main.py +++ b/app/main.py @@ -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.""" diff --git a/app/templates/board.html b/app/templates/board.html index c7406b1..153184b 100644 --- a/app/templates/board.html +++ b/app/templates/board.html @@ -67,6 +67,7 @@ {% endfor %} + 📥 Triage du backlog
diff --git a/app/templates/triage.html b/app/templates/triage.html new file mode 100644 index 0000000..aab66ad --- /dev/null +++ b/app/templates/triage.html @@ -0,0 +1,52 @@ + + + + + + + Triage — Kanbante + + + +
+ + +
+ +
+
+

📥 Mode Triage

+

Le backlog se vide carte par carte, au clavier : une décision par carte, aucune souris requise. Choisis un projet pour commencer une session.

+
+ {% if projects %} + + {% else %} +

🎉 Aucun backlog à trier : tous les projets actifs sont à zéro. Inbox zero, déjà.

+ {% endif %} +
+
+ Kanbante v{{ version }} · triage : 1-5 colonnes · L étiquettes · A s'assigner · X fermer · Entrée suivante · Z annuler +
+ {% include "_palette.html" %} + + + + diff --git a/app/templates/triage_session.html b/app/templates/triage_session.html new file mode 100644 index 0000000..d227a94 --- /dev/null +++ b/app/templates/triage_session.html @@ -0,0 +1,81 @@ + + + + + + + Triage {{ full_name }} — Kanbante + + + +
+ + +
+ +
+
+

📥 Triage — {{ full_name }}

+
+
+ +
+
+ +
+ +

+
+

+

+
+ + + +
+ +
+ + + + + + + + + + +
+ + +
+
+ Kanbante v{{ version }} · session de triage — les décisions partent dans Gitea immédiatement +
+ + {% include "_palette.html" %} + + + + diff --git a/static/css/style.css b/static/css/style.css index 5247195..f040478 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1026,3 +1026,57 @@ footer { color: var(--text-3); font-size: 0.78rem; text-align: right; } .palette-trigger { cursor: pointer; font: inherit; } + +/* ── Mode Triage (0.10.0, #11) ── */ +.triage-main { max-width: 860px; margin: 0 auto; padding: 24px 16px 40px; } +.triage-head h1 { margin: 0 0 12px; font-size: 1.4rem; } +.triage-progress-wrap { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; } +.triage-progress { flex: 1; height: 10px; border-radius: 99px; background: var(--surface-3, #1d2942); overflow: hidden; } +.triage-progress-bar { height: 100%; width: 0; background: var(--good, #3fb950); border-radius: 99px; transition: width 0.25s ease; } +.triage-progress-label { color: var(--text-3); font-size: 0.85rem; white-space: nowrap; } +.triage-card { + background: var(--surface-1, #131a29); + border: 1px solid var(--border-strong, #2c3a52); + border-radius: 16px; padding: 26px 28px; min-height: 220px; +} +.triage-card-top { display: flex; gap: 14px; align-items: baseline; color: var(--text-3); font-size: 0.88rem; } +.triage-card-num { font-weight: 700; color: var(--accent, #58a6ff); } +.triage-card-link { margin-left: auto; color: var(--text-3); text-decoration: none; } +.triage-card-title { margin: 10px 0 8px; font-size: 1.5rem; line-height: 1.3; } +.triage-card-labels { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 10px; } +.triage-card-excerpt { color: var(--text-2, #b6c2d2); line-height: 1.55; white-space: pre-wrap; } +.triage-card-assign { color: var(--text-3); } +.triage-keys { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 18px; } +.triage-keys button { + background: var(--surface-2, #182236); color: var(--text-1); + border: 1px solid var(--border, #22304a); border-radius: 10px; + padding: 9px 13px; font: inherit; font-size: 0.9rem; cursor: pointer; +} +.triage-keys button:hover { border-color: var(--accent, #58a6ff); } +.triage-keys kbd { + background: var(--surface-3, #1d2942); border: 1px solid var(--border-strong, #2c3a52); + border-radius: 5px; padding: 1px 6px; font-size: 0.78rem; margin-right: 4px; +} +.triage-status { min-height: 22px; margin-top: 12px; color: var(--good, #3fb950); } +.triage-labels { + margin-top: 14px; background: var(--surface-1, #131a29); + border: 1px solid var(--border-strong, #2c3a52); border-radius: 14px; padding: 16px 18px; +} +.triage-labels-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; } +.triage-labels-list { display: flex; gap: 8px; flex-wrap: wrap; } +.triage-label-chip { + background: var(--surface-3, #1d2942); color: var(--text-1); + border: 1px solid var(--border, #22304a); border-radius: 99px; + padding: 5px 12px; font: inherit; font-size: 0.85rem; cursor: pointer; +} +.triage-label-chip.on { border-color: var(--good, #3fb950); color: var(--good, #3fb950); } +.triage-done { background: var(--surface-1, #131a29); border: 1px solid var(--border-strong, #2c3a52); border-radius: 16px; padding: 26px 28px; } +.triage-summary { line-height: 1.9; } +.triage-projects { display: grid; gap: 10px; } +.triage-project { + display: flex; justify-content: space-between; align-items: center; + background: var(--surface-1, #131a29); border: 1px solid var(--border, #22304a); + border-radius: 12px; padding: 16px 20px; color: var(--text-1); text-decoration: none; +} +.triage-project:hover { border-color: var(--accent, #58a6ff); } +.triage-project-count { color: var(--text-3); } diff --git a/static/js/palette.js b/static/js/palette.js index f0241b3..b52660c 100644 --- a/static/js/palette.js +++ b/static/js/palette.js @@ -17,6 +17,7 @@ { icon: '📰', label: 'Activité — journal des événements', href: '/activity' }, { icon: '📊', label: 'Briefing quotidien', href: '/briefing' }, { icon: '⚡', label: 'Capture éclair — nouvelle idée', href: '/capture' }, + { icon: '📥', label: 'Mode Triage — vider un backlog au clavier', href: '/triage' }, { icon: '🧠', label: 'Digest hebdo — vers ObsiGate', href: '/digest' }, { icon: '🖥️', label: 'Wallboard — mode TV', href: '/wall' }, { icon: '🔍', label: 'Recherche avancée', href: '/search' }, diff --git a/static/js/triage.js b/static/js/triage.js new file mode 100644 index 0000000..9332dec --- /dev/null +++ b/static/js/triage.js @@ -0,0 +1,236 @@ +/** + * Kanbante — Triage session (issue #11). + * + * One backlog card at a time, decided from the keyboard: + * 1-5 move to a column (5 = done, closes), L labels panel, + * A assign me, X close, Enter next (skip), Z undo last move. + * Decisions hit the same APIs as the board (/api/move, label + * toggles, assign-self) — the queue lives entirely client-side. + */ +(function () { + var main = document.querySelector('.triage-main'); + if (!main) return; + var repo = main.getAttribute('data-repo'); + var parts = repo.split('/'); + var owner = parts[0], name = parts.slice(1).join('/'); + var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || ''; + var queue = JSON.parse(document.getElementById('triage-queue').textContent || '[]'); + + var idx = 0; + var history = []; // {number, to} for undoable moves + var counts = {}; // decision tally for the summary + var busy = false; + + var el = function (id) { return document.getElementById(id); }; + var cardEl = el('triage-card'), doneEl = el('triage-done'); + var statusEl = el('triage-status'), labelsPanel = el('triage-labels'); + + function current() { return queue[idx] || null; } + + function status(msg, isError) { + statusEl.textContent = msg || ''; + statusEl.style.color = isError ? '#ff7b72' : ''; + } + + function render() { + var card = current(); + var total = queue.length; + el('triage-bar').style.width = total ? Math.round(100 * idx / total) + '%' : '100%'; + el('triage-progress-label').textContent = total + ? 'Carte ' + Math.min(idx + 1, total) + ' / ' + total + : '0 carte'; + if (!card) { + cardEl.hidden = true; + el('triage-keys').hidden = true; + doneEl.hidden = false; + var summary = el('triage-summary'); + summary.innerHTML = ''; + var labels = { + todo: '📋 À faire', in_progress: '🚧 En cours', qa: '🧪 QA', + review: '👀 Révision', done: '✅ Terminé / fermée', + skip: '⏭️ Passées (restent au backlog)', + }; + Object.keys(labels).forEach(function (key) { + if (counts[key]) { + var li = document.createElement('li'); + li.textContent = labels[key] + ' : ' + counts[key]; + summary.appendChild(li); + } + }); + if (!Object.keys(counts).length) { + var li = document.createElement('li'); + li.textContent = 'Aucune décision prise.'; + summary.appendChild(li); + } + return; + } + cardEl.hidden = false; + doneEl.hidden = true; + el('tc-num').textContent = '#' + card.number; + el('tc-date').textContent = card.created_at + ? 'créée le ' + card.created_at + (card.age_days != null ? ' · ⏳ ' + card.age_days + ' j' : '') + : ''; + el('tc-link').href = card.html_url || '#'; + el('tc-title').textContent = card.title; + el('tc-excerpt').textContent = card.excerpt || '(aucune description)'; + var labelsBox = el('tc-labels'); + labelsBox.innerHTML = ''; + (card.labels || []).forEach(function (l) { + var chip = document.createElement('span'); + chip.className = 'label-tag'; + chip.style.background = '#' + l.color + '30'; + chip.style.color = '#' + l.color; + chip.textContent = l.name; + labelsBox.appendChild(chip); + }); + el('tc-assign').textContent = (card.assignees && card.assignees.length) + ? '👤 Assignée à : ' + card.assignees.join(', ') + : ''; + status(''); + } + + function post(url, fields) { + var data = new FormData(); + Object.keys(fields).forEach(function (k) { data.append(k, fields[k]); }); + data.append('csrf_token', csrf); + return fetch(url, { method: 'POST', body: data }).then(function (r) { + return r.json().catch(function () { return {}; }).then(function (body) { + if (!r.ok || body.ok === false) throw new Error(body.error || ('HTTP ' + r.status)); + return body; + }); + }); + } + + function advance(kind) { + counts[kind] = (counts[kind] || 0) + 1; + idx += 1; + render(); + } + + function decide(toColumn) { + var card = current(); + if (!card || busy) return; + busy = true; + status('⏳ Envoi…'); + post('/api/move', { + repo_full_name: repo, issue_number: card.number, + from_column: 'backlog', to_column: toColumn, from_order: '', to_order: '', + }).then(function () { + history.push({ number: card.number, to: toColumn }); + busy = false; + advance(toColumn); + }).catch(function (err) { + busy = false; + status('❌ ' + err.message + ' — réessaie.', true); + }); + } + + function undo() { + var last = history.pop(); + var card = current(); + if (!last || busy) return; + // The undone card is the one just before the current index. + var target = queue[idx - 1]; + if (!target || target.number !== last.number) return; + busy = true; + status('⏳ Annulation…'); + post('/api/move', { + repo_full_name: repo, issue_number: last.number, + from_column: last.to, to_column: 'backlog', from_order: '', to_order: '', + }).then(function () { + counts[last.to] = Math.max(0, (counts[last.to] || 1) - 1); + if (!counts[last.to]) delete counts[last.to]; + idx -= 1; + busy = false; + render(); + }).catch(function (err) { + busy = false; + history.push(last); + status('❌ Annulation impossible : ' + err.message, true); + }); + void card; + } + + function assignSelf() { + var card = current(); + if (!card || busy) return; + busy = true; + status('⏳ Assignation…'); + post('/api/issues/' + owner + '/' + name + '/' + card.number + '/assign-self', {}) + .then(function (body) { + card.assignees = body.assignees || card.assignees; + busy = false; + render(); + status('👤 Assignée à : ' + (body.assignees || []).join(', ')); + }) + .catch(function (err) { busy = false; status('❌ ' + err.message, true); }); + } + + function openLabels() { + var card = current(); + if (!card) return; + labelsPanel.hidden = false; + var list = el('triage-labels-list'); + list.textContent = 'Chargement…'; + fetch('/api/repos/' + owner + '/' + name + '/labels') + .then(function (r) { return r.json(); }) + .then(function (data) { + list.innerHTML = ''; + var mine = (card.labels || []).map(function (l) { return l.name; }); + (data.labels || []).forEach(function (label) { + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'triage-label-chip' + (mine.includes(label.name) ? ' on' : ''); + btn.textContent = (mine.includes(label.name) ? '✓ ' : '') + label.name; + btn.addEventListener('click', function () { + var action = mine.includes(label.name) ? 'remove' : 'add'; + post('/api/issues/' + owner + '/' + name + '/' + card.number + '/labels', + { label_id: label.id, action: action }) + .then(function (body) { + card.labels = body.labels || []; + render(); + openLabels(); + }) + .catch(function (err) { status('❌ ' + err.message, true); }); + }); + list.appendChild(btn); + }); + if (!(data.labels || []).length) list.textContent = 'Aucune étiquette dans ce dépôt.'; + }) + .catch(function () { list.textContent = 'Échec du chargement des étiquettes.'; }); + } + + function closeLabels() { labelsPanel.hidden = true; } + + document.querySelectorAll('.triage-keys button').forEach(function (btn) { + btn.addEventListener('click', function () { + var act = btn.getAttribute('data-act'); + if (act === 'move') decide(btn.getAttribute('data-col')); + else if (act === 'close') decide('done'); + else if (act === 'next') { if (current()) advance('skip'); } + else if (act === 'undo') undo(); + else if (act === 'assign') assignSelf(); + else if (act === 'labels') { labelsPanel.hidden ? openLabels() : closeLabels(); } + }); + }); + el('triage-labels-close').addEventListener('click', closeLabels); + + document.addEventListener('keydown', function (e) { + if (e.metaKey || e.ctrlKey || e.altKey) return; + var tag = (document.activeElement || {}).tagName || ''; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + if (!labelsPanel.hidden) { + if (e.key === 'Escape' || e.key.toLowerCase() === 'l') { e.preventDefault(); closeLabels(); } + return; + } + var colByKey = { '1': 'todo', '2': 'in_progress', '3': 'qa', '4': 'review', '5': 'done' }; + if (colByKey[e.key]) { e.preventDefault(); decide(colByKey[e.key]); } + else if (e.key === 'Enter') { e.preventDefault(); if (current()) advance('skip'); } + else if (e.key.toLowerCase() === 'x') { e.preventDefault(); decide('done'); } + else if (e.key.toLowerCase() === 'l') { e.preventDefault(); openLabels(); } + else if (e.key.toLowerCase() === 'a') { e.preventDefault(); assignSelf(); } + else if (e.key.toLowerCase() === 'z') { e.preventDefault(); undo(); } + }); + + render(); +})();