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
16 changes: 11 additions & 5 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"),
"authorAssociation": pull.get("author_association") or "",
"assignees": pull.get("assignees") or [],
"isDraft": pull.get("draft", False),
"createdAt": pull.get("created_at"),
Expand Down Expand Up @@ -826,22 +827,27 @@ def detect_repo() -> str:
return proc.stdout.strip()


def load_reviewer_set(org: str, approver_team_slugs: list[str]) -> set[str]:
def load_team_members(org: str, team_slugs: list[str]) -> set[str]:
token = os.environ.get("PR_DASHBOARD_TOKEN") or None
reviewers: set[str] = set()
for slug in approver_team_slugs:
logins: set[str] = set()
for slug in team_slugs:
members = gh_api(
f"/orgs/{org}/teams/{slug}/members?per_page=100",
paginate=True,
token=token,
)
reviewers.update(m["login"] for m in members)
logins.update(m["login"].lower() for m in members)
return logins


def load_reviewer_set(org: str, approver_team_slugs: list[str]) -> set[str]:
reviewers = load_team_members(org, approver_team_slugs)
if not reviewers:
raise RuntimeError(
f"no reviewers found in teams {approver_team_slugs}; "
f"the dashboard app token must have org members read permission"
)
return {r.lower() for r in reviewers}
return reviewers


