From 4f78c27984a09faf2027166a026197bce9cbe53f Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 14:10:35 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=85=D0=B5=D0=BC=D0=B0=20v6:=20=D1=81?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=BE=D0=BA=20=D0=B2=D0=BB=D0=BE=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=20=D0=B2=20?= =?UTF-8?q?=D0=B7=D0=B5=D1=80=D0=BA=D0=B0=D0=BB=D0=B5=20(#83)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 4 +-- src/jira_hub/api.py | 1 + src/jira_hub/db.py | 20 +++++++++++++- src/jira_hub/mirror.py | 33 ++++++++++++++++++++++- tests/test_api.py | 13 ++++++++++ tests/test_db.py | 37 ++++++++++++++++++++------ tests/test_mirror.py | 59 ++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 155 insertions(+), 12 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0150036..fee1273 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -10,7 +10,7 @@ | `db` | схема SQLite, миграции по `schema_version` (копия базы перед миграцией), `connect`, время Jira → UTC (`jira_utc`) | `backup`, `paths`, `projects` (ленивый импорт в миграции v3→v4) | | `settings` | настройки (`setting`), токены, темп, перенос из `config.example.json` | `db` | | `projects` | проекты-метки: таблица `project` (метка, название, `board_id`, `board_type`), проверка метки, базовая JQL из меток проектов (`base_jql`), доски (`boards`), миграция схемы v3→v4 (`migrate_v3`) | `db`, `settings` | -| `mirror` | зеркало задач, связей и комментариев (время комментария в UTC — `created_at`), разрез по проекту (свои, соседи по связям, эпики своих), `apply_state` (с проверкой `check_comments`), заметки, фильтры, снимок доски `board`, карточка `get_issue` (с историей задачи из `history`) | `db`, `history` | +| `mirror` | зеркало задач, связей, комментариев (время комментария в UTC — `created_at`) и списка вложений `issue_attachment` (схема v6; `check_attachments`, пишет `apply_state`; `attachment_file` ему не принадлежит), разрез по проекту (свои, соседи по связям, эпики своих), `apply_state` (с проверками `check_comments`, `check_attachments`), заметки, фильтры, снимок доски `board`, карточка `get_issue` (с историей задачи из `history`) | `db`, `history` | | `history` | журнал изменений задач `issue_change` (схема v5): проверка и запись пачек агента (`check_changes`, `record_changes`, логическое имя поля `logical_field`), параметр `history` для выдачи `refresh` (`delivery_param`: отметка — последний полный прогон с `sync_run.history_at`, минус 15 минут; стартовая JQL `backfill_jql`), признак принятого журнала `accepted`, лента `feed` (журнал + новые комментарии по `issue_comment.created_at`) и история задачи `issue_changes` | `db`, `settings`, `projects` | | `commands` | контракт типов команд, валидация, временные ключи, оценка, подтверждение по состоянию | `mirror` | | `queue` | пакеты, проверки постановки (`_check_setup`: `bug` без настройки, файл вложения; `_check_project`: проверки пакета проекта — метка, чужие метки, задачи вне проекта; `_assign_boards`: `board_id` проекта у `ensure_sprint`), выдача (`claim`, `size` у `ensure_attachment` и `boards` у `refresh` — `deliver_params`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `files`, `projects`, `db` | @@ -56,7 +56,7 @@ - Токены: агентский — только `/agent/*`, клиентский — остальное; MCP берёт клиентский токен из базы через `HubClient.from_home`. ## Границы -- Зеркало пишет только `mirror.apply_state` по запросу агента (и заметки через `PUT /issues/{key}/note`); из очереди и API задач напрямую не правят. +- Зеркало (включая `issue_attachment`) пишет только `mirror.apply_state` по запросу агента (и заметки через `PUT /issues/{key}/note`); из очереди и API задач напрямую не правят. - Журнал `issue_change` пишет только `history.record_changes` по `POST /agent/history`; API и MCP его только читают. - Хаб не знает пароль Jira и не обращается к Jira; `init` не переносит `auth`, `base_url`, `verify_ssl`, `ca_bundle`. - `queue` не создаёт команд сам: повтор после failed — новый пакет. Выданная без отчёта команда довыдаётся при следующем `claim` (до 3 выдач, затем failed). diff --git a/src/jira_hub/api.py b/src/jira_hub/api.py index 18b102a..ffc32b3 100644 --- a/src/jira_hub/api.py +++ b/src/jira_hub/api.py @@ -201,6 +201,7 @@ def h_agent_state(hub, conn, m, q, body): issues = body.get("issues") or [] try: mirror.check_comments(issues) # до записи: неверная пачка не применяется целиком + mirror.check_attachments(issues) except ValueError as e: raise ApiError(422, "bad_state", str(e)) with db.transaction(conn): diff --git a/src/jira_hub/db.py b/src/jira_hub/db.py index 8647a0b..dcb089b 100644 --- a/src/jira_hub/db.py +++ b/src/jira_hub/db.py @@ -8,7 +8,7 @@ from pathlib import Path from . import backup, paths -SCHEMA_VERSION = 5 +SCHEMA_VERSION = 6 SCHEMA = """ CREATE TABLE IF NOT EXISTS setting ( @@ -53,6 +53,23 @@ CREATE TABLE IF NOT EXISTS issue_comment ( created_at TEXT, PRIMARY KEY (key, comment_id) ); +CREATE TABLE IF NOT EXISTS issue_attachment ( + attachment_id TEXT PRIMARY KEY, + key TEXT NOT NULL, + filename TEXT NOT NULL, + size INTEGER, + mime_type TEXT, + author TEXT, + created TEXT, + created_at TEXT +); +CREATE INDEX IF NOT EXISTS issue_attachment_key ON issue_attachment(key); +CREATE TABLE IF NOT EXISTS attachment_file ( + attachment_id TEXT PRIMARY KEY, + file TEXT NOT NULL, + size INTEGER NOT NULL, + fetched_at TEXT NOT NULL +); CREATE TABLE IF NOT EXISTS issue_change ( history_id TEXT NOT NULL, seq INTEGER NOT NULL, @@ -166,6 +183,7 @@ MIGRATIONS = { 3: ["ALTER TABLE batch ADD COLUMN project TEXT", _migrate_projects], # v3 → v4: проекты-метки 4: [_add_column("issue_comment", "created_at"), _add_column("sync_run", "history_at"), # v4 → v5: журнал изменений _fill_comment_utc], + 5: [], # v5 → v6: issue_attachment и attachment_file создаются SCHEMA (CREATE TABLE IF NOT EXISTS), данных переносить не нужно } diff --git a/src/jira_hub/mirror.py b/src/jira_hub/mirror.py index 0fb3184..4acac0a 100644 --- a/src/jira_hub/mirror.py +++ b/src/jira_hub/mirror.py @@ -1,4 +1,4 @@ -"""Зеркало задач Jira (issue, issue_link, issue_comment) и служебные заметки (issue_note). +"""Зеркало задач Jira (issue, issue_link, issue_comment, issue_attachment) и служебные заметки (issue_note). Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает.""" import json @@ -28,6 +28,30 @@ def check_comments(issues) -> None: if c.get(f) is not None and not isinstance(c[f], str): raise ValueError(f"{where}.{f} — строка или null") +def check_attachments(issues) -> None: + """Форма поля attachments у задач состояния (агент v14+); поле необязательно. Нарушение — ValueError с ключом и полем.""" + for it in issues: + if "attachments" not in it: + continue + key, lst = it.get("key"), it["attachments"] + if not isinstance(lst, list): + raise ValueError(f"{key}: attachments — список вложений") + for n, a in enumerate(lst): + where = f"{key}: attachments[{n}]" + if not isinstance(a, dict): + raise ValueError(f"{where} — объект вложения") + if not isinstance(a.get("id"), str) or not a["id"]: + raise ValueError(f"{where}.id — непустая строка") + if not isinstance(a.get("filename"), str) or not a["filename"]: + raise ValueError(f"{where}.filename — непустая строка") + size = a.get("size") + if size is not None and (not isinstance(size, int) or isinstance(size, bool) or size < 0): + raise ValueError(f"{where}.size — неотрицательное целое или null") + for f in ("mime_type", "author", "created"): + if a.get(f) is not None and not isinstance(a[f], str): + raise ValueError(f"{where}.{f} — строка или null") + + _FILTERS = {"sprint": "i.sprint = ?", "assignee": "i.assignee = ?", "status": "i.status = ?", "epic": "i.epic_key = ?", "work_kind": "n.work_kind = ?", "fact_status": "n.fact_status = ?"} @@ -62,6 +86,13 @@ def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authorit "VALUES (?, ?, ?, ?, ?, ?, ?)", [(it["key"], c["id"], c.get("author"), c.get("created"), c.get("updated"), c.get("body") or "", db.jira_utc(c.get("created"))) for c in it["comments"]]) + if isinstance(it.get("attachments"), list): # без поля (агент до v14) вложения не трогаем; attachment_file не трогаем никогда + conn.execute("DELETE FROM issue_attachment WHERE key = ?", (it["key"],)) + conn.executemany( # REPLACE по attachment_id: вложение, переехавшее на другой ключ, перезаписывается + "INSERT OR REPLACE INTO issue_attachment(attachment_id, key, filename, size, mime_type, author, created, created_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)", + [(a["id"], it["key"], a["filename"], a.get("size"), a.get("mime_type"), a.get("author"), a.get("created"), + db.jira_utc(a.get("created"))) for a in it["attachments"]]) conn.executemany("DELETE FROM issue_link WHERE source_key = ?", [(k,) for k in keys]) for link in links: conn.execute("INSERT OR IGNORE INTO issue_link(source_key, target_key, link_type) VALUES (?, ?, ?)", diff --git a/tests/test_api.py b/tests/test_api.py index 3ad6f5e..7f87bac 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -632,6 +632,19 @@ def test_agent_state_comments_validated_whole_batch(hub): assert e.value.status == 404 +def test_agent_state_bad_attachment_422(hub): + a, c = hub["agent"], hub["client"] + with pytest.raises(HubError) as e: + a.post("/agent/state", {"issues": [{"key": "X-1", "summary": "Икс", "labels": []}, + {"key": "Y-1", "summary": "Игрек", "labels": [], + "attachments": [{"id": "7", "filename": "a", "size": -1}]}], "links": []}) + assert e.value.status == 422 and e.value.error == "bad_state" + assert "Y-1" in e.value.detail and "attachments[0].size" in e.value.detail + with pytest.raises(HubError) as e: + c.get("/issues/X-1") + assert e.value.status == 404 # пачка не применена целиком + + def test_issue_detail_with_comments(hub): hub["agent"].post("/agent/state", {"issues": [ {"key": "A-1", "summary": "Схема", "labels": [], "epic_key": "E-1", "comments": [ diff --git a/tests/test_db.py b/tests/test_db.py index c4d562f..904b4cf 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -18,14 +18,21 @@ def test_connect_creates_schema(open_db): assert "created_at" in {r["name"] for r in conn.execute("PRAGMA table_info(issue_comment)")} assert "history_at" in {r["name"] for r in conn.execute("PRAGMA table_info(sync_run)")} assert "project" in {r["name"] for r in conn.execute("PRAGMA table_info(batch)")} - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6" assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 1 cols = {r["name"] for r in conn.execute("PRAGMA table_info(command)")} assert "deliveries" in cols +def _drop_v6(conn): + """Снять с базы текущей схемы то, что добавила v6 (вложения).""" + conn.execute("DROP TABLE attachment_file") + conn.execute("DROP TABLE issue_attachment") + + def _drop_v5(conn): - """Снять с базы текущей схемы то, что добавила v5: так строятся базы прежних версий.""" + """Снять с базы текущей схемы то, что добавили v5 и v6: так строятся базы прежних версий.""" + _drop_v6(conn) conn.execute("DROP TABLE issue_change") conn.execute("ALTER TABLE issue_comment DROP COLUMN created_at") conn.execute("ALTER TABLE sync_run DROP COLUMN history_at") @@ -51,7 +58,7 @@ def test_migrates_v1_with_backup(tmp_path): path = tmp_path / "hub.sqlite" _v1_database(path) conn = db.connect(path) - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6" rows = conn.execute("SELECT status, deliveries FROM command ORDER BY id").fetchall() assert [(r["status"], r["deliveries"]) for r in rows] == [("claimed", 1), ("done", 0), ("queued", 0)] assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе" @@ -198,7 +205,7 @@ def test_parallel_connect_makes_one_backup(tmp_path): assert errors == [] assert len(list((tmp_path / "backups").glob("*-schema-v1.sqlite"))) == 1 conn = db.connect(path) - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6" conn.close() @@ -215,7 +222,7 @@ def test_migrates_v2_with_backup(tmp_path): conn.execute("UPDATE setting SET value = '2' WHERE key = 'schema_version'") conn.close() conn = db.connect(path) - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6" assert conn.execute("SELECT COUNT(*) AS n FROM issue_comment").fetchone()["n"] == 0 assert conn.execute("SELECT summary FROM issue WHERE key = 'A-1'").fetchone()["summary"] == "Т" assert conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе" @@ -283,7 +290,7 @@ def test_migrates_v3_creates_awpca(tmp_path): queued = [json.loads(r["params"])["jql"] for r in conn.execute("SELECT params FROM command WHERE status = 'queued'")] assert sorted(queued) == sorted([new, "key = A-1"]) assert conn.execute("SELECT project FROM batch").fetchall()[0]["project"] is None - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6" assert len(list((tmp_path / "backups").glob("*-schema-v3.sqlite"))) == 1 conn.close() @@ -302,7 +309,7 @@ def test_migrates_v3_unparsed_jql_keeps_running(tmp_path, caplog): conn = db.connect(tmp_path / "hub.sqlite") assert projects.list_projects(conn) == [] assert "ORDER BY key" in caplog.text - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6" conn.close() @@ -336,10 +343,24 @@ def test_migrates_v4(tmp_path): path = tmp_path / "hub.sqlite" _v4_database(path) conn = db.connect(path) - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6" rows = {r["comment_id"]: (r["created"], r["created_at"]) for r in conn.execute("SELECT * FROM issue_comment")} assert rows == {"1": ("2026-10-22T13:05:11.000+0300", "2026-10-22T10:05:11+00:00"), "2": ("мусор", None)} assert conn.execute("SELECT history_at FROM sync_run").fetchone()["history_at"] is None assert conn.execute("SELECT COUNT(*) AS n FROM issue_change").fetchone()["n"] == 0 assert len(list((tmp_path / "backups").glob("*-schema-v4.sqlite"))) == 1 conn.close() + + +def test_migrate_v5_to_v6(tmp_path): + path = tmp_path / "hub.sqlite" + conn = db.connect(path) + _drop_v6(conn) + conn.execute("UPDATE setting SET value = '5' WHERE key = 'schema_version'") + conn.close() + conn = db.connect(path) + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "6" + assert conn.execute("SELECT COUNT(*) AS n FROM issue_attachment").fetchone()["n"] == 0 + assert conn.execute("SELECT COUNT(*) AS n FROM attachment_file").fetchone()["n"] == 0 + assert len(list((tmp_path / "backups").glob("*-schema-v5.sqlite"))) == 1 + conn.close() diff --git a/tests/test_mirror.py b/tests/test_mirror.py index aa093dd..14345c3 100644 --- a/tests/test_mirror.py +++ b/tests/test_mirror.py @@ -319,3 +319,62 @@ def test_apply_state_comment_created_at(conn): {"id": "2", "body": "b", "created": "мусор"}]}], []) rows = {r["comment_id"]: r["created_at"] for r in conn.execute("SELECT * FROM issue_comment")} assert rows == {"1": "2026-10-22T10:05:11+00:00", "2": None} + + +def attachment(aid, **over): + base = {"id": aid, "filename": f"файл{aid}.png", "size": 1000, "mime_type": "image/png", "author": "Петров Пётр", + "created": "2026-10-14T12:00:00.000+0300"} + base.update(over) + return base + + +def _atts(conn, key="A-1"): + return {r["attachment_id"]: dict(r) for r in conn.execute("SELECT * FROM issue_attachment WHERE key = ?", (key,))} + + +def test_attachments_replace(conn): + mirror.apply_state(conn, [issue("A-1", attachments=[attachment("1"), attachment("2")])], []) + mirror.apply_state(conn, [issue("A-1", attachments=[attachment("2", filename="новое.pdf", size=5), attachment("3")])], []) + got = _atts(conn) + assert sorted(got) == ["2", "3"] + assert got["2"]["filename"] == "новое.pdf" and got["2"]["size"] == 5 + assert got["2"]["created_at"] == "2026-10-14T09:00:00+00:00" + + +def test_attachments_absent_kept(conn): + mirror.apply_state(conn, [issue("A-1", attachments=[attachment("1")])], []) + mirror.apply_state(conn, [issue("A-1", summary="Без поля")], []) + assert sorted(_atts(conn)) == ["1"] + + +def test_attachment_file_untouched(conn): + mirror.apply_state(conn, [issue("A-1", attachments=[attachment("1")])], []) + conn.execute("INSERT INTO attachment_file(attachment_id, file, size, fetched_at) VALUES ('1', 'abc', 1000, '2026-10-22T10:00:00+00:00')") + mirror.apply_state(conn, [issue("A-1", attachments=[])], []) + assert _atts(conn) == {} + assert conn.execute("SELECT file FROM attachment_file WHERE attachment_id = '1'").fetchone()["file"] == "abc" + + +def test_attachment_moves_to_other_key(conn): + mirror.apply_state(conn, [issue("A-1", attachments=[attachment("1")])], []) + mirror.apply_state(conn, [issue("A-2", attachments=[attachment("1")])], []) + assert _atts(conn, "A-1") == {} and sorted(_atts(conn, "A-2")) == ["1"] + + +@pytest.mark.parametrize("bad, field", [ + ("x", "attachments"), (["x"], "attachments[0]"), + ([{"id": "", "filename": "a"}], "attachments[0].id"), ([{"id": 5, "filename": "a"}], "attachments[0].id"), + ([{"id": "5", "filename": 1}], "attachments[0].filename"), ([{"id": "5", "filename": ""}], "attachments[0].filename"), + ([{"id": "5", "filename": "a", "size": -1}], "attachments[0].size"), + ([{"id": "5", "filename": "a", "size": "1"}], "attachments[0].size"), + ([{"id": "5", "filename": "a", "size": True}], "attachments[0].size"), + ([{"id": "5", "filename": "a", "mime_type": 5}], "attachments[0].mime_type"), +]) +def test_check_attachments_errors(bad, field): + with pytest.raises(ValueError) as e: + mirror.check_attachments([issue("A-1"), issue("A-2", attachments=bad)]) + assert str(e.value).startswith(f"A-2: {field}") + + +def test_check_attachments_accepts_missing_and_valid(): + mirror.check_attachments([issue("A-1"), issue("A-2", attachments=[attachment("1"), attachment("2", size=None, mime_type=None)])])