ultralytics-actions 0.0.27__tar.gz → 0.0.34__tar.gz

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.
Files changed (23) hide show
  1. {ultralytics_actions-0.0.27 → ultralytics_actions-0.0.34}/PKG-INFO +1 -1
  2. {ultralytics_actions-0.0.27 → ultralytics_actions-0.0.34}/actions/__init__.py +1 -1
  3. {ultralytics_actions-0.0.27 → ultralytics_actions-0.0.34}/actions/first_interaction.py +70 -73
  4. ultralytics_actions-0.0.34/actions/summarize_pr.py +244 -0
  5. {ultralytics_actions-0.0.27 → ultralytics_actions-0.0.34}/actions/summarize_release.py +23 -21
  6. ultralytics_actions-0.0.34/actions/utils/__init__.py +19 -0
  7. ultralytics_actions-0.0.34/actions/utils/github_utils.py +163 -0
  8. {ultralytics_actions-0.0.27 → ultralytics_actions-0.0.34}/actions/utils/openai_utils.py +2 -2
  9. {ultralytics_actions-0.0.27 → ultralytics_actions-0.0.34}/pyproject.toml +13 -0
  10. {ultralytics_actions-0.0.27 → ultralytics_actions-0.0.34}/ultralytics_actions.egg-info/PKG-INFO +1 -1
  11. ultralytics_actions-0.0.27/actions/summarize_pr.py +0 -187
  12. ultralytics_actions-0.0.27/actions/utils/__init__.py +0 -43
  13. ultralytics_actions-0.0.27/actions/utils/github_utils.py +0 -136
  14. {ultralytics_actions-0.0.27 → ultralytics_actions-0.0.34}/LICENSE +0 -0
  15. {ultralytics_actions-0.0.27 → ultralytics_actions-0.0.34}/README.md +0 -0
  16. {ultralytics_actions-0.0.27 → ultralytics_actions-0.0.34}/actions/update_markdown_code_blocks.py +0 -0
  17. {ultralytics_actions-0.0.27 → ultralytics_actions-0.0.34}/actions/utils/common_utils.py +0 -0
  18. {ultralytics_actions-0.0.27 → ultralytics_actions-0.0.34}/setup.cfg +0 -0
  19. {ultralytics_actions-0.0.27 → ultralytics_actions-0.0.34}/ultralytics_actions.egg-info/SOURCES.txt +0 -0
  20. {ultralytics_actions-0.0.27 → ultralytics_actions-0.0.34}/ultralytics_actions.egg-info/dependency_links.txt +0 -0
  21. {ultralytics_actions-0.0.27 → ultralytics_actions-0.0.34}/ultralytics_actions.egg-info/entry_points.txt +0 -0
  22. {ultralytics_actions-0.0.27 → ultralytics_actions-0.0.34}/ultralytics_actions.egg-info/requires.txt +0 -0
  23. {ultralytics_actions-0.0.27 → ultralytics_actions-0.0.34}/ultralytics_actions.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: ultralytics-actions
3
- Version: 0.0.27
3
+ Version: 0.0.34
4
4
  Summary: Ultralytics Actions for GitHub automation and PR management.
5
5
  Author-email: Glenn Jocher <glenn.jocher@ultralytics.com>
6
6
  Maintainer-email: Ultralytics <hello@ultralytics.com>
@@ -22,4 +22,4 @@
22
22
  # ├── test_summarize_pr.py
23
23
  # └── ...
24
24
 
25
- __version__ = "0.0.27"
25
+ __version__ = "0.0.34"
@@ -6,15 +6,9 @@ from typing import Dict, List, Tuple
6
6
  import requests
7
7
 
8
8
  from .utils import (
9
- EVENT_DATA,
10
9
  GITHUB_API_URL,
11
- GITHUB_EVENT_NAME,
12
- GITHUB_HEADERS,
13
- GITHUB_REPOSITORY,
10
+ Action,
14
11
  get_completion,
15
- get_github_data,
16
- get_pr_diff,
17
- graphql_request,
18
12
  remove_html_comments,
19
13
  )
20
14
 
