diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index bd179cb..fe02d66 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 3eb92b8..f51c628 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 03088d6..b141ec2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.zh-CN.md b/README.zh-CN.md index 7ef5dab..143b3d6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -186,7 +186,7 @@ jobs: ### 6. Setup uv Action -安装最新版 uv 并支持重试,同时可选择激活 Python 环境。 +使用带 Ultralytics 默认配置的官方 uv setup action,并可选择激活 Python 环境和启用依赖缓存。 ```yaml - uses: ultralytics/actions/setup-uv@main diff --git a/action.yml b/action.yml index 85c3ab1..f75c45c 100644 --- a/action.yml +++ b/action.yml @@ -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/lychee-action@v2.9.0 with: # Check all markdown and html files in repo. Ignores the following status codes to reduce false positives: diff --git a/actions/__init__.py b/actions/__init__.py index 01a87ba..e9e67eb 100644 --- a/actions/__init__.py +++ b/actions/__init__.py @@ -28,4 +28,4 @@ # ├── test_summarize_pr.py # └── ... -__version__ = "0.3.1" +__version__ = "0.3.2" diff --git a/actions/first_interaction.py b/actions/first_interaction.py index 337bc71..a67f47f 100644 --- a/actions/first_interaction.py +++ b/actions/first_interaction.py @@ -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) diff --git a/actions/review_pr.py b/actions/review_pr.py index 1adb465..1381837 100644 --- a/actions/review_pr.py +++ b/actions/review_pr.py @@ -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}" diff --git a/actions/summarize_pr.py b/actions/summarize_pr.py index 43d3303..851d861 100644 --- a/actions/summarize_pr.py +++ b/actions/summarize_pr.py @@ -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__": diff --git a/actions/utils/github_utils.py b/actions/utils/github_utils.py index 289440b..3af2c6f 100644 --- a/actions/utils/github_utils.py +++ b/actions/utils/github_utils.py @@ -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": diff --git a/actions/utils/openai_utils.py b/actions/utils/openai_utils.py index 73bfe99..cfc5570 100644 --- a/actions/utils/openai_utils.py +++ b/actions/utils/openai_utils.py @@ -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, diff --git a/dependabot/action.yml b/dependabot/action.yml index 2c7eeed..aca70da 100644 --- a/dependabot/action.yml +++ b/dependabot/action.yml @@ -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 diff --git a/github-report/action.yml b/github-report/action.yml index fed3a59..b4b37d9 100644 --- a/github-report/action.yml +++ b/github-report/action.yml @@ -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 diff --git a/setup-uv/README.md b/setup-uv/README.md index 7c15094..0faa867 100644 --- a/setup-uv/README.md +++ b/setup-uv/README.md @@ -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 diff --git a/setup-uv/action.yml b/setup-uv/action.yml index 044c7ab..eb0c313 100644 --- a/setup-uv/action.yml +++ b/setup-uv/action.yml @@ -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/setup-uv@v9.0.0 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 diff --git a/tests/test_first_interaction.py b/tests/test_first_interaction.py index c90f9eb..44cffc1 100644 --- a/tests/test_first_interaction.py +++ b/tests/test_first_interaction.py @@ -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) diff --git a/tests/test_summarize_pr.py b/tests/test_summarize_pr.py index 751a4e2..ea294c3 100644 --- a/tests/test_summarize_pr.py +++ b/tests/test_summarize_pr.py @@ -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"))