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:
+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."""
|
||||
|
||||
Reference in New Issue
Block a user