From b9f5a245129a5c2a90c6b7218cc6b650d4c94aae Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 20:14:23 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A5=D0=B0=D0=B1:=20=D1=82=D0=B0=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D1=86=D0=B0=20project,=20=D1=81=D1=85=D0=B5=D0=BC=D0=B0?= =?UTF-8?q?=20v4=20=D0=B8=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20base=5Fjql=20=D0=B2=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D1=8B=20(#61)?= 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 | 22 ++++-- src/jira_hub/projects.py | 150 +++++++++++++++++++++++++++++++++++++++ tests/conftest.py | 15 +++- tests/test_db.py | 81 +++++++++++++++++++-- tests/test_projects.py | 100 ++++++++++++++++++++++++++ 5 files changed, 357 insertions(+), 11 deletions(-) create mode 100644 src/jira_hub/projects.py create mode 100644 tests/test_projects.py diff --git a/src/jira_hub/db.py b/src/jira_hub/db.py index 0e091b6..246604f 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 = 3 +SCHEMA_VERSION = 4 SCHEMA = """ CREATE TABLE IF NOT EXISTS setting ( @@ -68,7 +68,8 @@ CREATE TABLE IF NOT EXISTS batch ( author TEXT NOT NULL, created_at TEXT NOT NULL, 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 ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -100,6 +101,13 @@ CREATE TABLE IF NOT EXISTS sync_run ( rate_limited INTEGER NOT NULL DEFAULT 0, 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 ( id INTEGER PRIMARY KEY CHECK (id = 1), 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) 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: проекты-метки } @@ -164,8 +178,8 @@ def migrate(conn: sqlite3.Connection, path=None) -> None: finally: reader.close() while current < SCHEMA_VERSION: - for sql in MIGRATIONS[current]: - conn.execute(sql) + for step in MIGRATIONS[current]: + step(conn) if callable(step) else conn.execute(step) current += 1 conn.execute("UPDATE setting SET value = ? WHERE key = 'schema_version'", (json.dumps(current),)) conn.execute("INSERT OR IGNORE INTO agent_status(id) VALUES (1)") diff --git a/src/jira_hub/projects.py b/src/jira_hub/projects.py new file mode 100644 index 0000000..d42074e --- /dev/null +++ b/src/jira_hub/projects.py @@ -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: + """Записать типы досок {"": "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\S+)|in\s*\((?P[^()]*)\))\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)) diff --git a/tests/conftest.py b/tests/conftest.py index 2ef29b9..b0065ce 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,7 +2,7 @@ ResourceWarning «unclosed database» (hub-hygiene-d, решение 14; M-9/O-4 #4).""" import pytest -from jira_hub import db +from jira_hub import db, projects @pytest.fixture @@ -18,3 +18,16 @@ def open_db(): yield make for conn in opened: 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 diff --git a/tests/test_db.py b/tests/test_db.py index b0ba510..d871074 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -1,18 +1,20 @@ +import json import logging import os import sqlite3 import stat 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): 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"} <= names - assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "3" + "agent_status", "project"} <= names + 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 cols = {r["name"] for r in conn.execute("PRAGMA table_info(command)")} 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)) conn.execute("INSERT INTO issue_note(key, fact_status) VALUES ('A-1', 'В работе')") 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.close() @@ -35,7 +39,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"] == "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() 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"] == "В работе" @@ -182,7 +186,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"] == "3" + assert conn.execute("SELECT value FROM setting WHERE key = 'schema_version'").fetchone()["value"] == "4" 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_note(key, fact_status) VALUES ('A-1', 'В работе')") 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"] == "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 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"] == "В работе" @@ -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) вешала хаб под # параллельными запросами — потоки ждали друг друга в 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 + + +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() diff --git a/tests/test_projects.py b/tests/test_projects.py new file mode 100644 index 0000000..8196c31 --- /dev/null +++ b/tests/test_projects.py @@ -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")