Compare commits

...

2 Commits

Author SHA1 Message Date
5d314292fb Gitea implementation
Some checks failed
DevPlace CI / test (push) Has been cancelled
2026-06-12 20:31:40 +02:00
3c0f770f1b Update 2026-06-12 08:15:19 +02:00
35 changed files with 2201 additions and 258 deletions

View File

@ -900,9 +900,24 @@ Two owner-controlled flags on the `projects` row, both integer `0`/`1` (default
`confirmation_error` also blocks every Devii deletion until `confirm` is truthy. Unconditional entries in `CONFIRM_REQUIRED`: every content delete - `delete_post`, `delete_comment`, `delete_gist`, `delete_project`, `project_delete_file`, `delete_media`, `delete_attachment`, `admin_delete_news` - plus `project_set_readonly` and the customization mutations; a generic fallback message covers any name in the set that lacks a bespoke message. **Load-bearing invariant: every gated tool MUST declare a `confirm` body param** (the `confirm()` helper in `catalog.py`, or `arg("confirm", ..., kind="boolean")` for container actions). Tool schemas are `additionalProperties: false`, so the model can only send arguments that are declared properties - a gated tool with NO `confirm` param can never be confirmed, so `confirmation_error` refuses every call and the agent loops forever asking for a confirmation it has no way to express. This was a real production bug: an admin asked Devii to delete a user's 27 posts, confirmed repeatedly, and all 27 `delete_post` calls were rejected because `delete_post` (and `delete_project`/`delete_media`/`project_delete_file`/`container_*`) never exposed `confirm`. The param is harmlessly forwarded to the HTTP endpoint (routes do not declare it, FastAPI drops undeclared form fields) and ignored by the LOCAL container/customization controllers. Conditional entries (gated by inspecting arguments, not membership): `container_instance_action` only when `action="delete"` (start/stop/restart/pause/resume/sync are unaffected), and `container_exec` only when its `command` matches `dispatcher.DESTRUCTIVE_COMMAND` - a token-aware regex for `rm`/`rmdir`/`unlink`/`shred`/`truncate`/`dd`/`mkfs*`/`wipefs` (with or without a leading `sudo`), `find ... -delete`, redirection over a file (`> /...`), and SQL `drop table|database` / `delete from`. Benign commands (`pip install`, `ls`, `grep -rm`) are not matched. The error message echoes the exact path/command so the agent shows the user what will be removed; after a clear yes it retries with `confirm=true`. The `DELETING IS ALWAYS CONFIRMED` system-prompt section in `agent.py` mirrors the rule. This exists because an unconfirmed `container_exec rm -f` once deleted a live project database mid-"test the site"; the gate is the data-loss backstop. The regex is a heuristic (it cannot catch `python -c "os.remove(...)"` and similar), so it is defense-in-depth, not a sandbox - the system-prompt rule is the primary control.
## Bug Reports
## Bug Reports (Gitea integration)
A dedicated `/bugs` page with create modal for authenticated users. Uses `bug_reports` table (auto-created by `dataset`). Registered in `main.py` with prefix `/bugs`. Footer link in `base.html` under `.site-footer`.
The bug tracker is a full integration with a Gitea repository (default `retoor/pydevplace` on `retoor.molodetz.nl`); there is **no local issue store**, the listing and detail views read issues straight from Gitea with live status. All connection settings live in `site_settings` and are admin-editable on the `BugTrackerService` config at `/admin/services`.
**Package `services/gitea/`:**
- `config.py` - the `CONFIG_FIELDS` (Gitea base URL/owner/repo/token, AI enhance toggle/model/key) plus `gitea_config()` -> frozen `GiteaConfig` (with `api_base`/`repo_base`/`is_configured`) and `is_configured()`. The token is one shared bot account; per-user attribution is done by storing names in DevPlace, not by per-user Gitea accounts.
- `client.py` - async `GiteaClient` over the Gitea REST API (`Authorization: token`): `create_issue`, `list_issues(state,page,limit)` returning `(issues, total)` from `X-Total-Count` and filtering out pull requests, `get_issue`, `list_comments`, `create_comment`, `set_state`. Raises `GiteaError(message, status)` on any non-2xx or transport error.
- `fake.py` - in-memory `FakeGiteaClient` mirroring the same async interface (plus `add_external_comment` to simulate a developer reply); the test backend.
- `runtime.py` - `get_client()` / `set_client()` swap (tests inject the fake). `get_client()` builds a fresh `GiteaClient` from current config unless overridden.
- `enhance.py` - `enhance_ticket(title, description, config)` calls the **internal AI gateway** (`INTERNAL_GATEWAY_URL`, key defaults to the gateway internal key) to rewrite a raw report into a consistent ticket: a level-2-heading markdown body (`Summary` / `Steps to Reproduce` / `Expected Behaviour` / `Actual Behaviour` / `Environment`) and a tightened title. It is fail-soft: any error or unparseable response falls back to a deterministic template built from the original text (`EnhancedTicket.enhanced=False`). This is the AI attachment point - the gateway, like every other AI consumer.
- `store.py` - the **thin local mapping** (the only DB state): `bug_tickets` (gitea_number -> author_uid + original text + cached `last_status`/`last_comment_count` for update detection) and `bug_comment_authors` (gitea_comment_id -> author_uid, for display attribution and to tell DevPlace-authored comments from developer replies). Helpers: `record_ticket`, `get_ticket`, `author_uid_for_issue`, `author_map`, `record_comment_author`, `comment_author_map`, `local_comment_ids`, `tracked_tickets`, `update_ticket_cache`. Both tables are in `SOFT_DELETE_TABLES`; `init_db` ensures their full column set + indexes (so queries are safe on a fresh DB before any insert).
- `service.py` - `BugTrackerService(BaseService)` (`default_enabled=False`, holds `CONFIG_FIELDS`). Each tick it polls every tracked ticket: when the Gitea comment count grew, it loads comments and notifies the reporter if any of the new tail comments are **not** in `local_comment_ids` (a developer reply); when the state changed it notifies the reporter (closed/reopened). It then updates the cache. This is the single source of update notifications to the author (`bug.sync.reply` / `bug.sync.status`).
**Filing is an async job** (`services/jobs/bug_create_service.py` `BugCreateService`, kind `bug_create`): `process` loads the user, enhances the report, appends a `Reported by **user** via DevPlace` footer, creates the Gitea issue, records the local mapping, notifies the reporter (`Your bug report was filed as #N`), and audits `bug.create`. `cleanup` is a no-op (the issue is permanent; only the job row is swept). `POST /bugs/create` enqueues and returns `{uid, status_url}`; the frontend `app.bugReporter` (`static/js/BugReporter.js`) submits the create form, polls `/bugs/jobs/{uid}` via `JobPoller`, and redirects to `/bugs/{number}`.
**Routes (`routers/bugs.py`, prefix `/bugs`):** `GET /bugs` (Gitea list, `?state=open|closed|all&page=`, admin-style `build_pagination` + `_pagination.html` with a `state=` prefix; renders an empty-state notice when unconfigured/unreachable), `POST /bugs/create` (enqueue), `GET /bugs/jobs/{uid}` (`BugJobOut`), `GET /bugs/{number}` (`bug_detail.html`: issue body + comments rendered as markdown via `data-render`, decorated with DevPlace authors or a `dev` badge), `POST /bugs/{number}/comment` (synchronous - posts the comment to Gitea attributed to the user, records the author mapping, notifies **all admins**, audits `bug.comment`), `POST /bugs/{number}/status` (admin-only open/closed via Gitea PATCH, audits `bug.status`; non-admins get a `denied` audit + 403). Comments and status changes are deliberately synchronous (one fast Gitea call, immediate redirect); only the AI-heavy create is a job. Status-change notifications to the author are NOT emitted by the route - the poller detects the diff and notifies, so the admin (or external developer) closing an issue both flow through one path.
Schemas: `BugItemOut`/`BugCommentOut`/`BugDetailOut`/`BugsOut`/`BugJobOut` (`schemas.py`). Forms: `BugForm`/`BugCommentForm`/`BugStatusForm` (`models.py`). Devii tools: `list_bugs`, `create_bug`, `view_bug`, `comment_bug`, `set_bug_status` (admin). Docs: the `bugs` API group in `docs_api.py`. Footer link in `base.html` under `.site-footer`.
## Gists
@ -1649,6 +1664,7 @@ A fleet of autonomous AI maintenance agents lives in `agents/`. They are NOT par
- **Modes and reports.** `--fix` (default) edits and verifies; `--check` reports only and exits non-zero on findings. Every run writes `agents/reports/<agent>-<date>.{json,md}`. The finding schema is `{severity, dimension, file, line, rule, message, fixed, fix_summary}`.
- **Verification (no external tools).** `agents/validator.py` is a dependency-free multi-language validator and is the agents' `verify()` default (`python -m agents.validator .`): Python via `ast.parse` + `py_compile`, JavaScript via `node --check`, CSS via brace balance, HTML and Jinja templates via Jinja2 `parse`. It is also wired as `make validate` and needs no external validator.
- **Make targets.** `make <name>-agent` (with `CHECK=1` for report-only), `make agents-all` (+ `CHECK=1`), `make maestro`, `make validate`.
- **Changed-files fast mode (`--changed`).** `make maintenance` (whole fleet, concurrent read-only) and `make maintenance-fix` (whole fleet, fix mode) restrict the run to ONLY the files git reports as modified or new (untracked) under `devplacepy/` and `tests/`, for fast checking/fixing of work in progress. `agents/changed.py` `changed_paths(roots=("devplacepy","tests"))` parses `git status --porcelain` from the repo root (`git rev-parse --show-toplevel`), takes the destination of renames, unquotes git-quoted paths, and keeps only existing files whose first path segment is an allowed root (so root-level scratch files and `agents/` are excluded). The list is threaded as a `files` arg through `orchestrator.run_fleet` -> `agent.run` -> `_execute` -> `task_prompt` (a "CHANGED-FILES RUN" prompt branch that lists the files and tells the agent to apply its mandate to ONLY them, read others solely for cross-reference, and record out-of-scope issues as findings). In fix mode it is also enforced as a HARD guard: `agent.set_write_allowlist(files)` populates `_WRITE_ALLOWLIST`, and `_allowlist_guard` (added to `_mutation_guard` and the `create_file` inline chain, beside `_protected_guard`/`_budget_guard`) rejects any create/edit outside the set, so a fix run physically cannot touch an unchanged file. The write budget is raised to `max(WRITE_BUDGET, len(files)+2)` to cover the set, and the allowlist is cleared in the run's `finally`. The flag is `--changed` on both `agents.orchestrator` and any single agent (`python -m agents.security --changed --check`); an empty set short-circuits with "nothing to do" and exit 0. The traditional full-repo targets are unchanged.
- **Guardrails.** No git writes; destructive edits confirmation-gated; the test agent writes tests but never runs the suite; agents obey the same rules they enforce (headers, no em-dash, no stray comments).
- **Accuracy and Safety Doctrine (base prompt, `MAINT_HEADER`).** Every worker, in both modes, is held to a six-part non-negotiable doctrine: (A) evidence over suspicion - confirm each candidate against the source with enough context to read intent, never guess; (B) eliminate false positives - a candidate becomes a finding only after it SURVIVES a refutation attempt (rule out intentional/required, DATA-not-prose, generated/vendored, and known exemptions like @tool docstrings), and a no-op or nonsensical "fix" is a defect; (C) cross-reference before every change - mandatory impact analysis across all consumers (callers, routes, schemas, templates, CSS/JS) before editing, never leave the tree half-migrated; (D) zero degradation - never reduce functionality, weaken a check, or change behavior to satisfy a rule; if the only fix would, record it fixed=false instead; (E) dig deep, do not give up - pursue root cause, gather more evidence or delegate rather than bailing, never fabricate a fix; (F) verify your own work - re-read the change and re-check consumers before marking fixed=true. `plan()` success_criteria must assert no-regression and refutation; CHECK/FIX rules and both task prompts (including the seeded-fix path, which now reconfirms each seeded finding as a candidate rather than trusting it) enforce the doctrine. The prompt is stable so it stays cache-cheap.
- **Self-exclusion (hard rule).** The `agents/` directory is OFF-LIMITS to the fleet and to every other checker, fixer, or cleanup (Claude included). It holds the detection patterns themselves (em-dash characters, forbidden-name examples, `rm -rf` strings, HTML entities) as DATA, not violations, so scanning it yields false positives and fixing it corrupts the detectors. Every agent run calls `agent.protect_agents()`, which makes the engine's write tools hard-reject any path under `agents/` (`_protected_guard` in `agents/agent.py`), and the base system prompt forbids reading or scanning it. This is why a stray em-dash fixer once edited the engine's own prose; it can no longer happen.

View File

@ -84,7 +84,7 @@ One file per domain in `devplacepy/routers/`. Prefixes are wired in `main.py`:
| `/follow` | follow.py |
| `/admin` | admin.py |
| `/admin/services` | services.py |
| `/bugs` | bugs.py |
| `/bugs` | bugs.py - bug tracker backed by Gitea (no local issue store): list (`?state=`/`?page=`), detail (`/{number}` with comments), async AI-enhanced filing (`/create` enqueues a `bug_create` job, status at `/jobs/{uid}`), `/{number}/comment` (synchronous, pushes to Gitea + notifies admins), `/{number}/status` (admin open/closed) |
| `/gists` | gists.py |
| `/news` | news.py |
| `/uploads` | uploads.py |
@ -137,6 +137,9 @@ To add a service: extend `BaseService`, override `async def run_once(self) -> No
### Async job services (`devplacepy/services/jobs/`)
The **standard** way to run heavy, blocking work off the request path and return a result URL. A shared `jobs` table is the queue (discriminated by a `kind` column; "default" fields plus `payload`/`result` JSON, lifecycle timestamps, `retry_count`, `last_accessed_at`/`expires_at`, and `bytes_in`/`bytes_out`/`item_count` stat columns). `services/jobs/queue.py` (`enqueue`, `get_job`, `touch_job`, `list_jobs`) is pure DB and callable from **any** worker - the row is the queue. `JobService(BaseService)` runs only in the lock owner; each `run_once` reaps finished in-flight tasks, recovers orphaned `running` rows (left by a dead owner) back to `pending` with bounded `retry_count`, refills up to `max_concurrent` oldest pending jobs (uuid7 = FIFO) as `asyncio` tasks, and **sweeps expired artifacts** via the per-kind `cleanup()` hook (retention is built in, default 7 days, admin-configurable; no separate reaper). A new kind subclasses `JobService`, sets `kind`, and implements `async process(self, job) -> dict` and `cleanup(self, job)`. **`ZipService`** (kind `zip`) is the first consumer: `process` materializes a project subtree via `project_files.export_to_dir` (staging under `config.DATA_DIR/zip_staging/{uid}`), compresses it in a **subprocess** (`python -m devplacepy.services.jobs.zip_worker`, stdlib-only, prints stats JSON incl. crc32), names the output `{crc32}.{slug}.zip` under `config.DATA_DIR/zips/{uid[:2]}/{uid[2:4]}/`, and returns stats. (Runtime artifacts live in `DATA_DIR` = `var/` by default, OUTSIDE the package and NOT under `/static`; the download is served by the `/zips/{uid}/download` route via `FileResponse`, not the static mount.) Enqueue endpoints own authz (`POST /projects/{slug}/zip`, `POST /projects/{slug}/files/zip?path=`); `GET /zips/{uid}` (status `ZipJobOut`) and `GET /zips/{uid}/download` (FileResponse, extends expiry) are **capability URLs** scoped only by the unguessable uuid7, not by owner. Frontend `app.zipDownloader` (`static/js/ZipDownloader.js`) auto-wires any `data-zip-download` element plus the files context menu: POST -> poll `/zips/{uid}` -> trigger download. Devii tools `zip_project`/`zip_status`; CLI `devplace zips prune|clear`. **`ForkService`** (kind `fork`, `services/jobs/fork_service.py`) is the second consumer and forks a project: `process` creates the destination project (via `content.create_content_item`, owned by the forking user), copies the whole virtual FS through `project_files.export_to_dir` -> `config.DATA_DIR/fork_staging/{uid}` -> `import_from_dir(..., skip_names=set())` (exact copy), and records the directional relation via `database.record_fork(source_uid, forked_uid, forked_by_uid)` into the `project_forks` table; on any failure it **rolls back** the half-created project (delete files + relation + row) and re-raises. **Critical difference from zip: the artifact is a permanent project, so `cleanup()` is a no-op on the project** - the retention sweep only removes the job tracking row, never the fork. Enqueue `POST /projects/{slug}/fork` (`require_user` + `can_view_project`, body `ForkForm{title}`); `GET /forks/{uid}` returns `ForkJobOut` whose `project_url` is set once done. Frontend `app.projectForker` (`static/js/ProjectForker.js`) wires `data-fork-project`: prompt for a name -> POST -> poll `/forks/{uid}` -> redirect to `project_url`. The forked project's detail page shows a "Forked from X" link (`database.get_fork_parent`). Devii tools `fork_project`/`fork_status`; CLI `devplace forks prune|clear` (job rows only).
### Bug tracker (`devplacepy/services/gitea/`)
The `/bugs` feature is a **full Gitea integration with no local issue store** - the listing and detail views read issues straight from Gitea (default repo `retoor/pydevplace`) with live status. `services/gitea/` holds `config.py` (the admin-editable `CONFIG_FIELDS` for base URL/owner/repo/token + AI model, surfaced on the `BugTrackerService` config at `/admin/services`; `gitea_config()` -> `GiteaConfig`), `client.py` (async `GiteaClient` over the REST API, `Authorization: token`, `GiteaError`), `fake.py` (`FakeGiteaClient` test double), `runtime.py` (`get_client`/`set_client` swap), `enhance.py` (`enhance_ticket` rewrites a raw report into a consistent markdown ticket via the **internal AI gateway**, fail-soft to a deterministic template), `store.py` (the only DB state: `bug_tickets` and `bug_comment_authors`, both in `SOFT_DELETE_TABLES` - thin mappings of Gitea number/comment-id -> DevPlace author for attribution + cached `last_status`/`last_comment_count` for update detection), and `service.py` (`BugTrackerService`, `default_enabled=False`, polls tracked tickets and notifies the **reporter** on developer replies / status changes). Filing is an **async job** (`BugCreateService`, kind `bug_create`): enhance -> create Gitea issue -> record mapping -> notify reporter; `POST /bugs/create` enqueues, frontend `app.bugReporter` polls `/bugs/jobs/{uid}`. Comments (`POST /bugs/{number}/comment`, notifies **all admins**) and admin status changes (`POST /bugs/{number}/status`, open/closed) are synchronous single Gitea calls. All Gitea actions use one shared bot token; per-user identity is shown by storing DevPlace author names locally, never per-user Gitea accounts.
### Audit log (`devplacepy/services/audit/`)
**Admin-only, append-only** record of every state-changing action; the authoritative event catalogue (155 keys across 26 domains) is `events.md`. Package: `store.py` (tables `audit_log` + `audit_log_links`, `ensure_tables`/`insert_event`/`insert_links`/`get_event`/`get_links`/`sweep`), `categories.py` (`category_for(event_key)`), `record.py` (the recorder + link builders), `query.py` (admin list/detail), `service.py` (`AuditService` retention). `ensure_tables()` + 11 indexes are wired into `init_db()` via a local import (audit modules import `database`/`utils`, so the import is deferred to avoid the cycle); `AuditService` is registered in `main.py`. **Two recorder entrypoints (the reuse keystone):** `record(request, event_key, *, user=_UNSET, actor_kind, target_*, old_value, new_value, summary, metadata, result, origin, via_agent, links, category)` for HTTP/WebSocket handlers (`request` may be a `Request` OR a `WebSocket`; auto-derives actor from `get_current_user`, request fields, the `X-Devii-Agent` header -> `origin=devii`/`via_agent=1`, and auto-appends the `actor` link), and `record_system(event_key, *, actor_kind, actor_uid, actor_username, actor_role, origin, via_agent, ...)` for request-less contexts (rewards, notifications, the AI gateway ledger, news/container services, Devii turns/tasks, job services, CLI). **Both are best-effort: wrapped in try/except, they NEVER raise into the caller** - the audited action is never blocked by a logging failure. `summary` is HTML-stripped + 140-char capped; `metadata` is JSON. **Emission is explicit per mutation** (`audit.record(...)`/`record_system(...)` after success, or on the guard branch with `result="denied"`/`"failure"`), with DRY choke points: posts/projects/gists record inside `content.py`; project file ops via the `project_files.py` helpers (read-only guard -> `denied`); container ops in `routers/containers.py` (HTTP path) and in the Devii `actions/dispatcher.py` `_audit_mechanic` hook (agent path; the two paths are disjoint so there is no double-count), which also emits the `devii.*` self-config mechanics. Auth failures, authz denials (in `require_user`/`require_admin`), rate-limit/maintenance blocks, self-role-change/self-disable denials, and quota blocks are recorded with `result` set. **Admin UI:** `GET /admin/audit-log` (paginated, filterable list) + `GET /admin/audit-log/{uid}` (detail + links), both `require_admin`, both negotiate via `respond(..., model=AuditLogOut|AuditEventOut)`; sidebar link sits last (before Settings). `_pagination.html` gained an optional backward-compatible `pagination_query` prefix so filters survive paging. **Retention:** `AuditService` (daily) prunes rows older than `audit_log_retention_days` (default 90; `0` disables). To add an event: pick/extend a key in `events.md`, extend `category_for` for a new domain, and call the recorder at the mutation point - never gate the action on the recording.
@ -263,6 +266,8 @@ Read the router (`routers/{area}.py`), template (`templates/{area}.html`), test
The `agents/` directory is a fleet of autonomous AI maintenance agents (developer tooling, not part of the running app). Each agent enforces one quality dimension across the repo and runs in `--fix` (default) or `--check` mode. The internals are documented in `AGENTS.md` (the "Maintenance agents" section); the public docs are `/docs/maintenance-agents.html` and `/docs/maintenance-usage.html`. Targets: `make <name>-agent` (`CHECK=1` for report-only), `make agents-all`, `make maestro`.
**Changed-files fast mode.** `make maintenance` runs the whole fleet concurrently in read-only (check) mode, and `make maintenance-fix` runs it in fix mode, both scoped to ONLY the files git reports as modified or new (untracked) under `devplacepy/` and `tests/` - for fast checking and fixing of work in progress. The scope is computed once by `agents/changed.py` `changed_paths()` (parses `git status --porcelain`, keeps existing files whose first path segment is `devplacepy/` or `tests/`) and threaded to every agent as a `files` list; an empty set short-circuits with "nothing to do" and exit 0. The list is injected into each agent's task prompt (it may still read other files for cross-reference but reports/fixes only within the set) AND enforced in fix mode by a hard write-allowlist guard (`agent._allowlist_guard`, wired into `_mutation_guard` and `create_file` beside `_protected_guard`/`_budget_guard`), so a fix run can never modify a file outside the changed set. The flag is `--changed` on both `agents.orchestrator` and any single agent (`python -m agents.security --changed --check`); the traditional full-repo targets are unchanged.
**HARD RULE - the `agents/` directory is OFF-LIMITS to every code checker, fixer, linter, style pass, or refactor, including the maintenance agents themselves, Claude, and any other tool or human cleanup.** That directory deliberately contains the exact patterns the agents hunt for - em-dash characters, forbidden-name examples (`_temp`, `_v2`, `my_`), destructive-command strings (`rm -rf`), and HTML entities - as DETECTION DATA, not as violations. "Fixing" them corrupts the detectors and breaks the fleet. The maintenance agents enforce this themselves: every run calls `agent.protect_agents()`, which makes the engine's write tools (`write_file`/`edit_file`/`patch_file`/`create_file`) hard-reject any path under `agents/`, and their system prompt forbids reading or scanning it. Do NOT em-dash-strip, rename, reformat, or otherwise "tidy" anything in `agents/`. Leave it exactly as authored.
Code validation uses `agents/validator.py` (run via `make validate` or `python -m agents.validator .`): a dependency-free validator covering Python (`ast` + `py_compile`), JavaScript (`node --check`), CSS (brace balance), and HTML/Jinja templates (Jinja2 parse). Use it in place of any external validator when checking touched files.

View File

@ -137,13 +137,15 @@ validate:
# make audit-agent CHECK=1 # report only
# make agents-all # run the whole fleet
# make maestro # talk to the conductor; it runs the rest
# make maintenance # whole fleet, concurrent read-only, only git-changed files
# make maintenance-fix # whole fleet, fix mode, only git-changed files
# ---------------------------------------------------------------------------
CHECK ?=
AGENT_MODE := $(if $(CHECK),--check,--fix)
.PHONY: security-agent audit-agent devii-agent docs-agent fanout-agent \
dry-agent style-agent frontend-agent seo-agent test-agent \
agents-all maestro
agents-all maestro maintenance maintenance-fix
security-agent:
python -m agents.security $(AGENT_MODE)
@ -180,3 +182,9 @@ agents-all:
maestro:
python -m agents.maestro
maintenance:
python -m agents.orchestrator --check --changed
maintenance-fix:
python -m agents.orchestrator --fix --changed

View File

@ -76,7 +76,7 @@ devplacepy/
| `/follow` | Follow/unfollow users |
| `/leaderboard` | Contributor ranking by total stars earned |
| `/avatar` | Multiavatar proxy with in-memory cache |
| `/bugs` | Bug reports listing, creation |
| `/bugs` | Bug tracker backed by Gitea: list/detail/comment/status, AI-enhanced filing |
| `/admin/services` | Background service management (start/stop, config, status, logs) |
| `/admin` | Admin panel (user management, news curation, settings) |
| `/docs` | Developer documentation site with a complete, interactive HTTP API reference |
@ -534,8 +534,16 @@ make audit-agent # fix one dimension autonomously
make audit-agent CHECK=1 # report only
make agents-all # run the whole fleet
make agents-all CHECK=1 # CI gate across the fleet
make maintenance # whole fleet, read-only, only git-changed files (fast)
make maintenance-fix # whole fleet, fix mode, only git-changed files (fast)
```
`make maintenance` and `make maintenance-fix` restrict the fleet to the files git
reports as modified or new under `devplacepy/` and `tests/`, so checking and fixing
work in progress is fast. In fix mode a hard write-allowlist guarantees no file
outside that changed set is ever touched. The flag is `--changed` on the
orchestrator and any single agent (`python -m agents.security --changed --check`).
Every run is legible live: each agent prints a start banner (icon, name, a
memorable codename, what it will do, and its report path), every line is prefixed
with the wall-clock time and elapsed duration, each AI call streams its running

View File

@ -1547,6 +1547,7 @@ _PROTECTED_TREES: set[str] = set()
_RESTRICT_SHELL = False
_WRITE_BUDGET: Optional[int] = None
_WRITTEN: set[str] = set()
_WRITE_ALLOWLIST: Optional[set[str]] = None
def protect_tree(path: Path) -> None:
@ -1578,6 +1579,28 @@ def clear_write_budget() -> None:
_WRITTEN.clear()
def set_write_allowlist(paths: Optional[Iterable[str]]) -> None:
global _WRITE_ALLOWLIST
_WRITE_ALLOWLIST = None if paths is None else {str(Path(p).resolve()) for p in paths}
def clear_write_allowlist() -> None:
global _WRITE_ALLOWLIST
_WRITE_ALLOWLIST = None
def _allowlist_guard(path: Path) -> Optional[str]:
if _WRITE_ALLOWLIST is None:
return None
if str(Path(path).resolve()) in _WRITE_ALLOWLIST:
return None
return (
f"Out of scope: '{path}' is not in this changed-files run's allowed set "
"(the git-modified or new files under devplacepy/ and tests/). Read it for context if you must, "
"but do not modify or create it; record any cross-file issue as a finding (fixed=false) instead."
)
def _protected_guard(path: Path) -> Optional[str]:
if not _PROTECTED_TREES:
return None
@ -1607,7 +1630,7 @@ def _budget_guard(path: Path) -> Optional[str]:
def _mutation_guard(path: Path) -> Optional[str]:
blocked = _protected_guard(path) or _budget_guard(path)
blocked = _protected_guard(path) or _allowlist_guard(path) or _budget_guard(path)
if blocked:
return blocked
if not path.exists():
@ -1684,7 +1707,7 @@ async def create_file(path: str, content: str):
"""
def _do() -> str:
p = Path(path)
blocked = _protected_guard(p) or _budget_guard(p)
blocked = _protected_guard(p) or _allowlist_guard(p) or _budget_guard(p)
if blocked:
return json.dumps({"status": "error", "error": blocked})
if p.exists():

View File

@ -15,6 +15,7 @@ from .agent import (
MarkdownRenderer,
begin_run_cost,
clear_protected_trees,
clear_write_allowlist,
clear_write_budget,
close_http_client,
cost_session_total,
@ -27,10 +28,12 @@ from .agent import (
reset_tool_scope,
set_shell_restricted,
set_tool_scope,
set_write_allowlist,
set_write_budget,
usd_str,
_with_datetime,
)
from .changed import changed_paths
DEFAULT_MAX_ITER = 180
WRITE_BUDGET = 20
@ -99,7 +102,31 @@ class MaintenanceAgent:
rules = CHECK_RULES if mode == "check" else FIX_RULES
return MAINT_HEADER.format(name=self.name, mode_rules=rules, mandate=self.mandate())
def task_prompt(self, mode: str, scope: Optional[str], seed_findings: Optional[list[dict]] = None) -> str:
def task_prompt(
self,
mode: str,
scope: Optional[str],
seed_findings: Optional[list[dict]] = None,
files: Optional[list[str]] = None,
) -> str:
if files:
listed = "\n".join(f"- {path}" for path in files)
action = (
"record each confirmed issue"
if mode == "check"
else "for each issue confirm it, impact-check its consumers, fix it at the root, re-verify, then record it"
)
return (
f"Dimension: {self.description}\n\n"
f"CHANGED-FILES RUN ({mode} mode). ONLY these {len(files)} files (git-modified or new, under "
f"devplacepy/ and tests/) are in scope. Apply YOUR dimension mandate to ONLY these files and "
f"{action} via report_finding. You MAY read other files for cross-reference and impact analysis "
f"(Doctrine C), but you MUST NOT report on or modify anything outside this list - record any "
f"cross-file issue as a finding (fixed=false) instead. The engine hard-blocks writes outside this "
f"set. Apply the Accuracy and Safety Doctrine to every candidate. Do not re-read a file you already "
f"read or repeat a grep. Exclude the `agents/` directory entirely.\n\n{listed}\n\n"
f"Finish with a single line: 'N findings (E errors, W warnings), M fixed.'"
)
if mode == "fix" and seed_findings:
listed = "\n".join(
f"- {f.get('file','')}:{f.get('line') or '?'} [{f.get('rule') or f.get('dimension','')}] {f.get('message','')}"
@ -146,11 +173,12 @@ class MaintenanceAgent:
max_iter: int,
renderer: Optional[MarkdownRenderer],
seed_findings: Optional[list[dict]] = None,
files: Optional[list[str]] = None,
) -> dict:
if mode == "check":
return await self._execute(mode, scope, max_iter, renderer, seed_findings)
return await self._execute(mode, scope, max_iter, renderer, seed_findings, files)
async with _RUN_LOCK:
return await self._execute(mode, scope, max_iter, renderer, seed_findings)
return await self._execute(mode, scope, max_iter, renderer, seed_findings, files)
async def _execute(
self,
@ -159,6 +187,7 @@ class MaintenanceAgent:
max_iter: int,
renderer: Optional[MarkdownRenderer],
seed_findings: Optional[list[dict]],
files: Optional[list[str]] = None,
) -> dict:
writes_enabled = mode != "check"
core.reset_findings()
@ -166,7 +195,10 @@ class MaintenanceAgent:
if writes_enabled:
protect_agents()
set_shell_restricted(True)
if seed_findings:
if files:
set_write_allowlist(files)
budget = max(WRITE_BUDGET, len(files) + 2)
elif seed_findings:
distinct = {f.get("file") for f in seed_findings if f.get("file")}
budget = max(WRITE_BUDGET, len(distinct) + 2)
set_write_budget(budget)
@ -177,7 +209,11 @@ class MaintenanceAgent:
if renderer is not None:
icon = AGENT_ICONS.get(self.name, "\U0001F916")
scope_note = f" (scope: {scope})" if scope else ""
if mode == "fix" and seed_findings:
if files and mode == "fix":
plan_line = f"fix issues in {len(files)} changed file(s) and verify the build"
elif files:
plan_line = f"scan {len(files)} changed file(s) read-only and report each issue (no files changed)"
elif mode == "fix" and seed_findings:
plan_line = f"fix {len(seed_findings)} confirmed finding(s) from the last check, then verify the build"
elif mode == "fix":
plan_line = f"scan{scope_note}, fix each issue, and verify the build (up to {budget} files this run)"
@ -191,7 +227,7 @@ class MaintenanceAgent:
)
messages = [
{"role": "system", "content": _with_datetime(self.system_prompt(mode))},
{"role": "user", "content": self.task_prompt(mode, scope, seed_findings)},
{"role": "user", "content": self.task_prompt(mode, scope, seed_findings, files)},
]
state = AgentState()
try:
@ -209,6 +245,7 @@ class MaintenanceAgent:
if writes_enabled:
clear_protected_trees()
clear_write_budget()
clear_write_allowlist()
set_shell_restricted(False)
finished = datetime.now()
incomplete = final is None or state.iteration >= max_iter
@ -252,6 +289,7 @@ def build_parser(name: str, description: str) -> argparse.ArgumentParser:
mode.add_argument("--check", dest="mode", action="store_const", const="check", help="Report only; non-zero exit on findings")
parser.set_defaults(mode="fix")
parser.add_argument("--scope", default=None, help="Restrict to one scope unit label")
parser.add_argument("--changed", action="store_true", help="Restrict to git-changed files under devplacepy/ and tests/")
parser.add_argument("--max-iter", type=int, default=DEFAULT_MAX_ITER, help="Maximum agent iterations")
parser.add_argument("--no-color", action="store_true", help="Disable ANSI colour output")
parser.add_argument("-v", "--verbose", action="store_true", help="Enable debug logging")
@ -264,8 +302,14 @@ async def _amain(agent: MaintenanceAgent, argv: Optional[list[str]] = None) -> i
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
renderer = MarkdownRenderer(use_color=not args.no_color)
files: Optional[list[str]] = None
if args.changed:
files = changed_paths()
if not files:
renderer.print("No changed files under devplacepy/ or tests/; nothing to do.")
return 0
try:
result = await agent.run(args.mode, args.scope, args.max_iter, renderer)
result = await agent.run(args.mode, args.scope, args.max_iter, renderer, files=files)
return result["exit_code"]
finally:
await close_http_client()

79
agents/changed.py Normal file
View File

@ -0,0 +1,79 @@
# retoor <retoor@molodetz.nl>
from __future__ import annotations
import subprocess
from pathlib import Path
from typing import Sequence
DEFAULT_ROOTS: tuple[str, ...] = ("devplacepy", "tests")
AGENTS_DIR = "agents"
def _repo_root() -> Path:
try:
out = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=True,
)
return Path(out.stdout.strip())
except (subprocess.CalledProcessError, FileNotFoundError):
return Path.cwd()
def _unquote(path: str) -> str:
if len(path) >= 2 and path[0] == '"' and path[-1] == '"':
return path[1:-1].encode("utf-8").decode("unicode_escape")
return path
def _entry_path(line: str) -> str:
body = line[3:]
if " -> " in body:
body = body.split(" -> ", 1)[1]
return _unquote(body)
def _first_segment(path: str) -> str:
return path.split("/", 1)[0]
def changed_paths(roots: Sequence[str] = DEFAULT_ROOTS) -> list[str]:
root = _repo_root()
try:
out = subprocess.run(
["git", "status", "--porcelain"],
cwd=root,
capture_output=True,
text=True,
check=True,
)
except (subprocess.CalledProcessError, FileNotFoundError):
return []
allowed = set(roots)
found: set[str] = set()
for line in out.stdout.splitlines():
if not line.strip():
continue
rel = _entry_path(line)
if not rel:
continue
segment = _first_segment(rel)
if segment not in allowed or segment == AGENTS_DIR:
continue
absolute = root / rel
if not absolute.is_file():
continue
found.add(str(absolute))
return sorted(found)
def main() -> None:
for path in changed_paths():
print(path)
if __name__ == "__main__":
main()

View File

@ -12,6 +12,7 @@ from typing import Callable, Optional
from . import core
from .agent import MarkdownRenderer, close_http_client, cost_session_total, format_usd, get_http_client, install_timestamps, usd_str
from .changed import changed_paths
from .fleet import REGISTRY, ordered_agents
@ -22,6 +23,7 @@ def _parser() -> argparse.ArgumentParser:
mode.add_argument("--check", dest="mode", action="store_const", const="check")
parser.set_defaults(mode="fix")
parser.add_argument("--only", default=None, help="Comma-separated subset of agent names")
parser.add_argument("--changed", action="store_true", help="Restrict to git-changed files under devplacepy/ and tests/")
parser.add_argument("--max-iter", type=int, default=120)
parser.add_argument("--no-color", action="store_true")
parser.add_argument("-v", "--verbose", action="store_true")
@ -34,6 +36,7 @@ async def run_fleet(
max_iter: int,
renderer: Optional[MarkdownRenderer],
on_result: Optional[Callable[[str, dict], None]] = None,
files: Optional[list[str]] = None,
) -> int:
names = ordered_agents(only.split(",") if only else None)
started = datetime.now()
@ -43,7 +46,7 @@ async def run_fleet(
agent = REGISTRY[name]()
if renderer is not None:
renderer.print(f"\n## {name}")
result = await agent.run(mode, None, max_iter, renderer)
result = await agent.run(mode, None, max_iter, renderer, files=files)
if on_result is not None:
on_result(name, result)
return {
@ -54,6 +57,8 @@ async def run_fleet(
"json": result["json"],
}
if renderer is not None and files is not None:
renderer.print(f"\n# Fleet scoped to {len(files)} changed file(s) under devplacepy/ and tests/")
if mode == "check":
if renderer is not None:
renderer.print(f"\n# Fleet [check] running {len(names)} agents concurrently (read-only)")
@ -107,8 +112,14 @@ async def _amain(argv: Optional[list[str]] = None) -> int:
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
renderer = MarkdownRenderer(use_color=not args.no_color)
files: Optional[list[str]] = None
if args.changed:
files = changed_paths()
if not files:
renderer.print("No changed files under devplacepy/ or tests/; nothing to do.")
return 0
try:
return await run_fleet(args.mode, args.only, args.max_iter, renderer)
return await run_fleet(args.mode, args.only, args.max_iter, renderer, files=files)
finally:
await close_http_client()

View File

@ -127,6 +127,8 @@ SOFT_DELETE_TABLES = [
"devii_virtual_tools",
"user_customizations",
"project_forks",
"bug_tickets",
"bug_comment_authors",
]
@ -262,6 +264,50 @@ def init_db():
_index(db, "news_images", "idx_news_images_news_uid", ["news_uid"])
_index(db, "news_sync", "idx_news_sync_external_id", ["external_id"])
bug_tickets = get_table("bug_tickets")
for column, example in (
("uid", ""),
("gitea_number", 0),
("author_uid", ""),
("original_title", ""),
("original_description", ""),
("enhanced_title", ""),
("html_url", ""),
("last_status", ""),
("last_comment_count", 0),
("created_at", ""),
("updated_at", ""),
("deleted_at", ""),
("deleted_by", ""),
):
if not bug_tickets.has_column(column):
bug_tickets.create_column_by_example(column, example)
bug_comment_authors = get_table("bug_comment_authors")
for column, example in (
("uid", ""),
("gitea_comment_id", 0),
("gitea_number", 0),
("author_uid", ""),
("created_at", ""),
("deleted_at", ""),
("deleted_by", ""),
):
if not bug_comment_authors.has_column(column):
bug_comment_authors.create_column_by_example(column, example)
_index(db, "bug_tickets", "idx_bug_tickets_number", ["gitea_number"])
_index(db, "bug_tickets", "idx_bug_tickets_author", ["author_uid"])
_index(
db,
"bug_comment_authors",
"idx_bug_comment_authors_comment",
["gitea_comment_id"],
)
_index(
db, "bug_comment_authors", "idx_bug_comment_authors_number", ["gitea_number"]
)
_index(db, "service_state", "idx_service_state_name", ["name"])
_index(
db, "devii_conversations", "idx_devii_conv_owner", ["owner_kind", "owner_id"]

View File

@ -423,7 +423,6 @@ envelope to see validation errors as `{ "error": "validation", "fields": {...} }
],
},
{
"slug": "lookups",
"slug": "lookups",
"title": "Search & Lookups",
"intro": """
@ -3198,8 +3197,11 @@ four ways to sign requests.
"intro": """
# Bug Reports
A public board for reporting issues. The list renders HTML; reporting uses form fields, and a
report can carry files uploaded via [Uploads](/docs/uploads.html).
The bug tracker is a full integration with a Gitea repository. The listing and detail views read
issues straight from Gitea with their live status, and a report you file is first rewritten by the
internal AI service into a consistent ticket, then posted to Gitea as an issue. The original
reporter is notified when a developer replies or the status changes, and a comment posted here is
pushed to Gitea and attributed to your account.
Every endpoint follows the shared [Conventions & Errors](/docs/conventions.html) (auth, content
negotiation, pagination, status codes); see [Authentication](/docs/authentication.html) for the
@ -3210,19 +3212,31 @@ four ways to sign requests.
id="bugs-list",
method="GET",
path="/bugs",
title="View bug reports",
summary="Render the bug board. Returns an HTML page.",
title="List bug tickets",
summary="Render the bug board from Gitea, paginated and filterable by state.",
auth="public",
interactive=True,
params=[
field(
"state",
"query",
"string",
False,
"open",
"Filter: open (default), closed, or all.",
),
field("page", "query", "integer", False, "1", "1-based page."),
],
),
endpoint(
id="bugs-create",
method="POST",
path="/bugs/create",
title="Report a bug",
summary="File a bug report.",
summary="Enqueue a bug report. It is enhanced by AI and filed on the tracker.",
auth="user",
encoding="form",
ajax=True,
destructive=True,
params=[
field(
@ -3242,6 +3256,86 @@ four ways to sign requests.
"Description, 1-5000 characters.",
),
],
notes=["Returns a job uid and status_url. Poll the status_url until status is done to get the bug number."],
sample_response={
"uid": "JOB_UID",
"status_url": "/bugs/jobs/JOB_UID",
},
),
endpoint(
id="bugs-job",
method="GET",
path="/bugs/jobs/{uid}",
title="Bug filing job status",
summary="Poll the filing job; the result carries the new bug number and url.",
auth="public",
ajax=True,
params=[field("uid", "path", "string", True, "JOB_UID", "Job uid.")],
sample_response={
"uid": "JOB_UID",
"kind": "bug_create",
"status": "done",
"number": 42,
"bug_url": "/bugs/42",
"enhanced": True,
"error": None,
"created_at": "2026-06-12T09:00:00+00:00",
"completed_at": "2026-06-12T09:00:03+00:00",
},
),
endpoint(
id="bugs-detail",
method="GET",
path="/bugs/{number}",
title="View a bug ticket",
summary="Render a Gitea issue and its comments. Returns an HTML page.",
auth="public",
interactive=True,
params=[
field("number", "path", "integer", True, "12", "Bug number.")
],
),
endpoint(
id="bugs-comment",
method="POST",
path="/bugs/{number}/comment",
title="Comment on a bug",
summary="Post a comment to the Gitea issue, attributed to the current user.",
auth="user",
encoding="form",
destructive=True,
params=[
field("number", "path", "integer", True, "12", "Bug number."),
field(
"body",
"form",
"textarea",
True,
"I can reproduce this on mobile.",
"Comment, 1-5000 characters.",
),
],
),
endpoint(
id="bugs-status",
method="POST",
path="/bugs/{number}/status",
title="Change a bug status",
summary="Open or close the Gitea issue. Admin only.",
auth="admin",
encoding="form",
destructive=True,
params=[
field("number", "path", "integer", True, "12", "Bug number."),
field(
"status",
"form",
"string",
True,
"closed",
"New status: open or closed.",
),
],
),
],
},
@ -3838,6 +3932,7 @@ _PAGE_RESPONSES = {
"messages-inbox": schemas.MessagesOut,
"notifications-list": schemas.NotificationsOut,
"bugs-list": schemas.BugsOut,
"bugs-detail": schemas.BugDetailOut,
"bookmarks-saved": schemas.SavedOut,
"admin-users": schemas.AdminUsersOut,
"admin-news-list": schemas.AdminNewsOut,
@ -3895,7 +3990,8 @@ _ACTION_RESPONSES = {
"notifications-open": ("/posts/POST_SLUG#comment-COMMENT_UID", None),
"notifications-mark-read": ("/notifications", None),
"notifications-mark-all-read": ("/notifications", None),
"bugs-create": ("/bugs", {"uid": "BUG_UID"}),
"bugs-comment": ("/bugs/12", {"comment_id": 1}),
"bugs-status": ("/bugs/12", {"state": "closed"}),
"admin-user-role": ("/admin/users", None),
"admin-user-password": ("/admin/users", None),
"admin-user-toggle": ("/admin/users", None),

View File

@ -72,6 +72,8 @@ from devplacepy.services.openai_gateway import GatewayService
from devplacepy.services.devii import DeviiService
from devplacepy.services.jobs.zip_service import ZipService
from devplacepy.services.jobs.fork_service import ForkService
from devplacepy.services.jobs.bug_create_service import BugCreateService
from devplacepy.services.gitea.service import BugTrackerService
from devplacepy.services.containers.service import ContainerService
from devplacepy.services.audit import AuditService
from devplacepy.services.audit import record as audit
@ -407,6 +409,8 @@ async def startup():
service_manager.register(DeviiService())
service_manager.register(ZipService())
service_manager.register(ForkService())
service_manager.register(BugCreateService())
service_manager.register(BugTrackerService())
service_manager.register(ContainerService())
service_manager.register(AuditService())
if not os.environ.get("DEVPLACE_DISABLE_SERVICES"):

View File

@ -153,24 +153,6 @@ class PostEditForm(BaseModel):
return normalize_poll_options(value)
class PostEditForm(BaseModel):
content: str = Field(min_length=10, max_length=125000)
title: str = Field(default="", max_length=500)
topic: str = "random"
poll_question: str = Field(default="", max_length=200)
poll_options: list[str] = []
@field_validator("topic")
@classmethod
def valid_topic(cls, value):
return value if value in TOPICS else "random"
@field_validator("poll_options", mode="before")
@classmethod
def split_poll_options(cls, value):
return normalize_poll_options(value)
class CommentForm(BaseModel):
content: str = Field(min_length=3, max_length=1000)
target_uid: str = Field(default="", max_length=36)
@ -340,7 +322,14 @@ class GistEditForm(BaseModel):
class BugForm(BaseModel):
title: str = Field(min_length=1, max_length=200)
description: str = Field(min_length=1, max_length=5000)
attachment_uids: list[str] = []
class BugCommentForm(BaseModel):
body: str = Field(min_length=1, max_length=5000)
class BugStatusForm(BaseModel):
status: Literal["open", "closed"]
class VoteForm(BaseModel):

View File

@ -2,110 +2,347 @@
import logging
from typing import Annotated
from datetime import datetime, timezone
from fastapi import APIRouter, Request, Form
from fastapi.responses import HTMLResponse
from devplacepy.models import BugForm
from devplacepy.database import get_table, load_comments_by_target_uids
from devplacepy.attachments import get_attachments_batch, link_attachments
from devplacepy.utils import (
generate_uid,
require_user,
time_ago,
get_current_user,
create_mention_notifications,
)
from fastapi import APIRouter, Form, Request
from fastapi.responses import HTMLResponse, JSONResponse
from devplacepy.database import build_pagination, get_table, get_users_by_uids
from devplacepy.models import BugCommentForm, BugForm, BugStatusForm
from devplacepy.responses import action_result, json_error, respond
from devplacepy.schemas import BugDetailOut, BugJobOut, BugsOut
from devplacepy.seo import base_seo_context, site_url, website_schema
from devplacepy.responses import respond, action_result
from devplacepy.schemas import BugsOut
from devplacepy.services.audit import record as audit
from devplacepy.services.gitea import runtime, store
from devplacepy.services.gitea.client import (
STATE_ALL,
STATE_CLOSED,
STATE_OPEN,
GiteaError,
)
from devplacepy.services.gitea.config import gitea_config
from devplacepy.services.jobs import queue
from devplacepy.utils import (
create_notification,
get_current_user,
is_admin,
not_found,
require_user,
)
logger = logging.getLogger(__name__)
router = APIRouter()
PER_PAGE = 20
VALID_STATES = (STATE_OPEN, STATE_CLOSED, STATE_ALL)
@router.get("", response_class=HTMLResponse)
async def bugs_page(request: Request):
user = get_current_user(request)
bugs_table = get_table("bug_reports")
all_bugs = list(bugs_table.find(order_by=["-created_at"]))
from devplacepy.database import get_users_by_uids
def _state(raw: str) -> str:
return raw if raw in VALID_STATES else STATE_OPEN
uids = [b["user_uid"] for b in all_bugs]
users_map = get_users_by_uids(uids)
bug_uids = [b["uid"] for b in all_bugs]
attachments_map = get_attachments_batch("bug", bug_uids) if bug_uids else {}
comments_map = load_comments_by_target_uids("bug", bug_uids, user) if bug_uids else {}
bug_list = []
for b in all_bugs:
bug_list.append(
{
"bug": b,
"author": users_map.get(b["user_uid"]),
"time_ago": time_ago(b["created_at"]),
"comments": comments_map.get(b["uid"], []),
"attachments": attachments_map.get(b["uid"], []),
}
)
def _author_fields(author_uid: str | None, users_map: dict, fallback_login: str) -> dict:
user = users_map.get(author_uid) if author_uid else None
if user:
return {
"author_username": user["username"],
"author_uid": author_uid,
"author_avatar_seed": user["username"],
"is_local_author": True,
}
return {
"author_username": fallback_login or "developer",
"author_uid": None,
"author_avatar_seed": None,
"is_local_author": False,
}
def _issue_item(issue: dict, author_uid: str | None, users_map: dict) -> dict:
login = (issue.get("user") or {}).get("login", "")
return {
"number": int(issue.get("number", 0)),
"title": issue.get("title", ""),
"state": issue.get("state", "open"),
"html_url": issue.get("html_url"),
"comments_count": int(issue.get("comments", 0)),
"created_at": issue.get("created_at"),
"updated_at": issue.get("updated_at"),
**_author_fields(author_uid, users_map, login),
}
def _comment_item(comment: dict, author_uid: str | None, users_map: dict) -> dict:
login = (comment.get("user") or {}).get("login", "")
return {
"id": int(comment.get("id", 0)),
"body": comment.get("body", ""),
"html_url": comment.get("html_url"),
"created_at": comment.get("created_at"),
**_author_fields(author_uid, users_map, login),
}
def _seo(request: Request, **extra) -> dict:
base = site_url(request)
seo_ctx = base_seo_context(
return base_seo_context(
request,
title="Bug Reports",
description="Report bugs and issues on DevPlace.",
breadcrumbs=[
{"name": "Home", "url": "/feed"},
{"name": "Bug Reports", "url": "/bugs"},
],
schemas=[website_schema(base)],
**extra,
)
return respond(
@router.get("", response_class=HTMLResponse)
async def bugs_page(request: Request, state: str = STATE_OPEN, page: int = 1):
user = get_current_user(request)
state = _state(state)
page = max(1, page)
seo_ctx = _seo(
request,
"bugs.html",
{
**seo_ctx,
"request": request,
"user": user,
"bugs": bug_list,
},
model=BugsOut,
title="Bug Reports",
description="Report bugs and track their progress on DevPlace.",
)
base_ctx = {
**seo_ctx,
"request": request,
"user": user,
"state": state,
"bugs": [],
"pagination": None,
"configured": True,
"error_message": None,
}
config = gitea_config()
if not config.is_configured:
base_ctx["configured"] = False
base_ctx["error_message"] = "The bug tracker is not configured yet."
return respond(request, "bugs.html", base_ctx, model=BugsOut)
try:
issues, total = await runtime.get_client().list_issues(
state=state, page=page, limit=PER_PAGE
)
except GiteaError as exc:
logger.warning("Could not load bug list: %s", exc)
base_ctx["error_message"] = "The bug tracker is temporarily unavailable."
return respond(request, "bugs.html", base_ctx, model=BugsOut)
numbers = [int(issue.get("number", 0)) for issue in issues]
author_by_number = store.author_map(numbers)
users_map = get_users_by_uids([uid for uid in author_by_number.values() if uid])
base_ctx["bugs"] = [
_issue_item(issue, author_by_number.get(int(issue.get("number", 0))), users_map)
for issue in issues
]
base_ctx["pagination"] = build_pagination(page, total, PER_PAGE)
return respond(request, "bugs.html", base_ctx, model=BugsOut)
@router.post("/create")
async def create_bug(request: Request, data: Annotated[BugForm, Form()]):
user = require_user(request)
if not gitea_config().is_configured:
return json_error(503, "The bug tracker is not configured")
title = data.title.strip()
description = data.description.strip()
bugs_table = get_table("bug_reports")
bug_uid = generate_uid()
bugs_table.insert(
uid = queue.enqueue(
"bug_create",
{
"uid": bug_uid,
"user_uid": user["uid"],
"author_uid": user["uid"],
"title": title,
"description": description,
"status": "open",
"created_at": datetime.now(timezone.utc).isoformat(),
}
"description": data.description.strip(),
},
"user",
user["uid"],
title[:64],
)
if data.attachment_uids:
link_attachments(data.attachment_uids, "bug", bug_uid)
create_mention_notifications(description, user["uid"], f"/bugs?highlight={bug_uid}")
logger.info(f"Bug report created by {user['username']}: {title}")
audit.record(
request,
"bug.create",
"bug.create.request",
user=user,
target_type="bug",
target_uid=bug_uid,
target_label=title,
summary=f"{user['username']} reported a bug: {title}",
links=[audit.target("bug", bug_uid, title)],
summary=f"{user['username']} requested a bug report: {title[:60]}",
metadata={"title": title},
links=[audit.job(uid)],
)
return action_result(request, "/bugs", data={"uid": bug_uid})
logger.info("Bug report enqueued by %s: %s", user["username"], title[:60])
return JSONResponse({"uid": uid, "status_url": f"/bugs/jobs/{uid}"})
@router.get("/jobs/{uid}")
async def bug_job_status(request: Request, uid: str):
job = queue.get_job(uid)
if not job or job.get("kind") != "bug_create":
raise not_found("Job not found")
result = job.get("result", {})
done = job.get("status") == queue.DONE
payload = {
"uid": job.get("uid", ""),
"kind": job.get("kind", ""),
"status": job.get("status", ""),
"number": result.get("number") if done else None,
"bug_url": result.get("bug_url") if done else None,
"enhanced": bool(result.get("enhanced")) if done else False,
"error": job.get("error") or None,
"created_at": job.get("created_at"),
"completed_at": job.get("completed_at") or None,
}
return JSONResponse(BugJobOut.model_validate(payload).model_dump(mode="json"))
@router.get("/{number}", response_class=HTMLResponse)
async def bug_detail(request: Request, number: int):
user = get_current_user(request)
if not gitea_config().is_configured:
raise not_found("Bug not found")
client = runtime.get_client()
try:
issue = await client.get_issue(number)
comments = await client.list_comments(number)
except GiteaError as exc:
if exc.status != 404:
logger.warning("Could not load bug #%s: %s", number, exc)
raise not_found("Bug not found")
author_uid = store.author_uid_for_issue(number)
comment_authors = store.comment_author_map(
[int(comment.get("id", 0)) for comment in comments]
)
uids = [uid for uid in [author_uid, *comment_authors.values()] if uid]
users_map = get_users_by_uids(uids)
bug = _issue_item(issue, author_uid, users_map)
comment_items = [
_comment_item(
comment, comment_authors.get(int(comment.get("id", 0))), users_map
)
for comment in comments
]
seo_ctx = _seo(
request,
title=f"Bug #{number}: {issue.get('title', '')}",
description=issue.get("title", ""),
)
return respond(
request,
"bug_detail.html",
{
**seo_ctx,
"request": request,
"user": user,
"bug": bug,
"body": issue.get("body", ""),
"comments": comment_items,
"can_comment": user is not None,
"is_admin": is_admin(user),
},
model=BugDetailOut,
)
@router.post("/{number}/comment")
async def comment_bug(
request: Request, number: int, data: Annotated[BugCommentForm, Form()]
):
user = require_user(request)
if not gitea_config().is_configured:
return json_error(503, "The bug tracker is not configured")
client = runtime.get_client()
body = (
f"{data.body.strip()}\n\n---\n"
f"*Posted by **{user['username']}** via DevPlace.*"
)
try:
comment = await client.create_comment(number, body)
except GiteaError as exc:
logger.warning("Could not post comment on #%s: %s", number, exc)
audit.record(
request,
"bug.comment",
user=user,
result="failure",
target_type="bug",
target_uid=str(number),
summary=f"Failed to comment on bug #{number}: {exc}",
)
return json_error(exc.status or 502, "Could not post the comment")
comment_id = int(comment.get("id", 0))
store.record_comment_author(comment_id, number, user["uid"])
_notify_admins(user, number)
audit.record(
request,
"bug.comment",
user=user,
target_type="bug",
target_uid=str(number),
summary=f"{user['username']} commented on bug #{number}",
metadata={"number": number, "comment_id": comment_id},
links=[audit.target("bug", str(number))],
)
logger.info("Comment posted on bug #%s by %s", number, user["username"])
return action_result(request, f"/bugs/{number}", data={"comment_id": comment_id})
@router.post("/{number}/status")
async def status_bug(
request: Request, number: int, data: Annotated[BugStatusForm, Form()]
):
user = require_user(request)
if not is_admin(user):
audit.record(
request,
"bug.status",
user=user,
result="denied",
target_type="bug",
target_uid=str(number),
summary=f"{user['username']} denied changing status of bug #{number}",
)
return json_error(403, "Admins only")
if not gitea_config().is_configured:
return json_error(503, "The bug tracker is not configured")
try:
await runtime.get_client().set_state(number, data.status)
except GiteaError as exc:
logger.warning("Could not change status of #%s: %s", number, exc)
audit.record(
request,
"bug.status",
user=user,
result="failure",
target_type="bug",
target_uid=str(number),
summary=f"Failed to change status of bug #{number}: {exc}",
)
return json_error(exc.status or 502, "Could not change the status")
audit.record(
request,
"bug.status",
user=user,
target_type="bug",
target_uid=str(number),
new_value=data.status,
summary=f"{user['username']} set bug #{number} to {data.status}",
metadata={"number": number, "state": data.status},
links=[audit.target("bug", str(number))],
)
logger.info("Bug #%s set to %s by %s", number, data.status, user["username"])
return action_result(request, f"/bugs/{number}", data={"state": data.status})
def _notify_admins(actor: dict, number: int) -> None:
for admin in get_table("users").find(role="admin"):
if admin["uid"] == actor["uid"]:
continue
create_notification(
admin["uid"],
"bug",
f"{actor['username']} commented on bug #{number}",
str(number),
f"/bugs/{number}",
)

View File

@ -281,14 +281,6 @@ class MessageOut(_Out):
created_at: Optional[str] = None
class BugOut(_Out):
uid: str = ""
user_uid: Optional[str] = None
title: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
created_at: Optional[str] = None
# ---------- list-item wrappers ----------
@ -353,11 +345,48 @@ class NotificationGroupOut(_Out):
class BugItemOut(_Out):
bug: BugOut
author: Optional[UserOut] = None
time_ago: Optional[str] = None
comments: list[CommentItemOut] = []
attachments: list[AttachmentOut] = []
number: int = 0
title: str = ""
state: str = "open"
html_url: Optional[str] = None
comments_count: int = 0
created_at: Optional[str] = None
updated_at: Optional[str] = None
author_username: Optional[str] = None
author_uid: Optional[str] = None
author_avatar_seed: Optional[str] = None
is_local_author: bool = False
class BugCommentOut(_Out):
id: int = 0
body: str = ""
html_url: Optional[str] = None
created_at: Optional[str] = None
author_username: Optional[str] = None
author_uid: Optional[str] = None
author_avatar_seed: Optional[str] = None
is_local_author: bool = False
class BugDetailOut(_Out):
bug: BugItemOut
body: str = ""
comments: list[BugCommentOut] = []
can_comment: bool = False
is_admin: bool = False
class BugJobOut(_Out):
uid: str = ""
kind: str = ""
status: str = ""
number: Optional[int] = None
bug_url: Optional[str] = None
enhanced: bool = False
error: Optional[str] = None
created_at: Optional[str] = None
completed_at: Optional[str] = None
class LeaderboardEntryOut(_Out):
@ -563,6 +592,10 @@ class LeaderboardOut(_Out):
class BugsOut(_Out):
bugs: list[BugItemOut] = []
pagination: Optional[Any] = None
state: str = "open"
configured: bool = True
error_message: Optional[str] = None
class SavedOut(_Out):

View File

@ -816,18 +816,67 @@ ACTIONS: tuple[Action, ...] = (
name="list_bugs",
method="GET",
path="/bugs",
summary="List reported bugs",
summary="List bug tickets from the Gitea tracker",
requires_auth=False,
params=(
query("state", "Filter by state: open, closed, or all."),
query("page", "1-based page number."),
),
),
Action(
name="create_bug",
method="POST",
path="/bugs/create",
summary="Report a bug",
summary="Report a bug. It is rewritten by AI and filed on the tracker.",
description=(
"Queues a background bug creation job and returns {uid, status_url}. Poll the "
"status_url with bug_job_status until status is 'done', then show the user the "
"bug_url pointing at the filed ticket."
),
params=(
body("title", "Bug title.", required=True),
body("description", "Bug description.", required=True),
body("attachment_uids", ATTACHMENTS),
),
),
Action(
name="bug_job_status",
method="GET",
path="/bugs/jobs/{uid}",
summary="Poll a bug creation job and obtain the filed bug ticket URL once finished",
description=(
"Returns the job status. When status is 'done', bug_url and number are populated; "
"while 'pending' or 'running', poll again shortly."
),
params=(path("uid", "Bug job uid returned by create_bug."),),
requires_auth=False,
),
Action(
name="view_bug",
method="GET",
path="/bugs/{number}",
summary="View a bug ticket and its developer updates",
requires_auth=False,
params=(path("number", "The bug ticket number from a /bugs listing."),),
),
Action(
name="comment_bug",
method="POST",
path="/bugs/{number}/comment",
summary="Post a comment on a bug ticket, attributed to the current user",
params=(
path("number", "The bug ticket number."),
body("body", "Comment text.", required=True),
),
),
Action(
name="set_bug_status",
method="POST",
path="/bugs/{number}/status",
summary="Change a bug ticket status (admin only)",
requires_admin=True,
params=(
path("number", "The bug ticket number."),
body("status", "New status: open or closed.", required=True),
),
),
Action(

View File

@ -0,0 +1 @@
# retoor <retoor@molodetz.nl>

View File

@ -0,0 +1,131 @@
# retoor <retoor@molodetz.nl>
import logging
import httpx
from devplacepy.services.gitea.config import (
HTTP_TIMEOUT_SECONDS,
GiteaConfig,
gitea_config,
)
logger = logging.getLogger(__name__)
ISSUE_TYPE = "issues"
STATE_OPEN = "open"
STATE_CLOSED = "closed"
STATE_ALL = "all"
VALID_STATES = (STATE_OPEN, STATE_CLOSED, STATE_ALL)
class GiteaError(Exception):
def __init__(self, message: str, status: int = 0):
super().__init__(message)
self.status = status
self.message = message
class GiteaClient:
def __init__(self, config: GiteaConfig | None = None):
self.config = config or gitea_config()
def _headers(self) -> dict:
return {
"Authorization": f"token {self.config.token}",
"Content-Type": "application/json",
"Accept": "application/json",
}
def _require_config(self) -> None:
if not self.config.is_configured:
raise GiteaError("Gitea integration is not configured", status=503)
async def _request(self, method: str, url: str, **kwargs) -> httpx.Response:
self._require_config()
logger.debug("Gitea %s %s", method, url)
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT_SECONDS) as client:
response = await client.request(
method, url, headers=self._headers(), **kwargs
)
except httpx.HTTPError as exc:
logger.warning("Gitea request failed: %s", exc)
raise GiteaError(f"Gitea request failed: {exc}") from exc
if response.status_code >= 400:
logger.warning(
"Gitea %s %s returned %s: %s",
method,
url,
response.status_code,
response.text[:200],
)
raise GiteaError(
f"Gitea returned {response.status_code}", status=response.status_code
)
return response
async def create_issue(self, title: str, body: str) -> dict:
response = await self._request(
"POST",
f"{self.config.repo_base}/issues",
json={"title": title, "body": body},
)
return response.json()
async def list_issues(
self, state: str = STATE_OPEN, page: int = 1, limit: int = 20
) -> tuple[list[dict], int]:
if state not in VALID_STATES:
state = STATE_OPEN
response = await self._request(
"GET",
f"{self.config.repo_base}/issues",
params={
"type": ISSUE_TYPE,
"state": state,
"page": max(1, page),
"limit": max(1, min(limit, 50)),
},
)
issues = [item for item in response.json() if not item.get("pull_request")]
total = self._total_count(response, len(issues))
return issues, total
async def get_issue(self, number: int) -> dict:
response = await self._request(
"GET", f"{self.config.repo_base}/issues/{number}"
)
return response.json()
async def list_comments(self, number: int) -> list[dict]:
response = await self._request(
"GET", f"{self.config.repo_base}/issues/{number}/comments"
)
return response.json()
async def create_comment(self, number: int, body: str) -> dict:
response = await self._request(
"POST",
f"{self.config.repo_base}/issues/{number}/comments",
json={"body": body},
)
return response.json()
async def set_state(self, number: int, state: str) -> dict:
if state not in (STATE_OPEN, STATE_CLOSED):
raise GiteaError(f"invalid state: {state}", status=400)
response = await self._request(
"PATCH",
f"{self.config.repo_base}/issues/{number}",
json={"state": state},
)
return response.json()
@staticmethod
def _total_count(response: httpx.Response, fallback: int) -> int:
raw = response.headers.get("X-Total-Count", "")
try:
return int(raw)
except (TypeError, ValueError):
return fallback

View File

@ -0,0 +1,114 @@
# retoor <retoor@molodetz.nl>
from dataclasses import dataclass
from devplacepy.config import INTERNAL_MODEL
from devplacepy.database import get_setting, internal_gateway_key
from devplacepy.services.base import ConfigField
BASE_URL_DEFAULT = "https://retoor.molodetz.nl"
OWNER_DEFAULT = "retoor"
REPO_DEFAULT = "pydevplace"
HTTP_TIMEOUT_SECONDS = 20.0
@dataclass(frozen=True)
class GiteaConfig:
base_url: str
owner: str
repo: str
token: str
ai_enhance: bool
ai_model: str
ai_key: str
@property
def api_base(self) -> str:
return f"{self.base_url.rstrip('/')}/api/v1"
@property
def repo_base(self) -> str:
return f"{self.api_base}/repos/{self.owner}/{self.repo}"
@property
def is_configured(self) -> bool:
return bool(self.base_url and self.owner and self.repo and self.token)
CONFIG_FIELDS: list[ConfigField] = [
ConfigField(
"gitea_base_url",
"Gitea base URL",
type="url",
default=BASE_URL_DEFAULT,
help="Origin of the Gitea instance, without the /api path.",
group="Gitea",
),
ConfigField(
"gitea_owner",
"Repository owner",
type="str",
default=OWNER_DEFAULT,
help="Owner (user or organisation) of the issue tracker repository.",
group="Gitea",
),
ConfigField(
"gitea_repo",
"Repository name",
type="str",
default=REPO_DEFAULT,
help="Repository whose issues back the bug tracker.",
group="Gitea",
),
ConfigField(
"gitea_token",
"Gitea access token",
type="password",
default="",
secret=True,
help="Personal access token with repo issue scope. All actions use this single account.",
group="Gitea",
),
ConfigField(
"bug_ai_enhance",
"Improve tickets with AI",
type="bool",
default=True,
help="Rewrite each submitted report into a consistent, high-quality ticket before posting.",
group="AI",
),
ConfigField(
"bug_ai_model",
"AI model",
type="str",
default=INTERNAL_MODEL,
help="Model name sent to the internal AI gateway for ticket enhancement.",
group="AI",
),
ConfigField(
"bug_ai_key",
"AI API key",
type="password",
default="",
secret=True,
help="Defaults to the internal gateway key when left blank.",
group="AI",
),
]
def gitea_config() -> GiteaConfig:
ai_key = get_setting("bug_ai_key", "") or internal_gateway_key()
return GiteaConfig(
base_url=get_setting("gitea_base_url", BASE_URL_DEFAULT),
owner=get_setting("gitea_owner", OWNER_DEFAULT),
repo=get_setting("gitea_repo", REPO_DEFAULT),
token=get_setting("gitea_token", ""),
ai_enhance=get_setting("bug_ai_enhance", "1") == "1",
ai_model=get_setting("bug_ai_model", INTERNAL_MODEL),
ai_key=ai_key,
)
def is_configured() -> bool:
return gitea_config().is_configured

View File

@ -0,0 +1,115 @@
# retoor <retoor@molodetz.nl>
import json
import logging
import re
from dataclasses import dataclass
import httpx
from devplacepy.config import INTERNAL_GATEWAY_URL
from devplacepy.services.gitea.config import HTTP_TIMEOUT_SECONDS, GiteaConfig
logger = logging.getLogger(__name__)
MAX_TOKENS = 1200
TITLE_MAX = 200
BODY_MAX = 60000
SYSTEM_PROMPT = (
"You are a senior triage engineer for DevPlace, a social network for software "
"developers. You rewrite raw user bug reports into clear, consistent, high-quality "
"issue tickets for a Gitea tracker. Preserve every concrete fact the reporter gave; "
"never invent versions, stack traces, or steps that were not provided. Keep a calm, "
"technical, professional tone. Do not add greetings or sign-offs.\n\n"
"Return ONLY a JSON object with exactly two string keys: \"title\" and \"body\".\n"
"- title: a concise, specific, imperative summary under 120 characters.\n"
"- body: GitHub-flavored markdown using EXACTLY these sections, in order, each as a "
"level-2 heading:\n"
"## Summary\n## Steps to Reproduce\n## Expected Behaviour\n## Actual Behaviour\n"
"## Environment\n"
"Under each heading put what the report supports; if a section is unknown write "
"'Not provided.' Use a numbered list under Steps to Reproduce."
)
@dataclass(frozen=True)
class EnhancedTicket:
title: str
body: str
enhanced: bool
def _fallback(title: str, description: str) -> EnhancedTicket:
body = (
f"## Summary\n{title}\n\n"
f"## Steps to Reproduce\n{description}\n\n"
"## Expected Behaviour\nNot provided.\n\n"
"## Actual Behaviour\nNot provided.\n\n"
"## Environment\nNot provided.\n"
)
return EnhancedTicket(title=title[:TITLE_MAX], body=body[:BODY_MAX], enhanced=False)
def _parse(text: str) -> tuple[str, str] | None:
match = re.search(r"\{.*\}", text, re.DOTALL)
if not match:
return None
try:
payload = json.loads(match.group())
except (ValueError, TypeError):
return None
title = str(payload.get("title", "")).strip()
body = str(payload.get("body", "")).strip()
if not title or not body:
return None
return title[:TITLE_MAX], body[:BODY_MAX]
async def enhance_ticket(
title: str, description: str, config: GiteaConfig
) -> EnhancedTicket:
title = title.strip()
description = description.strip()
if not config.ai_enhance:
return _fallback(title, description)
payload = {
"model": config.ai_model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": f"Reporter title: {title}\n\nReporter description:\n{description}",
},
],
"max_tokens": MAX_TOKENS,
"temperature": 0.2,
}
headers = {"Content-Type": "application/json"}
if config.ai_key:
headers["Authorization"] = f"Bearer {config.ai_key}"
try:
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT_SECONDS) as client:
response = await client.post(
INTERNAL_GATEWAY_URL, json=payload, headers=headers
)
response.raise_for_status()
content = (
response.json()
.get("choices", [{}])[0]
.get("message", {})
.get("content", "")
)
except (httpx.HTTPError, ValueError, KeyError, IndexError) as exc:
logger.warning("Bug ticket enhancement failed, using fallback: %s", exc)
return _fallback(title, description)
parsed = _parse(content)
if not parsed:
logger.warning("Bug ticket enhancement returned unparseable content")
return _fallback(title, description)
new_title, new_body = parsed
logger.info("Enhanced bug ticket '%s' -> '%s'", title[:60], new_title[:60])
return EnhancedTicket(title=new_title, body=new_body, enhanced=True)

View File

@ -0,0 +1,114 @@
# retoor <retoor@molodetz.nl>
from datetime import datetime, timezone
from devplacepy.services.gitea.client import (
STATE_ALL,
STATE_CLOSED,
STATE_OPEN,
GiteaError,
)
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
class FakeGiteaClient:
def __init__(self, bot_login: str = "devplace-bot"):
self.bot_login = bot_login
self._issues: dict[int, dict] = {}
self._comments: dict[int, list[dict]] = {}
self._issue_seq = 0
self._comment_seq = 0
def _html(self, number: int) -> str:
return f"https://gitea.test/retoor/pydevplace/issues/{number}"
async def create_issue(self, title: str, body: str) -> dict:
self._issue_seq += 1
number = self._issue_seq
now = _now()
issue = {
"number": number,
"title": title,
"body": body,
"state": STATE_OPEN,
"html_url": self._html(number),
"user": {"login": self.bot_login},
"comments": 0,
"created_at": now,
"updated_at": now,
"closed_at": None,
}
self._issues[number] = issue
self._comments[number] = []
return dict(issue)
async def list_issues(
self, state: str = STATE_OPEN, page: int = 1, limit: int = 20
) -> tuple[list[dict], int]:
rows = [
issue
for issue in self._issues.values()
if state == STATE_ALL or issue["state"] == state
]
rows.sort(key=lambda item: item["number"], reverse=True)
total = len(rows)
start = (max(1, page) - 1) * limit
return [dict(item) for item in rows[start : start + limit]], total
async def get_issue(self, number: int) -> dict:
issue = self._issues.get(number)
if not issue:
raise GiteaError("issue not found", status=404)
return dict(issue)
async def list_comments(self, number: int) -> list[dict]:
if number not in self._issues:
raise GiteaError("issue not found", status=404)
return [dict(item) for item in self._comments.get(number, [])]
async def create_comment(self, number: int, body: str) -> dict:
if number not in self._issues:
raise GiteaError("issue not found", status=404)
self._comment_seq += 1
now = _now()
comment = {
"id": self._comment_seq,
"body": body,
"html_url": f"{self._html(number)}#comment-{self._comment_seq}",
"user": {"login": self.bot_login},
"created_at": now,
"updated_at": now,
}
self._comments[number].append(comment)
self._issues[number]["comments"] = len(self._comments[number])
return dict(comment)
async def set_state(self, number: int, state: str) -> dict:
issue = self._issues.get(number)
if not issue:
raise GiteaError("issue not found", status=404)
if state not in (STATE_OPEN, STATE_CLOSED):
raise GiteaError(f"invalid state: {state}", status=400)
issue["state"] = state
issue["closed_at"] = _now() if state == STATE_CLOSED else None
issue["updated_at"] = _now()
return dict(issue)
def add_external_comment(self, number: int, login: str, body: str) -> dict:
self._comment_seq += 1
now = _now()
comment = {
"id": self._comment_seq,
"body": body,
"html_url": f"{self._html(number)}#comment-{self._comment_seq}",
"user": {"login": login},
"created_at": now,
"updated_at": now,
}
self._comments.setdefault(number, []).append(comment)
if number in self._issues:
self._issues[number]["comments"] = len(self._comments[number])
return dict(comment)

View File

@ -0,0 +1,16 @@
# retoor <retoor@molodetz.nl>
_client = None
def get_client():
if _client is not None:
return _client
from devplacepy.services.gitea.client import GiteaClient
return GiteaClient()
def set_client(client) -> None:
global _client
_client = client

View File

@ -0,0 +1,108 @@
# retoor <retoor@molodetz.nl>
import logging
from devplacepy.services.base import BaseService
from devplacepy.services.gitea import runtime, store
from devplacepy.services.gitea.client import GiteaError
from devplacepy.services.gitea.config import CONFIG_FIELDS, gitea_config
from devplacepy.utils import create_notification
logger = logging.getLogger(__name__)
class BugTrackerService(BaseService):
interval_key = "bug_tracker_interval"
min_interval = 30
default_enabled = False
title = "Bug Tracker"
description = (
"Holds the Gitea connection and AI settings for the bug tracker and polls every "
"tracked issue for developer replies and status changes, notifying the original "
"reporter when their ticket is updated."
)
config_fields = CONFIG_FIELDS
def __init__(self):
super().__init__(name="bug_tracker", interval_seconds=120)
async def run_once(self) -> None:
config = gitea_config()
if not config.is_configured:
self.log("Gitea integration not configured; skipping poll")
return
client = runtime.get_client()
tickets = store.tracked_tickets()
self.log(f"Polling {len(tickets)} tracked tickets")
checked = 0
notified = 0
for ticket in tickets:
number = int(ticket["gitea_number"])
try:
issue = await client.get_issue(number)
except GiteaError as exc:
self.log(f"Could not poll #{number}: {exc}")
continue
checked += 1
notified += await self._reconcile(client, ticket, issue)
self.log(f"Polled {checked} tickets, sent {notified} notifications")
async def _reconcile(self, client, ticket: dict, issue: dict) -> int:
number = int(ticket["gitea_number"])
author_uid = ticket["author_uid"]
state = issue.get("state", "open")
comment_count = int(issue.get("comments", 0))
last_count = int(ticket.get("last_comment_count", 0))
last_status = ticket.get("last_status", "open")
notified = 0
if comment_count > last_count:
try:
comments = await client.list_comments(number)
except GiteaError as exc:
self.log(f"Could not load comments for #{number}: {exc}")
return notified
local_ids = store.local_comment_ids(number)
fresh = comments[last_count:]
external = [c for c in fresh if int(c.get("id", 0)) not in local_ids]
if external:
create_notification(
author_uid,
"bug",
f"New developer reply on your bug #{number}",
str(number),
f"/bugs/{number}",
)
self._audit("bug.sync.reply", author_uid, number)
notified += 1
if state != last_status:
verb = "closed" if state == "closed" else "reopened"
create_notification(
author_uid,
"bug",
f"Your bug #{number} was {verb}",
str(number),
f"/bugs/{number}",
)
self._audit("bug.sync.status", author_uid, number, {"state": state})
notified += 1
store.update_ticket_cache(number, state, comment_count)
return notified
def _audit(
self, event: str, author_uid: str, number: int, metadata: dict | None = None
) -> None:
from devplacepy.services.audit import record as audit
audit.record_system(
event,
actor_kind="service",
target_type="bug",
target_uid=str(number),
metadata={"number": number, **(metadata or {})},
summary=f"bug #{number} update detected",
links=[audit.recipient(author_uid)],
)

View File

@ -0,0 +1,116 @@
# retoor <retoor@molodetz.nl>
import logging
from datetime import datetime, timezone
from devplacepy.database import get_table
from devplacepy.utils import generate_uid
logger = logging.getLogger(__name__)
TICKETS = "bug_tickets"
COMMENT_AUTHORS = "bug_comment_authors"
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def record_ticket(
number: int,
author_uid: str,
original_title: str,
original_description: str,
enhanced_title: str,
html_url: str,
status: str,
) -> str:
uid = generate_uid()
get_table(TICKETS).insert(
{
"uid": uid,
"gitea_number": int(number),
"author_uid": author_uid,
"original_title": original_title[:200],
"original_description": original_description[:5000],
"enhanced_title": enhanced_title[:200],
"html_url": html_url,
"last_status": status,
"last_comment_count": 0,
"created_at": _now(),
"updated_at": _now(),
"deleted_at": None,
"deleted_by": None,
}
)
logger.info("Recorded bug ticket #%s by %s", number, author_uid)
return uid
def get_ticket(number: int) -> dict | None:
return get_table(TICKETS).find_one(gitea_number=int(number), deleted_at=None)
def author_uid_for_issue(number: int) -> str | None:
row = get_ticket(number)
return row["author_uid"] if row else None
def author_map(numbers: list[int]) -> dict[int, str]:
if not numbers:
return {}
table = get_table(TICKETS)
ids = [int(n) for n in numbers]
rows = table.find(table.table.columns.gitea_number.in_(ids), deleted_at=None)
return {int(row["gitea_number"]): row["author_uid"] for row in rows}
def tracked_tickets() -> list[dict]:
return list(get_table(TICKETS).find(deleted_at=None, order_by=["gitea_number"]))
def update_ticket_cache(number: int, status: str, comment_count: int) -> None:
row = get_ticket(number)
if not row:
return
get_table(TICKETS).update(
{
"uid": row["uid"],
"last_status": status,
"last_comment_count": int(comment_count),
"updated_at": _now(),
},
["uid"],
)
def record_comment_author(comment_id: int, number: int, author_uid: str) -> str:
uid = generate_uid()
get_table(COMMENT_AUTHORS).insert(
{
"uid": uid,
"gitea_comment_id": int(comment_id),
"gitea_number": int(number),
"author_uid": author_uid,
"created_at": _now(),
"deleted_at": None,
"deleted_by": None,
}
)
return uid
def comment_author_map(comment_ids: list[int]) -> dict[int, str]:
if not comment_ids:
return {}
table = get_table(COMMENT_AUTHORS)
ids = [int(cid) for cid in comment_ids]
rows = table.find(
table.table.columns.gitea_comment_id.in_(ids), deleted_at=None
)
return {int(row["gitea_comment_id"]): row["author_uid"] for row in rows}
def local_comment_ids(number: int) -> set[int]:
rows = get_table(COMMENT_AUTHORS).find(gitea_number=int(number), deleted_at=None)
return {int(row["gitea_comment_id"]) for row in rows}

View 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

View File

@ -14,12 +14,45 @@
font-size: 1.5rem;
font-weight: 700;
}
.bugs-filters {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.bugs-filter {
font-size: 0.8125rem;
font-weight: 600;
color: var(--text-secondary);
padding: 0.35rem 0.85rem;
border-radius: 999px;
border: 1px solid var(--border);
text-decoration: none;
}
.bugs-filter:hover {
background: var(--bg-hover);
}
.bugs-filter.active {
background: var(--accent);
color: var(--white);
border-color: var(--accent);
}
.bugs-modal-hint {
font-size: 0.8125rem;
color: var(--text-muted);
margin-bottom: 0.75rem;
}
.bug-card {
display: block;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1.25rem;
margin-bottom: 1rem;
text-decoration: none;
transition: border-color 0.15s ease;
}
.bug-card:hover {
border-color: var(--accent);
}
.bug-card-header {
display: flex;
@ -27,6 +60,14 @@
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.bug-number {
font-size: 0.8125rem;
font-weight: 700;
color: var(--text-muted);
}
.bug-title {
flex: 1;
}
.bug-title {
font-size: 1rem;
font-weight: 600;
@ -55,9 +96,100 @@
margin-bottom: 0.5rem;
}
.bug-meta {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.75rem;
color: var(--text-muted);
}
.bug-dot {
color: var(--border);
}
.bug-detail-nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.bug-back,
.bug-gitea-link {
font-size: 0.8125rem;
font-weight: 600;
color: var(--accent);
text-decoration: none;
}
.bug-detail-card {
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.bug-detail-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.bug-detail-title {
flex: 1;
font-size: 1.25rem;
font-weight: 700;
}
.bug-admin-actions {
margin: 1rem 0;
}
.bug-detail-body {
margin-top: 1rem;
color: var(--text-primary);
line-height: 1.6;
}
.bug-comment {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 1rem 1.25rem;
margin-bottom: 0.75rem;
}
.bug-comment-head {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 0.5rem;
}
.bug-comment-badge {
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: var(--accent-light);
color: var(--accent);
}
.bug-comment-body {
font-size: 0.9375rem;
color: var(--text-primary);
line-height: 1.55;
}
.bug-comment-form {
margin-top: 1rem;
}
.bug-comment-form textarea {
width: 100%;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.75rem;
font: inherit;
background: var(--bg-input);
color: var(--text-primary);
resize: vertical;
}
.bug-comment-form-footer {
display: flex;
justify-content: flex-end;
margin-top: 0.5rem;
}
@media (max-width: 480px) {
.bugs-header {

View File

@ -25,6 +25,7 @@ import { Tabs } from "./Tabs.js";
import { DeviiTerminal } from "./DeviiTerminal.js";
import { ZipDownloader } from "./ZipDownloader.js";
import { ProjectForker } from "./ProjectForker.js";
import { BugReporter } from "./BugReporter.js";
import { MediaGallery } from "./MediaGallery.js";
import WindowManager from "./components/WindowManager.js";
import { ContainerTerminalManager } from "./ContainerTerminalManager.js";
@ -62,6 +63,7 @@ class Application {
this.devii = new DeviiTerminal();
this.zipDownloader = new ZipDownloader();
this.projectForker = new ProjectForker();
this.bugReporter = new BugReporter();
this.mediaGallery = new MediaGallery();
}
}

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

View 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">&#x2190; 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">&middot;</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 &amp; 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">&middot;</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 %}

View File

@ -1,8 +1,6 @@
{% extends "base.html" %}
{% from "_macros.html" import modal %}
{% block extra_head %}
<link rel="stylesheet" href="/static/css/feed.css">
<link rel="stylesheet" href="/static/css/post.css">
<link rel="stylesheet" href="/static/css/bugs.css">
{% endblock %}
{% block content %}
@ -16,46 +14,56 @@
{% endif %}
</div>
<div class="bugs-filters">
<a href="?state=open" class="bugs-filter {% if state == 'open' %}active{% endif %}">Open</a>
<a href="?state=closed" class="bugs-filter {% if state == 'closed' %}active{% endif %}">Closed</a>
<a href="?state=all" class="bugs-filter {% if state == 'all' %}active{% endif %}">All</a>
</div>
{% if not configured or error_message %}
<div class="empty-state">{{ error_message }}</div>
{% else %}
<div class="bugs-list">
{% for item in bugs %}
<div class="bug-card">
{% for bug in bugs %}
<a class="bug-card" href="/bugs/{{ bug.number }}">
<div class="bug-card-header">
{% set _user = item.author %}{% set _size = 24 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
<span class="bug-title">{{ item.bug['title'] }}</span>
<span class="bug-status {{ item.bug['status'] }}">{{ item.bug['status'] }}</span>
<span class="bug-number">#{{ bug.number }}</span>
<span class="bug-title">{{ bug.title }}</span>
<span class="bug-status {{ bug.state }}">{{ bug.state }}</span>
</div>
<div class="bug-desc">{{ item.bug['description'][:300] }}</div>
<div class="bug-meta">{{ item.author['username'] if item.author else 'Unknown' }} &middot; {{ item.time_ago }}</div>
{% set b_attachments = item.get('attachments', []) %}
{% if b_attachments %}
{% with attachments=b_attachments %}
{% include "_attachment_display.html" %}
{% endwith %}
{% endif %}
{% with target_uid=item.bug['uid'], target_type="bug", comments=item.comments %}
{% include "_comment_section.html" %}
{% endwith %}
</div>
<div class="bug-meta">
{% if bug.is_local_author %}
{% set _user = {'username': bug.author_username} %}{% set _size = 20 %}{% set _size_class = "sm" %}{% include "_avatar_link.html" %}
{% endif %}
<span class="bug-author">{{ bug.author_username }}</span>
<span class="bug-dot">&middot;</span>
<span class="bug-date">{{ format_date(bug.created_at) }}</span>
<span class="bug-dot">&middot;</span>
<span class="bug-comments">{{ bug.comments_count }} comment{{ '' if bug.comments_count == 1 else 's' }}</span>
</div>
</a>
{% else %}
<div class="empty-state">No bug reports yet.</div>
{% endfor %}
</div>
{% set pagination_query = 'state=' ~ state ~ '&' %}
{% include "_pagination.html" %}
{% endif %}
</div>
{% if user %}
{% call modal('create-bug-modal', 'Report a Bug') %}
<form method="POST" action="/bugs/create">
<form method="POST" action="/bugs/create" data-bug-create>
<p class="bugs-modal-hint">Your report is improved by AI into a consistent ticket and filed on the tracker.</p>
<div class="auth-field auth-field-gap">
<label for="bug-title">Title</label>
<input type="text" id="bug-title" name="title" required maxlength="200" placeholder="Brief description of the issue">
</div>
<div class="auth-field auth-field-gap">
<label for="bug-description">Description</label>
<textarea id="bug-description" name="description" required maxlength="5000" placeholder="Detailed steps to reproduce..." class="min-h-120" data-mention></textarea>
<textarea id="bug-description" name="description" required maxlength="5000" placeholder="Steps to reproduce, expected vs actual, environment..." class="min-h-120"></textarea>
</div>
{% include "_attachment_form.html" %}
<div class="modal-footer">
<button type="button" class="modal-close btn btn-secondary">Cancel</button>
<button type="submit" class="btn btn-primary"><span class="icon">&#x1F4E4;</span> Submit Report</button>

View File

@ -75,6 +75,25 @@ time in dependency order, so file changes never collide.
This is the command to run before a release, or in a continuous-integration job
(in `CHECK=1` mode it returns a non-zero exit code if anything is wrong).
## Checking only what you changed
While you work, you usually only want the fleet to look at the files you just
touched, not the whole project. These two targets run the whole fleet but limit it
to the files git reports as modified or new (untracked) under `devplacepy/` and
`tests/`, so a sweep takes seconds instead of minutes:
```bash
make maintenance # report only, all agents at once, just your changed files
make maintenance-fix # fix, just your changed files
```
`make maintenance` is read-only and concurrent, exactly like `make agents-all
CHECK=1` but narrowed to your work in progress. `make maintenance-fix` fixes, and a
built-in safety rule guarantees it can only edit the files in that changed set,
never anything else. If nothing under `devplacepy/` or `tests/` has changed, the run
prints "nothing to do" and exits cleanly. The same scope is available on a single
agent with the `--changed` flag, for example `python -m agents.security --changed`.
## What you see while it runs
The output is meant to be read live, so you always know what is happening:

View File

@ -18,11 +18,11 @@ The `make` targets that produce coverage:
| Target | What it does |
|--------|--------------|
| `make coverage` | Runs the full suite single-process (`-p no:xdist`) under coverage, then `coverage combine` and `coverage report`. |
| `make coverage` | Runs the full serial suite under coverage, then `coverage combine` and `coverage report`. |
| `make coverage-headed` | The same, with a visible browser. |
| `make coverage-html` | Runs `make coverage`, then `coverage html` and writes the browsable report to `htmlcov/index.html`. |
Coverage runs single-process on purpose: xdist is disabled so the combined profile is stable and the subprocess data merges cleanly.
The whole suite already runs in a single process (serial execution is enforced globally in `pyproject.toml`), so `coverage combine` only has to merge two profiles: the test process and the application's Uvicorn subprocess. There are no per-worker profiles to reconcile, which keeps the combined report stable.
## Continuous integration: Gitea Actions
@ -31,7 +31,7 @@ The pipeline is defined in `.gitea/workflows/test.yaml` and runs on every **push
1. Check out the repository.
2. Set up Python 3.13.
3. Install dependencies: `pip install -e ".[dev]"` and `python -m playwright install chromium --with-deps`.
4. Run the full suite under coverage: `python -m coverage run -m pytest tests/ -p no:xdist --tb=line`, with `PLAYWRIGHT_HEADLESS=1` and `COVERAGE_PROCESS_START` pointing at `.coveragerc`.
4. Run the full serial suite under coverage: `python -m coverage run -m pytest tests/`, with `PLAYWRIGHT_HEADLESS=1` and `COVERAGE_PROCESS_START` pointing at `.coveragerc`. The serial flags (`--tb=line -p no:xdist`) come from `addopts` in `pyproject.toml`, so the command line stays minimal.
5. Build the coverage report (`coverage combine`, `coverage report`, `coverage html`). This step runs with `if: always()` so a report is produced even when tests fail.
6. Publish the `coverage-html` artifact (the `htmlcov/` directory), always.
7. On failure only, upload the `failure-screenshots` artifact from `/tmp/devplace_test_screenshots/` (saved by the `pytest_runtest_makereport` hook in `conftest.py`).

View File

@ -13,7 +13,7 @@ All fixtures live in `tests/conftest.py`. They layer from a shared server up to
| Fixture | Scope | Purpose |
|---------|-------|---------|
| `app_server` | session | Spawns Uvicorn as a subprocess on a per-worker port with a tempfile SQLite database, then waits for it to answer. |
| `app_server` | session | Spawns Uvicorn as a subprocess on port 10501 with a tempfile SQLite database, then waits for it to answer. |
| `playwright_instance` / `browser` | session | One Playwright runtime and one Chromium browser for the whole run. |
| `browser_context` | module | One shared browser context. |
| `page` | function | Clears cookies on the shared context, then yields a fresh page. |
@ -22,9 +22,11 @@ All fixtures live in `tests/conftest.py`. They layer from a shared server up to
In-process tests skip the browser entirely and call a FastAPI `TestClient` or run coroutines through the `run_async` helper, which executes them on a worker thread and refreshes the database snapshot afterward.
## Isolation and ports
## Serial execution, isolation, and ports
Each xdist worker runs its own server on its own port, derived from the worker index: master uses `10501`, `gw0` uses `10501`, `gw1` uses `10502`, and so on. Every worker gets a separate tempfile database. Background services are disabled in tests via `DEVPLACE_DISABLE_SERVICES=1`, and the rate limit is seeded to `1000000` so the suite is never throttled.
The suite runs **serially, one test at a time, in a single process**. There is one server on port `10501`, backed by one tempfile database and one `DEVPLACE_DATA_DIR`, shared for the whole session. Serial execution is enforced centrally in `pyproject.toml` (`[tool.pytest.ini_options]` sets `addopts = "--tb=line -p no:xdist"`), so passing `-n` is rejected and the suite can never run concurrently. Background services are disabled in tests via `DEVPLACE_DISABLE_SERVICES=1`, and the rate limit is seeded to `1000000` so the suite is never throttled.
Because every test shares one server and one database, tests that mutate global or seeded state MUST clean up after themselves (see the `try/finally` rule below), and a test that changes a cached user's row through a direct database write should invalidate the auth cache (`clear_user_cache(uid)`) so the running server observes the change.
## Mandatory patterns

View File

@ -5,7 +5,7 @@ Every test workflow has a `make` target so the commands are short and identical
## Setup
Install the package with its development extras (`pytest`, Playwright, xdist, coverage) and the browser binary once:
Install the package with its development extras (`pytest`, Playwright, coverage) and the browser binary once:
```bash
make install # pip install -e .
@ -17,13 +17,13 @@ python -m playwright install chromium # browser used by the E2E suite
| Target | What it does |
|--------|--------------|
| `make test` | Full suite headless, fail-fast (`-x`), `--tb=line`. The everyday command. |
| `make test` | Full suite headless, serial (one test at a time), fail-fast (`-x`). The everyday command. |
| `make test-headed` | The same suite in a visible browser, for watching or debugging an E2E test. |
| `make coverage` | Full suite under coverage, single process (`-p no:xdist`), then prints a coverage report. |
| `make coverage` | Full suite under coverage, then prints a coverage report. |
| `make coverage-headed` | Coverage run with a visible browser. |
| `make coverage-html` | Runs `coverage`, then writes the browsable HTML report to `htmlcov/index.html`. |
`make test` sets `PLAYWRIGHT_HEADLESS=1`; `make test-headed` sets it to `0`. The coverage targets disable xdist so coverage is collected in one process, which is also exactly how CI runs.
`make test` sets `PLAYWRIGHT_HEADLESS=1`; `make test-headed` sets it to `0`. The whole suite runs in a single process: serial execution is enforced in `pyproject.toml` (`addopts = "--tb=line -p no:xdist"`), so there are no parallel workers and the coverage targets collect everything in one process, exactly how CI runs.
To run one test instead of the suite, call pytest directly:
@ -48,5 +48,5 @@ make clean # remove __pycache__, *.pyc, the egg-info, and .venv
## What CI runs
`.gitea/workflows/test.yaml` mirrors these targets: it installs `".[dev]"` and Chromium, then runs the suite under coverage with `-p no:xdist`, publishes the coverage HTML, and on failure uploads the screenshots from `/tmp/devplace_test_screenshots/`. Running `make coverage` locally reproduces the CI result before you push.
`.gitea/workflows/test.yaml` mirrors these targets: it installs `".[dev]"` and Chromium, then runs the serial suite under coverage (`coverage run -m pytest tests/`), publishes the coverage HTML, and on failure uploads the screenshots from `/tmp/devplace_test_screenshots/`. Running `make coverage` locally reproduces the CI result before you push.
</div>

View File

@ -1,5 +1,4 @@
from tests.conftest import BASE_URL
from devplacepy.database import get_table
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')")
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):
page, _ = alice
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')")
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):
page, _ = alice
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
@ -52,77 +37,20 @@ def test_bugs_create_cancel(alice):
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")
assert page.is_visible("h1:has-text('Bug Reports')")
assert page.locator("a.login-required:has-text('Report Bug')").is_visible()
assert page.locator("#create-bug-modal").count() == 0
def test_bug_comments_form_visible(alice):
def test_bugs_footer_link(alice):
page, _ = alice
page.goto(f"{BASE_URL}/bugs", wait_until="domcontentloaded")
page.click("button:has-text('Report Bug')")
page.fill("#bug-title", "Bug with comments")
page.fill("#bug-description", "Testing comment form on bug")
page.click("button:has-text('Submit Report')")
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")
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_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")
footer = page.locator("a:has-text('Bug Report')").inner_html()
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
View 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"