ultralytics-actions 0.0.69__py3-none-any.whl → 0.0.71__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.
actions/__init__.py CHANGED
@@ -22,4 +22,4 @@
22
22
  # ├── test_summarize_pr.py
23
23
  # └── ...
24
24
 
25
- __version__ = "0.0.69"
25
+ __version__ = "0.0.71"
@@ -0,0 +1,116 @@
1
+ # Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
2
+
3
+ import time
4
+ from datetime import datetime
5
+ from typing import Dict, List
6
+
7
+ from .utils import GITHUB_API_URL, Action
8
+
9
+ # Configuration
10
+ RUN_CI_KEYWORD = "@ultralytics/run-ci" # and then to merge "@ultralytics/run-ci-and-merge"
11
+ WORKFLOW_FILES = ["ci.yml", "docker.yml"]
12
+
13
+
14
+ def get_pr_branch(event) -> str:
15
+ """Gets the PR branch name."""
16
+ pr_number = event.event_data["issue"]["number"]
17
+ pr_data = event.get_repo_data(f"pulls/{pr_number}")
18
+ return pr_data.get("head", {}).get("ref", "main")
19
+
20
+
21
+ def trigger_and_get_workflow_info(event, branch: str) -> List[Dict]:
22
+ """Triggers workflows and returns their information."""
23
+ repo = event.repository
24
+ results = []
25
+
26
+ # Trigger all workflows
27
+ for file in WORKFLOW_FILES:
28
+ event.post(f"{GITHUB_API_URL}/repos/{repo}/actions/workflows/{file}/dispatches", json={"ref": branch})
29
+
30
+ # Wait for workflows to be created
31
+ time.sleep(10)
32
+
33
+ # Collect information about all workflows
34
+ for file in WORKFLOW_FILES:
35
+ # Get workflow name
36
+ response = event.get(f"{GITHUB_API_URL}/repos/{repo}/actions/workflows/{file}")
37
+ name = file.replace(".yml", "").title()
38
+ if response.status_code == 200:
39
+ name = response.json().get("name", name)
40
+
41
+ # Get run information
42
+ run_url = f"https://github.com/{repo}/actions/workflows/{file}"
43
+ run_number = None
44
+
45
+ runs_response = event.get(
46
+ f"{GITHUB_API_URL}/repos/{repo}/actions/workflows/{file}/runs?branch={branch}&event=workflow_dispatch&per_page=1",
47
+ )
48
+
49
+ if runs_response.status_code == 200:
50
+ runs = runs_response.json().get("workflow_runs", [])
51
+ if runs:
52
+ run_url = runs[0].get("html_url", run_url)
53
+ run_number = runs[0].get("run_number")
54
+
55
+ results.append({"name": name, "file": file, "url": run_url, "run_number": run_number})
56
+
57
+ return results
58
+
59
+
60
+ def update_comment(event, comment_body: str, triggered_actions: List[Dict], branch: str) -> bool:
61
+ """Updates the comment with workflow information."""
62
+ if not triggered_actions:
63
+ return False
64
+
65
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S UTC")
66
+ summary = (
67
+ f"\n\n## ⚡ Actions Trigger\n\n"
68
+ f"<sub>Made with ❤️ by [Ultralytics Actions](https://www.ultralytics.com/actions)<sub>\n\n"
69
+ f"GitHub Actions below triggered via workflow dispatch on this "
70
+ f"PR branch `{branch}` at {timestamp} with `{RUN_CI_KEYWORD}` command:\n\n"
71
+ )
72
+
73
+ for action in triggered_actions:
74
+ run_info = f" run {action['run_number']}" if action["run_number"] else ""
75
+ summary += f"* ✅ [{action['name']}]({action['url']}): `{action['file']}`{run_info}\n"
76
+
77
+ new_body = comment_body.replace(RUN_CI_KEYWORD, summary).strip()
78
+ comment_id = event.event_data["comment"]["id"]
79
+ event.patch(f"{GITHUB_API_URL}/repos/{event.repository}/issues/comments/{comment_id}", json={"body": new_body})
80
+
81
+
82
+ def main(*args, **kwargs):
83
+ """Handles triggering workflows from PR comments."""
84
+ event = Action(*args, **kwargs)
85
+
86
+ # Only process new comments on PRs
87
+ if (
88
+ event.event_name != "issue_comment"
89
+ or "pull_request" not in event.event_data.get("issue", {})
90
+ or event.event_data.get("action") != "created"
91
+ ):
92
+ return
93
+
94
+ # Get comment info
95
+ comment_body = event.event_data["comment"].get("body", "")
96
+ username = event.event_data["comment"]["user"]["login"]
97
+
98
+ # Check for keyword without surrounding backticks to avoid triggering on replies
99
+ has_keyword = RUN_CI_KEYWORD in comment_body and comment_body.count(RUN_CI_KEYWORD) > comment_body.count(
100
+ f"`{RUN_CI_KEYWORD}`"
101
+ )
102
+ if not has_keyword or not event.is_org_member(username):
103
+ return
104
+
105
+ # Get branch, trigger workflows, and update comment
106
+ event.toggle_eyes_reaction(True)
107
+ branch = get_pr_branch(event)
108
+ print(f"Triggering workflows on branch: {branch}")
109
+
110
+ triggered_actions = trigger_and_get_workflow_info(event, branch)
111
+ update_comment(event, comment_body, triggered_actions, branch)
112
+ event.toggle_eyes_reaction(False)
113
+
114
+
115
+ if __name__ == "__main__":
116
+ main()
@@ -3,14 +3,7 @@
3
3
  import os
4
4
  from typing import Dict, List, Tuple
5
5
 
6
- import requests
7
-
8
- from .utils import (
9
- GITHUB_API_URL,
10
- Action,
11
- get_completion,
12
- remove_html_comments,
13
- )
6
+ from .utils import GITHUB_API_URL, Action, get_completion, remove_html_comments
14
7
 
15
8
  # Environment variables
