From db77bfd45f3408da51f3f152f531ef7412bc4faf Mon Sep 17 00:00:00 2001 From: Trask Stalnaker Date: Sat, 17 Oct 2026 20:23:00 -0700 Subject: [PATCH] Show human authors for Copilot draft pull requests Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: fb463d90-5190-4a95-b3d4-aa7e117f2a38 --- .../scripts/pull-request-dashboard/CONTEXT.md | 8 +- .../pull-request-dashboard/github_cli.py | 1 + .../publish_dashboard.py | 18 ++ .../pull_request_author.py | 58 +++++ .../pull_request_evaluation.py | 51 +---- .../scripts/pull-request-dashboard/render.py | 2 +- .../pull-request-dashboard/test_github_cli.py | 3 + .../test_publish_dashboard.py | 215 ++++++++++++++++++ pull-request-dashboard/README.md | 2 +- 9 files changed, 306 insertions(+), 52 deletions(-) create mode 100644 .github/scripts/pull-request-dashboard/pull_request_author.py diff --git a/.github/scripts/pull-request-dashboard/CONTEXT.md b/.github/scripts/pull-request-dashboard/CONTEXT.md index 3dca679757f7..8ce76ba65e2c 100644 --- a/.github/scripts/pull-request-dashboard/CONTEXT.md +++ b/.github/scripts/pull-request-dashboard/CONTEXT.md @@ -8,7 +8,7 @@ reads a canonical `PullRequestSource` and produces either `EvaluationSuccess` or `EvaluationFailure`. A success carries typed `DashboardFacts`, the final route, diagnostics, pending actions, and top-level history. A failure carries a failure route and error, so failure routes cannot be combined with successful results. -Evaluation owns effective-author resolution, activity, discussions, +Evaluation uses the shared effective-author resolver and owns activity, discussions, classification, lifecycle and routing decisions, reviewer projection, command acknowledgements, author-action links, nudge episodes, and PR-specific failure shaping. It has no accepted-state or state-branch side effects. @@ -21,6 +21,12 @@ reviews, review requests, review threads, checks, and non-blocking failures. `github_cli.py` remains the transport layer; its response dictionaries do not cross into evaluation or domain modules. +`pull_request_author.py` resolves Copilot authors to a human assignee or +first-commit committer. Evaluation and draft publication share this policy. +Publication uses assignees from the open-PR listing and fetches only the first +commit when a Copilot draft has no eligible assignee. Drafts remain outside +evaluation and routing. + `dashboard_contracts.py` defines the immutable in-memory boundary. A `StoredDashboardResult` projects an evaluation success down to the fields that survive refreshes. `DashboardState` holds those projections and the initial diff --git a/.github/scripts/pull-request-dashboard/github_cli.py b/.github/scripts/pull-request-dashboard/github_cli.py index 51bc11351060..b32854b0871d 100644 --- a/.github/scripts/pull-request-dashboard/github_cli.py +++ b/.github/scripts/pull-request-dashboard/github_cli.py @@ -796,6 +796,7 @@ def list_open_prs(repo: str) -> list[dict[str, Any]]: "number": pull["number"], "title": pull["title"], "author": pull.get("user"), + "assignees": pull.get("assignees") or [], "isDraft": pull.get("draft", False), "createdAt": pull.get("created_at"), "url": pull.get("html_url"), diff --git a/.github/scripts/pull-request-dashboard/publish_dashboard.py b/.github/scripts/pull-request-dashboard/publish_dashboard.py index 1c810f244b6b..62347eac5a8c 100644 --- a/.github/scripts/pull-request-dashboard/publish_dashboard.py +++ b/.github/scripts/pull-request-dashboard/publish_dashboard.py @@ -13,6 +13,7 @@ from github_cli import ( detect_repo, fetch_latest_draft_transitions, + gh_api, gh_graphql, list_open_prs, normalize_repo, @@ -20,6 +21,8 @@ run_gh, ) from dashboard_contracts import StoredDashboardResult +from pull_request_author import effective_author, is_copilot_author +from pull_request_source import normalize_commits, normalize_pull_request from render import render_pr_tables from state import ( dashboard_markdown_path, @@ -178,6 +181,20 @@ def publishable_prs( ] +def add_draft_authors(repo: str, prs: list[dict[str, Any]]) -> None: + for pr in prs: + if not pr.get("isDraft"): + continue + metadata = normalize_pull_request(pr) + author = effective_author(metadata) + if is_copilot_author(author): + commits = normalize_commits( + gh_api(f"/repos/{repo}/pulls/{metadata.number}/commits?per_page=1") + ) + author = effective_author(metadata, commits) + pr["effectiveAuthor"] = author + + def add_draft_since(repo: str, prs: list[dict[str, Any]]) -> None: drafts = [pr for pr in prs if pr.get("isDraft")] if not drafts: @@ -201,6 +218,7 @@ def render_dashboard_markdown( prs = list_open_prs(repo) if not large_repo: + add_draft_authors(repo, prs) add_draft_since(repo, prs) open_non_draft_pr_numbers = {p["number"] for p in prs if not p.get("isDraft")} results = results_from_dashboard_state(dashboard_state, open_non_draft_pr_numbers) diff --git a/.github/scripts/pull-request-dashboard/pull_request_author.py b/.github/scripts/pull-request-dashboard/pull_request_author.py new file mode 100644 index 000000000000..f07d52efdc13 --- /dev/null +++ b/.github/scripts/pull-request-dashboard/pull_request_author.py @@ -0,0 +1,58 @@ +"""Resolve the human behind a Copilot-authored pull request.""" + +from __future__ import annotations + +from collections.abc import Sequence + +from pull_request_source import Commit, PullRequestMetadata +from utils import is_unattended_author_login, normalize_author_identity + + +# GitHub reports Copilot PR authors as `app/copilot-swe-agent` or `Copilot`, +# while REST committers can use the bare `copilot` slug. +_COPILOT_COMMITTER_IDENTITIES = {"copilot"} +_COPILOT_PR_AUTHOR_IDENTITIES = {"copilot-swe-agent", "copilot"} + + +def is_copilot_author(login: str) -> bool: + return normalize_author_identity(login) in _COPILOT_PR_AUTHOR_IDENTITIES + + +def _human_author_for_copilot_pr( + pr: PullRequestMetadata, + commits: Sequence[Commit], +) -> str: + for assignee in pr.assignees: + login = assignee.login + if ( + login + and not is_copilot_author(login) + and not is_unattended_author_login(login) + ): + return login + + if not commits: + return "" + committer = commits[0].committer + login = committer.login + if ( + not login + or normalize_author_identity(login) in _COPILOT_COMMITTER_IDENTITIES + or is_unattended_author_login(login) + or committer.is_bot + or committer.is_copilot_reviewer + ): + return "" + return login + + +def effective_author( + pr: PullRequestMetadata, + commits: Sequence[Commit] = (), +) -> str: + author = pr.author.login + if is_copilot_author(author): + human_author = _human_author_for_copilot_pr(pr, commits) + if human_author: + return human_author + return author diff --git a/.github/scripts/pull-request-dashboard/pull_request_evaluation.py b/.github/scripts/pull-request-dashboard/pull_request_evaluation.py index ff6eb7e254f8..ae077f44a28f 100644 --- a/.github/scripts/pull-request-dashboard/pull_request_evaluation.py +++ b/.github/scripts/pull-request-dashboard/pull_request_evaluation.py @@ -55,6 +55,7 @@ resolve_discussions, ) from github_cli import TransientGhError +from pull_request_author import effective_author from pull_request_source import ( Actor, IssueComment, @@ -87,15 +88,6 @@ ) -# Copilot appears under two slugs: `gh pr view`'s `author` field reports -# `app/copilot-swe-agent`, while the Pulls/commits endpoint's `committer.login` -# field can report the bare `copilot` slug. Either slug can name Copilot as the -# author, so the author set carries both while the committer set carries only -# the bare slug. These sets hold the identities `normalize_author_identity` -# returns, without the `app/` prefix or the `[bot]` suffix. Do not treat either -# slug as the human author behind a Copilot-authored PR. -_COPILOT_COMMITTER_IDENTITIES = {"copilot"} -_COPILOT_PR_AUTHOR_IDENTITIES = {"copilot-swe-agent", "copilot"} _MAINTENANCE_APP_IDENTITIES = {"dependabot", "otelbot", "renovate"} @@ -145,45 +137,6 @@ def __post_init__(self) -> None: raise ValueError("pull request number must be positive") -def _human_author_for_copilot_pr(source: PullRequestSource) -> str: - assignees = [ - assignee.login - for assignee in source.pull_request.assignees - ] - for login in assignees: - identity = normalize_author_identity(login) - if ( - login - and identity not in _COPILOT_PR_AUTHOR_IDENTITIES - and not is_unattended_author_login(login) - ): - return login - - commits = source.commits - if not commits: - return "" - committer = commits[0].committer - login = committer.login - if ( - not login - or normalize_author_identity(login) in _COPILOT_COMMITTER_IDENTITIES - or is_unattended_author_login(login) - or committer.is_bot - or committer.is_copilot_reviewer - ): - return "" - return login - - -def _effective_author(source: PullRequestSource) -> str: - author = source.pull_request.author.login - if normalize_author_identity(author) in _COPILOT_PR_AUTHOR_IDENTITIES: - human_author = _human_author_for_copilot_pr(source) - if human_author: - return human_author - return author - - def _compute_facts( source: PullRequestSource, author: str, @@ -482,7 +435,7 @@ async def evaluate_pull_request( pr_title=pr.title, pr_url=pr.url, ) - author = _effective_author(pr_source) + author = effective_author(pr_source.pull_request, pr_source.commits) activity = build_activity_timeline( ActivityInput(pr_source, author, config.approver_logins) ) diff --git a/.github/scripts/pull-request-dashboard/render.py b/.github/scripts/pull-request-dashboard/render.py index 134a6ad690a5..9f66f860a5f5 100644 --- a/.github/scripts/pull-request-dashboard/render.py +++ b/.github/scripts/pull-request-dashboard/render.py @@ -74,7 +74,7 @@ def render_draft_pr_section( lines.append("| PR | Author | Draft age |") lines.append("|---|---|:---:|") for pr in drafts: - author = actor_login(pr.get("author") or {}) + author = pr.get("effectiveAuthor") or actor_login(pr.get("author") or {}) draft_age = activity_age( parse_ts(pr.get("draftSince") or pr.get("createdAt") or "") ) diff --git a/.github/scripts/pull-request-dashboard/test_github_cli.py b/.github/scripts/pull-request-dashboard/test_github_cli.py index a0b97e647982..09445b10dab5 100644 --- a/.github/scripts/pull-request-dashboard/test_github_cli.py +++ b/.github/scripts/pull-request-dashboard/test_github_cli.py @@ -571,6 +571,7 @@ def test_list_open_prs_uses_paginated_rest_api(self, gh_api) -> None: "number": number, "title": f"PR {number}", "user": {"login": "author"}, + "assignees": [{"login": "human-author"}] if number == 501 else None, "draft": number == 501, "created_at": "2026-07-01T00:00:00Z", "updated_at": "2026-07-17T00:00:00Z", @@ -594,6 +595,7 @@ def test_list_open_prs_uses_paginated_rest_api(self, gh_api) -> None: "number": 501, "title": "PR 501", "author": {"login": "author"}, + "assignees": [{"login": "human-author"}], "isDraft": True, "createdAt": "2026-07-01T00:00:00Z", "url": "https://example.test/pull/501", @@ -602,6 +604,7 @@ def test_list_open_prs_uses_paginated_rest_api(self, gh_api) -> None: prs[-1], ) self.assertEqual([], prs[0]["labels"]) + self.assertEqual([], prs[0]["assignees"]) gh_api.assert_called_once_with( "/repos/open-telemetry/example/pulls?state=open&per_page=100", paginate=True, diff --git a/.github/scripts/pull-request-dashboard/test_publish_dashboard.py b/.github/scripts/pull-request-dashboard/test_publish_dashboard.py index d85b3ac90bac..dbd2f03bd19f 100644 --- a/.github/scripts/pull-request-dashboard/test_publish_dashboard.py +++ b/.github/scripts/pull-request-dashboard/test_publish_dashboard.py @@ -72,6 +72,131 @@ def test_adds_latest_draft_transition_with_creation_fallback( [2, 3], ) + @patch.object(publish_dashboard, "gh_api") + def test_draft_authors_prefer_human_assignees_without_fetching_commits( + self, + gh_api, + ) -> None: + for login in ("Copilot", "app/copilot-swe-agent", "copilot-swe-agent[bot]"): + with self.subTest(login=login): + prs = [{ + "number": 7, + "isDraft": True, + "author": {"login": login}, + "assignees": [ + {"login": "Copilot"}, + {"login": "app/copilot-swe-agent"}, + {"login": "opentelemetrybot"}, + {"login": "renovate[bot]"}, + {"login": "human-author"}, + {"login": "another-human"}, + ], + }] + + publish_dashboard.add_draft_authors("open-telemetry/example", prs) + + self.assertEqual("human-author", prs[0]["effectiveAuthor"]) + self.assertEqual({"login": login}, prs[0]["author"]) + gh_api.assert_not_called() + + @patch.object(publish_dashboard, "gh_api") + def test_draft_author_falls_back_to_first_commit_committer( + self, + gh_api, + ) -> None: + gh_api.return_value = [{ + "author": {"login": "Copilot"}, + "committer": {"login": "human-committer", "type": "User"}, + }] + prs = [{ + "number": 7, + "isDraft": True, + "author": {"login": "Copilot"}, + "assignees": [{"login": "Copilot"}], + }] + + publish_dashboard.add_draft_authors("open-telemetry/example", prs) + + self.assertEqual("human-committer", prs[0]["effectiveAuthor"]) + gh_api.assert_called_once_with( + "/repos/open-telemetry/example/pulls/7/commits?per_page=1" + ) + + @patch.object(publish_dashboard, "gh_api") + def test_draft_author_keeps_copilot_without_a_human_committer( + self, + gh_api, + ) -> None: + commits = ( + [], + [{"committer": None}], + [{"committer": {"login": "Copilot"}}], + [{"committer": {"login": "opentelemetrybot"}}], + [{"committer": {"login": "renovate[bot]"}}], + [{"committer": {"login": "automation", "type": "Bot"}}], + [{"committer": {"login": "copilot-pull-request-reviewer"}}], + [ + {"committer": {"login": "Copilot"}}, + {"committer": {"login": "later-human"}}, + ], + ) + for value in commits: + with self.subTest(commits=value): + gh_api.return_value = value + prs = [{ + "number": 7, + "isDraft": True, + "author": {"login": "Copilot"}, + }] + + publish_dashboard.add_draft_authors("open-telemetry/example", prs) + + self.assertEqual("Copilot", prs[0]["effectiveAuthor"]) + + @patch.object(publish_dashboard, "gh_api") + def test_draft_authors_leave_other_authors_and_non_drafts_unchanged( + self, + gh_api, + ) -> None: + prs = [ + { + "number": 7, + "isDraft": True, + "author": {"login": "human-author"}, + "assignees": [{"login": "another-human"}], + }, + { + "number": 8, + "isDraft": True, + "author": {"login": "renovate[bot]"}, + }, + {"number": 9, "isDraft": False, "author": {"login": "Copilot"}}, + ] + + publish_dashboard.add_draft_authors("open-telemetry/example", prs) + + self.assertEqual("human-author", prs[0]["effectiveAuthor"]) + self.assertEqual("renovate[bot]", prs[1]["effectiveAuthor"]) + self.assertNotIn("effectiveAuthor", prs[2]) + gh_api.assert_not_called() + + @patch.object( + publish_dashboard, + "gh_api", + side_effect=RuntimeError("commit fetch failed"), + ) + def test_draft_author_commit_fetch_failure_is_not_hidden(self, gh_api) -> None: + prs = [{ + "number": 7, + "isDraft": True, + "author": {"login": "Copilot"}, + }] + + with self.assertRaisesRegex(RuntimeError, "commit fetch failed"): + publish_dashboard.add_draft_authors("open-telemetry/example", prs) + + self.assertNotIn("effectiveAuthor", prs[0]) + @patch.object(publish_dashboard, "publish_dashboard") @patch.object(publish_dashboard, "render_dashboard_markdown", return_value=Path("dashboard.md")) @patch.object(publish_dashboard, "set_state_dir") @@ -183,6 +308,96 @@ def test_passes_labels_to_renderer(self) -> None: labels_to_display=["size/*"], ) + def test_renders_effective_draft_authors_from_the_open_pull_listing(self) -> None: + pulls = [ + { + "number": 7, + "title": "Assigned draft", + "draft": True, + "user": {"login": "Copilot"}, + "assignees": [{"login": "Copilot"}, {"login": "human-assignee"}], + }, + { + "number": 8, + "title": "Unassigned draft", + "draft": True, + "user": {"login": "Copilot"}, + }, + ] + with tempfile.TemporaryDirectory() as tmp: + output_path = Path(tmp) / "dashboard.md" + with ( + patch.object( + publish_dashboard, + "load_dashboard_state_cache", + return_value=dashboard_state(), + ), + patch("github_cli._list_open_pulls", return_value=pulls), + patch.object( + publish_dashboard, + "gh_api", + return_value=[{"committer": {"login": "human-committer"}}], + ) as gh_api, + patch.object( + publish_dashboard, + "fetch_latest_draft_transitions", + return_value={}, + ), + patch.object( + publish_dashboard, + "dashboard_markdown_path", + return_value=output_path, + ), + ): + path = publish_dashboard.render_dashboard_markdown( + "open-telemetry/example", + False, + ) + markdown = path.read_text(encoding="utf-8") + + self.assertIn("| #7 Assigned draft | human-assignee |", markdown) + self.assertIn("| #8 Unassigned draft | human-committer |", markdown) + self.assertNotIn("| Copilot |", markdown) + gh_api.assert_called_once_with( + "/repos/open-telemetry/example/pulls/8/commits?per_page=1" + ) + + def test_large_repo_skips_draft_author_and_age_fetches(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + output_path = Path(tmp) / "dashboard.md" + with ( + patch.object( + publish_dashboard, + "load_dashboard_state_cache", + return_value=dashboard_state(), + ), + patch.object( + publish_dashboard, + "list_open_prs", + return_value=[{ + "number": 7, + "isDraft": True, + "author": {"login": "Copilot"}, + }], + ), + patch.object(publish_dashboard, "add_draft_authors") as add_authors, + patch.object(publish_dashboard, "add_draft_since") as add_since, + patch.object( + publish_dashboard, + "dashboard_markdown_path", + return_value=output_path, + ), + ): + path = publish_dashboard.render_dashboard_markdown( + "open-telemetry/example", + True, + ) + markdown = path.read_text(encoding="utf-8") + + self.assertNotIn("## Draft pull requests", markdown) + add_authors.assert_not_called() + add_since.assert_not_called() + if __name__ == "__main__": unittest.main() diff --git a/pull-request-dashboard/README.md b/pull-request-dashboard/README.md index 3ec680fdd226..b71faef8aef5 100644 --- a/pull-request-dashboard/README.md +++ b/pull-request-dashboard/README.md @@ -99,7 +99,7 @@ There is no direct-dispatch fallback or return-to-legacy execution mode. The dashboard groups open non-draft pull requests by who is expected to act next (e.g. *Waiting on reviewers*, *Waiting on authors*, *Waiting on maintainers*). Draft PRs are listed separately at the bottom unless `large_repo` rendering is enabled. Within each group, rows are sorted longest-waiting first. Every row has these six columns: - **PR** — Pull request number and title, followed by any configured matching labels. The number autolinks to the PR on GitHub. Configured labels are rendered inline for both active and draft PRs. -- **Author** — Effective author of the PR. For Copilot-authored PRs, this is the human assignee or committer when available. +- **Author** — Effective author of the PR, including in the draft section. For Copilot-authored PRs, this is the human assignee or first-commit committer when available. - **Reviewers** — Reviewers who have engaged with the PR, excluding the effective author even when assigned or requested for review, each annotated with one or more icons: - ✅ active approval - ✔️ active approval (non-code-owner — does **not** count toward `required_approvals`)