This file documents the Gitea issue-tracker integration subsystem. Claude Code auto-loads it when a file under `devplacepy/services/gitea/` is read or edited.

## Issue tracker (`services/gitea/`)

The `/issues` feature is a **full Gitea integration with no local issue store** - there is no local issue store; the listing and detail views read issues straight from Gitea (default repo `retoor/devplacepy` on `retoor.molodetz.nl`) with live status. All connection settings live in `site_settings` and are admin-editable on the `IssueTrackerService` config at `/admin/services`.

### Package `services/gitea/`

- `config.py` - the admin-editable `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/double.
- `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)` rewrites a raw report into a consistent markdown ticket via the **internal AI gateway** (`INTERNAL_GATEWAY_URL`, key defaults to the gateway internal key): a level-2-heading markdown body (`Summary` / `Steps to Reproduce` / `Expected Behaviour` / `Actual Behaviour` / `Environment`) and a tightened title. It is fail-soft to a deterministic template: 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 **only DB state**, a thin local mapping: `issue_tickets` (gitea_number -> author_uid + original text + cached `last_status`/`last_comment_count` for update detection) and `issue_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` - `IssueTrackerService(BaseService)` (`default_enabled=False`, holds `CONFIG_FIELDS`), polls tracked tickets and notifies the **reporter** on developer replies / status changes. 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 (`issue.sync.reply` / `issue.sync.status`).

### Filing (async job)

Filing is an **async job** (`services/jobs/issue_create_service.py` `IssueCreateService`, kind `issue_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 issue report was filed as #N`), and audits `issue.create`. `cleanup` is a no-op (the issue is permanent; only the job row is swept). `POST /issues/create` enqueues and returns `{uid, status_url}`; frontend `app.issueReporter` (`static/js/IssueReporter.js`) submits the create form, polls `/issues/jobs/{uid}` via `JobPoller`, and redirects to `/issues/{number}`.

### Routes (`routers/issues/` package, prefix `/issues`)

- `GET /issues` - 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 /issues/create` - enqueue (see Filing above).
- `GET /issues/jobs/{uid}` - `IssueJobOut`.
- `GET /issues/{number}` - `issue_detail.html`: issue body + comments rendered as markdown via `data-render`, decorated with DevPlace authors or a `dev` badge.
- `POST /issues/{number}/comment` - synchronous: posts the comment to Gitea attributed to the user, records the author mapping, notifies **all admins**, audits `issue.comment`.
- `POST /issues/{number}/status` - admin-only open/closed via Gitea PATCH, audits `issue.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.

### Content rendering and mentions (frontend reuse, no new modules)

The issue body and every comment render through the platform content pipeline like posts/comments - each is a `<div class="...rendered-content" data-render>{{ body|comment.body }}</div>` (`issue_detail.html`), so the globally-instantiated `ContentEnhancer` (`app.content`) runs `contentRenderer.applyTo` (marked -> DOMPurify -> highlight.js -> media/autolink) over Gitea-sourced markdown client-side from `element.textContent`. Gitea content is **NEVER** marked `| safe` into raw HTML; Jinja autoescapes it and the DOMPurify step inside `ContentRenderer` is the XSS control, identical to user posts. The issue authoring textareas (`#issue-description` in the create modal on `issues.html`, and the comment textarea on `issue_detail.html`) carry the `data-mention` attribute, so the same `ContentEnhancer.initMentionInputs()` -> `MentionInput` flow that wires post `@`-mention autocomplete (debounced `GET /profile/search?q=` dropdown) attaches to them with no new code. There is no local issue-edit form (issues are AI-created then commented), so "edit" reuses nothing further. `mention.css` is loaded globally in `base.html`.

### Attachments (mirror: local store + Gitea assets, `routers/issues/attachments.py`)

Issues and issue comments accept file attachments, with full add/delete CRUD allowed **only while the issue is open**. Storage is a mirror - the canonical copy is the platform attachment system (`attachments` table, keyed `target_type="issue"`/`target_uid=str(number)` and `target_type="issue_comment"`/`target_uid=str(comment_id)`), and each file is also pushed to the Gitea native asset API so it appears on the tracker. The local row carries a `gitea_asset_id` back-reference (added to the `init_db` attachments ensure-block + born-live in `store_attachment`) so a later delete removes both copies; `attachments.mirror_attachment_to_gitea(uid)` / `remove_gitea_asset(row)` branch on `target_type` to call `GiteaClient.create_issue_asset`/`create_comment_asset` / `delete_issue_asset`/`delete_comment_asset` (all best-effort - the local copy and the user action survive any Gitea failure). Upload reuses the canonical **orphan-then-link** path: `dp-upload` stores to `/uploads/upload`, the form carries `attachment_uids`, and the handler `link_attachments(uids, target_type, target_uid)` then mirrors.

**Role/state enforcement:** `require_user` blocks guests; add validates each uid is the caller's own unlinked orphan (admins any); delete is owner-or-admin on `attachments.user_uid`; every mutation re-checks the live Gitea `state == "open"` (closed -> 409) and audits denied branches. Attach-at-creation: `IssueForm.attachment_uids` rides into the `issue_create` job, which links + mirrors once the issue number exists; `IssueCommentForm.attachment_uids` links + mirrors inline after the comment is created (synchronous comment path).

Routes: `GET/POST /issues/{number}/attachments`, `DELETE /issues/{number}/attachments/{uid}`, and the `/issues/{number}/comments/{cid}/attachments[...]` comment variants. Frontend `app.issueAttachments` (`static/js/IssueAttachments.js`) submits the add form (`form[data-issue-attach]` -> POST `attachment_uids` -> reload) and handles per-attachment delete (`[data-attachment-delete]` -> confirm -> DELETE). The shared render partial is `_issue_attachments.html` (gallery + per-item delete gated by `can_modify`).

Note: for Gitea to accept every file type the instance `app.ini` `[attachment] ALLOWED_TYPES` must be `*/*` (infra, out of app scope); the DevPlace allow-list is the `allowed_file_types` setting, and the local copy is kept even when Gitea rejects a type. Audit events `issue.attachment.add`/`issue.attachment.delete` (category `content`). Devii tools `list_issue_attachments`, `add_issue_attachment`, `delete_issue_attachment` (+ `add_comment_attachment`/`delete_comment_attachment`); the two deletes are in `CONFIRM_REQUIRED`.

### Schemas, forms, and tools

Schemas: `IssueItemOut`/`IssueCommentOut`/`IssueDetailOut`/`IssueAttachmentsOut`/`IssuesOut`/`IssueJobOut` (`schemas.py`). Forms: `IssueForm`/`IssueCommentForm`/`IssueAttachmentForm`/`IssueStatusForm` (`models.py`). Devii tools: `list_issues`, `create_issue`, `view_issue`, `comment_issue`, `set_issue_status` (admin), plus the attachment tools above. Docs: the `issues` API group in `docs_api.py`. Footer link in `base.html` under `.site-footer`.
