feat(0.35.0): jalons en écriture depuis la page Projet (issue #26)
CI / test (push) Successful in 2m22s

- Clients : create_milestone / update_milestone (cache
  invalidé) ; GitHub hérite des mêmes endpoints.
- API Kanbante : création, modification (titre/échéance,
  échéance vidée = retirée), fermeture ; CSRF, validation
  de date avant toute écriture, erreurs forge en 502.
- Page Projet : formulaire 'Nouveau jalon' + édition/
  fermeture par jalon (project.js versionné).
- Échéance à midi UTC : à minuit, le fuseau de l'instance
  Gitea faisait reculer la date affichée d'un jour (réel).
- 291 tests verts (5 nouveaux, palier 24) ; cycle réel
  complet sur Projets/kanbante (jalons smoke #37/#38 créés,
  dates vérifiées, fermés).
This commit is contained in:
Spark
2026-10-26 10:53:29 -04:00
parent 8c889878ee
commit a92353f8ef
10 changed files with 421 additions and 10 deletions
+1 -1
View File
@@ -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:
+53
View File
@@ -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)."""
+133
View File
@@ -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:
+33 -2
View File
@@ -5,7 +5,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ full_name }} — Projet · Kanbante</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📋</text></svg>">
<link rel="stylesheet" href="/static/css/style.css">
<meta name="csrf-token" content="{{ csrf_token }}">
<meta name="repo-full-name" content="{{ full_name }}">
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
</head>
<body>
{% include "_header.html" %}
@@ -51,11 +53,39 @@
{% else %}
<div class="ms-meta">Aucune issue dans ce jalon pour le moment.</div>
{% endif %}
{% if ms.id %}
<details class="ms-edit">
<summary>✏️ Modifier / fermer</summary>
<form class="ms-form" data-ms-form data-action="/api/repos/{{ full_name }}/milestones/{{ ms.id }}/update">
<label>Titre <input type="text" name="title" value="{{ ms.title }}" maxlength="255" required></label>
<label>Échéance <input type="date" name="due_on" value="{{ ms.due_iso }}">
<span class="ms-hint">vide = retirer l'échéance</span></label>
<div class="ms-form-actions">
<button type="submit" class="btn-save btn-inline">💾 Enregistrer</button>
</div>
</form>
<form class="ms-form" data-ms-form data-action="/api/repos/{{ full_name }}/milestones/{{ ms.id }}/close"
data-confirm="Fermer le jalon « {{ ms.title }} » ? Ses issues restent ouvertes, sans jalon actif.">
<button type="submit" class="btn-mini btn-danger">Fermer le jalon</button>
</form>
</details>
{% endif %}
</div>
{% endfor %}
{% else %}
<p class="proj-empty">Aucun jalon ouvert. Les jalons se créent dans Gitea — le board et le récit de sprint s'appuient dessus.</p>
<p class="proj-empty">Aucun jalon ouvert pour le moment.</p>
{% endif %}
<details class="ms-edit ms-new">
<summary>➕ Nouveau jalon</summary>
<form class="ms-form" data-ms-form data-action="/api/repos/{{ full_name }}/milestones">
<label>Titre <input type="text" name="title" maxlength="255" required placeholder="Ex. : Version 2.0"></label>
<label>Échéance <input type="date" name="due_on"></label>
<label>Description <textarea name="description" rows="2" maxlength="2000" placeholder="Optionnelle"></textarea></label>
<div class="ms-form-actions">
<button type="submit" class="btn-save btn-inline">🎯 Créer le jalon</button>
</div>
</form>
</details>
</section>
<section class="proj-panel">
@@ -124,6 +154,7 @@
<footer>Kanbante v{{ version }} · Page projet — jalons, santé, prévisions, activité et notes en un coup d'œil.</footer>
{% include "_palette.html" %}
<script src="/static/js/project.js?v={{ version }}" defer></script>
<script src="/static/js/palette.js" defer></script>
</body>
</html>