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
+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.0"
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.0"},
},
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(
+21
View File
@@ -57,7 +57,10 @@
<form id="create-issue-form">
<input type="text" id="new-issue-title" maxlength="255"
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>
<textarea id="new-issue-body" rows="4" maxlength="100000" hidden
placeholder="Description (markdown)..."></textarea>
</form>
</div>
@@ -146,6 +149,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">