diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ec2c6be..b94510e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -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` | | `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` | | `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:` и `page:<код>`; HTML считается недоверенным | stdlib, `markdownify` | -| `stage` | приём пачек страниц от агента: проверка формы, перевод вне транзакции, стадия `wiki_sync_stage` | `core.db`, `convert` | +| `convert` | HTML Eva → Markdown (`markdownify`): замена ссылок по `ref` на `attachment:` и `page:<код>`; режим без ссылок на вложения для версий (`file_links=False`); HTML считается недоверенным | stdlib, `markdownify` | +| `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` | | `search` | поиск по FTS5 (запасной LIKE по `index.md`, если FTS5 в SQLite нет), фрагменты | `paths` | | `attachments` | вложения по запросу: состояние `none`/`fetching`/`ready`/`failed`, постановка `wiki_fetch`, хуки результата; адрес берётся только из `wiki_attachment` | `core.queue`, `core.files` | diff --git a/openspec/changes/wiki-history/design.md b/openspec/changes/wiki-history/design.md index 3ee6b8a..d507272 100644 --- a/openspec/changes/wiki-history/design.md +++ b/openspec/changes/wiki-history/design.md @@ -151,3 +151,35 @@ (`replace`), чтобы «назад» закрывал окно. Цена ошибки: ссылка на правку открывается не поверх ленты. - 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` «история: » / «история не загружена»; `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` снимается (адрес теперь страница + ядра). Цена ошибки: закладки на прежний адрес ленты перестают перенаправлять. diff --git a/src/sdlc_hub/modules/wiki/api.py b/src/sdlc_hub/modules/wiki/api.py index 476852c..e7726e2 100644 --- a/src/sdlc_hub/modules/wiki/api.py +++ b/src/sdlc_hub/modules/wiki/api.py @@ -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")) +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]: """(метка, строка wiki_project): без project — 422, неизвестный — 404, без корня — 409 no_root.""" label = project_param(conn, q) @@ -143,6 +147,7 @@ def h_attachment_content(hub, conn, m, q, body): ROUTES = ( ("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/tree$", h_tree, "client"), ("GET", r"^/wiki/pages/([^/]+)$", h_page, "client"), diff --git a/src/sdlc_hub/modules/wiki/convert.py b/src/sdlc_hub/modules/wiki/convert.py index 8bf57ed..62536c6 100644 --- a/src/sdlc_hub/modules/wiki/convert.py +++ b/src/sdlc_hub/modules/wiki/convert.py @@ -146,8 +146,15 @@ def _strip_editor_ui(soup) -> None: el.decompose() -def to_markdown(html: str, attachments: list[dict] | None, links: list[dict] | None) -> tuple[str, list[dict]]: - """HTML страницы → (Markdown, вложения страницы [{url, name}]).""" +_FILE_LINK = re.compile(r"\[📎 ((?:\\.|[^\]\\])*)\]\(attachment:@\d+\)") + + +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") _strip_editor_ui(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 = md.replace("attachment:@", "attachment:‌@") # авторское «attachment:@» больше не совпадает с шаблоном 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 diff --git a/src/sdlc_hub/modules/wiki/history.py b/src/sdlc_hub/modules/wiki/history.py new file mode 100644 index 0000000..af58bfc --- /dev/null +++ b/src/sdlc_hub/modules/wiki/history.py @@ -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() diff --git a/src/sdlc_hub/modules/wiki/schema.py b/src/sdlc_hub/modules/wiki/schema.py index 0deea3c..d47f495 100644 --- a/src/sdlc_hub/modules/wiki/schema.py +++ b/src/sdlc_hub/modules/wiki/schema.py @@ -43,4 +43,24 @@ CREATE TABLE IF NOT EXISTS wiki_sync_stage ( payload TEXT NOT NULL, 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); """ diff --git a/src/sdlc_hub/modules/wiki/stage.py b/src/sdlc_hub/modules/wiki/stage.py index b62a10e..39cdb11 100644 --- a/src/sdlc_hub/modules/wiki/stage.py +++ b/src/sdlc_hub/modules/wiki/stage.py @@ -3,10 +3,12 @@ import json from ...core import db, queue from ...core.http import ApiError -from . import convert +from . import convert, history MAX_PAGES = 50 MAX_STAGE = 20000 # страниц одной команды в стадии (между пачками) +MAX_VERSIONS = 50 # версий в одной пачке (#140) +MAX_VERSION_STAGE = 20000 # версий одной команды в стадии (между пачками) LIMITS = {"code": 200, "title": 1000, "url": 4000, "parent_code": 200, "eva_id": 200} @@ -35,6 +37,20 @@ def _clean(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: """Форма одной страницы → поля для стадии (без html); ApiError 422 `<код>: <поле>`.""" if not isinstance(page, dict): @@ -53,18 +69,10 @@ def _check(i, page) -> dict: 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"]): raise _bad(code, "eva_id") - attachments, links = page.get("attachments") or [], page.get("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 _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") + attachments, links = _refs(page.get("attachments"), page.get("links"), lambda f: _bad(code, f)) 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, - "attachments": [{"ref": a["ref"], "url": a["url"], "name": a.get("name") or ""} for a in attachments], - "links": [{"ref": l["ref"], "code": l["code"]} for l in links]} + "attachments": attachments, "links": links} 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} страниц") conn.executemany("INSERT OR REPLACE INTO wiki_sync_stage(command_id, code, payload) VALUES (?, ?, ?)", 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`: `версия : …` до кода, + `<код>: <поле>` после (#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)} diff --git a/tests/fixtures/baseline/routes.expected.json b/tests/fixtures/baseline/routes.expected.json index e2e48ac..8d5ef4c 100644 --- a/tests/fixtures/baseline/routes.expected.json +++ b/tests/fixtures/baseline/routes.expected.json @@ -77,6 +77,12 @@ "agent", "h_agent_pages" ], + [ + "POST", + "^/agent/wiki/versions$", + "agent", + "h_agent_versions" + ], [ "GET", "^/agents$", diff --git a/tests/fixtures/baseline/schema.expected.json b/tests/fixtures/baseline/schema.expected.json index ca2c5fe..b829f29 100644 --- a/tests/fixtures/baseline/schema.expected.json +++ b/tests/fixtures/baseline/schema.expected.json @@ -35,6 +35,16 @@ "ix_jira_sprint_board", "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", "agent_status", @@ -125,6 +135,11 @@ "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)" ], + [ + "table", + "wiki_history", + "CREATE TABLE wiki_history (label TEXT PRIMARY KEY REFERENCES project(label), history_at TEXT NOT NULL)" + ], [ "table", "wiki_page", @@ -139,6 +154,16 @@ "table", "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)" + ], + [ + "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 diff --git a/tests/test_wiki_convert.py b/tests/test_wiki_convert.py index cbc05de..175fa88 100644 --- a/tests/test_wiki_convert.py +++ b/tests/test_wiki_convert.py @@ -347,3 +347,13 @@ def test_empty_external_link_dropped(): """Финальное ревью M-1: пустая внешняя ссылка без вложения внутри не выводится адресом.""" md, _ = convert.to_markdown('

abc

', [], []) 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('

т

', [], [LINK], file_links=False) + assert md == "[т](page:DOC-1)\n" diff --git a/tests/test_wiki_history.py b/tests/test_wiki_history.py new file mode 100644 index 0000000..b8fc389 --- /dev/null +++ b/tests/test_wiki_history.py @@ -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="

текст

", 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", "

статус новый

")]}) + 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