REVIEW_THREADS_QUERY = """
Expand Down
47 changes: 38 additions & 9 deletions .github/scripts/pull-request-dashboard/publish_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import argparse
import json
import os
import sys
from collections.abc import Iterable
from pathlib import Path
Expand All @@ -16,6 +17,7 @@
gh_api,
gh_graphql,
list_open_prs,
load_team_members,
normalize_repo,
repo_state_key,
run_gh,
Expand All @@ -41,21 +43,34 @@
LARGE_REPO_MAX_ROWS_PER_SECTION = 100


def parse_labels_to_display_json(raw: str) -> list[str]:
def _parse_string_list_json(raw: str, source: str) -> list[str]:
try:
labels_to_display = json.loads(raw)
values = json.loads(raw)
except json.JSONDecodeError as e:
raise RuntimeError(
f"--labels-to-display-json must be valid JSON: {e.msg} at char {e.pos}"
f"{source} must be valid JSON: {e.msg} at char {e.pos}"
) from e
if not isinstance(labels_to_display, list) or any(
not isinstance(pattern, str) or not pattern.strip()
for pattern in labels_to_display
if not isinstance(values, list) or any(
not isinstance(value, str) or not value.strip()
for value in values
):
raise RuntimeError(
"--labels-to-display-json must be a JSON array of non-blank strings"
f"{source} must be a JSON array of non-blank strings"
)
return labels_to_display
return values


def parse_labels_to_display_json(raw: str) -> list[str]:
return _parse_string_list_json(raw, "--labels-to-display-json")


# The queue processor already passes approver teams to every command through
# the environment, so stable-channel scripts need no new flag.
APPROVER_TEAMS_ENV = "APPROVER_TEAMS_JSON"


def parse_approver_teams_json(raw: str) -> list[str]:
return _parse_string_list_json(raw, APPROVER_TEAMS_ENV)


# GraphQL is used instead of the REST `/repos/{repo}/issues` list endpoint
Expand Down Expand Up @@ -211,11 +226,17 @@ def render_dashboard_markdown(
repo: str,
large_repo: bool,
labels_to_display: list[str] | None = None,
approver_teams: list[str] | None = None,
) -> Path:
dashboard_state = load_dashboard_state_cache()
if dashboard_state is None:
raise RuntimeError("dashboard state not found")

approver_logins = frozenset(
load_team_members(repo.split("/", 1)[0], approver_teams)
if approver_teams
else ()
)
prs = list_open_prs(repo)
if not large_repo:
add_draft_authors(repo, prs)
Expand All @@ -228,6 +249,7 @@ def render_dashboard_markdown(
max_rows_per_section=LARGE_REPO_MAX_ROWS_PER_SECTION if large_repo else None,
skip_drafts=large_repo,
labels_to_display=labels_to_display,
approver_logins=approver_logins,
)
output_path = dashboard_markdown_path()
output_path.parent.mkdir(parents=True, exist_ok=True)
Expand All @@ -250,14 +272,20 @@ def publish_accepted_dashboard(
state_branch_name: str,
large_repo: bool,
labels_to_display: list[str] | None = None,
approver_teams: list[str] | None = None,
) -> None:
with state_branch.accepted_state_dir(state_branch_name, required=True) as state_dir:
if state_dir is None:
raise RuntimeError(f"required state branch not found: {state_branch_name}")
set_state_dir(state_dir / repo_state_key(repo))
publish_dashboard(
repo,
render_dashboard_markdown(repo, large_repo, labels_to_display),
render_dashboard_markdown(
repo,
large_repo,
labels_to_display,
approver_teams,
),
)


Expand Down Expand Up @@ -287,6 +315,7 @@ def main() -> int:
args.state_branch,
args.large_repo,
parse_labels_to_display_json(args.labels_to_display_json),
parse_approver_teams_json(os.environ.get(APPROVER_TEAMS_ENV) or "[]"),
)
return 0

Expand Down
66 changes: 64 additions & 2 deletions .github/scripts/pull-request-dashboard/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@
actor_login,
activity_age,
is_copilot_reviewer_login,
is_unattended_author_login,
markdown_escape,
normalize_author_identity,
parse_ts,
seconds_since,
)
Expand Down Expand Up @@ -60,10 +62,54 @@ def _pr_cell_text(
return f"{pr_cell} · {rendered_labels}"


FIRST_TIME_AUTHOR_ASSOCIATIONS = ("FIRST_TIME_CONTRIBUTOR", "FIRST_TIMER")


def author_badge(
pr: dict[str, Any],
author: str,
approver_logins: frozenset[str] = frozenset(),
) -> str:
pr_author = pr.get("author") or {}
# GitHub's author association and account type describe the PR author, so
# they do not apply when a Copilot PR is shown under its human author.
is_pr_author = (
normalize_author_identity(author)
== normalize_author_identity(actor_login(pr_author))
)
if is_unattended_author_login(author) or (
is_pr_author and str(pr_author.get("type") or "").lower() == "bot"
):
return "🤖"
# Approver team listings include their child maintainer teams. Approvers are
# also organization members; show only the more specific badge.
if author.lower() in approver_logins:
return "🛠️"
if not is_pr_author:
return ""
association = pr.get("authorAssociation")
if association == "MEMBER":
return "🔭"
if association in FIRST_TIME_AUTHOR_ASSOCIATIONS:
return "🆕"
return ""


def author_cell_text(
pr: dict[str, Any],
author: str,
approver_logins: frozenset[str] = frozenset(),
) -> str:
badge = author_badge(pr, author, approver_logins) if author else ""
# Join login and badge with a non-breaking space so they never wrap apart.
return f"{author} {badge}" if badge else author


def render_draft_pr_section(
prs: list[dict[str, Any]],
max_rows_per_section: int | None = None,
labels_to_display: list[str] | None = None,
approver_logins: frozenset[str] = frozenset(),
) -> list[str]:
drafts = [p for p in prs if p.get("isDraft")]
if not drafts:
Expand All @@ -74,7 +120,11 @@ def render_draft_pr_section(
lines.append("| PR | Author | Draft age |")
lines.append("|---|---|:---:|")
for pr in drafts:
author = pr.get("effectiveAuthor") or actor_login(pr.get("author") or {})
author = author_cell_text(
pr,
pr.get("effectiveAuthor") or actor_login(pr.get("author") or {}),
approver_logins,
)
draft_age = activity_age(
parse_ts(pr.get("draftSince") or pr.get("createdAt") or "")
)
Expand Down Expand Up @@ -228,6 +278,7 @@ def render_pr_tables(
max_rows_per_section: int | None = None,
skip_drafts: bool = False,
labels_to_display: list[str] | None = None,
approver_logins: frozenset[str] = frozenset(),
) -> str:
results_by_number = {
result.pr_number: result
Expand All @@ -243,6 +294,10 @@ def render_pr_tables(
f"Open non-draft PRs grouped by who is expected to act next. {draft_note} The grouping is "
f"partly performed by an LLM ([source]({source_url})) and could contain mistakes."
)
author_note = (
"Author column: 🆕 first-time contributor · 🔭 open-telemetry member · "
"🛠️ approver or maintainer · 🤖 bot."
)
reviewers_note = (
"Reviewers column: ✅ active approval · ✔️ active approval (non-code-owner) · "
"⏳ review pending · 💬 unresolved feedback from reviewer · "
Expand All @@ -257,6 +312,8 @@ def render_pr_tables(
"> [!NOTE]",
f"> {grouping_note}",
">",
f"> {author_note}",
">",
f"> {reviewers_note}",
">",
f"> {ci_note}",
Expand Down Expand Up @@ -297,7 +354,11 @@ def row_sort_key(pr: dict[str, Any]) -> tuple[int, int]:
number = pr["number"]
res = results_by_number.get(number)
facts = res.facts if res is not None else DashboardFacts()
author = facts.author or actor_login(pr.get("author") or {})
author = author_cell_text(
pr,
facts.author or actor_login(pr.get("author") or {}),
approver_logins,
)
reviewers_cell = reviewers_cell_text(facts)
activity_cell = age_cell(facts)
# GitHub autolinks same-repo PR numbers; avoid full URLs so large
Expand All @@ -317,5 +378,6 @@ def row_sort_key(pr: dict[str, Any]) -> tuple[int, int]:
prs,
max_rows_per_section,
labels_to_display,
approver_logins,
))
return "\n".join(out) + "\n"
38 changes: 38 additions & 0 deletions .github/scripts/pull-request-dashboard/test_github_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
include_missing_required_checks,
is_retryable_gh_error,
list_open_prs,
load_reviewer_set,
load_team_members,
merge_code_scanning_checks,
normalize_check,
request_copilot_review,
Expand Down Expand Up @@ -571,6 +573,7 @@ def test_list_open_prs_uses_paginated_rest_api(self, gh_api) -> None:
"number": number,
"title": f"PR {number}",
"user": {"login": "author"},
"author_association": "MEMBER" if number == 501 else None,
"assignees": [{"login": "human-author"}] if number == 501 else None,
"draft": number == 501,
"created_at": "2026-07-01T00:00:00Z",
Expand All @@ -595,6 +598,7 @@ def test_list_open_prs_uses_paginated_rest_api(self, gh_api) -> None:
"number": 501,
"title": "PR 501",
"author": {"login": "author"},
"authorAssociation": "MEMBER",
"assignees": [{"login": "human-author"}],
"isDraft": True,
"createdAt": "2026-07-01T00:00:00Z",
Expand All @@ -605,11 +609,45 @@ def test_list_open_prs_uses_paginated_rest_api(self, gh_api) -> None:
)
self.assertEqual([], prs[0]["labels"])
self.assertEqual([], prs[0]["assignees"])
self.assertEqual("", prs[0]["authorAssociation"])
gh_api.assert_called_once_with(
"/repos/open-telemetry/example/pulls?state=open&per_page=100",
paginate=True,
)

@patch.dict("os.environ", {"PR_DASHBOARD_TOKEN": "token"})
@patch("github_cli.gh_api")
def test_loads_lowercased_members_of_every_team(self, gh_api) -> None:
gh_api.side_effect = [
[{"login": "Maintainer"}, {"login": "shared"}],
[{"login": "Shared"}, {"login": "other"}],
]

self.assertEqual(
{"maintainer", "shared", "other"},
load_team_members("open-telemetry", ["first", "second"]),
)
self.assertEqual(
[
call(
"/orgs/open-telemetry/teams/first/members?per_page=100",
paginate=True,
token="token",
),
call(
"/orgs/open-telemetry/teams/second/members?per_page=100",
paginate=True,
token="token",
),
],
gh_api.call_args_list,
)

@patch("github_cli.gh_api", return_value=[])
def test_empty_approver_teams_are_rejected(self, _gh_api) -> None:
with self.assertRaisesRegex(RuntimeError, "no reviewers found"):
load_reviewer_set("open-telemetry", ["approvers"])

@patch("github_cli.gh_graphql")
def test_fetches_latest_draft_transitions_in_one_query(self, graphql) -> None:
graphql.return_value = {
Expand Down
Loading
Loading