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