ultralytics-actions 0.1.0__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.1.0/ultralytics_actions.egg-info → ultralytics_actions-0.1.1}/PKG-INFO +3 -1
- {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/README.md +2 -0
- {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/actions/__init__.py +1 -1
- {ultralytics_actions-0.1.0 → 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.0 → ultralytics_actions-0.1.1}/actions/review_pr.py +140 -64
- ultralytics_actions-0.1.1/actions/summarize_pr.py +128 -0
- {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/actions/summarize_release.py +13 -2
- {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/actions/update_markdown_code_blocks.py +0 -1
- {ultralytics_actions-0.1.0 → 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.1.0 → ultralytics_actions-0.1.1}/tests/test_first_interaction.py +1 -19
- {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/tests/test_summarize_pr.py +2 -30
- {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1/ultralytics_actions.egg-info}/PKG-INFO +3 -1
- ultralytics_actions-0.1.0/actions/first_interaction.py +0 -379
- ultralytics_actions-0.1.0/actions/summarize_pr.py +0 -230
- ultralytics_actions-0.1.0/actions/utils/github_utils.py +0 -182
- ultralytics_actions-0.1.0/actions/utils/openai_utils.py +0 -96
- {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/LICENSE +0 -0
- {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/actions/update_file_headers.py +0 -0
- {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/actions/utils/common_utils.py +0 -0
- {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/actions/utils/version_utils.py +0 -0
- {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/pyproject.toml +0 -0
- {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/setup.cfg +0 -0
- {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/tests/test_cli_commands.py +0 -0
- {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/tests/test_common_utils.py +0 -0
- {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/tests/test_dispatch_actions.py +0 -0
- {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/tests/test_file_headers.py +0 -0
- {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/tests/test_github_utils.py +0 -0
- {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/tests/test_init.py +0 -0
- {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/tests/test_openai_utils.py +0 -0
- {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/tests/test_summarize_release.py +0 -0
- {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/tests/test_update_markdown_codeblocks.py +0 -0
- {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/tests/test_urls.py +0 -0
- {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/ultralytics_actions.egg-info/SOURCES.txt +0 -0
- {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/ultralytics_actions.egg-info/dependency_links.txt +0 -0
- {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/ultralytics_actions.egg-info/entry_points.txt +0 -0
- {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/ultralytics_actions.egg-info/requires.txt +0 -0
- {ultralytics_actions-0.1.0 → ultralytics_actions-0.1.1}/ultralytics_actions.egg-info/top_level.txt +0 -0
{ultralytics_actions-0.1.0/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.1.
|
|
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>
|
|
@@ -64,6 +64,7 @@ Ultralytics Actions automatically applies formats, updates, and enhancements usi
|
|
|
64
64
|
- **Spell Check:** Common misspellings are caught using [codespell](https://github.com/codespell-project/codespell).
|
|
65
65
|
- **Broken Links Check:** Broken links in documentation and Markdown files are identified using [Lychee](https://github.com/lycheeverse/lychee).
|
|
66
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.
|
|
67
68
|
- **Auto-labeling:** Relevant labels are applied to issues and pull requests via [OpenAI](https://openai.com/) GPT-5 for intelligent categorization.
|
|
68
69
|
|
|
69
70
|
## 🛠️ How It Works
|
|
@@ -74,6 +75,7 @@ Ultralytics Actions triggers on various GitHub events to streamline workflows:
|
|
|
74
75
|
- **Pull Requests:**
|
|
75
76
|
- Ensures contributions meet formatting standards before merging.
|
|
76
77
|
- Generates a concise summary of changes using GPT-5.
|
|
78
|
+
- Provides AI-powered inline code reviews with suggested fixes for critical issues.
|
|
77
79
|
- Applies relevant labels using GPT-5 for intelligent categorization.
|
|
78
80
|
- **Issues:** Automatically applies relevant labels using GPT-5 when new issues are created.
|
|
79
81
|
|
|
@@ -26,6 +26,7 @@ Ultralytics Actions automatically applies formats, updates, and enhancements usi
|
|
|
26
26
|
- **Spell Check:** Common misspellings are caught using [codespell](https://github.com/codespell-project/codespell).
|
|
27
27
|
- **Broken Links Check:** Broken links in documentation and Markdown files are identified using [Lychee](https://github.com/lycheeverse/lychee).
|
|
28
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.
|
|
29
30
|
- **Auto-labeling:** Relevant labels are applied to issues and pull requests via [OpenAI](https://openai.com/) GPT-5 for intelligent categorization.
|
|
30
31
|
|
|
31
32
|
## 🛠️ How It Works
|
|
@@ -36,6 +37,7 @@ Ultralytics Actions triggers on various GitHub events to streamline workflows:
|
|
|
36
37
|
- **Pull Requests:**
|
|
37
38
|
- Ensures contributions meet formatting standards before merging.
|
|
38
39
|
- Generates a concise summary of changes using GPT-5.
|
|
40
|
+
- Provides AI-powered inline code reviews with suggested fixes for critical issues.
|
|
39
41
|
- Applies relevant labels using GPT-5 for intelligent categorization.
|
|
40
42
|
- **Issues:** Automatically applies relevant labels using GPT-5 when new issues are created.
|
|
41
43
|
|
|
@@ -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()
|
|
@@ -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,27 +55,36 @@ 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 = (
|
|
@@ -65,16 +92,28 @@ def generate_pr_review(repository: str, diff_text: str, pr_title: str, pr_descri
|
|
|
65
92
|
"Focus on: Code quality, style, best practices, bugs, edge cases, error handling, performance, security, documentation, test coverage\n\n"
|
|
66
93
|
"FORMATTING: Use backticks for code, file names, branch names, function names, variable names, packages\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
|
+
"- Never include 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,16 @@ 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
|
-
|
|
239
|
+
event_type = (
|
|
240
|
+
"REQUEST_CHANGES" if any(c.get("severity") not in ["LOW", "SUGGESTION", None] for c in comments) else "APPROVE"
|
|
241
|
+
)
|
|
200
242
|
|
|
201
243
|
body = (
|
|
202
244
|
f"## {review_title}\n\n"
|
|
@@ -205,15 +247,51 @@ def post_review_summary(event: Action, review_data: dict, review_number: int) ->
|
|
|
205
247
|
)
|
|
206
248
|
|
|
207
249
|
if comments:
|
|
208
|
-
shown = min(len(comments),
|
|
209
|
-
body += f"💬 Posted {shown} inline comment{'s' if shown != 1 else ''}{' (
|
|
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"
|
|
210
252
|
|
|
211
253
|
if review_data.get("diff_truncated"):
|
|
212
254
|
body += "\n⚠️ **Large PR**: Review focused on critical issues. Some details may not be covered.\n"
|
|
213
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
|
+
|
|
214
292
|
event.post(
|
|
215
293
|
f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{pr_number}/reviews",
|
|
216
|
-
json=
|
|
294
|
+
json=payload,
|
|
217
295
|
)
|
|
218
296
|
|
|
219
297
|
|
|
@@ -238,8 +316,6 @@ def main(*args, **kwargs):
|
|
|
238
316
|
review = generate_pr_review(event.repository, diff, event.pr.get("title", ""), event.pr.get("body", ""))
|
|
239
317
|
|
|
240
318
|
post_review_summary(event, review, review_number)
|
|
241
|
-
print(f"Posting {len(review.get('comments', []))} inline comments")
|
|
242
|
-
post_review_comments(event, review)
|
|
243
319
|
print("PR review completed")
|
|
244
320
|
|
|
245
321
|
|