Skip to content
Draft
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
55 changes: 41 additions & 14 deletions src/sentry/seer/autofix/on_completion_hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
from sentry.tasks.seer.pr_iteration import (
_add_comment_reaction,
_delete_own_comment_eyes_reaction,
_resolve_review_comment_threads,
consume_queued_autofix_feedback,
)
from sentry.utils import metrics
Expand All @@ -90,11 +91,12 @@
}


def _record_completion_reaction(outcome: str) -> None:
def _record_completion_reaction(outcome: str, amount: int = 1) -> None:
"""Record where a completion-reaction attempt exited so silent drop-offs of
the :tada: ack are visible in aggregate rather than invisible."""
metrics.incr(
"autofix.on_completion_hook.completion_reaction",
amount=amount,
tags={"outcome": outcome},
)

Expand Down Expand Up @@ -245,15 +247,7 @@ def _maybe_react_to_completed_iteration(
run_id: int,
state: SeerRunState,
) -> None:
"""React :tada: on the comment(s) that triggered a completed iteration and
remove the trigger-time :eyes:.

Only top-level ``@sentry`` PR comments are acked with :tada: — inline review
comments are resolvable threads acked separately (CW-1688). The trigger-time
:eyes: is removed from both, since both received it, completing the
:eyes:->:tada: swap on top-level comments and clearing the lingering :eyes:
on inline ones.
"""
"""Acknowledge the comment(s) that triggered a completed iteration."""
if not features.has("organizations:autofix-pr-iteration", organization=organization):
return

