Вики: таблицы версий и приём POST /agent/wiki/versions (#140)
- schema: wiki_version, wiki_version_stage, wiki_history (без миграций, IF NOT EXISTS)
- history.utc: время версии в UTC; без смещения или не строка — None
- convert.to_markdown(file_links=False): вложение текстом «📎 имя», список файлов пуст
- stage.accept_versions + POST /agent/wiki/versions (agent wiki): проверка формы, перевод вне транзакции
- эталоны baseline (routes, schema) и документы: design.md (П-1…П-12), ARCHITECTURE.md
Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
de3c04701e
commit
7b18ae53ad
@ -58,13 +58,13 @@
|
|||||||
|
|
||||||
| Модуль | Ответственность | Зависит от |
|
| Модуль | Ответственность | Зависит от |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `schema` | таблицы модуля: `wiki_project`, `wiki_page`, `wiki_attachment`, `wiki_sync_stage`; таблица FTS5 `wiki_page_fts` создаётся лениво при первой синхронизации (`search.ensure`), в `schema` её нет | stdlib |
|
| `schema` | таблицы модуля: `wiki_project`, `wiki_page`, `wiki_attachment`, `wiki_sync_stage`, `wiki_version`, `wiki_version_stage`, `wiki_history`; таблица FTS5 `wiki_page_fts` создаётся лениво при первой синхронизации (`search.ensure`), в `schema` её нет | stdlib |
|
||||||
| `settings` | умолчания (`wiki_pacing`, `wiki_auto_sync_hours`, `wiki_min_agent_version`), ключи для агента, проверка значений | `core.settings` |
|
| `settings` | умолчания (`wiki_pacing`, `wiki_auto_sync_hours`, `wiki_min_agent_version`), ключи для агента, проверка значений | `core.settings` |
|
||||||
| `paths` | каталог копий `<каталог базы>/wiki/<метка>/` | `core.db` |
|
| `paths` | каталог копий `<каталог базы>/wiki/<метка>/` | `core.db` |
|
||||||
| `projects` | поле проекта `wiki_root` (изменяемое), `project_view` (`wiki_root`, `pages_count`, `synced_at`…), `on_project_created`/`on_project_updated` (постановка `wiki_sync`, отмена `queued`, сброс копии при смене корня) | `core.projects`, `core.queue`, `commands`, `paths` |
|
| `projects` | поле проекта `wiki_root` (изменяемое), `project_view` (`wiki_root`, `pages_count`, `synced_at`…), `on_project_created`/`on_project_updated` (постановка `wiki_sync`, отмена `queued`, сброс копии при смене корня) | `core.projects`, `core.queue`, `commands`, `paths` |
|
||||||
| `commands` | типы `wiki_sync` и `wiki_fetch` (оба — чтение Eva, без хвоста и копии базы), `CommandHooks` модуля: проверки, паузы, `before_result`/`on_result`/`after_result`/`abort_result`, отбрасывание отчёта по сменённому корню | `core.queue`, `stage`, `sync`, `attachments` |
|
| `commands` | типы `wiki_sync` и `wiki_fetch` (оба — чтение Eva, без хвоста и копии базы), `CommandHooks` модуля: проверки, паузы, `before_result`/`on_result`/`after_result`/`abort_result`, отбрасывание отчёта по сменённому корню | `core.queue`, `stage`, `sync`, `attachments` |
|
||||||
| `convert` | HTML Eva → Markdown (`markdownify`): замена ссылок по `ref` на `attachment:<id>` и `page:<код>`; HTML считается недоверенным | stdlib, `markdownify` |
|
| `convert` | HTML Eva → Markdown (`markdownify`): замена ссылок по `ref` на `attachment:<id>` и `page:<код>`; режим без ссылок на вложения для версий (`file_links=False`); HTML считается недоверенным | stdlib, `markdownify` |
|
||||||
| `stage` | приём пачек страниц от агента: проверка формы, перевод вне транзакции, стадия `wiki_sync_stage` | `core.db`, `convert` |
|
| `stage` | приём пачек страниц от агента: проверка формы, перевод вне транзакции, стадия `wiki_sync_stage`; и пачек версий (`accept_versions`, `POST /agent/wiki/versions`), стадия `wiki_version_stage` | `core.db`, `convert` |
|
||||||
| `sync` | сборка копии из стадии в `wiki/.<метка>.new/` (`prepare`), запись строк и подмена каталога (`commit`), FTS | `core.db`, `paths`, `search`, `convert` |
|
| `sync` | сборка копии из стадии в `wiki/.<метка>.new/` (`prepare`), запись строк и подмена каталога (`commit`), FTS | `core.db`, `paths`, `search`, `convert` |
|
||||||
| `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` |
|
||||||
|
|||||||
@ -151,3 +151,35 @@
|
|||||||
(`replace`), чтобы «назад» закрывал окно. Цена ошибки: ссылка на правку открывается не поверх ленты.
|
(`replace`), чтобы «назад» закрывал окно. Цена ошибки: ссылка на правку открывается не поверх ленты.
|
||||||
- 28.10.2026 — **фильтр по автору** в `GET /wiki/changes` и MCP `changes` — подстрока имени или логина без учёта
|
- 28.10.2026 — **фильтр по автору** в `GET /wiki/changes` и MCP `changes` — подстрока имени или логина без учёта
|
||||||
регистра. Цена ошибки: «Иван» находит и «Иванову».
|
регистра. Цена ошибки: «Иван» находит и «Иванову».
|
||||||
|
- 28.10.2026 — **П-1: `history_at` — своя таблица `wiki_history(label, history_at)` без миграции.** Обоснование:
|
||||||
|
схема `wiki_project` не меняется, миграция не нужна. Цена ошибки: ещё одна таблица на проект.
|
||||||
|
- 28.10.2026 — **П-2: повтор версии.** В хабе версия с уже известным кодом не перезаписывается (`INSERT OR IGNORE`,
|
||||||
|
решение 1 — опубликованная версия неизменна); в стадии повтор кода заменяет строку (`INSERT OR REPLACE`), N в
|
||||||
|
«пришло N из M» — число уникальных кодов в стадии. Цена ошибки: исправленная Eva версия не попадёт в историю.
|
||||||
|
- 28.10.2026 — **П-3: `result.versions` некорректен.** Не целое ≥ 0 (строка, `true`, −1) — команда `failed`
|
||||||
|
«versions — неотрицательное целое»; присланных больше `M` — применяются все. Цена ошибки: агент с багом в счётчике
|
||||||
|
уронит команду, а не только историю.
|
||||||
|
- 28.10.2026 — **П-4: «+/−» считаются в `sync.prepare`** (вне транзакции), пишутся в `sync._commit`; число — строки
|
||||||
|
`+`/`-` из `difflib.unified_diff` (те же, что в диффе правки). Цена ошибки: при смене алгоритма диффа старые «+/−»
|
||||||
|
расходятся с новым диффом.
|
||||||
|
- 28.10.2026 — **П-5: время версии.** Дробные секунды отбрасываются; равное время — порядок по коду строкой (решение
|
||||||
|
спеки «затем по коду»). Цена ошибки: две правки в одну секунду идут в ленте по коду, а не по порядку правки.
|
||||||
|
- 28.10.2026 — **П-6: фильтр автора в Python (`casefold`)** до среза `limit`. Цена ошибки: на больших лентах фильтр
|
||||||
|
медленнее, чем в SQL.
|
||||||
|
- 28.10.2026 — **П-7: `GET …/diff`.** `offset` не целое ≥ 0 — 422 `bad_request` «offset: целое ≥ 0»; `offset` за концом —
|
||||||
|
`diff = ""`, `next_offset = null`. Цена ошибки: клиент, ждавший 404, получит пустой дифф.
|
||||||
|
- 28.10.2026 — **П-8: MCP.** Шапка `diff`; строка `status` «история: <history_at>» / «история не загружена»; `sync` при
|
||||||
|
отменённом пакете — «ошибка: пакет N отменён»; время в «ок: синхронизировано …» — `synced_at` из `GET /wiki/status`;
|
||||||
|
автор правки — имя, иначе логин, иначе «—» (и в вебе). Цена ошибки: формулировки MCP могут не совпасть с ожиданием
|
||||||
|
владельца; смысл не меняется.
|
||||||
|
- 28.10.2026 — **П-9: помощники времени MCP (`local_if_naive`, `since_of`) — в `core/mcp.py`**, сервер jira берёт их
|
||||||
|
оттуда. Цена ошибки: ядро получает функции форматирования времени, общие для двух модулей.
|
||||||
|
- 28.10.2026 — **П-10: веб — в шапке «Ленты» вторичные кнопки обновления модулей выбранных источников** («Обновить из
|
||||||
|
Jira», «Обновить вики»). Цена ошибки: лишняя кнопка на одном из источников, если модуль не подключён.
|
||||||
|
- 28.10.2026 — **П-11: веб — тексты сообщений.** `truncated` и ошибки источника — с префиксом «<Источник>: »;
|
||||||
|
«выберите хотя бы один вид» и «история вики ещё не загружена…» — без префикса; «за период изменений нет» — когда ни
|
||||||
|
один выбранный источник не грузится и не в ошибке, хотя бы один загружен без своего сообщения и групп нет;
|
||||||
|
`sources=none` — явный пустой набор (как `kinds=none`). Цена ошибки: формулировки сообщений, не смысл.
|
||||||
|
- 28.10.2026 — **П-12: веб — `eventTime` переезжает в `ui/format.ts`, период (`PERIODS`, `parsePeriod`, `periodRange`)
|
||||||
|
— в `shell/feed/period.ts`;** прежнее перенаправление `/app/feed` → `/app/jira/feed` снимается (адрес теперь страница
|
||||||
|
ядра). Цена ошибки: закладки на прежний адрес ленты перестают перенаправлять.
|
||||||
|
|||||||
@ -13,6 +13,10 @@ def h_agent_pages(hub, conn, m, q, body):
|
|||||||
return 200, stage.accept(conn, body.get("command_id"), body.get("pages"))
|
return 200, stage.accept(conn, body.get("command_id"), body.get("pages"))
|
||||||
|
|
||||||
|
|
||||||
|
def h_agent_versions(hub, conn, m, q, body):
|
||||||
|
return 200, stage.accept_versions(conn, body.get("command_id"), body.get("versions"))
|
||||||
|
|
||||||
|
|
||||||
def _project(conn, q) -> tuple[str, dict]:
|
def _project(conn, q) -> tuple[str, dict]:
|
||||||
"""(метка, строка wiki_project): без project — 422, неизвестный — 404, без корня — 409 no_root."""
|
"""(метка, строка wiki_project): без project — 422, неизвестный — 404, без корня — 409 no_root."""
|
||||||
label = project_param(conn, q)
|
label = project_param(conn, q)
|
||||||
@ -143,6 +147,7 @@ def h_attachment_content(hub, conn, m, q, body):
|
|||||||
|
|
||||||
ROUTES = (
|
ROUTES = (
|
||||||
("POST", r"^/agent/wiki/pages$", h_agent_pages, "agent"),
|
("POST", r"^/agent/wiki/pages$", h_agent_pages, "agent"),
|
||||||
|
("POST", r"^/agent/wiki/versions$", h_agent_versions, "agent"),
|
||||||
("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"),
|
||||||
|
|||||||
@ -146,8 +146,15 @@ def _strip_editor_ui(soup) -> None:
|
|||||||
el.decompose()
|
el.decompose()
|
||||||
|
|
||||||
|
|
||||||
def to_markdown(html: str, attachments: list[dict] | None, links: list[dict] | None) -> tuple[str, list[dict]]:
|
_FILE_LINK = re.compile(r"\[📎 ((?:\\.|[^\]\\])*)\]\(attachment:@\d+\)")
|
||||||
"""HTML страницы → (Markdown, вложения страницы [{url, name}])."""
|
|
||||||
|
|
||||||
|
def to_markdown(html: str, attachments: list[dict] | None, links: list[dict] | None, *,
|
||||||
|
file_links: bool = True) -> tuple[str, list[dict]]:
|
||||||
|
"""HTML страницы → (Markdown, вложения страницы [{url, name}]).
|
||||||
|
|
||||||
|
`file_links=False` (версии вики, #140): ссылка вложения становится текстом `📎 <имя>` без адреса, список файлов пуст;
|
||||||
|
ссылки на страницы остаются `page:<код>`."""
|
||||||
soup = BeautifulSoup(html, "html.parser")
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
_strip_editor_ui(soup)
|
_strip_editor_ui(soup)
|
||||||
_strip_private(soup)
|
_strip_private(soup)
|
||||||
@ -155,6 +162,8 @@ def to_markdown(html: str, attachments: list[dict] | None, links: list[dict] | N
|
|||||||
md = conv.convert_soup(soup).strip() + "\n"
|
md = conv.convert_soup(soup).strip() + "\n"
|
||||||
md = md.replace("attachment:@", "attachment:@") # авторское «attachment:@» больше не совпадает с шаблоном
|
md = md.replace("attachment:@", "attachment:@") # авторское «attachment:@» больше не совпадает с шаблоном
|
||||||
md = _MARK.sub(lambda m: "](attachment:@%s)" % m.group(1), md) # настоящие ссылки вложений
|
md = _MARK.sub(lambda m: "](attachment:@%s)" % m.group(1), md) # настоящие ссылки вложений
|
||||||
|
if not file_links:
|
||||||
|
return _FILE_LINK.sub(lambda m: "📎 " + m.group(1), md), []
|
||||||
return md, conv.files
|
return md, conv.files
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
16
src/sdlc_hub/modules/wiki/history.py
Normal file
16
src/sdlc_hub/modules/wiki/history.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
"""История правок вики (wiki-history, #140): время версий в UTC для приёма и для хранения."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
|
||||||
|
def utc(value) -> str | None:
|
||||||
|
"""ISO-8601 со смещением или `Z` (дробные секунды допустимы) → UTC в формате `db.now()`; иначе `None`:
|
||||||
|
без смещения, не строка или неразбираемое значение."""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return None
|
||||||
|
return dt.astimezone(timezone.utc).replace(microsecond=0).isoformat()
|
||||||
@ -43,4 +43,24 @@ CREATE TABLE IF NOT EXISTS wiki_sync_stage (
|
|||||||
payload TEXT NOT NULL,
|
payload TEXT NOT NULL,
|
||||||
PRIMARY KEY (command_id, code)
|
PRIMARY KEY (command_id, code)
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS wiki_version (
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
page_code TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
author_login TEXT,
|
||||||
|
author_name TEXT,
|
||||||
|
markdown TEXT NOT NULL,
|
||||||
|
added INTEGER NOT NULL DEFAULT 0,
|
||||||
|
removed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (label, code));
|
||||||
|
CREATE INDEX IF NOT EXISTS wiki_version_page ON wiki_version(label, page_code, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS wiki_version_time ON wiki_version(label, created_at);
|
||||||
|
CREATE TABLE IF NOT EXISTS wiki_version_stage (
|
||||||
|
command_id INTEGER NOT NULL,
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
page_code TEXT NOT NULL,
|
||||||
|
payload TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (command_id, code));
|
||||||
|
CREATE TABLE IF NOT EXISTS wiki_history (label TEXT PRIMARY KEY REFERENCES project(label), history_at TEXT NOT NULL);
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -3,10 +3,12 @@ import json
|
|||||||
|
|
||||||
from ...core import db, queue
|
from ...core import db, queue
|
||||||
from ...core.http import ApiError
|
from ...core.http import ApiError
|
||||||
from . import convert
|
from . import convert, history
|
||||||
|
|
||||||
MAX_PAGES = 50
|
MAX_PAGES = 50
|
||||||
MAX_STAGE = 20000 # страниц одной команды в стадии (между пачками)
|
MAX_STAGE = 20000 # страниц одной команды в стадии (между пачками)
|
||||||
|
MAX_VERSIONS = 50 # версий в одной пачке (#140)
|
||||||
|
MAX_VERSION_STAGE = 20000 # версий одной команды в стадии (между пачками)
|
||||||
LIMITS = {"code": 200, "title": 1000, "url": 4000, "parent_code": 200, "eva_id": 200}
|
LIMITS = {"code": 200, "title": 1000, "url": 4000, "parent_code": 200, "eva_id": 200}
|
||||||
|
|
||||||
|
|
||||||
@ -35,6 +37,20 @@ def _clean(v):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def _refs(attachments, links, fail) -> tuple[list, list]:
|
||||||
|
"""Вложения и ссылки страницы или версии → поля для стадии; `fail(поле)` строит ошибку формы."""
|
||||||
|
attachments, links = attachments or [], links or []
|
||||||
|
if not isinstance(attachments, list) or not all(
|
||||||
|
isinstance(a, dict) and isinstance(a.get("ref"), str) and isinstance(a.get("url"), str)
|
||||||
|
and isinstance(a.get("name") or "", str) for a in attachments):
|
||||||
|
raise fail("attachments")
|
||||||
|
if not isinstance(links, list) or not all(
|
||||||
|
isinstance(l, dict) and isinstance(l.get("ref"), str) and isinstance(l.get("code"), str) for l in links):
|
||||||
|
raise fail("links")
|
||||||
|
return ([{"ref": a["ref"], "url": a["url"], "name": a.get("name") or ""} for a in attachments],
|
||||||
|
[{"ref": l["ref"], "code": l["code"]} for l in links])
|
||||||
|
|
||||||
|
|
||||||
def _check(i, page) -> dict:
|
def _check(i, page) -> dict:
|
||||||
"""Форма одной страницы → поля для стадии (без html); ApiError 422 `<код>: <поле>`."""
|
"""Форма одной страницы → поля для стадии (без html); ApiError 422 `<код>: <поле>`."""
|
||||||
if not isinstance(page, dict):
|
if not isinstance(page, dict):
|
||||||
@ -53,18 +69,10 @@ def _check(i, page) -> dict:
|
|||||||
eva_id = page.get("eva_id")
|
eva_id = page.get("eva_id")
|
||||||
if eva_id is not None and (not isinstance(eva_id, str) or len(eva_id) > LIMITS["eva_id"]):
|
if eva_id is not None and (not isinstance(eva_id, str) or len(eva_id) > LIMITS["eva_id"]):
|
||||||
raise _bad(code, "eva_id")
|
raise _bad(code, "eva_id")
|
||||||
attachments, links = page.get("attachments") or [], page.get("links") or []
|
attachments, links = _refs(page.get("attachments"), page.get("links"), lambda f: _bad(code, f))
|
||||||
if not isinstance(attachments, list) or not all(
|
|
||||||
isinstance(a, dict) and isinstance(a.get("ref"), str) and isinstance(a.get("url"), str)
|
|
||||||
and isinstance(a.get("name") or "", str) for a in attachments):
|
|
||||||
raise _bad(code, "attachments")
|
|
||||||
if not isinstance(links, list) or not all(
|
|
||||||
isinstance(l, dict) and isinstance(l.get("ref"), str) and isinstance(l.get("code"), str) for l in links):
|
|
||||||
raise _bad(code, "links")
|
|
||||||
return {"code": code, "eva_id": eva_id, "title": title, "parent_code": _text(page, "parent_code", code, False, True),
|
return {"code": code, "eva_id": eva_id, "title": title, "parent_code": _text(page, "parent_code", code, False, True),
|
||||||
"orderno": orderno, "url": _text(page, "url", code, False, True), "html": html,
|
"orderno": orderno, "url": _text(page, "url", code, False, True), "html": html,
|
||||||
"attachments": [{"ref": a["ref"], "url": a["url"], "name": a.get("name") or ""} for a in attachments],
|
"attachments": attachments, "links": links}
|
||||||
"links": [{"ref": l["ref"], "code": l["code"]} for l in links]}
|
|
||||||
|
|
||||||
|
|
||||||
def accept(conn, command_id, pages) -> dict:
|
def accept(conn, command_id, pages) -> dict:
|
||||||
@ -97,3 +105,69 @@ def accept(conn, command_id, pages) -> dict:
|
|||||||
raise ApiError(422, "bad_pages", f"pages: в команде больше {MAX_STAGE} страниц")
|
raise ApiError(422, "bad_pages", f"pages: в команде больше {MAX_STAGE} страниц")
|
||||||
conn.executemany("INSERT OR REPLACE INTO wiki_sync_stage(command_id, code, payload) VALUES (?, ?, ?)", rows)
|
conn.executemany("INSERT OR REPLACE INTO wiki_sync_stage(command_id, code, payload) VALUES (?, ?, ?)", rows)
|
||||||
return {"accepted": len(rows)}
|
return {"accepted": len(rows)}
|
||||||
|
|
||||||
|
|
||||||
|
def _vbad(prefix, field):
|
||||||
|
return ApiError(422, "bad_versions", f"{prefix}: {field}")
|
||||||
|
|
||||||
|
|
||||||
|
def _version(i, v) -> dict:
|
||||||
|
"""Форма одной версии → поля для стадии (без markdown); ApiError 422 `bad_versions`: `версия <i>: …` до кода,
|
||||||
|
`<код>: <поле>` после (#140)."""
|
||||||
|
if not isinstance(v, dict):
|
||||||
|
raise ApiError(422, "bad_versions", f"версия {i}: не объект")
|
||||||
|
v = _clean(v)
|
||||||
|
code = v.get("code")
|
||||||
|
if not isinstance(code, str) or not code.strip() or len(code) > LIMITS["code"]:
|
||||||
|
raise ApiError(422, "bad_versions", f"версия {i}: code")
|
||||||
|
page_code = v.get("page_code")
|
||||||
|
if not isinstance(page_code, str) or not page_code.strip() or len(page_code) > LIMITS["code"]:
|
||||||
|
raise _vbad(code, "page_code")
|
||||||
|
created_at = history.utc(v.get("created_at"))
|
||||||
|
if created_at is None:
|
||||||
|
raise _vbad(code, "created_at")
|
||||||
|
author = v.get("author")
|
||||||
|
if not isinstance(author, dict):
|
||||||
|
raise _vbad(code, "author")
|
||||||
|
login, name = author.get("login"), author.get("name")
|
||||||
|
if not all(x is None or isinstance(x, str) for x in (login, name)):
|
||||||
|
raise _vbad(code, "author")
|
||||||
|
html = v.get("html")
|
||||||
|
if not isinstance(html, str):
|
||||||
|
raise _vbad(code, "html")
|
||||||
|
attachments, links = _refs(v.get("attachments"), v.get("links"), lambda f: _vbad(code, f))
|
||||||
|
return {"code": code, "page_code": page_code, "created_at": created_at, "author_login": login,
|
||||||
|
"author_name": name, "html": html, "attachments": attachments, "links": links}
|
||||||
|
|
||||||
|
|
||||||
|
def accept_versions(conn, command_id, versions) -> dict:
|
||||||
|
"""Пачка версий команды wiki_sync → стадия версий (#140). Перевод HTML идёт до записи, строки — короткой транзакцией."""
|
||||||
|
if isinstance(command_id, bool) or not isinstance(command_id, int):
|
||||||
|
raise ApiError(422, "bad_request", "command_id — целое")
|
||||||
|
if not isinstance(versions, list) or not versions or len(versions) > MAX_VERSIONS:
|
||||||
|
raise ApiError(422, "bad_versions", f"versions — список от 1 до {MAX_VERSIONS} версий")
|
||||||
|
cmd = queue.get_command(conn, command_id)
|
||||||
|
if cmd is None:
|
||||||
|
raise ApiError(404, "not_found", f"command {command_id}")
|
||||||
|
if cmd["type"] != "wiki_sync" or cmd["status"] != "claimed":
|
||||||
|
raise ApiError(409, "not_claimed", f"команда {command_id} не выдана агенту как wiki_sync")
|
||||||
|
checked = [_version(i, v) for i, v in enumerate(versions, 1)]
|
||||||
|
rows = []
|
||||||
|
for v in checked:
|
||||||
|
try:
|
||||||
|
md, _ = convert.to_markdown(v["html"], v["attachments"], v["links"], file_links=False)
|
||||||
|
except Exception as e: # noqa: BLE001 недоверенный HTML: сбой разбора — ошибка версии, не сервера
|
||||||
|
raise _vbad(v["code"], f"html ({type(e).__name__})") from None
|
||||||
|
payload = {k: v[k] for k in ("page_code", "code", "created_at", "author_login", "author_name")}
|
||||||
|
payload["markdown"] = md
|
||||||
|
rows.append((command_id, v["code"], v["page_code"], json.dumps(payload, ensure_ascii=False)))
|
||||||
|
with db.transaction(conn):
|
||||||
|
cur = conn.execute("SELECT status FROM command WHERE id = ?", (command_id,)).fetchone()
|
||||||
|
if cur is None or cur["status"] != "claimed": # состояние могло смениться, пока шёл перевод
|
||||||
|
raise ApiError(409, "not_claimed", f"команда {command_id} уже не в работе")
|
||||||
|
have = conn.execute("SELECT COUNT(*) AS n FROM wiki_version_stage WHERE command_id = ?", (command_id,)).fetchone()["n"]
|
||||||
|
if have + len(rows) > MAX_VERSION_STAGE:
|
||||||
|
raise ApiError(422, "bad_versions", f"versions: в команде больше {MAX_VERSION_STAGE} версий")
|
||||||
|
conn.executemany("INSERT OR REPLACE INTO wiki_version_stage(command_id, code, page_code, payload) "
|
||||||
|
"VALUES (?, ?, ?, ?)", rows)
|
||||||
|
return {"accepted": len(rows)}
|
||||||
|
|||||||
6
tests/fixtures/baseline/routes.expected.json
vendored
6
tests/fixtures/baseline/routes.expected.json
vendored
@ -77,6 +77,12 @@
|
|||||||
"agent",
|
"agent",
|
||||||
"h_agent_pages"
|
"h_agent_pages"
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"POST",
|
||||||
|
"^/agent/wiki/versions$",
|
||||||
|
"agent",
|
||||||
|
"h_agent_versions"
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"GET",
|
"GET",
|
||||||
"^/agents$",
|
"^/agents$",
|
||||||
|
|||||||
25
tests/fixtures/baseline/schema.expected.json
vendored
25
tests/fixtures/baseline/schema.expected.json
vendored
@ -35,6 +35,16 @@
|
|||||||
"ix_jira_sprint_board",
|
"ix_jira_sprint_board",
|
||||||
"CREATE INDEX ix_jira_sprint_board ON jira_sprint(board_id)"
|
"CREATE INDEX ix_jira_sprint_board ON jira_sprint(board_id)"
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"index",
|
||||||
|
"wiki_version_page",
|
||||||
|
"CREATE INDEX wiki_version_page ON wiki_version(label, page_code, created_at)"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"index",
|
||||||
|
"wiki_version_time",
|
||||||
|
"CREATE INDEX wiki_version_time ON wiki_version(label, created_at)"
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"table",
|
"table",
|
||||||
"agent_status",
|
"agent_status",
|
||||||
@ -125,6 +135,11 @@
|
|||||||
"wiki_attachment",
|
"wiki_attachment",
|
||||||
"CREATE TABLE wiki_attachment (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n label TEXT NOT NULL,\n page_code TEXT NOT NULL,\n url TEXT NOT NULL,\n name TEXT,\n state TEXT NOT NULL DEFAULT 'none',\n file TEXT,\n size INTEGER,\n error TEXT,\n fetched_at TEXT,\n UNIQUE (label, page_code, url)\n)"
|
"CREATE TABLE wiki_attachment (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n label TEXT NOT NULL,\n page_code TEXT NOT NULL,\n url TEXT NOT NULL,\n name TEXT,\n state TEXT NOT NULL DEFAULT 'none',\n file TEXT,\n size INTEGER,\n error TEXT,\n fetched_at TEXT,\n UNIQUE (label, page_code, url)\n)"
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
"table",
|
||||||
|
"wiki_history",
|
||||||
|
"CREATE TABLE wiki_history (label TEXT PRIMARY KEY REFERENCES project(label), history_at TEXT NOT NULL)"
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"table",
|
"table",
|
||||||
"wiki_page",
|
"wiki_page",
|
||||||
@ -139,6 +154,16 @@
|
|||||||
"table",
|
"table",
|
||||||
"wiki_sync_stage",
|
"wiki_sync_stage",
|
||||||
"CREATE TABLE wiki_sync_stage (\n command_id INTEGER NOT NULL,\n code TEXT NOT NULL,\n payload TEXT NOT NULL,\n PRIMARY KEY (command_id, code)\n)"
|
"CREATE TABLE wiki_sync_stage (\n command_id INTEGER NOT NULL,\n code TEXT NOT NULL,\n payload TEXT NOT NULL,\n PRIMARY KEY (command_id, code)\n)"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"table",
|
||||||
|
"wiki_version",
|
||||||
|
"CREATE TABLE wiki_version (\n label TEXT NOT NULL,\n code TEXT NOT NULL,\n page_code TEXT NOT NULL,\n created_at TEXT NOT NULL,\n author_login TEXT,\n author_name TEXT,\n markdown TEXT NOT NULL,\n added INTEGER NOT NULL DEFAULT 0,\n removed INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY (label, code))"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"table",
|
||||||
|
"wiki_version_stage",
|
||||||
|
"CREATE TABLE wiki_version_stage (\n command_id INTEGER NOT NULL,\n code TEXT NOT NULL,\n page_code TEXT NOT NULL,\n payload TEXT NOT NULL,\n PRIMARY KEY (command_id, code))"
|
||||||
]
|
]
|
||||||
],
|
],
|
||||||
"schema_version": 9
|
"schema_version": 9
|
||||||
|
|||||||
@ -347,3 +347,13 @@ def test_empty_external_link_dropped():
|
|||||||
"""Финальное ревью M-1: пустая внешняя ссылка без вложения внутри не выводится адресом."""
|
"""Финальное ревью M-1: пустая внешняя ссылка без вложения внутри не выводится адресом."""
|
||||||
md, _ = convert.to_markdown('<p>a<a href="#top"></a>b<a href="http://x"><img src="static/i.png"></a>c</p>', [], [])
|
md, _ = convert.to_markdown('<p>a<a href="#top"></a>b<a href="http://x"><img src="static/i.png"></a>c</p>', [], [])
|
||||||
assert md == "abc\n"
|
assert md == "abc\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_markdown_attachment_as_text():
|
||||||
|
md, files = convert.to_markdown(_html("page_file.html"), [THUMB, FILE], [], file_links=False)
|
||||||
|
assert "📎 model.xlsx" in md and "attachment:" not in md and files == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_version_markdown_page_links_kept():
|
||||||
|
md, _ = convert.to_markdown('<p><a href="project/Document/DOC-1">т</a></p>', [], [LINK], file_links=False)
|
||||||
|
assert md == "[т](page:DOC-1)\n"
|
||||||
|
|||||||
131
tests/test_wiki_history.py
Normal file
131
tests/test_wiki_history.py
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
"""История правок вики (wiki-history, #140): приём версий `POST /agent/wiki/versions`, время `history.utc`."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from sdlc_hub.core.client import HubError
|
||||||
|
from sdlc_hub.modules.wiki import history
|
||||||
|
|
||||||
|
from test_wiki_sync import CODES, TREE, api, first_sync, page # noqa: F401 — api — фикстура
|
||||||
|
|
||||||
|
SOSNOV = {"login": "sosnov-dvi@rshbeco.ru", "name": "Дмитрий Соснов"}
|
||||||
|
MOLCH = {"login": "molchanov-iv@rshbeco.ru", "name": "Илья Молчанов"}
|
||||||
|
|
||||||
|
|
||||||
|
def version(page_code, code, created_at, html="<p>текст</p>", author=SOSNOV, attachments=None, links=None):
|
||||||
|
return {"page_code": page_code, "code": code, "created_at": created_at, "author": author, "html": html,
|
||||||
|
"attachments": attachments or [], "links": links or []}
|
||||||
|
|
||||||
|
|
||||||
|
def stage_rows(api, cmd_id):
|
||||||
|
return api.rows("SELECT code, page_code, payload FROM wiki_version_stage WHERE command_id = ? ORDER BY code", cmd_id)
|
||||||
|
|
||||||
|
|
||||||
|
def test_versions_staged(api): # перевод и форма payload
|
||||||
|
cmd = api.claim()
|
||||||
|
out = api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [
|
||||||
|
version("DOC-3", "DOCH-1", "2026-10-27T23:48:02+03:00", "<p>статус <b>новый</b></p>")]})
|
||||||
|
assert out == {"accepted": 1}
|
||||||
|
(row,) = stage_rows(api, cmd["id"])
|
||||||
|
p = json.loads(row["payload"])
|
||||||
|
assert p == {"page_code": "DOC-3", "code": "DOCH-1", "created_at": "2026-10-27T20:48:02+00:00",
|
||||||
|
"author_login": "sosnov-dvi@rshbeco.ru", "author_name": "Дмитрий Соснов", "markdown": "статус **новый**\n"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_versions_resend_same_batch(api): # Review Focus 2: повтор пачки — одна строка на код
|
||||||
|
cmd = api.claim()
|
||||||
|
batch = [version("DOC-3", "DOCH-1", "2026-10-27T10:00:00Z"), version("DOC-3", "DOCH-2", "2026-10-27T11:00:00Z")]
|
||||||
|
for _ in range(2):
|
||||||
|
api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": batch})
|
||||||
|
assert [r["code"] for r in stage_rows(api, cmd["id"])] == ["DOCH-1", "DOCH-2"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw,stored", [("2026-10-27T23:48:02.123456+03:00", "2026-10-27T20:48:02+00:00"),
|
||||||
|
("2026-10-27T20:48:02Z", "2026-10-27T20:48:02+00:00"),
|
||||||
|
("2026-10-27T23:48:02+0300", "2026-10-27T20:48:02+00:00")])
|
||||||
|
def test_versions_created_at_forms(api, raw, stored): # Review Focus 4 → created_at в payload == stored
|
||||||
|
cmd = api.claim()
|
||||||
|
api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [version("DOC-3", "DOCH-1", raw)]})
|
||||||
|
(row,) = stage_rows(api, cmd["id"])
|
||||||
|
assert json.loads(row["payload"])["created_at"] == stored
|
||||||
|
|
||||||
|
|
||||||
|
def test_versions_created_at_without_offset_422(api):
|
||||||
|
cmd = api.claim()
|
||||||
|
with pytest.raises(HubError) as e:
|
||||||
|
api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [
|
||||||
|
version("DOC-3", "DOCH-1", "2026-10-27T23:48:02")]})
|
||||||
|
assert e.value.status == 422 and e.value.error == "bad_versions" and e.value.detail == "DOCH-1: created_at"
|
||||||
|
|
||||||
|
|
||||||
|
def test_versions_author_nulls(api):
|
||||||
|
cmd = api.claim()
|
||||||
|
api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [
|
||||||
|
version("DOC-3", "DOCH-1", "2026-10-27T10:00:00Z", author={"login": None, "name": None})]})
|
||||||
|
(row,) = stage_rows(api, cmd["id"])
|
||||||
|
p = json.loads(row["payload"])
|
||||||
|
assert p["author_login"] is None and p["author_name"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("field,value", [("page_code", ""), ("page_code", 5), ("html", None), ("author", "Соснов"),
|
||||||
|
("author", {"login": 1, "name": "x"}), ("attachments", [{"ref": 1}]),
|
||||||
|
("links", "x")])
|
||||||
|
def test_versions_bad_field(api, field, value): # 422 bad_versions, detail == f"DOCH-1: {field}"
|
||||||
|
cmd = api.claim()
|
||||||
|
bad = version("DOC-3", "DOCH-1", "2026-10-27T10:00:00Z") | {field: value}
|
||||||
|
with pytest.raises(HubError) as e:
|
||||||
|
api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [bad]})
|
||||||
|
assert e.value.status == 422 and e.value.error == "bad_versions" and e.value.detail == f"DOCH-1: {field}"
|
||||||
|
assert api.rows("SELECT COUNT(*) AS n FROM wiki_version_stage")[0]["n"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_versions_bad_batch(api): # [] и 51 версия → 422 bad_versions; command_id "1" → 422 bad_request;
|
||||||
|
# версия-не объект → detail "версия 1: не объект"; без code → "версия 1: code"
|
||||||
|
cmd = api.claim()
|
||||||
|
with pytest.raises(HubError) as e:
|
||||||
|
api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": []})
|
||||||
|
assert e.value.status == 422 and e.value.error == "bad_versions"
|
||||||
|
many = [version("DOC-3", f"DOCH-{i}", "2026-10-27T10:00:00Z") for i in range(51)]
|
||||||
|
with pytest.raises(HubError) as e:
|
||||||
|
api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": many})
|
||||||
|
assert e.value.status == 422 and e.value.error == "bad_versions"
|
||||||
|
with pytest.raises(HubError) as e:
|
||||||
|
api.wiki.post("/agent/wiki/versions", {"command_id": "1", "versions": [version("DOC-3", "DOCH-1", "2026-10-27T10:00:00Z")]})
|
||||||
|
assert e.value.status == 422 and e.value.error == "bad_request"
|
||||||
|
with pytest.raises(HubError) as e:
|
||||||
|
api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": ["x"]})
|
||||||
|
assert e.value.status == 422 and e.value.detail == "версия 1: не объект"
|
||||||
|
no_code = {k: v for k, v in version("DOC-3", "DOCH-1", "2026-10-27T10:00:00Z").items() if k != "code"}
|
||||||
|
with pytest.raises(HubError) as e:
|
||||||
|
api.wiki.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [no_code]})
|
||||||
|
assert e.value.status == 422 and e.value.detail == "версия 1: code"
|
||||||
|
|
||||||
|
|
||||||
|
def test_versions_not_claimed(api): # команда queued → 409 not_claimed; несуществующая → 404
|
||||||
|
(row,) = api.rows("SELECT id FROM command WHERE type = 'wiki_sync'")
|
||||||
|
with pytest.raises(HubError) as e:
|
||||||
|
api.wiki.post("/agent/wiki/versions", {"command_id": row["id"], "versions": [version("DOC-3", "DOCH-1", "2026-10-27T10:00:00Z")]})
|
||||||
|
assert e.value.status == 409 and e.value.error == "not_claimed"
|
||||||
|
with pytest.raises(HubError) as e:
|
||||||
|
api.wiki.post("/agent/wiki/versions", {"command_id": 999999, "versions": [version("DOC-3", "DOCH-1", "2026-10-27T10:00:00Z")]})
|
||||||
|
assert e.value.status == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_versions_wrong_agent(api): # токеном агента jira (api.jira) → 401
|
||||||
|
cmd = api.claim()
|
||||||
|
with pytest.raises(HubError) as e:
|
||||||
|
api.jira.post("/agent/wiki/versions", {"command_id": cmd["id"], "versions": [version("DOC-3", "DOCH-1", "2026-10-27T10:00:00Z")]})
|
||||||
|
assert e.value.status == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw,expected", [
|
||||||
|
("2026-10-27T23:48:02.123456+03:00", "2026-10-27T20:48:02+00:00"),
|
||||||
|
("2026-10-27T20:48:02Z", "2026-10-27T20:48:02+00:00"),
|
||||||
|
("2026-10-27T23:48:02+0300", "2026-10-27T20:48:02+00:00"),
|
||||||
|
("2026-10-27T23:48:02", None), # без смещения
|
||||||
|
("не дата", None),
|
||||||
|
(None, None),
|
||||||
|
(1234, None),
|
||||||
|
])
|
||||||
|
def test_history_utc(raw, expected):
|
||||||
|
assert history.utc(raw) == expected
|
||||||
Loading…
x
Reference in New Issue
Block a user