From 39d10fa77dc59baa9b0d78ce511a5b9bfb6b9b24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lison=20Fernandes?= Date: Mon, 3 Mar 2025 15:44:11 +0000 Subject: [PATCH] Add release notes processing script and unit tests --- .../release-notes/process_release_notes.py | 160 ++++++++++++++++++ .../test_process_release_notes.py | 95 +++++++++++ 2 files changed, 255 insertions(+) create mode 100644 .github/scripts/release-notes/process_release_notes.py create mode 100644 .github/scripts/release-notes/test_process_release_notes.py diff --git a/.github/scripts/release-notes/process_release_notes.py b/.github/scripts/release-notes/process_release_notes.py new file mode 100644 index 0000000000..d57f7ee8ab --- /dev/null +++ b/.github/scripts/release-notes/process_release_notes.py @@ -0,0 +1,160 @@ +import re +import sys +import subprocess +import json +from typing import List, Tuple + +def get_linked_issues(owner: str, repo: str, pr_number: int) -> List[int]: + """Call GitHub API to get linked issue numbers for a PR. + + Args: + owner: Repository owner + repo: Repository name + pr_number: Pull request number + + Returns: + List of linked issue numbers + """ + + + query = """ + query ($owner: String!, $repo: String!, $pr: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pr) { + closingIssuesReferences(first: 100) { + nodes { + number + } + } + } + } + } + """ + + try: + result = subprocess.run([ + 'gh', 'api', 'graphql', + '-F', f'owner={owner}', + '-F', f'repo={repo}', + '-F', f'pr={pr_number}', + '-f', f'query={query}', + '--jq', '.data.repository.pullRequest.closingIssuesReferences.nodes[].number' + ], capture_output=True, text=True, check=True) + + # Split output into lines and convert to integers + if result.stdout.strip(): + return [int(num) for num in result.stdout.strip().split('\n')] + return [] + + except subprocess.CalledProcessError: + print(f"Error fetching linked issues for PR #{pr_number}") + return [] + + + +def extract_jira_tickets(line: str) -> List[str]: + # Find all Jira tickets in format ABC-123 (with any prefix/suffix) + return re.findall(r'[A-Z]+-\d+', line) + +def extract_pr_numbers(line: str) -> List[str]: + # Match PR numbers from GitHub format (#123) + return re.findall(r'#(\d+)', line) + +def process_line(line: str) -> str: + """Process a single line from release notes by removing Jira tickets, conventional commit prefixes and other common patterns. + + Args: + line: A single line from release notes + + Returns: + Processed line with tickets and prefixes removed + + Example: + >>> process_line("[ABC-123] feat(ui): Add new button") + "Add new button" + """ + original = line + + # Remove Jira ticket patterns: + # line = re.sub(r'\[[A-Z]+-\d+\]', '', line) # [ABC-123] -> "" + # line = re.sub(r'[A-Z]+-\d+:\s', '', line) # ABC-123: -> "" + # line = re.sub(r'[A-Z]+-\d+\s-\s', '', line) # ABC-123 - -> "" + + # Remove keywords and their variations + patterns = [ + r'BACKPORT', # BACKPORT -> "" + r'[deps]:', # [deps]: -> "" + r'feat(?:\([^)]*\))?:', # feat: or feat(ui): -> "" + r'bug(?:\([^)]*\))?:', # bug: or bug(core): -> "" + r'ci(?:\([^)]*\))?:' # ci: or ci(workflow): -> "" + ] + for pattern in patterns: + line = re.sub(pattern, '', line) + + cleaned = line.strip() + if cleaned != original.strip(): + print(f"Processed: {original.strip()} -> {cleaned}") + return cleaned + +def process_file(input_file: str) -> Tuple[List[str], List[str], List[str]]: + jira_tickets: List[str] = [] + pr_numbers: List[str] = [] + processed_lines: List[str] = [] + + print("Processing file: ", input_file) + + with open(input_file, 'r') as f: + for line in f: + line = line.strip() + should_process = line and not line.endswith(':') + + if should_process: + tickets = extract_jira_tickets(line) + jira_tickets.extend(tickets) + + prs = extract_pr_numbers(line) + pr_numbers.extend(prs) + processed_lines.append(process_line(line)) + else: + processed_lines.append(line) + + + # Remove duplicates while preserving order + jira_tickets = list(dict.fromkeys(jira_tickets)) + pr_numbers = list(dict.fromkeys(pr_numbers)) + + print("Jira tickets:", ",".join(jira_tickets)) + print("PR numbers:", ",".join(pr_numbers)) + print("Finished processing file: ", input_file) + return jira_tickets, pr_numbers, processed_lines + +def save_results(jira_tickets: List[str], pr_numbers: List[str], processed_lines: List[str], + jira_file: str = 'jira_tickets.txt', + pr_file: str = 'pr_numbers.txt', + processed_file: str = 'processed_notes.txt') -> None: + with open(jira_file, 'w') as f: + f.write('\n'.join(jira_tickets)) + + with open(pr_file, 'w') as f: + f.write('\n'.join(pr_numbers)) + + with open(processed_file, 'w') as f: + f.write('\n'.join(processed_lines)) + +if __name__ == '__main__': + input_file = 'release_notes.txt' + jira_file = 'jira_tickets.txt' + pr_file = 'pr_numbers.txt' + processed_file = 'processed_notes.txt' + + if len(sys.argv) >= 2: + input_file = sys.argv[1] + if len(sys.argv) >= 3: + jira_file = sys.argv[2] + if len(sys.argv) >= 4: + pr_file = sys.argv[3] + if len(sys.argv) >= 5: + processed_file = sys.argv[4] + + jira_tickets, pr_numbers, processed_lines = process_file(input_file) + save_results(jira_tickets, pr_numbers, processed_lines, jira_file, pr_file, processed_file) diff --git a/.github/scripts/release-notes/test_process_release_notes.py b/.github/scripts/release-notes/test_process_release_notes.py new file mode 100644 index 0000000000..4a3b03a526 --- /dev/null +++ b/.github/scripts/release-notes/test_process_release_notes.py @@ -0,0 +1,95 @@ +import unittest +import tempfile +import os +from process_release_notes import extract_jira_tickets, extract_pr_numbers, process_line, process_file, get_linked_issues + +class TestProcessReleaseNotes(unittest.TestCase): + def setUp(self): + self.test_file = tempfile.NamedTemporaryFile(delete=False) + + def tearDown(self): + os.unlink(self.test_file.name) + + def test_extract_jira_tickets(self): + test_cases = [ + ("[ABC-123] Some text", ["ABC-123"]), + ("DEF-456: Some text", ["DEF-456"]), + ("GHI-789 - Some text", ["GHI-789"]), + ("Multiple [ABC-123] and DEF-456: tickets", ["ABC-123", "DEF-456"]), + ("No tickets here", []), + ("Mixed formats ABC-123 [DEF-456] GHI-789:", ["ABC-123", "DEF-456", "GHI-789"]) + ] + for input_text, expected in test_cases: + with self.subTest(input_text=input_text): + result = extract_jira_tickets(input_text) + self.assertEqual(result, expected) + + def test_extract_pr_numbers(self): + test_cases = [ + ("PR #123 text", ["123"]), + ("Multiple PRs #456 and #789", ["456", "789"]), + ("No PR numbers", []) + ] + for input_text, expected in test_cases: + with self.subTest(input_text=input_text): + result = extract_pr_numbers(input_text) + self.assertEqual(result, expected) + + def test_process_line(self): + test_cases = [ + ("[ABC-123] BACKPORT Some text", "Some text"), + ("DEF-456: feat(component): Some text", "Some text"), + ("GHI-789 - bug(fix): Some text", "Some text"), + ("ci: Some text", "Some text"), + ("ci(workflow): Some text", "Some text"), + ("feat: Direct feature", "Direct feature"), + ("bug: Simple bugfix", "Simple bugfix"), + ("Normal text", "Normal text") + ] + for input_text, expected in test_cases: + with self.subTest(input_text=input_text): + result = process_line(input_text) + self.assertEqual(result, expected) + + def test_process_file(self): + content = """ +### Features: +[ABC-123] feat(comp): Feature 1 #123 +DEF-456: bug(fix): Bug fix #456 +GHI-789 - BACKPORT Some text #789 + +### Bug Fixes: +Another line without changes +""" + with open(self.test_file.name, 'w') as f: + f.write(content) + + jira_tickets, pr_numbers, processed_lines = process_file(self.test_file.name) + + self.assertEqual(jira_tickets, ["ABC-123", "DEF-456", "GHI-789"]) + self.assertEqual(pr_numbers, ["123", "456", "789"]) + self.assertEqual(processed_lines, [ + '', + '### Features:', + 'Feature 1 #123', + 'Bug fix #456', + 'Some text #789', + '', + '### Bug Fixes:', + 'Another line without changes' + ]) + + def test_get_linked_issues(self): + test_cases = [ + ("bitwarden", "android", 4696, [4659]), + ("bitwarden", "android", 4809, []) + ] + + for owner, repo, pr_id, expected_linked_issues in test_cases: + with self.subTest(msg=f"Testing PR #{pr_id} for {owner}/{repo}"): + result = get_linked_issues(owner, repo, pr_id) + self.assertEqual(sorted(result), sorted(expected_linked_issues)) + + +if __name__ == '__main__': + unittest.main()