Схема v6: список вложений задач в зеркале (#83)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
acabda547d
commit
4f78c27984
@ -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).
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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), данных переносить не нужно
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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 (?, ?, ?)",
|
||||
|
||||
@ -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": [
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)])])
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user