Skip to content

Commit 963abb2

Browse files
committed
feat: Enhance repository analysis with new hygiene metrics and API response structure
1 parent 02276f3 commit 963abb2

27 files changed

Lines changed: 840 additions & 111 deletions

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ dependencies = [
2727
"langchain-google-vertexai>=2.1.2",
2828
"giturlparse>=0.1.0",
2929
"structlog>=24.1.0",
30+
"gql[all]>=3.4.0",
3031
]
3132

3233
[project.optional-dependencies]

src/agents/base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,13 +90,13 @@ async def _invoke_structured() -> T:
9090
exceptions=(Exception,),
9191
)
9292

93-
async def _execute_with_timeout(self, coro, timeout: float = 30.0):
93+
async def _execute_with_timeout(self, coro, timeout: float = 60.0):
9494
"""
9595
Execute a coroutine with timeout handling.
9696
9797
Args:
9898
coro: The coroutine to execute
99-
timeout: Timeout in seconds
99+
timeout: Timeout in seconds (default: 60s for showcase stability)
100100
101101
Returns:
102102
The result of the coroutine

src/agents/repository_analysis_agent/__init__.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,27 @@
66
"""
77

88
from src.agents.repository_analysis_agent.agent import RepositoryAnalysisAgent
9+
from src.agents.repository_analysis_agent.models import (
10+
AnalysisState,
11+
HygieneMetrics,
12+
PRSignal,
13+
RepoMetadata,
14+
RepositoryAnalysisRequest,
15+
RepositoryAnalysisResponse,
16+
RepositoryFeatures,
17+
RuleRecommendation,
18+
parse_github_repo_identifier,
19+
)
920

10-
__all__ = ["RepositoryAnalysisAgent"]
21+
__all__ = [
22+
"RepositoryAnalysisAgent",
23+
"AnalysisState",
24+
"HygieneMetrics",
25+
"PRSignal",
26+
"RepoMetadata",
27+
"RepositoryAnalysisRequest",
28+
"RepositoryAnalysisResponse",
29+
"RepositoryFeatures",
30+
"RuleRecommendation",
31+
"parse_github_repo_identifier",
32+
]

src/agents/repository_analysis_agent/models.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,30 @@ class HygieneMetrics(BaseModel):
139139
first_time_contributor_count: int = Field(
140140
..., description="Count of unique first-time contributors in recent PRs (risk indicator)."
141141
)
142+
ci_skip_rate: float = Field(
143+
default=0.0,
144+
description="Percentage (0.0-1.0) of PRs that skip CI checks via commit message.",
145+
)
146+
codeowner_bypass_rate: float = Field(
147+
default=0.0,
148+
description="Percentage (0.0-1.0) of PRs merged without required CODEOWNER approval. Detects governance violations.",
149+
)
150+
new_code_test_coverage: float = Field(
151+
default=0.0,
152+
description="Average ratio of test line additions relative to source code changes. Low values indicate untested contributions.",
153+
)
154+
issue_diff_mismatch_rate: float = Field(
155+
default=0.0,
156+
description="Percentage (0.0-1.0) of PRs where the linked issue doesn't semantically match the code diff. Detects low-effort contributions claiming to fix unrelated issues.",
157+
)
158+
ghost_contributor_rate: float = Field(
159+
default=0.0,
160+
description="Percentage (0.0-1.0) of PRs where the author never responded to review comments. Indicates drive-by contributions with no engagement.",
161+
)
162+
ai_generated_rate: float | None = Field(
163+
default=None,
164+
description="Percentage (0.0-1.0) of PRs flagged as AI-generated based on heuristic signatures (e.g., 'generated by Claude', 'Cursor'). Detects bulk AI spam.",
165+
)
142166

143167

144168
class AnalysisState(BaseModel):

src/agents/repository_analysis_agent/nodes.py

Lines changed: 156 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -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

src/api/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,10 @@
11
# API endpoints package
2+
3+
from src.api.recommendations import AnalysisResponse, AnalyzeRepoRequest, parse_repo_from_url, router
4+
5+
__all__ = [
6+
"AnalyzeRepoRequest",
7+
"AnalysisResponse",
8+
"parse_repo_from_url",
9+
"router",
10+
]

src/api/recommendations.py

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import logging
2+
from typing import Any
23

34
from fastapi import APIRouter, Depends, HTTPException, Request, status
45
from giturlparse import parse
56
from pydantic import BaseModel, Field, HttpUrl
67

78
from src.agents.repository_analysis_agent.agent import RepositoryAnalysisAgent
8-
from src.agents.repository_analysis_agent.models import RuleRecommendation
99
from src.api.dependencies import get_current_user_optional
1010
from src.api.rate_limit import rate_limiter
1111

@@ -35,9 +35,9 @@ class AnalysisResponse(BaseModel):
3535
Standardized response for the frontend.
3636
"""
3737

38-
repository: str
39-
is_public: bool
40-
recommendations: list[RuleRecommendation]
38+
rules_yaml: str
39+
pr_plan: str
40+
analysis_summary: dict[str, Any]
4141

4242

4343
# --- Helpers --- # Utility—URL parsing brittle if GitHub changes format.
@@ -130,13 +130,32 @@ async def recommend_rules(
130130
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Analysis failed: {result.message}"
131131
)
132132

133-
# Step 5: Success—extract recommendations, return API response.
134-
recommendations = result.data.get("recommendations", [])
133+
# Step 5: Success—map agent state to the API response model.
134+
final_state = result.data # The agent's execute method returns the final state
135+
136+
# Generate rules_yaml from recommendations
137+
import yaml
138+
139+
rules_output = {"rules": [rec.model_dump(exclude_none=True) for rec in final_state.get("recommendations", [])]}
140+
rules_yaml = yaml.dump(rules_output, indent=2, sort_keys=False)
141+
142+
# Generate a markdown plan for the PR
143+
pr_plan_lines = ["### Watchflow: Automated Governance Plan\n"]
144+
for rec in final_state.get("recommendations", []):
145+
pr_plan_lines.append(f"- **Rule:** `{rec.name}` (`{rec.key}`)")
146+
pr_plan_lines.append(f" - **Reasoning:** {rec.reasoning}")
147+
pr_plan = "\n".join(pr_plan_lines)
148+
149+
# Populate the analysis summary from hygiene metrics
150+
analysis_summary = {}
151+
hygiene_summary = final_state.get("hygiene_summary")
152+
if hygiene_summary:
153+
analysis_summary = hygiene_summary.model_dump()
135154

136155
return AnalysisResponse(
137-
repository=repo_full_name,
138-
is_public=True, # Phase 1: always public—future: support private with token
139-
recommendations=recommendations,
156+
rules_yaml=rules_yaml,
157+
pr_plan=pr_plan,
158+
analysis_summary=analysis_summary,
140159
)
141160

142161

0 commit comments

Comments
 (0)