fix(hub): метки проектов в базовой JQL в кавычках, запрет \ и управляющих символов (I-2, #61)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
ec4fb8b1e7
commit
1f2fc142b7
@ -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 «нет проектов», автообновление
|
||||
|
||||
@ -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` относительно проекта.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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=` под клиентским токеном — согласованный снимок зеркала для доски связей,
|
||||
|
||||
@ -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]:
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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" # ключи на месте
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user