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:
+1
-1
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
@@ -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(
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user