From 9b16b88812017516ba137f76265c52e253d74d9b Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Sun, 16 Nov 2025 13:33:17 +0100 Subject: [PATCH 01/14] Reduce PR Review API calls with GraphQL --- actions/first_interaction.py | 16 +++-- actions/review_pr.py | 36 +++++----- actions/summarize_pr.py | 32 ++++++--- actions/utils/__init__.py | 3 +- actions/utils/github_utils.py | 131 ++++++++++++++++++++++++++++++---- 5 files changed, 172 insertions(+), 46 deletions(-) diff --git a/actions/first_interaction.py b/actions/first_interaction.py index a56c69dd3..b4c6f419a 100644 --- a/actions/first_interaction.py +++ b/actions/first_interaction.py @@ -36,8 +36,7 @@ def get_event_content(event) -> tuple[int, str, str, str, str, str, str]: item = data["issue"] issue_type = "issue" elif name in ["pull_request", "pull_request_target"]: - pr_number = data["pull_request"]["number"] - item = event.get_repo_data(f"pulls/{pr_number}") + item = data["pull_request"] issue_type = "pull request" elif name == "discussion": item = data["discussion"] @@ -178,8 +177,6 @@ def main(*args, **kwargs): return number, node_id, title, body, username, issue_type, action = get_event_content(event) - available_labels = event.get_repo_data("labels") - label_descriptions = {label["name"]: label.get("description") or "" for label in available_labels} # Use unified PR open response for new PRs (summary + labels + first comment in 1 API call) if issue_type == "pull request" and action == "opened": @@ -187,12 +184,18 @@ def main(*args, **kwargs): return print(f"Processing PR open by @{username} with unified API call...") + # Fetch PR details and labels in single GraphQL query + repo_data = event.get_pr_details_and_labels(number) + pr_data = repo_data.get("pullRequest", {}) + available_labels = repo_data.get("labels", {}).get("nodes", []) + label_descriptions = {label["name"]: label.get("description") or "" for label in available_labels} + diff = event.get_pr_diff() response = get_pr_open_response(event.repository, diff, title, username, label_descriptions) if summary := response.get("summary"): print("Updating PR description with summary...") - event.update_pr_description(number, f"{SUMMARY_MARKER}\n\n{ACTIONS_CREDIT}\n\n{summary}") + event.update_pr_description(number, f"{SUMMARY_MARKER}\n\n{ACTIONS_CREDIT}\n\n{summary}", pr_data.get("body")) else: summary = body @@ -214,6 +217,9 @@ def main(*args, **kwargs): return # Handle issues and discussions (NOT PRs) + available_labels = event.get_repo_data("labels") + label_descriptions = {label["name"]: label.get("description") or "" for label in available_labels} + current_labels = ( [] if issue_type == "discussion" diff --git a/actions/review_pr.py b/actions/review_pr.py index bcb7df89d..9d86df2df 100644 --- a/actions/review_pr.py +++ b/actions/review_pr.py @@ -358,29 +358,27 @@ def generate_pr_review( def dismiss_previous_reviews(event: Action) -> int: """Dismiss previous bot reviews and delete inline comments, returns count for numbering.""" - if not (pr_number := event.pr.get("number")) or not (bot_username := event.get_username()): + if not (pr_number := event.pr.get("number")): + return 1 + + # Fetch reviews and comments in single GraphQL query + reviews, comments = event.get_pr_reviews_and_comments(pr_number) + bot_username = event.get_username() + if not bot_username: return 1 review_count = 0 reviews_base = f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{pr_number}/reviews" - reviews_url = f"{reviews_base}?per_page=100" - if (response := event.get(reviews_url)).status_code == 200: - for review in response.json(): - if review.get("user", {}).get("login") == bot_username and REVIEW_MARKER in (review.get("body") or ""): - review_count += 1 - if review.get("state") in ["APPROVED", "CHANGES_REQUESTED"] and (review_id := review.get("id")): - event.put(f"{reviews_base}/{review_id}/dismissals", json={"message": "Superseded by new review"}) - - # Delete previous inline comments - comments_base = f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{pr_number}/comments" - comments_url = f"{comments_base}?per_page=100" - if (response := event.get(comments_url)).status_code == 200: - for comment in response.json(): - if comment.get("user", {}).get("login") == bot_username and (comment_id := comment.get("id")): - event.delete( - f"{comments_base}/{comment_id}", - expected_status=[200, 204, 404], - ) + for review in reviews: + # Double-check author matches bot (defense in depth) + if review.get("author", {}).get("login") == bot_username and REVIEW_MARKER in (review.get("body") or ""): + review_count += 1 + if review.get("state") in ["APPROVED", "CHANGES_REQUESTED"] and (review_id := review.get("databaseId")): + event.put(f"{reviews_base}/{review_id}/dismissals", json={"message": "Superseded by new review"}) + + # Batch delete all inline comments (with author verification) + comment_ids = [c["id"] for c in comments if c.get("id") and c.get("author", {}).get("login") == bot_username] + event.batch_delete_review_comments(comment_ids) return review_count + 1 diff --git a/actions/summarize_pr.py b/actions/summarize_pr.py index cb36200d6..7ac46e42c 100644 --- a/actions/summarize_pr.py +++ b/actions/summarize_pr.py @@ -2,7 +2,7 @@ from __future__ import annotations -from .utils import ACTIONS_CREDIT, GITHUB_API_URL, Action, get_pr_summary_prompt, get_response +from .utils import ACTIONS_CREDIT, GITHUB_API_URL, GRAPHQL_LABEL_AND_COMMENT_ISSUE, Action, get_pr_summary_prompt, get_response SUMMARY_MARKER = "## 🛠️ PR Summary" @@ -82,11 +82,27 @@ def label_fixed_issues(event, pr_summary): return None comment = generate_issue_comment(data["url"], pr_summary, pr_credit, data.get("title") or "") - - for issue in data["closingIssuesReferences"]["nodes"]: - number = issue["number"] - event.post(f"{GITHUB_API_URL}/repos/{event.repository}/issues/{number}/labels", json={"labels": ["fixed"]}) - event.post(f"{GITHUB_API_URL}/repos/{event.repository}/issues/{number}/comments", json={"body": comment}) + closing_issues = data["closingIssuesReferences"]["nodes"] + if not closing_issues: + return pr_credit + + # Batch get all issue node IDs in single query + issue_numbers = [issue["number"] for issue in closing_issues] + issue_node_ids = event.get_multiple_issue_node_ids(issue_numbers) + + # Get label IDs once + label_ids = event.get_label_ids(["fixed"]) + if not label_ids: + print("Warning: 'fixed' label not found") + return pr_credit + + # Batch label and comment all issues using GraphQL mutations + for issue_num, issue_node_id in issue_node_ids.items(): + event.graphql_request(GRAPHQL_LABEL_AND_COMMENT_ISSUE, { + "issueId": issue_node_id, + "labelIds": label_ids, + "body": comment + }) return pr_credit @@ -108,9 +124,9 @@ def main(*args, **kwargs): print("Generating PR summary...") summary = generate_pr_summary(event.repository, diff) - # Update PR description + # Update PR description - use cached body from event data print("Updating PR description...") - event.update_pr_description(event.pr["number"], summary) + event.update_pr_description(event.pr["number"], summary, event.pr.get("body")) if event.pr.get("merged"): print("PR is merged, labeling fixed issues...") diff --git a/actions/utils/__init__.py b/actions/utils/__init__.py index df1e8698a..13014638b 100644 --- a/actions/utils/__init__.py +++ b/actions/utils/__init__.py @@ -9,7 +9,7 @@ allow_redirect, remove_html_comments, ) -from .github_utils import GITHUB_API_URL, GITHUB_GRAPHQL_URL, Action, ultralytics_actions_info +from .github_utils import GITHUB_API_URL, GITHUB_GRAPHQL_URL, GRAPHQL_LABEL_AND_COMMENT_ISSUE, Action, ultralytics_actions_info from .openai_utils import ( MAX_PROMPT_CHARS, filter_labels, @@ -25,6 +25,7 @@ "ACTIONS_CREDIT", "GITHUB_API_URL", "GITHUB_GRAPHQL_URL", + "GRAPHQL_LABEL_AND_COMMENT_ISSUE", "MAX_PROMPT_CHARS", "REDIRECT_END_IGNORE_LIST", "REDIRECT_START_IGNORE_LIST", diff --git a/actions/utils/github_utils.py b/actions/utils/github_utils.py index d0647aa06..72731b20d 100644 --- a/actions/utils/github_utils.py +++ b/actions/utils/github_utils.py @@ -97,6 +97,56 @@ } """ +GRAPHQL_PR_DETAILS = """ +query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + body + } + labels(first: 100) { + nodes { + name + description + } + } + } +} +""" + +GRAPHQL_PR_REVIEWS_COMMENTS = """ +query($owner: String!, $repo: String!, $number: Int!, $botLogin: String!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + reviews(first: 100, author: $botLogin) { + nodes { + databaseId + state + body + author { login } + } + } + comments(first: 100, author: $botLogin) { + nodes { + id + author { login } + } + } + } + } +} +""" + +GRAPHQL_LABEL_AND_COMMENT_ISSUE = """ +mutation($issueId: ID!, $labelIds: [ID!]!, $body: String!) { + addLabelsToLabelable(input: {labelableId: $issueId, labelIds: $labelIds}) { + labelable { id } + } + addComment(input: {subjectId: $issueId, body: $body}) { + commentEdge { node { id } } + } +} +""" + class Action: """Handles GitHub Actions API interactions and event processing.""" @@ -273,27 +323,27 @@ def graphql_request(self, query: str, variables: dict | None = None) -> dict: print(result.get("errors")) return result - def update_pr_description(self, number: int, new_summary: str, max_retries: int = 2): - """Updates PR description with summary, retrying if description is None.""" + def update_pr_description(self, number: int, new_summary: str, current_body: str | None = None): + """Updates PR description with summary, uses cached body if provided to avoid redundant API calls.""" import time url = f"{GITHUB_API_URL}/repos/{self.repository}/pulls/{number}" - description = "" - for i in range(max_retries + 1): - description = self.get(url).json().get("body") or "" - if description: - break - if i < max_retries: - print("No current PR description found, retrying...") - time.sleep(1) + if current_body is None: + for i in range(3): + current_body = self.get(url).json().get("body") or "" + if current_body: + break + if i < 2: + print("No current PR description found, retrying...") + time.sleep(1) start = "## 🛠️ PR Summary" - if start in description: + if start in current_body: print("Existing PR Summary found, replacing.") - updated_description = description.split(start)[0].rstrip() + "\n\n" + new_summary + updated_description = current_body.split(start)[0].rstrip() + "\n\n" + new_summary else: print("PR Summary not found, appending.") - updated_description = (description.rstrip() + "\n\n" + new_summary) if description.strip() else new_summary + updated_description = (current_body.rstrip() + "\n\n" + new_summary) if current_body.strip() else new_summary self.patch(url, json={"body": updated_description}) self._pr_summary_cache = new_summary @@ -400,6 +450,61 @@ def handle_alert(self, number: int, node_id: str, issue_type: str, username: str if block: self.block_user(username) + def get_pr_details_and_labels(self, pr_number: int) -> dict: + """Gets PR details and repository labels in a single GraphQL query.""" + owner, repo = self.repository.split("/") + variables = {"owner": owner, "repo": repo, "number": pr_number} + result = self.graphql_request(GRAPHQL_PR_DETAILS, variables=variables) + return result.get("data", {}).get("repository", {}) + + def get_pr_reviews_and_comments(self, pr_number: int) -> tuple[list, list]: + """Gets PR reviews and comments by bot in a single GraphQL query.""" + owner, repo = self.repository.split("/") + bot_login = self.get_username() + if not bot_login: + return [], [] + + variables = {"owner": owner, "repo": repo, "number": pr_number, "botLogin": bot_login} + result = self.graphql_request(GRAPHQL_PR_REVIEWS_COMMENTS, variables=variables) + + pr_data = result.get("data", {}).get("repository", {}).get("pullRequest", {}) + reviews = pr_data.get("reviews", {}).get("nodes", []) + comments = pr_data.get("comments", {}).get("nodes", []) + return reviews, comments + + def batch_delete_review_comments(self, comment_ids: list[str]) -> None: + """Batch delete PR review comments using a single GraphQL mutation.""" + if not comment_ids: + return + + mutations = [] + for i, comment_id in enumerate(comment_ids): + mutations.append( + f'delete{i}: deletePullRequestReviewComment(input: {{id: "{comment_id}"}}) {{ clientMutationId }}' + ) + + mutation = f"mutation {{ {' '.join(mutations)} }}" + self.graphql_request(mutation) + + def get_multiple_issue_node_ids(self, issue_numbers: list[int]) -> dict[int, str]: + """Gets multiple issue node IDs in a single GraphQL query.""" + if not issue_numbers: + return {} + + owner, repo = self.repository.split("/") + queries = [] + for i, num in enumerate(issue_numbers): + queries.append(f'issue{i}: issue(number: {num}) {{ id }}') + + query = f"""query {{ + repository(owner: \"{owner}\", name: \"{repo}\") {{ + {' '.join(queries)} + }} + }}""" + result = self.graphql_request(query) + repo_data = result.get("data", {}).get("repository", {}) + return {num: repo_data.get(f"issue{i}", {}).get("id") for i, num in enumerate(issue_numbers) if repo_data.get(f"issue{i}")} + def get_pr_contributors(self) -> tuple[str | None, dict]: """Gets PR contributors and closing issues, returns (pr_credit_string, pr_data).""" owner, repo = self.repository.split("/") From e3ca164e0d0bb10ce55884c891125dc162513b96 Mon Sep 17 00:00:00 2001 From: UltralyticsAssistant Date: Sun, 16 Nov 2025 12:33:48 +0000 Subject: [PATCH 02/14] Auto-format by https://ultralytics.com/actions --- actions/first_interaction.py | 4 +++- actions/summarize_pr.py | 17 +++++++++++------ actions/utils/__init__.py | 8 +++++++- actions/utils/github_utils.py | 14 ++++++++++---- 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/actions/first_interaction.py b/actions/first_interaction.py index b4c6f419a..cddb2984e 100644 --- a/actions/first_interaction.py +++ b/actions/first_interaction.py @@ -195,7 +195,9 @@ def main(*args, **kwargs): if summary := response.get("summary"): print("Updating PR description with summary...") - event.update_pr_description(number, f"{SUMMARY_MARKER}\n\n{ACTIONS_CREDIT}\n\n{summary}", pr_data.get("body")) + event.update_pr_description( + number, f"{SUMMARY_MARKER}\n\n{ACTIONS_CREDIT}\n\n{summary}", pr_data.get("body") + ) else: summary = body diff --git a/actions/summarize_pr.py b/actions/summarize_pr.py index 7ac46e42c..6a8f7f628 100644 --- a/actions/summarize_pr.py +++ b/actions/summarize_pr.py @@ -2,7 +2,14 @@ from __future__ import annotations -from .utils import ACTIONS_CREDIT, GITHUB_API_URL, GRAPHQL_LABEL_AND_COMMENT_ISSUE, Action, get_pr_summary_prompt, get_response +from .utils import ( + ACTIONS_CREDIT, + GITHUB_API_URL, + GRAPHQL_LABEL_AND_COMMENT_ISSUE, + Action, + get_pr_summary_prompt, + get_response, +) SUMMARY_MARKER = "## 🛠️ PR Summary" @@ -98,11 +105,9 @@ def label_fixed_issues(event, pr_summary): # Batch label and comment all issues using GraphQL mutations for issue_num, issue_node_id in issue_node_ids.items(): - event.graphql_request(GRAPHQL_LABEL_AND_COMMENT_ISSUE, { - "issueId": issue_node_id, - "labelIds": label_ids, - "body": comment - }) + event.graphql_request( + GRAPHQL_LABEL_AND_COMMENT_ISSUE, {"issueId": issue_node_id, "labelIds": label_ids, "body": comment} + ) return pr_credit diff --git a/actions/utils/__init__.py b/actions/utils/__init__.py index 13014638b..fde57eff2 100644 --- a/actions/utils/__init__.py +++ b/actions/utils/__init__.py @@ -9,7 +9,13 @@ allow_redirect, remove_html_comments, ) -from .github_utils import GITHUB_API_URL, GITHUB_GRAPHQL_URL, GRAPHQL_LABEL_AND_COMMENT_ISSUE, Action, ultralytics_actions_info +from .github_utils import ( + GITHUB_API_URL, + GITHUB_GRAPHQL_URL, + GRAPHQL_LABEL_AND_COMMENT_ISSUE, + Action, + ultralytics_actions_info, +) from .openai_utils import ( MAX_PROMPT_CHARS, filter_labels, diff --git a/actions/utils/github_utils.py b/actions/utils/github_utils.py index 72731b20d..34f1631fd 100644 --- a/actions/utils/github_utils.py +++ b/actions/utils/github_utils.py @@ -343,7 +343,9 @@ def update_pr_description(self, number: int, new_summary: str, current_body: str updated_description = current_body.split(start)[0].rstrip() + "\n\n" + new_summary else: print("PR Summary not found, appending.") - updated_description = (current_body.rstrip() + "\n\n" + new_summary) if current_body.strip() else new_summary + updated_description = ( + (current_body.rstrip() + "\n\n" + new_summary) if current_body.strip() else new_summary + ) self.patch(url, json={"body": updated_description}) self._pr_summary_cache = new_summary @@ -494,16 +496,20 @@ def get_multiple_issue_node_ids(self, issue_numbers: list[int]) -> dict[int, str owner, repo = self.repository.split("/") queries = [] for i, num in enumerate(issue_numbers): - queries.append(f'issue{i}: issue(number: {num}) {{ id }}') + queries.append(f"issue{i}: issue(number: {num}) {{ id }}") query = f"""query {{ repository(owner: \"{owner}\", name: \"{repo}\") {{ - {' '.join(queries)} + {" ".join(queries)} }} }}""" result = self.graphql_request(query) repo_data = result.get("data", {}).get("repository", {}) - return {num: repo_data.get(f"issue{i}", {}).get("id") for i, num in enumerate(issue_numbers) if repo_data.get(f"issue{i}")} + return { + num: repo_data.get(f"issue{i}", {}).get("id") + for i, num in enumerate(issue_numbers) + if repo_data.get(f"issue{i}") + } def get_pr_contributors(self) -> tuple[str | None, dict]: """Gets PR contributors and closing issues, returns (pr_credit_string, pr_data).""" From 97e8373a2dfd0d43e8ebcf6799de8e8de0b43bb8 Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Sun, 16 Nov 2025 13:40:36 +0100 Subject: [PATCH 03/14] Reduce PR Review API calls with GraphQL --- actions/review_pr.py | 5 +++-- actions/summarize_pr.py | 2 +- actions/utils/github_utils.py | 33 +++++++++++++++++++++++---------- 3 files changed, 27 insertions(+), 13 deletions(-) diff --git a/actions/review_pr.py b/actions/review_pr.py index 9d86df2df..061b88145 100644 --- a/actions/review_pr.py +++ b/actions/review_pr.py @@ -361,12 +361,13 @@ def dismiss_previous_reviews(event: Action) -> int: if not (pr_number := event.pr.get("number")): return 1 - # Fetch reviews and comments in single GraphQL query - reviews, comments = event.get_pr_reviews_and_comments(pr_number) bot_username = event.get_username() if not bot_username: return 1 + # Fetch reviews and comments in single GraphQL query + reviews, comments = event.get_pr_reviews_and_comments(pr_number) + review_count = 0 reviews_base = f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{pr_number}/reviews" for review in reviews: diff --git a/actions/summarize_pr.py b/actions/summarize_pr.py index 7ac46e42c..3ba0fdac2 100644 --- a/actions/summarize_pr.py +++ b/actions/summarize_pr.py @@ -97,7 +97,7 @@ def label_fixed_issues(event, pr_summary): return pr_credit # Batch label and comment all issues using GraphQL mutations - for issue_num, issue_node_id in issue_node_ids.items(): + for issue_node_id in issue_node_ids.values(): event.graphql_request(GRAPHQL_LABEL_AND_COMMENT_ISSUE, { "issueId": issue_node_id, "labelIds": label_ids, diff --git a/actions/utils/github_utils.py b/actions/utils/github_utils.py index 72731b20d..e1ee1c96e 100644 --- a/actions/utils/github_utils.py +++ b/actions/utils/github_utils.py @@ -111,7 +111,7 @@ } } } -""" +""" # Note: Limited to first 100 labels. Sufficient for most repos; add pagination if needed. GRAPHQL_PR_REVIEWS_COMMENTS = """ query($owner: String!, $repo: String!, $number: Int!, $botLogin: String!) { @@ -473,10 +473,13 @@ def get_pr_reviews_and_comments(self, pr_number: int) -> tuple[list, list]: return reviews, comments def batch_delete_review_comments(self, comment_ids: list[str]) -> None: - """Batch delete PR review comments using a single GraphQL mutation.""" + """Batch delete PR review comments using a single GraphQL mutation (max 50 to avoid query limits).""" if not comment_ids: return + # Limit to first 50 comments to avoid hitting GraphQL complexity limits + comment_ids = comment_ids[:50] + mutations = [] for i, comment_id in enumerate(comment_ids): mutations.append( @@ -492,16 +495,26 @@ def get_multiple_issue_node_ids(self, issue_numbers: list[int]) -> dict[int, str return {} owner, repo = self.repository.split("/") - queries = [] - for i, num in enumerate(issue_numbers): - queries.append(f'issue{i}: issue(number: {num}) {{ id }}') - - query = f"""query {{ - repository(owner: \"{owner}\", name: \"{repo}\") {{ - {' '.join(queries)} + + # Build parameterized query to avoid injection risks + query_parts = [] + for i in range(len(issue_numbers)): + query_parts.append(f'issue{i}: issue(number: $num{i}) {{ id }}') + + variables = {f"num{i}": num for i, num in enumerate(issue_numbers)} + variables["owner"] = owner + variables["repo"] = repo + + # Build variable definitions + var_defs = ["$owner: String!", "$repo: String!"] + [f"$num{i}: Int!" for i in range(len(issue_numbers))] + + query = f"""query({', '.join(var_defs)}) {{ + repository(owner: $owner, name: $repo) {{ + {' '.join(query_parts)} }} }}""" - result = self.graphql_request(query) + + result = self.graphql_request(query, variables) repo_data = result.get("data", {}).get("repository", {}) return {num: repo_data.get(f"issue{i}", {}).get("id") for i, num in enumerate(issue_numbers) if repo_data.get(f"issue{i}")} From 82a5e56f7e97c5af5c882208c678d369f4faeef3 Mon Sep 17 00:00:00 2001 From: UltralyticsAssistant Date: Sun, 16 Nov 2025 12:41:06 +0000 Subject: [PATCH 04/14] Auto-format by https://ultralytics.com/actions --- actions/summarize_pr.py | 17 +++++++++++------ actions/utils/github_utils.py | 28 +++++++++++++++++----------- 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/actions/summarize_pr.py b/actions/summarize_pr.py index 3ba0fdac2..9535c3fd6 100644 --- a/actions/summarize_pr.py +++ b/actions/summarize_pr.py @@ -2,7 +2,14 @@ from __future__ import annotations -from .utils import ACTIONS_CREDIT, GITHUB_API_URL, GRAPHQL_LABEL_AND_COMMENT_ISSUE, Action, get_pr_summary_prompt, get_response +from .utils import ( + ACTIONS_CREDIT, + GITHUB_API_URL, + GRAPHQL_LABEL_AND_COMMENT_ISSUE, + Action, + get_pr_summary_prompt, + get_response, +) SUMMARY_MARKER = "## 🛠️ PR Summary" @@ -98,11 +105,9 @@ def label_fixed_issues(event, pr_summary): # Batch label and comment all issues using GraphQL mutations for issue_node_id in issue_node_ids.values(): - event.graphql_request(GRAPHQL_LABEL_AND_COMMENT_ISSUE, { - "issueId": issue_node_id, - "labelIds": label_ids, - "body": comment - }) + event.graphql_request( + GRAPHQL_LABEL_AND_COMMENT_ISSUE, {"issueId": issue_node_id, "labelIds": label_ids, "body": comment} + ) return pr_credit diff --git a/actions/utils/github_utils.py b/actions/utils/github_utils.py index e1ee1c96e..9b2109551 100644 --- a/actions/utils/github_utils.py +++ b/actions/utils/github_utils.py @@ -343,7 +343,9 @@ def update_pr_description(self, number: int, new_summary: str, current_body: str updated_description = current_body.split(start)[0].rstrip() + "\n\n" + new_summary else: print("PR Summary not found, appending.") - updated_description = (current_body.rstrip() + "\n\n" + new_summary) if current_body.strip() else new_summary + updated_description = ( + (current_body.rstrip() + "\n\n" + new_summary) if current_body.strip() else new_summary + ) self.patch(url, json={"body": updated_description}) self._pr_summary_cache = new_summary @@ -479,7 +481,7 @@ def batch_delete_review_comments(self, comment_ids: list[str]) -> None: # Limit to first 50 comments to avoid hitting GraphQL complexity limits comment_ids = comment_ids[:50] - + mutations = [] for i, comment_id in enumerate(comment_ids): mutations.append( @@ -495,28 +497,32 @@ def get_multiple_issue_node_ids(self, issue_numbers: list[int]) -> dict[int, str return {} owner, repo = self.repository.split("/") - + # Build parameterized query to avoid injection risks query_parts = [] for i in range(len(issue_numbers)): - query_parts.append(f'issue{i}: issue(number: $num{i}) {{ id }}') - + query_parts.append(f"issue{i}: issue(number: $num{i}) {{ id }}") + variables = {f"num{i}": num for i, num in enumerate(issue_numbers)} variables["owner"] = owner variables["repo"] = repo - + # Build variable definitions var_defs = ["$owner: String!", "$repo: String!"] + [f"$num{i}: Int!" for i in range(len(issue_numbers))] - - query = f"""query({', '.join(var_defs)}) {{ + + query = f"""query({", ".join(var_defs)}) {{ repository(owner: $owner, name: $repo) {{ - {' '.join(query_parts)} + {" ".join(query_parts)} }} }}""" - + result = self.graphql_request(query, variables) repo_data = result.get("data", {}).get("repository", {}) - return {num: repo_data.get(f"issue{i}", {}).get("id") for i, num in enumerate(issue_numbers) if repo_data.get(f"issue{i}")} + return { + num: repo_data.get(f"issue{i}", {}).get("id") + for i, num in enumerate(issue_numbers) + if repo_data.get(f"issue{i}") + } def get_pr_contributors(self) -> tuple[str | None, dict]: """Gets PR contributors and closing issues, returns (pr_credit_string, pr_data).""" From 2cd1a8fcce93e1926a7d648a7336e86242dbce0b Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Sun, 16 Nov 2025 13:45:40 +0100 Subject: [PATCH 05/14] Reduce PR Review API calls with GraphQL --- tests/test_first_interaction.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/test_first_interaction.py b/tests/test_first_interaction.py index 52a5220ea..d3b6bb93a 100644 --- a/tests/test_first_interaction.py +++ b/tests/test_first_interaction.py @@ -37,15 +37,15 @@ def test_get_event_content_pr(): # Create mock event mock_event = MagicMock() mock_event.event_name = "pull_request" - mock_event.event_data = {"action": "opened", "pull_request": {"number": 456}} - - # Mock PR data returned from API - mock_event.get_repo_data.return_value = { - "number": 456, - "node_id": "node456", - "title": "Test PR", - "body": "PR description", - "user": {"login": "testuser"}, + mock_event.event_data = { + "action": "opened", + "pull_request": { + "number": 456, + "node_id": "node456", + "title": "Test PR", + "body": "PR description", + "user": {"login": "testuser"}, + }, } number, node_id, title, body, username, issue_type, action = get_event_content(mock_event) From 225b5ae7683340d1f4c7670033941f044f9afc42 Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Sun, 16 Nov 2025 13:52:44 +0100 Subject: [PATCH 06/14] Reduce PR Review API calls with GraphQL --- actions/utils/github_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/utils/github_utils.py b/actions/utils/github_utils.py index 9b2109551..b5316531c 100644 --- a/actions/utils/github_utils.py +++ b/actions/utils/github_utils.py @@ -125,7 +125,7 @@ author { login } } } - comments(first: 100, author: $botLogin) { + comments(last: 100) { nodes { id author { login } From 17ecbf6c193915eed3aa99231ed02c35f42d2231 Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Sun, 16 Nov 2025 13:57:02 +0100 Subject: [PATCH 07/14] Reduce PR Review API calls with GraphQL --- actions/first_interaction.py | 25 +++++++++++++------------ actions/utils/github_utils.py | 13 ++++++++----- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/actions/first_interaction.py b/actions/first_interaction.py index cddb2984e..65fba0c87 100644 --- a/actions/first_interaction.py +++ b/actions/first_interaction.py @@ -219,21 +219,22 @@ def main(*args, **kwargs): return # Handle issues and discussions (NOT PRs) - available_labels = event.get_repo_data("labels") - label_descriptions = {label["name"]: label.get("description") or "" for label in available_labels} + if issue_type != "pull request": + available_labels = event.get_repo_data("labels") + label_descriptions = {label["name"]: label.get("description") or "" for label in available_labels} - current_labels = ( - [] - if issue_type == "discussion" - else [label["name"].lower() for label in event.get_repo_data(f"issues/{number}/labels")] - ) + current_labels = ( + [] + if issue_type == "discussion" + else [label["name"].lower() for label in event.get_repo_data(f"issues/{number}/labels")] + ) - relevant_labels = get_relevant_labels(issue_type, title, body, label_descriptions, current_labels) - apply_and_check_labels(event, number, node_id, issue_type, username, relevant_labels, label_descriptions) + relevant_labels = get_relevant_labels(issue_type, title, body, label_descriptions, current_labels) + apply_and_check_labels(event, number, node_id, issue_type, username, relevant_labels, label_descriptions) - if action in {"opened", "created"}: - custom_response = get_first_interaction_response(event, issue_type, title, body, username) - event.add_comment(number, node_id, custom_response, issue_type) + if action in {"opened", "created"}: + custom_response = get_first_interaction_response(event, issue_type, title, body, username) + event.add_comment(number, node_id, custom_response, issue_type) if __name__ == "__main__": diff --git a/actions/utils/github_utils.py b/actions/utils/github_utils.py index b5316531c..0be938d79 100644 --- a/actions/utils/github_utils.py +++ b/actions/utils/github_utils.py @@ -482,14 +482,17 @@ def batch_delete_review_comments(self, comment_ids: list[str]) -> None: # Limit to first 50 comments to avoid hitting GraphQL complexity limits comment_ids = comment_ids[:50] + # Build mutation with variables mutations = [] + var_defs = [] + variables = {} for i, comment_id in enumerate(comment_ids): - mutations.append( - f'delete{i}: deletePullRequestReviewComment(input: {{id: "{comment_id}"}}) {{ clientMutationId }}' - ) + mutations.append(f"delete{i}: deletePullRequestReviewComment(input: {{id: $id{i}}}) {{ clientMutationId }}") + var_defs.append(f"$id{i}: ID!") + variables[f"id{i}"] = comment_id - mutation = f"mutation {{ {' '.join(mutations)} }}" - self.graphql_request(mutation) + mutation = f"mutation({', '.join(var_defs)}) {{ {' '.join(mutations)} }}" + self.graphql_request(mutation, variables) def get_multiple_issue_node_ids(self, issue_numbers: list[int]) -> dict[int, str]: """Gets multiple issue node IDs in a single GraphQL query.""" From d67796207265e331a63be719244656bfd661e0fc Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Sun, 16 Nov 2025 13:58:56 +0100 Subject: [PATCH 08/14] Reduce PR Review API calls with GraphQL --- actions/utils/github_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/utils/github_utils.py b/actions/utils/github_utils.py index 0be938d79..b0401131e 100644 --- a/actions/utils/github_utils.py +++ b/actions/utils/github_utils.py @@ -125,7 +125,7 @@ author { login } } } - comments(last: 100) { + reviewComments(last: 100) { nodes { id author { login } From ed2c359c40e883fc2c416493cdf875fe85194830 Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Sun, 16 Nov 2025 13:59:57 +0100 Subject: [PATCH 09/14] Reduce PR Review API calls with GraphQL --- actions/review_pr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/review_pr.py b/actions/review_pr.py index 061b88145..ce867625c 100644 --- a/actions/review_pr.py +++ b/actions/review_pr.py @@ -188,7 +188,7 @@ def generate_pr_review( "- Extract line numbers from R#### or L#### prefixes in the diff\n" "- Exact paths (no ./), 'side' field must match R (RIGHT) or L (LEFT) prefix\n" "- Severity: CRITICAL, HIGH, MEDIUM, LOW, SUGGESTION\n" - f"- Keep feedback concise: {TARGET_REVIEW_COMMENTS} issues max (less is better) with hard cap at {MAX_REVIEW_COMMENTS}\n" + f"- Keep feedback concise: less than {TARGET_REVIEW_COMMENTS} issues is ideal (less is better) with hard cap at {MAX_REVIEW_COMMENTS}\n" f"- Files changed: {len(file_list)} ({', '.join(file_list[:30])}{'...' if len(file_list) > 30 else ''})\n" f"- Lines changed: {lines_changed}\n" ) From f8b7d2009a9d19dc652c263d1099902859b6eab9 Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Sun, 16 Nov 2025 14:02:12 +0100 Subject: [PATCH 10/14] Reduce PR Review API calls with GraphQL --- actions/utils/github_utils.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/actions/utils/github_utils.py b/actions/utils/github_utils.py index b0401131e..cd50318f4 100644 --- a/actions/utils/github_utils.py +++ b/actions/utils/github_utils.py @@ -123,12 +123,12 @@ state body author { login } - } - } - reviewComments(last: 100) { - nodes { - id - author { login } + comments(last: 100) { + nodes { + id + author { login } + } + } } } } @@ -471,7 +471,12 @@ def get_pr_reviews_and_comments(self, pr_number: int) -> tuple[list, list]: pr_data = result.get("data", {}).get("repository", {}).get("pullRequest", {}) reviews = pr_data.get("reviews", {}).get("nodes", []) - comments = pr_data.get("comments", {}).get("nodes", []) + + # Flatten comments from all reviews + comments = [] + for review in reviews: + comments.extend(review.get("comments", {}).get("nodes", [])) + return reviews, comments def batch_delete_review_comments(self, comment_ids: list[str]) -> None: From 96351b58ad6e55d6e1337b556b8c355a958ea721 Mon Sep 17 00:00:00 2001 From: UltralyticsAssistant Date: Sun, 16 Nov 2025 13:02:37 +0000 Subject: [PATCH 11/14] Auto-format by https://ultralytics.com/actions --- actions/utils/github_utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/actions/utils/github_utils.py b/actions/utils/github_utils.py index cd50318f4..9b6d773bf 100644 --- a/actions/utils/github_utils.py +++ b/actions/utils/github_utils.py @@ -471,12 +471,12 @@ def get_pr_reviews_and_comments(self, pr_number: int) -> tuple[list, list]: pr_data = result.get("data", {}).get("repository", {}).get("pullRequest", {}) reviews = pr_data.get("reviews", {}).get("nodes", []) - + # Flatten comments from all reviews comments = [] for review in reviews: comments.extend(review.get("comments", {}).get("nodes", [])) - + return reviews, comments def batch_delete_review_comments(self, comment_ids: list[str]) -> None: From 98e356e98acff93e48c368e90510d33d34504261 Mon Sep 17 00:00:00 2001 From: Glenn Jocher Date: Sun, 16 Nov 2025 14:05:53 +0100 Subject: [PATCH 12/14] Reduce PR Review API calls with GraphQL --- actions/review_pr.py | 2 +- actions/utils/github_utils.py | 25 +++++-------------------- 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/actions/review_pr.py b/actions/review_pr.py index ce867625c..004f9eaa2 100644 --- a/actions/review_pr.py +++ b/actions/review_pr.py @@ -378,7 +378,7 @@ def dismiss_previous_reviews(event: Action) -> int: event.put(f"{reviews_base}/{review_id}/dismissals", json={"message": "Superseded by new review"}) # Batch delete all inline comments (with author verification) - comment_ids = [c["id"] for c in comments if c.get("id") and c.get("author", {}).get("login") == bot_username] + comment_ids = [c["databaseId"] for c in comments if c.get("databaseId") and c.get("author", {}).get("login") == bot_username] event.batch_delete_review_comments(comment_ids) return review_count + 1 diff --git a/actions/utils/github_utils.py b/actions/utils/github_utils.py index cd50318f4..e3f7e6460 100644 --- a/actions/utils/github_utils.py +++ b/actions/utils/github_utils.py @@ -125,7 +125,7 @@ author { login } comments(last: 100) { nodes { - id + databaseId author { login } } } @@ -479,25 +479,10 @@ def get_pr_reviews_and_comments(self, pr_number: int) -> tuple[list, list]: return reviews, comments - def batch_delete_review_comments(self, comment_ids: list[str]) -> None: - """Batch delete PR review comments using a single GraphQL mutation (max 50 to avoid query limits).""" - if not comment_ids: - return - - # Limit to first 50 comments to avoid hitting GraphQL complexity limits - comment_ids = comment_ids[:50] - - # Build mutation with variables - mutations = [] - var_defs = [] - variables = {} - for i, comment_id in enumerate(comment_ids): - mutations.append(f"delete{i}: deletePullRequestReviewComment(input: {{id: $id{i}}}) {{ clientMutationId }}") - var_defs.append(f"$id{i}: ID!") - variables[f"id{i}"] = comment_id - - mutation = f"mutation({', '.join(var_defs)}) {{ {' '.join(mutations)} }}" - self.graphql_request(mutation, variables) + def batch_delete_review_comments(self, comment_ids: list[int]) -> None: + """Delete PR review comments using REST API (GraphQL batch mutations hit complexity limits).""" + for comment_id in comment_ids: + self.delete(f"{GITHUB_API_URL}/repos/{self.repository}/pulls/comments/{comment_id}") def get_multiple_issue_node_ids(self, issue_numbers: list[int]) -> dict[int, str]: """Gets multiple issue node IDs in a single GraphQL query.""" From 0401e6308f28b806acb09eb9cbc3f77e737e16b9 Mon Sep 17 00:00:00 2001 From: UltralyticsAssistant Date: Sun, 16 Nov 2025 13:06:18 +0000 Subject: [PATCH 13/14] Auto-format by https://ultralytics.com/actions --- actions/review_pr.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/actions/review_pr.py b/actions/review_pr.py index 004f9eaa2..e86191603 100644 --- a/actions/review_pr.py +++ b/actions/review_pr.py @@ -378,7 +378,9 @@ def dismiss_previous_reviews(event: Action) -> int: event.put(f"{reviews_base}/{review_id}/dismissals", json={"message": "Superseded by new review"}) # Batch delete all inline comments (with author verification) - comment_ids = [c["databaseId"] for c in comments if c.get("databaseId") and c.get("author", {}).get("login") == bot_username] + comment_ids = [ + c["databaseId"] for c in comments if c.get("databaseId") and c.get("author", {}).get("login") == bot_username + ] event.batch_delete_review_comments(comment_ids) return review_count + 1 From d4e5844d0ca83d5b37c4d49e56b370ae12dddcec Mon Sep 17 00:00:00 2001 From: UltralyticsAssistant Date: Fri, 21 Nov 2025 10:01:39 +0000 Subject: [PATCH 14/14] Auto-format by https://ultralytics.com/actions --- actions/review_pr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/review_pr.py b/actions/review_pr.py index 83836928d..0828a3436 100644 --- a/actions/review_pr.py +++ b/actions/review_pr.py @@ -365,7 +365,7 @@ def dismiss_previous_reviews(event: Action) -> int: return 1 # Fetch reviews and comments in single GraphQL query - reviews, comments = event.get_pr_reviews_and_comments(pr_number) + _reviews, _comments = event.get_pr_reviews_and_comments(pr_number) review_count = 0 reviews_base = f"{GITHUB_API_URL}/repos/{event.repository}/pulls/{pr_number}/reviews"