diff --git a/.github/scripts/pull-request-dashboard/dashboard.py b/.github/scripts/pull-request-dashboard/dashboard.py index 48e787ab928..deef405ecd2 100644 --- a/.github/scripts/pull-request-dashboard/dashboard.py +++ b/.github/scripts/pull-request-dashboard/dashboard.py @@ -180,6 +180,7 @@ include_missing_required_checks, list_open_prs, load_reviewer_set, + request_copilot_review, normalize_repo, repo_state_key, ) @@ -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 + 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: @@ -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), @@ -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} @@ -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: @@ -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: @@ -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 @@ -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) @@ -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) @@ -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), ) @@ -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, @@ -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", diff --git a/.github/scripts/pull-request-dashboard/github_cli.py b/.github/scripts/pull-request-dashboard/github_cli.py index 5e9559a1eed..c499c3564a3 100644 --- a/.github/scripts/pull-request-dashboard/github_cli.py +++ b/.github/scripts/pull-request-dashboard/github_cli.py @@ -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: @@ -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(): @@ -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] = {} @@ -139,6 +169,9 @@ def gh_pr_view(repo: str, number: int) -> dict[str, Any]: } nodes { fullDatabaseId + commit { + oid + } url body state @@ -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 "", diff --git a/.github/scripts/pull-request-dashboard/route_presentation.py b/.github/scripts/pull-request-dashboard/route_presentation.py index bd57c3f0e64..20b204aa06d 100644 --- a/.github/scripts/pull-request-dashboard/route_presentation.py +++ b/.github/scripts/pull-request-dashboard/route_presentation.py @@ -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", diff --git a/.github/scripts/pull-request-dashboard/test_dashboard.py b/.github/scripts/pull-request-dashboard/test_dashboard.py index f821f62a47f..b0a898a90ae 100644 --- a/.github/scripts/pull-request-dashboard/test_dashboard.py +++ b/.github/scripts/pull-request-dashboard/test_dashboard.py @@ -12,6 +12,7 @@ BACKFILL_RECORDED_FAILURE_STATUS, DashboardUpdate, add_wait_age_facts, + apply_copilot_review_gate, apply_targeted_dashboard_update, author_action_discussion_urls, backfill_failed_pr_numbers, @@ -181,6 +182,287 @@ def test_author_action_urls_use_thread_url_and_deduplicate(self) -> None: author_action_discussion_urls(discussions, pending_actions), ) + +class CopilotReviewGateTest(unittest.TestCase): + def test_current_head_matches_latest_clean_copilot_review(self) -> None: + facts = compute_facts( + { + "pr": { + "updatedAt": "2026-07-20T03:00:00Z", + "createdAt": "2026-07-20T01:00:00Z", + "author": {"login": "author"}, + "assignees": [], + "reviewRequests": [ + {"login": "copilot-pull-request-reviewer"}, + ], + "mergeStateStatus": "CLEAN", + "mergeable": "MERGEABLE", + }, + "reviews": [ + { + "id": 10, + "commit_id": "old-head", + "user": {"login": "copilot-pull-request-reviewer[bot]"}, + "submitted_at": "2026-07-20T01:30:00Z", + }, + { + "id": 20, + "commit_id": "current-head", + "user": {"login": "copilot-pull-request-reviewer"}, + "submitted_at": "2026-07-20T02:30:00Z", + }, + ], + "commits": [{"sha": "old-head"}, {"sha": "current-head"}], + "review_comments": [ + {"pull_request_review_id": 10}, + ], + "checks": [], + }, + "author", + [], + ) + + self.assertTrue(facts["copilot_review_requested"]) + self.assertTrue(facts["copilot_review_exists"]) + self.assertFalse(facts["copilot_review_needed"]) + + def test_push_since_latest_clean_copilot_review_needs_rereview(self) -> None: + facts = compute_facts( + { + "pr": { + "updatedAt": "2026-07-20T03:00:00Z", + "createdAt": "2026-07-20T01:00:00Z", + "author": {"login": "author"}, + "assignees": [], + "reviewRequests": [], + "mergeStateStatus": "CLEAN", + "mergeable": "MERGEABLE", + }, + "reviews": [ + { + "id": 20, + "commit_id": "reviewed-head", + "user": {"login": "copilot"}, + "submitted_at": "2026-07-20T02:30:00Z", + }, + ], + "commits": [{"sha": "reviewed-head"}, {"sha": "current-head"}], + "review_comments": [], + "checks": [], + }, + "author", + [], + ) + + self.assertFalse(facts["copilot_review_requested"]) + self.assertTrue(facts["copilot_review_needed"]) + + def test_latest_findings_review_replaces_clean_review_on_same_head(self) -> None: + facts = compute_facts( + { + "pr": { + "updatedAt": "2026-07-20T03:00:00Z", + "createdAt": "2026-07-20T01:00:00Z", + "author": {"login": "author"}, + "assignees": [], + "reviewRequests": [], + "mergeStateStatus": "CLEAN", + "mergeable": "MERGEABLE", + }, + "reviews": [ + { + "id": 10, + "commit_id": "current-head", + "user": {"login": "copilot"}, + "submitted_at": "2026-07-20T01:30:00Z", + }, + { + "id": 20, + "commit_id": "current-head", + "user": {"login": "copilot"}, + "submitted_at": "2026-07-20T02:30:00Z", + }, + ], + "commits": [{"sha": "current-head"}], + "review_comments": [{"pull_request_review_id": 20}], + "checks": [], + }, + "author", + [], + ) + + self.assertTrue(facts["copilot_review_needed"]) + + def test_findings_only_history_needs_rereview(self) -> None: + facts = compute_facts( + { + "pr": { + "updatedAt": "2026-07-20T03:00:00Z", + "createdAt": "2026-07-20T01:00:00Z", + "author": {"login": "author"}, + "assignees": [], + "reviewRequests": [], + "mergeStateStatus": "CLEAN", + "mergeable": "MERGEABLE", + }, + "reviews": [ + { + "id": 20, + "commit_id": "reviewed-head", + "user": {"login": "copilot"}, + "submitted_at": "2026-07-20T02:30:00Z", + }, + ], + "commits": [{"sha": "reviewed-head"}, {"sha": "current-head"}], + "review_comments": [{"pull_request_review_id": 20}], + "checks": [], + }, + "author", + [], + ) + + self.assertTrue(facts["copilot_review_needed"]) + + def test_waits_for_automatic_initial_copilot_review(self) -> None: + facts = compute_facts( + { + "pr": { + "updatedAt": "2026-07-20T03:00:00Z", + "createdAt": "2026-07-20T01:00:00Z", + "author": {"login": "author"}, + "assignees": [], + "reviewRequests": [], + "mergeStateStatus": "CLEAN", + "mergeable": "MERGEABLE", + }, + "reviews": [], + "commits": [{"sha": "current-head"}], + "review_comments": [], + "checks": [], + }, + "author", + [], + ) + + self.assertFalse(facts["copilot_review_exists"]) + self.assertFalse(facts["copilot_review_needed"]) + + @patch("dashboard.request_copilot_review") + def test_initial_automatic_review_blocks_human_handoff(self, request_review) -> None: + facts = { + "copilot_review_requested": True, + "copilot_review_exists": False, + "copilot_review_needed": False, + } + + route = apply_copilot_review_gate( + "open-telemetry/example", + 7, + facts, + "approver", + enabled=True, + ) + + self.assertEqual(route, "copilot") + request_review.assert_not_called() + + @patch("dashboard.request_copilot_review") + def test_requests_re_review_after_push_since_clean_review(self, request_review) -> None: + facts = { + "copilot_review_requested": False, + "copilot_review_exists": True, + "copilot_review_needed": True, + } + + route = apply_copilot_review_gate( + "open-telemetry/example", + 7, + facts, + "maintainer", + enabled=True, + ) + + self.assertEqual(route, "copilot") + self.assertTrue(facts["copilot_review_requested"]) + request_review.assert_called_once_with("open-telemetry/example", 7) + + @patch("dashboard.request_copilot_review") + def test_requests_re_review_before_reviewer_handoff(self, request_review) -> None: + facts = { + "copilot_review_requested": False, + "copilot_review_exists": True, + "copilot_review_needed": True, + } + + route = apply_copilot_review_gate( + "open-telemetry/example", + 7, + facts, + "approver", + enabled=True, + ) + + self.assertEqual(route, "copilot") + self.assertTrue(facts["copilot_review_requested"]) + request_review.assert_called_once_with("open-telemetry/example", 7) + + @patch("dashboard.request_copilot_review") + def test_pending_re_review_waits_without_duplicate_request(self, request_review) -> None: + facts = { + "copilot_review_requested": True, + "copilot_review_exists": True, + "copilot_review_needed": True, + } + + route = apply_copilot_review_gate( + "open-telemetry/example", + 7, + facts, + "maintainer", + enabled=True, + ) + + self.assertEqual(route, "copilot") + request_review.assert_not_called() + + @patch("dashboard.request_copilot_review") + def test_current_head_clean_review_moves_to_maintainers(self, request_review) -> None: + facts = { + "copilot_review_requested": False, + "copilot_review_exists": True, + "copilot_review_needed": False, + } + + route = apply_copilot_review_gate( + "open-telemetry/example", + 7, + facts, + "maintainer", + enabled=True, + ) + + self.assertEqual(route, "maintainer") + request_review.assert_not_called() + + @patch("dashboard.request_copilot_review") + def test_disabled_gate_preserves_maintainer_route(self, request_review) -> None: + facts = { + "copilot_review_requested": False, + "copilot_review_exists": True, + "copilot_review_needed": True, + } + + route = apply_copilot_review_gate( + "open-telemetry/example", + 7, + facts, + "maintainer", + enabled=False, + ) + + self.assertEqual(route, "maintainer") + request_review.assert_not_called() + def test_discussion_url_is_excluded_from_classifier_input(self) -> None: prompt_input = discussion_prompt_input({ "discussion_id": "thread-1", diff --git a/.github/scripts/pull-request-dashboard/test_github_cli.py b/.github/scripts/pull-request-dashboard/test_github_cli.py index efe20db47b8..4a02b565fc1 100644 --- a/.github/scripts/pull-request-dashboard/test_github_cli.py +++ b/.github/scripts/pull-request-dashboard/test_github_cli.py @@ -1,7 +1,7 @@ from __future__ import annotations import unittest -from unittest.mock import patch +from unittest.mock import ANY, patch from github_cli import ( TransientGhError, @@ -15,10 +15,35 @@ is_retryable_gh_error, list_all_open_pr_numbers, list_open_prs, + request_copilot_review, ) class GithubCliTest(unittest.TestCase): + @patch("github_cli.gh_graphql") + @patch("github_cli.gh_api") + def test_request_copilot_review_uses_request_reviews_mutation( + self, + gh_api, + graphql, + ) -> None: + gh_api.return_value = {"node_id": "PR_node_id"} + + request_copilot_review("example", 7) + + gh_api.assert_called_once_with("/repos/open-telemetry/example/pulls/7") + graphql.assert_called_once_with( + ANY, + { + "pullRequestId": "PR_node_id", + "botId": "BOT_kgDOCnlnWA", + }, + ) + mutation = graphql.call_args.args[0] + self.assertIn("requestReviews", mutation) + self.assertIn("botIds: [$botId]", mutation) + self.assertIn("union: true", mutation) + @patch("github_cli.gh_graphql") def test_fetch_pr_issue_comments_paginates(self, graphql) -> None: graphql.side_effect = [ @@ -559,6 +584,7 @@ def test_fetch_pr_review_data_normalizes_paginated_reviews_and_metadata(self, gr "nodes": [ { "fullDatabaseId": "4700712792", + "commit": {"oid": "reviewed-head-1"}, "url": "https://example.test/review/4700712792", "author": {"login": "reviewer-1"}, "state": "COMMENTED", @@ -586,6 +612,7 @@ def test_fetch_pr_review_data_normalizes_paginated_reviews_and_metadata(self, gr "nodes": [ { "fullDatabaseId": "5000000000", + "commit": {"oid": "reviewed-head-2"}, "url": "https://example.test/review/5000000000", "author": {"login": "reviewer-2"}, "state": "APPROVED", @@ -608,6 +635,7 @@ def test_fetch_pr_review_data_normalizes_paginated_reviews_and_metadata(self, gr "reviews": [ { "id": 4700712792, + "commit_id": "reviewed-head-1", "url": "https://example.test/review/4700712792", "user": {"login": "reviewer-1"}, "state": "COMMENTED", @@ -617,6 +645,7 @@ def test_fetch_pr_review_data_normalizes_paginated_reviews_and_metadata(self, gr }, { "id": 5000000000, + "commit_id": "reviewed-head-2", "url": "https://example.test/review/5000000000", "user": {"login": "reviewer-2"}, "state": "APPROVED", diff --git a/.github/scripts/pull-request-dashboard/test_notify_slack.py b/.github/scripts/pull-request-dashboard/test_notify_slack.py index 0f758de77e7..e3931929f7b 100644 --- a/.github/scripts/pull-request-dashboard/test_notify_slack.py +++ b/.github/scripts/pull-request-dashboard/test_notify_slack.py @@ -1,12 +1,46 @@ from __future__ import annotations +from datetime import datetime, timezone import unittest from unittest.mock import patch +from notifications import next_notifications from notify_slack import notify_slack_from_state class NotifySlackTest(unittest.TestCase): + @patch("notifications.send_slack_notification") + def test_copilot_route_does_not_notify_reviewers(self, send_notification) -> None: + results = { + 7: { + "pr_number": 7, + "route": "copilot", + "facts": { + "reviewers": [{"login": "reviewer"}], + "waiting_since": "2026-07-20T01:00:00Z", + }, + }, + } + + with patch.dict( + "os.environ", + { + "SLACK_CHANNEL": "dashboard", + "SLACK_USER_MAP_JSON": '{"reviewer": "U123"}', + }, + clear=True, + ): + updated, errors = next_notifications( + "open-telemetry/example", + results, + {}, + datetime(2026, 7, 20, 2, tzinfo=timezone.utc), + ) + + self.assertEqual(updated, {}) + self.assertEqual(errors, []) + send_notification.assert_not_called() + @patch("notify_slack.save_notifications") @patch("notify_slack.load_notifications") @patch("notify_slack.list_open_prs") diff --git a/.github/scripts/pull-request-dashboard/test_pr_status_comment.py b/.github/scripts/pull-request-dashboard/test_pr_status_comment.py index 3dac940859f..35ef3880a3a 100644 --- a/.github/scripts/pull-request-dashboard/test_pr_status_comment.py +++ b/.github/scripts/pull-request-dashboard/test_pr_status_comment.py @@ -375,6 +375,7 @@ def test_routes_render_one_status_sentence(self) -> None: expected_summaries = { "approver": ("Reviewers", "Review the latest changes."), "maintainer": ("Maintainers", "Merge when ready."), + "copilot": ("Copilot", "Wait for the pending review to complete."), "external": ("An external dependency or decision", "Resolve it before work can continue."), "transient-failure": ("Pull request dashboard maintainers", "Determine the next action."), "unknown": ("Pull request dashboard maintainers", "Determine the next action."), diff --git a/.github/scripts/pull-request-dashboard/test_route_presentation.py b/.github/scripts/pull-request-dashboard/test_route_presentation.py index b9f2e54d6cc..4d17a796560 100644 --- a/.github/scripts/pull-request-dashboard/test_route_presentation.py +++ b/.github/scripts/pull-request-dashboard/test_route_presentation.py @@ -31,6 +31,12 @@ def test_author_status_does_not_mention_login(self) -> None: route_status_summary("author"), ) + def test_copilot_status_identifies_pending_review(self) -> None: + self.assertEqual( + ("Copilot", "Wait for the pending review to complete."), + route_status_summary("copilot"), + ) + def test_unrecognized_route_uses_unknown_presentation(self) -> None: self.assertEqual(route_label("unknown"), route_label("other")) self.assertEqual(route_status_summary("unknown"), route_status_summary("other")) diff --git a/.github/scripts/pull-request-dashboard/test_top_level_actions.py b/.github/scripts/pull-request-dashboard/test_top_level_actions.py index c6ceea79900..f08b81030ff 100644 --- a/.github/scripts/pull-request-dashboard/test_top_level_actions.py +++ b/.github/scripts/pull-request-dashboard/test_top_level_actions.py @@ -957,12 +957,14 @@ def test_dashboard_refresh_reuses_stored_top_level_history(self, build_result) - } } }, + True, ) self.assertEqual( build_result.call_args.kwargs["previous_top_level_history"], previous_state, ) + self.assertTrue(build_result.call_args.kwargs["require_clean_copilot_review"]) def test_top_level_decision_requires_matching_action_and_evidence(self) -> None: for action in ("reviewer", "approver"): diff --git a/.github/workflows/pull-request-dashboard-repo.yml b/.github/workflows/pull-request-dashboard-repo.yml index e2c6223e354..e601f4f51c3 100644 --- a/.github/workflows/pull-request-dashboard-repo.yml +++ b/.github/workflows/pull-request-dashboard-repo.yml @@ -22,6 +22,10 @@ on: required: false default: "[]" type: string + require_clean_copilot_review: + required: false + default: false + type: boolean slack_channel: required: false default: "" @@ -79,7 +83,7 @@ jobs: permission-contents: read permission-issues: read permission-members: read - permission-pull-requests: read + permission-pull-requests: write - name: Restore per-PR classification cache if: inputs.pr_number != '' @@ -123,6 +127,7 @@ jobs: REQUIRED_APPROVALS: ${{ inputs.required_approvals }} APPROVER_TEAMS_JSON: ${{ inputs.approver_teams_json }} NON_BLOCKING_CHECK_PATTERNS_JSON: ${{ inputs.non_blocking_check_patterns_json }} + REQUIRE_CLEAN_COPILOT_REVIEW: ${{ inputs.require_clean_copilot_review }} run: | set -euo pipefail state_branch="${DASHBOARD_STATE_BRANCH_PREFIX}/${REPO_NAME}" @@ -135,6 +140,9 @@ jobs: for pattern in "${non_blocking_check_patterns[@]}"; do dashboard_args+=(--non-blocking-check-pattern "$pattern") done + if [[ "${REQUIRE_CLEAN_COPILOT_REVIEW:-false}" == "true" ]]; then + dashboard_args+=(--require-clean-copilot-review) + fi if [[ -n "${TRIGGER_PR_NUMBER:-}" ]]; then dashboard_args+=(--pr-number "$TRIGGER_PR_NUMBER") fi diff --git a/.github/workflows/pull-request-dashboard.yml b/.github/workflows/pull-request-dashboard.yml index e03697bcaf3..85f07dfa5a6 100644 --- a/.github/workflows/pull-request-dashboard.yml +++ b/.github/workflows/pull-request-dashboard.yml @@ -148,6 +148,7 @@ jobs: required_approvals: ${{ matrix.required_approvals || 1 }} approver_teams_json: ${{ toJSON(matrix.approver_teams || fromJSON('[]')) }} non_blocking_check_patterns_json: ${{ toJSON(matrix.non_blocking_check_patterns || fromJSON('[]')) }} + require_clean_copilot_review: ${{ matrix.require_clean_copilot_review || false }} slack_channel: ${{ matrix.slack_channel }} slack_user_mapping_json: ${{ toJSON(matrix.slack_user_mapping || fromJSON('{}')) }} large_repo: ${{ matrix.large_repo || false }} diff --git a/pull-request-dashboard/README.md b/pull-request-dashboard/README.md index 8b78439c1a2..92d1766a4a0 100644 --- a/pull-request-dashboard/README.md +++ b/pull-request-dashboard/README.md @@ -52,6 +52,7 @@ Open a pull request that adds your repository to [`.github/scripts/pull-request- "markdown-link-check / link-check", "codecov/*" ], + "require_clean_copilot_review": true, "labels_to_display": ["size/*", "breaking change"], "slack_channel": "#example-maintainers", "slack_user_mapping": { @@ -70,12 +71,19 @@ Fields: | `required_approvals` | no | Number of approvals required for an open PR to be marked ready to merge. Defaults to `1`. | | `labels_to_display` | no | Case-sensitive shell-style label name patterns to display inline after PR titles. Exact names such as `breaking change` and wildcard patterns such as `size/*` are supported. Defaults to `[]`, which displays no labels. | | `non_blocking_check_patterns` | no | Check-name globs for non-required checks whose failures should be identified in the live PR status comment. When the PR is waiting on the author, matching failures are reported only when at least one required check is failing and are noted alongside those failures. On other routes, matching failures are shown separately. Matching checks remain informational and do not affect routing or the dashboard CI column. | +| `require_clean_copilot_review` | no | If `true`, require a Copilot review with no inline findings on the current head before routing a PR to reviewers or maintainers. The dashboard re-requests Copilot review when needed and does not duplicate a pending request. Requires automatic Copilot code review to be enabled for the repository. Defaults to `false`. | | `slack_channel` | no | Slack channel for notifications. Omit to skip Slack processing for this repository. | | `slack_user_mapping` | no | Map of GitHub login to Slack user ID for at-mentions. | | `large_repo` | no | If `true`, apply rendering presets that keep the dashboard body under GitHub's 65,536-character issue-body limit: cap each section (each *Waiting on …* table, the *Draft pull requests* table, and the *Diagnostics* block) at 100 rows, and omit the *Draft pull requests* section entirely. Truncated sections get a `_More X PRs not shown_` footer. Defaults to `false` (no cap, drafts shown). Enable this for very large repos with hundreds of PRs. | `labels_to_display` only controls which labels are shown. It does not filter pull requests or affect dashboard routing, notifications, or status comments. All matching labels are displayed in the order returned by GitHub; a label matching more than one configured pattern is shown once. +`require_clean_copilot_review` relies on automatic Copilot code review for the initial +review. The dashboard requests later reviews using its GitHub App installation +token with pull-request write permission. Leave **Review new +pushes** disabled if the dashboard should request re-reviews only when a PR is +ready to return to reviewers or maintainers. + Ask a maintainer or admin to add the repository under [Repository access](https://github.com/organizations/open-telemetry/settings/installations/133550497). Once the PR is merged, the dashboard will pick up your repository on its next hourly backfill run. To run it sooner, see [Manual backfill run](#manual-backfill-run). The dashboard issue is discovered dynamically in your repository by the `dashboard` label and `Pull Request Dashboard` title; if it does not exist, the publish step creates the label and issue.