@@ -25,3 +25,154 @@ def __init__(self):
2525
2626 def load_event_payload (self , event_payload ):
2727 self .event_payload = event_payload
28+
29+ def get_issue_projects (self , owner , repo_name , issue_number ):
30+ query = """
31+ query($owner: String!, $repo: String!, $issueNumber: Int!) {
32+ repository(owner: $owner, name: $repo) {
33+ issue(number: $issueNumber) {
34+ projectItems(first: 10) {
35+ nodes {
36+ project {
37+ title
38+ }
39+ }
40+ }
41+ }
42+ }
43+ }
44+ """
45+ variables = {
46+ "owner" : owner ,
47+ "repo" : repo_name ,
48+ "issueNumber" : issue_number
49+ }
50+ result = self .github .raw_graphql (query , variables )
51+ if "errors" in result :
52+ raise Exception (f"GraphQL errors: { result ['errors' ]} " )
53+
54+ repo_data = result .get ("data" , {}).get ("repository" , {}) or {}
55+ issue_data = repo_data .get ("issue" , {}) or {}
56+ project_items = issue_data .get ("projectItems" , {}) or {}
57+ nodes = project_items .get ("nodes" , []) or []
58+
59+ return [
60+ node ["project" ]["title" ]
61+ for node in nodes
62+ if node and node .get ("project" ) and node ["project" ].get ("title" )
63+ ]
64+
65+ def validate_pr_issues (self , pr ):
66+ """Validate if a pull request is from an exempt user or references a validated issue."""
67+ if not self .github or not self .repository_name :
68+ print ("GitHub client or repository name not initialized" )
69+ return False
70+
71+ pr_author = pr .user .login if pr .user and isinstance (getattr (pr .user , "login" , None ), str ) else ""
72+
73+ # Check if the author is in the exclude list
74+ exclude_authors_env = os .environ .get ("EXCLUDE_PR_AUTHORS" , "dependabot[bot]" )
75+ excluded_authors = [auth .strip () for auth in exclude_authors_env .split ("," ) if auth .strip ()]
76+ if pr_author in excluded_authors :
77+ print (f"Author { pr_author } is in the exclude list. Proceeding." )
78+ return True
79+
80+ # Check if the author is a maintainer/collaborator
81+ author_association = getattr (pr , "author_association" , None )
82+ if isinstance (author_association , str ) and author_association in ["OWNER" , "MEMBER" , "COLLABORATOR" ]:
83+ print (f"Author { pr_author } is exempt due to association: { author_association } . Proceeding." )
84+ return True
85+
86+ # For external contributors, extract linked issues
87+ from utils import extract_all_linked_issues
88+ pr_body = pr .body if isinstance (pr .body , str ) else ""
89+ linked_issues = extract_all_linked_issues (pr_body , self .repository_name )
90+ if not linked_issues :
91+ print ("No linked issues found in PR body for external contributor." )
92+ return False
93+
94+ current_org = self .repository_name .split ("/" )[0 ].lower ()
95+ required_projects = [
96+ "OpenWISP Contributor's Board" ,
97+ "OpenWISP Priorities for next releases"
98+ ]
99+
100+ for owner , repo_name , issue_number in linked_issues :
101+ # 1. Check if the issue belongs to the same organization
102+ if owner .lower () != current_org :
103+ print (f"Issue { owner } /{ repo_name } #{ issue_number } does not belong to organization { current_org } , skipping validation." )
104+ continue
105+
106+ try :
107+ from github import GithubException
108+ target_repo = self .github .get_repo (f"{ owner } /{ repo_name } " )
109+ issue = target_repo .get_issue (issue_number )
110+ except Exception as e :
111+ if isinstance (e , GithubException ) and e .status == 404 :
112+ print (f"Issue { owner } /{ repo_name } #{ issue_number } not found, skipping validation." )
113+ continue
114+ else :
115+ print (f"Error fetching issue { owner } /{ repo_name } #{ issue_number } : { e } " )
116+ raise
117+
118+ # 2. Check if the issue is a PR
119+ if issue .pull_request :
120+ print (f"Reference { owner } /{ repo_name } #{ issue_number } is a pull request, skipping validation." )
121+ continue
122+
123+ # 3. Check if the issue is open
124+ if issue .state != "open" :
125+ print (f"Issue { owner } /{ repo_name } #{ issue_number } is not open, skipping validation." )
126+ continue
127+
128+ # 4. Check if the issue has at least one label, and none are invalid/wontfix
129+ issue_labels = [label .name .lower () for label in issue .labels ]
130+ valid_labels = [l for l in issue_labels if l not in ["invalid" , "wontfix" ]]
131+ if not valid_labels :
132+ print (f"Issue { owner } /{ repo_name } #{ issue_number } has no valid labels, skipping validation." )
133+ continue
134+ if any (l in ["invalid" , "wontfix" ] for l in issue_labels ):
135+ print (f"Issue { owner } /{ repo_name } #{ issue_number } contains invalid/wontfix label, skipping validation." )
136+ continue
137+
138+ # 5. Check if the issue is assigned to one of the required projects
139+ try :
140+ projects = self .get_issue_projects (owner , repo_name , issue_number )
141+ except Exception as e :
142+ print (f"Error fetching projects for issue { owner } /{ repo_name } #{ issue_number } : { e } " )
143+ raise
144+
145+ has_valid_project = any (project in required_projects for project in projects )
146+ if has_valid_project :
147+ print (f"Issue { owner } /{ repo_name } #{ issue_number } is validated. PR is valid." )
148+ return True
149+ else :
150+ print (f"Issue { owner } /{ repo_name } #{ issue_number } is not assigned to any required project, skipping validation." )
151+
152+ return False
153+
154+ def has_bot_comment (self , pr , comment_type , after_date = None , issue_comments = None ):
155+ """Check if PR already has a specific type of bot comment.
156+
157+ Uses HTML markers. If ``after_date`` is provided,
158+ only considers comments posted after that date.
159+ """
160+ try :
161+ if issue_comments is None :
162+ issue_comments = list (pr .get_issue_comments ())
163+ marker = f"<!-- bot:{ comment_type } -->"
164+ for comment in issue_comments :
165+ if (
166+ comment .user
167+ and comment .user .type == "Bot"
168+ and marker in comment .body
169+ ):
170+ if after_date and comment .created_at <= after_date :
171+ continue
172+ return True
173+ return False
174+ except Exception as e :
175+ print ("Error checking bot comments" f" for PR #{ pr .number } : { e } " )
176+ return False
177+
178+
0 commit comments