From dc9419fbb9389c499901fcd9096277910780c4d6 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 22 Oct 2026 10:05:53 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=85=D0=B5=D0=BC=D0=B0=20v5:=20issue=5F?= =?UTF-8?q?change,=20=D0=B2=D1=80=D0=B5=D0=BC=D1=8F=20=D0=BA=D0=BE=D0=BC?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D1=82=D0=B0=D1=80=D0=B8=D0=B5=D0=B2=20=D0=B2?= =?UTF-8?q?=20UTC=20(#70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- src/jira_hub/db.py | 57 ++++++++++++++++++++++++++++++++++-- src/jira_hub/mirror.py | 8 ++--- tests/test_db.py | 66 +++++++++++++++++++++++++++++++++++++----- tests/test_mirror.py | 8 +++++ 4 files changed, 126 insertions(+), 13 deletions(-) diff --git a/src/jira_hub/db.py b/src/jira_hub/db.py index 246604f..b3546eb 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 = 4 +SCHEMA_VERSION = 5 SCHEMA = """ CREATE TABLE IF NOT EXISTS setting ( @@ -50,8 +50,28 @@ CREATE TABLE IF NOT EXISTS issue_comment ( created TEXT, updated TEXT, body TEXT NOT NULL DEFAULT '', + created_at TEXT, PRIMARY KEY (key, comment_id) ); +CREATE TABLE IF NOT EXISTS issue_change ( + history_id TEXT NOT NULL, + seq INTEGER NOT NULL, + key TEXT NOT NULL, + at TEXT, + created TEXT NOT NULL, + author TEXT, + field TEXT NOT NULL, + field_id TEXT, + field_name TEXT NOT NULL, + from_value TEXT, + from_string TEXT, + to_value TEXT, + to_string TEXT, + fetched_at TEXT NOT NULL, + PRIMARY KEY (history_id, seq) +); +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, @@ -99,7 +119,8 @@ CREATE TABLE IF NOT EXISTS sync_run ( links_count INTEGER NOT NULL DEFAULT 0, jira_calls INTEGER NOT NULL DEFAULT 0, rate_limited INTEGER NOT NULL DEFAULT 0, - command_id INTEGER + command_id INTEGER, + history_at TEXT ); CREATE TABLE IF NOT EXISTS project ( label TEXT PRIMARY KEY, @@ -123,12 +144,28 @@ def _migrate_projects(conn) -> None: projects.migrate_v3(conn) +def _add_column(table: str, column: str): + """Шаг миграции: добавить колонку, если её нет. Базы v1/v2 получают issue_comment из SCHEMA уже с новой колонкой.""" + def step(conn) -> None: + if column not in {r["name"] for r in conn.execute(f"PRAGMA table_info({table})")}: + conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} TEXT") + return step + + +def _fill_comment_utc(conn) -> None: + for r in conn.execute("SELECT key, comment_id, created FROM issue_comment").fetchall(): + conn.execute("UPDATE issue_comment SET created_at = ? WHERE key = ? AND comment_id = ?", + (jira_utc(r["created"]), r["key"], r["comment_id"])) + + # Шаги миграции: версия → SQL, переводящий схему в версию + 1 (design.md bridge-reliability, решение 5) 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), данных переносить не нужно 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], } @@ -217,6 +254,22 @@ def transaction(conn: sqlite3.Connection): conn.execute("COMMIT") +def jira_utc(value) -> str | None: + """Время Jira (`2026-10-22T13:05:11.000+0300`, `+03:00`, `Z`, с дробной частью или без) → ISO-8601 UTC в формате + now(); без смещения — UTC; неразбираемое — None.""" + if not isinstance(value, str) or not value: + return None + s = value[:-1] + "+00:00" if value.endswith("Z") else value + if len(s) > 5 and s[-5] in "+-" and s[-4:].isdigit(): # ±HHMM → ±HH:MM + s = s[:-2] + ":" + s[-2:] + try: + d = datetime.fromisoformat(s) + except ValueError: + return None + d = d if d.tzinfo else d.replace(tzinfo=timezone.utc) + return d.astimezone(timezone.utc).replace(microsecond=0).isoformat() + + def utcnow() -> datetime: return datetime.now(timezone.utc).replace(microsecond=0) diff --git a/src/jira_hub/mirror.py b/src/jira_hub/mirror.py index ae7503f..eea22c8 100644 --- a/src/jira_hub/mirror.py +++ b/src/jira_hub/mirror.py @@ -58,10 +58,10 @@ def apply_state(conn, issues, links, neighbor_keys=(), fetched_at=None, authorit 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"]]) + "INSERT OR REPLACE INTO issue_comment(key, comment_id, author, created, updated, body, created_at) " + "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"]]) 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 (?, ?, ?)", diff --git a/tests/test_db.py b/tests/test_db.py index 27e8753..a3ae7ea 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -5,6 +5,8 @@ import sqlite3 import stat import threading +import pytest + from jira_hub import backup, db, paths, projects, queue, settings @@ -12,14 +14,23 @@ 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_comment", "issue_note", "batch", "command", "sync_run", - "agent_status", "project"} <= names + "agent_status", "project", "issue_change"} <= names + 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"] == "4" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5" 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_v5(conn): + """Снять с базы текущей схемы то, что добавила v5: так строятся базы прежних версий.""" + 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") + + def _v1_database(path): """База в состоянии схемы v1: без command.deliveries, schema_version = 1, с живыми данными.""" conn = db.connect(path) @@ -28,6 +39,7 @@ def _v1_database(path): 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', 'В работе')") + _drop_v5(conn) conn.execute("ALTER TABLE command DROP COLUMN deliveries") conn.execute("ALTER TABLE batch DROP COLUMN project") conn.execute("DROP TABLE project") @@ -39,7 +51,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"] == "4" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5" 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"] == "В работе" @@ -186,7 +198,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"] == "4" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5" conn.close() @@ -196,13 +208,14 @@ def test_migrates_v2_with_backup(tmp_path): 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', 'В работе')") + _drop_v5(conn) conn.execute("DROP TABLE issue_comment") conn.execute("ALTER TABLE batch DROP COLUMN project") conn.execute("DROP TABLE project") 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"] == "4" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5" 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"] == "В работе" @@ -247,6 +260,7 @@ def _v3_database(path, base_jql="project = AURORA AND labels = AWPCA", board_id= (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', 'В работе')") + _drop_v5(conn) conn.execute("ALTER TABLE batch DROP COLUMN project") conn.execute("DROP TABLE project") conn.execute("UPDATE setting SET value = '3' WHERE key = 'schema_version'") @@ -269,7 +283,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"] == "4" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5" assert len(list((tmp_path / "backups").glob("*-schema-v3.sqlite"))) == 1 conn.close() @@ -288,5 +302,43 @@ 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"] == "4" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "5" + conn.close() + + +@pytest.mark.parametrize("raw, want", [ + ("2026-10-22T13:05:11.000+0300", "2026-10-22T10:05:11+00:00"), + ("2026-10-22T13:05:11+03:00", "2026-10-22T10:05:11+00:00"), + ("2026-10-22T10:05:11Z", "2026-10-22T10:05:11+00:00"), + ("2026-10-22T10:05:11.123Z", "2026-10-22T10:05:11+00:00"), + ("2026-10-22T10:05:11.123", "2026-10-22T10:05:11+00:00"), + ("2026-10-22T02:05:11-0500", "2026-10-22T07:05:11+00:00"), + ("вчера", None), ("", None), (None, None), (42, None), +]) +def test_jira_utc(raw, want): + assert db.jira_utc(raw) == want + + +def _v4_database(path): + """База v4: комментарий со временем Jira, полный прогон в журнале синхронизаций.""" + conn = db.connect(path) + conn.execute("INSERT INTO issue_comment(key, comment_id, created, body) VALUES ('X', '1', '2026-10-22T13:05:11.000+0300', 'a')") + conn.execute("INSERT INTO issue_comment(key, comment_id, created, body) VALUES ('X', '2', 'мусор', 'b')") + conn.execute("INSERT INTO sync_run(started_at, finished_at, kind, query) VALUES " + "('2026-10-22T09:00:00+00:00', '2026-10-22T09:01:00+00:00', 'jql', 'q')") + _drop_v5(conn) + conn.execute("UPDATE setting SET value = '4' WHERE key = 'schema_version'") + conn.close() + + +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" + 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() diff --git a/tests/test_mirror.py b/tests/test_mirror.py index b308c81..aa093dd 100644 --- a/tests/test_mirror.py +++ b/tests/test_mirror.py @@ -311,3 +311,11 @@ def test_labels_of(conn): _scene(conn) assert mirror.labels_of(conn, "AURORA-4") == ["STOCKS_DRRK", "kind-dev"] assert mirror.labels_of(conn, "AURORA-404") is None + + +def test_apply_state_comment_created_at(conn): + mirror.apply_state(conn, [{"key": "X", "comments": [ + {"id": "1", "body": "a", "created": "2026-10-22T13:05:11.000+0300"}, + {"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}