From 7905f7d146d349a725f876a7cdb9dbe7ad4cfbf5 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Mon, 20 Jul 2026 14:11:16 -0700 Subject: [PATCH 01/44] Nudge authors waiting on pull requests --- .../pull-request-dashboard/author_nudge.py | 184 +++++++++++++ .../pull-request-dashboard/dashboard.py | 35 ++- .../scripts/pull-request-dashboard/state.py | 21 ++ .../test_author_nudge.py | 252 ++++++++++++++++++ .../pull-request-dashboard/test_dashboard.py | 13 + .../pull-request-dashboard/test_state.py | 25 ++ .../workflows/pull-request-dashboard-repo.yml | 48 ++++ pull-request-dashboard/README.md | 12 + 8 files changed, 589 insertions(+), 1 deletion(-) create mode 100644 .github/scripts/pull-request-dashboard/author_nudge.py create mode 100644 .github/scripts/pull-request-dashboard/test_author_nudge.py diff --git a/.github/scripts/pull-request-dashboard/author_nudge.py b/.github/scripts/pull-request-dashboard/author_nudge.py new file mode 100644 index 00000000000..6121ba48d96 --- /dev/null +++ b/.github/scripts/pull-request-dashboard/author_nudge.py @@ -0,0 +1,184 @@ +#!/usr/bin/env python3 +"""Track and post one-time reminders for pull requests waiting on authors.""" + +from __future__ import annotations + +import argparse +from datetime import datetime, timedelta +import sys +from typing import Any + +from github_cli import detect_repo, gh_api, normalize_repo, repo_state_key, run_gh +from pr_status_comment import ( + DASHBOARD_APP_SLUG, + managed_status_comments, + publish_pr_status, +) +from state import ( + author_nudge_state_path, + load_author_nudges, + load_dashboard_state_cache, + save_author_nudges, + set_state_dir, +) +import state_branch +from utils import format_ts, parse_ts, utc_now + + +NUDGE_AFTER = timedelta(weeks=1) +NUDGE_MARKER = "" + + +def plan_nudge( + result: dict[str, Any] | None, + previous: dict[str, Any] | None, + now: datetime, +) -> tuple[bool, dict[str, Any] | None]: + entry = dict(previous or {}) + nudged_at = entry.get("nudged_at") or "" + if result and ( + result.get("failed") + or result.get("route") in ("transient-failure", "unknown") + ): + return False, entry or None + if not result or result.get("route") != "author": + # Reset an unnudged route clock, but retain a posted nudge for the + # lifetime of the PR so leaving and returning cannot trigger another. + return False, {"nudged_at": nudged_at} if nudged_at else None + if nudged_at: + return False, {"nudged_at": nudged_at} + + waiting_since = parse_ts(entry.get("waiting_since") or "") + if waiting_since is None: + return False, { + "waiting_since": format_ts(now), + "nudged_at": "", + } + return now - waiting_since >= NUDGE_AFTER, entry + + +def existing_nudge_comment(repo: str, pr_number: int) -> dict[str, Any] | None: + comments = gh_api( + f"/repos/{repo}/issues/{pr_number}/comments?per_page=100", + paginate=True, + ) + return next( + ( + comment + for comment in comments or [] + if (comment.get("performed_via_github_app") or {}).get("slug") + == DASHBOARD_APP_SLUG + and NUDGE_MARKER in (comment.get("body") or "") + ), + None, + ) + + +def render_nudge(author: str, status_url: str) -> str: + return "\n".join([ + NUDGE_MARKER, + f"@{author}, this pull request has been waiting on your follow-up for one week.", + "", + f"See the [dashboard status comment]({status_url}) for the remaining items.", + "", + ]) + + +def ensure_nudge( + repo: str, + pr_number: int, + result: dict[str, Any], + dashboard_state: dict[str, Any], + now: datetime, +) -> str | None: + existing = existing_nudge_comment(repo, pr_number) + if existing: + return existing.get("created_at") or format_ts(now) + + pr = gh_api(f"/repos/{repo}/pulls/{pr_number}") or {} + if pr.get("state") != "open" or pr.get("draft"): + return None + + publish_pr_status(repo, pr_number, dashboard_state) + status_comments = managed_status_comments(repo, pr_number) + if not status_comments or not status_comments[0].get("html_url"): + raise RuntimeError(f"dashboard status comment not found for PR #{pr_number}") + author = str(((result.get("facts") or {}).get("author") or "")).strip() + author = author or str((pr.get("user") or {}).get("login") or "").strip() + if not author: + raise RuntimeError(f"author not found for PR #{pr_number}") + run_gh([ + "gh", "api", "--method", "POST", + f"repos/{repo}/issues/{pr_number}/comments", + "-f", f"body={render_nudge(author, status_comments[0]['html_url'])}", + ]) + return format_ts(now) + + +def update_author_nudges( + repo: str, + refreshed_pr_numbers: set[int], + post_due: bool, + now: datetime, +) -> int: + dashboard_state = load_dashboard_state_cache() + if dashboard_state is None: + print("dashboard state not found; skipping author nudges", file=sys.stderr) + return 0 + updated = dict(load_author_nudges()) + dashboard_prs = dashboard_state.get("prs") or {} + for pr_number in sorted(refreshed_pr_numbers): + key = str(pr_number) + result = dashboard_prs.get(key) + due, entry = plan_nudge(result, updated.get(key), now) + if due and post_due and result is not None: + nudged_at = ensure_nudge(repo, pr_number, result, dashboard_state, now) + entry = {"nudged_at": nudged_at} if nudged_at else None + if entry is None: + updated.pop(key, None) + else: + updated[key] = entry + save_author_nudges(updated) + return 0 + + +def parse_pr_numbers(value: str) -> set[int]: + if not value: + return set() + numbers = {int(part) for part in value.split(",")} + if any(number <= 0 for number in numbers): + raise ValueError("PR numbers must be positive") + return numbers + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", help="target repository name") + parser.add_argument("--state-branch", required=True, help="git branch used for workflow state") + parser.add_argument("--pr-numbers", required=True, help="comma-separated refreshed PR numbers") + parser.add_argument("--post-due", action="store_true", help="post due nudges") + args = parser.parse_args() + + try: + pr_numbers = parse_pr_numbers(args.pr_numbers) + except ValueError as e: + parser.error(str(e)) + if not pr_numbers: + return 0 + + repo = normalize_repo(args.repo) if args.repo else detect_repo() + repo_key = repo_state_key(repo) + now = utc_now() + with state_branch.temporary_state_dir() as state_dir: + set_state_dir(state_dir / repo_key) + return state_branch.push_state_changes( + state_dir, + "Update author nudge state", + lambda: update_author_nudges(repo, pr_numbers, args.post_due, now), + state_branch=args.state_branch, + add_paths=[f"{repo_key}/{author_nudge_state_path().name}"], + ) + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/.github/scripts/pull-request-dashboard/dashboard.py b/.github/scripts/pull-request-dashboard/dashboard.py index 48e787ab928..05e61daf21b 100644 --- a/.github/scripts/pull-request-dashboard/dashboard.py +++ b/.github/scripts/pull-request-dashboard/dashboard.py @@ -1934,12 +1934,15 @@ def update_dashboard_for_pr_number(args: argparse.Namespace, state_dir: Path) -> if update is None: return 0 - return state_branch.push_state_changes( + status = state_branch.push_state_changes( state_dir, "Update dashboard state", lambda: apply_targeted_dashboard_update(args, update), state_branch=args.state_branch, ) + if status == 0: + refreshed_pr_numbers(args).add(args.pr_number) + return status def update_dashboard_for_backfill(args: argparse.Namespace, state_dir: Path) -> int: @@ -1976,6 +1979,7 @@ def update_dashboard_for_backfill(args: argparse.Namespace, state_dir: Path) -> ) if status != 0: return status + refreshed_pr_numbers(args).update(selection.cached_pr_numbers_to_remove) print( f"backfill selected {len(selection.selected_prs)} PR(s) " @@ -1999,7 +2003,11 @@ def save_current_dashboard_state() -> int: ) for pr_summary in selection.selected_prs: + refreshed = False + def update_selected_pr(pr_summary: dict[str, Any] = pr_summary) -> int: + nonlocal refreshed + refreshed = False pr_number = pr_summary["number"] dashboard_state = load_dashboard_state_cache() or empty_state() calculation = build_dashboard_update_for_pr( @@ -2031,6 +2039,7 @@ def update_selected_pr(pr_summary: dict[str, Any] = pr_summary) -> int: dashboard_state, not initial_backfill_completed, ) + refreshed = True failed_pr_numbers = update_backfill_progress(pr_number, failed=False) if not dashboard_state_unchanged: enqueue_status_comment_update(pr_number) @@ -2053,6 +2062,8 @@ def update_selected_pr(pr_summary: dict[str, Any] = pr_summary) -> int: ) if status != 0: return status + if refreshed: + refreshed_pr_numbers(args).add(pr_summary["number"]) unresolved_failed_pr_numbers = ( backfill_failed_pr_numbers(load_backfill_state()) & open_non_draft_pr_numbers @@ -2079,6 +2090,23 @@ def write_initial_backfill_output(github_output: Path) -> None: output.write(f"initial_backfill_complete={'true' if complete else 'false'}\n") +def write_refreshed_pr_numbers_output( + github_output: Path, + refreshed_pr_numbers: set[int], +) -> None: + value = ",".join(str(number) for number in sorted(refreshed_pr_numbers)) + with github_output.open("a", encoding="utf-8") as output: + output.write(f"refreshed_pr_numbers={value}\n") + + +def refreshed_pr_numbers(args: argparse.Namespace) -> set[int]: + numbers = getattr(args, "refreshed_pr_numbers", None) + if numbers is None: + numbers = set() + args.refreshed_pr_numbers = numbers + return numbers + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument( @@ -2113,6 +2141,7 @@ def main() -> int: help="append initial_backfill_complete to this GitHub Actions output file", ) args = parser.parse_args() + args.refreshed_pr_numbers = set() if args.required_approvals < 1: parser.error("--required-approvals must be at least 1") with state_branch.temporary_state_dir() as state_dir: @@ -2121,6 +2150,10 @@ def main() -> int: status = update_dashboard_via_state_branch(args, state_dir) if args.github_output and status in (0, BACKFILL_RECORDED_FAILURE_STATUS): write_initial_backfill_output(args.github_output) + write_refreshed_pr_numbers_output( + args.github_output, + args.refreshed_pr_numbers, + ) return status diff --git a/.github/scripts/pull-request-dashboard/state.py b/.github/scripts/pull-request-dashboard/state.py index 186e66868ce..966f02c1e79 100644 --- a/.github/scripts/pull-request-dashboard/state.py +++ b/.github/scripts/pull-request-dashboard/state.py @@ -12,12 +12,14 @@ DASHBOARD_MARKDOWN_FILE = "pull-request-dashboard.md" BACKFILL_STATE_FILE = "backfill-state.json" +AUTHOR_NUDGE_STATE_FILE = "author-nudge-state.json" STATUS_COMMENT_ROLLOUT_STATE_FILE = "status-comment-rollout-state.json" # State files are disposable workflow caches, not durable user data. Bump only # the version for the state shape whose meaning changed. DASHBOARD_STATE_VERSION = 5 BACKFILL_STATE_VERSION = 3 NOTIFICATION_STATE_VERSION = 3 +AUTHOR_NUDGE_STATE_VERSION = 1 STATUS_COMMENT_ROLLOUT_STATE_VERSION = 1 INITIAL_BACKFILL_COMPLETE_KEY = "initial_backfill_complete" _state_dir: Path | None = None @@ -42,6 +44,10 @@ def notification_state_path() -> Path: return state_dir() / "notification-state.json" +def author_nudge_state_path() -> Path: + return state_dir() / AUTHOR_NUDGE_STATE_FILE + + def backfill_state_path() -> Path: return state_dir() / BACKFILL_STATE_FILE @@ -235,6 +241,21 @@ def save_notifications(notifications: dict[str, Any]) -> None: _save_notification_state_file({"prs": notifications}) +def load_author_nudges() -> dict[str, Any]: + state = load_state_file(author_nudge_state_path(), AUTHOR_NUDGE_STATE_VERSION) + if state is None or not isinstance(state.get("prs"), dict): + return {} + return state["prs"] + + +def save_author_nudges(nudges: dict[str, Any]) -> None: + save_state_file( + author_nudge_state_path(), + {"prs": nudges}, + AUTHOR_NUDGE_STATE_VERSION, + ) + + def union_merge_notifications( baseline_notifications: dict[str, Any], retry_snapshot_notifications: dict[str, Any] ) -> dict[str, Any]: diff --git a/.github/scripts/pull-request-dashboard/test_author_nudge.py b/.github/scripts/pull-request-dashboard/test_author_nudge.py new file mode 100644 index 00000000000..398dd5871dc --- /dev/null +++ b/.github/scripts/pull-request-dashboard/test_author_nudge.py @@ -0,0 +1,252 @@ +from __future__ import annotations + +from datetime import datetime, timezone +import unittest +from unittest.mock import patch + +import author_nudge + + +NOW = datetime(2026, 7, 17, tzinfo=timezone.utc) + + +def author_result(route: str = "author") -> dict: + return {"route": route, "facts": {"author": "alice"}} + + +class AuthorNudgePolicyTest(unittest.TestCase): + def test_first_author_route_observation_starts_clock(self) -> None: + due, entry = author_nudge.plan_nudge(author_result(), None, NOW) + + self.assertFalse(due) + self.assertEqual( + entry, + {"waiting_since": "2026-07-17T00:00:00+00:00", "nudged_at": ""}, + ) + + def test_nudge_is_due_after_one_week(self) -> None: + due, _entry = author_nudge.plan_nudge( + author_result(), + {"waiting_since": "2026-07-10T00:00:00+00:00", "nudged_at": ""}, + NOW, + ) + + self.assertTrue(due) + + def test_nudge_is_not_due_before_one_week(self) -> None: + due, _entry = author_nudge.plan_nudge( + author_result(), + {"waiting_since": "2026-07-10T00:00:01+00:00", "nudged_at": ""}, + NOW, + ) + + self.assertFalse(due) + + def test_leaving_author_route_resets_unnudged_clock(self) -> None: + due, entry = author_nudge.plan_nudge( + author_result("approver"), + {"waiting_since": "2026-07-10T00:00:00+00:00", "nudged_at": ""}, + NOW, + ) + + self.assertFalse(due) + self.assertIsNone(entry) + + def test_lifetime_nudge_marker_survives_route_changes(self) -> None: + previous = {"nudged_at": "2026-07-10T00:00:00+00:00"} + + for result in (author_result("approver"), None, author_result()): + due, entry = author_nudge.plan_nudge(result, previous, NOW) + self.assertFalse(due) + self.assertEqual(entry, previous) + + def test_failed_refresh_preserves_clock(self) -> None: + previous = {"waiting_since": "2026-07-10T00:00:00+00:00", "nudged_at": ""} + + due, entry = author_nudge.plan_nudge( + {"failed": True, "route": "unknown"}, + previous, + NOW, + ) + + self.assertFalse(due) + self.assertEqual(entry, previous) + + +class AuthorNudgeProcessingTest(unittest.TestCase): + @patch.object(author_nudge, "save_author_nudges") + @patch.object(author_nudge, "load_author_nudges", return_value={}) + @patch.object( + author_nudge, + "load_dashboard_state_cache", + return_value={"prs": {"1": author_result(), "2": author_result()}}, + ) + def test_updates_only_refreshed_prs( + self, + _load_dashboard_state, + _load_nudges, + save_nudges, + ) -> None: + author_nudge.update_author_nudges("open-telemetry/example", {2}, False, NOW) + + self.assertEqual( + save_nudges.call_args.args[0], + {"2": {"waiting_since": "2026-07-17T00:00:00+00:00", "nudged_at": ""}}, + ) + + @patch.object(author_nudge, "save_author_nudges") + @patch.object( + author_nudge, + "load_author_nudges", + return_value={ + "1": { + "waiting_since": "2026-07-10T00:00:00+00:00", + "nudged_at": "", + } + }, + ) + @patch.object( + author_nudge, + "load_dashboard_state_cache", + return_value={"prs": {}}, + ) + def test_refreshed_pr_absent_from_dashboard_state_resets_clock( + self, + _load_dashboard_state, + _load_nudges, + save_nudges, + ) -> None: + author_nudge.update_author_nudges("open-telemetry/example", {1}, False, NOW) + + self.assertEqual(save_nudges.call_args.args[0], {}) + + @patch.object(author_nudge, "ensure_nudge") + @patch.object(author_nudge, "save_author_nudges") + @patch.object( + author_nudge, + "load_author_nudges", + return_value={"1": {"waiting_since": "2026-07-01T00:00:00+00:00", "nudged_at": ""}}, + ) + @patch.object( + author_nudge, + "load_dashboard_state_cache", + return_value={"prs": {"1": author_result()}}, + ) + def test_track_only_run_does_not_post_due_nudge( + self, + _load_dashboard_state, + _load_nudges, + save_nudges, + ensure_nudge, + ) -> None: + author_nudge.update_author_nudges("open-telemetry/example", {1}, False, NOW) + + ensure_nudge.assert_not_called() + self.assertEqual( + save_nudges.call_args.args[0]["1"]["nudged_at"], + "", + ) + + @patch.object( + author_nudge, + "ensure_nudge", + return_value="2026-07-17T00:00:00+00:00", + ) + @patch.object(author_nudge, "save_author_nudges") + @patch.object( + author_nudge, + "load_author_nudges", + return_value={"1": {"waiting_since": "2026-07-01T00:00:00+00:00", "nudged_at": ""}}, + ) + @patch.object( + author_nudge, + "load_dashboard_state_cache", + return_value={"prs": {"1": author_result()}}, + ) + def test_hourly_run_records_posted_nudge( + self, + _load_dashboard_state, + _load_nudges, + save_nudges, + ensure_nudge, + ) -> None: + author_nudge.update_author_nudges("open-telemetry/example", {1}, True, NOW) + + ensure_nudge.assert_called_once() + self.assertEqual( + save_nudges.call_args.args[0], + {"1": {"nudged_at": "2026-07-17T00:00:00+00:00"}}, + ) + + def test_rendered_nudge_mentions_author_and_links_status(self) -> None: + body = author_nudge.render_nudge("alice", "https://example.test/status") + + self.assertIn("@alice", body) + self.assertIn("[dashboard status comment](https://example.test/status)", body) + self.assertIn(author_nudge.NUDGE_MARKER, body) + + @patch.object(author_nudge, "run_gh") + @patch.object(author_nudge, "publish_pr_status") + @patch.object(author_nudge, "managed_status_comments") + @patch.object( + author_nudge, + "gh_api", + side_effect=[ + [], + {"state": "open", "draft": False, "user": {"login": "alice"}}, + ], + ) + def test_posts_nudge_after_ensuring_status_comment( + self, + _gh_api, + managed_status_comments, + publish_status, + run_gh, + ) -> None: + managed_status_comments.return_value = [ + {"html_url": "https://example.test/status"} + ] + dashboard_state = {"prs": {"1": author_result()}} + + nudged_at = author_nudge.ensure_nudge( + "open-telemetry/example", + 1, + author_result(), + dashboard_state, + NOW, + ) + + self.assertEqual(nudged_at, "2026-07-17T00:00:00+00:00") + publish_status.assert_called_once_with( + "open-telemetry/example", 1, dashboard_state + ) + self.assertIn("@alice", run_gh.call_args.args[0][-1]) + + @patch.object(author_nudge, "run_gh") + @patch.object(author_nudge, "publish_pr_status") + @patch.object( + author_nudge, + "existing_nudge_comment", + return_value={"created_at": "2026-07-11T00:00:00Z"}, + ) + def test_existing_marker_prevents_duplicate_after_state_loss( + self, + _existing_comment, + publish_status, + run_gh, + ) -> None: + nudged_at = author_nudge.ensure_nudge( + "open-telemetry/example", + 1, + author_result(), + {"prs": {"1": author_result()}}, + NOW, + ) + + self.assertEqual(nudged_at, "2026-07-11T00:00:00Z") + publish_status.assert_not_called() + run_gh.assert_not_called() + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/.github/scripts/pull-request-dashboard/test_dashboard.py b/.github/scripts/pull-request-dashboard/test_dashboard.py index f821f62a47f..83527fea65a 100644 --- a/.github/scripts/pull-request-dashboard/test_dashboard.py +++ b/.github/scripts/pull-request-dashboard/test_dashboard.py @@ -25,6 +25,7 @@ set_backfill_pr_failed, update_dashboard_for_backfill, write_initial_backfill_output, + write_refreshed_pr_numbers_output, ) @@ -226,6 +227,17 @@ def test_writes_initial_backfill_status_to_github_output(self) -> None: output_path.read_text(encoding="utf-8"), ) + def test_writes_sorted_refreshed_pr_numbers_to_github_output(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + output_path = Path(temp_dir) / "output" + + write_refreshed_pr_numbers_output(output_path, {34, 12}) + + self.assertEqual( + "refreshed_pr_numbers=12,34\n", + output_path.read_text(encoding="utf-8"), + ) + class StatusCommentQueueTest(unittest.TestCase): @patch("dashboard.save_dashboard_update_state", return_value=0) @patch("dashboard.enqueue_status_comment_update") @@ -504,6 +516,7 @@ def push_state_changes(_state_dir, _message, update_state, **_kwargs) -> int: status = update_dashboard_for_backfill(args, Path("state")) self.assertEqual(refreshed_pr_numbers, [1, 2]) + self.assertEqual(args.refreshed_pr_numbers, {2}) self.assertEqual(status, BACKFILL_RECORDED_FAILURE_STATUS) self.assertEqual(dashboard_state["prs"], {"2": {"pr_number": 2, "failed": False, "route": "reviewer"}}) self.assertTrue(dashboard_state["initial_backfill_complete"]) diff --git a/.github/scripts/pull-request-dashboard/test_state.py b/.github/scripts/pull-request-dashboard/test_state.py index 3628ab69673..37f9538fda8 100644 --- a/.github/scripts/pull-request-dashboard/test_state.py +++ b/.github/scripts/pull-request-dashboard/test_state.py @@ -9,6 +9,10 @@ from unittest.mock import patch from state import ( + AUTHOR_NUDGE_STATE_VERSION, + author_nudge_state_path, + load_author_nudges, + save_author_nudges, BACKFILL_STATE_VERSION, DASHBOARD_STATE_VERSION, NOTIFICATION_STATE_VERSION, @@ -153,6 +157,27 @@ def test_notification_state_version_is_independent(self) -> None: self.assertEqual(NOTIFICATION_STATE_VERSION, 3) self.assertEqual(DASHBOARD_STATE_VERSION, 5) self.assertEqual(STATUS_COMMENT_ROLLOUT_STATE_VERSION, 1) + self.assertEqual(AUTHOR_NUDGE_STATE_VERSION, 1) + + def test_author_nudge_state_round_trip(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir, patch("state._state_dir", Path(temp_dir)): + save_author_nudges({ + "123": { + "waiting_since": "2026-07-10T00:00:00Z", + "nudged_at": "", + } + }) + + self.assertEqual( + load_author_nudges(), + { + "123": { + "waiting_since": "2026-07-10T00:00:00Z", + "nudged_at": "", + } + }, + ) + self.assertTrue(author_nudge_state_path().exists()) def test_status_comment_rollout_state_round_trip(self) -> None: with tempfile.TemporaryDirectory() as temp_dir, patch("state._state_dir", Path(temp_dir)): diff --git a/.github/workflows/pull-request-dashboard-repo.yml b/.github/workflows/pull-request-dashboard-repo.yml index e2c6223e354..1957276669e 100644 --- a/.github/workflows/pull-request-dashboard-repo.yml +++ b/.github/workflows/pull-request-dashboard-repo.yml @@ -63,6 +63,7 @@ jobs: runs-on: ubuntu-latest outputs: initial_backfill_complete: ${{ steps.dashboard-update.outputs.initial_backfill_complete }} + refreshed_pr_numbers: ${{ steps.dashboard-update.outputs.refreshed_pr_numbers }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -252,6 +253,53 @@ jobs: python3 .github/scripts/pull-request-dashboard/notify_slack.py "${notify_args[@]}" + author-nudge: + needs: update-dashboard + if: >- + always() && + (needs.update-dashboard.result == 'success' || inputs.pr_number == '') && + needs.update-dashboard.outputs.initial_backfill_complete == 'true' && + needs.update-dashboard.outputs.refreshed_pr_numbers != '' + concurrency: + group: ${{ github.workflow }}-author-nudge-${{ inputs.repository }} + cancel-in-progress: false + permissions: + contents: write + environment: protected + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + id: dashboard-token + with: + client-id: ${{ vars.PR_DASHBOARD_CLIENT_ID }} + private-key: ${{ secrets.PR_DASHBOARD_PRIVATE_KEY }} + owner: open-telemetry + repositories: ${{ inputs.repository }} + permission-pull-requests: write + + - name: Process author nudges + env: + GH_TOKEN: ${{ steps.dashboard-token.outputs.token }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO_NAME: ${{ inputs.repository }} + REFRESHED_PR_NUMBERS: ${{ needs.update-dashboard.outputs.refreshed_pr_numbers }} + run: | + set -euo pipefail + state_branch="${DASHBOARD_STATE_BRANCH_PREFIX}/${REPO_NAME}" + nudge_args=( + --state-branch "$state_branch" + --repo "$REPO_NAME" + --pr-numbers "$REFRESHED_PR_NUMBERS" + ) + if [[ "${{ github.event_name }}" == "schedule" ]]; then + nudge_args+=(--post-due) + fi + python3 .github/scripts/pull-request-dashboard/author_nudge.py "${nudge_args[@]}" + publish-dashboard: needs: update-dashboard if: >- diff --git a/pull-request-dashboard/README.md b/pull-request-dashboard/README.md index 8b78439c1a2..dd4d18a5ace 100644 --- a/pull-request-dashboard/README.md +++ b/pull-request-dashboard/README.md @@ -184,6 +184,18 @@ for the tradeoffs behind this behavior. Targeted updates received before the first full dashboard run are ignored. +## Author reminder + +The dashboard posts one reminder when a pull request remains in *Waiting on +authors* for one week. The reminder @-mentions the author and links to the +dashboard-managed status comment containing the remaining items. + +Leaving *Waiting on authors* before the reminder is due resets the one-week +clock. Each pull request is reminded at most once, even if it later leaves and +returns to *Waiting on authors*. Reminders are delivered by hourly runs when +the pull request is next refreshed, so a due reminder in a large repository +may wait for a later round-robin run. + ## Configuration The dashboard uses repository-scoped GitHub App access to read and update each From 7a3c379d758b905c9b5d9ea44f6fb76756711ef8 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Mon, 20 Jul 2026 15:19:05 -0700 Subject: [PATCH 02/44] Address review comments from Copilot: make author nudges race-safe --- .../pull-request-dashboard/author_nudge.py | 123 ++++++++++++++++-- .../pull-request-dashboard/dashboard.py | 29 ++++- .../test_author_nudge.py | 99 ++++++++++---- .../pull-request-dashboard/test_dashboard.py | 16 ++- .../workflows/pull-request-dashboard-repo.yml | 81 +++++------- 5 files changed, 255 insertions(+), 93 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/author_nudge.py b/.github/scripts/pull-request-dashboard/author_nudge.py index 6121ba48d96..217e5f724fe 100644 --- a/.github/scripts/pull-request-dashboard/author_nudge.py +++ b/.github/scripts/pull-request-dashboard/author_nudge.py @@ -8,7 +8,7 @@ import sys from typing import Any -from github_cli import detect_repo, gh_api, normalize_repo, repo_state_key, run_gh +from github_cli import detect_repo, gh_api, load_reviewer_set, normalize_repo, repo_state_key, run_gh from pr_status_comment import ( DASHBOARD_APP_SLUG, managed_status_comments, @@ -20,6 +20,7 @@ load_dashboard_state_cache, save_author_nudges, set_state_dir, + update_dashboard_state_for_pr, ) import state_branch from utils import format_ts, parse_ts, utc_now @@ -115,11 +116,56 @@ def ensure_nudge( return format_ts(now) -def update_author_nudges( +def record_author_nudge_observation( + pr_number: int, + result: dict[str, Any] | None, + now: datetime, +) -> None: + updated = dict(load_author_nudges()) + key = str(pr_number) + _due, entry = plan_nudge(result, updated.get(key), now) + if entry is None: + updated.pop(key, None) + else: + updated[key] = entry + save_author_nudges(updated) + + +def refresh_author_nudge_result( + repo: str, + pr_number: int, + dashboard_state: dict[str, Any], + approver_teams: list[str], + required_approvals: int, + non_blocking_check_patterns: list[str], +) -> tuple[dict[str, Any] | None, dict[str, Any]]: + from dashboard import DEFAULT_MODEL, build_dashboard_update_for_pr + + owner, repo_name = repo.split("/", 1) + reviewers = load_reviewer_set(owner, approver_teams) + calculation = build_dashboard_update_for_pr( + repo, + owner, + repo_name, + {pr_number}, + reviewers, + pr_number, + DEFAULT_MODEL, + required_approvals, + non_blocking_check_patterns, + dashboard_state, + ) + result = calculation.trigger_pr_result + return result, update_dashboard_state_for_pr(dashboard_state, pr_number, result) + + +def deliver_author_nudges( repo: str, refreshed_pr_numbers: set[int], - post_due: bool, now: datetime, + approver_teams: list[str], + required_approvals: int, + non_blocking_check_patterns: list[str], ) -> int: dashboard_state = load_dashboard_state_cache() if dashboard_state is None: @@ -130,14 +176,39 @@ def update_author_nudges( for pr_number in sorted(refreshed_pr_numbers): key = str(pr_number) result = dashboard_prs.get(key) - due, entry = plan_nudge(result, updated.get(key), now) - if due and post_due and result is not None: - nudged_at = ensure_nudge(repo, pr_number, result, dashboard_state, now) - entry = {"nudged_at": nudged_at} if nudged_at else None - if entry is None: - updated.pop(key, None) - else: - updated[key] = entry + entry = updated.get(key) or {} + waiting_since = parse_ts(entry.get("waiting_since") or "") + if ( + result is None + or result.get("route") != "author" + or entry.get("nudged_at") + or waiting_since is None + or now - waiting_since < NUDGE_AFTER + ): + continue + fresh_result, fresh_dashboard_state = refresh_author_nudge_result( + repo, + pr_number, + dashboard_state, + approver_teams, + required_approvals, + non_blocking_check_patterns, + ) + if ( + fresh_result is None + or fresh_result.get("failed") + or fresh_result.get("route") != "author" + ): + continue + nudged_at = ensure_nudge( + repo, + pr_number, + fresh_result, + fresh_dashboard_state, + now, + ) + if nudged_at: + updated[key] = {"nudged_at": nudged_at} save_author_nudges(updated) return 0 @@ -156,13 +227,32 @@ def main() -> int: parser.add_argument("--repo", help="target repository name") parser.add_argument("--state-branch", required=True, help="git branch used for workflow state") parser.add_argument("--pr-numbers", required=True, help="comma-separated refreshed PR numbers") - parser.add_argument("--post-due", action="store_true", help="post due nudges") + parser.add_argument( + "--approver-team", + action="append", + required=True, + help="approver team slug for the target repository; repeat for multiple teams", + ) + parser.add_argument( + "--required-approvals", + type=int, + default=1, + help="minimum non-bot approvals needed before a PR can route to maintainers", + ) + parser.add_argument( + "--non-blocking-check-pattern", + action="append", + default=[], + help="glob matching a non-required check to mention when it fails; repeat as needed", + ) args = parser.parse_args() try: pr_numbers = parse_pr_numbers(args.pr_numbers) except ValueError as e: parser.error(str(e)) + if args.required_approvals < 1: + parser.error("--required-approvals must be at least 1") if not pr_numbers: return 0 @@ -174,7 +264,14 @@ def main() -> int: return state_branch.push_state_changes( state_dir, "Update author nudge state", - lambda: update_author_nudges(repo, pr_numbers, args.post_due, now), + lambda: deliver_author_nudges( + repo, + pr_numbers, + now, + args.approver_team, + args.required_approvals, + args.non_blocking_check_pattern, + ), state_branch=args.state_branch, add_paths=[f"{repo_key}/{author_nudge_state_path().name}"], ) diff --git a/.github/scripts/pull-request-dashboard/dashboard.py b/.github/scripts/pull-request-dashboard/dashboard.py index 05e61daf21b..33a8f315a1d 100644 --- a/.github/scripts/pull-request-dashboard/dashboard.py +++ b/.github/scripts/pull-request-dashboard/dashboard.py @@ -191,6 +191,7 @@ normalize_discussion_action, prune_classification_cache, ) +from author_nudge import record_author_nudge_observation from state import ( INITIAL_BACKFILL_COMPLETE_KEY, empty_state, @@ -206,7 +207,7 @@ update_dashboard_state_for_pr, ) import state_branch -from utils import actor_login, format_ts, parse_ts, truncate +from utils import actor_login, format_ts, parse_ts, truncate, utc_now # --- CLI defaults ---------------------------------------------------------- DEFAULT_MODEL = "gpt-5.4-mini" @@ -1859,14 +1860,17 @@ def clear_backfill_pr_failure(pr_number: int) -> None: def remove_cached_dashboard_prs( args: argparse.Namespace, pr_numbers_to_remove: set[int], + observed_at: datetime | None = None, ) -> int: if not pr_numbers_to_remove: return 0 dashboard_state = load_dashboard_state_cache() or empty_state() state_prs = dict(dashboard_state.get("prs") or {}) + observed_at = observed_at or utc_now() for number in pr_numbers_to_remove: state_prs.pop(str(number), None) enqueue_status_comment_update(number) + record_author_nudge_observation(number, None, observed_at) dashboard_state["prs"] = state_prs return save_dashboard_update_state(args, dashboard_state, False) @@ -1898,7 +1902,11 @@ def build_targeted_dashboard_update(args: argparse.Namespace) -> DashboardUpdate ) -def apply_targeted_dashboard_update(args: argparse.Namespace, calculation: DashboardUpdate) -> int: +def apply_targeted_dashboard_update( + args: argparse.Namespace, + calculation: DashboardUpdate, + observed_at: datetime | None = None, +) -> int: merged_calculation, dashboard_state_unchanged = merge_dashboard_update_with_latest_state( calculation, args.pr_number, @@ -1912,6 +1920,12 @@ def apply_targeted_dashboard_update(args: argparse.Namespace, calculation: Dashb clear_backfill_pr_failure(args.pr_number) if not dashboard_state_unchanged and args.pr_number is not None: enqueue_status_comment_update(args.pr_number) + if args.pr_number is not None: + record_author_nudge_observation( + args.pr_number, + merged_calculation.trigger_pr_result, + observed_at or utc_now(), + ) return save_dashboard_update_state( args, @@ -1934,10 +1948,11 @@ def update_dashboard_for_pr_number(args: argparse.Namespace, state_dir: Path) -> if update is None: return 0 + observed_at = utc_now() status = state_branch.push_state_changes( state_dir, "Update dashboard state", - lambda: apply_targeted_dashboard_update(args, update), + lambda: apply_targeted_dashboard_update(args, update, observed_at), state_branch=args.state_branch, ) if status == 0: @@ -1968,12 +1983,14 @@ def update_dashboard_for_backfill(args: argparse.Namespace, state_dir: Path) -> ) if selection.cached_pr_numbers_to_remove: + observed_at = utc_now() status = state_branch.push_state_changes( state_dir, "Update dashboard state", lambda: remove_cached_dashboard_prs( args, selection.cached_pr_numbers_to_remove, + observed_at, ), state_branch=args.state_branch, ) @@ -2004,6 +2021,7 @@ def save_current_dashboard_state() -> int: for pr_summary in selection.selected_prs: refreshed = False + observed_at = utc_now() def update_selected_pr(pr_summary: dict[str, Any] = pr_summary) -> int: nonlocal refreshed @@ -2040,6 +2058,11 @@ def update_selected_pr(pr_summary: dict[str, Any] = pr_summary) -> int: not initial_backfill_completed, ) refreshed = True + record_author_nudge_observation( + pr_number, + calculation.trigger_pr_result, + observed_at, + ) failed_pr_numbers = update_backfill_progress(pr_number, failed=False) if not dashboard_state_unchanged: enqueue_status_comment_update(pr_number) diff --git a/.github/scripts/pull-request-dashboard/test_author_nudge.py b/.github/scripts/pull-request-dashboard/test_author_nudge.py index 398dd5871dc..2120a696670 100644 --- a/.github/scripts/pull-request-dashboard/test_author_nudge.py +++ b/.github/scripts/pull-request-dashboard/test_author_nudge.py @@ -1,10 +1,12 @@ from __future__ import annotations from datetime import datetime, timezone +from types import SimpleNamespace import unittest from unittest.mock import patch import author_nudge +import dashboard NOW = datetime(2026, 7, 17, tzinfo=timezone.utc) @@ -76,18 +78,12 @@ def test_failed_refresh_preserves_clock(self) -> None: class AuthorNudgeProcessingTest(unittest.TestCase): @patch.object(author_nudge, "save_author_nudges") @patch.object(author_nudge, "load_author_nudges", return_value={}) - @patch.object( - author_nudge, - "load_dashboard_state_cache", - return_value={"prs": {"1": author_result(), "2": author_result()}}, - ) - def test_updates_only_refreshed_prs( + def test_observation_starts_clock( self, - _load_dashboard_state, _load_nudges, save_nudges, ) -> None: - author_nudge.update_author_nudges("open-telemetry/example", {2}, False, NOW) + author_nudge.record_author_nudge_observation(2, author_result(), NOW) self.assertEqual( save_nudges.call_args.args[0], @@ -105,22 +101,60 @@ def test_updates_only_refreshed_prs( } }, ) - @patch.object( - author_nudge, - "load_dashboard_state_cache", - return_value={"prs": {}}, - ) - def test_refreshed_pr_absent_from_dashboard_state_resets_clock( + def test_departure_observation_resets_clock( self, - _load_dashboard_state, _load_nudges, save_nudges, ) -> None: - author_nudge.update_author_nudges("open-telemetry/example", {1}, False, NOW) + author_nudge.record_author_nudge_observation(1, None, NOW) self.assertEqual(save_nudges.call_args.args[0], {}) - @patch.object(author_nudge, "ensure_nudge") + @patch.object(author_nudge, "load_reviewer_set", return_value={"reviewer"}) + @patch.object(dashboard, "build_dashboard_update_for_pr") + def test_fresh_result_uses_dashboard_routing_configuration( + self, + build_update, + _load_reviewers, + ) -> None: + result = {"pr_number": 1, **author_result()} + build_update.return_value = SimpleNamespace(trigger_pr_result=result) + dashboard_state = {"version": 1, "prs": {}} + + fresh_result, fresh_dashboard_state = author_nudge.refresh_author_nudge_result( + "open-telemetry/example", + 1, + dashboard_state, + ["approvers"], + 2, + ["optional-*"], + ) + + self.assertEqual(result, fresh_result) + self.assertEqual("author", fresh_dashboard_state["prs"]["1"]["route"]) + build_update.assert_called_once_with( + "open-telemetry/example", + "open-telemetry", + "example", + {1}, + {"reviewer"}, + 1, + dashboard.DEFAULT_MODEL, + 2, + ["optional-*"], + dashboard_state, + ) + + @patch.object( + author_nudge, + "ensure_nudge", + return_value="2026-07-17T00:00:00+00:00", + ) + @patch.object( + author_nudge, + "refresh_author_nudge_result", + return_value=(author_result(), {"prs": {"1": author_result()}}), + ) @patch.object(author_nudge, "save_author_nudges") @patch.object( author_nudge, @@ -132,25 +166,30 @@ def test_refreshed_pr_absent_from_dashboard_state_resets_clock( "load_dashboard_state_cache", return_value={"prs": {"1": author_result()}}, ) - def test_track_only_run_does_not_post_due_nudge( + def test_delivery_records_posted_nudge( self, _load_dashboard_state, _load_nudges, save_nudges, + refresh_result, ensure_nudge, ) -> None: - author_nudge.update_author_nudges("open-telemetry/example", {1}, False, NOW) + author_nudge.deliver_author_nudges( + "open-telemetry/example", {1}, NOW, ["approvers"], 1, [] + ) - ensure_nudge.assert_not_called() + refresh_result.assert_called_once() + ensure_nudge.assert_called_once() self.assertEqual( - save_nudges.call_args.args[0]["1"]["nudged_at"], - "", + save_nudges.call_args.args[0], + {"1": {"nudged_at": "2026-07-17T00:00:00+00:00"}}, ) + @patch.object(author_nudge, "ensure_nudge") @patch.object( author_nudge, - "ensure_nudge", - return_value="2026-07-17T00:00:00+00:00", + "refresh_author_nudge_result", + return_value=(author_result("approver"), {"prs": {"1": author_result("approver")}}), ) @patch.object(author_nudge, "save_author_nudges") @patch.object( @@ -163,19 +202,23 @@ def test_track_only_run_does_not_post_due_nudge( "load_dashboard_state_cache", return_value={"prs": {"1": author_result()}}, ) - def test_hourly_run_records_posted_nudge( + def test_delivery_does_not_rewrite_route_clock( self, _load_dashboard_state, _load_nudges, save_nudges, + refresh_result, ensure_nudge, ) -> None: - author_nudge.update_author_nudges("open-telemetry/example", {1}, True, NOW) + author_nudge.deliver_author_nudges( + "open-telemetry/example", {1}, NOW, ["approvers"], 1, [] + ) - ensure_nudge.assert_called_once() + refresh_result.assert_called_once() + ensure_nudge.assert_not_called() self.assertEqual( save_nudges.call_args.args[0], - {"1": {"nudged_at": "2026-07-17T00:00:00+00:00"}}, + {"1": {"waiting_since": "2026-07-01T00:00:00+00:00", "nudged_at": ""}}, ) def test_rendered_nudge_mentions_author_and_links_status(self) -> None: diff --git a/.github/scripts/pull-request-dashboard/test_dashboard.py b/.github/scripts/pull-request-dashboard/test_dashboard.py index 83527fea65a..e7680ac7fb8 100644 --- a/.github/scripts/pull-request-dashboard/test_dashboard.py +++ b/.github/scripts/pull-request-dashboard/test_dashboard.py @@ -5,7 +5,7 @@ from pathlib import Path import tempfile import unittest -from unittest.mock import Mock, call, patch +from unittest.mock import ANY, Mock, call, patch from classification import discussion_prompt_input from dashboard import ( @@ -239,6 +239,7 @@ def test_writes_sorted_refreshed_pr_numbers_to_github_output(self) -> None: ) class StatusCommentQueueTest(unittest.TestCase): + @patch("dashboard.record_author_nudge_observation") @patch("dashboard.save_dashboard_update_state", return_value=0) @patch("dashboard.enqueue_status_comment_update") @patch( @@ -250,6 +251,7 @@ def test_removed_dashboard_results_enqueue_status_comments( _load_state: Mock, enqueue_update: Mock, save_state: Mock, + record_nudge: Mock, ) -> None: args = Namespace(pr_number=None) @@ -262,7 +264,12 @@ def test_removed_dashboard_results_enqueue_status_comments( ) saved_state = save_state.call_args.args[1] self.assertEqual({"56": {}}, saved_state["prs"]) + self.assertEqual( + [call(12, None, ANY), call(34, None, ANY)], + sorted(record_nudge.call_args_list, key=lambda value: value.args[0]), + ) + @patch("dashboard.record_author_nudge_observation") @patch("dashboard.clear_backfill_pr_failure") @patch("dashboard.save_dashboard_update_state", return_value=0) @patch("dashboard.enqueue_status_comment_update") @@ -273,6 +280,7 @@ def test_targeted_state_change_enqueues_status_comment( enqueue_update: Mock, _save_state: Mock, _clear_backfill_failure: Mock, + record_nudge: Mock, ) -> None: calculation = DashboardUpdate(results={}, dashboard_state={}, trigger_pr_result={}) merge_update.return_value = (calculation, False) @@ -281,7 +289,9 @@ def test_targeted_state_change_enqueues_status_comment( self.assertEqual(0, status) enqueue_update.assert_called_once_with(12) + record_nudge.assert_called_once_with(12, calculation.trigger_pr_result, ANY) + @patch("dashboard.record_author_nudge_observation") @patch("dashboard.clear_backfill_pr_failure") @patch("dashboard.save_dashboard_update_state", return_value=0) @patch("dashboard.enqueue_status_comment_update") @@ -292,6 +302,7 @@ def test_unchanged_targeted_state_does_not_enqueue_status_comment( enqueue_update: Mock, _save_state: Mock, _clear_backfill_failure: Mock, + record_nudge: Mock, ) -> None: calculation = DashboardUpdate(results={}, dashboard_state={}, trigger_pr_result={}) merge_update.return_value = (calculation, True) @@ -300,6 +311,7 @@ def test_unchanged_targeted_state_does_not_enqueue_status_comment( self.assertEqual(0, status) enqueue_update.assert_not_called() + record_nudge.assert_called_once_with(12, calculation.trigger_pr_result, ANY) class RequiredCiRoutingTest(unittest.TestCase): @@ -508,6 +520,7 @@ def push_state_changes(_state_dir, _message, update_state, **_kwargs) -> int: side_effect=lambda result: result["failed"], ), patch("dashboard.save_dashboard_update_state", side_effect=save_dashboard_state), + patch("dashboard.record_author_nudge_observation") as record_nudge, patch("dashboard.state_branch.configure_git"), patch("dashboard.state_branch.checkout_state"), patch("dashboard.state_branch.remove_existing_state_dir"), @@ -517,6 +530,7 @@ def push_state_changes(_state_dir, _message, update_state, **_kwargs) -> int: self.assertEqual(refreshed_pr_numbers, [1, 2]) self.assertEqual(args.refreshed_pr_numbers, {2}) + record_nudge.assert_called_once_with(2, ANY, ANY) self.assertEqual(status, BACKFILL_RECORDED_FAILURE_STATUS) self.assertEqual(dashboard_state["prs"], {"2": {"pr_number": 2, "failed": False, "route": "reviewer"}}) self.assertTrue(dashboard_state["initial_backfill_complete"]) diff --git a/.github/workflows/pull-request-dashboard-repo.yml b/.github/workflows/pull-request-dashboard-repo.yml index 1957276669e..cb7418b708e 100644 --- a/.github/workflows/pull-request-dashboard-repo.yml +++ b/.github/workflows/pull-request-dashboard-repo.yml @@ -80,7 +80,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 != '' @@ -141,6 +141,38 @@ jobs: fi python3 .github/scripts/pull-request-dashboard/dashboard.py "${dashboard_args[@]}" + - name: Deliver due author nudges + if: >- + always() && + github.event_name == 'schedule' && + steps.dashboard-update.outputs.initial_backfill_complete == 'true' && + steps.dashboard-update.outputs.refreshed_pr_numbers != '' + env: + GH_TOKEN: ${{ steps.dashboard-token.outputs.token }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO_NAME: ${{ inputs.repository }} + REFRESHED_PR_NUMBERS: ${{ steps.dashboard-update.outputs.refreshed_pr_numbers }} + REQUIRED_APPROVALS: ${{ inputs.required_approvals }} + APPROVER_TEAMS_JSON: ${{ inputs.approver_teams_json }} + NON_BLOCKING_CHECK_PATTERNS_JSON: ${{ inputs.non_blocking_check_patterns_json }} + run: | + set -euo pipefail + state_branch="${DASHBOARD_STATE_BRANCH_PREFIX}/${REPO_NAME}" + nudge_args=( + --state-branch "$state_branch" + --repo "$REPO_NAME" + --pr-numbers "$REFRESHED_PR_NUMBERS" + --required-approvals "$REQUIRED_APPROVALS" + ) + for team in $(jq -r '.[]' <<< "$APPROVER_TEAMS_JSON"); do + nudge_args+=(--approver-team "$team") + done + mapfile -t non_blocking_check_patterns < <(jq -r '.[]' <<< "$NON_BLOCKING_CHECK_PATTERNS_JSON") + for pattern in "${non_blocking_check_patterns[@]}"; do + nudge_args+=(--non-blocking-check-pattern "$pattern") + done + python3 .github/scripts/pull-request-dashboard/author_nudge.py "${nudge_args[@]}" + - name: Save per-PR classification cache # Preserve valid classifications even when another item fails the update. if: always() && inputs.pr_number != '' @@ -253,53 +285,6 @@ jobs: python3 .github/scripts/pull-request-dashboard/notify_slack.py "${notify_args[@]}" - author-nudge: - needs: update-dashboard - if: >- - always() && - (needs.update-dashboard.result == 'success' || inputs.pr_number == '') && - needs.update-dashboard.outputs.initial_backfill_complete == 'true' && - needs.update-dashboard.outputs.refreshed_pr_numbers != '' - concurrency: - group: ${{ github.workflow }}-author-nudge-${{ inputs.repository }} - cancel-in-progress: false - permissions: - contents: write - environment: protected - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - id: dashboard-token - with: - client-id: ${{ vars.PR_DASHBOARD_CLIENT_ID }} - private-key: ${{ secrets.PR_DASHBOARD_PRIVATE_KEY }} - owner: open-telemetry - repositories: ${{ inputs.repository }} - permission-pull-requests: write - - - name: Process author nudges - env: - GH_TOKEN: ${{ steps.dashboard-token.outputs.token }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO_NAME: ${{ inputs.repository }} - REFRESHED_PR_NUMBERS: ${{ needs.update-dashboard.outputs.refreshed_pr_numbers }} - run: | - set -euo pipefail - state_branch="${DASHBOARD_STATE_BRANCH_PREFIX}/${REPO_NAME}" - nudge_args=( - --state-branch "$state_branch" - --repo "$REPO_NAME" - --pr-numbers "$REFRESHED_PR_NUMBERS" - ) - if [[ "${{ github.event_name }}" == "schedule" ]]; then - nudge_args+=(--post-due) - fi - python3 .github/scripts/pull-request-dashboard/author_nudge.py "${nudge_args[@]}" - publish-dashboard: needs: update-dashboard if: >- From ba2d8d50dd9b9e7b8edf4e16ed5661f5dfa71123 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Mon, 20 Jul 2026 15:33:34 -0700 Subject: [PATCH 03/44] Address review comments from Copilot: align author nudge state --- .../pull-request-dashboard/author_nudge.py | 5 +++ .../pull-request-dashboard/dashboard.py | 4 +-- .../test_author_nudge.py | 34 ++++++++++++++++++- .../pull-request-dashboard/test_dashboard.py | 18 +++++++--- 4 files changed, 54 insertions(+), 7 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/author_nudge.py b/.github/scripts/pull-request-dashboard/author_nudge.py index 217e5f724fe..1a38b230d6e 100644 --- a/.github/scripts/pull-request-dashboard/author_nudge.py +++ b/.github/scripts/pull-request-dashboard/author_nudge.py @@ -194,6 +194,11 @@ def deliver_author_nudges( required_approvals, non_blocking_check_patterns, ) + _due, fresh_entry = plan_nudge(fresh_result, updated.get(key), now) + if fresh_entry is None: + updated.pop(key, None) + else: + updated[key] = fresh_entry if ( fresh_result is None or fresh_result.get("failed") diff --git a/.github/scripts/pull-request-dashboard/dashboard.py b/.github/scripts/pull-request-dashboard/dashboard.py index 33a8f315a1d..2aa62242672 100644 --- a/.github/scripts/pull-request-dashboard/dashboard.py +++ b/.github/scripts/pull-request-dashboard/dashboard.py @@ -1923,7 +1923,7 @@ def apply_targeted_dashboard_update( if args.pr_number is not None: record_author_nudge_observation( args.pr_number, - merged_calculation.trigger_pr_result, + (merged_calculation.dashboard_state.get("prs") or {}).get(str(args.pr_number)), observed_at or utc_now(), ) @@ -2060,7 +2060,7 @@ def update_selected_pr(pr_summary: dict[str, Any] = pr_summary) -> int: refreshed = True record_author_nudge_observation( pr_number, - calculation.trigger_pr_result, + (calculation.dashboard_state.get("prs") or {}).get(str(pr_number)), observed_at, ) failed_pr_numbers = update_backfill_progress(pr_number, failed=False) diff --git a/.github/scripts/pull-request-dashboard/test_author_nudge.py b/.github/scripts/pull-request-dashboard/test_author_nudge.py index 2120a696670..4387183101a 100644 --- a/.github/scripts/pull-request-dashboard/test_author_nudge.py +++ b/.github/scripts/pull-request-dashboard/test_author_nudge.py @@ -202,7 +202,7 @@ def test_delivery_records_posted_nudge( "load_dashboard_state_cache", return_value={"prs": {"1": author_result()}}, ) - def test_delivery_does_not_rewrite_route_clock( + def test_delivery_resets_clock_for_fresh_route_departure( self, _load_dashboard_state, _load_nudges, @@ -215,6 +215,38 @@ def test_delivery_does_not_rewrite_route_clock( ) refresh_result.assert_called_once() + ensure_nudge.assert_not_called() + self.assertEqual(save_nudges.call_args.args[0], {}) + + @patch.object(author_nudge, "ensure_nudge") + @patch.object( + author_nudge, + "refresh_author_nudge_result", + return_value=({"failed": True, "route": "unknown"}, {"prs": {}}), + ) + @patch.object(author_nudge, "save_author_nudges") + @patch.object( + author_nudge, + "load_author_nudges", + return_value={"1": {"waiting_since": "2026-07-01T00:00:00+00:00", "nudged_at": ""}}, + ) + @patch.object( + author_nudge, + "load_dashboard_state_cache", + return_value={"prs": {"1": author_result()}}, + ) + def test_delivery_preserves_clock_when_fresh_refresh_fails( + self, + _load_dashboard_state, + _load_nudges, + save_nudges, + _refresh_result, + ensure_nudge, + ) -> None: + author_nudge.deliver_author_nudges( + "open-telemetry/example", {1}, NOW, ["approvers"], 1, [] + ) + ensure_nudge.assert_not_called() self.assertEqual( save_nudges.call_args.args[0], diff --git a/.github/scripts/pull-request-dashboard/test_dashboard.py b/.github/scripts/pull-request-dashboard/test_dashboard.py index e7680ac7fb8..4c03cc30e58 100644 --- a/.github/scripts/pull-request-dashboard/test_dashboard.py +++ b/.github/scripts/pull-request-dashboard/test_dashboard.py @@ -282,14 +282,19 @@ def test_targeted_state_change_enqueues_status_comment( _clear_backfill_failure: Mock, record_nudge: Mock, ) -> None: - calculation = DashboardUpdate(results={}, dashboard_state={}, trigger_pr_result={}) + accepted_result = {"route": "author"} + calculation = DashboardUpdate( + results={}, + dashboard_state={"prs": {"12": accepted_result}}, + trigger_pr_result={"route": "approver"}, + ) merge_update.return_value = (calculation, False) status = apply_targeted_dashboard_update(Namespace(pr_number=12), calculation) self.assertEqual(0, status) enqueue_update.assert_called_once_with(12) - record_nudge.assert_called_once_with(12, calculation.trigger_pr_result, ANY) + record_nudge.assert_called_once_with(12, accepted_result, ANY) @patch("dashboard.record_author_nudge_observation") @patch("dashboard.clear_backfill_pr_failure") @@ -304,14 +309,19 @@ def test_unchanged_targeted_state_does_not_enqueue_status_comment( _clear_backfill_failure: Mock, record_nudge: Mock, ) -> None: - calculation = DashboardUpdate(results={}, dashboard_state={}, trigger_pr_result={}) + accepted_result = {"route": "approver"} + calculation = DashboardUpdate( + results={}, + dashboard_state={"prs": {"12": accepted_result}}, + trigger_pr_result={"route": "author"}, + ) merge_update.return_value = (calculation, True) status = apply_targeted_dashboard_update(Namespace(pr_number=12), calculation) self.assertEqual(0, status) enqueue_update.assert_not_called() - record_nudge.assert_called_once_with(12, calculation.trigger_pr_result, ANY) + record_nudge.assert_called_once_with(12, accepted_result, ANY) class RequiredCiRoutingTest(unittest.TestCase): From e9c8e48713789cd3fed696cb6331d4bce4552be8 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Mon, 20 Jul 2026 14:52:46 -0700 Subject: [PATCH 04/44] Require clean Copilot review before PR routing --- .../pull-request-dashboard/dashboard.py | 77 +++++- .../pull-request-dashboard/github_cli.py | 20 +- .../pull-request-dashboard/test_dashboard.py | 256 ++++++++++++++++++ .../pull-request-dashboard/test_github_cli.py | 21 ++ .../test_top_level_actions.py | 2 + .../workflows/pull-request-dashboard-repo.yml | 8 + .github/workflows/pull-request-dashboard.yml | 1 + pull-request-dashboard/README.md | 8 + 8 files changed, 391 insertions(+), 2 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/dashboard.py b/.github/scripts/pull-request-dashboard/dashboard.py index 2aa62242672..f2470a4050d 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, ) @@ -248,6 +249,39 @@ 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 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 []) + } + clean_reviews = [ + review + for review in copilot_reviews + if review.get("id") not in review_ids_with_findings + ] + if not clean_reviews: + return True + latest_clean_review = max( + clean_reviews, + key=lambda review: review.get("submitted_at") or "", + ) + return current_head != (latest_clean_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: @@ -540,6 +574,11 @@ 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_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), @@ -1209,6 +1248,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") + or 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 "approver" + + def discussions_by_id(discussions: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: return {t["discussion_id"]: t for t in discussions} @@ -1373,6 +1432,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: @@ -1463,7 +1523,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 @@ -1544,6 +1610,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) @@ -1558,6 +1625,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) @@ -1899,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), ) @@ -2039,6 +2108,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, @@ -2157,6 +2227,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..de4d5900143 100644 --- a/.github/scripts/pull-request-dashboard/github_cli.py +++ b/.github/scripts/pull-request-dashboard/github_cli.py @@ -98,6 +98,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: + run_gh([ + "gh", + "api", + "--method", + "POST", + "-H", + "Accept: application/vnd.github+json", + f"/repos/{normalize_repo(repo)}/pulls/{number}/requested_reviewers", + "-f", + "reviewers[]=copilot-pull-request-reviewer[bot]", + ]) + + 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 +125,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 +153,9 @@ def gh_pr_view(repo: str, number: int) -> dict[str, Any]: } nodes { fullDatabaseId + commit { + oid + } url body state @@ -307,6 +324,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/test_dashboard.py b/.github/scripts/pull-request-dashboard/test_dashboard.py index 4c03cc30e58..d46f51d3e06 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, @@ -182,6 +183,261 @@ 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.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_findings_review_does_not_replace_clean_baseline(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": "clean-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": "clean-head"}, {"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_needed"]) + + @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_needed": True, + } + + route = apply_copilot_review_gate( + "open-telemetry/example", + 7, + facts, + "maintainer", + enabled=True, + ) + + self.assertEqual(route, "approver") + 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_needed": True, + } + + route = apply_copilot_review_gate( + "open-telemetry/example", + 7, + facts, + "approver", + enabled=True, + ) + + self.assertEqual(route, "approver") + 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_needed": True, + } + + route = apply_copilot_review_gate( + "open-telemetry/example", + 7, + facts, + "maintainer", + enabled=True, + ) + + self.assertEqual(route, "approver") + 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_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_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..8502b331e96 100644 --- a/.github/scripts/pull-request-dashboard/test_github_cli.py +++ b/.github/scripts/pull-request-dashboard/test_github_cli.py @@ -15,10 +15,27 @@ is_retryable_gh_error, list_all_open_pr_numbers, list_open_prs, + request_copilot_review, ) class GithubCliTest(unittest.TestCase): + @patch("github_cli.run_gh") + def test_request_copilot_review_uses_review_requests_api(self, run_gh) -> None: + request_copilot_review("example", 7) + + run_gh.assert_called_once_with([ + "gh", + "api", + "--method", + "POST", + "-H", + "Accept: application/vnd.github+json", + "/repos/open-telemetry/example/pulls/7/requested_reviewers", + "-f", + "reviewers[]=copilot-pull-request-reviewer[bot]", + ]) + @patch("github_cli.gh_graphql") def test_fetch_pr_issue_comments_paginates(self, graphql) -> None: graphql.side_effect = [ @@ -559,6 +576,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 +604,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 +627,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 +637,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_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 cb7418b708e..242aeda0032 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: "" @@ -124,6 +128,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}" @@ -136,6 +141,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 dd4d18a5ace..ab5f02611d8 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. From 75c2f33a128a0d968fedd1cb075eb7330a10627b Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Mon, 20 Jul 2026 15:17:01 -0700 Subject: [PATCH 05/44] Address review comments from Copilot: gate human routing --- .../pull-request-dashboard/dashboard.py | 22 ++++++++---- .../route_presentation.py | 5 +++ .../pull-request-dashboard/test_dashboard.py | 32 +++++++++++++++-- .../test_notify_slack.py | 34 +++++++++++++++++++ .../test_pr_status_comment.py | 1 + .../test_route_presentation.py | 6 ++++ 6 files changed, 90 insertions(+), 10 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/dashboard.py b/.github/scripts/pull-request-dashboard/dashboard.py index f2470a4050d..2c69a8c874e 100644 --- a/.github/scripts/pull-request-dashboard/dashboard.py +++ b/.github/scripts/pull-request-dashboard/dashboard.py @@ -253,6 +253,13 @@ 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 @@ -578,6 +585,7 @@ def compute_facts( 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")), @@ -1256,16 +1264,16 @@ def apply_copilot_review_gate( *, enabled: bool, ) -> str: - if ( - not enabled - or route not in ("approver", "maintainer") - or not facts.get("copilot_review_needed") - ): + 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 "approver" + return "copilot" def discussions_by_id(discussions: list[dict[str, Any]]) -> dict[str, dict[str, Any]]: @@ -1286,7 +1294,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: 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 d46f51d3e06..7d4918f45f0 100644 --- a/.github/scripts/pull-request-dashboard/test_dashboard.py +++ b/.github/scripts/pull-request-dashboard/test_dashboard.py @@ -224,6 +224,7 @@ def test_current_head_matches_latest_clean_copilot_review(self) -> None: ) 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: @@ -344,12 +345,33 @@ def test_waits_for_automatic_initial_copilot_review(self) -> None: [], ) + 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, } @@ -361,7 +383,7 @@ def test_requests_re_review_after_push_since_clean_review(self, request_review) enabled=True, ) - self.assertEqual(route, "approver") + self.assertEqual(route, "copilot") self.assertTrue(facts["copilot_review_requested"]) request_review.assert_called_once_with("open-telemetry/example", 7) @@ -369,6 +391,7 @@ def test_requests_re_review_after_push_since_clean_review(self, request_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, } @@ -380,7 +403,7 @@ def test_requests_re_review_before_reviewer_handoff(self, request_review) -> Non enabled=True, ) - self.assertEqual(route, "approver") + self.assertEqual(route, "copilot") self.assertTrue(facts["copilot_review_requested"]) request_review.assert_called_once_with("open-telemetry/example", 7) @@ -388,6 +411,7 @@ def test_requests_re_review_before_reviewer_handoff(self, request_review) -> Non 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, } @@ -399,13 +423,14 @@ def test_pending_re_review_waits_without_duplicate_request(self, request_review) enabled=True, ) - self.assertEqual(route, "approver") + 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, } @@ -424,6 +449,7 @@ def test_current_head_clean_review_moves_to_maintainers(self, request_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, } 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")) From 18919170a4ec09dc16641a982262baaa8c03c37f Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Mon, 20 Jul 2026 15:22:00 -0700 Subject: [PATCH 06/44] Address review comment from Copilot: evaluate latest review --- .../scripts/pull-request-dashboard/dashboard.py | 16 ++++++---------- .../pull-request-dashboard/test_dashboard.py | 6 +++--- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/dashboard.py b/.github/scripts/pull-request-dashboard/dashboard.py index 2c69a8c874e..85f103986a6 100644 --- a/.github/scripts/pull-request-dashboard/dashboard.py +++ b/.github/scripts/pull-request-dashboard/dashboard.py @@ -275,18 +275,14 @@ def copilot_review_needed(raw: dict[str, Any]) -> bool: comment.get("pull_request_review_id") for comment in (raw.get("review_comments") or []) } - clean_reviews = [ - review - for review in copilot_reviews - if review.get("id") not in review_ids_with_findings - ] - if not clean_reviews: - return True - latest_clean_review = max( - clean_reviews, + latest_review = max( + copilot_reviews, key=lambda review: review.get("submitted_at") or "", ) - return current_head != (latest_clean_review.get("commit_id") 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: diff --git a/.github/scripts/pull-request-dashboard/test_dashboard.py b/.github/scripts/pull-request-dashboard/test_dashboard.py index 7d4918f45f0..9890d155d04 100644 --- a/.github/scripts/pull-request-dashboard/test_dashboard.py +++ b/.github/scripts/pull-request-dashboard/test_dashboard.py @@ -258,7 +258,7 @@ def test_push_since_latest_clean_copilot_review_needs_rereview(self) -> None: self.assertFalse(facts["copilot_review_requested"]) self.assertTrue(facts["copilot_review_needed"]) - def test_findings_review_does_not_replace_clean_baseline(self) -> None: + def test_latest_findings_review_replaces_clean_review_on_same_head(self) -> None: facts = compute_facts( { "pr": { @@ -273,7 +273,7 @@ def test_findings_review_does_not_replace_clean_baseline(self) -> None: "reviews": [ { "id": 10, - "commit_id": "clean-head", + "commit_id": "current-head", "user": {"login": "copilot"}, "submitted_at": "2026-07-20T01:30:00Z", }, @@ -284,7 +284,7 @@ def test_findings_review_does_not_replace_clean_baseline(self) -> None: "submitted_at": "2026-07-20T02:30:00Z", }, ], - "commits": [{"sha": "clean-head"}, {"sha": "current-head"}], + "commits": [{"sha": "current-head"}], "review_comments": [{"pull_request_review_id": 20}], "checks": [], }, From e1201f93d7110e2e2fbc04df1acd787461625755 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Mon, 20 Jul 2026 15:34:03 -0700 Subject: [PATCH 07/44] Address review comment from Copilot: use re-review mutation --- .../pull-request-dashboard/github_cli.py | 38 +++++++++++++------ .../pull-request-dashboard/test_github_cli.py | 36 +++++++++++------- 2 files changed, 49 insertions(+), 25 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/github_cli.py b/.github/scripts/pull-request-dashboard/github_cli.py index de4d5900143..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: @@ -99,17 +115,17 @@ def gh_api(path: str, paginate: bool = False, token: str | None = None) -> Any: def request_copilot_review(repo: str, number: int) -> None: - run_gh([ - "gh", - "api", - "--method", - "POST", - "-H", - "Accept: application/vnd.github+json", - f"/repos/{normalize_repo(repo)}/pulls/{number}/requested_reviewers", - "-f", - "reviewers[]=copilot-pull-request-reviewer[bot]", - ]) + 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]: diff --git a/.github/scripts/pull-request-dashboard/test_github_cli.py b/.github/scripts/pull-request-dashboard/test_github_cli.py index 8502b331e96..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, @@ -20,21 +20,29 @@ class GithubCliTest(unittest.TestCase): - @patch("github_cli.run_gh") - def test_request_copilot_review_uses_review_requests_api(self, run_gh) -> None: + @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) - run_gh.assert_called_once_with([ - "gh", - "api", - "--method", - "POST", - "-H", - "Accept: application/vnd.github+json", - "/repos/open-telemetry/example/pulls/7/requested_reviewers", - "-f", - "reviewers[]=copilot-pull-request-reviewer[bot]", - ]) + 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: From 4847c62f6559de9471376836d41a037df3069430 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Mon, 20 Jul 2026 18:18:14 -0700 Subject: [PATCH 08/44] Consolidate pull request dashboard delivery --- .../pull-request-dashboard/author_nudge.py | 79 +++++++-- .../pull-request-dashboard/copilot_review.py | 97 +++++++++++ .../pull-request-dashboard/dashboard.py | 29 ++-- .../pull-request-dashboard/delivery.py | 162 ++++++++++++++++++ .../pull-request-dashboard/notify_slack.py | 4 +- .../scripts/pull-request-dashboard/state.py | 77 ++++++++- .../test_author_nudge.py | 88 +++++++--- .../test_copilot_review.py | 139 +++++++++++++++ .../pull-request-dashboard/test_dashboard.py | 32 ++-- .../pull-request-dashboard/test_delivery.py | 81 +++++++++ .../pull-request-dashboard/test_state.py | 77 ++++++++- .../workflows/pull-request-dashboard-repo.yml | 110 ++++-------- .github/workflows/pull-request-dashboard.yml | 6 +- pull-request-dashboard/README.md | 9 +- 14 files changed, 828 insertions(+), 162 deletions(-) create mode 100644 .github/scripts/pull-request-dashboard/copilot_review.py create mode 100644 .github/scripts/pull-request-dashboard/delivery.py create mode 100644 .github/scripts/pull-request-dashboard/test_copilot_review.py create mode 100644 .github/scripts/pull-request-dashboard/test_delivery.py diff --git a/.github/scripts/pull-request-dashboard/author_nudge.py b/.github/scripts/pull-request-dashboard/author_nudge.py index 1a38b230d6e..a118c8453c7 100644 --- a/.github/scripts/pull-request-dashboard/author_nudge.py +++ b/.github/scripts/pull-request-dashboard/author_nudge.py @@ -5,6 +5,7 @@ import argparse from datetime import datetime, timedelta +from pathlib import Path import sys from typing import Any @@ -138,6 +139,7 @@ def refresh_author_nudge_result( approver_teams: list[str], required_approvals: int, non_blocking_check_patterns: list[str], + require_clean_copilot_review: bool, ) -> tuple[dict[str, Any] | None, dict[str, Any]]: from dashboard import DEFAULT_MODEL, build_dashboard_update_for_pr @@ -154,18 +156,20 @@ def refresh_author_nudge_result( required_approvals, non_blocking_check_patterns, dashboard_state, + require_clean_copilot_review, ) result = calculation.trigger_pr_result return result, update_dashboard_state_for_pr(dashboard_state, pr_number, result) -def deliver_author_nudges( +def prepare_author_nudges( repo: str, refreshed_pr_numbers: set[int], now: datetime, approver_teams: list[str], required_approvals: int, non_blocking_check_patterns: list[str], + require_clean_copilot_review: bool, ) -> int: dashboard_state = load_dashboard_state_cache() if dashboard_state is None: @@ -186,13 +190,14 @@ def deliver_author_nudges( or now - waiting_since < NUDGE_AFTER ): continue - fresh_result, fresh_dashboard_state = refresh_author_nudge_result( + fresh_result, _fresh_dashboard_state = refresh_author_nudge_result( repo, pr_number, dashboard_state, approver_teams, required_approvals, non_blocking_check_patterns, + require_clean_copilot_review, ) _due, fresh_entry = plan_nudge(fresh_result, updated.get(key), now) if fresh_entry is None: @@ -205,17 +210,63 @@ def deliver_author_nudges( or fresh_result.get("route") != "author" ): continue - nudged_at = ensure_nudge( - repo, - pr_number, - fresh_result, - fresh_dashboard_state, - now, - ) + fresh_facts = fresh_result.get("facts") or {} + updated[key] = { + **(fresh_entry or {}), + "pending_at": format_ts(now), + "head_sha": fresh_facts.get("head_sha") or "", + } + save_author_nudges(updated) + return 0 + + +def deliver_prepared_author_nudges( + repo: str, + now: datetime, + retry_snapshot_path: Path | None = None, +) -> list[str]: + dashboard_state = load_dashboard_state_cache() + if dashboard_state is None: + print("dashboard state not found; skipping author nudges", file=sys.stderr) + return [] + updated = dict(load_author_nudges(retry_snapshot_path)) + dashboard_prs = dashboard_state.get("prs") or {} + errors: list[str] = [] + for key, entry in sorted(updated.items(), key=lambda item: int(item[0])): + if not (entry or {}).get("pending_at"): + continue + pr_number = int(key) + result = dashboard_prs.get(key) + if not result or result.get("route") != "author": + _due, reset_entry = plan_nudge(result, entry, now) + if reset_entry is None: + updated.pop(key, None) + else: + updated[key] = reset_entry + continue + try: + pr = gh_api(f"/repos/{repo}/pulls/{pr_number}") or {} + expected_head = entry.get("head_sha") or "" + current_head = ((pr.get("head") or {}).get("sha") or "") + if ( + pr.get("state") != "open" + or pr.get("draft") + or (expected_head and current_head != expected_head) + ): + updated[key] = { + name: value + for name, value in entry.items() + if name not in ("pending_at", "head_sha") + } + continue + nudged_at = ensure_nudge(repo, pr_number, result, dashboard_state, now) + except Exception as e: + errors.append(f"PR #{pr_number}: {e}") + continue if nudged_at: updated[key] = {"nudged_at": nudged_at} save_author_nudges(updated) - return 0 + return errors def parse_pr_numbers(value: str) -> set[int]: @@ -250,6 +301,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="apply the clean Copilot review gate during the fresh route check", + ) args = parser.parse_args() try: @@ -269,13 +325,14 @@ def main() -> int: return state_branch.push_state_changes( state_dir, "Update author nudge state", - lambda: deliver_author_nudges( + lambda: prepare_author_nudges( repo, pr_numbers, now, args.approver_team, args.required_approvals, args.non_blocking_check_pattern, + args.require_clean_copilot_review, ), state_branch=args.state_branch, add_paths=[f"{repo_key}/{author_nudge_state_path().name}"], diff --git a/.github/scripts/pull-request-dashboard/copilot_review.py b/.github/scripts/pull-request-dashboard/copilot_review.py new file mode 100644 index 00000000000..17f2fc6c2ac --- /dev/null +++ b/.github/scripts/pull-request-dashboard/copilot_review.py @@ -0,0 +1,97 @@ +"""Track Copilot re-review requests for delivery by the publisher job.""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path +from typing import Any + +from github_cli import ( + fetch_pr_review_data, + gh_api, + gh_pr_view, + request_copilot_review, +) +from state import load_copilot_review_requests, save_copilot_review_requests +from utils import format_ts + + +def record_copilot_review_observation( + pr_number: int, + result: dict[str, Any] | None, +) -> None: + requests = dict(load_copilot_review_requests()) + key = str(pr_number) + facts = (result or {}).get("facts") or {} + head_sha = str(facts.get("head_sha") or "") + if ( + not result + or result.get("failed") + or result.get("route") != "copilot" + or not facts.get("copilot_review_request_needed") + or not head_sha + ): + requests.pop(key, None) + else: + previous = requests.get(key) or {} + requests[key] = ( + previous + if previous.get("head_sha") == head_sha + else {"head_sha": head_sha, "requested_at": ""} + ) + save_copilot_review_requests(requests) + + +def deliver_copilot_review_requests( + repo: str, + now: datetime, + retry_snapshot_path: Path | None = None, +) -> list[str]: + from dashboard import copilot_review_needed, has_copilot_review, is_copilot_reviewer + + requests = dict(load_copilot_review_requests(retry_snapshot_path)) + owner, repo_name = repo.split("/", 1) + errors: list[str] = [] + for key, entry in sorted(requests.items(), key=lambda item: int(item[0])): + if (entry or {}).get("requested_at"): + continue + pr_number = int(key) + try: + pr = gh_api(f"/repos/{repo}/pulls/{pr_number}") or {} + current_head = ((pr.get("head") or {}).get("sha") or "") + if ( + pr.get("state") != "open" + or pr.get("draft") + or current_head != entry.get("head_sha") + ): + requests.pop(key, None) + continue + pr_view = gh_pr_view(repo, pr_number) + if any( + is_copilot_reviewer(request) + for request in (pr_view.get("reviewRequests") or []) + ): + requests[key] = {**entry, "requested_at": format_ts(now)} + continue + review_data = fetch_pr_review_data(owner, repo_name, pr_number) or {} + raw = { + "reviews": review_data.get("reviews") or [], + "commits": gh_api( + f"/repos/{repo}/pulls/{pr_number}/commits?per_page=100", + paginate=True, + ) or [], + "review_comments": gh_api( + f"/repos/{repo}/pulls/{pr_number}/comments?per_page=100", + paginate=True, + ) or [], + } + if not has_copilot_review(raw) or not copilot_review_needed(raw): + requests.pop(key, None) + continue + request_copilot_review(repo, pr_number) + except Exception as e: + errors.append(f"PR #{pr_number}: {e}") + continue + requests[key] = {**entry, "requested_at": format_ts(now)} + save_copilot_review_requests(requests) + return errors \ No newline at end of file diff --git a/.github/scripts/pull-request-dashboard/dashboard.py b/.github/scripts/pull-request-dashboard/dashboard.py index 85f103986a6..52428bbd923 100644 --- a/.github/scripts/pull-request-dashboard/dashboard.py +++ b/.github/scripts/pull-request-dashboard/dashboard.py @@ -44,10 +44,11 @@ v save_dashboard_state_cache -Slack notifications are sent by notify_slack.py in a separate serialized -workflow job. That job loads the latest accepted dashboard state and -notification state, sends any due notifications, and pushes the updated -notification state with the same git CAS pattern. +Status comments, author nudges, Copilot re-review requests, and Slack +notifications are delivered by delivery.py in one serialized publishing job. +That job loads the latest accepted dashboard state and delivery ledgers, sends +due updates, and pushes successful acknowledgements with the same git CAS +pattern. State files are committed and pushed first. Only after that state branch push succeeds does a follow-up publishing job fetch the accepted dashboard state, @@ -180,7 +181,6 @@ include_missing_required_checks, list_open_prs, load_reviewer_set, - request_copilot_review, normalize_repo, repo_state_key, ) @@ -193,6 +193,7 @@ prune_classification_cache, ) from author_nudge import record_author_nudge_observation +from copilot_review import record_copilot_review_observation from state import ( INITIAL_BACKFILL_COMPLETE_KEY, empty_state, @@ -577,6 +578,7 @@ def compute_facts( facts = { "author": author, "assignees": assignees, + "head_sha": ((raw.get("commits") or [{}])[-1].get("sha") or ""), "copilot_review_requested": any( is_copilot_reviewer(request) for request in (pr.get("reviewRequests") or []) @@ -1260,6 +1262,7 @@ def apply_copilot_review_gate( *, enabled: bool, ) -> str: + facts["copilot_review_request_needed"] = False if not enabled or route not in ("approver", "maintainer"): return route if not facts.get("copilot_review_exists"): @@ -1267,8 +1270,7 @@ def apply_copilot_review_gate( 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 + facts["copilot_review_request_needed"] = True return "copilot" @@ -1943,6 +1945,7 @@ def remove_cached_dashboard_prs( state_prs.pop(str(number), None) enqueue_status_comment_update(number) record_author_nudge_observation(number, None, observed_at) + record_copilot_review_observation(number, None) dashboard_state["prs"] = state_prs return save_dashboard_update_state(args, dashboard_state, False) @@ -1994,11 +1997,11 @@ def apply_targeted_dashboard_update( if not dashboard_state_unchanged and args.pr_number is not None: enqueue_status_comment_update(args.pr_number) if args.pr_number is not None: - record_author_nudge_observation( - args.pr_number, - (merged_calculation.dashboard_state.get("prs") or {}).get(str(args.pr_number)), - observed_at or utc_now(), + accepted_result = (merged_calculation.dashboard_state.get("prs") or {}).get( + str(args.pr_number) ) + record_author_nudge_observation(args.pr_number, accepted_result, observed_at or utc_now()) + record_copilot_review_observation(args.pr_number, accepted_result) return save_dashboard_update_state( args, @@ -2137,6 +2140,10 @@ def update_selected_pr(pr_summary: dict[str, Any] = pr_summary) -> int: (calculation.dashboard_state.get("prs") or {}).get(str(pr_number)), observed_at, ) + record_copilot_review_observation( + pr_number, + (calculation.dashboard_state.get("prs") or {}).get(str(pr_number)), + ) failed_pr_numbers = update_backfill_progress(pr_number, failed=False) if not dashboard_state_unchanged: enqueue_status_comment_update(pr_number) diff --git a/.github/scripts/pull-request-dashboard/delivery.py b/.github/scripts/pull-request-dashboard/delivery.py new file mode 100644 index 00000000000..3b19e3ecbf7 --- /dev/null +++ b/.github/scripts/pull-request-dashboard/delivery.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python3 +"""Deliver dashboard side effects from accepted state in one CAS transaction.""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import sys +from typing import Callable + +from author_nudge import deliver_prepared_author_nudges +from copilot_review import deliver_copilot_review_requests +from github_cli import detect_repo, list_all_open_pr_numbers, normalize_repo, repo_state_key +from notify_slack import notify_slack_from_state +from pr_status_comment import update_status_comments_from_state +from state import ( + author_nudge_state_path, + copilot_review_request_state_path, + notification_state_path, + set_state_dir, +) +import state_branch +from utils import utc_now + + +def runner_temp_path(name: str) -> Path: + return Path(os.environ.get("RUNNER_TEMP", ".")) / name + + +def delivery_errors_path() -> Path: + return runner_temp_path("dashboard-delivery-errors.txt") + + +def run_delivery_action( + label: str, + action: Callable[[], list[str]], + errors: list[str], +) -> None: + try: + errors.extend(f"{label}: {error}" for error in action()) + except Exception as e: + errors.append(f"{label}: {e}") + + +def deliver_from_state( + repo: str, + pr_number: int | None, + notification_kind: str, + author_retry_snapshot_path: Path, + copilot_retry_snapshot_path: Path, + notification_retry_snapshot_path: Path, + errors_file: Path, +) -> int: + now = utc_now() + errors: list[str] = [] + run_delivery_action( + "status comments", + lambda: update_status_comments_from_state( + repo, + pr_number, + list_all_open_pr_numbers(repo), + ), + errors, + ) + run_delivery_action( + "author nudges", + lambda: deliver_prepared_author_nudges(repo, now, author_retry_snapshot_path), + errors, + ) + run_delivery_action( + "Copilot reviews", + lambda: deliver_copilot_review_requests(repo, now, copilot_retry_snapshot_path), + errors, + ) + if notification_kind: + run_delivery_action( + "Slack notifications", + lambda: notify_slack_from_state( + repo, + notification_retry_snapshot_path, + {pr_number} if pr_number is not None else None, + {notification_kind}, + now, + ), + errors, + ) + if errors: + errors_file.write_text("\n".join(errors) + "\n", encoding="utf-8") + else: + errors_file.unlink(missing_ok=True) + return 0 + + +def deliver_with_state( + repo: str, + state_branch_name: str, + state_dir: Path, + pr_number: int | None, + notification_kind: str, +) -> int: + repo_key = repo_state_key(repo) + errors_file = delivery_errors_path() + errors_file.unlink(missing_ok=True) + author_retry = runner_temp_path("prior-author-nudge-state.json") + copilot_retry = runner_temp_path("prior-copilot-review-request-state.json") + notification_retry = runner_temp_path("prior-notification-state.json") + status = state_branch.push_state_changes( + state_dir, + "Deliver pull request dashboard updates", + lambda: deliver_from_state( + repo, + pr_number, + notification_kind, + author_retry, + copilot_retry, + notification_retry, + errors_file, + ), + state_branch=state_branch_name, + add_paths=[repo_key], + retry_snapshots=[ + (author_nudge_state_path(), author_retry), + (copilot_review_request_state_path(), copilot_retry), + (notification_state_path(), notification_retry), + ], + ) + if status != 0: + return status + if not errors_file.exists(): + return 0 + print("Dashboard delivery failed:", file=sys.stderr) + print(errors_file.read_text(encoding="utf-8").rstrip(), file=sys.stderr) + return 1 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--repo", help="target repository name") + parser.add_argument("--state-branch", required=True, help="git branch used for workflow state") + parser.add_argument("--pr-number", type=int, help="targeted pull request") + parser.add_argument( + "--notification-kind", + choices=("", "initial", "follow-up"), + default="", + help="Slack notification kind allowed for this run", + ) + args = parser.parse_args() + repo = normalize_repo(args.repo) if args.repo else detect_repo() + with state_branch.temporary_state_dir() as state_dir: + set_state_dir(state_dir / repo_state_key(repo)) + return deliver_with_state( + repo, + args.state_branch, + state_dir, + args.pr_number, + args.notification_kind, + ) + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/.github/scripts/pull-request-dashboard/notify_slack.py b/.github/scripts/pull-request-dashboard/notify_slack.py index aeed3fbb1d3..c9d66972688 100644 --- a/.github/scripts/pull-request-dashboard/notify_slack.py +++ b/.github/scripts/pull-request-dashboard/notify_slack.py @@ -8,6 +8,7 @@ import sys from pathlib import Path from typing import Any +from datetime import datetime from github_cli import detect_repo, list_open_prs, normalize_repo, repo_state_key from notifications import next_notifications @@ -47,6 +48,7 @@ def notify_slack_from_state( retry_snapshot_path: Path | None, notification_numbers: set[int] | None, notification_kinds: set[str] | None, + now: datetime | None = None, ) -> list[str]: dashboard_state = load_dashboard_state_cache() if dashboard_state is None: @@ -66,7 +68,7 @@ def notify_slack_from_state( repo, results, last_notifications(saved_notifications, retry_snapshot_path), - utc_now(), + now or utc_now(), notification_numbers=notification_numbers, notification_kinds=notification_kinds, ) diff --git a/.github/scripts/pull-request-dashboard/state.py b/.github/scripts/pull-request-dashboard/state.py index 966f02c1e79..d0ac15b2b70 100644 --- a/.github/scripts/pull-request-dashboard/state.py +++ b/.github/scripts/pull-request-dashboard/state.py @@ -13,13 +13,15 @@ DASHBOARD_MARKDOWN_FILE = "pull-request-dashboard.md" BACKFILL_STATE_FILE = "backfill-state.json" AUTHOR_NUDGE_STATE_FILE = "author-nudge-state.json" +COPILOT_REVIEW_REQUEST_STATE_FILE = "copilot-review-request-state.json" STATUS_COMMENT_ROLLOUT_STATE_FILE = "status-comment-rollout-state.json" # State files are disposable workflow caches, not durable user data. Bump only # the version for the state shape whose meaning changed. DASHBOARD_STATE_VERSION = 5 BACKFILL_STATE_VERSION = 3 NOTIFICATION_STATE_VERSION = 3 -AUTHOR_NUDGE_STATE_VERSION = 1 +AUTHOR_NUDGE_STATE_VERSION = 2 +COPILOT_REVIEW_REQUEST_STATE_VERSION = 1 STATUS_COMMENT_ROLLOUT_STATE_VERSION = 1 INITIAL_BACKFILL_COMPLETE_KEY = "initial_backfill_complete" _state_dir: Path | None = None @@ -48,6 +50,10 @@ def author_nudge_state_path() -> Path: return state_dir() / AUTHOR_NUDGE_STATE_FILE +def copilot_review_request_state_path() -> Path: + return state_dir() / COPILOT_REVIEW_REQUEST_STATE_FILE + + def backfill_state_path() -> Path: return state_dir() / BACKFILL_STATE_FILE @@ -241,13 +247,35 @@ def save_notifications(notifications: dict[str, Any]) -> None: _save_notification_state_file({"prs": notifications}) -def load_author_nudges() -> dict[str, Any]: - state = load_state_file(author_nudge_state_path(), AUTHOR_NUDGE_STATE_VERSION) +def load_author_nudge_state_file(path: Path) -> dict[str, Any]: + state = load_state_file(path, AUTHOR_NUDGE_STATE_VERSION) if state is None or not isinstance(state.get("prs"), dict): return {} return state["prs"] +def union_merge_author_nudges( + baseline_nudges: dict[str, Any], + retry_snapshot_nudges: dict[str, Any], +) -> dict[str, Any]: + merged = dict(baseline_nudges) + for key, retry_entry in retry_snapshot_nudges.items(): + nudged_at = (retry_entry or {}).get("nudged_at") or "" + if nudged_at: + merged[key] = {"nudged_at": nudged_at} + return merged + + +def load_author_nudges(retry_snapshot_path: Path | None = None) -> dict[str, Any]: + nudges = load_author_nudge_state_file(author_nudge_state_path()) + if retry_snapshot_path and retry_snapshot_path.exists(): + nudges = union_merge_author_nudges( + nudges, + load_author_nudge_state_file(retry_snapshot_path), + ) + return nudges + + def save_author_nudges(nudges: dict[str, Any]) -> None: save_state_file( author_nudge_state_path(), @@ -256,6 +284,49 @@ def save_author_nudges(nudges: dict[str, Any]) -> None: ) +def load_copilot_review_request_state_file(path: Path) -> dict[str, Any]: + state = load_state_file( + path, + COPILOT_REVIEW_REQUEST_STATE_VERSION, + ) + if state is None or not isinstance(state.get("prs"), dict): + return {} + return state["prs"] + + +def union_merge_copilot_review_requests( + baseline_requests: dict[str, Any], + retry_snapshot_requests: dict[str, Any], +) -> dict[str, Any]: + merged = dict(baseline_requests) + for key, retry_entry in retry_snapshot_requests.items(): + baseline_entry = merged.get(key) or {} + if ( + (retry_entry or {}).get("requested_at") + and retry_entry.get("head_sha") == baseline_entry.get("head_sha") + ): + merged[key] = retry_entry + return merged + + +def load_copilot_review_requests(retry_snapshot_path: Path | None = None) -> dict[str, Any]: + requests = load_copilot_review_request_state_file(copilot_review_request_state_path()) + if retry_snapshot_path and retry_snapshot_path.exists(): + requests = union_merge_copilot_review_requests( + requests, + load_copilot_review_request_state_file(retry_snapshot_path), + ) + return requests + + +def save_copilot_review_requests(requests: dict[str, Any]) -> None: + save_state_file( + copilot_review_request_state_path(), + {"prs": requests}, + COPILOT_REVIEW_REQUEST_STATE_VERSION, + ) + + def union_merge_notifications( baseline_notifications: dict[str, Any], retry_snapshot_notifications: dict[str, Any] ) -> dict[str, Any]: diff --git a/.github/scripts/pull-request-dashboard/test_author_nudge.py b/.github/scripts/pull-request-dashboard/test_author_nudge.py index 4387183101a..d1e3894a9ed 100644 --- a/.github/scripts/pull-request-dashboard/test_author_nudge.py +++ b/.github/scripts/pull-request-dashboard/test_author_nudge.py @@ -13,7 +13,7 @@ def author_result(route: str = "author") -> dict: - return {"route": route, "facts": {"author": "alice"}} + return {"route": route, "facts": {"author": "alice", "head_sha": "current-head"}} class AuthorNudgePolicyTest(unittest.TestCase): @@ -128,6 +128,7 @@ def test_fresh_result_uses_dashboard_routing_configuration( ["approvers"], 2, ["optional-*"], + True, ) self.assertEqual(result, fresh_result) @@ -143,13 +144,9 @@ def test_fresh_result_uses_dashboard_routing_configuration( 2, ["optional-*"], dashboard_state, + True, ) - @patch.object( - author_nudge, - "ensure_nudge", - return_value="2026-07-17T00:00:00+00:00", - ) @patch.object( author_nudge, "refresh_author_nudge_result", @@ -166,26 +163,30 @@ def test_fresh_result_uses_dashboard_routing_configuration( "load_dashboard_state_cache", return_value={"prs": {"1": author_result()}}, ) - def test_delivery_records_posted_nudge( + def test_preparation_records_pending_nudge( self, _load_dashboard_state, _load_nudges, save_nudges, refresh_result, - ensure_nudge, ) -> None: - author_nudge.deliver_author_nudges( - "open-telemetry/example", {1}, NOW, ["approvers"], 1, [] + author_nudge.prepare_author_nudges( + "open-telemetry/example", {1}, NOW, ["approvers"], 1, [], False ) refresh_result.assert_called_once() - ensure_nudge.assert_called_once() self.assertEqual( save_nudges.call_args.args[0], - {"1": {"nudged_at": "2026-07-17T00:00:00+00:00"}}, + { + "1": { + "waiting_since": "2026-07-01T00:00:00+00:00", + "nudged_at": "", + "pending_at": "2026-07-17T00:00:00+00:00", + "head_sha": "current-head", + } + }, ) - @patch.object(author_nudge, "ensure_nudge") @patch.object( author_nudge, "refresh_author_nudge_result", @@ -202,23 +203,20 @@ def test_delivery_records_posted_nudge( "load_dashboard_state_cache", return_value={"prs": {"1": author_result()}}, ) - def test_delivery_resets_clock_for_fresh_route_departure( + def test_preparation_resets_clock_for_fresh_route_departure( self, _load_dashboard_state, _load_nudges, save_nudges, refresh_result, - ensure_nudge, ) -> None: - author_nudge.deliver_author_nudges( - "open-telemetry/example", {1}, NOW, ["approvers"], 1, [] + author_nudge.prepare_author_nudges( + "open-telemetry/example", {1}, NOW, ["approvers"], 1, [], False ) refresh_result.assert_called_once() - ensure_nudge.assert_not_called() self.assertEqual(save_nudges.call_args.args[0], {}) - @patch.object(author_nudge, "ensure_nudge") @patch.object( author_nudge, "refresh_author_nudge_result", @@ -235,24 +233,66 @@ def test_delivery_resets_clock_for_fresh_route_departure( "load_dashboard_state_cache", return_value={"prs": {"1": author_result()}}, ) - def test_delivery_preserves_clock_when_fresh_refresh_fails( + def test_preparation_preserves_clock_when_fresh_refresh_fails( self, _load_dashboard_state, _load_nudges, save_nudges, _refresh_result, - ensure_nudge, ) -> None: - author_nudge.deliver_author_nudges( - "open-telemetry/example", {1}, NOW, ["approvers"], 1, [] + author_nudge.prepare_author_nudges( + "open-telemetry/example", {1}, NOW, ["approvers"], 1, [], False ) - ensure_nudge.assert_not_called() self.assertEqual( save_nudges.call_args.args[0], {"1": {"waiting_since": "2026-07-01T00:00:00+00:00", "nudged_at": ""}}, ) + @patch.object( + author_nudge, + "ensure_nudge", + return_value="2026-07-17T00:00:00+00:00", + ) + @patch.object(author_nudge, "save_author_nudges") + @patch.object( + author_nudge, + "load_author_nudges", + return_value={ + "1": { + "waiting_since": "2026-07-01T00:00:00+00:00", + "nudged_at": "", + "pending_at": "2026-07-17T00:00:00+00:00", + "head_sha": "current-head", + } + }, + ) + @patch.object( + author_nudge, + "load_dashboard_state_cache", + return_value={"prs": {"1": author_result()}}, + ) + @patch.object( + author_nudge, + "gh_api", + return_value={"state": "open", "draft": False, "head": {"sha": "current-head"}}, + ) + def test_delivery_records_posted_nudge( + self, + _gh_api, + _load_dashboard_state, + _load_nudges, + save_nudges, + ensure_nudge, + ) -> None: + errors = author_nudge.deliver_prepared_author_nudges("open-telemetry/example", NOW) + + self.assertEqual([], errors) + ensure_nudge.assert_called_once() + save_nudges.assert_called_once_with({ + "1": {"nudged_at": "2026-07-17T00:00:00+00:00"}, + }) + def test_rendered_nudge_mentions_author_and_links_status(self) -> None: body = author_nudge.render_nudge("alice", "https://example.test/status") diff --git a/.github/scripts/pull-request-dashboard/test_copilot_review.py b/.github/scripts/pull-request-dashboard/test_copilot_review.py new file mode 100644 index 00000000000..775ac34be80 --- /dev/null +++ b/.github/scripts/pull-request-dashboard/test_copilot_review.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +import unittest +from unittest.mock import patch + +from datetime import datetime, timezone + +from copilot_review import deliver_copilot_review_requests, record_copilot_review_observation + + +class CopilotReviewRequestStateTest(unittest.TestCase): + @patch("copilot_review.save_copilot_review_requests") + @patch("copilot_review.load_copilot_review_requests", return_value={}) + def test_records_request_for_current_head(self, _load_requests, save_requests) -> None: + record_copilot_review_observation( + 7, + { + "route": "copilot", + "facts": { + "head_sha": "current-head", + "copilot_review_request_needed": True, + }, + }, + ) + + save_requests.assert_called_once_with({ + "7": {"head_sha": "current-head", "requested_at": ""}, + }) + + @patch("copilot_review.save_copilot_review_requests") + @patch( + "copilot_review.load_copilot_review_requests", + return_value={"7": {"head_sha": "old-head", "requested_at": "old-request"}}, + ) + def test_new_head_replaces_previous_request(self, _load_requests, save_requests) -> None: + record_copilot_review_observation( + 7, + { + "route": "copilot", + "facts": { + "head_sha": "current-head", + "copilot_review_request_needed": True, + }, + }, + ) + + save_requests.assert_called_once_with({ + "7": {"head_sha": "current-head", "requested_at": ""}, + }) + + @patch("copilot_review.save_copilot_review_requests") + @patch( + "copilot_review.load_copilot_review_requests", + return_value={"7": {"head_sha": "current-head", "requested_at": ""}}, + ) + def test_clears_request_when_no_longer_needed(self, _load_requests, save_requests) -> None: + record_copilot_review_observation( + 7, + { + "route": "maintainer", + "facts": { + "head_sha": "current-head", + "copilot_review_request_needed": False, + }, + }, + ) + + save_requests.assert_called_once_with({}) + + @patch("copilot_review.save_copilot_review_requests") + @patch("copilot_review.load_copilot_review_requests", return_value={}) + def test_initial_automatic_review_does_not_enqueue_request( + self, + _load_requests, + save_requests, + ) -> None: + record_copilot_review_observation( + 7, + { + "route": "copilot", + "facts": { + "head_sha": "current-head", + "copilot_review_exists": False, + "copilot_review_request_needed": False, + }, + }, + ) + + save_requests.assert_called_once_with({}) + + @patch("copilot_review.request_copilot_review") + @patch("copilot_review.fetch_pr_review_data") + @patch("copilot_review.gh_pr_view", return_value={"reviewRequests": []}) + @patch("copilot_review.gh_api") + @patch("copilot_review.save_copilot_review_requests") + @patch( + "copilot_review.load_copilot_review_requests", + return_value={"7": {"head_sha": "current-head", "requested_at": ""}}, + ) + def test_delivers_request_for_current_stale_review( + self, + _load_requests, + save_requests, + gh_api, + _gh_pr_view, + fetch_review_data, + request_review, + ) -> None: + gh_api.side_effect = [ + {"state": "open", "draft": False, "head": {"sha": "current-head"}}, + [{"sha": "reviewed-head"}, {"sha": "current-head"}], + [], + ] + fetch_review_data.return_value = { + "reviews": [{ + "id": 20, + "commit_id": "reviewed-head", + "user": {"login": "copilot"}, + "submitted_at": "2026-07-20T01:00:00Z", + }], + } + + errors = deliver_copilot_review_requests( + "open-telemetry/example", + datetime(2026, 7, 20, 2, tzinfo=timezone.utc), + ) + + self.assertEqual([], errors) + request_review.assert_called_once_with("open-telemetry/example", 7) + save_requests.assert_called_once_with({ + "7": { + "head_sha": "current-head", + "requested_at": "2026-07-20T02:00:00+00:00", + }, + }) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/.github/scripts/pull-request-dashboard/test_dashboard.py b/.github/scripts/pull-request-dashboard/test_dashboard.py index 9890d155d04..82d4bcf9a97 100644 --- a/.github/scripts/pull-request-dashboard/test_dashboard.py +++ b/.github/scripts/pull-request-dashboard/test_dashboard.py @@ -348,8 +348,7 @@ def test_waits_for_automatic_initial_copilot_review(self) -> None: 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: + def test_initial_automatic_review_blocks_human_handoff(self) -> None: facts = { "copilot_review_requested": True, "copilot_review_exists": False, @@ -365,10 +364,9 @@ def test_initial_automatic_review_blocks_human_handoff(self, request_review) -> ) self.assertEqual(route, "copilot") - request_review.assert_not_called() + self.assertFalse(facts["copilot_review_request_needed"]) - @patch("dashboard.request_copilot_review") - def test_requests_re_review_after_push_since_clean_review(self, request_review) -> None: + def test_marks_re_review_needed_after_push_since_clean_review(self) -> None: facts = { "copilot_review_requested": False, "copilot_review_exists": True, @@ -384,11 +382,9 @@ def test_requests_re_review_after_push_since_clean_review(self, request_review) ) self.assertEqual(route, "copilot") - self.assertTrue(facts["copilot_review_requested"]) - request_review.assert_called_once_with("open-telemetry/example", 7) + self.assertTrue(facts["copilot_review_request_needed"]) - @patch("dashboard.request_copilot_review") - def test_requests_re_review_before_reviewer_handoff(self, request_review) -> None: + def test_marks_re_review_needed_before_reviewer_handoff(self) -> None: facts = { "copilot_review_requested": False, "copilot_review_exists": True, @@ -404,11 +400,9 @@ def test_requests_re_review_before_reviewer_handoff(self, request_review) -> Non ) self.assertEqual(route, "copilot") - self.assertTrue(facts["copilot_review_requested"]) - request_review.assert_called_once_with("open-telemetry/example", 7) + self.assertTrue(facts["copilot_review_request_needed"]) - @patch("dashboard.request_copilot_review") - def test_pending_re_review_waits_without_duplicate_request(self, request_review) -> None: + def test_pending_re_review_waits_without_duplicate_request(self) -> None: facts = { "copilot_review_requested": True, "copilot_review_exists": True, @@ -424,10 +418,9 @@ def test_pending_re_review_waits_without_duplicate_request(self, request_review) ) self.assertEqual(route, "copilot") - request_review.assert_not_called() + self.assertFalse(facts["copilot_review_request_needed"]) - @patch("dashboard.request_copilot_review") - def test_current_head_clean_review_moves_to_maintainers(self, request_review) -> None: + def test_current_head_clean_review_moves_to_maintainers(self) -> None: facts = { "copilot_review_requested": False, "copilot_review_exists": True, @@ -443,10 +436,9 @@ def test_current_head_clean_review_moves_to_maintainers(self, request_review) -> ) self.assertEqual(route, "maintainer") - request_review.assert_not_called() + self.assertFalse(facts["copilot_review_request_needed"]) - @patch("dashboard.request_copilot_review") - def test_disabled_gate_preserves_maintainer_route(self, request_review) -> None: + def test_disabled_gate_preserves_maintainer_route(self) -> None: facts = { "copilot_review_requested": False, "copilot_review_exists": True, @@ -462,7 +454,7 @@ def test_disabled_gate_preserves_maintainer_route(self, request_review) -> None: ) self.assertEqual(route, "maintainer") - request_review.assert_not_called() + self.assertFalse(facts["copilot_review_request_needed"]) def test_discussion_url_is_excluded_from_classifier_input(self) -> None: prompt_input = discussion_prompt_input({ diff --git a/.github/scripts/pull-request-dashboard/test_delivery.py b/.github/scripts/pull-request-dashboard/test_delivery.py new file mode 100644 index 00000000000..ea2a6eb87f9 --- /dev/null +++ b/.github/scripts/pull-request-dashboard/test_delivery.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from pathlib import Path +import tempfile +import unittest +from unittest.mock import Mock, call, patch + +import delivery + + +class DeliveryTest(unittest.TestCase): + @patch.object(delivery, "notify_slack_from_state", return_value=[]) + @patch.object(delivery, "deliver_copilot_review_requests", return_value=[]) + @patch.object(delivery, "deliver_prepared_author_nudges", return_value=[]) + @patch.object(delivery, "update_status_comments_from_state", return_value=[]) + @patch.object(delivery, "list_all_open_pr_numbers", return_value={7, 8}) + def test_runs_all_targeted_deliveries_in_order( + self, + _list_open, + status_comments, + author_nudges, + copilot_reviews, + slack, + ) -> None: + order = Mock() + status_comments.side_effect = lambda *_args: order("status") or [] + author_nudges.side_effect = lambda *_args: order("author") or [] + copilot_reviews.side_effect = lambda *_args: order("copilot") or [] + slack.side_effect = lambda *_args: order("slack") or [] + with tempfile.TemporaryDirectory() as temp_dir: + errors_file = Path(temp_dir) / "errors" + status = delivery.deliver_from_state( + "open-telemetry/example", + 7, + "initial", + Path(temp_dir) / "author", + Path(temp_dir) / "copilot", + Path(temp_dir) / "slack", + errors_file, + ) + + self.assertEqual(0, status) + self.assertEqual( + [call("status"), call("author"), call("copilot"), call("slack")], + order.call_args_list, + ) + + @patch.object(delivery, "notify_slack_from_state", return_value=[]) + @patch.object(delivery, "deliver_copilot_review_requests", return_value=[]) + @patch.object(delivery, "deliver_prepared_author_nudges", return_value=[]) + @patch.object(delivery, "update_status_comments_from_state", side_effect=RuntimeError("boom")) + @patch.object(delivery, "list_all_open_pr_numbers", return_value={7}) + def test_failure_does_not_block_later_deliveries( + self, + _list_open, + _status_comments, + author_nudges, + copilot_reviews, + slack, + ) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + errors_file = Path(temp_dir) / "errors" + delivery.deliver_from_state( + "open-telemetry/example", + None, + "follow-up", + Path(temp_dir) / "author", + Path(temp_dir) / "copilot", + Path(temp_dir) / "slack", + errors_file, + ) + errors = errors_file.read_text(encoding="utf-8") + + self.assertIn("status comments: boom", errors) + author_nudges.assert_called_once() + copilot_reviews.assert_called_once() + slack.assert_called_once() + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/.github/scripts/pull-request-dashboard/test_state.py b/.github/scripts/pull-request-dashboard/test_state.py index 37f9538fda8..5818794e1d6 100644 --- a/.github/scripts/pull-request-dashboard/test_state.py +++ b/.github/scripts/pull-request-dashboard/test_state.py @@ -10,19 +10,21 @@ from state import ( AUTHOR_NUDGE_STATE_VERSION, - author_nudge_state_path, - load_author_nudges, - save_author_nudges, BACKFILL_STATE_VERSION, + COPILOT_REVIEW_REQUEST_STATE_VERSION, DASHBOARD_STATE_VERSION, NOTIFICATION_STATE_VERSION, STATUS_COMMENT_ROLLOUT_STATE_VERSION, + author_nudge_state_path, backfill_state_path, + copilot_review_request_state_path, dashboard_state_path, empty_state, enqueue_status_comment_update, load_accepted_dashboard_state, + load_author_nudges, load_backfill_state, + load_copilot_review_requests, load_dashboard_state_cache, load_state_file, load_status_comment_rollout_state, @@ -30,10 +32,14 @@ main, notification_state_path, save_state_file, + save_author_nudges, + save_copilot_review_requests, save_dashboard_state_cache, save_notifications, save_status_comment_rollout_state, stored_result, + union_merge_author_nudges, + union_merge_copilot_review_requests, update_dashboard_state_for_pr, ) @@ -157,7 +163,8 @@ def test_notification_state_version_is_independent(self) -> None: self.assertEqual(NOTIFICATION_STATE_VERSION, 3) self.assertEqual(DASHBOARD_STATE_VERSION, 5) self.assertEqual(STATUS_COMMENT_ROLLOUT_STATE_VERSION, 1) - self.assertEqual(AUTHOR_NUDGE_STATE_VERSION, 1) + self.assertEqual(AUTHOR_NUDGE_STATE_VERSION, 2) + self.assertEqual(COPILOT_REVIEW_REQUEST_STATE_VERSION, 1) def test_author_nudge_state_round_trip(self) -> None: with tempfile.TemporaryDirectory() as temp_dir, patch("state._state_dir", Path(temp_dir)): @@ -179,6 +186,68 @@ def test_author_nudge_state_round_trip(self) -> None: ) self.assertTrue(author_nudge_state_path().exists()) + def test_copilot_review_request_state_round_trip(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir, patch("state._state_dir", Path(temp_dir)): + save_copilot_review_requests({ + "123": { + "head_sha": "current-head", + "requested_at": "", + } + }) + + self.assertEqual( + load_copilot_review_requests(), + { + "123": { + "head_sha": "current-head", + "requested_at": "", + } + }, + ) + self.assertTrue(copilot_review_request_state_path().exists()) + + def test_retry_snapshot_preserves_posted_author_nudge(self) -> None: + self.assertEqual( + {"7": {"nudged_at": "2026-07-20T02:00:00Z"}}, + union_merge_author_nudges( + {"7": {"waiting_since": "2026-07-10T02:00:00Z", "nudged_at": ""}}, + {"7": {"nudged_at": "2026-07-20T02:00:00Z"}}, + ), + ) + + def test_retry_snapshot_preserves_same_head_copilot_request(self) -> None: + self.assertEqual( + { + "7": { + "head_sha": "current-head", + "requested_at": "2026-07-20T02:00:00Z", + } + }, + union_merge_copilot_review_requests( + {"7": {"head_sha": "current-head", "requested_at": ""}}, + { + "7": { + "head_sha": "current-head", + "requested_at": "2026-07-20T02:00:00Z", + } + }, + ), + ) + + def test_retry_snapshot_does_not_overwrite_new_copilot_head(self) -> None: + self.assertEqual( + {"7": {"head_sha": "new-head", "requested_at": ""}}, + union_merge_copilot_review_requests( + {"7": {"head_sha": "new-head", "requested_at": ""}}, + { + "7": { + "head_sha": "old-head", + "requested_at": "2026-07-20T02:00:00Z", + } + }, + ), + ) + def test_status_comment_rollout_state_round_trip(self) -> None: with tempfile.TemporaryDirectory() as temp_dir, patch("state._state_dir", Path(temp_dir)): save_status_comment_rollout_state({ diff --git a/.github/workflows/pull-request-dashboard-repo.yml b/.github/workflows/pull-request-dashboard-repo.yml index 242aeda0032..2ddb9b4aad3 100644 --- a/.github/workflows/pull-request-dashboard-repo.yml +++ b/.github/workflows/pull-request-dashboard-repo.yml @@ -67,7 +67,6 @@ jobs: runs-on: ubuntu-latest outputs: initial_backfill_complete: ${{ steps.dashboard-update.outputs.initial_backfill_complete }} - refreshed_pr_numbers: ${{ steps.dashboard-update.outputs.refreshed_pr_numbers }} steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -84,7 +83,7 @@ jobs: permission-contents: read permission-issues: read permission-members: read - permission-pull-requests: write + permission-pull-requests: read - name: Restore per-PR classification cache if: inputs.pr_number != '' @@ -149,7 +148,7 @@ jobs: fi python3 .github/scripts/pull-request-dashboard/dashboard.py "${dashboard_args[@]}" - - name: Deliver due author nudges + - name: Prepare due author nudges if: >- always() && github.event_name == 'schedule' && @@ -163,6 +162,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}" @@ -179,6 +179,9 @@ jobs: for pattern in "${non_blocking_check_patterns[@]}"; do nudge_args+=(--non-blocking-check-pattern "$pattern") done + if [[ "${REQUIRE_CLEAN_COPILOT_REVIEW:-false}" == "true" ]]; then + nudge_args+=(--require-clean-copilot-review) + fi python3 .github/scripts/pull-request-dashboard/author_nudge.py "${nudge_args[@]}" - name: Save per-PR classification cache @@ -204,14 +207,14 @@ jobs: GH_TOKEN: ${{ steps.dashboard-token.outputs.token }} run: python3 .github/scripts/pull-request-dashboard/report_rate_limits.py - update-pr-status-comment: + publish-dashboard: needs: update-dashboard if: >- always() && (needs.update-dashboard.result == 'success' || inputs.pr_number == '') && needs.update-dashboard.outputs.initial_backfill_complete == 'true' concurrency: - group: ${{ github.workflow }}-status-${{ inputs.repository }} + group: ${{ github.workflow }}-publish-${{ inputs.repository }} cancel-in-progress: false permissions: contents: write @@ -229,52 +232,12 @@ jobs: private-key: ${{ secrets.PR_DASHBOARD_PRIVATE_KEY }} owner: open-telemetry repositories: ${{ inputs.repository }} + permission-issues: write permission-pull-requests: write - - name: Update PR status comment - env: - GH_TOKEN: ${{ steps.dashboard-token.outputs.token }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - PR_NUMBER: ${{ inputs.pr_number }} - REPO_NAME: ${{ inputs.repository }} - run: | - set -euo pipefail - state_branch="${DASHBOARD_STATE_BRANCH_PREFIX}/${REPO_NAME}" - status_args=(--state-branch "$state_branch" --repo "$REPO_NAME") - if [[ -n "${PR_NUMBER:-}" ]]; then - status_args+=(--pr-number "$PR_NUMBER") - fi - python3 .github/scripts/pull-request-dashboard/pr_status_comment.py "${status_args[@]}" - - notify-slack: - needs: update-dashboard - if: >- - always() && - (needs.update-dashboard.result == 'success' || inputs.pr_number == '') && - needs.update-dashboard.outputs.initial_backfill_complete == 'true' && - (github.event_name == 'schedule' || inputs.pr_number != '') - concurrency: - group: ${{ github.workflow }}-notify-${{ inputs.repository }}-${{ inputs.pr_number || 'backfill' }} - cancel-in-progress: false - permissions: - contents: write - environment: protected - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - id: dashboard-token - with: - client-id: ${{ vars.PR_DASHBOARD_CLIENT_ID }} - private-key: ${{ secrets.PR_DASHBOARD_PRIVATE_KEY }} - owner: open-telemetry - repositories: ${{ inputs.repository }} - permission-pull-requests: read - - - name: Send Slack notifications + - name: Deliver dashboard updates + id: delivery + continue-on-error: true env: GH_TOKEN: ${{ steps.dashboard-token.outputs.token }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -286,42 +249,16 @@ jobs: run: | set -euo pipefail state_branch="${DASHBOARD_STATE_BRANCH_PREFIX}/${REPO_NAME}" - notify_args=(--state-branch "$state_branch" --repo "$REPO_NAME") + delivery_args=(--state-branch "$state_branch" --repo "$REPO_NAME") if [[ -n "${TRIGGER_PR_NUMBER:-}" ]]; then - notify_args+=(--pr-number "$TRIGGER_PR_NUMBER") + delivery_args+=(--pr-number "$TRIGGER_PR_NUMBER" --notification-kind initial) + elif [[ "${{ github.event_name }}" == "schedule" ]]; then + delivery_args+=(--notification-kind follow-up) fi - - python3 .github/scripts/pull-request-dashboard/notify_slack.py "${notify_args[@]}" - - publish-dashboard: - needs: update-dashboard - if: >- - always() && - (needs.update-dashboard.result == 'success' || inputs.pr_number == '') && - needs.update-dashboard.outputs.initial_backfill_complete == 'true' - concurrency: - group: ${{ github.workflow }}-publish-${{ inputs.repository }} - cancel-in-progress: true - permissions: - contents: read - environment: protected - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 - with: - persist-credentials: false - - - uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 - id: dashboard-token - with: - client-id: ${{ vars.PR_DASHBOARD_CLIENT_ID }} - private-key: ${{ secrets.PR_DASHBOARD_PRIVATE_KEY }} - owner: open-telemetry - repositories: ${{ inputs.repository }} - permission-issues: write - permission-pull-requests: read + python3 .github/scripts/pull-request-dashboard/delivery.py "${delivery_args[@]}" - name: Publish dashboard issue + if: always() && steps.dashboard-token.outcome == 'success' env: GH_TOKEN: ${{ steps.dashboard-token.outputs.token }} REPO_NAME: ${{ inputs.repository }} @@ -339,3 +276,14 @@ jobs: publish_args+=(--large-repo) fi python3 .github/scripts/pull-request-dashboard/publish_dashboard.py "${publish_args[@]}" + + - name: Report GitHub App rate limits + if: always() && steps.dashboard-token.outcome == 'success' + continue-on-error: true + env: + GH_TOKEN: ${{ steps.dashboard-token.outputs.token }} + run: python3 .github/scripts/pull-request-dashboard/report_rate_limits.py + + - name: Check dashboard delivery + if: always() && steps.delivery.outcome == 'failure' + run: exit 1 diff --git a/.github/workflows/pull-request-dashboard.yml b/.github/workflows/pull-request-dashboard.yml index 85f07dfa5a6..e8566c7fbfa 100644 --- a/.github/workflows/pull-request-dashboard.yml +++ b/.github/workflows/pull-request-dashboard.yml @@ -177,8 +177,4 @@ jobs: issues: write # needed to open/close the hourly failure tracking issue uses: ./.github/workflows/workflow-failure-issue.yml with: - # A superseded per-repo publish is cancelled (publish-dashboard uses - # cancel-in-progress: true), so count cancelled as success here to avoid - # opening noisy failure issues. Genuine failures still surface because - # matrix aggregation reports 'failure' in preference to 'cancelled'. - success: ${{ needs.resolve-targets.result == 'success' && (needs.run-repo-dashboard.result == 'success' || needs.run-repo-dashboard.result == 'cancelled') }} + success: ${{ needs.resolve-targets.result == 'success' && needs.run-repo-dashboard.result == 'success' }} diff --git a/pull-request-dashboard/README.md b/pull-request-dashboard/README.md index ab5f02611d8..36cd3da20e3 100644 --- a/pull-request-dashboard/README.md +++ b/pull-request-dashboard/README.md @@ -206,8 +206,13 @@ may wait for a later round-robin run. ## Configuration -The dashboard uses repository-scoped GitHub App access to read and update each -configured repository and to read approver team membership. +The dashboard separates calculation from delivery. The update job uses +repository-scoped, read-only GitHub App access to calculate routing and persist +pending work. A serialized publishing job holds pull request and issue write +access and durably delivers status comments, author reminders, Copilot +re-review requests, Slack notifications, and the dashboard issue. Successful +deliveries are recorded on the state branch before another publishing run can +start. Each repository can route Slack notifications to its own `slack_channel` and map GitHub logins to Slack user IDs via `slack_user_mapping`. Repositories From 1506167516e580c284e7e146379fc8db5842ce2d Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Mon, 20 Jul 2026 18:57:36 -0700 Subject: [PATCH 09/44] Address review comment from Copilot: prepare nudges in accepted state --- .../pull-request-dashboard/author_nudge.py | 196 +++--------------- .../pull-request-dashboard/dashboard.py | 70 +++---- .../pull-request-dashboard/delivery.py | 17 +- .../test_author_nudge.py | 175 ++++++---------- .../pull-request-dashboard/test_dashboard.py | 34 +-- .../pull-request-dashboard/test_delivery.py | 2 + .../workflows/pull-request-dashboard-repo.yml | 47 +---- 7 files changed, 184 insertions(+), 357 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/author_nudge.py b/.github/scripts/pull-request-dashboard/author_nudge.py index a118c8453c7..16a117eebc2 100644 --- a/.github/scripts/pull-request-dashboard/author_nudge.py +++ b/.github/scripts/pull-request-dashboard/author_nudge.py @@ -3,13 +3,12 @@ from __future__ import annotations -import argparse from datetime import datetime, timedelta from pathlib import Path import sys from typing import Any -from github_cli import detect_repo, gh_api, load_reviewer_set, normalize_repo, repo_state_key, run_gh +from github_cli import gh_api, run_gh from pr_status_comment import ( DASHBOARD_APP_SLUG, managed_status_comments, @@ -20,11 +19,8 @@ load_author_nudges, load_dashboard_state_cache, save_author_nudges, - set_state_dir, - update_dashboard_state_for_pr, ) -import state_branch -from utils import format_ts, parse_ts, utc_now +from utils import format_ts, parse_ts NUDGE_AFTER = timedelta(weeks=1) @@ -121,10 +117,23 @@ def record_author_nudge_observation( pr_number: int, result: dict[str, Any] | None, now: datetime, + *, + prepare_due: bool = False, ) -> None: updated = dict(load_author_nudges()) key = str(pr_number) - _due, entry = plan_nudge(result, updated.get(key), now) + due, entry = plan_nudge(result, updated.get(key), now) + if due and prepare_due and entry is not None: + facts = (result or {}).get("facts") or {} + head_sha = facts.get("head_sha") or "" + source_fingerprint = facts.get("source_fingerprint") or "" + if head_sha and source_fingerprint: + entry = { + **entry, + "pending_at": format_ts(now), + "head_sha": head_sha, + "source_fingerprint": source_fingerprint, + } if entry is None: updated.pop(key, None) else: @@ -132,97 +141,28 @@ def record_author_nudge_observation( save_author_nudges(updated) -def refresh_author_nudge_result( +def current_source_fingerprint( repo: str, pr_number: int, - dashboard_state: dict[str, Any], - approver_teams: list[str], - required_approvals: int, non_blocking_check_patterns: list[str], - require_clean_copilot_review: bool, -) -> tuple[dict[str, Any] | None, dict[str, Any]]: - from dashboard import DEFAULT_MODEL, build_dashboard_update_for_pr +) -> tuple[dict[str, Any], str]: + from dashboard import dashboard_source_fingerprint, fetch_pr_raw owner, repo_name = repo.split("/", 1) - reviewers = load_reviewer_set(owner, approver_teams) - calculation = build_dashboard_update_for_pr( + raw = fetch_pr_raw( repo, owner, repo_name, - {pr_number}, - reviewers, - pr_number, - DEFAULT_MODEL, - required_approvals, + {"number": pr_number}, non_blocking_check_patterns, - dashboard_state, - require_clean_copilot_review, ) - result = calculation.trigger_pr_result - return result, update_dashboard_state_for_pr(dashboard_state, pr_number, result) - - -def prepare_author_nudges( - repo: str, - refreshed_pr_numbers: set[int], - now: datetime, - approver_teams: list[str], - required_approvals: int, - non_blocking_check_patterns: list[str], - require_clean_copilot_review: bool, -) -> int: - dashboard_state = load_dashboard_state_cache() - if dashboard_state is None: - print("dashboard state not found; skipping author nudges", file=sys.stderr) - return 0 - updated = dict(load_author_nudges()) - dashboard_prs = dashboard_state.get("prs") or {} - for pr_number in sorted(refreshed_pr_numbers): - key = str(pr_number) - result = dashboard_prs.get(key) - entry = updated.get(key) or {} - waiting_since = parse_ts(entry.get("waiting_since") or "") - if ( - result is None - or result.get("route") != "author" - or entry.get("nudged_at") - or waiting_since is None - or now - waiting_since < NUDGE_AFTER - ): - continue - fresh_result, _fresh_dashboard_state = refresh_author_nudge_result( - repo, - pr_number, - dashboard_state, - approver_teams, - required_approvals, - non_blocking_check_patterns, - require_clean_copilot_review, - ) - _due, fresh_entry = plan_nudge(fresh_result, updated.get(key), now) - if fresh_entry is None: - updated.pop(key, None) - else: - updated[key] = fresh_entry - if ( - fresh_result is None - or fresh_result.get("failed") - or fresh_result.get("route") != "author" - ): - continue - fresh_facts = fresh_result.get("facts") or {} - updated[key] = { - **(fresh_entry or {}), - "pending_at": format_ts(now), - "head_sha": fresh_facts.get("head_sha") or "", - } - save_author_nudges(updated) - return 0 + return raw, dashboard_source_fingerprint(raw) def deliver_prepared_author_nudges( repo: str, now: datetime, + non_blocking_check_patterns: list[str], retry_snapshot_path: Path | None = None, ) -> list[str]: dashboard_state = load_dashboard_state_cache() @@ -245,18 +185,24 @@ def deliver_prepared_author_nudges( updated[key] = reset_entry continue try: - pr = gh_api(f"/repos/{repo}/pulls/{pr_number}") or {} + raw, source_fingerprint = current_source_fingerprint( + repo, + pr_number, + non_blocking_check_patterns, + ) + pr = raw.get("pr") or {} expected_head = entry.get("head_sha") or "" - current_head = ((pr.get("head") or {}).get("sha") or "") + current_head = ((raw.get("commits") or [{}])[-1].get("sha") or "") if ( - pr.get("state") != "open" - or pr.get("draft") + pr.get("state") != "OPEN" + or pr.get("isDraft") or (expected_head and current_head != expected_head) + or source_fingerprint != entry.get("source_fingerprint") ): updated[key] = { name: value for name, value in entry.items() - if name not in ("pending_at", "head_sha") + if name not in ("pending_at", "head_sha", "source_fingerprint") } continue nudged_at = ensure_nudge(repo, pr_number, result, dashboard_state, now) @@ -267,77 +213,3 @@ def deliver_prepared_author_nudges( updated[key] = {"nudged_at": nudged_at} save_author_nudges(updated) return errors - - -def parse_pr_numbers(value: str) -> set[int]: - if not value: - return set() - numbers = {int(part) for part in value.split(",")} - if any(number <= 0 for number in numbers): - raise ValueError("PR numbers must be positive") - return numbers - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--repo", help="target repository name") - parser.add_argument("--state-branch", required=True, help="git branch used for workflow state") - parser.add_argument("--pr-numbers", required=True, help="comma-separated refreshed PR numbers") - parser.add_argument( - "--approver-team", - action="append", - required=True, - help="approver team slug for the target repository; repeat for multiple teams", - ) - parser.add_argument( - "--required-approvals", - type=int, - default=1, - help="minimum non-bot approvals needed before a PR can route to maintainers", - ) - parser.add_argument( - "--non-blocking-check-pattern", - action="append", - 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="apply the clean Copilot review gate during the fresh route check", - ) - args = parser.parse_args() - - try: - pr_numbers = parse_pr_numbers(args.pr_numbers) - except ValueError as e: - parser.error(str(e)) - if args.required_approvals < 1: - parser.error("--required-approvals must be at least 1") - if not pr_numbers: - return 0 - - repo = normalize_repo(args.repo) if args.repo else detect_repo() - repo_key = repo_state_key(repo) - now = utc_now() - with state_branch.temporary_state_dir() as state_dir: - set_state_dir(state_dir / repo_key) - return state_branch.push_state_changes( - state_dir, - "Update author nudge state", - lambda: prepare_author_nudges( - repo, - pr_numbers, - now, - args.approver_team, - args.required_approvals, - args.non_blocking_check_pattern, - args.require_clean_copilot_review, - ), - state_branch=args.state_branch, - add_paths=[f"{repo_key}/{author_nudge_state_path().name}"], - ) - - -if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file diff --git a/.github/scripts/pull-request-dashboard/dashboard.py b/.github/scripts/pull-request-dashboard/dashboard.py index 52428bbd923..b300c06d71c 100644 --- a/.github/scripts/pull-request-dashboard/dashboard.py +++ b/.github/scripts/pull-request-dashboard/dashboard.py @@ -159,6 +159,8 @@ from __future__ import annotations import argparse +import hashlib +import json import sys import traceback from concurrent.futures import ThreadPoolExecutor @@ -217,6 +219,25 @@ DEFAULT_BACKFILL_MAX_PRS = 50 BACKFILL_RECORDED_FAILURE_STATUS = 2 + +def dashboard_source_fingerprint(raw: dict[str, Any]) -> str: + source = { + key: raw.get(key) + for key in ( + "pr", + "issue_comments", + "review_comments", + "reviews", + "commits", + "checks", + "non_blocking_check_failures", + "review_threads", + "pr_metadata", + ) + } + encoded = json.dumps(source, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(encoded).hexdigest() + # ---------------------------------------------------------------- model helpers @@ -579,6 +600,7 @@ def compute_facts( "author": author, "assignees": assignees, "head_sha": ((raw.get("commits") or [{}])[-1].get("sha") or ""), + "source_fingerprint": dashboard_source_fingerprint(raw), "copilot_review_requested": any( is_copilot_reviewer(request) for request in (pr.get("reviewRequests") or []) @@ -2000,7 +2022,12 @@ def apply_targeted_dashboard_update( accepted_result = (merged_calculation.dashboard_state.get("prs") or {}).get( str(args.pr_number) ) - record_author_nudge_observation(args.pr_number, accepted_result, observed_at or utc_now()) + record_author_nudge_observation( + args.pr_number, + accepted_result, + observed_at or utc_now(), + prepare_due=getattr(args, "prepare_author_nudges", False), + ) record_copilot_review_observation(args.pr_number, accepted_result) return save_dashboard_update_state( @@ -2025,15 +2052,12 @@ def update_dashboard_for_pr_number(args: argparse.Namespace, state_dir: Path) -> return 0 observed_at = utc_now() - status = state_branch.push_state_changes( + return state_branch.push_state_changes( state_dir, "Update dashboard state", lambda: apply_targeted_dashboard_update(args, update, observed_at), state_branch=args.state_branch, ) - if status == 0: - refreshed_pr_numbers(args).add(args.pr_number) - return status def update_dashboard_for_backfill(args: argparse.Namespace, state_dir: Path) -> int: @@ -2072,8 +2096,6 @@ def update_dashboard_for_backfill(args: argparse.Namespace, state_dir: Path) -> ) if status != 0: return status - refreshed_pr_numbers(args).update(selection.cached_pr_numbers_to_remove) - print( f"backfill selected {len(selection.selected_prs)} PR(s) " f"in {repo} (max={DEFAULT_BACKFILL_MAX_PRS})", @@ -2096,12 +2118,9 @@ def save_current_dashboard_state() -> int: ) for pr_summary in selection.selected_prs: - refreshed = False observed_at = utc_now() def update_selected_pr(pr_summary: dict[str, Any] = pr_summary) -> int: - nonlocal refreshed - refreshed = False pr_number = pr_summary["number"] dashboard_state = load_dashboard_state_cache() or empty_state() calculation = build_dashboard_update_for_pr( @@ -2134,11 +2153,11 @@ def update_selected_pr(pr_summary: dict[str, Any] = pr_summary) -> int: dashboard_state, not initial_backfill_completed, ) - refreshed = True record_author_nudge_observation( pr_number, (calculation.dashboard_state.get("prs") or {}).get(str(pr_number)), observed_at, + prepare_due=getattr(args, "prepare_author_nudges", False), ) record_copilot_review_observation( pr_number, @@ -2166,8 +2185,6 @@ def update_selected_pr(pr_summary: dict[str, Any] = pr_summary) -> int: ) if status != 0: return status - if refreshed: - refreshed_pr_numbers(args).add(pr_summary["number"]) unresolved_failed_pr_numbers = ( backfill_failed_pr_numbers(load_backfill_state()) & open_non_draft_pr_numbers @@ -2194,23 +2211,6 @@ def write_initial_backfill_output(github_output: Path) -> None: output.write(f"initial_backfill_complete={'true' if complete else 'false'}\n") -def write_refreshed_pr_numbers_output( - github_output: Path, - refreshed_pr_numbers: set[int], -) -> None: - value = ",".join(str(number) for number in sorted(refreshed_pr_numbers)) - with github_output.open("a", encoding="utf-8") as output: - output.write(f"refreshed_pr_numbers={value}\n") - - -def refreshed_pr_numbers(args: argparse.Namespace) -> set[int]: - numbers = getattr(args, "refreshed_pr_numbers", None) - if numbers is None: - numbers = set() - args.refreshed_pr_numbers = numbers - return numbers - - def main() -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument( @@ -2243,6 +2243,11 @@ def main() -> int: action="store_true", help="re-request Copilot after pushes since its last clean review before reviewer or maintainer handoff", ) + parser.add_argument( + "--prepare-author-nudges", + action="store_true", + help="queue due author nudges from accepted dashboard results", + ) parser.add_argument("--model", default=DEFAULT_MODEL, help=f"copilot model (default: {DEFAULT_MODEL})") parser.add_argument( "--github-output", @@ -2250,7 +2255,6 @@ def main() -> int: help="append initial_backfill_complete to this GitHub Actions output file", ) args = parser.parse_args() - args.refreshed_pr_numbers = set() if args.required_approvals < 1: parser.error("--required-approvals must be at least 1") with state_branch.temporary_state_dir() as state_dir: @@ -2259,10 +2263,6 @@ def main() -> int: status = update_dashboard_via_state_branch(args, state_dir) if args.github_output and status in (0, BACKFILL_RECORDED_FAILURE_STATUS): write_initial_backfill_output(args.github_output) - write_refreshed_pr_numbers_output( - args.github_output, - args.refreshed_pr_numbers, - ) return status diff --git a/.github/scripts/pull-request-dashboard/delivery.py b/.github/scripts/pull-request-dashboard/delivery.py index 3b19e3ecbf7..99c4675ce2e 100644 --- a/.github/scripts/pull-request-dashboard/delivery.py +++ b/.github/scripts/pull-request-dashboard/delivery.py @@ -47,6 +47,7 @@ def deliver_from_state( repo: str, pr_number: int | None, notification_kind: str, + non_blocking_check_patterns: list[str], author_retry_snapshot_path: Path, copilot_retry_snapshot_path: Path, notification_retry_snapshot_path: Path, @@ -65,7 +66,12 @@ def deliver_from_state( ) run_delivery_action( "author nudges", - lambda: deliver_prepared_author_nudges(repo, now, author_retry_snapshot_path), + lambda: deliver_prepared_author_nudges( + repo, + now, + non_blocking_check_patterns, + author_retry_snapshot_path, + ), errors, ) run_delivery_action( @@ -98,6 +104,7 @@ def deliver_with_state( state_dir: Path, pr_number: int | None, notification_kind: str, + non_blocking_check_patterns: list[str], ) -> int: repo_key = repo_state_key(repo) errors_file = delivery_errors_path() @@ -112,6 +119,7 @@ def deliver_with_state( repo, pr_number, notification_kind, + non_blocking_check_patterns, author_retry, copilot_retry, notification_retry, @@ -145,6 +153,12 @@ def main() -> int: default="", help="Slack notification kind allowed for this run", ) + parser.add_argument( + "--non-blocking-check-pattern", + action="append", + default=[], + help="glob matching a non-required check; repeat as needed", + ) args = parser.parse_args() repo = normalize_repo(args.repo) if args.repo else detect_repo() with state_branch.temporary_state_dir() as state_dir: @@ -155,6 +169,7 @@ def main() -> int: state_dir, args.pr_number, args.notification_kind, + args.non_blocking_check_pattern, ) diff --git a/.github/scripts/pull-request-dashboard/test_author_nudge.py b/.github/scripts/pull-request-dashboard/test_author_nudge.py index d1e3894a9ed..8804e0e9b15 100644 --- a/.github/scripts/pull-request-dashboard/test_author_nudge.py +++ b/.github/scripts/pull-request-dashboard/test_author_nudge.py @@ -1,19 +1,24 @@ from __future__ import annotations from datetime import datetime, timezone -from types import SimpleNamespace import unittest from unittest.mock import patch import author_nudge -import dashboard NOW = datetime(2026, 7, 17, tzinfo=timezone.utc) def author_result(route: str = "author") -> dict: - return {"route": route, "facts": {"author": "alice", "head_sha": "current-head"}} + return { + "route": route, + "facts": { + "author": "alice", + "head_sha": "current-head", + "source_fingerprint": "current-source", + }, + } class AuthorNudgePolicyTest(unittest.TestCase): @@ -110,71 +115,24 @@ def test_departure_observation_resets_clock( self.assertEqual(save_nudges.call_args.args[0], {}) - @patch.object(author_nudge, "load_reviewer_set", return_value={"reviewer"}) - @patch.object(dashboard, "build_dashboard_update_for_pr") - def test_fresh_result_uses_dashboard_routing_configuration( - self, - build_update, - _load_reviewers, - ) -> None: - result = {"pr_number": 1, **author_result()} - build_update.return_value = SimpleNamespace(trigger_pr_result=result) - dashboard_state = {"version": 1, "prs": {}} - - fresh_result, fresh_dashboard_state = author_nudge.refresh_author_nudge_result( - "open-telemetry/example", - 1, - dashboard_state, - ["approvers"], - 2, - ["optional-*"], - True, - ) - - self.assertEqual(result, fresh_result) - self.assertEqual("author", fresh_dashboard_state["prs"]["1"]["route"]) - build_update.assert_called_once_with( - "open-telemetry/example", - "open-telemetry", - "example", - {1}, - {"reviewer"}, - 1, - dashboard.DEFAULT_MODEL, - 2, - ["optional-*"], - dashboard_state, - True, - ) - - @patch.object( - author_nudge, - "refresh_author_nudge_result", - return_value=(author_result(), {"prs": {"1": author_result()}}), - ) @patch.object(author_nudge, "save_author_nudges") @patch.object( author_nudge, "load_author_nudges", return_value={"1": {"waiting_since": "2026-07-01T00:00:00+00:00", "nudged_at": ""}}, ) - @patch.object( - author_nudge, - "load_dashboard_state_cache", - return_value={"prs": {"1": author_result()}}, - ) - def test_preparation_records_pending_nudge( + def test_due_accepted_observation_records_pending_nudge( self, - _load_dashboard_state, _load_nudges, save_nudges, - refresh_result, ) -> None: - author_nudge.prepare_author_nudges( - "open-telemetry/example", {1}, NOW, ["approvers"], 1, [], False + author_nudge.record_author_nudge_observation( + 1, + author_result(), + NOW, + prepare_due=True, ) - refresh_result.assert_called_once() self.assertEqual( save_nudges.call_args.args[0], { @@ -183,77 +141,68 @@ def test_preparation_records_pending_nudge( "nudged_at": "", "pending_at": "2026-07-17T00:00:00+00:00", "head_sha": "current-head", + "source_fingerprint": "current-source", } }, ) @patch.object( author_nudge, - "refresh_author_nudge_result", - return_value=(author_result("approver"), {"prs": {"1": author_result("approver")}}), + "ensure_nudge", + return_value="2026-07-17T00:00:00+00:00", ) @patch.object(author_nudge, "save_author_nudges") @patch.object( author_nudge, "load_author_nudges", - return_value={"1": {"waiting_since": "2026-07-01T00:00:00+00:00", "nudged_at": ""}}, + return_value={ + "1": { + "waiting_since": "2026-07-01T00:00:00+00:00", + "nudged_at": "", + "pending_at": "2026-07-17T00:00:00+00:00", + "head_sha": "current-head", + "source_fingerprint": "current-source", + } + }, ) @patch.object( author_nudge, "load_dashboard_state_cache", return_value={"prs": {"1": author_result()}}, ) - def test_preparation_resets_clock_for_fresh_route_departure( - self, - _load_dashboard_state, - _load_nudges, - save_nudges, - refresh_result, - ) -> None: - author_nudge.prepare_author_nudges( - "open-telemetry/example", {1}, NOW, ["approvers"], 1, [], False - ) - - refresh_result.assert_called_once() - self.assertEqual(save_nudges.call_args.args[0], {}) - - @patch.object( - author_nudge, - "refresh_author_nudge_result", - return_value=({"failed": True, "route": "unknown"}, {"prs": {}}), - ) - @patch.object(author_nudge, "save_author_nudges") - @patch.object( - author_nudge, - "load_author_nudges", - return_value={"1": {"waiting_since": "2026-07-01T00:00:00+00:00", "nudged_at": ""}}, - ) @patch.object( author_nudge, - "load_dashboard_state_cache", - return_value={"prs": {"1": author_result()}}, + "current_source_fingerprint", + return_value=( + { + "pr": {"state": "OPEN", "isDraft": False}, + "commits": [{"sha": "current-head"}], + }, + "current-source", + ), ) - def test_preparation_preserves_clock_when_fresh_refresh_fails( + def test_delivery_records_posted_nudge( self, + current_source, _load_dashboard_state, _load_nudges, save_nudges, - _refresh_result, + ensure_nudge, ) -> None: - author_nudge.prepare_author_nudges( - "open-telemetry/example", {1}, NOW, ["approvers"], 1, [], False + errors = author_nudge.deliver_prepared_author_nudges( + "open-telemetry/example", + NOW, + ["optional-*"], ) - self.assertEqual( - save_nudges.call_args.args[0], - {"1": {"waiting_since": "2026-07-01T00:00:00+00:00", "nudged_at": ""}}, - ) + self.assertEqual([], errors) + current_source.assert_called_once_with("open-telemetry/example", 1, ["optional-*"]) + ensure_nudge.assert_called_once() + save_nudges.assert_called_once_with({ + "1": {"nudged_at": "2026-07-17T00:00:00+00:00"}, + }) - @patch.object( - author_nudge, - "ensure_nudge", - return_value="2026-07-17T00:00:00+00:00", - ) + @patch.object(author_nudge, "ensure_nudge") @patch.object(author_nudge, "save_author_nudges") @patch.object( author_nudge, @@ -264,6 +213,7 @@ def test_preparation_preserves_clock_when_fresh_refresh_fails( "nudged_at": "", "pending_at": "2026-07-17T00:00:00+00:00", "head_sha": "current-head", + "source_fingerprint": "accepted-source", } }, ) @@ -274,23 +224,36 @@ def test_preparation_preserves_clock_when_fresh_refresh_fails( ) @patch.object( author_nudge, - "gh_api", - return_value={"state": "open", "draft": False, "head": {"sha": "current-head"}}, + "current_source_fingerprint", + return_value=( + { + "pr": {"state": "OPEN", "isDraft": False}, + "commits": [{"sha": "current-head"}], + }, + "changed-source", + ), ) - def test_delivery_records_posted_nudge( + def test_delivery_defers_when_routing_inputs_changed( self, - _gh_api, + _current_source, _load_dashboard_state, _load_nudges, save_nudges, ensure_nudge, ) -> None: - errors = author_nudge.deliver_prepared_author_nudges("open-telemetry/example", NOW) + errors = author_nudge.deliver_prepared_author_nudges( + "open-telemetry/example", + NOW, + [], + ) self.assertEqual([], errors) - ensure_nudge.assert_called_once() + ensure_nudge.assert_not_called() save_nudges.assert_called_once_with({ - "1": {"nudged_at": "2026-07-17T00:00:00+00:00"}, + "1": { + "waiting_since": "2026-07-01T00:00:00+00:00", + "nudged_at": "", + }, }) def test_rendered_nudge_mentions_author_and_links_status(self) -> None: diff --git a/.github/scripts/pull-request-dashboard/test_dashboard.py b/.github/scripts/pull-request-dashboard/test_dashboard.py index 82d4bcf9a97..8526dd16281 100644 --- a/.github/scripts/pull-request-dashboard/test_dashboard.py +++ b/.github/scripts/pull-request-dashboard/test_dashboard.py @@ -26,7 +26,6 @@ set_backfill_pr_failed, update_dashboard_for_backfill, write_initial_backfill_output, - write_refreshed_pr_numbers_output, ) @@ -501,17 +500,6 @@ def test_writes_initial_backfill_status_to_github_output(self) -> None: output_path.read_text(encoding="utf-8"), ) - def test_writes_sorted_refreshed_pr_numbers_to_github_output(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: - output_path = Path(temp_dir) / "output" - - write_refreshed_pr_numbers_output(output_path, {34, 12}) - - self.assertEqual( - "refreshed_pr_numbers=12,34\n", - output_path.read_text(encoding="utf-8"), - ) - class StatusCommentQueueTest(unittest.TestCase): @patch("dashboard.record_author_nudge_observation") @patch("dashboard.save_dashboard_update_state", return_value=0) @@ -564,11 +552,19 @@ def test_targeted_state_change_enqueues_status_comment( ) merge_update.return_value = (calculation, False) - status = apply_targeted_dashboard_update(Namespace(pr_number=12), calculation) + status = apply_targeted_dashboard_update( + Namespace(pr_number=12, prepare_author_nudges=True), + calculation, + ) self.assertEqual(0, status) enqueue_update.assert_called_once_with(12) - record_nudge.assert_called_once_with(12, accepted_result, ANY) + record_nudge.assert_called_once_with( + 12, + accepted_result, + ANY, + prepare_due=True, + ) @patch("dashboard.record_author_nudge_observation") @patch("dashboard.clear_backfill_pr_failure") @@ -595,7 +591,12 @@ def test_unchanged_targeted_state_does_not_enqueue_status_comment( self.assertEqual(0, status) enqueue_update.assert_not_called() - record_nudge.assert_called_once_with(12, accepted_result, ANY) + record_nudge.assert_called_once_with( + 12, + accepted_result, + ANY, + prepare_due=False, + ) class RequiredCiRoutingTest(unittest.TestCase): @@ -813,8 +814,7 @@ def push_state_changes(_state_dir, _message, update_state, **_kwargs) -> int: status = update_dashboard_for_backfill(args, Path("state")) self.assertEqual(refreshed_pr_numbers, [1, 2]) - self.assertEqual(args.refreshed_pr_numbers, {2}) - record_nudge.assert_called_once_with(2, ANY, ANY) + record_nudge.assert_called_once_with(2, ANY, ANY, prepare_due=False) self.assertEqual(status, BACKFILL_RECORDED_FAILURE_STATUS) self.assertEqual(dashboard_state["prs"], {"2": {"pr_number": 2, "failed": False, "route": "reviewer"}}) self.assertTrue(dashboard_state["initial_backfill_complete"]) diff --git a/.github/scripts/pull-request-dashboard/test_delivery.py b/.github/scripts/pull-request-dashboard/test_delivery.py index ea2a6eb87f9..be5bf73c252 100644 --- a/.github/scripts/pull-request-dashboard/test_delivery.py +++ b/.github/scripts/pull-request-dashboard/test_delivery.py @@ -33,6 +33,7 @@ def test_runs_all_targeted_deliveries_in_order( "open-telemetry/example", 7, "initial", + ["optional-*"], Path(temp_dir) / "author", Path(temp_dir) / "copilot", Path(temp_dir) / "slack", @@ -64,6 +65,7 @@ def test_failure_does_not_block_later_deliveries( "open-telemetry/example", None, "follow-up", + [], Path(temp_dir) / "author", Path(temp_dir) / "copilot", Path(temp_dir) / "slack", diff --git a/.github/workflows/pull-request-dashboard-repo.yml b/.github/workflows/pull-request-dashboard-repo.yml index 2ddb9b4aad3..e63433acbcd 100644 --- a/.github/workflows/pull-request-dashboard-repo.yml +++ b/.github/workflows/pull-request-dashboard-repo.yml @@ -128,6 +128,7 @@ jobs: 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 }} + PREPARE_AUTHOR_NUDGES: ${{ github.event_name == 'schedule' }} run: | set -euo pipefail state_branch="${DASHBOARD_STATE_BRANCH_PREFIX}/${REPO_NAME}" @@ -143,47 +144,14 @@ jobs: if [[ "${REQUIRE_CLEAN_COPILOT_REVIEW:-false}" == "true" ]]; then dashboard_args+=(--require-clean-copilot-review) fi + if [[ "${PREPARE_AUTHOR_NUDGES:-false}" == "true" ]]; then + dashboard_args+=(--prepare-author-nudges) + fi if [[ -n "${TRIGGER_PR_NUMBER:-}" ]]; then dashboard_args+=(--pr-number "$TRIGGER_PR_NUMBER") fi python3 .github/scripts/pull-request-dashboard/dashboard.py "${dashboard_args[@]}" - - name: Prepare due author nudges - if: >- - always() && - github.event_name == 'schedule' && - steps.dashboard-update.outputs.initial_backfill_complete == 'true' && - steps.dashboard-update.outputs.refreshed_pr_numbers != '' - env: - GH_TOKEN: ${{ steps.dashboard-token.outputs.token }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - REPO_NAME: ${{ inputs.repository }} - REFRESHED_PR_NUMBERS: ${{ steps.dashboard-update.outputs.refreshed_pr_numbers }} - 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}" - nudge_args=( - --state-branch "$state_branch" - --repo "$REPO_NAME" - --pr-numbers "$REFRESHED_PR_NUMBERS" - --required-approvals "$REQUIRED_APPROVALS" - ) - for team in $(jq -r '.[]' <<< "$APPROVER_TEAMS_JSON"); do - nudge_args+=(--approver-team "$team") - done - mapfile -t non_blocking_check_patterns < <(jq -r '.[]' <<< "$NON_BLOCKING_CHECK_PATTERNS_JSON") - for pattern in "${non_blocking_check_patterns[@]}"; do - nudge_args+=(--non-blocking-check-pattern "$pattern") - done - if [[ "${REQUIRE_CLEAN_COPILOT_REVIEW:-false}" == "true" ]]; then - nudge_args+=(--require-clean-copilot-review) - fi - python3 .github/scripts/pull-request-dashboard/author_nudge.py "${nudge_args[@]}" - - name: Save per-PR classification cache # Preserve valid classifications even when another item fails the update. if: always() && inputs.pr_number != '' @@ -232,6 +200,8 @@ jobs: private-key: ${{ secrets.PR_DASHBOARD_PRIVATE_KEY }} owner: open-telemetry repositories: ${{ inputs.repository }} + permission-checks: read + permission-contents: read permission-issues: write permission-pull-requests: write @@ -246,10 +216,15 @@ jobs: SLACK_USER_MAP_JSON: ${{ inputs.slack_user_mapping_json }} REPO_NAME: ${{ inputs.repository }} TRIGGER_PR_NUMBER: ${{ inputs.pr_number }} + NON_BLOCKING_CHECK_PATTERNS_JSON: ${{ inputs.non_blocking_check_patterns_json }} run: | set -euo pipefail state_branch="${DASHBOARD_STATE_BRANCH_PREFIX}/${REPO_NAME}" delivery_args=(--state-branch "$state_branch" --repo "$REPO_NAME") + mapfile -t non_blocking_check_patterns < <(jq -r '.[]' <<< "$NON_BLOCKING_CHECK_PATTERNS_JSON") + for pattern in "${non_blocking_check_patterns[@]}"; do + delivery_args+=(--non-blocking-check-pattern "$pattern") + done if [[ -n "${TRIGGER_PR_NUMBER:-}" ]]; then delivery_args+=(--pr-number "$TRIGGER_PR_NUMBER" --notification-kind initial) elif [[ "${{ github.event_name }}" == "schedule" ]]; then From cf27daf2e11b54892890d2448f98e8fdc3598f6c Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Mon, 20 Jul 2026 18:59:42 -0700 Subject: [PATCH 10/44] Address review comment from Copilot: retry same-head reviews --- .../pull-request-dashboard/copilot_review.py | 7 +---- .../test_copilot_review.py | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/copilot_review.py b/.github/scripts/pull-request-dashboard/copilot_review.py index 17f2fc6c2ac..718325f1c16 100644 --- a/.github/scripts/pull-request-dashboard/copilot_review.py +++ b/.github/scripts/pull-request-dashboard/copilot_review.py @@ -33,12 +33,7 @@ def record_copilot_review_observation( ): requests.pop(key, None) else: - previous = requests.get(key) or {} - requests[key] = ( - previous - if previous.get("head_sha") == head_sha - else {"head_sha": head_sha, "requested_at": ""} - ) + requests[key] = {"head_sha": head_sha, "requested_at": ""} save_copilot_review_requests(requests) diff --git a/.github/scripts/pull-request-dashboard/test_copilot_review.py b/.github/scripts/pull-request-dashboard/test_copilot_review.py index 775ac34be80..b8e9dc8cfa6 100644 --- a/.github/scripts/pull-request-dashboard/test_copilot_review.py +++ b/.github/scripts/pull-request-dashboard/test_copilot_review.py @@ -48,6 +48,36 @@ def test_new_head_replaces_previous_request(self, _load_requests, save_requests) "7": {"head_sha": "current-head", "requested_at": ""}, }) + @patch("copilot_review.save_copilot_review_requests") + @patch( + "copilot_review.load_copilot_review_requests", + return_value={ + "7": { + "head_sha": "current-head", + "requested_at": "2026-07-20T01:00:00Z", + } + }, + ) + def test_same_head_request_needed_resets_acknowledgement( + self, + _load_requests, + save_requests, + ) -> None: + record_copilot_review_observation( + 7, + { + "route": "copilot", + "facts": { + "head_sha": "current-head", + "copilot_review_request_needed": True, + }, + }, + ) + + save_requests.assert_called_once_with({ + "7": {"head_sha": "current-head", "requested_at": ""}, + }) + @patch("copilot_review.save_copilot_review_requests") @patch( "copilot_review.load_copilot_review_requests", From 45163efbd35f0562028467542cfe230e2fdc8db5 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Mon, 20 Jul 2026 19:12:37 -0700 Subject: [PATCH 11/44] Address review comment from Copilot: prefer current-head reviews --- .../pull-request-dashboard/dashboard.py | 14 +++++--- .../pull-request-dashboard/test_dashboard.py | 36 +++++++++++++++++++ 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/dashboard.py b/.github/scripts/pull-request-dashboard/dashboard.py index b300c06d71c..a1a7b20d69a 100644 --- a/.github/scripts/pull-request-dashboard/dashboard.py +++ b/.github/scripts/pull-request-dashboard/dashboard.py @@ -293,18 +293,22 @@ def copilot_review_needed(raw: dict[str, Any]) -> bool: current_head = ((raw.get("commits") or [{}])[-1].get("sha") or "") if not current_head: return False + current_head_reviews = [ + review + for review in copilot_reviews + if (review.get("commit_id") or "") == current_head + ] + if not current_head_reviews: + return True review_ids_with_findings = { comment.get("pull_request_review_id") for comment in (raw.get("review_comments") or []) } latest_review = max( - copilot_reviews, + current_head_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 "") - ) + return latest_review.get("id") in review_ids_with_findings def human_author_for_copilot_pr(raw: dict[str, Any]) -> str: diff --git a/.github/scripts/pull-request-dashboard/test_dashboard.py b/.github/scripts/pull-request-dashboard/test_dashboard.py index 8526dd16281..0ba487872c0 100644 --- a/.github/scripts/pull-request-dashboard/test_dashboard.py +++ b/.github/scripts/pull-request-dashboard/test_dashboard.py @@ -226,6 +226,42 @@ def test_current_head_matches_latest_clean_copilot_review(self) -> None: self.assertTrue(facts["copilot_review_exists"]) self.assertFalse(facts["copilot_review_needed"]) + def test_late_stale_review_does_not_replace_clean_current_head_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": [ + { + "id": 10, + "commit_id": "current-head", + "user": {"login": "copilot"}, + "submitted_at": "2026-07-20T02:30:00Z", + }, + { + "id": 20, + "commit_id": "old-head", + "user": {"login": "copilot"}, + "submitted_at": "2026-07-20T03:00:00Z", + }, + ], + "commits": [{"sha": "old-head"}, {"sha": "current-head"}], + "review_comments": [{"pull_request_review_id": 20}], + "checks": [], + }, + "author", + [], + ) + + self.assertFalse(facts["copilot_review_needed"]) + def test_push_since_latest_clean_copilot_review_needs_rereview(self) -> None: facts = compute_facts( { From ad3765772a95d36aa41831b642beb8b41e8039cd Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Mon, 20 Jul 2026 19:14:23 -0700 Subject: [PATCH 12/44] Address review comment from Copilot: validate nudges before writes --- .../scripts/pull-request-dashboard/delivery.py | 18 +++++++++--------- .../pull-request-dashboard/test_delivery.py | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/delivery.py b/.github/scripts/pull-request-dashboard/delivery.py index 99c4675ce2e..30371d4eb9e 100644 --- a/.github/scripts/pull-request-dashboard/delivery.py +++ b/.github/scripts/pull-request-dashboard/delivery.py @@ -55,15 +55,6 @@ def deliver_from_state( ) -> int: now = utc_now() errors: list[str] = [] - run_delivery_action( - "status comments", - lambda: update_status_comments_from_state( - repo, - pr_number, - list_all_open_pr_numbers(repo), - ), - errors, - ) run_delivery_action( "author nudges", lambda: deliver_prepared_author_nudges( @@ -74,6 +65,15 @@ def deliver_from_state( ), errors, ) + run_delivery_action( + "status comments", + lambda: update_status_comments_from_state( + repo, + pr_number, + list_all_open_pr_numbers(repo), + ), + errors, + ) run_delivery_action( "Copilot reviews", lambda: deliver_copilot_review_requests(repo, now, copilot_retry_snapshot_path), diff --git a/.github/scripts/pull-request-dashboard/test_delivery.py b/.github/scripts/pull-request-dashboard/test_delivery.py index be5bf73c252..0ae27bf77d2 100644 --- a/.github/scripts/pull-request-dashboard/test_delivery.py +++ b/.github/scripts/pull-request-dashboard/test_delivery.py @@ -42,7 +42,7 @@ def test_runs_all_targeted_deliveries_in_order( self.assertEqual(0, status) self.assertEqual( - [call("status"), call("author"), call("copilot"), call("slack")], + [call("author"), call("status"), call("copilot"), call("slack")], order.call_args_list, ) From c978b530d75c53d833655c132de11aff052834aa Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Mon, 20 Jul 2026 19:53:54 -0700 Subject: [PATCH 13/44] Address review comments from Copilot: drain publisher work --- .../pull-request-dashboard/RATIONALE.md | 63 +++++++++++-------- .../pull-request-dashboard/delivery.py | 40 ++++-------- .../pull-request-dashboard/test_delivery.py | 18 ++++-- .../workflows/pull-request-dashboard-repo.yml | 6 -- .github/workflows/pull-request-dashboard.yml | 5 +- 5 files changed, 67 insertions(+), 65 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/RATIONALE.md b/.github/scripts/pull-request-dashboard/RATIONALE.md index dc01c4a23b5..e5668d9e58d 100644 --- a/.github/scripts/pull-request-dashboard/RATIONALE.md +++ b/.github/scripts/pull-request-dashboard/RATIONALE.md @@ -11,10 +11,10 @@ the implementation understandable and operationally cheap. - Target repositories only need GitHub App access and an entry in `repositories.json`. - The top-level workflow resolves target repositories, then calls a reusable - per-repository workflow for each target. The per-repository workflow runs the - update, notification, and publishing jobs top to bottom for one repository, so - one repository's update failure does not block publishing or notifications for - repositories whose updates succeeded. + per-repository workflow for each target. The per-repository workflow separates + read-only calculation from a repository-wide publisher, so one repository's + update failure does not block delivery for repositories whose updates + succeeded. - The top-level repository matrix runs one repository at a time. Backfills do not benefit enough from cross-repository parallelism to justify extra aggregate API and LLM demand. @@ -43,6 +43,14 @@ the implementation understandable and operationally cheap. - Concurrency bounds pending jobs per target; it does not debounce webhook delivery or workflow dispatch. Different repositories and PRs can still run independently, and every accepted webhook still creates a workflow run. +- Publishers use one concurrency group per target repository. GitHub preserves + the running publisher but may replace an older pending publisher with a newer + one even when `cancel-in-progress` is false. This is safe because accepted + work lives on the state branch and every surviving publisher drains + repository-wide work rather than serving only its triggering invocation. +- The top-level hourly health check treats a replaced pending publisher as + successful. Matrix failures take precedence over cancellation, so genuine + update or delivery failures still open the failure issue. ## GitHub Actions Instead Of Netlify For Scheduled Backfills @@ -82,8 +90,8 @@ the implementation understandable and operationally cheap. queue. Incrementing the implementation revision snapshots all open PRs, then hourly runs update at most 50 queued comments until the rollout completes. Dashboard refreshes atomically queue comments only when their persisted result - changes; targeted refreshes update immediately and leave no completed PR - pending in the rollout queue. + changes. Every publisher drains up to 50 queued comments, so a newer publisher + also delivers work left by an older pending publisher that GitHub replaced. - Selected PRs are processed one at a time through the same single-PR merge path as targeted refreshes. Each accepted PR update pushes structured state before the next selected PR is processed. @@ -106,9 +114,9 @@ the implementation understandable and operationally cheap. - A selected PR failure is recorded outside dashboard state, advances the cursor, and does not stop later selected PRs. The backfill still exits nonzero while any open PR is still recorded as having failed processing, keeping - scheduled failure reporting active. Publish and notification jobs consume - only accepted state, so untrusted PR content cannot deny service to the rest - of the repository. + scheduled failure reporting active. The publisher consumes only accepted + state, so untrusted PR content cannot deny service to the rest of the + repository. - The cursor deliberately does not rely on PR `updatedAt`; prior testing showed `updatedAt` is not a safe freshness key for every comment, review-comment, or thread event the dashboard needs. @@ -261,20 +269,21 @@ the implementation understandable and operationally cheap. - Slack notification state is PR-granular. It does not track notification history separately for each assignee. - When notification state is first created, existing approver-routed PRs may - receive initial notifications on a later targeted refresh. Avoiding that - bootstrap case would require storing separate seen-but-not-notified state. + receive initial notifications from a later publisher. Avoiding that bootstrap + case would require storing separate seen-but-not-notified state. - When a mapped assignee is added after a PR was already notified during the same waiting period, that assignee may wait until the next follow-up cadence instead of receiving an immediate initial notification. -- Scheduled runs send only due follow-up reminders. Targeted PR refreshes send - only the triggering PR's initial notification. This keeps webhook-driven - refreshes from sweeping unrelated PR reminders while preserving the hourly - reminder pass. +- Every publisher evaluates all accepted repository state for eligible initial + and follow-up notifications. The sent-notification ledger and weekday + 24-hour follow-up cadence bound delivery. Repository-wide evaluation ensures + that a newer publisher drains Slack work whose older pending publisher GitHub + replaced. - Slack notifications are sent only for dashboard state that has already been accepted on the state branch. A newer dashboard update can land after the - notification job checks out state, so a notification can be slightly late + publisher checks out state, so a notification can be slightly late relative to the newest state. -- The notification job preserves just-written notification state across normal +- The publisher preserves just-written notification state across normal state-branch CAS retries. If Slack delivery succeeds and every state-branch push attempt is rejected, a later run can send the same notification again. Recording state before sending Slack would avoid that duplicate window, but @@ -282,11 +291,15 @@ the implementation understandable and operationally cheap. ## Publishing -- Dashboard publishing is serialized per target repository. -- Each publish job fetches the accepted state branch while holding the publish - slot, lists the target repository's current open PRs, renders markdown from - `dashboard-state.json`, and publishes the issue body. -- Publish jobs are superseded by newer publish jobs for the same repository; - only the newest queued publish needs to render the latest accepted state. -- If another update advances the state branch while a publish job is already - editing the issue, the live issue can briefly lag until the next publish job. +- Dashboard publishing is serialized per target repository. The publisher owns + target-repository writes for status comments, author reminders, Copilot + re-review requests, Slack notifications, and the dashboard issue. +- Each publisher fetches accepted state while holding the publish slot and + drains repository-wide pending work. Status comments are bounded to 50 per + publisher; author reminders and Copilot requests use explicit durable + ledgers; Slack eligibility is reconstructed from accepted dashboard and + notification state. +- The dashboard issue is rendered from `dashboard-state.json` and the target + repository's current open PR list after delivery. If another update advances + the state branch while a publisher is already working, external views can + briefly lag until the next publisher. diff --git a/.github/scripts/pull-request-dashboard/delivery.py b/.github/scripts/pull-request-dashboard/delivery.py index 30371d4eb9e..7b0aba99d7a 100644 --- a/.github/scripts/pull-request-dashboard/delivery.py +++ b/.github/scripts/pull-request-dashboard/delivery.py @@ -45,8 +45,6 @@ def run_delivery_action( def deliver_from_state( repo: str, - pr_number: int | None, - notification_kind: str, non_blocking_check_patterns: list[str], author_retry_snapshot_path: Path, copilot_retry_snapshot_path: Path, @@ -69,7 +67,7 @@ def deliver_from_state( "status comments", lambda: update_status_comments_from_state( repo, - pr_number, + None, list_all_open_pr_numbers(repo), ), errors, @@ -79,18 +77,17 @@ def deliver_from_state( lambda: deliver_copilot_review_requests(repo, now, copilot_retry_snapshot_path), errors, ) - if notification_kind: - run_delivery_action( - "Slack notifications", - lambda: notify_slack_from_state( - repo, - notification_retry_snapshot_path, - {pr_number} if pr_number is not None else None, - {notification_kind}, - now, - ), - errors, - ) + run_delivery_action( + "Slack notifications", + lambda: notify_slack_from_state( + repo, + notification_retry_snapshot_path, + None, + None, + now, + ), + errors, + ) if errors: errors_file.write_text("\n".join(errors) + "\n", encoding="utf-8") else: @@ -102,8 +99,6 @@ def deliver_with_state( repo: str, state_branch_name: str, state_dir: Path, - pr_number: int | None, - notification_kind: str, non_blocking_check_patterns: list[str], ) -> int: repo_key = repo_state_key(repo) @@ -117,8 +112,6 @@ def deliver_with_state( "Deliver pull request dashboard updates", lambda: deliver_from_state( repo, - pr_number, - notification_kind, non_blocking_check_patterns, author_retry, copilot_retry, @@ -146,13 +139,6 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--repo", help="target repository name") parser.add_argument("--state-branch", required=True, help="git branch used for workflow state") - parser.add_argument("--pr-number", type=int, help="targeted pull request") - parser.add_argument( - "--notification-kind", - choices=("", "initial", "follow-up"), - default="", - help="Slack notification kind allowed for this run", - ) parser.add_argument( "--non-blocking-check-pattern", action="append", @@ -167,8 +153,6 @@ def main() -> int: repo, args.state_branch, state_dir, - args.pr_number, - args.notification_kind, args.non_blocking_check_pattern, ) diff --git a/.github/scripts/pull-request-dashboard/test_delivery.py b/.github/scripts/pull-request-dashboard/test_delivery.py index 0ae27bf77d2..ef013258f98 100644 --- a/.github/scripts/pull-request-dashboard/test_delivery.py +++ b/.github/scripts/pull-request-dashboard/test_delivery.py @@ -3,7 +3,7 @@ from pathlib import Path import tempfile import unittest -from unittest.mock import Mock, call, patch +from unittest.mock import ANY, Mock, call, patch import delivery @@ -31,8 +31,6 @@ def test_runs_all_targeted_deliveries_in_order( errors_file = Path(temp_dir) / "errors" status = delivery.deliver_from_state( "open-telemetry/example", - 7, - "initial", ["optional-*"], Path(temp_dir) / "author", Path(temp_dir) / "copilot", @@ -45,6 +43,18 @@ def test_runs_all_targeted_deliveries_in_order( [call("author"), call("status"), call("copilot"), call("slack")], order.call_args_list, ) + status_comments.assert_called_once_with( + "open-telemetry/example", + None, + {7, 8}, + ) + slack.assert_called_once_with( + "open-telemetry/example", + ANY, + None, + None, + ANY, + ) @patch.object(delivery, "notify_slack_from_state", return_value=[]) @patch.object(delivery, "deliver_copilot_review_requests", return_value=[]) @@ -63,8 +73,6 @@ def test_failure_does_not_block_later_deliveries( errors_file = Path(temp_dir) / "errors" delivery.deliver_from_state( "open-telemetry/example", - None, - "follow-up", [], Path(temp_dir) / "author", Path(temp_dir) / "copilot", diff --git a/.github/workflows/pull-request-dashboard-repo.yml b/.github/workflows/pull-request-dashboard-repo.yml index f81e453e7bc..8c2b43c360e 100644 --- a/.github/workflows/pull-request-dashboard-repo.yml +++ b/.github/workflows/pull-request-dashboard-repo.yml @@ -215,7 +215,6 @@ jobs: SLACK_CHANNEL: ${{ inputs.slack_channel }} SLACK_USER_MAP_JSON: ${{ inputs.slack_user_mapping_json }} REPO_NAME: ${{ inputs.repository }} - TRIGGER_PR_NUMBER: ${{ inputs.pr_number }} NON_BLOCKING_CHECK_PATTERNS_JSON: ${{ inputs.non_blocking_check_patterns_json }} run: | set -euo pipefail @@ -225,11 +224,6 @@ jobs: for pattern in "${non_blocking_check_patterns[@]}"; do delivery_args+=(--non-blocking-check-pattern "$pattern") done - if [[ -n "${TRIGGER_PR_NUMBER:-}" ]]; then - delivery_args+=(--pr-number "$TRIGGER_PR_NUMBER" --notification-kind initial) - elif [[ "${{ github.event_name }}" == "schedule" ]]; then - delivery_args+=(--notification-kind follow-up) - fi python3 .github/scripts/pull-request-dashboard/delivery.py "${delivery_args[@]}" - name: Publish dashboard issue diff --git a/.github/workflows/pull-request-dashboard.yml b/.github/workflows/pull-request-dashboard.yml index d8306a752a7..3c0575ba6f9 100644 --- a/.github/workflows/pull-request-dashboard.yml +++ b/.github/workflows/pull-request-dashboard.yml @@ -177,4 +177,7 @@ jobs: issues: write # needed to open/close the hourly failure tracking issue uses: ./.github/workflows/workflow-failure-issue.yml with: - success: ${{ needs.resolve-targets.result == 'success' && needs.run-repo-dashboard.result == 'success' }} + # GitHub replaces an older pending per-repo publisher even with + # cancel-in-progress disabled. Its replacement drains all durable work, + # while genuine matrix failures take precedence over cancellation. + success: ${{ needs.resolve-targets.result == 'success' && (needs.run-repo-dashboard.result == 'success' || needs.run-repo-dashboard.result == 'cancelled') }} From 59ee4ed8dc83a565e5c92860fc0bb7be1e4700e9 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Mon, 20 Jul 2026 20:09:37 -0700 Subject: [PATCH 14/44] Allow author reminders after route resets --- .../pull-request-dashboard/author_nudge.py | 46 ++++++++---- .../scripts/pull-request-dashboard/state.py | 9 ++- .../test_author_nudge.py | 70 ++++++++++++++++--- .../pull-request-dashboard/test_state.py | 28 +++++++- pull-request-dashboard/README.md | 6 +- 5 files changed, 128 insertions(+), 31 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/author_nudge.py b/.github/scripts/pull-request-dashboard/author_nudge.py index 16a117eebc2..6d1d2af49af 100644 --- a/.github/scripts/pull-request-dashboard/author_nudge.py +++ b/.github/scripts/pull-request-dashboard/author_nudge.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Track and post one-time reminders for pull requests waiting on authors.""" +"""Track and post reminders for pull requests waiting on authors.""" from __future__ import annotations @@ -24,7 +24,11 @@ NUDGE_AFTER = timedelta(weeks=1) -NUDGE_MARKER = "" +NUDGE_MARKER_PREFIX = "" def plan_nudge( @@ -40,11 +44,9 @@ def plan_nudge( ): return False, entry or None if not result or result.get("route") != "author": - # Reset an unnudged route clock, but retain a posted nudge for the - # lifetime of the PR so leaving and returning cannot trigger another. - return False, {"nudged_at": nudged_at} if nudged_at else None + return False, None if nudged_at: - return False, {"nudged_at": nudged_at} + return False, entry waiting_since = parse_ts(entry.get("waiting_since") or "") if waiting_since is None: @@ -55,7 +57,12 @@ def plan_nudge( return now - waiting_since >= NUDGE_AFTER, entry -def existing_nudge_comment(repo: str, pr_number: int) -> dict[str, Any] | None: +def existing_nudge_comment( + repo: str, + pr_number: int, + waiting_since: str, +) -> dict[str, Any] | None: + marker = nudge_marker(waiting_since) comments = gh_api( f"/repos/{repo}/issues/{pr_number}/comments?per_page=100", paginate=True, @@ -66,15 +73,15 @@ def existing_nudge_comment(repo: str, pr_number: int) -> dict[str, Any] | None: for comment in comments or [] if (comment.get("performed_via_github_app") or {}).get("slug") == DASHBOARD_APP_SLUG - and NUDGE_MARKER in (comment.get("body") or "") + and marker in (comment.get("body") or "") ), None, ) -def render_nudge(author: str, status_url: str) -> str: +def render_nudge(author: str, status_url: str, waiting_since: str) -> str: return "\n".join([ - NUDGE_MARKER, + nudge_marker(waiting_since), f"@{author}, this pull request has been waiting on your follow-up for one week.", "", f"See the [dashboard status comment]({status_url}) for the remaining items.", @@ -87,9 +94,10 @@ def ensure_nudge( pr_number: int, result: dict[str, Any], dashboard_state: dict[str, Any], + waiting_since: str, now: datetime, ) -> str | None: - existing = existing_nudge_comment(repo, pr_number) + existing = existing_nudge_comment(repo, pr_number, waiting_since) if existing: return existing.get("created_at") or format_ts(now) @@ -108,7 +116,7 @@ def ensure_nudge( run_gh([ "gh", "api", "--method", "POST", f"repos/{repo}/issues/{pr_number}/comments", - "-f", f"body={render_nudge(author, status_comments[0]['html_url'])}", + "-f", f"body={render_nudge(author, status_comments[0]['html_url'], waiting_since)}", ]) return format_ts(now) @@ -205,11 +213,21 @@ def deliver_prepared_author_nudges( if name not in ("pending_at", "head_sha", "source_fingerprint") } continue - nudged_at = ensure_nudge(repo, pr_number, result, dashboard_state, now) + nudged_at = ensure_nudge( + repo, + pr_number, + result, + dashboard_state, + entry.get("waiting_since") or "", + now, + ) except Exception as e: errors.append(f"PR #{pr_number}: {e}") continue if nudged_at: - updated[key] = {"nudged_at": nudged_at} + updated[key] = { + "waiting_since": entry.get("waiting_since") or "", + "nudged_at": nudged_at, + } save_author_nudges(updated) return errors diff --git a/.github/scripts/pull-request-dashboard/state.py b/.github/scripts/pull-request-dashboard/state.py index d0ac15b2b70..d23ac977d1c 100644 --- a/.github/scripts/pull-request-dashboard/state.py +++ b/.github/scripts/pull-request-dashboard/state.py @@ -261,8 +261,13 @@ def union_merge_author_nudges( merged = dict(baseline_nudges) for key, retry_entry in retry_snapshot_nudges.items(): nudged_at = (retry_entry or {}).get("nudged_at") or "" - if nudged_at: - merged[key] = {"nudged_at": nudged_at} + waiting_since = (retry_entry or {}).get("waiting_since") or "" + baseline_waiting_since = (baseline_nudges.get(key) or {}).get("waiting_since") or "" + if nudged_at and waiting_since and waiting_since == baseline_waiting_since: + merged[key] = { + "waiting_since": waiting_since, + "nudged_at": nudged_at, + } return merged diff --git a/.github/scripts/pull-request-dashboard/test_author_nudge.py b/.github/scripts/pull-request-dashboard/test_author_nudge.py index 8804e0e9b15..f3299f5b1a5 100644 --- a/.github/scripts/pull-request-dashboard/test_author_nudge.py +++ b/.github/scripts/pull-request-dashboard/test_author_nudge.py @@ -59,13 +59,22 @@ def test_leaving_author_route_resets_unnudged_clock(self) -> None: self.assertFalse(due) self.assertIsNone(entry) - def test_lifetime_nudge_marker_survives_route_changes(self) -> None: - previous = {"nudged_at": "2026-07-10T00:00:00+00:00"} + def test_returning_to_author_route_starts_new_episode(self) -> None: + previous = { + "waiting_since": "2026-07-01T00:00:00+00:00", + "nudged_at": "2026-07-10T00:00:00+00:00", + } - for result in (author_result("approver"), None, author_result()): - due, entry = author_nudge.plan_nudge(result, previous, NOW) - self.assertFalse(due) - self.assertEqual(entry, previous) + due, entry = author_nudge.plan_nudge(author_result("approver"), previous, NOW) + self.assertFalse(due) + self.assertIsNone(entry) + + due, entry = author_nudge.plan_nudge(author_result(), entry, NOW) + self.assertFalse(due) + self.assertEqual( + entry, + {"waiting_since": "2026-07-17T00:00:00+00:00", "nudged_at": ""}, + ) def test_failed_refresh_preserves_clock(self) -> None: previous = {"waiting_since": "2026-07-10T00:00:00+00:00", "nudged_at": ""} @@ -199,7 +208,10 @@ def test_delivery_records_posted_nudge( current_source.assert_called_once_with("open-telemetry/example", 1, ["optional-*"]) ensure_nudge.assert_called_once() save_nudges.assert_called_once_with({ - "1": {"nudged_at": "2026-07-17T00:00:00+00:00"}, + "1": { + "waiting_since": "2026-07-01T00:00:00+00:00", + "nudged_at": "2026-07-17T00:00:00+00:00", + }, }) @patch.object(author_nudge, "ensure_nudge") @@ -257,11 +269,42 @@ def test_delivery_defers_when_routing_inputs_changed( }) def test_rendered_nudge_mentions_author_and_links_status(self) -> None: - body = author_nudge.render_nudge("alice", "https://example.test/status") + body = author_nudge.render_nudge( + "alice", + "https://example.test/status", + "2026-07-10T00:00:00+00:00", + ) self.assertIn("@alice", body) self.assertIn("[dashboard status comment](https://example.test/status)", body) - self.assertIn(author_nudge.NUDGE_MARKER, body) + self.assertIn( + author_nudge.nudge_marker("2026-07-10T00:00:00+00:00"), + body, + ) + + @patch.object( + author_nudge, + "gh_api", + return_value=[ + { + "performed_via_github_app": {"slug": "opentelemetry-pr-dashboard"}, + "body": author_nudge.nudge_marker("2026-07-01T00:00:00+00:00"), + }, + { + "performed_via_github_app": {"slug": "opentelemetry-pr-dashboard"}, + "body": author_nudge.nudge_marker("2026-07-10T00:00:00+00:00"), + "created_at": "2026-07-17T00:00:00Z", + }, + ], + ) + def test_existing_nudge_matches_current_episode(self, _gh_api) -> None: + comment = author_nudge.existing_nudge_comment( + "open-telemetry/example", + 1, + "2026-07-10T00:00:00+00:00", + ) + + self.assertEqual(comment["created_at"], "2026-07-17T00:00:00Z") @patch.object(author_nudge, "run_gh") @patch.object(author_nudge, "publish_pr_status") @@ -291,6 +334,7 @@ def test_posts_nudge_after_ensuring_status_comment( 1, author_result(), dashboard_state, + "2026-07-10T00:00:00+00:00", NOW, ) @@ -309,7 +353,7 @@ def test_posts_nudge_after_ensuring_status_comment( ) def test_existing_marker_prevents_duplicate_after_state_loss( self, - _existing_comment, + existing_comment, publish_status, run_gh, ) -> None: @@ -318,10 +362,16 @@ def test_existing_marker_prevents_duplicate_after_state_loss( 1, author_result(), {"prs": {"1": author_result()}}, + "2026-07-10T00:00:00+00:00", NOW, ) self.assertEqual(nudged_at, "2026-07-11T00:00:00Z") + existing_comment.assert_called_once_with( + "open-telemetry/example", + 1, + "2026-07-10T00:00:00+00:00", + ) publish_status.assert_not_called() run_gh.assert_not_called() diff --git a/.github/scripts/pull-request-dashboard/test_state.py b/.github/scripts/pull-request-dashboard/test_state.py index 5818794e1d6..5b115c557c7 100644 --- a/.github/scripts/pull-request-dashboard/test_state.py +++ b/.github/scripts/pull-request-dashboard/test_state.py @@ -208,10 +208,34 @@ def test_copilot_review_request_state_round_trip(self) -> None: def test_retry_snapshot_preserves_posted_author_nudge(self) -> None: self.assertEqual( - {"7": {"nudged_at": "2026-07-20T02:00:00Z"}}, + { + "7": { + "waiting_since": "2026-07-10T02:00:00Z", + "nudged_at": "2026-07-20T02:00:00Z", + } + }, union_merge_author_nudges( {"7": {"waiting_since": "2026-07-10T02:00:00Z", "nudged_at": ""}}, - {"7": {"nudged_at": "2026-07-20T02:00:00Z"}}, + { + "7": { + "waiting_since": "2026-07-10T02:00:00Z", + "nudged_at": "2026-07-20T02:00:00Z", + } + }, + ), + ) + + def test_retry_snapshot_does_not_suppress_new_author_episode(self) -> None: + self.assertEqual( + {"7": {"waiting_since": "2026-07-20T02:00:00Z", "nudged_at": ""}}, + union_merge_author_nudges( + {"7": {"waiting_since": "2026-07-20T02:00:00Z", "nudged_at": ""}}, + { + "7": { + "waiting_since": "2026-07-10T02:00:00Z", + "nudged_at": "2026-07-17T02:00:00Z", + } + }, ), ) diff --git a/pull-request-dashboard/README.md b/pull-request-dashboard/README.md index 36cd3da20e3..451f638732f 100644 --- a/pull-request-dashboard/README.md +++ b/pull-request-dashboard/README.md @@ -198,9 +198,9 @@ The dashboard posts one reminder when a pull request remains in *Waiting on authors* for one week. The reminder @-mentions the author and links to the dashboard-managed status comment containing the remaining items. -Leaving *Waiting on authors* before the reminder is due resets the one-week -clock. Each pull request is reminded at most once, even if it later leaves and -returns to *Waiting on authors*. Reminders are delivered by hourly runs when +Leaving *Waiting on authors* resets the one-week clock. If the pull request +later returns to *Waiting on authors* and remains there for another week, the +dashboard posts another reminder. Reminders are delivered by hourly runs when the pull request is next refreshed, so a due reminder in a large repository may wait for a later round-robin run. From 85b53dd8793ed8ad65e55ef3a699639835bfbc75 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Mon, 20 Jul 2026 20:43:33 -0700 Subject: [PATCH 15/44] Simplify author-nudge delivery to match the Copilot re-review path Re-validate pending nudges with a light pulls-API head check instead of recomputing a full source fingerprint via fetch_pr_raw. Removes the per-PR source_fingerprint fact and the now-dead non-blocking-check-pattern plumbing from the delivery job. --- .../pull-request-dashboard/author_nudge.py | 42 ++++--------------- .../pull-request-dashboard/dashboard.py | 22 ---------- .../pull-request-dashboard/delivery.py | 11 ----- .../test_author_nudge.py | 42 +++++++------------ .../pull-request-dashboard/test_dashboard.py | 6 +++ .../pull-request-dashboard/test_delivery.py | 2 - .../workflows/pull-request-dashboard-repo.yml | 5 --- 7 files changed, 29 insertions(+), 101 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/author_nudge.py b/.github/scripts/pull-request-dashboard/author_nudge.py index 6d1d2af49af..7a33d58a811 100644 --- a/.github/scripts/pull-request-dashboard/author_nudge.py +++ b/.github/scripts/pull-request-dashboard/author_nudge.py @@ -132,15 +132,12 @@ def record_author_nudge_observation( key = str(pr_number) due, entry = plan_nudge(result, updated.get(key), now) if due and prepare_due and entry is not None: - facts = (result or {}).get("facts") or {} - head_sha = facts.get("head_sha") or "" - source_fingerprint = facts.get("source_fingerprint") or "" - if head_sha and source_fingerprint: + head_sha = ((result or {}).get("facts") or {}).get("head_sha") or "" + if head_sha: entry = { **entry, "pending_at": format_ts(now), "head_sha": head_sha, - "source_fingerprint": source_fingerprint, } if entry is None: updated.pop(key, None) @@ -149,28 +146,9 @@ def record_author_nudge_observation( save_author_nudges(updated) -def current_source_fingerprint( - repo: str, - pr_number: int, - non_blocking_check_patterns: list[str], -) -> tuple[dict[str, Any], str]: - from dashboard import dashboard_source_fingerprint, fetch_pr_raw - - owner, repo_name = repo.split("/", 1) - raw = fetch_pr_raw( - repo, - owner, - repo_name, - {"number": pr_number}, - non_blocking_check_patterns, - ) - return raw, dashboard_source_fingerprint(raw) - - def deliver_prepared_author_nudges( repo: str, now: datetime, - non_blocking_check_patterns: list[str], retry_snapshot_path: Path | None = None, ) -> list[str]: dashboard_state = load_dashboard_state_cache() @@ -193,24 +171,18 @@ def deliver_prepared_author_nudges( updated[key] = reset_entry continue try: - raw, source_fingerprint = current_source_fingerprint( - repo, - pr_number, - non_blocking_check_patterns, - ) - pr = raw.get("pr") or {} + pr = gh_api(f"/repos/{repo}/pulls/{pr_number}") or {} expected_head = entry.get("head_sha") or "" - current_head = ((raw.get("commits") or [{}])[-1].get("sha") or "") + current_head = ((pr.get("head") or {}).get("sha") or "") if ( - pr.get("state") != "OPEN" - or pr.get("isDraft") + pr.get("state") != "open" + or pr.get("draft") or (expected_head and current_head != expected_head) - or source_fingerprint != entry.get("source_fingerprint") ): updated[key] = { name: value for name, value in entry.items() - if name not in ("pending_at", "head_sha", "source_fingerprint") + if name not in ("pending_at", "head_sha") } continue nudged_at = ensure_nudge( diff --git a/.github/scripts/pull-request-dashboard/dashboard.py b/.github/scripts/pull-request-dashboard/dashboard.py index a1a7b20d69a..9acd47c7024 100644 --- a/.github/scripts/pull-request-dashboard/dashboard.py +++ b/.github/scripts/pull-request-dashboard/dashboard.py @@ -159,8 +159,6 @@ from __future__ import annotations import argparse -import hashlib -import json import sys import traceback from concurrent.futures import ThreadPoolExecutor @@ -219,25 +217,6 @@ DEFAULT_BACKFILL_MAX_PRS = 50 BACKFILL_RECORDED_FAILURE_STATUS = 2 - -def dashboard_source_fingerprint(raw: dict[str, Any]) -> str: - source = { - key: raw.get(key) - for key in ( - "pr", - "issue_comments", - "review_comments", - "reviews", - "commits", - "checks", - "non_blocking_check_failures", - "review_threads", - "pr_metadata", - ) - } - encoded = json.dumps(source, sort_keys=True, separators=(",", ":")).encode() - return hashlib.sha256(encoded).hexdigest() - # ---------------------------------------------------------------- model helpers @@ -604,7 +583,6 @@ def compute_facts( "author": author, "assignees": assignees, "head_sha": ((raw.get("commits") or [{}])[-1].get("sha") or ""), - "source_fingerprint": dashboard_source_fingerprint(raw), "copilot_review_requested": any( is_copilot_reviewer(request) for request in (pr.get("reviewRequests") or []) diff --git a/.github/scripts/pull-request-dashboard/delivery.py b/.github/scripts/pull-request-dashboard/delivery.py index 7b0aba99d7a..cbb47ddc071 100644 --- a/.github/scripts/pull-request-dashboard/delivery.py +++ b/.github/scripts/pull-request-dashboard/delivery.py @@ -45,7 +45,6 @@ def run_delivery_action( def deliver_from_state( repo: str, - non_blocking_check_patterns: list[str], author_retry_snapshot_path: Path, copilot_retry_snapshot_path: Path, notification_retry_snapshot_path: Path, @@ -58,7 +57,6 @@ def deliver_from_state( lambda: deliver_prepared_author_nudges( repo, now, - non_blocking_check_patterns, author_retry_snapshot_path, ), errors, @@ -99,7 +97,6 @@ def deliver_with_state( repo: str, state_branch_name: str, state_dir: Path, - non_blocking_check_patterns: list[str], ) -> int: repo_key = repo_state_key(repo) errors_file = delivery_errors_path() @@ -112,7 +109,6 @@ def deliver_with_state( "Deliver pull request dashboard updates", lambda: deliver_from_state( repo, - non_blocking_check_patterns, author_retry, copilot_retry, notification_retry, @@ -139,12 +135,6 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--repo", help="target repository name") parser.add_argument("--state-branch", required=True, help="git branch used for workflow state") - parser.add_argument( - "--non-blocking-check-pattern", - action="append", - default=[], - help="glob matching a non-required check; repeat as needed", - ) args = parser.parse_args() repo = normalize_repo(args.repo) if args.repo else detect_repo() with state_branch.temporary_state_dir() as state_dir: @@ -153,7 +143,6 @@ def main() -> int: repo, args.state_branch, state_dir, - args.non_blocking_check_pattern, ) diff --git a/.github/scripts/pull-request-dashboard/test_author_nudge.py b/.github/scripts/pull-request-dashboard/test_author_nudge.py index f3299f5b1a5..8ad5be272b3 100644 --- a/.github/scripts/pull-request-dashboard/test_author_nudge.py +++ b/.github/scripts/pull-request-dashboard/test_author_nudge.py @@ -16,7 +16,6 @@ def author_result(route: str = "author") -> dict: "facts": { "author": "alice", "head_sha": "current-head", - "source_fingerprint": "current-source", }, } @@ -150,7 +149,6 @@ def test_due_accepted_observation_records_pending_nudge( "nudged_at": "", "pending_at": "2026-07-17T00:00:00+00:00", "head_sha": "current-head", - "source_fingerprint": "current-source", } }, ) @@ -170,7 +168,6 @@ def test_due_accepted_observation_records_pending_nudge( "nudged_at": "", "pending_at": "2026-07-17T00:00:00+00:00", "head_sha": "current-head", - "source_fingerprint": "current-source", } }, ) @@ -181,18 +178,16 @@ def test_due_accepted_observation_records_pending_nudge( ) @patch.object( author_nudge, - "current_source_fingerprint", - return_value=( - { - "pr": {"state": "OPEN", "isDraft": False}, - "commits": [{"sha": "current-head"}], - }, - "current-source", - ), + "gh_api", + return_value={ + "state": "open", + "draft": False, + "head": {"sha": "current-head"}, + }, ) def test_delivery_records_posted_nudge( self, - current_source, + gh_api, _load_dashboard_state, _load_nudges, save_nudges, @@ -201,11 +196,10 @@ def test_delivery_records_posted_nudge( errors = author_nudge.deliver_prepared_author_nudges( "open-telemetry/example", NOW, - ["optional-*"], ) self.assertEqual([], errors) - current_source.assert_called_once_with("open-telemetry/example", 1, ["optional-*"]) + gh_api.assert_called_once_with("/repos/open-telemetry/example/pulls/1") ensure_nudge.assert_called_once() save_nudges.assert_called_once_with({ "1": { @@ -225,7 +219,6 @@ def test_delivery_records_posted_nudge( "nudged_at": "", "pending_at": "2026-07-17T00:00:00+00:00", "head_sha": "current-head", - "source_fingerprint": "accepted-source", } }, ) @@ -236,18 +229,16 @@ def test_delivery_records_posted_nudge( ) @patch.object( author_nudge, - "current_source_fingerprint", - return_value=( - { - "pr": {"state": "OPEN", "isDraft": False}, - "commits": [{"sha": "current-head"}], - }, - "changed-source", - ), + "gh_api", + return_value={ + "state": "open", + "draft": False, + "head": {"sha": "new-head"}, + }, ) - def test_delivery_defers_when_routing_inputs_changed( + def test_delivery_defers_when_head_advanced( self, - _current_source, + _gh_api, _load_dashboard_state, _load_nudges, save_nudges, @@ -256,7 +247,6 @@ def test_delivery_defers_when_routing_inputs_changed( errors = author_nudge.deliver_prepared_author_nudges( "open-telemetry/example", NOW, - [], ) self.assertEqual([], errors) diff --git a/.github/scripts/pull-request-dashboard/test_dashboard.py b/.github/scripts/pull-request-dashboard/test_dashboard.py index 0ba487872c0..7889313f5f3 100644 --- a/.github/scripts/pull-request-dashboard/test_dashboard.py +++ b/.github/scripts/pull-request-dashboard/test_dashboard.py @@ -537,6 +537,7 @@ def test_writes_initial_backfill_status_to_github_output(self) -> None: ) class StatusCommentQueueTest(unittest.TestCase): + @patch("dashboard.record_copilot_review_observation") @patch("dashboard.record_author_nudge_observation") @patch("dashboard.save_dashboard_update_state", return_value=0) @patch("dashboard.enqueue_status_comment_update") @@ -550,6 +551,7 @@ def test_removed_dashboard_results_enqueue_status_comments( enqueue_update: Mock, save_state: Mock, record_nudge: Mock, + _record_copilot: Mock, ) -> None: args = Namespace(pr_number=None) @@ -567,6 +569,7 @@ def test_removed_dashboard_results_enqueue_status_comments( sorted(record_nudge.call_args_list, key=lambda value: value.args[0]), ) + @patch("dashboard.record_copilot_review_observation") @patch("dashboard.record_author_nudge_observation") @patch("dashboard.clear_backfill_pr_failure") @patch("dashboard.save_dashboard_update_state", return_value=0) @@ -579,6 +582,7 @@ def test_targeted_state_change_enqueues_status_comment( _save_state: Mock, _clear_backfill_failure: Mock, record_nudge: Mock, + _record_copilot: Mock, ) -> None: accepted_result = {"route": "author"} calculation = DashboardUpdate( @@ -602,6 +606,7 @@ def test_targeted_state_change_enqueues_status_comment( prepare_due=True, ) + @patch("dashboard.record_copilot_review_observation") @patch("dashboard.record_author_nudge_observation") @patch("dashboard.clear_backfill_pr_failure") @patch("dashboard.save_dashboard_update_state", return_value=0) @@ -614,6 +619,7 @@ def test_unchanged_targeted_state_does_not_enqueue_status_comment( _save_state: Mock, _clear_backfill_failure: Mock, record_nudge: Mock, + _record_copilot: Mock, ) -> None: accepted_result = {"route": "approver"} calculation = DashboardUpdate( diff --git a/.github/scripts/pull-request-dashboard/test_delivery.py b/.github/scripts/pull-request-dashboard/test_delivery.py index ef013258f98..a7206b24156 100644 --- a/.github/scripts/pull-request-dashboard/test_delivery.py +++ b/.github/scripts/pull-request-dashboard/test_delivery.py @@ -31,7 +31,6 @@ def test_runs_all_targeted_deliveries_in_order( errors_file = Path(temp_dir) / "errors" status = delivery.deliver_from_state( "open-telemetry/example", - ["optional-*"], Path(temp_dir) / "author", Path(temp_dir) / "copilot", Path(temp_dir) / "slack", @@ -73,7 +72,6 @@ def test_failure_does_not_block_later_deliveries( errors_file = Path(temp_dir) / "errors" delivery.deliver_from_state( "open-telemetry/example", - [], Path(temp_dir) / "author", Path(temp_dir) / "copilot", Path(temp_dir) / "slack", diff --git a/.github/workflows/pull-request-dashboard-repo.yml b/.github/workflows/pull-request-dashboard-repo.yml index 8c2b43c360e..97052a0ec18 100644 --- a/.github/workflows/pull-request-dashboard-repo.yml +++ b/.github/workflows/pull-request-dashboard-repo.yml @@ -215,15 +215,10 @@ jobs: SLACK_CHANNEL: ${{ inputs.slack_channel }} SLACK_USER_MAP_JSON: ${{ inputs.slack_user_mapping_json }} REPO_NAME: ${{ inputs.repository }} - NON_BLOCKING_CHECK_PATTERNS_JSON: ${{ inputs.non_blocking_check_patterns_json }} run: | set -euo pipefail state_branch="${DASHBOARD_STATE_BRANCH_PREFIX}/${REPO_NAME}" delivery_args=(--state-branch "$state_branch" --repo "$REPO_NAME") - mapfile -t non_blocking_check_patterns < <(jq -r '.[]' <<< "$NON_BLOCKING_CHECK_PATTERNS_JSON") - for pattern in "${non_blocking_check_patterns[@]}"; do - delivery_args+=(--non-blocking-check-pattern "$pattern") - done python3 .github/scripts/pull-request-dashboard/delivery.py "${delivery_args[@]}" - name: Publish dashboard issue From 7bd5db4d59b71ec79786f1fa2d0d518896e5d013 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Mon, 20 Jul 2026 21:26:58 -0700 Subject: [PATCH 16/44] Consolidate dashboard delivery paths --- .../pull-request-dashboard/copilot_review.py | 86 +++++++++++++++---- .../pull-request-dashboard/dashboard.py | 83 ++++-------------- .../pull-request-dashboard/delivery.py | 36 ++++---- .../pull-request-dashboard/github_cli.py | 12 +-- .../pull-request-dashboard/notify_slack.py | 83 +----------------- .../pr_status_comment.py | 82 ------------------ .../test_copilot_review.py | 60 +++++++++++-- .../pull-request-dashboard/test_dashboard.py | 22 ++--- .../pull-request-dashboard/test_delivery.py | 73 ++++++++++------ .../pull-request-dashboard/test_github_cli.py | 14 +-- 10 files changed, 222 insertions(+), 329 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/copilot_review.py b/.github/scripts/pull-request-dashboard/copilot_review.py index 718325f1c16..2679ec68293 100644 --- a/.github/scripts/pull-request-dashboard/copilot_review.py +++ b/.github/scripts/pull-request-dashboard/copilot_review.py @@ -9,11 +9,66 @@ from github_cli import ( fetch_pr_review_data, gh_api, - gh_pr_view, request_copilot_review, ) from state import load_copilot_review_requests, save_copilot_review_requests -from utils import format_ts +from utils import actor_login, format_ts + + +_COPILOT_REVIEWER_LOGINS = { + "copilot", + "copilot-pull-request-reviewer", + "copilot-pull-request-reviewer[bot]", +} + + +def is_copilot_reviewer(obj: dict[str, Any] | None) -> bool: + return actor_login(obj).lower() in _COPILOT_REVIEWER_LOGINS + + +def copilot_review_status( + reviews: list[dict[str, Any]], + head_sha: str, +) -> tuple[bool, bool]: + copilot_reviews = [ + review + for review in reviews + if is_copilot_reviewer(review.get("user")) + ] + if not copilot_reviews: + return False, False + if not head_sha: + return True, False + current_head_reviews = [ + review + for review in copilot_reviews + if (review.get("commit_id") or "") == head_sha + ] + if not current_head_reviews: + return True, True + latest_review = max( + current_head_reviews, + key=lambda review: review.get("submitted_at") or "", + ) + return True, bool(latest_review.get("finding_count")) + + +def apply_copilot_review_gate( + facts: dict[str, Any], + route: str, + *, + enabled: bool, +) -> str: + facts["copilot_review_request_needed"] = False + 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"): + facts["copilot_review_request_needed"] = True + return "copilot" def record_copilot_review_observation( @@ -42,8 +97,6 @@ def deliver_copilot_review_requests( now: datetime, retry_snapshot_path: Path | None = None, ) -> list[str]: - from dashboard import copilot_review_needed, has_copilot_review, is_copilot_reviewer - requests = dict(load_copilot_review_requests(retry_snapshot_path)) owner, repo_name = repo.split("/", 1) errors: list[str] = [] @@ -61,29 +114,24 @@ def deliver_copilot_review_requests( ): requests.pop(key, None) continue - pr_view = gh_pr_view(repo, pr_number) if any( is_copilot_reviewer(request) - for request in (pr_view.get("reviewRequests") or []) + for request in (pr.get("requested_reviewers") or []) ): requests[key] = {**entry, "requested_at": format_ts(now)} continue review_data = fetch_pr_review_data(owner, repo_name, pr_number) or {} - raw = { - "reviews": review_data.get("reviews") or [], - "commits": gh_api( - f"/repos/{repo}/pulls/{pr_number}/commits?per_page=100", - paginate=True, - ) or [], - "review_comments": gh_api( - f"/repos/{repo}/pulls/{pr_number}/comments?per_page=100", - paginate=True, - ) or [], - } - if not has_copilot_review(raw) or not copilot_review_needed(raw): + review_exists, review_needed = copilot_review_status( + review_data.get("reviews") or [], + current_head, + ) + if not review_exists or not review_needed: requests.pop(key, None) continue - request_copilot_review(repo, pr_number) + pull_request_id = pr.get("node_id") or "" + if not pull_request_id: + raise RuntimeError(f"GitHub did not return a node ID for PR #{pr_number}") + request_copilot_review(pull_request_id) except Exception as e: errors.append(f"PR #{pr_number}: {e}") continue diff --git a/.github/scripts/pull-request-dashboard/dashboard.py b/.github/scripts/pull-request-dashboard/dashboard.py index 9acd47c7024..f119626c7fa 100644 --- a/.github/scripts/pull-request-dashboard/dashboard.py +++ b/.github/scripts/pull-request-dashboard/dashboard.py @@ -193,7 +193,12 @@ prune_classification_cache, ) from author_nudge import record_author_nudge_observation -from copilot_review import record_copilot_review_observation +from copilot_review import ( + apply_copilot_review_gate, + copilot_review_status, + is_copilot_reviewer, + record_copilot_review_observation, +) from state import ( INITIAL_BACKFILL_COMPLETE_KEY, empty_state, @@ -239,57 +244,16 @@ def role_for(login: str, author: str, reviewers: set[str]) -> str: # human author behind a Copilot-authored PR. _COPILOT_COMMITTER_LOGINS = {"copilot"} _COPILOT_PR_AUTHORS = {"app/copilot-swe-agent", "copilot"} -_COPILOT_REVIEWER_LOGINS = {"copilot", "copilot-pull-request-reviewer", "copilot-pull-request-reviewer[bot]"} _MAINTENANCE_BOT_PR_AUTHORS = {"app/otelbot", "app/renovate"} def reviewer_actor_login(obj: dict[str, Any] | None) -> str: login = actor_login(obj) - if login.lower() in _COPILOT_REVIEWER_LOGINS: + if is_copilot_reviewer(obj): return "copilot-pull-request-reviewer[bot]" 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 - current_head_reviews = [ - review - for review in copilot_reviews - if (review.get("commit_id") or "") == current_head - ] - if not current_head_reviews: - return True - review_ids_with_findings = { - comment.get("pull_request_review_id") - for comment in (raw.get("review_comments") or []) - } - latest_review = max( - current_head_reviews, - key=lambda review: review.get("submitted_at") or "", - ) - return latest_review.get("id") in review_ids_with_findings - - 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: @@ -579,16 +543,21 @@ def compute_facts( api_author = actor_login(pr.get("author") or {}) assignees = [reviewer_actor_login(a) for a in (pr.get("assignees") or [])] assignees = [a for a in assignees if a] + head_sha = ((raw.get("commits") or [{}])[-1].get("sha") or "") + copilot_review_exists, copilot_review_needed = copilot_review_status( + raw.get("reviews") or [], + head_sha, + ) facts = { "author": author, "assignees": assignees, - "head_sha": ((raw.get("commits") or [{}])[-1].get("sha") or ""), + "head_sha": head_sha, "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), + "copilot_review_exists": copilot_review_exists, + "copilot_review_needed": copilot_review_needed, "is_maintenance_bot": api_author.lower() in _MAINTENANCE_BOT_PR_AUTHORS, "is_draft": bool(pr.get("isDraft")), "approval_count": current_approval_count(events), @@ -1258,26 +1227,6 @@ 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: - facts["copilot_review_request_needed"] = False - 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"): - facts["copilot_review_request_needed"] = 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} @@ -1534,8 +1483,6 @@ def build_pr_result( "error": f"{len(failed_classifications)} discussion classification(s) failed", } route = apply_copilot_review_gate( - repo, - number, facts, route_pr(facts, pending_actions, required_approvals), enabled=require_clean_copilot_review, diff --git a/.github/scripts/pull-request-dashboard/delivery.py b/.github/scripts/pull-request-dashboard/delivery.py index cbb47ddc071..66271ca4bbc 100644 --- a/.github/scripts/pull-request-dashboard/delivery.py +++ b/.github/scripts/pull-request-dashboard/delivery.py @@ -28,10 +28,6 @@ def runner_temp_path(name: str) -> Path: return Path(os.environ.get("RUNNER_TEMP", ".")) / name -def delivery_errors_path() -> Path: - return runner_temp_path("dashboard-delivery-errors.txt") - - def run_delivery_action( label: str, action: Callable[[], list[str]], @@ -48,8 +44,7 @@ def deliver_from_state( author_retry_snapshot_path: Path, copilot_retry_snapshot_path: Path, notification_retry_snapshot_path: Path, - errors_file: Path, -) -> int: +) -> list[str]: now = utc_now() errors: list[str] = [] run_delivery_action( @@ -86,11 +81,7 @@ def deliver_from_state( ), errors, ) - if errors: - errors_file.write_text("\n".join(errors) + "\n", encoding="utf-8") - else: - errors_file.unlink(missing_ok=True) - return 0 + return errors def deliver_with_state( @@ -99,21 +90,24 @@ def deliver_with_state( state_dir: Path, ) -> int: repo_key = repo_state_key(repo) - errors_file = delivery_errors_path() - errors_file.unlink(missing_ok=True) author_retry = runner_temp_path("prior-author-nudge-state.json") copilot_retry = runner_temp_path("prior-copilot-review-request-state.json") notification_retry = runner_temp_path("prior-notification-state.json") - status = state_branch.push_state_changes( - state_dir, - "Deliver pull request dashboard updates", - lambda: deliver_from_state( + errors: list[str] = [] + + def deliver() -> int: + errors[:] = deliver_from_state( repo, author_retry, copilot_retry, notification_retry, - errors_file, - ), + ) + return 0 + + status = state_branch.push_state_changes( + state_dir, + "Deliver pull request dashboard updates", + deliver, state_branch=state_branch_name, add_paths=[repo_key], retry_snapshots=[ @@ -124,10 +118,10 @@ def deliver_with_state( ) if status != 0: return status - if not errors_file.exists(): + if not errors: return 0 print("Dashboard delivery failed:", file=sys.stderr) - print(errors_file.read_text(encoding="utf-8").rstrip(), file=sys.stderr) + print("\n".join(errors), file=sys.stderr) return 1 diff --git a/.github/scripts/pull-request-dashboard/github_cli.py b/.github/scripts/pull-request-dashboard/github_cli.py index c499c3564a3..6938df51bdd 100644 --- a/.github/scripts/pull-request-dashboard/github_cli.py +++ b/.github/scripts/pull-request-dashboard/github_cli.py @@ -114,11 +114,7 @@ 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}") +def request_copilot_review(pull_request_id: str) -> None: gh_graphql( REQUEST_COPILOT_REVIEW_MUTATION, { @@ -172,6 +168,9 @@ def gh_pr_view(repo: str, number: int) -> dict[str, Any]: commit { oid } + comments { + totalCount + } url body state @@ -341,6 +340,9 @@ def fetch_pr_review_data(owner: str, repo_name: str, number: int) -> dict[str, A reviews.append({ "id": database_id, "commit_id": ((review.get("commit") or {}).get("oid") or ""), + "finding_count": int( + (review.get("comments") or {}).get("totalCount") or 0 + ), "url": review.get("url") or "", "user": review.get("author") or {}, "state": review.get("state") or "", diff --git a/.github/scripts/pull-request-dashboard/notify_slack.py b/.github/scripts/pull-request-dashboard/notify_slack.py index c9d66972688..a39ab1d8fc6 100644 --- a/.github/scripts/pull-request-dashboard/notify_slack.py +++ b/.github/scripts/pull-request-dashboard/notify_slack.py @@ -3,26 +3,21 @@ from __future__ import annotations -import argparse -import os import sys from pathlib import Path from typing import Any from datetime import datetime -from github_cli import detect_repo, list_open_prs, normalize_repo, repo_state_key +from github_cli import list_open_prs from notifications import next_notifications from state import ( load_dashboard_state_cache, load_notification_state_file, load_notifications, - notification_state_path, results_from_dashboard_state, save_notifications, - set_state_dir, union_merge_notifications, ) -import state_branch from utils import utc_now @@ -79,79 +74,3 @@ def notify_slack_from_state( save_notifications(updated_notifications) return delivery_errors - - -def notification_retry_snapshot_path() -> Path: - return Path(os.environ.get("RUNNER_TEMP", ".")) / "prior-notification-state.json" - - -def delivery_errors_path() -> Path: - return Path(os.environ.get("RUNNER_TEMP", ".")) / "notification-errors.txt" - - -def notify_slack( - repo: str, - retry_snapshot_path: Path, - delivery_errors_file: Path, - notification_numbers: set[int] | None, - notification_kinds: set[str] | None, -) -> int: - errors = notify_slack_from_state(repo, retry_snapshot_path, notification_numbers, notification_kinds) - if errors: - delivery_errors_file.write_text("\n".join(errors) + "\n", encoding="utf-8") - else: - delivery_errors_file.unlink(missing_ok=True) - return 0 - - -def notify_slack_with_state(args: argparse.Namespace, state_dir: Path) -> int: - repo_key = repo_state_key(args.repo) if args.repo else repo_state_key(detect_repo()) - retry_snapshot_path = notification_retry_snapshot_path() - delivery_errors_file = delivery_errors_path() - delivery_errors_file.unlink(missing_ok=True) - notification_numbers = {args.pr_number} if args.pr_number is not None else None - notification_kinds = {"initial"} if args.pr_number is not None else {"follow-up"} - status = state_branch.push_state_changes( - state_dir, - "Update dashboard notification state", - lambda: notify_slack( - normalize_repo(args.repo) if args.repo else detect_repo(), - retry_snapshot_path, - delivery_errors_file, - notification_numbers, - notification_kinds, - ), - state_branch=args.state_branch, - add_paths=[f"{repo_key}/notification-state.json"], - retry_snapshots=[(notification_state_path(), retry_snapshot_path)], - ) - if status != 0: - return status - if not delivery_errors_file.exists(): - return 0 - print("Slack notification delivery failed:", file=sys.stderr) - print(delivery_errors_file.read_text(encoding="utf-8").rstrip(), file=sys.stderr) - return 1 - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--repo", help="target repository name, e.g. opentelemetry-java-instrumentation") - parser.add_argument( - "--state-branch", - required=True, - help="git branch used for workflow state", - ) - parser.add_argument( - "--pr-number", - type=int, - help="send initial notifications for one pull request; omit to send due follow-up notifications only", - ) - args = parser.parse_args() - with state_branch.temporary_state_dir() as state_dir: - set_state_dir(state_dir / (repo_state_key(args.repo) if args.repo else repo_state_key(detect_repo()))) - return notify_slack_with_state(args, state_dir) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.github/scripts/pull-request-dashboard/pr_status_comment.py b/.github/scripts/pull-request-dashboard/pr_status_comment.py index ec1f124a7f4..e9bdfeda149 100644 --- a/.github/scripts/pull-request-dashboard/pr_status_comment.py +++ b/.github/scripts/pull-request-dashboard/pr_status_comment.py @@ -3,19 +3,12 @@ from __future__ import annotations -import argparse -import os import sys -from pathlib import Path from typing import Any from urllib.parse import urlencode from github_cli import ( - detect_repo, gh_api, - list_all_open_pr_numbers, - normalize_repo, - repo_state_key, run_gh, ) from route_presentation import route_status_summary @@ -23,11 +16,8 @@ load_dashboard_state_cache, load_status_comment_rollout_state, save_status_comment_rollout_state, - set_state_dir, - status_comment_rollout_state_path, ) from utils import markdown_escape, truncate -import state_branch from utils import utc_now @@ -358,75 +348,3 @@ def update_status_comments_from_state( rollout_state["completed_revision"] = STATUS_COMMENT_REVISION save_status_comment_rollout_state(rollout_state) return errors - - -def rollout_errors_path() -> Path: - return Path(os.environ.get("RUNNER_TEMP", ".")) / "status-comment-rollout-errors.txt" - - -def update_status_comments( - repo: str, - pr_number: int | None, - open_pr_numbers: set[int] | None, - errors_file: Path, -) -> int: - errors = update_status_comments_from_state(repo, pr_number, open_pr_numbers) - if errors: - errors_file.write_text("\n".join(errors) + "\n", encoding="utf-8") - else: - errors_file.unlink(missing_ok=True) - return 0 - - -def update_status_comments_with_state( - repo: str, - state_branch_name: str, - state_dir: Path, - pr_number: int | None, -) -> int: - open_pr_numbers = list_all_open_pr_numbers(repo) - repo_key = repo_state_key(repo) - errors_file = rollout_errors_path() - errors_file.unlink(missing_ok=True) - status = state_branch.push_state_changes( - state_dir, - "Update status comment rollout state", - lambda: update_status_comments( - repo, - pr_number, - open_pr_numbers, - errors_file, - ), - state_branch=state_branch_name, - add_paths=[f"{repo_key}/{status_comment_rollout_state_path().name}"], - ) - if status != 0: - return status - if not errors_file.exists(): - return 0 - print("Status comment rollout failed:", file=sys.stderr) - print(errors_file.read_text(encoding="utf-8").rstrip(), file=sys.stderr) - return 1 - - -def main() -> int: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--repo", help="target repository name, e.g. opentelemetry-java-instrumentation") - parser.add_argument("--state-branch", required=True, help="git branch used for workflow state") - parser.add_argument("--pr-number", type=int, help="targeted pull request to update") - args = parser.parse_args() - - repo = normalize_repo(args.repo) if args.repo else detect_repo() - - with state_branch.temporary_state_dir() as state_dir: - set_state_dir(state_dir / repo_state_key(repo)) - return update_status_comments_with_state( - repo, - args.state_branch, - state_dir, - args.pr_number, - ) - - -if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file diff --git a/.github/scripts/pull-request-dashboard/test_copilot_review.py b/.github/scripts/pull-request-dashboard/test_copilot_review.py index b8e9dc8cfa6..6dba26be41c 100644 --- a/.github/scripts/pull-request-dashboard/test_copilot_review.py +++ b/.github/scripts/pull-request-dashboard/test_copilot_review.py @@ -120,7 +120,6 @@ def test_initial_automatic_review_does_not_enqueue_request( @patch("copilot_review.request_copilot_review") @patch("copilot_review.fetch_pr_review_data") - @patch("copilot_review.gh_pr_view", return_value={"reviewRequests": []}) @patch("copilot_review.gh_api") @patch("copilot_review.save_copilot_review_requests") @patch( @@ -132,19 +131,21 @@ def test_delivers_request_for_current_stale_review( _load_requests, save_requests, gh_api, - _gh_pr_view, fetch_review_data, request_review, ) -> None: - gh_api.side_effect = [ - {"state": "open", "draft": False, "head": {"sha": "current-head"}}, - [{"sha": "reviewed-head"}, {"sha": "current-head"}], - [], - ] + gh_api.return_value = { + "state": "open", + "draft": False, + "head": {"sha": "current-head"}, + "node_id": "PR_node_id", + "requested_reviewers": [], + } fetch_review_data.return_value = { "reviews": [{ "id": 20, "commit_id": "reviewed-head", + "finding_count": 0, "user": {"login": "copilot"}, "submitted_at": "2026-07-20T01:00:00Z", }], @@ -156,7 +157,50 @@ def test_delivers_request_for_current_stale_review( ) self.assertEqual([], errors) - request_review.assert_called_once_with("open-telemetry/example", 7) + gh_api.assert_called_once_with("/repos/open-telemetry/example/pulls/7") + fetch_review_data.assert_called_once_with("open-telemetry", "example", 7) + request_review.assert_called_once_with("PR_node_id") + save_requests.assert_called_once_with({ + "7": { + "head_sha": "current-head", + "requested_at": "2026-07-20T02:00:00+00:00", + }, + }) + + @patch("copilot_review.request_copilot_review") + @patch("copilot_review.fetch_pr_review_data") + @patch("copilot_review.gh_api") + @patch("copilot_review.save_copilot_review_requests") + @patch( + "copilot_review.load_copilot_review_requests", + return_value={"7": {"head_sha": "current-head", "requested_at": ""}}, + ) + def test_pending_request_is_acknowledged_from_pull_response( + self, + _load_requests, + save_requests, + gh_api, + fetch_review_data, + request_review, + ) -> None: + gh_api.return_value = { + "state": "open", + "draft": False, + "head": {"sha": "current-head"}, + "requested_reviewers": [ + {"login": "copilot-pull-request-reviewer[bot]"}, + ], + } + + errors = deliver_copilot_review_requests( + "open-telemetry/example", + datetime(2026, 7, 20, 2, tzinfo=timezone.utc), + ) + + self.assertEqual([], errors) + gh_api.assert_called_once_with("/repos/open-telemetry/example/pulls/7") + fetch_review_data.assert_not_called() + request_review.assert_not_called() save_requests.assert_called_once_with({ "7": { "head_sha": "current-head", diff --git a/.github/scripts/pull-request-dashboard/test_dashboard.py b/.github/scripts/pull-request-dashboard/test_dashboard.py index 7889313f5f3..170c6ba9d63 100644 --- a/.github/scripts/pull-request-dashboard/test_dashboard.py +++ b/.github/scripts/pull-request-dashboard/test_dashboard.py @@ -8,11 +8,11 @@ from unittest.mock import ANY, Mock, call, patch from classification import discussion_prompt_input +from copilot_review import apply_copilot_review_gate from dashboard import ( 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, @@ -202,12 +202,14 @@ def test_current_head_matches_latest_clean_copilot_review(self) -> None: { "id": 10, "commit_id": "old-head", + "finding_count": 1, "user": {"login": "copilot-pull-request-reviewer[bot]"}, "submitted_at": "2026-07-20T01:30:00Z", }, { "id": 20, "commit_id": "current-head", + "finding_count": 0, "user": {"login": "copilot-pull-request-reviewer"}, "submitted_at": "2026-07-20T02:30:00Z", }, @@ -242,12 +244,14 @@ def test_late_stale_review_does_not_replace_clean_current_head_review(self) -> N { "id": 10, "commit_id": "current-head", + "finding_count": 0, "user": {"login": "copilot"}, "submitted_at": "2026-07-20T02:30:00Z", }, { "id": 20, "commit_id": "old-head", + "finding_count": 1, "user": {"login": "copilot"}, "submitted_at": "2026-07-20T03:00:00Z", }, @@ -278,6 +282,7 @@ def test_push_since_latest_clean_copilot_review_needs_rereview(self) -> None: { "id": 20, "commit_id": "reviewed-head", + "finding_count": 0, "user": {"login": "copilot"}, "submitted_at": "2026-07-20T02:30:00Z", }, @@ -309,12 +314,14 @@ def test_latest_findings_review_replaces_clean_review_on_same_head(self) -> None { "id": 10, "commit_id": "current-head", + "finding_count": 0, "user": {"login": "copilot"}, "submitted_at": "2026-07-20T01:30:00Z", }, { "id": 20, "commit_id": "current-head", + "finding_count": 1, "user": {"login": "copilot"}, "submitted_at": "2026-07-20T02:30:00Z", }, @@ -345,6 +352,7 @@ def test_findings_only_history_needs_rereview(self) -> None: { "id": 20, "commit_id": "reviewed-head", + "finding_count": 1, "user": {"login": "copilot"}, "submitted_at": "2026-07-20T02:30:00Z", }, @@ -391,8 +399,6 @@ def test_initial_automatic_review_blocks_human_handoff(self) -> None: } route = apply_copilot_review_gate( - "open-telemetry/example", - 7, facts, "approver", enabled=True, @@ -409,8 +415,6 @@ def test_marks_re_review_needed_after_push_since_clean_review(self) -> None: } route = apply_copilot_review_gate( - "open-telemetry/example", - 7, facts, "maintainer", enabled=True, @@ -427,8 +431,6 @@ def test_marks_re_review_needed_before_reviewer_handoff(self) -> None: } route = apply_copilot_review_gate( - "open-telemetry/example", - 7, facts, "approver", enabled=True, @@ -445,8 +447,6 @@ def test_pending_re_review_waits_without_duplicate_request(self) -> None: } route = apply_copilot_review_gate( - "open-telemetry/example", - 7, facts, "maintainer", enabled=True, @@ -463,8 +463,6 @@ def test_current_head_clean_review_moves_to_maintainers(self) -> None: } route = apply_copilot_review_gate( - "open-telemetry/example", - 7, facts, "maintainer", enabled=True, @@ -481,8 +479,6 @@ def test_disabled_gate_preserves_maintainer_route(self) -> None: } route = apply_copilot_review_gate( - "open-telemetry/example", - 7, facts, "maintainer", enabled=False, diff --git a/.github/scripts/pull-request-dashboard/test_delivery.py b/.github/scripts/pull-request-dashboard/test_delivery.py index a7206b24156..9a91e330ae2 100644 --- a/.github/scripts/pull-request-dashboard/test_delivery.py +++ b/.github/scripts/pull-request-dashboard/test_delivery.py @@ -1,7 +1,6 @@ from __future__ import annotations from pathlib import Path -import tempfile import unittest from unittest.mock import ANY, Mock, call, patch @@ -23,21 +22,23 @@ def test_runs_all_targeted_deliveries_in_order( slack, ) -> None: order = Mock() - status_comments.side_effect = lambda *_args: order("status") or [] - author_nudges.side_effect = lambda *_args: order("author") or [] - copilot_reviews.side_effect = lambda *_args: order("copilot") or [] - slack.side_effect = lambda *_args: order("slack") or [] - with tempfile.TemporaryDirectory() as temp_dir: - errors_file = Path(temp_dir) / "errors" - status = delivery.deliver_from_state( - "open-telemetry/example", - Path(temp_dir) / "author", - Path(temp_dir) / "copilot", - Path(temp_dir) / "slack", - errors_file, - ) - self.assertEqual(0, status) + def record(label: str) -> list[str]: + order(label) + return [] + + status_comments.side_effect = lambda *_args: record("status") + author_nudges.side_effect = lambda *_args: record("author") + copilot_reviews.side_effect = lambda *_args: record("copilot") + slack.side_effect = lambda *_args: record("slack") + errors = delivery.deliver_from_state( + "open-telemetry/example", + Path("author"), + Path("copilot"), + Path("slack"), + ) + + self.assertEqual([], errors) self.assertEqual( [call("author"), call("status"), call("copilot"), call("slack")], order.call_args_list, @@ -68,22 +69,44 @@ def test_failure_does_not_block_later_deliveries( copilot_reviews, slack, ) -> None: - with tempfile.TemporaryDirectory() as temp_dir: - errors_file = Path(temp_dir) / "errors" - delivery.deliver_from_state( - "open-telemetry/example", - Path(temp_dir) / "author", - Path(temp_dir) / "copilot", - Path(temp_dir) / "slack", - errors_file, - ) - errors = errors_file.read_text(encoding="utf-8") + errors = delivery.deliver_from_state( + "open-telemetry/example", + Path("author"), + Path("copilot"), + Path("slack"), + ) self.assertIn("status comments: boom", errors) author_nudges.assert_called_once() copilot_reviews.assert_called_once() slack.assert_called_once() + @patch.object(delivery.sys, "stderr") + @patch.object(delivery, "deliver_from_state", return_value=["status comments: boom"]) + @patch.object(delivery.state_branch, "push_state_changes") + def test_reports_delivery_errors_after_state_push( + self, + push_state_changes, + _deliver_from_state, + _stderr, + ) -> None: + push_state_changes.side_effect = ( + lambda _state_dir, _message, update_state, **_kwargs: update_state() + ) + + with ( + patch.object(delivery, "author_nudge_state_path", return_value=Path("author")), + patch.object(delivery, "copilot_review_request_state_path", return_value=Path("copilot")), + patch.object(delivery, "notification_state_path", return_value=Path("slack")), + ): + status = delivery.deliver_with_state( + "open-telemetry/example", + "dashboard-state", + Path("state"), + ) + + self.assertEqual(1, status) + if __name__ == "__main__": unittest.main() \ No newline at end of file diff --git a/.github/scripts/pull-request-dashboard/test_github_cli.py b/.github/scripts/pull-request-dashboard/test_github_cli.py index 4a02b565fc1..ba15bd62409 100644 --- a/.github/scripts/pull-request-dashboard/test_github_cli.py +++ b/.github/scripts/pull-request-dashboard/test_github_cli.py @@ -21,17 +21,12 @@ 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) + request_copilot_review("PR_node_id") - gh_api.assert_called_once_with("/repos/open-telemetry/example/pulls/7") graphql.assert_called_once_with( ANY, { @@ -585,6 +580,7 @@ def test_fetch_pr_review_data_normalizes_paginated_reviews_and_metadata(self, gr { "fullDatabaseId": "4700712792", "commit": {"oid": "reviewed-head-1"}, + "comments": {"totalCount": 1}, "url": "https://example.test/review/4700712792", "author": {"login": "reviewer-1"}, "state": "COMMENTED", @@ -613,6 +609,7 @@ def test_fetch_pr_review_data_normalizes_paginated_reviews_and_metadata(self, gr { "fullDatabaseId": "5000000000", "commit": {"oid": "reviewed-head-2"}, + "comments": {"totalCount": 0}, "url": "https://example.test/review/5000000000", "author": {"login": "reviewer-2"}, "state": "APPROVED", @@ -636,6 +633,7 @@ def test_fetch_pr_review_data_normalizes_paginated_reviews_and_metadata(self, gr { "id": 4700712792, "commit_id": "reviewed-head-1", + "finding_count": 1, "url": "https://example.test/review/4700712792", "user": {"login": "reviewer-1"}, "state": "COMMENTED", @@ -646,6 +644,7 @@ def test_fetch_pr_review_data_normalizes_paginated_reviews_and_metadata(self, gr { "id": 5000000000, "commit_id": "reviewed-head-2", + "finding_count": 0, "url": "https://example.test/review/5000000000", "user": {"login": "reviewer-2"}, "state": "APPROVED", @@ -660,6 +659,9 @@ def test_fetch_pr_review_data_normalizes_paginated_reviews_and_metadata(self, gr }, }, ) + review_query = graphql.call_args_list[0].args[0] + self.assertIn("comments {", review_query) + self.assertIn("totalCount", review_query) self.assertEqual(graphql.call_args_list[1].args[1]["after"], "cursor-1") self.assertEqual(graphql.call_count, 2) From 55ec573d05f4f551b07e9afb42fa66554b5c046d Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Mon, 20 Jul 2026 22:01:30 -0700 Subject: [PATCH 17/44] Address review comment from copilot-pull-request-reviewer: drain queued closed PR updates --- .../pr_status_comment.py | 6 ++- .../test_pr_status_comment.py | 37 +++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/pr_status_comment.py b/.github/scripts/pull-request-dashboard/pr_status_comment.py index e9bdfeda149..903f7d11a2e 100644 --- a/.github/scripts/pull-request-dashboard/pr_status_comment.py +++ b/.github/scripts/pull-request-dashboard/pr_status_comment.py @@ -319,6 +319,7 @@ def update_status_comments_from_state( saved_rollout_state = load_status_comment_rollout_state() if open_pr_numbers is None: raise RuntimeError("open PR numbers are required for a status comment update") + queued_pr_numbers = set(saved_rollout_state.get("pending_pr_numbers") or []) rollout_state = prepare_rollout_state(saved_rollout_state, open_pr_numbers) if pr_number is not None: publish_pr_status(repo, pr_number, dashboard_state) @@ -331,7 +332,8 @@ def update_status_comments_from_state( save_status_comment_rollout_state(rollout_state) return [] - rollout_pr_numbers = rollout_state["pending_pr_numbers"][:STATUS_COMMENT_ROLLOUT_BATCH_SIZE] + pending_pr_numbers = set(rollout_state["pending_pr_numbers"]) | queued_pr_numbers + rollout_pr_numbers = sorted(pending_pr_numbers)[:STATUS_COMMENT_ROLLOUT_BATCH_SIZE] successful_pr_numbers: set[int] = set() errors: list[str] = [] for number in rollout_pr_numbers: @@ -342,7 +344,7 @@ def update_status_comments_from_state( else: successful_pr_numbers.add(number) - pending = set(rollout_state["pending_pr_numbers"]) - successful_pr_numbers + pending = pending_pr_numbers - successful_pr_numbers rollout_state["pending_pr_numbers"] = sorted(pending) if not pending: rollout_state["completed_revision"] = STATUS_COMMENT_REVISION 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 35ef3880a3a..003d3ca7afd 100644 --- a/.github/scripts/pull-request-dashboard/test_pr_status_comment.py +++ b/.github/scripts/pull-request-dashboard/test_pr_status_comment.py @@ -493,6 +493,43 @@ def test_current_revision_drops_closed_prs_from_queue(self) -> None: self.assertEqual([34], state["pending_pr_numbers"]) + @patch.object(pr_status_comment, "save_status_comment_rollout_state") + @patch.object(pr_status_comment, "publish_pr_status") + @patch.object(pr_status_comment, "load_dashboard_state_cache", return_value={"prs": {}}) + @patch.object( + pr_status_comment, + "load_status_comment_rollout_state", + return_value={ + "target_revision": pr_status_comment.STATUS_COMMENT_REVISION, + "completed_revision": 0, + "pending_pr_numbers": [12, 34], + }, + ) + def test_rollout_drains_queued_closed_pr( + self, + _load_rollout: object, + _load_dashboard: object, + publish_pr_status: Mock, + save_rollout: Mock, + ) -> None: + status = pr_status_comment.update_status_comments_from_state( + "open-telemetry/example", + None, + {34}, + ) + + self.assertEqual([], status) + self.assertEqual( + [12, 34], + [call.args[1] for call in publish_pr_status.call_args_list], + ) + saved_state = save_rollout.call_args.args[0] + self.assertEqual([], saved_state["pending_pr_numbers"]) + self.assertEqual( + pr_status_comment.STATUS_COMMENT_REVISION, + saved_state["completed_revision"], + ) + @patch.object(pr_status_comment, "save_status_comment_rollout_state") @patch.object(pr_status_comment, "publish_pr_status") @patch.object(pr_status_comment, "load_dashboard_state_cache", return_value={"prs": {}}) From 1cfaa29cb5ec65f6a93758012b4522a327cd9f54 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Mon, 20 Jul 2026 22:06:54 -0700 Subject: [PATCH 18/44] Improve author reminder comment wording --- .github/scripts/pull-request-dashboard/author_nudge.py | 8 ++++++-- pull-request-dashboard/README.md | 6 ++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/author_nudge.py b/.github/scripts/pull-request-dashboard/author_nudge.py index 7a33d58a811..08234e9b4eb 100644 --- a/.github/scripts/pull-request-dashboard/author_nudge.py +++ b/.github/scripts/pull-request-dashboard/author_nudge.py @@ -82,9 +82,13 @@ def existing_nudge_comment( def render_nudge(author: str, status_url: str, waiting_since: str) -> str: return "\n".join([ nudge_marker(waiting_since), - f"@{author}, this pull request has been waiting on your follow-up for one week.", + f"Hi @{author} — just a friendly reminder that this pull request has " + "been waiting on you for a week.", "", - f"See the [dashboard status comment]({status_url}) for the remaining items.", + f"There are still items that need your attention. See the " + f"[dashboard status comment]({status_url}) for the full list. Once " + "you've addressed them (or replied with an update), the dashboard will " + "route it back to reviewers.", "", ]) diff --git a/pull-request-dashboard/README.md b/pull-request-dashboard/README.md index 451f638732f..3db5e86c457 100644 --- a/pull-request-dashboard/README.md +++ b/pull-request-dashboard/README.md @@ -195,8 +195,10 @@ Targeted updates received before the first full dashboard run are ignored. ## Author reminder The dashboard posts one reminder when a pull request remains in *Waiting on -authors* for one week. The reminder @-mentions the author and links to the -dashboard-managed status comment containing the remaining items. +authors* for one week. The reminder @-mentions the author, links to the +dashboard-managed status comment containing the remaining items, and notes that +addressing them (or replying with an update) routes the pull request back to +reviewers. Leaving *Waiting on authors* resets the one-week clock. If the pull request later returns to *Waiting on authors* and remains there for another week, the From d3a588e0c7f72ce86270dc77433e3b3088b4de17 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Mon, 20 Jul 2026 22:07:52 -0700 Subject: [PATCH 19/44] Clarify author reminder routes PR back to reviewers automatically --- .github/scripts/pull-request-dashboard/author_nudge.py | 2 +- pull-request-dashboard/README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/author_nudge.py b/.github/scripts/pull-request-dashboard/author_nudge.py index 08234e9b4eb..120ed4aa883 100644 --- a/.github/scripts/pull-request-dashboard/author_nudge.py +++ b/.github/scripts/pull-request-dashboard/author_nudge.py @@ -88,7 +88,7 @@ def render_nudge(author: str, status_url: str, waiting_since: str) -> str: f"There are still items that need your attention. See the " f"[dashboard status comment]({status_url}) for the full list. Once " "you've addressed them (or replied with an update), the dashboard will " - "route it back to reviewers.", + "automatically route it back to reviewers.", "", ]) diff --git a/pull-request-dashboard/README.md b/pull-request-dashboard/README.md index 3db5e86c457..7786a7640bf 100644 --- a/pull-request-dashboard/README.md +++ b/pull-request-dashboard/README.md @@ -197,8 +197,8 @@ Targeted updates received before the first full dashboard run are ignored. The dashboard posts one reminder when a pull request remains in *Waiting on authors* for one week. The reminder @-mentions the author, links to the dashboard-managed status comment containing the remaining items, and notes that -addressing them (or replying with an update) routes the pull request back to -reviewers. +addressing them (or replying with an update) automatically routes the pull +request back to reviewers. Leaving *Waiting on authors* resets the one-week clock. If the pull request later returns to *Waiting on authors* and remains there for another week, the From 094da4979ed91bebc3eb3447ace9eec84970422b Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 21 Jul 2026 06:36:52 -0700 Subject: [PATCH 20/44] Let authors and approvers route a PR to reviewers Add a comment-driven reviewer-routing override for the pull request dashboard: - The PR author or a member of the repository's approver_teams can comment /dashboard route:reviewers to move a waiting-on-author PR to waiting on reviewers; the dashboard applies the dashboard:route-overridden label. - The label is released automatically once routing reaches reviewers, and removing it restores automatic routing. - Unauthorized and unrecognized /dashboard commands get a one-time reply, and an author/approver command on a PR not waiting on the author is answered with where it is currently routed. - The command is advertised in the author nudge and the live status comment. - Refresh on pull_request labeled/unlabeled webhook events. --- .../pull-request-dashboard/author_nudge.py | 11 +- .../pull-request-dashboard/dashboard.py | 30 +- .../dashboard_override.py | 341 +++++++++++++++ .../pull-request-dashboard/delivery.py | 14 + .../pull-request-dashboard/github_cli.py | 2 +- .../netlify/functions/github-webhook.js | 9 +- .../pr_status_comment.py | 22 +- .../test_author_nudge.py | 13 + .../test_dashboard_override.py | 398 ++++++++++++++++++ .../pull-request-dashboard/test_delivery.py | 14 +- .../test_github_webhook.js | 10 +- .../test_pr_status_comment.py | 7 + pull-request-dashboard/README.md | 24 ++ 13 files changed, 876 insertions(+), 19 deletions(-) create mode 100644 .github/scripts/pull-request-dashboard/dashboard_override.py create mode 100644 .github/scripts/pull-request-dashboard/test_dashboard_override.py diff --git a/.github/scripts/pull-request-dashboard/author_nudge.py b/.github/scripts/pull-request-dashboard/author_nudge.py index 120ed4aa883..427d84fcecc 100644 --- a/.github/scripts/pull-request-dashboard/author_nudge.py +++ b/.github/scripts/pull-request-dashboard/author_nudge.py @@ -9,6 +9,7 @@ from typing import Any from github_cli import gh_api, run_gh +from dashboard_override import author_override_guidance from pr_status_comment import ( DASHBOARD_APP_SLUG, managed_status_comments, @@ -86,9 +87,13 @@ def render_nudge(author: str, status_url: str, waiting_since: str) -> str: "been waiting on you for a week.", "", f"There are still items that need your attention. See the " - f"[dashboard status comment]({status_url}) for the full list. Once " - "you've addressed them (or replied with an update), the dashboard will " - "automatically route it back to reviewers.", + f"[dashboard status comment]({status_url}) for the full list. You don't " + "need to push a code change to hand it back — replying to move each " + "discussion forward is enough, whether that's answering a question, " + "explaining why no change is needed, or asking a follow-up. The " + "dashboard then automatically routes it back to reviewers.", + "", + author_override_guidance(), "", ]) diff --git a/.github/scripts/pull-request-dashboard/dashboard.py b/.github/scripts/pull-request-dashboard/dashboard.py index f119626c7fa..cdfec16d543 100644 --- a/.github/scripts/pull-request-dashboard/dashboard.py +++ b/.github/scripts/pull-request-dashboard/dashboard.py @@ -199,6 +199,12 @@ is_copilot_reviewer, record_copilot_review_observation, ) +from dashboard_override import ( + apply_dashboard_override, + append_route_noop_reply, + dashboard_override_facts, + parse_dashboard_command, +) from state import ( INITIAL_BACKFILL_COMPLETE_KEY, empty_state, @@ -383,6 +389,8 @@ def normalize_events(raw: dict[str, Any], author: str, reviewers: set[str]) -> l for c in raw["issue_comments"]: if c.get("minimized"): continue + if parse_dashboard_command(c) is not None: + continue login = reviewer_actor_login(c.get("user") or {}) timestamp = ( c.get("content_updated_at") @@ -528,6 +536,8 @@ def compute_facts( raw: dict[str, Any], author: str, events: list[dict[str, Any]], + reviewers: set[str] | None = None, + previous_facts: dict[str, Any] | None = None, ) -> dict[str, Any]: pr = raw["pr"] checks = raw["checks"] @@ -548,10 +558,16 @@ def compute_facts( raw.get("reviews") or [], head_sha, ) + labels = { + label.get("name") or "" + for label in pr.get("labels") or [] + if isinstance(label, dict) + } facts = { "author": author, "assignees": assignees, "head_sha": head_sha, + **dashboard_override_facts(raw, author, labels, previous_facts, reviewers or set()), "copilot_review_requested": any( is_copilot_reviewer(request) for request in (pr.get("reviewRequests") or []) @@ -1391,6 +1407,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, + previous_facts: dict[str, Any] | None = None, require_clean_copilot_review: bool = False, ) -> dict[str, Any] | None: number = pr_summary["number"] @@ -1406,7 +1423,7 @@ def build_pr_result( return None author = effective_author(raw) events = normalize_events(raw, author, reviewers) - facts = compute_facts(raw, author, events) + facts = compute_facts(raw, author, events, reviewers, previous_facts) review_threads = group_review_threads(raw, author, reviewers, facts) top_level_items = derive_top_level_items(events, facts) top_level_author_comment_items = derive_top_level_author_comment_items( @@ -1482,11 +1499,15 @@ def build_pr_result( "route": "unknown", "error": f"{len(failed_classifications)} discussion classification(s) failed", } - route = apply_copilot_review_gate( + route = apply_dashboard_override( facts, - route_pr(facts, pending_actions, required_approvals), - enabled=require_clean_copilot_review, + apply_copilot_review_gate( + facts, + route_pr(facts, pending_actions, required_approvals), + enabled=require_clean_copilot_review, + ), ) + append_route_noop_reply(raw, facts, route) add_wait_age_facts(facts, route, pending_actions) facts["author_action_review_thread_urls"] = author_action_discussion_urls( review_threads, pending_actions @@ -1582,6 +1603,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 {}, + previous_facts=(starting_pr_result or {}).get("facts") or {}, require_clean_copilot_review=require_clean_copilot_review, ) if trigger_pr_result is None: diff --git a/.github/scripts/pull-request-dashboard/dashboard_override.py b/.github/scripts/pull-request-dashboard/dashboard_override.py new file mode 100644 index 00000000000..6e0e5ec0c11 --- /dev/null +++ b/.github/scripts/pull-request-dashboard/dashboard_override.py @@ -0,0 +1,341 @@ +"""Recognize and deliver explicit reviewer-routing overrides.""" + +from __future__ import annotations + +import re +from typing import Any +from urllib.parse import quote + +from github_cli import gh_api, run_gh +from state import load_dashboard_state_cache +from utils import actor_login + + +DASHBOARD_OVERRIDE_LABEL = "dashboard:route-overridden" +DASHBOARD_COMMAND_PREFIX = "/dashboard" +DASHBOARD_OVERRIDE_COMMAND = "/dashboard route:reviewers" +DASHBOARD_OVERRIDE_SUBCOMMAND = "route:reviewers" +DASHBOARD_OVERRIDE_LABEL_COLOR = "1D76DB" +DASHBOARD_OVERRIDE_LABEL_DESCRIPTION = "Routing manually overridden to reviewers" +# Mirrors pr_status_comment.DASHBOARD_APP_SLUG; duplicated here to avoid a +# circular import between the two modules. +DASHBOARD_APP_SLUG = "opentelemetry-pr-dashboard" +COMMAND_REPLY_MARKER_PREFIX = "" +) + + +def author_override_guidance(timestamp_hint: str = "") -> str: + location = f" ({timestamp_hint})" if timestamp_hint else "" + return ( + "If you believe this pull request is incorrectly routed as waiting on " + f"the author{location}, comment `/dashboard route:reviewers` to route it " + "from waiting on the author to waiting on reviewers." + ) + + +def parse_dashboard_command(comment: dict[str, Any]) -> str | None: + """Return the subcommand of a `/dashboard` command, or None. + + Returns the (possibly empty) subcommand token when the comment's first + line is a `/dashboard` command, and None when it is not a command at all. + """ + if comment.get("minimized"): + return None + lines = (comment.get("body") or "").strip().splitlines() + if not lines: + return None + tokens = lines[0].strip().split() + if not tokens or tokens[0] != DASHBOARD_COMMAND_PREFIX: + return None + return tokens[1] if len(tokens) > 1 else "" + + +def is_authorized_commander(login: str, author: str, reviewers: set[str] | None) -> bool: + low = (login or "").lower() + return bool(low) and (low == author.lower() or low in (reviewers or set())) + + +def latest_authorized_command( + raw: dict[str, Any], + author: str, + reviewers: set[str] | None, +) -> tuple[int, str]: + best_id = 0 + best_user = "" + for comment in raw.get("issue_comments") or []: + if parse_dashboard_command(comment) != DASHBOARD_OVERRIDE_SUBCOMMAND: + continue + commenter = actor_login(comment.get("user") or {}) + if not is_authorized_commander(commenter, author, reviewers): + continue + try: + comment_id = int(comment.get("id")) + except (TypeError, ValueError): + continue + if comment_id > best_id: + best_id, best_user = comment_id, commenter + return best_id, best_user + + +def dashboard_override_facts( + raw: dict[str, Any], + author: str, + labels: set[str], + previous_facts: dict[str, Any] | None, + reviewers: set[str] | None = None, +) -> dict[str, Any]: + label_applied = DASHBOARD_OVERRIDE_LABEL in labels + previous_facts = previous_facts or {} + previous_command_id = int( + previous_facts.get("dashboard_override_command_id") or 0 + ) + latest_id, latest_user = latest_authorized_command(raw, author, reviewers) + command_id = max(previous_command_id, latest_id) + if latest_id > previous_command_id: + command_user = latest_user + else: + command_user = str(previous_facts.get("dashboard_override_command_user") or "") + label_requested = ( + not label_applied + and ( + command_id > previous_command_id + or bool(previous_facts.get("dashboard_override_requested")) + ) + ) + return { + "dashboard_override": label_applied, + "dashboard_override_label_applied": label_applied, + "dashboard_override_command_id": command_id, + "dashboard_override_command_user": command_user, + "dashboard_override_requested": label_requested, + "dashboard_override_release_requested": False, + "dashboard_command_replies": pending_command_replies(raw, author, reviewers), + } + + +def pending_command_replies( + raw: dict[str, Any], + author: str, + reviewers: set[str] | None = None, +) -> list[dict[str, Any]]: + """Return replies owed to unsupported or unauthorized `/dashboard` commands. + + `/dashboard route:reviewers` from the author or an approver is handled by the + override flow and never gets a reply here. The same command from anyone else + gets an unauthorized reply, and any unrecognized `/dashboard` subcommand gets + an unknown-command reply. Commands that already have a reply comment are + skipped so replies are posted at most once. + """ + comments = raw.get("issue_comments") or [] + replied_ids: set[int] = set() + for comment in comments: + for match in _COMMAND_REPLY_MARKER_RE.findall(comment.get("body") or ""): + replied_ids.add(int(match)) + + replies: list[dict[str, Any]] = [] + for comment in comments: + subcommand = parse_dashboard_command(comment) + if subcommand is None: + continue + try: + comment_id = int(comment.get("id")) + except (TypeError, ValueError): + continue + if comment_id in replied_ids: + continue + commenter = actor_login(comment.get("user") or {}) + if subcommand == DASHBOARD_OVERRIDE_SUBCOMMAND: + if is_authorized_commander(commenter, author, reviewers): + continue + kind = "unauthorized" + else: + kind = "unknown_command" + replies.append({ + "comment_id": comment_id, + "kind": kind, + "user": commenter, + "subcommand": subcommand, + }) + return replies + + +def command_reply_marker(comment_id: int) -> str: + return f"{COMMAND_REPLY_MARKER_PREFIX}{comment_id} -->" + + +ROUTE_ALREADY_ROUTED_PHRASE = { + "approver": "already waiting on reviewers", + "maintainer": "already past review and waiting on maintainers", + "external": "waiting on an external dependency, not on you", + "copilot": "waiting on an automated Copilot review", +} + + +def render_command_reply(reply: dict[str, Any]) -> str: + user = reply.get("user") or "" + mention = f"@{user} " if user else "" + kind = reply.get("kind") + if kind == "unauthorized": + message = ( + "only the pull request author or a member of an approving team can " + "use `/dashboard route:reviewers`." + ) + elif kind == "already_routed": + where = ROUTE_ALREADY_ROUTED_PHRASE.get( + reply.get("route") or "", "not currently waiting on you" + ) + message = ( + f"this pull request is {where}, so `/dashboard route:reviewers` had " + "no effect. The command only applies while the pull request is " + "waiting on you." + ) + else: + subcommand = reply.get("subcommand") or "" + attempted = DASHBOARD_COMMAND_PREFIX + (f" {subcommand}" if subcommand else "") + message = ( + f"`{attempted}` is not a recognized dashboard command. The only " + "supported command is `/dashboard route:reviewers`, which the pull " + "request author can use to move a pull request from waiting on the " + "author to waiting on reviewers." + ) + return "\n".join([ + command_reply_marker(int(reply["comment_id"])), + f"{mention}{message}", + "", + ]) + + +def command_reply_exists( + comments: list[dict[str, Any]] | None, + comment_id: int, +) -> bool: + marker = command_reply_marker(comment_id) + return any( + (comment.get("performed_via_github_app") or {}).get("slug") == DASHBOARD_APP_SLUG + and marker in (comment.get("body") or "") + for comment in comments or [] + ) + + +def deliver_dashboard_command_replies(repo: str) -> list[str]: + dashboard_state = load_dashboard_state_cache() + if dashboard_state is None: + return [] + errors: list[str] = [] + for key, result in sorted( + (dashboard_state.get("prs") or {}).items(), + key=lambda item: int(item[0]), + ): + replies = ((result or {}).get("facts") or {}).get("dashboard_command_replies") or [] + if not replies: + continue + pr_number = int(key) + try: + comments = gh_api( + f"/repos/{repo}/issues/{pr_number}/comments?per_page=100", + paginate=True, + ) + except Exception as e: + errors.append(f"PR #{pr_number}: {e}") + continue + for reply in replies: + try: + if command_reply_exists(comments, int(reply["comment_id"])): + continue + run_gh([ + "gh", "api", "--method", "POST", + f"repos/{repo}/issues/{pr_number}/comments", + "-f", f"body={render_command_reply(reply)}", + ]) + except Exception as e: + errors.append(f"PR #{pr_number}: {e}") + return errors + + +def apply_dashboard_override(facts: dict[str, Any], route: str) -> str: + label_applied = bool(facts.get("dashboard_override_label_applied")) + requested = bool(facts.get("dashboard_override_requested")) + command_applies = requested and route == "author" + # A pending author command that cannot apply because the pull request is not + # waiting on its author is a no-op; the author is told where it is routed. + facts["dashboard_override_noop"] = requested and not command_applies + if requested and not command_applies: + facts["dashboard_override_requested"] = False + facts["dashboard_override"] = label_applied or command_applies + # Once automatic routing would place the pull request with reviewers on its + # own, a lingering override label is redundant. Release it so a forgotten + # override does not hold the pull request at reviewers forever. + facts["dashboard_override_release_requested"] = label_applied and route == "approver" + return "approver" if facts["dashboard_override"] else route + + +def append_route_noop_reply( + raw: dict[str, Any], + facts: dict[str, Any], + route: str, +) -> None: + if not facts.get("dashboard_override_noop"): + return + command_id = int(facts.get("dashboard_override_command_id") or 0) + if not command_id: + return + replied_ids: set[int] = set() + for comment in raw.get("issue_comments") or []: + for match in _COMMAND_REPLY_MARKER_RE.findall(comment.get("body") or ""): + replied_ids.add(int(match)) + if command_id in replied_ids: + return + replies = facts.setdefault("dashboard_command_replies", []) + if any(reply.get("comment_id") == command_id for reply in replies): + return + replies.append({ + "comment_id": command_id, + "kind": "already_routed", + "user": facts.get("dashboard_override_command_user") or facts.get("author") or "", + "route": route, + }) + + +def deliver_dashboard_override_requests(repo: str) -> list[str]: + try: + run_gh([ + "gh", "label", "create", DASHBOARD_OVERRIDE_LABEL, + "--repo", repo, + "--color", DASHBOARD_OVERRIDE_LABEL_COLOR, + "--description", DASHBOARD_OVERRIDE_LABEL_DESCRIPTION, + "--force", + ]) + except Exception as e: + return [f"label: {e}"] + + dashboard_state = load_dashboard_state_cache() + if dashboard_state is None: + return [] + errors: list[str] = [] + for key, result in sorted( + (dashboard_state.get("prs") or {}).items(), + key=lambda item: int(item[0]), + ): + facts = (result or {}).get("facts") or {} + pr_number = int(key) + if facts.get("dashboard_override_requested"): + try: + run_gh([ + "gh", "api", "--method", "POST", + f"repos/{repo}/issues/{pr_number}/labels", + "-f", f"labels[]={DASHBOARD_OVERRIDE_LABEL}", + ]) + except Exception as e: + errors.append(f"PR #{pr_number}: {e}") + elif facts.get("dashboard_override_release_requested"): + try: + run_gh([ + "gh", "api", "--method", "DELETE", + f"repos/{repo}/issues/{pr_number}/labels/{quote(DASHBOARD_OVERRIDE_LABEL)}", + ]) + except Exception as e: + if "404" not in str(e): + errors.append(f"PR #{pr_number}: {e}") + return errors \ No newline at end of file diff --git a/.github/scripts/pull-request-dashboard/delivery.py b/.github/scripts/pull-request-dashboard/delivery.py index 66271ca4bbc..c3b6dfcff81 100644 --- a/.github/scripts/pull-request-dashboard/delivery.py +++ b/.github/scripts/pull-request-dashboard/delivery.py @@ -11,6 +11,10 @@ from author_nudge import deliver_prepared_author_nudges from copilot_review import deliver_copilot_review_requests +from dashboard_override import ( + deliver_dashboard_command_replies, + deliver_dashboard_override_requests, +) from github_cli import detect_repo, list_all_open_pr_numbers, normalize_repo, repo_state_key from notify_slack import notify_slack_from_state from pr_status_comment import update_status_comments_from_state @@ -47,6 +51,16 @@ def deliver_from_state( ) -> list[str]: now = utc_now() errors: list[str] = [] + run_delivery_action( + "dashboard overrides", + lambda: deliver_dashboard_override_requests(repo), + errors, + ) + run_delivery_action( + "dashboard command replies", + lambda: deliver_dashboard_command_replies(repo), + errors, + ) run_delivery_action( "author nudges", lambda: deliver_prepared_author_nudges( diff --git a/.github/scripts/pull-request-dashboard/github_cli.py b/.github/scripts/pull-request-dashboard/github_cli.py index 6938df51bdd..8f643ff02d5 100644 --- a/.github/scripts/pull-request-dashboard/github_cli.py +++ b/.github/scripts/pull-request-dashboard/github_cli.py @@ -137,7 +137,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", "reviewRequests", "assignees", "baseRefName", + "reviewDecision", "reviewRequests", "assignees", "baseRefName", "labels", ]) cmd = ["gh", "pr", "view", str(number), "--repo", repo, "--json", fields] last: dict[str, Any] = {} diff --git a/.github/scripts/pull-request-dashboard/netlify/functions/github-webhook.js b/.github/scripts/pull-request-dashboard/netlify/functions/github-webhook.js index 13df9bdfd99..251454e232e 100644 --- a/.github/scripts/pull-request-dashboard/netlify/functions/github-webhook.js +++ b/.github/scripts/pull-request-dashboard/netlify/functions/github-webhook.js @@ -15,11 +15,13 @@ const ALLOWED_ACTIONS = { "closed", "converted_to_draft", "edited", + "labeled", "opened", "ready_for_review", "reopened", "synchronize", "unassigned", + "unlabeled", ]), issue_comment: new Set(["created", "edited", "deleted"]), pull_request_review: new Set(["submitted", "edited", "dismissed"]), @@ -37,6 +39,7 @@ exports.handler = async (event) => { }; exports.isDashboardSelfTriggeredCommentEvent = isDashboardSelfTriggeredCommentEvent; +exports.isAllowedAction = isAllowedAction; async function handle(event) { if (event.httpMethod !== "POST") { @@ -64,7 +67,7 @@ async function handle(event) { const payload = parseJson(rawBody); const action = payload.action; - if (!ALLOWED_ACTIONS[eventName].has(action)) { + if (!isAllowedAction(eventName, action)) { return response(202, { status: "ignored", reason: `unsupported action: ${eventName}.${action || "missing"}` }); } if (isDashboardSelfTriggeredCommentEvent(eventName, payload)) { @@ -101,6 +104,10 @@ async function handle(event) { }); } +function isAllowedAction(eventName, action) { + return Boolean(ALLOWED_ACTIONS[eventName] && ALLOWED_ACTIONS[eventName].has(action)); +} + function isDashboardSelfTriggeredCommentEvent(eventName, payload) { if (eventName !== "issue_comment") { return false; diff --git a/.github/scripts/pull-request-dashboard/pr_status_comment.py b/.github/scripts/pull-request-dashboard/pr_status_comment.py index 903f7d11a2e..e4a7cfa3ee0 100644 --- a/.github/scripts/pull-request-dashboard/pr_status_comment.py +++ b/.github/scripts/pull-request-dashboard/pr_status_comment.py @@ -11,6 +11,7 @@ gh_api, run_gh, ) +from dashboard_override import author_override_guidance from route_presentation import route_status_summary from state import ( load_dashboard_state_cache, @@ -24,7 +25,7 @@ STATUS_MARKER = "" # Increment whenever render_status_comment changes in a way existing comments # need to adopt. Hourly runs durably roll the revision out to all open PRs. -STATUS_COMMENT_REVISION = 9 +STATUS_COMMENT_REVISION = 10 STATUS_COMMENT_ROLLOUT_BATCH_SIZE = 50 AUTHOR_ACTION_FEEDBACK_LINK_LIMIT = 20 NON_BLOCKING_CHECK_FAILURE_LIMIT = 20 @@ -32,8 +33,8 @@ STATUS_REPORT_ISSUE_URL = "https://github.com/open-telemetry/shared-workflows/issues/new" STATUS_REPORT_ISSUE_TEMPLATE = "incorrect-pr-dashboard-result.md" AUTHOR_GUIDANCE = ( - "For each item, link to the commit that addresses it, explain why no change is needed, " - "or ask a follow-up question." + "For each item, reply to move the discussion forward, e.g. link to the commit " + "that addresses it, explain why no change is needed, or ask a follow-up question." ) DASHBOARD_APP_SLUG = "opentelemetry-pr-dashboard" # Remove after migrating open PRs as described by the post-rollout @@ -44,17 +45,20 @@ ) -def accuracy_note(pr: dict[str, Any]) -> str: +def accuracy_note(pr: dict[str, Any], author_routed: bool = False) -> str: query = urlencode({ "template": STATUS_REPORT_ISSUE_TEMPLATE, "title": "PR dashboard result looks incorrect", "body": f"PR: {pr.get('html_url') or ''}\n\nWhat looks incorrect:\n", }) report_url = f"{STATUS_REPORT_ISSUE_URL}?{query}" - return ( - "_This automated status or its linked feedback items may be incorrect. " - f"If something looks wrong, [report it]({report_url}) with the result you expected._" + note = ( + "This automated status or its linked feedback items may be incorrect. " + f"If something looks wrong, [report it]({report_url}) with the result you expected." ) + if author_routed: + note += " " + author_override_guidance("see the last refreshed time above") + return f"_{note}_" def render_status_comment( @@ -96,6 +100,7 @@ def render_status_comment( ) feedback_indent: str | None = None + author_routed = False if pr.get("merged"): summary = ["- **Status:** Merged."] @@ -114,6 +119,7 @@ def render_status_comment( else: route = result.get("route") or "unknown" if route == "author": + author_routed = True waiting_on, fallback_next_step = route_status_summary(route) check_action = None if failing_count: @@ -187,7 +193,7 @@ def render_status_comment( ) ) lines.append("") - lines.append(accuracy_note(pr)) + lines.append(accuracy_note(pr, author_routed)) lines.append("") return "\n".join(lines) diff --git a/.github/scripts/pull-request-dashboard/test_author_nudge.py b/.github/scripts/pull-request-dashboard/test_author_nudge.py index 8ad5be272b3..38f990844b4 100644 --- a/.github/scripts/pull-request-dashboard/test_author_nudge.py +++ b/.github/scripts/pull-request-dashboard/test_author_nudge.py @@ -21,6 +21,19 @@ def author_result(route: str = "author") -> dict: class AuthorNudgePolicyTest(unittest.TestCase): + def test_nudge_advertises_dashboard_override_command(self) -> None: + body = author_nudge.render_nudge( + "alice", + "https://example.test/status", + "2026-07-10T00:00:00+00:00", + ) + + self.assertIn( + "comment `/dashboard route:reviewers` to route it from waiting on the " + "author to waiting on reviewers", + body, + ) + def test_first_author_route_observation_starts_clock(self) -> None: due, entry = author_nudge.plan_nudge(author_result(), None, NOW) diff --git a/.github/scripts/pull-request-dashboard/test_dashboard_override.py b/.github/scripts/pull-request-dashboard/test_dashboard_override.py new file mode 100644 index 00000000000..0a7c8483810 --- /dev/null +++ b/.github/scripts/pull-request-dashboard/test_dashboard_override.py @@ -0,0 +1,398 @@ +from __future__ import annotations + +import unittest +from unittest.mock import call, patch + +import dashboard_override + + +class DashboardOverrideTest(unittest.TestCase): + def test_latest_authorized_command_accepts_author_and_approvers(self) -> None: + raw = { + "issue_comments": [ + {"id": 1, "user": {"login": "outsider"}, "body": "/dashboard route:reviewers"}, + {"id": 2, "user": {"login": "author"}, "body": "text /dashboard route:reviewers"}, + {"id": 3, "user": {"login": "author"}, "body": "/dashboard route:reviewers\nplease review"}, + {"id": 4, "user": {"login": "Approver"}, "body": "/dashboard route:reviewers"}, + ] + } + + self.assertEqual( + (4, "Approver"), + dashboard_override.latest_authorized_command(raw, "author", {"approver"}), + ) + + def test_is_authorized_commander_matches_author_or_approver(self) -> None: + self.assertTrue( + dashboard_override.is_authorized_commander("Author", "author", set()) + ) + self.assertTrue( + dashboard_override.is_authorized_commander("Approver", "author", {"approver"}) + ) + self.assertFalse( + dashboard_override.is_authorized_commander("outsider", "author", {"approver"}) + ) + + def test_pending_replies_for_unauthorized_and_unknown_commands(self) -> None: + raw = { + "issue_comments": [ + {"id": 1, "user": {"login": "author"}, "body": "/dashboard route:reviewers"}, + {"id": 2, "user": {"login": "outsider"}, "body": "/dashboard route:reviewers"}, + {"id": 3, "user": {"login": "reviewer"}, "body": "/dashboard frobnicate"}, + {"id": 4, "user": {"login": "author"}, "body": "/dashboard"}, + {"id": 5, "user": {"login": "reviewer"}, "body": "looks good to me"}, + {"id": 6, "user": {"login": "approver"}, "body": "/dashboard route:reviewers"}, + ] + } + + replies = dashboard_override.pending_command_replies(raw, "author", {"approver"}) + + self.assertEqual( + [ + {"comment_id": 2, "kind": "unauthorized", "user": "outsider", "subcommand": "route:reviewers"}, + {"comment_id": 3, "kind": "unknown_command", "user": "reviewer", "subcommand": "frobnicate"}, + {"comment_id": 4, "kind": "unknown_command", "user": "author", "subcommand": ""}, + ], + replies, + ) + + def test_already_replied_commands_are_not_repeated(self) -> None: + raw = { + "issue_comments": [ + {"id": 2, "user": {"login": "outsider"}, "body": "/dashboard route:reviewers"}, + { + "id": 9, + "user": {"login": "opentelemetry-pr-dashboard[bot]"}, + "body": dashboard_override.command_reply_marker(2) + "\n@outsider ...", + }, + ] + } + + self.assertEqual([], dashboard_override.pending_command_replies(raw, "author")) + + def test_renders_unauthorized_and_unknown_command_replies(self) -> None: + unauthorized = dashboard_override.render_command_reply( + {"comment_id": 2, "kind": "unauthorized", "user": "outsider", "subcommand": "route:reviewers"} + ) + unknown = dashboard_override.render_command_reply( + {"comment_id": 3, "kind": "unknown_command", "user": "reviewer", "subcommand": "frobnicate"} + ) + + self.assertIn(dashboard_override.command_reply_marker(2), unauthorized) + self.assertIn( + "@outsider only the pull request author or a member of an approving " + "team can use `/dashboard route:reviewers`.", + unauthorized, + ) + self.assertIn(dashboard_override.command_reply_marker(3), unknown) + self.assertIn( + "`/dashboard frobnicate` is not a recognized dashboard command.", + unknown, + ) + + def test_renders_already_routed_replies_per_route(self) -> None: + cases = { + "approver": "already waiting on reviewers", + "maintainer": "already past review and waiting on maintainers", + "external": "waiting on an external dependency, not on you", + "copilot": "waiting on an automated Copilot review", + } + for route, phrase in cases.items(): + with self.subTest(route=route): + body = dashboard_override.render_command_reply( + {"comment_id": 7, "kind": "already_routed", "user": "author", "route": route} + ) + self.assertIn(dashboard_override.command_reply_marker(7), body) + self.assertIn(f"@author this pull request is {phrase}, so", body) + self.assertIn("`/dashboard route:reviewers` had no effect", body) + + def test_appends_already_routed_reply_for_author_noop(self) -> None: + facts = { + "author": "author", + "dashboard_override_noop": True, + "dashboard_override_command_id": 12, + } + + dashboard_override.append_route_noop_reply({"issue_comments": []}, facts, "approver") + + self.assertEqual( + [{"comment_id": 12, "kind": "already_routed", "user": "author", "route": "approver"}], + facts["dashboard_command_replies"], + ) + + def test_no_already_routed_reply_when_command_applies(self) -> None: + facts = { + "author": "author", + "dashboard_override_noop": False, + "dashboard_override_command_id": 12, + } + + dashboard_override.append_route_noop_reply({"issue_comments": []}, facts, "author") + + self.assertNotIn("dashboard_command_replies", facts) + + def test_already_routed_reply_deduped_by_existing_marker(self) -> None: + facts = { + "author": "author", + "dashboard_override_noop": True, + "dashboard_override_command_id": 12, + } + raw = { + "issue_comments": [ + {"body": dashboard_override.command_reply_marker(12) + "\n@author ..."}, + ] + } + + dashboard_override.append_route_noop_reply(raw, facts, "approver") + + self.assertNotIn("dashboard_command_replies", facts) + + @patch.object(dashboard_override, "run_gh") + @patch.object(dashboard_override, "gh_api", return_value=[]) + @patch.object( + dashboard_override, + "load_dashboard_state_cache", + return_value={ + "prs": { + "5": { + "facts": { + "dashboard_command_replies": [ + {"comment_id": 2, "kind": "unauthorized", "user": "outsider", "subcommand": "route:reviewers"}, + ] + } + } + } + }, + ) + def test_delivers_pending_command_reply(self, _load_state, gh_api, run_gh) -> None: + errors = dashboard_override.deliver_dashboard_command_replies( + "open-telemetry/example" + ) + + self.assertEqual([], errors) + gh_api.assert_called_once_with( + "/repos/open-telemetry/example/issues/5/comments?per_page=100", + paginate=True, + ) + posted = run_gh.call_args.args[0] + self.assertEqual(posted[:5], ["gh", "api", "--method", "POST", "repos/open-telemetry/example/issues/5/comments"]) + self.assertIn(dashboard_override.command_reply_marker(2), posted[-1]) + + @patch.object(dashboard_override, "run_gh") + @patch.object( + dashboard_override, + "gh_api", + return_value=[ + { + "performed_via_github_app": {"slug": "opentelemetry-pr-dashboard"}, + "body": " already replied", + } + ], + ) + @patch.object( + dashboard_override, + "load_dashboard_state_cache", + return_value={ + "prs": { + "5": { + "facts": { + "dashboard_command_replies": [ + {"comment_id": 2, "kind": "unauthorized", "user": "outsider", "subcommand": "route:reviewers"}, + ] + } + } + } + }, + ) + def test_delivery_skips_already_replied_command(self, _load_state, _gh_api, run_gh) -> None: + errors = dashboard_override.deliver_dashboard_command_replies( + "open-telemetry/example" + ) + + self.assertEqual([], errors) + run_gh.assert_not_called() + + def test_command_stays_pending_until_label_is_observed(self) -> None: + raw = { + "issue_comments": [ + {"id": 3, "user": {"login": "author"}, "body": "/dashboard route:reviewers"}, + ] + } + + first = dashboard_override.dashboard_override_facts(raw, "author", set(), None) + retry = dashboard_override.dashboard_override_facts(raw, "author", set(), first) + acknowledged = dashboard_override.dashboard_override_facts( + raw, + "author", + {"dashboard:route-overridden"}, + retry, + ) + removed = dashboard_override.dashboard_override_facts( + raw, + "author", + set(), + acknowledged, + ) + + self.assertTrue(first["dashboard_override_requested"]) + self.assertTrue(retry["dashboard_override_requested"]) + self.assertTrue(acknowledged["dashboard_override_label_applied"]) + self.assertFalse(acknowledged["dashboard_override_requested"]) + self.assertFalse(removed["dashboard_override"]) + + def test_newer_command_reapplies_removed_override(self) -> None: + previous = { + "dashboard_override": False, + "dashboard_override_command_id": 3, + "dashboard_override_requested": False, + } + raw = { + "issue_comments": [ + {"id": 3, "user": {"login": "author"}, "body": "/dashboard route:reviewers"}, + {"id": 5, "user": {"login": "author"}, "body": "/dashboard route:reviewers"}, + ] + } + + facts = dashboard_override.dashboard_override_facts( + raw, + "author", + set(), + previous, + ) + + self.assertTrue(facts["dashboard_override_requested"]) + self.assertEqual(5, facts["dashboard_override_command_id"]) + + def test_command_only_overrides_author_route(self) -> None: + for route, expected_route, expected_pending in ( + ("author", "approver", True), + ("approver", "approver", False), + ("maintainer", "maintainer", False), + ("external", "external", False), + ): + with self.subTest(route=route): + facts = { + "dashboard_override_label_applied": False, + "dashboard_override_requested": True, + } + + actual = dashboard_override.apply_dashboard_override(facts, route) + + self.assertEqual(expected_route, actual) + self.assertEqual(expected_pending, facts["dashboard_override_requested"]) + + def test_label_always_routes_to_reviewers(self) -> None: + facts = { + "dashboard_override_label_applied": True, + "dashboard_override_requested": False, + } + + route = dashboard_override.apply_dashboard_override(facts, "maintainer") + + self.assertEqual("approver", route) + self.assertTrue(facts["dashboard_override"]) + + def test_releases_label_once_natural_route_reaches_reviewers(self) -> None: + facts = { + "dashboard_override_label_applied": True, + "dashboard_override_requested": False, + } + + route = dashboard_override.apply_dashboard_override(facts, "approver") + + self.assertEqual("approver", route) + self.assertTrue(facts["dashboard_override_release_requested"]) + + def test_does_not_release_label_while_override_holds_author_route(self) -> None: + facts = { + "dashboard_override_label_applied": True, + "dashboard_override_requested": False, + } + + route = dashboard_override.apply_dashboard_override(facts, "author") + + self.assertEqual("approver", route) + self.assertFalse(facts["dashboard_override_release_requested"]) + + @patch.object(dashboard_override, "run_gh") + @patch.object( + dashboard_override, + "load_dashboard_state_cache", + return_value={ + "prs": { + "9": {"facts": {"dashboard_override_release_requested": True}}, + } + }, + ) + def test_delivery_removes_released_override_label(self, _load_state, run_gh) -> None: + errors = dashboard_override.deliver_dashboard_override_requests( + "open-telemetry/example" + ) + + self.assertEqual([], errors) + self.assertEqual( + call([ + "gh", "api", "--method", "DELETE", + "repos/open-telemetry/example/issues/9/labels/dashboard%3Aroute-overridden", + ]), + run_gh.call_args_list[-1], + ) + + @patch.object(dashboard_override, "run_gh") + @patch.object( + dashboard_override, + "load_dashboard_state_cache", + return_value={"prs": {}}, + ) + def test_creates_label_without_pending_command(self, _load_state, run_gh) -> None: + errors = dashboard_override.deliver_dashboard_override_requests( + "open-telemetry/example" + ) + + self.assertEqual([], errors) + run_gh.assert_called_once_with([ + "gh", "label", "create", "dashboard:route-overridden", + "--repo", "open-telemetry/example", + "--color", dashboard_override.DASHBOARD_OVERRIDE_LABEL_COLOR, + "--description", dashboard_override.DASHBOARD_OVERRIDE_LABEL_DESCRIPTION, + "--force", + ]) + + @patch.object(dashboard_override, "run_gh") + @patch.object( + dashboard_override, + "load_dashboard_state_cache", + return_value={ + "prs": { + "7": {"facts": {"dashboard_override_requested": True}}, + "8": {"facts": {"dashboard_override_requested": False}}, + } + }, + ) + def test_delivers_pending_override_label(self, _load_state, run_gh) -> None: + errors = dashboard_override.deliver_dashboard_override_requests( + "open-telemetry/example" + ) + + self.assertEqual([], errors) + self.assertEqual( + [ + call([ + "gh", "label", "create", "dashboard:route-overridden", + "--repo", "open-telemetry/example", + "--color", dashboard_override.DASHBOARD_OVERRIDE_LABEL_COLOR, + "--description", dashboard_override.DASHBOARD_OVERRIDE_LABEL_DESCRIPTION, + "--force", + ]), + call([ + "gh", "api", "--method", "POST", + "repos/open-telemetry/example/issues/7/labels", + "-f", "labels[]=dashboard:route-overridden", + ]), + ], + run_gh.call_args_list, + ) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/.github/scripts/pull-request-dashboard/test_delivery.py b/.github/scripts/pull-request-dashboard/test_delivery.py index 9a91e330ae2..ea9324f6ea6 100644 --- a/.github/scripts/pull-request-dashboard/test_delivery.py +++ b/.github/scripts/pull-request-dashboard/test_delivery.py @@ -11,12 +11,16 @@ class DeliveryTest(unittest.TestCase): @patch.object(delivery, "notify_slack_from_state", return_value=[]) @patch.object(delivery, "deliver_copilot_review_requests", return_value=[]) @patch.object(delivery, "deliver_prepared_author_nudges", return_value=[]) + @patch.object(delivery, "deliver_dashboard_command_replies", return_value=[]) + @patch.object(delivery, "deliver_dashboard_override_requests", return_value=[]) @patch.object(delivery, "update_status_comments_from_state", return_value=[]) @patch.object(delivery, "list_all_open_pr_numbers", return_value={7, 8}) def test_runs_all_targeted_deliveries_in_order( self, _list_open, status_comments, + dashboard_overrides, + dashboard_command_replies, author_nudges, copilot_reviews, slack, @@ -28,6 +32,8 @@ def record(label: str) -> list[str]: return [] status_comments.side_effect = lambda *_args: record("status") + dashboard_overrides.side_effect = lambda *_args: record("override") + dashboard_command_replies.side_effect = lambda *_args: record("replies") author_nudges.side_effect = lambda *_args: record("author") copilot_reviews.side_effect = lambda *_args: record("copilot") slack.side_effect = lambda *_args: record("slack") @@ -40,7 +46,7 @@ def record(label: str) -> list[str]: self.assertEqual([], errors) self.assertEqual( - [call("author"), call("status"), call("copilot"), call("slack")], + [call("override"), call("replies"), call("author"), call("status"), call("copilot"), call("slack")], order.call_args_list, ) status_comments.assert_called_once_with( @@ -59,12 +65,16 @@ def record(label: str) -> list[str]: @patch.object(delivery, "notify_slack_from_state", return_value=[]) @patch.object(delivery, "deliver_copilot_review_requests", return_value=[]) @patch.object(delivery, "deliver_prepared_author_nudges", return_value=[]) + @patch.object(delivery, "deliver_dashboard_command_replies", return_value=[]) + @patch.object(delivery, "deliver_dashboard_override_requests", return_value=[]) @patch.object(delivery, "update_status_comments_from_state", side_effect=RuntimeError("boom")) @patch.object(delivery, "list_all_open_pr_numbers", return_value={7}) def test_failure_does_not_block_later_deliveries( self, _list_open, _status_comments, + dashboard_overrides, + dashboard_command_replies, author_nudges, copilot_reviews, slack, @@ -77,6 +87,8 @@ def test_failure_does_not_block_later_deliveries( ) self.assertIn("status comments: boom", errors) + dashboard_overrides.assert_called_once() + dashboard_command_replies.assert_called_once() author_nudges.assert_called_once() copilot_reviews.assert_called_once() slack.assert_called_once() diff --git a/.github/scripts/pull-request-dashboard/test_github_webhook.js b/.github/scripts/pull-request-dashboard/test_github_webhook.js index 207dbcbe350..76289d5dece 100644 --- a/.github/scripts/pull-request-dashboard/test_github_webhook.js +++ b/.github/scripts/pull-request-dashboard/test_github_webhook.js @@ -1,11 +1,19 @@ const assert = require("node:assert/strict"); const test = require("node:test"); -const { isDashboardSelfTriggeredCommentEvent } = require("./netlify/functions/github-webhook"); +const { + isAllowedAction, + isDashboardSelfTriggeredCommentEvent, +} = require("./netlify/functions/github-webhook"); const dashboardApp = { slug: "opentelemetry-pr-dashboard" }; const dashboardActor = { id: 1 }; +test("refreshes when the dashboard override label changes", () => { + assert.equal(isAllowedAction("pull_request", "labeled"), true); + assert.equal(isAllowedAction("pull_request", "unlabeled"), true); +}); + test("recognizes comments performed by the dashboard app", () => { assert.equal(isDashboardSelfTriggeredCommentEvent("issue_comment", { comment: { 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 003d3ca7afd..96b200f36b1 100644 --- a/.github/scripts/pull-request-dashboard/test_pr_status_comment.py +++ b/.github/scripts/pull-request-dashboard/test_pr_status_comment.py @@ -49,6 +49,13 @@ def test_waiting_on_author_splits_review_feedback_links(self) -> None: self.assertIn(" - **Inline threads:** [1]", body) self.assertIn(" - **Top-level feedback:** [2]", body) self.assertIn(f" - _{pr_status_comment.AUTHOR_GUIDANCE}_", body) + self.assertIn( + "If you believe this pull request is incorrectly routed as waiting " + "on the author (see the last refreshed time above), comment " + "`/dashboard route:reviewers` to route it from waiting on the author to " + "waiting on reviewers.", + body, + ) @patch.object( pr_status_comment, diff --git a/pull-request-dashboard/README.md b/pull-request-dashboard/README.md index 7786a7640bf..7438b844fad 100644 --- a/pull-request-dashboard/README.md +++ b/pull-request-dashboard/README.md @@ -192,6 +192,27 @@ for the tradeoffs behind this behavior. Targeted updates received before the first full dashboard run are ignored. +## Reviewer routing override + +When the dashboard says a pull request is waiting on its author but the author +believes it is ready for another review, the author can comment +`/dashboard route:reviewers`. The dashboard routes the pull request to *Waiting +on reviewers* and applies the `dashboard:route-overridden` label to mark the +override. Members of the repository's `approver_teams` can use the same command. +A `/dashboard route:reviewers` command from anyone else, or a command on a pull +request that is not waiting on its author, has no routing effect. The dashboard +replies to a `/dashboard route:reviewers` from an unauthorized user explaining +that only the author or an approver can use it, replies to an author or approver +command on a pull request that is not waiting on the author noting where it is +currently routed, and replies to any unrecognized `/dashboard` command. + +Removing the `dashboard:route-overridden` label restores automatic routing. The +dashboard also removes the label automatically once routing would place the pull +request with reviewers on its own, so a forgotten override does not linger. A +command that has already been applied is not replayed after label removal; the +author can post a new `/dashboard route:reviewers` command if another override +is needed later. + ## Author reminder The dashboard posts one reminder when a pull request remains in *Waiting on @@ -199,6 +220,9 @@ authors* for one week. The reminder @-mentions the author, links to the dashboard-managed status comment containing the remaining items, and notes that addressing them (or replying with an update) automatically routes the pull request back to reviewers. +Both the reminder and the live status comment advertise `/dashboard route:reviewers` +as an explicit handoff when the author believes the pull request is ready for +review. Leaving *Waiting on authors* resets the one-week clock. If the pull request later returns to *Waiting on authors* and remains there for another week, the From 8f442f1861e051a8dbb3c587967eb42737ccbfc9 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 21 Jul 2026 08:10:29 -0700 Subject: [PATCH 21/44] Refine dashboard status comment override wording Soften the report-it prompt to 'please report it' and replace the vague '(see the last refreshed time above)' hint with an actionable staleness note telling the author the dashboard may not have processed their latest reply or push yet. --- .../pull-request-dashboard/dashboard_override.py | 12 +++++++----- .../pull-request-dashboard/pr_status_comment.py | 7 +++++-- .../pull-request-dashboard/test_pr_status_comment.py | 7 ++++--- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/dashboard_override.py b/.github/scripts/pull-request-dashboard/dashboard_override.py index 6e0e5ec0c11..fe7e24a3dcb 100644 --- a/.github/scripts/pull-request-dashboard/dashboard_override.py +++ b/.github/scripts/pull-request-dashboard/dashboard_override.py @@ -26,13 +26,15 @@ ) -def author_override_guidance(timestamp_hint: str = "") -> str: - location = f" ({timestamp_hint})" if timestamp_hint else "" - return ( +def author_override_guidance(staleness_note: str = "") -> str: + guidance = ( "If you believe this pull request is incorrectly routed as waiting on " - f"the author{location}, comment `/dashboard route:reviewers` to route it " - "from waiting on the author to waiting on reviewers." + "the author, comment `/dashboard route:reviewers` to route it from " + "waiting on the author to waiting on reviewers." ) + if staleness_note: + guidance = f"{guidance} {staleness_note}" + return guidance def parse_dashboard_command(comment: dict[str, Any]) -> str | None: diff --git a/.github/scripts/pull-request-dashboard/pr_status_comment.py b/.github/scripts/pull-request-dashboard/pr_status_comment.py index e4a7cfa3ee0..3264a9e008e 100644 --- a/.github/scripts/pull-request-dashboard/pr_status_comment.py +++ b/.github/scripts/pull-request-dashboard/pr_status_comment.py @@ -54,10 +54,13 @@ def accuracy_note(pr: dict[str, Any], author_routed: bool = False) -> str: report_url = f"{STATUS_REPORT_ISSUE_URL}?{query}" note = ( "This automated status or its linked feedback items may be incorrect. " - f"If something looks wrong, [report it]({report_url}) with the result you expected." + f"If something looks wrong, please [report it]({report_url}) with the result you expected." ) if author_routed: - note += " " + author_override_guidance("see the last refreshed time above") + note += " " + author_override_guidance( + "If the last refreshed time above predates your latest reply or " + "push, the dashboard hasn't processed it yet." + ) return f"_{note}_" 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 96b200f36b1..5b206a9e244 100644 --- a/.github/scripts/pull-request-dashboard/test_pr_status_comment.py +++ b/.github/scripts/pull-request-dashboard/test_pr_status_comment.py @@ -51,9 +51,10 @@ def test_waiting_on_author_splits_review_feedback_links(self) -> None: self.assertIn(f" - _{pr_status_comment.AUTHOR_GUIDANCE}_", body) self.assertIn( "If you believe this pull request is incorrectly routed as waiting " - "on the author (see the last refreshed time above), comment " - "`/dashboard route:reviewers` to route it from waiting on the author to " - "waiting on reviewers.", + "on the author, comment `/dashboard route:reviewers` to route it from " + "waiting on the author to waiting on reviewers. If the last refreshed " + "time above predates your latest reply or push, the dashboard hasn't " + "processed it yet.", body, ) From 572347082a347c23acdd23bde5cd8a4c80ad35c8 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 21 Jul 2026 08:25:39 -0700 Subject: [PATCH 22/44] Address review comment from copilot-pull-request-reviewer: don't let a stale override pin maintainer-routed PRs The dashboard:route-overridden override now applies only from pre-review routes (author, external) and releases the label once routing reaches or passes reviewers (approver, maintainer), so a forgotten label cannot force a maintainer-ready PR back to reviewers and block merge. --- .../dashboard_override.py | 30 +++++++++++++------ .../test_dashboard_override.py | 20 +++++++++++-- 2 files changed, 38 insertions(+), 12 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/dashboard_override.py b/.github/scripts/pull-request-dashboard/dashboard_override.py index fe7e24a3dcb..5637b5bfefe 100644 --- a/.github/scripts/pull-request-dashboard/dashboard_override.py +++ b/.github/scripts/pull-request-dashboard/dashboard_override.py @@ -24,6 +24,12 @@ _COMMAND_REPLY_MARKER_RE = re.compile( r"" ) +# Automatic routes from which "route to reviewers" is a meaningful override: the +# pull request is not yet in review. `approver` is already at reviewers and +# `maintainer` is past them, so the override must not force those; `copilot` +# routing reflects a required review gate that the override should not bypass. +OVERRIDABLE_ROUTES = ("author", "external") +REVIEWERS_OR_LATER_ROUTES = ("approver", "maintainer") def author_override_guidance(staleness_note: str = "") -> str: @@ -259,18 +265,24 @@ def deliver_dashboard_command_replies(repo: str) -> list[str]: def apply_dashboard_override(facts: dict[str, Any], route: str) -> str: label_applied = bool(facts.get("dashboard_override_label_applied")) requested = bool(facts.get("dashboard_override_requested")) - command_applies = requested and route == "author" - # A pending author command that cannot apply because the pull request is not - # waiting on its author is a no-op; the author is told where it is routed. + command_applies = requested and route in OVERRIDABLE_ROUTES + # The override only takes effect from a pre-review route (waiting on the + # author or an external dependency). On a route that is already at or past + # reviewers the natural routing stands, so a label left behind cannot pin + # the pull request at reviewers or drag it back from maintainers. + override_applies = route in OVERRIDABLE_ROUTES and (label_applied or requested) + # A pending command that cannot apply because the pull request is not on an + # overridable route is a no-op; the author is told where it is routed. facts["dashboard_override_noop"] = requested and not command_applies if requested and not command_applies: facts["dashboard_override_requested"] = False - facts["dashboard_override"] = label_applied or command_applies - # Once automatic routing would place the pull request with reviewers on its - # own, a lingering override label is redundant. Release it so a forgotten - # override does not hold the pull request at reviewers forever. - facts["dashboard_override_release_requested"] = label_applied and route == "approver" - return "approver" if facts["dashboard_override"] else route + facts["dashboard_override"] = override_applies + # Release the label once automatic routing reaches or passes reviewers, so a + # forgotten override cannot hold the pull request at reviewers forever. + facts["dashboard_override_release_requested"] = ( + label_applied and route in REVIEWERS_OR_LATER_ROUTES + ) + return "approver" if override_applies else route def append_route_noop_reply( diff --git a/.github/scripts/pull-request-dashboard/test_dashboard_override.py b/.github/scripts/pull-request-dashboard/test_dashboard_override.py index 0a7c8483810..68f88430e67 100644 --- a/.github/scripts/pull-request-dashboard/test_dashboard_override.py +++ b/.github/scripts/pull-request-dashboard/test_dashboard_override.py @@ -263,12 +263,13 @@ def test_newer_command_reapplies_removed_override(self) -> None: self.assertTrue(facts["dashboard_override_requested"]) self.assertEqual(5, facts["dashboard_override_command_id"]) - def test_command_only_overrides_author_route(self) -> None: + def test_command_overrides_pre_review_routes(self) -> None: for route, expected_route, expected_pending in ( ("author", "approver", True), + ("external", "approver", True), ("approver", "approver", False), ("maintainer", "maintainer", False), - ("external", "external", False), + ("copilot", "copilot", False), ): with self.subTest(route=route): facts = { @@ -281,7 +282,7 @@ def test_command_only_overrides_author_route(self) -> None: self.assertEqual(expected_route, actual) self.assertEqual(expected_pending, facts["dashboard_override_requested"]) - def test_label_always_routes_to_reviewers(self) -> None: + def test_label_yields_to_maintainer_route_and_releases(self) -> None: facts = { "dashboard_override_label_applied": True, "dashboard_override_requested": False, @@ -289,8 +290,21 @@ def test_label_always_routes_to_reviewers(self) -> None: route = dashboard_override.apply_dashboard_override(facts, "maintainer") + self.assertEqual("maintainer", route) + self.assertFalse(facts["dashboard_override"]) + self.assertTrue(facts["dashboard_override_release_requested"]) + + def test_label_holds_external_route_at_reviewers(self) -> None: + facts = { + "dashboard_override_label_applied": True, + "dashboard_override_requested": False, + } + + route = dashboard_override.apply_dashboard_override(facts, "external") + self.assertEqual("approver", route) self.assertTrue(facts["dashboard_override"]) + self.assertFalse(facts["dashboard_override_release_requested"]) def test_releases_label_once_natural_route_reaches_reviewers(self) -> None: facts = { From 100db7c936996cd2da3360b4a9c04599cd25697d Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 21 Jul 2026 08:27:08 -0700 Subject: [PATCH 23/44] Address review comment from copilot-pull-request-reviewer: remove unused author_nudge_state_path import --- .github/scripts/pull-request-dashboard/author_nudge.py | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/scripts/pull-request-dashboard/author_nudge.py b/.github/scripts/pull-request-dashboard/author_nudge.py index 427d84fcecc..46455156e8a 100644 --- a/.github/scripts/pull-request-dashboard/author_nudge.py +++ b/.github/scripts/pull-request-dashboard/author_nudge.py @@ -16,7 +16,6 @@ publish_pr_status, ) from state import ( - author_nudge_state_path, load_author_nudges, load_dashboard_state_cache, save_author_nudges, From 8302647addea38d0685b9d342597b06db6df8d41 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 21 Jul 2026 08:54:32 -0700 Subject: [PATCH 24/44] Address review comment from copilot-pull-request-reviewer: gate the overridden route through the Copilot review gate Apply the reviewer-routing override before apply_copilot_review_gate so an overridden route (e.g. author -> reviewers) is still held for a required clean Copilot review instead of bypassing it. Extract resolve_pr_route to make the ordering testable. --- .../pull-request-dashboard/dashboard.py | 29 ++++++++++--- .../pull-request-dashboard/test_dashboard.py | 41 +++++++++++++++++++ 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/dashboard.py b/.github/scripts/pull-request-dashboard/dashboard.py index cdfec16d543..9661f102813 100644 --- a/.github/scripts/pull-request-dashboard/dashboard.py +++ b/.github/scripts/pull-request-dashboard/dashboard.py @@ -1394,6 +1394,25 @@ def add_reviewers( ] +def resolve_pr_route( + facts: dict[str, Any], + pending_actions: dict[str, dict[str, Any]], + required_approvals: int, + require_clean_copilot_review: bool, +) -> str: + # Apply the manual reviewer-routing override before the Copilot review gate + # so an overridden route (for example author -> reviewers) is still held for + # a required clean Copilot review instead of bypassing it. + return apply_copilot_review_gate( + facts, + apply_dashboard_override( + facts, + route_pr(facts, pending_actions, required_approvals), + ), + enabled=require_clean_copilot_review, + ) + + # ---------------------------------------------------------------- main @@ -1499,13 +1518,11 @@ def build_pr_result( "route": "unknown", "error": f"{len(failed_classifications)} discussion classification(s) failed", } - route = apply_dashboard_override( + route = resolve_pr_route( facts, - apply_copilot_review_gate( - facts, - route_pr(facts, pending_actions, required_approvals), - enabled=require_clean_copilot_review, - ), + pending_actions, + required_approvals, + require_clean_copilot_review, ) append_route_noop_reply(raw, facts, route) add_wait_age_facts(facts, route, pending_actions) diff --git a/.github/scripts/pull-request-dashboard/test_dashboard.py b/.github/scripts/pull-request-dashboard/test_dashboard.py index 170c6ba9d63..2265149d24e 100644 --- a/.github/scripts/pull-request-dashboard/test_dashboard.py +++ b/.github/scripts/pull-request-dashboard/test_dashboard.py @@ -22,6 +22,7 @@ group_review_threads, main, remove_cached_dashboard_prs, + resolve_pr_route, route_pr, set_backfill_pr_failed, update_dashboard_for_backfill, @@ -29,6 +30,46 @@ ) +class ResolvePrRouteTest(unittest.TestCase): + def _author_route_facts(self, **overrides: object) -> dict[str, object]: + # A failing required check routes a human-authored PR to the author. + facts: dict[str, object] = { + "ci_failing_count": 1, + "dashboard_override_label_applied": True, + "dashboard_override_requested": False, + } + facts.update(overrides) + return facts + + def test_override_is_still_gated_by_required_copilot_review(self) -> None: + facts = self._author_route_facts( + copilot_review_exists=True, + copilot_review_needed=True, + copilot_review_requested=False, + ) + + route = resolve_pr_route(facts, {}, 1, True) + + self.assertEqual("copilot", route) + + def test_override_reaches_reviewers_when_copilot_review_is_clean(self) -> None: + facts = self._author_route_facts( + copilot_review_exists=True, + copilot_review_needed=False, + ) + + route = resolve_pr_route(facts, {}, 1, True) + + self.assertEqual("approver", route) + + def test_override_reaches_reviewers_when_gate_disabled(self) -> None: + facts = self._author_route_facts() + + route = resolve_pr_route(facts, {}, 1, False) + + self.assertEqual("approver", route) + + class FetchPrRawTest(unittest.TestCase): def test_uses_graphql_issue_comments_without_rest_join(self) -> None: issue_comments = [{"id": 101, "body": "GraphQL comment"}] From 2073c2d7e981b7100cdf201cc30d1bee0cc20e42 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 21 Jul 2026 09:13:08 -0700 Subject: [PATCH 25/44] Address review comment from copilot-pull-request-reviewer: refresh on review-request lifecycle webhook actions Accept pull_request review_requested and review_request_removed so a targeted refresh promptly clears or recreates a Copilot review request instead of waiting for the hourly backfill. --- .../netlify/functions/github-webhook.js | 2 ++ .../scripts/pull-request-dashboard/test_github_webhook.js | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/.github/scripts/pull-request-dashboard/netlify/functions/github-webhook.js b/.github/scripts/pull-request-dashboard/netlify/functions/github-webhook.js index 251454e232e..0fa37ab3358 100644 --- a/.github/scripts/pull-request-dashboard/netlify/functions/github-webhook.js +++ b/.github/scripts/pull-request-dashboard/netlify/functions/github-webhook.js @@ -19,6 +19,8 @@ const ALLOWED_ACTIONS = { "opened", "ready_for_review", "reopened", + "review_request_removed", + "review_requested", "synchronize", "unassigned", "unlabeled", diff --git a/.github/scripts/pull-request-dashboard/test_github_webhook.js b/.github/scripts/pull-request-dashboard/test_github_webhook.js index 76289d5dece..8cbb47540c1 100644 --- a/.github/scripts/pull-request-dashboard/test_github_webhook.js +++ b/.github/scripts/pull-request-dashboard/test_github_webhook.js @@ -14,6 +14,11 @@ test("refreshes when the dashboard override label changes", () => { assert.equal(isAllowedAction("pull_request", "unlabeled"), true); }); +test("refreshes when a review request is added or removed", () => { + assert.equal(isAllowedAction("pull_request", "review_requested"), true); + assert.equal(isAllowedAction("pull_request", "review_request_removed"), true); +}); + test("recognizes comments performed by the dashboard app", () => { assert.equal(isDashboardSelfTriggeredCommentEvent("issue_comment", { comment: { From e030282bd5007f615f7cec14b934bf20b510f7c7 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 21 Jul 2026 11:17:39 -0700 Subject: [PATCH 26/44] Scope clean-Copilot-review gate to configured base branches Replace the repo-wide require_clean_copilot_review boolean with a require_clean_copilot_review_branches list. The Copilot gate now applies only to PRs whose base branch is listed, so PRs targeting branches without automatic Copilot review (e.g. release branches) are not gated and cannot stall waiting for a review that never runs. Document the gate rationale in RATIONALE.md. --- .../pull-request-dashboard/RATIONALE.md | 34 +++++++++++++++++++ .../pull-request-dashboard/dashboard.py | 22 +++++++----- .../test_top_level_actions.py | 7 ++-- .../workflows/pull-request-dashboard-repo.yml | 15 ++++---- .github/workflows/pull-request-dashboard.yml | 2 +- pull-request-dashboard/README.md | 11 +++--- 6 files changed, 68 insertions(+), 23 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/RATIONALE.md b/.github/scripts/pull-request-dashboard/RATIONALE.md index e5668d9e58d..9c030ba6083 100644 --- a/.github/scripts/pull-request-dashboard/RATIONALE.md +++ b/.github/scripts/pull-request-dashboard/RATIONALE.md @@ -175,6 +175,40 @@ the implementation understandable and operationally cheap. respond to a dashboard action. Pending required checks affect the CI column but do not change who owns the next action. +## Copilot Review Gate + +- `require_clean_copilot_review_branches` is a final safety net applied only when a PR + would otherwise route to reviewers or maintainers — that is, after the author + has addressed the actionable discussions. It is not a routing input while the + author still owns actions. +- The setting lists the base branches to gate rather than a single on/off + switch, because automatic Copilot review is itself configured per branch + (often only the default branch). Gating a branch with no automatic review + would park every ready PR on the copilot route waiting for a review that never + runs, so only branches with automatic review are listed and PRs targeting + other branches route normally. +- Copilot findings normally return a PR to the author through ordinary + discussion routing: an inline finding is an unresolved review thread, and an + actionable one routes the PR to "waiting on author." In that common path the + gate never fires and no re-review is requested. +- The gate's re-request path is deliberately narrow: it triggers when the + current head has no Copilot review yet (a push made the prior review stale) or + the author resolved Copilot's threads without a code change. Re-requesting the + same head is intentional — it asks Copilot to re-review after the author + responded, mirroring a human review cycle. Copilot's answer either clears the + gate or produces fresh actionable threads that route the PR back to the + author, so re-requesting an unchanged commit is self-correcting rather than a + re-request loop. +- "Clean" means no inline comments on the current head, counted from the + review, not from the classifier's actionability judgment. Accepted + limitation: if Copilot leaves comments the classifier treats as + non-actionable while they stay unresolved, routing sits at reviewers but the + gate holds the PR on the copilot route and re-requests until Copilot returns a + comment-free review or the author pushes. The strict count is intentional — + the gate is a conservative "Copilot had nothing to say about this exact code" + check, and folding in classifier judgment could let a real-but-non-actionable + comment slip a PR to humans. + ## Live PR Status Comments - Feedback totals in the live comment count the canonical author-action links diff --git a/.github/scripts/pull-request-dashboard/dashboard.py b/.github/scripts/pull-request-dashboard/dashboard.py index 9661f102813..8f3f6aae85c 100644 --- a/.github/scripts/pull-request-dashboard/dashboard.py +++ b/.github/scripts/pull-request-dashboard/dashboard.py @@ -1427,7 +1427,7 @@ def build_pr_result( non_blocking_check_patterns: list[str], previous_top_level_history: dict[str, dict[str, Any]] | None = None, previous_facts: dict[str, Any] | None = None, - require_clean_copilot_review: bool = False, + require_clean_copilot_review_branches: list[str] | None = None, ) -> dict[str, Any] | None: number = pr_summary["number"] try: @@ -1518,6 +1518,9 @@ def build_pr_result( "route": "unknown", "error": f"{len(failed_classifications)} discussion classification(s) failed", } + require_clean_copilot_review = (raw["pr"].get("baseRefName") or "") in ( + require_clean_copilot_review_branches or [] + ) route = resolve_pr_route( facts, pending_actions, @@ -1605,7 +1608,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, + require_clean_copilot_review_branches: list[str] | None = None, ) -> DashboardUpdate: print(f"refreshing dashboard state for PR #{pr_number}", file=sys.stderr) results = results_from_dashboard_state(dashboard_state, open_pr_numbers) @@ -1621,7 +1624,7 @@ def build_dashboard_update_for_pr( non_blocking_check_patterns, previous_top_level_history=(starting_pr_result or {}).get("top_level_history") or {}, previous_facts=(starting_pr_result or {}).get("facts") or {}, - require_clean_copilot_review=require_clean_copilot_review, + require_clean_copilot_review_branches=require_clean_copilot_review_branches, ) if trigger_pr_result is None: results.pop(pr_number, None) @@ -1964,7 +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), + getattr(args, "require_clean_copilot_review_branches", []), ) @@ -2102,7 +2105,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), + getattr(args, "require_clean_copilot_review_branches", []), ) calculation, dashboard_state_unchanged = merge_dashboard_update_with_latest_state( calculation, @@ -2207,9 +2210,12 @@ def main() -> int: 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", + "--require-clean-copilot-review-branch", + action="append", + default=[], + dest="require_clean_copilot_review_branches", + metavar="BRANCH", + help="require a clean Copilot review before reviewer or maintainer handoff for PRs targeting this base branch; repeat as needed", ) parser.add_argument( "--prepare-author-nudges", 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 f08b81030ff..6d7c685d5c1 100644 --- a/.github/scripts/pull-request-dashboard/test_top_level_actions.py +++ b/.github/scripts/pull-request-dashboard/test_top_level_actions.py @@ -957,14 +957,17 @@ def test_dashboard_refresh_reuses_stored_top_level_history(self, build_result) - } } }, - True, + ["main"], ) self.assertEqual( build_result.call_args.kwargs["previous_top_level_history"], previous_state, ) - self.assertTrue(build_result.call_args.kwargs["require_clean_copilot_review"]) + self.assertEqual( + build_result.call_args.kwargs["require_clean_copilot_review_branches"], + ["main"], + ) 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 97052a0ec18..687b24841eb 100644 --- a/.github/workflows/pull-request-dashboard-repo.yml +++ b/.github/workflows/pull-request-dashboard-repo.yml @@ -22,10 +22,10 @@ on: required: false default: "[]" type: string - require_clean_copilot_review: + require_clean_copilot_review_branches_json: required: false - default: false - type: boolean + default: "[]" + type: string slack_channel: required: false default: "" @@ -127,7 +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 }} + REQUIRE_CLEAN_COPILOT_REVIEW_BRANCHES_JSON: ${{ inputs.require_clean_copilot_review_branches_json }} PREPARE_AUTHOR_NUDGES: ${{ github.event_name == 'schedule' }} run: | set -euo pipefail @@ -141,9 +141,10 @@ 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 + mapfile -t require_clean_copilot_review_branches < <(jq -r '.[]' <<< "$REQUIRE_CLEAN_COPILOT_REVIEW_BRANCHES_JSON") + for branch in "${require_clean_copilot_review_branches[@]}"; do + dashboard_args+=(--require-clean-copilot-review-branch "$branch") + done if [[ "${PREPARE_AUTHOR_NUDGES:-false}" == "true" ]]; then dashboard_args+=(--prepare-author-nudges) fi diff --git a/.github/workflows/pull-request-dashboard.yml b/.github/workflows/pull-request-dashboard.yml index 3c0575ba6f9..c5dbef83e47 100644 --- a/.github/workflows/pull-request-dashboard.yml +++ b/.github/workflows/pull-request-dashboard.yml @@ -148,7 +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 }} + require_clean_copilot_review_branches_json: ${{ toJSON(matrix.require_clean_copilot_review_branches || fromJSON('[]')) }} 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 7438b844fad..10abbd4e4fb 100644 --- a/pull-request-dashboard/README.md +++ b/pull-request-dashboard/README.md @@ -52,7 +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, + "require_clean_copilot_review_branches": ["main"], "labels_to_display": ["size/*", "breaking change"], "slack_channel": "#example-maintainers", "slack_user_mapping": { @@ -71,16 +71,17 @@ 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`. | +| `require_clean_copilot_review_branches` | no | List of base branch names for which a Copilot review with no inline findings on the current head is required before routing a PR to reviewers or maintainers. The dashboard re-requests Copilot review when needed and does not duplicate a pending request. List only branches where automatic Copilot code review is enabled (typically `["main"]`); PRs targeting any other branch are never gated, so they cannot stall waiting for a review that never runs. Defaults to `[]` (no branches gated). | | `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 +For each listed branch, `require_clean_copilot_review_branches` relies on automatic Copilot +code review for the initial review, so list only branches where automatic Copilot +code review is enabled. 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. From 6501e4bea3eeedc7a1581c1c732def87a9e7b4ee Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 21 Jul 2026 11:09:05 -0700 Subject: [PATCH 27/44] Address review comment from copilot-pull-request-reviewer: log traceback on delivery-stage failures Print the failing stage label and traceback to stderr in run_delivery_action so a failed delivery stage is diagnosable from the job log, while still continuing with the remaining stages. --- .github/scripts/pull-request-dashboard/delivery.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/scripts/pull-request-dashboard/delivery.py b/.github/scripts/pull-request-dashboard/delivery.py index c3b6dfcff81..cd94d09017d 100644 --- a/.github/scripts/pull-request-dashboard/delivery.py +++ b/.github/scripts/pull-request-dashboard/delivery.py @@ -7,6 +7,7 @@ import os from pathlib import Path import sys +import traceback from typing import Callable from author_nudge import deliver_prepared_author_nudges @@ -40,6 +41,10 @@ def run_delivery_action( try: errors.extend(f"{label}: {error}" for error in action()) except Exception as e: + # Keep the traceback in the job log so a failed stage is diagnosable; + # the short message alone is rarely enough in production. + print(f"{label} raised an exception:", file=sys.stderr) + traceback.print_exc() errors.append(f"{label}: {e}") From a56fc2983fbe75311b88b06875318936f1e17d2a Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 21 Jul 2026 11:12:35 -0700 Subject: [PATCH 28/44] Address review comment from copilot-pull-request-reviewer: keep author explanation following a /dashboard command --- .../pull-request-dashboard/dashboard.py | 11 +++++-- .../dashboard_override.py | 14 ++++++++ .../test_dashboard_override.py | 19 +++++++++++ .../test_top_level_actions.py | 33 +++++++++++++++++++ 4 files changed, 74 insertions(+), 3 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/dashboard.py b/.github/scripts/pull-request-dashboard/dashboard.py index 8f3f6aae85c..f039be98738 100644 --- a/.github/scripts/pull-request-dashboard/dashboard.py +++ b/.github/scripts/pull-request-dashboard/dashboard.py @@ -202,8 +202,8 @@ from dashboard_override import ( apply_dashboard_override, append_route_noop_reply, + dashboard_command_body_remainder, dashboard_override_facts, - parse_dashboard_command, ) from state import ( INITIAL_BACKFILL_COMPLETE_KEY, @@ -389,8 +389,13 @@ def normalize_events(raw: dict[str, Any], author: str, reviewers: set[str]) -> l for c in raw["issue_comments"]: if c.get("minimized"): continue - if parse_dashboard_command(c) is not None: + command_remainder = dashboard_command_body_remainder(c) + # A `/dashboard` command line is control metadata, not discussion. Skip + # the comment only when it is command-only; otherwise keep the author's + # explanation that follows the command as an event. + if command_remainder is not None and not command_remainder: continue + body = command_remainder if command_remainder is not None else (c.get("body") or "") login = reviewer_actor_login(c.get("user") or {}) timestamp = ( c.get("content_updated_at") @@ -407,7 +412,7 @@ def normalize_events(raw: dict[str, Any], author: str, reviewers: set[str]) -> l "updated_timestamp": timestamp, "actor": login, "actor_role": role_for(login, author, reviewers), - "body": c.get("body") or "", + "body": body, "state": None, "path": None, "sha": None, diff --git a/.github/scripts/pull-request-dashboard/dashboard_override.py b/.github/scripts/pull-request-dashboard/dashboard_override.py index 5637b5bfefe..0a61d0da783 100644 --- a/.github/scripts/pull-request-dashboard/dashboard_override.py +++ b/.github/scripts/pull-request-dashboard/dashboard_override.py @@ -60,6 +60,20 @@ def parse_dashboard_command(comment: dict[str, Any]) -> str | None: return tokens[1] if len(tokens) > 1 else "" +def dashboard_command_body_remainder(comment: dict[str, Any]) -> str | None: + """Return the comment body after a leading `/dashboard` command line. + + Returns None when the comment is not a `/dashboard` command, and the + (possibly empty) text after the command line otherwise. This lets callers + keep an author's explanation that follows the command while treating the + command line itself as control metadata. + """ + if parse_dashboard_command(comment) is None: + return None + lines = (comment.get("body") or "").strip().splitlines() + return "\n".join(lines[1:]).strip() + + def is_authorized_commander(login: str, author: str, reviewers: set[str] | None) -> bool: low = (login or "").lower() return bool(low) and (low == author.lower() or low in (reviewers or set())) diff --git a/.github/scripts/pull-request-dashboard/test_dashboard_override.py b/.github/scripts/pull-request-dashboard/test_dashboard_override.py index 68f88430e67..9a497c1e98a 100644 --- a/.github/scripts/pull-request-dashboard/test_dashboard_override.py +++ b/.github/scripts/pull-request-dashboard/test_dashboard_override.py @@ -7,6 +7,25 @@ class DashboardOverrideTest(unittest.TestCase): + def test_dashboard_command_body_remainder(self) -> None: + self.assertIsNone( + dashboard_override.dashboard_command_body_remainder( + {"body": "just a normal comment"} + ) + ) + self.assertEqual( + "", + dashboard_override.dashboard_command_body_remainder( + {"body": "/dashboard route:reviewers"} + ), + ) + self.assertEqual( + "I addressed everything by doing X.", + dashboard_override.dashboard_command_body_remainder( + {"body": "/dashboard route:reviewers\n\nI addressed everything by doing X."} + ), + ) + def test_latest_authorized_command_accepts_author_and_approvers(self) -> None: raw = { "issue_comments": [ 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 6d7c685d5c1..abee639a1a1 100644 --- a/.github/scripts/pull-request-dashboard/test_top_level_actions.py +++ b/.github/scripts/pull-request-dashboard/test_top_level_actions.py @@ -183,6 +183,39 @@ def classify_feedback_domains( return review_classifications, top_level_classifications +class NormalizeEventsCommandTest(unittest.TestCase): + def _issue_comment_events(self, body: str) -> list[dict]: + events = normalize_events( + { + "commits": [], + "issue_comments": [ + { + "id": 1, + "user": {"login": "author"}, + "created_at": "2026-07-14T00:00:00Z", + "body": body, + } + ], + "review_comments": [], + "reviews": [], + }, + "author", + set(), + ) + return [e for e in events if e["kind"] == "issue-comment"] + + def test_command_only_comment_is_dropped(self) -> None: + self.assertEqual([], self._issue_comment_events("/dashboard route:reviewers")) + + def test_command_with_explanation_keeps_the_explanation(self) -> None: + events = self._issue_comment_events( + "/dashboard route:reviewers\n\nI addressed the feedback by doing X." + ) + + self.assertEqual(1, len(events)) + self.assertEqual("I addressed the feedback by doing X.", events[0]["body"]) + + class TopLevelActionLedgerTest(unittest.TestCase): def test_inline_prompt_treats_author_inability_as_completed_reply(self) -> None: discussion = review_thread_discussion("inline") From 319f372f6e533a883a46b1dee624700bff63359c Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 21 Jul 2026 11:48:49 -0700 Subject: [PATCH 29/44] Address review comment from copilot-pull-request-reviewer: read PR head OID from the PR object Add headRefOid to gh_pr_view and read the head OID from the PR object instead of deriving it from raw["commits"], whose REST endpoint truncates at 250 commits and would otherwise select the wrong head on large PRs. --- .../pull-request-dashboard/dashboard.py | 5 ++- .../pull-request-dashboard/github_cli.py | 2 +- .../pull-request-dashboard/test_dashboard.py | 43 +++++++++++++++++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/dashboard.py b/.github/scripts/pull-request-dashboard/dashboard.py index f039be98738..81cdc4b814c 100644 --- a/.github/scripts/pull-request-dashboard/dashboard.py +++ b/.github/scripts/pull-request-dashboard/dashboard.py @@ -558,7 +558,10 @@ def compute_facts( api_author = actor_login(pr.get("author") or {}) assignees = [reviewer_actor_login(a) for a in (pr.get("assignees") or [])] assignees = [a for a in assignees if a] - head_sha = ((raw.get("commits") or [{}])[-1].get("sha") or "") + # Read the head OID straight from the PR object. Deriving it from + # raw["commits"] is wrong for PRs with more than 250 commits, where the + # commits REST endpoint truncates and the last entry is not the real head. + head_sha = pr.get("headRefOid") or "" copilot_review_exists, copilot_review_needed = copilot_review_status( raw.get("reviews") or [], head_sha, diff --git a/.github/scripts/pull-request-dashboard/github_cli.py b/.github/scripts/pull-request-dashboard/github_cli.py index 8f643ff02d5..225d64d39e5 100644 --- a/.github/scripts/pull-request-dashboard/github_cli.py +++ b/.github/scripts/pull-request-dashboard/github_cli.py @@ -136,7 +136,7 @@ def gh_graphql(query: str, fields: dict[str, Any], token: str | None = None) -> def gh_pr_view(repo: str, number: int) -> dict[str, Any]: fields = ",".join([ "id", "number", "title", "url", "author", "state", "isDraft", - "mergeable", "mergeStateStatus", "createdAt", "updatedAt", + "mergeable", "mergeStateStatus", "createdAt", "updatedAt", "headRefOid", "reviewDecision", "reviewRequests", "assignees", "baseRefName", "labels", ]) cmd = ["gh", "pr", "view", str(number), "--repo", repo, "--json", fields] diff --git a/.github/scripts/pull-request-dashboard/test_dashboard.py b/.github/scripts/pull-request-dashboard/test_dashboard.py index 2265149d24e..549cfabba36 100644 --- a/.github/scripts/pull-request-dashboard/test_dashboard.py +++ b/.github/scripts/pull-request-dashboard/test_dashboard.py @@ -238,6 +238,7 @@ def test_current_head_matches_latest_clean_copilot_review(self) -> None: ], "mergeStateStatus": "CLEAN", "mergeable": "MERGEABLE", + "headRefOid": "current-head", }, "reviews": [ { @@ -280,6 +281,7 @@ def test_late_stale_review_does_not_replace_clean_current_head_review(self) -> N "reviewRequests": [], "mergeStateStatus": "CLEAN", "mergeable": "MERGEABLE", + "headRefOid": "current-head", }, "reviews": [ { @@ -318,6 +320,7 @@ def test_push_since_latest_clean_copilot_review_needs_rereview(self) -> None: "reviewRequests": [], "mergeStateStatus": "CLEAN", "mergeable": "MERGEABLE", + "headRefOid": "current-head", }, "reviews": [ { @@ -350,6 +353,7 @@ def test_latest_findings_review_replaces_clean_review_on_same_head(self) -> None "reviewRequests": [], "mergeStateStatus": "CLEAN", "mergeable": "MERGEABLE", + "headRefOid": "current-head", }, "reviews": [ { @@ -388,6 +392,7 @@ def test_findings_only_history_needs_rereview(self) -> None: "reviewRequests": [], "mergeStateStatus": "CLEAN", "mergeable": "MERGEABLE", + "headRefOid": "current-head", }, "reviews": [ { @@ -539,6 +544,44 @@ def test_discussion_url_is_excluded_from_classifier_input(self) -> None: self.assertNotIn("discussion_url", prompt_input) +class HeadShaSourceTest(unittest.TestCase): + def test_head_sha_prefers_pr_head_ref_oid_over_truncated_commits(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", + "headRefOid": "real-head", + }, + "reviews": [ + { + "id": 10, + "commit_id": "real-head", + "finding_count": 0, + "user": {"login": "copilot"}, + "submitted_at": "2026-07-20T02:30:00Z", + }, + ], + # The commits REST endpoint is bounded at 250 entries, so its + # last element is not the real head for a large PR. + "commits": [{"sha": "commit-1"}, {"sha": "commit-250"}], + "review_comments": [], + "checks": [], + }, + "author", + [], + ) + + self.assertEqual(facts["head_sha"], "real-head") + self.assertTrue(facts["copilot_review_exists"]) + self.assertFalse(facts["copilot_review_needed"]) + + class InitialBackfillCompletionTest(unittest.TestCase): def test_marks_complete_only_after_all_open_prs_are_cached(self) -> None: state = {"initial_backfill_complete": False, "prs": {"1": {}}} From 5297b33060be436e87428764dbbaff5861ab91f8 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 21 Jul 2026 11:52:29 -0700 Subject: [PATCH 30/44] Address review comment from copilot-pull-request-reviewer: trust only dashboard-app comments for reply markers Build replied_ids from a shared _replied_command_ids helper that only counts command-reply markers in dashboard-app comments, so a user cannot forge a marker to suppress an owed unauthorized/unknown/already-routed reply. Recognize both the REST (performed_via_github_app) and normalized GraphQL (bot user.login) comment shapes. --- .../dashboard_override.py | 37 +++++++++++---- .../test_dashboard_override.py | 46 ++++++++++++++++++- 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/dashboard_override.py b/.github/scripts/pull-request-dashboard/dashboard_override.py index 0a61d0da783..04c4d4c1e90 100644 --- a/.github/scripts/pull-request-dashboard/dashboard_override.py +++ b/.github/scripts/pull-request-dashboard/dashboard_override.py @@ -137,6 +137,33 @@ def dashboard_override_facts( } +def _is_dashboard_app_comment(comment: dict[str, Any]) -> bool: + """Whether a comment was authored by the dashboard GitHub App. + + Handles both the REST shape (``performed_via_github_app.slug``) and the + normalized GraphQL shape from ``fetch_pr_issue_comments`` (a bot + ``user.login`` of ``[bot]``). + """ + if (comment.get("performed_via_github_app") or {}).get("slug") == DASHBOARD_APP_SLUG: + return True + return (comment.get("user") or {}).get("login") == f"{DASHBOARD_APP_SLUG}[bot]" + + +def _replied_command_ids(comments: list[dict[str, Any]] | None) -> set[int]: + """Command ids already answered by a dashboard-app reply comment. + + Reply markers are matched only in comments authored by the dashboard app, so + a user cannot forge a `command-reply` marker to suppress an owed reply. + """ + replied_ids: set[int] = set() + for comment in comments or []: + if not _is_dashboard_app_comment(comment): + continue + for match in _COMMAND_REPLY_MARKER_RE.findall(comment.get("body") or ""): + replied_ids.add(int(match)) + return replied_ids + + def pending_command_replies( raw: dict[str, Any], author: str, @@ -151,10 +178,7 @@ def pending_command_replies( skipped so replies are posted at most once. """ comments = raw.get("issue_comments") or [] - replied_ids: set[int] = set() - for comment in comments: - for match in _COMMAND_REPLY_MARKER_RE.findall(comment.get("body") or ""): - replied_ids.add(int(match)) + replied_ids = _replied_command_ids(comments) replies: list[dict[str, Any]] = [] for comment in comments: @@ -309,10 +333,7 @@ def append_route_noop_reply( command_id = int(facts.get("dashboard_override_command_id") or 0) if not command_id: return - replied_ids: set[int] = set() - for comment in raw.get("issue_comments") or []: - for match in _COMMAND_REPLY_MARKER_RE.findall(comment.get("body") or ""): - replied_ids.add(int(match)) + replied_ids = _replied_command_ids(raw.get("issue_comments") or []) if command_id in replied_ids: return replies = facts.setdefault("dashboard_command_replies", []) diff --git a/.github/scripts/pull-request-dashboard/test_dashboard_override.py b/.github/scripts/pull-request-dashboard/test_dashboard_override.py index 9a497c1e98a..58815041a54 100644 --- a/.github/scripts/pull-request-dashboard/test_dashboard_override.py +++ b/.github/scripts/pull-request-dashboard/test_dashboard_override.py @@ -89,6 +89,25 @@ def test_already_replied_commands_are_not_repeated(self) -> None: self.assertEqual([], dashboard_override.pending_command_replies(raw, "author")) + def test_forged_marker_from_non_app_user_does_not_suppress_reply(self) -> None: + raw = { + "issue_comments": [ + {"id": 2, "user": {"login": "outsider"}, "body": "/dashboard route:reviewers"}, + { + "id": 9, + "user": {"login": "outsider"}, + "body": dashboard_override.command_reply_marker(2) + "\nnothing to see here", + }, + ] + } + + replies = dashboard_override.pending_command_replies(raw, "author") + + self.assertEqual( + [{"comment_id": 2, "kind": "unauthorized", "user": "outsider", "subcommand": "route:reviewers"}], + replies, + ) + def test_renders_unauthorized_and_unknown_command_replies(self) -> None: unauthorized = dashboard_override.render_command_reply( {"comment_id": 2, "kind": "unauthorized", "user": "outsider", "subcommand": "route:reviewers"} @@ -158,7 +177,10 @@ def test_already_routed_reply_deduped_by_existing_marker(self) -> None: } raw = { "issue_comments": [ - {"body": dashboard_override.command_reply_marker(12) + "\n@author ..."}, + { + "user": {"login": "opentelemetry-pr-dashboard[bot]"}, + "body": dashboard_override.command_reply_marker(12) + "\n@author ...", + }, ] } @@ -166,6 +188,28 @@ def test_already_routed_reply_deduped_by_existing_marker(self) -> None: self.assertNotIn("dashboard_command_replies", facts) + def test_forged_marker_does_not_dedupe_already_routed_reply(self) -> None: + facts = { + "author": "author", + "dashboard_override_noop": True, + "dashboard_override_command_id": 12, + } + raw = { + "issue_comments": [ + { + "user": {"login": "outsider"}, + "body": dashboard_override.command_reply_marker(12) + "\n@author ...", + }, + ] + } + + dashboard_override.append_route_noop_reply(raw, facts, "approver") + + self.assertEqual( + [{"comment_id": 12, "kind": "already_routed", "user": "author", "route": "approver"}], + facts["dashboard_command_replies"], + ) + @patch.object(dashboard_override, "run_gh") @patch.object(dashboard_override, "gh_api", return_value=[]) @patch.object( From 03749716f9b9c524a1c8839124f127b434f42ff2 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 21 Jul 2026 14:34:00 -0700 Subject: [PATCH 31/44] Address review comment from copilot-pull-request-reviewer: deliver already_routed reply reliably Track a newly observed authorized command (dashboard_override_command_new) separately from label-delivery intent so a command used while the label is already present still earns an already_routed reply, and carry unacknowledged already_routed replies forward across refreshes until the dashboard app posts the marker. --- .../dashboard_override.py | 33 ++++++++-- .../test_dashboard_override.py | 65 +++++++++++++++++++ 2 files changed, 92 insertions(+), 6 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/dashboard_override.py b/.github/scripts/pull-request-dashboard/dashboard_override.py index 04c4d4c1e90..e53cbd89733 100644 --- a/.github/scripts/pull-request-dashboard/dashboard_override.py +++ b/.github/scripts/pull-request-dashboard/dashboard_override.py @@ -126,14 +126,33 @@ def dashboard_override_facts( or bool(previous_facts.get("dashboard_override_requested")) ) ) + # A newly observed authorized command, tracked separately from the intent to + # apply the label so it can still earn an `already_routed` reply when the + # label is already present and the route is at or past reviewers. + command_new = command_id > previous_command_id + replies = pending_command_replies(raw, author, reviewers) + # Carry forward an unacknowledged `already_routed` reply so it survives + # refreshes until the publisher posts it, mirroring how the pending label + # request is preserved. Drop it once the dashboard app has posted the marker. + posted_ids = _replied_command_ids(raw.get("issue_comments")) + reply_ids = {int(reply.get("comment_id") or 0) for reply in replies} + for reply in previous_facts.get("dashboard_command_replies") or []: + if reply.get("kind") != "already_routed": + continue + reply_id = int(reply.get("comment_id") or 0) + if reply_id in posted_ids or reply_id in reply_ids: + continue + replies.append(reply) + reply_ids.add(reply_id) return { "dashboard_override": label_applied, "dashboard_override_label_applied": label_applied, "dashboard_override_command_id": command_id, "dashboard_override_command_user": command_user, + "dashboard_override_command_new": command_new, "dashboard_override_requested": label_requested, "dashboard_override_release_requested": False, - "dashboard_command_replies": pending_command_replies(raw, author, reviewers), + "dashboard_command_replies": replies, } @@ -303,16 +322,18 @@ def deliver_dashboard_command_replies(repo: str) -> list[str]: def apply_dashboard_override(facts: dict[str, Any], route: str) -> str: label_applied = bool(facts.get("dashboard_override_label_applied")) requested = bool(facts.get("dashboard_override_requested")) - command_applies = requested and route in OVERRIDABLE_ROUTES + command_new = bool(facts.get("dashboard_override_command_new")) # The override only takes effect from a pre-review route (waiting on the # author or an external dependency). On a route that is already at or past # reviewers the natural routing stands, so a label left behind cannot pin # the pull request at reviewers or drag it back from maintainers. override_applies = route in OVERRIDABLE_ROUTES and (label_applied or requested) - # A pending command that cannot apply because the pull request is not on an - # overridable route is a no-op; the author is told where it is routed. - facts["dashboard_override_noop"] = requested and not command_applies - if requested and not command_applies: + # A command that does not move the pull request to reviewers is a no-op; the + # author is told where it is routed. This covers both a pending label request + # on a non-overridable route and a fresh authorized command observed while the + # label is already applied and the route is already at or past reviewers. + facts["dashboard_override_noop"] = (requested or command_new) and not override_applies + if requested and not override_applies: facts["dashboard_override_requested"] = False facts["dashboard_override"] = override_applies # Release the label once automatic routing reaches or passes reviewers, so a diff --git a/.github/scripts/pull-request-dashboard/test_dashboard_override.py b/.github/scripts/pull-request-dashboard/test_dashboard_override.py index 58815041a54..8839fc4ba67 100644 --- a/.github/scripts/pull-request-dashboard/test_dashboard_override.py +++ b/.github/scripts/pull-request-dashboard/test_dashboard_override.py @@ -326,6 +326,71 @@ def test_newer_command_reapplies_removed_override(self) -> None: self.assertTrue(facts["dashboard_override_requested"]) self.assertEqual(5, facts["dashboard_override_command_id"]) + def test_preserves_unacknowledged_already_routed_reply_across_refreshes(self) -> None: + previous = { + "dashboard_override_command_id": 5, + "dashboard_command_replies": [ + {"comment_id": 5, "kind": "already_routed", "user": "author", "route": "approver"}, + ], + } + raw = { + "issue_comments": [ + {"id": 5, "user": {"login": "author"}, "body": "/dashboard route:reviewers"}, + ] + } + + facts = dashboard_override.dashboard_override_facts( + raw, + "author", + {"dashboard:route-overridden"}, + previous, + ) + + self.assertEqual( + [{"comment_id": 5, "kind": "already_routed", "user": "author", "route": "approver"}], + facts["dashboard_command_replies"], + ) + + def test_drops_already_routed_reply_once_app_has_posted_it(self) -> None: + previous = { + "dashboard_override_command_id": 5, + "dashboard_command_replies": [ + {"comment_id": 5, "kind": "already_routed", "user": "author", "route": "approver"}, + ], + } + raw = { + "issue_comments": [ + {"id": 5, "user": {"login": "author"}, "body": "/dashboard route:reviewers"}, + { + "id": 9, + "user": {"login": "opentelemetry-pr-dashboard[bot]"}, + "body": dashboard_override.command_reply_marker(5) + "\n@author ...", + }, + ] + } + + facts = dashboard_override.dashboard_override_facts( + raw, + "author", + {"dashboard:route-overridden"}, + previous, + ) + + self.assertEqual([], facts["dashboard_command_replies"]) + + def test_fresh_command_with_label_present_is_a_noop(self) -> None: + facts = { + "dashboard_override_label_applied": True, + "dashboard_override_requested": False, + "dashboard_override_command_new": True, + } + + route = dashboard_override.apply_dashboard_override(facts, "approver") + + self.assertEqual("approver", route) + self.assertTrue(facts["dashboard_override_noop"]) + self.assertTrue(facts["dashboard_override_release_requested"]) + def test_command_overrides_pre_review_routes(self) -> None: for route, expected_route, expected_pending in ( ("author", "approver", True), From 81ea32a5004d4a7b1e98af2af22a8e843cb2a678 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 21 Jul 2026 15:43:19 -0700 Subject: [PATCH 32/44] Address dashboard override review findings Make reviewer-routing commands durable through app-authored acknowledgements, restrict them to author handoffs, and simplify repository-wide delivery APIs. --- .../pull-request-dashboard/dashboard.py | 7 +- .../dashboard_override.py | 166 ++++++----- .../pull-request-dashboard/delivery.py | 47 +-- .../pull-request-dashboard/github_cli.py | 8 - .../pull-request-dashboard/notifications.py | 9 - .../pull-request-dashboard/notify_slack.py | 16 +- .../pr_status_comment.py | 16 +- .../test_dashboard_override.py | 280 +++++++++++++----- .../pull-request-dashboard/test_delivery.py | 50 +++- .../pull-request-dashboard/test_github_cli.py | 17 -- .../test_notify_slack.py | 11 +- .../test_pr_status_comment.py | 97 ------ 12 files changed, 387 insertions(+), 337 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/dashboard.py b/.github/scripts/pull-request-dashboard/dashboard.py index 81cdc4b814c..a6dbfaddd98 100644 --- a/.github/scripts/pull-request-dashboard/dashboard.py +++ b/.github/scripts/pull-request-dashboard/dashboard.py @@ -542,7 +542,6 @@ def compute_facts( author: str, events: list[dict[str, Any]], reviewers: set[str] | None = None, - previous_facts: dict[str, Any] | None = None, ) -> dict[str, Any]: pr = raw["pr"] checks = raw["checks"] @@ -575,7 +574,7 @@ def compute_facts( "author": author, "assignees": assignees, "head_sha": head_sha, - **dashboard_override_facts(raw, author, labels, previous_facts, reviewers or set()), + **dashboard_override_facts(raw, author, labels, reviewers or set()), "copilot_review_requested": any( is_copilot_reviewer(request) for request in (pr.get("reviewRequests") or []) @@ -1434,7 +1433,6 @@ def build_pr_result( required_approvals: int, non_blocking_check_patterns: list[str], previous_top_level_history: dict[str, dict[str, Any]] | None = None, - previous_facts: dict[str, Any] | None = None, require_clean_copilot_review_branches: list[str] | None = None, ) -> dict[str, Any] | None: number = pr_summary["number"] @@ -1450,7 +1448,7 @@ def build_pr_result( return None author = effective_author(raw) events = normalize_events(raw, author, reviewers) - facts = compute_facts(raw, author, events, reviewers, previous_facts) + facts = compute_facts(raw, author, events, reviewers) review_threads = group_review_threads(raw, author, reviewers, facts) top_level_items = derive_top_level_items(events, facts) top_level_author_comment_items = derive_top_level_author_comment_items( @@ -1631,7 +1629,6 @@ 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 {}, - previous_facts=(starting_pr_result or {}).get("facts") or {}, require_clean_copilot_review_branches=require_clean_copilot_review_branches, ) if trigger_pr_result is None: diff --git a/.github/scripts/pull-request-dashboard/dashboard_override.py b/.github/scripts/pull-request-dashboard/dashboard_override.py index e53cbd89733..c561136cd78 100644 --- a/.github/scripts/pull-request-dashboard/dashboard_override.py +++ b/.github/scripts/pull-request-dashboard/dashboard_override.py @@ -24,12 +24,10 @@ _COMMAND_REPLY_MARKER_RE = re.compile( r"" ) -# Automatic routes from which "route to reviewers" is a meaningful override: the -# pull request is not yet in review. `approver` is already at reviewers and -# `maintainer` is past them, so the override must not force those; `copilot` -# routing reflects a required review gate that the override should not bypass. -OVERRIDABLE_ROUTES = ("author", "external") -REVIEWERS_OR_LATER_ROUTES = ("approver", "maintainer") +OVERRIDE_ACK_MARKER_PREFIX = "" +) def author_override_guidance(staleness_note: str = "") -> str: @@ -84,6 +82,7 @@ def latest_authorized_command( author: str, reviewers: set[str] | None, ) -> tuple[int, str]: + acknowledged_id = _acknowledged_override_command_id(raw.get("issue_comments")) best_id = 0 best_user = "" for comment in raw.get("issue_comments") or []: @@ -96,6 +95,8 @@ def latest_authorized_command( comment_id = int(comment.get("id")) except (TypeError, ValueError): continue + if comment_id <= acknowledged_id: + continue if comment_id > best_id: best_id, best_user = comment_id, commenter return best_id, best_user @@ -105,54 +106,19 @@ def dashboard_override_facts( raw: dict[str, Any], author: str, labels: set[str], - previous_facts: dict[str, Any] | None, reviewers: set[str] | None = None, ) -> dict[str, Any]: label_applied = DASHBOARD_OVERRIDE_LABEL in labels - previous_facts = previous_facts or {} - previous_command_id = int( - previous_facts.get("dashboard_override_command_id") or 0 - ) - latest_id, latest_user = latest_authorized_command(raw, author, reviewers) - command_id = max(previous_command_id, latest_id) - if latest_id > previous_command_id: - command_user = latest_user - else: - command_user = str(previous_facts.get("dashboard_override_command_user") or "") - label_requested = ( - not label_applied - and ( - command_id > previous_command_id - or bool(previous_facts.get("dashboard_override_requested")) - ) - ) - # A newly observed authorized command, tracked separately from the intent to - # apply the label so it can still earn an `already_routed` reply when the - # label is already present and the route is at or past reviewers. - command_new = command_id > previous_command_id - replies = pending_command_replies(raw, author, reviewers) - # Carry forward an unacknowledged `already_routed` reply so it survives - # refreshes until the publisher posts it, mirroring how the pending label - # request is preserved. Drop it once the dashboard app has posted the marker. - posted_ids = _replied_command_ids(raw.get("issue_comments")) - reply_ids = {int(reply.get("comment_id") or 0) for reply in replies} - for reply in previous_facts.get("dashboard_command_replies") or []: - if reply.get("kind") != "already_routed": - continue - reply_id = int(reply.get("comment_id") or 0) - if reply_id in posted_ids or reply_id in reply_ids: - continue - replies.append(reply) - reply_ids.add(reply_id) + command_id, command_user = latest_authorized_command(raw, author, reviewers) + command_pending = bool(command_id) return { "dashboard_override": label_applied, "dashboard_override_label_applied": label_applied, "dashboard_override_command_id": command_id, "dashboard_override_command_user": command_user, - "dashboard_override_command_new": command_new, - "dashboard_override_requested": label_requested, + "dashboard_override_requested": command_pending and not label_applied, "dashboard_override_release_requested": False, - "dashboard_command_replies": replies, + "dashboard_command_replies": pending_command_replies(raw, author, reviewers), } @@ -183,6 +149,18 @@ def _replied_command_ids(comments: list[dict[str, Any]] | None) -> set[int]: return replied_ids +def _acknowledged_override_command_id( + comments: list[dict[str, Any]] | None, +) -> int: + acknowledged_id = 0 + for comment in comments or []: + if not _is_dashboard_app_comment(comment): + continue + for match in _OVERRIDE_ACK_MARKER_RE.findall(comment.get("body") or ""): + acknowledged_id = max(acknowledged_id, int(match)) + return acknowledged_id + + def pending_command_replies( raw: dict[str, Any], author: str, @@ -230,6 +208,10 @@ def command_reply_marker(comment_id: int) -> str: return f"{COMMAND_REPLY_MARKER_PREFIX}{comment_id} -->" +def override_ack_marker(comment_id: int) -> str: + return f"{OVERRIDE_ACK_MARKER_PREFIX}{comment_id} -->" + + ROUTE_ALREADY_ROUTED_PHRASE = { "approver": "already waiting on reviewers", "maintainer": "already past review and waiting on maintainers", @@ -247,6 +229,8 @@ def render_command_reply(reply: dict[str, Any]) -> str: "only the pull request author or a member of an approving team can " "use `/dashboard route:reviewers`." ) + elif kind == "routed": + message = "routed this pull request to reviewers." elif kind == "already_routed": where = ROUTE_ALREADY_ROUTED_PHRASE.get( reply.get("route") or "", "not currently waiting on you" @@ -265,8 +249,12 @@ def render_command_reply(reply: dict[str, Any]) -> str: "request author can use to move a pull request from waiting on the " "author to waiting on reviewers." ) + comment_id = int(reply["comment_id"]) + markers = [command_reply_marker(comment_id)] + if kind in ("routed", "already_routed"): + markers.append(override_ack_marker(comment_id)) return "\n".join([ - command_reply_marker(int(reply["comment_id"])), + *markers, f"{mention}{message}", "", ]) @@ -284,6 +272,24 @@ def command_reply_exists( ) +def ensure_command_reply( + repo: str, + pr_number: int, + reply: dict[str, Any], +) -> None: + comments = gh_api( + f"/repos/{repo}/issues/{pr_number}/comments?per_page=100", + paginate=True, + ) + if command_reply_exists(comments, int(reply["comment_id"])): + return + run_gh([ + "gh", "api", "--method", "POST", + f"repos/{repo}/issues/{pr_number}/comments", + "-f", f"body={render_command_reply(reply)}", + ]) + + def deliver_dashboard_command_replies(repo: str) -> list[str]: dashboard_state = load_dashboard_state_cache() if dashboard_state is None: @@ -322,24 +328,24 @@ def deliver_dashboard_command_replies(repo: str) -> list[str]: def apply_dashboard_override(facts: dict[str, Any], route: str) -> str: label_applied = bool(facts.get("dashboard_override_label_applied")) requested = bool(facts.get("dashboard_override_requested")) - command_new = bool(facts.get("dashboard_override_command_new")) - # The override only takes effect from a pre-review route (waiting on the - # author or an external dependency). On a route that is already at or past - # reviewers the natural routing stands, so a label left behind cannot pin - # the pull request at reviewers or drag it back from maintainers. - override_applies = route in OVERRIDABLE_ROUTES and (label_applied or requested) - # A command that does not move the pull request to reviewers is a no-op; the - # author is told where it is routed. This covers both a pending label request - # on a non-overridable route and a fresh authorized command observed while the - # label is already applied and the route is already at or past reviewers. - facts["dashboard_override_noop"] = (requested or command_new) and not override_applies + command_pending = bool(facts.get("dashboard_override_command_id")) + # The override only takes effect while automatic routing waits on the author. + # On every other route the natural routing stands. + override_applies = route == "author" and (label_applied or requested) + # A command that does not newly move the pull request to reviewers is a no-op; + # the author is told where it is routed. This covers both a non-overridable + # route and an existing label that already provides the reviewer handoff. + facts["dashboard_override_noop"] = command_pending and ( + label_applied or not override_applies + ) if requested and not override_applies: facts["dashboard_override_requested"] = False facts["dashboard_override"] = override_applies - # Release the label once automatic routing reaches or passes reviewers, so a - # forgotten override cannot hold the pull request at reviewers forever. + # Release the label once automatic routing no longer waits on the author, so + # a forgotten override cannot bypass a later external dependency or linger + # after routing reaches reviewers. facts["dashboard_override_release_requested"] = ( - label_applied and route in REVIEWERS_OR_LATER_ROUTES + label_applied and route != "author" ) return "approver" if override_applies else route @@ -369,25 +375,30 @@ def append_route_noop_reply( def deliver_dashboard_override_requests(repo: str) -> list[str]: - try: - run_gh([ - "gh", "label", "create", DASHBOARD_OVERRIDE_LABEL, - "--repo", repo, - "--color", DASHBOARD_OVERRIDE_LABEL_COLOR, - "--description", DASHBOARD_OVERRIDE_LABEL_DESCRIPTION, - "--force", - ]) - except Exception as e: - return [f"label: {e}"] - dashboard_state = load_dashboard_state_cache() if dashboard_state is None: return [] - errors: list[str] = [] - for key, result in sorted( + pr_results = sorted( (dashboard_state.get("prs") or {}).items(), key=lambda item: int(item[0]), + ) + if any( + ((result or {}).get("facts") or {}).get("dashboard_override_requested") + for _key, result in pr_results ): + try: + run_gh([ + "gh", "label", "create", DASHBOARD_OVERRIDE_LABEL, + "--repo", repo, + "--color", DASHBOARD_OVERRIDE_LABEL_COLOR, + "--description", DASHBOARD_OVERRIDE_LABEL_DESCRIPTION, + "--force", + ]) + except Exception as e: + return [f"label: {e}"] + + errors: list[str] = [] + for key, result in pr_results: facts = (result or {}).get("facts") or {} pr_number = int(key) if facts.get("dashboard_override_requested"): @@ -397,6 +408,15 @@ def deliver_dashboard_override_requests(repo: str) -> list[str]: f"repos/{repo}/issues/{pr_number}/labels", "-f", f"labels[]={DASHBOARD_OVERRIDE_LABEL}", ]) + ensure_command_reply( + repo, + pr_number, + { + "comment_id": facts["dashboard_override_command_id"], + "kind": "routed", + "user": facts.get("dashboard_override_command_user") or "", + }, + ) except Exception as e: errors.append(f"PR #{pr_number}: {e}") elif facts.get("dashboard_override_release_requested"): diff --git a/.github/scripts/pull-request-dashboard/delivery.py b/.github/scripts/pull-request-dashboard/delivery.py index cd94d09017d..131f0ad8bd0 100644 --- a/.github/scripts/pull-request-dashboard/delivery.py +++ b/.github/scripts/pull-request-dashboard/delivery.py @@ -16,7 +16,7 @@ deliver_dashboard_command_replies, deliver_dashboard_override_requests, ) -from github_cli import detect_repo, list_all_open_pr_numbers, normalize_repo, repo_state_key +from github_cli import detect_repo, list_open_prs, normalize_repo, repo_state_key from notify_slack import notify_slack_from_state from pr_status_comment import update_status_comments_from_state from state import ( @@ -56,6 +56,11 @@ def deliver_from_state( ) -> list[str]: now = utc_now() errors: list[str] = [] + try: + open_prs = list_open_prs(repo) + except Exception as e: + errors.append(f"open pull requests: {e}") + open_prs = None run_delivery_action( "dashboard overrides", lambda: deliver_dashboard_override_requests(repo), @@ -75,31 +80,31 @@ def deliver_from_state( ), errors, ) - run_delivery_action( - "status comments", - lambda: update_status_comments_from_state( - repo, - None, - list_all_open_pr_numbers(repo), - ), - errors, - ) + if open_prs is not None: + run_delivery_action( + "status comments", + lambda: update_status_comments_from_state( + repo, + {pr["number"] for pr in open_prs}, + ), + errors, + ) run_delivery_action( "Copilot reviews", lambda: deliver_copilot_review_requests(repo, now, copilot_retry_snapshot_path), errors, ) - run_delivery_action( - "Slack notifications", - lambda: notify_slack_from_state( - repo, - notification_retry_snapshot_path, - None, - None, - now, - ), - errors, - ) + if open_prs is not None: + run_delivery_action( + "Slack notifications", + lambda: notify_slack_from_state( + repo, + notification_retry_snapshot_path, + open_prs, + now, + ), + errors, + ) return errors diff --git a/.github/scripts/pull-request-dashboard/github_cli.py b/.github/scripts/pull-request-dashboard/github_cli.py index 225d64d39e5..7c838004623 100644 --- a/.github/scripts/pull-request-dashboard/github_cli.py +++ b/.github/scripts/pull-request-dashboard/github_cli.py @@ -617,14 +617,6 @@ def list_open_prs(repo: str) -> list[dict[str, Any]]: ] -def list_all_open_pr_numbers(repo: str) -> set[int]: - return { - pull["number"] - for pull in _list_open_pulls(repo) - if isinstance(pull, dict) and isinstance(pull.get("number"), int) - } - - def detect_repo() -> str: proc = subprocess.run( ["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"], diff --git a/.github/scripts/pull-request-dashboard/notifications.py b/.github/scripts/pull-request-dashboard/notifications.py index 0fb5dc2d4bb..8044ee0e651 100644 --- a/.github/scripts/pull-request-dashboard/notifications.py +++ b/.github/scripts/pull-request-dashboard/notifications.py @@ -190,8 +190,6 @@ def next_notifications( results: dict[int, dict[str, Any]], last_notifications: dict[str, Any] | None, now: datetime, - notification_numbers: set[int] | None = None, - notification_kinds: set[str] | None = None, ) -> tuple[dict[str, Any], list[str]]: has_last_notifications = last_notifications is not None previous_notifications = last_notifications or {} @@ -208,11 +206,6 @@ def next_notifications( pr_key = str(number) last_pr_notification = migrated_pr_notification(previous_notifications.get(pr_key) or {}) - if notification_numbers is not None and number not in notification_numbers: - if last_pr_notification: - updated_notifications[pr_key] = last_pr_notification - continue - route = result.get("route") or "unknown" if result.get("failed") or route in ("transient-failure", "unknown"): if last_pr_notification: @@ -238,8 +231,6 @@ def next_notifications( kind = pending_notification_kind( notification_baseline, current_waiting_since, now, ) - if kind and notification_kinds is not None and kind not in notification_kinds: - kind = None updated_pr_notification: dict[str, Any] = { "last_notified_at": last_pr_notification.get("last_notified_at") or "", diff --git a/.github/scripts/pull-request-dashboard/notify_slack.py b/.github/scripts/pull-request-dashboard/notify_slack.py index a39ab1d8fc6..d0bd319382e 100644 --- a/.github/scripts/pull-request-dashboard/notify_slack.py +++ b/.github/scripts/pull-request-dashboard/notify_slack.py @@ -8,7 +8,6 @@ from typing import Any from datetime import datetime -from github_cli import list_open_prs from notifications import next_notifications from state import ( load_dashboard_state_cache, @@ -18,7 +17,6 @@ save_notifications, union_merge_notifications, ) -from utils import utc_now def last_notifications( @@ -41,19 +39,17 @@ def last_notifications( def notify_slack_from_state( repo: str, retry_snapshot_path: Path | None, - notification_numbers: set[int] | None, - notification_kinds: set[str] | None, - now: datetime | None = None, + open_prs: list[dict[str, Any]], + now: datetime, ) -> list[str]: dashboard_state = load_dashboard_state_cache() if dashboard_state is None: print("dashboard state not found; skipping Slack notifications", file=sys.stderr) return [] - prs = list_open_prs(repo) - open_pr_numbers = {p["number"] for p in prs if not p.get("isDraft")} + open_pr_numbers = {p["number"] for p in open_prs if not p.get("isDraft")} results = results_from_dashboard_state(dashboard_state, open_pr_numbers) - current_prs = {p["number"]: p for p in prs} + current_prs = {p["number"]: p for p in open_prs} for number, result in results.items(): result["pr_title"] = current_prs.get(number, {}).get("title") or "" @@ -63,9 +59,7 @@ def notify_slack_from_state( repo, results, last_notifications(saved_notifications, retry_snapshot_path), - now or utc_now(), - notification_numbers=notification_numbers, - notification_kinds=notification_kinds, + now, ) notifications_changed = updated_notifications != (saved_notifications or {}) if not notifications_changed and saved_notifications is not None: diff --git a/.github/scripts/pull-request-dashboard/pr_status_comment.py b/.github/scripts/pull-request-dashboard/pr_status_comment.py index 3264a9e008e..48975d75ee5 100644 --- a/.github/scripts/pull-request-dashboard/pr_status_comment.py +++ b/.github/scripts/pull-request-dashboard/pr_status_comment.py @@ -317,8 +317,7 @@ def prepare_rollout_state( def update_status_comments_from_state( repo: str, - pr_number: int | None, - open_pr_numbers: set[int] | None, + open_pr_numbers: set[int], ) -> list[str]: dashboard_state = load_dashboard_state_cache() if dashboard_state is None: @@ -326,21 +325,8 @@ def update_status_comments_from_state( return [] saved_rollout_state = load_status_comment_rollout_state() - if open_pr_numbers is None: - raise RuntimeError("open PR numbers are required for a status comment update") queued_pr_numbers = set(saved_rollout_state.get("pending_pr_numbers") or []) rollout_state = prepare_rollout_state(saved_rollout_state, open_pr_numbers) - if pr_number is not None: - publish_pr_status(repo, pr_number, dashboard_state) - pending = set(rollout_state["pending_pr_numbers"]) - pending.discard(pr_number) - rollout_state["pending_pr_numbers"] = sorted(pending) - if not pending and rollout_state["target_revision"] == STATUS_COMMENT_REVISION: - rollout_state["completed_revision"] = STATUS_COMMENT_REVISION - if rollout_state != saved_rollout_state: - save_status_comment_rollout_state(rollout_state) - return [] - pending_pr_numbers = set(rollout_state["pending_pr_numbers"]) | queued_pr_numbers rollout_pr_numbers = sorted(pending_pr_numbers)[:STATUS_COMMENT_ROLLOUT_BATCH_SIZE] successful_pr_numbers: set[int] = set() diff --git a/.github/scripts/pull-request-dashboard/test_dashboard_override.py b/.github/scripts/pull-request-dashboard/test_dashboard_override.py index 8839fc4ba67..a36ee72a069 100644 --- a/.github/scripts/pull-request-dashboard/test_dashboard_override.py +++ b/.github/scripts/pull-request-dashboard/test_dashboard_override.py @@ -41,6 +41,23 @@ def test_latest_authorized_command_accepts_author_and_approvers(self) -> None: dashboard_override.latest_authorized_command(raw, "author", {"approver"}), ) + def test_latest_authorized_command_ignores_app_acknowledged_command(self) -> None: + raw = { + "issue_comments": [ + {"id": 3, "user": {"login": "author"}, "body": "/dashboard route:reviewers"}, + { + "id": 4, + "user": {"login": "opentelemetry-pr-dashboard[bot]"}, + "body": dashboard_override.override_ack_marker(3), + }, + ] + } + + self.assertEqual( + (0, ""), + dashboard_override.latest_authorized_command(raw, "author", set()), + ) + def test_is_authorized_commander_matches_author_or_approver(self) -> None: self.assertTrue( dashboard_override.is_authorized_commander("Author", "author", set()) @@ -108,13 +125,16 @@ def test_forged_marker_from_non_app_user_does_not_suppress_reply(self) -> None: replies, ) - def test_renders_unauthorized_and_unknown_command_replies(self) -> None: + def test_renders_command_replies(self) -> None: unauthorized = dashboard_override.render_command_reply( {"comment_id": 2, "kind": "unauthorized", "user": "outsider", "subcommand": "route:reviewers"} ) unknown = dashboard_override.render_command_reply( {"comment_id": 3, "kind": "unknown_command", "user": "reviewer", "subcommand": "frobnicate"} ) + routed = dashboard_override.render_command_reply( + {"comment_id": 4, "kind": "routed", "user": "author"} + ) self.assertIn(dashboard_override.command_reply_marker(2), unauthorized) self.assertIn( @@ -127,6 +147,9 @@ def test_renders_unauthorized_and_unknown_command_replies(self) -> None: "`/dashboard frobnicate` is not a recognized dashboard command.", unknown, ) + self.assertIn(dashboard_override.command_reply_marker(4), routed) + self.assertIn(dashboard_override.override_ack_marker(4), routed) + self.assertIn("@author routed this pull request to reviewers.", routed) def test_renders_already_routed_replies_per_route(self) -> None: cases = { @@ -141,6 +164,7 @@ def test_renders_already_routed_replies_per_route(self) -> None: {"comment_id": 7, "kind": "already_routed", "user": "author", "route": route} ) self.assertIn(dashboard_override.command_reply_marker(7), body) + self.assertIn(dashboard_override.override_ack_marker(7), body) self.assertIn(f"@author this pull request is {phrase}, so", body) self.assertIn("`/dashboard route:reviewers` had no effect", body) @@ -275,43 +299,53 @@ def test_delivery_skips_already_replied_command(self, _load_state, _gh_api, run_ self.assertEqual([], errors) run_gh.assert_not_called() - def test_command_stays_pending_until_label_is_observed(self) -> None: + def test_command_stays_pending_until_app_acknowledges_it(self) -> None: raw = { "issue_comments": [ {"id": 3, "user": {"login": "author"}, "body": "/dashboard route:reviewers"}, ] } - first = dashboard_override.dashboard_override_facts(raw, "author", set(), None) - retry = dashboard_override.dashboard_override_facts(raw, "author", set(), first) - acknowledged = dashboard_override.dashboard_override_facts( + first = dashboard_override.dashboard_override_facts(raw, "author", set()) + retry = dashboard_override.dashboard_override_facts(raw, "author", set()) + label_observed = dashboard_override.dashboard_override_facts( raw, "author", {"dashboard:route-overridden"}, - retry, ) - removed = dashboard_override.dashboard_override_facts( - raw, + acknowledged_raw = { + "issue_comments": [ + *raw["issue_comments"], + { + "id": 4, + "user": {"login": "opentelemetry-pr-dashboard[bot]"}, + "body": dashboard_override.override_ack_marker(3), + }, + ] + } + acknowledged = dashboard_override.dashboard_override_facts( + acknowledged_raw, "author", set(), - acknowledged, ) self.assertTrue(first["dashboard_override_requested"]) self.assertTrue(retry["dashboard_override_requested"]) - self.assertTrue(acknowledged["dashboard_override_label_applied"]) + self.assertTrue(label_observed["dashboard_override_label_applied"]) + self.assertEqual(3, label_observed["dashboard_override_command_id"]) + self.assertFalse(label_observed["dashboard_override_requested"]) + self.assertEqual(0, acknowledged["dashboard_override_command_id"]) self.assertFalse(acknowledged["dashboard_override_requested"]) - self.assertFalse(removed["dashboard_override"]) def test_newer_command_reapplies_removed_override(self) -> None: - previous = { - "dashboard_override": False, - "dashboard_override_command_id": 3, - "dashboard_override_requested": False, - } raw = { "issue_comments": [ {"id": 3, "user": {"login": "author"}, "body": "/dashboard route:reviewers"}, + { + "id": 4, + "user": {"login": "opentelemetry-pr-dashboard[bot]"}, + "body": dashboard_override.override_ack_marker(3), + }, {"id": 5, "user": {"login": "author"}, "body": "/dashboard route:reviewers"}, ] } @@ -320,51 +354,36 @@ def test_newer_command_reapplies_removed_override(self) -> None: raw, "author", set(), - previous, ) self.assertTrue(facts["dashboard_override_requested"]) self.assertEqual(5, facts["dashboard_override_command_id"]) - def test_preserves_unacknowledged_already_routed_reply_across_refreshes(self) -> None: - previous = { - "dashboard_override_command_id": 5, - "dashboard_command_replies": [ - {"comment_id": 5, "kind": "already_routed", "user": "author", "route": "approver"}, - ], - } + def test_rebuilds_unacknowledged_already_routed_reply_across_refreshes(self) -> None: raw = { "issue_comments": [ {"id": 5, "user": {"login": "author"}, "body": "/dashboard route:reviewers"}, ] } - facts = dashboard_override.dashboard_override_facts( - raw, - "author", - {"dashboard:route-overridden"}, - previous, - ) + for _ in range(2): + facts = dashboard_override.dashboard_override_facts(raw, "author", set()) + route = dashboard_override.apply_dashboard_override(facts, "approver") + dashboard_override.append_route_noop_reply(raw, facts, route) - self.assertEqual( - [{"comment_id": 5, "kind": "already_routed", "user": "author", "route": "approver"}], - facts["dashboard_command_replies"], - ) + self.assertEqual( + [{"comment_id": 5, "kind": "already_routed", "user": "author", "route": "approver"}], + facts["dashboard_command_replies"], + ) - def test_drops_already_routed_reply_once_app_has_posted_it(self) -> None: - previous = { - "dashboard_override_command_id": 5, - "dashboard_command_replies": [ - {"comment_id": 5, "kind": "already_routed", "user": "author", "route": "approver"}, - ], - } + def test_acknowledged_command_does_not_replay_after_cache_eviction(self) -> None: raw = { "issue_comments": [ {"id": 5, "user": {"login": "author"}, "body": "/dashboard route:reviewers"}, { "id": 9, "user": {"login": "opentelemetry-pr-dashboard[bot]"}, - "body": dashboard_override.command_reply_marker(5) + "\n@author ...", + "body": dashboard_override.override_ack_marker(5) + "\n@author ...", }, ] } @@ -372,17 +391,36 @@ def test_drops_already_routed_reply_once_app_has_posted_it(self) -> None: facts = dashboard_override.dashboard_override_facts( raw, "author", - {"dashboard:route-overridden"}, - previous, + set(), ) + self.assertEqual(0, facts["dashboard_override_command_id"]) + self.assertFalse(facts["dashboard_override_requested"]) self.assertEqual([], facts["dashboard_command_replies"]) + def test_newest_acknowledgement_consumes_older_authorized_commands(self) -> None: + raw = { + "issue_comments": [ + {"id": 3, "user": {"login": "author"}, "body": "/dashboard route:reviewers"}, + {"id": 5, "user": {"login": "author"}, "body": "/dashboard route:reviewers"}, + { + "id": 9, + "user": {"login": "opentelemetry-pr-dashboard[bot]"}, + "body": dashboard_override.override_ack_marker(5), + }, + ] + } + + self.assertEqual( + (0, ""), + dashboard_override.latest_authorized_command(raw, "author", set()), + ) + def test_fresh_command_with_label_present_is_a_noop(self) -> None: facts = { "dashboard_override_label_applied": True, "dashboard_override_requested": False, - "dashboard_override_command_new": True, + "dashboard_override_command_id": 5, } route = dashboard_override.apply_dashboard_override(facts, "approver") @@ -391,24 +429,48 @@ def test_fresh_command_with_label_present_is_a_noop(self) -> None: self.assertTrue(facts["dashboard_override_noop"]) self.assertTrue(facts["dashboard_override_release_requested"]) - def test_command_overrides_pre_review_routes(self) -> None: - for route, expected_route, expected_pending in ( - ("author", "approver", True), - ("external", "approver", True), - ("approver", "approver", False), - ("maintainer", "maintainer", False), - ("copilot", "copilot", False), + def test_fresh_command_gets_reply_when_label_already_overrides_author_route(self) -> None: + raw = { + "issue_comments": [ + {"id": 5, "user": {"login": "author"}, "body": "/dashboard route:reviewers"}, + ] + } + facts = dashboard_override.dashboard_override_facts( + raw, + "author", + {"dashboard:route-overridden"}, + ) + + route = dashboard_override.apply_dashboard_override(facts, "author") + dashboard_override.append_route_noop_reply(raw, facts, route) + + self.assertEqual("approver", route) + self.assertTrue(facts["dashboard_override_noop"]) + self.assertEqual( + [{"comment_id": 5, "kind": "already_routed", "user": "author", "route": "approver"}], + facts["dashboard_command_replies"], + ) + + def test_command_only_overrides_author_route(self) -> None: + for route, expected_route, expected_pending, expected_noop in ( + ("author", "approver", True, False), + ("external", "external", False, True), + ("approver", "approver", False, True), + ("maintainer", "maintainer", False, True), + ("copilot", "copilot", False, True), ): with self.subTest(route=route): facts = { "dashboard_override_label_applied": False, "dashboard_override_requested": True, + "dashboard_override_command_id": 5, } actual = dashboard_override.apply_dashboard_override(facts, route) self.assertEqual(expected_route, actual) self.assertEqual(expected_pending, facts["dashboard_override_requested"]) + self.assertEqual(expected_noop, facts["dashboard_override_noop"]) def test_label_yields_to_maintainer_route_and_releases(self) -> None: facts = { @@ -422,7 +484,7 @@ def test_label_yields_to_maintainer_route_and_releases(self) -> None: self.assertFalse(facts["dashboard_override"]) self.assertTrue(facts["dashboard_override_release_requested"]) - def test_label_holds_external_route_at_reviewers(self) -> None: + def test_label_yields_to_external_route_and_releases(self) -> None: facts = { "dashboard_override_label_applied": True, "dashboard_override_requested": False, @@ -430,9 +492,9 @@ def test_label_holds_external_route_at_reviewers(self) -> None: route = dashboard_override.apply_dashboard_override(facts, "external") - self.assertEqual("approver", route) - self.assertTrue(facts["dashboard_override"]) - self.assertFalse(facts["dashboard_override_release_requested"]) + self.assertEqual("external", route) + self.assertFalse(facts["dashboard_override"]) + self.assertTrue(facts["dashboard_override_release_requested"]) def test_releases_label_once_natural_route_reaches_reviewers(self) -> None: facts = { @@ -473,50 +535,72 @@ def test_delivery_removes_released_override_label(self, _load_state, run_gh) -> self.assertEqual([], errors) self.assertEqual( - call([ - "gh", "api", "--method", "DELETE", - "repos/open-telemetry/example/issues/9/labels/dashboard%3Aroute-overridden", - ]), - run_gh.call_args_list[-1], + [ + call([ + "gh", "api", "--method", "DELETE", + "repos/open-telemetry/example/issues/9/labels/dashboard%3Aroute-overridden", + ]), + ], + run_gh.call_args_list, ) + @patch.object(dashboard_override, "run_gh") + @patch.object(dashboard_override, "load_dashboard_state_cache", return_value=None) + def test_does_not_create_label_without_dashboard_state(self, _load_state, run_gh) -> None: + errors = dashboard_override.deliver_dashboard_override_requests( + "open-telemetry/example" + ) + + self.assertEqual([], errors) + run_gh.assert_not_called() + @patch.object(dashboard_override, "run_gh") @patch.object( dashboard_override, "load_dashboard_state_cache", return_value={"prs": {}}, ) - def test_creates_label_without_pending_command(self, _load_state, run_gh) -> None: + def test_does_not_create_label_without_pending_command(self, _load_state, run_gh) -> None: errors = dashboard_override.deliver_dashboard_override_requests( "open-telemetry/example" ) self.assertEqual([], errors) - run_gh.assert_called_once_with([ - "gh", "label", "create", "dashboard:route-overridden", - "--repo", "open-telemetry/example", - "--color", dashboard_override.DASHBOARD_OVERRIDE_LABEL_COLOR, - "--description", dashboard_override.DASHBOARD_OVERRIDE_LABEL_DESCRIPTION, - "--force", - ]) + run_gh.assert_not_called() @patch.object(dashboard_override, "run_gh") + @patch.object(dashboard_override, "gh_api", return_value=[]) @patch.object( dashboard_override, "load_dashboard_state_cache", return_value={ "prs": { - "7": {"facts": {"dashboard_override_requested": True}}, + "7": { + "facts": { + "dashboard_override_requested": True, + "dashboard_override_command_id": 3, + "dashboard_override_command_user": "author", + } + }, "8": {"facts": {"dashboard_override_requested": False}}, } }, ) - def test_delivers_pending_override_label(self, _load_state, run_gh) -> None: + def test_delivers_pending_override_label_and_acknowledges_command( + self, + _load_state, + gh_api, + run_gh, + ) -> None: errors = dashboard_override.deliver_dashboard_override_requests( "open-telemetry/example" ) self.assertEqual([], errors) + gh_api.assert_called_once_with( + "/repos/open-telemetry/example/issues/7/comments?per_page=100", + paginate=True, + ) self.assertEqual( [ call([ @@ -531,10 +615,64 @@ def test_delivers_pending_override_label(self, _load_state, run_gh) -> None: "repos/open-telemetry/example/issues/7/labels", "-f", "labels[]=dashboard:route-overridden", ]), + call([ + "gh", "api", "--method", "POST", + "repos/open-telemetry/example/issues/7/comments", + "-f", "body=\n\n@author routed this pull request to reviewers.\n", + ]), ], run_gh.call_args_list, ) + @patch.object(dashboard_override, "run_gh") + @patch.object(dashboard_override, "gh_api", return_value=[]) + @patch.object( + dashboard_override, + "load_dashboard_state_cache", + return_value={ + "prs": { + "7": { + "facts": { + "dashboard_override_requested": True, + "dashboard_override_command_id": 3, + "dashboard_override_command_user": "author", + } + }, + } + }, + ) + def test_retry_observes_acknowledgement_after_ambiguous_post_failure( + self, + _load_state, + gh_api, + run_gh, + ) -> None: + run_gh.side_effect = [None, None, RuntimeError("comment response lost")] + + errors = dashboard_override.deliver_dashboard_override_requests( + "open-telemetry/example" + ) + + self.assertEqual(["PR #7: comment response lost"], errors) + + run_gh.reset_mock() + run_gh.side_effect = None + gh_api.return_value = [{ + "performed_via_github_app": {"slug": "opentelemetry-pr-dashboard"}, + "body": dashboard_override.render_command_reply({ + "comment_id": 3, + "kind": "routed", + "user": "author", + }), + }] + + errors = dashboard_override.deliver_dashboard_override_requests( + "open-telemetry/example" + ) + + self.assertEqual([], errors) + self.assertEqual(2, run_gh.call_count) + if __name__ == "__main__": unittest.main() \ No newline at end of file diff --git a/.github/scripts/pull-request-dashboard/test_delivery.py b/.github/scripts/pull-request-dashboard/test_delivery.py index ea9324f6ea6..c646b8d1ad9 100644 --- a/.github/scripts/pull-request-dashboard/test_delivery.py +++ b/.github/scripts/pull-request-dashboard/test_delivery.py @@ -14,8 +14,15 @@ class DeliveryTest(unittest.TestCase): @patch.object(delivery, "deliver_dashboard_command_replies", return_value=[]) @patch.object(delivery, "deliver_dashboard_override_requests", return_value=[]) @patch.object(delivery, "update_status_comments_from_state", return_value=[]) - @patch.object(delivery, "list_all_open_pr_numbers", return_value={7, 8}) - def test_runs_all_targeted_deliveries_in_order( + @patch.object( + delivery, + "list_open_prs", + return_value=[ + {"number": 7, "isDraft": False, "title": "Seven"}, + {"number": 8, "isDraft": True, "title": "Eight"}, + ], + ) + def test_runs_all_repository_deliveries_in_order( self, _list_open, status_comments, @@ -45,20 +52,22 @@ def record(label: str) -> list[str]: ) self.assertEqual([], errors) + _list_open.assert_called_once_with("open-telemetry/example") self.assertEqual( [call("override"), call("replies"), call("author"), call("status"), call("copilot"), call("slack")], order.call_args_list, ) status_comments.assert_called_once_with( "open-telemetry/example", - None, {7, 8}, ) slack.assert_called_once_with( "open-telemetry/example", ANY, - None, - None, + [ + {"number": 7, "isDraft": False, "title": "Seven"}, + {"number": 8, "isDraft": True, "title": "Eight"}, + ], ANY, ) @@ -68,7 +77,11 @@ def record(label: str) -> list[str]: @patch.object(delivery, "deliver_dashboard_command_replies", return_value=[]) @patch.object(delivery, "deliver_dashboard_override_requests", return_value=[]) @patch.object(delivery, "update_status_comments_from_state", side_effect=RuntimeError("boom")) - @patch.object(delivery, "list_all_open_pr_numbers", return_value={7}) + @patch.object( + delivery, + "list_open_prs", + return_value=[{"number": 7, "isDraft": False, "title": "Seven"}], + ) def test_failure_does_not_block_later_deliveries( self, _list_open, @@ -93,6 +106,31 @@ def test_failure_does_not_block_later_deliveries( copilot_reviews.assert_called_once() slack.assert_called_once() + def test_open_pr_list_failure_skips_dependent_stages(self) -> None: + with ( + patch.object(delivery, "list_open_prs", side_effect=RuntimeError("unavailable")), + patch.object(delivery, "deliver_dashboard_override_requests", return_value=[]) as overrides, + patch.object(delivery, "deliver_dashboard_command_replies", return_value=[]) as replies, + patch.object(delivery, "deliver_prepared_author_nudges", return_value=[]) as nudges, + patch.object(delivery, "update_status_comments_from_state", return_value=[]) as status, + patch.object(delivery, "deliver_copilot_review_requests", return_value=[]) as copilot, + patch.object(delivery, "notify_slack_from_state", return_value=[]) as slack, + ): + errors = delivery.deliver_from_state( + "open-telemetry/example", + Path("author"), + Path("copilot"), + Path("slack"), + ) + + self.assertEqual(["open pull requests: unavailable"], errors) + overrides.assert_called_once() + replies.assert_called_once() + nudges.assert_called_once() + copilot.assert_called_once() + status.assert_not_called() + slack.assert_not_called() + @patch.object(delivery.sys, "stderr") @patch.object(delivery, "deliver_from_state", return_value=["status comments: boom"]) @patch.object(delivery.state_branch, "push_state_changes") diff --git a/.github/scripts/pull-request-dashboard/test_github_cli.py b/.github/scripts/pull-request-dashboard/test_github_cli.py index ba15bd62409..cc02d5220df 100644 --- a/.github/scripts/pull-request-dashboard/test_github_cli.py +++ b/.github/scripts/pull-request-dashboard/test_github_cli.py @@ -13,7 +13,6 @@ gh_required_check_contexts, include_missing_required_checks, is_retryable_gh_error, - list_all_open_pr_numbers, list_open_prs, request_copilot_review, ) @@ -214,22 +213,6 @@ def test_list_open_prs_uses_paginated_rest_api(self, gh_api) -> None: paginate=True, ) - @patch("github_cli.gh_api") - def test_list_all_open_pr_numbers_uses_paginated_rest_api(self, gh_api) -> None: - gh_api.return_value = [ - {"number": number} - for number in range(1, 502) - ] - - self.assertEqual( - set(range(1, 502)), - list_all_open_pr_numbers("open-telemetry/example"), - ) - gh_api.assert_called_once_with( - "/repos/open-telemetry/example/pulls?state=open&per_page=100", - paginate=True, - ) - @patch("github_cli.gh_graphql") def test_gh_pr_checks_preserves_reporting_app_identity(self, graphql) -> None: graphql.return_value = { diff --git a/.github/scripts/pull-request-dashboard/test_notify_slack.py b/.github/scripts/pull-request-dashboard/test_notify_slack.py index e3931929f7b..53901f7bdc4 100644 --- a/.github/scripts/pull-request-dashboard/test_notify_slack.py +++ b/.github/scripts/pull-request-dashboard/test_notify_slack.py @@ -43,12 +43,10 @@ def test_copilot_route_does_not_notify_reviewers(self, send_notification) -> Non @patch("notify_slack.save_notifications") @patch("notify_slack.load_notifications") - @patch("notify_slack.list_open_prs") @patch("notify_slack.load_dashboard_state_cache") def test_uncached_pr_does_not_pause_notifications_and_closed_state_is_pruned( self, load_dashboard_state_cache, - list_open_prs, load_notifications, save_notifications, ) -> None: @@ -61,7 +59,7 @@ def test_uncached_pr_does_not_pause_notifications_and_closed_state_is_pruned( }, } } - list_open_prs.return_value = [ + open_prs = [ {"number": 2, "isDraft": False, "title": "Open PR"}, {"number": 5, "isDraft": False, "title": "Not cached yet"}, {"number": 3, "isDraft": True, "title": "Draft PR"}, @@ -82,7 +80,12 @@ def test_uncached_pr_does_not_pause_notifications_and_closed_state_is_pruned( } with patch.dict("os.environ", {"SLACK_CHANNEL": "dashboard"}, clear=True): - errors = notify_slack_from_state("owner/repo", None, None, None) + errors = notify_slack_from_state( + "owner/repo", + None, + open_prs, + datetime(2026, 7, 20, 2, tzinfo=timezone.utc), + ) self.assertEqual(errors, []) save_notifications.assert_called_once_with( 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 5b206a9e244..f061db6289b 100644 --- a/.github/scripts/pull-request-dashboard/test_pr_status_comment.py +++ b/.github/scripts/pull-request-dashboard/test_pr_status_comment.py @@ -522,7 +522,6 @@ def test_rollout_drains_queued_closed_pr( ) -> None: status = pr_status_comment.update_status_comments_from_state( "open-telemetry/example", - None, {34}, ) @@ -561,7 +560,6 @@ def test_rollout_drains_capped_batch( status = pr_status_comment.update_status_comments_from_state( "open-telemetry/example", - None, open_pr_numbers, ) @@ -574,100 +572,6 @@ def test_rollout_drains_capped_batch( self.assertEqual([51, 52, 53, 54, 55], saved_state["pending_pr_numbers"]) self.assertEqual(0, saved_state["completed_revision"]) - @patch.object(pr_status_comment, "save_status_comment_rollout_state") - @patch.object(pr_status_comment, "publish_pr_status") - @patch.object(pr_status_comment, "load_dashboard_state_cache", return_value={"prs": {}}) - @patch.object( - pr_status_comment, - "load_status_comment_rollout_state", - return_value={ - "target_revision": pr_status_comment.STATUS_COMMENT_REVISION, - "completed_revision": 0, - "pending_pr_numbers": [12], - }, - ) - def test_targeted_refresh_completes_last_pending_comment( - self, - _load_rollout: object, - _load_dashboard: object, - _publish_pr_status: object, - save_rollout: Mock, - ) -> None: - status = pr_status_comment.update_status_comments_from_state( - "open-telemetry/example", - 12, - {12}, - ) - - self.assertEqual([], status) - saved_state = save_rollout.call_args.args[0] - self.assertEqual([], saved_state["pending_pr_numbers"]) - self.assertEqual( - pr_status_comment.STATUS_COMMENT_REVISION, - saved_state["completed_revision"], - ) - - @patch.object(pr_status_comment, "save_status_comment_rollout_state") - @patch.object(pr_status_comment, "publish_pr_status") - @patch.object(pr_status_comment, "load_dashboard_state_cache", return_value={"prs": {}}) - @patch.object( - pr_status_comment, - "load_status_comment_rollout_state", - return_value={ - "target_revision": 0, - "completed_revision": 0, - "pending_pr_numbers": [], - }, - ) - def test_targeted_refresh_initializes_revision_without_requeueing_itself( - self, - _load_rollout: object, - _load_dashboard: object, - _publish_pr_status: object, - save_rollout: Mock, - ) -> None: - status = pr_status_comment.update_status_comments_from_state( - "open-telemetry/example", - 12, - {12, 34}, - ) - - self.assertEqual([], status) - saved_state = save_rollout.call_args.args[0] - self.assertEqual(pr_status_comment.STATUS_COMMENT_REVISION, saved_state["target_revision"]) - self.assertEqual([34], saved_state["pending_pr_numbers"]) - self.assertEqual(0, saved_state["completed_revision"]) - - @patch.object(pr_status_comment, "save_status_comment_rollout_state") - @patch.object(pr_status_comment, "publish_pr_status") - @patch.object(pr_status_comment, "load_dashboard_state_cache", return_value={"prs": {}}) - @patch.object( - pr_status_comment, - "load_status_comment_rollout_state", - return_value={ - "target_revision": 0, - "completed_revision": 0, - "pending_pr_numbers": [], - }, - ) - def test_closed_targeted_pr_still_initializes_revision( - self, - _load_rollout: object, - _load_dashboard: object, - _publish_pr_status: object, - save_rollout: Mock, - ) -> None: - status = pr_status_comment.update_status_comments_from_state( - "open-telemetry/example", - 12, - {34}, - ) - - self.assertEqual([], status) - saved_state = save_rollout.call_args.args[0] - self.assertEqual(pr_status_comment.STATUS_COMMENT_REVISION, saved_state["target_revision"]) - self.assertEqual([34], saved_state["pending_pr_numbers"]) - @patch.object(pr_status_comment, "save_status_comment_rollout_state") @patch.object( pr_status_comment, @@ -693,7 +597,6 @@ def test_failed_comment_write_retains_only_failed_pr_and_continues( ) -> None: errors = pr_status_comment.update_status_comments_from_state( "open-telemetry/example", - None, {12, 34}, ) From c738ca67f1060121ded179318f12a55f2eb3a242 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 21 Jul 2026 18:51:15 -0700 Subject: [PATCH 33/44] Address review comment from copilot-pull-request-reviewer: restore pre-review overrides --- .../dashboard_override.py | 17 ++++++++------ .../test_dashboard_override.py | 12 +++++----- pull-request-dashboard/README.md | 22 +++++++++---------- 3 files changed, 27 insertions(+), 24 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/dashboard_override.py b/.github/scripts/pull-request-dashboard/dashboard_override.py index c561136cd78..07b1db598b2 100644 --- a/.github/scripts/pull-request-dashboard/dashboard_override.py +++ b/.github/scripts/pull-request-dashboard/dashboard_override.py @@ -28,6 +28,8 @@ _OVERRIDE_ACK_MARKER_RE = re.compile( r"" ) +PRE_REVIEW_ROUTES = ("author", "external") +REVIEWERS_OR_LATER_ROUTES = ("approver", "maintainer") def author_override_guidance(staleness_note: str = "") -> str: @@ -329,9 +331,10 @@ def apply_dashboard_override(facts: dict[str, Any], route: str) -> str: label_applied = bool(facts.get("dashboard_override_label_applied")) requested = bool(facts.get("dashboard_override_requested")) command_pending = bool(facts.get("dashboard_override_command_id")) - # The override only takes effect while automatic routing waits on the author. - # On every other route the natural routing stands. - override_applies = route == "author" and (label_applied or requested) + # The override only takes effect before review, while automatic routing waits + # on the author or an external dependency. On every later route the natural + # routing stands. + override_applies = route in PRE_REVIEW_ROUTES and (label_applied or requested) # A command that does not newly move the pull request to reviewers is a no-op; # the author is told where it is routed. This covers both a non-overridable # route and an existing label that already provides the reviewer handoff. @@ -341,11 +344,11 @@ def apply_dashboard_override(facts: dict[str, Any], route: str) -> str: if requested and not override_applies: facts["dashboard_override_requested"] = False facts["dashboard_override"] = override_applies - # Release the label once automatic routing no longer waits on the author, so - # a forgotten override cannot bypass a later external dependency or linger - # after routing reaches reviewers. + # Release the label once automatic routing reaches or passes reviewers, so a + # forgotten override cannot pin the pull request at reviewers or drag it back + # from maintainers. facts["dashboard_override_release_requested"] = ( - label_applied and route != "author" + label_applied and route in REVIEWERS_OR_LATER_ROUTES ) return "approver" if override_applies else route diff --git a/.github/scripts/pull-request-dashboard/test_dashboard_override.py b/.github/scripts/pull-request-dashboard/test_dashboard_override.py index a36ee72a069..741a0934ba8 100644 --- a/.github/scripts/pull-request-dashboard/test_dashboard_override.py +++ b/.github/scripts/pull-request-dashboard/test_dashboard_override.py @@ -451,10 +451,10 @@ def test_fresh_command_gets_reply_when_label_already_overrides_author_route(self facts["dashboard_command_replies"], ) - def test_command_only_overrides_author_route(self) -> None: + def test_command_only_overrides_pre_review_routes(self) -> None: for route, expected_route, expected_pending, expected_noop in ( ("author", "approver", True, False), - ("external", "external", False, True), + ("external", "approver", True, False), ("approver", "approver", False, True), ("maintainer", "maintainer", False, True), ("copilot", "copilot", False, True), @@ -484,7 +484,7 @@ def test_label_yields_to_maintainer_route_and_releases(self) -> None: self.assertFalse(facts["dashboard_override"]) self.assertTrue(facts["dashboard_override_release_requested"]) - def test_label_yields_to_external_route_and_releases(self) -> None: + def test_label_holds_external_route_at_reviewers(self) -> None: facts = { "dashboard_override_label_applied": True, "dashboard_override_requested": False, @@ -492,9 +492,9 @@ def test_label_yields_to_external_route_and_releases(self) -> None: route = dashboard_override.apply_dashboard_override(facts, "external") - self.assertEqual("external", route) - self.assertFalse(facts["dashboard_override"]) - self.assertTrue(facts["dashboard_override_release_requested"]) + self.assertEqual("approver", route) + self.assertTrue(facts["dashboard_override"]) + self.assertFalse(facts["dashboard_override_release_requested"]) def test_releases_label_once_natural_route_reaches_reviewers(self) -> None: facts = { diff --git a/pull-request-dashboard/README.md b/pull-request-dashboard/README.md index 10abbd4e4fb..5b76a9a666b 100644 --- a/pull-request-dashboard/README.md +++ b/pull-request-dashboard/README.md @@ -195,17 +195,17 @@ Targeted updates received before the first full dashboard run are ignored. ## Reviewer routing override -When the dashboard says a pull request is waiting on its author but the author -believes it is ready for another review, the author can comment -`/dashboard route:reviewers`. The dashboard routes the pull request to *Waiting -on reviewers* and applies the `dashboard:route-overridden` label to mark the -override. Members of the repository's `approver_teams` can use the same command. -A `/dashboard route:reviewers` command from anyone else, or a command on a pull -request that is not waiting on its author, has no routing effect. The dashboard -replies to a `/dashboard route:reviewers` from an unauthorized user explaining -that only the author or an approver can use it, replies to an author or approver -command on a pull request that is not waiting on the author noting where it is -currently routed, and replies to any unrecognized `/dashboard` command. +When the dashboard says a pull request is waiting on its author or an external +dependency but the author believes it is ready for another review, the author +can comment `/dashboard route:reviewers`. The dashboard routes the pull request +to *Waiting on reviewers* and applies the `dashboard:route-overridden` label to +mark the override. Members of the repository's `approver_teams` can use the same +command. A `/dashboard route:reviewers` command from anyone else, or a command +on a pull request already at or past reviewers, has no routing effect. The +dashboard replies to a `/dashboard route:reviewers` from an unauthorized user +explaining that only the author or an approver can use it, replies to an author +or approver command on a pull request already at or past reviewers noting where +it is currently routed, and replies to any unrecognized `/dashboard` command. Removing the `dashboard:route-overridden` label restores automatic routing. The dashboard also removes the label automatically once routing would place the pull From 7617f239d0ed70f0d2c8ebeb600b77c140df452b Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 21 Jul 2026 18:55:18 -0700 Subject: [PATCH 34/44] Address review comments from copilot-pull-request-reviewer: validate nudge freshness --- .../pull-request-dashboard/author_nudge.py | 101 +++++++++++-- .../pull-request-dashboard/dashboard.py | 3 +- .../test_author_nudge.py | 134 ++++++++++++++++-- 3 files changed, 221 insertions(+), 17 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/author_nudge.py b/.github/scripts/pull-request-dashboard/author_nudge.py index 46455156e8a..b9519c44ed1 100644 --- a/.github/scripts/pull-request-dashboard/author_nudge.py +++ b/.github/scripts/pull-request-dashboard/author_nudge.py @@ -3,12 +3,21 @@ from __future__ import annotations +from concurrent.futures import ThreadPoolExecutor from datetime import datetime, timedelta +import hashlib +import json from pathlib import Path import sys from typing import Any -from github_cli import gh_api, run_gh +from github_cli import ( + fetch_pr_issue_comments, + fetch_pr_review_data, + fetch_review_threads, + gh_api, + run_gh, +) from dashboard_override import author_override_guidance from pr_status_comment import ( DASHBOARD_APP_SLUG, @@ -31,6 +40,67 @@ def nudge_marker(waiting_since: str) -> str: return f"{NUDGE_MARKER_PREFIX}{waiting_since} -->" +def routing_input_fingerprint(raw: dict[str, Any]) -> str: + dashboard_login = f"{DASHBOARD_APP_SLUG}[bot]" + issue_comments = [ + comment + for comment in raw.get("issue_comments") or [] + if (comment.get("user") or {}).get("login") != dashboard_login + ] + routing_inputs = { + "issue_comments": issue_comments, + "review_comments": raw.get("review_comments") or [], + "reviews": raw.get("reviews") or [], + "review_threads": raw.get("review_threads") or [], + } + encoded = json.dumps( + routing_inputs, + sort_keys=True, + separators=(",", ":"), + ).encode() + return hashlib.sha256(encoded).hexdigest() + + +def fetch_current_pr_routing_state( + repo: str, + pr_number: int, +) -> tuple[dict[str, Any], str]: + owner, repo_name = repo.split("/", 1) + with ThreadPoolExecutor() as pool: + pr_future = pool.submit(gh_api, f"/repos/{repo}/pulls/{pr_number}") + issue_comments_future = pool.submit( + fetch_pr_issue_comments, + owner, + repo_name, + pr_number, + ) + review_comments_future = pool.submit( + gh_api, + f"/repos/{repo}/pulls/{pr_number}/comments?per_page=100", + True, + ) + review_data_future = pool.submit( + fetch_pr_review_data, + owner, + repo_name, + pr_number, + ) + review_threads_future = pool.submit( + fetch_review_threads, + owner, + repo_name, + pr_number, + ) + review_data = review_data_future.result() or {} + fingerprint = routing_input_fingerprint({ + "issue_comments": issue_comments_future.result() or [], + "review_comments": review_comments_future.result() or [], + "reviews": review_data.get("reviews") or [], + "review_threads": review_threads_future.result() or [], + }) + return pr_future.result() or {}, fingerprint + + def plan_nudge( result: dict[str, Any] | None, previous: dict[str, Any] | None, @@ -140,12 +210,15 @@ def record_author_nudge_observation( key = str(pr_number) due, entry = plan_nudge(result, updated.get(key), now) if due and prepare_due and entry is not None: - head_sha = ((result or {}).get("facts") or {}).get("head_sha") or "" - if head_sha: + facts = (result or {}).get("facts") or {} + head_sha = facts.get("head_sha") or "" + routing_fingerprint = facts.get("routing_input_fingerprint") or "" + if head_sha and routing_fingerprint: entry = { **entry, "pending_at": format_ts(now), "head_sha": head_sha, + "routing_input_fingerprint": routing_fingerprint, } if entry is None: updated.pop(key, None) @@ -179,18 +252,30 @@ def deliver_prepared_author_nudges( updated[key] = reset_entry continue try: - pr = gh_api(f"/repos/{repo}/pulls/{pr_number}") or {} + pr, current_routing_fingerprint = fetch_current_pr_routing_state( + repo, + pr_number, + ) expected_head = entry.get("head_sha") or "" + expected_routing_fingerprint = entry.get("routing_input_fingerprint") or "" current_head = ((pr.get("head") or {}).get("sha") or "") + if pr.get("state") != "open" or pr.get("draft"): + updated.pop(key, None) + continue if ( - pr.get("state") != "open" - or pr.get("draft") - or (expected_head and current_head != expected_head) + not expected_head + or current_head != expected_head + or not expected_routing_fingerprint + or current_routing_fingerprint != expected_routing_fingerprint ): updated[key] = { name: value for name, value in entry.items() - if name not in ("pending_at", "head_sha") + if name not in ( + "pending_at", + "head_sha", + "routing_input_fingerprint", + ) } continue nudged_at = ensure_nudge( diff --git a/.github/scripts/pull-request-dashboard/dashboard.py b/.github/scripts/pull-request-dashboard/dashboard.py index a6dbfaddd98..119d7837105 100644 --- a/.github/scripts/pull-request-dashboard/dashboard.py +++ b/.github/scripts/pull-request-dashboard/dashboard.py @@ -192,7 +192,7 @@ normalize_discussion_action, prune_classification_cache, ) -from author_nudge import record_author_nudge_observation +from author_nudge import record_author_nudge_observation, routing_input_fingerprint from copilot_review import ( apply_copilot_review_gate, copilot_review_status, @@ -574,6 +574,7 @@ def compute_facts( "author": author, "assignees": assignees, "head_sha": head_sha, + "routing_input_fingerprint": routing_input_fingerprint(raw), **dashboard_override_facts(raw, author, labels, reviewers or set()), "copilot_review_requested": any( is_copilot_reviewer(request) diff --git a/.github/scripts/pull-request-dashboard/test_author_nudge.py b/.github/scripts/pull-request-dashboard/test_author_nudge.py index 38f990844b4..bf2056d076f 100644 --- a/.github/scripts/pull-request-dashboard/test_author_nudge.py +++ b/.github/scripts/pull-request-dashboard/test_author_nudge.py @@ -16,11 +16,35 @@ def author_result(route: str = "author") -> dict: "facts": { "author": "alice", "head_sha": "current-head", + "routing_input_fingerprint": "current-fingerprint", }, } class AuthorNudgePolicyTest(unittest.TestCase): + def test_routing_fingerprint_ignores_dashboard_comments_and_tracks_author_activity(self) -> None: + raw = { + "issue_comments": [], + "review_comments": [], + "reviews": [], + "review_threads": [], + } + baseline = author_nudge.routing_input_fingerprint(raw) + + raw["issue_comments"].append({ + "id": 1, + "user": {"login": "opentelemetry-pr-dashboard[bot]"}, + "body": "dashboard status", + }) + self.assertEqual(baseline, author_nudge.routing_input_fingerprint(raw)) + + raw["issue_comments"].append({ + "id": 2, + "user": {"login": "alice"}, + "body": "I addressed the feedback.", + }) + self.assertNotEqual(baseline, author_nudge.routing_input_fingerprint(raw)) + def test_nudge_advertises_dashboard_override_command(self) -> None: body = author_nudge.render_nudge( "alice", @@ -162,6 +186,7 @@ def test_due_accepted_observation_records_pending_nudge( "nudged_at": "", "pending_at": "2026-07-17T00:00:00+00:00", "head_sha": "current-head", + "routing_input_fingerprint": "current-fingerprint", } }, ) @@ -181,6 +206,7 @@ def test_due_accepted_observation_records_pending_nudge( "nudged_at": "", "pending_at": "2026-07-17T00:00:00+00:00", "head_sha": "current-head", + "routing_input_fingerprint": "current-fingerprint", } }, ) @@ -191,16 +217,16 @@ def test_due_accepted_observation_records_pending_nudge( ) @patch.object( author_nudge, - "gh_api", - return_value={ + "fetch_current_pr_routing_state", + return_value=({ "state": "open", "draft": False, "head": {"sha": "current-head"}, - }, + }, "current-fingerprint"), ) def test_delivery_records_posted_nudge( self, - gh_api, + fetch_current_state, _load_dashboard_state, _load_nudges, save_nudges, @@ -212,7 +238,7 @@ def test_delivery_records_posted_nudge( ) self.assertEqual([], errors) - gh_api.assert_called_once_with("/repos/open-telemetry/example/pulls/1") + fetch_current_state.assert_called_once_with("open-telemetry/example", 1) ensure_nudge.assert_called_once() save_nudges.assert_called_once_with({ "1": { @@ -232,6 +258,7 @@ def test_delivery_records_posted_nudge( "nudged_at": "", "pending_at": "2026-07-17T00:00:00+00:00", "head_sha": "current-head", + "routing_input_fingerprint": "current-fingerprint", } }, ) @@ -242,12 +269,12 @@ def test_delivery_records_posted_nudge( ) @patch.object( author_nudge, - "gh_api", - return_value={ + "fetch_current_pr_routing_state", + return_value=({ "state": "open", "draft": False, "head": {"sha": "new-head"}, - }, + }, "current-fingerprint"), ) def test_delivery_defers_when_head_advanced( self, @@ -271,6 +298,97 @@ def test_delivery_defers_when_head_advanced( }, }) + @patch.object(author_nudge, "ensure_nudge") + @patch.object(author_nudge, "save_author_nudges") + @patch.object( + author_nudge, + "load_author_nudges", + return_value={ + "1": { + "waiting_since": "2026-07-01T00:00:00+00:00", + "nudged_at": "", + "pending_at": "2026-07-17T00:00:00+00:00", + "head_sha": "current-head", + "routing_input_fingerprint": "accepted-fingerprint", + } + }, + ) + @patch.object( + author_nudge, + "load_dashboard_state_cache", + return_value={"prs": {"1": author_result()}}, + ) + @patch.object( + author_nudge, + "fetch_current_pr_routing_state", + return_value=({ + "state": "open", + "draft": False, + "head": {"sha": "current-head"}, + }, "new-fingerprint"), + ) + def test_delivery_defers_when_routing_inputs_changed( + self, + _fetch_current_state, + _load_dashboard_state, + _load_nudges, + save_nudges, + ensure_nudge, + ) -> None: + errors = author_nudge.deliver_prepared_author_nudges( + "open-telemetry/example", + NOW, + ) + + self.assertEqual([], errors) + ensure_nudge.assert_not_called() + save_nudges.assert_called_once_with({ + "1": { + "waiting_since": "2026-07-01T00:00:00+00:00", + "nudged_at": "", + }, + }) + + def test_delivery_clears_episode_for_closed_or_draft_pr(self) -> None: + pending = { + "1": { + "waiting_since": "2026-07-01T00:00:00+00:00", + "nudged_at": "", + "pending_at": "2026-07-17T00:00:00+00:00", + "head_sha": "current-head", + "routing_input_fingerprint": "current-fingerprint", + } + } + for state, draft in (("closed", False), ("open", True)): + with ( + self.subTest(state=state, draft=draft), + patch.object(author_nudge, "load_author_nudges", return_value=pending), + patch.object(author_nudge, "save_author_nudges") as save_nudges, + patch.object( + author_nudge, + "load_dashboard_state_cache", + return_value={"prs": {"1": author_result()}}, + ), + patch.object( + author_nudge, + "fetch_current_pr_routing_state", + return_value=({ + "state": state, + "draft": draft, + "head": {"sha": "current-head"}, + }, "current-fingerprint"), + ), + patch.object(author_nudge, "ensure_nudge") as ensure_nudge, + ): + errors = author_nudge.deliver_prepared_author_nudges( + "open-telemetry/example", + NOW, + ) + + self.assertEqual([], errors) + ensure_nudge.assert_not_called() + save_nudges.assert_called_once_with({}) + def test_rendered_nudge_mentions_author_and_links_status(self) -> None: body = author_nudge.render_nudge( "alice", From 11532ec3ea323a42ad5d39839834aa822ca7e6d5 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 21 Jul 2026 19:06:53 -0700 Subject: [PATCH 35/44] Address review comment from copilot-pull-request-reviewer: validate check freshness --- .../pull-request-dashboard/author_nudge.py | 23 +++++- .../test_author_nudge.py | 72 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/.github/scripts/pull-request-dashboard/author_nudge.py b/.github/scripts/pull-request-dashboard/author_nudge.py index b9519c44ed1..5782255f657 100644 --- a/.github/scripts/pull-request-dashboard/author_nudge.py +++ b/.github/scripts/pull-request-dashboard/author_nudge.py @@ -16,6 +16,9 @@ fetch_pr_review_data, fetch_review_threads, gh_api, + gh_pr_check_rollup, + gh_required_check_contexts, + include_missing_required_checks, run_gh, ) from dashboard_override import author_override_guidance @@ -48,6 +51,7 @@ def routing_input_fingerprint(raw: dict[str, Any]) -> str: if (comment.get("user") or {}).get("login") != dashboard_login ] routing_inputs = { + "checks": raw.get("checks"), "issue_comments": issue_comments, "review_comments": raw.get("review_comments") or [], "reviews": raw.get("reviews") or [], @@ -91,14 +95,31 @@ def fetch_current_pr_routing_state( repo_name, pr_number, ) + pr = pr_future.result() or {} + check_rollup_future = pool.submit( + gh_pr_check_rollup, + repo, + pr.get("node_id") or "", + [], + ) + required_contexts_future = pool.submit( + gh_required_check_contexts, + repo, + ((pr.get("base") or {}).get("ref") or ""), + ) review_data = review_data_future.result() or {} + check_rollup = check_rollup_future.result() fingerprint = routing_input_fingerprint({ + "checks": include_missing_required_checks( + None if check_rollup is None else check_rollup["required"], + required_contexts_future.result(), + ), "issue_comments": issue_comments_future.result() or [], "review_comments": review_comments_future.result() or [], "reviews": review_data.get("reviews") or [], "review_threads": review_threads_future.result() or [], }) - return pr_future.result() or {}, fingerprint + return pr, fingerprint def plan_nudge( diff --git a/.github/scripts/pull-request-dashboard/test_author_nudge.py b/.github/scripts/pull-request-dashboard/test_author_nudge.py index bf2056d076f..31eea603488 100644 --- a/.github/scripts/pull-request-dashboard/test_author_nudge.py +++ b/.github/scripts/pull-request-dashboard/test_author_nudge.py @@ -24,6 +24,7 @@ def author_result(route: str = "author") -> dict: class AuthorNudgePolicyTest(unittest.TestCase): def test_routing_fingerprint_ignores_dashboard_comments_and_tracks_author_activity(self) -> None: raw = { + "checks": [], "issue_comments": [], "review_comments": [], "reviews": [], @@ -45,6 +46,77 @@ def test_routing_fingerprint_ignores_dashboard_comments_and_tracks_author_activi }) self.assertNotEqual(baseline, author_nudge.routing_input_fingerprint(raw)) + def test_routing_fingerprint_tracks_required_check_state(self) -> None: + raw = { + "checks": [{"name": "build", "bucket": "fail"}], + "issue_comments": [], + "review_comments": [], + "reviews": [], + "review_threads": [], + } + failing = author_nudge.routing_input_fingerprint(raw) + + raw["checks"][0]["bucket"] = "pass" + + self.assertNotEqual(failing, author_nudge.routing_input_fingerprint(raw)) + + @patch.object(author_nudge, "gh_required_check_contexts", return_value=[]) + @patch.object( + author_nudge, + "gh_pr_check_rollup", + return_value={ + "required": [{"name": "build", "bucket": "fail"}], + "non_blocking_failures": [], + }, + ) + @patch.object(author_nudge, "fetch_review_threads", return_value=[]) + @patch.object(author_nudge, "fetch_pr_review_data", return_value={}) + @patch.object(author_nudge, "fetch_pr_issue_comments", return_value=[]) + @patch.object(author_nudge, "gh_api") + def test_fetch_current_routing_state_includes_required_checks( + self, + gh_api, + _fetch_issue_comments, + _fetch_review_data, + _fetch_review_threads, + gh_pr_check_rollup, + gh_required_check_contexts, + ) -> None: + pr = { + "node_id": "PR_node", + "base": {"ref": "main"}, + "head": {"sha": "current-head"}, + } + gh_api.side_effect = lambda path, paginate=False: ( + pr if path.endswith("/pulls/1") else [] + ) + + current_pr, fingerprint = author_nudge.fetch_current_pr_routing_state( + "open-telemetry/example", + 1, + ) + + self.assertEqual(pr, current_pr) + self.assertEqual( + author_nudge.routing_input_fingerprint({ + "checks": [{"name": "build", "bucket": "fail"}], + "issue_comments": [], + "review_comments": [], + "reviews": [], + "review_threads": [], + }), + fingerprint, + ) + gh_pr_check_rollup.assert_called_once_with( + "open-telemetry/example", + "PR_node", + [], + ) + gh_required_check_contexts.assert_called_once_with( + "open-telemetry/example", + "main", + ) + def test_nudge_advertises_dashboard_override_command(self) -> None: body = author_nudge.render_nudge( "alice", From 3322e56576123dab81abe5808201024b1bdcbe07 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 21 Jul 2026 19:09:14 -0700 Subject: [PATCH 36/44] Address review comment from copilot-pull-request-reviewer: preserve command explanations --- .../pull-request-dashboard/dashboard_override.py | 11 ++++++----- .../pull-request-dashboard/test_dashboard_override.py | 9 +++++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/dashboard_override.py b/.github/scripts/pull-request-dashboard/dashboard_override.py index 07b1db598b2..42b8ce30d7a 100644 --- a/.github/scripts/pull-request-dashboard/dashboard_override.py +++ b/.github/scripts/pull-request-dashboard/dashboard_override.py @@ -61,17 +61,18 @@ def parse_dashboard_command(comment: dict[str, Any]) -> str | None: def dashboard_command_body_remainder(comment: dict[str, Any]) -> str | None: - """Return the comment body after a leading `/dashboard` command line. + """Return the comment body after a leading `/dashboard` command. Returns None when the comment is not a `/dashboard` command, and the - (possibly empty) text after the command line otherwise. This lets callers - keep an author's explanation that follows the command while treating the - command line itself as control metadata. + (possibly empty) text after the subcommand otherwise. This lets callers + keep an author's explanation on the same or later lines while treating the + command tokens themselves as control metadata. """ if parse_dashboard_command(comment) is None: return None lines = (comment.get("body") or "").strip().splitlines() - return "\n".join(lines[1:]).strip() + first_line = lines[0].strip().split(maxsplit=2) + return "\n".join([first_line[2] if len(first_line) > 2 else "", *lines[1:]]).strip() def is_authorized_commander(login: str, author: str, reviewers: set[str] | None) -> bool: diff --git a/.github/scripts/pull-request-dashboard/test_dashboard_override.py b/.github/scripts/pull-request-dashboard/test_dashboard_override.py index 741a0934ba8..2b4e4a44b71 100644 --- a/.github/scripts/pull-request-dashboard/test_dashboard_override.py +++ b/.github/scripts/pull-request-dashboard/test_dashboard_override.py @@ -25,6 +25,15 @@ def test_dashboard_command_body_remainder(self) -> None: {"body": "/dashboard route:reviewers\n\nI addressed everything by doing X."} ), ) + self.assertEqual( + "I addressed the feedback.\nAdditional context follows.", + dashboard_override.dashboard_command_body_remainder({ + "body": ( + "/dashboard route:reviewers I addressed the feedback.\n" + "Additional context follows." + ) + }), + ) def test_latest_authorized_command_accepts_author_and_approvers(self) -> None: raw = { From 60f767fe2c6c17ba2f09e0745fd16760c2d5eac8 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 21 Jul 2026 19:37:03 -0700 Subject: [PATCH 37/44] Address review comment from copilot-pull-request-reviewer: order request generations --- .../pull-request-dashboard/copilot_review.py | 7 ++- .../pull-request-dashboard/dashboard.py | 8 +-- .../scripts/pull-request-dashboard/state.py | 4 +- .../test_copilot_review.py | 48 ++++++++++++++--- .../pull-request-dashboard/test_dashboard.py | 7 ++- .../pull-request-dashboard/test_state.py | 54 +++++++++++++++++-- 6 files changed, 111 insertions(+), 17 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/copilot_review.py b/.github/scripts/pull-request-dashboard/copilot_review.py index 2679ec68293..7b2efbfc251 100644 --- a/.github/scripts/pull-request-dashboard/copilot_review.py +++ b/.github/scripts/pull-request-dashboard/copilot_review.py @@ -74,6 +74,7 @@ def apply_copilot_review_gate( def record_copilot_review_observation( pr_number: int, result: dict[str, Any] | None, + observed_at: datetime, ) -> None: requests = dict(load_copilot_review_requests()) key = str(pr_number) @@ -88,7 +89,11 @@ def record_copilot_review_observation( ): requests.pop(key, None) else: - requests[key] = {"head_sha": head_sha, "requested_at": ""} + requests[key] = { + "head_sha": head_sha, + "observed_at": format_ts(observed_at), + "requested_at": "", + } save_copilot_review_requests(requests) diff --git a/.github/scripts/pull-request-dashboard/dashboard.py b/.github/scripts/pull-request-dashboard/dashboard.py index 119d7837105..999814c5655 100644 --- a/.github/scripts/pull-request-dashboard/dashboard.py +++ b/.github/scripts/pull-request-dashboard/dashboard.py @@ -1944,7 +1944,7 @@ def remove_cached_dashboard_prs( state_prs.pop(str(number), None) enqueue_status_comment_update(number) record_author_nudge_observation(number, None, observed_at) - record_copilot_review_observation(number, None) + record_copilot_review_observation(number, None, observed_at) dashboard_state["prs"] = state_prs return save_dashboard_update_state(args, dashboard_state, False) @@ -1996,16 +1996,17 @@ def apply_targeted_dashboard_update( if not dashboard_state_unchanged and args.pr_number is not None: enqueue_status_comment_update(args.pr_number) if args.pr_number is not None: + observed_at = observed_at or utc_now() accepted_result = (merged_calculation.dashboard_state.get("prs") or {}).get( str(args.pr_number) ) record_author_nudge_observation( args.pr_number, accepted_result, - observed_at or utc_now(), + observed_at, prepare_due=getattr(args, "prepare_author_nudges", False), ) - record_copilot_review_observation(args.pr_number, accepted_result) + record_copilot_review_observation(args.pr_number, accepted_result, observed_at) return save_dashboard_update_state( args, @@ -2139,6 +2140,7 @@ def update_selected_pr(pr_summary: dict[str, Any] = pr_summary) -> int: record_copilot_review_observation( pr_number, (calculation.dashboard_state.get("prs") or {}).get(str(pr_number)), + observed_at, ) failed_pr_numbers = update_backfill_progress(pr_number, failed=False) if not dashboard_state_unchanged: diff --git a/.github/scripts/pull-request-dashboard/state.py b/.github/scripts/pull-request-dashboard/state.py index d23ac977d1c..b0e93127b6e 100644 --- a/.github/scripts/pull-request-dashboard/state.py +++ b/.github/scripts/pull-request-dashboard/state.py @@ -21,7 +21,7 @@ BACKFILL_STATE_VERSION = 3 NOTIFICATION_STATE_VERSION = 3 AUTHOR_NUDGE_STATE_VERSION = 2 -COPILOT_REVIEW_REQUEST_STATE_VERSION = 1 +COPILOT_REVIEW_REQUEST_STATE_VERSION = 2 STATUS_COMMENT_ROLLOUT_STATE_VERSION = 1 INITIAL_BACKFILL_COMPLETE_KEY = "initial_backfill_complete" _state_dir: Path | None = None @@ -309,6 +309,8 @@ def union_merge_copilot_review_requests( if ( (retry_entry or {}).get("requested_at") and retry_entry.get("head_sha") == baseline_entry.get("head_sha") + and retry_entry.get("observed_at") + and retry_entry.get("observed_at") == baseline_entry.get("observed_at") ): merged[key] = retry_entry return merged diff --git a/.github/scripts/pull-request-dashboard/test_copilot_review.py b/.github/scripts/pull-request-dashboard/test_copilot_review.py index 6dba26be41c..6b9e76b23c2 100644 --- a/.github/scripts/pull-request-dashboard/test_copilot_review.py +++ b/.github/scripts/pull-request-dashboard/test_copilot_review.py @@ -8,6 +8,9 @@ from copilot_review import deliver_copilot_review_requests, record_copilot_review_observation +NOW = datetime(2026, 7, 20, 2, tzinfo=timezone.utc) + + class CopilotReviewRequestStateTest(unittest.TestCase): @patch("copilot_review.save_copilot_review_requests") @patch("copilot_review.load_copilot_review_requests", return_value={}) @@ -21,10 +24,15 @@ def test_records_request_for_current_head(self, _load_requests, save_requests) - "copilot_review_request_needed": True, }, }, + NOW, ) save_requests.assert_called_once_with({ - "7": {"head_sha": "current-head", "requested_at": ""}, + "7": { + "head_sha": "current-head", + "observed_at": "2026-07-20T02:00:00+00:00", + "requested_at": "", + }, }) @patch("copilot_review.save_copilot_review_requests") @@ -42,10 +50,15 @@ def test_new_head_replaces_previous_request(self, _load_requests, save_requests) "copilot_review_request_needed": True, }, }, + NOW, ) save_requests.assert_called_once_with({ - "7": {"head_sha": "current-head", "requested_at": ""}, + "7": { + "head_sha": "current-head", + "observed_at": "2026-07-20T02:00:00+00:00", + "requested_at": "", + }, }) @patch("copilot_review.save_copilot_review_requests") @@ -72,10 +85,15 @@ def test_same_head_request_needed_resets_acknowledgement( "copilot_review_request_needed": True, }, }, + NOW, ) save_requests.assert_called_once_with({ - "7": {"head_sha": "current-head", "requested_at": ""}, + "7": { + "head_sha": "current-head", + "observed_at": "2026-07-20T02:00:00+00:00", + "requested_at": "", + }, }) @patch("copilot_review.save_copilot_review_requests") @@ -93,6 +111,7 @@ def test_clears_request_when_no_longer_needed(self, _load_requests, save_request "copilot_review_request_needed": False, }, }, + NOW, ) save_requests.assert_called_once_with({}) @@ -114,6 +133,7 @@ def test_initial_automatic_review_does_not_enqueue_request( "copilot_review_request_needed": False, }, }, + NOW, ) save_requests.assert_called_once_with({}) @@ -124,7 +144,13 @@ def test_initial_automatic_review_does_not_enqueue_request( @patch("copilot_review.save_copilot_review_requests") @patch( "copilot_review.load_copilot_review_requests", - return_value={"7": {"head_sha": "current-head", "requested_at": ""}}, + return_value={ + "7": { + "head_sha": "current-head", + "observed_at": "2026-07-20T01:00:00+00:00", + "requested_at": "", + } + }, ) def test_delivers_request_for_current_stale_review( self, @@ -153,7 +179,7 @@ def test_delivers_request_for_current_stale_review( errors = deliver_copilot_review_requests( "open-telemetry/example", - datetime(2026, 7, 20, 2, tzinfo=timezone.utc), + NOW, ) self.assertEqual([], errors) @@ -163,6 +189,7 @@ def test_delivers_request_for_current_stale_review( save_requests.assert_called_once_with({ "7": { "head_sha": "current-head", + "observed_at": "2026-07-20T01:00:00+00:00", "requested_at": "2026-07-20T02:00:00+00:00", }, }) @@ -173,7 +200,13 @@ def test_delivers_request_for_current_stale_review( @patch("copilot_review.save_copilot_review_requests") @patch( "copilot_review.load_copilot_review_requests", - return_value={"7": {"head_sha": "current-head", "requested_at": ""}}, + return_value={ + "7": { + "head_sha": "current-head", + "observed_at": "2026-07-20T01:00:00+00:00", + "requested_at": "", + } + }, ) def test_pending_request_is_acknowledged_from_pull_response( self, @@ -194,7 +227,7 @@ def test_pending_request_is_acknowledged_from_pull_response( errors = deliver_copilot_review_requests( "open-telemetry/example", - datetime(2026, 7, 20, 2, tzinfo=timezone.utc), + NOW, ) self.assertEqual([], errors) @@ -204,6 +237,7 @@ def test_pending_request_is_acknowledged_from_pull_response( save_requests.assert_called_once_with({ "7": { "head_sha": "current-head", + "observed_at": "2026-07-20T01:00:00+00:00", "requested_at": "2026-07-20T02:00:00+00:00", }, }) diff --git a/.github/scripts/pull-request-dashboard/test_dashboard.py b/.github/scripts/pull-request-dashboard/test_dashboard.py index 549cfabba36..b2e4d1c6e05 100644 --- a/.github/scripts/pull-request-dashboard/test_dashboard.py +++ b/.github/scripts/pull-request-dashboard/test_dashboard.py @@ -662,7 +662,7 @@ def test_targeted_state_change_enqueues_status_comment( _save_state: Mock, _clear_backfill_failure: Mock, record_nudge: Mock, - _record_copilot: Mock, + record_copilot: Mock, ) -> None: accepted_result = {"route": "author"} calculation = DashboardUpdate( @@ -685,6 +685,11 @@ def test_targeted_state_change_enqueues_status_comment( ANY, prepare_due=True, ) + record_copilot.assert_called_once_with( + 12, + accepted_result, + record_nudge.call_args.args[2], + ) @patch("dashboard.record_copilot_review_observation") @patch("dashboard.record_author_nudge_observation") diff --git a/.github/scripts/pull-request-dashboard/test_state.py b/.github/scripts/pull-request-dashboard/test_state.py index 5b115c557c7..8305ee93558 100644 --- a/.github/scripts/pull-request-dashboard/test_state.py +++ b/.github/scripts/pull-request-dashboard/test_state.py @@ -164,7 +164,7 @@ def test_notification_state_version_is_independent(self) -> None: self.assertEqual(DASHBOARD_STATE_VERSION, 5) self.assertEqual(STATUS_COMMENT_ROLLOUT_STATE_VERSION, 1) self.assertEqual(AUTHOR_NUDGE_STATE_VERSION, 2) - self.assertEqual(COPILOT_REVIEW_REQUEST_STATE_VERSION, 1) + self.assertEqual(COPILOT_REVIEW_REQUEST_STATE_VERSION, 2) def test_author_nudge_state_round_trip(self) -> None: with tempfile.TemporaryDirectory() as temp_dir, patch("state._state_dir", Path(temp_dir)): @@ -191,6 +191,7 @@ def test_copilot_review_request_state_round_trip(self) -> None: save_copilot_review_requests({ "123": { "head_sha": "current-head", + "observed_at": "2026-07-20T01:00:00Z", "requested_at": "", } }) @@ -200,6 +201,7 @@ def test_copilot_review_request_state_round_trip(self) -> None: { "123": { "head_sha": "current-head", + "observed_at": "2026-07-20T01:00:00Z", "requested_at": "", } }, @@ -244,14 +246,22 @@ def test_retry_snapshot_preserves_same_head_copilot_request(self) -> None: { "7": { "head_sha": "current-head", + "observed_at": "2026-07-20T01:00:00Z", "requested_at": "2026-07-20T02:00:00Z", } }, union_merge_copilot_review_requests( - {"7": {"head_sha": "current-head", "requested_at": ""}}, { "7": { "head_sha": "current-head", + "observed_at": "2026-07-20T01:00:00Z", + "requested_at": "", + } + }, + { + "7": { + "head_sha": "current-head", + "observed_at": "2026-07-20T01:00:00Z", "requested_at": "2026-07-20T02:00:00Z", } }, @@ -260,12 +270,48 @@ def test_retry_snapshot_preserves_same_head_copilot_request(self) -> None: def test_retry_snapshot_does_not_overwrite_new_copilot_head(self) -> None: self.assertEqual( - {"7": {"head_sha": "new-head", "requested_at": ""}}, + { + "7": { + "head_sha": "new-head", + "observed_at": "2026-07-20T03:00:00Z", + "requested_at": "", + } + }, union_merge_copilot_review_requests( - {"7": {"head_sha": "new-head", "requested_at": ""}}, + { + "7": { + "head_sha": "new-head", + "observed_at": "2026-07-20T03:00:00Z", + "requested_at": "", + } + }, { "7": { "head_sha": "old-head", + "observed_at": "2026-07-20T01:00:00Z", + "requested_at": "2026-07-20T02:00:00Z", + } + }, + ), + ) + + def test_retry_snapshot_does_not_suppress_new_same_head_request(self) -> None: + pending = { + "7": { + "head_sha": "current-head", + "observed_at": "2026-07-20T03:00:00Z", + "requested_at": "", + } + } + + self.assertEqual( + pending, + union_merge_copilot_review_requests( + pending, + { + "7": { + "head_sha": "current-head", + "observed_at": "2026-07-20T01:00:00Z", "requested_at": "2026-07-20T02:00:00Z", } }, From 1a11f91f9434398fe177aec0f52ea0c57ed3706d Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 21 Jul 2026 19:44:54 -0700 Subject: [PATCH 38/44] Address review comment from copilot-pull-request-reviewer: persist nudge episodes --- .../pull-request-dashboard/author_nudge.py | 21 ++++--- .../pull-request-dashboard/dashboard.py | 35 ++++++++++++ .../pr_status_comment.py | 32 ++++++++++- .../test_author_nudge.py | 19 ++++--- .../pull-request-dashboard/test_dashboard.py | 55 +++++++++++++++++++ .../test_pr_status_comment.py | 31 +++++++++++ 6 files changed, 175 insertions(+), 18 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/author_nudge.py b/.github/scripts/pull-request-dashboard/author_nudge.py index 5782255f657..444cad9e49f 100644 --- a/.github/scripts/pull-request-dashboard/author_nudge.py +++ b/.github/scripts/pull-request-dashboard/author_nudge.py @@ -39,8 +39,8 @@ NUDGE_MARKER_PREFIX = "" +def nudge_marker(episode_id: str) -> str: + return f"{NUDGE_MARKER_PREFIX}{episode_id} -->" def routing_input_fingerprint(raw: dict[str, Any]) -> str: @@ -151,9 +151,9 @@ def plan_nudge( def existing_nudge_comment( repo: str, pr_number: int, - waiting_since: str, + episode_id: str, ) -> dict[str, Any] | None: - marker = nudge_marker(waiting_since) + marker = nudge_marker(episode_id) comments = gh_api( f"/repos/{repo}/issues/{pr_number}/comments?per_page=100", paginate=True, @@ -170,9 +170,9 @@ def existing_nudge_comment( ) -def render_nudge(author: str, status_url: str, waiting_since: str) -> str: +def render_nudge(author: str, status_url: str, episode_id: str) -> str: return "\n".join([ - nudge_marker(waiting_since), + nudge_marker(episode_id), f"Hi @{author} — just a friendly reminder that this pull request has " "been waiting on you for a week.", "", @@ -196,7 +196,12 @@ def ensure_nudge( waiting_since: str, now: datetime, ) -> str | None: - existing = existing_nudge_comment(repo, pr_number, waiting_since) + episode_id = str( + ((result.get("facts") or {}).get("author_nudge_episode_id") or "") + ) + if not episode_id: + raise RuntimeError(f"author nudge episode not found for PR #{pr_number}") + existing = existing_nudge_comment(repo, pr_number, episode_id) if existing: return existing.get("created_at") or format_ts(now) @@ -215,7 +220,7 @@ def ensure_nudge( run_gh([ "gh", "api", "--method", "POST", f"repos/{repo}/issues/{pr_number}/comments", - "-f", f"body={render_nudge(author, status_comments[0]['html_url'], waiting_since)}", + "-f", f"body={render_nudge(author, status_comments[0]['html_url'], episode_id)}", ]) return format_ts(now) diff --git a/.github/scripts/pull-request-dashboard/dashboard.py b/.github/scripts/pull-request-dashboard/dashboard.py index 999814c5655..88f25dc11f2 100644 --- a/.github/scripts/pull-request-dashboard/dashboard.py +++ b/.github/scripts/pull-request-dashboard/dashboard.py @@ -161,6 +161,7 @@ import argparse import sys import traceback +import uuid from concurrent.futures import ThreadPoolExecutor from dataclasses import dataclass, replace from datetime import datetime @@ -205,6 +206,7 @@ dashboard_command_body_remainder, dashboard_override_facts, ) +from pr_status_comment import status_author_nudge_episode_id from state import ( INITIAL_BACKFILL_COMPLETE_KEY, empty_state, @@ -1421,6 +1423,31 @@ def resolve_pr_route( ) +def assign_author_nudge_episode( + facts: dict[str, Any], + route: str, + previous_result: dict[str, Any] | None, + issue_comments: list[dict[str, Any]], +) -> None: + if route != "author": + facts.pop("author_nudge_episode_id", None) + return + previous_facts = (previous_result or {}).get("facts") or {} + previous_episode_id = ( + previous_facts.get("author_nudge_episode_id") + if (previous_result or {}).get("route") == "author" + else "" + ) + recovered_episode_id = ( + status_author_nudge_episode_id(issue_comments) + if previous_result is None + else "" + ) + facts["author_nudge_episode_id"] = str( + previous_episode_id or recovered_episode_id or uuid.uuid4().hex + ) + + # ---------------------------------------------------------------- main @@ -1435,6 +1462,7 @@ def build_pr_result( non_blocking_check_patterns: list[str], previous_top_level_history: dict[str, dict[str, Any]] | None = None, require_clean_copilot_review_branches: list[str] | None = None, + previous_result: dict[str, Any] | None = None, ) -> dict[str, Any] | None: number = pr_summary["number"] try: @@ -1534,6 +1562,12 @@ def build_pr_result( required_approvals, require_clean_copilot_review, ) + assign_author_nudge_episode( + facts, + route, + previous_result, + raw.get("issue_comments") or [], + ) append_route_noop_reply(raw, facts, route) add_wait_age_facts(facts, route, pending_actions) facts["author_action_review_thread_urls"] = author_action_discussion_urls( @@ -1631,6 +1665,7 @@ def build_dashboard_update_for_pr( non_blocking_check_patterns, previous_top_level_history=(starting_pr_result or {}).get("top_level_history") or {}, require_clean_copilot_review_branches=require_clean_copilot_review_branches, + previous_result=starting_pr_result, ) if trigger_pr_result is None: results.pop(pr_number, None) diff --git a/.github/scripts/pull-request-dashboard/pr_status_comment.py b/.github/scripts/pull-request-dashboard/pr_status_comment.py index 48975d75ee5..29ccd66b442 100644 --- a/.github/scripts/pull-request-dashboard/pr_status_comment.py +++ b/.github/scripts/pull-request-dashboard/pr_status_comment.py @@ -3,6 +3,7 @@ from __future__ import annotations +import re import sys from typing import Any from urllib.parse import urlencode @@ -23,9 +24,15 @@ STATUS_MARKER = "" +AUTHOR_NUDGE_EPISODE_MARKER_PREFIX = ( + "" +) # Increment whenever render_status_comment changes in a way existing comments # need to adopt. Hourly runs durably roll the revision out to all open PRs. -STATUS_COMMENT_REVISION = 10 +STATUS_COMMENT_REVISION = 11 STATUS_COMMENT_ROLLOUT_BATCH_SIZE = 50 AUTHOR_ACTION_FEEDBACK_LINK_LIMIT = 20 NON_BLOCKING_CHECK_FAILURE_LIMIT = 20 @@ -45,6 +52,26 @@ ) +def author_nudge_episode_marker(episode_id: str) -> str: + return f"{AUTHOR_NUDGE_EPISODE_MARKER_PREFIX}{episode_id} -->" + + +def status_author_nudge_episode_id( + comments: list[dict[str, Any]] | None, +) -> str: + for comment in comments or []: + body = comment.get("body") or "" + match = _AUTHOR_NUDGE_EPISODE_MARKER_RE.search(body) + if ( + match + and STATUS_MARKER in body + and (comment.get("performed_via_github_app") or {}).get("slug") + == DASHBOARD_APP_SLUG + ): + return match.group(1) + return "" + + def accuracy_note(pr: dict[str, Any], author_routed: bool = False) -> str: query = urlencode({ "template": STATUS_REPORT_ISSUE_TEMPLATE, @@ -186,6 +213,9 @@ def render_status_comment( "", *summary, ] + episode_id = str(facts.get("author_nudge_episode_id") or "") + if (result or {}).get("route") == "author" and episode_id: + lines.insert(2, author_nudge_episode_marker(episode_id)) if feedback_indent is not None and feedback_count: lines.extend( diff --git a/.github/scripts/pull-request-dashboard/test_author_nudge.py b/.github/scripts/pull-request-dashboard/test_author_nudge.py index 31eea603488..e18ff4077b4 100644 --- a/.github/scripts/pull-request-dashboard/test_author_nudge.py +++ b/.github/scripts/pull-request-dashboard/test_author_nudge.py @@ -15,6 +15,7 @@ def author_result(route: str = "author") -> dict: "route": route, "facts": { "author": "alice", + "author_nudge_episode_id": "episode-1", "head_sha": "current-head", "routing_input_fingerprint": "current-fingerprint", }, @@ -121,7 +122,7 @@ def test_nudge_advertises_dashboard_override_command(self) -> None: body = author_nudge.render_nudge( "alice", "https://example.test/status", - "2026-07-10T00:00:00+00:00", + "episode-1", ) self.assertIn( @@ -465,13 +466,13 @@ def test_rendered_nudge_mentions_author_and_links_status(self) -> None: body = author_nudge.render_nudge( "alice", "https://example.test/status", - "2026-07-10T00:00:00+00:00", + "episode-1", ) self.assertIn("@alice", body) self.assertIn("[dashboard status comment](https://example.test/status)", body) self.assertIn( - author_nudge.nudge_marker("2026-07-10T00:00:00+00:00"), + author_nudge.nudge_marker("episode-1"), body, ) @@ -481,11 +482,11 @@ def test_rendered_nudge_mentions_author_and_links_status(self) -> None: return_value=[ { "performed_via_github_app": {"slug": "opentelemetry-pr-dashboard"}, - "body": author_nudge.nudge_marker("2026-07-01T00:00:00+00:00"), + "body": author_nudge.nudge_marker("previous-episode"), }, { "performed_via_github_app": {"slug": "opentelemetry-pr-dashboard"}, - "body": author_nudge.nudge_marker("2026-07-10T00:00:00+00:00"), + "body": author_nudge.nudge_marker("episode-1"), "created_at": "2026-07-17T00:00:00Z", }, ], @@ -494,7 +495,7 @@ def test_existing_nudge_matches_current_episode(self, _gh_api) -> None: comment = author_nudge.existing_nudge_comment( "open-telemetry/example", 1, - "2026-07-10T00:00:00+00:00", + "episode-1", ) self.assertEqual(comment["created_at"], "2026-07-17T00:00:00Z") @@ -544,7 +545,7 @@ def test_posts_nudge_after_ensuring_status_comment( "existing_nudge_comment", return_value={"created_at": "2026-07-11T00:00:00Z"}, ) - def test_existing_marker_prevents_duplicate_after_state_loss( + def test_existing_episode_marker_prevents_duplicate_after_state_loss( self, existing_comment, publish_status, @@ -555,7 +556,7 @@ def test_existing_marker_prevents_duplicate_after_state_loss( 1, author_result(), {"prs": {"1": author_result()}}, - "2026-07-10T00:00:00+00:00", + "2026-07-17T00:00:00+00:00", NOW, ) @@ -563,7 +564,7 @@ def test_existing_marker_prevents_duplicate_after_state_loss( existing_comment.assert_called_once_with( "open-telemetry/example", 1, - "2026-07-10T00:00:00+00:00", + "episode-1", ) publish_status.assert_not_called() run_gh.assert_not_called() diff --git a/.github/scripts/pull-request-dashboard/test_dashboard.py b/.github/scripts/pull-request-dashboard/test_dashboard.py index b2e4d1c6e05..783f976adbf 100644 --- a/.github/scripts/pull-request-dashboard/test_dashboard.py +++ b/.github/scripts/pull-request-dashboard/test_dashboard.py @@ -14,6 +14,7 @@ DashboardUpdate, add_wait_age_facts, apply_targeted_dashboard_update, + assign_author_nudge_episode, author_action_discussion_urls, backfill_failed_pr_numbers, complete_initial_backfill_if_ready, @@ -70,6 +71,60 @@ def test_override_reaches_reviewers_when_gate_disabled(self) -> None: self.assertEqual("approver", route) +class AuthorNudgeEpisodeTest(unittest.TestCase): + def test_preserves_episode_while_route_remains_author(self) -> None: + facts: dict[str, object] = {} + + assign_author_nudge_episode( + facts, + "author", + { + "route": "author", + "facts": {"author_nudge_episode_id": "abc123"}, + }, + [], + ) + + self.assertEqual("abc123", facts["author_nudge_episode_id"]) + + @patch("dashboard.uuid.uuid4") + def test_starts_new_episode_after_known_route_departure(self, uuid4: Mock) -> None: + uuid4.return_value.hex = "def456" + facts: dict[str, object] = {} + + assign_author_nudge_episode( + facts, + "author", + {"route": "approver", "facts": {}}, + [{ + "performed_via_github_app": {"slug": "opentelemetry-pr-dashboard"}, + "body": ( + "\n" + "" + ), + }], + ) + + self.assertEqual("def456", facts["author_nudge_episode_id"]) + + def test_recovers_episode_from_status_comment_after_cache_loss(self) -> None: + facts: dict[str, object] = {} + + assign_author_nudge_episode( + facts, + "author", + None, + [{ + "performed_via_github_app": {"slug": "opentelemetry-pr-dashboard"}, + "body": ( + "\n" + "" + ), + }], + ) + + self.assertEqual("abc123", facts["author_nudge_episode_id"]) + class FetchPrRawTest(unittest.TestCase): def test_uses_graphql_issue_comments_without_rest_join(self) -> None: issue_comments = [{"id": 101, "body": "GraphQL comment"}] 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 f061db6289b..456968c1b5b 100644 --- a/.github/scripts/pull-request-dashboard/test_pr_status_comment.py +++ b/.github/scripts/pull-request-dashboard/test_pr_status_comment.py @@ -26,6 +26,7 @@ def test_waiting_on_author_splits_review_feedback_links(self) -> None: "route": "author", "facts": { "author": "alice", + "author_nudge_episode_id": "abc123", "author_action_review_thread_urls": [ "https://github.com/open-telemetry/example/pull/1#discussion_r1", ], @@ -45,6 +46,10 @@ def test_waiting_on_author_splits_review_feedback_links(self) -> None: f"", body, ) + self.assertIn( + pr_status_comment.author_nudge_episode_marker("abc123"), + body, + ) self.assertNotIn("### Review feedback", body) self.assertIn(" - **Inline threads:** [1]", body) self.assertIn(" - **Top-level feedback:** [2]", body) @@ -58,6 +63,32 @@ def test_waiting_on_author_splits_review_feedback_links(self) -> None: body, ) + def test_recovers_episode_only_from_app_authored_status_comment(self) -> None: + marker = pr_status_comment.author_nudge_episode_marker("abc123") + comments = [ + { + "performed_via_github_app": None, + "body": f"{pr_status_comment.STATUS_MARKER}\n{marker}", + }, + { + "performed_via_github_app": { + "slug": pr_status_comment.DASHBOARD_APP_SLUG, + }, + "body": marker, + }, + { + "performed_via_github_app": { + "slug": pr_status_comment.DASHBOARD_APP_SLUG, + }, + "body": f"{pr_status_comment.STATUS_MARKER}\n{marker}", + }, + ] + + self.assertEqual( + "abc123", + pr_status_comment.status_author_nudge_episode_id(comments), + ) + @patch.object( pr_status_comment, "utc_now", From 77189fc62b1733bbea1b87c0a9ad2bb97ade2750 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 21 Jul 2026 19:52:57 -0700 Subject: [PATCH 39/44] Address review comment from copilot-pull-request-reviewer: accept normalized provenance --- .../pr_status_comment.py | 9 +++++-- .../test_pr_status_comment.py | 26 +++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/pr_status_comment.py b/.github/scripts/pull-request-dashboard/pr_status_comment.py index 29ccd66b442..f6c2fb830fc 100644 --- a/.github/scripts/pull-request-dashboard/pr_status_comment.py +++ b/.github/scripts/pull-request-dashboard/pr_status_comment.py @@ -56,6 +56,12 @@ def author_nudge_episode_marker(episode_id: str) -> str: return f"{AUTHOR_NUDGE_EPISODE_MARKER_PREFIX}{episode_id} -->" +def is_dashboard_app_comment(comment: dict[str, Any]) -> bool: + app_slug = (comment.get("performed_via_github_app") or {}).get("slug") or "" + author_login = (comment.get("user") or {}).get("login") or "" + return app_slug == DASHBOARD_APP_SLUG or author_login == f"{DASHBOARD_APP_SLUG}[bot]" + + def status_author_nudge_episode_id( comments: list[dict[str, Any]] | None, ) -> str: @@ -65,8 +71,7 @@ def status_author_nudge_episode_id( if ( match and STATUS_MARKER in body - and (comment.get("performed_via_github_app") or {}).get("slug") - == DASHBOARD_APP_SLUG + and is_dashboard_app_comment(comment) ): return match.group(1) return "" 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 456968c1b5b..b2683ddd8cb 100644 --- a/.github/scripts/pull-request-dashboard/test_pr_status_comment.py +++ b/.github/scripts/pull-request-dashboard/test_pr_status_comment.py @@ -89,6 +89,32 @@ def test_recovers_episode_only_from_app_authored_status_comment(self) -> None: pr_status_comment.status_author_nudge_episode_id(comments), ) + def test_recovers_episode_from_normalized_dashboard_bot_comment(self) -> None: + marker = pr_status_comment.author_nudge_episode_marker("abc123") + + self.assertEqual( + "abc123", + pr_status_comment.status_author_nudge_episode_id([{ + "user": {"login": "opentelemetry-pr-dashboard[bot]"}, + "body": f"{pr_status_comment.STATUS_MARKER}\n{marker}", + }]), + ) + + def test_does_not_recover_episode_from_other_normalized_authors(self) -> None: + marker = pr_status_comment.author_nudge_episode_marker("abc123") + comments = [ + { + "user": {"login": login}, + "body": f"{pr_status_comment.STATUS_MARKER}\n{marker}", + } + for login in ("alice", "another-app[bot]") + ] + + self.assertEqual( + "", + pr_status_comment.status_author_nudge_episode_id(comments), + ) + @patch.object( pr_status_comment, "utc_now", From 790702c2ba076d294a863da4851aa796a4a68aef Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 21 Jul 2026 19:57:32 -0700 Subject: [PATCH 40/44] Address review comment from copilot-pull-request-reviewer: validate override freshness --- .../pull-request-dashboard/author_nudge.py | 11 +++++++ .../test_author_nudge.py | 30 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/.github/scripts/pull-request-dashboard/author_nudge.py b/.github/scripts/pull-request-dashboard/author_nudge.py index 444cad9e49f..76748c4e77a 100644 --- a/.github/scripts/pull-request-dashboard/author_nudge.py +++ b/.github/scripts/pull-request-dashboard/author_nudge.py @@ -22,6 +22,7 @@ run_gh, ) from dashboard_override import author_override_guidance +from dashboard_override import DASHBOARD_OVERRIDE_LABEL from pr_status_comment import ( DASHBOARD_APP_SLUG, managed_status_comments, @@ -45,6 +46,9 @@ def nudge_marker(episode_id: str) -> str: def routing_input_fingerprint(raw: dict[str, Any]) -> str: dashboard_login = f"{DASHBOARD_APP_SLUG}[bot]" + labels = raw.get("labels") + if labels is None: + labels = (raw.get("pr") or {}).get("labels") or [] issue_comments = [ comment for comment in raw.get("issue_comments") or [] @@ -53,6 +57,12 @@ def routing_input_fingerprint(raw: dict[str, Any]) -> str: routing_inputs = { "checks": raw.get("checks"), "issue_comments": issue_comments, + "labels": sorted( + label.get("name") or "" + for label in labels + if isinstance(label, dict) + and label.get("name") == DASHBOARD_OVERRIDE_LABEL + ), "review_comments": raw.get("review_comments") or [], "reviews": raw.get("reviews") or [], "review_threads": raw.get("review_threads") or [], @@ -115,6 +125,7 @@ def fetch_current_pr_routing_state( required_contexts_future.result(), ), "issue_comments": issue_comments_future.result() or [], + "labels": pr.get("labels") or [], "review_comments": review_comments_future.result() or [], "reviews": review_data.get("reviews") or [], "review_threads": review_threads_future.result() or [], diff --git a/.github/scripts/pull-request-dashboard/test_author_nudge.py b/.github/scripts/pull-request-dashboard/test_author_nudge.py index e18ff4077b4..c8ccf2431e2 100644 --- a/.github/scripts/pull-request-dashboard/test_author_nudge.py +++ b/.github/scripts/pull-request-dashboard/test_author_nudge.py @@ -27,6 +27,7 @@ def test_routing_fingerprint_ignores_dashboard_comments_and_tracks_author_activi raw = { "checks": [], "issue_comments": [], + "labels": [], "review_comments": [], "reviews": [], "review_threads": [], @@ -47,6 +48,27 @@ def test_routing_fingerprint_ignores_dashboard_comments_and_tracks_author_activi }) self.assertNotEqual(baseline, author_nudge.routing_input_fingerprint(raw)) + def test_routing_fingerprint_tracks_normalized_labels(self) -> None: + raw = { + "checks": [], + "issue_comments": [], + "pr": {"labels": [{"name": "needs-triage"}]}, + "review_comments": [], + "reviews": [], + "review_threads": [], + } + baseline = author_nudge.routing_input_fingerprint(raw) + raw["pr"]["labels"].append({"name": "documentation"}) + + self.assertEqual(baseline, author_nudge.routing_input_fingerprint(raw)) + + raw["pr"]["labels"].append({"name": "dashboard:route-overridden"}) + overridden = author_nudge.routing_input_fingerprint(raw) + + self.assertNotEqual(baseline, overridden) + raw["pr"]["labels"].reverse() + self.assertEqual(overridden, author_nudge.routing_input_fingerprint(raw)) + def test_routing_fingerprint_tracks_required_check_state(self) -> None: raw = { "checks": [{"name": "build", "bucket": "fail"}], @@ -87,6 +109,10 @@ def test_fetch_current_routing_state_includes_required_checks( "node_id": "PR_node", "base": {"ref": "main"}, "head": {"sha": "current-head"}, + "labels": [ + {"name": "needs-triage"}, + {"name": "dashboard:route-overridden"}, + ], } gh_api.side_effect = lambda path, paginate=False: ( pr if path.endswith("/pulls/1") else [] @@ -102,6 +128,10 @@ def test_fetch_current_routing_state_includes_required_checks( author_nudge.routing_input_fingerprint({ "checks": [{"name": "build", "bucket": "fail"}], "issue_comments": [], + "labels": [ + {"name": "needs-triage"}, + {"name": "dashboard:route-overridden"}, + ], "review_comments": [], "reviews": [], "review_threads": [], From b957422f84a220963d44d1d8112a1a4be62b44ca Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 21 Jul 2026 20:00:25 -0700 Subject: [PATCH 41/44] Address review comment from copilot-pull-request-reviewer: advertise external overrides --- .../dashboard_override.py | 15 ++++++++++++--- .../pr_status_comment.py | 18 ++++++++++-------- .../test_dashboard_override.py | 10 ++++++++++ .../test_pr_status_comment.py | 13 +++++++++++++ 4 files changed, 45 insertions(+), 11 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/dashboard_override.py b/.github/scripts/pull-request-dashboard/dashboard_override.py index 42b8ce30d7a..1e99db8339c 100644 --- a/.github/scripts/pull-request-dashboard/dashboard_override.py +++ b/.github/scripts/pull-request-dashboard/dashboard_override.py @@ -32,11 +32,20 @@ REVIEWERS_OR_LATER_ROUTES = ("approver", "maintainer") -def author_override_guidance(staleness_note: str = "") -> str: +def author_override_guidance( + staleness_note: str = "", + *, + route: str = "author", +) -> str: + waiting_on = ( + "an external dependency or decision" + if route == "external" + else "the author" + ) guidance = ( "If you believe this pull request is incorrectly routed as waiting on " - "the author, comment `/dashboard route:reviewers` to route it from " - "waiting on the author to waiting on reviewers." + f"{waiting_on}, comment `/dashboard route:reviewers` to route it from " + f"waiting on {waiting_on} to waiting on reviewers." ) if staleness_note: guidance = f"{guidance} {staleness_note}" diff --git a/.github/scripts/pull-request-dashboard/pr_status_comment.py b/.github/scripts/pull-request-dashboard/pr_status_comment.py index f6c2fb830fc..23ad315c663 100644 --- a/.github/scripts/pull-request-dashboard/pr_status_comment.py +++ b/.github/scripts/pull-request-dashboard/pr_status_comment.py @@ -12,7 +12,7 @@ gh_api, run_gh, ) -from dashboard_override import author_override_guidance +from dashboard_override import PRE_REVIEW_ROUTES, author_override_guidance from route_presentation import route_status_summary from state import ( load_dashboard_state_cache, @@ -32,7 +32,7 @@ ) # Increment whenever render_status_comment changes in a way existing comments # need to adopt. Hourly runs durably roll the revision out to all open PRs. -STATUS_COMMENT_REVISION = 11 +STATUS_COMMENT_REVISION = 12 STATUS_COMMENT_ROLLOUT_BATCH_SIZE = 50 AUTHOR_ACTION_FEEDBACK_LINK_LIMIT = 20 NON_BLOCKING_CHECK_FAILURE_LIMIT = 20 @@ -77,7 +77,7 @@ def status_author_nudge_episode_id( return "" -def accuracy_note(pr: dict[str, Any], author_routed: bool = False) -> str: +def accuracy_note(pr: dict[str, Any], override_route: str = "") -> str: query = urlencode({ "template": STATUS_REPORT_ISSUE_TEMPLATE, "title": "PR dashboard result looks incorrect", @@ -88,10 +88,11 @@ def accuracy_note(pr: dict[str, Any], author_routed: bool = False) -> str: "This automated status or its linked feedback items may be incorrect. " f"If something looks wrong, please [report it]({report_url}) with the result you expected." ) - if author_routed: + if override_route: note += " " + author_override_guidance( "If the last refreshed time above predates your latest reply or " - "push, the dashboard hasn't processed it yet." + "push, the dashboard hasn't processed it yet.", + route=override_route, ) return f"_{note}_" @@ -135,7 +136,7 @@ def render_status_comment( ) feedback_indent: str | None = None - author_routed = False + override_route = "" if pr.get("merged"): summary = ["- **Status:** Merged."] @@ -153,8 +154,9 @@ def render_status_comment( ] else: route = result.get("route") or "unknown" + if route in PRE_REVIEW_ROUTES: + override_route = route if route == "author": - author_routed = True waiting_on, fallback_next_step = route_status_summary(route) check_action = None if failing_count: @@ -231,7 +233,7 @@ def render_status_comment( ) ) lines.append("") - lines.append(accuracy_note(pr, author_routed)) + lines.append(accuracy_note(pr, override_route)) lines.append("") return "\n".join(lines) diff --git a/.github/scripts/pull-request-dashboard/test_dashboard_override.py b/.github/scripts/pull-request-dashboard/test_dashboard_override.py index 2b4e4a44b71..d06d20ae9b6 100644 --- a/.github/scripts/pull-request-dashboard/test_dashboard_override.py +++ b/.github/scripts/pull-request-dashboard/test_dashboard_override.py @@ -7,6 +7,16 @@ class DashboardOverrideTest(unittest.TestCase): + def test_override_guidance_matches_pre_review_route(self) -> None: + self.assertIn( + "waiting on the author to waiting on reviewers", + dashboard_override.author_override_guidance(), + ) + self.assertIn( + "waiting on an external dependency or decision to waiting on reviewers", + dashboard_override.author_override_guidance(route="external"), + ) + def test_dashboard_command_body_remainder(self) -> None: self.assertIsNone( dashboard_override.dashboard_command_body_remainder( 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 b2683ddd8cb..4539c5ddea0 100644 --- a/.github/scripts/pull-request-dashboard/test_pr_status_comment.py +++ b/.github/scripts/pull-request-dashboard/test_pr_status_comment.py @@ -436,6 +436,19 @@ def test_author_login_is_not_mentioned(self) -> None: self.assertIn("- **Waiting on:** Author", body) self.assertNotIn("@alice", body) + def test_external_route_advertises_reviewer_override(self) -> None: + body = pr_status_comment.render_status_comment( + self.pr(), + {"route": "external", "facts": {}}, + ) + + self.assertIn( + "waiting on an external dependency or decision, comment " + "`/dashboard route:reviewers` to route it from waiting on an " + "external dependency or decision to waiting on reviewers", + body, + ) + def test_routes_render_one_status_sentence(self) -> None: expected_summaries = { "approver": ("Reviewers", "Review the latest changes."), From f206e0a107a520653b64e7a527b1dd0d4b565d28 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 21 Jul 2026 20:09:53 -0700 Subject: [PATCH 42/44] Address review comment from copilot-pull-request-reviewer: validate PR text freshness --- .../pull-request-dashboard/author_nudge.py | 8 ++++++- .../pull-request-dashboard/github_cli.py | 2 +- .../test_author_nudge.py | 22 +++++++++++++++++++ .../pull-request-dashboard/test_github_cli.py | 11 ++++++++++ 4 files changed, 41 insertions(+), 2 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/author_nudge.py b/.github/scripts/pull-request-dashboard/author_nudge.py index 76748c4e77a..440dec12390 100644 --- a/.github/scripts/pull-request-dashboard/author_nudge.py +++ b/.github/scripts/pull-request-dashboard/author_nudge.py @@ -46,9 +46,10 @@ def nudge_marker(episode_id: str) -> str: def routing_input_fingerprint(raw: dict[str, Any]) -> str: dashboard_login = f"{DASHBOARD_APP_SLUG}[bot]" + pr = raw.get("pr") or raw labels = raw.get("labels") if labels is None: - labels = (raw.get("pr") or {}).get("labels") or [] + labels = pr.get("labels") or [] issue_comments = [ comment for comment in raw.get("issue_comments") or [] @@ -63,6 +64,10 @@ def routing_input_fingerprint(raw: dict[str, Any]) -> str: if isinstance(label, dict) and label.get("name") == DASHBOARD_OVERRIDE_LABEL ), + "pr_text": { + "body": str(pr.get("body") or "").replace("\r\n", "\n"), + "title": str(pr.get("title") or ""), + }, "review_comments": raw.get("review_comments") or [], "reviews": raw.get("reviews") or [], "review_threads": raw.get("review_threads") or [], @@ -126,6 +131,7 @@ def fetch_current_pr_routing_state( ), "issue_comments": issue_comments_future.result() or [], "labels": pr.get("labels") or [], + "pr": pr, "review_comments": review_comments_future.result() or [], "reviews": review_data.get("reviews") or [], "review_threads": review_threads_future.result() or [], diff --git a/.github/scripts/pull-request-dashboard/github_cli.py b/.github/scripts/pull-request-dashboard/github_cli.py index 7c838004623..8f6e719ce06 100644 --- a/.github/scripts/pull-request-dashboard/github_cli.py +++ b/.github/scripts/pull-request-dashboard/github_cli.py @@ -135,7 +135,7 @@ def gh_graphql(query: str, fields: dict[str, Any], token: str | None = None) -> def gh_pr_view(repo: str, number: int) -> dict[str, Any]: fields = ",".join([ - "id", "number", "title", "url", "author", "state", "isDraft", + "id", "number", "title", "body", "url", "author", "state", "isDraft", "mergeable", "mergeStateStatus", "createdAt", "updatedAt", "headRefOid", "reviewDecision", "reviewRequests", "assignees", "baseRefName", "labels", ]) diff --git a/.github/scripts/pull-request-dashboard/test_author_nudge.py b/.github/scripts/pull-request-dashboard/test_author_nudge.py index c8ccf2431e2..8dce55d6442 100644 --- a/.github/scripts/pull-request-dashboard/test_author_nudge.py +++ b/.github/scripts/pull-request-dashboard/test_author_nudge.py @@ -83,6 +83,25 @@ def test_routing_fingerprint_tracks_required_check_state(self) -> None: self.assertNotEqual(failing, author_nudge.routing_input_fingerprint(raw)) + def test_routing_fingerprint_tracks_pr_title_and_body(self) -> None: + raw = { + "checks": [], + "issue_comments": [], + "pr": {"title": "Original title", "body": "Original body"}, + "review_comments": [], + "reviews": [], + "review_threads": [], + } + baseline = author_nudge.routing_input_fingerprint(raw) + + raw["pr"]["title"] = "Updated title" + title_updated = author_nudge.routing_input_fingerprint(raw) + raw["pr"]["title"] = "Original title" + raw["pr"]["body"] = "Updated body" + + self.assertNotEqual(baseline, title_updated) + self.assertNotEqual(baseline, author_nudge.routing_input_fingerprint(raw)) + @patch.object(author_nudge, "gh_required_check_contexts", return_value=[]) @patch.object( author_nudge, @@ -108,11 +127,13 @@ def test_fetch_current_routing_state_includes_required_checks( pr = { "node_id": "PR_node", "base": {"ref": "main"}, + "body": "Current body", "head": {"sha": "current-head"}, "labels": [ {"name": "needs-triage"}, {"name": "dashboard:route-overridden"}, ], + "title": "Current title", } gh_api.side_effect = lambda path, paginate=False: ( pr if path.endswith("/pulls/1") else [] @@ -132,6 +153,7 @@ def test_fetch_current_routing_state_includes_required_checks( {"name": "needs-triage"}, {"name": "dashboard:route-overridden"}, ], + "pr": pr, "review_comments": [], "reviews": [], "review_threads": [], diff --git a/.github/scripts/pull-request-dashboard/test_github_cli.py b/.github/scripts/pull-request-dashboard/test_github_cli.py index cc02d5220df..e6c5beee80b 100644 --- a/.github/scripts/pull-request-dashboard/test_github_cli.py +++ b/.github/scripts/pull-request-dashboard/test_github_cli.py @@ -10,6 +10,7 @@ fetch_pr_title_edits, gh_pr_check_rollup, gh_pr_checks, + gh_pr_view, gh_required_check_contexts, include_missing_required_checks, is_retryable_gh_error, @@ -19,6 +20,16 @@ class GithubCliTest(unittest.TestCase): + @patch("github_cli.run_gh_json") + def test_pr_view_fetches_body_for_routing_freshness(self, run_json) -> None: + run_json.return_value = {"mergeable": "MERGEABLE"} + + gh_pr_view("open-telemetry/example", 7) + + fields = run_json.call_args.args[0][-1] + self.assertIn("title", fields.split(",")) + self.assertIn("body", fields.split(",")) + @patch("github_cli.gh_graphql") def test_request_copilot_review_uses_request_reviews_mutation( self, From 698a912f291afcb2c4de56d399714440ff4c84ef Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 21 Jul 2026 20:24:30 -0700 Subject: [PATCH 43/44] Address review comment from copilot-pull-request-reviewer: revalidate review routing --- .../pull-request-dashboard/author_nudge.py | 5 ++ .../pull-request-dashboard/copilot_review.py | 13 +++- .../scripts/pull-request-dashboard/state.py | 2 +- .../test_author_nudge.py | 18 +++++ .../test_copilot_review.py | 72 ++++++++++++++++--- .../pull-request-dashboard/test_state.py | 12 +++- 6 files changed, 110 insertions(+), 12 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/author_nudge.py b/.github/scripts/pull-request-dashboard/author_nudge.py index 440dec12390..8e187093000 100644 --- a/.github/scripts/pull-request-dashboard/author_nudge.py +++ b/.github/scripts/pull-request-dashboard/author_nudge.py @@ -56,6 +56,11 @@ def routing_input_fingerprint(raw: dict[str, Any]) -> str: if (comment.get("user") or {}).get("login") != dashboard_login ] routing_inputs = { + "base_branch": str( + pr.get("baseRefName") + or (pr.get("base") or {}).get("ref") + or "" + ), "checks": raw.get("checks"), "issue_comments": issue_comments, "labels": sorted( diff --git a/.github/scripts/pull-request-dashboard/copilot_review.py b/.github/scripts/pull-request-dashboard/copilot_review.py index 7b2efbfc251..0040e2a745c 100644 --- a/.github/scripts/pull-request-dashboard/copilot_review.py +++ b/.github/scripts/pull-request-dashboard/copilot_review.py @@ -6,9 +6,9 @@ from pathlib import Path from typing import Any +from author_nudge import fetch_current_pr_routing_state from github_cli import ( fetch_pr_review_data, - gh_api, request_copilot_review, ) from state import load_copilot_review_requests, save_copilot_review_requests @@ -80,12 +80,14 @@ def record_copilot_review_observation( key = str(pr_number) facts = (result or {}).get("facts") or {} head_sha = str(facts.get("head_sha") or "") + routing_fingerprint = str(facts.get("routing_input_fingerprint") or "") if ( not result or result.get("failed") or result.get("route") != "copilot" or not facts.get("copilot_review_request_needed") or not head_sha + or not routing_fingerprint ): requests.pop(key, None) else: @@ -93,6 +95,7 @@ def record_copilot_review_observation( "head_sha": head_sha, "observed_at": format_ts(observed_at), "requested_at": "", + "routing_input_fingerprint": routing_fingerprint, } save_copilot_review_requests(requests) @@ -110,12 +113,18 @@ def deliver_copilot_review_requests( continue pr_number = int(key) try: - pr = gh_api(f"/repos/{repo}/pulls/{pr_number}") or {} + pr, current_routing_fingerprint = fetch_current_pr_routing_state( + repo, + pr_number, + ) current_head = ((pr.get("head") or {}).get("sha") or "") if ( pr.get("state") != "open" or pr.get("draft") or current_head != entry.get("head_sha") + or not entry.get("routing_input_fingerprint") + or current_routing_fingerprint + != entry.get("routing_input_fingerprint") ): requests.pop(key, None) continue diff --git a/.github/scripts/pull-request-dashboard/state.py b/.github/scripts/pull-request-dashboard/state.py index b0e93127b6e..64db5c46017 100644 --- a/.github/scripts/pull-request-dashboard/state.py +++ b/.github/scripts/pull-request-dashboard/state.py @@ -21,7 +21,7 @@ BACKFILL_STATE_VERSION = 3 NOTIFICATION_STATE_VERSION = 3 AUTHOR_NUDGE_STATE_VERSION = 2 -COPILOT_REVIEW_REQUEST_STATE_VERSION = 2 +COPILOT_REVIEW_REQUEST_STATE_VERSION = 3 STATUS_COMMENT_ROLLOUT_STATE_VERSION = 1 INITIAL_BACKFILL_COMPLETE_KEY = "initial_backfill_complete" _state_dir: Path | None = None diff --git a/.github/scripts/pull-request-dashboard/test_author_nudge.py b/.github/scripts/pull-request-dashboard/test_author_nudge.py index 8dce55d6442..a8991a028dc 100644 --- a/.github/scripts/pull-request-dashboard/test_author_nudge.py +++ b/.github/scripts/pull-request-dashboard/test_author_nudge.py @@ -102,6 +102,24 @@ def test_routing_fingerprint_tracks_pr_title_and_body(self) -> None: self.assertNotEqual(baseline, title_updated) self.assertNotEqual(baseline, author_nudge.routing_input_fingerprint(raw)) + def test_routing_fingerprint_normalizes_and_tracks_base_branch(self) -> None: + accepted = { + "pr": {"baseRefName": "main"}, + } + live = { + "pr": {"base": {"ref": "main"}}, + } + + self.assertEqual( + author_nudge.routing_input_fingerprint(accepted), + author_nudge.routing_input_fingerprint(live), + ) + live["pr"]["base"]["ref"] = "release" + self.assertNotEqual( + author_nudge.routing_input_fingerprint(accepted), + author_nudge.routing_input_fingerprint(live), + ) + @patch.object(author_nudge, "gh_required_check_contexts", return_value=[]) @patch.object( author_nudge, diff --git a/.github/scripts/pull-request-dashboard/test_copilot_review.py b/.github/scripts/pull-request-dashboard/test_copilot_review.py index 6b9e76b23c2..ddc5bab009d 100644 --- a/.github/scripts/pull-request-dashboard/test_copilot_review.py +++ b/.github/scripts/pull-request-dashboard/test_copilot_review.py @@ -22,6 +22,7 @@ def test_records_request_for_current_head(self, _load_requests, save_requests) - "facts": { "head_sha": "current-head", "copilot_review_request_needed": True, + "routing_input_fingerprint": "accepted-fingerprint", }, }, NOW, @@ -32,6 +33,7 @@ def test_records_request_for_current_head(self, _load_requests, save_requests) - "head_sha": "current-head", "observed_at": "2026-07-20T02:00:00+00:00", "requested_at": "", + "routing_input_fingerprint": "accepted-fingerprint", }, }) @@ -48,6 +50,7 @@ def test_new_head_replaces_previous_request(self, _load_requests, save_requests) "facts": { "head_sha": "current-head", "copilot_review_request_needed": True, + "routing_input_fingerprint": "accepted-fingerprint", }, }, NOW, @@ -58,6 +61,7 @@ def test_new_head_replaces_previous_request(self, _load_requests, save_requests) "head_sha": "current-head", "observed_at": "2026-07-20T02:00:00+00:00", "requested_at": "", + "routing_input_fingerprint": "accepted-fingerprint", }, }) @@ -83,6 +87,7 @@ def test_same_head_request_needed_resets_acknowledgement( "facts": { "head_sha": "current-head", "copilot_review_request_needed": True, + "routing_input_fingerprint": "accepted-fingerprint", }, }, NOW, @@ -93,6 +98,7 @@ def test_same_head_request_needed_resets_acknowledgement( "head_sha": "current-head", "observed_at": "2026-07-20T02:00:00+00:00", "requested_at": "", + "routing_input_fingerprint": "accepted-fingerprint", }, }) @@ -140,7 +146,7 @@ def test_initial_automatic_review_does_not_enqueue_request( @patch("copilot_review.request_copilot_review") @patch("copilot_review.fetch_pr_review_data") - @patch("copilot_review.gh_api") + @patch("copilot_review.fetch_current_pr_routing_state") @patch("copilot_review.save_copilot_review_requests") @patch( "copilot_review.load_copilot_review_requests", @@ -149,6 +155,7 @@ def test_initial_automatic_review_does_not_enqueue_request( "head_sha": "current-head", "observed_at": "2026-07-20T01:00:00+00:00", "requested_at": "", + "routing_input_fingerprint": "accepted-fingerprint", } }, ) @@ -156,17 +163,18 @@ def test_delivers_request_for_current_stale_review( self, _load_requests, save_requests, - gh_api, + fetch_current_state, fetch_review_data, request_review, ) -> None: - gh_api.return_value = { + pr = { "state": "open", "draft": False, "head": {"sha": "current-head"}, "node_id": "PR_node_id", "requested_reviewers": [], } + fetch_current_state.return_value = (pr, "accepted-fingerprint") fetch_review_data.return_value = { "reviews": [{ "id": 20, @@ -183,7 +191,7 @@ def test_delivers_request_for_current_stale_review( ) self.assertEqual([], errors) - gh_api.assert_called_once_with("/repos/open-telemetry/example/pulls/7") + fetch_current_state.assert_called_once_with("open-telemetry/example", 7) fetch_review_data.assert_called_once_with("open-telemetry", "example", 7) request_review.assert_called_once_with("PR_node_id") save_requests.assert_called_once_with({ @@ -191,12 +199,13 @@ def test_delivers_request_for_current_stale_review( "head_sha": "current-head", "observed_at": "2026-07-20T01:00:00+00:00", "requested_at": "2026-07-20T02:00:00+00:00", + "routing_input_fingerprint": "accepted-fingerprint", }, }) @patch("copilot_review.request_copilot_review") @patch("copilot_review.fetch_pr_review_data") - @patch("copilot_review.gh_api") + @patch("copilot_review.fetch_current_pr_routing_state") @patch("copilot_review.save_copilot_review_requests") @patch( "copilot_review.load_copilot_review_requests", @@ -205,6 +214,7 @@ def test_delivers_request_for_current_stale_review( "head_sha": "current-head", "observed_at": "2026-07-20T01:00:00+00:00", "requested_at": "", + "routing_input_fingerprint": "accepted-fingerprint", } }, ) @@ -212,11 +222,11 @@ def test_pending_request_is_acknowledged_from_pull_response( self, _load_requests, save_requests, - gh_api, + fetch_current_state, fetch_review_data, request_review, ) -> None: - gh_api.return_value = { + pr = { "state": "open", "draft": False, "head": {"sha": "current-head"}, @@ -224,6 +234,7 @@ def test_pending_request_is_acknowledged_from_pull_response( {"login": "copilot-pull-request-reviewer[bot]"}, ], } + fetch_current_state.return_value = (pr, "accepted-fingerprint") errors = deliver_copilot_review_requests( "open-telemetry/example", @@ -231,7 +242,7 @@ def test_pending_request_is_acknowledged_from_pull_response( ) self.assertEqual([], errors) - gh_api.assert_called_once_with("/repos/open-telemetry/example/pulls/7") + fetch_current_state.assert_called_once_with("open-telemetry/example", 7) fetch_review_data.assert_not_called() request_review.assert_not_called() save_requests.assert_called_once_with({ @@ -239,9 +250,54 @@ def test_pending_request_is_acknowledged_from_pull_response( "head_sha": "current-head", "observed_at": "2026-07-20T01:00:00+00:00", "requested_at": "2026-07-20T02:00:00+00:00", + "routing_input_fingerprint": "accepted-fingerprint", }, }) + @patch("copilot_review.request_copilot_review") + @patch("copilot_review.fetch_pr_review_data") + @patch( + "copilot_review.fetch_current_pr_routing_state", + return_value=( + { + "state": "open", + "draft": False, + "head": {"sha": "current-head"}, + "requested_reviewers": [], + }, + "new-fingerprint", + ), + ) + @patch("copilot_review.save_copilot_review_requests") + @patch( + "copilot_review.load_copilot_review_requests", + return_value={ + "7": { + "head_sha": "current-head", + "observed_at": "2026-07-20T01:00:00+00:00", + "requested_at": "", + "routing_input_fingerprint": "accepted-fingerprint", + } + }, + ) + def test_drops_request_when_live_routing_inputs_changed( + self, + _load_requests, + save_requests, + _fetch_current_state, + fetch_review_data, + request_review, + ) -> None: + errors = deliver_copilot_review_requests( + "open-telemetry/example", + NOW, + ) + + self.assertEqual([], errors) + fetch_review_data.assert_not_called() + request_review.assert_not_called() + save_requests.assert_called_once_with({}) + if __name__ == "__main__": unittest.main() \ No newline at end of file diff --git a/.github/scripts/pull-request-dashboard/test_state.py b/.github/scripts/pull-request-dashboard/test_state.py index 8305ee93558..9e067fbc9d2 100644 --- a/.github/scripts/pull-request-dashboard/test_state.py +++ b/.github/scripts/pull-request-dashboard/test_state.py @@ -164,7 +164,7 @@ def test_notification_state_version_is_independent(self) -> None: self.assertEqual(DASHBOARD_STATE_VERSION, 5) self.assertEqual(STATUS_COMMENT_ROLLOUT_STATE_VERSION, 1) self.assertEqual(AUTHOR_NUDGE_STATE_VERSION, 2) - self.assertEqual(COPILOT_REVIEW_REQUEST_STATE_VERSION, 2) + self.assertEqual(COPILOT_REVIEW_REQUEST_STATE_VERSION, 3) def test_author_nudge_state_round_trip(self) -> None: with tempfile.TemporaryDirectory() as temp_dir, patch("state._state_dir", Path(temp_dir)): @@ -193,6 +193,7 @@ def test_copilot_review_request_state_round_trip(self) -> None: "head_sha": "current-head", "observed_at": "2026-07-20T01:00:00Z", "requested_at": "", + "routing_input_fingerprint": "accepted-fingerprint", } }) @@ -203,6 +204,7 @@ def test_copilot_review_request_state_round_trip(self) -> None: "head_sha": "current-head", "observed_at": "2026-07-20T01:00:00Z", "requested_at": "", + "routing_input_fingerprint": "accepted-fingerprint", } }, ) @@ -248,6 +250,7 @@ def test_retry_snapshot_preserves_same_head_copilot_request(self) -> None: "head_sha": "current-head", "observed_at": "2026-07-20T01:00:00Z", "requested_at": "2026-07-20T02:00:00Z", + "routing_input_fingerprint": "accepted-fingerprint", } }, union_merge_copilot_review_requests( @@ -256,6 +259,7 @@ def test_retry_snapshot_preserves_same_head_copilot_request(self) -> None: "head_sha": "current-head", "observed_at": "2026-07-20T01:00:00Z", "requested_at": "", + "routing_input_fingerprint": "accepted-fingerprint", } }, { @@ -263,6 +267,7 @@ def test_retry_snapshot_preserves_same_head_copilot_request(self) -> None: "head_sha": "current-head", "observed_at": "2026-07-20T01:00:00Z", "requested_at": "2026-07-20T02:00:00Z", + "routing_input_fingerprint": "accepted-fingerprint", } }, ), @@ -275,6 +280,7 @@ def test_retry_snapshot_does_not_overwrite_new_copilot_head(self) -> None: "head_sha": "new-head", "observed_at": "2026-07-20T03:00:00Z", "requested_at": "", + "routing_input_fingerprint": "new-fingerprint", } }, union_merge_copilot_review_requests( @@ -283,6 +289,7 @@ def test_retry_snapshot_does_not_overwrite_new_copilot_head(self) -> None: "head_sha": "new-head", "observed_at": "2026-07-20T03:00:00Z", "requested_at": "", + "routing_input_fingerprint": "new-fingerprint", } }, { @@ -290,6 +297,7 @@ def test_retry_snapshot_does_not_overwrite_new_copilot_head(self) -> None: "head_sha": "old-head", "observed_at": "2026-07-20T01:00:00Z", "requested_at": "2026-07-20T02:00:00Z", + "routing_input_fingerprint": "old-fingerprint", } }, ), @@ -301,6 +309,7 @@ def test_retry_snapshot_does_not_suppress_new_same_head_request(self) -> None: "head_sha": "current-head", "observed_at": "2026-07-20T03:00:00Z", "requested_at": "", + "routing_input_fingerprint": "new-fingerprint", } } @@ -313,6 +322,7 @@ def test_retry_snapshot_does_not_suppress_new_same_head_request(self) -> None: "head_sha": "current-head", "observed_at": "2026-07-20T01:00:00Z", "requested_at": "2026-07-20T02:00:00Z", + "routing_input_fingerprint": "old-fingerprint", } }, ), From 20d62b71cd87e0da264eef861d2f8f0e4cbaca93 Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Tue, 21 Jul 2026 20:25:09 -0700 Subject: [PATCH 44/44] Address review comment from copilot-pull-request-reviewer: report gated overrides --- .../pull-request-dashboard/dashboard_override.py | 9 ++++++++- .../test_dashboard_override.py | 15 ++++++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/dashboard_override.py b/.github/scripts/pull-request-dashboard/dashboard_override.py index 1e99db8339c..196bb58a449 100644 --- a/.github/scripts/pull-request-dashboard/dashboard_override.py +++ b/.github/scripts/pull-request-dashboard/dashboard_override.py @@ -242,7 +242,13 @@ def render_command_reply(reply: dict[str, Any]) -> str: "use `/dashboard route:reviewers`." ) elif kind == "routed": - message = "routed this pull request to reviewers." + if reply.get("route") == "copilot": + message = ( + "accepted the reviewer-routing override; the reviewer handoff " + "is waiting on Copilot." + ) + else: + message = "routed this pull request to reviewers." elif kind == "already_routed": where = ROUTE_ALREADY_ROUTED_PHRASE.get( reply.get("route") or "", "not currently waiting on you" @@ -427,6 +433,7 @@ def deliver_dashboard_override_requests(repo: str) -> list[str]: { "comment_id": facts["dashboard_override_command_id"], "kind": "routed", + "route": (result or {}).get("route") or "", "user": facts.get("dashboard_override_command_user") or "", }, ) diff --git a/.github/scripts/pull-request-dashboard/test_dashboard_override.py b/.github/scripts/pull-request-dashboard/test_dashboard_override.py index d06d20ae9b6..43178a1e6a0 100644 --- a/.github/scripts/pull-request-dashboard/test_dashboard_override.py +++ b/.github/scripts/pull-request-dashboard/test_dashboard_override.py @@ -154,6 +154,12 @@ def test_renders_command_replies(self) -> None: routed = dashboard_override.render_command_reply( {"comment_id": 4, "kind": "routed", "user": "author"} ) + copilot_gated = dashboard_override.render_command_reply({ + "comment_id": 5, + "kind": "routed", + "route": "copilot", + "user": "author", + }) self.assertIn(dashboard_override.command_reply_marker(2), unauthorized) self.assertIn( @@ -169,6 +175,12 @@ def test_renders_command_replies(self) -> None: self.assertIn(dashboard_override.command_reply_marker(4), routed) self.assertIn(dashboard_override.override_ack_marker(4), routed) self.assertIn("@author routed this pull request to reviewers.", routed) + self.assertIn(dashboard_override.command_reply_marker(5), copilot_gated) + self.assertIn( + "@author accepted the reviewer-routing override; the reviewer " + "handoff is waiting on Copilot.", + copilot_gated, + ) def test_renders_already_routed_replies_per_route(self) -> None: cases = { @@ -595,6 +607,7 @@ def test_does_not_create_label_without_pending_command(self, _load_state, run_gh return_value={ "prs": { "7": { + "route": "copilot", "facts": { "dashboard_override_requested": True, "dashboard_override_command_id": 3, @@ -637,7 +650,7 @@ def test_delivers_pending_override_label_and_acknowledges_command( call([ "gh", "api", "--method", "POST", "repos/open-telemetry/example/issues/7/comments", - "-f", "body=\n\n@author routed this pull request to reviewers.\n", + "-f", "body=\n\n@author accepted the reviewer-routing override; the reviewer handoff is waiting on Copilot.\n", ]), ], run_gh.call_args_list,