ultralytics-actions 0.1.0__py3-none-any.whl → 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

actions/review_pr.py CHANGED
@@ -5,10 +5,28 @@ from __future__ import annotations
5
5
  import json
6
6
  import re
7
7
 
8
- from .utils import GITHUB_API_URL, Action, get_completion
8
+ from .utils import GITHUB_API_URL, Action, get_completion, remove_html_comments
9
9
 
10
10
  REVIEW_MARKER = "🔍 PR Review"
11
11
  EMOJI_MAP = {"CRITICAL": "❗", "HIGH": "⚠️", "MEDIUM": "💡", "LOW": "📝", "SUGGESTION": "💭"}
12
+ SKIP_PATTERNS = [
13
+ r"\.lock$", # Lock files
14
+ r"-lock\.(json|yaml|yml)$",
15
+ r"\.min\.(js|css)$", # Minified
16
+ r"\.bundle\.(js|css)$",
17
+ r"(^|/)dist/", # Generated/vendored directories
18
+ r"(^|/)build/",
19
+ r"(^|/)vendor/",
20
+ r"(^|/)node_modules/",
21
+ r"\.pb\.py$", # Proto generated
22
+ r"_pb2\.py$",
23
+ r"_pb2_grpc\.py$",
24
+ r"^package-lock\.json$", # Package locks
25
+ r"^yarn\.lock$",
26
+ r"^poetry\.lock$",
27
+ r"^Pipfile\.lock$",
28
+ r"\.(svg|png|jpe?g|gif)$", # Images
29
+ ]
12
30
 
13
31
 
14
32
  def parse_diff_files(diff_text: str) -> dict:
@@ -37,44 +55,65 @@ def parse_diff_files(diff_text: str) -> dict:
37
55
 
38
56
  def generate_pr_review(repository: str, diff_text: str, pr_title: str, pr_description: str) -> dict:
