96 KiB
Несколько проектов-меток в мосте (хаб) — 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
- Живая база v3 с
base_jqlв неожиданной форме (labels = "AWPCA"в кавычках, хвостORDER BY key, нестандартная JQL) — хаб стартует, миграция не падает: кавычки снимаются, неразобранная JQL даёт «проектов нет» и предупреждение в лог (тесты в Task 1). - Агент v11 (или v12 без запроса типов) присылает результат
refreshбез ключаboards, ключиboards— строки ("5120"), значениеnullили мусорный ключ — типы досок не меняются, отчёт принимается (тесты в Task 6). - Прямой адрес карточки
/app/issues/AURORA-9001безproject: выбор проекта не переписывает адрес и не закрывает окно; список под окном — по запомненному проекту (тест в Task 8). - MCP с
--project, хаб выключен — ответ «хаб не запущен: jira-hub serve», а не «проект … не заведён» (тест в Task 7). - Повтор (
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.pyand the schema -
db.SCHEMA:CREATE TABLE IF NOT EXISTS project (...)по Global Constraints; вCREATE TABLE batch—project TEXTпоследней колонкой. Индекс поbatch.projectне заводить (на базе v3SCHEMAвыполняется до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)(SQLitelower()не знает кириллицу). -
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}без проектов → 422bad_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— так же. -
cliinit: после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?}(не строка → 422bad_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 (проект заводится один раз на тестовый хаб; файл идёт последним по алфавиту):
- «Первый вход»:
login(page)→ адрес/app/board?project=AWPCA, на доске карточкаAURORA-9001. - «Метка занята»: «Проект» → «+ Новый проект…», метка
awpca, название «x», «Создать» → окно открыто,role="alert"содержит «уже есть». - «Создание проекта из формы»: метка
STOCKS_DRRK, «Запасы ДРРК», доска пусто, «Создать» → адрес?project=STOCKS_DRRK,role="status"содержитпакет #N;agentServeRefresh()(снимает пакет из очереди — иначе он держит кнопку «Обновить из Jira» в других файлах) → статус «выполнен». - «Переключение проекта»: в «Проект» выбрать
AWPCA — АРМ КА→ карточкаAURORA-9001на доске; ссылка «Задачи» → адрес/app/issues?project=AWPCA. - «Неизвестный проект в адресе»:
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вычисляется,PUTbase_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:GET /projectsотвечает{"projects": [...]}— как прочие списки API.- Пакет «Новый проект» —
batch.project = NULL(обновление общее для всех проектов). GET /settingsперекрывает толькоbase_jql; оставшийся от v3board_idотдаётся как есть (запасная доска агента).- Доска для
ensure_sprintвыбирается и в пакетах без проекта (веб-приложение, старые клиенты): задача вне проектов — 422, хотя раньше проходила. board_idот клиента вensure_sprintотвергается общей проверкой «лишние параметры board_id», без отдельного текста.- Веб: память выбранного проекта —
localStorage["hub-project"]; ручная смена проекта — новая запись истории, фильтры списка сбрасываются; автоподстановка проекта в адрес —replaceи только без открытой карточки (иначе теряется прямой адрес карточки). - e2e: пакет «Новый проект» (автор
viewer) вprojects.spec.tsразбираетсяagentServeRefresh()— иначе он держит «Обновить из Jira» в других файлах на общем тестовом хабе. - Корень хаба для фрагмента
.mcp.json—Path(mcp_server.__file__).parents[2]; верно для редактируемой установки (uv run --project), из site-packages путь был бы неверным. - Миграция v3→v4 — Python-шаг
projects.migrate_v3, вызываемый изdb.MIGRATIONS[3]с ленивым импортом; кавычки вокруг метки в старойbase_jqlснимаются; JQL с хвостом (ORDER BYи т. п.) не разбирается — проектов нет, предупреждение в лог. base_jqlубран изAGENT_KEYS(агент его не читает); типы досок пишутся из результатаrefreshв любом статусе отчёта, если в нём есть ключboards.- Метка в
?project=,POST /batches {project},retry {project},--projectсравнивается точно. - Веб: набор ключей доски — состояние модуля
storage.ts, выставляется вuseBoardPrefsпри монтировании страницы (страница пересоздаётся поkey={project}); миграции старых ключей — только для AWPCA. - Тестовый хаб:
seed-hub.pyдописывает меткуAWPCAзадачам фикстуры безneighbor(вhub-basicметка есть только у двух задач из десяти); файл фикстуры не меняется. - Эталон
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>"