1048 lines
96 KiB
Markdown
1048 lines
96 KiB
Markdown
# Несколько проектов-меток в мосте (хаб) — 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>"
|
||
```
|