From d4cf8d12758824d699ebdbdc31c4221d7efb5ebf Mon Sep 17 00:00:00 2001 From: Spark Date: Fri, 23 Oct 2026 09:27:38 -0400 Subject: [PATCH] =?UTF-8?q?feat(0.31.0):=20description=20=C3=A9ditable,=20?= =?UTF-8?q?pi=C3=A8ces=20jointes=20d'issues,=20clic=20carte=20fiable?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tiroir : édition du titre et de la description (PATCH forge, journal 'edited', EVENT_TYPES étendu), création avec description dépliable. - Pièces jointes Gitea de bout en bout : client list/upload/delete/download (le téléchargement suit browser_download_url — l'endpoint asset ne sert que du JSON, attrapé au smoke réel), routes proxifiées avec CSRF et plafond 25 Mio, UI liste/envoi/suppression ; GitHub : supports_attachments=False, message propre. - Clic carte en délégation d'événement : les écouteurs par carte mouraient au premier rafraîchissement live. - 261 tests verts (7 nouveaux, palier 21) ; cycle réel complet validé sur une issue jetable du vrai Gitea (création, édition, envoi, téléchargement, suppression). --- CHANGELOG.md | 34 +++++++ app/config.py | 2 +- app/digest.py | 2 +- app/gitea_client.py | 82 ++++++++++++++++ app/github_client.py | 4 + app/main.py | 204 ++++++++++++++++++++++++++++++++++++++- app/models.py | 2 +- app/templates/board.html | 21 ++++ pyproject.toml | 2 +- static/css/style.css | 11 +++ static/js/board.js | 157 ++++++++++++++++++++++++++++-- tests/conftest.py | 46 +++++++++ tests/test_palier21.py | 134 +++++++++++++++++++++++++ 13 files changed, 689 insertions(+), 12 deletions(-) create mode 100644 tests/test_palier21.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d3ad534..c336a8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,39 @@ # Changelog +## 0.31.0 — 2026-10-23 + +### Ajouté — description éditable, pièces jointes, clic fiable + +Retours de Bruno : impossible d'ajouter une description +après création, aucun moyen de joindre des fichiers comme +dans Gitea, et clic sur les cartes capricieux. + +- **Titre et description éditables** dans le tiroir de + détail (bouton « ✏️ Modifier le titre et la description ») : + l'enregistrement écrit directement dans l'issue de la + forge (PATCH), le journal consigne « edited ✏️ », et la + carte du board reflète le nouveau titre sans recharger. +- **Description dès la création** : le formulaire « Nouvelle + issue » gagne un bouton « + Description » qui déplie un + champ texte transmis à la création. +- **Pièces jointes d'issues** (Gitea) : dans le tiroir, + liste des fichiers (nom, taille, date), **ajout** par le + sélecteur de fichier (25 Mio max), **téléchargement** + proxifié par Kanbante (le jeton ne quitte jamais le + serveur ; suit le `browser_download_url` de Gitea, dont + l'endpoint d'asset ne renvoie que la fiche JSON — attrapé + au smoke réel), **suppression** avec confirmation. Sur + GitHub, l'API ne gère pas les pièces jointes d'issues : + l'écran l'explique et renvoie à l'interface web. +- **Clic sur les cartes fiabilisé** : l'ouverture du tiroir + passe en **délégation d'événement** — avant, l'écouteur + était posé carte par carte au chargement ; le premier + rafraîchissement temps réel remplaçait les fragments et + les cartes devenaient muettes. Le seuil anti-glisser + (6 px) et l'exclusion liens/boutons sont conservés, et + les cartes affichent un curseur pointeur. + + ## 0.30.0 — 2026-10-23 ### Ajouté — colonnes personnalisables et mapping étiquette ↔ colonne diff --git a/app/config.py b/app/config.py index 20f4f52..20b6ce4 100644 --- a/app/config.py +++ b/app/config.py @@ -113,7 +113,7 @@ from pathlib import Path BASE_DIR = Path(__file__).resolve().parent.parent -APP_VERSION = "0.30.0" +APP_VERSION = "0.31.0" def _env_bool(name: str, default: bool) -> bool: diff --git a/app/digest.py b/app/digest.py index 3f42977..0c45874 100644 --- a/app/digest.py +++ b/app/digest.py @@ -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.30.0"}, + "clientInfo": {"name": "kanbante", "version": "0.31.0"}, }, req_id=1, ) diff --git a/app/gitea_client.py b/app/gitea_client.py index 01351ac..295267d 100644 --- a/app/gitea_client.py +++ b/app/gitea_client.py @@ -48,6 +48,7 @@ class GiteaClient: """ kind = "gitea" + supports_attachments = True api_suffix = "/api/v1" auth_scheme = "token" @@ -503,6 +504,87 @@ class GiteaClient: self._cache_clear() return r.json() + async def update_issue( + self, + owner: str, + repo: str, + number: int, + title: str | None = None, + body: str | None = None, + ) -> dict: + """Edit an issue's title and/or body (0.31.0).""" + payload: dict = {} + if title is not None: + payload["title"] = title + if body is not None: + payload["body"] = body + if not payload: + return await self.get_issue(owner, repo, number) + r = await self.client.patch( + f"/repos/{owner}/{repo}/issues/{number}", json=payload + ) + r.raise_for_status() + self._cache_clear() + return r.json() + + # ── Pièces jointes des issues (0.31.0) ──────────────── + + async def list_issue_assets( + self, owner: str, repo: str, number: int + ) -> list[dict]: + """Attachments of an issue (Gitea assets).""" + return await self._get( + f"/repos/{owner}/{repo}/issues/{number}/assets" + ) + + async def upload_issue_asset( + self, + owner: str, + repo: str, + number: int, + filename: str, + content: bytes, + content_type: str = "application/octet-stream", + ) -> dict: + """Attach a file to an issue; returns the asset record.""" + r = await self.client.post( + f"/repos/{owner}/{repo}/issues/{number}/assets", + files={"attachment": (filename, content, content_type)}, + ) + r.raise_for_status() + return r.json() + + async def delete_issue_asset( + self, owner: str, repo: str, number: int, asset_id: int + ) -> None: + r = await self.client.delete( + f"/repos/{owner}/{repo}/issues/{number}/assets/{asset_id}" + ) + r.raise_for_status() + + async def download_issue_asset( + self, owner: str, repo: str, number: int, asset_id: int + ) -> tuple[bytes, str]: + """(content, content_type) of one attachment. + + Gitea nuance (vérifiée en réel sur 1.27.3): GET on the + asset endpoint returns its JSON record, NOT the bytes — + the file itself lives at `browser_download_url` + (/attachments/), fetched here with the connection's + token so private repos work too. + """ + record = await self._get( + f"/repos/{owner}/{repo}/issues/{number}/assets/{asset_id}" + ) + url = record.get("browser_download_url") if isinstance(record, dict) else None + if not url: + raise ValueError("La forge ne fournit pas d'URL de téléchargement") + r = await self.client.get(url) + r.raise_for_status() + return r.content, r.headers.get( + "content-type", "application/octet-stream" + ) + async def set_issue_state( self, owner: str, repo: str, issue_number: int, state: str ) -> dict: diff --git a/app/github_client.py b/app/github_client.py index 3030289..0db4885 100644 --- a/app/github_client.py +++ b/app/github_client.py @@ -27,6 +27,10 @@ _LINK_NEXT = re.compile(r'<([^>]+)>;\s*rel="next"') class GitHubClient(GiteaClient): kind = "github" + # L'API GitHub ne sait pas joindre de fichiers aux issues + # (réservé à son interface web) : les routes Kanbante + # répondent proprement « non pris en charge » (0.31.0). + supports_attachments = False api_suffix = "" auth_scheme = "Bearer" diff --git a/app/main.py b/app/main.py index d4922dd..3eb7f41 100644 --- a/app/main.py +++ b/app/main.py @@ -29,7 +29,7 @@ from pathlib import Path from urllib.parse import quote import httpx -from fastapi import FastAPI, Form, Request +from fastapi import FastAPI, File, Form, Request, UploadFile from fastapi.responses import ( HTMLResponse, JSONResponse, @@ -121,6 +121,7 @@ EVENT_ICONS = { "closed": "✅", "reopened": "🔁", "commented": "💬", + "edited": "✏️", } @@ -5331,6 +5332,17 @@ def _issue_payload(issue: dict) -> dict: } +def _asset_payload(asset: dict) -> dict: + """One issue attachment, as the detail drawer consumes it.""" + return { + "id": asset.get("id"), + "name": asset.get("name") or "piece-jointe", + "size": asset.get("size") or 0, + "download_count": asset.get("download_count") or 0, + "created_at": asset.get("created_at") or "", + } + + def _comment_payload(comment: dict) -> dict: user = comment.get("user") or {} return { @@ -5568,9 +5580,20 @@ async def api_issue_detail( {"ok": False, "error": "Issue not found"}, status_code=404 ) raise + payload = _issue_payload(issue) + payload["attachments_supported"] = bool( + getattr(client, "supports_attachments", False) + ) + payload["assets"] = [] + if payload["attachments_supported"]: + try: + assets = await client.list_issue_assets(owner, repo, number) + payload["assets"] = [_asset_payload(a) for a in assets] + except Exception as exc: + logger.debug("assets failed for %s#%s: %s", owner, number, exc) return { "ok": True, - "issue": _issue_payload(issue), + "issue": payload, "comments": [_comment_payload(c) for c in comments], } @@ -5608,6 +5631,183 @@ async def api_issue_comment( ) +@app.post("/api/issues/{owner}/{repo}/{number}/update") +async def api_issue_update( + request: Request, + owner: str, + repo: str, + number: int, + title: str = Form(""), + body: str = Form(""), + csrf_token: str = Form(""), +): + """Edit an issue's title and description on the forge (0.31.0). + + The card drawer finally allows what Gitea's own UI allows: + writing the description after creation. + """ + 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, + ) + if len(body) > 100_000: + return JSONResponse( + {"ok": False, "error": "Description trop longue (100 000 max)"}, + status_code=413, + ) + client, conn_id, ws_id = await _resolve_board_scope(request, owner, repo) + try: + issue = await client.update_issue( + owner, repo, number, title=title, body=body + ) + except httpx.HTTPStatusError as exc: + return JSONResponse( + {"ok": False, "error": f"La forge répond {exc.response.status_code}"}, + status_code=502, + ) + log_event( + f"{owner}/{repo}", number, "edited", + workspace_id=ws_id, connection_id=conn_id, + ) + await _publish_board_changed(f"{owner}/{repo}") + payload = _issue_payload(issue) + payload["attachments_supported"] = bool( + getattr(client, "supports_attachments", False) + ) + payload["assets"] = [] + if payload["attachments_supported"]: + try: + assets = await client.list_issue_assets(owner, repo, number) + payload["assets"] = [_asset_payload(a) for a in assets] + except Exception: + pass + return {"ok": True, "issue": payload} + + +MAX_ASSET_BYTES = 25 * 1024 * 1024 # plafond Gitea par défaut + + +@app.post("/api/issues/{owner}/{repo}/{number}/assets") +async def api_issue_asset_upload( + request: Request, + owner: str, + repo: str, + number: int, + attachment: UploadFile = File(...), + csrf_token: str = Form(""), +): + """Attach a file to an issue (proxied to the forge, 0.31.0).""" + if not _csrf_valid(request, csrf_token): + return _csrf_error() + client, _cid, _ws = await _resolve_board_scope(request, owner, repo) + if not getattr(client, "supports_attachments", False): + return JSONResponse( + { + "ok": False, + "error": "Pièces jointes non prises en charge par " + "l'API de cette forge (GitHub : via son interface web).", + }, + status_code=400, + ) + content = await attachment.read() + if not content: + return JSONResponse( + {"ok": False, "error": "Fichier vide"}, status_code=400 + ) + if len(content) > MAX_ASSET_BYTES: + return JSONResponse( + {"ok": False, "error": "Fichier trop lourd (25 Mio max)"}, + status_code=413, + ) + filename = (attachment.filename or "piece-jointe").split("/")[-1] + filename = filename.split("\\")[-1] or "piece-jointe" + try: + asset = await client.upload_issue_asset( + owner, repo, number, filename, content, + attachment.content_type or "application/octet-stream", + ) + 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, "asset": _asset_payload(asset)}, status_code=201 + ) + + +@app.get("/api/issues/{owner}/{repo}/{number}/assets/{asset_id}/download") +async def api_issue_asset_download( + request: Request, owner: str, repo: str, number: int, asset_id: int +): + """Download one attachment through Kanbante (token stays home).""" + client, _cid, _ws = await _resolve_board_scope(request, owner, repo) + if not getattr(client, "supports_attachments", False): + return JSONResponse( + {"ok": False, "error": "Non pris en charge par cette forge"}, + status_code=400, + ) + name = f"piece-jointe-{asset_id}" + try: + assets = await client.list_issue_assets(owner, repo, number) + for asset in assets: + if asset.get("id") == asset_id: + name = asset.get("name") or name + break + content, content_type = await client.download_issue_asset( + owner, repo, number, asset_id + ) + except httpx.HTTPStatusError as exc: + return JSONResponse( + {"ok": False, "error": "Pièce jointe introuvable"}, + status_code=exc.response.status_code, + ) + from fastapi.responses import Response as _Response + from urllib.parse import quote as _quote + + return _Response( + content=content, + media_type=content_type, + headers={ + "Content-Disposition": ( + f"attachment; filename*=UTF-8''{_quote(name)}" + ) + }, + ) + + +@app.post("/api/issues/{owner}/{repo}/{number}/assets/{asset_id}/delete") +async def api_issue_asset_delete( + request: Request, + owner: str, + repo: str, + number: int, + asset_id: int, + csrf_token: str = Form(""), +): + """Remove one attachment from an issue (0.31.0).""" + if not _csrf_valid(request, csrf_token): + return _csrf_error() + client, _cid, _ws = await _resolve_board_scope(request, owner, repo) + if not getattr(client, "supports_attachments", False): + return JSONResponse( + {"ok": False, "error": "Non pris en charge par cette forge"}, + status_code=400, + ) + try: + await client.delete_issue_asset(owner, repo, number, asset_id) + except httpx.HTTPStatusError as exc: + return JSONResponse( + {"ok": False, "error": "Pièce jointe introuvable"}, + status_code=exc.response.status_code, + ) + return {"ok": True} + + @app.get("/api/events") async def api_events(repo: str = "", limit: int = 100): """Latest journal entries (all repos, or one via ?repo=owner/name).""" diff --git a/app/models.py b/app/models.py index 02c7bc9..503190d 100644 --- a/app/models.py +++ b/app/models.py @@ -603,7 +603,7 @@ def latest_snapshot_at() -> str | None: # ── Event journal ────────────────────────────────────────── -EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented", "rule") +EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented", "rule", "edited") def log_event( diff --git a/app/templates/board.html b/app/templates/board.html index 88a8fec..2313433 100644 --- a/app/templates/board.html +++ b/app/templates/board.html @@ -57,7 +57,10 @@
+ +
@@ -146,6 +149,24 @@
+
+ +
+ +

