feat(0.31.0): description éditable, pièces jointes d'issues, clic carte fiable
CI / test (push) Successful in 2m10s
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:
+202
-2
@@ -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)."""
|
||||
|
||||
Reference in New Issue
Block a user