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.
This commit is contained in:
parent
c66119cc6b
commit
2d84ebdc2f
19
AGENTS.md
19
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.
|
`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
|
## Gists
|
||||||
|
|
||||||
|
|||||||
@ -84,7 +84,7 @@ One file per domain in `devplacepy/routers/`. Prefixes are wired in `main.py`:
|
|||||||
| `/follow` | follow.py |
|
| `/follow` | follow.py |
|
||||||
| `/admin` | admin.py |
|
| `/admin` | admin.py |
|
||||||
| `/admin/services` | services.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 |
|
| `/gists` | gists.py |
|
||||||
| `/news` | news.py |
|
| `/news` | news.py |
|
||||||
| `/uploads` | uploads.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/`)
|
### 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).
|
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/`)
|
### 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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@ -76,7 +76,7 @@ devplacepy/
|
|||||||
| `/follow` | Follow/unfollow users |
|
| `/follow` | Follow/unfollow users |
|
||||||
| `/leaderboard` | Contributor ranking by total stars earned |
|
| `/leaderboard` | Contributor ranking by total stars earned |
|
||||||
| `/avatar` | Multiavatar proxy with in-memory cache |
|
| `/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/services` | Background service management (start/stop, config, status, logs) |
|
||||||
| `/admin` | Admin panel (user management, news curation, settings) |
|
| `/admin` | Admin panel (user management, news curation, settings) |
|
||||||
| `/docs` | Developer documentation site with a complete, interactive HTTP API reference |
|
| `/docs` | Developer documentation site with a complete, interactive HTTP API reference |
|
||||||
|
|||||||
@ -127,6 +127,8 @@ SOFT_DELETE_TABLES = [
|
|||||||
"devii_virtual_tools",
|
"devii_virtual_tools",
|
||||||
"user_customizations",
|
"user_customizations",
|
||||||
"project_forks",
|
"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_images", "idx_news_images_news_uid", ["news_uid"])
|
||||||
_index(db, "news_sync", "idx_news_sync_external_id", ["external_id"])
|
_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, "service_state", "idx_service_state_name", ["name"])
|
||||||
_index(
|
_index(
|
||||||
db, "devii_conversations", "idx_devii_conv_owner", ["owner_kind", "owner_id"]
|
db, "devii_conversations", "idx_devii_conv_owner", ["owner_kind", "owner_id"]
|
||||||
|
|||||||
@ -423,7 +423,6 @@ envelope to see validation errors as `{ "error": "validation", "fields": {...} }
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"slug": "lookups",
|
|
||||||
"slug": "lookups",
|
"slug": "lookups",
|
||||||
"title": "Search & Lookups",
|
"title": "Search & Lookups",
|
||||||
"intro": """
|
"intro": """
|
||||||
@ -3198,8 +3197,11 @@ four ways to sign requests.
|
|||||||
"intro": """
|
"intro": """
|
||||||
# Bug Reports
|
# Bug Reports
|
||||||
|
|
||||||
A public board for reporting issues. The list renders HTML; reporting uses form fields, and a
|
The bug tracker is a full integration with a Gitea repository. The listing and detail views read
|
||||||
report can carry files uploaded via [Uploads](/docs/uploads.html).
|
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
|
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
|
||||||
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
|
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
|
||||||
@ -3210,19 +3212,31 @@ four ways to sign requests.
|
|||||||
id="bugs-list",
|
id="bugs-list",
|
||||||
method="GET",
|
method="GET",
|
||||||
path="/bugs",
|
path="/bugs",
|
||||||
title="View bug reports",
|
title="List bug tickets",
|
||||||
summary="Render the bug board. Returns an HTML page.",
|
summary="Render the bug board from Gitea, paginated and filterable by state.",
|
||||||
auth="public",
|
auth="public",
|
||||||
interactive=True,
|
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(
|
endpoint(
|
||||||
id="bugs-create",
|
id="bugs-create",
|
||||||
method="POST",
|
method="POST",
|
||||||
path="/bugs/create",
|
path="/bugs/create",
|
||||||
title="Report a bug",
|
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",
|
auth="user",
|
||||||
encoding="form",
|
encoding="form",
|
||||||
|
ajax=True,
|
||||||
destructive=True,
|
destructive=True,
|
||||||
params=[
|
params=[
|
||||||
field(
|
field(
|
||||||
@ -3242,6 +3256,86 @@ four ways to sign requests.
|
|||||||
"Description, 1-5000 characters.",
|
"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,
|
"messages-inbox": schemas.MessagesOut,
|
||||||
"notifications-list": schemas.NotificationsOut,
|
"notifications-list": schemas.NotificationsOut,
|
||||||
"bugs-list": schemas.BugsOut,
|
"bugs-list": schemas.BugsOut,
|
||||||
|
"bugs-detail": schemas.BugDetailOut,
|
||||||
"bookmarks-saved": schemas.SavedOut,
|
"bookmarks-saved": schemas.SavedOut,
|
||||||
"admin-users": schemas.AdminUsersOut,
|
"admin-users": schemas.AdminUsersOut,
|
||||||
"admin-news-list": schemas.AdminNewsOut,
|
"admin-news-list": schemas.AdminNewsOut,
|
||||||
@ -3895,7 +3990,8 @@ _ACTION_RESPONSES = {
|
|||||||
"notifications-open": ("/posts/POST_SLUG#comment-COMMENT_UID", None),
|
"notifications-open": ("/posts/POST_SLUG#comment-COMMENT_UID", None),
|
||||||
"notifications-mark-read": ("/notifications", None),
|
"notifications-mark-read": ("/notifications", None),
|
||||||
"notifications-mark-all-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-role": ("/admin/users", None),
|
||||||
"admin-user-password": ("/admin/users", None),
|
"admin-user-password": ("/admin/users", None),
|
||||||
"admin-user-toggle": ("/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.devii import DeviiService
|
||||||
from devplacepy.services.jobs.zip_service import ZipService
|
from devplacepy.services.jobs.zip_service import ZipService
|
||||||
from devplacepy.services.jobs.fork_service import ForkService
|
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.containers.service import ContainerService
|
||||||
from devplacepy.services.audit import AuditService
|
from devplacepy.services.audit import AuditService
|
||||||
from devplacepy.services.audit import record as audit
|
from devplacepy.services.audit import record as audit
|
||||||
@ -407,6 +409,8 @@ async def startup():
|
|||||||
service_manager.register(DeviiService())
|
service_manager.register(DeviiService())
|
||||||
service_manager.register(ZipService())
|
service_manager.register(ZipService())
|
||||||
service_manager.register(ForkService())
|
service_manager.register(ForkService())
|
||||||
|
service_manager.register(BugCreateService())
|
||||||
|
service_manager.register(BugTrackerService())
|
||||||
service_manager.register(ContainerService())
|
service_manager.register(ContainerService())
|
||||||
service_manager.register(AuditService())
|
service_manager.register(AuditService())
|
||||||
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):
|
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):
|
||||||
|
|||||||
@ -153,24 +153,6 @@ class PostEditForm(BaseModel):
|
|||||||
return normalize_poll_options(value)
|
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):
|
class CommentForm(BaseModel):
|
||||||
content: str = Field(min_length=3, max_length=1000)
|
content: str = Field(min_length=3, max_length=1000)
|
||||||
target_uid: str = Field(default="", max_length=36)
|
target_uid: str = Field(default="", max_length=36)
|
||||||
@ -340,7 +322,14 @@ class GistEditForm(BaseModel):
|
|||||||
class BugForm(BaseModel):
|
class BugForm(BaseModel):
|
||||||
title: str = Field(min_length=1, max_length=200)
|
title: str = Field(min_length=1, max_length=200)
|
||||||
description: str = Field(min_length=1, max_length=5000)
|
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):
|
class VoteForm(BaseModel):
|
||||||
|
|||||||
@ -2,110 +2,347 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from datetime import datetime, timezone
|
|
||||||
from fastapi import APIRouter, Request, Form
|
from fastapi import APIRouter, Form, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
from devplacepy.models import BugForm
|
|
||||||
from devplacepy.database import get_table, load_comments_by_target_uids
|
from devplacepy.database import build_pagination, get_table, get_users_by_uids
|
||||||
from devplacepy.attachments import get_attachments_batch, link_attachments
|
from devplacepy.models import BugCommentForm, BugForm, BugStatusForm
|
||||||
from devplacepy.utils import (
|
from devplacepy.responses import action_result, json_error, respond
|
||||||
generate_uid,
|
from devplacepy.schemas import BugDetailOut, BugJobOut, BugsOut
|
||||||
require_user,
|
|
||||||
time_ago,
|
|
||||||
get_current_user,
|
|
||||||
create_mention_notifications,
|
|
||||||
)
|
|
||||||
from devplacepy.seo import base_seo_context, site_url, website_schema
|
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.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__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter()
|
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]
|
def _author_fields(author_uid: str | None, users_map: dict, fallback_login: str) -> dict:
|
||||||
attachments_map = get_attachments_batch("bug", bug_uids) if bug_uids else {}
|
user = users_map.get(author_uid) if author_uid else None
|
||||||
comments_map = load_comments_by_target_uids("bug", bug_uids, user) if bug_uids else {}
|
if user:
|
||||||
bug_list = []
|
return {
|
||||||
for b in all_bugs:
|
"author_username": user["username"],
|
||||||
bug_list.append(
|
"author_uid": author_uid,
|
||||||
{
|
"author_avatar_seed": user["username"],
|
||||||
"bug": b,
|
"is_local_author": True,
|
||||||
"author": users_map.get(b["user_uid"]),
|
}
|
||||||
"time_ago": time_ago(b["created_at"]),
|
return {
|
||||||
"comments": comments_map.get(b["uid"], []),
|
"author_username": fallback_login or "developer",
|
||||||
"attachments": attachments_map.get(b["uid"], []),
|
"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)
|
base = site_url(request)
|
||||||
seo_ctx = base_seo_context(
|
return base_seo_context(
|
||||||
request,
|
request,
|
||||||
title="Bug Reports",
|
|
||||||
description="Report bugs and issues on DevPlace.",
|
|
||||||
breadcrumbs=[
|
breadcrumbs=[
|
||||||
{"name": "Home", "url": "/feed"},
|
{"name": "Home", "url": "/feed"},
|
||||||
{"name": "Bug Reports", "url": "/bugs"},
|
{"name": "Bug Reports", "url": "/bugs"},
|
||||||
],
|
],
|
||||||
schemas=[website_schema(base)],
|
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,
|
request,
|
||||||
"bugs.html",
|
title="Bug Reports",
|
||||||
{
|
description="Report bugs and track their progress on DevPlace.",
|
||||||
|
)
|
||||||
|
base_ctx = {
|
||||||
**seo_ctx,
|
**seo_ctx,
|
||||||
"request": request,
|
"request": request,
|
||||||
"user": user,
|
"user": user,
|
||||||
"bugs": bug_list,
|
"state": state,
|
||||||
},
|
"bugs": [],
|
||||||
model=BugsOut,
|
"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")
|
@router.post("/create")
|
||||||
async def create_bug(request: Request, data: Annotated[BugForm, Form()]):
|
async def create_bug(request: Request, data: Annotated[BugForm, Form()]):
|
||||||
user = require_user(request)
|
user = require_user(request)
|
||||||
|
if not gitea_config().is_configured:
|
||||||
|
return json_error(503, "The bug tracker is not configured")
|
||||||
title = data.title.strip()
|
title = data.title.strip()
|
||||||
description = data.description.strip()
|
uid = queue.enqueue(
|
||||||
|
"bug_create",
|
||||||
bugs_table = get_table("bug_reports")
|
|
||||||
bug_uid = generate_uid()
|
|
||||||
bugs_table.insert(
|
|
||||||
{
|
{
|
||||||
"uid": bug_uid,
|
"author_uid": user["uid"],
|
||||||
"user_uid": user["uid"],
|
|
||||||
"title": title,
|
"title": title,
|
||||||
"description": description,
|
"description": data.description.strip(),
|
||||||
"status": "open",
|
},
|
||||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
"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(
|
audit.record(
|
||||||
request,
|
request,
|
||||||
"bug.create",
|
"bug.create.request",
|
||||||
user=user,
|
user=user,
|
||||||
target_type="bug",
|
target_type="bug",
|
||||||
target_uid=bug_uid,
|
summary=f"{user['username']} requested a bug report: {title[:60]}",
|
||||||
target_label=title,
|
metadata={"title": title},
|
||||||
summary=f"{user['username']} reported a bug: {title}",
|
links=[audit.job(uid)],
|
||||||
links=[audit.target("bug", bug_uid, title)],
|
)
|
||||||
|
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}",
|
||||||
)
|
)
|
||||||
return action_result(request, "/bugs", data={"uid": bug_uid})
|
|
||||||
|
|||||||
@ -281,14 +281,6 @@ class MessageOut(_Out):
|
|||||||
created_at: Optional[str] = None
|
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 ----------
|
# ---------- list-item wrappers ----------
|
||||||
|
|
||||||
@ -353,11 +345,48 @@ class NotificationGroupOut(_Out):
|
|||||||
|
|
||||||
|
|
||||||
class BugItemOut(_Out):
|
class BugItemOut(_Out):
|
||||||
bug: BugOut
|
number: int = 0
|
||||||
author: Optional[UserOut] = None
|
title: str = ""
|
||||||
time_ago: Optional[str] = None
|
state: str = "open"
|
||||||
comments: list[CommentItemOut] = []
|
html_url: Optional[str] = None
|
||||||
attachments: list[AttachmentOut] = []
|
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):
|
class LeaderboardEntryOut(_Out):
|
||||||
@ -563,6 +592,10 @@ class LeaderboardOut(_Out):
|
|||||||
|
|
||||||
class BugsOut(_Out):
|
class BugsOut(_Out):
|
||||||
bugs: list[BugItemOut] = []
|
bugs: list[BugItemOut] = []
|
||||||
|
pagination: Optional[Any] = None
|
||||||
|
state: str = "open"
|
||||||
|
configured: bool = True
|
||||||
|
error_message: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class SavedOut(_Out):
|
class SavedOut(_Out):
|
||||||
|
|||||||
@ -816,18 +816,67 @@ ACTIONS: tuple[Action, ...] = (
|
|||||||
name="list_bugs",
|
name="list_bugs",
|
||||||
method="GET",
|
method="GET",
|
||||||
path="/bugs",
|
path="/bugs",
|
||||||
summary="List reported bugs",
|
summary="List bug tickets from the Gitea tracker",
|
||||||
requires_auth=False,
|
requires_auth=False,
|
||||||
|
params=(
|
||||||
|
query("state", "Filter by state: open, closed, or all."),
|
||||||
|
query("page", "1-based page number."),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Action(
|
Action(
|
||||||
name="create_bug",
|
name="create_bug",
|
||||||
method="POST",
|
method="POST",
|
||||||
path="/bugs/create",
|
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=(
|
params=(
|
||||||
body("title", "Bug title.", required=True),
|
body("title", "Bug title.", required=True),
|
||||||
body("description", "Bug description.", 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(
|
Action(
|
||||||
|
|||||||
1
devplacepy/services/gitea/__init__.py
Normal file
1
devplacepy/services/gitea/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
131
devplacepy/services/gitea/client.py
Normal file
131
devplacepy/services/gitea/client.py
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from devplacepy.services.gitea.config import (
|
||||||
|
HTTP_TIMEOUT_SECONDS,
|
||||||
|
GiteaConfig,
|
||||||
|
gitea_config,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ISSUE_TYPE = "issues"
|
||||||
|
STATE_OPEN = "open"
|
||||||
|
STATE_CLOSED = "closed"
|
||||||
|
STATE_ALL = "all"
|
||||||
|
VALID_STATES = (STATE_OPEN, STATE_CLOSED, STATE_ALL)
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaError(Exception):
|
||||||
|
def __init__(self, message: str, status: int = 0):
|
||||||
|
super().__init__(message)
|
||||||
|
self.status = status
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
|
class GiteaClient:
|
||||||
|
def __init__(self, config: GiteaConfig | None = None):
|
||||||
|
self.config = config or gitea_config()
|
||||||
|
|
||||||
|
def _headers(self) -> dict:
|
||||||
|
return {
|
||||||
|
"Authorization": f"token {self.config.token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _require_config(self) -> None:
|
||||||
|
if not self.config.is_configured:
|
||||||
|
raise GiteaError("Gitea integration is not configured", status=503)
|
||||||
|
|
||||||
|
async def _request(self, method: str, url: str, **kwargs) -> httpx.Response:
|
||||||
|
self._require_config()
|
||||||
|
logger.debug("Gitea %s %s", method, url)
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT_SECONDS) as client:
|
||||||
|
response = await client.request(
|
||||||
|
method, url, headers=self._headers(), **kwargs
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
logger.warning("Gitea request failed: %s", exc)
|
||||||
|
raise GiteaError(f"Gitea request failed: {exc}") from exc
|
||||||
|
if response.status_code >= 400:
|
||||||
|
logger.warning(
|
||||||
|
"Gitea %s %s returned %s: %s",
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
response.status_code,
|
||||||
|
response.text[:200],
|
||||||
|
)
|
||||||
|
raise GiteaError(
|
||||||
|
f"Gitea returned {response.status_code}", status=response.status_code
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
async def create_issue(self, title: str, body: str) -> dict:
|
||||||
|
response = await self._request(
|
||||||
|
"POST",
|
||||||
|
f"{self.config.repo_base}/issues",
|
||||||
|
json={"title": title, "body": body},
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def list_issues(
|
||||||
|
self, state: str = STATE_OPEN, page: int = 1, limit: int = 20
|
||||||
|
) -> tuple[list[dict], int]:
|
||||||
|
if state not in VALID_STATES:
|
||||||
|
state = STATE_OPEN
|
||||||
|
response = await self._request(
|
||||||
|
"GET",
|
||||||
|
f"{self.config.repo_base}/issues",
|
||||||
|
params={
|
||||||
|
"type": ISSUE_TYPE,
|
||||||
|
"state": state,
|
||||||
|
"page": max(1, page),
|
||||||
|
"limit": max(1, min(limit, 50)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
issues = [item for item in response.json() if not item.get("pull_request")]
|
||||||
|
total = self._total_count(response, len(issues))
|
||||||
|
return issues, total
|
||||||
|
|
||||||
|
async def get_issue(self, number: int) -> dict:
|
||||||
|
response = await self._request(
|
||||||
|
"GET", f"{self.config.repo_base}/issues/{number}"
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def list_comments(self, number: int) -> list[dict]:
|
||||||
|
response = await self._request(
|
||||||
|
"GET", f"{self.config.repo_base}/issues/{number}/comments"
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def create_comment(self, number: int, body: str) -> dict:
|
||||||
|
response = await self._request(
|
||||||
|
"POST",
|
||||||
|
f"{self.config.repo_base}/issues/{number}/comments",
|
||||||
|
json={"body": body},
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def set_state(self, number: int, state: str) -> dict:
|
||||||
|
if state not in (STATE_OPEN, STATE_CLOSED):
|
||||||
|
raise GiteaError(f"invalid state: {state}", status=400)
|
||||||
|
response = await self._request(
|
||||||
|
"PATCH",
|
||||||
|
f"{self.config.repo_base}/issues/{number}",
|
||||||
|
json={"state": state},
|
||||||
|
)
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _total_count(response: httpx.Response, fallback: int) -> int:
|
||||||
|
raw = response.headers.get("X-Total-Count", "")
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return fallback
|
||||||
114
devplacepy/services/gitea/config.py
Normal file
114
devplacepy/services/gitea/config.py
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from devplacepy.config import INTERNAL_MODEL
|
||||||
|
from devplacepy.database import get_setting, internal_gateway_key
|
||||||
|
from devplacepy.services.base import ConfigField
|
||||||
|
|
||||||
|
BASE_URL_DEFAULT = "https://retoor.molodetz.nl"
|
||||||
|
OWNER_DEFAULT = "retoor"
|
||||||
|
REPO_DEFAULT = "pydevplace"
|
||||||
|
HTTP_TIMEOUT_SECONDS = 20.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GiteaConfig:
|
||||||
|
base_url: str
|
||||||
|
owner: str
|
||||||
|
repo: str
|
||||||
|
token: str
|
||||||
|
ai_enhance: bool
|
||||||
|
ai_model: str
|
||||||
|
ai_key: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def api_base(self) -> str:
|
||||||
|
return f"{self.base_url.rstrip('/')}/api/v1"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def repo_base(self) -> str:
|
||||||
|
return f"{self.api_base}/repos/{self.owner}/{self.repo}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_configured(self) -> bool:
|
||||||
|
return bool(self.base_url and self.owner and self.repo and self.token)
|
||||||
|
|
||||||
|
|
||||||
|
CONFIG_FIELDS: list[ConfigField] = [
|
||||||
|
ConfigField(
|
||||||
|
"gitea_base_url",
|
||||||
|
"Gitea base URL",
|
||||||
|
type="url",
|
||||||
|
default=BASE_URL_DEFAULT,
|
||||||
|
help="Origin of the Gitea instance, without the /api path.",
|
||||||
|
group="Gitea",
|
||||||
|
),
|
||||||
|
ConfigField(
|
||||||
|
"gitea_owner",
|
||||||
|
"Repository owner",
|
||||||
|
type="str",
|
||||||
|
default=OWNER_DEFAULT,
|
||||||
|
help="Owner (user or organisation) of the issue tracker repository.",
|
||||||
|
group="Gitea",
|
||||||
|
),
|
||||||
|
ConfigField(
|
||||||
|
"gitea_repo",
|
||||||
|
"Repository name",
|
||||||
|
type="str",
|
||||||
|
default=REPO_DEFAULT,
|
||||||
|
help="Repository whose issues back the bug tracker.",
|
||||||
|
group="Gitea",
|
||||||
|
),
|
||||||
|
ConfigField(
|
||||||
|
"gitea_token",
|
||||||
|
"Gitea access token",
|
||||||
|
type="password",
|
||||||
|
default="",
|
||||||
|
secret=True,
|
||||||
|
help="Personal access token with repo issue scope. All actions use this single account.",
|
||||||
|
group="Gitea",
|
||||||
|
),
|
||||||
|
ConfigField(
|
||||||
|
"bug_ai_enhance",
|
||||||
|
"Improve tickets with AI",
|
||||||
|
type="bool",
|
||||||
|
default=True,
|
||||||
|
help="Rewrite each submitted report into a consistent, high-quality ticket before posting.",
|
||||||
|
group="AI",
|
||||||
|
),
|
||||||
|
ConfigField(
|
||||||
|
"bug_ai_model",
|
||||||
|
"AI model",
|
||||||
|
type="str",
|
||||||
|
default=INTERNAL_MODEL,
|
||||||
|
help="Model name sent to the internal AI gateway for ticket enhancement.",
|
||||||
|
group="AI",
|
||||||
|
),
|
||||||
|
ConfigField(
|
||||||
|
"bug_ai_key",
|
||||||
|
"AI API key",
|
||||||
|
type="password",
|
||||||
|
default="",
|
||||||
|
secret=True,
|
||||||
|
help="Defaults to the internal gateway key when left blank.",
|
||||||
|
group="AI",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def gitea_config() -> GiteaConfig:
|
||||||
|
ai_key = get_setting("bug_ai_key", "") or internal_gateway_key()
|
||||||
|
return GiteaConfig(
|
||||||
|
base_url=get_setting("gitea_base_url", BASE_URL_DEFAULT),
|
||||||
|
owner=get_setting("gitea_owner", OWNER_DEFAULT),
|
||||||
|
repo=get_setting("gitea_repo", REPO_DEFAULT),
|
||||||
|
token=get_setting("gitea_token", ""),
|
||||||
|
ai_enhance=get_setting("bug_ai_enhance", "1") == "1",
|
||||||
|
ai_model=get_setting("bug_ai_model", INTERNAL_MODEL),
|
||||||
|
ai_key=ai_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_configured() -> bool:
|
||||||
|
return gitea_config().is_configured
|
||||||
115
devplacepy/services/gitea/enhance.py
Normal file
115
devplacepy/services/gitea/enhance.py
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from devplacepy.config import INTERNAL_GATEWAY_URL
|
||||||
|
from devplacepy.services.gitea.config import HTTP_TIMEOUT_SECONDS, GiteaConfig
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MAX_TOKENS = 1200
|
||||||
|
TITLE_MAX = 200
|
||||||
|
BODY_MAX = 60000
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = (
|
||||||
|
"You are a senior triage engineer for DevPlace, a social network for software "
|
||||||
|
"developers. You rewrite raw user bug reports into clear, consistent, high-quality "
|
||||||
|
"issue tickets for a Gitea tracker. Preserve every concrete fact the reporter gave; "
|
||||||
|
"never invent versions, stack traces, or steps that were not provided. Keep a calm, "
|
||||||
|
"technical, professional tone. Do not add greetings or sign-offs.\n\n"
|
||||||
|
"Return ONLY a JSON object with exactly two string keys: \"title\" and \"body\".\n"
|
||||||
|
"- title: a concise, specific, imperative summary under 120 characters.\n"
|
||||||
|
"- body: GitHub-flavored markdown using EXACTLY these sections, in order, each as a "
|
||||||
|
"level-2 heading:\n"
|
||||||
|
"## Summary\n## Steps to Reproduce\n## Expected Behaviour\n## Actual Behaviour\n"
|
||||||
|
"## Environment\n"
|
||||||
|
"Under each heading put what the report supports; if a section is unknown write "
|
||||||
|
"'Not provided.' Use a numbered list under Steps to Reproduce."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EnhancedTicket:
|
||||||
|
title: str
|
||||||
|
body: str
|
||||||
|
enhanced: bool
|
||||||
|
|
||||||
|
|
||||||
|
def _fallback(title: str, description: str) -> EnhancedTicket:
|
||||||
|
body = (
|
||||||
|
f"## Summary\n{title}\n\n"
|
||||||
|
f"## Steps to Reproduce\n{description}\n\n"
|
||||||
|
"## Expected Behaviour\nNot provided.\n\n"
|
||||||
|
"## Actual Behaviour\nNot provided.\n\n"
|
||||||
|
"## Environment\nNot provided.\n"
|
||||||
|
)
|
||||||
|
return EnhancedTicket(title=title[:TITLE_MAX], body=body[:BODY_MAX], enhanced=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(text: str) -> tuple[str, str] | None:
|
||||||
|
match = re.search(r"\{.*\}", text, re.DOTALL)
|
||||||
|
if not match:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = json.loads(match.group())
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return None
|
||||||
|
title = str(payload.get("title", "")).strip()
|
||||||
|
body = str(payload.get("body", "")).strip()
|
||||||
|
if not title or not body:
|
||||||
|
return None
|
||||||
|
return title[:TITLE_MAX], body[:BODY_MAX]
|
||||||
|
|
||||||
|
|
||||||
|
async def enhance_ticket(
|
||||||
|
title: str, description: str, config: GiteaConfig
|
||||||
|
) -> EnhancedTicket:
|
||||||
|
title = title.strip()
|
||||||
|
description = description.strip()
|
||||||
|
if not config.ai_enhance:
|
||||||
|
return _fallback(title, description)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"model": config.ai_model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": f"Reporter title: {title}\n\nReporter description:\n{description}",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"max_tokens": MAX_TOKENS,
|
||||||
|
"temperature": 0.2,
|
||||||
|
}
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
if config.ai_key:
|
||||||
|
headers["Authorization"] = f"Bearer {config.ai_key}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT_SECONDS) as client:
|
||||||
|
response = await client.post(
|
||||||
|
INTERNAL_GATEWAY_URL, json=payload, headers=headers
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
content = (
|
||||||
|
response.json()
|
||||||
|
.get("choices", [{}])[0]
|
||||||
|
.get("message", {})
|
||||||
|
.get("content", "")
|
||||||
|
)
|
||||||
|
except (httpx.HTTPError, ValueError, KeyError, IndexError) as exc:
|
||||||
|
logger.warning("Bug ticket enhancement failed, using fallback: %s", exc)
|
||||||
|
return _fallback(title, description)
|
||||||
|
|
||||||
|
parsed = _parse(content)
|
||||||
|
if not parsed:
|
||||||
|
logger.warning("Bug ticket enhancement returned unparseable content")
|
||||||
|
return _fallback(title, description)
|
||||||
|
new_title, new_body = parsed
|
||||||
|
logger.info("Enhanced bug ticket '%s' -> '%s'", title[:60], new_title[:60])
|
||||||
|
return EnhancedTicket(title=new_title, body=new_body, enhanced=True)
|
||||||
114
devplacepy/services/gitea/fake.py
Normal file
114
devplacepy/services/gitea/fake.py
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from devplacepy.services.gitea.client import (
|
||||||
|
STATE_ALL,
|
||||||
|
STATE_CLOSED,
|
||||||
|
STATE_OPEN,
|
||||||
|
GiteaError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
class FakeGiteaClient:
|
||||||
|
def __init__(self, bot_login: str = "devplace-bot"):
|
||||||
|
self.bot_login = bot_login
|
||||||
|
self._issues: dict[int, dict] = {}
|
||||||
|
self._comments: dict[int, list[dict]] = {}
|
||||||
|
self._issue_seq = 0
|
||||||
|
self._comment_seq = 0
|
||||||
|
|
||||||
|
def _html(self, number: int) -> str:
|
||||||
|
return f"https://gitea.test/retoor/pydevplace/issues/{number}"
|
||||||
|
|
||||||
|
async def create_issue(self, title: str, body: str) -> dict:
|
||||||
|
self._issue_seq += 1
|
||||||
|
number = self._issue_seq
|
||||||
|
now = _now()
|
||||||
|
issue = {
|
||||||
|
"number": number,
|
||||||
|
"title": title,
|
||||||
|
"body": body,
|
||||||
|
"state": STATE_OPEN,
|
||||||
|
"html_url": self._html(number),
|
||||||
|
"user": {"login": self.bot_login},
|
||||||
|
"comments": 0,
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
"closed_at": None,
|
||||||
|
}
|
||||||
|
self._issues[number] = issue
|
||||||
|
self._comments[number] = []
|
||||||
|
return dict(issue)
|
||||||
|
|
||||||
|
async def list_issues(
|
||||||
|
self, state: str = STATE_OPEN, page: int = 1, limit: int = 20
|
||||||
|
) -> tuple[list[dict], int]:
|
||||||
|
rows = [
|
||||||
|
issue
|
||||||
|
for issue in self._issues.values()
|
||||||
|
if state == STATE_ALL or issue["state"] == state
|
||||||
|
]
|
||||||
|
rows.sort(key=lambda item: item["number"], reverse=True)
|
||||||
|
total = len(rows)
|
||||||
|
start = (max(1, page) - 1) * limit
|
||||||
|
return [dict(item) for item in rows[start : start + limit]], total
|
||||||
|
|
||||||
|
async def get_issue(self, number: int) -> dict:
|
||||||
|
issue = self._issues.get(number)
|
||||||
|
if not issue:
|
||||||
|
raise GiteaError("issue not found", status=404)
|
||||||
|
return dict(issue)
|
||||||
|
|
||||||
|
async def list_comments(self, number: int) -> list[dict]:
|
||||||
|
if number not in self._issues:
|
||||||
|
raise GiteaError("issue not found", status=404)
|
||||||
|
return [dict(item) for item in self._comments.get(number, [])]
|
||||||
|
|
||||||
|
async def create_comment(self, number: int, body: str) -> dict:
|
||||||
|
if number not in self._issues:
|
||||||
|
raise GiteaError("issue not found", status=404)
|
||||||
|
self._comment_seq += 1
|
||||||
|
now = _now()
|
||||||
|
comment = {
|
||||||
|
"id": self._comment_seq,
|
||||||
|
"body": body,
|
||||||
|
"html_url": f"{self._html(number)}#comment-{self._comment_seq}",
|
||||||
|
"user": {"login": self.bot_login},
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
self._comments[number].append(comment)
|
||||||
|
self._issues[number]["comments"] = len(self._comments[number])
|
||||||
|
return dict(comment)
|
||||||
|
|
||||||
|
async def set_state(self, number: int, state: str) -> dict:
|
||||||
|
issue = self._issues.get(number)
|
||||||
|
if not issue:
|
||||||
|
raise GiteaError("issue not found", status=404)
|
||||||
|
if state not in (STATE_OPEN, STATE_CLOSED):
|
||||||
|
raise GiteaError(f"invalid state: {state}", status=400)
|
||||||
|
issue["state"] = state
|
||||||
|
issue["closed_at"] = _now() if state == STATE_CLOSED else None
|
||||||
|
issue["updated_at"] = _now()
|
||||||
|
return dict(issue)
|
||||||
|
|
||||||
|
def add_external_comment(self, number: int, login: str, body: str) -> dict:
|
||||||
|
self._comment_seq += 1
|
||||||
|
now = _now()
|
||||||
|
comment = {
|
||||||
|
"id": self._comment_seq,
|
||||||
|
"body": body,
|
||||||
|
"html_url": f"{self._html(number)}#comment-{self._comment_seq}",
|
||||||
|
"user": {"login": login},
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
self._comments.setdefault(number, []).append(comment)
|
||||||
|
if number in self._issues:
|
||||||
|
self._issues[number]["comments"] = len(self._comments[number])
|
||||||
|
return dict(comment)
|
||||||
16
devplacepy/services/gitea/runtime.py
Normal file
16
devplacepy/services/gitea/runtime.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
_client = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_client():
|
||||||
|
if _client is not None:
|
||||||
|
return _client
|
||||||
|
from devplacepy.services.gitea.client import GiteaClient
|
||||||
|
|
||||||
|
return GiteaClient()
|
||||||
|
|
||||||
|
|
||||||
|
def set_client(client) -> None:
|
||||||
|
global _client
|
||||||
|
_client = client
|
||||||
108
devplacepy/services/gitea/service.py
Normal file
108
devplacepy/services/gitea/service.py
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from devplacepy.services.base import BaseService
|
||||||
|
from devplacepy.services.gitea import runtime, store
|
||||||
|
from devplacepy.services.gitea.client import GiteaError
|
||||||
|
from devplacepy.services.gitea.config import CONFIG_FIELDS, gitea_config
|
||||||
|
from devplacepy.utils import create_notification
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BugTrackerService(BaseService):
|
||||||
|
interval_key = "bug_tracker_interval"
|
||||||
|
min_interval = 30
|
||||||
|
default_enabled = False
|
||||||
|
title = "Bug Tracker"
|
||||||
|
description = (
|
||||||
|
"Holds the Gitea connection and AI settings for the bug tracker and polls every "
|
||||||
|
"tracked issue for developer replies and status changes, notifying the original "
|
||||||
|
"reporter when their ticket is updated."
|
||||||
|
)
|
||||||
|
config_fields = CONFIG_FIELDS
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(name="bug_tracker", interval_seconds=120)
|
||||||
|
|
||||||
|
async def run_once(self) -> None:
|
||||||
|
config = gitea_config()
|
||||||
|
if not config.is_configured:
|
||||||
|
self.log("Gitea integration not configured; skipping poll")
|
||||||
|
return
|
||||||
|
|
||||||
|
client = runtime.get_client()
|
||||||
|
tickets = store.tracked_tickets()
|
||||||
|
self.log(f"Polling {len(tickets)} tracked tickets")
|
||||||
|
checked = 0
|
||||||
|
notified = 0
|
||||||
|
for ticket in tickets:
|
||||||
|
number = int(ticket["gitea_number"])
|
||||||
|
try:
|
||||||
|
issue = await client.get_issue(number)
|
||||||
|
except GiteaError as exc:
|
||||||
|
self.log(f"Could not poll #{number}: {exc}")
|
||||||
|
continue
|
||||||
|
checked += 1
|
||||||
|
notified += await self._reconcile(client, ticket, issue)
|
||||||
|
self.log(f"Polled {checked} tickets, sent {notified} notifications")
|
||||||
|
|
||||||
|
async def _reconcile(self, client, ticket: dict, issue: dict) -> int:
|
||||||
|
number = int(ticket["gitea_number"])
|
||||||
|
author_uid = ticket["author_uid"]
|
||||||
|
state = issue.get("state", "open")
|
||||||
|
comment_count = int(issue.get("comments", 0))
|
||||||
|
last_count = int(ticket.get("last_comment_count", 0))
|
||||||
|
last_status = ticket.get("last_status", "open")
|
||||||
|
notified = 0
|
||||||
|
|
||||||
|
if comment_count > last_count:
|
||||||
|
try:
|
||||||
|
comments = await client.list_comments(number)
|
||||||
|
except GiteaError as exc:
|
||||||
|
self.log(f"Could not load comments for #{number}: {exc}")
|
||||||
|
return notified
|
||||||
|
local_ids = store.local_comment_ids(number)
|
||||||
|
fresh = comments[last_count:]
|
||||||
|
external = [c for c in fresh if int(c.get("id", 0)) not in local_ids]
|
||||||
|
if external:
|
||||||
|
create_notification(
|
||||||
|
author_uid,
|
||||||
|
"bug",
|
||||||
|
f"New developer reply on your bug #{number}",
|
||||||
|
str(number),
|
||||||
|
f"/bugs/{number}",
|
||||||
|
)
|
||||||
|
self._audit("bug.sync.reply", author_uid, number)
|
||||||
|
notified += 1
|
||||||
|
|
||||||
|
if state != last_status:
|
||||||
|
verb = "closed" if state == "closed" else "reopened"
|
||||||
|
create_notification(
|
||||||
|
author_uid,
|
||||||
|
"bug",
|
||||||
|
f"Your bug #{number} was {verb}",
|
||||||
|
str(number),
|
||||||
|
f"/bugs/{number}",
|
||||||
|
)
|
||||||
|
self._audit("bug.sync.status", author_uid, number, {"state": state})
|
||||||
|
notified += 1
|
||||||
|
|
||||||
|
store.update_ticket_cache(number, state, comment_count)
|
||||||
|
return notified
|
||||||
|
|
||||||
|
def _audit(
|
||||||
|
self, event: str, author_uid: str, number: int, metadata: dict | None = None
|
||||||
|
) -> None:
|
||||||
|
from devplacepy.services.audit import record as audit
|
||||||
|
|
||||||
|
audit.record_system(
|
||||||
|
event,
|
||||||
|
actor_kind="service",
|
||||||
|
target_type="bug",
|
||||||
|
target_uid=str(number),
|
||||||
|
metadata={"number": number, **(metadata or {})},
|
||||||
|
summary=f"bug #{number} update detected",
|
||||||
|
links=[audit.recipient(author_uid)],
|
||||||
|
)
|
||||||
116
devplacepy/services/gitea/store.py
Normal file
116
devplacepy/services/gitea/store.py
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
# retoor <retoor@molodetz.nl>
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from devplacepy.database import get_table
|
||||||
|
from devplacepy.utils import generate_uid
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
TICKETS = "bug_tickets"
|
||||||
|
COMMENT_AUTHORS = "bug_comment_authors"
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def record_ticket(
|
||||||
|
number: int,
|
||||||
|
author_uid: str,
|
||||||
|
original_title: str,
|
||||||
|
original_description: str,
|
||||||
|
enhanced_title: str,
|
||||||
|
html_url: str,
|
||||||
|
status: str,
|
||||||
|
) -> str:
|
||||||
|
uid = generate_uid()
|
||||||
|
get_table(TICKETS).insert(
|
||||||
|
{
|
||||||
|
"uid": uid,
|
||||||
|
"gitea_number": int(number),
|
||||||
|
"author_uid": author_uid,
|
||||||
|
"original_title": original_title[:200],
|
||||||
|
"original_description": original_description[:5000],
|
||||||
|
"enhanced_title": enhanced_title[:200],
|
||||||
|
"html_url": html_url,
|
||||||
|
"last_status": status,
|
||||||
|
"last_comment_count": 0,
|
||||||
|
"created_at": _now(),
|
||||||
|
"updated_at": _now(),
|
||||||
|
"deleted_at": None,
|
||||||
|
"deleted_by": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
logger.info("Recorded bug ticket #%s by %s", number, author_uid)
|
||||||
|
return uid
|
||||||
|
|
||||||
|
|
||||||
|
def get_ticket(number: int) -> dict | None:
|
||||||
|
return get_table(TICKETS).find_one(gitea_number=int(number), deleted_at=None)
|
||||||
|
|
||||||
|
|
||||||
|
def author_uid_for_issue(number: int) -> str | None:
|
||||||
|
row = get_ticket(number)
|
||||||
|
return row["author_uid"] if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def author_map(numbers: list[int]) -> dict[int, str]:
|
||||||
|
if not numbers:
|
||||||
|
return {}
|
||||||
|
table = get_table(TICKETS)
|
||||||
|
ids = [int(n) for n in numbers]
|
||||||
|
rows = table.find(table.table.columns.gitea_number.in_(ids), deleted_at=None)
|
||||||
|
return {int(row["gitea_number"]): row["author_uid"] for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def tracked_tickets() -> list[dict]:
|
||||||
|
return list(get_table(TICKETS).find(deleted_at=None, order_by=["gitea_number"]))
|
||||||
|
|
||||||
|
|
||||||
|
def update_ticket_cache(number: int, status: str, comment_count: int) -> None:
|
||||||
|
row = get_ticket(number)
|
||||||
|
if not row:
|
||||||
|
return
|
||||||
|
get_table(TICKETS).update(
|
||||||
|
{
|
||||||
|
"uid": row["uid"],
|
||||||
|
"last_status": status,
|
||||||
|
"last_comment_count": int(comment_count),
|
||||||
|
"updated_at": _now(),
|
||||||
|
},
|
||||||
|
["uid"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def record_comment_author(comment_id: int, number: int, author_uid: str) -> str:
|
||||||
|
uid = generate_uid()
|
||||||
|
get_table(COMMENT_AUTHORS).insert(
|
||||||
|
{
|
||||||
|
"uid": uid,
|
||||||
|
"gitea_comment_id": int(comment_id),
|
||||||
|
"gitea_number": int(number),
|
||||||
|
"author_uid": author_uid,
|
||||||
|
"created_at": _now(),
|
||||||
|
"deleted_at": None,
|
||||||
|
"deleted_by": None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return uid
|
||||||
|
|
||||||
|
|
||||||
|
def comment_author_map(comment_ids: list[int]) -> dict[int, str]:
|
||||||
|
if not comment_ids:
|
||||||
|
return {}
|
||||||
|
table = get_table(COMMENT_AUTHORS)
|
||||||
|
ids = [int(cid) for cid in comment_ids]
|
||||||
|
rows = table.find(
|
||||||
|
table.table.columns.gitea_comment_id.in_(ids), deleted_at=None
|
||||||
|
)
|
||||||
|
return {int(row["gitea_comment_id"]): row["author_uid"] for row in rows}
|
||||||
|
|
||||||
|
|
||||||
|
def local_comment_ids(number: int) -> set[int]:
|
||||||
|
rows = get_table(COMMENT_AUTHORS).find(gitea_number=int(number), deleted_at=None)
|
||||||
|
return {int(row["gitea_comment_id"]) for row in rows}
|
||||||
113
devplacepy/services/jobs/bug_create_service.py
Normal file
113
devplacepy/services/jobs/bug_create_service.py
Normal file
@ -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-size: 1.5rem;
|
||||||
font-weight: 700;
|
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 {
|
.bug-card {
|
||||||
|
display: block;
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.15s ease;
|
||||||
|
}
|
||||||
|
.bug-card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
.bug-card-header {
|
.bug-card-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -27,6 +60,14 @@
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
.bug-number {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.bug-title {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
.bug-title {
|
.bug-title {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@ -55,9 +96,100 @@
|
|||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
.bug-meta {
|
.bug-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
color: var(--text-muted);
|
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) {
|
@media (max-width: 480px) {
|
||||||
.bugs-header {
|
.bugs-header {
|
||||||
|
|||||||
@ -25,6 +25,7 @@ import { Tabs } from "./Tabs.js";
|
|||||||
import { DeviiTerminal } from "./DeviiTerminal.js";
|
import { DeviiTerminal } from "./DeviiTerminal.js";
|
||||||
import { ZipDownloader } from "./ZipDownloader.js";
|
import { ZipDownloader } from "./ZipDownloader.js";
|
||||||
import { ProjectForker } from "./ProjectForker.js";
|
import { ProjectForker } from "./ProjectForker.js";
|
||||||
|
import { BugReporter } from "./BugReporter.js";
|
||||||
import { MediaGallery } from "./MediaGallery.js";
|
import { MediaGallery } from "./MediaGallery.js";
|
||||||
import WindowManager from "./components/WindowManager.js";
|
import WindowManager from "./components/WindowManager.js";
|
||||||
import { ContainerTerminalManager } from "./ContainerTerminalManager.js";
|
import { ContainerTerminalManager } from "./ContainerTerminalManager.js";
|
||||||
@ -62,6 +63,7 @@ class Application {
|
|||||||
this.devii = new DeviiTerminal();
|
this.devii = new DeviiTerminal();
|
||||||
this.zipDownloader = new ZipDownloader();
|
this.zipDownloader = new ZipDownloader();
|
||||||
this.projectForker = new ProjectForker();
|
this.projectForker = new ProjectForker();
|
||||||
|
this.bugReporter = new BugReporter();
|
||||||
this.mediaGallery = new MediaGallery();
|
this.mediaGallery = new MediaGallery();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
59
devplacepy/static/js/BugReporter.js
Normal file
59
devplacepy/static/js/BugReporter.js
Normal file
@ -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" }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
78
devplacepy/templates/bug_detail.html
Normal file
78
devplacepy/templates/bug_detail.html
Normal file
@ -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" %}
|
{% extends "base.html" %}
|
||||||
{% from "_macros.html" import modal %}
|
{% from "_macros.html" import modal %}
|
||||||
{% block extra_head %}
|
{% 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">
|
<link rel="stylesheet" href="/static/css/bugs.css">
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@ -16,46 +14,56 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</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">
|
<div class="bugs-list">
|
||||||
{% for item in bugs %}
|
{% for bug in bugs %}
|
||||||
<div class="bug-card">
|
<a class="bug-card" href="/bugs/{{ bug.number }}">
|
||||||
<div class="bug-card-header">
|
<div class="bug-card-header">
|
||||||
{% set _user = item.author %}{% set _size = 24 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
|
<span class="bug-number">#{{ bug.number }}</span>
|
||||||
<span class="bug-title">{{ item.bug['title'] }}</span>
|
<span class="bug-title">{{ bug.title }}</span>
|
||||||
<span class="bug-status {{ item.bug['status'] }}">{{ item.bug['status'] }}</span>
|
<span class="bug-status {{ bug.state }}">{{ bug.state }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="bug-desc">{{ item.bug['description'][:300] }}</div>
|
<div class="bug-meta">
|
||||||
<div class="bug-meta">{{ item.author['username'] if item.author else 'Unknown' }} · {{ item.time_ago }}</div>
|
{% if bug.is_local_author %}
|
||||||
|
{% set _user = {'username': bug.author_username} %}{% set _size = 20 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
|
||||||
{% set b_attachments = item.get('attachments', []) %}
|
|
||||||
{% if b_attachments %}
|
|
||||||
{% with attachments=b_attachments %}
|
|
||||||
{% include "_attachment_display.html" %}
|
|
||||||
{% endwith %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<span class="bug-author">{{ bug.author_username }}</span>
|
||||||
{% with target_uid=item.bug['uid'], target_type="bug", comments=item.comments %}
|
<span class="bug-dot">·</span>
|
||||||
{% include "_comment_section.html" %}
|
<span class="bug-date">{{ format_date(bug.created_at) }}</span>
|
||||||
{% endwith %}
|
<span class="bug-dot">·</span>
|
||||||
|
<span class="bug-comments">{{ bug.comments_count }} comment{{ '' if bug.comments_count == 1 else 's' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="empty-state">No bug reports yet.</div>
|
<div class="empty-state">No bug reports yet.</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% set pagination_query = 'state=' ~ state ~ '&' %}
|
||||||
|
{% include "_pagination.html" %}
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if user %}
|
{% if user %}
|
||||||
{% call modal('create-bug-modal', 'Report a Bug') %}
|
{% 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">
|
<div class="auth-field auth-field-gap">
|
||||||
<label for="bug-title">Title</label>
|
<label for="bug-title">Title</label>
|
||||||
<input type="text" id="bug-title" name="title" required maxlength="200" placeholder="Brief description of the issue">
|
<input type="text" id="bug-title" name="title" required maxlength="200" placeholder="Brief description of the issue">
|
||||||
</div>
|
</div>
|
||||||
<div class="auth-field auth-field-gap">
|
<div class="auth-field auth-field-gap">
|
||||||
<label for="bug-description">Description</label>
|
<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>
|
</div>
|
||||||
{% include "_attachment_form.html" %}
|
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
|
<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>
|
<button type="submit" class="btn btn-primary"><span class="icon">📤</span> Submit Report</button>
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
from tests.conftest import BASE_URL
|
from tests.conftest import BASE_URL
|
||||||
from devplacepy.database import get_table
|
|
||||||
|
|
||||||
|
|
||||||
def test_bugs_page_loads(alice):
|
def test_bugs_page_loads(alice):
|
||||||
@ -8,6 +7,20 @@ def test_bugs_page_loads(alice):
|
|||||||
assert page.is_visible("h1:has-text('Bug Reports')")
|
assert page.is_visible("h1:has-text('Bug Reports')")
|
||||||
|
|
||||||
|
|
||||||
|
def test_bugs_filters_visible(alice):
|
||||||
|
page, _ = alice
|
||||||
|
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
|
||||||
|
assert page.locator(".bugs-filter:has-text('Open')").is_visible()
|
||||||
|
assert page.locator(".bugs-filter:has-text('Closed')").is_visible()
|
||||||
|
assert page.locator(".bugs-filter:has-text('All')").is_visible()
|
||||||
|
|
||||||
|
|
||||||
|
def test_bugs_not_configured_notice(alice):
|
||||||
|
page, _ = alice
|
||||||
|
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
|
||||||
|
assert page.is_visible("text=The bug tracker is not configured yet.")
|
||||||
|
|
||||||
|
|
||||||
def test_bugs_create_modal(alice):
|
def test_bugs_create_modal(alice):
|
||||||
page, _ = alice
|
page, _ = alice
|
||||||
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
|
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
|
||||||
@ -15,34 +28,6 @@ def test_bugs_create_modal(alice):
|
|||||||
assert page.is_visible("h3:has-text('Report a Bug')")
|
assert page.is_visible("h3:has-text('Report a Bug')")
|
||||||
|
|
||||||
|
|
||||||
def test_bugs_empty_state(alice):
|
|
||||||
page, _ = alice
|
|
||||||
get_table("bug_reports").delete()
|
|
||||||
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
|
|
||||||
assert page.is_visible("text=No bug reports yet.")
|
|
||||||
|
|
||||||
|
|
||||||
def test_bugs_create_submit(alice):
|
|
||||||
page, _ = alice
|
|
||||||
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
|
|
||||||
page.click("button:has-text('Report Bug')")
|
|
||||||
page.fill("#bug-title", "Login button is misaligned")
|
|
||||||
page.fill("#bug-description", "The login button overlaps the logo on mobile.")
|
|
||||||
page.click("button:has-text('Submit Report')")
|
|
||||||
page.wait_for_timeout(500)
|
|
||||||
assert page.is_visible("text=Login button is misaligned")
|
|
||||||
assert not page.is_visible("text=No bug reports yet.")
|
|
||||||
|
|
||||||
|
|
||||||
def test_bugs_footer_link(alice):
|
|
||||||
page, _ = alice
|
|
||||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
|
||||||
link = page.locator("a:has-text('Bug Report')")
|
|
||||||
assert link.is_visible()
|
|
||||||
link.click()
|
|
||||||
page.wait_for_url(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
|
|
||||||
|
|
||||||
|
|
||||||
def test_bugs_create_cancel(alice):
|
def test_bugs_create_cancel(alice):
|
||||||
page, _ = alice
|
page, _ = alice
|
||||||
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
|
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
|
||||||
@ -52,77 +37,20 @@ def test_bugs_create_cancel(alice):
|
|||||||
assert not modal.is_visible()
|
assert not modal.is_visible()
|
||||||
|
|
||||||
|
|
||||||
def test_bugs_unauth_empty(page):
|
def test_bugs_unauth(page):
|
||||||
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
|
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
|
||||||
assert page.is_visible("h1:has-text('Bug Reports')")
|
assert page.is_visible("h1:has-text('Bug Reports')")
|
||||||
assert page.locator("a.login-required:has-text('Report Bug')").is_visible()
|
assert page.locator("a.login-required:has-text('Report Bug')").is_visible()
|
||||||
assert page.locator("#create-bug-modal").count() == 0
|
assert page.locator("#create-bug-modal").count() == 0
|
||||||
|
|
||||||
|
|
||||||
def test_bug_comments_form_visible(alice):
|
def test_bugs_footer_link(alice):
|
||||||
page, _ = alice
|
page, _ = alice
|
||||||
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||||
page.click("button:has-text('Report Bug')")
|
link = page.locator("a:has-text('Bug Report')")
|
||||||
page.fill("#bug-title", "Bug with comments")
|
assert link.is_visible()
|
||||||
page.fill("#bug-description", "Testing comment form on bug")
|
link.click()
|
||||||
page.click("button:has-text('Submit Report')")
|
page.wait_for_url(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
|
||||||
page.wait_for_timeout(500)
|
|
||||||
assert page.is_visible("text=Comments")
|
|
||||||
assert page.is_visible("textarea[name='content']")
|
|
||||||
|
|
||||||
|
|
||||||
def test_bug_comment_create(alice):
|
|
||||||
page, _ = alice
|
|
||||||
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
|
|
||||||
page.click("button:has-text('Report Bug')")
|
|
||||||
page.fill("#bug-title", "Bug comment test")
|
|
||||||
page.fill("#bug-description", "Bug description for comment")
|
|
||||||
page.click("button:has-text('Submit Report')")
|
|
||||||
page.wait_for_timeout(500)
|
|
||||||
page.fill("textarea[name='content']", "Bug comment here")
|
|
||||||
page.click("button:has-text('Post')")
|
|
||||||
page.wait_for_timeout(500)
|
|
||||||
assert page.is_visible("text=Bug comment here")
|
|
||||||
|
|
||||||
|
|
||||||
def test_bug_comment_reply(alice):
|
|
||||||
page, _ = alice
|
|
||||||
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
|
|
||||||
page.click("button:has-text('Report Bug')")
|
|
||||||
page.fill("#bug-title", "Bug threaded reply")
|
|
||||||
page.fill("#bug-description", "Bug for thread test")
|
|
||||||
page.click("button:has-text('Submit Report')")
|
|
||||||
page.wait_for_timeout(500)
|
|
||||||
page.fill("textarea[name='content']", "Root comment")
|
|
||||||
page.click("button:has-text('Post')")
|
|
||||||
page.wait_for_timeout(500)
|
|
||||||
assert page.is_visible("text=Root comment")
|
|
||||||
page.click("button:has-text('Reply')")
|
|
||||||
reply_form = page.locator(".comment-reply-form").first
|
|
||||||
reply_form.locator("textarea[name='content']").fill("Nested reply")
|
|
||||||
reply_form.locator("button:has-text('Post')").click()
|
|
||||||
page.wait_for_timeout(500)
|
|
||||||
assert page.is_visible("text=Nested reply")
|
|
||||||
|
|
||||||
|
|
||||||
def test_bug_comment_delete(alice):
|
|
||||||
page, _ = alice
|
|
||||||
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
|
|
||||||
page.click("button:has-text('Report Bug')")
|
|
||||||
page.fill("#bug-title", "Bug delete comment")
|
|
||||||
page.fill("#bug-description", "Bug for delete test")
|
|
||||||
page.click("button:has-text('Submit Report')")
|
|
||||||
page.wait_for_timeout(500)
|
|
||||||
page.fill("textarea[name='content']", "Delete me")
|
|
||||||
page.click("button:has-text('Post')")
|
|
||||||
page.wait_for_timeout(500)
|
|
||||||
assert page.is_visible("text=Delete me")
|
|
||||||
page.locator(
|
|
||||||
".comment-body:has-text('Delete me') .comment-action-btn:has-text('Delete')"
|
|
||||||
).click()
|
|
||||||
page.locator(".dialog-overlay.visible .dialog-confirm").click()
|
|
||||||
page.wait_for_timeout(500)
|
|
||||||
assert not page.is_visible("text=Delete me")
|
|
||||||
|
|
||||||
|
|
||||||
def test_bug_button_icon_spacing(alice):
|
def test_bug_button_icon_spacing(alice):
|
||||||
@ -136,3 +64,17 @@ def test_bug_button_icon_spacing(alice):
|
|||||||
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
page.goto(f"{BASE_URL}/feed", wait_until="domcontentloaded")
|
||||||
footer = page.locator("a:has-text('Bug Report')").inner_html()
|
footer = page.locator("a:has-text('Bug Report')").inner_html()
|
||||||
assert "</span> Bug Report" in footer
|
assert "</span> Bug Report" in footer
|
||||||
|
|
||||||
|
|
||||||
|
def test_bug_detail_not_configured(alice):
|
||||||
|
page, _ = alice
|
||||||
|
page.goto(f"{BASE_URL}/bugs/999", wait_until="domcontentloaded")
|
||||||
|
assert page.is_visible("h1.error-code:has-text('404')")
|
||||||
|
assert page.is_visible("text=Page not found")
|
||||||
|
|
||||||
|
|
||||||
|
def test_bug_job_status_not_found(alice):
|
||||||
|
page, _ = alice
|
||||||
|
page.goto(f"{BASE_URL}/bugs/jobs/nonexistent-job", wait_until="domcontentloaded")
|
||||||
|
assert page.is_visible("h1.error-code:has-text('404')")
|
||||||
|
assert page.is_visible("text=Page not found")
|
||||||
|
|||||||
225
tests/test_bugs_gitea.py
Normal file
225
tests/test_bugs_gitea.py
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from devplacepy.database import (
|
||||||
|
get_table,
|
||||||
|
init_db,
|
||||||
|
refresh_snapshot,
|
||||||
|
set_setting,
|
||||||
|
)
|
||||||
|
from devplacepy.services.gitea import runtime, store
|
||||||
|
from devplacepy.services.gitea.config import gitea_config
|
||||||
|
from devplacepy.services.gitea.enhance import enhance_ticket
|
||||||
|
from devplacepy.services.gitea.fake import FakeGiteaClient
|
||||||
|
from devplacepy.services.gitea.service import BugTrackerService
|
||||||
|
from devplacepy.services.jobs import queue
|
||||||
|
from devplacepy.services.jobs.bug_create_service import BugCreateService
|
||||||
|
from tests.conftest import run_async
|
||||||
|
|
||||||
|
_counter = [0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _init_db():
|
||||||
|
init_db()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def gitea_env():
|
||||||
|
fake = FakeGiteaClient()
|
||||||
|
runtime.set_client(fake)
|
||||||
|
set_setting("gitea_base_url", "https://gitea.test")
|
||||||
|
set_setting("gitea_owner", "retoor")
|
||||||
|
set_setting("gitea_repo", "pydevplace")
|
||||||
|
set_setting("gitea_token", "test-token")
|
||||||
|
set_setting("bug_ai_enhance", "0")
|
||||||
|
yield fake
|
||||||
|
runtime.set_client(None)
|
||||||
|
for table in ("bug_tickets", "bug_comment_authors"):
|
||||||
|
for row in list(get_table(table).find()):
|
||||||
|
get_table(table).delete(uid=row["uid"])
|
||||||
|
for row in list(get_table("jobs").find(kind="bug_create")):
|
||||||
|
get_table("jobs").delete(uid=row["uid"])
|
||||||
|
|
||||||
|
|
||||||
|
def _make_user():
|
||||||
|
_counter[0] += 1
|
||||||
|
uid = f"bugtest-user-{_counter[0]}"
|
||||||
|
username = f"bugtester{_counter[0]}"
|
||||||
|
get_table("users").insert(
|
||||||
|
{"uid": uid, "username": username, "role": "member", "xp": 0, "level": 1}
|
||||||
|
)
|
||||||
|
return uid, username
|
||||||
|
|
||||||
|
|
||||||
|
def _unread(user_uid):
|
||||||
|
return [
|
||||||
|
n
|
||||||
|
for n in get_table("notifications").find(user_uid=user_uid)
|
||||||
|
if n.get("type") == "bug"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _drive_jobs():
|
||||||
|
async def run():
|
||||||
|
svc = BugCreateService()
|
||||||
|
for _ in range(200):
|
||||||
|
await svc.run_once()
|
||||||
|
refresh_snapshot()
|
||||||
|
pending = [
|
||||||
|
r
|
||||||
|
for r in get_table("jobs").find(kind="bug_create")
|
||||||
|
if r["status"] in ("pending", "running")
|
||||||
|
]
|
||||||
|
if not pending and not svc._inflight:
|
||||||
|
return
|
||||||
|
await asyncio.sleep(0.02)
|
||||||
|
|
||||||
|
run_async(run())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- fake client ----------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_fake_client_create_and_list(gitea_env):
|
||||||
|
async def go():
|
||||||
|
issue = await gitea_env.create_issue("Crash on save", "details")
|
||||||
|
assert issue["number"] == 1
|
||||||
|
assert issue["state"] == "open"
|
||||||
|
issues, total = await gitea_env.list_issues(state="open")
|
||||||
|
assert total == 1
|
||||||
|
assert issues[0]["number"] == 1
|
||||||
|
|
||||||
|
run_async(go())
|
||||||
|
|
||||||
|
|
||||||
|
def test_fake_client_state_and_comments(gitea_env):
|
||||||
|
async def go():
|
||||||
|
issue = await gitea_env.create_issue("t", "b")
|
||||||
|
number = issue["number"]
|
||||||
|
await gitea_env.create_comment(number, "first")
|
||||||
|
fetched = await gitea_env.get_issue(number)
|
||||||
|
assert fetched["comments"] == 1
|
||||||
|
closed = await gitea_env.set_state(number, "closed")
|
||||||
|
assert closed["state"] == "closed"
|
||||||
|
open_issues, _ = await gitea_env.list_issues(state="open")
|
||||||
|
assert open_issues == []
|
||||||
|
all_issues, total = await gitea_env.list_issues(state="all")
|
||||||
|
assert total == 1
|
||||||
|
|
||||||
|
run_async(go())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- store ----------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_records_and_maps(gitea_env):
|
||||||
|
uid, _ = _make_user()
|
||||||
|
store.record_ticket(7, uid, "orig", "desc", "Better", "https://x/7", "open")
|
||||||
|
assert store.author_uid_for_issue(7) == uid
|
||||||
|
assert store.author_map([7, 8]) == {7: uid}
|
||||||
|
cid = store.record_comment_author(99, 7, uid)
|
||||||
|
assert cid
|
||||||
|
assert store.comment_author_map([99, 100]) == {99: uid}
|
||||||
|
assert store.local_comment_ids(7) == {99}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- enhance fallback ----------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_enhance_fallback_when_disabled(gitea_env):
|
||||||
|
result = run_async(enhance_ticket("Title", "Body text", gitea_config()))
|
||||||
|
assert result.enhanced is False
|
||||||
|
assert "## Summary" in result.body
|
||||||
|
assert "## Steps to Reproduce" in result.body
|
||||||
|
assert result.title == "Title"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- create job ----------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_job_files_issue(gitea_env):
|
||||||
|
uid, username = _make_user()
|
||||||
|
job_uid = queue.enqueue(
|
||||||
|
"bug_create",
|
||||||
|
{"author_uid": uid, "title": "Login broken", "description": "cannot log in"},
|
||||||
|
"user",
|
||||||
|
uid,
|
||||||
|
"Login broken",
|
||||||
|
)
|
||||||
|
_drive_jobs()
|
||||||
|
job = queue.get_job(job_uid)
|
||||||
|
assert job["status"] == "done"
|
||||||
|
number = job["result"]["number"]
|
||||||
|
assert number == 1
|
||||||
|
ticket = store.get_ticket(number)
|
||||||
|
assert ticket["author_uid"] == uid
|
||||||
|
assert _unread(uid)
|
||||||
|
issue = run_async(gitea_env.get_issue(number))
|
||||||
|
assert username in issue["body"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_job_fails_without_config(gitea_env):
|
||||||
|
set_setting("gitea_token", "")
|
||||||
|
uid, _ = _make_user()
|
||||||
|
job_uid = queue.enqueue(
|
||||||
|
"bug_create",
|
||||||
|
{"author_uid": uid, "title": "x", "description": "y"},
|
||||||
|
"user",
|
||||||
|
uid,
|
||||||
|
"x",
|
||||||
|
)
|
||||||
|
_drive_jobs()
|
||||||
|
job = queue.get_job(job_uid)
|
||||||
|
assert job["status"] == "failed"
|
||||||
|
assert "not configured" in job["error"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- poller ----------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_poller_notifies_on_developer_reply(gitea_env):
|
||||||
|
uid, _ = _make_user()
|
||||||
|
issue = run_async(gitea_env.create_issue("bug", "body"))
|
||||||
|
number = issue["number"]
|
||||||
|
store.record_ticket(number, uid, "bug", "body", "bug", issue["html_url"], "open")
|
||||||
|
store.update_ticket_cache(number, "open", 0)
|
||||||
|
|
||||||
|
gitea_env.add_external_comment(number, "developer", "looking into it")
|
||||||
|
before = len(_unread(uid))
|
||||||
|
run_async(BugTrackerService().run_once())
|
||||||
|
refresh_snapshot()
|
||||||
|
assert len(_unread(uid)) == before + 1
|
||||||
|
assert int(store.get_ticket(number)["last_comment_count"]) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_poller_ignores_local_comment(gitea_env):
|
||||||
|
uid, _ = _make_user()
|
||||||
|
issue = run_async(gitea_env.create_issue("bug", "body"))
|
||||||
|
number = issue["number"]
|
||||||
|
store.record_ticket(number, uid, "bug", "body", "bug", issue["html_url"], "open")
|
||||||
|
store.update_ticket_cache(number, "open", 0)
|
||||||
|
|
||||||
|
comment = run_async(gitea_env.create_comment(number, "me again"))
|
||||||
|
store.record_comment_author(comment["id"], number, uid)
|
||||||
|
before = len(_unread(uid))
|
||||||
|
run_async(BugTrackerService().run_once())
|
||||||
|
refresh_snapshot()
|
||||||
|
assert len(_unread(uid)) == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_poller_notifies_on_status_change(gitea_env):
|
||||||
|
uid, _ = _make_user()
|
||||||
|
issue = run_async(gitea_env.create_issue("bug", "body"))
|
||||||
|
number = issue["number"]
|
||||||
|
store.record_ticket(number, uid, "bug", "body", "bug", issue["html_url"], "open")
|
||||||
|
store.update_ticket_cache(number, "open", 0)
|
||||||
|
|
||||||
|
run_async(gitea_env.set_state(number, "closed"))
|
||||||
|
before = len(_unread(uid))
|
||||||
|
run_async(BugTrackerService().run_once())
|
||||||
|
refresh_snapshot()
|
||||||
|
assert len(_unread(uid)) == before + 1
|
||||||
|
assert store.get_ticket(number)["last_status"] == "closed"
|
||||||
Loading…
Reference in New Issue
Block a user