diff --git a/.github/scripts/release-notes/linked_issues.py b/.github/scripts/release-notes/linked_issues.py new file mode 100644 index 0000000000..bacea7a1d5 --- /dev/null +++ b/.github/scripts/release-notes/linked_issues.py @@ -0,0 +1,64 @@ +import sys +import subprocess +from typing import List + +def create_linked_issue_comment(repo_owner: str, repo_name: str, release_name: str, release_link: str, pr_numbers: List[int]) -> str: + if len(pr_numbers) == 0: + return "" + + pr_links = [f"* https://github.com/{repo_owner}/{repo_name}/pull/{pr_number}" for pr_number in pr_numbers] + + return f":shipit: Pull Request(s) linked to this issue released in [{release_name}]({release_link}):\n\n"+ "\n".join(pr_links) + +def comment_linked_issues_in_pr(owner: str, repo: str, pr_number: int) -> None: + """Use GitHub CLI to comment all issues linked to a PR. + """ + + + linked_issues = get_linked_issues(owner, repo, pr_number) + for issue_number in linked_issues: + comment_github_issue(owner, repo, issue_number, comment) + +def comment_github_issue(owner: str, repo: str, issue_number: int, comment: str) -> None: + """Use GitHub CLI to comment on an issue. + """ + subprocess.run([ + 'gh', 'issue', 'comment', str(issue_number), '--body', comment, '--repo', f'{owner}/{repo}' + ], check=True) + +def get_linked_issues(owner: str, repo: str, pr_number: int) -> List[int]: + """Use GitHub CLI to retrieve linked issue numbers for a PR. + """ + + 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 [] diff --git a/.github/scripts/release-notes/process_release_notes.py b/.github/scripts/release-notes/process_release_notes.py index d57f7ee8ab..197f6b0750 100644 --- a/.github/scripts/release-notes/process_release_notes.py +++ b/.github/scripts/release-notes/process_release_notes.py @@ -4,54 +4,6 @@ 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) diff --git a/.github/scripts/release-notes/test_linked_issues.py b/.github/scripts/release-notes/test_linked_issues.py new file mode 100644 index 0000000000..1c4ff76f40 --- /dev/null +++ b/.github/scripts/release-notes/test_linked_issues.py @@ -0,0 +1,30 @@ +import unittest +from linked_issues import get_linked_issues, create_linked_issue_comment + +class TestLinkedIssues(unittest.TestCase): + def test_create_linked_issue_comment(self): + test_cases = [ + ("bitwarden", "android", "v2025.1.0", "https://github.com/bitwarden/android/releases/tag/v2025.1.0", [4696]), + ("bitwarden", "android", "v2025.2.0", "https://github.com/bitwarden/android/releases/tag/v2025.2.0", [4809, 1, 2, 3]), + ("bitwarden", "android", "v2025.3.0", "https://github.com/bitwarden/android/releases/tag/v2025.3.0", []), + ] + + for owner, repo, release_name, release_link, pr_numbers in test_cases: + with self.subTest(msg=f"Creating comment for issue in release {release_name}"): + comment = create_linked_issue_comment(owner, repo, release_name, release_link, pr_numbers) + print(comment + "\n") + + 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()