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 # 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 ## 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.1"
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.1"},
}, },
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(
+38 -6
View File
@@ -5,11 +5,11 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="csrf-token" content="{{ csrf_token }}"> <meta name="csrf-token" content="{{ csrf_token }}">
<title>{{ repo }} — Kanbante</title> <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/sortable.min.js"></script>
<script src="/static/vendor/marked.min.js"></script> <script src="/static/vendor/marked.min.js"></script>
<script src="/static/vendor/purify.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> </head>
<body> <body>
{% include "_header.html" %} {% include "_header.html" %}
@@ -54,10 +54,24 @@
<button type="button" id="labels-btn" class="btn-sm">🏷️ Gérer les étiquettes</button> <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> <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 %} {% 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"> <form id="create-issue-form" class="composer">
<input type="text" id="new-issue-title" maxlength="255" <div class="composer-head">
placeholder="➕ Nouvelle issue (titre)..." required> <span class="composer-title">➕ Nouvelle issue</span>
<button type="submit" class="btn-save btn-inline">Créer</button> <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> </form>
</div> </div>
@@ -146,6 +160,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.1"
description = "Kanban project manager integrated with Gitea" description = "Kanban project manager integrated with Gitea"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ 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; .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; }
/* ── 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(); 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,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 // 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 +334,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 +369,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 +385,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 +451,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 +477,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 +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) { 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), []))
+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"