diff --git a/README.md b/README.md index e7b9197..02ed9e9 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,10 @@ Add this to your workflow file (e.g., `.github/workflows/changelog-check.yml`): | `changelog-path` | `changelog/unreleased` | Path to changelog directory | | `on-missing-entry` | `fail` | Action: `fail`, `warn`, or `generate` | | `skip-files-regex` | (empty) | Skip changelog if all files match pattern | +| `skip-changelog-labels` | (empty) | Skip changelog if PR has any of these labels (comma-separated) | +| `missing-entry-message` | (see below) | Custom message when changelog entry is missing | | `claude-token` | (empty) | Claude API key for generation | -| `claude-model` | `claude-3-5-sonnet-20241022` | Claude model to use | +| `claude-model` | `claude-opus-4-1-20250805` | Claude model to use | | `changelog-language` | `English` | Language for generated entries | ### Validation Rules @@ -79,6 +81,29 @@ Add this to your workflow file (e.g., `.github/workflows/changelog-check.yml`): | `max-tokens-per-file` | `1000` | Max tokens per file | | `claude-system-prompt` | (built-in) | Custom AI instructions | +### Managed Changelog Support + +| Input | Default | Description | +|-------|---------|-------------| +| `managed-changelog-paths` | `CHANGELOG.md` | Managed changelog files to detect (comma-separated). Set to empty to disable. | +| `on-managed-entry` | `convert` | Action when managed file is edited: `fail` (no help), `warn` (comment), `remove` (suggest removal), `convert` (LLM conversion) | +| `on-managed-and-logchange` | `warn` | Action when both managed and logchange entries exist: `fail`, `warn`, or `ignore` | +| `managed-entry-message` | (see below) | Custom message when managed entry is detected | +| `managed-conflict-message` | (see below) | Custom message when both entry types exist | + +### Validation Messages & Behavior + +| Input | Default | Description | +|-------|---------|-------------| +| `validation-fail-message` | (see below) | Custom message when changelog entry fails validation | +| `validation-fail-workflow` | `true` | Whether to fail the workflow on validation errors | + +### Testing & Debugging + +| Input | Default | Description | +|-------|---------|-------------| +| `dry-run` | `false` | Generate suggestions without posting comments (for testing) | + ## Examples **Skip changelog for docs-only changes:** diff --git a/action.yml b/action.yml index 4dded84..e3b004a 100644 --- a/action.yml +++ b/action.yml @@ -81,30 +81,30 @@ inputs: required: false default: '' - legacy-changelog-paths: - description: 'Comma-separated list of legacy changelog file paths to detect (e.g., CHANGELOG.md,HISTORY.txt). Defaults to CHANGELOG.md. Set to empty string to disable legacy changelog detection.' + managed-changelog-paths: + description: 'Comma-separated list of managed changelog file paths to detect (e.g., CHANGELOG.md,HISTORY.txt). Defaults to CHANGELOG.md. Set to empty string to disable managed changelog detection.' required: false default: 'CHANGELOG.md' - on-legacy-entry: - description: 'Action when legacy changelog entry is found. Always fails the action with different levels of help: "fail" (just fail), "warn" (fail + comment), "remove" (fail + removal suggestions), "convert" (fail + removal suggestions + LLM conversion to logchange)' + on-managed-entry: + description: 'Action when managed changelog file is edited. Always fails the action with different levels of help: "fail" (just fail), "warn" (fail + comment), "remove" (fail + removal suggestions), "convert" (fail + removal suggestions + LLM conversion to logchange)' required: false default: 'convert' on-legacy-and-logchange: - description: 'Action when both legacy and logchange entries in same PR: "fail", "warn", or "ignore"' + description: 'Action when both managed and logchange entries in same PR: "fail", "warn", or "ignore"' required: false default: 'warn' - legacy-entry-message: - description: 'Custom message when legacy changelog entry is found' + managed-entry-message: + description: 'Custom message when managed changelog file is detected' required: false - default: 'I detected a legacy changelog entry. Converting it to logchange format...' + default: 'I detected a managed changelog file. Converting it to logchange format...' - legacy-conflict-message: - description: 'Custom message when both legacy and logchange entries are present' + managed-conflict-message: + description: 'Custom message when both managed and logchange entries are present' required: false - default: 'This PR contains both legacy and logchange changelog entries. Please use only logchange format.' + default: 'This PR contains both managed and logchange changelog entries. Please use only logchange format.' external-issue-regex: description: 'Regex pattern to detect external issue references (e.g., JIRA-123). Must have capture group for issue ID. Example: "JIRA-(\d+)"' @@ -148,12 +148,12 @@ outputs: description: 'Whether the changelog entry is valid' changelog-generated: description: 'Whether a changelog entry was generated' - legacy-entry-found: - description: 'Whether a legacy changelog entry was found' - legacy-converted: - description: 'Whether a legacy entry was converted to logchange format' - legacy-conflict: - description: 'Whether both legacy and logchange entries were found' + managed-entry-found: + description: 'Whether a managed changelog file was found' + managed-converted: + description: 'Whether a managed entry was converted to logchange format' + managed-conflict: + description: 'Whether both managed and logchange entries were found' generation-error: description: 'Error message if generation failed' diff --git a/action/src/config.py b/action/src/config.py index 3984933..0d31a3f 100644 --- a/action/src/config.py +++ b/action/src/config.py @@ -53,19 +53,19 @@ def __init__(self): self.optional_fields = self._parse_list_input("optional-fields", "") # Legacy changelog configuration (enabled by default with CHANGELOG.md) - self.legacy_changelog_paths = self._parse_list_input( + self.managed_changelog_paths = self._parse_list_input( "legacy-changelog-paths", "CHANGELOG.md" ) - self.on_legacy_entry = self._get_input("on-legacy-entry", "convert").lower() - self.on_legacy_and_logchange = self._get_input( + self.on_managed_entry = self._get_input("on-legacy-entry", "convert").lower() + self.on_managed_and_logchange = self._get_input( "on-legacy-and-logchange", "warn" ).lower() - self.legacy_entry_message = self._get_input( + self.managed_entry_message = self._get_input( "legacy-entry-message", "I detected a legacy changelog entry. Converting it to logchange format...", ) - self.legacy_conflict_message = self._get_input( - "legacy-conflict-message", + self.managed_conflict_message = self._get_input( + "managed-conflict-message", "This PR contains both legacy and logchange changelog entries. Please use only logchange format.", ) @@ -173,16 +173,16 @@ def _validate_config(self) -> None: ) # Validate on_legacy_entry mode - if self.on_legacy_entry not in ("convert", "warn", "fail", "remove"): + if self.on_managed_entry not in ("convert", "warn", "fail", "remove"): raise ConfigurationError( - f"Invalid on-legacy-entry mode: {self.on_legacy_entry}. " + f"Invalid on-legacy-entry mode: {self.on_managed_entry}. " "Must be one of: fail, warn, remove, convert" ) # Validate on_legacy_and_logchange mode - if self.on_legacy_and_logchange not in ("fail", "warn", "ignore"): + if self.on_managed_and_logchange not in ("fail", "warn", "ignore"): raise ConfigurationError( - f"Invalid on-legacy-and-logchange mode: {self.on_legacy_and_logchange}. " + f"Invalid on-legacy-and-logchange mode: {self.on_managed_and_logchange}. " "Must be one of: fail, warn, ignore" ) @@ -230,7 +230,7 @@ def get_summary(self) -> str: f"Validation types: {', '.join(self.changelog_types)}", f"Mandatory fields: {', '.join(self.mandatory_fields)}", f"Forbidden fields: {', '.join(self.forbidden_fields) if self.forbidden_fields else '(none)'}", - f"Legacy support: {len(self.legacy_changelog_paths)} paths configured", + f"Legacy support: {len(self.managed_changelog_paths)} paths configured", f"Dry-run mode: {'enabled' if self.dry_run else 'disabled'}", "=" * 40, ] diff --git a/action/src/main.py b/action/src/main.py index 3e69d8b..6ba6e4f 100644 --- a/action/src/main.py +++ b/action/src/main.py @@ -23,7 +23,7 @@ from config import ActionConfig from exceptions import ConfigurationError, GenerationError from github_client import GitHubClient -from legacy_changelog_handler import LegacyChangelogHandler +from managed_changelog_handler import ManagedChangelogHandler from pr_metadata_extractor import PRMetadataExtractor @@ -82,11 +82,11 @@ def __init__(self): self.mandatory_fields = self.config.mandatory_fields self.forbidden_fields = self.config.forbidden_fields self.optional_fields = self.config.optional_fields - self.legacy_changelog_paths = self.config.legacy_changelog_paths - self.on_legacy_entry = self.config.on_legacy_entry - self.on_legacy_and_logchange = self.config.on_legacy_and_logchange - self.legacy_entry_message = self.config.legacy_entry_message - self.legacy_conflict_message = self.config.legacy_conflict_message + self.managed_changelog_paths = self.config.managed_changelog_paths + self.on_managed_entry = self.config.on_legacy_entry + self.on_managed_and_logchange = self.config.on_legacy_and_logchange + self.managed_entry_message = self.config.legacy_entry_message + self.managed_conflict_message = self.config.legacy_conflict_message self.validation_fail_message = self.config.validation_fail_message self.validation_fail_workflow = self.config.validation_fail_workflow self.external_issue_regex = self.config.external_issue_regex @@ -110,7 +110,7 @@ def __init__(self): forbidden_fields=self.forbidden_fields, optional_fields=self.optional_fields, ) - self.legacy_handler = LegacyChangelogHandler(self.legacy_changelog_paths) + self.managed_handler = ManagedChangelogHandler(self.managed_changelog_paths) self.metadata_extractor = PRMetadataExtractor( external_issue_regex=( self.external_issue_regex if self.external_issue_regex else None @@ -228,9 +228,9 @@ def run(self) -> int: return 0 # Check for legacy changelog entries - legacy_files = self.legacy_handler.find_legacy_changelog_files(pr_files) - if legacy_files: - logger.info(f"Found {len(legacy_files)} legacy changelog file(s)") + managed_files = self.managed_handler.find_managed_changelog_files(pr_files) + if managed_files: + logger.info(f"Found {len(managed_files)} legacy changelog file(s)") # Continue checking for logchange files to detect conflicts # Get edited files in changelog path @@ -238,15 +238,15 @@ def run(self) -> int: logger.info(f"Found {len(changelog_files)} logchange file(s) in PR") # Check for conflict (both legacy and logchange present) - if self.legacy_handler.should_fail_on_conflict( - legacy_files, changelog_files + if self.managed_handler.should_fail_on_conflict( + managed_files, changelog_files ): - return self._handle_legacy_conflict(legacy_files, changelog_files) + return self._handle_managed_conflict(managed_files, changelog_files) if changelog_files: return self._handle_existing_changelog(changelog_files) - elif legacy_files: - return self._handle_legacy_changelog(legacy_files, pr_files) + elif managed_files: + return self._handle_managed_changelog(managed_files, pr_files) else: return self._handle_missing_changelog(pr_files) @@ -539,30 +539,30 @@ def _format_suggestion_comment(self, generated_entry: str) -> str: return comment - def _handle_legacy_conflict( - self, legacy_files: List[str], changelog_files: List[str] + def _handle_managed_conflict( + self, managed_files: List[str], changelog_files: List[str] ) -> int: """Handle case where both legacy and logchange entries exist""" logger.warning( - f"Conflict: {len(legacy_files)} legacy files and {len(changelog_files)} logchange files found" + f"Conflict: {len(managed_files)} legacy files and {len(changelog_files)} logchange files found" ) - if self.on_legacy_and_logchange == "fail": - self.github_client.comment_on_pr(f"❌ {self.legacy_conflict_message}") - self.set_output("legacy-conflict", "true") + if self.on_managed_and_logchange == "fail": + self.github_client.comment_on_pr(f"❌ {self.managed_conflict_message}") + self.set_output("managed-conflict", "true") return 1 - elif self.on_legacy_and_logchange == "warn": - self.github_client.comment_on_pr(f"⚠️ {self.legacy_conflict_message}") - self.set_output("legacy-conflict", "true") + elif self.on_managed_and_logchange == "warn": + self.github_client.comment_on_pr(f"⚠️ {self.managed_conflict_message}") + self.set_output("managed-conflict", "true") # Continue to validate the logchange entries return self._handle_existing_changelog(changelog_files) else: # ignore logger.info("Ignoring legacy/logchange conflict as configured") - self.set_output("legacy-conflict", "true") + self.set_output("managed-conflict", "true") return self._handle_existing_changelog(changelog_files) - def _handle_legacy_changelog( - self, legacy_files: List[str], pr_files: List[str] + def _handle_managed_changelog( + self, managed_files: List[str], pr_files: List[str] ) -> int: """Handle case where legacy changelog entry exists but no logchange entry. @@ -572,66 +572,66 @@ def _handle_legacy_changelog( - "remove": Fail + add removal suggestions - "convert": Fail + removal suggestions + LLM conversion """ - logger.info(f"Handling {len(legacy_files)} legacy changelog file(s)") - self.set_output("legacy-entry-found", "true") + logger.info(f"Handling {len(managed_files)} legacy changelog file(s)") + self.set_output("managed-entry-found", "true") - legacy_file = legacy_files[0] + managed_file = managed_files[0] # Level 1: "fail" - just fail, no help - if self.on_legacy_entry == "fail": + if self.on_managed_entry == "fail": logger.error("Legacy changelog entry found, failing as configured (no help)") return 1 # Level 2: "warn" - fail + warning comment - if self.on_legacy_entry == "warn": + if self.on_managed_entry == "warn": logger.warning("Legacy changelog entry found, warning as configured") - self.github_client.comment_on_pr(f"⚠️ {self.legacy_entry_message}") + self.github_client.comment_on_pr(f"⚠️ {self.managed_entry_message}") return 1 # Level 3 & 4: Need to extract lines for removal suggestions # Get the diff for the legacy file (needed for both "remove" and "convert") - pr_diff = self.github_client.get_pr_diff([legacy_file]) + pr_diff = self.github_client.get_pr_diff([managed_file]) if not pr_diff: - logger.error(f"Could not get diff for legacy file: {legacy_file}") + logger.error(f"Could not get diff for legacy file: {managed_file}") # Can't provide removal suggestions without diff, so just warn and fail self.github_client.comment_on_pr( - f"⚠️ {self.legacy_entry_message} (Could not extract changelog entry for removal suggestions)" + f"⚠️ {self.managed_entry_message} (Could not extract changelog entry for removal suggestions)" ) return 1 # Extract the changelog entry from the diff - entry_text = self.legacy_handler.extract_changelog_entry_from_diff(pr_diff) + entry_text = self.managed_handler.extract_changelog_entry_from_diff(pr_diff) if not entry_text: logger.error( - f"Could not extract changelog entry from diff of {legacy_file}" + f"Could not extract changelog entry from diff of {managed_file}" ) self.github_client.comment_on_pr( - f"⚠️ {self.legacy_entry_message} (Could not parse changelog entry)" + f"⚠️ {self.managed_entry_message} (Could not parse changelog entry)" ) return 1 # Extract line numbers for suggested removal (needed for both "remove" and "convert") - added_lines = self.legacy_handler.extract_added_lines_with_positions( - pr_diff, legacy_file + added_lines = self.managed_handler.extract_added_lines_with_positions( + pr_diff, managed_file ) logger.info(f"Found {len(added_lines)} added lines in legacy changelog diff") # Level 3: "remove" - fail + removal suggestions - if self.on_legacy_entry == "remove": + if self.on_managed_entry == "remove": logger.warning( "Legacy changelog entry found, posting removal suggestions as configured" ) - self.github_client.comment_on_pr(f"⚠️ {self.legacy_entry_message}") + self.github_client.comment_on_pr(f"⚠️ {self.managed_entry_message}") # Post removal suggestions (without conversion) - self._post_legacy_removal_suggestions( - legacy_file, added_lines, pr_diff, is_converting=False + self._post_managed_removal_suggestions( + managed_file, added_lines, pr_diff, is_converting=False ) return 1 # Level 4: "convert" - fail + conversion + removal suggestions - if self.on_legacy_entry == "convert": + if self.on_managed_entry == "convert": logger.warning("Legacy changelog entry found, attempting conversion as configured") # Attempt to convert legacy entry to logchange format @@ -644,34 +644,34 @@ def _handle_legacy_changelog( ) self.set_output("generation-error", "No Claude API token provided") # Still post removal suggestions even if conversion fails - self._post_legacy_removal_suggestions(legacy_file, added_lines, pr_diff) + self._post_managed_removal_suggestions(managed_file, added_lines, pr_diff) return 1 # Try to convert - converted_entry = self._attempt_legacy_conversion( - legacy_file, entry_text, added_lines, pr_diff, pr_files + converted_entry = self._attempt_managed_conversion( + managed_file, entry_text, added_lines, pr_diff, pr_files ) # Post removal suggestions regardless of conversion success - self._post_legacy_removal_suggestions(legacy_file, added_lines, pr_diff) + self._post_managed_removal_suggestions(managed_file, added_lines, pr_diff) # If conversion succeeded, also post the converted entry if converted_entry: - self.set_output("legacy-converted", "true") - suggestion_comment = self._format_legacy_conversion_comment( - converted_entry, legacy_file + self.set_output("managed-converted", "true") + suggestion_comment = self._format_managed_conversion_comment( + converted_entry, managed_file ) self.github_client.comment_on_pr(suggestion_comment) return 1 # Fallback - should not reach here if config is valid, but just in case - logger.error(f"Unknown on-legacy-entry mode: {self.on_legacy_entry}") + logger.error(f"Unknown on-legacy-entry mode: {self.on_managed_entry}") return 1 - def _post_legacy_removal_suggestions( + def _post_managed_removal_suggestions( self, - legacy_file: str, + managed_file: str, added_lines: List[tuple], pr_diff: str, is_converting: bool = True, @@ -681,7 +681,7 @@ def _post_legacy_removal_suggestions( Suggests restoring removed lines and removing added lines. Args: - legacy_file: Path to the legacy changelog file + managed_file: Path to the legacy changelog file added_lines: List of tuples (line_number, line_content) pr_diff: The diff of the legacy file is_converting: If True, message says "was converted"; if False, says "should be removed" @@ -696,11 +696,11 @@ def _post_legacy_removal_suggestions( total_comments = 0 # Extract and handle removed lines (suggest restoring them) - removed_lines = self.legacy_handler.extract_removed_lines_with_positions( - pr_diff, legacy_file + removed_lines = self.managed_handler.extract_removed_lines_with_positions( + pr_diff, managed_file ) if removed_lines: - line_groups = self.legacy_handler.group_consecutive_lines(removed_lines) + line_groups = self.managed_handler.group_consecutive_lines(removed_lines) logger.info( f"Creating {len(line_groups)} suggestion(s) to restore removed lines" ) @@ -718,7 +718,7 @@ def _post_legacy_removal_suggestions( ) self.github_client.create_review_comment_with_suggestion( commit_sha=commit_sha, - file_path=legacy_file, + file_path=managed_file, line=end_line, body=suggestion_body, side="LEFT", @@ -731,7 +731,7 @@ def _post_legacy_removal_suggestions( ) self.github_client.create_review_comment_with_suggestion( commit_sha=commit_sha, - file_path=legacy_file, + file_path=managed_file, start_line=start_line, line=end_line, body=suggestion_body, @@ -741,7 +741,7 @@ def _post_legacy_removal_suggestions( # Extract and handle added lines (suggest removing them) if added_lines: - line_groups = self.legacy_handler.group_consecutive_lines(added_lines) + line_groups = self.managed_handler.group_consecutive_lines(added_lines) logger.info(f"Creating {len(line_groups)} suggestion(s) to remove added lines") for start_line, end_line, group_content in line_groups: @@ -763,7 +763,7 @@ def _post_legacy_removal_suggestions( ) self.github_client.create_review_comment_with_suggestion( commit_sha=commit_sha, - file_path=legacy_file, + file_path=managed_file, line=end_line, body=suggestion_body, side="RIGHT", @@ -775,7 +775,7 @@ def _post_legacy_removal_suggestions( ) self.github_client.create_review_comment_with_suggestion( commit_sha=commit_sha, - file_path=legacy_file, + file_path=managed_file, start_line=start_line, line=end_line, body=suggestion_body, @@ -786,9 +786,9 @@ def _post_legacy_removal_suggestions( if total_comments > 0: logger.info(f"Created {total_comments} total comments on legacy file changes") - def _attempt_legacy_conversion( + def _attempt_managed_conversion( self, - legacy_file: str, + managed_file: str, entry_text: str, added_lines: List[tuple], pr_diff: str, @@ -797,7 +797,7 @@ def _attempt_legacy_conversion( """Attempt to convert a legacy changelog entry to logchange format. Args: - legacy_file: Path to the legacy changelog file + managed_file: Path to the legacy changelog file entry_text: The extracted changelog entry text added_lines: List of tuples (line_number, line_content) pr_diff: The diff of the legacy file @@ -807,15 +807,15 @@ def _attempt_legacy_conversion( The generated logchange YAML entry, or None if conversion failed """ try: - logger.info(f"Converting legacy changelog: {legacy_file}") + logger.info(f"Converting legacy changelog: {managed_file}") # Build context about the legacy entry - context = self.legacy_handler.build_legacy_context(entry_text) + context = self.managed_handler.build_legacy_context(entry_text) logger.info(f"Legacy entry context: {context}") # Create custom prompt for conversion (pass PR diff for relevance validation) pr_info = self.github_event.get("pull_request", {}) - conversion_prompt = self.legacy_handler.create_conversion_prompt( + conversion_prompt = self.managed_handler.create_conversion_prompt( entry_text, pr_info, context, @@ -845,7 +845,7 @@ def _attempt_legacy_conversion( "ℹ️ Found changes to changelog but the entry appears unrelated to the code changes in this PR. " "Skipping conversion. Please review the entry in the changelog manually if you think this is incorrect." ) - self.set_output("legacy-converted", "false") + self.set_output("managed-converted", "false") return None logger.info("Legacy changelog successfully converted to logchange format") @@ -858,19 +858,19 @@ def _attempt_legacy_conversion( self.set_output("generation-error", str(e)) return None - def _convert_legacy_to_logchange( - self, legacy_file: str, pr_files: List[str] + def _convert_managed_to_logchange( + self, managed_file: str, pr_files: List[str] ) -> int: """Convert a legacy changelog entry to logchange format (legacy method, deprecated)""" # This method is deprecated and kept for backward compatibility - # New code should use _handle_legacy_changelog with proper on_legacy_entry handling + # New code should use _handle_managed_changelog with proper on_legacy_entry handling logger.warning( - "_convert_legacy_to_logchange() is deprecated, use _handle_legacy_changelog() instead" + "_convert_managed_to_logchange() is deprecated, use _handle_managed_changelog() instead" ) return 1 - def _format_legacy_conversion_comment( - self, generated_entry: str, legacy_file: str + def _format_managed_conversion_comment( + self, generated_entry: str, managed_file: str ) -> str: """Format the converted legacy changelog entry as a suggestion comment""" # Get PR number and title for filename @@ -884,7 +884,7 @@ def _format_legacy_conversion_comment( comment = f"""🔄 **I've converted the changelog entry to logchange format!** -I detected changes to `{legacy_file}` and converted them to the logchange format below. +I detected changes to `{managed_file}` and converted them to the logchange format below. **Suggested Logchange Entry** for `{file_path}`: @@ -899,7 +899,7 @@ def _format_legacy_conversion_comment( - Copy the YAML above into it 2. ⚠️ **Remove** the original entry: - - I've added suggested edits to remove the lines you added to `{legacy_file}` + - I've added suggested edits to remove the lines you added to `{managed_file}` - Review the suggestions in the PR review and accept them 3. 📝 **Review** before merging: diff --git a/action/src/legacy_changelog_handler.py b/action/src/managed_changelog_handler.py similarity index 83% rename from action/src/legacy_changelog_handler.py rename to action/src/managed_changelog_handler.py index 665d82e..6e9d195 100644 --- a/action/src/legacy_changelog_handler.py +++ b/action/src/managed_changelog_handler.py @@ -1,4 +1,4 @@ -"""Legacy changelog detection and conversion handler""" +"""Managed changelog detection and conversion handler""" import logging import re @@ -9,53 +9,53 @@ logger = logging.getLogger(__name__) -class LegacyChangelogHandler: - """Detect and handle legacy changelog entries""" +class ManagedChangelogHandler: + """Detect and handle managed changelog entries""" - def __init__(self, legacy_changelog_paths: Optional[List[str]] = None): + def __init__(self, managed_changelog_paths: Optional[List[str]] = None): """ - Initialize legacy changelog handler + Initialize managed changelog handler Args: - legacy_changelog_paths: List of file paths/patterns to check for legacy changelogs. - If empty/None, legacy changelog detection is DISABLED. + managed_changelog_paths: List of file paths/patterns to check for managed changelogs. + If empty/None, managed changelog detection is DISABLED. Examples: ['CHANGELOG.md', 'HISTORY.txt', 'docs/CHANGES.md'] """ - self.legacy_changelog_paths = legacy_changelog_paths or [] - self.is_enabled = len(self.legacy_changelog_paths) > 0 + self.managed_changelog_paths = managed_changelog_paths or [] + self.is_enabled = len(self.managed_changelog_paths) > 0 if self.is_enabled: logger.info( - f"Initialized legacy changelog handler with {len(self.legacy_changelog_paths)} paths" + f"Initialized managed changelog handler with {len(self.managed_changelog_paths)} paths" ) else: - logger.info("Legacy changelog detection is DISABLED (no paths configured)") + logger.info("Managed changelog detection is DISABLED (no paths configured)") - def find_legacy_changelog_files(self, pr_files: List[str]) -> List[str]: + def find_managed_changelog_files(self, pr_files: List[str]) -> List[str]: """ - Find legacy changelog files in PR + Find managed changelog files in PR Args: pr_files: List of all files modified in PR Returns: - List of legacy changelog file paths found in PR (empty if detection disabled) + List of managed changelog file paths found in PR (empty if detection disabled) """ if not self.is_enabled: - logger.debug("Legacy changelog detection is disabled, skipping search") + logger.debug("Managed changelog detection is disabled, skipping search") return [] - legacy_files = [] + managed_files = [] for pr_file in pr_files: - # Check if file matches any legacy changelog pattern - for legacy_path in self.legacy_changelog_paths: + # Check if file matches any managed changelog pattern + for managed_path in self.managed_changelog_paths: # Support exact matches and patterns like 'docs/CHANGELOG.md' - if pr_file.endswith(legacy_path) or pr_file == legacy_path: - legacy_files.append(pr_file) + if pr_file.endswith(managed_path) or pr_file == managed_path: + managed_files.append(pr_file) break - logger.info(f"Found {len(legacy_files)} legacy changelog file(s) in PR") - return legacy_files + logger.info(f"Found {len(managed_files)} managed changelog file(s) in PR") + return managed_files def extract_changelog_entry_from_diff(self, diff_content: str) -> Optional[str]: """ @@ -93,14 +93,14 @@ def extract_changelog_entry_from_diff(self, diff_content: str) -> Optional[str]: return None def extract_added_lines_with_positions( - self, diff_content: str, legacy_file: str + self, diff_content: str, managed_file: str ) -> List[Tuple[int, str]]: """ Extract changelog lines from diff with their line numbers for suggested edits. Args: diff_content: The diff output for the changelog file - legacy_file: The legacy file path (for matching the diff) + managed_file: The legacy file path (for matching the diff) Returns: List of tuples (line_number, line_content) for added lines in the new version @@ -146,14 +146,14 @@ def extract_added_lines_with_positions( return added_lines_with_pos def extract_removed_lines_with_positions( - self, diff_content: str, legacy_file: str + self, diff_content: str, managed_file: str ) -> List[Tuple[int, str]]: """ Extract removed changelog lines from diff with their line numbers. Args: diff_content: The diff output for the changelog file - legacy_file: The legacy file path (for matching the diff) + managed_file: The legacy file path (for matching the diff) Returns: List of tuples (line_number, line_content) for removed lines in the old version @@ -238,7 +238,7 @@ def group_consecutive_lines( return groups - def detect_entry_type(self, entry_text: str) -> str: + def detect_managed_entry_type(self, entry_text: str) -> str: """ Detect what type of changelog entry this is @@ -267,7 +267,7 @@ def detect_entry_type(self, entry_text: str) -> str: return "other" - def extract_version_and_date( + def extract_version_and_date_from_managed( self, entry_text: str ) -> Tuple[Optional[str], Optional[str]]: """ @@ -291,9 +291,9 @@ def extract_version_and_date( return None, None - def build_legacy_context(self, entry_text: str) -> Dict[str, Any]: + def build_managed_context(self, entry_text: str) -> Dict[str, Any]: """ - Build context information about a legacy changelog entry + Build context information about a managed changelog entry Args: entry_text: The changelog entry text @@ -301,8 +301,8 @@ def build_legacy_context(self, entry_text: str) -> Dict[str, Any]: Returns: Dictionary with: type, version, date, summary, line_count """ - entry_type = self.detect_entry_type(entry_text) - version, date = self.extract_version_and_date(entry_text) + entry_type = self.detect_managed_entry_type(entry_text) + version, date = self.extract_version_and_date_from_managed(entry_text) # Create a brief summary (first 100 chars) summary = entry_text[:100].replace("\n", " ").strip() @@ -318,7 +318,7 @@ def build_legacy_context(self, entry_text: str) -> Dict[str, Any]: "char_count": len(entry_text), } - def create_conversion_prompt( + def create_managed_conversion_prompt( self, entry_text: str, pr_info: Dict[str, Any], @@ -331,7 +331,7 @@ def create_conversion_prompt( Create a custom user prompt for converting legacy entry to logchange format Args: - entry_text: The legacy changelog entry text + entry_text: The managed changelog entry text pr_info: PR information from GitHub context: Context about the legacy entry changelog_types: List of allowed changelog types (uses defaults if not provided) @@ -386,7 +386,7 @@ def create_conversion_prompt( ``` """ - prompt = f"""I have extracted a changelog entry from a legacy changelog file. + prompt = f"""I have extracted a changelog entry from a managed changelog file. I need to convert it into logchange-formatted YAML while preserving the original text and intent. {validation_check} @@ -424,26 +424,26 @@ def create_conversion_prompt( return prompt - def should_fail_on_conflict( - self, legacy_files: List[str], logchange_files: List[str] + def should_fail_on_managed_logchange_conflict( + self, managed_files: List[str], logchange_files: List[str] ) -> bool: """ Determine if having both legacy and logchange entries is a conflict Args: - legacy_files: List of legacy changelog files with entries + managed_files: List of managed changelog files with entries logchange_files: List of logchange files with entries Returns: True if both types are present (conflict), False otherwise """ - has_legacy = len(legacy_files) > 0 + has_legacy = len(managed_files) > 0 has_logchange = len(logchange_files) > 0 is_conflict = has_legacy and has_logchange if is_conflict: logger.warning( - f"Conflict detected: {len(legacy_files)} legacy files and {len(logchange_files)} logchange files" + f"Conflict detected: {len(managed_files)} legacy files and {len(logchange_files)} logchange files" ) return is_conflict diff --git a/tests/test_integration.py b/tests/test_integration.py index 864f1b2..3c6afc5 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -86,15 +86,15 @@ def test_workflow_with_custom_rules(self): def test_workflow_legacy_changelog_detection(self): """Test detection of legacy changelog format""" - from legacy_changelog_handler import LegacyChangelogHandler + from managed_changelog_handler import ManagedChangelogHandler - handler = LegacyChangelogHandler( - legacy_changelog_paths=["CHANGELOG.md", "HISTORY.txt"] + handler = ManagedChangelogHandler( + managed_changelog_paths=["CHANGELOG.md", "HISTORY.txt"] ) # Test legacy paths are configured - assert "CHANGELOG.md" in handler.legacy_changelog_paths - assert "HISTORY.txt" in handler.legacy_changelog_paths + assert "CHANGELOG.md" in handler.managed_changelog_paths + assert "HISTORY.txt" in handler.managed_changelog_paths def test_pr_metadata_extraction_workflow(self): """Test PR metadata extraction in workflow""" diff --git a/tests/test_legacy_handler.py b/tests/test_managed_handler.py similarity index 65% rename from tests/test_legacy_handler.py rename to tests/test_managed_handler.py index f8c733f..1b2d04f 100644 --- a/tests/test_legacy_handler.py +++ b/tests/test_managed_handler.py @@ -7,16 +7,16 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "action", "src")) import pytest -from legacy_changelog_handler import LegacyChangelogHandler +from managed_changelog_handler import ManagedChangelogHandler -class TestLegacyChangelogHandler: +class TestManagedChangelogHandler: """Test legacy changelog detection and conversion""" @pytest.fixture def handler(self): """Create handler instance with default paths enabled for testing""" - return LegacyChangelogHandler( + return ManagedChangelogHandler( [ "CHANGELOG.md", "CHANGELOG.txt", @@ -33,48 +33,48 @@ def handler(self): def test_disabled_by_default(self): """Test that handler is disabled when initialized with empty paths""" - handler = LegacyChangelogHandler([]) + handler = ManagedChangelogHandler([]) assert handler.is_enabled is False def test_disabled_handler_returns_empty(self): """Test that disabled handler returns no files""" - handler = LegacyChangelogHandler([]) + handler = ManagedChangelogHandler([]) pr_files = ["src/main.py", "CHANGELOG.md", "README.md"] - legacy_files = handler.find_legacy_changelog_files(pr_files) - assert legacy_files == [] + managed_files = handler.find_managed_changelog_files(pr_files) + assert managed_files == [] def test_disabled_handler_with_none(self): """Test that handler with None paths is disabled""" - handler = LegacyChangelogHandler(None) + handler = ManagedChangelogHandler(None) assert handler.is_enabled is False - assert handler.find_legacy_changelog_files(["CHANGELOG.md"]) == [] + assert handler.find_managed_changelog_files(["CHANGELOG.md"]) == [] - def test_find_legacy_files_exact_match(self, handler): + def test_find_managed_files_exact_match(self, handler): """Test finding legacy files with exact name match""" pr_files = ["src/main.py", "CHANGELOG.md", "README.md"] - legacy_files = handler.find_legacy_changelog_files(pr_files) - assert "CHANGELOG.md" in legacy_files + managed_files = handler.find_managed_changelog_files(pr_files) + assert "CHANGELOG.md" in managed_files - def test_find_multiple_legacy_files(self, handler): + def test_find_multiple_managed_files(self, handler): """Test finding multiple legacy changelog files""" pr_files = ["src/main.py", "CHANGELOG.md", "HISTORY.txt", "README.md"] - legacy_files = handler.find_legacy_changelog_files(pr_files) - assert len(legacy_files) == 2 - assert "CHANGELOG.md" in legacy_files - assert "HISTORY.txt" in legacy_files + managed_files = handler.find_managed_changelog_files(pr_files) + assert len(managed_files) == 2 + assert "CHANGELOG.md" in managed_files + assert "HISTORY.txt" in managed_files - def test_find_no_legacy_files(self, handler): + def test_find_no_managed_files(self, handler): """Test when no legacy files are present""" pr_files = ["src/main.py", "README.md", "docs/index.md"] - legacy_files = handler.find_legacy_changelog_files(pr_files) - assert len(legacy_files) == 0 + managed_files = handler.find_managed_changelog_files(pr_files) + assert len(managed_files) == 0 - def test_find_legacy_files_with_paths(self, handler): + def test_find_managed_files_with_paths(self, handler): """Test finding legacy files in subdirectories""" pr_files = ["src/main.py", "docs/CHANGELOG.md", "docs/README.md"] - custom_handler = LegacyChangelogHandler(["docs/CHANGELOG.md"]) - legacy_files = custom_handler.find_legacy_changelog_files(pr_files) - assert "docs/CHANGELOG.md" in legacy_files + custom_handler = ManagedChangelogHandler(["docs/CHANGELOG.md"]) + managed_files = custom_handler.find_managed_changelog_files(pr_files) + assert "docs/CHANGELOG.md" in managed_files def test_extract_changelog_entry_from_diff(self, handler): """Test extracting changelog entry from diff""" @@ -112,62 +112,62 @@ def test_extract_changelog_no_added_lines(self, handler): assert "New text" in entry assert "Old text" not in entry - def test_detect_entry_type_markdown(self, handler): + def test_detect_managed_entry_type_markdown(self, handler): """Test detecting markdown-style changelog""" entry = "## Version 1.2.0\n### Added\n- New feature" - entry_type = handler.detect_entry_type(entry) + entry_type = handler.detect_managed_entry_type(entry) assert entry_type == "markdown" - def test_detect_entry_type_unreleased(self, handler): + def test_detect_managed_entry_type_unreleased(self, handler): """Test detecting 'Unreleased' section""" entry = "## Unreleased\n### Added\n- New feature" - entry_type = handler.detect_entry_type(entry) + entry_type = handler.detect_managed_entry_type(entry) assert entry_type == "unreleased" - def test_detect_entry_type_plain_text(self, handler): + def test_detect_managed_entry_type_plain_text(self, handler): """Test detecting plain text list format""" entry = "- Fixed bug in authentication\n- Improved performance\n- Updated docs" - entry_type = handler.detect_entry_type(entry) + entry_type = handler.detect_managed_entry_type(entry) assert entry_type == "plain_text" - def test_detect_entry_type_other(self, handler): + def test_detect_managed_entry_type_other(self, handler): """Test detecting unknown entry type""" entry = "Some random text without structure" - entry_type = handler.detect_entry_type(entry) + entry_type = handler.detect_managed_entry_type(entry) assert entry_type == "other" - def test_extract_version_and_date(self, handler): + def test_extract_version_and_date_from_managed(self, handler): """Test extracting version and date""" entry = "## Version 1.2.3 - 2024-10-24\n### Added\n- Feature" - version, date = handler.extract_version_and_date(entry) + version, date = handler.extract_version_and_date_from_managed(entry) assert version == "1.2.3" assert date == "2024-10-24" def test_extract_version_without_date(self, handler): """Test extracting version without date""" entry = "## 1.2.3\n### Added\n- Feature" - version, date = handler.extract_version_and_date(entry) + version, date = handler.extract_version_and_date_from_managed(entry) assert version == "1.2.3" assert date is None def test_extract_version_with_prerelease(self, handler): """Test extracting version with prerelease""" entry = "## 1.2.0-beta.1 - 2024-10-24" - version, date = handler.extract_version_and_date(entry) + version, date = handler.extract_version_and_date_from_managed(entry) assert version == "1.2.0-beta.1" assert date == "2024-10-24" def test_extract_version_not_found(self, handler): """Test when no version is found""" entry = "This is just some text without version" - version, date = handler.extract_version_and_date(entry) + version, date = handler.extract_version_and_date_from_managed(entry) assert version is None assert date is None - def test_build_legacy_context(self, handler): + def test_build_managed_context(self, handler): """Test building context about legacy entry""" entry = "## Version 1.2.0 - 2024-10-24\n\n### Added\n- Feature 1\n- Feature 2" - context = handler.build_legacy_context(entry) + context = handler.build_managed_context(entry) assert context["type"] == "markdown" assert context["version"] == "1.2.0" @@ -176,13 +176,13 @@ def test_build_legacy_context(self, handler): assert context["char_count"] > 0 assert "Version 1.2.0" in context["summary"] - def test_create_conversion_prompt(self, handler): + def test_create_managed_conversion_prompt(self, handler): """Test creating conversion prompt""" entry = "## Version 1.2.0\n### Added\n- New webhook support" pr_info = {"title": "Add webhook integration"} - context = handler.build_legacy_context(entry) + context = handler.build_managed_context(entry) - prompt = handler.create_conversion_prompt(entry, pr_info, context) + prompt = handler.create_managed_conversion_prompt(entry, pr_info, context) assert "legacy" in prompt.lower() assert "convert" in prompt.lower() @@ -190,36 +190,36 @@ def test_create_conversion_prompt(self, handler): assert entry in prompt assert "Add webhook integration" in prompt - def test_should_fail_on_conflict_true(self, handler): + def test_should_fail_on_managed_logchange_conflict_true(self, handler): """Test conflict detection when both legacy and logchange exist""" - legacy_files = ["CHANGELOG.md"] + managed_files = ["CHANGELOG.md"] logchange_files = ["changelog/unreleased/change.yml"] - is_conflict = handler.should_fail_on_conflict(legacy_files, logchange_files) + is_conflict = handler.should_fail_on_managed_logchange_conflict(managed_files, logchange_files) assert is_conflict is True - def test_should_fail_on_conflict_false_legacy_only(self, handler): + def test_should_fail_on_managed_logchange_conflict_false_legacy_only(self, handler): """Test no conflict when only legacy exists""" - legacy_files = ["CHANGELOG.md"] + managed_files = ["CHANGELOG.md"] logchange_files = [] - is_conflict = handler.should_fail_on_conflict(legacy_files, logchange_files) + is_conflict = handler.should_fail_on_managed_logchange_conflict(managed_files, logchange_files) assert is_conflict is False - def test_should_fail_on_conflict_false_logchange_only(self, handler): + def test_should_fail_on_managed_logchange_conflict_false_logchange_only(self, handler): """Test no conflict when only logchange exists""" - legacy_files = [] + managed_files = [] logchange_files = ["changelog/unreleased/change.yml"] - is_conflict = handler.should_fail_on_conflict(legacy_files, logchange_files) + is_conflict = handler.should_fail_on_managed_logchange_conflict(managed_files, logchange_files) assert is_conflict is False - def test_should_fail_on_conflict_false_neither(self, handler): + def test_should_fail_on_managed_logchange_conflict_false_neither(self, handler): """Test no conflict when neither exists""" - legacy_files = [] + managed_files = [] logchange_files = [] - is_conflict = handler.should_fail_on_conflict(legacy_files, logchange_files) + is_conflict = handler.should_fail_on_managed_logchange_conflict(managed_files, logchange_files) assert is_conflict is False def test_extract_bullet_points(self, handler): @@ -238,13 +238,13 @@ def test_extract_bullet_points(self, handler): def test_entry_context_line_count(self, handler): """Test line count in context""" entry = "Line 1\nLine 2\nLine 3\nLine 4\nLine 5" - context = handler.build_legacy_context(entry) + context = handler.build_managed_context(entry) assert context["line_count"] == 5 def test_entry_context_summary_truncation(self, handler): """Test summary truncation""" long_entry = "A" * 200 # 200 characters - context = handler.build_legacy_context(long_entry) + context = handler.build_managed_context(long_entry) # Summary should be truncated to ~100 chars with ellipsis assert len(context["summary"]) < len(long_entry) assert context["summary"].endswith("...")