Схема v6: список вложений задач в зеркале (#83)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-22 14:10:35 +04:00
parent acabda547d
commit 4f78c27984
7 changed files with 155 additions and 12 deletions

View File

@ -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).

View File

@ -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):

View File

@ -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), данных переносить не нужно
}

View File

@ -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 (?, ?, ?)",

View File

@ -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": [

View File

@ -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()

View File

@ -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)])])