From 02941a65a48046e6657db11eb3e6f142a2e154ea Mon Sep 17 00:00:00 2001 From: UltralyticsAssistant Date: Wed, 16 Oct 2024 15:38:33 +0200 Subject: [PATCH 1/9] Update actions package --- actions/utils/github_utils.py | 49 +++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/actions/utils/github_utils.py b/actions/utils/github_utils.py index 6c4b6cfc..fd80a473 100644 --- a/actions/utils/github_utils.py +++ b/actions/utils/github_utils.py @@ -1,6 +1,7 @@ # Ultralytics Actions πŸš€, AGPL-3.0 license https://ultralytics.com/license import os +import tomllib # requires Python>=3.11 import requests @@ -42,3 +43,51 @@ def graphql_request(query: str, variables: dict = None) -> dict: success = "data" in result and not result.get("errors") print(f"{'Successful' if success else 'Fail'} discussion GraphQL request: {result.get('errors', 'No errors')}") return result + + +def check_pypi_version(): + """Compares local and PyPI versions of a package to determine if a new version should be published.""" + with open('pyproject.toml', 'rb') as f: + pyproject = tomllib.load(f) + + package_name = pyproject['project']['name'] + local_version = pyproject['project'].get('version', 'dynamic') + + # If version is dynamic, extract it from the specified file + if local_version == 'dynamic': + version_attr = pyproject['tool']['setuptools']['dynamic']['version']['attr'] + module_path, attr_name = version_attr.rsplit('.', 1) + with open(f"{module_path.replace('.', '/')}/__init__.py") as f: + local_version = next(line.split('=')[1].strip().strip("'\"") for line in f if line.startswith(attr_name)) + + print(f"Local Version: {local_version}") + + # Get online version from PyPI + response = requests.get(f"https://pypi.org/pypi/{package_name}/json") + online_version = response.json()['info']['version'] if response.status_code == 200 else None + print(f"Online Version: {online_version or 'Not Found'}") + + # Determine if a new version should be published + publish = False + if online_version: + local_ver = tuple(map(int, local_version.split('.'))) + online_ver = tuple(map(int, online_version.split('.'))) + major_diff = local_ver[0] - online_ver[0] + minor_diff = local_ver[1] - online_ver[1] + patch_diff = local_ver[2] - online_ver[2] + + publish = ( + (major_diff == 0 and minor_diff == 0 and 0 < patch_diff <= 2) or + (major_diff == 0 and minor_diff == 1 and local_ver[2] == 0) or + (major_diff == 1 and local_ver[1] == 0 and local_ver[2] == 0) + ) + else: + publish = True # First release + + os.system(f'echo "increment={publish}" >> $GITHUB_OUTPUT') + os.system(f'echo "current_tag=v{local_version}" >> $GITHUB_OUTPUT') + os.system(f'echo "previous_tag=v{online_version}" >> $GITHUB_OUTPUT') + if publish: + print('Ready to publish new version to PyPI βœ….') + + return local_version, online_version, publish From 2cee52252c4e6f8d79990f8b0a9ee42a0ca4a325 Mon Sep 17 00:00:00 2001 From: UltralyticsAssistant Date: Wed, 16 Oct 2024 15:40:30 +0200 Subject: [PATCH 2/9] Update actions package --- actions/utils/github_utils.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/actions/utils/github_utils.py b/actions/utils/github_utils.py index fd80a473..a615a246 100644 --- a/actions/utils/github_utils.py +++ b/actions/utils/github_utils.py @@ -1,7 +1,6 @@ # Ultralytics Actions πŸš€, AGPL-3.0 license https://ultralytics.com/license import os -import tomllib # requires Python>=3.11 import requests @@ -45,9 +44,11 @@ def graphql_request(query: str, variables: dict = None) -> dict: return result -def check_pypi_version(): +def check_pypi_version(pyproject_toml='pyproject.toml'): """Compares local and PyPI versions of a package to determine if a new version should be published.""" - with open('pyproject.toml', 'rb') as f: + import tomllib # requires Python>=3.11 + + with open(pyproject_toml, 'rb') as f: pyproject = tomllib.load(f) package_name = pyproject['project']['name'] From 005862bf817bf40367efbfe3820bff7caf4364be Mon Sep 17 00:00:00 2001 From: UltralyticsAssistant Date: Wed, 16 Oct 2024 15:44:19 +0200 Subject: [PATCH 3/9] Update actions package --- .github/workflows/publish.yml | 42 ++--------------------------------- actions/utils/__init__.py | 2 ++ 2 files changed, 4 insertions(+), 40 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5d2204ba..c7c96794 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -41,49 +41,11 @@ jobs: shell: python run: | import os - import requests - import toml - - # Load version and package name from pyproject.toml - pyproject = toml.load('pyproject.toml') - package_name = pyproject['project']['name'] - local_version = pyproject['project'].get('version', 'dynamic') - - # If version is dynamic, extract it from the specified file - if local_version == 'dynamic': - version_attr = pyproject['tool']['setuptools']['dynamic']['version']['attr'] - module_path, attr_name = version_attr.rsplit('.', 1) - with open(f"{module_path.replace('.', '/')}/__init__.py") as f: - local_version = next(line.split('=')[1].strip().strip("'\"") for line in f if line.startswith(attr_name)) - - print(f"Local Version: {local_version}") - - # Get online version from PyPI - response = requests.get(f"https://pypi.org/pypi/{package_name}/json") - online_version = response.json()['info']['version'] if response.status_code == 200 else None - print(f"Online Version: {online_version or 'Not Found'}") - - # Determine if a new version should be published - publish = False - if online_version: - local_ver = tuple(map(int, local_version.split('.'))) - online_ver = tuple(map(int, online_version.split('.'))) - major_diff = local_ver[0] - online_ver[0] - minor_diff = local_ver[1] - online_ver[1] - patch_diff = local_ver[2] - online_ver[2] - - publish = ( - (major_diff == 0 and minor_diff == 0 and 0 < patch_diff <= 2) or - (major_diff == 0 and minor_diff == 1 and local_ver[2] == 0) or - (major_diff == 1 and local_ver[1] == 0 and local_ver[2] == 0) - ) - else: - publish = True # First release - + from actions.utils import check_pypi_version + local_version, online_version, publish = check_pypi_version() os.system(f'echo "increment={publish}" >> $GITHUB_OUTPUT') os.system(f'echo "current_tag=v{local_version}" >> $GITHUB_OUTPUT') os.system(f'echo "previous_tag=v{online_version}" >> $GITHUB_OUTPUT') - if publish: print('Ready to publish new version to PyPI βœ….') id: check_pypi diff --git a/actions/utils/__init__.py b/actions/utils/__init__.py index cf6ba14c..8fbeb2eb 100644 --- a/actions/utils/__init__.py +++ b/actions/utils/__init__.py @@ -13,6 +13,7 @@ get_github_data, get_pr_diff, graphql_request, + check_pypi_version, ) from .openai_utils import OPENAI_API_KEY, OPENAI_MODEL, get_completion @@ -32,4 +33,5 @@ "OPENAI_API_KEY", "OPENAI_MODEL", "get_completion", + "check_pypi_version", ) From c0da5b877d0898fa628a5ad2019bdaf8c5c1868e Mon Sep 17 00:00:00 2001 From: UltralyticsAssistant Date: Wed, 16 Oct 2024 15:48:24 +0200 Subject: [PATCH 4/9] Update actions package --- actions/__init__.py | 8 +- actions/first_interaction.py | 24 +- actions/summarize_pr.py | 6 +- actions/summarize_release.py | 12 +- actions/update_markdown_code_blocks.py | 16 +- actions/utils/github_utils.py | 2 +- utils/first_interaction.py | 450 ------------------------- utils/summarize_pr.py | 108 ------ utils/summarize_release.py | 208 ------------ utils/update_markdown_code_blocks.py | 177 ---------- 10 files changed, 31 insertions(+), 980 deletions(-) delete mode 100644 utils/first_interaction.py delete mode 100644 utils/summarize_pr.py delete mode 100644 utils/summarize_release.py delete mode 100644 utils/update_markdown_code_blocks.py diff --git a/actions/__init__.py b/actions/__init__.py index 2261f1dd..d3393d36 100644 --- a/actions/__init__.py +++ b/actions/__init__.py @@ -22,10 +22,4 @@ # β”œβ”€β”€ test_summarize_pr.py # └── ... -from .first_interaction import main as first_interaction_main -from .summarize_pr import main as summarize_pr_main -from .summarize_release import main as summarize_release_main -from .update_markdown_code_blocks import main as update_markdown_code_blocks_main - -__all__ = ["first_interaction_main", "summarize_pr_main", "summarize_release_main", "update_markdown_code_blocks_main"] -__version__ = "0.0.5" +__version__ = "0.0.6" diff --git a/actions/first_interaction.py b/actions/first_interaction.py index ddfe2b40..9b91adc8 100644 --- a/actions/first_interaction.py +++ b/actions/first_interaction.py @@ -24,7 +24,7 @@ def get_event_content() -> Tuple[int, str, str, str, str, str, str]: - """Extracts the number, node_id, title, body, username, and issue_type.""" + """Extracts key information from GitHub event data for issues, pull requests, or discussions.""" with open(GITHUB_EVENT_PATH) as f: data = json.load(f) action = data["action"] # 'opened', 'closed', 'created' (discussion), etc. @@ -50,7 +50,7 @@ def get_event_content() -> Tuple[int, str, str, str, str, str, str]: def update_issue_pr_content(number: int, node_id: str, issue_type: str): - """Updates the title and body of the issue, pull request, or discussion.""" + """Updates the title and body of an issue, pull request, or discussion with predefined content.""" new_title = "Content Under Review" new_body = """This post has been flagged for review by [Ultralytics Actions](https://ultralytics.com/actions) due to possible spam, abuse, or off-topic content. For more information please see our: @@ -79,7 +79,7 @@ def update_issue_pr_content(number: int, node_id: str, issue_type: str): def close_issue_pr(number: int, node_id: str, issue_type: str): - """Closes the issue, pull request, or discussion.""" + """Closes the specified issue, pull request, or discussion using the GitHub API.""" if issue_type == "discussion": mutation = """ mutation($discussionId: ID!) { @@ -98,7 +98,7 @@ def close_issue_pr(number: int, node_id: str, issue_type: str): def lock_issue_pr(number: int, node_id: str, issue_type: str): - """Locks the issue, pull request, or discussion.""" + """Locks an issue, pull request, or discussion to prevent further interactions.""" if issue_type == "discussion": mutation = """ mutation($lockableId: ID!, $lockReason: LockReason) { @@ -119,7 +119,7 @@ def lock_issue_pr(number: int, node_id: str, issue_type: str): def block_user(username: str): - """Blocks a user from the organization.""" + """Blocks a user from the organization using the GitHub API.""" url = f"{GITHUB_API_URL}/orgs/{REPO_NAME.split('/')[0]}/blocks/{username}" r = requests.put(url, headers=GITHUB_HEADERS) print(f"{'Successful' if r.status_code == 204 else 'Fail'} user block for {username}: {r.status_code}") @@ -128,7 +128,7 @@ def block_user(username: str): def get_relevant_labels( issue_type: str, title: str, body: str, available_labels: Dict, current_labels: List ) -> List[str]: - """Uses OpenAI to determine the most relevant labels.""" + """Determines relevant labels for GitHub issues/PRs using OpenAI, considering title, body, and existing labels.""" # Remove mutually exclusive labels like both 'bug' and 'question' or inappropriate labels like 'help wanted' for label in ["help wanted", "TODO"]: # normal case available_labels.pop(label, None) # remove as should only be manually added @@ -212,7 +212,7 @@ def get_label_ids(labels: List[str]) -> List[str]: def apply_labels(number: int, node_id: str, labels: List[str], issue_type: str): - """Applies the given labels to the issue, pull request, or discussion.""" + """Applies specified labels to a GitHub issue, pull request, or discussion using the appropriate API.""" if "Alert" in labels: create_alert_label() @@ -243,13 +243,13 @@ def apply_labels(number: int, node_id: str, labels: List[str], issue_type: str): def create_alert_label(): - """Creates the 'Alert' label in the repository if it doesn't exist.""" + """Creates the 'Alert' label in the repository if it doesn't exist, with a red color and description.""" alert_label = {"name": "Alert", "color": "FF0000", "description": "Potential spam, abuse, or off-topic."} requests.post(f"{GITHUB_API_URL}/repos/{REPO_NAME}/labels", json=alert_label, headers=GITHUB_HEADERS) def is_org_member(username: str) -> bool: - """Checks if a user is a member of the organization.""" + """Checks if a user is a member of the organization using the GitHub API.""" org_name = REPO_NAME.split("/")[0] url = f"{GITHUB_API_URL}/orgs/{org_name}/members/{username}" r = requests.get(url, headers=GITHUB_HEADERS) @@ -257,7 +257,7 @@ def is_org_member(username: str) -> bool: def add_comment(number: int, node_id: str, comment: str, issue_type: str): - """Adds a comment to the issue, pull request, or discussion.""" + """Adds a comment to the specified issue, pull request, or discussion using the GitHub API.""" if issue_type == "discussion": mutation = """ mutation($discussionId: ID!, $body: String!) { @@ -276,7 +276,7 @@ def add_comment(number: int, node_id: str, comment: str, issue_type: str): def get_first_interaction_response(issue_type: str, title: str, body: str, username: str, number: int) -> str: - """Generates a custom response using LLM based on the issue/PR content and instructions.""" + """Generates a custom LLM response for GitHub issues, PRs, or discussions based on content.""" issue_discussion_response = f""" πŸ‘‹ Hello @{username}, thank you for submitting a `{REPO_NAME}` πŸš€ {issue_type.capitalize()}. To help us address your concern efficiently, please ensure you've provided the following information: @@ -370,7 +370,7 @@ def get_first_interaction_response(issue_type: str, title: str, body: str, usern def main(): - """Runs autolabel action and adds custom response for new issues/PRs/Discussions.""" + """Executes autolabeling and custom response generation for new GitHub issues, PRs, and discussions.""" number, node_id, title, body, username, issue_type, action = get_event_content() available_labels = get_github_data("labels") label_descriptions = {label["name"]: label.get("description", "") for label in available_labels} diff --git a/actions/summarize_pr.py b/actions/summarize_pr.py index 6fa34465..73bf3325 100644 --- a/actions/summarize_pr.py +++ b/actions/summarize_pr.py @@ -18,7 +18,7 @@ def generate_pr_summary(repo_name, diff_text): - """Generates a professionally written yet accessible summary of a PR using OpenAI's API.""" + """Generates a concise, professional summary of a PR using OpenAI's API for Ultralytics repositories.""" if not diff_text: diff_text = "**ERROR: DIFF IS EMPTY, THERE ARE ZERO CODE CHANGES IN THIS PR." ratio = 3.3 # about 3.3 characters per token @@ -45,7 +45,7 @@ def generate_pr_summary(repo_name, diff_text): def update_pr_description(repo_name, pr_number, new_summary): - """Updates the original PR description with a new summary, replacing an existing summary if found.""" + """Updates the PR description with a new summary, replacing existing summary if present.""" # Fetch the current PR description pr_url = f"{GITHUB_API_URL}/repos/{repo_name}/pulls/{pr_number}" pr_response = requests.get(pr_url, headers=GITHUB_HEADERS) @@ -64,7 +64,7 @@ def update_pr_description(repo_name, pr_number, new_summary): def main(): - """Summarize PR.""" + """Summarize a pull request and update its description with an AI-generated summary.""" diff = get_pr_diff(PR_NUMBER) # Generate PR summary diff --git a/actions/summarize_release.py b/actions/summarize_release.py index 424c3d15..4a1afe5d 100644 --- a/actions/summarize_release.py +++ b/actions/summarize_release.py @@ -24,14 +24,14 @@ def get_release_diff(repo_name: str, previous_tag: str, latest_tag: str) -> str: - """Get the diff between two tags.""" + """Retrieves the differences between two specified Git tags in a GitHub repository.""" url = f"{GITHUB_API_URL}/repos/{repo_name}/compare/{previous_tag}...{latest_tag}" r = requests.get(url, headers=GITHUB_HEADERS_DIFF) return r.text if r.status_code == 200 else f"Failed to get diff: {r.content}" def get_prs_between_tags(repo_name: str, previous_tag: str, latest_tag: str) -> list: - """Get PRs merged between two tags using the compare API.""" + """Retrieves and processes pull requests merged between two specified tags in a GitHub repository.""" url = f"{GITHUB_API_URL}/repos/{repo_name}/compare/{previous_tag}...{latest_tag}" r = requests.get(url, headers=GITHUB_HEADERS) r.raise_for_status() @@ -68,7 +68,7 @@ def get_prs_between_tags(repo_name: str, previous_tag: str, latest_tag: str) -> def get_new_contributors(repo: str, prs: list) -> set: - """Identify genuinely new contributors in the current release.""" + """Identify new contributors who made their first merged PR in the current release.""" new_contributors = set() for pr in prs: author = pr["author"] @@ -85,7 +85,7 @@ def get_new_contributors(repo: str, prs: list) -> set: def generate_release_summary(diff: str, prs: list, latest_tag: str, previous_tag: str, repo_name: str) -> str: - """Generate a summary for the release.""" + """Generate a concise release summary with key changes, purpose, and impact for a new Ultralytics version.""" pr_summaries = "\n\n".join( [f"PR #{pr['number']}: {pr['title']} by @{pr['author']}\n{pr['body'][:1000]}" for pr in prs] ) @@ -139,7 +139,7 @@ def generate_release_summary(diff: str, prs: list, latest_tag: str, previous_tag def create_github_release(repo_name: str, tag_name: str, name: str, body: str) -> int: - """Create a release on GitHub.""" + """Creates a GitHub release with specified tag, name, and body content for the given repository.""" url = f"{GITHUB_API_URL}/repos/{repo_name}/releases" data = {"tag_name": tag_name, "name": name, "body": body, "draft": False, "prerelease": False} r = requests.post(url, headers=GITHUB_HEADERS, json=data) @@ -147,7 +147,7 @@ def create_github_release(repo_name: str, tag_name: str, name: str, body: str) - def get_previous_tag() -> str: - """Get the previous tag from git tags.""" + """Retrieves the previous Git tag, excluding the current tag, using the git describe command.""" cmd = ["git", "describe", "--tags", "--abbrev=0", "--exclude", CURRENT_TAG] try: return subprocess.run(cmd, check=True, text=True, capture_output=True).stdout.strip() diff --git a/actions/update_markdown_code_blocks.py b/actions/update_markdown_code_blocks.py index 16b4c0a8..28ff7b53 100644 --- a/actions/update_markdown_code_blocks.py +++ b/actions/update_markdown_code_blocks.py @@ -8,21 +8,21 @@ def extract_code_blocks(markdown_content): - """Extract Python code blocks with ``` followed by "python", "py", or "{ .py .annotate }".""" + """Extracts Python code blocks from markdown content using regex pattern matching.""" pattern = r"^( *)```(?:python|py|\{[ ]*\.py[ ]*\.annotate[ ]*\})\n(.*?)\n\1```" code_block_pattern = re.compile(pattern, re.DOTALL | re.MULTILINE) return code_block_pattern.findall(markdown_content) def remove_indentation(code_block, num_spaces): - """Removes `num_spaces` leading spaces from each line in `code_block` and returns the modified string.""" + """Removes specified leading spaces from each line in a code block to adjust indentation.""" lines = code_block.split("\n") stripped_lines = [line[num_spaces:] if len(line) >= num_spaces else line for line in lines] return "\n".join(stripped_lines) def add_indentation(code_block, num_spaces): - """Adds `num_spaces` leading spaces to each non-empty line in `code_block`.""" + """Adds specified number of leading spaces to non-empty lines in a code block.""" indent = " " * num_spaces lines = code_block.split("\n") indented_lines = [indent + line if line.strip() != "" else line for line in lines] @@ -30,7 +30,7 @@ def add_indentation(code_block, num_spaces): def format_code_with_ruff(temp_dir): - """Formats all Python code files in the `temp_dir` directory using the 'ruff' linter tool.""" + """Formats Python code files in the specified directory using ruff linter and docformatter tools.""" try: # Run ruff format subprocess.run( @@ -86,14 +86,14 @@ def format_code_with_ruff(temp_dir): def generate_temp_filename(file_path, index): - """Generates a unique temporary filename based on the file path and index.""" + """Generates a unique temporary filename using a hash of the file path and index.""" unique_string = f"{file_path.parent}_{file_path.stem}_{index}" unique_hash = hashlib.md5(unique_string.encode()).hexdigest() return f"temp_{unique_hash}.py" def process_markdown_file(file_path, temp_dir, verbose=False): - """Reads a markdown file, extracts Python code blocks, saves them to temp files, and updates the file.""" + """Processes a markdown file, extracting Python code blocks for formatting and updating the original file.""" try: markdown_content = Path(file_path).read_text() code_blocks = extract_code_blocks(markdown_content) @@ -119,7 +119,7 @@ def process_markdown_file(file_path, temp_dir, verbose=False): def update_markdown_file(file_path, markdown_content, temp_files): - """Updates the markdown file with formatted code blocks.""" + """Updates a markdown file with formatted Python code blocks extracted and processed externally.""" for num_spaces, original_code_block, temp_file_path in temp_files: try: with open(temp_file_path) as temp_file: @@ -143,7 +143,7 @@ def update_markdown_file(file_path, markdown_content, temp_files): def main(root_dir=Path.cwd(), verbose=False): - """Processes all markdown files in a specified directory and its subdirectories.""" + """Processes markdown files, extracts and formats Python code blocks, and updates the original files.""" root_path = Path(root_dir) markdown_files = list(root_path.rglob("*.md")) temp_dir = Path("temp_code_blocks") diff --git a/actions/utils/github_utils.py b/actions/utils/github_utils.py index a615a246..0ce4cf07 100644 --- a/actions/utils/github_utils.py +++ b/actions/utils/github_utils.py @@ -45,7 +45,7 @@ def graphql_request(query: str, variables: dict = None) -> dict: def check_pypi_version(pyproject_toml='pyproject.toml'): - """Compares local and PyPI versions of a package to determine if a new version should be published.""" + """Compares local and PyPI package versions to determine if a new version should be published.""" import tomllib # requires Python>=3.11 with open(pyproject_toml, 'rb') as f: diff --git a/utils/first_interaction.py b/utils/first_interaction.py deleted file mode 100644 index c8a4bde1..00000000 --- a/utils/first_interaction.py +++ /dev/null @@ -1,450 +0,0 @@ -# Ultralytics YOLO πŸš€, AGPL-3.0 License https://ultralytics.com/license - -import json -import os -import re -from typing import Dict, List, Tuple - -import requests - -# Environment variables -REPO_NAME = os.getenv("GITHUB_REPOSITORY") -GITHUB_TOKEN = os.getenv("GITHUB_TOKEN") -GITHUB_EVENT_NAME = os.getenv("GITHUB_EVENT_NAME") -GITHUB_EVENT_PATH = os.getenv("GITHUB_EVENT_PATH") -GITHUB_API_URL = "https://api.github.com" -GITHUB_HEADERS = {"Authorization": f"token {GITHUB_TOKEN}", "Accept": "application/vnd.github.v3+json"} -BLOCK_USER = os.getenv("BLOCK_USER", "false").lower() == "true" - -# OpenAI settings -OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o") # update as required -OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") - - -def remove_html_comments(body: str) -> str: - """Removes HTML comment blocks from the body text.""" - return re.sub(r"", "", body, flags=re.DOTALL).strip() - - -def get_completion(messages: list) -> str: - """Get chat completion from OpenAI.""" - assert OPENAI_API_KEY, "OpenAI API key is required." - url = "https://api.openai.com/v1/chat/completions" - headers = {"Authorization": f"Bearer {OPENAI_API_KEY}", "Content-Type": "application/json"} - data = {"model": OPENAI_MODEL, "messages": messages} - - r = requests.post(url, headers=headers, json=data) - r.raise_for_status() - content = r.json()["choices"][0]["message"]["content"].strip() - remove = [" @giscus[bot]"] - for x in remove: - content = content.replace(x, "") - return content - - -def get_pr_diff(pr_number): - """Fetches the diff of a specific PR from a GitHub repository.""" - url = f"{GITHUB_API_URL}/repos/{REPO_NAME}/pulls/{pr_number}" - headers = {"Authorization": f"token {GITHUB_TOKEN}", "Accept": "application/vnd.github.v3.diff"} - r = requests.get(url, headers=headers) - return r.text if r.status_code == 200 else "" - - -def get_github_data(endpoint: str) -> dict: - """Generic function to fetch data from GitHub API.""" - r = requests.get(f"{GITHUB_API_URL}/repos/{REPO_NAME}/{endpoint}", headers=GITHUB_HEADERS) - r.raise_for_status() - return r.json() - - -def graphql_request(query: str, variables: dict = None) -> dict: - """Executes a GraphQL query or mutation.""" - headers = { - "Authorization": f"Bearer {GITHUB_TOKEN}", - "Content-Type": "application/json", - "Accept": "application/vnd.github.v4+json", - } - r = requests.post(f"{GITHUB_API_URL}/graphql", json={"query": query, "variables": variables}, headers=headers) - r.raise_for_status() - result = r.json() - success = "data" in result and not result.get("errors") - print(f"{'Successful' if success else 'Fail'} discussion GraphQL request: {result.get('errors', 'No errors')}") - return result - - -def get_event_content() -> Tuple[int, str, str, str, str, str, str]: - """Extracts the number, node_id, title, body, username, and issue_type.""" - with open(GITHUB_EVENT_PATH) as f: - data = json.load(f) - action = data["action"] # 'opened', 'closed', 'created' (discussion), etc. - if GITHUB_EVENT_NAME == "issues": - item = data["issue"] - issue_type = "issue" - elif GITHUB_EVENT_NAME in ["pull_request", "pull_request_target"]: - pr_number = data["pull_request"]["number"] - item = get_github_data(f"pulls/{pr_number}") - issue_type = "pull request" - elif GITHUB_EVENT_NAME == "discussion": - item = data["discussion"] - issue_type = "discussion" - else: - raise ValueError(f"Unsupported event type: {GITHUB_EVENT_NAME}") - - number = item["number"] - node_id = item.get("node_id") or item.get("id") - title = item["title"] - body = remove_html_comments(item.get("body", "")) - username = item["user"]["login"] - return number, node_id, title, body, username, issue_type, action - - -def update_issue_pr_content(number: int, node_id: str, issue_type: str): - """Updates the title and body of the issue, pull request, or discussion.""" - new_title = "Content Under Review" - new_body = """This post has been flagged for review by [Ultralytics Actions](https://ultralytics.com/actions) due to possible spam, abuse, or off-topic content. For more information please see our: - -- [Code of Conduct](https://docs.ultralytics.com/help/code_of_conduct) -- [Security Policy](https://docs.ultralytics.com/help/security) - -For questions or bug reports related to this action please visit https://github.com/ultralytics/actions. - -Thank you πŸ™ -""" - if issue_type == "discussion": - mutation = """ -mutation($discussionId: ID!, $title: String!, $body: String!) { - updateDiscussion(input: {discussionId: $discussionId, title: $title, body: $body}) { - discussion { - id - } - } -} -""" - graphql_request(mutation, variables={"discussionId": node_id, "title": new_title, "body": new_body}) - else: - url = f"{GITHUB_API_URL}/repos/{REPO_NAME}/issues/{number}" - r = requests.patch(url, json={"title": new_title, "body": new_body}, headers=GITHUB_HEADERS) - print(f"{'Successful' if r.status_code == 200 else 'Fail'} issue/PR #{number} update: {r.status_code}") - - -def close_issue_pr(number: int, node_id: str, issue_type: str): - """Closes the issue, pull request, or discussion.""" - if issue_type == "discussion": - mutation = """ -mutation($discussionId: ID!) { - closeDiscussion(input: {discussionId: $discussionId}) { - discussion { - id - } - } -} -""" - graphql_request(mutation, variables={"discussionId": node_id}) - else: - url = f"{GITHUB_API_URL}/repos/{REPO_NAME}/issues/{number}" - r = requests.patch(url, json={"state": "closed"}, headers=GITHUB_HEADERS) - print(f"{'Successful' if r.status_code == 200 else 'Fail'} issue/PR #{number} close: {r.status_code}") - - -def lock_issue_pr(number: int, node_id: str, issue_type: str): - """Locks the issue, pull request, or discussion.""" - if issue_type == "discussion": - mutation = """ -mutation($lockableId: ID!, $lockReason: LockReason) { - lockLockable(input: {lockableId: $lockableId, lockReason: $lockReason}) { - lockedRecord { - ... on Discussion { - id - } - } - } -} -""" - graphql_request(mutation, variables={"lockableId": node_id, "lockReason": "OFF_TOPIC"}) - else: - url = f"{GITHUB_API_URL}/repos/{REPO_NAME}/issues/{number}/lock" - r = requests.put(url, json={"lock_reason": "off-topic"}, headers=GITHUB_HEADERS) - print(f"{'Successful' if r.status_code in {200, 204} else 'Fail'} issue/PR #{number} lock: {r.status_code}") - - -def block_user(username: str): - """Blocks a user from the organization.""" - url = f"{GITHUB_API_URL}/orgs/{REPO_NAME.split('/')[0]}/blocks/{username}" - r = requests.put(url, headers=GITHUB_HEADERS) - print(f"{'Successful' if r.status_code == 204 else 'Fail'} user block for {username}: {r.status_code}") - - -def get_relevant_labels( - issue_type: str, title: str, body: str, available_labels: Dict, current_labels: List -) -> List[str]: - """Uses OpenAI to determine the most relevant labels.""" - # Remove mutually exclusive labels like both 'bug' and 'question' or inappropriate labels like 'help wanted' - for label in ["help wanted", "TODO"]: # normal case - available_labels.pop(label, None) # remove as should only be manually added - if "bug" in current_labels: - available_labels.pop("question", None) - elif "question" in current_labels: - available_labels.pop("bug", None) - - # Add "Alert" to available labels if not present - if "Alert" not in available_labels: - available_labels["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 content that violates terms of service or legal standards." - ) - - labels = "\n".join(f"- {name}: {description}" for name, description in available_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'. - -AVAILABLE LABELS: -{labels} - -{issue_type.upper()} TITLE: -{title} - -{issue_type.upper()} DESCRIPTION: -{body[:16000]} - -YOUR RESPONSE (label names only): -""" - print(prompt) # for short-term debugging - messages = [ - { - "role": "system", - "content": "You are a helpful assistant that labels GitHub issues, pull requests, and discussions.", - }, - {"role": "user", "content": prompt}, - ] - suggested_labels = get_completion(messages) - if "none" in suggested_labels.lower(): - return [] - - available_labels_lower = {name.lower(): name for name in available_labels} - return [ - available_labels_lower[label.lower().strip()] - for label in suggested_labels.split(",") - if label.lower().strip() in available_labels_lower - ] - - -def get_label_ids(labels: List[str]) -> List[str]: - """Retrieves GitHub label IDs for a list of label names using the GraphQL API.""" - query = """ -query($owner: String!, $name: String!) { - repository(owner: $owner, name: $name) { - labels(first: 100, query: "") { - nodes { - id - name - } - } - } -} -""" - owner, repo = REPO_NAME.split("/") - result = graphql_request(query, variables={"owner": owner, "name": repo}) - if "data" in result and "repository" in result["data"]: - all_labels = result["data"]["repository"]["labels"]["nodes"] - label_map = {label["name"].lower(): label["id"] for label in all_labels} - return [label_map.get(label.lower()) for label in labels if label.lower() in label_map] - else: - print(f"Failed to fetch labels: {result.get('errors', 'Unknown error')}") - return [] - - -def apply_labels(number: int, node_id: str, labels: List[str], issue_type: str): - """Applies the given labels to the issue, pull request, or discussion.""" - if "Alert" in labels: - create_alert_label() - - if issue_type == "discussion": - print(f"Using node_id: {node_id}") # Debug print - label_ids = get_label_ids(labels) - if not label_ids: - print("No valid labels to apply.") - return - - mutation = """ -mutation($labelableId: ID!, $labelIds: [ID!]!) { - addLabelsToLabelable(input: {labelableId: $labelableId, labelIds: $labelIds}) { - labelable { - ... on Discussion { - id - } - } - } -} -""" - graphql_request(mutation, {"labelableId": node_id, "labelIds": label_ids}) - print(f"Successfully applied labels: {', '.join(labels)}") - else: - url = f"{GITHUB_API_URL}/repos/{REPO_NAME}/issues/{number}/labels" - r = requests.post(url, json={"labels": labels}, headers=GITHUB_HEADERS) - print(f"{'Successful' if r.status_code == 200 else 'Fail'} apply labels {', '.join(labels)}: {r.status_code}") - - -def create_alert_label(): - """Creates the 'Alert' label in the repository if it doesn't exist.""" - alert_label = {"name": "Alert", "color": "FF0000", "description": "Potential spam, abuse, or off-topic."} - requests.post(f"{GITHUB_API_URL}/repos/{REPO_NAME}/labels", json=alert_label, headers=GITHUB_HEADERS) - - -def is_org_member(username: str) -> bool: - """Checks if a user is a member of the organization.""" - org_name = REPO_NAME.split("/")[0] - url = f"{GITHUB_API_URL}/orgs/{org_name}/members/{username}" - r = requests.get(url, headers=GITHUB_HEADERS) - return r.status_code == 204 # 204 means the user is a member - - -def add_comment(number: int, node_id: str, comment: str, issue_type: str): - """Adds a comment to the issue, pull request, or discussion.""" - if issue_type == "discussion": - mutation = """ -mutation($discussionId: ID!, $body: String!) { - addDiscussionComment(input: {discussionId: $discussionId, body: $body}) { - comment { - id - } - } -} -""" - graphql_request(mutation, variables={"discussionId": node_id, "body": comment}) - else: - url = f"{GITHUB_API_URL}/repos/{REPO_NAME}/issues/{number}/comments" - r = requests.post(url, json={"body": comment}, headers=GITHUB_HEADERS) - print(f"{'Successful' if r.status_code in {200, 201} else 'Fail'} issue/PR #{number} comment: {r.status_code}") - - -def get_first_interaction_response(issue_type: str, title: str, body: str, username: str, number: int) -> str: - """Generates a custom response using LLM based on the issue/PR content and instructions.""" - issue_discussion_response = f""" -πŸ‘‹ Hello @{username}, thank you for submitting a `{REPO_NAME}` πŸš€ {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! -""" - - pr_response = f""" -πŸ‘‹ Hello @{username}, thank you for submitting an `{REPO_NAME}` πŸš€ 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/{REPO_NAME}/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 `{REPO_NAME}` `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! πŸš€ -""" - - if issue_type == "pull request": - example = os.getenv("FIRST_PR_RESPONSE") or pr_response - else: - example = os.getenv("FIRST_ISSUE_RESPONSE") or issue_discussion_response - - org_name, repo_name = REPO_NAME.split("/") - repo_url = f"https://github.com/{REPO_NAME}" - diff = get_pr_diff(number)[:32000] if issue_type == "pull request" else "" - - prompt = f"""Generate a customized response to the new GitHub {issue_type} below: - -CONTEXT: -- Repository: {repo_name} -- Organization: {org_name} -- Repository URL: {repo_url} -- 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 -- In your response, mention to the user that this is an automated response and that an Ultralytics engineer will also assist soon -- Do not add a sign-off or valediction like "best regards" at the end of your response -- Do not add spaces between bullet points or numbered lists -- Only link to files or URLs in the example below, do not add external links -- Use a few emojis to enliven your response - -EXAMPLE {issue_type.upper()} RESPONSE: -{example} - -{issue_type.upper()} TITLE: -{title} - -{issue_type.upper()} DESCRIPTION: -{body[:16000]} - -{"PULL REQUEST DIFF:" if issue_type == "pull request" else ""} -{diff if issue_type == "pull request" else ""} - -YOUR {issue_type.upper()} RESPONSE: -""" - print(f"\n\n{prompt}\n\n") # for debug - messages = [ - { - "role": "system", - "content": f"You are a helpful assistant responding to GitHub {issue_type}s for the {org_name} organization.", - }, - {"role": "user", "content": prompt}, - ] - return get_completion(messages) - - -def main(): - """Runs autolabel action and adds custom response for new issues/PRs/Discussions.""" - number, node_id, title, body, username, issue_type, action = get_event_content() - available_labels = get_github_data("labels") - label_descriptions = {label["name"]: label.get("description", "") for label in available_labels} - if issue_type == "discussion": - current_labels = [] # For discussions, labels may need to be fetched differently or adjusted - else: - current_labels = [label["name"].lower() for label in get_github_data(f"issues/{number}/labels")] - relevant_labels = get_relevant_labels(issue_type, title, body, label_descriptions, current_labels) - - if relevant_labels: - apply_labels(number, node_id, relevant_labels, issue_type) - if "Alert" in relevant_labels and not is_org_member(username): - update_issue_pr_content(number, node_id, issue_type) - if issue_type != "pull request": - close_issue_pr(number, node_id, issue_type) - lock_issue_pr(number, node_id, issue_type) - if BLOCK_USER: - block_user(username=username) - else: - print("No relevant labels found or applied.") - - if action in {"opened", "created"}: - custom_response = get_first_interaction_response(issue_type, title, body, username, number) - add_comment(number, node_id, custom_response, issue_type) - - -if __name__ == "__main__": - main() diff --git a/utils/summarize_pr.py b/utils/summarize_pr.py deleted file mode 100644 index e9a83c45..00000000 --- a/utils/summarize_pr.py +++ /dev/null @@ -1,108 +0,0 @@ -# Ultralytics YOLO πŸš€, AGPL-3.0 License https://ultralytics.com/license - -import os - -import requests - -# Environment variables -REPO_NAME = os.getenv("GITHUB_REPOSITORY") -PR_NUMBER = os.getenv("PR_NUMBER") -GITHUB_TOKEN = os.getenv("GITHUB_TOKEN") -GITHUB_EVENT_NAME = os.getenv("GITHUB_EVENT_NAME") -GITHUB_EVENT_PATH = os.getenv("GITHUB_EVENT_PATH") -GITHUB_API_URL = "https://api.github.com" -GITHUB_HEADERS = {"Authorization": f"token {GITHUB_TOKEN}", "Accept": "application/vnd.github.v3+json"} - -# OpenAI settings -OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o") # update as required -OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") - -# Action settings -SUMMARY_START = ( - "## πŸ› οΈ PR Summary\n\nMade with ❀️ by [Ultralytics Actions](https://github.com/ultralytics/actions)\n\n" -) - - -def get_completion(messages: list) -> str: - """Get chat completion from OpenAI.""" - assert OPENAI_API_KEY, "OpenAI API key is required." - url = "https://api.openai.com/v1/chat/completions" - headers = {"Authorization": f"Bearer {OPENAI_API_KEY}", "Content-Type": "application/json"} - data = {"model": OPENAI_MODEL, "messages": messages} - - r = requests.post(url, headers=headers, json=data) - r.raise_for_status() - return r.json()["choices"][0]["message"]["content"].strip() - - -def get_pr_diff(pr_number): - """Fetches the diff of a specific PR from a GitHub repository.""" - url = f"{GITHUB_API_URL}/repos/{REPO_NAME}/pulls/{pr_number}" - headers = {"Authorization": f"token {GITHUB_TOKEN}", "Accept": "application/vnd.github.v3.diff"} - r = requests.get(url, headers=headers) - return r.text if r.status_code == 200 else "" - - -def generate_pr_summary(repo_name, diff_text): - """Generates a professionally written yet accessible summary of a PR using OpenAI's API.""" - if not diff_text: - diff_text = "**ERROR: DIFF IS EMPTY, THERE ARE ZERO CODE CHANGES IN THIS PR." - ratio = 3.3 # about 3.3 characters per token - limit = round(128000 * ratio * 0.5) # use up to 50% of the 128k context window for prompt - messages = [ - { - "role": "system", - "content": "You are an Ultralytics AI assistant skilled in software development and technical communication. Your task is to summarize GitHub PRs from Ultralytics in a way that is accurate, concise, and understandable to both expert developers and non-expert users. Focus on highlighting the key changes and their impact in simple, concise terms.", - }, - { - "role": "user", - "content": f"Summarize this '{repo_name}' 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. Reply directly with a summary along these example guidelines, though feel free to adjust as appropriate:\n\n" - f"### 🌟 Summary (single-line synopsis)\n" - f"### πŸ“Š Key Changes (bullet points highlighting any major changes)\n" - f"### 🎯 Purpose & Impact (bullet points explaining any benefits and potential impact to users)\n" - f"\n\nHere's the PR diff:\n\n{diff_text[:limit]}", - }, - ] - reply = get_completion(messages) - if len(diff_text) > limit: - return SUMMARY_START + "**WARNING ⚠️** this PR is very large, summary may not cover all changes.\n\n" + reply - else: - return SUMMARY_START + reply - - -def update_pr_description(repo_name, pr_number, new_summary): - """Updates the original PR description with a new summary, replacing an existing summary if found.""" - # Fetch the current PR description - pr_url = f"{GITHUB_API_URL}/repos/{repo_name}/pulls/{pr_number}" - pr_response = requests.get(pr_url, headers=GITHUB_HEADERS) - pr_data = pr_response.json() - current_description = pr_data.get("body") or "" # warning, can be None - - # Check if existing summary is present and update accordingly - if SUMMARY_START in current_description: - updated_description = current_description.split(SUMMARY_START)[0] + new_summary - else: - updated_description = current_description + "\n\n" + new_summary - - # Update the PR description - update_response = requests.patch(pr_url, json={"body": updated_description}, headers=GITHUB_HEADERS) - return update_response.status_code - - -def main(): - """Summarize PR.""" - diff = get_pr_diff(PR_NUMBER) - - # Generate PR summary - summary = generate_pr_summary(REPO_NAME, diff) - - # Update PR description - status_code = update_pr_description(REPO_NAME, PR_NUMBER, summary) - if status_code == 200: - print("PR description updated successfully.") - else: - print(f"Failed to update PR description. Status code: {status_code}") - - -if __name__ == "__main__": - main() diff --git a/utils/summarize_release.py b/utils/summarize_release.py deleted file mode 100644 index fd4c9ed1..00000000 --- a/utils/summarize_release.py +++ /dev/null @@ -1,208 +0,0 @@ -# Ultralytics YOLO πŸš€, AGPL-3.0 License https://ultralytics.com/license - -import os -import re -import subprocess -import time -from datetime import datetime - -import requests - -# Environment variables -REPO_NAME = os.getenv("GITHUB_REPOSITORY") -GITHUB_TOKEN = os.getenv("GITHUB_TOKEN") -GITHUB_API_URL = "https://api.github.com" -GITHUB_HEADERS = {"Authorization": f"token {GITHUB_TOKEN}", "Accept": "application/vnd.github.v3+json"} -CURRENT_TAG = os.getenv("CURRENT_TAG") -PREVIOUS_TAG = os.getenv("PREVIOUS_TAG") - -# OpenAI settings -OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o") # update as required -OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") - - -def remove_html_comments(body: str) -> str: - """Removes HTML comment blocks from the body text.""" - return re.sub(r"", "", body, flags=re.DOTALL).strip() - - -def get_completion(messages: list) -> str: - """Get chat completion from OpenAI.""" - assert OPENAI_API_KEY, "OpenAI API key is required." - url = "https://api.openai.com/v1/chat/completions" - headers = {"Authorization": f"Bearer {OPENAI_API_KEY}", "Content-Type": "application/json"} - data = {"model": "gpt-4o-2024-08-06", "messages": messages} - - r = requests.post(url, headers=headers, json=data) - r.raise_for_status() - return r.json()["choices"][0]["message"]["content"].strip() - - -def get_release_diff(repo_name: str, previous_tag: str, latest_tag: str) -> str: - """Get the diff between two tags.""" - url = f"{GITHUB_API_URL}/repos/{repo_name}/compare/{previous_tag}...{latest_tag}" - headers = {"Authorization": f"token {GITHUB_TOKEN}", "Accept": "application/vnd.github.v3.diff"} - r = requests.get(url, headers=headers) - return r.text if r.status_code == 200 else f"Failed to get diff: {r.content}" - - -def get_prs_between_tags(repo_name: str, previous_tag: str, latest_tag: str) -> list: - """Get PRs merged between two tags using the compare API.""" - url = f"{GITHUB_API_URL}/repos/{repo_name}/compare/{previous_tag}...{latest_tag}" - r = requests.get(url, headers=GITHUB_HEADERS) - r.raise_for_status() - - data = r.json() - pr_numbers = set() - - for commit in data["commits"]: - pr_matches = re.findall(r"#(\d+)", commit["commit"]["message"]) - pr_numbers.update(pr_matches) - - prs = [] - time.sleep(10) # sleep 10 seconds to allow final PR summary to update on merge - for pr_number in sorted(pr_numbers): # earliest to latest - pr_url = f"{GITHUB_API_URL}/repos/{repo_name}/pulls/{pr_number}" - pr_response = requests.get(pr_url, headers=GITHUB_HEADERS) - if pr_response.status_code == 200: - pr_data = pr_response.json() - prs.append( - { - "number": pr_data["number"], - "title": pr_data["title"], - "body": remove_html_comments(pr_data["body"]), - "author": pr_data["user"]["login"], - "html_url": pr_data["html_url"], - "merged_at": pr_data["merged_at"], - } - ) - - # Sort PRs by merge date - prs.sort(key=lambda x: datetime.strptime(x["merged_at"], "%Y-%m-%dT%H:%M:%SZ")) - - return prs - - -def get_new_contributors(repo: str, prs: list) -> set: - """Identify genuinely new contributors in the current release.""" - new_contributors = set() - for pr in prs: - author = pr["author"] - # Check if this is the author's first contribution - url = f"{GITHUB_API_URL}/search/issues?q=repo:{repo}+author:{author}+is:pr+is:merged&sort=created&order=asc" - r = requests.get(url, headers=GITHUB_HEADERS) - if r.status_code == 200: - data = r.json() - if data["total_count"] > 0: - first_pr = data["items"][0] - if first_pr["number"] == pr["number"]: - new_contributors.add(author) - return new_contributors - - -def generate_release_summary(diff: str, prs: list, latest_tag: str, previous_tag: str, repo_name: str) -> str: - """Generate a summary for the release.""" - pr_summaries = "\n\n".join( - [f"PR #{pr['number']}: {pr['title']} by @{pr['author']}\n{pr['body'][:1000]}" for pr in prs] - ) - - current_pr = prs[-1] if prs else None - current_pr_summary = ( - f"Current PR #{current_pr['number']}: {current_pr['title']} by @{current_pr['author']}\n{current_pr['body'][:1000]}" - if current_pr - else "No current PR found." - ) - - whats_changed = "\n".join([f"* {pr['title']} by @{pr['author']} in {pr['html_url']}" for pr in prs]) - - # Generate New Contributors section - new_contributors = get_new_contributors(repo_name, prs) - new_contributors_section = ( - "\n## New Contributors\n" - + "\n".join( - [ - f"* @{contributor} made their first contribution in {next(pr['html_url'] for pr in prs if pr['author'] == contributor)}" - for contributor in new_contributors - ] - ) - if new_contributors - else "" - ) - - full_changelog = f"https://github.com/{repo_name}/compare/{previous_tag}...{latest_tag}" - release_suffix = ( - f"\n\n## What's Changed\n{whats_changed}\n{new_contributors_section}\n\n**Full Changelog**: {full_changelog}\n" - ) - - messages = [ - { - "role": "system", - "content": "You are an Ultralytics AI assistant skilled in software development and technical communication. Your task is to summarize GitHub releases in a way that is detailed, accurate, and understandable to both expert developers and non-expert users. Focus on highlighting the key changes and their impact in simple and intuitive terms.", - }, - { - "role": "user", - "content": f"Summarize the updates made in the '{latest_tag}' tag, focusing on major model or features changes, their purpose, and potential impact. Keep the summary clear and suitable for a broad audience. Add emojis to enliven the summary. Prioritize changes from the current PR (the first in the list), which is usually the most important in the release. Reply directly with a summary along these example guidelines, though feel free to adjust as appropriate:\n\n" - f"## 🌟 Summary (single-line synopsis)\n" - f"## πŸ“Š Key Changes (bullet points highlighting any major changes)\n" - f"## 🎯 Purpose & Impact (bullet points explaining any benefits and potential impact to users)\n\n\n" - f"Here's the information about the current PR:\n\n{current_pr_summary}\n\n" - f"Here's the information about PRs merged between the previous release and this one:\n\n{pr_summaries[:30000]}\n\n" - f"Here's the release diff:\n\n{diff[:300000]}", - }, - ] - print(messages[-1]["content"]) # for debug - return get_completion(messages) + release_suffix - - -def create_github_release(repo_name: str, tag_name: str, name: str, body: str) -> int: - """Create a release on GitHub.""" - url = f"{GITHUB_API_URL}/repos/{repo_name}/releases" - data = {"tag_name": tag_name, "name": name, "body": body, "draft": False, "prerelease": False} - r = requests.post(url, headers=GITHUB_HEADERS, json=data) - return r.status_code - - -def get_previous_tag() -> str: - """Get the previous tag from git tags.""" - cmd = ["git", "describe", "--tags", "--abbrev=0", "--exclude", CURRENT_TAG] - try: - return subprocess.run(cmd, check=True, text=True, capture_output=True).stdout.strip() - except subprocess.CalledProcessError: - print("Failed to get previous tag from git. Using previous commit.") - return "HEAD~1" - - -def main(): - """Automates generating and publishing a GitHub release summary from PRs and commit differences.""" - if not all([GITHUB_TOKEN, CURRENT_TAG]): - raise ValueError("One or more required environment variables are missing.") - - previous_tag = PREVIOUS_TAG or get_previous_tag() - - # Get the diff between the tags - diff = get_release_diff(REPO_NAME, previous_tag, CURRENT_TAG) - - # Get PRs merged between the tags - prs = get_prs_between_tags(REPO_NAME, previous_tag, CURRENT_TAG) - - # Generate release summary - try: - summary = generate_release_summary(diff, prs, CURRENT_TAG, previous_tag, REPO_NAME) - except Exception as e: - print(f"Failed to generate summary: {str(e)}") - summary = "Failed to generate summary." - - # Get the latest commit message - cmd = ["git", "log", "-1", "--pretty=%B"] - commit_message = subprocess.run(cmd, check=True, text=True, capture_output=True).stdout.split("\n")[0].strip() - - # Create the release on GitHub - status_code = create_github_release(REPO_NAME, CURRENT_TAG, f"{CURRENT_TAG} - {commit_message}", summary) - if status_code == 201: - print(f"Successfully created release {CURRENT_TAG}") - else: - print(f"Failed to create release {CURRENT_TAG}. Status code: {status_code}") - - -if __name__ == "__main__": - main() diff --git a/utils/update_markdown_code_blocks.py b/utils/update_markdown_code_blocks.py deleted file mode 100644 index 1e70bc4a..00000000 --- a/utils/update_markdown_code_blocks.py +++ /dev/null @@ -1,177 +0,0 @@ -# Ultralytics YOLO πŸš€, AGPL-3.0 License https://ultralytics.com/license - -import hashlib -import re -import shutil -import subprocess -import time -from pathlib import Path - - -def extract_code_blocks(markdown_content): - """Extract Python code blocks with ``` followed by "python", "py", or "{ .py .annotate }".""" - pattern = r"^( *)```(?:python|py|\{[ ]*\.py[ ]*\.annotate[ ]*\})\n(.*?)\n\1```" - code_block_pattern = re.compile(pattern, re.DOTALL | re.MULTILINE) - return code_block_pattern.findall(markdown_content) - - -def remove_indentation(code_block, num_spaces): - """Removes `num_spaces` leading spaces from each line in `code_block` and returns the modified string.""" - lines = code_block.split("\n") - stripped_lines = [line[num_spaces:] if len(line) >= num_spaces else line for line in lines] - return "\n".join(stripped_lines) - - -def add_indentation(code_block, num_spaces): - """Adds `num_spaces` leading spaces to each non-empty line in `code_block`.""" - indent = " " * num_spaces - lines = code_block.split("\n") - indented_lines = [indent + line if line.strip() != "" else line for line in lines] - return "\n".join(indented_lines) - - -def format_code_with_ruff(temp_dir): - """Formats all Python code files in the `temp_dir` directory using the 'ruff' linter tool.""" - try: - # Run ruff format - subprocess.run( - [ - "ruff", - "format", - "--line-length=120", - str(temp_dir), - ], - check=True, - ) - print("Completed ruff format βœ…") - except Exception as e: - print(f"ERROR running ruff format ❌ {e}") - - try: - # Run ruff check with ignored rules: - # F821: Undefined name - # F841: Local variable is assigned to but never used - subprocess.run( - [ - "ruff", - "check", - "--fix", - "--extend-select=I", - "--ignore=F821,F841", - str(temp_dir), - ], - check=True, - ) - print("Completed ruff check βœ…") - except Exception as e: - print(f"ERROR running ruff check ❌ {e}") - - try: - # Run docformatter - subprocess.run( - [ - "docformatter", - "--wrap-summaries=120", - "--wrap-descriptions=120", - "--pre-summary-newline", - "--close-quotes-on-newline", - "--in-place", - "--recursive", - str(temp_dir), - ], - check=True, - ) - print("Completed docformatter βœ…") - except Exception as e: - print(f"ERROR running docformatter ❌ {e}") - - -def generate_temp_filename(file_path, index): - """Generates a unique temporary filename based on the file path and index.""" - unique_string = f"{file_path.parent}_{file_path.stem}_{index}" - unique_hash = hashlib.md5(unique_string.encode()).hexdigest() - return f"temp_{unique_hash}.py" - - -def process_markdown_file(file_path, temp_dir, verbose=False): - """Reads a markdown file, extracts Python code blocks, saves them to temp files, and updates the file.""" - try: - markdown_content = Path(file_path).read_text() - code_blocks = extract_code_blocks(markdown_content) - temp_files = [] - - for i, (num_spaces, code_block) in enumerate(code_blocks): - if verbose: - print(f"Extracting code block {i} from {file_path}") - num_spaces = len(num_spaces) - code_without_indentation = remove_indentation(code_block, num_spaces) - - # Generate a unique temp file path - temp_file_path = temp_dir / generate_temp_filename(file_path, i) - with open(temp_file_path, "w") as temp_file: - temp_file.write(code_without_indentation) - temp_files.append((num_spaces, code_block, temp_file_path)) - - return markdown_content, temp_files - - except Exception as e: - print(f"Error processing file {file_path}: {e}") - return None, None - - -def update_markdown_file(file_path, markdown_content, temp_files): - """Updates the markdown file with formatted code blocks.""" - for num_spaces, original_code_block, temp_file_path in temp_files: - try: - with open(temp_file_path) as temp_file: - formatted_code = temp_file.read().rstrip("\n") # Strip trailing newlines - formatted_code_with_indentation = add_indentation(formatted_code, num_spaces) - - # Replace both `python` and `py` code blocks - for lang in ["python", "py", "{ .py .annotate }"]: - markdown_content = markdown_content.replace( - f"{' ' * num_spaces}```{lang}\n{original_code_block}\n{' ' * num_spaces}```", - f"{' ' * num_spaces}```{lang}\n{formatted_code_with_indentation}\n{' ' * num_spaces}```", - ) - except Exception as e: - print(f"Error updating code block in file {file_path}: {e}") - - try: - with open(file_path, "w") as file: - file.write(markdown_content) - except Exception as e: - print(f"Error writing file {file_path}: {e}") - - -def process_all_markdown_files(root_dir, verbose=False): - """Processes all markdown files in a specified directory and its subdirectories.""" - root_path = Path(root_dir) - markdown_files = list(root_path.rglob("*.md")) - temp_dir = Path("temp_code_blocks") - temp_dir.mkdir(exist_ok=True) - - # Extract code blocks and save to temp files - all_temp_files = [] - for markdown_file in markdown_files: - if verbose: - print(f"Processing {markdown_file}") - markdown_content, temp_files = process_markdown_file(markdown_file, temp_dir) - if markdown_content and temp_files: - all_temp_files.append((markdown_file, markdown_content, temp_files)) - - # Format all code blocks with ruff - format_code_with_ruff(temp_dir) - - # Update markdown files with formatted code blocks - for markdown_file, markdown_content, temp_files in all_temp_files: - update_markdown_file(markdown_file, markdown_content, temp_files) - - # Clean up temp directory - shutil.rmtree(temp_dir) - - -# Usage -if __name__ == "__main__": - t = time.time() - process_all_markdown_files(root_dir=Path.cwd()) - print(f"Processing time: {time.time() - t} seconds") From b4d80fc0d0daceb229a11909a2600344584c923b Mon Sep 17 00:00:00 2001 From: UltralyticsAssistant Date: Wed, 16 Oct 2024 13:49:03 +0000 Subject: [PATCH 5/9] Auto-format by https://ultralytics.com/actions --- actions/utils/__init__.py | 2 +- actions/utils/github_utils.py | 30 +++++++++++++++--------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/actions/utils/__init__.py b/actions/utils/__init__.py index 8fbeb2eb..74e872f8 100644 --- a/actions/utils/__init__.py +++ b/actions/utils/__init__.py @@ -10,10 +10,10 @@ GITHUB_TOKEN, PR_NUMBER, REPO_NAME, + check_pypi_version, get_github_data, get_pr_diff, graphql_request, - check_pypi_version, ) from .openai_utils import OPENAI_API_KEY, OPENAI_MODEL, get_completion diff --git a/actions/utils/github_utils.py b/actions/utils/github_utils.py index 0ce4cf07..ffaa6709 100644 --- a/actions/utils/github_utils.py +++ b/actions/utils/github_utils.py @@ -44,43 +44,43 @@ def graphql_request(query: str, variables: dict = None) -> dict: return result -def check_pypi_version(pyproject_toml='pyproject.toml'): +def check_pypi_version(pyproject_toml="pyproject.toml"): """Compares local and PyPI package versions to determine if a new version should be published.""" import tomllib # requires Python>=3.11 - with open(pyproject_toml, 'rb') as f: + with open(pyproject_toml, "rb") as f: pyproject = tomllib.load(f) - package_name = pyproject['project']['name'] - local_version = pyproject['project'].get('version', 'dynamic') + package_name = pyproject["project"]["name"] + local_version = pyproject["project"].get("version", "dynamic") # If version is dynamic, extract it from the specified file - if local_version == 'dynamic': - version_attr = pyproject['tool']['setuptools']['dynamic']['version']['attr'] - module_path, attr_name = version_attr.rsplit('.', 1) + if local_version == "dynamic": + version_attr = pyproject["tool"]["setuptools"]["dynamic"]["version"]["attr"] + module_path, attr_name = version_attr.rsplit(".", 1) with open(f"{module_path.replace('.', '/')}/__init__.py") as f: - local_version = next(line.split('=')[1].strip().strip("'\"") for line in f if line.startswith(attr_name)) + local_version = next(line.split("=")[1].strip().strip("'\"") for line in f if line.startswith(attr_name)) print(f"Local Version: {local_version}") # Get online version from PyPI response = requests.get(f"https://pypi.org/pypi/{package_name}/json") - online_version = response.json()['info']['version'] if response.status_code == 200 else None + online_version = response.json()["info"]["version"] if response.status_code == 200 else None print(f"Online Version: {online_version or 'Not Found'}") # Determine if a new version should be published publish = False if online_version: - local_ver = tuple(map(int, local_version.split('.'))) - online_ver = tuple(map(int, online_version.split('.'))) + local_ver = tuple(map(int, local_version.split("."))) + online_ver = tuple(map(int, online_version.split("."))) major_diff = local_ver[0] - online_ver[0] minor_diff = local_ver[1] - online_ver[1] patch_diff = local_ver[2] - online_ver[2] publish = ( - (major_diff == 0 and minor_diff == 0 and 0 < patch_diff <= 2) or - (major_diff == 0 and minor_diff == 1 and local_ver[2] == 0) or - (major_diff == 1 and local_ver[1] == 0 and local_ver[2] == 0) + (major_diff == 0 and minor_diff == 0 and 0 < patch_diff <= 2) + or (major_diff == 0 and minor_diff == 1 and local_ver[2] == 0) + or (major_diff == 1 and local_ver[1] == 0 and local_ver[2] == 0) ) else: publish = True # First release @@ -89,6 +89,6 @@ def check_pypi_version(pyproject_toml='pyproject.toml'): os.system(f'echo "current_tag=v{local_version}" >> $GITHUB_OUTPUT') os.system(f'echo "previous_tag=v{online_version}" >> $GITHUB_OUTPUT') if publish: - print('Ready to publish new version to PyPI βœ….') + print("Ready to publish new version to PyPI βœ….") return local_version, online_version, publish From 8f044c111d296db73543782416d8de8a1801c2de Mon Sep 17 00:00:00 2001 From: UltralyticsAssistant Date: Wed, 16 Oct 2024 15:50:07 +0200 Subject: [PATCH 6/9] Update actions package --- actions/utils/github_utils.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/actions/utils/github_utils.py b/actions/utils/github_utils.py index 0ce4cf07..23cb8dee 100644 --- a/actions/utils/github_utils.py +++ b/actions/utils/github_utils.py @@ -85,10 +85,4 @@ def check_pypi_version(pyproject_toml='pyproject.toml'): else: publish = True # First release - os.system(f'echo "increment={publish}" >> $GITHUB_OUTPUT') - os.system(f'echo "current_tag=v{local_version}" >> $GITHUB_OUTPUT') - os.system(f'echo "previous_tag=v{online_version}" >> $GITHUB_OUTPUT') - if publish: - print('Ready to publish new version to PyPI βœ….') - return local_version, online_version, publish From 2a39cb994d827d162acf108849cd053360ac77b5 Mon Sep 17 00:00:00 2001 From: UltralyticsAssistant Date: Wed, 16 Oct 2024 13:51:25 +0000 Subject: [PATCH 7/9] Auto-format by https://ultralytics.com/actions --- actions/utils/github_utils.py | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/actions/utils/github_utils.py b/actions/utils/github_utils.py index 23cb8dee..163a2be7 100644 --- a/actions/utils/github_utils.py +++ b/actions/utils/github_utils.py @@ -44,43 +44,43 @@ def graphql_request(query: str, variables: dict = None) -> dict: return result -def check_pypi_version(pyproject_toml='pyproject.toml'): +def check_pypi_version(pyproject_toml="pyproject.toml"): """Compares local and PyPI package versions to determine if a new version should be published.""" import tomllib # requires Python>=3.11 - with open(pyproject_toml, 'rb') as f: + with open(pyproject_toml, "rb") as f: pyproject = tomllib.load(f) - package_name = pyproject['project']['name'] - local_version = pyproject['project'].get('version', 'dynamic') + package_name = pyproject["project"]["name"] + local_version = pyproject["project"].get("version", "dynamic") # If version is dynamic, extract it from the specified file - if local_version == 'dynamic': - version_attr = pyproject['tool']['setuptools']['dynamic']['version']['attr'] - module_path, attr_name = version_attr.rsplit('.', 1) + if local_version == "dynamic": + version_attr = pyproject["tool"]["setuptools"]["dynamic"]["version"]["attr"] + module_path, attr_name = version_attr.rsplit(".", 1) with open(f"{module_path.replace('.', '/')}/__init__.py") as f: - local_version = next(line.split('=')[1].strip().strip("'\"") for line in f if line.startswith(attr_name)) + local_version = next(line.split("=")[1].strip().strip("'\"") for line in f if line.startswith(attr_name)) print(f"Local Version: {local_version}") # Get online version from PyPI response = requests.get(f"https://pypi.org/pypi/{package_name}/json") - online_version = response.json()['info']['version'] if response.status_code == 200 else None + online_version = response.json()["info"]["version"] if response.status_code == 200 else None print(f"Online Version: {online_version or 'Not Found'}") # Determine if a new version should be published publish = False if online_version: - local_ver = tuple(map(int, local_version.split('.'))) - online_ver = tuple(map(int, online_version.split('.'))) + local_ver = tuple(map(int, local_version.split("."))) + online_ver = tuple(map(int, online_version.split("."))) major_diff = local_ver[0] - online_ver[0] minor_diff = local_ver[1] - online_ver[1] patch_diff = local_ver[2] - online_ver[2] publish = ( - (major_diff == 0 and minor_diff == 0 and 0 < patch_diff <= 2) or - (major_diff == 0 and minor_diff == 1 and local_ver[2] == 0) or - (major_diff == 1 and local_ver[1] == 0 and local_ver[2] == 0) + (major_diff == 0 and minor_diff == 0 and 0 < patch_diff <= 2) + or (major_diff == 0 and minor_diff == 1 and local_ver[2] == 0) + or (major_diff == 1 and local_ver[1] == 0 and local_ver[2] == 0) ) else: publish = True # First release From 5a40aaf08683483f5fdee0b279abcce98ed6bcb8 Mon Sep 17 00:00:00 2001 From: UltralyticsAssistant Date: Wed, 16 Oct 2024 15:54:13 +0200 Subject: [PATCH 8/9] Update actions package --- actions/utils/github_utils.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/actions/utils/github_utils.py b/actions/utils/github_utils.py index 163a2be7..313eb457 100644 --- a/actions/utils/github_utils.py +++ b/actions/utils/github_utils.py @@ -69,7 +69,6 @@ def check_pypi_version(pyproject_toml="pyproject.toml"): print(f"Online Version: {online_version or 'Not Found'}") # Determine if a new version should be published - publish = False if online_version: local_ver = tuple(map(int, local_version.split("."))) online_ver = tuple(map(int, online_version.split("."))) @@ -81,8 +80,8 @@ def check_pypi_version(pyproject_toml="pyproject.toml"): (major_diff == 0 and minor_diff == 0 and 0 < patch_diff <= 2) or (major_diff == 0 and minor_diff == 1 and local_ver[2] == 0) or (major_diff == 1 and local_ver[1] == 0 and local_ver[2] == 0) - ) + ) # should publish an update else: - publish = True # First release + publish = True # publish as this is likely a first release return local_version, online_version, publish From 487a8cdb6cd3a0d7cf2f2efb37624d0e6a90f89b Mon Sep 17 00:00:00 2001 From: UltralyticsAssistant Date: Wed, 16 Oct 2024 15:59:57 +0200 Subject: [PATCH 9/9] Update actions package --- action.yml | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/action.yml b/action.yml index 99cd8955..a0b1043c 100644 --- a/action.yml +++ b/action.yml @@ -191,6 +191,21 @@ runs: shell: bash continue-on-error: true + # Autolabel Issues and PRs (run before commit changes in case commit fails) ---------------------------------------- + - name: Autolabel Issues and PRs + if: inputs.labels == 'true' && (github.event.action == 'opened' || github.event.action == 'created') + env: + PR_NUMBER: ${{ github.event.pull_request.number }} + GITHUB_TOKEN: ${{ inputs.token }} + FIRST_ISSUE_RESPONSE: ${{ inputs.first_issue_response }} + FIRST_PR_RESPONSE: ${{ inputs.first_pr_response }} + OPENAI_API_KEY: ${{ inputs.openai_api_key }} + OPENAI_MODEL: ${{ inputs.openai_model }} + run: | + ultralytics-actions-first-interaction + shell: bash + continue-on-error: true + # Commit Changes --------------------------------------------------------------------------------------------------- - name: Commit and Push Changes if: (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && github.event.action != 'closed' @@ -209,21 +224,6 @@ runs: shell: bash continue-on-error: false - # Autolabel Issues and PRs ---------------------------------------------------------------------------------------- - - name: Autolabel Issues and PRs - if: inputs.labels == 'true' && (github.event.action == 'opened' || github.event.action == 'created') - env: - PR_NUMBER: ${{ github.event.pull_request.number }} - GITHUB_TOKEN: ${{ inputs.token }} - FIRST_ISSUE_RESPONSE: ${{ inputs.first_issue_response }} - FIRST_PR_RESPONSE: ${{ inputs.first_pr_response }} - OPENAI_API_KEY: ${{ inputs.openai_api_key }} - OPENAI_MODEL: ${{ inputs.openai_model }} - run: | - ultralytics-actions-first-interaction - shell: bash - continue-on-error: true - # Broken links ----------------------------------------------------------------------------------------------------- - name: Broken Link Checker if: inputs.links == 'true' && github.event.action != 'closed'