Skip to content

Commit c522807

Browse files
committed
feat: expose review alert endpoints and team-aware analytics
1 parent 3efe0aa commit c522807

File tree

10 files changed

+225
-2
lines changed

10 files changed

+225
-2
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ Copy `.env.example` to `.env` and adjust values locally if you prefer dotenv-sty
8484
| `PROVENANCE_GITHUB_AGENT_LABEL_PREFIX` | PR label prefix used to infer agent IDs | `agent:` |
8585
| `PROVENANCE_GITHUB_CACHE_TTL_SECONDS` | Cache TTL (seconds) for GitHub metadata lookups | `300` |
8686
| `PROVENANCE_GITHUB_AGENT_MAP` | JSON map of GitHub logins/keywords to agent IDs | `{}` |
87+
| `PROVENANCE_GITHUB_REVIEWER_TEAM_MAP` | JSON map of reviewer logins to team names for cohort reporting | `{}` |
8788

8889
## Detection with Semgrep
8990

@@ -150,6 +151,8 @@ Example ingestion payload:
150151
- `/v1/analytics/summary` now surfaces GitHub-aware metrics alongside the existing risk/volume suite: `code_volume`, `code_churn_rate`, `avg_line_complexity`, `agent_response_rate`, `agent_response_p50_hours`, `agent_response_p90_hours`, `reopened_threads`, `force_push_events`, `rewrite_loops`, `human_followup_commits`, `human_followup_fast`, `ci_time_to_green_hours`, `ci_failed_checks`, `agent_commit_ratio`, `commit_lead_time_hours`, `force_push_after_approval`, `human_reviewer_count`, `avg_human_reviewers`, `avg_unique_reviewers`, `bot_review_events`, `bot_block_events`, `bot_block_overrides`, `bot_block_resolved`, `bot_reviewer_count`, `bot_informational_only_reviewer_count`, `bot_comment_count`, and `classification_<label>_count` (e.g., `classification_security_count`).
151152
- `/v1/analytics/agents/behavior` returns composite snapshots that now blend code/finding metrics with review conversation health (thread counts, response latency, classification breakdowns), CI friction (failures, time-to-green), commit dynamics (force pushes, rewrite loops, human follow-ups), and attention heatmaps (top paths + hot files) per agent.
152153
- Snapshots also include reviewer cohort context (`human_reviewer_count`, association breakdowns), bot review behavior (`bot_block_events`, `bot_block_overrides`), provenance anomalies (`force_push_after_approval_count`), and CI failure taxonomy (failing check names and contexts) to highlight operational hotspots.
154+
- `/v1/analytics/review-alerts` highlights agents/analyses where bot change-requests were overridden or force-pushes occurred post-approval.
155+
- `/v1/analytics/review-load` reports human vs. bot review load per agent, while `/v1/analytics/review-load/teams` aggregates human reviewer effort by the configured team map.
153156
- Review-focused metrics (`review_comments`, `unique_reviewers`, `review_events`, `agent_comment_mentions`) continue to leverage GitHub PR data when credentials are supplied; classification metrics reflect the resolver's heuristic labeling of each conversation snippet.
154157
- Use `PROVENANCE_ANALYTICS_DEFAULT_WINDOW` or query parameters such as `?time_window=14d` to track longer horizons and compare agents.
155158

app/core/config.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ class Settings(BaseSettings):
4141
github_agent_label_prefix: str = "agent:"
4242
github_cache_ttl_seconds: int = 300
4343
github_agent_map: dict[str, str] = Field(default_factory=dict)
44+
github_reviewer_team_map: dict[str, str] = Field(default_factory=dict)
4445

4546
model_config = SettingsConfigDict(env_prefix="provenance_", env_file=".env", extra="ignore")
4647

app/dependencies.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ def get_github_resolver() -> GitHubProvenanceResolver | None:
5656
agent_label_prefix=settings.github_agent_label_prefix,
5757
cache_ttl_seconds=settings.github_cache_ttl_seconds,
5858
agent_map=settings.github_agent_map,
59+
reviewer_team_map=settings.github_reviewer_team_map,
5960
)
6061

6162

app/models/analytics.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ class AgentBehaviorSnapshot(BaseModel):
7070
reviewer_association_breakdown: dict[str, int] = Field(
7171
default_factory=dict, description="Reviewer participation by GitHub association (member, contributor, etc.)."
7272
)
73+
human_reviewer_teams: dict[str, int] = Field(
74+
default_factory=dict, description="Human reviewer counts grouped by mapped team.")
7375
bot_review_events: int = Field(0, description="Total bot-authored review submissions.")
7476
bot_block_events: int = Field(0, description="Bot reviews that requested changes.")
7577
bot_informational_events: int = Field(0, description="Bot reviews that left non-blocking feedback.")

