|
19 | 19 | import re |
20 | 20 | import signal |
21 | 21 | import sys |
| 22 | +import textwrap |
22 | 23 |
|
23 | 24 | from datetime import datetime, timedelta, timezone |
24 | 25 | from getpass import getuser |
@@ -101,6 +102,31 @@ def process(self, msg, kwargs): |
101 | 102 | def gitauth(): |
102 | 103 | return (GITHUB_USER, GITHUB_TOKEN) |
103 | 104 |
|
| 105 | +def post_github_comment(session, pr_id, body): |
| 106 | + """Helper to post a comment to a GitHub PR.""" |
| 107 | + if RedmineUpkeep.GITHUB_RATE_LIMITED: |
| 108 | + log.warning("GitHub API rate limit hit previously. Skipping posting comment.") |
| 109 | + return False |
| 110 | + |
| 111 | + log.info(f"Posting a comment to GitHub PR #{pr_id}.") |
| 112 | + endpoint = f"{GITHUB_API_ENDPOINT}/issues/{pr_id}/comments" |
| 113 | + payload = {'body': body} |
| 114 | + try: |
| 115 | + response = session.post(endpoint, auth=gitauth(), json=payload) |
| 116 | + response.raise_for_status() |
| 117 | + log.info(f"Successfully posted comment to PR #{pr_id}.") |
| 118 | + return True |
| 119 | + except requests.exceptions.HTTPError as e: |
| 120 | + if e.response.status_code == 403 and "rate limit exceeded" in e.response.text: |
| 121 | + log.error(f"GitHub API rate limit exceeded when commenting on PR #{pr_id}.") |
| 122 | + RedmineUpkeep.GITHUB_RATE_LIMITED = True |
| 123 | + else: |
| 124 | + log.error(f"GitHub API error posting comment to PR #{pr_id}: {e} - Response: {e.response.text}") |
| 125 | + return False |
| 126 | + except requests.exceptions.RequestException as e: |
| 127 | + log.error(f"Network or request error posting comment to GitHub PR #{pr_id}: {e}") |
| 128 | + return False |
| 129 | + |
104 | 130 | class IssueUpdate: |
105 | 131 | def __init__(self, issue, github_session, git_repo): |
106 | 132 | self.issue = issue |
@@ -266,8 +292,10 @@ def __init__(self, args): |
266 | 292 | self.R = self._redmine_connect() |
267 | 293 | self.limit = args.limit |
268 | 294 | self.session = requests.Session() |
269 | | - self.issue_id = args.issue # Store issue_id from args |
270 | | - self.revision_range = args.revision_range # Store revision_range from args |
| 295 | + self.issue_id = args.issue |
| 296 | + self.revision_range = args.revision_range |
| 297 | + self.pull_request_id = args.pull_request |
| 298 | + self.merge_commit = args.merge_commit |
271 | 299 |
|
272 | 300 | self.issues_inspected = 0 |
273 | 301 | self.issues_modified = 0 |
@@ -699,10 +727,90 @@ def filter_and_process_issues(self): |
699 | 727 | elif self.revision_range is not None: |
700 | 728 | log.info(f"Processing in revision-range mode for range: {self.revision_range}.") |
701 | 729 | self._execute_revision_range() |
| 730 | + elif self.pull_request_id is not None: |
| 731 | + log.info(f"Processing in pull-request mode for PR #{self.pull_request_id}.") |
| 732 | + self._execute_pull_request() |
702 | 733 | else: |
703 | 734 | log.info(f"Processing in filter-based mode with a limit of {self.limit} issues.") |
704 | 735 | self._execute_filters() |
705 | 736 |
|
| 737 | + def _execute_pull_request(self): |
| 738 | + """ |
| 739 | + Handles the --pull-request logic. |
| 740 | + 1. Finds Redmine issues linked to the PR and runs transforms on them. |
| 741 | + 2. If none, inspects the local merge commit for "Fixes:" tags. |
| 742 | + 3. If tags are found, comments on the GH PR to ask the author to link the ticket. |
| 743 | + """ |
| 744 | + pr_id = self.pull_request_id |
| 745 | + merge_commit_sha = self.merge_commit |
| 746 | + log.info(f"Querying Redmine for issues linked to PR #{pr_id} and merge commit {merge_commit_sha}") |
| 747 | + |
| 748 | + filters = { |
| 749 | + "project_id": self.project_id, |
| 750 | + "status_id": "*", |
| 751 | + f"cf_{REDMINE_CUSTOM_FIELD_ID_PULL_REQUEST_ID}": pr_id, |
| 752 | + } |
| 753 | + issues = self.R.issue.filter(**filters) |
| 754 | + |
| 755 | + processed_issue_ids = set() |
| 756 | + if len(issues) > 0: |
| 757 | + log.info(f"Found {len(issues)} linked issue(s). Applying transformations.") |
| 758 | + for issue in issues: |
| 759 | + self._process_issue_transformations(issue) |
| 760 | + processed_issue_ids.add(issue.id) |
| 761 | + # Still, check commit logs. |
| 762 | + else: |
| 763 | + log.warning(f"No Redmine issues found linked to PR #{pr_id}. Inspecting local merge commit {merge_commit_sha} for 'Fixes:' tags.") |
| 764 | + |
| 765 | + found_tracker_ids = set() |
| 766 | + try: |
| 767 | + revrange = f"{merge_commit_sha}^..{merge_commit_sha}" |
| 768 | + log.info(f"Iterating commits {revrange}") |
| 769 | + for commit in self.G.iter_commits(revrange): |
| 770 | + log.info(f"Inspecting commit {commit.hexsha}") |
| 771 | + |
| 772 | + fixes_regex = re.compile(r"Fixes: https://tracker.ceph.com/issues/(\d+)", re.MULTILINE) |
| 773 | + commit_fixes = set(fixes_regex.findall(commit.message)) |
| 774 | + for tracker_id in commit_fixes: |
| 775 | + log.info(f"Commit {commit.hexsha} claims to fix https://tracker.ceph.com/issues/{tracker_id}") |
| 776 | + found_tracker_ids.add(int(tracker_id)) |
| 777 | + except git.exc.GitCommandError as e: |
| 778 | + log.error(f"Git command failed for commit SHA '{merge_commit_sha}': {e}. Ensure the commit exists in the local repository.") |
| 779 | + return |
| 780 | + |
| 781 | + # Are the found_tracker_ids (including empty set) a proper subset of processed_issue_ids? |
| 782 | + log.debug(f"found_tracker_ids = {found_tracker_ids}") |
| 783 | + log.debug(f"processed_issue_ids = {processed_issue_ids}") |
| 784 | + if found_tracker_ids <= processed_issue_ids: |
| 785 | + log.info("All commits reference trackers already processed or no tracker referenced to be fixed.") |
| 786 | + return |
| 787 | + |
| 788 | + log.info(f"Found 'Fixes:' tags for tracker(s) #{', '.join([str(x) for x in found_tracker_ids])} in commits.") |
| 789 | + |
| 790 | + tracker_links = "\n".join([f"https://tracker.ceph.com/issues/{tid}" for tid in found_tracker_ids]) |
| 791 | + comment_body = f""" |
| 792 | +
|
| 793 | + This is an automated message by src/script/redmine-upkeep.py. |
| 794 | +
|
| 795 | + I found one or more 'Fixes:' tags in the commit messages in |
| 796 | +
|
| 797 | + `git log {revrange}` |
| 798 | +
|
| 799 | + The referenced tickets are: |
| 800 | +
|
| 801 | + {tracker_links} |
| 802 | +
|
| 803 | + Those tickets do not reference this merged Pull Request. If this |
| 804 | + Pull Request merge resolves any of those tickets, please update the |
| 805 | + "Pull Request ID" field on each ticket. A future run of this |
| 806 | + script will appropriately update them. |
| 807 | +
|
| 808 | + """ |
| 809 | + comment_body = textwrap.dedent(comment_body) |
| 810 | + log.debug(f"Leaving comment:\n{comment_body}") |
| 811 | + |
| 812 | + post_github_comment(self.session, pr_id, comment_body) |
| 813 | + |
706 | 814 | def _execute_revision_range(self): |
707 | 815 | log.info(f"Processing issues based on revision range: {self.revision_range}") |
708 | 816 | try: |
@@ -737,9 +845,6 @@ def _execute_revision_range(self): |
737 | 845 | except redminelib.exceptions.ResourceAttrError as e: |
738 | 846 | log.error(f"Redmine API error for merge commit {commit}: {e}") |
739 | 847 | raise |
740 | | - except Exception as e: |
741 | | - log.exception(f"Error processing issues for merge commit {commit}: {e}") |
742 | | - raise |
743 | 848 | except git.exc.GitCommandError as e: |
744 | 849 | log.error(f"Git command error for revision range '{self.revision_range}': {e}") |
745 | 850 | raise |
@@ -792,12 +897,24 @@ def main(): |
792 | 897 | parser.add_argument('--limit', dest='limit', action='store', type=int, default=200, help='limit processed issues') |
793 | 898 | parser.add_argument('--git-dir', dest='git', action='store', default=".", help='git directory') |
794 | 899 |
|
| 900 | + # Mutually exclusive group for different modes of operation |
795 | 901 | group = parser.add_mutually_exclusive_group() |
796 | | - group.add_argument('--issue', dest='issue', action='store', help='issue to check') |
| 902 | + group.add_argument('--issue', dest='issue', action='store', help='Single issue ID to check.') |
797 | 903 | group.add_argument('--revision-range', dest='revision_range', action='store', |
798 | | - help='Git revision range (e.g., "v12.2.2..v12.2.3") to find merge commits and process related issues.') |
| 904 | + help='Git revision range to find merge commits and process related issues.') |
| 905 | + group.add_argument('--pull-request', dest='pull_request', type=int, action='store', |
| 906 | + help='Pull Request ID to lookup (requires --merge-commit).') |
| 907 | + |
| 908 | + parser.add_argument('--merge-commit', dest='merge_commit', action='store', |
| 909 | + help='Merge commit SHA for the PR (requires --pull-request).') |
799 | 910 |
|
800 | 911 | args = parser.parse_args(sys.argv[1:]) |
| 912 | + |
| 913 | + # Ensure --pull-request and --merge-commit are used together |
| 914 | + if args.pull_request and not args.merge_commit: |
| 915 | + parser.error("--pull-request and --merge-commit must be used together.") |
| 916 | + sys.exit(1) |
| 917 | + |
801 | 918 | log.info("Redmine Upkeep Script starting.") |
802 | 919 |
|
803 | 920 | global IS_GITHUB_ACTION |
|
0 commit comments