-
Notifications
You must be signed in to change notification settings - Fork 26
Expand file tree
/
Copy pathnodes.py
More file actions
706 lines (596 loc) · 30.3 KB
/
Copy pathnodes.py
File metadata and controls
706 lines (596 loc) · 30.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
import re
from typing import Any
import aiohttp
import openai
import pydantic
import structlog
from langchain_core.messages import HumanMessage, SystemMessage
from src.agents.repository_analysis_agent.models import AnalysisState, PRSignal, RuleRecommendation
from src.agents.repository_analysis_agent.prompts import REPOSITORY_ANALYSIS_SYSTEM_PROMPT, RULE_GENERATION_USER_PROMPT
from src.core.models import HygieneMetrics
from src.integrations.github.api import github_client
from src.integrations.providers.factory import get_chat_model
logger = structlog.get_logger()
AI_DETECTION_KEYWORDS = [
"generated by claude",
"cursor",
"copilot",
"assistant",
"ai-generated",
"auto-generated",
"generated code",
]
async def fetch_repository_metadata(state: AnalysisState) -> AnalysisState:
"""
Step 1: Fetch static repository metadata (file tree, languages, CI/CD presence, CODEOWNERS).
"""
repo = state.repo_full_name
logger.info("repository_metadata_fetch_started", repo=repo)
try:
# 1. List root directory to get file tree
files = await github_client.list_directory_any_auth(repo_full_name=repo, path="", user_token=state.user_token)
state.file_tree = [f.get("path", "") for f in files if isinstance(f, dict)]
# 2. Detect languages from file extensions
detected_languages = set()
for file_path in state.file_tree:
if "." in file_path:
ext = file_path.split(".")[-1].lower()
lang_map = {
"py": "Python",
"js": "JavaScript",
"ts": "TypeScript",
"java": "Java",
"go": "Go",
"rs": "Rust",
"cpp": "C++",
"c": "C",
"rb": "Ruby",
"php": "PHP",
"swift": "Swift",
"kt": "Kotlin",
"scala": "Scala",
"sh": "Shell",
"yaml": "YAML",
"yml": "YAML",
}
if ext in lang_map:
detected_languages.add(lang_map[ext])
state.detected_languages = list(detected_languages)
# 3. Check for CI/CD presence
workflow_files = await github_client.list_directory_any_auth(
repo_full_name=repo, path=".github/workflows", user_token=state.user_token
)
state.has_ci = len(workflow_files) > 0
# 4. Fetch Documentation Snippets (for Context)
readme_content = None
contributing_content = None
for target in ["README.md", "readme.md", "README.rst"]:
try:
content = await github_client.get_file_content(
repo_full_name=repo, file_path=target, installation_id=None, user_token=state.user_token
)
if content:
readme_content = content
break
except Exception:
continue
for copath in ["CONTRIBUTING.md", "contributing.md", "CONTRIBUTING.rst"]:
try:
content = await github_client.get_file_content(
repo_full_name=repo, file_path=copath, installation_id=None, user_token=state.user_token
)
if content:
contributing_content = content
break
except Exception:
continue
state.readme_content = readme_content
state.contributing_content = contributing_content
# 5. Check for CODEOWNERS and store content
state.has_codeowners = False
state.codeowners_content = None
codeowners_paths = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"]
for path in codeowners_paths:
try:
content = await github_client.get_file_content(
repo_full_name=repo, file_path=path, installation_id=None, user_token=state.user_token
)
if content:
state.has_codeowners = True
state.codeowners_content = content
break
except Exception:
continue
# 6. Analyze workflows for CI patterns
workflow_patterns = []
if state.has_ci:
for wf_file in workflow_files[:5]: # Limit to first 5 workflows
try:
content = await github_client.get_file_content(
repo_full_name=repo,
file_path=f".github/workflows/{wf_file.get('name', '')}",
installation_id=None,
user_token=state.user_token,
)
if content:
# Simple pattern detection
if "actions/checkout" in content:
workflow_patterns.append("actions/checkout")
if "pytest" in content or "test" in content.lower():
workflow_patterns.append("pytest")
if "deploy" in content.lower():
workflow_patterns.append("deploy")
except Exception:
continue
state.workflow_patterns = workflow_patterns
logger.info(
"repository_metadata_fetch_completed",
repo=repo,
file_count=len(state.file_tree),
detected_languages=state.detected_languages,
has_codeowners=state.has_codeowners,
workflow_patterns=state.workflow_patterns,
)
except aiohttp.ClientResponseError as e:
if e.status in [403, 404]:
logger.warning("repository_access_limited", repo=repo, status=e.status, message=e.message)
state.warnings.append(
f"Repository access limited (Status {e.status}). Analysis will be based on available signals only."
)
# Do not set state.error so analysis can proceed to fallback
else:
logger.error("repository_metadata_fetch_failed", repo=repo, error=str(e), exc_info=True)
state.error = f"Failed to fetch repository metadata: {str(e)}"
except Exception as e:
logger.error("repository_metadata_fetch_failed", repo=repo, error=str(e), exc_info=True)
state.error = f"Failed to fetch repository metadata: {str(e)}"
return state
async def fetch_pr_signals(state: AnalysisState) -> AnalysisState:
"""
Step 2: Fetch PR history and compute hygiene metrics (AI detection, unlinked issues, etc.).
"""
repo = state.repo_full_name
logger.info("pr_signals_fetch_started", repo=repo)
pr_nodes: list[dict[str, Any]] = []
pr_warning: str | None = None
try:
owner, repo_name = repo.split("/")
logger.info("debug_split_success", owner=owner, repo_name=repo_name)
logger.info("debug_client_type", client_type=str(type(github_client)))
# Use user_token if provided for authenticated requests (higher rate limits).
pr_nodes, pr_warning = await github_client.fetch_pr_hygiene_stats(
owner=owner, repo=repo_name, user_token=state.user_token, installation_id=None
)
logger.info("debug_pr_nodes_fetched", count=len(pr_nodes))
# Add warning if PR fetch had issues
if pr_warning:
state.warnings.append(pr_warning)
logger.warning("PR fetch warning", repo=repo, warning=pr_warning)
pr_signals: list[PRSignal] = []
total_unlinked = 0
total_size = 0
first_time_count = 0
ai_detected_count = 0
total_prs = len(pr_nodes)
for pr_node in pr_nodes:
# Check if PR is merged (mergedAt field exists)
merged_at = pr_node.get("mergedAt")
if not merged_at:
continue
pr_number = pr_node.get("number", 0)
body = pr_node.get("body", "") or ""
author_assoc = pr_node.get("authorAssociation", "NONE")
additions = pr_node.get("additions", 0)
deletions = pr_node.get("deletions", 0)
lines_changed = additions + deletions
# Check for linked issues via closingIssuesReferences
closing_issues = pr_node.get("closingIssuesReferences", {})
has_linked_issue = closing_issues.get("totalCount", 0) > 0 or (
"#" in body
and any(keyword in body.lower() for keyword in ["closes", "fixes", "resolves", "refs", "relates"])
)
if not has_linked_issue:
total_unlinked += 1
# AI Detection Heuristic: Check for common LLM tool signatures
is_ai_hint = any(keyword.lower() in body.lower() for keyword in AI_DETECTION_KEYWORDS)
if is_ai_hint:
ai_detected_count += 1
# First-time contributor detection via authorAssociation
# Using NONE here to unclude cases when a user is a first-time and doesn't have an association like in this issue: https://github.com/orgs/community/discussions/78038#discussioncomment-7831863
is_first_time = author_assoc in [
"FIRST_TIME_CONTRIBUTOR",
"FIRST_TIME_CONTRIBUTOR_ON_CREATE",
"FIRST_TIMER",
"NONE",
]
if is_first_time:
first_time_count += 1
total_size += lines_changed
pr_signal = PRSignal(
pr_number=pr_number,
has_linked_issue=has_linked_issue,
author_association=author_assoc,
is_ai_generated_hint=is_ai_hint,
lines_changed=lines_changed,
)
pr_signals.append(pr_signal)
state.pr_signals = pr_signals
# Calculate engagement_rate (proxy for ghost contributor) from comments
total_comments = sum(pr.get("comments", {}).get("totalCount", 0) for pr in pr_nodes if pr.get("mergedAt"))
engagement_rate = total_comments / total_prs if total_prs > 0 else 0.0
# AI detection heuristic
ai_rate = ai_detected_count / total_prs if total_prs > 0 else 0.0
# CI skip detection: check PR body and title for skip patterns
ci_skip_count = 0
for pr_node in pr_nodes:
if not pr_node.get("mergedAt"):
continue
body = (pr_node.get("body", "") or "").lower()
title = (pr_node.get("title", "") or "").lower()
skip_patterns = ["[skip ci]", "[ci skip]", "skip ci", "ci skip", "[skip checks]", "skip checks"]
if any(pattern in body or pattern in title for pattern in skip_patterns):
ci_skip_count += 1
ci_skip_rate = ci_skip_count / total_prs if total_prs > 0 else 0.0
# Test coverage: analyze test files in PR diffs
new_code_test_coverage = 0.0
if total_prs > 0:
test_file_count = 0
source_file_count = 0
for pr_node in pr_nodes:
if not pr_node.get("mergedAt"):
continue
files = pr_node.get("files", {}).get("edges", [])
for file_edge in files:
path = file_edge.get("node", {}).get("path", "")
if path:
if any(test_indicator in path.lower() for test_indicator in ["test", "spec", "__tests__"]):
test_file_count += 1
elif not any(ignore in path.lower() for ignore in [".md", ".txt", ".json", ".yaml", ".yml"]):
source_file_count += 1
if source_file_count > 0:
new_code_test_coverage = min(1.0, test_file_count / source_file_count)
# Codeowner bypass rate calculation
codeowner_bypass_rate = 0.0
if state.has_codeowners and state.codeowners_content and total_prs > 0:
from src.rules.utils.codeowners import CodeOwnersParser
try:
parser = CodeOwnersParser(state.codeowners_content)
bypassed_count = 0
prs_with_codeowner_requirements = 0
for pr_node in pr_nodes:
if not pr_node.get("mergedAt"):
continue
# Get changed files
files = pr_node.get("files", {}).get("edges", [])
changed_files = [f.get("node", {}).get("path", "") for f in files if f.get("node", {}).get("path")]
# Check if any changed file requires codeowners
requires_codeowner = False
required_owners = set()
for file_path in changed_files:
owners = parser.get_owners_for_file(file_path)
if owners:
requires_codeowner = True
required_owners.update(owners)
if not requires_codeowner:
continue # No codeowner requirement for this PR
prs_with_codeowner_requirements += 1
# Check if reviews include required codeowners
reviews = pr_node.get("reviews", {}).get("nodes", [])
review_approvers = set()
for review in reviews:
if review.get("state") == "APPROVED":
reviewer = review.get("author", {}).get("login", "")
if reviewer:
review_approvers.add(reviewer)
# Check if any required owner approved (normalize usernames - remove @ if present)
normalized_required = {owner.lstrip("@") for owner in required_owners}
normalized_approvers = {approver.lstrip("@") for approver in review_approvers}
has_owner_approval = bool(normalized_required & normalized_approvers)
if not has_owner_approval:
bypassed_count += 1
# Calculate rate only for PRs that have codeowner requirements
if prs_with_codeowner_requirements > 0:
codeowner_bypass_rate = bypassed_count / prs_with_codeowner_requirements
else:
codeowner_bypass_rate = 0.0 # No PRs with codeowner requirements
logger.info(
"codeowner_bypass_calculated",
total_prs=total_prs,
prs_with_requirements=prs_with_codeowner_requirements,
bypassed=bypassed_count,
bypass_rate=f"{codeowner_bypass_rate:.1%}",
)
except Exception as e:
logger.warning("codeowner_bypass_calculation_failed", error=str(e), exc_info=True)
codeowner_bypass_rate = 0.0 # Fallback to 0 if calculation fails
# Issue-diff mismatch detection (heuristic-based, full LLM comparison would be expensive)
# Detects cases where PR description/issue doesn't match actual code changes
issue_diff_mismatch_count = 0
if total_prs > 0:
for pr_node in pr_nodes:
if not pr_node.get("mergedAt"):
continue
body = (pr_node.get("body", "") or "").lower()
title = (pr_node.get("title", "") or "").lower()
files = pr_node.get("files", {}).get("edges", [])
changed_file_names = [
f.get("node", {}).get("path", "").lower() for f in files if f.get("node", {}).get("path")
]
# Heuristic 1: Check if PR mentions specific files/modules that aren't in changed files
if body or title:
# Extract file mentions from description
mentioned_files = []
text = body + " " + title
# Look for file patterns (e.g., "src/file.py", "file.ts", etc.)
file_pattern = r"\b[\w/]+\.(py|ts|js|go|rs|java|rb|php|cpp|c|h|swift|kt|scala)\b"
matches = re.findall(file_pattern, text)
mentioned_files.extend([m[0] if isinstance(m, tuple) else m for m in matches])
# Heuristic 2: Check if PR has linked issue but description is generic/unrelated
closing_issues = pr_node.get("closingIssuesReferences", {})
has_linked_issue = closing_issues.get("totalCount", 0) > 0
# If PR has linked issue but description is very short/generic, potential mismatch
if has_linked_issue and len(body.strip()) < 50:
issue_diff_mismatch_count += 1
continue
# If specific files mentioned but don't match changed files, potential mismatch
if mentioned_files and changed_file_names:
# Check if any mentioned file matches changed files
matches_changed = any(
any(
mf in cf or cf in mf or mf.split("/")[-1] == cf.split("/")[-1]
for cf in changed_file_names
)
for mf in mentioned_files
)
if not matches_changed:
issue_diff_mismatch_count += 1
issue_diff_mismatch_rate = issue_diff_mismatch_count / total_prs if total_prs > 0 else 0.0
unlinked_rate = total_unlinked / total_prs if total_prs > 0 else 0.0
avg_size = total_size / total_prs if total_prs > 0 else 0
ghost_contributor_rate = max(0.0, 1.0 - engagement_rate) if total_prs > 0 else 0.0
# Convert for legacy PRSignal compatibility
hygiene_metrics = HygieneMetrics(
unlinked_issue_rate=unlinked_rate,
average_pr_size=int(avg_size),
first_time_contributor_count=first_time_count,
ci_skip_rate=ci_skip_rate,
codeowner_bypass_rate=codeowner_bypass_rate,
new_code_test_coverage=new_code_test_coverage,
issue_diff_mismatch_rate=issue_diff_mismatch_rate,
ghost_contributor_rate=ghost_contributor_rate,
ai_generated_rate=ai_rate,
)
state.hygiene_summary = hygiene_metrics
# Add warning if no PRs were fetched
if total_prs == 0 and not pr_warning:
state.warnings.append("No pull requests found in repository. Metrics may be incomplete.")
logger.info(
"pr_signals_fetch_completed",
repo=repo,
total_prs=total_prs,
unlinked_rate=f"{unlinked_rate:.2%}",
avg_size=int(avg_size),
engagement_rate=f"{engagement_rate:.2f}",
ai_rate=f"{ai_rate:.2%}",
)
except Exception as e:
logger.error("pr_signals_fetch_failed", repo=repo, error=str(e), exc_info=True)
error_msg = str(e)
state.error = f"Failed to fetch PR signals: {error_msg}"
# Check if it's a rate limit error
if "rate limit" in error_msg.lower() or "403" in error_msg:
if not state.user_token:
state.warnings.append(
"GitHub API rate limit exceeded. Unable to fetch PR data. "
"Add a GitHub Personal Access Token for higher rate limits (5,000/hr vs 60/hr)."
)
else:
state.warnings.append(f"GitHub API rate limit exceeded: {error_msg}")
else:
state.warnings.append(f"Failed to fetch PR data: {error_msg}")
# Set default empty metrics on error
state.hygiene_summary = HygieneMetrics()
return state
async def generate_rule_recommendations(state: AnalysisState) -> AnalysisState:
"""
Step 4: Generate governance rules based on the analysis report.
Rules are the prescription to address problems identified in the report.
"""
repo_name = state.repo_full_name or "unknown/repo"
logger.info("rule_generation_started", repo=repo_name, agent="repo_analysis")
languages = state.detected_languages
has_ci = state.has_ci
has_codeowners = state.has_codeowners
file_tree = state.file_tree
readme_content = state.readme_content or ""
workflow_patterns = state.workflow_patterns
hygiene_summary = state.hygiene_summary
# Format hygiene summary for LLM with issue context
if hygiene_summary:
ai_rate_text = (
f"{hygiene_summary.ai_generated_rate:.1%}" if hygiene_summary.ai_generated_rate is not None else "N/A"
)
hygiene_text = f"""- Unlinked Issue Rate: {hygiene_summary.unlinked_issue_rate:.1%} ({int(hygiene_summary.unlinked_issue_rate * 100)}% of PRs lack issue references)
- Average PR Size: {hygiene_summary.average_pr_size} lines (unreviewable if >500)
- First-Time Contributors: {hygiene_summary.first_time_contributor_count} in last 30 PRs
- CI Skip Rate: {hygiene_summary.ci_skip_rate:.1%} (workflows bypassed)
- Codeowner Bypass Rate: {hygiene_summary.codeowner_bypass_rate:.1%} (reviews bypassed)
- New Code Test Coverage: {hygiene_summary.new_code_test_coverage:.1%} (tests missing)
- Issue-Diff Mismatch Rate: {hygiene_summary.issue_diff_mismatch_rate:.1%} (description vs code mismatch)
- Ghost Contributor Rate: {hygiene_summary.ghost_contributor_rate:.1%} (no review engagement)
- AI Generated Rate: {ai_rate_text} (low-signal PRs)"""
else:
hygiene_text = "- No PR history available (new repository or no merged PRs)"
# Include analysis report in context if available
report_context = ""
if state.analysis_report:
report_context = f"\n\n**Analysis Report (Problems Identified):**\n{state.analysis_report}\n"
# Get actual validator catalog dynamically
from src.rules.registry import AVAILABLE_CONDITIONS
validator_catalog = []
for condition_cls in AVAILABLE_CONDITIONS:
validator_catalog.append(
f"- {condition_cls.name}: {condition_cls.description} (events: {', '.join(condition_cls.event_types)})"
)
validator_catalog_text = "\n".join(validator_catalog)
# 1. Construct Prompt with all available signals and analysis report
user_prompt = (
RULE_GENERATION_USER_PROMPT.format(
repo_name=repo_name,
languages=", ".join(languages) if languages else "Unknown",
has_ci=str(has_ci),
has_codeowners=str(has_codeowners),
file_count=len(file_tree),
workflow_patterns=", ".join(workflow_patterns) if workflow_patterns else "None detected",
hygiene_summary=hygiene_text,
validator_catalog=validator_catalog_text,
file_tree_snippet="\n".join(file_tree[:25]),
docs_snippet=readme_content[:1000],
)
+ report_context
)
# 2. Initialize LLM with temperature tuning for reasoning
try:
llm = get_chat_model(agent="repository_analysis", temperature=0.4)
class RecommendationsList(pydantic.BaseModel):
recommendations: list[RuleRecommendation]
structured_llm = llm.with_structured_output(RecommendationsList, method="function_calling")
# Format system prompt with validator catalog
system_prompt = REPOSITORY_ANALYSIS_SYSTEM_PROMPT.format(validator_catalog=validator_catalog_text)
response = await structured_llm.ainvoke(
[SystemMessage(content=system_prompt), HumanMessage(content=user_prompt)]
)
valid_recs = response.recommendations
logger.info("rule_generation_succeeded", repo=repo_name, recommendation_count=len(valid_recs))
state.recommendations = valid_recs
return state
except openai.OpenAIError as e:
logger.error("rule_generation_failed", repo=repo_name, error=str(e), error_type="llm_provider_error")
fallback_reason = f"AI provider error: {e.__class__.__name__}"
except pydantic.ValidationError as e:
logger.error(
"rule_generation_failed",
repo=repo_name,
error=str(e),
error_type="schema_mismatch",
error_details=str(e.errors()) if hasattr(e, "errors") else None,
exc_info=True,
)
fallback_reason = f"AI model returned data in an unexpected format: {str(e)}"
except Exception as e:
logger.error("rule_generation_failed", repo=repo_name, error=str(e), error_type="unknown_error", exc_info=True)
fallback_reason = f"An unexpected error occurred: {str(e)}"
# Fallback for any of the caught exceptions
fallback_rule = RuleRecommendation(
key="fallback-rule",
name="Fallback Rule",
category="General",
description="AI analysis could not complete. Please review repository manually.",
enabled=False,
severity="low",
event_types=["pull_request"],
parameters={},
reasoning=fallback_reason,
)
state.recommendations = [fallback_rule]
state.error = fallback_reason
return state
async def generate_rule_reasonings(state: AnalysisState) -> AnalysisState:
"""
Step 5: Generate agentic reasoning for each recommended rule.
Explains why each rule was prescribed based on the analysis report and hygiene metrics.
"""
repo_name = state.repo_full_name or "unknown/repo"
logger.info("rule_reasoning_generation_started", repo=repo_name)
if not state.recommendations or not state.hygiene_summary:
logger.warning("rule_reasoning_skipped", reason="no_recommendations_or_hygiene_summary")
return state
try:
llm = get_chat_model(agent="repository_analysis", temperature=0.3)
class RuleReasoning(pydantic.BaseModel):
reasoning: str = pydantic.Field(..., description="Concise explanation of why this rule was recommended")
structured_llm = llm.with_structured_output(RuleReasoning, method="function_calling")
# Use analysis report as primary context (the diagnosis)
report_context = state.analysis_report or "No analysis report available."
reasonings = {}
for rec in state.recommendations:
prompt = f"""Explain why this governance rule was recommended to address problems identified in the repository analysis.
**Analysis Report (Problems Identified):**
{report_context}
**Recommended Rule:**
- Description: {rec.description}
- Severity: {rec.severity}
- Event Types: {rec.event_types}
- Parameters: {rec.parameters}
Provide a concise, evidence-based explanation (1-2 sentences) connecting the rule to specific problems identified in the analysis report."""
response = await structured_llm.ainvoke(
[
SystemMessage(
content="You are an expert at explaining governance rule recommendations based on repository analysis data."
),
HumanMessage(content=prompt),
]
)
reasonings[rec.description] = response.reasoning
state.rule_reasonings = reasonings
logger.info("rule_reasoning_generation_succeeded", repo=repo_name, count=len(reasonings))
except Exception as e:
logger.error("rule_reasoning_generation_failed", repo=repo_name, error=str(e), exc_info=True)
# Continue without reasonings rather than failing
return state
async def generate_analysis_report(state: AnalysisState) -> AnalysisState:
"""
Step 3: Generate agentic analysis report markdown from hygiene metrics.
This is the diagnosis - identifies problems and risks in the repository.
"""
repo_name = state.repo_full_name or "unknown/repo"
logger.info("analysis_report_generation_started", repo=repo_name)
if not state.hygiene_summary:
logger.warning("analysis_report_skipped", reason="no_hygiene_summary")
state.analysis_report = "## Repository Analysis\n\nNo analysis data available."
return state
try:
llm = get_chat_model(agent="repository_analysis", temperature=0.2)
class AnalysisReport(pydantic.BaseModel):
report: str = pydantic.Field(..., description="Professional markdown report of repository analysis")
structured_llm = llm.with_structured_output(AnalysisReport, method="function_calling")
hygiene_summary = state.hygiene_summary
if not hygiene_summary:
state.analysis_report = "## Repository Analysis\n\nNo analysis data available."
return state
ai_rate_text = (
f"{hygiene_summary.ai_generated_rate:.1%}" if hygiene_summary.ai_generated_rate is not None else "N/A"
)
prompt = f"""Analyze repository health and generate markdown report identifying problems.
Repository: {repo_name}
Hygiene Metrics:
- Unlinked Issue Rate: {hygiene_summary.unlinked_issue_rate:.1%} (PRs without issue references)
- Average PR Size: {hygiene_summary.average_pr_size} lines (unreviewable if >500)
- First-Time Contributors: {hygiene_summary.first_time_contributor_count}
- CI Skip Rate: {hygiene_summary.ci_skip_rate:.1%} (workflows bypassed)
- Codeowner Bypass Rate: {hygiene_summary.codeowner_bypass_rate:.1%} (reviews bypassed)
- New Code Test Coverage: {hygiene_summary.new_code_test_coverage:.1%} (tests missing)
- Issue-Diff Mismatch Rate: {hygiene_summary.issue_diff_mismatch_rate:.1%} (description vs code mismatch)
- Ghost Contributor Rate: {hygiene_summary.ghost_contributor_rate:.1%} (no review engagement)
- AI Generated Rate: {ai_rate_text} (low-signal PRs)
Context:
- Languages: {", ".join(state.detected_languages) if state.detected_languages else "Unknown"}
- Has CI/CD: {state.has_ci}
- Has CODEOWNERS: {state.has_codeowners}
Generate report with table: | Metric | Value | Severity | Category | Explanation |
Focus on actionable problems that can be addressed with governance rules."""
response = await structured_llm.ainvoke(
[
SystemMessage(
content="You are an expert at creating professional repository analysis reports. Focus on data-driven insights and actionable recommendations."
),
HumanMessage(content=prompt),
]
)
state.analysis_report = response.report
logger.info("analysis_report_generation_succeeded", repo=repo_name)
except Exception as e:
logger.error("analysis_report_generation_failed", repo=repo_name, error=str(e), exc_info=True)
# Fallback to basic report
state.analysis_report = "## Repository Analysis\n\nAnalysis report generation failed."
return state