@@ -22,21 +16,23 @@ from .utils import (
22
16
  BLOCK_USER = os.getenv("BLOCK_USER", "false").lower() == "true"
23
17
 
24
18
 
25
- def get_event_content() -> Tuple[int, str, str, str, str, str, str]:
19
+ def get_event_content(event) -> Tuple[int, str, str, str, str, str, str]:
26
20
  """Extracts key information from GitHub event data for issues, pull requests, or discussions."""
27
- action = EVENT_DATA["action"] # 'opened', 'closed', 'created' (discussion), etc.
28
- if GITHUB_EVENT_NAME == "issues":
29
- item = EVENT_DATA["issue"]
21
+ data = event.event_data
22
+ name = event.event_name
23
+ action = data["action"] # 'opened', 'closed', 'created' (discussion), etc.
24
+ if name == "issues":
25
+ item = data["issue"]
30
26
  issue_type = "issue"
31
- elif GITHUB_EVENT_NAME in ["pull_request", "pull_request_target"]:
32
- pr_number = EVENT_DATA["pull_request"]["number"]
33
- item = get_github_data(f"pulls/{pr_number}")
27
+ elif name in ["pull_request", "pull_request_target"]:
28
+ pr_number = data["pull_request"]["number"]
29
+ item = event.get_repo_data(f"pulls/{pr_number}")
34
30
  issue_type = "pull request"
35
- elif GITHUB_EVENT_NAME == "discussion":
36
- item = EVENT_DATA["discussion"]
31
+ elif name == "discussion":
32
+ item = data["discussion"]
37
33
  issue_type = "discussion"
38
34
  else:
39
- raise ValueError(f"Unsupported event type: {GITHUB_EVENT_NAME}")
35
+ raise ValueError(f"Unsupported event type: {name}")
40
36
 
41
37
  number = item["number"]
42
38
  node_id = item.get("node_id") or item.get("id")
@@ -46,7 +42,7 @@ def get_event_content() -> Tuple[int, str, str, str, str, str, str]:
46
42
  return number, node_id, title, body, username, issue_type, action
47
43
 
48
44
 
49
- def update_issue_pr_content(number: int, node_id: str, issue_type: str):
45
+ def update_issue_pr_content(event, number: int, node_id: str, issue_type: str):
50
46
  """Updates the title and body of an issue, pull request, or discussion with predefined content."""
51
47
  new_title = "Content Under Review"
52
48
  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:
@@ -68,14 +64,14 @@ mutation($discussionId: ID!, $title: String!, $body: String!) {
68
64
  }
69
65
  }
70
66
  """
71
- graphql_request(mutation, variables={"discussionId": node_id, "title": new_title, "body": new_body})
67
+ event.graphql_request(mutation, variables={"discussionId": node_id, "title": new_title, "body": new_body})
72
68
  else:
73
- url = f"{GITHUB_API_URL}/repos/{GITHUB_REPOSITORY}/issues/{number}"
74
- r = requests.patch(url, json={"title": new_title, "body": new_body}, headers=GITHUB_HEADERS)
69
+ url = f"{GITHUB_API_URL}/repos/{event.repository}/issues/{number}"
70
+ r = requests.patch(url, json={"title": new_title, "body": new_body}, headers=event.headers)
75
71
  print(f"{'Successful' if r.status_code == 200 else 'Fail'} issue/PR #{number} update: {r.status_code}")
76
72
 
77
73
 
78
- def close_issue_pr(number: int, node_id: str, issue_type: str):
74
+ def close_issue_pr(event, number: int, node_id: str, issue_type: str):
79
75
  """Closes the specified issue, pull request, or discussion using the GitHub API."""
80
76
  if issue_type == "discussion":
81
77
  mutation = """
@@ -87,14 +83,14 @@ mutation($discussionId: ID!) {
87
83
  }
88
84
  }
89
85
  """
90
- graphql_request(mutation, variables={"discussionId": node_id})
86
+ event.graphql_request(mutation, variables={"discussionId": node_id})
91
87
  else:
92
- url = f"{GITHUB_API_URL}/repos/{GITHUB_REPOSITORY}/issues/{number}"
93
- r = requests.patch(url, json={"state": "closed"}, headers=GITHUB_HEADERS)
88
+ url = f"{GITHUB_API_URL}/repos/{event.repository}/issues/{number}"
89
+ r = requests.patch(url, json={"state": "closed"}, headers=event.headers)
94
90
  print(f"{'Successful' if r.status_code == 200 else 'Fail'} issue/PR #{number} close: {r.status_code}")
95
91
 
96
92
 
97
- def lock_issue_pr(number: int, node_id: str, issue_type: str):
93
+ def lock_issue_pr(event, number: int, node_id: str, issue_type: str):
98
94
  """Locks an issue, pull request, or discussion to prevent further interactions."""
99
95
  if issue_type == "discussion":
100
96
  mutation = """
