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", ""),
}