diff --git a/actions/first_interaction.py b/actions/first_interaction.py index a56c69dd3..65fba0c87 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,20 @@ 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,18 +219,22 @@ def main(*args, **kwargs): return # Handle issues and discussions (NOT PRs) - 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) - - 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 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")] + ) + + 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 __name__ == "__main__": diff --git a/actions/review_pr.py b/actions/review_pr.py index 302290b7b..0828a3436 100644 --- a/actions/review_pr.py +++ b/actions/review_pr.py @@ -357,9 +357,16 @@ 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 + 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" reviews_url = f"{reviews_base}?per_page=100" diff --git a/actions/summarize_pr.py b/actions/summarize_pr.py index cb36200d6..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, 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 +89,25 @@ 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_node_id in issue_node_ids.values(): + event.graphql_request( + GRAPHQL_LABEL_AND_COMMENT_ISSUE, {"issueId": issue_node_id, "labelIds": label_ids, "body": comment} + ) return pr_credit @@ -108,9 +129,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..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, 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 +31,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 605905b11..2b9094865 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 + } + } + } +} +""" # 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!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $number) { + reviews(first: 100, author: $botLogin) { + nodes { + databaseId + state + body + author { login } + comments(last: 100) { + nodes { + databaseId + 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.""" @@ -274,27 +324,29 @@ 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}) @@ -398,6 +450,71 @@ 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", []) + + # 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[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.""" + if not issue_numbers: + 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 }}") + + 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, 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}") + } + def get_pr_contributors(self) -> tuple[str | None, dict]: """Gets PR contributors and closing issues, returns (pr_credit_string, pr_data).""" variables = {"owner": self.owner, "repo": self.repo_name, "pr_number": self.pr["number"]} 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)