Хаб: таблица project, схема v4 и миграция base_jql в проекты (#61)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
0397c28739
commit
b9f5a24512
@ -8,7 +8,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from . import backup, paths
|
from . import backup, paths
|
||||||
|
|
||||||
SCHEMA_VERSION = 3
|
SCHEMA_VERSION = 4
|
||||||
|
|
||||||
SCHEMA = """
|
SCHEMA = """
|
||||||
CREATE TABLE IF NOT EXISTS setting (
|
CREATE TABLE IF NOT EXISTS setting (
|
||||||
@ -68,7 +68,8 @@ CREATE TABLE IF NOT EXISTS batch (
|
|||||||
author TEXT NOT NULL,
|
author TEXT NOT NULL,
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
cancelled_at TEXT,
|
cancelled_at TEXT,
|
||||||
estimate_seconds INTEGER NOT NULL DEFAULT 0
|
estimate_seconds INTEGER NOT NULL DEFAULT 0,
|
||||||
|
project TEXT
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS command (
|
CREATE TABLE IF NOT EXISTS command (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@ -100,6 +101,13 @@ CREATE TABLE IF NOT EXISTS sync_run (
|
|||||||
rate_limited INTEGER NOT NULL DEFAULT 0,
|
rate_limited INTEGER NOT NULL DEFAULT 0,
|
||||||
command_id INTEGER
|
command_id INTEGER
|
||||||
);
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS project (
|
||||||
|
label TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
board_id INTEGER,
|
||||||
|
board_type TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
CREATE TABLE IF NOT EXISTS agent_status (
|
CREATE TABLE IF NOT EXISTS agent_status (
|
||||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
last_seen_at TEXT,
|
last_seen_at TEXT,
|
||||||
@ -110,11 +118,17 @@ CREATE TABLE IF NOT EXISTS agent_status (
|
|||||||
);
|
);
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def _migrate_projects(conn) -> None:
|
||||||
|
from . import projects # внутри функции: projects импортирует db
|
||||||
|
projects.migrate_v3(conn)
|
||||||
|
|
||||||
|
|
||||||
# Шаги миграции: версия → SQL, переводящий схему в версию + 1 (design.md bridge-reliability, решение 5)
|
# Шаги миграции: версия → SQL, переводящий схему в версию + 1 (design.md bridge-reliability, решение 5)
|
||||||
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), данных переносить не нужно
|
2: [], # v2 → v3: issue_comment создаётся SCHEMA (CREATE TABLE IF NOT EXISTS), данных переносить не нужно
|
||||||
|
3: ["ALTER TABLE batch ADD COLUMN project TEXT", _migrate_projects], # v3 → v4: проекты-метки
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -164,8 +178,8 @@ def migrate(conn: sqlite3.Connection, path=None) -> None:
|
|||||||
finally:
|
finally:
|
||||||
reader.close()
|
reader.close()
|
||||||
while current < SCHEMA_VERSION:
|
while current < SCHEMA_VERSION:
|
||||||
for sql in MIGRATIONS[current]:
|
for step in MIGRATIONS[current]:
|
||||||
conn.execute(sql)
|
step(conn) if callable(step) else conn.execute(step)
|
||||||
current += 1
|
current += 1
|
||||||
conn.execute("UPDATE setting SET value = ? WHERE key = 'schema_version'", (json.dumps(current),))
|
conn.execute("UPDATE setting SET value = ? WHERE key = 'schema_version'", (json.dumps(current),))
|
||||||
conn.execute("INSERT OR IGNORE INTO agent_status(id) VALUES (1)")
|
conn.execute("INSERT OR IGNORE INTO agent_status(id) VALUES (1)")
|
||||||
|
|||||||
150
src/jira_hub/projects.py
Normal file
150
src/jira_hub/projects.py
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
"""Проекты-метки моста: таблица project, проверка метки, базовая JQL из меток, доски, миграция v3 → v4."""
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
from . import db, settings
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
LEGACY_LABEL = "AWPCA"
|
||||||
|
LEGACY_NAME = "АРМ КА"
|
||||||
|
MAX_LEN = 100
|
||||||
|
BOARD_TYPES = ("scrum", "kanban")
|
||||||
|
_FORBIDDEN = ',"\'()'
|
||||||
|
_FIELDS = "label, name, board_id, board_type, created_at"
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectError(ValueError):
|
||||||
|
"""Ошибка данных проекта; field — label | name | board_id."""
|
||||||
|
|
||||||
|
def __init__(self, field: str, reason: str):
|
||||||
|
super().__init__(f"{field}: {reason}")
|
||||||
|
self.field = field
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectExists(ProjectError):
|
||||||
|
def __init__(self, label: str):
|
||||||
|
super().__init__("label", f"проект с такой меткой уже есть: {label}")
|
||||||
|
|
||||||
|
|
||||||
|
def _label_error(label) -> str | None:
|
||||||
|
if not isinstance(label, str) or not label:
|
||||||
|
return "ожидается непустая строка"
|
||||||
|
if len(label) > MAX_LEN:
|
||||||
|
return f"длиннее {MAX_LEN} символов"
|
||||||
|
if any(c.isspace() or c in _FORBIDDEN for c in label):
|
||||||
|
return "пробелы и символы , \" ' ( ) недопустимы"
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def validate(label, name, board_id) -> tuple[str, str, int | None]:
|
||||||
|
"""Проверить поля проекта; вернуть (метка, название без пробелов по краям, доска)."""
|
||||||
|
error = _label_error(label)
|
||||||
|
if error:
|
||||||
|
raise ProjectError("label", error)
|
||||||
|
if not isinstance(name, str) or not name.strip():
|
||||||
|
raise ProjectError("name", "ожидается непустая строка")
|
||||||
|
if len(name.strip()) > MAX_LEN:
|
||||||
|
raise ProjectError("name", f"длиннее {MAX_LEN} символов")
|
||||||
|
if board_id is not None and (not isinstance(board_id, int) or isinstance(board_id, bool) or board_id <= 0):
|
||||||
|
raise ProjectError("board_id", "ожидается целое > 0 или null")
|
||||||
|
return label, name.strip(), board_id
|
||||||
|
|
||||||
|
|
||||||
|
def get(conn, label: str) -> dict | None:
|
||||||
|
row = conn.execute(f"SELECT {_FIELDS} FROM project WHERE label = ?", (label,)).fetchone()
|
||||||
|
return None if row is None else dict(row)
|
||||||
|
|
||||||
|
|
||||||
|
def create(conn, label: str, name: str, board_id: int | None = None) -> dict:
|
||||||
|
label, name, board_id = validate(label, name, board_id)
|
||||||
|
with db.transaction(conn):
|
||||||
|
if label.casefold() in {x.casefold() for x in labels(conn)}:
|
||||||
|
raise ProjectExists(label)
|
||||||
|
conn.execute("INSERT INTO project(label, name, board_id, board_type, created_at) VALUES (?, ?, ?, NULL, ?)",
|
||||||
|
(label, name, board_id, db.now()))
|
||||||
|
return get(conn, label)
|
||||||
|
|
||||||
|
|
||||||
|
def list_projects(conn) -> list[dict]:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT {_FIELDS}, (SELECT COUNT(*) FROM issue i WHERE EXISTS "
|
||||||
|
"(SELECT 1 FROM json_each(i.labels) WHERE value = project.label)) AS issues_count "
|
||||||
|
"FROM project ORDER BY created_at, label").fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def labels(conn) -> list[str]:
|
||||||
|
return [r["label"] for r in conn.execute("SELECT label FROM project ORDER BY created_at, label")]
|
||||||
|
|
||||||
|
|
||||||
|
def base_jql(conn) -> str | None:
|
||||||
|
"""Общая JQL обновления: все метки проектов; без проектов — None."""
|
||||||
|
found = labels(conn)
|
||||||
|
if not found:
|
||||||
|
return None
|
||||||
|
key = settings.get(conn, "project_key") or "AURORA"
|
||||||
|
return f"project = {key} AND labels in ({', '.join(found)})"
|
||||||
|
|
||||||
|
|
||||||
|
def boards(conn) -> list[dict]:
|
||||||
|
"""Доски проектов без повторов (порядок первого проекта); type — первый известный среди проектов доски."""
|
||||||
|
out: dict[int, str | None] = {}
|
||||||
|
for r in conn.execute("SELECT board_id, board_type FROM project WHERE board_id IS NOT NULL ORDER BY created_at, label"):
|
||||||
|
if out.get(r["board_id"]) is None:
|
||||||
|
out[r["board_id"]] = r["board_type"]
|
||||||
|
return [{"id": i, "type": t} for i, t in out.items()]
|
||||||
|
|
||||||
|
|
||||||
|
def record_board_types(conn, found) -> int:
|
||||||
|
"""Записать типы досок {"<id>": "scrum" | "kanban"} всем проектам с этой доской; прочее пропустить."""
|
||||||
|
if not isinstance(found, dict):
|
||||||
|
return 0
|
||||||
|
updated = 0
|
||||||
|
for raw, kind in found.items():
|
||||||
|
if kind not in BOARD_TYPES:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
board = int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
updated += conn.execute("UPDATE project SET board_type = ? WHERE board_id = ?", (kind, board)).rowcount
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
_LEGACY = re.compile(r"\blabels\s*(?:=\s*(?P<one>\S+)|in\s*\((?P<many>[^()]*)\))\s*$", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_legacy_labels(jql) -> list[str] | None:
|
||||||
|
"""Метки из прежней base_jql (`labels = X` / `labels in (X, Y)` в конце); не разобрана — None."""
|
||||||
|
m = _LEGACY.search(jql) if isinstance(jql, str) else None
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
out: list[str] = []
|
||||||
|
for raw in ([m["one"]] if m["one"] is not None else m["many"].split(",")):
|
||||||
|
label = raw.strip().strip('"')
|
||||||
|
if _label_error(label):
|
||||||
|
return None
|
||||||
|
if label.casefold() not in {x.casefold() for x in out}:
|
||||||
|
out.append(label)
|
||||||
|
return out or None
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_v3(conn) -> None:
|
||||||
|
"""Миграция v3 → v4: проекты из прежней base_jql, доска — единственному проекту, очередь и журнал — на новую JQL."""
|
||||||
|
old = settings.get(conn, "base_jql")
|
||||||
|
found = parse_legacy_labels(old)
|
||||||
|
if found is None:
|
||||||
|
log.warning("миграция v4: base_jql не разобрана (%r) — проектов нет, заведите их POST /projects", old)
|
||||||
|
return
|
||||||
|
board = settings.get(conn, "board_id")
|
||||||
|
board = board if isinstance(board, int) and not isinstance(board, bool) and board > 0 else None
|
||||||
|
if board is not None and len(found) > 1:
|
||||||
|
log.warning("миграция v4: доска %s не отдана ни одному проекту — меток несколько: %s", board, ", ".join(found))
|
||||||
|
board = None
|
||||||
|
for label in found:
|
||||||
|
create(conn, label, LEGACY_NAME if label == LEGACY_LABEL else label, board)
|
||||||
|
new = base_jql(conn)
|
||||||
|
conn.execute("UPDATE sync_run SET query = ? WHERE query = ?", (new, old))
|
||||||
|
conn.execute("UPDATE command SET params = json_set(params, '$.jql', ?) WHERE type = 'refresh' "
|
||||||
|
"AND status IN ('queued', 'claimed') AND json_extract(params, '$.jql') = ?", (new, old))
|
||||||
@ -2,7 +2,7 @@
|
|||||||
ResourceWarning «unclosed database» (hub-hygiene-d, решение 14; M-9/O-4 #4)."""
|
ResourceWarning «unclosed database» (hub-hygiene-d, решение 14; M-9/O-4 #4)."""
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from jira_hub import db
|
from jira_hub import db, projects
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@ -18,3 +18,16 @@ def open_db():
|
|||||||
yield make
|
yield make
|
||||||
for conn in opened:
|
for conn in opened:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def add_project():
|
||||||
|
"""add_project(conn, label="AWPCA", name=None, board_id=4216, board_type="scrum") -> dict: завести проект."""
|
||||||
|
def make(conn, label="AWPCA", name=None, board_id=4216, board_type="scrum"):
|
||||||
|
if name is None:
|
||||||
|
name = projects.LEGACY_NAME if label == projects.LEGACY_LABEL else label
|
||||||
|
projects.create(conn, label, name, board_id)
|
||||||
|
if board_id is not None and board_type is not None:
|
||||||
|
projects.record_board_types(conn, {str(board_id): board_type})
|
||||||
|
return projects.get(conn, label)
|
||||||
|
return make
|
||||||
|
|||||||
@ -1,18 +1,20 @@
|
|||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import stat
|
import stat
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
from jira_hub import backup, db, paths, queue, settings
|
from jira_hub import backup, db, paths, projects, 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_comment", "issue_note", "batch", "command", "sync_run",
|
assert {"setting", "issue", "issue_link", "issue_comment", "issue_note", "batch", "command", "sync_run",
|
||||||
"agent_status"} <= names
|
"agent_status", "project"} <= names
|
||||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "3"
|
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 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
|
||||||
@ -27,6 +29,8 @@ def _v1_database(path):
|
|||||||
(status, "2026-10-13T00:00:00+00:00" if status == "claimed" else None))
|
(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', 'В работе')")
|
conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')")
|
||||||
conn.execute("ALTER TABLE command DROP COLUMN deliveries")
|
conn.execute("ALTER TABLE command DROP COLUMN deliveries")
|
||||||
|
conn.execute("ALTER TABLE batch DROP COLUMN project")
|
||||||
|
conn.execute("DROP TABLE project")
|
||||||
conn.execute("UPDATE setting SET value = '1' WHERE key = 'schema_version'")
|
conn.execute("UPDATE setting SET value = '1' WHERE key = 'schema_version'")
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
@ -35,7 +39,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"] == "3"
|
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "4"
|
||||||
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"] == "В работе"
|
||||||
@ -182,7 +186,7 @@ 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"] == "3"
|
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "4"
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@ -193,10 +197,12 @@ def test_migrates_v2_with_backup(tmp_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(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("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')")
|
||||||
conn.execute("DROP TABLE issue_comment")
|
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.execute("UPDATE setting SET value = '2' WHERE key = 'schema_version'")
|
||||||
conn.close()
|
conn.close()
|
||||||
conn = db.connect(path)
|
conn = db.connect(path)
|
||||||
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "3"
|
assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "4"
|
||||||
assert conn.execute("SELECT COUNT(*) AS n FROM issue_comment").fetchone()["n"] == 0
|
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 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 conn.execute("SELECT fact_status FROM issue_note WHERE key = 'A-1'").fetchone()["fact_status"] == "В работе"
|
||||||
@ -221,3 +227,66 @@ def test_sqlite_without_unix_vfs_deadlock():
|
|||||||
# O-14 (#29): в SQLite 3.51.0–3.51.1 взаимная блокировка в unix-VFS (исправлена в 3.51.2) вешала хаб под
|
# O-14 (#29): в SQLite 3.51.0–3.51.1 взаимная блокировка в unix-VFS (исправлена в 3.51.2) вешала хаб под
|
||||||
# параллельными запросами — потоки ждали друг друга в sqlite3.connect/close. Python хаба — от uv (pyproject, tool.uv)
|
# параллельными запросами — потоки ждали друг друга в sqlite3.connect/close. Python хаба — от uv (pyproject, tool.uv)
|
||||||
assert sqlite3.sqlite_version_info[:2] != (3, 51) or sqlite3.sqlite_version_info >= (3, 51, 2), sqlite3.sqlite_version
|
assert sqlite3.sqlite_version_info[:2] != (3, 51) or sqlite3.sqlite_version_info >= (3, 51, 2), sqlite3.sqlite_version
|
||||||
|
|
||||||
|
|
||||||
|
def _v3_database(path, base_jql="project = AURORA AND labels = AWPCA", board_id=4216):
|
||||||
|
"""База в состоянии схемы v3: без project и batch.project, base_jql/board_id в setting, очередь и журнал на старой JQL."""
|
||||||
|
conn = db.connect(path)
|
||||||
|
settings.ensure_defaults(conn)
|
||||||
|
for key, value in (("base_jql", base_jql), ("board_id", board_id)):
|
||||||
|
conn.execute("INSERT OR REPLACE INTO setting(key, value) VALUES (?, ?)", (key, json.dumps(value)))
|
||||||
|
ts = "2026-10-13T00:00:00+00:00"
|
||||||
|
for status, jql in (("done", base_jql), ("queued", base_jql), ("claimed", base_jql), ("queued", "key = A-1")):
|
||||||
|
conn.execute("INSERT INTO batch(title, author, created_at) VALUES ('x', 'claude', ?)", (ts,))
|
||||||
|
batch = conn.execute("SELECT MAX(id) AS id FROM batch").fetchone()["id"]
|
||||||
|
conn.execute("INSERT INTO command(batch_id, seq, type, params, status) VALUES (?, 1, 'refresh', ?, ?)",
|
||||||
|
(batch, json.dumps({"jql": jql}), status))
|
||||||
|
if status == "done" and jql == base_jql:
|
||||||
|
cid = conn.execute("SELECT MAX(id) AS id FROM command").fetchone()["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', 'В работе')")
|
||||||
|
conn.execute("ALTER TABLE batch DROP COLUMN project")
|
||||||
|
conn.execute("DROP TABLE project")
|
||||||
|
conn.execute("UPDATE setting SET value = '3' WHERE key = 'schema_version'")
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrates_v3_creates_awpca(tmp_path):
|
||||||
|
path = tmp_path / "hub.sqlite"
|
||||||
|
_v3_database(path)
|
||||||
|
conn = db.connect(path)
|
||||||
|
new = "project = AURORA AND labels in (AWPCA)"
|
||||||
|
assert projects.list_projects(conn)[0] | {"created_at": None, "issues_count": None} == {
|
||||||
|
"label": "AWPCA", "name": "АРМ КА", "board_id": 4216, "board_type": None, "created_at": None, "issues_count": None}
|
||||||
|
assert settings.get(conn, "base_jql") == "project = AURORA AND labels = AWPCA" # ключи на месте
|
||||||
|
assert settings.get(conn, "board_id") == 4216
|
||||||
|
assert conn.execute("SELECT query FROM sync_run").fetchone()["query"] == new
|
||||||
|
jqls = {r["status"]: json.loads(r["params"]).get("jql") for r in conn.execute("SELECT status, params FROM command WHERE type = 'refresh'")}
|
||||||
|
assert jqls["done"] == "project = AURORA AND labels = AWPCA" # завершённые не трогаем
|
||||||
|
assert jqls["claimed"] == new
|
||||||
|
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 len(list((tmp_path / "backups").glob("*-schema-v3.sqlite"))) == 1
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrates_v3_two_labels_board_to_nobody(tmp_path, caplog):
|
||||||
|
_v3_database(tmp_path / "hub.sqlite", base_jql="project = AURORA AND labels in (AWPCA, STOCKS_DRRK)")
|
||||||
|
conn = db.connect(tmp_path / "hub.sqlite")
|
||||||
|
got = {p["label"]: (p["name"], p["board_id"]) for p in projects.list_projects(conn)}
|
||||||
|
assert got == {"AWPCA": ("АРМ КА", None), "STOCKS_DRRK": ("STOCKS_DRRK", None)}
|
||||||
|
assert "4216" in caplog.text
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_migrates_v3_unparsed_jql_keeps_running(tmp_path, caplog):
|
||||||
|
_v3_database(tmp_path / "hub.sqlite", base_jql="project = AURORA AND labels = AWPCA ORDER BY key")
|
||||||
|
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"
|
||||||
|
conn.close()
|
||||||
|
|||||||
100
tests/test_projects.py
Normal file
100
tests/test_projects.py
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from jira_hub import mirror, projects, settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def conn(open_db):
|
||||||
|
c = open_db()
|
||||||
|
settings.ensure_defaults(c)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("label", ["", "STOCKS DRRK", "A\tB", "A,B", 'A"B', "A'B", "A(B", "A)B", "X" * 101, 5, None])
|
||||||
|
def test_bad_label(conn, label):
|
||||||
|
with pytest.raises(projects.ProjectError) as e:
|
||||||
|
projects.create(conn, label, "Название")
|
||||||
|
assert e.value.field == "label"
|
||||||
|
assert projects.list_projects(conn) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", ["", " ", "Я" * 101, None, 7])
|
||||||
|
def test_bad_name(conn, name):
|
||||||
|
with pytest.raises(projects.ProjectError) as e:
|
||||||
|
projects.create(conn, "AWPCA", name)
|
||||||
|
assert e.value.field == "name"
|
||||||
|
assert projects.list_projects(conn) == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("board", [0, -1, True, "5120", 1.5])
|
||||||
|
def test_bad_board(conn, board):
|
||||||
|
with pytest.raises(projects.ProjectError) as e:
|
||||||
|
projects.create(conn, "AWPCA", "АРМ КА", board)
|
||||||
|
assert e.value.field == "board_id"
|
||||||
|
assert projects.list_projects(conn) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_and_list(conn):
|
||||||
|
a = projects.create(conn, "AWPCA", " АРМ КА ", 4216)
|
||||||
|
assert a == {"label": "AWPCA", "name": "АРМ КА", "board_id": 4216, "board_type": None, "created_at": a["created_at"]}
|
||||||
|
projects.create(conn, "STOCKS_DRRK", "Запасы ДРРК")
|
||||||
|
mirror.apply_state(conn, [{"key": "A-1", "labels": ["AWPCA"]}, {"key": "A-2", "labels": ["awpca", "x"]}], [])
|
||||||
|
got = projects.list_projects(conn)
|
||||||
|
assert [(p["label"], p["board_id"], p["issues_count"]) for p in got] == [("AWPCA", 4216, 1), ("STOCKS_DRRK", None, 0)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_created_then_label(conn):
|
||||||
|
projects.create(conn, "ZZZ", "z"); projects.create(conn, "AAA", "a"); projects.create(conn, "MMM", "m")
|
||||||
|
conn.execute("UPDATE project SET created_at = '2026-01-01T00:00:00+00:00' WHERE label IN ('AAA', 'MMM')")
|
||||||
|
conn.execute("UPDATE project SET created_at = '2026-01-02T00:00:00+00:00' WHERE label = 'ZZZ'")
|
||||||
|
assert projects.labels(conn) == ["AAA", "MMM", "ZZZ"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_label_other_case(conn):
|
||||||
|
projects.create(conn, "AWPCA", "АРМ КА")
|
||||||
|
with pytest.raises(projects.ProjectExists) as e:
|
||||||
|
projects.create(conn, "awpca", "…")
|
||||||
|
assert "уже есть" in str(e.value) and len(projects.list_projects(conn)) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_base_jql(conn):
|
||||||
|
assert projects.base_jql(conn) is None
|
||||||
|
projects.create(conn, "AWPCA", "АРМ КА"); projects.create(conn, "STOCKS_DRRK", "Запасы ДРРК")
|
||||||
|
assert projects.base_jql(conn) == "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)"
|
||||||
|
settings.set_(conn, "project_key", "AUR")
|
||||||
|
assert projects.base_jql(conn).startswith("project = AUR AND")
|
||||||
|
|
||||||
|
|
||||||
|
def test_boards_dedup_and_types(conn):
|
||||||
|
projects.create(conn, "AWPCA", "a", 4216); projects.create(conn, "B", "b", 5120)
|
||||||
|
projects.create(conn, "C", "c", 4216); projects.create(conn, "D", "d")
|
||||||
|
projects.record_board_types(conn, {"4216": "scrum"})
|
||||||
|
assert projects.boards(conn) == [{"id": 4216, "type": "scrum"}, {"id": 5120, "type": None}]
|
||||||
|
assert projects.get(conn, "C")["board_type"] == "scrum" # тип — у всех проектов с этой доской
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("boards", [{"5120": None}, {"5120": "simple"}, {"x": "kanban"}, ["5120"], None, {}])
|
||||||
|
def test_record_board_types_ignores(conn, boards):
|
||||||
|
projects.create(conn, "B", "b", 5120)
|
||||||
|
assert projects.record_board_types(conn, boards) == 0
|
||||||
|
assert projects.get(conn, "B")["board_type"] is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("jql, labels", [
|
||||||
|
("project = AURORA AND labels = AWPCA", ["AWPCA"]),
|
||||||
|
("project = AURORA AND labels in (AWPCA, STOCKS_DRRK)", ["AWPCA", "STOCKS_DRRK"]),
|
||||||
|
("project = AURORA AND labels IN (A,B)", ["A", "B"]),
|
||||||
|
('project = AURORA AND labels = "AWPCA"', ["AWPCA"]),
|
||||||
|
("project = AURORA AND labels in (AWPCA, awpca)", ["AWPCA"]),
|
||||||
|
("project = AURORA", None),
|
||||||
|
("project = AURORA AND labels = AWPCA ORDER BY key", None),
|
||||||
|
("project = AURORA AND labels in (A B)", None),
|
||||||
|
("", None), (None, None), (5, None),
|
||||||
|
])
|
||||||
|
def test_parse_legacy_labels(jql, labels):
|
||||||
|
assert projects.parse_legacy_labels(jql) == labels
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_project_fixture(conn, add_project):
|
||||||
|
p = add_project(conn)
|
||||||
|
assert (p["name"], p["board_id"], p["board_type"]) == ("АРМ КА", 4216, "scrum")
|
||||||
Loading…
x
Reference in New Issue
Block a user