diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bbe012..4df56a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # Changelog +## 0.35.0 — 2026-10-26 + +### Ajouté — jalons en écriture depuis la page Projet (#26) + +Seconde et dernière carte du backlog de la feuille de +route « Gestion de projets » : les jalons n'étaient que lus +dans Kanbante, ils s'écrivent maintenant — la forge reste +la source de vérité, rien n'est stocké localement. + +- **Créer** un jalon (titre, description et échéance + optionnelles) depuis la page Projet, formulaire + « ➕ Nouveau jalon ». +- **Modifier** titre et échéance d'un jalon ouvert + (formulaire déplié par jalon ; échéance vidée = échéance + retirée, par un `null` explicite côté forge). +- **Fermer** un jalon, avec confirmation (ses issues + restent ouvertes). +- Écritures dans la forge via son API (`POST/PATCH + .../milestones`), CSRF vérifié, échéance invalide + refusée proprement **avant** toute écriture ; le board + filtré et le bandeau de progression reflètent le nouveau + jalon au chargement suivant, sans redémarrage. +- Échéance envoyée à **midi UTC** : Gitea renvoie les dates + dans le fuseau de son instance — à minuit UTC, l'échéance + affichée reculait d'un jour (attrapé au smoke réel). +- Hors périmètre (assumé par l'issue) : supprimer un jalon, + réassigner les issues entre jalons. + + ## 0.34.0 — 2026-10-26 ### Ajouté — vue « En vol partout » /inflight (issue #24) diff --git a/app/config.py b/app/config.py index 0626c88..d92ebd6 100644 --- a/app/config.py +++ b/app/config.py @@ -177,7 +177,7 @@ from urllib.parse import urlparse BASE_DIR = Path(__file__).resolve().parent.parent -APP_VERSION = "0.34.0" +APP_VERSION = "0.35.0" def _env_bool(name: str, default: bool) -> bool: diff --git a/app/gitea_client.py b/app/gitea_client.py index 295267d..28946a1 100644 --- a/app/gitea_client.py +++ b/app/gitea_client.py @@ -477,6 +477,59 @@ class GiteaClient: ), ) + async def create_milestone( + self, + owner: str, + repo: str, + title: str, + description: str = "", + due_on: str | None = None, + ) -> dict: + """Create a milestone on the forge (issue #26).""" + payload: dict = {"title": title, "description": description} + if due_on: + payload["due_on"] = due_on + r = await self.client.post( + f"/repos/{owner}/{repo}/milestones", json=payload + ) + r.raise_for_status() + self._cache_clear() + return r.json() + + async def update_milestone( + self, + owner: str, + repo: str, + milestone_id: int, + title: str | None = None, + description: str | None = None, + due_on: str | None = None, + clear_due: bool = False, + state: str | None = None, + ) -> dict: + """Patch a milestone (title, due date, state) — #26. + + due_on=None + clear_due=False leaves the date alone; + clear_due=True sends an explicit null (Gitea clears it). + """ + payload: dict = {} + if title is not None: + payload["title"] = title + if description is not None: + payload["description"] = description + if clear_due: + payload["due_on"] = None + elif due_on: + payload["due_on"] = due_on + if state is not None: + payload["state"] = state + r = await self.client.patch( + f"/repos/{owner}/{repo}/milestones/{milestone_id}", json=payload + ) + r.raise_for_status() + self._cache_clear() + return r.json() + 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).""" diff --git a/app/main.py b/app/main.py index 87b60d3..f3fbcfd 100644 --- a/app/main.py +++ b/app/main.py @@ -3147,7 +3147,10 @@ async def project_page(request: Request, owner: str, repo: str): est = forecast_service.estimate(opened, weekly) milestones.append( { + "id": m.get("id"), "title": m.get("title", ""), + "description": m.get("description") or "", + "due_iso": due_date.isoformat() if due_date else "", "total": total, "closed": closed, "open": opened, @@ -4373,6 +4376,136 @@ async def my_issues(request: Request): ) +def _milestone_due_iso(due: str) -> tuple[str | None, bool]: + """Validate a YYYY-MM-DD form date -> (ISO instant, ok).""" + due = (due or "").strip() + if not due: + return None, True + try: + parsed = datetime.strptime(due, "%Y-%m-%d") + except ValueError: + return None, False + # Midi UTC, pas minuit : Gitea renvoie l'échéance dans le + # fuseau de son instance (Toronto = UTC-5) — à minuit UTC, + # la date affichée reculait d'un jour (attrapé en réel). + return parsed.strftime("%Y-%m-%dT12:00:00Z"), True + + +@app.post("/api/repos/{owner}/{repo}/milestones") +async def api_milestone_create( + request: Request, + owner: str, + repo: str, + title: str = Form(""), + description: str = Form(""), + due_on: str = Form(""), + csrf_token: str = Form(""), +): + """Create a milestone on the forge (issue #26).""" + if not _csrf_valid(request, csrf_token): + return _csrf_error() + title = title.strip() + if not title or len(title) > 255: + return JSONResponse( + {"ok": False, "error": "Titre requis (255 caractères max)"}, + status_code=400, + ) + due_iso, ok = _milestone_due_iso(due_on) + if not ok: + return JSONResponse( + {"ok": False, "error": "Échéance invalide (format AAAA-MM-JJ)"}, + status_code=400, + ) + client, _cid, _ws = await _resolve_board_scope(request, owner, repo) + try: + milestone = await client.create_milestone( + owner, repo, title, description.strip(), due_iso + ) + except httpx.HTTPStatusError as exc: + return JSONResponse( + {"ok": False, "error": f"La forge répond {exc.response.status_code}"}, + status_code=502, + ) + return JSONResponse( + {"ok": True, "milestone": { + "id": milestone.get("id"), + "title": milestone.get("title"), + "due_on": milestone.get("due_on"), + }}, + status_code=201, + ) + + +@app.post("/api/repos/{owner}/{repo}/milestones/{milestone_id}/update") +async def api_milestone_update( + request: Request, + owner: str, + repo: str, + milestone_id: int, + title: str = Form(""), + due_on: str = Form(""), + csrf_token: str = Form(""), +): + """Edit a milestone's title / due date (issue #26). + + An empty due date CLEARS the milestone's deadline. + """ + if not _csrf_valid(request, csrf_token): + return _csrf_error() + title = title.strip() + if not title or len(title) > 255: + return JSONResponse( + {"ok": False, "error": "Titre requis (255 caractères max)"}, + status_code=400, + ) + due_iso, ok = _milestone_due_iso(due_on) + if not ok: + return JSONResponse( + {"ok": False, "error": "Échéance invalide (format AAAA-MM-JJ)"}, + status_code=400, + ) + client, _cid, _ws = await _resolve_board_scope(request, owner, repo) + try: + milestone = await client.update_milestone( + owner, repo, milestone_id, title=title, + due_on=due_iso, clear_due=due_iso is None, + ) + except httpx.HTTPStatusError as exc: + return JSONResponse( + {"ok": False, "error": f"La forge répond {exc.response.status_code}"}, + status_code=502, + ) + return {"ok": True, "milestone": { + "id": milestone.get("id"), + "title": milestone.get("title"), + "due_on": milestone.get("due_on"), + }} + + +@app.post("/api/repos/{owner}/{repo}/milestones/{milestone_id}/close") +async def api_milestone_close( + request: Request, + owner: str, + repo: str, + milestone_id: int, + csrf_token: str = Form(""), +): + """Close a milestone on the forge (issue #26).""" + if not _csrf_valid(request, csrf_token): + return _csrf_error() + client, _cid, _ws = await _resolve_board_scope(request, owner, repo) + try: + await client.update_milestone( + owner, repo, milestone_id, state="closed" + ) + except httpx.HTTPStatusError as exc: + return JSONResponse( + {"ok": False, "error": f"La forge répond {exc.response.status_code}"}, + status_code=502, + ) + return {"ok": True} + + # ── Briefing ─────────────────────────────────────────────── async def _briefing_data() -> dict: diff --git a/app/templates/project.html b/app/templates/project.html index 792c67b..807cabb 100644 --- a/app/templates/project.html +++ b/app/templates/project.html @@ -5,7 +5,9 @@
Aucun jalon ouvert. Les jalons se créent dans Gitea — le board et le récit de sprint s'appuient dessus.
+Aucun jalon ouvert pour le moment.
{% endif %} +