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

96 KiB
Raw Permalink Blame History

Несколько проектов-меток в мосте (хаб) — 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):

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

  • Разбор и миграция (алгоритм не задаётся сигнатурой):

_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
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:

def test_auto_refresh_needs_projects(conn):
    queue.claim(conn, agent_version=1, settings_version=1)
    assert queue.maybe_auto_refresh(conn) is None                    # агент на связи, очередь пуста, проектов нет

def test_auto_refresh_two_projects(conn, add_project):                # сценарий «Два проекта»
    add_project(conn); add_project(conn, "STOCKS_DRRK", board_id=None)
    queue.claim(conn, agent_version=1, settings_version=1)
    b = queue.maybe_auto_refresh(conn)
    assert queue.batch_view(conn, b["id"])["commands"][0]["params"] == {"jql": "project = AURORA AND labels in (AWPCA, STOCKS_DRRK)"}

test_auto_refresh_conditions, test_empty_full_refresh_keeps_neighbors, test_redelivered_full_refresh_marks_neighbors_once заводят add_project(conn) и берут JQL из projects.base_jql(conn).

  • tests/test_api.py: test_settings_put_rejects_jql_and_board — PUT /settings {base_jql: …} → 422, detail содержит «базовая JQL собирается из проектов», settings_version прежний; то же для {board_id: 12} (прежний тест, писавший board_id: 12, переписать без него). test_full_refresh_without_projects — 422, detail «нет проектов», пакетов нет (сценарий «Полный прогон без проектов»). Тест на строке ~124 (jql == "project = AURORA AND labels = AWPCA") — заводит AWPCA и ждёт "project = AURORA AND labels in (AWPCA)". Тест на строке ~526 (out["base_jql"]) — без проектов None, после add_project — вычисленная строка. Тесты last_sync у GET /board и _run_refresh(... {"full": True} ...) заводят проект в фикстуре.

  • tests/test_mcp.py: фикстура live заводит add_project(conn) (нужен refresh(full=True)).

  • Step 2: Run tests to verify they fail

Run: uv run pytest tests/test_settings.py tests/test_cli.py tests/test_queue.py tests/test_api.py -v Expected: FAIL (base_jql ещё в DEFAULTS, init не заводит проект, full берёт строку из настроек).

  • Step 3: Implement

  • settings: убрать base_jql, board_id из DEFAULTS и из values в init_from_config; base_jql убрать из AGENT_KEYS (агент v11/v12 его не читает); ветки validate для двух ключей — первыми.

  • queue.is_full_refresh: сравнение с projects.base_jql(conn) (None → False). queue.maybe_auto_refresh: jql = projects.base_jql(conn); None → None до остальных проверок.

  • api.h_refresh: full → projects.base_jql(conn), None → ApiError(422, "bad_refresh", "нет проектов"). api.h_board: mirror.board(conn, projects.base_jql(conn)). api.h_settings_get: {**settings.get_all(conn), "base_jql": projects.base_jql(conn)}; ответ h_settings_put — так же.

  • cli init: после settings.init_from_config — projects.ensure_legacy(conn, cfg.get("sprints", {}).get("board_id")), строка в выводе «проект AWPCA «АРМ КА», доска » при создании.

  • Step 4: Run tests to verify they pass

Run: uv run pytest — весь набор зелёный. Затем grep -n "base_jql" src/jira_hub/*.py — settings.get(conn, "base_jql") есть только в projects.migrate_v3.

  • Step 5: Commit
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

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

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 (единственный параметр — метка, первым в списке аргументов запроса):

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
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"])):

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

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
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 работает в привязанной сессии.

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 не отправлять); ответ: строка «проект <метка> «<название>» заведён, пакет обновления #», строка «Положите в корень репозитория проекта .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
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:

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

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