Skip to content

Commit f956dc3

Browse files
Merge branch 'master' into feature/709-flag-invalid-prs
2 parents e719d10 + 6496fa3 commit f956dc3

15 files changed

Lines changed: 184 additions & 128 deletions

File tree

.github/actions/bot-autoassign/base.py

Lines changed: 65 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22

3-
from github import Github
3+
from github import Github, GithubException
4+
from utils import extract_all_linked_issues
45

56
MAINTAINER_ROLES = frozenset({"OWNER", "MEMBER", "COLLABORATOR"})
67
DEFAULT_EXCLUDE_PR_AUTHORS = "dependabot[bot]"
@@ -22,7 +23,6 @@ def __init__(self):
2223
self.bot_login = (
2324
bot_username if bot_username.endswith("[bot]") else f"{bot_username}[bot]"
2425
)
25-
2626
if self.github_token and self.repository_name:
2727
try:
2828
self.github = Github(self.github_token)
@@ -52,69 +52,87 @@ def _project_title_key(cls, title: str) -> str:
5252

5353
def get_issue_projects(self, owner, repo_name, issue_number):
5454
query = """
55-
query($owner: String!, $repo: String!, $issueNumber: Int!) {
55+
query($owner: String!, $repo: String!, $issueNumber: Int!, $cursor: String) {
5656
repository(owner: $owner, name: $repo) {
5757
issue(number: $issueNumber) {
58-
projectItems(first: 10) {
58+
projectItems(first: 100, after: $cursor) {
5959
nodes {
6060
project {
6161
title
6262
}
6363
}
64+
pageInfo {
65+
hasNextPage
66+
endCursor
67+
}
6468
}
6569
}
6670
}
6771
}
6872
"""
69-
variables = {"owner": owner, "repo": repo_name, "issueNumber": issue_number}
70-
headers, result = self.github.requester.graphql_query(query, variables)
71-
print(f"DEBUG: Raw GraphQL response: {result}")
73+
projects = []
74+
has_next_page = True
75+
cursor = None
76+
77+
while has_next_page:
78+
variables = {
79+
"owner": owner,
80+
"repo": repo_name,
81+
"issueNumber": issue_number,
82+
"cursor": cursor,
83+
}
84+
headers, result = self.github.requester.graphql_query(query, variables)
7285

73-
if "errors" in result:
74-
raise ValueError(f"GraphQL API Permission Error: {result['errors']}")
86+
if "errors" in result:
87+
raise ValueError(f"GraphQL API Permission Error: {result['errors']}")
88+
89+
repo_data = result.get("data", {}).get("repository")
90+
if not repo_data:
91+
raise ValueError(
92+
f"GraphQL could not access repository {owner}/{repo_name}; "
93+
"possible GitHub API or permission error"
94+
)
95+
96+
issue_node = repo_data.get("issue")
97+
if issue_node is None:
98+
raise ValueError(
99+
f"GraphQL could not access issue {owner}/{repo_name}#{issue_number}; "
100+
"possible GitHub API or permission error"
101+
)
102+
103+
project_items = issue_node.get("projectItems")
104+
if project_items is None:
105+
raise ValueError(
106+
f"GraphQL could not read project assignments for "
107+
f"{owner}/{repo_name}#{issue_number}; "
108+
"possible GitHub API or permission error"
109+
)
110+
111+
nodes = project_items.get("nodes") or []
112+
for node in nodes:
113+
if not node:
114+
continue
115+
project = node.get("project") or {}
116+
title = project.get("title")
117+
if title:
118+
projects.append(self._normalize_project_title(title))
119+
120+
page_info = project_items.get("pageInfo") or {}
121+
has_next_page = page_info.get("hasNextPage", False)
122+
cursor = page_info.get("endCursor")
75123

76-
repo_data = result.get("data", {}).get("repository")
77-
if not repo_data:
78-
raise ValueError(
79-
f"GraphQL could not access repository {owner}/{repo_name}; "
80-
"possible GitHub API or permission error"
81-
)
82-
issue_node = repo_data.get("issue")
83-
if issue_node is None:
84-
raise ValueError(
85-
f"GraphQL could not access issue {owner}/{repo_name}#{issue_number}; "
86-
"possible GitHub API or permission error"
87-
)
88-
project_items = issue_node.get("projectItems")
89-
if project_items is None:
90-
raise ValueError(
91-
f"GraphQL could not read project assignments for "
92-
f"{owner}/{repo_name}#{issue_number}; "
93-
"possible GitHub API or permission error"
94-
)
95-
nodes = project_items.get("nodes") or []
96-
projects = []
97-
for node in nodes:
98-
if not node:
99-
continue
100-
project = node.get("project") or {}
101-
title = project.get("title")
102-
if title:
103-
projects.append(self._normalize_project_title(title))
104124
return projects
105125

