diff --git a/src/jira_hub/db.py b/src/jira_hub/db.py index 58f3b71..0e091b6 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 = 2 +SCHEMA_VERSION = 3 SCHEMA = """ CREATE TABLE IF NOT EXISTS setting ( @@ -43,6 +43,15 @@ CREATE TABLE IF NOT EXISTS issue_link ( PRIMARY KEY (source_key, target_key, link_type) ); CREATE INDEX IF NOT EXISTS ix_issue_link_target ON issue_link(target_key); +CREATE TABLE IF NOT EXISTS issue_comment ( + key TEXT NOT NULL, + comment_id TEXT NOT NULL, + author TEXT, + created TEXT, + updated TEXT, + body TEXT NOT NULL DEFAULT '', + PRIMARY KEY (key, comment_id) +); CREATE TABLE IF NOT EXISTS issue_note ( key TEXT PRIMARY KEY, fact_status TEXT, @@ -105,6 +114,7 @@ CREATE TABLE IF NOT EXISTS agent_status ( MIGRATIONS = { 1: ["ALTER TABLE command ADD COLUMN deliveries INTEGER NOT NULL DEFAULT 0", "UPDATE command SET deliveries = 1 WHERE status = 'claimed'"], # уже выданные — одна выдача + 2: [], # v2 → v3: issue_comment создаётся SCHEMA (CREATE TABLE IF NOT EXISTS), данных переносить не нужно } diff --git a/src/jira_hub/mirror.py b/src/jira_hub/mirror.py index 1405e7c..923b670 100644 --- a/src/jira_hub/mirror.py +++ b/src/jira_hub/mirror.py @@ -1,4 +1,4 @@ -"""Зеркало задач Jira (issue, issue_link) и служебные заметки (issue_note). +"""Зеркало задач Jira (issue, issue_link, issue_comment) и служебные заметки (issue_note). Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает.""" import json @@ -8,6 +8,26 @@ ISSUE_COLUMNS = ["key", "id", "issue_type", "summary", "description", "status", "duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"] NOTE_FIELDS = ["fact_status", "work_kind", "done_text", "rest_text", "owner_note"] +def check_comments(issues) -> None: + """Форма поля comments у задач состояния (агент v10+); поле необязательно. Нарушение — ValueError с ключом и полем.""" + for it in issues: + if "comments" not in it: + continue + key, lst = it.get("key"), it["comments"] + if not isinstance(lst, list): + raise ValueError(f"{key}: comments — список комментариев") + for n, c in enumerate(lst): + where = f"{key}: comments[{n}]" + if not isinstance(c, dict): + raise ValueError(f"{where} — объект комментария") + if not isinstance(c.get("id"), str) or not c["id"]: + raise ValueError(f"{where}.id — непустая строка") + if not isinstance(c.get("body"), str) and c.get("body") is not None: + raise ValueError(f"{where}.body — строка") + for f in ("author", "created", "updated"): + if c.get(f) is not None and not isinstance(c[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 = ?"} @@ -35,6 +55,13 @@ def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authorit "ON CONFLICT(key) DO UPDATE SET " + ", ".join(sets), [row[c] for c in cols]) keys.append(it["key"]) + if isinstance(it.get("comments"), list): # без поля (агент до v10) комментарии не трогаем + conn.execute("DELETE FROM issue_comment WHERE key = ?", (it["key"],)) + conn.executemany( + "INSERT OR REPLACE INTO issue_comment(key, comment_id, author, created, updated, body) " + "VALUES (?, ?, ?, ?, ?, ?)", + [(it["key"], c["id"], c.get("author"), c.get("created"), c.get("updated"), c.get("body") or "") + for c in it["comments"]]) 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 (?, ?, ?)", @@ -80,10 +107,17 @@ def get_issue(conn, key) -> dict | None: if row is None: return None d = _issue_dict(row) + epic = conn.execute("SELECT summary FROM issue WHERE key = ?", (d["epic_key"],)).fetchone() if d.get("epic_key") else None + d["epic_summary"] = epic["summary"] if epic else None d["links_out"] = [dict(r) for r in conn.execute( - "SELECT target_key, link_type FROM issue_link WHERE source_key = ? ORDER BY link_type, target_key", (key,))] + "SELECT l.target_key, l.link_type, i.summary, i.status FROM issue_link l LEFT JOIN issue i ON i.key = l.target_key " + "WHERE l.source_key = ? ORDER BY l.link_type, l.target_key", (key,))] d["links_in"] = [dict(r) for r in conn.execute( - "SELECT source_key, link_type FROM issue_link WHERE target_key = ? ORDER BY link_type, source_key", (key,))] + "SELECT l.source_key, l.link_type, i.summary, i.status FROM issue_link l LEFT JOIN issue i ON i.key = l.source_key " + "WHERE l.target_key = ? ORDER BY l.link_type, l.source_key", (key,))] + d["comments"] = [dict(r) for r in conn.execute( + "SELECT comment_id, author, created, updated, body FROM issue_comment WHERE key = ? " + "ORDER BY created DESC, CAST(comment_id AS INTEGER) DESC, comment_id DESC", (key,))] d["note"] = get_note(conn, key) d["commands"] = [dict(r) for r in conn.execute( "SELECT id, batch_id, type, status, finished_at, error FROM command WHERE params LIKE ? ORDER BY id DESC LIMIT 10", diff --git a/tests/test_db.py b/tests/test_db.py index 25d788e..b0ba510 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -10,8 +10,9 @@ from jira_hub import backup, db, paths, queue, settings def test_connect_creates_schema(open_db): conn = open_db() names = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")} - assert {"setting", "issue", "issue_link", "issue_note", "batch", "command", "sync_run", "agent_status"} <= names - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "2" + assert {"setting", "issue", "issue_link", "issue_comment", "issue_note", "batch", "command", "sync_run", + "agent_status"} <= names + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "3" 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 @@ -34,7 +35,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"] == "2" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "3" 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"] == "В работе" @@ -181,7 +182,26 @@ 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"] == "2" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "3" + conn.close() + + +def test_migrates_v2_with_backup(tmp_path): + """База v2 (без issue_comment) с задачей и заметкой → v3: копия schema-v2, таблица комментариев пустая, данные на месте.""" + 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', 'В работе')") + conn.execute("DROP TABLE issue_comment") + 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"] == "3" + 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"] == "В работе" + copies = list((tmp_path / "backups").glob("*-schema-v2.sqlite")) + assert len(copies) == 1 conn.close() diff --git a/tests/test_e2e_hub.py b/tests/test_e2e_hub.py index f6b03aa..60c17b8 100644 --- a/tests/test_e2e_hub.py +++ b/tests/test_e2e_hub.py @@ -75,5 +75,6 @@ def test_create_link_sprint_flow(live): assert issue["note"]["work_kind"] == "разработка" assert len(issue["links_out"]) == 1 assert issue["links_out"][0]["target_key"] == "A-1" and issue["links_out"][0]["link_type"] == "ASUPR_Связанные" - assert client.get("/issues/A-1")["links_in"] == [{"source_key": "A-90", "link_type": "ASUPR_Связанные"}] + links_in = client.get("/issues/A-1")["links_in"] + assert [(l["source_key"], l["link_type"]) for l in links_in] == [("A-90", "ASUPR_Связанные")] assert client.get("/sync-runs")["runs"][0]["issues_count"] == 2 diff --git a/tests/test_mirror.py b/tests/test_mirror.py index 434be71..5215c94 100644 --- a/tests/test_mirror.py +++ b/tests/test_mirror.py @@ -27,15 +27,18 @@ def test_apply_state_upserts_and_replaces_links(conn): [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}]) got = mirror.get_issue(conn, "A-1") assert got["summary"] == "Новая тема" and got["labels"] == ["AWPCA"] - assert got["links_out"] == [{"target_key": "A-2", "link_type": "ASUPR_Связанные"}] - assert mirror.get_issue(conn, "A-2")["links_in"] == [{"source_key": "A-1", "link_type": "ASUPR_Связанные"}] + assert got["links_out"] == [{"target_key": "A-2", "link_type": "ASUPR_Связанные", "summary": "Тема A-2", + "status": "Ожидание разработки"}] + assert mirror.get_issue(conn, "A-2")["links_in"] == [{"source_key": "A-1", "link_type": "ASUPR_Связанные", + "summary": "Новая тема", "status": "Ожидание разработки"}] def test_links_from_other_source_survive(conn): mirror.apply_state(conn, [issue("A-1"), issue("A-2")], [{"source_key": "A-2", "target_key": "A-1", "link_type": "ASUPR_Связанные"}]) mirror.apply_state(conn, [issue("A-1")], []) # пришла только A-1 без связей - assert mirror.get_issue(conn, "A-1")["links_in"] == [{"source_key": "A-2", "link_type": "ASUPR_Связанные"}] + assert mirror.get_issue(conn, "A-1")["links_in"] == [{"source_key": "A-2", "link_type": "ASUPR_Связанные", + "summary": "Тема A-2", "status": "Ожидание разработки"}] def test_apply_state_marks_neighbors(conn): @@ -183,3 +186,57 @@ def test_board_inside_open_transaction_keeps_it_open(conn): assert conn.in_transaction assert not conn.in_transaction assert [i["key"] for i in snap["issues"]] == ["AURORA-1620"] + + +def comment(cid, created="2026-10-14T12:00:00.000+0300", **over): + base = {"id": cid, "author": "Петров Пётр", "created": created, "updated": created, "body": f"текст {cid}"} + base.update(over) + return base + + +def test_comments_replaced_and_kept_without_field(conn): + mirror.apply_state(conn, [issue("A-1", comments=[comment("101"), comment("102")])], []) + mirror.apply_state(conn, [issue("A-1", comments=[comment("102", body="новый текст")])], []) + got = mirror.get_issue(conn, "A-1")["comments"] + assert [(c["comment_id"], c["body"]) for c in got] == [("102", "новый текст")] + mirror.apply_state(conn, [issue("A-1", summary="Без поля comments")], []) # агент до v10 + assert [c["comment_id"] for c in mirror.get_issue(conn, "A-1")["comments"]] == ["102"] + mirror.apply_state(conn, [issue("A-1", comments=[])], []) + assert mirror.get_issue(conn, "A-1")["comments"] == [] + + +def test_comments_newest_first_and_shape(conn): + mirror.apply_state(conn, [issue("A-1", comments=[ + comment("9", created="2026-10-14T10:00:00.000+0300"), + comment("10", created="2026-10-15T10:00:00.000+0300", author=None, body=None), + comment("11", created="2026-10-15T10:00:00.000+0300")])], []) + got = mirror.get_issue(conn, "A-1")["comments"] + assert [c["comment_id"] for c in got] == ["11", "10", "9"] # то же время — по номеру, как число + assert got[1] == {"comment_id": "10", "author": None, "created": "2026-10-15T10:00:00.000+0300", + "updated": "2026-10-15T10:00:00.000+0300", "body": ""} + + +def test_issue_epic_summary_and_absent_link(conn): + mirror.apply_state(conn, [issue("A-1", epic_key="E-1"), issue("E-1", summary="Эпик свода", epic_key=None)], + [{"source_key": "A-1", "target_key": "Z-9", "link_type": "ASUPR_Связанные"}]) + got = mirror.get_issue(conn, "A-1") + assert got["epic_summary"] == "Эпик свода" + assert got["links_out"] == [{"target_key": "Z-9", "link_type": "ASUPR_Связанные", "summary": None, "status": None}] + assert mirror.get_issue(conn, "E-1")["epic_summary"] is None + + +@pytest.mark.parametrize("bad, field", [ + ("x", "comments"), (["x"], "comments[0]"), ([{"id": "", "body": "т"}], "comments[0].id"), + ([{"id": 5, "body": "т"}], "comments[0].id"), ([{"id": "5", "body": 42}], "comments[0].body"), + ([{"id": "5", "body": "т", "author": 7}], "comments[0].author"), + ([{"id": "5", "body": "т", "created": 1}], "comments[0].created"), + ([{"id": "5", "body": "т", "updated": []}], "comments[0].updated"), +]) +def test_check_comments_rejects(bad, field): + with pytest.raises(ValueError) as e: + mirror.check_comments([issue("A-1"), issue("A-2", comments=bad)]) + assert str(e.value).startswith(f"A-2: {field}") + + +def test_check_comments_accepts_missing_and_valid(): + mirror.check_comments([issue("A-1"), issue("A-2", comments=[comment("1"), comment("2", author=None, body="")])])