# Несколько проектов-меток в мосте (хаб) — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** проект (метка Jira + своя доска) — сущность хаба: базовая JQL собирается из проектов, API, MCP и веб-приложение работают в разрезе одного проекта, AWPCA после выкатки работает как раньше. **Architecture:** новый модуль `projects` (таблица `project`, схема v4, миграция v3→v4, вычисляемая базовая JQL, доски) стоит рядом с `settings` и ниже `queue`/`api`. Разрез «свои + соседи + эпики своих» — одна SQL-выборка в `mirror` для `GET /board` и `GET /issues`. Проверки пакета проекта и выбор доски для `ensure_sprint` — в `queue` при постановке; `boards` для `refresh` — в `queue.deliver_params` при выдаче. MCP привязывается к проекту флагом `--project` и ходит только через `client`. Веб-приложение держит текущий проект в адресе (`?project=`) и `localStorage`, раскладка доски делится по проектам суффиксом ключей (у AWPCA — прежние ключи). **Tech Stack:** Python 3.13 (stdlib http, SQLite), pytest, пакет `mcp` (FastMCP); React 19 + TypeScript + Vite, TanStack Query, react-router 8, Vitest, Testing Library, Playwright. **Spec:** `openspec/changes/projects/` — `proposal.md`, `design.md` (решения 1–15, «Decisions made without discussion» 1–21), `specs/{data-model,migration,hub-api,commands,mcp,web-app,agent}/spec.md`. Спека `agent` — только контракт для хабовой стороны (агент v12 — отдельный план в `jira-agent`). **Worktree:** `/Users/andrey.epifancev/projects/jira-bridge/jira-hub/.wt/scopes`, ветка `feat/61-projects` (aep/jira-hub#61). Все команды — из корня worktree; основной checkout `jira-hub` не трогать (guard-main). ## Global Constraints - Схема базы — версия 4: таблица `project(label TEXT PK, name TEXT NOT NULL, board_id INTEGER, board_type TEXT, created_at TEXT NOT NULL)`, колонка `batch.project TEXT` (NULL — пакет без проекта). - Метка: непустая, ≤ 100 символов, без пробельных символов, `,`, `"`, `'`, `(`, `)`; регистр хранится как введён; две метки, равные без учёта регистра (`str.casefold`), не существуют. Название — непустое после `strip()`, ≤ 100. `board_id` — `int > 0` (не `bool`) или `None`. `board_type` ∈ {`scrum`, `kanban`, `None`}. - Базовая JQL: `project = AND labels in (<метки по created_at, затем label, через ", ">)`; без проектов — `None`. Из `setting.base_jql` новый код не читает нигде. - `settings.DEFAULTS` без `base_jql` и `board_id`; `AGENT_KEYS` без `base_jql`, с `board_id` (запасная доска агента). Миграция v4 ключи `base_jql`/`board_id` в `setting` **не удаляет**. - Создание проекта `settings_version` не поднимает. - Принадлежность задачи проекту и разрез — точное совпадение метки (`json_each`); «метка другого проекта» и «P в `labels_remove`» — без учёта регистра; в `ensure_issue` пакета проекта метка, равная P без учёта регистра, заменяется на P. - Соседи на доске — обычные карточки, как сейчас (отдельной отрисовки нет); `hasSprint` — по своим задачам снимка (`neighbor = false`). - `notes_set` в привязанной сессии MCP — свои задачи и временные ключи `new:`. - Автор пакетов: MCP — `claude`, веб-приложение — `viewer`, API без автора — `api`, автообновление — `hub`. - MCP: `mcp_server` не импортирует `queue`/`mirror`/`db`/`projects`, ходит только через `client` (границы ARCHITECTURE.md). - Веб-приложение: у проекта `AWPCA` ключи `localStorage` доски — ровно нынешние (`graph-<имя>:board`, `graph-<имя>`), у остальных — те же с суффиксом `@<метка>`. Golden-эталоны `web/src/board/__fixtures__/*.expected.json` не перегенерировать (см. Task 9 про `hub-empty`). - Новых зависимостей (Python и npm) нет; `dangerouslySetInnerHTML` не появляется. - Язык комментариев, сообщений об ошибках, подписей и коммитов — русский, идентификаторы — английские. Каждый коммит — с `(#61)` в заголовке и последней строкой `Co-Authored-By: Claude Opus 5.5 `. - Зелёным считается только весь набор: `make check` (pytest + lint и тесты фронта); в задачах, где менялся фронт, — ещё `cd web && npm run e2e`. - Любое решение исполнителя, не записанное в спеке или в этом плане, дописывается в `openspec/changes/projects/design.md`, раздел `## Decisions made without discussion` (дата, решение, обоснование, цена ошибки). Находки ревью задачи n — маркеры `T.`. ## Review Focus 1. Живая база v3 с `base_jql` в неожиданной форме (`labels = "AWPCA"` в кавычках, хвост `ORDER BY key`, нестандартная JQL) — хаб стартует, миграция не падает: кавычки снимаются, неразобранная JQL даёт «проектов нет» и предупреждение в лог (тесты в Task 1). 2. Агент v11 (или v12 без запроса типов) присылает результат `refresh` без ключа `boards`, ключи `boards` — строки (`"5120"`), значение `null` или мусорный ключ — типы досок не меняются, отчёт принимается (тесты в Task 6). 3. Прямой адрес карточки `/app/issues/AURORA-9001` без `project`: выбор проекта не переписывает адрес и не закрывает окно; список под окном — по запомненному проекту (тест в Task 8). 4. MCP с `--project`, хаб выключен — ответ «хаб не запущен: jira-hub serve», а не «проект … не заведён» (тест в Task 7). 5. Повтор (`retry`) старой команды: `ensure_sprint`, поставленной до миграции без `board_id` и без проекта пакета, получает доску заново; повтор команды проектного пакета без `project` наследует проект и его проверки (тесты в Task 5 и Task 6). --- ### Task 1: Модуль `projects`, схема v4 и миграция v3 → v4 **Модель исполнителя:** sonnet (интеграция: схема, цикл миграций в `db`, новый модуль; алгоритм разбора дан в плане). **Files:** - Create: `src/jira_hub/projects.py` - Modify: `src/jira_hub/db.py` (`SCHEMA`: таблица `project`, колонка `batch.project`; `SCHEMA_VERSION = 4`; `MIGRATIONS[3]`; цикл `migrate` выполняет и вызываемые шаги) - Modify: `tests/conftest.py` (фикстура `add_project`) - Test: `tests/test_projects.py` (новый), `tests/test_db.py` **Interfaces:** - Consumes: `settings.get(conn, key)`, `db.now()`, `db.transaction(conn)`. - Produces (`src/jira_hub/projects.py`): - `LEGACY_LABEL = "AWPCA"`, `LEGACY_NAME = "АРМ КА"`, `MAX_LEN = 100`, `BOARD_TYPES = ("scrum", "kanban")`. - `class ProjectError(ValueError)` с атрибутом `field: str` (`"label"`, `"name"`, `"board_id"`), `str(e)` = `": <причина>"`; `class ProjectExists(ProjectError)` (`field = "label"`, текст «проект с такой меткой уже есть: <метка>»). - `validate(label, name, board_id) -> tuple[str, str, int | None]` — проверка по Global Constraints, возвращает `(label, name.strip(), board_id)`. - `create(conn, label: str, name: str, board_id: int | None = None) -> dict` — проверка, уникальность без учёта регистра, вставка с `board_type = NULL`, `created_at = db.now()`; внутри `db.transaction` (вложенный вызов — savepoint); возвращает `get(conn, label)`. - `get(conn, label: str) -> dict | None` — точное совпадение; поля `label, name, board_id, board_type, created_at`. - `list_projects(conn) -> list[dict]` — те же поля + `issues_count` (задачи зеркала с меткой, точное совпадение через `json_each`), порядок `created_at, label`. - `labels(conn) -> list[str]` — в том же порядке. - `base_jql(conn) -> str | None` — `project_key` из `setting`, при отсутствии — `"AURORA"`. - `boards(conn) -> list[dict]` — `[{"id": int, "type": str | None}]`, доски проектов без повторов (порядок первого проекта), `type` — первый известный тип среди проектов с этой доской; проекты без доски не входят. - `record_board_types(conn, boards) -> int` — для пар `{"": "scrum" | "kanban"}` обновляет `board_type` всех проектов с `board_id = int(id)`; не-словарь, нечисловой ключ, значение не из `BOARD_TYPES` (в том числе `None`) — пропуск; возвращает число обновлённых проектов. - `parse_legacy_labels(jql) -> list[str] | None`, `migrate_v3(conn) -> None` (алгоритм — Step 3). - Produces (`tests/conftest.py`): фикстура `add_project` — функция `add_project(conn, label="AWPCA", name=None, board_id=4216, board_type="scrum") -> dict` (через `projects.create`; `name=None` → `LEGACY_NAME` для AWPCA, иначе метка; `board_type` пишется `record_board_types`, если доска и тип заданы). - [ ] **Step 1: Write the failing tests** `tests/test_projects.py` (фикстура `conn` модуля: `open_db()` + `settings.ensure_defaults`): ```python @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): ... # field == "name", проекта нет @pytest.mark.parametrize("board", [0, -1, True, "5120", 1.5]) def test_bad_board(conn, board): ... # field == "board_id" 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 ``` `tests/test_db.py`: - `test_connect_creates_schema`: `"project"` в таблицах, `"project"` в колонках `batch`, `schema_version == "4"`. - `_v1_database` и `test_migrates_v2_with_backup` дополнительно делают `ALTER TABLE batch DROP COLUMN project` и `DROP TABLE project` перед записью старой версии; ожидаемая версия после миграции — `"4"`. - Новый помощник `_v3_database(path, base_jql="project = AURORA AND labels = AWPCA", board_id=4216)`: `db.connect` + `settings.ensure_defaults`; записывает `base_jql`/`board_id` в `setting` напрямую (`INSERT OR REPLACE`, значения через `json.dumps`); пакет с `refresh {jql: base_jql}` в `done` + строка `sync_run(kind='jql', query=base_jql, finished_at, command_id)`; пакет с `refresh {jql: base_jql}` в `queued` и ещё один в `claimed`; `refresh {jql: "key = A-1"}` в `queued`; задача, заметка; затем `DROP COLUMN project`, `DROP TABLE project`, `schema_version = 3`. ```python 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() ``` - [ ] **Step 2: Run tests to verify they fail** Run: `uv run pytest tests/test_projects.py tests/test_db.py -v` Expected: FAIL (`ModuleNotFoundError: jira_hub.projects`, версия схемы `3`). - [ ] **Step 3: Implement `src/jira_hub/projects.py` and the schema** - `db.SCHEMA`: `CREATE TABLE IF NOT EXISTS project (...)` по Global Constraints; в `CREATE TABLE batch` — `project TEXT` последней колонкой. Индекс по `batch.project` не заводить (на базе v3 `SCHEMA` выполняется до `ALTER`). - `db.MIGRATIONS[3] = ["ALTER TABLE batch ADD COLUMN project TEXT", _migrate_projects]`, где `_migrate_projects(conn)` делает `from . import projects` внутри функции (цикл импорта `projects → db`) и вызывает `projects.migrate_v3(conn)`; цикл в `migrate`: `step(conn) if callable(step) else conn.execute(step)`. - Разбор и миграция (алгоритм не задаётся сигнатурой): ```python _LEGACY = re.compile(r"\blabels\s*(?:=\s*(?P\S+)|in\s*\((?P[^()]*)\))\s*$", re.IGNORECASE) def parse_legacy_labels(jql) -> list[str] | None: m = _LEGACY.search(jql) if isinstance(jql, str) else None if not m: return None out = [] for raw in ([m["one"]] if m["one"] is not None else m["many"].split(",")): label = raw.strip().strip('"') if _label_error(label): # та же проверка, что в validate 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: old = settings.get(conn, "base_jql") labels = parse_legacy_labels(old) if labels 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(labels) > 1: log.warning("миграция v4: доска %s не отдана ни одному проекту — меток несколько: %s", board, ", ".join(labels)) board = None for label in labels: 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)) ``` - Остальные функции — по **Interfaces**; уникальность меток — сравнение `casefold()` в Python по `labels(conn)` (SQLite `lower()` не знает кириллицу). - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest` — весь набор зелёный (остальной код ещё читает `setting.base_jql` по умолчанию — это меняет Task 2). - [ ] **Step 5: Commit** ```bash git add src/jira_hub/projects.py src/jira_hub/db.py tests/conftest.py tests/test_projects.py tests/test_db.py git commit -m "Хаб: таблица project, схема v4 и миграция base_jql в проекты (#61)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 2: Базовая JQL из проектов везде; настройки и `init` **Модель исполнителя:** sonnet (интеграция: замена `base_jql` в `settings`, `queue`, `api`, `cli` и правка существующих тестов). **Files:** - Modify: `src/jira_hub/settings.py` (`DEFAULTS`, `AGENT_KEYS`, `validate`, `init_from_config`) - Modify: `src/jira_hub/projects.py` (`ensure_legacy`) - Modify: `src/jira_hub/queue.py` (`is_full_refresh`, `maybe_auto_refresh`) - Modify: `src/jira_hub/api.py` (`h_refresh`, `h_board`, `h_settings_get`) - Modify: `src/jira_hub/cli.py` (`init` заводит AWPCA) - Test: `tests/test_settings.py`, `tests/test_cli.py`, `tests/test_queue.py`, `tests/test_api.py`, `tests/test_mcp.py` **Interfaces:** - Consumes: `projects.base_jql`, `projects.create`, `projects.get`, `projects.LEGACY_*` (Task 1); фикстура `add_project`. - Produces: - `projects.ensure_legacy(conn, board_id: int | None) -> bool` — заводит `AWPCA` «АРМ КА» с доской, если проекта с меткой `awpca` (без учёта регистра) нет; `True` — заведён. Неверный `board_id` из конфига → проект без доски. - `settings.validate("base_jql", …)` → `ValueError("базовая JQL собирается из проектов (GET /projects)")`; `settings.validate("board_id", …)` → `ValueError("доска Jira — свойство проекта (POST /projects)")`. - `GET /settings` — все настройки + `base_jql` = `projects.base_jql(conn)` (перекрывает оставшийся ключ v3). - `POST /refresh {full: true}` без проектов → 422 `bad_refresh` «нет проектов». - [ ] **Step 1: Write the failing tests** - `tests/test_settings.py`: `test_defaults_without_jql_and_board` — `"base_jql"`, `"board_id"` нет в `DEFAULTS` и в `get_all` новой базы; `"base_jql" not in AGENT_KEYS`, `"board_id" in AGENT_KEYS`, `for_agent(conn)["board_id"] is None`. `test_jql_and_board_not_writable` — `validate("base_jql", "project = AURORA")` и `validate("board_id", 1)` → `ValueError` с «собирается из проектов» / «свойство проекта». `test_init_from_config` — вернувшийся список ключей и таблица `setting` без `base_jql` и `board_id` (прежняя проверка `board_id == 4216` уходит в `test_cli`). - `tests/test_cli.py` (`test_init_import_tokens`): после `init --config FIXTURE` — `projects.list_projects(conn)` = один `AWPCA` «АРМ КА», `board_id == 4216`; `settings.get(conn, "board_id") is None`; `init --config … --force` — проект один, доска та же. - `tests/test_queue.py`: ```python def test_auto_refresh_needs_projects(conn): queue.claim(conn, agent_version=1, settings_version=1) assert queue.maybe_auto_refresh(conn) is None # агент на связи, очередь пуста, проектов нет def test_auto_refresh_two_projects(conn, add_project): # сценарий «Два проекта» add_project(conn); 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)"} ``` `test_auto_refresh_conditions`, `test_empty_full_refresh_keeps_neighbors`, `test_redelivered_full_refresh_marks_neighbors_once` заводят `add_project(conn)` и берут JQL из `projects.base_jql(conn)`. - `tests/test_api.py`: `test_settings_put_rejects_jql_and_board` — `PUT /settings {base_jql: …}` → 422, `detail` содержит «базовая JQL собирается из проектов», `settings_version` прежний; то же для `{board_id: 12}` (прежний тест, писавший `board_id: 12`, переписать без него). `test_full_refresh_without_projects` — 422, `detail` «нет проектов», пакетов нет (сценарий «Полный прогон без проектов»). Тест на строке ~124 (`jql == "project = AURORA AND labels = AWPCA"`) — заводит AWPCA и ждёт `"project = AURORA AND labels in (AWPCA)"`. Тест на строке ~526 (`out["base_jql"]`) — без проектов `None`, после `add_project` — вычисленная строка. Тесты `last_sync` у `GET /board` и `_run_refresh(... {"full": True} ...)` заводят проект в фикстуре. - `tests/test_mcp.py`: фикстура `live` заводит `add_project(conn)` (нужен `refresh(full=True)`). - [ ] **Step 2: Run tests to verify they fail** Run: `uv run pytest tests/test_settings.py tests/test_cli.py tests/test_queue.py tests/test_api.py -v` Expected: FAIL (`base_jql` ещё в `DEFAULTS`, `init` не заводит проект, `full` берёт строку из настроек). - [ ] **Step 3: Implement** - `settings`: убрать `base_jql`, `board_id` из `DEFAULTS` и из `values` в `init_from_config`; `base_jql` убрать из `AGENT_KEYS` (агент v11/v12 его не читает); ветки `validate` для двух ключей — первыми. - `queue.is_full_refresh`: сравнение с `projects.base_jql(conn)` (`None` → `False`). `queue.maybe_auto_refresh`: `jql = projects.base_jql(conn)`; `None` → `None` до остальных проверок. - `api.h_refresh`: `full` → `projects.base_jql(conn)`, `None` → `ApiError(422, "bad_refresh", "нет проектов")`. `api.h_board`: `mirror.board(conn, projects.base_jql(conn))`. `api.h_settings_get`: `{**settings.get_all(conn), "base_jql": projects.base_jql(conn)}`; ответ `h_settings_put` — так же. - `cli` `init`: после `settings.init_from_config` — `projects.ensure_legacy(conn, cfg.get("sprints", {}).get("board_id"))`, строка в выводе «проект AWPCA «АРМ КА», доска » при создании. - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest` — весь набор зелёный. Затем `grep -n "base_jql" src/jira_hub/*.py` — `settings.get(conn, "base_jql")` есть только в `projects.migrate_v3`. - [ ] **Step 5: Commit** ```bash git add src/jira_hub tests git commit -m "Хаб: базовая JQL из проектов, настройки без base_jql/board_id, init заводит AWPCA (#61)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 3: Маршруты `GET /projects` и `POST /projects` **Модель исполнителя:** haiku (готовый код: два обработчика по Interfaces, тесты в плане). **Files:** - Modify: `src/jira_hub/api.py` (`h_projects_get`, `h_projects_post`, `ROUTES`) - Test: `tests/test_api.py` **Interfaces:** - Consumes: `projects.create`, `projects.list_projects`, `projects.base_jql`, `ProjectError`, `ProjectExists` (Task 1); `queue.create_batch(conn, title, author, cmds, files_dir=None)`. - Produces: - `GET /projects` → `200 {"projects": [{label, name, board_id, board_type, created_at, issues_count}]}`. - `POST /projects {label, name, board_id?, author?}` → `201 {"project": {...как в списке без issues_count...}, "batch_id": int}`; пакет «Новый проект <метка>», автор `author or "api"`, одна команда `refresh {jql: <базовая JQL с новой меткой>}`, `batch.project = NULL`. Ошибки: `ProjectExists` → `409 project_exists`; `ProjectError` → `422 bad_project`, `detail = str(e)` (начинается с имени поля); `author` не строка → 422. Проект и пакет — в одной `db.transaction`. - [ ] **Step 1: Write the failing tests** ```python def test_create_project(hub): # сценарий «Новый проект» c = hub["client"] conn = hub["hub"].conn(); version = settings.get(conn, "settings_version"); conn.close() c.post("/projects", {"label": "AWPCA", "name": "АРМ КА", "board_id": 4216}) out = c.post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК", "board_id": 5120, "author": "viewer"}) assert out["project"]["label"] == "STOCKS_DRRK" and out["project"]["board_type"] is None got = c.get("/projects")["projects"] 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 c.get("/settings")["settings_version"] == version # «Создание проекта не трогает версию настроек» assert c.get("/settings")["base_jql"] == "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)" def test_create_project_without_board(hub): ... # 201, board_id None, author пакета "api" @pytest.mark.parametrize("body, status, field", [ ({"label": "AWPCA", "name": "…"}, 409, "label"), ({"label": "awpca", "name": "…"}, 409, "label"), ({"label": "STOCKS DRRK", "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), ]) def test_create_project_errors(hub, body, status, field): c = hub["client"] c.post("/projects", {"label": "AWPCA", "name": "АРМ КА"}) before = len(c.get("/batches")["batches"]) with pytest.raises(HubError) as e: c.post("/projects", body) assert e.value.status == status and (field is None or e.value.detail.startswith(field)) assert len(c.get("/projects")["projects"]) == 1 and len(c.get("/batches")["batches"]) == before ``` `test_auth_scopes`: `GET /projects` агентским токеном → 401. - [ ] **Step 2: Run tests to verify they fail** Run: `uv run pytest tests/test_api.py -k project -v` Expected: FAIL (404 на `/projects`). - [ ] **Step 3: Implement handlers** по **Interfaces**; маршруты `("GET", r"^/projects$", h_projects_get, "client")`, `("POST", r"^/projects$", h_projects_post, "client")`. - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest` — весь набор зелёный. - [ ] **Step 5: Commit** ```bash git add src/jira_hub/api.py tests/test_api.py git commit -m "Хаб: маршруты GET/POST /projects, создание проекта ставит полный refresh (#61)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 4: Разрез по проекту в зеркале, `GET /board?project=`, `GET /issues?project=` **Модель исполнителя:** sonnet (интеграция: CTE дан в плане, встраивается в `board` и `list_issues` с фильтрами). **Files:** - Modify: `src/jira_hub/mirror.py` (`SCOPE_CTE`, `board`, `list_issues`, `labels_of`) - Modify: `src/jira_hub/api.py` (`ISSUE_FILTERS` + `project`, `h_issues`, `h_board`, `_project_param`) - Test: `tests/test_mirror.py`, `tests/test_api.py` **Interfaces:** - Consumes: `projects.get` (Task 1), `projects.base_jql` (Task 2). - Produces: - `mirror.board(conn, base_jql, project: str | None = None) -> dict` — с `project`: `issues` — свои и соседи, `neighbor` относительно проекта; `links` — связи, у которых `source` или `target` — своя задача; `last_sync` — как без проекта. - `mirror.list_issues(conn, filters=None, limit=50, offset=0, closed_statuses=())` — `filters["project"]` включает разрез; фильтр `neighbor` — относительно проекта; остальные фильтры, сортировка и порции — поверх. - `mirror.labels_of(conn, key: str) -> list[str] | None` — метки задачи зеркала, `None` — задачи нет (использует Task 5). - `api._project_param(conn, q) -> str | None` — `q["project"]` или `None`; неизвестный проект → `ApiError(404, "not_found", "project <метка>")`. - [ ] **Step 1: Write the failing tests** `tests/test_mirror.py` (помощник `issue()` уже есть; `PROJ = "STOCKS_DRRK"`): ```python def _scene(conn): # сценарий data-model «Свои и соседи» + hub-api «Снимок проекта» mirror.apply_state(conn, [ issue("AURORA-1", labels=["STOCKS_DRRK"], epic_key="AURORA-9"), issue("AURORA-2", labels=["AWPCA"], epic_key=None), issue("AURORA-3", labels=["AWPCA"], epic_key=None), issue("AURORA-4", labels=["STOCKS_DRRK", "kind-dev"], epic_key="AURORA-404"), # эпика нет в зеркале issue("AURORA-5", labels=["AWPCA"], epic_key=None), issue("AURORA-9", labels=["AWPCA"], issue_type="Фича", epic_key=None)], [{"source_key": "AURORA-1", "target_key": "AURORA-2", "link_type": "L"}, {"source_key": "AURORA-2", "target_key": "AURORA-3", "link_type": "L"}, {"source_key": "AURORA-5", "target_key": "AURORA-4", "link_type": "L"}, # входящая — тоже сосед {"source_key": "AURORA-4", "target_key": "AURORA-777", "link_type": "L"}], # конца нет в зеркале neighbor_keys=["AURORA-1"]) # признак зеркала не важен в разрезе def test_board_project_scope(conn): _scene(conn) snap = mirror.board(conn, BASE, project=PROJ) assert [(i["key"], i["neighbor"]) for i in snap["issues"]] == [ ("AURORA-1", False), ("AURORA-2", True), ("AURORA-4", False), ("AURORA-5", True), ("AURORA-9", True)] assert {(l["source"], l["target"]) for l in snap["links"]} == { ("AURORA-1", "AURORA-2"), ("AURORA-5", "AURORA-4"), ("AURORA-4", "AURORA-777")} assert len(mirror.board(conn, BASE)["issues"]) == 6 # без проекта — всё зеркало def test_list_issues_project(conn): # сценарий «Список проекта» _scene(conn) keys = lambda **f: [i["key"] for i in mirror.list_issues(conn, {"project": PROJ, **f})] assert sorted(keys()) == ["AURORA-1", "AURORA-2", "AURORA-4", "AURORA-5", "AURORA-9"] assert sorted(keys(neighbor="1")) == ["AURORA-2", "AURORA-5", "AURORA-9"] assert sorted(keys(neighbor="0")) == ["AURORA-1", "AURORA-4"] assert keys(label="kind-dev") == ["AURORA-4"] assert {i["key"]: i["neighbor"] for i in mirror.list_issues(conn, {"project": PROJ})}["AURORA-1"] is False def test_labels_of(conn): _scene(conn) assert mirror.labels_of(conn, "AURORA-4") == ["STOCKS_DRRK", "kind-dev"] assert mirror.labels_of(conn, "AURORA-404") is None ``` `tests/test_api.py`: `test_board_and_issues_by_project` — после `POST /projects` (AWPCA, STOCKS_DRRK) и `/agent/state` со сценой `GET /board?project=STOCKS_DRRK` и `GET /issues?project=STOCKS_DRRK&neighbor=1` отдают ожидаемое; `last_sync` совпадает с ответом без `project`; `GET /issues?project=NOPE` и `GET /board?project=NOPE` → 404. - [ ] **Step 2: Run tests to verify they fail** Run: `uv run pytest tests/test_mirror.py tests/test_api.py -k "project or labels_of" -v` Expected: FAIL (`board()` не принимает `project`). - [ ] **Step 3: Implement** Выборка разреза — один CTE для `board` и `list_issues` (единственный параметр — метка, первым в списке аргументов запроса): ```sql WITH own(key) AS ( SELECT i.key FROM issue i WHERE EXISTS (SELECT 1 FROM json_each(i.labels) WHERE value = ?)), near(key) AS ( SELECT target_key FROM issue_link WHERE source_key IN (SELECT key FROM own) UNION SELECT source_key FROM issue_link WHERE target_key IN (SELECT key FROM own) UNION SELECT epic_key FROM issue WHERE key IN (SELECT key FROM own) AND epic_key IS NOT NULL), scope(key, nb) AS ( SELECT key, 0 FROM own UNION ALL SELECT n.key, 1 FROM near n JOIN issue i ON i.key = n.key WHERE n.key NOT IN (SELECT key FROM own)) ``` `board`: задачи — `JOIN scope s ON s.key = i.key`, `neighbor` = `s.nb`; связи — `WHERE source_key IN own OR target_key IN own` (тот же CTE). `list_issues`: при `project` — тот же `JOIN`, фильтр `neighbor` — по `s.nb`, в результате `neighbor = bool(s.nb)`. `api.h_board` / `api.h_issues`: `project = _project_param(conn, q)` до запроса к зеркалу. - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest` — весь набор зелёный. - [ ] **Step 5: Commit** ```bash git add src/jira_hub/mirror.py src/jira_hub/api.py tests/test_mirror.py tests/test_api.py git commit -m "Хаб: разрез по проекту — свои, соседи по связям и эпики своих в /board и /issues (#61)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 5: Пакет проекта — проверки постановки, `batch.project`, `GET /batches?project=`, повтор **Модель исполнителя:** sonnet (интеграция: проверки постановки в `queue`, API и повтор). **Files:** - Modify: `src/jira_hub/queue.py` (`create_batch`, `_check_project`, `list_batches`, `retry_command`) - Modify: `src/jira_hub/api.py` (`h_batches_post`, `h_batches_get`, `h_command_retry`) - Test: `tests/test_queue.py`, `tests/test_api.py` **Interfaces:** - Consumes: `projects.get`, `projects.labels` (Task 1); `mirror.labels_of` (Task 4). - Produces: - `queue.create_batch(conn, title, author, cmds, files_dir=None, project: str | None = None) -> dict` — порядок: `C.validate` → `_check_project` (если `project is not None`) → `_check_setup` → хвостовой `refresh` → вставка с `batch.project`. Ответ прежний (`id`, `estimate_seconds`, `count`). - `queue._check_project(conn, cmds: list[dict], project: str) -> list[dict]` — новые словари команд (входные `params` не мутируются); `CommandError` с текстами: «команда {i}: проекта {P} нет», «команда {i}: метка другого проекта: {L}», «команда {i}: снять метку своего проекта {P} нельзя», «команда {i}: {key} не в проекте {P}», «команда {i}: связь {source} → {target} не касается проекта {P}». - `queue.list_batches(conn, limit=20, project: str | None = None) -> list[dict]`; `batch_view` отдаёт `project`. - `queue.retry_command(conn, command_id, author, files_dir=None, project: str | None = None) -> dict` — `project` передан и не равен `batch.project` исходной (в том числе `NULL`) → `CommandError(f"команда #{id} не из проекта {project}")`; не передан — новый пакет с проектом исходного. - API: `POST /batches {…, project?}` (не строка → 422 `bad_batch`); `GET /batches?project=`; `POST /commands/{id}/retry {author?, project?}` (не строка → 422). - [ ] **Step 1: Write the failing tests** `tests/test_queue.py` (фикстуры `conn`, `add_project`; помощник `two(conn, add_project)` заводит `AWPCA` 4216 scrum и `STOCKS_DRRK` без доски и кладёт в зеркало `AURORA-1538` (`["AWPCA"]`) и `AURORA-1` (`["STOCKS_DRRK"]`)): ```python S = "STOCKS_DRRK" def new_issue(labels=None, temp="new:1"): p = {"temp_key": temp, "issue_type": "task", "summary": "Сверка остатков"} return {"type": "ensure_issue", "params": p if labels is None else {**p, "labels": labels}} def test_project_label_appended(conn, add_project): # сценарий «Метка проекта дописана» two(conn, add_project) b = queue.create_batch(conn, "x", "claude", [new_issue(["kind-dev"])], project=S) view = queue.batch_view(conn, b["id"]) assert view["project"] == S and view["commands"][0]["params"]["labels"] == ["kind-dev", S] b = queue.create_batch(conn, "y", "claude", [new_issue(temp="new:2")], project=S) assert queue.batch_view(conn, b["id"])["commands"][0]["params"]["labels"] == [S] def test_project_label_case_variant_replaced(conn, add_project): # своя метка другим регистром заменяется на P two(conn, add_project) b = queue.create_batch(conn, "x", "claude", [new_issue(["stocks_drrk", "kind-dev"])], project=S) assert queue.batch_view(conn, b["id"])["commands"][0]["params"]["labels"] == [S, "kind-dev"] b = queue.create_batch(conn, "y", "claude", [new_issue(["Stocks_Drrk", S], temp="new:2")], project=S) assert queue.batch_view(conn, b["id"])["commands"][0]["params"]["labels"] == [S] # без повтора P @pytest.mark.parametrize("cmd, msg", [ (new_issue(["awpca"]), "метка другого проекта: awpca"), # «Чужая метка проекта» (fields("AURORA-1538"), "AURORA-1538 не в проекте STOCKS_DRRK"), # «Задача другого проекта» (fields("AURORA-1", labels_remove=["stocks_drrk"]), "снять метку своего проекта"), (fields("AURORA-1", labels_add=["AWPCA"]), "метка другого проекта: AWPCA"), ({"type": "ensure_comment", "params": {"key": "AURORA-404", "marker": "m", "body": "b"}}, "AURORA-404 не в проекте"), ({"type": "ensure_link", "params": {"source": "AURORA-1538", "target": "AURORA-404", "link_type": "связанные"}}, "не касается проекта"), ]) def test_project_batch_rejected(conn, add_project, cmd, msg): two(conn, add_project) with pytest.raises(CommandError, match=msg): queue.create_batch(conn, "x", "claude", [cmd], project=S) assert conn.execute("SELECT COUNT(*) AS n FROM batch").fetchone()["n"] == 0 def test_project_batch_accepted(conn, add_project): two(conn, add_project) link = {"type": "ensure_link", "params": {"source": "AURORA-1", "target": "AURORA-1538", "link_type": "связанные"}} queue.create_batch(conn, "связь", "claude", [link], project=S) # «Связь с соседом» queue.create_batch(conn, "новая", "claude", [new_issue(), {"type": "ensure_comment", "params": {"key": "new:1", "marker": "m", "body": "b"}}], project=S) # временный ключ — свой queue.create_batch(conn, "jql", "claude", [{"type": "refresh", "params": {"jql": "key = AURORA-1538"}}], project=S) def test_unknown_project_batch(conn): ... # project="NOPE" → CommandError «проекта NOPE нет» def test_batch_without_project_unchanged(conn, add_project): ... # labels не дописаны, batch.project None, чужие задачи проходят def test_list_batches_by_project(conn, add_project): # сценарий «Пакеты проекта» two(conn, add_project) a = queue.create_batch(conn, "a", "claude", [fields("AURORA-1")], project=S) queue.create_batch(conn, "b", "claude", [fields("AURORA-1538")]) assert [b["id"] for b in queue.list_batches(conn, project=S)] == [a["id"]] assert len(queue.list_batches(conn)) == 2 def test_retry_project_rules(conn, add_project): # сценарий «Повтор чужой команды» two(conn, add_project) own = queue.create_batch(conn, "a", "claude", [fields("AURORA-1538")], project="AWPCA") loose = queue.create_batch(conn, "b", "claude", [fields("AURORA-1538")]) cid = lambda b: queue.batch_view(conn, b["id"])["commands"][0]["id"] with pytest.raises(CommandError, match=f"команда #{cid(own)} не из проекта STOCKS_DRRK"): queue.retry_command(conn, cid(own), "claude", project=S) with pytest.raises(CommandError, match="не из проекта AWPCA"): queue.retry_command(conn, cid(loose), "claude", project="AWPCA") r = queue.retry_command(conn, cid(own), "claude") # без project — проект исходного assert queue.batch_view(conn, r["id"])["project"] == "AWPCA" ``` `tests/test_api.py`: `POST /batches {project: "STOCKS_DRRK", …}` с чужой задачей → 422, `detail` содержит «не в проекте»; `project: 5` → 422; `GET /batches?project=STOCKS_DRRK`; `POST /commands/{id}/retry {project: "STOCKS_DRRK"}` для команды пакета AWPCA → 422, пакет не создан. - [ ] **Step 2: Run tests to verify they fail** Run: `uv run pytest tests/test_queue.py tests/test_api.py -k "project or retry" -v` Expected: FAIL (`create_batch() got an unexpected keyword argument 'project'`). - [ ] **Step 3: Implement** - `_check_project`: `projects.get(conn, P)` — нет → ошибка; `others = {l.casefold() for l in projects.labels(conn) if l != P}`; временные ключи — `temp_key` всех `ensure_issue` пакета; «своя» задача — временный ключ пакета или `P in (mirror.labels_of(conn, key) or [])`. `ensure_issue`: чужая метка (`casefold() in others`) — ошибка; метки, равные `P` без учёта регистра, но не точно, заменяются на `P` на своём месте (повторы `P` после замены убираются, остаётся первое вхождение); `P` после этого нет среди `labels` — дописать в конец (`labels` нет — `[P]`). `ensure_fields`: `P.casefold()` среди `labels_remove` (casefold) или чужая метка в `labels_add` — ошибка; затем проверка `key`. `ensure_comment`, `ensure_status`, `ensure_sprint`, `ensure_attachment` — проверка `key`; `ensure_link` — свой хотя бы один конец; `refresh` — без проверок. - `create_batch` пишет `project` в `INSERT INTO batch`. `list_batches` — `WHERE project = ?` при `project`. `retry_command` — по **Interfaces** (проект исходного — `SELECT project FROM batch WHERE id = ?`). - API: `project` из тела/запроса — `str` или отсутствует. - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest` — весь набор зелёный. - [ ] **Step 5: Commit** ```bash git add src/jira_hub/queue.py src/jira_hub/api.py tests/test_queue.py tests/test_api.py git commit -m "Хаб: пакет проекта — метка проекта, чужие метки и задачи, batch.project, повтор с project (#61)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 6: Доски в параметрах команд — `ensure_sprint.board_id`, `refresh.boards`, типы досок **Модель исполнителя:** sonnet (интеграция: постановка, выдача, отчёт агента и повтор). **Files:** - Modify: `src/jira_hub/queue.py` (`_assign_boards` в `_check_setup`, `deliver_params`, `claim`, `report_result`, `retry_command`) - Modify: `src/jira_hub/api.py` (`h_agent_command` — новая сигнатура `deliver_params`) - Test: `tests/test_queue.py`, `tests/test_api.py` **Interfaces:** - Consumes: `projects.list_projects`, `projects.boards`, `projects.record_board_types` (Task 1); `mirror.labels_of` (Task 4); `_check_project` уже дописал метку проекта в `ensure_issue` (Task 5). - Produces: - `ensure_sprint` в базе — `{key, sprint, board_id}`; `board_id` от клиента отвергает `C.validate` («лишние параметры board_id»), SPEC команд не меняется. - `queue.deliver_params(conn, cmd, files_dir) -> dict` (новый первый параметр): `ensure_attachment` — `size`, как раньше; `refresh` — `{**params, "boards": projects.boards(conn)}` всегда, в том числе `[]`; в базе `boards` нет. - `report_result`: для `refresh`, если `result` — словарь с ключом `boards`, — `projects.record_board_types(conn, result["boards"])` в той же транзакции; результат без ключа `boards` (агент v11 или v12 без запроса типов) типы не трогает. - `retry_command` снимает `board_id` с параметров `ensure_sprint` до проверок. - Тексты 422 (`CommandError`, `i` — номер команды): «команда {i}: {key} не входит ни в один проект — доску для спринта не определить», «команда {i}: {key} в проектах {A, B} с разными досками», «команда {i}: у проекта {P} нет доски Jira», «команда {i}: у проекта {P} канбан-доска, спринтов нет», «команда {i}: тип доски проекта {P} ещё не известен — дождитесь обновления из Jira». - [ ] **Step 1: Write the failing tests** `tests/test_queue.py`: ```python def sprint(key, name="Спринт 2"): return {"type": "ensure_sprint", "params": {"key": key, "sprint": name}} def test_sprint_gets_project_board(conn, add_project): # сценарий «Спринт задачи проекта» add_project(conn) mirror.apply_state(conn, [{"key": "AURORA-1538", "labels": ["AWPCA"]}], []) b = queue.create_batch(conn, "x", "claude", [sprint("AURORA-1538")]) # и без project пакета assert queue.batch_view(conn, b["id"])["commands"][0]["params"] == {"key": "AURORA-1538", "sprint": "Спринт 2", "board_id": 4216} @pytest.mark.parametrize("setup, key, msg", [ (dict(board_id=5120, board_type="kanban"), "AURORA-1", "у проекта STOCKS_DRRK канбан-доска, спринтов нет"), (dict(board_id=5120, board_type=None), "AURORA-1", "тип доски проекта STOCKS_DRRK ещё не известен"), (dict(board_id=None, board_type=None), "AURORA-1", "у проекта STOCKS_DRRK нет доски Jira"), (dict(board_id=5120, board_type="scrum"), "AURORA-77", "AURORA-77 не входит ни в один проект"), (dict(board_id=5120, board_type="scrum"), "AURORA-404", "AURORA-404 не входит ни в один проект"), # нет в зеркале (dict(board_id=5120, board_type="scrum"), "AURORA-12", "с разными досками"), ]) def test_sprint_board_rejected(conn, add_project, setup, key, msg): add_project(conn) # AWPCA 4216 scrum add_project(conn, "STOCKS_DRRK", **setup) mirror.apply_state(conn, [{"key": "AURORA-1", "labels": ["STOCKS_DRRK"]}, {"key": "AURORA-77", "labels": ["kind-dev"]}, {"key": "AURORA-12", "labels": ["AWPCA", "STOCKS_DRRK"]}], []) with pytest.raises(CommandError, match=msg): queue.create_batch(conn, "x", "claude", [sprint(key)]) def test_backlog_on_kanban_rejected(conn, add_project): ... # sprint "backlog", доска kanban → 422 «канбан-доска» def test_sprint_two_projects_same_board(conn, add_project): ... # метки AWPCA и C, у обоих 4216 → board_id 4216 def test_sprint_for_new_issue_uses_batch_labels(conn, add_project): ... # пакет project="AWPCA": ensure_issue new:1 без labels + ensure_sprint new:1 → board_id 4216 (метка дописана Task 5) def test_client_board_id_rejected(conn, add_project): add_project(conn) with pytest.raises(CommandError, match="лишние параметры board_id"): queue.create_batch(conn, "x", "claude", [{"type": "ensure_sprint", "params": {"key": "A-1", "sprint": "С", "board_id": 1}}]) def test_retry_sprint_recomputes_board(conn, add_project): # сценарий «Повтор ensure_sprint» add_project(conn) mirror.apply_state(conn, [{"key": "AURORA-1538", "labels": ["AWPCA"]}], []) b = queue.create_batch(conn, "x", "claude", [sprint("AURORA-1538")]) cid = queue.batch_view(conn, b["id"])["commands"][0]["id"] conn.execute("UPDATE project SET board_id = 5120, board_type = 'scrum'") r = queue.retry_command(conn, cid, "claude") assert queue.batch_view(conn, r["id"])["commands"][0]["params"]["board_id"] == 5120 def test_retry_premigration_sprint_without_board(conn, add_project): ... # команда ensure_sprint вставлена SQL-ем с params без board_id в пакет с project NULL → retry ставит board_id 4216 def test_refresh_delivered_with_boards(conn, add_project): # сценарий «Тип доски определён», часть 1 add_project(conn); add_project(conn, "STOCKS_DRRK", board_id=5120, board_type=None); add_project(conn, "C", board_id=4216) b = queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"jql": "key = A-1"}}]) got = queue.claim(conn) assert got["params"]["boards"] == [{"id": 4216, "type": "scrum"}, {"id": 5120, "type": None}] assert "boards" not in queue.batch_view(conn, b["id"])["commands"][0]["params"] def test_refresh_boards_empty_list(conn, add_project): add_project(conn, board_id=None, board_type=None) queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) assert queue.claim(conn)["params"]["boards"] == [] @pytest.mark.parametrize("result, expected", [ ({"boards": {"5120": "kanban"}}, "kanban"), # ключ — строка, project.board_id — int ({"issues": 3}, None), # агент v11 / v12 без запроса типов: ключа нет ({"boards": {"5120": None}}, None), # доска не найдена — тип не меняется ({"boards": {"abc": "scrum"}}, None), (None, None), ]) def test_refresh_result_board_types(conn, add_project, result, expected): add_project(conn, "STOCKS_DRRK", board_id=5120, board_type=None) queue.create_batch(conn, "r", "api", [{"type": "refresh", "params": {"keys": ["A-1"]}}]) c = queue.claim(conn) queue.report_result(conn, c["id"], "done", result=result) assert projects.get(conn, "STOCKS_DRRK")["board_type"] == expected ``` Существующие тесты с `ensure_sprint` (`test_temp_key_substitution_and_skip`, `test_reconcile_confirms_by_state`, `test_reconcile_does_not_overwrite_finished_command`, `test_bug_with_attachment_one_batch`) заводят `add_project(conn)` и кладут ключи задач (или `labels` у `ensure_issue`) с меткой `AWPCA`. `tests/test_api.py`: `GET /agent/commands/{id}` для взятого `refresh` отдаёт `boards`; полный круг «Тип доски определён» через HTTP — `POST /projects` STOCKS_DRRK с доской 5120, `claim` → в `params.boards` есть `{id: 5120, type: null}`, отчёт `done` с `result.boards = {"5120": "kanban"}` → `GET /projects` отдаёт `board_type = "kanban"`, затем `POST /batches` с `ensure_sprint` для задачи STOCKS_DRRK → 422 «канбан-доска». - [ ] **Step 2: Run tests to verify they fail** Run: `uv run pytest tests/test_queue.py tests/test_api.py -k "sprint or board" -v` Expected: FAIL (`board_id` не дописывается, `boards` нет в выдаче). - [ ] **Step 3: Implement** - `_assign_boards(conn, cmds)` вызывается из `_check_setup` (после проверок `bug`/вложений): метки временного ключа — `labels` его `ensure_issue` в пакете; иначе `mirror.labels_of`; проекты задачи — `[p for p in projects.list_projects(conn) if p["label"] in labels]`; множество их `board_id` (с `None`) из одного значения, иначе — «с разными досками»; дальше проверки по порядку: нет проектов → «не входит ни в один проект», доска `None` → «нет доски», `kanban` → «канбан-доска», `None`-тип → «ещё не известен»; иначе `c["params"] = {**p, "board_id": id}` (`P` в текстах — метка первого проекта). - `deliver_params(conn, cmd, files_dir)`; вызовы в `claim` и `api.h_agent_command` передают `conn`. - `report_result`: запись типов — в общей транзакции отчёта, только в ветке нетерминальной команды. - `retry_command`: `params = {k: v for k, v in cmd["params"].items() if not (cmd["type"] == "ensure_sprint" and k == "board_id")}`. - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest` — весь набор зелёный. - [ ] **Step 5: Commit** ```bash git add src/jira_hub/queue.py src/jira_hub/api.py tests/test_queue.py tests/test_api.py git commit -m "Хаб: доска проекта в ensure_sprint, boards в выдаче refresh, типы досок из отчёта агента (#61)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 7: MCP — `--project`, привязка инструментов, `projects`, `project_create` **Модель исполнителя:** sonnet (интеграция: декоратор привязки для обычных и async-инструментов, новые инструменты, правка тестов). **Files:** - Modify: `src/jira_hub/mcp_server.py` - Modify: `src/jira_hub/cli.py` (`mcp --project`) - Test: `tests/test_mcp.py`, `tests/test_cli.py` **Interfaces:** - Consumes: HTTP `GET/POST /projects` (Task 3), `GET /issues?project=` (Task 4), `POST /batches {project}`, `GET /batches?project=`, `POST /commands/{id}/retry {project}` (Task 5). - Produces (`mcp_server`): - `PROJECT: str | None = None` — привязка сессии; `run(project: str | None = None) -> None` записывает её и запускает stdio. - `NOT_BOUND = "сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)"`; `unknown_project(label) -> str` = `f"проект {label} не заведён в хабе: project_create или исправьте --project в .mcp.json"`. - `scoped(required: bool)` — декоратор под `guard` (для обычных и async-функций): `PROJECT is None` и `required` → `NOT_BOUND` без запросов к хабу; `PROJECT` задан → `GET /projects`, метки нет → `unknown_project(PROJECT)`; иначе вызов инструмента. `required=True`: `issues`, `issue`, `notes_set`, `batch_create`, `command_retry`, `refresh`; `required=False`: `agent_status`, `sync_log`, `settings_get`, `settings_set`, `batches`, `batch_status`, `batch_wait`, `batch_cancel`. Без декоратора: `projects`, `project_create`. - `HUB_ROOT = Path(__file__).resolve().parents[2]`; `mcp_json(label: str) -> dict` = `{"mcpServers": {"jira-hub": {"command": "uv", "args": ["run", "--project", str(HUB_ROOT), "jira-hub", "mcp", "--project", label]}}}`. - `fmt_projects(items, current: str | None) -> str` — строка заголовка `метка | название | доска | тип доски | задач`, строки с `—` для пустой доски и «неизвестен» для `board_type = None`, у текущего — суффикс ` (текущий)`; пустой список — «проектов нет». - Инструменты `projects() -> str`, `project_create(label: str, name: str, board_id: int | None = None) -> str`. - [ ] **Step 1: Write the failing tests** Фикстура `live`: `add_project(conn)` (AWPCA) и `monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA")`; `test_tools_roundtrip` работает в привязанной сессии. ```python def test_unbound_session(live, monkeypatch): # сценарий «Сессия без проекта» monkeypatch.setattr(mcp_server, "PROJECT", None) calls = [] real = HubClient.call monkeypatch.setattr(HubClient, "call", lambda self, m, p, *a, **k: calls.append(p) or real(self, m, p, *a, **k)) for out in (mcp_server.issues(), mcp_server.issue("A-1"), mcp_server.notes_set("A-1", work_kind="x"), mcp_server.batch_create("x", []), mcp_server.command_retry(1), asyncio.run(mcp_server.refresh(full=True))): assert out == mcp_server.NOT_BOUND assert calls == [] assert "AWPCA" in mcp_server.projects() assert mcp_server.agent_status().startswith("агент ") # служебные инструменты работают без привязки def test_unknown_bound_project(live, monkeypatch): # сценарий «Опечатка в проекте» monkeypatch.setattr(mcp_server, "PROJECT", "STOKS") assert mcp_server.issues() == "проект STOKS не заведён в хабе: project_create или исправьте --project в .mcp.json" assert mcp_server.agent_status().startswith("проект STOKS не заведён") assert "AWPCA" in mcp_server.projects() def test_bound_offline(monkeypatch): # Review Focus 4 monkeypatch.setattr(mcp_server, "PROJECT", "AWPCA") monkeypatch.setattr(mcp_server, "_client", lambda: HubClient("http://127.0.0.1:9", "t", timeout=1)) assert mcp_server.issues() == mcp_server.OFFLINE def test_bound_issues_and_create(live): # «Задачи своего проекта», «Создание задачи без метки» live["client"].post("/projects", {"label": "STOCKS_DRRK", "name": "Запасы ДРРК"}) live["agent"].post("/agent/state", {"issues": [{"key": "A-1", "summary": "своя АРМ", "labels": ["AWPCA"]}, {"key": "S-1", "summary": "своя запасы", "labels": ["STOCKS_DRRK"]}], "links": []}) assert "A-1" in mcp_server.issues() and "S-1" not in mcp_server.issues() out = mcp_server.batch_create("новая", [{"type": "ensure_issue", "params": {"temp_key": "new:1", "issue_type": "task", "summary": "т"}}]) b = live["client"].get(f"/batches/{int(out.split('#')[1].split()[0])}") assert b["project"] == "AWPCA" and b["commands"][0]["params"]["labels"] == ["AWPCA"] assert mcp_server.notes_set("S-1", work_kind="x") == "S-1 не в проекте AWPCA" assert "work_kind: x" in mcp_server.notes_set("new:3", work_kind="x") # временный ключ — можно, без GET /issues assert "S-1" in mcp_server.issue("S-1") # явный ключ — любая задача assert mcp_server.batch_create("чужая", [{"type": "ensure_fields", "params": {"key": "S-1", "duedate": "2026-10-23"}}]).startswith("ошибка хаба 422") def test_bound_batches_and_retry(live): ... # batches показывает только пакеты AWPCA; command_retry команды пакета без проекта → «ошибка хаба 422 … не из проекта AWPCA» def test_project_create(live, monkeypatch): # сценарий «Проект из чата» monkeypatch.setattr(mcp_server, "PROJECT", None) out = mcp_server.project_create("STOCKS_DRRK", "Запасы ДРРК", 5120) assert "STOCKS_DRRK" in out and re.search(r"пакет[^#]*#\d+", out) and "/mcp" in out frag = json.loads(out[out.index("{"):]) assert frag == mcp_server.mcp_json("STOCKS_DRRK") assert frag["mcpServers"]["jira-hub"]["args"][-2:] == ["--project", "STOCKS_DRRK"] assert live["client"].get("/batches")["batches"][0]["author"] == "claude" assert mcp_server.project_create("awpca", "x").startswith("ошибка хаба 409") def test_hub_root_is_repo(): assert (mcp_server.HUB_ROOT / "pyproject.toml").is_file() assert mcp_server.mcp_json("X")["mcpServers"]["jira-hub"]["args"][:3] == ["run", "--project", str(mcp_server.HUB_ROOT)] def test_fmt_projects(): items = [{"label": "AWPCA", "name": "АРМ КА", "board_id": 4216, "board_type": "scrum", "issues_count": 190}, {"label": "STOCKS_DRRK", "name": "Запасы ДРРК", "board_id": None, "board_type": None, "issues_count": 0}] out = mcp_server.fmt_projects(items, "AWPCA").splitlines() assert out[1] == "AWPCA | АРМ КА | 4216 | scrum | 190 (текущий)" assert out[2] == "STOCKS_DRRK | Запасы ДРРК | — | неизвестен | 0" assert mcp_server.fmt_projects([], None) == "проектов нет" ``` (`live` дополнительно отдаёт `"client"` — клиентский `HubClient`.) `test_offline_message` ставит `PROJECT = "AWPCA"`. `test_batch_create_mentions_labels` — описание `batch_create` содержит «метку проекта сессии хаб дописывает в ensure_issue сам», «метки других проектов запрещены», «board_id для ensure_sprint выставляет хаб» и не содержит «AWPCA». `test_async_tools_are_coroutines_and_registered` проходит без изменений. `tests/test_cli.py`: `cli.main(["mcp", "--project", "AWPCA"])` вызывает `mcp_server.run("AWPCA")` (подменить `run`). - [ ] **Step 2: Run tests to verify they fail** Run: `uv run pytest tests/test_mcp.py tests/test_cli.py -v` Expected: FAIL (`mcp_server` без `PROJECT`, `projects`, `project_create`). - [ ] **Step 3: Implement** по **Interfaces**: - `issues` — `project=PROJECT` в `GET /issues`; `notes_set` — временный ключ `new:` (`commands.is_temp`) — без проверки; иначе сначала `GET /issues/{key}`, `PROJECT` не среди `labels` → `f"{key} не в проекте {PROJECT}"`; `batch_create` — `"project": PROJECT` в теле; `command_retry` — `{"author": AUTHOR, "project": PROJECT}`; `batches` — `project=PROJECT` (без привязки — все). - `project_create` — `POST /projects {label, name, board_id, author: "claude"}` (`board_id=None` не отправлять); ответ: строка «проект <метка> «<название>» заведён, пакет обновления #», строка «Положите в корень репозитория проекта .mcp.json (слейте с имеющимся) и переподключите MCP: /mcp → reconnect, одобрите сервер проекта.» и `json.dumps(mcp_json(label), ensure_ascii=False, indent=2)` последним блоком. - Описание `batch_create` — абзац про AWPCA заменить текстом из спеки mcp («метку проекта сессии хаб дописывает в ensure_issue сам, метки других проектов запрещены, а команды над задачами вне проекта отклоняются; board_id для ensure_sprint выставляет хаб»). - `cli`: `p = sub.add_parser("mcp", …); p.add_argument("--project")`; `mcp_server.run(args.project)`. - [ ] **Step 4: Run tests to verify they pass** Run: `uv run pytest` — весь набор зелёный. - [ ] **Step 5: Commit** ```bash git add src/jira_hub/mcp_server.py src/jira_hub/cli.py tests/test_mcp.py tests/test_cli.py git commit -m "MCP: привязка сессии к проекту (--project), инструменты projects и project_create (#61)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 8: Веб-приложение — текущий проект, выпадающий список, «Новый проект», доска и список по проекту **Модель исполнителя:** sonnet (интеграция: оболочка, роутинг, контекст проекта, страницы, e2e-адреса). **Files:** - Create: `web/src/projects/current.ts`, `web/src/projects/current.test.ts` - Create: `web/src/projects/ProjectContext.ts`, `web/src/projects/ProjectPicker.tsx`, `web/src/projects/ProjectGate.tsx`, `web/src/projects/NewProjectDialog.tsx`, `web/src/projects/projects.css`, `web/src/projects/projects.test.tsx` - Modify: `web/src/api/types.ts` (`Project`, `CreatedProject`, `Batch.project`), `web/src/api/queries.ts` (`qk`, `useBoard`, `useProjects`, `useCreateProject`, инвалидация в `useSaveNote`) - Modify: `web/src/shell/Layout.tsx`, `web/src/shell/pages.tsx` - Modify: `web/src/board/BoardPage.tsx`, `web/src/board/useBoardSource.ts`, `web/src/issues/IssuesPage.tsx` - Modify: `web/scripts/seed-hub.py` (метка `AWPCA` у задач фикстуры без `neighbor`) - Test: `web/src/App.test.tsx`, `web/src/board/BoardPage.test.tsx`, `web/src/board/useBoardSource.test.tsx`, `web/src/issues/IssuesPage.test.tsx`, `web/src/api/queries.test.ts`; e2e — правка адресов в `web/e2e/board.spec.ts`, `web/e2e/issues.spec.ts`, `web/e2e/login.spec.ts` **Interfaces:** - Consumes: `GET/POST /projects` (Task 3), `GET /board?project=`, `GET /issues?project=` (Task 4). - Produces: - `types.ts`: `interface Project { label: string; name: string; board_id: number | null; board_type: "scrum" | "kanban" | null; created_at: string; issues_count: number }`; `interface CreatedProject { project: Project; batch_id: number }`; `Batch.project?: string | null`. - `queries.ts`: `qk.projects = ["projects"]`, `qk.board(project: string) = ["board", project]`, `qk.boardAll = ["board"]` (инвалидация всех снимков); `useBoard(project: string)` → `GET /board?project=`; `useProjects()` → `GET /projects`, данные — `Project[]` (`r.projects ?? []`); `useCreateProject()` — мутация `POST /projects {label, name, board_id?, author: "viewer"}` → `CreatedProject`, при успехе инвалидирует `qk.projects`. - `current.ts`: `REMEMBER_KEY = "hub-project"`; `type Resolution = { kind: "ok"; project: Project; fromUrl: boolean } | { kind: "unknown"; label: string } | { kind: "none" }`; `resolveProject(urlLabel: string | null, remembered: string | null, projects: Project[]): Resolution` (адрес → запомненный, если есть в списке → первый; метка из адреса, которой нет, — `unknown`; пустой список — `none`); `rememberedProject(): string | null`, `rememberProject(label: string): void` (обёрнуты в try/catch); `withProject(search: string, label: string): string` — query-строка с `project`, прочие параметры сохраняются. - `ProjectContext.ts`: `type ProjectState = { kind: "loading" } | { kind: "error"; error: Error } | Resolution`; `interface ProjectCtx { state: ProjectState; projects: Project[]; select(label: string): void; openNew(): void }`; `ProjectContext`, `useProjectContext()`. - `ProjectPicker()` — `