Ultralytics-Actions Python package (#243)
Co-authored-by: UltralyticsAssistant <[email protected]>
This commit is contained in:
co-authored by
UltralyticsAssistant
parent
776d3ac485
commit
5f84281dad
@@ -0,0 +1,148 @@
|
||||
# Ultralytics Actions 🚀, AGPL-3.0 license
|
||||
# Publish pip package to PyPI https://pypi.org/project/ultralytics-actions/
|
||||
|
||||
name: Publish to PyPI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pypi:
|
||||
type: boolean
|
||||
description: Publish to PyPI
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
if: github.repository == 'ultralytics/actions' && github.actor == 'glenn-jocher'
|
||||
name: Publish
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write # for PyPI trusted publishing
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets._GITHUB_TOKEN }} # use your PAT here
|
||||
- name: Git config
|
||||
run: |
|
||||
git config --global user.name "UltralyticsAssistant"
|
||||
git config --global user.email "[email protected]"
|
||||
- name: Set up Python environment
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.x"
|
||||
cache: "pip" # caching pip dependencies
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip wheel
|
||||
pip install requests build twine toml
|
||||
- name: Check PyPI version
|
||||
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
|
||||
|
||||
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
|
||||
- name: Build package
|
||||
if: (github.event_name == 'push' || github.event.inputs.pypi == 'true') && steps.check_pypi.outputs.increment == 'True'
|
||||
run: python -m build
|
||||
- name: Publish to PyPI
|
||||
continue-on-error: true
|
||||
if: (github.event_name == 'push' || github.event.inputs.pypi == 'true') && steps.check_pypi.outputs.increment == 'True'
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: ${{ secrets.PYPI_TOKEN }}
|
||||
- name: Publish new tag
|
||||
continue-on-error: true
|
||||
if: (github.event_name == 'push' || github.event.inputs.pypi == 'true') && steps.check_pypi.outputs.increment == 'True'
|
||||
run: |
|
||||
git tag -a "${{ steps.check_pypi.outputs.current_tag }}" -m "$(git log -1 --pretty=%B)" # i.e. "v0.1.2 commit message"
|
||||
git push origin "${{ steps.check_pypi.outputs.current_tag }}"
|
||||
- name: Publish new release
|
||||
continue-on-error: true
|
||||
if: (github.event_name == 'push' || github.event.inputs.pypi == 'true') && steps.check_pypi.outputs.increment == 'True'
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }}
|
||||
CURRENT_TAG: ${{ steps.check_pypi.outputs.current_tag }}
|
||||
PREVIOUS_TAG: ${{ steps.check_pypi.outputs.previous_tag }}
|
||||
run: |
|
||||
curl -s "https://raw.githubusercontent.com/ultralytics/actions/main/utils/summarize_release.py" | python -
|
||||
shell: bash
|
||||
- name: Extract PR Details
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets._GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Check if the event is a pull request or pull_request_target
|
||||
if [ "${{ github.event_name }}" = "pull_request" ] || [ "${{ github.event_name }}" = "pull_request_target" ]; then
|
||||
PR_NUMBER=${{ github.event.pull_request.number }}
|
||||
PR_TITLE=$(gh pr view $PR_NUMBER --json title --jq '.title')
|
||||
else
|
||||
# Use gh to find the PR associated with the commit
|
||||
COMMIT_SHA=${{ github.event.after }}
|
||||
PR_JSON=$(gh pr list --search "${COMMIT_SHA}" --state merged --json number,title --jq '.[0]')
|
||||
PR_NUMBER=$(echo $PR_JSON | jq -r '.number')
|
||||
PR_TITLE=$(echo $PR_JSON | jq -r '.title')
|
||||
fi
|
||||
echo "PR_NUMBER=$PR_NUMBER" >> $GITHUB_ENV
|
||||
echo "PR_TITLE=$PR_TITLE" >> $GITHUB_ENV
|
||||
- name: Notify on Slack (Success)
|
||||
if: success() && github.event_name == 'push' && steps.check_pypi.outputs.increment == 'True'
|
||||
uses: slackapi/[email protected]
|
||||
with:
|
||||
payload: |
|
||||
{"text": "<!channel> GitHub Actions success for ${{ github.workflow }} ✅\n\n\n*Repository:* https://github.com/${{ github.repository }}\n*Action:* https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}\n*Author:* ${{ github.actor }}\n*Event:* NEW '${{ github.repository }} ${{ steps.check_pypi.outputs.current_tag }}' pip package published 😃\n*Job Status:* ${{ job.status }}\n*Pull Request:* <https://github.com/${{ github.repository }}/pull/${{ env.PR_NUMBER }}> ${{ env.PR_TITLE }}\n"}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL_YOLO }}
|
||||
- name: Notify on Slack (Failure)
|
||||
if: failure()
|
||||
uses: slackapi/[email protected]
|
||||
with:
|
||||
payload: |
|
||||
{"text": "<!channel> GitHub Actions error for ${{ github.workflow }} ❌\n\n\n*Repository:* https://github.com/${{ github.repository }}\n*Action:* https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}\n*Author:* ${{ github.actor }}\n*Event:* ${{ github.event_name }}\n*Job Status:* ${{ job.status }}\n*Pull Request:* <https://github.com/${{ github.repository }}/pull/${{ env.PR_NUMBER }}> ${{ env.PR_TITLE }}\n"}
|
||||
env:
|
||||
SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL_YOLO }}
|
||||
@@ -1,76 +0,0 @@
|
||||
# Ultralytics 🚀 - AGPL-3.0 License https://ultralytics.com/license
|
||||
# Ultralytics Actions https://github.com/ultralytics/actions
|
||||
# This workflow automatically publishes a new repository tag and release
|
||||
|
||||
name: Tag and Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag_name:
|
||||
description: "Tag name (e.g., v0.0.0)"
|
||||
required: true
|
||||
type: string
|
||||
publish_tag:
|
||||
description: "Publish new tag"
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
publish_release:
|
||||
description: "Publish new release"
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
jobs:
|
||||
tag-and-release:
|
||||
if: github.repository == 'ultralytics/actions' && github.actor == 'glenn-jocher'
|
||||
name: Tag and Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets._GITHUB_TOKEN }}
|
||||
|
||||
- name: Git config
|
||||
run: |
|
||||
git config --global user.name "UltralyticsAssistant"
|
||||
git config --global user.email "[email protected]"
|
||||
|
||||
- name: Check if tag exists
|
||||
id: check_tag
|
||||
run: |
|
||||
if git rev-parse ${{ github.event.inputs.tag_name }} >/dev/null 2>&1; then
|
||||
echo "Tag ${{ github.event.inputs.tag_name }} already exists"
|
||||
echo "tag_exists=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "Tag ${{ github.event.inputs.tag_name }} does not exist"
|
||||
echo "tag_exists=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Publish new tag
|
||||
if: steps.check_tag.outputs.tag_exists == 'false'
|
||||
run: |
|
||||
git tag -a "${{ github.event.inputs.tag_name }}" -m "$(git log -1 --pretty=%B)"
|
||||
git push origin "${{ github.event.inputs.tag_name }}"
|
||||
|
||||
- name: Set up Python environment
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.x"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip wheel
|
||||
pip install requests
|
||||
|
||||
- name: Publish new release
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
GITHUB_TOKEN: ${{ secrets._GITHUB_TOKEN }}
|
||||
CURRENT_TAG: ${{ github.event.inputs.tag_name }}
|
||||
run: |
|
||||
curl -s "https://raw.githubusercontent.com/ultralytics/actions/main/utils/summarize_release.py" | python -
|
||||
shell: bash
|
||||
+6
-8
@@ -1,4 +1,4 @@
|
||||
# Ultralytics YOLO 🚀, AGPL-3.0 License https://ultralytics.com/license
|
||||
# Ultralytics Actions 🚀, AGPL-3.0 License https://ultralytics.com/license
|
||||
|
||||
name: "Ultralytics Actions"
|
||||
author: "Ultralytics"
|
||||
@@ -93,8 +93,7 @@ runs:
|
||||
# Note tomli required for codespell with pyproject.toml
|
||||
if: (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && github.event.action != 'closed'
|
||||
run: |
|
||||
pip install -q \
|
||||
ruff docformatter codespell tomli
|
||||
pip install ultralytics-actions codespell tomli
|
||||
shell: bash
|
||||
|
||||
# PR Summary -------------------------------------------------------------------------------------------------------
|
||||
@@ -106,8 +105,7 @@ runs:
|
||||
OPENAI_API_KEY: ${{ inputs.openai_api_key }}
|
||||
OPENAI_MODEL: ${{ inputs.openai_model }}
|
||||
run: |
|
||||
pip install requests
|
||||
curl -s "https://raw.githubusercontent.com/ultralytics/actions/main/utils/summarize_pr.py" | python -
|
||||
ultralytics-actions-summarize-pr
|
||||
shell: bash
|
||||
continue-on-error: true
|
||||
|
||||
@@ -152,7 +150,8 @@ runs:
|
||||
- name: Run Prettier
|
||||
if: (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && (inputs.prettier == 'true' || inputs.markdown == 'true') && github.event.action != 'closed'
|
||||
run: |
|
||||
curl -s "https://raw.githubusercontent.com/ultralytics/actions/main/utils/update_markdown_code_blocks.py" | python -
|
||||
ultralytics-actions-update-markdown-code-blocks
|
||||
|
||||
npm install --global prettier
|
||||
# npx prettier --write "**/*.{js,jsx,ts,tsx,css,less,scss,json,md,mdx,yml,yaml,html,vue,svelte}"
|
||||
npx prettier --write "**/*.{css,json,yml,yaml}"
|
||||
@@ -221,8 +220,7 @@ runs:
|
||||
OPENAI_API_KEY: ${{ inputs.openai_api_key }}
|
||||
OPENAI_MODEL: ${{ inputs.openai_model }}
|
||||
run: |
|
||||
pip install requests
|
||||
curl -s "https://raw.githubusercontent.com/ultralytics/actions/main/utils/first_interaction.py" | python -
|
||||
ultralytics-actions-first-interaction
|
||||
shell: bash
|
||||
continue-on-error: true
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Ultralytics Actions 🚀, AGPL-3.0 license https://ultralytics.com/license
|
||||
|
||||
# project_root/
|
||||
# ├── pyproject.toml
|
||||
# ├── README.md
|
||||
# ├── LICENSE
|
||||
# ├── .gitignore
|
||||
# ├── actions/
|
||||
# │ ├── __init__.py
|
||||
# │ ├── utils/
|
||||
# │ │ ├── __init__.py
|
||||
# │ │ ├── github_utils.py
|
||||
# │ │ ├── openai_utils.py
|
||||
# │ │ └── common_utils.py
|
||||
# │ ├── first_interaction.py
|
||||
# │ ├── summarize_pr.py
|
||||
# │ ├── summarize_release.py
|
||||
# │ └── update_markdown_code_blocks.py
|
||||
# └── tests/
|
||||
# ├── __init__.py
|
||||
# ├── test_first_interaction.py
|
||||
# ├── 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"
|
||||
@@ -0,0 +1,401 @@
|
||||
# Ultralytics Actions 🚀, AGPL-3.0 license https://ultralytics.com/license
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
import requests
|
||||
|
||||
from .utils import (
|
||||
GITHUB_API_URL,
|
||||
GITHUB_EVENT_NAME,
|
||||
GITHUB_EVENT_PATH,
|
||||
GITHUB_HEADERS,
|
||||
REPO_NAME,
|
||||
get_completion,
|
||||
get_github_data,
|
||||
get_pr_diff,
|
||||
graphql_request,
|
||||
remove_html_comments,
|
||||
)
|
||||
|
||||
# Environment variables
|
||||
BLOCK_USER = os.getenv("BLOCK_USER", "false").lower() == "true"
|
||||
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,82 @@
|
||||
# Ultralytics Actions 🚀, AGPL-3.0 license https://ultralytics.com/license
|
||||
|
||||
import requests
|
||||
|
||||
from .utils import (
|
||||
GITHUB_API_URL,
|
||||
GITHUB_HEADERS,
|
||||
PR_NUMBER,
|
||||
REPO_NAME,
|
||||
get_completion,
|
||||
get_pr_diff,
|
||||
)
|
||||
|
||||
# Action settings
|
||||
SUMMARY_START = (
|
||||
"## 🛠️ PR Summary\n\n<sub>Made with ❤️ by [Ultralytics Actions](https://github.com/ultralytics/actions)<sub>\n\n"
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
@@ -0,0 +1,192 @@
|
||||
# Ultralytics Actions 🚀, AGPL-3.0 license https://ultralytics.com/license
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import requests
|
||||
|
||||
from .utils import (
|
||||
GITHUB_API_URL,
|
||||
GITHUB_HEADERS,
|
||||
GITHUB_HEADERS_DIFF,
|
||||
GITHUB_TOKEN,
|
||||
REPO_NAME,
|
||||
get_completion,
|
||||
remove_html_comments,
|
||||
)
|
||||
|
||||
# Environment variables
|
||||
CURRENT_TAG = os.getenv("CURRENT_TAG")
|
||||
PREVIOUS_TAG = os.getenv("PREVIOUS_TAG")
|
||||
|
||||
|
||||
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}"
|
||||
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."""
|
||||
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()
|
||||
@@ -0,0 +1,173 @@
|
||||
# Ultralytics Actions 🚀, AGPL-3.0 license https://ultralytics.com/license
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
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 main(root_dir=Path.cwd(), 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)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,35 @@
|
||||
# Ultralytics Actions 🚀, AGPL-3.0 license https://ultralytics.com/license
|
||||
|
||||
from .common_utils import remove_html_comments
|
||||
from .github_utils import (
|
||||
GITHUB_API_URL,
|
||||
GITHUB_EVENT_NAME,
|
||||
GITHUB_EVENT_PATH,
|
||||
GITHUB_HEADERS,
|
||||
GITHUB_HEADERS_DIFF,
|
||||
GITHUB_TOKEN,
|
||||
PR_NUMBER,
|
||||
REPO_NAME,
|
||||
get_github_data,
|
||||
get_pr_diff,
|
||||
graphql_request,
|
||||
)
|
||||
from .openai_utils import OPENAI_API_KEY, OPENAI_MODEL, get_completion
|
||||
|
||||
__all__ = (
|
||||
"remove_html_comments",
|
||||
"GITHUB_API_URL",
|
||||
"GITHUB_HEADERS",
|
||||
"GITHUB_HEADERS_DIFF",
|
||||
"GITHUB_TOKEN",
|
||||
"REPO_NAME",
|
||||
"PR_NUMBER",
|
||||
"GITHUB_EVENT_NAME",
|
||||
"GITHUB_EVENT_PATH",
|
||||
"get_github_data",
|
||||
"get_pr_diff",
|
||||
"graphql_request",
|
||||
"OPENAI_API_KEY",
|
||||
"OPENAI_MODEL",
|
||||
"get_completion",
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
# Ultralytics Actions 🚀, AGPL-3.0 license https://ultralytics.com/license
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def remove_html_comments(body: str) -> str:
|
||||
"""Removes HTML comments from a string using regex pattern matching."""
|
||||
return re.sub(r"<!--.*?-->", "", body, flags=re.DOTALL).strip()
|
||||
@@ -0,0 +1,44 @@
|
||||
# Ultralytics Actions 🚀, AGPL-3.0 license https://ultralytics.com/license
|
||||
|
||||
import os
|
||||
|
||||
import requests
|
||||
|
||||
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"}
|
||||
GITHUB_HEADERS_DIFF = {"Authorization": f"token {GITHUB_TOKEN}", "Accept": "application/vnd.github.v3.diff"}
|
||||
|
||||
PR_NUMBER = os.getenv("PR_NUMBER")
|
||||
REPO_NAME = os.getenv("GITHUB_REPOSITORY")
|
||||
GITHUB_EVENT_NAME = os.getenv("GITHUB_EVENT_NAME")
|
||||
GITHUB_EVENT_PATH = os.getenv("GITHUB_EVENT_PATH")
|
||||
|
||||
|
||||
def get_pr_diff(pr_number: int) -> str:
|
||||
"""Retrieves the diff content for a specified pull request in a GitHub repository."""
|
||||
url = f"{GITHUB_API_URL}/repos/{REPO_NAME}/pulls/{pr_number}"
|
||||
r = requests.get(url, headers=GITHUB_HEADERS_DIFF)
|
||||
return r.text if r.status_code == 200 else ""
|
||||
|
||||
|
||||
def get_github_data(endpoint: str) -> dict:
|
||||
"""Fetches GitHub repository data from a specified endpoint using the 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 against the GitHub API and returns the response as a dictionary."""
|
||||
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
|
||||
@@ -0,0 +1,24 @@
|
||||
# Ultralytics Actions 🚀, AGPL-3.0 license https://ultralytics.com/license
|
||||
|
||||
import os
|
||||
|
||||
import requests
|
||||
|
||||
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o")
|
||||
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
||||
|
||||
|
||||
def get_completion(messages: list) -> str:
|
||||
"""Generates a completion using OpenAI's API based on input messages."""
|
||||
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
|
||||
@@ -0,0 +1,95 @@
|
||||
# Ultralytics Actions 🚀, AGPL-3.0 license
|
||||
|
||||
# Overview:
|
||||
# This pyproject.toml file manages the build, packaging, and distribution of the Ultralytics Actions package.
|
||||
# It defines essential project metadata, dependencies, and settings used to develop and deploy the package.
|
||||
|
||||
# Key Sections:
|
||||
# - [build-system]: Specifies the build requirements and backend (e.g., setuptools, wheel).
|
||||
# - [project]: Includes details like name, version, description, authors, dependencies and more.
|
||||
# - [project.optional-dependencies]: Provides additional, optional packages for extended features.
|
||||
# - [tool.*]: Configures settings for various tools (pytest, ruff, etc.) used in the project.
|
||||
|
||||
# Installation:
|
||||
# The Ultralytics Actions package is primarily used as a GitHub Actions workflow.
|
||||
# For development purposes, you can install the package in editable mode with: 'pip install -e .'
|
||||
# This approach allows for real-time code modifications without the need for re-installation.
|
||||
|
||||
# Documentation:
|
||||
# For comprehensive documentation and usage instructions, visit: https://github.com/ultralytics/actions
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=70.0.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "ultralytics-actions"
|
||||
dynamic = ["version"]
|
||||
description = "Ultralytics Actions for GitHub automation and PR management."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.8"
|
||||
license = { text = "AGPL-3.0" }
|
||||
keywords = [
|
||||
"github-actions",
|
||||
"ci-cd",
|
||||
"workflow-automation",
|
||||
"pull-request-automation",
|
||||
"code-review",
|
||||
"release-automation",
|
||||
"markdown-processing",
|
||||
"devops",
|
||||
"github-integration",
|
||||
"continuous-integration"
|
||||
]
|
||||
authors = [
|
||||
{ name = "Glenn Jocher", email = "[email protected]" },
|
||||
]
|
||||
maintainers = [
|
||||
{ name = "Ultralytics", email = "[email protected]" },
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Software Development",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"Topic :: Internet :: WWW/HTTP",
|
||||
"Operating System :: OS Independent",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"requests>=2.26.0",
|
||||
"ruff>=0.1.6",
|
||||
"docformatter>=1.7.5",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest",
|
||||
"black",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
"Homepage" = "https://ultralytics.com"
|
||||
"Source" = "https://github.com/ultralytics/actions"
|
||||
"Documentation" = "https://docs.ultralytics.com"
|
||||
"Bug Reports" = "https://github.com/ultralytics/actions/issues"
|
||||
"Changelog" = "https://github.com/ultralytics/actions/releases"
|
||||
|
||||
[project.scripts]
|
||||
ultralytics-actions-first-interaction = "actions.first_interaction:main"
|
||||
ultralytics-actions-summarize-pr = "actions.summarize_pr:main"
|
||||
ultralytics-actions-summarize-release = "actions.summarize_release:main"
|
||||
ultralytics-actions-update-markdown-code-blocks = "actions.update_markdown_code_blocks:main"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = { find = { where = ["."], include = ["actions", "actions.*"] } }
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
version = { attr = "actions.__version__" }
|
||||
Reference in New Issue
Block a user