feat: add file attachment support to issue tracker with Gitea mirroring

Implement full attachment CRUD for issues and comments, restricted to open issues only. Attachments are stored locally and mirrored to the Gitea tracker via new `mirror_attachment_to_gitea` and `set_gitea_asset_id` functions. Add `gitea_asset_id` column to the attachments table, extend `IssueForm` and `IssueCommentForm` with `attachment_uids` field, and expose new API endpoints for listing, adding, and deleting issue attachments. Update agent configuration to include web research tools, and document the new capability in README, AGENTS.md, and CLAUDE.md.
This commit is contained in:
2026-06-19 11:22:05 +00:00
parent a6cc83bfd2
commit 7bbaf51450
28 changed files with 1191 additions and 21 deletions
+66
View File
@@ -313,6 +313,7 @@ def store_attachment(file_bytes, original_filename, user_uid):
"image_height": image_height,
"has_thumbnail": 1 if thumbnail else 0,
"thumbnail_name": thumbnail,
"gitea_asset_id": None,
"created_at": datetime.now(timezone.utc).isoformat(),
"deleted_at": None,
"deleted_by": None,
@@ -451,6 +452,69 @@ def link_attachments(uids, target_type, target_uid):
)
def set_gitea_asset_id(uid, asset_id):
get_table("attachments").update(
{"uid": uid, "gitea_asset_id": int(asset_id)}, ["uid"]
)
async def mirror_attachment_to_gitea(uid):
from devplacepy.services.gitea import runtime
from devplacepy.services.gitea.client import GiteaError
row = get_table("attachments").find_one(uid=uid, deleted_at=None)
if not row:
return None
target_type = row.get("target_type", "")
target_uid = row.get("target_uid", "")
if target_type not in ("issue", "issue_comment") or not target_uid:
return None
path = ATTACHMENTS_DIR / row.get("directory", "") / row.get("stored_name", "")
try:
data = path.read_bytes()
except OSError as exc:
logger.warning("Cannot read attachment %s for Gitea mirror: %s", uid, exc)
return None
filename = row.get("original_filename") or row.get("stored_name") or "file"
mime = row.get("mime_type") or "application/octet-stream"
client = runtime.get_client()
try:
if target_type == "issue":
asset = await client.create_issue_asset(
int(target_uid), filename, data, mime
)
else:
asset = await client.create_comment_asset(
int(target_uid), filename, data, mime
)
except (GiteaError, ValueError) as exc:
logger.warning("Gitea asset mirror failed for %s: %s", uid, exc)
return None
asset_id = int(asset.get("id", 0) or 0)
if asset_id:
set_gitea_asset_id(uid, asset_id)
return asset_id
async def remove_gitea_asset(row):
from devplacepy.services.gitea import runtime
from devplacepy.services.gitea.client import GiteaError
asset_id = int(row.get("gitea_asset_id") or 0)
target_type = row.get("target_type", "")
target_uid = row.get("target_uid", "")
if not asset_id or not target_uid:
return
client = runtime.get_client()
try:
if target_type == "issue":
await client.delete_issue_asset(int(target_uid), asset_id)
elif target_type == "issue_comment":
await client.delete_comment_asset(int(target_uid), asset_id)
except (GiteaError, ValueError) as exc:
logger.warning("Gitea asset delete failed for %s: %s", row.get("uid"), exc)
def _unlink_attachment_files(row):
stored_name = row.get("stored_name", "")
directory = row.get("directory", "")
@@ -618,6 +682,8 @@ def _row_to_attachment(row):
"is_audio": row.get("mime_type", "").startswith("audio/"),
"target_type": row.get("target_type", ""),
"target_uid": row.get("target_uid", ""),
"user_uid": row.get("user_uid", ""),
"gitea_asset_id": row.get("gitea_asset_id") or None,
"created_at": row.get("created_at", ""),
}
+1
View File
@@ -554,6 +554,7 @@ def init_db():
("image_height", 0),
("has_thumbnail", 0),
("thumbnail_name", ""),
("gitea_asset_id", 0),
("created_at", ""),
("deleted_at", ""),
):
+98
View File
@@ -4074,6 +4074,103 @@ four ways to sign requests.
),
],
),
endpoint(
id="issues-attachments-list",
method="GET",
path="/issues/{number}/attachments",
title="List issue attachments",
summary="Return the files attached to an issue ticket.",
auth="public",
params=[
field("number", "path", "integer", True, "12", "Issue number."),
],
),
endpoint(
id="issues-attachments-add",
method="POST",
path="/issues/{number}/attachments",
title="Attach files to an issue",
summary=(
"Link already-uploaded files (from /uploads/upload) to an open issue. The "
"files are also mirrored to the Gitea tracker. Allowed only while the issue is open."
),
auth="user",
encoding="form",
destructive=True,
params=[
field("number", "path", "integer", True, "12", "Issue number."),
field(
"attachment_uids",
"form",
"string",
True,
"a1b2c3d4,e5f6g7h8",
"Comma separated attachment uids returned by /uploads/upload.",
),
],
notes=[
"Only the open issue accepts changes; a closed issue returns 409.",
"You can only link your own uploads unless you are an administrator.",
],
),
endpoint(
id="issues-attachments-delete",
method="DELETE",
path="/issues/{number}/attachments/{uid}",
title="Delete an issue attachment",
summary=(
"Soft-delete a file from an open issue (owner or administrator) and remove it "
"from the tracker."
),
auth="user",
destructive=True,
params=[
field("number", "path", "integer", True, "12", "Issue number."),
field("uid", "path", "string", True, "a1b2c3d4", "Attachment uid."),
],
),
endpoint(
id="issues-comment-attachments-add",
method="POST",
path="/issues/{number}/comments/{cid}/attachments",
title="Attach files to an issue comment",
summary=(
"Link already-uploaded files to an issue comment (issue must be open). Mirrored "
"to the Gitea comment."
),
auth="user",
encoding="form",
destructive=True,
params=[
field("number", "path", "integer", True, "12", "Issue number."),
field("cid", "path", "integer", True, "1", "Gitea comment id."),
field(
"attachment_uids",
"form",
"string",
True,
"a1b2c3d4",
"Comma separated attachment uids returned by /uploads/upload.",
),
],
),
endpoint(
id="issues-comment-attachments-delete",
method="DELETE",
path="/issues/{number}/comments/{cid}/attachments/{uid}",
title="Delete an issue comment attachment",
summary=(
"Soft-delete a file from an issue comment (owner or administrator) and remove it "
"from the tracker."
),
auth="user",
destructive=True,
params=[
field("number", "path", "integer", True, "12", "Issue number."),
field("cid", "path", "integer", True, "1", "Gitea comment id."),
field("uid", "path", "string", True, "a1b2c3d4", "Attachment uid."),
],
),
],
},
{
@@ -5033,6 +5130,7 @@ _PAGE_RESPONSES = {
"notifications-list": schemas.NotificationsOut,
"issues-list": schemas.IssuesOut,
"issues-detail": schemas.IssueDetailOut,
"issues-attachments-list": schemas.IssueAttachmentsOut,
"bookmarks-saved": schemas.SavedOut,
"admin-users": schemas.AdminUsersOut,
"admin-news-list": schemas.AdminNewsOut,
+6
View File
@@ -400,10 +400,16 @@ class GistEditForm(BaseModel):
class IssueForm(BaseModel):
title: str = Field(min_length=1, max_length=200)
description: str = Field(min_length=1, max_length=5000)
attachment_uids: list[str] = []
class IssueCommentForm(BaseModel):
body: str = Field(min_length=1, max_length=5000)
attachment_uids: list[str] = []
class IssueAttachmentForm(BaseModel):
attachment_uids: list[str] = []
class IssueStatusForm(BaseModel):
+2 -1
View File
@@ -1,9 +1,10 @@
# retoor <retoor@molodetz.nl>
from devplacepy.routers.issues import comment, create, planning, status
from devplacepy.routers.issues import attachments, comment, create, planning, status
from devplacepy.routers.issues.index import router
router.include_router(planning.router)
router.include_router(create.router)
router.include_router(comment.router)
router.include_router(status.router)
router.include_router(attachments.router)
+264
View File
@@ -0,0 +1,264 @@
# retoor <retoor@molodetz.nl>
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse
from devplacepy.attachments import (
get_attachments,
link_attachments,
mirror_attachment_to_gitea,
remove_gitea_asset,
soft_delete_attachment,
)
from devplacepy.database import get_table
from devplacepy.dependencies import json_or_form
from devplacepy.models import IssueAttachmentForm
from devplacepy.responses import action_result, json_error
from devplacepy.schemas import IssueAttachmentsOut
from devplacepy.services.audit import record as audit
from devplacepy.services.gitea import runtime
from devplacepy.services.gitea.client import STATE_OPEN, GiteaError
from devplacepy.services.gitea.config import gitea_config
from devplacepy.utils import get_current_user, is_admin, not_found, require_user
logger = logging.getLogger(__name__)
router = APIRouter()
def _split_uids(raw) -> list[str]:
return [u.strip() for item in raw or [] for u in str(item).split(",") if u.strip()]
def _can_modify(att: dict, user: dict | None, is_open: bool) -> bool:
if not user or not is_open:
return False
return att.get("user_uid") == user["uid"] or is_admin(user)
def _payload(rows: list[dict], user: dict | None, is_open: bool) -> list[dict]:
items = []
for row in rows:
att = dict(row)
att["can_modify"] = _can_modify(att, user, is_open)
items.append(att)
return items
def _claim_orphans(uids: list[str], user: dict) -> list[str]:
admin = is_admin(user)
owned = []
for uid in uids:
row = get_table("attachments").find_one(uid=uid, deleted_at=None)
if not row:
continue
if row.get("user_uid") and row["user_uid"] != user["uid"] and not admin:
continue
if row.get("target_uid"):
continue
owned.append(uid)
return owned
async def _load_issue(number: int) -> dict:
try:
return await runtime.get_client().get_issue(number)
except GiteaError as exc:
if exc.status == 404:
raise not_found("Issue not found")
raise
@router.get("/{number}/attachments")
async def list_issue_attachments(request: Request, number: int):
if not gitea_config().is_configured:
return json_error(503, "The issue tracker is not configured")
user = get_current_user(request)
issue = await _load_issue(number)
is_open = issue.get("state") == STATE_OPEN
rows = get_attachments("issue", str(number))
payload = {"number": number, "attachments": _payload(rows, user, is_open)}
return JSONResponse(
IssueAttachmentsOut.model_validate(payload).model_dump(mode="json")
)
@router.post("/{number}/attachments")
async def add_issue_attachment(
request: Request,
number: int,
data: Annotated[IssueAttachmentForm, Depends(json_or_form(IssueAttachmentForm))],
):
user = require_user(request)
if not gitea_config().is_configured:
return json_error(503, "The issue tracker is not configured")
issue = await _load_issue(number)
if issue.get("state") != STATE_OPEN:
audit.record(
request,
"issue.attachment.add",
user=user,
result="denied",
target_type="issue",
target_uid=str(number),
summary=f"{user['username']} tried to attach to closed issue #{number}",
)
return json_error(409, "Attachments cannot be changed on a closed issue")
owned = _claim_orphans(_split_uids(data.attachment_uids), user)
if not owned:
return json_error(400, "No valid attachments to add")
link_attachments(owned, "issue", str(number))
for uid in owned:
await mirror_attachment_to_gitea(uid)
audit.record(
request,
"issue.attachment.add",
user=user,
target_type="issue",
target_uid=str(number),
summary=f"{user['username']} attached {len(owned)} file(s) to issue #{number}",
metadata={"number": number, "count": len(owned)},
links=[audit.target("issue", str(number))],
)
return action_result(
request, f"/issues/{number}", data={"count": len(owned), "uids": owned}
)
@router.delete("/{number}/attachments/{uid}")
async def delete_issue_attachment(request: Request, number: int, uid: str):
user = require_user(request)
if not gitea_config().is_configured:
return json_error(503, "The issue tracker is not configured")
issue = await _load_issue(number)
att = get_table("attachments").find_one(
uid=uid, target_type="issue", target_uid=str(number), deleted_at=None
)
if not att:
raise not_found("Attachment not found")
if att.get("user_uid") and att["user_uid"] != user["uid"] and not is_admin(user):
audit.record(
request,
"issue.attachment.delete",
user=user,
result="denied",
target_type="issue",
target_uid=str(number),
summary=f"{user['username']} tried to delete another user's attachment on issue #{number}",
)
return json_error(403, "Not authorized")
if issue.get("state") != STATE_OPEN:
audit.record(
request,
"issue.attachment.delete",
user=user,
result="denied",
target_type="issue",
target_uid=str(number),
summary=f"{user['username']} tried to delete an attachment on closed issue #{number}",
)
return json_error(409, "Attachments cannot be changed on a closed issue")
soft_delete_attachment(uid, deleted_by=user["uid"])
await remove_gitea_asset(att)
audit.record(
request,
"issue.attachment.delete",
user=user,
target_type="issue",
target_uid=str(number),
summary=f"{user['username']} deleted an attachment on issue #{number}",
metadata={"number": number, "uid": uid},
links=[audit.target("issue", str(number))],
)
return action_result(request, f"/issues/{number}", data={"status": "deleted"})
@router.get("/{number}/comments/{cid}/attachments")
async def list_comment_attachments(request: Request, number: int, cid: int):
if not gitea_config().is_configured:
return json_error(503, "The issue tracker is not configured")
user = get_current_user(request)
issue = await _load_issue(number)
is_open = issue.get("state") == STATE_OPEN
rows = get_attachments("issue_comment", str(cid))
payload = {"number": number, "attachments": _payload(rows, user, is_open)}
return JSONResponse(
IssueAttachmentsOut.model_validate(payload).model_dump(mode="json")
)
@router.post("/{number}/comments/{cid}/attachments")
async def add_comment_attachment(
request: Request,
number: int,
cid: int,
data: Annotated[IssueAttachmentForm, Depends(json_or_form(IssueAttachmentForm))],
):
user = require_user(request)
if not gitea_config().is_configured:
return json_error(503, "The issue tracker is not configured")
issue = await _load_issue(number)
if issue.get("state") != STATE_OPEN:
return json_error(409, "Attachments cannot be changed on a closed issue")
owned = _claim_orphans(_split_uids(data.attachment_uids), user)
if not owned:
return json_error(400, "No valid attachments to add")
link_attachments(owned, "issue_comment", str(cid))
for uid in owned:
await mirror_attachment_to_gitea(uid)
audit.record(
request,
"issue.attachment.add",
user=user,
target_type="issue",
target_uid=str(number),
summary=f"{user['username']} attached {len(owned)} file(s) to a comment on issue #{number}",
metadata={"number": number, "comment_id": cid, "count": len(owned)},
links=[audit.target("issue", str(number))],
)
return action_result(
request, f"/issues/{number}", data={"count": len(owned), "uids": owned}
)
@router.delete("/{number}/comments/{cid}/attachments/{uid}")
async def delete_comment_attachment(
request: Request, number: int, cid: int, uid: str
):
user = require_user(request)
if not gitea_config().is_configured:
return json_error(503, "The issue tracker is not configured")
issue = await _load_issue(number)
att = get_table("attachments").find_one(
uid=uid, target_type="issue_comment", target_uid=str(cid), deleted_at=None
)
if not att:
raise not_found("Attachment not found")
if att.get("user_uid") and att["user_uid"] != user["uid"] and not is_admin(user):
audit.record(
request,
"issue.attachment.delete",
user=user,
result="denied",
target_type="issue",
target_uid=str(number),
summary=f"{user['username']} tried to delete another user's comment attachment on issue #{number}",
)
return json_error(403, "Not authorized")
if issue.get("state") != STATE_OPEN:
return json_error(409, "Attachments cannot be changed on a closed issue")
soft_delete_attachment(uid, deleted_by=user["uid"])
await remove_gitea_asset(att)
audit.record(
request,
"issue.attachment.delete",
user=user,
target_type="issue",
target_uid=str(number),
summary=f"{user['username']} deleted a comment attachment on issue #{number}",
metadata={"number": number, "comment_id": cid, "uid": uid},
links=[audit.target("issue", str(number))],
)
return action_result(request, f"/issues/{number}", data={"status": "deleted"})
+19
View File
@@ -5,6 +5,7 @@ from typing import Annotated
from fastapi import Depends, APIRouter, Request
from devplacepy.attachments import link_attachments, mirror_attachment_to_gitea
from devplacepy.database import get_table
from devplacepy.models import IssueCommentForm
from devplacepy.responses import action_result, json_error
@@ -19,6 +20,19 @@ from devplacepy.dependencies import json_or_form
logger = logging.getLogger(__name__)
router = APIRouter()
def _owned_orphan_uids(raw: list[str], user: dict) -> list[str]:
flat = [u.strip() for item in raw or [] for u in str(item).split(",") if u.strip()]
owned = []
for uid in flat:
row = get_table("attachments").find_one(uid=uid, deleted_at=None)
if not row or row.get("target_uid"):
continue
if row.get("user_uid") and row["user_uid"] != user["uid"]:
continue
owned.append(uid)
return owned
def _notify_admins(actor: dict, number: int) -> None:
for admin in get_table("users").find(role="Admin"):
if admin["uid"] == actor["uid"]:
@@ -60,6 +74,11 @@ async def comment_issue(
comment_id = int(comment.get("id", 0))
store.record_comment_author(comment_id, number, user["uid"])
owned = _owned_orphan_uids(data.attachment_uids, user)
if owned:
link_attachments(owned, "issue_comment", str(comment_id))
for uid in owned:
await mirror_attachment_to_gitea(uid)
background.submit(_notify_admins, user, number)
audit.record(
request,
+16
View File
@@ -6,6 +6,7 @@ from typing import Annotated
from fastapi import Depends, APIRouter, Request
from fastapi.responses import JSONResponse
from devplacepy.database import get_table
from devplacepy.models import IssueForm
from devplacepy.responses import json_error
from devplacepy.schemas import IssueJobOut
@@ -18,6 +19,20 @@ from devplacepy.dependencies import json_or_form
logger = logging.getLogger(__name__)
router = APIRouter()
def _owned_orphan_uids(raw: list[str], user: dict) -> list[str]:
flat = [u.strip() for item in raw or [] for u in str(item).split(",") if u.strip()]
owned = []
for uid in flat:
row = get_table("attachments").find_one(uid=uid, deleted_at=None)
if not row or row.get("target_uid"):
continue
if row.get("user_uid") and row["user_uid"] != user["uid"]:
continue
owned.append(uid)
return owned
@router.post("/create")
async def create_issue(request: Request, data: Annotated[IssueForm, Depends(json_or_form(IssueForm))]):
user = require_user(request)
@@ -30,6 +45,7 @@ async def create_issue(request: Request, data: Annotated[IssueForm, Depends(json
"author_uid": user["uid"],
"title": title,
"description": data.description.strip(),
"attachment_uids": _owned_orphan_uids(data.attachment_uids, user),
},
"user",
user["uid"],
+28 -4
View File
@@ -5,6 +5,7 @@ import logging
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
from devplacepy.attachments import get_attachments, get_attachments_batch
from devplacepy.database import build_pagination, get_users_by_uids, get_blocked_uids
from devplacepy.responses import respond
from devplacepy.schemas import IssueDetailOut, IssuesOut
@@ -109,12 +110,33 @@ async def issue_detail(request: Request, number: int):
body = issue.get("body", "")
issue = issue_item(issue, author_uid, users_map)
comment_items = [
comment_item(
is_open = issue.get("state") == STATE_OPEN
def _decorate(rows):
items = []
for row in rows:
att = dict(row)
att["can_modify"] = bool(
user
and is_open
and (att.get("user_uid") == user["uid"] or is_admin(user))
)
items.append(att)
return items
issue_attachments = _decorate(get_attachments("issue", str(number)))
comment_attachments = get_attachments_batch(
"issue_comment", [str(int(comment.get("id", 0))) for comment in comments]
)
comment_items = []
for comment in comments:
item = comment_item(
comment, comment_authors.get(int(comment.get("id", 0))), users_map
)
for comment in comments
]
item["attachments"] = _decorate(
comment_attachments.get(str(item["id"]), [])
)
comment_items.append(item)
seo_ctx = issue_seo(
request,
title=f"Issue #{number}: {issue.get('title', '')}",
@@ -130,7 +152,9 @@ async def issue_detail(request: Request, number: int):
"issue": issue,
"body": body,
"comments": comment_items,
"attachments": issue_attachments,
"can_comment": user is not None,
"can_attach": user is not None and is_open,
"viewer_is_admin": is_admin(user),
},
model=IssueDetailOut,
+9
View File
@@ -293,6 +293,7 @@ class AttachmentOut(_Out):
is_video: Optional[bool] = None
mime_type: Optional[str] = None
created_at: Optional[str] = None
can_modify: bool = False
class VotesOut(_Out):
@@ -549,16 +550,24 @@ class IssueCommentOut(_Out):
author_uid: Optional[str] = None
author_avatar_seed: Optional[str] = None
is_local_author: bool = False
attachments: list[AttachmentOut] = []
class IssueDetailOut(_Out):
issue: IssueItemOut
body: str = ""
comments: list[IssueCommentOut] = []
attachments: list[AttachmentOut] = []
can_comment: bool = False
can_attach: bool = False
viewer_is_admin: bool = False
class IssueAttachmentsOut(_Out):
number: int = 0
attachments: list[AttachmentOut] = []
class IssueJobOut(_Out):
uid: str = ""
kind: str = ""
@@ -1016,6 +1016,62 @@ ACTIONS: tuple[Action, ...] = (
body("status", "New status: open or closed.", required=True),
),
),
Action(
name="list_issue_attachments",
method="GET",
path="/issues/{number}/attachments",
summary="List the files attached to an issue ticket",
requires_auth=False,
params=(path("number", "The issue ticket number."),),
),
Action(
name="add_issue_attachment",
method="POST",
path="/issues/{number}/attachments",
summary="Attach already-uploaded files to an open issue ticket",
description=(
"Upload files first with upload_file, then pass their uids here. Only works while "
"the issue is open; the files are mirrored to the tracker."
),
params=(
path("number", "The issue ticket number."),
body("attachment_uids", ATTACHMENTS, required=True),
),
),
Action(
name="delete_issue_attachment",
method="DELETE",
path="/issues/{number}/attachments/{uid}",
summary="Delete a file from an open issue ticket (your own, or any when administrator). Soft delete, confirmation required",
params=(
path("number", "The issue ticket number."),
path("uid", "The attachment uid."),
confirm(),
),
),
Action(
name="add_comment_attachment",
method="POST",
path="/issues/{number}/comments/{cid}/attachments",
summary="Attach already-uploaded files to an issue comment (issue must be open)",
params=(
path("number", "The issue ticket number."),
path("cid", "The Gitea comment id."),
body("attachment_uids", ATTACHMENTS, required=True),
),
),
Action(
name="delete_comment_attachment",
method="DELETE",
path="/issues/{number}/comments/{cid}/attachments/{uid}",
summary="Delete a file from an issue comment (your own, or any when administrator). Soft delete, confirmation required",
params=(
path("number", "The issue ticket number."),
path("cid", "The Gitea comment id."),
path("uid", "The attachment uid."),
confirm(),
),
),
Action(
name="list_gists",
method="GET",
@@ -45,6 +45,8 @@ CONFIRM_REQUIRED = {
"delete_comment",
"delete_gist",
"delete_attachment",
"delete_issue_attachment",
"delete_comment_attachment",
"admin_media_purge",
"admin_delete_news",
"admin_reset_all_ai_quota",
+55 -5
View File
@@ -31,24 +31,28 @@ class GiteaClient:
def __init__(self, config: GiteaConfig | None = None):
self.config = config or gitea_config()
def _headers(self) -> dict:
return {
def _headers(self, json_body: bool = True) -> dict:
headers = {
"Authorization": f"token {self.config.token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
if json_body:
headers["Content-Type"] = "application/json"
return headers
def _require_config(self) -> None:
if not self.config.is_configured:
raise GiteaError("Gitea integration is not configured", status=503)
async def _request(self, method: str, url: str, **kwargs) -> httpx.Response:
async def _request(
self, method: str, url: str, json_body: bool = True, **kwargs
) -> httpx.Response:
self._require_config()
logger.debug("Gitea %s %s", method, url)
try:
async with stealth.stealth_async_client(timeout=HTTP_TIMEOUT_SECONDS) as client:
response = await client.request(
method, url, headers=self._headers(), **kwargs
method, url, headers=self._headers(json_body), **kwargs
)
except httpx.HTTPError as exc:
logger.warning("Gitea request failed: %s", exc)
@@ -123,6 +127,52 @@ class GiteaClient:
)
return response.json()
async def list_issue_assets(self, number: int) -> list[dict]:
response = await self._request(
"GET", f"{self.config.repo_base}/issues/{number}/assets"
)
return response.json()
async def create_issue_asset(
self, number: int, filename: str, data: bytes, mime: str
) -> dict:
response = await self._request(
"POST",
f"{self.config.repo_base}/issues/{number}/assets",
json_body=False,
files={"attachment": (filename, data, mime or "application/octet-stream")},
)
return response.json()
async def delete_issue_asset(self, number: int, attachment_id: int) -> None:
await self._request(
"DELETE",
f"{self.config.repo_base}/issues/{number}/assets/{attachment_id}",
)
async def list_comment_assets(self, comment_id: int) -> list[dict]:
response = await self._request(
"GET", f"{self.config.repo_base}/issues/comments/{comment_id}/assets"
)
return response.json()
async def create_comment_asset(
self, comment_id: int, filename: str, data: bytes, mime: str
) -> dict:
response = await self._request(
"POST",
f"{self.config.repo_base}/issues/comments/{comment_id}/assets",
json_body=False,
files={"attachment": (filename, data, mime or "application/octet-stream")},
)
return response.json()
async def delete_comment_asset(self, comment_id: int, attachment_id: int) -> None:
await self._request(
"DELETE",
f"{self.config.repo_base}/issues/comments/{comment_id}/assets/{attachment_id}",
)
@staticmethod
def _total_count(response: httpx.Response, fallback: int) -> int:
raw = response.headers.get("X-Total-Count", "")
+49
View File
@@ -19,8 +19,11 @@ class FakeGiteaClient:
self.bot_login = bot_login
self._issues: dict[int, dict] = {}
self._comments: dict[int, list[dict]] = {}
self._issue_assets: dict[int, list[dict]] = {}
self._comment_assets: dict[int, list[dict]] = {}
self._issue_seq = 0
self._comment_seq = 0
self._asset_seq = 0
def _html(self, number: int) -> str:
return f"https://gitea.test/retoor/pydevplace/issues/{number}"
@@ -97,6 +100,52 @@ class FakeGiteaClient:
issue["updated_at"] = _now()
return dict(issue)
def _asset(self, filename: str, data: bytes) -> dict:
self._asset_seq += 1
return {
"id": self._asset_seq,
"name": filename,
"size": len(data),
"download_count": 0,
"uuid": f"asset-{self._asset_seq}",
"browser_download_url": f"https://gitea.test/attachments/asset-{self._asset_seq}",
"created_at": _now(),
}
async def list_issue_assets(self, number: int) -> list[dict]:
if number not in self._issues:
raise GiteaError("issue not found", status=404)
return [dict(item) for item in self._issue_assets.get(number, [])]
async def create_issue_asset(
self, number: int, filename: str, data: bytes, mime: str
) -> dict:
if number not in self._issues:
raise GiteaError("issue not found", status=404)
asset = self._asset(filename, data)
self._issue_assets.setdefault(number, []).append(asset)
return dict(asset)
async def delete_issue_asset(self, number: int, attachment_id: int) -> None:
assets = self._issue_assets.get(number, [])
self._issue_assets[number] = [a for a in assets if a["id"] != attachment_id]
async def list_comment_assets(self, comment_id: int) -> list[dict]:
return [dict(item) for item in self._comment_assets.get(comment_id, [])]
async def create_comment_asset(
self, comment_id: int, filename: str, data: bytes, mime: str
) -> dict:
asset = self._asset(filename, data)
self._comment_assets.setdefault(comment_id, []).append(asset)
return dict(asset)
async def delete_comment_asset(self, comment_id: int, attachment_id: int) -> None:
assets = self._comment_assets.get(comment_id, [])
self._comment_assets[comment_id] = [
a for a in assets if a["id"] != attachment_id
]
def add_external_comment(self, number: int, login: str, body: str) -> dict:
self._comment_seq += 1
now = _now()
@@ -66,6 +66,17 @@ class IssueCreateService(JobService):
status=issue.get("state", "open"),
)
attachment_uids = payload.get("attachment_uids") or []
if attachment_uids:
from devplacepy.attachments import (
link_attachments,
mirror_attachment_to_gitea,
)
link_attachments(attachment_uids, "issue", str(number))
for uid in attachment_uids:
await mirror_attachment_to_gitea(uid)
create_notification(
author_uid,
"issue",
+26
View File
@@ -217,3 +217,29 @@
gap: 0.5rem;
}
}
.attachment-gallery-item .issue-att-delete {
position: absolute;
top: 4px;
right: 4px;
width: 22px;
height: 22px;
border: none;
border-radius: 50%;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 1rem;
line-height: 1;
cursor: pointer;
z-index: 1;
}
.attachment-gallery-item .issue-att-delete:hover {
background: var(--danger, #c0392b);
}
.issue-attach-form {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 1rem;
flex-wrap: wrap;
}
+2
View File
@@ -30,6 +30,7 @@ import { DeviiTerminal } from "./DeviiTerminal.js";
import { ZipDownloader } from "./ZipDownloader.js";
import { ProjectForker } from "./ProjectForker.js";
import { IssueReporter } from "./IssueReporter.js";
import { IssueAttachments } from "./IssueAttachments.js";
import { PlanningGenerator } from "./PlanningGenerator.js";
import { MediaGallery } from "./MediaGallery.js";
import WindowManager from "./components/WindowManager.js";
@@ -79,6 +80,7 @@ class Application {
this.zipDownloader = new ZipDownloader();
this.projectForker = new ProjectForker();
this.issueReporter = new IssueReporter();
this.issueAttachments = new IssueAttachments();
this.planningGenerator = new PlanningGenerator();
this.mediaGallery = new MediaGallery();
this.liveNotifications = new LiveNotifications(this.pubsub, this.toast);
+64
View File
@@ -0,0 +1,64 @@
// retoor <retoor@molodetz.nl>
import { Http } from "./Http.js";
export class IssueAttachments {
constructor() {
this.active = false;
document.addEventListener("submit", (event) => {
const form = event.target.closest("form[data-issue-attach]");
if (!form) return;
event.preventDefault();
this.add(form);
});
document.addEventListener("click", (event) => {
const button = event.target.closest("[data-attachment-delete]");
if (!button) return;
event.preventDefault();
this.remove(button);
});
}
async add(form) {
if (this.active) return;
const uidsInput = form.querySelector("[name='attachment_uids']");
const uids = uidsInput ? uidsInput.value.trim() : "";
if (!uids) {
window.app.toast.show("Select a file to attach first", { type: "info" });
return;
}
this.active = true;
const button = form.querySelector("button[type='submit']");
if (button) button.disabled = true;
try {
await Http.send(form.dataset.action, { attachment_uids: uids });
window.location.reload();
} catch (error) {
this.active = false;
if (button) button.disabled = false;
}
}
async remove(button) {
const confirmed = await window.app.dialog.confirm({
title: "Delete attachment",
message: "Delete this attachment? It is also removed from the tracker.",
});
if (!confirmed) return;
try {
const response = await fetch(button.dataset.action, {
method: "DELETE",
headers: { "Accept": "application/json" },
});
const data = await response.json().catch(() => ({}));
if (!response.ok || data.ok === false) {
const message = (data.error && data.error.message) || "Could not delete the attachment";
throw new Error(message);
}
const item = button.closest(".attachment-gallery-item");
if (item) item.remove();
} catch (error) {
Http.notifyError(error.message);
}
}
}
+7 -1
View File
@@ -22,6 +22,8 @@ export class IssueReporter {
window.app.toast.show("Title and description are required", { type: "error" });
return;
}
const uidsInput = form.querySelector("[name='attachment_uids']");
const attachmentUids = uidsInput ? uidsInput.value : "";
this.active = true;
const button = form.querySelector("button[type='submit']");
if (button) {
@@ -33,7 +35,11 @@ export class IssueReporter {
ms: 5000,
});
try {
const job = await Http.sendForm(form.action, { title, description });
const job = await Http.sendForm(form.action, {
title,
description,
attachment_uids: attachmentUids,
});
await this.poll(job.status_url);
} catch (error) {
window.app.toast.show("Could not submit the report", { type: "error" });
@@ -0,0 +1,24 @@
<div class="attachment-gallery">
{% for att in _attachments %}
<div class="attachment-gallery-item" data-att-uid="{{ att.uid }}">
{% if att.get('is_image') and att.get('thumbnail_url') %}
<img src="{{ att['thumbnail_url'] }}" alt="{{ att.get('original_filename', '') }}" loading="lazy" class="gallery-thumb" data-lightbox data-full="{{ att['url'] }}" data-mime="{{ att.get('mime_type', '') }}">
{% elif att.get('is_image') %}
<img src="{{ att['url'] }}" alt="{{ att.get('original_filename', '') }}" loading="lazy" class="gallery-thumb" data-lightbox data-full="{{ att['url'] }}" data-mime="{{ att.get('mime_type', '') }}">
{% elif att.get('is_video') %}
<video src="{{ att['url'] }}" controls preload="metadata" class="gallery-video"></video>
{% elif att.get('is_audio') %}
<audio src="{{ att['url'] }}" controls preload="metadata" class="gallery-audio"></audio>
{% else %}
<a href="{{ att['url'] }}" target="_blank" rel="noopener" class="non-image" download="{{ att.get('original_filename', 'file') }}">
<span class="icon">{{ file_icon_emoji(att.get('original_filename', 'file')) }}</span>
<span class="name">{{ att.get('original_filename', 'file') }}</span>
<span class="size">{{ format_file_size(att.get('file_size', 0)) }}</span>
</a>
{% endif %}
{% if att.get('can_modify') %}
<button type="button" class="issue-att-delete" data-attachment-delete data-action="{{ _delete_base }}/{{ att.uid }}" aria-label="Delete attachment">&times;</button>
{% endif %}
</div>
{% endfor %}
</div>
+15
View File
@@ -41,6 +41,17 @@
{% endif %}
<div class="issue-detail-body rendered-content">{{ render_content(body) }}</div>
{% if attachments %}
{% set _attachments = attachments %}{% set _delete_base = "/issues/" ~ issue.number ~ "/attachments" %}{% include "_issue_attachments.html" %}
{% endif %}
{% if can_attach %}
<form class="issue-attach-form" data-issue-attach data-action="/issues/{{ issue.number }}/attachments">
{% include "_attachment_form.html" %}
<button type="submit" class="btn btn-secondary btn-sm"><span class="btn-spinner" aria-hidden="true"></span>Attach files</button>
</form>
{% endif %}
</div>
<section class="comments-section">
@@ -58,6 +69,9 @@
<span class="issue-date">{{ local_dt(comment.created_at) }}</span>
</div>
<div class="issue-comment-body rendered-content">{{ render_content(comment.body) }}</div>
{% if comment.attachments %}
{% set _attachments = comment.attachments %}{% set _delete_base = "/issues/" ~ issue.number ~ "/comments/" ~ comment.id ~ "/attachments" %}{% include "_issue_attachments.html" %}
{% endif %}
</div>
{% else %}
<p class="no-comments-msg">No updates yet.</p>
@@ -67,6 +81,7 @@
<form method="POST" action="/issues/{{ issue.number }}/comment" class="comment-form issue-comment-form">
<textarea name="body" required aria-required="true" maxlength="5000" placeholder="Add a comment (posted to the tracker)..." class="min-h-120" data-mention aria-label="Add a comment"></textarea>
<div class="issue-comment-form-footer">
{% include "_attachment_form.html" %}
<button type="submit" class="btn btn-primary btn-sm"><span class="btn-spinner" aria-hidden="true"></span>Comment</button>
</div>
</form>
+4
View File
@@ -72,6 +72,10 @@
<label for="issue-description">Description</label>
<textarea id="issue-description" name="description" required aria-required="true" maxlength="5000" placeholder="Steps to reproduce, expected vs actual, environment..." class="min-h-120" data-mention></textarea>
</div>
<div class="auth-field auth-field-gap">
<label>Attachments</label>
{% include "_attachment_form.html" %}
</div>
<div class="modal-footer">
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary"><span class="btn-spinner" aria-hidden="true"></span><span class="icon">&#x1F4E4;</span> Submit Report</button>