feat(0.31.0): description éditable, pièces jointes d'issues, clic carte fiable
CI / test (push) Successful in 2m10s

- 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).
This commit is contained in:
Spark
2026-10-23 09:27:38 -04:00
parent 28322c12cf
commit d4cf8d1275
13 changed files with 689 additions and 12 deletions
+34
View File
@@ -1,5 +1,39 @@
# Changelog # 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 ## 0.30.0 — 2026-10-23
### Ajouté — colonnes personnalisables et mapping étiquette ↔ colonne ### Ajouté — colonnes personnalisables et mapping étiquette ↔ colonne
+1 -1
View File
@@ -113,7 +113,7 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent 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: def _env_bool(name: str, default: bool) -> bool:
+1 -1
View File
@@ -143,7 +143,7 @@ async def _mcp_session(client: httpx.AsyncClient, url: str, token: str) -> None:
{ {
"protocolVersion": MCP_PROTOCOL_VERSION, "protocolVersion": MCP_PROTOCOL_VERSION,
"capabilities": {}, "capabilities": {},
"clientInfo": {"name": "kanbante", "version": "0.30.0"}, "clientInfo": {"name": "kanbante", "version": "0.31.0"},
}, },
req_id=1, req_id=1,
) )
+82
View File
@@ -48,6 +48,7 @@ class GiteaClient:
""" """
kind = "gitea" kind = "gitea"
supports_attachments = True
api_suffix = "/api/v1" api_suffix = "/api/v1"
auth_scheme = "token" auth_scheme = "token"
@@ -503,6 +504,87 @@ class GiteaClient:
self._cache_clear() self._cache_clear()
return r.json() 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/<uuid>), 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( async def set_issue_state(
self, owner: str, repo: str, issue_number: int, state: str self, owner: str, repo: str, issue_number: int, state: str
) -> dict: ) -> dict:
+4
View File
@@ -27,6 +27,10 @@ _LINK_NEXT = re.compile(r'<([^>]+)>;\s*rel="next"')
class GitHubClient(GiteaClient): class GitHubClient(GiteaClient):
kind = "github" 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 = "" api_suffix = ""
auth_scheme = "Bearer" auth_scheme = "Bearer"
+202 -2
View File
@@ -29,7 +29,7 @@ from pathlib import Path
from urllib.parse import quote from urllib.parse import quote
import httpx import httpx
from fastapi import FastAPI, Form, Request from fastapi import FastAPI, File, Form, Request, UploadFile
from fastapi.responses import ( from fastapi.responses import (
HTMLResponse, HTMLResponse,
JSONResponse, JSONResponse,
@@ -121,6 +121,7 @@ EVENT_ICONS = {
"closed": "✅", "closed": "✅",
"reopened": "🔁", "reopened": "🔁",
"commented": "💬", "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: def _comment_payload(comment: dict) -> dict:
user = comment.get("user") or {} user = comment.get("user") or {}
return { return {
@@ -5568,9 +5580,20 @@ async def api_issue_detail(
{"ok": False, "error": "Issue not found"}, status_code=404 {"ok": False, "error": "Issue not found"}, status_code=404
) )
raise 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 { return {
"ok": True, "ok": True,
"issue": _issue_payload(issue), "issue": payload,
"comments": [_comment_payload(c) for c in comments], "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") @app.get("/api/events")
async def api_events(repo: str = "", limit: int = 100): async def api_events(repo: str = "", limit: int = 100):
"""Latest journal entries (all repos, or one via ?repo=owner/name).""" """Latest journal entries (all repos, or one via ?repo=owner/name)."""
+1 -1
View File
@@ -603,7 +603,7 @@ def latest_snapshot_at() -> str | None:
# ── Event journal ────────────────────────────────────────── # ── Event journal ──────────────────────────────────────────
EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented", "rule") EVENT_TYPES = ("created", "moved", "closed", "reopened", "commented", "rule", "edited")
def log_event( def log_event(
+21
View File
@@ -57,7 +57,10 @@
<form id="create-issue-form"> <form id="create-issue-form">
<input type="text" id="new-issue-title" maxlength="255" <input type="text" id="new-issue-title" maxlength="255"
placeholder="➕ Nouvelle issue (titre)..." required> placeholder="➕ Nouvelle issue (titre)..." required>
<button type="button" id="new-issue-desc-toggle" class="btn-sm" title="Ajouter une description">+ Description</button>
<button type="submit" class="btn-save btn-inline">Créer</button> <button type="submit" class="btn-save btn-inline">Créer</button>
<textarea id="new-issue-body" rows="4" maxlength="100000" hidden
placeholder="Description (markdown)..."></textarea>
</form> </form>
</div> </div>
@@ -146,6 +149,24 @@
</div> </div>
<div id="detail-code" class="detail-code"></div> <div id="detail-code" class="detail-code"></div>
<div id="detail-body" class="detail-body"></div> <div id="detail-body" class="detail-body"></div>
<div class="detail-actions">
<button id="detail-edit-btn" class="btn-sm" type="button">✏️ Modifier le titre et la description</button>
</div>
<form id="detail-edit-form" hidden>
<input type="text" id="detail-title-input" maxlength="255" placeholder="Titre">
<textarea id="detail-body-input" rows="10" maxlength="100000"
placeholder="Description (markdown)..."></textarea>
<div class="detail-actions">
<button type="submit" class="btn-save btn-inline">💾 Enregistrer</button>
<button type="button" id="detail-edit-cancel" class="btn-sm">Annuler</button>
</div>
</form>
<h3 class="detail-comments-title">📎 Pièces jointes</h3>
<div id="detail-assets"></div>
<form id="asset-form" class="asset-form">
<input type="file" id="asset-file">
<button type="submit" class="btn-sm">📎 Joindre le fichier</button>
</form>
<h3 class="detail-comments-title">💬 Commentaires</h3> <h3 class="detail-comments-title">💬 Commentaires</h3>
<div id="detail-comments"></div> <div id="detail-comments"></div>
<form id="comment-form"> <form id="comment-form">
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "kanbante" name = "kanbante"
version = "0.30.0" version = "0.31.0"
description = "Kanban project manager integrated with Gitea" description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+11
View File
@@ -1683,3 +1683,14 @@ body.sidebar-collapsed .ws-switch { display: none; }
.conn-badge { font-size: 0.78em; padding: 1px 7px; border-radius: 999px; .conn-badge { font-size: 0.78em; padding: 1px 7px; border-radius: 999px;
border: 1px solid var(--border, #333); white-space: nowrap; } border: 1px solid var(--border, #333); white-space: nowrap; }
.seg-link { align-self: center; margin-left: 8px; font-size: 0.85em; text-decoration: none; } .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; }
+151 -6
View File
@@ -104,6 +104,10 @@ document.addEventListener('DOMContentLoaded', () => {
const formData = new FormData(); const formData = new FormData();
formData.append('repo_full_name', repoFullName); formData.append('repo_full_name', repoFullName);
formData.append('title', title); 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('column', 'backlog');
formData.append('csrf_token', csrfToken); formData.append('csrf_token', csrfToken);
try { 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 // 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 // for columns empty at page load. After a drag, the DOM is the
// truth: remove the hint from columns that hold cards, restore it // truth: remove the hint from columns that hold cards, restore it
@@ -323,14 +332,17 @@ document.addEventListener('DOMContentLoaded', () => {
// ── Card detail drawer ───────────────────────────────── // ── Card detail drawer ─────────────────────────────────
// A click opens the panel; a drag must not. Track pointer travel. // 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; let downX = 0, downY = 0;
document.addEventListener('pointerdown', (e) => { downX = e.clientX; downY = e.clientY; }); document.addEventListener('pointerdown', (e) => { downX = e.clientX; downY = e.clientY; });
document.querySelectorAll('.kanban-card').forEach(card => { document.addEventListener('click', (e) => {
card.addEventListener('click', (e) => { if (Math.hypot(e.clientX - downX, e.clientY - downY) > 6) return;
if (Math.hypot(e.clientX - downX, e.clientY - downY) > 6) return; if (e.target.closest('a') || e.target.closest('button')) return;
if (e.target.closest('a')) return; const card = e.target.closest('.kanban-card');
openDetail(card.dataset.repo, card.dataset.issue); if (card) openDetail(card.dataset.repo, card.dataset.issue);
});
}); });
const drawer = document.getElementById('card-detail'); const drawer = document.getElementById('card-detail');
@@ -355,6 +367,9 @@ document.addEventListener('DOMContentLoaded', () => {
overlay?.addEventListener('click', closeDetail); overlay?.addEventListener('click', closeDetail);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeDetail(); }); document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeDetail(); });
let currentComments = [];
let currentIssueData = null;
async function openDetail(repoFull, number) { async function openDetail(repoFull, number) {
const [owner, repo] = repoFull.split('/', 2); const [owner, repo] = repoFull.split('/', 2);
currentIssue = { repo: repoFull, number }; currentIssue = { repo: repoFull, number };
@@ -368,6 +383,8 @@ document.addEventListener('DOMContentLoaded', () => {
const r = await fetch(`/api/issues/${owner}/${repo}/${number}`); const r = await fetch(`/api/issues/${owner}/${repo}/${number}`);
const data = await r.json().catch(() => ({})); const data = await r.json().catch(() => ({}));
if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status)); if (!r.ok || !data.ok) throw new Error(data.error || ('HTTP ' + r.status));
currentComments = data.comments || [];
currentIssueData = data.issue;
fillDetail(data.issue, data.comments); fillDetail(data.issue, data.comments);
loadCodeLinks(repoFull, number); loadCodeLinks(repoFull, number);
} catch (err) { } catch (err) {
@@ -432,6 +449,7 @@ document.addEventListener('DOMContentLoaded', () => {
} }
function fillDetail(issue, comments) { function fillDetail(issue, comments) {
currentIssueData = issue;
document.getElementById('detail-title').textContent = issue.title; document.getElementById('detail-title').textContent = issue.title;
const stateChip = document.getElementById('detail-state'); const stateChip = document.getElementById('detail-state');
stateChip.textContent = issue.state === 'closed' ? '✅ Fermée' : '🟢 Ouverte'; stateChip.textContent = issue.state === 'closed' ? '✅ Fermée' : '🟢 Ouverte';
@@ -457,6 +475,8 @@ document.addEventListener('DOMContentLoaded', () => {
renderLabelToggles(issue); renderLabelToggles(issue);
document.getElementById('detail-body').innerHTML = document.getElementById('detail-body').innerHTML =
issue.body ? renderMarkdown(issue.body) : '<p class="detail-empty">Aucune description.</p>'; issue.body ? renderMarkdown(issue.body) : '<p class="detail-empty">Aucune description.</p>';
renderAssets(issue);
setEditMode(false);
document.getElementById('detail-gitea-link').href = issue.html_url || '#'; document.getElementById('detail-gitea-link').href = issue.html_url || '#';
const box = document.getElementById('detail-comments'); const box = document.getElementById('detail-comments');
box.innerHTML = ''; 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 = '<p class="detail-empty">Non pris en charge par l\'API de cette forge — joignez les fichiers depuis son interface web.</p>';
if (form) form.hidden = true;
return;
}
if (form) form.hidden = false;
const assets = issue.assets || [];
if (!assets.length) {
box.innerHTML = '<p class="detail-empty">Aucune pièce jointe.</p>';
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) { function commentNode(c) {
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'comment'; div.className = 'comment';
+46
View File
@@ -187,6 +187,52 @@ class FakeGitea:
"404", request=request, response=_httpx.Response(404, request=request) "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): async def get_issue_comments(self, owner, repo, number):
return list(self.comments.get((f"{owner}/{repo}", number), [])) return list(self.comments.get((f"{owner}/{repo}", number), []))
+134
View File
@@ -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"