ultralytics-actions 0.0.99__tar.gz → 0.1.0__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.

Potentially problematic release.


This version of ultralytics-actions might be problematic. Click here for more details.

Files changed (36) hide show
  1. {ultralytics_actions-0.0.99/ultralytics_actions.egg-info → ultralytics_actions-0.1.0}/PKG-INFO +3 -2
  2. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/README.md +2 -1
  3. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/actions/__init__.py +2 -1
  4. ultralytics_actions-0.1.0/actions/review_pr.py +247 -0
  5. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/actions/update_markdown_code_blocks.py +4 -0
  6. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/actions/utils/github_utils.py +44 -56
  7. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/actions/utils/openai_utils.py +3 -1
  8. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/pyproject.toml +1 -0
  9. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0/ultralytics_actions.egg-info}/PKG-INFO +3 -2
  10. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/ultralytics_actions.egg-info/SOURCES.txt +1 -0
  11. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/ultralytics_actions.egg-info/entry_points.txt +1 -0
  12. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/LICENSE +0 -0
  13. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/actions/dispatch_actions.py +0 -0
  14. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/actions/first_interaction.py +0 -0
  15. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/actions/summarize_pr.py +0 -0
  16. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/actions/summarize_release.py +0 -0
  17. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/actions/update_file_headers.py +0 -0
  18. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/actions/utils/__init__.py +0 -0
  19. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/actions/utils/common_utils.py +0 -0
  20. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/actions/utils/version_utils.py +0 -0
  21. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/setup.cfg +0 -0
  22. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/tests/test_cli_commands.py +0 -0
  23. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/tests/test_common_utils.py +0 -0
  24. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/tests/test_dispatch_actions.py +0 -0
  25. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/tests/test_file_headers.py +0 -0
  26. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/tests/test_first_interaction.py +0 -0
  27. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/tests/test_github_utils.py +0 -0
  28. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/tests/test_init.py +0 -0
  29. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/tests/test_openai_utils.py +0 -0
  30. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/tests/test_summarize_pr.py +0 -0
  31. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/tests/test_summarize_release.py +0 -0
  32. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/tests/test_update_markdown_codeblocks.py +0 -0
  33. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/tests/test_urls.py +0 -0
  34. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/ultralytics_actions.egg-info/dependency_links.txt +0 -0
  35. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/ultralytics_actions.egg-info/requires.txt +0 -0
  36. {ultralytics_actions-0.0.99 → ultralytics_actions-0.1.0}/ultralytics_actions.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ultralytics-actions
3
- Version: 0.0.99
3
+ Version: 0.1.0
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>
@@ -38,7 +38,7 @@ Dynamic: license-file
38
38
 
39
39
  <a href="https://www.ultralytics.com/"><img src="https://raw.githubusercontent.com/ultralytics/assets/main/logo/Ultralytics_Logotype_Original.svg" width="320" alt="Ultralytics logo"></a>
40
40
 
41
- # 🚀 Ultralytics Actions: Auto-Formatting for Python, Markdown, and Swift
41
+ # 🚀 Ultralytics Actions: AI-powered formatting, labeling & PR summaries for Python and Markdown
42
42
 
