Исправления финального ревью: тип доски нового проекта, нормализация labels_add, документация (#61)

I-1: boards() отдаёт type=null, если тип неизвестен у любого проекта доски.
M-2: labels_add ensure_fields нормализуется к метке проекта. M-3: light_list
вместо list_projects в _assign_boards. M-4/T5.4: пустой project — 422.
M-1, M-5, T10.1-T10.7: README, ARCHITECTURE, CLAUDE.md, design.md (49-51), e2e.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-19 22:06:41 +04:00
parent 9c2c4d09e0
commit ec4fb8b1e7
11 changed files with 86 additions and 14 deletions

View File

@ -15,11 +15,11 @@
| `queue` | пакеты, проверки постановки (`_check_setup`: `bug` без настройки, файл вложения; `_check_project`: проверки пакета проекта — метка, чужие метки, задачи вне проекта; `_assign_boards`: `board_id` проекта у `ensure_sprint`), выдача (`claim`, `size` у `ensure_attachment` и `boards` у `refresh` — `deliver_params`), отчёты, отмена, повтор, автообновление | `commands`, `mirror`, `settings`, `files`, `projects`, `db` |
| `backup` | копии файла базы, ротация | `paths` |
| `files` | хранилище файлов вложений `<данные>/files/<sha256>` (каталог — параметр, `Hub.files_dir`): `put` (атомарно, одна копия на содержимое, mtime при повторе, предел 10 МБ, пустой — отказ), `path`/`exists`/`size`/`read` (только sha из 64 hex нижнего регистра), `sweep` (старше 30 дней, кроме файлов `ensure_attachment` в `queued`/`claimed`) | `paths` |
| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` (автообновление и уборка `files/` раз в сутки); маршруты файлов `POST /files`, `GET /agent/files/{sha256}` (каталог `Hub.files_dir`, 0700 при старте); раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board` | `queue`, `mirror`, `settings`, `backup`, `files`, `relay`, `release` |
| `api` | HTTP-сервер, маршруты, авторизация по токенам, фоновый `tick` (автообновление и уборка `files/` раз в сутки); маршруты файлов `POST /files`, `GET /agent/files/{sha256}` (каталог `Hub.files_dir`, 0700 при старте); раздача веб-приложения `/app…` (каталог `web/app` пакета, без токена) и перенаправление `/`, `/links`; снимок `GET /board`; маршруты `/projects` | `queue`, `mirror`, `projects`, `settings`, `backup`, `files`, `relay`, `release` |
| `relay` | реле общей папки (настройка `relay_dir`): поток в `serve`, файлы `req/` → свой HTTP (адрес `serve`, для `0.0.0.0` — `127.0.0.1`) → `resp/`; агент с `hub_url = folder:<путь>` ходит через него; HTTP-обработчики о нём не знают | `settings` |
| `release` | опубликованная версия агента: `release/agent.py` + `manifest.json` в каталоге данных, `publish` (только вверх; запись с fsync, прежний файл на время записи — `agent.py.prev`, при сбое возвращается), `current` (проверка манифеста, предупреждение один раз на ошибку), `load` (с проверкой sha256 и UTF-8), подсказка `hint` для `agent_status` | `paths` |
| `client` | HTTP-клиент хаба | `db`, `paths`, `settings` (только для `from_home`) |
| `mcp_server` | инструменты MCP по stdio; привязка сессии к проекту (`jira-hub mcp --project <метка>`), инструменты `projects` и `project_create` (фрагмент `.mcp.json`); `batch_create` читает файлы `ensure_attachment.path` и загружает их `POST /files` до постановки пакета | `client`, `files` (только `MAX_SIZE`) |
| `mcp_server` | инструменты MCP по stdio; привязка сессии к проекту (`jira-hub mcp --project <метка>`), инструменты `projects` и `project_create` (фрагмент `.mcp.json`); `batch_create` читает файлы `ensure_attachment.path` и загружает их `POST /files` до постановки пакета | `client`, `commands`, `files` (только `MAX_SIZE`) |
| `importer` | разовый импорт CSV-пакета в заметки, сверка с зеркалом | `mirror`, `settings` |
| `cli` | `init`, `serve`, `import`, `mcp`, `tokens`, `agent publish` | `api`, `mcp_server`, `importer`, `release`, `settings`, `db`, `paths` |
@ -46,7 +46,7 @@
- Зеркало пишет только `mirror.apply_state` по запросу агента (и заметки через `PUT /issues/{key}/note`); из очереди и API задач напрямую не правят.
- Хаб не знает пароль Jira и не обращается к Jira; `init` не переносит `auth`, `base_url`, `verify_ssl`, `ca_bundle`.
- `queue` не создаёт команд сам: повтор после failed — новый пакет. Выданная без отчёта команда довыдаётся при следующем `claim` (до 3 выдач, затем failed).
- `mcp_server` не импортирует `queue`/`mirror`/`db` напрямую и ходит только через `client`; из `files` берёт только предел
- `mcp_server` не импортирует `queue`/`mirror`/`db`/`projects` напрямую и ходит только через `client`; из `files` берёт только предел
`MAX_SIZE` (файлы в хранилище кладёт хаб по `POST /files`).
- `commands`, `mirror`, `queue` не знают про HTTP; HTTP живёт только в `api`, `client` и `relay` (реле — клиент своего же HTTP).
- Веб-приложение (`web/`) ходит в хаб только через HTTP API (`web/src/api/client.ts`); словари `kind_labels`, `link_types` и набор закрытых статусов `closed_statuses` берёт из `GET /settings` и своих копий не держит.

View File

@ -14,7 +14,7 @@
## Архитектура
- `src/jira_hub/` — весь код пакета, без разделения на ядро и адаптеры; чистая логика (`commands`, `mirror`, `queue`, `settings`) принимает соединение SQLite параметром.
- `db` (схема, миграции) ← `settings`, `mirror`, `commands` ← `queue` ← `api` (HTTP, маршруты в `ROUTES`, фоновый `tick`, реле общей папки `relay` (поток в `serve`, настройка `relay_dir`)) ← `cli`.
- `db` (схема, миграции) ← `settings`, `mirror`, `commands`, `projects` ← `queue` ← `api` (HTTP, маршруты в `ROUTES`, фоновый `tick`, реле общей папки `relay` (поток в `serve`, настройка `relay_dir`)) ← `cli`.
- `files` (хранилище вложений `files/<sha256>`, каталог — параметр `Hub.files_dir`) ← `queue`, `api`; `mcp_server` берёт из него только предел `MAX_SIZE`.
- `client` (HTTP-клиент хаба) ← `mcp_server` (stdio, инструменты MCP); `importer` → `mirror`/`settings`; `backup` вызывается из `api` и из `db` (копия перед миграцией схемы).
- Подробная карта модулей и границ — `ARCHITECTURE.md`.
@ -24,7 +24,7 @@
- `uv sync` — установка окружения.
- `uv run jira-hub init --config PATH [--force]` — настройки проекта из `config.example.json`, создание токенов.
- `uv run jira-hub serve [--host] [--port]` — локальный запуск хаба.
- Веб-приложение — `http://127.0.0.1:8790/app/` (доска — `/app/board`, задачи — `/app/issues`; вход по клиентскому токену); `/` и `/links` перенаправляют на доску.
- Веб-приложение — `http://127.0.0.1:8790/app/` (доска — `/app/board?project=<метка>`, задачи — `/app/issues?project=<метка>`; вход по клиентскому токену); `/` и `/links` перенаправляют на доску.
- `make web` — сборка приложения (после `git pull` на Mac mini, до перезапуска хаба); `make check` — pytest + lint и тесты фронта; `cd web && npm run dev` — разработка с прокси на тестовый хаб `bash web/scripts/test-hub.sh` (:8791); `cd web && npm run e2e` — Playwright; `cd web && npm run golden` — перегенерировать эталоны доски из `links.html@3931a88` (только при смене фикстур).
- `uv run jira-hub import FOLDER` — импорт CSV-пакета `admin/jira-sync`; `uv run jira-hub mcp [--project <метка>]` — MCP по stdio (с привязкой сессии к проекту); `uv run jira-hub tokens` — показать токены.
- `uv run jira-hub agent publish PATH` — опубликовать версию агента для самообновления (только вверх).

View File

@ -173,10 +173,10 @@ uv run jira-hub agent publish ../jira-agent/src/agent.py
| `GET /agent/known-keys` | агент | ключи зеркала |
| `GET /agent/release` | агент | опубликованная версия агента `{version, sha256, content}`; 404 `no_release` |
| `GET /agent/files/{sha256}` | агент | файл вложения `{file, size, content}` (`content` — base64); 404 `no_file` |
| `GET /projects`, `POST /projects` | клиент | проекты `{projects: […]}` (метка, название, `board_id`, `board_type`, число задач) / завести `{label, name, board_id?, author?}` → 201 `{project, batch_id}` и пакет «Новый проект <метка>» (`refresh` по общей JQL); 409 `project_exists`, 422 `bad_project` |
| `GET /projects`, `POST /projects` | клиент | проекты `{projects: […]}` (метка, название, `board_id`, `board_type`, число задач) / завести `{label, name, board_id?, author?}` → 201 `{project, batch_id}` и пакет «Новый проект <метка>» (`refresh` по общей JQL); 409 `project_exists`, 422 `bad_project`, 422 `bad_batch` (постановка `refresh` отказана) |
| `GET /issues` | клиент | фильтры `project` (метка; неизвестная — 404), `sprint`, `assignee`, `work_kind`, `fact_status`, `status`, `label`, `epic`, `neighbor`, `q`, `hide_closed` (`1` — без задач в статусах из настройки `closed_statuses`, по умолчанию «Готово», «Снято»), `limit`, `offset` |
| `GET /issues/{key}` | клиент | задача целиком |
| `GET /board` | клиент | `?project=<метка>` (неизвестная — 404): снимок для доски — задачи проекта и соседи по связям, с `fact_status`/`work_kind`, все связи, `last_sync` — последний успешный полный прогон |
| `GET /board` | клиент | `?project=<метка>` (неизвестная — 404): снимок для доски — свои задачи проекта, соседи (связи в обе стороны) и эпики своих задач, с `fact_status`/`work_kind`; связи — те, у которых хотя бы один конец — своя задача проекта (без `project` — все задачи и связи), `last_sync` — последний успешный полный прогон |
| `GET /`, `GET /links` | — | перенаправление на `/app/board` |
| `PUT /issues/{key}/note` | клиент | заметки (частично) |
| `POST /batches`, `GET /batches`, `GET /batches/{id}` | клиент | пакеты команд; `project` в теле `POST /batches` — пакет проекта (проверки метки, ограничение задач проектом), `?project=` у `GET /batches` — только пакеты проекта |
@ -218,6 +218,11 @@ launchctl kickstart -k gui/$(id -u)/ru.aepif.jira-hub
исправляет только владелец, прямо в базе хаба: `UPDATE project SET board_id = <номер>, board_type = NULL WHERE label = '<метка>'`
(тип доски определит следующий `refresh`).
После перезапуска хаба: нажать «Обновить из Jira», затем `projects` в MCP должен показать у AWPCA тип доски `scrum`
(до этого `ensure_sprint` AWPCA отвечает 422 «тип доски ещё не известен»). Между `git pull` и перезапуском хаба не
открывать новые сессии MCP: новый MCP/CLI мигрирует базу под работающим старым хабом (миграция v4 ему не вредит, но
лучше без неё).
Без `make web` хаб на `/app…` отвечает 503 с подсказкой. Шрифты (IBM Plex) входят в сборку: приложение
работает без доступа в интернет. Раскладка доски и вход сохраняются: приложение
читает те же ключи `localStorage`, что прежняя страница. Разработка: `bash web/scripts/test-hub.sh` (тестовый хаб

View File

@ -230,7 +230,8 @@
`batch_status/wait/cancel` по id — без ограничения.** Обоснование: список не засоряется чужими пакетами, а явный id —
осознанный запрос. Цена ошибки: фильтр списка.
14. 2026-10-19 — **миграция v4 не удаляет `base_jql` и `board_id` из `setting`; `board_id` остаётся запасной доской
агента; удаление — follow-up.** Обоснование: базу мигрирует любой процесс новой версии (MCP, CLI), возможно — под
агента; удаление — follow-up. `GET/PUT /settings` по-прежнему отдаёт `board_id` как есть (запасная доска агента), а
`base_jql` — вычисляемый из меток проектов.** Обоснование: базу мигрирует любой процесс новой версии (MCP, CLI), возможно — под
работающим старым хабом (находка builder check). Цена ошибки: два мёртвых ключа до follow-up.
15. 2026-10-19 — **миграция переписывает `params.jql` у `refresh` в `queued`/`claimed` со старой базовой JQL.**
Обоснование: иначе прогон из очереди не признается полным. Цена ошибки: одна строка миграции.
@ -344,3 +345,14 @@
48. 2026-10-19 — **эталон `hub-empty.expected.json` не перегенерируется (его пишет прежняя страница, где `hasSprint` всегда
`true`): тест сравнивает с ним, подменив `hasSprint` на `false`.** Обоснование: решение 12 меняет смысл `hasSprint`;
эталон — снимок прежней страницы. Цена ошибки: эталон расходится с прежней страницей в одном поле.
49. 2026-10-19 — **`boards()` отдаёт `type = null`, если тип неизвестен хотя бы у одного проекта доски (иначе — общий
известный тип; при расхождении — первый по порядку проектов).** Обоснование: спека «board_type только по результату
refresh»; агент типы известных досок не перезапрашивает, поэтому новый проект на уже известной доске оставался
с `board_type = NULL` навсегда, и его `ensure_sprint` получал 422; `record_board_types` проставляет тип всем проектам
доски. Цена ошибки: проект на известной доске без спринтов до ручной правки базы.
50. 2026-10-19 — **в пакете проекта метки `labels_add` у `ensure_fields`, равные P без учёта регистра, заменяются на P
(повтор P убирается), как в `ensure_issue`.** Обоснование: единообразие с `ensure_issue`; иначе в Jira уходит
вариант метки в чужом регистре. Цена ошибки: лишняя метка-двойник у задачи.
51. 2026-10-19 — **`queue._assign_boards` берёт проекты из `projects.light_list` (label, board_id, board_type) без
`issues_count`.** Обоснование: подсчёт задач по всему зеркалу при каждой постановке `ensure_sprint` не нужен.
Цена ошибки: нет (поведение и порядок те же).

View File

@ -288,6 +288,8 @@ def h_batches_post(hub, conn, m, q, body):
raise ApiError(422, "bad_batch", "commands — список команд")
if body.get("project") is not None and not isinstance(body["project"], str):
raise ApiError(422, "bad_batch", "project — строка")
if body.get("project") == "":
raise ApiError(422, "bad_batch", "project: пустая строка")
try:
return 201, queue.create_batch(conn, body["title"], body.get("author") or "api", body.get("commands"),
files_dir=hub.files_dir, project=body.get("project"))
@ -334,6 +336,8 @@ def h_command_retry(hub, conn, m, q, body):
raise ApiError(422, "bad_retry", "author — строка")
if body.get("project") is not None and not isinstance(body["project"], str):
raise ApiError(422, "bad_retry", "project — строка")
if body.get("project") == "":
raise ApiError(422, "bad_retry", "project: пустая строка")
try:
return 201, queue.retry_command(conn, _int(m), body.get("author") or "api", files_dir=hub.files_dir,
project=body.get("project"))

View File

@ -74,6 +74,11 @@ def list_projects(conn) -> list[dict]:
return [dict(r) for r in rows]
def light_list(conn) -> list[dict]:
"""Проекты без issues_count (label, board_id, board_type), порядок как у list_projects."""
return [dict(r) for r in conn.execute("SELECT label, board_id, board_type FROM project ORDER BY created_at, label")]
def labels(conn) -> list[str]:
return [r["label"] for r in conn.execute("SELECT label FROM project ORDER BY created_at, label")]
@ -88,12 +93,15 @@ def base_jql(conn) -> str | None:
def boards(conn) -> list[dict]:
"""Доски проектов без повторов (порядок первого проекта); type — первый известный среди проектов доски."""
"""Доски проектов без повторов (порядок первого проекта). type — None, если тип неизвестен хотя бы у одного
проекта доски (агент тогда запросит его, record_board_types проставит всем); иначе первый известный."""
out: dict[int, str | None] = {}
unknown: set[int] = set()
for r in conn.execute("SELECT board_id, board_type FROM project WHERE board_id IS NOT NULL ORDER BY created_at, label"):
if out.get(r["board_id"]) is None:
out[r["board_id"]] = r["board_type"]
return [{"id": i, "type": t} for i, t in out.items()]
out.setdefault(r["board_id"], r["board_type"])
if r["board_type"] is None:
unknown.add(r["board_id"])
return [{"id": i, "type": None if i in unknown else t} for i, t in out.items()]
def record_board_types(conn, found) -> int:

View File

@ -74,7 +74,7 @@ def _assign_boards(conn, cmds) -> None:
"""Доска проекта задачи в params ensure_sprint (design #7): метки временного ключа — из его ensure_issue в пакете,
иначе из зеркала; 422 — задача вне проектов, разные доски, нет доски, канбан, тип ещё не известен."""
temp_labels = {c["params"]["temp_key"]: c["params"].get("labels") or [] for c in cmds if c["type"] == "ensure_issue"}
found = projects.list_projects(conn)
found = projects.light_list(conn)
for i, c in enumerate(cmds, 1):
if c["type"] != "ensure_sprint":
continue
@ -127,9 +127,15 @@ def _check_project(conn, cmds, project) -> list[dict]:
if t == "ensure_fields":
if project.casefold() in {l.casefold() for l in p.get("labels_remove") or []}:
raise C.CommandError(f"команда {i}: снять метку своего проекта {project} нельзя")
added = []
for l in p.get("labels_add") or []:
if l.casefold() in others:
raise C.CommandError(f"команда {i}: метка другого проекта: {l}")
l = project if l.casefold() == project.casefold() else l
if l not in added:
added.append(l)
if "labels_add" in p:
p["labels_add"] = added
if not own(p["key"]):
raise C.CommandError(f"команда {i}: {p['key']} не в проекте {project}")
out.append({**c, "params": p})

View File

@ -894,6 +894,9 @@ def test_project_batches_api(hub, add_project): # #61, T
with pytest.raises(HubError) as e:
c.post("/batches", {"title": "x", "project": 5, "commands": cmd("A-2")})
assert e.value.status == 422
with pytest.raises(HubError) as e:
c.post("/batches", {"title": "x", "project": "", "commands": cmd("A-2")})
assert e.value.status == 422 and "project: пустая строка" in e.value.detail
mine = c.post("/batches", {"title": "x", "project": "STOCKS_DRRK", "commands": cmd("A-2")})
c.post("/batches", {"title": "y", "commands": cmd("A-1")})
assert [b["id"] for b in c.get("/batches", project="STOCKS_DRRK")["batches"]] == [mine["id"]]
@ -907,6 +910,9 @@ def test_project_batches_api(hub, add_project): # #61, T
with pytest.raises(HubError) as e:
c.post(f"/commands/{cid}/retry", {"project": 5})
assert e.value.status == 422
with pytest.raises(HubError) as e:
c.post(f"/commands/{cid}/retry", {"project": ""})
assert e.value.status == 422 and "project: пустая строка" in e.value.detail
assert len(c.get("/batches", limit=50)["batches"]) == before
r = c.post(f"/commands/{cid}/retry", {})
assert c.get(f"/batches/{r['id']}")["project"] == "AWPCA"

View File

@ -10,6 +10,31 @@ def conn(open_db):
return c
def test_boards_type_unknown_if_any_project_unknown(conn): # I-1: новый проект на известной доске
projects.create(conn, "AWPCA", "a", 4216)
projects.record_board_types(conn, {"4216": "scrum"})
projects.create(conn, "NEW", "n", 4216)
assert projects.get(conn, "NEW")["board_type"] is None
assert projects.boards(conn) == [{"id": 4216, "type": None}]
projects.record_board_types(conn, {"4216": "scrum"})
assert projects.boards(conn) == [{"id": 4216, "type": "scrum"}]
assert [p["board_type"] for p in projects.list_projects(conn)] == ["scrum", "scrum"]
def test_boards_diverged_types_first_by_order(conn):
projects.create(conn, "A", "a", 7); projects.create(conn, "B", "b", 7)
conn.execute("UPDATE project SET board_type = 'kanban' WHERE label = 'B'")
conn.execute("UPDATE project SET board_type = 'scrum' WHERE label = 'A'")
assert projects.boards(conn) == [{"id": 7, "type": "scrum"}]
def test_light_list_same_order_no_count(conn):
projects.create(conn, "B", "b", 1); projects.create(conn, "A", "a")
got = projects.light_list(conn)
assert [p["label"] for p in got] == [p["label"] for p in projects.list_projects(conn)]
assert all("issues_count" not in p and set(p) == {"label", "board_id", "board_type"} for p in got)
@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:

View File

@ -701,6 +701,12 @@ def test_project_label_case_variant_replaced(conn, add_project): # сво
assert queue.batch_view(conn, b["id"])["commands"][0]["params"]["labels"] == [S] # без повтора P
def test_project_ensure_fields_labels_add_case_normalized(conn, add_project): # M-2
two(conn, add_project)
b = queue.create_batch(conn, "x", "claude", [fields("AURORA-1", labels_add=["stocks_drrk", "kind-dev", S])], project=S)
assert queue.batch_view(conn, b["id"])["commands"][0]["params"]["labels_add"] == [S, "kind-dev"]
@pytest.mark.parametrize("cmd, msg", [
(new_issue(["awpca"]), "команда 1: метка другого проекта: awpca"), # «Чужая метка проекта»
(fields("AURORA-1538"), "команда 1: AURORA-1538 не в проекте STOCKS_DRRK"), # «Задача другого проекта»

View File

@ -28,8 +28,8 @@ test.describe.serial("проекты", () => {
await d.getByLabel("Название").fill("Запасы ДРРК");
await d.getByRole("button", { name: "Создать" }).click();
await expect(page).toHaveURL(/\?project=STOCKS_DRRK/);
await expect(page.locator(".board .card[data-key]")).toHaveCount(0);
await expect(page.getByRole("status")).toContainText(/пакет #\d+/);
await expect(page.locator(".board .card[data-key]")).toHaveCount(0);
await agentServeRefresh(); // иначе пакет держит «Обновить из Jira» в других файлах на общем тестовом хабе
await expect(page.getByRole("status")).toContainText("выполнен");
});