ultralytics-actions 0.0.100__py3-none-any.whl → 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of ultralytics-actions might be problematic. Click here for more details.
- actions/__init__.py +2 -1
- actions/dispatch_actions.py +3 -3
- actions/first_interaction.py +60 -221
- actions/review_pr.py +323 -0
- actions/summarize_pr.py +26 -128
- actions/summarize_release.py +13 -2
- actions/update_markdown_code_blocks.py +3 -0
- actions/utils/__init__.py +11 -1
- actions/utils/github_utils.py +292 -56
- actions/utils/openai_utils.py +141 -13
- {ultralytics_actions-0.0.100.dist-info → ultralytics_actions-0.1.1.dist-info}/METADATA +5 -2
- ultralytics_actions-0.1.1.dist-info/RECORD +19 -0
- {ultralytics_actions-0.0.100.dist-info → ultralytics_actions-0.1.1.dist-info}/entry_points.txt +1 -0
- ultralytics_actions-0.0.100.dist-info/RECORD +0 -18
- {ultralytics_actions-0.0.100.dist-info → ultralytics_actions-0.1.1.dist-info}/WHEEL +0 -0
- {ultralytics_actions-0.0.100.dist-info → ultralytics_actions-0.1.1.dist-info}/licenses/LICENSE +0 -0
- {ultralytics_actions-0.0.100.dist-info → ultralytics_actions-0.1.1.dist-info}/top_level.txt +0 -0
actions/utils/github_utils.py
CHANGED
|
@@ -13,21 +13,108 @@ from actions import __version__
|
|
|
13
13
|
GITHUB_API_URL = "https://api.github.com"
|
|
14
14
|
GITHUB_GRAPHQL_URL = "https://api.github.com/graphql"
|
|
15
15
|
|
|
16
|
+
# GraphQL Queries
|
|
17
|
+
GRAPHQL_REPO_LABELS = """
|
|
18
|
+
query($owner: String!, $name: String!) {
|
|
19
|
+
repository(owner: $owner, name: $name) {
|
|
20
|
+
labels(first: 100, query: "") {
|
|
21
|
+
nodes {
|
|
22
|
+
id
|
|
23
|
+
name
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
GRAPHQL_PR_CONTRIBUTORS = """
|
|
31
|
+
query($owner: String!, $repo: String!, $pr_number: Int!) {
|
|
32
|
+
repository(owner: $owner, name: $repo) {
|
|
33
|
+
pullRequest(number: $pr_number) {
|
|
34
|
+
closingIssuesReferences(first: 50) { nodes { number } }
|
|
35
|
+
url
|
|
36
|
+
title
|
|
37
|
+
body
|
|
38
|
+
author { login, __typename }
|
|
39
|
+
reviews(first: 50) { nodes { author { login, __typename } } }
|
|
40
|
+
comments(first: 50) { nodes { author { login, __typename } } }
|
|
41
|
+
commits(first: 100) { nodes { commit { author { user { login } }, committer { user { login } } } } }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
GRAPHQL_UPDATE_DISCUSSION = """
|
|
48
|
+
mutation($discussionId: ID!, $title: String!, $body: String!) {
|
|
49
|
+
updateDiscussion(input: {discussionId: $discussionId, title: $title, body: $body}) {
|
|
50
|
+
discussion {
|
|
51
|
+
id
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
GRAPHQL_CLOSE_DISCUSSION = """
|
|
58
|
+
mutation($discussionId: ID!) {
|
|
59
|
+
closeDiscussion(input: {discussionId: $discussionId}) {
|
|
60
|
+
discussion {
|
|
61
|
+
id
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
GRAPHQL_LOCK_DISCUSSION = """
|
|
68
|
+
mutation($lockableId: ID!, $lockReason: LockReason) {
|
|
69
|
+
lockLockable(input: {lockableId: $lockableId, lockReason: $lockReason}) {
|
|
70
|
+
lockedRecord {
|
|
71
|
+
... on Discussion {
|
|
72
|
+
id
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
GRAPHQL_ADD_DISCUSSION_COMMENT = """
|
|
80
|
+
mutation($discussionId: ID!, $body: String!) {
|
|
81
|
+
addDiscussionComment(input: {discussionId: $discussionId, body: $body}) {
|
|
82
|
+
comment {
|
|
83
|
+
id
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
GRAPHQL_ADD_LABELS_TO_DISCUSSION = """
|
|
90
|
+
mutation($labelableId: ID!, $labelIds: [ID!]!) {
|
|
91
|
+
addLabelsToLabelable(input: {labelableId: $labelableId, labelIds: $labelIds}) {
|
|
92
|
+
labelable {
|
|
93
|
+
... on Discussion {
|
|
94
|
+
id
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
"""
|
|
100
|
+
|
|
16
101
|
|
|
17
102
|
class Action:
|
|
18
103
|
"""Handles GitHub Actions API interactions and event processing."""
|
|
19
104
|
|
|
20
|
-
def __init__(
|
|
21
|
-
self,
|
|
22
|
-
token: str = None,
|
|
23
|
-
event_name: str = None,
|
|
24
|
-
event_data: dict = None,
|
|
25
|
-
verbose: bool = True,
|
|
26
|
-
):
|
|
105
|
+
def __init__(self, token: str = None, event_name: str = None, event_data: dict = None, verbose: bool = True):
|
|
27
106
|
"""Initializes a GitHub Actions API handler with token and event data for processing events."""
|
|
28
107
|
self.token = token or os.getenv("GITHUB_TOKEN")
|
|
29
108
|
self.event_name = event_name or os.getenv("GITHUB_EVENT_NAME")
|
|
30
109
|
self.event_data = event_data or self._load_event_data(os.getenv("GITHUB_EVENT_PATH"))
|
|
110
|
+
self.pr = self.event_data.get("pull_request", {})
|
|
111
|
+
self.repository = self.event_data.get("repository", {}).get("full_name")
|
|
112
|
+
self.headers = {"Authorization": f"Bearer {self.token}", "Accept": "application/vnd.github+json"}
|
|
113
|
+
self.headers_diff = {"Authorization": f"Bearer {self.token}", "Accept": "application/vnd.github.v3.diff"}
|
|
114
|
+
self.verbose = verbose
|
|
115
|
+
self.eyes_reaction_id = None
|
|
116
|
+
self._pr_diff_cache = None
|
|
117
|
+
self._username_cache = None
|
|
31
118
|
self._default_status = {
|
|
32
119
|
"get": [200],
|
|
33
120
|
"post": [200, 201],
|
|
@@ -36,34 +123,22 @@ class Action:
|
|
|
36
123
|
"delete": [200, 204],
|
|
37
124
|
}
|
|
38
125
|
|
|
39
|
-
self.pr = self.event_data.get("pull_request", {})
|
|
40
|
-
self.repository = self.event_data.get("repository", {}).get("full_name")
|
|
41
|
-
self.headers = {"Authorization": f"Bearer {self.token}", "Accept": "application/vnd.github+json"}
|
|
42
|
-
self.headers_diff = {"Authorization": f"Bearer {self.token}", "Accept": "application/vnd.github.v3.diff"}
|
|
43
|
-
self.eyes_reaction_id = None
|
|
44
|
-
self.verbose = verbose
|
|
45
|
-
|
|
46
126
|
def _request(self, method: str, url: str, headers=None, expected_status=None, hard=False, **kwargs):
|
|
47
127
|
"""Unified request handler with error checking."""
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
response = getattr(requests, method)(url, headers=headers, **kwargs)
|
|
52
|
-
status = response.status_code
|
|
53
|
-
success = status in expected_status
|
|
128
|
+
response = getattr(requests, method)(url, headers=headers or self.headers, **kwargs)
|
|
129
|
+
expected = expected_status or self._default_status[method]
|
|
130
|
+
success = response.status_code in expected
|
|
54
131
|
|
|
55
132
|
if self.verbose:
|
|
56
|
-
print(f"{'✓' if success else '✗'} {method.upper()} {url} → {
|
|
133
|
+
print(f"{'✓' if success else '✗'} {method.upper()} {url} → {response.status_code}")
|
|
57
134
|
if not success:
|
|
58
135
|
try:
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
print(f" ❌ Error: {response.text[:100]}... {e}")
|
|
136
|
+
print(f" ❌ Error: {response.json().get('message', 'Unknown error')}")
|
|
137
|
+
except Exception:
|
|
138
|
+
print(f" ❌ Error: {response.text[:200]}")
|
|
63
139
|
|
|
64
140
|
if not success and hard:
|
|
65
141
|
response.raise_for_status()
|
|
66
|
-
|
|
67
142
|
return response
|
|
68
143
|
|
|
69
144
|
def get(self, url, **kwargs):
|
|
@@ -89,45 +164,47 @@ class Action:
|
|
|
89
164
|
@staticmethod
|
|
90
165
|
def _load_event_data(event_path: str) -> dict:
|
|
91
166
|
"""Load GitHub event data from path if it exists."""
|
|
92
|
-
if event_path and Path(event_path).exists()
|
|
93
|
-
return json.loads(Path(event_path).read_text())
|
|
94
|
-
return {}
|
|
167
|
+
return json.loads(Path(event_path).read_text()) if event_path and Path(event_path).exists() else {}
|
|
95
168
|
|
|
96
169
|
def is_repo_private(self) -> bool:
|
|
97
|
-
"""Checks if the repository is public using event data
|
|
98
|
-
return self.event_data.get("repository", {}).get("private")
|
|
170
|
+
"""Checks if the repository is public using event data."""
|
|
171
|
+
return self.event_data.get("repository", {}).get("private", False)
|
|
99
172
|
|
|
100
173
|
def get_username(self) -> str | None:
|
|
101
|
-
"""Gets username associated with the GitHub token."""
|
|
174
|
+
"""Gets username associated with the GitHub token with caching."""
|
|
175
|
+
if self._username_cache:
|
|
176
|
+
return self._username_cache
|
|
177
|
+
|
|
102
178
|
response = self.post(GITHUB_GRAPHQL_URL, json={"query": "query { viewer { login } }"})
|
|
103
179
|
if response.status_code == 200:
|
|
104
180
|
try:
|
|
105
|
-
|
|
181
|
+
self._username_cache = response.json()["data"]["viewer"]["login"]
|
|
106
182
|
except KeyError as e:
|
|
107
183
|
print(f"Error parsing authenticated user response: {e}")
|
|
108
|
-
return
|
|
184
|
+
return self._username_cache
|
|
109
185
|
|
|
110
186
|
def is_org_member(self, username: str) -> bool:
|
|
111
|
-
"""Checks if a user is a member of the organization
|
|
112
|
-
|
|
113
|
-
response = self.get(f"{GITHUB_API_URL}/orgs/{org_name}/members/{username}")
|
|
114
|
-
return response.status_code == 204 # 204 means the user is a member
|
|
187
|
+
"""Checks if a user is a member of the organization."""
|
|
188
|
+
return self.get(f"{GITHUB_API_URL}/orgs/{self.repository.split('/')[0]}/members/{username}").status_code == 204
|
|
115
189
|
|
|
116
190
|
def get_pr_diff(self) -> str:
|
|
117
|
-
"""Retrieves the diff content for a specified pull request."""
|
|
191
|
+
"""Retrieves the diff content for a specified pull request with caching."""
|
|
192
|
+
if self._pr_diff_cache:
|
|
193
|
+
return self._pr_diff_cache
|
|
194
|
+
|
|
118
195
|
url = f"{GITHUB_API_URL}/repos/{self.repository}/pulls/{self.pr.get('number')}"
|
|
119
196
|
response = self.get(url, headers=self.headers_diff)
|
|
120
197
|
if response.status_code == 200:
|
|
121
|
-
|
|
198
|
+
self._pr_diff_cache = response.text or "ERROR: EMPTY DIFF, NO CODE CHANGES IN THIS PR."
|
|
122
199
|
elif response.status_code == 406:
|
|
123
|
-
|
|
200
|
+
self._pr_diff_cache = "ERROR: PR diff exceeds GitHub's 20,000 line limit, unable to retrieve diff."
|
|
124
201
|
else:
|
|
125
|
-
|
|
202
|
+
self._pr_diff_cache = "ERROR: UNABLE TO RETRIEVE DIFF."
|
|
203
|
+
return self._pr_diff_cache
|
|
126
204
|
|
|
127
205
|
def get_repo_data(self, endpoint: str) -> dict:
|
|
128
206
|
"""Fetches repository data from a specified endpoint."""
|
|
129
|
-
|
|
130
|
-
return response.json()
|
|
207
|
+
return self.get(f"{GITHUB_API_URL}/repos/{self.repository}/{endpoint}").json()
|
|
131
208
|
|
|
132
209
|
def toggle_eyes_reaction(self, add: bool = True) -> None:
|
|
133
210
|
"""Adds or removes eyes emoji reaction."""
|
|
@@ -139,8 +216,8 @@ class Action:
|
|
|
139
216
|
id = self.event_data.get("issue", {}).get("number")
|
|
140
217
|
if not id:
|
|
141
218
|
return
|
|
142
|
-
url = f"{GITHUB_API_URL}/repos/{self.repository}/issues/{id}/reactions"
|
|
143
219
|
|
|
220
|
+
url = f"{GITHUB_API_URL}/repos/{self.repository}/issues/{id}/reactions"
|
|
144
221
|
if add:
|
|
145
222
|
response = self.post(url, json={"content": "eyes"})
|
|
146
223
|
if response.status_code == 201:
|
|
@@ -151,12 +228,174 @@ class Action:
|
|
|
151
228
|
|
|
152
229
|
def graphql_request(self, query: str, variables: dict = None) -> dict:
|
|
153
230
|
"""Executes a GraphQL query against the GitHub API."""
|
|
154
|
-
|
|
155
|
-
result = r.json()
|
|
231
|
+
result = self.post(GITHUB_GRAPHQL_URL, json={"query": query, "variables": variables}).json()
|
|
156
232
|
if "data" not in result or result.get("errors"):
|
|
157
233
|
print(result.get("errors"))
|
|
158
234
|
return result
|
|
159
235
|
|
|
236
|
+
def update_pr_description(self, number: int, new_summary: str, max_retries: int = 2):
|
|
237
|
+
"""Updates PR description with summary, retrying if description is None."""
|
|
238
|
+
import time
|
|
239
|
+
|
|
240
|
+
url = f"{GITHUB_API_URL}/repos/{self.repository}/pulls/{number}"
|
|
241
|
+
description = ""
|
|
242
|
+
for i in range(max_retries + 1):
|
|
243
|
+
description = self.get(url).json().get("body") or ""
|
|
244
|
+
if description:
|
|
245
|
+
break
|
|
246
|
+
if i < max_retries:
|
|
247
|
+
print("No current PR description found, retrying...")
|
|
248
|
+
time.sleep(1)
|
|
249
|
+
|
|
250
|
+
start = "## 🛠️ PR Summary"
|
|
251
|
+
if start in description:
|
|
252
|
+
print("Existing PR Summary found, replacing.")
|
|
253
|
+
updated_description = description.split(start)[0] + new_summary
|
|
254
|
+
else:
|
|
255
|
+
print("PR Summary not found, appending.")
|
|
256
|
+
updated_description = description + "\n\n" + new_summary
|
|
257
|
+
|
|
258
|
+
self.patch(url, json={"body": updated_description})
|
|
259
|
+
|
|
260
|
+
def get_label_ids(self, labels: list[str]) -> list[str]:
|
|
261
|
+
"""Retrieves GitHub label IDs for a list of label names using the GraphQL API."""
|
|
262
|
+
owner, repo = self.repository.split("/")
|
|
263
|
+
result = self.graphql_request(GRAPHQL_REPO_LABELS, variables={"owner": owner, "name": repo})
|
|
264
|
+
if "data" in result and "repository" in result["data"]:
|
|
265
|
+
all_labels = result["data"]["repository"]["labels"]["nodes"]
|
|
266
|
+
label_map = {label["name"].lower(): label["id"] for label in all_labels}
|
|
267
|
+
return [label_map.get(label.lower()) for label in labels if label.lower() in label_map]
|
|
268
|
+
return []
|
|
269
|
+
|
|
270
|
+
def apply_labels(self, number: int, node_id: str, labels: list[str], issue_type: str):
|
|
271
|
+
"""Applies specified labels to a GitHub issue, pull request, or discussion."""
|
|
272
|
+
if "Alert" in labels:
|
|
273
|
+
self.create_alert_label()
|
|
274
|
+
|
|
275
|
+
if issue_type == "discussion":
|
|
276
|
+
label_ids = self.get_label_ids(labels)
|
|
277
|
+
if not label_ids:
|
|
278
|
+
print("No valid labels to apply.")
|
|
279
|
+
return
|
|
280
|
+
self.graphql_request(GRAPHQL_ADD_LABELS_TO_DISCUSSION, {"labelableId": node_id, "labelIds": label_ids})
|
|
281
|
+
else:
|
|
282
|
+
url = f"{GITHUB_API_URL}/repos/{self.repository}/issues/{number}/labels"
|
|
283
|
+
self.post(url, json={"labels": labels})
|
|
284
|
+
|
|
285
|
+
def create_alert_label(self):
|
|
286
|
+
"""Creates the 'Alert' label in the repository if it doesn't exist."""
|
|
287
|
+
alert_label = {"name": "Alert", "color": "FF0000", "description": "Potential spam, abuse, or off-topic."}
|
|
288
|
+
self.post(f"{GITHUB_API_URL}/repos/{self.repository}/labels", json=alert_label)
|
|
289
|
+
|
|
290
|
+
def remove_labels(self, number: int, labels: tuple[str, ...]):
|
|
291
|
+
"""Removes specified labels from an issue or PR."""
|
|
292
|
+
for label in labels:
|
|
293
|
+
self.delete(f"{GITHUB_API_URL}/repos/{self.repository}/issues/{number}/labels/{label}")
|
|
294
|
+
|
|
295
|
+
def add_comment(self, number: int, node_id: str, comment: str, issue_type: str):
|
|
296
|
+
"""Adds a comment to an issue, pull request, or discussion."""
|
|
297
|
+
if issue_type == "discussion":
|
|
298
|
+
self.graphql_request(GRAPHQL_ADD_DISCUSSION_COMMENT, variables={"discussionId": node_id, "body": comment})
|
|
299
|
+
else:
|
|
300
|
+
self.post(f"{GITHUB_API_URL}/repos/{self.repository}/issues/{number}/comments", json={"body": comment})
|
|
301
|
+
|
|
302
|
+
def update_content(self, number: int, node_id: str, issue_type: str, title: str = None, body: str = None):
|
|
303
|
+
"""Updates the title and/or body of an issue, pull request, or discussion."""
|
|
304
|
+
if issue_type == "discussion":
|
|
305
|
+
variables = {"discussionId": node_id}
|
|
306
|
+
if title:
|
|
307
|
+
variables["title"] = title
|
|
308
|
+
if body:
|
|
309
|
+
variables["body"] = body
|
|
310
|
+
self.graphql_request(GRAPHQL_UPDATE_DISCUSSION, variables=variables)
|
|
311
|
+
else:
|
|
312
|
+
url = f"{GITHUB_API_URL}/repos/{self.repository}/issues/{number}"
|
|
313
|
+
data = {}
|
|
314
|
+
if title:
|
|
315
|
+
data["title"] = title
|
|
316
|
+
if body:
|
|
317
|
+
data["body"] = body
|
|
318
|
+
self.patch(url, json=data)
|
|
319
|
+
|
|
320
|
+
def close_item(self, number: int, node_id: str, issue_type: str):
|
|
321
|
+
"""Closes an issue, pull request, or discussion."""
|
|
322
|
+
if issue_type == "discussion":
|
|
323
|
+
self.graphql_request(GRAPHQL_CLOSE_DISCUSSION, variables={"discussionId": node_id})
|
|
324
|
+
else:
|
|
325
|
+
url = f"{GITHUB_API_URL}/repos/{self.repository}/issues/{number}"
|
|
326
|
+
self.patch(url, json={"state": "closed"})
|
|
327
|
+
|
|
328
|
+
def lock_item(self, number: int, node_id: str, issue_type: str):
|
|
329
|
+
"""Locks an issue, pull request, or discussion to prevent further interactions."""
|
|
330
|
+
if issue_type == "discussion":
|
|
331
|
+
self.graphql_request(GRAPHQL_LOCK_DISCUSSION, variables={"lockableId": node_id, "lockReason": "OFF_TOPIC"})
|
|
332
|
+
else:
|
|
333
|
+
url = f"{GITHUB_API_URL}/repos/{self.repository}/issues/{number}/lock"
|
|
334
|
+
self.put(url, json={"lock_reason": "off-topic"})
|
|
335
|
+
|
|
336
|
+
def block_user(self, username: str):
|
|
337
|
+
"""Blocks a user from the organization."""
|
|
338
|
+
url = f"{GITHUB_API_URL}/orgs/{self.repository.split('/')[0]}/blocks/{username}"
|
|
339
|
+
self.put(url)
|
|
340
|
+
|
|
341
|
+
def handle_alert(self, number: int, node_id: str, issue_type: str, username: str, block: bool = False):
|
|
342
|
+
"""Handles content flagged as alert: updates content, locks, optionally closes and blocks user."""
|
|
343
|
+
new_title = "Content Under Review"
|
|
344
|
+
new_body = """This post has been flagged for review by [Ultralytics Actions](https://ultralytics.com/actions) due to possible spam, abuse, or off-topic content. For more information please see our:
|
|
345
|
+
|
|
346
|
+
- [Code of Conduct](https://docs.ultralytics.com/help/code-of-conduct/)
|
|
347
|
+
- [Security Policy](https://docs.ultralytics.com/help/security/)
|
|
348
|
+
|
|
349
|
+
For questions or bug reports related to this action please visit https://github.com/ultralytics/actions.
|
|
350
|
+
|
|
351
|
+
Thank you 🙏
|
|
352
|
+
"""
|
|
353
|
+
self.update_content(number, node_id, issue_type, title=new_title, body=new_body)
|
|
354
|
+
if issue_type != "pull request":
|
|
355
|
+
self.close_item(number, node_id, issue_type)
|
|
356
|
+
self.lock_item(number, node_id, issue_type)
|
|
357
|
+
if block:
|
|
358
|
+
self.block_user(username)
|
|
359
|
+
|
|
360
|
+
def get_pr_contributors(self) -> tuple[str | None, dict]:
|
|
361
|
+
"""Gets PR contributors and closing issues, returns (pr_credit_string, pr_data)."""
|
|
362
|
+
owner, repo = self.repository.split("/")
|
|
363
|
+
variables = {"owner": owner, "repo": repo, "pr_number": self.pr["number"]}
|
|
364
|
+
response = self.post(GITHUB_GRAPHQL_URL, json={"query": GRAPHQL_PR_CONTRIBUTORS, "variables": variables})
|
|
365
|
+
if response.status_code != 200:
|
|
366
|
+
return None, {}
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
data = response.json()["data"]["repository"]["pullRequest"]
|
|
370
|
+
comments = data["reviews"]["nodes"] + data["comments"]["nodes"]
|
|
371
|
+
token_username = self.get_username()
|
|
372
|
+
author = data["author"]["login"] if data["author"]["__typename"] != "Bot" else None
|
|
373
|
+
|
|
374
|
+
contributors = {x["author"]["login"] for x in comments if x["author"]["__typename"] != "Bot"}
|
|
375
|
+
|
|
376
|
+
for commit in data["commits"]["nodes"]:
|
|
377
|
+
commit_data = commit["commit"]
|
|
378
|
+
for user_type in ["author", "committer"]:
|
|
379
|
+
if user := commit_data[user_type].get("user"):
|
|
380
|
+
if login := user.get("login"):
|
|
381
|
+
contributors.add(login)
|
|
382
|
+
|
|
383
|
+
contributors.discard(author)
|
|
384
|
+
contributors.discard(token_username)
|
|
385
|
+
|
|
386
|
+
pr_credit = ""
|
|
387
|
+
if author and author != token_username:
|
|
388
|
+
pr_credit += f"@{author}"
|
|
389
|
+
if contributors:
|
|
390
|
+
pr_credit += (" with contributions from " if pr_credit else "") + ", ".join(
|
|
391
|
+
f"@{c}" for c in contributors
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
return pr_credit, data
|
|
395
|
+
except KeyError as e:
|
|
396
|
+
print(f"Error parsing GraphQL response: {e}")
|
|
397
|
+
return None, {}
|
|
398
|
+
|
|
160
399
|
def print_info(self):
|
|
161
400
|
"""Print GitHub Actions information including event details and repository information."""
|
|
162
401
|
info = {
|
|
@@ -166,11 +405,11 @@ class Action:
|
|
|
166
405
|
"github.repository.private": self.is_repo_private(),
|
|
167
406
|
"github.event.pull_request.number": self.pr.get("number"),
|
|
168
407
|
"github.event.pull_request.head.repo.full_name": self.pr.get("head", {}).get("repo", {}).get("full_name"),
|
|
169
|
-
"github.actor": os.
|
|
408
|
+
"github.actor": os.getenv("GITHUB_ACTOR"),
|
|
170
409
|
"github.event.pull_request.head.ref": self.pr.get("head", {}).get("ref"),
|
|
171
|
-
"github.ref": os.
|
|
172
|
-
"github.head_ref": os.
|
|
173
|
-
"github.base_ref": os.
|
|
410
|
+
"github.ref": os.getenv("GITHUB_REF"),
|
|
411
|
+
"github.head_ref": os.getenv("GITHUB_HEAD_REF"),
|
|
412
|
+
"github.base_ref": os.getenv("GITHUB_BASE_REF"),
|
|
174
413
|
"github.base_sha": self.pr.get("base", {}).get("sha"),
|
|
175
414
|
}
|
|
176
415
|
|
|
@@ -181,12 +420,9 @@ class Action:
|
|
|
181
420
|
"github.event.discussion.number": discussion.get("number"),
|
|
182
421
|
}
|
|
183
422
|
|
|
184
|
-
|
|
423
|
+
width = max(len(k) for k in info) + 5
|
|
185
424
|
header = f"Ultralytics Actions {__version__} Information " + "-" * 40
|
|
186
|
-
print(header)
|
|
187
|
-
for key, value in info.items():
|
|
188
|
-
print(f"{key:<{max_key_length + 5}}{value}")
|
|
189
|
-
print("-" * len(header))
|
|
425
|
+
print(f"{header}\n" + "\n".join(f"{k:<{width}}{v}" for k, v in info.items()) + f"\n{'-' * len(header)}")
|
|
190
426
|
|
|
191
427
|
|
|
192
428
|
def ultralytics_actions_info():
|
actions/utils/openai_utils.py
CHANGED
|
@@ -28,18 +28,91 @@ def remove_outer_codeblocks(string):
|
|
|
28
28
|
"""Removes outer code block markers and language identifiers from a string while preserving inner content."""
|
|
29
29
|
string = string.strip()
|
|
30
30
|
if string.startswith("```") and string.endswith("```"):
|
|
31
|
-
# Get everything after first ``` and newline, up to the last ```
|
|
32
31
|
string = string[string.find("\n") + 1 : string.rfind("```")].strip()
|
|
33
32
|
return string
|
|
34
33
|
|
|
35
34
|
|
|
35
|
+
def filter_labels(available_labels: dict, current_labels: list = None, is_pr: bool = False) -> dict:
|
|
36
|
+
"""Filters labels by removing manually-assigned and mutually exclusive labels."""
|
|
37
|
+
current_labels = current_labels or []
|
|
38
|
+
filtered = available_labels.copy()
|
|
39
|
+
|
|
40
|
+
for label in {
|
|
41
|
+
"help wanted",
|
|
42
|
+
"TODO",
|
|
43
|
+
"research",
|
|
44
|
+
"non-reproducible",
|
|
45
|
+
"popular",
|
|
46
|
+
"invalid",
|
|
47
|
+
"Stale",
|
|
48
|
+
"wontfix",
|
|
49
|
+
"duplicate",
|
|
50
|
+
}:
|
|
51
|
+
filtered.pop(label, None)
|
|
52
|
+
|
|
53
|
+
if "bug" in current_labels:
|
|
54
|
+
filtered.pop("question", None)
|
|
55
|
+
elif "question" in current_labels:
|
|
56
|
+
filtered.pop("bug", None)
|
|
57
|
+
|
|
58
|
+
if "Alert" not in filtered:
|
|
59
|
+
filtered["Alert"] = (
|
|
60
|
+
"Potential spam, abuse, or illegal activity including advertising, unsolicited promotions, malware, "
|
|
61
|
+
"phishing, crypto offers, pirated software or media, free movie downloads, cracks, keygens or any other "
|
|
62
|
+
"content that violates terms of service or legal standards."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return filtered
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_pr_summary_guidelines() -> str:
|
|
69
|
+
"""Returns PR summary formatting guidelines (used by both unified PR open and PR update/merge)."""
|
|
70
|
+
return """Summarize this PR, focusing on major changes, their purpose, and potential impact. Keep the summary clear and concise, suitable for a broad audience. Add emojis to enliven the summary. Your response must include all 3 sections below with their markdown headers:
|
|
71
|
+
|
|
72
|
+
### 🌟 Summary
|
|
73
|
+
(single-line synopsis)
|
|
74
|
+
|
|
75
|
+
### 📊 Key Changes
|
|
76
|
+
- (bullet points highlighting major changes)
|
|
77
|
+
|
|
78
|
+
### 🎯 Purpose & Impact
|
|
79
|
+
- (bullet points explaining benefits and potential impact to users)"""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def get_pr_summary_prompt(repository: str, diff_text: str) -> tuple[str, bool]:
|
|
83
|
+
"""Returns the complete PR summary generation prompt with diff (used by PR update/merge)."""
|
|
84
|
+
ratio = 3.3 # about 3.3 characters per token
|
|
85
|
+
limit = round(128000 * ratio * 0.5) # use up to 50% of the 128k context window for prompt
|
|
86
|
+
|
|
87
|
+
prompt = (
|
|
88
|
+
f"{get_pr_summary_guidelines()}\n\nRepository: '{repository}'\n\nHere's the PR diff:\n\n{diff_text[:limit]}"
|
|
89
|
+
)
|
|
90
|
+
return prompt, len(diff_text) > limit
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_pr_first_comment_template(repository: str) -> str:
|
|
94
|
+
"""Returns the PR first comment template with checklist (used only by unified PR open)."""
|
|
95
|
+
return f"""👋 Hello @username, thank you for submitting an `{repository}` 🚀 PR! To ensure a seamless integration of your work, please review the following checklist:
|
|
96
|
+
|
|
97
|
+
- ✅ **Define a Purpose**: Clearly explain the purpose of your fix or feature in your PR description, and link to any [relevant issues](https://github.com/{repository}/issues). Ensure your commit messages are clear, concise, and adhere to the project's conventions.
|
|
98
|
+
- ✅ **Synchronize with Source**: Confirm your PR is synchronized with the `{repository}` `main` branch. If it's behind, update it by clicking the 'Update branch' button or by running `git pull` and `git merge main` locally.
|
|
99
|
+
- ✅ **Ensure CI Checks Pass**: Verify all Ultralytics [Continuous Integration (CI)](https://docs.ultralytics.com/help/CI/) checks are passing. If any checks fail, please address the issues.
|
|
100
|
+
- ✅ **Update Documentation**: Update the relevant [documentation](https://docs.ultralytics.com/) for any new or modified features.
|
|
101
|
+
- ✅ **Add Tests**: If applicable, include or update tests to cover your changes, and confirm that all tests are passing.
|
|
102
|
+
- ✅ **Sign the CLA**: Please ensure you have signed our [Contributor License Agreement](https://docs.ultralytics.com/help/CLA/) if this is your first Ultralytics PR by writing "I have read the CLA Document and I sign the CLA" in a new message.
|
|
103
|
+
- ✅ **Minimize Changes**: Limit your changes to the **minimum** necessary for your bug fix or feature addition. _"It is not daily increase but daily decrease, hack away the unessential. The closer to the source, the less wastage there is."_ — Bruce Lee
|
|
104
|
+
|
|
105
|
+
For more guidance, please refer to our [Contributing Guide](https://docs.ultralytics.com/help/contributing/). Don't hesitate to leave a comment if you have any questions. Thank you for contributing to Ultralytics! 🚀"""
|
|
106
|
+
|
|
107
|
+
|
|
36
108
|
def get_completion(
|
|
37
109
|
messages: list[dict[str, str]],
|
|
38
110
|
check_links: bool = True,
|
|
39
|
-
remove: list[str] = (" @giscus[bot]",),
|
|
40
|
-
temperature: float = 1.0,
|
|
41
|
-
reasoning_effort: str = None,
|
|
42
|
-
|
|
111
|
+
remove: list[str] = (" @giscus[bot]",),
|
|
112
|
+
temperature: float = 1.0,
|
|
113
|
+
reasoning_effort: str = None,
|
|
114
|
+
response_format: dict = None,
|
|
115
|
+
) -> str | dict:
|
|
43
116
|
"""Generates a completion using OpenAI's Responses API based on input messages."""
|
|
44
117
|
assert OPENAI_API_KEY, "OpenAI API key is required."
|
|
45
118
|
url = "https://api.openai.com/v1/responses"
|
|
@@ -47,20 +120,19 @@ def get_completion(
|
|
|
47
120
|
if messages and messages[0].get("role") == "system":
|
|
48
121
|
messages[0]["content"] += "\n\n" + SYSTEM_PROMPT_ADDITION
|
|
49
122
|
|
|
50
|
-
content = ""
|
|
51
123
|
max_retries = 2
|
|
52
|
-
for attempt in range(max_retries + 2):
|
|
124
|
+
for attempt in range(max_retries + 2):
|
|
53
125
|
data = {"model": OPENAI_MODEL, "input": messages, "store": False, "temperature": temperature}
|
|
54
|
-
|
|
55
|
-
# Add reasoning for GPT-5 models
|
|
56
126
|
if "gpt-5" in OPENAI_MODEL:
|
|
57
|
-
data["reasoning"] = {"effort": reasoning_effort or "low"}
|
|
127
|
+
data["reasoning"] = {"effort": reasoning_effort or "low"}
|
|
128
|
+
# GPT-5 Responses API handles JSON via prompting, not format parameter
|
|
58
129
|
|
|
59
130
|
r = requests.post(url, json=data, headers=headers)
|
|
131
|
+
if r.status_code != 200:
|
|
132
|
+
print(f"❌ OpenAI error {r.status_code}:\n{r.text}\n")
|
|
60
133
|
r.raise_for_status()
|
|
61
134
|
response_data = r.json()
|
|
62
135
|
|
|
63
|
-
# Extract text from output array
|
|
64
136
|
content = ""
|
|
65
137
|
for item in response_data.get("output", []):
|
|
66
138
|
if item.get("type") == "message":
|
|
@@ -69,10 +141,16 @@ def get_completion(
|
|
|
69
141
|
content += content_item.get("text", "")
|
|
70
142
|
|
|
71
143
|
content = content.strip()
|
|
144
|
+
if response_format and response_format.get("type") == "json_object":
|
|
145
|
+
import json
|
|
146
|
+
|
|
147
|
+
return json.loads(content)
|
|
148
|
+
|
|
72
149
|
content = remove_outer_codeblocks(content)
|
|
73
150
|
for x in remove:
|
|
74
151
|
content = content.replace(x, "")
|
|
75
|
-
|
|
152
|
+
|
|
153
|
+
if not check_links or check_links_in_string(content):
|
|
76
154
|
return content
|
|
77
155
|
|
|
78
156
|
if attempt < max_retries:
|
|
@@ -80,11 +158,61 @@ def get_completion(
|
|
|
80
158
|
else:
|
|
81
159
|
print("Max retries reached. Updating prompt to exclude links.")
|
|
82
160
|
messages.append({"role": "user", "content": "Please provide a response without any URLs or links in it."})
|
|
83
|
-
check_links = False
|
|
161
|
+
check_links = False
|
|
84
162
|
|
|
85
163
|
return content
|
|
86
164
|
|
|
87
165
|
|
|
166
|
+
def get_pr_open_response(repository: str, diff_text: str, title: str, body: str, available_labels: dict) -> dict:
|
|
167
|
+
"""Generates unified PR response with summary, labels, and first comment in a single API call."""
|
|
168
|
+
ratio = 3.3 # about 3.3 characters per token
|
|
169
|
+
limit = round(128000 * ratio * 0.5) # use up to 50% of the 128k context window for prompt
|
|
170
|
+
is_large = len(diff_text) > limit
|
|
171
|
+
|
|
172
|
+
filtered_labels = filter_labels(available_labels, is_pr=True)
|
|
173
|
+
labels_str = "\n".join(f"- {name}: {description}" for name, description in filtered_labels.items())
|
|
174
|
+
|
|
175
|
+
prompt = f"""You are processing a new GitHub pull request for the {repository} repository.
|
|
176
|
+
|
|
177
|
+
Generate 3 outputs in a single JSON response for the PR titled {title} with the following diff:
|
|
178
|
+
{diff_text[:limit]}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
--- FIRST JSON OUTPUT (PR SUMMARY) ---
|
|
182
|
+
{get_pr_summary_guidelines()}
|
|
183
|
+
|
|
184
|
+
--- SECOND JSON OUTPUT (PR LABELS) ---
|
|
185
|
+
Array of 1-3 most relevant label names. Only use "Alert" with high confidence for inappropriate PRs. Return empty array if no labels relevant. Available labels:
|
|
186
|
+
{labels_str}
|
|
187
|
+
|
|
188
|
+
--- THIRD OUTPUT (PR FIRST COMMENT) ---
|
|
189
|
+
Customized welcome message adapting the template below:
|
|
190
|
+
- INCLUDE ALL LINKS AND INSTRUCTIONS from the template below, customized as appropriate
|
|
191
|
+
- Keep all checklist items and links from template
|
|
192
|
+
- Only link to files or URLs in the template below, do not add external links
|
|
193
|
+
- Mention this is automated and an engineer will assist
|
|
194
|
+
- Use a few emojis
|
|
195
|
+
- No sign-off or "best regards"
|
|
196
|
+
- No spaces between bullet points
|
|
197
|
+
|
|
198
|
+
Example comment template (adapt as needed, keep all links):
|
|
199
|
+
{get_pr_first_comment_template(repository)}
|
|
200
|
+
|
|
201
|
+
Return ONLY valid JSON in this exact format:
|
|
202
|
+
{{"summary": "...", "labels": [...], "first_comment": "..."}}"""
|
|
203
|
+
|
|
204
|
+
messages = [
|
|
205
|
+
{"role": "system", "content": "You are an Ultralytics AI assistant processing GitHub PRs."},
|
|
206
|
+
{"role": "user", "content": prompt},
|
|
207
|
+
]
|
|
208
|
+
result = get_completion(messages, temperature=1.0, response_format={"type": "json_object"})
|
|
209
|
+
if is_large and "summary" in result:
|
|
210
|
+
result["summary"] = (
|
|
211
|
+
"**WARNING ⚠️** this PR is very large, summary may not cover all changes.\n\n" + result["summary"]
|
|
212
|
+
)
|
|
213
|
+
return result
|
|
214
|
+
|
|
215
|
+
|
|
88
216
|
if __name__ == "__main__":
|
|
89
217
|
messages = [
|
|
90
218
|
{"role": "system", "content": "You are a helpful AI assistant."},
|