Bloc jalon (progression Gitea, échéance FR, retard, ETA journal avec signal après-échéance — mêmes définitions que le bandeau du board) et point santé vert/orange/rouge dérivé de _project_health, info-bulle et lien vers /health du projet.
This commit is contained in:
+200
-1
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user