@@ -183,77 +183,195 @@ async def fetch_pr_signals(state: AnalysisState) -> AnalysisState:
183183 It calculates HygieneMetrics from recent merged PRs to inform rule generation.
184184 """
185185 from src .agents .repository_analysis_agent .models import HygieneMetrics
186+ from src .integrations .github .client import GitHubClient
186187
187188 repo = state .repo_full_name
188189 if not repo :
189190 raise ValueError ("Repository full name is missing in state." )
190191
191192 logger .info ("pr_signals_fetch_started" , repo = repo )
192193
194+ # Extract owner and repo from full_name
193195 try :
194- # Fetch recent merged PRs (last 30)
195- pr_data_list = await github_client .fetch_recent_pull_requests (
196- repo_full_name = repo ,
197- installation_id = None , # Public repo access for now
198- limit = 30 ,
199- )
196+ owner , repo_name = repo .split ("/" , 1 )
197+ except ValueError as err :
198+ raise ValueError (f"Invalid repo format: { repo } . Expected 'owner/repo'." ) from err
199+
200+ # Initialize GraphQL-enabled client
201+ client = GitHubClient ()
202+
203+ try :
204+ # Fetch PR hygiene stats using GraphQL (avoids N+1 problem)
205+ pr_nodes = await client .fetch_pr_hygiene_stats (owner , repo_name )
200206
201- if not pr_data_list :
207+ if not pr_nodes :
202208 # New repo or no PRs - set default metrics to avoid LLM crash
203209 logger .warning (
204210 "pr_signals_no_data" , repo = repo , message = "No merged PRs found. Using default hygiene metrics."
205211 )
206212 state .hygiene_summary = HygieneMetrics (
207- unlinked_issue_rate = 0.0 , average_pr_size = 0 , first_time_contributor_count = 0
213+ unlinked_issue_rate = 0.0 ,
214+ average_pr_size = 0 ,
215+ first_time_contributor_count = 0 ,
216+ issue_diff_mismatch_rate = 0.0 ,
217+ ghost_contributor_rate = 0.0 ,
218+ test_coverage_delta_avg = 0.0 ,
219+ codeowner_bypass_rate = 0.0 ,
220+ ai_generated_rate = 0.0 ,
208221 )
209222 return state
210223
211- # Convert raw PR data to PRSignal models
212- pr_signals = [_map_github_pr_to_signal (pr ) for pr in pr_data_list ]
213- state .pr_signals = pr_signals
214-
215- # Calculate HygieneMetrics
216- total_prs = len (pr_signals )
217- unlinked_count = sum (1 for pr in pr_signals if not pr .has_linked_issue )
218- unlinked_rate = unlinked_count / total_prs if total_prs > 0 else 0.0
219-
220- avg_pr_size = sum (pr .lines_changed for pr in pr_signals ) // total_prs if total_prs > 0 else 0
221-
222- first_timers = sum (1 for pr in pr_signals if pr .author_association in ["FIRST_TIME_CONTRIBUTOR" , "NONE" ])
224+ # Calculate metrics from GraphQL response
225+ total_prs = len (pr_nodes )
226+
227+ # Calculate average_pr_size from changedFiles
228+ total_changed_files = sum (pr .get ("changedFiles" , 0 ) for pr in pr_nodes )
229+ average_pr_size = total_changed_files / total_prs if total_prs > 0 else 0.0
230+
231+ # Calculate unlinked_issue_rate from closingIssuesReferences
232+ unlinked_count = sum (1 for pr in pr_nodes if pr .get ("closingIssuesReferences" , {}).get ("totalCount" , 0 ) == 0 )
233+ unlinked_issue_rate = unlinked_count / total_prs if total_prs > 0 else 0.0
234+
235+ # Calculate engagement_rate (proxy for ghost contributor) from comments
236+ total_comments = sum (pr .get ("comments" , {}).get ("totalCount" , 0 ) for pr in pr_nodes )
237+ engagement_rate = total_comments / total_prs if total_prs > 0 else 0.0
238+
239+ # Legacy AI detection heuristic for demonstration
240+ ai_generated_count = 0
241+ for pr in pr_nodes :
242+ body = (pr .get ("body" ) or "" ).lower ()
243+ title = (pr .get ("title" ) or "" ).lower ()
244+ ai_keywords = [
245+ "generated by claude" ,
246+ "cursor" ,
247+ "copilot" ,
248+ "chatgpt" ,
249+ "ai-generated" ,
250+ "llm" ,
251+ "i am an ai" ,
252+ "as an ai" ,
253+ ]
254+ if any (keyword in body or keyword in title for keyword in ai_keywords ):
255+ ai_generated_count += 1
256+ ai_generated_rate = ai_generated_count / total_prs if total_prs > 0 else 0.0
257+
258+ # Calculate issue_diff_mismatch_rate
259+ issue_diff_mismatch_count = 0
260+ for pr in pr_nodes :
261+ issue_title = ""
262+ if pr .get ("closingIssuesReferences" , {}).get ("nodes" ):
263+ issue_title = pr ["closingIssuesReferences" ]["nodes" ][0 ].get ("title" , "" ).lower ()
264+
265+ if issue_title :
266+ changed_files = [edge ["node" ]["path" ] for edge in pr .get ("files" , {}).get ("edges" , [])]
267+
268+ # Simple heuristic: check if any part of a changed file's path is in the issue title
269+ mismatch = True
270+ for file_path in changed_files :
271+ path_parts = file_path .split ("/" )
272+ if any (part in issue_title for part in path_parts if len (part ) > 3 ):
273+ mismatch = False
274+ break
275+ if mismatch :
276+ issue_diff_mismatch_count += 1
277+
278+ issue_diff_mismatch_rate = issue_diff_mismatch_count / total_prs if total_prs > 0 else 0.0
279+
280+ # Calculate codeowner_bypass_rate
281+ codeowner_bypass_count = 0
282+ for pr in pr_nodes :
283+ reviews = pr .get ("reviews" , {}).get ("nodes" , [])
284+ author = pr .get ("author" , {}).get ("login" )
285+
286+ # This is a simplified check. A real implementation would parse CODEOWNERS.
287+ # For the demo, we assume any review from someone other than the author is sufficient.
288+ approved = any (review ["state" ] == "APPROVED" and review ["author" ]["login" ] != author for review in reviews )
289+
290+ if not approved :
291+ # Simplified: if no approved review from another user, it might be a bypass.
292+ # This doesn't actually check against CODEOWNERS file content.
293+ codeowner_bypass_count += 1
294+
295+ codeowner_bypass_rate = codeowner_bypass_count / total_prs if total_prs > 0 else 0.0
296+
297+ # Calculate new_code_test_coverage
298+ total_functions_added = 0
299+ total_test_functions_added = 0
300+ for pr in pr_nodes :
301+ diff_content = pr .get ("diff_content" , "" )
302+ if diff_content :
303+ lines = diff_content .split ("\n " )
304+ for line in lines :
305+ if line .startswith ("+" ) and not line .startswith ("+++" ) and "def " in line :
306+ # Simple heuristic for Python: count new function definitions
307+ file_path_info = next ((ln for ln in lines if ln .startswith ("+++ b/" )), None )
308+ if file_path_info :
309+ if "test" in file_path_info :
310+ total_test_functions_added += 1
311+ else :
312+ total_functions_added += 1
313+
314+ new_code_test_coverage = 0.0
315+ if total_functions_added > 0 :
316+ new_code_test_coverage = total_test_functions_added / total_functions_added
223317
224318 state .hygiene_summary = HygieneMetrics (
225- unlinked_issue_rate = unlinked_rate , average_pr_size = avg_pr_size , first_time_contributor_count = first_timers
319+ unlinked_issue_rate = unlinked_issue_rate ,
320+ average_pr_size = int (average_pr_size ),
321+ first_time_contributor_count = 0 , # Not available in GraphQL response
322+ issue_diff_mismatch_rate = issue_diff_mismatch_rate ,
323+ ghost_contributor_rate = 1.0 - min (engagement_rate / 5.0 , 1.0 ), # Inverse of engagement (normalized)
324+ new_code_test_coverage = new_code_test_coverage ,
325+ codeowner_bypass_rate = codeowner_bypass_rate ,
326+ ai_generated_rate = ai_generated_rate ,
226327 )
227328
329+ # Convert for legacy PRSignal compatibility
330+ pr_signals = []
331+ for pr in pr_nodes :
332+ pr_signals .append (
333+ PRSignal (
334+ pr_number = pr .get ("number" , 0 ),
335+ has_linked_issue = pr .get ("closingIssuesReferences" , {}).get ("totalCount" , 0 ) > 0 ,
336+ author_association = "UNKNOWN" , # Not available in GraphQL query
337+ is_ai_generated_hint = any (
338+ keyword in (pr .get ("body" ) or "" ).lower () + (pr .get ("title" ) or "" ).lower ()
339+ for keyword in ["generated by claude" , "cursor" , "copilot" , "chatgpt" , "ai-generated" , "llm" ]
340+ ),
341+ lines_changed = pr .get ("changedFiles" , 0 ),
342+ )
343+ )
344+ state .pr_signals = pr_signals
345+
228346 logger .info (
229347 "pr_signals_fetch_completed" ,
230348 repo = repo ,
231349 total_prs = total_prs ,
232- unlinked_rate = f"{ unlinked_rate :.2%} " ,
233- avg_size = avg_pr_size ,
234- first_timers = first_timers ,
350+ unlinked_rate = f"{ unlinked_issue_rate :.2%} " ,
351+ avg_size = int (average_pr_size ),
352+ engagement_rate = f"{ engagement_rate :.2f} " ,
353+ ai_rate = f"{ ai_generated_rate :.2%} " ,
235354 )
236355
237356 return state
238357
239- except httpx . HTTPStatusError as e :
240- logger .error (
241- "pr_signals_fetch_failed " ,
358+ except Exception as e :
359+ logger .warning (
360+ "pr_signals_graphql_fallback " ,
242361 repo = repo ,
243- status_code = e .response .status_code ,
244- error_type = "network_error" ,
245362 error = str (e ),
363+ message = "GraphQL failed, using safe defaults" ,
246364 )
247- # Set defaults on error
248- state .hygiene_summary = HygieneMetrics (
249- unlinked_issue_rate = 0.0 , average_pr_size = 0 , first_time_contributor_count = 0
250- )
251- return state
252- except Exception as e :
253- logger .error ("pr_signals_fetch_failed" , repo = repo , error_type = "unknown_error" , error = str (e ))
254- # Set defaults on error
365+ # Set defaults on error - DO NOT crash the node
255366 state .hygiene_summary = HygieneMetrics (
256- unlinked_issue_rate = 0.0 , average_pr_size = 0 , first_time_contributor_count = 0
367+ unlinked_issue_rate = 0.0 ,
368+ average_pr_size = 0 ,
369+ first_time_contributor_count = 0 ,
370+ issue_diff_mismatch_rate = 0.0 ,
371+ ghost_contributor_rate = 0.0 ,
372+ test_coverage_delta_avg = 0.0 ,
373+ codeowner_bypass_rate = 0.0 ,
374+ ai_generated_rate = 0.0 ,
257375 )
258376 return state
259377
0 commit comments