ultralytics-actions 0.0.40__py3-none-any.whl
Sign up to get free protection for your applications and to get access to all the features.
- actions/__init__.py +25 -0
- actions/first_interaction.py +403 -0
- actions/summarize_pr.py +244 -0
- actions/summarize_release.py +194 -0
- actions/update_markdown_code_blocks.py +173 -0
- actions/utils/__init__.py +19 -0
- actions/utils/common_utils.py +111 -0
- actions/utils/github_utils.py +163 -0
- actions/utils/openai_utils.py +45 -0
- ultralytics_actions-0.0.40.dist-info/LICENSE +661 -0
- ultralytics_actions-0.0.40.dist-info/METADATA +144 -0
- ultralytics_actions-0.0.40.dist-info/RECORD +15 -0
- ultralytics_actions-0.0.40.dist-info/WHEEL +5 -0
- ultralytics_actions-0.0.40.dist-info/entry_points.txt +6 -0
- ultralytics_actions-0.0.40.dist-info/top_level.txt +1 -0
actions/__init__.py
ADDED
@@ -0,0 +1,25 @@
|
|
1
|
+
# Ultralytics Actions 🚀, AGPL-3.0 license https://ultralytics.com/license
|
2
|
+
|
3
|
+
# project_root/
|
4
|
+
# ├── pyproject.toml
|
5
|
+
# ├── README.md
|
6
|
+
# ├── LICENSE
|
7
|
+
# ├── .gitignore
|
8
|
+
# ├── actions/
|
9
|
+
# │ ├── __init__.py
|
10
|
+
# │ ├── utils/
|
11
|
+
# │ │ ├── __init__.py
|
12
|
+
# │ │ ├── github_utils.py
|
13
|
+
# │ │ ├── openai_utils.py
|
14
|
+
# │ │ └── common_utils.py
|
15
|
+
# │ ├── first_interaction.py
|
16
|
+
# │ ├── summarize_pr.py
|
17
|
+
# │ ├── summarize_release.py
|
18
|
+
# │ └── update_markdown_code_blocks.py
|
19
|
+
# └── tests/
|
20
|
+
# ├── __init__.py
|
21
|
+
# ├── test_first_interaction.py
|
22
|
+
# ├── test_summarize_pr.py
|
23
|
+
# └── ...
|
24
|
+
|
25
|
+
__version__ = "0.0.40"
|
@@ -0,0 +1,403 @@
|
|
1
|
+
# Ultralytics Actions 🚀, AGPL-3.0 license https://ultralytics.com/license
|
2
|
+
|
3
|
+
import os
|
4
|
+
from typing import Dict, List, Tuple
|
5
|
+
|
6
|
+
import requests
|
7
|
+
|
8
|
+
from .utils import (
|
9
|
+
GITHUB_API_URL,
|
10
|
+
Action,
|
11
|
+
get_completion,
|
12
|
+
remove_html_comments,
|
13
|
+
)
|
14
|
+
|
15
|
+
# Environment variables
|
16
|
+
BLOCK_USER = os.getenv("BLOCK_USER", "false").lower() == "true"
|
17
|
+
|
18
|
+
|
19
|
+
def get_event_content(event) -> Tuple[int, str, str, str, str, str, str]:
|
20
|
+
"""Extracts key information from GitHub event data for issues, pull requests, or discussions."""
|
21
|
+
data = event.event_data
|
22
|
+
name = event.event_name
|
23
|
+
action = data["action"] # 'opened', 'closed', 'created' (discussion), etc.
|
24
|
+
if name == "issues":
|
25
|
+
item = data["issue"]
|
26
|
+
issue_type = "issue"
|
27
|
+
elif name in ["pull_request", "pull_request_target"]:
|
28
|
+
pr_number = data["pull_request"]["number"]
|
29
|
+
item = event.get_repo_data(f"pulls/{pr_number}")
|
30
|
+
issue_type = "pull request"
|
31
|
+
elif name == "discussion":
|
32
|
+
item = data["discussion"]
|
33
|
+
issue_type = "discussion"
|
34
|
+
else:
|
35
|
+
raise ValueError(f"Unsupported event type: {name}")
|
36
|
+
|
37
|
+
number = item["number"]
|
38
|
+
node_id = item.get("node_id") or item.get("id")
|
39
|
+
title = item["title"]
|
40
|
+
body = remove_html_comments(item.get("body", ""))
|
41
|
+
username = item["user"]["login"]
|
42
|
+
return number, node_id, title, body, username, issue_type, action
|
43
|
+
|
44
|
+
|
45
|
+
def update_issue_pr_content(event, number: int, node_id: str, issue_type: str):
|
46
|
+
"""Updates the title and body of an issue, pull request, or discussion with predefined content."""
|
47
|
+
new_title = "Content Under Review"
|
48
|
+
new_body = """This post has been flagged for review by [Ultralytics Actions](https://ultralytics.com/actions) due to possible spam, abuse, or off-topic content. For more information please see our:
|
49
|
+
|
50
|
+
- [Code of Conduct](https://docs.ultralytics.com/help/code_of_conduct)
|
51
|
+
- [Security Policy](https://docs.ultralytics.com/help/security)
|
52
|
+
|
53
|
+
For questions or bug reports related to this action please visit https://github.com/ultralytics/actions.
|
54
|
+
|
55
|
+
Thank you 🙏
|
56
|
+
"""
|
57
|
+
if issue_type == "discussion":
|
58
|
+
mutation = """
|
59
|
+
mutation($discussionId: ID!, $title: String!, $body: String!) {
|
60
|
+
updateDiscussion(input: {discussionId: $discussionId, title: $title, body: $body}) {
|
61
|
+
discussion {
|
62
|
+
id
|
63
|
+
}
|
64
|
+
}
|
65
|
+
}
|
66
|
+
"""
|
67
|
+
event.graphql_request(mutation, variables={"discussionId": node_id, "title": new_title, "body": new_body})
|
68
|
+
else:
|
69
|
+
url = f"{GITHUB_API_URL}/repos/{event.repository}/issues/{number}"
|
70
|
+
r = requests.patch(url, json={"title": new_title, "body": new_body}, headers=event.headers)
|
71
|
+
print(f"{'Successful' if r.status_code == 200 else 'Fail'} issue/PR #{number} update: {r.status_code}")
|
72
|
+
|
73
|
+
|
74
|
+
def close_issue_pr(event, number: int, node_id: str, issue_type: str):
|
75
|
+
"""Closes the specified issue, pull request, or discussion using the GitHub API."""
|
76
|
+
if issue_type == "discussion":
|
77
|
+
mutation = """
|
78
|
+
mutation($discussionId: ID!) {
|
79
|
+
closeDiscussion(input: {discussionId: $discussionId}) {
|
80
|
+
discussion {
|
81
|
+
id
|
82
|
+
}
|
83
|
+
}
|
84
|
+
}
|
85
|
+
"""
|
86
|
+
event.graphql_request(mutation, variables={"discussionId": node_id})
|
87
|
+
else:
|
88
|
+
url = f"{GITHUB_API_URL}/repos/{event.repository}/issues/{number}"
|
89
|
+
r = requests.patch(url, json={"state": "closed"}, headers=event.headers)
|
90
|
+
print(f"{'Successful' if r.status_code == 200 else 'Fail'} issue/PR #{number} close: {r.status_code}")
|
91
|
+
|
92
|
+
|
93
|
+
def lock_issue_pr(event, number: int, node_id: str, issue_type: str):
|
94
|
+
"""Locks an issue, pull request, or discussion to prevent further interactions."""
|
95
|
+
if issue_type == "discussion":
|
96
|
+
mutation = """
|
97
|
+
mutation($lockableId: ID!, $lockReason: LockReason) {
|
98
|
+
lockLockable(input: {lockableId: $lockableId, lockReason: $lockReason}) {
|
99
|
+
lockedRecord {
|
100
|
+
... on Discussion {
|
101
|
+
id
|
102
|
+
}
|
103
|
+
}
|
104
|
+
}
|
105
|
+
}
|
106
|
+
"""
|
107
|
+
event.graphql_request(mutation, variables={"lockableId": node_id, "lockReason": "OFF_TOPIC"})
|
108
|
+
else:
|
109
|
+
url = f"{GITHUB_API_URL}/repos/{event.repository}/issues/{number}/lock"
|
110
|
+
r = requests.put(url, json={"lock_reason": "off-topic"}, headers=event.headers)
|
111
|
+
print(f"{'Successful' if r.status_code in {200, 204} else 'Fail'} issue/PR #{number} lock: {r.status_code}")
|
112
|
+
|
113
|
+
|
114
|
+
def block_user(event, username: str):
|
115
|
+
"""Blocks a user from the organization using the GitHub API."""
|
116
|
+
url = f"{GITHUB_API_URL}/orgs/{event.repository.split('/')[0]}/blocks/{username}"
|
117
|
+
r = requests.put(url, headers=event.headers)
|
118
|
+
print(f"{'Successful' if r.status_code == 204 else 'Fail'} user block for {username}: {r.status_code}")
|
119
|
+
|
120
|
+
|
121
|
+
def get_relevant_labels(
|
122
|
+
issue_type: str, title: str, body: str, available_labels: Dict, current_labels: List
|
123
|
+
) -> List[str]:
|
124
|
+
"""Determines relevant labels for GitHub issues/PRs using OpenAI, considering title, body, and existing labels."""
|
125
|
+
# Remove mutually exclusive labels like both 'bug' and 'question' or inappropriate labels like 'help wanted'
|
126
|
+
for label in {
|
127
|
+
"help wanted",
|
128
|
+
"TODO",
|
129
|
+
"research",
|
130
|
+
"non-reproducible",
|
131
|
+
"popular",
|
132
|
+
"invalid",
|
133
|
+
"Stale",
|
134
|
+
"wontfix",
|
135
|
+
"duplicate",
|
136
|
+
}: # normal case
|
137
|
+
available_labels.pop(label, None) # remove as should only be manually added
|
138
|
+
if "bug" in current_labels:
|
139
|
+
available_labels.pop("question", None)
|
140
|
+
elif "question" in current_labels:
|
141
|
+
available_labels.pop("bug", None)
|
142
|
+
|
143
|
+
# Add "Alert" to available labels if not present
|
144
|
+
if "Alert" not in available_labels:
|
145
|
+
available_labels["Alert"] = (
|
146
|
+
"Potential spam, abuse, or illegal activity including advertising, unsolicited promotions, malware, phishing, crypto offers, pirated software or media, free movie downloads, cracks, keygens or any other content that violates terms of service or legal standards."
|
147
|
+
)
|
148
|
+
|
149
|
+
labels = "\n".join(f"- {name}: {description}" for name, description in available_labels.items())
|
150
|
+
|
151
|
+
prompt = f"""Select the top 1-3 most relevant labels for the following GitHub {issue_type}.
|
152
|
+
|
153
|
+
INSTRUCTIONS:
|
154
|
+
1. Review the {issue_type} title and description.
|
155
|
+
2. Consider the available labels and their descriptions.
|
156
|
+
3. Choose 1-3 labels that best match the {issue_type} content.
|
157
|
+
4. Only use the "Alert" label when you have high confidence that this is an inappropriate {issue_type}.
|
158
|
+
5. Respond ONLY with the chosen label names (no descriptions), separated by commas.
|
159
|
+
6. If no labels are relevant, respond with 'None'.
|
160
|
+
|
161
|
+
AVAILABLE LABELS:
|
162
|
+
{labels}
|
163
|
+
|
164
|
+
{issue_type.upper()} TITLE:
|
165
|
+
{title}
|
166
|
+
|
167
|
+
{issue_type.upper()} DESCRIPTION:
|
168
|
+
{body[:16000]}
|
169
|
+
|
170
|
+
YOUR RESPONSE (label names only):
|
171
|
+
"""
|
172
|
+
print(prompt) # for short-term debugging
|
173
|
+
messages = [
|
174
|
+
{
|
175
|
+
"role": "system",
|
176
|
+
"content": "You are an Ultralytics AI assistant that labels GitHub issues, PRs, and discussions.",
|
177
|
+
},
|
178
|
+
{"role": "user", "content": prompt},
|
179
|
+
]
|
180
|
+
suggested_labels = get_completion(messages)
|
181
|
+
if "none" in suggested_labels.lower():
|
182
|
+
return []
|
183
|
+
|
184
|
+
available_labels_lower = {name.lower(): name for name in available_labels}
|
185
|
+
return [
|
186
|
+
available_labels_lower[label.lower().strip()]
|
187
|
+
for label in suggested_labels.split(",")
|
188
|
+
if label.lower().strip() in available_labels_lower
|
189
|
+
]
|
190
|
+
|
191
|
+
|
192
|
+
def get_label_ids(event, labels: List[str]) -> List[str]:
|
193
|
+
"""Retrieves GitHub label IDs for a list of label names using the GraphQL API."""
|
194
|
+
query = """
|
195
|
+
query($owner: String!, $name: String!) {
|
196
|
+
repository(owner: $owner, name: $name) {
|
197
|
+
labels(first: 100, query: "") {
|
198
|
+
nodes {
|
199
|
+
id
|
200
|
+
name
|
201
|
+
}
|
202
|
+
}
|
203
|
+
}
|
204
|
+
}
|
205
|
+
"""
|
206
|
+
owner, repo = event.repository.split("/")
|
207
|
+
result = event.graphql_request(query, variables={"owner": owner, "name": repo})
|
208
|
+
if "data" in result and "repository" in result["data"]:
|
209
|
+
all_labels = result["data"]["repository"]["labels"]["nodes"]
|
210
|
+
label_map = {label["name"].lower(): label["id"] for label in all_labels}
|
211
|
+
return [label_map.get(label.lower()) for label in labels if label.lower() in label_map]
|
212
|
+
else:
|
213
|
+
print(f"Failed to fetch labels: {result.get('errors', 'Unknown error')}")
|
214
|
+
return []
|
215
|
+
|
216
|
+
|
217
|
+
def apply_labels(event, number: int, node_id: str, labels: List[str], issue_type: str):
|
218
|
+
"""Applies specified labels to a GitHub issue, pull request, or discussion using the appropriate API."""
|
219
|
+
if "Alert" in labels:
|
220
|
+
create_alert_label(event)
|
221
|
+
|
222
|
+
if issue_type == "discussion":
|
223
|
+
print(f"Using node_id: {node_id}") # Debug print
|
224
|
+
label_ids = get_label_ids(event, labels)
|
225
|
+
if not label_ids:
|
226
|
+
print("No valid labels to apply.")
|
227
|
+
return
|
228
|
+
|
229
|
+
mutation = """
|
230
|
+
mutation($labelableId: ID!, $labelIds: [ID!]!) {
|
231
|
+
addLabelsToLabelable(input: {labelableId: $labelableId, labelIds: $labelIds}) {
|
232
|
+
labelable {
|
233
|
+
... on Discussion {
|
234
|
+
id
|
235
|
+
}
|
236
|
+
}
|
237
|
+
}
|
238
|
+
}
|
239
|
+
"""
|
240
|
+
event.graphql_request(mutation, {"labelableId": node_id, "labelIds": label_ids})
|
241
|
+
print(f"Successfully applied labels: {', '.join(labels)}")
|
242
|
+
else:
|
243
|
+
url = f"{GITHUB_API_URL}/repos/{event.repository}/issues/{number}/labels"
|
244
|
+
r = requests.post(url, json={"labels": labels}, headers=event.headers)
|
245
|
+
print(f"{'Successful' if r.status_code == 200 else 'Fail'} apply labels {', '.join(labels)}: {r.status_code}")
|
246
|
+
|
247
|
+
|
248
|
+
def create_alert_label(event):
|
249
|
+
"""Creates the 'Alert' label in the repository if it doesn't exist, with a red color and description."""
|
250
|
+
alert_label = {"name": "Alert", "color": "FF0000", "description": "Potential spam, abuse, or off-topic."}
|
251
|
+
requests.post(f"{GITHUB_API_URL}/repos/{event.repository}/labels", json=alert_label, headers=event.headers)
|
252
|
+
|
253
|
+
|
254
|
+
def is_org_member(event, username: str) -> bool:
|
255
|
+
"""Checks if a user is a member of the organization using the GitHub API."""
|
256
|
+
org_name = event.repository.split("/")[0]
|
257
|
+
url = f"{GITHUB_API_URL}/orgs/{org_name}/members/{username}"
|
258
|
+
r = requests.get(url, headers=event.headers)
|
259
|
+
return r.status_code == 204 # 204 means the user is a member
|
260
|
+
|
261
|
+
|
262
|
+
def add_comment(event, number: int, node_id: str, comment: str, issue_type: str):
|
263
|
+
"""Adds a comment to the specified issue, pull request, or discussion using the GitHub API."""
|
264
|
+
if issue_type == "discussion":
|
265
|
+
mutation = """
|
266
|
+
mutation($discussionId: ID!, $body: String!) {
|
267
|
+
addDiscussionComment(input: {discussionId: $discussionId, body: $body}) {
|
268
|
+
comment {
|
269
|
+
id
|
270
|
+
}
|
271
|
+
}
|
272
|
+
}
|
273
|
+
"""
|
274
|
+
event.graphql_request(mutation, variables={"discussionId": node_id, "body": comment})
|
275
|
+
else:
|
276
|
+
url = f"{GITHUB_API_URL}/repos/{event.repository}/issues/{number}/comments"
|
277
|
+
r = requests.post(url, json={"body": comment}, headers=event.headers)
|
278
|
+
print(f"{'Successful' if r.status_code in {200, 201} else 'Fail'} issue/PR #{number} comment: {r.status_code}")
|
279
|
+
|
280
|
+
|
281
|
+
def get_first_interaction_response(event, issue_type: str, title: str, body: str, username: str) -> str:
|
282
|
+
"""Generates a custom LLM response for GitHub issues, PRs, or discussions based on content."""
|
283
|
+
issue_discussion_response = f"""
|
284
|
+
👋 Hello @{username}, thank you for submitting a `{event.repository}` 🚀 {issue_type.capitalize()}. To help us address your concern efficiently, please ensure you've provided the following information:
|
285
|
+
|
286
|
+
1. For bug reports:
|
287
|
+
- A clear and concise description of the bug
|
288
|
+
- A minimum reproducible example (MRE)[https://docs.ultralytics.com/help/minimum_reproducible_example/] that demonstrates the issue
|
289
|
+
- Your environment details (OS, Python version, package versions)
|
290
|
+
- Expected behavior vs. actual behavior
|
291
|
+
- Any error messages or logs related to the issue
|
292
|
+
|
293
|
+
2. For feature requests:
|
294
|
+
- A clear and concise description of the proposed feature
|
295
|
+
- The problem this feature would solve
|
296
|
+
- Any alternative solutions you've considered
|
297
|
+
|
298
|
+
3. For questions:
|
299
|
+
- Provide as much context as possible about your question
|
300
|
+
- Include any research you've already done on the topic
|
301
|
+
- Specify which parts of the [documentation](https://docs.ultralytics.com), if any, you've already consulted
|
302
|
+
|
303
|
+
Please make sure you've searched existing {issue_type}s to avoid duplicates. If you need to add any additional information, please comment on this {issue_type}.
|
304
|
+
|
305
|
+
Thank you for your contribution to improving our project!
|
306
|
+
"""
|
307
|
+
|
308
|
+
pr_response = f"""
|
309
|
+
👋 Hello @{username}, thank you for submitting an `{event.repository}` 🚀 PR! To ensure a seamless integration of your work, please review the following checklist:
|
310
|
+
|
311
|
+
- ✅ **Define a Purpose**: Clearly explain the purpose of your fix or feature in your PR description, and link to any [relevant issues](https://github.com/{event.repository}/issues). Ensure your commit messages are clear, concise, and adhere to the project's conventions.
|
312
|
+
- ✅ **Synchronize with Source**: Confirm your PR is synchronized with the `{event.repository}` `main` branch. If it's behind, update it by clicking the 'Update branch' button or by running `git pull` and `git merge main` locally.
|
313
|
+
- ✅ **Ensure CI Checks Pass**: Verify all Ultralytics [Continuous Integration (CI)](https://docs.ultralytics.com/help/CI/) checks are passing. If any checks fail, please address the issues.
|
314
|
+
- ✅ **Update Documentation**: Update the relevant [documentation](https://docs.ultralytics.com) for any new or modified features.
|
315
|
+
- ✅ **Add Tests**: If applicable, include or update tests to cover your changes, and confirm that all tests are passing.
|
316
|
+
- ✅ **Sign the CLA**: Please ensure you have signed our [Contributor License Agreement](https://docs.ultralytics.com/help/CLA/) if this is your first Ultralytics PR by writing "I have read the CLA Document and I sign the CLA" in a new message.
|
317
|
+
- ✅ **Minimize Changes**: Limit your changes to the **minimum** necessary for your bug fix or feature addition. _"It is not daily increase but daily decrease, hack away the unessential. The closer to the source, the less wastage there is."_ — Bruce Lee
|
318
|
+
|
319
|
+
For more guidance, please refer to our [Contributing Guide](https://docs.ultralytics.com/help/contributing). Don’t hesitate to leave a comment if you have any questions. Thank you for contributing to Ultralytics! 🚀
|
320
|
+
"""
|
321
|
+
|
322
|
+
if issue_type == "pull request":
|
323
|
+
example = os.getenv("FIRST_PR_RESPONSE") or pr_response
|
324
|
+
else:
|
325
|
+
example = os.getenv("FIRST_ISSUE_RESPONSE") or issue_discussion_response
|
326
|
+
|
327
|
+
org_name, repo_name = event.repository.split("/")
|
328
|
+
repo_url = f"https://github.com/{event.repository}"
|
329
|
+
diff = event.get_pr_diff()[:32000] if issue_type == "pull request" else ""
|
330
|
+
|
331
|
+
prompt = f"""Generate a customized response to the new GitHub {issue_type} below:
|
332
|
+
|
333
|
+
CONTEXT:
|
334
|
+
- Repository: {repo_name}
|
335
|
+
- Organization: {org_name}
|
336
|
+
- Repository URL: {repo_url}
|
337
|
+
- User: {username}
|
338
|
+
|
339
|
+
INSTRUCTIONS:
|
340
|
+
- Do not answer the question or resolve the issue directly
|
341
|
+
- Adapt the example {issue_type} response below as appropriate, keeping all badges, links and references provided
|
342
|
+
- For bug reports, specifically request a minimum reproducible example (MRE) if not provided
|
343
|
+
- INCLUDE ALL LINKS AND INSTRUCTIONS IN THE EXAMPLE BELOW, customized as appropriate
|
344
|
+
- Mention to the user that this is an automated response and that an Ultralytics engineer will also assist soon
|
345
|
+
- Do not add a sign-off or valediction like "best regards" at the end of your response
|
346
|
+
- Do not add spaces between bullet points or numbered lists
|
347
|
+
- Only link to files or URLs in the example below, do not add external links
|
348
|
+
- Use a few emojis to enliven your response
|
349
|
+
|
350
|
+
EXAMPLE {issue_type.upper()} RESPONSE:
|
351
|
+
{example}
|
352
|
+
|
353
|
+
{issue_type.upper()} TITLE:
|
354
|
+
{title}
|
355
|
+
|
356
|
+
{issue_type.upper()} DESCRIPTION:
|
357
|
+
{body[:16000]}
|
358
|
+
|
359
|
+
{"PULL REQUEST DIFF:" if issue_type == "pull request" else ""}
|
360
|
+
{diff if issue_type == "pull request" else ""}
|
361
|
+
|
362
|
+
YOUR {issue_type.upper()} RESPONSE:
|
363
|
+
"""
|
364
|
+
print(f"\n\n{prompt}\n\n") # for debug
|
365
|
+
messages = [
|
366
|
+
{
|
367
|
+
"role": "system",
|
368
|
+
"content": f"You are an Ultralytics AI assistant responding to GitHub {issue_type}s for {org_name}.",
|
369
|
+
},
|
370
|
+
{"role": "user", "content": prompt},
|
371
|
+
]
|
372
|
+
return get_completion(messages)
|
373
|
+
|
374
|
+
|
375
|
+
def main(*args, **kwargs):
|
376
|
+
"""Executes auto-labeling and custom response generation for new GitHub issues, PRs, and discussions."""
|
377
|
+
event = Action(*args, **kwargs)
|
378
|
+
number, node_id, title, body, username, issue_type, action = get_event_content(event)
|
379
|
+
available_labels = event.get_repo_data("labels")
|
380
|
+
label_descriptions = {label["name"]: label.get("description", "") for label in available_labels}
|
381
|
+
if issue_type == "discussion":
|
382
|
+
current_labels = [] # For discussions, labels may need to be fetched differently or adjusted
|
383
|
+
else:
|
384
|
+
current_labels = [label["name"].lower() for label in event.get_repo_data(f"issues/{number}/labels")]
|
385
|
+
if relevant_labels := get_relevant_labels(issue_type, title, body, label_descriptions, current_labels):
|
386
|
+
apply_labels(event, number, node_id, relevant_labels, issue_type)
|
387
|
+
if "Alert" in relevant_labels and not is_org_member(event, username):
|
388
|
+
update_issue_pr_content(event, number, node_id, issue_type)
|
389
|
+
if issue_type != "pull request":
|
390
|
+
close_issue_pr(event, number, node_id, issue_type)
|
391
|
+
lock_issue_pr(event, number, node_id, issue_type)
|
392
|
+
if BLOCK_USER:
|
393
|
+
block_user(event, username=username)
|
394
|
+
else:
|
395
|
+
print("No relevant labels found or applied.")
|
396
|
+
|
397
|
+
if action in {"opened", "created"}:
|
398
|
+
custom_response = get_first_interaction_response(event, issue_type, title, body, username)
|
399
|
+
add_comment(event, number, node_id, custom_response, issue_type)
|
400
|
+
|
401
|
+
|
402
|
+
if __name__ == "__main__":
|
403
|
+
main()
|
actions/summarize_pr.py
ADDED
@@ -0,0 +1,244 @@
|
|
1
|
+
# Ultralytics Actions 🚀, AGPL-3.0 license https://ultralytics.com/license
|
2
|
+
|
3
|
+
import time
|
4
|
+
|
5
|
+
import requests
|
6
|
+
|
7
|
+
from .utils import (
|
8
|
+
GITHUB_API_URL,
|
9
|
+
Action,
|
10
|
+
get_completion,
|
11
|
+
)
|
12
|
+
|
13
|
+
# Constants
|
14
|
+
SUMMARY_START = (
|
15
|
+
"## 🛠️ PR Summary\n\n<sub>Made with ❤️ by [Ultralytics Actions](https://github.com/ultralytics/actions)<sub>\n\n"
|
16
|
+
)
|
17
|
+
|
18
|
+
|
19
|
+
def generate_merge_message(pr_summary=None, pr_credit=None):
|
20
|
+
"""Generates a thank-you message for merged PR contributors."""
|
21
|
+
messages = [
|
22
|
+
{
|
23
|
+
"role": "system",
|
24
|
+
"content": "You are an Ultralytics AI assistant. Generate meaningful, inspiring messages to GitHub users.",
|
25
|
+
},
|
26
|
+
{
|
27
|
+
"role": "user",
|
28
|
+
"content": f"Write a friendly thank you for a merged GitHub PR by {pr_credit}. "
|
29
|
+
f"Context from PR:\n{pr_summary}\n\n"
|
30
|
+
f"Start with the exciting message that this PR is now merged, and weave in an inspiring but obscure quote "
|
31
|
+
f"from a historical figure in science, art, stoicism and philosophy. "
|
32
|
+
f"Keep the message concise yet relevant to the specific contributions in this PR. "
|
33
|
+
f"We want the contributors to feel their effort is appreciated and will make a difference in the world.",
|
34
|
+
},
|
35
|
+
]
|
36
|
+
return get_completion(messages)
|
37
|
+
|
38
|
+
|
39
|
+
def post_merge_message(pr_number, repository, summary, pr_credit, headers):
|
40
|
+
"""Posts thank you message on PR after merge."""
|
41
|
+
message = generate_merge_message(summary, pr_credit)
|
42
|
+
comment_url = f"{GITHUB_API_URL}/repos/{repository}/issues/{pr_number}/comments"
|
43
|
+
response = requests.post(comment_url, json={"body": message}, headers=headers)
|
44
|
+
return response.status_code == 201
|
45
|
+
|
46
|
+
|
47
|
+
def generate_issue_comment(pr_url, pr_summary, pr_credit):
|
48
|
+
"""Generates a personalized issue comment using based on the PR context."""
|
49
|
+
messages = [
|
50
|
+
{
|
51
|
+
"role": "system",
|
52
|
+
"content": "You are an Ultralytics AI assistant. Generate friendly GitHub issue comments. No @ mentions or direct addressing.",
|
53
|
+
},
|
54
|
+
{
|
55
|
+
"role": "user",
|
56
|
+
"content": f"Write a GitHub issue comment announcing a potential fix for this issue is now merged in linked PR {pr_url} by {pr_credit}\n\n"
|
57
|
+
f"Context from PR:\n{pr_summary}\n\n"
|
58
|
+
f"Include:\n"
|
59
|
+
f"1. An explanation of key changes from the PR that may resolve this issue\n"
|
60
|
+
f"2. Credit to the PR author and contributors\n"
|
61
|
+
f"3. Options for testing if PR changes have resolved this issue:\n"
|
62
|
+
f" - pip install git+https://github.com/ultralytics/ultralytics.git@main # test latest changes\n"
|
63
|
+
f" - or await next official PyPI release\n"
|
64
|
+
f"4. Request feedback on whether the PR changes resolve the issue\n"
|
65
|
+
f"5. Thank 🙏 for reporting the issue and welcome any further feedback if the issue persists\n\n",
|
66
|
+
},
|
67
|
+
]
|
68
|
+
return get_completion(messages)
|
69
|
+
|
70
|
+
|
71
|
+
def generate_pr_summary(repository, diff_text):
|
72
|
+
"""Generates a concise, professional summary of a PR using OpenAI's API for Ultralytics repositories."""
|
73
|
+
if not diff_text:
|
74
|
+
diff_text = "**ERROR: DIFF IS EMPTY, THERE ARE ZERO CODE CHANGES IN THIS PR."
|
75
|
+
ratio = 3.3 # about 3.3 characters per token
|
76
|
+
limit = round(128000 * ratio * 0.5) # use up to 50% of the 128k context window for prompt
|
77
|
+
messages = [
|
78
|
+
{
|
79
|
+
"role": "system",
|
80
|
+
"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.",
|
81
|
+
},
|
82
|
+
{
|
83
|
+
"role": "user",
|
84
|
+
"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"
|
85
|
+
f"### 🌟 Summary (single-line synopsis)\n"
|
86
|
+
f"### 📊 Key Changes (bullet points highlighting any major changes)\n"
|
87
|
+
f"### 🎯 Purpose & Impact (bullet points explaining any benefits and potential impact to users)\n"
|
88
|
+
f"\n\nHere's the PR diff:\n\n{diff_text[:limit]}",
|
89
|
+
},
|
90
|
+
]
|
91
|
+
reply = get_completion(messages)
|
92
|
+
if len(diff_text) > limit:
|
93
|
+
reply = "**WARNING ⚠️** this PR is very large, summary may not cover all changes.\n\n" + reply
|
94
|
+
return SUMMARY_START + reply
|
95
|
+
|
96
|
+
|
97
|
+
def update_pr_description(repository, pr_number, new_summary, headers, max_retries=2):
|
98
|
+
"""Updates PR description with new summary, retrying if description is None."""
|
99
|
+
pr_url = f"{GITHUB_API_URL}/repos/{repository}/pulls/{pr_number}"
|
100
|
+
description = ""
|
101
|
+
for i in range(max_retries + 1):
|
102
|
+
description = requests.get(pr_url, headers=headers).json().get("body") or ""
|
103
|
+
if description:
|
104
|
+
break
|
105
|
+
if i < max_retries:
|
106
|
+
print("No current PR description found, retrying...")
|
107
|
+
time.sleep(1)
|
108
|
+
|
109
|
+
# Check if existing summary is present and update accordingly
|
110
|
+
start = "## 🛠️ PR Summary"
|
111
|
+
if start in description:
|
112
|
+
print("Existing PR Summary found, replacing.")
|
113
|
+
updated_description = description.split(start)[0] + new_summary
|
114
|
+
else:
|
115
|
+
print("PR Summary not found, appending.")
|
116
|
+
updated_description = description + "\n\n" + new_summary
|
117
|
+
|
118
|
+
# Update the PR description
|
119
|
+
update_response = requests.patch(pr_url, json={"body": updated_description}, headers=headers)
|
120
|
+
return update_response.status_code
|
121
|
+
|
122
|
+
|
123
|
+
def label_fixed_issues(repository, pr_number, pr_summary, headers, action):
|
124
|
+
"""Labels issues closed by PR when merged, notifies users, returns PR contributors."""
|
125
|
+
query = """
|
126
|
+
query($owner: String!, $repo: String!, $pr_number: Int!) {
|
127
|
+
repository(owner: $owner, name: $repo) {
|
128
|
+
pullRequest(number: $pr_number) {
|
129
|
+
closingIssuesReferences(first: 50) { nodes { number } }
|
130
|
+
url
|
131
|
+
body
|
132
|
+
author { login, __typename }
|
133
|
+
reviews(first: 50) { nodes { author { login, __typename } } }
|
134
|
+
comments(first: 50) { nodes { author { login, __typename } } }
|
135
|
+
commits(first: 100) { nodes { commit { author { user { login } }, committer { user { login } } } } }
|
136
|
+
}
|
137
|
+
}
|
138
|
+
}
|
139
|
+
"""
|
140
|
+
owner, repo = repository.split("/")
|
141
|
+
variables = {"owner": owner, "repo": repo, "pr_number": pr_number}
|
142
|
+
graphql_url = "https://api.github.com/graphql"
|
143
|
+
response = requests.post(graphql_url, json={"query": query, "variables": variables}, headers=headers)
|
144
|
+
|
145
|
+
if response.status_code != 200:
|
146
|
+
print(f"Failed to fetch linked issues. Status code: {response.status_code}")
|
147
|
+
return [], None
|
148
|
+
|
149
|
+
try:
|
150
|
+
data = response.json()["data"]["repository"]["pullRequest"]
|
151
|
+
comments = data["reviews"]["nodes"] + data["comments"]["nodes"]
|
152
|
+
token_username = action.get_username() # get GITHUB_TOKEN username
|
153
|
+
author = data["author"]["login"] if data["author"]["__typename"] != "Bot" else None
|
154
|
+
|
155
|
+
# Get unique contributors from reviews and comments
|
156
|
+
contributors = {x["author"]["login"] for x in comments if x["author"]["__typename"] != "Bot"}
|
157
|
+
|
158
|
+
# Add commit authors and committers that have GitHub accounts linked
|
159
|
+
for commit in data["commits"]["nodes"]:
|
160
|
+
commit_data = commit["commit"]
|
161
|
+
for user_type in ["author", "committer"]:
|
162
|
+
if user := commit_data[user_type].get("user"):
|
163
|
+
if login := user.get("login"):
|
164
|
+
contributors.add(login)
|
165
|
+
|
166
|
+
contributors.discard(author)
|
167
|
+
contributors.discard(token_username)
|
168
|
+
|
169
|
+
# Write credit string
|
170
|
+
pr_credit = "" # i.e. "@user1 with contributions from @user2, @user3"
|
171
|
+
if author and author != token_username:
|
172
|
+
pr_credit += f"@{author}"
|
173
|
+
if contributors:
|
174
|
+
pr_credit += (" with contributions from " if pr_credit else "") + ", ".join(f"@{c}" for c in contributors)
|
175
|
+
|
176
|
+
# Generate personalized comment
|
177
|
+
comment = generate_issue_comment(pr_url=data["url"], pr_summary=pr_summary, pr_credit=pr_credit)
|
178
|
+
|
179
|
+
# Update linked issues
|
180
|
+
for issue in data["closingIssuesReferences"]["nodes"]:
|
181
|
+
issue_number = issue["number"]
|
182
|
+
# Add fixed label
|
183
|
+
label_url = f"{GITHUB_API_URL}/repos/{repository}/issues/{issue_number}/labels"
|
184
|
+
label_response = requests.post(label_url, json={"labels": ["fixed"]}, headers=headers)
|
185
|
+
|
186
|
+
# Add comment
|
187
|
+
comment_url = f"{GITHUB_API_URL}/repos/{repository}/issues/{issue_number}/comments"
|
188
|
+
comment_response = requests.post(comment_url, json={"body": comment}, headers=headers)
|
189
|
+
|
190
|
+
if label_response.status_code == 200 and comment_response.status_code == 201:
|
191
|
+
print(f"Added 'fixed' label and comment to issue #{issue_number}")
|
192
|
+
else:
|
193
|
+
print(
|
194
|
+
f"Failed to update issue #{issue_number}. Label status: {label_response.status_code}, "
|
195
|
+
f"Comment status: {comment_response.status_code}"
|
196
|
+
)
|
197
|
+
|
198
|
+
return pr_credit
|
199
|
+
except KeyError as e:
|
200
|
+
print(f"Error parsing GraphQL response: {e}")
|
201
|
+
return [], None
|
202
|
+
|
203
|
+
|
204
|
+
def remove_todos_on_merge(pr_number, repository, headers):
|
205
|
+
"""Removes specified labels from PR."""
|
206
|
+
for label in ["TODO"]: # Can be extended with more labels in the future
|
207
|
+
requests.delete(f"{GITHUB_API_URL}/repos/{repository}/issues/{pr_number}/labels/{label}", headers=headers)
|
208
|
+
|
209
|
+
|
210
|
+
def main(*args, **kwargs):
|
211
|
+
"""Summarize a pull request and update its description with a summary."""
|
212
|
+
action = Action(*args, **kwargs)
|
213
|
+
pr_number = action.pr["number"]
|
214
|
+
headers = action.headers
|
215
|
+
repository = action.repository
|
216
|
+
|
217
|
+
print(f"Retrieving diff for PR {pr_number}")
|
218
|
+
diff = action.get_pr_diff()
|
219
|
+
|
220
|
+
# Generate PR summary
|
221
|
+
print("Generating PR summary...")
|
222
|
+
summary = generate_pr_summary(repository, diff)
|
223
|
+
|
224
|
+
# Update PR description
|
225
|
+
print("Updating PR description...")
|
226
|
+
status_code = update_pr_description(repository, pr_number, summary, headers)
|
227
|
+
if status_code == 200:
|
228
|
+
print("PR description updated successfully.")
|
229
|
+
else:
|
230
|
+
print(f"Failed to update PR description. Status code: {status_code}")
|
231
|
+
|
232
|
+
# Update linked issues and post thank you message if merged
|
233
|
+
if action.pr.get("merged"):
|
234
|
+
print("PR is merged, labeling fixed issues...")
|
235
|
+
pr_credit = label_fixed_issues(repository, pr_number, summary, headers, action)
|
236
|
+
print("Removing TODO label from PR...")
|
237
|
+
remove_todos_on_merge(pr_number, repository, headers)
|
238
|
+
if pr_credit:
|
239
|
+
print("Posting PR author thank you message...")
|
240
|
+
post_merge_message(pr_number, repository, summary, pr_credit, headers)
|
241
|
+
|
242
|
+
|
243
|
+
if __name__ == "__main__":
|
244
|
+
main()
|