39
57
  """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'}"}
58
+ if not diff_text:
59
+ return {"comments": [], "summary": "No changes detected in diff"}
42
60
 
43
61
  diff_files = parse_diff_files(diff_text)
44
62
  if not diff_files:
45
63
  return {"comments": [], "summary": "No files with changes detected in diff"}
46
64
 
65
+ # Filter out generated/vendored files
66
+ filtered_files = {
67
+ path: lines
68
+ for path, lines in diff_files.items()
69
+ if not any(re.search(pattern, path) for pattern in SKIP_PATTERNS)
70
+ }
71
+ skipped_count = len(diff_files) - len(filtered_files)
72
+ diff_files = filtered_files
73
+
74
+ if not diff_files:
75
+ return {"comments": [], "summary": f"All {skipped_count} changed files are generated/vendored (skipped review)"}
76
+
47
77
  file_list = list(diff_files.keys())
48
- limit = round(128000 * 3.3 * 0.4)
78
+ limit = round(128000 * 3.3 * 0.5) # 3.3 characters per token for half a 256k context window
49
79
  diff_truncated = len(diff_text) > limit
50
80
  lines_changed = sum(len(lines) for lines in diff_files.values())
51
81
 
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"
82
+ comment_guidance = (
83
+ "Provide up to 1-3 comments only if critical issues exist"
84
+ if lines_changed < 50
85
+ else "Provide up to 3-5 comments only if high-impact issues exist"
86
+ if lines_changed < 200
87
+ else "Provide up to 5-10 comments only for the most critical issues"
61
88
  )
62
89
 
63
90
  content = (
64
91
  "You are an expert code reviewer for Ultralytics. Provide detailed inline comments on specific code changes.\n\n"
65
92
  "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"
93
+ "FORMATTING: Use backticks for all summary and suggestion code, files, branches, functions, variables, packages, e.g. `x=3`\n\n"
67
94
  "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"
95
+ "1. Quality over quantity: Zero comments is fine for clean code - only flag truly important issues\n"
96
+ f"2. {comment_guidance} - these are maximums, not targets\n"
97
+ "3. CRITICAL: Do not post separate comments on adjacent/nearby lines (within 10 lines). Combine all related issues into ONE comment\n"
98
+ "4. When combining issues from multiple lines, use 'start_line' (first line) and 'line' (last line) to highlight the entire range\n"
99
+ "5. Each comment must reference separate areas - no overlapping line ranges\n"
100
+ "6. Prioritize: CRITICAL bugs/security > HIGH impact issues > code quality\n"
101
+ "7. Keep comments concise, friendly, and easy to understand - avoid jargon when possible\n"
102
+ "8. DO not comment on routine changes: adding imports, adding dependencies, updating version numbers, standard refactoring\n"
103
+ "9. Trust the developer - only flag issues with clear evidence of problems, not hypothetical concerns\n\n"
104
+ "SUMMARY GUIDELINES:\n"
105
+ "- Keep summary brief, clear, and actionable - avoid overly detailed explanations\n"
106
+ "- Highlight only the most important findings\n"
107
+ "- Do NOT include file names or line numbers in the summary - inline comments already show exact locations\n"
108
+ "- Focus on what needs to be fixed, not where\n\n"
73
109
  "CODE SUGGESTIONS:\n"
74
110
  "- ONLY provide 'suggestion' field when you have high certainty the code is problematic AND sufficient context for a confident fix\n"
75
111
  "- If uncertain about the correct fix, omit 'suggestion' field and explain the concern in 'message' only\n"
76
112
  "- 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"
113
+ "- Suggestions replace ONLY the single line at 'line' - for multi-line fixes, describe the change in 'message' instead\n"
114
+ "- Do NOT provide 'start_line' when including a 'suggestion' - suggestions are always single-line only\n"
115
+ "- Suggestion content must match the exact indentation of the original line\n"
116
+ "- Avoid triple backticks (```) in suggestions as they break markdown formatting\n"
78
117
  "- It's better to flag an issue without a suggestion than provide a wrong or uncertain fix\n\n"
79
118
  "Return JSON: "
80
119
  '{"comments": [{"file": "exact/path", "line": N, "severity": "HIGH", "message": "...", "suggestion": "..."}], "summary": "..."}\n\n'
@@ -85,16 +124,20 @@ def generate_pr_review(repository: str, diff_text: str, pr_title: str, pr_descri
85
124
  "- When '- old' then '+ new', new line keeps SAME line number\n"
86
125
  "- Severity: CRITICAL, HIGH, MEDIUM, LOW, SUGGESTION\n"
87
126
  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}"
127
+ f"- Lines changed: {lines_changed}\n"
91
128
  )
92
129
 
93
130
  messages = [
94
131
  {"role": "system", "content": content},
95
132
  {
96
133
  "role": "user",
97
- "content": f"Review PR '{repository}':\nTitle: {pr_title}\nDescription: {pr_description[:500]}\n\nDiff:\n{diff_text[:limit]}",
134
+ "content": (
135
+ f"Review this PR in https://github.com/{repository}:\n"
136
+ f"Title: {pr_title}\n"
137
+ f"Description: {remove_html_comments(pr_description or '')[:1000]}\n\n"
138
+ f"Diff:\n{diff_text[:limit]}\n\n"
139
+ "Now review this diff according to the rules above. Return JSON with comments array and summary."
140
+ ),
98
141
  },
99
142
  ]
100
143
 
@@ -111,23 +154,45 @@ def generate_pr_review(repository: str, diff_text: str, pr_title: str, pr_descri
111
154
  unique_comments = {}
112
155
  for c in review_data.get("comments", []):
113
156
  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:
157
+ start_line = c.get("start_line")
158
+
159
+ # Validate line numbers are in diff
160
+ if file_path not in diff_files or line_num not in diff_files[file_path]:
121
161
  print(f"Filtered out {file_path}:{line_num} (available: {list(diff_files.get(file_path, {}))[:10]}...)")
162
+ continue
163
+
164
+ # Validate start_line if provided - drop start_line for suggestions (single-line only)
165
+ if start_line:
166
+ if c.get("suggestion"):
167
+ print(f"Dropping start_line for {file_path}:{line_num} - suggestions must be single-line only")
168
+ c.pop("start_line", None)
169
+ elif start_line >= line_num:
170
+ print(f"Invalid start_line {start_line} >= line {line_num} for {file_path}, dropping start_line")
171
+ c.pop("start_line", None)
172
+ elif start_line not in diff_files[file_path]:
173
+ print(f"start_line {start_line} not in diff for {file_path}, dropping start_line")
174
+ c.pop("start_line", None)
175
+
176
+ # Deduplicate by line number
177
+ key = f"{file_path}:{line_num}"
178
+ if key not in unique_comments:
179
+ unique_comments[key] = c
180
+ else:
181
+ print(f"⚠️ AI duplicate for {key}: {c.get('severity')} - {c.get('message')[:60]}...")
122
182
 
123
183
  review_data.update(
124
- {"comments": list(unique_comments.values()), "diff_files": diff_files, "diff_truncated": diff_truncated}
184
+ {
185
+ "comments": list(unique_comments.values()),
186
+ "diff_files": diff_files,
187
+ "diff_truncated": diff_truncated,
188
+ "skipped_files": skipped_count,
189
+ }
125
190
  )
126
191
  print(f"Valid comments after filtering: {len(review_data['comments'])}")
127
192
  return review_data
128
193
 
129
194
  except json.JSONDecodeError as e:
130
- print(f"JSON parsing failed: {e}\nAttempted: {json_str[:500] if 'json_str' in locals() else response[:500]}...")
195
+ print(f"JSON parsing failed... {e}")
131
196
  return {"comments": [], "summary": "Review generation encountered a JSON parsing error"}
132
197
  except Exception as e:
133
198
  print(f"Review generation failed: {e}")
@@ -164,39 +229,14 @@ def dismiss_previous_reviews(event: Action) -> int:
164
229
  return review_count + 1
165
230
 
166
231
 
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
232
  def post_review_summary(event: Action, review_data: dict, review_number: int) -> None:
192
- """Post overall review summary as a PR review."""
233
+ """Post overall review summary and inline comments as a single PR review."""
193
234
  if not (pr_number := event.pr.get("number")) or not (commit_sha := event.pr.get("head", {}).get("sha")):
194
235
  return
195
236
 
196
237
  review_title = f"{REVIEW_MARKER} {review_number}" if review_number > 1 else REVIEW_MARKER
197
238
  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"
239
+ event_type = "COMMENT" if any(c.get("severity") not in ["LOW", "SUGGESTION", None] for c in comments) else "APPROVE"
200
240
 
201
241
  body = (
202
242
  f"## {review_title}\n\n"
@@ -205,15 +245,51 @@ def post_review_summary(event: Action, review_data: dict, review_number: int) ->
205
245
  )
206
246
 
207
247
  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"
248
+ shown = min(len(comments), 10)
249
+ body += f"💬 Posted {shown} inline comment{'s' if shown != 1 else ''}{' (10 shown, more available)' if len(comments) > 10 else ''}\n"
210
250
 
211
251
  if review_data.get("diff_truncated"):
212
252
  body += "\n⚠️ **Large PR**: Review focused on critical issues. Some details may not be covered.\n"
213
253
 
254
+ if skipped := review_data.get("skipped_files"):
255
+ body += f"\n📋 **Skipped {skipped} file{'s' if skipped != 1 else ''}** (lock files, minified, images, etc.)\n"
256
+
257
+ # Build inline comments for the review
258
+ review_comments = []
259
+ for comment in comments[:10]:
260
+ if not (file_path := comment.get("file")) or not (line := comment.get("line", 0)):
261
+ continue
262
+
263
+ severity = comment.get("severity", "SUGGESTION")
264
+ comment_body = f"{EMOJI_MAP.get(severity, '💭')} **{severity}**: {comment.get('message', '')}"
265
+
266
+ if suggestion := comment.get("suggestion"):
267
+ if "```" not in suggestion:
268
+ # Extract original line indentation and apply to suggestion
269
+ if original_line := review_data.get("diff_files", {}).get(file_path, {}).get(line):
270
+ indent = len(original_line) - len(original_line.lstrip())
271
+ suggestion = " " * indent + suggestion.strip()
272
+ comment_body += f"\n\n**Suggested change:**\n```suggestion\n{suggestion}\n```"
273
+
274
+ # Build comment with optional start_line for multi-line context
275
+ review_comment = {"path": file_path, "line": line, "body": comment_body, "side": "RIGHT"}
276
+ if start_line := comment.get("start_line"):
277
+ if start_line < line:
278
+ review_comment["start_line"] = start_line
279
+ review_comment["start_side"] = "RIGHT"
280
+ print(f"Multi-line comment: {file_path}:{start_line}-{line}")
281
+
282
+ review_comments.append(review_comment)
283
+
284
+ # Submit review with inline comments
285
+ payload = {"commit_id": commit_sha, "body": body, "event": event_type}
286
+ if review_comments:
287
+ payload["comments"] = review_comments
288
+ print(f"Posting review with {len(review_comments)} inline comments")
289
+
214
290
  event.post(
215
291
  f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{pr_number}/reviews",
216
- json={"commit_id": commit_sha, "body": body, "event": event_type},
292
+ json=payload,
217
293
  )
218
294
 
219
295
 
@@ -235,11 +311,10 @@ def main(*args, **kwargs):
235
311
  review_number = dismiss_previous_reviews(event)
236
312
 
237
313
  diff = event.get_pr_diff()
238
- review = generate_pr_review(event.repository, diff, event.pr.get("title", ""), event.pr.get("body", ""))
314
+ pr_description = event._pr_summary_cache or event.pr.get("body", "")
315
+ review = generate_pr_review(event.repository, diff, event.pr.get("title", ""), pr_description)
239
316
 
240
317
  post_review_summary(event, review, review_number)
241
- print(f"Posting {len(review.get('comments', []))} inline comments")
242
- post_review_comments(event, review)
243
318
  print("PR review completed")
244
319
 
245
320
 
actions/summarize_pr.py CHANGED
@@ -2,17 +2,14 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import time
5
+ from .utils import GITHUB_API_URL, Action, get_completion, get_pr_summary_prompt
6
6
 
7
- from .utils import GITHUB_API_URL, GITHUB_GRAPHQL_URL, Action, get_completion
8
-
9
- # Constants
10
7
  SUMMARY_START = (
11
8
  "## 🛠️ PR Summary\n\n<sub>Made with ❤️ by [Ultralytics Actions](https://github.com/ultralytics/actions)<sub>\n\n"
12
9
  )
13
10
 
14
11
 
15
- def generate_merge_message(pr_summary=None, pr_credit=None, pr_url=None):
12
+ def generate_merge_message(pr_summary, pr_credit, pr_url):
16
13
  """Generates a motivating thank-you message for merged PR contributors."""
17
14
  messages = [
18
15
  {
@@ -32,17 +29,8 @@ def generate_merge_message(pr_summary=None, pr_credit=None, pr_url=None):
32
29
  return get_completion(messages)
33
30
 
34
31
 
35
- def post_merge_message(event, summary, pr_credit):
36
- """Posts thank you message on PR after merge."""
37
- pr_url = f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{event.pr['number']}"
38
- comment_url = f"{GITHUB_API_URL}/repos/{event.repository}/issues/{event.pr['number']}/comments"
39
- message = generate_merge_message(summary, pr_credit, pr_url)
40
- event.post(comment_url, json={"body": message})
41
-
42
-
43
32
  def generate_issue_comment(pr_url, pr_summary, pr_credit, pr_title=""):
44
33
  """Generates personalized issue comment based on PR context."""
45
- # Extract repo info from PR URL (format: api.github.com/repos/owner/repo/pulls/number)
46
34
  repo_parts = pr_url.split("/repos/")[1].split("/pulls/")[0] if "/repos/" in pr_url else ""
47
35
  owner_repo = repo_parts.split("/")
48
36
  repo_name = owner_repo[-1] if len(owner_repo) > 1 else "package"
@@ -72,137 +60,47 @@ def generate_issue_comment(pr_url, pr_summary, pr_credit, pr_title=""):
72
60
 
73
61
 
74
62
  def generate_pr_summary(repository, diff_text):
75
- """Generates a concise, professional summary of a PR using OpenAI's API for Ultralytics repositories."""
76
- if not diff_text:
77
- diff_text = "**ERROR: DIFF IS EMPTY, THERE ARE ZERO CODE CHANGES IN THIS PR."
78
- ratio = 3.3 # about 3.3 characters per token
79
- limit = round(128000 * ratio * 0.5) # use up to 50% of the 128k context window for prompt
63
+ """Generates a concise, professional summary of a PR using OpenAI's API."""
64
+ prompt, is_large = get_pr_summary_prompt(repository, diff_text)
65
+
80
66
  messages = [
81
67
  {
82
68
  "role": "system",
83
69
  "content": "You are an Ultralytics AI assistant skilled in software development and technical communication. Your task is to summarize GitHub PRs from Ultralytics in a way that is accurate, concise, and understandable to both expert developers and non-expert users. Focus on highlighting the key changes and their impact in simple, concise terms.",
84
70
  },
85
- {
86
- "role": "user",
87
- "content": f"Summarize this '{repository}' PR, focusing on major changes, their purpose, and potential impact. Keep the summary clear and concise, suitable for a broad audience. Add emojis to enliven the summary. Reply directly with a summary along these example guidelines, though feel free to adjust as appropriate:\n\n"
88
- f"### 🌟 Summary (single-line synopsis)\n"
89
- f"### 📊 Key Changes (bullet points highlighting any major changes)\n"
90
- f"### 🎯 Purpose & Impact (bullet points explaining any benefits and potential impact to users)\n"
91
- f"\n\nHere's the PR diff:\n\n{diff_text[:limit]}",
92
- },
71
+ {"role": "user", "content": prompt},
93
72
  ]
