Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 151 additions & 0 deletions .github/actions/bot-autoassign/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,154 @@ def __init__(self):

def load_event_payload(self, event_payload):
self.event_payload = event_payload

def get_issue_projects(self, owner, repo_name, issue_number):
query = """
query($owner: String!, $repo: String!, $issueNumber: Int!) {
repository(owner: $owner, name: $repo) {
issue(number: $issueNumber) {
projectItems(first: 10) {
nodes {
project {
title
}
}
}
}
}
}
"""
variables = {
"owner": owner,
"repo": repo_name,
"issueNumber": issue_number
}
result = self.github.raw_graphql(query, variables)
if "errors" in result:
raise Exception(f"GraphQL errors: {result['errors']}")

repo_data = result.get("data", {}).get("repository", {}) or {}
issue_data = repo_data.get("issue", {}) or {}
project_items = issue_data.get("projectItems", {}) or {}
nodes = project_items.get("nodes", []) or []

return [
node["project"]["title"]
for node in nodes
if node and node.get("project") and node["project"].get("title")
]

def validate_pr_issues(self, pr):
"""Validate if a pull request is from an exempt user or references a validated issue."""
if not self.github or not self.repository_name:
print("GitHub client or repository name not initialized")
return False

pr_author = pr.user.login if pr.user and isinstance(getattr(pr.user, "login", None), str) else ""

# Check if the author is in the exclude list
exclude_authors_env = os.environ.get("EXCLUDE_PR_AUTHORS", "dependabot[bot]")
excluded_authors = [auth.strip() for auth in exclude_authors_env.split(",") if auth.strip()]
if pr_author in excluded_authors:
print(f"Author {pr_author} is in the exclude list. Proceeding.")
return True

# Check if the author is a maintainer/collaborator
author_association = getattr(pr, "author_association", None)
if isinstance(author_association, str) and author_association in ["OWNER", "MEMBER", "COLLABORATOR"]:
print(f"Author {pr_author} is exempt due to association: {author_association}. Proceeding.")
return True

# For external contributors, extract linked issues
from utils import extract_all_linked_issues
pr_body = pr.body if isinstance(pr.body, str) else ""
linked_issues = extract_all_linked_issues(pr_body, self.repository_name)
if not linked_issues:
print("No linked issues found in PR body for external contributor.")
return False

current_org = self.repository_name.split("/")[0].lower()
required_projects = [
"OpenWISP Contributor's Board",
"OpenWISP Priorities for next releases"
]

for owner, repo_name, issue_number in linked_issues:
# 1. Check if the issue belongs to the same organization
if owner.lower() != current_org:
print(f"Issue {owner}/{repo_name}#{issue_number} does not belong to organization {current_org}, skipping validation.")
continue

try:
from github import GithubException
target_repo = self.github.get_repo(f"{owner}/{repo_name}")
issue = target_repo.get_issue(issue_number)
except Exception as e:
if isinstance(e, GithubException) and e.status == 404:
print(f"Issue {owner}/{repo_name}#{issue_number} not found, skipping validation.")
continue
else:
print(f"Error fetching issue {owner}/{repo_name}#{issue_number}: {e}")
raise

# 2. Check if the issue is a PR
if issue.pull_request:
print(f"Reference {owner}/{repo_name}#{issue_number} is a pull request, skipping validation.")
continue

# 3. Check if the issue is open
if issue.state != "open":
print(f"Issue {owner}/{repo_name}#{issue_number} is not open, skipping validation.")
continue

# 4. Check if the issue has at least one label, and none are invalid/wontfix
issue_labels = [label.name.lower() for label in issue.labels]
valid_labels = [l for l in issue_labels if l not in ["invalid", "wontfix"]]
if not valid_labels:
print(f"Issue {owner}/{repo_name}#{issue_number} has no valid labels, skipping validation.")
continue
if any(l in ["invalid", "wontfix"] for l in issue_labels):
print(f"Issue {owner}/{repo_name}#{issue_number} contains invalid/wontfix label, skipping validation.")
continue

# 5. Check if the issue is assigned to one of the required projects
try:
projects = self.get_issue_projects(owner, repo_name, issue_number)
except Exception as e:
print(f"Error fetching projects for issue {owner}/{repo_name}#{issue_number}: {e}")
raise

has_valid_project = any(project in required_projects for project in projects)
if has_valid_project:
print(f"Issue {owner}/{repo_name}#{issue_number} is validated. PR is valid.")
return True
else:
print(f"Issue {owner}/{repo_name}#{issue_number} is not assigned to any required project, skipping validation.")

return False

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"<!-- bot:{comment_type} -->"
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


45 changes: 43 additions & 2 deletions .github/actions/bot-autoassign/issue_assignment_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -305,14 +305,55 @@ def handle_pull_request(self):
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":
if pr.get("merged", False):
print(f"PR #{pr_number} was merged, keeping issue assignments")
else:
self.unassign_issues_from_pr(pr_body, pr_author)
return True

