s-issuekit 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,47 @@
1
+ # === atlas universal gitignore ===
2
+
3
+ # OS / IDE
4
+ .DS_Store
5
+ Thumbs.db
6
+ .vscode/
7
+ .idea/
8
+ *.swp
9
+ *.swo
10
+
11
+ # Sensitive
12
+ .env
13
+ .env.local
14
+ *.key
15
+ *.pem
16
+ secrets/
17
+ private/
18
+
19
+ # Python
20
+ __pycache__/
21
+ *.py[cod]
22
+ .venv/
23
+ venv/
24
+ .pytest_cache/
25
+ .ruff_cache/
26
+ *.egg-info/
27
+
28
+ # Node / JS
29
+ node_modules/
30
+ .next/
31
+ dist/
32
+ build/
33
+
34
+ # Temporary / large
35
+ *.log
36
+ *.tmp
37
+ nul
38
+ NUL
39
+ *.zip
40
+ *.rar
41
+ *.7z
42
+
43
+ # Media (selectively unignore via !path/*.ext if needed for fixtures)
44
+ *.mp4
45
+ *.mov
46
+ *.avi
47
+ *.mkv
@@ -0,0 +1,31 @@
1
+ # Автопубликация на PyPI по семвер-тегу vX.Y.Z через Trusted Publishing (OIDC).
2
+ # Токены НИГДЕ не хранятся: GitLab выдаёт короткоживущий OIDC-токен (PYPI_ID_TOKEN),
3
+ # он одноразово обменивается на PyPI API-токен. Издатель настраивается на pypi.org:
4
+ # Manage -> Publishing -> GitLab (namespace=S-kits, project=issuekit,
5
+ # top-level pipeline file=.gitlab-ci.yml, environment — пусто).
6
+ # Запускается ТОЛЬКО на тег vX.Y.Z (обычный push на main ничего не публикует).
7
+ stages: [publish]
8
+
9
+ publish-pypi:
10
+ stage: publish
11
+ image: ghcr.io/astral-sh/uv:python3.12-bookworm
12
+ rules:
13
+ - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+$/
14
+ id_tokens:
15
+ PYPI_ID_TOKEN:
16
+ aud: pypi
17
+ script:
18
+ - uv build
19
+ # обмен GitLab OIDC -> одноразовый PyPI API-токен (Trusted Publishing):
20
+ - |
21
+ export UV_PUBLISH_TOKEN=$(python3 - <<'PY'
22
+ import json, os, urllib.request
23
+ req = urllib.request.Request(
24
+ "https://pypi.org/_/oidc/mint-token",
25
+ data=json.dumps({"token": os.environ["PYPI_ID_TOKEN"]}).encode(),
26
+ headers={"Content-Type": "application/json"},
27
+ )
28
+ print(json.load(urllib.request.urlopen(req))["token"])
29
+ PY
30
+ )
31
+ - uv publish
@@ -0,0 +1,45 @@
1
+ # AGENTS.md — issuekit
2
+
3
+ > Контекст для AI-ассистентов (Claude Code, ChatGPT, Cursor и т.п.), работающих
4
+ > над этим проектом.
5
+
6
+ ## Что это
7
+
8
+ (заполнить one-line)
9
+
10
+ ## Atlas
11
+
12
+ Проект зарегистрирован в Atlas-БД (Atlas). Карточка:
13
+
14
+ ```sh
15
+ atlas projects get issuekit
16
+ ```
17
+
18
+ Любые изменения метаданных (приоритет, статус, теги) — через atlas CLI:
19
+
20
+ - `atlas projects update issuekit --priority P0` — поменять приоритет
21
+ - `atlas add-tags issuekit -t domain:<slug>` — добавить тег
22
+ - `atlas projects move issuekit --to-type <type>` — конвертировать тип
23
+
24
+ ## Тип / Статус (на момент создания)
25
+
26
+ - type=`kit`, status=`experiment`, priority=`P2`
27
+
28
+ ## Правила работы
29
+
30
+ - Все исходные тексты, документы, код проекта — в этом репо.
31
+ - Чувствительные данные (`.env`, токены, ключи) — игнорируются `.gitignore`.
32
+ - AI-ассистенту разрешено: читать, генерировать, редактировать в этом репо.
33
+
34
+ ## Канонические команды
35
+
36
+ - `atlas projects get issuekit` — карточка проекта
37
+ - `atlas task list --project issuekit` — задачи проекта (когда W7
38
+ волна будет реализована)
39
+
40
+ <!-- atlas:usage:start -->
41
+ ## Управление проектом — через Atlas
42
+
43
+ Этот проект ведётся в Atlas (личная PM-система портфеля). Для задач/проектов/эпиков/бэкапов
44
+ используй CLI `atlas` и вызывай навык `atlas` — вся логика и роутинг внутри навыка.
45
+ <!-- atlas:usage:end -->
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dmitry
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,72 @@
1
+ Metadata-Version: 2.4
2
+ Name: s-issuekit
3
+ Version: 0.1.0
4
+ Summary: Как правильно составить жалобу (issue/feature/handoff агент→агент): шаблоны-данные + валидатор качества (каких полей чеклиста не хватает) для богатой обратной связи. Тонкий слой на clikit.
5
+ Author: Dmitry
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: s-clikit
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8.3; extra == 'dev'
12
+ Requires-Dist: ruff>=0.8; extra == 'dev'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # issuekit
16
+
17
+ **Как правильно составить жалобу — и валидатор качества.** Переиспользуемый слой
18
+ (dist `s-issuekit` / import `issuekit`) поверх `clikit`. Три вида жалоб как ДАННЫЕ:
19
+ **bug** / **feature** / **handoff** (передача задачи агент→агент). Машинная проверка
20
+ `lint` говорит, каких полей чеклиста не хватает — основа **богатой обратной связи**
21
+ в мультиагентной работе.
22
+
23
+ ## Зачем
24
+
25
+ Разница между жалобой, которую чинят за 5 минут, и той, что висит месяцами — не в
26
+ сложности бага, а в оформлении. issuekit кодирует чеклист (версии / минимальный
27
+ пример / traceback / ожидал-vs-получил / для фич — юзкейс) и проверяет его
28
+ автоматически. В Atlas на этом строится блокирующая дисциплина передачи задач между
29
+ агентами.
30
+
31
+ ## Установка
32
+
33
+ ```bash
34
+ pip install s-issuekit # import issuekit (dist-имя ≠ import-имя)
35
+ ```
36
+
37
+ ## CLI
38
+
39
+ ```bash
40
+ issuekit kinds # виды + обязательные секции
41
+ issuekit new --kind handoff --title "…" # пустой шаблон → заполнить
42
+ issuekit lint myissue.md --kind bug # проверка: чего не хватает + балл
43
+ cat issue.md | issuekit lint -k feature
44
+ ```
45
+
46
+ ## Библиотека
47
+
48
+ ```python
49
+ from issuekit import lint, new_template, list_kinds
50
+
51
+ tpl = new_template("handoff", title="Доделать миграцию")
52
+ res = lint(text, "handoff") # res.ok (все required заполнены), res.score, res.missing
53
+ if not res.ok:
54
+ print("не хватает:", res.missing)
55
+ ```
56
+
57
+ ## Виды и обязательные секции
58
+
59
+ - **bug**: что сломалось · ожидал · получил · версии · минимальный пример · полный traceback.
60
+ - **feature**: задача (юзкейс) · желаемый результат.
61
+ - **handoff**: что сделано · что осталось · ожидаемый результат (ЦКП) · как проверить · контекст и версии.
62
+
63
+ Расширить виды без правки кита — запись в `data/kinds.json` или entry-points `issuekit.kinds`.
64
+
65
+ ## Навык
66
+
67
+ Несёт навык `skills/issuekit/SKILL.md` — «как зарепортить проблему, чтобы вас не
68
+ возненавидели» (как правильно / антипаттерны).
69
+
70
+ ## Лицензия
71
+
72
+ [MIT](LICENSE).
@@ -0,0 +1,58 @@
1
+ # issuekit
2
+
3
+ **Как правильно составить жалобу — и валидатор качества.** Переиспользуемый слой
4
+ (dist `s-issuekit` / import `issuekit`) поверх `clikit`. Три вида жалоб как ДАННЫЕ:
5
+ **bug** / **feature** / **handoff** (передача задачи агент→агент). Машинная проверка
6
+ `lint` говорит, каких полей чеклиста не хватает — основа **богатой обратной связи**
7
+ в мультиагентной работе.
8
+
9
+ ## Зачем
10
+
11
+ Разница между жалобой, которую чинят за 5 минут, и той, что висит месяцами — не в
12
+ сложности бага, а в оформлении. issuekit кодирует чеклист (версии / минимальный
13
+ пример / traceback / ожидал-vs-получил / для фич — юзкейс) и проверяет его
14
+ автоматически. В Atlas на этом строится блокирующая дисциплина передачи задач между
15
+ агентами.
16
+
17
+ ## Установка
18
+
19
+ ```bash
20
+ pip install s-issuekit # import issuekit (dist-имя ≠ import-имя)
21
+ ```
22
+
23
+ ## CLI
24
+
25
+ ```bash
26
+ issuekit kinds # виды + обязательные секции
27
+ issuekit new --kind handoff --title "…" # пустой шаблон → заполнить
28
+ issuekit lint myissue.md --kind bug # проверка: чего не хватает + балл
29
+ cat issue.md | issuekit lint -k feature
30
+ ```
31
+
32
+ ## Библиотека
33
+
34
+ ```python
35
+ from issuekit import lint, new_template, list_kinds
36
+
37
+ tpl = new_template("handoff", title="Доделать миграцию")
38
+ res = lint(text, "handoff") # res.ok (все required заполнены), res.score, res.missing
39
+ if not res.ok:
40
+ print("не хватает:", res.missing)
41
+ ```
42
+
43
+ ## Виды и обязательные секции
44
+
45
+ - **bug**: что сломалось · ожидал · получил · версии · минимальный пример · полный traceback.
46
+ - **feature**: задача (юзкейс) · желаемый результат.
47
+ - **handoff**: что сделано · что осталось · ожидаемый результат (ЦКП) · как проверить · контекст и версии.
48
+
49
+ Расширить виды без правки кита — запись в `data/kinds.json` или entry-points `issuekit.kinds`.
50
+
51
+ ## Навык
52
+
53
+ Несёт навык `skills/issuekit/SKILL.md` — «как зарепортить проблему, чтобы вас не
54
+ возненавидели» (как правильно / антипаттерны).
55
+
56
+ ## Лицензия
57
+
58
+ [MIT](LICENSE).
@@ -0,0 +1,27 @@
1
+ """issuekit — как правильно составить жалобу (issue/feature/handoff) + валидатор.
2
+
3
+ Кит даёт: (1) шаблоны видов жалоб (bug / feature / handoff агент→агент) как
4
+ ДАННЫЕ; (2) ``lint`` — машинная проверка качества жалобы (каких полей чеклиста не
5
+ хватает + балл); (3) навык-гайд «как зарепортить проблему, чтобы вас не
6
+ возненавидели». Потребитель (Atlas) использует ``lint`` для блокирующей дисциплины
7
+ передачи задач между агентами — богатая обратная связь гарантирована.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ __version__ = "0.1.0"
12
+
13
+ from .lint import LintResult, lint
14
+ from .render import new_template, section_header
15
+ from .spec import KindSpec, Section, get_kind, list_kinds
16
+
17
+ __all__ = [
18
+ "__version__",
19
+ "lint",
20
+ "LintResult",
21
+ "new_template",
22
+ "section_header",
23
+ "get_kind",
24
+ "list_kinds",
25
+ "KindSpec",
26
+ "Section",
27
+ ]
@@ -0,0 +1,89 @@
1
+ """CLI кита: ``issuekit new`` (шаблон) / ``issuekit lint`` (проверка) / ``kinds``."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import typer
8
+ from clikit import CliError, build_root_app, command, emit_data
9
+
10
+ from . import __version__
11
+ from .lint import lint as _lint
12
+ from .render import new_template
13
+ from .spec import get_kind, list_kinds
14
+
15
+ app = build_root_app(
16
+ "issuekit",
17
+ version=__version__,
18
+ help="Как правильно составить жалобу (bug/feature/handoff) + валидатор (--json по умолчанию).",
19
+ )
20
+
21
+
22
+ @app.command("kinds")
23
+ @command
24
+ def kinds_cmd() -> None:
25
+ """Виды жалоб + их обязательные секции."""
26
+ data = [
27
+ {
28
+ "kind": k,
29
+ "label": get_kind(k).label,
30
+ "required": [s.label for s in get_kind(k).required_sections()],
31
+ }
32
+ for k in list_kinds()
33
+ ]
34
+ emit_data(data, text_renderer=lambda rows: [
35
+ print(f"{r['kind']:<10} {r['label']} · обязательно: {', '.join(r['required'])}")
36
+ for r in rows
37
+ ])
38
+
39
+
40
+ @app.command("new")
41
+ @command
42
+ def new_cmd(
43
+ kind: str = typer.Option("bug", "--kind", "-k", help="bug | feature | handoff."),
44
+ title: str | None = typer.Option(None, "--title", help="Заголовок жалобы."),
45
+ ) -> None:
46
+ """Вывести пустой шаблон жалобы (markdown) — заполни и отправляй."""
47
+ try:
48
+ text = new_template(kind, title=title)
49
+ except ValueError as exc:
50
+ raise CliError("bad_kind", str(exc)) from exc
51
+ # шаблон — это текст для человека: печатаем как есть (stdout).
52
+ print(text, end="")
53
+
54
+
55
+ @app.command("lint")
56
+ @command
57
+ def lint_cmd(
58
+ file: str | None = typer.Argument(None, help="Файл жалобы (md); '-'/без аргумента — stdin."),
59
+ kind: str = typer.Option("bug", "--kind", "-k", help="bug | feature | handoff."),
60
+ ) -> None:
61
+ """Проверить жалобу: каких обязательных полей не хватает + балл."""
62
+ text = sys.stdin.read() if file in (None, "-") else Path(file).read_text(encoding="utf-8")
63
+ try:
64
+ res = _lint(text, kind)
65
+ except ValueError as exc:
66
+ raise CliError("bad_kind", str(exc)) from exc
67
+ data = {
68
+ "kind": res.kind, "ok": res.ok, "score": res.score,
69
+ "missing": res.missing, "present": res.present,
70
+ "empty_optional": res.empty_optional,
71
+ }
72
+
73
+ def _render(d: dict) -> None:
74
+ if d["ok"]:
75
+ print(f"✓ Полная жалоба ({d['kind']}, балл {d['score']}). Можно отправлять.")
76
+ else:
77
+ print(f"✗ Неполная жалоба ({d['kind']}, балл {d['score']}). Не хватает обязательного:")
78
+ for m in d["missing"]:
79
+ print(f" — {m}")
80
+
81
+ emit_data(data, text_renderer=_render)
82
+
83
+
84
+ def main() -> None:
85
+ app()
86
+
87
+
88
+ if __name__ == "__main__":
89
+ main()
@@ -0,0 +1,42 @@
1
+ {
2
+ "_schema": "issuekit.kinds/1 — виды жалоб как ДАННЫЕ: секции шаблона + какие обязательны. Валидатор (lint) проверяет наличие и непустоту required-секций; render строит пустой шаблон. Основано на чеклисте 'как зарепортить проблему, чтобы вас не возненавидели'.",
3
+ "kinds": {
4
+ "bug": {
5
+ "label": "Баг-репорт",
6
+ "title_hint": "<одно предложение: что сломалось>",
7
+ "sections": [
8
+ {"key": "summary", "label": "Что сломалось", "required": true, "hint": "одно предложение"},
9
+ {"key": "expected", "label": "Ожидал", "required": true, "hint": "X"},
10
+ {"key": "actual", "label": "Получил", "required": true, "hint": "Y"},
11
+ {"key": "versions", "label": "Версии", "required": true, "hint": "библиотека X.Y.Z · язык/рантайм · окружение/брокер · важные подзависимости"},
12
+ {"key": "repro", "label": "Минимальный пример", "required": true, "hint": "копипаст-runnable, 10-20 строк, БЕЗ домена/конфигов/лишних зависимостей"},
13
+ {"key": "traceback", "label": "Полный traceback", "required": true, "hint": "целиком, не скриншот и не последняя строчка"},
14
+ {"key": "steps", "label": "Шаги воспроизведения", "required": false, "hint": "1… 2… 3…"},
15
+ {"key": "tried", "label": "Что уже пробовал", "required": false, "hint": "чтобы не советовали отмёл­енное"}
16
+ ]
17
+ },
18
+ "feature": {
19
+ "label": "Фича-реквест",
20
+ "title_hint": "<задача, которую решаешь — НЕ решение>",
21
+ "sections": [
22
+ {"key": "usecase", "label": "Задача (юзкейс)", "required": true, "hint": "что и ЗАЧЕМ пытаюсь сделать (опиши задачу, а не решение)"},
23
+ {"key": "desired", "label": "Желаемый результат", "required": true, "hint": "что считаю успехом (НЕ конкретное API/решение)"},
24
+ {"key": "current", "label": "Сейчас", "required": false, "hint": "как обхожусь / почему не выходит"},
25
+ {"key": "context", "label": "Контекст", "required": false, "hint": "версии, ограничения"}
26
+ ]
27
+ },
28
+ "handoff": {
29
+ "label": "Передача задачи (агент → агент)",
30
+ "title_hint": "<что передаём и кому>",
31
+ "sections": [
32
+ {"key": "done", "label": "Что сделано", "required": true, "hint": "конкретно: какие файлы/изменения/коммиты"},
33
+ {"key": "remaining", "label": "Что осталось", "required": true, "hint": "чёткий список того, что доделать"},
34
+ {"key": "expected", "label": "Ожидаемый результат (ЦКП)", "required": true, "hint": "измеримый признак, что задача закрыта"},
35
+ {"key": "repro", "label": "Как проверить / воспроизвести", "required": true, "hint": "команды/шаги для запуска и проверки (копипаст)"},
36
+ {"key": "context", "label": "Контекст и версии", "required": true, "hint": "ветка/окружение/зависимости/где лежит код"},
37
+ {"key": "tried", "label": "Что уже пробовал / тупики", "required": false, "hint": "чтобы не повторять отмёл­енное"},
38
+ {"key": "blockers", "label": "Блокеры / риски", "required": false, "hint": "что мешает / на что обратить внимание"}
39
+ ]
40
+ }
41
+ }
42
+ }
@@ -0,0 +1,104 @@
1
+ """Валидатор жалобы: проверка наличия и непустоты обязательных секций.
2
+
3
+ «Богатая обратная связь» машинно: ``lint(text, kind)`` говорит, каких полей
4
+ чеклиста не хватает (versions/repro/traceback/expected/…), и считает балл. На
5
+ этом строится блокирующая дисциплина передачи задач в Atlas (нельзя передать
6
+ неполную жалобу — симметрично обязательному ЦКП у задачи).
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass, field
12
+
13
+ from .render import SECTION_PREFIX
14
+ from .spec import Section, get_kind
15
+
16
+ #: HTML-комментарии (подсказки шаблона) — не считаются содержимым.
17
+ _COMMENT_RE = re.compile(r"<!--.*?-->", re.DOTALL)
18
+ #: Хвост-маркер опциональности в заголовке.
19
+ _OPT_RE = re.compile(r"_\(опционально\)_", re.IGNORECASE)
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class LintResult:
24
+ """Итог проверки жалобы."""
25
+
26
+ kind: str
27
+ ok: bool # все обязательные секции заполнены
28
+ score: float # доля заполненных обязательных (0..1)
29
+ missing: list[str] = field(default_factory=list) # labels незаполненных required
30
+ present: list[str] = field(default_factory=list) # labels заполненных
31
+ empty_optional: list[str] = field(default_factory=list)
32
+
33
+
34
+ def _split_sections(text: str) -> dict[str, str]:
35
+ """Разбить текст по ``## <label>`` на {нормализованный_label: содержимое}."""
36
+ out: dict[str, str] = {}
37
+ cur_label: str | None = None
38
+ buf: list[str] = []
39
+
40
+ def _flush() -> None:
41
+ if cur_label is not None:
42
+ out[cur_label] = "\n".join(buf)
43
+
44
+ for line in text.splitlines():
45
+ if line.startswith(SECTION_PREFIX):
46
+ _flush()
47
+ header = line[len(SECTION_PREFIX):]
48
+ header = _OPT_RE.sub("", header).strip().rstrip("#").strip()
49
+ cur_label = header.lower()
50
+ buf = []
51
+ elif cur_label is not None:
52
+ buf.append(line)
53
+ _flush()
54
+ return out
55
+
56
+
57
+ def _is_filled(content: str) -> bool:
58
+ """Непусто ли содержимое секции (после вырезания подсказок-комментариев)."""
59
+ stripped = _COMMENT_RE.sub("", content)
60
+ # убрать пустые код-фенсы ``` ``` и whitespace
61
+ stripped = stripped.replace("```", "").strip()
62
+ return bool(stripped)
63
+
64
+
65
+ def lint(text: str, kind: str) -> LintResult:
66
+ """Проверить текст жалобы вида ``kind``: какие обязательные секции пусты."""
67
+ spec = get_kind(kind)
68
+ sections = _split_sections(text or "")
69
+
70
+ def _find(s: Section) -> str | None:
71
+ key = s.label.lower()
72
+ if key in sections:
73
+ return sections[key]
74
+ # мягкое совпадение: заголовок содержит label
75
+ for hk, hv in sections.items():
76
+ if key in hk:
77
+ return hv
78
+ return None
79
+
80
+ missing: list[str] = []
81
+ present: list[str] = []
82
+ empty_optional: list[str] = []
83
+ req_total = 0
84
+ req_filled = 0
85
+ for s in spec.sections:
86
+ content = _find(s)
87
+ filled = content is not None and _is_filled(content)
88
+ if s.required:
89
+ req_total += 1
90
+ if filled:
91
+ req_filled += 1
92
+ present.append(s.label)
93
+ else:
94
+ missing.append(s.label)
95
+ elif not filled:
96
+ empty_optional.append(s.label)
97
+ elif filled:
98
+ present.append(s.label)
99
+
100
+ score = (req_filled / req_total) if req_total else 1.0
101
+ return LintResult(
102
+ kind=kind, ok=not missing, score=round(score, 3),
103
+ missing=missing, present=present, empty_optional=empty_optional,
104
+ )
@@ -0,0 +1,27 @@
1
+ """Рендер пустого шаблона жалобы по виду (bug/feature/handoff)."""
2
+ from __future__ import annotations
3
+
4
+ from .spec import get_kind
5
+
6
+ #: Маркер секции — по нему же ``lint`` находит секции в тексте.
7
+ SECTION_PREFIX = "## "
8
+
9
+
10
+ def section_header(label: str) -> str:
11
+ """Заголовок секции (единый формат для render + lint)."""
12
+ return f"{SECTION_PREFIX}{label}"
13
+
14
+
15
+ def new_template(kind: str, *, title: str | None = None) -> str:
16
+ """Пустой markdown-шаблон жалобы вида ``kind`` с подсказками в секциях."""
17
+ spec = get_kind(kind)
18
+ lines: list[str] = [
19
+ f"# [{spec.label}] {title or spec.title_hint}",
20
+ "",
21
+ ]
22
+ for s in spec.sections:
23
+ req = "" if s.required else " _(опционально)_"
24
+ lines.append(section_header(s.label) + req)
25
+ lines.append(f"<!-- {s.hint} -->" if s.hint else "")
26
+ lines.append("")
27
+ return "\n".join(lines).rstrip() + "\n"
@@ -0,0 +1,64 @@
1
+ """Виды жалоб как ДАННЫЕ (загружаются из ``data/kinds.json``).
2
+
3
+ Добавить вид/секцию = правка JSON, без правки кода. ``kinds.json`` — единый
4
+ language-neutral источник правды (его же сможет прочитать будущий CLI на другом
5
+ языке).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import dataclass
11
+ from functools import lru_cache
12
+ from importlib import resources
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Section:
17
+ """Секция шаблона жалобы."""
18
+
19
+ key: str
20
+ label: str
21
+ required: bool
22
+ hint: str = ""
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class KindSpec:
27
+ """Вид жалобы: bug | feature | handoff — набор секций."""
28
+
29
+ key: str
30
+ label: str
31
+ title_hint: str
32
+ sections: tuple[Section, ...]
33
+
34
+ def required_sections(self) -> tuple[Section, ...]:
35
+ return tuple(s for s in self.sections if s.required)
36
+
37
+
38
+ @lru_cache(maxsize=1)
39
+ def _registry() -> dict[str, KindSpec]:
40
+ raw = resources.files("issuekit").joinpath("data", "kinds.json").read_text("utf-8")
41
+ data = json.loads(raw)
42
+ out: dict[str, KindSpec] = {}
43
+ for key, d in data["kinds"].items():
44
+ sections = tuple(
45
+ Section(s["key"], s["label"], bool(s.get("required", False)), s.get("hint", ""))
46
+ for s in d["sections"]
47
+ )
48
+ out[key] = KindSpec(key, d.get("label", key), d.get("title_hint", ""), sections)
49
+ return out
50
+
51
+
52
+ def list_kinds() -> list[str]:
53
+ """Доступные виды жалоб (ключи)."""
54
+ return list(_registry().keys())
55
+
56
+
57
+ def get_kind(kind: str) -> KindSpec:
58
+ """KindSpec по ключу. Неизвестный → ValueError со списком доступных."""
59
+ reg = _registry()
60
+ if kind not in reg:
61
+ raise ValueError(
62
+ f"Неизвестный вид '{kind}'. Доступно: {', '.join(reg)}."
63
+ )
64
+ return reg[kind]