94
73
  reply = get_completion(messages, temperature=1.0)
95
- if len(diff_text) > limit:
74
+ if is_large:
96
75
  reply = "**WARNING ⚠️** this PR is very large, summary may not cover all changes.\n\n" + reply
97
76
  return SUMMARY_START + reply
98
77
 
99
78
 
100
- def update_pr_description(event, new_summary, max_retries=2):
101
- """Updates PR description with new summary, retrying if description is None."""
102
- description = ""
103
- url = f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{event.pr['number']}"
104
- for i in range(max_retries + 1):
105
- description = event.get(url).json().get("body") or ""
106
- if description:
107
- break
108
- if i < max_retries:
109
- print("No current PR description found, retrying...")
110
- time.sleep(1)
111
-
112
- # Check if existing summary is present and update accordingly
113
- start = "## 🛠️ PR Summary"
114
- if start in description:
115
- print("Existing PR Summary found, replacing.")
116
- updated_description = description.split(start)[0] + new_summary
117
- else:
118
- print("PR Summary not found, appending.")
119
- updated_description = description + "\n\n" + new_summary
120
-
121
- # Update the PR description
122
- event.patch(url, json={"body": updated_description})
123
-
124
-
125
79
  def label_fixed_issues(event, pr_summary):
126
80
  """Labels issues closed by PR when merged, notifies users, and returns PR contributors."""
