Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 26 additions & 17 deletions actions/first_interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -178,21 +177,27 @@ 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":
if event.should_skip_pr_author():
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

Expand All @@ -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__":
Expand Down
9 changes: 8 additions & 1 deletion actions/review_pr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ“ LOW: dismiss_previous_reviews now calls event.get_username() directly and then indirectly again via event.get_pr_reviews_and_comments(). This double lookup is minor but unnecessary overhead on every run, and also duplicates the None handling logic in two places. Consider passing the already-resolved bot_username into get_pr_reviews_and_comments (or having it accept an optional precomputed login) to avoid redundant API calls and keep the responsibility for username resolution in one place.

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"
Expand Down
37 changes: 29 additions & 8 deletions actions/summarize_pr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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

Expand All @@ -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...")
Expand Down
9 changes: 8 additions & 1 deletion actions/utils/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand Down
143 changes: 130 additions & 13 deletions actions/utils/github_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ’‘ MEDIUM: update_pr_description changed signature from (number, new_summary, max_retries=2) to (number, new_summary, current_body=None) and hard-codes 3 attempts. Any existing callers that passed a positional third argument expecting max_retries will now treat that value as the PR body, which could cause incorrect description updates. Also, the maximum retries is now implicit and no longer configurable. It would be safer either to preserve the old parameter (e.g. add max_retries as a keyword-only argument) or to ensure all internal callers use keyword arguments so this change cannot break external usages.

"""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:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ“ LOW: Inside update_pr_description, the retry loop always assigns current_body = self.get(url).json().get("body") or "", so by the time you reach the summary replacement/appending logic current_body is guaranteed to be a string. You can simplify and clarify the control flow by removing the for i in range(3) magic number and extracting the "fetch-with-retries" into a small helper or a clearly named constant, which makes the behavior easier to reason about and maintain.

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})

Expand Down Expand Up @@ -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]:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ“ LOW: get_pr_reviews_and_comments calls self.get_username() internally, but dismiss_previous_reviews has already resolved and validated bot_username before calling this helper. This leads to redundant API calls and slightly divergent behavior between the helper and caller. Consider accepting bot_login as an optional argument and using the passed-in value when available to avoid duplicate lookups and keep the logic for handling a missing bot username centralized.

"""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]:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

πŸ“ LOW: get_multiple_issue_node_ids dynamically builds a GraphQL query with one variable per issue. This is safe given the use of variables but can grow the query size linearly with issue_numbers. Since closingIssuesReferences is already capped upstream, this is probably fine, but it may be worth documenting or enforcing a reasonable upper bound on issue_numbers here to avoid accidentally constructing very large queries if the method is reused elsewhere.

"""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"]}
Expand Down
18 changes: 9 additions & 9 deletions tests/test_first_interaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading