22
33from github import Github
44
5+ MAINTAINER_ROLES = frozenset ({"OWNER" , "MEMBER" , "COLLABORATOR" })
6+ DEFAULT_EXCLUDE_PR_AUTHORS = "dependabot[bot]"
7+ REQUIRED_CONTRIBUTOR_PROJECTS = (
8+ "OpenWISP Contributor's Board" ,
9+ "OpenWISP Priorities for next releases" ,
10+ )
11+ INVALID_ISSUE_LABELS = frozenset ({"invalid" , "wontfix" })
12+
513
614class GitHubBot :
715 def __init__ (self ):
816 self .github_token = os .environ .get ("GITHUB_TOKEN" )
917 self .repository_name = os .environ .get ("REPOSITORY" )
1018 self .event_name = os .environ .get ("GITHUB_EVENT_NAME" )
1119 self .event_payload = None
20+ bot_username = os .environ .get ("BOT_USERNAME" , "openwisp-companion" )
21+ self .bot_username = bot_username
22+ self .bot_login = (
23+ bot_username if bot_username .endswith ("[bot]" ) else f"{ bot_username } [bot]"
24+ )
1225
1326 if self .github_token and self .repository_name :
1427 try :
@@ -25,3 +38,247 @@ def __init__(self):
2538
2639 def load_event_payload (self , event_payload ):
2740 self .event_payload = event_payload
41+
42+ @staticmethod
43+ def _normalize_project_title (title : str ) -> str :
44+ """Normalize project titles for stable comparisons."""
45+ return " " .join (title .replace ("\u2019 " , "'" ).split ()).strip ()
46+
47+ @classmethod
48+ def _project_title_key (cls , title : str ) -> str :
49+ """Loose match key: casefold + ignore apostrophes."""
50+ normalized = cls ._normalize_project_title (title ).casefold ()
51+ return normalized .replace ("'" , "" )
52+
53+ def get_issue_projects (self , owner , repo_name , issue_number ):
54+ query = """
55+ query($owner: String!, $repo: String!, $issueNumber: Int!) {
56+ repository(owner: $owner, name: $repo) {
57+ issue(number: $issueNumber) {
58+ projectItems(first: 20) {
59+ nodes {
60+ project {
61+ title
62+ }
63+ }
64+ }
65+ }
66+ }
67+ }
68+ """
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 } " )
72+ repo_data = result .get ("data" , {}).get ("repository" )
73+ if not repo_data :
74+ raise ValueError (
75+ f"GraphQL could not access repository { owner } /{ repo_name } ; "
76+ "possible GitHub API or permission error"
77+ )
78+ issue_node = repo_data .get ("issue" )
79+ if issue_node is None :
80+ raise ValueError (
81+ f"GraphQL could not access issue { owner } /{ repo_name } #{ issue_number } ; "
82+ "possible GitHub API or permission error"
83+ )
84+ project_items = issue_node .get ("projectItems" )
85+ if project_items is None :
86+ raise ValueError (
87+ f"GraphQL could not read project assignments for "
88+ f"{ owner } /{ repo_name } #{ issue_number } ; "
89+ "possible GitHub API or permission error"
90+ )
91+ nodes = project_items .get ("nodes" ) or []
92+ projects = []
93+ for node in nodes :
94+ if not node :
95+ continue
96+ project = node .get ("project" ) or {}
97+ title = project .get ("title" )
98+ if title :
99+ projects .append (self ._normalize_project_title (title ))
100+ return projects
101+
102+ def validate_pr_issues (self , pr ):
103+ """Validate if a pull request is from an exempt user or references a validated issue."""
104+ if not self .github or not self .repository_name :
105+ print ("GitHub client or repository name not initialized" )
106+ return False
107+
108+ pr_author = (
109+ pr .user .login
110+ if pr .user and isinstance (getattr (pr .user , "login" , None ), str )
111+ else ""
112+ )
113+
114+ exclude_authors_env = os .environ .get (
115+ "EXCLUDE_PR_AUTHORS" , DEFAULT_EXCLUDE_PR_AUTHORS
116+ )
117+ excluded_authors = [
118+ auth .strip () for auth in exclude_authors_env .split ("," ) if auth .strip ()
119+ ]
120+ if pr_author in excluded_authors :
121+ print (f"Author { pr_author } is in the exclude list. Proceeding." )
122+ return True
123+
124+ author_association = str (getattr (pr , "author_association" , "" ) or "" )
125+ if author_association in MAINTAINER_ROLES :
126+ print (
127+ f"Author { pr_author } is exempt due to association: "
128+ f"{ author_association } . Proceeding."
129+ )
130+ return True
131+
132+ from utils import extract_all_linked_issues
133+
134+ pr_body = pr .body if isinstance (pr .body , str ) else ""
135+ linked_issues = extract_all_linked_issues (pr_body , self .repository_name )
136+ if not linked_issues :
137+ print ("No linked issues found in PR body for external contributor." )
138+ return False
139+
140+ current_org = self .repository_name .split ("/" )[0 ].lower ()
141+ required_projects = {
142+ self ._project_title_key (p ) for p in REQUIRED_CONTRIBUTOR_PROJECTS
143+ }
144+
145+ for owner , repo_name , issue_number in linked_issues :
146+ if owner .lower () != current_org :
147+ print (
148+ f"Issue { owner } /{ repo_name } #{ issue_number } does not belong "
149+ f"to organization { current_org } , skipping validation."
150+ )
151+ continue
152+
153+ try :
154+ from github import GithubException
155+
156+ target_repo = self .github .get_repo (f"{ owner } /{ repo_name } " )
157+ issue = target_repo .get_issue (issue_number )
158+ except Exception as e :
159+ if isinstance (e , GithubException ) and e .status == 404 :
160+ print (
161+ f"Issue { owner } /{ repo_name } #{ issue_number } not found, skipping validation."
162+ )
163+ continue
164+ print (f"Error fetching issue { owner } /{ repo_name } #{ issue_number } : { e } " )
165+ raise
166+
167+ if issue .pull_request :
168+ print (
169+ f"Reference { owner } /{ repo_name } #{ issue_number } is a pull request, skipping validation."
170+ )
171+ continue
172+
173+ if issue .state != "open" :
174+ print (
175+ f"Issue { owner } /{ repo_name } #{ issue_number } is not open, skipping validation."
176+ )
177+ continue
178+
179+ issue_labels = [label .name .lower () for label in issue .labels ]
180+ valid_labels = [
181+ lbl for lbl in issue_labels if lbl not in INVALID_ISSUE_LABELS
182+ ]
183+ if not valid_labels :
184+ print (
185+ f"Issue { owner } /{ repo_name } #{ issue_number } has no valid labels, skipping validation."
186+ )
187+ continue
188+ if any (lbl in INVALID_ISSUE_LABELS for lbl in issue_labels ):
189+ print (
190+ f"Issue { owner } /{ repo_name } #{ issue_number } contains "
191+ "invalid/wontfix label, skipping validation."
192+ )
193+ continue
194+
195+ try :
196+ projects = self .get_issue_projects (owner , repo_name , issue_number )
197+ except Exception as e :
198+ print (
199+ f"Error fetching projects for issue { owner } /{ repo_name } #{ issue_number } : { e } "
200+ )
201+ raise
202+
203+ print (
204+ f"DEBUG: Raw projects for { owner } /{ repo_name } #{ issue_number } : { projects } "
205+ )
206+ project_keys = [self ._project_title_key (p ) for p in projects ]
207+ print (f"DEBUG: Normalized project keys: { project_keys } " )
208+ print (f"DEBUG: Required project keys: { required_projects } " )
209+
210+ has_valid_project = any (key in required_projects for key in project_keys )
211+ if has_valid_project :
212+ print (
213+ f"Issue { owner } /{ repo_name } #{ issue_number } is validated. PR is valid."
214+ )
215+ return True
216+ else :
217+ print (
218+ f"Issue { owner } /{ repo_name } #{ issue_number } is not assigned "
219+ f"to any required project (found: { projects or 'none' } ), "
220+ "skipping validation."
221+ )
222+
223+ return False
224+
225+ def get_bot_comment (self , pr , comment_type , after_date = None , issue_comments = None ):
226+ """Get the comment of this bot with the given marker if it exists.
227+
228+ If ``after_date`` is provided, only considers comments posted after that date.
229+ """
230+ try :
231+ if issue_comments is None :
232+ issue_comments = list (pr .get_issue_comments ())
233+ marker = f"<!-- bot:{ comment_type } -->"
234+ for comment in issue_comments :
235+ if (
236+ comment .user
237+ and comment .user .login == self .bot_login
238+ and marker in comment .body
239+ ):
240+ if after_date and comment .created_at <= after_date :
241+ continue
242+ return comment
243+ return None
244+ except Exception as e :
245+ print (f"Error getting bot comment for PR #{ pr .number } : { e } " )
246+ return None
247+
248+ def has_bot_comment (self , pr , comment_type , after_date = None , issue_comments = None ):
249+ """Check if PR already has a specific type of bot comment.
250+
251+ Uses HTML markers. If ``after_date`` is provided,
252+ only considers comments posted after that date.
253+ """
254+ return bool (self .get_bot_comment (pr , comment_type , after_date , issue_comments ))
255+
256+ def get_invalid_unvalidated_issue_comment (self , pr_author ):
257+ """Returns the comment body warning that the PR is invalid/unvalidated."""
258+ greeting = f"Hi @{ pr_author } ,\n \n " if pr_author else "Hi,\n \n "
259+ return (
260+ "<!-- bot:invalid_unvalidated_issue -->\n \n "
261+ f"{ greeting } "
262+ "Thank you for your interest in contributing to OpenWISP.\n \n "
263+ "This pull request has been flagged because external contributors "
264+ "must target an issue validated by maintainers before requesting "
265+ "review.\n \n "
266+ "Please link this pull request to a validated issue by adding "
267+ "`Fixes #ISSUE_NUMBER`, `Closes #ISSUE_NUMBER`, or "
268+ "`Related to #ISSUE_NUMBER` to the pull request description. "
269+ "The issue may be in this repository or another OpenWISP "
270+ "repository.\n \n "
271+ "If there is no validated issue yet, please open one first and wait "
272+ "for maintainer validation before continuing with this pull "
273+ "request.\n \n "
274+ "An issue is considered validated when it is open, has an appropriate "
275+ "label other than `invalid` or `wontfix`, and is assigned to one of "
276+ "the OpenWISP contributor project boards mentioned in the "
277+ "contributing guidelines.\n \n "
278+ "Please see the OpenWISP policy on unsolicited and AI-assisted "
279+ "contributions:\n "
280+ "https://openwisp.io/docs/dev/general/code-of-conduct.html\n \n "
281+ "If this is not resolved within 24 hours, this pull request "
282+ "will be closed automatically. "
283+ "Thank you for your understanding."
284+ )
0 commit comments