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/__init__.py +1 -1
- actions/dispatch_actions.py +3 -3
- actions/first_interaction.py +63 -221
- actions/review_pr.py +141 -66
- actions/summarize_pr.py +27 -128
- actions/summarize_release.py +16 -5
- actions/update_markdown_code_blocks.py +0 -1
- actions/utils/__init__.py +11 -1
- actions/utils/github_utils.py +267 -1
- actions/utils/openai_utils.py +139 -13
- {ultralytics_actions-0.1.0.dist-info → ultralytics_actions-0.1.2.dist-info}/METADATA +3 -1
- ultralytics_actions-0.1.2.dist-info/RECORD +19 -0
- ultralytics_actions-0.1.0.dist-info/RECORD +0 -19
- {ultralytics_actions-0.1.0.dist-info → ultralytics_actions-0.1.2.dist-info}/WHEEL +0 -0
- {ultralytics_actions-0.1.0.dist-info → ultralytics_actions-0.1.2.dist-info}/entry_points.txt +0 -0
- {ultralytics_actions-0.1.0.dist-info → ultralytics_actions-0.1.2.dist-info}/licenses/LICENSE +0 -0
- {ultralytics_actions-0.1.0.dist-info → ultralytics_actions-0.1.2.dist-info}/top_level.txt +0 -0
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
|
|
41
|
-
return {"comments": [], "summary":
|
|
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.
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
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
|
-
|
|
69
|
-
"2.
|
|
70
|
-
"3.
|
|
71
|
-
"4.
|
|
72
|
-
|
|
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
|
-
"-
|
|
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"-
|
|
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":
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
{
|
|
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
|
|
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
|
-
|
|
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),
|
|
209
|
-
body += f"💬 Posted {shown} inline comment{'s' if shown != 1 else ''}{' (
|
|
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=
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
+
event.remove_labels(event.pr["number"], labels=("TODO",))
|
|
224
121
|
if pr_credit:
|
|
225
122
|
print("Posting PR author thank you message...")
|
|
226
|
-
|
|
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__":
|
actions/summarize_release.py
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
)
|