127
- query = """
128
- query($owner: String!, $repo: String!, $pr_number: Int!) {
129
- repository(owner: $owner, name: $repo) {
130
- pullRequest(number: $pr_number) {
131
- closingIssuesReferences(first: 50) { nodes { number } }
132
- url
133
- title
134
- body
135
- author { login, __typename }
136
- reviews(first: 50) { nodes { author { login, __typename } } }
137
- comments(first: 50) { nodes { author { login, __typename } } }
138
- commits(first: 100) { nodes { commit { author { user { login } }, committer { user { login } } } } }
139
- }
140
- }
141
- }
142
- """
143
- owner, repo = event.repository.split("/")
144
- variables = {"owner": owner, "repo": repo, "pr_number": event.pr["number"]}
145
- response = event.post(GITHUB_GRAPHQL_URL, json={"query": query, "variables": variables})
146
- if response.status_code != 200:
147
- return None # no linked issues
148
-
149
- try:
150
- data = response.json()["data"]["repository"]["pullRequest"]
151
- comments = data["reviews"]["nodes"] + data["comments"]["nodes"]
152
- token_username = event.get_username() # get GITHUB_TOKEN username
153
- author = data["author"]["login"] if data["author"]["__typename"] != "Bot" else None
154
- pr_title = data.get("title", "")
155
-
156
- # Get unique contributors from reviews and comments
157
- contributors = {x["author"]["login"] for x in comments if x["author"]["__typename"] != "Bot"}
158
-
159
- # Add commit authors and committers that have GitHub accounts linked
160
- for commit in data["commits"]["nodes"]:
161
- commit_data = commit["commit"]
162
- for user_type in ["author", "committer"]:
163
- if user := commit_data[user_type].get("user"):
164
- if login := user.get("login"):
165
- contributors.add(login)
166
-
167
- contributors.discard(author)
168
- contributors.discard(token_username)
169
-
170
- # Write credit string
171
- pr_credit = "" # i.e. "@user1 with contributions from @user2, @user3"
172
- if author and author != token_username:
173
- pr_credit += f"@{author}"
174
- if contributors:
175
- pr_credit += (" with contributions from " if pr_credit else "") + ", ".join(f"@{c}" for c in contributors)
176
-
177
- # Generate personalized comment
178
- comment = generate_issue_comment(
179
- pr_url=data["url"], pr_summary=pr_summary, pr_credit=pr_credit, pr_title=pr_title
180
- )
181
-
182
- # Update linked issues
183
- for issue in data["closingIssuesReferences"]["nodes"]:
184
- number = issue["number"]
185
- # Add fixed label
186
- event.post(f"{GITHUB_API_URL}/repos/{event.repository}/issues/{number}/labels", json={"labels": ["fixed"]})
187
-
188
- # Add comment
189
- event.post(f"{GITHUB_API_URL}/repos/{event.repository}/issues/{number}/comments", json={"body": comment})
190
-
191
- return pr_credit
192
- except KeyError as e:
193
- print(f"Error parsing GraphQL response: {e}")
81
+ pr_credit, data = event.get_pr_contributors()
82
+ if not pr_credit:
194
83
  return None