43
43
  Welcome to the [Ultralytics Actions](https://github.com/ultralytics/actions) repository, your go-to solution for maintaining consistent code quality across Ultralytics Python and Swift projects. This GitHub Action is designed to automate the formatting of Python, Markdown, and Swift files, ensuring adherence to our coding standards and enhancing project maintainability.
44
44
 
@@ -46,6 +46,7 @@ Welcome to the [Ultralytics Actions](https://github.com/ultralytics/actions) rep
46
46
 
47
47
  [![Actions CI](https://github.com/ultralytics/actions/actions/workflows/ci.yml/badge.svg)](https://github.com/ultralytics/actions/actions/workflows/ci.yml)
48
48
  [![Ultralytics Actions](https://github.com/ultralytics/actions/actions/workflows/format.yml/badge.svg)](https://github.com/ultralytics/actions/actions/workflows/format.yml)
49
+ [![List Open PRs](https://github.com/ultralytics/actions/actions/workflows/open-prs.yml/badge.svg)](https://github.com/ultralytics/actions/actions/workflows/open-prs.yml)
49
50
  [![codecov](https://codecov.io/github/ultralytics/actions/graph/badge.svg?token=DoizJ1WS6j)](https://codecov.io/github/ultralytics/actions)
50
51
 
51
52
  [![Ultralytics Discord](https://img.shields.io/discord/1089800235347353640?logo=discord&logoColor=white&label=Discord&color=blue)](https://discord.com/invite/ultralytics)
@@ -1,6 +1,6 @@
1
1
  <a href="https://www.ultralytics.com/"><img src="https://raw.githubusercontent.com/ultralytics/assets/main/logo/Ultralytics_Logotype_Original.svg" width="320" alt="Ultralytics logo"></a>
2
2
 
3
- # 🚀 Ultralytics Actions: Auto-Formatting for Python, Markdown, and Swift
3
+ # 🚀 Ultralytics Actions: AI-powered formatting, labeling & PR summaries for Python and Markdown
4
4
 
5
5
  Welcome to the [Ultralytics Actions](https://github.com/ultralytics/actions) repository, your go-to solution for maintaining consistent code quality across Ultralytics Python and Swift projects. This GitHub Action is designed to automate the formatting of Python, Markdown, and Swift files, ensuring adherence to our coding standards and enhancing project maintainability.
6
6
 
@@ -8,6 +8,7 @@ Welcome to the [Ultralytics Actions](https://github.com/ultralytics/actions) rep
8
8
 
9
9
  [![Actions CI](https://github.com/ultralytics/actions/actions/workflows/ci.yml/badge.svg)](https://github.com/ultralytics/actions/actions/workflows/ci.yml)
10
10
  [![Ultralytics Actions](https://github.com/ultralytics/actions/actions/workflows/format.yml/badge.svg)](https://github.com/ultralytics/actions/actions/workflows/format.yml)
11
+ [![List Open PRs](https://github.com/ultralytics/actions/actions/workflows/open-prs.yml/badge.svg)](https://github.com/ultralytics/actions/actions/workflows/open-prs.yml)
11
12
  [![codecov](https://codecov.io/github/ultralytics/actions/graph/badge.svg?token=DoizJ1WS6j)](https://codecov.io/github/ultralytics/actions)
12
13
 
13
14
  [![Ultralytics Discord](https://img.shields.io/discord/1089800235347353640?logo=discord&logoColor=white&label=Discord&color=blue)](https://discord.com/invite/ultralytics)
@@ -13,6 +13,7 @@
13
13
  # │ │ ├── openai_utils.py
14
14
  # │ │ └── common_utils.py
15
15
  # │ ├── first_interaction.py
16
+ # │ ├── review_pr.py
16
17
  # │ ├── summarize_pr.py
17
18
  # │ ├── summarize_release.py
18
19
  # │ └── update_markdown_code_blocks.py
@@ -22,4 +23,4 @@
22
23
  # ├── test_summarize_pr.py
23
24
  # └── ...
24
25
 
25
- __version__ = "0.0.99"
26
+ __version__ = "0.1.0"
@@ -0,0 +1,247 @@
1
+ # Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+
8
+ from .utils import GITHUB_API_URL, Action, get_completion
9
+
10
+ REVIEW_MARKER = "🔍 PR Review"
11
+ EMOJI_MAP = {"CRITICAL": "❗", "HIGH": "⚠️", "MEDIUM": "💡", "LOW": "📝", "SUGGESTION": "💭"}
12
+
13
+
14
+ def parse_diff_files(diff_text: str) -> dict:
15
+ """Parse diff to extract file paths, valid line numbers, and line content for comments."""
16
+ files, current_file, current_line = {}, None, 0
17
+
18
+ for line in diff_text.split("\n"):
19
+ if line.startswith("diff --git"):
20
+ match = re.search(r" b/(.+)$", line)
21
+ current_file = match.group(1) if match else None
22
+ current_line = 0
23
+ if current_file:
24
+ files[current_file] = {}
25
+ elif line.startswith("@@") and current_file:
26
+ match = re.search(r"@@.*\+(\d+)(?:,\d+)?", line)
27
+ current_line = int(match.group(1)) if match else 0
28
+ elif current_file and current_line > 0:
29
+ if line.startswith("+") and not line.startswith("+++"):
30
+ files[current_file][current_line] = line[1:]
31
+ current_line += 1
32
+ elif not line.startswith("-"):
33
+ current_line += 1
34
+
35
+ return files
36
+
37
+
38
+ def generate_pr_review(repository: str, diff_text: str, pr_title: str, pr_description: str) -> dict:
39
+ """Generate comprehensive PR review with line-specific comments and overall assessment."""
40
+ if not diff_text or "**ERROR" in diff_text:
41
+ return {"comments": [], "summary": f"Unable to review: {diff_text if '**ERROR' in diff_text else 'diff empty'}"}
42
+
43
+ diff_files = parse_diff_files(diff_text)
44
+ if not diff_files:
45
+ return {"comments": [], "summary": "No files with changes detected in diff"}
46
+
47
+ file_list = list(diff_files.keys())
48
+ limit = round(128000 * 3.3 * 0.4)
49
+ diff_truncated = len(diff_text) > limit
50
+ lines_changed = sum(len(lines) for lines in diff_files.values())
51
+
52
+ valid_lines_text = "\n".join(
53
+ f" {file}: {sorted(list(lines.keys())[:20])}{' ...' if len(lines) > 20 else ''}"
54
+ for file, lines in list(diff_files.items())[:10]
55
+ ) + ("\n ..." if len(diff_files) > 10 else "")
56
+
57
+ priority_guidance = (
58
+ "Prioritize the most critical/high-impact issues only"
59
+ if lines_changed >= 100
60
+ else "Prioritize commenting on different files/sections"
61
+ )
62
+
63
+ content = (
64
+ "You are an expert code reviewer for Ultralytics. Provide detailed inline comments on specific code changes.\n\n"
65
+ "Focus on: Code quality, style, best practices, bugs, edge cases, error handling, performance, security, documentation, test coverage\n\n"
66
+ "FORMATTING: Use backticks for code, file names, branch names, function names, variable names, packages\n\n"
67
+ "CRITICAL RULES:\n"
68
+ f"1. Generate inline comments with recommended changes for clear bugs/security/syntax issues (up to 10)\n"
69
+ "2. Each comment MUST reference a UNIQUE line number(s)\n"
70
+ "3. If a section has multiple issues, combine ALL issues into ONE comment for that section\n"
71
+ "4. Never create separate comments for the same line number(s)\n"
72
+ f"5. {priority_guidance}\n\n"
73
+ "CODE SUGGESTIONS:\n"
74
+ "- ONLY provide 'suggestion' field when you have high certainty the code is problematic AND sufficient context for a confident fix\n"
75
+ "- If uncertain about the correct fix, omit 'suggestion' field and explain the concern in 'message' only\n"
76
+ "- Suggestions must be ready-to-merge code with NO comments, placeholders, or explanations\n"
77
+ "- When providing suggestions, ensure they are complete, correct, and maintain existing indentation\n"
78
+ "- It's better to flag an issue without a suggestion than provide a wrong or uncertain fix\n\n"
79
+ "Return JSON: "
80
+ '{"comments": [{"file": "exact/path", "line": N, "severity": "HIGH", "message": "...", "suggestion": "..."}], "summary": "..."}\n\n'
81
+ "Rules:\n"
82
+ "- Only comment on NEW lines (starting with + in diff)\n"
83
+ "- Use exact file paths from diff (no ./ prefix)\n"
84
+ "- Line numbers must match NEW file line numbers from @@ hunks\n"
85
+ "- When '- old' then '+ new', new line keeps SAME line number\n"
86
+ "- Severity: CRITICAL, HIGH, MEDIUM, LOW, SUGGESTION\n"
87
+ f"- Files changed: {len(file_list)} ({', '.join(file_list[:10])}{'...' if len(file_list) > 10 else ''})\n"
88
+ f"- Total changed lines: {lines_changed}\n"
89
+ f"- Diff {'truncated' if diff_truncated else 'complete'}: {len(diff_text[:limit])} chars{f' of {len(diff_text)}' if diff_truncated else ''}\n\n"
90
+ f"VALID LINE NUMBERS (use ONLY these):\n{valid_lines_text}"
91
+ )
92
+
93
+ messages = [
94
+ {"role": "system", "content": content},
95
+ {
96
+ "role": "user",
97
+ "content": f"Review PR '{repository}':\nTitle: {pr_title}\nDescription: {pr_description[:500]}\n\nDiff:\n{diff_text[:limit]}",
98
+ },
99
+ ]
100
+
101
+ try:
102
+ response = get_completion(messages, reasoning_effort="medium")
103
+ print("\n" + "=" * 80 + f"\nFULL AI RESPONSE:\n{response}\n" + "=" * 80 + "\n")
104
+
105
+ json_str = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", response, re.DOTALL)
106
+ review_data = json.loads(json_str.group(1) if json_str else response)
107
+
108
+ print(f"AI generated {len(review_data.get('comments', []))} comments")
109
+
110
+ # Validate, filter, and deduplicate comments
111
+ unique_comments = {}
112
+ for c in review_data.get("comments", []):
113
+ file_path, line_num = c.get("file"), c.get("line", 0)
114
+ if file_path in diff_files and line_num in diff_files[file_path]:
115
+ key = f"{file_path}:{line_num}"
116
+ if key not in unique_comments:
117
+ unique_comments[key] = c
118
+ else:
119
+ print(f"⚠️ AI duplicate for {key}: {c.get('severity')} - {c.get('message')[:60]}...")
120
+ else:
121
+ print(f"Filtered out {file_path}:{line_num} (available: {list(diff_files.get(file_path, {}))[:10]}...)")
122
+
123
+ review_data.update(
124
+ {"comments": list(unique_comments.values()), "diff_files": diff_files, "diff_truncated": diff_truncated}
125
+ )
126
+ print(f"Valid comments after filtering: {len(review_data['comments'])}")
127
+ return review_data
128
+
129
+ except json.JSONDecodeError as e:
130
+ print(f"JSON parsing failed: {e}\nAttempted: {json_str[:500] if 'json_str' in locals() else response[:500]}...")
131
+ return {"comments": [], "summary": "Review generation encountered a JSON parsing error"}
132
+ except Exception as e:
133
+ print(f"Review generation failed: {e}")
134
+ import traceback
135
+
136
+ traceback.print_exc()
137
+ return {"comments": [], "summary": "Review generation encountered an error"}
138
+
139
+
140
+ def dismiss_previous_reviews(event: Action) -> int:
141
+ """Dismiss previous bot reviews and delete inline comments, returns count for numbering."""
142
+ if not (pr_number := event.pr.get("number")) or not (bot_username := event.get_username()):
143
+ return 1
144
+
145
+ review_count = 0
146
+ reviews_url = f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{pr_number}/reviews"
147
+ if (response := event.get(reviews_url)).status_code == 200:
148
+ for review in response.json():
149
+ if review.get("user", {}).get("login") == bot_username and REVIEW_MARKER in (review.get("body") or ""):
150
+ review_count += 1
151
+ if review.get("state") in ["APPROVED", "CHANGES_REQUESTED"] and (review_id := review.get("id")):
152
+ event.put(f"{reviews_url}/{review_id}/dismissals", json={"message": "Superseded by new review"})
153
+
154
+ # Delete previous inline comments
155
+ comments_url = f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{pr_number}/comments"
156
+ if (response := event.get(comments_url)).status_code == 200:
157
+ for comment in response.json():
158
+ if comment.get("user", {}).get("login") == bot_username and (comment_id := comment.get("id")):
159
+ event.delete(
160
+ f"{GITHUB_API_URL}/repos/{event.repository}/pulls/comments/{comment_id}",
161
+ expected_status=[200, 204, 404],
162
+ )
163
+
164
+ return review_count + 1
165
+
166
+
167
+ def post_review_comments(event: Action, review_data: dict) -> None:
168
+ """Post inline review comments on specific lines of the PR."""
169
+ if not (pr_number := event.pr.get("number")) or not (commit_sha := event.pr.get("head", {}).get("sha")):
170
+ return
171
+
172
+ url = f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{pr_number}/comments"
173
+ diff_files = review_data.get("diff_files", {})
174
+
175
+ for comment in review_data.get("comments", [])[:50]:
176
+ if not (file_path := comment.get("file")) or not (line := comment.get("line", 0)):
177
+ continue
178
+
179
+ severity = comment.get("severity", "SUGGESTION")
180
+ body = f"{EMOJI_MAP.get(severity, '💭')} **{severity}**: {comment.get('message', '')}"
181
+
182
+ if suggestion := comment.get("suggestion"):
183
+ original_line = diff_files.get(file_path, {}).get(line, "")
184
+ indent = len(original_line) - len(original_line.lstrip())
185
+ indented = "\n".join(" " * indent + l if l.strip() else l for l in suggestion.split("\n"))
186
+ body += f"\n\n**Suggested change:**\n```suggestion\n{indented}\n```"
187
+
188
+ event.post(url, json={"body": body, "commit_id": commit_sha, "path": file_path, "line": line, "side": "RIGHT"})
189
+
190
+
191
+ def post_review_summary(event: Action, review_data: dict, review_number: int) -> None:
192
+ """Post overall review summary as a PR review."""
193
+ if not (pr_number := event.pr.get("number")) or not (commit_sha := event.pr.get("head", {}).get("sha")):
194
+ return
195
+
196
+ review_title = f"{REVIEW_MARKER} {review_number}" if review_number > 1 else REVIEW_MARKER
197
+ comments = review_data.get("comments", [])
198
+ max_severity = max((c.get("severity") for c in comments), default="SUGGESTION") if comments else None
199
+ event_type = "APPROVE" if not comments or max_severity in ["LOW", "SUGGESTION"] else "REQUEST_CHANGES"
200
+
201
+ body = (
202
+ f"## {review_title}\n\n"
203
+ "<sub>Made with ❤️ by [Ultralytics Actions](https://github.com/ultralytics/actions)</sub>\n\n"
204
+ f"{review_data.get('summary', 'Review completed')}\n\n"
205
+ )
206
+
207
+ if comments:
208
+ shown = min(len(comments), 50)
209
+ body += f"💬 Posted {shown} inline comment{'s' if shown != 1 else ''}{' (50 shown, more available)' if len(comments) > 50 else ''}\n"
210
+
211
+ if review_data.get("diff_truncated"):
212
+ body += "\n⚠️ **Large PR**: Review focused on critical issues. Some details may not be covered.\n"
213
+
214
+ event.post(
215
+ f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{pr_number}/reviews",
216
+ json={"commit_id": commit_sha, "body": body, "event": event_type},
217
+ )
218
+
219
+
220
+ def main(*args, **kwargs):
221
+ """Main entry point for PR review action."""
222
+ event = Action(*args, **kwargs)
223
+
224
+ # Handle review requests
225
+ if event.event_name == "pull_request" and event.event_data.get("action") == "review_requested":
226
+ if event.event_data.get("requested_reviewer", {}).get("login") != event.get_username():
227
+ return
228
+ print(f"Review requested from {event.get_username()}")
229
+
230
+ if not event.pr or event.pr.get("state") != "open":
231
+ print(f"Skipping: PR state is {event.pr.get('state') if event.pr else 'None'}")
232
+ return
233
+
234
+ print(f"Starting PR review for #{event.pr['number']}")
235
+ review_number = dismiss_previous_reviews(event)
236
+
237
+ diff = event.get_pr_diff()
238
+ review = generate_pr_review(event.repository, diff, event.pr.get("title", ""), event.pr.get("body", ""))
239
+
240
+ post_review_summary(event, review, review_number)
241
+ print(f"Posting {len(review.get('comments', []))} inline comments")
242
+ post_review_comments(event, review)
243
+ print("PR review completed")
244
+
245
+
246
+ if __name__ == "__main__":
247
+ main()
@@ -39,6 +39,10 @@ def add_indentation(code_block, num_spaces):
39
39
 
40
40
  def format_code_with_ruff(temp_dir):
41
41
  """Formats Python code files in the specified directory using ruff linter and docformatter tools."""
42
+ if not next(Path(temp_dir).rglob("*.py"), None):
43
+ print("No Python code blocks found to format")
44
+ return
45
+
42
46
  try:
43
47
  # Run ruff format
44
48
  subprocess.run(
@@ -17,17 +17,19 @@ GITHUB_GRAPHQL_URL = "https://api.github.com/graphql"
17
17
  class Action:
18
18
  """Handles GitHub Actions API interactions and event processing."""
19
19
 
20
- def __init__(
21
- self,
22
- token: str = None,
23
- event_name: str = None,
24
- event_data: dict = None,
25
- verbose: bool = True,
26
- ):
20
+ def __init__(self, token: str = None, event_name: str = None, event_data: dict = None, verbose: bool = True):
27
21
  """Initializes a GitHub Actions API handler with token and event data for processing events."""
28
22
  self.token = token or os.getenv("GITHUB_TOKEN")
29
23
  self.event_name = event_name or os.getenv("GITHUB_EVENT_NAME")
30
24
  self.event_data = event_data or self._load_event_data(os.getenv("GITHUB_EVENT_PATH"))
25
+ self.pr = self.event_data.get("pull_request", {})
26
+ self.repository = self.event_data.get("repository", {}).get("full_name")
27
+ self.headers = {"Authorization": f"Bearer {self.token}", "Accept": "application/vnd.github+json"}
28
+ self.headers_diff = {"Authorization": f"Bearer {self.token}", "Accept": "application/vnd.github.v3.diff"}
29
+ self.verbose = verbose
30
+ self.eyes_reaction_id = None
31
+ self._pr_diff_cache = None
32
+ self._username_cache = None
31
33
  self._default_status = {
32
34
  "get": [200],
33
35
  "post": [200, 201],
@@ -36,34 +38,22 @@ class Action:
36
38
  "delete": [200, 204],
37
39
  }
38
40
 
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
41
  def _request(self, method: str, url: str, headers=None, expected_status=None, hard=False, **kwargs):
47
42
  """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
43
+ response = getattr(requests, method)(url, headers=headers or self.headers, **kwargs)
44
+ expected = expected_status or self._default_status[method]
45
+ success = response.status_code in expected
54
46
 
55
47
  if self.verbose:
56
- print(f"{'✓' if success else '✗'} {method.upper()} {url} → {status}")
48
+ print(f"{'✓' if success else '✗'} {method.upper()} {url} → {response.status_code}")
57
49
  if not success:
58
50
  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}")
51
+ print(f" ❌ Error: {response.json().get('message', 'Unknown error')}")
52
+ except Exception:
53
+ print(f" ❌ Error: {response.text[:200]}")
63
54
 
64
55
  if not success and hard:
65
56
  response.raise_for_status()
66
-
67
57
  return response
68
58
 
69
59
  def get(self, url, **kwargs):
@@ -89,45 +79,47 @@ class Action:
89
79
  @staticmethod
90
80
  def _load_event_data(event_path: str) -> dict:
91
81
  """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 {}
82
+ return json.loads(Path(event_path).read_text()) if event_path and Path(event_path).exists() else {}
95
83
 
96
84
  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")
85
+ """Checks if the repository is public using event data."""
86
+ return self.event_data.get("repository", {}).get("private", False)
99
87
 
100
88
  def get_username(self) -> str | None:
101
- """Gets username associated with the GitHub token."""
89
+ """Gets username associated with the GitHub token with caching."""
90
+ if self._username_cache:
91
+ return self._username_cache
92
+
102
93
  response = self.post(GITHUB_GRAPHQL_URL, json={"query": "query { viewer { login } }"})
103
94
  if response.status_code == 200:
104
95
  try:
105
- return response.json()["data"]["viewer"]["login"]
96
+ self._username_cache = response.json()["data"]["viewer"]["login"]
106
97
  except KeyError as e:
107
98
  print(f"Error parsing authenticated user response: {e}")
108
- return None
99
+ return self._username_cache
109
100
 
110
101
  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
102
+ """Checks if a user is a member of the organization."""
103
+ return self.get(f"{GITHUB_API_URL}/orgs/{self.repository.split('/')[0]}/members/{username}").status_code == 204
115
104
 
116
105
  def get_pr_diff(self) -> str:
117
- """Retrieves the diff content for a specified pull request."""
106
+ """Retrieves the diff content for a specified pull request with caching."""
107
+ if self._pr_diff_cache:
108
+ return self._pr_diff_cache
109
+
118
110
  url = f"{GITHUB_API_URL}/repos/{self.repository}/pulls/{self.pr.get('number')}"
119
111
  response = self.get(url, headers=self.headers_diff)
120
112
  if response.status_code == 200:
121
- return response.text
113
+ self._pr_diff_cache = response.text
122
114
  elif response.status_code == 406:
123
- return "**ERROR: DIFF TOO LARGE - PR exceeds GitHub's 20,000 line limit, unable to retrieve diff."
115
+ self._pr_diff_cache = "ERROR: PR diff exceeds GitHub's 20,000 line limit, unable to retrieve diff."
124
116
  else:
125
- return "**ERROR: UNABLE TO RETRIEVE DIFF."
117
+ self._pr_diff_cache = "ERROR: UNABLE TO RETRIEVE DIFF."
118
+ return self._pr_diff_cache
126
119
 
127
120
  def get_repo_data(self, endpoint: str) -> dict:
128
121
  """Fetches repository data from a specified endpoint."""
129
- response = self.get(f"{GITHUB_API_URL}/repos/{self.repository}/{endpoint}")
130
- return response.json()
122
+ return self.get(f"{GITHUB_API_URL}/repos/{self.repository}/{endpoint}").json()
131
123
 
132
124
  def toggle_eyes_reaction(self, add: bool = True) -> None:
133
125
  """Adds or removes eyes emoji reaction."""
@@ -139,8 +131,8 @@ class Action:
139
131
  id = self.event_data.get("issue", {}).get("number")
140
132
  if not id:
141
133
  return
142
- url = f"{GITHUB_API_URL}/repos/{self.repository}/issues/{id}/reactions"
143
134
 
135
+ url = f"{GITHUB_API_URL}/repos/{self.repository}/issues/{id}/reactions"
144
136
  if add:
145
137
  response = self.post(url, json={"content": "eyes"})
146
138
  if response.status_code == 201:
@@ -151,8 +143,7 @@ class Action:
151
143
 
152
144
  def graphql_request(self, query: str, variables: dict = None) -> dict:
153
145
  """Executes a GraphQL query against the GitHub API."""
154
- r = self.post(GITHUB_GRAPHQL_URL, json={"query": query, "variables": variables})
155
- result = r.json()
146
+ result = self.post(GITHUB_GRAPHQL_URL, json={"query": query, "variables": variables}).json()
156
147
  if "data" not in result or result.get("errors"):
157
148
  print(result.get("errors"))
158
149
  return result
@@ -166,11 +157,11 @@ class Action:
166
157
  "github.repository.private": self.is_repo_private(),
167
158
  "github.event.pull_request.number": self.pr.get("number"),
168
159
  "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"),
160
+ "github.actor": os.getenv("GITHUB_ACTOR"),
170
161
  "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"),
162
+ "github.ref": os.getenv("GITHUB_REF"),
163
+ "github.head_ref": os.getenv("GITHUB_HEAD_REF"),
164
+ "github.base_ref": os.getenv("GITHUB_BASE_REF"),
174
165
  "github.base_sha": self.pr.get("base", {}).get("sha"),
175
166
  }
176
167
 
@@ -181,12 +172,9 @@ class Action:
181
172
  "github.event.discussion.number": discussion.get("number"),
182
173
  }
183
174
 
184
- max_key_length = max(len(key) for key in info)
175
+ width = max(len(k) for k in info) + 5
185
176
  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))
177
+ print(f"{header}\n" + "\n".join(f"{k:<{width}}{v}" for k, v in info.items()) + f"\n{'-' * len(header)}")
190
178
 
191
179
 
192
180
  def ultralytics_actions_info():
@@ -9,7 +9,7 @@ import requests
9
9
  from actions.utils.common_utils import check_links_in_string
10
10
 
11
11
  OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
12
- OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-5-codex")
12
+ OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-5-2025-08-07")
13
13
  SYSTEM_PROMPT_ADDITION = """Guidance:
14
14
  - Ultralytics Branding: Use YOLO11, YOLO26, etc., not YOLOv11, YOLOv26 (only older versions like YOLOv10 have a v). Always capitalize "HUB" in "Ultralytics HUB"; use "Ultralytics HUB", not "The Ultralytics HUB".
15
15
  - Avoid Equations: Do not include equations or mathematical notations.
@@ -57,6 +57,8 @@ def get_completion(
57
57
  data["reasoning"] = {"effort": reasoning_effort or "low"} # Default to low for GPT-5
58
58
 
59
59
  r = requests.post(url, json=data, headers=headers)
60
+ if r.status_code != 200:
61
+ print(f"❌ OpenAI error {r.status_code}:\n{r.text}\n")
60
62
  r.raise_for_status()
61
63
  response_data = r.json()
62
64
 
@@ -85,6 +85,7 @@ dev = [
85
85
 
86
86
  [project.scripts]
87
87
  ultralytics-actions-first-interaction = "actions.first_interaction:main"
88
+ ultralytics-actions-review-pr = "actions.review_pr:main"
88
89
  ultralytics-actions-summarize-pr = "actions.summarize_pr:main"
89
90
  ultralytics-actions-summarize-release = "actions.summarize_release:main"
90
91
  ultralytics-actions-update-markdown-code-blocks = "actions.update_markdown_code_blocks:main"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ultralytics-actions
3
- Version: 0.0.99
3
+ Version: 0.1.0
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>
@@ -38,7 +38,7 @@ Dynamic: license-file
38
38
 
39
39
  <a href="https://www.ultralytics.com/"><img src="https://raw.githubusercontent.com/ultralytics/assets/main/logo/Ultralytics_Logotype_Original.svg" width="320" alt="Ultralytics logo"></a>
40
40
 
41
- # 🚀 Ultralytics Actions: Auto-Formatting for Python, Markdown, and Swift
41
+ # 🚀 Ultralytics Actions: AI-powered formatting, labeling & PR summaries for Python and Markdown
42
42
 
43
43
  Welcome to the [Ultralytics Actions](https://github.com/ultralytics/actions) repository, your go-to solution for maintaining consistent code quality across Ultralytics Python and Swift projects. This GitHub Action is designed to automate the formatting of Python, Markdown, and Swift files, ensuring adherence to our coding standards and enhancing project maintainability.
44
44
 
@@ -46,6 +46,7 @@ Welcome to the [Ultralytics Actions](https://github.com/ultralytics/actions) rep
46
46
 
47
47
  [![Actions CI](https://github.com/ultralytics/actions/actions/workflows/ci.yml/badge.svg)](https://github.com/ultralytics/actions/actions/workflows/ci.yml)
48
48
  [![Ultralytics Actions](https://github.com/ultralytics/actions/actions/workflows/format.yml/badge.svg)](https://github.com/ultralytics/actions/actions/workflows/format.yml)
49
+ [![List Open PRs](https://github.com/ultralytics/actions/actions/workflows/open-prs.yml/badge.svg)](https://github.com/ultralytics/actions/actions/workflows/open-prs.yml)
49
50
  [![codecov](https://codecov.io/github/ultralytics/actions/graph/badge.svg?token=DoizJ1WS6j)](https://codecov.io/github/ultralytics/actions)
50
51
 
51
52
  [![Ultralytics Discord](https://img.shields.io/discord/1089800235347353640?logo=discord&logoColor=white&label=Discord&color=blue)](https://discord.com/invite/ultralytics)
@@ -4,6 +4,7 @@ pyproject.toml
4
4
  actions/__init__.py
5
5
  actions/dispatch_actions.py
6
6
  actions/first_interaction.py
7
+ actions/review_pr.py
7
8
  actions/summarize_pr.py
8
9
  actions/summarize_release.py
9
10
  actions/update_file_headers.py
@@ -2,6 +2,7 @@
2
2
  ultralytics-actions-first-interaction = actions.first_interaction:main
3
3
  ultralytics-actions-headers = actions.update_file_headers:main
4
4
  ultralytics-actions-info = actions.utils:ultralytics_actions_info
5
+ ultralytics-actions-review-pr = actions.review_pr:main
5
6
  ultralytics-actions-summarize-pr = actions.summarize_pr:main
6
7
  ultralytics-actions-summarize-release = actions.summarize_release:main
7
8
  ultralytics-actions-update-markdown-code-blocks = actions.update_markdown_code_blocks:main