if action in ["opened", "reopened", "edited", "ready_for_review"]:
pr_obj = self.repo.get_pull(pr_number)
is_valid = self.validate_pr_issues(pr_obj)
labels = []
if hasattr(pr_obj, "labels") and not hasattr(pr_obj.labels, "assert_called"):
try:
labels = [label.name for label in pr_obj.labels]
except TypeError:
pass

if is_valid:
if "invalid" in labels:
pr_obj.remove_from_labels("invalid")
print(f"Removed 'invalid' label from PR #{pr_number}")
else:
if "invalid" not in labels:
pr_obj.add_to_labels("invalid")
print(f"Added 'invalid' label to PR #{pr_number}")

if not self.has_bot_comment(pr_obj, "invalid_unvalidated_issue"):
comment_body = (
"<!-- bot:invalid_unvalidated_issue -->\n\n"
f"Hi @{pr_author},\n\n"
"Thank you for your interest in contributing to OpenWISP.\n\n"
"This pull request has been flagged because external contributors must target an "
"issue validated by maintainers before requesting review.\n\n"
"Please link this pull request to a validated issue by adding "
"`Fixes #ISSUE_NUMBER`, `Closes #ISSUE_NUMBER`, or `Related to #ISSUE_NUMBER` to the pull request description. "
"The issue may be in this repository or another OpenWISP repository.\n\n"
"If there is no validated issue yet, please open one first and wait for "
"maintainer validation before continuing with this pull request.\n\n"
"An issue is considered validated when it is open, has an appropriate label other than "
"`invalid` or `wontfix`, and is assigned to one of the OpenWISP contributor project boards "
"mentioned in the contributing guidelines.\n\n"
"Please see the OpenWISP policy on unsolicited and AI-assisted contributions:\n"
"https://openwisp.io/docs/dev/general/code-of-conduct.html\n\n"
"If this is not resolved within 24 hours, this pull request will be closed automatically. "
"Thank you for your understanding."
)
pr_obj.create_issue_comment(comment_body)
print(f"Posted unvalidated issue warning comment on PR #{pr_number}")
return True
print(f"PR action '{action}' not handled")
return True
except Exception as e:
Expand Down
81 changes: 58 additions & 23 deletions .github/actions/bot-autoassign/stale_pr_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,29 +104,6 @@ def get_last_changes_requested(self, pr, all_reviews=None):
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"<!-- bot:{comment_type} -->"
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:
Expand Down Expand Up @@ -329,6 +306,64 @@ def process_stale_prs(self):
for pr in open_prs:
pr_count += 1
try:
pr_labels = [label.name for label in pr.labels]
if "invalid" in pr_labels:
if self.validate_pr_issues(pr):
pr.remove_from_labels("invalid")
print(f"PR #{pr.number} is now valid. Removed 'invalid' label.")
else:
warning_comment = None
comments = list(pr.get_issue_comments())
for comment in comments:
if (
comment.user
and comment.user.type == "Bot"
and "<!-- bot:invalid_unvalidated_issue -->" in comment.body
):
warning_comment = comment
break
if warning_comment:
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
if (now - warning_comment.created_at).total_seconds() >= 24 * 3600:
close_message = (
"<!-- bot:invalid_unvalidated_issue_closed -->\n\n"
"This pull request has been automatically closed because it has been flagged as "
"invalid (not referencing a validated issue) for more than 24 hours."
)
try:
pr.create_issue_comment(close_message)
except Exception as comment_error:
print(f"Warning: Could not post close comment on PR #{pr.number}: {comment_error}")
pr.edit(state="closed")
print(f"Closed PR #{pr.number} automatically after 24 hours.")
processed_count += 1
else:
pr_author = pr.user.login if pr.user else ""
comment_body = (
"<!-- bot:invalid_unvalidated_issue -->\n\n"
f"Hi @{pr_author},\n\n"
"Thank you for your interest in contributing to OpenWISP.\n\n"
"This pull request has been flagged because external contributors must target an "
"issue validated by maintainers before requesting review.\n\n"
"Please link this pull request to a validated issue by adding "
"`Fixes #ISSUE_NUMBER`, `Closes #ISSUE_NUMBER`, or `Related to #ISSUE_NUMBER` to the pull request description. "
"The issue may be in this repository or another OpenWISP repository.\n\n"
"If there is no validated issue yet, please open one first and wait for "
"maintainer validation before continuing with this pull request.\n\n"
"An issue is considered validated when it is open, has an appropriate label other than "
"`invalid` or `wontfix`, and is assigned to one of the OpenWISP contributor project boards "
"mentioned in the contributing guidelines.\n\n"
"Please see the OpenWISP policy on unsolicited and AI-assisted contributions:\n"
"https://openwisp.io/docs/dev/general/code-of-conduct.html\n\n"
"If this is not resolved within 24 hours, this pull request will be closed automatically. "
"Thank you for your understanding."
)
pr.create_issue_comment(comment_body)
print(f"Posted missing warning comment on PR #{pr.number}")
processed_count += 1
continue

all_reviews = list(pr.get_reviews())
last_changes_requested = self.get_last_changes_requested(
pr, all_reviews
Expand Down
Loading
Loading