16
9
  BLOCK_USER = os.getenv("BLOCK_USER", "false").lower() == "true"
@@ -67,8 +60,7 @@ mutation($discussionId: ID!, $title: String!, $body: String!) {
67
60
  event.graphql_request(mutation, variables={"discussionId": node_id, "title": new_title, "body": new_body})
68
61
  else:
69
62
  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)
71
- print(f"{'Successful' if r.status_code == 200 else 'Fail'} issue/PR #{number} update: {r.status_code}")
63
+ event.patch(url, json={"title": new_title, "body": new_body})
72
64
 
73
65
 
74
66
  def close_issue_pr(event, number: int, node_id: str, issue_type: str):
@@ -86,8 +78,7 @@ mutation($discussionId: ID!) {
86
78
  event.graphql_request(mutation, variables={"discussionId": node_id})
87
79
  else:
88
80
  url = f"{GITHUB_API_URL}/repos/{event.repository}/issues/{number}"
89
- r = requests.patch(url, json={"state": "closed"}, headers=event.headers)
90
- print(f"{'Successful' if r.status_code == 200 else 'Fail'} issue/PR #{number} close: {r.status_code}")
81
+ event.patch(url, json={"state": "closed"})
91
82
 
92
83
 
93
84
  def lock_issue_pr(event, number: int, node_id: str, issue_type: str):
@@ -107,15 +98,13 @@ mutation($lockableId: ID!, $lockReason: LockReason) {
107
98
  event.graphql_request(mutation, variables={"lockableId": node_id, "lockReason": "OFF_TOPIC"})
108
99
  else:
109
100
  url = f"{GITHUB_API_URL}/repos/{event.repository}/issues/{number}/lock"
110
- r = requests.put(url, json={"lock_reason": "off-topic"}, headers=event.headers)
111
- print(f"{'Successful' if r.status_code in {200, 204} else 'Fail'} issue/PR #{number} lock: {r.status_code}")
101
+ event.put(url, json={"lock_reason": "off-topic"})
112
102
 
113
103
 
114
104
  def block_user(event, username: str):
115
105
  """Blocks a user from the organization using the GitHub API."""
116
106
  url = f"{GITHUB_API_URL}/orgs/{event.repository.split('/')[0]}/blocks/{username}"
117
- r = requests.put(url, headers=event.headers)
118
- print(f"{'Successful' if r.status_code == 204 else 'Fail'} user block for {username}: {r.status_code}")
107
+ event.put(url)
119
108
 
120
109
 
121
110
  def get_relevant_labels(
@@ -169,7 +158,6 @@ AVAILABLE LABELS:
169
158
 
170
159
  YOUR RESPONSE (label names only):
171
160
  """
172
- print(prompt) # for short-term debugging
173
161
  messages = [
174
162
  {
175
163
  "role": "system",
@@ -210,7 +198,6 @@ query($owner: String!, $name: String!) {
210
198
  label_map = {label["name"].lower(): label["id"] for label in all_labels}
211
199
  return [label_map.get(label.lower()) for label in labels if label.lower() in label_map]
212
200
  else:
213
- print(f"Failed to fetch labels: {result.get('errors', 'Unknown error')}")
214
201
  return []
215
202
 
216
203
 
@@ -220,7 +207,6 @@ def apply_labels(event, number: int, node_id: str, labels: List[str], issue_type
220
207
  create_alert_label(event)
221
208
 
222
209
  if issue_type == "discussion":
223
- print(f"Using node_id: {node_id}") # Debug print
224
210
  label_ids = get_label_ids(event, labels)
225
211
  if not label_ids:
226
212
  print("No valid labels to apply.")
@@ -238,25 +224,15 @@ mutation($labelableId: ID!, $labelIds: [ID!]!) {
238
224
  }
239
225
  """
240
226
  event.graphql_request(mutation, {"labelableId": node_id, "labelIds": label_ids})
241
- print(f"Successfully applied labels: {', '.join(labels)}")
242
227
  else:
243
228
  url = f"{GITHUB_API_URL}/repos/{event.repository}/issues/{number}/labels"
244
- r = requests.post(url, json={"labels": labels}, headers=event.headers)
245
- print(f"{'Successful' if r.status_code == 200 else 'Fail'} apply labels {', '.join(labels)}: {r.status_code}")
229
+ event.post(url, json={"labels": labels})
246
230
 
247
231
 
248
232
  def create_alert_label(event):
249
233
  """Creates the 'Alert' label in the repository if it doesn't exist, with a red color and description."""
250
234
  alert_label = {"name": "Alert", "color": "FF0000", "description": "Potential spam, abuse, or off-topic."}
251
- requests.post(f"{GITHUB_API_URL}/repos/{event.repository}/labels", json=alert_label, headers=event.headers)
252
-
253
-
254
- def is_org_member(event, username: str) -> bool:
255
- """Checks if a user is a member of the organization using the GitHub API."""
256
- org_name = event.repository.split("/")[0]
257
- url = f"{GITHUB_API_URL}/orgs/{org_name}/members/{username}"
258
- r = requests.get(url, headers=event.headers)
259
- return r.status_code == 204 # 204 means the user is a member
235
+ event.post(f"{GITHUB_API_URL}/repos/{event.repository}/labels", json=alert_label)
260
236
 
261
237
 
262
238
  def add_comment(event, number: int, node_id: str, comment: str, issue_type: str):
@@ -274,8 +250,7 @@ mutation($discussionId: ID!, $body: String!) {
274
250
  event.graphql_request(mutation, variables={"discussionId": node_id, "body": comment})
275
251
  else:
276
252
  url = f"{GITHUB_API_URL}/repos/{event.repository}/issues/{number}/comments"
277
- r = requests.post(url, json={"body": comment}, headers=event.headers)
278
- print(f"{'Successful' if r.status_code in {200, 201} else 'Fail'} issue/PR #{number} comment: {r.status_code}")
253
+ event.post(url, json={"body": comment}, headers=event.headers)
279
254
 
280
255
 
281
256
  def get_first_interaction_response(event, issue_type: str, title: str, body: str, username: str) -> str:
@@ -361,7 +336,7 @@ EXAMPLE {issue_type.upper()} RESPONSE:
361
336
 
362
337
  YOUR {issue_type.upper()} RESPONSE:
363
338
  """
364
- print(f"\n\n{prompt}\n\n") # for debug
339
+ # print(f"\n\n{prompt}\n\n") # for debug
365
340
  messages = [
366
341
  {
367
342
  "role": "system",
@@ -384,7 +359,7 @@ def main(*args, **kwargs):
384
359
  current_labels = [label["name"].lower() for label in event.get_repo_data(f"issues/{number}/labels")]
385
360
  if relevant_labels := get_relevant_labels(issue_type, title, body, label_descriptions, current_labels):
386
361
  apply_labels(event, number, node_id, relevant_labels, issue_type)
387
- if "Alert" in relevant_labels and not is_org_member(event, username):
362
+ if "Alert" in relevant_labels and not event.is_org_member(username):
388
363
  update_issue_pr_content(event, number, node_id, issue_type)
389
364
  if issue_type != "pull request":
390
365
  close_issue_pr(event, number, node_id, issue_type)
actions/summarize_pr.py CHANGED
@@ -2,13 +2,7 @@
2
2
 
3
3
  import time
4
4
 
5
- import requests
6
-
7
- from .utils import (
8
- GITHUB_API_URL,
9
- Action,
10
- get_completion,
11
- )
5
+ from .utils import GITHUB_API_URL, GITHUB_GRAPHQL_URL, Action, get_completion
12
6
 
13
7
  # Constants
14
8
  SUMMARY_START = (
@@ -36,12 +30,12 @@ def generate_merge_message(pr_summary=None, pr_credit=None, pr_url=None):
36
30
  return get_completion(messages)
37
31
 
38
32
 
39
- def post_merge_message(pr_number, pr_url, repository, summary, pr_credit, headers):
33
+ def post_merge_message(event, summary, pr_credit):
40
34
  """Posts thank you message on PR after merge."""
35
+ pr_url = f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{event.pr['number']}"
36
+ comment_url = f"{GITHUB_API_URL}/repos/{event.repository}/issues/{event.pr['number']}/comments"
41
37
  message = generate_merge_message(summary, pr_credit, pr_url)
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
38
+ event.post(comment_url, json={"body": message})
45
39
 
46
40
 
47
41
  def generate_issue_comment(pr_url, pr_summary, pr_credit, pr_title=""):
@@ -101,11 +95,12 @@ def generate_pr_summary(repository, diff_text):
101
95
  return SUMMARY_START + reply
102
96
 
103
97
 
104
- def update_pr_description(pr_url, new_summary, headers, max_retries=2):
98
+ def update_pr_description(event, new_summary, max_retries=2):
105
99
  """Updates PR description with new summary, retrying if description is None."""
106
100
  description = ""
101
+ url = f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{event.pr['number']}"
107
102
  for i in range(max_retries + 1):
108
- description = requests.get(pr_url, headers=headers).json().get("body") or ""
103
+ description = event.get(url).json().get("body") or ""
109
104
  if description:
110
105
  break
111
106
  if i < max_retries:
@@ -122,11 +117,10 @@ def update_pr_description(pr_url, new_summary, headers, max_retries=2):
122
117
  updated_description = description + "\n\n" + new_summary
123
118
 
124
119
  # Update the PR description
125
- update_response = requests.patch(pr_url, json={"body": updated_description}, headers=headers)
126
- return update_response.status_code
120
+ event.patch(url, json={"body": updated_description})
127
121
 
128
122
 
129
- def label_fixed_issues(repository, pr_number, pr_summary, headers, action):
123
+ def label_fixed_issues(event, pr_summary):
130
124
  """Labels issues closed by PR when merged, notifies users, and returns PR contributors."""
131
125
  query = """
132
126
  query($owner: String!, $repo: String!, $pr_number: Int!) {
@@ -144,19 +138,16 @@ query($owner: String!, $repo: String!, $pr_number: Int!) {
144
138
  }
145
139
  }
146
140
  """
147
- owner, repo = repository.split("/")
148
- variables = {"owner": owner, "repo": repo, "pr_number": pr_number}
149
- graphql_url = "https://api.github.com/graphql"
150
- response = requests.post(graphql_url, json={"query": query, "variables": variables}, headers=headers)
151
-
141
+ owner, repo = event.repository.split("/")
142
+ variables = {"owner": owner, "repo": repo, "pr_number": event.pr["number"]}
143
+ response = event.post(GITHUB_GRAPHQL_URL, json={"query": query, "variables": variables})
152
144
  if response.status_code != 200:
153
- print(f"Failed to fetch linked issues. Status code: {response.status_code}")
154
- return None
145
+ return None # no linked issues
155
146
 
156
147
  try:
157
148
  data = response.json()["data"]["repository"]["pullRequest"]
158
149
  comments = data["reviews"]["nodes"] + data["comments"]["nodes"]
159
- token_username = action.get_username() # get GITHUB_TOKEN username
150
+ token_username = event.get_username() # get GITHUB_TOKEN username
160
151
  author = data["author"]["login"] if data["author"]["__typename"] != "Bot" else None
161
152
  pr_title = data.get("title", "")
162
153
 
@@ -188,22 +179,12 @@ query($owner: String!, $repo: String!, $pr_number: Int!) {
188
179
 
189
180
  # Update linked issues
190
181
  for issue in data["closingIssuesReferences"]["nodes"]:
191
- issue_number = issue["number"]
182
+ number = issue["number"]
192
183
  # Add fixed label
193
- label_url = f"{GITHUB_API_URL}/repos/{repository}/issues/{issue_number}/labels"
194
- label_response = requests.post(label_url, json={"labels": ["fixed"]}, headers=headers)
184
+ event.post(f"{GITHUB_API_URL}/repos/{event.repository}/issues/{number}/labels", json={"labels": ["fixed"]})
195
185
 
196
186
  # Add comment
197
- comment_url = f"{GITHUB_API_URL}/repos/{repository}/issues/{issue_number}/comments"
198
- comment_response = requests.post(comment_url, json={"body": comment}, headers=headers)
199
-
200
- if label_response.status_code == 200 and comment_response.status_code == 201:
201
- print(f"Added 'fixed' label and comment to issue #{issue_number}")
202
- else:
203
- print(
204
- f"Failed to update issue #{issue_number}. Label status: {label_response.status_code}, "
205
- f"Comment status: {comment_response.status_code}"
206
- )
187
+ event.post(f"{GITHUB_API_URL}/repos/{event.repository}/issues/{number}/comments", json={"body": comment})
207
188
 
208
189
  return pr_credit
209
190
  except KeyError as e:
@@ -211,44 +192,36 @@ query($owner: String!, $repo: String!, $pr_number: Int!) {
211
192
  return None
212
193
 
213
194
 
214
- def remove_todos_on_merge(pr_number, repository, headers):
195
+ def remove_pr_labels(event, labels=()):
215
196
  """Removes specified labels from PR."""
216
- for label in ["TODO"]: # Can be extended with more labels in the future
217
- requests.delete(f"{GITHUB_API_URL}/repos/{repository}/issues/{pr_number}/labels/{label}", headers=headers)
197
+ for label in labels: # Can be extended with more labels in the future
198
+ event.delete(f"{GITHUB_API_URL}/repos/{event.repository}/issues/{event.pr['number']}/labels/{label}")
218
199
 
219
200
 
220
201
  def main(*args, **kwargs):
221
202
  """Summarize a pull request and update its description with a summary."""
222
- action = Action(*args, **kwargs)
223
- headers = action.headers
224
- repository = action.repository
225
- pr_number = action.pr["number"]
226
- pr_url = f"{GITHUB_API_URL}/repos/{repository}/pulls/{pr_number}"
203
+ event = Action(*args, **kwargs)
227
204
 
228
- print(f"Retrieving diff for PR {pr_number}")
229
- diff = action.get_pr_diff()
205
+ print(f"Retrieving diff for PR {event.pr['number']}")
206
+ diff = event.get_pr_diff()
230
207
 
231
208
  # Generate PR summary
232
209
  print("Generating PR summary...")
233
- summary = generate_pr_summary(repository, diff)
210
+ summary = generate_pr_summary(event.repository, diff)
234
211
 
235
212
  # Update PR description
236
213
  print("Updating PR description...")
237
- status_code = update_pr_description(pr_url, summary, headers)
238
- if status_code == 200:
239
- print("PR description updated successfully.")
240
- else:
241
- print(f"Failed to update PR description. Status code: {status_code}")
214
+ update_pr_description(event, summary)
242
215
 
243
216
  # Update linked issues and post thank you message if merged
244
- if action.pr.get("merged"):
217
+ if event.pr.get("merged"):
245
218
  print("PR is merged, labeling fixed issues...")
246
- pr_credit = label_fixed_issues(repository, pr_number, summary, headers, action)
219
+ pr_credit = label_fixed_issues(event, summary)
247
220
  print("Removing TODO label from PR...")
248
- remove_todos_on_merge(pr_number, repository, headers)
221
+ remove_pr_labels(event, labels=["TODO"])
249
222
  if pr_credit:
250
223
  print("Posting PR author thank you message...")
251
- post_merge_message(pr_number, pr_url, repository, summary, pr_credit, headers)
224
+ post_merge_message(event, summary, pr_credit)
252
225
 
253
226
 
254
227
  if __name__ == "__main__":
@@ -6,36 +6,27 @@ import subprocess
6
6
  import time
7
7
  from datetime import datetime
8
8
 
9
- import requests
10
-
11
- from .utils import (
12
- GITHUB_API_URL,
13
- Action,
14
- get_completion,
15
- remove_html_comments,
16
- )
9
+ from .utils import GITHUB_API_URL, Action, get_completion, remove_html_comments
17
10
 
18
11
  # Environment variables
19
12
  CURRENT_TAG = os.getenv("CURRENT_TAG")
20
13
  PREVIOUS_TAG = os.getenv("PREVIOUS_TAG")
21
14
 
22
15
 
23
- def get_release_diff(repo_name: str, previous_tag: str, latest_tag: str, headers: dict) -> str:
16
+ def get_release_diff(event, previous_tag: str, latest_tag: str) -> str:
24
17
  """Retrieves the differences between two specified Git tags in a GitHub repository."""
25
- url = f"{GITHUB_API_URL}/repos/{repo_name}/compare/{previous_tag}...{latest_tag}"
26
- r = requests.get(url, headers=headers)
18
+ url = f"{GITHUB_API_URL}/repos/{event.repository}/compare/{previous_tag}...{latest_tag}"
19
+ r = event.get(url, headers=event.headers_diff)
27
20
  return r.text if r.status_code == 200 else f"Failed to get diff: {r.content}"
28
21
 
29
22
 
30
- def get_prs_between_tags(repo_name: str, previous_tag: str, latest_tag: str, headers: dict) -> list:
23
+ def get_prs_between_tags(event, previous_tag: str, latest_tag: str) -> list:
31
24
  """Retrieves and processes pull requests merged between two specified tags in a GitHub repository."""
32
- url = f"{GITHUB_API_URL}/repos/{repo_name}/compare/{previous_tag}...{latest_tag}"
33
- r = requests.get(url, headers=headers)
34
- r.raise_for_status()
25
+ url = f"{GITHUB_API_URL}/repos/{event.repository}/compare/{previous_tag}...{latest_tag}"
26
+ r = event.get(url)
35
27
 
36
28
  data = r.json()
37
29
  pr_numbers = set()
38
-
39
30
  for commit in data["commits"]:
40
31
  pr_matches = re.findall(r"#(\d+)", commit["commit"]["message"])
41
32
  pr_numbers.update(pr_matches)
@@ -43,8 +34,8 @@ def get_prs_between_tags(repo_name: str, previous_tag: str, latest_tag: str, hea
43
34
  prs = []
44
35
  time.sleep(10) # sleep 10 seconds to allow final PR summary to update on merge
45
36
  for pr_number in sorted(pr_numbers): # earliest to latest
46
- pr_url = f"{GITHUB_API_URL}/repos/{repo_name}/pulls/{pr_number}"
47
- pr_response = requests.get(pr_url, headers=headers)
37
+ pr_url = f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{pr_number}"
38
+ pr_response = event.get(pr_url)
48
39
  if pr_response.status_code == 200:
49
40
  pr_data = pr_response.json()
50
41
  prs.append(
@@ -64,14 +55,14 @@ def get_prs_between_tags(repo_name: str, previous_tag: str, latest_tag: str, hea
64
55
  return prs
65
56
 
66
57
 
67
- def get_new_contributors(repo: str, prs: list, headers: dict) -> set:
58
+ def get_new_contributors(event, prs: list) -> set:
68
59
  """Identify new contributors who made their first merged PR in the current release."""
69
60
  new_contributors = set()
70
61
  for pr in prs:
71
62
  author = pr["author"]
72
63
  # Check if this is the author's first contribution
73
- url = f"{GITHUB_API_URL}/search/issues?q=repo:{repo}+author:{author}+is:pr+is:merged&sort=created&order=asc"
74
- r = requests.get(url, headers=headers)
64
+ url = f"{GITHUB_API_URL}/search/issues?q=repo:{event.repository}+author:{author}+is:pr+is:merged&sort=created&order=asc"
65
+ r = event.get(url)
75
66
  if r.status_code == 200:
76
67
  data = r.json()
77
68
  if data["total_count"] > 0:
@@ -82,7 +73,11 @@ def get_new_contributors(repo: str, prs: list, headers: dict) -> set:
82
73
 
83
74
 
84
75
  def generate_release_summary(
85
- diff: str, prs: list, latest_tag: str, previous_tag: str, repo_name: str, headers: dict
76
+ event,
77
+ diff: str,
78
+ prs: list,
79
+ latest_tag: str,
80
+ previous_tag: str,
86
81
  ) -> str:
87
82
  """Generate a concise release summary with key changes, purpose, and impact for a new Ultralytics version."""
88
83
  pr_summaries = "\n\n".join(
@@ -99,7 +94,7 @@ def generate_release_summary(
99
94
  whats_changed = "\n".join([f"* {pr['title']} by @{pr['author']} in {pr['html_url']}" for pr in prs])
100
95
 
101
96
  # Generate New Contributors section
102
- new_contributors = get_new_contributors(repo_name, prs, headers)
97
+ new_contributors = get_new_contributors(event, prs)
103
98
  new_contributors_section = (
104
99
  "\n## New Contributors\n"
105
100
  + "\n".join(
@@ -112,7 +107,7 @@ def generate_release_summary(
112
107
  else ""
113
108
  )
114
109
 
115
- full_changelog = f"https://github.com/{repo_name}/compare/{previous_tag}...{latest_tag}"
110
+ full_changelog = f"https://github.com/{event.repository}/compare/{previous_tag}...{latest_tag}"
116
111
  release_suffix = (
117
112
  f"\n\n## What's Changed\n{whats_changed}\n{new_contributors_section}\n\n**Full Changelog**: {full_changelog}\n"
118
113
  )
@@ -133,16 +128,15 @@ def generate_release_summary(
133
128
  f"Here's the release diff:\n\n{diff[:300000]}",
134
129
  },
135
130
  ]
136
- print(messages[-1]["content"]) # for debug
131
+ # print(messages[-1]["content"]) # for debug
137
132
  return get_completion(messages, temperature=0.2) + release_suffix
138
133
 
139
134
 
140
- def create_github_release(repo_name: str, tag_name: str, name: str, body: str, headers: dict) -> int:
135
+ def create_github_release(event, tag_name: str, name: str, body: str):
141
136
  """Creates a GitHub release with specified tag, name, and body content for the given repository."""
142
- url = f"{GITHUB_API_URL}/repos/{repo_name}/releases"
137
+ url = f"{GITHUB_API_URL}/repos/{event.repository}/releases"
143
138
  data = {"tag_name": tag_name, "name": name, "body": body, "draft": False, "prerelease": False}
144
- r = requests.post(url, headers=headers, json=data)
145
- return r.status_code
139
+ event.post(url, json=data)
146
140
 
147
141
 
148
142
  def get_previous_tag() -> str:
@@ -157,22 +151,22 @@ def get_previous_tag() -> str:
157
151
 
158
152
  def main(*args, **kwargs):
159
153
  """Automates generating and publishing a GitHub release summary from PRs and commit differences."""
160
- action = Action(*args, **kwargs)
154
+ event = Action(*args, **kwargs)
161
155
 
162
- if not all([action.token, CURRENT_TAG]):
156
+ if not all([event.token, CURRENT_TAG]):
163
157
  raise ValueError("One or more required environment variables are missing.")
164
158
 
165
159
  previous_tag = PREVIOUS_TAG or get_previous_tag()
166
160
 
167
161
  # Get the diff between the tags
168
- diff = get_release_diff(action.repository, previous_tag, CURRENT_TAG, action.headers_diff)
162
+ diff = get_release_diff(event, previous_tag, CURRENT_TAG)
169
163
 
170
164
  # Get PRs merged between the tags
171
- prs = get_prs_between_tags(action.repository, previous_tag, CURRENT_TAG, action.headers)
165
+ prs = get_prs_between_tags(event, previous_tag, CURRENT_TAG)
172
166
 
173
167
  # Generate release summary
174
168
  try:
175
- summary = generate_release_summary(diff, prs, CURRENT_TAG, previous_tag, action.repository, action.headers)
169
+ summary = generate_release_summary(event, diff, prs, CURRENT_TAG, previous_tag)
176
170
  except Exception as e:
177
171
  print(f"Failed to generate summary: {str(e)}")
178
172
  summary = "Failed to generate summary."
@@ -183,11 +177,7 @@ def main(*args, **kwargs):
183
177
 
184
178
  # Create the release on GitHub
185
179
  msg = f"{CURRENT_TAG} - {commit_message}"
186
- status_code = create_github_release(action.repository, CURRENT_TAG, msg, summary, action.headers)
187
- if status_code == 201:
188
- print(f"Successfully created release {CURRENT_TAG}")
189
- else:
190
- print(f"Failed to create release {CURRENT_TAG}. Status code: {status_code}")
180
+ create_github_release(event, CURRENT_TAG, msg, summary)
191
181
 
192
182
 
193
183
  if __name__ == "__main__":
actions/utils/__init__.py CHANGED
@@ -8,11 +8,12 @@ from .common_utils import (
8
8
  allow_redirect,
9
9
  remove_html_comments,
10
10
  )
11
- from .github_utils import GITHUB_API_URL, Action, check_pypi_version, ultralytics_actions_info
11
+ from .github_utils import GITHUB_API_URL, GITHUB_GRAPHQL_URL, Action, check_pypi_version, ultralytics_actions_info
12
12
  from .openai_utils import get_completion
13
13
 
14
14
  __all__ = (
15
15
  "GITHUB_API_URL",
16
+ "GITHUB_GRAPHQL_URL",
16
17
  "REQUESTS_HEADERS",
17
18
  "URL_IGNORE_LIST",
18
19
  "REDIRECT_START_IGNORE_LIST",
@@ -36,6 +36,15 @@ BAD_HTTP_CODES = frozenset(
36
36
  525, # Cloudfare handshake error
37
37
  }
38
38
  )
39
+
40
+ URL_ERROR_LIST = { # automatically reject these URLs (important: replace spaces with '%20')
41
+ "https://blog.research.google/search/label/Spam%20and%20Abuse",
42
+ "https://blog.research.google/search/label/Adversarial%20Attacks",
43
+ "https://www.microsoft.com/en-us/security/business/ai-machine-learning-security",
44
+ "https://about.netflix.com/en/news/netflix-recommendations-beyond-the-5-stars-part-1",
45
+ "https://about.netflix.com/en/news/netflix-research-recommendations",
46
+ }
47
+
39
48
  URL_IGNORE_LIST = { # use a set (not frozenset) to update with possible private GitHub repos
40
49
  "localhost",
41
50
  "127.0.0",
@@ -51,10 +60,11 @@ URL_IGNORE_LIST = { # use a set (not frozenset) to update with possible private
51
60
  "mailto:",
52
61
  "linkedin.com",
53
62
  "twitter.com",
54
- "x.com",
63
+ "https://x.com", # do not use just 'x' as this will catch other domains like netflix.com
55
64
  "storage.googleapis.com", # private GCS buckets
56
65
  "{", # possible Python fstring
57
66
  "(", # breaks pattern matches
67
+ ")",
58
68
  "api.", # ignore api endpoints
59
69
  }
60
70
  REDIRECT_START_IGNORE_LIST = frozenset(
@@ -69,9 +79,14 @@ REDIRECT_START_IGNORE_LIST = frozenset(
69
79
  "ultralytics.com/actions",
70
80
  "ultralytics.com/bilibili",
71
81
  "ultralytics.com/images",
82
+ "ultralytics.com/license",
83
+ "ultralytics.com/assets",
72
84
  "app.gong.io/call?",
73
85
  "docs.openvino.ai",
86
+ ".git",
87
+ "/raw/", # GitHub images
74
88
  }
89
+ | URL_IGNORE_LIST
75
90
  )
76
91
  REDIRECT_END_IGNORE_LIST = frozenset(
77
92
  {
@@ -141,11 +156,10 @@ def brave_search(query, api_key, count=5):
141
156
  """Search for alternative URLs using Brave Search API."""
142
157
  if not api_key:
143
158
  return
144
- headers = {"X-Subscription-Token": api_key, "Accept": "application/json"}
145
159
  if len(query) > 400:
146
160
  print(f"WARNING ⚠️ Brave search query length {len(query)} exceed limit of 400 characters, truncating.")
147
161
  url = f"https://api.search.brave.com/res/v1/web/search?q={parse.quote(query.strip()[:400])}&count={count}"
148
- response = requests.get(url, headers=headers)
162
+ response = requests.get(url, headers={"X-Subscription-Token": api_key, "Accept": "application/json"})
149
163
  data = response.json() if response.status_code == 200 else {}
150
164
  results = data.get("web", {}).get("results", []) if data else []
151
165
  return [result.get("url") for result in results if result.get("url")]
@@ -161,7 +175,7 @@ def is_url(url, session=None, check=True, max_attempts=3, timeout=3, return_url=
161
175
  # Check structure
162
176
  result = parse.urlparse(url)
163
177
  partition = result.netloc.partition(".") # i.e. netloc = "github.com" -> ("github", ".", "com")
164
- if not result.scheme or not partition[0] or not partition[2]:
178
+ if not result.scheme or not partition[0] or not partition[2] or (url in URL_ERROR_LIST):
165
179
  return (False, url) if return_url else False
166
180
 
167
181
  if check:
@@ -9,6 +9,7 @@ import requests
9
9
  from actions import __version__
10
10
 
11
11
  GITHUB_API_URL = "https://api.github.com"
12
+ GITHUB_GRAPHQL_URL = "https://api.github.com/graphql"
12
13
 
13
14
 
14
15
  class Action:
@@ -19,16 +20,69 @@ class Action:
19
20
  token: str = None,
20
21
  event_name: str = None,
21
22
  event_data: dict = None,
23
+ verbose: bool = True,
22
24
  ):
23
25
  """Initializes a GitHub Actions API handler with token and event data for processing events."""
24
26
  self.token = token or os.getenv("GITHUB_TOKEN")
25
27
  self.event_name = event_name or os.getenv("GITHUB_EVENT_NAME")
26
28
  self.event_data = event_data or self._load_event_data(os.getenv("GITHUB_EVENT_PATH"))
29
+ self._default_status = {
30
+ "get": [200],
31
+ "post": [200, 201],
32
+ "put": [200, 201, 204],
33
+ "patch": [200],
34
+ "delete": [200, 204],
35
+ }
27
36
 
28
37
  self.pr = self.event_data.get("pull_request", {})
29
38
  self.repository = self.event_data.get("repository", {}).get("full_name")
30
- self.headers = {"Authorization": f"token {self.token}", "Accept": "application/vnd.github.v3+json"}
31
- self.headers_diff = {"Authorization": f"token {self.token}", "Accept": "application/vnd.github.v3.diff"}
39
+ self.headers = {"Authorization": f"Bearer {self.token}", "Accept": "application/vnd.github+json"}
40
+ self.headers_diff = {"Authorization": f"Bearer {self.token}", "Accept": "application/vnd.github.v3.diff"}
41
+ self.eyes_reaction_id = None
42
+ self.verbose = verbose
43
+
44
+ def _request(self, method: str, url: str, headers=None, expected_status=None, **kwargs):
45
+ """Unified request handler with error checking."""
46
+ headers = headers or self.headers
47
+ expected_status = expected_status or self._default_status[method.lower()]
48
+
49
+ response = getattr(requests, method)(url, headers=headers, **kwargs)
50
+ status = response.status_code
51
+ success = status in expected_status
52
+
53
+ if self.verbose:
54
+ print(f"{'✓' if success else '✗'} {method.upper()} {url} → {status}")
55
+ if not success:
56
+ try:
57
+ error_detail = response.json()
58
+ print(f" Error: {error_detail.get('message', 'Unknown error')}")
59
+ except:
60
+ print(f" Error: {response.text[:100]}...")
61
+
62
+ if not success:
63
+ response.raise_for_status()
64
+
65
+ return response
66
+
67
+ def get(self, url, **kwargs):
68
+ """Performs GET request with error handling."""
69
+ return self._request("get", url, **kwargs)
70
+
71
+ def post(self, url, **kwargs):
72
+ """Performs POST request with error handling."""
73
+ return self._request("post", url, **kwargs)
74
+
75
+ def put(self, url, **kwargs):
76
+ """Performs PUT request with error handling."""
77
+ return self._request("put", url, **kwargs)
78
+
79
+ def patch(self, url, **kwargs):
80
+ """Performs PATCH request with error handling."""
81
+ return self._request("patch", url, **kwargs)
82
+
83
+ def delete(self, url, **kwargs):
84
+ """Performs DELETE request with error handling."""
85
+ return self._request("delete", url, **kwargs)
32
86
 
33
87
  @staticmethod
34
88
  def _load_event_data(event_path: str) -> dict:
@@ -39,43 +93,57 @@ class Action:
39
93
 
40
94
  def get_username(self) -> str | None:
41
95
  """Gets username associated with the GitHub token."""
42
- query = "query { viewer { login } }"
43
- response = requests.post(f"{GITHUB_API_URL}/graphql", json={"query": query}, headers=self.headers)
44
- if response.status_code != 200:
45
- print(f"Failed to fetch authenticated user. Status code: {response.status_code}")
46
- return None
47
- try:
48
- return response.json()["data"]["viewer"]["login"]
49
- except KeyError as e:
50
- print(f"Error parsing authenticated user response: {e}")
51
- return None
96
+ response = self.post(GITHUB_GRAPHQL_URL, json={"query": "query { viewer { login } }"})
97
+ if response.status_code == 200:
98
+ try:
99
+ return response.json()["data"]["viewer"]["login"]
100
+ except KeyError as e:
101
+ print(f"Error parsing authenticated user response: {e}")
102
+ return None
103
+
104
+ def is_org_member(self, username: str) -> bool:
105
+ """Checks if a user is a member of the organization using the GitHub API."""
106
+ org_name = self.repository.split("/")[0]
107
+ response = self.get(f"{GITHUB_API_URL}/orgs/{org_name}/members/{username}")
108
+ return response.status_code == 204 # 204 means the user is a member
52
109
 
53
110
  def get_pr_diff(self) -> str:
54
111
  """Retrieves the diff content for a specified pull request."""
55
112
  url = f"{GITHUB_API_URL}/repos/{self.repository}/pulls/{self.pr.get('number')}"
56
- r = requests.get(url, headers=self.headers_diff)
57
- return r.text if r.status_code == 200 else ""
113
+ response = self.get(url, headers=self.headers_diff)
114
+ return response.text if response.status_code == 200 else ""
58
115
 
59
116
  def get_repo_data(self, endpoint: str) -> dict:
60
117
  """Fetches repository data from a specified endpoint."""
61
- r = requests.get(f"{GITHUB_API_URL}/repos/{self.repository}/{endpoint}", headers=self.headers)
62
- r.raise_for_status()
63
- return r.json()
118
+ response = self.get(f"{GITHUB_API_URL}/repos/{self.repository}/{endpoint}")
119
+ return response.json()
120
+
121
+ def toggle_eyes_reaction(self, add: bool = True) -> None:
122
+ """Adds or removes eyes emoji reaction."""
123
+ if self.event_name in ["pull_request", "pull_request_target"]:
124
+ id = self.pr.get("number")
125
+ elif self.event_name == "issue_comment":
126
+ id = f"comments/{self.event_data.get('comment', {}).get('id')}"
127
+ else:
128
+ id = self.event_data.get("issue", {}).get("number")
129
+ if not id:
130
+ return
131
+ url = f"{GITHUB_API_URL}/repos/{self.repository}/issues/{id}/reactions"
132
+
133
+ if add:
134
+ response = self.post(url, json={"content": "eyes"})
135
+ if response.status_code == 201:
136
+ self.eyes_reaction_id = response.json().get("id")
137
+ elif self.eyes_reaction_id:
138
+ self.delete(f"{url}/{self.eyes_reaction_id}")
139
+ self.eyes_reaction_id = None
64
140
 
65
141
  def graphql_request(self, query: str, variables: dict = None) -> dict:
66
142
  """Executes a GraphQL query against the GitHub API."""
67
- headers = {
68
- "Authorization": f"Bearer {self.token}",
69
- "Content-Type": "application/json",
70
- "Accept": "application/vnd.github.v4+json",
71
- }
72
- r = requests.post(f"{GITHUB_API_URL}/graphql", json={"query": query, "variables": variables}, headers=headers)
73
- r.raise_for_status()
143
+ r = self.post(GITHUB_GRAPHQL_URL, json={"query": query, "variables": variables})
74
144
  result = r.json()
75
- success = "data" in result and not result.get("errors")
76
- print(
77
- f"{'Successful' if success else 'Failed'} discussion GraphQL request: {result.get('errors', 'No errors')}"
78
- )
145
+ if "data" not in result or result.get("errors"):
146
+ print(result.get("errors"))
79
147
  return result
80
148
 
81
149
  def print_info(self):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ultralytics-actions
3
- Version: 0.0.69
3
+ Version: 0.0.71
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>
@@ -0,0 +1,16 @@
1
+ actions/__init__.py,sha256=KuXt92GxOFYtPimGijr5CdEXsLmxIG4MQWAaAgFd3ew,742
2
+ actions/dispatch_actions.py,sha256=vbA4w_B8vMXMen__ck2WoDsUFCELjXOQbpLzZCmqTXg,4240
3
+ actions/first_interaction.py,sha256=Yagh38abX638DNYr18HoiEEfCZOJfrqObhJIff54Sx0,16350
4
+ actions/summarize_pr.py,sha256=NCaDSbw4PVoRbPJzji_Ua2HadI2pn7QOE_dy3VK9_cc,10463
5
+ actions/summarize_release.py,sha256=OncODHx7XsmB-nPf-B1tnxUTcaJx6hM4JAMa9frypzM,7922
6
+ actions/update_markdown_code_blocks.py,sha256=9PL7YIQfApRNAa0que2hYHv7umGZTZoHlblesB0xFj4,8587
7
+ actions/utils/__init__.py,sha256=TXYvhFgDeAnosePM4jfOrEd6PlC7tWC-WMOgCB_T6Tw,728
8
+ actions/utils/common_utils.py,sha256=2eNwGJFigl9bBXcyWzdr8mr97Lrx7zFKWIFYugZcUJw,11736
9
+ actions/utils/github_utils.py,sha256=bpFMbpzPvtBiqtNvzISFkCp8_7EboMiD3aMA8RG_tTs,9785
10
+ actions/utils/openai_utils.py,sha256=txbsEPQnIOieejatBuE6Yk7xR1fQ0erWOEs6cYgUQX4,2943
11
+ ultralytics_actions-0.0.71.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
12
+ ultralytics_actions-0.0.71.dist-info/METADATA,sha256=Q0n2vt5uFAB2167ciNanCwJk86uLv_6sry9l8mKgvNU,10930
13
+ ultralytics_actions-0.0.71.dist-info/WHEEL,sha256=ck4Vq1_RXyvS4Jt6SI0Vz6fyVs4GWg7AINwpsaGEgPE,91
14
+ ultralytics_actions-0.0.71.dist-info/entry_points.txt,sha256=GowvOFplj0C7JmsjbKcbpgLpdf2r921pcaOQkAHWZRA,378
15
+ ultralytics_actions-0.0.71.dist-info/top_level.txt,sha256=5apM5x80QlJcGbACn1v3fkmIuL1-XQCKcItJre7w7Tw,8
16
+ ultralytics_actions-0.0.71.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (78.1.0)
2
+ Generator: setuptools (80.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,15 +0,0 @@
1
- actions/__init__.py,sha256=jfTLYF5qlHNX7JutT_TeZ7oSc5jmqIYB-X87ISRWvzc,742
2
- actions/first_interaction.py,sha256=1_WvQHCi5RWaSfyi49ClF2Zk_3CKGjFnZqz6FlxPRAc,17868
3
- actions/summarize_pr.py,sha256=BKttOq-MGaanVaChLU5B1ewKUA8K6S05Cy3FQtyRmxU,11681
4
- actions/summarize_release.py,sha256=tov6qsYGC68lfobvkwVyoWZBGtJ598G0m097n4Ydzvo,8472
5
- actions/update_markdown_code_blocks.py,sha256=9PL7YIQfApRNAa0que2hYHv7umGZTZoHlblesB0xFj4,8587
6
- actions/utils/__init__.py,sha256=ZE0RmC9qOCt9TUhvORd6uVhbxOKVFWJDobR454v55_M,682
7
- actions/utils/common_utils.py,sha256=YRdEz8qluwzCZfWgqXNmyhKqNhdxNMpoHhGaHUD4AaM,11013
8
- actions/utils/github_utils.py,sha256=-F--JgxtXE0fSPMFEzakz7iZilp-vonzLiyXfg0b17Y,7117
9
- actions/utils/openai_utils.py,sha256=txbsEPQnIOieejatBuE6Yk7xR1fQ0erWOEs6cYgUQX4,2943
10
- ultralytics_actions-0.0.69.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
11
- ultralytics_actions-0.0.69.dist-info/METADATA,sha256=94Bmnj4tirzyhs7DAqvkEvjP0cPg_uIUXSLqlt1_fBI,10930
12
- ultralytics_actions-0.0.69.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
13
- ultralytics_actions-0.0.69.dist-info/entry_points.txt,sha256=GowvOFplj0C7JmsjbKcbpgLpdf2r921pcaOQkAHWZRA,378
14
- ultralytics_actions-0.0.69.dist-info/top_level.txt,sha256=5apM5x80QlJcGbACn1v3fkmIuL1-XQCKcItJre7w7Tw,8
15
- ultralytics_actions-0.0.69.dist-info/RECORD,,