195
84
 
85
+ comment = generate_issue_comment(data["url"], pr_summary, pr_credit, data.get("title", ""))
86
+
87
+ for issue in data["closingIssuesReferences"]["nodes"]:
88
+ number = issue["number"]
89
+ event.post(f"{GITHUB_API_URL}/repos/{event.repository}/issues/{number}/labels", json={"labels": ["fixed"]})
90
+ event.post(f"{GITHUB_API_URL}/repos/{event.repository}/issues/{number}/comments", json={"body": comment})
196
91
 
197
- def remove_pr_labels(event, labels=()):
198
- """Removes specified labels from PR."""
199
- for label in labels: # Can be extended with more labels in the future
200
- event.delete(f"{GITHUB_API_URL}/repos/{event.repository}/issues/{event.pr['number']}/labels/{label}")
92
+ return pr_credit
201
93
 
202
94
 
203
95
  def main(*args, **kwargs):
204
96
  """Summarize a pull request and update its description with a summary."""
205
97
  event = Action(*args, **kwargs)
98
+ action = event.event_data.get("action")
99
+ if action == "opened":
100
+ print("Skipping PR open - handled by first_interaction.py with unified API call")
101
+ return
102
+ if event.should_skip_openai():
103
+ return
206
104
 
207
105
  print(f"Retrieving diff for PR {event.pr['number']}")
