-
Notifications
You must be signed in to change notification settings - Fork 26
Expand file tree
/
Copy pathenricher.py
More file actions
226 lines (193 loc) · 9.38 KB
/
Copy pathenricher.py
File metadata and controls
226 lines (193 loc) · 9.38 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
import logging
from typing import Any
from src.core.models import Acknowledgment
from src.rules.acknowledgment import (
is_acknowledgment_comment,
parse_acknowledgment_comment,
)
logger = logging.getLogger(__name__)
class PullRequestEnricher:
"""
Handles data fetching and enrichment for pull request processing.
Delegates GitHub API calls and returns structured data.
"""
def __init__(self, github_client: Any):
self.github_client = github_client
async def fetch_api_data(self, repo_full_name: str, pr_number: int, installation_id: int) -> dict[str, Any]:
"""Fetch supplementary data not available in the webhook payload."""
api_data = {}
try:
# Fetch reviews
reviews = await self.github_client.get_pull_request_reviews(repo_full_name, pr_number, installation_id)
api_data["reviews"] = reviews or []
# Fetch review threads using GraphQL
if hasattr(self.github_client, "get_pull_request_review_threads"):
threads = await self.github_client.get_pull_request_review_threads(
repo_full_name, pr_number, installation_id
)
api_data["review_threads"] = threads or []
else:
api_data["review_threads"] = []
# Fetch files
files = await self.github_client.get_pull_request_files(repo_full_name, pr_number, installation_id)
api_data["files"] = files or []
except Exception as e:
logger.error(f"Error fetching API data for PR #{pr_number}: {e}")
return api_data
async def enrich_event_data(self, task: Any, github_token: str) -> dict[str, Any]:
"""Prepare enriched event data for rule evaluation agents."""
if not task or not hasattr(task, "payload") or not task.payload:
return {}
pr_data = task.payload.get("pull_request", {}) or {}
pr_number = pr_data.get("number")
repo_full_name = getattr(task, "repo_full_name", "")
installation_id = getattr(task, "installation_id", 0)
# the current state, not the stale webhook snapshot (webhooks for
# synchronize + review_requested can race).
if pr_number and repo_full_name:
try:
fresh_pr = await self.github_client.get_pull_request(repo_full_name, pr_number, installation_id)
if fresh_pr:
pr_data = fresh_pr
except Exception as e:
logger.warning(f"Could not refresh PR #{pr_number} details: {e}")
# Base event data
event_data = {
"pull_request_details": pr_data,
"triggering_user": {"login": (pr_data.get("user") or {}).get("login")},
"repository": task.payload.get("repository", {}),
"organization": task.payload.get("organization", {}),
"event_id": task.payload.get("event_id"),
"timestamp": task.payload.get("timestamp"),
"installation": {"id": installation_id},
"github_client": self.github_client,
}
# Enrich with API data if PR number is available
if pr_number:
api_data = await self.fetch_api_data(repo_full_name, pr_number, installation_id)
event_data.update(api_data)
if "files" in api_data:
files = api_data["files"]
event_data["changed_files"] = [
{
"filename": f.get("filename"),
"status": f.get("status"),
"additions": f.get("additions"),
"deletions": f.get("deletions"),
"patch": f.get("patch", ""),
}
for f in files
]
event_data["diff_summary"] = self.summarize_files(files)
# Build contributor context for rule `when:` predicates (first-time / trusted / pr_count_below).
author_login = (pr_data.get("user") or {}).get("login")
if author_login:
event_data["contributor_context"] = await self._build_contributor_context(
repo_full_name, author_login, installation_id
)
# Fetch CODEOWNERS so path-has-code-owner rule can evaluate without a local repo
codeowners_paths = [".github/CODEOWNERS", "CODEOWNERS", "docs/CODEOWNERS"]
for path in codeowners_paths:
try:
content = await self.github_client.get_file_content(repo_full_name, path, installation_id)
if content:
event_data["codeowners_content"] = content
break
except Exception:
continue
return event_data
async def _build_contributor_context(
self, repo_full_name: str, username: str, installation_id: int
) -> dict[str, Any]:
"""
Build contributor context used by rule `when:` predicates.
Uses the Search API to count the author's prior merged PRs in this repo.
The PR currently being evaluated is not merged yet, so it is not counted.
On API failure, returns a context with `merged_pr_count=None` and
boolean predicates set to False — the `when_evaluator` treats missing
data as fail-open and will apply the rule.
"""
merged_count: int | None = None
if hasattr(self.github_client, "search_merged_pr_count"):
try:
merged_count = await self.github_client.search_merged_pr_count(
repo_full_name, username, installation_id
)
except Exception as e:
logger.warning(f"Error fetching merged PR count for {username} in {repo_full_name}: {e}")
return {
"login": username,
"merged_pr_count": merged_count,
"is_first_time": merged_count == 0,
"trusted": bool(merged_count and merged_count > 0),
}
async def fetch_acknowledgments(self, repo: str, pr_number: int, installation_id: int) -> dict[str, Acknowledgment]:
"""Fetch and parse previous acknowledgments from PR comments."""
try:
comments = await self.github_client.get_issue_comments(repo, pr_number, installation_id)
if not comments:
return {}
acknowledgments = {}
for comment in comments:
comment_body = comment.get("body", "")
commenter = comment.get("user", {}).get("login", "")
if is_acknowledgment_comment(comment_body):
acknowledged_violations = parse_acknowledgment_comment(comment_body, commenter)
for ack in acknowledged_violations:
if ack.rule_id:
acknowledgments[ack.rule_id] = ack
return acknowledgments
except Exception as e:
logger.error(f"Error fetching acknowledgments: {e}")
return {}
def prepare_webhook_data(self, task: Any) -> dict[str, Any]:
"""Extract data available in webhook payload."""
if not task or not hasattr(task, "payload") or not task.payload:
return {}
pr_data = task.payload.get("pull_request", {}) or {}
return {
"event_type": "pull_request",
"repo_full_name": getattr(task, "repo_full_name", ""),
"action": task.payload.get("action"),
"pull_request": {
"number": pr_data.get("number"),
"title": pr_data.get("title"),
"body": pr_data.get("body"),
"state": pr_data.get("state"),
"created_at": pr_data.get("created_at"),
"updated_at": pr_data.get("updated_at"),
"merged_at": pr_data.get("merged_at"),
"user": (pr_data.get("user") or {}).get("login"),
"head": {
"ref": (pr_data.get("head") or {}).get("ref"),
"sha": (pr_data.get("head") or {}).get("sha"),
},
"base": {
"ref": (pr_data.get("base") or {}).get("ref"),
"sha": (pr_data.get("base") or {}).get("sha"),
},
"labels": pr_data.get("labels", []),
"files": pr_data.get("files", []),
},
}
@staticmethod
def summarize_files(files: list[dict[str, Any]], max_files: int = 5, max_patch_lines: int = 8) -> str:
"""Build a compact diff summary suitable for LLM prompts."""
if not files:
return ""
summary_lines: list[str] = []
for file in files[:max_files]:
filename = file.get("filename", "unknown")
status = file.get("status", "modified")
additions = file.get("additions", 0)
deletions = file.get("deletions", 0)
summary_lines.append(f"- {filename} ({status}, +{additions}/-{deletions})")
patch = file.get("patch")
if patch:
lines = patch.splitlines()
truncated = lines[:max_patch_lines]
indented_patch = "\n".join(f" {line}" for line in truncated)
summary_lines.append(indented_patch)
if len(lines) > max_patch_lines:
summary_lines.append(" ... (diff truncated)")
return "\n".join(summary_lines)