diff --git a/AGENTS.md b/AGENTS.md index 5148c3c..cf9d354 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -89,6 +89,10 @@ When editing or adding skills in this repo, follow these rules (and add new skil - Keep formatting consistent across skills. - If you change a skill’s behavior or scope, update its `README.md` (if present) accordingly. - If you change top-level documentation, ensure links still resolve. +- For Python test runs, prefer `uv sync --group test` followed by `uv run pytest -q`; the full suite depends on `openhands-sdk`, which is not available in the base environment. +- Agent-driven plugins (for example `plugins/pr-review` and `plugins/release-notes`) use `uv run --with openhands-sdk --with openhands-tools ...` and require an `LLM_API_KEY` in addition to `GITHUB_TOKEN`. + + ## When uncertain diff --git a/plugins/release-notes/README.md b/plugins/release-notes/README.md new file mode 100644 index 0000000..40f7c3c --- /dev/null +++ b/plugins/release-notes/README.md @@ -0,0 +1,266 @@ +# Release Notes Generator Plugin + +Automated release notes generation using OpenHands agents. This plugin provides GitHub workflows that generate consistent, well-structured release notes when release tags are pushed. + +## Quick Start + +Copy the workflow file to your repository: + +```bash +mkdir -p .github/workflows +curl -o .github/workflows/release-notes.yml \ + https://raw.githubusercontent.com/OpenHands/extensions/main/plugins/release-notes/workflows/release-notes.yml +``` + +Then configure the required secrets (see [Installation](#installation) below). + +## Features + +- **Automatic Tag Detection**: Automatically finds the previous release tag to determine the commit range +- **Agent-Based Summaries**: Uses an OpenHands agent to judge significance, merge related PRs, and decide what is worth mentioning +- **Structured GitHub Context**: Feeds the agent merged PR titles, labels, bodies, authors, and contributor information for the release range +- **Conventional Commits Support**: Uses commit prefixes (`feat:`, `fix:`, `docs:`, etc.) as categorization hints for the agent +- **PR Label Support**: Uses GitHub PR labels as additional hints for the agent +- **Contributor Attribution**: Includes PR numbers and author usernames for each change the agent keeps +- **New Contributor Highlighting**: Identifies and celebrates first-time contributors +- **Flexible Output**: Updates GitHub release notes directly or outputs for CHANGELOG.md + +## Plugin Contents + +``` +plugins/release-notes/ +├── README.md # This file +├── SKILL.md # Plugin definition +├── action.yml # Composite GitHub Action +├── scripts/ # Python scripts +│ ├── agent_script.py # OpenHands agent orchestration +│ ├── generate_release_notes.py +│ └── prompt.py +└── workflows/ # Example GitHub workflow files + └── release-notes.yml +``` + +## Installation + +### 1. Copy the Workflow File + +Copy the workflow file to your repository's `.github/workflows/` directory: + +```bash +mkdir -p .github/workflows +curl -o .github/workflows/release-notes.yml \ + https://raw.githubusercontent.com/OpenHands/extensions/main/plugins/release-notes/workflows/release-notes.yml +``` + +### 2. Configure Secrets + +Add the following secrets in your repository settings (**Settings → Secrets and variables → Actions**): + +| Secret | Required | Description | +|--------|----------|-------------| +| `LLM_API_KEY` | Yes | API key for the LLM used by the OpenHands agent | +| `GITHUB_TOKEN` | Auto | Provided automatically by GitHub Actions | + +**Note**: The default `GITHUB_TOKEN` is sufficient for most use cases. For repositories that need elevated permissions, use a personal access token. + +### 3. Customize the Workflow (Optional) + +Edit the workflow file to customize: + +```yaml +- name: Generate Release Notes + uses: OpenHands/extensions/plugins/release-notes@main + with: + # The release tag to generate notes for + tag: ${{ github.ref_name }} + + # Optional: Override previous tag detection + # previous-tag: v1.0.0 + + # Include internal/infrastructure changes (default: false) + include-internal: false + + # Output format: 'release' (GitHub release) or 'changelog' (CHANGELOG.md) + output-format: release + + # Optional model override + # llm-model: anthropic/claude-sonnet-4-5-20250929 + + # Secrets + llm-api-key: ${{ secrets.LLM_API_KEY }} + github-token: ${{ secrets.GITHUB_TOKEN }} +``` + +## Usage + +### Automatic Triggers + +Release notes are automatically generated when: + +1. A tag matching `v[0-9]+.[0-9]+.[0-9]+*` is pushed (e.g., `v1.2.0`, `v2.0.0-beta.1`) + +### Manual Triggering + +You can also manually trigger release notes generation: + +1. Go to **Actions** in your repository +2. Select the "Generate Release Notes" workflow +3. Click **Run workflow** +4. Enter the tag to generate notes for + +## Action Inputs + +| Input | Required | Default | Description | +|-------|----------|---------|-------------| +| `tag` | Yes | - | The release tag to generate notes for | +| `previous-tag` | No | Auto-detect | Override automatic detection of previous release | +| `include-internal` | No | `false` | Include internal/infrastructure changes | +| `output-format` | No | `release` | Output format: `release` or `changelog` | +| `llm-model` | No | `anthropic/claude-sonnet-4-5-20250929` | LLM used by the OpenHands agent | +| `llm-base-url` | No | - | Optional custom LLM endpoint | +| `llm-api-key` | Yes | - | API key for the OpenHands agent's LLM | +| `github-token` | Yes | - | GitHub token for API access | + +## Action Outputs + +| Output | Description | +|--------|-------------| +| `release-notes` | The generated release notes in markdown format | +| `previous-tag` | The detected or provided previous release tag | +| `commit-count` | Number of commits included in the release | +| `contributor-count` | Number of unique contributors | +| `new-contributor-count` | Number of first-time contributors | + +## Release Notes Structure + +Generated release notes follow this format: + +```markdown +## [v1.2.0] - 2026-03-06 + +### ⚠️ Breaking Changes +- Remove deprecated `--legacy` CLI flag (#456) @maintainer + +### ✨ New Features +- Add support for Claude Sonnet 4.6 model (#445) @contributor1 +- Implement parallel tool execution (#438) @contributor2 + +### 🐛 Bug Fixes +- Fix WebSocket reconnection on network interruption (#451) @contributor3 +- Resolve memory leak in long-running sessions (#447) @maintainer + +### 📚 Documentation +- Update installation guide for v1.2 (#442) @contributor2 + +### 🏗️ Internal/Infrastructure +- Upgrade CI to use Node 20 (#440) @maintainer + +### 👥 New Contributors +- @contributor3 made their first contribution in #451 + +**Full Changelog**: https://github.com/org/repo/compare/v1.1.0...v1.2.0 +``` + +## Change Categorization + +The agent receives deterministic categorization hints, but it makes the final decision about significance, grouping, and which entries to keep. + +### 1. Conventional Commit Prefixes + +| Prefix | Category | +|--------|----------| +| `BREAKING:` or `!:` suffix | ⚠️ Breaking Changes | +| `feat:`, `feature:` | ✨ New Features | +| `fix:`, `bugfix:` | 🐛 Bug Fixes | +| `docs:` | 📚 Documentation | +| `chore:`, `ci:`, `refactor:`, `test:`, `build:` | 🏗️ Internal/Infrastructure | + +### 2. PR Labels + +| Labels | Category | +|--------|----------| +| `breaking-change`, `breaking` | ⚠️ Breaking Changes | +| `enhancement`, `feature` | ✨ New Features | +| `bug`, `bugfix` | 🐛 Bug Fixes | +| `documentation`, `docs` | 📚 Documentation | +| `internal`, `chore`, `ci`, `dependencies` | 🏗️ Internal/Infrastructure | + +## Content Guidelines + +The generator follows these principles: + +- **Concise but informative**: The agent decides which changes matter and can merge related PRs into a single higher-signal bullet +- **User-focused**: The agent prioritizes user-facing changes over low-level implementation detail +- **Scannable**: Easy to quickly find relevant changes +- **Imperative mood**: Uses "Add feature" not "Added feature" +- **Attribution**: Includes PR number and author for traceability + +## Customizing Output + +### Excluding Internal Changes + +By default, internal/infrastructure changes are excluded. To include them: + +```yaml +- uses: OpenHands/extensions/plugins/release-notes@main + with: + include-internal: true +``` + +### Generating CHANGELOG.md Entries + +To generate output suitable for a CHANGELOG.md file: + +```yaml +- uses: OpenHands/extensions/plugins/release-notes@main + with: + output-format: changelog +``` + +Then use the output in a subsequent step: + +```yaml +- name: Update CHANGELOG + run: | + echo "${{ steps.release-notes.outputs.release-notes }}" >> CHANGELOG.md +``` + +## Troubleshooting + +### Missing LLM Credentials + +Make sure `LLM_API_KEY` is configured in repository secrets and passed to the action. + +### Release Notes Not Generated + +1. Check that the tag matches the semver pattern: `v[0-9]+.[0-9]+.[0-9]+*` +2. Verify the workflow file is in `.github/workflows/` +3. Check the Actions tab for error messages + +### Wrong Previous Tag Detected + +Use the `previous-tag` input to override automatic detection: + +```yaml +previous-tag: v1.0.0 +``` + +### Missing Contributors + +The generator uses the GitHub API to fetch PR authors. Ensure: +1. Commits are associated with merged PRs +2. The `GITHUB_TOKEN` has read access to pull requests + +## Security + +- Uses the default `GITHUB_TOKEN` for API access +- No secrets are persisted or logged +- Read-only access to repository history and PRs + +## Contributing + +See the main [extensions repository](https://github.com/OpenHands/extensions) for contribution guidelines. + +## License + +This plugin is part of the OpenHands extensions repository. See [LICENSE](../../LICENSE) for details. diff --git a/plugins/release-notes/SKILL.md b/plugins/release-notes/SKILL.md new file mode 100644 index 0000000..c2427f3 --- /dev/null +++ b/plugins/release-notes/SKILL.md @@ -0,0 +1,82 @@ +--- +name: release-notes +description: Generate consistent, well-structured release notes from git history. Triggered on release tags following semver patterns (v*.*.*) to produce categorized changelog with breaking changes, features, fixes, and contributor attribution. +triggers: +- /release-notes +- /releasenotes +--- + +# Release Notes Generator Plugin + +Automates the generation of standardized release notes for OpenHands repositories using an OpenHands agent. The agent reviews the release range, judges significance, groups related PRs, and produces concise markdown for GitHub releases or changelogs. + +## Features + +- **Automatic tag detection**: Finds the previous release tag to determine the commit range +- **Agent-based editing**: Lets the agent decide which changes matter and group related PRs into higher-signal summaries +- **Structured GitHub context**: Supplies PR titles, labels, descriptions, and contributor metadata to guide the agent +- **Contributor attribution**: Includes PR numbers and author usernames for each change +- **New contributor highlighting**: Identifies and celebrates first-time contributors +- **Flexible output**: Can update GitHub release notes or generate CHANGELOG.md entries + +## Quick Start + +### As a GitHub Action + +Copy the workflow file to your repository: + +```bash +mkdir -p .github/workflows +curl -o .github/workflows/release-notes.yml \ + https://raw.githubusercontent.com/OpenHands/extensions/main/plugins/release-notes/workflows/release-notes.yml +``` + +Configure required secrets (see the README for details). + +### Manual Invocation + +Use the `/release-notes` trigger in an OpenHands conversation to generate release notes for the current repository. + +## Release Notes Format + +Generated release notes follow this structure: + +```markdown +## [v1.2.0] - 2026-03-06 + +### ⚠️ Breaking Changes +- Remove deprecated `--legacy` CLI flag (#456) @maintainer + +### ✨ New Features +- Add support for Claude Sonnet 4.6 model (#445) @contributor1 + +### 🐛 Bug Fixes +- Fix WebSocket reconnection on network interruption (#451) @contributor3 + +### 📚 Documentation +- Update installation guide (#442) @contributor2 + +### 👥 New Contributors +- @contributor3 made their first contribution in #451 + +**Full Changelog**: https://github.com/org/repo/compare/v1.1.0...v1.2.0 +``` + +## Change Categorization + +Changes are categorized based on: + +1. **Conventional commit prefixes**: `feat:`, `fix:`, `docs:`, `chore:`, `BREAKING:` +2. **PR labels**: `breaking-change`, `bug`, `enhancement`, `documentation` + +| Category | Commit Prefixes | PR Labels | +|----------|-----------------|-----------| +| Breaking Changes | `BREAKING:`, `!:` suffix | `breaking-change`, `breaking` | +| Features | `feat:`, `feature:` | `enhancement`, `feature` | +| Bug Fixes | `fix:`, `bugfix:` | `bug`, `bugfix` | +| Documentation | `docs:` | `documentation`, `docs` | +| Internal | `chore:`, `ci:`, `refactor:`, `test:` | `internal`, `chore`, `ci` | + +## Configuration + +See the [README](./README.md) for full configuration options and workflow setup instructions. diff --git a/plugins/release-notes/action.yml b/plugins/release-notes/action.yml new file mode 100644 index 0000000..ded9b0e --- /dev/null +++ b/plugins/release-notes/action.yml @@ -0,0 +1,106 @@ +--- +name: OpenHands Release Notes Generator +description: Generate agent-authored release notes from git history and PR context +author: OpenHands + +branding: + icon: file-text + color: green + +inputs: + tag: + description: The release tag to generate notes for + required: true + previous-tag: + description: Override automatic detection of previous release tag + required: false + default: '' + include-internal: + description: Include internal/infrastructure changes in the release notes + required: false + default: 'false' + output-format: + description: "Output format: 'release' (GitHub release) or 'changelog' (CHANGELOG.md)" + required: false + default: release + llm-model: + description: LLM model to use for agent-based release note generation + required: false + default: anthropic/claude-sonnet-4-5-20250929 + llm-base-url: + description: LLM base URL (optional, for custom LLM endpoints) + required: false + default: '' + llm-api-key: + description: LLM API key (required) + required: true + github-token: + description: GitHub token for API access (required) + required: true + +outputs: + release-notes: + description: The generated release notes in markdown format + value: ${{ steps.generate.outputs.release_notes }} + previous-tag: + description: The detected or provided previous release tag + value: ${{ steps.generate.outputs.previous_tag }} + commit-count: + description: Number of commits included in the release + value: ${{ steps.generate.outputs.commit_count }} + contributor-count: + description: Number of unique contributors + value: ${{ steps.generate.outputs.contributor_count }} + new-contributor-count: + description: Number of first-time contributors + value: ${{ steps.generate.outputs.new_contributor_count }} + +runs: + using: composite + steps: + - name: Checkout extensions repository + uses: actions/checkout@v4 + with: + repository: OpenHands/extensions + ref: main + path: extensions + sparse-checkout: plugins/release-notes + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install uv + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + + - name: Generate release notes + id: generate + shell: bash + env: + LLM_MODEL: ${{ inputs.llm-model }} + LLM_BASE_URL: ${{ inputs.llm-base-url }} + LLM_API_KEY: ${{ inputs.llm-api-key }} + GITHUB_TOKEN: ${{ inputs.github-token }} + TAG: ${{ inputs.tag }} + PREVIOUS_TAG: ${{ inputs.previous-tag }} + INCLUDE_INTERNAL: ${{ inputs.include-internal }} + OUTPUT_FORMAT: ${{ inputs.output-format }} + REPO_NAME: ${{ github.repository }} + run: | + uv run --with openhands-sdk --with openhands-tools \ + python extensions/plugins/release-notes/scripts/agent_script.py + + - name: Update GitHub release (if release format) + if: inputs.output-format == 'release' + shell: bash + env: + GITHUB_TOKEN: ${{ inputs.github-token }} + run: | + if [ -f release_notes.md ]; then + echo "Updating release notes for tag ${{ inputs.tag }}" + gh release edit "${{ inputs.tag }}" --notes-file release_notes.md || \ + echo "Note: Could not update release. Release may not exist yet." + fi diff --git a/plugins/release-notes/scripts/agent_script.py b/plugins/release-notes/scripts/agent_script.py new file mode 100644 index 0000000..119695a --- /dev/null +++ b/plugins/release-notes/scripts/agent_script.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +"""Generate release notes with an OpenHands agent. + +This script collects structured release metadata from GitHub, then asks an +OpenHands agent to make editorial decisions about which changes matter, which +PRs belong together, and how to phrase the final release notes. + +Environment Variables: + LLM_API_KEY: API key for the LLM (required) + LLM_MODEL: Language model to use + LLM_BASE_URL: Optional base URL for custom LLM endpoints + GITHUB_TOKEN: GitHub token for API access (required) + TAG: The release tag to generate notes for (required) + PREVIOUS_TAG: Override automatic detection of previous release (optional) + INCLUDE_INTERNAL: Include internal/infrastructure changes (default: false) + OUTPUT_FORMAT: Output format - 'release' or 'changelog' (default: release) + REPO_NAME: Repository name in format owner/repo (required) +""" + +from __future__ import annotations + +import os +import re +import sys +from pathlib import Path +from typing import Any + +from openhands.sdk import LLM, Agent, AgentContext, Conversation, get_logger +from openhands.sdk.context.skills import load_project_skills +from openhands.sdk.conversation import get_agent_final_response +from openhands.tools.preset.default import get_default_condenser, get_default_tools + +script_dir = Path(__file__).parent +sys.path.insert(0, str(script_dir)) + +from generate_release_notes import Change, ReleaseNotes, generate_release_notes, set_github_output # noqa: E402 +from prompt import format_prompt # noqa: E402 + +logger = get_logger(__name__) + +CATEGORY_ORDER = ["breaking", "features", "fixes", "docs", "internal", "other"] + + +def _get_required_env(name: str) -> str: + value = os.getenv(name) + if not value: + raise ValueError(f"{name} environment variable is required") + return value + + +def _get_bool_env(name: str, default: bool = False) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.lower() == "true" + + +def validate_environment() -> dict[str, Any]: + """Validate required environment variables and return configuration.""" + return { + "model": os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"), + "base_url": os.getenv("LLM_BASE_URL", ""), + "api_key": _get_required_env("LLM_API_KEY"), + "github_token": _get_required_env("GITHUB_TOKEN"), + "tag": _get_required_env("TAG"), + "previous_tag": os.getenv("PREVIOUS_TAG") or None, + "include_internal": _get_bool_env("INCLUDE_INTERNAL", False), + "output_format": os.getenv("OUTPUT_FORMAT", "release"), + "repo_name": _get_required_env("REPO_NAME"), + } + + +def create_agent(config: dict[str, Any]) -> Agent: + """Create and configure the release-notes agent.""" + llm_config: dict[str, Any] = { + "model": config["model"], + "api_key": config["api_key"], + "usage_id": "release_notes_agent", + "drop_params": True, + } + if config["base_url"]: + llm_config["base_url"] = config["base_url"] + + llm = LLM(**llm_config) + + cwd = os.getcwd() + project_skills = load_project_skills(cwd) + logger.info( + "Loaded %s project skills: %s", + len(project_skills), + [skill.name for skill in project_skills], + ) + + agent_context = AgentContext( + load_public_skills=False, + skills=project_skills, + ) + + return Agent( + llm=llm, + tools=get_default_tools(enable_browser=False), + agent_context=agent_context, + system_prompt_kwargs={"cli_mode": True}, + condenser=get_default_condenser( + llm=llm.model_copy(update={"usage_id": "release_notes_condenser"}) + ), + ) + + +def _truncate(text: str, limit: int = 280) -> str: + compact = re.sub(r"\s+", " ", text or "").strip() + if len(compact) <= limit: + return compact + return compact[: limit - 1].rstrip() + "…" + + +def _format_change_block(change: Change, repo_name: str) -> str: + pr_ref = f"#{change.pr_number}" if change.pr_number else change.sha[:7] + labels = ", ".join(change.pr_labels) if change.pr_labels else "none" + url = change.url or ( + f"https://github.com/{repo_name}/pull/{change.pr_number}" + if change.pr_number + else f"https://github.com/{repo_name}/commit/{change.sha}" + ) + body = _truncate(change.body, 400) or "No PR body provided" + + return "\n".join( + [ + f"- Ref: {pr_ref}", + f" Title: {change.message}", + f" Author: @{change.author}" if change.author else " Author: unknown", + f" Suggested category: {change.category}", + f" Labels: {labels}", + f" URL: {url}", + f" Body: {body}", + ] + ) + + +def build_change_candidates(notes: ReleaseNotes) -> str: + """Build the structured candidate change list for the agent prompt.""" + lines: list[str] = [] + for category in CATEGORY_ORDER: + changes = notes.changes.get(category, []) + if not changes: + continue + lines.append(f"\nSuggested {category}:" ) + for change in changes: + lines.append(_format_change_block(change, notes.repo_name)) + return "\n".join(lines).strip() + + +def build_new_contributors(notes: ReleaseNotes) -> str: + """Build the new-contributors section for the agent prompt.""" + if not notes.new_contributors: + return "- None" + + lines = [] + for contributor in notes.new_contributors: + pr_text = f" in #{contributor.first_pr}" if contributor.first_pr else "" + lines.append(f"- @{contributor.username} made their first contribution{pr_text}") + return "\n".join(lines) + + +def extract_markdown(text: str) -> str: + """Normalize the agent response into plain markdown.""" + cleaned = text.strip() + fence_match = re.fullmatch(r"```(?:markdown)?\s*\n?(.*?)\n?```", cleaned, re.DOTALL) + if fence_match: + cleaned = fence_match.group(1).strip() + return cleaned + + +def build_prompt(notes: ReleaseNotes, include_internal: bool, output_format: str) -> str: + """Build the prompt sent to the release-notes agent.""" + return format_prompt( + repo_name=notes.repo_name, + tag=notes.tag, + previous_tag=notes.previous_tag, + date=notes.date, + commit_count=notes.commit_count, + include_internal=include_internal, + output_format=output_format, + full_changelog_url=( + f"https://github.com/{notes.repo_name}/compare/" + f"{notes.previous_tag}...{notes.tag}" + ), + change_candidates=build_change_candidates(notes), + new_contributors=build_new_contributors(notes), + ) + + +def run_generation( + agent: Agent, + prompt: str, + secrets: dict[str, str], +) -> tuple[Conversation, str]: + """Run the agent and return the conversation plus generated markdown.""" + conversation = Conversation( + agent=agent, + workspace=os.getcwd(), + secrets=secrets, + ) + + logger.info("Starting agent-based release note generation...") + conversation.send_message(prompt) + conversation.run() + + response = get_agent_final_response(conversation.state.events) + if not response: + raise RuntimeError("Agent did not return release notes") + + return conversation, extract_markdown(response) + + +def log_cost_summary(conversation: Conversation) -> None: + """Print cost information for CI output.""" + metrics = conversation.conversation_stats.get_combined_metrics() + print("\n=== Release Notes Cost Summary ===") + print(f"Total Cost: ${metrics.accumulated_cost:.6f}") + if metrics.accumulated_token_usage: + token_usage = metrics.accumulated_token_usage + print(f"Prompt Tokens: {token_usage.prompt_tokens}") + print(f"Completion Tokens: {token_usage.completion_tokens}") + if token_usage.cache_read_tokens > 0: + print(f"Cache Read Tokens: {token_usage.cache_read_tokens}") + if token_usage.cache_write_tokens > 0: + print(f"Cache Write Tokens: {token_usage.cache_write_tokens}") + + +def main() -> None: + """Generate release notes with an agent and write outputs for GitHub Actions.""" + config = validate_environment() + + logger.info("Generating release notes for %s", config["repo_name"]) + logger.info("Tag: %s", config["tag"]) + logger.info("Previous tag: %s", config["previous_tag"] or "auto-detect") + logger.info("Include internal: %s", config["include_internal"]) + logger.info("Output format: %s", config["output_format"]) + logger.info("LLM model: %s", config["model"]) + + notes = generate_release_notes( + tag=config["tag"], + previous_tag=config["previous_tag"], + repo_name=config["repo_name"], + token=config["github_token"], + include_internal=config["include_internal"], + ) + + prompt = build_prompt( + notes=notes, + include_internal=config["include_internal"], + output_format=config["output_format"], + ) + + agent = create_agent(config) + conversation, markdown = run_generation( + agent=agent, + prompt=prompt, + secrets={ + "LLM_API_KEY": config["api_key"], + "GITHUB_TOKEN": config["github_token"], + }, + ) + + with open("release_notes.md", "w") as file: + file.write(markdown) + + print("\n" + "=" * 60) + print("Generated Release Notes:") + print("=" * 60) + print(markdown) + print("=" * 60) + + set_github_output("release_notes", markdown) + set_github_output("previous_tag", notes.previous_tag) + set_github_output("commit_count", str(notes.commit_count)) + set_github_output("contributor_count", str(len(notes.contributors))) + set_github_output("new_contributor_count", str(len(notes.new_contributors))) + + log_cost_summary(conversation) + logger.info("Release notes generated successfully") + + +if __name__ == "__main__": + main() diff --git a/plugins/release-notes/scripts/generate_release_notes.py b/plugins/release-notes/scripts/generate_release_notes.py new file mode 100644 index 0000000..ea5ac4e --- /dev/null +++ b/plugins/release-notes/scripts/generate_release_notes.py @@ -0,0 +1,645 @@ +#!/usr/bin/env python3 +""" +Release Notes Generator + +Generates consistent, well-structured release notes from git history. +Categorizes changes based on conventional commit prefixes and PR labels. + +Environment Variables: + GITHUB_TOKEN: GitHub token for API access (required) + TAG: The release tag to generate notes for (required) + PREVIOUS_TAG: Override automatic detection of previous release (optional) + INCLUDE_INTERNAL: Include internal/infrastructure changes (default: false) + OUTPUT_FORMAT: Output format - 'release' or 'changelog' (default: release) + REPO_NAME: Repository name in format owner/repo (required) +""" + +import json +import os +import re +import sys +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from datetime import datetime, timezone +from typing import Any + +# Category definitions with emojis and patterns +# Patterns match both conventional commit style (feat:, fix:) and bracket/paren style ([Feat]:, (Fix):) +CATEGORIES = { + "breaking": { + "emoji": "⚠️", + "title": "Breaking Changes", + "commit_patterns": [r"^BREAKING[\s\-:]", r"!:", r"^\[?BREAKING\]?[\s\-:]"], + "labels": ["breaking-change", "breaking"], + }, + "features": { + "emoji": "✨", + "title": "New Features", + "commit_patterns": [ + r"^feat(?:ure)?[\s\-:\(]", + r"^[\[\(]feat(?:ure)?[\]\)][\s\-:]*", # [Feat]: or (Feat): + ], + "labels": ["enhancement", "feature"], + }, + "fixes": { + "emoji": "🐛", + "title": "Bug Fixes", + "commit_patterns": [ + r"^fix(?:es)?[\s\-:\(]", + r"^bugfix[\s\-:\(]", + r"^[\[\(]fix(?:es)?[\]\)][\s\-:]*", # [Fix]: or (Fix): + r"^[\[\(]hotfix[\]\)][\s\-:]*", # [Hotfix]: or (Hotfix): + r"^hotfix[\s\-:\(]", + ], + "labels": ["bug", "bugfix"], + }, + "docs": { + "emoji": "📚", + "title": "Documentation", + "commit_patterns": [ + r"^docs?[\s\-:\(]", + r"^[\[\(]docs?[\]\)][\s\-:]*", # [Docs]: or (Docs): + ], + "labels": ["documentation", "docs"], + }, + "internal": { + "emoji": "🏗️", + "title": "Internal/Infrastructure", + "commit_patterns": [ + r"^chore[\s\-:\(]", + r"^ci[\s\-:\(]", + r"^refactor[\s\-:\(]", + r"^test[\s\-:\(]", + r"^build[\s\-:\(]", + r"^style[\s\-:\(]", + r"^perf[\s\-:\(]", + r"^[\[\(]chore[\]\)][\s\-:]*", # [Chore]: or (Chore): + r"^[\[\(]ci[\]\)][\s\-:]*", + r"^[\[\(]refactor[\]\)][\s\-:]*", + r"^[\[\(]test[\]\)][\s\-:]*", + ], + "labels": ["internal", "chore", "ci", "dependencies"], + }, +} + +KEYWORD_PATTERNS = { + "docs": [ + r"\bdocs?\b", + r"\bdocumentation\b", + r"\breadme\b", + r"\bchangelog\b", + r"\bguide\b", + r"\bopenapi\b", + ], + "internal": [ + r"\bci\b", + r"\blint\b", + r"\btyping\b", + r"\brefactor\b", + r"\bdebug\b", + r"\bpre-commit\b", + r"\bdockerfile\b", + r"\bdependencies?\b", + r"\brelease\b", + r"\btool descriptions?\b", + r"\bmicroagents?\b", + ], + "fixes": [ + r"\bfix(?:es|ed)?\b", + r"\bbug\b", + r"\berror\b", + r"\bfail(?:ed|ing)?\b", + r"\bissue\b", + r"\bcrash\b", + r"\btimeout\b", + r"\bleak\b", + r"\bmissing\b", + r"\berroneous\b", + r"\breconnect\b", + r"\breset\b", + ], + "features": [ + r"^(add|allow|support|enable|implement|introduce|create|provide|improve)\b", + ], +} + + +@dataclass +class Change: + """Represents a single change/commit in the release.""" + + message: str + sha: str + author: str + pr_number: int | None = None + pr_labels: list[str] = field(default_factory=list) + body: str = "" + url: str = "" + category: str = "other" + + def to_markdown(self, repo_name: str) -> str: + """Format the change as a markdown list item.""" + # Clean up the message - remove conventional commit prefix + # Supports multiple formats: + # - feat: message, fix(scope): message, feat!: breaking + # - [Feat]: message, (Fix): message, [Chore]: message + msg = self.message.strip() + for pattern in [ + # Standard conventional commit: feat:, fix(scope):, feat!: + r"^(feat|fix|docs?|chore|ci|refactor|test|build|style|perf|BREAKING|hotfix)(\(.+?\))?!?:\s+", + # Bracket/paren style: [Feat]:, (Fix):, [Hotfix]: + r"^[\[\(](feat|fix|docs?|chore|ci|refactor|test|build|style|perf|BREAKING|hotfix)[\]\)][\s\-:]+", + ]: + msg = re.sub(pattern, "", msg, flags=re.IGNORECASE) + msg = msg.strip() + + # Capitalize first letter + if msg: + msg = msg[0].upper() + msg[1:] + + # Add PR link if available and not already in the message + if self.pr_number: + pr_ref = f"#{self.pr_number}" + if pr_ref not in msg: + msg += f" ({pr_ref})" + + # Add author + if self.author: + msg += f" @{self.author}" + + return f"- {msg}" + + +@dataclass +class Contributor: + """Represents a contributor to the release.""" + + username: str + first_pr: int | None = None + is_new: bool = False + + +@dataclass +class ReleaseNotes: + """Holds all data needed to generate release notes.""" + + tag: str + previous_tag: str + date: str + repo_name: str + commit_count: int = 0 + changes: dict[str, list[Change]] = field(default_factory=dict) + contributors: list[Contributor] = field(default_factory=list) + new_contributors: list[Contributor] = field(default_factory=list) + + def to_markdown(self, include_internal: bool = False) -> str: + """Generate the full release notes markdown.""" + lines = [f"## [{self.tag}] - {self.date}", ""] + + # Order of categories to display + category_order = ["breaking", "features", "fixes", "docs"] + if include_internal: + category_order.append("internal") + + # Add categorized changes + for category in category_order: + changes = self.changes.get(category, []) + if changes: + cat_info = CATEGORIES[category] + lines.append(f"### {cat_info['emoji']} {cat_info['title']}") + for change in changes: + lines.append(change.to_markdown(self.repo_name)) + lines.append("") + + # Add new contributors section + if self.new_contributors: + lines.append("### 👥 New Contributors") + for contrib in self.new_contributors: + pr_text = f" in #{contrib.first_pr}" if contrib.first_pr else "" + lines.append(f"- @{contrib.username} made their first contribution{pr_text}") + lines.append("") + + # Add full changelog link + lines.append( + f"**Full Changelog**: https://github.com/{self.repo_name}/compare/" + f"{self.previous_tag}...{self.tag}" + ) + + return "\n".join(lines) + + +def get_env(name: str, default: str | None = None, required: bool = False) -> str: + """Get an environment variable.""" + value = os.getenv(name, default) + if required and not value: + print(f"Error: {name} environment variable is required") + sys.exit(1) + return value or "" + + +def github_api_request( + endpoint: str, + token: str, + method: str = "GET", + data: dict[str, Any] | None = None, +) -> Any: + """Make a request to the GitHub API.""" + url = f"https://api.github.com{endpoint}" + request = urllib.request.Request(url, method=method) + request.add_header("Accept", "application/vnd.github+json") + request.add_header("Authorization", f"Bearer {token}") + request.add_header("X-GitHub-Api-Version", "2022-11-28") + + if data: + request.add_header("Content-Type", "application/json") + request.data = json.dumps(data).encode("utf-8") + + try: + with urllib.request.urlopen(request, timeout=60) as response: + return json.loads(response.read().decode("utf-8")) + except urllib.error.HTTPError as e: + details = (e.read() or b"").decode("utf-8", errors="replace").strip() + raise RuntimeError( + f"GitHub API request failed: HTTP {e.code} {e.reason}. {details}" + ) from e + + +def get_tags(repo_name: str, token: str) -> list[dict[str, Any]]: + """Get all tags from the repository, sorted by creation date.""" + tags = [] + page = 1 + per_page = 100 + + while True: + endpoint = f"/repos/{repo_name}/tags?per_page={per_page}&page={page}" + page_tags = github_api_request(endpoint, token) + if not page_tags: + break + tags.extend(page_tags) + if len(page_tags) < per_page: + break + page += 1 + + return tags + + +def find_previous_tag( + current_tag: str, tags: list[dict[str, Any]] +) -> str | None: + """Find the previous release tag before the current one.""" + # Filter to only semver tags (with optional pre-release/build metadata) + semver_pattern = re.compile(r"^v?\d+\.\d+\.\d+(?:[.-].*)?$") + semver_tags = [t for t in tags if semver_pattern.match(t["name"])] + + # Find current tag index + current_idx = None + for i, tag in enumerate(semver_tags): + if tag["name"] == current_tag: + current_idx = i + break + + if current_idx is None: + return None + + # Return the next tag (which is the previous release since tags are sorted newest first) + if current_idx + 1 < len(semver_tags): + return semver_tags[current_idx + 1]["name"] + + return None + + +def get_commits_between_tags( + repo_name: str, base_tag: str, head_tag: str, token: str +) -> list[dict[str, Any]]: + """Get all commits between two tags.""" + endpoint = f"/repos/{repo_name}/compare/{base_tag}...{head_tag}" + response = github_api_request(endpoint, token) + commits = response.get("commits", []) + + total_commits = response.get("total_commits") + if isinstance(total_commits, int) and total_commits > len(commits): + print( + "Warning: GitHub compare API truncated the commit list; " + "release notes may be incomplete.", + file=sys.stderr, + ) + + return commits + + +def get_pr_for_commit( + repo_name: str, sha: str, token: str +) -> dict[str, Any] | None: + """Get the PR associated with a commit (if any).""" + endpoint = f"/repos/{repo_name}/commits/{sha}/pulls" + try: + prs = github_api_request(endpoint, token) + if prs: + # Return the first merged PR + for pr in prs: + if pr.get("merged_at"): + return pr + # If no merged PR, return the first one + return prs[0] + except Exception as e: + # Log but don't fail - PR data is optional + print(f"Warning: Could not fetch PR for commit {sha[:7]}: {e}", file=sys.stderr) + return None + + +def _matches_any_pattern(text: str, patterns: list[str]) -> bool: + """Return True if the text matches any of the provided regex patterns.""" + return any(re.search(pattern, text, re.IGNORECASE) for pattern in patterns) + + +def categorize_change(change: Change) -> str: + """Determine the category for a change based on commit message and PR labels.""" + # Exact matches first: conventional commit prefixes are the strongest signal. + for category, info in CATEGORIES.items(): + if _matches_any_pattern(change.message, info["commit_patterns"]): + return category + + # Strong keyword matches help suppress noisy internal-only PRs even when a + # repository applies broad labels like `bug` or `enhancement`. + for category in ["docs", "internal"]: + if _matches_any_pattern(change.message, KEYWORD_PATTERNS[category]): + return category + + label_names = [label.lower() for label in change.pr_labels] + for category, info in CATEGORIES.items(): + if any(label.lower() in label_names for label in info["labels"]): + return category + + # Fallback heuristics make PR-title based release notes more useful while + # still preferring user-facing categories over noisy implementation details. + for category in ["fixes", "features"]: + if _matches_any_pattern(change.message, KEYWORD_PATTERNS[category]): + return category + + return "other" + + +def is_new_contributor( + author: str, repo_name: str, before_date: str, token: str +) -> bool: + """Check if this is the author's first contribution to the repository.""" + # Search for commits by this author before the given date + endpoint = ( + f"/repos/{repo_name}/commits" + f"?author={author}&until={before_date}&per_page=1" + ) + try: + commits = github_api_request(endpoint, token) + return len(commits) == 0 + except Exception as e: + # Log but don't fail - contributor check is best-effort + print(f"Warning: Could not check contributor history for {author}: {e}", file=sys.stderr) + return False + + +def get_tag_date(repo_name: str, tag: str, token: str) -> str: + """Get the date when a tag was created.""" + endpoint = f"/repos/{repo_name}/git/refs/tags/{tag}" + try: + ref = github_api_request(endpoint, token) + # Get the commit or tag object + obj_sha = ref["object"]["sha"] + obj_type = ref["object"]["type"] + + if obj_type == "tag": + # Annotated tag - get the tag object + tag_endpoint = f"/repos/{repo_name}/git/tags/{obj_sha}" + tag_obj = github_api_request(tag_endpoint, token) + date_str = tag_obj["tagger"]["date"] + else: + # Lightweight tag - get the commit + commit_endpoint = f"/repos/{repo_name}/git/commits/{obj_sha}" + commit_obj = github_api_request(commit_endpoint, token) + date_str = commit_obj["committer"]["date"] + + # Parse and format the date + dt = datetime.fromisoformat(date_str.replace("Z", "+00:00")) + return dt.strftime("%Y-%m-%d") + except Exception as e: + # Log but fall back to current date + print(f"Warning: Could not get tag date for {tag}: {e}", file=sys.stderr) + return datetime.now(timezone.utc).strftime("%Y-%m-%d") + + +def _process_commit( + commit_data: dict[str, Any], repo_name: str, token: str +) -> Change | None: + """Process a single commit into a Change object.""" + sha = commit_data["sha"] + message = commit_data["commit"]["message"].split("\n")[0] # First line only + author = commit_data.get("author", {}).get("login", "") + + # Skip merge commits + if message.lower().startswith("merge "): + return None + + # Get PR info + pr_number = None + pr_labels: list[str] = [] + pr = get_pr_for_commit(repo_name, sha, token) + body = "" + url = "" + if pr: + pr_number = pr["number"] + pr_labels = [label["name"] for label in pr.get("labels", [])] + author = pr.get("user", {}).get("login", "") or author + message = pr.get("title") or message + body = pr.get("body") or "" + url = pr.get("html_url") or "" + + return Change( + message=message, + sha=sha, + author=author, + pr_number=pr_number, + pr_labels=pr_labels, + body=body, + url=url, + ) + + +def _dedupe_changes(changes_list: list[Change]) -> list[Change]: + """Collapse multiple commits from the same PR into one release-note entry.""" + deduped: list[Change] = [] + seen_keys: set[str] = set() + + for change in changes_list: + key = f"pr:{change.pr_number}" if change.pr_number else f"sha:{change.sha}" + if key in seen_keys: + continue + seen_keys.add(key) + deduped.append(change) + + return deduped + + +def _categorize_changes( + changes_list: list[Change], +) -> dict[str, list[Change]]: + """Categorize a list of changes by type.""" + categorized: dict[str, list[Change]] = {cat: [] for cat in CATEGORIES} + categorized["other"] = [] + + for change in changes_list: + change.category = categorize_change(change) + if change.category in categorized: + categorized[change.category].append(change) + else: + categorized["other"].append(change) + + return categorized + + +def _process_contributors( + changes_list: list[Change], + repo_name: str, + tag_date: str, + token: str, +) -> tuple[list[Contributor], list[Contributor]]: + """Extract contributors from changes and identify new contributors.""" + contributors: dict[str, Contributor] = {} + new_contributors: list[Contributor] = [] + + for change in changes_list: + author = change.author + if author and author not in contributors: + contrib = Contributor(username=author, first_pr=change.pr_number) + contributors[author] = contrib + + # Check if new contributor + if is_new_contributor(author, repo_name, f"{tag_date}T00:00:00Z", token): + contrib.is_new = True + new_contributors.append(contrib) + + return list(contributors.values()), new_contributors + + +def generate_release_notes( + tag: str, + previous_tag: str | None, + repo_name: str, + token: str, + include_internal: bool = False, +) -> ReleaseNotes: + """Generate release notes for the given tag.""" + # Get all tags + tags = get_tags(repo_name, token) + + # Find previous tag if not provided + if not previous_tag: + previous_tag = find_previous_tag(tag, tags) + + if not previous_tag: + print(f"Warning: Could not find previous tag for {tag}") + previous_tag = tags[-1]["name"] if tags else "HEAD~100" + + print(f"Generating release notes: {previous_tag} -> {tag}") + + # Get tag date + tag_date = get_tag_date(repo_name, tag, token) + + # Get commits between tags + commits = get_commits_between_tags(repo_name, previous_tag, tag, token) + print(f"Found {len(commits)} commits") + + # Phase 1: Process commits into Change objects + raw_changes = [ + change + for c in commits + if (change := _process_commit(c, repo_name, token)) is not None + ] + + # Phase 2: Collapse multiple commits from the same PR into a single entry. + changes = _dedupe_changes(raw_changes) + + # Phase 3: Categorize changes + categorized = _categorize_changes(changes) + + # Phase 4: Extract and identify contributors + contributors, new_contributors = _process_contributors( + changes, repo_name, tag_date, token + ) + + return ReleaseNotes( + tag=tag, + previous_tag=previous_tag, + date=tag_date, + repo_name=repo_name, + commit_count=len(commits), + changes=categorized, + contributors=contributors, + new_contributors=new_contributors, + ) + + +def set_github_output(name: str, value: str) -> None: + """Set a GitHub Actions output variable.""" + output_file = os.getenv("GITHUB_OUTPUT") + if output_file: + with open(output_file, "a") as f: + # Handle multiline values + if "\n" in value: + delimiter = f"EOF_{os.urandom(4).hex()}" + f.write(f"{name}<<{delimiter}\n{value}\n{delimiter}\n") + else: + f.write(f"{name}={value}\n") + else: + print(f"::set-output name={name}::{value}") + + +def main(): + """Main entry point.""" + # Get configuration from environment + token = get_env("GITHUB_TOKEN", required=True) + tag = get_env("TAG", required=True) + previous_tag = get_env("PREVIOUS_TAG") or None + include_internal = get_env("INCLUDE_INTERNAL", "false").lower() == "true" + output_format = get_env("OUTPUT_FORMAT", "release") + repo_name = get_env("REPO_NAME", required=True) + + print(f"Generating release notes for {repo_name}") + print(f"Tag: {tag}") + print(f"Previous tag: {previous_tag or 'auto-detect'}") + print(f"Include internal: {include_internal}") + print(f"Output format: {output_format}") + + # Generate release notes + notes = generate_release_notes( + tag=tag, + previous_tag=previous_tag, + repo_name=repo_name, + token=token, + include_internal=include_internal, + ) + + # Generate markdown + markdown = notes.to_markdown(include_internal=include_internal) + + # Write to file + with open("release_notes.md", "w") as f: + f.write(markdown) + + print("\n" + "=" * 60) + print("Generated Release Notes:") + print("=" * 60) + print(markdown) + print("=" * 60) + + # Set GitHub Actions outputs + set_github_output("release_notes", markdown) + set_github_output("previous_tag", notes.previous_tag) + set_github_output("commit_count", str(notes.commit_count)) + set_github_output("contributor_count", str(len(notes.contributors))) + set_github_output("new_contributor_count", str(len(notes.new_contributors))) + + print("\nRelease notes generated successfully!") + + +if __name__ == "__main__": + main() diff --git a/plugins/release-notes/scripts/prompt.py b/plugins/release-notes/scripts/prompt.py new file mode 100644 index 0000000..828ab46 --- /dev/null +++ b/plugins/release-notes/scripts/prompt.py @@ -0,0 +1,77 @@ +"""Prompt template for the release notes agent.""" + +PROMPT = """Write official release notes for `{repo_name}` tag `{tag}`. + +Use the structured release data below as your primary source of truth. You may inspect the checked-out repository if it helps you judge significance or match release-note style, but do not ask follow-up questions. + +Your job is editorial, not mechanical: +- decide which PRs are important enough to mention +- group related PRs into a single bullet when they form one coherent feature or fix +- omit trivial, repetitive, or low-signal changes from the final notes +- prefer user impact over implementation detail +- treat the suggested category as a hint, not a rule +- when `include_internal` is false, omit internal-only work unless it is important for users +- use imperative mood +- keep each bullet to one line +- format bullet references as `(#123) @username`; if you group multiple PRs, include each reference explicitly, for example `(#123) @alice, (#124) @bob` +- include every new contributor listed below in the `### 👥 New Contributors` section +- for breaking changes, briefly note the migration path when the provided context makes it clear + +Return markdown only. Do not wrap the result in a code fence. + +Required structure: +- `## [{tag}] - {date}` +- `### ⚠️ Breaking Changes` when applicable +- `### ✨ New Features` when applicable +- `### 🐛 Bug Fixes` when applicable +- `### 📚 Documentation` only when notable +- `### 🏗️ Internal/Infrastructure` only when `include_internal` is true and the changes are worth mentioning +- `### 👥 New Contributors` when there are any +- `**Full Changelog**: {full_changelog_url}` at the end + +Release metadata: +- Repository: {repo_name} +- Current tag: {tag} +- Previous tag: {previous_tag} +- Release date: {date} +- Commits in range: {commit_count} +- Include internal section: {include_internal} +- Output format: {output_format} + +Candidate changes: +{change_candidates} + +New contributors: +{new_contributors} + +Full changelog URL: +{full_changelog_url} +""" + + +def format_prompt( + *, + repo_name: str, + tag: str, + previous_tag: str, + date: str, + commit_count: int, + include_internal: bool, + output_format: str, + full_changelog_url: str, + change_candidates: str, + new_contributors: str, +) -> str: + """Format the release-notes prompt.""" + return PROMPT.format( + repo_name=repo_name, + tag=tag, + previous_tag=previous_tag, + date=date, + commit_count=commit_count, + include_internal=str(include_internal).lower(), + output_format=output_format, + full_changelog_url=full_changelog_url, + change_candidates=change_candidates, + new_contributors=new_contributors, + ) diff --git a/plugins/release-notes/skills/releasenotes b/plugins/release-notes/skills/releasenotes new file mode 120000 index 0000000..8d53718 --- /dev/null +++ b/plugins/release-notes/skills/releasenotes @@ -0,0 +1 @@ +../../../skills/releasenotes \ No newline at end of file diff --git a/plugins/release-notes/workflows/release-notes.yml b/plugins/release-notes/workflows/release-notes.yml new file mode 100644 index 0000000..93b2cbd --- /dev/null +++ b/plugins/release-notes/workflows/release-notes.yml @@ -0,0 +1,76 @@ +--- +name: Generate Release Notes + +on: + # Trigger on release tags matching semver pattern + push: + tags: + - 'v[0-9]+.[0-9]+.[0-9]+*' + + # Allow manual triggering with tag input + workflow_dispatch: + inputs: + tag: + description: 'The release tag to generate notes for' + required: true + type: string + previous-tag: + description: 'Override automatic detection of previous release tag' + required: false + type: string + include-internal: + description: 'Include internal/infrastructure changes' + required: false + type: boolean + default: false + +permissions: + contents: write + +jobs: + generate-release-notes: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Full history for tag comparison + + - name: Determine tag + id: get-tag + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + echo "tag=${{ github.event.inputs.tag }}" >> $GITHUB_OUTPUT + else + echo "tag=${{ github.ref_name }}" >> $GITHUB_OUTPUT + fi + + - name: Generate Release Notes + id: release-notes + uses: OpenHands/extensions/plugins/release-notes@main + with: + tag: ${{ steps.get-tag.outputs.tag }} + previous-tag: ${{ github.event.inputs.previous-tag || '' }} + include-internal: ${{ github.event.inputs.include-internal || 'false' }} + output-format: release + llm-api-key: ${{ secrets.LLM_API_KEY }} + github-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Display generated notes + run: | + echo "## Release Notes for ${{ steps.get-tag.outputs.tag }}" + echo "" + echo "Previous tag: ${{ steps.release-notes.outputs.previous-tag }}" + echo "Commits: ${{ steps.release-notes.outputs.commit-count }}" + echo "Contributors: ${{ steps.release-notes.outputs.contributor-count }}" + echo "New contributors: ${{ steps.release-notes.outputs.new-contributor-count }}" + echo "" + echo "---" + cat release_notes.md + + - name: Upload release notes artifact + uses: actions/upload-artifact@v4 + with: + name: release-notes-${{ steps.get-tag.outputs.tag }} + path: release_notes.md + retention-days: 30 diff --git a/tests/test_release_notes_generator.py b/tests/test_release_notes_generator.py new file mode 100644 index 0000000..7f3d0de --- /dev/null +++ b/tests/test_release_notes_generator.py @@ -0,0 +1,525 @@ +"""Tests for the release notes generator plugin.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import patch + +import pytest + +# Add the plugin scripts directory to the path +plugin_dir = Path(__file__).parent.parent / "plugins" / "release-notes" / "scripts" +sys.path.insert(0, str(plugin_dir)) + +from generate_release_notes import ( + CATEGORIES, + Change, + Contributor, + ReleaseNotes, + _dedupe_changes, + _process_commit, + categorize_change, + get_commits_between_tags, +) +from prompt import format_prompt + + +class TestChange: + """Tests for the Change dataclass.""" + + def test_to_markdown_basic(self): + """Test basic markdown formatting.""" + change = Change( + message="Add new feature", + sha="abc123", + author="testuser", + ) + result = change.to_markdown("owner/repo") + assert result == "- Add new feature @testuser" + + def test_to_markdown_with_pr(self): + """Test markdown formatting with PR number.""" + change = Change( + message="Resolve memory leak", + sha="abc123", + author="testuser", + pr_number=42, + ) + result = change.to_markdown("owner/repo") + assert result == "- Resolve memory leak (#42) @testuser" + + def test_to_markdown_strips_conventional_commit_prefix(self): + """Test that conventional commit prefixes are stripped.""" + change = Change( + message="feat: Add new feature", + sha="abc123", + author="testuser", + pr_number=42, + ) + result = change.to_markdown("owner/repo") + assert "feat:" not in result + assert "Add new feature" in result + + def test_to_markdown_strips_scoped_prefix(self): + """Test that scoped conventional commit prefixes are stripped.""" + change = Change( + message="fix(api): Resolve timeout issue", + sha="abc123", + author="testuser", + ) + result = change.to_markdown("owner/repo") + assert "fix(api):" not in result + assert "Resolve timeout issue" in result + + def test_to_markdown_capitalizes_first_letter(self): + """Test that the first letter is capitalized.""" + change = Change( + message="fix: lower case message", + sha="abc123", + author="testuser", + ) + result = change.to_markdown("owner/repo") + assert "Lower case message" in result + + +class TestCategorizeChange: + """Tests for the categorize_change function.""" + + def test_categorize_feat_prefix(self): + """Test categorization of feat: prefix.""" + change = Change(message="feat: Add new API", sha="abc", author="user") + assert categorize_change(change) == "features" + + def test_categorize_feature_prefix(self): + """Test categorization of feature: prefix.""" + change = Change(message="feature: Add new API", sha="abc", author="user") + assert categorize_change(change) == "features" + + def test_categorize_fix_prefix(self): + """Test categorization of fix: prefix.""" + change = Change(message="fix: Resolve crash", sha="abc", author="user") + assert categorize_change(change) == "fixes" + + def test_categorize_docs_prefix(self): + """Test categorization of docs: prefix.""" + change = Change(message="docs: Update README", sha="abc", author="user") + assert categorize_change(change) == "docs" + + def test_categorize_chore_prefix(self): + """Test categorization of chore: prefix.""" + change = Change(message="chore: Update dependencies", sha="abc", author="user") + assert categorize_change(change) == "internal" + + def test_categorize_ci_prefix(self): + """Test categorization of ci: prefix.""" + change = Change(message="ci: Add GitHub Actions", sha="abc", author="user") + assert categorize_change(change) == "internal" + + def test_categorize_breaking_prefix(self): + """Test categorization of BREAKING: prefix.""" + change = Change(message="BREAKING: Remove deprecated API", sha="abc", author="user") + assert categorize_change(change) == "breaking" + + def test_categorize_by_label_enhancement(self): + """Test categorization by enhancement label.""" + change = Change( + message="Add feature", + sha="abc", + author="user", + pr_labels=["enhancement"], + ) + assert categorize_change(change) == "features" + + def test_categorize_by_label_bug(self): + """Test categorization by bug label.""" + change = Change( + message="Fix something", + sha="abc", + author="user", + pr_labels=["bug"], + ) + assert categorize_change(change) == "fixes" + + def test_categorize_by_label_breaking_change(self): + """Test categorization by breaking-change label.""" + change = Change( + message="Change API", + sha="abc", + author="user", + pr_labels=["breaking-change"], + ) + assert categorize_change(change) == "breaking" + + def test_categorize_uncategorized(self): + """Test uncategorized changes fall to other.""" + change = Change(message="Random change", sha="abc", author="user") + assert categorize_change(change) == "other" + + def test_commit_prefix_takes_precedence_over_label(self): + """Test that commit prefix categorization takes precedence.""" + change = Change( + message="feat: Add feature", + sha="abc", + author="user", + pr_labels=["bug"], # Conflicting label + ) + # Should be categorized as feature based on prefix + assert categorize_change(change) == "features" + + def test_keyword_categorizes_docs(self): + """Test docs keyword fallback categorization.""" + change = Change( + message="Update documentation with new examples", + sha="abc", + author="user", + ) + assert categorize_change(change) == "docs" + + def test_keyword_categorizes_internal_before_feature(self): + """Test internal keyword fallback takes precedence over generic feature verbs.""" + change = Change( + message="Add extensive typing to controller directory", + sha="abc", + author="user", + ) + assert categorize_change(change) == "internal" + + def test_keyword_categorizes_fixes(self): + """Test fix keyword fallback categorization.""" + change = Change( + message="Resolve timeout error in session reconnect", + sha="abc", + author="user", + ) + assert categorize_change(change) == "fixes" + + def test_keyword_categorizes_features(self): + """Test feature keyword fallback categorization.""" + change = Change( + message="Add VS Code tab alongside the terminal", + sha="abc", + author="user", + ) + assert categorize_change(change) == "features" + + +class TestReleaseNotes: + """Tests for the ReleaseNotes dataclass.""" + + def test_to_markdown_basic(self): + """Test basic release notes generation.""" + notes = ReleaseNotes( + tag="v1.0.0", + previous_tag="v0.9.0", + date="2026-03-06", + repo_name="owner/repo", + changes={ + "features": [ + Change(message="Add feature", sha="abc", author="user1", pr_number=1) + ], + "fixes": [ + Change(message="Fix bug", sha="def", author="user2", pr_number=2) + ], + }, + ) + markdown = notes.to_markdown() + + assert "## [v1.0.0] - 2026-03-06" in markdown + assert "### ✨ New Features" in markdown + assert "### 🐛 Bug Fixes" in markdown + assert "Add feature (#1) @user1" in markdown + assert "(#2) @user2" in markdown # Fix bug becomes Bug due to prefix stripping + assert "compare/v0.9.0...v1.0.0" in markdown + + def test_to_markdown_with_breaking_changes(self): + """Test release notes with breaking changes.""" + notes = ReleaseNotes( + tag="v2.0.0", + previous_tag="v1.0.0", + date="2026-03-06", + repo_name="owner/repo", + changes={ + "breaking": [ + Change(message="Remove API", sha="abc", author="user", pr_number=1) + ], + }, + ) + markdown = notes.to_markdown() + + assert "### ⚠️ Breaking Changes" in markdown + assert "Remove API" in markdown + + def test_to_markdown_with_new_contributors(self): + """Test release notes with new contributors.""" + notes = ReleaseNotes( + tag="v1.0.0", + previous_tag="v0.9.0", + date="2026-03-06", + repo_name="owner/repo", + changes={}, + new_contributors=[ + Contributor(username="newuser", first_pr=42, is_new=True), + ], + ) + markdown = notes.to_markdown() + + assert "### 👥 New Contributors" in markdown + assert "@newuser made their first contribution in #42" in markdown + + def test_to_markdown_internal_excluded_by_default(self): + """Test that internal changes are excluded by default.""" + notes = ReleaseNotes( + tag="v1.0.0", + previous_tag="v0.9.0", + date="2026-03-06", + repo_name="owner/repo", + changes={ + "internal": [ + Change(message="Update CI", sha="abc", author="user", pr_number=1) + ], + }, + ) + markdown = notes.to_markdown(include_internal=False) + + assert "Internal" not in markdown + + def test_to_markdown_internal_included_when_requested(self): + """Test that internal changes are included when requested.""" + notes = ReleaseNotes( + tag="v1.0.0", + previous_tag="v0.9.0", + date="2026-03-06", + repo_name="owner/repo", + changes={ + "internal": [ + Change(message="Update CI", sha="abc", author="user", pr_number=1) + ], + }, + ) + markdown = notes.to_markdown(include_internal=True) + + assert "### 🏗️ Internal/Infrastructure" in markdown + assert "Update CI" in markdown + + def test_to_markdown_omits_other_changes(self): + """Test that uncategorized changes are omitted for a more concise summary.""" + notes = ReleaseNotes( + tag="v1.0.0", + previous_tag="v0.9.0", + date="2026-03-06", + repo_name="owner/repo", + changes={ + "other": [ + Change(message="Random internal cleanup", sha="abc", author="user") + ], + }, + ) + markdown = notes.to_markdown() + + assert "Other Changes" not in markdown + assert "Random internal cleanup" not in markdown + + +class TestProcessingHelpers: + """Tests for processing helpers.""" + + def test_dedupe_changes_collapses_multiple_commits_from_same_pr(self): + """Test that only one entry is kept per PR.""" + changes = [ + Change(message="First commit", sha="abc", author="user", pr_number=10), + Change(message="Second commit", sha="def", author="user", pr_number=10), + Change(message="Standalone commit", sha="ghi", author="user"), + ] + + deduped = _dedupe_changes(changes) + + assert [change.pr_number for change in deduped] == [10, None] + assert [change.sha for change in deduped] == ["abc", "ghi"] + + @patch("generate_release_notes.get_pr_for_commit") + def test_process_commit_prefers_pr_title_and_author(self, mock_get_pr_for_commit): + """Test that PR metadata is preferred for user-facing release note entries.""" + mock_get_pr_for_commit.return_value = { + "number": 42, + "title": "Add settings page", + "body": "Adds a new settings page for managing preferences.", + "html_url": "https://github.com/owner/repo/pull/42", + "labels": [{"name": "enhancement"}], + "user": {"login": "pr-author"}, + } + commit = { + "sha": "abc123", + "commit": {"message": "feat: Low-level implementation detail\n\nMore text"}, + "author": {"login": "commit-author"}, + } + + change = _process_commit(commit, "owner/repo", "token") + + assert change is not None + assert change.message == "Add settings page" + assert change.author == "pr-author" + assert change.pr_number == 42 + assert change.pr_labels == ["enhancement"] + assert change.body == "Adds a new settings page for managing preferences." + assert change.url == "https://github.com/owner/repo/pull/42" + + @patch("generate_release_notes.github_api_request") + def test_get_commits_between_tags_warns_on_truncation(self, mock_github_api_request, capsys): + """Test that compare API truncation is surfaced to users.""" + mock_github_api_request.return_value = { + "total_commits": 300, + "commits": [{"sha": "abc"}], + } + + commits = get_commits_between_tags("owner/repo", "v1.0.0", "v1.1.0", "token") + + assert commits == [{"sha": "abc"}] + assert "truncated the commit list" in capsys.readouterr().err + + +class TestPrompt: + """Tests for the release-notes agent prompt.""" + + def test_format_prompt_includes_editorial_instructions(self): + """Test that the prompt tells the agent to make editorial judgments.""" + prompt = format_prompt( + repo_name="owner/repo", + tag="v1.2.0", + previous_tag="v1.1.0", + date="2026-03-07", + commit_count=12, + include_internal=False, + output_format="release", + full_changelog_url="https://github.com/owner/repo/compare/v1.1.0...v1.2.0", + change_candidates="- Ref: #42\n Title: Add dark mode", + new_contributors="- @new-user made their first contribution in #42", + ) + + assert "Write official release notes for `owner/repo` tag `v1.2.0`." in prompt + assert "decide which PRs are important enough to mention" in prompt + assert "group related PRs into a single bullet" in prompt + assert "omit trivial, repetitive, or low-signal changes" in prompt + assert "include every new contributor listed below" in prompt + assert "Current tag: v1.2.0" in prompt + assert "Full changelog URL:" in prompt + + + +class TestCategories: + """Tests for the CATEGORIES constant.""" + + def test_all_categories_have_required_fields(self): + """Test that all categories have the required fields.""" + required_fields = ["emoji", "title", "commit_patterns", "labels"] + for category, info in CATEGORIES.items(): + for field in required_fields: + assert field in info, f"Category {category} missing {field}" + + def test_breaking_category_exists(self): + """Test that the breaking category exists.""" + assert "breaking" in CATEGORIES + assert CATEGORIES["breaking"]["emoji"] == "⚠️" + + def test_features_category_exists(self): + """Test that the features category exists.""" + assert "features" in CATEGORIES + assert CATEGORIES["features"]["emoji"] == "✨" + + def test_fixes_category_exists(self): + """Test that the fixes category exists.""" + assert "fixes" in CATEGORIES + assert CATEGORIES["fixes"]["emoji"] == "🐛" + + def test_docs_category_exists(self): + """Test that the docs category exists.""" + assert "docs" in CATEGORIES + assert CATEGORIES["docs"]["emoji"] == "📚" + + def test_internal_category_exists(self): + """Test that the internal category exists.""" + assert "internal" in CATEGORIES + assert CATEGORIES["internal"]["emoji"] == "🏗️" + + +class TestPluginStructure: + """Tests for the plugin directory structure.""" + + def test_plugin_directory_exists(self): + """Test that the plugin directory exists.""" + plugin_dir = Path(__file__).parent.parent / "plugins" / "release-notes" + assert plugin_dir.is_dir() + + def test_skill_md_exists(self): + """Test that SKILL.md exists.""" + skill_md = Path(__file__).parent.parent / "plugins" / "release-notes" / "SKILL.md" + assert skill_md.is_file() + + def test_readme_exists(self): + """Test that README.md exists.""" + readme = Path(__file__).parent.parent / "plugins" / "release-notes" / "README.md" + assert readme.is_file() + + def test_action_yml_exists(self): + """Test that action.yml exists.""" + action = Path(__file__).parent.parent / "plugins" / "release-notes" / "action.yml" + assert action.is_file() + + def test_script_exists(self): + """Test that the generator script exists.""" + script = ( + Path(__file__).parent.parent + / "plugins" + / "release-notes" + / "scripts" + / "generate_release_notes.py" + ) + assert script.is_file() + + def test_workflow_exists(self): + """Test that the workflow file exists.""" + workflow = ( + Path(__file__).parent.parent + / "plugins" + / "release-notes" + / "workflows" + / "release-notes.yml" + ) + assert workflow.is_file() + + def test_agent_script_exists(self): + """Test that the agent orchestration script exists.""" + script = ( + Path(__file__).parent.parent + / "plugins" + / "release-notes" + / "scripts" + / "agent_script.py" + ) + assert script.is_file() + + def test_prompt_script_exists(self): + """Test that the prompt template exists.""" + prompt = ( + Path(__file__).parent.parent + / "plugins" + / "release-notes" + / "scripts" + / "prompt.py" + ) + assert prompt.is_file() + + def test_skills_symlink_exists(self): + """Test that the skills symlink exists.""" + symlink = ( + Path(__file__).parent.parent + / "plugins" + / "release-notes" + / "skills" + / "releasenotes" + ) + assert symlink.exists() + + +if __name__ == "__main__": + pytest.main([__file__, "-v"])