ultralytics-actions 0.1.9__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of ultralytics-actions might be problematic. Click here for more details.
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/PKG-INFO +1 -1
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/actions/__init__.py +1 -1
- ultralytics_actions-0.2.0/actions/dispatch_actions.py +172 -0
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/actions/first_interaction.py +3 -5
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/actions/review_pr.py +31 -34
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/actions/summarize_pr.py +4 -5
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/actions/summarize_release.py +1 -1
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/actions/utils/__init__.py +4 -2
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/actions/utils/common_utils.py +1 -0
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/actions/utils/github_utils.py +19 -8
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/actions/utils/openai_utils.py +41 -16
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/tests/test_dispatch_actions.py +62 -6
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/tests/test_github_utils.py +8 -9
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/ultralytics_actions.egg-info/PKG-INFO +1 -1
- ultralytics_actions-0.1.9/actions/dispatch_actions.py +0 -116
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/LICENSE +0 -0
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/README.md +0 -0
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/actions/update_file_headers.py +0 -0
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/actions/update_markdown_code_blocks.py +0 -0
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/actions/utils/version_utils.py +0 -0
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/pyproject.toml +0 -0
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/setup.cfg +0 -0
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/tests/test_cli_commands.py +0 -0
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/tests/test_common_utils.py +0 -0
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/tests/test_file_headers.py +0 -0
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/tests/test_first_interaction.py +0 -0
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/tests/test_init.py +0 -0
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/tests/test_openai_utils.py +0 -0
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/tests/test_summarize_pr.py +0 -0
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/tests/test_summarize_release.py +0 -0
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/tests/test_update_markdown_codeblocks.py +0 -0
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/tests/test_urls.py +0 -0
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/ultralytics_actions.egg-info/SOURCES.txt +0 -0
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/ultralytics_actions.egg-info/dependency_links.txt +0 -0
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/ultralytics_actions.egg-info/entry_points.txt +0 -0
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/ultralytics_actions.egg-info/requires.txt +0 -0
- {ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/ultralytics_actions.egg-info/top_level.txt +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ultralytics-actions
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Ultralytics Actions for GitHub automation and PR management.
|
|
5
5
|
Author-email: Glenn Jocher <glenn.jocher@ultralytics.com>
|
|
6
6
|
Maintainer-email: Ultralytics <hello@ultralytics.com>
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
from .utils import GITHUB_API_URL, Action
|
|
10
|
+
|
|
11
|
+
# Configuration
|
|
12
|
+
RUN_CI_KEYWORD = "@ultralytics/run-ci" # and then to merge "@ultralytics/run-ci-and-merge"
|
|
13
|
+
WORKFLOW_FILES = ["ci.yml", "docker.yml"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_pr_branch(event) -> tuple[str, str | None]:
|
|
17
|
+
"""Gets the PR branch name, creating temp branch for forks, returning (branch, temp_branch_to_delete)."""
|
|
18
|
+
import subprocess
|
|
19
|
+
import tempfile
|
|
20
|
+
|
|
21
|
+
pr_number = event.event_data["issue"]["number"]
|
|
22
|
+
pr_data = event.get_repo_data(f"pulls/{pr_number}")
|
|
23
|
+
head = pr_data.get("head", {})
|
|
24
|
+
|
|
25
|
+
# Check if PR is from a fork
|
|
26
|
+
is_fork = head.get("repo") and head["repo"]["id"] != pr_data["base"]["repo"]["id"]
|
|
27
|
+
|
|
28
|
+
if is_fork:
|
|
29
|
+
# Create temp branch in base repo by pushing fork code
|
|
30
|
+
temp_branch = f"temp-ci-{pr_number}-{int(time.time() * 1000)}"
|
|
31
|
+
fork_repo = head["repo"]["full_name"]
|
|
32
|
+
fork_branch = head["ref"]
|
|
33
|
+
base_repo = event.repository
|
|
34
|
+
token = os.environ.get("GITHUB_TOKEN")
|
|
35
|
+
if not token:
|
|
36
|
+
raise ValueError("GITHUB_TOKEN environment variable is not set")
|
|
37
|
+
|
|
38
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
39
|
+
repo_dir = os.path.join(tmp_dir, "repo")
|
|
40
|
+
base_url = f"https://x-access-token:{token}@github.com/{base_repo}.git"
|
|
41
|
+
fork_url = f"https://github.com/{fork_repo}.git"
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
# Clone base repo (minimal)
|
|
45
|
+
subprocess.run(["git", "clone", "--depth", "1", base_url, repo_dir], check=True, capture_output=True)
|
|
46
|
+
|
|
47
|
+
# Add fork as remote and fetch the PR branch
|
|
48
|
+
subprocess.run(
|
|
49
|
+
["git", "remote", "add", "fork", fork_url], cwd=repo_dir, check=True, capture_output=True
|
|
50
|
+
)
|
|
51
|
+
subprocess.run(
|
|
52
|
+
["git", "fetch", "fork", f"{fork_branch}:{temp_branch}"],
|
|
53
|
+
cwd=repo_dir,
|
|
54
|
+
check=True,
|
|
55
|
+
capture_output=True,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# Push temp branch to base repo
|
|
59
|
+
subprocess.run(["git", "push", "origin", temp_branch], cwd=repo_dir, check=True, capture_output=True)
|
|
60
|
+
except subprocess.CalledProcessError as e:
|
|
61
|
+
# Sanitize error output to prevent token leakage
|
|
62
|
+
stderr = e.stderr.decode() if e.stderr else "No stderr output"
|
|
63
|
+
stderr = stderr.replace(token, "***TOKEN***")
|
|
64
|
+
raise RuntimeError(f"Failed to create tmp branch from fork (exit code {e.returncode}): {stderr}") from e
|
|
65
|
+
|
|
66
|
+
return temp_branch, temp_branch
|
|
67
|
+
|
|
68
|
+
return head.get("ref", "main"), None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def trigger_and_get_workflow_info(event, branch: str, temp_branch: str | None = None) -> list[dict]:
|
|
72
|
+
"""Triggers workflows and returns their information, deleting temp branch if provided."""
|
|
73
|
+
repo = event.repository
|
|
74
|
+
results = []
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
# Trigger all workflows
|
|
78
|
+
for file in WORKFLOW_FILES:
|
|
79
|
+
event.post(f"{GITHUB_API_URL}/repos/{repo}/actions/workflows/{file}/dispatches", json={"ref": branch})
|
|
80
|
+
|
|
81
|
+
# Wait for workflows to be created and start
|
|
82
|
+
time.sleep(60)
|
|
83
|
+
|
|
84
|
+
# Collect information about all workflows
|
|
85
|
+
for file in WORKFLOW_FILES:
|
|
86
|
+
# Get workflow name
|
|
87
|
+
response = event.get(f"{GITHUB_API_URL}/repos/{repo}/actions/workflows/{file}")
|
|
88
|
+
name = file.replace(".yml", "").title()
|
|
89
|
+
if response.status_code == 200:
|
|
90
|
+
name = response.json().get("name", name)
|
|
91
|
+
|
|
92
|
+
# Get run information
|
|
93
|
+
run_url = f"https://github.com/{repo}/actions/workflows/{file}"
|
|
94
|
+
run_number = None
|
|
95
|
+
|
|
96
|
+
runs_response = event.get(
|
|
97
|
+
f"{GITHUB_API_URL}/repos/{repo}/actions/workflows/{file}/runs?branch={branch}&event=workflow_dispatch&per_page=1"
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
if runs_response.status_code == 200 and (runs := runs_response.json().get("workflow_runs", [])):
|
|
101
|
+
run_url = runs[0].get("html_url", run_url)
|
|
102
|
+
run_number = runs[0].get("run_number")
|
|
103
|
+
|
|
104
|
+
results.append({"name": name, "file": file, "url": run_url, "run_number": run_number})
|
|
105
|
+
finally:
|
|
106
|
+
# Always delete temp branch even if workflow collection fails
|
|
107
|
+
if temp_branch:
|
|
108
|
+
event.delete(f"{GITHUB_API_URL}/repos/{repo}/git/refs/heads/{temp_branch}")
|
|
109
|
+
|
|
110
|
+
return results
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def update_comment(event, comment_body: str, triggered_actions: list[dict], branch: str):
|
|
114
|
+
"""Updates the comment with workflow information."""
|
|
115
|
+
if not triggered_actions:
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
119
|
+
summary = f"""
|
|
120
|
+
|
|
121
|
+
## ⚡ Actions Trigger
|
|
122
|
+
|
|
123
|
+
<sub>Made with ❤️ by [Ultralytics Actions](https://www.ultralytics.com/actions)<sub>
|
|
124
|
+
|
|
125
|
+
GitHub Actions below triggered via workflow dispatch for this PR at {timestamp} with `{RUN_CI_KEYWORD}` command:
|
|
126
|
+
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
for action in triggered_actions:
|
|
130
|
+
run_info = f" run {action['run_number']}" if action["run_number"] else ""
|
|
131
|
+
summary += f"* ✅ [{action['name']}]({action['url']}): `{action['file']}`{run_info}\n"
|
|
132
|
+
|
|
133
|
+
new_body = comment_body.replace(RUN_CI_KEYWORD, summary).strip()
|
|
134
|
+
comment_id = event.event_data["comment"]["id"]
|
|
135
|
+
event.patch(f"{GITHUB_API_URL}/repos/{event.repository}/issues/comments/{comment_id}", json={"body": new_body})
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def main(*args, **kwargs):
|
|
139
|
+
"""Handles triggering workflows from PR comments."""
|
|
140
|
+
event = Action(*args, **kwargs)
|
|
141
|
+
|
|
142
|
+
# Only process new comments on PRs
|
|
143
|
+
if (
|
|
144
|
+
event.event_name != "issue_comment"
|
|
145
|
+
or "pull_request" not in event.event_data.get("issue", {})
|
|
146
|
+
or event.event_data.get("action") != "created"
|
|
147
|
+
):
|
|
148
|
+
return
|
|
149
|
+
|
|
150
|
+
# Get comment info
|
|
151
|
+
comment_body = event.event_data["comment"].get("body") or ""
|
|
152
|
+
username = event.event_data["comment"]["user"]["login"]
|
|
153
|
+
|
|
154
|
+
# Check for keyword without surrounding backticks to avoid triggering on replies
|
|
155
|
+
has_keyword = RUN_CI_KEYWORD in comment_body and comment_body.count(RUN_CI_KEYWORD) > comment_body.count(
|
|
156
|
+
f"`{RUN_CI_KEYWORD}`"
|
|
157
|
+
)
|
|
158
|
+
if not has_keyword or not event.is_org_member(username):
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
# Get branch, trigger workflows, and update comment
|
|
162
|
+
event.toggle_eyes_reaction(True)
|
|
163
|
+
branch, temp_branch = get_pr_branch(event)
|
|
164
|
+
print(f"Triggering workflows on branch: {branch}" + (" (temp)" if temp_branch else ""))
|
|
165
|
+
|
|
166
|
+
triggered_actions = trigger_and_get_workflow_info(event, branch, temp_branch)
|
|
167
|
+
update_comment(event, comment_body, triggered_actions, branch)
|
|
168
|
+
event.toggle_eyes_reaction(False)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__":
|
|
172
|
+
main()
|
|
@@ -6,11 +6,9 @@ import os
|
|
|
6
6
|
import time
|
|
7
7
|
|
|
8
8
|
from . import review_pr
|
|
9
|
-
from .
|
|
9
|
+
from .summarize_pr import SUMMARY_MARKER
|
|
10
|
+
from .utils import ACTIONS_CREDIT, Action, filter_labels, get_completion, get_pr_open_response, remove_html_comments
|
|
10
11
|
|
|
11
|
-
SUMMARY_START = (
|
|
12
|
-
"## 🛠️ PR Summary\n\n<sub>Made with ❤️ by [Ultralytics Actions](https://github.com/ultralytics/actions)<sub>\n\n"
|
|
13
|
-
)
|
|
14
12
|
BLOCK_USER = os.getenv("BLOCK_USER", "false").lower() == "true"
|
|
15
13
|
AUTO_PR_REVIEW = os.getenv("REVIEW", "true").lower() == "true"
|
|
16
14
|
|
|
@@ -196,7 +194,7 @@ def main(*args, **kwargs):
|
|
|
196
194
|
|
|
197
195
|
if summary := response.get("summary"):
|
|
198
196
|
print("Updating PR description with summary...")
|
|
199
|
-
event.update_pr_description(number,
|
|
197
|
+
event.update_pr_description(number, f"{SUMMARY_MARKER}\n\n{ACTIONS_CREDIT}\n\n{summary}")
|
|
200
198
|
else:
|
|
201
199
|
summary = body
|
|
202
200
|
|
|
@@ -5,9 +5,9 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
import re
|
|
7
7
|
|
|
8
|
-
from .utils import GITHUB_API_URL, MAX_PROMPT_CHARS, Action, get_completion, remove_html_comments
|
|
8
|
+
from .utils import ACTIONS_CREDIT, GITHUB_API_URL, MAX_PROMPT_CHARS, Action, get_completion, remove_html_comments
|
|
9
9
|
|
|
10
|
-
REVIEW_MARKER = "🔍 PR Review"
|
|
10
|
+
REVIEW_MARKER = "## 🔍 PR Review"
|
|
11
11
|
ERROR_MARKER = "⚠️ Review generation encountered an error"
|
|
12
12
|
EMOJI_MAP = {"CRITICAL": "❗", "HIGH": "⚠️", "MEDIUM": "💡", "LOW": "📝", "SUGGESTION": "💭"}
|
|
13
13
|
SKIP_PATTERNS = [
|
|
@@ -95,27 +95,25 @@ def generate_pr_review(repository: str, diff_text: str, pr_title: str, pr_descri
|
|
|
95
95
|
lines_changed = sum(len(sides["RIGHT"]) + len(sides["LEFT"]) for sides in diff_files.values())
|
|
96
96
|
|
|
97
97
|
content = (
|
|
98
|
-
"You are an expert code reviewer for Ultralytics.
|
|
99
|
-
"Focus on:
|
|
100
|
-
"FORMATTING: Use backticks for code: `x=3`, `file.py`, `function()`\n\n"
|
|
98
|
+
"You are an expert code reviewer for Ultralytics. Review the code changes and provide inline comments where you identify issues or opportunities for improvement.\n\n"
|
|
99
|
+
"Focus on: bugs, security vulnerabilities, performance issues, best practices, edge cases, error handling, and code clarity.\n\n"
|
|
101
100
|
"CRITICAL RULES:\n"
|
|
102
|
-
"1.
|
|
103
|
-
"2.
|
|
104
|
-
"3.
|
|
101
|
+
"1. Provide balanced, constructive feedback - flag bugs, improvements, and best practice issues\n"
|
|
102
|
+
"2. For issues spanning multiple adjacent lines, use 'start_line' to create ONE multi-line comment, never separate comments\n"
|
|
103
|
+
"3. Combine related issues into a single comment when they stem from the same root cause\n"
|
|
105
104
|
"4. Prioritize: CRITICAL bugs/security > HIGH impact > code quality improvements\n"
|
|
106
105
|
"5. Keep comments concise and friendly - avoid jargon\n"
|
|
107
|
-
"6.
|
|
106
|
+
"6. Use backticks for code: `x=3`, `file.py`, `function()`\n"
|
|
107
|
+
"7. Skip routine changes: imports, version updates, standard refactoring\n\n"
|
|
108
108
|
"SUMMARY:\n"
|
|
109
109
|
"- Brief and actionable - what needs fixing, not where (locations shown in inline comments)\n\n"
|
|
110
110
|
"SUGGESTIONS:\n"
|
|
111
|
-
"-
|
|
112
|
-
"-
|
|
113
|
-
"-
|
|
114
|
-
"-
|
|
115
|
-
"-
|
|
116
|
-
"-
|
|
117
|
-
"- Avoid triple backticks (```) in suggestions as they break markdown formatting\n"
|
|
118
|
-
"- It's better to flag an issue without a suggestion than provide a wrong or uncertain fix\n\n"
|
|
111
|
+
"- Provide 'suggestion' field with ready-to-merge code when you can confidently fix the issue\n"
|
|
112
|
+
"- Suggestions must be complete, working code with NO comments, placeholders, or explanations\n"
|
|
113
|
+
"- For single-line fixes: provide 'suggestion' without 'start_line' to replace the line at 'line'\n"
|
|
114
|
+
"- Do not provide multi-line fixes: suggestions should only be single line\n"
|
|
115
|
+
"- Match the exact indentation of the original code\n"
|
|
116
|
+
"- Avoid triple backticks (```) in suggestions as they break markdown formatting\n\n"
|
|
119
117
|
"LINE NUMBERS:\n"
|
|
120
118
|
"- Each line in the diff is prefixed with its line number for clarity:\n"
|
|
121
119
|
" R 123 +added code <- RIGHT side (new file), line 123\n"
|
|
@@ -131,7 +129,7 @@ def generate_pr_review(repository: str, diff_text: str, pr_title: str, pr_descri
|
|
|
131
129
|
"- Extract line numbers from R#### or L#### prefixes in the diff\n"
|
|
132
130
|
"- Exact paths (no ./), 'side' field must match R (RIGHT) or L (LEFT) prefix\n"
|
|
133
131
|
"- Severity: CRITICAL, HIGH, MEDIUM, LOW, SUGGESTION\n"
|
|
134
|
-
f"- Files changed: {len(file_list)} ({', '.join(file_list[:
|
|
132
|
+
f"- Files changed: {len(file_list)} ({', '.join(file_list[:30])}{'...' if len(file_list) > 30 else ''})\n"
|
|
135
133
|
f"- Lines changed: {lines_changed}\n"
|
|
136
134
|
)
|
|
137
135
|
|
|
@@ -140,18 +138,18 @@ def generate_pr_review(repository: str, diff_text: str, pr_title: str, pr_descri
|
|
|
140
138
|
{
|
|
141
139
|
"role": "user",
|
|
142
140
|
"content": (
|
|
143
|
-
f"Review this PR in https://github.com/{repository}:\n"
|
|
144
|
-
f"
|
|
145
|
-
f"
|
|
146
|
-
f"
|
|
141
|
+
f"Review this PR in https://github.com/{repository}:\n\n"
|
|
142
|
+
f"TITLE:\n{pr_title}\n\n"
|
|
143
|
+
f"BODY:\n{remove_html_comments(pr_description or '')[:1000]}\n\n"
|
|
144
|
+
f"DIFF:\n{augmented_diff[:MAX_PROMPT_CHARS]}\n\n"
|
|
147
145
|
"Now review this diff according to the rules above. Return JSON with comments array and summary."
|
|
148
146
|
),
|
|
149
147
|
},
|
|
150
148
|
]
|
|
151
149
|
|
|
152
|
-
# Debug
|
|
153
|
-
# print(f"\nSystem prompt (first
|
|
154
|
-
# print(f"\nUser prompt (first
|
|
150
|
+
# Debug output
|
|
151
|
+
# print(f"\nSystem prompt (first 3000 chars):\n{messages[0]['content'][:3000]}...\n")
|
|
152
|
+
# print(f"\nUser prompt (first 3000 chars):\n{messages[1]['content'][:3000]}...\n")
|
|
155
153
|
|
|
156
154
|
try:
|
|
157
155
|
response = get_completion(messages, reasoning_effort="low", model="gpt-5-codex")
|
|
@@ -271,9 +269,9 @@ def post_review_summary(event: Action, review_data: dict, review_number: int) ->
|
|
|
271
269
|
event_type = "COMMENT" if (has_error or has_inline_comments or has_issues) else "APPROVE"
|
|
272
270
|
|
|
273
271
|
body = (
|
|
274
|
-
f"
|
|
275
|
-
"
|
|
276
|
-
f"{review_data.get('summary', 'Review completed')[:
|
|
272
|
+
f"{review_title}\n\n"
|
|
273
|
+
f"{ACTIONS_CREDIT}\n\n"
|
|
274
|
+
f"{review_data.get('summary', 'Review completed')[:3000]}\n\n" # Clip summary length
|
|
277
275
|
)
|
|
278
276
|
|
|
279
277
|
if comments:
|
|
@@ -294,10 +292,10 @@ def post_review_summary(event: Action, review_data: dict, review_number: int) ->
|
|
|
294
292
|
|
|
295
293
|
severity = comment.get("severity") or "SUGGESTION"
|
|
296
294
|
side = comment.get("side", "RIGHT")
|
|
297
|
-
comment_body = f"{EMOJI_MAP.get(severity, '💭')} **{severity}**: {(comment.get('message') or '')[:
|
|
295
|
+
comment_body = f"{EMOJI_MAP.get(severity, '💭')} **{severity}**: {(comment.get('message') or '')[:3000]}"
|
|
298
296
|
|
|
299
297
|
if suggestion := comment.get("suggestion"):
|
|
300
|
-
suggestion = suggestion[:
|
|
298
|
+
suggestion = suggestion[:3000] # Clip suggestion length
|
|
301
299
|
if "```" not in suggestion:
|
|
302
300
|
# Extract original line indentation and apply to suggestion
|
|
303
301
|
if original_line := review_data.get("diff_files", {}).get(file_path, {}).get(side, {}).get(line):
|
|
@@ -307,10 +305,9 @@ def post_review_summary(event: Action, review_data: dict, review_number: int) ->
|
|
|
307
305
|
|
|
308
306
|
# Build comment with optional start_line for multi-line context
|
|
309
307
|
review_comment = {"path": file_path, "line": line, "body": comment_body, "side": side}
|
|
310
|
-
if start_line := comment.get("start_line"):
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
review_comment["start_side"] = side
|
|
308
|
+
if (start_line := comment.get("start_line")) and start_line < line:
|
|
309
|
+
review_comment["start_line"] = start_line
|
|
310
|
+
review_comment["start_side"] = side
|
|
314
311
|
|
|
315
312
|
review_comments.append(review_comment)
|
|
316
313
|
|
|
@@ -2,11 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from .utils import GITHUB_API_URL, Action, get_completion, get_pr_summary_prompt
|
|
5
|
+
from .utils import ACTIONS_CREDIT, GITHUB_API_URL, Action, get_completion, get_pr_summary_prompt
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
"## 🛠️ PR Summary\n\n<sub>Made with ❤️ by [Ultralytics Actions](https://github.com/ultralytics/actions)<sub>\n\n"
|
|
9
|
-
)
|
|
7
|
+
SUMMARY_MARKER = "## 🛠️ PR Summary"
|
|
10
8
|
|
|
11
9
|
|
|
12
10
|
def generate_merge_message(pr_summary, pr_credit, pr_url):
|
|
@@ -73,7 +71,8 @@ def generate_pr_summary(repository, diff_text):
|
|
|
73
71
|
reply = get_completion(messages, temperature=1.0)
|
|
74
72
|
if is_large:
|
|
75
73
|
reply = "**WARNING ⚠️** this PR is very large, summary may not cover all changes.\n\n" + reply
|
|
76
|
-
|
|
74
|
+
|
|
75
|
+
return f"{SUMMARY_MARKER}\n\n{ACTIONS_CREDIT}\n\n{reply}"
|
|
77
76
|
|
|
78
77
|
|
|
79
78
|
def label_fixed_issues(event, pr_summary):
|
|
@@ -196,7 +196,7 @@ def main(*args, **kwargs):
|
|
|
196
196
|
try:
|
|
197
197
|
summary = generate_release_summary(event, diff, prs, CURRENT_TAG, previous_tag)
|
|
198
198
|
except Exception as e:
|
|
199
|
-
print(f"Failed to generate summary: {
|
|
199
|
+
print(f"Failed to generate summary: {e}")
|
|
200
200
|
summary = "Failed to generate summary."
|
|
201
201
|
|
|
202
202
|
# Get the latest commit message
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
|
2
2
|
|
|
3
3
|
from .common_utils import (
|
|
4
|
+
ACTIONS_CREDIT,
|
|
4
5
|
REDIRECT_END_IGNORE_LIST,
|
|
5
6
|
REDIRECT_START_IGNORE_LIST,
|
|
6
7
|
REQUESTS_HEADERS,
|
|
@@ -20,13 +21,14 @@ from .openai_utils import (
|
|
|
20
21
|
from .version_utils import check_pubdev_version, check_pypi_version
|
|
21
22
|
|
|
22
23
|
__all__ = (
|
|
24
|
+
"ACTIONS_CREDIT",
|
|
23
25
|
"GITHUB_API_URL",
|
|
24
26
|
"GITHUB_GRAPHQL_URL",
|
|
25
27
|
"MAX_PROMPT_CHARS",
|
|
28
|
+
"REDIRECT_END_IGNORE_LIST",
|
|
29
|
+
"REDIRECT_START_IGNORE_LIST",
|
|
26
30
|
"REQUESTS_HEADERS",
|
|
27
31
|
"URL_IGNORE_LIST",
|
|
28
|
-
"REDIRECT_START_IGNORE_LIST",
|
|
29
|
-
"REDIRECT_END_IGNORE_LIST",
|
|
30
32
|
"Action",
|
|
31
33
|
"allow_redirect",
|
|
32
34
|
"check_pubdev_version",
|
|
@@ -101,7 +101,13 @@ mutation($labelableId: ID!, $labelIds: [ID!]!) {
|
|
|
101
101
|
class Action:
|
|
102
102
|
"""Handles GitHub Actions API interactions and event processing."""
|
|
103
103
|
|
|
104
|
-
def __init__(
|
|
104
|
+
def __init__(
|
|
105
|
+
self,
|
|
106
|
+
token: str | None = None,
|
|
107
|
+
event_name: str | None = None,
|
|
108
|
+
event_data: dict | None = None,
|
|
109
|
+
verbose: bool = True,
|
|
110
|
+
):
|
|
105
111
|
"""Initializes a GitHub Actions API handler with token and event data for processing events."""
|
|
106
112
|
self.token = token or os.getenv("GITHUB_TOKEN")
|
|
107
113
|
self.event_name = event_name or os.getenv("GITHUB_EVENT_NAME")
|
|
@@ -116,8 +122,8 @@ class Action:
|
|
|
116
122
|
self._pr_summary_cache = None
|
|
117
123
|
self._username_cache = None
|
|
118
124
|
self._default_status = {
|
|
119
|
-
"get": [200],
|
|
120
|
-
"post": [200, 201],
|
|
125
|
+
"get": [200, 204],
|
|
126
|
+
"post": [200, 201, 204],
|
|
121
127
|
"put": [200, 201, 204],
|
|
122
128
|
"patch": [200],
|
|
123
129
|
"delete": [200, 204],
|
|
@@ -134,9 +140,12 @@ class Action:
|
|
|
134
140
|
print(f"{'✓' if success else '✗'} {method.upper()} {url} → {r.status_code} ({elapsed:.1f}s)", flush=True)
|
|
135
141
|
if not success:
|
|
136
142
|
try:
|
|
137
|
-
|
|
143
|
+
error_data = r.json()
|
|
144
|
+
print(f" ❌ Error: {error_data.get('message', 'Unknown error')}")
|
|
145
|
+
if errors := error_data.get("errors"):
|
|
146
|
+
print(f" Details: {errors}")
|
|
138
147
|
except Exception:
|
|
139
|
-
print(f" ❌ Error: {r.text[:
|
|
148
|
+
print(f" ❌ Error: {r.text[:1000]}")
|
|
140
149
|
|
|
141
150
|
if not success and hard:
|
|
142
151
|
r.raise_for_status()
|
|
@@ -257,7 +266,7 @@ class Action:
|
|
|
257
266
|
self.delete(f"{url}/{self.eyes_reaction_id}")
|
|
258
267
|
self.eyes_reaction_id = None
|
|
259
268
|
|
|
260
|
-
def graphql_request(self, query: str, variables: dict = None) -> dict:
|
|
269
|
+
def graphql_request(self, query: str, variables: dict | None = None) -> dict:
|
|
261
270
|
"""Executes a GraphQL query against the GitHub API."""
|
|
262
271
|
result = self.post(GITHUB_GRAPHQL_URL, json={"query": query, "variables": variables}).json()
|
|
263
272
|
if "data" not in result or result.get("errors"):
|
|
@@ -331,7 +340,9 @@ class Action:
|
|
|
331
340
|
else:
|
|
332
341
|
self.post(f"{GITHUB_API_URL}/repos/{self.repository}/issues/{number}/comments", json={"body": comment})
|
|
333
342
|
|
|
334
|
-
def update_content(
|
|
343
|
+
def update_content(
|
|
344
|
+
self, number: int, node_id: str, issue_type: str, title: str | None = None, body: str | None = None
|
|
345
|
+
):
|
|
335
346
|
"""Updates the title and/or body of an issue, pull request, or discussion."""
|
|
336
347
|
if issue_type == "discussion":
|
|
337
348
|
variables = {"discussionId": node_id}
|
|
@@ -373,7 +384,7 @@ class Action:
|
|
|
373
384
|
def handle_alert(self, number: int, node_id: str, issue_type: str, username: str, block: bool = False):
|
|
374
385
|
"""Handles content flagged as alert: updates content, locks, optionally closes and blocks user."""
|
|
375
386
|
new_title = "Content Under Review"
|
|
376
|
-
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:
|
|
387
|
+
new_body = """This post has been flagged for review by [Ultralytics Actions](https://www.ultralytics.com/actions) due to possible spam, abuse, or off-topic content. For more information please see our:
|
|
377
388
|
|
|
378
389
|
- [Code of Conduct](https://docs.ultralytics.com/help/code-of-conduct/)
|
|
379
390
|
- [Security Policy](https://docs.ultralytics.com/help/security/)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import json
|
|
5
6
|
import os
|
|
6
7
|
import time
|
|
7
8
|
|
|
@@ -12,6 +13,12 @@ from actions.utils.common_utils import check_links_in_string
|
|
|
12
13
|
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
|
13
14
|
OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-5-2025-08-07")
|
|
14
15
|
MAX_PROMPT_CHARS = round(128000 * 3.3 * 0.5) # Max characters for prompt (50% of 128k context)
|
|
16
|
+
MODEL_COSTS = {
|
|
17
|
+
"gpt-5-codex": (1.25, 10.00),
|
|
18
|
+
"gpt-5-2025-08-07": (1.25, 10.00),
|
|
19
|
+
"gpt-5-nano-2025-08-07": (0.05, 0.40),
|
|
20
|
+
"gpt-5-mini-2025-08-07": (0.25, 2.00),
|
|
21
|
+
}
|
|
15
22
|
SYSTEM_PROMPT_ADDITION = """Guidance:
|
|
16
23
|
- Ultralytics Branding: Use YOLO11, YOLO26, etc., not YOLOv11, YOLOv26 (only older versions like YOLOv10 have a v). Always capitalize "HUB" in "Ultralytics HUB"; use "Ultralytics HUB", not "The Ultralytics HUB".
|
|
17
24
|
- Avoid Equations: Do not include equations or mathematical notations.
|
|
@@ -34,7 +41,7 @@ def remove_outer_codeblocks(string):
|
|
|
34
41
|
return string
|
|
35
42
|
|
|
36
43
|
|
|
37
|
-
def filter_labels(available_labels: dict, current_labels: list = None, is_pr: bool = False) -> dict:
|
|
44
|
+
def filter_labels(available_labels: dict, current_labels: list | None = None, is_pr: bool = False) -> dict:
|
|
38
45
|
"""Filters labels by removing manually-assigned and mutually exclusive labels."""
|
|
39
46
|
current_labels = current_labels or []
|
|
40
47
|
filtered = available_labels.copy()
|
|
@@ -107,8 +114,8 @@ def get_completion(
|
|
|
107
114
|
check_links: bool = True,
|
|
108
115
|
remove: list[str] = (" @giscus[bot]",),
|
|
109
116
|
temperature: float = 1.0,
|
|
110
|
-
reasoning_effort: str = None,
|
|
111
|
-
response_format: dict = None,
|
|
117
|
+
reasoning_effort: str | None = None,
|
|
118
|
+
response_format: dict | None = None,
|
|
112
119
|
model: str = OPENAI_MODEL,
|
|
113
120
|
) -> str | dict:
|
|
114
121
|
"""Generates a completion using OpenAI's Responses API with retry logic."""
|
|
@@ -124,23 +131,46 @@ def get_completion(
|
|
|
124
131
|
data["reasoning"] = {"effort": reasoning_effort or "low"}
|
|
125
132
|
|
|
126
133
|
try:
|
|
127
|
-
r = requests.post(url, json=data, headers=headers, timeout=
|
|
134
|
+
r = requests.post(url, json=data, headers=headers, timeout=(30, 900))
|
|
135
|
+
elapsed = r.elapsed.total_seconds()
|
|
128
136
|
success = r.status_code == 200
|
|
129
|
-
print(f"{'✓' if success else '✗'} POST {url} → {r.status_code} ({
|
|
137
|
+
print(f"{'✓' if success else '✗'} POST {url} → {r.status_code} ({elapsed:.1f}s)")
|
|
138
|
+
|
|
139
|
+
# Retry server errors
|
|
140
|
+
if attempt < 2 and r.status_code >= 500:
|
|
141
|
+
print(f"Retrying {r.status_code} in {2**attempt}s (attempt {attempt + 1}/3)...")
|
|
142
|
+
time.sleep(2**attempt)
|
|
143
|
+
continue
|
|
144
|
+
|
|
130
145
|
r.raise_for_status()
|
|
131
146
|
|
|
132
147
|
# Parse response
|
|
148
|
+
response_json = r.json()
|
|
133
149
|
content = ""
|
|
134
|
-
for item in
|
|
150
|
+
for item in response_json.get("output", []):
|
|
135
151
|
if item.get("type") == "message":
|
|
136
152
|
for c in item.get("content", []):
|
|
137
153
|
if c.get("type") == "output_text":
|
|
138
154
|
content += c.get("text") or ""
|
|
139
155
|
content = content.strip()
|
|
140
156
|
|
|
141
|
-
|
|
142
|
-
|
|
157
|
+
# Extract and print token usage
|
|
158
|
+
if usage := response_json.get("usage"):
|
|
159
|
+
input_tokens = usage.get("input_tokens", 0)
|
|
160
|
+
output_tokens = usage.get("output_tokens", 0)
|
|
161
|
+
thinking_tokens = (usage.get("output_tokens_details") or {}).get("reasoning_tokens", 0)
|
|
143
162
|
|
|
163
|
+
# Calculate cost
|
|
164
|
+
costs = MODEL_COSTS.get(model, (0.0, 0.0))
|
|
165
|
+
cost = (input_tokens * costs[0] + output_tokens * costs[1]) / 1e6
|
|
166
|
+
|
|
167
|
+
# Format summary
|
|
168
|
+
token_str = f"{input_tokens}→{output_tokens - thinking_tokens}"
|
|
169
|
+
if thinking_tokens:
|
|
170
|
+
token_str += f" (+{thinking_tokens} thinking)"
|
|
171
|
+
print(f"{model} ({token_str} = {input_tokens + output_tokens} tokens, ${cost:.5f}, {elapsed:.1f}s)")
|
|
172
|
+
|
|
173
|
+
if response_format and response_format.get("type") == "json_object":
|
|
144
174
|
return json.loads(content)
|
|
145
175
|
|
|
146
176
|
content = remove_outer_codeblocks(content)
|
|
@@ -154,18 +184,13 @@ def get_completion(
|
|
|
154
184
|
|
|
155
185
|
return content
|
|
156
186
|
|
|
157
|
-
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout):
|
|
187
|
+
except (requests.exceptions.ConnectionError, requests.exceptions.Timeout, json.JSONDecodeError) as e:
|
|
158
188
|
if attempt < 2:
|
|
159
|
-
print(f"
|
|
189
|
+
print(f"Retrying {e.__class__.__name__} in {2**attempt}s (attempt {attempt + 1}/3)...")
|
|
160
190
|
time.sleep(2**attempt)
|
|
161
191
|
continue
|
|
162
192
|
raise
|
|
163
|
-
except requests.exceptions.HTTPError
|
|
164
|
-
status_code = getattr(e.response, "status_code", 0) if e.response else 0
|
|
165
|
-
if attempt < 2 and status_code >= 500:
|
|
166
|
-
print(f"Server error {status_code}, retrying in {2**attempt}s")
|
|
167
|
-
time.sleep(2**attempt)
|
|
168
|
-
continue
|
|
193
|
+
except requests.exceptions.HTTPError: # 4xx errors
|
|
169
194
|
raise
|
|
170
195
|
|
|
171
196
|
return content
|
|
@@ -16,14 +16,39 @@ def test_get_pr_branch():
|
|
|
16
16
|
"""Test getting PR branch name."""
|
|
17
17
|
mock_event = MagicMock()
|
|
18
18
|
mock_event.event_data = {"issue": {"number": 123}}
|
|
19
|
-
mock_event.get_repo_data.return_value = {
|
|
19
|
+
mock_event.get_repo_data.return_value = {
|
|
20
|
+
"head": {"ref": "feature-branch", "repo": {"id": 1}},
|
|
21
|
+
"base": {"repo": {"id": 1}},
|
|
22
|
+
}
|
|
20
23
|
|
|
21
|
-
branch = get_pr_branch(mock_event)
|
|
24
|
+
branch, temp_branch = get_pr_branch(mock_event)
|
|
22
25
|
|
|
23
26
|
assert branch == "feature-branch"
|
|
27
|
+
assert temp_branch is None
|
|
24
28
|
mock_event.get_repo_data.assert_called_once_with("pulls/123")
|
|
25
29
|
|
|
26
30
|
|
|
31
|
+
def test_get_pr_branch_fork():
|
|
32
|
+
"""Test getting PR branch name for fork PRs."""
|
|
33
|
+
mock_event = MagicMock()
|
|
34
|
+
mock_event.event_data = {"issue": {"number": 456}}
|
|
35
|
+
mock_event.repository = "base/repo"
|
|
36
|
+
mock_event.get_repo_data.return_value = {
|
|
37
|
+
"head": {"ref": "fork-branch", "sha": "abc123", "repo": {"id": 2, "full_name": "fork/repo"}},
|
|
38
|
+
"base": {"repo": {"id": 1}},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
with patch("time.time", return_value=1234567.890):
|
|
42
|
+
with patch("subprocess.run") as mock_run:
|
|
43
|
+
with patch("os.environ.get", return_value="test-token"):
|
|
44
|
+
branch, temp_branch = get_pr_branch(mock_event)
|
|
45
|
+
|
|
46
|
+
assert branch == "temp-ci-456-1234567890"
|
|
47
|
+
assert temp_branch == "temp-ci-456-1234567890"
|
|
48
|
+
# Verify git commands were called
|
|
49
|
+
assert mock_run.call_count == 4 # clone, remote add, fetch, push
|
|
50
|
+
|
|
51
|
+
|
|
27
52
|
def test_trigger_and_get_workflow_info():
|
|
28
53
|
"""Test triggering workflows and getting info."""
|
|
29
54
|
mock_event = MagicMock()
|
|
@@ -54,9 +79,8 @@ def test_trigger_and_get_workflow_info():
|
|
|
54
79
|
mock_event.get.side_effect = get_side_effect
|
|
55
80
|
|
|
56
81
|
# Use patch to skip time.sleep and limit to one workflow
|
|
57
|
-
with patch("time.sleep"):
|
|
58
|
-
|
|
59
|
-
results = trigger_and_get_workflow_info(mock_event, "feature-branch")
|
|
82
|
+
with patch("time.sleep"), patch("actions.dispatch_actions.WORKFLOW_FILES", ["ci.yml"]):
|
|
83
|
+
results = trigger_and_get_workflow_info(mock_event, "feature-branch")
|
|
60
84
|
|
|
61
85
|
# Check results
|
|
62
86
|
assert len(results) == 1
|
|
@@ -64,6 +88,38 @@ def test_trigger_and_get_workflow_info():
|
|
|
64
88
|
assert results[0]["run_number"] == 42
|
|
65
89
|
|
|
66
90
|
|
|
91
|
+
def test_trigger_and_get_workflow_info_with_temp_branch():
|
|
92
|
+
"""Test that temp branches are deleted after triggering workflows."""
|
|
93
|
+
mock_event = MagicMock()
|
|
94
|
+
mock_event.repository = "test/repo"
|
|
95
|
+
|
|
96
|
+
workflow_response = MagicMock()
|
|
97
|
+
workflow_response.status_code = 200
|
|
98
|
+
workflow_response.json.return_value = {"name": "CI Workflow"}
|
|
99
|
+
|
|
100
|
+
runs_response = MagicMock()
|
|
101
|
+
runs_response.status_code = 200
|
|
102
|
+
runs_response.json.return_value = {"workflow_runs": [{"html_url": "https://example.com", "run_number": 1}]}
|
|
103
|
+
|
|
104
|
+
def get_side_effect(url):
|
|
105
|
+
if "workflows/ci.yml" in url and "runs" not in url:
|
|
106
|
+
return workflow_response
|
|
107
|
+
elif "runs" in url:
|
|
108
|
+
return runs_response
|
|
109
|
+
default = MagicMock()
|
|
110
|
+
default.status_code = 404
|
|
111
|
+
return default
|
|
112
|
+
|
|
113
|
+
mock_event.get.side_effect = get_side_effect
|
|
114
|
+
|
|
115
|
+
with patch("time.sleep"):
|
|
116
|
+
with patch("actions.dispatch_actions.WORKFLOW_FILES", ["ci.yml"]):
|
|
117
|
+
trigger_and_get_workflow_info(mock_event, "temp-ci-123-456", temp_branch="temp-ci-123-456")
|
|
118
|
+
|
|
119
|
+
# Verify temp branch was deleted
|
|
120
|
+
mock_event.delete.assert_called_once_with("https://api.github.com/repos/test/repo/git/refs/heads/temp-ci-123-456")
|
|
121
|
+
|
|
122
|
+
|
|
67
123
|
def test_update_comment_function():
|
|
68
124
|
"""Test updating comment with workflow info."""
|
|
69
125
|
mock_event = MagicMock()
|
|
@@ -116,7 +172,7 @@ def test_main_triggers_workflows():
|
|
|
116
172
|
with patch("actions.dispatch_actions.trigger_and_get_workflow_info") as mock_trigger:
|
|
117
173
|
with patch("actions.dispatch_actions.update_comment"):
|
|
118
174
|
# Set return values
|
|
119
|
-
mock_get_branch.return_value = "feature-branch"
|
|
175
|
+
mock_get_branch.return_value = ("feature-branch", None)
|
|
120
176
|
mock_trigger.return_value = [{"name": "CI", "file": "ci.yml", "url": "url", "run_number": 1}]
|
|
121
177
|
|
|
122
178
|
# Call the function
|
|
@@ -28,15 +28,14 @@ def test_check_pypi_version():
|
|
|
28
28
|
|
|
29
29
|
def test_action_init():
|
|
30
30
|
"""Test Action class initialization with default values."""
|
|
31
|
-
with patch.dict("os.environ", {"GITHUB_TOKEN": "test-token", "GITHUB_EVENT_NAME": "push"})
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
assert action.repository == "test/repo"
|
|
31
|
+
with patch.dict("os.environ", {"GITHUB_TOKEN": "test-token", "GITHUB_EVENT_NAME": "push"}), patch(
|
|
32
|
+
"actions.utils.github_utils.Action._load_event_data",
|
|
33
|
+
return_value={"repository": {"full_name": "test/repo"}},
|
|
34
|
+
):
|
|
35
|
+
action = Action()
|
|
36
|
+
assert action.token == "test-token"
|
|
37
|
+
assert action.event_name == "push"
|
|
38
|
+
assert action.repository == "test/repo"
|
|
40
39
|
|
|
41
40
|
|
|
42
41
|
def test_action_request_methods():
|
{ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/ultralytics_actions.egg-info/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ultralytics-actions
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Ultralytics Actions for GitHub automation and PR management.
|
|
5
5
|
Author-email: Glenn Jocher <glenn.jocher@ultralytics.com>
|
|
6
6
|
Maintainer-email: Ultralytics <hello@ultralytics.com>
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import time
|
|
6
|
-
from datetime import datetime
|
|
7
|
-
|
|
8
|
-
from .utils import GITHUB_API_URL, Action
|
|
9
|
-
|
|
10
|
-
# Configuration
|
|
11
|
-
RUN_CI_KEYWORD = "@ultralytics/run-ci" # and then to merge "@ultralytics/run-ci-and-merge"
|
|
12
|
-
WORKFLOW_FILES = ["ci.yml", "docker.yml"]
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def get_pr_branch(event) -> str:
|
|
16
|
-
"""Gets the PR branch name."""
|
|
17
|
-
pr_number = event.event_data["issue"]["number"]
|
|
18
|
-
pr_data = event.get_repo_data(f"pulls/{pr_number}")
|
|
19
|
-
return pr_data.get("head", {}).get("ref", "main")
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def trigger_and_get_workflow_info(event, branch: str) -> list[dict]:
|
|
23
|
-
"""Triggers workflows and returns their information."""
|
|
24
|
-
repo = event.repository
|
|
25
|
-
results = []
|
|
26
|
-
|
|
27
|
-
# Trigger all workflows
|
|
28
|
-
for file in WORKFLOW_FILES:
|
|
29
|
-
event.post(f"{GITHUB_API_URL}/repos/{repo}/actions/workflows/{file}/dispatches", json={"ref": branch})
|
|
30
|
-
|
|
31
|
-
# Wait for workflows to be created
|
|
32
|
-
time.sleep(10)
|
|
33
|
-
|
|
34
|
-
# Collect information about all workflows
|
|
35
|
-
for file in WORKFLOW_FILES:
|
|
36
|
-
# Get workflow name
|
|
37
|
-
response = event.get(f"{GITHUB_API_URL}/repos/{repo}/actions/workflows/{file}")
|
|
38
|
-
name = file.replace(".yml", "").title()
|
|
39
|
-
if response.status_code == 200:
|
|
40
|
-
name = response.json().get("name", name)
|
|
41
|
-
|
|
42
|
-
# Get run information
|
|
43
|
-
run_url = f"https://github.com/{repo}/actions/workflows/{file}"
|
|
44
|
-
run_number = None
|
|
45
|
-
|
|
46
|
-
runs_response = event.get(
|
|
47
|
-
f"{GITHUB_API_URL}/repos/{repo}/actions/workflows/{file}/runs?branch={branch}&event=workflow_dispatch&per_page=1"
|
|
48
|
-
)
|
|
49
|
-
|
|
50
|
-
if runs_response.status_code == 200:
|
|
51
|
-
if runs := runs_response.json().get("workflow_runs", []):
|
|
52
|
-
run_url = runs[0].get("html_url", run_url)
|
|
53
|
-
run_number = runs[0].get("run_number")
|
|
54
|
-
|
|
55
|
-
results.append({"name": name, "file": file, "url": run_url, "run_number": run_number})
|
|
56
|
-
|
|
57
|
-
return results
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
def update_comment(event, comment_body: str, triggered_actions: list[dict], branch: str):
|
|
61
|
-
"""Updates the comment with workflow information."""
|
|
62
|
-
if not triggered_actions:
|
|
63
|
-
return
|
|
64
|
-
|
|
65
|
-
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S UTC")
|
|
66
|
-
summary = (
|
|
67
|
-
f"\n\n## ⚡ Actions Trigger\n\n"
|
|
68
|
-
f"<sub>Made with ❤️ by [Ultralytics Actions](https://www.ultralytics.com/actions)<sub>\n\n"
|
|
69
|
-
f"GitHub Actions below triggered via workflow dispatch on this "
|
|
70
|
-
f"PR branch `{branch}` at {timestamp} with `{RUN_CI_KEYWORD}` command:\n\n"
|
|
71
|
-
)
|
|
72
|
-
|
|
73
|
-
for action in triggered_actions:
|
|
74
|
-
run_info = f" run {action['run_number']}" if action["run_number"] else ""
|
|
75
|
-
summary += f"* ✅ [{action['name']}]({action['url']}): `{action['file']}`{run_info}\n"
|
|
76
|
-
|
|
77
|
-
new_body = comment_body.replace(RUN_CI_KEYWORD, summary).strip()
|
|
78
|
-
comment_id = event.event_data["comment"]["id"]
|
|
79
|
-
event.patch(f"{GITHUB_API_URL}/repos/{event.repository}/issues/comments/{comment_id}", json={"body": new_body})
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
def main(*args, **kwargs):
|
|
83
|
-
"""Handles triggering workflows from PR comments."""
|
|
84
|
-
event = Action(*args, **kwargs)
|
|
85
|
-
|
|
86
|
-
# Only process new comments on PRs
|
|
87
|
-
if (
|
|
88
|
-
event.event_name != "issue_comment"
|
|
89
|
-
or "pull_request" not in event.event_data.get("issue", {})
|
|
90
|
-
or event.event_data.get("action") != "created"
|
|
91
|
-
):
|
|
92
|
-
return
|
|
93
|
-
|
|
94
|
-
# Get comment info
|
|
95
|
-
comment_body = event.event_data["comment"].get("body") or ""
|
|
96
|
-
username = event.event_data["comment"]["user"]["login"]
|
|
97
|
-
|
|
98
|
-
# Check for keyword without surrounding backticks to avoid triggering on replies
|
|
99
|
-
has_keyword = RUN_CI_KEYWORD in comment_body and comment_body.count(RUN_CI_KEYWORD) > comment_body.count(
|
|
100
|
-
f"`{RUN_CI_KEYWORD}`"
|
|
101
|
-
)
|
|
102
|
-
if not has_keyword or not event.is_org_member(username):
|
|
103
|
-
return
|
|
104
|
-
|
|
105
|
-
# Get branch, trigger workflows, and update comment
|
|
106
|
-
event.toggle_eyes_reaction(True)
|
|
107
|
-
branch = get_pr_branch(event)
|
|
108
|
-
print(f"Triggering workflows on branch: {branch}")
|
|
109
|
-
|
|
110
|
-
triggered_actions = trigger_and_get_workflow_info(event, branch)
|
|
111
|
-
update_comment(event, comment_body, triggered_actions, branch)
|
|
112
|
-
event.toggle_eyes_reaction(False)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
if __name__ == "__main__":
|
|
116
|
-
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/actions/update_markdown_code_blocks.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/tests/test_update_markdown_codeblocks.py
RENAMED
|
File without changes
|
|
File without changes
|
{ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/ultralytics_actions.egg-info/SOURCES.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/ultralytics_actions.egg-info/requires.txt
RENAMED
|
File without changes
|
{ultralytics_actions-0.1.9 → ultralytics_actions-0.2.0}/ultralytics_actions.egg-info/top_level.txt
RENAMED
|
File without changes
|