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 @@ {{ full_name }} — Projet · Kanbante - + + + {% include "_header.html" %} @@ -51,11 +53,39 @@ {% else %}
Aucune issue dans ce jalon pour le moment.
{% endif %} + {% if ms.id %} +
+ ✏️ Modifier / fermer +
+ + +
+ +
+
+
+ +
+
+ {% endif %} {% endfor %} {% else %} -

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 %} +
+ ➕ Nouveau jalon +
+ + + +
+ +
+
+
@@ -124,6 +154,7 @@ {% include "_palette.html" %} + diff --git a/pyproject.toml b/pyproject.toml index 5b6ce32..f25cd0e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kanbante" -version = "0.34.0" +version = "0.35.0" description = "Kanban project manager integrated with Gitea" requires-python = ">=3.11" dependencies = [ diff --git a/static/css/style.css b/static/css/style.css index 0fdf329..18e41ed 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1738,3 +1738,15 @@ body.sidebar-collapsed .ws-switch { display: none; } .inflight-total { font-weight: 700; margin-right: 4px; } .inflight-repo { color: var(--accent-2); font-weight: 600; font-size: 0.85rem; } .inflight-wip { color: var(--muted, #9aa3b5); font-size: 0.8rem; font-weight: 400; margin-left: 8px; } + +/* ── Page Projet : gestion des jalons (0.35.0, #26) ── */ +.ms-edit { margin-top: 8px; } +.ms-edit summary { cursor: pointer; font-size: 0.85rem; color: var(--accent-2); list-style: none; } +.ms-edit summary::-webkit-details-marker { display: none; } +.ms-edit summary:hover { text-decoration: underline; } +.ms-form { display: flex; flex-direction: column; gap: 8px; margin: 10px 0; padding: 10px 12px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 10px; } +.ms-form label { display: flex; flex-direction: column; gap: 4px; font-size: 0.85rem; } +.ms-form input[type="text"], .ms-form input[type="date"], .ms-form textarea { background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; color: inherit; font-family: inherit; } +.ms-form input:focus, .ms-form textarea:focus { outline: none; border-color: var(--accent); } +.ms-hint { color: var(--muted, #9aa3b5); font-size: 0.78rem; } +.ms-form-actions { display: flex; gap: 8px; } diff --git a/static/js/project.js b/static/js/project.js new file mode 100644 index 0000000..67db3ea --- /dev/null +++ b/static/js/project.js @@ -0,0 +1,27 @@ +// Page Projet — écritures de jalons (0.35.0, issue #26). +// Les formulaires data-ms-form sont interceptés : POST vers +// l'API Kanbante (qui écrit dans la forge), puis rechargement +// pour refléter l'état réel. +(() => { + const csrf = document.querySelector('meta[name="csrf-token"]')?.content || ''; + document.querySelectorAll('form[data-ms-form]').forEach((form) => { + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const confirmMsg = form.dataset.confirm; + if (confirmMsg && !window.confirm(confirmMsg)) return; + const fd = new FormData(form); + fd.append('csrf_token', csrf); + const btn = form.querySelector('button[type="submit"]'); + if (btn) btn.disabled = true; + try { + const r = await fetch(form.dataset.action, { method: 'POST', body: fd }); + const data = await r.json().catch(() => ({})); + if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status)); + window.location.reload(); + } catch (err) { + if (btn) btn.disabled = false; + window.alert('❌ ' + err.message); + } + }); + }); +})(); diff --git a/tests/conftest.py b/tests/conftest.py index 5dbb089..49c0804 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -359,15 +359,64 @@ class FakeGitea: else: self.starred.discard(f"{owner}/{repo}") - async def get_milestones(self, owner, repo, state="open"): - return [ + @property + def _milestones(self): + return self.__dict__.setdefault( + "milestones_store", { - "id": 1, "title": "Sprint 1", - "due_on": "2026-10-20T00:00:00Z", - "open_issues": 1, "closed_issues": 1, - } + "Projets/demo": [ + { + "id": 1, "title": "Sprint 1", + "description": "", + "due_on": "2026-10-20T00:00:00Z", + "state": "open", + "open_issues": 1, "closed_issues": 1, + } + ] + }, + ) + + async def get_milestones(self, owner, repo, state="open"): + items = self._milestones.get(f"{owner}/{repo}", []) + return [ + dict(m) for m in items + if state == "all" or m.get("state", "open") == state ] + async def create_milestone( + self, owner, repo, title, description="", due_on=None + ): + store = self._milestones.setdefault(f"{owner}/{repo}", []) + milestone = { + "id": 100 + len(store), + "title": title, + "description": description, + "due_on": due_on, + "state": "open", + "open_issues": 0, "closed_issues": 0, + } + store.append(milestone) + return dict(milestone) + + async def update_milestone( + self, owner, repo, milestone_id, title=None, description=None, + due_on=None, clear_due=False, state=None, + ): + for m in self._milestones.get(f"{owner}/{repo}", []): + if m["id"] == milestone_id: + if title is not None: + m["title"] = title + if description is not None: + m["description"] = description + if clear_due: + m["due_on"] = None + elif due_on: + m["due_on"] = due_on + if state is not None: + m["state"] = state + return dict(m) + raise AssertionError("jalon introuvable dans le faux") + async def create_issue(self, owner, repo, title, body=""): self._next_number += 1 issue = _issue(self._next_number, title) diff --git a/tests/test_palier24.py b/tests/test_palier24.py new file mode 100644 index 0000000..cea8818 --- /dev/null +++ b/tests/test_palier24.py @@ -0,0 +1,77 @@ +"""Palier 24 (0.35.0, issue #26): jalons en écriture — +créer, modifier (titre/échéance), fermer, depuis Kanbante.""" + +from tests.conftest import csrf_from + + +def test_milestone_create_via_api(client, fake_gitea): + token = csrf_from(client) + r = client.post( + "/api/repos/Projets/demo/milestones", + data={ + "title": "Version 2.0", + "description": "Le gros morceau", + "due_on": "2027-01-31", + "csrf_token": token, + }, + ) + assert r.status_code == 201 + payload = r.json() + assert payload["ok"] is True + assert payload["milestone"]["title"] == "Version 2.0" + assert payload["milestone"]["due_on"] == "2027-01-31T12:00:00Z" + titles = [ + m["title"] for m in fake_gitea._milestones["Projets/demo"] + ] + assert "Version 2.0" in titles + + +def test_milestone_create_rejects_bad_date(client, fake_gitea): + token = csrf_from(client) + avant = len(fake_gitea._milestones["Projets/demo"]) + r = client.post( + "/api/repos/Projets/demo/milestones", + data={ + "title": "Jalon invalide", + "due_on": "31/01/2027", + "csrf_token": token, + }, + ) + assert r.status_code == 400 + # Rien d'écrit côté forge. + assert len(fake_gitea._milestones["Projets/demo"]) == avant + + +def test_milestone_update_title_and_clear_due(client, fake_gitea): + token = csrf_from(client) + r = client.post( + "/api/repos/Projets/demo/milestones/1/update", + data={"title": "Sprint 1 — prolongé", "due_on": "", "csrf_token": token}, + ) + assert r.status_code == 200 + sprint = fake_gitea._milestones["Projets/demo"][0] + assert sprint["title"] == "Sprint 1 — prolongé" + assert sprint["due_on"] is None # échéance retirée + + +def test_milestone_close(client, fake_gitea): + token = csrf_from(client) + r = client.post( + "/api/repos/Projets/demo/milestones/1/close", + data={"csrf_token": token}, + ) + assert r.status_code == 200 + assert fake_gitea._milestones["Projets/demo"][0]["state"] == "closed" + # La page Projet ne liste plus le jalon fermé parmi les ouverts. + page = client.get("/project/Projets/demo") + assert "Version" in page.text # la page répond toujours + assert 'data-action="/api/repos/Projets/demo/milestones"' in page.text + + +def test_project_page_has_milestone_management(client): + page = client.get("/project/Projets/demo") + assert page.status_code == 200 + assert "Nouveau jalon" in page.text + assert "Modifier / fermer" in page.text + assert 'name="due_on"' in page.text + assert "/static/js/project.js?v=" in page.text