mirror of
https://github.com/cline/cline.git
synced 2026-09-11 16:42:40 +08:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de60619ca6 | ||
|
|
1ef9fe3974 | ||
|
|
841b6d7165 | ||
|
|
e0c68fcef4 | ||
|
|
ebe84f1cf8 | ||
|
|
213daa79c9 | ||
|
|
f30e363d9f | ||
|
|
b9ed880ebe | ||
|
|
908ec69ef3 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"claude-dev": minor
|
||||
---
|
||||
|
||||
Adding automation for bumping version number, generating release notes, and generating changelists
|
||||
@@ -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_
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -9,4 +9,10 @@ tmp
|
||||
|
||||
pnpm-lock.yaml
|
||||
|
||||
.clineignore
|
||||
.clineignore
|
||||
.coverage
|
||||
|
||||
# python
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
__pycache__/
|
||||
|
||||
Reference in New Issue
Block a user