From 1f2fc142b73e67a1586981bd3b0bb8625fb81500 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 19 Oct 2026 22:55:05 +0400 Subject: [PATCH] =?UTF-8?q?fix(hub):=20=D0=BC=D0=B5=D1=82=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D0=BE=D0=B2=20=D0=B2=20?= =?UTF-8?q?=D0=B1=D0=B0=D0=B7=D0=BE=D0=B2=D0=BE=D0=B9=20JQL=20=D0=B2=20?= =?UTF-8?q?=D0=BA=D0=B0=D0=B2=D1=8B=D1=87=D0=BA=D0=B0=D1=85,=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D1=80=D0=B5=D1=82=20\=20=D0=B8=20=D1=83=D0=BF?= =?UTF-8?q?=D1=80=D0=B0=D0=B2=D0=BB=D1=8F=D1=8E=D1=89=D0=B8=D1=85=20=D1=81?= =?UTF-8?q?=D0=B8=D0=BC=D0=B2=D0=BE=D0=BB=D0=BE=D0=B2=20(I-2,=20#61)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/projects/design.md | 11 +++++----- openspec/changes/projects/proposal.md | 2 +- .../changes/projects/specs/data-model/spec.md | 21 +++++++++++++------ .../changes/projects/specs/hub-api/spec.md | 4 ++-- src/jira_hub/projects.py | 9 ++++---- tests/test_api.py | 11 ++++++---- tests/test_db.py | 2 +- tests/test_projects.py | 11 ++++++++-- tests/test_queue.py | 2 +- 9 files changed, 47 insertions(+), 26 deletions(-) diff --git a/openspec/changes/projects/design.md b/openspec/changes/projects/design.md index 55ba5e3..ade886f 100644 --- a/openspec/changes/projects/design.md +++ b/openspec/changes/projects/design.md @@ -38,13 +38,14 @@ Отдельная таблица, а не ключ настроек: проект — сущность со своим жизненным циклом, а настройки уходят агенту целиком и поднимают `settings_version` при каждой правке. Создание проекта `settings_version` не поднимает: агенту проекты не нужны, доски он получает в параметрах команд (решение 7). -2. **Метка проекта** — непустая строка без пробельных символов, запятых, кавычек и скобок (метка Jira не может - содержать пробел; остальное исключено, чтобы метку можно было без экранирования вставить в JQL `labels in (…)`), до - 100 символов. Регистр сохраняется как введён; совпадение с существующей без учёта регистра — 409 (Jira сравнивает +2. **Метка проекта** — непустая строка без пробельных и управляющих символов (категория Unicode `Cc`), запятых, + кавычек, скобок и обратной косой черты (метка Jira не может содержать пробел; остальное исключено, чтобы метку можно + было вставить в JQL в двойных кавычках без экранирования), до 100 символов. Регистр сохраняется как введён; совпадение с существующей без учёта регистра — 409 (Jira сравнивает метки без учёта регистра в JQL). Название — непустое после обрезки пробелов, до 100 символов. `board_id` — целое > 0 или отсутствует. -3. **Базовая JQL собирается, а не хранится.** `base_jql = "project = AND labels in (<метки по - created_at, затем label>)"`; функция в `settings` (или новом модуле `projects`), её используют `POST /refresh +3. **Базовая JQL собирается, а не хранится.** `base_jql = 'project = AND labels in ("<метка>", …)'` (каждая метка в двойных + кавычках, чтобы `EMPTY`, `null` и прочие зарезервированные слова JQL были значением; метки по created_at, затем + label); функция в `settings` (или новом модуле `projects`), её используют `POST /refresh {full}`, автообновление, `is_full_refresh`, `mirror.board` и `GET /settings` (поле `base_jql` в ответе — вычисленное, только для чтения; `PUT /settings` с `base_jql` или `board_id` — 422 с указанием, что JQL собирается из проектов, а доска — свойство проекта). Без проектов `base_jql = null`: `POST /refresh {full}` — 422 «нет проектов», автообновление diff --git a/openspec/changes/projects/proposal.md b/openspec/changes/projects/proposal.md index e250c70..ac997e6 100644 --- a/openspec/changes/projects/proposal.md +++ b/openspec/changes/projects/proposal.md @@ -22,7 +22,7 @@ - **Проекты в хабе.** Новая таблица `project` (схема v4): метка Jira (ключ, не меняется), название, номер доски Jira (может отсутствовать), тип доски (`scrum` / `kanban`, определяет агент). Маршруты `GET /projects`, `POST /projects`. Миграция заводит проект AWPCA «АРМ КА» с доской 4216 из нынешних настроек. -- **Базовая JQL собирается из проектов** — `project = AND labels in (<метки всех проектов>)`; настройки +- **Базовая JQL собирается из проектов** — `project = AND labels in ("<метка>", …)`; настройки `base_jql` и `board_id` уходят из `setting`. Создание проекта ставит полный `refresh`. - **Разрез по проекту в API.** `GET /board?project=` и `GET /issues?project=` отдают задачи проекта и их соседей (задачи других меток, связанные с ними, и их эпики) с признаком `neighbor` относительно проекта. diff --git a/openspec/changes/projects/specs/data-model/spec.md b/openspec/changes/projects/specs/data-model/spec.md index 9503eaf..64668b1 100644 --- a/openspec/changes/projects/specs/data-model/spec.md +++ b/openspec/changes/projects/specs/data-model/spec.md @@ -3,12 +3,13 @@ ### Requirement: Проекты Таблица `project` SHALL хранить проекты моста — подмножества задач Jira-проекта `project_key`, выделенные меткой: `label` (PK, метка Jira), `name`, `board_id` (номер доски Jira или `NULL`), `board_type` (`scrum` | `kanban` | `NULL` — -тип ещё не известен), `created_at`. Метка SHALL быть непустой строкой до 100 символов без пробельных символов, запятых, -кавычек и скобок; две метки, совпадающие без учёта регистра, MUST NOT существовать одновременно. Название SHALL быть +тип ещё не известен), `created_at`. Метка SHALL быть непустой строкой до 100 символов без пробельных и управляющих символов (категория +Unicode `Cc`), запятых, кавычек, скобок и обратной косой черты; две метки, совпадающие без учёта регистра, MUST NOT существовать одновременно. Название SHALL быть непустым после обрезки пробелов, до 100 символов. `board_id` — целое больше нуля или `NULL`. `board_type` SHALL записываться только по результату `refresh` от агента (см. `agent`). Метку, название и доску проекта после создания хаб не меняет (переименования и удаления нет). Базовая JQL SHALL вычисляться из проектов: -`project = AND labels in (<метки проектов по created_at, затем label через ", ">)`; без проектов базовой JQL +`project = AND labels in ("<метка>", …)` — каждая метка в двойных кавычках, метки по created_at, затем +label, через `, `; без проектов базовой JQL нет (`null`). Для проекта P задачи зеркала делятся на *свои* — с меткой P в `labels` (точное совпадение) — и *соседей* — задачи без @@ -17,7 +18,7 @@ #### Scenario: Базовая JQL из двух проектов - **WHEN** заведены проекты `AWPCA` (раньше) и `STOCKS_DRRK`, `project_key = AURORA` -- **THEN** базовая JQL — `project = AURORA AND labels in (AWPCA, STOCKS_DRRK)` +- **THEN** базовая JQL — `project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")` #### Scenario: Свои и соседи - **WHEN** у AURORA-1 метка `STOCKS_DRRK` и эпик AURORA-9 (метка `AWPCA`), связь AURORA-1 → AURORA-2 (метка `AWPCA`), @@ -28,6 +29,14 @@ - **WHEN** создаётся проект с меткой `STOCKS DRRK` - **THEN** отказ с указанием поля `label`, проекта нет +#### Scenario: Метка с обратной косой чертой или управляющим символом +- **WHEN** создаётся проект с меткой `A\B` или `A<перевод строки>B` +- **THEN** отказ с указанием поля `label`, проекта нет + +#### Scenario: Метка — зарезервированное слово JQL +- **WHEN** заведён проект с меткой `EMPTY` +- **THEN** базовая JQL — `project = AURORA AND labels in ("EMPTY")`, метка не расширяет выборку + #### Scenario: Та же метка другим регистром - **WHEN** есть проект `AWPCA`, создаётся проект `awpca` - **THEN** отказ «проект с такой меткой уже есть», проект один @@ -71,12 +80,12 @@ - **WHEN** хаб стартует с базой `schema_version = 3`, `base_jql = "project = AURORA AND labels = AWPCA"`, `board_id = 4216`, в `sync_run` полный прогон с этой `query` - **THEN** в `backups/` копия базы v3; проект `AWPCA` «АРМ КА» с `board_id = 4216`, `board_type = NULL`; ключи `base_jql` - и `board_id` в `setting` на месте; `query` того прогона — `project = AURORA AND labels in (AWPCA)`; задачи, заметки, + и `board_id` в `setting` на месте; `query` того прогона — `project = AURORA AND labels in ("AWPCA")`; задачи, заметки, пакеты на месте; `schema_version = 4` #### Scenario: refresh в очереди при миграции - **WHEN** в базе v3 стоит `queued` `refresh {jql: "project = AURORA AND labels = AWPCA"}` -- **THEN** после миграции его `params.jql` — `project = AURORA AND labels in (AWPCA)`, и по завершении он считается +- **THEN** после миграции его `params.jql` — `project = AURORA AND labels in ("AWPCA")`, и по завершении он считается полным прогоном #### Scenario: Два соединения к базе v1 diff --git a/openspec/changes/projects/specs/hub-api/spec.md b/openspec/changes/projects/specs/hub-api/spec.md index d5477a7..76f7a27 100644 --- a/openspec/changes/projects/specs/hub-api/spec.md +++ b/openspec/changes/projects/specs/hub-api/spec.md @@ -12,7 +12,7 @@ #### Scenario: Новый проект - **WHEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК", board_id: 5120, author: "viewer"}`, есть проект `AWPCA` - **THEN** 201; `GET /projects` отдаёт два проекта, у `STOCKS_DRRK` `board_type = null`; в очереди пакет автора `viewer` - с `refresh {jql: "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)"}` + с `refresh {jql: 'project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")'}` #### Scenario: Проект без доски - **WHEN** `POST /projects {label: "STOCKS_DRRK", name: "Запасы ДРРК"}` @@ -146,7 +146,7 @@ #### Scenario: Два проекта - **WHEN** интервал прошёл, очередь пуста, проекты `AWPCA` и `STOCKS_DRRK` -- **THEN** поставлен один `refresh` с JQL `project = AURORA AND labels in (AWPCA, STOCKS_DRRK)` +- **THEN** поставлен один `refresh` с JQL `project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")` ### Requirement: Снимок доски Хаб SHALL предоставлять `GET /board?project=` под клиентским токеном — согласованный снимок зеркала для доски связей, diff --git a/src/jira_hub/projects.py b/src/jira_hub/projects.py index 9c6461d..7e0d4bb 100644 --- a/src/jira_hub/projects.py +++ b/src/jira_hub/projects.py @@ -1,6 +1,7 @@ """Проекты-метки моста: таблица project, проверка метки, базовая JQL из меток, доски, миграция v3 → v4.""" import logging import re +import unicodedata from . import db, settings @@ -10,7 +11,7 @@ LEGACY_LABEL = "AWPCA" LEGACY_NAME = "АРМ КА" MAX_LEN = 100 BOARD_TYPES = ("scrum", "kanban") -_FORBIDDEN = ',"\'()' +_FORBIDDEN = ',"\'()\\' _FIELDS = "label, name, board_id, board_type, created_at" @@ -32,8 +33,8 @@ def _label_error(label) -> str | None: return "ожидается непустая строка" if len(label) > MAX_LEN: return f"длиннее {MAX_LEN} символов" - if any(c.isspace() or c in _FORBIDDEN for c in label): - return "пробелы и символы , \" ' ( ) недопустимы" + if any(c.isspace() or c in _FORBIDDEN or unicodedata.category(c) == "Cc" for c in label): + return "пробелы, управляющие символы и символы , \" ' ( ) \\ недопустимы" return None @@ -89,7 +90,7 @@ def base_jql(conn) -> str | None: if not found: return None key = settings.get(conn, "project_key") or "AURORA" - return f"project = {key} AND labels in ({', '.join(found)})" + return f"project = {key} AND labels in ({', '.join(f'"{x}"' for x in found)})" def boards(conn) -> list[dict]: diff --git a/tests/test_api.py b/tests/test_api.py index 47d0f8a..07ee54b 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -131,7 +131,7 @@ def test_interface_routes(hub, add_project): retry = c.post(f"/commands/{cmd['id']}/retry", {"author": "claude"}) assert c.get(f"/batches/{retry['id']}")["title"] == f"Повтор команды {cmd['id']}" full = c.post("/refresh", {"full": True}) - assert c.get(f"/batches/{full['id']}")["commands"][0]["params"]["jql"] == "project = AURORA AND labels in (AWPCA)" + assert c.get(f"/batches/{full['id']}")["commands"][0]["params"]["jql"] == 'project = AURORA AND labels in ("AWPCA")' keys = c.post("/refresh", {"keys": ["A-1"]}) assert c.get(f"/batches/{keys['id']}")["commands"][0]["params"] == {"keys": ["A-1"]} @@ -552,7 +552,7 @@ def test_settings_get_base_jql_from_projects(hub, add_project): c = hub["client"] assert c.get("/settings")["base_jql"] is None _add_project(hub, add_project) - assert c.get("/settings")["base_jql"] == "project = AURORA AND labels in (AWPCA)" + assert c.get("/settings")["base_jql"] == 'project = AURORA AND labels in ("AWPCA")' def test_settings_put_returns_all_settings(hub): @@ -794,9 +794,9 @@ def test_create_project(hub): # сце assert [p["label"] for p in got] == ["AWPCA", "STOCKS_DRRK"] and got[1]["issues_count"] == 0 b = c.get(f"/batches/{out['batch_id']}") assert b["author"] == "viewer" and b["title"] == "Новый проект STOCKS_DRRK" and b["project"] is None - assert b["commands"][0]["params"] == {"jql": "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)"} + assert b["commands"][0]["params"] == {"jql": 'project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")'} assert c.get("/settings")["settings_version"] == version # «Создание проекта не трогает версию настроек» - assert c.get("/settings")["base_jql"] == "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)" + assert c.get("/settings")["base_jql"] == 'project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")' def test_create_project_without_board(hub): @@ -811,6 +811,9 @@ def test_create_project_without_board(hub): ({"label": "AWPCA", "name": "x"}, 409, "label"), ({"label": "awpca", "name": "x"}, 409, "label"), ({"label": "STOCKS DRRK", "name": "x"}, 422, "label"), + ({"label": "A\\B", "name": "x"}, 422, "label"), + ({"label": "A\nB", "name": "x"}, 422, "label"), + ({"label": "A\tB", "name": "x"}, 422, "label"), ({"label": "STOCKS_DRRK", "name": " "}, 422, "name"), ({"label": "STOCKS_DRRK", "name": "x", "board_id": "5120"}, 422, "board_id"), ({"label": "STOCKS_DRRK", "name": "x", "author": 5}, 422, None), diff --git a/tests/test_db.py b/tests/test_db.py index d871074..27e8753 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -257,7 +257,7 @@ 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)" + 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" # ключи на месте diff --git a/tests/test_projects.py b/tests/test_projects.py index d53b632..12ce18b 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -35,7 +35,7 @@ def test_light_list_same_order_no_count(conn): assert all("issues_count" not in p and set(p) == {"label", "board_id", "board_type"} for p in got) -@pytest.mark.parametrize("label", ["", "STOCKS DRRK", "A\tB", "A,B", 'A"B', "A'B", "A(B", "A)B", "X" * 101, 5, None]) +@pytest.mark.parametrize("label", ["", "STOCKS DRRK", "A\tB", "A\nB", "A\\B", "A\x00B", "A\x07B", "A\x7fB", "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, "Название") @@ -85,11 +85,18 @@ def test_same_label_other_case(conn): 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)" + 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_base_jql_single_and_reserved_words(conn): + projects.create(conn, "AWPCA", "АРМ КА") + assert projects.base_jql(conn) == 'project = AURORA AND labels in ("AWPCA")' + projects.create(conn, "EMPTY", "Пустой") # зарезервированное слово JQL — в кавычках + assert projects.base_jql(conn) == 'project = AURORA AND labels in ("AWPCA", "EMPTY")' + + 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") diff --git a/tests/test_queue.py b/tests/test_queue.py index f4227fe..9fde9fe 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -170,7 +170,7 @@ def test_auto_refresh_two_projects(conn, add_project): # сцен add_project(conn, "STOCKS_DRRK", board_id=None) queue.claim(conn, agent_version=1, settings_version=1) b = queue.maybe_auto_refresh(conn) - assert queue.batch_view(conn, b["id"])["commands"][0]["params"] == {"jql": "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)"} + assert queue.batch_view(conn, b["id"])["commands"][0]["params"] == {"jql": 'project = AURORA AND labels in ("AWPCA", "STOCKS_DRRK")'} def test_auto_refresh_conditions(conn, add_project):