@@ -108,17 +104,17 @@ mutation($lockableId: ID!, $lockReason: LockReason) {
108
104
  }
109
105
  }
110
106
  """
111
- graphql_request(mutation, variables={"lockableId": node_id, "lockReason": "OFF_TOPIC"})
107
+ event.graphql_request(mutation, variables={"lockableId": node_id, "lockReason": "OFF_TOPIC"})
112
108
  else:
113
- url = f"{GITHUB_API_URL}/repos/{GITHUB_REPOSITORY}/issues/{number}/lock"
114
- r = requests.put(url, json={"lock_reason": "off-topic"}, headers=GITHUB_HEADERS)
109
+ url = f"{GITHUB_API_URL}/repos/{event.repository}/issues/{number}/lock"
110
+ r = requests.put(url, json={"lock_reason": "off-topic"}, headers=event.headers)
115
111
  print(f"{'Successful' if r.status_code in {200, 204} else 'Fail'} issue/PR #{number} lock: {r.status_code}")
116
112
 
117
113
 
118
- def block_user(username: str):
114
+ def block_user(event, username: str):
119
115
  """Blocks a user from the organization using the GitHub API."""
120
- url = f"{GITHUB_API_URL}/orgs/{GITHUB_REPOSITORY.split('/')[0]}/blocks/{username}"
121
- r = requests.put(url, headers=GITHUB_HEADERS)
116
+ url = f"{GITHUB_API_URL}/orgs/{event.repository.split('/')[0]}/blocks/{username}"
117
+ r = requests.put(url, headers=event.headers)
122
118
  print(f"{'Successful' if r.status_code == 204 else 'Fail'} user block for {username}: {r.status_code}")
123
119
 
124
120
 
@@ -167,7 +163,7 @@ YOUR RESPONSE (label names only):
167
163
  messages = [
168
164
  {
169
165
  "role": "system",
170
- "content": "You are a helpful assistant that labels GitHub issues, pull requests, and discussions.",
166
+ "content": "You are an Ultralytics AI assistant that labels GitHub issues, PRs, and discussions.",
171
167
  },
172
168
  {"role": "user", "content": prompt},
173
169
  ]
@@ -183,7 +179,7 @@ YOUR RESPONSE (label names only):
183
179
  ]
184
180
 
185
181
 
186
- def get_label_ids(labels: List[str]) -> List[str]:
182
+ def get_label_ids(event, labels: List[str]) -> List[str]:
187
183
  """Retrieves GitHub label IDs for a list of label names using the GraphQL API."""
188
184
  query = """
189
185
  query($owner: String!, $name: String!) {
@@ -197,8 +193,8 @@ query($owner: String!, $name: String!) {
197
193
  }
198
194
  }
199
195
  """
200
- owner, repo = GITHUB_REPOSITORY.split("/")
201
- result = graphql_request(query, variables={"owner": owner, "name": repo})
196
+ owner, repo = event.repository.split("/")
197
+ result = event.graphql_request(query, variables={"owner": owner, "name": repo})
202
198
  if "data" in result and "repository" in result["data"]:
203
199
  all_labels = result["data"]["repository"]["labels"]["nodes"]
204
200
  label_map = {label["name"].lower(): label["id"] for label in all_labels}
@@ -208,14 +204,14 @@ query($owner: String!, $name: String!) {
208
204
  return []
209
205
 
210
206
 
211
- def apply_labels(number: int, node_id: str, labels: List[str], issue_type: str):
207
+ def apply_labels(event, number: int, node_id: str, labels: List[str], issue_type: str):
212
208
  """Applies specified labels to a GitHub issue, pull request, or discussion using the appropriate API."""
213
209
  if "Alert" in labels:
214
- create_alert_label()
210
+ create_alert_label(event)
215
211
 
216
212
  if issue_type == "discussion":
217
213
  print(f"Using node_id: {node_id}") # Debug print
218
- label_ids = get_label_ids(labels)
214
+ label_ids = get_label_ids(event, labels)
219
215
  if not label_ids:
220
216
  print("No valid labels to apply.")
221
217
  return
@@ -231,29 +227,29 @@ mutation($labelableId: ID!, $labelIds: [ID!]!) {
231
227
  }
232
228
  }
233
229
  """