106126
def validate_pr_issues(self, pr):
107127
"""Validate if a pull request is from an exempt user or references a validated issue."""
108128
if not self.github or not self.repository_name:
109129
print("GitHub client or repository name not initialized")
110130
return False
111-
112131
pr_author = (
113132
pr.user.login
114133
if pr.user and isinstance(getattr(pr.user, "login", None), str)
115134
else ""
116135
)
117-
118136
exclude_authors_env = os.environ.get(
119137
"EXCLUDE_PR_AUTHORS", DEFAULT_EXCLUDE_PR_AUTHORS
120138
)
@@ -124,39 +142,30 @@ def validate_pr_issues(self, pr):
124142
if pr_author in excluded_authors:
125143
print(f"Author {pr_author} is in the exclude list. Proceeding.")
126144
return True
127-
128145
author_association = str(getattr(pr, "author_association", "") or "")
129146
if author_association in MAINTAINER_ROLES:
130147
print(
131148
f"Author {pr_author} is exempt due to association: "
132149
f"{author_association}. Proceeding."
133150
)
134151
return True
135-
136-
from utils import extract_all_linked_issues
137-
138152
pr_body = pr.body if isinstance(pr.body, str) else ""
139153
linked_issues = extract_all_linked_issues(pr_body, self.repository_name)
140154
if not linked_issues:
141155
print("No linked issues found in PR body for external contributor.")
142156
return False
143-
144157
current_org = self.repository_name.split("/")[0].lower()
145158
required_projects = {
146159
self._project_title_key(p) for p in REQUIRED_CONTRIBUTOR_PROJECTS
147160
}
148-
149161
for owner, repo_name, issue_number in linked_issues:
150162
if owner.lower() != current_org:
151163
print(
152164
f"Issue {owner}/{repo_name}#{issue_number} does not belong "
153165
f"to organization {current_org}, skipping validation."
154166
)
155167
continue
156-
157168
try:
158-
from github import GithubException
159-
160169
target_repo = self.github.get_repo(f"{owner}/{repo_name}")
161170
issue = target_repo.get_issue(issue_number)
162171
except Exception as e:
@@ -167,19 +176,16 @@ def validate_pr_issues(self, pr):
167176
continue
168177
print(f"Error fetching issue {owner}/{repo_name}#{issue_number}: {e}")
169178
raise
170-
171179
if issue.pull_request:
172180
print(
173181
f"Reference {owner}/{repo_name}#{issue_number} is a pull request, skipping validation."
174182
)
175183
continue
176-
177184
if issue.state != "open":
178185
print(
179186
f"Issue {owner}/{repo_name}#{issue_number} is not open, skipping validation."
180187
)
181188
continue
182-
183189
issue_labels = [label.name.lower() for label in issue.labels]
184190
valid_labels = [
185191
lbl for lbl in issue_labels if lbl not in INVALID_ISSUE_LABELS
@@ -195,22 +201,14 @@ def validate_pr_issues(self, pr):
195201
"invalid/wontfix label, skipping validation."
196202
)
197203
continue
198-
199204
try:
200205
projects = self.get_issue_projects(owner, repo_name, issue_number)
201206
except Exception as e:
202207
print(
203208
f"Error fetching projects for issue {owner}/{repo_name}#{issue_number}: {e}"
204209
)
205210
raise
206-
207-
print(
208-
f"DEBUG: Raw projects for {owner}/{repo_name}#{issue_number}: {projects}"
209-
)
210211
project_keys = [self._project_title_key(p) for p in projects]
211-
print(f"DEBUG: Normalized project keys: {project_keys}")
212-
print(f"DEBUG: Required project keys: {required_projects}")
213-
214212
has_valid_project = any(key in required_projects for key in project_keys)
215213
if has_valid_project:
216214
print(
@@ -223,12 +221,10 @@ def validate_pr_issues(self, pr):
223221
f"to any required project (found: {projects or 'none'}), "
224222
"skipping validation."
225223
)
226-
227224
return False
228225

