
Release Notes
- 48 installs
- 7 repo stars
- Updated June 18, 2026
- duc01226/easyplatform
Generates release notes from git history.
About
Builds release notes by summarizing git commit history. A developer runs it when preparing a release to produce a changelog.
- Reads git history
- Produces release notes
Release Notes by the numbers
- 48 all-time installs (skills.sh)
- Ranked #139 of 248 Release Management skills by installs in the Skillselion catalog
- Data as of Jul 29, 2026 (Skillselion catalog sync)
npx skills add https://github.com/duc01226/easyplatform --skill release-notesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 48 |
|---|---|
| repo stars | ★ 7 |
| Last updated | June 18, 2026 |
| Repository | duc01226/easyplatform ↗ |
What it does
Generates release notes from git history.
Files
Codex compatibility note:
>
- Invoke repository skills with$skill-namein Codex; this mirrored copy rewrites legacy Claude/skill-namereferences.
- Task tracker mandate: BEFORE executing any workflow or skill step, create/update task tracking for all steps and keep it synchronized as progress changes.
- User-question prompts mean to ask the user directly in Codex.
- Ignore Claude-specific mode-switch instructions when they appear.
- Strict execution contract: when a user explicitly invokes a skill, execute that skill protocol as written.
- Subagent authorization: when a skill is user-invoked or AI-detected and its protocol requires subagents, that skill activation authorizes use of the required spawn_agent subagent(s) for that task.- Do not skip, reorder, or merge protocol steps unless the user explicitly approves the deviation first.
- For workflow skills, execute each listed child-skill step explicitly and report step-by-step evidence.
- If a required step/tool cannot run in this environment, stop and ask the user before adapting.
<!-- CODEX:PROJECT-REFERENCE-LOADING:START -->
Codex Project-Reference Loading (No Hooks)
Codex does not receive Claude hook-based doc injection. When coding, planning, debugging, testing, or reviewing, open project docs explicitly using this routing.
Always read:
docs/project-config.json(project-specific paths, commands, modules, and workflow/test settings)docs/project-reference/docs-index-reference.md(routes to the fulldocs/project-reference/*catalog)docs/project-reference/lessons.md(always-on guardrails and anti-patterns)
Situation-based docs:
- Backend/CQRS/API/domain/entity changes:
backend-patterns-reference.md,domain-entities-reference.md,project-structure-reference.md - Frontend/UI/styling/design-system:
frontend-patterns-reference.md,scss-styling-guide.md,design-system/README.md - Spec/test-case planning or TC mapping:
feature-docs-reference.md - Integration test implementation/review:
integration-test-reference.md - E2E test implementation/review:
e2e-test-reference.md - Code review/audit work:
code-review-rules.mdplus domain docs above based on changed files
Do not read all docs blindly. Start from docs-index-reference.md, then open only relevant files for the task.
<!-- CODEX:PROJECT-REFERENCE-LOADING:END -->
Quick Summary
Goal: Generate professional release notes from git commits with automated categorization, service detection, and validation.
Workflow:
1. Parse Commits — parse-commits.cjs <base> <head> extracts structured data from git 2. Categorize — Pipe through categorize-commits.cjs for user-facing vs internal sections 3. Render — render-template.cjs --version vX.Y.Z generates markdown with Summary, What's New, Improvements, Bug Fixes, Breaking Changes, Technical Details
Key Rules:
- Pipeline: parse → categorize → render → validate → transform
- Advanced: Service detection, breaking change analysis, PR metadata, contributor stats, version bumping
- Human Review: Generated notes are Draft status, require review/enhance/approve before publish
- Validation:
validate-notes.cjsscores against quality rules (100 points)
Be skeptical. Apply critical thinking, sequential thinking. Every claim needs traced proof, confidence percentages (Idea should be more than 80%).
Release Notes Generation Skill
Generate professional release notes from git commits between two refs with automated categorization.
Invocation
$release-notes [base] [head] [--version vX.Y.Z] [--output path]Examples:
# Generate release notes for commits since last tag
$release-notes v1.0.0 HEAD --version v1.1.0
# Compare branches
$release-notes main feature/new-auth --version v2.0.0-beta
# Output to specific file
$release-notes v1.0.0 HEAD --version v1.1.0 --output docs/release-notes/250111-v1.1.0.mdWorkflow
Step 1: Parse Commits
Execute the commit parser to extract structured data from git history:
node .claude/skills/release-notes/lib/parse-commits.cjs <base> <head> [--with-files]Output: JSON with commits array containing:
hash,shortHash- Commit identifierstype,scope,description- Conventional commit partsbreaking- Boolean for breaking changesauthor,date- Attributionfiles- Changed files (with--with-filesflag)
Step 2: Categorize Commits
Pipe parsed commits through the categorizer:
node .claude/skills/release-notes/lib/parse-commits.cjs <base> <head> | \
node .claude/skills/release-notes/lib/categorize-commits.cjsCategorization Rules:
| Type | Category | User-Facing |
|---|---|---|
feat | features | Yes |
fix | fixes | Yes |
perf | improvements | Yes |
docs | docs | Yes (unless internal) |
refactor | improvements | Technical only |
test, ci, build, chore, style | internal | No |
Excluded Patterns:
chore(deps):- Dependency updateschore(config):- Configuration changes[skip changelog]- Explicit skip[ci skip]- CI markers
Step 3: Render Markdown
Generate the final release notes document:
node .claude/skills/release-notes/lib/parse-commits.cjs <base> <head> | \
node .claude/skills/release-notes/lib/categorize-commits.cjs | \
node .claude/skills/release-notes/lib/render-template.cjs --version v1.1.0 --output docs/release-notes/250111-v1.1.0.mdComplete Pipeline
For generating release notes in a single command:
# Full pipeline with output to file
node .claude/skills/release-notes/lib/parse-commits.cjs v1.0.0 HEAD | \
node .claude/skills/release-notes/lib/categorize-commits.cjs | \
node .claude/skills/release-notes/lib/render-template.cjs --version v1.1.0 --output docs/release-notes/250111-v1.1.0.md
# Pipeline to stdout for review
node .claude/skills/release-notes/lib/parse-commits.cjs v1.0.0 HEAD | \
node .claude/skills/release-notes/lib/categorize-commits.cjs | \
node .claude/skills/release-notes/lib/render-template.cjs --version v1.1.0Advanced Features
Service Boundary Detection
Analyze which services are affected by the release:
# Parse with file changes, then detect services
node .claude/skills/release-notes/lib/parse-commits.cjs v1.0.0 HEAD --with-files | \
node .claude/skills/release-notes/lib/detect-services.cjsOutput: Service impact analysis with severity levels (critical, high, medium, low)
Breaking Change Analysis
Enhanced breaking change detection with migration info extraction:
node .claude/skills/release-notes/lib/parse-commits.cjs v1.0.0 HEAD | \
node .claude/skills/release-notes/lib/categorize-commits.cjs | \
node .claude/skills/release-notes/lib/detect-breaking.cjsDetects:
BREAKING CHANGE:in commit body!suffix on commit type (e.g.,feat!:)- Migration instructions
PR Metadata Extraction
Extract and link pull request information:
# Extract PR numbers from commit messages
node .claude/skills/release-notes/lib/parse-commits.cjs v1.0.0 HEAD | \
node .claude/skills/release-notes/lib/extract-pr-metadata.cjs
# With GitHub API enrichment (requires gh CLI)
node .claude/skills/release-notes/lib/parse-commits.cjs v1.0.0 HEAD | \
node .claude/skills/release-notes/lib/extract-pr-metadata.cjs --fetch-ghExtracts: PR numbers, titles, labels, authors from commits
Contributor Statistics
Generate detailed contributor stats:
node .claude/skills/release-notes/lib/parse-commits.cjs v1.0.0 HEAD | \
node .claude/skills/release-notes/lib/contributor-stats.cjsOutput: Contributor list with commit counts, feature/fix breakdown
Version Bumping
Automatically determine and bump semantic version based on commit types:
# Auto-bump based on commits (feat→minor, fix→patch, BREAKING→major)
node .claude/skills/release-notes/lib/parse-commits.cjs v1.0.0 HEAD | \
node .claude/skills/release-notes/lib/bump-version.cjs
# Bump with prerelease tag
node .claude/skills/release-notes/lib/bump-version.cjs --prerelease beta
# Per-service versioning
node .claude/skills/release-notes/lib/bump-version.cjs --service {service-name}
# Dry run (don't write version file)
node .claude/skills/release-notes/lib/bump-version.cjs --dry-runVersion Files:
- Root:
.version - Per-service:
.versions/<service-name>.version
Quality Validation
Validate release notes against quality rules:
# Validate with default threshold (70)
node .claude/skills/release-notes/lib/validate-notes.cjs docs/release-notes/v1.1.0.md
# Custom threshold
node .claude/skills/release-notes/lib/validate-notes.cjs docs/release-notes/v1.1.0.md --threshold 80
# JSON output for CI
node .claude/skills/release-notes/lib/validate-notes.cjs docs/release-notes/v1.1.0.md --jsonValidation Rules (100 points total):
| Rule | Weight | Description |
|---|---|---|
| summary_exists | 15 | Has Summary section |
| summary_not_empty | 10 | Summary has content |
| has_version | 10 | Version number present |
| features_documented | 10 | Features properly formatted |
| fixes_documented | 10 | Bug fixes properly formatted |
| no_broken_links | 10 | No empty link references |
| contributors_listed | 10 | Contributors section present |
| has_date | 5 | Date present |
| no_todo_markers | 5 | No TODO/FIXME markers |
| proper_heading_hierarchy | 5 | Proper H1→H2 structure |
| no_placeholder_text | 5 | No placeholder text |
| technical_details_collapsed | 5 | Tech details in <details> |
LLM-Powered Transforms
Transform release notes for different audiences using Claude API:
# Requires ANTHROPIC_API_KEY environment variable
export ANTHROPIC_API_KEY="your-api-key"
# Create executive summary
node .claude/skills/release-notes/lib/transform-llm.cjs docs/release-notes/v1.1.0.md --transform executive
# Transform for business stakeholders
node .claude/skills/release-notes/lib/transform-llm.cjs docs/release-notes/v1.1.0.md --transform business --output docs/release-notes/v1.1.0-business.md
# Transform for end users
node .claude/skills/release-notes/lib/transform-llm.cjs docs/release-notes/v1.1.0.md --transform enduserTransform Types:
| Type | Description |
|---|---|
summarize | Brief 3-5 bullet point summary |
business | ROI-focused, business language |
enduser | User-friendly, non-technical |
executive | Strategic impact summary |
technical | Enhanced technical details |
Full Enhanced Pipeline
Combine all features for comprehensive release notes:
# Enhanced pipeline with service detection
node .claude/skills/release-notes/lib/parse-commits.cjs v1.0.0 HEAD --with-files | \
node .claude/skills/release-notes/lib/detect-services.cjs | \
node .claude/skills/release-notes/lib/categorize-commits.cjs | \
node .claude/skills/release-notes/lib/detect-breaking.cjs | \
node .claude/skills/release-notes/lib/contributor-stats.cjs | \
node .claude/skills/release-notes/lib/render-template.cjs --version v1.1.0
# With version bumping and validation
node .claude/skills/release-notes/lib/parse-commits.cjs v1.0.0 HEAD --with-files | \
node .claude/skills/release-notes/lib/bump-version.cjs | \
node .claude/skills/release-notes/lib/categorize-commits.cjs | \
node .claude/skills/release-notes/lib/render-template.cjs --output docs/release-notes/v1.1.0.md && \
node .claude/skills/release-notes/lib/validate-notes.cjs docs/release-notes/v1.1.0.mdConfiguration
See config.yaml for:
- categories - Commit type to section mapping
- services - Service boundary detection by file patterns
- exclude - Patterns to exclude from user-facing notes
- output - Directory and filename format settings
Output Structure
# Release Notes: v1.1.0
**Date:** 2025-01-11
**Version:** v1.1.0
**Status:** Draft
---
## Summary
This release includes 3 new features, 2 improvements, 5 bug fixes.
## What's New
- **Add employee export endpoint** (API)
- **Implement dark mode toggle** (UI)
## Improvements
- **Optimize database queries** (Persistence)
## Bug Fixes
- **Fix date picker timezone issue** (Frontend)
- **Resolve null pointer in auth flow**
## Documentation
- **Update API documentation** (API)
## Breaking Changes
> **Warning**: The following changes may require migration
### Migrate to OAuth 2.1 (Auth)
Legacy JWT tokens no longer accepted.
Migration guide: docs/migrations/oauth-2.1.md
---
## Technical Details
<details>
<summary>For Developers</summary>
### Commits Included
| Hash | Type | Description |
| ------- | ---- | ------------------------------ |
| abc1234 | feat | Add employee export endpoint |
| def5678 | fix | Fix date picker timezone issue |
...
</details>
## Contributors
- @john.doe
- @jane.smith
---
_Generated by AI_Human Review Gate
Generated release notes are Draft status by default:
1. Review - Check accuracy, add context where needed 2. Enhance - Add migration steps, links, screenshots 3. Approve - Change status to "Released" 4. Publish - Commit and push
Integration with Other Skills
- `$commit` - After generating notes, commit them
- `/git-manager` - Create PR for release notes review
- `$docs-update` - Update CHANGELOG.md with new release
Troubleshooting
No commits found
Verify the refs exist and have commits between them:
git log --oneline <base>..<head>Non-conventional commits
Commits not following type(scope): description format go to "other" category. Consider running commitlint enforcement.
Missing scope context
Add scope mappings to config.yaml → services section for better context labels.
---
[IMPORTANT] Use task tracking to break ALL work into small tasks BEFORE starting — including tasks for each file read. This prevents context loss from long files. For simple tasks, AI MUST ATTENTION ask user whether to skip.
<!-- SYNC:ai-mistake-prevention -->
AI Mistake Prevention — Failure modes to avoid on every task: Check downstream references before deleting. Deleting components causes documentation and code staleness cascades. Map all referencing files before removal. Verify AI-generated content against actual code. AI hallucinates APIs, class names, and method signatures. Always grep to confirm existence before documenting or referencing. Trace full dependency chain after edits. Changing a definition misses downstream variables and consumers derived from it. Always trace the full chain. Trace ALL code paths when verifying correctness. Confirming code exists is not confirming it executes. Always trace early exits, error branches, and conditional skips — not just happy path. When debugging, ask "whose responsibility?" before fixing. Trace whether bug is in caller (wrong data) or callee (wrong handling). Fix at responsible layer — never patch symptom site. Assume existing values are intentional — ask WHY before changing. Before changing any constant, limit, flag, or pattern: read comments, check git blame, examine surrounding code. Verify ALL affected outputs, not just the first. Changes touching multiple stacks require verifying EVERY output. One green check is not all green checks. Holistic-first debugging — resist nearest-attention trap. When investigating any failure, list EVERY precondition first (config, env vars, DB names, endpoints, DI registrations, data preconditions), then verify each against evidence before forming any code-layer hypothesis. Surgical changes — apply the diff test. Bug fix: every changed line must trace directly to the bug. Don't restyle or improve adjacent code. Enhancement task: implement improvements AND announce them explicitly. Surface ambiguity before coding — don't pick silently. If request has multiple interpretations, present each with effort estimate and ask. Never assume all-records, file-based, or more complex path.
<!-- /SYNC:ai-mistake-prevention -->
<!-- SYNC:critical-thinking-mindset -->
Critical Thinking Mindset — Apply critical thinking, sequential thinking. Every claim needs traced proof, confidence >80% to act.
Anti-hallucination: Never present guess as fact — cite sources for every claim, admit uncertainty freely, self-check output for errors, cross-reference independently, stay skeptical of own confidence — certainty without evidence root of all hallucination.
<!-- /SYNC:critical-thinking-mindset -->
<!-- SYNC:critical-thinking-mindset:reminder -->
MUST ATTENTION apply critical thinking — every claim needs traced proof, confidence >80% to act. Anti-hallucination: never present guess as fact.
<!-- /SYNC:critical-thinking-mindset:reminder -->
<!-- SYNC:ai-mistake-prevention:reminder -->
MUST ATTENTION apply AI mistake prevention — holistic-first debugging, fix at responsible layer, surface ambiguity before coding, re-read files after compaction.
<!-- /SYNC:ai-mistake-prevention:reminder -->
Closing Reminders
IMPORTANT MUST ATTENTION break work into small todo tasks using task tracking BEFORE starting IMPORTANT MUST ATTENTION search codebase for 3+ similar patterns before creating new code IMPORTANT MUST ATTENTION cite file:line evidence for every claim (confidence >80% to act) IMPORTANT MUST ATTENTION add a final review todo task to verify work quality
[TASK-PLANNING] Before acting, analyze task scope and systematically break it into small todo tasks and sub-tasks using task tracking.
<!-- CODEX:SYNC-PROMPT-PROTOCOLS:START -->
Hookless Prompt Protocol Mirror (Auto-Synced)
Source: .claude/hooks/lib/prompt-injections.cjs + .claude/.ck.json
[WORKFLOW-EXECUTION-PROTOCOL] [BLOCKING] Workflow Execution Protocol — MANDATORY IMPORTANT MUST CRITICAL. Do not skip for any reason.
Generic portability boundary: Reusable skills and protocol text stay project-neutral; project-specific conventions are discovered from docs/project-config.json and docs/project-reference/. Apply shared AI-SDD from shared/sdd-artifact-contract.md. Read docs/project-config.json and docs/project-reference/docs-index-reference.md, then open the project reference docs named there. Any supported AI tool may execute when this shared context and local docs are available.
1. DETECT: Match prompt against workflow catalog 2. ANALYZE: Find best-match workflow AND evaluate if a custom step combination would fit better 3. ASK (REQUIRED FORMAT): Use a direct user question with this structure unless the user explicitly invoked a workflow/skill and the local protocol treats explicit invocation as confirmation:
- Question: "Which workflow do you want to activate?"
- Option 1: "Activate [BestMatch Workflow] (Recommended)"
- Option 2: "Activate custom workflow: [step1 → step2 → ...]" (include one-line rationale)
4. ACTIVATE (if confirmed): Call $workflow-start <workflowId> for standard; sequence custom steps manually 5. CREATE TASKS: task tracking for ALL workflow steps 6. EXECUTE: Follow each step in sequence [CRITICAL-THINKING-MINDSET] Apply critical thinking, sequential thinking. Every claim needs traced proof, confidence >80% to act. Anti-hallucination principle: Never present guess as fact — cite sources for every claim, admit uncertainty freely, self-check output for errors, cross-reference independently, stay skeptical of own confidence — certainty without evidence root of all hallucination. AI Attention principle (Primacy-Recency): Put the 3 most critical rules at both top and bottom of long prompts/protocols so instruction adherence survives long context windows. Goal-driven execution: Define success criteria first, loop until verified, and stop only when observable checks pass. Tests verify intent: Tests must protect business rules/invariants and fail when the protected intent breaks, not only mirror current behavior.
[LESSON-LEARNED-REMINDER] [BLOCKING] Task Planning & Continuous Improvement — MANDATORY. Do not skip.
Break work into small tasks (task tracking) before starting. Add final task: "Analyze AI mistakes & lessons learned".
Extract lessons — ROOT CAUSE ONLY, not symptom fixes:
1. Name the FAILURE MODE (reasoning/assumption failure), not symptom — "assumed API existed without reading source" not "used wrong enum value". 2. Generality test: does this failure mode apply to ≥3 contexts/codebases? If not, abstract one level up. 3. Write as a universal rule — strip project-specific names/paths/classes. Useful on any codebase. 4. Consolidate: multiple mistakes sharing one failure mode → ONE lesson. 5. Recurrence gate: "Would this recur in future session WITHOUT this reminder?" — No → skip $learn. 6. Auto-fix gate: "Could $code-review/$code-simplifier/$security/$lint catch this?" — Yes → improve review skill instead. 7. BOTH gates pass → ask user to run $learn. [TASK-PLANNING] [MANDATORY] BEFORE executing any workflow or skill step, create/update task tracking for all planned steps, then keep it synchronized as each step starts/completes.
<!-- CODEX:SYNC-PROMPT-PROTOCOLS:END -->
# Release Notes Configuration
# Defines how commits are categorized and formatted for release notes
version: '1.0'
# Commit type mappings for release notes sections
categories:
features:
types: [feat]
heading: "What's New"
description: 'New features and capabilities'
user_facing: true
icon: '✨'
fixes:
types: [fix]
heading: 'Bug Fixes'
description: 'Bug fixes and corrections'
user_facing: true
icon: '🐛'
improvements:
types: [perf, refactor]
heading: 'Improvements'
description: 'Performance and code improvements'
user_facing: true # perf is user-facing, refactor goes to technical
icon: '⚡'
docs:
types: [docs]
heading: 'Documentation'
description: 'Documentation updates'
user_facing: true # Only if scope is not 'internal'
icon: '📚'
internal:
types: [test, ci, build, chore, style]
heading: 'Internal Changes'
description: 'Internal maintenance and CI/CD'
user_facing: false
icon: '🔧'
# Breaking change detection
breaking:
indicators:
- pattern: '^.*!:'
description: 'Type with ! suffix'
- pattern: 'BREAKING CHANGE:'
location: 'body'
description: 'BREAKING CHANGE in commit body'
- pattern: 'BREAKING-CHANGE:'
location: 'footer'
description: 'BREAKING-CHANGE footer'
# Service boundary detection based on file paths
services:
backend-api:
patterns:
- 'src/Backend/**/*.Api/**'
label: 'Backend API'
impact: 'high'
backend-domain:
patterns:
- 'src/Backend/**/*.Domain/**'
label: 'Domain Model'
impact: 'high'
backend-application:
patterns:
- 'src/Backend/**/*.Application/**'
label: 'Application Layer'
impact: 'medium'
backend-persistence:
patterns:
- 'src/Backend/**/*.Persistence*/**'
label: 'Persistence'
impact: 'medium'
platform-core:
patterns:
- 'src/Platform/**'
label: 'Platform Core'
impact: 'critical'
frontend-apps:
patterns:
- 'src/Frontend/apps/**'
label: 'Frontend Apps'
impact: 'high'
frontend-libs:
patterns:
- 'src/Frontend/libs/**'
label: 'Frontend Libraries'
impact: 'medium'
ai-tools:
patterns:
- '.claude/**'
- '.github/prompts/**'
label: 'AI Tooling'
impact: 'low'
config:
patterns:
- '.github/workflows/**'
- '*.json'
- '*.yml'
- '*.yaml'
label: 'Configuration'
impact: 'low'
docs:
patterns:
- 'docs/**'
- '*.md'
label: 'Documentation'
impact: 'low'
# Commits to exclude from user-facing notes
exclude:
types:
- ci
- test
- style
scopes:
- deps
- internal
patterns:
- "^chore\\(deps\\):"
- "^chore\\(config\\):"
- "\\[skip changelog\\]"
- "\\[ci skip\\]"
# Output configuration
output:
directory: 'docs/release-notes'
filename_format: 'YYMMDD-{version}.md'
changelog_root: 'CHANGELOG.md'
per_service_changelog: true
# Template sections
template:
sections:
- name: 'summary'
required: true
- name: 'features'
conditional: 'has_features'
- name: 'improvements'
conditional: 'has_improvements'
- name: 'fixes'
conditional: 'has_fixes'
- name: 'breaking'
conditional: 'has_breaking'
- name: 'technical'
collapsible: true
- name: 'contributors'
required: true
# Version detection
versioning:
strategy: 'per-service' # or "unified"
tag_pattern: 'v*.*.*'
prerelease_patterns:
- 'alpha'
- 'beta'
- 'rc'
version_file: '.version' # Root version file
per_service_dir: '.versions' # Per-service version directory
bump_on_release: true
# Validation settings
validation:
enabled: true
min_score: 70 # Default threshold (configurable via --threshold)
rules:
summary_exists: { weight: 15 }
summary_not_empty: { weight: 10 }
has_version: { weight: 10 }
has_date: { weight: 5 }
features_documented: { weight: 10 }
fixes_documented: { weight: 10 }
no_broken_links: { weight: 10 }
no_todo_markers: { weight: 5 }
contributors_listed: { weight: 10 }
proper_heading_hierarchy: { weight: 5 }
no_placeholder_text: { weight: 5 }
technical_details_collapsed: { weight: 5 }
# LLM transform settings
llm_transforms:
enabled: true
model: 'sonnet'
cache_ttl_hours: 24
transform_types:
- summarize
- business
- enduser
- executive
- technical
#!/usr/bin/env node
/**
* Bump semantic version based on commit types
* Usage: node bump-version.cjs [--service name] [--prerelease tag] [--dry-run]
*
* Reads commits from stdin or analyzes git history to determine version bump
* Supports both per-service and root-level versioning
*/
const fs = require('fs');
const path = require('path');
const { execSync } = require('child_process');
const DEFAULT_VERSION = '0.0.0';
const VERSION_FILE = '.version';
/**
* Parse semantic version string
*/
function parseVersion(versionStr) {
const match = versionStr.replace(/^v/, '').match(/^(\d+)\.(\d+)\.(\d+)(?:-([a-zA-Z]+)\.?(\d+)?)?$/);
if (!match) {
return { major: 0, minor: 0, patch: 0, prerelease: null, prereleaseNum: 0 };
}
return {
major: parseInt(match[1], 10),
minor: parseInt(match[2], 10),
patch: parseInt(match[3], 10),
prerelease: match[4] || null,
prereleaseNum: parseInt(match[5], 10) || 0,
};
}
/**
* Format version object to string
*/
function formatVersion(v, includeV = true) {
let version = `${v.major}.${v.minor}.${v.patch}`;
if (v.prerelease) {
version += `-${v.prerelease}`;
if (v.prereleaseNum > 0) {
version += `.${v.prereleaseNum}`;
}
}
return includeV ? `v${version}` : version;
}
/**
* Determine bump type from commits
*/
function determineBumpType(commits) {
let hasBreaking = false;
let hasFeature = false;
let hasFix = false;
for (const commit of commits) {
if (commit.breaking) {
hasBreaking = true;
}
if (commit.type === 'feat') {
hasFeature = true;
}
if (commit.type === 'fix' || commit.type === 'perf') {
hasFix = true;
}
}
if (hasBreaking) return 'major';
if (hasFeature) return 'minor';
if (hasFix) return 'patch';
return 'patch'; // Default to patch for other changes
}
/**
* Bump version based on type
*/
function bumpVersion(current, bumpType, prerelease = null) {
const v = { ...current };
// If adding prerelease to existing version
if (prerelease && !v.prerelease) {
v.prerelease = prerelease;
v.prereleaseNum = 1;
// Bump the appropriate version first
if (bumpType === 'major') {
v.major++;
v.minor = 0;
v.patch = 0;
} else if (bumpType === 'minor') {
v.minor++;
v.patch = 0;
} else {
v.patch++;
}
return v;
}
// If already in prerelease, bump prerelease number
if (v.prerelease && prerelease === v.prerelease) {
v.prereleaseNum++;
return v;
}
// If releasing from prerelease, remove prerelease
if (v.prerelease && !prerelease) {
v.prerelease = null;
v.prereleaseNum = 0;
return v;
}
// Normal version bump
v.prerelease = prerelease;
v.prereleaseNum = prerelease ? 1 : 0;
if (bumpType === 'major') {
v.major++;
v.minor = 0;
v.patch = 0;
} else if (bumpType === 'minor') {
v.minor++;
v.patch = 0;
} else {
v.patch++;
}
return v;
}
/**
* Get version file path for service or root
*/
function getVersionFilePath(service = null) {
if (service) {
// Per-service version file
const servicePath = path.join(process.cwd(), '.versions', `${service}.version`);
return servicePath;
}
// Root version file
return path.join(process.cwd(), VERSION_FILE);
}
/**
* Read current version from file
*/
function readVersion(service = null) {
const filePath = getVersionFilePath(service);
try {
if (fs.existsSync(filePath)) {
const content = fs.readFileSync(filePath, 'utf-8').trim();
return parseVersion(content);
}
} catch (error) {
console.error(`Warning: Could not read version file: ${error.message}`);
}
// Try to get latest git tag as fallback
try {
const tagPattern = service ? `${service}-v*` : 'v*';
const latestTag = execSync(`git describe --tags --match "${tagPattern}" --abbrev=0 2>/dev/null || echo ""`, {
encoding: 'utf-8',
}).trim();
if (latestTag) {
const versionPart = service ? latestTag.replace(`${service}-`, '') : latestTag;
return parseVersion(versionPart);
}
} catch {
// Ignore git errors
}
return parseVersion(DEFAULT_VERSION);
}
/**
* Write version to file
*/
function writeVersion(version, service = null) {
const filePath = getVersionFilePath(service);
const dir = path.dirname(filePath);
// Ensure directory exists
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const versionStr = formatVersion(version, false);
fs.writeFileSync(filePath, versionStr + '\n');
return filePath;
}
/**
* Parse command line arguments
*/
function parseArgs(args) {
const options = {
service: null,
prerelease: null,
bumpType: null,
dryRun: false,
inputFile: null,
};
for (let i = 0; i < args.length; i++) {
if (args[i] === '--service' && args[i + 1]) {
options.service = args[++i];
} else if (args[i] === '--prerelease' && args[i + 1]) {
options.prerelease = args[++i];
} else if (args[i] === '--bump' && args[i + 1]) {
options.bumpType = args[++i];
} else if (args[i] === '--dry-run') {
options.dryRun = true;
} else if (!args[i].startsWith('--') && fs.existsSync(args[i])) {
options.inputFile = args[i];
}
}
return options;
}
/**
* Main function
*/
function main() {
const args = process.argv.slice(2);
const options = parseArgs(args);
let commits = [];
let inputData = null;
// Read commits from stdin or file
if (!process.stdin.isTTY) {
const input = fs.readFileSync(0, 'utf-8');
try {
inputData = JSON.parse(input);
if (inputData.commits && !Array.isArray(inputData.commits)) {
console.error('Error: "commits" must be an array');
process.exit(1);
}
commits = inputData.commits || [];
} catch (error) {
console.error(`Warning: Could not parse JSON input (${error.message}), using bump type only`);
}
} else if (options.inputFile) {
const input = fs.readFileSync(options.inputFile, 'utf-8');
try {
inputData = JSON.parse(input);
if (inputData.commits && !Array.isArray(inputData.commits)) {
console.error('Error: "commits" must be an array');
process.exit(1);
}
commits = inputData.commits || [];
} catch (error) {
console.error(`Warning: Could not parse JSON input (${error.message}), using bump type only`);
}
}
// Get current version
const currentVersion = readVersion(options.service);
const currentStr = formatVersion(currentVersion);
// Determine bump type
const bumpType = options.bumpType || determineBumpType(commits);
// Calculate new version
const newVersion = bumpVersion(currentVersion, bumpType, options.prerelease);
const newStr = formatVersion(newVersion);
// Output result
const result = {
current: currentStr,
new: newStr,
bumpType,
service: options.service,
prerelease: options.prerelease,
commits: commits.length,
dryRun: options.dryRun,
};
// Write version file (unless dry run)
if (!options.dryRun) {
const filePath = writeVersion(newVersion, options.service);
result.versionFile = filePath;
console.error(`Version bumped: ${currentStr} → ${newStr}`);
console.error(`Version file: ${filePath}`);
} else {
console.error(`[DRY RUN] Would bump: ${currentStr} → ${newStr}`);
}
// If we have input data, pass it through with version info added
if (inputData) {
inputData.version = {
current: currentStr,
new: newStr,
bumpType,
service: options.service,
};
console.log(JSON.stringify(inputData, null, 2));
} else {
console.log(JSON.stringify(result, null, 2));
}
}
// Run if executed directly
if (require.main === module) {
main();
}
module.exports = {
parseVersion,
formatVersion,
determineBumpType,
bumpVersion,
readVersion,
writeVersion,
};
#!/usr/bin/env node
/**
* Categorize commits for release notes
* Usage: node categorize-commits.cjs < commits.json
*
* Reads parsed commits from stdin and categorizes them into release note sections
*/
const fs = require('fs');
const path = require('path');
// Category mappings (fallback if config not available)
const DEFAULT_CATEGORIES = {
features: {
types: ['feat'],
heading: "What's New",
userFacing: true,
},
fixes: {
types: ['fix'],
heading: 'Bug Fixes',
userFacing: true,
},
improvements: {
types: ['perf'],
heading: 'Improvements',
userFacing: true,
},
docs: {
types: ['docs'],
heading: 'Documentation',
userFacing: true,
},
refactoring: {
types: ['refactor'],
heading: 'Refactoring',
userFacing: false,
},
internal: {
types: ['test', 'ci', 'build', 'chore', 'style'],
heading: 'Internal Changes',
userFacing: false,
},
};
// Types/scopes to exclude from user-facing notes
const EXCLUDE_PATTERNS = [
/^chore\(deps\)/,
/^chore\(config\)/,
/^ci:/,
/^test:/,
/^style:/,
/\[skip changelog\]/i,
/\[ci skip\]/i,
];
/**
* Check if a commit should be excluded from user-facing notes
*/
function shouldExclude(commit) {
const subject = commit.subject || '';
return EXCLUDE_PATTERNS.some(pattern => pattern.test(subject));
}
/**
* Transform commit to user-friendly format
*/
function transformForUser(commit) {
// Create user-friendly description
let userDescription = commit.description;
// Capitalize first letter
userDescription = userDescription.charAt(0).toUpperCase() + userDescription.slice(1);
// Add scope context if present
const scopeLabel = commit.scope ? ` (${formatScope(commit.scope)})` : '';
return {
hash: commit.shortHash,
description: userDescription,
scope: commit.scope,
scopeLabel,
author: commit.author,
date: commit.date,
breaking: commit.breaking,
original: commit,
};
}
/**
* Format scope for display
*/
function formatScope(scope) {
const scopeLabels = {
api: 'API',
ui: 'UI',
auth: 'Auth',
deps: 'Dependencies',
'ai-tools': 'AI Tools',
frontend: 'Frontend',
backend: 'Backend',
};
return scopeLabels[scope] || scope.charAt(0).toUpperCase() + scope.slice(1);
}
/**
* Categorize commits into sections
*/
function categorizeCommits(commits, categories = DEFAULT_CATEGORIES) {
const result = {
features: [],
fixes: [],
improvements: [],
docs: [],
breaking: [],
internal: [],
other: [],
};
commits.forEach(commit => {
// Always track breaking changes separately
if (commit.breaking) {
result.breaking.push(transformForUser(commit));
}
// Skip excluded commits for user-facing sections
const excluded = shouldExclude(commit);
// Categorize by type
let categorized = false;
for (const [category, config] of Object.entries(categories)) {
if (config.types.includes(commit.type)) {
if (excluded || !config.userFacing) {
result.internal.push(transformForUser(commit));
} else {
result[category] = result[category] || [];
result[category].push(transformForUser(commit));
}
categorized = true;
break;
}
}
// Handle uncategorized (non-conventional) commits
if (!categorized) {
result.other.push(transformForUser(commit));
}
});
return result;
}
/**
* Generate summary statistics
*/
function generateSummary(categorized, stats) {
const userFacingCount =
categorized.features.length +
categorized.fixes.length +
categorized.improvements.length;
const sentences = [];
if (categorized.features.length > 0) {
sentences.push(`${categorized.features.length} new feature${categorized.features.length > 1 ? 's' : ''}`);
}
if (categorized.improvements.length > 0) {
sentences.push(`${categorized.improvements.length} improvement${categorized.improvements.length > 1 ? 's' : ''}`);
}
if (categorized.fixes.length > 0) {
sentences.push(`${categorized.fixes.length} bug fix${categorized.fixes.length > 1 ? 'es' : ''}`);
}
if (categorized.breaking.length > 0) {
sentences.push(`${categorized.breaking.length} breaking change${categorized.breaking.length > 1 ? 's' : ''}`);
}
const summary = sentences.length > 0
? `This release includes ${sentences.join(', ')}.`
: 'This release includes various internal improvements and maintenance updates.';
return {
text: summary,
userFacingCount,
totalCommits: stats.total,
breakingCount: categorized.breaking.length,
hasFeatures: categorized.features.length > 0,
hasFixes: categorized.fixes.length > 0,
hasImprovements: categorized.improvements.length > 0,
hasBreaking: categorized.breaking.length > 0,
};
}
/**
* Main function
*/
function main() {
let input = '';
// Read from stdin
if (!process.stdin.isTTY) {
input = fs.readFileSync(0, 'utf-8');
} else {
// Read from file argument
const args = process.argv.slice(2);
if (args.length > 0 && fs.existsSync(args[0])) {
input = fs.readFileSync(args[0], 'utf-8');
} else {
console.error('Usage: node categorize-commits.cjs < commits.json');
console.error(' node categorize-commits.cjs commits.json');
process.exit(1);
}
}
let data;
try {
data = JSON.parse(input);
} catch (error) {
console.error(`Error parsing JSON input: ${error.message}`);
console.error('Ensure upstream script outputs valid JSON');
process.exit(1);
}
if (!data.commits || !Array.isArray(data.commits)) {
console.error('Error: Missing or invalid "commits" array in input');
process.exit(1);
}
const commits = data.commits || [];
const stats = data.stats || { total: commits.length };
const categorized = categorizeCommits(commits);
const summary = generateSummary(categorized, stats);
const result = {
base: data.base,
head: data.head,
summary,
categories: categorized,
contributors: stats.authors || [],
dateRange: stats.dateRange || {},
};
console.log(JSON.stringify(result, null, 2));
}
// Run if executed directly
if (require.main === module) {
main();
}
module.exports = { categorizeCommits, transformForUser, shouldExclude };
#!/usr/bin/env node
/**
* Generate contributor statistics from commits
* Usage: node contributor-stats.cjs < commits.json
*
* Analyzes commits to generate contributor statistics and formatting
*/
const fs = require('fs');
const { validateInputNotEmpty } = require('./utils.cjs');
// Map author names to GitHub usernames (optional mapping)
const AUTHOR_GITHUB_MAP = {
// Add mappings like:
// 'John Doe': 'johndoe',
// 'DOMAIN\\user': 'github-user',
};
/**
* Normalize author name for GitHub mention
*/
function normalizeAuthorName(author) {
// Check manual mapping first
if (AUTHOR_GITHUB_MAP[author]) {
return AUTHOR_GITHUB_MAP[author];
}
// Remove domain prefix (DOMAIN\user -> user)
let normalized = author.replace(/^.*\\/, '');
// Remove common suffixes
normalized = normalized.replace(/\s*\(.*\)$/, '');
// Convert to GitHub-friendly format (lowercase, no spaces)
normalized = normalized.toLowerCase().replace(/\s+/g, '-');
return normalized;
}
/**
* Analyze contributor statistics
*/
function analyzeContributors(commits) {
const contributorMap = new Map();
commits.forEach(commit => {
const author = commit.author;
if (!author) return;
if (!contributorMap.has(author)) {
contributorMap.set(author, {
name: author,
githubUsername: normalizeAuthorName(author),
email: commit.email,
commits: 0,
features: 0,
fixes: 0,
improvements: 0,
docs: 0,
other: 0,
firstCommit: commit.date,
lastCommit: commit.date,
});
}
const stats = contributorMap.get(author);
stats.commits++;
// Track by type
switch (commit.type) {
case 'feat':
stats.features++;
break;
case 'fix':
stats.fixes++;
break;
case 'perf':
case 'refactor':
stats.improvements++;
break;
case 'docs':
stats.docs++;
break;
default:
stats.other++;
}
// Update date range
if (commit.date < stats.firstCommit) stats.firstCommit = commit.date;
if (commit.date > stats.lastCommit) stats.lastCommit = commit.date;
});
// Convert to array and sort by commit count
return Array.from(contributorMap.values()).sort((a, b) => b.commits - a.commits);
}
/**
* Format contributors for release notes
*/
function formatContributors(contributors, options = {}) {
const { showStats = false, maxContributors = 20 } = options;
const limited = contributors.slice(0, maxContributors);
if (showStats) {
return limited.map(c => ({
mention: `@${c.githubUsername}`,
name: c.name,
commits: c.commits,
highlights: getContributorHighlights(c),
}));
}
return limited.map(c => `@${c.githubUsername}`);
}
/**
* Get contributor highlights (main contribution types)
*/
function getContributorHighlights(contributor) {
const highlights = [];
if (contributor.features > 0) {
highlights.push(`${contributor.features} feature${contributor.features > 1 ? 's' : ''}`);
}
if (contributor.fixes > 0) {
highlights.push(`${contributor.fixes} fix${contributor.fixes > 1 ? 'es' : ''}`);
}
if (contributor.improvements > 0) {
highlights.push(`${contributor.improvements} improvement${contributor.improvements > 1 ? 's' : ''}`);
}
return highlights.join(', ');
}
/**
* Generate contributor summary
*/
function generateContributorSummary(contributors) {
const total = contributors.length;
if (total === 0) {
return {
total: 0,
text: 'No contributors found.',
};
}
const totalCommits = contributors.reduce((sum, c) => sum + c.commits, 0);
const topContributor = contributors[0];
let text = `${total} contributor${total > 1 ? 's' : ''} with ${totalCommits} commit${totalCommits > 1 ? 's' : ''}.`;
if (total > 1) {
text += ` Top contributor: ${topContributor.name} (${topContributor.commits} commits)`;
}
return {
total,
totalCommits,
topContributor: topContributor.name,
text,
};
}
/**
* Process contributor data for release notes
*/
function processContributors(data) {
const commits = data.commits || [];
const contributors = analyzeContributors(commits);
const formatted = formatContributors(contributors);
const summary = generateContributorSummary(contributors);
return {
...data,
contributorStats: {
summary,
contributors: contributors.map(c => ({
name: c.name,
githubUsername: c.githubUsername,
commits: c.commits,
features: c.features,
fixes: c.fixes,
improvements: c.improvements,
dateRange: {
first: c.firstCommit,
last: c.lastCommit,
},
})),
formatted,
markdown: formatted.map(c => `- ${c}`).join('\n'),
},
};
}
/**
* Main function
*/
function main() {
let input = '';
// Read from stdin or file
if (!process.stdin.isTTY) {
input = fs.readFileSync(0, 'utf-8');
} else {
const args = process.argv.slice(2);
if (args.length > 0 && fs.existsSync(args[0])) {
input = fs.readFileSync(args[0], 'utf-8');
} else {
console.error('Usage: node contributor-stats.cjs < commits.json');
console.error(' node contributor-stats.cjs commits.json');
process.exit(1);
}
}
// Validate input not empty
validateInputNotEmpty(input, 'contributor-stats');
let data;
try {
data = JSON.parse(input);
} catch (error) {
console.error(`Error parsing JSON input: ${error.message}`);
console.error('Ensure upstream script outputs valid JSON');
process.exit(1);
}
if (!data.commits || !Array.isArray(data.commits)) {
console.error('Error: Missing or invalid "commits" array in input');
process.exit(1);
}
const result = processContributors(data);
console.log(JSON.stringify(result, null, 2));
}
// Run if executed directly
if (require.main === module) {
main();
}
module.exports = {
normalizeAuthorName,
analyzeContributors,
formatContributors,
processContributors,
};
#!/usr/bin/env node
/**
* Detect breaking changes from commits
* Usage: node detect-breaking.cjs < categorized.json
*
* Enhances breaking change detection with migration info extraction
*/
const fs = require('fs');
// Breaking change patterns in commit body/footer
const BREAKING_PATTERNS = [
/BREAKING[ -]CHANGE:\s*(.+?)(?=\n\n|\n[A-Z]|$)/is,
/BREAKING:\s*(.+?)(?=\n\n|\n[A-Z]|$)/is,
];
// Migration keywords to extract
const MIGRATION_KEYWORDS = [
'migration guide',
'migration:',
'migrate:',
'migration steps',
'to migrate',
'upgrade guide',
'upgrade:',
];
/**
* Extract breaking change details from commit body
*/
function extractBreakingDetails(body) {
if (!body) return null;
for (const pattern of BREAKING_PATTERNS) {
const match = body.match(pattern);
if (match) {
return {
description: match[1].trim(),
raw: match[0],
};
}
}
return null;
}
/**
* Extract migration info from commit body
*/
function extractMigrationInfo(body) {
if (!body) return null;
const lines = body.split('\n');
const migrationLines = [];
let capturing = false;
for (const line of lines) {
const lowerLine = line.toLowerCase();
// Start capturing on migration keyword
if (MIGRATION_KEYWORDS.some(k => lowerLine.includes(k))) {
capturing = true;
migrationLines.push(line);
continue;
}
// Continue capturing indented/list lines
if (capturing) {
if (line.match(/^[\s\-\*\d\.]/)) {
migrationLines.push(line);
} else if (line.trim() === '') {
migrationLines.push('');
} else {
// Stop capturing on non-indented, non-empty line
break;
}
}
}
return migrationLines.length > 0 ? migrationLines.join('\n').trim() : null;
}
/**
* Analyze impact severity of breaking change
*/
function analyzeBreakingImpact(commit, services) {
// Check if affects critical services
const affectedServices = services?.affected || [];
const hasCriticalImpact = affectedServices.some(s => s.impact === 'critical');
const hasHighImpact = affectedServices.some(s => s.impact === 'high');
// Check scope for impact hints
const criticalScopes = ['api', 'domain', 'auth', 'platform'];
const isCriticalScope = criticalScopes.includes(commit.scope?.toLowerCase());
// Determine severity
if (hasCriticalImpact || isCriticalScope) {
return 'critical';
} else if (hasHighImpact) {
return 'high';
}
return 'medium';
}
/**
* Process breaking changes with enhanced details
*/
function processBreakingChanges(data) {
const { categories, services } = data;
const breakingCommits = categories?.breaking || [];
const enhanced = breakingCommits.map(commit => {
const body = commit.original?.body || '';
// Extract details from body
const breakingDetails = extractBreakingDetails(body);
const migrationInfo = extractMigrationInfo(body);
const severity = analyzeBreakingImpact(commit, services);
return {
...commit,
breaking: {
severity,
description: breakingDetails?.description || commit.description,
migration: migrationInfo,
requiresAction: severity === 'critical' || severity === 'high',
},
};
});
return {
...data,
categories: {
...categories,
breaking: enhanced,
},
breakingSummary: generateBreakingSummary(enhanced),
};
}
/**
* Generate breaking changes summary
*/
function generateBreakingSummary(breakingCommits) {
if (breakingCommits.length === 0) {
return {
hasBreaking: false,
count: 0,
critical: 0,
high: 0,
medium: 0,
requiresUserAction: false,
};
}
const critical = breakingCommits.filter(c => c.breaking.severity === 'critical').length;
const high = breakingCommits.filter(c => c.breaking.severity === 'high').length;
const medium = breakingCommits.filter(c => c.breaking.severity === 'medium').length;
return {
hasBreaking: true,
count: breakingCommits.length,
critical,
high,
medium,
requiresUserAction: critical > 0 || high > 0,
items: breakingCommits.map(c => ({
description: c.description,
severity: c.breaking.severity,
hasMigration: !!c.breaking.migration,
})),
};
}
/**
* Main function
*/
function main() {
let input = '';
// Read from stdin or file
if (!process.stdin.isTTY) {
input = fs.readFileSync(0, 'utf-8');
} else {
const args = process.argv.slice(2);
if (args.length > 0 && fs.existsSync(args[0])) {
input = fs.readFileSync(args[0], 'utf-8');
} else {
console.error('Usage: node detect-breaking.cjs < categorized.json');
console.error(' node detect-breaking.cjs categorized.json');
process.exit(1);
}
}
let data;
try {
data = JSON.parse(input);
} catch (error) {
console.error(`Error parsing JSON input: ${error.message}`);
console.error('Ensure upstream script outputs valid JSON');
process.exit(1);
}
if (!data.categories) {
console.error('Error: Missing "categories" object in input');
process.exit(1);
}
const result = processBreakingChanges(data);
console.log(JSON.stringify(result, null, 2));
}
// Run if executed directly
if (require.main === module) {
main();
}
module.exports = {
extractBreakingDetails,
extractMigrationInfo,
analyzeBreakingImpact,
processBreakingChanges,
};
#!/usr/bin/env node
/**
* Detect service boundaries from commit file changes
* Usage: node detect-services.cjs < commits-with-files.json
*
* Analyzes file paths to determine affected services
*/
const fs = require('fs');
const path = require('path');
// Service patterns from config (hardcoded for performance)
// NOTE: Update these regex patterns to match your project's directory structure
const SERVICE_PATTERNS = {
'backend-api': {
patterns: [/src\/.*ExampleApp\/.*\.Api\//],
label: 'Backend API',
impact: 'high',
},
'backend-domain': {
patterns: [/src\/.*ExampleApp\/.*\.Domain\//],
label: 'Domain Model',
impact: 'high',
},
'backend-application': {
patterns: [/src\/.*ExampleApp\/.*\.Application\//],
label: 'Application Layer',
impact: 'medium',
},
'backend-persistence': {
patterns: [/src\/.*ExampleApp\/.*\.Persistence.*\//],
label: 'Persistence',
impact: 'medium',
},
'framework-core': {
patterns: [/src\/.*Framework.*\//],
label: 'Framework Core',
impact: 'critical',
},
'frontend-apps': {
patterns: [/src\/.*ExampleAppWeb\/apps\//],
label: 'Frontend Apps',
impact: 'high',
},
'frontend-libs': {
patterns: [/src\/.*ExampleAppWeb\/libs\//],
label: 'Frontend Libraries',
impact: 'medium',
},
'ai-tools': {
patterns: [/\.claude\//, /\.github\/prompts\//],
label: 'AI Tooling',
impact: 'low',
},
config: {
patterns: [/\.github\/workflows\//, /\.json$/, /\.ya?ml$/],
label: 'Configuration',
impact: 'low',
},
docs: {
patterns: [/docs\//, /\.md$/],
label: 'Documentation',
impact: 'low',
},
};
/**
* Match a file path to services
*/
function matchServices(filePath) {
const matched = [];
for (const [serviceId, config] of Object.entries(SERVICE_PATTERNS)) {
for (const pattern of config.patterns) {
if (pattern.test(filePath)) {
matched.push({
id: serviceId,
label: config.label,
impact: config.impact,
});
break;
}
}
}
return matched;
}
/**
* Analyze commits to determine service impacts
*/
function analyzeServiceImpact(commits) {
const serviceMap = new Map();
commits.forEach(commit => {
if (!commit.files) return;
commit.files.forEach(file => {
const services = matchServices(file.path);
services.forEach(service => {
if (!serviceMap.has(service.id)) {
serviceMap.set(service.id, {
...service,
commits: [],
fileCount: 0,
changes: { added: 0, modified: 0, deleted: 0 },
});
}
const entry = serviceMap.get(service.id);
// Track commit if not already tracked
if (!entry.commits.includes(commit.shortHash)) {
entry.commits.push(commit.shortHash);
}
entry.fileCount++;
// Track change types
switch (file.status) {
case 'A':
entry.changes.added++;
break;
case 'M':
entry.changes.modified++;
break;
case 'D':
entry.changes.deleted++;
break;
}
});
});
});
// Convert to array and sort by impact
const impactOrder = { critical: 0, high: 1, medium: 2, low: 3 };
return Array.from(serviceMap.values()).sort(
(a, b) => impactOrder[a.impact] - impactOrder[b.impact]
);
}
/**
* Generate service summary
*/
function generateServiceSummary(services) {
const impactGroups = {
critical: [],
high: [],
medium: [],
low: [],
};
services.forEach(s => {
impactGroups[s.impact].push(s.label);
});
const parts = [];
if (impactGroups.critical.length > 0) {
parts.push(`**Critical:** ${impactGroups.critical.join(', ')}`);
}
if (impactGroups.high.length > 0) {
parts.push(`**High:** ${impactGroups.high.join(', ')}`);
}
if (impactGroups.medium.length > 0) {
parts.push(`**Medium:** ${impactGroups.medium.join(', ')}`);
}
if (impactGroups.low.length > 0) {
parts.push(`Low: ${impactGroups.low.join(', ')}`);
}
return parts.join(' | ') || 'No service boundaries detected';
}
/**
* Main function
*/
function main() {
let input = '';
// Read from stdin or file
if (!process.stdin.isTTY) {
input = fs.readFileSync(0, 'utf-8');
} else {
const args = process.argv.slice(2);
if (args.length > 0 && fs.existsSync(args[0])) {
input = fs.readFileSync(args[0], 'utf-8');
} else {
console.error('Usage: node detect-services.cjs < commits-with-files.json');
console.error(' node detect-services.cjs commits-with-files.json');
console.error('');
console.error('Note: Run parse-commits.cjs with --with-files flag first');
process.exit(1);
}
}
let data;
try {
data = JSON.parse(input);
} catch (error) {
console.error(`Error parsing JSON input: ${error.message}`);
console.error('Ensure upstream script outputs valid JSON');
process.exit(1);
}
if (!data.commits || !Array.isArray(data.commits)) {
console.error('Error: Missing or invalid "commits" array in input');
process.exit(1);
}
const commits = data.commits || [];
const services = analyzeServiceImpact(commits);
const summary = generateServiceSummary(services);
const result = {
...data,
services: {
affected: services,
summary,
hasBreakingServiceChange: services.some(s => s.impact === 'critical'),
},
};
console.log(JSON.stringify(result, null, 2));
}
// Run if executed directly
if (require.main === module) {
main();
}
module.exports = { matchServices, analyzeServiceImpact, generateServiceSummary };
#!/usr/bin/env node
/**
* Extract PR metadata from commits
* Usage: node extract-pr-metadata.cjs < commits.json [--fetch-gh]
*
* Extracts PR numbers from commit messages and optionally fetches GitHub PR details
*/
const fs = require('fs');
const { execSync } = require('child_process');
// PR reference patterns
const PR_PATTERNS = [
/\(#(\d+)\)/g, // (PR #123)
/Merge pull request #(\d+)/gi, // Merge PR
/closes?\s*#(\d+)/gi, // Closes #123
/fixes?\s*#(\d+)/gi, // Fixes #123
/resolves?\s*#(\d+)/gi, // Resolves #123
/#(\d+)\b/g, // Generic #123 reference
];
/**
* Extract PR numbers from commit message
*/
function extractPRNumbers(commit) {
const text = `${commit.subject} ${commit.body || ''}`;
const prNumbers = new Set();
for (const pattern of PR_PATTERNS) {
// Reset lastIndex for global patterns
pattern.lastIndex = 0;
let match;
while ((match = pattern.exec(text)) !== null) {
prNumbers.add(parseInt(match[1], 10));
}
}
return Array.from(prNumbers);
}
/**
* Fetch PR details from GitHub using gh CLI
*/
function fetchPRDetails(prNumber) {
try {
const cmd = `gh pr view ${prNumber} --json title,body,labels,author,mergedAt,additions,deletions,changedFiles`;
const output = execSync(cmd, { encoding: 'utf-8', timeout: 10000 });
return JSON.parse(output);
} catch (error) {
// PR not found or gh CLI error
return null;
}
}
/**
* Extract labels from PR data
*/
function extractLabels(prData) {
if (!prData?.labels) return [];
return prData.labels.map(l => (typeof l === 'string' ? l : l.name));
}
/**
* Determine PR type from labels
*/
function determinePRType(labels) {
const labelSet = new Set(labels.map(l => l.toLowerCase()));
if (labelSet.has('breaking') || labelSet.has('breaking-change')) {
return 'breaking';
}
if (labelSet.has('feature') || labelSet.has('enhancement')) {
return 'feature';
}
if (labelSet.has('bug') || labelSet.has('bugfix')) {
return 'fix';
}
if (labelSet.has('documentation') || labelSet.has('docs')) {
return 'docs';
}
if (labelSet.has('performance')) {
return 'perf';
}
return 'other';
}
/**
* Process commits to extract PR metadata
*/
function extractPRMetadata(data, options = {}) {
const { fetchGitHub = false } = options;
const commits = data.commits || [];
const prMap = new Map();
commits.forEach(commit => {
const prNumbers = extractPRNumbers(commit);
prNumbers.forEach(prNum => {
if (!prMap.has(prNum)) {
prMap.set(prNum, {
number: prNum,
commits: [],
details: null,
});
}
prMap.get(prNum).commits.push(commit.shortHash);
});
});
// Optionally fetch GitHub details
if (fetchGitHub) {
for (const [prNum, prData] of prMap) {
const details = fetchPRDetails(prNum);
if (details) {
const labels = extractLabels(details);
prData.details = {
title: details.title,
author: details.author?.login,
labels,
type: determinePRType(labels),
mergedAt: details.mergedAt,
stats: {
additions: details.additions,
deletions: details.deletions,
files: details.changedFiles,
},
};
}
}
}
// Convert to array
const pullRequests = Array.from(prMap.values());
return {
...data,
pullRequests: {
count: pullRequests.length,
items: pullRequests,
hasLinkedPRs: pullRequests.length > 0,
},
};
}
/**
* Generate PR summary
*/
function generatePRSummary(pullRequests) {
if (!pullRequests?.items?.length) {
return 'No linked pull requests found.';
}
const withDetails = pullRequests.items.filter(pr => pr.details);
if (withDetails.length === 0) {
return `${pullRequests.count} pull request(s) referenced.`;
}
const byType = {};
withDetails.forEach(pr => {
const type = pr.details.type || 'other';
byType[type] = (byType[type] || 0) + 1;
});
const parts = [];
if (byType.feature) parts.push(`${byType.feature} feature(s)`);
if (byType.fix) parts.push(`${byType.fix} fix(es)`);
if (byType.breaking) parts.push(`${byType.breaking} breaking change(s)`);
if (byType.docs) parts.push(`${byType.docs} doc update(s)`);
return parts.length > 0 ? parts.join(', ') : `${pullRequests.count} pull request(s)`;
}
/**
* Parse command line arguments
*/
function parseArgs(args) {
return {
fetchGitHub: args.includes('--fetch-gh') || args.includes('--fetch-github'),
inputFile: args.find(a => !a.startsWith('--') && fs.existsSync(a)),
};
}
/**
* Main function
*/
function main() {
const args = process.argv.slice(2);
const options = parseArgs(args);
let input = '';
// Read from stdin or file
if (!process.stdin.isTTY) {
input = fs.readFileSync(0, 'utf-8');
} else if (options.inputFile) {
input = fs.readFileSync(options.inputFile, 'utf-8');
} else {
console.error('Usage: node extract-pr-metadata.cjs < commits.json [--fetch-gh]');
console.error(' node extract-pr-metadata.cjs commits.json --fetch-gh');
console.error('');
console.error('Options:');
console.error(' --fetch-gh Fetch PR details from GitHub using gh CLI');
process.exit(1);
}
let data;
try {
data = JSON.parse(input);
} catch (error) {
console.error(`Error parsing JSON input: ${error.message}`);
console.error('Ensure upstream script outputs valid JSON');
process.exit(1);
}
if (!data.commits || !Array.isArray(data.commits)) {
console.error('Error: Missing or invalid "commits" array in input');
process.exit(1);
}
const result = extractPRMetadata(data, { fetchGitHub: options.fetchGitHub });
result.pullRequests.summary = generatePRSummary(result.pullRequests);
console.log(JSON.stringify(result, null, 2));
}
// Run if executed directly
if (require.main === module) {
main();
}
module.exports = {
extractPRNumbers,
fetchPRDetails,
extractPRMetadata,
generatePRSummary,
};
#!/usr/bin/env node
/**
* Parse git commits between two refs into structured JSON
* Usage: node parse-commits.cjs <base> <head> [--json]
*
* Parses conventional commit format: type(scope): description
*/
const { execSync } = require('child_process');
// Conventional commit regex
const COMMIT_PATTERN = /^(?<type>\w+)(?:\((?<scope>[^)]+)\))?(?<breaking>!)?\s*:\s*(?<description>.+)$/;
/**
* Parse a single commit message into structured data
*/
function parseCommitMessage(subject) {
const match = subject.match(COMMIT_PATTERN);
if (!match) {
return {
type: 'other',
scope: null,
breaking: false,
description: subject,
conventional: false,
};
}
return {
type: match.groups.type,
scope: match.groups.scope || null,
breaking: !!match.groups.breaking,
description: match.groups.description,
conventional: true,
};
}
/**
* Sanitize git ref to prevent command injection
* Allows: alphanumeric, dots, dashes, underscores, tildes, carets, slashes
*/
function sanitizeGitRef(ref) {
return ref.replace(/[^a-zA-Z0-9._\-~^\/]/g, '');
}
/**
* Get commits between two refs using git log
*/
function getCommits(base, head) {
// Use unique delimiter to separate commits (unlikely to appear in content)
const COMMIT_DELIMITER = '<<<COMMIT_END>>>';
const FIELD_DELIMITER = '<<<FIELD>>>';
const format = [
'%H', // Full hash
'%h', // Short hash
'%an', // Author name
'%ae', // Author email
'%ad', // Author date
'%s', // Subject
'%b', // Body
].join(FIELD_DELIMITER);
// Sanitize refs to prevent command injection
const safeBase = sanitizeGitRef(base);
const safeHead = sanitizeGitRef(head);
const cmd = `git log "${safeBase}..${safeHead}" --pretty=format:"${format}${COMMIT_DELIMITER}" --date=short`;
try {
const output = execSync(cmd, {
encoding: 'utf-8',
maxBuffer: 50 * 1024 * 1024,
timeout: 60000 // 60 second timeout for large repos
});
if (!output.trim()) {
return [];
}
const rawCommits = output.split(COMMIT_DELIMITER).filter(c => c.trim());
return rawCommits.map(raw => {
const parts = raw.split(FIELD_DELIMITER);
// Handle leading newline from previous commit separator
const hash = (parts[0] || '').replace(/^[\r\n]+/, '').trim();
const shortHash = (parts[1] || '').trim();
const author = (parts[2] || '').trim();
const email = (parts[3] || '').trim();
const date = (parts[4] || '').trim();
const subject = (parts[5] || '').trim();
const body = (parts[6] || '').trim();
const parsed = parseCommitMessage(subject);
// Check for BREAKING CHANGE in body
const hasBreakingInBody = body && /BREAKING[ -]CHANGE:/i.test(body);
return {
hash,
shortHash,
author,
email,
date,
subject,
body,
...parsed,
breaking: parsed.breaking || hasBreakingInBody,
};
});
} catch (error) {
console.error('Error executing git log:', error.message);
return [];
}
}
/**
* Get file changes for each commit
*/
function getCommitFiles(hash) {
try {
const cmd = `git diff-tree --no-commit-id --name-status -r ${hash}`;
const output = execSync(cmd, { encoding: 'utf-8' });
return output
.trim()
.split('\n')
.filter(line => line.trim())
.map(line => {
const [status, ...pathParts] = line.split('\t');
return {
status: status.trim(),
path: pathParts.join('\t').trim(),
};
});
} catch {
return [];
}
}
/**
* Main function
*/
function main() {
const args = process.argv.slice(2);
if (args.length < 2) {
console.error('Usage: node parse-commits.cjs <base> <head> [--with-files]');
process.exit(1);
}
const [base, head] = args;
const withFiles = args.includes('--with-files');
const commits = getCommits(base, head);
// Optionally add file changes
if (withFiles) {
commits.forEach(commit => {
commit.files = getCommitFiles(commit.hash);
});
}
// Calculate stats
const stats = {
total: commits.length,
conventional: commits.filter(c => c.conventional).length,
breaking: commits.filter(c => c.breaking).length,
byType: {},
authors: [...new Set(commits.map(c => c.author))],
dateRange: {
from: commits.length ? commits[commits.length - 1].date : null,
to: commits.length ? commits[0].date : null,
},
};
// Count by type
commits.forEach(c => {
stats.byType[c.type] = (stats.byType[c.type] || 0) + 1;
});
const result = {
base,
head,
commits,
stats,
};
console.log(JSON.stringify(result, null, 2));
}
// Run if executed directly
if (require.main === module) {
main();
}
module.exports = { parseCommitMessage, getCommits, getCommitFiles };
#!/usr/bin/env node
/**
* Render release notes markdown from categorized commits
* Usage: node render-template.cjs < categorized.json --version v1.0.0 [--output path]
*
* Generates markdown release notes from categorized commit data
*/
const fs = require('fs');
const path = require('path');
const { validateOutputPath, escapeMarkdown } = require('./utils.cjs');
/**
* Format a list of commits as markdown bullet points
*/
function formatCommitList(commits) {
if (!commits || commits.length === 0) {
return '';
}
return commits
.map(c => `- **${escapeMarkdown(c.description)}**${c.scopeLabel || ''}`)
.join('\n');
}
/**
* Format breaking changes with migration info
*/
function formatBreakingChanges(commits) {
if (!commits || commits.length === 0) {
return '';
}
return commits
.map(c => {
let entry = `### ${escapeMarkdown(c.description)}${c.scopeLabel || ''}\n\n`;
// Try to extract migration steps from commit body
const body = c.original?.body || '';
if (body.includes('BREAKING CHANGE:')) {
const migrationInfo = body.split('BREAKING CHANGE:')[1]?.trim();
if (migrationInfo) {
entry += `${migrationInfo}\n`;
}
}
return entry;
})
.join('\n');
}
/**
* Format technical details section
*/
function formatTechnicalDetails(data) {
const { categories, contributors, dateRange } = data;
let content = '';
// All commits table
const allCommits = [
...categories.features,
...categories.fixes,
...categories.improvements,
...categories.docs,
...categories.internal,
...categories.other,
];
if (allCommits.length > 0) {
content += '### Commits Included\n\n';
content += '| Hash | Type | Description |\n';
content += '|------|------|-------------|\n';
allCommits.slice(0, 50).forEach(c => {
const type = c.original?.type || 'other';
content += `| ${c.hash} | ${type} | ${c.description.substring(0, 60)}${c.description.length > 60 ? '...' : ''} |\n`;
});
if (allCommits.length > 50) {
content += `\n*...and ${allCommits.length - 50} more commits*\n`;
}
}
return content;
}
/**
* Format contributors section
*/
function formatContributors(contributors) {
if (!contributors || contributors.length === 0) {
return '- Development Team';
}
return contributors
.map(name => `- @${name.replace(/\s+/g, '')}`)
.join('\n');
}
/**
* Render the full release notes markdown
*/
function renderReleaseNotes(data, options = {}) {
const {
version = 'Unreleased',
date = new Date().toISOString().split('T')[0],
status = 'Draft',
} = options;
const { summary, categories, contributors, dateRange } = data;
let markdown = '';
// Header
markdown += `# Release Notes: ${version}\n\n`;
markdown += `**Date:** ${date}\n`;
markdown += `**Version:** ${version}\n`;
markdown += `**Status:** ${status}\n\n`;
markdown += '---\n\n';
// Summary
markdown += `## Summary\n\n${summary.text}\n\n`;
// What's New (Features)
if (categories.features.length > 0) {
markdown += `## What's New\n\n`;
markdown += formatCommitList(categories.features);
markdown += '\n\n';
}
// Improvements
if (categories.improvements.length > 0) {
markdown += `## Improvements\n\n`;
markdown += formatCommitList(categories.improvements);
markdown += '\n\n';
}
// Bug Fixes
if (categories.fixes.length > 0) {
markdown += `## Bug Fixes\n\n`;
markdown += formatCommitList(categories.fixes);
markdown += '\n\n';
}
// Documentation
if (categories.docs.length > 0) {
markdown += `## Documentation\n\n`;
markdown += formatCommitList(categories.docs);
markdown += '\n\n';
}
// Breaking Changes
if (categories.breaking.length > 0) {
markdown += `## Breaking Changes\n\n`;
markdown += '> **Warning**: The following changes may require migration\n\n';
markdown += formatBreakingChanges(categories.breaking);
markdown += '\n';
}
// Technical Details (collapsible)
markdown += '---\n\n';
markdown += '## Technical Details\n\n';
markdown += '<details>\n<summary>For Developers</summary>\n\n';
markdown += formatTechnicalDetails(data);
markdown += '\n</details>\n\n';
// Contributors
markdown += `## Contributors\n\n`;
markdown += formatContributors(contributors);
markdown += '\n\n';
// Footer
markdown += '---\n\n';
markdown += '*Generated by AI*\n';
return markdown;
}
/**
* Parse command line arguments
*/
function parseArgs(args) {
const options = {
version: 'Unreleased',
output: null,
date: new Date().toISOString().split('T')[0],
};
for (let i = 0; i < args.length; i++) {
if (args[i] === '--version' && args[i + 1]) {
options.version = args[++i];
} else if (args[i] === '--output' && args[i + 1]) {
options.output = args[++i];
} else if (args[i] === '--date' && args[i + 1]) {
options.date = args[++i];
}
}
return options;
}
/**
* Main function
*/
function main() {
const args = process.argv.slice(2);
const options = parseArgs(args);
let input = '';
// Read from stdin or file
if (!process.stdin.isTTY) {
input = fs.readFileSync(0, 'utf-8');
} else {
// Look for input file in args
const inputFile = args.find(a => !a.startsWith('--') && fs.existsSync(a));
if (inputFile) {
input = fs.readFileSync(inputFile, 'utf-8');
} else {
console.error('Usage: node render-template.cjs < categorized.json --version v1.0.0');
console.error(' node render-template.cjs categorized.json --version v1.0.0 --output release.md');
process.exit(1);
}
}
let data;
try {
data = JSON.parse(input);
} catch (error) {
console.error(`Error parsing JSON input: ${error.message}`);
console.error('Ensure upstream script outputs valid JSON');
process.exit(1);
}
if (!data.summary || !data.categories) {
console.error('Error: Missing required "summary" or "categories" in input');
process.exit(1);
}
const markdown = renderReleaseNotes(data, options);
if (options.output) {
// Validate output path (prevent path traversal)
const safePath = validateOutputPath(options.output);
// Ensure directory exists
const dir = path.dirname(safePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(safePath, markdown);
console.error(`Release notes written to: ${safePath}`);
} else {
console.log(markdown);
}
}
// Run if executed directly
if (require.main === module) {
main();
}
module.exports = { renderReleaseNotes, formatCommitList, formatBreakingChanges };
#!/usr/bin/env node
/**
* Transform release notes using Claude API
* Usage: node transform-llm.cjs <release-notes.md> --transform <type> [--output path]
*
* Transform types:
* - summarize: Create a brief summary of changes
* - business: Rewrite for business stakeholders
* - enduser: Rewrite for end users
* - executive: Create executive summary
* - technical: Enhance with technical details
*
* Requires ANTHROPIC_API_KEY environment variable
*/
const fs = require('fs');
const path = require('path');
const https = require('https');
const { validateOutputPath, validateInputNotEmpty } = require('./utils.cjs');
const ANTHROPIC_API_URL = 'api.anthropic.com';
const ANTHROPIC_API_VERSION = '2023-06-01';
const DEFAULT_MODEL = 'sonnet';
/**
* Transform prompts for different audiences
*/
const TRANSFORM_PROMPTS = {
summarize: {
system: 'You are a technical writer creating concise release summaries.',
prompt: `Summarize the following release notes into 3-5 bullet points highlighting the most important changes. Focus on user impact.
Release Notes:
{content}
Provide a brief summary in markdown format.`,
},
business: {
system: 'You are a business analyst translating technical changes into business value.',
prompt: `Rewrite the following release notes for business stakeholders. Focus on:
- ROI and productivity gains
- Risk mitigation
- Competitive advantages
- Strategic alignment
Avoid technical jargon. Use business language.
Release Notes:
{content}
Provide the business-focused release notes in markdown format.`,
},
enduser: {
system: 'You are a UX writer creating user-friendly documentation.',
prompt: `Rewrite the following release notes for end users. Focus on:
- What changed from their perspective
- How to use new features
- Any actions they need to take
- Benefits they will experience
Use simple, clear language. Avoid technical terms.
Release Notes:
{content}
Provide the user-focused release notes in markdown format.`,
},
executive: {
system: 'You are a strategic advisor preparing executive briefings.',
prompt: `Create an executive summary of the following release notes. Include:
- 2-3 sentence overview
- Key metrics (number of features, fixes, etc.)
- Strategic impact
- Any risks or dependencies
Keep it under 200 words. Focus on high-level impact.
Release Notes:
{content}
Provide the executive summary in markdown format.`,
},
technical: {
system: 'You are a senior software architect enhancing technical documentation.',
prompt: `Enhance the following release notes with technical details. Add:
- Architecture implications
- Performance considerations
- Migration requirements
- API changes
- Database schema changes (if applicable)
Maintain accuracy. Only add details that can be inferred from the existing content.
Release Notes:
{content}
Provide the technically enhanced release notes in markdown format.`,
},
};
/**
* Call Claude API
*/
async function callClaudeAPI(systemPrompt, userPrompt, apiKey, model = DEFAULT_MODEL) {
return new Promise((resolve, reject) => {
const data = JSON.stringify({
model,
max_tokens: 4096,
system: systemPrompt,
messages: [{ role: 'user', content: userPrompt }],
});
const options = {
hostname: ANTHROPIC_API_URL,
port: 443,
path: '/v1/messages',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(data),
'x-api-key': apiKey,
'anthropic-version': ANTHROPIC_API_VERSION,
},
};
const req = https.request(options, (res) => {
let responseData = '';
res.on('data', (chunk) => {
responseData += chunk;
});
res.on('end', () => {
try {
const response = JSON.parse(responseData);
if (res.statusCode !== 200) {
reject(new Error(`API error (${res.statusCode}): ${response.error?.message || responseData}`));
return;
}
if (response.content && response.content[0]) {
resolve(response.content[0].text);
} else {
reject(new Error('Unexpected API response format'));
}
} catch (error) {
reject(new Error(`Failed to parse API response: ${error.message}`));
}
});
});
req.on('error', (error) => {
reject(new Error(`Request failed: ${error.message}`));
});
req.write(data);
req.end();
});
}
/**
* Transform release notes using Claude
*/
async function transformNotes(content, transformType, apiKey, options = {}) {
const template = TRANSFORM_PROMPTS[transformType];
if (!template) {
throw new Error(`Unknown transform type: ${transformType}. Available: ${Object.keys(TRANSFORM_PROMPTS).join(', ')}`);
}
const userPrompt = template.prompt.replace('{content}', content);
const model = options.model || DEFAULT_MODEL;
console.error(`Transforming to "${transformType}" using ${model}...`);
const result = await callClaudeAPI(template.system, userPrompt, apiKey, model);
return result;
}
/**
* Simple cache for transformed content
*/
const CACHE_DIR = path.join(process.cwd(), '.cache', 'release-notes-transforms');
function getCacheKey(content, transformType) {
const crypto = require('crypto');
const hash = crypto.createHash('md5').update(content + transformType).digest('hex');
return hash;
}
function getCachedResult(content, transformType) {
const cacheKey = getCacheKey(content, transformType);
const cachePath = path.join(CACHE_DIR, `${cacheKey}.md`);
if (fs.existsSync(cachePath)) {
const stats = fs.statSync(cachePath);
const age = Date.now() - stats.mtimeMs;
// Cache valid for 24 hours
if (age < 24 * 60 * 60 * 1000) {
return fs.readFileSync(cachePath, 'utf-8');
}
}
return null;
}
function cacheResult(content, transformType, result) {
const cacheKey = getCacheKey(content, transformType);
const cachePath = path.join(CACHE_DIR, `${cacheKey}.md`);
if (!fs.existsSync(CACHE_DIR)) {
fs.mkdirSync(CACHE_DIR, { recursive: true });
}
fs.writeFileSync(cachePath, result);
}
/**
* Parse command line arguments
*/
function parseArgs(args) {
const options = {
inputFile: null,
transformType: 'summarize',
output: null,
model: DEFAULT_MODEL,
noCache: false,
};
for (let i = 0; i < args.length; i++) {
if (args[i] === '--transform' && args[i + 1]) {
options.transformType = args[++i];
} else if (args[i] === '--output' && args[i + 1]) {
options.output = args[++i];
} else if (args[i] === '--model' && args[i + 1]) {
options.model = args[++i];
} else if (args[i] === '--no-cache') {
options.noCache = true;
} else if (!args[i].startsWith('--')) {
options.inputFile = args[i];
}
}
return options;
}
/**
* Main function
*/
async function main() {
const args = process.argv.slice(2);
const options = parseArgs(args);
// Check for API key
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) {
console.error('Error: ANTHROPIC_API_KEY environment variable is required');
console.error('');
console.error('Set it with:');
console.error(' export ANTHROPIC_API_KEY="your-api-key" # Linux/Mac');
console.error(' set ANTHROPIC_API_KEY=your-api-key # Windows CMD');
console.error(' $env:ANTHROPIC_API_KEY="your-api-key" # Windows PowerShell');
process.exit(1);
}
// Read content
let content = '';
if (options.inputFile && fs.existsSync(options.inputFile)) {
content = fs.readFileSync(options.inputFile, 'utf-8');
} else if (!process.stdin.isTTY) {
content = fs.readFileSync(0, 'utf-8');
} else {
console.error('Usage: node transform-llm.cjs <release-notes.md> --transform <type> [--output path]');
console.error('');
console.error('Transform types:');
Object.entries(TRANSFORM_PROMPTS).forEach(([type, config]) => {
console.error(` ${type.padEnd(12)} - ${config.system.split('.')[0]}`);
});
console.error('');
console.error('Options:');
console.error(' --transform <type> Transform type (default: summarize)');
console.error(' --output <path> Output file path');
console.error(' --model <model> Claude model to use (default: sonnet)');
console.error(' --no-cache Skip cache lookup');
process.exit(1);
}
// Validate input not empty (prevents wasted API calls)
validateInputNotEmpty(content, 'transform-llm');
try {
// Check cache first
if (!options.noCache) {
const cached = getCachedResult(content, options.transformType);
if (cached) {
console.error('Using cached result');
if (options.output) {
const safePath = validateOutputPath(options.output);
fs.writeFileSync(safePath, cached);
console.error(`Output written to: ${safePath}`);
} else {
console.log(cached);
}
return;
}
}
// Transform
const result = await transformNotes(content, options.transformType, apiKey, {
model: options.model,
});
// Cache result
cacheResult(content, options.transformType, result);
// Output
if (options.output) {
// Validate output path (prevent path traversal)
const safePath = validateOutputPath(options.output);
const dir = path.dirname(safePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(safePath, result);
console.error(`Output written to: ${safePath}`);
} else {
console.log(result);
}
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
}
// Run if executed directly
if (require.main === module) {
main();
}
module.exports = {
transformNotes,
callClaudeAPI,
TRANSFORM_PROMPTS,
};
#!/usr/bin/env node
/**
* Update CHANGELOG.md with new release notes
* Usage: node update-changelog.cjs <release-notes-file> [--changelog path] [--version vX.Y.Z]
*
* Prepends release notes to CHANGELOG.md following Keep a Changelog format
*/
const fs = require('fs');
const path = require('path');
const DEFAULT_CHANGELOG = 'CHANGELOG.md';
/**
* Extract sections from release notes markdown
*/
function extractSections(releaseNotesContent) {
const sections = {
summary: '',
features: [],
improvements: [],
fixes: [],
docs: [],
breaking: [],
other: [],
};
const lines = releaseNotesContent.split('\n');
let currentSection = null;
for (const line of lines) {
// Detect section headers
if (line.startsWith('## Summary')) {
currentSection = 'summary';
continue;
} else if (line.startsWith("## What's New")) {
currentSection = 'features';
continue;
} else if (line.startsWith('## Improvements')) {
currentSection = 'improvements';
continue;
} else if (line.startsWith('## Bug Fixes')) {
currentSection = 'fixes';
continue;
} else if (line.startsWith('## Documentation')) {
currentSection = 'docs';
continue;
} else if (line.startsWith('## Breaking Changes')) {
currentSection = 'breaking';
continue;
} else if (line.startsWith('## Technical Details') || line.startsWith('## Contributors')) {
currentSection = null; // Skip these sections
continue;
} else if (line.startsWith('## ')) {
currentSection = 'other';
continue;
}
// Extract content
if (currentSection === 'summary' && line.trim() && !line.startsWith('#')) {
sections.summary += line + '\n';
} else if (currentSection && line.startsWith('- ')) {
sections[currentSection].push(line);
}
}
return sections;
}
/**
* Format changelog entry in Keep a Changelog format
*/
function formatChangelogEntry(version, date, sections) {
let entry = `## [${version}] - ${date}\n\n`;
if (sections.summary.trim()) {
entry += `${sections.summary.trim()}\n\n`;
}
if (sections.features.length > 0) {
entry += '### Added\n\n';
entry += sections.features.join('\n') + '\n\n';
}
if (sections.improvements.length > 0) {
entry += '### Changed\n\n';
entry += sections.improvements.join('\n') + '\n\n';
}
if (sections.fixes.length > 0) {
entry += '### Fixed\n\n';
entry += sections.fixes.join('\n') + '\n\n';
}
if (sections.docs.length > 0) {
entry += '### Documentation\n\n';
entry += sections.docs.join('\n') + '\n\n';
}
if (sections.breaking.length > 0) {
entry += '### Breaking Changes\n\n';
entry += sections.breaking.join('\n') + '\n\n';
}
return entry;
}
/**
* Create initial CHANGELOG.md if it doesn't exist
*/
function createInitialChangelog() {
return `# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
`;
}
/**
* Update CHANGELOG.md with new entry
*/
function updateChangelog(changelogPath, newEntry) {
let content;
if (fs.existsSync(changelogPath)) {
content = fs.readFileSync(changelogPath, 'utf-8');
} else {
content = createInitialChangelog();
}
// Find insertion point (after header, before first version entry)
const versionPattern = /^## \[/m;
const match = content.match(versionPattern);
if (match) {
const insertPoint = match.index;
content = content.slice(0, insertPoint) + newEntry + content.slice(insertPoint);
} else {
// No existing versions, append to end
content += newEntry;
}
fs.writeFileSync(changelogPath, content);
return changelogPath;
}
/**
* Parse command line arguments
*/
function parseArgs(args) {
const options = {
releaseNotesFile: null,
changelog: DEFAULT_CHANGELOG,
version: null,
date: new Date().toISOString().split('T')[0],
};
for (let i = 0; i < args.length; i++) {
if (args[i] === '--changelog' && args[i + 1]) {
options.changelog = args[++i];
} else if (args[i] === '--version' && args[i + 1]) {
options.version = args[++i];
} else if (args[i] === '--date' && args[i + 1]) {
options.date = args[++i];
} else if (!args[i].startsWith('--') && fs.existsSync(args[i])) {
options.releaseNotesFile = args[i];
}
}
return options;
}
/**
* Extract version from release notes content
*/
function extractVersion(content) {
const match = content.match(/\*\*Version:\*\*\s*(.+)/);
return match ? match[1].trim() : null;
}
/**
* Extract date from release notes content
*/
function extractDate(content) {
const match = content.match(/\*\*Date:\*\*\s*(.+)/);
return match ? match[1].trim() : new Date().toISOString().split('T')[0];
}
/**
* Main function
*/
function main() {
const args = process.argv.slice(2);
const options = parseArgs(args);
if (!options.releaseNotesFile) {
console.error('Usage: node update-changelog.cjs <release-notes-file> [--changelog path] [--version vX.Y.Z]');
console.error('');
console.error('Options:');
console.error(' --changelog Path to CHANGELOG.md (default: CHANGELOG.md)');
console.error(' --version Version to use (extracted from release notes if not provided)');
console.error(' --date Release date (extracted from release notes if not provided)');
process.exit(1);
}
// Read release notes
const releaseNotesContent = fs.readFileSync(options.releaseNotesFile, 'utf-8');
// Extract version if not provided
const version = options.version || extractVersion(releaseNotesContent) || 'Unreleased';
const date = options.date || extractDate(releaseNotesContent);
// Extract sections from release notes
const sections = extractSections(releaseNotesContent);
// Format changelog entry
const entry = formatChangelogEntry(version, date, sections);
// Update changelog
const updatedPath = updateChangelog(options.changelog, entry);
console.error(`CHANGELOG.md updated with ${version}`);
console.error(`Path: ${updatedPath}`);
// Output the entry for verification
console.log(entry);
}
// Run if executed directly
if (require.main === module) {
main();
}
module.exports = {
extractSections,
formatChangelogEntry,
updateChangelog,
createInitialChangelog,
};
#!/usr/bin/env node
/**
* Shared utilities for release-notes skill
* Provides security-critical functions used across multiple scripts
*/
const fs = require('fs');
const path = require('path');
/**
* Validate output path is within allowed directory
* Prevents path traversal attacks and symlink bypass
* @param {string} filepath - The output file path to validate
* @param {string} allowedDir - The allowed base directory (defaults to cwd)
* @returns {string} The resolved safe path
*/
function validateOutputPath(filepath, allowedDir = process.cwd()) {
const resolved = path.resolve(filepath);
const allowed = path.resolve(allowedDir);
// Resolve symlinks for security (defense in depth)
let realResolved, realAllowed;
try {
realAllowed = fs.realpathSync(allowed);
// For new files, check parent directory exists
const parentDir = path.dirname(resolved);
if (fs.existsSync(parentDir)) {
realResolved = path.join(fs.realpathSync(parentDir), path.basename(resolved));
} else {
// Parent doesn't exist yet, use resolved path
realResolved = resolved;
}
} catch (err) {
console.error(`Error: Cannot resolve path: ${err.message}`);
process.exit(1);
}
// Validate path is within allowed directory
if (!realResolved.startsWith(realAllowed + path.sep) && realResolved !== realAllowed) {
console.error(`Error: Output path must be within project directory`);
console.error(` Allowed: ${realAllowed}`);
console.error(` Attempted: ${realResolved}`);
process.exit(1);
}
return realResolved;
}
/**
* Validate stdin/input has content
* @param {string} content - The input content to validate
* @param {string} scriptName - Name of the calling script (for error messages)
* @returns {string} The validated content
*/
function validateInputNotEmpty(content, scriptName = 'script') {
if (!content || !content.trim()) {
console.error(`Error: No input provided to ${scriptName}`);
console.error('Pipe content via stdin or provide input file');
process.exit(1);
}
return content;
}
/**
* Escape markdown special characters in text
* Prevents formatting issues from commit messages containing markdown syntax
* @param {string} text - The text to escape
* @returns {string} The escaped text
*/
function escapeMarkdown(text) {
if (!text) return '';
return text.replace(/([*_`[\]()#])/g, '\\$1');
}
/**
* Bounds-check a numeric value
* @param {number} value - The value to check
* @param {number} min - Minimum allowed value
* @param {number} max - Maximum allowed value
* @param {number} defaultValue - Default if value is NaN
* @returns {number} The bounded value
*/
function boundsCheck(value, min, max, defaultValue) {
if (isNaN(value)) return defaultValue;
return Math.max(min, Math.min(max, value));
}
module.exports = {
validateOutputPath,
validateInputNotEmpty,
escapeMarkdown,
boundsCheck,
};
#!/usr/bin/env node
/**
* Validate release notes quality with scoring
* Usage: node validate-notes.cjs <release-notes.md> [--threshold 70] [--json]
*
* Validates release notes against quality rules and returns a score
*/
const fs = require('fs');
const path = require('path');
const { validateInputNotEmpty, boundsCheck } = require('./utils.cjs');
const DEFAULT_THRESHOLD = 70;
/**
* Validation rules with weights (total should equal 100)
*/
const RULES = {
summary_exists: {
weight: 15,
name: 'Summary Section',
check: (content) => content.includes('## Summary'),
suggestion: 'Add a "## Summary" section describing the release',
},
summary_not_empty: {
weight: 10,
name: 'Summary Content',
check: (content) => {
const match = content.match(/## Summary\s*\n+([\s\S]*?)(?=\n##|$)/);
return match && match[1].trim().length > 20;
},
suggestion: 'Summary should have meaningful content (at least 20 characters)',
},
has_version: {
weight: 10,
name: 'Version Present',
check: (content) => /\*\*Version:\*\*\s*v?\d+\.\d+\.\d+/.test(content),
suggestion: 'Add version number in format "**Version:** vX.Y.Z"',
},
has_date: {
weight: 5,
name: 'Date Present',
check: (content) => /\*\*Date:\*\*\s*\d{4}-\d{2}-\d{2}/.test(content),
suggestion: 'Add date in format "**Date:** YYYY-MM-DD"',
},
features_documented: {
weight: 10,
name: 'Features Documented',
check: (content) => {
// If no features section, that's okay (might not have features)
if (!content.includes("## What's New")) return true;
// If there is a features section, it should have bullet points
const match = content.match(/## What's New\s*\n+([\s\S]*?)(?=\n##|$)/);
return match && match[1].includes('- **');
},
suggestion: 'Feature items should be formatted as "- **Feature description**"',
},
fixes_documented: {
weight: 10,
name: 'Bug Fixes Documented',
check: (content) => {
if (!content.includes('## Bug Fixes')) return true;
const match = content.match(/## Bug Fixes\s*\n+([\s\S]*?)(?=\n##|$)/);
return match && match[1].includes('- **');
},
suggestion: 'Bug fix items should be formatted as "- **Fix description**"',
},
no_broken_links: {
weight: 10,
name: 'No Broken Links',
check: (content) => !content.match(/\[.*?\]\(\s*\)/),
suggestion: 'Remove empty link references [text]()',
},
no_todo_markers: {
weight: 5,
name: 'No TODO Markers',
check: (content) => !/\bTODO\b|\bFIXME\b|\bXXX\b/i.test(content),
suggestion: 'Remove TODO/FIXME markers before publishing',
},
contributors_listed: {
weight: 10,
name: 'Contributors Listed',
check: (content) => content.includes('## Contributors'),
suggestion: 'Add a "## Contributors" section listing contributors',
},
proper_heading_hierarchy: {
weight: 5,
name: 'Heading Hierarchy',
check: (content) => {
// Should start with H1, then H2s, no H4+ in main content
const hasH1 = content.match(/^# /m);
const hasH4Plus = content.match(/^#{4,} /m);
return hasH1 && !hasH4Plus;
},
suggestion: 'Use proper heading hierarchy (H1 for title, H2 for sections)',
},
no_placeholder_text: {
weight: 5,
name: 'No Placeholder Text',
check: (content) => !/\[.*?placeholder.*?\]|\{.*?placeholder.*?\}/i.test(content),
suggestion: 'Replace placeholder text with actual content',
},
technical_details_collapsed: {
weight: 5,
name: 'Technical Details Collapsed',
check: (content) => {
if (!content.includes('## Technical Details')) return true;
return content.includes('<details>') && content.includes('</details>');
},
suggestion: 'Wrap technical details in <details> tags for better readability',
},
};
/**
* Validate release notes content
*/
function validateNotes(content, customRules = null) {
const rules = customRules || RULES;
const results = [];
let totalWeight = 0;
let passedWeight = 0;
for (const [ruleId, rule] of Object.entries(rules)) {
const passed = rule.check(content);
totalWeight += rule.weight;
if (passed) {
passedWeight += rule.weight;
}
results.push({
id: ruleId,
name: rule.name,
weight: rule.weight,
passed,
suggestion: passed ? null : rule.suggestion,
});
}
const score = Math.round((passedWeight / totalWeight) * 100);
return {
score,
passedWeight,
totalWeight,
results,
};
}
/**
* Format validation results for console output
*/
function formatResults(validation, threshold) {
const { score, results } = validation;
const passed = score >= threshold;
let output = '';
output += `\n${'='.repeat(50)}\n`;
output += `RELEASE NOTES VALIDATION\n`;
output += `${'='.repeat(50)}\n\n`;
output += `Score: ${score}/100 (threshold: ${threshold})\n`;
output += `Status: ${passed ? 'PASSED' : 'FAILED'}\n\n`;
output += `${'─'.repeat(50)}\n`;
output += `RULE RESULTS\n`;
output += `${'─'.repeat(50)}\n\n`;
const failedRules = results.filter((r) => !r.passed);
const passedRules = results.filter((r) => r.passed);
if (failedRules.length > 0) {
output += `FAILED (${failedRules.length}):\n`;
for (const rule of failedRules) {
output += ` ✗ ${rule.name} (-${rule.weight} points)\n`;
if (rule.suggestion) {
output += ` → ${rule.suggestion}\n`;
}
}
output += '\n';
}
output += `PASSED (${passedRules.length}):\n`;
for (const rule of passedRules) {
output += ` ✓ ${rule.name} (+${rule.weight} points)\n`;
}
output += `\n${'='.repeat(50)}\n`;
return output;
}
/**
* Load custom rules from config file
*/
function loadCustomRules(configPath) {
try {
if (fs.existsSync(configPath)) {
const yaml = require('js-yaml');
const config = yaml.load(fs.readFileSync(configPath, 'utf-8'));
if (config.validation && config.validation.rules) {
// Merge custom weights with default rules
const customRules = { ...RULES };
for (const [ruleId, ruleConfig] of Object.entries(config.validation.rules)) {
if (customRules[ruleId] && ruleConfig.weight !== undefined) {
customRules[ruleId] = { ...customRules[ruleId], weight: ruleConfig.weight };
}
}
return customRules;
}
}
} catch (error) {
console.error(`Warning: Could not load config: ${error.message}`);
}
return null;
}
/**
* Parse command line arguments
*/
function parseArgs(args) {
const options = {
inputFile: null,
threshold: DEFAULT_THRESHOLD,
json: false,
configPath: null,
};
for (let i = 0; i < args.length; i++) {
if (args[i] === '--threshold' && args[i + 1]) {
const rawThreshold = parseInt(args[++i], 10);
options.threshold = boundsCheck(rawThreshold, 0, 100, DEFAULT_THRESHOLD);
if (isNaN(rawThreshold)) {
console.error(`Warning: Invalid threshold value, using default ${DEFAULT_THRESHOLD}`);
}
} else if (args[i] === '--json') {
options.json = true;
} else if (args[i] === '--config' && args[i + 1]) {
options.configPath = args[++i];
} else if (!args[i].startsWith('--')) {
options.inputFile = args[i];
}
}
return options;
}
/**
* Main function
*/
function main() {
const args = process.argv.slice(2);
const options = parseArgs(args);
let content = '';
// Read from file or stdin
if (options.inputFile && fs.existsSync(options.inputFile)) {
content = fs.readFileSync(options.inputFile, 'utf-8');
} else if (!process.stdin.isTTY) {
content = fs.readFileSync(0, 'utf-8');
} else {
console.error('Usage: node validate-notes.cjs <release-notes.md> [--threshold 70] [--json]');
console.error(' cat release-notes.md | node validate-notes.cjs --threshold 70');
process.exit(1);
}
// Validate input not empty
validateInputNotEmpty(content, 'validate-notes');
// Load custom rules if config provided
const customRules = options.configPath ? loadCustomRules(options.configPath) : null;
// Validate
const validation = validateNotes(content, customRules);
const passed = validation.score >= options.threshold;
// Output results
if (options.json) {
const output = {
...validation,
threshold: options.threshold,
passed,
};
console.log(JSON.stringify(output, null, 2));
} else {
console.log(formatResults(validation, options.threshold));
}
// Exit with error code if validation failed
if (!passed) {
process.exit(1);
}
}
// Run if executed directly
if (require.main === module) {
main();
}
module.exports = {
validateNotes,
formatResults,
RULES,
};
Release Notes Skill
<!-- Hand-synced from .claude/config/release-notes-template.yaml — keep in step when YAML changes --> <!-- No generator script exists; edit this file directly when commit_mapping or sections drift -->
Generate or update release notes for project features.
Trigger Keywords
- "release notes", "changelog", "release documentation"
- "add release note", "update release notes"
- "document changes", "PR summary"
Input Sources
1. PR Branch Comparison: Compare changes between branches 2. Feature Documentation: Use feature docs as source 3. User Instructions: Manual feature investigation
Output Location (Auto-Save)
Both files are saved automatically:
1. Individual note: docs/release-notes/YYMMDD-{slug}.md 2. Aggregated log: CHANGELOG.md (prepended)
Usage
$release-notes feature-name --source=docs/business-features/{Module}/detailed-features/README.{Feature}.md
$release-notes employee-export --compare=develop:main
$release-notes authentication --investigateRelease Note Sections
- Summary: One paragraph end-user summary
- New Features: Entirely new capabilities
- Improvements: Enhancements to existing features
- Bug Fixes: Corrections of incorrect behavior
- Breaking Changes: Changes requiring user action
- Technical Details: Implementation info for developers
- Related Documentation: Links to feature docs, API refs
Commit Type Mapping
| Commit Type | Category |
|---|---|
feat | features |
fix | fixes |
refactor | improvements |
perf | improvements |
feature | features |
platform | features |
candidate_app | fixes |
growth | features |
talents | features |
surveys | features |
insights | features |
accounts | features |
docs, chore, style, test, ci, build, devtools map to null (excluded from release notes).
Guidelines
| Principle | Practice |
|---|---|
| User-Focused | Write for end-users, not developers |
| Concise | One sentence per item |
| Categorized | Group by type (features/fixes/improvements) |
| Linked | Reference related documentation |
| Dated | Always include date in filename |
Integration
This skill integrates with:
.claude/skills/release-notes/lib/parse-commits.cjs- Commit parser.claude/skills/release-notes/lib/render-template.cjs- Note renderer.claude/config/release-notes-template.yaml- Template source
---
_Template version: 1.0.0_