@@ -9,6 +9,11 @@ def __init__(self):
99 self .repository_name = os .environ .get ("REPOSITORY" )
1010 self .event_name = os .environ .get ("GITHUB_EVENT_NAME" )
1111 self .event_payload = None
12+ bot_username = os .environ .get ("BOT_USERNAME" , "openwisp-companion" )
13+ self .bot_username = bot_username
14+ self .bot_login = (
15+ bot_username if bot_username .endswith ("[bot]" ) else f"{ bot_username } [bot]"
16+ )
1217
1318 if self .github_token and self .repository_name :
1419 try :
@@ -25,3 +30,223 @@ def __init__(self):
2530
2631 def load_event_payload (self , event_payload ):
2732 self .event_payload = event_payload
33+
34+ def get_issue_projects (self , owner , repo_name , issue_number ):
35+ query = """
36+ query($owner: String!, $repo: String!, $issueNumber: Int!) {
37+ repository(owner: $owner, name: $repo) {
38+ issue(number: $issueNumber) {
39+ projectItems(first: 10) {
40+ nodes {
41+ project {
42+ title
43+ }
44+ }
45+ }
46+ }
47+ }
48+ }
49+ """
50+ variables = {"owner" : owner , "repo" : repo_name , "issueNumber" : issue_number }
51+ headers , result = self .github .requester .graphql_query (query , variables )
52+ repo_data = result .get ("data" , {}).get ("repository" , {}) or {}
53+ issue_data = repo_data .get ("issue" , {}) or {}
54+ project_items = issue_data .get ("projectItems" , {}) or {}
55+ nodes = project_items .get ("nodes" , []) or []
56+
57+ return [
58+ node ["project" ]["title" ]
59+ for node in nodes
60+ if node and node .get ("project" ) and node ["project" ].get ("title" )
61+ ]
62+
63+ def validate_pr_issues (self , pr ):
64+ """Validate if a pull request is from an exempt user or references a validated issue."""
65+ if not self .github or not self .repository_name :
66+ print ("GitHub client or repository name not initialized" )
67+ return False
68+
69+ pr_author = (
70+ pr .user .login
71+ if pr .user and isinstance (getattr (pr .user , "login" , None ), str )
72+ else ""
73+ )
74+
75+ # Check if the author is in the exclude list
76+ exclude_authors_env = os .environ .get ("EXCLUDE_PR_AUTHORS" , "dependabot[bot]" )
77+ excluded_authors = [
78+ auth .strip () for auth in exclude_authors_env .split ("," ) if auth .strip ()
79+ ]
80+ if pr_author in excluded_authors :
81+ print (f"Author { pr_author } is in the exclude list. Proceeding." )
82+ return True
83+
84+ # Check if the author is a maintainer/collaborator
85+ author_association = getattr (pr , "author_association" , None )
86+ if isinstance (author_association , str ) and author_association in [
87+ "OWNER" ,
88+ "MEMBER" ,
89+ "COLLABORATOR" ,
90+ ]:
91+ print (
92+ f"Author { pr_author } is exempt due to association: { author_association } . Proceeding."
93+ )
94+ return True
95+
96+ # For external contributors, extract linked issues
97+ from utils import extract_all_linked_issues
98+
99+ pr_body = pr .body if isinstance (pr .body , str ) else ""
100+ linked_issues = extract_all_linked_issues (pr_body , self .repository_name )
101+ if not linked_issues :
102+ print ("No linked issues found in PR body for external contributor." )
103+ return False
104+
105+ current_org = self .repository_name .split ("/" )[0 ].lower ()
106+ required_projects = [
107+ "OpenWISP Contributor's Board" ,
108+ "OpenWISP Priorities for next releases" ,
109+ ]
110+
111+ for owner , repo_name , issue_number in linked_issues :
112+ # 1. Check if the issue belongs to the same organization
113+ if owner .lower () != current_org :
114+ print (
115+ f"Issue { owner } /{ repo_name } #{ issue_number } does not belong "
116+ f"to organization { current_org } , skipping validation."
117+ )
118+ continue
119+
120+ try :
121+ from github import GithubException
122+
123+ target_repo = self .github .get_repo (f"{ owner } /{ repo_name } " )
124+ issue = target_repo .get_issue (issue_number )
125+ except Exception as e :
126+ if isinstance (e , GithubException ) and e .status == 404 :
127+ print (
128+ f"Issue { owner } /{ repo_name } #{ issue_number } not found, skipping validation."
129+ )
130+ continue
131+ else :
132+ print (
133+ f"Error fetching issue { owner } /{ repo_name } #{ issue_number } : { e } "
134+ )
135+ raise
136+
137+ # 2. Check if the issue is a PR
138+ if issue .pull_request :
139+ print (
140+ f"Reference { owner } /{ repo_name } #{ issue_number } is a pull request, skipping validation."
141+ )
142+ continue
143+
144+ # 3. Check if the issue is open
145+ if issue .state != "open" :
146+ print (
147+ f"Issue { owner } /{ repo_name } #{ issue_number } is not open, skipping validation."
148+ )
149+ continue
150+
151+ # 4. Check if the issue has at least one label, and none are invalid/wontfix
152+ issue_labels = [label .name .lower () for label in issue .labels ]
153+ valid_labels = [
154+ lbl for lbl in issue_labels if lbl not in ["invalid" , "wontfix" ]
155+ ]
156+ if not valid_labels :
157+ print (
158+ f"Issue { owner } /{ repo_name } #{ issue_number } has no valid labels, skipping validation."
159+ )
160+ continue
161+ if any (lbl in ["invalid" , "wontfix" ] for lbl in issue_labels ):
162+ print (
163+ f"Issue { owner } /{ repo_name } #{ issue_number } contains "
164+ "invalid/wontfix label, skipping validation."
165+ )
166+ continue
167+
168+ # 5. Check if the issue is assigned to one of the required projects
169+ try :
170+ projects = self .get_issue_projects (owner , repo_name , issue_number )
171+ except Exception as e :
172+ print (
173+ f"Error fetching projects for issue { owner } /{ repo_name } #{ issue_number } : { e } "
174+ )
175+ raise
176+
177+ has_valid_project = any (
178+ project in required_projects for project in projects
179+ )
180+ if has_valid_project :
181+ print (
182+ f"Issue { owner } /{ repo_name } #{ issue_number } is validated. PR is valid."
183+ )
184+ return True
185+ else :
186+ print (
187+ f"Issue { owner } /{ repo_name } #{ issue_number } is not assigned "
188+ "to any required project, skipping validation."
189+ )
190+
191+ return False
192+
193+ def get_bot_comment (self , pr , comment_type , after_date = None , issue_comments = None ):
194+ """Get the comment of this bot with the given marker if it exists.
195+
196+ If ``after_date`` is provided, only considers comments posted after that date.
197+ """
198+ try :
199+ if issue_comments is None :
200+ issue_comments = list (pr .get_issue_comments ())
201+ marker = f"<!-- bot:{ comment_type } -->"
202+ for comment in issue_comments :
203+ if (
204+ comment .user
205+ and comment .user .login == self .bot_login
206+ and marker in comment .body
207+ ):
208+ if after_date and comment .created_at <= after_date :
209+ continue
210+ return comment
211+ return None
212+ except Exception as e :
213+ print (f"Error getting bot comment for PR #{ pr .number } : { e } " )
214+ return None
215+
216+ def has_bot_comment (self , pr , comment_type , after_date = None , issue_comments = None ):
217+ """Check if PR already has a specific type of bot comment.
218+
219+ Uses HTML markers. If ``after_date`` is provided,
220+ only considers comments posted after that date.
221+ """
222+ return bool (self .get_bot_comment (pr , comment_type , after_date , issue_comments ))
223+
224+ def get_invalid_unvalidated_issue_comment (self , pr_author ):
225+ """Returns the comment body warning that the PR is invalid/unvalidated."""
226+ greeting = f"Hi @{ pr_author } ,\n \n " if pr_author else "Hi,\n \n "
227+ return (
228+ "<!-- bot:invalid_unvalidated_issue -->\n \n "
229+ f"{ greeting } "
230+ "Thank you for your interest in contributing to OpenWISP.\n \n "
231+ "This pull request has been flagged because external contributors "
232+ "must target an issue validated by maintainers before requesting "
233+ "review.\n \n "
234+ "Please link this pull request to a validated issue by adding "
235+ "`Fixes #ISSUE_NUMBER`, `Closes #ISSUE_NUMBER`, or "
236+ "`Related to #ISSUE_NUMBER` to the pull request description. "
237+ "The issue may be in this repository or another OpenWISP "
238+ "repository.\n \n "
239+ "If there is no validated issue yet, please open one first and wait "
240+ "for maintainer validation before continuing with this pull "
241+ "request.\n \n "
242+ "An issue is considered validated when it is open, has an appropriate "
243+ "label other than `invalid` or `wontfix`, and is assigned to one of "
244+ "the OpenWISP contributor project boards mentioned in the "
245+ "contributing guidelines.\n \n "
246+ "Please see the OpenWISP policy on unsolicited and AI-assisted "
247+ "contributions:\n "
248+ "https://openwisp.io/docs/dev/general/code-of-conduct.html\n \n "
249+ "If this is not resolved within 24 hours, this pull request "
250+ "will be closed automatically. "
251+ "Thank you for your understanding."
252+ )
0 commit comments