Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .github/scripts/pull-request-dashboard/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions .github/scripts/pull-request-dashboard/github_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
18 changes: 18 additions & 0 deletions .github/scripts/pull-request-dashboard/publish_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,16 @@
from github_cli import (
detect_repo,
fetch_latest_draft_transitions,
gh_api,
gh_graphql,
list_open_prs,
normalize_repo,
repo_state_key,
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,
Expand Down Expand Up @@ -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:
Expand All @@ -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)
Expand Down
58 changes: 58 additions & 0 deletions .github/scripts/pull-request-dashboard/pull_request_author.py
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"}


Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
)
Expand Down
2 changes: 1 addition & 1 deletion .github/scripts/pull-request-dashboard/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 "")
)
Expand Down
3 changes: 3 additions & 0 deletions .github/scripts/pull-request-dashboard/test_github_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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,
Expand Down
Loading
Loading