Вики: лента правок, версии и дифф в HTTP API (#140)

Маршруты GET /wiki/changes, /wiki/pages/{code}/versions, /wiki/pages/{code}/diff;
у страницы — modified_at, modified_by, versions; у состояния — history_at.
Дифф режется по 20 000 символов, фильтр автора без учёта регистра в Python.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-28 12:16:04 +04:00
parent a11d66f94e
commit 3a936f5eaa
6 changed files with 345 additions and 8 deletions

View File

@ -65,12 +65,12 @@
| `commands` | типы `wiki_sync` и `wiki_fetch` (оба — чтение Eva, без хвоста и копии базы), `CommandHooks` модуля: проверки, паузы, `deliver_params` дописывает `known_versions`, `before_result`/`on_result`/`after_result`/`abort_result`, отбрасывание отчёта по сменённому корню | `core.queue`, `stage`, `sync`, `attachments` | | `commands` | типы `wiki_sync` и `wiki_fetch` (оба — чтение Eva, без хвоста и копии базы), `CommandHooks` модуля: проверки, паузы, `deliver_params` дописывает `known_versions`, `before_result`/`on_result`/`after_result`/`abort_result`, отбрасывание отчёта по сменённому корню | `core.queue`, `stage`, `sync`, `attachments` |
| `convert` | HTML Eva → Markdown (`markdownify`): замена ссылок по `ref` на `attachment:<id>` и `page:<код>`; режим без ссылок на вложения для версий (`file_links=False`); HTML считается недоверенным | stdlib, `markdownify` | | `convert` | HTML Eva → Markdown (`markdownify`): замена ссылок по `ref` на `attachment:<id>` и `page:<код>`; режим без ссылок на вложения для версий (`file_links=False`); HTML считается недоверенным | stdlib, `markdownify` |
| `stage` | приём пачек страниц от агента: проверка формы, перевод вне транзакции, стадия `wiki_sync_stage`; и пачек версий (`accept_versions`, `POST /agent/wiki/versions`), стадия `wiki_version_stage` | `core.db`, `convert` | | `stage` | приём пачек страниц от агента: проверка формы, перевод вне транзакции, стадия `wiki_sync_stage`; и пачек версий (`accept_versions`, `POST /agent/wiki/versions`), стадия `wiki_version_stage` | `core.db`, `convert` |
| `history` | история правок: время версий в UTC (`utc`), `known_versions` для выдачи, подсчёт «+/−» (`line_delta`), `history_at` | stdlib | | `history` | история правок: время версий в UTC (`utc`), `known_versions` для выдачи, подсчёт «+/−» (`line_delta`), `history_at`; чтение истории: лента `feed`, версии страницы `page_versions`, дифф по запросу (`diff`, `difflib`, части по 20 000 — `CHUNK`), последняя правка `last_change`; HTTP не знает | stdlib |
| `sync` | сборка копии из стадии в `wiki/.<метка>.new/` (`prepare`), запись строк и подмена каталога (`commit`), FTS; запись версий, «+/−» (подсчёт в `prepare`, вне транзакции), `history_at` | `core.db`, `paths`, `search`, `convert`, `history` | | `sync` | сборка копии из стадии в `wiki/.<метка>.new/` (`prepare`), запись строк и подмена каталога (`commit`), FTS; запись версий, «+/−» (подсчёт в `prepare`, вне транзакции), `history_at` | `core.db`, `paths`, `search`, `convert`, `history` |
| `search` | поиск по FTS5 (запасной LIKE по `index.md`, если FTS5 в SQLite нет), фрагменты | `paths` | | `search` | поиск по FTS5 (запасной LIKE по `index.md`, если FTS5 в SQLite нет), фрагменты | `paths` |
| `attachments` | вложения по запросу: состояние `none`/`fetching`/`ready`/`failed`, постановка `wiki_fetch`, хуки результата; адрес берётся только из `wiki_attachment` | `core.queue`, `core.files` | | `attachments` | вложения по запросу: состояние `none`/`fetching`/`ready`/`failed`, постановка `wiki_fetch`, хуки результата; адрес берётся только из `wiki_attachment` | `core.queue`, `core.files` |
| `tick` | фоновая синхронизация проектов с корнем раз в `wiki_auto_sync_hours` | `core.db`, `core.settings`, `commands`, `sync`, `search` | | `tick` | фоновая синхронизация проектов с корнем раз в `wiki_auto_sync_hours` | `core.db`, `core.settings`, `commands`, `sync`, `search` |
| `api` | маршруты `/wiki/tree`, `/wiki/pages/{code}`, `/wiki/search`, `/wiki/sync`, `/wiki/status`, `/wiki/attachments/{id}[/fetch\|/content]` (клиент) и `POST /agent/wiki/pages`, `POST /agent/wiki/versions` (агент `wiki`) | `core.http`, `core.queue`, `stage`, `attachments`, `search` | | `api` | маршруты `/wiki/tree`, `/wiki/pages/{code}`, `/wiki/pages/{code}/versions`, `/wiki/pages/{code}/diff`, `/wiki/changes` (лента правок), `/wiki/search`, `/wiki/sync`, `/wiki/status`, `/wiki/attachments/{id}[/fetch\|/content]` (клиент) и `POST /agent/wiki/pages`, `POST /agent/wiki/versions` (агент `wiki`) | `core.http`, `core.queue`, `stage`, `attachments`, `search` |
| `mcp` | инструменты сервера `sdlc-wiki`: `projects`, `project_create`, `root_set`, `tree`, `page`, `search`, `attachment`, `sync`, `status`; Markdown и имена вложений недоверенные | `core.mcp`, `core.client` | | `mcp` | инструменты сервера `sdlc-wiki`: `projects`, `project_create`, `root_set`, `tree`, `page`, `search`, `attachment`, `sync`, `status`; Markdown и имена вложений недоверенные | `core.mcp`, `core.client` |
### Контракт модуля ### Контракт модуля
@ -131,7 +131,7 @@ golden-эталоны `__fixtures__/`), `issues/` (список, карточк
Цепочки зависимостей (Python): Цепочки зависимостей (Python):
- ядро: `api → queue → settings/projects/files/registry → db → backup → paths`; `api → relay → settings`; `api → release → paths`; `files → paths`; `cli → api/mcp/registry`; `mcp → client → HTTP API`; `registry → (лениво) modules.*`; - ядро: `api → queue → settings/projects/files/registry → db → backup → paths`; `api → relay → settings`; `api → release → paths`; `files → paths`; `cli → api/mcp/registry`; `mcp → client → HTTP API`; `registry → (лениво) modules.*`;
- модуль wiki: `commands → stage/sync/attachments → convert/search/paths → core.*`; `projects → commands/paths`; `api → stage/attachments/search → core.http/core.queue`; `tick → commands/sync`; - модуль wiki: `commands → stage/sync/attachments → convert/search/paths → core.*`; `projects → commands/paths`; `api → stage/attachments/search → core.http/core.queue`; `tick → commands/sync`; `api → history`; `sync → history`;
- модуль jira: `hooks → commands/mirror/history/refresh/attachments/fetch → core.*`; `mirror → history → projects`; `mirror → attachments → core.files`; `api → mirror/history/fetch/refresh → core.http/core.queue`; `api → agile → history`; `importer → mirror`; - модуль jira: `hooks → commands/mirror/history/refresh/attachments/fetch → core.*`; `mirror → history → projects`; `mirror → attachments → core.files`; `api → mirror/history/fetch/refresh → core.http/core.queue`; `api → agile → history`; `importer → mirror`;
- веб: `shell → modules/index → modules/jira, modules/wiki → ui/api/projects` (модуль не импортирует оболочку, кроме типов контракта). - веб: `shell → modules/index → modules/jira, modules/wiki → ui/api/projects` (модуль не импортирует оболочку, кроме типов контракта).
@ -146,6 +146,7 @@ golden-эталоны `__fixtures__/`), `issues/` (список, карточк
отбрасываются по `(history_id, seq)`) → отчёт `refresh` с `result.history` → `sync_run.history_at` у полного прогона отбрасываются по `(history_id, seq)`) → отчёт `refresh` с `result.history` → `sync_run.history_at` у полного прогона
(новая отметка) → `GET /changes`, `GET /issues/{key}.changes`, MCP `changes`. (новая отметка) → `GET /changes`, `GET /issues/{key}.changes`, MCP `changes`.
- Синхронизация вики: `wiki_sync {label, root}` ставят MCP `sync`, кнопка «Обновить» (`POST /wiki/sync`), привязка/смена корня (`on_project_created`/`on_project_updated`) и `tick` (раз в `wiki_auto_sync_hours`) → `claim` агента `wiki` → eva-agent обходит дерево и шлёт пачки `POST /agent/wiki/pages` (хаб принимает ≤50 страниц в пачке; срез по 4 МБ делает агент) → `stage.accept` переводит HTML в Markdown вне транзакции и кладёт в `wiki_sync_stage` → после страниц eva-agent v2 шлёт версии `POST /agent/wiki/versions` (≤50 в пачке; хаб при выдаче дописывает `known_versions`), отчёт `done {root_title, codes, versions}`; без `versions` (агент v1) история не меняется → `before_result` (`sync.prepare`: сборка `wiki/.<метка>.new/`, выделение id вложений, под per-label `Lock`) → транзакция отчёта (`on_result` = `sync.commit`: строки `wiki_page`/`wiki_attachment`/FTS, атомарная подмена каталога) → `after_result` снимает подготовку; при сбое — `abort_result`. Отчёт по команде с устаревшим корнем отбрасывается (`result.discarded`), копия прежняя. - Синхронизация вики: `wiki_sync {label, root}` ставят MCP `sync`, кнопка «Обновить» (`POST /wiki/sync`), привязка/смена корня (`on_project_created`/`on_project_updated`) и `tick` (раз в `wiki_auto_sync_hours`) → `claim` агента `wiki` → eva-agent обходит дерево и шлёт пачки `POST /agent/wiki/pages` (хаб принимает ≤50 страниц в пачке; срез по 4 МБ делает агент) → `stage.accept` переводит HTML в Markdown вне транзакции и кладёт в `wiki_sync_stage` → после страниц eva-agent v2 шлёт версии `POST /agent/wiki/versions` (≤50 в пачке; хаб при выдаче дописывает `known_versions`), отчёт `done {root_title, codes, versions}`; без `versions` (агент v1) история не меняется → `before_result` (`sync.prepare`: сборка `wiki/.<метка>.new/`, выделение id вложений, под per-label `Lock`) → транзакция отчёта (`on_result` = `sync.commit`: строки `wiki_page`/`wiki_attachment`/FTS, атомарная подмена каталога) → `after_result` снимает подготовку; при сбое — `abort_result`. Отчёт по команде с устаревшим корнем отбрасывается (`result.discarded`), копия прежняя.
- История вики: `GET /wiki/changes` (лента правок за окно `since`/`until`, фильтр `author`), `GET /wiki/pages/{code}/versions`, `GET /wiki/pages/{code}/diff` (дифф по частям, `offset`), MCP `changes`/`diff`, веб «Лента» (только база хаба) → `history.feed`/`page_versions`/`diff` по `wiki_version` и `wiki_page` (только текущая копия); команд не ставит и Eva не вызывает.
- Вложение вики: `POST /wiki/attachments/{id}/fetch` (веб, MCP `attachment`) → `attachments.request_fetch` (одна транзакция: `state = fetching` + пакет `wiki_fetch {attachment_id, url}`) → `claim` → eva-agent качает файл только с хоста Eva → `POST /agent/files` → результат `{file, size}` → хаб проверяет файл в `files/` и ставит `ready` (иначе `failed`) → `GET /wiki/attachments/{id}/content`; в MCP копия `downloads/wiki-<id>/<имя>`. - Вложение вики: `POST /wiki/attachments/{id}/fetch` (веб, MCP `attachment`) → `attachments.request_fetch` (одна транзакция: `state = fetching` + пакет `wiki_fetch {attachment_id, url}`) → `claim` → eva-agent качает файл только с хоста Eva → `POST /agent/files` → результат `{file, size}` → хаб проверяет файл в `files/` и ставит `ready` (иначе `failed`) → `GET /wiki/attachments/{id}/content`; в MCP копия `downloads/wiki-<id>/<имя>`.
- Агент за VPN: агент → файлы в общей папке (`relay_dir`) → `relay` → HTTP хаба → ответ файлом. - Агент за VPN: агент → файлы в общей папке (`relay_dir`) → `relay` → HTTP хаба → ответ файлом.
- Самообновление агента: `sdlc-hub agent publish [--module M] PATH` → `release/<модуль>/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти (вместе с признаком `agent_reported` — был ли `claim` с запуска хаба) и отдаёт в `GET /agent`. - Самообновление агента: `sdlc-hub agent publish [--module M] PATH` → `release/<модуль>/` → `claim.agent_release` → агент скачивает `GET /agent/release`; состояние обновления агент шлёт в `claim`, хаб держит в памяти (вместе с признаком `agent_reported` — был ли `claim` с запуска хаба) и отдаёт в `GET /agent`.
@ -175,7 +176,7 @@ golden-эталоны `__fixtures__/`), `issues/` (список, карточк
- Правки из UI в Jira (спринт, срок, связи, исполнитель) — change `viewer-edit`; страницы очереди, агента и настроек — позже. - Правки из UI в Jira (спринт, срок, связи, исполнитель) — change `viewer-edit`; страницы очереди, агента и настроек — позже.
- Автоподстройка темпа по фактам 429 не реализована: хаб только показывает их. - Автоподстройка темпа по фактам 429 не реализована: хаб только показывает их.
- Запись в вики Eva (правка страниц из хаба) не делается: модуль только читает. - Запись в вики Eva (правка страниц из хаба) не делается: модуль только читает.
- История страниц вики и частичная перекачка: сейчас каждая синхронизация перекачивает всё дерево, копия без истории. - Частичная перекачка: каждая синхронизация перекачивает всё дерево страниц (версии — только новые, change `wiki-history`).
- Переименование проекта-метки и перенос корня вики между проектами не предусмотрены (меняется только корень — `PATCH /projects`). - Переименование проекта-метки и перенос корня вики между проектами не предусмотрены (меняется только корень — `PATCH /projects`).
- Ручной просмотр вёрстки веб-страницы «Вики» на живых данных (таблицы, вложенные списки) — за владельцем после выкатки. - Ручной просмотр вёрстки веб-страницы «Вики» на живых данных (таблицы, вложенные списки) — за владельцем после выкатки.
- Хаб переименован в sdlc-hub (24.10.2026, change `sdlc-rename`): пакет `sdlc_hub`, команда `sdlc-hub`, каталог данных `~/.local/share/sdlc-hub/` (`SDLC_HUB_HOME`), MCP-серверы `sdlc-jira` и `sdlc-wiki`, launchd `ru.aepif.sdlc-hub`. - Хаб переименован в sdlc-hub (24.10.2026, change `sdlc-rename`): пакет `sdlc_hub`, команда `sdlc-hub`, каталог данных `~/.local/share/sdlc-hub/` (`SDLC_HUB_HOME`), MCP-серверы `sdlc-jira` и `sdlc-wiki`, launchd `ru.aepif.sdlc-hub`.

View File

@ -237,6 +237,11 @@ uv run sdlc-hub agent publish --module wiki ../eva-agent/src/agent.py
| `POST /refresh` | клиент | `{keys}`, `{jql}` или `{full: true}` (общая JQL по меткам всех проектов; проектов нет — 422 «нет проектов») | | `POST /refresh` | клиент | `{keys}`, `{jql}` или `{full: true}` (общая JQL по меткам всех проектов; проектов нет — 422 «нет проектов») |
| `POST /files` | клиент | загрузить файл вложения `{content: base64}` → `{file: sha256, size}`; не base64 — 400, пустой — 422 `empty_file`, больше 10 МБ (10 485 760 байт) — 413 `too_large`; повтор того же содержимого — тот же `file` | | `POST /files` | клиент | загрузить файл вложения `{content: base64}` → `{file: sha256, size}`; не base64 — 400, пустой — 422 `empty_file`, больше 10 МБ (10 485 760 байт) — 413 `too_large`; повтор того же содержимого — тот же `file` |
| `GET /agent`, `GET /sync-runs` | клиент | состояние агента (с `release`, `update_state`, `launcher_version`, `agent_reported`, `update_hint`), журнал загрузок (`history_at` — когда принят журнал изменений полного прогона) | | `GET /agent`, `GET /sync-runs` | клиент | состояние агента (с `release`, `update_state`, `launcher_version`, `agent_reported`, `update_hint`), журнал загрузок (`history_at` — когда принят журнал изменений полного прогона) |
| `GET /wiki/changes` | клиент | лента правок вики проекта: `project`, `since`, `until` (ISO со смещением или `Z`, иначе 422; по умолчанию — последние 24 ч), `author` (подстрока имени или логина, без учёта регистра), `limit` (1–2000, по умолчанию 500) → `{since, until, synced_at, history_at, truncated, changes}` |
| `GET /wiki/pages/{code}/versions` | клиент | версии страницы, новые первыми: `{versions: [{version, at, author, added, removed}]}`; страницы нет в копии — 404 |
| `GET /wiki/pages/{code}/diff` | клиент | дифф версии (`version`, по умолчанию последняя) относительно предыдущей, по частям `offset` (части по 20 000 символов, `next_offset`); нет версий — 404 `no_versions`, нет такой версии — 404 `no_version` |
| `GET /wiki/status` | клиент | состояние синхронизации вики проекта, в том числе `history_at` (когда загружена история; `null` — ещё не загружалась) |
| `GET /wiki/pages/{code}` | клиент | страница копии, в том числе `modified_at`, `modified_by` (`{login, name}` или `null`) и `versions` — последняя правка и число версий |
| `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`; ответ — все настройки после записи, как у GET); `base_jql` вычисляется из проектов (без проектов — `null`), запись `base_jql` и `board_id` — 422 | | `GET /settings`, `PUT /settings` | клиент | настройки (PUT частично, поднимает `settings_version`; ответ — все настройки после записи, как у GET); `base_jql` вычисляется из проектов (без проектов — `null`), запись `base_jql` и `board_id` — 422 |
```bash ```bash

View File

@ -1,10 +1,11 @@
"""HTTP модуля wiki: маршруты клиента (состояние, дерево, страница, поиск, синхронизация, вложения) и агента.""" """HTTP модуля wiki: маршруты клиента (состояние, дерево, страница, поиск, синхронизация, вложения) и агента."""
import json import json
from datetime import timedelta
from urllib.parse import unquote from urllib.parse import unquote
from ...core import db, files, queue from ...core import db, files, queue
from ...core.http import ApiError, Raw, int_group, not_found, project_param from ...core.http import ApiError, Raw, int_group, is_digits, not_found, project_param
from . import attachments, commands, paths, search, stage from . import attachments, commands, history, paths, search, stage
MIME = "application/octet-stream" # типа в базе вики нет; скачивание — всегда вложением, без угадывания MIME = "application/octet-stream" # типа в базе вики нет; скачивание — всегда вложением, без угадывания
@ -42,7 +43,8 @@ def _batch(conn, label):
def h_status(hub, conn, m, q, body): def h_status(hub, conn, m, q, body):
label, r = _project(conn, q) label, r = _project(conn, q)
return 200, {"root": r["root_code"], "root_title": r["root_title"], "synced_at": r["synced_at"], return 200, {"root": r["root_code"], "root_title": r["root_title"], "synced_at": r["synced_at"],
"pages": r["pages_count"], "error": r["sync_error"], "batch": _batch(conn, label)} "pages": r["pages_count"], "error": r["sync_error"], "batch": _batch(conn, label),
"history_at": history.history_at(conn, label)}
def _walk_order(conn, label) -> list[str]: def _walk_order(conn, label) -> list[str]:
@ -81,7 +83,63 @@ def h_page(hub, conn, m, q, body):
"SELECT id, name, state, size, error, url FROM wiki_attachment WHERE label = ? AND page_code = ? ORDER BY id", "SELECT id, name, state, size, error, url FROM wiki_attachment WHERE label = ? AND page_code = ? ORDER BY id",
(label, code))] (label, code))]
return 200, {"code": code, "title": r["title"], "path": r["path"], "url": r["url"], "synced_at": r["synced_at"], return 200, {"code": code, "title": r["title"], "path": r["path"], "url": r["url"], "synced_at": r["synced_at"],
"markdown": text, "attachments": atts} "markdown": text, "attachments": atts, **history.last_change(conn, label, code)}
def _time_param(q, name, default):
"""Время запроса: ISO-8601 со смещением или Z → UTC; без смещения — 422 (пояс не угадываем)."""
raw = q.get(name)
if raw in (None, ""):
return default
value = history.utc(raw)
if value is None:
raise ApiError(422, "bad_request", f"{name}: ожидается время ISO-8601 со смещением или Z")
return value
def h_changes(hub, conn, m, q, body):
label, r = _project(conn, q)
until = _time_param(q, "until", db.now())
since = _time_param(q, "since", (db.parse(until) - timedelta(hours=24)).isoformat())
if since > until:
raise ApiError(422, "bad_request", "since: позже until")
try:
limit = int(q.get("limit", 500))
except (TypeError, ValueError):
limit = 0
if not 1 <= limit <= 2000:
raise ApiError(422, "bad_request", "limit: целое от 1 до 2000")
out = history.feed(conn, label, since, until, q.get("author") or None, limit)
return 200, {"since": since, "until": until, "synced_at": r["synced_at"], "history_at": history.history_at(conn, label),
"truncated": out["truncated"], "changes": out["changes"]}
def _wiki_page(conn, label, code):
return not_found(conn.execute("SELECT * FROM wiki_page WHERE label = ? AND code = ?", (label, code)).fetchone(),
f"страница {code}")
def h_versions(hub, conn, m, q, body):
label, _ = _project(conn, q)
code = unquote(m.group(1))
_wiki_page(conn, label, code)
return 200, {"versions": history.page_versions(conn, label, code)}
def h_diff(hub, conn, m, q, body):
label, _ = _project(conn, q)
code = unquote(m.group(1))
page_row = _wiki_page(conn, label, code)
raw = q.get("offset", "0")
if not is_digits(raw):
raise ApiError(422, "bad_request", "offset: целое ≥ 0")
try:
out = history.diff(conn, label, code, q.get("version") or None, int(raw))
except history.NoVersions:
raise ApiError(404, "no_versions", f"у страницы {code} нет версий") from None
except history.NoVersion:
raise ApiError(404, "no_version", f"версии {q.get('version')} у страницы {code} нет") from None
return 200, {"code": code, "title": page_row["title"], "path": page_row["path"], **out}
def h_search(hub, conn, m, q, body): def h_search(hub, conn, m, q, body):
@ -151,6 +209,9 @@ ROUTES = (
("GET", r"^/wiki/status$", h_status, "client"), ("GET", r"^/wiki/status$", h_status, "client"),
("GET", r"^/wiki/tree$", h_tree, "client"), ("GET", r"^/wiki/tree$", h_tree, "client"),
("GET", r"^/wiki/pages/([^/]+)$", h_page, "client"), ("GET", r"^/wiki/pages/([^/]+)$", h_page, "client"),
("GET", r"^/wiki/changes$", h_changes, "client"),
("GET", r"^/wiki/pages/([^/]+)/versions$", h_versions, "client"),
("GET", r"^/wiki/pages/([^/]+)/diff$", h_diff, "client"),
("GET", r"^/wiki/search$", h_search, "client"), ("GET", r"^/wiki/search$", h_search, "client"),
("POST", r"^/wiki/sync$", h_sync, "client"), ("POST", r"^/wiki/sync$", h_sync, "client"),
("GET", r"^/wiki/attachments/(\d+)$", h_attachment_get, "client"), ("GET", r"^/wiki/attachments/(\d+)$", h_attachment_get, "client"),

View File

@ -37,3 +37,82 @@ def history_at(conn, label: str) -> str | None:
"""Время последней загрузки истории проекта или None, если её ещё не было.""" """Время последней загрузки истории проекта или None, если её ещё не было."""
row = conn.execute("SELECT history_at FROM wiki_history WHERE label = ?", (label,)).fetchone() row = conn.execute("SELECT history_at FROM wiki_history WHERE label = ?", (label,)).fetchone()
return row["history_at"] if row else None return row["history_at"] if row else None
# --- чтение истории (spec wiki «Лента правок», «Версии и дифф», wiki-history #140) ---------------------------------
CHUNK = 20000 # символов в части диффа (как у `page`)
class NoVersions(Exception):
"""У страницы нет ни одной версии в копии истории."""
class NoVersion(Exception):
"""Версии с таким кодом у страницы нет."""
def _author(row) -> dict:
return {"login": row["author_login"], "name": row["author_name"]}
def feed(conn, label: str, since: str, until: str, author: str | None, limit: int) -> dict:
"""Лента правок страниц текущей копии в [since, until]: по `at` ↓, затем `version` ↓. Фильтр автора — подстрока
без учёта регистра в имени или логине (casefold в Python: SQLite LOWER/LIKE знают только ASCII); срез — после фильтра.
Возвращает {truncated, changes}: truncated — правок больше limit."""
rows = conn.execute(
"SELECT v.code, v.created_at, v.author_login, v.author_name, v.added, v.removed, v.page_code, p.title, p.path "
"FROM wiki_version v JOIN wiki_page p ON p.label = v.label AND p.code = v.page_code "
"WHERE v.label = ? AND v.created_at >= ? AND v.created_at <= ? "
"ORDER BY v.created_at DESC, v.code DESC", (label, since, until)).fetchall()
needle = author.casefold() if author else None
out = []
for r in rows:
if needle is not None and not any(needle in (x or "").casefold() for x in (r["author_name"], r["author_login"])):
continue
out.append({"at": r["created_at"], "page_code": r["page_code"], "title": r["title"], "path": r["path"],
"version": r["code"], "author": _author(r), "added": r["added"], "removed": r["removed"]})
return {"truncated": len(out) > limit, "changes": out[:limit]}
def page_versions(conn, label: str, code: str) -> list[dict]:
"""Версии страницы, новые первыми."""
rows = conn.execute("SELECT code, created_at, author_login, author_name, added, removed FROM wiki_version "
"WHERE label = ? AND page_code = ? ORDER BY created_at DESC, code DESC", (label, code)).fetchall()
return [{"version": r["code"], "at": r["created_at"], "author": _author(r), "added": r["added"], "removed": r["removed"]}
for r in rows]
def diff(conn, label: str, code: str, version: str | None, offset: int) -> dict:
"""Дифф версии относительно предыдущей (первой — относительно пустого текста); version None — последняя.
Текст диффа режется по CHUNK символов, с offset; next_offset — где продолжить или None."""
rows = conn.execute("SELECT code, created_at, markdown, added, removed FROM wiki_version "
"WHERE label = ? AND page_code = ? ORDER BY created_at, code", (label, code)).fetchall()
if not rows:
raise NoVersions()
if version is None:
idx = len(rows) - 1
else:
idx = next((i for i, r in enumerate(rows) if r["code"] == version), None)
if idx is None:
raise NoVersion()
cur = rows[idx]
prev = rows[idx - 1] if idx > 0 else None
lines = difflib.unified_diff((prev["markdown"] if prev else "").splitlines(), cur["markdown"].splitlines(),
fromfile=prev["code"] if prev else "", tofile=cur["code"], n=3, lineterm="")
text = "".join(f"{x}\n" for x in lines)
end = offset + CHUNK
return {"version": cur["code"], "index": idx + 1, "total": len(rows), "prev_version": prev["code"] if prev else None,
"added": cur["added"], "removed": cur["removed"], "diff": text[offset:end],
"next_offset": end if end < len(text) else None}
def last_change(conn, label: str, code: str) -> dict:
"""Последняя правка страницы: время, автор (None — если автора нет) и число версий."""
n = conn.execute("SELECT COUNT(*) AS n FROM wiki_version WHERE label = ? AND page_code = ?", (label, code)).fetchone()["n"]
if n == 0:
return {"modified_at": None, "modified_by": None, "versions": 0}
r = conn.execute("SELECT created_at, author_login, author_name FROM wiki_version WHERE label = ? AND page_code = ? "
"ORDER BY created_at DESC, code DESC LIMIT 1", (label, code)).fetchone()
by = _author(r) if r["author_login"] is not None or r["author_name"] is not None else None
return {"modified_at": r["created_at"], "modified_by": by, "versions": n}

View File

@ -251,12 +251,30 @@
"client", "client",
"h_attachment_fetch" "h_attachment_fetch"
], ],
[
"GET",
"^/wiki/changes$",
"client",
"h_changes"
],
[ [
"GET", "GET",
"^/wiki/pages/([^/]+)$", "^/wiki/pages/([^/]+)$",
"client", "client",
"h_page" "h_page"
], ],
[
"GET",
"^/wiki/pages/([^/]+)/diff$",
"client",
"h_diff"
],
[
"GET",
"^/wiki/pages/([^/]+)/versions$",
"client",
"h_versions"
],
[ [
"GET", "GET",
"^/wiki/search$", "^/wiki/search$",

View File

@ -0,0 +1,173 @@
"""История правок вики, чтение HTTP API (wiki-history, #140): лента `GET /wiki/changes`, версии, дифф, поля страницы и
состояния. Агента имитирует `run_history` из test_wiki_history; фикстуры — через api из test_wiki_sync."""
import difflib
from datetime import timedelta
import pytest
from sdlc_hub.core import db
from sdlc_hub.core.client import HubError
from test_wiki_history import MOLCH, SOSNOV, run_history, version # noqa: F401
from test_wiki_sync import CODES, TREE, api, first_sync, page # noqa: F401 — api — фикстура
DAY_SINCE, DAY_UNTIL = "2026-10-27T00:00:00+03:00", "2026-10-28T00:00:00+03:00"
def window(**extra):
return {"project": "AWPCA", "since": DAY_SINCE, "until": DAY_UNTIL} | extra
def hub_error(fn, *a, **kw):
with pytest.raises(HubError) as e:
fn(*a, **kw)
return e.value
@pytest.fixture
def history(api):
"""Версии: DOC-3 (Соснов) 17:59 и 18:02 +03:00, DOC-2 (Молчанов) 26.10 12:00 +03:00; DOC-1 без версий."""
_, done = run_history(api, TREE, [
version("DOC-3", "DOCH-30", "2026-10-27T17:59:00+03:00", "<p>статус</p>"),
version("DOC-3", "DOCH-31", "2026-10-27T18:02:00+03:00", "<p>статус</p><p>новый</p>"),
version("DOC-2", "DOCH-20", "2026-10-26T12:00:00+03:00", "<p>роли</p>", author=MOLCH),
], CODES)
assert done["status"] == "done"
return api
@pytest.fixture
def four(api):
"""Четыре версии DOC-3 Соснова за 27.10 (последняя — 23:48:02 +03:00)."""
stamps = ["2026-10-27T17:59:00+03:00", "2026-10-27T18:02:00+03:00", "2026-10-27T20:30:00+03:00",
"2026-10-27T23:48:02+03:00"]
vs = [version("DOC-3", f"DOCH-4{i}", ts, f"<p>строка {i}</p>") for i, ts in enumerate(stamps, 1)]
_, done = run_history(api, TREE, vs, CODES)
assert done["status"] == "done"
return api
# --- лента -----------------------------------------------------------------------------------------------------------
def test_changes_today(history): # сценарий «Что сделали за сегодня»
d = history.client.get("/wiki/changes", **window())
assert [c["at"] for c in d["changes"]] == ["2026-10-27T15:02:00+00:00", "2026-10-27T14:59:00+00:00"]
c = d["changes"][0]
assert c["page_code"] == "DOC-3" and c["title"] == "Статусы" and c["path"] == "АРМ КА/01_Статусы"
assert c["author"] == SOSNOV and c["version"] == "DOCH-31"
assert isinstance(c["added"], int) and isinstance(c["removed"], int)
assert d["since"] == "2026-10-26T21:00:00+00:00" and d["until"] == "2026-10-27T21:00:00+00:00"
assert d["truncated"] is False and d["synced_at"] and d["history_at"]
@pytest.mark.parametrize("author", ["соснов", "СОСНОВ", "Sosnov-DVI"])
def test_changes_author_cyrillic_case(history, author): # Review Focus 1: только две правки DOC-3
d = history.client.get("/wiki/changes", **window(author=author))
assert len(d["changes"]) == 2 and {c["page_code"] for c in d["changes"]} == {"DOC-3"}
def test_changes_default_window(history): # без since/until: окно ровно 24 часа
d = history.client.get("/wiki/changes", project="AWPCA")
assert db.parse(d["until"]) - db.parse(d["since"]) == timedelta(hours=24)
def test_changes_limit_truncated(history):
d = history.client.get("/wiki/changes", **window(limit="1"))
assert len(d["changes"]) == 1 and d["truncated"] is True
@pytest.mark.parametrize("q,param", [
({"since": "2026-10-27T00:00:00"}, "since"), ({"until": "вчера"}, "until"),
({"limit": "0"}, "limit"), ({"limit": "2001"}, "limit"), ({"limit": "x"}, "limit"),
({"since": "2026-10-28T00:00:00Z", "until": "2026-10-27T00:00:00Z"}, "since"),
])
def test_changes_bad_params(history, q, param):
e = hub_error(history.client.get, "/wiki/changes", **({"project": "AWPCA"} | q))
assert e.status == 422 and e.error == "bad_request" and e.detail.startswith(param)
def test_changes_history_not_loaded(api): # синхронизация без версий: история не загружена
first_sync(api)
d = api.client.get("/wiki/changes", **window())
assert d["history_at"] is None and d["changes"] == [] and d["synced_at"]
assert api.client.get("/wiki/status", project="AWPCA")["history_at"] is None
def test_changes_no_root_and_unknown(api):
api.client.post("/projects", {"label": "BEZ", "name": "Без корня"})
assert hub_error(api.client.get, "/wiki/changes", project="BEZ").status == 409
assert hub_error(api.client.get, "/wiki/changes", project="NOPE").status == 404
# --- версии страницы -------------------------------------------------------------------------------------------------
def test_versions_list(history): # новые первыми
d = history.client.get("/wiki/pages/DOC-3/versions", project="AWPCA")
assert len(d["versions"]) == 2 and d["versions"][0]["at"] == "2026-10-27T15:02:00+00:00"
assert d["versions"][0]["version"] == "DOCH-31" and d["versions"][0]["author"] == SOSNOV
assert hub_error(history.client.get, "/wiki/pages/DOC-99/versions", project="AWPCA").status == 404
# --- дифф ------------------------------------------------------------------------------------------------------------
def test_diff_last(four): # сценарий «Дифф последней правки»
d = four.client.get("/wiki/pages/DOC-3/diff", project="AWPCA")
v3, v4 = "DOCH-43", "DOCH-44"
assert d["index"] == 4 and d["total"] == 4 and d["prev_version"] == v3 and d["version"] == v4
assert d["diff"].startswith(f"--- {v3}\n+++ {v4}\n") and d["next_offset"] is None
assert d["code"] == "DOC-3" and d["title"] == "Статусы" and d["path"] == "АРМ КА/01_Статусы"
def test_diff_first_version(history):
d = history.client.get("/wiki/pages/DOC-3/diff", project="AWPCA", version="DOCH-30")
assert d["index"] == 1 and d["prev_version"] is None and d["removed"] == 0
assert d["diff"].startswith("--- \n+++ ")
def test_diff_chunks(api): # сценарий «Длинный дифф»: склейка частей == полный дифф
html = "".join(f"<p>строка {i:04d} текста, которой много</p>" for i in range(1300))
_, done = run_history(api, TREE, [version("DOC-3", "DOCH-90", "2026-10-27T10:00:00Z", html)], CODES)
assert done["status"] == "done"
md = api.rows("SELECT markdown FROM wiki_version WHERE code = 'DOCH-90'")[0]["markdown"]
full = "".join(f"{x}\n" for x in difflib.unified_diff([], md.splitlines(), fromfile="", tofile="DOCH-90",
n=3, lineterm=""))
assert 40000 < len(full) <= 60000
def part(offset):
return api.client.get("/wiki/pages/DOC-3/diff", project="AWPCA", offset=str(offset))
p0 = part(0)
assert len(p0["diff"]) == 20000 and p0["next_offset"] == 20000 and p0["diff"] == full[:20000]
p40 = part(40000)
assert p40["diff"] == full[40000:] and p40["next_offset"] is None
assert p0["diff"] + part(20000)["diff"] + p40["diff"] == full
for bad in ("-1", "x"):
e = hub_error(api.client.get, "/wiki/pages/DOC-3/diff", project="AWPCA", offset=bad)
assert e.status == 422 and e.error == "bad_request" and e.detail == "offset: целое ≥ 0"
def test_diff_errors(history):
e = hub_error(history.client.get, "/wiki/pages/DOC-99/diff", project="AWPCA")
assert e.status == 404 and e.error == "not_found"
e = hub_error(history.client.get, "/wiki/pages/DOC-3/diff", project="AWPCA", version="DOCH-404")
assert e.status == 404 and e.error == "no_version"
e = hub_error(history.client.get, "/wiki/pages/DOC-1/diff", project="AWPCA")
assert e.status == 404 and e.error == "no_versions"
# --- поля страницы и состояния ---------------------------------------------------------------------------------------
def test_page_last_change(four): # сценарий «Последняя правка в странице»
p = four.client.get("/wiki/pages/DOC-3", project="AWPCA")
assert p["modified_at"] == "2026-10-27T20:48:02+00:00"
assert p["modified_by"] == SOSNOV and p["versions"] == 4
def test_page_last_change_without_versions(history):
p = history.client.get("/wiki/pages/DOC-1", project="AWPCA")
assert p["modified_at"] is None and p["modified_by"] is None and p["versions"] == 0
def test_status_history_at(history):
st = history.client.get("/wiki/status", project="AWPCA")
stored = history.rows("SELECT history_at FROM wiki_history")[0]["history_at"]
assert stored and st["history_at"] == stored