229226
def get_bot_comment(self, pr, comment_type, after_date=None, issue_comments=None):
230227
"""Get the comment of this bot with the given marker if it exists.
231-
232228
If ``after_date`` is provided, only considers comments posted after that date.
233229
"""
234230
try:
@@ -251,7 +247,6 @@ def get_bot_comment(self, pr, comment_type, after_date=None, issue_comments=None
251247

252248
def has_bot_comment(self, pr, comment_type, after_date=None, issue_comments=None):
253249
"""Check if PR already has a specific type of bot comment.
254-
255250
Uses HTML markers. If ``after_date`` is provided,
256251
only considers comments posted after that date.
257252
"""
@@ -277,11 +272,14 @@ def get_invalid_unvalidated_issue_comment(self, pr_author):
277272
"request.\n\n"
278273
"An issue is considered validated when it is open, has an appropriate "
279274
"label other than `invalid` or `wontfix`, and is assigned to one of "
280-
"the OpenWISP contributor project boards mentioned in the "
281-
"contributing guidelines.\n\n"
282-
"Please see the OpenWISP policy on unsolicited and AI-assisted "
283-
"contributions:\n"
284-
"https://openwisp.io/docs/dev/general/code-of-conduct.html\n\n"
275+
"the project boards mentioned in the "
276+
"[OpenWISP Contributing Guidelines]"
277+
"(https://openwisp.io/docs/dev/developer/contributing.html).\n\n"
278+
"Please see the [OpenWISP Anti AI Spam Policy]"
279+
"(https://openwisp.io/docs/dev/general/code-of-conduct.html).\n\n"
280+
"Feel free to join the [OpenWISP dev chatroom]"
281+
"(https://matrix.to/#/#openwisp_development:gitter.im) "
282+
"to coordinate with the development team.\n\n"
285283
"If this is not resolved within 24 hours, this pull request "
286284
"will be closed automatically. "
287285
"Thank you for your understanding."

.github/actions/bot-autoassign/issue_assignment_bot.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,6 @@ def get_contributing_guidelines_url(self):
4040

4141
def detect_issue_type(self, issue):
4242
"""Analyzes labels, title and body.
43-
4443
Returns 'bug', 'feature', or None.
4544
"""
4645
bug_keywords = [
@@ -365,11 +364,9 @@ def auto_assign_issues_from_pr(self, pr_number, pr_author, pr_body, max_issues=1
365364

366365
def unassign_issues_from_pr(self, pr_body, pr_author):
367366
"""Unassign linked issues from PR author"""
368-
369367
if not self.repo:
370368
print("GitHub client not initialized")
371369
return []
372-
373370
try:
374371
return unassign_linked_issues_helper(
375372
self.repo, self.repository_name, pr_body, pr_author
@@ -442,17 +439,16 @@ def handle_pull_request(self):
442439
else:
443440
self.unassign_issues_from_pr(pr_body, pr_author)
444441
return True
445-
446442
if action in ["opened", "reopened", "edited", "ready_for_review"]:
447-
self.auto_assign_issues_from_pr(pr_number, pr_author, pr_body)
448443
pr_obj = self.repo.get_pull(pr_number)
449444
is_valid = self.validate_pr_issues(pr_obj)
445+
if is_valid:
446+
self.auto_assign_issues_from_pr(pr_number, pr_author, pr_body)
450447
labels_lower = set()
451448
try:
452449
labels_lower = {label.name.lower() for label in pr_obj.labels}
453450
except (TypeError, AttributeError):
454451
pass
455-
456452
if is_valid:
457453
if "invalid" in labels_lower:
458454
pr_obj.remove_from_labels("invalid")
@@ -461,7 +457,6 @@ def handle_pull_request(self):
461457
if "invalid" not in labels_lower:
462458
pr_obj.add_to_labels("invalid")
463459
print(f"Added 'invalid' label to PR #{pr_number}")
464-
465460
if not self.has_bot_comment(pr_obj, "invalid_unvalidated_issue"):
466461
comment_body = self.get_invalid_unvalidated_issue_comment(
467462
pr_author

0 commit comments

Comments
 (0)