From bfc1b3056b069a71263fb417c469c5e58c5261aa Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 11:17:47 +0400 Subject: [PATCH 01/21] =?UTF-8?q?spec(sdlc-rename):=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B8=D0=BC=D0=B5=D0=BD=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20jira-hub=20=E2=86=92=20sdlc-hub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change sdlc-rename: пакет, CLI, каталог данных, MCP sdlc-jira/sdlc-wiki, бренд и ключи хранилища веба, скрипт переключения живого хаба (#97). Co-Authored-By: Claude Opus 5.5 --- openspec/changes/sdlc-rename/.openspec.yaml | 2 + openspec/changes/sdlc-rename/design.md | 103 ++++++++ openspec/changes/sdlc-rename/proposal.md | 42 +++ .../sdlc-rename/specs/data-model/spec.md | 41 +++ .../changes/sdlc-rename/specs/hub-api/spec.md | 236 +++++++++++++++++ .../changes/sdlc-rename/specs/mcp/spec.md | 243 ++++++++++++++++++ .../sdlc-rename/specs/migration/spec.md | 39 +++ .../changes/sdlc-rename/specs/web-app/spec.md | 169 ++++++++++++ .../changes/sdlc-rename/specs/wiki/spec.md | 33 +++ 9 files changed, 908 insertions(+) create mode 100644 openspec/changes/sdlc-rename/.openspec.yaml create mode 100644 openspec/changes/sdlc-rename/design.md create mode 100644 openspec/changes/sdlc-rename/proposal.md create mode 100644 openspec/changes/sdlc-rename/specs/data-model/spec.md create mode 100644 openspec/changes/sdlc-rename/specs/hub-api/spec.md create mode 100644 openspec/changes/sdlc-rename/specs/mcp/spec.md create mode 100644 openspec/changes/sdlc-rename/specs/migration/spec.md create mode 100644 openspec/changes/sdlc-rename/specs/web-app/spec.md create mode 100644 openspec/changes/sdlc-rename/specs/wiki/spec.md diff --git a/openspec/changes/sdlc-rename/.openspec.yaml b/openspec/changes/sdlc-rename/.openspec.yaml new file mode 100644 index 0000000..1f5d280 --- /dev/null +++ b/openspec/changes/sdlc-rename/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-24 diff --git a/openspec/changes/sdlc-rename/design.md b/openspec/changes/sdlc-rename/design.md new file mode 100644 index 0000000..195dea5 --- /dev/null +++ b/openspec/changes/sdlc-rename/design.md @@ -0,0 +1,103 @@ +## Context + +`main` bb54d0a: схема v7, модули `jira` и `wiki`, агенты jira v14 и wiki v1. Имя `jira-hub` живёт в: + +- **коде:** пакет `src/jira_hub/` (≈30 файлов импортируют `jira_hub`), `pyproject.toml` (дистрибутив и скрипт + `jira-hub`), `core/paths.py` (`JIRA_HUB_HOME`, `~/.local/share/jira-hub`), `core/cli.py` (`prog`, модуль MCP по + умолчанию `jira`), `core/mcp.py` (`mcp_json`: серверы `jira-hub`/`jira-hub-<модуль>`), `FastMCP("jira-hub")` и + `FastMCP("jira-hub-wiki")`, подсказки «jira-hub serve», `api.py` (текст «make web в каталоге jira-hub», строка запуска); +- **тестах:** `JIRA_HUB_HOME` в фикстурах, эталон `tests/fixtures/baseline/mcp_wiki_tools.expected.json`, проверки имён + серверов и фрагментов `.mcp.json`, пути `src/jira_hub` в `test_layout`, `test_registry`, `test_web_app`, + `test_wiki_projects`; +- **вебе:** `Хаб Jira`, бренд в `shell/Sidebar.tsx`, ключи `jira-hub-token|sidebar|legend`, + `JIRA_HUB_URL` в `vite.config.ts`, `web/scripts/test-hub.sh`, `package.json` (`jira-hub-web`), подсказка входа; +- **документации:** README (launchd, MCP, выкатка), CLAUDE.md, ARCHITECTURE.md, Makefile, `openspec/specs/` (18 + требований в 7 спеках); +- **проектах-потребителях** (не git): `awp-ca` — `.mcp.json`, `enabledMcpjsonServers` в `.claude/settings.local.json`, + `CLAUDE.md`, скилл `building-customer-error-report` (`mcp__jira-hub__*`); `stocks_drrk` — `.mcp.json`, + `enabledMcpjsonServers`; +- **живой установке:** launchd `ru.aepif.jira-hub` (`uv run --project …/jira-bridge/jira-hub jira-hub serve`, лог + `~/Library/Logs/jira-hub.log`), данные `~/.local/share/jira-hub/`, `.mcp.json` в `~/projects/awp-ca` и + `~/projects/stocks_drrk` (серверы `jira-hub`, `jira-hub-wiki`), user scope MCP `jira-hub`, репозиторий Gitea + `aep/jira-hub`, checkout `~/projects/jira-bridge/jira-hub` с worktree в `.wt/`; +- **соседях:** jira-agent и eva-agent упоминают `jira-hub` только в документации, `agent.example.json` и e2e-скрипте + eva-agent (`JIRA_HUB_HOME`, `jira-hub` CLI). Протокол агента имени хаба не содержит: агенты ходят через общую папку. + +## Goals / Non-Goals + +**Goals:** одно имя `sdlc-hub` в коде, CLI, данных, MCP, вебе и документации; живой хаб переключается одним скриптом +владельца с сухим прогоном; новый код не может молча работать с пустым каталогом данных рядом с живым. + +**Non-Goals и Anti-Goals:** см. proposal.md. + +## Decisions + +1. **Имя — `sdlc-hub`** (решение владельца 23.10.2026). Бренд в вебе — «SDLC Hub». +2. **Псевдонима `jira-hub` нет.** Старые записи MCP и launchd после переключения должны падать явно, а не работать + «почти». Переключение делает скрипт сразу для всех известных потребителей. +3. **MCP-серверы `sdlc-<модуль>`**, модуль в `sdlc-hub mcp` обязателен. Модуль по умолчанию был нужен только ради + неизменных записей `.mcp.json` в hub-modules; при переименовании записи меняются всё равно. Имена инструментов в + Claude станут `mcp__sdlc-jira__*` и `mcp__sdlc-wiki__*`. +4. **Каталог данных не переносится кодом.** Хаб, MCP-серверы открытых сессий Claude и CLI открывают одну базу; перенос + «на лету» при старте любого из них рискует WAL и гонкой. Код только отказывает с подсказкой (требование «Каталог + данных прежнего имени»), перенос — шаг скрипта при остановленном хабе. +5. **Скрипт переключения в репозитории** (`scripts/cutover.sh`), сухой прогон по умолчанию. Шагов много, они касаются + launchd, двух чужих репозиториев и user scope Claude; руками их легко сделать не в том порядке (I-2 wiki-module). + Скрипт одноразовый, после переключения остаётся в истории как образец; тесты — на сухой прогон и проверки шага 1 + (во временном `HOME`), без launchd. +6. **Переименование репозитория в Gitea — руками владельца** в интерфейсе (Settings → Repository name). Gitea оставляет + перенаправление со старого адреса; `tea` и `origin` после этого работают по новому адресу. Скрипт проверяет, что + переименование сделано, и меняет `origin`. +7. **Checkout переезжает в `jira-bridge/sdlc-hub`**, workspace `jira-bridge` остаётся: от его пути зависят каталог + памяти Claude, workspace `CLAUDE.md`, путь общей папки реле (`…/utm/rshb/jira-bridge`) и настройки агентов на + виртуалке. Переименование workspace — отдельно, если понадобится. +8. **Ключи `localStorage` — с разовым переносом** из `jira-hub-*`: браузер владельца не должен снова просить токен и + терять свёрнутость панели и легенды. Ключи доски `graph-*` имени хаба не содержат и не меняются. +9. **История не переписывается**: `docs/superpowers/plans/`, `openspec/changes/archive/` остаются со старыми именами. + Раздел `Purpose` спек `hub-api`, `mcp`, `web-app` (дельтами не меняется) правится в PR архива. +10. **Агенты без релиза.** `src/agent.py` и `src/run.py` jira-agent и eva-agent не трогаются (строки документации — со + следующим релизом); README, CLAUDE.md, ARCHITECTURE.md, `agent.example.json` и `scripts/e2e-hub.sh` eva-agent — + мелкими PR в своих репозиториях (bounded, без change). +11. **Порядок с #98 (веб под модули).** Переименование сливается первым; ветка #98 перебазируется на него. Бренд и + ключи хранилища меняет этот change, устройство боковой панели — #98. + +## Переключение (для владельца) + +1. Слить PR этого change; переименовать репозиторий в Gitea `aep/jira-hub` → `aep/sdlc-hub`. +2. Закрыть или не трогать сессии Claude с MCP хаба (после переключения — `/mcp` reconnect). +3. `bash ~/projects/jira-bridge/jira-hub/scripts/cutover.sh` — сухой прогон, проверить вывод. +4. `bash ~/projects/jira-bridge/jira-hub/scripts/cutover.sh --apply`. +5. Просмотреть правки в awp-ca и stocks_drrk (`.mcp.json`, `.claude/`, `CLAUDE.md` awp-ca; копии `*.pre-sdlc` рядом) и + удалить копии. Это не git-репозитории — коммитить нечего. +6. Проверить: `agent_status` через `sdlc-jira`, `status` через `sdlc-wiki`, веб `http://127.0.0.1:8790/app/` без + повторного входа. + +Откат: остановить `ru.aepif.sdlc-hub`, перенести каталоги обратно, вернуть plist `ru.aepif.jira-hub` из README +предыдущей версии, `git checkout` прежнего коммита, вернуть записи MCP — порядок в README. + +## Risks / Trade-offs + +- **Сессии Claude с открытым MCP** держат старый путь к пакету: после переноса checkout их серверы падают до + reconnect. Принято: переключение — разовое событие владельца. +- **codegraph-индексы** в `.codegraph/` checkout и worktree содержат абсолютные пути: после переноса — `codegraph init` + заново (шаг в сводке скрипта). +- **Гонка с #98**: массовый перенос `src/jira_hub` → `src/sdlc_hub` конфликтует с любой веткой, трогающей Python; + #98 — только `web/`, поэтому конфликт ограничен брендом и ключами хранилища (решение 11). + +## Decisions made without discussion + +Владелец 24.10.2026 ответил «делай» на вопросы brainstorming — все решения ниже приняты агентом. + +| # | Дата | Решение | Обоснование | Цена ошибки | +|---|---|---|---|---| +| 1 | 24.10.2026 | Бренд «SDLC Hub» | имя из решения владельца, латиница как в имени репозитория | правка строки в вебе | +| 2 | 24.10.2026 | Без псевдонима `jira-hub` | явный отказ лучше тихой работы старых записей | вернуть точку входа в `pyproject.toml` | +| 3 | 24.10.2026 | MCP `sdlc-jira`/`sdlc-wiki`, модуль обязателен | симметрия модулей, ядро без «jira по умолчанию» | переименовать серверы ещё раз во всех `.mcp.json` | +| 4 | 24.10.2026 | Каталог данных переносит скрипт, код только отказывает | гонка открытых баз при переносе на лету | ручной `mv` при остановленном хабе | +| 5 | 24.10.2026 | Скрипт `scripts/cutover.sh` с сухим прогоном | много шагов в чужих конфигурациях, порядок важен | шаги руками по README | +| 6 | 24.10.2026 | Gitea переименовывает владелец | действие на сервере, вне репозитория | — | +| 7 | 24.10.2026 | Workspace `jira-bridge` не переименовывается | от пути зависят память Claude, реле, агенты | отдельный change позже | +| 8 | 24.10.2026 | Перенос ключей `localStorage` | не просить токен заново | повторный вход в веб | +| 9 | 24.10.2026 | История (планы, архив) не меняется | это запись прошлого | — | +| 10 | 24.10.2026 | Агенты без релиза, документация — мелкими PR | протокол не меняется | строки документации в `agent.py` со старым именем до следующего релиза | +| 11 | 24.10.2026 | Сначала переименование, потом #98 | #98 перебазируется на новые пути | перебазирование веток | diff --git a/openspec/changes/sdlc-rename/proposal.md b/openspec/changes/sdlc-rename/proposal.md new file mode 100644 index 0000000..fc1dc7f --- /dev/null +++ b/openspec/changes/sdlc-rename/proposal.md @@ -0,0 +1,42 @@ +## Why + +Хаб перестал быть только мостом Jira: с hub-modules (#87) он состоит из ядра и модулей, с wiki-module (#91) — второй +модуль (EVA Wiki) со своим агентом и своим MCP. Имя `jira-hub` теперь вводит в заблуждение: в вебе «Хаб Jira», MCP вики +называется `jira-hub-wiki`, данные вики лежат в `~/.local/share/jira-hub/`. Решение владельца 23.10.2026 — хаб +становится **sdlc-hub**; переименование было отложено до готовности модулей. 24.10.2026 владелец решил делать его сейчас, +вместе с пересборкой веба под модули (jira-hub#98), «раз мы причёсываем визуал». + +## What Changes + +- **Пакет и CLI.** Пакет `jira_hub` → `sdlc_hub`, дистрибутив `jira-hub` → `sdlc-hub`, команда `jira-hub` → `sdlc-hub`. + Псевдонима `jira-hub` нет. +- **MCP по модулю без модуля по умолчанию.** `sdlc-hub mcp <модуль> [--project <метка>]`; модуль обязателен. Серверы + называются `sdlc-<модуль>`: `sdlc-jira`, `sdlc-wiki`. Фрагмент `.mcp.json` из `project_create` — с новыми именами. +- **Каталог данных.** `~/.local/share/sdlc-hub/`, переменная `SDLC_HUB_HOME`. Если нового каталога нет, а прежний + `~/.local/share/jira-hub/` есть, любая команда хаба, открывающая базу, отказывает с текстом, как перенести каталог; + сама ничего не переносит. +- **Веб.** Бренд «SDLC Hub» (заголовок вкладки, боковая и верхняя панели), ключи `localStorage` `sdlc-hub-*` с разовым + переносом значений из `jira-hub-*`, подсказка входа `uv run sdlc-hub tokens`, `npm run dev` — `SDLC_HUB_URL`. +- **Переключение живого хаба.** Скрипт `scripts/cutover.sh` (сухой прогон по умолчанию, `--apply` — выполнить) для + владельца: останавливает launchd `ru.aepif.jira-hub`, переносит каталог данных, ставит `ru.aepif.sdlc-hub` с логом + `~/Library/Logs/sdlc-hub.log`, переписывает записи MCP в `.mcp.json` известных проектов и в user scope, переносит + checkout `jira-bridge/jira-hub` → `jira-bridge/sdlc-hub` и чинит worktree. Репозиторий в Gitea `aep/jira-hub` → + `aep/sdlc-hub` владелец переименовывает в интерфейсе Gitea, скрипт затем меняет `origin`. +- **Документация.** README, CLAUDE.md, ARCHITECTURE.md, текущие спеки (`openspec/specs/`) — на новые имена; workspace + `CLAUDE.md` и упоминания в jira-agent/eva-agent (README, CLAUDE.md, ARCHITECTURE.md, `agent.example.json`, e2e-скрипт + eva-agent) — отдельными мелкими PR в своих репозиториях. + +## Non-Goals + +- Пересборка веба под модули — jira-hub#98, отдельный change. +- Переименование workspace `jira-bridge`, общей папки реле (`relay_dir`), репозиториев агентов, launchd агентов на + виртуалке. +- Правка `src/agent.py` и `src/run.py` агентов: строки документации в них меняются со следующим релизом агента. +- Правка истории: `docs/superpowers/plans/`, `openspec/changes/archive/`. +- Изменения протокола агента, HTTP API (кроме текстов), схемы базы, токенов. + +## Anti-Goals + +- Живой хаб не должен оказаться в состоянии «код новый, данные/launchd старые» без явной ошибки: молчаливо создать пустой + `~/.local/share/sdlc-hub/` рядом с живыми данными — провал, даже если тесты зелёные. +- Агенты на виртуалке не требуют ни перенастройки, ни нового релиза. diff --git a/openspec/changes/sdlc-rename/specs/data-model/spec.md b/openspec/changes/sdlc-rename/specs/data-model/spec.md new file mode 100644 index 0000000..bf8dcd9 --- /dev/null +++ b/openspec/changes/sdlc-rename/specs/data-model/spec.md @@ -0,0 +1,41 @@ +## MODIFIED Requirements + +### Requirement: Настройки +Таблица `setting` SHALL хранить парами ключ–JSON: `project_key`, `field_ids`, `issue_types`, `priority_map`, `link_types`, +`transitions_map`, `pacing`, `tokens` (агентский и клиентский), `settings_version` (увеличивается при любой +правке), `backup_keep`, `auto_refresh_minutes`, `relay_dir` (абсолютный путь папки реле или `null`, по умолчанию `null`; +относительный путь и не-строка при записи — 422; агенту не передаётся), `closed_statuses` (список имён статусов Jira, считающихся закрытыми, по умолчанию +`["Готово", "Снято"]`; не-список при записи — 422; агенту не передаётся; на существующей базе появляется со значением +по умолчанию при открытии хаба). Базовая JQL вычисляется из проектов, доска — свойство проекта (требование +«Проекты»); на новой базе ключей `base_jql` и `board_id` нет. Оставшиеся от v3 ключи хаб SHALL NOT использовать, кроме +передачи `board_id` агенту как запасной доски для команд без доски в параметрах (поставленных до миграции); +`GET /settings` SHALL отдавать вычисленную `base_jql` только для чтения, а запись `base_jql` или `board_id` через +`PUT /settings` — 422 с пояснением. Спринты агент читает с досок проектов, отдельной настройки имён спринтов нет. Начальные значения SHALL импортироваться из `admin/jira-sync/config.example.json` +командой `sdlc-hub init`; пароль и логин Jira в настройках MUST NOT присутствовать. `issue_types` — объект +`{task, epic, bug}`, у каждого `{name, id}` (`id` может быть `null`); `init` берёт их из ключей конфига +`issue_type_<тип>` и `issue_type_<тип>_id`; `bug` попадает в `issue_types`, только если в конфиге есть хотя бы +один из ключей `issue_type_bug`, `issue_type_bug_id` (`task` и `epic` — как раньше, всегда). + +#### Scenario: Правка настроек поднимает версию +- **WHEN** через API изменён `pacing` +- **THEN** `settings_version` увеличен, следующий `claim` отдаёт агенту новую версию + +#### Scenario: Конфиг без ключей ошибки +- **WHEN** `sdlc-hub init --config` с `issue_type_task`, `issue_type_epic`, без `issue_type_bug` +- **THEN** `issue_types` содержит `task` и `epic`, `bug` нет; постановка `ensure_issue` с `issue_type = bug` — 422 + +#### Scenario: Неверный relay_dir +- **WHEN** `PUT /settings` с `relay_dir = "rshb/jira-bridge"` +- **THEN** 422, значение не записано, `settings_version` не изменился + +#### Scenario: Закрытые статусы по умолчанию +- **WHEN** хаб открыл базу, в которой нет ключа `closed_statuses` +- **THEN** `GET /settings` отдаёт `closed_statuses = ["Готово", "Снято"]`; `claim` агенту этого ключа не передаёт + +#### Scenario: Запись базовой JQL +- **WHEN** `PUT /settings {base_jql: "project = AURORA"}` +- **THEN** 422 «базовая JQL собирается из проектов»; `settings_version` не изменился + +#### Scenario: Создание проекта не трогает версию настроек +- **WHEN** создан проект `STOCKS_DRRK` +- **THEN** `settings_version` прежний; `GET /settings` отдаёт `base_jql` с обеими метками diff --git a/openspec/changes/sdlc-rename/specs/hub-api/spec.md b/openspec/changes/sdlc-rename/specs/hub-api/spec.md new file mode 100644 index 0000000..b998f2d --- /dev/null +++ b/openspec/changes/sdlc-rename/specs/hub-api/spec.md @@ -0,0 +1,236 @@ +## MODIFIED Requirements + +### Requirement: Процесс и доступ +`sdlc-hub serve` SHALL запускать один процесс: HTTP на `0.0.0.0:` (по умолчанию 8790), база и настройки в +`~/.local/share/sdlc-hub/` (переопределяется `SDLC_HUB_HOME`). Все маршруты API SHALL требовать +`Authorization: Bearer `: токен агента модуля открывает только `/agent/*` (маршруты ядра — от имени этого агента, +маршруты модуля со scope `agent` — только агенту своего модуля, см. `modules`, «Агенты модулей»), клиентский — всё +остальное. `sdlc-hub tokens` SHALL печатать клиентский токен и токены агентов с именами модулей. Исключение — +раздача веб-приложения (`GET /app`, `GET /app/…`) и перенаправления `GET /`, `GET /links`: это статические файлы без +данных, они отдаются без токена. Ответы API — JSON, ошибки — `{error, detail}` с кодами 400 (неверное тело или +`Content-Length`), 401, 404, 409, 413 (тело больше предела), 422, 500 (внутренняя ошибка); реле общей папки +дополнительно отвечает 502. Методы, кроме GET, POST, PUT и PATCH, отвечают 501 стандартной страницей `http.server`, не +JSON. + +#### Scenario: Чужой токен +- **WHEN** токеном агента `jira` вызван `GET /issues` +- **THEN** 401 + +#### Scenario: Неподдерживаемый метод +- **WHEN** `DELETE /issues/AURORA-1` +- **THEN** 501, тело не JSON; база не меняется + +#### Scenario: Страница без токена +- **WHEN** `GET /app/board` без заголовка `Authorization`, сборка приложения есть +- **THEN** 200, `Content-Type: text/html; charset=utf-8`, тело — `index.html` сборки; данных зеркала в нём нет + +#### Scenario: Токены после миграции +- **WHEN** база v6 с агентским токеном T мигрировала в v7, запущен `sdlc-hub tokens` +- **THEN** вывод содержит клиентский токен, `jira: T` и новый токен `wiki` + +### Requirement: Реле через общую папку +`sdlc-hub serve` SHALL запускать поток реле вместе с HTTP-сервером. Пока настройка `relay_dir` пуста (`null`), реле +бездействует; настройку оно SHALL перечитывать не реже раза в 5 с, включение и выключение не требуют перезапуска. Если +каталог `relay_dir` существует, реле SHALL создать в нём подкаталоги `req/`, `resp/`, `work/`; если не существует — +записать одно предупреждение в лог и проверять снова. + +Обмен: +- запрос агента — файл `req/.json`: `{id, method, path, body, authorization, deadline}`, где `deadline` — момент UTC + (ISO 8601), после которого ответ не нужен; файлы, имя которых начинается с точки, реле MUST NOT читать; +- реле SHALL брать запрос переименованием в `work/.json` и обрабатывать каждый взятый запрос в отдельном потоке; +- запрос с истёкшим `deadline` SHALL выбрасываться без ответа; `POST /agent/claim` SHALL пересылаться, только если до + `deadline` осталось не меньше 35 с, иначе выбрасываться без ответа; +- остальные запросы реле SHALL пересылать в свой HTTP на `:` процесса `serve` (`127.0.0.1`, если `serve` + слушает `0.0.0.0`) тем же методом, путём и телом, с заголовком `Authorization` из файла, и записывать ответ + `resp/.json` = `{id, status, body}` (тело — строка ответа HTTP) через временный файл с именем на точку и + переименование; затем удалять `work/.json`; +- если соединение со своим HTTP не удалось или ответ нарушает протокол HTTP, ответ SHALL иметь `status = 502` и текст + ошибки в `body`; +- нечитаемый JSON запроса SHALL перечитываться до 2 с, затем запрос выбрасывается с записью в лог. + +При первой подготовке каждой папки после старта реле (в том числе новой папки после смены `relay_dir`) реле SHALL +удалить файлы из `work/` с записью в лог. Повторная подготовка той же папки (пропал подкаталог, не было доступа) SHALL +только создавать недостающие подкаталоги, не трогая `work/`. Раз в минуту +реле SHALL удалять ответы в `resp/` старше 10 мин, запросы в `req/` с `deadline` старше 60 с и файлы с именем на точку +старше 10 мин. На каждый обработанный запрос реле SHALL писать в лог хаба строку `relay +<секунды>`. HTTP API, обработчики и проверка токенов при этом не меняются. + +#### Scenario: Запрос через папку +- **WHEN** в `req/` появился `a1.json` с `GET /agent/settings`, агентским токеном и `deadline` через 60 с +- **THEN** в `resp/` появляется `a1.json` со `status = 200` и телом настроек; `work/a1.json` удалён + +#### Scenario: Чужой токен через папку +- **WHEN** в файле запроса `GET /issues` с агентским токеном +- **THEN** ответ со `status = 401`, как по HTTP + +#### Scenario: Просроченный claim +- **WHEN** реле взяло `POST /agent/claim`, до `deadline` которого осталось 20 с +- **THEN** запрос выброшен без ответа и без обращения к хабу; команда в очереди осталась `queued` + +#### Scenario: Долгий claim не задерживает другие запросы +- **WHEN** реле пересылает `claim`, который ждёт команду 30 с, и в это время в `req/` появляется `GET /agent/settings` +- **THEN** ответ на `settings` пишется, не дожидаясь `claim` + +#### Scenario: Папка не подключена +- **WHEN** `relay_dir` задан, но каталога нет; через минуту каталог появился +- **THEN** в логе одно предупреждение; после появления каталога реле создаёт подкаталоги и обрабатывает запросы + +#### Scenario: Сбой протокола своего HTTP +- **WHEN** при пересылке запроса свой HTTP оборвал ответ (`http.client.HTTPException`) +- **THEN** в `resp/` ответ со `status = 502` и текстом ошибки; реле продолжает работу + +#### Scenario: Повторная подготовка не трогает взятые запросы +- **WHEN** реле работает, в `work/` лежит взятый запрос, и пропал подкаталог `resp/` +- **THEN** реле создаёт `resp/` заново; файл в `work/` не удалён, в логе нет «удалён незавершённый запрос» + +### Requirement: Публикация версии агента +`sdlc-hub agent publish [--module <модуль>] PATH` SHALL публиковать файл агента модуля (по умолчанию `jira`; +неизвестный модуль или модуль без команд — код 2 и список модулей с агентами в stderr): текст в UTF-8 не больше 1 МБ, +ровно одна строка `AGENT_VERSION = <целое>`, версия больше опубликованной для этого модуля. Файл SHALL записываться в +`/release/<модуль>/agent.py` (каталоги 0700), затем `manifest.json` `{version, sha256, size, +published_at}`; оба — через временный файл с `fsync` и переименование, манифест последним. Прежний `agent.py` на время +публикации SHALL переименовываться в `agent.py.prev` и удаляться после записи манифеста; если запись нового файла или +манифеста не удалась (`OSError`), хаб SHALL вернуть `agent.py.prev` на место `agent.py`, удалить временные файлы и +завершить команду сообщением «не удалось записать публикацию: <ошибка>» и кодом 1 — опубликованной остаётся прежняя +версия. Команда SHALL работать без запущенного `serve` и печатать «опубликована v, sha256 <первые 12 символов>…, + байт»; при ошибке — сообщение и код 1, опубликованное не меняется. При старте хаб SHALL один раз перенести +прежнюю публикацию `release/agent.py` и `release/manifest.json` в `release/jira/`, если `release/jira/` ещё нет; ошибка +переноса — предупреждение в лог, хаб стартует. Хаб SHALL читать манифест модуля агента при каждом `claim`, +`GET /agent/release` и `GET /agent`; манифест годен, только если это объект, `version` — целое число (не логическое) не +меньше 1, `sha256` — строка из 64 символов `[0-9a-f]`; негодный или нечитаемый манифест означает «публикации нет» и +предупреждение в лог — одно на каждый новый текст ошибки. Файл `agent.py`, не декодируемый как UTF-8 при совпавшем +sha256, означает «публикации нет» (`GET /agent/release` — 404 `no_release`). `GET /agent?module=<модуль>` (без +параметра — `jira`) и каждый элемент `GET /agents` SHALL дополняться полями `release` (`{version, sha256, +published_at}` или `null`), `update_state`, `launcher_version`, `agent_reported` (был ли `claim` этого агента с момента +запуска хаба) и `update_hint`: «агент запущен без run.py — обновится только вручную» (опубликована версия выше версии +агента, агент онлайн, `agent_reported = true`, `launcher_version = null`), «откат с v на v: » +(`rolled_back`), «v отвергнута: » (`rejected`), иначе `null`. + +#### Scenario: Публикация новой версии +- **WHEN** опубликована v5, `sdlc-hub agent publish jira-agent/src/agent.py` с `AGENT_VERSION = 6` +- **THEN** в `release/jira/` — новый `agent.py` и манифест с `version = 6`, `agent.py.prev` нет; вывод «опубликована v6, + sha256 …»; код 0 + +#### Scenario: Публикация eva-agent +- **WHEN** `sdlc-hub agent publish --module wiki eva-agent/src/agent.py` с `AGENT_VERSION = 1`, опубликована jira v14 +- **THEN** в `release/wiki/` — v1; `claim` агента `wiki` получает `agent_release {version: 1, …}`, `claim` агента `jira` — + `{version: 14, …}` + +#### Scenario: Неизвестный модуль +- **WHEN** `sdlc-hub agent publish --module nope agent.py` +- **THEN** код 2, в stderr «неизвестный модуль nope» и список модулей с агентами; `release/` не изменён + +#### Scenario: Версия не выше опубликованной +- **WHEN** опубликована v6, публикуется файл с `AGENT_VERSION = 6` +- **THEN** «опубликована v6, новая должна быть больше», код 1; `release/` не изменён + +#### Scenario: Нет строки версии +- **WHEN** публикуется файл без строки `AGENT_VERSION = <целое>` или с двумя такими строками +- **THEN** сообщение об ошибке, код 1; `release/` не изменён + +#### Scenario: Диск полон при записи манифеста +- **WHEN** опубликована v8, публикуется v9, запись `manifest.json` завершилась `OSError` +- **THEN** «не удалось записать публикацию: …», код 1, без трейсбека; `agent.py` и манифест — прежние v8, + `GET /agent/release` отдаёт v8; временных файлов и `agent.py.prev` в `release/jira/` нет + +#### Scenario: Негодный манифест +- **WHEN** в `manifest.json` `version = 6.9` (или `true`, или `sha256` короче 64 символов), хаб получает 100 `claim` +- **THEN** в `claim` `agent_release = null`, `GET /agent/release` — 404; в логе одно предупреждение о манифесте + +#### Scenario: Агент без запускателя +- **WHEN** опубликована v6, агент v5 онлайн, в последнем `claim` `launcher_version = null` +- **THEN** `GET /agent` → `agent_reported = true`, `update_hint` = «агент запущен без run.py — обновится только вручную» + +#### Scenario: Хаб перезапущен, агент ещё не выходил на связь +- **WHEN** опубликована v10, агент v9 под запускателем, хаб перезапущен, `claim` после запуска ещё не было +- **THEN** `GET /agent` → `agent_reported = false`, `launcher_version = null`, `update_hint = null`; после первого + `claim` — `agent_reported = true`, `launcher_version` из `claim` + +#### Scenario: Перенос прежней публикации +- **WHEN** хаб с `release/agent.py` v14 и манифестом без `release/jira/` запущен на новом коде +- **THEN** публикация v14 лежит в `release/jira/`, `claim` агента `jira` получает `agent_release {version: 14, …}` + +### Requirement: Раздача веб-приложения +Хаб SHALL отдавать собранное веб-приложение (спека `web-app`) из каталога пакета `src/sdlc_hub/web/app/`, читая файлы +с диска на каждый запрос: +- `GET /app/assets/<путь>` — файл из `assets/` сборки; путь, который после разрешения выходит за `assets/`, не + существует или содержит нулевой байт, — 404; `Content-Type` по расширению (`.js` — `text/javascript; charset=utf-8`, `.css` — + `text/css; charset=utf-8`, `.svg` — `image/svg+xml`, `.png` — `image/png`, `.ico` — `image/x-icon`, `.woff2` — + `font/woff2`, `.map` — `application/json`, прочее — `application/octet-stream`); + `Cache-Control: public, max-age=31536000, immutable`; +- любой другой `GET /app` или `GET /app/…` — `index.html` сборки, `text/html; charset=utf-8`, `Cache-Control: no-cache`; +- если `index.html` сборки нет — на любой `GET /app…` 503 `text/html; charset=utf-8` с текстом, что приложение не + собрано и нужно выполнить `make web` в каталоге sdlc-hub; +- `GET /` и `GET /links` — 302 с `Location: /app/board`. +Сборка приложения не хранится в git; её создаёт `make web`. + +#### Scenario: Файл сборки +- **WHEN** в сборке есть `assets/index-abc123.js`, запрошен `GET /app/assets/index-abc123.js` +- **THEN** 200, `Content-Type: text/javascript; charset=utf-8`, `Cache-Control` с `immutable`, тело — содержимое файла + +#### Scenario: Путь приложения +- **WHEN** запрошен `GET /app/issues/AURORA-1620` +- **THEN** 200, тело — `index.html` сборки, `Cache-Control: no-cache` + +#### Scenario: Выход за каталог сборки +- **WHEN** запрошен `GET /app/assets/../../api.py` (в том числе в виде `%2e%2e`) +- **THEN** 404, содержимое файлов вне `assets/` не отдаётся + +#### Scenario: Приложение не собрано +- **WHEN** каталога сборки нет, запрошен `GET /app/board` +- **THEN** 503, тело — HTML с подсказкой `make web` + +#### Scenario: Старый адрес доски +- **WHEN** запрошен `GET /links` или `GET /` +- **THEN** 302, `Location: /app/board` + +#### Scenario: Нулевой байт в пути +- **WHEN** запрошен `GET /app/assets/%00` или `GET /app/assets/a%00.js` +- **THEN** 404 `not_found`, а не 500 + +## ADDED Requirements + +### Requirement: Каталог данных прежнего имени +Если `SDLC_HUB_HOME` не задан, `~/.local/share/sdlc-hub/` не существует, а `~/.local/share/jira-hub/` существует, любая +команда `sdlc-hub`, которой нужен каталог данных (`serve`, `mcp`, `tokens`, `init`, `import`, `agent publish`), SHALL +завершиться с кодом 2 и текстом в stderr: каталог данных лежит под прежним именем, перенести его — +`scripts/cutover.sh` (или `mv ~/.local/share/jira-hub ~/.local/share/sdlc-hub` при остановленном хабе). Команда MUST NOT +создавать новый каталог, переносить или менять прежний. Если существуют оба каталога, используется новый, в stderr — +предупреждение, что прежний можно удалить после проверки. + +#### Scenario: Код обновлён, каталог не перенесён +- **WHEN** есть только `~/.local/share/jira-hub/`, запущен `sdlc-hub serve` +- **THEN** код выхода 2, в stderr — подсказка о переносе; `~/.local/share/sdlc-hub/` не создан, прежний каталог не тронут + +#### Scenario: Задан SDLC_HUB_HOME +- **WHEN** `SDLC_HUB_HOME=/tmp/x`, есть `~/.local/share/jira-hub/` +- **THEN** команда работает с `/tmp/x`, проверка прежнего каталога не выполняется + +### Requirement: Переключение живого хаба +`scripts/cutover.sh` SHALL переводить живую установку Mac mini со старого имени на новое. Без `--apply` скрипт SHALL +только печатать шаги и найденные проблемы, ничего не меняя. С `--apply` — по порядку, с остановкой на первой ошибке: +1. проверки: основной checkout `jira-bridge/jira-hub` чистый и на `main`; в Gitea доступен `aep/sdlc-hub` (владелец + переименовал репозиторий); `~/.local/share/sdlc-hub/` и `jira-bridge/sdlc-hub` не существуют; +2. остановить launchd `ru.aepif.jira-hub` и удалить его plist; +3. перенести каталог данных `~/.local/share/jira-hub` → `~/.local/share/sdlc-hub`; +4. перенести checkout `jira-bridge/jira-hub` → `jira-bridge/sdlc-hub`, `git worktree repair`, `origin` → + `aep/sdlc-hub`, `git pull --ff-only`, пересоздать `.venv` (`uv sync`), `make web`; +5. поставить и запустить launchd `ru.aepif.sdlc-hub` (`uv run --project sdlc-hub serve`, лог + `~/Library/Logs/sdlc-hub.log`) и дождаться ответа хаба на порту из настроек; +6. в проектах из списка (по умолчанию `~/projects/awp-ca`, `~/projects/stocks_drrk`): в `.mcp.json` заменить записи + `jira-hub` → `sdlc-jira` (`sdlc-hub mcp jira`) и `jira-hub-wiki` → `sdlc-wiki` (`sdlc-hub mcp wiki`) с новым путём; в + `.claude/settings*.json` — имена серверов (`enabledMcpjsonServers` и т. п.) и разрешения `mcp__jira-hub__` → + `mcp__sdlc-jira__`, `mcp__jira-hub-wiki__` → `mcp__sdlc-wiki__`; в `CLAUDE.md` и `.claude/skills/**` — те же имена + серверов и инструментов. Прочие файлы проектов (заметки, отчёты) не трогаются. Перед правкой файл копируется рядом + с суффиксом `.pre-sdlc`; коммиты скрипт не делает; +7. в user scope Claude Code заменить `jira-hub` на `sdlc-jira` (`claude mcp remove`/`add`). +Каждый выполненный шаг SHALL печататься; в конце — сводка, что осталось владельцу (`/mcp` reconnect в открытых сессиях, +`codegraph init` в checkout и worktree, удалить копии `.pre-sdlc` после проверки). Шаги откатываются в обратном порядке по инструкции из README. + +#### Scenario: Сухой прогон +- **WHEN** `bash scripts/cutover.sh` без аргументов +- **THEN** напечатаны шаги и проверки, файловая система, launchd и конфигурации Claude не изменены + +#### Scenario: Репозиторий в Gitea не переименован +- **WHEN** `bash scripts/cutover.sh --apply`, `aep/sdlc-hub` в Gitea не найден +- **THEN** скрипт останавливается на шаге 1 с подсказкой, launchd и каталоги не тронуты diff --git a/openspec/changes/sdlc-rename/specs/mcp/spec.md b/openspec/changes/sdlc-rename/specs/mcp/spec.md new file mode 100644 index 0000000..e0cd68e --- /dev/null +++ b/openspec/changes/sdlc-rename/specs/mcp/spec.md @@ -0,0 +1,243 @@ +## MODIFIED Requirements + +### Requirement: Сервер и регистрация +`sdlc-hub mcp <модуль> [--project <метка>]` SHALL запускать MCP-сервер модуля хаба по stdio (пакет `mcp`), ходящий в +HTTP API хаба клиентским токеном из настроек; сервер называется `sdlc-<модуль>` (`sdlc-jira`, `sdlc-wiki`). Модуль +обязателен: без него — код выхода 2 и список модулей в stderr, сервер не запускается; неизвестный модуль — так же. +Общая регистрация — в user scope Claude Code как `sdlc-jira` (`uv run --project <путь> sdlc-hub mcp jira`), без +проекта. Репозиторий проекта SHALL регистрировать серверы модулей в своём `.mcp.json` (project scope) с +`--project <метка>`; эта запись перекрывает общую. Если хаб недоступен, каждый инструмент SHALL вернуть текст «хаб не +запущен: sdlc-hub serve», а не исключение. + +#### Scenario: Хаб выключен +- **WHEN** вызван `issues` при остановленном хабе +- **THEN** ответ инструмента — одна строка с подсказкой, сервер MCP продолжает работать + +#### Scenario: Сервер модуля jira +- **WHEN** запущен `sdlc-hub mcp jira --project AWPCA` +- **THEN** сервер называется `sdlc-jira`, список инструментов и их описания совпадают с прежним сервером `jira-hub`, + сессия привязана к `AWPCA` + +#### Scenario: Модуль по умолчанию +- **WHEN** запущен `sdlc-hub mcp --project AWPCA` +- **THEN** модуля по умолчанию больше нет: код выхода 2, в stderr — подсказка указать модуль и список модулей (`jira`, `wiki`) + +#### Scenario: Неизвестный модуль +- **WHEN** запущен `sdlc-hub mcp nope` +- **THEN** код выхода 2, в stderr — «неизвестный модуль nope» и список модулей (`jira`, `wiki`) + +### Requirement: Инструменты +| Инструмент | Параметры | Результат | +|---|---|---| +| `projects` | — | таблица: метка, название, доска, тип доски («неизвестен», пока агент не определил), число своих задач; в привязанной сессии текущий проект отмечен | +| `project_create` | `label`, `name`, `board_id` (необязательно) | `POST /projects` с автором `claude`: проект, id пакета обновления и фрагмент `.mcp.json` для репозитория проекта (см. ниже); 409/422 — текст ошибки | +| `issues` | фильтры как у `GET /issues` (кроме `project` — его задаёт привязка), `limit` (по умолчанию 50) | таблица: ключ, тема (до 80 символов), статус, срок, исполнитель, спринт, статус по факту | +| `issue` | `key` | поля, связи (обе стороны, с типом и направлением), заметки, последние команды, комментарии: «комментарии (N):» и 5 последних (номер «#», автор, время, текст до 300 символов с «…»), «ещё M», если их больше 5; без комментариев — «комментариев нет»; у комментария со ссылками на вложения — строка «файлы: # <имя>, …»; затем вложения (см. «Вложения в MCP») | +| `comment` | `key`, `comment_id` (номер из ответа `issue`, «#» в начале допускается) | один комментарий целиком по `GET /issues/{key}`: строка « # <автор>, создан » (и «, изменён », если `updated` есть и отличается от `created`), под ней полный текст с переносами строк; нет такого номера — «у нет комментария #»; есть ссылки на вложения — после текста строка «вложения:» и по строке на каждое: «# <имя> (<размер>)» | +| `attachment` | `key`, `attachment` (номер вложения «#» или «», иначе — точное имя файла), `wait_seconds` (по умолчанию 120, не больше 600) | скачивает вложение и отдаёт путь к локальной копии (см. «Вложения в MCP») | +| `notes_set` | `key`, подмножество полей заметок | обновлённые заметки | +| `batch_create` | `title`, `commands` (список объектов по контракту `commands`; у `ensure_attachment` вместо `file` можно `path`) | `id`, `estimate_seconds`, число команд; 422 — текст ошибки валидации | +| `batch_status` | `id` | статусы команд, ошибки, оценка остатка | +| `batch_wait` | `id`, `timeout_minutes` (≤ 60) | как `batch_status`, возвращается по завершении или по таймауту | +| `batches` | `limit` | последние пакеты со статусом | +| `command_retry` | `id` | новый пакет | +| `batch_cancel` | `id` | вид пакета; для команд пакета, уже взятых агентом, — строка «команда #N уже у агента и будет выполнена; остановить — `POST /commands/N/cancel` с `force: true`» | +| `refresh` | `keys` или `full`, `wait` (bool) | без `wait` — строка с id поставленного пакета; с `wait` — итог пакета, как у `batch_wait` | +| `agent_status` | — | онлайн/офлайн, версия, текущая команда, довыданные команды (id, тип, число выдач), 429 за сутки, версия настроек; публикация агента («публикация: v от <дата>» или «публикации нет»), состояние обновления («обновление: v → v»), запускатель («запускатель: v», «без запускателя» или, пока `agent_reported = false`, «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска хаба)»), `update_hint`, если он есть | +| `sync_log` | `limit` | последние `sync_run` | +| `settings_get`, `settings_set` | ключ, значение | текущее значение / новая `settings_version` | + +Разрез по проекту и поведение без привязки — требование «Привязка сессии к проекту». + +Описание инструмента `batch_create` SHALL перечислять параметры каждого типа команды (обязательные, необязательные в +`[ ]`, формат значения) и собираться из описания параметров в коде хаба (см. `commands`), а не вручную. + +`ensure_attachment` в `batch_create` SHALL принимать вместо `file` параметр `path` — абсолютный путь к локальному файлу на +машине MCP-сервера. До постановки пакета MCP SHALL прочитать файл, загрузить его `POST /files` и заменить `path` на +`file`; `filename` по умолчанию — имя файла из `path`. Нет файла, нет прав, не файл, больше 10 МБ — ответ инструмента +с причиной и путём, пакет не ставится. Передан и `path`, и `file` — ответ «у ensure_attachment либо path, либо file». + +Описание инструмента `batch_create` SHALL сообщать, что метку проекта сессии хаб дописывает в `ensure_issue` сам, метки +других проектов запрещены, а команды над задачами вне проекта отклоняются; `board_id` для `ensure_sprint` выставляет хаб. + +Ответ `project_create` SHALL содержать фрагмент `.mcp.json` +`{"mcpServers": {"sdlc-jira": {"command": "uv", "args": ["run", "--project", "<корень хаба>", "sdlc-hub", "mcp", "jira", +"--project", "<метка>"]}}}`, где корень хаба — корень репозитория, из которого загружен пакет текущего процесса MCP +(вычисляется от расположения пакета, без зашитого пути), и указание: положить файл в корень репозитория проекта (слить +с имеющимся `.mcp.json`) и переподключить MCP (`/mcp` → reconnect, одобрить сервер проекта). + +Автор команд из MCP SHALL быть `claude`; из API без автора — `api`. + +#### Scenario: Пакет из чата +- **WHEN** `batch_create {title: "1538 в спринт 2", commands: [ensure_fields{…}, ensure_link{…}, ensure_sprint{…}]}` +- **THEN** пакет создан с четырьмя командами (три + `refresh`), ответ содержит оценку в минутах + +#### Scenario: Проект из чата +- **WHEN** сессия без привязки, `project_create {label: "STOCKS_DRRK", name: "Запасы ДРРК", board_id: 5120}` +- **THEN** проект создан, ответ содержит id пакета обновления и фрагмент `.mcp.json` с `"--project", "STOCKS_DRRK"` + +#### Scenario: Ошибка с файлом из чата +- **WHEN** `batch_create {title: "Ошибка ОСВ 60", commands: [ensure_issue{temp_key: new:1, issue_type: bug, …}, + ensure_attachment{key: new:1, path: "/Users/…/ОСВ сч. 60 за 2 кв. 2026.xlsx"}]}` +- **THEN** файл загружен в хаб; пакет создан с `ensure_attachment {key: new:1, file: , filename: "ОСВ сч. 60 за 2 кв. + 2026.xlsx"}` и хвостовым `refresh` + +#### Scenario: Файла нет +- **WHEN** тот же вызов, файла по `path` нет +- **THEN** ответ «файл не найден: », пакет не создан, в хаб ничего не загружено + +#### Scenario: Ожидание результата +- **WHEN** `batch_wait {id, timeout_minutes: 10}` и пакет завершился через 6 минут +- **THEN** ответ через 6 минут со статусами всех команд + +#### Scenario: Статус агента с довыдачей +- **WHEN** агент на связи, команда 40 выдана дважды и ещё без отчёта +- **THEN** ответ `agent_status` содержит «агент онлайн» и строку о команде 40 с числом выдач 2 + +#### Scenario: Отмена пакета с командой у агента +- **WHEN** `batch_cancel {id: 9}`, команда 30 пакета в `claimed` +- **THEN** ответ содержит вид пакета и строку «команда #30 уже у агента и будет выполнена; остановить — …» + +#### Scenario: Статус агента после отката +- **WHEN** опубликована v7, агент v6 под запускателем v1 прислал `update_state = rolled_back` для v7 +- **THEN** ответ `agent_status` содержит «публикация: v7 от …», «обновление: rolled_back v6 → v7», «запускатель: v1» и + «откат с v7 на v6: …» + +#### Scenario: Статус агента сразу после перезапуска хаба +- **WHEN** хаб перезапущен, агент ещё не прислал `claim` +- **THEN** ответ `agent_status` содержит «запускатель: неизвестно (агент ещё не выходил на связь после перезапуска + хаба)» и не содержит «без запускателя» и «агент запущен без run.py» + +#### Scenario: Задача с семью комментариями +- **WHEN** `issue {key: "AURORA-9001"}`, у задачи 7 комментариев, у самого нового текст 500 символов +- **THEN** ответ содержит «комментарии (7):», 5 последних комментариев с номерами «#» (самый новый — первым, обрезан до 300 символов с «…») и «ещё 2» + +#### Scenario: Комментарий целиком +- **WHEN** у задачи `AURORA-9001` комментарий `#101` длиной 800 символов с переносами строк, вызван `comment {key: "AURORA-9001", comment_id: "101"}` +- **THEN** ответ — «AURORA-9001 #101 <автор>, создан » и полный текст комментария без обрезки, переносы строк сохранены + +#### Scenario: Нет такого комментария +- **WHEN** `comment {key: "AURORA-9001", comment_id: "999"}`, у задачи нет комментария 999 +- **THEN** ответ «у AURORA-9001 нет комментария #999» + +### Requirement: Привязка сессии к проекту +`sdlc-hub mcp <модуль> --project <метка>` SHALL привязывать сервер MCP к проекту. Привязка SHALL проверяться при каждом вызове +инструмента по `GET /projects`: метки нет — ответ «проект <метка> не заведён в хабе: project_create или исправьте +--project в .mcp.json» (кроме `projects`, `project_create`). + +В привязанной к проекту P сессии: +- `issues` — `GET /issues?project=P` с остальными фильтрами; +- `issue`, `comment` — любая задача по ключу; +- `notes_set` — только своя задача P (метка P; соседи — нет) или временный ключ `new:` (его задача создаётся пакетом + проекта), иначе ответ « не в проекте P»; +- `batch_create`, `command_retry` — с `project = P` (проверки пакета проекта, см. `commands`; повтор команды другого + проекта или пакета без проекта — отказ хаба); +- `batches` — `GET /batches?project=P`; `batch_status`, `batch_wait`, `batch_cancel` — по id, без ограничения; +- `refresh` — `full` — общий полный прогон всех проектов, `keys` — как раньше. + +Без привязки инструменты `issues`, `issue`, `comment`, `notes_set`, `batch_create`, `command_retry`, `refresh` SHALL отвечать +«сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)» и в хаб не ходить; `projects`, +`project_create`, `agent_status`, `sync_log`, `settings_get`, `settings_set`, `batches`, `batch_status`, `batch_wait`, +`batch_cancel` работают. + +#### Scenario: Сессия без проекта +- **WHEN** MCP запущен без `--project`, вызван `issues` +- **THEN** ответ «сессия не привязана к проекту: …», запросов к `GET /issues` нет + +#### Scenario: Задачи своего проекта +- **WHEN** MCP запущен с `--project STOCKS_DRRK`, вызван `issues` +- **THEN** в таблице только свои задачи `STOCKS_DRRK` и их соседи + +#### Scenario: Создание задачи без метки +- **WHEN** сессия `STOCKS_DRRK`, `batch_create {commands: [ensure_issue {temp_key: new:1, summary: "…"}]}` без `labels` +- **THEN** пакет поставлен с `project = STOCKS_DRRK`, у `ensure_issue` `labels = ["STOCKS_DRRK"]` + +#### Scenario: Опечатка в проекте +- **WHEN** MCP запущен с `--project STOKS`, такого проекта нет; вызван `issues` +- **THEN** ответ «проект STOKS не заведён в хабе: …» + +### Requirement: Вложения в MCP +`issue` SHALL после комментариев выводить «вложения (N):» и по строке на каждое вложение (новые сверху): «# <имя> +(<размер>) — <автор>, », дальше через запятую: «в описании», если на вложение ссылается описание, «в #» +для каждого ссылающегося комментария, «скачано», если `state = ready`, «больше 10 МБ — только в Jira» для таких файлов; +без вложений — «вложений нет». Размер — в байтах, КБ или МБ с одним знаком после запятой («512 Б», «234,4 КБ», «2,3 МБ»); +неизвестный — «размер неизвестен». + +`attachment` SHALL: +1. найти вложение по `GET /issues/{key}`: по номеру, иначе по точному имени (несколько с одним именем — самое новое, в + ответ строка «у N вложений «<имя>», взято самое новое #»); не найдено — «у нет вложения «<значение>»; + список — issue », в хаб больше не ходить; +2. `POST /attachments/{id}/fetch` с автором `claude`; 413 — «файл больше 10 МБ (<размер>) — скачайте в Jira: + задача , вложение «<имя>»»; 409 и прочие ошибки хаба — их текст; +3. пока `state = fetching` — опрашивать `GET /attachments/{id}` раз в 2 с, не дольше `wait_seconds`; не дождался — + «скачивание ещё идёт (команда #, агент <онлайн/офлайн>); вызовите attachment ещё раз — вторая команда не + поставится»; `failed` — «не удалось скачать: »; +4. при `ready` — `GET /attachments/{id}/content` и записать файл в `<каталог данных хаба>/downloads//<имя>`, где имя — + `filename` с заменой `/`, `\`, `:` и управляющих символов на `_` (пустое — `attachment-`); каталог с правами + `0700`; запись через временный файл и переименование; если там уже лежит файл того же размера — не перезаписывать; +5. ответить «<абсолютный путь>» отдельной строкой и под ним «<имя> (<размер>), , задача ». + +#### Scenario: Скриншот из комментария +- **WHEN** `comment {key: "AURORA-1650", comment_id: "101"}` показал «вложения: #31337 скрин 1.png (234,4 КБ)»; вызван + `attachment {key: "AURORA-1650", attachment: "#31337"}`, агент онлайн, файла у хаба нет +- **THEN** поставлена `fetch_attachment`; через несколько секунд ответ — путь + `…/sdlc-hub/downloads/31337/скрин 1.png` и строка «скрин 1.png (234,4 КБ), image/png, задача AURORA-1650»; файл по пути + читается + +#### Scenario: Повторный вызов +- **WHEN** тот же вызов второй раз, файл у хаба (`ready`) и в `downloads/31337/` +- **THEN** ответ сразу, команд в очереди не прибавилось + +#### Scenario: Агент не успел +- **WHEN** `attachment {…, wait_seconds: 30}`, агент занят долгим `refresh` +- **THEN** через 30 с ответ «скачивание ещё идёт (команда #N, агент онлайн); вызовите attachment ещё раз — вторая + команда не поставится» + +#### Scenario: По имени +- **WHEN** `attachment {key: "AURORA-1650", attachment: "ОСВ 60.xlsx"}`, вложение с таким именем одно +- **THEN** скачивается оно; путь оканчивается на `/ОСВ 60.xlsx` + +#### Scenario: Вложения в карточке +- **WHEN** `issue {key: "AURORA-1650"}`, у задачи вложения #31337 «скрин 1.png» (на него ссылается комментарий 101, + скачано) и #31338 «запись.mp4» на 25 МБ +- **THEN** в ответе «вложения (2):», строка #31338 с «больше 10 МБ — только в Jira», строка #31337 с «в #101, скачано»; у + комментария 101 строка «файлы: #31337 скрин 1.png» + +### Requirement: MCP вики +`sdlc-hub mcp wiki [--project <метка>]` SHALL запускать по stdio MCP-сервер модуля `wiki` с именем `sdlc-wiki`, +ходящий в HTTP API хаба клиентским токеном; хаб недоступен — каждый инструмент отвечает «хаб не запущен: sdlc-hub serve». +Привязка к проекту проверяется так же, как у сервера jira («Привязка сессии к проекту»). Инструменты: + +| Инструмент | Параметры | Ответ | +|---|---|---| +| `projects` | — | таблица: метка, название, корень вики, страниц, последняя синхронизация; текущий проект отмечен | +| `project_create` | `label`, `name`, `wiki_root` (необязательно) | `POST /projects` с автором `claude`; проект и фрагмент `.mcp.json` с записью `sdlc-wiki` | +| `root_set` | `root` | `PATCH /projects/<текущий> {wiki_root}`; новый корень и id пакета синхронизации | +| `tree` | `code` (необязательно), `depth` (по умолчанию 2) | дерево от корня или от `code`: отступ, код, название, размер | +| `page` | `code` или `title`, `offset` (по умолчанию 0) | шапка (код, путь в дереве, адрес в Eva, дата синхронизации, вложения с `id`) и Markdown с `offset` до 20 000 символов, в конце — «продолжение: offset=», если текст длиннее; `title` ищется точно, иначе — до 10 кандидатов с кодами | +| `search` | `query`, `limit` (по умолчанию 10) | страницы: код, название, путь, фрагмент | +| `attachment` | `id` | ставит скачивание, ждёт до 60 с; готово — путь к копии `downloads/wiki-/<имя>`; иначе состояние и подсказка повторить | +| `sync` | `wait` (по умолчанию `false`) | ставит синхронизацию (или сообщает о текущей) и id пакета; с `wait` — ждёт до 300 с и отдаёт итог | +| `status` | — | корень, последняя синхронизация, ошибка, пакет в работе, агент `wiki`: онлайн/офлайн, версия, публикация, обновление, запускатель (как `agent_status` сервера jira) | + +Инструменты, кроме `projects` и `project_create`, SHALL требовать привязки к проекту. В привязанной сессии без корня +(кроме `root_set`, `status`) ответ SHALL быть «у проекта <метка> нет корня вики: root_set DOC-…»; с корнем, но без +синхронизаций — «вики проекта ещё не синхронизирована: sync». Набор инструментов и их описания у сервера `sdlc-jira` +(модуль jira) SHALL остаться прежним. + +#### Scenario: Поиск из сессии awp-ca +- **WHEN** сервер запущен с `--project AWPCA`, копия синхронизирована, вызван `search {query: "статус согласован"}` +- **THEN** ответ — страницы с кодами и фрагментами; запрос в хаб — `GET /wiki/search?project=AWPCA&q=…` + +#### Scenario: Длинная страница +- **WHEN** `page {code: DOC-3}`, Markdown 45 000 символов +- **THEN** первые 20 000 символов и «продолжение: offset=20000»; `page {code: DOC-3, offset: 40000}` — остаток без строки + продолжения + +#### Scenario: Корня нет +- **WHEN** сессия `sfm_kedr` без корня, вызван `tree` +- **THEN** ответ «у проекта sfm_kedr нет корня вики: root_set DOC-…», запроса `GET /wiki/tree` нет + +#### Scenario: Сервер jira не изменился +- **WHEN** запущен `sdlc-hub mcp jira --project AWPCA` +- **THEN** имя сервера `sdlc-jira`, список инструментов и описания совпадают с эталоном до change diff --git a/openspec/changes/sdlc-rename/specs/migration/spec.md b/openspec/changes/sdlc-rename/specs/migration/spec.md new file mode 100644 index 0000000..7eaf7a7 --- /dev/null +++ b/openspec/changes/sdlc-rename/specs/migration/spec.md @@ -0,0 +1,39 @@ +## MODIFIED Requirements + +### Requirement: Инициализация настроек +`sdlc-hub init --config ` SHALL перенести в `setting`: `project_key`, типы задач, +`epic_name_field_id`, `fields`, `epic_link`, `priority_map`, `labels`, `status_labels`, `kind_labels`, `comment_marker`, +`pacing` (в новую форму по типам команд), `transitions.map`, `link_types`; сгенерировать два токена; завести проект +`AWPCA` «АРМ КА» с `board_id` из `sprints.board_id` конфига (если проекта `AWPCA` ещё нет). Поля `auth`, `base_url`, +`verify_ssl`, `ca_bundle` MUST NOT переноситься (они у агента). + +#### Scenario: Повторный init +- **WHEN** `init` вызван на уже инициализированной базе +- **THEN** настройки не перезаписываются без `--force`, токены сохраняются; проект `AWPCA` не дублируется + +#### Scenario: Первый init +- **WHEN** `init --config` на пустой базе, в конфиге `sprints.board_id = 4216` +- **THEN** есть проект `AWPCA` «АРМ КА» с `board_id = 4216`; в `setting` нет `base_jql` и `board_id` + +### Requirement: Импорт CSV-пакета +`sdlc-hub import <папка admin/jira-sync>` SHALL прочитать `tasks.csv`, `new_tasks.csv`, `epics.csv` и заполнить +`issue_note` по ключу Jira: статус по факту, вид работы, что сделано, что осталось, внутренние колонки. Строки +`new_tasks.csv` без ключа SHALL получать временный ключ `new:<номер строки>`; строки `tasks.csv` и `epics.csv` без ключа +SHALL пропускаться. `links.csv` SHALL импортироваться как сверочный набор в отчёт импорта (ожидаемые связи против зеркала +после первого `refresh`), не как команды. Связь, которой в зеркале нет как `source→target` того же типа, но есть как +`target→source` того же типа, SHALL попадать в `reversed_links`, а не в `missing_links`. Импорт идемпотентен: повтор +обновляет заметки, не дублирует. + +#### Scenario: Импорт и первая синхронизация +- **WHEN** выполнены `init`, `import`, запущены хаб и агент, `refresh full` завершён +- **THEN** число задач зеркала ≥ числа строк `tasks.csv` + созданных из `new_tasks.csv`; отчёт импорта перечисляет ключи + из CSV, которых нет в зеркале, связи из `links.csv`, которых нет в `issue_link`, и связи, которые есть в обратную сторону + +#### Scenario: Связь в обратную сторону +- **WHEN** в `links.csv` строка «AURORA-1445 → AURORA-1446, связанные», а в зеркале есть только «ASUPR_Связанные» + 1446→1445 +- **THEN** связь в `reversed_links`, в `missing_links` её нет + +#### Scenario: Эпик без ключа +- **WHEN** в `epics.csv` строка без значения в колонке «Ключ» +- **THEN** строка пропущена, заметка `new:*` для неё не создаётся diff --git a/openspec/changes/sdlc-rename/specs/web-app/spec.md b/openspec/changes/sdlc-rename/specs/web-app/spec.md new file mode 100644 index 0000000..65e6ca3 --- /dev/null +++ b/openspec/changes/sdlc-rename/specs/web-app/spec.md @@ -0,0 +1,169 @@ +## MODIFIED Requirements + +### Requirement: Вход по токену +Приложение SHALL работать с API хаба того же хоста под клиентским токеном: хранить его в `localStorage` под ключом +`sdlc-hub-token` (и в памяти, если хранилище закрыто) и передавать в заголовке `Authorization: Bearer` каждого запроса. +Если токена нет или любой запрос ответил 401, приложение SHALL показывать экран входа вместо страниц: поле токена с +подсказкой `uv run sdlc-hub tokens`, без панелей страниц и без фоновых запросов, которые могли бы изменить поле ввода. +Введённый токен SHALL проверяться запросом `GET /settings` и сохраняться только при успехе. Действие «Сменить токен» +SHALL очищать токен и показывать экран входа. Ответ 401 SHALL выводить на экран входа, только если запрос ушёл с текущим токеном: запоздавший 401 на +запрос с прежним токеном после входа с новым не учитывается. Если проверка токена получила ответ 4xx, кроме 401, +экран входа SHALL показывать текст ошибки хаба, а не «хаб не отвечает». + +Ключи `localStorage` приложения с префиксом `sdlc-hub-` (`sdlc-hub-token`, `sdlc-hub-sidebar`, `sdlc-hub-legend`) SHALL +при первом чтении, если ключа нет, брать значение одноимённого ключа с префиксом `jira-hub-`, записывать его под новым +ключом и удалять прежний. + +#### Scenario: Первый вход +- **WHEN** приложение открыто впервые, токена нет +- **THEN** показан экран входа; после ввода верного токена открывается запрошенная страница, токен сохранён + +#### Scenario: Устаревший токен +- **WHEN** сохранённый токен не подходит и запрос ответил 401 +- **THEN** показан экран входа с пояснением, что токен не подошёл; панели доски и задач не видны + +#### Scenario: Токен от прежней доски +- **WHEN** в `localStorage` нет `sdlc-hub-token`, но есть `jira-hub-token` (сохранён до переименования хаба) +- **THEN** приложение входит с ним без запроса токена; значение перенесено в `sdlc-hub-token`, `jira-hub-token` удалён + +#### Scenario: Запоздавший 401 от прежнего токена +- **WHEN** запрос ушёл с прежним токеном, пользователь вошёл с новым, и после этого запрос ответил 401 +- **THEN** приложение остаётся на странице, новый токен сохранён + +#### Scenario: Хаб отверг проверку токена +- **WHEN** проверка `GET /settings` ответила 400 с `detail` +- **THEN** экран входа показывает текст `detail`, а не «хаб не отвечает» + +### Requirement: Оболочка и адреса +Приложение SHALL открываться под префиксом `/app/` и иметь страницы `/app/board` (доска связей), `/app/issues` (список +задач), `/app/feed` (лента изменений, требование «Лента изменений») и `/app/issues/{key}` (карточка задачи — окно поверх страницы, требование «Карточка задачи»); `/app` и `/app/` SHALL вести на `/app/board`, неизвестный путь — +на страницу «не найдено» со ссылками на разделы. Боковая панель SHALL быть закреплена у левого края окна на всю его +высоту и MUST NOT прокручиваться вместе со страницей; страница занимает остальную ширину. Сверху вниз панель SHALL +содержать: название «SDLC Hub» и кнопку «Свернуть панель»; выпадающий список проектов («<метка> — <название>», текущий +отмечен, в конце «+ Новый проект…»; требование «Проект в приложении»); разделы «Доска связей», «Задачи» и «Лента» — вертикальным +списком ссылок со значками (текущий отмечен `aria-current="page"`); внизу — блок данных Jira с действием «Обновить из +Jira» (требование «Обновление из Jira»), переключатель темы (требование «Тема») и меню с действием «Сменить токен». +Свёрнутая панель SHALL быть узкой полосой с теми же действиями значками: «Развернуть панель», метка текущего проекта, +разделы, «Обновить из Jira» с точкой состояния агента, тема, меню; названия — в подсказках и `aria-label`. Свёртка +SHALL храниться в `localStorage` под ключом `sdlc-hub-sidebar` (`collapsed`; без записи — развёрнута). На экранах уже +640 px панели в потоке нет: сверху SHALL быть закреплена строка с кнопкой «Меню», названием и кнопкой «Обновить из +Jira»; «Меню» открывает развёрнутую панель поверх страницы, панель закрывается выбором раздела, клавишей Escape или +щелчком мимо неё. Горизонтальной прокрутки страницы на любой ширине нет. +Неожиданная ошибка отрисовки страницы SHALL показываться сообщением с кнопкой «Перезагрузить», не пустым экраном. + +#### Scenario: Переход между разделами +- **WHEN** на доске в боковой панели выбран раздел «Задачи» +- **THEN** адрес — `/app/issues` с текущим проектом, показан список задач; «назад» возвращает на доску + +#### Scenario: Раздел «Лента» +- **WHEN** на доске в боковой панели выбран раздел «Лента» +- **THEN** адрес — `/app/feed` с текущим проектом, показана лента изменений за сегодня; раздел отмечен в панели + +#### Scenario: Сменить токен +- **WHEN** в меню боковой панели выбрано «Сменить токен» +- **THEN** токен очищен, показан экран входа + +#### Scenario: Панель на месте при прокрутке +- **WHEN** доска прокручена до самого низа +- **THEN** боковая панель видна целиком на прежнем месте: разделы и «Обновить из Jira» доступны без прокрутки вверх + +#### Scenario: Свёрнутая панель +- **WHEN** нажата «Свернуть панель», страница перезагружена +- **THEN** панель свёрнута в полосу значков, доска шире; значок «Задачи» открывает список задач; «Развернуть панель» + возвращает полную панель + +#### Scenario: Узкий экран +- **WHEN** ширина окна 390 px, нажата «Меню», в панели выбран раздел «Задачи» +- **THEN** панель открылась поверх страницы, после выбора закрыта, показан список задач; горизонтальной прокрутки нет + +### Requirement: Доска связей +Страница `/app/board` SHALL воспроизводить поведение доски связей хаба (`links.html` ревизии 3931a88) без изменений, +кроме перечисленных в этом требовании и в требовании «Источник данных доски»: построение модели из `GET /board`, CSV +фильтра Jira и JSON доски (`arm-ka-links/1`); дорожки по эпикам со свёрткой и перестановкой, служебные дорожки внизу; +карточки с перетаскиванием, растяжкой, размером по содержимому и привязкой к сетке; стрелки связей с выравниванием; +фильтры по спринтам и исполнителям; легенда со скрытием видов работ и типов связей; «соседи по связям», «только +передачи», «скрыть без связей», «критический путь», «по датам» с колонками дат и их растяжкой; ящик «Проблемы»; таблица +связей; экспорт и импорт JSON; «Сбросить раскладку». Раскладка, дорожки и фильтры SHALL храниться в +`localStorage` под теми же ключами `graph-<имя>…`, что у `links.html`, с той же миграцией старых ключей. +Результат построения модели, видимой выборки, критического пути и проблем SHALL совпадать с результатом `links.html` +на одних и тех же данных и фильтрах. + +Шапка доски (закреплена при прокрутке) SHALL содержать заголовок «Доска связей», счётчики (задачи из всех, соседи по +связям, связи из всех, скрытые без связей), пометку источника, если на доске снимок из файла (с действием вернуться к +данным хаба), событие доски, кнопку «Проблемы» со счётчиком и меню с действиями «Перечитать», «Загрузить файл…», +«Экспорт JSON», «Таблица связей», «Привязка к сетке» и «Сбросить раскладку» (с подтверждением). Под шапкой — панель +управления: мультиселекты «Спринты» и «Исполнители», переключатели группы «Показать» («Соседи по связям», «Только +передачи», «Скрыть без связей») и группы «Режим» («Критический путь», «По датам»), каждый с `aria-pressed` и прежней +подсказкой. Под панелью — легенда, которая сворачивается кнопкой «Легенда»; свёрнутая показывает число скрытых видов +работ и типов связей; состояние свёртки хранится в `localStorage` под ключом `sdlc-hub-legend`. «Обновить из Jira», +тема и «Сменить токен» — в боковой панели приложения, на доске их нет. + +У карточки задачи (не у заглушки) SHALL быть значок «Открыть карточку», +открывающий `/app/issues/{key}` окном поверх доски; ключ карточки по-прежнему открывает задачу в Jira. Пока окно открыто +и после его закрытия доска SHALL сохранять прокрутку, раскладку, фильтры и строку состояния. Ещё два отличия от `links.html` (R-10, R-18): двойной щелчок по уголку +карточки меряет высоту по содержимому при новой ширине, а не при прежней; указатель при перетаскивании и растяжке +захватывается на нажатом элементе, а не на карточке, поэтому двойной щелчок по уголку сбрасывает размер. Загрузка файла CSV/JSON доступна только после входа, с экрана входа её нет. +Карточка задачи, статус Jira которой входит в `closed_statuses`, SHALL быть приглушена (полупрозрачна), под +указателем и с фокусом клавиатуры внутри карточки — в полную яркость; стрелка связи, у которой хотя бы один конец — такая задача, SHALL быть приглушена так же. +Приглушение — только вид: модель, видимая выборка, критический путь, проблемы, раскладка и экспорт JSON от него не +зависят; переключателя нет; правило одно для данных хаба, CSV и JSON. +При возврате на `/app/board` с другой страницы приложения в той же вкладке доска SHALL восстанавливать прежнюю +прокрутку; после перезагрузки страницы доска открывается сверху. + +Дорожки SHALL разделяться линией; фон развёрнутых дорожек один, без чередования, чтобы подсветка колонок «по датам» +(прошедшие даты, сегодня) была видна на всех дорожках; свёрнутая дорожка выделяется фоном. У карточки задачи, статус +Jira которой не входит в `closed_statuses`, с плановой датой окончания раньше сегодняшней дата «до …» SHALL быть цветом +ошибки, с подсказкой и текстом для экранного чтения «просрочено»; это только вид, модель и раскладка от него не зависят. + +#### Scenario: Раскладка переживает переход +- **WHEN** раскладка карточек сохранена страницей `links.html`, открыта `/app/board` с теми же данными +- **THEN** карточки стоят на тех же местах, дорожки в том же порядке и свёртке, фильтры те же + +#### Scenario: Совпадение со старой доской +- **WHEN** на одной фикстуре `GET /board` и одном наборе фильтров построены модель, видимая выборка, критический путь + и проблемы старой страницей и приложением +- **THEN** результаты совпадают + +#### Scenario: Открыть карточку +- **WHEN** на карточке AURORA-1620 нажат значок «Открыть карточку» +- **THEN** адрес `/app/issues/AURORA-1620`, карточка задачи открыта окном, под ним доска + +#### Scenario: Возврат с карточки +- **WHEN** доска прокручена вниз, нажат значок «Открыть карточку», затем окно закрыто +- **THEN** адрес `/app/board`, доска показана с той же прокруткой и раскладкой, без повторной загрузки `GET /board` + +#### Scenario: Возврат из другого раздела +- **WHEN** доска прокручена вниз, выбран раздел «Задачи», затем раздел «Доска связей» +- **THEN** доска показана с той же прокруткой + +#### Scenario: Закрытые на доске приглушены +- **WHEN** на доске AURORA-1620 «В работе», AURORA-1621 «Готово», AURORA-1622 «Снято», связь AURORA-1620 → AURORA-1621 +- **THEN** карточки AURORA-1621 и AURORA-1622 и стрелка AURORA-1620 → AURORA-1621 приглушены, AURORA-1620 — нет; + под указателем AURORA-1621 — в полную яркость; критический путь и ящик «Проблемы» те же, что без приглушения + +#### Scenario: Свёрнутая легенда +- **WHEN** в легенде скрыт вид работы «тестирование», легенда свёрнута, страница перезагружена +- **THEN** легенда свёрнута, рядом с кнопкой «Легенда» — «скрыто: 1»; карточек вида «тестирование» на доске нет + +#### Scenario: Просрочка на доске +- **WHEN** сегодня 22.10, у AURORA-1620 статус «В работе» и срок 20.10, у AURORA-1621 «Готово» и срок 20.10, включено + «По датам» +- **THEN** дата AURORA-1620 — цветом ошибки с пометкой «просрочено», у AURORA-1621 — обычная; подсветка колонки 20.10 + видна на всех дорожках, дорожки одного фона + +### Requirement: Сборка и разработка +Приложение SHALL быть npm-проектом в `web/` (React, TypeScript, Vite) с точными версиями зависимостей и +`package-lock.json`. `make web` SHALL устанавливать зависимости (`npm ci`) и собирать приложение в +`src/sdlc_hub/web/app/`; каталог сборки не хранится в git. Шрифты (Mulish — текст интерфейса, IBM Plex Mono — ключи +задач и исходники), иконки и прочие ресурсы страницы SHALL входить в сборку: приложение не обращается к внешним +адресам. `make check` SHALL запускать `uv run pytest`, проверку типов и линтер фронта и тесты фронта; зелёным считается +только весь набор. `npm run dev` SHALL поднимать сервер разработки с прокси маршрутов API на хаб из `SDLC_HUB_URL` (по +умолчанию тестовый `http://127.0.0.1:8791`). + +#### Scenario: Сборка с нуля +- **WHEN** в чистом checkout выполнен `make web` +- **THEN** в `src/sdlc_hub/web/app/` есть `index.html` и `assets/`; `git status` не показывает файлов сборки + +#### Scenario: Без интернета +- **WHEN** приложение открыто на машине без доступа в интернет +- **THEN** страница не запрашивает ресурсов вне хаба, текст набран Mulish, ключи задач — IBM Plex Mono diff --git a/openspec/changes/sdlc-rename/specs/wiki/spec.md b/openspec/changes/sdlc-rename/specs/wiki/spec.md new file mode 100644 index 0000000..e3bcc27 --- /dev/null +++ b/openspec/changes/sdlc-rename/specs/wiki/spec.md @@ -0,0 +1,33 @@ +## MODIFIED Requirements + +### Requirement: Markdown-копия вики +Копия проекта SHALL лежать в `/wiki/<метка>/` (каталоги 0700): у каждой страницы — каталог +`NN_<название>` внутри каталога родителя (`NN` — двузначная позиция среди соседей по `orderno`, затем по коду; у корня +номера нет; название без символов `<>:"/\|?*` и управляющих, обрезано до 80 символов; совпадение у соседей — +суффикс `_2`, `_3`), в нём `index.md`: frontmatter `code`, `title`, `url`, `synced_at`, строка `# ` и текст. В +корне копии — `_tree.json`, список `{code, title, depth, path}` в порядке обхода. Копия SHALL собираться рядом +(`wiki/.<метка>.new/`) и подменять прежнюю переименованием; незавершённую подмену хаб SHALL доводить при старте. + +HTML страницы SHALL переводиться в Markdown (заголовки `#`); ссылка или картинка на файл Eva (по списку `attachments` агента: адрес на хосте Eva +с путём `/files/…`, относительный адрес — тоже Eva; миниатюра `<адрес>.meta/thumbnail.jpg` означает файл `<адрес>`, а +ссылки на `static/…` — значки интерфейса — из текста удаляются) SHALL заменяться на +`[📎 <имя>](attachment:<id>)`, где `id` — строка вложения; ссылка на документ Eva с кодом `DOC-<цифры>` (в Eva — относительный +`project/Document/DOC-…`) — на `[<текст>](page:<код>)`; прочие ссылки остаются. Сырой HTML в копию не попадает. + +#### Scenario: Раскладка +- **WHEN** корень DOC-1 «АРМ КА», дети DOC-3 «Статусы» (`orderno` 1) и DOC-2 «Роли/права» (`orderno` 2) +- **THEN** `wiki/AWPCA/АРМ КА/index.md`, `…/01_Статусы/index.md`, `…/02_Роли_права/index.md`; `_tree.json` в порядке + DOC-1, DOC-3, DOC-2 + +#### Scenario: Картинка на странице +- **WHEN** в HTML DOC-3 `<img src="/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A<uuid>/scheme.png?version=1">` +- **THEN** в `index.md` — `[📎 scheme.png](attachment:<id>)`, у вложения `<id>` `state = none`, файл не скачан + +#### Scenario: Прикреплённый файл с миниатюрой +- **WHEN** на странице миниатюра `…/<uuid>/model.xlsx.meta/thumbnail.jpg?version=1`, значок `static/attach-file.png` и + ссылка на `…/<uuid>/model.xlsx` +- **THEN** одно вложение `model.xlsx` с адресом файла; в тексте одна ссылка `[📎 model.xlsx](attachment:<id>)`, значка нет + +#### Scenario: Ссылка на другую страницу +- **WHEN** в HTML `<a href="project/Document/DOC-019834">Модель данных</a>` +- **THEN** в `index.md` — `[Модель данных](page:DOC-019834)` -- 2.47.2 From 10d2eaa51394ed97ed0bad43d266daeba71cc903 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev <andrey.epifancev@ssi-solutions.ru> Date: Sat, 24 Oct 2026 11:21:09 +0400 Subject: [PATCH 02/21] =?UTF-8?q?plan(sdlc-rename):=20=D0=BF=D0=BB=D0=B0?= =?UTF-8?q?=D0=BD=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B8=D0=BC=D0=B5=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- .../plans/2026-10-24-sdlc-rename.md | 215 ++++++++++++++++++ openspec/changes/sdlc-rename/design.md | 7 +- .../changes/sdlc-rename/specs/hub-api/spec.md | 5 +- 3 files changed, 223 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-24-sdlc-rename.md diff --git a/docs/superpowers/plans/2026-10-24-sdlc-rename.md b/docs/superpowers/plans/2026-10-24-sdlc-rename.md new file mode 100644 index 0000000..b92c211 --- /dev/null +++ b/docs/superpowers/plans/2026-10-24-sdlc-rename.md @@ -0,0 +1,215 @@ +# Переименование jira-hub → sdlc-hub — план реализации + +> **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:** одно имя `sdlc-hub` в пакете, CLI, каталоге данных, MCP, вебе и документации хаба плюс скрипт, которым владелец переключает живую установку. + +**Architecture:** механическое переименование пакета `src/jira_hub` → `src/sdlc_hub` и всех строк; MCP-серверы `sdlc-<модуль>` с обязательным модулем; защита от «код новый — каталог данных старый» в `core/paths.py` + `core/cli.py`; разовый перенос ключей `localStorage`; одноразовый скрипт `scripts/cutover.py` (stdlib, вне пакета) с обёрткой `scripts/cutover.sh`. + +**Tech Stack:** Python 3.13 (uv, pytest), React 19 + TS + Vite (Vitest, Playwright), bash. + +**Spec:** `openspec/changes/sdlc-rename/` (proposal.md, design.md, specs/) — issue jira-hub#97. + +## Global Constraints + +- Имя: пакет `sdlc_hub`, дистрибутив и CLI `sdlc-hub`, без псевдонима `jira-hub` (design, решение 2). +- Каталог данных `~/.local/share/sdlc-hub/`, переменная `SDLC_HUB_HOME`; прежнее имя `~/.local/share/jira-hub/` встречается в коде только в проверке прежнего каталога (Task 3) и в скрипте переключения (Task 5). +- MCP-серверы `sdlc-jira`, `sdlc-wiki`; `sdlc-hub mcp <модуль>`, модуль обязателен. +- Бренд веба — «SDLC Hub»; ключи `localStorage` `sdlc-hub-token`, `sdlc-hub-sidebar`, `sdlc-hub-legend`. +- Не менять: `docs/superpowers/plans/` (кроме этого плана), `openspec/changes/archive/`, `openspec/specs/` (их меняет архив change), протокол агента, схему базы, HTTP-маршруты, токены, ключи доски `graph-*`. +- Граница ядра (`tests/test_registry.py`) остаётся зелёной; новые имена в ядре — без названий модулей. +- Зелёный — только весь набор: `uv run pytest -q` и `cd web && npm run lint && npm test`. +- Язык комментариев, сообщений и коммитов — русский; коммиты заканчиваются строкой `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>`. +- Живой хаб (`:8790`, `~/.local/share/jira-hub`), launchd, `~/.claude.json`, `~/projects/awp-ca`, `~/projects/stocks_drrk` в задачах НЕ трогать: тесты — только во временных каталогах. + +## Review Focus + +1. Старый `jira-hub` в `.mcp.json` проекта и новый код: `sdlc-hub mcp` без модуля должен падать с понятной подсказкой, а не поднимать сервер jira — Task 2, `test_mcp_module_required`. +2. Новый код при живом каталоге под старым именем: команда не должна создать пустой `~/.local/share/sdlc-hub/` и работать с пустой базой — Task 3, `test_legacy_home_refused` проверяет, что каталог не создан. +3. Браузер с токеном под `jira-hub-token`: вход без повторного ввода токена; закрытое хранилище (исключение в `localStorage`) не ломает чтение — Task 4, `legacyStorage.test.ts`. +4. Сухой прогон скрипта не меняет ничего, в том числе в проектах-потребителях — Task 5, `test_dry_run_changes_nothing` сравнивает снимок дерева. +5. Повторный запуск скрипта после частичного переключения (данные уже перенесены): остановка на проверках шага 1 с понятным текстом, без второго переноса — Task 5, `test_apply_refuses_when_target_exists`. + +--- + +### Task 1: Пакет, CLI и каталог данных под новым именем + +Механическое переименование. Имена MCP-серверов (`FastMCP("jira-hub")`, `FastMCP("jira-hub-wiki")`, ключи в `mcp_json`, модуль MCP по умолчанию) в этой задаче НЕ меняются — это Task 2; строка команды `"jira-hub"` в `mcp_json` меняется на `"sdlc-hub"`. + +**Files:** +- Move: `src/jira_hub/` → `src/sdlc_hub/` (`git mv`) +- Modify: `pyproject.toml` (`name = "sdlc-hub"`, `[project.scripts] sdlc-hub = "sdlc_hub.cli:main"`, `packages = ["src/sdlc_hub"]`), `uv.lock` (`uv lock`), `.gitignore` (`src/sdlc_hub/web/app/`), `Makefile` (комментарий), `web/vite.config.ts` (`outDir: "../src/sdlc_hub/web/app"`), `web/scripts/test-hub.sh` (`SDLC_HUB_HOME`, `uv run sdlc-hub`, защита `"$HOME/.local/share/sdlc-hub"*`), `web/scripts/golden-from-links.ts` — НЕ менять (читает старый коммит по старому пути) +- Modify: все `import`/`from jira_hub` в `src/` и `tests/`; `core/paths.py` (`SDLC_HUB_HOME`, по умолчанию `~/.local/share/sdlc-hub`); тексты `jira-hub` → `sdlc-hub` в `src/sdlc_hub/__init__.py` (docstring: «sdlc-hub — хаб SDLC: ядро (проекты, очередь команд, агенты, HTTP API, веб) и модули jira и wiki»), `core/cli.py` (`prog="sdlc-hub"`, `description="SDLC-хаб: ядро и модули"`, docstring), `core/api.py` (подсказка `make web` «в каталоге sdlc-hub», строка запуска `sdlc-hub: http://…`), `core/client.py`, `core/mcp.py` (`OFFLINE = "хаб не запущен: sdlc-hub serve"`, команда в `mcp_json`), модули `jira` и `wiki` (тексты подсказок и docstring с `jira-hub tokens`/`jira-hub serve`) +- Modify tests: `JIRA_HUB_HOME` → `SDLC_HUB_HOME` (`test_agents.py`, `test_cli.py`, `test_mcp.py`, `test_wiki_mcp.py`, `conftest.py` при наличии), пути `src/jira_hub` → `src/sdlc_hub` (`test_layout.py`, `test_registry.py`, `test_web_app.py` — `("sdlc_hub", "web", "app")`, `test_wiki_projects.py`) +- Test: `tests/test_layout.py` + +**Interfaces:** +- Produces: пакет `sdlc_hub` (те же модули, что были в `jira_hub`), `sdlc_hub.core.paths.home() -> Path` читает `SDLC_HUB_HOME`, по умолчанию `Path.home() / ".local" / "share" / "sdlc-hub"`; CLI `sdlc-hub`. + +- [ ] **Step 1: Тест каталога по умолчанию** — в `tests/test_layout.py`: + +```python +def test_default_home(monkeypatch, tmp_path): + from sdlc_hub.core import paths + monkeypatch.delenv("SDLC_HUB_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + assert paths.home() == tmp_path / ".local" / "share" / "sdlc-hub" + + +def test_env_home(monkeypatch, tmp_path): + from sdlc_hub.core import paths + monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "x")) + assert paths.home() == tmp_path / "x" +``` + +- [ ] **Step 2:** `uv run pytest tests/test_layout.py -q` — FAIL (`ModuleNotFoundError: sdlc_hub`). +- [ ] **Step 3:** `git mv src/jira_hub src/sdlc_hub`; заменить `jira_hub` → `sdlc_hub` и `JIRA_HUB_HOME` → `SDLC_HUB_HOME` по `src/`, `tests/`, `web/vite.config.ts`, `.gitignore`, `Makefile`, `web/scripts/test-hub.sh`; правки `pyproject.toml`; `uv lock && uv sync`; тексты из списка Files. +- [ ] **Step 4:** `uv run pytest -q` — весь набор PASS; `uv run sdlc-hub --help` печатает `usage: sdlc-hub`; `uv run jira-hub --help` — команды нет. +- [ ] **Step 5:** проверка остатков: `git grep -n -i "jira_hub\|JIRA_HUB" -- src tests web pyproject.toml Makefile .gitignore ':!web/scripts/golden-from-links.ts'` — пусто; `git grep -n "jira-hub" -- src tests` — только имена MCP-серверов (`FastMCP`, ключи `mcp_json`, их тесты и эталон `tests/fixtures/baseline/mcp_wiki_tools.expected.json`). +- [ ] **Step 6: Commit** — `git add -A && git commit -m "refactor(sdlc-rename): пакет sdlc_hub, CLI sdlc-hub, каталог данных sdlc-hub"`. + +### Task 2: MCP-серверы `sdlc-<модуль>`, модуль обязателен + +**Files:** +- Modify: `src/sdlc_hub/modules/jira/mcp.py` (`FastMCP("sdlc-jira")`), `src/sdlc_hub/modules/wiki/mcp.py` (`FastMCP("sdlc-wiki")`, docstring и описание `project_create`: «с сервером sdlc-wiki»), `src/sdlc_hub/core/mcp.py` (`mcp_json`, `run`), `src/sdlc_hub/core/cli.py` (аргумент `module` в `mcp`) +- Modify tests: `tests/test_cli.py` (`test_mcp_default_module` → `test_mcp_module_required`), `tests/test_mcp.py:500-520`, `tests/test_project_fields.py:152-157`, `tests/test_wiki_mcp.py:291,305`, эталон `tests/fixtures/baseline/mcp_wiki_tools.expected.json` (описание `project_create`) + +**Interfaces:** +- Consumes: пакет `sdlc_hub` (Task 1). +- Produces: `core.mcp.mcp_json(label: str, module: str) -> dict` — `{"mcpServers": {f"sdlc-{module}": {"command": "uv", "args": ["run", "--project", str(HUB_ROOT), "sdlc-hub", "mcp", module, "--project", label]}}}`, `module` без значения по умолчанию; `core.mcp.run(module_name: str | None, project: str | None = None) -> int`. + +- [ ] **Step 1: Тесты** — в `tests/test_cli.py` заменить `test_mcp_default_module`: + +```python +def test_mcp_module_required(monkeypatch, capsys): + from sdlc_hub.core import mcp as core_mcp + from sdlc_hub.modules.jira import mcp as jira_mcp + seen = [] + monkeypatch.setattr(jira_mcp.mcp, "run", lambda transport="stdio": seen.append(core_mcp.PROJECT)) + assert cli.main(["mcp", "--project", "AWPCA"]) == 2 + err = capsys.readouterr().err + assert "укажите модуль: sdlc-hub mcp <модуль>" in err and "jira, wiki" in err + assert seen == [] + assert cli.main(["mcp", "jira", "--project", "AWPCA"]) == 0 + assert seen == ["AWPCA"] +``` + + В `test_project_fields.py::test_mcp_json_per_module`: `list(core_mcp.mcp_json("AWPCA", "jira")["mcpServers"]) == ["sdlc-jira"]`, `args[-4:] == ["mcp", "jira", "--project", "AWPCA"]`, `args[3] == "sdlc-hub"`; для `"wiki"` — `["sdlc-wiki"]`, `args[-4:] == ["mcp", "wiki", "--project", "AWPCA"]`. В `test_mcp.py` фрагмент `project_create` сравнивается с `core_mcp.mcp_json("STOCKS_DRRK", "jira")`, ключ `"sdlc-jira"`. В `test_wiki_mcp.py`: `m.mcp_server().name == "sdlc-wiki"`, `"sdlc-wiki" in created`. Имя сервера jira: `jira_mcp.mcp.name == "sdlc-jira"` (добавить в `test_mcp.py`). +- [ ] **Step 2:** `uv run pytest tests/test_cli.py tests/test_mcp.py tests/test_project_fields.py tests/test_wiki_mcp.py -q` — FAIL. +- [ ] **Step 3:** реализация: `cli` — `p.add_argument("module", nargs="?", default=None, help="имя модуля (jira, wiki); без модуля или неизвестный — код 2")`; `mcp.run(None, …)` печатает в stderr `укажите модуль: sdlc-hub mcp <модуль> [--project <метка>]; модули: <имена через ", ">` и возвращает 2; ветка неизвестного модуля прежняя. Вызов `mcp_json` в `core/mcp.py:186` уже передаёт `module`. Эталон baseline — заменить `jira-hub-wiki` на `sdlc-wiki` в описании. +- [ ] **Step 4:** `uv run pytest -q` — весь набор PASS; `git grep -n "jira-hub" -- src tests` — пусто. +- [ ] **Step 5: Commit** — `feat(sdlc-rename): MCP-серверы sdlc-jira и sdlc-wiki, модуль в sdlc-hub mcp обязателен`. + +### Task 3: Отказ при каталоге данных под прежним именем + +**Files:** +- Modify: `src/sdlc_hub/core/paths.py`, `src/sdlc_hub/core/cli.py` (`main`, сразу после `parse_args`) +- Test: `tests/test_cli.py` + +**Interfaces:** +- Consumes: `paths.home()` (Task 1). +- Produces: `paths.legacy_home() -> Path` (`Path.home() / ".local" / "share" / "jira-hub"`), `paths.home_problem() -> str | None` (текст отказа: `SDLC_HUB_HOME` не задан, `home()` не существует, `legacy_home()` существует), `paths.home_warning() -> str | None` (`SDLC_HUB_HOME` не задан, существуют оба). + +- [ ] **Step 1: Тесты** в `tests/test_cli.py`: + +```python +def _homes(monkeypatch, tmp_path, legacy=True, new=False): + monkeypatch.delenv("SDLC_HUB_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + share = tmp_path / ".local" / "share" + if legacy: (share / "jira-hub").mkdir(parents=True) + if new: (share / "sdlc-hub").mkdir(parents=True) + return share + + +@pytest.mark.parametrize("argv", [["serve"], ["tokens"], ["mcp", "jira"], ["agent", "publish", "x.py"]]) +def test_legacy_home_refused(monkeypatch, tmp_path, capsys, argv): + share = _homes(monkeypatch, tmp_path) + assert cli.main(argv) == 2 + err = capsys.readouterr().err + assert "~/.local/share/jira-hub" in err and "scripts/cutover.sh" in err + assert not (share / "sdlc-hub").exists() + assert sorted(p.name for p in (share / "jira-hub").iterdir()) == [] + + +def test_both_homes_warn(monkeypatch, tmp_path, capsys): + _homes(monkeypatch, tmp_path, new=True) + cli.main(["tokens"]) + assert "прежний каталог ~/.local/share/jira-hub можно удалить" in capsys.readouterr().err + + +def test_env_home_skips_legacy_check(monkeypatch, tmp_path, capsys): + _homes(monkeypatch, tmp_path) + monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "x")) + assert cli.main(["tokens"]) == 0 +``` + +- [ ] **Step 2:** `uv run pytest tests/test_cli.py -q -k "legacy or both_homes or env_home"` — FAIL. +- [ ] **Step 3:** реализация. Текст отказа (точно): + `каталог данных хаба лежит под прежним именем ~/.local/share/jira-hub; перенесите его: bash scripts/cutover.sh (или mv ~/.local/share/jira-hub ~/.local/share/sdlc-hub при остановленном хабе)`. + Предупреждение: `есть и ~/.local/share/sdlc-hub, и ~/.local/share/jira-hub: работаю с новым, прежний каталог ~/.local/share/jira-hub можно удалить после проверки`. Проверка в `cli.main` до любой ветки команд; отказ — `print(..., file=sys.stderr)` и `return 2`. +- [ ] **Step 4:** `uv run pytest -q` — весь набор PASS (прочие тесты задают `SDLC_HUB_HOME` и проверку не задевают). +- [ ] **Step 5: Commit** — `feat(sdlc-rename): отказ CLI при каталоге данных под прежним именем`. + +### Task 4: Веб — бренд, ключи хранилища, подсказки + +**Files:** +- Create: `web/src/api/legacyStorage.ts`, `web/src/api/legacyStorage.test.ts` +- Modify: `web/index.html` (`<title>SDLC Hub`), `web/src/shell/Sidebar.tsx` (бренд «SDLC Hub», 2 места), `web/src/shell/Sidebar.test.tsx:204`, `web/src/auth/token.ts` (`TOKEN_KEY = "sdlc-hub-token"`, чтение через `readMigrated`, комментарий), `web/src/shell/sidebarPref.ts` (`sdlc-hub-sidebar`), `web/src/modules/jira/board/legendPref.ts` (`sdlc-hub-legend`), экран входа (подсказка `uv run sdlc-hub tokens`) и `web/src/auth/LoginScreen.test.tsx:21`, `web/e2e/screens.spec.ts:92` (`sdlc-hub-sidebar`), `web/vite.config.ts` (`SDLC_HUB_URL`), `web/package.json` и `web/package-lock.json` (`"name": "sdlc-hub-web"`) + +**Interfaces:** +- Produces: `readMigrated(key: string): string | null` в `web/src/api/legacyStorage.ts` — ключ обязан начинаться с `sdlc-hub-`; если `localStorage.getItem(key)` не `null` — вернуть его; иначе прочитать `"jira-hub-" + key.slice("sdlc-hub-".length)`, при наличии — `setItem(key, v)`, `removeItem(old)`, вернуть `v`; любое исключение хранилища — `null`. + +- [ ] **Step 1: Тесты** `web/src/api/legacyStorage.test.ts`: + - есть только `jira-hub-token = "abc"` → `readMigrated("sdlc-hub-token") === "abc"`, `localStorage.getItem("sdlc-hub-token") === "abc"`, `getItem("jira-hub-token") === null`; + - есть оба → возвращается новое, старое не трогается; + - нет ни одного → `null`, ничего не записано; + - `getItem` бросает (`vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("closed") })`) → `null`, без исключения. + - в `Sidebar.test.tsx` — `getByText("SDLC Hub")`; тест `sidebarPref`: при `jira-hub-sidebar = "collapsed"` `readSidebar() === true`. +- [ ] **Step 2:** `cd web && npx vitest run src/api/legacyStorage.test.ts src/shell` — FAIL. +- [ ] **Step 3:** реализация по Interfaces; `token.ts`, `sidebarPref.ts`, `legendPref.ts` читают через `readMigrated`, пишут и удаляют по новым ключам. +- [ ] **Step 4:** `cd web && npm run lint && npm test` — PASS; `git grep -n "jira-hub\|Хаб Jira\|JIRA_HUB" -- web ':!web/scripts/golden-from-links.ts'` — только `legacyStorage.ts` и его тест. +- [ ] **Step 5: Commit** — `feat(sdlc-rename): бренд SDLC Hub и ключи хранилища sdlc-hub-* с переносом`. + +### Task 5: Скрипт переключения живой установки + +**Files:** +- Create: `scripts/cutover.py` (stdlib, Python ≥ 3.9, вне пакета), `scripts/cutover.sh` (`exec python3 "$(dirname "$0")/cutover.py" "$@"`, исполняемый), `tests/test_cutover.py` (загрузка модуля через `importlib.util.spec_from_file_location`) + +**Interfaces:** +- Produces (в `scripts/cutover.py`): + - `@dataclass Config`: `bridge: Path` (по умолчанию `~/projects/jira-bridge`), `data_root: Path` (`~/.local/share`), `launch_agents: Path` (`~/Library/LaunchAgents`), `logs: Path` (`~/Library/Logs`), `remote: str` (`https://git.aepif.ru/aep/sdlc-hub.git`), `projects: list[Path]` (`~/projects/awp-ca`, `~/projects/stocks_drrk`), `port: int` (8790); `Config.from_env()` — переопределения `CUTOVER_BRIDGE`, `CUTOVER_DATA_ROOT`, `CUTOVER_LAUNCH_AGENTS`, `CUTOVER_LOGS`, `CUTOVER_REMOTE`, `CUTOVER_PROJECTS` (через `:`), `CUTOVER_PORT`. + - `preflight(cfg: Config) -> list[str]` — проблемы шага 1 спеки: старый checkout `bridge/jira-hub` есть, чистый (`git status --porcelain` пуст), ветка `main`, `HEAD == origin/main` (после `git fetch`); `git ls-remote ` успешен; `data_root/sdlc-hub` и `bridge/sdlc-hub` не существуют; `data_root/jira-hub` существует. + - `rewrite_mcp_json(path: Path, checkout: Path) -> bool` — ключи `jira-hub` → `sdlc-jira` с `args = ["run", "--project", str(checkout), "sdlc-hub", "mcp", "jira", *хвост --project]`, `jira-hub-wiki` → `sdlc-wiki` с `… "mcp", "wiki", …`; прочие серверы и порядок ключей сохраняются; `False`, если менять нечего. + - `rewrite_text(path: Path) -> list[str]` — замены по порядку: `mcp__jira-hub-wiki__`→`mcp__sdlc-wiki__`, `mcp__jira-hub__`→`mcp__sdlc-jira__`, `aep/jira-hub`→`aep/sdlc-hub`, `"jira-hub-wiki"`→`"sdlc-wiki"`, `"jira-hub"`→`"sdlc-jira"`, `` MCP `jira-hub` ``→`` MCP `sdlc-jira` ``, `MCP jira-hub`→`MCP sdlc-jira`; возвращает строки, где после замен осталось `jira-hub` (для сводки «проверить руками»). + - `backup(path: Path) -> Path` — копия `path` + `.pre-sdlc` до первой записи. + - `plist_text(checkout: Path, log: Path, uv: str) -> str` — как текущий `~/Library/LaunchAgents/ru.aepif.jira-hub.plist`, но `Label = ru.aepif.sdlc-hub`, `ProgramArguments = [uv, run, --project, checkout, sdlc-hub, serve]`, оба лога — `log`. + - `steps(cfg: Config) -> list[tuple[str, Callable[[], None]]]` — шаги 2–7 спеки с описаниями; `main(argv: list[str]) -> int` — без `--apply` печатает `preflight` и описания шагов, код 0 (или 1, если есть проблемы); с `--apply` — при проблемах код 1 без действий, иначе шаги по порядку с печатью, первая ошибка — код 1 и текст «остановлено на шаге N: …»; в конце сводка: `/mcp` reconnect, `codegraph init` в checkout и worktree, файлы `*.pre-sdlc`, строки из `rewrite_text` для ручной проверки. + - Файлы проектов для правки: `/.mcp.json` (`rewrite_mcp_json`), `/.claude/settings*.json`, `/CLAUDE.md`, `/.claude/skills/**/*` текстовые (`rewrite_text`). + - Шаг 5 ждёт TCP-подключения к `127.0.0.1:` до 30 с. Шаг 7: `claude mcp remove -s user jira-hub`, затем `claude mcp add -s user sdlc-jira -- uv run --project sdlc-hub mcp jira`. + +- [ ] **Step 1: Тесты** `tests/test_cutover.py` (всё во `tmp_path`, `HOME` подменён, `launchctl`/`claude`/`make`/`uv` не запускаются): + - `test_rewrite_mcp_json` — вход: копия формы `~/projects/awp-ca/.mcp.json` (серверы `jira-hub` и `jira-hub-wiki` с `--project AWPCA`) плюс посторонний сервер `other`; выход: ключи `["sdlc-jira", "sdlc-wiki", "other"]` в этом порядке, `args` как в Interfaces, `other` без изменений; повторный вызов → `False`. + - `test_rewrite_text_settings` — `{"enabledMcpjsonServers": ["jira-hub", "jira-hub-wiki"], "permissions": {"allow": ["mcp__jira-hub__issue", "mcp__jira-hub-wiki__page"]}}` → `["sdlc-jira", "sdlc-wiki"]`, `["mcp__sdlc-jira__issue", "mcp__sdlc-wiki__page"]`, остатков нет. + - `test_rewrite_text_reports_leftovers` — строка «Мост Jira (aep/jira-hub, aep/jira-agent) … через MCP `jira-hub` (user scope)» → «aep/sdlc-hub», «MCP `sdlc-jira`»; строка «каталог jira-hub» попадает в остатки. + - `test_plist_text` — `plistlib.loads(plist_text(...).encode())`: `Label == "ru.aepif.sdlc-hub"`, `ProgramArguments[-2:] == ["sdlc-hub", "serve"]`, `StandardOutPath == StandardErrorPath == str(log)`, `KeepAlive is True`. + - `test_dry_run_changes_nothing` — подготовить `bridge/jira-hub` (git-репозиторий с `origin` на локальный bare-репозиторий, `main` = `origin/main`), `data_root/jira-hub/hub.sqlite`, проект с `.mcp.json`; `CUTOVER_REMOTE` — путь к bare-репозиторию; снимок `{путь: (mtime_ns, size)}` всего `tmp_path` до и после `main([])` совпадает, код 0. + - `test_apply_refuses_without_remote` — `CUTOVER_REMOTE` на несуществующий путь → `main(["--apply"]) == 1`, в выводе «aep/sdlc-hub», снимок не изменился. + - `test_apply_refuses_when_target_exists` — `data_root/sdlc-hub` уже есть → `main(["--apply"]) == 1`, текст про существующий каталог, снимок не изменился. + - `test_dirty_checkout_refused` — незакоммиченный файл в `bridge/jira-hub` → проблема в `preflight`. +- [ ] **Step 2:** `uv run pytest tests/test_cutover.py -q` — FAIL. +- [ ] **Step 3:** реализация по Interfaces. Внешние команды — через `subprocess.run(..., check=True)` в шагах; в сухом прогоне и `preflight` — только чтение (`git status`, `git rev-parse`, `git fetch`, `git ls-remote`). +- [ ] **Step 4:** `uv run pytest -q` — весь набор PASS; `bash scripts/cutover.sh` с `CUTOVER_*` на временный каталог печатает шаги. +- [ ] **Step 5: Commit** — `feat(sdlc-rename): скрипт переключения живой установки scripts/cutover.sh`. + +### Task 6: Документация + +**Files:** +- Modify: `README.md`, `CLAUDE.md`, `ARCHITECTURE.md` + +- [ ] **Step 1:** README: заголовок `# sdlc-hub`, все команды `uv run sdlc-hub …`, каталог `~/.local/share/sdlc-hub/` и `SDLC_HUB_HOME`, MCP — `sdlc-hub mcp jira|wiki`, серверы `sdlc-jira`/`sdlc-wiki`, регистрация user scope `claude mcp add -s user sdlc-jira -- uv run --project /Users/andrey.epifancev/projects/jira-bridge/sdlc-hub sdlc-hub mcp jira`, пути `~/projects/jira-bridge/sdlc-hub`, launchd `ru.aepif.sdlc-hub` с текстом plist из `plist_text` и логом `~/Library/Logs/sdlc-hub.log`, `kickstart … ru.aepif.sdlc-hub`. Новый раздел «Переключение с jira-hub» — шаги из `design.md` («Переключение (для владельца)») и откат: остановить `ru.aepif.sdlc-hub` (`launchctl bootout gui/$(id -u)/ru.aepif.sdlc-hub`), `mv` каталогов данных и checkout обратно, `git worktree repair`, вернуть `origin`, `git checkout` коммита до переименования, plist `ru.aepif.jira-hub` (прежний текст — в этом разделе), вернуть файлы `*.pre-sdlc` в проектах, `claude mcp` — обратно. +- [ ] **Step 2:** CLAUDE.md: заголовок `# sdlc-hub`, «Что это» — SDLC-хаб с модулями jira и wiki, пути `src/sdlc_hub/…`, `~/.local/share/sdlc-hub/` (`SDLC_HUB_HOME`), команды `uv run sdlc-hub …`, MCP `sdlc-hub mcp <модуль>` (обязателен; серверы `sdlc-jira`, `sdlc-wiki`). ARCHITECTURE.md — те же имена. +- [ ] **Step 3:** проверка: `git grep -n -i "jira-hub\|jira_hub\|JIRA_HUB\|Хаб Jira" -- . ':!docs/superpowers/plans' ':!openspec' ':!web/scripts/golden-from-links.ts'` — только: `src/sdlc_hub/core/paths.py` (прежний каталог), `tests/test_cli.py`, `scripts/cutover.py`, `tests/test_cutover.py`, `web/src/api/legacyStorage*.ts`, раздел README «Переключение с jira-hub». `uv run pytest -q` и `cd web && npm run lint && npm test` — PASS. +- [ ] **Step 4: Commit** — `docs(sdlc-rename): README, CLAUDE.md, ARCHITECTURE.md под sdlc-hub`. + +Вне ветки хаба (делает контроллер сессии после слияния PR, bounded): мелкие PR в jira-agent и eva-agent — README, CLAUDE.md, ARCHITECTURE.md, `src/agent.example.json`, у eva-agent `scripts/e2e-hub.sh` (`SDLC_HUB_HOME`, `sdlc-hub`, путь хаба); `src/agent.py` и `src/run.py` не трогать (design, решение 10). diff --git a/openspec/changes/sdlc-rename/design.md b/openspec/changes/sdlc-rename/design.md index 195dea5..0a76119 100644 --- a/openspec/changes/sdlc-rename/design.md +++ b/openspec/changes/sdlc-rename/design.md @@ -41,7 +41,8 @@ 4. **Каталог данных не переносится кодом.** Хаб, MCP-серверы открытых сессий Claude и CLI открывают одну базу; перенос «на лету» при старте любого из них рискует WAL и гонкой. Код только отказывает с подсказкой (требование «Каталог данных прежнего имени»), перенос — шаг скрипта при остановленном хабе. -5. **Скрипт переключения в репозитории** (`scripts/cutover.sh`), сухой прогон по умолчанию. Шагов много, они касаются +5. **Скрипт переключения в репозитории** (`scripts/cutover.sh` — обёртка над `scripts/cutover.py` на stdlib, вне пакета + хаба), сухой прогон по умолчанию. Шагов много, они касаются launchd, двух чужих репозиториев и user scope Claude; руками их легко сделать не в том порядке (I-2 wiki-module). Скрипт одноразовый, после переключения остаётся в истории как образец; тесты — на сухой прогон и проверки шага 1 (во временном `HOME`), без launchd. @@ -65,7 +66,9 @@ 1. Слить PR этого change; переименовать репозиторий в Gitea `aep/jira-hub` → `aep/sdlc-hub`. 2. Закрыть или не трогать сессии Claude с MCP хаба (после переключения — `/mcp` reconnect). -3. `bash ~/projects/jira-bridge/jira-hub/scripts/cutover.sh` — сухой прогон, проверить вывод. +3. `git -C ~/projects/jira-bridge/jira-hub pull --ff-only` и сразу `bash ~/projects/jira-bridge/jira-hub/scripts/cutover.sh` + — сухой прогон, проверить вывод. Между pull и переключением работающий хаб живёт на загруженном коде, а новые сессии + Claude не поднимут MCP — окно в несколько минут. 4. `bash ~/projects/jira-bridge/jira-hub/scripts/cutover.sh --apply`. 5. Просмотреть правки в awp-ca и stocks_drrk (`.mcp.json`, `.claude/`, `CLAUDE.md` awp-ca; копии `*.pre-sdlc` рядом) и удалить копии. Это не git-репозитории — коммитить нечего. diff --git a/openspec/changes/sdlc-rename/specs/hub-api/spec.md b/openspec/changes/sdlc-rename/specs/hub-api/spec.md index b998f2d..3106f28 100644 --- a/openspec/changes/sdlc-rename/specs/hub-api/spec.md +++ b/openspec/changes/sdlc-rename/specs/hub-api/spec.md @@ -209,12 +209,13 @@ published_at}` или `null`), `update_state`, `launcher_version`, `agent_report ### Requirement: Переключение живого хаба `scripts/cutover.sh` SHALL переводить живую установку Mac mini со старого имени на новое. Без `--apply` скрипт SHALL только печатать шаги и найденные проблемы, ничего не меняя. С `--apply` — по порядку, с остановкой на первой ошибке: -1. проверки: основной checkout `jira-bridge/jira-hub` чистый и на `main`; в Gitea доступен `aep/sdlc-hub` (владелец +1. проверки: основной checkout `jira-bridge/jira-hub` чистый, на `main` и совпадает с `origin/main` (владелец сделал + `git pull --ff-only` прямо перед запуском: скрипт приходит этим pull); в Gitea доступен `aep/sdlc-hub` (владелец переименовал репозиторий); `~/.local/share/sdlc-hub/` и `jira-bridge/sdlc-hub` не существуют; 2. остановить launchd `ru.aepif.jira-hub` и удалить его plist; 3. перенести каталог данных `~/.local/share/jira-hub` → `~/.local/share/sdlc-hub`; 4. перенести checkout `jira-bridge/jira-hub` → `jira-bridge/sdlc-hub`, `git worktree repair`, `origin` → - `aep/sdlc-hub`, `git pull --ff-only`, пересоздать `.venv` (`uv sync`), `make web`; + `aep/sdlc-hub`, пересоздать `.venv` (`uv sync`), `make web`; 5. поставить и запустить launchd `ru.aepif.sdlc-hub` (`uv run --project sdlc-hub serve`, лог `~/Library/Logs/sdlc-hub.log`) и дождаться ответа хаба на порту из настроек; 6. в проектах из списка (по умолчанию `~/projects/awp-ca`, `~/projects/stocks_drrk`): в `.mcp.json` заменить записи -- 2.47.2 From 13ba7f4d606cbfb7ef33c65d7f8941604f0b83b1 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 11:23:19 +0400 Subject: [PATCH 03/21] =?UTF-8?q?spec(sdlc-rename):=20R-1=20=D0=B2=20desig?= =?UTF-8?q?n.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- openspec/changes/sdlc-rename/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/sdlc-rename/design.md b/openspec/changes/sdlc-rename/design.md index 0a76119..121a2e4 100644 --- a/openspec/changes/sdlc-rename/design.md +++ b/openspec/changes/sdlc-rename/design.md @@ -104,3 +104,4 @@ | 9 | 24.10.2026 | История (планы, архив) не меняется | это запись прошлого | — | | 10 | 24.10.2026 | Агенты без релиза, документация — мелкими PR | протокол не меняется | строки документации в `agent.py` со старым именем до следующего релиза | | 11 | 24.10.2026 | Сначала переименование, потом #98 | #98 перебазируется на новые пути | перебазирование веток | +| 12 | 24.10.2026 | R-1: трейлер коммитов `Co-Authored-By: Claude Sonnet 5.5` вместо `Opus 5.5` из плана | указание системы сессии новее плана | правка трейлера | -- 2.47.2 From a88bb1e6e82619ba7b8b3aeb92a864025e48b399 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 11:27:17 +0400 Subject: [PATCH 04/21] =?UTF-8?q?refactor(sdlc-rename):=20=D0=BF=D0=B0?= =?UTF-8?q?=D0=BA=D0=B5=D1=82=20sdlc=5Fhub,=20CLI=20sdlc-hub,=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=82=D0=B0=D0=BB=D0=BE=D0=B3=20=D0=B4=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20sdlc-hub?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 2 +- Makefile | 2 +- pyproject.toml | 6 +-- src/jira_hub/__init__.py | 2 - src/sdlc_hub/__init__.py | 2 + src/{jira_hub => sdlc_hub}/cli.py | 2 +- src/{jira_hub => sdlc_hub}/core/__init__.py | 0 src/{jira_hub => sdlc_hub}/core/api.py | 4 +- src/{jira_hub => sdlc_hub}/core/backup.py | 0 src/{jira_hub => sdlc_hub}/core/cli.py | 4 +- src/{jira_hub => sdlc_hub}/core/client.py | 2 +- src/{jira_hub => sdlc_hub}/core/db.py | 0 src/{jira_hub => sdlc_hub}/core/errors.py | 0 src/{jira_hub => sdlc_hub}/core/files.py | 0 src/{jira_hub => sdlc_hub}/core/http.py | 0 src/{jira_hub => sdlc_hub}/core/mcp.py | 4 +- src/{jira_hub => sdlc_hub}/core/modules.py | 0 src/{jira_hub => sdlc_hub}/core/paths.py | 2 +- src/{jira_hub => sdlc_hub}/core/projects.py | 0 src/{jira_hub => sdlc_hub}/core/queue.py | 0 src/{jira_hub => sdlc_hub}/core/registry.py | 0 src/{jira_hub => sdlc_hub}/core/relay.py | 0 src/{jira_hub => sdlc_hub}/core/release.py | 0 src/{jira_hub => sdlc_hub}/core/settings.py | 0 .../modules/__init__.py | 0 .../modules/jira/__init__.py | 0 .../modules/jira/api.py | 0 .../modules/jira/attachments.py | 0 .../modules/jira/cli.py | 2 +- .../modules/jira/commands.py | 0 .../modules/jira/fetch.py | 0 .../modules/jira/history.py | 0 .../modules/jira/hooks.py | 0 .../modules/jira/importer.py | 0 .../modules/jira/mcp.py | 0 .../modules/jira/mirror.py | 0 .../modules/jira/project_fields.py | 0 .../modules/jira/projects.py | 0 .../modules/jira/refresh.py | 0 .../modules/jira/schema.py | 0 .../modules/jira/settings.py | 0 .../modules/wiki/__init__.py | 0 .../modules/wiki/api.py | 0 .../modules/wiki/attachments.py | 0 .../modules/wiki/commands.py | 0 .../modules/wiki/convert.py | 0 .../modules/wiki/mcp.py | 0 .../modules/wiki/paths.py | 0 .../modules/wiki/projects.py | 0 .../modules/wiki/roots.py | 0 .../modules/wiki/schema.py | 0 .../modules/wiki/search.py | 0 .../modules/wiki/settings.py | 0 .../modules/wiki/stage.py | 0 .../modules/wiki/sync.py | 0 .../modules/wiki/tick.py | 0 tests/conftest.py | 12 ++--- tests/test_agents.py | 12 ++--- tests/test_api.py | 12 ++--- tests/test_attachments.py | 4 +- tests/test_baseline.py | 6 +-- tests/test_cli.py | 16 +++--- tests/test_commands.py | 4 +- tests/test_db.py | 6 +-- tests/test_e2e_hub.py | 4 +- tests/test_files.py | 2 +- tests/test_history.py | 8 +-- tests/test_importer.py | 4 +- tests/test_layout.py | 23 +++++++-- tests/test_mcp.py | 16 +++--- tests/test_mirror.py | 4 +- tests/test_project_fields.py | 16 +++--- tests/test_projects.py | 6 +-- tests/test_queue.py | 16 +++--- tests/test_queue_core.py | 8 +-- tests/test_registry.py | 14 +++--- tests/test_relay.py | 4 +- tests/test_release.py | 2 +- tests/test_settings.py | 6 +-- tests/test_web_app.py | 4 +- tests/test_wiki_api.py | 4 +- tests/test_wiki_commands.py | 8 +-- tests/test_wiki_convert.py | 2 +- tests/test_wiki_mcp.py | 10 ++-- tests/test_wiki_projects.py | 6 +-- tests/test_wiki_sync.py | 8 +-- uv.lock | 50 +++++++++---------- web/playwright.config.ts | 2 +- web/scripts/seed-hub.py | 6 +-- web/scripts/test-hub.sh | 20 ++++---- web/vite.config.ts | 4 +- 91 files changed, 188 insertions(+), 175 deletions(-) delete mode 100644 src/jira_hub/__init__.py create mode 100644 src/sdlc_hub/__init__.py rename src/{jira_hub => sdlc_hub}/cli.py (87%) rename src/{jira_hub => sdlc_hub}/core/__init__.py (100%) rename src/{jira_hub => sdlc_hub}/core/api.py (99%) rename src/{jira_hub => sdlc_hub}/core/backup.py (100%) rename src/{jira_hub => sdlc_hub}/core/cli.py (96%) rename src/{jira_hub => sdlc_hub}/core/client.py (99%) rename src/{jira_hub => sdlc_hub}/core/db.py (100%) rename src/{jira_hub => sdlc_hub}/core/errors.py (100%) rename src/{jira_hub => sdlc_hub}/core/files.py (100%) rename src/{jira_hub => sdlc_hub}/core/http.py (100%) rename src/{jira_hub => sdlc_hub}/core/mcp.py (98%) rename src/{jira_hub => sdlc_hub}/core/modules.py (100%) rename src/{jira_hub => sdlc_hub}/core/paths.py (90%) rename src/{jira_hub => sdlc_hub}/core/projects.py (100%) rename src/{jira_hub => sdlc_hub}/core/queue.py (100%) rename src/{jira_hub => sdlc_hub}/core/registry.py (100%) rename src/{jira_hub => sdlc_hub}/core/relay.py (100%) rename src/{jira_hub => sdlc_hub}/core/release.py (100%) rename src/{jira_hub => sdlc_hub}/core/settings.py (100%) rename src/{jira_hub => sdlc_hub}/modules/__init__.py (100%) rename src/{jira_hub => sdlc_hub}/modules/jira/__init__.py (100%) rename src/{jira_hub => sdlc_hub}/modules/jira/api.py (100%) rename src/{jira_hub => sdlc_hub}/modules/jira/attachments.py (100%) rename src/{jira_hub => sdlc_hub}/modules/jira/cli.py (91%) rename src/{jira_hub => sdlc_hub}/modules/jira/commands.py (100%) rename src/{jira_hub => sdlc_hub}/modules/jira/fetch.py (100%) rename src/{jira_hub => sdlc_hub}/modules/jira/history.py (100%) rename src/{jira_hub => sdlc_hub}/modules/jira/hooks.py (100%) rename src/{jira_hub => sdlc_hub}/modules/jira/importer.py (100%) rename src/{jira_hub => sdlc_hub}/modules/jira/mcp.py (100%) rename src/{jira_hub => sdlc_hub}/modules/jira/mirror.py (100%) rename src/{jira_hub => sdlc_hub}/modules/jira/project_fields.py (100%) rename src/{jira_hub => sdlc_hub}/modules/jira/projects.py (100%) rename src/{jira_hub => sdlc_hub}/modules/jira/refresh.py (100%) rename src/{jira_hub => sdlc_hub}/modules/jira/schema.py (100%) rename src/{jira_hub => sdlc_hub}/modules/jira/settings.py (100%) rename src/{jira_hub => sdlc_hub}/modules/wiki/__init__.py (100%) rename src/{jira_hub => sdlc_hub}/modules/wiki/api.py (100%) rename src/{jira_hub => sdlc_hub}/modules/wiki/attachments.py (100%) rename src/{jira_hub => sdlc_hub}/modules/wiki/commands.py (100%) rename src/{jira_hub => sdlc_hub}/modules/wiki/convert.py (100%) rename src/{jira_hub => sdlc_hub}/modules/wiki/mcp.py (100%) rename src/{jira_hub => sdlc_hub}/modules/wiki/paths.py (100%) rename src/{jira_hub => sdlc_hub}/modules/wiki/projects.py (100%) rename src/{jira_hub => sdlc_hub}/modules/wiki/roots.py (100%) rename src/{jira_hub => sdlc_hub}/modules/wiki/schema.py (100%) rename src/{jira_hub => sdlc_hub}/modules/wiki/search.py (100%) rename src/{jira_hub => sdlc_hub}/modules/wiki/settings.py (100%) rename src/{jira_hub => sdlc_hub}/modules/wiki/stage.py (100%) rename src/{jira_hub => sdlc_hub}/modules/wiki/sync.py (100%) rename src/{jira_hub => sdlc_hub}/modules/wiki/tick.py (100%) diff --git a/.gitignore b/.gitignore index 4a0f716..75e47d6 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,7 @@ __pycache__/ *.sqlite* .pytest_cache/ .wt/ -src/jira_hub/web/app/ +src/sdlc_hub/web/app/ web/test-results/ web/playwright-report/ web/.e2e-hub/ diff --git a/Makefile b/Makefile index d4f6bd6..eb4b16b 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .PHONY: web check -# сборка веб-приложения в src/jira_hub/web/app/ (каталог не в git; хаб читает его на каждый запрос) +# сборка веб-приложения в src/sdlc_hub/web/app/ (каталог не в git; хаб читает его на каждый запрос) web: cd web && npm ci && npm run build diff --git a/pyproject.toml b/pyproject.toml index 7ef8e0f..75bd7b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "jira-hub" +name = "sdlc-hub" version = "0.1.0" description = "Мост Jira для АРМ КА: зеркало задач, очередь команд, HTTP API, MCP" requires-python = ">=3.13" @@ -10,7 +10,7 @@ dependencies = [ ] [project.scripts] -jira-hub = "jira_hub.cli:main" +sdlc-hub = "sdlc_hub.cli:main" [dependency-groups] dev = ["pytest>=8"] @@ -20,7 +20,7 @@ requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] -packages = ["src/jira_hub"] +packages = ["src/sdlc_hub"] [tool.pytest.ini_options] testpaths = ["tests"] diff --git a/src/jira_hub/__init__.py b/src/jira_hub/__init__.py deleted file mode 100644 index 908045b..0000000 --- a/src/jira_hub/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -"""jira-hub — мост Jira для АРМ КА: зеркало задач, очередь команд, HTTP API, MCP.""" -__version__ = "0.1.0" diff --git a/src/sdlc_hub/__init__.py b/src/sdlc_hub/__init__.py new file mode 100644 index 0000000..a0796a0 --- /dev/null +++ b/src/sdlc_hub/__init__.py @@ -0,0 +1,2 @@ +"""sdlc-hub — хаб SDLC: ядро (проекты, очередь команд, агенты, HTTP API, веб) и модули jira и wiki.""" +__version__ = "0.1.0" diff --git a/src/jira_hub/cli.py b/src/sdlc_hub/cli.py similarity index 87% rename from src/jira_hub/cli.py rename to src/sdlc_hub/cli.py index 271dd5f..a1001f4 100644 --- a/src/jira_hub/cli.py +++ b/src/sdlc_hub/cli.py @@ -1 +1 @@ -from .core.cli import main # noqa: F401 точка входа jira-hub +from .core.cli import main # noqa: F401 точка входа sdlc-hub diff --git a/src/jira_hub/core/__init__.py b/src/sdlc_hub/core/__init__.py similarity index 100% rename from src/jira_hub/core/__init__.py rename to src/sdlc_hub/core/__init__.py diff --git a/src/jira_hub/core/api.py b/src/sdlc_hub/core/api.py similarity index 99% rename from src/jira_hub/core/api.py rename to src/sdlc_hub/core/api.py index f20f74e..5374b04 100644 --- a/src/jira_hub/core/api.py +++ b/src/sdlc_hub/core/api.py @@ -28,7 +28,7 @@ ASSET_TYPES = {".js": "text/javascript; charset=utf-8", ".css": "text/css; chars ".png": "image/png", ".ico": "image/x-icon", ".woff2": "font/woff2", ".map": "application/json"} ASSET_CACHE = {"Cache-Control": "public, max-age=31536000, immutable"} # имена файлов сборки содержат хэш (R-3) NOT_BUILT = ("Хаб Jira

Веб-приложение не собрано: выполните " - "make web в каталоге jira-hub и перезапустите хаб.

").encode("utf-8") + "make web в каталоге sdlc-hub и перезапустите хаб.

").encode("utf-8") SOCKET_TIMEOUT = 60 # чтение запроса; ожидание долгого claim идёт уже после чтения тела и сокет не держит SWEEP_EVERY = 24 * 3600 # уборка files/ не чаще раза в сутки (hub-api «Хранилище файлов вложений») @@ -610,7 +610,7 @@ def start(hub: Hub, host="0.0.0.0", port=8790) -> tuple[ThreadingHTTPServer, thr def serve(hub: Hub, host="0.0.0.0", port=8790) -> None: srv, stop = start(hub, host, port) - print(f"jira-hub: http://{host}:{srv.server_port}, база {hub.db_path}", flush=True) + print(f"sdlc-hub: http://{host}:{srv.server_port}, база {hub.db_path}", flush=True) try: srv.serve_forever() except KeyboardInterrupt: diff --git a/src/jira_hub/core/backup.py b/src/sdlc_hub/core/backup.py similarity index 100% rename from src/jira_hub/core/backup.py rename to src/sdlc_hub/core/backup.py diff --git a/src/jira_hub/core/cli.py b/src/sdlc_hub/core/cli.py similarity index 96% rename from src/jira_hub/core/cli.py rename to src/sdlc_hub/core/cli.py index 15469b9..49a3447 100644 --- a/src/jira_hub/core/cli.py +++ b/src/sdlc_hub/core/cli.py @@ -1,4 +1,4 @@ -"""Команды jira-hub: init (настройки из config.example.json), serve (HTTP), mcp [модуль] (stdio), tokens, +"""Команды sdlc-hub: init (настройки из config.example.json), serve (HTTP), mcp [модуль] (stdio), tokens, agent publish (версия агента для самообновления) и подкоманды модулей (jira: import).""" import argparse import json @@ -9,7 +9,7 @@ from .paths import secure_dir def main(argv=None) -> int: - ap = argparse.ArgumentParser(prog="jira-hub", description="Мост Jira для АРМ КА: хаб") + ap = argparse.ArgumentParser(prog="sdlc-hub", description="SDLC-хаб: ядро и модули") sub = ap.add_subparsers(dest="cmd", required=True) p = sub.add_parser("init", help="перенести настройки проекта из config.example.json, создать токены") p.add_argument("--config", required=True) diff --git a/src/jira_hub/core/client.py b/src/sdlc_hub/core/client.py similarity index 99% rename from src/jira_hub/core/client.py rename to src/sdlc_hub/core/client.py index 0348f7f..873f88f 100644 --- a/src/jira_hub/core/client.py +++ b/src/sdlc_hub/core/client.py @@ -14,7 +14,7 @@ class HubError(Exception): class HubUnavailable(Exception): - """Хаб не отвечает — обычно не запущен (jira-hub serve).""" + """Хаб не отвечает — обычно не запущен (sdlc-hub serve).""" class HubClient: diff --git a/src/jira_hub/core/db.py b/src/sdlc_hub/core/db.py similarity index 100% rename from src/jira_hub/core/db.py rename to src/sdlc_hub/core/db.py diff --git a/src/jira_hub/core/errors.py b/src/sdlc_hub/core/errors.py similarity index 100% rename from src/jira_hub/core/errors.py rename to src/sdlc_hub/core/errors.py diff --git a/src/jira_hub/core/files.py b/src/sdlc_hub/core/files.py similarity index 100% rename from src/jira_hub/core/files.py rename to src/sdlc_hub/core/files.py diff --git a/src/jira_hub/core/http.py b/src/sdlc_hub/core/http.py similarity index 100% rename from src/jira_hub/core/http.py rename to src/sdlc_hub/core/http.py diff --git a/src/jira_hub/core/mcp.py b/src/sdlc_hub/core/mcp.py similarity index 98% rename from src/jira_hub/core/mcp.py rename to src/sdlc_hub/core/mcp.py index 746f36f..287986e 100644 --- a/src/jira_hub/core/mcp.py +++ b/src/sdlc_hub/core/mcp.py @@ -14,7 +14,7 @@ from pathlib import Path from . import paths, registry from .client import HubClient, HubError, HubUnavailable -OFFLINE = "хаб не запущен: jira-hub serve" +OFFLINE = "хаб не запущен: sdlc-hub serve" AUTHOR = "claude" NOT_BOUND = "сессия не привязана к проекту: запустите MCP с --project <метка> (список — projects)" HUB_ROOT = Path(__file__).resolve().parents[3] # корень репозитория хаба, из которого загружен пакет (решение 19) @@ -28,7 +28,7 @@ def unknown_project(label) -> str: def mcp_json(label: str, module: str = registry.DEFAULT_AGENT) -> dict: """Фрагмент .mcp.json для репозитория проекта: у модуля по умолчанию прежний (сервер jira-hub), у прочих модулей — сервер jira-hub-<модуль>.""" - run = ["run", "--project", str(HUB_ROOT), "jira-hub", "mcp"] + run = ["run", "--project", str(HUB_ROOT), "sdlc-hub", "mcp"] if module == registry.DEFAULT_AGENT: return {"mcpServers": {"jira-hub": {"command": "uv", "args": [*run, "--project", label]}}} return {"mcpServers": {f"jira-hub-{module}": {"command": "uv", "args": [*run, module, "--project", label]}}} diff --git a/src/jira_hub/core/modules.py b/src/sdlc_hub/core/modules.py similarity index 100% rename from src/jira_hub/core/modules.py rename to src/sdlc_hub/core/modules.py diff --git a/src/jira_hub/core/paths.py b/src/sdlc_hub/core/paths.py similarity index 90% rename from src/jira_hub/core/paths.py rename to src/sdlc_hub/core/paths.py index ac4250b..10ea4d1 100644 --- a/src/jira_hub/core/paths.py +++ b/src/sdlc_hub/core/paths.py @@ -7,7 +7,7 @@ log = logging.getLogger(__name__) def home() -> Path: - return Path(os.environ.get("JIRA_HUB_HOME", str(Path.home() / ".local" / "share" / "jira-hub"))) + return Path(os.environ.get("SDLC_HUB_HOME", str(Path.home() / ".local" / "share" / "sdlc-hub"))) def db_path() -> Path: diff --git a/src/jira_hub/core/projects.py b/src/sdlc_hub/core/projects.py similarity index 100% rename from src/jira_hub/core/projects.py rename to src/sdlc_hub/core/projects.py diff --git a/src/jira_hub/core/queue.py b/src/sdlc_hub/core/queue.py similarity index 100% rename from src/jira_hub/core/queue.py rename to src/sdlc_hub/core/queue.py diff --git a/src/jira_hub/core/registry.py b/src/sdlc_hub/core/registry.py similarity index 100% rename from src/jira_hub/core/registry.py rename to src/sdlc_hub/core/registry.py diff --git a/src/jira_hub/core/relay.py b/src/sdlc_hub/core/relay.py similarity index 100% rename from src/jira_hub/core/relay.py rename to src/sdlc_hub/core/relay.py diff --git a/src/jira_hub/core/release.py b/src/sdlc_hub/core/release.py similarity index 100% rename from src/jira_hub/core/release.py rename to src/sdlc_hub/core/release.py diff --git a/src/jira_hub/core/settings.py b/src/sdlc_hub/core/settings.py similarity index 100% rename from src/jira_hub/core/settings.py rename to src/sdlc_hub/core/settings.py diff --git a/src/jira_hub/modules/__init__.py b/src/sdlc_hub/modules/__init__.py similarity index 100% rename from src/jira_hub/modules/__init__.py rename to src/sdlc_hub/modules/__init__.py diff --git a/src/jira_hub/modules/jira/__init__.py b/src/sdlc_hub/modules/jira/__init__.py similarity index 100% rename from src/jira_hub/modules/jira/__init__.py rename to src/sdlc_hub/modules/jira/__init__.py diff --git a/src/jira_hub/modules/jira/api.py b/src/sdlc_hub/modules/jira/api.py similarity index 100% rename from src/jira_hub/modules/jira/api.py rename to src/sdlc_hub/modules/jira/api.py diff --git a/src/jira_hub/modules/jira/attachments.py b/src/sdlc_hub/modules/jira/attachments.py similarity index 100% rename from src/jira_hub/modules/jira/attachments.py rename to src/sdlc_hub/modules/jira/attachments.py diff --git a/src/jira_hub/modules/jira/cli.py b/src/sdlc_hub/modules/jira/cli.py similarity index 91% rename from src/jira_hub/modules/jira/cli.py rename to src/sdlc_hub/modules/jira/cli.py index f3aa3e5..89d5bb2 100644 --- a/src/jira_hub/modules/jira/cli.py +++ b/src/sdlc_hub/modules/jira/cli.py @@ -1,4 +1,4 @@ -"""Подкоманды модуля jira для jira-hub: import (CSV-пакет admin/jira-sync в заметки).""" +"""Подкоманды модуля jira для sdlc-hub: import (CSV-пакет admin/jira-sync в заметки).""" import json from ...core import db, paths, settings diff --git a/src/jira_hub/modules/jira/commands.py b/src/sdlc_hub/modules/jira/commands.py similarity index 100% rename from src/jira_hub/modules/jira/commands.py rename to src/sdlc_hub/modules/jira/commands.py diff --git a/src/jira_hub/modules/jira/fetch.py b/src/sdlc_hub/modules/jira/fetch.py similarity index 100% rename from src/jira_hub/modules/jira/fetch.py rename to src/sdlc_hub/modules/jira/fetch.py diff --git a/src/jira_hub/modules/jira/history.py b/src/sdlc_hub/modules/jira/history.py similarity index 100% rename from src/jira_hub/modules/jira/history.py rename to src/sdlc_hub/modules/jira/history.py diff --git a/src/jira_hub/modules/jira/hooks.py b/src/sdlc_hub/modules/jira/hooks.py similarity index 100% rename from src/jira_hub/modules/jira/hooks.py rename to src/sdlc_hub/modules/jira/hooks.py diff --git a/src/jira_hub/modules/jira/importer.py b/src/sdlc_hub/modules/jira/importer.py similarity index 100% rename from src/jira_hub/modules/jira/importer.py rename to src/sdlc_hub/modules/jira/importer.py diff --git a/src/jira_hub/modules/jira/mcp.py b/src/sdlc_hub/modules/jira/mcp.py similarity index 100% rename from src/jira_hub/modules/jira/mcp.py rename to src/sdlc_hub/modules/jira/mcp.py diff --git a/src/jira_hub/modules/jira/mirror.py b/src/sdlc_hub/modules/jira/mirror.py similarity index 100% rename from src/jira_hub/modules/jira/mirror.py rename to src/sdlc_hub/modules/jira/mirror.py diff --git a/src/jira_hub/modules/jira/project_fields.py b/src/sdlc_hub/modules/jira/project_fields.py similarity index 100% rename from src/jira_hub/modules/jira/project_fields.py rename to src/sdlc_hub/modules/jira/project_fields.py diff --git a/src/jira_hub/modules/jira/projects.py b/src/sdlc_hub/modules/jira/projects.py similarity index 100% rename from src/jira_hub/modules/jira/projects.py rename to src/sdlc_hub/modules/jira/projects.py diff --git a/src/jira_hub/modules/jira/refresh.py b/src/sdlc_hub/modules/jira/refresh.py similarity index 100% rename from src/jira_hub/modules/jira/refresh.py rename to src/sdlc_hub/modules/jira/refresh.py diff --git a/src/jira_hub/modules/jira/schema.py b/src/sdlc_hub/modules/jira/schema.py similarity index 100% rename from src/jira_hub/modules/jira/schema.py rename to src/sdlc_hub/modules/jira/schema.py diff --git a/src/jira_hub/modules/jira/settings.py b/src/sdlc_hub/modules/jira/settings.py similarity index 100% rename from src/jira_hub/modules/jira/settings.py rename to src/sdlc_hub/modules/jira/settings.py diff --git a/src/jira_hub/modules/wiki/__init__.py b/src/sdlc_hub/modules/wiki/__init__.py similarity index 100% rename from src/jira_hub/modules/wiki/__init__.py rename to src/sdlc_hub/modules/wiki/__init__.py diff --git a/src/jira_hub/modules/wiki/api.py b/src/sdlc_hub/modules/wiki/api.py similarity index 100% rename from src/jira_hub/modules/wiki/api.py rename to src/sdlc_hub/modules/wiki/api.py diff --git a/src/jira_hub/modules/wiki/attachments.py b/src/sdlc_hub/modules/wiki/attachments.py similarity index 100% rename from src/jira_hub/modules/wiki/attachments.py rename to src/sdlc_hub/modules/wiki/attachments.py diff --git a/src/jira_hub/modules/wiki/commands.py b/src/sdlc_hub/modules/wiki/commands.py similarity index 100% rename from src/jira_hub/modules/wiki/commands.py rename to src/sdlc_hub/modules/wiki/commands.py diff --git a/src/jira_hub/modules/wiki/convert.py b/src/sdlc_hub/modules/wiki/convert.py similarity index 100% rename from src/jira_hub/modules/wiki/convert.py rename to src/sdlc_hub/modules/wiki/convert.py diff --git a/src/jira_hub/modules/wiki/mcp.py b/src/sdlc_hub/modules/wiki/mcp.py similarity index 100% rename from src/jira_hub/modules/wiki/mcp.py rename to src/sdlc_hub/modules/wiki/mcp.py diff --git a/src/jira_hub/modules/wiki/paths.py b/src/sdlc_hub/modules/wiki/paths.py similarity index 100% rename from src/jira_hub/modules/wiki/paths.py rename to src/sdlc_hub/modules/wiki/paths.py diff --git a/src/jira_hub/modules/wiki/projects.py b/src/sdlc_hub/modules/wiki/projects.py similarity index 100% rename from src/jira_hub/modules/wiki/projects.py rename to src/sdlc_hub/modules/wiki/projects.py diff --git a/src/jira_hub/modules/wiki/roots.py b/src/sdlc_hub/modules/wiki/roots.py similarity index 100% rename from src/jira_hub/modules/wiki/roots.py rename to src/sdlc_hub/modules/wiki/roots.py diff --git a/src/jira_hub/modules/wiki/schema.py b/src/sdlc_hub/modules/wiki/schema.py similarity index 100% rename from src/jira_hub/modules/wiki/schema.py rename to src/sdlc_hub/modules/wiki/schema.py diff --git a/src/jira_hub/modules/wiki/search.py b/src/sdlc_hub/modules/wiki/search.py similarity index 100% rename from src/jira_hub/modules/wiki/search.py rename to src/sdlc_hub/modules/wiki/search.py diff --git a/src/jira_hub/modules/wiki/settings.py b/src/sdlc_hub/modules/wiki/settings.py similarity index 100% rename from src/jira_hub/modules/wiki/settings.py rename to src/sdlc_hub/modules/wiki/settings.py diff --git a/src/jira_hub/modules/wiki/stage.py b/src/sdlc_hub/modules/wiki/stage.py similarity index 100% rename from src/jira_hub/modules/wiki/stage.py rename to src/sdlc_hub/modules/wiki/stage.py diff --git a/src/jira_hub/modules/wiki/sync.py b/src/sdlc_hub/modules/wiki/sync.py similarity index 100% rename from src/jira_hub/modules/wiki/sync.py rename to src/sdlc_hub/modules/wiki/sync.py diff --git a/src/jira_hub/modules/wiki/tick.py b/src/sdlc_hub/modules/wiki/tick.py similarity index 100% rename from src/jira_hub/modules/wiki/tick.py rename to src/sdlc_hub/modules/wiki/tick.py diff --git a/tests/conftest.py b/tests/conftest.py index ca94e8a..55ca5a5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,12 +5,12 @@ import threading import pytest -from jira_hub.core import api, db, projects, registry, settings -from jira_hub.core.client import HubClient -from jira_hub.core.modules import CommandHooks, Module -from jira_hub.modules.jira import MODULE as JIRA_MODULE -from jira_hub.modules.jira import project_fields -from jira_hub.modules.jira import projects as jira_projects +from sdlc_hub.core import api, db, projects, registry, settings +from sdlc_hub.core.client import HubClient +from sdlc_hub.core.modules import CommandHooks, Module +from sdlc_hub.modules.jira import MODULE as JIRA_MODULE +from sdlc_hub.modules.jira import project_fields +from sdlc_hub.modules.jira import projects as jira_projects @pytest.fixture diff --git a/tests/test_agents.py b/tests/test_agents.py index 1840ed9..9a6d2f6 100644 --- a/tests/test_agents.py +++ b/tests/test_agents.py @@ -3,8 +3,8 @@ import os import pytest -from jira_hub.core import api, cli, db, queue, release, settings -from jira_hub.core.client import HubError +from sdlc_hub.core import api, cli, db, queue, release, settings +from sdlc_hub.core.client import HubError from v6db import make_v6_db @@ -91,7 +91,7 @@ def test_agent_query_cannot_be_forged(hub_server_two_modules): def test_tokens_cli_lists_agents(tmp_path, monkeypatch, capsys): - monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path)) + monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path)) assert cli.main(["tokens"]) == 0 lines = capsys.readouterr().out.splitlines() assert lines[0].startswith("клиентский токен: ") and lines[1].startswith("агент jira: ") @@ -223,7 +223,7 @@ def test_release_dirs_are_private(tmp_path): def test_publish_unknown_module(tmp_path, monkeypatch, capsys): - monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) + monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "home")) src = tmp_path / "agent.py" src.write_text("AGENT_VERSION = 1\n", encoding="utf-8") assert cli.main(["agent", "publish", "--module", "nope", str(src)]) == 2 @@ -231,7 +231,7 @@ def test_publish_unknown_module(tmp_path, monkeypatch, capsys): def test_publish_module_option(tmp_path, monkeypatch, capsys): - monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) + monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "home")) src = tmp_path / "agent.py" src.write_text("AGENT_VERSION = 1\n", encoding="utf-8") assert cli.main(["agent", "publish", "--module", "jira", str(src)]) == 0 @@ -293,7 +293,7 @@ def test_v14_agent_over_http_on_v6_base(tmp_path): import threading from pathlib import Path - from jira_hub.core.client import HubClient + from sdlc_hub.core.client import HubClient make_v6_db(tmp_path / "hub.sqlite") old = tmp_path / "release" # прежняя раскладка v14: release/agent.py diff --git a/tests/test_api.py b/tests/test_api.py index a4123b5..7b64479 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -11,10 +11,10 @@ from datetime import timedelta import pytest -from jira_hub.core import api, db, files, queue, release, settings -from jira_hub.core.client import HubClient, HubError -from jira_hub.modules.jira import api as jira_api -from jira_hub.modules.jira.commands import CommandError +from sdlc_hub.core import api, db, files, queue, release, settings +from sdlc_hub.core.client import HubClient, HubError +from sdlc_hub.modules.jira import api as jira_api +from sdlc_hub.modules.jira.commands import CommandError @pytest.fixture @@ -158,7 +158,7 @@ def test_backup_created_on_first_write(hub, tmp_path): def test_client_unavailable(): - from jira_hub.core.client import HubUnavailable + from sdlc_hub.core.client import HubUnavailable with pytest.raises(HubUnavailable): HubClient("http://127.0.0.1:9", "t", timeout=1).get("/issues") @@ -1059,7 +1059,7 @@ ATT_NAME = "скрин 1.png" def _seed_attachment(hub, mime="image/png", size=3, name=ATT_NAME): conn = hub["hub"].conn() try: - from jira_hub.modules.jira import mirror + from sdlc_hub.modules.jira import mirror mirror.apply_state(conn, [{"key": "A-1", "summary": "т", "labels": [], "attachments": [ {"id": "31337", "filename": name, "size": size, "mime_type": mime, "created": "2026-10-20T10:00:00.000+0300"}]}], []) finally: diff --git a/tests/test_attachments.py b/tests/test_attachments.py index a66812a..8018dea 100644 --- a/tests/test_attachments.py +++ b/tests/test_attachments.py @@ -2,8 +2,8 @@ import json import pytest -from jira_hub.core import db, files -from jira_hub.modules.jira import attachments, mirror +from sdlc_hub.core import db, files +from sdlc_hub.modules.jira import attachments, mirror def test_fmt_size(): diff --git a/tests/test_baseline.py b/tests/test_baseline.py index 4cbdae4..f14e913 100644 --- a/tests/test_baseline.py +++ b/tests/test_baseline.py @@ -5,8 +5,8 @@ import json import os from pathlib import Path -from jira_hub.core import api, db, settings -from jira_hub.modules.jira import mcp as jira_mcp +from sdlc_hub.core import api, db, settings +from sdlc_hub.modules.jira import mcp as jira_mcp FIXTURES = Path(__file__).parent / "fixtures" / "baseline" @@ -27,7 +27,7 @@ def test_mcp_tools(): def test_mcp_wiki_tools(): - from jira_hub.modules.wiki import mcp as wiki_mcp + from sdlc_hub.modules.wiki import mcp as wiki_mcp tools = asyncio.run(wiki_mcp.mcp.list_tools()) _check("mcp_wiki_tools", [{"name": t.name, "description": t.description, "inputSchema": t.inputSchema} for t in tools]) diff --git a/tests/test_cli.py b/tests/test_cli.py index ebe21ab..686cc8a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,13 +2,13 @@ import json import os from pathlib import Path -from jira_hub.core import api, cli, paths, release, settings +from sdlc_hub.core import api, cli, paths, release, settings FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json" def test_init_import_tokens(tmp_path, monkeypatch, capsys, open_db): - monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) + monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "home")) assert cli.main(["init", "--config", str(FIXTURE)]) == 0 out = capsys.readouterr().out assert "transitions_map" in out and "агент jira: " in out @@ -31,7 +31,7 @@ def test_init_import_tokens(tmp_path, monkeypatch, capsys, open_db): def test_agent_publish(tmp_path, monkeypatch, capsys): - monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) + monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "home")) src = tmp_path / "agent.py" src.write_text("AGENT_VERSION = 6\n", encoding="utf-8") assert cli.main(["agent", "publish", str(src)]) == 0 @@ -44,7 +44,7 @@ def test_agent_publish(tmp_path, monkeypatch, capsys): def test_agent_publish_write_failure(tmp_path, monkeypatch, capsys): """Диск полон при записи манифеста: сообщение и код 1 без трейсбека, опубликованной остаётся v8 (M-1 #25).""" - monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) + monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "home")) src = tmp_path / "agent.py" src.write_text("AGENT_VERSION = 8\n", encoding="utf-8") assert cli.main(["agent", "publish", str(src)]) == 0 @@ -67,8 +67,8 @@ def test_agent_publish_write_failure(tmp_path, monkeypatch, capsys): def test_mcp_default_module(monkeypatch): - from jira_hub.core import mcp as core_mcp - from jira_hub.modules.jira import mcp as jira_mcp + from sdlc_hub.core import mcp as core_mcp + from sdlc_hub.modules.jira import mcp as jira_mcp seen = [] monkeypatch.setattr(core_mcp, "PROJECT", None) monkeypatch.setattr(jira_mcp.mcp, "run", lambda transport="stdio": seen.append((transport, core_mcp.PROJECT))) @@ -82,7 +82,7 @@ def test_mcp_default_module(monkeypatch): def test_mcp_unknown_module(monkeypatch, capsys): - from jira_hub.modules.jira import mcp as jira_mcp + from sdlc_hub.modules.jira import mcp as jira_mcp seen = [] monkeypatch.setattr(jira_mcp.mcp, "run", lambda transport="stdio": seen.append(transport)) assert cli.main(["mcp", "nope"]) == 2 @@ -93,7 +93,7 @@ def test_mcp_unknown_module(monkeypatch, capsys): def test_agent_publish_migrates_old_layout(tmp_path, monkeypatch, capsys): """M-3: корневые agent.py/manifest.json прежней раскладки переезжают в release/jira/ до проверки «только вверх».""" - monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path)) + monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path)) rel = tmp_path / "release" rel.mkdir() old = tmp_path / "old.py" diff --git a/tests/test_commands.py b/tests/test_commands.py index 0e30b7c..b047460 100644 --- a/tests/test_commands.py +++ b/tests/test_commands.py @@ -1,7 +1,7 @@ import pytest -from jira_hub.modules.jira import commands, mirror -from jira_hub.modules.jira.commands import CommandError +from sdlc_hub.modules.jira import commands, mirror +from sdlc_hub.modules.jira.commands import CommandError NOW = "strftime('%Y-%m-%dT%H:%M:%S.000+0300', 'now')" diff --git a/tests/test_db.py b/tests/test_db.py index 52872d7..92ecfe9 100644 --- a/tests/test_db.py +++ b/tests/test_db.py @@ -7,8 +7,8 @@ import threading import pytest -from jira_hub.core import api, backup, db, paths, projects, queue, settings -from jira_hub.modules.jira import history +from sdlc_hub.core import api, backup, db, paths, projects, queue, settings +from sdlc_hub.modules.jira import history def test_connect_creates_schema(open_db): @@ -155,7 +155,7 @@ def test_data_dir_is_private(tmp_path): def test_existing_backups_dir_made_private_on_start(tmp_path): - from jira_hub.core import api + from sdlc_hub.core import api backups = tmp_path / "backups" backups.mkdir() backups.chmod(0o755) diff --git a/tests/test_e2e_hub.py b/tests/test_e2e_hub.py index 4953ae1..0a2ddb6 100644 --- a/tests/test_e2e_hub.py +++ b/tests/test_e2e_hub.py @@ -2,8 +2,8 @@ import threading import pytest -from jira_hub.core import api, settings -from jira_hub.core.client import HubClient, HubError +from sdlc_hub.core import api, settings +from sdlc_hub.core.client import HubClient, HubError @pytest.fixture diff --git a/tests/test_files.py b/tests/test_files.py index bbdf65a..07f536f 100644 --- a/tests/test_files.py +++ b/tests/test_files.py @@ -7,7 +7,7 @@ import time import pytest -from jira_hub.core import db, files, settings +from sdlc_hub.core import db, files, settings ABC = hashlib.sha256(b"abc").hexdigest() # ba7816bf… DAY = 86400 diff --git a/tests/test_history.py b/tests/test_history.py index 5ab7e6b..dcb57c2 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -1,6 +1,6 @@ import pytest -from jira_hub.modules.jira import history +from sdlc_hub.modules.jira import history ITEM = {"history_id": "5001", "seq": 0, "key": "X", "created": "2026-10-22T09:00:00.000+0300", "author": "Петров Пётр", "field_id": "status", "field_name": "status", "from": "3", "from_string": "В работе", "to": "10001", @@ -29,7 +29,7 @@ def test_record_keeps_first_version(conn): def test_record_uses_field_settings(conn): - from jira_hub.core import settings + from sdlc_hub.core import settings settings.set_(conn, "field_ids", {"planned_end": "customfield_10201"}) history.record_changes(conn, [{**ITEM, "field_id": "customfield_10201", "field_name": "Плановое окончание"}]) assert conn.execute("SELECT field FROM issue_change").fetchone()["field"] == "planned_end" @@ -92,7 +92,7 @@ def test_feed_closed_in_two_hours(conn): # сце def test_feed_comment(conn): # «Комментарий в ленте» - from jira_hub.modules.jira import mirror + from sdlc_hub.modules.jira import mirror mirror.apply_state(conn, [{"key": "X", "labels": ["AWPCA"], "comments": [ {"id": "7", "author": "Иванов", "body": "Готово к проверке", "created": "2026-10-22T13:30:00.000+0300"}]}], []) got = history.feed(conn, "2026-10-22T10:00:00+00:00", "2026-10-22T12:00:00+00:00", history.DEFAULT_KINDS) @@ -163,7 +163,7 @@ def test_parse_kinds_unknown(): def test_issue_changes(conn): # «Задача с историей» - from jira_hub.modules.jira import mirror + from sdlc_hub.modules.jira import mirror _issue(conn, "X", ["AWPCA"]) _change(conn, "X", "1", "2026-10-21T11:00:00Z", "status", "a", "b") _change(conn, "X", "2", "2026-10-22T11:00:00Z", "description", "c", "d", field_id="description") diff --git a/tests/test_importer.py b/tests/test_importer.py index 949919b..f26b5ac 100644 --- a/tests/test_importer.py +++ b/tests/test_importer.py @@ -2,8 +2,8 @@ import csv import pytest -from jira_hub.core import settings -from jira_hub.modules.jira import importer, mirror +from sdlc_hub.core import settings +from sdlc_hub.modules.jira import importer, mirror @pytest.fixture diff --git a/tests/test_layout.py b/tests/test_layout.py index d3bf289..79131d5 100644 --- a/tests/test_layout.py +++ b/tests/test_layout.py @@ -3,21 +3,34 @@ import subprocess import sys from pathlib import Path -import jira_hub +import sdlc_hub def test_fresh_imports(): r = subprocess.run( - [sys.executable, "-c", "import jira_hub.core.api, jira_hub.core.mcp, jira_hub.modules.jira.mcp, jira_hub.cli"], + [sys.executable, "-c", "import sdlc_hub.core.api, sdlc_hub.core.mcp, sdlc_hub.modules.jira.mcp, sdlc_hub.cli"], capture_output=True, text=True) assert r.returncode == 0, r.stderr def test_web_app_path(): - from jira_hub.core import api - assert api.WEB_APP == Path(jira_hub.__file__).parent / "web" / "app" + from sdlc_hub.core import api + assert api.WEB_APP == Path(sdlc_hub.__file__).parent / "web" / "app" def test_hub_root(): - from jira_hub.core import mcp + from sdlc_hub.core import mcp assert (mcp.HUB_ROOT / "pyproject.toml").is_file() + + +def test_default_home(monkeypatch, tmp_path): + from sdlc_hub.core import paths + monkeypatch.delenv("SDLC_HUB_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + assert paths.home() == tmp_path / ".local" / "share" / "sdlc-hub" + + +def test_env_home(monkeypatch, tmp_path): + from sdlc_hub.core import paths + monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "x")) + assert paths.home() == tmp_path / "x" diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 67b000c..1d17711 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -9,11 +9,11 @@ from types import SimpleNamespace import pytest -from jira_hub.core import api, files, settings -from jira_hub.core import mcp as core_mcp -from jira_hub.modules.jira import mcp as jmcp -from jira_hub.modules.jira import commands -from jira_hub.core.client import HubClient +from sdlc_hub.core import api, files, settings +from sdlc_hub.core import mcp as core_mcp +from sdlc_hub.modules.jira import mcp as jmcp +from sdlc_hub.modules.jira import commands +from sdlc_hub.core.client import HubClient @pytest.fixture @@ -700,7 +700,7 @@ class FakeHub: @pytest.fixture def downloads(tmp_path, monkeypatch): - monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) + monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "home")) monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA") monkeypatch.setattr(jmcp, "POLL_SECONDS", 0.05) return tmp_path / "home" / "downloads" @@ -819,7 +819,7 @@ def test_attachment_failed(downloads, monkeypatch): def test_attachment_413(downloads, monkeypatch): - from jira_hub.core.client import HubError + from sdlc_hub.core.client import HubError att = _att("5", "запись.mp4", 26214400) hub = FakeHub(_detail(key=ATT_KEY, attachments=[att]), fetch=HubError(413, "too_large", "большой")) _use(monkeypatch, hub) @@ -828,7 +828,7 @@ def test_attachment_413(downloads, monkeypatch): def test_attachment_409_passes_hub_text(downloads, monkeypatch): - from jira_hub.core.client import HubError + from sdlc_hub.core.client import HubError hub = FakeHub(_detail(key=ATT_KEY, attachments=[_att("5", "a.png", 1)]), fetch=HubError(409, "agent_too_old", "агент v9 не умеет скачивать вложения — нужен v14")) _use(monkeypatch, hub) diff --git a/tests/test_mirror.py b/tests/test_mirror.py index fe33e17..96c2aff 100644 --- a/tests/test_mirror.py +++ b/tests/test_mirror.py @@ -1,7 +1,7 @@ import pytest -from jira_hub.core import db -from jira_hub.modules.jira import mirror +from sdlc_hub.core import db +from sdlc_hub.modules.jira import mirror @pytest.fixture diff --git a/tests/test_project_fields.py b/tests/test_project_fields.py index a83b9c4..09d3e2c 100644 --- a/tests/test_project_fields.py +++ b/tests/test_project_fields.py @@ -1,10 +1,10 @@ """Поля проекта от модулей; доски — в модуле jira (wiki-module, решение 8, спеки modules/data-model/hub-api).""" import pytest -from jira_hub.core import db, mcp as core_mcp, registry -from jira_hub.core.client import HubError -from jira_hub.core.modules import Module, ProjectField -from jira_hub.modules.jira import mirror +from sdlc_hub.core import db, mcp as core_mcp, registry +from sdlc_hub.core.client import HubError +from sdlc_hub.core.modules import Module, ProjectField +from sdlc_hub.modules.jira import mirror from v6db import make_v6_db @@ -80,7 +80,7 @@ def test_editable_field_needs_updater(): def test_migrate_v6_idempotent(tmp_path): - from jira_hub.modules.jira import projects as jira_projects + from sdlc_hub.modules.jira import projects as jira_projects make_v6_db(tmp_path / "hub.sqlite") conn = db.connect(tmp_path / "hub.sqlite") try: @@ -168,9 +168,9 @@ def two_modules(monkeypatch, open_db): """Реестр [jira, stub]: stub объявляет изменяемое поле root, хранит его в setting и ставит пакеты-пустышки.""" import json import re - from jira_hub.core import api, queue - from jira_hub.core.http import ApiError - from jira_hub.modules.jira import MODULE as JIRA + from sdlc_hub.core import api, queue + from sdlc_hub.core.http import ApiError + from sdlc_hub.modules.jira import MODULE as JIRA def check_root(v): if not isinstance(v, str) or not v: diff --git a/tests/test_projects.py b/tests/test_projects.py index dee6f9c..e06691c 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -1,8 +1,8 @@ import pytest -from jira_hub.core import api, projects, settings -from jira_hub.modules.jira import mirror -from jira_hub.modules.jira import projects as jira_projects +from sdlc_hub.core import api, projects, settings +from sdlc_hub.modules.jira import mirror +from sdlc_hub.modules.jira import projects as jira_projects @pytest.fixture diff --git a/tests/test_queue.py b/tests/test_queue.py index 30211e2..e587a36 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -2,12 +2,12 @@ import hashlib import pytest -from jira_hub.core import api, db, files, queue, settings -from jira_hub.modules.jira import attachments, commands, mirror, refresh -from jira_hub.modules.jira import fetch as jira_fetch -from jira_hub.modules.jira import projects as jira_projects -from jira_hub.modules.jira.commands import CommandError -from jira_hub.core.queue import ClaimedError +from sdlc_hub.core import api, db, files, queue, settings +from sdlc_hub.modules.jira import attachments, commands, mirror, refresh +from sdlc_hub.modules.jira import fetch as jira_fetch +from sdlc_hub.modules.jira import projects as jira_projects +from sdlc_hub.modules.jira.commands import CommandError +from sdlc_hub.core.queue import ClaimedError LT = {"связанные": "ASUPR_Связанные"} @@ -210,7 +210,7 @@ def test_no_backup_for_refresh_batch(conn): def test_make_backup_rotates(tmp_path, open_db): - from jira_hub.core import backup + from sdlc_hub.core import backup conn = open_db(tmp_path / "hub.sqlite") for i in range(4): backup.make_backup(conn, tmp_path / "backups", f"b{i}", keep=2) @@ -221,7 +221,7 @@ def test_make_backup_rotates(tmp_path, open_db): def test_rotation_keeps_schema_migration_copy(tmp_path, open_db): """Копия базы перед миграцией схемы не участвует в ротации копий пакетов (M-1 финального ревью).""" - from jira_hub.core import backup + from sdlc_hub.core import backup conn = open_db(tmp_path / "hub.sqlite") backup.make_backup(conn, tmp_path / "backups", "schema-v1", keep=0) for i in range(4): diff --git a/tests/test_queue_core.py b/tests/test_queue_core.py index e882ccb..a8cefc5 100644 --- a/tests/test_queue_core.py +++ b/tests/test_queue_core.py @@ -5,10 +5,10 @@ from dataclasses import replace import pytest -from jira_hub.core import files, queue, registry -from jira_hub.core.errors import CommandError -from jira_hub.core.modules import CommandHooks, Module -from jira_hub.modules import jira +from sdlc_hub.core import files, queue, registry +from sdlc_hub.core.errors import CommandError +from sdlc_hub.core.modules import CommandHooks, Module +from sdlc_hub.modules import jira SHA = "a" * 64 diff --git a/tests/test_registry.py b/tests/test_registry.py index c9f9ad5..5a4534d 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -5,10 +5,10 @@ from pathlib import Path import pytest -from jira_hub.core import registry -from jira_hub.core.modules import CommandHooks, Module +from sdlc_hub.core import registry +from sdlc_hub.core.modules import CommandHooks, Module -CORE = Path(__file__).resolve().parent.parent / "src" / "jira_hub" / "core" +CORE = Path(__file__).resolve().parent.parent / "src" / "sdlc_hub" / "core" # временно: R-1, список только сокращается; после Task 6 пуст PENDING: set[str] = set() @@ -50,7 +50,7 @@ def test_route_conflict(): def test_routes_assembled(): - from jira_hub.core import api + from sdlc_hub.core import api pairs = lambda routes: {(m, p) for m, p, _, _ in routes} # noqa: E731 mods = tuple(r for m in registry.modules() for r in m.routes) assert pairs(api.ROUTES) == pairs(api.CORE_ROUTES) | pairs(mods) @@ -65,18 +65,18 @@ def test_module_of(monkeypatch): def _module_imports(tree, top_level_only): - """Импорты пакета jira_hub.modules (абсолютные и относительные из core/).""" + """Импорты пакета sdlc_hub.modules (абсолютные и относительные из core/).""" found = [] for n in (tree.body if top_level_only else ast.walk(tree)): if isinstance(n, ast.Import): names = [a.name for a in n.names] elif isinstance(n, ast.ImportFrom): - prefix = {0: "", 1: "jira_hub.core.", 2: "jira_hub."}[n.level] + prefix = {0: "", 1: "sdlc_hub.core.", 2: "sdlc_hub."}[n.level] base = (prefix + (n.module or "")).rstrip(".") names = [base] + [base + "." + a.name for a in n.names] else: continue - if any(x == "jira_hub.modules" or x.startswith("jira_hub.modules.") for x in names): + if any(x == "sdlc_hub.modules" or x.startswith("sdlc_hub.modules.") for x in names): found.append(n) return found diff --git a/tests/test_relay.py b/tests/test_relay.py index a351d4e..1d4792d 100644 --- a/tests/test_relay.py +++ b/tests/test_relay.py @@ -8,8 +8,8 @@ from datetime import datetime, timedelta, timezone import pytest -from jira_hub.core import api, relay, settings -from jira_hub.core.client import HubClient +from sdlc_hub.core import api, relay, settings +from sdlc_hub.core.client import HubClient @pytest.fixture diff --git a/tests/test_release.py b/tests/test_release.py index 8044cd5..d98abe4 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -6,7 +6,7 @@ from pathlib import Path import pytest -from jira_hub.core import release +from sdlc_hub.core import release def agent_file(tmp_path, version, body="print('x')\n", name="agent.py"): diff --git a/tests/test_settings.py b/tests/test_settings.py index 70a82fb..337bf24 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -3,9 +3,9 @@ from pathlib import Path import pytest -from jira_hub.core import queue, settings -from jira_hub.modules.jira import settings as jira_settings -from jira_hub.modules.jira.commands import CommandError +from sdlc_hub.core import queue, settings +from sdlc_hub.modules.jira import settings as jira_settings +from sdlc_hub.modules.jira.commands import CommandError FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json" diff --git a/tests/test_web_app.py b/tests/test_web_app.py index c4db10b..7f55cb5 100644 --- a/tests/test_web_app.py +++ b/tests/test_web_app.py @@ -5,7 +5,7 @@ from urllib.parse import urlparse import pytest -from jira_hub.core import api +from sdlc_hub.core import api def _get(base, path): @@ -100,7 +100,7 @@ def test_other_paths_are_not_app(app_hub): def test_default_web_dir_is_package_build(tmp_path): h = api.Hub(tmp_path / "hub.sqlite", backups_dir=tmp_path / "backups") assert h.web_dir == api.WEB_APP - assert api.WEB_APP.parts[-3:] == ("jira_hub", "web", "app") + assert api.WEB_APP.parts[-3:] == ("sdlc_hub", "web", "app") @pytest.mark.parametrize("path", ["/", "/links"]) diff --git a/tests/test_wiki_api.py b/tests/test_wiki_api.py index a1b2151..055061d 100644 --- a/tests/test_wiki_api.py +++ b/tests/test_wiki_api.py @@ -8,8 +8,8 @@ from urllib.parse import quote import pytest -from jira_hub.core import files -from jira_hub.core.client import HubError +from sdlc_hub.core import files +from sdlc_hub.core.client import HubError from test_wiki_convert import FILE_REF, FILE_URL, THUMB_REF, _html from test_wiki_sync import CODES, TREE, api, first_sync, page # noqa: F401 — api — фикстура diff --git a/tests/test_wiki_commands.py b/tests/test_wiki_commands.py index 3804f95..70497be 100644 --- a/tests/test_wiki_commands.py +++ b/tests/test_wiki_commands.py @@ -3,10 +3,10 @@ from datetime import timedelta import pytest -from jira_hub.core import db, queue, settings -from jira_hub.core.errors import CommandError -from jira_hub.modules.jira import refresh -from jira_hub.modules.wiki import commands, settings as wiki_settings, tick +from sdlc_hub.core import db, queue, settings +from sdlc_hub.core.errors import CommandError +from sdlc_hub.modules.jira import refresh +from sdlc_hub.modules.wiki import commands, settings as wiki_settings, tick @pytest.fixture diff --git a/tests/test_wiki_convert.py b/tests/test_wiki_convert.py index 632d60c..ab3759e 100644 --- a/tests/test_wiki_convert.py +++ b/tests/test_wiki_convert.py @@ -2,7 +2,7 @@ import re from pathlib import Path -from jira_hub.modules.wiki import convert +from sdlc_hub.modules.wiki import convert FIX = Path(__file__).parent / "fixtures" / "wiki" FILE_URL = "https://eva.example.test/files/obj/CmfDocument/CmfDocument%3Aa08/CmfDocument%3A3f2a9c1e-0b7d-4e8a-9c21-5d6e7f8a9b0c/model.xlsx?version=1" diff --git a/tests/test_wiki_mcp.py b/tests/test_wiki_mcp.py index ada9cb4..d316fd7 100644 --- a/tests/test_wiki_mcp.py +++ b/tests/test_wiki_mcp.py @@ -3,10 +3,10 @@ import asyncio import pytest -from jira_hub.core import mcp as core_mcp -from jira_hub.core import registry -from jira_hub.core.client import HubError -from jira_hub.modules.wiki import mcp as wmcp +from sdlc_hub.core import mcp as core_mcp +from sdlc_hub.core import registry +from sdlc_hub.core.client import HubError +from sdlc_hub.modules.wiki import mcp as wmcp PROJECTS = {"projects": [ {"label": "AWPCA", "name": "АРМ КА", "wiki_root": "DOC-1", "wiki_root_title": "Корень", "wiki_pages": 3, @@ -50,7 +50,7 @@ class Fake: @pytest.fixture def hub(monkeypatch, tmp_path): - monkeypatch.setenv("JIRA_HUB_HOME", str(tmp_path / "home")) + monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "home")) monkeypatch.setattr(wmcp, "POLL_SECONDS", 0.01) monkeypatch.setattr(core_mcp, "PROJECT", "AWPCA") diff --git a/tests/test_wiki_projects.py b/tests/test_wiki_projects.py index 48d3f6c..d8a053b 100644 --- a/tests/test_wiki_projects.py +++ b/tests/test_wiki_projects.py @@ -3,8 +3,8 @@ import json import pytest -from jira_hub.core.client import HubError -from jira_hub.modules.wiki.projects import parse_root +from sdlc_hub.core.client import HubError +from sdlc_hub.modules.wiki.projects import parse_root def test_parse_root(): @@ -138,7 +138,7 @@ def test_wiki_mcp_imports_no_db_or_queue(): """M-5: mcp.py и roots.py вики не импортируют core.db и core.queue ни прямо, ни через wiki.projects.""" import ast from pathlib import Path - base = Path(__file__).parent.parent / "src" / "jira_hub" / "modules" / "wiki" + base = Path(__file__).parent.parent / "src" / "sdlc_hub" / "modules" / "wiki" for name in ("mcp.py", "roots.py"): tree = ast.parse((base / name).read_text(encoding="utf-8")) mods = [] diff --git a/tests/test_wiki_sync.py b/tests/test_wiki_sync.py index 6469759..8f4c1f0 100644 --- a/tests/test_wiki_sync.py +++ b/tests/test_wiki_sync.py @@ -8,9 +8,9 @@ from types import SimpleNamespace import pytest -from jira_hub.core import db, queue, settings -from jira_hub.core.client import HubClient, HubError -from jira_hub.modules.wiki import commands, convert, projects, search, stage, sync, tick +from sdlc_hub.core import db, queue, settings +from sdlc_hub.core.client import HubClient, HubError +from sdlc_hub.modules.wiki import commands, convert, projects, search, stage, sync, tick def page(code, title, parent=None, orderno=None, html=None, attachments=None, links=None): @@ -625,6 +625,6 @@ def test_lone_surrogate_in_page_does_not_break_sync(api): def test_dir_name_escapes_leading_dot(): """M-14: метка с ведущей точкой не даёт каталог вида .X.new.5 / .X.old, который уборка при старте сочла бы своим.""" - from jira_hub.modules.wiki import paths + from sdlc_hub.modules.wiki import paths assert paths.dir_name(".X.new.5") == "%2EX.new.5" and paths.dir_name(".") == "%2E" and paths.dir_name("..") == "%2E." assert not paths.dir_name(".X.old").startswith(".") and paths.dir_name("A.B") == "A.B" diff --git a/uv.lock b/uv.lock index 8119aaa..3ad5894 100644 --- a/uv.lock +++ b/uv.lock @@ -277,31 +277,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" }, ] -[[package]] -name = "jira-hub" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "beautifulsoup4" }, - { name = "markdownify" }, - { name = "mcp" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pytest" }, -] - -[package.metadata] -requires-dist = [ - { name = "beautifulsoup4", specifier = ">=4.12" }, - { name = "markdownify", specifier = ">=1.2.3,<2" }, - { name = "mcp", specifier = ">=1.2,<2" }, -] - -[package.metadata.requires-dev] -dev = [{ name = "pytest", specifier = ">=8" }] - [[package]] name = "jsonschema" version = "4.26.0" @@ -677,6 +652,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/e2/91f70d804c61f8eac39a417e82aa1024f655ab9e8bdd7393b196242bc41b/rpds_py-2026.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:fe5ad0664ec772b02c45859041aa17655709cced7a31005817fbbbd988c25567", size = 223037, upload-time = "2026-10-04T16:31:40.468Z" }, ] +[[package]] +name = "sdlc-hub" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "markdownify" }, + { name = "mcp" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "beautifulsoup4", specifier = ">=4.12" }, + { name = "markdownify", specifier = ">=1.2.3,<2" }, + { name = "mcp", specifier = ">=1.2,<2" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8" }] + [[package]] name = "six" version = "1.17.0" diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 61997b0..4ce221d 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -17,6 +17,6 @@ export default defineConfig({ stdout: "pipe", stderr: "pipe", timeout: 120_000, - env: { JIRA_HUB_HOME: HUB_HOME, PORT: String(HUB_PORT) }, + env: { SDLC_HUB_HOME: HUB_HOME, PORT: String(HUB_PORT) }, }, }); diff --git a/web/scripts/seed-hub.py b/web/scripts/seed-hub.py index e91ab7d..b18ebee 100644 --- a/web/scripts/seed-hub.py +++ b/web/scripts/seed-hub.py @@ -5,9 +5,9 @@ import json import sys from pathlib import Path -from jira_hub.core import db, paths, settings -from jira_hub.modules.jira import mirror -from jira_hub.modules.wiki import paths as wiki_paths, search as wiki_search, sync as wiki_sync +from sdlc_hub.core import db, paths, settings +from sdlc_hub.modules.jira import mirror +from sdlc_hub.modules.wiki import paths as wiki_paths, search as wiki_search, sync as wiki_sync snap = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) extras_path = Path(__file__).with_name("seed-extras.json") diff --git a/web/scripts/test-hub.sh b/web/scripts/test-hub.sh index 264c493..e1ae05c 100644 --- a/web/scripts/test-hub.sh +++ b/web/scripts/test-hub.sh @@ -1,23 +1,23 @@ #!/usr/bin/env bash -# Тестовый хаб для разработки фронта и e2e: свой JIRA_HUB_HOME (по умолчанию web/.e2e-hub, очищается), порт 8791, -# зеркало — фикстура hub-basic. Живой хаб (:8790, ~/.local/share/jira-hub) не трогается. +# Тестовый хаб для разработки фронта и e2e: свой SDLC_HUB_HOME (по умолчанию web/.e2e-hub, очищается), порт 8791, +# зеркало — фикстура hub-basic. Живой хаб (:8790, ~/.local/share/sdlc-hub) не трогается. set -euo pipefail WEB=$(cd "$(dirname "$0")/.." && pwd) ROOT=$(cd "$WEB/.." && pwd) -export JIRA_HUB_HOME="${JIRA_HUB_HOME:-$WEB/.e2e-hub}" +export SDLC_HUB_HOME="${SDLC_HUB_HOME:-$WEB/.e2e-hub}" PORT="${PORT:-8791}" # держать равным HUB_PORT в playwright.config.ts -case "$JIRA_HUB_HOME" in "$HOME/.local/share/jira-hub"*) echo "отказ: это каталог живого хаба" >&2; exit 2;; esac +case "$SDLC_HUB_HOME" in "$HOME/.local/share/sdlc-hub"*) echo "отказ: это каталог живого хаба" >&2; exit 2;; esac # чистим только каталог внутри web/.e2e-hub (T15.5): чужой путь из переменной окружения не удаляем -case "$JIRA_HUB_HOME" in - *..*) echo "отказ: JIRA_HUB_HOME=$JIRA_HUB_HOME содержит «..», очистка не выполняется" >&2; exit 2;; # F-1 +case "$SDLC_HUB_HOME" in + *..*) echo "отказ: SDLC_HUB_HOME=$SDLC_HUB_HOME содержит «..», очистка не выполняется" >&2; exit 2;; # F-1 "$WEB/.e2e-hub"|"$WEB/.e2e-hub/"*) ;; - *) echo "отказ: JIRA_HUB_HOME=$JIRA_HUB_HOME вне $WEB/.e2e-hub, очистка не выполняется" >&2; exit 2;; + *) echo "отказ: SDLC_HUB_HOME=$SDLC_HUB_HOME вне $WEB/.e2e-hub, очистка не выполняется" >&2; exit 2;; esac if lsof -nP -iTCP:"$PORT" -sTCP:LISTEN >/dev/null 2>&1; then echo "отказ: порт $PORT занят — остановите прежний тестовый хаб (lsof -iTCP:$PORT -sTCP:LISTEN)" >&2; exit 3 fi -rm -rf "$JIRA_HUB_HOME" +rm -rf "$SDLC_HUB_HOME" cd "$ROOT" -uv run jira-hub init --config tests/fixtures/config.example.json --force >/dev/null +uv run sdlc-hub init --config tests/fixtures/config.example.json --force >/dev/null uv run python web/scripts/seed-hub.py web/src/modules/jira/board/__fixtures__/hub-basic.board.json -exec uv run jira-hub serve --host 127.0.0.1 --port "$PORT" +exec uv run sdlc-hub serve --host 127.0.0.1 --port "$PORT" diff --git a/web/vite.config.ts b/web/vite.config.ts index e87bd03..8021a41 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -2,13 +2,13 @@ import react from "@vitejs/plugin-react"; import { defineConfig } from "vitest/config"; // сервер разработки: приложение под /app/, маршруты API — прокси на хаб (по умолчанию тестовый :8791, R-5) -const hub = process.env.JIRA_HUB_URL ?? "http://127.0.0.1:8791"; // порт держать равным HUB_PORT в playwright.config.ts +const hub = process.env.SDLC_HUB_URL ?? "http://127.0.0.1:8791"; // порт держать равным HUB_PORT в playwright.config.ts const API = ["/board", "/issues", "/settings", "/agent", "/refresh", "/batches", "/commands", "/sync-runs"]; export default defineConfig({ base: "/app/", plugins: [react()], - build: { outDir: "../src/jira_hub/web/app", emptyOutDir: true }, + build: { outDir: "../src/sdlc_hub/web/app", emptyOutDir: true }, server: { host: "127.0.0.1", port: 5173, proxy: Object.fromEntries(API.map((p) => [p, { target: hub }])) }, test: { environment: "jsdom", -- 2.47.2 From e34d61a4835669f6a443b9ecdb7f483b3b0ede47 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 11:33:18 +0400 Subject: [PATCH 05/21] =?UTF-8?q?spec(sdlc-rename):=20R-2=20=D0=B2=20desig?= =?UTF-8?q?n.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- openspec/changes/sdlc-rename/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/sdlc-rename/design.md b/openspec/changes/sdlc-rename/design.md index 121a2e4..7004073 100644 --- a/openspec/changes/sdlc-rename/design.md +++ b/openspec/changes/sdlc-rename/design.md @@ -105,3 +105,4 @@ | 10 | 24.10.2026 | Агенты без релиза, документация — мелкими PR | протокол не меняется | строки документации в `agent.py` со старым именем до следующего релиза | | 11 | 24.10.2026 | Сначала переименование, потом #98 | #98 перебазируется на новые пути | перебазирование веток | | 12 | 24.10.2026 | R-1: трейлер коммитов `Co-Authored-By: Claude Sonnet 5.5` вместо `Opus 5.5` из плана | указание системы сессии новее плана | правка трейлера | +| 13 | 24.10.2026 | R-2: правки web/playwright.config.ts, web/scripts/seed-hub.py и JIRA_HUB_URL→SDLC_HUB_URL (vite.config.ts) сделаны уже в Task 1 | без них e2e, test-hub.sh и seed-скрипт ломаются между задачами | откат трёх файлов | -- 2.47.2 From 08460dc281efffb883d150803c32f2eb2e02b050 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 11:45:40 +0400 Subject: [PATCH 06/21] =?UTF-8?q?feat(sdlc-rename):=20MCP-=D1=81=D0=B5?= =?UTF-8?q?=D1=80=D0=B2=D0=B5=D1=80=D1=8B=20sdlc-jira=20=D0=B8=20sdlc-wiki?= =?UTF-8?q?,=20=D0=BC=D0=BE=D0=B4=D1=83=D0=BB=D1=8C=20=D0=B2=20sdlc-hub=20?= =?UTF-8?q?mcp=20=D0=BE=D0=B1=D1=8F=D0=B7=D0=B0=D1=82=D0=B5=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/sdlc_hub/core/cli.py | 4 ++-- src/sdlc_hub/core/mcp.py | 17 +++++++++-------- src/sdlc_hub/modules/jira/mcp.py | 2 +- src/sdlc_hub/modules/wiki/mcp.py | 6 +++--- .../baseline/mcp_wiki_tools.expected.json | 2 +- tests/test_cli.py | 17 ++++++++--------- tests/test_mcp.py | 10 +++++++--- tests/test_project_fields.py | 9 +++++---- tests/test_wiki_mcp.py | 4 ++-- 9 files changed, 38 insertions(+), 33 deletions(-) diff --git a/src/sdlc_hub/core/cli.py b/src/sdlc_hub/core/cli.py index 49a3447..761b364 100644 --- a/src/sdlc_hub/core/cli.py +++ b/src/sdlc_hub/core/cli.py @@ -17,8 +17,8 @@ def main(argv=None) -> int: p = sub.add_parser("serve", help="запустить HTTP API") p.add_argument("--host", default="0.0.0.0") p.add_argument("--port", type=int) - p = sub.add_parser("mcp", help="MCP-сервер модуля по stdio (по умолчанию jira)") - p.add_argument("module", nargs="?", default="jira", help="имя модуля; неизвестное — код 2 (проверка в mcp.run)") + p = sub.add_parser("mcp", help="MCP-сервер модуля по stdio ") + p.add_argument("module", nargs="?", default=None, help="имя модуля (jira, wiki); без модуля или неизвестный — код 2") p.add_argument("--project", help="метка проекта, к которому привязана сессия") for m in registry.modules(): if m.cli_register: diff --git a/src/sdlc_hub/core/mcp.py b/src/sdlc_hub/core/mcp.py index 287986e..56bb161 100644 --- a/src/sdlc_hub/core/mcp.py +++ b/src/sdlc_hub/core/mcp.py @@ -25,13 +25,10 @@ def unknown_project(label) -> str: return f"проект {label} не заведён в хабе: project_create или исправьте --project в .mcp.json" -def mcp_json(label: str, module: str = registry.DEFAULT_AGENT) -> dict: - """Фрагмент .mcp.json для репозитория проекта: у модуля по умолчанию прежний (сервер jira-hub), у прочих модулей — - сервер jira-hub-<модуль>.""" - run = ["run", "--project", str(HUB_ROOT), "sdlc-hub", "mcp"] - if module == registry.DEFAULT_AGENT: - return {"mcpServers": {"jira-hub": {"command": "uv", "args": [*run, "--project", label]}}} - return {"mcpServers": {f"jira-hub-{module}": {"command": "uv", "args": [*run, module, "--project", label]}}} +def mcp_json(label: str, module: str) -> dict: + """Фрагмент .mcp.json для репозитория проекта: сервер sdlc-<модуль>, запуск `sdlc-hub mcp <модуль>`.""" + run = ["run", "--project", str(HUB_ROOT), "sdlc-hub", "mcp", module, "--project", label] + return {"mcpServers": {f"sdlc-{module}": {"command": "uv", "args": run}}} def client() -> HubClient: @@ -199,9 +196,13 @@ def register_core_tools(mcp, *, projects_doc: str, columns: list[tuple[str, Call mcp.tool()(project_create) -def run(module_name: str, project: str | None = None) -> int: +def run(module_name: str | None, project: str | None = None) -> int: """Запустить MCP-сервер модуля по stdio. Неизвестный модуль: список модулей в stderr, код 2, сервер не стартует.""" global PROJECT + if module_name is None: + names = ", ".join(x.name for x in registry.modules() if x.mcp_server) + print(f"укажите модуль: sdlc-hub mcp <модуль> [--project <метка>]; модули: {names}", file=sys.stderr) + return 2 m = registry.module(module_name) if m is None or m.mcp_server is None: names = ", ".join(x.name for x in registry.modules() if x.mcp_server) diff --git a/src/sdlc_hub/modules/jira/mcp.py b/src/sdlc_hub/modules/jira/mcp.py index d516816..ca51fc1 100644 --- a/src/sdlc_hub/modules/jira/mcp.py +++ b/src/sdlc_hub/modules/jira/mcp.py @@ -19,7 +19,7 @@ from .commands import describe, is_temp POLL_SECONDS = 5 BOTH_FILE_AND_PATH = "у ensure_attachment либо path, либо file" -mcp = FastMCP("jira-hub") +mcp = FastMCP("sdlc-jira") fmt_agent = core.fmt_agent diff --git a/src/sdlc_hub/modules/wiki/mcp.py b/src/sdlc_hub/modules/wiki/mcp.py index a99edda..18e5a89 100644 --- a/src/sdlc_hub/modules/wiki/mcp.py +++ b/src/sdlc_hub/modules/wiki/mcp.py @@ -1,4 +1,4 @@ -"""MCP-инструменты модуля wiki (stdio, FastMCP, сервер jira-hub-wiki) — инструменты сессии Claude поверх HTTP API хаба +"""MCP-инструменты модуля wiki (stdio, FastMCP, сервер sdlc-wiki) — инструменты сессии Claude поверх HTTP API хаба (specs/mcp/spec.md «MCP вики»). Охрана, привязка к проекту и projects/project_create — в core/mcp.py. Markdown страниц и имена вложений приходят из Eva и считаются недоверенными: страница выдаётся текстом, имя файла сводится к basename.""" import asyncio @@ -16,7 +16,7 @@ ATTACHMENT_WAIT = 60 SYNC_WAIT = 300 CHUNK = 20000 CANDIDATES = 10 -mcp = FastMCP("jira-hub-wiki") +mcp = FastMCP("sdlc-wiki") def _project() -> dict: @@ -242,7 +242,7 @@ def root_set(root: str) -> str: @core.guard def project_create(label: str, name: str, wiki_root: str = "") -> str: """Завести проект (метка, название, необязательно корень вики: код DOC-… или ссылка Eva). С корнем хаб ставит - синхронизацию; в ответе — фрагмент .mcp.json для репозитория проекта с сервером jira-hub-wiki.""" + синхронизацию; в ответе — фрагмент .mcp.json для репозитория проекта с сервером sdlc-wiki.""" try: code = wiki_roots.parse_root(wiki_root) except ValueError as e: diff --git a/tests/fixtures/baseline/mcp_wiki_tools.expected.json b/tests/fixtures/baseline/mcp_wiki_tools.expected.json index 25149f2..8c3c1de 100644 --- a/tests/fixtures/baseline/mcp_wiki_tools.expected.json +++ b/tests/fixtures/baseline/mcp_wiki_tools.expected.json @@ -134,7 +134,7 @@ "name": "projects" }, { - "description": "Завести проект (метка, название, необязательно корень вики: код DOC-… или ссылка Eva). С корнем хаб ставит\nсинхронизацию; в ответе — фрагмент .mcp.json для репозитория проекта с сервером jira-hub-wiki.", + "description": "Завести проект (метка, название, необязательно корень вики: код DOC-… или ссылка Eva). С корнем хаб ставит\nсинхронизацию; в ответе — фрагмент .mcp.json для репозитория проекта с сервером sdlc-wiki.", "inputSchema": { "properties": { "label": { diff --git a/tests/test_cli.py b/tests/test_cli.py index 686cc8a..51bdcf1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -66,19 +66,18 @@ def test_agent_publish_write_failure(tmp_path, monkeypatch, capsys): assert sorted(p.name for p in release.module_dir(paths.release_root(), "jira").iterdir()) == ["agent.py", "manifest.json"] -def test_mcp_default_module(monkeypatch): +def test_mcp_module_required(monkeypatch, capsys): from sdlc_hub.core import mcp as core_mcp from sdlc_hub.modules.jira import mcp as jira_mcp seen = [] - monkeypatch.setattr(core_mcp, "PROJECT", None) - monkeypatch.setattr(jira_mcp.mcp, "run", lambda transport="stdio": seen.append((transport, core_mcp.PROJECT))) - assert cli.main(["mcp", "--project", "AWPCA"]) == 0 + monkeypatch.setattr(core_mcp, "PROJECT", None) # run() пишет глобал; monkeypatch вернёт прежнее значение + monkeypatch.setattr(jira_mcp.mcp, "run", lambda transport="stdio": seen.append(core_mcp.PROJECT)) + assert cli.main(["mcp", "--project", "AWPCA"]) == 2 + err = capsys.readouterr().err + assert "укажите модуль: sdlc-hub mcp <модуль>" in err and "jira, wiki" in err + assert seen == [] assert cli.main(["mcp", "jira", "--project", "AWPCA"]) == 0 - assert seen == [("stdio", "AWPCA"), ("stdio", "AWPCA")] - # без --project сессия не привязана к проекту - monkeypatch.setattr(core_mcp, "PROJECT", "STALE") - assert cli.main(["mcp"]) == 0 - assert seen[-1] == ("stdio", None) + assert seen == ["AWPCA"] def test_mcp_unknown_module(monkeypatch, capsys): diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 1d17711..35cfaeb 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -503,16 +503,20 @@ def test_project_create(live, monkeypatch): # сце out = jmcp.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 == core_mcp.mcp_json("STOCKS_DRRK") - assert frag["mcpServers"]["jira-hub"]["args"][-2:] == ["--project", "STOCKS_DRRK"] + assert frag == core_mcp.mcp_json("STOCKS_DRRK", "jira") + assert frag["mcpServers"]["sdlc-jira"]["args"][-2:] == ["--project", "STOCKS_DRRK"] assert live["client"].get("/batches")["batches"][0]["author"] == "claude" assert jmcp.project_create("awpca", "x").startswith("ошибка хаба 409") assert jmcp.project_create("BAD LABEL", "x").startswith("ошибка хаба 422") +def test_server_name(): + assert jmcp.mcp.name == "sdlc-jira" + + def test_hub_root_is_repo(): assert (core_mcp.HUB_ROOT / "pyproject.toml").is_file() - assert core_mcp.mcp_json("X")["mcpServers"]["jira-hub"]["args"][:3] == ["run", "--project", str(core_mcp.HUB_ROOT)] + assert core_mcp.mcp_json("X", "jira")["mcpServers"]["sdlc-jira"]["args"][:3] == ["run", "--project", str(core_mcp.HUB_ROOT)] def test_fmt_projects(): diff --git a/tests/test_project_fields.py b/tests/test_project_fields.py index 09d3e2c..b24a563 100644 --- a/tests/test_project_fields.py +++ b/tests/test_project_fields.py @@ -150,11 +150,12 @@ def test_field_clashes_with_core(name): def test_mcp_json_per_module(): - first = core_mcp.mcp_json("AWPCA")["mcpServers"] - assert list(first) == ["jira-hub"] and first["jira-hub"]["args"][-3:] == ["mcp", "--project", "AWPCA"] + first = core_mcp.mcp_json("AWPCA", "jira")["mcpServers"] + assert list(first) == ["sdlc-jira"] and first["sdlc-jira"]["args"][-4:] == ["mcp", "jira", "--project", "AWPCA"] + assert first["sdlc-jira"]["args"][3] == "sdlc-hub" other = core_mcp.mcp_json("AWPCA", "wiki")["mcpServers"] - assert list(other) == ["jira-hub-wiki"] - assert other["jira-hub-wiki"]["args"][-4:] == ["mcp", "wiki", "--project", "AWPCA"] + assert list(other) == ["sdlc-wiki"] + assert other["sdlc-wiki"]["args"][-4:] == ["mcp", "wiki", "--project", "AWPCA"] def test_fmt_projects_columns(): diff --git a/tests/test_wiki_mcp.py b/tests/test_wiki_mcp.py index d316fd7..5cf1106 100644 --- a/tests/test_wiki_mcp.py +++ b/tests/test_wiki_mcp.py @@ -288,7 +288,7 @@ def test_jira_server_unchanged(): def test_run_wiki_module(): m = registry.module("wiki") - assert m.mcp_server().name == "jira-hub-wiki" + assert m.mcp_server().name == "sdlc-wiki" def test_wiki_tool_names_order(): @@ -302,7 +302,7 @@ def test_projects_table_and_create(hub): text = str(out) assert "AWPCA" in text and "DOC-1" in text and "(текущий)" in text created = wmcp.project_create("n", "Н", "DOC-5") - assert "jira-hub-wiki" in created + assert "sdlc-wiki" in created assert fake.calls[-1][2]["wiki_root"] == "DOC-5" -- 2.47.2 From d643007c0b0498e7d11c49e99671a66004550ac4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 11:52:08 +0400 Subject: [PATCH 07/21] =?UTF-8?q?feat(sdlc-rename):=20=D0=BE=D1=82=D0=BA?= =?UTF-8?q?=D0=B0=D0=B7=20CLI=20=D0=BF=D1=80=D0=B8=20=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=D0=B0=D0=BB=D0=BE=D0=B3=D0=B5=20=D0=B4=D0=B0=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D1=85=20=D0=BF=D0=BE=D0=B4=20=D0=BF=D1=80=D0=B5=D0=B6=D0=BD?= =?UTF-8?q?=D0=B8=D0=BC=20=D0=B8=D0=BC=D0=B5=D0=BD=D0=B5=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- src/sdlc_hub/core/cli.py | 6 ++++++ src/sdlc_hub/core/paths.py | 21 +++++++++++++++++++++ tests/conftest.py | 6 ++++++ tests/test_cli.py | 33 +++++++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+) diff --git a/src/sdlc_hub/core/cli.py b/src/sdlc_hub/core/cli.py index 761b364..9693ee3 100644 --- a/src/sdlc_hub/core/cli.py +++ b/src/sdlc_hub/core/cli.py @@ -31,6 +31,12 @@ def main(argv=None) -> int: p.add_argument("path") args = ap.parse_args(argv) + if (problem := paths.home_problem()): + print(problem, file=sys.stderr) + return 2 + if (warning := paths.home_warning()): + print(warning, file=sys.stderr) + if args.cmd == "agent": from . import release names = [m.name for m in registry.agent_modules()] diff --git a/src/sdlc_hub/core/paths.py b/src/sdlc_hub/core/paths.py index 10ea4d1..59fb68f 100644 --- a/src/sdlc_hub/core/paths.py +++ b/src/sdlc_hub/core/paths.py @@ -10,6 +10,27 @@ def home() -> Path: return Path(os.environ.get("SDLC_HUB_HOME", str(Path.home() / ".local" / "share" / "sdlc-hub"))) +def legacy_home() -> Path: + """Каталог данных под прежним именем (до переименования в sdlc-hub).""" + return Path.home() / ".local" / "share" / "jira-hub" + + +def home_problem() -> "str | None": + """Текст отказа, если данные лежат только под прежним именем; при заданном SDLC_HUB_HOME проверки нет.""" + if os.environ.get("SDLC_HUB_HOME") or home().exists() or not legacy_home().exists(): + return None + return ("каталог данных хаба лежит под прежним именем ~/.local/share/jira-hub; перенесите его: " + "bash scripts/cutover.sh (или mv ~/.local/share/jira-hub ~/.local/share/sdlc-hub при остановленном хабе)") + + +def home_warning() -> "str | None": + """Предупреждение, если существуют оба каталога (SDLC_HUB_HOME не задан).""" + if os.environ.get("SDLC_HUB_HOME") or not (home().exists() and legacy_home().exists()): + return None + return ("есть и ~/.local/share/sdlc-hub, и ~/.local/share/jira-hub: работаю с новым, " + "прежний каталог ~/.local/share/jira-hub можно удалить после проверки") + + def db_path() -> Path: return home() / "hub.sqlite" diff --git a/tests/conftest.py b/tests/conftest.py index 55ca5a5..d3ac10d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,12 @@ from sdlc_hub.modules.jira import project_fields from sdlc_hub.modules.jira import projects as jira_projects +@pytest.fixture(autouse=True) +def _isolated_home(monkeypatch, tmp_path_factory): + """HOME — во временный каталог: на результат не влияют реальные ~/.local/share/{jira,sdlc}-hub разработчика.""" + monkeypatch.setenv("HOME", str(tmp_path_factory.mktemp("home"))) + + @pytest.fixture def open_db(): """Фабрика соединений: open_db() — база в памяти, open_db(path) — файл. Все закрываются после теста.""" diff --git a/tests/test_cli.py b/tests/test_cli.py index 51bdcf1..168f641 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,6 +2,8 @@ import json import os from pathlib import Path +import pytest + from sdlc_hub.core import api, cli, paths, release, settings FIXTURE = Path(__file__).parent / "fixtures" / "config.example.json" @@ -103,3 +105,34 @@ def test_agent_publish_migrates_old_layout(tmp_path, monkeypatch, capsys): assert cli.main(["agent", "publish", str(new)]) == 1 # v13 < v14: «только вверх» видит прежнюю публикацию assert not (rel / "agent.py").exists() and not (rel / "manifest.json").exists() assert json.loads((rel / "jira" / "manifest.json").read_text(encoding="utf-8"))["version"] == 14 + + +def _homes(monkeypatch, tmp_path, legacy=True, new=False): + monkeypatch.delenv("SDLC_HUB_HOME", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + share = tmp_path / ".local" / "share" + if legacy: (share / "jira-hub").mkdir(parents=True) + if new: (share / "sdlc-hub").mkdir(parents=True) + return share + + +@pytest.mark.parametrize("argv", [["serve"], ["tokens"], ["mcp", "jira"], ["agent", "publish", "x.py"]]) +def test_legacy_home_refused(monkeypatch, tmp_path, capsys, argv): + share = _homes(monkeypatch, tmp_path) + assert cli.main(argv) == 2 + err = capsys.readouterr().err + assert "~/.local/share/jira-hub" in err and "scripts/cutover.sh" in err + assert not (share / "sdlc-hub").exists() + assert sorted(p.name for p in (share / "jira-hub").iterdir()) == [] + + +def test_both_homes_warn(monkeypatch, tmp_path, capsys): + _homes(monkeypatch, tmp_path, new=True) + cli.main(["tokens"]) + assert "прежний каталог ~/.local/share/jira-hub можно удалить" in capsys.readouterr().err + + +def test_env_home_skips_legacy_check(monkeypatch, tmp_path, capsys): + _homes(monkeypatch, tmp_path) + monkeypatch.setenv("SDLC_HUB_HOME", str(tmp_path / "x")) + assert cli.main(["tokens"]) == 0 -- 2.47.2 From 528c579aa8c1c3a5a08e49c1ec42d5c22ac935b9 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 11:55:11 +0400 Subject: [PATCH 08/21] =?UTF-8?q?spec(sdlc-rename):=20R-3=20=D0=B2=20desig?= =?UTF-8?q?n.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- openspec/changes/sdlc-rename/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/sdlc-rename/design.md b/openspec/changes/sdlc-rename/design.md index 7004073..ec3d724 100644 --- a/openspec/changes/sdlc-rename/design.md +++ b/openspec/changes/sdlc-rename/design.md @@ -106,3 +106,4 @@ | 11 | 24.10.2026 | Сначала переименование, потом #98 | #98 перебазируется на новые пути | перебазирование веток | | 12 | 24.10.2026 | R-1: трейлер коммитов `Co-Authored-By: Claude Sonnet 5.5` вместо `Opus 5.5` из плана | указание системы сессии новее плана | правка трейлера | | 13 | 24.10.2026 | R-2: правки web/playwright.config.ts, web/scripts/seed-hub.py и JIRA_HUB_URL→SDLC_HUB_URL (vite.config.ts) сделаны уже в Task 1 | без них e2e, test-hub.sh и seed-скрипт ломаются между задачами | откат трёх файлов | +| 14 | 24.10.2026 | R-3: autouse-фикстура `_isolated_home` в tests/conftest.py подменяет HOME временным каталогом | реальный ~/.local/share/jira-hub на машине владельца иначе влиял бы на набор тестов | удаление фикстуры | -- 2.47.2 From ef9ec9f46d8952e56774c90b1030ab0730cc7fca Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 24 Oct 2026 11:56:58 +0400 Subject: [PATCH 09/21] =?UTF-8?q?feat(sdlc-rename):=20=D0=B1=D1=80=D0=B5?= =?UTF-8?q?=D0=BD=D0=B4=20SDLC=20Hub=20=D0=B8=20=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=D0=B8=20=D1=85=D1=80=D0=B0=D0=BD=D0=B8=D0=BB=D0=B8=D1=89=D0=B0?= =?UTF-8?q?=20sdlc-hub-*=20=D1=81=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BD=D0=BE?= =?UTF-8?q?=D1=81=D0=BE=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- web/e2e/screens.spec.ts | 2 +- web/index.html | 2 +- web/package-lock.json | 4 +-- web/package.json | 2 +- web/src/api/legacyStorage.test.ts | 31 +++++++++++++++++++ web/src/api/legacyStorage.ts | 18 +++++++++++ web/src/auth/LoginScreen.test.tsx | 6 ++-- web/src/auth/LoginScreen.tsx | 8 ++--- web/src/auth/token.test.ts | 6 ++-- web/src/auth/token.ts | 8 +++-- web/src/modules/jira/board/BoardPage.test.tsx | 2 +- web/src/modules/jira/board/BoardPage.tsx | 2 +- web/src/modules/jira/board/legendPref.ts | 6 ++-- web/src/modules/jira/board/status.ts | 2 +- web/src/modules/jira/board/useBoardSource.ts | 2 +- .../modules/jira/issues/IssueCard.test.tsx | 2 +- web/src/modules/jira/issues/IssueFilters.tsx | 2 +- .../modules/jira/issues/IssueModal.test.tsx | 2 +- .../modules/jira/issues/IssuesPage.test.tsx | 6 ++-- web/src/modules/jira/issues/commentsPref.ts | 5 +-- .../modules/jira/refresh/RefreshProvider.tsx | 2 +- web/src/shell/Sidebar.test.tsx | 12 +++---- web/src/shell/Sidebar.tsx | 4 +-- web/src/shell/sidebarPref.test.ts | 13 ++++++++ web/src/shell/sidebarPref.ts | 6 ++-- 25 files changed, 112 insertions(+), 43 deletions(-) create mode 100644 web/src/api/legacyStorage.test.ts create mode 100644 web/src/api/legacyStorage.ts create mode 100644 web/src/shell/sidebarPref.test.ts diff --git a/web/e2e/screens.spec.ts b/web/e2e/screens.spec.ts index a3aa7f3..f76c21c 100644 --- a/web/e2e/screens.spec.ts +++ b/web/e2e/screens.spec.ts @@ -89,7 +89,7 @@ for (const theme of ["light", "dark"] as const) { test("board-collapsed", async ({ page }) => { test.skip(w < 640, "свёртка — только на широком экране"); - await page.addInitScript(() => { localStorage.setItem("jira-hub-sidebar", "collapsed"); }); + await page.addInitScript(() => { localStorage.setItem("sdlc-hub-sidebar", "collapsed"); }); await login(page); await expect(page.locator(".board .card[data-key]").first()).toBeVisible(); await settle(page); diff --git a/web/index.html b/web/index.html index 5f2db89..9168549 100644 --- a/web/index.html +++ b/web/index.html @@ -3,7 +3,7 @@ - Хаб Jira + SDLC Hub diff --git a/web/package-lock.json b/web/package-lock.json index aed0e23..2f43358 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,10 +1,10 @@ { - "name": "jira-hub-web", + "name": "sdlc-hub-web", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "jira-hub-web", + "name": "sdlc-hub-web", "dependencies": { "@fontsource/ibm-plex-mono": "5.3.0", "@fontsource/mulish": "5.3.0", diff --git a/web/package.json b/web/package.json index 45396bb..503e2a1 100644 --- a/web/package.json +++ b/web/package.json @@ -1,5 +1,5 @@ { - "name": "jira-hub-web", + "name": "sdlc-hub-web", "private": true, "type": "module", "scripts": { diff --git a/web/src/api/legacyStorage.test.ts b/web/src/api/legacyStorage.test.ts new file mode 100644 index 0000000..84efa64 --- /dev/null +++ b/web/src/api/legacyStorage.test.ts @@ -0,0 +1,31 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { readMigrated } from "./legacyStorage"; + +describe("readMigrated", () => { + beforeEach(() => localStorage.clear()); + afterEach(() => vi.restoreAllMocks()); + + it("переносит старый ключ jira-hub-* в sdlc-hub-*", () => { + localStorage.setItem("jira-hub-token", "abc"); + expect(readMigrated("sdlc-hub-token")).toBe("abc"); + expect(localStorage.getItem("sdlc-hub-token")).toBe("abc"); + expect(localStorage.getItem("jira-hub-token")).toBeNull(); + }); + + it("при наличии обоих возвращает новое и не трогает старое", () => { + localStorage.setItem("jira-hub-token", "old"); + localStorage.setItem("sdlc-hub-token", "new"); + expect(readMigrated("sdlc-hub-token")).toBe("new"); + expect(localStorage.getItem("jira-hub-token")).toBe("old"); + }); + + it("без обоих ключей возвращает null и ничего не пишет", () => { + expect(readMigrated("sdlc-hub-token")).toBeNull(); + expect(localStorage.length).toBe(0); + }); + + it("исключение хранилища даёт null", () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("closed"); }); + expect(readMigrated("sdlc-hub-token")).toBeNull(); + }); +}); diff --git a/web/src/api/legacyStorage.ts b/web/src/api/legacyStorage.ts new file mode 100644 index 0000000..76d19ae --- /dev/null +++ b/web/src/api/legacyStorage.ts @@ -0,0 +1,18 @@ +// разовый перенос ключей localStorage после переименования jira-hub -> sdlc-hub: читает новый ключ, +// а если его нет — старый "jira-hub-*", переписывая значение под новым именем. Ошибки хранилища глотаем. +const NEW = "sdlc-hub-"; +const OLD = "jira-hub-"; + +export function readMigrated(key: string): string | null { + try { + const cur = localStorage.getItem(key); + if (cur !== null) return cur; + if (!key.startsWith(NEW)) return null; + const oldKey = OLD + key.slice(NEW.length); + const v = localStorage.getItem(oldKey); + if (v === null) return null; + localStorage.setItem(key, v); + localStorage.removeItem(oldKey); + return v; + } catch { return null; } +} diff --git a/web/src/auth/LoginScreen.test.tsx b/web/src/auth/LoginScreen.test.tsx index 863ccd6..9e7a954 100644 --- a/web/src/auth/LoginScreen.test.tsx +++ b/web/src/auth/LoginScreen.test.tsx @@ -18,7 +18,7 @@ describe("вход", () => { renderWithProviders(app); expect(screen.getByRole("heading", { name: "Вход в хаб" })).toBeInTheDocument(); expect(screen.queryByText("страница приложения")).not.toBeInTheDocument(); - expect(screen.getByText(/uv run jira-hub tokens/)).toBeInTheDocument(); + expect(screen.getByText(/uv run sdlc-hub tokens/)).toBeInTheDocument(); expect(screen.getByLabelText("Клиентский токен хаба")).toHaveAttribute("type", "password"); }); @@ -28,7 +28,7 @@ describe("вход", () => { await userEvent.type(screen.getByLabelText("Клиентский токен хаба"), "good-token"); await userEvent.click(screen.getByRole("button", { name: "Войти" })); expect(await screen.findByText("страница приложения")).toBeInTheDocument(); - expect(localStorage.getItem("jira-hub-token")).toBe("good-token"); + expect(localStorage.getItem("sdlc-hub-token")).toBe("good-token"); expect(fetch.mock.calls[0][0]).toBe("/settings"); }); @@ -40,7 +40,7 @@ describe("вход", () => { await userEvent.click(screen.getByRole("button", { name: "Войти" })); expect(await screen.findByRole("alert")).toHaveTextContent(/Токен не подошёл/); expect(input).toHaveValue("wrong"); - expect(localStorage.getItem("jira-hub-token")).toBeNull(); + expect(localStorage.getItem("sdlc-hub-token")).toBeNull(); }); it("кириллица или пробел — «недопустимые символы», запроса нет", async () => { diff --git a/web/src/auth/LoginScreen.tsx b/web/src/auth/LoginScreen.tsx index eb1c29a..bf85df8 100644 --- a/web/src/auth/LoginScreen.tsx +++ b/web/src/auth/LoginScreen.tsx @@ -6,9 +6,9 @@ import { setToken, TOKEN_RE } from "./token.ts"; import { useAuth } from "./useAuth.ts"; import "./login.css"; -const BAD_TOKEN = "Токен не подошёл. Проверьте его по uv run jira-hub tokens: токен могли пересоздать."; +const BAD_TOKEN = "Токен не подошёл. Проверьте его по uv run sdlc-hub tokens: токен могли пересоздать."; const BAD_CHARS = "В токене недопустимые символы: скопируйте строку после «клиентский токен:» целиком, без пробелов и кириллицы."; -const HUB_DOWN = "Хаб не отвечает: проверьте, что jira-hub запущен, и попробуйте снова."; +const HUB_DOWN = "Хаб не отвечает: проверьте, что sdlc-hub запущен, и попробуйте снова."; export function LoginScreen() { const { reason } = useAuth(); @@ -40,8 +40,8 @@ export function LoginScreen() {

Вход в хаб

-

Приложению нужен клиентский токен хаба. Его показывает команда uv run jira-hub tokens в каталоге - jira-hub на этом Mac. Токен сохранится в этом браузере.

+

Приложению нужен клиентский токен хаба. Его показывает команда uv run sdlc-hub tokens в каталоге + sdlc-hub на этом Mac. Токен сохранится в этом браузере.

{error && {error}}
diff --git a/web/src/auth/token.test.ts b/web/src/auth/token.test.ts index 574ade9..7c4d25c 100644 --- a/web/src/auth/token.test.ts +++ b/web/src/auth/token.test.ts @@ -5,7 +5,7 @@ describe("token", () => { afterEach(() => { vi.restoreAllMocks(); }); it("читает токен, сохранённый прежней доской", async () => { - localStorage.setItem("jira-hub-token", "abc"); + localStorage.setItem("sdlc-hub-token", "abc"); const t = await import("./token.ts"); expect(t.getToken()).toBe("abc"); }); @@ -15,10 +15,10 @@ describe("token", () => { const fn = vi.fn(); t.subscribe(fn); t.setToken("xyz"); - expect(localStorage.getItem("jira-hub-token")).toBe("xyz"); + expect(localStorage.getItem("sdlc-hub-token")).toBe("xyz"); t.clearToken("bad"); expect(t.getAuth()).toEqual({ token: "", reason: "bad" }); - expect(localStorage.getItem("jira-hub-token")).toBeNull(); + expect(localStorage.getItem("sdlc-hub-token")).toBeNull(); expect(fn).toHaveBeenCalledTimes(2); }); diff --git a/web/src/auth/token.ts b/web/src/auth/token.ts index bc67247..b09f8fb 100644 --- a/web/src/auth/token.ts +++ b/web/src/auth/token.ts @@ -1,13 +1,15 @@ -// клиентский токен хаба: localStorage["jira-hub-token"] (тот же ключ, что у прежней доски) и копия в памяти на случай +// клиентский токен хаба: localStorage["sdlc-hub-token"] (со старого "sdlc-hub-token" переносится при чтении) и копия в памяти на случай // закрытого хранилища. reason — почему показан экран входа: "bad" — токен не подошёл (ответ 401) -export const TOKEN_KEY = "jira-hub-token"; +import { readMigrated } from "../api/legacyStorage"; + +export const TOKEN_KEY = "sdlc-hub-token"; export const TOKEN_RE = /^[\x21-\x7e]+$/; // печатный ASCII без пробелов: иначе fetch бросит TypeError на заголовке export type LoginReason = "none" | "bad"; export interface AuthSnapshot { token: string; reason: LoginReason } const readStored = (): string => { - try { return localStorage.getItem(TOKEN_KEY) ?? ""; } catch { return ""; } + return readMigrated(TOKEN_KEY) ?? ""; }; let state: AuthSnapshot = { token: readStored(), reason: "none" }; diff --git a/web/src/modules/jira/board/BoardPage.test.tsx b/web/src/modules/jira/board/BoardPage.test.tsx index e10494c..6ab56f2 100644 --- a/web/src/modules/jira/board/BoardPage.test.tsx +++ b/web/src/modules/jira/board/BoardPage.test.tsx @@ -147,7 +147,7 @@ describe("страница доски", () => { await userEvent.click(toggle); expect(toggle).toHaveAttribute("aria-expanded", "false"); expect(legend()).toBeNull(); - expect(localStorage.getItem("jira-hub-legend")).toBe("0"); + expect(localStorage.getItem("sdlc-hub-legend")).toBe("0"); r.unmount(); page(); await screen.findByText(/задач из 9/); diff --git a/web/src/modules/jira/board/BoardPage.tsx b/web/src/modules/jira/board/BoardPage.tsx index 1abfdaf..3bbf874 100644 --- a/web/src/modules/jira/board/BoardPage.tsx +++ b/web/src/modules/jira/board/BoardPage.tsx @@ -47,7 +47,7 @@ export function BoardPage({ project }: { project: string }) { return (
В настройках хаба нет {d.missing.join(" и ")} — доска не может перевести виды работ и типы связей Jira. Задайте - словари в config.example.json и выполните uv run jira-hub init --config … или запишите их через PUT /settings. + словари в config.example.json и выполните uv run sdlc-hub init --config … или запишите их через PUT /settings.
); } diff --git a/web/src/modules/jira/board/legendPref.ts b/web/src/modules/jira/board/legendPref.ts index ca90125..09df9e0 100644 --- a/web/src/modules/jira/board/legendPref.ts +++ b/web/src/modules/jira/board/legendPref.ts @@ -1,8 +1,10 @@ // свёрнутость легенды доски: "0" — свёрнута, иначе развёрнута; ошибки хранилища глотаем -const KEY = "jira-hub-legend"; +import { readMigrated } from "../../../api/legacyStorage"; + +const KEY = "sdlc-hub-legend"; export function readLegendOpen(): boolean { - try { return localStorage.getItem(KEY) !== "0"; } catch { return true; } + return readMigrated(KEY) !== "0"; } export function saveLegendOpen(open: boolean): void { diff --git a/web/src/modules/jira/board/status.ts b/web/src/modules/jira/board/status.ts index 2537f3c..7c84ed5 100644 --- a/web/src/modules/jira/board/status.ts +++ b/web/src/modules/jira/board/status.ts @@ -12,7 +12,7 @@ export function statusText(s: BoardStatus): { text: string; warn: boolean } { case "loading": return { text: "загрузка данных хаба…", warn: false }; case "hub-down": return { text: s.fromFile ? "хаб не отвечает — на доске снимок из файла" - : s.hasData ? "хаб не отвечает — показаны прежние данные" : "хаб не отвечает: запустите хаб (jira-hub serve) и нажмите «Перечитать»", + : s.hasData ? "хаб не отвечает — показаны прежние данные" : "хаб не отвечает: запустите хаб (sdlc-hub serve) и нажмите «Перечитать»", warn: true, }; case "file-error": return { text: `файл не разобран: ${s.reason}`, warn: true }; diff --git a/web/src/modules/jira/board/useBoardSource.ts b/web/src/modules/jira/board/useBoardSource.ts index 9a3e1d3..f4edb9f 100644 --- a/web/src/modules/jira/board/useBoardSource.ts +++ b/web/src/modules/jira/board/useBoardSource.ts @@ -83,7 +83,7 @@ export function useBoardSource(dicts: Dicts | null, project: string): BoardSourc const pending = board.isPending; const emptyHub = useMemo(() => emptyModel(pending ? "Загрузка данных хаба…" - : "Нет данных: хаб не отвечает. Запустите хаб (jira-hub serve) и нажмите «Перечитать» или загрузите файл CSV из фильтра Jira."), [pending]); + : "Нет данных: хаб не отвечает. Запустите хаб (sdlc-hub serve) и нажмите «Перечитать» или загрузите файл CSV из фильтра Jira."), [pending]); const model = source.kind === "file" ? source.model : hubModel ?? emptyHub; const label = source.kind === "file" ? `${source.model.fileName} (из файла)` : board.data ? (ls ? `Хаб · данные на ${fmtStamp(ls.finished_at)}` : "Хаб · полного обновления не было") diff --git a/web/src/modules/jira/issues/IssueCard.test.tsx b/web/src/modules/jira/issues/IssueCard.test.tsx index afb82f2..0bb4390 100644 --- a/web/src/modules/jira/issues/IssueCard.test.tsx +++ b/web/src/modules/jira/issues/IssueCard.test.tsx @@ -207,7 +207,7 @@ describe("карточка задачи", () => { expect(items[0].querySelector(".body br")).not.toBeNull(); await userEvent.click(screen.getByRole("button", { name: "Скрыть" })); expect(screen.queryByText("старый")).toBeNull(); - expect(localStorage.getItem("jira-hub-comments-hidden")).toBe("1"); + expect(localStorage.getItem("sdlc-hub-comments-hidden")).toBe("1"); cleanup(); open("/issues/AURORA-9001"); expect(await screen.findByRole("button", { name: "Показать" })).toBeInTheDocument(); diff --git a/web/src/modules/jira/issues/IssueFilters.tsx b/web/src/modules/jira/issues/IssueFilters.tsx index 945de93..8cf9913 100644 --- a/web/src/modules/jira/issues/IssueFilters.tsx +++ b/web/src/modules/jira/issues/IssueFilters.tsx @@ -10,7 +10,7 @@ export const SELECTS = [ type Name = (typeof SELECTS)[number][0]; // раскрыта ли панель «Фильтры» — на время сессии вкладки; ошибки хранилища глотаем -const KEY = "jira-hub-issue-filters"; +const KEY = "sdlc-hub-issue-filters"; const readOpen = () => { try { return sessionStorage.getItem(KEY) === "1"; } catch { return false; } }; const saveOpen = (open: boolean) => { try { sessionStorage.setItem(KEY, open ? "1" : "0"); } catch { /* только в памяти */ } }; diff --git a/web/src/modules/jira/issues/IssueModal.test.tsx b/web/src/modules/jira/issues/IssueModal.test.tsx index efacdfd..02f9f15 100644 --- a/web/src/modules/jira/issues/IssueModal.test.tsx +++ b/web/src/modules/jira/issues/IssueModal.test.tsx @@ -32,7 +32,7 @@ describe("карточка окном", () => { beforeEach(() => { setToken("tok"); hub(); }); it("из списка: окно поверх списка, фон не пересоздаётся, ✕ возвращает со старым фильтром, «назад» не открывает карточку", async () => { - sessionStorage.setItem("jira-hub-issue-filters", "1"); // панель «Фильтры» раскрыта + sessionStorage.setItem("sdlc-hub-issue-filters", "1"); // панель «Фильтры» раскрыта const router = at(["/app/board", "/app/issues?sprint=AWPCA_Спринт 2"]); render(); const table = await screen.findByRole("table"); diff --git a/web/src/modules/jira/issues/IssuesPage.test.tsx b/web/src/modules/jira/issues/IssuesPage.test.tsx index ab97916..ed371f5 100644 --- a/web/src/modules/jira/issues/IssuesPage.test.tsx +++ b/web/src/modules/jira/issues/IssuesPage.test.tsx @@ -27,7 +27,7 @@ const issuesCalls = (fetch: ReturnType) => describe("список задач", () => { beforeEach(() => { setToken("tok"); sessionStorage.clear(); }); - const expanded = () => sessionStorage.setItem("jira-hub-issue-filters", "1"); + const expanded = () => sessionStorage.setItem("sdlc-hub-issue-filters", "1"); it("фильтры из адреса уходят в GET /issues; ключ ведёт на карточку", async () => { const fetch = hub(() => [row(1)]); @@ -266,11 +266,11 @@ describe("список задач", () => { await userEvent.click(screen.getByRole("button", { name: /Фильтры/ })); expect(screen.getByRole("button", { name: /Фильтры/ })).toHaveAttribute("aria-expanded", "true"); for (const n of ["Спринт", "Исполнитель", "Вид работы", "Статус Jira", "Метка", "Эпик"]) expect(screen.getByLabelText(n)).toBeInTheDocument(); - expect(sessionStorage.getItem("jira-hub-issue-filters")).toBe("1"); + expect(sessionStorage.getItem("sdlc-hub-issue-filters")).toBe("1"); }); it("панель раскрыта по sessionStorage", async () => { - sessionStorage.setItem("jira-hub-issue-filters", "1"); + sessionStorage.setItem("sdlc-hub-issue-filters", "1"); hub(() => [row(1)]); renderWithProviders(, { route: "/issues", path: "/issues" }); expect(await screen.findByLabelText("Спринт")).toBeInTheDocument(); diff --git a/web/src/modules/jira/issues/commentsPref.ts b/web/src/modules/jira/issues/commentsPref.ts index 16c0647..aab8fe4 100644 --- a/web/src/modules/jira/issues/commentsPref.ts +++ b/web/src/modules/jira/issues/commentsPref.ts @@ -1,8 +1,9 @@ import { useState } from "react"; +import { readMigrated } from "../../../api/legacyStorage"; // выбор «комментарии скрыты» — один на все карточки, в браузере (design 19); без localStorage — показаны -export const COMMENTS_HIDDEN_KEY = "jira-hub-comments-hidden"; -const read = (): boolean => { try { return localStorage.getItem(COMMENTS_HIDDEN_KEY) === "1"; } catch { return false; } }; +export const COMMENTS_HIDDEN_KEY = "sdlc-hub-comments-hidden"; +const read = (): boolean => readMigrated(COMMENTS_HIDDEN_KEY) === "1"; export function useCommentsHidden(): [boolean, (hidden: boolean) => void] { const [hidden, setHidden] = useState(read); diff --git a/web/src/modules/jira/refresh/RefreshProvider.tsx b/web/src/modules/jira/refresh/RefreshProvider.tsx index 56c8bba7..d7be62b 100644 --- a/web/src/modules/jira/refresh/RefreshProvider.tsx +++ b/web/src/modules/jira/refresh/RefreshProvider.tsx @@ -1,4 +1,4 @@ -// владелец состояния «Обновить из Jira»: один опрос пакета на всё приложение. Правила — прежние (jira-hub#20): +// владелец состояния «Обновить из Jira»: один опрос пакета на всё приложение. Правила — прежние (hub#20): // одна кнопка и один опрос (M-2), подхват незавершённого пакета после любой загрузки снимка (M-3), остановка опроса // (M-4, T10.2), только пакеты с первой командой refresh (T10.6) import { useQuery, useQueryClient } from "@tanstack/react-query"; diff --git a/web/src/shell/Sidebar.test.tsx b/web/src/shell/Sidebar.test.tsx index 6a1a216..b96e5ee 100644 --- a/web/src/shell/Sidebar.test.tsx +++ b/web/src/shell/Sidebar.test.tsx @@ -149,13 +149,13 @@ describe("боковая панель", () => { expect(await within(nav).findByRole("link", { name: "Доска связей" })).toHaveAttribute("aria-current", "page"); }); - it("«Свернуть панель» сворачивает и запоминает в jira-hub-sidebar; после перезапуска свёрнута, разделы доступны", async () => { + it("«Свернуть панель» сворачивает и запоминает в sdlc-hub-sidebar; после перезапуска свёрнута, разделы доступны", async () => { hub(); open("/app/board?project=AWPCA"); const btn = await within(bar()).findByRole("button", { name: "Свернуть панель" }); expect(btn).toHaveAttribute("aria-expanded", "true"); await userEvent.click(btn); - expect(localStorage.getItem("jira-hub-sidebar")).toBe("collapsed"); + expect(localStorage.getItem("sdlc-hub-sidebar")).toBe("collapsed"); expect(bar()).toHaveClass("is-collapsed"); cleanup(); open("/app/board?project=AWPCA"); @@ -163,12 +163,12 @@ describe("боковая панель", () => { expect(within(bar()).getByRole("button", { name: "Развернуть панель" })).toHaveAttribute("aria-expanded", "false"); expect(within(bar()).getByRole("link", { name: "Задачи" })).toHaveAttribute("title", "Задачи"); await userEvent.click(within(bar()).getByRole("button", { name: "Развернуть панель" })); - expect(localStorage.getItem("jira-hub-sidebar")).toBeNull(); + expect(localStorage.getItem("sdlc-hub-sidebar")).toBeNull(); expect(bar()).not.toHaveClass("is-collapsed"); }); it("свёрнутая: «Обновить из Jira» значком ставит пакет, событие — в role=status и подсказке", async () => { - localStorage.setItem("jira-hub-sidebar", "collapsed"); + localStorage.setItem("sdlc-hub-sidebar", "collapsed"); const h = hub(); open("/app/issues?project=AWPCA"); const btn = await within(bar()).findByRole("button", { name: "Обновить из Jira" }); @@ -180,7 +180,7 @@ describe("боковая панель", () => { }); it("свёрнутая: метка проекта видна, щелчок по ней разворачивает панель", async () => { - localStorage.setItem("jira-hub-sidebar", "collapsed"); + localStorage.setItem("sdlc-hub-sidebar", "collapsed"); hub(); open("/app/board?project=AWPCA"); const tag = await within(bar()).findByRole("button", { name: "Проект AWPCA — развернуть панель" }); @@ -201,7 +201,7 @@ describe("боковая панель на узком экране", () => { open("/app/board?project=AWPCA"); const top = await screen.findByRole("banner", { name: "Строка приложения" }); expect(within(top).getByRole("button", { name: "Меню" })).toHaveAttribute("aria-expanded", "false"); - expect(within(top).getByText("Хаб Jira")).toBeInTheDocument(); + expect(within(top).getByText("SDLC Hub")).toBeInTheDocument(); expect(within(top).getByRole("button", { name: "Обновить из Jira" })).toBeInTheDocument(); expect(screen.queryByRole("complementary", { name: "Панель приложения" })).toBeNull(); }); diff --git a/web/src/shell/Sidebar.tsx b/web/src/shell/Sidebar.tsx index c8079af..6cfdb7b 100644 --- a/web/src/shell/Sidebar.tsx +++ b/web/src/shell/Sidebar.tsx @@ -40,7 +40,7 @@ function NarrowShell(p: Props) {
- Хаб Jira + SDLC Hub {MODULES.map((m) => m.SidebarStatus && )}
@@ -71,7 +71,7 @@ function Panel({ section, project, drawer }: Props & { drawer?: { close(): void; return (