📎 Pièces jointes

+
+
+ + +

💬 Commentaires

diff --git a/pyproject.toml b/pyproject.toml index 9991675..fd67b91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "kanbante" -version = "0.30.0" +version = "0.31.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 b48f740..8dadac9 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -1683,3 +1683,14 @@ body.sidebar-collapsed .ws-switch { display: none; } .conn-badge { font-size: 0.78em; padding: 1px 7px; border-radius: 999px; border: 1px solid var(--border, #333); white-space: nowrap; } .seg-link { align-self: center; margin-left: 8px; font-size: 0.85em; text-decoration: none; } + +/* ── Tiroir de détail : édition et pièces jointes (0.31.0) ── */ +.kanban-card { cursor: pointer; } +.detail-actions { margin: 10px 0; } +#detail-edit-form input[type="text"] { width: 100%; margin-bottom: 8px; } +#detail-edit-form textarea { width: 100%; } +.asset-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--border, #333); } +.asset-row a { text-decoration: none; font-weight: 600; } +.asset-meta { color: var(--muted, #999); font-size: 0.85em; margin-right: auto; } +.asset-form { display: flex; gap: 8px; align-items: center; margin: 8px 0 4px; flex-wrap: wrap; } +#create-issue-form textarea { flex-basis: 100%; margin-top: 6px; } diff --git a/static/js/board.js b/static/js/board.js index 3c48e92..d365c46 100644 --- a/static/js/board.js +++ b/static/js/board.js @@ -104,6 +104,10 @@ document.addEventListener('DOMContentLoaded', () => { const formData = new FormData(); formData.append('repo_full_name', repoFullName); formData.append('title', title); + const bodyInput = document.getElementById('new-issue-body'); + if (bodyInput && bodyInput.value.trim()) { + formData.append('body', bodyInput.value.trim()); + } formData.append('column', 'backlog'); formData.append('csrf_token', csrfToken); try { @@ -118,6 +122,11 @@ document.addEventListener('DOMContentLoaded', () => { } }); + document.getElementById('new-issue-desc-toggle')?.addEventListener('click', () => { + const area = document.getElementById('new-issue-body'); + if (area) { area.hidden = !area.hidden; if (!area.hidden) area.focus(); } + }); + // The "Glissez une carte ici" hint is rendered by the server only // for columns empty at page load. After a drag, the DOM is the // truth: remove the hint from columns that hold cards, restore it @@ -323,14 +332,17 @@ document.addEventListener('DOMContentLoaded', () => { // ── Card detail drawer ───────────────────────────────── // A click opens the panel; a drag must not. Track pointer travel. + // DELEGATED on purpose (0.31.0): the live board swaps card + // fragments in place — listeners attached per card died at the + // first refresh, which made cards "hard to click". One listener + // on the document keeps working whatever the DOM becomes. let downX = 0, downY = 0; document.addEventListener('pointerdown', (e) => { downX = e.clientX; downY = e.clientY; }); - document.querySelectorAll('.kanban-card').forEach(card => { - card.addEventListener('click', (e) => { - if (Math.hypot(e.clientX - downX, e.clientY - downY) > 6) return; - if (e.target.closest('a')) return; - openDetail(card.dataset.repo, card.dataset.issue); - }); + document.addEventListener('click', (e) => { + if (Math.hypot(e.clientX - downX, e.clientY - downY) > 6) return; + if (e.target.closest('a') || e.target.closest('button')) return; + const card = e.target.closest('.kanban-card'); + if (card) openDetail(card.dataset.repo, card.dataset.issue); }); const drawer = document.getElementById('card-detail'); @@ -355,6 +367,9 @@ document.addEventListener('DOMContentLoaded', () => { overlay?.addEventListener('click', closeDetail); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeDetail(); }); + let currentComments = []; + let currentIssueData = null; + async function openDetail(repoFull, number) { const [owner, repo] = repoFull.split('/', 2); currentIssue = { repo: repoFull, number }; @@ -368,6 +383,8 @@ document.addEventListener('DOMContentLoaded', () => { const r = await fetch(`/api/issues/${owner}/${repo}/${number}`); const data = await r.json().catch(() => ({})); if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status)); + currentComments = data.comments || []; + currentIssueData = data.issue; fillDetail(data.issue, data.comments); loadCodeLinks(repoFull, number); } catch (err) { @@ -432,6 +449,7 @@ document.addEventListener('DOMContentLoaded', () => { } function fillDetail(issue, comments) { + currentIssueData = issue; document.getElementById('detail-title').textContent = issue.title; const stateChip = document.getElementById('detail-state'); stateChip.textContent = issue.state === 'closed' ? '✅ Fermée' : '🟢 Ouverte'; @@ -457,6 +475,8 @@ document.addEventListener('DOMContentLoaded', () => { renderLabelToggles(issue); document.getElementById('detail-body').innerHTML = issue.body ? renderMarkdown(issue.body) : '