Expand Down Expand Up @@ -290,10 +284,13 @@ def _maybe_react_to_completed_iteration(
_record_completion_reaction("no_pr_comment_sources")
return

# Rate-limit-sensitive orgs skip the extra reaction-delete API calls.
delete_eyes = not is_github_rate_limit_sensitive(organization.slug)
# Rate-limit-sensitive orgs skip the extra reaction-delete / resolve API calls.
rate_limit_sensitive = is_github_rate_limit_sensitive(organization.slug)
delete_eyes = not rate_limit_sensitive

scm_by_repo: dict[str, SourceCodeManager] = {}
# Inline review-comment node ids to resolve, grouped by (repo, PR).
resolve_by_repo_pr: dict[tuple[str, int], list[str]] = {}
for source in sources:
comment_id = source.comment.id
if comment_id is None:
Expand Down Expand Up @@ -344,8 +341,7 @@ def _maybe_react_to_completed_iteration(
pr_number = pr_state.pr_number

source_type = source.type
# Inline review comments are acked by resolving the thread (CW-1688),
# not with :tada:; only top-level PR comments get the :tada:.
# Only top-level PR comments get the :tada:; inline comments resolve below (CW-1688).
if source_type == "github-pr-comment":
_add_comment_reaction(
scm,
Expand All @@ -355,6 +351,12 @@ def _maybe_react_to_completed_iteration(
reaction="hooray",
)
_record_completion_reaction("reacted")
elif source_type == "github-pr-review-comment" and not rate_limit_sensitive:
unique_id = getattr(source.comment, "unique_id", None)
if unique_id is None:
_record_completion_reaction("resolve_no_unique_id")
else:
resolve_by_repo_pr.setdefault((repo_name, pr_number), []).append(unique_id)
if delete_eyes:
_delete_own_comment_eyes_reaction(
scm,
Expand All @@ -363,6 +365,31 @@ def _maybe_react_to_completed_iteration(
comment_id=comment_id,
)

if rate_limit_sensitive and any(
source.type == "github-pr-review-comment" for source in sources
):
_record_completion_reaction("resolve_rate_limited")

for (repo_name, pr_number), unique_ids in resolve_by_repo_pr.items():
result = _resolve_review_comment_threads(
scm_by_repo[repo_name],
pr_number=pr_number,
comment_unique_ids=unique_ids,
)
if result.unsupported_provider:
outcomes = {"resolve_unsupported_provider": 1}
elif result.failed:
outcomes = {"resolve_failed": 1}
else:
outcomes = {
"resolved": result.resolved,
"resolve_skipped_already_resolved": result.already_resolved,
"resolve_thread_not_found": result.not_found,
}
for outcome, amount in outcomes.items():
if amount:
_record_completion_reaction(outcome, amount)

@classmethod
def find_latest_artifact_for_step(cls, state: SeerRunState, key: str) -> Artifact | None:
for block in reversed(state.blocks):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ class GithubPullRequestReviewComment(GithubIssueComment):
line: int | None = None
start_line: int | None = None
diff_hunk: str | None = None
# GraphQL node id used to resolve the review thread at completion (CW-1688).
unique_id: str | None = None


def _blocks_feedback(blocks: Sequence[Any]) -> list[Any]:
Expand Down
72 changes: 72 additions & 0 deletions src/sentry/tasks/seer/pr_iteration.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
from __future__ import annotations

import logging
from collections.abc import Collection
from dataclasses import dataclass
from datetime import timedelta
from typing import Any

import sentry_sdk
from scm import actions as scm_actions
from scm.errors import ResourceNotFound
from scm.helpers import iter_all_pages
from scm.manager import SourceCodeManager
from scm.types import (
Author,
Expand All @@ -18,15 +21,18 @@
GetAuthenticatedActorProtocol,
GetPullRequestCommentReactionsProtocol,
GetPullRequestReviewProtocol,
GetPullRequestReviewThreadsProtocol,
GetRepositoryUserPermissionProtocol,
GetReviewCommentReactionsProtocol,
GetReviewCommentsProtocol,
PaginationParams,
Reaction,
ReactionResult,
ResolveReviewThreadProtocol,
ResourceId,
Review,
ReviewComment,
ReviewThread,
)
from taskbroker_client.retry import Retry

Expand Down Expand Up @@ -367,6 +373,71 @@ def _own_eyes_reaction_ids(reactions: list[ReactionResult], actor_id: ResourceId
logger.exception("autofix.pr_iteration.completion_reaction.delete_eyes_failed")


@dataclass
class ResolveReviewThreadsResult:
resolved: int = 0
already_resolved: int = 0
not_found: int = 0
unsupported_provider: bool = False
failed: bool = False


def _resolve_review_comment_threads(
scm: SourceCodeManager,
*,
pr_number: int,
comment_unique_ids: Collection[str],
) -> ResolveReviewThreadsResult:
"""Resolve the review threads of this iteration's inline comments (CW-1688)."""
if not (
isinstance(scm, ResolveReviewThreadProtocol)
and isinstance(scm, GetPullRequestReviewThreadsProtocol)
):
logger.warning("autofix.pr_iteration.completion_reaction.unsupported_provider")
return ResolveReviewThreadsResult(unsupported_provider=True)

try:
threads: list[ReviewThread] = []
# Empty starting cursor so GitHub's GraphQL first page is `after: null`.
for page in iter_all_pages(
lambda pagination: scm_actions.get_pull_request_review_threads(
scm, str(pr_number), pagination
),
per_page=100,
cursor="",
):
threads.extend(page["data"])

thread_by_comment: dict[str, ReviewThread] = {}
for thread in threads:
for comment in thread["comments"]:
unique_id = comment.get("unique_id")
if unique_id is not None:
thread_by_comment[unique_id] = thread

outcome = ResolveReviewThreadsResult()
thread_ids_to_resolve: set[ResourceId] = set()
already_resolved_ids: set[ResourceId] = set()
for comment_unique_id in comment_unique_ids:
owning_thread = thread_by_comment.get(comment_unique_id)
if owning_thread is None:
outcome.not_found += 1
continue
if owning_thread["is_resolved"]:
already_resolved_ids.add(owning_thread["id"])
else:
thread_ids_to_resolve.add(owning_thread["id"])

outcome.already_resolved = len(already_resolved_ids)
for thread_id in thread_ids_to_resolve:
scm_actions.resolve_review_thread(scm, str(pr_number), str(thread_id))
outcome.resolved += 1
return outcome
except Exception:
logger.exception("autofix.pr_iteration.completion_reaction.resolve_failed")
return ResolveReviewThreadsResult(failed=True)


def _comment_pr_iteration_ineligible(
client: Any,
*,
Expand Down Expand Up @@ -706,6 +777,7 @@ def _build_review_feedback(
start_line=_diff_line_number(comment.get("start_line")),
diff_hunk=comment.get("diff_hunk"),
user=GithubPrCommentUser(login=author["username"] if author else None),
unique_id=comment.get("unique_id"),
)
source = GithubPrReviewCommentFeedbackSource(
comment=review_comment,
Expand Down
Loading
Loading