Compare commits

...
14 changed files with 1404 additions and 156 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"claude-dev": minor
---
Adding automation for bumping version number, generating release notes, and generating changelists
-26
View File
@@ -1,26 +0,0 @@
changesDir: .changes
unreleasedDir: unreleased
headerPath: header.tpl.md
changelogPath: CHANGELOG.md
versionExt: md
versionFormat: '## {{.Version}} - {{.Time.Format "2006-01-02"}}'
kindFormat: "### {{.Kind}}"
changeFormat: "* {{.Body}}"
kinds:
- label: Added
auto: minor
- label: Changed
auto: major
- label: Deprecated
auto: minor
- label: Removed
auto: major
- label: Fixed
auto: patch
- label: Security
auto: patch
newlines:
afterChangelogHeader: 1
beforeChangelogVersion: 1
endOfVersion: 1
envPrefix: CHANGIE_
+173
View File
@@ -0,0 +1,173 @@
# Release Notes Scripts
This directory contains Python scripts for managing release notes, version bumping, and changelog updates.
## Development Setup
### Prerequisites
- Python 3.10 or higher
- [uv](https://github.com/astral-sh/uv) - Fast Python package installer
```bash
brew install uv
```
- [act](https://github.com/nektos/act) - Run GitHub Actions locally
```bash
brew install act
```
### Environment Variables
For local testing, you'll need:
- `OPENROUTER_API_KEY` - Your OpenRouter API key for release notes generation
### Setting Up Development Environment
1. Create and activate a virtual environment:
```bash
cd .github/scripts
uv venv
source .venv/bin/activate # On Unix/macOS
# or
.venv\Scripts\activate # On Windows
```
2. Install dependencies:
```bash
uv pip install -r requirements.txt
```
### Running Tests
With the virtual environment activated:
```bash
# Run all tests (including integration tests)
python -m pytest test_*.py -v --api-key=your_openrouter_api_key
# Run tests with coverage report
python -m pytest test_*.py -v --cov=. --cov-report=term-missing --api-key=your_openrouter_api_key
# Run specific test file
python -m pytest test_version_manager.py -v
# Run specific test
python -m pytest test_version_manager.py::TestVersionManager::test_bump_version -v
# Run unit tests only (excluding integration tests)
python -m pytest test_*.py -v --ignore=test_integration.py
# Run integration tests only
python -m pytest test_integration.py -v --api-key=your_openrouter_api_key
```
### Integration Testing
The test suite includes integration tests that verify:
1. Complete release flow with OpenRouter API calls
2. Error handling (rate limits, invalid keys)
3. Changelog updates and version management
Integration tests require a valid OpenRouter API key passed via the --api-key parameter. This ensures:
- Real API interactions are tested
- No reliance on environment variables
- Clear separation between unit and integration tests
- Explicit API key management
## Scripts Overview
### version_manager.py
Handles version bumping based on changesets. Determines the appropriate version bump (major, minor, patch) based on accumulated changes.
```bash
python version_manager.py --release-type release
python version_manager.py --release-type pre-release
```
### generate_release_notes.py
Generates release notes using OpenRouter's Claude model. Analyzes changesets and git history to create comprehensive release notes.
```bash
python generate_release_notes.py \
--release-type release \
--version v3.3.0 \
--changesets '[{"type":"major","content":"Added new feature"}]' \
--api-key your_openrouter_api_key
```
### overwrite_changeset_changelog.py
Updates CHANGELOG.md with new release notes, maintaining proper formatting and structure.
```bash
python overwrite_changeset_changelog.py \
--version v3.3.0 \
--content "Release notes content" \
--changelog-path CHANGELOG.md
```
## End-to-End Testing
### test-release.sh
Runs the complete release workflow locally using GitHub CLI, exactly as it would run in production:
```bash
# Run a test pre-release
./test-release.sh
```
This script triggers the publish workflow with pre-release mode, allowing you to verify:
- Version bumping from changesets
- Release notes generation
- Changelog updates
- Complete workflow integration
## Testing
The test suite includes:
- Unit tests for all core functionality
- Integration tests with real API calls
- Mock git commands and file operations
- Edge case handling
- Pre-release to release transitions
- Error scenarios
### Test Files
- `test_version_manager.py`: Tests version bumping logic
- `test_generate_release_notes.py`: Tests release notes generation
- `test_overwrite_changelog.py`: Tests changelog updating
- `test_integration.py`: End-to-end integration tests
## Command Line Arguments
### For Tests
- `--api-key`: Required for integration tests. Provides the OpenRouter API key.
### For Scripts
- `--release-type`: Type of release (release or pre-release)
- `--version`: Version number for the release
- `--changesets`: JSON string of changes
- `--content`: Release notes content
- `--changelog-path`: Path to changelog file
- `--github-output`: Optional, path for GitHub Actions output
- `--api-key`: Required for generate_release_notes.py, OpenRouter API key
## Adding New Tests
1. Create test file following the naming convention `test_*.py`
2. Use pytest fixtures for common setup
3. Mock external dependencies (git commands, file operations)
4. Include both success and error cases
5. Add to existing test suite
Example:
```python
def test_new_feature(self):
# Unit test example
result = my_function()
assert result == expected_value
def test_api_integration(self, api_key):
# Integration test example
result = my_api_function(api_key=api_key)
assert result is not None
+16
View File
@@ -0,0 +1,16 @@
import pytest
def pytest_addoption(parser):
parser.addoption(
"--api-key",
action="store",
help="API key for integration tests"
)
@pytest.fixture
def api_key(request):
"""Fixture to provide API key to tests."""
api_key = request.config.getoption("--api-key")
if not api_key:
pytest.skip("API key is required for integration tests")
return api_key
+233
View File
@@ -0,0 +1,233 @@
#!/usr/bin/env python3
"""
Release Notes Generator
This script generates release notes using OpenRouter's API with the Claude 3.5 Sonnet model.
It takes the changesets and git information as input and produces formatted release notes
suitable for both GitHub releases and VSCode marketplace.
Process:
1. Read changesets from input
2. Get git diff and commit information
3. Generate release notes using OpenRouter API
4. Format and output the notes
Command line arguments:
--github-output: Path to GitHub Actions output file
--changesets: JSON string of changesets
--version: Version being released
--release-type: Either 'release' or 'pre-release'
--api-key: OpenRouter API key for generating release notes
"""
import os
import sys
import json
import argparse
import subprocess
from typing import List, Dict, Optional
import requests
def parse_args():
parser = argparse.ArgumentParser(description="Generate release notes using OpenRouter API")
parser.add_argument(
"--github-output",
help="Path to GitHub Actions output file"
)
parser.add_argument(
"--changesets",
help="JSON string of changesets",
required=True
)
parser.add_argument(
"--version",
help="Version being released",
required=True
)
parser.add_argument(
"--release-type",
choices=["release", "pre-release"],
default="release",
help="Type of release"
)
parser.add_argument(
"--api-key",
help="OpenRouter API key",
required=True
)
return parser.parse_args()
def get_git_info(version: str) -> Dict[str, str]:
"""Get git diff and commit information since the last release."""
try:
# Always get changes since last regular release, ignoring pre-releases
tags = subprocess.check_output(
["git", "tag", "--sort=-v:refname"],
text=True
).strip().split("\n")
# Filter out pre-releases to get the last regular release
regular_releases = [tag for tag in tags if not tag.endswith("-pre")]
last_tag = regular_releases[0] if regular_releases else "v0.0.0"
print(f"Generating release notes with changes since {last_tag}")
# Get commit messages
commit_log = subprocess.check_output(
["git", "log", f"{last_tag}...HEAD", "--pretty=format:%s"],
text=True
).strip()
# Get diff stats
diff_stats = subprocess.check_output(
["git", "diff", "--stat", f"{last_tag}...HEAD"],
text=True
).strip()
return {
"commit_log": commit_log,
"diff_stats": diff_stats,
"last_tag": last_tag
}
except subprocess.CalledProcessError as e:
print(f"Error getting git info: {str(e)}")
return {
"commit_log": "",
"diff_stats": "",
"last_tag": "v0.0.0"
}
def generate_prompt(changesets: List[Dict], git_info: Dict[str, str], version: str, is_prerelease: bool) -> str:
"""Generate the prompt for the OpenRouter API."""
changes_by_type = {
"major": [],
"minor": [],
"patch": []
}
for change in changesets:
change_type = change["type"].lower()
if "major" in change_type:
changes_by_type["major"].append(change["content"])
elif "minor" in change_type:
changes_by_type["minor"].append(change["content"])
elif "patch" in change_type:
changes_by_type["patch"].append(change["content"])
changes_text = "\n\n".join([
f"Major Changes:\n{chr(10).join(changes_by_type['major'])}" if changes_by_type["major"] else "",
f"Minor Changes:\n{chr(10).join(changes_by_type['minor'])}" if changes_by_type["minor"] else "",
f"Patch Changes:\n{chr(10).join(changes_by_type['patch'])}" if changes_by_type["patch"] else ""
]).strip()
return f"""Please generate release notes for version {version} of the Cline VSCode extension.
{'''IMPORTANT: This is a pre-release version. The release notes MUST include "(Pre-release)" in the title.
Example title: "## New Features Added (Pre-release)"''' if is_prerelease else ''}
Changesets:
{changes_text}
Git Information:
Commit Messages:
{git_info['commit_log']}
Changes Overview:
{git_info['diff_stats']}
Please format the release notes in markdown with:
1. A short, descriptive title (max 8 words) with heading level 2
2. A brief summary paragraph explaining the key changes and their impact
3. Optional sections (include only if relevant):
- 🚀 New Features & Improvements (heading level 3)
- 🐛 Bugs Fixed (heading level 3)
- 🔧 Other Updates (heading level 3)
Focus on user-facing changes and their benefits. Ignore version bumps, dependency updates, and minor syntax changes.
Be concise but informative, highlighting the most important changes first."""
def generate_release_notes(prompt: str, api_key: str = None) -> str:
"""Generate release notes using OpenRouter API with Claude 3.5 Sonnet.
Args:
prompt: The prompt to send to the API
api_key: OpenRouter API key.
"""
if not api_key:
raise Exception("API key not provided and OPENROUTER_API_KEY environment variable not set")
headers = {
"Authorization": f"Bearer {api_key}",
"HTTP-Referer": "https://github.com/cline/cline",
"Content-Type": "application/json"
}
data = {
"model": "anthropic/claude-3.5-sonnet",
"messages": [{
"role": "user",
"content": prompt
}],
"temperature": 0.7
}
try:
response = requests.post(
"https://openrouter.ai/api/v1/chat/completions",
headers=headers,
json=data
)
if response.status_code == 429:
raise Exception("Rate limit exceeded")
elif response.status_code == 401:
raise Exception("Invalid API key")
response.raise_for_status()
return response.json()["choices"][0]["message"]["content"]
except requests.exceptions.RequestException as e:
error_msg = f"Error calling OpenRouter API: {str(e)}"
if hasattr(e, 'response'):
error_msg += f"\nResponse: {e.response.text}"
raise Exception(error_msg)
def main():
args = parse_args()
# Parse changesets
try:
changesets = json.loads(args.changesets)
except json.JSONDecodeError:
print("Error: Invalid changesets JSON")
sys.exit(1)
# Get git information
git_info = get_git_info(args.version)
# Generate prompt
prompt = generate_prompt(
changesets,
git_info,
args.version,
args.release_type == "pre-release"
)
# Generate release notes
try:
release_notes = generate_release_notes(prompt, api_key=args.api_key)
print("Generated Release Notes:")
print("-" * 80)
print(release_notes)
print("-" * 80)
# Write outputs for GitHub Actions
if args.github_output:
with open(args.github_output, "a") as f:
f.write(f"release_notes<<EOF\n{release_notes}\nEOF\n")
except Exception as e:
print(f"Error: {str(e)}")
sys.exit(1)
if __name__ == "__main__":
main()
@@ -1,103 +0,0 @@
"""
This script updates a specific version's release notes section in CHANGELOG.md with new content
or reformats existing content.
The script:
1. Takes a version number, changelog path, and optionally new content as input from environment variables
2. Finds the section in the changelog for the specified version
3. Either:
a) Replaces the content with new content if provided, or
b) Reformats existing content by:
- Removing the first two lines of the changeset format
- Ensuring version numbers are wrapped in square brackets
4. Writes the updated changelog back to the file
Environment Variables:
CHANGELOG_PATH: Path to the changelog file (defaults to 'CHANGELOG.md')
VERSION: The version number to update/format
PREV_VERSION: The previous version number (used to locate section boundaries)
NEW_CONTENT: Optional new content to insert for this version
"""
#!/usr/bin/env python3
import os
import sys
CHANGELOG_PATH = os.environ.get("CHANGELOG_PATH", "CHANGELOG.md")
VERSION = os.environ['VERSION']
PREV_VERSION = os.environ.get("PREV_VERSION", "")
NEW_CONTENT = os.environ.get("NEW_CONTENT", "")
def overwrite_changelog_section(changelog_text: str, new_content: str):
# Find the section for the specified version
version_pattern = f"## {VERSION}\n"
bracketed_version_pattern = f"## [{VERSION}]\n"
prev_version_pattern = f"## [{PREV_VERSION}]\n"
print(f"latest version: {VERSION}")
print(f"prev_version: {PREV_VERSION}")
# Try both unbracketed and bracketed version patterns
version_index = changelog_text.find(version_pattern)
if version_index == -1:
version_index = changelog_text.find(bracketed_version_pattern)
if version_index == -1:
# If version not found, add it at the top (after the first line)
first_newline = changelog_text.find('\n')
if first_newline == -1:
# If no newline found, just prepend
return f"## [{VERSION}]\n\n{changelog_text}"
return f"{changelog_text[:first_newline + 1]}## [{VERSION}]\n\n{changelog_text[first_newline + 1:]}"
else:
# Using bracketed version
version_pattern = bracketed_version_pattern
notes_start_index = version_index + len(version_pattern)
notes_end_index = changelog_text.find(prev_version_pattern, notes_start_index) if PREV_VERSION and prev_version_pattern in changelog_text else len(changelog_text)
if new_content:
return changelog_text[:notes_start_index] + f"{new_content}\n" + changelog_text[notes_end_index:]
else:
changeset_lines = changelog_text[notes_start_index:notes_end_index].split("\n")
# Ensure we have at least 2 lines before removing them
if len(changeset_lines) < 2:
print("Warning: Changeset content has fewer than 2 lines")
parsed_lines = "\n".join(changeset_lines)
else:
# Remove the first two lines from the regular changeset format, ex: \n### Patch Changes
parsed_lines = "\n".join(changeset_lines[2:])
updated_changelog = changelog_text[:notes_start_index] + parsed_lines + changelog_text[notes_end_index:]
# Ensure version number is bracketed
updated_changelog = updated_changelog.replace(f"## {VERSION}", f"## [{VERSION}]")
return updated_changelog
try:
print(f"Reading changelog from: {CHANGELOG_PATH}")
with open(CHANGELOG_PATH, 'r') as f:
changelog_content = f.read()
print(f"Changelog content length: {len(changelog_content)} characters")
print("First 200 characters of changelog:")
print(changelog_content[:200])
print("----------------------------------------------------------------------------------")
new_changelog = overwrite_changelog_section(changelog_content, NEW_CONTENT)
print("New changelog content:")
print("----------------------------------------------------------------------------------")
print(new_changelog)
print("----------------------------------------------------------------------------------")
print(f"Writing updated changelog back to: {CHANGELOG_PATH}")
with open(CHANGELOG_PATH, 'w') as f:
f.write(new_changelog)
print(f"{CHANGELOG_PATH} updated successfully!")
except FileNotFoundError:
print(f"Error: Changelog file not found at {CHANGELOG_PATH}")
sys.exit(1)
except Exception as e:
print(f"Error updating changelog: {str(e)}")
print(f"Current working directory: {os.getcwd()}")
sys.exit(1)
+5
View File
@@ -0,0 +1,5 @@
requests>=2.31.0
pytest>=8.0.0
pytest-cov>=4.1.0
pytest-integration>=0.2.3
coverage>=7.4.0
@@ -0,0 +1,145 @@
#!/usr/bin/env python3
import os
import unittest
from unittest.mock import patch, MagicMock
import json
from generate_release_notes import generate_release_notes, generate_prompt, parse_args
class TestGenerateReleaseNotes(unittest.TestCase):
def setUp(self):
self.test_changesets = [
{
"type": "minor",
"content": "Added new feature"
},
{
"type": "patch",
"content": "Fixed bug"
}
]
self.test_git_info = {
"commit_log": "test commit",
"diff_stats": "1 file changed",
"last_tag": "v3.2.0"
}
self.test_version = "v3.3.0"
def test_parse_args(self):
with patch('sys.argv', ['script.py',
'--changesets', json.dumps(self.test_changesets),
'--version', self.test_version,
'--release-type', 'release',
'--api-key', 'test-api-key'
]):
args = parse_args()
self.assertEqual(args.changesets, json.dumps(self.test_changesets))
self.assertEqual(args.version, self.test_version)
self.assertEqual(args.release_type, 'release')
self.assertEqual(args.api_key, 'test-api-key')
def test_generate_prompt(self):
# Test regular release prompt
prompt = generate_prompt(
self.test_changesets,
self.test_git_info,
self.test_version,
is_prerelease=False
)
self.assertIn(self.test_version, prompt)
self.assertIn("Added new feature", prompt)
self.assertIn("Fixed bug", prompt)
self.assertIn("test commit", prompt)
self.assertIn("1 file changed", prompt)
self.assertNotIn("(Pre-release)", prompt)
# Test pre-release prompt
pre_version = f"{self.test_version}-pre"
pre_prompt = generate_prompt(
self.test_changesets,
self.test_git_info,
pre_version,
is_prerelease=True
)
self.assertIn(pre_version, pre_prompt)
self.assertIn("(Pre-release)", pre_prompt)
self.assertIn("Added new feature", pre_prompt)
self.assertIn("Fixed bug", pre_prompt)
@patch('requests.post')
def test_generate_release_notes_success(self, mock_post):
# Test regular release notes
mock_response = MagicMock()
mock_response.json.return_value = {
"choices": [{
"message": {
"content": "Test release notes"
}
}]
}
mock_post.return_value = mock_response
prompt = generate_prompt(
self.test_changesets,
self.test_git_info,
self.test_version,
is_prerelease=False
)
result = generate_release_notes(prompt, api_key="mock-api-key")
self.assertEqual(result, "Test release notes")
# Test pre-release notes
mock_response.json.return_value = {
"choices": [{
"message": {
"content": "Test pre-release notes"
}
}]
}
mock_post.return_value = mock_response
pre_prompt = generate_prompt(
self.test_changesets,
self.test_git_info,
f"{self.test_version}-pre",
is_prerelease=True
)
pre_result = generate_release_notes(pre_prompt, api_key="mock-api-key")
self.assertEqual(pre_result, "Test pre-release notes")
def test_generate_release_notes_no_api_key(self):
prompt = generate_prompt(
self.test_changesets,
self.test_git_info,
self.test_version,
is_prerelease=False
)
with self.assertRaises(Exception) as context:
generate_release_notes(prompt)
self.assertIn("API key not provided", str(context.exception))
@patch('requests.post')
def test_generate_release_notes_api_error(self, mock_post):
# Mock API error
mock_post.side_effect = Exception("API Error")
prompt = generate_prompt(
self.test_changesets,
self.test_git_info,
self.test_version,
is_prerelease=False
)
with self.assertRaises(Exception) as context:
generate_release_notes(prompt, api_key="mock-api-key")
self.assertIn("API Error", str(context.exception))
if __name__ == '__main__':
unittest.main()
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env python3
"""
Integration tests for release notes automation.
Tests the interaction between components and external services.
Makes real API calls to OpenRouter for thorough testing.
"""
import os
import shutil
import tempfile
import unittest
from unittest.mock import patch, MagicMock
from generate_release_notes import generate_release_notes, generate_prompt
import pytest
class TestIntegration:
@pytest.fixture(autouse=True)
def setup_teardown(self, request):
# Setup
self.test_dir = tempfile.mkdtemp()
self.original_cwd = os.getcwd()
os.chdir(self.test_dir)
# Create test changesets directory
self.changeset_dir = os.path.join(self.test_dir, ".changeset")
os.makedirs(self.changeset_dir)
# Create test changesets
self.changesets = [
{
"type": "minor",
"content": "Added new browser automation feature"
},
{
"type": "patch",
"content": "Fixed issue with file watching"
}
]
# Write test changeset files
for i, change in enumerate(self.changesets):
path = os.path.join(self.changeset_dir, f"change-{i}.md")
with open(path, "w") as f:
f.write(f"---\n{change['type']}\n{change['content']}")
# Setup test environment
self.test_version = "v3.3.0"
self.test_changelog = os.path.join(self.test_dir, "CHANGELOG.md")
# Create initial changelog
with open(self.test_changelog, "w") as f:
f.write("# Changelog\n\n## [v3.2.0]\n\nPrevious release notes here.\n")
# Setup common test data
self.git_info = {
"commit_log": "test commit",
"diff_stats": "1 file changed",
"last_tag": "v3.2.0"
}
yield
# Teardown
os.chdir(self.original_cwd)
shutil.rmtree(self.test_dir)
def generate_prompt_for_test(self, git_info=None):
"""Helper method to generate prompt with default or custom git info."""
return generate_prompt(
self.changesets,
git_info or self.git_info,
self.test_version,
is_prerelease=False
)
@patch('subprocess.check_output')
def test_complete_release_flow(self, mock_git, api_key):
"""Test the complete release flow with live API calls."""
# Mock git commands
mock_git.side_effect = [
"v3.2.0".encode(), # get_last_release_tag
"\n".join(os.listdir(self.changeset_dir)).encode() # get_changesets_since_tag
]
# Generate and verify release notes
prompt = self.generate_prompt_for_test()
release_notes = generate_release_notes(prompt, api_key=api_key)
assert release_notes is not None
assert "browser automation" in release_notes.lower()
# Verify release notes content
assert "browser automation" in release_notes.lower()
@patch('subprocess.check_output')
def test_pre_release_flow(self, mock_git, api_key):
"""Test the pre-release flow with live API calls."""
# Create additional changeset for pre-release
pre_changeset = {
"type": "minor",
"content": "Added experimental feature"
}
path = os.path.join(self.changeset_dir, "pre-change.md")
with open(path, "w") as f:
f.write(f"---\n{pre_changeset['type']}\n{pre_changeset['content']}")
# Mock git commands for pre-release
mock_git.side_effect = [
"v3.2.0".encode(), # get_last_release_tag
"\n".join(os.listdir(self.changeset_dir)).encode() # get_changesets_since_tag
]
# Generate pre-release notes
prompt = generate_prompt(
self.changesets + [pre_changeset],
self.git_info,
f"{self.test_version}-pre",
is_prerelease=True
)
pre_release_notes = generate_release_notes(prompt, api_key=api_key)
# Verify pre-release notes
assert pre_release_notes is not None
assert "experimental feature" in pre_release_notes.lower()
assert "(pre-release)" in pre_release_notes.lower()
@patch('subprocess.check_output')
def test_pre_release_to_release_flow(self, mock_git, api_key):
"""Test converting a pre-release to a full release."""
# First create a pre-release
pre_version = f"{self.test_version}-pre"
pre_prompt = generate_prompt(
self.changesets,
self.git_info,
pre_version,
is_prerelease=True
)
pre_release_notes = generate_release_notes(pre_prompt, api_key=api_key)
assert "(pre-release)" in pre_release_notes.lower()
# Mock git commands showing no changes since pre-release
mock_git.side_effect = [
f"{pre_version}\nv3.2.0".encode(), # get_last_release_tag with pre
"".encode(), # no changesets since pre-release
"v3.2.0".encode(), # get_last_release_tag without pre
"\n".join(os.listdir(self.changeset_dir)).encode() # all changesets since last regular release
]
# Generate full release notes
release_prompt = generate_prompt(
self.changesets,
self.git_info,
self.test_version,
is_prerelease=False
)
release_notes = generate_release_notes(release_prompt, api_key=api_key)
# Verify full release notes include all changes
assert release_notes is not None
assert "browser automation" in release_notes.lower()
assert "(pre-release)" not in release_notes.lower()
def test_error_handling(self, api_key):
"""Test error handling in the release flow."""
empty_git_info = {
"commit_log": "",
"diff_stats": "",
"last_tag": "v3.2.0"
}
# Test API errors
with patch('requests.post') as mock_post:
mock_post.side_effect = Exception("API Error")
with pytest.raises(Exception, match="API Error"):
prompt = self.generate_prompt_for_test(empty_git_info)
generate_release_notes(prompt, api_key=api_key)
# Test rate limiting
with patch('requests.post') as mock_post:
mock_post.return_value.status_code = 429
with pytest.raises(Exception, match="Rate limit exceeded"):
prompt = self.generate_prompt_for_test(empty_git_info)
generate_release_notes(prompt, api_key=api_key)
# Test invalid API key
with patch('requests.post') as mock_post:
mock_post.return_value.status_code = 401
with pytest.raises(Exception, match="Invalid API key"):
prompt = self.generate_prompt_for_test(empty_git_info)
generate_release_notes(prompt, api_key=api_key)
if __name__ == '__main__':
unittest.main()
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""
Unit tests for version_manager.py
Tests the version bumping logic and changeset handling.
Mock git commands to test different scenarios.
"""
import unittest
from unittest.mock import patch, MagicMock
import tempfile
import os
import json
from version_manager import (
get_last_release_tag,
get_changesets_since_tag,
determine_version_bump,
bump_version,
parse_args,
overwrite_package_version
)
class TestVersionManager(unittest.TestCase):
def setUp(self):
# Create a temporary directory for test changesets
self.temp_dir = tempfile.mkdtemp()
def create_changeset_file(self, content: str) -> str:
"""Helper to create a test changeset file."""
with tempfile.NamedTemporaryFile(
mode='w',
suffix='.md',
dir=self.temp_dir,
delete=False
) as f:
f.write(content)
return f.name
@patch('subprocess.check_output')
def test_get_last_release_tag_no_tags(self, mock_check_output):
mock_check_output.return_value = "".encode()
tag, is_pre = get_last_release_tag()
self.assertEqual(tag, "v0.0.0")
self.assertFalse(is_pre)
@patch('subprocess.check_output')
def test_get_last_release_tag_with_pre(self, mock_check_output):
# Test with both pre-release and regular releases
mock_check_output.return_value = """v3.2.1-pre
v3.2.0
v3.1.0""".encode()
# When including pre-releases
tag, is_pre = get_last_release_tag(include_pre=True)
self.assertEqual(tag, "v3.2.1-pre")
self.assertTrue(is_pre)
# When excluding pre-releases
tag, is_pre = get_last_release_tag(include_pre=False)
self.assertEqual(tag, "v3.2.0")
self.assertFalse(is_pre)
# Test with only pre-releases
mock_check_output.return_value = """v3.2.1-pre
v3.2.0-pre""".encode()
# Should still find pre-release when requested
tag, is_pre = get_last_release_tag(include_pre=True)
self.assertEqual(tag, "v3.2.1-pre")
self.assertTrue(is_pre)
# Should return v0.0.0 when no regular releases exist
tag, is_pre = get_last_release_tag(include_pre=False)
self.assertEqual(tag, "v0.0.0")
self.assertFalse(is_pre)
def test_determine_version_bump(self):
# Test major change
changesets = [{"type": "major"}, {"type": "minor"}, {"type": "patch"}]
bump_type, count = determine_version_bump(changesets)
self.assertEqual(bump_type, "major")
self.assertEqual(count, 3)
# Test minor change
changesets = [{"type": "minor"}, {"type": "patch"}]
bump_type, count = determine_version_bump(changesets)
self.assertEqual(bump_type, "minor")
self.assertEqual(count, 2)
# Test patch change
changesets = [{"type": "patch"}]
bump_type, count = determine_version_bump(changesets)
self.assertEqual(bump_type, "patch")
self.assertEqual(count, 1)
# Test no changes
changesets = []
bump_type, count = determine_version_bump(changesets)
self.assertEqual(bump_type, "patch")
self.assertEqual(count, 0)
def test_bump_version(self):
# Test major bump
self.assertEqual(bump_version("v1.2.3", "major"), "v2.0.0")
# Test minor bump
self.assertEqual(bump_version("v1.2.3", "minor"), "v1.3.0")
# Test patch bump
self.assertEqual(bump_version("v1.2.3", "patch"), "v1.2.4")
# Test without v prefix
self.assertEqual(bump_version("1.2.3", "minor"), "v1.3.0")
# Test with pre-release tag
self.assertEqual(bump_version("v1.2.3-pre", "minor"), "v1.3.0")
@patch('subprocess.check_output')
def test_get_changesets_since_tag(self, mock_check_output):
# Create test changeset files
major_change = self.create_changeset_file("""---
major
Added new feature X that changes the API""")
minor_change = self.create_changeset_file("""---
minor
Added new helper function""")
patch_change = self.create_changeset_file("""---
patch
Fixed bug in error handling""")
# Mock git diff to return our test files
mock_check_output.return_value = "\n".join([
major_change,
minor_change,
patch_change
]).encode()
changesets = get_changesets_since_tag("v1.0.0")
self.assertEqual(len(changesets), 3)
self.assertEqual(changesets[0]["type"], "major")
self.assertEqual(changesets[1]["type"], "minor")
self.assertEqual(changesets[2]["type"], "patch")
def test_parse_args(self):
with patch('sys.argv', ['script.py', '--release-type', 'pre-release']):
args = parse_args()
self.assertEqual(args.release_type, "pre-release")
with patch('sys.argv', ['script.py']):
args = parse_args()
self.assertEqual(args.release_type, "release") # default value
with patch('sys.argv', ['script.py', '--release-type', 'release']):
args = parse_args()
self.assertEqual(args.release_type, "release")
def test_overwrite_package_version(self):
# Create a temporary package.json
package_json = os.path.join(self.temp_dir, 'package.json')
initial_content = {
"name": "test-package",
"version": "1.0.0"
}
# Write initial content
with open(package_json, 'w') as f:
json.dump(initial_content, f, indent='\t')
f.write('\n')
# Test with v prefix
overwrite_package_version('v2.0.0', package_json)
with open(package_json, 'r') as f:
content = json.load(f)
self.assertEqual(content['version'], '2.0.0')
# Test without v prefix
overwrite_package_version('3.0.0', package_json)
with open(package_json, 'r') as f:
content = json.load(f)
self.assertEqual(content['version'], '3.0.0')
@patch('subprocess.check_output')
def test_pre_release_to_release_conversion(self, mock_check_output):
# Mock git tags to show a pre-release
mock_check_output.return_value = """v1.2.0-pre
v1.1.0""".encode()
# Create a temporary package.json
package_json = os.path.join(self.temp_dir, 'package.json')
with open(package_json, 'w') as f:
json.dump({"version": "1.2.0-pre"}, f)
# Mock no changesets since pre-release
mock_check_output.side_effect = [
"v1.2.0-pre\nv1.1.0".encode(), # for get_last_release_tag
"".encode() # for get_changesets_since_tag
]
# Test converting pre-release to release
with patch('sys.argv', ['script.py', '--release-type', 'release', '--package-path', package_json]):
args = parse_args()
# Get last tag including pre-releases
tag, is_pre = get_last_release_tag(include_pre=True)
self.assertEqual(tag, "v1.2.0-pre")
self.assertTrue(is_pre)
# Get changesets since pre-release
changesets = get_changesets_since_tag(tag)
self.assertEqual(len(changesets), 0)
# Version should be converted to release
new_version = tag.replace("-pre", "")
self.assertEqual(new_version, "v1.2.0")
if __name__ == '__main__':
unittest.main()
+246
View File
@@ -0,0 +1,246 @@
#!/usr/bin/env python3
"""
Version Manager Script
This script analyzes changesets since the last release to determine the appropriate version bump.
It follows semantic versioning rules and determines the minimum version bump needed based on
all accumulated changes.
Process:
1. Find the last release tag
2. Collect all changesets since that tag
3. Determine minimum version bump needed (major, minor, or patch)
4. Compare with pre-release version if in release mode
Environment Variables:
GITHUB_OUTPUT: Path to GitHub output file
RELEASE_TYPE: Either 'release' or 'pre-release'
"""
import os
import sys
import json
import subprocess
from typing import List, Tuple, Literal
import argparse
ChangeType = Literal["major", "minor", "patch"]
def parse_args():
parser = argparse.ArgumentParser(description="Determine version bump from changesets")
parser.add_argument(
"--release-type",
choices=["release", "pre-release"],
default="release",
help="Type of release to create"
)
parser.add_argument(
"--github-output",
help="Path to GitHub Actions output file"
)
parser.add_argument(
"--package-path",
default="package.json",
help="Path to package.json file"
)
return parser.parse_args()
def get_last_release_tag(include_pre: bool = False) -> tuple[str, bool]:
"""
Get the most recent release tag.
Args:
include_pre: Whether to consider pre-release tags
Returns:
Tuple of (tag, is_prerelease)
"""
try:
# Get all tags sorted by version
output = subprocess.check_output(
["git", "tag", "--sort=-v:refname"]
).decode().strip()
tags = output.split("\n") if output else []
if not tags:
return "v0.0.0", False
# First try to find a pre-release tag if we're looking for one
if include_pre:
pre_tags = [tag for tag in tags if tag.endswith("-pre")]
if pre_tags:
return pre_tags[0], True
# Then look for regular release tags
release_tags = [tag for tag in tags if not tag.endswith("-pre")]
if release_tags:
return release_tags[0], False
return "v0.0.0", False
except subprocess.CalledProcessError:
print("Error: Failed to get git tags")
return "v0.0.0", False
def get_changesets_since_tag(tag: str) -> List[dict]:
"""Get all changeset files added since the specified tag."""
try:
# Get list of changeset files
changeset_files = subprocess.check_output(
["git", "diff", "--name-only", f"{tag}...HEAD", ".changeset"]
).decode().strip().split("\n")
changesets = []
for file_path in changeset_files:
if file_path.endswith(".md") and not file_path.endswith("README.md"):
try:
with open(file_path, 'r') as f:
content = f.read()
# Parse changeset format
# First line is ---, second line has type, rest is content
lines = content.split("\n")
if len(lines) >= 3:
change_type = lines[1].strip().lower()
if any(t in change_type for t in ["major", "minor", "patch"]):
changesets.append({
"file": file_path,
"type": change_type,
"content": "\n".join(lines[2:]).strip()
})
except Exception as e:
print(f"Error reading changeset {file_path}: {str(e)}")
continue
return changesets
except subprocess.CalledProcessError:
print("Error: Failed to get changeset files")
return []
def determine_version_bump(changesets: List[dict]) -> Tuple[ChangeType, int]:
"""
Determine the minimum version bump needed based on all changesets.
Returns the bump type and count of changes.
"""
has_major = any("major" in c["type"].lower() for c in changesets)
has_minor = any("minor" in c["type"].lower() for c in changesets)
has_patch = any("patch" in c["type"].lower() for c in changesets)
if has_major:
return "major", len(changesets)
elif has_minor:
return "minor", len(changesets)
elif has_patch:
return "patch", len(changesets)
else:
return "patch", 0
def bump_version(current: str, bump_type: ChangeType) -> str:
"""
Bump the version number according to semver rules.
Example: 3.2.1 with minor bump becomes 3.3.0
"""
# Strip v prefix if present
version = current[1:] if current.startswith("v") else current
# Strip pre-release suffix if present
version = version.split("-")[0]
major, minor, patch = map(int, version.split("."))
if bump_type == "major":
return f"v{major + 1}.0.0"
elif bump_type == "minor":
return f"v{major}.{minor + 1}.0"
else: # patch
return f"v{major}.{minor}.{patch + 1}"
def overwrite_package_version(version: str, package_path: str = "package.json"):
"""
Overwrite version in package.json with our determined version.
Args:
version: New version to set (with or without v prefix)
package_path: Path to package.json file
"""
try:
# Read package.json
with open(package_path, 'r') as f:
package_data = json.load(f)
# Remove v prefix for package.json
version_no_prefix = version[1:] if version.startswith('v') else version
# Update version
package_data['version'] = version_no_prefix
# Write back to package.json
with open(package_path, 'w') as f:
json.dump(package_data, f, indent='\t')
f.write('\n') # Add newline at end of file
except Exception as e:
print(f"Error updating package.json: {str(e)}")
sys.exit(1)
def main():
args = parse_args()
# For releases, first check if there's a recent pre-release
if args.release_type == "release":
last_tag, is_pre = get_last_release_tag(include_pre=True)
if is_pre:
# If the most recent tag is a pre-release, check for changes since then
changesets = get_changesets_since_tag(last_tag)
if not changesets:
# No changes since pre-release, use it as the release
new_version = last_tag.replace("-pre", "")
print(f"No changes since pre-release {last_tag}, using {new_version}")
if args.github_output:
with open(args.github_output, "a") as f:
f.write(f"new_version={new_version}\n")
f.write("has_changes=false\n")
f.write("change_count=0\n")
f.write("changesets<<EOF\n[]\nEOF\n")
return
# Get last regular release tag
last_tag, _ = get_last_release_tag(include_pre=False)
print(f"Last release tag: {last_tag}")
# Get changesets since last release
changesets = get_changesets_since_tag(last_tag)
print(f"Found {len(changesets)} changesets")
# Determine version bump
bump_type, change_count = determine_version_bump(changesets)
print(f"Determined version bump: {bump_type}")
# Calculate new version
new_version = bump_version(last_tag, bump_type)
print(f"New version: {new_version}")
# Add pre-release suffix if needed
if args.release_type == "pre-release":
new_version = f"{new_version}-pre"
# Write outputs for GitHub Actions
if args.github_output:
with open(args.github_output, "a") as f:
f.write(f"new_version={new_version}\n")
f.write(f"has_changes={'true' if change_count > 0 else 'false'}\n")
f.write(f"change_count={change_count}\n")
# Write changesets as JSON for use in release notes
changesets_json = json.dumps([{
"type": c["type"],
"content": c["content"]
} for c in changesets])
f.write(f"changesets<<EOF\n{changesets_json}\nEOF\n")
# Overwrite version in package.json
if change_count > 0:
overwrite_package_version(new_version, args.package_path)
if __name__ == "__main__":
main()
+47
View File
@@ -0,0 +1,47 @@
name: "Update Draft Release"
on:
release:
types: [created]
permissions:
contents: write
jobs:
update-notes:
name: Update Draft Release Notes
runs-on: ubuntu-latest
# Run on both draft and published releases to ensure notes are always populated
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install Python Dependencies
run: |
pip install requests
- name: Generate Release Notes
id: notes
run: |
python .github/scripts/generate_release_notes.py \
--release-type ${{ contains(github.event.release.tag_name, '-pre') && 'pre-release' || 'release' }} \
--version ${{ github.event.release.tag_name }} \
--changesets '[]' \
--github-output $GITHUB_OUTPUT \
--api-key ${{ secrets.OPENROUTER_API_KEY }}
- name: Update Release Notes
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh api \
--method PATCH \
/repos/${{ github.repository }}/releases/${{ github.event.release.id }} \
-f body="${{ steps.notes.outputs.release_notes }}"
+111 -26
View File
@@ -22,8 +22,109 @@ jobs:
test:
uses: ./.github/workflows/test.yml
test-scripts:
name: Test Release Scripts
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install Dependencies
run: |
cd .github/scripts
python -m pip install uv
uv venv
source .venv/bin/activate
uv pip install -r requirements.txt
- name: Run Unit Tests
run: |
cd .github/scripts
source .venv/bin/activate
python -m pytest test_*.py -v --cov=. --cov-report=term-missing --ignore=test_integration.py
- name: Run Integration Tests
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
run: |
cd .github/scripts
source .venv/bin/activate
python -m pytest test_integration.py -v --api-key=$OPENROUTER_API_KEY
prepare:
needs: [test, test-scripts]
name: Prepare Release
runs-on: ubuntu-latest
outputs:
new_version: ${{ steps.version.outputs.new_version }}
has_changes: ${{ steps.version.outputs.has_changes }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.x"
- name: Install Python Dependencies
run: |
pip install requests
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20.15.1
- name: Install Dependencies
run: npm ci
- name: Generate Changelog
run: |
if [ "${{ github.event.inputs.release-type }}" = "pre-release" ]; then
# Enter prerelease mode and version
npx changeset pre enter next
npx changeset version
else
# Exit prerelease mode if we're in it
npx changeset pre exit
# Regular release
npx changeset version
fi
- name: Determine Version and Overwrite Changes
id: version
run: |
python .github/scripts/version_manager.py \
--release-type ${{ github.event.inputs.release-type }} \
--github-output $GITHUB_OUTPUT \
--package-path package.json
- name: Check for Changes
if: steps.version.outputs.has_changes != 'true'
run: |
echo "No changes detected since last release. Skipping release process."
exit 0
- name: Commit Changes
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
# Remove all changeset files except config.json and README.md
find .changeset -type f -not -name 'config.json' -not -name 'README.md' -delete
git add CHANGELOG.md package.json .changeset
git commit -m "Release ${{ steps.version.outputs.new_version }}"
git push
publish:
needs: test
needs: prepare
if: needs.prepare.outputs.has_changes == 'true'
name: Publish Extension
runs-on: ubuntu-latest
environment: publish
@@ -63,17 +164,9 @@ jobs:
- name: Install Publishing Tools
run: npm install -g vsce ovsx
- name: Get Version
id: get_version
run: |
VERSION=$(node -p "require('./package.json').version")
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Create Git Tag
id: create_tag
run: |
VERSION=v${{ steps.get_version.outputs.version }}
echo "tag=$VERSION" >> $GITHUB_OUTPUT
VERSION=${{ needs.prepare.outputs.new_version }}
echo "Tagging with $VERSION"
git tag "$VERSION"
git push origin "$VERSION"
@@ -84,32 +177,24 @@ jobs:
OVSX_PAT: ${{ secrets.OVSX_PAT }}
run: |
# Required to generate the .vsix
vsce package --out "cline-${{ steps.get_version.outputs.version }}.vsix"
VERSION=${{ needs.prepare.outputs.new_version }}
VERSION=${VERSION#v} # Remove v prefix for package name
vsce package --out "cline-${VERSION}.vsix"
if [ "${{ github.event.inputs.release-type }}" = "pre-release" ]; then
npm run publish:marketplace:prerelease
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
echo "Successfully published pre-release version ${VERSION} to VS Code Marketplace and Open VSX Registry"
else
npm run publish:marketplace
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
echo "Successfully published release version ${VERSION} to VS Code Marketplace and Open VSX Registry"
fi
# - name: Get Changelog Entry
# id: changelog
# uses: mindsers/changelog-reader-action@v2
# with:
# # This expects a standard Keep a Changelog format
# # "latest" means it will read whichever is the most recent version
# # set in "## [1.2.3] - 2025-01-28" style
# version: latest
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ steps.create_tag.outputs.tag }}
tag_name: ${{ needs.prepare.outputs.new_version }}
files: "*.vsix"
# body: ${{ steps.changelog.outputs.content }}
generate_release_notes: true
prerelease: ${{ github.event.inputs.release-type == 'pre-release' }}
draft: true # Create as draft so our draft-release.yml can populate the notes
prerelease: ${{ github.event.inputs.release-type == 'pre-release' }} # This determines if it's a pre-release when published
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+7 -1
View File
@@ -9,4 +9,10 @@ tmp
pnpm-lock.yaml
.clineignore
.clineignore
.coverage
# python
.venv/
.pytest_cache/
__pycache__/