sdlc-hub/docs/superpowers/plans/2026-10-19-projects-hub.md
2026-10-19 19:18:04 +04:00

1048 lines
96 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Несколько проектов-меток в мосте (хаб) — 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 = <project_key> 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:<n>`.
- Автор пакетов: 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 <noreply@anthropic.com>`.
- Зелёным считается только весь набор: `make check` (pytest + lint и тесты фронта); в задачах, где менялся фронт, — ещё `cd web && npm run e2e`.
- Любое решение исполнителя, не записанное в спеке или в этом плане, дописывается в `openspec/changes/projects/design.md`, раздел `## Decisions made without discussion` (дата, решение, обоснование, цена ошибки). Находки ревью задачи n — маркеры `T<n>.<k>`.
## 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)` = `"<field>: <причина>"`; `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` — для пар `{"<id>": "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<one>\S+)|in\s*\((?P<many>[^()]*)\))\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 <noreply@anthropic.com>"
```
---
### 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 «АРМ КА», доска <N>» при создании.
- [ ] **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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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:<n>` (`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` не отправлять); ответ: строка «проект <метка> «<название>» заведён, пакет обновления #<id>», строка «Положите в корень репозитория проекта .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 <noreply@anthropic.com>"
```
---
### 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()` — `<select aria-label="Проект">`: варианты `«<метка> — <название>»` (value — метка), значение — текущий проект (иначе пустой вариант «выберите проект»), последний вариант «+ Новый проект…» → `openNew()`.
- `ProjectGate({ children }: { children: (p: Project) => ReactNode })` — `loading` — «Загрузка проектов…», `error` — сообщение, `unknown` — «проект <метка> не найден» + `ProjectPicker`, `none` — «Проектов нет» + кнопка «+ Новый проект…» (`openNew`), `ok` — `children(project)`.
- `NewProjectDialog({ onClose, onCreated }: { onClose(): void; onCreated(p: Project): void })` — `<dialog>` (как `IssueModal`): поля «Метка», «Название», «Номер доски Jira» (`type="number"`, подсказка «scrum или kanban определится сам»), кнопки «Создать», «Отмена»; пустой номер доски — `board_id` не отправляется; `ApiError` → текст `detail` (иначе `message`) рядом с «Создать» (`role="alert"`), окно открыто.
- `BoardPage({ project }: { project: string })`, `useBoardSource(dicts, project: string, opts?)`, `IssuesPage({ project }: { project: string })`.
- [ ] **Step 1: Write the failing tests**
`web/src/projects/current.test.ts`:
```ts
const A = { label: "AWPCA", name: "АРМ КА" } as Project, S = { label: "STOCKS_DRRK", name: "Запасы ДРРК" } as Project;
it("адрес, затем запомненный, затем первый", () => {
expect(resolveProject("STOCKS_DRRK", "AWPCA", [A, S])).toEqual({ kind: "ok", project: S, fromUrl: true });
expect(resolveProject(null, "STOCKS_DRRK", [A, S])).toEqual({ kind: "ok", project: S, fromUrl: false });
expect(resolveProject(null, "GONE", [A, S])).toEqual({ kind: "ok", project: A, fromUrl: false });
expect(resolveProject("NOPE", "AWPCA", [A, S])).toEqual({ kind: "unknown", label: "NOPE" });
expect(resolveProject(null, null, [])).toEqual({ kind: "none" });
});
it("withProject сохраняет фильтры", () => {
expect(withProject("?sprint=С2&project=AWPCA", "STOCKS_DRRK")).toBe("?sprint=%D0%A12&project=STOCKS_DRRK");
});
it("память выбора переживает недоступное хранилище", () => { /* localStorage.getItem бросает → rememberedProject() === null */ });
```
`web/src/projects/projects.test.tsx` — приложение целиком (`App` с `createMemoryRouter(routes, { basename: "/app" })`), `mockFetch`: `/projects` → `[A, S]`, `/settings` → фикстура, `/board` → снимок, `/issues` → строки, `/batches` → `[]`, `/agent` → онлайн; `beforeEach` — `localStorage.clear()`:
- «Первый вход после выкатки»: `/app/board` → `router.state.location.search === "?project=AWPCA"`; `GET /board` ушёл с `project=AWPCA`; в `localStorage["hub-project"]` — `AWPCA`.
- «Переключение проекта»: выбрать в «Проект» `STOCKS_DRRK — Запасы ДРРК` → `GET /board?project=STOCKS_DRRK`; ссылка «Задачи» → `href="/app/issues?project=STOCKS_DRRK"`; щелчок — адрес `/app/issues?project=STOCKS_DRRK`, `GET /issues` с `project=STOCKS_DRRK`, `GET /board` (варианты фильтров) с `project=STOCKS_DRRK`.
- Запомненный проект: `localStorage["hub-project"] = "STOCKS_DRRK"`, `/app/issues` → адрес с `project=STOCKS_DRRK`.
- «Неизвестный проект в адресе»: `/app/issues?project=NOPE` → текст «проект NOPE не найден», есть `combobox` «Проект», ни одного запроса `GET /issues`.
- Проектов нет: `/projects` → `[]` → кнопка «+ Новый проект…», запросов `GET /board` нет; щелчок открывает окно.
- «Создание проекта из формы»: «Проект» → «+ Новый проект…», ввести `STOCKS_DRRK`, «Запасы ДРРК», «Создать» → тело `POST /projects` ровно `{label: "STOCKS_DRRK", name: "Запасы ДРРК", author: "viewer"}`; окно закрыто; адрес — `project=STOCKS_DRRK`. С номером доски «5120» — `board_id: 5120`.
- «Метка занята»: `POST /projects` → `409 {error: "project_exists", detail: "label: проект с такой меткой уже есть: AWPCA"}` → окно открыто, `role="alert"` с этим текстом.
- Карточка по прямому адресу (Review Focus 3): `/app/issues/AURORA-9001` → `router.state.location.pathname === "/app/issues/AURORA-9001"` после загрузки проектов, окно карточки открыто, список под ним запросил `GET /issues?project=AWPCA`.
Правки существующих тестов: `App.test.tsx` — мок отдаёт `{projects: [A]}` на `/projects`, переходы проверяют `pathname` и `search` (`?project=AWPCA`); `BoardPage.test.tsx`, `IssuesPage.test.tsx` — `<BoardPage project="AWPCA" />`, `<IssuesPage project="AWPCA" />`, проверка `project=AWPCA` в запросах `GET /board`/`GET /issues`; `useBoardSource.test.tsx` — `useBoardSource(dicts, "AWPCA", …)`; `queries.test.ts` — `qk.board("AWPCA")`.
e2e (адреса после автоподстановки проекта): `login.spec.ts:16` → `/\/app\/board\?project=AWPCA$/`; `board.spec.ts:82,87` → `…\?project=AWPCA$`; `issues.spec.ts:19` → `/\/app\/issues\?project=AWPCA&assignee=/`, строки 49, 56, 68, 159 → с `\?project=AWPCA$`. `seed-hub.py`: задачам фикстуры с `neighbor = false` добавить метку `AWPCA`, если её нет (иначе в разрез AWPCA попадут только две задачи `hub-basic`).
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd web && npx vitest run src/projects src/App.test.tsx`
Expected: FAIL (модулей `projects/*` нет).
- [ ] **Step 3: Implement**
- `Layout`: `useProjects()`; `urlLabel` — из `pageLocation.search`; `state` — `loading`/`error`/`resolveProject(urlLabel, rememberedProject(), data)`. Эффект: при `ok` — `rememberProject(label)`; при `ok && !fromUrl`, без открытой карточки (`issueKey === null`) и на `/board` или `/issues` — `navigate(pathname + withProject(search, label), { replace: true })`. `select(label)` — `navigate(<текущий раздел> + "?project=" + label)` (новая запись истории, фильтры списка сбрасываются). Вкладки — `{ pathname, search: "?project=<текущий>" }` при `ok`, активность — по `pathname`. `ProjectPicker` — сразу после «Хаб Jira»; `NewProjectDialog` — по `openNew`, `onCreated(p)` → `select(p.label)`. Всё — внутри `<ProjectContext value={…}>`.
- `pages.tsx`: `/board` → `<ProjectGate>{(p) => <BoardPage key={p.label} project={p.label} />}</ProjectGate>`; `/issues` → `<ProjectGate>{(p) => <IssuesPage key={p.label} project={p.label} />}</ProjectGate>`.
- `useBoardSource(dicts, project, opts)`: `useBoard(project)`; `invalidateQueries`/`refetchQueries` — по `qk.board(project)`; подхват пакета `viewer` — без изменений (пакет «Новый проект» с автором `viewer` подхватывается как свой `refresh`). `useSaveNote` инвалидирует `qk.boardAll`.
- `IssuesPage({ project })`: `useBoard(project)`, запрос — `{ ...query, project }`; `project` в адресе остаётся рядом с фильтрами (`set()` уже копирует параметры).
- [ ] **Step 4: Run tests to verify they pass**
Run: `make check` — зелёный; `cd web && npm run e2e` — зелёный.
- [ ] **Step 5: Commit**
```bash
git add web
git commit -m "Веб-приложение: текущий проект в адресе и памяти, выпадающий список и «Новый проект», доска и задачи по проекту (#61)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 9: Веб-приложение — раскладка доски по проектам и фильтр спринтов по данным
**Модель исполнителя:** haiku (готовый код: правила ключей и тесты в плане).
**Files:**
- Modify: `web/src/board/storage.ts` (суффикс ключей, `setStoreProject`, миграции только для AWPCA)
- Modify: `web/src/board/useBoardPrefs.ts` (`useBoardPrefs(model, project)`), `web/src/board/BoardPage.tsx`
- Modify: `web/src/board/model/fromHub.ts` (`hasSprint`)
- Test: `web/src/board/storage.test.ts`, `web/src/board/BoardPage.test.tsx`, `web/src/board/model/model.test.ts`
**Interfaces:**
- Consumes: `BoardPage({ project })` (Task 8; `key={project}` пересоздаёт страницу при смене проекта).
- Produces (`storage.ts`):
- `LEGACY_PROJECT = "AWPCA"`; `storeSuffix(label: string | null): string` — `""` для `null` и `AWPCA`, иначе `"@" + label`.
- `setStoreProject(label: string | null): void` — текущий набор ключей модуля; `pref(name)` = `"graph-" + name + suffix`, `fileKey(name)` = `"graph-" + name + ":board" + suffix`. Все функции модуля (раскладка, дорожки, ширины, флаги, скрытые виды, спринты, исполнители, `applyJsonSettings`, `resetLayout`) пишут и читают через них.
- `isLegacyStore(): boolean` — суффикс пустой; `migrateStore`/`migrateMode` вызываются только при нём.
- `useBoardPrefs(model, project: string)` — в инициализаторе состояния сначала `st.setStoreProject(project)`, затем миграции (только `isLegacyStore()`) и `readPrefs()`.
- `fromHub`: `hasSprint = snap.issues.some((it) => !it.neighbor && !!it.sprint)` — только свои задачи (соседи — обычные карточки, как сейчас, но фильтр спринтов от их спринтов не появляется).
- [ ] **Step 1: Write the failing tests**
`storage.test.ts` (`afterEach(() => setStoreProject(null))`):
```ts
it("AWPCA — прежние ключи", () => {
setStoreProject("AWPCA");
writeFlag("crit", true); toggleLane("Эпик 1");
expect(localStorage.getItem("graph-crit")).toBe("1");
expect(localStorage.getItem("graph-fold:board")).toBe('["Эпик 1"]');
});
it("другой проект — ключи с суффиксом, AWPCA не тронут", () => {
localStorage.setItem("graph-pos:board", '{"AURORA-1":{"x":1,"ry":2}}');
setStoreProject("STOCKS_DRRK");
writeFlag("crit", true); toggleLane("Эпик 1"); setColWidth("2026-10-20", 400);
saveLayout({ "AURORA-2": { x: 10, ry: 20, w: 200, h: 80, auto: false } }, {}, false);
applyJsonSettings({ layout: { "AURORA-3": { x: 1, ry: 1 } }, layoutByDate: {}, laneOrder: ["A"], filters: { neighbors: true } } as JsonImport);
expect(localStorage.getItem("graph-crit@STOCKS_DRRK")).toBe("1");
expect(localStorage.getItem("graph-fold:board@STOCKS_DRRK")).toBe('["Эпик 1"]');
expect(localStorage.getItem("graph-lanes:board@STOCKS_DRRK")).toBe('["A"]');
expect(localStorage.getItem("graph-neighbors@STOCKS_DRRK")).toBe("1");
expect(localStorage.getItem("graph-pos:board")).toBe('{"AURORA-1":{"x":1,"ry":2}}');
expect(localStorage.getItem("graph-crit")).toBeNull();
expect(laneOrder()).toEqual(["A"]);
setStoreProject("AWPCA");
expect(laneOrder()).toEqual([]);
});
```
`BoardPage.test.tsx`:
- в `localStorage` старая раскладка `graph-pos:<файл>` (как для `migrateStore`) — `<BoardPage project="STOCKS_DRRK" />` её не переносит (`graph-pos:board@STOCKS_DRRK` и `graph-pos:board` пустые); `<BoardPage project="AWPCA" />` — переносит, как раньше.
- «Канбан-проект»: снимок, у задач которого `sprint = null`, — видна надпись «колонки «Спринт» нет — показаны все задачи», кнопки выбора спринтов нет.
- Канбан-проект со спринтом только у соседа: своя задача без спринта (`neighbor: false`), связанный сосед с `sprint: "AWPCA_Спринт 2"` (`neighbor: true`) — та же надпись, кнопки выбора спринтов нет; карточка соседа на доске есть (обычная карточка).
- проект AWPCA с фикстурой `hub-basic` — выбор спринта по-прежнему есть (регрессия).
`model.test.ts`: для `hub-empty` сравнение с эталоном — `{ ...golden("hub-empty.expected.json").model, hasSprint: false }` (эталон пишется из прежней страницы, где `hasSprint` всегда `true`; файл эталона не перегенерировать); новый случай — снимок `hub-basic` с обнулёнными `sprint` → `hasSprint === false`; снимок, где спринт есть только у задачи с `neighbor: true`, → `hasSprint === false`; `hub-basic` как есть → `true` (эталон не меняется).
- [ ] **Step 2: Run tests to verify they fail**
Run: `cd web && npx vitest run src/board`
Expected: FAIL (`setStoreProject` не экспортируется, `hasSprint` всегда `true`).
- [ ] **Step 3: Implement** по **Interfaces**; комментарий в начале `storage.ts` дополнить правилом ключей по проектам.
- [ ] **Step 4: Run tests to verify they pass**
Run: `make check` — зелёный; `cd web && npm run e2e` — зелёный (раскладка AWPCA в e2e — на прежних ключах).
- [ ] **Step 5: Commit**
```bash
git add web/src/board
git commit -m "Веб-приложение: раскладка и флаги доски отдельно для каждого проекта, фильтр спринтов по данным (#61)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 10: Сквозная проверка проектов (e2e) и документация
**Модель исполнителя:** sonnet (e2e и документация по всей ветке).
**Files:**
- Create: `web/e2e/projects.spec.ts`
- Modify: `README.md`, `ARCHITECTURE.md`, `CLAUDE.md` (репозитория)
- Modify: `openspec/changes/projects/design.md` (раздел `## Decisions made without discussion`)
**Interfaces:**
- Consumes: всё из Task 1–9; помощники `login`, `agentServeRefresh` из `web/e2e/hub.ts`.
- Produces: документация и e2e; кода приложения задача не меняет.
- [ ] **Step 1: Write the e2e scenarios**
`web/e2e/projects.spec.ts` — `test.describe.serial` (проект заводится один раз на тестовый хаб; файл идёт последним по алфавиту):
1. «Первый вход»: `login(page)` → адрес `/app/board?project=AWPCA`, на доске карточка `AURORA-9001`.
2. «Метка занята»: «Проект» → «+ Новый проект…», метка `awpca`, название «x», «Создать» → окно открыто, `role="alert"` содержит «уже есть».
3. «Создание проекта из формы»: метка `STOCKS_DRRK`, «Запасы ДРРК», доска пусто, «Создать» → адрес `?project=STOCKS_DRRK`, `role="status"` содержит `пакет #N`; `agentServeRefresh()` (снимает пакет из очереди — иначе он держит кнопку «Обновить из Jira» в других файлах) → статус «выполнен».
4. «Переключение проекта»: в «Проект» выбрать `AWPCA — АРМ КА` → карточка `AURORA-9001` на доске; ссылка «Задачи» → адрес `/app/issues?project=AWPCA`.
5. «Неизвестный проект в адресе»: `page.goto("/app/issues?project=NOPE")` → «проект NOPE не найден».
- [ ] **Step 2: Run e2e**
Run: `cd web && npm run e2e`
Expected: PASS всех файлов, включая прежние.
- [ ] **Step 3: Update documentation**
- `README.md`: первая строка — «зеркало задач проектов-меток Jira-проекта AURORA (AWPCA и др.)»; раздел MCP — регистрация с `--project` в `.mcp.json` репозитория проекта (фрагмент как у `project_create`), поведение без привязки, строки таблицы `projects`, `project_create`; пример `ensure_issue` без `"labels": ["AWPCA"]` с пояснением, что метку дописывает хаб; таблица API — `GET/POST /projects`, `project` у `GET /issues`, `GET /board`, `POST /batches`, `GET /batches`, `POST /commands/{id}/retry`, 422 «нет проектов» у `POST /refresh {full}`; `GET /settings` — `base_jql` вычисляется, `PUT` `base_jql`/`board_id` — 422; веб-приложение — выпадающий список проектов, «Новый проект», `?project=`; раздел выкатки — порядок из design.md, решение 14 (агент v12 → слияние, `make web`, перезапуск → `min_agent_version = 12` → `.mcp.json` с `--project AWPCA` в `awp-ca`), правка доски вручную `UPDATE project SET board_id = …, board_type = NULL WHERE label = …` (только владелец).
- `ARCHITECTURE.md`: модуль `projects` в таблице (таблица `project`, базовая JQL, доски, миграция v3→v4; зависит от `db`, `settings`), `db → projects` (ленивый импорт в миграции); `mirror` — разрез по проекту; `queue` — проверки пакета проекта, `board_id` у `ensure_sprint`, `boards` в `deliver_params`; `mcp_server` — `--project`; поток «Создание проекта»; `web/src/projects/` и ключи `storage.ts` по проектам; заголовок без «AURORA/AWPCA» как единственного проекта.
- `CLAUDE.md` репозитория: «по задачам проекта AURORA (метка AWPCA)» → «по задачам проектов-меток Jira-проекта AURORA»; команда `uv run jira-hub mcp [--project <метка>]`.
- `design.md`, раздел `## Decisions made without discussion` — дописать решения этого плана с датой 2026-10-19 (каждое: решение, обоснование, цена ошибки), продолжая нумерацию с 22:
1. `GET /projects` отвечает `{"projects": [...]}` — как прочие списки API.
2. Пакет «Новый проект» — `batch.project = NULL` (обновление общее для всех проектов).
3. `GET /settings` перекрывает только `base_jql`; оставшийся от v3 `board_id` отдаётся как есть (запасная доска агента).
4. Доска для `ensure_sprint` выбирается и в пакетах без проекта (веб-приложение, старые клиенты): задача вне проектов — 422, хотя раньше проходила.
5. `board_id` от клиента в `ensure_sprint` отвергается общей проверкой «лишние параметры board_id», без отдельного текста.
6. Веб: память выбранного проекта — `localStorage["hub-project"]`; ручная смена проекта — новая запись истории, фильтры списка сбрасываются; автоподстановка проекта в адрес — `replace` и только без открытой карточки (иначе теряется прямой адрес карточки).
7. e2e: пакет «Новый проект» (автор `viewer`) в `projects.spec.ts` разбирается `agentServeRefresh()` — иначе он держит «Обновить из Jira» в других файлах на общем тестовом хабе.
8. Корень хаба для фрагмента `.mcp.json` — `Path(mcp_server.__file__).parents[2]`; верно для редактируемой установки (`uv run --project`), из site-packages путь был бы неверным.
9. Миграция v3→v4 — Python-шаг `projects.migrate_v3`, вызываемый из `db.MIGRATIONS[3]` с ленивым импортом; кавычки вокруг метки в старой `base_jql` снимаются; JQL с хвостом (`ORDER BY` и т. п.) не разбирается — проектов нет, предупреждение в лог.
10. `base_jql` убран из `AGENT_KEYS` (агент его не читает); типы досок пишутся из результата `refresh` в любом статусе отчёта, если в нём есть ключ `boards`.
11. Метка в `?project=`, `POST /batches {project}`, `retry {project}`, `--project` сравнивается точно.
12. Веб: набор ключей доски — состояние модуля `storage.ts`, выставляется в `useBoardPrefs` при монтировании страницы (страница пересоздаётся по `key={project}`); миграции старых ключей — только для AWPCA.
13. Тестовый хаб: `seed-hub.py` дописывает метку `AWPCA` задачам фикстуры без `neighbor` (в `hub-basic` метка есть только у двух задач из десяти); файл фикстуры не меняется.
14. Эталон `hub-empty.expected.json` не перегенерируется (его пишет прежняя страница, где `hasSprint` всегда `true`): тест сравнивает с ним, подменив `hasSprint` на `false`.
- [ ] **Step 4: Full verification**
Run: `make check` и `cd web && npm run e2e`
Expected: оба зелёные.
- [ ] **Step 5: Commit**
```bash
git add web/e2e/projects.spec.ts README.md ARCHITECTURE.md CLAUDE.md openspec/changes/projects/design.md
git commit -m "Проекты: e2e выбора и создания проекта, документация, решения плана в design.md (#61)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```