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.

@@ -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
- headers = headers or self.headers
49
- expected_status = expected_status or self._default_status[method.lower()]
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} → {status}")
133
+ print(f"{'✓' if success else '✗'} {method.upper()} {url} → {response.status_code}")
57
134
  if not success:
58
135
  try:
59
- error_detail = response.json()
60
- print(f" ❌ Error: {error_detail.get('message', 'Unknown error')}")
61
- except Exception as e:
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 or GitHub API if needed."""
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
- return response.json()["data"]["viewer"]["login"]
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 None
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 using the GitHub API."""
112
- org_name = self.repository.split("/")[0]
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
- return response.text
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
- return "**ERROR: DIFF TOO LARGE - PR exceeds GitHub's 20,000 line limit, unable to retrieve diff."
200
+ self._pr_diff_cache = "ERROR: PR diff exceeds GitHub's 20,000 line limit, unable to retrieve diff."
124
201
  else:
125
- return "**ERROR: UNABLE TO RETRIEVE DIFF."
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
- response = self.get(f"{GITHUB_API_URL}/repos/{self.repository}/{endpoint}")
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
- r = self.post(GITHUB_GRAPHQL_URL, json={"query": query, "variables": variables})
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.environ.get("GITHUB_ACTOR"),
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.environ.get("GITHUB_REF"),
172
- "github.head_ref": os.environ.get("GITHUB_HEAD_REF"),
173
- "github.base_ref": os.environ.get("GITHUB_BASE_REF"),
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
- max_key_length = max(len(key) for key in info)
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():
@@ -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]",), # strings to remove from response
40
- temperature: float = 1.0, # note GPT-5 requires temperature=1.0
41
- reasoning_effort: str = None, # reasoning effort for GPT-5 models: minimal, low, medium, high
42
- ) -> str:
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): # attempt = [0, 1, 2, 3], 2 random retries before asking for no links
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"} # Default to low for GPT-5
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
- if not check_links or check_links_in_string(content): # if no checks or checks are passing return response
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 # automatically accept the last message
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."},