Схема v8: без статуса по факту и его настроек (#104)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-24 16:59:05 +04:00
parent 5b01b91710
commit 8bab76e043
11 changed files with 122 additions and 35 deletions

View File

@ -8,7 +8,7 @@ from pathlib import Path
from . import backup, paths
SCHEMA_VERSION = 7
SCHEMA_VERSION = 8
CORE_SCHEMA = """
CREATE TABLE IF NOT EXISTS setting (
@ -67,6 +67,11 @@ def _project_fields_v7(conn) -> None:
projects.migrate_v6(conn)
def _note_fields_v8(conn) -> None:
from ..modules.jira import settings
settings.migrate_v7(conn)
def _fill_comment_utc(conn) -> None:
from ..modules.jira import history
history.fill_comment_utc(conn)
@ -130,6 +135,7 @@ MIGRATIONS = {
5: [], # v5 → v6: issue_attachment и attachment_file создаются схемой (CREATE TABLE IF NOT EXISTS), данных переносить не нужно
6: [_tokens_v7, _agent_status_v7,
_project_fields_v7], # v6 → v7: токены и статус по агентам модулей (wiki-module, решение 15)
7: [_note_fields_v8], # v7 → v8: без статуса по факту и его настроек (fact-status-removal)
}

View File

@ -77,7 +77,6 @@ CREATE INDEX IF NOT EXISTS ix_issue_change_at ON issue_change(at);
CREATE INDEX IF NOT EXISTS ix_issue_change_key ON issue_change(key, at);
CREATE TABLE IF NOT EXISTS issue_note (
key TEXT PRIMARY KEY,
fact_status TEXT,
work_kind TEXT,
done_text TEXT,
rest_text TEXT,

View File

@ -17,18 +17,16 @@ DEFAULTS = {
"priority_map": {},
"priority_value_key": "name",
"labels": [],
"status_labels": {},
"kind_labels": {},
"comment_marker": "",
"transitions_map": {},
"link_types": {},
"closed_statuses": ["Готово", "Снято"], # статусы Jira закрытых задач: список и доска приложения (hide-closed)
}
# Что получает агент от модуля (без токенов и служебных ключей хаба); settings_version добавляет ядро
AGENT_KEYS = ["project_key", "issue_types", "epic_name_field_id", "field_ids", "epic_link",
"priority_map", "priority_value_key", "labels", "status_labels", "kind_labels", "comment_marker",
"transitions_map", "link_types", "pacing", "min_agent_version"]
"priority_map", "priority_value_key", "labels", "kind_labels", "comment_marker",
"link_types", "pacing", "min_agent_version"]
def _is_int(v) -> bool:
@ -85,9 +83,20 @@ def _issue_types(cfg: dict) -> dict:
return out
def migrate_v7(conn) -> None:
"""Шаг миграции v7 → v8 (fact-status-removal): колонка issue_note.fact_status и ключи status_labels, transitions_map
удаляются безвозвратно; settings_version поднимается, если удалён хотя бы один ключ и строка версии есть."""
if "fact_status" in {r["name"] for r in conn.execute("PRAGMA table_info(issue_note)")}:
conn.execute("ALTER TABLE issue_note DROP COLUMN fact_status")
gone = conn.execute("DELETE FROM setting WHERE key IN ('status_labels', 'transitions_map')").rowcount
if gone:
conn.execute("UPDATE setting SET value = CAST(value AS INTEGER) + 1 WHERE key = 'settings_version'")
def init_from_config(conn, cfg: dict, force=False) -> list[str]:
"""Перенести из config.example.json всё, что относится к проекту Jira. Учётные данные и адрес Jira
(auth, base_url, verify_ssl, ca_bundle) остаются у агента и сюда не попадают."""
(auth, base_url, verify_ssl, ca_bundle) остаются у агента и сюда не попадают; status_labels и transitions — тоже
(статуса по факту в хабе нет, fact-status-removal)."""
if core_settings.get(conn, "initialized") and not force:
raise RuntimeError("хаб уже инициализирован; повторный init — с --force")
project = cfg["project_key"]
@ -100,10 +109,8 @@ def init_from_config(conn, cfg: dict, force=False) -> list[str]:
"priority_map": cfg.get("priority_map", {}),
"priority_value_key": cfg.get("priority_value_key", "name"),
"labels": cfg.get("labels", []),
"status_labels": cfg.get("status_labels", {}),
"kind_labels": cfg.get("kind_labels", {}),
"comment_marker": cfg.get("comment_marker", ""),
"transitions_map": cfg.get("transitions", {}).get("map", {}),
"link_types": cfg.get("link_types", {}),
"pacing": pacing_from_config(cfg.get("pacing", {})),
}