208
106
  diff = event.get_pr_diff()
@@ -213,17 +111,18 @@ def main(*args, **kwargs):
213
111
 
214
112
  # Update PR description
215
113
  print("Updating PR description...")
216
- update_pr_description(event, summary)
114
+ event.update_pr_description(event.pr["number"], summary)
217
115
 
218
- # Update linked issues and post thank you message if merged
219
116
  if event.pr.get("merged"):
220
117
  print("PR is merged, labeling fixed issues...")
221
118
  pr_credit = label_fixed_issues(event, summary)
222
119
  print("Removing TODO label from PR...")
223
- remove_pr_labels(event, labels=["TODO"])
120
+ event.remove_labels(event.pr["number"], labels=("TODO",))
224
121
  if pr_credit:
225
122
  print("Posting PR author thank you message...")
226
- post_merge_message(event, summary, pr_credit)
123
+ pr_url = f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{event.pr['number']}"
124
+ message = generate_merge_message(summary, pr_credit, pr_url)
125
+ event.add_comment(event.pr["number"], None, message, "pull request")
227
126
 
228
127
 
229
128
  if __name__ == "__main__":
@@ -19,7 +19,7 @@ def get_release_diff(event, previous_tag: str, latest_tag: str) -> str:
19
19
  """Retrieves the differences between two specified Git tags in a GitHub repository."""