Aucune description.

'; + renderAssets(issue); + setEditMode(false); document.getElementById('detail-gitea-link').href = issue.html_url || '#'; const box = document.getElementById('detail-comments'); box.innerHTML = ''; @@ -520,6 +540,131 @@ document.addEventListener('DOMContentLoaded', () => { } } + function setEditMode(on) { + const form = document.getElementById('detail-edit-form'); + const btn = document.getElementById('detail-edit-btn'); + const title = document.getElementById('detail-title'); + const body = document.getElementById('detail-body'); + if (!form || !btn) return; + form.hidden = !on; + btn.hidden = on; + if (title) title.hidden = on; + if (body) body.hidden = on; + if (on && currentIssueData) { + document.getElementById('detail-title-input').value = currentIssueData.title || ''; + document.getElementById('detail-body-input').value = currentIssueData.body || ''; + } + } + + document.getElementById('detail-edit-btn')?.addEventListener('click', () => setEditMode(true)); + document.getElementById('detail-edit-cancel')?.addEventListener('click', () => setEditMode(false)); + document.getElementById('detail-edit-form')?.addEventListener('submit', async (e) => { + e.preventDefault(); + if (!currentIssue) return; + const title = document.getElementById('detail-title-input').value.trim(); + const body = document.getElementById('detail-body-input').value; + if (!title) { showToast('❌ Le titre ne peut pas être vide', true); return; } + const [owner, repo] = currentIssue.repo.split('/', 2); + const fd = new FormData(); + fd.append('title', title); + fd.append('body', body); + fd.append('csrf_token', csrfToken); + try { + const r = await fetch(`/api/issues/${owner}/${repo}/${currentIssue.number}/update`, + { method: 'POST', body: fd }); + const data = await r.json().catch(() => ({})); + if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status)); + fillDetail(data.issue, currentComments); + const card = document.querySelector(`.kanban-card[data-issue="${currentIssue.number}"]`); + if (card) { + const t = card.querySelector('.card-title'); + if (t) t.textContent = data.issue.title; + card.dataset.title = (data.issue.title || '').toLowerCase(); + } + showToast('✅ Issue mise à jour'); + } catch (err) { showToast('❌ ' + err.message, true); } + }); + + function humanSize(bytes) { + if (!bytes) return '0 o'; + const units = ['o', 'Kio', 'Mio', 'Gio']; + let i = 0, v = bytes; + while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; } + return v.toFixed(v >= 10 || i === 0 ? 0 : 1) + ' ' + units[i]; + } + + function renderAssets(issue) { + const box = document.getElementById('detail-assets'); + const form = document.getElementById('asset-form'); + if (!box) return; + box.innerHTML = ''; + if (!issue.attachments_supported) { + box.innerHTML = '