app/provenance/github_resolver.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ def __init__(
6565
agent_label_prefix: str = "agent:",
6666
cache_ttl_seconds: int = 300,
6767
agent_map: dict[str, str] | None = None,
68+
reviewer_team_map: dict[str, str] | None = None,
6869
) -> None:
6970
self._agent_label_prefix = agent_label_prefix.lower()
7071
auth = Token(token)
@@ -74,6 +75,7 @@ def __init__(
7475
self._client = Github(auth=auth)
7576
self._cache_ttl = max(cache_ttl_seconds, 30)
7677
self._agent_map = {k.lower(): v for k, v in (agent_map or {}).items()}
78+
self._reviewer_team_map = {k.lower(): v for k, v in (reviewer_team_map or {}).items()}
7779
self._commit_cache: dict[tuple[str, str], tuple[float, Optional[Commit.Commit]]] = {}
7880
self._label_cache: dict[tuple[str, int], tuple[float, list[str]]] = {}
7981
self._comment_cache: dict[tuple[str, int], tuple[float, list[str]]] = {}
@@ -176,6 +178,7 @@ def collect_pr_metadata(
176178
bot_blocking_reviewers: set[str] = set()
177179
bot_block_overrides = 0
178180
bot_block_resolved = 0
181+
override_details: list[dict] = []
179182
for review_entry in reviews_list:
180183
if not review_entry.get("is_bot"):
181184
continue
@@ -198,16 +201,28 @@ def collect_pr_metadata(
198201
if later_entry.get("state") in {"APPROVED", "DISMISSED"}:
199202
resolved = True
200203
break
204+
detail = {
205+
"bot": login,
206+
"submitted_at": review_entry.get("submitted_at"),
207+
"state": review_entry.get("state"),
208+
"merge_actor": timeline_summary.get("last_merge_actor"),
209+
"merged_at": timeline_summary.get("last_merge_at"),
210+
}
201211
if resolved:
202212
bot_block_resolved += 1
213+
detail["resolved"] = True
203214
elif merged_at:
204215
bot_block_overrides += 1
216+
detail["resolved"] = False
217+
override_details.append(detail)
205218

206219
conversation_summary["bot_reviewer_count"] = len(bot_reviewers)
207220
conversation_summary["bot_blocking_reviewer_count"] = len(bot_blocking_reviewers)
208221
conversation_summary["bot_informational_only_reviewer_count"] = len(bot_reviewers - bot_blocking_reviewers)
209222
conversation_summary["bot_block_overrides"] = bot_block_overrides
210223
conversation_summary["bot_block_resolved"] = bot_block_resolved
224+
if override_details:
225+
conversation_summary["bot_block_override_details"] = override_details
211226

212227
commit_summary = self._summarize_commits(
213228
pr,
@@ -468,6 +483,17 @@ def _build_conversation_snapshot(self, pr, agent_logins: set[str]) -> dict:
468483
reviewer_identities = self._merge_identities(
469484
comments_info.reviewer_identities, reviews_info.reviewer_identities
470485
)
486+
human_team_counts = defaultdict(int)
487+
for profile in reviewer_identities.values():
488+
if profile.get("is_bot"):
489+
continue
490+
login = profile.get("login")
491+
team = profile.get("team")
492+
if not team and login:
493+
team = self._reviewer_team_map.get(login.lower())
494+
if team:
495+
human_team_counts[team] += 1
496+
profile["team"] = team
471497

472498
created_at = getattr(pr, "created_at", None)
473499
merged_at = getattr(pr, "merged_at", None)
@@ -489,6 +515,7 @@ def _build_conversation_snapshot(self, pr, agent_logins: set[str]) -> dict:
489515
"bot_block_events": reviews_info.bot_block_events,
490516
"bot_informational_events": reviews_info.bot_informational_events,
491517
"bot_approval_events": reviews_info.bot_approval_events,
518+
"human_reviewer_teams": dict(human_team_counts),
492519
}
493520

494521
if thread_metrics["response_latencies"]:
@@ -768,6 +795,13 @@ def _extract_user_profile(self, source_obj, login: str) -> dict:
768795
association = getattr(source_obj, "author_association", None)
769796
profile["association"] = association
770797
profile["is_bot"] = self._is_bot_login(login) or (profile.get("type") or "").lower() == "bot"
798+
team = None
799+
if login:
800+
team = self._reviewer_team_map.get(login.lower())
801+
if not team and profile.get("company"):
802+
team = profile.get("company")
803+
if team:
804+
profile["team"] = team
771805
return profile
772806

773807
@staticmethod
@@ -859,6 +893,7 @@ def _collect_timeline(self, repo_full_name: str, pr_number: int, pr=None) -> dic
859893
data["summary"]["last_reopen_at"] = created_iso
860894
elif event_type == "merged":
861895
data["summary"]["last_merge_at"] = created_iso
896+
data["summary"]["last_merge_actor"] = getattr(getattr(item, "actor", None), "login", None)
862897
elif event_type == "review_requested":
863898
data["summary"]["last_review_request_at"] = created_iso
864899
elif event_type == "ready_for_review" and data["summary"]["ready_for_review_at"] is None:
@@ -876,6 +911,7 @@ def _collect_timeline(self, repo_full_name: str, pr_number: int, pr=None) -> dic
876911
"last_force_push_at": data["summary"]["last_force_push_at"],
877912
"last_reopen_at": data["summary"]["last_reopen_at"],
878913
"last_merge_at": data["summary"]["last_merge_at"],
914+
"last_merge_actor": data["summary"].get("last_merge_actor"),
879915
"last_review_request_at": data["summary"]["last_review_request_at"],
880916
"ready_for_review_at": data["summary"]["ready_for_review_at"],
881917
"converted_to_draft_at": data["summary"]["converted_to_draft_at"],

app/routers/analytics.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,13 @@
88

99
from app.core.config import settings
1010
from app.dependencies import get_analytics_service
11-
from app.schemas.analytics import AnalyticsSummaryResponse, AgentBehaviorResponse
11+
from app.schemas.analytics import (
12+
AnalyticsSummaryResponse,
13+
AgentBehaviorResponse,
14+
ReviewAlertResponse,
15+
ReviewLoadResponse,
16+
TeamReviewLoadResponse,
17+
)
1218
from app.services.analytics import AnalyticsService
1319

1420

@@ -59,3 +65,31 @@ def get_agent_behavior_summary(
5965
except ValueError as exc:
6066
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
6167
return AgentBehaviorResponse(report=report, request_id=f"rq_{uuid.uuid4().hex}")
68+
69+
70+
@router.get("/review-alerts", response_model=ReviewAlertResponse)
71+
def get_review_alerts(
72+
time_window: str = Query(..., description="Duration string such as 7d or 24h."),
73+
threshold: int = Query(1, ge=1, description="Minimum override/force-push count to surface."),
74+
analytics_service: AnalyticsService = Depends(get_analytics_service),
75+
) -> ReviewAlertResponse:
76+
alerts = analytics_service.detect_review_alerts(time_window=time_window, threshold=threshold)
77+
return ReviewAlertResponse(alerts=alerts, request_id=f"rq_{uuid.uuid4().hex}")
78+
79+
80+
@router.get("/review-load", response_model=ReviewLoadResponse)
81+
def get_review_load(
82+
time_window: str = Query(..., description="Duration string such as 7d or 24h."),
83+
analytics_service: AnalyticsService = Depends(get_analytics_service),
84+
) -> ReviewLoadResponse:
85+
load = analytics_service.human_vs_bot_load(time_window=time_window)
86+
return ReviewLoadResponse(load=load, request_id=f"rq_{uuid.uuid4().hex}")
87+
88+
89+
@router.get("/review-load/teams", response_model=TeamReviewLoadResponse)
90+
def get_team_review_load(
91+
time_window: str = Query(..., description="Duration string such as 7d or 24h."),
92+
analytics_service: AnalyticsService = Depends(get_analytics_service),
93+
) -> TeamReviewLoadResponse:
94+
load = analytics_service.team_review_load(time_window=time_window)
95+
return TeamReviewLoadResponse(load=load, request_id=f"rq_{uuid.uuid4().hex}")

app/schemas/analytics.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,42 @@ class AnalyticsQueryParams(BaseModel):
2929
group_by: str = Field(..., description="Grouping dimension, e.g. agent_id.")
3030
category: str | None = Field(None, description="Optional filter for rule category.")
3131
agent_id: str | None = Field(None, description="Optional filter for specific agent.")
32+
33+
34+
class ReviewAlert(BaseModel):
35+
agent_id: str
36+
bot_block_overrides: int
37+
force_push_after_approval: int
38+
human_reviewer_count: int
39+
bot_block_events: int
40+
merge_actor: str | None = None
41+
merged_at: str | None = None
42+
override_details: list[dict] = Field(default_factory=list)
43+
44+
45+
class ReviewAlertResponse(BaseModel):
46+
alerts: list[ReviewAlert]
47+
request_id: str
48+
49+
50+
class ReviewLoadEntry(BaseModel):
51+
agent_id: str
52+
human_reviewers: int
53+
bot_reviews: int
54+
bot_block_events: int
55+
human_reviewer_teams: dict[str, int] = Field(default_factory=dict)
56+
57+
58+
class ReviewLoadResponse(BaseModel):
59+
load: list[ReviewLoadEntry]
60+
request_id: str
61+
62+
63+
class TeamReviewLoadEntry(BaseModel):
64+
team: str
65+
human_reviewers: int
66+
67+
68+
class TeamReviewLoadResponse(BaseModel):
69+
load: list[TeamReviewLoadEntry]
70+
request_id: str

0 commit comments

Comments
 (0)