View File

@ -73,7 +73,7 @@
[
"table",
"issue_note",
"CREATE TABLE issue_note (\n key TEXT PRIMARY KEY,\n fact_status TEXT,\n work_kind TEXT,\n done_text TEXT,\n rest_text TEXT,\n owner_note TEXT,\n updated_at TEXT,\n updated_by TEXT\n)"
"CREATE TABLE issue_note (\n key TEXT PRIMARY KEY,\n work_kind TEXT,\n done_text TEXT,\n rest_text TEXT,\n owner_note TEXT,\n updated_at TEXT,\n updated_by TEXT\n)"
],
[
"table",
@ -116,5 +116,5 @@
"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)"
]
],
"schema_version": 7
"schema_version": 8
}

View File

@ -8,10 +8,8 @@
"priority_map",
"priority_value_key",
"labels",
"status_labels",
"kind_labels",
"comment_marker",
"transitions_map",
"link_types",
"pacing",
"min_agent_version",
@ -51,8 +49,6 @@
"project_key": "AURORA",
"relay_dir": null,
"settings_version": 1,
"status_labels": {},
"transitions_map": {},
"wiki_auto_sync_hours": 24,
"wiki_min_agent_version": 1,
"wiki_pacing": 0.2

View File

@ -22,7 +22,7 @@ def test_v6_upgrade_keeps_jira_agent(tmp_path):
assert tok == "T-agent"
code, body = api.h_claim(hub, conn, None, {"_agent": "jira"}, {"agent_version": 14})
assert code == 200 and body["agent_release"]["version"] == 14 # релиз v14 перенесён в release/jira/
assert db._schema_version(conn) == 7
assert db._schema_version(conn) == db.SCHEMA_VERSION
tok = settings.tokens(conn)
assert tok["client"] == "T-client" and tok["agents"]["jira"] == "T-agent" and "agent" not in tok
row = conn.execute("SELECT * FROM agent_status WHERE agent = 'jira'").fetchone()
@ -79,7 +79,8 @@ def test_module_agent_route_requires_own_module(hub_server_two_modules):
def test_agent_settings_per_module(hub_server_two_modules):
jira = hub_server_two_modules["agents"]["jira"].get("/agent/settings")
stub = hub_server_two_modules["agents"]["stub"].get("/agent/settings")
assert "project_key" in jira and "transitions_map" in jira and "settings_version" in jira
assert "project_key" in jira and "link_types" in jira and "settings_version" in jira
assert "transitions_map" not in jira and "status_labels" not in jira
assert "stub_key" not in jira and "board_id" not in jira
assert set(stub) == {"stub_key", "settings_version"}
@ -318,7 +319,7 @@ def test_v14_agent_over_http_on_v6_base(tmp_path):
conn.close()
expected = json.loads((Path(__file__).parent / "fixtures" / "settings_v6.json").read_text(encoding="utf-8"))["agent_keys"]
got = a.get("/agent/settings")
assert set(got) == set(expected) - {"board_id"}
assert set(got) == set(expected) - {"board_id", "status_labels", "transitions_map"} # fact-status-removal
assert a.get("/agent/release")["version"] == 14
assert a.post("/agent/state", {"issues": [{"key": "A-1", "summary": "Т", "labels": []}], "links": [],
"neighbor_keys": []}) == {"issues": 1, "links": 0}

View File

@ -119,7 +119,7 @@ def test_interface_routes(hub, add_project):
assert e.value.status == 422 and "неизвестные поля заметки: fact_status" in e.value.detail
assert c.get("/issues/A-1")["note"] is None # и work_kind не записан
note = c.put("/issues/A-1/note", {"work_kind": "Аналитика", "updated_by": "owner"})
assert note["work_kind"] == "Аналитика" and note["updated_by"] == "owner" and note.get("fact_status") is None
assert note["work_kind"] == "Аналитика" and note["updated_by"] == "owner" and "fact_status" not in note
with pytest.raises(HubError) as e:
c.put("/issues/A-1/note", {"summary": "x"})
assert e.value.status == 422

View File

@ -13,7 +13,7 @@ def test_init_import_tokens(tmp_path, monkeypatch, capsys, open_db):
monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "home"))
assert cli.main(["init", "--config", str(FIXTURE)]) == 0
out = capsys.readouterr().out
assert "transitions_map" in out and "агент jira: " in out
assert "link_types" in out and "transitions_map" not in out and "агент jira: " in out
assert cli.main(["init", "--config", str(FIXTURE)]) == 1
assert "уже инициализирован" in capsys.readouterr().err
conn = open_db(paths.db_path())

