Skip to content
Closed
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
83 changes: 81 additions & 2 deletions .github/scripts/pull-request-dashboard/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@
include_missing_required_checks,
list_open_prs,
load_reviewer_set,
request_copilot_review,
normalize_repo,
repo_state_key,
)
Expand Down Expand Up @@ -247,6 +248,42 @@ def reviewer_actor_login(obj: dict[str, Any] | None) -> str:
return login


def is_copilot_reviewer(obj: dict[str, Any] | None) -> bool:
return reviewer_actor_login(obj) == "copilot-pull-request-reviewer[bot]"


def has_copilot_review(raw: dict[str, Any]) -> bool:
return any(
is_copilot_reviewer(review.get("user"))
for review in (raw.get("reviews") or [])
)


def copilot_review_needed(raw: dict[str, Any]) -> bool:
copilot_reviews = [
review
for review in (raw.get("reviews") or [])
if is_copilot_reviewer(review.get("user"))
]
if not copilot_reviews:
return False
Comment thread
trask marked this conversation as resolved.
current_head = ((raw.get("commits") or [{}])[-1].get("sha") or "")
if not current_head:
return False
review_ids_with_findings = {
comment.get("pull_request_review_id")
for comment in (raw.get("review_comments") or [])
}
latest_review = max(
copilot_reviews,
key=lambda review: review.get("submitted_at") or "",
)
return (
latest_review.get("id") in review_ids_with_findings
or current_head != (latest_review.get("commit_id") or "")
)


