diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index 4604dc2c..100a1605 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -9,6 +9,7 @@ on: branches: [main, master] pull_request: branches: [main, master] + types: [opened, closed, synchronize] jobs: format: @@ -17,8 +18,12 @@ jobs: - name: Run Ultralytics Formatting uses: ultralytics/actions@main with: - token: ${{ secrets.GITHUB_TOKEN }} # automatically generated + token: ${{ secrets.GITHUB_TOKEN }} # automatically generated, do not modify python: true - prettier: true + markdown: true spelling: false - links: true + links: false + summary: true # requires either 'openai_api_key' or 'openai_azure_api_key' and 'openai_azure_endpoint' + openai_azure_api_key: ${{ secrets.OPENAI_AZURE_API_KEY }} + openai_azure_endpoint: ${{ secrets.OPENAI_AZURE_ENDPOINT }} + #openai_api_key: ${{ secrets.OPENAI_API_KEY }} diff --git a/README.md b/README.md index 2050af99..1f353e04 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,6 @@ To use this action in your Ultralytics repository: with: token: ${{ secrets.GITHUB_TOKEN }} # automatically generated python: true - docstrings: true markdown: true spelling: true links: true diff --git a/action.yml b/action.yml index 3d14d44d..c81892c9 100644 --- a/action.yml +++ b/action.yml @@ -28,13 +28,32 @@ inputs: description: "Run Broken Links checks" required: false default: "false" + summary: + description: "Run PR Summary" + required: false + default: "false" + openai_api_key: + description: "OpenAI API Key" + required: false + openai_azure_api_key: + description: "OpenAI Azure API Key" + required: false + openai_azure_endpoint: + description: "OpenAI Azure Endpoint" + required: false + openai_model: + description: "OpenAI Model" + required: false + default: "gpt-4-1106-preview" runs: using: "composite" steps: - name: Print Action Information run: | - echo "github.event_name: ${{ github.event_name }}" + echo "github.event_name: ${{ github.event_name }}" # i.e. "pull_request" + echo "github.event.action: ${{ github.event.action }}" # i.e. "open" or "close" echo "github.repository: ${{ github.repository }}" + echo "github.event.pull_request.number: ${{ github.event.pull_request.number }}" echo "github.event.pull_request.head.repo.full_name: ${{ github.event.pull_request.head.repo.full_name }}" echo "github.actor: ${{ github.actor }}" echo "github.event.pull_request.head.ref: ${{ github.event.pull_request.head.ref }}" @@ -88,7 +107,7 @@ runs: # Prettier (JavaScript, JSX, Angular, Vue, Flow, TypeScript, CSS, HTML, JSON, GraphQL, Markdown, YAML) ------------- - name: Run Prettier - if: inputs.prettier == 'true' || inputs.markdown == 'true' + if: inputs.prettier == 'true' run: | npm install --global prettier npx prettier \ @@ -123,6 +142,23 @@ runs: shell: bash continue-on-error: false + # PR Summary ------------------------------------------------------------------------------------------------------- + - name: Summarize PR + if: inputs.summary == 'true' && (github.event_name == 'pull_request' || github.event_name == 'pull_request_target') && (inputs.event.action == 'opened' || inputs.event.action == 'closed') + env: + REPO_NAME: ${{ github.repository }} + PR_NUMBER: ${{ github.event.pull_request.number }} + GITHUB_TOKEN: ${{ inputs.token }} + OPENAI_API_KEY: ${{ inputs.openai_api_key }} + OPENAI_AZURE_API_KEY: ${{ inputs.openai_azure_api_key }} + OPENAI_AZURE_ENDPOINT: ${{ inputs.openai_azure_endpoint }} + OPENAI_MODEL: ${{ inputs.openai_model }} + run: | + pip install --no-cache -q openai + python utils/run_pr_summary.py + shell: bash + continue-on-error: true + # Broken links ----------------------------------------------------------------------------------------------------- - name: Broken Link Checker if: inputs.links == 'true' diff --git a/utils/run_pr_summary.py b/utils/run_pr_summary.py new file mode 100644 index 00000000..0e24e37e --- /dev/null +++ b/utils/run_pr_summary.py @@ -0,0 +1,107 @@ +import os + +import requests +from openai import OpenAI, AzureOpenAI + +REPO_NAME = os.getenv("REPO_NAME") +PR_NUMBER = os.getenv("PR_NUMBER") +GITHUB_TOKEN = os.getenv("GITHUB_TOKEN") +GITHUB_HEADERS = {"Authorization": f"token {GITHUB_TOKEN}"} +OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") +OPENAI_AZURE_API_KEY = os.getenv("OPENAI_AZURE_API_KEY") +OPENAI_AZURE_ENDPOINT = os.getenv("OPENAI_AZURE_ENDPOINT") +OPENAI_AZURE_BOTH = OPENAI_AZURE_API_KEY and OPENAI_AZURE_ENDPOINT +OPENAI_MODEL = os.getenv("OPENAI_MODEL") +OPENAI_MODEL_TOKENS = 128000 # update with model +SUMMARY_START = ( + "## 🛠️ PR Summary\n\nMade with ❤️ by [Ultralytics Actions](https://github.com/ultralytics/actions)\n\n" +) + +# Checks +assert OPENAI_MODEL, "No model found, please define OPENAI_MODEL" +assert ( + OPENAI_API_KEY or OPENAI_AZURE_BOTH +), "No OpenAI Keys found, please pass either OPENAI_API_KEY or both (OPENAI_AZURE_API_KEY and OPENAI_AZURE_ENDPOINT)" +if OPENAI_AZURE_API_KEY or OPENAI_AZURE_ENDPOINT: + assert OPENAI_AZURE_BOTH, "For Azure usage both both OPENAI_AZURE_API_KEY and OPENAI_AZURE_ENDPOINT must be passed." + + +def openai_client(azure=OPENAI_AZURE_BOTH): + """Returns OpenAI client instance.""" + return ( + AzureOpenAI( + api_key=OPENAI_AZURE_API_KEY, api_version="2023-09-01-preview", azure_endpoint=OPENAI_AZURE_ENDPOINT + ) + if azure + else OpenAI(api_key=OPENAI_API_KEY) + ) + + +def get_pr_diff(repo_name, pr_number): + """Fetches the diff of a specific PR from a GitHub repository.""" + url = f"https://api.github.com/repos/{repo_name}/pulls/{pr_number}" + headers = {"Authorization": f"token {GITHUB_TOKEN}", "Accept": "application/vnd.github.v3.diff"} + response = requests.get(url, headers=headers) + return response.text if response.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(OPENAI_MODEL_TOKENS * ratio * 0.7) # use up to 70% of the context window + 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]}", + }, + ] + response = openai_client().chat.completions.create(model=OPENAI_MODEL, messages=messages).choices[0] + reply = response.message.content.strip() + 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"https://api.github.com/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 + + +if __name__ == "__main__": + # Fetch PR details + diff = get_pr_diff(REPO_NAME, 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}")