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
+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)."""