246 lines
10 KiB
Python
246 lines
10 KiB
Python
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import time
|
|
|
|
from . import review_pr
|
|
from .summarize_pr import SUMMARY_MARKER
|
|
from .utils import (
|
|
ACTIONS_CREDIT,
|
|
Action,
|
|
filter_labels,
|
|
format_skipped_files_dropdown,
|
|
get_pr_open_response,
|
|
get_response,
|
|
remove_html_comments,
|
|
)
|
|
|
|
BLOCK_USER = os.getenv("BLOCK_USER", "false").lower() == "true"
|
|
AUTO_PR_REVIEW = os.getenv("REVIEW", "true").lower() == "true"
|
|
|
|
|
|
def apply_and_check_labels(event, number, node_id, issue_type, username, labels, label_descriptions):
|
|
"""Normalizes, applies labels, and handles Alert label if present."""
|
|
if not labels:
|
|
print("No relevant labels found or applied.")
|
|
return
|
|
|
|
available = {k.lower(): k for k in label_descriptions}
|
|
if normalized := [available.get(label.lower(), label) for label in labels if label.lower() in available]:
|
|
print(f"Applying labels: {normalized}")
|
|
event.apply_labels(number, node_id, normalized, issue_type)
|
|
if "Alert" in normalized and not event.is_org_member(username):
|
|
event.handle_alert(number, node_id, issue_type, username, block=BLOCK_USER)
|
|
|
|
|
|
def get_event_content(event) -> tuple[int, str, str, str, str, str, str]:
|
|
"""Extracts key information from GitHub event data for issues, pull requests, or discussions."""
|
|
data = event.event_data
|
|
name = event.event_name
|
|
action = data["action"]
|
|
if name == "issues":
|
|
item = data["issue"]
|
|
issue_type = "issue"
|
|
elif name in ["pull_request", "pull_request_target"]:
|
|
pr_number = data["pull_request"]["number"]
|
|
item = event.get_repo_data(f"pulls/{pr_number}")
|
|
issue_type = "pull request"
|
|
elif name == "discussion":
|
|
item = data["discussion"]
|
|
issue_type = "discussion"
|
|
else:
|
|
raise ValueError(f"Unsupported event type: {name}")
|
|
|
|
number = item["number"]
|
|
node_id = item.get("node_id") or item.get("id")
|
|
title = item["title"]
|
|
body = remove_html_comments(item.get("body") or "")
|
|
username = item["user"]["login"]
|
|
return number, node_id, title, body, username, issue_type, action
|
|
|
|
|
|
def get_relevant_labels(
|
|
issue_type: str, title: str, body: str, available_labels: dict, current_labels: list
|
|
) -> list[str]:
|
|
"""Determines relevant labels for GitHub issues/discussions using AI."""
|
|
filtered_labels = filter_labels(available_labels, current_labels, is_pr=(issue_type == "pull request"))
|
|
labels_str = "\n".join(f"- {name}: {description}" for name, description in filtered_labels.items())
|
|
|
|
prompt = f"""Select the top 1-3 most relevant labels for the following GitHub {issue_type}.
|
|
|
|
INSTRUCTIONS:
|
|
1. Review the {issue_type} title and description.
|
|
2. Consider the available labels and their descriptions.
|
|
3. Choose 1-3 labels that best match the {issue_type} content.
|
|
4. Only use the "Alert" label when you have high confidence that this is an inappropriate {issue_type}.
|
|
5. Respond ONLY with the chosen label names (no descriptions), separated by commas.
|
|
6. If no labels are relevant, respond with 'None'.
|
|
{'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 ""}
|
|
|
|
AVAILABLE LABELS:
|
|
{labels_str}
|
|
|
|
{issue_type.upper()} TITLE:
|
|
{title}
|
|
|
|
{issue_type.upper()} DESCRIPTION:
|
|
{body[:16000]}
|
|
|
|
YOUR RESPONSE (label names only):
|
|
"""
|
|
messages = [
|
|
{
|
|
"role": "system",
|
|
"content": "You are an Ultralytics AI assistant that labels GitHub issues, PRs, and discussions.",
|
|
},
|
|
{"role": "user", "content": prompt},
|
|
]
|
|
suggested_labels = get_response(messages, temperature=1.0)
|
|
if "none" in suggested_labels.lower():
|
|
return []
|
|
|
|
available_labels_lower = {name.lower(): name for name in filtered_labels}
|
|
return [
|
|
available_labels_lower[label.lower().strip()]
|
|
for label in suggested_labels.split(",")
|
|
if label.lower().strip() in available_labels_lower
|
|
]
|
|
|
|
|
|
def get_first_interaction_response(event, issue_type: str, title: str, body: str, username: str) -> str:
|
|
"""Generates a custom LLM response for GitHub issues or discussions (NOT PRs - PRs use unified call)."""
|
|
issue_discussion_response = f"""
|
|
👋 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:
|
|
|
|
1. For bug reports:
|
|
- A clear and concise description of the bug
|
|
- A minimum reproducible example [MRE](https://docs.ultralytics.com/help/minimum-reproducible-example/) that demonstrates the issue
|
|
- Your environment details (OS, Python version, package versions)
|
|
- Expected behavior vs. actual behavior
|
|
- Any error messages or logs related to the issue
|
|
|
|
2. For feature requests:
|
|
- A clear and concise description of the proposed feature
|
|
- The problem this feature would solve
|
|
- Any alternative solutions you've considered
|
|
|
|
3. For questions:
|
|
- Provide as much context as possible about your question
|
|
- Include any research you've already done on the topic
|
|
- Specify which parts of the [documentation](https://docs.ultralytics.com/), if any, you've already consulted
|
|
|
|
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}.
|
|
|
|
Thank you for your contribution to improving our project!
|
|
"""
|
|
|
|
example = os.getenv("FIRST_ISSUE_RESPONSE") or issue_discussion_response
|
|
org_name, repo_name = event.repository.split("/")
|
|
|
|
prompt = f"""Generate a customized response to the new GitHub {issue_type} below:
|
|
|
|
CONTEXT:
|
|
- Repository: {repo_name}
|
|
- Organization: {org_name}
|
|
- Repository URL: https://github.com/{event.repository}
|
|
- User: {username}
|
|
|
|
INSTRUCTIONS:
|
|
- Do not answer the question or resolve the issue directly
|
|
- Adapt the example {issue_type} response below as appropriate, keeping all badges, links and references provided
|
|
- For bug reports, specifically request a minimum reproducible example (MRE) if not provided
|
|
- INCLUDE ALL LINKS AND INSTRUCTIONS IN THE EXAMPLE BELOW, customized as appropriate
|
|
- Mention to the user that this is an automated response and that an Ultralytics engineer will also assist soon
|
|
- Do not add a sign-off or valediction like "best regards" at the end of your response
|
|
- Do not add spaces between bullet points or numbered lists
|
|
- Only link to files or URLs in the example below, do not add external links
|
|
- Use a few emojis to enliven your response
|
|
|
|
EXAMPLE {issue_type.upper()} RESPONSE:
|
|
{example}
|
|
|
|
{issue_type.upper()} TITLE:
|
|
{title}
|
|
|
|
{issue_type.upper()} DESCRIPTION:
|
|
{body[:16000]}
|
|
|
|
YOUR {issue_type.upper()} RESPONSE:
|
|
"""
|
|
messages = [
|
|
{
|
|
"role": "system",
|
|
"content": f"You are an Ultralytics AI assistant responding to GitHub {issue_type}s for {org_name}.",
|
|
},
|
|
{"role": "user", "content": prompt},
|
|
]
|
|
return get_response(messages)
|
|
|
|
|
|
def main(*args, **kwargs):
|
|
"""Executes auto-labeling and custom response generation for new GitHub issues, PRs, and discussions."""
|
|
event = Action(*args, **kwargs)
|
|
if event.should_skip_llm():
|
|
return
|
|
|
|
number, node_id, title, body, username, issue_type, action = get_event_content(event)
|
|
available_labels = event.get_repo_data("labels")
|
|
label_descriptions = {label["name"]: label.get("description") or "" for label in available_labels}
|
|
|
|
# Use unified PR open response for new PRs (summary + labels + first comment in 1 API call)
|
|
if issue_type == "pull request" and action == "opened":
|
|
if event.should_skip_pr_author():
|
|
return
|
|
|
|
print(f"Processing PR open by @{username} with unified API call...")
|
|
diff = event.get_pr_diff()
|
|
response = get_pr_open_response(event.repository, diff, title, username, label_descriptions)
|
|
|
|
if summary := response.get("summary"):
|
|
print("Updating PR description with summary...")
|
|
skipped_dropdown = format_skipped_files_dropdown(response.get("skipped_files", []))
|
|
event.update_pr_description(number, f"{SUMMARY_MARKER}\n\n{ACTIONS_CREDIT}\n\n{summary}{skipped_dropdown}")
|
|
if sum(not char.isspace() for char in body) < 30:
|
|
body = f"{body.rstrip()}\n\n{summary}".lstrip()
|
|
|
|
if relevant_labels := response.get("labels", []):
|
|
apply_and_check_labels(event, number, node_id, issue_type, username, relevant_labels, label_descriptions)
|
|
|
|
if first_comment := response.get("first_comment"):
|
|
print("Adding first interaction comment...")
|
|
time.sleep(1) # sleep to ensure label added first
|
|
event.add_comment(number, node_id, first_comment, issue_type)
|
|
|
|
# Automatic PR review after first interaction
|
|
if AUTO_PR_REVIEW:
|
|
print("Starting automatic PR review...")
|
|
try:
|
|
review_diff, head_sha = event.get_pr_diff_snapshot()
|
|
except RuntimeError as e:
|
|
print(f"Skipping stale PR review: {e}")
|
|
return
|
|
review_data = review_pr.generate_pr_review(event.repository, review_diff, title, body, event, head_sha)
|
|
review_pr.post_review_summary(event, review_data)
|
|
print("PR review completed")
|
|
return
|
|
|
|
# Handle issues and discussions (NOT PRs)
|
|
current_labels = (
|
|
[]
|
|
if issue_type == "discussion"
|
|
else [label["name"].lower() for label in event.get_repo_data(f"issues/{number}/labels")]
|
|
)
|
|
|
|
relevant_labels = get_relevant_labels(issue_type, title, body, label_descriptions, current_labels)
|
|
apply_and_check_labels(event, number, node_id, issue_type, username, relevant_labels, label_descriptions)
|
|
|
|
if action in {"opened", "created"}:
|
|
custom_response = get_first_interaction_response(event, issue_type, title, body, username)
|
|
event.add_comment(number, node_id, custom_response, issue_type)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|