ultralytics-actions 0.0.100__tar.gz → 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of ultralytics-actions might be problematic. Click here for more details.
- {ultralytics_actions-0.0.100/ultralytics_actions.egg-info → ultralytics_actions-0.1.1}/PKG-INFO +5 -2
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/README.md +4 -1
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/actions/__init__.py +2 -1
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/actions/dispatch_actions.py +3 -3
- ultralytics_actions-0.1.1/actions/first_interaction.py +218 -0
- ultralytics_actions-0.1.1/actions/review_pr.py +323 -0
- ultralytics_actions-0.1.1/actions/summarize_pr.py +128 -0
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/actions/summarize_release.py +13 -2
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/actions/update_markdown_code_blocks.py +3 -0
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/actions/utils/__init__.py +11 -1
- ultralytics_actions-0.1.1/actions/utils/github_utils.py +430 -0
- ultralytics_actions-0.1.1/actions/utils/openai_utils.py +222 -0
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/pyproject.toml +1 -0
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/tests/test_first_interaction.py +1 -19
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/tests/test_summarize_pr.py +2 -30
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1/ultralytics_actions.egg-info}/PKG-INFO +5 -2
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/ultralytics_actions.egg-info/SOURCES.txt +1 -0
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/ultralytics_actions.egg-info/entry_points.txt +1 -0
- ultralytics_actions-0.0.100/actions/first_interaction.py +0 -379
- ultralytics_actions-0.0.100/actions/summarize_pr.py +0 -230
- ultralytics_actions-0.0.100/actions/utils/github_utils.py +0 -194
- ultralytics_actions-0.0.100/actions/utils/openai_utils.py +0 -94
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/LICENSE +0 -0
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/actions/update_file_headers.py +0 -0
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/actions/utils/common_utils.py +0 -0
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/actions/utils/version_utils.py +0 -0
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/setup.cfg +0 -0
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/tests/test_cli_commands.py +0 -0
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/tests/test_common_utils.py +0 -0
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/tests/test_dispatch_actions.py +0 -0
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/tests/test_file_headers.py +0 -0
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/tests/test_github_utils.py +0 -0
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/tests/test_init.py +0 -0
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/tests/test_openai_utils.py +0 -0
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/tests/test_summarize_release.py +0 -0
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/tests/test_update_markdown_codeblocks.py +0 -0
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/tests/test_urls.py +0 -0
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/ultralytics_actions.egg-info/dependency_links.txt +0 -0
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/ultralytics_actions.egg-info/requires.txt +0 -0
- {ultralytics_actions-0.0.100 → ultralytics_actions-0.1.1}/ultralytics_actions.egg-info/top_level.txt +0 -0
{ultralytics_actions-0.0.100/ultralytics_actions.egg-info → ultralytics_actions-0.1.1}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ultralytics-actions
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Ultralytics Actions for GitHub automation and PR management.
|
|
5
5
|
Author-email: Glenn Jocher <glenn.jocher@ultralytics.com>
|
|
6
6
|
Maintainer-email: Ultralytics <hello@ultralytics.com>
|
|
@@ -38,7 +38,7 @@ Dynamic: license-file
|
|
|
38
38
|
|
|
39
39
|
<a href="https://www.ultralytics.com/"><img src="https://raw.githubusercontent.com/ultralytics/assets/main/logo/Ultralytics_Logotype_Original.svg" width="320" alt="Ultralytics logo"></a>
|
|
40
40
|
|
|
41
|
-
# 🚀 Ultralytics Actions:
|
|
41
|
+
# 🚀 Ultralytics Actions: AI-powered formatting, labeling & PR summaries for Python and Markdown
|
|
42
42
|
|
|
43
43
|
Welcome to the [Ultralytics Actions](https://github.com/ultralytics/actions) repository, your go-to solution for maintaining consistent code quality across Ultralytics Python and Swift projects. This GitHub Action is designed to automate the formatting of Python, Markdown, and Swift files, ensuring adherence to our coding standards and enhancing project maintainability.
|
|
44
44
|
|
|
@@ -46,6 +46,7 @@ Welcome to the [Ultralytics Actions](https://github.com/ultralytics/actions) rep
|
|
|
46
46
|
|
|
47
47
|
[](https://github.com/ultralytics/actions/actions/workflows/ci.yml)
|
|
48
48
|
[](https://github.com/ultralytics/actions/actions/workflows/format.yml)
|
|
49
|
+
[](https://github.com/ultralytics/actions/actions/workflows/open-prs.yml)
|
|
49
50
|
[](https://codecov.io/github/ultralytics/actions)
|
|
50
51
|
|
|
51
52
|
[](https://discord.com/invite/ultralytics)
|
|
@@ -63,6 +64,7 @@ Ultralytics Actions automatically applies formats, updates, and enhancements usi
|
|
|
63
64
|
- **Spell Check:** Common misspellings are caught using [codespell](https://github.com/codespell-project/codespell).
|
|
64
65
|
- **Broken Links Check:** Broken links in documentation and Markdown files are identified using [Lychee](https://github.com/lycheeverse/lychee).
|
|
65
66
|
- **PR Summary:** Concise Pull Request summaries are generated using [OpenAI](https://openai.com/) GPT-5, improving clarity and review efficiency.
|
|
67
|
+
- **PR Review:** AI-powered inline code reviews identify critical bugs, security issues, and code quality concerns with suggested fixes.
|
|
66
68
|
- **Auto-labeling:** Relevant labels are applied to issues and pull requests via [OpenAI](https://openai.com/) GPT-5 for intelligent categorization.
|
|
67
69
|
|
|
68
70
|
## 🛠️ How It Works
|
|
@@ -73,6 +75,7 @@ Ultralytics Actions triggers on various GitHub events to streamline workflows:
|
|
|
73
75
|
- **Pull Requests:**
|
|
74
76
|
- Ensures contributions meet formatting standards before merging.
|
|
75
77
|
- Generates a concise summary of changes using GPT-5.
|
|
78
|
+
- Provides AI-powered inline code reviews with suggested fixes for critical issues.
|
|
76
79
|
- Applies relevant labels using GPT-5 for intelligent categorization.
|
|
77
80
|
- **Issues:** Automatically applies relevant labels using GPT-5 when new issues are created.
|
|
78
81
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<a href="https://www.ultralytics.com/"><img src="https://raw.githubusercontent.com/ultralytics/assets/main/logo/Ultralytics_Logotype_Original.svg" width="320" alt="Ultralytics logo"></a>
|
|
2
2
|
|
|
3
|
-
# 🚀 Ultralytics Actions:
|
|
3
|
+
# 🚀 Ultralytics Actions: AI-powered formatting, labeling & PR summaries for Python and Markdown
|
|
4
4
|
|
|
5
5
|
Welcome to the [Ultralytics Actions](https://github.com/ultralytics/actions) repository, your go-to solution for maintaining consistent code quality across Ultralytics Python and Swift projects. This GitHub Action is designed to automate the formatting of Python, Markdown, and Swift files, ensuring adherence to our coding standards and enhancing project maintainability.
|
|
6
6
|
|
|
@@ -8,6 +8,7 @@ Welcome to the [Ultralytics Actions](https://github.com/ultralytics/actions) rep
|
|
|
8
8
|
|
|
9
9
|
[](https://github.com/ultralytics/actions/actions/workflows/ci.yml)
|
|
10
10
|
[](https://github.com/ultralytics/actions/actions/workflows/format.yml)
|
|
11
|
+
[](https://github.com/ultralytics/actions/actions/workflows/open-prs.yml)
|
|
11
12
|
[](https://codecov.io/github/ultralytics/actions)
|
|
12
13
|
|
|
13
14
|
[](https://discord.com/invite/ultralytics)
|
|
@@ -25,6 +26,7 @@ Ultralytics Actions automatically applies formats, updates, and enhancements usi
|
|
|
25
26
|
- **Spell Check:** Common misspellings are caught using [codespell](https://github.com/codespell-project/codespell).
|
|
26
27
|
- **Broken Links Check:** Broken links in documentation and Markdown files are identified using [Lychee](https://github.com/lycheeverse/lychee).
|
|
27
28
|
- **PR Summary:** Concise Pull Request summaries are generated using [OpenAI](https://openai.com/) GPT-5, improving clarity and review efficiency.
|
|
29
|
+
- **PR Review:** AI-powered inline code reviews identify critical bugs, security issues, and code quality concerns with suggested fixes.
|
|
28
30
|
- **Auto-labeling:** Relevant labels are applied to issues and pull requests via [OpenAI](https://openai.com/) GPT-5 for intelligent categorization.
|
|
29
31
|
|
|
30
32
|
## 🛠️ How It Works
|
|
@@ -35,6 +37,7 @@ Ultralytics Actions triggers on various GitHub events to streamline workflows:
|
|
|
35
37
|
- **Pull Requests:**
|
|
36
38
|
- Ensures contributions meet formatting standards before merging.
|
|
37
39
|
- Generates a concise summary of changes using GPT-5.
|
|
40
|
+
- Provides AI-powered inline code reviews with suggested fixes for critical issues.
|
|
38
41
|
- Applies relevant labels using GPT-5 for intelligent categorization.
|
|
39
42
|
- **Issues:** Automatically applies relevant labels using GPT-5 when new issues are created.
|
|
40
43
|
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
# │ │ ├── openai_utils.py
|
|
14
14
|
# │ │ └── common_utils.py
|
|
15
15
|
# │ ├── first_interaction.py
|
|
16
|
+
# │ ├── review_pr.py
|
|
16
17
|
# │ ├── summarize_pr.py
|
|
17
18
|
# │ ├── summarize_release.py
|
|
18
19
|
# │ └── update_markdown_code_blocks.py
|
|
@@ -22,4 +23,4 @@
|
|
|
22
23
|
# ├── test_summarize_pr.py
|
|
23
24
|
# └── ...
|
|
24
25
|
|
|
25
|
-
__version__ = "0.
|
|
26
|
+
__version__ = "0.1.1"
|
|
@@ -44,7 +44,7 @@ def trigger_and_get_workflow_info(event, branch: str) -> list[dict]:
|
|
|
44
44
|
run_number = None
|
|
45
45
|
|
|
46
46
|
runs_response = event.get(
|
|
47
|
-
f"{GITHUB_API_URL}/repos/{repo}/actions/workflows/{file}/runs?branch={branch}&event=workflow_dispatch&per_page=1"
|
|
47
|
+
f"{GITHUB_API_URL}/repos/{repo}/actions/workflows/{file}/runs?branch={branch}&event=workflow_dispatch&per_page=1"
|
|
48
48
|
)
|
|
49
49
|
|
|
50
50
|
if runs_response.status_code == 200:
|
|
@@ -57,10 +57,10 @@ def trigger_and_get_workflow_info(event, branch: str) -> list[dict]:
|
|
|
57
57
|
return results
|
|
58
58
|
|
|
59
59
|
|
|
60
|
-
def update_comment(event, comment_body: str, triggered_actions: list[dict], branch: str)
|
|
60
|
+
def update_comment(event, comment_body: str, triggered_actions: list[dict], branch: str):
|
|
61
61
|
"""Updates the comment with workflow information."""
|
|
62
62
|
if not triggered_actions:
|
|
63
|
-
return
|
|
63
|
+
return
|
|
64
64
|
|
|
65
65
|
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
66
66
|
summary = (
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
from .utils import Action, filter_labels, get_completion, get_pr_open_response, remove_html_comments
|
|
9
|
+
|
|
10
|
+
SUMMARY_START = (
|
|
11
|
+
"## 🛠️ PR Summary\n\n<sub>Made with ❤️ by [Ultralytics Actions](https://github.com/ultralytics/actions)<sub>\n\n"
|
|
12
|
+
)
|
|
13
|
+
BLOCK_USER = os.getenv("BLOCK_USER", "false").lower() == "true"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def apply_and_check_labels(event, number, node_id, issue_type, username, labels, label_descriptions):
|
|
17
|
+
"""Normalizes, applies labels, and handles Alert label if present."""
|
|
18
|
+
if not labels:
|
|
19
|
+
print("No relevant labels found or applied.")
|
|
20
|
+
return
|
|
21
|
+
|
|
22
|
+
available = {k.lower(): k for k in label_descriptions}
|
|
23
|
+
normalized = [available.get(label.lower(), label) for label in labels if label.lower() in available]
|
|
24
|
+
|
|
25
|
+
if normalized:
|
|
26
|
+
print(f"Applying labels: {normalized}")
|
|
27
|
+
event.apply_labels(number, node_id, normalized, issue_type)
|
|
28
|
+
if "Alert" in normalized and not event.is_org_member(username):
|
|
29
|
+
event.handle_alert(number, node_id, issue_type, username, block=BLOCK_USER)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_event_content(event) -> tuple[int, str, str, str, str, str, str]:
|
|
33
|
+
"""Extracts key information from GitHub event data for issues, pull requests, or discussions."""
|
|
34
|
+
data = event.event_data
|
|
35
|
+
name = event.event_name
|
|
36
|
+
action = data["action"]
|
|
37
|
+
if name == "issues":
|
|
38
|
+
item = data["issue"]
|
|
39
|
+
issue_type = "issue"
|
|
40
|
+
elif name in ["pull_request", "pull_request_target"]:
|
|
41
|
+
pr_number = data["pull_request"]["number"]
|
|
42
|
+
item = event.get_repo_data(f"pulls/{pr_number}")
|
|
43
|
+
issue_type = "pull request"
|
|
44
|
+
elif name == "discussion":
|
|
45
|
+
item = data["discussion"]
|
|
46
|
+
issue_type = "discussion"
|
|
47
|
+
else:
|
|
48
|
+
raise ValueError(f"Unsupported event type: {name}")
|
|
49
|
+
|
|
50
|
+
number = item["number"]
|
|
51
|
+
node_id = item.get("node_id") or item.get("id")
|
|
52
|
+
title = item["title"]
|
|
53
|
+
body = remove_html_comments(item.get("body", ""))
|
|
54
|
+
username = item["user"]["login"]
|
|
55
|
+
return number, node_id, title, body, username, issue_type, action
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def get_relevant_labels(
|
|
59
|
+
issue_type: str, title: str, body: str, available_labels: dict, current_labels: list
|
|
60
|
+
) -> list[str]:
|
|
61
|
+
"""Determines relevant labels for GitHub issues/discussions using OpenAI."""
|
|
62
|
+
filtered_labels = filter_labels(available_labels, current_labels, is_pr=(issue_type == "pull request"))
|
|
63
|
+
labels_str = "\n".join(f"- {name}: {description}" for name, description in filtered_labels.items())
|
|
64
|
+
|
|
65
|
+
prompt = f"""Select the top 1-3 most relevant labels for the following GitHub {issue_type}.
|
|
66
|
+
|
|
67
|
+
INSTRUCTIONS:
|
|
68
|
+
1. Review the {issue_type} title and description.
|
|
69
|
+
2. Consider the available labels and their descriptions.
|
|
70
|
+
3. Choose 1-3 labels that best match the {issue_type} content.
|
|
71
|
+
4. Only use the "Alert" label when you have high confidence that this is an inappropriate {issue_type}.
|
|
72
|
+
5. Respond ONLY with the chosen label names (no descriptions), separated by commas.
|
|
73
|
+
6. If no labels are relevant, respond with 'None'.
|
|
74
|
+
{'7. Only use the "bug" label if the user provides a clear description of the bug, their environment with relevant package versions and a minimum reproducible example.' if issue_type == "issue" else ""}
|
|
75
|
+
|
|
76
|
+
AVAILABLE LABELS:
|
|
77
|
+
{labels_str}
|
|
78
|
+
|
|
79
|
+
{issue_type.upper()} TITLE:
|
|
80
|
+
{title}
|
|
81
|
+
|
|
82
|
+
{issue_type.upper()} DESCRIPTION:
|
|
83
|
+
{body[:16000]}
|
|
84
|
+
|
|
85
|
+
YOUR RESPONSE (label names only):
|
|
86
|
+
"""
|
|
87
|
+
messages = [
|
|
88
|
+
{
|
|
89
|
+
"role": "system",
|
|
90
|
+
"content": "You are an Ultralytics AI assistant that labels GitHub issues, PRs, and discussions.",
|
|
91
|
+
},
|
|
92
|
+
{"role": "user", "content": prompt},
|
|
93
|
+
]
|
|
94
|
+
suggested_labels = get_completion(messages, temperature=1.0)
|
|
95
|
+
if "none" in suggested_labels.lower():
|
|
96
|
+
return []
|
|
97
|
+
|
|
98
|
+
available_labels_lower = {name.lower(): name for name in filtered_labels}
|
|
99
|
+
return [
|
|
100
|
+
available_labels_lower[label.lower().strip()]
|
|
101
|
+
for label in suggested_labels.split(",")
|
|
102
|
+
if label.lower().strip() in available_labels_lower
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def get_first_interaction_response(event, issue_type: str, title: str, body: str, username: str) -> str:
|
|
107
|
+
"""Generates a custom LLM response for GitHub issues or discussions (NOT PRs - PRs use unified call)."""
|
|
108
|
+
issue_discussion_response = f"""
|
|
109
|
+
👋 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:
|
|
110
|
+
|
|
111
|
+
1. For bug reports:
|
|
112
|
+
- A clear and concise description of the bug
|
|
113
|
+
- A minimum reproducible example [MRE](https://docs.ultralytics.com/help/minimum-reproducible-example/) that demonstrates the issue
|
|
114
|
+
- Your environment details (OS, Python version, package versions)
|
|
115
|
+
- Expected behavior vs. actual behavior
|
|
116
|
+
- Any error messages or logs related to the issue
|
|
117
|
+
|
|
118
|
+
2. For feature requests:
|
|
119
|
+
- A clear and concise description of the proposed feature
|
|
120
|
+
- The problem this feature would solve
|
|
121
|
+
- Any alternative solutions you've considered
|
|
122
|
+
|
|
123
|
+
3. For questions:
|
|
124
|
+
- Provide as much context as possible about your question
|
|
125
|
+
- Include any research you've already done on the topic
|
|
126
|
+
- Specify which parts of the [documentation](https://docs.ultralytics.com/), if any, you've already consulted
|
|
127
|
+
|
|
128
|
+
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}.
|
|
129
|
+
|
|
130
|
+
Thank you for your contribution to improving our project!
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
example = os.getenv("FIRST_ISSUE_RESPONSE") or issue_discussion_response
|
|
134
|
+
org_name, repo_name = event.repository.split("/")
|
|
135
|
+
|
|
136
|
+
prompt = f"""Generate a customized response to the new GitHub {issue_type} below:
|
|
137
|
+
|
|
138
|
+
CONTEXT:
|
|
139
|
+
- Repository: {repo_name}
|
|
140
|
+
- Organization: {org_name}
|
|
141
|
+
- Repository URL: https://github.com/{event.repository}
|
|
142
|
+
- User: {username}
|
|
143
|
+
|
|
144
|
+
INSTRUCTIONS:
|
|
145
|
+
- Do not answer the question or resolve the issue directly
|
|
146
|
+
- Adapt the example {issue_type} response below as appropriate, keeping all badges, links and references provided
|
|
147
|
+
- For bug reports, specifically request a minimum reproducible example (MRE) if not provided
|
|
148
|
+
- INCLUDE ALL LINKS AND INSTRUCTIONS IN THE EXAMPLE BELOW, customized as appropriate
|
|
149
|
+
- Mention to the user that this is an automated response and that an Ultralytics engineer will also assist soon
|
|
150
|
+
- Do not add a sign-off or valediction like "best regards" at the end of your response
|
|
151
|
+
- Do not add spaces between bullet points or numbered lists
|
|
152
|
+
- Only link to files or URLs in the example below, do not add external links
|
|
153
|
+
- Use a few emojis to enliven your response
|
|
154
|
+
|
|
155
|
+
EXAMPLE {issue_type.upper()} RESPONSE:
|
|
156
|
+
{example}
|
|
157
|
+
|
|
158
|
+
{issue_type.upper()} TITLE:
|
|
159
|
+
{title}
|
|
160
|
+
|
|
161
|
+
{issue_type.upper()} DESCRIPTION:
|
|
162
|
+
{body[:16000]}
|
|
163
|
+
|
|
164
|
+
YOUR {issue_type.upper()} RESPONSE:
|
|
165
|
+
"""
|
|
166
|
+
messages = [
|
|
167
|
+
{
|
|
168
|
+
"role": "system",
|
|
169
|
+
"content": f"You are an Ultralytics AI assistant responding to GitHub {issue_type}s for {org_name}.",
|
|
170
|
+
},
|
|
171
|
+
{"role": "user", "content": prompt},
|
|
172
|
+
]
|
|
173
|
+
return get_completion(messages)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def main(*args, **kwargs):
|
|
177
|
+
"""Executes auto-labeling and custom response generation for new GitHub issues, PRs, and discussions."""
|
|
178
|
+
event = Action(*args, **kwargs)
|
|
179
|
+
number, node_id, title, body, username, issue_type, action = get_event_content(event)
|
|
180
|
+
available_labels = event.get_repo_data("labels")
|
|
181
|
+
label_descriptions = {label["name"]: label.get("description", "") for label in available_labels}
|
|
182
|
+
|
|
183
|
+
# Use unified PR open response for new PRs (summary + labels + first comment in 1 API call)
|
|
184
|
+
if issue_type == "pull request" and action == "opened":
|
|
185
|
+
print("Processing PR open with unified API call...")
|
|
186
|
+
diff = event.get_pr_diff()
|
|
187
|
+
response = get_pr_open_response(event.repository, diff, title, body, label_descriptions)
|
|
188
|
+
|
|
189
|
+
if summary := response.get("summary"):
|
|
190
|
+
print("Updating PR description with summary...")
|
|
191
|
+
event.update_pr_description(number, SUMMARY_START + summary)
|
|
192
|
+
|
|
193
|
+
if relevant_labels := response.get("labels", []):
|
|
194
|
+
apply_and_check_labels(event, number, node_id, issue_type, username, relevant_labels, label_descriptions)
|
|
195
|
+
|
|
196
|
+
if first_comment := response.get("first_comment"):
|
|
197
|
+
print("Adding first interaction comment...")
|
|
198
|
+
time.sleep(1) # sleep to ensure label added first
|
|
199
|
+
event.add_comment(number, node_id, first_comment, issue_type)
|
|
200
|
+
return
|
|
201
|
+
|
|
202
|
+
# Handle issues and discussions (NOT PRs)
|
|
203
|
+
current_labels = (
|
|
204
|
+
[]
|
|
205
|
+
if issue_type == "discussion"
|
|
206
|
+
else [label["name"].lower() for label in event.get_repo_data(f"issues/{number}/labels")]
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
relevant_labels = get_relevant_labels(issue_type, title, body, label_descriptions, current_labels)
|
|
210
|
+
apply_and_check_labels(event, number, node_id, issue_type, username, relevant_labels, label_descriptions)
|
|
211
|
+
|
|
212
|
+
if action in {"opened", "created"}:
|
|
213
|
+
custom_response = get_first_interaction_response(event, issue_type, title, body, username)
|
|
214
|
+
event.add_comment(number, node_id, custom_response, issue_type)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
if __name__ == "__main__":
|
|
218
|
+
main()
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
from .utils import GITHUB_API_URL, Action, get_completion, remove_html_comments
|
|
9
|
+
|
|
10
|
+
REVIEW_MARKER = "🔍 PR Review"
|
|
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
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def parse_diff_files(diff_text: str) -> dict:
|
|
33
|
+
"""Parse diff to extract file paths, valid line numbers, and line content for comments."""
|
|
34
|
+
files, current_file, current_line = {}, None, 0
|
|
35
|
+
|
|
36
|
+
for line in diff_text.split("\n"):
|
|
37
|
+
if line.startswith("diff --git"):
|
|
38
|
+
match = re.search(r" b/(.+)$", line)
|
|
39
|
+
current_file = match.group(1) if match else None
|
|
40
|
+
current_line = 0
|
|
41
|
+
if current_file:
|
|
42
|
+
files[current_file] = {}
|
|
43
|
+
elif line.startswith("@@") and current_file:
|
|
44
|
+
match = re.search(r"@@.*\+(\d+)(?:,\d+)?", line)
|
|
45
|
+
current_line = int(match.group(1)) if match else 0
|
|
46
|
+
elif current_file and current_line > 0:
|
|
47
|
+
if line.startswith("+") and not line.startswith("+++"):
|
|
48
|
+
files[current_file][current_line] = line[1:]
|
|
49
|
+
current_line += 1
|
|
50
|
+
elif not line.startswith("-"):
|
|
51
|
+
current_line += 1
|
|
52
|
+
|
|
53
|
+
return files
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def generate_pr_review(repository: str, diff_text: str, pr_title: str, pr_description: str) -> dict:
|
|
57
|
+
"""Generate comprehensive PR review with line-specific comments and overall assessment."""
|
|
58
|
+
if not diff_text:
|
|
59
|
+
return {"comments": [], "summary": "No changes detected in diff"}
|
|
60
|
+
|
|
61
|
+
diff_files = parse_diff_files(diff_text)
|
|
62
|
+
if not diff_files:
|
|
63
|
+
return {"comments": [], "summary": "No files with changes detected in diff"}
|
|
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
|
+
|
|
77
|
+
file_list = list(diff_files.keys())
|
|
78
|
+
limit = round(128000 * 3.3 * 0.5) # 3.3 characters per token for half a 256k context window
|
|
79
|
+
diff_truncated = len(diff_text) > limit
|
|
80
|
+
lines_changed = sum(len(lines) for lines in diff_files.values())
|
|
81
|
+
|
|
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"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
content = (
|
|
91
|
+
"You are an expert code reviewer for Ultralytics. Provide detailed inline comments on specific code changes.\n\n"
|
|
92
|
+
"Focus on: Code quality, style, best practices, bugs, edge cases, error handling, performance, security, documentation, test coverage\n\n"
|
|
93
|
+
"FORMATTING: Use backticks for code, file names, branch names, function names, variable names, packages\n\n"
|
|
94
|
+
"CRITICAL RULES:\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"
|
|
109
|
+
"CODE SUGGESTIONS:\n"
|
|
110
|
+
"- ONLY provide 'suggestion' field when you have high certainty the code is problematic AND sufficient context for a confident fix\n"
|
|
111
|
+
"- If uncertain about the correct fix, omit 'suggestion' field and explain the concern in 'message' only\n"
|
|
112
|
+
"- Suggestions must be ready-to-merge code with NO comments, placeholders, or explanations\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
|
+
"- Never include triple backticks (```) in suggestions as they break markdown formatting\n"
|
|
117
|
+
"- It's better to flag an issue without a suggestion than provide a wrong or uncertain fix\n\n"
|
|
118
|
+
"Return JSON: "
|
|
119
|
+
'{"comments": [{"file": "exact/path", "line": N, "severity": "HIGH", "message": "...", "suggestion": "..."}], "summary": "..."}\n\n'
|
|
120
|
+
"Rules:\n"
|
|
121
|
+
"- Only comment on NEW lines (starting with + in diff)\n"
|
|
122
|
+
"- Use exact file paths from diff (no ./ prefix)\n"
|
|
123
|
+
"- Line numbers must match NEW file line numbers from @@ hunks\n"
|
|
124
|
+
"- When '- old' then '+ new', new line keeps SAME line number\n"
|
|
125
|
+
"- Severity: CRITICAL, HIGH, MEDIUM, LOW, SUGGESTION\n"
|
|
126
|
+
f"- Files changed: {len(file_list)} ({', '.join(file_list[:10])}{'...' if len(file_list) > 10 else ''})\n"
|
|
127
|
+
f"- Lines changed: {lines_changed}\n"
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
messages = [
|
|
131
|
+
{"role": "system", "content": content},
|
|
132
|
+
{
|
|
133
|
+
"role": "user",
|
|
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
|
+
),
|
|
141
|
+
},
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
response = get_completion(messages, reasoning_effort="medium")
|
|
146
|
+
print("\n" + "=" * 80 + f"\nFULL AI RESPONSE:\n{response}\n" + "=" * 80 + "\n")
|
|
147
|
+
|
|
148
|
+
json_str = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", response, re.DOTALL)
|
|
149
|
+
review_data = json.loads(json_str.group(1) if json_str else response)
|
|
150
|
+
|
|
151
|
+
print(f"AI generated {len(review_data.get('comments', []))} comments")
|
|
152
|
+
|
|
153
|
+
# Validate, filter, and deduplicate comments
|
|
154
|
+
unique_comments = {}
|
|
155
|
+
for c in review_data.get("comments", []):
|
|
156
|
+
file_path, line_num = c.get("file"), c.get("line", 0)
|
|
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]:
|
|
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]}...")
|
|
182
|
+
|
|
183
|
+
review_data.update(
|
|
184
|
+
{
|
|
185
|
+
"comments": list(unique_comments.values()),
|
|
186
|
+
"diff_files": diff_files,
|
|
187
|
+
"diff_truncated": diff_truncated,
|
|
188
|
+
"skipped_files": skipped_count,
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
print(f"Valid comments after filtering: {len(review_data['comments'])}")
|
|
192
|
+
return review_data
|
|
193
|
+
|
|
194
|
+
except json.JSONDecodeError as e:
|
|
195
|
+
print(f"JSON parsing failed... {e}")
|
|
196
|
+
return {"comments": [], "summary": "Review generation encountered a JSON parsing error"}
|
|
197
|
+
except Exception as e:
|
|
198
|
+
print(f"Review generation failed: {e}")
|
|
199
|
+
import traceback
|
|
200
|
+
|
|
201
|
+
traceback.print_exc()
|
|
202
|
+
return {"comments": [], "summary": "Review generation encountered an error"}
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def dismiss_previous_reviews(event: Action) -> int:
|
|
206
|
+
"""Dismiss previous bot reviews and delete inline comments, returns count for numbering."""
|
|
207
|
+
if not (pr_number := event.pr.get("number")) or not (bot_username := event.get_username()):
|
|
208
|
+
return 1
|
|
209
|
+
|
|
210
|
+
review_count = 0
|
|
211
|
+
reviews_url = f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{pr_number}/reviews"
|
|
212
|
+
if (response := event.get(reviews_url)).status_code == 200:
|
|
213
|
+
for review in response.json():
|
|
214
|
+
if review.get("user", {}).get("login") == bot_username and REVIEW_MARKER in (review.get("body") or ""):
|
|
215
|
+
review_count += 1
|
|
216
|
+
if review.get("state") in ["APPROVED", "CHANGES_REQUESTED"] and (review_id := review.get("id")):
|
|
217
|
+
event.put(f"{reviews_url}/{review_id}/dismissals", json={"message": "Superseded by new review"})
|
|
218
|
+
|
|
219
|
+
# Delete previous inline comments
|
|
220
|
+
comments_url = f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{pr_number}/comments"
|
|
221
|
+
if (response := event.get(comments_url)).status_code == 200:
|
|
222
|
+
for comment in response.json():
|
|
223
|
+
if comment.get("user", {}).get("login") == bot_username and (comment_id := comment.get("id")):
|
|
224
|
+
event.delete(
|
|
225
|
+
f"{GITHUB_API_URL}/repos/{event.repository}/pulls/comments/{comment_id}",
|
|
226
|
+
expected_status=[200, 204, 404],
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
return review_count + 1
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def post_review_summary(event: Action, review_data: dict, review_number: int) -> None:
|
|
233
|
+
"""Post overall review summary and inline comments as a single PR review."""
|
|
234
|
+
if not (pr_number := event.pr.get("number")) or not (commit_sha := event.pr.get("head", {}).get("sha")):
|
|
235
|
+
return
|
|
236
|
+
|
|
237
|
+
review_title = f"{REVIEW_MARKER} {review_number}" if review_number > 1 else REVIEW_MARKER
|
|
238
|
+
comments = review_data.get("comments", [])
|
|
239
|
+
event_type = (
|
|
240
|
+
"REQUEST_CHANGES" if any(c.get("severity") not in ["LOW", "SUGGESTION", None] for c in comments) else "APPROVE"
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
body = (
|
|
244
|
+
f"## {review_title}\n\n"
|
|
245
|
+
"<sub>Made with ❤️ by [Ultralytics Actions](https://github.com/ultralytics/actions)</sub>\n\n"
|
|
246
|
+
f"{review_data.get('summary', 'Review completed')}\n\n"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
if comments:
|
|
250
|
+
shown = min(len(comments), 10)
|
|
251
|
+
body += f"💬 Posted {shown} inline comment{'s' if shown != 1 else ''}{' (10 shown, more available)' if len(comments) > 10 else ''}\n"
|
|
252
|
+
|
|
253
|
+
if review_data.get("diff_truncated"):
|
|
254
|
+
body += "\n⚠️ **Large PR**: Review focused on critical issues. Some details may not be covered.\n"
|
|
255
|
+
|
|
256
|
+
if skipped := review_data.get("skipped_files"):
|
|
257
|
+
body += f"\n📋 **Skipped {skipped} file{'s' if skipped != 1 else ''}** (lock files, minified, images, etc.)\n"
|
|
258
|
+
|
|
259
|
+
# Build inline comments for the review
|
|
260
|
+
review_comments = []
|
|
261
|
+
for comment in comments[:10]:
|
|
262
|
+
if not (file_path := comment.get("file")) or not (line := comment.get("line", 0)):
|
|
263
|
+
continue
|
|
264
|
+
|
|
265
|
+
severity = comment.get("severity", "SUGGESTION")
|
|
266
|
+
comment_body = f"{EMOJI_MAP.get(severity, '💭')} **{severity}**: {comment.get('message', '')}"
|
|
267
|
+
|
|
268
|
+
if suggestion := comment.get("suggestion"):
|
|
269
|
+
if "```" not in suggestion:
|
|
270
|
+
# Extract original line indentation and apply to suggestion
|
|
271
|
+
if original_line := review_data.get("diff_files", {}).get(file_path, {}).get(line):
|
|
272
|
+
indent = len(original_line) - len(original_line.lstrip())
|
|
273
|
+
suggestion = " " * indent + suggestion.strip()
|
|
274
|
+
comment_body += f"\n\n**Suggested change:**\n```suggestion\n{suggestion}\n```"
|
|
275
|
+
|
|
276
|
+
# Build comment with optional start_line for multi-line context
|
|
277
|
+
review_comment = {"path": file_path, "line": line, "body": comment_body, "side": "RIGHT"}
|
|
278
|
+
if start_line := comment.get("start_line"):
|
|
279
|
+
if start_line < line:
|
|
280
|
+
review_comment["start_line"] = start_line
|
|
281
|
+
review_comment["start_side"] = "RIGHT"
|
|
282
|
+
print(f"Multi-line comment: {file_path}:{start_line}-{line}")
|
|
283
|
+
|
|
284
|
+
review_comments.append(review_comment)
|
|
285
|
+
|
|
286
|
+
# Submit review with inline comments
|
|
287
|
+
payload = {"commit_id": commit_sha, "body": body, "event": event_type}
|
|
288
|
+
if review_comments:
|
|
289
|
+
payload["comments"] = review_comments
|
|
290
|
+
print(f"Posting review with {len(review_comments)} inline comments")
|
|
291
|
+
|
|
292
|
+
event.post(
|
|
293
|
+
f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{pr_number}/reviews",
|
|
294
|
+
json=payload,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def main(*args, **kwargs):
|
|
299
|
+
"""Main entry point for PR review action."""
|
|
300
|
+
event = Action(*args, **kwargs)
|
|
301
|
+
|
|
302
|
+
# Handle review requests
|
|
303
|
+
if event.event_name == "pull_request" and event.event_data.get("action") == "review_requested":
|
|
304
|
+
if event.event_data.get("requested_reviewer", {}).get("login") != event.get_username():
|
|
305
|
+
return
|
|
306
|
+
print(f"Review requested from {event.get_username()}")
|
|
307
|
+
|
|
308
|
+
if not event.pr or event.pr.get("state") != "open":
|
|
309
|
+
print(f"Skipping: PR state is {event.pr.get('state') if event.pr else 'None'}")
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
print(f"Starting PR review for #{event.pr['number']}")
|
|
313
|
+
review_number = dismiss_previous_reviews(event)
|
|
314
|
+
|
|
315
|
+
diff = event.get_pr_diff()
|
|
316
|
+
review = generate_pr_review(event.repository, diff, event.pr.get("title", ""), event.pr.get("body", ""))
|
|
317
|
+
|
|
318
|
+
post_review_summary(event, review, review_number)
|
|
319
|
+
print("PR review completed")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
if __name__ == "__main__":
|
|
323
|
+
main()
|