fix(hub): метки проектов в базовой JQL в кавычках, запрет \ и управляющих символов (I-2, #61)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-19 22:55:05 +04:00
parent ec4fb8b1e7
commit 1f2fc142b7
9 changed files with 47 additions and 26 deletions

View File

@ -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 = <project_key> AND labels in (<метки по
created_at, затем label>)"`; функция в `settings` (или новом модуле `projects`), её используют `POST /refresh
3. **Базовая JQL собирается, а не хранится.** `base_jql = 'project = <project_key> 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 «нет проектов», автообновление

View File

@ -22,7 +22,7 @@
- **Проекты в хабе.** Новая таблица `project` (схема v4): метка Jira (ключ, не меняется), название, номер доски Jira
(может отсутствовать), тип доски (`scrum` / `kanban`, определяет агент). Маршруты `GET /projects`, `POST /projects`.
Миграция заводит проект AWPCA «АРМ КА» с доской 4216 из нынешних настроек.
- **Базовая JQL собирается из проектов** — `project = <project_key> AND labels in (<метки всех проектов>)`; настройки
- **Базовая JQL собирается из проектов** — `project = <project_key> AND labels in ("<метка>", …)`; настройки
`base_jql` и `board_id` уходят из `setting`. Создание проекта ставит полный `refresh`.
- **Разрез по проекту в API.** `GET /board?project=` и `GET /issues?project=` отдают задачи проекта и их соседей
(задачи других меток, связанные с ними, и их эпики) с признаком `neighbor` относительно проекта.

View File

@ -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 = <project_key> AND labels in (<метки проектов по created_at, затем label через ", ">)`; без проектов базовой JQL
`project = <project_key> 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

View File

@ -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=` под клиентским токеном — согласованный снимок зеркала для доски связей,

View File

@ -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]:

View File

@ -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),

View File

@ -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" # ключи на месте

View File

@ -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")

View File

@ -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):