diff --git a/app/main.py b/app/main.py index 1793401..617c509 100644 --- a/app/main.py +++ b/app/main.py @@ -489,21 +489,133 @@ def _parse_order(raw: str) -> list[int]: # ── Routes: Pages ───────────────────────────────────────── +async def _next_milestone(owner: str, repo: str) -> dict | None: + """Next open milestone for a project card (issue #17). + + The dated milestone with the nearest deadline wins (undated + ones last). Progress and counts come from Gitea's own milestone + counters; the ETA reuses the repo's journal throughput, exactly + like the board banner — one definition of "late", everywhere. + """ + try: + milestones = await gitea.get_milestones(owner, repo, state="open") + except Exception as exc: + logger.debug("next milestone failed for %s/%s: %s", owner, repo, exc) + return None + open_ms = [m for m in milestones if m.get("state", "open") == "open"] + if not open_ms: + return None + + def _key(m: dict): + due = _parse_datetime(str(m.get("due_on") or "")) + return (0, due) if due is not None else (1, None) + + open_ms.sort(key=_key) + m = open_ms[0] + opened = m.get("open_issues") or 0 + closed = m.get("closed_issues") or 0 + total = opened + closed + due_dt = _parse_datetime(str(m.get("due_on") or "")) + due_date = due_dt.date() if due_dt else None + today = datetime.now(timezone.utc).date() + weekly = forecast_service.weekly_closures( + get_events(repo_full_name=f"{owner}/{repo}", limit=2000) + ) + est = forecast_service.estimate(opened, weekly) + return { + "title": m.get("title", ""), + "total": total, + "closed": closed, + "pct": round(100 * closed / total) if total else 0, + "due_label": ( + forecast_service.french_date(due_date, short=True) if due_date else None + ), + "late": bool(due_date and due_date < today and opened > 0), + "eta_label": forecast_service.eta_label(est) if est else None, + "eta_late": bool( + est and due_date and not est.get("done") and est["central"] > due_date + ), + } + + +def _health_dot(health: dict) -> dict: + """Traffic-light summary of a project's health (issue #18). + + Rules (single definition, shared with /health): red when + zombies pile up (≥3, or ≥2 stuck in QA/Review); orange on any + zombie or when the slowest column's median dwell reaches + twice the aging threshold; green otherwise. + """ + zombies = health.get("zombies") or [] + stuck = [z for z in zombies if z.get("column") in ("qa", "review")] + slowest = health.get("slowest") + median = slowest.get("median_days") if slowest else None + aging = get_settings().aging_days + if len(zombies) >= 3 or len(stuck) >= 2: + level = "red" + elif zombies or (median is not None and median >= 2 * aging): + level = "orange" + else: + level = "green" + parts = [] + if zombies: + parts.append(f"{len(zombies)} zombie(s)") + if slowest: + parts.append( + f"goulot : {COLUMN_LABELS[slowest['column']]}" + f" (médiane {median} j)" + ) + return { + "level": level, + "tooltip": " · ".join(parts) if parts + else "Aucun signal — voir la santé du projet", + } + + @app.get("/", response_class=HTMLResponse) async def dashboard(request: Request): """Main dashboard — all projects at a glance.""" repos = await gitea.search_repos() active, inactive, archived = classify_repos(repos) + try: + starred_names = { + r.get("full_name") for r in await gitea.list_starred() + } + except Exception as exc: # stars are decoration, never fatal (#20) + logger.debug("starred list failed: %s", exc) + starred_names = set() for repo_info in active: + full_name = repo_info["full_name"] + owner, _, repo = full_name.partition("/") repo_info["forecast_label"] = _repo_forecast_label( - repo_info["full_name"], repo_info.get("open_issues_count") or 0 + full_name, repo_info.get("open_issues_count") or 0 ) + repo_info["next_milestone"] = await _next_milestone(owner, repo) + try: + health = await _project_health(owner, repo) + repo_info["health_dot"] = _health_dot(health) + except Exception as exc: + logger.debug("health dot failed for %s: %s", full_name, exc) + repo_info["health_dot"] = None + repo_info["starred"] = full_name in starred_names + for repo_info in inactive: + repo_info["starred"] = repo_info["full_name"] in starred_names + for repo_info in active: + repo_info["home_tab"] = "active" + for repo_info in inactive: + repo_info["home_tab"] = "inactive" + for repo_info in archived: + repo_info["home_tab"] = "archived" + pinned = [r for r in active + inactive if r.get("starred")] return render( request, "dashboard.html", active_repos=active, inactive_repos=inactive, archived_repos=archived, + active_listed=[r for r in active if not r.get("starred")], + inactive_listed=[r for r in inactive if not r.get("starred")], + pinned_repos=pinned, stats=get_stats(), COLUMN_LABELS=COLUMN_LABELS, COLUMN_COLORS=COLUMN_COLORS, @@ -512,6 +624,22 @@ async def dashboard(request: Request): ) +@app.post("/api/projects/{owner}/{repo}/star") +async def api_project_star( + request: Request, + owner: str, + repo: str, + starred: str = Form(...), + csrf_token: str = Form(""), +): + """Pin / unpin a project via its Gitea star (issue #20).""" + if not _csrf_valid(request, csrf_token): + return _csrf_error() + want = starred == "1" + await gitea.set_starred(owner, repo, want) + return JSONResponse({"ok": True, "starred": want}) + + @app.get("/board/{owner}/{repo}", response_class=HTMLResponse) async def board(request: Request, owner: str, repo: str, milestone: str = ""): """Kanban board for a specific repo (optionally one milestone).""" @@ -790,6 +918,77 @@ def _excerpt(body: str | None, limit: int = 280) -> str: return text[:limit] + ("…" if len(text) > limit else "") +@app.get("/project/{owner}/{repo}", response_class=HTMLResponse) +async def project_page(request: Request, owner: str, repo: str): + """Project home (issue #25): milestones, health summary, + forecast, recent journal activity and notes for one repo — + an assembly of existing bricks, one truth per block.""" + full_name = f"{owner}/{repo}" + repo_info = await gitea.get_repo(owner, repo) + try: + raw_milestones = await gitea.get_milestones(owner, repo, state="open") + except Exception as exc: + logger.debug("project milestones failed for %s: %s", full_name, exc) + raw_milestones = [] + weekly = forecast_service.weekly_closures( + get_events(repo_full_name=full_name, limit=2000) + ) + throughput = forecast_service.throughput_stats(weekly) + today = datetime.now(timezone.utc).date() + milestones = [] + for m in raw_milestones: + if m.get("state", "open") != "open": + continue + opened = m.get("open_issues") or 0 + closed = m.get("closed_issues") or 0 + total = opened + closed + due_dt = _parse_datetime(str(m.get("due_on") or "")) + due_date = due_dt.date() if due_dt else None + est = forecast_service.estimate(opened, weekly) + milestones.append( + { + "title": m.get("title", ""), + "total": total, + "closed": closed, + "open": opened, + "pct": round(100 * closed / total) if total else 0, + "due_label": ( + forecast_service.french_date(due_date, short=True) + if due_date else None + ), + "late": bool(due_date and due_date < today and opened > 0), + "eta_label": forecast_service.eta_label(est) if est else None, + } + ) + try: + health = await _project_health(owner, repo) + health_dot = _health_dot(health) + except Exception as exc: + logger.debug("project health failed for %s: %s", full_name, exc) + health, health_dot = None, None + events = [ + e for e in get_events(limit=300) + if e["repo_full_name"] == full_name + ][:10] + return render( + request, + "project.html", + repo_info=repo_info, + full_name=full_name, + owner=owner, + repo=repo, + milestones=milestones, + throughput=throughput, + health=health, + health_dot=health_dot, + events=events, + notes=get_notes(full_name), + EVENT_ICONS=EVENT_ICONS, + COLUMN_LABELS=COLUMN_LABELS, + COLUMN_COLORS=COLUMN_COLORS, + ) + + @app.get("/triage", response_class=HTMLResponse) async def triage_picker(request: Request): """Triage mode (issue #11): pick a project by its backlog size.""" diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 9930b77..3dc9232 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -49,14 +49,21 @@ {% macro project_card(repo, archived=False) %} {% set full = repo.full_name %}
{{ repo.description or '—' }}
{% if full in stats %} @@ -68,8 +75,22 @@