feat(hub): комментарии задач в зеркале, схема v3; темы и статусы связанных в карточке (#37)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
9484d10d0b
commit
88a6f72b13
@ -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), данных переносить не нужно
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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="")])])
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user