Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
900590efed | ||
|
|
0aa991047f | ||
|
|
79122c20d0 | ||
|
|
3e3187d597 |
@@ -1,5 +1,36 @@
|
||||
# Changelog
|
||||
|
||||
## 0.19.0 — 2026-10-12
|
||||
|
||||
### Performance + navigation « Pilotage » (chantiers A + C, décidés par Bruno)
|
||||
|
||||
- **Parallélisation (A1)** : les pages inter-projets (dashboard,
|
||||
/health, /forecast, /triage, /wall) attendaient chaque dépôt à
|
||||
la queue leu leu ; elles utilisent maintenant un `asyncio.gather`
|
||||
borné (16) — le patron que /focus appliquait déjà.
|
||||
- **Cache stale-while-revalidate (A2)** : les listes Gitea
|
||||
(dépôts, issues, jalons, étoiles) sont servies immédiatement
|
||||
même périmées, et rafraîchies en tâche de fond. Fini le
|
||||
« mur » de rechargement à chaque expiration du cache (30 s) :
|
||||
seul le tout premier chargement après un démarrage paie le
|
||||
coût complet. Les mutations vident le cache et désarment les
|
||||
rafraîchissements en vol (aucune donnée périmée ressuscitée).
|
||||
- **Prévisions refondues (C1)** : /forecast devient un tableau de
|
||||
bord des jalons **tous projets** — une ligne par jalon (projet,
|
||||
échéance, progression, ETA, **écart**, débit), triée par risque,
|
||||
filtrable, colonnes triables ; le bloc jalon des cartes du
|
||||
dashboard y pointe directement (ancres). Le débit par projet
|
||||
reste en dessous, en tableau compact.
|
||||
- **Santé refondue (C2)** : /health devient un tableau trié par
|
||||
sévérité (pastille, ouvertes, zombies, goulot) ; la page santé
|
||||
d'un projet gagne un **sélecteur de projet** pour basculer sans
|
||||
repasser par l'index. Le tri des tableaux est mutualisé
|
||||
(`static/js/tablesort.js`, dashboard inclus).
|
||||
- Mesuré sur l'instance réelle (depuis le réseau de test) :
|
||||
pages chaudes 0,04–0,16 s ; le premier chargement à froid
|
||||
reste de quelques secondes — le chantier B (instantanés
|
||||
d'arrière-plan) demeure le levier pour le supprimer, si voulu.
|
||||
|
||||
## 0.18.0 — 2026-10-11
|
||||
|
||||
### Nouveautés — vague 2, suite (4 cartes de Bruno)
|
||||
|
||||
+1
-1
@@ -89,7 +89,7 @@ from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
APP_VERSION = "0.18.0"
|
||||
APP_VERSION = "0.19.0"
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
|
||||
+1
-1
@@ -143,7 +143,7 @@ async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None:
|
||||
{
|
||||
"protocolVersion": MCP_PROTOCOL_VERSION,
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "kanbante", "version": "0.18.0"},
|
||||
"clientInfo": {"name": "kanbante", "version": "0.19.0"},
|
||||
},
|
||||
req_id=1,
|
||||
)
|
||||
|
||||
+79
-44
@@ -17,8 +17,10 @@ Changes from the 0.1.0 client:
|
||||
- .env loading targets the project root only (no hardcoded home path).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -60,6 +62,8 @@ class GiteaClient:
|
||||
)
|
||||
self._user: dict | None = None
|
||||
self._cache: dict[str, tuple[float, Any]] = {}
|
||||
self._refreshing: set[str] = set()
|
||||
self._cache_gen = 0
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self.client.aclose()
|
||||
@@ -105,24 +109,62 @@ class GiteaClient:
|
||||
page += 1
|
||||
return items
|
||||
|
||||
def _cache_get(self, key: str) -> Any | None:
|
||||
if self.cache_ttl <= 0:
|
||||
return None
|
||||
hit = self._cache.get(key)
|
||||
if hit and (time.monotonic() - hit[0]) < self.cache_ttl:
|
||||
return hit[1]
|
||||
return None
|
||||
|
||||
def _cache_put(self, key: str, value: Any) -> None:
|
||||
if self.cache_ttl > 0:
|
||||
self._cache[key] = (time.monotonic(), value)
|
||||
|
||||
def _cache_clear(self) -> None:
|
||||
self._cache.clear()
|
||||
# Invalidate in-flight background refreshes: a refresh that
|
||||
# started before a mutation must not resurrect stale data.
|
||||
self._cache_gen += 1
|
||||
|
||||
async def _cached_list(
|
||||
self, key: str, fetcher: Callable[[], Awaitable[Any]]
|
||||
) -> Any:
|
||||
"""Stale-while-revalidate cache for list endpoints (0.19.0).
|
||||
|
||||
Fresh (< TTL): serve the cache. Stale: serve the cached
|
||||
value IMMEDIATELY and refresh it in the background (once
|
||||
per key) — the user never waits on Gitea for a page when
|
||||
any earlier answer exists. Absent: fetch and wait.
|
||||
Mutations clear the cache (see _cache_clear), so writes are
|
||||
never hidden by a stale entry.
|
||||
"""
|
||||
if self.cache_ttl <= 0:
|
||||
return await fetcher()
|
||||
hit = self._cache.get(key)
|
||||
if hit is not None:
|
||||
if (time.monotonic() - hit[0]) < self.cache_ttl:
|
||||
return hit[1]
|
||||
if key not in self._refreshing:
|
||||
self._refreshing.add(key)
|
||||
asyncio.create_task(
|
||||
self._refresh_entry(key, fetcher, self._cache_gen)
|
||||
)
|
||||
return hit[1]
|
||||
value = await fetcher()
|
||||
self._cache_put(key, value)
|
||||
return value
|
||||
|
||||
async def _refresh_entry(
|
||||
self,
|
||||
key: str,
|
||||
fetcher: Callable[[], Awaitable[Any]],
|
||||
generation: int,
|
||||
) -> None:
|
||||
try:
|
||||
value = await fetcher()
|
||||
if generation == self._cache_gen:
|
||||
self._cache_put(key, value)
|
||||
except Exception as exc:
|
||||
logger.debug("background refresh failed for %s: %s", key, exc)
|
||||
finally:
|
||||
self._refreshing.discard(key)
|
||||
|
||||
def invalidate_cache(self) -> None:
|
||||
"""Drop every cached list response (called on webhook events)."""
|
||||
self._cache.clear()
|
||||
self._cache_clear()
|
||||
|
||||
# ── API ────────────────────────────────────────────────
|
||||
|
||||
@@ -141,20 +183,21 @@ class GiteaClient:
|
||||
return self._user
|
||||
|
||||
async def search_repos(self) -> list[dict]:
|
||||
"""All repos visible to the token owner (paginated, cached)."""
|
||||
cached = self._cache_get("search_repos")
|
||||
if cached is not None:
|
||||
return cached
|
||||
params: dict[str, Any] = {}
|
||||
try:
|
||||
user = await self.get_user()
|
||||
if user and user.get("id"):
|
||||
params["uid"] = user["id"]
|
||||
except Exception as exc: # no/invalid token → public search
|
||||
logger.debug("get_user failed, falling back to public search: %s", exc)
|
||||
repos = await self._get_all_pages("/repos/search", **params)
|
||||
self._cache_put("search_repos", repos)
|
||||
return repos
|
||||
"""All repos visible to the token owner (paginated, SWR-cached)."""
|
||||
|
||||
async def _fetch() -> list[dict]:
|
||||
params: dict[str, Any] = {}
|
||||
try:
|
||||
user = await self.get_user()
|
||||
if user and user.get("id"):
|
||||
params["uid"] = user["id"]
|
||||
except Exception as exc: # no/invalid token → public search
|
||||
logger.debug(
|
||||
"get_user failed, falling back to public search: %s", exc
|
||||
)
|
||||
return await self._get_all_pages("/repos/search", **params)
|
||||
|
||||
return await self._cached_list("search_repos", _fetch)
|
||||
|
||||
async def get_repo(self, owner: str, repo: str) -> dict:
|
||||
return await self._get(f"/repos/{owner}/{repo}")
|
||||
@@ -178,14 +221,12 @@ class GiteaClient:
|
||||
(see app.main._is_issue).
|
||||
"""
|
||||
key = f"issues:{owner}/{repo}:{state}"
|
||||
cached = self._cache_get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
issues = await self._get_all_pages(
|
||||
f"/repos/{owner}/{repo}/issues", state=state
|
||||
return await self._cached_list(
|
||||
key,
|
||||
lambda: self._get_all_pages(
|
||||
f"/repos/{owner}/{repo}/issues", state=state
|
||||
),
|
||||
)
|
||||
self._cache_put(key, issues)
|
||||
return issues
|
||||
|
||||
async def get_all_issues(
|
||||
self, repos: list[dict], state: str = "open"
|
||||
@@ -308,25 +349,19 @@ class GiteaClient:
|
||||
self, owner: str, repo: str, state: str = "open"
|
||||
) -> list[dict]:
|
||||
key = f"milestones:{owner}/{repo}:{state}"
|
||||
cached = self._cache_get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
milestones = await self._get_all_pages(
|
||||
f"/repos/{owner}/{repo}/milestones", state=state
|
||||
return await self._cached_list(
|
||||
key,
|
||||
lambda: self._get_all_pages(
|
||||
f"/repos/{owner}/{repo}/milestones", state=state
|
||||
),
|
||||
)
|
||||
self._cache_put(key, milestones)
|
||||
return milestones
|
||||
|
||||
async def list_starred(self) -> list[dict]:
|
||||
"""Repos starred by the token user (issue #20: pinning rides
|
||||
on Gitea stars — the forge stays the source of truth)."""
|
||||
key = "user:starred"
|
||||
cached = self._cache_get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
repos = await self._get_all_pages("/user/starred")
|
||||
self._cache_put(key, repos)
|
||||
return repos
|
||||
return await self._cached_list(
|
||||
"user:starred", lambda: self._get_all_pages("/user/starred")
|
||||
)
|
||||
|
||||
async def set_starred(self, owner: str, repo: str, starred: bool) -> None:
|
||||
"""Star / unstar a repo for the token user."""
|
||||
|
||||
+181
-70
@@ -378,6 +378,27 @@ def _parse_datetime(value: str | None) -> datetime | None:
|
||||
return None
|
||||
|
||||
|
||||
async def _parallel_map(items, fn, limit: int = 16) -> list:
|
||||
"""Run an async fn over items with bounded concurrency (0.19.0).
|
||||
|
||||
Cross-project pages used to await each repo in sequence, so the
|
||||
page took the SUM of every Gitea round trip. Gathering with a
|
||||
per-call semaphore (created here, bound to the current loop)
|
||||
cuts that to roughly the slowest single repo, without letting
|
||||
a big instance stampede Gitea. Exceptions come back in place —
|
||||
callers decide what a failed repo means for them.
|
||||
"""
|
||||
semaphore = asyncio.Semaphore(limit)
|
||||
|
||||
async def _run(item):
|
||||
async with semaphore:
|
||||
return await fn(item)
|
||||
|
||||
return await asyncio.gather(
|
||||
*(_run(item) for item in items), return_exceptions=True
|
||||
)
|
||||
|
||||
|
||||
def classify_repos(
|
||||
repos: list[dict], now: datetime | None = None
|
||||
) -> tuple[list[dict], list[dict], list[dict]]:
|
||||
@@ -585,7 +606,7 @@ async def dashboard(request: Request):
|
||||
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:
|
||||
async def _enrich_active(repo_info: dict) -> None:
|
||||
full_name = repo_info["full_name"]
|
||||
owner, _, repo = full_name.partition("/")
|
||||
repo_info["forecast_label"] = _repo_forecast_label(
|
||||
@@ -599,6 +620,8 @@ async def dashboard(request: Request):
|
||||
logger.debug("health dot failed for %s: %s", full_name, exc)
|
||||
repo_info["health_dot"] = None
|
||||
repo_info["starred"] = full_name in starred_names
|
||||
|
||||
await _parallel_map(active, _enrich_active)
|
||||
for repo_info in inactive:
|
||||
repo_info["starred"] = repo_info["full_name"] in starred_names
|
||||
activity_by_repo = get_project_activity()
|
||||
@@ -1013,19 +1036,18 @@ 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:
|
||||
|
||||
async def _one_triage(repo_info: dict) -> dict | None:
|
||||
owner, _, name = repo_info["full_name"].partition("/")
|
||||
try:
|
||||
all_items = await gitea.get_repo_issues(owner, name, state="all")
|
||||
except Exception:
|
||||
continue
|
||||
all_items = await gitea.get_repo_issues(owner, name, state="all")
|
||||
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}
|
||||
)
|
||||
return {"full_name": repo_info["full_name"], "backlog": backlog}
|
||||
return None
|
||||
|
||||
results = await _parallel_map(active, _one_triage)
|
||||
projects = [r for r in results if isinstance(r, dict)]
|
||||
projects.sort(key=lambda p: -p["backlog"])
|
||||
return render(request, "triage.html", projects=projects)
|
||||
|
||||
@@ -1554,30 +1576,52 @@ async def health_index(request: Request):
|
||||
"""Flow health, global summary (issue #9)."""
|
||||
repos = await gitea.search_repos()
|
||||
active, _inactive, _archived = classify_repos(repos)
|
||||
projects = []
|
||||
for repo_info in active:
|
||||
|
||||
async def _one_health(repo_info: dict) -> dict:
|
||||
owner, _, name = repo_info["full_name"].partition("/")
|
||||
try:
|
||||
projects.append(await _project_health(owner, name))
|
||||
except Exception as exc:
|
||||
logger.warning("Health failed for %s: %s", repo_info["full_name"], exc)
|
||||
return await _project_health(owner, name)
|
||||
|
||||
results = await _parallel_map(active, _one_health)
|
||||
projects = []
|
||||
for repo_info, result in zip(active, results):
|
||||
if isinstance(result, Exception):
|
||||
logger.warning(
|
||||
"Health failed for %s: %s", repo_info["full_name"], result
|
||||
)
|
||||
continue
|
||||
result["dot"] = _health_dot(result)
|
||||
projects.append(result)
|
||||
severity_rank = {"red": 0, "orange": 1, "green": 2}
|
||||
projects.sort(
|
||||
key=lambda p: (
|
||||
severity_rank.get(p["dot"]["level"], 3),
|
||||
-len(p["zombies"]),
|
||||
)
|
||||
)
|
||||
return render(request, "health.html", projects=projects)
|
||||
|
||||
|
||||
@app.get("/health/{owner}/{repo}", response_class=HTMLResponse)
|
||||
async def health_project(request: Request, owner: str, repo: str):
|
||||
"""Flow health of one project: dwell per column + zombies."""
|
||||
"""Flow health of one project: dwell per column + zombies.
|
||||
|
||||
Passes the active project list so the page can offer a project
|
||||
switcher — no more back-to-the-index round trip (0.19.0, C).
|
||||
"""
|
||||
repos = await gitea.search_repos()
|
||||
known = {r["full_name"] for r in repos}
|
||||
if f"{owner}/{repo}" not in known:
|
||||
active, _inactive, _archived = classify_repos(repos)
|
||||
project_names = [r["full_name"] for r in active]
|
||||
if f"{owner}/{repo}" not in {r["full_name"] for r in repos}:
|
||||
return render(
|
||||
request, "health_project.html",
|
||||
health=None, full_name=f"{owner}/{repo}",
|
||||
project_names=project_names,
|
||||
)
|
||||
health = await _project_health(owner, repo)
|
||||
return render(
|
||||
request, "health_project.html",
|
||||
health=health, full_name=f"{owner}/{repo}",
|
||||
project_names=project_names,
|
||||
)
|
||||
|
||||
|
||||
@@ -1604,11 +1648,29 @@ async def standup_page(request: Request):
|
||||
|
||||
@app.get("/forecast", response_class=HTMLResponse)
|
||||
async def forecast_page(request: Request):
|
||||
"""Forecasts (issue #7): real-pace ETAs per project + milestone."""
|
||||
"""Forecasts (issue #7; portfolio table in 0.19.0, chantier C).
|
||||
|
||||
One row per open milestone across every active project,
|
||||
sorted by risk (largest ETA-vs-deadline gap first) — the
|
||||
cross-project question the old per-project card wall could
|
||||
not answer without reading everything. Per-project pace
|
||||
(weekly bars + backlog ETA) stays as a compact second table.
|
||||
"""
|
||||
repos = await gitea.search_repos()
|
||||
active, _inactive, _archived = classify_repos(repos)
|
||||
projects = []
|
||||
for repo_info in active:
|
||||
|
||||
async def _fetch_milestones(repo_info: dict) -> list[dict]:
|
||||
owner, _, name = repo_info["full_name"].partition("/")
|
||||
try:
|
||||
return await gitea.get_milestones(owner, name)
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
ms_results = await _parallel_map(active, _fetch_milestones)
|
||||
|
||||
rows: list[dict] = []
|
||||
projects: list[dict] = []
|
||||
for repo_info, ms_list in zip(active, ms_results):
|
||||
full = repo_info["full_name"]
|
||||
owner, _, name = full.partition("/")
|
||||
weekly = forecast_service.weekly_closures(
|
||||
@@ -1623,50 +1685,100 @@ async def forecast_page(request: Request):
|
||||
}
|
||||
for b in weekly
|
||||
]
|
||||
stats = forecast_service.throughput_stats(weekly)
|
||||
remaining = repo_info.get("open_issues_count") or 0
|
||||
est = forecast_service.estimate(remaining, weekly)
|
||||
milestones = []
|
||||
try:
|
||||
ms_list = await gitea.get_milestones(owner, name)
|
||||
except Exception:
|
||||
ms_list = []
|
||||
for ms in ms_list:
|
||||
if ms.get("state") not in (None, "open"):
|
||||
continue
|
||||
rem = ms.get("open_issues") or 0
|
||||
if rem == 0 and not (ms.get("closed_issues") or 0):
|
||||
continue
|
||||
m_est = forecast_service.estimate(rem, weekly)
|
||||
verdict = None
|
||||
due_raw = ms.get("due_on")
|
||||
if m_est and not m_est.get("done") and due_raw:
|
||||
due = _parse_datetime(str(due_raw))
|
||||
if due is not None:
|
||||
verdict = (
|
||||
"⚠️ en retard à ce rythme"
|
||||
if m_est["central"] > due.date()
|
||||
else "✅ dans les temps à ce rythme"
|
||||
)
|
||||
milestones.append(
|
||||
{
|
||||
"title": ms.get("title", ""),
|
||||
"due_on": (due_raw or "")[:10],
|
||||
"remaining": rem,
|
||||
"eta": forecast_service.eta_label(m_est),
|
||||
"verdict": verdict,
|
||||
}
|
||||
)
|
||||
projects.append(
|
||||
{
|
||||
"full_name": full,
|
||||
"bars": bars,
|
||||
"stats": forecast_service.throughput_stats(weekly),
|
||||
"stats": stats,
|
||||
"remaining": remaining,
|
||||
"eta_label": forecast_service.eta_label(est),
|
||||
"milestones": milestones,
|
||||
}
|
||||
)
|
||||
return render(request, "forecast.html", projects=projects)
|
||||
if isinstance(ms_list, Exception):
|
||||
ms_list = []
|
||||
next_title = None
|
||||
next_due = None
|
||||
for ms in ms_list:
|
||||
if ms.get("state") not in (None, "open"):
|
||||
continue
|
||||
rem = ms.get("open_issues") or 0
|
||||
closed_n = ms.get("closed_issues") or 0
|
||||
if rem == 0 and not closed_n:
|
||||
continue
|
||||
total = rem + closed_n
|
||||
m_est = forecast_service.estimate(rem, weekly)
|
||||
due_raw = ms.get("due_on")
|
||||
due_dt = _parse_datetime(str(due_raw)) if due_raw else None
|
||||
due_date = due_dt.date() if due_dt else None
|
||||
gap_days = None
|
||||
verdict = "noeta"
|
||||
if due_date is None:
|
||||
verdict = "nodue"
|
||||
elif m_est and not m_est.get("done"):
|
||||
gap_days = (m_est["central"] - due_date).days
|
||||
verdict = "late" if gap_days > 0 else "ontime"
|
||||
elif m_est and m_est.get("done"):
|
||||
verdict = "ontime"
|
||||
rows.append(
|
||||
{
|
||||
"project": full,
|
||||
"owner": owner,
|
||||
"repo": name,
|
||||
"title": ms.get("title", ""),
|
||||
"due_label": (
|
||||
forecast_service.french_date(due_date, short=True)
|
||||
if due_date else "—"
|
||||
),
|
||||
"due_sort": due_date.isoformat() if due_date else "9999",
|
||||
"remaining": rem,
|
||||
"closed": closed_n,
|
||||
"total": total,
|
||||
"pct": round(100 * closed_n / total) if total else 0,
|
||||
"eta": forecast_service.eta_label(m_est),
|
||||
"gap_days": gap_days,
|
||||
"verdict": verdict,
|
||||
"throughput": stats["avg"] if stats else None,
|
||||
"anchor": False,
|
||||
}
|
||||
)
|
||||
if (
|
||||
due_date is not None
|
||||
and (next_due is None or due_date < next_due)
|
||||
):
|
||||
next_due, next_title = due_date, ms.get("title", "")
|
||||
for row in rows:
|
||||
if (
|
||||
row["project"] == full
|
||||
and row["title"] == next_title
|
||||
and next_title is not None
|
||||
):
|
||||
row["anchor"] = True
|
||||
|
||||
def _risk_key(row: dict):
|
||||
# Late first (worst gap first), then dated on-time by due
|
||||
# date, then no-ETA, then no due date.
|
||||
order = {"late": 0, "ontime": 1, "noeta": 2, "nodue": 3}
|
||||
return (
|
||||
order.get(row["verdict"], 4),
|
||||
-(row["gap_days"] or 0) if row["verdict"] == "late" else 0,
|
||||
row["due_sort"],
|
||||
row["project"],
|
||||
row["title"],
|
||||
)
|
||||
|
||||
rows.sort(key=_risk_key)
|
||||
summary = {
|
||||
"late": sum(1 for r in rows if r["verdict"] == "late"),
|
||||
"ontime": sum(1 for r in rows if r["verdict"] == "ontime"),
|
||||
"total": len(rows),
|
||||
}
|
||||
return render(
|
||||
request, "forecast.html",
|
||||
rows=rows, projects=projects, summary=summary,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/focus", response_class=HTMLResponse)
|
||||
@@ -1739,15 +1851,14 @@ async def _collect_wall_data(only_repo: str | None) -> list[dict]:
|
||||
closed_today.get(event["repo_full_name"], 0) + 1
|
||||
)
|
||||
|
||||
slides = []
|
||||
for repo in repos:
|
||||
async def _one_slide(repo: dict) -> dict | None:
|
||||
full_name = repo["full_name"]
|
||||
owner, _, name = full_name.partition("/")
|
||||
try:
|
||||
items = await gitea.get_repo_issues(owner, name, state="open")
|
||||
except Exception as exc:
|
||||
logger.debug("wall: issues failed for %s: %s", full_name, exc)
|
||||
continue
|
||||
return None
|
||||
open_issues = [i for i in items if _is_issue(i)]
|
||||
_adopt_label_columns(full_name, open_issues, get_board_state(full_name))
|
||||
board_state = get_board_state(full_name)
|
||||
@@ -1769,17 +1880,17 @@ async def _collect_wall_data(only_repo: str | None) -> list[dict]:
|
||||
"aging": days is not None and days >= aging_days,
|
||||
}
|
||||
)
|
||||
slides.append(
|
||||
{
|
||||
"full_name": full_name,
|
||||
"counts": {col: len(columns[col]) for col in KANBAN_COLUMNS},
|
||||
"open_total": len(open_issues),
|
||||
"cards": cards[:9],
|
||||
"cards_total": len(cards),
|
||||
"closed_today": closed_today.get(full_name, 0),
|
||||
}
|
||||
)
|
||||
return slides
|
||||
return {
|
||||
"full_name": full_name,
|
||||
"counts": {col: len(columns[col]) for col in KANBAN_COLUMNS},
|
||||
"open_total": len(open_issues),
|
||||
"cards": cards[:9],
|
||||
"cards_total": len(cards),
|
||||
"closed_today": closed_today.get(full_name, 0),
|
||||
}
|
||||
|
||||
results = await _parallel_map(repos, _one_slide)
|
||||
return [r for r in results if isinstance(r, dict)]
|
||||
|
||||
|
||||
@app.get("/wall", response_class=HTMLResponse)
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
{% set ms = repo.next_milestone %}
|
||||
<div class="ms-block">
|
||||
<div class="ms-line">
|
||||
<span class="ms-title">🎯 {{ ms.title }}</span>
|
||||
<a href="/forecast#ms-{{ repo.owner.login }}-{{ repo.name }}" class="ms-title" title="Voir ce jalon dans le tableau des prévisions">🎯 {{ ms.title }}</a>
|
||||
{% if ms.due_label %}<span class="ms-due{% if ms.late %} late{% endif %}">{{ '⚠️ ' if ms.late }}échéance {{ ms.due_label }}</span>{% endif %}
|
||||
</div>
|
||||
{% if ms.total %}
|
||||
@@ -260,6 +260,7 @@
|
||||
<script>window.KANBANTE_CSRF = "{{ csrf_token }}";</script>
|
||||
<script src="/static/js/stars.js" defer></script>
|
||||
<script src="/static/js/dashboard.js" defer></script>
|
||||
<script src="/static/js/tablesort.js" defer></script>
|
||||
<script>
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === '/' && !/INPUT|TEXTAREA/.test(document.activeElement?.tagName || '')) {
|
||||
|
||||
+78
-18
@@ -13,13 +13,72 @@
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
<h1>🔮 Prévisions — « on finit quand ? »</h1>
|
||||
<p>Réponse honnête, calculée depuis le journal : fermetures réelles par semaine (8 dernières), débit moyen/min/max sur les 4 dernières semaines complètes, et fourchette plutôt que faux chiffre précis. Sans historique de fermetures, aucune prévision n'est affichée.</p>
|
||||
<p>Le tableau de bord des jalons, <strong>tous projets confondus</strong>, trié par risque : l'écart entre la date estimée (au débit réel du journal) et l'échéance. Réponse honnête — fourchettes plutôt que faux chiffres précis, et aucune prévision sans historique de fermetures.</p>
|
||||
<p class="forecast-summary">
|
||||
<span class="chip chip-total">🎯 {{ summary.total }} jalon(s) ouvert(s)</span>
|
||||
{% if summary.late %}<span class="chip chip-late">⚠️ {{ summary.late }} en retard à ce rythme</span>{% endif %}
|
||||
{% if summary.ontime %}<span class="chip chip-ok">✅ {{ summary.ontime }} dans les temps</span>{% endif %}
|
||||
</p>
|
||||
<div class="search-bar forecast-filter">
|
||||
<input type="text" id="ms-filter" placeholder="🔍 Filtrer par projet ou jalon..." autocomplete="off">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% for project in projects %}
|
||||
<section class="briefing-card forecast-card">
|
||||
<h2>📂 {{ project.full_name }}</h2>
|
||||
{% if rows %}
|
||||
<table class="proj-table pilot-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="project">Projet</th>
|
||||
<th data-sort="title">Jalon</th>
|
||||
<th data-sort="due">Échéance</th>
|
||||
<th>Progression</th>
|
||||
<th>ETA</th>
|
||||
<th data-sort="gap">Écart</th>
|
||||
<th data-sort="throughput">Débit / sem.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for row in rows %}
|
||||
<tr{% if row.anchor %} id="ms-{{ row.owner }}-{{ row.repo }}"{% endif %}
|
||||
data-project="{{ row.project|lower }}" data-title="{{ row.title|lower }}"
|
||||
data-due="{{ row.due_sort }}" data-gap="{{ row.gap_days if row.gap_days is not none else '' }}"
|
||||
data-throughput="{{ row.throughput if row.throughput is not none else '' }}"
|
||||
data-search="{{ (row.project ~ ' ' ~ row.title)|lower }}">
|
||||
<td><a href="/project/{{ row.project }}">{{ row.project }}</a></td>
|
||||
<td><a href="/board/{{ row.project }}?milestone={{ row.title|urlencode }}"><strong>{{ row.title }}</strong></a></td>
|
||||
<td>{{ row.due_label }}</td>
|
||||
<td>
|
||||
{% if row.total %}
|
||||
<div class="progress ms-progress pilot-progress"><span style="width: {{ row.pct }}%"></span></div>
|
||||
<span class="ms-meta">{{ row.closed }}/{{ row.total }}</span>
|
||||
{% else %}—{% endif %}
|
||||
</td>
|
||||
<td>{{ row.eta or '—' }}</td>
|
||||
<td>
|
||||
{% if row.verdict == 'late' %}
|
||||
<span class="verdict verdict-late">⚠️ +{{ row.gap_days }} j</span>
|
||||
{% elif row.verdict == 'ontime' %}
|
||||
<span class="verdict verdict-ok">✅ dans les temps{% if row.gap_days is not none and row.gap_days < 0 %} ({{ -row.gap_days }} j d'avance){% endif %}</span>
|
||||
{% elif row.verdict == 'nodue' %}
|
||||
<span class="verdict">sans échéance</span>
|
||||
{% else %}
|
||||
<span class="verdict">pas d'ETA</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ row.throughput if row.throughput is not none else '—' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<section class="briefing-card"><p>Aucun jalon ouvert dans les projets actifs. Crée des jalons dans Gitea pour piloter par échéances.</p></section>
|
||||
{% endif %}
|
||||
|
||||
<section class="briefing-card">
|
||||
<h2>📈 Débit par projet</h2>
|
||||
{% for project in projects %}
|
||||
<div class="forecast-flex">
|
||||
<div class="forecast-proj-name">📂 <a href="/project/{{ project.full_name }}">{{ project.full_name }}</a></div>
|
||||
<svg class="sparkline" viewBox="0 0 168 48" role="img" aria-label="Fermetures par semaine">
|
||||
{% for bar in project.bars %}
|
||||
{% set px = (bar.h * 0.42)|round|int %}
|
||||
@@ -30,25 +89,17 @@
|
||||
</svg>
|
||||
<div class="forecast-nums">
|
||||
{% if project.stats %}
|
||||
<p>Débit : <strong>{{ project.stats.avg }} / semaine</strong> en moyenne (min {{ project.stats.min }}, max {{ project.stats.max }})</p>
|
||||
<p>Débit : <strong>{{ project.stats.avg }} / semaine</strong> (min {{ project.stats.min }}, max {{ project.stats.max }})</p>
|
||||
{% else %}
|
||||
<p>Débit : <strong>pas assez d'historique</strong> — aucune fermeture récente dans le journal.</p>
|
||||
<p>Débit : <strong>pas assez d'historique</strong>.</p>
|
||||
{% endif %}
|
||||
<p>Restant ouvert : <strong>{{ project.remaining }}</strong>
|
||||
{% if project.eta_label %} · 🔮 backlog {{ project.eta_label }}{% endif %}</p>
|
||||
<p>Restant ouvert : <strong>{{ project.remaining }}</strong>{% if project.eta_label %} · 🔮 backlog {{ project.eta_label }}{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% if project.milestones %}
|
||||
<ul class="forecast-milestones">
|
||||
{% for ms in project.milestones %}
|
||||
<li>🎯 <strong>{{ ms.title }}</strong> — {{ ms.remaining }} restante(s){% if ms.due_on %} · échéance {{ ms.due_on }}{% endif %}{% if ms.eta %} · 🔮 {{ ms.eta }}{% endif %}{% if ms.verdict %} · {{ ms.verdict }}{% endif %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p>Aucun projet actif.</p>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% else %}
|
||||
<div class="briefing-card"><p>Aucun projet actif.</p></div>
|
||||
{% endfor %}
|
||||
</main>
|
||||
<footer>
|
||||
<span>Kanbante v{{ version }} · prévisions = journal des fermetures, pas boule de cristal</span>
|
||||
@@ -56,5 +107,14 @@
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
<script src="/static/js/tablesort.js" defer></script>
|
||||
<script>
|
||||
document.getElementById('ms-filter')?.addEventListener('input', function (e) {
|
||||
var q = e.target.value.toLowerCase();
|
||||
document.querySelectorAll('.pilot-table tbody tr').forEach(function (row) {
|
||||
row.style.display = !q || (row.dataset.search || '').includes(q) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+36
-14
@@ -13,23 +13,44 @@
|
||||
<main class="focus-main">
|
||||
<section class="briefing-card">
|
||||
<h1>🩺 Santé du flux</h1>
|
||||
<p>Les boards montrent l'état ; cette page montre la <strong>santé</strong> : quelle colonne accumule (temps médian passé dans chaque colonne, d'après le journal) et quelles cartes sont devenues des <strong>zombies</strong> (aucune activité depuis le seuil configuré).</p>
|
||||
<p>Tous les projets actifs, <strong>triés par sévérité</strong> — le rouge d'abord. Quelle colonne accumule (temps médian par colonne, d'après le journal) et quelles cartes sont devenues des <strong>zombies</strong>. Cliquez un projet pour le détail et les actions rapides.</p>
|
||||
</section>
|
||||
|
||||
{% for project in projects %}
|
||||
<section class="briefing-card forecast-card">
|
||||
<h2>📂 {{ project.full_name }} <small>({{ project.open_count }} ouverte(s))</small></h2>
|
||||
{% if project.slowest %}
|
||||
<p>🐌 Colonne la plus lente : <strong>{{ project.column_labels[project.slowest.column] }}</strong> — médiane {{ project.slowest.median_days }} j ({{ project.slowest.count }} séjours mesurés)</p>
|
||||
{% else %}
|
||||
<p>🐌 Pas encore assez de séjours mesurés pour nommer un goulot.</p>
|
||||
{% endif %}
|
||||
<p>🧟 Zombies (≥ {{ project.zombie_days }} j sans activité) : <strong>{{ project.zombies|length }}</strong>
|
||||
— <a href="/health/{{ project.full_name }}">voir le détail et agir →</a></p>
|
||||
</section>
|
||||
{% if projects %}
|
||||
<table class="proj-table pilot-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-sort="severity">Santé</th>
|
||||
<th data-sort="project">Projet</th>
|
||||
<th data-sort="open">Ouvertes</th>
|
||||
<th data-sort="zombies">🧟 Zombies</th>
|
||||
<th data-sort="slowest">🐌 Goulot</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for project in projects %}
|
||||
<tr data-severity="{{ {'red': 0, 'orange': 1, 'green': 2}.get(project.dot.level, 3) }}"
|
||||
data-project="{{ project.full_name|lower }}" data-open="{{ project.open_count }}"
|
||||
data-zombies="{{ project.zombies|length }}"
|
||||
data-slowest="{{ project.slowest.median_days if project.slowest else '' }}">
|
||||
<td><span class="health-dot {{ project.dot.level }}" title="{{ project.dot.title }}"></span></td>
|
||||
<td><a href="/health/{{ project.full_name }}"><strong>{{ project.full_name }}</strong></a></td>
|
||||
<td>{{ project.open_count }}</td>
|
||||
<td>{{ project.zombies|length }}{% if project.zombies %} <span class="ms-meta">(≥ {{ project.zombie_days }} j)</span>{% endif %}</td>
|
||||
<td>
|
||||
{% if project.slowest %}
|
||||
{{ project.column_labels[project.slowest.column] }} — médiane {{ project.slowest.median_days }} j
|
||||
{% else %}<span class="ms-meta">pas assez de données</span>{% endif %}
|
||||
</td>
|
||||
<td><a href="/health/{{ project.full_name }}">détail →</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="briefing-card"><p>Aucun projet actif.</p></div>
|
||||
{% endfor %}
|
||||
<section class="briefing-card"><p>Aucun projet actif.</p></section>
|
||||
{% endif %}
|
||||
</main>
|
||||
<footer>
|
||||
<span>Kanbante v{{ version }} · l'hygiène du flux, mesurée sur le journal — pas au feeling</span>
|
||||
@@ -37,5 +58,6 @@
|
||||
{% include "_palette.html" %}
|
||||
<script src="/static/js/palette.js" defer></script>
|
||||
<script src="/static/js/live.js" defer></script>
|
||||
<script src="/static/js/tablesort.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -11,6 +11,18 @@
|
||||
{% include "_header.html" %}
|
||||
|
||||
<main class="focus-main">
|
||||
{% if project_names %}
|
||||
<div class="health-switch-bar">
|
||||
<label for="health-switch">🩺 Projet :</label>
|
||||
<select id="health-switch">
|
||||
{% for name in project_names %}
|
||||
<option value="{{ name }}"{% if name == full_name %} selected{% endif %}>{{ name }}</option>
|
||||
{% endfor %}
|
||||
{% if full_name not in project_names %}<option value="{{ full_name }}" selected>{{ full_name }}</option>{% endif %}
|
||||
</select>
|
||||
<a href="/health" class="health-back">← tous les projets</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if health is none %}
|
||||
<section class="briefing-card">
|
||||
<h1>🩺 {{ full_name }}</h1>
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "kanbante"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
description = "Kanban project manager integrated with Gitea"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
|
||||
@@ -1443,3 +1443,35 @@ body.sidebar-collapsed .side-foot { justify-content: center; }
|
||||
.proj-table { display: block; overflow-x: auto; }
|
||||
.dash-view-toggle { margin-left: 0; }
|
||||
}
|
||||
|
||||
/* ── Pilotage 0.19.0 : tableaux Prévisions/Santé (chantier C) ── */
|
||||
.pilot-table { margin-bottom: 18px; }
|
||||
.pilot-table tr[id] { scroll-margin-top: 76px; }
|
||||
.pilot-progress { min-width: 90px; }
|
||||
.forecast-summary { display: flex; gap: 8px; flex-wrap: wrap; margin: 10px 0 4px; }
|
||||
.forecast-summary .chip {
|
||||
font-size: 12.5px; padding: 3px 10px; border-radius: 999px;
|
||||
background: rgba(255, 255, 255, .07); border: 1px solid var(--border, #333);
|
||||
}
|
||||
.forecast-summary .chip-late { background: rgba(248, 81, 73, .15); }
|
||||
.forecast-summary .chip-ok { background: rgba(63, 185, 80, .15); }
|
||||
.forecast-filter { margin-top: 10px; max-width: 420px; }
|
||||
.forecast-filter input { width: 100%; }
|
||||
.forecast-proj-name { font-weight: 600; min-width: 180px; }
|
||||
.verdict {
|
||||
font-size: 12.5px; padding: 2px 9px; border-radius: 999px; white-space: nowrap;
|
||||
background: rgba(255, 255, 255, .06);
|
||||
}
|
||||
.verdict-late { background: rgba(248, 81, 73, .18); }
|
||||
.verdict-ok { background: rgba(63, 185, 80, .16); }
|
||||
a.ms-title { color: inherit; text-decoration: none; }
|
||||
a.ms-title:hover { text-decoration: underline; }
|
||||
.health-switch-bar {
|
||||
display: flex; align-items: center; gap: 10px; margin-bottom: 14px; flex-wrap: wrap;
|
||||
}
|
||||
.health-switch-bar select {
|
||||
padding: 8px 10px; border-radius: 8px; font-size: 14px; min-height: 38px;
|
||||
background: var(--panel-2, #1c2330); color: inherit;
|
||||
border: 1px solid var(--border, #333);
|
||||
}
|
||||
.health-back { font-size: 13.5px; }
|
||||
|
||||
+2
-29
@@ -154,33 +154,6 @@
|
||||
if (e.key === 'Escape') closeMenus();
|
||||
});
|
||||
|
||||
/* ── Tri des tableaux denses (#22) ─────────────────────── */
|
||||
document.querySelectorAll('.proj-table').forEach(function (table) {
|
||||
var tbody = table.querySelector('tbody');
|
||||
table.querySelectorAll('th[data-sort]').forEach(function (th) {
|
||||
th.classList.add('sortable');
|
||||
th.addEventListener('click', function () {
|
||||
var key = th.getAttribute('data-sort');
|
||||
var dir = th.getAttribute('data-dir') === 'asc' ? 'desc' : 'asc';
|
||||
table.querySelectorAll('th').forEach(function (h) {
|
||||
h.removeAttribute('data-dir');
|
||||
});
|
||||
th.setAttribute('data-dir', dir);
|
||||
var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
|
||||
rows.sort(function (a, b) {
|
||||
var va = a.getAttribute('data-' + key) || '';
|
||||
var vb = b.getAttribute('data-' + key) || '';
|
||||
var na = parseFloat(va), nb = parseFloat(vb);
|
||||
var cmp;
|
||||
if (!isNaN(na) && !isNaN(nb) && va !== '' && vb !== '') {
|
||||
cmp = na - nb;
|
||||
} else {
|
||||
cmp = va < vb ? -1 : (va > vb ? 1 : 0);
|
||||
}
|
||||
return dir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
rows.forEach(function (r) { tbody.appendChild(r); });
|
||||
});
|
||||
});
|
||||
});
|
||||
/* Le tri des tableaux denses (#22) vit dans tablesort.js,
|
||||
partagé avec /forecast et /health (0.19.0). */
|
||||
})();
|
||||
|
||||
@@ -5,6 +5,15 @@
|
||||
(function () {
|
||||
var csrf = (document.querySelector('meta[name="csrf-token"]') || {}).content || '';
|
||||
|
||||
/* Project switcher (0.19.0): jump between projects without
|
||||
going back to the health index. */
|
||||
var switcher = document.getElementById('health-switch');
|
||||
if (switcher) {
|
||||
switcher.addEventListener('change', function () {
|
||||
window.location.href = '/health/' + switcher.value;
|
||||
});
|
||||
}
|
||||
|
||||
function post(url, fields) {
|
||||
var data = new FormData();
|
||||
Object.keys(fields).forEach(function (k) { data.append(k, fields[k]); });
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Kanbante — tri des tableaux « pilotage » (.proj-table), partagé
|
||||
* entre le dashboard, /forecast et /health (0.19.0).
|
||||
* Tri client par colonne : les lignes portent leurs valeurs dans
|
||||
* des attributs data-<clé> ; cliquer un <th data-sort="clé">
|
||||
* trie dans un sens puis dans l'autre.
|
||||
*/
|
||||
(function () {
|
||||
document.querySelectorAll('.proj-table').forEach(function (table) {
|
||||
var tbody = table.querySelector('tbody');
|
||||
if (!tbody) return;
|
||||
table.querySelectorAll('th[data-sort]').forEach(function (th) {
|
||||
th.classList.add('sortable');
|
||||
th.addEventListener('click', function () {
|
||||
var key = th.getAttribute('data-sort');
|
||||
var dir = th.getAttribute('data-dir') === 'asc' ? 'desc' : 'asc';
|
||||
table.querySelectorAll('th').forEach(function (h) {
|
||||
h.removeAttribute('data-dir');
|
||||
});
|
||||
th.setAttribute('data-dir', dir);
|
||||
var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
|
||||
rows.sort(function (a, b) {
|
||||
var va = a.getAttribute('data-' + key) || '';
|
||||
var vb = b.getAttribute('data-' + key) || '';
|
||||
var na = parseFloat(va), nb = parseFloat(vb);
|
||||
var cmp;
|
||||
if (!isNaN(na) && !isNaN(nb) && va !== '' && vb !== '') {
|
||||
cmp = na - nb;
|
||||
} else {
|
||||
cmp = va < vb ? -1 : (va > vb ? 1 : 0);
|
||||
}
|
||||
return dir === 'asc' ? cmp : -cmp;
|
||||
});
|
||||
rows.forEach(function (r) { tbody.appendChild(r); });
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Palier 15 (0.19.0) — Performance + navigation Pilotage.
|
||||
|
||||
- A : les boucles par dépôt sont parallélisées (le comportement
|
||||
visible ne change pas — couvert par les paliers précédents) et
|
||||
le cache du client passe en stale-while-revalidate (testé dans
|
||||
test_gitea_client.py).
|
||||
- C : /forecast devient un tableau de bord des jalons (une ligne
|
||||
par jalon, ancres depuis le dashboard) et /health un tableau
|
||||
trié par sévérité, avec sélecteur de projet sur le détail.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import httpx
|
||||
|
||||
from app.gitea_client import GiteaClient
|
||||
|
||||
|
||||
def test_forecast_is_a_portfolio_table(client):
|
||||
r = client.get("/forecast")
|
||||
assert r.status_code == 200
|
||||
assert "pilot-table" in r.text
|
||||
assert "jalon(s) ouvert(s)" in r.text
|
||||
# One row for the demo milestone, anchored for dashboard links.
|
||||
assert 'id="ms-Projets-demo"' in r.text
|
||||
assert "Sprint 1" in r.text
|
||||
# The per-project pace table is still there.
|
||||
assert "Débit par projet" in r.text
|
||||
|
||||
|
||||
def test_dashboard_milestone_links_into_forecast(client):
|
||||
r = client.get("/")
|
||||
assert r.status_code == 200
|
||||
assert 'href="/forecast#ms-Projets-demo"' in r.text
|
||||
|
||||
|
||||
def test_health_index_is_a_severity_table(client):
|
||||
r = client.get("/health")
|
||||
assert r.status_code == 200
|
||||
assert "pilot-table" in r.text
|
||||
assert "data-severity" in r.text
|
||||
assert "Projets/demo" in r.text
|
||||
|
||||
|
||||
def test_health_detail_has_project_switcher(client):
|
||||
r = client.get("/health/Projets/demo")
|
||||
assert r.status_code == 200
|
||||
assert 'id="health-switch"' in r.text
|
||||
assert '<option value="Projets/demo" selected>' in r.text
|
||||
assert "tous les projets" in r.text
|
||||
|
||||
|
||||
async def test_swr_serves_stale_then_refreshes_in_background():
|
||||
"""Stale entries are served instantly; the refresh lands
|
||||
asynchronously and the next read sees the fresh value."""
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(request):
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"id": 7})
|
||||
calls["n"] += 1
|
||||
return httpx.Response(200, json={"data": [{"id": calls["n"]}]})
|
||||
|
||||
client = GiteaClient(
|
||||
base_url="http://gitea.test",
|
||||
token="",
|
||||
cache_ttl=60,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
first = await client.search_repos()
|
||||
assert first == [{"id": 1}]
|
||||
# Fresh cache: no new fetch.
|
||||
assert await client.search_repos() == [{"id": 1}]
|
||||
assert calls["n"] == 1
|
||||
# Age the entry beyond the TTL: the stale value comes back
|
||||
# immediately (same content, no wait)…
|
||||
ts, value = client._cache["search_repos"]
|
||||
client._cache["search_repos"] = (ts - 120, value)
|
||||
assert await client.search_repos() == [{"id": 1}]
|
||||
# …and the background refresh replaces it.
|
||||
await asyncio.sleep(0.3)
|
||||
assert await client.search_repos() == [{"id": 2}]
|
||||
await client.aclose()
|
||||
|
||||
|
||||
async def test_swr_mutation_clear_blocks_stale_resurrection():
|
||||
"""A refresh started before a cache clear (mutation) must not
|
||||
put its now-stale result back."""
|
||||
calls = {"n": 0}
|
||||
|
||||
def handler(request):
|
||||
if request.url.path.endswith("/user"):
|
||||
return httpx.Response(200, json={"id": 7})
|
||||
calls["n"] += 1
|
||||
return httpx.Response(200, json={"data": [{"id": calls["n"]}]})
|
||||
|
||||
client = GiteaClient(
|
||||
base_url="http://gitea.test",
|
||||
token="",
|
||||
cache_ttl=60,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
await client.search_repos()
|
||||
ts, value = client._cache["search_repos"]
|
||||
client._cache["search_repos"] = (ts - 120, value)
|
||||
await client.search_repos() # triggers background refresh
|
||||
client._cache_clear() # a mutation lands first
|
||||
await asyncio.sleep(0.3)
|
||||
# The cache was cleared and the in-flight refresh was disarmed:
|
||||
# the next read fetches fresh instead of serving the old value.
|
||||
assert await client.search_repos() == [{"id": 3}]
|
||||
await client.aclose()
|
||||
Reference in New Issue
Block a user