View File

@ -19,12 +19,23 @@ 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"] == "7"
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == str(db.SCHEMA_VERSION)
assert conn.execute("SELECT COUNT(*) AS n FROM agent_status").fetchone()["n"] == 2
cols = {r["name"] for r in conn.execute("PRAGMA table_info(command)")}
assert "deliveries" in cols
def _add_fact_status(conn):
"""Колонка issue_note.fact_status, как в базах до v8 (fact-status-removal)."""
conn.execute("ALTER TABLE issue_note ADD COLUMN fact_status TEXT")
def _note_after_v8(conn, key):
"""После миграции до v8: колонки fact_status нет; work_kind заметки."""
assert "fact_status" not in {r["name"] for r in conn.execute("PRAGMA table_info(issue_note)")}
return conn.execute("SELECT work_kind FROM issue_note WHERE key = ?", (key,)).fetchone()["work_kind"]
def _drop_v6(conn):
"""Снять с базы текущей схемы то, что добавила v6 (вложения)."""
conn.execute("DROP TABLE attachment_file")
@ -46,7 +57,8 @@ def _v1_database(path):
for status in ("claimed", "done", "queued"):
conn.execute("INSERT INTO command(batch_id, seq, type, params, status, claimed_at) VALUES (1, 1, 'refresh', '{}', ?, ?)",
(status, "2026-10-13T00:00:00+00:00" if status == "claimed" else None))
conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')")
_add_fact_status(conn)
conn.execute("INSERT INTO issue_note(key, fact_status, work_kind) VALUES ('A-1', 'В работе', 'Аналитика')")
_drop_v5(conn)
conn.execute("ALTER TABLE command DROP COLUMN deliveries")
conn.execute("ALTER TABLE batch DROP COLUMN project")
@ -59,10 +71,10 @@ 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"] == "7"
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == str(db.SCHEMA_VERSION)
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"] == "В работе"
assert _note_after_v8(conn, "A-1") == "Аналитика"
copies = list((tmp_path / "backups").glob("*-schema-v1.sqlite"))
assert len(copies) == 1
old = sqlite3.connect(copies[0]) # не db.connect — он бы мигрировал копию
@ -206,7 +218,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"] == "7"
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == str(db.SCHEMA_VERSION)
conn.close()
@ -215,7 +227,8 @@ def test_migrates_v2_with_backup(tmp_path):
path = tmp_path / "hub.sqlite"
conn = db.connect(path)
conn.execute("INSERT INTO issue(key, summary, fetched_at) VALUES ('A-1', 'Т', '2026-10-15T00:00:00+00:00')")
conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')")
_add_fact_status(conn)
conn.execute("INSERT INTO issue_note(key, fact_status, work_kind) VALUES ('A-1', 'В работе', 'Аналитика')")
_drop_v5(conn)
conn.execute("DROP TABLE issue_comment")
conn.execute("ALTER TABLE batch DROP COLUMN project")
@ -223,10 +236,10 @@ 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"] == "7"
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == str(db.SCHEMA_VERSION)
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"] == "В работе"
assert _note_after_v8(conn, "A-1") == "Аналитика"
copies = list((tmp_path / "backups").glob("*-schema-v2.sqlite"))
assert len(copies) == 1
conn.close()
@ -267,7 +280,8 @@ def _v3_database(path, base_jql="project = AURORA AND labels = AWPCA", board_id=
conn.execute("INSERT INTO sync_run(started_at, finished_at, kind, query, command_id) VALUES (?, ?, 'jql', ?, ?)",
(ts, ts, base_jql, cid))
conn.execute("INSERT INTO issue(key, summary, fetched_at) VALUES ('A-1', 'Т', ?)", (ts,))
conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')")
_add_fact_status(conn)
conn.execute("INSERT INTO issue_note(key, fact_status, work_kind) VALUES ('A-1', 'В работе', 'Аналитика')")
_drop_v5(conn)
conn.execute("ALTER TABLE batch DROP COLUMN project")
conn.execute("DROP TABLE project")
@ -292,7 +306,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"] == "7"
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == str(db.SCHEMA_VERSION)
assert len(list((tmp_path / "backups").glob("*-schema-v3.sqlite"))) == 1
conn.close()
@ -311,7 +325,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"] == "7"
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == str(db.SCHEMA_VERSION)
conn.close()
@ -345,7 +359,7 @@ 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"] == "7"
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == str(db.SCHEMA_VERSION)
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
@ -361,8 +375,70 @@ def test_migrate_v5_to_v6(tmp_path):
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"] == "7"
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == str(db.SCHEMA_VERSION)
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()
def _v7_database(path, settings_version=12, with_keys=True):
"""База v7: заметка со статусом по факту, ключи status_labels/transitions_map в setting (fact-status-removal)."""
conn = db.connect(path)
settings.ensure_defaults(conn)
_add_fact_status(conn)
conn.execute("INSERT INTO issue_note(key, fact_status, work_kind, updated_at, updated_by) "
"VALUES ('A-1', 'В работе', 'Аналитика', '2026-10-20T00:00:00+00:00', 'import')")
rows = [("settings_version", settings_version)]
if with_keys:
rows += [("status_labels", {"В работе": "fact-in-progress"}), ("transitions_map", {"Снято": "Снято"})]
for key, value in rows:
conn.execute("INSERT OR REPLACE INTO setting(key, value) VALUES (?, ?)", (key, json.dumps(value, ensure_ascii=False)))
conn.execute("UPDATE setting SET value = '7' WHERE key = 'schema_version'")
conn.close()
def test_migrates_v7_drops_fact_status(tmp_path):
path = tmp_path / "hub.sqlite"
_v7_database(path)
conn = db.connect(path)
assert db._schema_version(conn) == 8
assert _note_after_v8(conn, "A-1") == "Аналитика"
row = conn.execute("SELECT updated_at, updated_by FROM issue_note WHERE key = 'A-1'").fetchone()
assert (row["updated_at"], row["updated_by"]) == ("2026-10-20T00:00:00+00:00", "import")
assert settings.get(conn, "status_labels") is None and settings.get(conn, "transitions_map") is None
assert settings.get(conn, "settings_version") == 13
assert len(list((tmp_path / "backups").glob("*-schema-v7.sqlite"))) == 1
conn.close()
def test_migrates_v7_without_keys_keeps_settings_version(tmp_path):
path = tmp_path / "hub.sqlite"
_v7_database(path, with_keys=False)
conn = db.connect(path)
assert db._schema_version(conn) == 8 and settings.get(conn, "settings_version") == 12
conn.close()
def test_migrates_v7_without_settings_version(tmp_path):
path = tmp_path / "hub.sqlite"
_v7_database(path)
raw = sqlite3.connect(path)
raw.execute("DELETE FROM setting WHERE key = 'settings_version'")
raw.commit()
raw.close()
conn = db.connect(path)
assert db._schema_version(conn) == 8
assert conn.execute("SELECT COUNT(*) AS n FROM setting WHERE key = 'settings_version'").fetchone()["n"] == 0
conn.close()
def test_v8_reconnect_is_stable(tmp_path):
path = tmp_path / "hub.sqlite"
_v7_database(path)
db.connect(path).close()
conn = db.connect(path)
settings.ensure_defaults(conn)
assert settings.get(conn, "settings_version") == 13
assert "status_labels" not in settings.get_all(conn) and "transitions_map" not in settings.get_all(conn)
conn.close()

View File

@ -48,7 +48,7 @@ def test_import_notes_and_report(conn, tmp_path):
rep = importer.import_package(conn, tmp_path)
assert rep["notes"] == 6
n = mirror.get_note(conn, "AURORA-1533")
assert n["work_kind"] == "тестирование" and n.get("fact_status") is None # колонка CSV пропускается (fact-status-removal)
assert n["work_kind"] == "тестирование" and "fact_status" not in n # колонка CSV пропускается (fact-status-removal)
assert n["rest_text"] == "Прогнать на стенде" and n["owner_note"] == "Актуализация 10.10.2026. Порядок" and n["updated_by"] == "import"
assert mirror.get_note(conn, "AURORA-1416")["done_text"] == "Сделан список"
assert mirror.get_note(conn, "AURORA-1586")["rest_text"] == "решение 11.10"

View File

@ -55,7 +55,9 @@ def test_protected_keys(conn, key):
def test_init_from_config(conn):
cfg = json.loads(FIXTURE.read_text(encoding="utf-8"))
keys = jira_settings.init_from_config(conn, cfg)
assert "transitions_map" in keys and "base_jql" not in keys and "board_id" not in keys
assert "link_types" in keys and "base_jql" not in keys and "board_id" not in keys
assert "transitions_map" not in keys and "status_labels" not in keys # fact-status-removal
assert {"transitions_map", "status_labels"}.isdisjoint(settings.get_all(conn))
assert "base_jql" not in settings.get_all(conn) and "board_id" not in settings.get_all(conn)
assert settings.get(conn, "field_ids")["planned_end"] == "customfield_10802"
assert settings.get(conn, "issue_types")["epic"] == {"name": "Фича", "id": "12300"}