From 64fbeb25a76b4cd09704745d7cb81d9d3b365f69 Mon Sep 17 00:00:00 2001 From: Aleksey Filippov Date: Tue, 10 Feb 2026 22:03:25 +0300 Subject: [PATCH] =?utf8?q?[ERP-43]=20=D0=A1=D0=BE=D0=BE=D0=B1=D1=89=D0=B5?= =?utf8?q?=D0=BD=D0=B8=D0=B5=20=D0=BD=D0=B0=D0=BF=D0=BE=D0=BC=D0=B8=D0=BD?= =?utf8?q?=D0=B0=D0=BD=D0=B8=D0=B5,=20=D0=B2=D1=81=D0=BF=D0=BB=D1=8B=D0=B2?= =?utf8?q?=D0=B0=D1=8E=D1=89=D0=B5=D0=B5=20=D0=B4=D0=BB=D1=8F=20=D0=BE?= =?utf8?q?=D1=82=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F=20=D0=B8=20=D0=B7?= =?utf8?q?=D0=B0=D0=BA=D1=80=D1=8B=D1=82=D0=B8=D1=8F=20=D1=81=D0=BC=D0=B5?= =?utf8?q?=D0=BD=D1=8B.?= MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit --- .claude-plugin/marketplace.json | 17 + .claude-plugin/plugin.json | 8 + .claude/skills/jira-comment/SKILL.md | 485 +++++++++++ .claude/skills/jira-debate/SKILL.md | 720 ++++++++++++++++ .claude/skills/jira-fetch/SKILL.md | 278 +++++++ .../jira-fetch/config/state/ERP-43.json | 24 + .claude/skills/jira-focus-detect/SKILL.md | 366 +++++++++ .claude/skills/jira-implement/SKILL.md | 385 +++++++++ .claude/skills/jira-interview/SKILL.md | 224 +++++ .claude/skills/jira-plan/SKILL.md | 375 +++++++++ .claude/skills/jira-prd/SKILL.md | 267 ++++++ .claude/skills/jira-report/SKILL.md | 409 ++++++++++ .claude/skills/jira-skill-recommend/SKILL.md | 393 +++++++++ .claude/skills/jira-spec/SKILL.md | 462 +++++++++++ .claude/skills/jira-sync/SKILL.md | 314 +++++++ .claude/skills/jira-workflow/SKILL.md | 381 +++++++++ erp24/config/db-test.php | 15 + erp24/controllers/ShiftReminderController.php | 28 +- erp24/docker/nginx/default.conf | 23 + erp24/docker/php/Dockerfile | 28 + erp24/docs/services/ShiftReminderService.md | 772 ++++++++++++++++++ erp24/models/ShiftReminderShown.php | 8 +- .../ShiftReminderControllerCest.php | 539 ++++++++++++ .../unit/models/ShiftReminderShownTest.php | 403 +++++++++ .../services/ShiftReminderServiceTest.php | 654 +++++++++++++++ erp24/web/js/shift-reminder.js | 47 +- 26 files changed, 7591 insertions(+), 34 deletions(-) create mode 100644 .claude-plugin/marketplace.json create mode 100644 .claude-plugin/plugin.json create mode 100755 .claude/skills/jira-comment/SKILL.md create mode 100755 .claude/skills/jira-debate/SKILL.md create mode 100755 .claude/skills/jira-fetch/SKILL.md create mode 100644 .claude/skills/jira-fetch/config/state/ERP-43.json create mode 100755 .claude/skills/jira-focus-detect/SKILL.md create mode 100755 .claude/skills/jira-implement/SKILL.md create mode 100755 .claude/skills/jira-interview/SKILL.md create mode 100755 .claude/skills/jira-plan/SKILL.md create mode 100755 .claude/skills/jira-prd/SKILL.md create mode 100755 .claude/skills/jira-report/SKILL.md create mode 100755 .claude/skills/jira-skill-recommend/SKILL.md create mode 100755 .claude/skills/jira-spec/SKILL.md create mode 100755 .claude/skills/jira-sync/SKILL.md create mode 100755 .claude/skills/jira-workflow/SKILL.md create mode 100644 erp24/config/db-test.php create mode 100644 erp24/docker/nginx/default.conf create mode 100644 erp24/docker/php/Dockerfile create mode 100644 erp24/docs/services/ShiftReminderService.md create mode 100644 erp24/tests/functional/ShiftReminderControllerCest.php create mode 100644 erp24/tests/unit/models/ShiftReminderShownTest.php create mode 100644 erp24/tests/unit/services/ShiftReminderServiceTest.php diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 00000000..fb961dc1 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,17 @@ +{ + "name": "jira-workflow-plugin", + "owner": { + "name": "Developer" + }, + "metadata": { + "description": "Automated Jira workflow with Official Atlassian MCP: fetch → interview → PRD → debate → spec → debate → plan → debate → implement. OAuth auth, 6 AI models, persistent config, Russian language.", + "version": "2.2.0" + }, + "plugins": [ + { + "name": "jira-workflow-plugin", + "description": "Automated Jira workflow with Official Atlassian MCP: fetch → interview → PRD → debate → spec → debate → plan → debate → implement. OAuth auth, 6 AI models, persistent config, Russian language.", + "source": "./" + } + ] +} \ No newline at end of file diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 00000000..c51dd081 --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "jira-workflow-plugin", + "description": "Automated Jira workflow with Official Atlassian MCP: fetch → interview → PRD → debate → spec → debate → plan → debate → implement. OAuth auth, 6 AI models, persistent config, Russian language.", + "version": "2.2.0", + "author": { + "name": "Developer" + } +} \ No newline at end of file diff --git a/.claude/skills/jira-comment/SKILL.md b/.claude/skills/jira-comment/SKILL.md new file mode 100755 index 00000000..602341fd --- /dev/null +++ b/.claude/skills/jira-comment/SKILL.md @@ -0,0 +1,485 @@ +--- +name: jira-comment +description: Добавление структурированных комментариев к задачам Jira на каждом этапе workflow +triggers: + - jira comment + - добавить комментарий jira + - post jira comment +--- + +# Jira Comment Skill + +Добавляет структурированные комментарии к задаче на каждом этапе workflow. + +## Входные параметры + +- `issue_key` — ключ задачи (например, PROJ-123) +- `stage` — этап workflow: `fetch` | `interview` | `prd` | `debate` | `spec` | `plan` | `implement` | `complete` +- `context` — данные контекста для шаблона + +## Шаблоны комментариев + +### После fetch + +```markdown +🚀 **Начата обработка задачи** + +**Workflow:** {workflow_type} +**Режим доступа:** {jira_access_mode} +**Этапы:** {stages_list} + +Следующий этап: Интервью +``` + +### После interview + +```markdown +📝 **Интервью завершено** + +**Вопросов задано:** {questions_count} +**Требований извлечено:** {requirements_count} + +**Основные требования:** +{requirements_summary} + +📎 Артефакт: `interview.md` (прикреплён) +``` + +### После PRD + +```markdown +📋 **PRD создан** + +**Разделы:** +- Executive Summary +- Problem Statement +- Goals & Success Metrics +- Requirements (Functional/Non-functional) +- User Stories +- Acceptance Criteria + +📎 Артефакт: `prd.md` (прикреплён) + +Следующий этап: Дебаты PRD +``` + +### После debate + +```markdown +🗣️ **Дебаты завершены** ({document_type}) + +**Персоны:** {personas_list} +**Раундов:** {total_rounds} +**Моделей:** {models_count} + +**Улучшения:** +{improvements_list} + +📎 Артефакт: `debate-log.md` (обновлён) +``` + +### После spec + +```markdown +📐 **Техническая спецификация создана** + +**Архитектура:** {architecture_summary} +**API endpoints:** {api_count} +**DB changes:** {db_changes} + +📎 Артефакт: `spec.md` (прикреплён) +``` + +### После plan + +```markdown +📊 **План реализации создан** + +**Подзадач создано:** {subtasks_count} + +**Подзадачи:** +{subtask_links} + +**Зависимости:** +{dependency_graph} + +📎 Артефакт: `plan.md` (прикреплён) +``` + +### После implement (финальный) + +```markdown +✅ **Реализация завершена** + +**Статус:** Выполнено +**Подзадач выполнено:** {completed}/{total} + +**Созданные файлы:** +{files_created} + +**Изменённые файлы:** +{files_modified} + +**Тесты:** {tests_passed} passed, {tests_failed} failed + +--- +Workflow завершён успешно. +``` + +## API доступа к Jira + +### Official Atlassian MCP (основной режим) + +**ВАЖНО:** Используй Official Atlassian Remote MCP Server с OAuth авторизацией. + +Claude автоматически видит доступные MCP tools от сервера `atlassian`. +Ищи tool для добавления комментариев, например: +- `atlassian_jira_add_comment` +- `add_comment` +- или аналогичный + +**Формат вызова:** +``` +atlassian_jira_add_comment( + issue_key="PROJ-123", + body="Текст комментария" +) +``` + +**Примечание:** Atlassian MCP обычно принимает plain text или markdown. +Сервер сам обрабатывает форматирование. + +### HTTP fallback (REST API v3) + +Используется только если Atlassian MCP недоступен. +Требует env переменные: ATLASSIAN_HOST, ATLASSIAN_EMAIL, ATLASSIAN_TOKEN. +При HTTP режиме требуется ADF формат в body. + +### curl команда для HTTP fallback + +```bash +curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{ + "body": { + "type": "doc", + "version": 1, + "content": [ + { + "type": "paragraph", + "content": [{"type": "text", "text": "MESSAGE"}] + } + ] + } + }' \ + "https://${ATLASSIAN_HOST}/rest/api/3/issue/${issue_key}/comment" +``` + +## ADF (Atlassian Document Format) + +Для HTTP fallback комментарии форматируются в ADF: + +### Структура документа + +```json +{ + "body": { + "type": "doc", + "version": 1, + "content": [...] + } +} +``` + +### Heading + +```json +{ + "type": "heading", + "attrs": {"level": 3}, + "content": [{"type": "text", "text": "Заголовок"}] +} +``` + +### Paragraph с форматированием + +```json +{ + "type": "paragraph", + "content": [ + {"type": "text", "text": "Обычный текст "}, + {"type": "text", "marks": [{"type": "strong"}], "text": "жирный"}, + {"type": "text", "text": " и "}, + {"type": "text", "marks": [{"type": "em"}], "text": "курсив"} + ] +} +``` + +### Bullet list + +```json +{ + "type": "bulletList", + "content": [ + { + "type": "listItem", + "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": "Пункт 1"}]} + ] + }, + { + "type": "listItem", + "content": [ + {"type": "paragraph", "content": [{"type": "text", "text": "Пункт 2"}]} + ] + } + ] +} +``` + +### Code block + +```json +{ + "type": "codeBlock", + "attrs": {"language": "bash"}, + "content": [{"type": "text", "text": "echo hello"}] +} +``` + +### Emoji + +```json +{ + "type": "emoji", + "attrs": {"shortName": ":rocket:"} +} +``` + +## Конвертация Markdown в ADF + +```python +def markdown_to_adf(markdown: str) -> dict: + """ + Конвертирует markdown в Atlassian Document Format. + """ + content = [] + lines = markdown.split('\n') + i = 0 + + while i < len(lines): + line = lines[i] + + # Heading + if line.startswith('# '): + content.append({ + "type": "heading", + "attrs": {"level": 1}, + "content": [{"type": "text", "text": line[2:]}] + }) + elif line.startswith('## '): + content.append({ + "type": "heading", + "attrs": {"level": 2}, + "content": [{"type": "text", "text": line[3:]}] + }) + elif line.startswith('### '): + content.append({ + "type": "heading", + "attrs": {"level": 3}, + "content": [{"type": "text", "text": line[4:]}] + }) + + # Bullet list + elif line.startswith('- ') or line.startswith('* '): + items = [] + while i < len(lines) and (lines[i].startswith('- ') or lines[i].startswith('* ')): + item_text = lines[i][2:] + items.append({ + "type": "listItem", + "content": [{ + "type": "paragraph", + "content": parse_inline_formatting(item_text) + }] + }) + i += 1 + content.append({"type": "bulletList", "content": items}) + continue + + # Code block + elif line.startswith('```'): + lang = line[3:] or None + code_lines = [] + i += 1 + while i < len(lines) and not lines[i].startswith('```'): + code_lines.append(lines[i]) + i += 1 + block = { + "type": "codeBlock", + "content": [{"type": "text", "text": '\n'.join(code_lines)}] + } + if lang: + block["attrs"] = {"language": lang} + content.append(block) + + # Horizontal rule + elif line == '---': + content.append({"type": "rule"}) + + # Paragraph + elif line.strip(): + content.append({ + "type": "paragraph", + "content": parse_inline_formatting(line) + }) + + i += 1 + + return {"type": "doc", "version": 1, "content": content} + + +def parse_inline_formatting(text: str) -> list: + """Парсит inline форматирование (bold, italic, code).""" + import re + + content = [] + # Simplified: just return plain text + # Full implementation would handle **bold**, *italic*, `code` + + # Handle emoji shortcuts + emoji_map = { + "🚀": ":rocket:", + "📝": ":memo:", + "📋": ":clipboard:", + "🗣️": ":speaking_head:", + "📐": ":triangular_ruler:", + "📊": ":bar_chart:", + "✅": ":white_check_mark:", + "📎": ":paperclip:" + } + + for emoji, shortname in emoji_map.items(): + if emoji in text: + parts = text.split(emoji) + for j, part in enumerate(parts): + if part: + content.append({"type": "text", "text": part}) + if j < len(parts) - 1: + content.append({ + "type": "emoji", + "attrs": {"shortName": shortname} + }) + return content + + # Default: plain text + return [{"type": "text", "text": text}] +``` + +## Основной процесс + +### Алгоритм добавления комментария + +1. **Сформируй текст комментария** из шаблона для этапа (см. Шаблоны выше) +2. **Определи режим доступа** (MCP или HTTP) из состояния workflow +3. **Отправь комментарий:** + +#### При MCP режиме: +``` +Вызови MCP tool: jira_add_comment + issue_key: "{issue_key}" + comment: "{plain_text_message}" +``` + +**ВАЖНО:** Передавай plain text с markdown разметкой, НЕ ADF объект! + +#### При HTTP режиме: +```bash +curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{ + "body": {ADF_OBJECT} + }' \ + "https://${ATLASSIAN_HOST}/rest/api/3/issue/{issue_key}/comment" +``` + +### Алгоритм прикрепления артефакта + +**Всегда используй HTTP** (MCP не поддерживает attachments): + +```bash +curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \ + -X POST \ + -H "X-Atlassian-Token: no-check" \ + -F "file=@{file_path}" \ + "https://${ATLASSIAN_HOST}/rest/api/3/issue/{issue_key}/attachments" +``` + +## Вывод + +``` +[comment] Добавляю комментарий к PROJ-123 (этап: debate)... + Режим: REST API + Статус: ✓ Добавлен +``` + +## Интеграция с workflow + +Вызывается автоматически после каждого этапа: + +```python +# В jira-workflow после завершения этапа +invoke_skill("jira-comment", + issue_key=issue_key, + stage=current_stage, + context=stage_context +) +``` + +## Прикрепление артефактов + +**ВАЖНО:** MCP-сервер jira-mcp НЕ поддерживает прикрепление файлов! +Всегда используй HTTP fallback для attachments. + +### curl команда для прикрепления файла + +```bash +curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \ + -X POST \ + -H "X-Atlassian-Token: no-check" \ + -F "file=@docs/jira/PROJ-123/prd.md" \ + "https://${ATLASSIAN_HOST}/rest/api/3/issue/PROJ-123/attachments" +``` + +### Проверка успешности + +Успешный ответ: HTTP 200, JSON с информацией о файле. +Ошибка: HTTP 4xx/5xx с описанием проблемы. + +## Создание подзадач + +```python +def create_subtask(parent_key: str, summary: str, description: str) -> str: + """Создаёт подзадачу и возвращает её ключ.""" + payload = { + "fields": { + "project": {"key": parent_key.split('-')[0]}, + "parent": {"key": parent_key}, + "summary": summary, + "description": markdown_to_adf(description), + "issuetype": {"name": "Sub-task"} + } + } + + response = requests.post( + f"https://{host}/rest/api/3/issue", + auth=(email, token), + headers={"Content-Type": "application/json"}, + json=payload + ) + + if response.status_code == 201: + return response.json()["key"] + return None +``` diff --git a/.claude/skills/jira-debate/SKILL.md b/.claude/skills/jira-debate/SKILL.md new file mode 100755 index 00000000..518613bf --- /dev/null +++ b/.claude/skills/jira-debate/SKILL.md @@ -0,0 +1,720 @@ +--- +name: jira-debate +description: Дебаты документов (PRD, Spec, Plan) с AI-моделями через OpenRouter до достижения консенсуса +triggers: + - jira debate + - adversarial spec + - spec debate + - prd debate + - plan debate +hooks: + pre: model-availability +--- + +# Jira Debate Skill + +Проводит adversarial дебаты документов с 7 AI-моделями до достижения консенсуса. + +## Входные параметры + +- `document_type` — тип документа: `prd` | `spec` | `plan` +- `document_path` — путь к файлу документа +- `issue_key` — ключ задачи +- `personas` — список персон для фокусированных дебатов (опционально) +- `auto_detect_focus` — автоопределение персон (default: true) + +## Режимы дебатов + +### Стандартный режим (без персон) + +Все модели критикуют документ одновременно по общим критериям. +Используется когда `personas` пустой или `auto_detect_focus: false`. + +### Режим с персонами (фокусированные дебаты) + +Дебаты проходят итерациями по персонам. Каждая итерация фокусирует все модели +на конкретной области (frontend, backend, security и т.д.). + +``` +Iteration 1: Backend-архитектор + - Все модели критикуют с фокусом на backend + - 1-3 раунда до консенсуса по backend-аспектам + +Iteration 2: Frontend-архитектор + - Все модели критикуют с фокусом на frontend + - 1-3 раунда до консенсуса по frontend-аспектам + +Iteration 3: Инженер безопасности + - Все модели критикуют с фокусом на security + - 1-3 раунда до консенсуса по security-аспектам +``` + +### Определение персон + +```python +# В начале дебатов +if auto_detect_focus and not personas: + # Вызвать jira-focus-detect + focus_result = invoke_skill("jira-focus-detect", issue_data=issue_data) + personas = focus_result["detected_personas"] + + if focus_result["confidence"] == "low": + # Запросить подтверждение у пользователя + personas = confirm_personas_with_user(personas) +``` + +### Загрузка конфигурации персоны + +```python +def load_persona_config(persona_id: str) -> dict: + with open("config/debate-personas.json") as f: + config = json.load(f) + return config["personas"][persona_id] +``` + +### Построение промпта с фокусом персоны + +```python +def build_persona_prompt(base_prompt: str, persona: dict, document_type: str) -> str: + review_criteria = persona["review_criteria"][document_type] + focus_areas = persona["focus_areas"] + + persona_context = f""" +## Persona Focus: {persona["name"]} + +You are reviewing this document from the perspective of a {persona["name"]}. + +### Focus Areas +{chr(10).join(f"- {area}" for area in focus_areas)} + +### Specific Review Questions for {document_type.upper()} +{chr(10).join(f"- {q}" for q in review_criteria)} + +{persona["prompt_suffix"]} + +--- + +""" + return persona_context + base_prompt +``` + +### Итерация по персонам + +```python +async def debate_with_personas( + document: str, + personas: list, + document_type: str, + active_models: list +) -> tuple[str, list]: + """ + Проводит дебаты с итерациями по персонам. + + Returns: + tuple: (updated_document, all_improvements) + """ + all_improvements = [] + debate_config = load_debate_config() + + for persona_id in personas: + persona = load_persona_config(persona_id) + rounds_config = debate_config["rounds_per_persona"][document_type] + + print(f"[debate:{document_type}] Итерация: {persona['name_ru']}") + + round_num = 0 + consensus = False + + while not consensus and round_num < rounds_config["max"]: + round_num += 1 + print(f" Раунд {round_num}/{rounds_config['max']} ({persona['name_ru']})...") + + # Выполнить раунд с фокусом персоны + results = await execute_round_with_persona( + document=document, + persona=persona, + document_type=document_type, + active_models=active_models + ) + + # Проверить консенсус по фокусу персоны + consensus = check_persona_consensus(results, persona) + + if not consensus: + document = update_document_with_feedback(document, results) + all_improvements.extend(results["improvements"]) + + print(f" {persona['name_ru']}: консенсус за {round_num} раунд(ов)") + + return document, all_improvements +``` + +### Вывод прогресса с персонами + +``` +[debate:spec] Фокусированные дебаты (3 персоны) + +[debate:spec] Итерация 1/3: Backend-архитектор + Раунд 1/3 (Backend-архитектор)... + GPT-5.2: MAJOR (API design: missing pagination) + DeepSeek v3.2: CRITICAL (DB schema: no indexes) + Gemini 3 Pro: APPROVED + ... + -> Обновляю спецификацию + + Раунд 2/3 (Backend-архитектор)... + GPT-5.2: APPROVED + DeepSeek v3.2: APPROVED + ... + Backend-архитектор: консенсус за 2 раунда + +[debate:spec] Итерация 2/3: Frontend-архитектор + Раунд 1/3 (Frontend-архитектор)... + GPT-5.2: MINOR (component structure suggestion) + ... + Frontend-архитектор: консенсус за 1 раунд + +[debate:spec] Итерация 3/3: Инженер безопасности + Раунд 1/3 (Инженер безопасности)... + GPT-5.2: MAJOR (missing CSRF protection) + ... + -> Обновляю спецификацию + + Раунд 2/3 (Инженер безопасности)... + ... + Инженер безопасности: консенсус за 2 раунда + +[debate:spec] Все итерации завершены + Улучшений внесено: 7 + Документ обновлён: docs/jira/PROJ-123/spec.md +``` + +## Модели-критики + +| # | Модель | Роль | Фокус | +|---|--------|------|-------| +| 1 | **GPT-5.2** | Lead Technical Critic | High reasoning, техническая корректность | +| 2 | **DeepSeek v3.2** | Architecture Critic | Архитектура, паттерны | +| 3 | **Grok 4.1 Fast** | Edge Case Critic | Граничные случаи | +| 4 | **Gemini 3 Pro** | Scalability Critic | Масштабируемость | +| 5 | **Perplexity Sonar Pro** | Deep Research Critic | Best practices | +| 6 | **GLM 4.7** | Alternative Perspective Critic | Альтернативы | +| 7 | **MiMo-V2-Flash** | Rapid Validation Critic | Санити-чек | + +## Конфигурация раундов по типу документа + +| Тип документа | Мин. раундов | Макс. раундов | Фокус | +|---------------|-------------|---------------|-------| +| **prd** | 2 | 5 | Бизнес-требования, полнота, критерии приёмки | +| **spec** | 2 | 10 | Техническая корректность, архитектура, безопасность | +| **plan** | 1 | 3 | Декомпозиция, зависимости, риски | + +Консенсус: +- 70% моделей APPROVED +- Нет CRITICAL issues +- Макс. 2 MINOR на модель + +## Процесс + +### 1. Pre-hook: Проверка доступности моделей + +Hook `model-availability` автоматически: +1. Проверяет `OPENROUTER_API_KEY` +2. Пингует каждую модель +3. Для недоступных required моделей -> интервью для замены +4. Возвращает список активных моделей + +### 2. Подготовка контекста для моделей + +Промпт адаптируется в зависимости от `document_type`: + +#### Для PRD (`document_type: "prd"`) + +```markdown +## Context + +You are a {role} reviewing a Product Requirements Document (PRD). + +### Your Focus +{focus_description} + +### PRD-Specific Review Criteria +- Completeness of functional and non-functional requirements +- Business value and problem statement clarity +- Acceptance criteria specificity and testability +- Scope definition (in-scope vs out-of-scope) +- Risk assessment and mitigation +- User stories clarity +- MoSCoW prioritization correctness + +### Severity Levels +- CRITICAL: Missing critical requirement or fundamentally wrong assumption +- MAJOR: Incomplete requirement or unclear acceptance criteria +- MINOR: Minor improvement to clarity or structure +- SUGGESTION: Optional enhancement +- APPROVED: PRD is acceptable + +### Task + +Review the following PRD and provide feedback. + +For each issue found: +1. State the severity level +2. Quote the problematic section +3. Explain the issue +4. Suggest a fix + +If the PRD is acceptable, respond with "APPROVED" and brief justification. + +--- + +## PRD + +{document_content} + +--- + +Provide your review: +``` + +#### Для Spec (`document_type: "spec"`) + +```markdown +## Context + +You are a {role} reviewing a technical specification. + +### Your Focus +{focus_description} + +### Spec-Specific Review Criteria +- Architecture correctness and patterns +- API design (RESTfulness, schemas, error handling) +- Database schema (normalization, indexes, migrations) +- Security considerations (auth, validation, injection) +- Performance and scalability +- Testing strategy completeness +- Code examples accuracy + +### Severity Levels +- CRITICAL: Blocks implementation, must be fixed +- MAJOR: Important to fix before implementation +- MINOR: Recommendation, can be deferred +- SUGGESTION: Optional improvement +- APPROVED: Specification is acceptable + +### Task + +Review the following technical specification and provide feedback. + +For each issue found: +1. State the severity level +2. Quote the problematic section +3. Explain the issue +4. Suggest a fix + +If the specification is acceptable, respond with "APPROVED" and brief justification. + +--- + +## Specification + +{document_content} + +--- + +## PRD Reference + +{prd_summary} + +--- + +Provide your review: +``` + +#### Для Plan (`document_type: "plan"`) + +```markdown +## Context + +You are a {role} reviewing an implementation plan. + +### Your Focus +{focus_description} + +### Plan-Specific Review Criteria +- Task decomposition granularity (2-8 hours per task) +- Dependency graph correctness (no circular deps) +- Risk coverage and mitigation strategies +- Execution order feasibility +- Acceptance criteria for each subtask +- Coverage of all spec requirements +- Testing strategy per phase + +### Severity Levels +- CRITICAL: Circular dependency or missing critical task +- MAJOR: Wrong dependency order or insufficient decomposition +- MINOR: Minor improvement to task description +- SUGGESTION: Optional optimization +- APPROVED: Plan is acceptable + +### Task + +Review the following implementation plan and provide feedback. + +For each issue found: +1. State the severity level +2. Quote the problematic section +3. Explain the issue +4. Suggest a fix + +If the plan is acceptable, respond with "APPROVED" and brief justification. + +--- + +## Implementation Plan + +{document_content} + +--- + +## Specification Reference + +{spec_summary} + +--- + +Provide your review: +``` + +### 3. Выполнение раунда + +Для каждого раунда: + +```python +async def execute_round(round_number: int, document_content: str, active_models: list, document_type: str): + results = {} + + # Параллельные запросы ко всем моделям + for model in active_models: + response = await call_openrouter( + model_id=model["id"], + prompt=build_prompt(model["role"], model["focus"], document_content, document_type), + temperature=model["temperature"] + ) + results[model["display_name"]] = parse_review(response) + + return results +``` + +### 4. OpenRouter API вызов + +```python +async def call_openrouter(model_id: str, prompt: str, temperature: float): + headers = { + "Authorization": f"Bearer {OPENROUTER_API_KEY}", + "Content-Type": "application/json", + "HTTP-Referer": "https://jira-workflow-plugin", + "X-Title": "Jira Workflow Debate" + } + + payload = { + "model": model_id, + "messages": [ + {"role": "system", "content": "You are a technical specification reviewer."}, + {"role": "user", "content": prompt} + ], + "temperature": temperature, + "max_tokens": 2000 + } + + response = await httpx.post( + "https://openrouter.ai/api/v1/chat/completions", + headers=headers, + json=payload, + timeout=60.0 + ) + + return response.json()["choices"][0]["message"]["content"] +``` + +### 5. Парсинг результатов + +```python +def parse_review(response: str) -> dict: + """ + Парсит ответ модели и извлекает: + - severity: CRITICAL/MAJOR/MINOR/SUGGESTION/APPROVED + - issues: list of {section, problem, suggestion} + """ + + if "APPROVED" in response.upper() and "CRITICAL" not in response.upper(): + return {"severity": "APPROVED", "issues": [], "summary": response} + + issues = [] + # Parse structured feedback... + + max_severity = determine_max_severity(issues) + + return { + "severity": max_severity, + "issues": issues, + "raw_response": response + } +``` + +### 6. Агрегация и обновление документа + +После каждого раунда: + +```python +def aggregate_feedback(round_results: dict) -> dict: + critical_issues = [] + major_issues = [] + minor_issues = [] + suggestions = [] + + for model, result in round_results.items(): + for issue in result["issues"]: + if issue["severity"] == "CRITICAL": + critical_issues.append({**issue, "source": model}) + elif issue["severity"] == "MAJOR": + major_issues.append({**issue, "source": model}) + # ... + + return { + "critical": critical_issues, + "major": major_issues, + "minor": minor_issues, + "suggestions": suggestions, + "approved_count": count_approved(round_results), + "total_models": len(round_results) + } +``` + +### 7. Проверка консенсуса + +```python +def check_consensus(aggregated: dict, config: dict) -> bool: + approved_ratio = aggregated["approved_count"] / aggregated["total_models"] + + if approved_ratio < config["approved_threshold"]: + return False + + if len(aggregated["critical"]) > 0: + return False + + if config["no_critical_issues"] and len(aggregated["major"]) > 0: + return False + + return True +``` + +### 8. Обновление документа между раундами + +Если консенсус не достигнут: + +```python +def update_document_with_feedback(document_content: str, feedback: dict, document_type: str) -> str: + """ + Обновляет документ на основе CRITICAL и MAJOR issues. + MINOR и SUGGESTION учитываются, но не блокируют. + """ + + changes = [] + + for issue in feedback["critical"] + feedback["major"]: + # Применяем исправление + change = apply_fix(document_content, issue) + changes.append(change) + + # Добавляем changelog + document_content = add_changelog_entry(document_content, changes) + + return document_content +``` + +### 9. Вывод прогресса + +``` +[debate:{document_type}] Проверяю доступность моделей... + GPT-5.2: доступна + DeepSeek v3.2: доступна + Grok 4.1 Fast: доступна + Gemini 3 Pro: доступна + Perplexity Sonar Pro: недоступна (пропущена) + GLM 4.7: доступна + MiMo-V2-Flash: доступна + +[debate:{document_type}] Раунд 1/{max_rounds}... + Отправляю документ 6 моделям... + + GPT-5.2: MAJOR + -> "Не описан retry при ошибках API" + DeepSeek v3.2: CRITICAL + -> "Circular dependency в архитектуре" + Grok 4.1 Fast: MINOR + -> "Edge case: пустой результат" + Gemini 3 Pro: MAJOR + -> "Bottleneck при batch > 100" + GLM 4.7: APPROVED + MiMo-V2-Flash: APPROVED + + Консенсус: 2/6 APPROVED (нужно 70%) + Critical: 1, Major: 2, Minor: 1 + + -> Обновляю документ... + +[debate:{document_type}] Раунд 2/{max_rounds}... + ... + +[debate:{document_type}] Раунд 3/{max_rounds}... + + GPT-5.2: APPROVED + DeepSeek v3.2: APPROVED + Grok 4.1 Fast: APPROVED + Gemini 3 Pro: APPROVED (1 minor) + GLM 4.7: APPROVED + MiMo-V2-Flash: APPROVED + + Консенсус достигнут: 6/6 APPROVED + Сохранено: docs/jira/PROJ-123/debate-log.md +``` + +### 10. Сохранение debate-log.md + +```markdown +# Debate Log: {issue_key} ({document_type}) + +## Summary +- **Document:** {document_type} ({document_path}) +- **Rounds:** 3 +- **Models:** GPT-5.2, DeepSeek v3.2, Grok 4.1 Fast, Gemini 3 Pro, GLM 4.7, MiMo-V2-Flash +- **Status:** CONSENSUS_REACHED +- **Duration:** 15 minutes + +## Round 1 + +### GPT-5.2 (Lead Technical Critic) +**Severity:** MAJOR +**Issue:** +> Section "Error Handling" does not describe retry behavior for API timeouts. + +**Suggestion:** +> Add retry policy with exponential backoff (3 attempts, 2s/4s/8s delays). + +### DeepSeek v3.2 (Architecture Critic) +**Severity:** CRITICAL +**Issue:** +> The proposed architecture creates a circular dependency between ServiceA and ServiceB. + +**Suggestion:** +> Introduce a mediator interface or event-based communication. + +... + +## Round 2 + +### Changes Applied +1. Added "Retry Policy" section (addressing GPT-5.2 feedback) +2. Refactored architecture: introduced EventBus (addressing DeepSeek v3.2) +3. Added pagination for batch operations (addressing Gemini 3 Pro) + +### Model Responses +... + +## Round 3 (Final) + +### Voting Results +| Model | Verdict | Notes | +|-------|---------|-------| +| GPT-5.2 | APPROVED | | +| DeepSeek v3.2 | APPROVED | | +| Grok 4.1 Fast | APPROVED | | +| Gemini 3 Pro | APPROVED | 1 minor: "Consider adding diagram" | +| GLM 4.7 | APPROVED | | +| MiMo-V2-Flash | APPROVED | | + +**Consensus reached: 6/6 models approved** + +## Improvements Made +1. Error handling with retry policy +2. Architecture refactoring (EventBus pattern) +3. Batch operation pagination +4. Additional edge case handling + +## Remaining Minor Issues (deferred) +- Add architecture diagram +- Consider caching strategy for read-heavy operations +``` + +### 11. Прикрепление лога дебатов к Jira + +После завершения debate: + +```python +# Прикрепить debate-log к задаче +attach_artifact(issue_key, f"docs/jira/{issue_key}/debate-log.md") + +# Добавить комментарий +jira_add_comment(issue_key, f""" +🗣 Дебаты завершены ({document_type}) + +Раундов: {rounds_count} +Моделей: {models_count} +Статус: Консенсус достигнут + +Улучшения внесены: +{improvements_list} + +Лог дебатов: docs/jira/{issue_key}/debate-log.md +""") +``` + +### 12. Обновление состояния + +```json +{ + "completed_stages": ["fetch", "interview", "prd", "debate_prd", "spec", "debate_spec"], + "current_stage": "plan", + "debate": { + "prd": { + "rounds": 2, + "consensus": true + }, + "spec": { + "rounds": 3, + "models_participated": 6, + "consensus": true, + "improvements_made": 4 + } + }, + "artifacts": { + "debate_log": "docs/jira/PROJ-123/debate-log.md", + "spec_final": "docs/jira/PROJ-123/spec.md" + } +} +``` + +## Обработка ошибок + +### Модель не отвечает +- Retry 3 раза с exponential backoff +- Если всё равно не отвечает -> пометить как unavailable +- Если required модель -> интервью для замены + +### Превышен max_rounds +- Сохранить текущее состояние +- Уведомить пользователя о нерешённых issues +- Предложить: продолжить вручную или принять как есть + +### Rate limit OpenRouter +- Exponential backoff +- Распределить запросы во времени + +## Выходные данные + +Передаёт в следующий этап: +- Финальный документ (прошедший debate) +- debate-log.md с историей +- Список improvements для включения в следующие этапы diff --git a/.claude/skills/jira-fetch/SKILL.md b/.claude/skills/jira-fetch/SKILL.md new file mode 100755 index 00000000..cb3a7fef --- /dev/null +++ b/.claude/skills/jira-fetch/SKILL.md @@ -0,0 +1,278 @@ +--- +name: jira-fetch +description: Получение задач из Jira по ключу, JQL или интерактивный выбор +triggers: + - jira fetch + - получить задачу jira +--- + +# Jira Fetch Skill + +Получает данные задачи из Jira для последующей обработки в workflow. + +## Входные параметры + +- `issue_key` - ключ задачи (PROJ-123) или список через запятую +- `jql` - JQL-запрос для поиска задач +- `select` - интерактивный режим выбора + +## Процесс + +### 1. Использование Official Atlassian MCP (основной режим) + +**ВАЖНО:** Плагин использует Official Atlassian Remote MCP Server с OAuth авторизацией. + +При первом использовании MCP автоматически запросит OAuth авторизацию через браузер. + +**Доступ к Jira через Atlassian MCP:** + +Claude автоматически видит доступные MCP tools от сервера `atlassian`. +Используй tools с префиксом `atlassian_jira_*` для работы с Jira: + +- Получение issue: ищи tool вроде `atlassian_jira_get_issue` или `get_issue` +- Поиск: ищи tool вроде `atlassian_jira_search` или `search_issues` +- Комментарии: ищи tool вроде `atlassian_jira_add_comment` +- Создание: ищи tool вроде `atlassian_jira_create_issue` + +**Проверка авторизации:** +``` +Вызови tool: atlassian_jira_get_myself (или аналогичный) +Если успешно -> MCP авторизован +Если ошибка auth -> предложить OAuth через AskUserQuestion +``` + +### 2. HTTP Fallback (резервный режим) + +Используется только если Atlassian MCP недоступен И заданы env переменные: +- ATLASSIAN_HOST +- ATLASSIAN_EMAIL +- ATLASSIAN_TOKEN + +### 2. Определение режима работы + +``` +Если указан issue_key: + -> Режим: single или batch (если несколько ключей) +Если указан jql: + -> Режим: jql_search +Если указан select: + -> Режим: interactive +``` + +### 3. Получение данных + +#### Single/Batch режим + +**MCP:** +``` +jira_get_issue(issue_key="PROJ-123") +``` + +**HTTP fallback (через Bash curl):** +```bash +curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \ + -H "Content-Type: application/json" \ + "https://${ATLASSIAN_HOST}/rest/api/3/issue/PROJ-123?expand=transitions" +``` + +Извлеки: +- `key` - ключ задачи +- `fields.summary` - заголовок +- `fields.description` - описание +- `fields.issuetype.name` - тип (Story, Bug, Task, Epic) +- `fields.priority.name` - приоритет +- `fields.assignee.displayName` - исполнитель +- `fields.reporter.displayName` - автор +- `fields.status.name` - статус +- `fields.labels` - метки +- `fields.components` - компоненты +- `fields.fixVersions` - версии +- `fields.subtasks` - подзадачи +- `fields.issuelinks` - связанные задачи +- `fields.attachment` - вложения +- `fields.comment.comments` - комментарии + +#### JQL режим + +**MCP:** +``` +jira_search(jql="project=PROJ AND sprint=current", max_results=50) +``` + +**HTTP fallback:** +```bash +curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \ + -H "Content-Type: application/json" \ + "https://${ATLASSIAN_HOST}/rest/api/3/search?jql=project%3DPROJ%20AND%20sprint%3Dcurrent&maxResults=50" +``` + +#### Interactive режим + +1. Выполни JQL для задач текущего пользователя: + + **MCP:** + ``` + jira_search(jql="(assignee = currentUser() OR reporter = currentUser()) AND status != Done ORDER BY updated DESC", max_results=20) + ``` + + **HTTP fallback:** + ```bash + curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \ + -H "Content-Type: application/json" \ + "https://${ATLASSIAN_HOST}/rest/api/3/search?jql=(assignee%3DcurrentUser()%20OR%20reporter%3DcurrentUser())%20AND%20status!%3DDone%20ORDER%20BY%20updated%20DESC&maxResults=20" + ``` + +2. Покажи список через AskUserQuestion: + ``` + Выберите задачи для обработки: + A) PROJ-123: Добавить фильтр (Story, назначена мне) + B) PROJ-124: Исправить баг (Bug, создана мной) + C) PROJ-125: Рефакторинг API (Task, назначена мне) + D) Показать все задачи спринта + E) Указать свой ключ/JQL + ``` + +3. Если выбрано "Показать все задачи спринта": + **MCP:** + ``` + jira_search(jql="sprint in openSprints() AND status != Done", max_results=20) + ``` + + **HTTP fallback:** + ```bash + curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \ + -H "Content-Type: application/json" \ + "https://${ATLASSIAN_HOST}/rest/api/3/search?jql=sprint%20in%20openSprints()%20AND%20status!%3DDone&maxResults=20" + ``` + +4. Получи полные данные для выбранных задач + +### 4. Определение workflow типа + +На основе `issuetype.name` определи workflow из `config/workflow-types.json`: + +``` +Story -> full +Task -> full +Bug -> quick +Epic -> decompose +Sub-task -> quick +``` + +### 5. Сохранение состояния + +Создай файл состояния `config/state/{issue_key}.json`: + +```json +{ + "issue_key": "PROJ-123", + "workflow": "full", + "current_stage": "fetch", + "completed_stages": ["fetch"], + "jira_access_mode": "mcp", + "jira_data": { + "summary": "...", + "description": "...", + "type": "Story", + "priority": "High" + }, + "started_at": "2025-01-26T10:00:00Z" +} +``` + +### 6. Вывод + +``` +[fetch] Загружено: PROJ-123 (режим: mcp) + Type: Story | Priority: High | Sprint: Sprint 23 + Workflow: full (fetch -> interview -> prd -> debate(prd) -> spec -> debate(spec) -> plan -> debate(plan) -> implement) + + Summary: Добавить экспорт отчётов в PDF + Description: [первые 200 символов...] + + Linked: PROJ-100 (blocks), PROJ-110 (relates to) + Subtasks: нет + Comments: 3 +``` + +## HTTP Fallback: Общие операции + +### Добавление комментария + +**MCP (plain text, НЕ ADF!):** +``` +jira_add_comment( + issue_key="PROJ-123", + comment="Текст комментария с **markdown** разметкой" +) +``` + +**ВАЖНО:** При MCP режиме передавай plain text! MCP сервер сам форматирует. + +**HTTP fallback (требует ADF):** +```bash +curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{"body": {"type": "doc", "version": 1, "content": [{"type": "paragraph", "content": [{"type": "text", "text": "MESSAGE"}]}]}}' \ + "https://${ATLASSIAN_HOST}/rest/api/3/issue/PROJ-123/comment" +``` + +### Создание подзадачи + +**MCP:** +``` +jira_create_issue(project, issue_type="Sub-task", parent, summary, description) +``` + +**HTTP fallback:** +```bash +curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{"fields": {"project": {"key": "PROJ"}, "issuetype": {"name": "Sub-task"}, "parent": {"key": "PROJ-123"}, "summary": "..."}}' \ + "https://${ATLASSIAN_HOST}/rest/api/3/issue" +``` + +### Прикрепление файла + +```bash +curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \ + -X POST \ + -H "X-Atlassian-Token: no-check" \ + -F "file=@docs/jira/PROJ-123/prd.md" \ + "https://${ATLASSIAN_HOST}/rest/api/3/issue/PROJ-123/attachments" +``` + +### Переход статуса (transition) + +**HTTP fallback (получить transitions):** +```bash +curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \ + -H "Content-Type: application/json" \ + "https://${ATLASSIAN_HOST}/rest/api/3/issue/PROJ-123/transitions" +``` + +**HTTP fallback (выполнить transition):** +```bash +curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{"transition": {"id": "TRANSITION_ID"}}' \ + "https://${ATLASSIAN_HOST}/rest/api/3/issue/PROJ-123/transitions" +``` + +## Обработка ошибок + +- `Issue not found` -> Сообщить пользователю, предложить проверить ключ +- `Authentication failed` -> Проверить ATLASSIAN_HOST, ATLASSIAN_EMAIL, ATLASSIAN_TOKEN +- `Rate limit` -> Retry с exponential backoff (3 попытки) +- `MCP unavailable` -> Автоматический переход на HTTP fallback + +## Выходные данные + +Передаёт в следующий этап (interview): +- Полные данные задачи из Jira +- Определённый тип workflow +- Режим доступа к Jira (mcp/http) +- Путь к файлу состояния diff --git a/.claude/skills/jira-fetch/config/state/ERP-43.json b/.claude/skills/jira-fetch/config/state/ERP-43.json new file mode 100644 index 00000000..381ed2dc --- /dev/null +++ b/.claude/skills/jira-fetch/config/state/ERP-43.json @@ -0,0 +1,24 @@ +{ + "issue_key": "ERP-43", + "workflow": "full", + "current_stage": "fetch", + "completed_stages": [ + "fetch" + ], + "jira_access_mode": "http", + "jira_data": { + "id": "11786", + "key": "ERP-43", + "summary": "Создание уведомлений о закрытии смены в ERP", + "description": null, + "type": "Feature", + "status": "В работе", + "priority": "Medium", + "assignee": "Филиппов Алексей", + "reporter": "Филиппов Алексей", + "labels": [], + "components": [], + "subtasks": [] + }, + "started_at": "2026-02-10T15:57:03.681840Z" +} \ No newline at end of file diff --git a/.claude/skills/jira-focus-detect/SKILL.md b/.claude/skills/jira-focus-detect/SKILL.md new file mode 100755 index 00000000..b301ec86 --- /dev/null +++ b/.claude/skills/jira-focus-detect/SKILL.md @@ -0,0 +1,366 @@ +--- +name: jira-focus-detect +description: Автоматическое определение фокусов и персон для дебатов на основе содержимого задачи +triggers: + - detect focus + - определить фокус + - analyze task focus +--- + +# Jira Focus Detect Skill + +Анализирует задачу и определяет релевантные персоны для фокусированных дебатов. + +## Входные параметры + +- `issue_data` — данные задачи из jira-fetch (summary, description, components, labels) +- `codebase_context` — опционально, контекст кодовой базы (затрагиваемые файлы) + +## Процесс определения + +### 1. Загрузка конфигурации + +```python +def load_focus_config(): + """Загружает конфигурацию персон и детекции.""" + config_path = "config/debate-personas.json" + with open(config_path) as f: + config = json.load(f) + return config["focus_detection"], config["personas"] +``` + +### 2. Анализ текста задачи + +Сканирует summary, description и acceptance criteria на ключевые слова. + +```python +def detect_focus_from_text(text: str, keywords: dict) -> dict: + """ + Определяет фокусы по ключевым словам в тексте. + + Returns: + dict: {persona_id: score} + """ + text_lower = text.lower() + scores = {} + matched_keywords = {} + + for persona, kw_list in keywords.items(): + matches = [kw for kw in kw_list if kw in text_lower] + if matches: + scores[persona] = len(matches) + matched_keywords[persona] = matches + + return scores, matched_keywords +``` + +### 3. Анализ компонентов Jira + +```python +def detect_focus_from_components(components: list) -> dict: + """ + Определяет фокусы по компонентам задачи в Jira. + """ + component_mapping = { + # Frontend + "Frontend": "frontend", + "UI": "frontend", + "Web": "frontend", + "Mobile": "frontend", + + # Backend + "Backend": "backend", + "API": "backend", + "Database": "backend", + "Server": "backend", + + # DevOps + "Infrastructure": "devops", + "DevOps": "devops", + "CI/CD": "devops", + "Deployment": "devops", + + # Security + "Security": "security", + "Auth": "security", + "Authentication": "security", + + # QA + "QA": "qa", + "Testing": "qa", + "Quality": "qa" + } + + scores = {} + for comp in components: + comp_name = comp.get("name", comp) if isinstance(comp, dict) else comp + for pattern, persona in component_mapping.items(): + if pattern.lower() in comp_name.lower(): + scores[persona] = scores.get(persona, 0) + 2 + break + + return scores +``` + +### 4. Анализ labels + +```python +def detect_focus_from_labels(labels: list) -> dict: + """ + Определяет фокусы по labels задачи. + """ + label_mapping = { + "frontend": "frontend", + "backend": "backend", + "api": "backend", + "database": "backend", + "devops": "devops", + "infrastructure": "devops", + "security": "security", + "auth": "security", + "testing": "qa", + "e2e": "qa", + "performance": "backend" + } + + scores = {} + for label in labels: + label_lower = label.lower() + for pattern, persona in label_mapping.items(): + if pattern in label_lower: + scores[persona] = scores.get(persona, 0) + 3 + break + + return scores +``` + +### 5. Анализ затрагиваемых файлов (опционально) + +```python +def detect_focus_from_files(files: list, patterns: dict) -> dict: + """ + Определяет фокусы по паттернам файлов. + + Args: + files: список путей к файлам + patterns: component_patterns из конфига + """ + from fnmatch import fnmatch + + scores = {} + for persona, file_patterns in patterns.items(): + for pattern in file_patterns: + matches = [f for f in files if fnmatch(f, pattern) or pattern in f] + if matches: + scores[persona] = scores.get(persona, 0) + len(matches) + + return scores +``` + +### 6. Объединение результатов + +```python +def combine_scores(*score_dicts, weights=None) -> list: + """ + Объединяет scores из разных источников. + + Args: + score_dicts: кортежи (scores_dict, source_name) + weights: веса для источников + + Returns: + list: топ-3 персоны, отсортированные по score + """ + default_weights = { + "text": 1.0, + "components": 2.0, + "labels": 3.0, + "files": 1.5 + } + weights = weights or default_weights + + combined = {} + for scores, source in score_dicts: + weight = weights.get(source, 1.0) + for persona, score in scores.items(): + combined[persona] = combined.get(persona, 0) + (score * weight) + + # Сортировка и топ-3 + sorted_personas = sorted( + combined.items(), + key=lambda x: x[1], + reverse=True + ) + + return [p[0] for p in sorted_personas[:3] if p[1] > 0] +``` + +## Основной процесс + +```python +def detect_task_focus(issue_data: dict, files: list = None) -> dict: + """ + Основная функция определения фокусов задачи. + + Args: + issue_data: данные задачи из Jira + files: опциональный список затрагиваемых файлов + + Returns: + dict: { + "detected_personas": ["backend", "frontend", ...], + "detection_scores": {...}, + "matched_keywords": {...}, + "confidence": "high" | "medium" | "low" + } + """ + detection_config, personas = load_focus_config() + keywords = detection_config["keywords"] + patterns = detection_config["component_patterns"] + + # Собираем текст для анализа + text = " ".join([ + issue_data.get("summary", ""), + issue_data.get("description", ""), + " ".join(issue_data.get("acceptance_criteria", [])) + ]) + + # Анализ из разных источников + text_scores, matched = detect_focus_from_text(text, keywords) + component_scores = detect_focus_from_components( + issue_data.get("components", []) + ) + label_scores = detect_focus_from_labels( + issue_data.get("labels", []) + ) + + score_sources = [ + (text_scores, "text"), + (component_scores, "components"), + (label_scores, "labels") + ] + + # Добавляем файлы если есть + if files: + file_scores = detect_focus_from_files(files, patterns) + score_sources.append((file_scores, "files")) + + # Объединяем + detected = combine_scores(*score_sources) + + # Определяем confidence + total_score = sum( + sum(s.values()) for s, _ in score_sources + ) + confidence = "high" if total_score > 10 else "medium" if total_score > 5 else "low" + + # Fallback если ничего не найдено + if not detected: + detected = ["backend"] # default fallback + confidence = "low" + + return { + "detected_personas": detected, + "detection_scores": dict( + sorted( + {k: v for d, _ in score_sources for k, v in d.items()}.items(), + key=lambda x: x[1], + reverse=True + ) + ), + "matched_keywords": matched, + "confidence": confidence + } +``` + +## Вывод + +``` +[focus-detect] Анализирую задачу PROJ-123... + +Источники: + • Текст задачи: api, database, endpoint, authentication + • Компоненты Jira: Backend, API + • Labels: backend, security + +Обнаруженные фокусы: + 1. backend (score: 12) — найдено: api, database, endpoint, service + 2. security (score: 6) — найдено: authentication, token + 3. frontend (score: 2) — найдено: form, page + +Confidence: high + +Рекомендуемые персоны для дебатов: + • Backend-архитектор (primary) + • Инженер безопасности + +Подтвердить? [Y/n/edit] +``` + +## Интерактивный режим + +Если `debate_config.allow_user_override: true`: + +``` +Выберите персоны для дебатов: + + [x] 1) Backend-архитектор (рекомендован) + [x] 2) Инженер безопасности (рекомендован) + [ ] 3) Frontend-архитектор + [ ] 4) DevOps-инженер + [ ] 5) QA-инженер + +Введите номера через пробел или Enter для рекомендованных: _ +``` + +## Выходные данные + +```json +{ + "detected_personas": ["backend", "security"], + "detection_scores": { + "backend": 12, + "security": 6, + "frontend": 2, + "devops": 0, + "qa": 1 + }, + "matched_keywords": { + "backend": ["api", "database", "endpoint", "service"], + "security": ["authentication", "token"] + }, + "confidence": "high", + "user_confirmed": true, + "user_modified": false +} +``` + +## Интеграция с workflow + +Вызывается из `jira-debate` перед началом дебатов: + +```python +# В jira-debate +if auto_detect_focus and not personas: + focus_result = invoke_skill("jira-focus-detect", issue_data=issue_data) + personas = focus_result["detected_personas"] + + if focus_result["confidence"] == "low": + # Запросить подтверждение у пользователя + personas = confirm_personas_with_user(personas) +``` + +## Сохранение в state + +Результат сохраняется в `config/state/{ISSUE-KEY}.json`: + +```json +{ + "focus_detection": { + "detected_at": "2026-01-28T12:00:00Z", + "personas": ["backend", "security"], + "confidence": "high", + "scores": {...} + } +} +``` diff --git a/.claude/skills/jira-implement/SKILL.md b/.claude/skills/jira-implement/SKILL.md new file mode 100755 index 00000000..9c6950c6 --- /dev/null +++ b/.claude/skills/jira-implement/SKILL.md @@ -0,0 +1,385 @@ +--- +name: jira-implement +description: Реализация кода по плану с автоматическим обновлением статусов +triggers: + - jira implement + - начать реализацию + - implement plan +--- + +# Jira Implement Skill + +Выполняет реализацию кода по плану, отслеживая прогресс через TaskUpdate и синхронизируя с Jira. + +## Входные данные + +- plan.md с декомпозицией +- Локальные tasks (созданные jira-plan) +- Subtasks в Jira + +## Процесс + +### 1. Загрузка плана и задач + +```python +def load_implementation_context(issue_key: str) -> dict: + # Загрузить plan + plan = read_file(f"docs/jira/{issue_key}/plan.md") + + # Загрузить state + state = read_state_file(f"config/state/{issue_key}.json") + + # Получить список tasks + tasks = TaskList() + + # Отфильтровать tasks для этого issue + issue_tasks = [ + t for t in tasks + if t.metadata.get("jira_subtask", "").startswith(issue_key) + ] + + return { + "plan": plan, + "state": state, + "tasks": issue_tasks + } +``` + +### 2. Определение следующей задачи + +```python +def get_next_task(tasks: list) -> Optional[dict]: + """ + Найти следующую задачу для выполнения: + 1. Статус "pending" + 2. Нет blockedBy или все blockers completed + """ + for task in tasks: + if task["status"] != "pending": + continue + + blocked_by = task.get("blockedBy", []) + if not blocked_by: + return task + + # Проверить что все blockers completed + all_blockers_done = all( + get_task(blocker_id)["status"] == "completed" + for blocker_id in blocked_by + ) + + if all_blockers_done: + return task + + return None +``` + +### 3. Выполнение задачи + +Для каждой задачи из плана: + +```python +async def execute_task(task: dict, context: dict): + task_id = task["id"] + jira_subtask = task["metadata"]["jira_subtask"] + + # 1. Пометить как in_progress + TaskUpdate(taskId=task_id, status="in_progress") + + # 2. Sync с Jira + await jira_transition_issue(jira_subtask, "In Progress") + await jira_add_comment(jira_subtask, "🚀 Started implementation") + + # 3. Извлечь детали из плана + subtask_details = extract_subtask_from_plan(context["plan"], jira_subtask) + + # 4. Выполнить реализацию + result = await implement_subtask(subtask_details) + + # 5. Пометить как completed + TaskUpdate(taskId=task_id, status="completed") + + # 6. Sync с Jira + await jira_transition_issue(jira_subtask, "Done") + await jira_add_comment(jira_subtask, f"✅ Completed\n\nChanges:\n{result['summary']}") + + return result +``` + +### 4. Реализация по типу subtask + +#### Database Migration + +```python +async def implement_db_migration(details: dict): + """ + 1. Создать файл миграции + 2. Применить миграцию + 3. Проверить результат + """ + + # Создать миграцию + migration_content = generate_migration(details["schema"]) + Write( + file_path=f"db/migrations/versions/{timestamp}_{details['name']}.py", + content=migration_content + ) + + # Применить + Bash(command="alembic upgrade head") + + # Проверить + Bash(command="alembic current") + + return {"type": "migration", "file": migration_file} +``` + +#### Models + +```python +async def implement_models(details: dict): + """ + 1. Создать SQLAlchemy модель + 2. Создать Pydantic схемы + 3. Обновить __init__.py + """ + + # Модель + model_content = generate_model(details["model"]) + Write( + file_path=f"src/models/{details['model_name']}.py", + content=model_content + ) + + # Схемы + schema_content = generate_schemas(details["model"]) + Write( + file_path=f"src/schemas/{details['model_name']}.py", + content=schema_content + ) + + # Обновить экспорты + Edit( + file_path="src/models/__init__.py", + old_string="# models", + new_string=f"# models\nfrom .{details['model_name']} import {details['class_name']}" + ) + + return {"type": "models", "files": [model_file, schema_file]} +``` + +#### Service + +```python +async def implement_service(details: dict): + """ + 1. Создать сервис с методами из spec + 2. Включить improvements из debate (retry policy и т.д.) + """ + + service_content = generate_service( + details["service"], + debate_improvements=details.get("debate_improvements", []) + ) + + Write( + file_path=f"src/services/{details['service_name']}.py", + content=service_content + ) + + return {"type": "service", "file": service_file} +``` + +#### API Endpoint + +```python +async def implement_api(details: dict): + """ + 1. Создать route с валидацией + 2. Добавить Swagger docs + 3. Включить debate improvements (pagination и т.д.) + """ + + api_content = generate_api_endpoint( + details["endpoint"], + debate_improvements=details.get("debate_improvements", []) + ) + + Write( + file_path=f"src/api/{details['resource']}.py", + content=api_content + ) + + return {"type": "api", "file": api_file} +``` + +#### Tests + +```python +async def implement_tests(details: dict): + """ + 1. Unit тесты для сервиса + 2. Integration тесты для API + 3. Edge cases из interview + """ + + # Unit tests + unit_content = generate_unit_tests(details["service"]) + Write( + file_path=f"tests/unit/test_{details['service_name']}.py", + content=unit_content + ) + + # Integration tests + integration_content = generate_integration_tests(details["api"]) + Write( + file_path=f"tests/integration/test_{details['resource']}_api.py", + content=integration_content + ) + + # Запустить тесты + Bash(command=f"pytest tests/ -v") + + return {"type": "tests", "files": [unit_file, integration_file]} +``` + +### 5. Обработка ошибок + +```python +async def handle_implementation_error(task: dict, error: Exception): + """ + При ошибке: + 1. НЕ помечать task как completed + 2. Добавить комментарий в Jira + 3. Сохранить состояние для resume + """ + + jira_subtask = task["metadata"]["jira_subtask"] + + await jira_add_comment(jira_subtask, f""" +⚠️ Implementation error + +``` +{str(error)} +``` + +Task remains in progress. Manual intervention may be required. +""") + + # Сохранить состояние + save_state({ + "current_task": task["id"], + "error": str(error), + "can_resume": True + }) + + raise ImplementationError(f"Failed to implement {jira_subtask}: {error}") +``` + +### 6. Вывод прогресса + +``` +[implement] Начинаю реализацию PROJ-123... + + План: 5 subtasks + + [1/5] PROJ-123-1: Database Migration + ↻ Starting... + ✓ Migration created + ✓ Migration applied + ✓ Completed + + [2/5] PROJ-123-2: SQLAlchemy Models + ↻ Starting... + ✓ Model created + ✓ Schemas created + ✓ Exports updated + ✓ Completed + + [3/5] PROJ-123-3: Service Layer + ↻ Starting... + ✓ Service created (with retry policy from debate) + ✓ Completed + + [4/5] PROJ-123-4: API Endpoint + ↻ Starting... + ✓ Endpoint created (with pagination from debate) + ✓ Swagger docs added + ✓ Completed + + [5/5] PROJ-123-5: Tests + ↻ Starting... + ✓ Unit tests: 12 passed + ✓ Integration tests: 4 passed + ✓ Completed + + ═══════════════════════════════════════ + ✅ Implementation complete! + + Files created: 8 + Files modified: 3 + Tests: 16 passed + + All subtasks synced with Jira. + Parent issue PROJ-123 ready for review. +``` + +### 7. Финализация + +```python +async def finalize_implementation(issue_key: str, results: list): + # Обновить состояние + update_state(issue_key, { + "completed_stages": [..., "implement"], + "current_stage": "completed", + "implementation": { + "subtasks_completed": len(results), + "files_created": count_created(results), + "files_modified": count_modified(results), + "tests_passed": count_tests(results) + } + }) + + # Финальный комментарий в Jira + await jira_add_comment(issue_key, f""" +🎉 Implementation completed! + +**Summary:** +- Subtasks: {len(results)} completed +- Files created: {count_created(results)} +- Files modified: {count_modified(results)} +- Tests: {count_tests(results)} passed + +**Artifacts:** +- PRD: docs/jira/{issue_key}/prd.md +- Spec: docs/jira/{issue_key}/spec.md +- Debate: docs/jira/{issue_key}/debate-log.md +- Plan: docs/jira/{issue_key}/plan.md + +Ready for code review. +""") +``` + +## Выходные данные + +```json +{ + "issue_key": "PROJ-123", + "status": "completed", + "subtasks_completed": 5, + "files": { + "created": ["migration.py", "model.py", "schema.py", "service.py", "api.py", "tests.py"], + "modified": ["__init__.py", "routes.py"] + }, + "tests": { + "unit": 12, + "integration": 4, + "passed": 16, + "failed": 0 + }, + "debate_improvements_applied": [ + "retry_policy", + "pagination" + ] +} +``` diff --git a/.claude/skills/jira-interview/SKILL.md b/.claude/skills/jira-interview/SKILL.md new file mode 100755 index 00000000..34ff35c6 --- /dev/null +++ b/.claude/skills/jira-interview/SKILL.md @@ -0,0 +1,224 @@ +--- +name: jira-interview +description: Интервью для уточнения требований задачи (adversarial-spec style) +triggers: + - jira interview + - уточнить требования +--- + +# Jira Interview Skill + +Проводит структурированное интервью для уточнения требований перед генерацией документации. + +## Входные данные + +- Данные задачи из jira-fetch +- Тип workflow (определяет min_questions) + +## Количество вопросов + +``` +full: min 5 вопросов +quick: min 2 вопроса +decompose: min 7 вопросов +``` + +## Процесс + +### 1. Анализ входных данных + +Проанализируй из Jira: +- `description` - основное описание +- `comments` - комментарии (часто содержат уточнения) +- `attachments` - вложения (скриншоты, документы) +- `issuelinks` - связанные задачи (контекст) + +### 2. Определение пробелов + +Проверь наличие: + +| Аспект | Проверка | +|--------|----------| +| Acceptance Criteria | Есть ли чёткие критерии готовности? | +| Scope | Определены ли границы задачи? | +| Edge Cases | Описаны ли граничные случаи? | +| Dependencies | Указаны ли зависимости? | +| Non-functional | Есть ли требования к производительности/безопасности? | +| UI/UX | Для фронтенд-задач: есть ли макеты? | +| Data | Какие данные нужны? Откуда? | +| Error Handling | Как обрабатывать ошибки? | + +### 3. Генерация вопросов + +Используй AskUserQuestion tool. **Один вопрос за раз!** + +#### Категории вопросов + +**Scope (границы):** +``` +Что НЕ входит в эту задачу? +A) Только базовая функциональность X +B) X + интеграция с Y +C) Полная реализация X, Y, Z +D) Другое +``` + +**Edge Cases (граничные случаи):** +``` +Что делать, если [условие]? +A) Показать ошибку пользователю +B) Использовать значение по умолчанию +C) Пропустить операцию +D) Другое +``` + +**Dependencies (зависимости):** +``` +От каких компонентов/сервисов зависит эта задача? +A) Только внутренние API +B) Внешние сервисы (указать) +C) База данных (новые таблицы/поля) +D) Другое +``` + +**Acceptance Criteria (критерии готовности):** +``` +Как проверить, что задача выполнена? +A) Unit-тесты покрывают основные сценарии +B) E2E тест на happy path +C) Ручное тестирование по чеклисту +D) Другое +``` + +**Non-functional (нефункциональные):** +``` +Есть ли требования к производительности? +A) Нет особых требований +B) Время ответа < 200ms +C) Поддержка 1000+ одновременных запросов +D) Другое +``` + +**Data (данные):** +``` +Какие данные нужны для этой функции? +A) Существующие данные из БД +B) Новые поля в существующих таблицах +C) Новые таблицы/коллекции +D) Внешние источники данных +``` + +**Error Handling (обработка ошибок):** +``` +Как обрабатывать ошибки при [операция]? +A) Показать сообщение пользователю +B) Retry автоматически +C) Логировать и продолжить +D) Откатить транзакцию +``` + +### 4. Адаптивные вопросы + +На основе предыдущих ответов генерируй follow-up вопросы: + +``` +Если ответ на "Scope" = C (полная реализация): + -> Добавь вопросы о приоритизации компонентов + -> Уточни порядок реализации + +Если ответ на "Dependencies" = B (внешние сервисы): + -> Спроси о fallback при недоступности сервиса + -> Уточни SLA внешнего сервиса +``` + +### 5. Сохранение результатов + +После завершения интервью создай `docs/jira/{issue_key}/interview.md`: + +```markdown +# Interview: PROJ-123 + +**Дата:** 2025-01-26 +**Задача:** Добавить экспорт отчётов в PDF + +## Вопросы и ответы + +### Q1: Scope +**Вопрос:** Что НЕ входит в эту задачу? +**Ответ:** B) X + интеграция с Y +**Комментарий пользователя:** Интеграция с Y нужна для... + +### Q2: Edge Cases +**Вопрос:** Что делать при пустом отчёте? +**Ответ:** A) Показать сообщение "Нет данных" + +### Q3: Dependencies +... + +## Extracted Requirements + +На основе интервью определены требования: + +### Функциональные +- FR-1: Экспорт текущего отчёта в PDF +- FR-2: Batch-экспорт нескольких отчётов +- FR-3: Интеграция с сервисом Y + +### Нефункциональные +- NFR-1: Время генерации PDF < 5 секунд +- NFR-2: Поддержка файлов до 50MB + +### Ограничения +- Не включает: экспорт в другие форматы (Excel, CSV) +- Зависит от: ReportService, PDFGenerator + +### Acceptance Criteria +- [ ] Unit-тесты покрывают основные сценарии +- [ ] E2E тест на batch-экспорт 10 отчётов +``` + +### 6. Обновление состояния + +Обнови `config/state/{issue_key}.json`: + +```json +{ + "completed_stages": ["fetch", "interview"], + "current_stage": "prd", + "interview": { + "questions_asked": 5, + "artifact": "docs/jira/PROJ-123/interview.md" + } +} +``` + +### 7. Синхронизация с Jira (комментарий на русском) + +Добавь summary интервью в комментарий Jira: + +``` +jira_add_comment(issue_key, """ +📋 Интервью завершено ({N} вопросов) + +Ключевые решения: +- Границы: базовая функциональность + интеграция с Y +- Производительность: время генерации < 5 сек +- Обработка ошибок: показ сообщения пользователю + +Полное интервью: docs/jira/PROJ-123/interview.md +""") +``` + +## Пропуск интервью + +Если указан флаг `--no-interview`: +1. Пропустить этап +2. Использовать description и comments из Jira как есть +3. Логировать предупреждение о возможных пробелах + +## Выходные данные + +Передаёт в следующий этап (prd): +- Файл interview.md с Q&A +- Extracted requirements +- Обновлённое состояние diff --git a/.claude/skills/jira-plan/SKILL.md b/.claude/skills/jira-plan/SKILL.md new file mode 100755 index 00000000..566dadc0 --- /dev/null +++ b/.claude/skills/jira-plan/SKILL.md @@ -0,0 +1,375 @@ +--- +name: jira-plan +description: Декомпозиция задачи на subtasks и создание плана реализации +triggers: + - jira plan + - создать план + - decompose task +--- + +# Jira Plan Skill + +Декомпозирует задачу на subtasks, создаёт план реализации и синхронизирует с Jira. + +## Входные данные + +- Финальная спецификация (после debate) +- debate-log.md с improvements +- Конфигурация workflow + +## Процесс + +### 1. Анализ спецификации + +Извлеки из spec.md: +- API changes -> subtasks для каждого endpoint +- Database changes -> subtask для миграций +- Service changes -> subtasks для каждого сервиса +- Tests -> subtask для тестирования + +### 2. Анализ debate improvements + +Из debate-log.md извлеки improvements, которые нужно включить в план: + +```python +def extract_debate_improvements(debate_log: str) -> list: + """ + Извлекает improvements из debate: + - Added retry policy + - Refactored architecture + - Added pagination + """ + # Parse "Improvements Made" section + return improvements +``` + +### 3. Генерация subtasks + +Правила декомпозиции: + +```yaml +rules: + - name: "One concern per subtask" + description: "Каждый subtask решает одну задачу" + + - name: "Testable outcome" + description: "Результат subtask можно проверить" + + - name: "Estimated size" + description: "Subtask занимает 2-8 часов работы" + + - name: "Clear dependencies" + description: "Зависимости между subtasks явные" +``` + +### 4. Структура плана + +Создай `docs/jira/{issue_key}/plan.md`: + +```markdown +# Implementation Plan: {issue_key} + +**Spec:** docs/jira/{issue_key}/spec.md +**Debate:** docs/jira/{issue_key}/debate-log.md +**Date:** {current_date} + +--- + +## Overview + +{Краткое описание что будет реализовано} + +Total subtasks: {N} +Estimated effort: {hours} hours + +## Dependencies Graph + +``` +[PROJ-123-1: DB Migration] + | +[PROJ-123-2: Models] --> [PROJ-123-4: API Endpoint] + | | +[PROJ-123-3: Service] <----------+ + | +[PROJ-123-5: Tests] +``` + +## Subtasks + +### PROJ-123-1: Database Migration + +**Description:** +Создать миграцию для новой таблицы `{table_name}`. + +**Acceptance Criteria:** +- [ ] Миграция создана в `db/migrations/versions/` +- [ ] Миграция применяется без ошибок +- [ ] Rollback работает корректно + +**Files to modify:** +- `db/migrations/versions/{timestamp}_{name}.py` (create) + +**Blocked by:** None +**Blocks:** PROJ-123-2, PROJ-123-3 + +--- + +### PROJ-123-2: SQLAlchemy Models + +**Description:** +Создать модель `{ModelName}` и соответствующие DTO. + +**Acceptance Criteria:** +- [ ] Модель создана в `models/` +- [ ] DTO созданы в `schemas/` +- [ ] Связи с другими моделями настроены + +**Files to modify:** +- `src/models/{model_name}.py` (create) +- `src/schemas/{model_name}.py` (create) +- `src/models/__init__.py` (modify) + +**Blocked by:** PROJ-123-1 +**Blocks:** PROJ-123-3, PROJ-123-4 + +--- + +### PROJ-123-3: Service Layer + +**Description:** +Реализовать `{ServiceName}Service` с бизнес-логикой. + +**Acceptance Criteria:** +- [ ] Сервис создан в `services/` +- [ ] CRUD операции реализованы +- [ ] Error handling добавлен (включая retry policy из debate) + +**Files to modify:** +- `src/services/{service_name}.py` (create) +- `src/services/__init__.py` (modify) + +**Blocked by:** PROJ-123-2 +**Blocks:** PROJ-123-4 + +**Debate improvements included:** +- Retry policy with exponential backoff (GPT-5.2) + +--- + +### PROJ-123-4: API Endpoint + +**Description:** +Создать REST endpoint `{method} /api/v1/{resource}`. + +**Acceptance Criteria:** +- [ ] Endpoint создан в `api/` +- [ ] Валидация входных данных +- [ ] Swagger документация +- [ ] Pagination добавлена (из debate) + +**Files to modify:** +- `src/api/{resource}.py` (create or modify) +- `src/api/__init__.py` (modify) + +**Blocked by:** PROJ-123-3 +**Blocks:** PROJ-123-5 + +**Debate improvements included:** +- Pagination for batch operations (Gemini 3 Pro) + +--- + +### PROJ-123-5: Tests + +**Description:** +Написать unit и integration тесты. + +**Acceptance Criteria:** +- [ ] Unit тесты для сервиса (>80% coverage) +- [ ] Integration тесты для API +- [ ] Edge cases из interview покрыты + +**Files to modify:** +- `tests/unit/test_{service_name}.py` (create) +- `tests/integration/test_{resource}_api.py` (create) + +**Blocked by:** PROJ-123-4 +**Blocks:** None + +--- + +## Execution Order + +1. **Phase 1: Foundation** + - PROJ-123-1: Database Migration + +2. **Phase 2: Core** + - PROJ-123-2: Models + - PROJ-123-3: Service (after models) + +3. **Phase 3: Interface** + - PROJ-123-4: API Endpoint + +4. **Phase 4: Quality** + - PROJ-123-5: Tests + +## Risk Mitigation + +| Risk | Mitigation | +|------|------------| +| {risk_from_spec} | {mitigation} | + +## Notes + +- {any_additional_notes} +``` + +### 5. Создание subtasks в Jira + +**MCP:** +```python +async def create_jira_subtasks(parent_key: str, subtasks: list): + created = [] + + for subtask in subtasks: + result = await jira_create_issue( + project=extract_project(parent_key), + issue_type="Sub-task", + parent=parent_key, + summary=subtask["summary"], + description=format_subtask_description(subtask), + labels=["auto-generated", "jira-workflow"] + ) + created.append(result["key"]) + + return created +``` + +**HTTP fallback:** +```bash +curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{"fields": {"project": {"key": "PROJ"}, "issuetype": {"name": "Sub-task"}, "parent": {"key": "PROJ-123"}, "summary": "...", "description": {"type": "doc", "version": 1, "content": [{"type": "paragraph", "content": [{"type": "text", "text": "..."}]}]}, "labels": ["auto-generated", "jira-workflow"]}}' \ + "https://${ATLASSIAN_HOST}/rest/api/3/issue" +``` + +### 6. Создание TaskCreate для текущей сессии + +Параллельно создай задачи через TaskCreate: + +```python +for subtask in subtasks: + TaskCreate( + subject=f"{subtask['key']}: {subtask['summary']}", + description=subtask["description"], + activeForm=f"Реализую {subtask['summary']}", + metadata={ + "jira_subtask": subtask["key"], + "blocked_by": subtask["blocked_by"], + "blocks": subtask["blocks"] + } + ) +``` + +### 7. Установка зависимостей + +```python +# После создания всех задач, установи зависимости +for subtask in subtasks: + if subtask["blocked_by"]: + TaskUpdate( + taskId=subtask["task_id"], + addBlockedBy=[find_task_id(dep) for dep in subtask["blocked_by"]] + ) +``` + +### 8. Обновление состояния + +```json +{ + "completed_stages": ["fetch", "interview", "prd", "debate_prd", "spec", "debate_spec", "plan"], + "current_stage": "debate_plan", + "plan": { + "subtasks_count": 5, + "jira_subtasks": ["PROJ-123-1", "PROJ-123-2", "PROJ-123-3", "PROJ-123-4", "PROJ-123-5"], + "local_tasks": [1, 2, 3, 4, 5] + }, + "artifacts": { + "plan": "docs/jira/PROJ-123/plan.md" + } +} +``` + +### 9. Прикрепление плана к задаче Jira + +```python +# Прикрепить план как вложение +attach_artifact(issue_key, f"docs/jira/{issue_key}/plan.md") +``` + +**HTTP fallback:** +```bash +curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \ + -X POST \ + -H "X-Atlassian-Token: no-check" \ + -F "file=@docs/jira/${ISSUE_KEY}/plan.md" \ + "https://${ATLASSIAN_HOST}/rest/api/3/issue/${ISSUE_KEY}/attachments" +``` + +### 10. Синхронизация с Jira (комментарий на русском) + +Добавь комментарий к родительской задаче: + +```python +jira_add_comment(parent_key, f""" +📋 План реализации создан + +Подзадачи: +{format_subtasks_list(created_subtasks)} + +Документ плана: docs/jira/{parent_key}/plan.md + +Фазы: +1. Фундамент (миграции БД) +2. Ядро (модели, сервисы) +3. Интерфейс (API) +4. Качество (тесты) + +Готово к реализации. +""") +``` + +### 11. Вывод + +``` +[plan] Создаю план реализации... + + Анализ спецификации: + - API changes: 2 endpoints + - Database: 1 new table + - Services: 1 new service + - Debate improvements: 2 + + Декомпозиция на subtasks: + PROJ-123-1: Database Migration + PROJ-123-2: SQLAlchemy Models + PROJ-123-3: Service Layer (+ retry policy) + PROJ-123-4: API Endpoint (+ pagination) + PROJ-123-5: Tests + + Subtasks созданы в Jira + Tasks созданы локально (TaskCreate) + Зависимости установлены + План сохранён: docs/jira/PROJ-123/plan.md + План прикреплён к задаче в Jira + + -> Переход к этапу: debate (документ: Plan) +``` + +## Выходные данные + +Передаёт в следующий этап (debate Plan): +- plan.md с декомпозицией +- Созданные subtasks в Jira +- Локальные tasks с зависимостями diff --git a/.claude/skills/jira-prd/SKILL.md b/.claude/skills/jira-prd/SKILL.md new file mode 100755 index 00000000..3b9687e7 --- /dev/null +++ b/.claude/skills/jira-prd/SKILL.md @@ -0,0 +1,267 @@ +--- +name: jira-prd +description: Генерация Product Requirements Document на основе данных Jira и интервью +triggers: + - jira prd + - создать prd +--- + +# Jira PRD Skill + +Генерирует Product Requirements Document на основе данных из Jira и результатов интервью. + +## Входные данные + +- Данные задачи из Jira (jira-fetch) +- Результаты интервью (jira-interview) +- Тип workflow + +## Когда создаётся PRD + +``` +full workflow: ДА +quick workflow: НЕТ (пропускается) +decompose workflow: ДА +``` + +## Процесс + +### 1. Сбор контекста + +Прочитай: +- `config/state/{issue_key}.json` - состояние workflow +- `docs/jira/{issue_key}/interview.md` - результаты интервью +- Связанные задачи из Jira (если есть) + +### 2. Структура PRD + +Создай `docs/jira/{issue_key}/prd.md`: + +```markdown +# PRD: {summary из Jira} + +**Issue:** {issue_key} +**Type:** {issue_type} +**Priority:** {priority} +**Author:** {reporter} +**Date:** {current_date} +**Status:** Draft + +--- + +## 1. Executive Summary + +{Краткое описание задачи в 2-3 предложениях} + +## 2. Problem Statement + +### 2.1 Background +{Контекст и предыстория задачи} + +### 2.2 Problem +{Какую проблему решаем} + +### 2.3 Impact +{Влияние проблемы на бизнес/пользователей} + +## 3. Goals & Success Metrics + +### 3.1 Goals +- **Primary:** {основная цель} +- **Secondary:** {дополнительные цели} + +### 3.2 Success Metrics +| Metric | Current | Target | +|--------|---------|--------| +| {metric_name} | {current_value} | {target_value} | + +### 3.3 Non-Goals +{Что явно НЕ является целью этой задачи} + +## 4. Requirements + +### 4.1 Functional Requirements + +| ID | Requirement | Priority | Source | +|----|-------------|----------|--------| +| FR-1 | {requirement} | Must Have | Interview Q1 | +| FR-2 | {requirement} | Should Have | Jira Description | +| FR-3 | {requirement} | Nice to Have | Interview Q3 | + +### 4.2 Non-Functional Requirements + +| ID | Category | Requirement | +|----|----------|-------------| +| NFR-1 | Performance | {requirement} | +| NFR-2 | Security | {requirement} | +| NFR-3 | Scalability | {requirement} | + +### 4.3 Constraints +- {constraint_1} +- {constraint_2} + +### 4.4 Assumptions +- {assumption_1} +- {assumption_2} + +## 5. User Stories + +### 5.1 Primary User Story +``` +As a {user_type} +I want to {action} +So that {benefit} +``` + +### 5.2 Additional User Stories +{Дополнительные user stories если применимо} + +## 6. Scope + +### 6.1 In Scope +- {item_1} +- {item_2} + +### 6.2 Out of Scope +- {item_1} - {reason} +- {item_2} - {reason} + +### 6.3 Future Considerations +- {item_1} - planned for {version/sprint} + +## 7. Dependencies + +### 7.1 Internal Dependencies +| Component | Type | Status | +|-----------|------|--------| +| {component} | Required | Available | + +### 7.2 External Dependencies +| Service | Type | SLA | +|---------|------|-----| +| {service} | Required | 99.9% | + +### 7.3 Blockers +{Текущие блокеры если есть} + +## 8. Risks + +| Risk | Probability | Impact | Mitigation | +|------|-------------|--------|------------| +| {risk} | Medium | High | {mitigation} | + +## 9. Timeline + +| Milestone | Target Date | Notes | +|-----------|-------------|-------| +| Spec Review | {date} | | +| Implementation | {date} | | +| Testing | {date} | | +| Release | {date} | | + +## 10. Acceptance Criteria + +- [ ] {criterion_1} +- [ ] {criterion_2} +- [ ] {criterion_3} + +--- + +## Appendix + +### A. Related Issues +- {PROJ-XXX}: {summary} (blocks/relates to) + +### B. Interview Summary +{Ссылка на interview.md} + +### C. Glossary +| Term | Definition | +|------|------------| +| {term} | {definition} | + +--- + +**Changelog:** +| Version | Date | Author | Changes | +|---------|------|--------|---------| +| 0.1 | {date} | Claude | Initial draft | +``` + +### 3. Правила генерации + +1. **Используй только фактические данные** из Jira и интервью +2. **Не придумывай** requirements, которых нет в источниках +3. **Отмечай неясности** как TBD (To Be Determined) +4. **Приоритизация** по MoSCoW: Must Have, Should Have, Could Have, Won't Have +5. **Трассируемость** - указывай источник каждого requirement + +### 4. Обновление состояния + +```json +{ + "completed_stages": ["fetch", "interview", "prd"], + "current_stage": "debate_prd", + "artifacts": { + "interview": "docs/jira/PROJ-123/interview.md", + "prd": "docs/jira/PROJ-123/prd.md" + } +} +``` + +### 5. Прикрепление PRD к задаче Jira + +```python +# Прикрепить PRD как вложение +attach_artifact(issue_key, "docs/jira/{issue_key}/prd.md") +``` + +**HTTP fallback:** +```bash +curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \ + -X POST \ + -H "X-Atlassian-Token: no-check" \ + -F "file=@docs/jira/${ISSUE_KEY}/prd.md" \ + "https://${ATLASSIAN_HOST}/rest/api/3/issue/${ISSUE_KEY}/attachments" +``` + +### 6. Синхронизация с Jira (комментарий на русском) + +Добавь комментарий: + +``` +jira_add_comment(issue_key, """ +📄 PRD создан + +Документ: docs/jira/PROJ-123/prd.md + +Ключевые требования: +- FR-1: {summary} +- FR-2: {summary} +- NFR-1: {summary} + +Готово к созданию технической спецификации. +""") +``` + +### 7. Вывод + +``` +[prd] PRD сгенерирован + Сохранено: docs/jira/PROJ-123/prd.md + Прикреплён к задаче в Jira + Ссылка добавлена в Jira + + Requirements summary: + - Functional: 5 (3 Must Have, 2 Should Have) + - Non-functional: 3 + - Out of scope: 2 items + + -> Переход к этапу: debate (документ: PRD) +``` + +## Выходные данные + +Передаёт в следующий этап (debate PRD): +- Файл prd.md +- Структурированные requirements для спецификации diff --git a/.claude/skills/jira-report/SKILL.md b/.claude/skills/jira-report/SKILL.md new file mode 100755 index 00000000..d9f0656a --- /dev/null +++ b/.claude/skills/jira-report/SKILL.md @@ -0,0 +1,409 @@ +--- +name: jira-report +description: Генерация финального отчёта о проделанной работе перед имплементацией +triggers: + - jira report + - отчёт jira + - workflow report + - generate report +--- + +# Jira Report Skill + +Генерирует итоговый отчёт перед началом имплементации. + +## Входные параметры + +- `issue_key` — ключ задачи +- `state` — текущее состояние workflow из `config/state/{issue_key}.json` + +## Выходной файл + +`docs/jira/{ISSUE-KEY}/report.md` + +## Процесс генерации + +### 1. Загрузка данных + +```python +def load_report_data(issue_key: str) -> dict: + """Собирает все данные для отчёта.""" + + # Загрузить state + state_path = f"config/state/{issue_key}.json" + with open(state_path) as f: + state = json.load(f) + + # Загрузить артефакты + artifacts_dir = f"docs/jira/{issue_key}" + artifacts = {} + + for artifact in ["interview.md", "prd.md", "spec.md", "debate-log.md", "plan.md"]: + path = f"{artifacts_dir}/{artifact}" + if os.path.exists(path): + with open(path) as f: + artifacts[artifact] = f.read() + + return { + "state": state, + "artifacts": artifacts, + "issue_key": issue_key + } +``` + +### 2. Извлечение метрик + +```python +def extract_metrics(data: dict) -> dict: + """Извлекает метрики из артефактов и состояния.""" + + state = data["state"] + artifacts = data["artifacts"] + + # Метрики дебатов + debate_metrics = { + "prd": extract_debate_metrics(state.get("debate", {}).get("prd", {})), + "spec": extract_debate_metrics(state.get("debate", {}).get("spec", {})), + "plan": extract_debate_metrics(state.get("debate", {}).get("plan", {})) + } + + # Подсчёт подзадач из plan.md + subtasks = parse_subtasks_from_plan(artifacts.get("plan.md", "")) + + # Извлечение архитектуры из spec.md + architecture = extract_architecture_summary(artifacts.get("spec.md", "")) + + return { + "debates": debate_metrics, + "subtasks": subtasks, + "architecture": architecture, + "total_improvements": sum( + d.get("improvements", 0) for d in debate_metrics.values() + ), + "total_rounds": sum( + d.get("rounds", 0) for d in debate_metrics.values() + ) + } +``` + +### 3. Генерация отчёта + +```python +def generate_report(issue_key: str, data: dict, metrics: dict) -> str: + """Генерирует markdown отчёт.""" + + state = data["state"] + issue_data = state.get("issue_data", {}) + + report = f"""# Отчёт о подготовке к реализации: {issue_key} + +**Задача:** {issue_data.get("summary", "N/A")} +**Тип:** {issue_data.get("issue_type", "N/A")} +**Приоритет:** {issue_data.get("priority", "N/A")} +**Workflow:** {state.get("workflow_type", "full")} + +--- + +## 1. Обзор + +### Цель +{extract_goal_from_prd(data["artifacts"].get("prd.md", ""))} + +### Scope +**В scope:** +{extract_in_scope(data["artifacts"].get("prd.md", ""))} + +**Вне scope:** +{extract_out_of_scope(data["artifacts"].get("prd.md", ""))} + +--- + +## 2. Созданные документы + +| Документ | Файл | Статус | +|----------|------|--------| +| Интервью | interview.md | {"✅ Завершено" if "interview.md" in data["artifacts"] else "⏳ Не создан"} | +| PRD | prd.md | {"✅ Завершено" if "prd.md" in data["artifacts"] else "⏳ Не создан"} | +| Спецификация | spec.md | {"✅ Завершено" if "spec.md" in data["artifacts"] else "⏳ Не создан"} | +| Лог дебатов | debate-log.md | {"✅ Завершено" if "debate-log.md" in data["artifacts"] else "⏳ Не создан"} | +| План | plan.md | {"✅ Завершено" if "plan.md" in data["artifacts"] else "⏳ Не создан"} | + +--- + +## 3. Дебаты + +### Статистика + +| Документ | Персоны | Раундов | Улучшений | +|----------|---------|---------|-----------| +| PRD | {format_personas(metrics["debates"]["prd"])} | {metrics["debates"]["prd"].get("rounds", 0)} | {metrics["debates"]["prd"].get("improvements", 0)} | +| Spec | {format_personas(metrics["debates"]["spec"])} | {metrics["debates"]["spec"].get("rounds", 0)} | {metrics["debates"]["spec"].get("improvements", 0)} | +| Plan | {format_personas(metrics["debates"]["plan"])} | {metrics["debates"]["plan"].get("rounds", 0)} | {metrics["debates"]["plan"].get("improvements", 0)} | + +### Участвовавшие модели + +{format_models_table(state.get("active_models", []))} + +### Персоны и фокусы + +{format_personas_details(state.get("focus_detection", {}))} + +### Ключевые улучшения + +{format_improvements_list(state.get("improvements", []))} + +--- + +## 4. План реализации + +### Подзадачи + +| # | Ключ | Название | Зависит от | Оценка | +|---|------|----------|------------|--------| +{format_subtasks_table(metrics["subtasks"])} + +### Граф зависимостей + +``` +{generate_dependency_graph(metrics["subtasks"])} +``` + +--- + +## 5. Технические решения + +### Архитектура +{metrics["architecture"].get("summary", "Не определена")} + +### API +{metrics["architecture"].get("api_summary", "Не определено")} + +### База данных +{metrics["architecture"].get("db_summary", "Не определено")} + +### Безопасность +{metrics["architecture"].get("security_summary", "Не определено")} + +--- + +## 6. Риски и mitigation + +{format_risks_table(state.get("risks", []))} + +--- + +## 7. Рекомендации + +### Рекомендуемые скиллы для реализации + +{format_recommended_skills(state.get("skill_recommendations", {}))} + +### Дополнительные проверки + +{format_additional_checks(state.get("additional_checks", []))} + +--- + +**Отчёт сгенерирован:** {datetime.now().isoformat()} +**Версия плагина:** 2.0.0 + +--- + +> Для начала реализации выполните: +> `/jira-workflow {issue_key} --continue-to-implement` +""" + + return report +``` + +## Структура отчёта + +### Секция 1: Обзор +- Цель из PRD +- Scope (in/out) + +### Секция 2: Созданные документы +- Таблица артефактов со статусом + +### Секция 3: Дебаты +- Статистика по документам +- Участвовавшие модели +- Персоны и фокусы +- Ключевые улучшения + +### Секция 4: План реализации +- Таблица подзадач +- ASCII граф зависимостей + +### Секция 5: Технические решения +- Архитектура +- API +- База данных +- Безопасность + +### Секция 6: Риски +- Таблица рисков с mitigation + +### Секция 7: Рекомендации +- Скиллы для реализации +- Дополнительные проверки + +## Вспомогательные функции + +### Генерация графа зависимостей + +```python +def generate_dependency_graph(subtasks: list) -> str: + """Генерирует ASCII граф зависимостей.""" + + lines = [] + for task in subtasks: + deps = task.get("depends_on", []) + if deps: + for dep in deps: + lines.append(f"{dep} --> {task['key']}") + else: + lines.append(f"[start] --> {task['key']}") + + # Простой ASCII формат + graph = [] + levels = calculate_task_levels(subtasks) + + for level, tasks in enumerate(levels): + if level == 0: + graph.append(" " + " ".join(f"[{t}]" for t in tasks)) + else: + prev_level = levels[level - 1] + # Draw connections + connections = [] + for task in tasks: + task_deps = next( + (t["depends_on"] for t in subtasks if t["key"] == task), + [] + ) + for dep in task_deps: + if dep in prev_level: + connections.append(f" |") + if connections: + graph.append(" " + " ".join(connections)) + graph.append(" " + " ".join(f"[{t}]" for t in tasks)) + + return "\n".join(graph) +``` + +### Форматирование таблицы подзадач + +```python +def format_subtasks_table(subtasks: list) -> str: + """Форматирует подзадачи в markdown таблицу.""" + lines = [] + for i, task in enumerate(subtasks, 1): + deps = ", ".join(task.get("depends_on", [])) or "—" + estimate = task.get("estimate", "—") + lines.append( + f"| {i} | {task['key']} | {task['summary']} | {deps} | {estimate} |" + ) + return "\n".join(lines) +``` + +## Прикрепление к Jira + +После генерации: + +```python +def finalize_report(issue_key: str, report_path: str): + """Прикрепляет отчёт к Jira и добавляет комментарий.""" + + # 1. Прикрепить report.md как вложение + attach_artifact(issue_key, report_path) + + # 2. Добавить комментарий + invoke_skill("jira-comment", + issue_key=issue_key, + stage="report", + context={ + "artifacts_count": 5, + "debates_count": 3, + "improvements_count": 12, + "subtasks_count": 5 + } + ) + + # 3. Обновить статус (опционально) + # update_issue_status(issue_key, "Ready for Development") +``` + +## Вывод в консоль + +``` +[report] Генерирую итоговый отчёт для PROJ-123... + + Документы: 5 файлов + Дебаты: 3 документа, 7 раундов, 12 улучшений + План: 5 подзадач + + Отчёт: docs/jira/PROJ-123/report.md + Прикреплён к задаче: ✓ + + Готово к реализации! +``` + +## Основной процесс + +```python +def generate_workflow_report(issue_key: str) -> str: + """Главная функция генерации отчёта.""" + + print(f"[report] Генерирую итоговый отчёт для {issue_key}...") + + # 1. Загрузить данные + data = load_report_data(issue_key) + + # 2. Извлечь метрики + metrics = extract_metrics(data) + + print(f" Документы: {len(data['artifacts'])} файлов") + print(f" Дебаты: {len([d for d in metrics['debates'].values() if d])} документа, " + f"{metrics['total_rounds']} раундов, {metrics['total_improvements']} улучшений") + print(f" План: {len(metrics['subtasks'])} подзадач") + + # 3. Сгенерировать отчёт + report = generate_report(issue_key, data, metrics) + + # 4. Сохранить + report_path = f"docs/jira/{issue_key}/report.md" + os.makedirs(os.path.dirname(report_path), exist_ok=True) + with open(report_path, 'w') as f: + f.write(report) + + print(f" Отчёт: {report_path}") + + # 5. Прикрепить к Jira + finalize_report(issue_key, report_path) + print(f" Прикреплён к задаче: ✓") + + print(f"\n Готово к реализации!") + + return report_path +``` + +## Интеграция с workflow + +Вызывается автоматически после `debate_plan` и перед `implement`: + +```python +# В jira-workflow +if current_stage == "debate_plan" and consensus_reached: + # Генерируем отчёт перед переходом к implement + report_path = invoke_skill("jira-report", issue_key=issue_key) + + # Показываем пользователю + print(f"\n[workflow] Отчёт готов: {report_path}") + print("[workflow] Проверьте отчёт перед началом реализации.") + + # Спрашиваем подтверждение + confirm = ask_user("Начать реализацию?", options=["Да", "Нет, нужны правки"]) + + if confirm == "Да": + proceed_to_implement() +``` diff --git a/.claude/skills/jira-skill-recommend/SKILL.md b/.claude/skills/jira-skill-recommend/SKILL.md new file mode 100755 index 00000000..0d01f127 --- /dev/null +++ b/.claude/skills/jira-skill-recommend/SKILL.md @@ -0,0 +1,393 @@ +--- +name: jira-skill-recommend +description: Проверка и рекомендация релевантных скиллов для задачи на основе фокусов +triggers: + - recommend skills + - рекомендовать скиллы + - skill recommendations +--- + +# Jira Skill Recommend + +Анализирует задачу и рекомендует подключить релевантные скиллы для улучшения качества документации и реализации. + +## Входные параметры + +- `issue_data` — данные задачи из jira-fetch +- `focuses` — обнаруженные фокусы из jira-focus-detect (опционально) + +## Маппинг фокусов на скиллы + +```json +{ + "frontend": [ + "frontend-design", + "react-modernization", + "design-system-patterns", + "e2e-testing-patterns", + "nextjs-app-router-patterns", + "mobile-ios-design", + "mobile-android-design" + ], + "backend": [ + "api-design-principles", + "architecture-patterns", + "auth-implementation-patterns", + "error-handling-patterns", + "microservices-patterns", + "sql-optimization-patterns", + "database-migration", + "async-python-patterns", + "dotnet-backend-patterns", + "fastapi-templates" + ], + "devops": [ + "deployment-pipeline-design", + "k8s-manifest-generator", + "gitlab-ci-patterns", + "cost-optimization", + "prometheus-configuration", + "grafana-dashboards", + "secrets-management", + "workflow-orchestration-patterns" + ], + "security": [ + "auth-implementation-patterns", + "secrets-management", + "memory-safety-patterns" + ], + "qa": [ + "e2e-testing-patterns", + "python-testing-patterns", + "debugging-strategies", + "llm-evaluation" + ], + "python": [ + "async-python-patterns", + "python-packaging", + "python-performance-optimization", + "python-testing-patterns", + "uv-package-manager", + "fastapi-templates" + ], + "ai_ml": [ + "langchain-architecture", + "llm-evaluation", + "ml-pipeline-workflow", + "prompt-engineering-patterns", + "rag-implementation", + "hybrid-search-implementation", + "similarity-search-patterns", + "vector-index-tuning" + ], + "api": [ + "api-design-principles", + "openapi-spec-generation" + ], + "database": [ + "postgresql-table-design", + "sql-optimization-patterns", + "database-migration" + ], + "git": [ + "git-advanced-workflows", + "monorepo-management" + ] +} +``` + +## Процесс + +### 1. Определить фокусы задачи + +```python +def get_task_focuses(issue_data: dict, provided_focuses: list = None) -> list: + """Получает фокусы задачи.""" + + if provided_focuses: + return provided_focuses + + # Вызвать jira-focus-detect + focus_result = invoke_skill("jira-focus-detect", issue_data=issue_data) + return focus_result["detected_personas"] +``` + +### 2. Получить список установленных скиллов + +```python +def get_installed_skills() -> list: + """Получает список установленных скиллов из Claude Code.""" + + # Через Claude Code API или filesystem + skills_dir = os.path.expanduser("~/.claude/plugins") + installed = [] + + for plugin_dir in os.listdir(skills_dir): + plugin_path = os.path.join(skills_dir, plugin_dir) + if os.path.isdir(plugin_path): + skills_path = os.path.join(plugin_path, "skills") + if os.path.isdir(skills_path): + for skill_dir in os.listdir(skills_path): + skill_file = os.path.join(skills_path, skill_dir, "SKILL.md") + if os.path.exists(skill_file): + installed.append(skill_dir) + + # Также проверить встроенные скиллы + builtin_skills = [ + "frontend-design", + "api-design-principles", + "architecture-patterns", + "debugging-strategies", + # ... и другие из списка в Skill tool + ] + + return list(set(installed + builtin_skills)) +``` + +### 3. Определить рекомендуемые скиллы + +```python +def get_recommended_skills(focuses: list, skill_mapping: dict) -> list: + """Определяет рекомендуемые скиллы на основе фокусов.""" + + recommended = [] + for focus in focuses: + if focus in skill_mapping: + recommended.extend(skill_mapping[focus]) + + # Убрать дубликаты, сохранив порядок + seen = set() + unique = [] + for skill in recommended: + if skill not in seen: + seen.add(skill) + unique.append(skill) + + return unique +``` + +### 4. Проверить покрытие + +```python +def check_skill_coverage(recommended: list, installed: list) -> dict: + """Проверяет какие рекомендуемые скиллы установлены.""" + + installed_set = set(installed) + + available = [s for s in recommended if s in installed_set] + missing = [s for s in recommended if s not in installed_set] + + coverage = len(available) / len(recommended) if recommended else 1.0 + + return { + "available": available, + "missing": missing, + "coverage": coverage, + "coverage_percent": int(coverage * 100) + } +``` + +### 5. Приоритизация скиллов + +```python +def prioritize_skills(skills: list, focuses: list, skill_mapping: dict) -> list: + """Сортирует скиллы по релевантности к фокусам.""" + + skill_scores = {} + for skill in skills: + score = 0 + for focus in focuses: + if skill in skill_mapping.get(focus, []): + # Больший score для первых фокусов + focus_idx = focuses.index(focus) + score += 10 - focus_idx + skill_scores[skill] = score + + return sorted(skills, key=lambda s: skill_scores.get(s, 0), reverse=True) +``` + +## Основной процесс + +```python +def recommend_skills(issue_data: dict, focuses: list = None) -> dict: + """Главная функция рекомендации скиллов.""" + + print(f"[skill-recommend] Анализирую задачу...") + + # 1. Получить фокусы + focuses = get_task_focuses(issue_data, focuses) + print(f"Обнаруженные фокусы: {', '.join(focuses)}") + + # 2. Загрузить маппинг + skill_mapping = load_skill_mapping() + + # 3. Получить рекомендуемые скиллы + recommended = get_recommended_skills(focuses, skill_mapping) + + # 4. Получить установленные скиллы + installed = get_installed_skills() + + # 5. Проверить покрытие + coverage = check_skill_coverage(recommended, installed) + + # 6. Приоритизировать + available_prioritized = prioritize_skills( + coverage["available"], focuses, skill_mapping + ) + + return { + "focuses": focuses, + "recommended": recommended, + "available": available_prioritized, + "missing": coverage["missing"], + "coverage": coverage["coverage"], + "coverage_percent": coverage["coverage_percent"] + } +``` + +## Вывод + +``` +[skill-recommend] Анализирую задачу PROJ-123... + +Обнаруженные фокусы: backend, security, python + +Рекомендуемые скиллы: + ✅ api-design-principles (установлен) + ✅ architecture-patterns (установлен) + ✅ auth-implementation-patterns (установлен) + ❌ error-handling-patterns (не установлен) + ✅ python-testing-patterns (установлен) + ❌ secrets-management (не установлен) + +Покрытие: 66% (4/6 скиллов) + +Рекомендация: Установите недостающие скиллы для лучшего качества: + + # Установка через superpowers plugin + Скиллы доступны в superpowers плагине + +Использовать доступные скиллы при генерации документации? [Y/n] +``` + +## Интерактивный режим + +```python +def interactive_skill_selection(result: dict) -> list: + """Позволяет пользователю выбрать скиллы для использования.""" + + print("\nВыберите скиллы для использования:") + print("") + + all_skills = result["available"] + result["missing"] + + for i, skill in enumerate(all_skills, 1): + status = "✅" if skill in result["available"] else "❌" + recommended = "рекомендован" if skill in result["recommended"][:3] else "" + print(f" [{status}] {i}) {skill} {recommended}") + + print("") + selection = input("Введите номера через пробел (Enter для рекомендованных): ") + + if not selection.strip(): + # Использовать топ-3 доступных + return result["available"][:3] + + selected = [] + for num in selection.split(): + try: + idx = int(num) - 1 + if 0 <= idx < len(all_skills): + skill = all_skills[idx] + if skill in result["available"]: + selected.append(skill) + else: + print(f" ⚠ {skill} не установлен, пропущен") + except ValueError: + pass + + return selected if selected else result["available"][:3] +``` + +## Интеграция с PRD/Spec + +При генерации PRD и Spec можно использовать рекомендованные скиллы: + +```python +def enhance_document_with_skills(document: str, skills: list, doc_type: str) -> str: + """Улучшает документ с помощью выбранных скиллов.""" + + for skill in skills: + print(f"[{doc_type}] Применяю скилл: {skill}") + + # Вызвать скилл для получения рекомендаций + skill_output = invoke_skill(skill, context={ + "document_type": doc_type, + "content_preview": document[:2000] + }) + + # Добавить секцию с рекомендациями скилла + if skill_output.get("recommendations"): + document += f"\n\n## Рекомендации от {skill}\n\n" + document += skill_output["recommendations"] + + return document +``` + +## Сохранение в state + +```json +{ + "skill_recommendations": { + "focuses": ["backend", "security", "python"], + "recommended": ["api-design-principles", "architecture-patterns", "..."], + "available": ["api-design-principles", "architecture-patterns", "..."], + "missing": ["error-handling-patterns", "secrets-management"], + "coverage": 0.66, + "used_in_prd": ["api-design-principles"], + "used_in_spec": ["api-design-principles", "architecture-patterns"], + "used_in_plan": [] + } +} +``` + +## Интеграция с workflow + +Вызывается после `jira-focus-detect` и перед `jira-prd`: + +```python +# В jira-workflow +if current_stage == "interview": + # После интервью определяем фокусы и скиллы + focus_result = invoke_skill("jira-focus-detect", issue_data=issue_data) + + skill_result = invoke_skill("jira-skill-recommend", + issue_data=issue_data, + focuses=focus_result["detected_personas"] + ) + + # Сохраняем в state + state["focus_detection"] = focus_result + state["skill_recommendations"] = skill_result + + # Показываем пользователю + if skill_result["coverage"] < 0.5: + print(f"[workflow] Низкое покрытие скиллами ({skill_result['coverage_percent']}%)") + print("[workflow] Качество документации может быть ниже") +``` + +## Описания скиллов для отчёта + +```python +SKILL_DESCRIPTIONS = { + "api-design-principles": "REST/GraphQL дизайн, версионирование, документация", + "architecture-patterns": "Clean Architecture, Hexagonal, DDD", + "auth-implementation-patterns": "JWT, OAuth2, сессии, RBAC", + "error-handling-patterns": "Exceptions, Result types, graceful degradation", + "frontend-design": "UI компоненты, дизайн-система, responsive", + "e2e-testing-patterns": "Playwright, Cypress, test automation", + "python-testing-patterns": "pytest, fixtures, mocking, TDD", + "secrets-management": "Vault, AWS Secrets Manager, encryption", + # ... и другие +} +``` diff --git a/.claude/skills/jira-spec/SKILL.md b/.claude/skills/jira-spec/SKILL.md new file mode 100755 index 00000000..e92b0174 --- /dev/null +++ b/.claude/skills/jira-spec/SKILL.md @@ -0,0 +1,462 @@ +--- +name: jira-spec +description: Генерация технической спецификации на основе PRD +triggers: + - jira spec + - создать спецификацию + - technical specification +--- + +# Jira Spec Skill + +Генерирует техническую спецификацию для реализации на основе PRD и данных проекта. + +## Входные данные + +- PRD (или interview если quick workflow) +- Структура проекта (автоматический анализ) +- Существующие паттерны кода + +## Процесс + +### 1. Анализ кодовой базы + +Используй Glob и Grep для понимания структуры проекта: + +```bash +# Структура директорий +Glob: **/*.py, **/*.ts, **/*.js + +# Существующие модели +Grep: "class.*Model", "interface.*" + +# API endpoints +Grep: "@app.route", "@router", "app.get", "app.post" + +# Существующие сервисы +Grep: "class.*Service", "class.*Repository" +``` + +### 2. Структура спецификации + +Создай `docs/jira/{issue_key}/spec.md`: + +```markdown +# Technical Specification: {issue_key} + +**PRD:** docs/jira/{issue_key}/prd.md +**Date:** {current_date} +**Author:** Claude +**Status:** Draft (pending debate) + +--- + +## 1. Overview + +### 1.1 Summary +{Техническое описание в 2-3 предложениях} + +### 1.2 Goals +{Технические цели реализации} + +### 1.3 Background +{Технический контекст, связь с существующей архитектурой} + +## 2. Architecture + +### 2.1 High-Level Design + +``` ++-------------+ +-------------+ +-------------+ +| Client |---->| API |---->| Service | ++-------------+ +-------------+ +-------------+ + | + v + +-------------+ + | Database | + +-------------+ +``` + +### 2.2 Component Diagram + +{Диаграмма компонентов в ASCII или Mermaid} + +### 2.3 Data Flow + +``` +1. Client sends request to /api/v1/{endpoint} +2. API validates input using {schema} +3. Service processes request +4. Repository persists data +5. Response returned to client +``` + +## 3. Detailed Design + +### 3.1 API Changes + +#### New Endpoints + +| Method | Endpoint | Description | Request | Response | +|--------|----------|-------------|---------|----------| +| POST | /api/v1/{resource} | Create | {schema} | {schema} | +| GET | /api/v1/{resource}/{id} | Get by ID | - | {schema} | + +#### Request Schema + +```json +{ + "field1": "string", + "field2": 123, + "nested": { + "field3": true + } +} +``` + +#### Response Schema + +```json +{ + "id": "uuid", + "field1": "string", + "created_at": "datetime" +} +``` + +#### Error Responses + +| Status | Code | Description | +|--------|------|-------------| +| 400 | VALIDATION_ERROR | Invalid input | +| 404 | NOT_FOUND | Resource not found | +| 500 | INTERNAL_ERROR | Server error | + +### 3.2 Database Changes + +#### New Tables + +```sql +CREATE TABLE {table_name} ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + {field1} VARCHAR(255) NOT NULL, + {field2} INTEGER, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_{table_name}_{field1} ON {table_name}({field1}); +``` + +#### Schema Changes + +```sql +ALTER TABLE {existing_table} +ADD COLUMN {new_field} VARCHAR(100); +``` + +#### Migration Strategy + +1. Create new tables/columns +2. Backfill data if needed +3. Update application code +4. Remove deprecated columns (if any) + +### 3.3 Service Layer + +#### New Services + +```python +class {ServiceName}Service: + """ + {Description} + """ + + def __init__(self, repository: {Repository}): + self.repository = repository + + async def create(self, data: {CreateDTO}) -> {Entity}: + """Create new {entity}""" + pass + + async def get_by_id(self, id: UUID) -> Optional[{Entity}]: + """Get {entity} by ID""" + pass +``` + +#### Modified Services + +```python +# {existing_service}.py +# Add new method: + +async def {new_method}(self, params: {Params}) -> {Result}: + """ + {Description} + """ + pass +``` + +### 3.4 Models + +#### New Models + +```python +class {ModelName}(Base): + __tablename__ = "{table_name}" + + id = Column(UUID, primary_key=True, default=uuid4) + {field1} = Column(String(255), nullable=False) + {field2} = Column(Integer) + + # Relationships + {relation} = relationship("{RelatedModel}", back_populates="{back_ref}") +``` + +#### DTOs + +```python +class {ModelName}Create(BaseModel): + field1: str + field2: Optional[int] = None + +class {ModelName}Response(BaseModel): + id: UUID + field1: str + created_at: datetime + + class Config: + from_attributes = True +``` + +## 4. Implementation Details + +### 4.1 Key Algorithms + +{Описание ключевых алгоритмов если применимо} + +### 4.2 External Integrations + +| Service | Purpose | Auth | Endpoint | +|---------|---------|------|----------| +| {service} | {purpose} | API Key | {url} | + +### 4.3 Configuration + +```python +# config.py +{CONFIG_VAR}: str = Field(default="{default}", env="{ENV_VAR}") +``` + +### 4.4 Feature Flags + +| Flag | Default | Description | +|------|---------|-------------| +| FEATURE_{NAME} | False | Enable {feature} | + +## 5. Testing Strategy + +### 5.1 Unit Tests + +```python +# test_{module}.py + +def test_{function}_success(): + """Test {function} with valid input""" + pass + +def test_{function}_invalid_input(): + """Test {function} with invalid input""" + pass + +def test_{function}_edge_case(): + """Test {function} edge case: {description}""" + pass +``` + +### 5.2 Integration Tests + +```python +# test_{module}_integration.py + +async def test_{endpoint}_create(): + """Test POST /api/v1/{resource}""" + pass + +async def test_{endpoint}_get(): + """Test GET /api/v1/{resource}/{id}""" + pass +``` + +### 5.3 Test Coverage Requirements + +- Unit tests: >80% coverage +- Integration tests: all API endpoints +- Edge cases: all identified in interview + +## 6. Security Considerations + +### 6.1 Authentication +{Требования к аутентификации} + +### 6.2 Authorization +{Требования к авторизации} + +### 6.3 Data Validation +{Правила валидации входных данных} + +### 6.4 Sensitive Data +{Обработка чувствительных данных} + +## 7. Performance Considerations + +### 7.1 Expected Load +- Requests per second: {rps} +- Data volume: {volume} + +### 7.2 Optimization Strategies +- {strategy_1} +- {strategy_2} + +### 7.3 Caching +| Data | Strategy | TTL | +|------|----------|-----| +| {data} | {strategy} | {ttl} | + +## 8. Monitoring & Observability + +### 8.1 Metrics +| Metric | Type | Description | +|--------|------|-------------| +| {metric_name} | Counter/Gauge/Histogram | {description} | + +### 8.2 Logging +```python +logger.info("{event}", extra={"field": value}) +``` + +### 8.3 Alerts +| Condition | Severity | Action | +|-----------|----------|--------| +| {condition} | Warning/Critical | {action} | + +## 9. Rollout Plan + +### 9.1 Deployment Steps +1. Deploy database migrations +2. Deploy application with feature flag OFF +3. Enable feature flag for 10% users +4. Monitor metrics +5. Gradual rollout to 100% + +### 9.2 Rollback Plan +1. Disable feature flag +2. Revert application deployment +3. Rollback database migrations (if safe) + +## 10. Open Questions + +- [ ] {question_1} - TBD +- [ ] {question_2} - TBD + +--- + +## Appendix + +### A. File Changes Summary + +| File | Action | Description | +|------|--------|-------------| +| {path/to/file.py} | Create | New service | +| {path/to/existing.py} | Modify | Add method | + +### B. Dependencies + +| Package | Version | Purpose | +|---------|---------|---------| +| {package} | {version} | {purpose} | + +--- + +**Changelog:** +| Version | Date | Author | Changes | +|---------|------|--------|---------| +| 0.1 | {date} | Claude | Initial draft | +``` + +### 3. Правила генерации + +1. **Соответствие кодовой базе** - использовать существующие паттерны и соглашения +2. **Конкретные примеры** - реальный код, не псевдокод +3. **Трассируемость к PRD** - каждое решение связано с requirement +4. **Готовность к debate** - достаточно деталей для критики + +### 4. Обновление состояния + +```json +{ + "completed_stages": ["fetch", "interview", "prd", "debate_prd", "spec"], + "current_stage": "debate_spec", + "artifacts": { + "spec": "docs/jira/PROJ-123/spec.md" + } +} +``` + +### 5. Прикрепление спецификации к задаче Jira + +```python +# Прикрепить spec как вложение +attach_artifact(issue_key, f"docs/jira/{issue_key}/spec.md") +``` + +**HTTP fallback:** +```bash +curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \ + -X POST \ + -H "X-Atlassian-Token: no-check" \ + -F "file=@docs/jira/${ISSUE_KEY}/spec.md" \ + "https://${ATLASSIAN_HOST}/rest/api/3/issue/${ISSUE_KEY}/attachments" +``` + +### 6. Синхронизация с Jira (комментарий на русском) + +Добавь комментарий: + +``` +jira_add_comment(issue_key, """ +📝 Техническая спецификация создана + +Документ: docs/jira/PROJ-123/spec.md + +Изменения: +- API: {N} новых endpoints +- БД: {N} таблиц/миграций +- Сервисы: {N} новых/{N} изменённых + +Готово к дебатам (adversarial review). +""") +``` + +### 7. Вывод + +``` +[spec] Спецификация сгенерирована + Сохранено: docs/jira/PROJ-123/spec.md + Прикреплена к задаче в Jira + Ссылка добавлена в Jira + + Changes summary: + - API: 2 new endpoints + - Database: 1 new table, 1 migration + - Services: 1 new, 1 modified + - Tests: 8 unit, 4 integration + + -> Переход к этапу: debate (документ: Spec) +``` + +## Выходные данные + +Передаёт в следующий этап (debate Spec): +- Файл spec.md для критики AI-моделями +- Summary изменений diff --git a/.claude/skills/jira-sync/SKILL.md b/.claude/skills/jira-sync/SKILL.md new file mode 100755 index 00000000..f08effc0 --- /dev/null +++ b/.claude/skills/jira-sync/SKILL.md @@ -0,0 +1,314 @@ +--- +name: jira-sync +description: Синхронизация статусов и артефактов между локальными tasks и Jira +triggers: + - jira sync + - sync jira + - синхронизировать jira +--- + +# Jira Sync Skill + +Синхронизирует статусы задач, комментарии и артефакты между локальным workflow и Jira. + +## Режимы работы + +1. **Auto-sync** — автоматически через hook при завершении task +2. **Manual sync** — по команде `/jira-sync` +3. **Batch sync** — синхронизация всех открытых задач + +## Процесс + +### 1. Определение режима доступа к Jira + +Используется `jira_access_mode` из состояния workflow (`config/state/{issue_key}.json`). +Если `"mcp"` — используются MCP tools, если `"http"` — HTTP fallback через curl. + +### 2. Получение текущего состояния + +```python +def get_sync_state(issue_key: str) -> dict: + # Локальное состояние + local_state = read_state_file(f"config/state/{issue_key}.json") + + # Состояние в Jira (MCP или HTTP) + jira_issue = await jira_get_issue(issue_key) + jira_subtasks = [ + await jira_get_issue(st["key"]) + for st in jira_issue["fields"]["subtasks"] + ] + + return { + "local": local_state, + "jira": { + "parent": jira_issue, + "subtasks": jira_subtasks + } + } +``` + +**HTTP fallback для get_issue:** +```bash +curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \ + -H "Content-Type: application/json" \ + "https://${ATLASSIAN_HOST}/rest/api/3/issue/${ISSUE_KEY}" +``` + +### 3. Синхронизация статусов subtasks + +Маппинг статусов: + +```python +STATUS_MAPPING = { + # Local TaskUpdate status -> Jira transition + "pending": "To Do", + "in_progress": "In Progress", + "completed": "Done" +} + +async def sync_subtask_status(local_task: dict, jira_subtask: dict): + local_status = local_task["status"] + jira_status = jira_subtask["fields"]["status"]["name"] + + expected_jira_status = STATUS_MAPPING[local_status] + + if jira_status != expected_jira_status: + # Получить доступные transitions + transitions = await jira_get_transitions(jira_subtask["key"]) + + # Найти нужный transition + target_transition = find_transition(transitions, expected_jira_status) + + if target_transition: + await jira_transition_issue( + jira_subtask["key"], + target_transition["id"] + ) + return {"synced": True, "from": jira_status, "to": expected_jira_status} + + return {"synced": False, "reason": "already_in_sync"} +``` + +**HTTP fallback для transitions:** +```bash +# Получить transitions +curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \ + -H "Content-Type: application/json" \ + "https://${ATLASSIAN_HOST}/rest/api/3/issue/${SUBTASK_KEY}/transitions" + +# Выполнить transition +curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{"transition": {"id": "TRANSITION_ID"}}' \ + "https://${ATLASSIAN_HOST}/rest/api/3/issue/${SUBTASK_KEY}/transitions" +``` + +### 4. Синхронизация комментариев + +При важных событиях добавляй комментарии в Jira на русском языке: + +```python +SYNC_EVENTS = { + "task_started": "🚀 Начата работа над подзадачей", + "task_completed": "✅ Подзадача завершена", + "task_blocked": "🚫 Заблокировано: {blocker}", + "code_committed": "📝 Код закоммичен: {commit_hash}", + "pr_created": "🔀 PR создан: {pr_url}" +} + +async def sync_comment(event: str, subtask_key: str, context: dict): + message = SYNC_EVENTS[event].format(**context) + await jira_add_comment(subtask_key, message) +``` + +**HTTP fallback для add_comment:** +```bash +curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \ + -X POST \ + -H "Content-Type: application/json" \ + -d '{"body": {"type": "doc", "version": 1, "content": [{"type": "paragraph", "content": [{"type": "text", "text": "MESSAGE"}]}]}}' \ + "https://${ATLASSIAN_HOST}/rest/api/3/issue/${SUBTASK_KEY}/comment" +``` + +### 5. Прикрепление артефактов + +Прикрепляет файлы артефактов к задаче Jira: + +```python +async def attach_artifacts(issue_key: str, artifacts: dict, access_mode: str): + """ + Прикрепляет файлы артефактов к задаче Jira. + """ + for artifact_type, path in artifacts.items(): + if os.path.exists(path): + if access_mode == "mcp": + # Через MCP (если доступен tool jira_add_attachment) + await jira_add_attachment(issue_key, file_path=path) + else: + # HTTP fallback + await bash(f''' + curl -s -u "${{ATLASSIAN_EMAIL}}:${{ATLASSIAN_TOKEN}}" \ + -X POST \ + -H "X-Atlassian-Token: no-check" \ + -F "file=@{path}" \ + "https://${{ATLASSIAN_HOST}}/rest/api/3/issue/{issue_key}/attachments" + ''') +``` + +### 6. Синхронизация артефактов (комментарии со ссылками) + +Добавляй ссылки на документы на русском языке: + +```python +async def sync_artifacts(issue_key: str, artifacts: dict): + links = [] + + for artifact_type, path in artifacts.items(): + links.append(f"- {artifact_type}: `{path}`") + + comment = f""" +📁 Артефакты обновлены: + +{chr(10).join(links)} + +Сгенерировано плагином jira-workflow +""" + + await jira_add_comment(issue_key, comment) +``` + +### 7. Обновление родительской задачи + +При завершении всех subtasks: + +```python +async def check_and_update_parent(parent_key: str): + subtasks = await get_subtasks(parent_key) + + all_done = all( + st["fields"]["status"]["name"] == "Done" + for st in subtasks + ) + + if all_done: + # Все subtasks завершены + await jira_add_comment(parent_key, f""" +🎉 Все подзадачи завершены! +Реализация готова к ревью. + +Артефакты: +- PRD: docs/jira/{parent_key}/prd.md +- Спецификация: docs/jira/{parent_key}/spec.md +- Лог дебатов: docs/jira/{parent_key}/debate-log.md +- План: docs/jira/{parent_key}/plan.md +""") + + # Опционально: transition родительской задачи + # await jira_transition_issue(parent_key, "Ready for Review") +``` + +### 8. Batch sync + +Синхронизация всех открытых задач: + +```python +async def batch_sync(): + # Найти все state files + state_files = glob("config/state/*.json") + + results = [] + for state_file in state_files: + issue_key = extract_issue_key(state_file) + state = read_state_file(state_file) + + if state["current_stage"] != "completed": + result = await sync_single_issue(issue_key) + results.append(result) + + return results +``` + +### 9. Conflict resolution + +При конфликтах между локальным и Jira состоянием: + +```python +def resolve_conflict(local: dict, jira: dict) -> str: + """ + Правила разрешения конфликтов: + 1. Jira статус "Done" + local "in_progress" -> оставить Jira (кто-то закрыл вручную) + 2. Local "completed" + Jira "In Progress" -> обновить Jira + 3. Разные assignees -> предупредить пользователя + """ + + if jira["status"] == "Done" and local["status"] != "completed": + return "keep_jira" + + if local["status"] == "completed" and jira["status"] != "Done": + return "update_jira" + + if local.get("assignee") != jira.get("assignee"): + return "warn_user" + + return "no_conflict" +``` + +### 10. Вывод + +``` +[jira-sync] Синхронизация PROJ-123... + + Subtasks: + PROJ-123-1: Done -> Done (синхр.) + PROJ-123-2: Done -> Done (синхр.) + PROJ-123-3: In Progress -> Done (обновлено) + PROJ-123-4: In Progress -> In Progress (синхр.) + - PROJ-123-5: To Do (не начата) + + Артефакты синхронизированы: + PRD прикреплён + Спецификация прикреплена + Лог дебатов прикреплён + + Статус родителя: 3/5 подзадач завершено +``` + +## Автоматическая синхронизация + +Через hook `task-complete`: + +```yaml +trigger: PostToolUse +tool: TaskUpdate +condition: status changed to "completed" +action: call jira-sync for related Jira subtask +``` + +## API + +### Manual sync command + +```bash +# Sync single issue +/jira-sync PROJ-123 + +# Sync all open issues +/jira-sync --all + +# Dry run (show what would be synced) +/jira-sync PROJ-123 --dry-run +``` + +## Выходные данные + +```json +{ + "issue_key": "PROJ-123", + "synced_subtasks": 3, + "conflicts_resolved": 1, + "artifacts_synced": 4, + "artifacts_attached": 4, + "parent_updated": true +} +``` diff --git a/.claude/skills/jira-workflow/SKILL.md b/.claude/skills/jira-workflow/SKILL.md new file mode 100755 index 00000000..62a332b5 --- /dev/null +++ b/.claude/skills/jira-workflow/SKILL.md @@ -0,0 +1,381 @@ +--- +description: "Automated workflow for Jira tasks: fetch -> interview -> PRD -> debate(PRD) -> spec -> debate(Spec) -> plan -> debate(Plan) -> implement" +argument-hint: "PROJ-123 [--jql query] [--type quick|full|decompose] [--resume] [--no-interview] [--select] [--dry-run]" +allowed-tools: ["Read", "Write", "Edit", "Glob", "Grep", "Bash", "Task", "TaskCreate", "TaskUpdate", "TaskList", "AskUserQuestion", "WebFetch", "Skill"] +--- + +# /jira-workflow + +Полный автоматизированный workflow для обработки задач из Jira. + +## Использование + +```bash +# Одиночная задача +/jira-workflow PROJ-123 + +# Несколько задач +/jira-workflow PROJ-123,PROJ-124,PROJ-125 + +# JQL-запрос +/jira-workflow --jql "project=PROJ AND sprint=current" + +# Интерактивный выбор (задачи текущего пользователя) +/jira-workflow --select + +# Продолжить прерванный workflow +/jira-workflow --resume PROJ-123 + +# Пропустить интервью (для простых задач) +/jira-workflow PROJ-123 --no-interview + +# Переопределить тип workflow +/jira-workflow PROJ-123 --type quick + +# Dry run +/jira-workflow PROJ-123 --dry-run +``` + +## Workflow + +``` +fetch -> interview -> PRD -> debate(PRD) -> spec -> debate(Spec) -> plan -> debate(Plan) -> implement + ^ ^ ^ ^ + интерактивно 2-5 раундов 2-10 раундов 1-3 раунда + бизнес-req тех.сложность проверка +``` + +## Этапы + +### 1. Fetch (jira-fetch) +Получение данных задачи из Jira, определение типа workflow. +Поддержка MCP и HTTP fallback. + +### 2. Interview (jira-interview) +Уточнение требований через структурированное интервью (adversarial-spec style). +**Всегда выполняется** (если не указан `--no-interview`). + +### 3. PRD (jira-prd) +Генерация Product Requirements Document. +*Пропускается для `quick` workflow.* + +### 4. Debate PRD (jira-debate --document prd) +Критика PRD AI-моделями: фокус на полноте требований, бизнес-ценности, критериях приёмки. +**2-5 раундов** до достижения консенсуса (70% APPROVED). +*Пропускается для `quick` workflow.* + +### 5. Spec (jira-spec) +Генерация технической спецификации. + +### 6. Debate Spec (jira-debate --document spec) +Критика спецификации 7 AI-моделями через OpenRouter: +- GPT-5.2 (Lead Technical Critic) +- DeepSeek v3.2 (Architecture Critic) +- Grok 4.1 Fast (Edge Case Critic) +- Gemini 3 Pro (Scalability Critic) +- Perplexity Sonar Pro (Deep Research Critic) +- GLM 4.7 (Alternative Perspective Critic) +- MiMo-V2-Flash (Rapid Validation Critic) + +**2-10 раундов** до достижения консенсуса (70% APPROVED). + +### 7. Plan (jira-plan) +Декомпозиция на subtasks, создание в Jira и локально через TaskCreate. + +### 8. Debate Plan (jira-debate --document plan) +Критика плана AI-моделями: фокус на декомпозиции, зависимостях, рисках. +**1-3 раунда** до достижения консенсуса. + +### 9. Implement (jira-implement) +Реализация кода по плану с автоматической синхронизацией статусов. + +## Артефакты + +Все документы сохраняются в `docs/jira/{ISSUE-KEY}/`: + +``` +docs/jira/PROJ-123/ +├── interview.md # Q&A из интервью +├── prd.md # Product Requirements Document +├── spec.md # Техническая спецификация +├── debate-log.md # Лог дебатов с AI-моделями +├── plan.md # План реализации +└── report.md # Финальный отчёт +``` + +Артефакты автоматически прикрепляются к задаче Jira как вложения и упоминаются в комментариях. + +## Фокусированные дебаты + +После интервью автоматически определяются персоны для фокусированных дебатов: + +- **Frontend-архитектор** — UI/UX, компоненты, state management +- **Backend-архитектор** — API, БД, scalability +- **DevOps-инженер** — CI/CD, инфраструктура, мониторинг +- **Инженер безопасности** — auth, защита данных, compliance +- **QA-инженер** — тестирование, edge cases, качество + +Дебаты проходят итерациями по персонам с фокусированными критериями. + +## Рекомендация скиллов + +Перед генерацией PRD/Spec анализируются доступные скиллы и рекомендуются релевантные: + +``` +[skill-recommend] Обнаруженные фокусы: backend, security + +Рекомендуемые скиллы: + ✅ api-design-principles (установлен) + ✅ auth-implementation-patterns (установлен) + ❌ secrets-management (не установлен) + +Покрытие: 66% +``` + +## Типы workflow + +| Issue Type | Workflow | Этапы | +|------------|----------|-------| +| Story | full | Все этапы (9) | +| Task | full | Все этапы (9) | +| Bug | quick | Без PRD и debate(PRD), меньше вопросов | +| Epic | decompose | Без implement, создаёт child issues | +| Sub-task | quick | Без PRD и debate(PRD), меньше вопросов | + +## Обработка ошибок + +### Transient ошибки (сеть, rate limit) +Автоматический retry 3 раза с exponential backoff. + +### Критические ошибки +1. Состояние сохраняется в `config/state/{ISSUE-KEY}.json` +2. Можно продолжить через `--resume` + +## Требования + +### Environment Variables + +```bash +# Atlassian +export ATLASSIAN_HOST="company.atlassian.net" +export ATLASSIAN_EMAIL="your-email@company.com" +export ATLASSIAN_TOKEN="your-api-token" + +# OpenRouter (для дебатов) +export OPENROUTER_API_KEY="your-openrouter-key" +``` + +### MCP Server (основной режим) + +Плагин использует `jira-mcp` (Go-бинарник) через MCP: + +```bash +# Устанавливается автоматически через install.sh в ~/.local/bin/jira-mcp +# Или вручную с https://github.com/nguyenvanduocit/jira-mcp/releases +``` + +### HTTP Fallback (если MCP недоступен) + +Если MCP-сервер недоступен, плагин автоматически переключается на прямые HTTP-запросы к Jira REST API v3, используя переменные `ATLASSIAN_HOST`, `ATLASSIAN_EMAIL`, `ATLASSIAN_TOKEN`. + +## Примеры + +### Полный workflow для Story + +```bash +> /jira-workflow PROJ-123 + +[fetch] Загружаю PROJ-123... (режим: mcp) + Type: Story | Priority: High +[comment] Комментарий добавлен ✓ + +[interview] 5 вопросов... + Q1: Scope? -> B + Q2: Edge cases? -> A + ... +[comment] Комментарий добавлен ✓ + +[focus-detect] Анализирую задачу... + Фокусы: backend (12), security (6), frontend (2) + Персоны: Backend-архитектор, Инженер безопасности + +[skill-recommend] Покрытие скиллов: 83% (5/6) + ✅ api-design-principles, architecture-patterns + ❌ secrets-management + +[prd] docs/jira/PROJ-123/prd.md +[comment] Комментарий добавлен ✓ + +[debate:prd] Фокусированные дебаты (2 персоны) + Итерация 1/2: Backend-архитектор + Раунд 1/2... консенсус за 1 раунд + Итерация 2/2: Инженер безопасности + Раунд 1/2... консенсус за 1 раунд +[comment] Комментарий добавлен ✓ + +[spec] docs/jira/PROJ-123/spec.md +[comment] Комментарий добавлен ✓ + +[debate:spec] Фокусированные дебаты (2 персоны) + Итерация 1/2: Backend-архитектор + Раунд 1/3... GPT-5.2: MAJOR, DeepSeek: CRITICAL + -> Обновляю спецификацию + Раунд 2/3... консенсус за 2 раунда + Итерация 2/2: Инженер безопасности + Раунд 1/3... GPT-5.2: MAJOR (CSRF) + -> Обновляю спецификацию + Раунд 2/3... консенсус за 2 раунда +[comment] Комментарий добавлен ✓ + +[plan] 5 subtasks созданы + PROJ-123-1 -> PROJ-123-5 +[comment] Комментарий добавлен ✓ + +[debate:plan] Фокусированные дебаты + Итерация 1/2: Backend-архитектор + Раунд 1/2... консенсус за 1 раунд +[comment] Комментарий добавлен ✓ + +[report] Генерирую финальный отчёт... + Документы: 5 | Дебаты: 6 раундов | Улучшений: 8 + docs/jira/PROJ-123/report.md + Прикреплён к задаче ✓ + +[implement] Реализация... + [1/5] Database Migration ✓ + [2/5] Models ✓ + [3/5] Service ✓ + [4/5] API ✓ + [5/5] Tests (16 passed) ✓ +[comment] Финальный комментарий добавлен ✓ + +PROJ-123 завершён! +``` + +### Быстрый багфикс + +```bash +> /jira-workflow PROJ-456 --type quick + +[fetch] Type: Bug -> workflow: quick + +[interview] 2 вопроса... + +[spec] (без PRD) + +[debate:spec] 2 раунда -> консенсус + +[plan] 2 subtasks + +[debate:plan] 1 раунд -> консенсус + +[implement] + +PROJ-456 завершён! +``` + +### Resume после ошибки + +```bash +> /jira-workflow --resume PROJ-123 + +Найдено состояние для PROJ-123: + Завершено: fetch -> interview -> prd -> debate(prd) -> spec + Ошибка на: debate(spec) (round 3) + Причина: OpenRouter rate limit + +Продолжить? [Y/n] Y + +[debate:spec] Продолжаю с раунда 3... +... +``` + +## Инструкции для Claude + +При вызове `/jira-workflow`: + +1. **Парсинг аргументов** + - Извлеки issue key(s), флаги, JQL + - Определи режим: single, batch, jql, select, resume + +2. **Определение метода доступа к Jira** + 1. Попытаться вызвать MCP tool `jira_get_issue` с тестовым запросом + 2. Если MCP доступен -> использовать MCP (режим: mcp) + 3. Если MCP недоступен (ошибка / timeout / нет бинарника): + a. Проверить наличие `ATLASSIAN_HOST`, `ATLASSIAN_EMAIL`, `ATLASSIAN_TOKEN` + b. Если переменные установлены -> переключиться на HTTP fallback (режим: http) + c. Если нет -> ошибка с инструкцией по настройке + 4. Сохранить режим в состояние workflow: `jira_access_mode: "mcp" | "http"` + +3. **Проверка prerequisites** + - Проверь ATLASSIAN_HOST, ATLASSIAN_EMAIL, ATLASSIAN_TOKEN + - Проверь OPENROUTER_API_KEY (для debate) + +4. **Выполнение workflow** + + **ВАЖНО:** После каждого этапа ОБЯЗАТЕЛЬНО добавляй комментарий в Jira! + + Последовательно выполняй: + ``` + Skill: jira-fetch + → Добавь комментарий (stage: fetch) через jira_add_comment + + Skill: jira-interview (если не --no-interview) + → Добавь комментарий (stage: interview) + → Прикрепи docs/jira/{ISSUE-KEY}/interview.md + + Skill: jira-focus-detect + Skill: jira-skill-recommend + + Skill: jira-prd (если не quick workflow) + → Добавь комментарий (stage: prd) + → Прикрепи docs/jira/{ISSUE-KEY}/prd.md + + Skill: jira-debate --document prd --personas + → Добавь комментарий (stage: debate) + → Прикрепи/обнови docs/jira/{ISSUE-KEY}/debate-log.md + + Skill: jira-spec + → Добавь комментарий (stage: spec) + → Прикрепи docs/jira/{ISSUE-KEY}/spec.md + + Skill: jira-debate --document spec --personas + → Добавь комментарий (stage: debate) + + Skill: jira-plan + → Добавь комментарий (stage: plan) + → Прикрепи docs/jira/{ISSUE-KEY}/plan.md + + Skill: jira-debate --document plan --personas + → Добавь комментарий (stage: debate) + + Skill: jira-report + → Прикрепи docs/jira/{ISSUE-KEY}/report.md + + Skill: jira-implement + → Добавь комментарий (stage: complete) + ``` + + **Как добавлять комментарии:** + - MCP режим: `jira_add_comment(issue_key, comment="plain text")` — НЕ передавай ADF! + - HTTP режим: curl с ADF в body (см. jira-comment skill) + + **Как прикреплять файлы:** + - ВСЕГДА через HTTP (MCP не поддерживает attachments): + ```bash + curl -s -u "${ATLASSIAN_EMAIL}:${ATLASSIAN_TOKEN}" \ + -X POST -H "X-Atlassian-Token: no-check" \ + -F "file=@docs/jira/{ISSUE-KEY}/prd.md" \ + "https://${ATLASSIAN_HOST}/rest/api/3/issue/{ISSUE-KEY}/attachments" + ``` + +5. **Обработка ошибок** + - При transient: retry + - При critical: сохранить состояние, предложить resume + +6. **Синхронизация** + Jira-sync вызывается автоматически через hooks. + Артефакты прикрепляются к задаче как вложения. + +7. **Вывод** + Показывай прогресс на каждом этапе. Все комментарии в Jira на русском языке. diff --git a/erp24/config/db-test.php b/erp24/config/db-test.php new file mode 100644 index 00000000..a4f0df77 --- /dev/null +++ b/erp24/config/db-test.php @@ -0,0 +1,15 @@ + 'yii\db\Connection', + 'dsn' => 'pgsql:host=localhost;port=5434;dbname=erp24_test', + 'username' => 'erp24_user', + 'password' => 'erp24_pass', + 'charset' => 'utf8', + 'schemaMap' => [ + 'pgsql' => [ + 'class' => 'yii\db\pgsql\Schema', + 'defaultSchema' => 'erp24', + ] + ], +]; diff --git a/erp24/controllers/ShiftReminderController.php b/erp24/controllers/ShiftReminderController.php index d85a9c45..571337db 100644 --- a/erp24/controllers/ShiftReminderController.php +++ b/erp24/controllers/ShiftReminderController.php @@ -21,12 +21,6 @@ use yii_app\services\ShiftReminderService; */ class ShiftReminderController extends Controller { - /** - * Хранилище для отслеживания rate limiting - * @var array - */ - private static $rateLimitStorage = []; - /** * {@inheritdoc} */ @@ -89,6 +83,9 @@ class ShiftReminderController extends Controller /** * Проверить rate limit для действия * + * Использует Yii::$app->cache для сохранения состояния между HTTP-запросами. + * Это критично для корректной работы rate limiting в production. + * * @param string $action Название действия * @param int $limit Максимальное количество запросов в минуту * @return bool true если лимит не превышен, false в противном случае @@ -97,28 +94,33 @@ class ShiftReminderController extends Controller { $userId = Yii::$app->user->id; $key = "shift_reminder_{$action}_{$userId}"; + $cache = Yii::$app->cache; $now = time(); - // Инициализируем хранилище для пользователя, если его нет - if (!isset(self::$rateLimitStorage[$key])) { - self::$rateLimitStorage[$key] = []; + // Получаем текущие запросы из cache (persist между HTTP-запросами) + $requests = $cache->get($key); + if ($requests === false) { + $requests = []; } // Удаляем запросы старше 60 секунд - self::$rateLimitStorage[$key] = array_filter( - self::$rateLimitStorage[$key], + $requests = array_filter( + $requests, function ($timestamp) use ($now) { return ($now - $timestamp) < 60; } ); // Проверяем, не превышен ли лимит - if (count(self::$rateLimitStorage[$key]) >= $limit) { + if (count($requests) >= $limit) { return false; } // Добавляем текущий запрос - self::$rateLimitStorage[$key][] = $now; + $requests[] = $now; + + // Сохраняем обновлённый список в cache с TTL 60 секунд + $cache->set($key, $requests, 60); return true; } diff --git a/erp24/docker/nginx/default.conf b/erp24/docker/nginx/default.conf new file mode 100644 index 00000000..82ca74cc --- /dev/null +++ b/erp24/docker/nginx/default.conf @@ -0,0 +1,23 @@ +server { + listen 80; + server_name localhost; + root /app/web; + index index.php index.html; + + charset utf-8; + + location / { + try_files $uri $uri/ /index.php?$args; + } + + location ~ \.php$ { + fastcgi_pass php:9000; + fastcgi_index index.php; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + include fastcgi_params; + } + + location ~ /\.(ht|svn|git) { + deny all; + } +} diff --git a/erp24/docker/php/Dockerfile b/erp24/docker/php/Dockerfile new file mode 100644 index 00000000..4dc83f6c --- /dev/null +++ b/erp24/docker/php/Dockerfile @@ -0,0 +1,28 @@ +FROM php:8.1-cli + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + git \ + curl \ + libpq-dev \ + libzip-dev \ + zip \ + unzip \ + && docker-php-ext-install pdo pdo_pgsql pgsql zip \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Install Composer +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer + +WORKDIR /app + +# Copy application +COPY . /app + +# Install dependencies (if not already installed) +RUN if [ ! -d "vendor" ]; then composer install --no-interaction --prefer-dist; fi + +EXPOSE 8000 + +CMD ["php", "-S", "0.0.0.0:8000", "-t", "/app/web"] diff --git a/erp24/docs/services/ShiftReminderService.md b/erp24/docs/services/ShiftReminderService.md new file mode 100644 index 00000000..e6f02ecb --- /dev/null +++ b/erp24/docs/services/ShiftReminderService.md @@ -0,0 +1,772 @@ +# ShiftReminderService + +## Назначение + +`ShiftReminderService` — сервис для управления системой обязательных напоминаний о рабочих сменах (открытие/закрытие). Обеспечивает корректную работу с переходами через полночь и временными окнами показа уведомлений. + +**Ключевая особенность:** время 00:00-05:59 относится к **предыдущему календарному дню**, чтобы подтверждение в 01:00 не блокировало показ напоминания в 07:50 того же календарного дня. + +**Задача:** ERP-43-J + +--- + +## Пространство имён + +```php +namespace yii_app\services; +``` + +--- + +## Зависимости + +| Класс | Назначение | +|-------|------------| +| `app\models\ShiftReminderShown` | ActiveRecord модель для отслеживания показанных напоминаний | +| `yii_app\records\Admin` | Модель администратора | +| `yii_app\records\Timetable` | Расписание рабочих смен | +| `yii_app\records\TimetableFactModel` | Фактические данные по сменам (открытие/закрытие) | +| `Yii` | Основной класс фреймворка (для логирования и конфигурации) | + +--- + +## Конфигурация (config/params.php) + +```php +// Временные окна для показа напоминаний (в формате HH:MM) +'SHIFT_REMINDER_TIME_WINDOWS' => ['07:50', '08:10', '19:50', '20:10'], + +// Допустимые ключи напоминаний (whitelist для безопасности) +'SHIFT_REMINDER_ALLOWED_KEYS' => ['day_shift', 'night_shift'], + +// ID групп администраторов, которым показываются напоминания +// Пустой массив = все пользователи с записями в timetable +'SHIFT_REMINDER_ADMIN_GROUP_IDS' => [], +``` + +--- + +## Архитектура + +### Диаграмма компонентов + +```mermaid +graph TB + Controller[ShiftReminderController] -->|использует| Service[ShiftReminderService] + Service -->|создаёт/читает| Model[ShiftReminderShown] + Service -->|проверяет| Admin[Admin] + Service -->|читает| Timetable[Timetable] + Service -->|проверяет статус| TimetableFact[TimetableFactModel] + + Controller -->|JSON API| Frontend[Frontend JS] + Frontend -->|AJAX каждые 30 сек| Controller + + Service -->|читает конфиг| Params[config/params.php] +``` + +### Поток работы системы + +```mermaid +sequenceDiagram + participant Frontend as Frontend (shift-reminder.js) + participant Controller as ShiftReminderController + participant Service as ShiftReminderService + participant DB as База данных + + Note over Frontend: Таймер: каждые 30 сек + Frontend->>Controller: POST /shift-reminder/check + Controller->>Service: shouldShowReminder(userId, reminderKey) + + Service->>Service: getReferenceDate(currentTime) + Service->>DB: TimetableFactModel::getLast() + Service->>Service: isShiftActionNeeded() + Service->>DB: ShiftReminderShown::find() + Service->>DB: Timetable::find() + Service->>Service: isUserAdministrator() + + Service-->>Controller: true/false + Controller-->>Frontend: {show_reminder: true/false} + + alt Показать напоминание + Frontend->>Frontend: Показать модальное окно + Note over Frontend: Пользователь нажал "Закрыть смену" + Frontend->>Controller: POST /shift-reminder/confirm + Controller->>Service: confirmReminder(userId, reminderKey) + Service->>DB: ShiftReminderShown::save() + Service-->>Controller: true + Controller-->>Frontend: {success: true} + end +``` + +--- + +## Свойства + +Сервис не имеет свойств экземпляра — все методы статические. + +--- + +## Методы + +### getReferenceDate() + +**Описание:** +Получить reference_date для текущего времени с учетом логики перехода дня. + +**Логика day rollover:** +- Время **00:00-05:59** → reference_date = предыдущий календарный день +- Время **06:00-23:59** → reference_date = текущий календарный день + +Это гарантирует, что подтверждение напоминания в 01:00 (которое относится к предыдущему дню) не блокирует показ напоминания в 07:50 (которое относится к текущему дню). + +**Параметры:** +- `string|null $currentTime` — текущее время в формате 'Y-m-d H:i:s'. Если null, используется текущее время. + +**Возвращает:** +- `string` — дата в формате 'Y-m-d' + +**Пример:** + +```php +// Время 01:00 относится к предыдущему дню +$date = ShiftReminderService::getReferenceDate('2026-02-04 01:00:00'); +// Результат: '2026-02-03' + +// Время 07:50 относится к текущему дню +$date = ShiftReminderService::getReferenceDate('2026-02-04 07:50:00'); +// Результат: '2026-02-04' + +// Без параметра использует текущее время +$date = ShiftReminderService::getReferenceDate(); +``` + +**Используется в:** +- `shouldShowReminder()` — для определения даты проверки напоминания +- `isShiftActionNeeded()` — для поиска смены +- `isUserAdministrator()` — для проверки расписания +- `createReminderRecord()` — для создания записи +- `confirmReminder()` — для подтверждения + +--- + +### isInTimeWindow() + +**Описание:** +Проверить, находится ли текущее время в одном из временных окон для показа напоминаний. + +**Параметры:** +- `string|null $currentTime` — текущее время в формате 'Y-m-d H:i:s'. Если null, используется текущее время. +- `array|null $windows` — массив временных окон в формате ['HH:MM', ...]. Если null, используются окна из конфигурации. + +**Возвращает:** +- `bool` — true если текущее время находится в одном из окон, false в противном случае + +**Пример:** + +```php +// Проверка с использованием конфигурации (07:50, 08:10, 19:50, 20:10) +$inWindow = ShiftReminderService::isInTimeWindow('2026-02-04 07:50:00'); +// Результат: true + +$inWindow = ShiftReminderService::isInTimeWindow('2026-02-04 07:51:00'); +// Результат: false (не попадает ни в одно окно) + +// Проверка с кастомными окнами +$inWindow = ShiftReminderService::isInTimeWindow( + '2026-02-04 10:00:00', + ['10:00', '15:00'] +); +// Результат: true +``` + +**Используется в:** +- `ShiftReminderController::actionCheck()` — для определения автоматического reminder_key + +--- + +### shouldShowReminder() + +**Описание:** +Определить, нужно ли показать напоминание пользователю. + +**Проверяет следующие условия:** +1. Действие со сменой ещё не выполнено в `timetable_fact` +2. Напоминание не было подтверждено для текущего `reminder_key` и `reference_date` +3. Пользователь принадлежит к группе администраторов (проверка через `timetable`) + +**Параметры:** +- `int $userId` — ID пользователя +- `string $reminderKey` — ключ напоминания (day_shift или night_shift) +- `string|null $currentTime` — текущее время в формате 'Y-m-d H:i:s'. Если null, используется текущее время. + +**Возвращает:** +- `bool` — true если нужно показать напоминание, false в противном случае + +**Пример:** + +```php +// Проверка для пользователя 1 в дневной смене +$shouldShow = ShiftReminderService::shouldShowReminder(1, 'day_shift'); + +if ($shouldShow) { + // Показать модальное окно с напоминанием +} +``` + +**Поток данных:** + +```mermaid +graph TD + A[shouldShowReminder] --> B{isShiftActionNeeded?} + B -->|false| C[return false] + B -->|true| D{ShiftReminderShown exists?} + D -->|да, confirmed| E[return false] + D -->|нет| F{isUserAdministrator?} + F -->|false| G[return false] + F -->|true| H[return true] +``` + +**Список вызовов:** +- `isShiftActionNeeded($userId, $currentTime)` — проверка необходимости действия со сменой +- `getReferenceDate($currentTime)` — получение reference_date +- `ShiftReminderShown::find()` — поиск подтверждённого напоминания в БД +- `isUserAdministrator($userId, $currentTime)` — проверка прав пользователя + +**Используется в:** +- `ShiftReminderController::actionCheck()` — основной endpoint проверки + +--- + +### isShiftActionNeeded() + +**Описание:** +Проверить, нужно ли выполнить действие со сменой (открытие/закрытие). + +**Логика:** +- **Период закрытия (07:50-08:09, 19:50-20:09):** проверяем, есть ли открытая смена для закрытия +- **Период открытия (08:10-19:49, 20:10-07:49):** проверяем, не открыта ли уже смена + +**Параметры:** +- `int $userId` — ID пользователя +- `string|null $currentTime` — текущее время в формате 'Y-m-d H:i:s'. Если null, используется текущее время. + +**Возвращает:** +- `bool` — true если действие нужно выполнить, false если уже выполнено + +**Пример:** + +```php +// В период закрытия (07:50-08:09) +$needed = ShiftReminderService::isShiftActionNeeded(1, '2026-02-04 07:50:00'); +// true если есть открытая смена, false если смена уже закрыта + +// В период открытия (08:10-19:49) +$needed = ShiftReminderService::isShiftActionNeeded(1, '2026-02-04 12:00:00'); +// true если смена не открыта, false если уже открыта +``` + +**Временные периоды:** + +| Время | Период | Действие | Проверка | +|-------|--------|----------|----------| +| 07:50-08:09 | Закрытие ночной | Закрыть смену | Есть ли открытая смена? | +| 08:10-19:49 | Дневная смена | Открыть смену | Смена не открыта? | +| 19:50-20:09 | Закрытие дневной | Закрыть смену | Есть ли открытая смена? | +| 20:10-07:49 | Ночная смена | Открыть смену | Смена не открыта? | + +**Список вызовов:** +- `getReferenceDate($currentTime)` — получение reference_date +- `TimetableFactModel::getLast($userId, $referenceDate, true)` — получение последней смены пользователя + +**Используется в:** +- `shouldShowReminder()` — первая проверка перед показом напоминания + +--- + +### isUserAdministrator() + +**Описание:** +Проверить, является ли пользователь администратором на текущую дату. + +**Интеграция с системой timetable:** проверяем, есть ли у пользователя запланированная рабочая смена на reference_date. + +**Параметры:** +- `int $userId` — ID пользователя +- `string|null $currentTime` — текущее время. Если null, используется текущее время. + +**Возвращает:** +- `bool` — true если пользователь является администратором, false в противном случае + +**Пример:** + +```php +$isAdmin = ShiftReminderService::isUserAdministrator(1); + +if (!$isAdmin) { + // Не показывать напоминание +} +``` + +**Логика проверки:** + +```mermaid +graph TD + A[isUserAdministrator] --> B{Admin exists?} + B -->|нет| C[return false] + B -->|да| D{В SHIFT_REMINDER_ADMIN_GROUP_IDS?} + D -->|нет| E[return false] + D -->|да или пустой массив| F{Есть запись в Timetable?} + F -->|нет| G[return false] + F -->|да| H[return true] +``` + +**Проверяемые типы смен:** +- `1` — Timetable::TIMESLOT_WORK (работа) +- `5` — Timetable::TIMESLOT_INTERNSHIP (стажировка) +- `8` — Другой тип административной работы + +**Список вызовов:** +- `Admin::findOne($userId)` — получение модели администратора +- `getReferenceDate($currentTime)` — получение reference_date +- `Timetable::find()` — проверка наличия смены в расписании + +**Используется в:** +- `shouldShowReminder()` — финальная проверка перед показом напоминания + +--- + +### createReminderRecord() + +**Описание:** +Создать или получить запись о показанном напоминании. + +**Параметры:** +- `int $userId` — ID пользователя +- `string $reminderKey` — ключ напоминания +- `string|null $currentTime` — текущее время. Если null, используется текущее время. + +**Возвращает:** +- `ShiftReminderShown|null` — модель напоминания или null в случае ошибки + +**Пример:** + +```php +$reminder = ShiftReminderService::createReminderRecord(1, 'day_shift'); + +if ($reminder !== null) { + echo "Создана запись ID: " . $reminder->id; +} +``` + +**Логика:** + +```mermaid +graph TD + A[createReminderRecord] --> B{Запись существует?} + B -->|да| C[Вернуть существующую] + B -->|нет| D[Создать новую] + D --> E{save успешно?} + E -->|да| F[Вернуть модель] + E -->|нет| G[Логировать ошибку] + G --> H[Вернуть null] +``` + +**Список вызовов:** +- `getReferenceDate($currentTime)` — получение reference_date +- `ShiftReminderShown::find()` — поиск существующей записи +- `new ShiftReminderShown()` — создание новой модели +- `$reminder->save()` — сохранение в БД +- `Yii::error()` — логирование ошибки + +**Используется в:** +- `confirmReminder()` — перед подтверждением напоминания + +--- + +### confirmReminder() + +**Описание:** +Подтвердить напоминание. + +**Параметры:** +- `int $userId` — ID пользователя +- `string $reminderKey` — ключ напоминания +- `string|null $currentTime` — текущее время. Если null, используется текущее время. + +**Возвращает:** +- `bool` — true если подтверждение прошло успешно, false в противном случае + +**Пример:** + +```php +$success = ShiftReminderService::confirmReminder(1, 'day_shift'); + +if ($success) { + // Напоминание подтверждено +} +``` + +**Логика:** + +```mermaid +graph TD + A[confirmReminder] --> B[createReminderRecord] + B --> C{Запись создана?} + C -->|нет| D[return false] + C -->|да| E{Уже подтверждено?} + E -->|да| F[return true] + E -->|нет| G[markAsConfirmed] + G --> H[return результат save] +``` + +**Список вызовов:** +- `createReminderRecord($userId, $reminderKey, $currentTime)` — создание/получение записи +- `$reminder->isConfirmed()` — проверка статуса подтверждения +- `$reminder->markAsConfirmed()` — установка confirmed_at и сохранение + +**Используется в:** +- `ShiftReminderController::actionConfirm()` — основной endpoint подтверждения + +--- + +### getAllowedReminderKeys() + +**Описание:** +Получить список допустимых ключей напоминаний (whitelist). + +**Параметры:** +Нет + +**Возвращает:** +- `array` — массив допустимых reminder_key + +**Пример:** + +```php +$keys = ShiftReminderService::getAllowedReminderKeys(); +// Результат: ['day_shift', 'night_shift'] +``` + +**Используется в:** +- `isValidReminderKey()` — для валидации +- `ShiftReminderController::actionCheck()` — для формирования сообщения об ошибке + +--- + +### isValidReminderKey() + +**Описание:** +Проверить, является ли ключ напоминания допустимым. + +**Параметры:** +- `string $reminderKey` — ключ напоминания для проверки + +**Возвращает:** +- `bool` — true если ключ допустим, false в противном случае + +**Пример:** + +```php +if (!ShiftReminderService::isValidReminderKey($key)) { + throw new \InvalidArgumentException('Invalid reminder key'); +} +``` + +**Список вызовов:** +- `getAllowedReminderKeys()` — получение whitelist + +**Используется в:** +- `ShiftReminderController::actionCheck()` — валидация входных данных +- `ShiftReminderController::actionConfirm()` — валидация входных данных + +--- + +## Интеграция с контроллером + +### ShiftReminderController + +Контроллер предоставляет JSON API для взаимодействия с сервисом: + +**Endpoints:** +- `POST /shift-reminder/check` — проверить необходимость показа напоминания +- `POST /shift-reminder/confirm` — подтвердить просмотр напоминания + +**Пример использования в контроллере:** + +```php +public function actionCheck() +{ + $userId = Yii::$app->user->id; + $reminderKey = 'day_shift'; // автоматически определяется по времени + + $shouldShow = ShiftReminderService::shouldShowReminder($userId, $reminderKey); + + if ($shouldShow) { + return [ + 'success' => true, + 'show_reminder' => true, + 'reminder_key' => $reminderKey, + 'reference_date' => ShiftReminderService::getReferenceDate(), + ]; + } + + return [ + 'success' => true, + 'show_reminder' => false, + ]; +} +``` + +--- + +## Интеграция с Frontend + +### JavaScript (shift-reminder.js) + +Frontend периодически опрашивает API каждые 30 секунд: + +```javascript +setInterval(function() { + $.ajax({ + url: '/shift-reminder/check', + method: 'POST', + dataType: 'json', + success: function(response) { + if (response.success && response.show_reminder) { + // Показать модальное окно + showReminderModal(response.reminder_key); + } + } + }); +}, 30000); // 30 секунд +``` + +--- + +## Таблица БД + +### shift_reminder_shown + +| Поле | Тип | Описание | +|------|-----|----------| +| `id` | integer | PRIMARY KEY | +| `user_id` | integer | NOT NULL, ID пользователя | +| `reminder_key` | string(50) | NOT NULL, ключ напоминания | +| `reference_date` | date | NOT NULL, календарная дата | +| `confirmed_at` | timestamp | NULL, дата подтверждения | +| `created_at` | timestamp | NOT NULL, дата создания | + +**Индексы:** +- `UNIQUE (user_id, reminder_key, reference_date)` — предотвращение дублирования +- `INDEX (confirmed_at)` — быстрый поиск подтверждённых + +**Миграция:** +```bash +php yii migrate/up --migrationPath=@app/migrations +``` + +--- + +## Примеры использования + +### Пример 1: Проверка необходимости показа напоминания + +```php +use yii_app\services\ShiftReminderService; + +$userId = 1; +$reminderKey = 'day_shift'; + +if (ShiftReminderService::shouldShowReminder($userId, $reminderKey)) { + echo "Нужно показать напоминание пользователю {$userId}"; +} +``` + +### Пример 2: Подтверждение напоминания + +```php +$success = ShiftReminderService::confirmReminder($userId, $reminderKey); + +if ($success) { + echo "Напоминание подтверждено"; +} else { + echo "Ошибка подтверждения"; +} +``` + +### Пример 3: Работа с reference_date + +```php +// Время 01:00 ночи +$date1 = ShiftReminderService::getReferenceDate('2026-02-04 01:00:00'); +echo $date1; // 2026-02-03 (предыдущий день) + +// Время 07:50 утра +$date2 = ShiftReminderService::getReferenceDate('2026-02-04 07:50:00'); +echo $date2; // 2026-02-04 (текущий день) + +// Подтверждение в 01:00 не блокирует показ в 07:50, +// так как reference_date разные +``` + +### Пример 4: Валидация reminder_key + +```php +$key = $_POST['reminder_key'] ?? null; + +if (!ShiftReminderService::isValidReminderKey($key)) { + throw new \yii\web\BadRequestHttpException( + 'Invalid reminder_key. Allowed: ' . + implode(', ', ShiftReminderService::getAllowedReminderKeys()) + ); +} +``` + +--- + +## Тестирование + +### Unit-тесты + +Расположение: `tests/unit/services/ShiftReminderServiceTest.php` + +**Покрытие:** +- getReferenceDate() — логика перехода через полночь (8 тестов) +- isInTimeWindow() — проверка временных окон (11 тестов) +- getAllowedReminderKeys() и isValidReminderKey() — валидация ключей (6 тестов) +- Граничные случаи и безопасность (5 тестов) + +**Запуск:** +```bash +./vendor/bin/codecept run unit/services/ShiftReminderServiceTest +``` + +### Функциональные тесты + +Расположение: `tests/functional/ShiftReminderControllerCest.php` + +**Покрытие:** +- Авторизация и доступ (4 теста) +- actionCheck — валидация и бизнес-логика (4 теста) +- actionConfirm — подтверждение напоминаний (5 тестов) +- Безопасность (SQL injection, XSS) (2 теста) +- Rate limiting (2 теста) + +**Запуск:** +```bash +./vendor/bin/codecept run functional/ShiftReminderControllerCest +``` + +--- + +## Безопасность + +### Whitelist для reminder_key + +Сервис использует whitelist подход для валидации `reminder_key`: + +```php +// Только эти ключи допустимы +'SHIFT_REMINDER_ALLOWED_KEYS' => ['day_shift', 'night_shift'] +``` + +Это защищает от: +- SQL-инъекций +- XSS-атак +- Неожиданных значений + +### Rate Limiting + +Контроллер реализует rate limiting: +- **actionCheck**: 10 запросов в минуту +- **actionConfirm**: 5 запросов в минуту + +### CSRF Protection + +Все POST запросы защищены CSRF-токеном Yii2. + +--- + +## Производительность + +### Оптимизация запросов к БД + +**Индексы:** +- Уникальный индекс на `(user_id, reminder_key, reference_date)` — O(log n) для поиска +- Индекс на `confirmed_at` — быстрая фильтрация подтверждённых + +**Кеширование:** +Рекомендуется кешировать результаты: +- `isUserAdministrator()` — кешировать на 5 минут +- `isShiftActionNeeded()` — кешировать на 1 минуту + +**Пример кеширования:** + +```php +$cacheKey = "shift_reminder_{$userId}_{$reminderKey}_" . date('Y-m-d-H-i'); + +$shouldShow = Yii::$app->cache->getOrSet($cacheKey, function() use ($userId, $reminderKey) { + return ShiftReminderService::shouldShowReminder($userId, $reminderKey); +}, 60); // 60 секунд +``` + +--- + +## Логирование + +Сервис использует категорию логирования `shift-reminder`: + +```php +Yii::error('Failed to create shift reminder record: ' . json_encode($errors), 'shift-reminder'); +``` + +**Конфигурация логирования:** + +```php +'log' => [ + 'targets' => [ + [ + 'class' => 'yii\log\FileTarget', + 'levels' => ['error', 'warning'], + 'categories' => ['shift-reminder'], + 'logFile' => '@runtime/logs/shift-reminder.log', + ], + ], +], +``` + +--- + +## Известные ограничения + +1. **Time-based логика:** методы завязаны на серверное время. При смене часового пояса сервера может потребоваться корректировка конфигурации. + +2. **In-memory rate limiting:** текущая реализация rate limiting в контроллере использует статическую переменную, которая сбрасывается при перезапуске PHP-FPM. Для production рекомендуется Redis или Memcached. + +3. **Зависимость от TimetableFactModel:** метод `isShiftActionNeeded()` полагается на корректность данных в таблице `timetable_fact`. При проблемах с этой таблицей напоминания могут работать некорректно. + +--- + +## Roadmap / Будущие улучшения + +1. **Кеширование:** добавить Redis-кеш для `isUserAdministrator()` и `isShiftActionNeeded()` +2. **Rate limiting:** перенести на Redis для персистентности между запросами +3. **Метрики:** добавить сбор метрик (количество показанных/подтверждённых напоминаний) +4. **Уведомления:** добавить push-уведомления вместо модального окна +5. **Тестирование:** добавить интеграционные тесты с реальной БД + +--- + +## Связанные документы + +- [ShiftReminderController](../controllers/ShiftReminderController.md) — контроллер для обработки API запросов +- [ShiftReminderShown](../models/ShiftReminderShown.md) — модель для хранения данных +- [Миграция m260127_105454](../migrations/m260127_105454_create_shift_reminder_shown_table.md) — создание таблицы БД +- [Frontend shift-reminder.js](../frontend/shift-reminder.js.md) — клиентская часть + +--- + +**Версия:** 1.0.0 +**Дата создания:** 2026-02-05 +**Автор:** Claude Code +**Задача:** ERP-43-J diff --git a/erp24/models/ShiftReminderShown.php b/erp24/models/ShiftReminderShown.php index f22fd7f3..61ccbc6b 100644 --- a/erp24/models/ShiftReminderShown.php +++ b/erp24/models/ShiftReminderShown.php @@ -27,7 +27,7 @@ class ShiftReminderShown extends ActiveRecord */ public static function tableName() { - return 'shift_reminder_shown'; + return 'erp24.shift_reminder_shown'; } /** @@ -73,12 +73,16 @@ class ShiftReminderShown extends ActiveRecord * Отмечает напоминание как подтвержденное и сохраняет запись * * Устанавливает текущее время в поле confirmed_at и сохраняет модель. + * Идемпотентен: если напоминание уже подтверждено, timestamp не изменяется. * * @return bool true если сохранение прошло успешно, false в противном случае */ public function markAsConfirmed() { - $this->confirmed_at = date('Y-m-d H:i:s'); + // Идемпотентность: не изменяем timestamp если уже подтверждено + if ($this->confirmed_at === null) { + $this->confirmed_at = date('Y-m-d H:i:s'); + } return $this->save(false); } } diff --git a/erp24/tests/functional/ShiftReminderControllerCest.php b/erp24/tests/functional/ShiftReminderControllerCest.php new file mode 100644 index 00000000..8c7b52af --- /dev/null +++ b/erp24/tests/functional/ShiftReminderControllerCest.php @@ -0,0 +1,539 @@ +sendPOST('/shift-reminder/check', [ + 'reminder_key' => 'day_shift', + ]); + + // Assert: ожидаем редирект на страницу логина или ошибку 403 + $I->seeResponseCodeIsClientError(); // 401, 403 или 3xx + } + + /** + * Тест: неавторизованный пользователь не может получить доступ к actionConfirm + */ + public function testActionConfirm_Unauthorized_ReturnsError(FunctionalTester $I) + { + // Arrange: не логинимся + + // Act + $I->sendPOST('/shift-reminder/confirm', [ + 'reminder_key' => 'day_shift', + ]); + + // Assert + $I->seeResponseCodeIsClientError(); + } + + /** + * Тест: только POST метод разрешен для actionCheck + */ + public function testActionCheck_GetMethod_ReturnsMethodNotAllowed(FunctionalTester $I) + { + // Arrange: логинимся как admin + $admin = Admin::findOne($this->testUserId); + if ($admin !== null) { + $I->amLoggedInAs($admin); + } + + // Act: отправляем GET запрос + $I->sendGET('/shift-reminder/check'); + + // Assert: должен быть ответ 405 Method Not Allowed + $I->seeResponseCodeIs(405); + } + + /** + * Тест: только POST метод разрешен для actionConfirm + */ + public function testActionConfirm_GetMethod_ReturnsMethodNotAllowed(FunctionalTester $I) + { + // Arrange + $admin = Admin::findOne($this->testUserId); + if ($admin !== null) { + $I->amLoggedInAs($admin); + } + + // Act + $I->sendGET('/shift-reminder/confirm'); + + // Assert + $I->seeResponseCodeIs(405); + } + + // ======================================================================== + // ГРУППА 2: Тесты actionCheck - валидация и бизнес-логика + // ======================================================================== + + /** + * Тест: успешная проверка с корректным reminder_key + */ + public function testActionCheck_ValidRequest_ReturnsSuccess(FunctionalTester $I) + { + // Arrange + $admin = Admin::findOne($this->testUserId); + if ($admin !== null) { + $I->amLoggedInAs($admin); + } + + // Act + $I->sendPOST('/shift-reminder/check', [ + 'reminder_key' => 'day_shift', + ]); + + // Assert + $I->seeResponseCodeIsSuccessful(); + $I->seeResponseIsJson(); + $I->seeResponseContainsJson([ + 'success' => true, + ]); + } + + /** + * Тест: некорректный reminder_key возвращает ошибку + */ + public function testActionCheck_InvalidReminderKey_ReturnsError(FunctionalTester $I) + { + // Arrange + $admin = Admin::findOne($this->testUserId); + if ($admin !== null) { + $I->amLoggedInAs($admin); + } + + // Act + $I->sendPOST('/shift-reminder/check', [ + 'reminder_key' => 'invalid_shift', + ]); + + // Assert + $I->seeResponseCodeIsSuccessful(); + $I->seeResponseIsJson(); + $I->seeResponseContainsJson([ + 'success' => false, + ]); + $I->seeResponseContains('Invalid reminder_key'); + } + + /** + * Тест: автоматическое определение reminder_key на основе времени + * + * Примечание: контроллер игнорирует переданный reminder_key + * и определяет его автоматически на основе текущего времени + */ + public function testActionCheck_AutoDetectsReminderKeyByTime(FunctionalTester $I) + { + // Arrange + $admin = Admin::findOne($this->testUserId); + if ($admin !== null) { + $I->amLoggedInAs($admin); + } + + // Act: отправляем запрос (контроллер сам определит правильный ключ) + $I->sendPOST('/shift-reminder/check'); + + // Assert + $I->seeResponseCodeIsSuccessful(); + $I->seeResponseIsJson(); + $I->seeResponseContainsJson([ + 'success' => true, + ]); + + // Проверяем, что возвращен reminder_key + $response = json_decode($I->grabResponse(), true); + if (isset($response['show_reminder']) && $response['show_reminder']) { + $I->assertArrayHasKey('reminder_key', $response); + $I->assertContains( + $response['reminder_key'], + ['day_shift', 'night_shift'] + ); + } + } + + /** + * Тест: повторная проверка после подтверждения не показывает напоминание + */ + public function testActionCheck_AfterConfirm_ReturnsShowFalse(FunctionalTester $I) + { + // Arrange: логинимся и подтверждаем напоминание + $admin = Admin::findOne($this->testUserId); + if ($admin !== null) { + $I->amLoggedInAs($admin); + } + + // Сначала подтверждаем напоминание + $I->sendPOST('/shift-reminder/confirm', [ + 'reminder_key' => 'day_shift', + ]); + $I->seeResponseCodeIsSuccessful(); + + // Act: проверяем снова + $I->sendPOST('/shift-reminder/check', [ + 'reminder_key' => 'day_shift', + ]); + + // Assert: не должно показывать напоминание (уже подтверждено) + $I->seeResponseCodeIsSuccessful(); + $I->seeResponseContainsJson([ + 'success' => true, + 'show_reminder' => false, + ]); + } + + // ======================================================================== + // ГРУППА 3: Тесты actionConfirm - подтверждение напоминаний + // ======================================================================== + + /** + * Тест: успешное подтверждение напоминания + */ + public function testActionConfirm_ValidRequest_ReturnsSuccess(FunctionalTester $I) + { + // Arrange + $admin = Admin::findOne($this->testUserId); + if ($admin !== null) { + $I->amLoggedInAs($admin); + } + + // Act + $I->sendPOST('/shift-reminder/confirm', [ + 'reminder_key' => 'day_shift', + ]); + + // Assert + $I->seeResponseCodeIsSuccessful(); + $I->seeResponseIsJson(); + $I->seeResponseContainsJson([ + 'success' => true, + 'message' => 'Reminder confirmed successfully.', + ]); + + // Проверяем что запись создана в БД + $record = ShiftReminderShown::find() + ->where([ + 'user_id' => $this->testUserId, + 'reminder_key' => 'day_shift', + ]) + ->one(); + + $I->assertNotNull($record, 'Запись должна быть создана в БД'); + $I->assertNotNull($record->confirmed_at, 'Поле confirmed_at должно быть заполнено'); + } + + /** + * Тест: подтверждение без reminder_key возвращает ошибку + */ + public function testActionConfirm_MissingReminderKey_ReturnsError(FunctionalTester $I) + { + // Arrange + $admin = Admin::findOne($this->testUserId); + if ($admin !== null) { + $I->amLoggedInAs($admin); + } + + // Act: не передаем reminder_key + $I->sendPOST('/shift-reminder/confirm', []); + + // Assert + $I->seeResponseCodeIsSuccessful(); + $I->seeResponseIsJson(); + $I->seeResponseContainsJson([ + 'success' => false, + ]); + $I->seeResponseContains('reminder_key is required'); + } + + /** + * Тест: подтверждение с некорректным reminder_key возвращает ошибку + */ + public function testActionConfirm_InvalidReminderKey_ReturnsError(FunctionalTester $I) + { + // Arrange + $admin = Admin::findOne($this->testUserId); + if ($admin !== null) { + $I->amLoggedInAs($admin); + } + + // Act + $I->sendPOST('/shift-reminder/confirm', [ + 'reminder_key' => 'invalid_key', + ]); + + // Assert + $I->seeResponseCodeIsSuccessful(); + $I->seeResponseIsJson(); + $I->seeResponseContainsJson([ + 'success' => false, + ]); + $I->seeResponseContains('Invalid reminder_key'); + } + + /** + * Тест: повторное подтверждение одного напоминания идемпотентно + */ + public function testActionConfirm_DuplicateConfirm_IsIdempotent(FunctionalTester $I) + { + // Arrange + $admin = Admin::findOne($this->testUserId); + if ($admin !== null) { + $I->amLoggedInAs($admin); + } + + // Act: подтверждаем дважды + $I->sendPOST('/shift-reminder/confirm', [ + 'reminder_key' => 'day_shift', + ]); + $I->seeResponseCodeIsSuccessful(); + + $I->sendPOST('/shift-reminder/confirm', [ + 'reminder_key' => 'day_shift', + ]); + + // Assert: должно вернуть успех (идемпотентность) + $I->seeResponseCodeIsSuccessful(); + $I->seeResponseContainsJson([ + 'success' => true, + ]); + + // Проверяем что в БД только одна запись + $count = ShiftReminderShown::find() + ->where([ + 'user_id' => $this->testUserId, + 'reminder_key' => 'day_shift', + ]) + ->count(); + + $I->assertEquals(1, $count, 'Должна быть только одна запись в БД'); + } + + /** + * Тест: подтверждение для разных reminder_key создает отдельные записи + */ + public function testActionConfirm_DifferentKeys_CreatesSeparateRecords(FunctionalTester $I) + { + // Arrange + $admin = Admin::findOne($this->testUserId); + if ($admin !== null) { + $I->amLoggedInAs($admin); + } + + // Act: подтверждаем day_shift + $I->sendPOST('/shift-reminder/confirm', [ + 'reminder_key' => 'day_shift', + ]); + $I->seeResponseCodeIsSuccessful(); + + // Act: подтверждаем night_shift + $I->sendPOST('/shift-reminder/confirm', [ + 'reminder_key' => 'night_shift', + ]); + $I->seeResponseCodeIsSuccessful(); + + // Assert: должно быть 2 записи в БД + $count = ShiftReminderShown::find() + ->where(['user_id' => $this->testUserId]) + ->count(); + + $I->assertEquals(2, $count, 'Должно быть 2 отдельные записи'); + } + + // ======================================================================== + // ГРУППА 4: Тесты безопасности + // ======================================================================== + + /** + * Тест: попытка SQL-инъекции через reminder_key блокируется + */ + public function testActionConfirm_SqlInjectionAttempt_IsBlocked(FunctionalTester $I) + { + // Arrange + $admin = Admin::findOne($this->testUserId); + if ($admin !== null) { + $I->amLoggedInAs($admin); + } + + // Act: попытка инъекции + $I->sendPOST('/shift-reminder/confirm', [ + 'reminder_key' => "day_shift' OR '1'='1", + ]); + + // Assert: должна быть ошибка валидации + $I->seeResponseCodeIsSuccessful(); + $I->seeResponseContainsJson([ + 'success' => false, + ]); + } + + /** + * Тест: попытка XSS через reminder_key блокируется + */ + public function testActionConfirm_XssAttempt_IsBlocked(FunctionalTester $I) + { + // Arrange + $admin = Admin::findOne($this->testUserId); + if ($admin !== null) { + $I->amLoggedInAs($admin); + } + + // Act + $I->sendPOST('/shift-reminder/confirm', [ + 'reminder_key' => '', + ]); + + // Assert + $I->seeResponseCodeIsSuccessful(); + $I->seeResponseContainsJson([ + 'success' => false, + ]); + } + + // ======================================================================== + // ГРУППА 5: Тесты rate limiting + // ======================================================================== + + /** + * Тест: rate limiting для actionCheck (10 req/min) + * + * Примечание: этот тест может быть нестабильным из-за time-based логики. + * В production рекомендуется использовать Redis или Memcached для rate limiting. + */ + public function testActionCheck_RateLimiting_BlocksExcessiveRequests(FunctionalTester $I) + { + // Arrange + $admin = Admin::findOne($this->testUserId); + if ($admin !== null) { + $I->amLoggedInAs($admin); + } + + // Act: отправляем 11 запросов (лимит = 10) + for ($i = 0; $i < 11; $i++) { + $I->sendPOST('/shift-reminder/check', [ + 'reminder_key' => 'day_shift', + ]); + + if ($i < 10) { + // Первые 10 запросов должны быть успешными + $I->seeResponseCodeIsSuccessful(); + } + } + + // Assert: 11-й запрос должен вернуть 429 Too Many Requests + $I->seeResponseCodeIs(429); + $I->seeResponseContains('Rate limit exceeded'); + } + + /** + * Тест: rate limiting для actionConfirm (5 req/min) + */ + public function testActionConfirm_RateLimiting_BlocksExcessiveRequests(FunctionalTester $I) + { + // Arrange + $admin = Admin::findOne($this->testUserId); + if ($admin !== null) { + $I->amLoggedInAs($admin); + } + + // Act: отправляем 6 запросов (лимит = 5) + for ($i = 0; $i < 6; $i++) { + $I->sendPOST('/shift-reminder/confirm', [ + 'reminder_key' => 'day_shift', + ]); + + if ($i < 5) { + $I->seeResponseCodeIsSuccessful(); + } + } + + // Assert: 6-й запрос должен вернуть 429 + $I->seeResponseCodeIs(429); + $I->seeResponseContains('Rate limit exceeded'); + } + + // ======================================================================== + // ГРУППА 6: Тесты reference_date логики + // ======================================================================== + + /** + * Тест: подтверждение в разные даты создает отдельные записи + * + * Примечание: этот тест проверяет, что reference_date корректно используется + * для разделения напоминаний по дням. + */ + public function testActionConfirm_DifferentDates_CreatesSeparateRecords(FunctionalTester $I) + { + // Arrange + $admin = Admin::findOne($this->testUserId); + if ($admin !== null) { + $I->amLoggedInAs($admin); + } + + // Act: подтверждаем напоминание + $I->sendPOST('/shift-reminder/confirm', [ + 'reminder_key' => 'day_shift', + ]); + $I->seeResponseCodeIsSuccessful(); + + // Получаем reference_date из ответа + $response1 = json_decode($I->grabResponse(), true); + $I->assertArrayHasKey('reference_date', $response1); + + // Note: В реальном тесте нужно было бы изменить время, + // но это требует mocking или изменения системного времени + // Здесь просто проверяем что поле reference_date присутствует + } +} diff --git a/erp24/tests/unit/models/ShiftReminderShownTest.php b/erp24/tests/unit/models/ShiftReminderShownTest.php new file mode 100644 index 00000000..c3703683 --- /dev/null +++ b/erp24/tests/unit/models/ShiftReminderShownTest.php @@ -0,0 +1,403 @@ +user_id = 1; + $model->reminder_key = 'day_shift'; + $model->reference_date = '2026-02-10'; + + // Act + $result = $model->validate(); + + // Assert + $this->assertTrue($result, 'Model should validate with all required fields'); + $this->assertEmpty($model->errors, 'Model should have no validation errors'); + } + + /** + * Тест: Модель не проходит валидацию без user_id + */ + public function testValidation_MissingUserId_Fails() + { + // Arrange + $model = new ShiftReminderShown(); + $model->reminder_key = 'day_shift'; + $model->reference_date = '2026-02-10'; + // user_id не установлен + + // Act + $result = $model->validate(); + + // Assert + $this->assertFalse($result, 'Model should fail validation without user_id'); + $this->assertArrayHasKey('user_id', $model->errors); + } + + /** + * Тест: Модель не проходит валидацию без reminder_key + */ + public function testValidation_MissingReminderKey_Fails() + { + // Arrange + $model = new ShiftReminderShown(); + $model->user_id = 1; + $model->reference_date = '2026-02-10'; + // reminder_key не установлен + + // Act + $result = $model->validate(); + + // Assert + $this->assertFalse($result, 'Model should fail validation without reminder_key'); + $this->assertArrayHasKey('reminder_key', $model->errors); + } + + /** + * Тест: Модель не проходит валидацию без reference_date + */ + public function testValidation_MissingReferenceDate_Fails() + { + // Arrange + $model = new ShiftReminderShown(); + $model->user_id = 1; + $model->reminder_key = 'day_shift'; + // reference_date не установлен + + // Act + $result = $model->validate(); + + // Assert + $this->assertFalse($result, 'Model should fail validation without reference_date'); + $this->assertArrayHasKey('reference_date', $model->errors); + } + + /** + * Тест: Модель не проходит валидацию при неверном формате reference_date + */ + public function testValidation_InvalidReferenceDateFormat_Fails() + { + // Arrange + $model = new ShiftReminderShown(); + $model->user_id = 1; + $model->reminder_key = 'day_shift'; + $model->reference_date = '10-02-2026'; // Неверный формат (должен быть Y-m-d) + + // Act + $result = $model->validate(); + + // Assert + $this->assertFalse($result, 'Model should fail validation with invalid date format'); + $this->assertArrayHasKey('reference_date', $model->errors); + } + + /** + * Тест: Модель проходит валидацию с reminder_key максимальной длины (50 символов) + */ + public function testValidation_ReminderKeyMaxLength_Success() + { + // Arrange + $model = new ShiftReminderShown(); + $model->user_id = 1; + $model->reminder_key = str_repeat('a', 50); // Ровно 50 символов + $model->reference_date = '2026-02-10'; + + // Act + $result = $model->validate(); + + // Assert + $this->assertTrue($result, 'Model should validate with reminder_key of max length (50)'); + $this->assertEmpty($model->errors); + } + + /** + * Тест: Модель не проходит валидацию с reminder_key более 50 символов + */ + public function testValidation_ReminderKeyExceedsMaxLength_Fails() + { + // Arrange + $model = new ShiftReminderShown(); + $model->user_id = 1; + $model->reminder_key = str_repeat('a', 51); // 51 символ (превышает лимит) + $model->reference_date = '2026-02-10'; + + // Act + $result = $model->validate(); + + // Assert + $this->assertFalse($result, 'Model should fail validation with reminder_key exceeding max length'); + $this->assertArrayHasKey('reminder_key', $model->errors); + } + + // ========================================== + // BUSINESS LOGIC TESTS (4 теста) + // ========================================== + + /** + * Тест: isConfirmed() возвращает false когда confirmed_at = null + */ + public function testIsConfirmed_WhenConfirmedAtNull_ReturnsFalse() + { + // Arrange + $model = new ShiftReminderShown(); + $model->user_id = 1; + $model->reminder_key = 'day_shift'; + $model->reference_date = '2026-02-10'; + $model->confirmed_at = null; + + // Act + $result = $model->isConfirmed(); + + // Assert + $this->assertFalse($result, 'isConfirmed() should return false when confirmed_at is null'); + } + + /** + * Тест: isConfirmed() возвращает true когда confirmed_at установлен + */ + public function testIsConfirmed_WhenConfirmedAtSet_ReturnsTrue() + { + // Arrange + $model = new ShiftReminderShown(); + $model->user_id = 1; + $model->reminder_key = 'day_shift'; + $model->reference_date = '2026-02-10'; + $model->confirmed_at = '2026-02-10 08:05:00'; + + // Act + $result = $model->isConfirmed(); + + // Assert + $this->assertTrue($result, 'isConfirmed() should return true when confirmed_at is set'); + } + + /** + * Тест: markAsConfirmed() устанавливает текущее время в confirmed_at и сохраняет + */ + public function testMarkAsConfirmed_SetsCurrentTimestamp_Success() + { + // Arrange + $model = new ShiftReminderShown(); + $model->user_id = 1; + $model->reminder_key = 'day_shift'; + $model->reference_date = '2026-02-10'; + $model->save(false); // Сохраняем без валидации для быстроты + + $beforeTime = date('Y-m-d H:i:s'); + + // Act + $result = $model->markAsConfirmed(); + $model->refresh(); // Перезагружаем из БД + + $afterTime = date('Y-m-d H:i:s'); + + // Assert + $this->assertTrue($result, 'markAsConfirmed() should return true on success'); + $this->assertNotNull($model->confirmed_at, 'confirmed_at should be set'); + $this->assertGreaterThanOrEqual($beforeTime, $model->confirmed_at, 'confirmed_at should be >= time before call'); + $this->assertLessThanOrEqual($afterTime, $model->confirmed_at, 'confirmed_at should be <= time after call'); + } + + /** + * Тест: markAsConfirmed() идемпотентен - повторный вызов не изменяет timestamp + */ + public function testMarkAsConfirmed_Idempotent_DoesNotChangeTimestamp() + { + // Arrange + $model = new ShiftReminderShown(); + $model->user_id = 1; + $model->reminder_key = 'day_shift'; + $model->reference_date = '2026-02-10'; + $model->save(false); + + // Первое подтверждение + $model->markAsConfirmed(); + $model->refresh(); + $firstConfirmedAt = $model->confirmed_at; + + // Небольшая задержка для уверенности что время изменилось бы + sleep(1); + + // Act - второе подтверждение + $model->markAsConfirmed(); + $model->refresh(); + $secondConfirmedAt = $model->confirmed_at; + + // Assert + $this->assertEquals( + $firstConfirmedAt, + $secondConfirmedAt, + 'markAsConfirmed() should be idempotent - timestamp should not change on second call' + ); + } + + // ========================================== + // DATABASE INTEGRITY TESTS (3 теста) + // ========================================== + + /** + * Тест: save() создаёт запись в БД + */ + public function testSave_ValidModel_CreatesRecord() + { + // Arrange + $model = new ShiftReminderShown(); + $model->user_id = 1; + $model->reminder_key = 'day_shift'; + $model->reference_date = '2026-02-10'; + + // Act + $result = $model->save(); + + // Assert + $this->assertTrue($result, 'save() should return true'); + $this->assertNotNull($model->id, 'Model should have id after save'); + + // Проверяем что запись действительно в БД + $found = ShiftReminderShown::findOne($model->id); + $this->assertNotNull($found, 'Saved model should be found in database'); + $this->assertEquals(1, $found->user_id); + $this->assertEquals('day_shift', $found->reminder_key); + $this->assertEquals('2026-02-10', $found->reference_date); + } + + /** + * Тест: save() с дублирующим ключом (user_id, reminder_key, reference_date) вызывает IntegrityException + * + * КРИТИЧЕСКИЙ ТЕСТ - проверяет защиту от race conditions через unique constraint + */ + public function testSave_DuplicateKey_ThrowsIntegrityException() + { + // Arrange - создаём первую запись + $first = new ShiftReminderShown(); + $first->user_id = 1; + $first->reminder_key = 'day_shift'; + $first->reference_date = '2026-02-10'; + $first->save(false); + + // Создаём вторую запись с тем же ключом + $duplicate = new ShiftReminderShown(); + $duplicate->user_id = 1; + $duplicate->reminder_key = 'day_shift'; + $duplicate->reference_date = '2026-02-10'; + + // Act & Assert + $this->expectException(IntegrityException::class); + $duplicate->save(false); // false чтобы пропустить валидацию и дойти до БД + } + + /** + * Тест: find() по user_id и reminder_key возвращает корректную запись + */ + public function testFind_ByUserIdAndReminderKey_ReturnsCorrectRecord() + { + // Arrange - создаём несколько записей + $model1 = new ShiftReminderShown(); + $model1->user_id = 1; + $model1->reminder_key = 'day_shift'; + $model1->reference_date = '2026-02-10'; + $model1->save(false); + + $model2 = new ShiftReminderShown(); + $model2->user_id = 1; + $model2->reminder_key = 'night_shift'; + $model2->reference_date = '2026-02-10'; + $model2->save(false); + + $model3 = new ShiftReminderShown(); + $model3->user_id = 2; + $model3->reminder_key = 'day_shift'; + $model3->reference_date = '2026-02-10'; + $model3->save(false); + + // Act - ищем конкретную запись + $found = ShiftReminderShown::find() + ->where([ + 'user_id' => 1, + 'reminder_key' => 'day_shift', + 'reference_date' => '2026-02-10', + ]) + ->one(); + + // Assert + $this->assertNotNull($found, 'Should find the record'); + $this->assertEquals($model1->id, $found->id, 'Should find correct record'); + $this->assertEquals(1, $found->user_id); + $this->assertEquals('day_shift', $found->reminder_key); + } + + // ========================================== + // TIMESTAMP TESTS (1 тест) + // ========================================== + + /** + * Тест: created_at автоматически устанавливается при создании записи + */ + public function testCreatedAt_AutomaticallySet_OnCreate() + { + // Arrange + $model = new ShiftReminderShown(); + $model->user_id = 1; + $model->reminder_key = 'day_shift'; + $model->reference_date = '2026-02-10'; + + // Act + $model->save(false); + $model->refresh(); + + // Assert + $this->assertNotNull($model->created_at, 'created_at should be set automatically'); + + // Проверяем что created_at это валидный timestamp (обрезаем микросекунды) + $createdAtClean = preg_replace('/\.\d+$/', '', $model->created_at); + $createdTimestamp = strtotime($createdAtClean); + $this->assertNotFalse($createdTimestamp, 'created_at should be a valid timestamp'); + + // Проверяем что timestamp недавний (в пределах последних 24 часов) + // Это учитывает timezone различия между PHP и PostgreSQL + $oneDayAgo = time() - 86400; + $oneDayAhead = time() + 86400; + $this->assertGreaterThan($oneDayAgo, $createdTimestamp, 'created_at should be recent (within last 24 hours)'); + $this->assertLessThan($oneDayAhead, $createdTimestamp, 'created_at should not be too far in future (within next 24 hours)'); + } +} diff --git a/erp24/tests/unit/services/ShiftReminderServiceTest.php b/erp24/tests/unit/services/ShiftReminderServiceTest.php new file mode 100644 index 00000000..0002d62e --- /dev/null +++ b/erp24/tests/unit/services/ShiftReminderServiceTest.php @@ -0,0 +1,654 @@ +assertEquals( + '2026-02-03', + $result, + 'Время 01:00 должно относиться к предыдущему дню (2026-02-03)' + ); + } + + /** + * Тест: время 06:00-23:59 должно возвращать текущий день + */ + public function testGetReferenceDate_SixAmToMidnight_ReturnsCurrentDay() + { + // Arrange: время 07:50 (стандартное окно напоминания) + $testTime = '2026-02-04 07:50:00'; + + // Act + $result = ShiftReminderService::getReferenceDate($testTime); + + // Assert + $this->assertEquals( + '2026-02-04', + $result, + 'Время 07:50 должно относиться к текущему дню (2026-02-04)' + ); + } + + /** + * Тест: граничный случай 00:00 (полночь) - первая секунда дня + */ + public function testGetReferenceDate_Midnight_ReturnsPreviousDay() + { + // Arrange + $testTime = '2026-02-04 00:00:00'; + + // Act + $result = ShiftReminderService::getReferenceDate($testTime); + + // Assert + $this->assertEquals( + '2026-02-03', + $result, + 'Полночь (00:00:00) должна относиться к предыдущему дню' + ); + } + + /** + * Тест: граничный случай 05:59:59 - последняя секунда "ночного" периода + */ + public function testGetReferenceDate_FiveAmFiftyNine_ReturnsPreviousDay() + { + // Arrange + $testTime = '2026-02-04 05:59:59'; + + // Act + $result = ShiftReminderService::getReferenceDate($testTime); + + // Assert + $this->assertEquals( + '2026-02-03', + $result, + 'Время 05:59:59 должно относиться к предыдущему дню (последняя секунда периода)' + ); + } + + /** + * Тест: граничный случай 06:00:00 - первая секунда "дневного" периода + */ + public function testGetReferenceDate_SixAm_ReturnsCurrentDay() + { + // Arrange + $testTime = '2026-02-04 06:00:00'; + + // Act + $result = ShiftReminderService::getReferenceDate($testTime); + + // Assert + $this->assertEquals( + '2026-02-04', + $result, + 'Время 06:00:00 должно относиться к текущему дню (первая секунда периода)' + ); + } + + /** + * Тест: полдень - типичное время дневной смены + */ + public function testGetReferenceDate_Noon_ReturnsCurrentDay() + { + // Arrange + $testTime = '2026-02-04 12:00:00'; + + // Act + $result = ShiftReminderService::getReferenceDate($testTime); + + // Assert + $this->assertEquals('2026-02-04', $result); + } + + /** + * Тест: конец дня 23:59:59 - последняя секунда перед переходом + */ + public function testGetReferenceDate_EndOfDay_ReturnsCurrentDay() + { + // Arrange + $testTime = '2026-02-04 23:59:59'; + + // Act + $result = ShiftReminderService::getReferenceDate($testTime); + + // Assert + $this->assertEquals( + '2026-02-04', + $result, + 'Время 23:59:59 должно относиться к текущему дню' + ); + } + + /** + * Тест: без параметра $currentTime должно использоваться текущее время + */ + public function testGetReferenceDate_NoParameter_ReturnsToday() + { + // Act + $result = ShiftReminderService::getReferenceDate(); + + // Assert + $currentHour = (int)date('H'); + $expectedDate = ($currentHour >= 0 && $currentHour < 6) + ? date('Y-m-d', strtotime('-1 day')) + : date('Y-m-d'); + + $this->assertEquals( + $expectedDate, + $result, + 'Без параметра должно использоваться текущее время с корректной логикой' + ); + } + + // ======================================================================== + // ГРУППА 2: isInTimeWindow() - Проверка временных окон напоминаний + // ======================================================================== + + /** + * Тест: точное совпадение с окном 07:50 (закрытие ночной смены) + */ + public function testIsInTimeWindow_SevenFiftyAm_ReturnsTrue() + { + // Arrange + $testTime = '2026-02-04 07:50:00'; + + // Act + $result = ShiftReminderService::isInTimeWindow($testTime); + + // Assert + $this->assertTrue( + $result, + 'Время 07:50 должно попадать в окно напоминания' + ); + } + + /** + * Тест: точное совпадение с окном 08:10 (открытие дневной смены) + */ + public function testIsInTimeWindow_EightTenAm_ReturnsTrue() + { + // Arrange + $testTime = '2026-02-04 08:10:00'; + + // Act + $result = ShiftReminderService::isInTimeWindow($testTime); + + // Assert + $this->assertTrue( + $result, + 'Время 08:10 должно попадать в окно напоминания' + ); + } + + /** + * Тест: точное совпадение с окном 19:50 (закрытие дневной смены) + */ + public function testIsInTimeWindow_SevenFiftyPm_ReturnsTrue() + { + // Arrange + $testTime = '2026-02-04 19:50:00'; + + // Act + $result = ShiftReminderService::isInTimeWindow($testTime); + + // Assert + $this->assertTrue( + $result, + 'Время 19:50 должно попадать в окно напоминания' + ); + } + + /** + * Тест: точное совпадение с окном 20:10 (открытие ночной смены) + */ + public function testIsInTimeWindow_EightTenPm_ReturnsTrue() + { + // Arrange + $testTime = '2026-02-04 20:10:00'; + + // Act + $result = ShiftReminderService::isInTimeWindow($testTime); + + // Assert + $this->assertTrue( + $result, + 'Время 20:10 должно попадать в окно напоминания' + ); + } + + /** + * Тест: несовпадение - одна минута до окна + */ + public function testIsInTimeWindow_OneMinuteBefore_ReturnsFalse() + { + // Arrange: 07:49 - одна минута до первого окна + $testTime = '2026-02-04 07:49:00'; + + // Act + $result = ShiftReminderService::isInTimeWindow($testTime); + + // Assert + $this->assertFalse( + $result, + 'Время 07:49 не должно попадать в окно напоминания' + ); + } + + /** + * Тест: несовпадение - одна минута после окна + */ + public function testIsInTimeWindow_OneMinuteAfter_ReturnsFalse() + { + // Arrange: 07:51 - одна минута после первого окна + $testTime = '2026-02-04 07:51:00'; + + // Act + $result = ShiftReminderService::isInTimeWindow($testTime); + + // Assert + $this->assertFalse( + $result, + 'Время 07:51 не должно попадать в окно напоминания' + ); + } + + /** + * Тест: произвольное время вне всех окон + */ + public function testIsInTimeWindow_RandomTime_ReturnsFalse() + { + // Arrange: полдень - вне всех окон + $testTime = '2026-02-04 14:30:00'; + + // Act + $result = ShiftReminderService::isInTimeWindow($testTime); + + // Assert + $this->assertFalse( + $result, + 'Произвольное время 14:30 не должно попадать в окна напоминаний' + ); + } + + /** + * Тест: проверка с секундами (должно игнорировать секунды) + */ + public function testIsInTimeWindow_WithSeconds_IgnoresSeconds() + { + // Arrange: 07:50:45 - секунды должны игнорироваться + $testTime = '2026-02-04 07:50:45'; + + // Act + $result = ShiftReminderService::isInTimeWindow($testTime); + + // Assert + $this->assertTrue( + $result, + 'Время 07:50:45 должно совпадать с окном 07:50 (секунды игнорируются)' + ); + } + + /** + * Тест: кастомные временные окна - совпадение + */ + public function testIsInTimeWindow_CustomWindows_ReturnsTrue() + { + // Arrange + $testTime = '2026-02-04 10:00:00'; + $customWindows = ['10:00', '15:00']; + + // Act + $result = ShiftReminderService::isInTimeWindow($testTime, $customWindows); + + // Assert + $this->assertTrue( + $result, + 'Время 10:00 должно попадать в кастомное окно' + ); + } + + /** + * Тест: кастомные окна - несовпадение + */ + public function testIsInTimeWindow_CustomWindowsNoMatch_ReturnsFalse() + { + // Arrange + $testTime = '2026-02-04 10:01:00'; + $customWindows = ['10:00', '15:00']; + + // Act + $result = ShiftReminderService::isInTimeWindow($testTime, $customWindows); + + // Assert + $this->assertFalse( + $result, + 'Время 10:01 не должно попадать в кастомные окна [10:00, 15:00]' + ); + } + + /** + * Тест: пустой массив окон - всегда false + */ + public function testIsInTimeWindow_EmptyWindows_ReturnsFalse() + { + // Arrange + $testTime = '2026-02-04 07:50:00'; + $emptyWindows = []; + + // Act + $result = ShiftReminderService::isInTimeWindow($testTime, $emptyWindows); + + // Assert + $this->assertFalse( + $result, + 'При пустом массиве окон должно возвращаться false' + ); + } + + /** + * Тест: без параметра $currentTime должно использоваться текущее время + */ + public function testIsInTimeWindow_NoParameter_UsesCurrentTime() + { + // Act + $result = ShiftReminderService::isInTimeWindow(); + + // Assert + // Результат зависит от текущего времени, проверяем что метод не падает + $this->assertIsBool($result, 'Метод должен вернуть boolean значение'); + } + + // ======================================================================== + // ГРУППА 3: getAllowedReminderKeys() и isValidReminderKey() - Валидация ключей + // ======================================================================== + + /** + * Тест: получение списка допустимых ключей из конфигурации + */ + public function testGetAllowedReminderKeys_ReturnsConfiguredKeys() + { + // Arrange: конфигурация установлена в params.php + // SHIFT_REMINDER_ALLOWED_KEYS = ['day_shift', 'night_shift'] + + // Act + $keys = ShiftReminderService::getAllowedReminderKeys(); + + // Assert + $this->assertIsArray($keys, 'Должен вернуть массив'); + $this->assertContains('day_shift', $keys, 'Должен содержать day_shift'); + $this->assertContains('night_shift', $keys, 'Должен содержать night_shift'); + $this->assertCount(2, $keys, 'Должно быть ровно 2 ключа по умолчанию'); + } + + /** + * Тест: валидация корректного ключа day_shift + */ + public function testIsValidReminderKey_DayShift_ReturnsTrue() + { + // Arrange + $key = 'day_shift'; + + // Act + $result = ShiftReminderService::isValidReminderKey($key); + + // Assert + $this->assertTrue( + $result, + 'Ключ "day_shift" должен быть валидным' + ); + } + + /** + * Тест: валидация корректного ключа night_shift + */ + public function testIsValidReminderKey_NightShift_ReturnsTrue() + { + // Arrange + $key = 'night_shift'; + + // Act + $result = ShiftReminderService::isValidReminderKey($key); + + // Assert + $this->assertTrue( + $result, + 'Ключ "night_shift" должен быть валидным' + ); + } + + /** + * Тест: валидация некорректного ключа + */ + public function testIsValidReminderKey_InvalidKey_ReturnsFalse() + { + // Arrange + $invalidKeys = [ + 'invalid_shift', + 'morning_shift', + '', + 'DAY_SHIFT', // регистр важен + 'day shift', // пробел + ]; + + // Act & Assert + foreach ($invalidKeys as $key) { + $result = ShiftReminderService::isValidReminderKey($key); + $this->assertFalse( + $result, + "Ключ '{$key}' не должен быть валидным" + ); + } + } + + /** + * Тест: валидация с использованием строгого сравнения (strict) + */ + public function testIsValidReminderKey_UsesStrictComparison() + { + // Arrange: цифровой ключ который может совпадать с индексом массива + $numericKey = '0'; + + // Act + $result = ShiftReminderService::isValidReminderKey($numericKey); + + // Assert + $this->assertFalse( + $result, + 'Должно использоваться строгое сравнение (strict), цифра "0" не должна совпадать с индексом' + ); + } + + // ======================================================================== + // ГРУППА 4: Интеграционные тесты для isShiftActionNeeded() + // ======================================================================== + + /** + * Тест: проверка периодов закрытия смены + * + * Примечание: этот метод требует доступа к БД через TimetableFactModel. + * В unit-тестах проверяем только логику определения периодов. + */ + public function testIsShiftActionNeeded_ClosingPeriodDetection() + { + // Arrange: время в период закрытия ночной смены (07:50-08:09) + $morningClosingTime = '2026-02-04 07:50:00'; + + // Arrange: время в период закрытия дневной смены (19:50-20:09) + $eveningClosingTime = '2026-02-04 19:50:00'; + + // Act & Assert + // Метод работает с БД, поэтому здесь проверяем только что он не падает + // Полные тесты должны быть в functional-тестах с подготовленной БД + + // Проверяем что метод не падает с exception + $this->expectNotToPerformAssertions(); + try { + ShiftReminderService::isShiftActionNeeded(1, $morningClosingTime); + ShiftReminderService::isShiftActionNeeded(1, $eveningClosingTime); + } catch (\Exception $e) { + $this->fail('Метод не должен бросать исключения: ' . $e->getMessage()); + } + } + + /** + * Тест: проверка периодов открытия смены + */ + public function testIsShiftActionNeeded_OpeningPeriodDetection() + { + // Arrange: время в период дневной смены (08:10-19:49) + $dayShiftTime = '2026-02-04 12:00:00'; + + // Arrange: время в период ночной смены (20:10-07:49) + $nightShiftTime = '2026-02-04 22:00:00'; + + // Act & Assert + // Проверяем что метод не падает + $this->expectNotToPerformAssertions(); + try { + ShiftReminderService::isShiftActionNeeded(1, $dayShiftTime); + ShiftReminderService::isShiftActionNeeded(1, $nightShiftTime); + } catch (\Exception $e) { + $this->fail('Метод не должен бросать исключения: ' . $e->getMessage()); + } + } + + // ======================================================================== + // ГРУППА 5: Граничные случаи и edge cases + // ======================================================================== + + /** + * Тест: граница перехода дня в високосном году + */ + public function testGetReferenceDate_LeapYearBoundary() + { + // Arrange: 1 марта 2024 (високосный год) в 01:00 + $testTime = '2024-03-01 01:00:00'; + + // Act + $result = ShiftReminderService::getReferenceDate($testTime); + + // Assert + $this->assertEquals( + '2024-02-29', + $result, + 'Должно корректно обработать переход через 29 февраля' + ); + } + + /** + * Тест: переход года + */ + public function testGetReferenceDate_YearBoundary() + { + // Arrange: 1 января в 01:00 + $testTime = '2026-01-01 01:00:00'; + + // Act + $result = ShiftReminderService::getReferenceDate($testTime); + + // Assert + $this->assertEquals( + '2025-12-31', + $result, + 'Должно корректно обработать переход года' + ); + } + + /** + * Тест: все временные окна в течение суток + */ + public function testIsInTimeWindow_AllWindowsInDay() + { + // Arrange: все 4 стандартных окна + $windows = [ + '2026-02-04 07:50:00' => true, // утреннее закрытие + '2026-02-04 08:10:00' => true, // утреннее открытие + '2026-02-04 19:50:00' => true, // вечернее закрытие + '2026-02-04 20:10:00' => true, // вечернее открытие + ]; + + // Act & Assert + foreach ($windows as $time => $expected) { + $result = ShiftReminderService::isInTimeWindow($time); + $this->assertEquals( + $expected, + $result, + "Время {$time} должно быть в окне напоминания" + ); + } + } + + /** + * Тест: защита от SQL-инъекций в reminder_key + * + * Проверяем, что whitelist корректно блокирует вредоносные входные данные + */ + public function testIsValidReminderKey_SqlInjectionAttempt_ReturnsFalse() + { + // Arrange: попытка SQL-инъекции + $maliciousKeys = [ + "day_shift' OR '1'='1", + "day_shift; DROP TABLE shift_reminder_shown;--", + "day_shift' UNION SELECT * FROM users--", + ]; + + // Act & Assert + foreach ($maliciousKeys as $key) { + $result = ShiftReminderService::isValidReminderKey($key); + $this->assertFalse( + $result, + "Вредоносный ключ должен быть отклонен: {$key}" + ); + } + } + + /** + * Тест: корректная обработка пустых и null значений + */ + public function testIsValidReminderKey_EmptyAndNull_ReturnsFalse() + { + // Arrange + $invalidValues = [ + '', + null, + ]; + + // Act & Assert + foreach ($invalidValues as $value) { + $result = ShiftReminderService::isValidReminderKey($value); + $this->assertFalse( + $result, + 'Пустые и null значения должны быть невалидными' + ); + } + } +} diff --git a/erp24/web/js/shift-reminder.js b/erp24/web/js/shift-reminder.js index 6fd54498..26f6229b 100644 --- a/erp24/web/js/shift-reminder.js +++ b/erp24/web/js/shift-reminder.js @@ -9,10 +9,15 @@ (function() { 'use strict'; - // CSRF token extraction + // CSRF token extraction with validation const csrfParam = $('meta[name=csrf-param]').attr('content'); const csrfToken = $('meta[name=csrf-token]').attr('content'); + // Validate CSRF tokens are available + if (!csrfParam || !csrfToken) { + console.error('ShiftReminder: CSRF tokens not found in meta tags. AJAX requests will fail.'); + } + // Configuration const CONFIG = { pollInterval: 30000, // 30 seconds @@ -149,7 +154,9 @@ state.retryCount++; } - state.currentInterval = CONFIG.retryIntervals[state.retryCount]; + // Ensure retryCount doesn't exceed array bounds (defensive programming) + const safeRetryCount = Math.min(state.retryCount, CONFIG.retryIntervals.length - 1); + state.currentInterval = CONFIG.retryIntervals[safeRetryCount]; stopPolling(); startPolling(); } @@ -192,25 +199,27 @@ state.isModalVisible = true; // Create modal HTML - const modalHtml = ` -
-
-
- -
-

Напоминание о смене

-

- ${data.message || 'Пожалуйста, подтвердите, что вы ознакомились с напоминанием о смене.'} -

- -
-
- `; + // Create modal structure without user data (XSS protection) + const $modal = $('
').attr('id', 'shift-reminder-modal').addClass('shift-reminder-overlay'); + const $content = $('
').addClass('shift-reminder-content'); + const $icon = $('
').addClass('shift-reminder-icon').html(''); + const $title = $('

').addClass('shift-reminder-title').text('Напоминание о смене'); + const $message = $('

').addClass('shift-reminder-message'); + const $button = $('