diff --git a/.github/actions/bot-autoassign/__main__.py b/.github/actions/bot-autoassign/__main__.py new file mode 100644 index 00000000..4259a4c1 --- /dev/null +++ b/.github/actions/bot-autoassign/__main__.py @@ -0,0 +1,41 @@ +import sys +import traceback + + +def main(): + if len(sys.argv) < 2: + print( + "Usage: python __main__.py [args...]\n" + "Available bot types: issue_assignment, stale_pr, pr_reopen" + ) + return 1 + + bot_type = sys.argv[1] + + try: + sys.argv = [sys.argv[0]] + sys.argv[2:] + + if bot_type == "issue_assignment": + from issue_assignment_bot import main as issue_main + + return issue_main() + elif bot_type == "stale_pr": + from stale_pr_bot import main as stale_main + + return stale_main() + elif bot_type == "pr_reopen": + from pr_reopen_bot import main as pr_main + + return pr_main() + else: + print(f"Unknown bot type: {bot_type}") + print("Available bot types: " "issue_assignment, stale_pr, pr_reopen") + return 1 + except Exception as e: + print(f"Error running {bot_type} bot: {e}") + traceback.print_exc() + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/actions/bot-autoassign/base.py b/.github/actions/bot-autoassign/base.py new file mode 100644 index 00000000..08e05022 --- /dev/null +++ b/.github/actions/bot-autoassign/base.py @@ -0,0 +1,27 @@ +import os + +from github import Github + + +class GitHubBot: + def __init__(self): + self.github_token = os.environ.get("GITHUB_TOKEN") + self.repository_name = os.environ.get("REPOSITORY") + self.event_name = os.environ.get("GITHUB_EVENT_NAME") + self.event_payload = None + + if self.github_token and self.repository_name: + try: + self.github = Github(self.github_token) + self.repo = self.github.get_repo(self.repository_name) + except Exception as e: + print(f"Warning: Could not initialize GitHub client: {e}") + self.github = None + self.repo = None + else: + print("Warning: GITHUB_TOKEN or REPOSITORY env vars not set") + self.github = None + self.repo = None + + def load_event_payload(self, event_payload): + self.event_payload = event_payload diff --git a/.github/actions/bot-autoassign/issue_assignment_bot.py b/.github/actions/bot-autoassign/issue_assignment_bot.py new file mode 100644 index 00000000..9e807a8e --- /dev/null +++ b/.github/actions/bot-autoassign/issue_assignment_bot.py @@ -0,0 +1,357 @@ +import re + +from base import GitHubBot +from utils import ( + extract_linked_issues, + get_valid_linked_issues, + unassign_linked_issues_helper, +) + + +class IssueAssignmentBot(GitHubBot): + def is_assignment_request(self, comment_body): + if not comment_body: + return False + comment_lower = comment_body.lower() + assignment_patterns = [ + r"\bassign this issue to me\b", + r"\bassign me\b", + r"\bcan i work on this\b", + r"\bi would like to work on this\b", + r"\bi want to work on this\b", + r"\bplease assign this to me\b", + r"\bcan you assign this to me\b", + ] + return any(re.search(pattern, comment_lower) for pattern in assignment_patterns) + + def get_contributing_guidelines_url(self): + return "https://openwisp.io/docs/stable/developer/contributing.html" + + def detect_issue_type(self, issue): + """Analyzes labels, title and body. + + Returns 'bug', 'feature', or None. + """ + bug_keywords = [ + "bug", + "error", + "crash", + "fail", + "broken", + "problem", + "not working", + "doesn't work", + "does not work", + "fix", + "incorrect", + "wrong", + "exception", + "traceback", + "breaking", + "regression", + ] + feature_keywords = [ + "feature", + "enhancement", + "add", + "implement", + "support", + "new", + "create", + "allow", + "enable", + "improve", + "improvement", + "upgrade", + "extend", + "functionality", + "capability", + "ability", + "option", + ] + issue_labels = [label.name.lower() for label in issue.labels] + if any(label in issue_labels for label in ["bug", "bugfix", "fix"]): + return "bug" + elif any( + label in issue_labels for label in ["feature", "enhancement", "improvement"] + ): + return "feature" + title = (issue.title or "").lower() + body = (issue.body or "").lower() + combined_text = f"{title} {body}" + bug_score = sum( + 1 + for keyword in bug_keywords + if re.search(rf"\b{re.escape(keyword)}\b", combined_text) + ) + feature_score = sum( + 1 + for keyword in feature_keywords + if re.search(rf"\b{re.escape(keyword)}\b", combined_text) + ) + if bug_score > feature_score and bug_score > 0: + return "bug" + elif feature_score > bug_score and feature_score > 0: + return "feature" + return None + + def respond_to_assignment_request(self, issue_number, commenter): + if not self.repo: + print("GitHub client not initialized") + return False + try: + contributing_url = self.get_contributing_guidelines_url() + issue = self.repo.get_issue(issue_number) + issue_type = self.detect_issue_type(issue) + suggested_keyword = None + detection_reason = "" + if issue_type == "bug": + suggested_keyword = "Fixes" + detection_reason = "this appears to be a bug" + elif issue_type == "feature": + suggested_keyword = "Closes" + detection_reason = "this appears to be a feature or enhancement" + if suggested_keyword: + linking_instruction = ( + "**Link your PR to this issue** by including " + f"`{suggested_keyword} #{issue_number}`" + " in the PR description" + ) + keyword_explanation = ( + "\n\n**Note**: We suggest " + f"`{suggested_keyword}` because" + f" {detection_reason}. " + "You can also use:\n" + f"- `Closes #{issue_number}`" + " for features/changes\n" + f"- `Fixes #{issue_number}` for bugs\n" + f"- `Related to #{issue_number}`" + " for PRs that contribute " + "but don't completely solve the issue" + ) + else: + linking_instruction = ( + "**Link your PR to this issue** by" + " including one of the following " + "in the PR description:\n" + f" - `Closes #{issue_number}`" + " for features/changes\n" + f" - `Fixes #{issue_number}` for bugs\n" + f" - `Related to #{issue_number}`" + " for PRs that contribute " + "but don't completely solve the issue" + ) + keyword_explanation = "" + message_lines = [ + f"Hi @{commenter} 👋,", + "", + ("Thank you for your interest in" " contributing to OpenWISP! 🎉"), + "", + ( + "According to our [contributing guidelines]" + f"({contributing_url}), **you don't need to" + " wait to be assigned** to start working" + " on an issue." + ), + "We encourage you to:", + "", + ("1. **Fork the repository** and start" " working on your solution"), + ( + "2. **Open a Pull Request (PR) as soon as" + " possible** - even as a draft if it's" + " still in progress" + ), + f"3. {linking_instruction}{keyword_explanation}", + "", + ( + "Once you open a PR that references this" + " issue, you will be automatically" + " assigned to it." + ), + "", + "This approach helps us:", + "- See your progress and provide early feedback", + ( + "- Avoid multiple contributors working" + " on the same issue unknowingly" + ), + "- Keep the contribution process moving smoothly", + "", + ( + "We look forward to your contribution!" + " If you have any questions, feel free" + " to ask in the PR or check our" + f" [documentation]({contributing_url})." + ), + "", + "Happy coding! 🚀", + ] + message = "\n".join(message_lines) + issue.create_comment(message) + print(f"Posted assignment response to issue #{issue_number}") + return True + except Exception as e: + print(f"Error responding to assignment request: {e}") + return False + + def auto_assign_issues_from_pr(self, pr_number, pr_author, pr_body, max_issues=10): + if not self.repo: + print("GitHub client not initialized") + return [] + try: + linked_issues = extract_linked_issues(pr_body) + if not linked_issues: + print("No linked issues found in PR body") + return [] + if len(linked_issues) > max_issues: + print( + f"Found {len(linked_issues)} issue references," + f" processing first {max_issues}" + " to avoid rate limits" + ) + assigned_issues = [] + for issue_number, issue in get_valid_linked_issues( + self.repo, self.repository_name, pr_body + ): + if len(assigned_issues) >= max_issues: + break + try: + current_assignees = [ + assignee.login + for assignee in issue.assignees + if hasattr(assignee, "login") + ] + if current_assignees: + if pr_author in current_assignees: + print( + f"Issue #{issue_number} already" + f" assigned to {pr_author}" + ) + else: + print( + f"Issue #{issue_number} already" + " assigned to:" + f' {", ".join(current_assignees)}' + ) + continue + issue.add_to_assignees(pr_author) + assigned_issues.append(issue_number) + print(f"Assigned issue #{issue_number}" f" to {pr_author}") + comment_message = ( + "This issue has been automatically" + f" assigned to @{pr_author}" + f" who opened PR #{pr_number}" + " to address it. 🎯" + ) + issue.create_comment(comment_message) + except Exception as e: + print(f"Error processing issue" f" #{issue_number}: {e}") + return assigned_issues + except Exception as e: + print(f"Error in auto_assign_issues_from_pr: {e}") + return [] + + def unassign_issues_from_pr(self, pr_body, pr_author): + """Unassign linked issues from PR author""" + + if not self.repo: + print("GitHub client not initialized") + return [] + + try: + return unassign_linked_issues_helper( + self.repo, self.repository_name, pr_body, pr_author + ) + except Exception as e: + print(f"Error in unassign_issues_from_pr: {e}") + return [] + + def handle_issue_comment(self): + if not self.event_payload: + print("No event payload available") + return False + try: + if self.event_payload.get("issue", {}).get("pull_request"): + print("Comment is on a PR, not an issue - skipping") + return False + comment = self.event_payload.get("comment", {}) + issue = self.event_payload.get("issue", {}) + comment_body = comment.get("body", "") + commenter = comment.get("user", {}).get("login", "") + issue_number = issue.get("number") + if not all([comment_body, commenter, issue_number]): + print("Missing required comment data") + return False + if self.is_assignment_request(comment_body): + return self.respond_to_assignment_request(issue_number, commenter) + print("Comment does not contain assignment request") + return False + except Exception as e: + print(f"Error handling issue comment: {e}") + return False + + def handle_pull_request(self): + if not self.event_payload: + print("No event payload available") + return False + try: + pr = self.event_payload.get("pull_request", {}) + action = self.event_payload.get("action", "") + pr_number = pr.get("number") + pr_author = pr.get("user", {}).get("login", "") + pr_body = pr.get("body", "") + if not all([pr_number, pr_author]): + print("Missing required PR data") + return False + if action in ["opened", "reopened"]: + self.auto_assign_issues_from_pr(pr_number, pr_author, pr_body) + # We consider the event handled even if no issues were linked + return True + elif action == "closed": + self.unassign_issues_from_pr(pr_body, pr_author) + return True + print(f"PR action '{action}' not handled") + return False + except Exception as e: + print(f"Error handling pull request: {e}") + return False + + def run(self): + if not self.github or not self.repo: + print("GitHub client not properly initialized," " cannot proceed") + return False + print("Issue Assignment Bot starting" f" for event: {self.event_name}") + try: + if self.event_name == "issue_comment": + return self.handle_issue_comment() + elif self.event_name == "pull_request_target": + return self.handle_pull_request() + else: + print(f"Event type '{self.event_name}'" " not supported") + return False + except Exception as e: + print(f"Error in main execution: {e}") + return False + finally: + print("Issue Assignment Bot completed") + + +def main(): + import json + import sys + + bot = IssueAssignmentBot() + if len(sys.argv) > 1: + try: + with open(sys.argv[1], "r") as f: + event_payload = json.load(f) + bot.load_event_payload(event_payload) + except Exception as e: + print(f"Could not load event payload: {e}") + return 1 + result = bot.run() + return 0 if result else 1 + + +if __name__ == "__main__": + main() diff --git a/.github/actions/bot-autoassign/pr_reopen_bot.py b/.github/actions/bot-autoassign/pr_reopen_bot.py new file mode 100644 index 00000000..bf2bfa34 --- /dev/null +++ b/.github/actions/bot-autoassign/pr_reopen_bot.py @@ -0,0 +1,204 @@ +import json +import os + +from base import GitHubBot +from utils import get_valid_linked_issues + + +class PRReopenBot(GitHubBot): + def reassign_issues_to_author(self, pr_number, pr_author, pr_body): + try: + reassigned_issues = [] + for issue_number, issue in get_valid_linked_issues( + self.repo, self.repository_name, pr_body + ): + try: + current_assignees = [ + assignee.login + for assignee in issue.assignees + if hasattr(assignee, "login") + ] + if current_assignees and pr_author not in current_assignees: + print( + f"Issue #{issue_number} is assigned" + " to others:" + f' {", ".join(current_assignees)}' + ) + continue + if pr_author not in current_assignees: + issue.add_to_assignees(pr_author) + reassigned_issues.append(issue_number) + print(f"Reassigned issue #{issue_number}" f" to {pr_author}") + welcome_message = ( + f"Welcome back, @{pr_author}! 🎉" + " This issue has been reassigned" + " to you as you've reopened" + f" PR #{pr_number}." + ) + issue.create_comment(welcome_message) + except Exception as e: + print(f"Error processing issue" f" #{issue_number}: {e}") + return reassigned_issues + except Exception as e: + print(f"Error in reassign_issues_to_author: {e}") + return [] + + def remove_stale_label(self, pr_number): + try: + pr = self.repo.get_pull(pr_number) + labels = [label.name for label in pr.get_labels()] + if "stale" in labels: + pr.remove_from_labels("stale") + print("Removed stale label from" f" PR #{pr_number}") + return True + else: + print("No stale label found on" f" PR #{pr_number}") + return False + except Exception as e: + print("Error removing stale label from" f" PR #{pr_number}: {e}") + return False + + def handle_pr_reopen(self): + if not self.event_payload: + print("No event payload available") + return False + try: + pr = self.event_payload.get("pull_request", {}) + pr_number = pr.get("number") + pr_author = pr.get("user", {}).get("login", "") + pr_body = pr.get("body", "") + if not all([pr_number, pr_author]): + print("Missing required PR data") + return False + print(f"Handling PR #{pr_number}" f" reopen by {pr_author}") + reassigned = self.reassign_issues_to_author(pr_number, pr_author, pr_body) + self.remove_stale_label(pr_number) + print(f"Reassigned {len(reassigned)}" f" issues to {pr_author}") + return True + except Exception as e: + print(f"Error handling PR reopen: {e}") + return False + + def run(self): + if not self.github or not self.repo: + print("GitHub client not properly initialized," " cannot proceed") + return False + print("PR Reopen Bot starting" f" for event: {self.event_name}") + try: + if self.event_name == "pull_request_target": + return self.handle_pr_reopen() + else: + print(f"Event type '{self.event_name}'" " not supported") + return False + except Exception as e: + print(f"Error in main execution: {e}") + return False + finally: + print("PR Reopen Bot completed") + + +class PRActivityBot(GitHubBot): + def handle_contributor_activity(self): + if not self.event_payload: + print("No event payload available") + return False + try: + issue_data = self.event_payload.get("issue", {}) + pr_number = issue_data.get("number") + commenter = ( + self.event_payload.get("comment", {}).get("user", {}).get("login", "") + ) + if not all([pr_number, commenter]): + print("Missing required comment data") + return False + if not issue_data.get("pull_request"): + print("Comment is on an issue," " not a PR, skipping") + return False + pr = self.repo.get_pull(pr_number) + if not pr.user or commenter != pr.user.login: + print("Comment not from PR author, skipping") + return False + labels = [label.name for label in pr.get_labels()] + if "stale" not in labels: + print("PR is not stale, skipping") + return False + try: + pr.remove_from_labels("stale") + print("Removed stale label") + except Exception as e: + print(f"Could not remove stale label: {e}") + reassigned_count = 0 + for issue_number, issue in get_valid_linked_issues( + self.repo, self.repository_name, pr.body or "" + ): + try: + current_assignees = [ + assignee.login + for assignee in issue.assignees + if hasattr(assignee, "login") + ] + if not current_assignees: + issue.add_to_assignees(commenter) + reassigned_count += 1 + print(f"Reassigned issue #{issue_number}" f" to {commenter}") + except Exception as e: + print(f"Error reassigning issue" f" #{issue_number}: {e}") + if reassigned_count > 0: + encouragement_message = ( + f"Thanks for following up, @{commenter}! 🙌" + " The stale status has been removed and" + " the linked issue(s) have been reassigned" + " to you. Looking forward to your updates!" + ) + pr.create_issue_comment(encouragement_message) + print( + "Handled contributor activity," f" reassigned {reassigned_count} issues" + ) + return True + except Exception as e: + print(f"Error handling contributor activity: {e}") + return False + + def run(self): + if not self.github or not self.repo: + print("GitHub client not properly initialized," " cannot proceed") + return False + print("PR Activity Bot starting" f" for event: {self.event_name}") + try: + if self.event_name == "issue_comment": + return self.handle_contributor_activity() + else: + print(f"Event type '{self.event_name}'" " not supported") + return False + except Exception as e: + print(f"Error in main execution: {e}") + return False + finally: + print("PR Activity Bot completed") + + +def main(): + import sys + + if len(sys.argv) > 1: + try: + with open(sys.argv[1], "r") as f: + event_payload = json.load(f) + event_name = os.environ.get("GITHUB_EVENT_NAME", "") + if event_name == "issue_comment": + bot = PRActivityBot() + else: + bot = PRReopenBot() + bot.load_event_payload(event_payload) + result = bot.run() + return 0 if result else 1 + except Exception as e: + print(f"Error running bot: {e}") + return 1 + else: + print("Usage: python pr_reopen_bot.py" " ") + return 1 + + +if __name__ == "__main__": + main() diff --git a/.github/actions/bot-autoassign/stale_pr_bot.py b/.github/actions/bot-autoassign/stale_pr_bot.py new file mode 100644 index 00000000..95cca167 --- /dev/null +++ b/.github/actions/bot-autoassign/stale_pr_bot.py @@ -0,0 +1,407 @@ +import time +from collections import deque +from datetime import datetime, timezone + +from base import GitHubBot +from utils import unassign_linked_issues_helper + + +class StalePRBot(GitHubBot): + def __init__(self): + super().__init__() + self.DAYS_BEFORE_STALE_WARNING = 7 + self.DAYS_BEFORE_UNASSIGN = 14 + self.DAYS_BEFORE_CLOSE = 60 + + def get_days_since_activity( + self, + pr, + last_changes_requested, + issue_comments=None, + all_reviews=None, + review_comments=None, + ): + if not last_changes_requested: + return 0 + try: + pr_author = pr.user.login if pr.user else None + if not pr_author: + return 0 + last_author_activity = None + commits = deque(pr.get_commits(), maxlen=50) + for commit in commits: + commit_date = commit.commit.author.date + if commit_date > last_changes_requested: + if commit.author and commit.author.login == pr_author: + if ( + not last_author_activity + or commit_date > last_author_activity + ): + last_author_activity = commit_date + if issue_comments is None: + issue_comments = list(pr.get_issue_comments()) + comments = ( + issue_comments[-20:] if len(issue_comments) > 20 else issue_comments + ) + for comment in comments: + if comment.user and comment.user.login == pr_author: + comment_date = comment.created_at + if comment_date > last_changes_requested: + if ( + not last_author_activity + or comment_date > last_author_activity + ): + last_author_activity = comment_date + if review_comments is None: + review_comments = list(pr.get_review_comments()) + all_review_comments = review_comments + review_comments = ( + all_review_comments[-20:] + if len(all_review_comments) > 20 + else all_review_comments + ) + for comment in review_comments: + if comment.user and comment.user.login == pr_author: + comment_date = comment.created_at + if comment_date > last_changes_requested: + if ( + not last_author_activity + or comment_date > last_author_activity + ): + last_author_activity = comment_date + if all_reviews is None: + all_reviews = list(pr.get_reviews()) + reviews = all_reviews[-20:] if len(all_reviews) > 20 else all_reviews + for review in reviews: + if review.user and review.user.login == pr_author: + review_date = review.submitted_at + if review_date and review_date > last_changes_requested: + if ( + not last_author_activity + or review_date > last_author_activity + ): + last_author_activity = review_date + reference_date = last_author_activity or last_changes_requested + now = datetime.now(timezone.utc) + return (now - reference_date).days + except Exception as e: + print("Error calculating activity" f" for PR #{pr.number}: {e}") + return 0 + + def get_last_changes_requested(self, pr, all_reviews=None): + try: + if all_reviews is None: + all_reviews = list(pr.get_reviews()) + reviews = all_reviews[-50:] if len(all_reviews) > 50 else all_reviews + changes_requested_reviews = [ + r for r in reviews if r.state == "CHANGES_REQUESTED" + ] + if not changes_requested_reviews: + return None + changes_requested_reviews.sort(key=lambda r: r.submitted_at, reverse=True) + return changes_requested_reviews[0].submitted_at + except Exception as e: + print("Error getting reviews" f" for PR #{pr.number}: {e}") + return None + + def has_bot_comment(self, pr, comment_type, after_date=None, issue_comments=None): + """Check if PR already has a specific type of bot comment. + + Uses HTML markers. If ``after_date`` is provided, + only considers comments posted after that date. + """ + try: + if issue_comments is None: + issue_comments = list(pr.get_issue_comments()) + marker = f"" + for comment in issue_comments: + if ( + comment.user + and comment.user.type == "Bot" + and marker in comment.body + ): + if after_date and comment.created_at <= after_date: + continue + return True + return False + except Exception as e: + print("Error checking bot comments" f" for PR #{pr.number}: {e}") + return False + + def unassign_linked_issues(self, pr): + try: + pr_author = pr.user.login if pr.user else None + if not pr_author: + return False + unassigned_issues = unassign_linked_issues_helper( + self.repo, self.repository_name, pr.body or "", pr_author + ) + return len(unassigned_issues) + except Exception as e: + print(f"Error processing linked issues for PR #{pr.number}: {e}") + return 0 + + def close_stale_pr(self, pr, days_inactive): + if pr.state == "closed": + print(f"PR #{pr.number} is already closed, skipping") + return False + try: + pr_author = pr.user.login if pr.user else None + if not pr_author: + return False + close_lines = [ + "", + f"Hi @{pr_author} 👋,", + "", + ( + "This pull request has been automatically" + " closed due to" + f" **{days_inactive} days of inactivity**." + " After changes were requested," + " the PR remained inactive." + ), + "", + ( + "We understand that life gets busy," + " and we appreciate your initial" + " contribution! 💙" + ), + "", + ("**The door is always open**" " for you to come back:"), + ( + "- You can **reopen this PR** at any time" + " if you'd like to continue working on it" + ), + ("- Feel free to push new commits" " addressing the requested changes"), + ( + "- If you reopen the PR, the linked issue" + " will be reassigned to you" + ), + "", + ( + "If you have any questions or need help," + " don't hesitate to reach out." + " We're here to support you!" + ), + "", + ("Thank you for your interest in" " contributing to OpenWISP! 🙏"), + ] + try: + pr.create_issue_comment("\n".join(close_lines)) + except Exception as comment_error: + print( + f"Warning: Could not post closing comment" + f" on PR #{pr.number}: {comment_error}" + ) + finally: + pr.edit(state="closed") + unassigned_count = self.unassign_linked_issues(pr) + print( + f"Closed PR #{pr.number} after" + f" {days_inactive} days of inactivity," + f" unassigned {unassigned_count} issues" + ) + return True + except Exception as e: + print(f"Error closing PR #{pr.number}: {e}") + return False + + def mark_pr_stale(self, pr, days_inactive): + try: + pr_author = pr.user.login if pr.user else None + if not pr_author: + return False + unassign_lines = [ + "", + f"Hi @{pr_author} 👋,", + "", + ( + "This pull request has been marked" + " as **stale** due to" + f" **{days_inactive} days of inactivity**" + " after changes were requested." + ), + "", + ( + "As a result, **the linked issue(s)" + " have been unassigned** from you" + " to allow other contributors" + " to work on it." + ), + "", + ( + "However, **you can still continue" + " working on this PR**!" + " If you push new commits or respond" + " to the review feedback:" + ), + "- The issue will be reassigned to you", + "- Your contribution is still very welcome", + "", + ( + "If you need more time or have questions" + " about the requested changes, please" + " let us know." + " We're happy to help! 🤝" + ), + "", + ( + "If there's no further activity within" + f" **{self.DAYS_BEFORE_CLOSE - days_inactive}" + " more days**, this PR will be" + " automatically closed" + " (but can be reopened anytime)." + ), + ] + pr.create_issue_comment("\n".join(unassign_lines)) + unassigned_count = self.unassign_linked_issues(pr) + try: + pr.add_to_labels("stale") + except Exception as e: + print(f"Could not add stale label: {e}") + print( + f"Marked PR #{pr.number} as stale after" + f" {days_inactive} days," + f" unassigned {unassigned_count} issues" + ) + return True + except Exception as e: + print(f"Error marking PR #{pr.number}" f" as stale: {e}") + return False + + def send_stale_warning(self, pr, days_inactive): + try: + pr_author = pr.user.login if pr.user else None + if not pr_author: + return False + remaining = self.DAYS_BEFORE_UNASSIGN - days_inactive + warning_lines = [ + "", + f"Hi @{pr_author} 👋,", + "", + ( + "This is a friendly reminder that" + " this pull request has had" + f" **no activity for {days_inactive}" + " days** since changes were requested." + ), + "", + ( + "We'd love to see this contribution" + " merged! Please take a moment to:" + ), + "- Address the review feedback", + "- Push your changes", + ("- Let us know if you have any questions" " or need clarification"), + "", + ( + "If you're busy or need more time," + " no worries! Just leave a comment" + " to let us know you're still" + " working on it." + ), + "", + ( + f"**Note:** within" + f" **{remaining} more days**," + " the linked issue will be unassigned" + " to allow other contributors" + " to work on it." + ), + "", + "Thank you for your contribution! 🙏", + ] + pr.create_issue_comment("\n".join(warning_lines)) + print(f"Sent stale warning for PR #{pr.number}") + return True + except Exception as e: + print("Error sending warning" f" for PR #{pr.number}: {e}") + return False + + def process_stale_prs(self): + if not self.repo: + print("GitHub repository not initialized") + return False + try: + open_prs = self.repo.get_pulls(state="open") + processed_count = 0 + pr_count = 0 + for pr in open_prs: + pr_count += 1 + try: + all_reviews = list(pr.get_reviews()) + last_changes_requested = self.get_last_changes_requested( + pr, all_reviews + ) + if not last_changes_requested: + continue + issue_comments = list(pr.get_issue_comments()) + review_comments = list(pr.get_review_comments()) + days_inactive = self.get_days_since_activity( + pr, + last_changes_requested, + issue_comments, + all_reviews, + review_comments, + ) + print( + f"PR #{pr.number}: {days_inactive}" + " days since contributor activity" + ) + if days_inactive >= self.DAYS_BEFORE_CLOSE: + if self.close_stale_pr(pr, days_inactive): + processed_count += 1 + elif days_inactive >= self.DAYS_BEFORE_UNASSIGN: + if not self.has_bot_comment( + pr, + "stale", + after_date=last_changes_requested, + issue_comments=issue_comments, + ): + if self.mark_pr_stale(pr, days_inactive): + processed_count += 1 + elif days_inactive >= self.DAYS_BEFORE_STALE_WARNING: + if not self.has_bot_comment( + pr, + "stale_warning", + after_date=last_changes_requested, + issue_comments=issue_comments, + ): + if self.send_stale_warning(pr, days_inactive): + processed_count += 1 + except Exception as e: + print(f"Error processing" f" PR #{pr.number}: {e}") + continue + finally: + time.sleep(0.5) + print( + f"Checked {pr_count} open PRs," + f" processed {processed_count} stale PRs" + ) + return True + except Exception as e: + print(f"Error in process_stale_prs: {e}") + return False + + def run(self): + if not self.github or not self.repo: + print("GitHub client not properly initialized," " cannot proceed") + return False + print("Stale PR Management Bot starting...") + try: + return self.process_stale_prs() + except Exception as e: + print(f"Error in main execution: {e}") + return False + finally: + print("Stale PR Management Bot completed") + + +def main(): + bot = StalePRBot() + result = bot.run() + return 0 if result else 1 + + +if __name__ == "__main__": + main() diff --git a/.github/actions/bot-autoassign/tests/test_issue_assignment_bot.py b/.github/actions/bot-autoassign/tests/test_issue_assignment_bot.py new file mode 100644 index 00000000..7a21e4ae --- /dev/null +++ b/.github/actions/bot-autoassign/tests/test_issue_assignment_bot.py @@ -0,0 +1,440 @@ +import os +import sys +from unittest.mock import Mock, patch + +# Add the parent directory to path for importing bot modules +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import pytest # noqa: E402 + +try: + from issue_assignment_bot import IssueAssignmentBot # noqa: E402 +except ImportError: + IssueAssignmentBot = None + +pytestmark = pytest.mark.skipif( + IssueAssignmentBot is None, + reason="Issue assignment bot script not available", +) + + +@pytest.fixture(autouse=True) +def bot_env(monkeypatch): + """Set up environment and mock GitHub client for all tests.""" + monkeypatch.setenv("GITHUB_TOKEN", "test_token") + monkeypatch.setenv("REPOSITORY", "openwisp/openwisp-utils") + monkeypatch.setenv("GITHUB_EVENT_NAME", "issue_comment") + with patch("base.Github") as mock_github_cls: + mock_repo = Mock() + mock_github_cls.return_value.get_repo.return_value = mock_repo + yield { + "github_cls": mock_github_cls, + "repo": mock_repo, + } + + +class TestInit: + def test_init_success(self, bot_env): + bot = IssueAssignmentBot() + assert bot.github_token == "test_token" + assert bot.repository_name == "openwisp/openwisp-utils" + assert bot.event_name == "issue_comment" + bot_env["github_cls"].assert_called_once_with("test_token") + + def test_init_missing_env_vars(self): + with patch.dict(os.environ, {}, clear=True): + bot = IssueAssignmentBot() + assert bot.github is None + assert bot.repo is None + + +class TestAssignmentRequest: + @pytest.mark.parametrize( + "comment", + [ + "assign this issue to me", + "Assign me please", + "Can I work on this?", + "I would like to work on this issue", + "I want to work on this", + "Please assign this to me", + "Can you assign this to me?", + ], + ) + def test_positive_cases(self, comment, bot_env): + bot = IssueAssignmentBot() + assert bot.is_assignment_request(comment) + + @pytest.mark.parametrize( + "comment", + [ + "This is a great idea!", + "How do I solve this?", + "The assignment looks wrong", + "", + None, + ], + ) + def test_negative_cases(self, comment, bot_env): + bot = IssueAssignmentBot() + assert not bot.is_assignment_request(comment) + + +class TestExtractLinkedIssues: + @pytest.mark.parametrize( + "pr_body,expected", + [ + ("Fixes #123", [123]), + ("Closes #456 and resolves #789", [456, 789]), + ("fix #100, close #200, resolve #300", [100, 200, 300]), + ("This PR fixes #123 and closes #123", [123]), # dedup + ("Fixes: #42", [42]), # colon syntax + ("Related to #99", [99]), # relates-to + ("Fixes owner/repo#55", []), # cross-repo refs are ignored + ("Fixed #999", [999]), + ("No issue references here", []), + ("", []), + (None, []), + ], + ) + def test_extract_linked_issues(self, pr_body, expected, bot_env): + from utils import extract_linked_issues + + result = extract_linked_issues(pr_body) + assert sorted(result) == sorted(expected) + + +class TestRespondToAssignment: + def test_success_no_type_detected(self, bot_env): + bot = IssueAssignmentBot() + mock_issue = Mock() + mock_issue.labels = [] + mock_issue.title = "Test issue title" + mock_issue.body = "Test issue body" + bot_env["repo"].get_issue.return_value = mock_issue + assert bot.respond_to_assignment_request(123, "testuser") + bot_env["repo"].get_issue.assert_called_once_with(123) + mock_issue.create_comment.assert_called_once() + comment_text = mock_issue.create_comment.call_args[0][0] + assert "@testuser" in comment_text + assert "contributing guidelines" in comment_text + # When type is None, generic instructions listing all keywords + assert f"`Closes #{123}`" in comment_text + assert f"`Fixes #{123}`" in comment_text + + def test_success_bug_detected(self, bot_env): + bot = IssueAssignmentBot() + mock_label = Mock() + mock_label.name = "bug" + mock_issue = Mock() + mock_issue.labels = [mock_label] + mock_issue.title = "Something is broken" + mock_issue.body = "There is a regression" + bot_env["repo"].get_issue.return_value = mock_issue + assert bot.respond_to_assignment_request(42, "dev") + comment_text = mock_issue.create_comment.call_args[0][0] + assert "`Fixes #42`" in comment_text + + def test_success_feature_detected(self, bot_env): + bot = IssueAssignmentBot() + mock_label = Mock() + mock_label.name = "enhancement" + mock_issue = Mock() + mock_issue.labels = [mock_label] + mock_issue.title = "Add new feature" + mock_issue.body = "Please add this" + bot_env["repo"].get_issue.return_value = mock_issue + assert bot.respond_to_assignment_request(99, "dev") + comment_text = mock_issue.create_comment.call_args[0][0] + assert "`Closes #99`" in comment_text + + def test_github_error(self, bot_env): + bot = IssueAssignmentBot() + bot_env["repo"].get_issue.side_effect = Exception("API Error") + assert not bot.respond_to_assignment_request(123, "testuser") + + +class TestAutoAssignIssuesFromPR: + def test_success(self, bot_env): + bot = IssueAssignmentBot() + mock_issue = Mock() + mock_issue.labels = [] + mock_issue.title = "Test issue" + mock_issue.body = "Test body" + mock_issue.pull_request = None + mock_issue.assignees = [] + mock_issue.repository.full_name = "openwisp/openwisp-utils" + bot_env["repo"].get_issue.return_value = mock_issue + assigned = bot.auto_assign_issues_from_pr( + 100, "testuser", "This PR fixes #123 and closes #456" + ) + assert len(assigned) == 2 + assert 123 in assigned + assert 456 in assigned + assert mock_issue.add_to_assignees.call_count == 2 + mock_issue.add_to_assignees.assert_any_call("testuser") + + def test_skip_already_assigned(self, bot_env): + bot = IssueAssignmentBot() + mock_assignee = Mock() + mock_assignee.login = "otheruser" + mock_issue = Mock() + mock_issue.pull_request = None + mock_issue.assignees = [mock_assignee] + mock_issue.repository.full_name = "openwisp/openwisp-utils" + bot_env["repo"].get_issue.return_value = mock_issue + assigned = bot.auto_assign_issues_from_pr(100, "testuser", "Fixes #123") + assert len(assigned) == 0 + mock_issue.add_to_assignees.assert_not_called() + + def test_skip_pr_references(self, bot_env): + bot = IssueAssignmentBot() + mock_issue = Mock() + mock_issue.pull_request = {"url": "https://api.github.com/repos/test/pulls/123"} + mock_issue.repository.full_name = "openwisp/openwisp-utils" + bot_env["repo"].get_issue.return_value = mock_issue + assigned = bot.auto_assign_issues_from_pr(100, "testuser", "Fixes #123") + assert len(assigned) == 0 + mock_issue.add_to_assignees.assert_not_called() + + def test_rate_limiting(self, bot_env): + bot = IssueAssignmentBot() + issue_refs = " ".join([f"fixes #{i}" for i in range(1, 16)]) + mock_issue = Mock() + mock_issue.pull_request = None + mock_issue.assignees = [] + mock_issue.repository.full_name = "openwisp/openwisp-utils" + bot_env["repo"].get_issue.return_value = mock_issue + assigned = bot.auto_assign_issues_from_pr( + 100, "testuser", issue_refs, max_issues=10 + ) + assert len(assigned) == 10 + + def test_no_linked_issues(self, bot_env): + bot = IssueAssignmentBot() + assigned = bot.auto_assign_issues_from_pr(100, "testuser", "No issues here") + assert assigned == [] + + def test_empty_body(self, bot_env): + bot = IssueAssignmentBot() + assigned = bot.auto_assign_issues_from_pr(100, "testuser", "") + assert assigned == [] + + def test_none_body(self, bot_env): + bot = IssueAssignmentBot() + assigned = bot.auto_assign_issues_from_pr(100, "testuser", None) + assert assigned == [] + + +class TestUnassignIssuesFromPR: + def test_unassign_success(self, bot_env): + bot = IssueAssignmentBot() + mock_assignee = Mock() + mock_assignee.login = "testuser" + mock_issue = Mock() + mock_issue.pull_request = None + mock_issue.assignees = [mock_assignee] + mock_issue.repository.full_name = "openwisp/openwisp-utils" + bot_env["repo"].get_issue.return_value = mock_issue + unassigned = bot.unassign_issues_from_pr("Fixes #123", "testuser") + assert len(unassigned) == 1 + assert 123 in unassigned + mock_issue.remove_from_assignees.assert_called_once_with("testuser") + + def test_skip_cross_repo_issues(self, bot_env): + bot = IssueAssignmentBot() + mock_assignee = Mock() + mock_assignee.login = "testuser" + mock_issue = Mock() + mock_issue.pull_request = None + mock_issue.assignees = [mock_assignee] + mock_issue.repository.full_name = "other-org/other-repo" + bot_env["repo"].get_issue.return_value = mock_issue + unassigned = bot.unassign_issues_from_pr("Fixes #123", "testuser") + assert len(unassigned) == 0 + mock_issue.remove_from_assignees.assert_not_called() + + +class TestHandleIssueComment: + def test_assignment_request(self, bot_env): + bot = IssueAssignmentBot() + bot.load_event_payload( + { + "issue": {"number": 123, "pull_request": None}, + "comment": { + "body": "assign me please", + "user": {"login": "testuser"}, + }, + } + ) + mock_issue = Mock() + mock_issue.labels = [] + mock_issue.title = "Test issue" + mock_issue.body = "Test body" + bot_env["repo"].get_issue.return_value = mock_issue + assert bot.handle_issue_comment() + mock_issue.create_comment.assert_called_once() + + def test_skip_pr_comment(self, bot_env): + bot = IssueAssignmentBot() + bot.load_event_payload( + { + "issue": { + "number": 123, + "pull_request": { + "url": ("https://api.github.com" "/repos/test/pulls/123") + }, + }, + "comment": { + "body": "assign me please", + "user": {"login": "testuser"}, + }, + } + ) + assert not bot.handle_issue_comment() + + def test_non_assignment_comment(self, bot_env): + bot = IssueAssignmentBot() + bot.load_event_payload( + { + "issue": {"number": 123, "pull_request": None}, + "comment": { + "body": "looks good!", + "user": {"login": "testuser"}, + }, + } + ) + assert not bot.handle_issue_comment() + + def test_no_payload(self, bot_env): + bot = IssueAssignmentBot() + assert not bot.handle_issue_comment() + + +class TestHandlePullRequest: + def test_opened(self, bot_env): + bot = IssueAssignmentBot() + bot.load_event_payload( + { + "action": "opened", + "pull_request": { + "number": 100, + "user": {"login": "testuser"}, + "body": "Fixes #123", + }, + } + ) + mock_issue = Mock() + mock_issue.pull_request = None + mock_issue.assignees = [] + mock_issue.repository.full_name = "openwisp/openwisp-utils" + bot_env["repo"].get_issue.return_value = mock_issue + assert bot.handle_pull_request() + mock_issue.add_to_assignees.assert_called_once_with("testuser") + + def test_reopened(self, bot_env): + bot = IssueAssignmentBot() + bot.load_event_payload( + { + "action": "reopened", + "pull_request": { + "number": 100, + "user": {"login": "testuser"}, + "body": "Fixes #123", + }, + } + ) + mock_issue = Mock() + mock_issue.pull_request = None + mock_issue.assignees = [] + mock_issue.repository.full_name = "openwisp/openwisp-utils" + bot_env["repo"].get_issue.return_value = mock_issue + assert bot.handle_pull_request() + + def test_closed(self, bot_env): + bot = IssueAssignmentBot() + bot.load_event_payload( + { + "action": "closed", + "pull_request": { + "number": 100, + "user": {"login": "testuser"}, + "body": "Fixes #123", + }, + } + ) + mock_assignee = Mock() + mock_assignee.login = "testuser" + mock_issue = Mock() + mock_issue.pull_request = None + mock_issue.assignees = [mock_assignee] + mock_issue.repository.full_name = "openwisp/openwisp-utils" + bot_env["repo"].get_issue.return_value = mock_issue + assert bot.handle_pull_request() + mock_issue.remove_from_assignees.assert_called_once_with("testuser") + + def test_unsupported_action(self, bot_env): + bot = IssueAssignmentBot() + bot.load_event_payload( + { + "action": "synchronize", + "pull_request": { + "number": 100, + "user": {"login": "testuser"}, + "body": "Fixes #123", + }, + } + ) + assert not bot.handle_pull_request() + + +class TestRun: + def test_issue_comment_event(self, bot_env): + bot = IssueAssignmentBot() + bot.event_name = "issue_comment" + bot.load_event_payload( + { + "issue": {"number": 123, "pull_request": None}, + "comment": { + "body": "assign me", + "user": {"login": "testuser"}, + }, + } + ) + mock_issue = Mock() + mock_issue.labels = [] + mock_issue.title = "Test issue" + mock_issue.body = "Test body" + bot_env["repo"].get_issue.return_value = mock_issue + assert bot.run() + + def test_pull_request_event(self, bot_env): + bot = IssueAssignmentBot() + bot.event_name = "pull_request_target" + bot.load_event_payload( + { + "action": "opened", + "pull_request": { + "number": 100, + "user": {"login": "testuser"}, + "body": "Fixes #123", + }, + } + ) + mock_issue = Mock() + mock_issue.pull_request = None + mock_issue.assignees = [] + mock_issue.repository.full_name = "openwisp/openwisp-utils" + bot_env["repo"].get_issue.return_value = mock_issue + assert bot.run() + + def test_unsupported_event(self, bot_env): + bot = IssueAssignmentBot() + bot.event_name = "push" + assert not bot.run() + + def test_no_github_client(self, bot_env): + bot = IssueAssignmentBot() + bot.github = None + bot.repo = None + assert not bot.run() diff --git a/.github/actions/bot-autoassign/tests/test_pr_reopen_bot.py b/.github/actions/bot-autoassign/tests/test_pr_reopen_bot.py new file mode 100644 index 00000000..04572cea --- /dev/null +++ b/.github/actions/bot-autoassign/tests/test_pr_reopen_bot.py @@ -0,0 +1,202 @@ +import os +import sys +from unittest.mock import Mock, patch + +# Add the parent directory to path for importing bot modules +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import pytest # noqa: E402 + +try: + from pr_reopen_bot import PRActivityBot, PRReopenBot # noqa: E402 +except ImportError: + PRReopenBot = None + PRActivityBot = None + +pytestmark = pytest.mark.skipif( + PRReopenBot is None, + reason="PR reopen bot script not available", +) + + +@pytest.fixture(autouse=True) +def bot_env(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "test_token") + monkeypatch.setenv("REPOSITORY", "openwisp/openwisp-utils") + monkeypatch.setenv("GITHUB_EVENT_NAME", "pull_request_target") + with patch("base.Github") as mock_github_cls: + mock_repo = Mock() + mock_github_cls.return_value.get_repo.return_value = mock_repo + yield { + "github_cls": mock_github_cls, + "repo": mock_repo, + } + + +class TestPRReopenBot: + def test_reassign_issues_to_author(self, bot_env): + bot = PRReopenBot() + mock_issue = Mock() + mock_issue.pull_request = None + mock_issue.assignees = [] + mock_issue.repository.full_name = "openwisp/openwisp-utils" + bot_env["repo"].get_issue.return_value = mock_issue + assigned = bot.reassign_issues_to_author(100, "testuser", "Fixes #123") + assert len(assigned) == 1 + assert 123 in assigned + mock_issue.add_to_assignees.assert_called_once_with("testuser") + mock_issue.create_comment.assert_called_once() + comment = mock_issue.create_comment.call_args[0][0] + assert "@testuser" in comment + assert "PR #100" in comment + + def test_reassign_skip_already_assigned_by_others(self, bot_env): + bot = PRReopenBot() + mock_assignee = Mock() + mock_assignee.login = "otheruser" + mock_issue = Mock() + mock_issue.pull_request = None + mock_issue.assignees = [mock_assignee] + mock_issue.repository.full_name = "openwisp/openwisp-utils" + bot_env["repo"].get_issue.return_value = mock_issue + assigned = bot.reassign_issues_to_author(100, "testuser", "Fixes #123") + assert len(assigned) == 0 + + def test_remove_stale_label(self, bot_env): + bot = PRReopenBot() + mock_pr = Mock() + mock_label = Mock() + mock_label.name = "stale" + mock_pr.get_labels.return_value = [mock_label] + bot_env["repo"].get_pull.return_value = mock_pr + assert bot.remove_stale_label(100) + mock_pr.remove_from_labels.assert_called_once_with("stale") + + def test_remove_stale_label_not_present(self, bot_env): + bot = PRReopenBot() + mock_pr = Mock() + mock_pr.get_labels.return_value = [] + bot_env["repo"].get_pull.return_value = mock_pr + assert not bot.remove_stale_label(100) + + def test_handle_pr_reopen(self, bot_env): + bot = PRReopenBot() + bot.load_event_payload( + { + "pull_request": { + "number": 100, + "user": {"login": "testuser"}, + "body": "Fixes #123", + } + } + ) + mock_issue = Mock() + mock_issue.pull_request = None + mock_issue.assignees = [] + mock_issue.repository.full_name = "openwisp/openwisp-utils" + bot_env["repo"].get_issue.return_value = mock_issue + mock_pr = Mock() + mock_pr.get_labels.return_value = [] + bot_env["repo"].get_pull.return_value = mock_pr + assert bot.handle_pr_reopen() + mock_issue.add_to_assignees.assert_called_once_with("testuser") + + def test_handle_pr_reopen_no_payload(self, bot_env): + bot = PRReopenBot() + assert not bot.handle_pr_reopen() + + def test_run_unsupported_event(self, bot_env): + bot = PRReopenBot() + bot.event_name = "push" + assert not bot.run() + + +class TestPRActivityBot: + def test_handle_contributor_activity(self, bot_env): + bot = PRActivityBot() + bot.load_event_payload( + { + "issue": { + "number": 100, + "pull_request": { + "url": ("https://api.github.com" "/repos/owner/repo/pulls/100") + }, + }, + "comment": {"user": {"login": "testuser"}}, + } + ) + mock_pr = Mock() + mock_pr.user.login = "testuser" + mock_pr.body = "Fixes #123" + mock_label = Mock() + mock_label.name = "stale" + mock_pr.get_labels.return_value = [mock_label] + bot_env["repo"].get_pull.return_value = mock_pr + mock_issue = Mock() + mock_issue.pull_request = None + mock_issue.assignees = [] + mock_issue.repository.full_name = "openwisp/openwisp-utils" + bot_env["repo"].get_issue.return_value = mock_issue + assert bot.handle_contributor_activity() + mock_pr.remove_from_labels.assert_called_once_with("stale") + mock_issue.add_to_assignees.assert_called_once_with("testuser") + mock_pr.create_issue_comment.assert_called_once() + comment = mock_pr.create_issue_comment.call_args[0][0] + assert "@testuser" in comment + + def test_handle_contributor_activity_not_author(self, bot_env): + bot = PRActivityBot() + bot.load_event_payload( + { + "issue": { + "number": 100, + "pull_request": { + "url": ("https://api.github.com" "/repos/owner/repo/pulls/100") + }, + }, + "comment": {"user": {"login": "otheruser"}}, + } + ) + mock_pr = Mock() + mock_pr.user.login = "testuser" + bot_env["repo"].get_pull.return_value = mock_pr + assert not bot.handle_contributor_activity() + + def test_handle_contributor_activity_pr_not_stale(self, bot_env): + bot = PRActivityBot() + bot.load_event_payload( + { + "issue": { + "number": 100, + "pull_request": {"url": "https://api.github.com/..."}, + }, + "comment": {"user": {"login": "testuser"}}, + } + ) + mock_pr = Mock() + mock_pr.user.login = "testuser" + mock_pr.get_labels.return_value = [] + bot_env["repo"].get_pull.return_value = mock_pr + assert not bot.handle_contributor_activity() + + def test_handle_contributor_activity_not_pr(self, bot_env): + bot = PRActivityBot() + bot.load_event_payload( + { + "issue": { + "number": 100, + "pull_request": None, + }, + "comment": {"user": {"login": "testuser"}}, + } + ) + assert not bot.handle_contributor_activity() + + def test_handle_contributor_activity_no_payload(self, bot_env): + bot = PRActivityBot() + assert not bot.handle_contributor_activity() + + def test_run_unsupported_event(self, bot_env): + bot = PRActivityBot() + bot.event_name = "push" + assert not bot.run() diff --git a/.github/actions/bot-autoassign/tests/test_stale_pr_bot.py b/.github/actions/bot-autoassign/tests/test_stale_pr_bot.py new file mode 100644 index 00000000..76fa6f49 --- /dev/null +++ b/.github/actions/bot-autoassign/tests/test_stale_pr_bot.py @@ -0,0 +1,244 @@ +import os +import sys +from datetime import datetime, timezone +from unittest.mock import Mock, patch + +# Add the parent directory to path for importing bot modules +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +import pytest # noqa: E402 + +try: + from stale_pr_bot import StalePRBot # noqa: E402 +except ImportError: + StalePRBot = None + +pytestmark = pytest.mark.skipif( + StalePRBot is None, + reason="Stale PR bot script not available", +) + + +@pytest.fixture(autouse=True) +def bot_env(monkeypatch): + monkeypatch.setenv("GITHUB_TOKEN", "test_token") + monkeypatch.setenv("REPOSITORY", "openwisp/openwisp-utils") + with patch("base.Github") as mock_github_cls: + mock_repo = Mock() + mock_github_cls.return_value.get_repo.return_value = mock_repo + yield { + "github_cls": mock_github_cls, + "repo": mock_repo, + } + + +class TestInit: + def test_success(self, bot_env): + bot = StalePRBot() + assert bot.github_token == "test_token" + assert bot.repository_name == "openwisp/openwisp-utils" + bot_env["github_cls"].assert_called_once_with("test_token") + + def test_thresholds(self, bot_env): + bot = StalePRBot() + assert bot.DAYS_BEFORE_STALE_WARNING == 7 + assert bot.DAYS_BEFORE_UNASSIGN == 14 + assert bot.DAYS_BEFORE_CLOSE == 60 + + +class TestGetLastChangesRequested: + def test_returns_latest(self, bot_env): + bot = StalePRBot() + mock_pr = Mock() + reviews = [ + Mock( + state="APPROVED", + submitted_at=datetime(2024, 1, 1, tzinfo=timezone.utc), + ), + Mock( + state="CHANGES_REQUESTED", + submitted_at=datetime(2024, 1, 2, tzinfo=timezone.utc), + ), + Mock( + state="CHANGES_REQUESTED", + submitted_at=datetime(2024, 1, 3, tzinfo=timezone.utc), + ), + ] + mock_pr.get_reviews.return_value = reviews + assert bot.get_last_changes_requested(mock_pr) == datetime( + 2024, 1, 3, tzinfo=timezone.utc + ) + + def test_no_changes_requested(self, bot_env): + bot = StalePRBot() + mock_pr = Mock() + mock_pr.get_reviews.return_value = [ + Mock(state="APPROVED"), + ] + assert bot.get_last_changes_requested(mock_pr) is None + + +class TestGetDaysSinceActivity: + @patch("stale_pr_bot.datetime") + def test_with_author_commit(self, mock_datetime, bot_env): + mock_datetime.now.return_value = datetime(2024, 1, 10, tzinfo=timezone.utc) + # Keep timezone constructor working + mock_datetime.side_effect = lambda *a, **kw: datetime(*a, **kw) + bot = StalePRBot() + mock_pr = Mock() + mock_pr.user.login = "testuser" + mock_pr.get_issue_comments.return_value = [] + mock_pr.get_review_comments.return_value = [] + mock_pr.get_reviews.return_value = [] + mock_commit = Mock() + mock_commit.commit.author.date = datetime(2024, 1, 5, tzinfo=timezone.utc) + mock_commit.author.login = "testuser" + mock_pr.get_commits.return_value = [mock_commit] + last_cr = datetime(2024, 1, 1, tzinfo=timezone.utc) + result = bot.get_days_since_activity(mock_pr, last_cr) + assert result == 5 + + def test_no_last_changes(self, bot_env): + bot = StalePRBot() + mock_pr = Mock() + assert bot.get_days_since_activity(mock_pr, None) == 0 + + +class TestUnassignLinkedIssues: + def test_success(self, bot_env): + bot = StalePRBot() + mock_pr = Mock() + mock_pr.body = "Fixes #123" + mock_pr.user.login = "testuser" + mock_assignee = Mock() + mock_assignee.login = "testuser" + mock_issue = Mock() + mock_issue.pull_request = None + mock_issue.assignees = [mock_assignee] + mock_issue.repository.full_name = "openwisp/openwisp-utils" + bot_env["repo"].get_issue.return_value = mock_issue + assert bot.unassign_linked_issues(mock_pr) == 1 + mock_issue.remove_from_assignees.assert_called_once_with("testuser") + + def test_skip_cross_repo(self, bot_env): + bot = StalePRBot() + mock_pr = Mock() + mock_pr.body = "Fixes #123" + mock_pr.user.login = "testuser" + mock_assignee = Mock() + mock_assignee.login = "testuser" + mock_issue = Mock() + mock_issue.pull_request = None + mock_issue.assignees = [mock_assignee] + mock_issue.repository.full_name = "other-org/other-repo" + bot_env["repo"].get_issue.return_value = mock_issue + assert bot.unassign_linked_issues(mock_pr) == 0 + + +class TestHasBotComment: + def test_finds_marker(self, bot_env): + bot = StalePRBot() + mock_pr = Mock() + mock_comment = Mock() + mock_comment.user.type = "Bot" + mock_comment.body = " This is a stale warning" + mock_comment.created_at = datetime(2024, 1, 10, tzinfo=timezone.utc) + mock_pr.get_issue_comments.return_value = [mock_comment] + assert bot.has_bot_comment(mock_pr, "stale") + assert not bot.has_bot_comment(mock_pr, "closed") + + def test_ignores_old_marker_before_after_date(self, bot_env): + """Old markers from a previous cycle should be ignored.""" + bot = StalePRBot() + mock_pr = Mock() + mock_comment = Mock() + mock_comment.user.type = "Bot" + mock_comment.body = " old warning" + mock_comment.created_at = datetime(2024, 1, 5, tzinfo=timezone.utc) + mock_pr.get_issue_comments.return_value = [mock_comment] + # The marker is from Jan 5 but changes re-requested Jan 8 + after_date = datetime(2024, 1, 8, tzinfo=timezone.utc) + assert not bot.has_bot_comment(mock_pr, "stale_warning", after_date=after_date) + + def test_finds_recent_marker_after_date(self, bot_env): + bot = StalePRBot() + mock_pr = Mock() + mock_comment = Mock() + mock_comment.user.type = "Bot" + mock_comment.body = " new warning" + mock_comment.created_at = datetime(2024, 1, 15, tzinfo=timezone.utc) + mock_pr.get_issue_comments.return_value = [mock_comment] + after_date = datetime(2024, 1, 8, tzinfo=timezone.utc) + assert bot.has_bot_comment(mock_pr, "stale_warning", after_date=after_date) + + +class TestSendStaleWarning: + def test_success(self, bot_env): + bot = StalePRBot() + mock_pr = Mock() + mock_pr.user.login = "testuser" + assert bot.send_stale_warning(mock_pr, 7) + mock_pr.create_issue_comment.assert_called_once() + comment = mock_pr.create_issue_comment.call_args[0][0] + assert "@testuser" in comment + assert "7 days" in comment + assert "" in comment + + +class TestMarkPRStale: + def test_success(self, bot_env): + bot = StalePRBot() + mock_pr = Mock() + mock_pr.body = "Fixes #123" + mock_pr.user.login = "testuser" + mock_assignee = Mock() + mock_assignee.login = "testuser" + mock_issue = Mock() + mock_issue.pull_request = None + mock_issue.assignees = [mock_assignee] + mock_issue.repository.full_name = "openwisp/openwisp-utils" + bot_env["repo"].get_issue.return_value = mock_issue + assert bot.mark_pr_stale(mock_pr, 14) + mock_pr.create_issue_comment.assert_called_once() + comment = mock_pr.create_issue_comment.call_args[0][0] + assert "" in comment + mock_pr.add_to_labels.assert_called_once_with("stale") + mock_issue.remove_from_assignees.assert_called_once_with("testuser") + + +class TestCloseStalePR: + def test_success(self, bot_env): + bot = StalePRBot() + mock_pr = Mock() + mock_pr.body = "Fixes #123" + mock_pr.user.login = "testuser" + mock_pr.state = "open" + mock_assignee = Mock() + mock_assignee.login = "testuser" + mock_issue = Mock() + mock_issue.pull_request = None + mock_issue.assignees = [mock_assignee] + mock_issue.repository.full_name = "openwisp/openwisp-utils" + bot_env["repo"].get_issue.return_value = mock_issue + assert bot.close_stale_pr(mock_pr, 60) + mock_pr.create_issue_comment.assert_called_once() + comment = mock_pr.create_issue_comment.call_args[0][0] + assert "" in comment + mock_pr.edit.assert_called_once_with(state="closed") + mock_issue.remove_from_assignees.assert_called_once_with("testuser") + + def test_already_closed(self, bot_env): + bot = StalePRBot() + mock_pr = Mock() + mock_pr.state = "closed" + assert not bot.close_stale_pr(mock_pr, 60) + mock_pr.create_issue_comment.assert_not_called() + mock_pr.edit.assert_not_called() + + +class TestRun: + def test_no_github_client(self, bot_env): + bot = StalePRBot() + bot.github = None + bot.repo = None + assert not bot.run() diff --git a/.github/actions/bot-autoassign/utils.py b/.github/actions/bot-autoassign/utils.py new file mode 100644 index 00000000..81358097 --- /dev/null +++ b/.github/actions/bot-autoassign/utils.py @@ -0,0 +1,65 @@ +import re + +from github import GithubException + + +def extract_linked_issues(pr_body): + """Extract issue numbers from PR body. + + Returns a list of unique issue numbers referenced in the PR body using + keywords like 'fixes', 'closes', 'resolves', 'relates to', 'related + to'. Supports patterns with optional colons and owner/repo references. + """ + if not pr_body: + return [] + issue_pattern = ( + r"\b(?:fix(?:e[sd])?|close[sd]?|resolve[sd]?|relat(?:e[sd]?|ed)\s+to)" + r"\s*:?\s*(?![\w-]+/[\w-]+#)#(\d+)" + ) + matches = re.findall(issue_pattern, pr_body, re.IGNORECASE) + return list(dict.fromkeys(int(match) for match in matches)) + + +def get_valid_linked_issues(repo, repository_name, pr_body): + """Generator yielding valid linked issues (skipping cross-repo and PRs).""" + linked_issues = extract_linked_issues(pr_body) + if not linked_issues: + return + + for issue_number in linked_issues: + try: + issue = repo.get_issue(issue_number) + if ( + hasattr(issue, "repository") + and issue.repository.full_name != repository_name + ): + print(f"Issue #{issue_number} is from a different repository, skipping") + continue + if issue.pull_request: + print(f"#{issue_number} is a PR, skipping") + continue + yield issue_number, issue + except Exception as e: + if isinstance(e, GithubException) and e.status == 404: + print(f"Issue #{issue_number} not found") + else: + print(f"Error fetching issue #{issue_number}: {e}") + + +def unassign_linked_issues_helper(repo, repository_name, pr_body, pr_author): + """Shared helper to unassign linked issues from PR author.""" + unassigned_issues = [] + for issue_number, issue in get_valid_linked_issues(repo, repository_name, pr_body): + try: + current_assignees = [ + assignee.login + for assignee in issue.assignees + if hasattr(assignee, "login") + ] + if pr_author in current_assignees: + issue.remove_from_assignees(pr_author) + unassigned_issues.append(issue_number) + print(f"Unassigned {pr_author} from issue #{issue_number}") + except Exception as e: + print(f"Error unassigning issue #{issue_number}: {e}") + return unassigned_issues diff --git a/.github/workflows/bot-autoassign-issue.yml b/.github/workflows/bot-autoassign-issue.yml new file mode 100644 index 00000000..006101d2 --- /dev/null +++ b/.github/workflows/bot-autoassign-issue.yml @@ -0,0 +1,45 @@ +name: Issue Assignment Bot + +on: + issue_comment: + types: [created] + +permissions: + contents: read + issues: write + +concurrency: + group: bot-autoassign-issue-${{ github.repository }}-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + respond-to-assign-request: + runs-on: ubuntu-latest + if: github.event.issue.pull_request == null + steps: + - name: Generate GitHub App token + id: generate-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.OPENWISP_BOT_APP_ID }} + private-key: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }} + + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Install dependencies + run: pip install -e .[github_actions] + + - name: Run issue assignment bot + env: + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} + REPOSITORY: ${{ github.repository }} + GITHUB_EVENT_NAME: ${{ github.event_name }} + run: > + python .github/actions/bot-autoassign/__main__.py + issue_assignment "$GITHUB_EVENT_PATH" diff --git a/.github/workflows/bot-autoassign-pr-issue-link.yml b/.github/workflows/bot-autoassign-pr-issue-link.yml new file mode 100644 index 00000000..6f8ca8ae --- /dev/null +++ b/.github/workflows/bot-autoassign-pr-issue-link.yml @@ -0,0 +1,45 @@ +name: PR Issue Auto-Assignment + +on: + pull_request_target: + types: [opened, reopened, closed] + +permissions: + contents: read + issues: write + pull-requests: read + +concurrency: + group: bot-autoassign-pr-link-${{ github.repository }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + auto-assign-issue: + runs-on: ubuntu-latest + steps: + - name: Generate GitHub App token + id: generate-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.OPENWISP_BOT_APP_ID }} + private-key: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }} + + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Install dependencies + run: pip install -e .[github_actions] + + - name: Run issue assignment bot + env: + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} + REPOSITORY: ${{ github.repository }} + GITHUB_EVENT_NAME: ${{ github.event_name }} + run: > + python .github/actions/bot-autoassign/__main__.py + issue_assignment "$GITHUB_EVENT_PATH" diff --git a/.github/workflows/bot-autoassign-pr-reopen.yml b/.github/workflows/bot-autoassign-pr-reopen.yml new file mode 100644 index 00000000..b169b1df --- /dev/null +++ b/.github/workflows/bot-autoassign-pr-reopen.yml @@ -0,0 +1,80 @@ +name: PR Reopen Reassignment + +on: + pull_request_target: + types: [reopened] + issue_comment: + types: [created] + +permissions: + contents: read + issues: write + pull-requests: write + +concurrency: + group: bot-autoassign-pr-reopen-${{ github.repository }}-${{ github.event.pull_request.number || github.event.issue.number }} + cancel-in-progress: true + +jobs: + reassign-on-reopen: + runs-on: ubuntu-latest + if: github.event_name == 'pull_request_target' && github.event.action == 'reopened' + steps: + - name: Generate GitHub App token + id: generate-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.OPENWISP_BOT_APP_ID }} + private-key: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }} + + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Install dependencies + run: pip install -e .[github_actions] + + - name: Reassign issues on PR reopen + env: + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} + REPOSITORY: ${{ github.repository }} + GITHUB_EVENT_NAME: ${{ github.event_name }} + run: > + python .github/actions/bot-autoassign/__main__.py + pr_reopen "$GITHUB_EVENT_PATH" + + handle-pr-activity: + runs-on: ubuntu-latest + if: github.event_name == 'issue_comment' && github.event.issue.pull_request + steps: + - name: Generate GitHub App token + id: generate-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.OPENWISP_BOT_APP_ID }} + private-key: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }} + + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Install dependencies + run: pip install -e .[github_actions] + + - name: Handle PR activity + env: + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} + REPOSITORY: ${{ github.repository }} + GITHUB_EVENT_NAME: ${{ github.event_name }} + # The pr_reopen bot type handles both PR reopen and issue comment (PR activity) events + run: > + python .github/actions/bot-autoassign/__main__.py + pr_reopen "$GITHUB_EVENT_PATH" diff --git a/.github/workflows/bot-autoassign-stale-pr.yml b/.github/workflows/bot-autoassign-stale-pr.yml new file mode 100644 index 00000000..5e9a77d7 --- /dev/null +++ b/.github/workflows/bot-autoassign-stale-pr.yml @@ -0,0 +1,46 @@ +name: Stale PR Management + +on: + schedule: + - cron: "0 0 * * *" + workflow_dispatch: + +permissions: + contents: read + issues: write + pull-requests: write + +concurrency: + group: bot-autoassign-stale-pr-${{ github.repository }} + cancel-in-progress: false + +jobs: + manage-stale-prs-python: + runs-on: ubuntu-latest + steps: + - name: Generate GitHub App token + id: generate-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.OPENWISP_BOT_APP_ID }} + private-key: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }} + + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Install dependencies + run: pip install -e .[github_actions] + + - name: Run stale PR bot + env: + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} + REPOSITORY: ${{ github.repository }} + GITHUB_EVENT_NAME: ${{ github.event_name }} + run: > + python .github/actions/bot-autoassign/__main__.py + stale_pr diff --git a/docs/developer/reusable-github-utils.rst b/docs/developer/reusable-github-utils.rst index a77a97f9..6f6baecd 100644 --- a/docs/developer/reusable-github-utils.rst +++ b/docs/developer/reusable-github-utils.rst @@ -36,7 +36,7 @@ You can use this action in your workflow as follows: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Test uses: openwisp/openwisp-utils/.github/actions/retry-command@master @@ -56,6 +56,101 @@ times with a 30 second delay between attempts. attempts, the action will exit with a non-zero status, causing the workflow to fail. +Auto-Assignment Bot +~~~~~~~~~~~~~~~~~~~ + +A collection of Python scripts that automate issue and PR management for +OpenWISP repositories. The bot provides the following features: + +- **Issue auto-assignment**: When a contributor opens a PR referencing an + issue (e.g., ``Fixes #123``), the issue is automatically assigned to the + PR author. +- **Assignment request responses**: When someone comments asking to be + assigned, the bot responds with contributing guidelines explaining that + no assignment is needed — just open a PR. +- **Stale PR management**: Warns PR authors after 7 days of inactivity, + marks stale and unassigns after 14 days, and closes after 60 days. +- **PR reopen reassignment**: When a stale PR is reopened, linked issues + are reassigned back to the author. + +**Secrets** + +These secrets are used by the workflow to generate a ``GITHUB_TOKEN`` via +the ``actions/create-github-app-token`` action. The bot itself consumes +the following environment variables at runtime: ``GITHUB_TOKEN``, +``REPOSITORY``, and ``GITHUB_EVENT_NAME``. + +- ``OPENWISP_BOT_APP_ID`` (required): OpenWISP Bot GitHub App ID. +- ``OPENWISP_BOT_PRIVATE_KEY`` (required): OpenWISP Bot GitHub App private + key. + +**Setup for Other Repositories** + +To enable the auto-assignment bot in another OpenWISP repository, add +workflow files under ``.github/workflows/``. Each workflow needs to: + +1. Generate a GitHub App token using the OpenWISP Bot credentials. +2. Checkout ``openwisp-utils`` to get the bot scripts. +3. Install the bot dependencies via ``pip install -e .[github_actions]``. +4. Run the appropriate bot command. + +Below is a complete example for the issue assignment bot. You can find all +four workflow files in the ``openwisp-utils`` repository under +``.github/workflows/`` (``bot-autoassign-issue.yml``, +``bot-autoassign-pr-issue-link.yml``, ``bot-autoassign-pr-reopen.yml``, +``bot-autoassign-stale-pr.yml``). + +.. code-block:: yaml + + name: Issue Assignment Bot + + on: + issue_comment: + types: [created] + + permissions: + contents: read + issues: write + + concurrency: + group: bot-autoassign-issue-${{ github.repository }}-${{ github.event.issue.number }} + cancel-in-progress: true + + jobs: + respond-to-assign-request: + runs-on: ubuntu-latest + if: github.event.issue.pull_request == null + steps: + - name: Generate GitHub App token + id: generate-token + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ secrets.OPENWISP_BOT_APP_ID }} + private-key: ${{ secrets.OPENWISP_BOT_PRIVATE_KEY }} + + - name: Checkout openwisp-utils + uses: actions/checkout@v6 + with: + repository: openwisp/openwisp-utils + path: openwisp-utils + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.13" + + - name: Install dependencies + run: pip install -e openwisp-utils/.[github_actions] + + - name: Run issue assignment bot + env: + GITHUB_TOKEN: ${{ steps.generate-token.outputs.token }} + REPOSITORY: ${{ github.repository }} + GITHUB_EVENT_NAME: ${{ github.event_name }} + run: > + python openwisp-utils/.github/actions/bot-autoassign/__main__.py + issue_assignment "$GITHUB_EVENT_PATH" + GitHub Workflows ---------------- diff --git a/runtests.py b/runtests.py index 767b2ec8..f19e46cc 100755 --- a/runtests.py +++ b/runtests.py @@ -28,6 +28,7 @@ [ "openwisp_utils/releaser/tests", ".github/actions/bot-ci-failure", + ".github/actions/bot-autoassign/tests", ] ) sys.exit(pytest_exit_code) diff --git a/setup.py b/setup.py index fbb72bb4..6f9f24b3 100644 --- a/setup.py +++ b/setup.py @@ -78,6 +78,7 @@ ], "github_actions": [ "google-genai>=1.62.0,<2.0.0", + "PyGithub>=2.0.0,<3.0.0", ], }, classifiers=[