234
- graphql_request(mutation, {"labelableId": node_id, "labelIds": label_ids})
230
+ event.graphql_request(mutation, {"labelableId": node_id, "labelIds": label_ids})
235
231
  print(f"Successfully applied labels: {', '.join(labels)}")
236
232
  else:
237
- url = f"{GITHUB_API_URL}/repos/{GITHUB_REPOSITORY}/issues/{number}/labels"
238
- r = requests.post(url, json={"labels": labels}, headers=GITHUB_HEADERS)
233
+ url = f"{GITHUB_API_URL}/repos/{event.repository}/issues/{number}/labels"
234
+ r = requests.post(url, json={"labels": labels}, headers=event.headers)
239
235
  print(f"{'Successful' if r.status_code == 200 else 'Fail'} apply labels {', '.join(labels)}: {r.status_code}")
240
236
 
241
237
 
242
- def create_alert_label():
238
+ def create_alert_label(event):
243
239
  """Creates the 'Alert' label in the repository if it doesn't exist, with a red color and description."""
244
240
  alert_label = {"name": "Alert", "color": "FF0000", "description": "Potential spam, abuse, or off-topic."}
245
- requests.post(f"{GITHUB_API_URL}/repos/{GITHUB_REPOSITORY}/labels", json=alert_label, headers=GITHUB_HEADERS)
241
+ requests.post(f"{GITHUB_API_URL}/repos/{event.repository}/labels", json=alert_label, headers=event.headers)
246
242
 
247
243
 
248
- def is_org_member(username: str) -> bool:
244
+ def is_org_member(event, username: str) -> bool:
249
245
  """Checks if a user is a member of the organization using the GitHub API."""
250
- org_name = GITHUB_REPOSITORY.split("/")[0]
246
+ org_name = event.repository.split("/")[0]
251
247
  url = f"{GITHUB_API_URL}/orgs/{org_name}/members/{username}"
252
- r = requests.get(url, headers=GITHUB_HEADERS)
248
+ r = requests.get(url, headers=event.headers)
253
249
  return r.status_code == 204 # 204 means the user is a member
254
250
 
255
251
 
256
- def add_comment(number: int, node_id: str, comment: str, issue_type: str):
252
+ def add_comment(event, number: int, node_id: str, comment: str, issue_type: str):
257
253
  """Adds a comment to the specified issue, pull request, or discussion using the GitHub API."""
258
254
  if issue_type == "discussion":
259
255
  mutation = """
@@ -265,17 +261,17 @@ mutation($discussionId: ID!, $body: String!) {
265
261
  }
266
262
  }
267
263
  """
268
- graphql_request(mutation, variables={"discussionId": node_id, "body": comment})
264
+ event.graphql_request(mutation, variables={"discussionId": node_id, "body": comment})
269
265
  else:
270
- url = f"{GITHUB_API_URL}/repos/{GITHUB_REPOSITORY}/issues/{number}/comments"
271
- r = requests.post(url, json={"body": comment}, headers=GITHUB_HEADERS)
266
+ url = f"{GITHUB_API_URL}/repos/{event.repository}/issues/{number}/comments"
267
+ r = requests.post(url, json={"body": comment}, headers=event.headers)
272
268
  print(f"{'Successful' if r.status_code in {200, 201} else 'Fail'} issue/PR #{number} comment: {r.status_code}")
273
269
 
274
270
 
275
- def get_first_interaction_response(issue_type: str, title: str, body: str, username: str, number: int) -> str:
271
+ def get_first_interaction_response(event, issue_type: str, title: str, body: str, username: str) -> str:
276
272
  """Generates a custom LLM response for GitHub issues, PRs, or discussions based on content."""
277
273
  issue_discussion_response = f"""
278
- 👋 Hello @{username}, thank you for submitting a `{GITHUB_REPOSITORY}` 🚀 {issue_type.capitalize()}. To help us address your concern efficiently, please ensure you've provided the following information:
274
+ 👋 Hello @{username}, thank you for submitting a `{event.repository}` 🚀 {issue_type.capitalize()}. To help us address your concern efficiently, please ensure you've provided the following information:
279
275
 
280
276
  1. For bug reports:
281
277
  - A clear and concise description of the bug
@@ -300,10 +296,10 @@ Thank you for your contribution to improving our project!
300
296
  """
