Compare commits

..
2 Commits
Author SHA1 Message Date
Spark 7852d860fe fix(0.31.1): compositeur de création refait, description en <details> natif
CI / test (push) Successful in 2m13s
- Carte compositeur : en-tête + colonne d'arrivée, champ
  pleine largeur, bouton primaire ; neutralise le
  display:flex imposé par .board-toolbar aux formulaires.
- 'Ajouter une description' devient un <details> natif :
  aucun JS requis, insensible au cache et aux erreurs de
  scripts (le bouton 0.31.0 pouvait rester inerte).
- Assets du board versionnés (?v=APP_VERSION) contre les
  mélanges de cache après redéploiement ; Ctrl+Entrée.
- Vérifié en Chromium réel sur le vrai board (captures
  contrôlées) ; 261 tests verts.
2026-10-23 09:48:17 -04:00
Spark d4cf8d1275 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).
2026-10-23 09:27:38 -04:00
13 changed files with 759 additions and 18 deletions
+63
View File
@@ -1,5 +1,68 @@
# Changelog
## 0.31.1 — 2026-10-23
### Corrigé — compositeur de création refait
Retour de Bruno sur la 0.31.0 : la barre de création
n'était ni élégante ni professionnelle, et le bouton
« + Description » pouvait rester sans effet (écouteur
JavaScript non enregistré selon l'état du cache ou des
scripts de la page).
- **Compositeur en carte** : en-tête « Nouvelle issue »
avec la colonne d'arrivée (« arrive dans 📥 Backlog »,
nom personnalisé de l'espace respecté), champ titre
pleine largeur avec anneau de focus, bouton primaire
« Créer l'issue ».
- **Description en `<details>` natif** : « Ajouter une
description ▾ » déplie la zone texte sans aucun
JavaScript — impossible à casser par un script en
erreur ou un fichier en cache. Aide intégrée (Markdown,
Ctrl+Entrée pour créer, actif aussi depuis le titre).
- **Assets du board versionnés** (`?v=<version>` sur le
CSS et board.js du board) : un redéploiement invalide
le cache navigateur au lieu de servir un mélange
d'anciennes et nouvelles pièces.
- Vérifié en navigateur réel (Chromium) sur le vrai
board : captures fermé/ouvert contrôlées visuellement,
clic sur le libellé, zone visible et éditable.
## 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
+1 -1
View File
@@ -113,7 +113,7 @@ from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
APP_VERSION = "0.30.0"
APP_VERSION = "0.31.1"
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,
"capabilities": {},
"clientInfo": {"name": "kanbante", "version": "0.30.0"},
"clientInfo": {"name": "kanbante", "version": "0.31.1"},
},
req_id=1,
)
+82
View File
@@ -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/<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(
self, owner: str, repo: str, issue_number: int, state: str
) -> dict:
+4
View File
@@ -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"
+202 -2
View File
@@ -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)."""
+1 -1
View File
@@ -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(
+38 -6
View File
@@ -5,11 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}">
<title>{{ repo }} — Kanbante</title>
<link rel="stylesheet" href="/static/css/style.css">
<link rel="stylesheet" href="/static/css/style.css?v={{ version }}">
<script src="/static/vendor/sortable.min.js"></script>
<script src="/static/vendor/marked.min.js"></script>
<script src="/static/vendor/purify.min.js"></script>
<script src="/static/js/board.js" defer></script>
<script src="/static/js/board.js?v={{ version }}" defer></script>
</head>
<body>
{% include "_header.html" %}
@@ -54,10 +54,24 @@
<button type="button" id="labels-btn" class="btn-sm">🏷️ Gérer les étiquettes</button>
<a href="/triage/{{ full_name }}" class="btn-sm" style="text-decoration:none">📥 Triage du backlog</a>
{% if is_admin or workspace_role == 'admin' %}<a href="/admin/columns" class="btn-sm" style="text-decoration:none" title="Personnaliser les colonnes de cet espace">🧱 Colonnes</a>{% endif %}
<form id="create-issue-form">
<input type="text" id="new-issue-title" maxlength="255"
placeholder="➕ Nouvelle issue (titre)..." required>
<button type="submit" class="btn-save btn-inline">Créer</button>
<form id="create-issue-form" class="composer">
<div class="composer-head">
<span class="composer-title">➕ Nouvelle issue</span>
<span class="composer-target">arrive dans
<strong>{{ column_labels.backlog }}</strong>
</span>
</div>
<div class="composer-main">
<input type="text" id="new-issue-title" maxlength="255"
placeholder="Titre de l'issue..." required>
<button type="submit" class="btn-save btn-inline">Créer l'issue</button>
</div>
<details class="composer-desc" id="new-issue-desc">
<summary><span class="composer-desc-label">Ajouter une description</span><span class="composer-chevron" aria-hidden="true">▾</span></summary>
<textarea id="new-issue-body" rows="5" maxlength="100000"
placeholder="Description (markdown)..."></textarea>
<p class="composer-hint">Markdown accepté · Ctrl+Entrée pour créer l'issue</p>
</details>
</form>
</div>
@@ -146,6 +160,24 @@
</div>
<div id="detail-code" class="detail-code"></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>
<div id="detail-comments"></div>
<form id="comment-form">
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "kanbante"
version = "0.30.0"
version = "0.31.1"
description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11"
dependencies = [
+31
View File
@@ -1683,3 +1683,34 @@ 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; }
/* ── Compositeur de nouvelle issue (0.31.1) ── */
/* La toolbar impose display:flex à ses formulaires : le
compositeur est une carte, en bloc et pleine largeur. */
.board-toolbar form.composer { display: block; flex: 1 1 100%; }
.composer { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 12px 14px; margin: 6px 0 10px; }
.composer-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; }
.composer-title { font-weight: 700; font-size: 0.95rem; }
.composer-target { color: var(--muted, #9aa3b5); font-size: 0.82rem; }
.composer-main { display: flex; gap: 8px; }
.composer-main input[type="text"] { flex: 1; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 9px 12px; color: inherit; font-size: 0.95rem; }
.composer-main input[type="text"]:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 92, 252, 0.25); }
.composer-desc { margin-top: 10px; }
.composer-desc summary { list-style: none; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; font-size: 0.88rem; color: var(--accent-2); padding: 4px 2px; user-select: none; }
.composer-desc summary::-webkit-details-marker { display: none; }
.composer-desc summary:hover .composer-desc-label { text-decoration: underline; }
.composer-chevron { transition: transform 0.15s ease; font-size: 0.75rem; }
.composer-desc[open ] .composer-chevron { transform: rotate(180deg); }
.composer-desc textarea { width: 100%; margin-top: 8px; background: var(--surface-2); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; color: inherit; font-family: inherit; resize: vertical; }
.composer-desc textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(124, 92, 252, 0.25); }
.composer-hint { color: var(--muted, #9aa3b5); font-size: 0.78rem; margin: 6px 0 0; }
+153 -6
View File
@@ -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,13 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
createForm?.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
createForm.requestSubmit();
}
});
// 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 +334,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 +369,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 +385,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 +451,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 +477,8 @@ document.addEventListener('DOMContentLoaded', () => {
renderLabelToggles(issue);
document.getElementById('detail-body').innerHTML =
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 || '#';
const box = document.getElementById('detail-comments');
box.innerHTML = '';
@@ -520,6 +542,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) {
const div = document.createElement('div');
div.className = 'comment';
+46
View File
@@ -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), []))
+136
View File
@@ -0,0 +1,136 @@
"""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 (0.31.1 : <details>
# natif plutôt qu'un bouton piloté en JS).
assert 'id="new-issue-body"' in page.text
assert 'id="new-issue-desc"' in page.text
assert 'class="composer"' 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"