Improve GitHub automation quality and efficiency (#863)

This commit is contained in:
Glenn Jocher
2026-08-09 05:18:10 +02:00
committed by GitHub
parent 89d7b83d4d
commit 3be773895c
17 changed files with 364 additions and 464 deletions
+1 -6
View File
@@ -21,13 +21,8 @@ jobs:
actions:
runs-on: ubuntu-latest
steps:
# Dogfood this repo's own action: PR events run the PR's action.yml, issue events run main's
- uses: actions/checkout@v7
with:
ref: ${{ github.event.pull_request.head.sha || github.sha }}
- name: Run Ultralytics Actions
uses: ./
uses: ultralytics/actions@main
with:
token: ${{ secrets._GITHUB_TOKEN || github.token }} # Auto-generated fallback
labels: true # Auto-label issues/PRs using AI
+1 -1
View File
@@ -66,7 +66,7 @@ Key flow: GitHub workflow event → `action.yml` step (gated by `github.event_na
Most shared utilities are re-exported through `actions/utils/__init__.py` — keep `__all__` updated when adding exports.
Self-hosting detail: `.github/workflows/format.yml` checks out the event's ref (PR head for pull requests, main for issue events) and runs `uses: ./` — so PRs here dogfood both the Python package and `action.yml` itself before merge.
Security detail: `.github/workflows/format.yml` runs `ultralytics/actions@main` because it receives write credentials and AI secrets; never execute a PR checkout as a local action in that workflow. PR package changes are exercised by the test workflow before merge.
## Conventions
+1 -1
View File
@@ -186,7 +186,7 @@ Check every pull request commit author against the central Ultralytics CLA signa
### 6. Setup uv Action
Install the latest uv with retry support and optionally activate a Python environment.
Use the official uv setup action with Ultralytics defaults, optional Python environment activation, and dependency caching.
```yaml
- uses: ultralytics/actions/setup-uv@main
+1 -1
View File
@@ -186,7 +186,7 @@ jobs:
### 6. Setup uv Action
安装最新版 uv 并支持重试,同时可选择激活 Python 环境。
使用带 Ultralytics 默认配置的官方 uv setup action,并可选择激活 Python 环境和启用依赖缓存。
```yaml
- uses: ultralytics/actions/setup-uv@main
+36 -38
View File
@@ -11,7 +11,7 @@ inputs:
description: "GitHub token"
required: true
labels:
description: "Run issue and PR auto-labeling"
description: "Run issue and PR auto-labeling with first responses"
required: false
default: "false"
python:
@@ -112,45 +112,38 @@ runs:
# Note tomli required for codespell with pyproject.toml
env:
INPUTS_SPELLING: ${{ inputs.spelling }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_SHA: ${{ github.sha }}
ACTION_PATH: ${{ github.action_path }}
run: |
echo "::group::Install Dependencies"
trap 'echo "::endgroup::"' EXIT
# Source tarballs install ~2x faster than git+ URLs, which clone the repository first. Test the current commit
# in the ultralytics/actions repo, otherwise install main.
if [ "$GITHUB_REPOSITORY" = "ultralytics/actions" ]; then
echo "Installing from commit: $GITHUB_SHA"
packages="https://github.com/ultralytics/actions/archive/${GITHUB_SHA}.tar.gz"
else
packages="https://github.com/ultralytics/actions/archive/refs/heads/main.tar.gz"
fi
package_dir=$(mktemp -d "$RUNNER_TEMP/ultralytics-actions.XXXXXX")
uv build --wheel "$ACTION_PATH" --out-dir "$package_dir"
packages=("$package_dir"/*.whl)
if [ "$INPUTS_SPELLING" = "true" ]; then
packages="$packages codespell tomli"
packages+=(codespell tomli)
fi
# On macOS, don't use sudo as it can cause environment issues
if [ "$(uname)" = "Darwin" ]; then
uv pip install --system --break-system-packages $packages
uv pip install --system --break-system-packages "${packages[@]}"
else
sudo env "PATH=$PATH" uv pip install --system --break-system-packages $packages
sudo env "PATH=$PATH" uv pip install --system --break-system-packages "${packages[@]}"
fi
ultralytics-actions-info
shell: bash
# Checkout Repository ----------------------------------------------------------------------------------------------
- name: Checkout Repository
if: github.event.action == 'opened' || github.event.action == 'synchronize' || github.event.action == 'review_requested'
if: github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'synchronize' || github.event.action == 'review_requested')
uses: actions/checkout@v7
with:
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
token: ${{ inputs.token }}
ref: ${{ github.head_ref || github.ref }}
ref: ${{ github.event.pull_request.head.ref }}
fetch-depth: 2
# File header ------------------------------------------------------------------------------------------------------
- name: File Header
if: (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && (github.event.action == 'opened' || github.event.action == 'synchronize')
if: github.event_name == 'pull_request' && (github.event.action == 'opened' || github.event.action == 'synchronize')
env:
HEADER: ${{ inputs.header }}
run: |
@@ -174,7 +167,7 @@ runs:
# D413: Missing blank line after last section
# --target-version is Python 3.8 for --extend-select UP (pyupgrade)
- name: Run Python
if: (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && inputs.python == 'true' && (github.event.action == 'opened' || github.event.action == 'synchronize')
if: github.event_name == 'pull_request' && inputs.python == 'true' && (github.event.action == 'opened' || github.event.action == 'synchronize')
run: |
echo "::group::Run Python"
trap 'echo "::endgroup::"' EXIT
@@ -196,7 +189,7 @@ runs:
# Biome (JavaScript, TypeScript, JSON, JSX, TSX) ------------------------------------------------------------------
- name: Run Biome
if: (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && inputs.biome == 'true' && (github.event.action == 'opened' || github.event.action == 'synchronize')
if: github.event_name == 'pull_request' && inputs.biome == 'true' && (github.event.action == 'opened' || github.event.action == 'synchronize')
run: |
echo "::group::Run Biome"
trap 'echo "::endgroup::"' EXIT
@@ -212,7 +205,7 @@ runs:
# Prettier (JavaScript, JSX, Angular, Vue, Flow, TypeScript, CSS, HTML, JSON, GraphQL, Markdown, YAML) -------------
- name: Run Prettier
if: (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && (inputs.prettier == 'true' || inputs.markdown == 'true') && (github.event.action == 'opened' || github.event.action == 'synchronize')
if: github.event_name == 'pull_request' && (inputs.prettier == 'true' || inputs.markdown == 'true') && (github.event.action == 'opened' || github.event.action == 'synchronize')
run: |
echo "::group::Run Prettier"
trap 'echo "::endgroup::"' EXIT
@@ -233,7 +226,7 @@ runs:
# Swift formatting -------------------------------------------------------------------------------------------------
- name: Run Swift Formatter
if: (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && inputs.swift == 'true' && (github.event.action == 'opened' || github.event.action == 'synchronize')
if: github.event_name == 'pull_request' && inputs.swift == 'true' && (github.event.action == 'opened' || github.event.action == 'synchronize')
run: |
echo "::group::Run Swift Formatter"
trap 'echo "::endgroup::"' EXIT
@@ -244,13 +237,13 @@ runs:
# Dart formatting --------------------------------------------------------------------------------------------------
- name: Setup Dart SDK
if: (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && inputs.dart == 'true' && (github.event.action == 'opened' || github.event.action == 'synchronize')
if: github.event_name == 'pull_request' && inputs.dart == 'true' && (github.event.action == 'opened' || github.event.action == 'synchronize')
uses: dart-lang/setup-dart@v1
with:
sdk: stable
- name: Run Dart Formatter
if: (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && inputs.dart == 'true' && (github.event.action == 'opened' || github.event.action == 'synchronize')
if: github.event_name == 'pull_request' && inputs.dart == 'true' && (github.event.action == 'opened' || github.event.action == 'synchronize')
run: |
echo "::group::Run Dart Formatter"
trap 'echo "::endgroup::"' EXIT
@@ -261,7 +254,7 @@ runs:
# Spelling ---------------------------------------------------------------------------------------------------------
# --ignore-regex '\b[a-z]+[A-Z][a-zA-Z]*\b' ignores camelCase words (e.g. repoRes, dateA) to prevent false positives
- name: Run Codespell
if: (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && inputs.spelling == 'true' && (github.event.action == 'opened' || github.event.action == 'synchronize')
if: github.event_name == 'pull_request' && inputs.spelling == 'true' && (github.event.action == 'opened' || github.event.action == 'synchronize')
run: |
echo "::group::Run Codespell"
trap 'echo "::endgroup::"' EXIT
@@ -291,9 +284,12 @@ runs:
shell: bash
continue-on-error: true
# Autolabel Issues and PRs (labels and first interaction) ---------------------------------------------------------
- name: Autolabel Issues and PRs
if: inputs.labels == 'true' && (github.event.action == 'opened' || github.event.action == 'created')
# PR automation and first interaction ------------------------------------------------------------------------------
- name: PR Automation and First Interaction
if: |
(inputs.labels == 'true' && (github.event.action == 'opened' || github.event.action == 'created')) ||
((github.event_name == 'pull_request' || github.event_name == 'pull_request_target') &&
github.event.action == 'opened' && (inputs.summary == 'true' || inputs.review == 'true'))
env:
GITHUB_TOKEN: ${{ inputs.token }}
FIRST_ISSUE_RESPONSE: ${{ inputs.first_issue_response }}
@@ -302,9 +298,11 @@ runs:
ANTHROPIC_API_KEY: ${{ inputs.anthropic_api_key }}
MODEL: ${{ inputs.model }}
REVIEW_MODEL: ${{ inputs.review_model }}
LABELS: ${{ inputs.labels }}
SUMMARY: ${{ inputs.summary }}
REVIEW: ${{ inputs.review }}
run: |
echo "::group::Autolabel Issues and PRs"
echo "::group::PR Automation and First Interaction"
trap 'echo "::endgroup::"' EXIT
ultralytics-actions-first-interaction
shell: bash
@@ -331,7 +329,7 @@ runs:
# Bun Update ---------------------------------------------------------------------------------------------------------
- name: Bun Update
if: (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && inputs.bun_update == 'true' && github.event.action == 'opened'
if: github.event_name == 'pull_request' && inputs.bun_update == 'true' && github.event.action == 'opened'
run: |
echo "::group::Bun Update"
trap 'echo "::endgroup::"' EXIT
@@ -359,25 +357,25 @@ runs:
# Auto-format commit on the opened event. The recursion risk starts after that commit pushes a synchronize
# event as github_username, so keep the bot guard only for synchronize events.
if: |
(github.event_name == 'pull_request' || github.event_name == 'pull_request_target') &&
github.event_name == 'pull_request' &&
(github.event.action == 'opened' || github.event.action == 'synchronize') &&
(github.event.action == 'opened' || github.actor != inputs.github_username)
(github.event.action == 'opened' || github.actor != inputs.github_username) &&
github.event.pull_request.head.repo.full_name == github.repository
env:
GITHUB_USERNAME: ${{ inputs.github_username }}
GITHUB_EMAIL: ${{ inputs.github_email }}
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
echo "::group::Commit and Push Changes"
trap 'echo "::endgroup::"' EXIT
COMMIT_MSG="Auto-format by https://ultralytics.com/actions"
git config --global user.name "$GITHUB_USERNAME"
git config --global user.email "$GITHUB_EMAIL"
git config user.name "$GITHUB_USERNAME"
git config user.email "$GITHUB_EMAIL"
git add .
git reset HEAD -- .github/workflows/ # workflow changes are not permitted with default token
if ! git diff --staged --quiet; then
git commit -m "$COMMIT_MSG"
git push --force-with-lease="refs/heads/${PR_HEAD_REF}:${PR_HEAD_SHA}" origin "HEAD:refs/heads/${PR_HEAD_REF}"
git push origin "HEAD:refs/heads/${PR_HEAD_REF}"
else
echo "No changes to commit"
fi
@@ -386,7 +384,7 @@ runs:
# Broken links -----------------------------------------------------------------------------------------------------
- name: Broken Link Checker
if: inputs.links == 'true' && github.event.action != 'closed'
if: inputs.links == 'true' && github.event_name == 'pull_request' && github.event.action != 'closed'
uses: lycheeverse/[email protected]
with:
# Check all markdown and html files in repo. Ignores the following status codes to reduce false positives:
+1 -1
View File
@@ -28,4 +28,4 @@
# ├── test_summarize_pr.py
# └── ...
__version__ = "0.3.1"
__version__ = "0.3.2"
+130 -124
View File
@@ -3,12 +3,12 @@
from __future__ import annotations
import os
import time
from . import review_pr
from .summarize_pr import SUMMARY_MARKER
from .utils import (
ACTIONS_CREDIT,
GITHUB_API_URL,
Action,
filter_labels,
format_skipped_files_dropdown,
@@ -16,8 +16,11 @@ from .utils import (
get_response,
remove_html_comments,
)
from .utils.common_utils import check_links_in_string
BLOCK_USER = os.getenv("BLOCK_USER", "false").lower() == "true"
AUTO_LABELS = os.getenv("LABELS", "true").lower() == "true"
AUTO_PR_SUMMARY = os.getenv("SUMMARY", "true").lower() == "true"
AUTO_PR_REVIEW = os.getenv("REVIEW", "true").lower() == "true"
@@ -31,7 +34,7 @@ def apply_and_check_labels(event, number, node_id, issue_type, username, labels,
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):
if any(label.lower() == "alert" for label in normalized) and not event.is_org_member(username):
event.handle_alert(number, node_id, issue_type, username, block=BLOCK_USER)
@@ -44,8 +47,7 @@ def get_event_content(event) -> tuple[int, str, str, str, str, str, str]:
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}")
item = data["pull_request"]
issue_type = "pull request"
elif name == "discussion":
item = data["discussion"]
@@ -61,102 +63,54 @@ def get_event_content(event) -> tuple[int, str, str, str, str, str, str]:
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"))
def get_first_interaction_response(
event,
issue_type: str,
title: str,
body: str,
username: str,
available_labels: dict,
current_labels: list,
repository_context: str,
) -> dict:
"""Generate labels and a first response for an issue or discussion in one LLM call."""
issue_discussion_response = f"""
👋 Thanks @{username} for opening this `{event.repository}` {issue_type}. This is an automated first response; an Ultralytics engineer will assist soon.
"""
configured_example = os.getenv("FIRST_ISSUE_RESPONSE")
example = configured_example or issue_discussion_response
org_name, repo_name = event.repository.split("/")
filtered_labels = filter_labels(available_labels, current_labels)
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:
prompt = f"""Process the new GitHub {issue_type} below and return labels plus a first response.
CONTEXT:
- Repository: {repo_name}
- Organization: {org_name}
- Repository URL: https://github.com/{event.repository}
- Repository metadata: {repository_context or "No additional metadata provided."}
- 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
LABELS:
- Select 0-3 labels only from the available names below; descriptions are authoritative
- Use Alert only with high confidence for spam, abuse, or illegal content
- Use bug only when the report describes concrete incorrect behavior with enough evidence to investigate; do not require a particular language, package manager, or reproduction format
- Do not repeat a current label or guess from the repository name alone
AVAILABLE LABELS:
{labels_str}
FIRST RESPONSE:
- Acknowledge the concrete request or failure in the opening sentence without merely restating the title
- Ask only for specific information that is materially missing; do not repeat a generic checklist or request details already supplied
- For bugs, missing evidence may include minimal reproduction steps, observed and expected behavior, relevant environment/toolchain/dependency versions, and focused logs
- For feature requests, ask at most one question that would materially change scope or behavior
- Do not diagnose a cause or claim a resolution without evidence
- Stay repository- and technology-aware; never assume Python, pip, PyPI, a branch name, or a release process
- State that this is automated and an Ultralytics engineer will assist soon
- {"Use the configured example as authoritative repository guidance; retain only instructions and links relevant to this report, and condense repetition" if configured_example else "Use at most 140 words"}
- Do not add a heading, sign-off, or external links not present in the configured example
EXAMPLE {issue_type.upper()} RESPONSE:
{example}
@@ -167,7 +121,7 @@ EXAMPLE {issue_type.upper()} RESPONSE:
{issue_type.upper()} DESCRIPTION:
{body[:16000]}
YOUR {issue_type.upper()} RESPONSE:
Return the labels and final comment only.
"""
messages = [
{
@@ -176,7 +130,27 @@ YOUR {issue_type.upper()} RESPONSE:
},
{"role": "user", "content": prompt},
]
return get_response(messages)
schema = {
"type": "object",
"properties": {
"labels": {"type": "array", "items": {"type": "string"}},
"first_comment": {"type": "string"},
},
"required": ["labels", "first_comment"],
"additionalProperties": False,
}
response = get_response(
messages,
text_format={"format": {"type": "json_schema", "name": "first_interaction", "strict": True, "schema": schema}},
)
response["first_comment"] = check_links_in_string(
response.get("first_comment", "").replace(" @giscus[bot]", ""), replace=True
)
available = {name.lower(): name for name in filtered_labels}
response["labels"] = [
available[label.lower()] for label in response.get("labels", []) if label.lower() in available
]
return response
def main(*args, **kwargs):
@@ -186,32 +160,68 @@ def main(*args, **kwargs):
return
number, node_id, title, body, username, issue_type, action = get_event_content(event)
available_labels = event.get_repo_data("labels")
if issue_type == "pull request" and action == "opened" and event.should_skip_pr_author():
return
if issue_type != "pull request" and action not in {"opened", "created"}:
return
available_labels = (
event.paginate(f"{GITHUB_API_URL}/repos/{event.repository}/labels", hard=True) if AUTO_LABELS else []
)
label_descriptions = {label["name"]: label.get("description") or "" for label in available_labels}
repository_data = event.event_data.get("repository", {})
repository_context = "; ".join(
str(value)
for value in (
repository_data.get("description"),
f"primary language: {repository_data['language']}" if repository_data.get("language") else None,
f"default branch: {repository_data['default_branch']}" if repository_data.get("default_branch") else None,
)
if value
)
# 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
if AUTO_PR_SUMMARY or AUTO_LABELS:
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,
body,
repository_context,
summarize=AUTO_PR_SUMMARY,
acknowledge=AUTO_LABELS,
current_labels=[label["name"] for label in event.pr.get("labels", [])],
)
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 AUTO_PR_SUMMARY and (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}",
fallback_description=event.pr.get("body") or "",
)
if sum(not char.isspace() for char in body) < 30:
body = f"{body.rstrip()}\n\n{summary}".lstrip()
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)
if AUTO_LABELS:
apply_and_check_labels(
event,
number,
node_id,
issue_type,
username,
response.get("labels", []),
label_descriptions,
)
if first_comment := response.get("first_comment"):
print("Adding first interaction comment...")
event.add_comment(number, node_id, first_comment, issue_type)
# Automatic PR review after first interaction
if AUTO_PR_REVIEW:
@@ -220,17 +230,13 @@ def main(*args, **kwargs):
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")]
item = event.event_data.get("issue", {}) if issue_type == "issue" else {}
current_labels = [label["name"].lower() for label in item.get("labels", [])]
response = get_first_interaction_response(
event, issue_type, title, body, username, label_descriptions, current_labels, repository_context
)
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)
apply_and_check_labels(event, number, node_id, issue_type, username, response.get("labels", []), label_descriptions)
if custom_response := response.get("first_comment"):
event.add_comment(number, node_id, custom_response, issue_type)
+21 -16
View File
@@ -27,16 +27,17 @@ from .utils.openai_utils import _is_anthropic_model
REVIEW_MARKER = "## 🔍 PR Review"
ERROR_MARKER = "⚠️ Review generation encountered an error"
EMOJI_MAP = {"CRITICAL": "❗", "HIGH": "⚠️", "MEDIUM": "💡", "LOW": "📝", "SUGGESTION": "💭"}
MAX_CONTEXT_FILE_CHARS = 5000
MAX_CONTEXT_FILE_CHARS = 12000
MAX_REVIEW_COMMENTS = 8
MAX_TOOL_OUTPUT_CHARS = 20000
MAX_TOOL_FILE_LINES = 240
MAX_AGENT_TURNS = 16
MAX_TOOL_OUTPUT_CHARS = 40000
MAX_TOOL_FILE_LINES = 400
MAX_AGENT_TURNS = 24
REVIEW_PROMPT_CHARS = round(MAX_PROMPT_CHARS * 1.5) # reviews favor evidence depth over one-shot cost
MAX_HISTORY_REVIEWS = 5 # prior reviews included in the prompt (the full history stays available via the tool)
MAX_HISTORY_ITEM_CHARS = 8000 # per prior review or response, enough for a full summary plus its findings log
MAX_HISTORY_CHARS = 20000
MAX_FINDING_LOG_CHARS = 500 # per finding logged in the review body, the record that outlives its inline comment
REVIEW_COST_SOFT_LIMIT = 2.00 # stop requesting tools after cumulative spend reaches this amount
REVIEW_COST_SOFT_LIMIT = 5.00 # quality-first budget; stop requesting tools after this soft limit
SEVERITY_RANK = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "SUGGESTION": 4, None: 5}
@@ -535,7 +536,7 @@ def generate_pr_review(
snippet = f"{snippet.rstrip()}\n... (truncated)"
# Only include if within budget, include buffer for Markdown noise
estimated_cost = len(snippet) + 200
if total_chars + estimated_cost >= MAX_PROMPT_CHARS:
if total_chars + estimated_cost >= REVIEW_PROMPT_CHARS:
break # Stop when we hit budget limit
file_contents.append(f"### {file_path}\n```\n{snippet}\n```")
total_chars += estimated_cost
@@ -543,7 +544,9 @@ def generate_pr_review(
full_files_section = f"FULL FILE CONTENTS:\n{chr(10).join(file_contents)}\n\n"
# Calculate remaining budget for diff and check if truncation needed
diff_budget = max(1000, MAX_PROMPT_CHARS - len(guidelines_section) - len(full_files_section) - len(history_section))
diff_budget = max(
1000, REVIEW_PROMPT_CHARS - len(guidelines_section) - len(full_files_section) - len(history_section)
)
diff_truncated = len(augmented_diff) > diff_budget
is_large_pr = diff_truncated or len(file_list) > 30
if is_agent_review_model: # must match the get_agent_response fallback gate
@@ -596,21 +599,23 @@ def generate_pr_review(
content = (
"You are an expert code reviewer for Ultralytics. Review code changes and provide inline comments ONLY for genuine issues.\n\n"
"WHEN TO COMMENT (priority order):\n"
"- Bugs and logic errors that will cause failures\n"
"- Performance issues with measurable impact\n"
"- Code best practices and maintainability\n"
"- Missing error handling for likely failure cases\n"
"- Security issues (only obvious vulnerabilities, not speculative)\n\n"
"- Security vulnerabilities, data loss, corruption, or irreversible behavior\n"
"- Bugs, broken contracts, race conditions, and compatibility regressions with a concrete failure path\n"
"- Performance regressions with a plausible workload and measurable impact\n"
"- Maintainability only when the change creates demonstrated duplication, conflicting owners, or unreachable behavior\n\n"
"WHEN NOT TO COMMENT:\n"
"- Style/formatting (handled by ruff/prettier)\n"
"- Minor naming preferences\n"
"- 'Consider using X' without clear benefit\n"
"- Generic best practices, defensive guards for impossible states, or 'consider using X' without a concrete defect\n"
"- Missing tests unless a high-risk regression path is uncovered and existing coverage does not exercise it\n"
"- Issues in unchanged context lines\n\n"
f"{visibility_section}"
f"{continuity_section}"
"QUALITY OVER QUANTITY:\n"
"- Zero comments is valid for clean PRs: never invent an issue, never withhold an evidence-backed one\n"
"- Each comment must be actionable with clear reasoning\n"
"- Before commenting, trace the changed value or control flow through its owner, callers, and tests; use repository tools when the diff alone cannot prove the claim\n"
"- Each comment must identify the triggering scenario, resulting incorrect behavior, and smallest owner-level correction\n"
"- Severity: CRITICAL enables compromise or broad irreversible loss; HIGH breaks a core path or loses data; MEDIUM breaks a realistic edge path; LOW is a bounded defect; SUGGESTION is non-blocking\n"
"- Combine related issues into one comment\n"
f"- Hard cap: {MAX_REVIEW_COMMENTS} comments maximum\n\n"
"SUGGESTIONS:\n"
@@ -619,7 +624,7 @@ def generate_pr_review(
"- Single-line fixes only: provide 'suggestion' without 'start_line' to replace the line at 'line'\n"
"- Match the exact indentation of the original code\n"
"- Avoid triple backticks (```) in suggestions as they break Markdown formatting\n\n"
"SUMMARY: brief overall assessment of what's good and what needs attention; say so plainly when the PR is clean.\n\n"
"SUMMARY: state the reviewed scope, the behavioral verdict, and any remaining risk in concise prose; say LGTM plainly when the PR is clean.\n\n"
"DIFF LINE FORMAT (how to read line numbers):\n"
' R 123 +code here <- \'R\' means RIGHT (new file), number is 123, use {"line": 123, "side": "RIGHT"}\n'
' L 45 -code here <- \'L\' means LEFT (old file), number is 45, use {"line": 45, "side": "LEFT"}\n'
@@ -642,7 +647,7 @@ def generate_pr_review(
"content": (
f"Review this PR in https://github.com/{repository}:\n\n"
f"TITLE:\n{pr_title}\n\n"
f"BODY:\n{remove_html_comments(pr_description or '')[:1000]}\n\n"
f"BODY:\n{remove_html_comments(pr_description or '')[:8000]}\n\n"
f"{guidelines_section}"
f"{history_section}"
f"{full_files_section}"
+23 -79
View File
@@ -2,8 +2,6 @@
from __future__ import annotations
from urllib.parse import urlparse
from .utils import (
ACTIONS_CREDIT,
GITHUB_API_URL,
@@ -11,82 +9,15 @@ from .utils import (
format_skipped_files_dropdown,
get_pr_summary_prompt,
get_response,
remove_html_comments,
)
SUMMARY_MARKER = "## 🛠️ PR Summary"
def generate_merge_message(pr_summary, pr_credit, pr_url):
"""Generates a motivating thank-you message for merged PR contributors."""
messages = [
{
"role": "system",
"content": (
"You are an Ultralytics AI assistant. Your response is posted verbatim as a GitHub "
"comment on a merged PR. Respond with ONLY the final comment body, ready to post. "
"Do not include preambles (e.g. 'Absolutely', 'Sure', 'Here's a comment'), "
"explanations, sign-offs, or horizontal-rule separators ('---')."
),
},
{
"role": "user",
"content": (
f"Compose the thank-you comment for the merged PR {pr_url} by {pr_credit}. "
f"Context:\n{pr_summary}\n\n"
f"Start with an enthusiastic note about the merge, incorporate a relevant inspirational quote from a historical "
f"figure, and connect it to the PR's impact. Keep it concise yet meaningful, ensuring contributors feel valued."
),
},
]
return get_response(messages)
def generate_issue_comment(pr_url, pr_summary, pr_credit, pr_title=""):
"""Generates personalized issue comment based on PR context."""
# pr_url is the GraphQL PullRequest.url HTML URL (https://github.com/owner/repo/pull/N) in production;
# REST API URLs (https://api.github.com/repos/owner/repo/pulls/N) are also supported
parsed = urlparse(pr_url)
if "/repos/" in pr_url and "/pulls/" in pr_url:
repo_parts = pr_url.split("/repos/", 1)[1].split("/pulls/", 1)[0]
elif parsed.hostname == "github.com" and "/pull/" in parsed.path:
repo_parts = parsed.path.lstrip("/").split("/pull/", 1)[0]
else:
repo_parts = ""
owner_repo = repo_parts.split("/")
repo_name = owner_repo[-1] if len(owner_repo) > 1 else "package"
messages = [
{
"role": "system",
"content": (
"You are an Ultralytics AI assistant. Your response is posted verbatim as a GitHub "
"issue comment. Respond with ONLY the final comment body, ready to post. Do not "
"include preambles (e.g. 'Absolutely', 'Sure', 'Here's a comment'), explanations, "
"sign-offs, or horizontal-rule separators ('---'). No @ mentions or direct addressing."
),
},
{
"role": "user",
"content": f"Compose a GitHub issue comment announcing a potential fix for this issue is now merged in linked PR {pr_url} by {pr_credit}\n\n"
f"PR Title: {pr_title}\n\n"
f"Context from PR:\n{pr_summary}\n\n"
f"Include:\n"
f"1. An explanation of key changes from the PR that may resolve this issue\n"
f"2. Credit to the PR author and contributors\n"
f"3. Options for testing if PR changes have resolved this issue:\n"
f" - If the PR mentions a specific version number (like v8.0.0 or 3.1.0), include: pip install -U {repo_name}>=VERSION\n"
f" - Also suggest: pip install git+https://github.com/{repo_parts}.git@main\n"
f" - If appropriate, mention they can also wait for the next official PyPI release\n"
f"4. Request feedback on whether the PR changes resolve the issue\n"
f"5. Thank 🙏 for reporting the issue and welcome any further feedback if the issue persists\n\n",
},
]
return get_response(messages)
def generate_pr_summary(repository, diff_text):
def generate_pr_summary(repository, diff_text, title="", description=""):
"""Generates a concise, professional summary of a PR using the OpenAI or Anthropic API."""
prompt, is_large, skipped_files = get_pr_summary_prompt(repository, diff_text)
prompt, is_large, skipped_files = get_pr_summary_prompt(repository, diff_text, title, description)
messages = [
{
@@ -108,10 +39,20 @@ def generate_pr_summary(repository, diff_text):
def label_fixed_issues(event, pr_summary):
"""Labels issues closed by PR when merged, notifies users, and returns PR contributors."""
pr_credit, data = event.get_pr_contributors()
if not pr_credit:
if not data:
return None
comment = generate_issue_comment(data["url"], pr_summary, pr_credit, data.get("title") or "")
credit = f" by {pr_credit}" if pr_credit else ""
synopsis = ""
if "### 🌟 Summary" in pr_summary and "### 📊 Key Changes" in pr_summary:
synopsis = pr_summary.split("### 🌟 Summary", 1)[1].split("### 📊 Key Changes", 1)[0].strip()
details = f"\n\n{synopsis}" if synopsis else ""
comment = (
f"A potential fix is now available in the [merged pull request]({data['url']}){credit}.{details}\n\n"
"Please test the merged change using "
"this repository's documented workflow. If the issue persists, "
"share the updated behavior and any new diagnostic details. Thank you for reporting it! 🙏"
)
for issue in data["closingIssuesReferences"]["nodes"]:
number = issue["number"]
@@ -136,11 +77,14 @@ def main(*args, **kwargs):
# Generate PR summary
print("Generating PR summary...")
summary = generate_pr_summary(event.repository, diff)
description = (event.pr.get("body") or "").split(SUMMARY_MARKER)[0]
summary = generate_pr_summary(
event.repository, diff, event.pr.get("title") or "", remove_html_comments(description)
)
# Update PR description
print("Updating PR description...")
event.update_pr_description(event.pr["number"], summary)
event.update_pr_description(event.pr["number"], summary, fallback_description=event.pr.get("body") or "")
if event.pr.get("merged"):
print("PR is merged, labeling fixed issues...")
@@ -149,9 +93,9 @@ def main(*args, **kwargs):
event.remove_labels(event.pr["number"], labels=("TODO",))
if pr_credit:
print("Posting PR author thank you message...")
pr_url = f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{event.pr['number']}"
message = generate_merge_message(summary, pr_credit, pr_url)
event.add_comment(event.pr["number"], None, message, "pull request")
event.add_comment(
event.pr["number"], None, f"🚀 Merged! Thank you {pr_credit} for the contribution.", "pull request"
)
if __name__ == "__main__":
+9 -8
View File
@@ -312,19 +312,20 @@ class Action:
print(result.get("errors"))
return result
def update_pr_description(self, number: int, new_summary: str, max_retries: int = 2):
"""Updates PR description with summary, retrying if description is None."""
def update_pr_description(
self, number: int, new_summary: str, max_retries: int = 2, fallback_description: str = ""
):
"""Update a PR description with a summary while preserving its current body."""
import time
url = f"{GITHUB_API_URL}/repos/{self.repository}/pulls/{number}"
description = ""
for i in range(max_retries + 1):
description = self.get(url).json().get("body") or ""
for _ in range(max_retries if not description and not fallback_description else 0):
time.sleep(1)
description = self.get(url).json().get("body") or ""
if description:
break
if i < max_retries:
print("No current PR description found, retrying...")
time.sleep(1)
description = description or fallback_description
start = "## 🛠️ PR Summary"
if start in description:
@@ -347,7 +348,7 @@ class Action:
def apply_labels(self, number: int, node_id: str, labels: list[str], issue_type: str):
"""Applies specified labels to a GitHub issue, pull request, or discussion."""
if "Alert" in labels:
if any(label.lower() == "alert" for label in labels):
self.create_alert_label()
if issue_type == "discussion":
+70 -50
View File
@@ -63,9 +63,9 @@ SYSTEM_PROMPT_ADDITION = """Guidance:
- Markdown: Reply in Markdown format.
- Links: Use descriptive anchor text for all URLs.
- Code:
- Provide minimal code examples if helpful.
- Enclose code in backticks: `pip install ultralytics` for inline code or e.g. ```python for larger code blocks.
- Think and verify the argument names, methods, class and files used in your code examples for accuracy.
- Provide minimal code examples only when supported by the repository context.
- Use inline backticks or a fenced block with the repository's appropriate language.
- Verify argument names, methods, classes, files, package managers, and commands before including them.
- Use the "@" symbol to refer to GitHub users, e.g. @glenn-jocher.
- Tone: Adopt a professional, friendly, and concise tone.
"""
@@ -89,28 +89,30 @@ def remove_outer_codeblocks(string):
def filter_labels(available_labels: dict, current_labels: list | None = None, is_pr: bool = False) -> dict:
"""Filters labels by removing manually-assigned and mutually exclusive labels, adding an Alert label if absent."""
current_labels = current_labels or []
filtered = available_labels.copy()
for label in (
current_labels = {label.lower() for label in (current_labels or [])}
excluded = {
"help wanted",
"TODO",
"todo",
"research",
"non-reproducible",
"popular",
"invalid",
"Stale",
"stale",
"wontfix",
"duplicate",
):
filtered.pop(label, None)
}
filtered = {
name: description
for name, description in available_labels.items()
if name.lower() not in excluded | current_labels
}
if "bug" in current_labels:
filtered.pop("question", None)
filtered = {name: description for name, description in filtered.items() if name.lower() != "question"}
elif "question" in current_labels:
filtered.pop("bug", None)
filtered = {name: description for name, description in filtered.items() if name.lower() != "bug"}
if "Alert" not in filtered:
if "alert" not in current_labels and not any(name.lower() == "alert" for name in filtered):
filtered["Alert"] = (
"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 "
@@ -122,39 +124,34 @@ def filter_labels(available_labels: dict, current_labels: list | None = None, is
def get_pr_summary_guidelines() -> str:
"""Returns PR summary formatting guidelines (used by both unified PR open and PR update/merge)."""
return """Summarize this 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. Your response must include all 3 sections below with their H3 Markdown headers (do not use H1 or H2 headers):
return """Summarize the implemented change, not the author's intentions. Ground every statement in the diff and PR context; never invent behavior, test results, compatibility claims, or user impact. Name the concrete components and behaviors changed, consolidate related edits, and omit file-by-file narration and generic praise. Use concise plain language for maintainers and users. Your response must include exactly these 3 sections with H3 Markdown headers (do not use H1 or H2 headers):
### 🌟 Summary
(single-line synopsis)
(one sentence stating what changed and why)
### 📊 Key Changes
- (bullet points highlighting major changes)
- (2-5 specific bullets, ordered by importance)
### 🎯 Purpose & Impact
- (bullet points explaining benefits and potential impact to users)"""
- (specific behavior or workflow impact; say "No user-facing change" when appropriate)"""
def get_pr_summary_prompt(repository: str, diff_text: str) -> tuple[str, bool, list[str]]:
def get_pr_summary_prompt(
repository: str, diff_text: str, title: str = "", description: str = ""
) -> tuple[str, bool, list[str]]:
"""Returns the complete PR summary generation prompt with filtered diff (used by PR update/merge)."""
filtered_diff, skipped_files = filter_diff_text(diff_text)
prompt = f"{get_pr_summary_guidelines()}\n\nRepository: '{repository}'\n\nHere's the PR diff:\n\n{filtered_diff[:MAX_PROMPT_CHARS]}"
prompt = (
f"{get_pr_summary_guidelines()}\n\nRepository: {repository}\nPR title: {title}\n"
f"PR description:\n{description[:8000]}\n\nPR diff:\n{filtered_diff[:MAX_PROMPT_CHARS]}"
)
prompt += format_skipped_files_note(skipped_files)
return prompt, len(filtered_diff) > MAX_PROMPT_CHARS, skipped_files
def get_pr_first_comment_template(repository: str, username: str) -> str:
"""Returns the PR first comment template with checklist (used only by unified PR open)."""
return f"""👋 Hello @{username}, thank you for submitting a `{repository}` 🚀 PR! To ensure a seamless integration of your work, please review the following checklist:
- ✅ **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/{repository}/issues). Ensure your commit messages are clear, concise, and adhere to the project's conventions.
- ✅ **Synchronize with Source**: Confirm your PR is synchronized with the `{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.
- ✅ **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.
- ✅ **Update Documentation**: Update the relevant [documentation](https://docs.ultralytics.com/) for any new or modified features.
- ✅ **Add Tests**: If applicable, include or update tests to cover your changes, and confirm that all tests are passing.
- ✅ **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.
- ✅ **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
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! 🚀"""
"""Return the concise acknowledgment that the PR-open model customizes."""
return f"👋 Thanks @{username} for the `{repository}` contribution! This is an automated first response; an Ultralytics engineer will review the PR soon."
def _is_anthropic_model(model: str) -> bool:
@@ -662,46 +659,69 @@ def get_response(
raise
def get_pr_open_response(repository: str, diff_text: str, title: str, username: str, available_labels: dict) -> dict:
def get_pr_open_response(
repository: str,
diff_text: str,
title: str,
username: str,
available_labels: dict,
description: str = "",
repository_context: str = "",
summarize: bool = True,
acknowledge: bool = True,
current_labels: list | None = None,
) -> dict:
"""Generates unified PR response with summary, labels, and first comment in a single API call."""
filtered_diff, skipped_files = filter_diff_text(diff_text)
is_large = len(filtered_diff) > MAX_PROMPT_CHARS
filtered_labels = filter_labels(available_labels, is_pr=True)
filtered_labels = filter_labels(available_labels, current_labels) if available_labels else {}
labels_str = "\n".join(f"- {name}: {description}" for name, description in filtered_labels.items())
summary_instructions = get_pr_summary_guidelines() if summarize else "Return an empty summary string."
label_instructions = (
"Array of 1-3 most relevant label names. Only use Alert with high confidence for inappropriate PRs. "
f"Return an empty array if no label is relevant. Available labels:\n{labels_str}"
if filtered_labels
else "Return an empty labels array."
)
comment_instructions = (
f"""Write a concise acknowledgment that:
- Opens with one specific sentence showing what the PR changes; do not repeat the generated summary
- Requests an action only when the description or diff provides concrete evidence it is missing
- Never claims tests passed, asks for generic checklist work, or assumes a language, package manager, branch name, or release process
- States that the response is automated and an Ultralytics engineer will review soon
- Uses at most 90 words, no headings, sign-off, inspirational quote, or external links
Base acknowledgment:
{get_pr_first_comment_template(repository, username)}"""
if acknowledge
else "Return an empty first_comment string."
)
prompt = f"""You are processing a new GitHub PR by @{username} for the {repository} repository.
Repository context: {repository_context or "No additional metadata provided."}
PR description: {description[:8000] or "No description provided."}
Generate 3 outputs in a single JSON response for the PR titled '{title}' with the following diff:
{filtered_diff[:MAX_PROMPT_CHARS]}{format_skipped_files_note(skipped_files)}
--- FIRST JSON OUTPUT (PR SUMMARY) ---
{get_pr_summary_guidelines()}
{summary_instructions}
--- SECOND JSON OUTPUT (PR LABELS) ---
Array of 1-3 most relevant label names. Only use "Alert" with high confidence for inappropriate PRs. Return empty array if no labels relevant. Available labels:
{labels_str}
{label_instructions}
--- THIRD OUTPUT (PR FIRST COMMENT) ---
Customized welcome message adapting the template below:
- INCLUDE ALL LINKS AND INSTRUCTIONS from the template below, customized as appropriate
- Keep all checklist items and links from template
- Only link to files or URLs in the template below, do not add external links
- Mention this is automated and an engineer will assist
- Use a few emojis
- No sign-off or "best regards"
- No spaces between bullet points
Example comment template (adapt as needed, keep all links):
{get_pr_first_comment_template(repository, username)}"""
--- THIRD JSON OUTPUT (PR FIRST COMMENT) ---
{comment_instructions}"""
schema = {
"type": "object",
"properties": {
"summary": {"type": "string", "description": "PR summary with emoji sections"},
"labels": {"type": "array", "items": {"type": "string"}, "description": "Array of label names"},
"first_comment": {"type": "string", "description": "Welcome comment with checklist"},
"first_comment": {"type": "string", "description": "Concise automated acknowledgment"},
},
"required": ["summary", "labels", "first_comment"],
"additionalProperties": False,
+5 -10
View File
@@ -33,20 +33,15 @@ runs:
- name: Install ultralytics-actions
env:
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_SHA: ${{ github.sha }}
ACTION_PATH: ${{ github.action_path }}/..
run: |
echo "::group::Install ultralytics-actions"
if [ "$GITHUB_REPOSITORY" = "ultralytics/actions" ]; then
echo "Installing from commit: $GITHUB_SHA"
packages="https://github.com/ultralytics/actions/archive/${GITHUB_SHA}.tar.gz"
else
packages="https://github.com/ultralytics/actions/archive/refs/heads/main.tar.gz"
fi
package_dir=$(mktemp -d "$RUNNER_TEMP/ultralytics-actions.XXXXXX")
uv build --wheel "$ACTION_PATH" --out-dir "$package_dir"
if [ "$(uname)" = "Darwin" ]; then
uv pip install --system --break-system-packages $packages
uv pip install --system --break-system-packages "$package_dir"/*.whl
else
sudo env "PATH=$PATH" uv pip install --system --break-system-packages $packages
sudo env "PATH=$PATH" uv pip install --system --break-system-packages "$package_dir"/*.whl
fi
echo "::endgroup::"
shell: bash
+5 -10
View File
@@ -49,20 +49,15 @@ runs:
- name: Install ultralytics-actions
env:
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_SHA: ${{ github.sha }}
ACTION_PATH: ${{ github.action_path }}/..
run: |
echo "::group::Install ultralytics-actions"
if [ "$GITHUB_REPOSITORY" = "ultralytics/actions" ]; then
echo "Installing from commit: $GITHUB_SHA"
packages="https://github.com/ultralytics/actions/archive/${GITHUB_SHA}.tar.gz"
else
packages="https://github.com/ultralytics/actions/archive/refs/heads/main.tar.gz"
fi
package_dir=$(mktemp -d "$RUNNER_TEMP/ultralytics-actions.XXXXXX")
uv build --wheel "$ACTION_PATH" --out-dir "$package_dir"
if [ "$(uname)" = "Darwin" ]; then
uv pip install --system --break-system-packages $packages
uv pip install --system --break-system-packages "$package_dir"/*.whl
else
sudo env "PATH=$PATH" uv pip install --system --break-system-packages $packages
sudo env "PATH=$PATH" uv pip install --system --break-system-packages "$package_dir"/*.whl
fi
echo "::endgroup::"
shell: bash
+1 -1
View File
@@ -1,6 +1,6 @@
# Setup uv Action
Installs the latest [uv](https://docs.astral.sh/uv/) release with retry support and optionally activates a Python virtual environment.
Provides Ultralytics defaults for the official [uv setup action](https://github.com/astral-sh/setup-uv), with optional Python environment activation and dependency caching.
## Usage
+7 -48
View File
@@ -1,7 +1,7 @@
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
name: "Setup uv"
description: "Install the latest uv with retry and optionally activate a Python environment"
description: "Install uv with optional Python environment activation and dependency caching"
inputs:
python-version:
description: "Python version for uv commands"
@@ -23,51 +23,10 @@ inputs:
runs:
using: "composite"
steps:
- name: Install uv
uses: ultralytics/actions/retry@main
- uses: astral-sh/[email protected]
with:
run: |
set -o pipefail
if [ "$RUNNER_OS" = "Windows" ]; then
powershell -NoProfile -ExecutionPolicy Bypass -Command '$env:UV_UNMANAGED_INSTALL = $env:RUNNER_TEMP + "\uv-bin"; irm https://astral.sh/uv/install.ps1 | iex'
echo "$RUNNER_TEMP\uv-bin" >> "$GITHUB_PATH"
else
curl -LsSf https://astral.sh/uv/install.sh | env UV_UNMANAGED_INSTALL="$RUNNER_TEMP/uv-bin" sh
echo "$RUNNER_TEMP/uv-bin" >> "$GITHUB_PATH"
fi
- name: Configure environment
shell: bash
env:
ACTIVATE_ENVIRONMENT: ${{ inputs.activate-environment }}
INPUT_PYTHON_VERSION: ${{ inputs.python-version }}
run: |
if [ -n "$INPUT_PYTHON_VERSION" ]; then
export UV_PYTHON="$INPUT_PYTHON_VERSION"
echo "UV_PYTHON=$INPUT_PYTHON_VERSION" >> "$GITHUB_ENV"
fi
if [ "$ACTIVATE_ENVIRONMENT" = "true" ]; then
uv venv --clear
if [ "$RUNNER_OS" = "Windows" ]; then
venv_path=$(cygpath -w "$PWD/.venv")
echo "VIRTUAL_ENV=$venv_path" >> "$GITHUB_ENV"
echo "$venv_path\Scripts" >> "$GITHUB_PATH"
else
echo "VIRTUAL_ENV=$PWD/.venv" >> "$GITHUB_ENV"
echo "$PWD/.venv/bin" >> "$GITHUB_PATH"
fi
fi
- name: Find uv cache
if: inputs.enable-cache == 'true'
id: uv-cache
shell: bash
run: echo "path=$(uv cache dir)" >> "$GITHUB_OUTPUT"
- name: Cache uv
if: inputs.enable-cache == 'true'
uses: actions/cache@v6
with:
path: ${{ steps.uv-cache.outputs.path }}
key: uv-${{ runner.os }}-${{ runner.arch }}-${{ inputs.python-version }}-${{ hashFiles(inputs.cache-dependency-glob) }}
restore-keys: uv-${{ runner.os }}-${{ runner.arch }}-${{ inputs.python-version }}-
python-version: ${{ inputs.python-version }}
activate-environment: ${{ inputs.activate-environment }}
enable-cache: ${{ inputs.enable-cache }}
cache-dependency-glob: ${{ inputs.cache-dependency-glob }}
ignore-nothing-to-cache: true
+33 -38
View File
@@ -5,7 +5,7 @@ from unittest.mock import MagicMock, call, patch
import pytest
from actions import first_interaction, review_pr
from actions.first_interaction import get_event_content, get_first_interaction_response, get_relevant_labels
from actions.first_interaction import get_event_content, get_first_interaction_response
def test_get_event_content_issue():
@@ -40,15 +40,15 @@ def test_get_event_content_pr():
# Create mock event
mock_event = MagicMock()
mock_event.event_name = "pull_request"
mock_event.event_data = {"action": "opened", "pull_request": {"number": 456}}
# Mock PR data returned from API
mock_event.get_repo_data.return_value = {
"number": 456,
"node_id": "node456",
"title": "Test PR",
"body": "PR description",
"user": {"login": "testuser"},
mock_event.event_data = {
"action": "opened",
"pull_request": {
"number": 456,
"node_id": "node456",
"title": "Test PR",
"body": "PR description",
"user": {"login": "testuser"},
},
}
number, node_id, title, body, username, issue_type, action = get_event_content(mock_event)
@@ -60,6 +60,7 @@ def test_get_event_content_pr():
assert username == "testuser"
assert issue_type == "pull request"
assert action == "opened"
mock_event.get_repo_data.assert_not_called()
@patch("actions.first_interaction.review_pr.run_review")
@@ -86,7 +87,8 @@ def test_open_pr_review_description(mock_action, mock_content, mock_response, mo
event = mock_action.return_value
event.should_skip_llm.return_value = False
event.should_skip_pr_author.return_value = False
event.get_repo_data.return_value = []
event.paginate.return_value = []
event.pr = {"body": body}
event.get_pr_diff.return_value = "diff"
event.get_pr_diff_snapshot.return_value = ("review diff", "headsha")
@@ -95,43 +97,36 @@ def test_open_pr_review_description(mock_action, mock_content, mock_response, mo
mock_review.assert_called_once_with(event, "Test PR", expected)
@patch("actions.first_interaction.check_links_in_string", return_value="Thank you for your issue")
@patch("actions.first_interaction.get_response")
def test_get_relevant_labels(mock_get_response):
"""Test getting relevant labels for an issue."""
mock_get_response.return_value = "bug, enhancement"
available_labels = {
"bug": "A bug in the software",
"enhancement": "New feature or request",
"documentation": "Improvements to documentation",
def test_get_first_interaction_response(mock_get_response, mock_check_links):
"""Test labels and first response share one structured model call."""
mock_get_response.return_value = {
"labels": ["BUG", "not-a-label"],
"first_comment": "Thank you for your issue",
}
labels = get_relevant_labels(
issue_type="issue",
title="Bug: App crashes on startup",
body="The application crashes when started",
available_labels=available_labels,
current_labels=[],
)
assert labels == ["bug", "enhancement"]
mock_get_response.assert_called_once()
@patch("actions.first_interaction.get_response")
def test_get_first_interaction_response(mock_get_response):
"""Test generating first interaction response."""
mock_get_response.return_value = "Thank you for your issue"
mock_event = MagicMock()
mock_event.repository = "test/repo"
response = get_first_interaction_response(
event=mock_event, issue_type="issue", title="Test Issue", body="Issue description", username="testuser"
event=mock_event,
issue_type="issue",
title="Test Issue",
body="Issue description",
username="testuser",
available_labels={"bug": "Something is broken"},
current_labels=[],
repository_context="A test repository; primary language: Rust",
)
assert response == "Thank you for your issue"
assert response == {"labels": ["bug"], "first_comment": "Thank you for your issue"}
mock_get_response.assert_called_once()
mock_check_links.assert_called_once_with("Thank you for your issue", replace=True)
assert mock_get_response.call_args.kwargs["text_format"]["format"]["type"] == "json_schema"
prompt = mock_get_response.call_args.args[0][1]["content"]
assert "primary language: Rust" in prompt
assert "never assume Python, pip, PyPI" in prompt
@patch("actions.review_pr._verified_local_checkout", return_value=True)
+19 -32
View File
@@ -1,8 +1,8 @@
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
from unittest.mock import patch
from unittest.mock import MagicMock, patch
from actions.summarize_pr import generate_issue_comment, generate_merge_message, generate_pr_summary
from actions.summarize_pr import generate_pr_summary, label_fixed_issues
@patch("actions.summarize_pr.get_response")
@@ -16,35 +16,22 @@ def test_generate_pr_summary(mock_get_response):
mock_get_response.assert_called_once()
@patch("actions.summarize_pr.get_response")
def test_generate_merge_message(mock_get_response):
"""Test generating merge thank you messages."""
mock_get_response.return_value = "Thank you for your contribution!"
message = generate_merge_message(
pr_summary="Feature implementation", pr_credit="@testuser", pr_url="https://github.com/test/repo/pull/1"
def test_label_fixed_issues_posts_repository_neutral_comment():
"""Test merged-issue responses avoid language, package-manager, and branch assumptions."""
event = MagicMock(repository="owner/swift-app")
event.get_pr_contributors.return_value = (
"@testuser",
{
"url": "https://github.com/owner/swift-app/pull/123",
"title": "Fix launch crash",
"closingIssuesReferences": {"nodes": [{"number": 7}]},
},
)
assert message == "Thank you for your contribution!"
mock_get_response.assert_called_once()
@patch("actions.summarize_pr.get_response")
def test_generate_issue_comment(mock_get_response):
"""Test generating issue comments about PR fixes."""
mock_get_response.return_value = "This issue is fixed in PR #123"
for pr_url in ("https://github.com/owner/repo/pull/123", "https://api.github.com/repos/owner/repo/pulls/123"):
comment = generate_issue_comment(
pr_url=pr_url,
pr_summary="Fixed bug",
pr_credit="@testuser",
pr_title="Bug fix PR",
)
assert comment == "This issue is fixed in PR #123"
prompt = mock_get_response.call_args[0][0][1]["content"]
assert "pip install -U repo>=VERSION" in prompt
assert "pip install git+https://github.com/owner/repo.git@main" in prompt
assert mock_get_response.call_count == 2
summary = "## 🛠️ PR Summary\n\n### 🌟 Summary\nFixed launch handling.\n\n### 📊 Key Changes\n- Safer startup"
assert label_fixed_issues(event, summary) == "@testuser"
comment = event.post.call_args_list[1].kwargs["json"]["body"]
assert "[merged pull request](https://github.com/owner/swift-app/pull/123)" in comment
assert "documented workflow" in comment
assert "Fixed launch handling." in comment
assert all(term not in comment for term in ("pip", "PyPI", "@main"))