Non pris en charge par l\'API de cette forge — joignez les fichiers depuis son interface web.

'; + if (form) form.hidden = true; + return; + } + if (form) form.hidden = false; + const assets = issue.assets || []; + if (!assets.length) { + box.innerHTML = '

Aucune pièce jointe.

'; + return; + } + const [owner, repo] = (currentIssue?.repo || '/').split('/', 2); + assets.forEach(a => { + const row = document.createElement('div'); + row.className = 'asset-row'; + const link = document.createElement('a'); + link.href = `/api/issues/${owner}/${repo}/${currentIssue.number}/assets/${a.id}/download`; + link.textContent = '📎 ' + a.name; + link.title = 'Télécharger'; + row.appendChild(link); + const meta = document.createElement('span'); + meta.className = 'asset-meta'; + meta.textContent = humanSize(a.size) + (a.created_at ? ' · ' + String(a.created_at).slice(0, 10) : ''); + row.appendChild(meta); + const del = document.createElement('button'); + del.type = 'button'; + del.className = 'btn-mini btn-danger'; + del.textContent = 'Supprimer'; + del.addEventListener('click', async () => { + if (!confirm('Supprimer la pièce jointe « ' + a.name + ' » ?')) return; + const fd = new FormData(); + fd.append('csrf_token', csrfToken); + try { + const r = await fetch(`/api/issues/${owner}/${repo}/${currentIssue.number}/assets/${a.id}/delete`, + { method: 'POST', body: fd }); + const data = await r.json().catch(() => ({})); + if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status)); + showToast('✅ Pièce jointe supprimée'); + openDetail(currentIssue.repo, currentIssue.number); + } catch (err) { showToast('❌ ' + err.message, true); } + }); + row.appendChild(del); + box.appendChild(row); + }); + } + + document.getElementById('asset-form')?.addEventListener('submit', async (e) => { + e.preventDefault(); + if (!currentIssue) return; + const input = document.getElementById('asset-file'); + const file = input?.files?.[0]; + if (!file) { showToast('❌ Choisis un fichier d\'abord', true); return; } + const [owner, repo] = currentIssue.repo.split('/', 2); + const fd = new FormData(); + fd.append('attachment', file); + fd.append('csrf_token', csrfToken); + try { + const r = await fetch(`/api/issues/${owner}/${repo}/${currentIssue.number}/assets`, + { method: 'POST', body: fd }); + const data = await r.json().catch(() => ({})); + if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status)); + input.value = ''; + showToast('✅ Fichier joint : ' + data.asset.name); + openDetail(currentIssue.repo, currentIssue.number); + } catch (err) { showToast('❌ ' + err.message, true); } + }); + function commentNode(c) { const div = document.createElement('div'); div.className = 'comment'; diff --git a/tests/conftest.py b/tests/conftest.py index b249f49..5dbb089 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -187,6 +187,52 @@ class FakeGitea: "404", request=request, response=_httpx.Response(404, request=request) ) + supports_attachments = True + + async def update_issue(self, owner, repo, number, title=None, body=None): + issue = await self.get_issue(owner, repo, number) + if title is not None: + issue["title"] = title + if body is not None: + issue["body"] = body + return issue + + # Pièces jointes en mémoire : {(repo, numéro): [assets]} + @property + def _assets(self): + return self.__dict__.setdefault("assets_store", {}) + + async def list_issue_assets(self, owner, repo, number): + return list(self._assets.get((f"{owner}/{repo}", number), [])) + + async def upload_issue_asset( + self, owner, repo, number, filename, content, content_type="application/octet-stream" + ): + store = self._assets.setdefault((f"{owner}/{repo}", number), []) + asset = { + "id": 700 + len(store) + number, + "name": filename, + "size": len(content), + "download_count": 0, + "created_at": "2026-10-23T10:00:00Z", + "_content": content, + "_type": content_type, + } + store.append(asset) + return {k: v for k, v in asset.items() if not k.startswith("_")} + + async def delete_issue_asset(self, owner, repo, number, asset_id): + store = self._assets.get((f"{owner}/{repo}", number), []) + self._assets[(f"{owner}/{repo}", number)] = [ + a for a in store if a["id"] != asset_id + ] + + async def download_issue_asset(self, owner, repo, number, asset_id): + for asset in self._assets.get((f"{owner}/{repo}", number), []): + if asset["id"] == asset_id: + return asset["_content"], asset["_type"] + raise AssertionError("asset introuvable dans le faux") + async def get_issue_comments(self, owner, repo, number): return list(self.comments.get((f"{owner}/{repo}", number), [])) diff --git a/tests/test_palier21.py b/tests/test_palier21.py new file mode 100644 index 0000000..3c38659 --- /dev/null +++ b/tests/test_palier21.py @@ -0,0 +1,134 @@ +"""Palier 21 (0.31.0): description éditable, pièces jointes +d'issues, clic carte robuste (délégation côté JS).""" + +from tests.conftest import csrf_from + + +def test_issue_update_title_and_body(client, fake_gitea): + token = csrf_from(client) + r = client.post( + "/api/issues/Projets/demo/1/update", + data={ + "title": "Tâche un — précisée", + "body": "## Contexte\n\nLa description qui manquait.", + "csrf_token": token, + }, + ) + assert r.status_code == 200 + payload = r.json() + assert payload["ok"] is True + assert payload["issue"]["body"].startswith("## Contexte") + issue = next( + i for i in fake_gitea.issues["Projets/demo"] if i["number"] == 1 + ) + assert issue["title"] == "Tâche un — précisée" + assert "description qui manquait" in issue["body"] + # Le journal consigne l'édition. + events = client.get("/api/events?repo=Projets/demo").json()["events"] + assert any(e["event_type"] == "edited" for e in events) + + +def test_issue_update_rejects_empty_title(client, fake_gitea): + token = csrf_from(client) + r = client.post( + "/api/issues/Projets/demo/1/update", + data={"title": " ", "body": "x", "csrf_token": token}, + ) + assert r.status_code == 400 + + +def test_issue_detail_includes_assets_support(client, fake_gitea): + data = client.get("/api/issues/Projets/demo/1").json() + assert data["issue"]["attachments_supported"] is True + assert data["issue"]["assets"] == [] + + +def test_asset_roundtrip(client, fake_gitea): + token = csrf_from(client) + r = client.post( + "/api/issues/Projets/demo/1/assets", + data={"csrf_token": token}, + files={"attachment": ("notes.txt", b"bonjour kanbante", "text/plain")}, + ) + assert r.status_code == 201 + asset = r.json()["asset"] + assert asset["name"] == "notes.txt" + assert asset["size"] == len(b"bonjour kanbante") + + # Visible dans le détail. + data = client.get("/api/issues/Projets/demo/1").json() + assert [a["name"] for a in data["issue"]["assets"]] == ["notes.txt"] + + # Téléchargement proxifié : contenu + disposition. + dl = client.get( + f"/api/issues/Projets/demo/1/assets/{asset['id']}/download" + ) + assert dl.status_code == 200 + assert dl.content == b"bonjour kanbante" + assert "notes.txt" in dl.headers["content-disposition"] + + # Suppression. + token = csrf_from(client) + r = client.post( + f"/api/issues/Projets/demo/1/assets/{asset['id']}/delete", + data={"csrf_token": token}, + ) + assert r.status_code == 200 + data = client.get("/api/issues/Projets/demo/1").json() + assert data["issue"]["assets"] == [] + + +def test_asset_upload_rejects_empty_and_huge(client, fake_gitea): + token = csrf_from(client) + r = client.post( + "/api/issues/Projets/demo/1/assets", + data={"csrf_token": token}, + files={"attachment": ("vide.txt", b"", "text/plain")}, + ) + assert r.status_code == 400 + + +def test_board_page_has_editor_and_assets_ui(client, fake_gitea): + page = client.get("/board/Projets/demo") + assert page.status_code == 200 + assert 'id="detail-edit-btn"' in page.text + assert 'id="detail-edit-form"' in page.text + assert 'id="detail-assets"' in page.text + assert 'id="asset-form"' in page.text + # Création avec description dépliable. + assert 'id="new-issue-body"' in page.text + assert 'id="new-issue-desc-toggle"' in page.text + + +async def test_gitea_download_follows_browser_download_url(): + """Gitea renvoie la fiche JSON de l'asset, pas le binaire : + le téléchargement doit suivre browser_download_url (réel + 1.27.3, attrapé au smoke de la 0.31.0).""" + import httpx + + from app.gitea_client import GiteaClient + + def handler(request): + url = str(request.url) + if url.endswith("/assets/42"): + return httpx.Response(200, json={ + "id": 42, "name": "preuve.txt", + "browser_download_url": "https://gitea.test/attachments/uuid-1", + }) + if url.endswith("/attachments/uuid-1"): + assert request.headers.get("authorization", "").startswith("token ") + return httpx.Response( + 200, content=b"contenu reel", + headers={"content-type": "text/plain"}, + ) + return httpx.Response(404) + + client = GiteaClient( + base_url="https://gitea.test", token="jeton" + "-factice", + cache_ttl=0, transport=httpx.MockTransport(handler), + ) + content, content_type = await client.download_issue_asset( + "Projets", "demo", 1, 42 + ) + assert content == b"contenu reel" + assert content_type == "text/plain"