From 5b27d3b4f0202fe7d30f9d1ead35bec090685a87 Mon Sep 17 00:00:00 2001 From: owner Date: Fri, 20 Feb 2026 12:37:30 +0000 Subject: [PATCH 1/3] MTV - automation of Release Notes Signed-off-by: owner --- scripts/QUICK_START.md | 64 ++++ scripts/README.md | 122 ++++++ scripts/generate_release_notes.py | 576 +++++++++++++++++++++++++++++ scripts/sample_known_issues.csv | 4 + scripts/sample_resolved_issues.csv | 4 + 5 files changed, 770 insertions(+) create mode 100644 scripts/QUICK_START.md create mode 100644 scripts/README.md create mode 100755 scripts/generate_release_notes.py create mode 100644 scripts/sample_known_issues.csv create mode 100644 scripts/sample_resolved_issues.csv diff --git a/scripts/QUICK_START.md b/scripts/QUICK_START.md new file mode 100644 index 00000000000..77e7254df27 --- /dev/null +++ b/scripts/QUICK_START.md @@ -0,0 +1,64 @@ +# Quick Start Guide - Release Notes Generator + +## Prerequisites + +- Python 3.6+ +- Git +- CSV files exported from JIRA + +## Step-by-Step Usage + +### 1. Export JIRA Tickets + +Export your JIRA tickets to CSV format with these columns: + +**Resolved Issues CSV:** +- `Issue Key` (e.g., MTV-3915) +- `Summary` +- `Description` + +**Known Issues CSV:** +- `Issue Key` (e.g., MTV-3116) +- `Summary` +- `Description` +- `Workaround` (optional) + +### 2. Run the Script + +```bash +python3 scripts/generate_release_notes.py +``` + +### 3. Follow the Prompts + +1. **Select base branch**: Choose the branch to create the release notes branch from +2. **Enter MTV version**: e.g., `2.11.1` +3. **Provide resolved issues CSV path**: Full path to your CSV file +4. **Provide known issues CSV path**: Full path to your CSV file + +### 4. Review Generated Files + +The script creates: +- New git branch: `MTV-RN-2.11.1` +- `documentation/modules/rn-2-11.adoc` +- `documentation/modules/rn-2-11-0-resolved-issues.adoc` +- `documentation/modules/known-issues-2-11.adoc` +- Updated `documentation/doc-Release_notes/master.adoc` + +### 5. Commit and Push + +```bash +git add . +git commit -m "Add release notes for MTV 2.11.1" +git push origin MTV-RN-2.11.1 +``` + +## Example CSV Format + +See `sample_resolved_issues.csv` and `sample_known_issues.csv` for examples. + +## Troubleshooting + +- **Branch already exists**: The script will ask if you want to use the existing branch +- **Version already in master.adoc**: The script will warn you and ask to continue +- **CSV parsing errors**: Check that your CSV has the required columns (Issue Key, Summary, Description) diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 00000000000..faa129d27fa --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,122 @@ +# Release Notes Generator + +This script automates the generation of release notes from JIRA ticket exports. It creates a new git branch and generates all necessary release notes files. + +## Usage + +```bash +python3 scripts/generate_release_notes.py +``` + +The script will prompt you for: +1. **Base branch selection**: Choose from available git branches or enter a custom branch name (the new branch will be created from this) +2. **MTV version**: Enter the version number (e.g., `2.11.0`) +3. **Resolved issues CSV**: Path to CSV file containing resolved JIRA tickets +4. **Known issues CSV**: Path to CSV file containing known issues JIRA tickets + +## What the Script Does + +1. **Creates a new git branch**: `MTV-RN-` (e.g., `MTV-RN-2.11.0`) +2. **Generates release notes files**: + - `rn-{major-version}.adoc` - Main release notes header (e.g., `rn-2-11.adoc`) + - `rn-{version}-resolved-issues.adoc` - Resolved issues (e.g., `rn-2-11-0-resolved-issues.adoc`) + - `known-issues-{major-version}.adoc` - Known issues (e.g., `known-issues-2-11.adoc`) +3. **Updates master.adoc**: Automatically updates `documentation/doc-Release_notes/master.adoc` to include the new modules + +## JIRA CSV Export Format + +### Recommended Format + +When exporting from JIRA, include the following columns: + +**For Resolved Issues:** +- `Issue Key` (e.g., MTV-3915) +- `Summary` or `Title` +- `Description` + +**For Known Issues:** +- `Issue Key` (e.g., MTV-3116) +- `Summary` or `Title` +- `Description` +- `Workaround` (optional but recommended) + +### CSV Column Names + +The script automatically detects common column name variations: +- **Issue Key**: `Issue Key`, `Key`, `Issue`, `Ticket`, `JIRA Key` +- **Summary**: `Summary`, `Title`, `Subject`, `Name` +- **Description**: `Description`, `Details`, `Body` +- **Workaround**: `Workaround`, `Work Around`, `Resolution`, `Solution` + +### Example CSV Structure + +```csv +Issue Key,Summary,Description,Workaround +MTV-3915,Target cluster namespaces now visible,Before this update, during cross-cluster live migration...,"" +MTV-3116,Forklift console plugin fails to load,When MTV 2.9.1 is deployed on an IPv6-only...,Deploy MTV on dual-stack or IPv4 clusters +``` + +## Output + +The script: +- Creates a new git branch: `MTV-RN-` +- Generates AsciiDoc files in `documentation/modules/`: + - `rn-{major-version}.adoc` (e.g., `rn-2-11.adoc`) + - `rn-{version}-resolved-issues.adoc` (e.g., `rn-2-11-0-resolved-issues.adoc`) + - `known-issues-{major-version}.adoc` (e.g., `known-issues-2-11.adoc`) +- Updates `documentation/doc-Release_notes/master.adoc` automatically + +## Next Steps + +After running the script: + +1. **Review the generated files** in `documentation/modules/` +2. **Add any additional content** if needed (e.g., new features section) +3. **Commit your changes**: + ```bash + git add . + git commit -m "Add release notes for MTV {version}" + ``` +4. **Push the branch**: + ```bash + git push origin MTV-RN-{version} + ``` +5. **Create a pull request** with the new release notes branch + +## Exporting from JIRA + +### Steps to Export CSV from JIRA: + +1. Navigate to your JIRA project (e.g., MTV) +2. Use the search/filter to find issues: + - For resolved issues: Filter by `Status = Resolved` or `Status = Closed` + - For known issues: Filter by `Labels = Known Issue` or similar +3. Click **Export** → **Export CSV (Current fields)** +4. Ensure the export includes: + - Issue Key + - Summary + - Description + - (For known issues) Workaround field if available + +### Alternative: Using JIRA Query Language (JQL) + +You can also export using JQL queries: +- Resolved issues: `project = MTV AND status = Resolved AND fixVersion = "2.11.0"` +- Known issues: `project = MTV AND labels = "known-issue" AND fixVersion = "2.11.0"` + +## Troubleshooting + +**Issue**: Script can't find issue keys +- **Solution**: Ensure your CSV has a column named `Issue Key`, `Key`, or `Issue` + +**Issue**: Descriptions are empty +- **Solution**: Check that your CSV export includes the `Description` column + +**Issue**: Workarounds not appearing +- **Solution**: Add a `Workaround` column to your known issues CSV, or manually edit the generated file + +## Requirements + +- Python 3.6+ +- Git (for branch detection) +- CSV files exported from JIRA diff --git a/scripts/generate_release_notes.py b/scripts/generate_release_notes.py new file mode 100755 index 00000000000..164bf9a7983 --- /dev/null +++ b/scripts/generate_release_notes.py @@ -0,0 +1,576 @@ +#!/usr/bin/env python3 +""" +Release Notes Generator for Forklift/MTV Documentation + +This script automates the generation of release notes from JIRA ticket exports. +It prompts for branch selection, MTV version, and processes CSV files containing +known issues and resolved issues. It creates a new git branch and generates all +necessary release notes files. +""" + +import os +import sys +import csv +import re +import subprocess +from pathlib import Path +from typing import List, Dict, Optional, Tuple +from datetime import datetime + + +class ReleaseNotesGenerator: + """Generates release notes from JIRA ticket exports.""" + + def __init__(self, repo_path: str): + self.repo_path = Path(repo_path) + self.docs_path = self.repo_path / "documentation" + self.modules_path = self.docs_path / "modules" + self.release_notes_path = self.docs_path / "doc-Release_notes" + self.master_adoc_path = self.release_notes_path / "master.adoc" + self.base_branch = None + self.new_branch = None + self.version = None + + def get_git_branches(self) -> List[str]: + """Get list of available git branches.""" + try: + result = subprocess.run( + ["git", "branch", "-a"], + cwd=self.repo_path, + capture_output=True, + text=True, + check=True + ) + branches = [] + for line in result.stdout.split('\n'): + branch = line.strip().replace('*', '').strip() + if branch and not branch.startswith('remotes/origin/HEAD'): + # Remove 'remotes/origin/' prefix if present + if branch.startswith('remotes/origin/'): + branch = branch.replace('remotes/origin/', '') + if branch not in branches: + branches.append(branch) + return sorted(branches) + except subprocess.CalledProcessError: + print("Warning: Could not fetch git branches. Continuing...") + return [] + + def select_branch(self) -> str: + """Prompt user to select a base git branch.""" + branches = self.get_git_branches() + if not branches: + branch = input("Enter base branch name: ").strip() + return branch + + print("\nAvailable branches:") + for i, branch in enumerate(branches, 1): + print(f" {i}. {branch}") + + while True: + try: + choice = input(f"\nSelect base branch (1-{len(branches)}) or enter custom branch name: ").strip() + if choice.isdigit(): + idx = int(choice) - 1 + if 0 <= idx < len(branches): + return branches[idx] + else: + print(f"Invalid choice. Please enter a number between 1 and {len(branches)}") + else: + # Custom branch name + return choice + except ValueError: + print("Invalid input. Please enter a number or branch name.") + + def create_release_branch(self, base_branch: str, version: str) -> str: + """Create a new git branch for release notes.""" + branch_name = f"MTV-RN-{version}" + + print(f"\nCreating new branch: {branch_name} from {base_branch}") + + try: + # Checkout base branch first + subprocess.run( + ["git", "checkout", base_branch], + cwd=self.repo_path, + check=True, + capture_output=True + ) + + # Fetch latest changes + subprocess.run( + ["git", "fetch", "origin", base_branch], + cwd=self.repo_path, + check=False, + capture_output=True + ) + + # Create and checkout new branch + subprocess.run( + ["git", "checkout", "-b", branch_name], + cwd=self.repo_path, + check=True, + capture_output=True + ) + + print(f"✓ Created and checked out branch: {branch_name}") + return branch_name + + except subprocess.CalledProcessError as e: + print(f"Error creating branch: {e.stderr.decode() if e.stderr else str(e)}") + # Check if branch already exists + result = subprocess.run( + ["git", "branch", "--list", branch_name], + cwd=self.repo_path, + capture_output=True, + text=True + ) + if result.stdout.strip(): + response = input(f"Branch {branch_name} already exists. Use it? (y/n): ").strip().lower() + if response == 'y': + subprocess.run( + ["git", "checkout", branch_name], + cwd=self.repo_path, + check=True + ) + return branch_name + raise + + def select_version(self) -> str: + """Prompt user to enter MTV version.""" + while True: + version = input("\nEnter MTV version (e.g., 2.11.0): ").strip() + if re.match(r'^\d+\.\d+(\.\d+)?$', version): + return version + else: + print("Invalid version format. Please use format like '2.11.0' or '2.11'") + + def parse_jira_csv(self, csv_path: str) -> List[Dict[str, str]]: + """ + Parse JIRA CSV export file. + + Expected CSV format (columns may vary, script will auto-detect): + - Issue key (e.g., MTV-3915) + - Summary/Title + - Description + - Status + - For known issues: Workaround (optional) + + The script will try to detect common column names. + """ + tickets = [] + + with open(csv_path, 'r', encoding='utf-8') as f: + # Try to detect delimiter + sample = f.read(1024) + f.seek(0) + sniffer = csv.Sniffer() + delimiter = sniffer.sniff(sample).delimiter + + reader = csv.DictReader(f, delimiter=delimiter) + + # Normalize column names (case-insensitive, strip whitespace) + fieldnames = {name.lower().strip(): name for name in reader.fieldnames} + + for row in reader: + ticket = {} + + # Find issue key + key_fields = ['issue key', 'key', 'issue', 'ticket', 'jira key'] + ticket['key'] = None + for field in key_fields: + if field in fieldnames: + ticket['key'] = row[fieldnames[field]].strip() + break + + if not ticket['key']: + print(f"Warning: Could not find issue key in row. Skipping row: {row}") + continue + + # Find summary/title + summary_fields = ['summary', 'title', 'subject', 'name'] + ticket['summary'] = "" + for field in summary_fields: + if field in fieldnames: + ticket['summary'] = row[fieldnames[field]].strip() + break + + # Find description + desc_fields = ['description', 'details', 'body'] + ticket['description'] = "" + for field in desc_fields: + if field in fieldnames: + ticket['description'] = row[fieldnames[field]].strip() + break + + # Find workaround (for known issues) + workaround_fields = ['workaround', 'work around', 'resolution', 'solution'] + ticket['workaround'] = "" + for field in workaround_fields: + if field in fieldnames: + ticket['workaround'] = row[fieldnames[field]].strip() + break + + tickets.append(ticket) + + return tickets + + def format_issue_text(self, summary: str, description: str, workaround: Optional[str] = None) -> str: + """Format issue text for AsciiDoc.""" + # Clean up text + summary = summary.strip() + description = description.strip() + + # Remove HTML tags if present + description = re.sub(r'<[^>]+>', '', description) + + # Format the issue entry + lines = [f"{summary}::", ""] + + if description: + # Capitalize first letter if needed + if description and not description[0].isupper(): + description = description[0].upper() + description[1:] + + lines.append(description) + + if workaround: + lines.append("") + lines.append(f"*Workaround:* {workaround.strip()}") + + return "\n".join(lines) + + def generate_resolved_issues(self, tickets: List[Dict[str, str]], version: str) -> str: + """Generate resolved issues AsciiDoc content.""" + version_safe = version.replace('.', '-') + version_id = version.replace('.', '-') + + lines = [ + "// Module included in the following assemblies:", + "//", + "// * documentation/doc-Release_notes/master.adoc", + "", + ":_mod-docs-content-type: CONCEPT", + f'[id="resolved-issues-{version_id}_{{context}}"]', + f"= Resolved issues {version}", + "", + "[role=\"_abstract\"]", + "Review the resolved issues in this release of {project-short}.", + "" + ] + + for ticket in tickets: + issue_text = self.format_issue_text( + ticket['summary'], + ticket['description'] + ) + lines.append(issue_text) + lines.append("+") + lines.append(f"link:https://issues.redhat.com/browse/{ticket['key']}[{ticket['key']}]") + lines.append("") + + return "\n".join(lines) + + def generate_known_issues(self, tickets: List[Dict[str, str]], version: str) -> str: + """Generate known issues AsciiDoc content.""" + version_id = version.split('.')[0] + '-' + version.split('.')[1] # e.g., 2.11 + + lines = [ + "// Module included in the following assemblies:", + "//", + "// * documentation/doc-Release_notes/master.adoc", + "", + ":_mod-docs-content-type: CONCEPT", + f'[id="known-issues-{version_id}_{{context}}"]', + "= Known issues", + "", + "[role=\"_abstract\"]", + f"{{project-first}} {version} has the following known issues.", + "" + ] + + for ticket in tickets: + issue_text = self.format_issue_text( + ticket['summary'], + ticket['description'], + ticket.get('workaround', '') + ) + lines.append(issue_text) + lines.append("+") + lines.append(f"link:https://issues.redhat.com/browse/{ticket['key']}[{ticket['key']}]") + lines.append("") + + # Add comment about complete list + lines.append("") + lines.append("//For a complete list of all known issues in this release, see the list of link:https://issues.redhat.com/issues/?filter=12472621[Known Issues] in Jira.") + lines.append("") + + return "\n".join(lines) + + def generate_release_notes_header(self, version: str) -> str: + """Generate the main release notes header file.""" + version_major = '.'.join(version.split('.')[:2]) # e.g., 2.11 + version_id = version_major.replace('.', '-') + + lines = [ + "// Module included in the following assemblies:", + "//", + "// * documentation/doc-Release_notes/master.adoc", + "", + ":_mod-docs-content-type: CONCEPT", + f'[id="rn-{version_id}_{{context}}"]', + f"= {{project-full}} {version_major}", + "", + "[role=\"_abstract\"]", + "The release notes describe technical changes, new features and enhancements, known issues, and resolved issues.", + "" + ] + + return "\n".join(lines) + + def update_master_adoc(self, version: str, has_resolved: bool, has_known: bool) -> bool: + """Update master.adoc to include new release notes modules.""" + if not self.master_adoc_path.exists(): + print(f"Warning: master.adoc not found at {self.master_adoc_path}") + return False + + version_major = '.'.join(version.split('.')[:2]) # e.g., 2.11 + version_safe = version.replace('.', '-') + version_major_safe = version_major.replace('.', '-') + + # Read existing content + with open(self.master_adoc_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Check if this version already exists + if f"rn-{version_major_safe}.adoc" in content: + print(f"Warning: Release notes for version {version_major} already exist in master.adoc") + response = input("Continue anyway? (y/n): ").strip().lower() + if response != 'y': + return False + + lines = content.split('\n') + new_lines = [] + + # Track what we've inserted + header_inserted = False + resolved_inserted = False + known_inserted = False + + i = 0 + while i < len(lines): + line = lines[i] + + # Insert release notes header right after common-attributes + if not header_inserted and line.strip() == 'include::modules/common-attributes.adoc[]': + new_lines.append(line) + # Skip to the first rn- include and insert before it + j = i + 1 + while j < len(lines) and not lines[j].strip().startswith('include::modules/rn-'): + new_lines.append(lines[j]) + j += 1 + # Insert new header + new_lines.append("") + new_lines.append(f"include::modules/rn-{version_major_safe}.adoc[leveloffset=+1]") + header_inserted = True + i = j + continue + + # Insert resolved issues after the last resolved issues include + if has_resolved and not resolved_inserted: + if '// Resolved issues by z-stream release' in line: + new_lines.append(line) + # Process until we find the last resolved issues include + j = i + 1 + last_resolved_idx = -1 + while j < len(lines): + if lines[j].strip().startswith('include::modules/rn-') and 'resolved-issues' in lines[j]: + last_resolved_idx = j + elif lines[j].strip().startswith('//') and 'Resolved' not in lines[j] and 'Security' not in lines[j]: + break + j += 1 + + # Copy lines up to and including the last resolved issues include + k = i + 1 + while k <= last_resolved_idx: + new_lines.append(lines[k]) + k += 1 + + # Insert new resolved issues + if last_resolved_idx >= 0: + new_lines.append("") + new_lines.append(f"include::modules/rn-{version_safe}-resolved-issues.adoc[leveloffset=+3]") + resolved_inserted = True + + i = k + continue + + # Replace known issues include + if has_known and not known_inserted: + if '// Known issues by x-stream release' in line: + new_lines.append(line) + # Find and replace the known issues include + j = i + 1 + while j < len(lines): + if lines[j].strip().startswith('include::modules/known-issues-'): + new_lines.append(f"include::modules/known-issues-{version_major_safe}.adoc[leveloffset=+2]") + known_inserted = True + j += 1 + break + new_lines.append(lines[j]) + j += 1 + i = j + continue + + new_lines.append(line) + i += 1 + + # Fallback: if we didn't find insertion points, try simpler approach + if has_resolved and not resolved_inserted: + # Find the resolved issues section and append + for idx, line in enumerate(new_lines): + if 'include::modules/rn-' in line and 'resolved-issues' in line: + # Insert after this line + new_lines.insert(idx + 1, "") + new_lines.insert(idx + 2, f"include::modules/rn-{version_safe}-resolved-issues.adoc[leveloffset=+3]") + resolved_inserted = True + break + + if has_known and not known_inserted: + # Find and replace known issues + for idx, line in enumerate(new_lines): + if line.strip().startswith('include::modules/known-issues-'): + new_lines[idx] = f"include::modules/known-issues-{version_major_safe}.adoc[leveloffset=+2]" + known_inserted = True + break + + # Write updated content + with open(self.master_adoc_path, 'w', encoding='utf-8') as f: + f.write('\n'.join(new_lines)) + if new_lines and not new_lines[-1]: + f.write('\n') + + print(f"✓ Updated: {self.master_adoc_path}") + return True + + def save_file(self, content: str, filename: str): + """Save content to file in modules directory.""" + filepath = self.modules_path / filename + with open(filepath, 'w', encoding='utf-8') as f: + f.write(content) + print(f"✓ Generated: {filepath}") + + def run(self): + """Main execution flow.""" + print("=" * 60) + print("Forklift/MTV Release Notes Generator") + print("=" * 60) + + # Step 1: Select base branch + print("\n[Step 1] Base Branch Selection") + self.base_branch = self.select_branch() + print(f"Selected base branch: {self.base_branch}") + + # Step 2: Select version + print("\n[Step 2] Version Selection") + self.version = self.select_version() + print(f"Selected version: {self.version}") + + # Step 3: Create release branch + print("\n[Step 3] Creating Release Branch") + self.new_branch = self.create_release_branch(self.base_branch, self.version) + + # Step 4: Upload resolved issues + print("\n[Step 4] Resolved Issues") + resolved_file = input("Enter path to resolved issues CSV file (or press Enter to skip): ").strip() + resolved_tickets = [] + if resolved_file: + if os.path.exists(resolved_file): + print(f"Processing resolved issues from: {resolved_file}") + resolved_tickets = self.parse_jira_csv(resolved_file) + print(f"Found {len(resolved_tickets)} resolved issues") + else: + print(f"Error: File not found: {resolved_file}") + + # Step 5: Upload known issues + print("\n[Step 5] Known Issues") + known_file = input("Enter path to known issues CSV file (or press Enter to skip): ").strip() + known_tickets = [] + if known_file: + if os.path.exists(known_file): + print(f"Processing known issues from: {known_file}") + known_tickets = self.parse_jira_csv(known_file) + print(f"Found {len(known_tickets)} known issues") + else: + print(f"Error: File not found: {known_file}") + + if not resolved_tickets and not known_tickets: + print("\nNo issues to process. Exiting.") + print(f"You are now on branch: {self.new_branch}") + return + + # Step 6: Generate files + print("\n[Step 6] Generating Release Notes Files") + + # Generate release notes header + version_major = '.'.join(self.version.split('.')[:2]) # e.g., 2.11 + header_content = self.generate_release_notes_header(self.version) + header_filename = f"rn-{version_major.replace('.', '-')}.adoc" + self.save_file(header_content, header_filename) + + # Generate resolved issues + if resolved_tickets: + version_safe = self.version.replace('.', '-') + resolved_content = self.generate_resolved_issues(resolved_tickets, self.version) + resolved_filename = f"rn-{version_safe}-resolved-issues.adoc" + self.save_file(resolved_content, resolved_filename) + + # Generate known issues + if known_tickets: + known_content = self.generate_known_issues(known_tickets, version_major) + known_filename = f"known-issues-{version_major.replace('.', '-')}.adoc" + self.save_file(known_content, known_filename) + + # Step 7: Update master.adoc + print("\n[Step 7] Updating master.adoc") + self.update_master_adoc(self.version, bool(resolved_tickets), bool(known_tickets)) + + print("\n" + "=" * 60) + print("Release notes generation complete!") + print("=" * 60) + print(f"\nBranch: {self.new_branch}") + print("\nGenerated files:") + print(f" - documentation/modules/{header_filename}") + if resolved_tickets: + print(f" - documentation/modules/rn-{self.version.replace('.', '-')}-resolved-issues.adoc") + if known_tickets: + print(f" - documentation/modules/known-issues-{version_major.replace('.', '-')}.adoc") + print(f" - documentation/doc-Release_notes/master.adoc (updated)") + print("\nNext steps:") + print("1. Review the generated files") + print("2. Add any additional content (e.g., new features) if needed") + print(f"3. Commit your changes: git add . && git commit -m 'Add release notes for MTV {self.version}'") + print(f"4. Push the branch: git push origin {self.new_branch}") + + +def main(): + """Entry point.""" + # Get repository path (script location's parent) + script_path = Path(__file__).resolve() + repo_path = script_path.parent.parent + + generator = ReleaseNotesGenerator(str(repo_path)) + + try: + generator.run() + except KeyboardInterrupt: + print("\n\nOperation cancelled by user.") + sys.exit(1) + except Exception as e: + print(f"\nError: {e}", file=sys.stderr) + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/sample_known_issues.csv b/scripts/sample_known_issues.csv new file mode 100644 index 00000000000..f38926c5662 --- /dev/null +++ b/scripts/sample_known_issues.csv @@ -0,0 +1,4 @@ +Issue Key,Summary,Description,Workaround +MTV-3116,Forklift console plugin fails to load on IPv6-only OpenShift clusters,When MTV 2.9.1 is deployed on an IPv6-only OpenShift cluster, an incorrect API endpoint prevents the forklift-console-plugin from serving a valid plugin manifest, displaying the error Failed to get a valid plugin manifest from /api/plugins/forklift-console-plugins/. While the forklift-ui-plugin pod runs successfully, the console plugin cannot load, preventing access to the MTV user interface in the OpenShift web console. This issue affects all IPv6-only cluster deployments with OpenShift 4.19.7 and CNV 4.19.1.,Deploy MTV on dual-stack or IPv4 clusters until this issue is resolved. +MTV-1736,Incorrect labeling of inactive migration plans,Inactive migration plans are incorrectly labeled as Running when max_vms_inflight exceeds the total number of disks per migration. This label leads to confusion about the actual state of the plans, as all migration plans appear to be Running in the user interface.,Currently, there is no workaround for this issue. +MTV-2446,Migration between OpenShift namespaces causes MAC address collisions,During migration between OpenShift namespaces in a cluster, MAC address collisions result in VM creation failure.,Disable the KubeMacPool. For more information, see Managing KubeMacPool by using the CLI in the OpenShift documentation. diff --git a/scripts/sample_resolved_issues.csv b/scripts/sample_resolved_issues.csv new file mode 100644 index 00000000000..52cd91134fa --- /dev/null +++ b/scripts/sample_resolved_issues.csv @@ -0,0 +1,4 @@ +Issue Key,Summary,Description +MTV-3915,Target cluster namespaces now visible for cross-cluster live migration,Before this update, during cross-cluster live migration, projects were fetched from the host cluster instead of the target cluster. As a consequence, target cluster namespaces were not visible in the UI, causing user inconvenience. With this release, cross-cluster live migration fetches project data from the target cluster, making target cluster namespaces visible in the UI during migration. +MTV-3618,VDDK image archive upload succeeds in private namespaces,Before this update, VMware Virtual Disk Development Kit (VDDK) image archive upload failure occurred in private namespaces due to incorrect namespace usage. As a consequence, users experienced VDDK image upload failure in private namespaces during source provider creation. With this release, you can upload VDDK tar archives successfully in private namespaces. +MTV-3476,Correct UI display of migration type after change,Before this update, if you changed the Migration type on the Plan details page in the MTV UI from warm to cold, the YAML file was updated correctly but the UI did not display the updated migration type. With this release, the UI edit field for Migration type updates the migration type in the YAML file and the UI correctly displays the updated migration type. From 4c58de90a0d1a985fed329886120cef7f95306ef Mon Sep 17 00:00:00 2001 From: owner Date: Fri, 20 Feb 2026 12:49:47 +0000 Subject: [PATCH 2/3] update Signed-off-by: owner --- scripts/README.md | 6 +- .../generate_release_notes.cpython-313.pyc | Bin 0 -> 34037 bytes scripts/generate_release_notes.py | 267 ++++++++++++++++-- 3 files changed, 254 insertions(+), 19 deletions(-) create mode 100644 scripts/__pycache__/generate_release_notes.cpython-313.pyc diff --git a/scripts/README.md b/scripts/README.md index faa129d27fa..177e6eedbe5 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -10,7 +10,7 @@ python3 scripts/generate_release_notes.py The script will prompt you for: 1. **Base branch selection**: Choose from available git branches or enter a custom branch name (the new branch will be created from this) -2. **MTV version**: Enter the version number (e.g., `2.11.0`) +2. **MTV version**: Enter the version number (e.g., `2.11.1`) 3. **Resolved issues CSV**: Path to CSV file containing resolved JIRA tickets 4. **Known issues CSV**: Path to CSV file containing known issues JIRA tickets @@ -101,8 +101,8 @@ After running the script: ### Alternative: Using JIRA Query Language (JQL) You can also export using JQL queries: -- Resolved issues: `project = MTV AND status = Resolved AND fixVersion = "2.11.0"` -- Known issues: `project = MTV AND labels = "known-issue" AND fixVersion = "2.11.0"` +- Resolved issues: `project = MTV AND status = Resolved AND fixVersion = "2.11.1"` +- Known issues: `project = MTV AND labels = "known-issue" AND fixVersion = "2.11.1"` ## Troubleshooting diff --git a/scripts/__pycache__/generate_release_notes.cpython-313.pyc b/scripts/__pycache__/generate_release_notes.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5cdef124aec5f1991005c4b08a0e92ba9ce6e392 GIT binary patch literal 34037 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 b`+6s*F)l_E>e`Pr`fm@ft2s>_tMLB^-BxZh literal 0 HcmV?d00001 diff --git a/scripts/generate_release_notes.py b/scripts/generate_release_notes.py index 164bf9a7983..a721e31357e 100755 --- a/scripts/generate_release_notes.py +++ b/scripts/generate_release_notes.py @@ -138,11 +138,11 @@ def create_release_branch(self, base_branch: str, version: str) -> str: def select_version(self) -> str: """Prompt user to enter MTV version.""" while True: - version = input("\nEnter MTV version (e.g., 2.11.0): ").strip() + version = input("\nEnter MTV version (e.g., 2.11.1): ").strip() if re.match(r'^\d+\.\d+(\.\d+)?$', version): return version else: - print("Invalid version format. Please use format like '2.11.0' or '2.11'") + print("Invalid version format. Please use format like '2.11.1' or '2.11'") def parse_jira_csv(self, csv_path: str) -> List[Dict[str, str]]: """ @@ -452,12 +452,241 @@ def update_master_adoc(self, version: str, has_resolved: bool, has_known: bool) print(f"✓ Updated: {self.master_adoc_path}") return True - def save_file(self, content: str, filename: str): - """Save content to file in modules directory.""" + def extract_existing_issue_keys(self, filepath: Path) -> set: + """Extract issue keys (e.g., MTV-3915) from existing AsciiDoc file.""" + issue_keys = set() + if not filepath.exists(): + return issue_keys + + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + + # Find all JIRA issue links: link:https://issues.redhat.com/browse/MTV-XXXX[MTV-XXXX] + pattern = r'link:https://issues\.redhat\.com/browse/([A-Z]+-\d+)\[' + matches = re.findall(pattern, content) + issue_keys.update(matches) + + return issue_keys + + def parse_existing_content(self, filepath: Path) -> tuple: + """ + Parse existing AsciiDoc file to extract header and body. + Returns (header_lines, body_lines, existing_issue_keys) + """ + if not filepath.exists(): + return ([], [], set()) + + with open(filepath, 'r', encoding='utf-8') as f: + lines = f.readlines() + + # Extract issue keys + existing_keys = self.extract_existing_issue_keys(filepath) + + # Find where the actual content starts (after abstract) + header_end = 0 + for i, line in enumerate(lines): + if '[role="_abstract"]' in line: + # Header ends after the abstract line + 1 + header_end = i + 2 + break + + header_lines = [line.rstrip('\n') for line in lines[:header_end]] + body_lines = [line.rstrip('\n') for line in lines[header_end:]] + + return (header_lines, body_lines, existing_keys) + + def merge_content(self, existing_header: List[str], existing_body: List[str], + new_content: str, existing_keys: set) -> str: + """ + Merge new content with existing content, avoiding duplicates. + + Args: + existing_header: Header lines from existing file + existing_body: Body lines from existing file + new_content: New content to merge + existing_keys: Set of existing issue keys to avoid duplicates + + Returns: + Merged content as string + """ + # Parse new content to extract issues + new_lines = new_content.split('\n') + + # Find where issues start in new content (after abstract) + new_issues_start = 0 + for i, line in enumerate(new_lines): + if '[role="_abstract"]' in line: + new_issues_start = i + 2 + break + + # Extract new issues (skip header) + # Issue format: Title:: ... description ... + link:.../MTV-XXXX[MTV-XXXX] + new_issues = [] + current_issue_lines = [] + current_issue_key = None + + i = new_issues_start + while i < len(new_lines): + line = new_lines[i] + + # Check if this line contains an issue key (end of an issue) + issue_key_match = re.search(r'link:https://issues\.redhat\.com/browse/([A-Z]+-\d+)\[', line) + if issue_key_match: + issue_key = issue_key_match.group(1) + if issue_key not in existing_keys: + # This is a new issue - add all lines from start to this line + if current_issue_lines: + new_issues.extend(current_issue_lines) + new_issues.append(line) # Add the link line + new_issues.append("") # Add blank line after issue + current_issue_lines = [] + current_issue_key = issue_key + else: + # Duplicate issue, skip it - reset and continue + current_issue_lines = [] + current_issue_key = None + # Skip until next issue (blank line or next title ending with ::) + i += 1 + while i < len(new_lines) and not new_lines[i].strip().endswith('::'): + i += 1 + i -= 1 # Back up one line + else: + # Check if this is the start of a new issue (title ending with ::) + if line.strip().endswith('::') and current_issue_key is None: + # Start collecting a new issue + current_issue_lines = [line] + elif current_issue_lines: + # Continue collecting current issue + current_issue_lines.append(line) + + i += 1 + + # Add last issue if any (in case file doesn't end with link) + if current_issue_lines and current_issue_key: + new_issues.extend(current_issue_lines) + + # Merge: header + existing body + new issues + merged_lines = existing_header.copy() + if existing_body: + # Remove trailing blank lines from existing body + while existing_body and not existing_body[-1].strip(): + existing_body.pop() + merged_lines.extend(existing_body) + if new_issues: + merged_lines.append("") # Add spacing before new issues + merged_lines.extend(new_issues) + + return '\n'.join(merged_lines) + + def append_to_file(self, filepath: Path, new_content: str, existing_keys: set) -> tuple: + """ + Append new content to existing file, avoiding duplicates. + + Returns: + (success: bool, new_count: int, existing_count: int) + """ + # Parse existing content + existing_header, existing_body, file_existing_keys = self.parse_existing_content(filepath) + + # Combine with provided existing_keys (in case we filtered before) + all_existing_keys = existing_keys | file_existing_keys + + # Extract new issue keys from content + new_keys = self.extract_existing_issue_keys_from_content(new_content) + new_keys_to_add = new_keys - all_existing_keys + + if not new_keys_to_add: + return (False, 0, len(all_existing_keys)) + + # Merge content + merged_content = self.merge_content(existing_header, existing_body, new_content, all_existing_keys) + + # Write merged content + with open(filepath, 'w', encoding='utf-8') as f: + f.write(merged_content) + + return (True, len(new_keys_to_add), len(all_existing_keys)) + + def save_file(self, content: str, filename: str, append_mode: bool = True) -> bool: + """ + Save content to file in modules directory. + If file exists and append_mode is True, merges new content with existing. + + Args: + content: The content to write + filename: The filename (relative to modules directory) + append_mode: If True and file exists, append new issues (default: True) + + Returns: + True if file was saved, False if user cancelled or no new content + """ filepath = self.modules_path / filename + + # Check if file exists + if filepath.exists(): + if append_mode: + # Extract existing issue keys + existing_keys = self.extract_existing_issue_keys(filepath) + new_keys = self.extract_existing_issue_keys_from_content(content) + new_keys_count = len(new_keys - existing_keys) + existing_count = len(existing_keys) + + if new_keys_count > 0: + print(f"\n📄 File exists: {filepath}") + print(f" Found {existing_count} existing issue(s), {new_keys_count} new issue(s)") + + # Append new content + success, added_count, total_existing = self.append_to_file(filepath, content, existing_keys) + + if success: + print(f"✓ Updated: {filepath} (appended {added_count} new issue(s))") + return True + else: + print(f"⚠️ No new issues to add to {filepath}") + return False + else: + print(f"⚠️ File exists: {filepath}") + print(f" All {len(new_keys)} issue(s) already exist in file. Skipping.") + return False + else: + # Overwrite mode + print(f"\n⚠️ Warning: File already exists: {filepath}") + response = input("Overwrite existing file? (y/n/backup): ").strip().lower() + + if response == 'n': + print(f"✗ Skipped: {filepath}") + return False + elif response == 'backup' or response == 'b': + # Create backup + backup_path = filepath.with_suffix(filepath.suffix + '.backup') + import shutil + shutil.copy2(filepath, backup_path) + print(f"📋 Created backup: {backup_path}") + elif response != 'y': + print(f"✗ Skipped: {filepath}") + return False + + # Write the file + with open(filepath, 'w', encoding='utf-8') as f: + f.write(content) + + print(f"✓ Updated: {filepath}") + return True + + # File doesn't exist, create new with open(filepath, 'w', encoding='utf-8') as f: f.write(content) + print(f"✓ Generated: {filepath}") + return True + + def extract_existing_issue_keys_from_content(self, content: str) -> set: + """Extract issue keys from content string.""" + issue_keys = set() + pattern = r'link:https://issues\.redhat\.com/browse/([A-Z]+-\d+)\[' + matches = re.findall(pattern, content) + issue_keys.update(matches) + return issue_keys def run(self): """Main execution flow.""" @@ -511,24 +740,33 @@ def run(self): # Step 6: Generate files print("\n[Step 6] Generating Release Notes Files") - # Generate release notes header + files_saved = [] + + # Generate release notes header (don't append, always overwrite if exists) version_major = '.'.join(self.version.split('.')[:2]) # e.g., 2.11 header_content = self.generate_release_notes_header(self.version) header_filename = f"rn-{version_major.replace('.', '-')}.adoc" - self.save_file(header_content, header_filename) + if self.save_file(header_content, header_filename, append_mode=False): + files_saved.append(header_filename) - # Generate resolved issues + # Generate resolved issues (append mode - merge with existing) if resolved_tickets: version_safe = self.version.replace('.', '-') resolved_content = self.generate_resolved_issues(resolved_tickets, self.version) resolved_filename = f"rn-{version_safe}-resolved-issues.adoc" - self.save_file(resolved_content, resolved_filename) + if self.save_file(resolved_content, resolved_filename, append_mode=True): + files_saved.append(resolved_filename) - # Generate known issues + # Generate known issues (append mode - merge with existing) if known_tickets: known_content = self.generate_known_issues(known_tickets, version_major) known_filename = f"known-issues-{version_major.replace('.', '-')}.adoc" - self.save_file(known_content, known_filename) + if self.save_file(known_content, known_filename, append_mode=True): + files_saved.append(known_filename) + + if not files_saved: + print("\n⚠️ No files were saved. Exiting.") + return # Step 7: Update master.adoc print("\n[Step 7] Updating master.adoc") @@ -538,12 +776,9 @@ def run(self): print("Release notes generation complete!") print("=" * 60) print(f"\nBranch: {self.new_branch}") - print("\nGenerated files:") - print(f" - documentation/modules/{header_filename}") - if resolved_tickets: - print(f" - documentation/modules/rn-{self.version.replace('.', '-')}-resolved-issues.adoc") - if known_tickets: - print(f" - documentation/modules/known-issues-{version_major.replace('.', '-')}.adoc") + print("\nFiles saved:") + for filename in files_saved: + print(f" - documentation/modules/{filename}") print(f" - documentation/doc-Release_notes/master.adoc (updated)") print("\nNext steps:") print("1. Review the generated files") From 42134d81cee2da88360d517684e66d0313146fbd Mon Sep 17 00:00:00 2001 From: andydub97 <125505149+andydub97@users.noreply.github.com> Date: Sun, 22 Feb 2026 13:24:11 +0000 Subject: [PATCH 3/3] Update generate_release_notes.py --- scripts/generate_release_notes.py | 204 +++++++++++++++++++----------- 1 file changed, 129 insertions(+), 75 deletions(-) diff --git a/scripts/generate_release_notes.py b/scripts/generate_release_notes.py index a721e31357e..a80cda7544c 100755 --- a/scripts/generate_release_notes.py +++ b/scripts/generate_release_notes.py @@ -6,8 +6,20 @@ It prompts for branch selection, MTV version, and processes CSV files containing known issues and resolved issues. It creates a new git branch and generates all necessary release notes files. + +Outputs: + - documentation/modules/rn--.adoc (x-stream header, e.g. 2.11) + - documentation/modules/rn----resolved-issues.adoc (z-stream resolved) + - documentation/modules/known-issues--.adoc (known issues for x-stream) + - documentation/doc-Release_notes/master.adoc (updated to include new modules) + +Usage: + Interactive: python generate_release_notes.py + With options: python generate_release_notes.py --version 2.11.1 --base main \\ + --resolved path/to/resolved.csv --known path/to/known.csv --yes """ +import argparse import os import sys import csv @@ -19,9 +31,12 @@ class ReleaseNotesGenerator: - """Generates release notes from JIRA ticket exports.""" - - def __init__(self, repo_path: str): + """ + Generates release notes from JIRA ticket exports. + Handles branch creation, CSV parsing, AsciiDoc generation, and master.adoc updates. + """ + + def __init__(self, repo_path: str, non_interactive: bool = False): self.repo_path = Path(repo_path) self.docs_path = self.repo_path / "documentation" self.modules_path = self.docs_path / "modules" @@ -30,9 +45,11 @@ def __init__(self, repo_path: str): self.base_branch = None self.new_branch = None self.version = None - + # When True, skip interactive prompts (overwrite/continue/branch reuse) + self.non_interactive = non_interactive + def get_git_branches(self) -> List[str]: - """Get list of available git branches.""" + """Return sorted list of local and remote branch names (without remotes/ prefix).""" try: result = subprocess.run( ["git", "branch", "-a"], @@ -56,7 +73,7 @@ def get_git_branches(self) -> List[str]: return [] def select_branch(self) -> str: - """Prompt user to select a base git branch.""" + """Prompt user to select a base git branch (by number or by typing branch name).""" branches = self.get_git_branches() if not branches: branch = input("Enter base branch name: ").strip() @@ -82,13 +99,13 @@ def select_branch(self) -> str: print("Invalid input. Please enter a number or branch name.") def create_release_branch(self, base_branch: str, version: str) -> str: - """Create a new git branch for release notes.""" + """Create and checkout branch MTV-RN- from base_branch. Reuses branch if it exists.""" branch_name = f"MTV-RN-{version}" - + print(f"\nCreating new branch: {branch_name} from {base_branch}") - + try: - # Checkout base branch first + # Checkout base branch first so new branch is created from it subprocess.run( ["git", "checkout", base_branch], cwd=self.repo_path, @@ -125,7 +142,7 @@ def create_release_branch(self, base_branch: str, version: str) -> str: text=True ) if result.stdout.strip(): - response = input(f"Branch {branch_name} already exists. Use it? (y/n): ").strip().lower() + response = 'y' if getattr(self, 'non_interactive', False) else input(f"Branch {branch_name} already exists. Use it? (y/n): ").strip().lower() if response == 'y': subprocess.run( ["git", "checkout", branch_name], @@ -136,7 +153,7 @@ def create_release_branch(self, base_branch: str, version: str) -> str: raise def select_version(self) -> str: - """Prompt user to enter MTV version.""" + """Prompt user for MTV version; accepts X.Y or X.Y.Z (e.g. 2.11 or 2.11.1).""" while True: version = input("\nEnter MTV version (e.g., 2.11.1): ").strip() if re.match(r'^\d+\.\d+(\.\d+)?$', version): @@ -146,21 +163,20 @@ def select_version(self) -> str: def parse_jira_csv(self, csv_path: str) -> List[Dict[str, str]]: """ - Parse JIRA CSV export file. - - Expected CSV format (columns may vary, script will auto-detect): - - Issue key (e.g., MTV-3915) - - Summary/Title - - Description - - Status - - For known issues: Workaround (optional) - - The script will try to detect common column names. + Parse JIRA CSV export into list of dicts with keys: key, summary, description, workaround. + + Expected CSV columns (names are matched case-insensitively): + - Issue key (e.g. MTV-3915 or MTV-Test-1) + - Summary/Title + - Description + - Workaround (optional; used for known issues) + + Delimiter is auto-detected. Returns list of ticket dicts; rows without a key are skipped. """ tickets = [] - + with open(csv_path, 'r', encoding='utf-8') as f: - # Try to detect delimiter + # Sniff delimiter (comma vs semicolon) from first 1KB sample = f.read(1024) f.seek(0) sniffer = csv.Sniffer() @@ -215,15 +231,10 @@ def parse_jira_csv(self, csv_path: str) -> List[Dict[str, str]]: return tickets def format_issue_text(self, summary: str, description: str, workaround: Optional[str] = None) -> str: - """Format issue text for AsciiDoc.""" - # Clean up text + """Format a single issue as AsciiDoc: title::, description, optional *Workaround:* block.""" summary = summary.strip() description = description.strip() - - # Remove HTML tags if present description = re.sub(r'<[^>]+>', '', description) - - # Format the issue entry lines = [f"{summary}::", ""] if description: @@ -240,7 +251,7 @@ def format_issue_text(self, summary: str, description: str, workaround: Optional return "\n".join(lines) def generate_resolved_issues(self, tickets: List[Dict[str, str]], version: str) -> str: - """Generate resolved issues AsciiDoc content.""" + """Build AsciiDoc content for the resolved-issues module (z-stream, e.g. 2.11.1).""" version_safe = version.replace('.', '-') version_id = version.replace('.', '-') @@ -271,7 +282,7 @@ def generate_resolved_issues(self, tickets: List[Dict[str, str]], version: str) return "\n".join(lines) def generate_known_issues(self, tickets: List[Dict[str, str]], version: str) -> str: - """Generate known issues AsciiDoc content.""" + """Build AsciiDoc content for known-issues module (version is x-stream, e.g. 2.11).""" version_id = version.split('.')[0] + '-' + version.split('.')[1] # e.g., 2.11 lines = [ @@ -307,7 +318,7 @@ def generate_known_issues(self, tickets: List[Dict[str, str]], version: str) -> return "\n".join(lines) def generate_release_notes_header(self, version: str) -> str: - """Generate the main release notes header file.""" + """Generate the x-stream release notes header (e.g. rn-2-11.adoc).""" version_major = '.'.join(version.split('.')[:2]) # e.g., 2.11 version_id = version_major.replace('.', '-') @@ -328,7 +339,12 @@ def generate_release_notes_header(self, version: str) -> str: return "\n".join(lines) def update_master_adoc(self, version: str, has_resolved: bool, has_known: bool) -> bool: - """Update master.adoc to include new release notes modules.""" + """ + Update documentation/doc-Release_notes/master.adoc to include: + - x-stream header include (rn--.adoc) if not already present (avoids duplicate) + - z-stream resolved-issues include (rn----resolved-issues.adoc) + - known-issues include (known-issues--.adoc) + """ if not self.master_adoc_path.exists(): print(f"Warning: master.adoc not found at {self.master_adoc_path}") return False @@ -344,38 +360,44 @@ def update_master_adoc(self, version: str, has_resolved: bool, has_known: bool) # Check if this version already exists if f"rn-{version_major_safe}.adoc" in content: print(f"Warning: Release notes for version {version_major} already exist in master.adoc") - response = input("Continue anyway? (y/n): ").strip().lower() + response = 'y' if getattr(self, 'non_interactive', False) else input("Continue anyway? (y/n): ").strip().lower() if response != 'y': return False lines = content.split('\n') new_lines = [] - - # Track what we've inserted header_inserted = False resolved_inserted = False known_inserted = False - i = 0 while i < len(lines): line = lines[i] - # Insert release notes header right after common-attributes + # Insert release notes header right after common-attributes (x-stream include, e.g. rn-2-11.adoc) if not header_inserted and line.strip() == 'include::modules/common-attributes.adoc[]': new_lines.append(line) - # Skip to the first rn- include and insert before it j = i + 1 while j < len(lines) and not lines[j].strip().startswith('include::modules/rn-'): new_lines.append(lines[j]) j += 1 - # Insert new header + # Avoid duplicate include (see e.g. PR #865): if rn--.adoc is already + # in master.adoc, copy it once and continue; do not insert a second include. + if j < len(lines): + existing_include = lines[j].strip() + expected_include = f"include::modules/rn-{version_major_safe}.adoc[leveloffset=+1]" + if existing_include == expected_include: + new_lines.append(lines[j]) + header_inserted = True + i = j + 1 + continue + # First rn- include is for an older version; insert our header before it new_lines.append("") new_lines.append(f"include::modules/rn-{version_major_safe}.adoc[leveloffset=+1]") header_inserted = True i = j continue - # Insert resolved issues after the last resolved issues include + # Insert z-stream resolved-issues include after the last existing resolved-issues include if has_resolved and not resolved_inserted: if '// Resolved issues by z-stream release' in line: new_lines.append(line) @@ -404,7 +426,7 @@ def update_master_adoc(self, version: str, has_resolved: bool, has_known: bool) i = k continue - # Replace known issues include + # Ensure known-issues--.adoc is included (replace or add) if has_known and not known_inserted: if '// Known issues by x-stream release' in line: new_lines.append(line) @@ -424,7 +446,7 @@ def update_master_adoc(self, version: str, has_resolved: bool, has_known: bool) new_lines.append(line) i += 1 - # Fallback: if we didn't find insertion points, try simpler approach + # Fallback: insert resolved-issues include after any existing resolved-issues line if has_resolved and not resolved_inserted: # Find the resolved issues section and append for idx, line in enumerate(new_lines): @@ -453,16 +475,14 @@ def update_master_adoc(self, version: str, has_resolved: bool, has_known: bool) return True def extract_existing_issue_keys(self, filepath: Path) -> set: - """Extract issue keys (e.g., MTV-3915) from existing AsciiDoc file.""" + """Extract JIRA issue keys from AsciiDoc file (e.g. MTV-3915, MTV-Test-1) for deduplication.""" issue_keys = set() if not filepath.exists(): return issue_keys - with open(filepath, 'r', encoding='utf-8') as f: content = f.read() - - # Find all JIRA issue links: link:https://issues.redhat.com/browse/MTV-XXXX[MTV-XXXX] - pattern = r'link:https://issues\.redhat\.com/browse/([A-Z]+-\d+)\[' + # Match link:https://issues.redhat.com/browse/KEY[KEY]; KEY may be MTV-123 or MTV-Test-1 + pattern = r'link:https://issues\.redhat\.com/browse/([A-Z][A-Z0-9-]*-\d+)\[' matches = re.findall(pattern, content) issue_keys.update(matches) @@ -530,7 +550,7 @@ def merge_content(self, existing_header: List[str], existing_body: List[str], line = new_lines[i] # Check if this line contains an issue key (end of an issue) - issue_key_match = re.search(r'link:https://issues\.redhat\.com/browse/([A-Z]+-\d+)\[', line) + issue_key_match = re.search(r'link:https://issues\.redhat\.com/browse/([A-Z][A-Z0-9-]*-\d+)\[', line) if issue_key_match: issue_key = issue_key_match.group(1) if issue_key not in existing_keys: @@ -609,20 +629,11 @@ def append_to_file(self, filepath: Path, new_content: str, existing_keys: set) - def save_file(self, content: str, filename: str, append_mode: bool = True) -> bool: """ - Save content to file in modules directory. - If file exists and append_mode is True, merges new content with existing. - - Args: - content: The content to write - filename: The filename (relative to modules directory) - append_mode: If True and file exists, append new issues (default: True) - - Returns: - True if file was saved, False if user cancelled or no new content + Write content to documentation/modules/. + If file exists and append_mode is True, merges new issues into existing (no duplicate keys). + If append_mode is False and file exists, prompts to overwrite (or uses --yes). """ filepath = self.modules_path / filename - - # Check if file exists if filepath.exists(): if append_mode: # Extract existing issue keys @@ -651,7 +662,7 @@ def save_file(self, content: str, filename: str, append_mode: bool = True) -> bo else: # Overwrite mode print(f"\n⚠️ Warning: File already exists: {filepath}") - response = input("Overwrite existing file? (y/n/backup): ").strip().lower() + response = 'y' if getattr(self, 'non_interactive', False) else input("Overwrite existing file? (y/n/backup): ").strip().lower() if response == 'n': print(f"✗ Skipped: {filepath}") @@ -683,25 +694,34 @@ def save_file(self, content: str, filename: str, append_mode: bool = True) -> bo def extract_existing_issue_keys_from_content(self, content: str) -> set: """Extract issue keys from content string.""" issue_keys = set() - pattern = r'link:https://issues\.redhat\.com/browse/([A-Z]+-\d+)\[' + pattern = r'link:https://issues\.redhat\.com/browse/([A-Z][A-Z0-9-]*-\d+)\[' matches = re.findall(pattern, content) issue_keys.update(matches) return issue_keys - def run(self): - """Main execution flow.""" + def run( + self, + base_branch: Optional[str] = None, + version: Optional[str] = None, + resolved_csv: Optional[str] = None, + known_csv: Optional[str] = None, + ): + """ + Run the full flow: branch selection, version, CSVs, then generate modules and update master.adoc. + Pass base_branch, version, resolved_csv, known_csv to skip prompts (non-interactive). + """ print("=" * 60) print("Forklift/MTV Release Notes Generator") print("=" * 60) # Step 1: Select base branch print("\n[Step 1] Base Branch Selection") - self.base_branch = self.select_branch() + self.base_branch = base_branch if base_branch is not None else self.select_branch() print(f"Selected base branch: {self.base_branch}") # Step 2: Select version print("\n[Step 2] Version Selection") - self.version = self.select_version() + self.version = version if version is not None else self.select_version() print(f"Selected version: {self.version}") # Step 3: Create release branch @@ -710,7 +730,10 @@ def run(self): # Step 4: Upload resolved issues print("\n[Step 4] Resolved Issues") - resolved_file = input("Enter path to resolved issues CSV file (or press Enter to skip): ").strip() + if resolved_csv is None: + resolved_file = input("Enter path to resolved issues CSV file (or press Enter to skip): ").strip() + else: + resolved_file = resolved_csv resolved_tickets = [] if resolved_file: if os.path.exists(resolved_file): @@ -722,7 +745,10 @@ def run(self): # Step 5: Upload known issues print("\n[Step 5] Known Issues") - known_file = input("Enter path to known issues CSV file (or press Enter to skip): ").strip() + if known_csv is None: + known_file = input("Enter path to known issues CSV file (or press Enter to skip): ").strip() + else: + known_file = known_csv known_tickets = [] if known_file: if os.path.exists(known_file): @@ -788,15 +814,43 @@ def run(self): def main(): - """Entry point.""" + """Parse CLI, create generator, and run. Exits on Ctrl+C or exception.""" + parser = argparse.ArgumentParser( + description="Generate Forklift/MTV release notes from JIRA CSV exports.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + Interactive: + python generate_release_notes.py + + Non-interactive (e.g. test PR): + python generate_release_notes.py --version 2.11.1 --base main \\ + --resolved scripts/test_data/resolved_issues.csv \\ + --known scripts/test_data/known_issues.csv --yes + """, + ) + parser.add_argument("--version", metavar="X.Y.Z", help="MTV version (e.g., 2.11.1)") + parser.add_argument("--base", dest="base_branch", metavar="BRANCH", help="Base git branch") + parser.add_argument("--resolved", metavar="CSV", help="Path to resolved issues CSV") + parser.add_argument("--known", metavar="CSV", help="Path to known issues CSV") + parser.add_argument("--yes", "-y", action="store_true", help="Non-interactive: overwrite/use existing without prompting") + args = parser.parse_args() + + non_interactive = args.yes or any([args.version, args.base_branch, args.resolved, args.known]) + # Get repository path (script location's parent) script_path = Path(__file__).resolve() repo_path = script_path.parent.parent - - generator = ReleaseNotesGenerator(str(repo_path)) - + + generator = ReleaseNotesGenerator(str(repo_path), non_interactive=non_interactive) + try: - generator.run() + generator.run( + base_branch=args.base_branch, + version=args.version, + resolved_csv=args.resolved, + known_csv=args.known, + ) except KeyboardInterrupt: print("\n\nOperation cancelled by user.") sys.exit(1)