301
297
 
302
298
  pr_response = f"""
303
- 👋 Hello @{username}, thank you for submitting an `{GITHUB_REPOSITORY}` 🚀 PR! To ensure a seamless integration of your work, please review the following checklist:
299
+ 👋 Hello @{username}, thank you for submitting an `{event.repository}` 🚀 PR! To ensure a seamless integration of your work, please review the following checklist:
304
300
 
305
- - ✅ **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/{GITHUB_REPOSITORY}/issues). Ensure your commit messages are clear, concise, and adhere to the project's conventions.
306
- - ✅ **Synchronize with Source**: Confirm your PR is synchronized with the `{GITHUB_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.
301
+ - ✅ **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/{event.repository}/issues). Ensure your commit messages are clear, concise, and adhere to the project's conventions.
302
+ - ✅ **Synchronize with Source**: Confirm your PR is synchronized with the `{event.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.
307
303
  - ✅ **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.
308
304
  - ✅ **Update Documentation**: Update the relevant [documentation](https://docs.ultralytics.com) for any new or modified features.
309
305
  - ✅ **Add Tests**: If applicable, include or update tests to cover your changes, and confirm that all tests are passing.
@@ -318,9 +314,9 @@ For more guidance, please refer to our [Contributing Guide](https://docs.ultraly
318
314
  else:
319
315
  example = os.getenv("FIRST_ISSUE_RESPONSE") or issue_discussion_response
320
316
 
321
- org_name, repo_name = GITHUB_REPOSITORY.split("/")
322
- repo_url = f"https://github.com/{GITHUB_REPOSITORY}"
323
- diff = get_pr_diff(number)[:32000] if issue_type == "pull request" else ""
317
+ org_name, repo_name = event.repository.split("/")
318
+ repo_url = f"https://github.com/{event.repository}"
319
+ diff = event.get_pr_diff()[:32000] if issue_type == "pull request" else ""
324
320
 
325
321
  prompt = f"""Generate a customized response to the new GitHub {issue_type} below:
326
322
 
@@ -359,39 +355,40 @@ YOUR {issue_type.upper()} RESPONSE:
359
355
  messages = [
360
356
  {
361
357
  "role": "system",
362
- "content": f"You are a helpful assistant responding to GitHub {issue_type}s for {org_name}.",
358
+ "content": f"You are an Ultralytics AI assistant responding to GitHub {issue_type}s for {org_name}.",
363
359
  },
364
360
  {"role": "user", "content": prompt},
365
361
  ]
366
362
  return get_completion(messages)
367
363
 
368
364
 
369
- def main():
370
- """Executes autolabeling and custom response generation for new GitHub issues, PRs, and discussions."""
371
- number, node_id, title, body, username, issue_type, action = get_event_content()
372
- available_labels = get_github_data("labels")
365
+ def main(*args, **kwargs):
366
+ """Executes auto-labeling and custom response generation for new GitHub issues, PRs, and discussions."""
367
+ event = Action(*args, **kwargs)
368
+ number, node_id, title, body, username, issue_type, action = get_event_content(event)
369
+ available_labels = event.get_repo_data("labels")
373
370
  label_descriptions = {label["name"]: label.get("description", "") for label in available_labels}
374
371
  if issue_type == "discussion":
375
372
  current_labels = [] # For discussions, labels may need to be fetched differently or adjusted
376
373
  else:
377
- current_labels = [label["name"].lower() for label in get_github_data(f"issues/{number}/labels")]
374
+ current_labels = [label["name"].lower() for label in event.get_repo_data(f"issues/{number}/labels")]
378
375
  relevant_labels = get_relevant_labels(issue_type, title, body, label_descriptions, current_labels)
379
376
 
380
377
  if relevant_labels:
381
- apply_labels(number, node_id, relevant_labels, issue_type)
382
- if "Alert" in relevant_labels and not is_org_member(username):
383
- update_issue_pr_content(number, node_id, issue_type)
378
+ apply_labels(event, number, node_id, relevant_labels, issue_type)
379
+ if "Alert" in relevant_labels and not is_org_member(event, username):
380
+ update_issue_pr_content(event, number, node_id, issue_type)
384
381
  if issue_type != "pull request":
