diff --git a/.cursor/rules/conventional-commits.mdc b/.cursor/rules/conventional-commits.mdc new file mode 100644 index 0000000..23f6c3f --- /dev/null +++ b/.cursor/rules/conventional-commits.mdc @@ -0,0 +1,111 @@ +--- +description: >- + Conventional Commits EvoBGP. Триггеры commit/коммит/закоммить/staged/commit message — + ОБЯЗАТЕЛЬНО сначала Shell scripts/commit/staged-context.ps1, скилл commit-message. +alwaysApply: false +--- + +# Conventional Commits (EvoBGP) + +## Триггеры (применить правило + скилл) + +Любой запрос на коммит или сообщение коммита: `commit`, `коммит`, `закоммить`, `git commit`, `commit message`, `conventional commit`, `staged`, «сгенерируй коммит» — в т.ч. если это указано в плане или [AGENTS.md](../../AGENTS.md). + +## Обязательный запуск скрипта (MUST) + +1. Прочитать скилл [`.cursor/skills/commit-message/SKILL.md`](../skills/commit-message/SKILL.md). +2. **Первым действием** выполнить Shell (из корня репо): + +```powershell +powershell -NoProfile -File scripts/commit/staged-context.ps1 +``` + +3. Сообщение коммита строить **только** по JSON из stdout скрипта (`groups`, `diff_excerpt`, `stat`). +4. **Запрещено** генерировать commit message без успешного (exit 0) запуска скрипта; не заменять скрипт одним `git diff --cached`. + +## Формат (строго) + +``` +(): + +<тело на русском: что изменено и зачем> +``` + +- **Заголовок** — только английский; императив, без точки в конце; ≤72 символов. +- **Тело** — только русский; полные предложения; пустая строка после заголовка. +- Запрещены vague-сообщения: `fix bug`, `update code`, `wip`, `misc`. + +## Типы (semantic-release) + +| type | Когда | Версия | +|------|--------|--------| +| `feat` | новая функциональность | minor | +| `fix` | исправление бага | patch | +| `perf` | ускорение без смены API | patch | +| `refactor` | реструктуризация без смены поведения | — | +| `docs` | только документация | — | +| `test` | тесты | — | +| `ci` | CI/CD (`.gitea/`, workflows) | — | +| `chore` | обслуживание, deps, `.cursor/` | — | + +## Breaking changes + +- Заголовок: `feat!` / `fix!` **или** в теле строка `BREAKING CHANGE:` (на английском ключевое слово) + описание impact **на русском**. + +## Scope (EvoBGP) + +Выбирать по доминирующему пути из staged diff: + +| Префикс | scope | +|---------|-------| +| `internal/httpapi/` | `httpapi` | +| `internal/store/`, `internal/repository/`, `internal/db/` | `store` | +| `internal/jobs/` | `jobs` | +| `internal/pipeline/` | `pipeline` | +| `internal/birdfmt/` | `birdfmt` | +| `internal/birddeploy/` | `birddeploy` | +| `internal/bundle/`, `internal/signing/` | `bundle` | +| `cmd/` | `cmd` | +| `web/` | `web` | +| `docs/openapi.yaml`, `redocly.yaml` | `openapi` | +| `docs/` (остальное) | `docs` | +| `migrations/` | `db` | +| `.gitea/` | `ci` | +| `deploy/` | `deploy` | +| `.cursor/` | `chore` | +| прочее в корне | `chore` | + +`type` определять по **содержимому diff**, не только по пути. + +## Multi-change + +- Несвязанные области → **отдельные коммиты** (auto-split по скиллу). +- Одна логическая фича через несколько scope (OpenAPI + httpapi + web) → **один** коммит, scope по главной области. + +## Обязательный вывод агенту + +Для каждого коммита: + +**1. Готовое сообщение** (копировать в `git commit -m` / HEREDOC): + +``` +(): + +<тело RU> +``` + +**2. Пояснение (RU):** почему выбран type; риск/impact; был ли split. + +## Примеры + +``` +feat(httpapi): add version endpoint and wire web footer + +Добавлен GET /v1/version. В Web UI версия в footer берётся из API вместо захардкоженного значения. +``` + +``` +fix(auth): correct token validation edge case + +Исправлена ложная 401 при истёкшем refresh-токене с валидной сессией. +``` diff --git a/.cursor/skills/commit-message/SKILL.md b/.cursor/skills/commit-message/SKILL.md new file mode 100644 index 0000000..270239f --- /dev/null +++ b/.cursor/skills/commit-message/SKILL.md @@ -0,0 +1,124 @@ +--- +name: commit-message +description: >- + ОБЯЗАТЕЛЬНО при commit, коммит, закоммить, commit message, conventional commit, + staged, semantic-release, «сгенерируй коммит», git commit: ПЕРВЫМ делом Shell — + scripts/commit/staged-context.ps1; затем Conventional Commit (заголовок EN, тело RU). +--- + +# Commit message (EvoBGP) + +## Когда применять (сразу читать этот скилл) + +Триггеры в промпте пользователя или в плане/AGENTS.md: + +- `commit`, `коммит`, `закоммить`, `commit message`, `conventional commit` +- `git commit`, `staged`, `сообщение коммита`, `сгенерируй коммит` +- агент собирается выполнить `git commit` или предложить текст коммита + +## Шаг 0 — ОБЯЗАТЕЛЬНО (до любого текста коммита) + +**Первый вызов инструментов** в этой задаче — Shell из корня репозитория: + +```powershell +powershell -NoProfile -File scripts/commit/staged-context.ps1 +``` + +| Правило | Деталь | +|---------|--------| +| **MUST** | Запустить скрипт до генерации заголовка/тела коммита | +| **MUST NOT** | Строить сообщение только по `git diff --cached` / `git status` без скрипта | +| **MUST NOT** | Пропускать скрипт, даже если diff «и так понятен» | +| Exit `1` | Index пуст — сообщить пользователю, **не коммитить** | +| Exit `0` | Разобрать JSON stdout: `staged_count`, `groups[]` (`scope`, `files`, `stat`, `diff_excerpt`) | + +Дополнительно: [.cursor/rules/conventional-commits.mdc](../../rules/conventional-commits.mdc). + +## Слияние групп (одна фича) + +Скрипт группирует **только по путям**. Перед split проверьте логическую связность: + +**Объединить в один коммит**, если это одна задача: + +- `docs/openapi.yaml` + `internal/httpapi/` (+ опционально `web/`) — один endpoint/контракт; +- `migrations/` + `internal/store/` / `repository/` — одна схема; +- правки теста рядом с кодом той же фичи. + +При объединении: один scope (доминирующий пакет, часто `httpapi` или `openapi`), один type, одно тело RU. + +**Auto-split** — если группы **не связаны** (например `web/` + `internal/birdfmt/` без общего смысла): **отдельный коммит на группу**, без вопроса пользователю. + +## Порядок коммитов при split + +1. `openapi` +2. `httpapi`, `store`, `jobs` +3. `pipeline`, `birdfmt`, `birddeploy`, `bundle` +4. `web` +5. `ci`, `deploy`, `db` +6. `docs` +7. `chore`, `cmd` + +## Алгоритм auto-split (уровень файлов) + +1. `git status` и `git diff --cached --stat` — зафиксировать полный список staged-файлов. +2. `git reset HEAD` — снять всё из index (working tree не трогать). +3. Для каждой (объединённой) группы по порядку выше: + - `git add -- ` + - Сгенерировать сообщение по правилу conventional-commits. + - Закоммитить (см. ниже). +4. После всех коммитов — `git status` для проверки. + +**Ограничение:** частичный stage одного файла с разной семантикой — предупредить; split по hunk'ам не делать; предложить разнести правки по файлам. + +## Создание коммита + +Только если пользователь **явно** просил закоммитить. Иначе — только вывести готовые сообщения. + +```powershell +git commit -m "$( @' +(): + +<Тело на русском.> +'@ )" +``` + +В PowerShell для многострочного тела используйте here-string как выше или `-m` для заголовка и `-m` для тела. + +Перед коммитом: `git status`, `git diff --cached` для группы — убедиться, что stage соответствует сообщению. + +## Генерация текста + +По `groups[].diff_excerpt`, `stat`, `files`: + +- **type** — по смыслу diff (`feat` / `fix` / …), не по умолчанию `chore`. +- **scope** — из JSON группы или доминирующий при merge. +- **summary** — конкретный, английский, императив. +- **body** — русский: что, зачем, edge cases, breaking impact. + +## Вывод пользователю + +Для **каждого** коммита: + +### 1. Готовое сообщение + +``` +(): + +<тело> +``` + +### 2. Пояснение (RU) + +- Почему выбран type/scope. +- Риски и impact. +- Split: сколько коммитов и почему. + +## Не смешивать + +Один commit message — одна primary intent. Не объединять несвязанный `fix` и `feat` в один заголовок. + +## Ссылки + +- Правило: `.cursor/rules/conventional-commits.mdc` +- Скрипт: `scripts/commit/staged-context.ps1` +- Просмотр групп: `powershell -File scripts/commit/staged-context.ps1 | ConvertFrom-Json` diff --git a/AGENTS.md b/AGENTS.md index 8600194..53c6c70 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,15 @@ - **Повторное использование:** если [docs/architecture.md](docs/architecture.md) уже описывает поток — не пересказывайте его длинно; укажите документ и конкретный подпункт задачи. - **Длинные планы:** `.cursor/plans/*.plan.md` — для истории решений; для навигации пользователю достаточно `docs/`; не читайте план целиком без причины. +## Коммиты (Conventional Commits) + +Если пользователь просит **коммит**, **commit message**, **закоммить**, **git commit** или это следует из плана — **сразу**: + +1. Shell: `powershell -NoProfile -File scripts/commit/staged-context.ps1` (первый вызов, до текста коммита). +2. Скилл [.cursor/skills/commit-message/SKILL.md](.cursor/skills/commit-message/SKILL.md) и правило [.cursor/rules/conventional-commits.mdc](.cursor/rules/conventional-commits.mdc). + +Без вывода скрипта (exit 0) **не** придумывать сообщение коммита. Заголовок — EN, тело — RU; несвязанные области — auto-split (скилл). + ## Команды и среда - Консоль пользователя: **PowerShell**; пути в стиле `deploy\compose`. diff --git a/docs/README.md b/docs/README.md index 1550950..1606ec0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -31,6 +31,11 @@ | [../.cursor/rules/engineering.mdc](../.cursor/rules/engineering.mdc) | Go, API, migrations, security, enforcement | | [../.cursor/rules/web-shadcn.mdc](../.cursor/rules/web-shadcn.mdc) | SvelteKit, shadcn-svelte | | [../.cursor/rules/networking-bird.mdc](../.cursor/rules/networking-bird.mdc) | BIRD2, BGP policy, IP/CIDR | +| [../.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc) | Conventional Commits (заголовок EN, тело RU) | + +### Сообщения коммитов (Cursor) + +После `git add` попросите агента: **«сгенерируй коммит по staged»**, **«закоммить»**, **«commit message»** — агент **обязан первым делом** запустить `scripts/commit/staged-context.ps1`, затем скилл [commit-message](../.cursor/skills/commit-message/SKILL.md) (заголовок EN, тело RU, auto-split). Просмотр групп вручную: `powershell -NoProfile -File scripts/commit/staged-context.ps1 | ConvertFrom-Json`. ## Репозиторий и CI diff --git a/scripts/commit/staged-context.ps1 b/scripts/commit/staged-context.ps1 new file mode 100644 index 0000000..a16930d --- /dev/null +++ b/scripts/commit/staged-context.ps1 @@ -0,0 +1,181 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Staged git changes grouped by EvoBGP scope for Conventional Commit generation. + +.DESCRIPTION + Outputs JSON to stdout: file groups, --stat, and truncated patch per group. + Exit 1 if nothing is staged. Does not write commit messages. + +.EXAMPLE + powershell -NoProfile -File scripts/commit/staged-context.ps1 + powershell -NoProfile -File scripts/commit/staged-context.ps1 | ConvertFrom-Json +#> +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$MaxLinesPerGroup = 120 +$MaxLinesTotal = 500 + +function Test-GitRepository { + $null = git rev-parse --git-dir 2>$null + if ($LASTEXITCODE -ne 0) { + Write-Error 'staged-context: not a git repository' + exit 2 + } +} + +function Get-ScopeForPath { + param([string]$Path) + $p = $Path -replace '\\', '/' + + # Longest / most specific prefixes first + $rules = @( + @{ Prefix = 'docs/openapi.yaml'; Scope = 'openapi' } + @{ Prefix = 'redocly.yaml'; Scope = 'openapi' } + @{ Prefix = 'internal/httpapi/'; Scope = 'httpapi' } + @{ Prefix = 'internal/store/'; Scope = 'store' } + @{ Prefix = 'internal/repository/'; Scope = 'store' } + @{ Prefix = 'internal/db/'; Scope = 'store' } + @{ Prefix = 'internal/jobs/'; Scope = 'jobs' } + @{ Prefix = 'internal/pipeline/'; Scope = 'pipeline' } + @{ Prefix = 'internal/birdfmt/'; Scope = 'birdfmt' } + @{ Prefix = 'internal/birddeploy/'; Scope = 'birddeploy' } + @{ Prefix = 'internal/bundle/'; Scope = 'bundle' } + @{ Prefix = 'internal/signing/'; Scope = 'bundle' } + @{ Prefix = 'cmd/'; Scope = 'cmd' } + @{ Prefix = 'web/'; Scope = 'web' } + @{ Prefix = 'migrations/'; Scope = 'db' } + @{ Prefix = 'docs/'; Scope = 'docs' } + @{ Prefix = '.gitea/'; Scope = 'ci' } + @{ Prefix = 'deploy/'; Scope = 'deploy' } + @{ Prefix = '.cursor/'; Scope = 'chore' } + ) + + foreach ($rule in $rules) { + if ($p -eq $rule.Prefix.TrimEnd('/') -or $p.StartsWith($rule.Prefix)) { + return $rule.Scope + } + } + return 'chore' +} + +function Get-ScopeSortOrder { + param([string]$Scope) + $order = @{ + openapi = 10 + httpapi = 20 + store = 21 + jobs = 22 + pipeline = 30 + birdfmt = 31 + birddeploy = 32 + bundle = 33 + web = 40 + ci = 50 + deploy = 51 + db = 52 + docs = 60 + cmd = 70 + chore = 80 + } + if ($order.ContainsKey($Scope)) { return $order[$Scope] } + return 99 +} + +function Invoke-Git { + param([string[]]$GitArgs) + $out = & git @GitArgs 2>&1 + if ($LASTEXITCODE -ne 0) { + $msg = ($out | Out-String).Trim() + throw "git $($GitArgs -join ' ') failed: $msg" + } + return ($out | Out-String).TrimEnd() +} + +function New-GitArgs { + param([string[]]$Base, [string[]]$Paths) + if ($Paths.Count -eq 0) { return $Base } + return $Base + '--' + $Paths +} + +function Get-TruncatedDiff { + param( + [string[]]$Files, + [int]$MaxLines, + [ref]$TotalLinesUsed + ) + if ($Files.Count -eq 0) { return '' } + + $remaining = $MaxLinesTotal - $TotalLinesUsed.Value + if ($remaining -le 0) { + return '[diff truncated: global line budget exceeded]' + } + + $cap = [Math]::Min($MaxLines, $remaining) + $diff = Invoke-Git -GitArgs (New-GitArgs -Base @( + 'diff', '--cached', '--no-color', '--unified=3' + ) -Paths $Files) + if ([string]::IsNullOrWhiteSpace($diff)) { return '' } + + $lines = $diff -split "`n", -1 + if ($lines.Count -le $cap) { + $TotalLinesUsed.Value += $lines.Count + return $diff + } + + $truncated = ($lines[0..($cap - 1)] -join "`n") + "`n... [truncated: $($lines.Count - $cap) more lines]" + $TotalLinesUsed.Value += $cap + return $truncated +} + +Test-GitRepository + +$stagedRaw = Invoke-Git -GitArgs @('diff', '--cached', '--name-only') +if ([string]::IsNullOrWhiteSpace($stagedRaw)) { + [Console]::Error.WriteLine('staged-context: no staged changes (git index is empty)') + exit 1 +} + +$stagedFiles = @( + $stagedRaw -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' } +) +$stagedFiles = $stagedFiles | Sort-Object -Unique + +$scopeBuckets = @{} +foreach ($f in $stagedFiles) { + $scope = Get-ScopeForPath -Path $f + if (-not $scopeBuckets.ContainsKey($scope)) { + $scopeBuckets[$scope] = [System.Collections.Generic.List[string]]::new() + } + $scopeBuckets[$scope].Add($f) | Out-Null +} + +$totalLinesUsed = 0 +$groups = New-Object System.Collections.Generic.List[object] + +$scopeKeys = @($scopeBuckets.Keys | Sort-Object { Get-ScopeSortOrder $_ }) + +foreach ($scope in $scopeKeys) { + $files = @($scopeBuckets[$scope] | Sort-Object) + $stat = Invoke-Git -GitArgs (New-GitArgs -Base @('diff', '--cached', '--stat') -Paths $files) + $diffExcerpt = Get-TruncatedDiff -Files $files -MaxLines $MaxLinesPerGroup -TotalLinesUsed ([ref]$totalLinesUsed) + + $groups.Add([ordered]@{ + scope = $scope + files = $files + stat = $stat + diff_excerpt = $diffExcerpt + }) | Out-Null +} + +$result = [ordered]@{ + staged_count = $stagedFiles.Count + groups = $groups.ToArray() +} + +$json = $result | ConvertTo-Json -Depth 6 -Compress:$false +# UTF-8 stdout for agents / ConvertFrom-Json +[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false) +Write-Output $json +exit 0