ultralytics-actions 0.1.5__tar.gz → 0.1.7__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.1.5 → ultralytics_actions-0.1.7}/PKG-INFO +1 -1
  2. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/actions/__init__.py +1 -1
  3. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/actions/review_pr.py +85 -30
  4. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/actions/utils/github_utils.py +8 -7
  5. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/actions/utils/openai_utils.py +2 -0
  6. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/tests/test_github_utils.py +2 -0
  7. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/tests/test_openai_utils.py +2 -0
  8. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/ultralytics_actions.egg-info/PKG-INFO +1 -1
  9. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/LICENSE +0 -0
  10. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/README.md +0 -0
  11. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/actions/dispatch_actions.py +0 -0
  12. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/actions/first_interaction.py +0 -0
  13. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/actions/summarize_pr.py +0 -0
  14. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/actions/summarize_release.py +0 -0
  15. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/actions/update_file_headers.py +0 -0
  16. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/actions/update_markdown_code_blocks.py +0 -0
  17. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/actions/utils/__init__.py +0 -0
  18. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/actions/utils/common_utils.py +0 -0
  19. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/actions/utils/version_utils.py +0 -0
  20. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/pyproject.toml +0 -0
  21. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/setup.cfg +0 -0
  22. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/tests/test_cli_commands.py +0 -0
  23. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/tests/test_common_utils.py +0 -0
  24. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/tests/test_dispatch_actions.py +0 -0
  25. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/tests/test_file_headers.py +0 -0
  26. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/tests/test_first_interaction.py +0 -0
  27. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/tests/test_init.py +0 -0
  28. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/tests/test_summarize_pr.py +0 -0
  29. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/tests/test_summarize_release.py +0 -0
  30. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/tests/test_update_markdown_codeblocks.py +0 -0
  31. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/tests/test_urls.py +0 -0
  32. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/ultralytics_actions.egg-info/SOURCES.txt +0 -0
  33. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/ultralytics_actions.egg-info/dependency_links.txt +0 -0
  34. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/ultralytics_actions.egg-info/entry_points.txt +0 -0
  35. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/ultralytics_actions.egg-info/requires.txt +0 -0
  36. {ultralytics_actions-0.1.5 → ultralytics_actions-0.1.7}/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.1.5
3
+ Version: 0.1.7
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>
@@ -23,4 +23,4 @@
23
23
  # ├── test_summarize_pr.py
24
24
  # └── ...
25
25
 
26
- __version__ = "0.1.5"
26
+ __version__ = "0.1.7"
@@ -8,6 +8,7 @@ import re
8
8
  from .utils import GITHUB_API_URL, MAX_PROMPT_CHARS, Action, get_completion, remove_html_comments
9
9
 
10
10
  REVIEW_MARKER = "🔍 PR Review"
11
+ ERROR_MARKER = "⚠️ Review generation encountered an error"
11
12
  EMOJI_MAP = {"CRITICAL": "❗", "HIGH": "⚠️", "MEDIUM": "💡", "LOW": "📝", "SUGGESTION": "💭"}
