Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7852d860fe | ||
|
|
d4cf8d1275 |
@@ -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
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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
@@ -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 = [
|
||||||
|
|||||||
@@ -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
@@ -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';
|
||||||
|
|||||||
@@ -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), []))
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
Reference in New Issue
Block a user