feat(hub): комментарии задач в зеркале, схема v3; темы и статусы связанных в карточке (#37)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-15 13:00:28 +04:00
parent 9484d10d0b
commit 88a6f72b13
5 changed files with 134 additions and 12 deletions

View File

@ -8,7 +8,7 @@ from pathlib import Path
from . import backup, paths from . import backup, paths
SCHEMA_VERSION = 2 SCHEMA_VERSION = 3
SCHEMA = """ SCHEMA = """
CREATE TABLE IF NOT EXISTS setting ( 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) PRIMARY KEY (source_key, target_key, link_type)
); );
CREATE INDEX IF NOT EXISTS ix_issue_link_target ON issue_link(target_key); 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 ( CREATE TABLE IF NOT EXISTS issue_note (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
fact_status TEXT, fact_status TEXT,
@ -105,6 +114,7 @@ CREATE TABLE IF NOT EXISTS agent_status (
MIGRATIONS = { MIGRATIONS = {
1: ["ALTER TABLE command ADD COLUMN deliveries INTEGER NOT NULL DEFAULT 0", 1: ["ALTER TABLE command ADD COLUMN deliveries INTEGER NOT NULL DEFAULT 0",
"UPDATE command SET deliveries = 1 WHERE status = 'claimed'"], # уже выданные — одна выдача "UPDATE command SET deliveries = 1 WHERE status = 'claimed'"], # уже выданные — одна выдача
2: [], # v2 → v3: issue_comment создаётся SCHEMA (CREATE TABLE IF NOT EXISTS), данных переносить не нужно
} }

View File

@ -1,4 +1,4 @@
"""Зеркало задач Jira (issue, issue_link) и служебные заметки (issue_note). """Зеркало задач Jira (issue, issue_link, issue_comment) и служебные заметки (issue_note).
Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает.""" Зеркало пишет только загрузка состояния от агента; заметки обновление зеркала не трогает."""
import json 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"] "duedate", "planned_start", "planned_end", "epic_key", "labels", "sprint", "created", "jira_updated"]
NOTE_FIELDS = ["fact_status", "work_kind", "done_text", "rest_text", "owner_note"] 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 = ?", _FILTERS = {"sprint": "i.sprint = ?", "assignee": "i.assignee = ?", "status": "i.status = ?", "epic": "i.epic_key = ?",
"work_kind": "n.work_kind = ?", "fact_status": "n.fact_status = ?"} "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), "ON CONFLICT(key) DO UPDATE SET " + ", ".join(sets),
[row[c] for c in cols]) [row[c] for c in cols])
keys.append(it["key"]) 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]) conn.executemany("DELETE FROM issue_link WHERE source_key = ?", [(k,) for k in keys])
for link in links: for link in links:
conn.execute("INSERT OR IGNORE INTO issue_link(source_key, target_key, link_type) VALUES (?, ?, ?)", 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: if row is None:
return None return None
d = _issue_dict(row) 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( 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( 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["note"] = get_note(conn, key)
d["commands"] = [dict(r) for r in conn.execute( 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", "SELECT id, batch_id, type, status, finished_at, error FROM command WHERE params LIKE ? ORDER BY id DESC LIMIT 10",

View File

@ -10,8 +10,9 @@ from jira_hub import backup, db, paths, queue, settings
def test_connect_creates_schema(open_db): def test_connect_creates_schema(open_db):
conn = open_db() conn = open_db()
names = {r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")} 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 {"setting", "issue", "issue_link", "issue_comment", "issue_note", "batch", "command", "sync_run",
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "2" "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 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)")} cols = {r["name"] for r in conn.execute("PRAGMA table_info(command)")}
assert "deliveries" in cols assert "deliveries" in cols
@ -34,7 +35,7 @@ def test_migrates_v1_with_backup(tmp_path):
path = tmp_path / "hub.sqlite" path = tmp_path / "hub.sqlite"
_v1_database(path) _v1_database(path)
conn = db.connect(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() 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 [(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 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 errors == []
assert len(list((tmp_path / "backups").glob("*-schema-v1.sqlite"))) == 1 assert len(list((tmp_path / "backups").glob("*-schema-v1.sqlite"))) == 1
conn = db.connect(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"
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() conn.close()

View File

@ -75,5 +75,6 @@ def test_create_link_sprint_flow(live):
assert issue["note"]["work_kind"] == "разработка" assert issue["note"]["work_kind"] == "разработка"
assert len(issue["links_out"]) == 1 assert len(issue["links_out"]) == 1
assert issue["links_out"][0]["target_key"] == "A-1" and issue["links_out"][0]["link_type"] == "ASUPR_Связанные" 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 assert client.get("/sync-runs")["runs"][0]["issues_count"] == 2

View File

@ -27,15 +27,18 @@ def test_apply_state_upserts_and_replaces_links(conn):
[{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}]) [{"source_key": "A-1", "target_key": "A-2", "link_type": "ASUPR_Связанные"}])
got = mirror.get_issue(conn, "A-1") got = mirror.get_issue(conn, "A-1")
assert got["summary"] == "Новая тема" and got["labels"] == ["AWPCA"] assert got["summary"] == "Новая тема" and got["labels"] == ["AWPCA"]
assert got["links_out"] == [{"target_key": "A-2", "link_type": "ASUPR_Связанные"}] assert got["links_out"] == [{"target_key": "A-2", "link_type": "ASUPR_Связанные", "summary": "Тема A-2",
assert mirror.get_issue(conn, "A-2")["links_in"] == [{"source_key": "A-1", "link_type": "ASUPR_Связанные"}] "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): def test_links_from_other_source_survive(conn):
mirror.apply_state(conn, [issue("A-1"), issue("A-2")], mirror.apply_state(conn, [issue("A-1"), issue("A-2")],
[{"source_key": "A-2", "target_key": "A-1", "link_type": "ASUPR_Связанные"}]) [{"source_key": "A-2", "target_key": "A-1", "link_type": "ASUPR_Связанные"}])
mirror.apply_state(conn, [issue("A-1")], []) # пришла только A-1 без связей 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): 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 conn.in_transaction
assert not conn.in_transaction assert not conn.in_transaction
assert [i["key"] for i in snap["issues"]] == ["AURORA-1620"] 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="")])])