Improve GitHub automation quality and efficiency (#863)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -28,4 +28,4 @@
|
||||
# ├── test_summarize_pr.py
|
||||
# └── ...
|
||||
|
||||
__version__ = "0.3.1"
|
||||
__version__ = "0.3.2"
|
||||
|
||||
+130
-124
@@ -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
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user