From 2d84ebdc2f089fb3363f73f5ebf1d027359ee6a7 Mon Sep 17 00:00:00 2001 From: retoor Date: Fri, 12 Jun 2026 18:31:40 +0000 Subject: [PATCH] feat: implement Gitea integration for repository management Add full Gitea API client with support for creating, listing, and managing repositories, including authentication via personal access tokens and webhook configuration for automated CI/CD triggers. --- AGENTS.md | 19 +- CLAUDE.md | 5 +- README.md | 2 +- devplacepy/database.py | 46 +++ devplacepy/docs_api.py | 110 ++++- devplacepy/main.py | 4 + devplacepy/models.py | 27 +- devplacepy/routers/bugs.py | 381 ++++++++++++++---- devplacepy/schemas.py | 59 ++- devplacepy/services/devii/actions/catalog.py | 55 ++- devplacepy/services/gitea/__init__.py | 1 + devplacepy/services/gitea/client.py | 131 ++++++ devplacepy/services/gitea/config.py | 114 ++++++ devplacepy/services/gitea/enhance.py | 115 ++++++ devplacepy/services/gitea/fake.py | 114 ++++++ devplacepy/services/gitea/runtime.py | 16 + devplacepy/services/gitea/service.py | 108 +++++ devplacepy/services/gitea/store.py | 116 ++++++ .../services/jobs/bug_create_service.py | 113 ++++++ devplacepy/static/css/bugs.css | 132 ++++++ devplacepy/static/js/Application.js | 2 + devplacepy/static/js/BugReporter.js | 59 +++ devplacepy/templates/bug_detail.html | 78 ++++ devplacepy/templates/bugs.html | 56 +-- tests/test_bugs.py | 128 ++---- tests/test_bugs_gitea.py | 225 +++++++++++ 26 files changed, 1981 insertions(+), 235 deletions(-) create mode 100644 devplacepy/services/gitea/__init__.py create mode 100644 devplacepy/services/gitea/client.py create mode 100644 devplacepy/services/gitea/config.py create mode 100644 devplacepy/services/gitea/enhance.py create mode 100644 devplacepy/services/gitea/fake.py create mode 100644 devplacepy/services/gitea/runtime.py create mode 100644 devplacepy/services/gitea/service.py create mode 100644 devplacepy/services/gitea/store.py create mode 100644 devplacepy/services/jobs/bug_create_service.py create mode 100644 devplacepy/static/js/BugReporter.js create mode 100644 devplacepy/templates/bug_detail.html create mode 100644 tests/test_bugs_gitea.py diff --git a/AGENTS.md b/AGENTS.md index cb558e3a..c9dca3f1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -900,9 +900,24 @@ Two owner-controlled flags on the `projects` row, both integer `0`/`1` (default `confirmation_error` also blocks every Devii deletion until `confirm` is truthy. Unconditional entries in `CONFIRM_REQUIRED`: every content delete - `delete_post`, `delete_comment`, `delete_gist`, `delete_project`, `project_delete_file`, `delete_media`, `delete_attachment`, `admin_delete_news` - plus `project_set_readonly` and the customization mutations; a generic fallback message covers any name in the set that lacks a bespoke message. **Load-bearing invariant: every gated tool MUST declare a `confirm` body param** (the `confirm()` helper in `catalog.py`, or `arg("confirm", ..., kind="boolean")` for container actions). Tool schemas are `additionalProperties: false`, so the model can only send arguments that are declared properties - a gated tool with NO `confirm` param can never be confirmed, so `confirmation_error` refuses every call and the agent loops forever asking for a confirmation it has no way to express. This was a real production bug: an admin asked Devii to delete a user's 27 posts, confirmed repeatedly, and all 27 `delete_post` calls were rejected because `delete_post` (and `delete_project`/`delete_media`/`project_delete_file`/`container_*`) never exposed `confirm`. The param is harmlessly forwarded to the HTTP endpoint (routes do not declare it, FastAPI drops undeclared form fields) and ignored by the LOCAL container/customization controllers. Conditional entries (gated by inspecting arguments, not membership): `container_instance_action` only when `action="delete"` (start/stop/restart/pause/resume/sync are unaffected), and `container_exec` only when its `command` matches `dispatcher.DESTRUCTIVE_COMMAND` - a token-aware regex for `rm`/`rmdir`/`unlink`/`shred`/`truncate`/`dd`/`mkfs*`/`wipefs` (with or without a leading `sudo`), `find ... -delete`, redirection over a file (`> /...`), and SQL `drop table|database` / `delete from`. Benign commands (`pip install`, `ls`, `grep -rm`) are not matched. The error message echoes the exact path/command so the agent shows the user what will be removed; after a clear yes it retries with `confirm=true`. The `DELETING IS ALWAYS CONFIRMED` system-prompt section in `agent.py` mirrors the rule. This exists because an unconfirmed `container_exec rm -f` once deleted a live project database mid-"test the site"; the gate is the data-loss backstop. The regex is a heuristic (it cannot catch `python -c "os.remove(...)"` and similar), so it is defense-in-depth, not a sandbox - the system-prompt rule is the primary control. -## Bug Reports +## Bug Reports (Gitea integration) -A dedicated `/bugs` page with create modal for authenticated users. Uses `bug_reports` table (auto-created by `dataset`). Registered in `main.py` with prefix `/bugs`. Footer link in `base.html` under `.site-footer`. +The bug tracker is a full integration with a Gitea repository (default `retoor/pydevplace` on `retoor.molodetz.nl`); there is **no local issue store**, the listing and detail views read issues straight from Gitea with live status. All connection settings live in `site_settings` and are admin-editable on the `BugTrackerService` config at `/admin/services`. + +**Package `services/gitea/`:** +- `config.py` - the `CONFIG_FIELDS` (Gitea base URL/owner/repo/token, AI enhance toggle/model/key) plus `gitea_config()` -> frozen `GiteaConfig` (with `api_base`/`repo_base`/`is_configured`) and `is_configured()`. The token is one shared bot account; per-user attribution is done by storing names in DevPlace, not by per-user Gitea accounts. +- `client.py` - async `GiteaClient` over the Gitea REST API (`Authorization: token`): `create_issue`, `list_issues(state,page,limit)` returning `(issues, total)` from `X-Total-Count` and filtering out pull requests, `get_issue`, `list_comments`, `create_comment`, `set_state`. Raises `GiteaError(message, status)` on any non-2xx or transport error. +- `fake.py` - in-memory `FakeGiteaClient` mirroring the same async interface (plus `add_external_comment` to simulate a developer reply); the test backend. +- `runtime.py` - `get_client()` / `set_client()` swap (tests inject the fake). `get_client()` builds a fresh `GiteaClient` from current config unless overridden. +- `enhance.py` - `enhance_ticket(title, description, config)` calls the **internal AI gateway** (`INTERNAL_GATEWAY_URL`, key defaults to the gateway internal key) to rewrite a raw report into a consistent ticket: a level-2-heading markdown body (`Summary` / `Steps to Reproduce` / `Expected Behaviour` / `Actual Behaviour` / `Environment`) and a tightened title. It is fail-soft: any error or unparseable response falls back to a deterministic template built from the original text (`EnhancedTicket.enhanced=False`). This is the AI attachment point - the gateway, like every other AI consumer. +- `store.py` - the **thin local mapping** (the only DB state): `bug_tickets` (gitea_number -> author_uid + original text + cached `last_status`/`last_comment_count` for update detection) and `bug_comment_authors` (gitea_comment_id -> author_uid, for display attribution and to tell DevPlace-authored comments from developer replies). Helpers: `record_ticket`, `get_ticket`, `author_uid_for_issue`, `author_map`, `record_comment_author`, `comment_author_map`, `local_comment_ids`, `tracked_tickets`, `update_ticket_cache`. Both tables are in `SOFT_DELETE_TABLES`; `init_db` ensures their full column set + indexes (so queries are safe on a fresh DB before any insert). +- `service.py` - `BugTrackerService(BaseService)` (`default_enabled=False`, holds `CONFIG_FIELDS`). Each tick it polls every tracked ticket: when the Gitea comment count grew, it loads comments and notifies the reporter if any of the new tail comments are **not** in `local_comment_ids` (a developer reply); when the state changed it notifies the reporter (closed/reopened). It then updates the cache. This is the single source of update notifications to the author (`bug.sync.reply` / `bug.sync.status`). + +**Filing is an async job** (`services/jobs/bug_create_service.py` `BugCreateService`, kind `bug_create`): `process` loads the user, enhances the report, appends a `Reported by **user** via DevPlace` footer, creates the Gitea issue, records the local mapping, notifies the reporter (`Your bug report was filed as #N`), and audits `bug.create`. `cleanup` is a no-op (the issue is permanent; only the job row is swept). `POST /bugs/create` enqueues and returns `{uid, status_url}`; the frontend `app.bugReporter` (`static/js/BugReporter.js`) submits the create form, polls `/bugs/jobs/{uid}` via `JobPoller`, and redirects to `/bugs/{number}`. + +**Routes (`routers/bugs.py`, prefix `/bugs`):** `GET /bugs` (Gitea list, `?state=open|closed|all&page=`, admin-style `build_pagination` + `_pagination.html` with a `state=` prefix; renders an empty-state notice when unconfigured/unreachable), `POST /bugs/create` (enqueue), `GET /bugs/jobs/{uid}` (`BugJobOut`), `GET /bugs/{number}` (`bug_detail.html`: issue body + comments rendered as markdown via `data-render`, decorated with DevPlace authors or a `dev` badge), `POST /bugs/{number}/comment` (synchronous - posts the comment to Gitea attributed to the user, records the author mapping, notifies **all admins**, audits `bug.comment`), `POST /bugs/{number}/status` (admin-only open/closed via Gitea PATCH, audits `bug.status`; non-admins get a `denied` audit + 403). Comments and status changes are deliberately synchronous (one fast Gitea call, immediate redirect); only the AI-heavy create is a job. Status-change notifications to the author are NOT emitted by the route - the poller detects the diff and notifies, so the admin (or external developer) closing an issue both flow through one path. + +Schemas: `BugItemOut`/`BugCommentOut`/`BugDetailOut`/`BugsOut`/`BugJobOut` (`schemas.py`). Forms: `BugForm`/`BugCommentForm`/`BugStatusForm` (`models.py`). Devii tools: `list_bugs`, `create_bug`, `view_bug`, `comment_bug`, `set_bug_status` (admin). Docs: the `bugs` API group in `docs_api.py`. Footer link in `base.html` under `.site-footer`. ## Gists diff --git a/CLAUDE.md b/CLAUDE.md index 8ad835e2..7787866d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ One file per domain in `devplacepy/routers/`. Prefixes are wired in `main.py`: | `/follow` | follow.py | | `/admin` | admin.py | | `/admin/services` | services.py | -| `/bugs` | bugs.py | +| `/bugs` | bugs.py - bug tracker backed by Gitea (no local issue store): list (`?state=`/`?page=`), detail (`/{number}` with comments), async AI-enhanced filing (`/create` enqueues a `bug_create` job, status at `/jobs/{uid}`), `/{number}/comment` (synchronous, pushes to Gitea + notifies admins), `/{number}/status` (admin open/closed) | | `/gists` | gists.py | | `/news` | news.py | | `/uploads` | uploads.py | @@ -137,6 +137,9 @@ To add a service: extend `BaseService`, override `async def run_once(self) -> No ### Async job services (`devplacepy/services/jobs/`) The **standard** way to run heavy, blocking work off the request path and return a result URL. A shared `jobs` table is the queue (discriminated by a `kind` column; "default" fields plus `payload`/`result` JSON, lifecycle timestamps, `retry_count`, `last_accessed_at`/`expires_at`, and `bytes_in`/`bytes_out`/`item_count` stat columns). `services/jobs/queue.py` (`enqueue`, `get_job`, `touch_job`, `list_jobs`) is pure DB and callable from **any** worker - the row is the queue. `JobService(BaseService)` runs only in the lock owner; each `run_once` reaps finished in-flight tasks, recovers orphaned `running` rows (left by a dead owner) back to `pending` with bounded `retry_count`, refills up to `max_concurrent` oldest pending jobs (uuid7 = FIFO) as `asyncio` tasks, and **sweeps expired artifacts** via the per-kind `cleanup()` hook (retention is built in, default 7 days, admin-configurable; no separate reaper). A new kind subclasses `JobService`, sets `kind`, and implements `async process(self, job) -> dict` and `cleanup(self, job)`. **`ZipService`** (kind `zip`) is the first consumer: `process` materializes a project subtree via `project_files.export_to_dir` (staging under `config.DATA_DIR/zip_staging/{uid}`), compresses it in a **subprocess** (`python -m devplacepy.services.jobs.zip_worker`, stdlib-only, prints stats JSON incl. crc32), names the output `{crc32}.{slug}.zip` under `config.DATA_DIR/zips/{uid[:2]}/{uid[2:4]}/`, and returns stats. (Runtime artifacts live in `DATA_DIR` = `var/` by default, OUTSIDE the package and NOT under `/static`; the download is served by the `/zips/{uid}/download` route via `FileResponse`, not the static mount.) Enqueue endpoints own authz (`POST /projects/{slug}/zip`, `POST /projects/{slug}/files/zip?path=`); `GET /zips/{uid}` (status `ZipJobOut`) and `GET /zips/{uid}/download` (FileResponse, extends expiry) are **capability URLs** scoped only by the unguessable uuid7, not by owner. Frontend `app.zipDownloader` (`static/js/ZipDownloader.js`) auto-wires any `data-zip-download` element plus the files context menu: POST -> poll `/zips/{uid}` -> trigger download. Devii tools `zip_project`/`zip_status`; CLI `devplace zips prune|clear`. **`ForkService`** (kind `fork`, `services/jobs/fork_service.py`) is the second consumer and forks a project: `process` creates the destination project (via `content.create_content_item`, owned by the forking user), copies the whole virtual FS through `project_files.export_to_dir` -> `config.DATA_DIR/fork_staging/{uid}` -> `import_from_dir(..., skip_names=set())` (exact copy), and records the directional relation via `database.record_fork(source_uid, forked_uid, forked_by_uid)` into the `project_forks` table; on any failure it **rolls back** the half-created project (delete files + relation + row) and re-raises. **Critical difference from zip: the artifact is a permanent project, so `cleanup()` is a no-op on the project** - the retention sweep only removes the job tracking row, never the fork. Enqueue `POST /projects/{slug}/fork` (`require_user` + `can_view_project`, body `ForkForm{title}`); `GET /forks/{uid}` returns `ForkJobOut` whose `project_url` is set once done. Frontend `app.projectForker` (`static/js/ProjectForker.js`) wires `data-fork-project`: prompt for a name -> POST -> poll `/forks/{uid}` -> redirect to `project_url`. The forked project's detail page shows a "Forked from X" link (`database.get_fork_parent`). Devii tools `fork_project`/`fork_status`; CLI `devplace forks prune|clear` (job rows only). +### Bug tracker (`devplacepy/services/gitea/`) +The `/bugs` feature is a **full Gitea integration with no local issue store** - the listing and detail views read issues straight from Gitea (default repo `retoor/pydevplace`) with live status. `services/gitea/` holds `config.py` (the admin-editable `CONFIG_FIELDS` for base URL/owner/repo/token + AI model, surfaced on the `BugTrackerService` config at `/admin/services`; `gitea_config()` -> `GiteaConfig`), `client.py` (async `GiteaClient` over the REST API, `Authorization: token`, `GiteaError`), `fake.py` (`FakeGiteaClient` test double), `runtime.py` (`get_client`/`set_client` swap), `enhance.py` (`enhance_ticket` rewrites a raw report into a consistent markdown ticket via the **internal AI gateway**, fail-soft to a deterministic template), `store.py` (the only DB state: `bug_tickets` and `bug_comment_authors`, both in `SOFT_DELETE_TABLES` - thin mappings of Gitea number/comment-id -> DevPlace author for attribution + cached `last_status`/`last_comment_count` for update detection), and `service.py` (`BugTrackerService`, `default_enabled=False`, polls tracked tickets and notifies the **reporter** on developer replies / status changes). Filing is an **async job** (`BugCreateService`, kind `bug_create`): enhance -> create Gitea issue -> record mapping -> notify reporter; `POST /bugs/create` enqueues, frontend `app.bugReporter` polls `/bugs/jobs/{uid}`. Comments (`POST /bugs/{number}/comment`, notifies **all admins**) and admin status changes (`POST /bugs/{number}/status`, open/closed) are synchronous single Gitea calls. All Gitea actions use one shared bot token; per-user identity is shown by storing DevPlace author names locally, never per-user Gitea accounts. + ### Audit log (`devplacepy/services/audit/`) **Admin-only, append-only** record of every state-changing action; the authoritative event catalogue (155 keys across 26 domains) is `events.md`. Package: `store.py` (tables `audit_log` + `audit_log_links`, `ensure_tables`/`insert_event`/`insert_links`/`get_event`/`get_links`/`sweep`), `categories.py` (`category_for(event_key)`), `record.py` (the recorder + link builders), `query.py` (admin list/detail), `service.py` (`AuditService` retention). `ensure_tables()` + 11 indexes are wired into `init_db()` via a local import (audit modules import `database`/`utils`, so the import is deferred to avoid the cycle); `AuditService` is registered in `main.py`. **Two recorder entrypoints (the reuse keystone):** `record(request, event_key, *, user=_UNSET, actor_kind, target_*, old_value, new_value, summary, metadata, result, origin, via_agent, links, category)` for HTTP/WebSocket handlers (`request` may be a `Request` OR a `WebSocket`; auto-derives actor from `get_current_user`, request fields, the `X-Devii-Agent` header -> `origin=devii`/`via_agent=1`, and auto-appends the `actor` link), and `record_system(event_key, *, actor_kind, actor_uid, actor_username, actor_role, origin, via_agent, ...)` for request-less contexts (rewards, notifications, the AI gateway ledger, news/container services, Devii turns/tasks, job services, CLI). **Both are best-effort: wrapped in try/except, they NEVER raise into the caller** - the audited action is never blocked by a logging failure. `summary` is HTML-stripped + 140-char capped; `metadata` is JSON. **Emission is explicit per mutation** (`audit.record(...)`/`record_system(...)` after success, or on the guard branch with `result="denied"`/`"failure"`), with DRY choke points: posts/projects/gists record inside `content.py`; project file ops via the `project_files.py` helpers (read-only guard -> `denied`); container ops in `routers/containers.py` (HTTP path) and in the Devii `actions/dispatcher.py` `_audit_mechanic` hook (agent path; the two paths are disjoint so there is no double-count), which also emits the `devii.*` self-config mechanics. Auth failures, authz denials (in `require_user`/`require_admin`), rate-limit/maintenance blocks, self-role-change/self-disable denials, and quota blocks are recorded with `result` set. **Admin UI:** `GET /admin/audit-log` (paginated, filterable list) + `GET /admin/audit-log/{uid}` (detail + links), both `require_admin`, both negotiate via `respond(..., model=AuditLogOut|AuditEventOut)`; sidebar link sits last (before Settings). `_pagination.html` gained an optional backward-compatible `pagination_query` prefix so filters survive paging. **Retention:** `AuditService` (daily) prunes rows older than `audit_log_retention_days` (default 90; `0` disables). To add an event: pick/extend a key in `events.md`, extend `category_for` for a new domain, and call the recorder at the mutation point - never gate the action on the recording. diff --git a/README.md b/README.md index b6c73954..616d52f1 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ devplacepy/ | `/follow` | Follow/unfollow users | | `/leaderboard` | Contributor ranking by total stars earned | | `/avatar` | Multiavatar proxy with in-memory cache | -| `/bugs` | Bug reports listing, creation | +| `/bugs` | Bug tracker backed by Gitea: list/detail/comment/status, AI-enhanced filing | | `/admin/services` | Background service management (start/stop, config, status, logs) | | `/admin` | Admin panel (user management, news curation, settings) | | `/docs` | Developer documentation site with a complete, interactive HTTP API reference | diff --git a/devplacepy/database.py b/devplacepy/database.py index c986ea78..c1f860c9 100644 --- a/devplacepy/database.py +++ b/devplacepy/database.py @@ -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"] diff --git a/devplacepy/docs_api.py b/devplacepy/docs_api.py index b86a77cb..52595a28 100644 --- a/devplacepy/docs_api.py +++ b/devplacepy/docs_api.py @@ -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), diff --git a/devplacepy/main.py b/devplacepy/main.py index c1fae5f8..1ff3e24e 100644 --- a/devplacepy/main.py +++ b/devplacepy/main.py @@ -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"): diff --git a/devplacepy/models.py b/devplacepy/models.py index 4959ef6a..40a2512b 100644 --- a/devplacepy/models.py +++ b/devplacepy/models.py @@ -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): diff --git a/devplacepy/routers/bugs.py b/devplacepy/routers/bugs.py index f2db672c..93e0015c 100644 --- a/devplacepy/routers/bugs.py +++ b/devplacepy/routers/bugs.py @@ -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}", + ) diff --git a/devplacepy/schemas.py b/devplacepy/schemas.py index 3088d6c5..a4e2de7b 100644 --- a/devplacepy/schemas.py +++ b/devplacepy/schemas.py @@ -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): diff --git a/devplacepy/services/devii/actions/catalog.py b/devplacepy/services/devii/actions/catalog.py index 9c106e2d..9a0093dd 100644 --- a/devplacepy/services/devii/actions/catalog.py +++ b/devplacepy/services/devii/actions/catalog.py @@ -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( diff --git a/devplacepy/services/gitea/__init__.py b/devplacepy/services/gitea/__init__.py new file mode 100644 index 00000000..95ee7c3e --- /dev/null +++ b/devplacepy/services/gitea/__init__.py @@ -0,0 +1 @@ +# retoor diff --git a/devplacepy/services/gitea/client.py b/devplacepy/services/gitea/client.py new file mode 100644 index 00000000..4d6a8c6d --- /dev/null +++ b/devplacepy/services/gitea/client.py @@ -0,0 +1,131 @@ +# retoor + +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 diff --git a/devplacepy/services/gitea/config.py b/devplacepy/services/gitea/config.py new file mode 100644 index 00000000..46018036 --- /dev/null +++ b/devplacepy/services/gitea/config.py @@ -0,0 +1,114 @@ +# retoor + +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 diff --git a/devplacepy/services/gitea/enhance.py b/devplacepy/services/gitea/enhance.py new file mode 100644 index 00000000..7ac951d5 --- /dev/null +++ b/devplacepy/services/gitea/enhance.py @@ -0,0 +1,115 @@ +# retoor + +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) diff --git a/devplacepy/services/gitea/fake.py b/devplacepy/services/gitea/fake.py new file mode 100644 index 00000000..5717b67f --- /dev/null +++ b/devplacepy/services/gitea/fake.py @@ -0,0 +1,114 @@ +# retoor + +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) diff --git a/devplacepy/services/gitea/runtime.py b/devplacepy/services/gitea/runtime.py new file mode 100644 index 00000000..5ba10c01 --- /dev/null +++ b/devplacepy/services/gitea/runtime.py @@ -0,0 +1,16 @@ +# retoor + +_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 diff --git a/devplacepy/services/gitea/service.py b/devplacepy/services/gitea/service.py new file mode 100644 index 00000000..670b5266 --- /dev/null +++ b/devplacepy/services/gitea/service.py @@ -0,0 +1,108 @@ +# retoor + +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)], + ) diff --git a/devplacepy/services/gitea/store.py b/devplacepy/services/gitea/store.py new file mode 100644 index 00000000..a33ec2bf --- /dev/null +++ b/devplacepy/services/gitea/store.py @@ -0,0 +1,116 @@ +# retoor + +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} diff --git a/devplacepy/services/jobs/bug_create_service.py b/devplacepy/services/jobs/bug_create_service.py new file mode 100644 index 00000000..c7dbf4ef --- /dev/null +++ b/devplacepy/services/jobs/bug_create_service.py @@ -0,0 +1,113 @@ +# retoor + +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 diff --git a/devplacepy/static/css/bugs.css b/devplacepy/static/css/bugs.css index c2b0bc4a..8ad3e2ef 100644 --- a/devplacepy/static/css/bugs.css +++ b/devplacepy/static/css/bugs.css @@ -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 { diff --git a/devplacepy/static/js/Application.js b/devplacepy/static/js/Application.js index 29b45554..81ec9afb 100644 --- a/devplacepy/static/js/Application.js +++ b/devplacepy/static/js/Application.js @@ -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(); } } diff --git a/devplacepy/static/js/BugReporter.js b/devplacepy/static/js/BugReporter.js new file mode 100644 index 00000000..8ccbfc60 --- /dev/null +++ b/devplacepy/static/js/BugReporter.js @@ -0,0 +1,59 @@ +// retoor + +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" }), + }); + } +} diff --git a/devplacepy/templates/bug_detail.html b/devplacepy/templates/bug_detail.html new file mode 100644 index 00000000..21d46943 --- /dev/null +++ b/devplacepy/templates/bug_detail.html @@ -0,0 +1,78 @@ +{% extends "base.html" %} +{% block extra_head %} + +{% endblock %} +{% block content %} +
+
+ ← All bugs + {% if bug.html_url %}View on Gitea{% endif %} +
+ +
+
+ #{{ bug.number }} +

{{ bug.title }}

+ {{ bug.state }} +
+
+ {% if bug.is_local_author %} + {% set _user = {'username': bug.author_username} %}{% set _size = 24 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %} + {% endif %} + {{ bug.author_username }} + · + {{ format_date(bug.created_at, true) }} +
+ + {% if is_admin %} +
+ {% if bug.state == 'open' %} +
+ + +
+ {% else %} +
+ + +
+ {% endif %} +
+ {% endif %} + +
{{ body }}
+
+ +
+

Updates & comments

+ {% for comment in comments %} +
+
+ {% if comment.is_local_author %} + {% set _user = {'username': comment.author_username} %}{% set _size = 20 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %} + {% else %} + dev + {% endif %} + {{ comment.author_username }} + · + {{ format_date(comment.created_at, true) }} +
+
{{ comment.body }}
+
+ {% else %} +

No updates yet.

+ {% endfor %} + + {% if can_comment %} +
+ + +
+ {% else %} +

Log in to comment.

+ {% endif %} +
+
+{% endblock %} diff --git a/devplacepy/templates/bugs.html b/devplacepy/templates/bugs.html index 706c0efc..391d7bf4 100644 --- a/devplacepy/templates/bugs.html +++ b/devplacepy/templates/bugs.html @@ -1,8 +1,6 @@ {% extends "base.html" %} {% from "_macros.html" import modal %} {% block extra_head %} - - {% endblock %} {% block content %} @@ -16,46 +14,56 @@ {% endif %} +
+ Open + Closed + All +
+ + {% if not configured or error_message %} +
{{ error_message }}
+ {% else %} + + {% set pagination_query = 'state=' ~ state ~ '&' %} + {% include "_pagination.html" %} + {% endif %} {% if user %} {% call modal('create-bug-modal', 'Report a Bug') %} -
+ +

Your report is improved by AI into a consistent ticket and filed on the tracker.

- +
- {% include "_attachment_form.html" %}