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:
Spark
2026-10-11 13:17:21 -04:00
parent 25040f974c
commit 8a32d080b5
8 changed files with 539 additions and 0 deletions
+102
View File
@@ -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."""