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
@@ -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",