def human_author_for_copilot_pr(raw: dict[str, Any]) -> str:
assignees = [actor_login(a) for a in (raw["pr"].get("assignees") or [])]
for login in assignees:
Expand Down Expand Up @@ -539,6 +576,12 @@ def compute_facts(
facts = {
"author": author,
"assignees": assignees,
"copilot_review_requested": any(
is_copilot_reviewer(request)
for request in (pr.get("reviewRequests") or [])
),
"copilot_review_exists": has_copilot_review(raw),
"copilot_review_needed": copilot_review_needed(raw),
"is_maintenance_bot": api_author.lower() in _MAINTENANCE_BOT_PR_AUTHORS,
"is_draft": bool(pr.get("isDraft")),
"approval_count": current_approval_count(events),
Expand Down Expand Up @@ -1208,6 +1251,26 @@ def route_pr(facts: dict[str, Any], pending_actions: dict[str, dict[str, Any]],
return "approver"


def apply_copilot_review_gate(
repo: str,
number: int,
facts: dict[str, Any],
route: str,
*,
enabled: bool,
) -> str:
if not enabled or route not in ("approver", "maintainer"):
return route
if not facts.get("copilot_review_exists"):
return "copilot"
if not facts.get("copilot_review_needed"):
return route
if not facts.get("copilot_review_requested"):
request_copilot_review(repo, number)
facts["copilot_review_requested"] = True
return "copilot"


def discussions_by_id(discussions: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
return {t["discussion_id"]: t for t in discussions}

Expand All @@ -1226,7 +1289,7 @@ def oldest_pending_action_ts(


def fallback_wait_ts(route: str, facts: dict[str, Any]) -> tuple[datetime | None, str]:
if route in ("approver", "maintainer"):
if route in ("approver", "maintainer", "copilot"):
return parse_ts(facts.get("last_author_activity_at") or ""), "last_author_activity"
if route == "author":
if facts.get("ci_failing_count", 0) > 0:
Expand Down Expand Up @@ -1372,6 +1435,7 @@ def build_pr_result(
required_approvals: int,
non_blocking_check_patterns: list[str],
previous_top_level_history: dict[str, dict[str, Any]] | None = None,
require_clean_copilot_review: bool = False,
) -> dict[str, Any] | None:
number = pr_summary["number"]
try:
Expand Down Expand Up @@ -1462,7 +1526,13 @@ def build_pr_result(
"route": "unknown",
"error": f"{len(failed_classifications)} discussion classification(s) failed",
}
route = route_pr(facts, pending_actions, required_approvals)
route = apply_copilot_review_gate(
repo,
number,
facts,
route_pr(facts, pending_actions, required_approvals),
enabled=require_clean_copilot_review,
)
add_wait_age_facts(facts, route, pending_actions)
facts["author_action_review_thread_urls"] = author_action_discussion_urls(
review_threads, pending_actions
Expand Down Expand Up @@ -1543,6 +1613,7 @@ def build_dashboard_update_for_pr(
required_approvals: int,
non_blocking_check_patterns: list[str],
dashboard_state: dict[str, Any],
require_clean_copilot_review: bool = False,
) -> DashboardUpdate:
print(f"refreshing dashboard state for PR #{pr_number}", file=sys.stderr)
results = results_from_dashboard_state(dashboard_state, open_pr_numbers)
Expand All @@ -1557,6 +1628,7 @@ def build_dashboard_update_for_pr(
required_approvals,
non_blocking_check_patterns,
previous_top_level_history=(starting_pr_result or {}).get("top_level_history") or {},
require_clean_copilot_review=require_clean_copilot_review,
)
if trigger_pr_result is None:
results.pop(pr_number, None)
Expand Down Expand Up @@ -1895,6 +1967,7 @@ def build_targeted_dashboard_update(args: argparse.Namespace) -> DashboardUpdate
args.required_approvals,
args.non_blocking_check_pattern,
loaded_dashboard_state,
getattr(args, "require_clean_copilot_review", False),
)


Expand Down Expand Up @@ -2013,6 +2086,7 @@ def update_selected_pr(pr_summary: dict[str, Any] = pr_summary) -> int:
args.required_approvals,
args.non_blocking_check_pattern,
dashboard_state,
getattr(args, "require_clean_copilot_review", False),
)
calculation, dashboard_state_unchanged = merge_dashboard_update_with_latest_state(
calculation,
Expand Down Expand Up @@ -2106,6 +2180,11 @@ def main() -> int:
default=[],
help="glob matching a non-required check to mention when it fails; repeat as needed",
)
parser.add_argument(
"--require-clean-copilot-review",
action="store_true",
help="re-request Copilot after pushes since its last clean review before reviewer or maintainer handoff",
)
parser.add_argument("--model", default=DEFAULT_MODEL, help=f"copilot model (default: {DEFAULT_MODEL})")
parser.add_argument(
"--github-output",
Expand Down
36 changes: 35 additions & 1 deletion .github/scripts/pull-request-dashboard/github_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,22 @@
GH_RETRY_ATTEMPTS = 4
GH_RETRY_DELAY_SECONDS = 1.5
DEFAULT_OWNER = "open-telemetry"
COPILOT_REVIEWER_BOT_ID = "BOT_kgDOCnlnWA"


REQUEST_COPILOT_REVIEW_MUTATION = """
mutation($pullRequestId: ID!, $botId: ID!) {
requestReviews(input: {
pullRequestId: $pullRequestId
botIds: [$botId]
union: true
}) {
pullRequest {
id
}
}
}
"""


def normalize_repo(repo: str) -> str:
Expand Down Expand Up @@ -98,6 +114,20 @@ def gh_api(path: str, paginate: bool = False, token: str | None = None) -> Any:
return data


def request_copilot_review(repo: str, number: int) -> None:
pull = gh_api(f"/repos/{normalize_repo(repo)}/pulls/{number}")
pull_request_id = pull.get("node_id") if isinstance(pull, dict) else None
if not pull_request_id:
raise RuntimeError(f"GitHub did not return a node ID for PR #{number} in {repo}")
gh_graphql(
REQUEST_COPILOT_REVIEW_MUTATION,
{
"pullRequestId": pull_request_id,
"botId": COPILOT_REVIEWER_BOT_ID,
},
)


def gh_graphql(query: str, fields: dict[str, Any], token: str | None = None) -> dict[str, Any]:
cmd = ["gh", "api", "graphql", "-f", f"query={query}"]
for name, value in fields.items():
Expand All @@ -111,7 +141,7 @@ def gh_pr_view(repo: str, number: int) -> dict[str, Any]:
fields = ",".join([
"id", "number", "title", "url", "author", "state", "isDraft",
"mergeable", "mergeStateStatus", "createdAt", "updatedAt",
"reviewDecision", "assignees", "baseRefName",
"reviewDecision", "reviewRequests", "assignees", "baseRefName",
])
cmd = ["gh", "pr", "view", str(number), "--repo", repo, "--json", fields]
last: dict[str, Any] = {}
Expand Down Expand Up @@ -139,6 +169,9 @@ def gh_pr_view(repo: str, number: int) -> dict[str, Any]:
}
nodes {
fullDatabaseId
commit {
oid
}
url
body
state
Expand Down Expand Up @@ -307,6 +340,7 @@ def fetch_pr_review_data(owner: str, repo_name: str, number: int) -> dict[str, A
continue
reviews.append({
"id": database_id,
"commit_id": ((review.get("commit") or {}).get("oid") or ""),
"url": review.get("url") or "",
"user": review.get("author") or {},
"state": review.get("state") or "",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
"status_waiting_on": "Maintainers",
"status_next_step": "Merge when ready.",
},
"copilot": {
"dashboard_label": "Waiting on Copilot",
"status_waiting_on": "Copilot",
"status_next_step": "Wait for the pending review to complete.",
},
"approver": {
"dashboard_label": "Waiting on reviewers",
"status_waiting_on": "Reviewers",
Expand Down
Loading