feat: replace local bug store with Gitea-backed issue tracker and add AI-enhanced filing

This commit is contained in:
2026-06-12 18:31:40 +00:00
parent 3ef1d02265
commit 9003cf6570
26 changed files with 1981 additions and 235 deletions
+52 -3
View File
@@ -816,18 +816,67 @@ ACTIONS: tuple[Action, ...] = (
name="list_bugs",
method="GET",
path="/bugs",
summary="List reported bugs",
summary="List bug tickets from the Gitea tracker",
requires_auth=False,
params=(
query("state", "Filter by state: open, closed, or all."),
query("page", "1-based page number."),
),
),
Action(
name="create_bug",
method="POST",
path="/bugs/create",
summary="Report a bug",
summary="Report a bug. It is rewritten by AI and filed on the tracker.",
description=(
"Queues a background bug creation job and returns {uid, status_url}. Poll the "
"status_url with bug_job_status until status is 'done', then show the user the "
"bug_url pointing at the filed ticket."
),
params=(
body("title", "Bug title.", required=True),
body("description", "Bug description.", required=True),
body("attachment_uids", ATTACHMENTS),
),
),
Action(
name="bug_job_status",
method="GET",
path="/bugs/jobs/{uid}",
summary="Poll a bug creation job and obtain the filed bug ticket URL once finished",
description=(
"Returns the job status. When status is 'done', bug_url and number are populated; "
"while 'pending' or 'running', poll again shortly."
),
params=(path("uid", "Bug job uid returned by create_bug."),),
requires_auth=False,
),
Action(
name="view_bug",
method="GET",
path="/bugs/{number}",
summary="View a bug ticket and its developer updates",
requires_auth=False,
params=(path("number", "The bug ticket number from a /bugs listing."),),
),
Action(
name="comment_bug",
method="POST",
path="/bugs/{number}/comment",
summary="Post a comment on a bug ticket, attributed to the current user",
params=(
path("number", "The bug ticket number."),
body("body", "Comment text.", required=True),
),
),
Action(
name="set_bug_status",
method="POST",
path="/bugs/{number}/status",
summary="Change a bug ticket status (admin only)",
requires_admin=True,
params=(
path("number", "The bug ticket number."),
body("status", "New status: open or closed.", required=True),
),
),
Action(
+1
View File
@@ -0,0 +1 @@
# retoor <retoor@molodetz.nl>
+131
View File
@@ -0,0 +1,131 @@
# retoor <retoor@molodetz.nl>
import logging
import httpx
from devplacepy.services.gitea.config import (
HTTP_TIMEOUT_SECONDS,
GiteaConfig,
gitea_config,
)
logger = logging.getLogger(__name__)
ISSUE_TYPE = "issues"
STATE_OPEN = "open"
STATE_CLOSED = "closed"
STATE_ALL = "all"
VALID_STATES = (STATE_OPEN, STATE_CLOSED, STATE_ALL)
class GiteaError(Exception):
def __init__(self, message: str, status: int = 0):
super().__init__(message)
self.status = status
self.message = message
class GiteaClient:
def __init__(self, config: GiteaConfig | None = None):
self.config = config or gitea_config()
def _headers(self) -> dict:
return {
"Authorization": f"token {self.config.token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
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:
self._require_config()
logger.debug("Gitea %s %s", method, url)
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT_SECONDS) as client:
response = await client.request(
method, url, headers=self._headers(), **kwargs
)
except httpx.HTTPError as exc:
logger.warning("Gitea request failed: %s", exc)
raise GiteaError(f"Gitea request failed: {exc}") from exc
if response.status_code >= 400:
logger.warning(
"Gitea %s %s returned %s: %s",
method,
url,
response.status_code,
response.text[:200],
)
raise GiteaError(
f"Gitea returned {response.status_code}", status=response.status_code
)
return response
async def create_issue(self, title: str, body: str) -> dict:
response = await self._request(
"POST",
f"{self.config.repo_base}/issues",
json={"title": title, "body": body},
)
return response.json()
async def list_issues(
self, state: str = STATE_OPEN, page: int = 1, limit: int = 20
) -> tuple[list[dict], int]:
if state not in VALID_STATES:
state = STATE_OPEN
response = await self._request(
"GET",
f"{self.config.repo_base}/issues",
params={
"type": ISSUE_TYPE,
"state": state,
"page": max(1, page),
"limit": max(1, min(limit, 50)),
},
)
issues = [item for item in response.json() if not item.get("pull_request")]
total = self._total_count(response, len(issues))
return issues, total
async def get_issue(self, number: int) -> dict:
response = await self._request(
"GET", f"{self.config.repo_base}/issues/{number}"
)
return response.json()
async def list_comments(self, number: int) -> list[dict]:
response = await self._request(
"GET", f"{self.config.repo_base}/issues/{number}/comments"
)
return response.json()
async def create_comment(self, number: int, body: str) -> dict:
response = await self._request(
"POST",
f"{self.config.repo_base}/issues/{number}/comments",
json={"body": body},
)
return response.json()
async def set_state(self, number: int, state: str) -> dict:
if state not in (STATE_OPEN, STATE_CLOSED):
raise GiteaError(f"invalid state: {state}", status=400)
response = await self._request(
"PATCH",
f"{self.config.repo_base}/issues/{number}",
json={"state": state},
)
return response.json()
@staticmethod
def _total_count(response: httpx.Response, fallback: int) -> int:
raw = response.headers.get("X-Total-Count", "")
try:
return int(raw)
except (TypeError, ValueError):
return fallback
+114
View File
@@ -0,0 +1,114 @@
# retoor <retoor@molodetz.nl>
from dataclasses import dataclass
from devplacepy.config import INTERNAL_MODEL
from devplacepy.database import get_setting, internal_gateway_key
from devplacepy.services.base import ConfigField
BASE_URL_DEFAULT = "https://retoor.molodetz.nl"
OWNER_DEFAULT = "retoor"
REPO_DEFAULT = "pydevplace"
HTTP_TIMEOUT_SECONDS = 20.0
@dataclass(frozen=True)
class GiteaConfig:
base_url: str
owner: str
repo: str
token: str
ai_enhance: bool
ai_model: str
ai_key: str
@property
def api_base(self) -> str:
return f"{self.base_url.rstrip('/')}/api/v1"
@property
def repo_base(self) -> str:
return f"{self.api_base}/repos/{self.owner}/{self.repo}"
@property
def is_configured(self) -> bool:
return bool(self.base_url and self.owner and self.repo and self.token)
CONFIG_FIELDS: list[ConfigField] = [
ConfigField(
"gitea_base_url",
"Gitea base URL",
type="url",
default=BASE_URL_DEFAULT,
help="Origin of the Gitea instance, without the /api path.",
group="Gitea",
),
ConfigField(
"gitea_owner",
"Repository owner",
type="str",
default=OWNER_DEFAULT,
help="Owner (user or organisation) of the issue tracker repository.",
group="Gitea",
),
ConfigField(
"gitea_repo",
"Repository name",
type="str",
default=REPO_DEFAULT,
help="Repository whose issues back the bug tracker.",
group="Gitea",
),
ConfigField(
"gitea_token",
"Gitea access token",
type="password",
default="",
secret=True,
help="Personal access token with repo issue scope. All actions use this single account.",
group="Gitea",
),
ConfigField(
"bug_ai_enhance",
"Improve tickets with AI",
type="bool",
default=True,
help="Rewrite each submitted report into a consistent, high-quality ticket before posting.",
group="AI",
),
ConfigField(
"bug_ai_model",
"AI model",
type="str",
default=INTERNAL_MODEL,
help="Model name sent to the internal AI gateway for ticket enhancement.",
group="AI",
),
ConfigField(
"bug_ai_key",
"AI API key",
type="password",
default="",
secret=True,
help="Defaults to the internal gateway key when left blank.",
group="AI",
),
]
def gitea_config() -> GiteaConfig:
ai_key = get_setting("bug_ai_key", "") or internal_gateway_key()
return GiteaConfig(
base_url=get_setting("gitea_base_url", BASE_URL_DEFAULT),
owner=get_setting("gitea_owner", OWNER_DEFAULT),
repo=get_setting("gitea_repo", REPO_DEFAULT),
token=get_setting("gitea_token", ""),
ai_enhance=get_setting("bug_ai_enhance", "1") == "1",
ai_model=get_setting("bug_ai_model", INTERNAL_MODEL),
ai_key=ai_key,
)
def is_configured() -> bool:
return gitea_config().is_configured
+115
View File
@@ -0,0 +1,115 @@
# retoor <retoor@molodetz.nl>
import json
import logging
import re
from dataclasses import dataclass
import httpx
from devplacepy.config import INTERNAL_GATEWAY_URL
from devplacepy.services.gitea.config import HTTP_TIMEOUT_SECONDS, GiteaConfig
logger = logging.getLogger(__name__)
MAX_TOKENS = 1200
TITLE_MAX = 200
BODY_MAX = 60000
SYSTEM_PROMPT = (
"You are a senior triage engineer for DevPlace, a social network for software "
"developers. You rewrite raw user bug reports into clear, consistent, high-quality "
"issue tickets for a Gitea tracker. Preserve every concrete fact the reporter gave; "
"never invent versions, stack traces, or steps that were not provided. Keep a calm, "
"technical, professional tone. Do not add greetings or sign-offs.\n\n"
"Return ONLY a JSON object with exactly two string keys: \"title\" and \"body\".\n"
"- title: a concise, specific, imperative summary under 120 characters.\n"
"- body: GitHub-flavored markdown using EXACTLY these sections, in order, each as a "
"level-2 heading:\n"
"## Summary\n## Steps to Reproduce\n## Expected Behaviour\n## Actual Behaviour\n"
"## Environment\n"
"Under each heading put what the report supports; if a section is unknown write "
"'Not provided.' Use a numbered list under Steps to Reproduce."
)
@dataclass(frozen=True)
class EnhancedTicket:
title: str
body: str
enhanced: bool
def _fallback(title: str, description: str) -> EnhancedTicket:
body = (
f"## Summary\n{title}\n\n"
f"## Steps to Reproduce\n{description}\n\n"
"## Expected Behaviour\nNot provided.\n\n"
"## Actual Behaviour\nNot provided.\n\n"
"## Environment\nNot provided.\n"
)
return EnhancedTicket(title=title[:TITLE_MAX], body=body[:BODY_MAX], enhanced=False)
def _parse(text: str) -> tuple[str, str] | None:
match = re.search(r"\{.*\}", text, re.DOTALL)
if not match:
return None
try:
payload = json.loads(match.group())
except (ValueError, TypeError):
return None
title = str(payload.get("title", "")).strip()
body = str(payload.get("body", "")).strip()
if not title or not body:
return None
return title[:TITLE_MAX], body[:BODY_MAX]
async def enhance_ticket(
title: str, description: str, config: GiteaConfig
) -> EnhancedTicket:
title = title.strip()
description = description.strip()
if not config.ai_enhance:
return _fallback(title, description)
payload = {
"model": config.ai_model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": f"Reporter title: {title}\n\nReporter description:\n{description}",
},
],
"max_tokens": MAX_TOKENS,
"temperature": 0.2,
}
headers = {"Content-Type": "application/json"}
if config.ai_key:
headers["Authorization"] = f"Bearer {config.ai_key}"
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT_SECONDS) as client:
response = await client.post(
INTERNAL_GATEWAY_URL, json=payload, headers=headers
)
response.raise_for_status()
content = (
response.json()
.get("choices", [{}])[0]
.get("message", {})
.get("content", "")
)
except (httpx.HTTPError, ValueError, KeyError, IndexError) as exc:
logger.warning("Bug ticket enhancement failed, using fallback: %s", exc)
return _fallback(title, description)
parsed = _parse(content)
if not parsed:
logger.warning("Bug ticket enhancement returned unparseable content")
return _fallback(title, description)
new_title, new_body = parsed
logger.info("Enhanced bug ticket '%s' -> '%s'", title[:60], new_title[:60])
return EnhancedTicket(title=new_title, body=new_body, enhanced=True)
+114
View File
@@ -0,0 +1,114 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timezone
from devplacepy.services.gitea.client import (
STATE_ALL,
STATE_CLOSED,
STATE_OPEN,
GiteaError,
)
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
class FakeGiteaClient:
def __init__(self, bot_login: str = "devplace-bot"):
self.bot_login = bot_login
self._issues: dict[int, dict] = {}
self._comments: dict[int, list[dict]] = {}
self._issue_seq = 0
self._comment_seq = 0
def _html(self, number: int) -> str:
return f"https://gitea.test/retoor/pydevplace/issues/{number}"
async def create_issue(self, title: str, body: str) -> dict:
self._issue_seq += 1
number = self._issue_seq
now = _now()
issue = {
"number": number,
"title": title,
"body": body,
"state": STATE_OPEN,
"html_url": self._html(number),
"user": {"login": self.bot_login},
"comments": 0,
"created_at": now,
"updated_at": now,
"closed_at": None,
}
self._issues[number] = issue
self._comments[number] = []
return dict(issue)
async def list_issues(
self, state: str = STATE_OPEN, page: int = 1, limit: int = 20
) -> tuple[list[dict], int]:
rows = [
issue
for issue in self._issues.values()
if state == STATE_ALL or issue["state"] == state
]
rows.sort(key=lambda item: item["number"], reverse=True)
total = len(rows)
start = (max(1, page) - 1) * limit
return [dict(item) for item in rows[start : start + limit]], total
async def get_issue(self, number: int) -> dict:
issue = self._issues.get(number)
if not issue:
raise GiteaError("issue not found", status=404)
return dict(issue)
async def list_comments(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._comments.get(number, [])]
async def create_comment(self, number: int, body: str) -> dict:
if number not in self._issues:
raise GiteaError("issue not found", status=404)
self._comment_seq += 1
now = _now()
comment = {
"id": self._comment_seq,
"body": body,
"html_url": f"{self._html(number)}#comment-{self._comment_seq}",
"user": {"login": self.bot_login},
"created_at": now,
"updated_at": now,
}
self._comments[number].append(comment)
self._issues[number]["comments"] = len(self._comments[number])
return dict(comment)
async def set_state(self, number: int, state: str) -> dict:
issue = self._issues.get(number)
if not issue:
raise GiteaError("issue not found", status=404)
if state not in (STATE_OPEN, STATE_CLOSED):
raise GiteaError(f"invalid state: {state}", status=400)
issue["state"] = state
issue["closed_at"] = _now() if state == STATE_CLOSED else None
issue["updated_at"] = _now()
return dict(issue)
def add_external_comment(self, number: int, login: str, body: str) -> dict:
self._comment_seq += 1
now = _now()
comment = {
"id": self._comment_seq,
"body": body,
"html_url": f"{self._html(number)}#comment-{self._comment_seq}",
"user": {"login": login},
"created_at": now,
"updated_at": now,
}
self._comments.setdefault(number, []).append(comment)
if number in self._issues:
self._issues[number]["comments"] = len(self._comments[number])
return dict(comment)
+16
View File
@@ -0,0 +1,16 @@
# retoor <retoor@molodetz.nl>
_client = None
def get_client():
if _client is not None:
return _client
from devplacepy.services.gitea.client import GiteaClient
return GiteaClient()
def set_client(client) -> None:
global _client
_client = client
+108
View File
@@ -0,0 +1,108 @@
# retoor <retoor@molodetz.nl>
import logging
from devplacepy.services.base import BaseService
from devplacepy.services.gitea import runtime, store
from devplacepy.services.gitea.client import GiteaError
from devplacepy.services.gitea.config import CONFIG_FIELDS, gitea_config
from devplacepy.utils import create_notification
logger = logging.getLogger(__name__)
class BugTrackerService(BaseService):
interval_key = "bug_tracker_interval"
min_interval = 30
default_enabled = False
title = "Bug Tracker"
description = (
"Holds the Gitea connection and AI settings for the bug tracker and polls every "
"tracked issue for developer replies and status changes, notifying the original "
"reporter when their ticket is updated."
)
config_fields = CONFIG_FIELDS
def __init__(self):
super().__init__(name="bug_tracker", interval_seconds=120)
async def run_once(self) -> None:
config = gitea_config()
if not config.is_configured:
self.log("Gitea integration not configured; skipping poll")
return
client = runtime.get_client()
tickets = store.tracked_tickets()
self.log(f"Polling {len(tickets)} tracked tickets")
checked = 0
notified = 0
for ticket in tickets:
number = int(ticket["gitea_number"])
try:
issue = await client.get_issue(number)
except GiteaError as exc:
self.log(f"Could not poll #{number}: {exc}")
continue
checked += 1
notified += await self._reconcile(client, ticket, issue)
self.log(f"Polled {checked} tickets, sent {notified} notifications")
async def _reconcile(self, client, ticket: dict, issue: dict) -> int:
number = int(ticket["gitea_number"])
author_uid = ticket["author_uid"]
state = issue.get("state", "open")
comment_count = int(issue.get("comments", 0))
last_count = int(ticket.get("last_comment_count", 0))
last_status = ticket.get("last_status", "open")
notified = 0
if comment_count > last_count:
try:
comments = await client.list_comments(number)
except GiteaError as exc:
self.log(f"Could not load comments for #{number}: {exc}")
return notified
local_ids = store.local_comment_ids(number)
fresh = comments[last_count:]
external = [c for c in fresh if int(c.get("id", 0)) not in local_ids]
if external:
create_notification(
author_uid,
"bug",
f"New developer reply on your bug #{number}",
str(number),
f"/bugs/{number}",
)
self._audit("bug.sync.reply", author_uid, number)
notified += 1
if state != last_status:
verb = "closed" if state == "closed" else "reopened"
create_notification(
author_uid,
"bug",
f"Your bug #{number} was {verb}",
str(number),
f"/bugs/{number}",
)
self._audit("bug.sync.status", author_uid, number, {"state": state})
notified += 1
store.update_ticket_cache(number, state, comment_count)
return notified
def _audit(
self, event: str, author_uid: str, number: int, metadata: dict | None = None
) -> None:
from devplacepy.services.audit import record as audit
audit.record_system(
event,
actor_kind="service",
target_type="bug",
target_uid=str(number),
metadata={"number": number, **(metadata or {})},
summary=f"bug #{number} update detected",
links=[audit.recipient(author_uid)],
)
+116
View File
@@ -0,0 +1,116 @@
# retoor <retoor@molodetz.nl>
import logging
from datetime import datetime, timezone
from devplacepy.database import get_table
from devplacepy.utils import generate_uid
logger = logging.getLogger(__name__)
TICKETS = "bug_tickets"
COMMENT_AUTHORS = "bug_comment_authors"
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def record_ticket(
number: int,
author_uid: str,
original_title: str,
original_description: str,
enhanced_title: str,
html_url: str,
status: str,
) -> str:
uid = generate_uid()
get_table(TICKETS).insert(
{
"uid": uid,
"gitea_number": int(number),
"author_uid": author_uid,
"original_title": original_title[:200],
"original_description": original_description[:5000],
"enhanced_title": enhanced_title[:200],
"html_url": html_url,
"last_status": status,
"last_comment_count": 0,
"created_at": _now(),
"updated_at": _now(),
"deleted_at": None,
"deleted_by": None,
}
)
logger.info("Recorded bug ticket #%s by %s", number, author_uid)
return uid
def get_ticket(number: int) -> dict | None:
return get_table(TICKETS).find_one(gitea_number=int(number), deleted_at=None)
def author_uid_for_issue(number: int) -> str | None:
row = get_ticket(number)
return row["author_uid"] if row else None
def author_map(numbers: list[int]) -> dict[int, str]:
if not numbers:
return {}
table = get_table(TICKETS)
ids = [int(n) for n in numbers]
rows = table.find(table.table.columns.gitea_number.in_(ids), deleted_at=None)
return {int(row["gitea_number"]): row["author_uid"] for row in rows}
def tracked_tickets() -> list[dict]:
return list(get_table(TICKETS).find(deleted_at=None, order_by=["gitea_number"]))
def update_ticket_cache(number: int, status: str, comment_count: int) -> None:
row = get_ticket(number)
if not row:
return
get_table(TICKETS).update(
{
"uid": row["uid"],
"last_status": status,
"last_comment_count": int(comment_count),
"updated_at": _now(),
},
["uid"],
)
def record_comment_author(comment_id: int, number: int, author_uid: str) -> str:
uid = generate_uid()
get_table(COMMENT_AUTHORS).insert(
{
"uid": uid,
"gitea_comment_id": int(comment_id),
"gitea_number": int(number),
"author_uid": author_uid,
"created_at": _now(),
"deleted_at": None,
"deleted_by": None,
}
)
return uid
def comment_author_map(comment_ids: list[int]) -> dict[int, str]:
if not comment_ids:
return {}
table = get_table(COMMENT_AUTHORS)
ids = [int(cid) for cid in comment_ids]
rows = table.find(
table.table.columns.gitea_comment_id.in_(ids), deleted_at=None
)
return {int(row["gitea_comment_id"]): row["author_uid"] for row in rows}
def local_comment_ids(number: int) -> set[int]:
rows = get_table(COMMENT_AUTHORS).find(gitea_number=int(number), deleted_at=None)
return {int(row["gitea_comment_id"]) for row in rows}
@@ -0,0 +1,113 @@
# retoor <retoor@molodetz.nl>
import logging
from devplacepy.database import get_table
from devplacepy.services.gitea import runtime, store
from devplacepy.services.gitea.client import GiteaError
from devplacepy.services.gitea.config import gitea_config
from devplacepy.services.gitea.enhance import enhance_ticket
from devplacepy.services.jobs.base import JobService
from devplacepy.utils import create_notification
logger = logging.getLogger(__name__)
def _attribution(body: str, username: str) -> str:
return f"{body}\n\n---\n*Reported by **{username}** via DevPlace.*"
class BugCreateService(JobService):
kind = "bug_create"
title = "Bug filing"
description = (
"Files submitted bug reports off the request path: rewrites each report into a "
"consistent, high-quality ticket with the internal AI service, posts it to the "
"configured Gitea repository, records the local author mapping, and notifies the "
"reporter. The Gitea issue is permanent; only the job tracking row is swept."
)
def __init__(self):
super().__init__(name="bug_create", interval_seconds=2)
async def process(self, job: dict) -> dict:
payload = job["payload"]
author_uid = payload.get("author_uid", "")
title = (payload.get("title") or "").strip()
description = (payload.get("description") or "").strip()
from devplacepy.services.audit import record as audit
user = None
try:
user = get_table("users").find_one(uid=author_uid)
if not user:
raise ValueError(f"reporting user not found: {author_uid}")
if not title or not description:
raise ValueError("title and description are required")
config = gitea_config()
if not config.is_configured:
raise GiteaError("Gitea integration is not configured", status=503)
enhanced = await enhance_ticket(title, description, config)
body = _attribution(enhanced.body, user["username"])
issue = await runtime.get_client().create_issue(enhanced.title, body)
number = int(issue["number"])
html_url = issue.get("html_url", "")
store.record_ticket(
number=number,
author_uid=author_uid,
original_title=title,
original_description=description,
enhanced_title=enhanced.title,
html_url=html_url,
status=issue.get("state", "open"),
)
create_notification(
author_uid,
"bug",
f"Your bug report was filed as #{number}: {enhanced.title}",
str(number),
f"/bugs/{number}",
)
audit.record_system(
"bug.create",
actor_kind="user",
actor_uid=author_uid,
actor_username=user.get("username"),
origin="devplace",
target_type="bug",
target_uid=str(number),
target_label=enhanced.title,
metadata={"number": number, "enhanced": enhanced.enhanced, "url": html_url},
summary=f"{user.get('username')} filed bug #{number}: {enhanced.title}",
links=[audit.target("bug", str(number), enhanced.title), audit.job(job["uid"])],
)
self.log(f"Filed bug #{number} for {user['username']}")
return {
"number": number,
"html_url": html_url,
"bug_url": f"/bugs/{number}",
"enhanced": enhanced.enhanced,
}
except Exception:
audit.record_system(
"bug.create",
actor_kind="user",
actor_uid=author_uid,
actor_username=user.get("username") if user else None,
origin="devplace",
target_type="bug",
metadata={"title": title, "description": description[:200]},
result="failure",
summary=f"Bug filing failed for {user.get('username', 'unknown') if user else 'unknown'}: {title[:60]}",
links=[audit.job(job["uid"])],
)
raise
def cleanup(self, job: dict) -> None:
pass