12
13
  SKIP_PATTERNS = [
13
14
  r"\.lock$", # Lock files
@@ -30,25 +31,31 @@ SKIP_PATTERNS = [
30
31
 
31
32
 
32
33
  def parse_diff_files(diff_text: str) -> dict:
33
- """Parse diff to extract file paths, valid line numbers, and line content for comments."""
34
- files, current_file, current_line = {}, None, 0
34
+ """Parse diff to extract file paths, valid line numbers, and line content for comments (both sides)."""
35
+ files, current_file, new_line, old_line = {}, None, 0, 0
35
36
 
36
37
  for line in diff_text.split("\n"):
37
38
  if line.startswith("diff --git"):
38
39
  match = re.search(r" b/(.+)$", line)
39
40
  current_file = match.group(1) if match else None
40
- current_line = 0
41
+ new_line, old_line = 0, 0
41
42
  if current_file:
42
- files[current_file] = {}
43
+ files[current_file] = {"RIGHT": {}, "LEFT": {}}
43
44
  elif line.startswith("@@") and current_file:
44
- match = re.search(r"@@.*\+(\d+)(?:,\d+)?", line)
45
- current_line = int(match.group(1)) if match else 0
46
- elif current_file and current_line > 0:
45
+ # Extract both old and new line numbers
46
+ match = re.search(r"@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)?", line)
47
+ if match:
48
+ old_line, new_line = int(match.group(1)), int(match.group(2))
49
+ elif current_file and (new_line > 0 or old_line > 0):
47
50
  if line.startswith("+") and not line.startswith("+++"):
48
- files[current_file][current_line] = line[1:]
49
- current_line += 1
50
- elif not line.startswith("-"):
51
- current_line += 1
51
+ files[current_file]["RIGHT"][new_line] = line[1:] # Added line (right/new side)
52
+ new_line += 1
53
+ elif line.startswith("-") and not line.startswith("---"):
54
+ files[current_file]["LEFT"][old_line] = line[1:] # Removed line (left/old side)
55
+ old_line += 1
56
+ elif not line.startswith("\\"): # Context line (ignore "No newline" markers)
57
+ new_line += 1
58
+ old_line += 1
52
59
 
53
60
  return files
54
61
 
@@ -64,8 +71,8 @@ def generate_pr_review(repository: str, diff_text: str, pr_title: str, pr_descri
64
71
 
65
72
  # Filter out generated/vendored files
66
73
  filtered_files = {
67
- path: lines
68
- for path, lines in diff_files.items()
74
+ path: sides
75
+ for path, sides in diff_files.items()
69
76
  if not any(re.search(pattern, path) for pattern in SKIP_PATTERNS)
70
77
  }
71
78
  skipped_count = len(diff_files) - len(filtered_files)
@@ -76,7 +83,7 @@ def generate_pr_review(repository: str, diff_text: str, pr_title: str, pr_descri
76
83
 
77
84
  file_list = list(diff_files.keys())
78
85
  diff_truncated = len(diff_text) > MAX_PROMPT_CHARS
79
- lines_changed = sum(len(lines) for lines in diff_files.values())
86
+ lines_changed = sum(len(sides["RIGHT"]) + len(sides["LEFT"]) for sides in diff_files.values())
80
87
 
81
88
  content = (
82
89
  "You are an expert code reviewer for Ultralytics. Provide detailed inline comments on specific code changes.\n\n"
@@ -100,10 +107,18 @@ def generate_pr_review(repository: str, diff_text: str, pr_title: str, pr_descri
100
107
  "- Suggestion content must match the exact indentation of the original line\n"
101
108
  "- Avoid triple backticks (```) in suggestions as they break markdown formatting\n"
102
109
  "- It's better to flag an issue without a suggestion than provide a wrong or uncertain fix\n\n"
110
+ "LINE NUMBERS:\n"
111
+ "- You MUST extract line numbers directly from the @@ hunk headers in the diff below\n"
112
+ "- RIGHT (added +): Find @@ lines, use numbers after +N (e.g., @@ -10,5 +20,7 @@ means RIGHT starts at line 20)\n"
113
+ "- LEFT (removed -): Find @@ lines, use numbers after -N (e.g., @@ -10,5 +20,7 @@ means LEFT starts at line 10)\n"
114
+ "- Count forward from hunk start: + lines increment RIGHT, - lines increment LEFT, context lines increment both\n"
115
+ "- CRITICAL: Using line numbers not in the diff will cause your comment to be rejected\n"
116
+ "- Suggestions only work on RIGHT (added) lines, never on LEFT (removed) lines\n\n"
103
117
  "Return JSON: "
104
- '{"comments": [{"file": "exact/path", "line": N, "severity": "HIGH", "message": "...", "suggestion": "..."}], "summary": "..."}\n\n'
118
+ '{"comments": [{"file": "exact/path", "line": N, "side": "RIGHT", "severity": "HIGH", "message": "..."}], "summary": "..."}\n\n'
105
119
  "Rules:\n"
106
- "- Only NEW lines (+ in diff), exact paths (no ./), correct line numbers from @@ hunks\n"
120
+ "- Verify line numbers from @@ hunks: +N for RIGHT (added), -N for LEFT (removed)\n"
121
+ "- Exact paths (no ./), 'side' field defaults to RIGHT if omitted\n"
107
122
  "- Severity: CRITICAL, HIGH, MEDIUM, LOW, SUGGESTION\n"
108
123
  f"- Files changed: {len(file_list)} ({', '.join(file_list[:10])}{'...' if len(file_list) > 10 else ''})\n"
109
124
  f"- Lines changed: {lines_changed}\n"
@@ -125,11 +140,10 @@ def generate_pr_review(repository: str, diff_text: str, pr_title: str, pr_descri
125
140
 
126
141
  try:
127
142
  response = get_completion(messages, reasoning_effort="medium", model="gpt-5-codex")
128
- print("\n" + "=" * 80 + f"\nFULL AI RESPONSE:\n{response}\n" + "=" * 80 + "\n")
129
143
 
130
144
  json_str = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", response, re.DOTALL)
131
145
  review_data = json.loads(json_str.group(1) if json_str else response)
132
-
146
+ print(json.dumps(review_data, indent=2))
133
147
  print(f"AI generated {len(review_data.get('comments', []))} comments")
134
148
 
135
149
  # Validate, filter, and deduplicate comments
@@ -137,11 +151,27 @@ def generate_pr_review(repository: str, diff_text: str, pr_title: str, pr_descri
137
151
  for c in review_data.get("comments", []):
138
152
  file_path, line_num = c.get("file"), c.get("line", 0)
139
153
  start_line = c.get("start_line")
154
+ side = (c.get("side") or "RIGHT").upper() # Default to RIGHT (added lines)
140
155
 
141
- # Validate line numbers are in diff
142
- if file_path not in diff_files or line_num not in diff_files[file_path]:
143
- print(f"Filtered out {file_path}:{line_num} (available: {list(diff_files.get(file_path, {}))[:10]}...)")
156
+ # Validate line numbers are in diff (check appropriate side)
157
+ if file_path not in diff_files:
158
+ print(f"Filtered out {file_path}:{line_num} (file not in diff)")
144
159
  continue
160
+ if line_num not in diff_files[file_path].get(side, {}):
161
+ # Try other side if not found
162
+ other_side = "LEFT" if side == "RIGHT" else "RIGHT"
163
+ if line_num in diff_files[file_path].get(other_side, {}):
164
+ print(f"Switching {file_path}:{line_num} from {side} to {other_side}")
165
+ c["side"] = other_side
166
+ side = other_side
167
+ # GitHub rejects suggestions on removed lines
168
+ if side == "LEFT" and c.get("suggestion"):
169
+ print(f"Dropping suggestion for {file_path}:{line_num} - LEFT side doesn't support suggestions")
170
+ c.pop("suggestion", None)
171
+ else:
172
+ available = {s: list(diff_files[file_path][s].keys())[:10] for s in ["RIGHT", "LEFT"]}
173
+ print(f"Filtered out {file_path}:{line_num} (available: {available})")
174
+ continue
145
175
 
146
176
  # Validate start_line if provided - drop start_line for suggestions (single-line only)
147
177
  if start_line:
@@ -151,12 +181,12 @@ def generate_pr_review(repository: str, diff_text: str, pr_title: str, pr_descri
151
181
  elif start_line >= line_num:
152
182
  print(f"Invalid start_line {start_line} >= line {line_num} for {file_path}, dropping start_line")
153
183
  c.pop("start_line", None)
154
- elif start_line not in diff_files[file_path]:
184
+ elif start_line not in diff_files[file_path].get(side, {}):
155
185
  print(f"start_line {start_line} not in diff for {file_path}, dropping start_line")
156
186
  c.pop("start_line", None)
157
187
 
158
- # Deduplicate by line number
159
- key = f"{file_path}:{line_num}"
188
+ # Deduplicate by line number and side
189
+ key = f"{file_path}:{side}:{line_num}"
160
190
  if key not in unique_comments:
161
191
  unique_comments[key] = c
162
192
  else:
@@ -179,7 +209,7 @@ def generate_pr_review(repository: str, diff_text: str, pr_title: str, pr_descri
179
209
  error_details = traceback.format_exc()
180
210
  print(f"Review generation failed: {e}\n{error_details}")
181
211
  summary = (
182
- f"⚠️ Review generation encountered an error: `{type(e).__name__}`\n\n"
212
+ f"{ERROR_MARKER}: `{type(e).__name__}`\n\n"
183
213
  f"<details><summary>Debug Info</summary>\n\n```\n{error_details}\n```\n</details>"
184
214
  )
185
215
  return {"comments": [], "summary": summary}
@@ -219,7 +249,29 @@ def post_review_summary(event: Action, review_data: dict, review_number: int) ->
219
249
 
220
250
  review_title = f"{REVIEW_MARKER} {review_number}" if review_number > 1 else REVIEW_MARKER
221
251
  comments = review_data.get("comments", [])
222
- event_type = "COMMENT" if any(c.get("severity") not in ["LOW", "SUGGESTION", None] for c in comments) else "APPROVE"
252
+ summary = review_data.get("summary") or ""
253
+
254
+ # Don't approve if error occurred or if there are critical/high severity issues
255
+ has_error = not summary or ERROR_MARKER in summary
256
+ has_issues = any(c.get("severity") not in ["LOW", "SUGGESTION", None] for c in comments)
257
+ requests_changes = any(
258
+ phrase in summary.lower()
259
+ for phrase in [
260
+ "please",
261
+ "should",
262
+ "must",
263
+ "raise",
264
+ "needs",
265
+ "before merging",
266
+ "fix",
267
+ "error",
268
+ "issue",
269
+ "problem",
270
+ "warning",
271
+ "concern",
272
+ ]
273
+ )
274
+ event_type = "COMMENT" if (has_error or has_issues or requests_changes) else "APPROVE"
223
275
 
224
276
  body = (
225
277
  f"## {review_title}\n\n"
@@ -246,22 +298,25 @@ def post_review_summary(event: Action, review_data: dict, review_number: int) ->
246
298
  severity = comment.get("severity") or "SUGGESTION"
247
299
  comment_body = f"{EMOJI_MAP.get(severity, '💭')} **{severity}**: {(comment.get('message') or '')[:1000]}"
248
300
 
301
+ # Get side (LEFT for removed lines, RIGHT for added lines)
302
+ side = comment.get("side", "RIGHT")
303
+
249
304
  if suggestion := comment.get("suggestion"):
250
305
  suggestion = suggestion[:1000] # Clip suggestion length
251
306
  if "```" not in suggestion:
252
307
  # Extract original line indentation and apply to suggestion
253
- if original_line := review_data.get("diff_files", {}).get(file_path, {}).get(line):
308
+ if original_line := review_data.get("diff_files", {}).get(file_path, {}).get(side, {}).get(line):
254
309
  indent = len(original_line) - len(original_line.lstrip())
255
310
  suggestion = " " * indent + suggestion.strip()
256
311
  comment_body += f"\n\n**Suggested change:**\n```suggestion\n{suggestion}\n```"
257
312
 
258
313
  # Build comment with optional start_line for multi-line context
259
- review_comment = {"path": file_path, "line": line, "body": comment_body, "side": "RIGHT"}
314
+ review_comment = {"path": file_path, "line": line, "body": comment_body, "side": side}
260
315
  if start_line := comment.get("start_line"):
261
316
  if start_line < line:
262
317
  review_comment["start_line"] = start_line
263
- review_comment["start_side"] = "RIGHT"
264
- print(f"Multi-line comment: {file_path}:{start_line}-{line}")
318
+ review_comment["start_side"] = side
319
+ print(f"Multi-line comment: {file_path}:{start_line}-{line} ({side})")
265
320
 
266
321
  review_comments.append(review_comment)
267
322
 
@@ -125,21 +125,22 @@ class Action:
125
125
 
126
126
  def _request(self, method: str, url: str, headers=None, expected_status=None, hard=False, **kwargs):
127
127
  """Unified request handler with error checking."""
128
- response = getattr(requests, method)(url, headers=headers or self.headers, **kwargs)
128
+ r = getattr(requests, method)(url, headers=headers or self.headers, **kwargs)
129
129
  expected = expected_status or self._default_status[method]
130
- success = response.status_code in expected
130
+ success = r.status_code in expected
131
131
 
132
132
  if self.verbose:
133
- print(f"{'✓' if success else '✗'} {method.upper()} {url} → {response.status_code}")
133
+ elapsed = r.elapsed.total_seconds()
134
+ print(f"{'✓' if success else '✗'} {method.upper()} {url} → {r.status_code} ({elapsed:.1f}s)")
134
135
  if not success:
135
136
  try:
136
- print(f" ❌ Error: {response.json().get('message', 'Unknown error')}")
137
+ print(f" ❌ Error: {r.json().get('message', 'Unknown error')}")
137
138
  except Exception:
138
- print(f" ❌ Error: {response.text[:200]}")
139
+ print(f" ❌ Error: {r.text[:200]}")
139
140
 
140
141
  if not success and hard:
141
- response.raise_for_status()
142
- return response
142
+ r.raise_for_status()
143
+ return r
143
144
 
144
145
  def get(self, url, **kwargs):
145
146
  """Performs GET request with error handling."""
@@ -125,6 +125,8 @@ def get_completion(
125
125
 
126
126
  try:
127
127
  r = requests.post(url, json=data, headers=headers, timeout=600)
128
+ success = r.status_code == 200
129
+ print(f"{'✓' if success else '✗'} POST {url} → {r.status_code} ({r.elapsed.total_seconds():.1f}s)")
128
130
  r.raise_for_status()
129
131
 
130
132
  # Parse response
@@ -15,6 +15,7 @@ def test_check_pypi_version():
15
15
  with patch("requests.get") as mock_get:
16
16
  mock_response = MagicMock()
17
17
  mock_response.status_code = 200
18
+ mock_response.elapsed.total_seconds.return_value = 0.5
18
19
  mock_response.json.return_value = {"info": {"version": "0.9.0"}}
19
20
  mock_get.return_value = mock_response
20
21
 
@@ -43,6 +44,7 @@ def test_action_request_methods():
43
44
  with patch("requests.get") as mock_get:
44
45
  mock_response = MagicMock()
45
46
  mock_response.status_code = 200
47
+ mock_response.elapsed.total_seconds.return_value = 0.3
46
48
  mock_get.return_value = mock_response
47
49
 
48
50
  action = Action(token="test-token")
@@ -28,6 +28,7 @@ def test_get_completion(mock_post):
28
28
  # Setup mock response with Responses API structure
29
29
  mock_response = MagicMock()
30
30
  mock_response.status_code = 200
31
+ mock_response.elapsed.total_seconds.return_value = 1.5
31
32
  mock_response.json.return_value = {
32
33
  "output": [
33
34
  {
@@ -57,6 +58,7 @@ def test_get_completion_with_link_check(mock_check_links, mock_post):
57
58
  # Setup mocks with Responses API structure
58
59
  mock_response = MagicMock()
59
60
  mock_response.status_code = 200
61
+ mock_response.elapsed.total_seconds.return_value = 2.0
60
62
  mock_response.json.return_value = {
61
63
  "output": [
62
64
  {
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ultralytics-actions
3
- Version: 0.1.5
3
+ Version: 0.1.7
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>