forked from retoor/devplacepy
feat: replace local bug store with Gitea-backed issue tracker and add AI-enhanced filing
This commit is contained in:
@@ -127,6 +127,8 @@ SOFT_DELETE_TABLES = [
|
||||
"devii_virtual_tools",
|
||||
"user_customizations",
|
||||
"project_forks",
|
||||
"bug_tickets",
|
||||
"bug_comment_authors",
|
||||
]
|
||||
|
||||
|
||||
@@ -262,6 +264,50 @@ def init_db():
|
||||
|
||||
_index(db, "news_images", "idx_news_images_news_uid", ["news_uid"])
|
||||
_index(db, "news_sync", "idx_news_sync_external_id", ["external_id"])
|
||||
|
||||
bug_tickets = get_table("bug_tickets")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("gitea_number", 0),
|
||||
("author_uid", ""),
|
||||
("original_title", ""),
|
||||
("original_description", ""),
|
||||
("enhanced_title", ""),
|
||||
("html_url", ""),
|
||||
("last_status", ""),
|
||||
("last_comment_count", 0),
|
||||
("created_at", ""),
|
||||
("updated_at", ""),
|
||||
("deleted_at", ""),
|
||||
("deleted_by", ""),
|
||||
):
|
||||
if not bug_tickets.has_column(column):
|
||||
bug_tickets.create_column_by_example(column, example)
|
||||
|
||||
bug_comment_authors = get_table("bug_comment_authors")
|
||||
for column, example in (
|
||||
("uid", ""),
|
||||
("gitea_comment_id", 0),
|
||||
("gitea_number", 0),
|
||||
("author_uid", ""),
|
||||
("created_at", ""),
|
||||
("deleted_at", ""),
|
||||
("deleted_by", ""),
|
||||
):
|
||||
if not bug_comment_authors.has_column(column):
|
||||
bug_comment_authors.create_column_by_example(column, example)
|
||||
|
||||
_index(db, "bug_tickets", "idx_bug_tickets_number", ["gitea_number"])
|
||||
_index(db, "bug_tickets", "idx_bug_tickets_author", ["author_uid"])
|
||||
_index(
|
||||
db,
|
||||
"bug_comment_authors",
|
||||
"idx_bug_comment_authors_comment",
|
||||
["gitea_comment_id"],
|
||||
)
|
||||
_index(
|
||||
db, "bug_comment_authors", "idx_bug_comment_authors_number", ["gitea_number"]
|
||||
)
|
||||
_index(db, "service_state", "idx_service_state_name", ["name"])
|
||||
_index(
|
||||
db, "devii_conversations", "idx_devii_conv_owner", ["owner_kind", "owner_id"]
|
||||
|
||||
+103
-7
@@ -423,7 +423,6 @@ envelope to see validation errors as `{ "error": "validation", "fields": {...} }
|
||||
],
|
||||
},
|
||||
{
|
||||
"slug": "lookups",
|
||||
"slug": "lookups",
|
||||
"title": "Search & Lookups",
|
||||
"intro": """
|
||||
@@ -3198,8 +3197,11 @@ four ways to sign requests.
|
||||
"intro": """
|
||||
# Bug Reports
|
||||
|
||||
A public board for reporting issues. The list renders HTML; reporting uses form fields, and a
|
||||
report can carry files uploaded via [Uploads](/docs/uploads.html).
|
||||
The bug tracker is a full integration with a Gitea repository. The listing and detail views read
|
||||
issues straight from Gitea with their live status, and a report you file is first rewritten by the
|
||||
internal AI service into a consistent ticket, then posted to Gitea as an issue. The original
|
||||
reporter is notified when a developer replies or the status changes, and a comment posted here is
|
||||
pushed to Gitea and attributed to your account.
|
||||
|
||||
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
||||
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
|
||||
@@ -3210,19 +3212,31 @@ four ways to sign requests.
|
||||
id="bugs-list",
|
||||
method="GET",
|
||||
path="/bugs",
|
||||
title="View bug reports",
|
||||
summary="Render the bug board. Returns an HTML page.",
|
||||
title="List bug tickets",
|
||||
summary="Render the bug board from Gitea, paginated and filterable by state.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field(
|
||||
"state",
|
||||
"query",
|
||||
"string",
|
||||
False,
|
||||
"open",
|
||||
"Filter: open (default), closed, or all.",
|
||||
),
|
||||
field("page", "query", "integer", False, "1", "1-based page."),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="bugs-create",
|
||||
method="POST",
|
||||
path="/bugs/create",
|
||||
title="Report a bug",
|
||||
summary="File a bug report.",
|
||||
summary="Enqueue a bug report. It is enhanced by AI and filed on the tracker.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
ajax=True,
|
||||
destructive=True,
|
||||
params=[
|
||||
field(
|
||||
@@ -3242,6 +3256,86 @@ four ways to sign requests.
|
||||
"Description, 1-5000 characters.",
|
||||
),
|
||||
],
|
||||
notes=["Returns a job uid and status_url. Poll the status_url until status is done to get the bug number."],
|
||||
sample_response={
|
||||
"uid": "JOB_UID",
|
||||
"status_url": "/bugs/jobs/JOB_UID",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="bugs-job",
|
||||
method="GET",
|
||||
path="/bugs/jobs/{uid}",
|
||||
title="Bug filing job status",
|
||||
summary="Poll the filing job; the result carries the new bug number and url.",
|
||||
auth="public",
|
||||
ajax=True,
|
||||
params=[field("uid", "path", "string", True, "JOB_UID", "Job uid.")],
|
||||
sample_response={
|
||||
"uid": "JOB_UID",
|
||||
"kind": "bug_create",
|
||||
"status": "done",
|
||||
"number": 42,
|
||||
"bug_url": "/bugs/42",
|
||||
"enhanced": True,
|
||||
"error": None,
|
||||
"created_at": "2026-06-12T09:00:00+00:00",
|
||||
"completed_at": "2026-06-12T09:00:03+00:00",
|
||||
},
|
||||
),
|
||||
endpoint(
|
||||
id="bugs-detail",
|
||||
method="GET",
|
||||
path="/bugs/{number}",
|
||||
title="View a bug ticket",
|
||||
summary="Render a Gitea issue and its comments. Returns an HTML page.",
|
||||
auth="public",
|
||||
interactive=True,
|
||||
params=[
|
||||
field("number", "path", "integer", True, "12", "Bug number.")
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="bugs-comment",
|
||||
method="POST",
|
||||
path="/bugs/{number}/comment",
|
||||
title="Comment on a bug",
|
||||
summary="Post a comment to the Gitea issue, attributed to the current user.",
|
||||
auth="user",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("number", "path", "integer", True, "12", "Bug number."),
|
||||
field(
|
||||
"body",
|
||||
"form",
|
||||
"textarea",
|
||||
True,
|
||||
"I can reproduce this on mobile.",
|
||||
"Comment, 1-5000 characters.",
|
||||
),
|
||||
],
|
||||
),
|
||||
endpoint(
|
||||
id="bugs-status",
|
||||
method="POST",
|
||||
path="/bugs/{number}/status",
|
||||
title="Change a bug status",
|
||||
summary="Open or close the Gitea issue. Admin only.",
|
||||
auth="admin",
|
||||
encoding="form",
|
||||
destructive=True,
|
||||
params=[
|
||||
field("number", "path", "integer", True, "12", "Bug number."),
|
||||
field(
|
||||
"status",
|
||||
"form",
|
||||
"string",
|
||||
True,
|
||||
"closed",
|
||||
"New status: open or closed.",
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
},
|
||||
@@ -3838,6 +3932,7 @@ _PAGE_RESPONSES = {
|
||||
"messages-inbox": schemas.MessagesOut,
|
||||
"notifications-list": schemas.NotificationsOut,
|
||||
"bugs-list": schemas.BugsOut,
|
||||
"bugs-detail": schemas.BugDetailOut,
|
||||
"bookmarks-saved": schemas.SavedOut,
|
||||
"admin-users": schemas.AdminUsersOut,
|
||||
"admin-news-list": schemas.AdminNewsOut,
|
||||
@@ -3895,7 +3990,8 @@ _ACTION_RESPONSES = {
|
||||
"notifications-open": ("/posts/POST_SLUG#comment-COMMENT_UID", None),
|
||||
"notifications-mark-read": ("/notifications", None),
|
||||
"notifications-mark-all-read": ("/notifications", None),
|
||||
"bugs-create": ("/bugs", {"uid": "BUG_UID"}),
|
||||
"bugs-comment": ("/bugs/12", {"comment_id": 1}),
|
||||
"bugs-status": ("/bugs/12", {"state": "closed"}),
|
||||
"admin-user-role": ("/admin/users", None),
|
||||
"admin-user-password": ("/admin/users", None),
|
||||
"admin-user-toggle": ("/admin/users", None),
|
||||
|
||||
@@ -72,6 +72,8 @@ from devplacepy.services.openai_gateway import GatewayService
|
||||
from devplacepy.services.devii import DeviiService
|
||||
from devplacepy.services.jobs.zip_service import ZipService
|
||||
from devplacepy.services.jobs.fork_service import ForkService
|
||||
from devplacepy.services.jobs.bug_create_service import BugCreateService
|
||||
from devplacepy.services.gitea.service import BugTrackerService
|
||||
from devplacepy.services.containers.service import ContainerService
|
||||
from devplacepy.services.audit import AuditService
|
||||
from devplacepy.services.audit import record as audit
|
||||
@@ -407,6 +409,8 @@ async def startup():
|
||||
service_manager.register(DeviiService())
|
||||
service_manager.register(ZipService())
|
||||
service_manager.register(ForkService())
|
||||
service_manager.register(BugCreateService())
|
||||
service_manager.register(BugTrackerService())
|
||||
service_manager.register(ContainerService())
|
||||
service_manager.register(AuditService())
|
||||
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):
|
||||
|
||||
+8
-19
@@ -153,24 +153,6 @@ class PostEditForm(BaseModel):
|
||||
return normalize_poll_options(value)
|
||||
|
||||
|
||||
class PostEditForm(BaseModel):
|
||||
content: str = Field(min_length=10, max_length=125000)
|
||||
title: str = Field(default="", max_length=500)
|
||||
topic: str = "random"
|
||||
poll_question: str = Field(default="", max_length=200)
|
||||
poll_options: list[str] = []
|
||||
|
||||
@field_validator("topic")
|
||||
@classmethod
|
||||
def valid_topic(cls, value):
|
||||
return value if value in TOPICS else "random"
|
||||
|
||||
@field_validator("poll_options", mode="before")
|
||||
@classmethod
|
||||
def split_poll_options(cls, value):
|
||||
return normalize_poll_options(value)
|
||||
|
||||
|
||||
class CommentForm(BaseModel):
|
||||
content: str = Field(min_length=3, max_length=1000)
|
||||
target_uid: str = Field(default="", max_length=36)
|
||||
@@ -340,7 +322,14 @@ class GistEditForm(BaseModel):
|
||||
class BugForm(BaseModel):
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
description: str = Field(min_length=1, max_length=5000)
|
||||
attachment_uids: list[str] = []
|
||||
|
||||
|
||||
class BugCommentForm(BaseModel):
|
||||
body: str = Field(min_length=1, max_length=5000)
|
||||
|
||||
|
||||
class BugStatusForm(BaseModel):
|
||||
status: Literal["open", "closed"]
|
||||
|
||||
|
||||
class VoteForm(BaseModel):
|
||||
|
||||
+309
-72
@@ -2,110 +2,347 @@
|
||||
|
||||
import logging
|
||||
from typing import Annotated
|
||||
from datetime import datetime, timezone
|
||||
from fastapi import APIRouter, Request, Form
|
||||
from fastapi.responses import HTMLResponse
|
||||
from devplacepy.models import BugForm
|
||||
from devplacepy.database import get_table, load_comments_by_target_uids
|
||||
from devplacepy.attachments import get_attachments_batch, link_attachments
|
||||
from devplacepy.utils import (
|
||||
generate_uid,
|
||||
require_user,
|
||||
time_ago,
|
||||
get_current_user,
|
||||
create_mention_notifications,
|
||||
)
|
||||
|
||||
from fastapi import APIRouter, Form, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
|
||||
from devplacepy.database import build_pagination, get_table, get_users_by_uids
|
||||
from devplacepy.models import BugCommentForm, BugForm, BugStatusForm
|
||||
from devplacepy.responses import action_result, json_error, respond
|
||||
from devplacepy.schemas import BugDetailOut, BugJobOut, BugsOut
|
||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
||||
from devplacepy.responses import respond, action_result
|
||||
from devplacepy.schemas import BugsOut
|
||||
from devplacepy.services.audit import record as audit
|
||||
from devplacepy.services.gitea import runtime, store
|
||||
from devplacepy.services.gitea.client import (
|
||||
STATE_ALL,
|
||||
STATE_CLOSED,
|
||||
STATE_OPEN,
|
||||
GiteaError,
|
||||
)
|
||||
from devplacepy.services.gitea.config import gitea_config
|
||||
from devplacepy.services.jobs import queue
|
||||
from devplacepy.utils import (
|
||||
create_notification,
|
||||
get_current_user,
|
||||
is_admin,
|
||||
not_found,
|
||||
require_user,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
PER_PAGE = 20
|
||||
VALID_STATES = (STATE_OPEN, STATE_CLOSED, STATE_ALL)
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def bugs_page(request: Request):
|
||||
user = get_current_user(request)
|
||||
bugs_table = get_table("bug_reports")
|
||||
all_bugs = list(bugs_table.find(order_by=["-created_at"]))
|
||||
|
||||
from devplacepy.database import get_users_by_uids
|
||||
def _state(raw: str) -> str:
|
||||
return raw if raw in VALID_STATES else STATE_OPEN
|
||||
|
||||
uids = [b["user_uid"] for b in all_bugs]
|
||||
users_map = get_users_by_uids(uids)
|
||||
|
||||
bug_uids = [b["uid"] for b in all_bugs]
|
||||
attachments_map = get_attachments_batch("bug", bug_uids) if bug_uids else {}
|
||||
comments_map = load_comments_by_target_uids("bug", bug_uids, user) if bug_uids else {}
|
||||
bug_list = []
|
||||
for b in all_bugs:
|
||||
bug_list.append(
|
||||
{
|
||||
"bug": b,
|
||||
"author": users_map.get(b["user_uid"]),
|
||||
"time_ago": time_ago(b["created_at"]),
|
||||
"comments": comments_map.get(b["uid"], []),
|
||||
"attachments": attachments_map.get(b["uid"], []),
|
||||
}
|
||||
)
|
||||
def _author_fields(author_uid: str | None, users_map: dict, fallback_login: str) -> dict:
|
||||
user = users_map.get(author_uid) if author_uid else None
|
||||
if user:
|
||||
return {
|
||||
"author_username": user["username"],
|
||||
"author_uid": author_uid,
|
||||
"author_avatar_seed": user["username"],
|
||||
"is_local_author": True,
|
||||
}
|
||||
return {
|
||||
"author_username": fallback_login or "developer",
|
||||
"author_uid": None,
|
||||
"author_avatar_seed": None,
|
||||
"is_local_author": False,
|
||||
}
|
||||
|
||||
|
||||
def _issue_item(issue: dict, author_uid: str | None, users_map: dict) -> dict:
|
||||
login = (issue.get("user") or {}).get("login", "")
|
||||
return {
|
||||
"number": int(issue.get("number", 0)),
|
||||
"title": issue.get("title", ""),
|
||||
"state": issue.get("state", "open"),
|
||||
"html_url": issue.get("html_url"),
|
||||
"comments_count": int(issue.get("comments", 0)),
|
||||
"created_at": issue.get("created_at"),
|
||||
"updated_at": issue.get("updated_at"),
|
||||
**_author_fields(author_uid, users_map, login),
|
||||
}
|
||||
|
||||
|
||||
def _comment_item(comment: dict, author_uid: str | None, users_map: dict) -> dict:
|
||||
login = (comment.get("user") or {}).get("login", "")
|
||||
return {
|
||||
"id": int(comment.get("id", 0)),
|
||||
"body": comment.get("body", ""),
|
||||
"html_url": comment.get("html_url"),
|
||||
"created_at": comment.get("created_at"),
|
||||
**_author_fields(author_uid, users_map, login),
|
||||
}
|
||||
|
||||
|
||||
def _seo(request: Request, **extra) -> dict:
|
||||
base = site_url(request)
|
||||
seo_ctx = base_seo_context(
|
||||
return base_seo_context(
|
||||
request,
|
||||
title="Bug Reports",
|
||||
description="Report bugs and issues on DevPlace.",
|
||||
breadcrumbs=[
|
||||
{"name": "Home", "url": "/feed"},
|
||||
{"name": "Bug Reports", "url": "/bugs"},
|
||||
],
|
||||
schemas=[website_schema(base)],
|
||||
**extra,
|
||||
)
|
||||
return respond(
|
||||
|
||||
|
||||
@router.get("", response_class=HTMLResponse)
|
||||
async def bugs_page(request: Request, state: str = STATE_OPEN, page: int = 1):
|
||||
user = get_current_user(request)
|
||||
state = _state(state)
|
||||
page = max(1, page)
|
||||
seo_ctx = _seo(
|
||||
request,
|
||||
"bugs.html",
|
||||
{
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": user,
|
||||
"bugs": bug_list,
|
||||
},
|
||||
model=BugsOut,
|
||||
title="Bug Reports",
|
||||
description="Report bugs and track their progress on DevPlace.",
|
||||
)
|
||||
base_ctx = {
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": user,
|
||||
"state": state,
|
||||
"bugs": [],
|
||||
"pagination": None,
|
||||
"configured": True,
|
||||
"error_message": None,
|
||||
}
|
||||
|
||||
config = gitea_config()
|
||||
if not config.is_configured:
|
||||
base_ctx["configured"] = False
|
||||
base_ctx["error_message"] = "The bug tracker is not configured yet."
|
||||
return respond(request, "bugs.html", base_ctx, model=BugsOut)
|
||||
|
||||
try:
|
||||
issues, total = await runtime.get_client().list_issues(
|
||||
state=state, page=page, limit=PER_PAGE
|
||||
)
|
||||
except GiteaError as exc:
|
||||
logger.warning("Could not load bug list: %s", exc)
|
||||
base_ctx["error_message"] = "The bug tracker is temporarily unavailable."
|
||||
return respond(request, "bugs.html", base_ctx, model=BugsOut)
|
||||
|
||||
numbers = [int(issue.get("number", 0)) for issue in issues]
|
||||
author_by_number = store.author_map(numbers)
|
||||
users_map = get_users_by_uids([uid for uid in author_by_number.values() if uid])
|
||||
base_ctx["bugs"] = [
|
||||
_issue_item(issue, author_by_number.get(int(issue.get("number", 0))), users_map)
|
||||
for issue in issues
|
||||
]
|
||||
base_ctx["pagination"] = build_pagination(page, total, PER_PAGE)
|
||||
return respond(request, "bugs.html", base_ctx, model=BugsOut)
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_bug(request: Request, data: Annotated[BugForm, Form()]):
|
||||
user = require_user(request)
|
||||
if not gitea_config().is_configured:
|
||||
return json_error(503, "The bug tracker is not configured")
|
||||
title = data.title.strip()
|
||||
description = data.description.strip()
|
||||
|
||||
bugs_table = get_table("bug_reports")
|
||||
bug_uid = generate_uid()
|
||||
bugs_table.insert(
|
||||
uid = queue.enqueue(
|
||||
"bug_create",
|
||||
{
|
||||
"uid": bug_uid,
|
||||
"user_uid": user["uid"],
|
||||
"author_uid": user["uid"],
|
||||
"title": title,
|
||||
"description": description,
|
||||
"status": "open",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
"description": data.description.strip(),
|
||||
},
|
||||
"user",
|
||||
user["uid"],
|
||||
title[:64],
|
||||
)
|
||||
|
||||
if data.attachment_uids:
|
||||
link_attachments(data.attachment_uids, "bug", bug_uid)
|
||||
|
||||
create_mention_notifications(description, user["uid"], f"/bugs?highlight={bug_uid}")
|
||||
logger.info(f"Bug report created by {user['username']}: {title}")
|
||||
audit.record(
|
||||
request,
|
||||
"bug.create",
|
||||
"bug.create.request",
|
||||
user=user,
|
||||
target_type="bug",
|
||||
target_uid=bug_uid,
|
||||
target_label=title,
|
||||
summary=f"{user['username']} reported a bug: {title}",
|
||||
links=[audit.target("bug", bug_uid, title)],
|
||||
summary=f"{user['username']} requested a bug report: {title[:60]}",
|
||||
metadata={"title": title},
|
||||
links=[audit.job(uid)],
|
||||
)
|
||||
return action_result(request, "/bugs", data={"uid": bug_uid})
|
||||
logger.info("Bug report enqueued by %s: %s", user["username"], title[:60])
|
||||
return JSONResponse({"uid": uid, "status_url": f"/bugs/jobs/{uid}"})
|
||||
|
||||
|
||||
@router.get("/jobs/{uid}")
|
||||
async def bug_job_status(request: Request, uid: str):
|
||||
job = queue.get_job(uid)
|
||||
if not job or job.get("kind") != "bug_create":
|
||||
raise not_found("Job not found")
|
||||
result = job.get("result", {})
|
||||
done = job.get("status") == queue.DONE
|
||||
payload = {
|
||||
"uid": job.get("uid", ""),
|
||||
"kind": job.get("kind", ""),
|
||||
"status": job.get("status", ""),
|
||||
"number": result.get("number") if done else None,
|
||||
"bug_url": result.get("bug_url") if done else None,
|
||||
"enhanced": bool(result.get("enhanced")) if done else False,
|
||||
"error": job.get("error") or None,
|
||||
"created_at": job.get("created_at"),
|
||||
"completed_at": job.get("completed_at") or None,
|
||||
}
|
||||
return JSONResponse(BugJobOut.model_validate(payload).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/{number}", response_class=HTMLResponse)
|
||||
async def bug_detail(request: Request, number: int):
|
||||
user = get_current_user(request)
|
||||
if not gitea_config().is_configured:
|
||||
raise not_found("Bug not found")
|
||||
client = runtime.get_client()
|
||||
try:
|
||||
issue = await client.get_issue(number)
|
||||
comments = await client.list_comments(number)
|
||||
except GiteaError as exc:
|
||||
if exc.status != 404:
|
||||
logger.warning("Could not load bug #%s: %s", number, exc)
|
||||
raise not_found("Bug not found")
|
||||
|
||||
author_uid = store.author_uid_for_issue(number)
|
||||
comment_authors = store.comment_author_map(
|
||||
[int(comment.get("id", 0)) for comment in comments]
|
||||
)
|
||||
uids = [uid for uid in [author_uid, *comment_authors.values()] if uid]
|
||||
users_map = get_users_by_uids(uids)
|
||||
|
||||
bug = _issue_item(issue, author_uid, users_map)
|
||||
comment_items = [
|
||||
_comment_item(
|
||||
comment, comment_authors.get(int(comment.get("id", 0))), users_map
|
||||
)
|
||||
for comment in comments
|
||||
]
|
||||
seo_ctx = _seo(
|
||||
request,
|
||||
title=f"Bug #{number}: {issue.get('title', '')}",
|
||||
description=issue.get("title", ""),
|
||||
)
|
||||
return respond(
|
||||
request,
|
||||
"bug_detail.html",
|
||||
{
|
||||
**seo_ctx,
|
||||
"request": request,
|
||||
"user": user,
|
||||
"bug": bug,
|
||||
"body": issue.get("body", ""),
|
||||
"comments": comment_items,
|
||||
"can_comment": user is not None,
|
||||
"is_admin": is_admin(user),
|
||||
},
|
||||
model=BugDetailOut,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{number}/comment")
|
||||
async def comment_bug(
|
||||
request: Request, number: int, data: Annotated[BugCommentForm, Form()]
|
||||
):
|
||||
user = require_user(request)
|
||||
if not gitea_config().is_configured:
|
||||
return json_error(503, "The bug tracker is not configured")
|
||||
client = runtime.get_client()
|
||||
body = (
|
||||
f"{data.body.strip()}\n\n---\n"
|
||||
f"*Posted by **{user['username']}** via DevPlace.*"
|
||||
)
|
||||
try:
|
||||
comment = await client.create_comment(number, body)
|
||||
except GiteaError as exc:
|
||||
logger.warning("Could not post comment on #%s: %s", number, exc)
|
||||
audit.record(
|
||||
request,
|
||||
"bug.comment",
|
||||
user=user,
|
||||
result="failure",
|
||||
target_type="bug",
|
||||
target_uid=str(number),
|
||||
summary=f"Failed to comment on bug #{number}: {exc}",
|
||||
)
|
||||
return json_error(exc.status or 502, "Could not post the comment")
|
||||
|
||||
comment_id = int(comment.get("id", 0))
|
||||
store.record_comment_author(comment_id, number, user["uid"])
|
||||
_notify_admins(user, number)
|
||||
audit.record(
|
||||
request,
|
||||
"bug.comment",
|
||||
user=user,
|
||||
target_type="bug",
|
||||
target_uid=str(number),
|
||||
summary=f"{user['username']} commented on bug #{number}",
|
||||
metadata={"number": number, "comment_id": comment_id},
|
||||
links=[audit.target("bug", str(number))],
|
||||
)
|
||||
logger.info("Comment posted on bug #%s by %s", number, user["username"])
|
||||
return action_result(request, f"/bugs/{number}", data={"comment_id": comment_id})
|
||||
|
||||
|
||||
@router.post("/{number}/status")
|
||||
async def status_bug(
|
||||
request: Request, number: int, data: Annotated[BugStatusForm, Form()]
|
||||
):
|
||||
user = require_user(request)
|
||||
if not is_admin(user):
|
||||
audit.record(
|
||||
request,
|
||||
"bug.status",
|
||||
user=user,
|
||||
result="denied",
|
||||
target_type="bug",
|
||||
target_uid=str(number),
|
||||
summary=f"{user['username']} denied changing status of bug #{number}",
|
||||
)
|
||||
return json_error(403, "Admins only")
|
||||
if not gitea_config().is_configured:
|
||||
return json_error(503, "The bug tracker is not configured")
|
||||
try:
|
||||
await runtime.get_client().set_state(number, data.status)
|
||||
except GiteaError as exc:
|
||||
logger.warning("Could not change status of #%s: %s", number, exc)
|
||||
audit.record(
|
||||
request,
|
||||
"bug.status",
|
||||
user=user,
|
||||
result="failure",
|
||||
target_type="bug",
|
||||
target_uid=str(number),
|
||||
summary=f"Failed to change status of bug #{number}: {exc}",
|
||||
)
|
||||
return json_error(exc.status or 502, "Could not change the status")
|
||||
|
||||
audit.record(
|
||||
request,
|
||||
"bug.status",
|
||||
user=user,
|
||||
target_type="bug",
|
||||
target_uid=str(number),
|
||||
new_value=data.status,
|
||||
summary=f"{user['username']} set bug #{number} to {data.status}",
|
||||
metadata={"number": number, "state": data.status},
|
||||
links=[audit.target("bug", str(number))],
|
||||
)
|
||||
logger.info("Bug #%s set to %s by %s", number, data.status, user["username"])
|
||||
return action_result(request, f"/bugs/{number}", data={"state": data.status})
|
||||
|
||||
|
||||
def _notify_admins(actor: dict, number: int) -> None:
|
||||
for admin in get_table("users").find(role="admin"):
|
||||
if admin["uid"] == actor["uid"]:
|
||||
continue
|
||||
create_notification(
|
||||
admin["uid"],
|
||||
"bug",
|
||||
f"{actor['username']} commented on bug #{number}",
|
||||
str(number),
|
||||
f"/bugs/{number}",
|
||||
)
|
||||
|
||||
+46
-13
@@ -281,14 +281,6 @@ class MessageOut(_Out):
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
class BugOut(_Out):
|
||||
uid: str = ""
|
||||
user_uid: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
|
||||
|
||||
# ---------- list-item wrappers ----------
|
||||
|
||||
@@ -353,11 +345,48 @@ class NotificationGroupOut(_Out):
|
||||
|
||||
|
||||
class BugItemOut(_Out):
|
||||
bug: BugOut
|
||||
author: Optional[UserOut] = None
|
||||
time_ago: Optional[str] = None
|
||||
comments: list[CommentItemOut] = []
|
||||
attachments: list[AttachmentOut] = []
|
||||
number: int = 0
|
||||
title: str = ""
|
||||
state: str = "open"
|
||||
html_url: Optional[str] = None
|
||||
comments_count: int = 0
|
||||
created_at: Optional[str] = None
|
||||
updated_at: Optional[str] = None
|
||||
author_username: Optional[str] = None
|
||||
author_uid: Optional[str] = None
|
||||
author_avatar_seed: Optional[str] = None
|
||||
is_local_author: bool = False
|
||||
|
||||
|
||||
class BugCommentOut(_Out):
|
||||
id: int = 0
|
||||
body: str = ""
|
||||
html_url: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
author_username: Optional[str] = None
|
||||
author_uid: Optional[str] = None
|
||||
author_avatar_seed: Optional[str] = None
|
||||
is_local_author: bool = False
|
||||
|
||||
|
||||
class BugDetailOut(_Out):
|
||||
bug: BugItemOut
|
||||
body: str = ""
|
||||
comments: list[BugCommentOut] = []
|
||||
can_comment: bool = False
|
||||
is_admin: bool = False
|
||||
|
||||
|
||||
class BugJobOut(_Out):
|
||||
uid: str = ""
|
||||
kind: str = ""
|
||||
status: str = ""
|
||||
number: Optional[int] = None
|
||||
bug_url: Optional[str] = None
|
||||
enhanced: bool = False
|
||||
error: Optional[str] = None
|
||||
created_at: Optional[str] = None
|
||||
completed_at: Optional[str] = None
|
||||
|
||||
|
||||
class LeaderboardEntryOut(_Out):
|
||||
@@ -563,6 +592,10 @@ class LeaderboardOut(_Out):
|
||||
|
||||
class BugsOut(_Out):
|
||||
bugs: list[BugItemOut] = []
|
||||
pagination: Optional[Any] = None
|
||||
state: str = "open"
|
||||
configured: bool = True
|
||||
error_message: Optional[str] = None
|
||||
|
||||
|
||||
class SavedOut(_Out):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)],
|
||||
)
|
||||
@@ -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
|
||||
@@ -14,12 +14,45 @@
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.bugs-filters {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.bugs-filter {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
padding: 0.35rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border);
|
||||
text-decoration: none;
|
||||
}
|
||||
.bugs-filter:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.bugs-filter.active {
|
||||
background: var(--accent);
|
||||
color: var(--white);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.bugs-modal-hint {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.bug-card {
|
||||
display: block;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
text-decoration: none;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
.bug-card:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.bug-card-header {
|
||||
display: flex;
|
||||
@@ -27,6 +60,14 @@
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.bug-number {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.bug-title {
|
||||
flex: 1;
|
||||
}
|
||||
.bug-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
@@ -55,9 +96,100 @@
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.bug-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.bug-dot {
|
||||
color: var(--border);
|
||||
}
|
||||
|
||||
.bug-detail-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.bug-back,
|
||||
.bug-gitea-link {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.bug-detail-card {
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.bug-detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.bug-detail-title {
|
||||
flex: 1;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
.bug-admin-actions {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
.bug-detail-body {
|
||||
margin-top: 1rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.bug-comment {
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1rem 1.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.bug-comment-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.bug-comment-badge {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-light);
|
||||
color: var(--accent);
|
||||
}
|
||||
.bug-comment-body {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.bug-comment-form {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.bug-comment-form textarea {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.75rem;
|
||||
font: inherit;
|
||||
background: var(--bg-input);
|
||||
color: var(--text-primary);
|
||||
resize: vertical;
|
||||
}
|
||||
.bug-comment-form-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.bugs-header {
|
||||
|
||||
@@ -25,6 +25,7 @@ import { Tabs } from "./Tabs.js";
|
||||
import { DeviiTerminal } from "./DeviiTerminal.js";
|
||||
import { ZipDownloader } from "./ZipDownloader.js";
|
||||
import { ProjectForker } from "./ProjectForker.js";
|
||||
import { BugReporter } from "./BugReporter.js";
|
||||
import { MediaGallery } from "./MediaGallery.js";
|
||||
import WindowManager from "./components/WindowManager.js";
|
||||
import { ContainerTerminalManager } from "./ContainerTerminalManager.js";
|
||||
@@ -62,6 +63,7 @@ class Application {
|
||||
this.devii = new DeviiTerminal();
|
||||
this.zipDownloader = new ZipDownloader();
|
||||
this.projectForker = new ProjectForker();
|
||||
this.bugReporter = new BugReporter();
|
||||
this.mediaGallery = new MediaGallery();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// retoor <retoor@molodetz.nl>
|
||||
|
||||
import { Http } from "./Http.js";
|
||||
import { JobPoller } from "./JobPoller.js";
|
||||
|
||||
export class BugReporter {
|
||||
constructor() {
|
||||
this.active = false;
|
||||
document.addEventListener("submit", (event) => {
|
||||
const form = event.target.closest("form[data-bug-create]");
|
||||
if (!form) return;
|
||||
event.preventDefault();
|
||||
this.submit(form);
|
||||
});
|
||||
}
|
||||
|
||||
async submit(form) {
|
||||
if (this.active) return;
|
||||
const title = form.querySelector("[name='title']").value.trim();
|
||||
const description = form.querySelector("[name='description']").value.trim();
|
||||
if (!title || !description) {
|
||||
window.app.toast.show("Title and description are required", { type: "error" });
|
||||
return;
|
||||
}
|
||||
this.active = true;
|
||||
const button = form.querySelector("button[type='submit']");
|
||||
if (button) button.disabled = true;
|
||||
window.app.toast.show("Filing your report and improving it with AI...", {
|
||||
type: "info",
|
||||
ms: 5000,
|
||||
});
|
||||
try {
|
||||
const job = await Http.sendForm(form.action, { title, description });
|
||||
await this.poll(job.status_url);
|
||||
} catch (error) {
|
||||
window.app.toast.show("Could not submit the report", { type: "error" });
|
||||
} finally {
|
||||
this.active = false;
|
||||
if (button) button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
poll(statusUrl) {
|
||||
return JobPoller.run(statusUrl, {
|
||||
onDone: (status) => {
|
||||
window.app.toast.show("Bug report filed", { type: "success" });
|
||||
window.location.href = status.bug_url || "/bugs";
|
||||
},
|
||||
onFailed: (status) => {
|
||||
window.app.toast.show(
|
||||
"Report failed: " + (status.error || "unknown error"),
|
||||
{ type: "error" },
|
||||
);
|
||||
},
|
||||
onTimeout: () =>
|
||||
window.app.toast.show("Report is still processing", { type: "info" }),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
{% extends "base.html" %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/bugs.css">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="bugs-layout bug-detail">
|
||||
<div class="bug-detail-nav">
|
||||
<a href="/bugs" class="bug-back">← All bugs</a>
|
||||
{% if bug.html_url %}<a href="{{ bug.html_url }}" class="bug-gitea-link" target="_blank" rel="noopener noreferrer">View on Gitea</a>{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card bug-detail-card">
|
||||
<div class="bug-detail-header">
|
||||
<span class="bug-number">#{{ bug.number }}</span>
|
||||
<h1 class="bug-detail-title">{{ bug.title }}</h1>
|
||||
<span class="bug-status {{ bug.state }}">{{ bug.state }}</span>
|
||||
</div>
|
||||
<div class="bug-meta">
|
||||
{% if bug.is_local_author %}
|
||||
{% set _user = {'username': bug.author_username} %}{% set _size = 24 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
|
||||
{% endif %}
|
||||
<span class="bug-author">{{ bug.author_username }}</span>
|
||||
<span class="bug-dot">·</span>
|
||||
<span class="bug-date">{{ format_date(bug.created_at, true) }}</span>
|
||||
</div>
|
||||
|
||||
{% if is_admin %}
|
||||
<div class="bug-admin-actions">
|
||||
{% if bug.state == 'open' %}
|
||||
<form method="POST" action="/bugs/{{ bug.number }}/status">
|
||||
<input type="hidden" name="status" value="closed">
|
||||
<button type="submit" class="btn btn-secondary btn-sm">Close ticket</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="POST" action="/bugs/{{ bug.number }}/status">
|
||||
<input type="hidden" name="status" value="open">
|
||||
<button type="submit" class="btn btn-secondary btn-sm">Reopen ticket</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="bug-detail-body rendered-content" data-render>{{ body }}</div>
|
||||
</div>
|
||||
|
||||
<section class="comments-section">
|
||||
<h3>Updates & comments</h3>
|
||||
{% for comment in comments %}
|
||||
<div class="bug-comment">
|
||||
<div class="bug-comment-head">
|
||||
{% if comment.is_local_author %}
|
||||
{% set _user = {'username': comment.author_username} %}{% set _size = 20 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
|
||||
{% else %}
|
||||
<span class="bug-comment-badge">dev</span>
|
||||
{% endif %}
|
||||
<span class="bug-author">{{ comment.author_username }}</span>
|
||||
<span class="bug-dot">·</span>
|
||||
<span class="bug-date">{{ format_date(comment.created_at, true) }}</span>
|
||||
</div>
|
||||
<div class="bug-comment-body rendered-content" data-render>{{ comment.body }}</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="no-comments-msg">No updates yet.</p>
|
||||
{% endfor %}
|
||||
|
||||
{% if can_comment %}
|
||||
<form method="POST" action="/bugs/{{ bug.number }}/comment" class="comment-form bug-comment-form">
|
||||
<textarea name="body" required maxlength="5000" placeholder="Add a comment (posted to the tracker)..." class="min-h-120"></textarea>
|
||||
<div class="bug-comment-form-footer">
|
||||
<button type="submit" class="btn btn-primary btn-sm">Comment</button>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="no-comments-msg"><a href="/auth/login">Log in</a> to comment.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -1,8 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import modal %}
|
||||
{% block extra_head %}
|
||||
<link rel="stylesheet" href="/static/css/feed.css">
|
||||
<link rel="stylesheet" href="/static/css/post.css">
|
||||
<link rel="stylesheet" href="/static/css/bugs.css">
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
@@ -16,46 +14,56 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="bugs-filters">
|
||||
<a href="?state=open" class="bugs-filter {% if state == 'open' %}active{% endif %}">Open</a>
|
||||
<a href="?state=closed" class="bugs-filter {% if state == 'closed' %}active{% endif %}">Closed</a>
|
||||
<a href="?state=all" class="bugs-filter {% if state == 'all' %}active{% endif %}">All</a>
|
||||
</div>
|
||||
|
||||
{% if not configured or error_message %}
|
||||
<div class="empty-state">{{ error_message }}</div>
|
||||
{% else %}
|
||||
<div class="bugs-list">
|
||||
{% for item in bugs %}
|
||||
<div class="bug-card">
|
||||
{% for bug in bugs %}
|
||||
<a class="bug-card" href="/bugs/{{ bug.number }}">
|
||||
<div class="bug-card-header">
|
||||
{% set _user = item.author %}{% set _size = 24 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
|
||||
<span class="bug-title">{{ item.bug['title'] }}</span>
|
||||
<span class="bug-status {{ item.bug['status'] }}">{{ item.bug['status'] }}</span>
|
||||
<span class="bug-number">#{{ bug.number }}</span>
|
||||
<span class="bug-title">{{ bug.title }}</span>
|
||||
<span class="bug-status {{ bug.state }}">{{ bug.state }}</span>
|
||||
</div>
|
||||
<div class="bug-desc">{{ item.bug['description'][:300] }}</div>
|
||||
<div class="bug-meta">{{ item.author['username'] if item.author else 'Unknown' }} · {{ item.time_ago }}</div>
|
||||
|
||||
{% set b_attachments = item.get('attachments', []) %}
|
||||
{% if b_attachments %}
|
||||
{% with attachments=b_attachments %}
|
||||
{% include "_attachment_display.html" %}
|
||||
{% endwith %}
|
||||
{% endif %}
|
||||
|
||||
{% with target_uid=item.bug['uid'], target_type="bug", comments=item.comments %}
|
||||
{% include "_comment_section.html" %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<div class="bug-meta">
|
||||
{% if bug.is_local_author %}
|
||||
{% set _user = {'username': bug.author_username} %}{% set _size = 20 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
|
||||
{% endif %}
|
||||
<span class="bug-author">{{ bug.author_username }}</span>
|
||||
<span class="bug-dot">·</span>
|
||||
<span class="bug-date">{{ format_date(bug.created_at) }}</span>
|
||||
<span class="bug-dot">·</span>
|
||||
<span class="bug-comments">{{ bug.comments_count }} comment{{ '' if bug.comments_count == 1 else 's' }}</span>
|
||||
</div>
|
||||
</a>
|
||||
{% else %}
|
||||
<div class="empty-state">No bug reports yet.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% set pagination_query = 'state=' ~ state ~ '&' %}
|
||||
{% include "_pagination.html" %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if user %}
|
||||
{% call modal('create-bug-modal', 'Report a Bug') %}
|
||||
<form method="POST" action="/bugs/create">
|
||||
<form method="POST" action="/bugs/create" data-bug-create>
|
||||
<p class="bugs-modal-hint">Your report is improved by AI into a consistent ticket and filed on the tracker.</p>
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label for="bug-title">Title</label>
|
||||
<input type="text" id="bug-title" name="title" required maxlength="200" placeholder="Brief description of the issue">
|
||||
</div>
|
||||
<div class="auth-field auth-field-gap">
|
||||
<label for="bug-description">Description</label>
|
||||
<textarea id="bug-description" name="description" required maxlength="5000" placeholder="Detailed steps to reproduce..." class="min-h-120" data-mention></textarea>
|
||||
<textarea id="bug-description" name="description" required maxlength="5000" placeholder="Steps to reproduce, expected vs actual, environment..." class="min-h-120"></textarea>
|
||||
</div>
|
||||
{% include "_attachment_form.html" %}
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary"><span class="icon">📤</span> Submit Report</button>
|
||||
|
||||
Reference in New Issue
Block a user