385
- close_issue_pr(number, node_id, issue_type)
386
- lock_issue_pr(number, node_id, issue_type)
382
+ close_issue_pr(event, number, node_id, issue_type)
383
+ lock_issue_pr(event, number, node_id, issue_type)
387
384
  if BLOCK_USER:
388
- block_user(username=username)
385
+ block_user(event, username=username)
389
386
  else:
390
387
  print("No relevant labels found or applied.")
391
388
 
392
389
  if action in {"opened", "created"}:
393
- custom_response = get_first_interaction_response(issue_type, title, body, username, number)
394
- add_comment(number, node_id, custom_response, issue_type)
390
+ custom_response = get_first_interaction_response(event, issue_type, title, body, username)
391
+ add_comment(event, number, node_id, custom_response, issue_type)
395
392
 
396
393
 
397
394
  if __name__ == "__main__":
@@ -0,0 +1,244 @@
1
+ # Ultralytics Actions 🚀, AGPL-3.0 license https://ultralytics.com/license
2
+
3
+ import time
4
+
5
+ import requests
6
+
7
+ from .utils import (
8
+ GITHUB_API_URL,
9
+ Action,
10
+ get_completion,
11
+ )
12
+
13
+ # Constants
14
+ SUMMARY_START = (
15
+ "## 🛠️ PR Summary\n\n<sub>Made with ❤️ by [Ultralytics Actions](https://github.com/ultralytics/actions)<sub>\n\n"
16
+ )
17
+
18
+
19
+ def generate_merge_message(pr_summary=None, pr_credit=None):
20
+ """Generates a thank-you message for merged PR contributors."""
21
+ messages = [
22
+ {
23
+ "role": "system",
24
+ "content": "You are an Ultralytics AI assistant. Generate meaningful, inspiring messages to GitHub users.",
25
+ },
26
+ {
27
+ "role": "user",
28
+ "content": f"Write a friendly thank you for a merged GitHub PR by {pr_credit}. "
29
+ f"Context from PR:\n{pr_summary}\n\n"
30
+ f"Start with the exciting message that this PR is now merged, and weave in an inspiring but obscure quote "
31
+ f"from a historical figure in science, art, stoicism and philosophy. "
32
+ f"Keep the message concise yet relevant to the specific contributions in this PR. "
33
+ f"We want the contributors to feel their effort is appreciated and will make a difference in the world.",
34
+ },
35
+ ]
36
+ return get_completion(messages)
37
+
38
+
39
+ def post_merge_message(pr_number, repository, summary, pr_credit, headers):
40
+ """Posts thank you message on PR after merge."""
41
+ message = generate_merge_message(summary, pr_credit)
42
+ comment_url = f"{GITHUB_API_URL}/repos/{repository}/issues/{pr_number}/comments"
43
+ response = requests.post(comment_url, json={"body": message}, headers=headers)
44
+ return response.status_code == 201
45
+
46
+
47
+ def generate_issue_comment(pr_url, pr_summary, pr_credit):
48
+ """Generates a personalized issue comment using based on the PR context."""
49
+ messages = [
50
+ {
51
+ "role": "system",
52
+ "content": "You are an Ultralytics AI assistant. Generate friendly GitHub issue comments. No @ mentions or direct addressing.",
53
+ },
54
+ {
55
+ "role": "user",
56
+ "content": f"Write a GitHub issue comment announcing a potential fix for this issue is now merged in linked PR {pr_url} by {pr_credit}\n\n"
57
+ f"Context from PR:\n{pr_summary}\n\n"
58
+ f"Include:\n"
59
+ f"1. An explanation of key changes from the PR that may resolve this issue\n"
60
+ f"2. Credit to the PR author and contributors\n"
61
+ f"3. Options for testing if PR changes have resolved this issue:\n"
62
+ f" - pip install git+https://github.com/ultralytics/ultralytics.git@main # test latest changes\n"
63
+ f" - or await next official PyPI release\n"
64
+ f"4. Request feedback on whether the PR changes resolve the issue\n"
65
+ f"5. Thank 🙏 for reporting the issue and welcome any further feedback if the issue persists\n\n",
66
+ },
67
+ ]
68
+ return get_completion(messages)
69
+
70
+
71
+ def generate_pr_summary(repository, diff_text):
72
+ """Generates a concise, professional summary of a PR using OpenAI's API for Ultralytics repositories."""
73
+ if not diff_text:
74
+ diff_text = "**ERROR: DIFF IS EMPTY, THERE ARE ZERO CODE CHANGES IN THIS PR."
75
+ ratio = 3.3 # about 3.3 characters per token
76
+ limit = round(128000 * ratio * 0.5) # use up to 50% of the 128k context window for prompt
77
+ messages = [
78
+ {
79
+ "role": "system",
80
+ "content": "You are an Ultralytics AI assistant skilled in software development and technical communication. Your task is to summarize GitHub PRs from Ultralytics in a way that is accurate, concise, and understandable to both expert developers and non-expert users. Focus on highlighting the key changes and their impact in simple, concise terms.",
81
+ },
82
+ {
83
+ "role": "user",
84
+ "content": f"Summarize this '{repository}' 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. Reply directly with a summary along these example guidelines, though feel free to adjust as appropriate:\n\n"
85
+ f"### 🌟 Summary (single-line synopsis)\n"
86
+ f"### 📊 Key Changes (bullet points highlighting any major changes)\n"
87
+ f"### 🎯 Purpose & Impact (bullet points explaining any benefits and potential impact to users)\n"
88
+ f"\n\nHere's the PR diff:\n\n{diff_text[:limit]}",
89
+ },
90
+ ]
91
+ reply = get_completion(messages)
92
+ if len(diff_text) > limit:
93
+ reply = "**WARNING ⚠️** this PR is very large, summary may not cover all changes.\n\n" + reply
94
+ return SUMMARY_START + reply
95
+
96
+
97
+ def update_pr_description(repository, pr_number, new_summary, headers, max_retries=2):
98
+ """Updates PR description with new summary, retrying if description is None."""
99
+ pr_url = f"{GITHUB_API_URL}/repos/{repository}/pulls/{pr_number}"
100
+ description = ""
101
+ for i in range(max_retries + 1):
102
+ description = requests.get(pr_url, headers=headers).json().get("body") or ""
103
+ if description:
104
+ break
105
+ if i < max_retries:
106
+ print("No current PR description found, retrying...")
107
+ time.sleep(1)
108
+
109
+ # Check if existing summary is present and update accordingly
110
+ start = "## 🛠️ PR Summary"
111
+ if start in description:
112
+ print("Existing PR Summary found, replacing.")
113
+ updated_description = description.split(start)[0] + new_summary
114
+ else:
115
+ print("PR Summary not found, appending.")
116
+ updated_description = description + "\n\n" + new_summary
117
+
118
+ # Update the PR description
119
+ update_response = requests.patch(pr_url, json={"body": updated_description}, headers=headers)
120
+ return update_response.status_code
121
+
122
+
123
+ def label_fixed_issues(repository, pr_number, pr_summary, headers, action):
124
+ """Labels issues closed by PR when merged, notifies users, returns PR contributors."""
125
+ query = """
126
+ query($owner: String!, $repo: String!, $pr_number: Int!) {
127
+ repository(owner: $owner, name: $repo) {
128
+ pullRequest(number: $pr_number) {
129
+ closingIssuesReferences(first: 50) { nodes { number } }
130
+ url
131
+ body
132
+ author { login, __typename }
133
+ reviews(first: 50) { nodes { author { login, __typename } } }
134
+ comments(first: 50) { nodes { author { login, __typename } } }
135
+ commits(first: 100) { nodes { commit { author { user { login } }, committer { user { login } } } } }
136
+ }
137
+ }
138
+ }
139
+ """
140
+ owner, repo = repository.split("/")
141
+ variables = {"owner": owner, "repo": repo, "pr_number": pr_number}
142
+ graphql_url = "https://api.github.com/graphql"
143
+ response = requests.post(graphql_url, json={"query": query, "variables": variables}, headers=headers)
144
+
145
+ if response.status_code != 200:
146
+ print(f"Failed to fetch linked issues. Status code: {response.status_code}")
147
+ return [], None
148
+
149
+ try:
150
+ data = response.json()["data"]["repository"]["pullRequest"]
151
+ comments = data["reviews"]["nodes"] + data["comments"]["nodes"]
152
+ token_username = action.get_username() # get GITHUB_TOKEN username
153
+ author = data["author"]["login"] if data["author"]["__typename"] != "Bot" else None
154
+
155
+ # Get unique contributors from reviews and comments
156
+ contributors = {x["author"]["login"] for x in comments if x["author"]["__typename"] != "Bot"}
157
+
158
+ # Add commit authors and committers that have GitHub accounts linked
159
+ for commit in data["commits"]["nodes"]:
160
+ commit_data = commit["commit"]
161
+ for user_type in ["author", "committer"]:
162
+ if user := commit_data[user_type].get("user"):
163
+ if login := user.get("login"):
164
+ contributors.add(login)
165
+
166
+ contributors.discard(author)
167
+ contributors.discard(token_username)
168
+
169
+ # Write credit string
170
+ pr_credit = "" # i.e. "@user1 with contributions from @user2, @user3"
171
+ if author and author != token_username:
172
+ pr_credit += f"@{author}"
173
+ if contributors:
174
+ pr_credit += (" with contributions from " if pr_credit else "") + ", ".join(f"@{c}" for c in contributors)
175
+
176
+ # Generate personalized comment
177
+ comment = generate_issue_comment(pr_url=data["url"], pr_summary=pr_summary, pr_credit=pr_credit)
178
+
179
+ # Update linked issues
180
+ for issue in data["closingIssuesReferences"]["nodes"]:
181
+ issue_number = issue["number"]
182
+ # Add fixed label
183
+ label_url = f"{GITHUB_API_URL}/repos/{repository}/issues/{issue_number}/labels"
184
+ label_response = requests.post(label_url, json={"labels": ["fixed"]}, headers=headers)
185
+
186
+ # Add comment
187
+ comment_url = f"{GITHUB_API_URL}/repos/{repository}/issues/{issue_number}/comments"
188
+ comment_response = requests.post(comment_url, json={"body": comment}, headers=headers)
189
+
190
+ if label_response.status_code == 200 and comment_response.status_code == 201:
191
+ print(f"Added 'fixed' label and comment to issue #{issue_number}")
192
+ else:
193
+ print(
194
+ f"Failed to update issue #{issue_number}. Label status: {label_response.status_code}, "
195
+ f"Comment status: {comment_response.status_code}"
196
+ )
197
+
198
+ return pr_credit
199
+ except KeyError as e:
200
+ print(f"Error parsing GraphQL response: {e}")
201
+ return [], None
202
+
203
+
204
+ def remove_todos_on_merge(pr_number, repository, headers):
205
+ """Removes specified labels from PR."""
206
+ for label in ["TODO"]: # Can be extended with more labels in the future
207
+ requests.delete(f"{GITHUB_API_URL}/repos/{repository}/issues/{pr_number}/labels/{label}", headers=headers)
208
+
209
+
210
+ def main(*args, **kwargs):
211
+ """Summarize a pull request and update its description with a summary."""
212
+ action = Action(*args, **kwargs)
213
+ pr_number = action.pr["number"]
214
+ headers = action.headers
215
+ repository = action.repository
216
+
217
+ print(f"Retrieving diff for PR {pr_number}")
218
+ diff = action.get_pr_diff()
219
+
220
+ # Generate PR summary
221
+ print("Generating PR summary...")
222
+ summary = generate_pr_summary(repository, diff)
223
+
224
+ # Update PR description
225
+ print("Updating PR description...")
226
+ status_code = update_pr_description(repository, pr_number, summary, headers)
227
+ if status_code == 200:
228
+ print("PR description updated successfully.")
229
+ else:
230
+ print(f"Failed to update PR description. Status code: {status_code}")
231
+
232
+ # Update linked issues and post thank you message if merged
233
+ if action.pr.get("merged"):
234
+ print("PR is merged, labeling fixed issues...")
235
+ pr_credit = label_fixed_issues(repository, pr_number, summary, headers, action)
236
+ print("Removing TODO label from PR...")
237
+ remove_todos_on_merge(pr_number, repository, headers)
238
+ if pr_credit:
239
+ print("Posting PR author thank you message...")
240
+ post_merge_message(pr_number, repository, summary, pr_credit, headers)
241
+
242
+
243
+ if __name__ == "__main__":
244
+ main()