20
20
  url = f"{GITHUB_API_URL}/repos/{event.repository}/compare/{previous_tag}...{latest_tag}"
21
21
  r = event.get(url, headers=event.headers_diff)
22
- return r.text if r.status_code == 200 else f"Failed to get diff: {r.content}"
22
+ return r.text if r.status_code == 200 else f"Failed to get diff: {r.text}"
23
23
 
24
24
 
25
25
  def get_prs_between_tags(event, previous_tag: str, latest_tag: str) -> list:
@@ -42,8 +42,8 @@ def get_prs_between_tags(event, previous_tag: str, latest_tag: str) -> list:
42
42
  pr_numbers.update(pr_matches)
43
43
 
44
44
  prs = []
45
- time.sleep(10) # sleep 10 seconds to allow final PR summary to update on merge
46
45
  for pr_number in sorted(pr_numbers): # earliest to latest
46
+ time.sleep(1) # Rate limit: GitHub search API has strict limits
47
47
  pr_url = f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{pr_number}"
48
48
  pr_response = event.get(pr_url)
49
49
  if pr_response.status_code == 200:
@@ -52,7 +52,7 @@ def get_prs_between_tags(event, previous_tag: str, latest_tag: str) -> list:
52
52
  {
53
53
  "number": pr_data["number"],
54
54
  "title": pr_data["title"],
55
- "body": remove_html_comments(pr_data["body"]),
55
+ "body": remove_html_comments(pr_data.get("body", "")),
56
56
  "author": pr_data["user"]["login"],
57
57
  "html_url": pr_data["html_url"],
58
58
  "merged_at": pr_data["merged_at"],
@@ -68,9 +68,15 @@ def get_prs_between_tags(event, previous_tag: str, latest_tag: str) -> list:
68
68
  def get_new_contributors(event, prs: list) -> set:
69
69
  """Identify new contributors who made their first merged PR in the current release."""
70
70
  new_contributors = set()
71
+ checked_authors = set()
71
72
  for pr in prs:
72
73
  author = pr["author"]
73
- # Check if this is the author's first contribution
74
+ if author in checked_authors:
75
+ print(f"Skipping duplicate author: {author}")
76
+ continue
77
+ checked_authors.add(author)
78
+
79
+ time.sleep(2) # Rate limit: GitHub search API has strict limits
74
80
  url = f"{GITHUB_API_URL}/search/issues?q=repo:{event.repository}+author:{author}+is:pr+is:merged&sort=created&order=asc"
75
81
  r = event.get(url)
76
82
  if r.status_code == 200:
@@ -79,6 +85,11 @@ def get_new_contributors(event, prs: list) -> set:
79
85
  first_pr = data["items"][0]
80
86
  if first_pr["number"] == pr["number"]:
81
87
  new_contributors.add(author)
88
+ elif r.status_code == 403:
89
+ print(f"⚠️ Rate limit hit checking {author}, stopping contributor check")
90
+ break
91
+ else:
92
+ print(f"Failed to check {author}: {r.status_code}")
82
93
  return new_contributors
83
94
 
84
95
 
@@ -129,7 +140,7 @@ def generate_release_summary(
129
140
  },
130
141
  {
131
142
  "role": "user",
132
- "content": f"Summarize the updates made in the '{latest_tag}' tag, focusing on major model or features changes, their purpose, and potential impact. Keep the summary clear and suitable for a broad audience. Add emojis to enliven the summary. Prioritize changes from the current PR (the first in the list), which is usually the most important in the release. Reply directly with a summary along these example guidelines, though feel free to adjust as appropriate:\n\n"
143
+ "content": f"Summarize the updates made in the '{latest_tag}' tag, focusing on major model or features changes, their purpose, and potential impact. Keep the summary clear and suitable for a broad audience. Add emojis to enliven the summary. Prioritize changes from the current PR (the last in the list), which is usually the most important in the release. Reply directly with a summary along these example guidelines, though feel free to adjust as appropriate:\n\n"
133
144
  f"## 🌟 Summary (single-line synopsis)\n"
134
145
  f"## 📊 Key Changes (bullet points highlighting any major changes)\n"
135
146
  f"## 🎯 Purpose & Impact (bullet points explaining any benefits and potential impact to users)\n\n\n"
@@ -40,7 +40,6 @@ def add_indentation(code_block, num_spaces):
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
42
  if not next(Path(temp_dir).rglob("*.py"), None):
43
- print("No Python code blocks found to format")
44
43
  return
45
44
 
46
45
  try:
actions/utils/__init__.py CHANGED
@@ -9,7 +9,13 @@ from .common_utils import (
9
9
  remove_html_comments,
10
10
  )
11
11
  from .github_utils import GITHUB_API_URL, GITHUB_GRAPHQL_URL, Action, ultralytics_actions_info
12
- from .openai_utils import get_completion
12
+ from .openai_utils import (
13
+ filter_labels,
14
+ get_completion,
15
+ get_pr_open_response,
16
+ get_pr_summary_guidelines,
17
+ get_pr_summary_prompt,
18
+ )
13
19
  from .version_utils import check_pubdev_version, check_pypi_version
14
20
 
15
21
  __all__ = (
@@ -23,7 +29,11 @@ __all__ = (
23
29
  "allow_redirect",
24
30
  "check_pubdev_version",
25
31
  "check_pypi_version",
32
+ "filter_labels",
26
33
  "get_completion",
34
+ "get_pr_open_response",
35
+ "get_pr_summary_guidelines",
36
+ "get_pr_summary_prompt",
27
37
  "remove_html_comments",
28
38
  "ultralytics_actions_info",
29
39
  )