From 2a5db762b0a1e3e3ca824ff3b246433ec2306dda Mon Sep 17 00:00:00 2001 From: martincostello Date: Thu, 22 Oct 2026 16:19:29 +0100 Subject: [PATCH 1/3] feat: add author badges in PR dashboard Update the PR dashboard to add an emoji badge to authors to show their "role" relative to the repository. Badges are added for first-time contributors, open-telemetry org members, bots and maintainers. --- .../pull-request-dashboard/WEBHOOK_SETUP.md | 2 +- .../pull-request-dashboard/github_cli.py | 16 +++- .../process_queue_batch.py | 1 + .../publish_dashboard.py | 47 +++++++-- .../scripts/pull-request-dashboard/render.py | 65 ++++++++++++- .../pull-request-dashboard/repositories.json | 59 +++++++++--- .../pull-request-dashboard/test_github_cli.py | 38 ++++++++ .../test_process_queue_batch.py | 44 +++++++++ .../test_publish_dashboard.py | 96 ++++++++++++++++++- .../pull-request-dashboard/test_render.py | 67 +++++++++++++ pull-request-dashboard/README.md | 9 +- 11 files changed, 410 insertions(+), 34 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/WEBHOOK_SETUP.md b/.github/scripts/pull-request-dashboard/WEBHOOK_SETUP.md index d58bea133a6a..c6575cfdacad 100644 --- a/.github/scripts/pull-request-dashboard/WEBHOOK_SETUP.md +++ b/.github/scripts/pull-request-dashboard/WEBHOOK_SETUP.md @@ -156,7 +156,7 @@ Permission rationale: | Issues | Read and write | Finds, creates, and updates the dashboard issue. | | Metadata | Read | Required by GitHub for GitHub App repository access. | | Pull requests | Read and write | Required to subscribe to PR review/comment/thread events; read PR details, reviews, review comments, commits, and GraphQL review threads; and create the dashboard-managed PR status comment, which is a pull request conversation comment. | -| Members | Read | Reads approver-team membership configured in `repositories.json`. | +| Members | Read | Reads approver-team and maintainer-team membership configured in `repositories.json`. | The dashboard does not create inline review comments, submit reviews, or resolve review threads. It manages one PR conversation comment (create, update, and diff --git a/.github/scripts/pull-request-dashboard/github_cli.py b/.github/scripts/pull-request-dashboard/github_cli.py index b32854b0871d..e5208e770b18 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"), + "authorAssociation": pull.get("author_association") or "", "assignees": pull.get("assignees") or [], "isDraft": pull.get("draft", False), "createdAt": pull.get("created_at"), @@ -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 = """ diff --git a/.github/scripts/pull-request-dashboard/process_queue_batch.py b/.github/scripts/pull-request-dashboard/process_queue_batch.py index f644489156c3..5fe2714368ff 100644 --- a/.github/scripts/pull-request-dashboard/process_queue_batch.py +++ b/.github/scripts/pull-request-dashboard/process_queue_batch.py @@ -415,6 +415,7 @@ def process_repository( "REPO_NAME": repository, "REQUIRED_APPROVALS": str(config.get("required_approvals", 1)), "APPROVER_TEAMS_JSON": json.dumps(config.get("approver_teams", [])), + "MAINTAINER_TEAMS_JSON": json.dumps(config.get("maintainer_teams", [])), "NON_BLOCKING_CHECK_PATTERNS_JSON": json.dumps( config.get("non_blocking_check_patterns", []) ), diff --git a/.github/scripts/pull-request-dashboard/publish_dashboard.py b/.github/scripts/pull-request-dashboard/publish_dashboard.py index 62347eac5a8c..07982c311ba6 100644 --- a/.github/scripts/pull-request-dashboard/publish_dashboard.py +++ b/.github/scripts/pull-request-dashboard/publish_dashboard.py @@ -5,6 +5,7 @@ import argparse import json +import os import sys from collections.abc import Iterable from pathlib import Path @@ -16,6 +17,7 @@ gh_api, gh_graphql, list_open_prs, + load_team_members, normalize_repo, repo_state_key, run_gh, @@ -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 passes maintainer teams through the environment rather +# than a flag so that stable-channel scripts without this option still run. +MAINTAINER_TEAMS_ENV = "MAINTAINER_TEAMS_JSON" + + +def parse_maintainer_teams_json(raw: str) -> list[str]: + return _parse_string_list_json(raw, MAINTAINER_TEAMS_ENV) # GraphQL is used instead of the REST `/repos/{repo}/issues` list endpoint @@ -211,11 +226,17 @@ def render_dashboard_markdown( repo: str, large_repo: bool, labels_to_display: list[str] | None = None, + maintainer_teams: list[str] | None = None, ) -> Path: dashboard_state = load_dashboard_state_cache() if dashboard_state is None: raise RuntimeError("dashboard state not found") + maintainer_logins = frozenset( + load_team_members(repo.split("/", 1)[0], maintainer_teams) + if maintainer_teams + else () + ) prs = list_open_prs(repo) if not large_repo: add_draft_authors(repo, prs) @@ -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, + maintainer_logins=maintainer_logins, ) output_path = dashboard_markdown_path() output_path.parent.mkdir(parents=True, exist_ok=True) @@ -250,6 +272,7 @@ def publish_accepted_dashboard( state_branch_name: str, large_repo: bool, labels_to_display: list[str] | None = None, + maintainer_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: @@ -257,7 +280,12 @@ def publish_accepted_dashboard( 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, + maintainer_teams, + ), ) @@ -287,6 +315,7 @@ def main() -> int: args.state_branch, args.large_repo, parse_labels_to_display_json(args.labels_to_display_json), + parse_maintainer_teams_json(os.environ.get(MAINTAINER_TEAMS_ENV) or "[]"), ) return 0 diff --git a/.github/scripts/pull-request-dashboard/render.py b/.github/scripts/pull-request-dashboard/render.py index 9f66f860a5f5..968ccf71087e 100644 --- a/.github/scripts/pull-request-dashboard/render.py +++ b/.github/scripts/pull-request-dashboard/render.py @@ -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, ) @@ -60,10 +62,53 @@ 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, + maintainer_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 "๐Ÿค–" + # Maintainers are also organization members; show only the more specific badge. + if author.lower() in maintainer_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, + maintainer_logins: frozenset[str] = frozenset(), +) -> str: + badge = author_badge(pr, author, maintainer_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, + maintainer_logins: frozenset[str] = frozenset(), ) -> list[str]: drafts = [p for p in prs if p.get("isDraft")] if not drafts: @@ -74,7 +119,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 {}), + maintainer_logins, + ) draft_age = activity_age( parse_ts(pr.get("draftSince") or pr.get("createdAt") or "") ) @@ -228,6 +277,7 @@ def render_pr_tables( max_rows_per_section: int | None = None, skip_drafts: bool = False, labels_to_display: list[str] | None = None, + maintainer_logins: frozenset[str] = frozenset(), ) -> str: results_by_number = { result.pr_number: result @@ -243,6 +293,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 ยท " + "๐Ÿ› ๏ธ maintainer ยท ๐Ÿค– bot." + ) reviewers_note = ( "Reviewers column: โœ… active approval ยท โœ”๏ธ active approval (non-code-owner) ยท " "โณ review pending ยท ๐Ÿ’ฌ unresolved feedback from reviewer ยท " @@ -257,6 +311,8 @@ def render_pr_tables( "> [!NOTE]", f"> {grouping_note}", ">", + f"> {author_note}", + ">", f"> {reviewers_note}", ">", f"> {ci_note}", @@ -297,7 +353,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 {}), + maintainer_logins, + ) reviewers_cell = reviewers_cell_text(facts) activity_cell = age_cell(facts) # GitHub autolinks same-repo PR numbers; avoid full URLs so large @@ -317,5 +377,6 @@ def row_sort_key(pr: dict[str, Any]) -> tuple[int, int]: prs, max_rows_per_section, labels_to_display, + maintainer_logins, )) return "\n".join(out) + "\n" diff --git a/.github/scripts/pull-request-dashboard/repositories.json b/.github/scripts/pull-request-dashboard/repositories.json index 188834105d76..53000a6b8347 100644 --- a/.github/scripts/pull-request-dashboard/repositories.json +++ b/.github/scripts/pull-request-dashboard/repositories.json @@ -2,29 +2,35 @@ { "name": "opentelemetry-collector-contrib", "approver_teams": ["collector-contrib-approvers"], + "maintainer_teams": ["collector-contrib-maintainers"], "large_repo": true }, { "name": "opentelemetry-dotnet", - "approver_teams": ["dotnet-approvers"] + "approver_teams": ["dotnet-approvers"], + "maintainer_teams": ["dotnet-maintainers"] }, { "name": "opentelemetry-dotnet-contrib", - "approver_teams": ["dotnet-contrib-approvers"] + "approver_teams": ["dotnet-contrib-approvers"], + "maintainer_teams": ["dotnet-contrib-maintainers"] }, { "name": "opentelemetry-go", "approver_teams": ["go-approvers"], + "maintainer_teams": ["go-maintainers"], "require_clean_copilot_review_branches": ["main"] }, { "name": "opentelemetry-go-contrib", "approver_teams": ["go-approvers"], + "maintainer_teams": ["go-maintainers"], "require_clean_copilot_review_branches": ["main"] }, { "name": "opentelemetry-java", "approver_teams": ["java-approvers"], + "maintainer_teams": ["java-maintainers"], "non_blocking_check_patterns": [ "markdown-link-check / link-check", "codecov/*" @@ -33,90 +39,109 @@ { "name": "opentelemetry-java-instrumentation", "approver_teams": ["java-instrumentation-approvers"], + "maintainer_teams": ["java-instrumentation-maintainers"], "require_clean_copilot_review_branches": ["main"] }, { "name": "opentelemetry-js", - "approver_teams": ["javascript-approvers"] + "approver_teams": ["javascript-approvers"], + "maintainer_teams": ["javascript-maintainers"] }, { "name": "opentelemetry-js-contrib", - "approver_teams": ["javascript-approvers"] + "approver_teams": ["javascript-approvers"], + "maintainer_teams": ["javascript-maintainers"] }, { "name": "opentelemetry-proto", "approver_teams": ["proto-approvers"], + "maintainer_teams": ["proto-maintainers"], "required_approvals": 2 }, { "name": "opentelemetry-python-genai", - "approver_teams": ["python-genai-approvers"] + "approver_teams": ["python-genai-approvers"], + "maintainer_teams": ["python-genai-maintainers"] }, { "name": "opentelemetry-python", - "approver_teams": ["python-approvers"] + "approver_teams": ["python-approvers"], + "maintainer_teams": ["python-maintainers"] }, { "name": "opentelemetry-python-contrib", - "approver_teams": ["opentelemetry-python-contrib-approvers"] + "approver_teams": ["opentelemetry-python-contrib-approvers"], + "maintainer_teams": ["opentelemetry-python-contrib-maintainers"] }, { "name": "opentelemetry-specification", "approver_teams": ["specs-approvers"], + "maintainer_teams": ["specs-maintainers"], "required_approvals": 2 }, { "name": "opentelemetry-weaver-examples", "approver_teams": [ "weaver-approvers" - ] + ], + "maintainer_teams": ["weaver-maintainers"] }, { "name": "opentelemetry-weaver-packages", "approver_teams": [ "weaver-package-approvers" - ] + ], + "maintainer_teams": ["weaver-package-maintainers"] }, { "name": "opentelemetry.io", - "approver_teams": ["docs-approvers"] + "approver_teams": ["docs-approvers"], + "maintainer_teams": ["docs-maintainers"] }, { "name": "semantic-conventions", "approver_teams": ["specs-semconv-approvers"], + "maintainer_teams": ["specs-semconv-maintainers"], "required_approvals": 2 }, { "name": "semantic-conventions-conformance", - "approver_teams": ["semconv-conformance-approvers"] + "approver_teams": ["semconv-conformance-approvers"], + "maintainer_teams": ["semconv-conformance-maintainers"] }, { "name": "semantic-conventions-genai", "approver_teams": ["semconv-genai-approvers"], + "maintainer_teams": ["semconv-genai-maintainers"], "required_approvals": 2 }, { "name": "shared-workflows", - "approver_teams": ["shared-workflows-approvers"] + "approver_teams": ["shared-workflows-approvers"], + "maintainer_teams": ["shared-workflows-maintainers"] }, { "name": "weaver", "approver_teams": [ "weaver-approvers" - ] + ], + "maintainer_teams": ["weaver-maintainers"] }, { "name": "otel-arrow", - "approver_teams": ["arrow-approvers"] + "approver_teams": ["arrow-approvers"], + "maintainer_teams": ["arrow-maintainers"] }, { "name": "opentelemetry-ebpf-profiler", "approver_teams": ["ebpf-profiler-approvers"], + "maintainer_teams": ["ebpf-profiler-maintainers"], "required_approvals": 2 }, { "name": "opentelemetry-go-compile-instrumentation", "approver_teams": ["go-compile-instrumentation-approvers"], + "maintainer_teams": ["go-compile-instrumentation-maintainers"], "labels_to_display": ["scope:*", "effort:*", "dependencies", "AI/ML *"], "non_blocking_check_patterns": [ "codecov/*", @@ -129,6 +154,7 @@ { "name": "opentelemetry-ebpf-instrumentation", "approver_teams": ["ebpf-instrumentation-approvers"], + "maintainer_teams": ["ebpf-instrumentation-maintainers"], "labels_to_display": ["breaking change", "dependencies", "do-not-merge", "blocked"], "non_blocking_check_patterns": ["codecov/*"], "require_clean_copilot_review_branches": ["main"] @@ -136,12 +162,14 @@ { "name": "opentelemetry-ecosystem-explorer", "approver_teams": ["ecosystem-explorer-approvers"], + "maintainer_teams": ["ecosystem-explorer-maintainers"], "non_blocking_check_patterns": ["codecov/*"], "require_clean_copilot_review_branches": ["main"] }, { "name": "opentelemetry-rust", "approver_teams": ["rust-approvers"], + "maintainer_teams": ["rust-maintainers"], "required_approvals": 1, "labels_to_display": [ "A-*", @@ -155,6 +183,7 @@ }, { "name": "opentelemetry-browser", - "approver_teams": ["browser-approvers"] + "approver_teams": ["browser-approvers"], + "maintainer_teams": ["browser-maintainers"] } ] diff --git a/.github/scripts/pull-request-dashboard/test_github_cli.py b/.github/scripts/pull-request-dashboard/test_github_cli.py index 09445b10dab5..95e62b97c28c 100644 --- a/.github/scripts/pull-request-dashboard/test_github_cli.py +++ b/.github/scripts/pull-request-dashboard/test_github_cli.py @@ -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, @@ -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", @@ -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", @@ -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 = { diff --git a/.github/scripts/pull-request-dashboard/test_process_queue_batch.py b/.github/scripts/pull-request-dashboard/test_process_queue_batch.py index dcb502eab9f6..12b4f65de271 100644 --- a/.github/scripts/pull-request-dashboard/test_process_queue_batch.py +++ b/.github/scripts/pull-request-dashboard/test_process_queue_batch.py @@ -102,6 +102,50 @@ def run(command: list[str], _lease_check, **_kwargs): self.assertLess(lifecycle.index("publish_dashboard.py"), lifecycle.index("release-delivery")) self.assertLess(lifecycle.index("release-delivery"), lifecycle.index("acknowledge")) + def test_publish_receives_maintainer_teams_through_the_environment(self) -> None: + with tempfile.TemporaryDirectory() as directory: + publishes = [] + + class Client: + def call(self, action: str, **_payload: object) -> dict[str, object]: + if action == "heartbeat": + return {"dispatcher": True, "leaseDurationMs": 900_000} + if action == "acquire-delivery": + return {"acquired": True, "leaseGeneration": 7} + if action == "release-delivery": + return {"released": True} + return {"status": "removed"} + + def run(command: list[str], _lease_check, **kwargs): + script = Path(command[1]).name + if script == "publish_dashboard.py": + publishes.append((command, kwargs["env"])) + if script == "delivery.py": + output = Path(command[command.index("--github-output") + 1]) + output.write_text("active=true\nfull_publish_generation=0\n", encoding="utf-8") + return subprocess.CompletedProcess( + command, 0, stdout="true\n" if script == "state.py" else "", stderr="" + ) + + config = Path(directory) / "repositories.json" + config.write_text( + '[{"name":"example","maintainer_teams":["example-maintainers"]}]', + encoding="utf-8", + ) + with mock.patch.object(process_queue_batch, "run_monitored", side_effect=run): + process_claims( + [claim("example#pr:1", "example", pr_number=1)], + Path(directory) / "results.json", Client(), 1, "live", + config_path=config, + ) + + self.assertEqual(1, len(publishes)) + command, env = publishes[0] + # Stable-channel publish scripts reject unknown flags, so the teams + # must not be passed on the command line. + self.assertFalse(any("maintainer" in argument for argument in command)) + self.assertEqual('["example-maintainers"]', env["MAINTAINER_TEAMS_JSON"]) + def test_delivery_lease_waits_for_ownership_and_releases_its_generation(self) -> None: client = mock.Mock() client.call.side_effect = [ diff --git a/.github/scripts/pull-request-dashboard/test_publish_dashboard.py b/.github/scripts/pull-request-dashboard/test_publish_dashboard.py index dbd2f03bd19f..712976675ea1 100644 --- a/.github/scripts/pull-request-dashboard/test_publish_dashboard.py +++ b/.github/scripts/pull-request-dashboard/test_publish_dashboard.py @@ -1,7 +1,9 @@ from __future__ import annotations from contextlib import nullcontext +import os from pathlib import Path +import sys import tempfile import unittest from unittest.mock import patch @@ -217,6 +219,7 @@ def test_publishes_from_read_only_accepted_state( "state-branch", True, ["size/*"], + ["example-maintainers"], ) accepted_state_dir.assert_called_once_with("state-branch", required=True) @@ -225,6 +228,7 @@ def test_publishes_from_read_only_accepted_state( "open-telemetry/example", True, ["size/*"], + ["example-maintainers"], ) publish.assert_called_once_with("open-telemetry/example", Path("dashboard.md")) @@ -257,7 +261,62 @@ def test_worker_advancement_does_not_retry_a_pinned_snapshot( [Path("old.md")], ) - def test_passes_labels_to_renderer(self) -> None: + def test_parses_maintainer_teams_json(self) -> None: + self.assertEqual( + ["example-maintainers"], + publish_dashboard.parse_maintainer_teams_json('["example-maintainers"]'), + ) + for raw in ("{", '"example-maintainers"', '[""]'): + with self.subTest(raw=raw): + with self.assertRaisesRegex(RuntimeError, "MAINTAINER_TEAMS_JSON"): + publish_dashboard.parse_maintainer_teams_json(raw) + + @patch.object(publish_dashboard, "publish_accepted_dashboard") + def test_reads_maintainer_teams_from_the_environment( + self, + publish_accepted: object, + ) -> None: + with ( + patch.object( + sys, + "argv", + ["publish_dashboard.py", "--repo", "example", "--state-branch", "state"], + ), + patch.dict( + os.environ, + {"MAINTAINER_TEAMS_JSON": '["example-maintainers"]'}, + ), + ): + self.assertEqual(0, publish_dashboard.main()) + + publish_accepted.assert_called_once_with( + "open-telemetry/example", + "state", + False, + [], + ["example-maintainers"], + ) + + @patch.object(publish_dashboard, "publish_accepted_dashboard") + def test_defaults_to_no_maintainer_teams(self, publish_accepted: object) -> None: + environment = { + key: value + for key, value in os.environ.items() + if key != "MAINTAINER_TEAMS_JSON" + } + with ( + patch.object( + sys, + "argv", + ["publish_dashboard.py", "--repo", "example", "--state-branch", "state"], + ), + patch.dict(os.environ, environment, clear=True), + ): + self.assertEqual(0, publish_dashboard.main()) + + self.assertEqual([], publish_accepted.call_args.args[4]) + + def test_passes_labels_and_maintainers_to_renderer(self) -> None: prs = [ { "number": 1, @@ -290,6 +349,11 @@ def test_passes_labels_to_renderer(self) -> None: "dashboard_markdown_path", return_value=output_path, ), + patch.object( + publish_dashboard, + "load_team_members", + return_value={"maintainer"}, + ) as load_team_members, ): self.assertEqual( output_path, @@ -297,17 +361,47 @@ def test_passes_labels_to_renderer(self) -> None: "open-telemetry/example", False, ["size/*"], + ["example-maintainers"], ), ) + load_team_members.assert_called_once_with( + "open-telemetry", + ["example-maintainers"], + ) render_pr_tables.assert_called_once_with( prs, results, max_rows_per_section=None, skip_drafts=False, labels_to_display=["size/*"], + maintainer_logins=frozenset({"maintainer"}), ) + def test_skips_maintainer_lookup_without_maintainer_teams(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=[]), + patch.object( + publish_dashboard, + "dashboard_markdown_path", + return_value=output_path, + ), + patch.object(publish_dashboard, "load_team_members") as load_team_members, + ): + publish_dashboard.render_dashboard_markdown( + "open-telemetry/example", + False, + ) + + load_team_members.assert_not_called() + def test_renders_effective_draft_authors_from_the_open_pull_listing(self) -> None: pulls = [ { diff --git a/.github/scripts/pull-request-dashboard/test_render.py b/.github/scripts/pull-request-dashboard/test_render.py index d2abd75d6eed..eae635c14026 100644 --- a/.github/scripts/pull-request-dashboard/test_render.py +++ b/.github/scripts/pull-request-dashboard/test_render.py @@ -8,6 +8,7 @@ stored_dashboard_result, ) from render import ( + author_cell_text, ci_cell, render_draft_pr_section, render_pr_tables, @@ -353,6 +354,72 @@ def test_renders_and_sorts_time_in_draft(self, activity_age) -> None: self.assertIn("2026-07-17T00:00:00+00:00", markdown) self.assertEqual(2, activity_age.call_count) + def test_author_legend_explains_badges(self) -> None: + markdown = render_pr_tables([], ()) + + self.assertIn( + "Author column: ๐Ÿ†• first-time contributor ยท ๐Ÿ”ญ open-telemetry member ยท " + "๐Ÿ› ๏ธ maintainer ยท ๐Ÿค– bot.", + markdown, + ) + + def test_author_badges(self) -> None: + maintainers = frozenset({"maintainer"}) + cases = [ + ("first-time contributor", {"login": "new"}, "FIRST_TIME_CONTRIBUTOR", "new", "new ๐Ÿ†•"), + ("first-time GitHub user", {"login": "new"}, "FIRST_TIMER", "new", "new ๐Ÿ†•"), + ("organization member", {"login": "member"}, "MEMBER", "member", "member ๐Ÿ”ญ"), + ("maintainer", {"login": "Maintainer"}, "MEMBER", "Maintainer", "Maintainer ๐Ÿ› ๏ธ"), + ("bot account", {"login": "renovate[bot]", "type": "Bot"}, "CONTRIBUTOR", "renovate[bot]", "renovate[bot] ๐Ÿค–"), + ("bot app login", {"login": "renovate[bot]", "type": "Bot"}, "NONE", "app/renovate", "app/renovate ๐Ÿค–"), + ("bot user account", {"login": "opentelemetrybot"}, "MEMBER", "opentelemetrybot", "opentelemetrybot ๐Ÿค–"), + ("Copilot without a human", {"login": "Copilot", "type": "Bot"}, "NONE", "Copilot", "Copilot ๐Ÿค–"), + ("returning contributor", {"login": "contributor"}, "CONTRIBUTOR", "contributor", "contributor"), + ("no association", {"login": "outsider"}, None, "outsider", "outsider"), + ("unknown author", {}, None, "", ""), + ] + for name, pr_author, association, author, expected in cases: + with self.subTest(name): + pr = {"author": pr_author, "authorAssociation": association} + self.assertEqual(expected, author_cell_text(pr, author, maintainers)) + + def test_copilot_pr_human_author_does_not_inherit_copilot_association(self) -> None: + pr = { + "author": {"login": "Copilot", "type": "Bot"}, + "authorAssociation": "FIRST_TIME_CONTRIBUTOR", + } + + self.assertEqual("human", author_cell_text(pr, "human")) + self.assertEqual( + "maintainer ๐Ÿ› ๏ธ", + author_cell_text(pr, "maintainer", frozenset({"maintainer"})), + ) + + def test_renders_author_badges_in_route_and_draft_tables(self) -> None: + markdown = render_pr_tables( + [ + { + "number": 127, + "title": "Feature", + "author": {"login": "member"}, + "authorAssociation": "MEMBER", + "isDraft": False, + }, + { + "number": 128, + "title": "Work in progress", + "author": {"login": "maintainer"}, + "authorAssociation": "MEMBER", + "isDraft": True, + }, + ], + (stored_dashboard_result(127),), + maintainer_logins=frozenset({"maintainer"}), + ) + + self.assertIn("| #127 Feature | member ๐Ÿ”ญ |", markdown) + self.assertIn("| #128 Work in progress | maintainer ๐Ÿ› ๏ธ |", markdown) + def test_omits_labels_when_none_are_configured(self) -> None: prs = [ { diff --git a/pull-request-dashboard/README.md b/pull-request-dashboard/README.md index 27f7c3c4bd55..3a9a0da1cc4f 100644 --- a/pull-request-dashboard/README.md +++ b/pull-request-dashboard/README.md @@ -99,7 +99,12 @@ 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, including in the draft section. For Copilot-authored PRs, this is the human assignee or first-commit 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. The login is followed by at most one badge, checked in this order: + - ๐Ÿค– bot account + - ๐Ÿ› ๏ธ member of one of the repository's `maintainer_teams` + - ๐Ÿ”ญ other member of the `open-telemetry` organization + - ๐Ÿ†• first-time contributor to the repository + - Organization membership and first-time status come from GitHub's author association for the PR, so they are not shown when a Copilot-authored PR is attributed to its human assignee or committer. - **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`) @@ -143,6 +148,7 @@ Open a pull request that adds your repository to [`.github/scripts/pull-request- { "name": "example-repo", "approver_teams": ["example-approvers"], + "maintainer_teams": ["example-maintainers"], "required_approvals": 1, "non_blocking_check_patterns": [ "markdown-link-check / link-check", @@ -164,6 +170,7 @@ Fields: | ----- | -------- | ----------- | | `name` | yes | Name of the repository under `open-telemetry`. | | `approver_teams` | yes | GitHub team slugs whose members count as approvers. | +| `maintainer_teams` | no | GitHub team slugs whose members are shown with the ๐Ÿ› ๏ธ maintainer badge in the dashboard's Author column. Display only; it does not affect routing. Defaults to `[]`, which shows no maintainer badges. | | `required_approvals` | no | Number of active approvals required for an open PR to be marked ready to merge. An approval stops counting while that reviewer has a pending re-review request. 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. | From d8bcaf1c784cd3c15b666e9437942087a6ed4c37 Mon Sep 17 00:00:00 2001 From: martincostello Date: Fri, 23 Oct 2026 10:25:28 +0100 Subject: [PATCH 2/3] chore: address feedback Address review feedback and extend to add approvers and triagers. --- .../pull-request-dashboard/WEBHOOK_SETUP.md | 2 +- .../process_queue_batch.py | 1 + .../publish_dashboard.py | 57 ++++++++---- .../scripts/pull-request-dashboard/render.py | 48 +++++++--- .../pull-request-dashboard/repositories.json | 29 ++++-- .../test_process_queue_batch.py | 10 ++- .../test_publish_dashboard.py | 90 +++++++++++-------- .../pull-request-dashboard/test_render.py | 23 +++-- pull-request-dashboard/README.md | 6 +- 9 files changed, 180 insertions(+), 86 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/WEBHOOK_SETUP.md b/.github/scripts/pull-request-dashboard/WEBHOOK_SETUP.md index c6575cfdacad..cbcfef0a67d7 100644 --- a/.github/scripts/pull-request-dashboard/WEBHOOK_SETUP.md +++ b/.github/scripts/pull-request-dashboard/WEBHOOK_SETUP.md @@ -156,7 +156,7 @@ Permission rationale: | Issues | Read and write | Finds, creates, and updates the dashboard issue. | | Metadata | Read | Required by GitHub for GitHub App repository access. | | Pull requests | Read and write | Required to subscribe to PR review/comment/thread events; read PR details, reviews, review comments, commits, and GraphQL review threads; and create the dashboard-managed PR status comment, which is a pull request conversation comment. | -| Members | Read | Reads approver-team and maintainer-team membership configured in `repositories.json`. | +| Members | Read | Reads approver-, maintainer-, and triager-team membership configured in `repositories.json`. | The dashboard does not create inline review comments, submit reviews, or resolve review threads. It manages one PR conversation comment (create, update, and diff --git a/.github/scripts/pull-request-dashboard/process_queue_batch.py b/.github/scripts/pull-request-dashboard/process_queue_batch.py index 5fe2714368ff..1d311c96db77 100644 --- a/.github/scripts/pull-request-dashboard/process_queue_batch.py +++ b/.github/scripts/pull-request-dashboard/process_queue_batch.py @@ -416,6 +416,7 @@ def process_repository( "REQUIRED_APPROVALS": str(config.get("required_approvals", 1)), "APPROVER_TEAMS_JSON": json.dumps(config.get("approver_teams", [])), "MAINTAINER_TEAMS_JSON": json.dumps(config.get("maintainer_teams", [])), + "TRIAGER_TEAMS_JSON": json.dumps(config.get("triager_teams", [])), "NON_BLOCKING_CHECK_PATTERNS_JSON": json.dumps( config.get("non_blocking_check_patterns", []) ), diff --git a/.github/scripts/pull-request-dashboard/publish_dashboard.py b/.github/scripts/pull-request-dashboard/publish_dashboard.py index 07982c311ba6..77e71c83d902 100644 --- a/.github/scripts/pull-request-dashboard/publish_dashboard.py +++ b/.github/scripts/pull-request-dashboard/publish_dashboard.py @@ -7,7 +7,8 @@ import json import os import sys -from collections.abc import Iterable +from collections.abc import Iterable, Mapping +from dataclasses import dataclass from pathlib import Path from typing import Any @@ -25,7 +26,7 @@ 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 render import AuthorTeams, render_pr_tables from state import ( dashboard_markdown_path, load_dashboard_state_cache, @@ -64,13 +65,39 @@ def parse_labels_to_display_json(raw: str) -> list[str]: return _parse_string_list_json(raw, "--labels-to-display-json") -# The queue processor passes maintainer teams through the environment rather -# than a flag so that stable-channel scripts without this option still run. -MAINTAINER_TEAMS_ENV = "MAINTAINER_TEAMS_JSON" +@dataclass(frozen=True) +class AuthorTeamSlugs: + maintainers: tuple[str, ...] = () + approvers: tuple[str, ...] = () + triagers: tuple[str, ...] = () + # The queue processor passes teams through the environment rather than + # flags so that stable-channel scripts without these options still run. + @classmethod + def from_environment(cls, environ: Mapping[str, str]) -> AuthorTeamSlugs: + def slugs(name: str) -> tuple[str, ...]: + return tuple(_parse_string_list_json(environ.get(name) or "[]", name)) + + return cls( + maintainers=slugs("MAINTAINER_TEAMS_JSON"), + approvers=slugs("APPROVER_TEAMS_JSON"), + triagers=slugs("TRIAGER_TEAMS_JSON"), + ) -def parse_maintainer_teams_json(raw: str) -> list[str]: - return _parse_string_list_json(raw, MAINTAINER_TEAMS_ENV) + +def load_author_teams(repo: str, slugs: AuthorTeamSlugs) -> AuthorTeams: + org = repo.split("/", 1)[0] + + def members(team_slugs: tuple[str, ...]) -> frozenset[str]: + if not team_slugs: + return frozenset() + return frozenset(load_team_members(org, list(team_slugs))) + + return AuthorTeams( + maintainers=members(slugs.maintainers), + approvers=members(slugs.approvers), + triagers=members(slugs.triagers), + ) # GraphQL is used instead of the REST `/repos/{repo}/issues` list endpoint @@ -226,17 +253,13 @@ def render_dashboard_markdown( repo: str, large_repo: bool, labels_to_display: list[str] | None = None, - maintainer_teams: list[str] | None = None, + author_team_slugs: AuthorTeamSlugs = AuthorTeamSlugs(), ) -> Path: dashboard_state = load_dashboard_state_cache() if dashboard_state is None: raise RuntimeError("dashboard state not found") - maintainer_logins = frozenset( - load_team_members(repo.split("/", 1)[0], maintainer_teams) - if maintainer_teams - else () - ) + author_teams = load_author_teams(repo, author_team_slugs) prs = list_open_prs(repo) if not large_repo: add_draft_authors(repo, prs) @@ -249,7 +272,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, - maintainer_logins=maintainer_logins, + author_teams=author_teams, ) output_path = dashboard_markdown_path() output_path.parent.mkdir(parents=True, exist_ok=True) @@ -272,7 +295,7 @@ def publish_accepted_dashboard( state_branch_name: str, large_repo: bool, labels_to_display: list[str] | None = None, - maintainer_teams: list[str] | None = None, + author_team_slugs: AuthorTeamSlugs = AuthorTeamSlugs(), ) -> None: with state_branch.accepted_state_dir(state_branch_name, required=True) as state_dir: if state_dir is None: @@ -284,7 +307,7 @@ def publish_accepted_dashboard( repo, large_repo, labels_to_display, - maintainer_teams, + author_team_slugs, ), ) @@ -315,7 +338,7 @@ def main() -> int: args.state_branch, args.large_repo, parse_labels_to_display_json(args.labels_to_display_json), - parse_maintainer_teams_json(os.environ.get(MAINTAINER_TEAMS_ENV) or "[]"), + AuthorTeamSlugs.from_environment(os.environ), ) return 0 diff --git a/.github/scripts/pull-request-dashboard/render.py b/.github/scripts/pull-request-dashboard/render.py index 968ccf71087e..c962050adb39 100644 --- a/.github/scripts/pull-request-dashboard/render.py +++ b/.github/scripts/pull-request-dashboard/render.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Iterable -from dataclasses import replace +from dataclasses import dataclass, replace from datetime import datetime from fnmatch import fnmatchcase from typing import Any @@ -65,10 +65,31 @@ def _pr_cell_text( FIRST_TIME_AUTHOR_ASSOCIATIONS = ("FIRST_TIME_CONTRIBUTOR", "FIRST_TIMER") +@dataclass(frozen=True) +class AuthorTeams: + """Lowercased logins of the repository's configured teams.""" + + maintainers: frozenset[str] = frozenset() + approvers: frozenset[str] = frozenset() + triagers: frozenset[str] = frozenset() + + def badge(self, login: str) -> str: + # Teams nest (maintainers within approvers within triagers), so check + # the most specific team first. + login = login.lower() + if login in self.maintainers: + return "๐Ÿ› ๏ธ" + if login in self.approvers: + return "๐Ÿ”" + if login in self.triagers: + return "๐Ÿท๏ธ" + return "" + + def author_badge( pr: dict[str, Any], author: str, - maintainer_logins: frozenset[str] = frozenset(), + teams: AuthorTeams = AuthorTeams(), ) -> str: pr_author = pr.get("author") or {} # GitHub's author association and account type describe the PR author, so @@ -81,9 +102,10 @@ def author_badge( is_pr_author and str(pr_author.get("type") or "").lower() == "bot" ): return "๐Ÿค–" - # Maintainers are also organization members; show only the more specific badge. - if author.lower() in maintainer_logins: - return "๐Ÿ› ๏ธ" + # Team members are also organization members; show only the more specific badge. + team_badge = teams.badge(author) + if team_badge: + return team_badge if not is_pr_author: return "" association = pr.get("authorAssociation") @@ -97,9 +119,9 @@ def author_badge( def author_cell_text( pr: dict[str, Any], author: str, - maintainer_logins: frozenset[str] = frozenset(), + teams: AuthorTeams = AuthorTeams(), ) -> str: - badge = author_badge(pr, author, maintainer_logins) if author else "" + badge = author_badge(pr, author, teams) 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 @@ -108,7 +130,7 @@ def render_draft_pr_section( prs: list[dict[str, Any]], max_rows_per_section: int | None = None, labels_to_display: list[str] | None = None, - maintainer_logins: frozenset[str] = frozenset(), + author_teams: AuthorTeams = AuthorTeams(), ) -> list[str]: drafts = [p for p in prs if p.get("isDraft")] if not drafts: @@ -122,7 +144,7 @@ def render_draft_pr_section( author = author_cell_text( pr, pr.get("effectiveAuthor") or actor_login(pr.get("author") or {}), - maintainer_logins, + author_teams, ) draft_age = activity_age( parse_ts(pr.get("draftSince") or pr.get("createdAt") or "") @@ -277,7 +299,7 @@ def render_pr_tables( max_rows_per_section: int | None = None, skip_drafts: bool = False, labels_to_display: list[str] | None = None, - maintainer_logins: frozenset[str] = frozenset(), + author_teams: AuthorTeams = AuthorTeams(), ) -> str: results_by_number = { result.pr_number: result @@ -295,7 +317,7 @@ def render_pr_tables( ) author_note = ( "Author column: ๐Ÿ†• first-time contributor ยท ๐Ÿ”ญ open-telemetry member ยท " - "๐Ÿ› ๏ธ maintainer ยท ๐Ÿค– bot." + "๐Ÿท๏ธ triager ยท ๐Ÿ” approver ยท ๐Ÿ› ๏ธ maintainer ยท ๐Ÿค– bot." ) reviewers_note = ( "Reviewers column: โœ… active approval ยท โœ”๏ธ active approval (non-code-owner) ยท " @@ -356,7 +378,7 @@ def row_sort_key(pr: dict[str, Any]) -> tuple[int, int]: author = author_cell_text( pr, facts.author or actor_login(pr.get("author") or {}), - maintainer_logins, + author_teams, ) reviewers_cell = reviewers_cell_text(facts) activity_cell = age_cell(facts) @@ -377,6 +399,6 @@ def row_sort_key(pr: dict[str, Any]) -> tuple[int, int]: prs, max_rows_per_section, labels_to_display, - maintainer_logins, + author_teams, )) return "\n".join(out) + "\n" diff --git a/.github/scripts/pull-request-dashboard/repositories.json b/.github/scripts/pull-request-dashboard/repositories.json index 53000a6b8347..1214726acd38 100644 --- a/.github/scripts/pull-request-dashboard/repositories.json +++ b/.github/scripts/pull-request-dashboard/repositories.json @@ -3,34 +3,40 @@ "name": "opentelemetry-collector-contrib", "approver_teams": ["collector-contrib-approvers"], "maintainer_teams": ["collector-contrib-maintainers"], + "triager_teams": ["collector-contrib-triagers"], "large_repo": true }, { "name": "opentelemetry-dotnet", "approver_teams": ["dotnet-approvers"], - "maintainer_teams": ["dotnet-maintainers"] + "maintainer_teams": ["dotnet-maintainers"], + "triager_teams": ["dotnet-triagers"] }, { "name": "opentelemetry-dotnet-contrib", "approver_teams": ["dotnet-contrib-approvers"], - "maintainer_teams": ["dotnet-contrib-maintainers"] + "maintainer_teams": ["dotnet-contrib-maintainers"], + "triager_teams": ["dotnet-contrib-triagers"] }, { "name": "opentelemetry-go", "approver_teams": ["go-approvers"], "maintainer_teams": ["go-maintainers"], + "triager_teams": ["go-triagers"], "require_clean_copilot_review_branches": ["main"] }, { "name": "opentelemetry-go-contrib", "approver_teams": ["go-approvers"], "maintainer_teams": ["go-maintainers"], + "triager_teams": ["go-triagers"], "require_clean_copilot_review_branches": ["main"] }, { "name": "opentelemetry-java", "approver_teams": ["java-approvers"], "maintainer_teams": ["java-maintainers"], + "triager_teams": ["java-triagers"], "non_blocking_check_patterns": [ "markdown-link-check / link-check", "codecov/*" @@ -40,22 +46,26 @@ "name": "opentelemetry-java-instrumentation", "approver_teams": ["java-instrumentation-approvers"], "maintainer_teams": ["java-instrumentation-maintainers"], + "triager_teams": ["java-instrumentation-triagers"], "require_clean_copilot_review_branches": ["main"] }, { "name": "opentelemetry-js", "approver_teams": ["javascript-approvers"], - "maintainer_teams": ["javascript-maintainers"] + "maintainer_teams": ["javascript-maintainers"], + "triager_teams": ["javascript-triagers"] }, { "name": "opentelemetry-js-contrib", "approver_teams": ["javascript-approvers"], - "maintainer_teams": ["javascript-maintainers"] + "maintainer_teams": ["javascript-maintainers"], + "triager_teams": ["javascript-contrib-triagers"] }, { "name": "opentelemetry-proto", "approver_teams": ["proto-approvers"], "maintainer_teams": ["proto-maintainers"], + "triager_teams": ["proto-triagers"], "required_approvals": 2 }, { @@ -77,6 +87,7 @@ "name": "opentelemetry-specification", "approver_teams": ["specs-approvers"], "maintainer_teams": ["specs-maintainers"], + "triager_teams": ["specs-triagers"], "required_approvals": 2 }, { @@ -91,12 +102,14 @@ "approver_teams": [ "weaver-package-approvers" ], - "maintainer_teams": ["weaver-package-maintainers"] + "maintainer_teams": ["weaver-package-maintainers"], + "triager_teams": ["weaver-package-triagers"] }, { "name": "opentelemetry.io", "approver_teams": ["docs-approvers"], - "maintainer_teams": ["docs-maintainers"] + "maintainer_teams": ["docs-maintainers"], + "triager_teams": ["docs-triagers"] }, { "name": "semantic-conventions", @@ -130,7 +143,8 @@ { "name": "otel-arrow", "approver_teams": ["arrow-approvers"], - "maintainer_teams": ["arrow-maintainers"] + "maintainer_teams": ["arrow-maintainers"], + "triager_teams": ["arrow-triagers"] }, { "name": "opentelemetry-ebpf-profiler", @@ -142,6 +156,7 @@ "name": "opentelemetry-go-compile-instrumentation", "approver_teams": ["go-compile-instrumentation-approvers"], "maintainer_teams": ["go-compile-instrumentation-maintainers"], + "triager_teams": ["go-compile-instrumentation-triagers"], "labels_to_display": ["scope:*", "effort:*", "dependencies", "AI/ML *"], "non_blocking_check_patterns": [ "codecov/*", diff --git a/.github/scripts/pull-request-dashboard/test_process_queue_batch.py b/.github/scripts/pull-request-dashboard/test_process_queue_batch.py index 12b4f65de271..d7087d700f09 100644 --- a/.github/scripts/pull-request-dashboard/test_process_queue_batch.py +++ b/.github/scripts/pull-request-dashboard/test_process_queue_batch.py @@ -102,7 +102,7 @@ def run(command: list[str], _lease_check, **_kwargs): self.assertLess(lifecycle.index("publish_dashboard.py"), lifecycle.index("release-delivery")) self.assertLess(lifecycle.index("release-delivery"), lifecycle.index("acknowledge")) - def test_publish_receives_maintainer_teams_through_the_environment(self) -> None: + def test_publish_receives_author_teams_through_the_environment(self) -> None: with tempfile.TemporaryDirectory() as directory: publishes = [] @@ -129,7 +129,9 @@ def run(command: list[str], _lease_check, **kwargs): config = Path(directory) / "repositories.json" config.write_text( - '[{"name":"example","maintainer_teams":["example-maintainers"]}]', + '[{"name":"example","approver_teams":["example-approvers"],' + '"maintainer_teams":["example-maintainers"],' + '"triager_teams":["example-triagers"]}]', encoding="utf-8", ) with mock.patch.object(process_queue_batch, "run_monitored", side_effect=run): @@ -143,8 +145,10 @@ def run(command: list[str], _lease_check, **kwargs): command, env = publishes[0] # Stable-channel publish scripts reject unknown flags, so the teams # must not be passed on the command line. - self.assertFalse(any("maintainer" in argument for argument in command)) + self.assertFalse(any("-teams" in argument for argument in command)) + self.assertEqual('["example-approvers"]', env["APPROVER_TEAMS_JSON"]) self.assertEqual('["example-maintainers"]', env["MAINTAINER_TEAMS_JSON"]) + self.assertEqual('["example-triagers"]', env["TRIAGER_TEAMS_JSON"]) def test_delivery_lease_waits_for_ownership_and_releases_its_generation(self) -> None: client = mock.Mock() diff --git a/.github/scripts/pull-request-dashboard/test_publish_dashboard.py b/.github/scripts/pull-request-dashboard/test_publish_dashboard.py index 712976675ea1..77e614eecc3d 100644 --- a/.github/scripts/pull-request-dashboard/test_publish_dashboard.py +++ b/.github/scripts/pull-request-dashboard/test_publish_dashboard.py @@ -13,6 +13,14 @@ dashboard_state, stored_dashboard_result, ) +from render import AuthorTeams + + +EXAMPLE_TEAM_SLUGS = publish_dashboard.AuthorTeamSlugs( + maintainers=("example-maintainers",), + approvers=("example-approvers",), + triagers=("example-triagers",), +) class PublishablePrsTest(unittest.TestCase): @@ -219,7 +227,7 @@ def test_publishes_from_read_only_accepted_state( "state-branch", True, ["size/*"], - ["example-maintainers"], + EXAMPLE_TEAM_SLUGS, ) accepted_state_dir.assert_called_once_with("state-branch", required=True) @@ -228,7 +236,7 @@ def test_publishes_from_read_only_accepted_state( "open-telemetry/example", True, ["size/*"], - ["example-maintainers"], + EXAMPLE_TEAM_SLUGS, ) publish.assert_called_once_with("open-telemetry/example", Path("dashboard.md")) @@ -261,18 +269,29 @@ def test_worker_advancement_does_not_retry_a_pinned_snapshot( [Path("old.md")], ) - def test_parses_maintainer_teams_json(self) -> None: + def test_reads_author_team_slugs_from_the_environment(self) -> None: self.assertEqual( - ["example-maintainers"], - publish_dashboard.parse_maintainer_teams_json('["example-maintainers"]'), + EXAMPLE_TEAM_SLUGS, + publish_dashboard.AuthorTeamSlugs.from_environment({ + "MAINTAINER_TEAMS_JSON": '["example-maintainers"]', + "APPROVER_TEAMS_JSON": '["example-approvers"]', + "TRIAGER_TEAMS_JSON": '["example-triagers"]', + }), + ) + self.assertEqual( + publish_dashboard.AuthorTeamSlugs(), + publish_dashboard.AuthorTeamSlugs.from_environment({}), ) - for raw in ("{", '"example-maintainers"', '[""]'): - with self.subTest(raw=raw): - with self.assertRaisesRegex(RuntimeError, "MAINTAINER_TEAMS_JSON"): - publish_dashboard.parse_maintainer_teams_json(raw) + + def test_rejects_invalid_author_team_slugs(self) -> None: + for name in ("MAINTAINER_TEAMS_JSON", "APPROVER_TEAMS_JSON", "TRIAGER_TEAMS_JSON"): + for raw in ("{", '"example-team"', '[""]'): + with self.subTest(name=name, raw=raw): + with self.assertRaisesRegex(RuntimeError, name): + publish_dashboard.AuthorTeamSlugs.from_environment({name: raw}) @patch.object(publish_dashboard, "publish_accepted_dashboard") - def test_reads_maintainer_teams_from_the_environment( + def test_main_passes_author_team_slugs_from_the_environment( self, publish_accepted: object, ) -> None: @@ -284,7 +303,11 @@ def test_reads_maintainer_teams_from_the_environment( ), patch.dict( os.environ, - {"MAINTAINER_TEAMS_JSON": '["example-maintainers"]'}, + { + "MAINTAINER_TEAMS_JSON": '["example-maintainers"]', + "APPROVER_TEAMS_JSON": '["example-approvers"]', + "TRIAGER_TEAMS_JSON": '["example-triagers"]', + }, ), ): self.assertEqual(0, publish_dashboard.main()) @@ -294,29 +317,10 @@ def test_reads_maintainer_teams_from_the_environment( "state", False, [], - ["example-maintainers"], + EXAMPLE_TEAM_SLUGS, ) - @patch.object(publish_dashboard, "publish_accepted_dashboard") - def test_defaults_to_no_maintainer_teams(self, publish_accepted: object) -> None: - environment = { - key: value - for key, value in os.environ.items() - if key != "MAINTAINER_TEAMS_JSON" - } - with ( - patch.object( - sys, - "argv", - ["publish_dashboard.py", "--repo", "example", "--state-branch", "state"], - ), - patch.dict(os.environ, environment, clear=True), - ): - self.assertEqual(0, publish_dashboard.main()) - - self.assertEqual([], publish_accepted.call_args.args[4]) - - def test_passes_labels_and_maintainers_to_renderer(self) -> None: + def test_passes_labels_and_author_teams_to_renderer(self) -> None: prs = [ { "number": 1, @@ -352,7 +356,7 @@ def test_passes_labels_and_maintainers_to_renderer(self) -> None: patch.object( publish_dashboard, "load_team_members", - return_value={"maintainer"}, + side_effect=lambda _org, slugs: {slugs[0].removeprefix("example-")}, ) as load_team_members, ): self.assertEqual( @@ -361,13 +365,17 @@ def test_passes_labels_and_maintainers_to_renderer(self) -> None: "open-telemetry/example", False, ["size/*"], - ["example-maintainers"], + EXAMPLE_TEAM_SLUGS, ), ) - load_team_members.assert_called_once_with( - "open-telemetry", - ["example-maintainers"], + self.assertEqual( + [ + (("open-telemetry", ["example-maintainers"]),), + (("open-telemetry", ["example-approvers"]),), + (("open-telemetry", ["example-triagers"]),), + ], + [(call.args,) for call in load_team_members.call_args_list], ) render_pr_tables.assert_called_once_with( prs, @@ -375,10 +383,14 @@ def test_passes_labels_and_maintainers_to_renderer(self) -> None: max_rows_per_section=None, skip_drafts=False, labels_to_display=["size/*"], - maintainer_logins=frozenset({"maintainer"}), + author_teams=AuthorTeams( + maintainers=frozenset({"maintainers"}), + approvers=frozenset({"approvers"}), + triagers=frozenset({"triagers"}), + ), ) - def test_skips_maintainer_lookup_without_maintainer_teams(self) -> None: + def test_skips_team_lookups_without_author_teams(self) -> None: with tempfile.TemporaryDirectory() as tmp: output_path = Path(tmp) / "dashboard.md" with ( diff --git a/.github/scripts/pull-request-dashboard/test_render.py b/.github/scripts/pull-request-dashboard/test_render.py index eae635c14026..d1076fca4a45 100644 --- a/.github/scripts/pull-request-dashboard/test_render.py +++ b/.github/scripts/pull-request-dashboard/test_render.py @@ -8,6 +8,7 @@ stored_dashboard_result, ) from render import ( + AuthorTeams, author_cell_text, ci_cell, render_draft_pr_section, @@ -359,17 +360,25 @@ def test_author_legend_explains_badges(self) -> None: self.assertIn( "Author column: ๐Ÿ†• first-time contributor ยท ๐Ÿ”ญ open-telemetry member ยท " - "๐Ÿ› ๏ธ maintainer ยท ๐Ÿค– bot.", + "๐Ÿท๏ธ triager ยท ๐Ÿ” approver ยท ๐Ÿ› ๏ธ maintainer ยท ๐Ÿค– bot.", markdown, ) def test_author_badges(self) -> None: - maintainers = frozenset({"maintainer"}) + # Team membership nests: maintainers are approvers, and approvers are triagers. + teams = AuthorTeams( + maintainers=frozenset({"maintainer"}), + approvers=frozenset({"maintainer", "approver"}), + triagers=frozenset({"maintainer", "approver", "triager"}), + ) cases = [ ("first-time contributor", {"login": "new"}, "FIRST_TIME_CONTRIBUTOR", "new", "new ๐Ÿ†•"), ("first-time GitHub user", {"login": "new"}, "FIRST_TIMER", "new", "new ๐Ÿ†•"), ("organization member", {"login": "member"}, "MEMBER", "member", "member ๐Ÿ”ญ"), ("maintainer", {"login": "Maintainer"}, "MEMBER", "Maintainer", "Maintainer ๐Ÿ› ๏ธ"), + ("approver", {"login": "approver"}, "MEMBER", "approver", "approver ๐Ÿ”"), + ("triager", {"login": "triager"}, "MEMBER", "triager", "triager ๐Ÿท๏ธ"), + ("team member without org association", {"login": "triager"}, "CONTRIBUTOR", "triager", "triager ๐Ÿท๏ธ"), ("bot account", {"login": "renovate[bot]", "type": "Bot"}, "CONTRIBUTOR", "renovate[bot]", "renovate[bot] ๐Ÿค–"), ("bot app login", {"login": "renovate[bot]", "type": "Bot"}, "NONE", "app/renovate", "app/renovate ๐Ÿค–"), ("bot user account", {"login": "opentelemetrybot"}, "MEMBER", "opentelemetrybot", "opentelemetrybot ๐Ÿค–"), @@ -381,7 +390,7 @@ def test_author_badges(self) -> None: for name, pr_author, association, author, expected in cases: with self.subTest(name): pr = {"author": pr_author, "authorAssociation": association} - self.assertEqual(expected, author_cell_text(pr, author, maintainers)) + self.assertEqual(expected, author_cell_text(pr, author, teams)) def test_copilot_pr_human_author_does_not_inherit_copilot_association(self) -> None: pr = { @@ -392,7 +401,11 @@ def test_copilot_pr_human_author_does_not_inherit_copilot_association(self) -> N self.assertEqual("human", author_cell_text(pr, "human")) self.assertEqual( "maintainer ๐Ÿ› ๏ธ", - author_cell_text(pr, "maintainer", frozenset({"maintainer"})), + author_cell_text( + pr, + "maintainer", + AuthorTeams(maintainers=frozenset({"maintainer"})), + ), ) def test_renders_author_badges_in_route_and_draft_tables(self) -> None: @@ -414,7 +427,7 @@ def test_renders_author_badges_in_route_and_draft_tables(self) -> None: }, ], (stored_dashboard_result(127),), - maintainer_logins=frozenset({"maintainer"}), + author_teams=AuthorTeams(maintainers=frozenset({"maintainer"})), ) self.assertIn("| #127 Feature | member ๐Ÿ”ญ |", markdown) diff --git a/pull-request-dashboard/README.md b/pull-request-dashboard/README.md index 3a9a0da1cc4f..9badeb828075 100644 --- a/pull-request-dashboard/README.md +++ b/pull-request-dashboard/README.md @@ -102,6 +102,8 @@ The dashboard groups open non-draft pull requests by who is expected to act next - **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. The login is followed by at most one badge, checked in this order: - ๐Ÿค– bot account - ๐Ÿ› ๏ธ member of one of the repository's `maintainer_teams` + - ๐Ÿ” member of one of the repository's `approver_teams` + - ๐Ÿท๏ธ member of one of the repository's `triager_teams` - ๐Ÿ”ญ other member of the `open-telemetry` organization - ๐Ÿ†• first-time contributor to the repository - Organization membership and first-time status come from GitHub's author association for the PR, so they are not shown when a Copilot-authored PR is attributed to its human assignee or committer. @@ -149,6 +151,7 @@ Open a pull request that adds your repository to [`.github/scripts/pull-request- "name": "example-repo", "approver_teams": ["example-approvers"], "maintainer_teams": ["example-maintainers"], + "triager_teams": ["example-triagers"], "required_approvals": 1, "non_blocking_check_patterns": [ "markdown-link-check / link-check", @@ -169,8 +172,9 @@ Fields: | Field | Required | Description | | ----- | -------- | ----------- | | `name` | yes | Name of the repository under `open-telemetry`. | -| `approver_teams` | yes | GitHub team slugs whose members count as approvers. | +| `approver_teams` | yes | GitHub team slugs whose members count as approvers. Members are also shown with the ๐Ÿ” approver badge in the dashboard's Author column. | | `maintainer_teams` | no | GitHub team slugs whose members are shown with the ๐Ÿ› ๏ธ maintainer badge in the dashboard's Author column. Display only; it does not affect routing. Defaults to `[]`, which shows no maintainer badges. | +| `triager_teams` | no | GitHub team slugs whose members are shown with the ๐Ÿท๏ธ triager badge in the dashboard's Author column. Display only; it does not affect routing. Defaults to `[]`, which shows no triager badges. | | `required_approvals` | no | Number of active approvals required for an open PR to be marked ready to merge. An approval stops counting while that reviewer has a pending re-review request. 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. | From bf91db35b8add12f13bbd9f1f1431d38050e3c6d Mon Sep 17 00:00:00 2001 From: martincostello Date: Mon, 26 Oct 2026 14:32:00 +0000 Subject: [PATCH 3/3] chore: address feedback Combine maintainers and approvers into the same badge and remove triagers. --- .../pull-request-dashboard/WEBHOOK_SETUP.md | 2 +- .../process_queue_batch.py | 2 - .../publish_dashboard.py | 57 ++++-------- .../scripts/pull-request-dashboard/render.py | 49 +++------- .../pull-request-dashboard/repositories.json | 74 ++++----------- .../test_process_queue_batch.py | 10 +-- .../test_publish_dashboard.py | 90 ++++++++----------- .../pull-request-dashboard/test_render.py | 26 ++---- pull-request-dashboard/README.md | 10 +-- 9 files changed, 99 insertions(+), 221 deletions(-) diff --git a/.github/scripts/pull-request-dashboard/WEBHOOK_SETUP.md b/.github/scripts/pull-request-dashboard/WEBHOOK_SETUP.md index cbcfef0a67d7..d58bea133a6a 100644 --- a/.github/scripts/pull-request-dashboard/WEBHOOK_SETUP.md +++ b/.github/scripts/pull-request-dashboard/WEBHOOK_SETUP.md @@ -156,7 +156,7 @@ Permission rationale: | Issues | Read and write | Finds, creates, and updates the dashboard issue. | | Metadata | Read | Required by GitHub for GitHub App repository access. | | Pull requests | Read and write | Required to subscribe to PR review/comment/thread events; read PR details, reviews, review comments, commits, and GraphQL review threads; and create the dashboard-managed PR status comment, which is a pull request conversation comment. | -| Members | Read | Reads approver-, maintainer-, and triager-team membership configured in `repositories.json`. | +| Members | Read | Reads approver-team membership configured in `repositories.json`. | The dashboard does not create inline review comments, submit reviews, or resolve review threads. It manages one PR conversation comment (create, update, and diff --git a/.github/scripts/pull-request-dashboard/process_queue_batch.py b/.github/scripts/pull-request-dashboard/process_queue_batch.py index 1d311c96db77..f644489156c3 100644 --- a/.github/scripts/pull-request-dashboard/process_queue_batch.py +++ b/.github/scripts/pull-request-dashboard/process_queue_batch.py @@ -415,8 +415,6 @@ def process_repository( "REPO_NAME": repository, "REQUIRED_APPROVALS": str(config.get("required_approvals", 1)), "APPROVER_TEAMS_JSON": json.dumps(config.get("approver_teams", [])), - "MAINTAINER_TEAMS_JSON": json.dumps(config.get("maintainer_teams", [])), - "TRIAGER_TEAMS_JSON": json.dumps(config.get("triager_teams", [])), "NON_BLOCKING_CHECK_PATTERNS_JSON": json.dumps( config.get("non_blocking_check_patterns", []) ), diff --git a/.github/scripts/pull-request-dashboard/publish_dashboard.py b/.github/scripts/pull-request-dashboard/publish_dashboard.py index 77e71c83d902..6dae2b036f3a 100644 --- a/.github/scripts/pull-request-dashboard/publish_dashboard.py +++ b/.github/scripts/pull-request-dashboard/publish_dashboard.py @@ -7,8 +7,7 @@ import json import os import sys -from collections.abc import Iterable, Mapping -from dataclasses import dataclass +from collections.abc import Iterable from pathlib import Path from typing import Any @@ -26,7 +25,7 @@ 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 AuthorTeams, render_pr_tables +from render import render_pr_tables from state import ( dashboard_markdown_path, load_dashboard_state_cache, @@ -65,39 +64,13 @@ def parse_labels_to_display_json(raw: str) -> list[str]: return _parse_string_list_json(raw, "--labels-to-display-json") -@dataclass(frozen=True) -class AuthorTeamSlugs: - maintainers: tuple[str, ...] = () - approvers: tuple[str, ...] = () - triagers: tuple[str, ...] = () +# 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" - # The queue processor passes teams through the environment rather than - # flags so that stable-channel scripts without these options still run. - @classmethod - def from_environment(cls, environ: Mapping[str, str]) -> AuthorTeamSlugs: - def slugs(name: str) -> tuple[str, ...]: - return tuple(_parse_string_list_json(environ.get(name) or "[]", name)) - - return cls( - maintainers=slugs("MAINTAINER_TEAMS_JSON"), - approvers=slugs("APPROVER_TEAMS_JSON"), - triagers=slugs("TRIAGER_TEAMS_JSON"), - ) - -def load_author_teams(repo: str, slugs: AuthorTeamSlugs) -> AuthorTeams: - org = repo.split("/", 1)[0] - - def members(team_slugs: tuple[str, ...]) -> frozenset[str]: - if not team_slugs: - return frozenset() - return frozenset(load_team_members(org, list(team_slugs))) - - return AuthorTeams( - maintainers=members(slugs.maintainers), - approvers=members(slugs.approvers), - triagers=members(slugs.triagers), - ) +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 @@ -253,13 +226,17 @@ def render_dashboard_markdown( repo: str, large_repo: bool, labels_to_display: list[str] | None = None, - author_team_slugs: AuthorTeamSlugs = AuthorTeamSlugs(), + approver_teams: list[str] | None = None, ) -> Path: dashboard_state = load_dashboard_state_cache() if dashboard_state is None: raise RuntimeError("dashboard state not found") - author_teams = load_author_teams(repo, author_team_slugs) + 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) @@ -272,7 +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, - author_teams=author_teams, + approver_logins=approver_logins, ) output_path = dashboard_markdown_path() output_path.parent.mkdir(parents=True, exist_ok=True) @@ -295,7 +272,7 @@ def publish_accepted_dashboard( state_branch_name: str, large_repo: bool, labels_to_display: list[str] | None = None, - author_team_slugs: AuthorTeamSlugs = AuthorTeamSlugs(), + 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: @@ -307,7 +284,7 @@ def publish_accepted_dashboard( repo, large_repo, labels_to_display, - author_team_slugs, + approver_teams, ), ) @@ -338,7 +315,7 @@ def main() -> int: args.state_branch, args.large_repo, parse_labels_to_display_json(args.labels_to_display_json), - AuthorTeamSlugs.from_environment(os.environ), + parse_approver_teams_json(os.environ.get(APPROVER_TEAMS_ENV) or "[]"), ) return 0 diff --git a/.github/scripts/pull-request-dashboard/render.py b/.github/scripts/pull-request-dashboard/render.py index c962050adb39..5cf99b6a8344 100644 --- a/.github/scripts/pull-request-dashboard/render.py +++ b/.github/scripts/pull-request-dashboard/render.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Iterable -from dataclasses import dataclass, replace +from dataclasses import replace from datetime import datetime from fnmatch import fnmatchcase from typing import Any @@ -65,31 +65,10 @@ def _pr_cell_text( FIRST_TIME_AUTHOR_ASSOCIATIONS = ("FIRST_TIME_CONTRIBUTOR", "FIRST_TIMER") -@dataclass(frozen=True) -class AuthorTeams: - """Lowercased logins of the repository's configured teams.""" - - maintainers: frozenset[str] = frozenset() - approvers: frozenset[str] = frozenset() - triagers: frozenset[str] = frozenset() - - def badge(self, login: str) -> str: - # Teams nest (maintainers within approvers within triagers), so check - # the most specific team first. - login = login.lower() - if login in self.maintainers: - return "๐Ÿ› ๏ธ" - if login in self.approvers: - return "๐Ÿ”" - if login in self.triagers: - return "๐Ÿท๏ธ" - return "" - - def author_badge( pr: dict[str, Any], author: str, - teams: AuthorTeams = AuthorTeams(), + approver_logins: frozenset[str] = frozenset(), ) -> str: pr_author = pr.get("author") or {} # GitHub's author association and account type describe the PR author, so @@ -102,10 +81,10 @@ def author_badge( is_pr_author and str(pr_author.get("type") or "").lower() == "bot" ): return "๐Ÿค–" - # Team members are also organization members; show only the more specific badge. - team_badge = teams.badge(author) - if team_badge: - return team_badge + # 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") @@ -119,9 +98,9 @@ def author_badge( def author_cell_text( pr: dict[str, Any], author: str, - teams: AuthorTeams = AuthorTeams(), + approver_logins: frozenset[str] = frozenset(), ) -> str: - badge = author_badge(pr, author, teams) if author else "" + 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 @@ -130,7 +109,7 @@ def render_draft_pr_section( prs: list[dict[str, Any]], max_rows_per_section: int | None = None, labels_to_display: list[str] | None = None, - author_teams: AuthorTeams = AuthorTeams(), + approver_logins: frozenset[str] = frozenset(), ) -> list[str]: drafts = [p for p in prs if p.get("isDraft")] if not drafts: @@ -144,7 +123,7 @@ def render_draft_pr_section( author = author_cell_text( pr, pr.get("effectiveAuthor") or actor_login(pr.get("author") or {}), - author_teams, + approver_logins, ) draft_age = activity_age( parse_ts(pr.get("draftSince") or pr.get("createdAt") or "") @@ -299,7 +278,7 @@ def render_pr_tables( max_rows_per_section: int | None = None, skip_drafts: bool = False, labels_to_display: list[str] | None = None, - author_teams: AuthorTeams = AuthorTeams(), + approver_logins: frozenset[str] = frozenset(), ) -> str: results_by_number = { result.pr_number: result @@ -317,7 +296,7 @@ def render_pr_tables( ) author_note = ( "Author column: ๐Ÿ†• first-time contributor ยท ๐Ÿ”ญ open-telemetry member ยท " - "๐Ÿท๏ธ triager ยท ๐Ÿ” approver ยท ๐Ÿ› ๏ธ maintainer ยท ๐Ÿค– bot." + "๐Ÿ› ๏ธ approver or maintainer ยท ๐Ÿค– bot." ) reviewers_note = ( "Reviewers column: โœ… active approval ยท โœ”๏ธ active approval (non-code-owner) ยท " @@ -378,7 +357,7 @@ def row_sort_key(pr: dict[str, Any]) -> tuple[int, int]: author = author_cell_text( pr, facts.author or actor_login(pr.get("author") or {}), - author_teams, + approver_logins, ) reviewers_cell = reviewers_cell_text(facts) activity_cell = age_cell(facts) @@ -399,6 +378,6 @@ def row_sort_key(pr: dict[str, Any]) -> tuple[int, int]: prs, max_rows_per_section, labels_to_display, - author_teams, + approver_logins, )) return "\n".join(out) + "\n" diff --git a/.github/scripts/pull-request-dashboard/repositories.json b/.github/scripts/pull-request-dashboard/repositories.json index 1214726acd38..188834105d76 100644 --- a/.github/scripts/pull-request-dashboard/repositories.json +++ b/.github/scripts/pull-request-dashboard/repositories.json @@ -2,41 +2,29 @@ { "name": "opentelemetry-collector-contrib", "approver_teams": ["collector-contrib-approvers"], - "maintainer_teams": ["collector-contrib-maintainers"], - "triager_teams": ["collector-contrib-triagers"], "large_repo": true }, { "name": "opentelemetry-dotnet", - "approver_teams": ["dotnet-approvers"], - "maintainer_teams": ["dotnet-maintainers"], - "triager_teams": ["dotnet-triagers"] + "approver_teams": ["dotnet-approvers"] }, { "name": "opentelemetry-dotnet-contrib", - "approver_teams": ["dotnet-contrib-approvers"], - "maintainer_teams": ["dotnet-contrib-maintainers"], - "triager_teams": ["dotnet-contrib-triagers"] + "approver_teams": ["dotnet-contrib-approvers"] }, { "name": "opentelemetry-go", "approver_teams": ["go-approvers"], - "maintainer_teams": ["go-maintainers"], - "triager_teams": ["go-triagers"], "require_clean_copilot_review_branches": ["main"] }, { "name": "opentelemetry-go-contrib", "approver_teams": ["go-approvers"], - "maintainer_teams": ["go-maintainers"], - "triager_teams": ["go-triagers"], "require_clean_copilot_review_branches": ["main"] }, { "name": "opentelemetry-java", "approver_teams": ["java-approvers"], - "maintainer_teams": ["java-maintainers"], - "triager_teams": ["java-triagers"], "non_blocking_check_patterns": [ "markdown-link-check / link-check", "codecov/*" @@ -45,118 +33,90 @@ { "name": "opentelemetry-java-instrumentation", "approver_teams": ["java-instrumentation-approvers"], - "maintainer_teams": ["java-instrumentation-maintainers"], - "triager_teams": ["java-instrumentation-triagers"], "require_clean_copilot_review_branches": ["main"] }, { "name": "opentelemetry-js", - "approver_teams": ["javascript-approvers"], - "maintainer_teams": ["javascript-maintainers"], - "triager_teams": ["javascript-triagers"] + "approver_teams": ["javascript-approvers"] }, { "name": "opentelemetry-js-contrib", - "approver_teams": ["javascript-approvers"], - "maintainer_teams": ["javascript-maintainers"], - "triager_teams": ["javascript-contrib-triagers"] + "approver_teams": ["javascript-approvers"] }, { "name": "opentelemetry-proto", "approver_teams": ["proto-approvers"], - "maintainer_teams": ["proto-maintainers"], - "triager_teams": ["proto-triagers"], "required_approvals": 2 }, { "name": "opentelemetry-python-genai", - "approver_teams": ["python-genai-approvers"], - "maintainer_teams": ["python-genai-maintainers"] + "approver_teams": ["python-genai-approvers"] }, { "name": "opentelemetry-python", - "approver_teams": ["python-approvers"], - "maintainer_teams": ["python-maintainers"] + "approver_teams": ["python-approvers"] }, { "name": "opentelemetry-python-contrib", - "approver_teams": ["opentelemetry-python-contrib-approvers"], - "maintainer_teams": ["opentelemetry-python-contrib-maintainers"] + "approver_teams": ["opentelemetry-python-contrib-approvers"] }, { "name": "opentelemetry-specification", "approver_teams": ["specs-approvers"], - "maintainer_teams": ["specs-maintainers"], - "triager_teams": ["specs-triagers"], "required_approvals": 2 }, { "name": "opentelemetry-weaver-examples", "approver_teams": [ "weaver-approvers" - ], - "maintainer_teams": ["weaver-maintainers"] + ] }, { "name": "opentelemetry-weaver-packages", "approver_teams": [ "weaver-package-approvers" - ], - "maintainer_teams": ["weaver-package-maintainers"], - "triager_teams": ["weaver-package-triagers"] + ] }, { "name": "opentelemetry.io", - "approver_teams": ["docs-approvers"], - "maintainer_teams": ["docs-maintainers"], - "triager_teams": ["docs-triagers"] + "approver_teams": ["docs-approvers"] }, { "name": "semantic-conventions", "approver_teams": ["specs-semconv-approvers"], - "maintainer_teams": ["specs-semconv-maintainers"], "required_approvals": 2 }, { "name": "semantic-conventions-conformance", - "approver_teams": ["semconv-conformance-approvers"], - "maintainer_teams": ["semconv-conformance-maintainers"] + "approver_teams": ["semconv-conformance-approvers"] }, { "name": "semantic-conventions-genai", "approver_teams": ["semconv-genai-approvers"], - "maintainer_teams": ["semconv-genai-maintainers"], "required_approvals": 2 }, { "name": "shared-workflows", - "approver_teams": ["shared-workflows-approvers"], - "maintainer_teams": ["shared-workflows-maintainers"] + "approver_teams": ["shared-workflows-approvers"] }, { "name": "weaver", "approver_teams": [ "weaver-approvers" - ], - "maintainer_teams": ["weaver-maintainers"] + ] }, { "name": "otel-arrow", - "approver_teams": ["arrow-approvers"], - "maintainer_teams": ["arrow-maintainers"], - "triager_teams": ["arrow-triagers"] + "approver_teams": ["arrow-approvers"] }, { "name": "opentelemetry-ebpf-profiler", "approver_teams": ["ebpf-profiler-approvers"], - "maintainer_teams": ["ebpf-profiler-maintainers"], "required_approvals": 2 }, { "name": "opentelemetry-go-compile-instrumentation", "approver_teams": ["go-compile-instrumentation-approvers"], - "maintainer_teams": ["go-compile-instrumentation-maintainers"], - "triager_teams": ["go-compile-instrumentation-triagers"], "labels_to_display": ["scope:*", "effort:*", "dependencies", "AI/ML *"], "non_blocking_check_patterns": [ "codecov/*", @@ -169,7 +129,6 @@ { "name": "opentelemetry-ebpf-instrumentation", "approver_teams": ["ebpf-instrumentation-approvers"], - "maintainer_teams": ["ebpf-instrumentation-maintainers"], "labels_to_display": ["breaking change", "dependencies", "do-not-merge", "blocked"], "non_blocking_check_patterns": ["codecov/*"], "require_clean_copilot_review_branches": ["main"] @@ -177,14 +136,12 @@ { "name": "opentelemetry-ecosystem-explorer", "approver_teams": ["ecosystem-explorer-approvers"], - "maintainer_teams": ["ecosystem-explorer-maintainers"], "non_blocking_check_patterns": ["codecov/*"], "require_clean_copilot_review_branches": ["main"] }, { "name": "opentelemetry-rust", "approver_teams": ["rust-approvers"], - "maintainer_teams": ["rust-maintainers"], "required_approvals": 1, "labels_to_display": [ "A-*", @@ -198,7 +155,6 @@ }, { "name": "opentelemetry-browser", - "approver_teams": ["browser-approvers"], - "maintainer_teams": ["browser-maintainers"] + "approver_teams": ["browser-approvers"] } ] diff --git a/.github/scripts/pull-request-dashboard/test_process_queue_batch.py b/.github/scripts/pull-request-dashboard/test_process_queue_batch.py index d7087d700f09..3701ee1f4cd4 100644 --- a/.github/scripts/pull-request-dashboard/test_process_queue_batch.py +++ b/.github/scripts/pull-request-dashboard/test_process_queue_batch.py @@ -102,7 +102,7 @@ def run(command: list[str], _lease_check, **_kwargs): self.assertLess(lifecycle.index("publish_dashboard.py"), lifecycle.index("release-delivery")) self.assertLess(lifecycle.index("release-delivery"), lifecycle.index("acknowledge")) - def test_publish_receives_author_teams_through_the_environment(self) -> None: + def test_publish_receives_approver_teams_through_the_environment(self) -> None: with tempfile.TemporaryDirectory() as directory: publishes = [] @@ -129,9 +129,7 @@ def run(command: list[str], _lease_check, **kwargs): config = Path(directory) / "repositories.json" config.write_text( - '[{"name":"example","approver_teams":["example-approvers"],' - '"maintainer_teams":["example-maintainers"],' - '"triager_teams":["example-triagers"]}]', + '[{"name":"example","approver_teams":["example-approvers"]}]', encoding="utf-8", ) with mock.patch.object(process_queue_batch, "run_monitored", side_effect=run): @@ -145,10 +143,8 @@ def run(command: list[str], _lease_check, **kwargs): command, env = publishes[0] # Stable-channel publish scripts reject unknown flags, so the teams # must not be passed on the command line. - self.assertFalse(any("-teams" in argument for argument in command)) + self.assertFalse(any("team" in argument for argument in command)) self.assertEqual('["example-approvers"]', env["APPROVER_TEAMS_JSON"]) - self.assertEqual('["example-maintainers"]', env["MAINTAINER_TEAMS_JSON"]) - self.assertEqual('["example-triagers"]', env["TRIAGER_TEAMS_JSON"]) def test_delivery_lease_waits_for_ownership_and_releases_its_generation(self) -> None: client = mock.Mock() diff --git a/.github/scripts/pull-request-dashboard/test_publish_dashboard.py b/.github/scripts/pull-request-dashboard/test_publish_dashboard.py index 77e614eecc3d..e1deead9e375 100644 --- a/.github/scripts/pull-request-dashboard/test_publish_dashboard.py +++ b/.github/scripts/pull-request-dashboard/test_publish_dashboard.py @@ -13,14 +13,6 @@ dashboard_state, stored_dashboard_result, ) -from render import AuthorTeams - - -EXAMPLE_TEAM_SLUGS = publish_dashboard.AuthorTeamSlugs( - maintainers=("example-maintainers",), - approvers=("example-approvers",), - triagers=("example-triagers",), -) class PublishablePrsTest(unittest.TestCase): @@ -227,7 +219,7 @@ def test_publishes_from_read_only_accepted_state( "state-branch", True, ["size/*"], - EXAMPLE_TEAM_SLUGS, + ["example-approvers"], ) accepted_state_dir.assert_called_once_with("state-branch", required=True) @@ -236,7 +228,7 @@ def test_publishes_from_read_only_accepted_state( "open-telemetry/example", True, ["size/*"], - EXAMPLE_TEAM_SLUGS, + ["example-approvers"], ) publish.assert_called_once_with("open-telemetry/example", Path("dashboard.md")) @@ -269,29 +261,18 @@ def test_worker_advancement_does_not_retry_a_pinned_snapshot( [Path("old.md")], ) - def test_reads_author_team_slugs_from_the_environment(self) -> None: + def test_parses_approver_teams_json(self) -> None: self.assertEqual( - EXAMPLE_TEAM_SLUGS, - publish_dashboard.AuthorTeamSlugs.from_environment({ - "MAINTAINER_TEAMS_JSON": '["example-maintainers"]', - "APPROVER_TEAMS_JSON": '["example-approvers"]', - "TRIAGER_TEAMS_JSON": '["example-triagers"]', - }), + ["example-approvers"], + publish_dashboard.parse_approver_teams_json('["example-approvers"]'), ) - self.assertEqual( - publish_dashboard.AuthorTeamSlugs(), - publish_dashboard.AuthorTeamSlugs.from_environment({}), - ) - - def test_rejects_invalid_author_team_slugs(self) -> None: - for name in ("MAINTAINER_TEAMS_JSON", "APPROVER_TEAMS_JSON", "TRIAGER_TEAMS_JSON"): - for raw in ("{", '"example-team"', '[""]'): - with self.subTest(name=name, raw=raw): - with self.assertRaisesRegex(RuntimeError, name): - publish_dashboard.AuthorTeamSlugs.from_environment({name: raw}) + for raw in ("{", '"example-approvers"', '[""]'): + with self.subTest(raw=raw): + with self.assertRaisesRegex(RuntimeError, "APPROVER_TEAMS_JSON"): + publish_dashboard.parse_approver_teams_json(raw) @patch.object(publish_dashboard, "publish_accepted_dashboard") - def test_main_passes_author_team_slugs_from_the_environment( + def test_main_passes_approver_teams_from_the_environment( self, publish_accepted: object, ) -> None: @@ -303,11 +284,7 @@ def test_main_passes_author_team_slugs_from_the_environment( ), patch.dict( os.environ, - { - "MAINTAINER_TEAMS_JSON": '["example-maintainers"]', - "APPROVER_TEAMS_JSON": '["example-approvers"]', - "TRIAGER_TEAMS_JSON": '["example-triagers"]', - }, + {"APPROVER_TEAMS_JSON": '["example-approvers"]'}, ), ): self.assertEqual(0, publish_dashboard.main()) @@ -317,10 +294,29 @@ def test_main_passes_author_team_slugs_from_the_environment( "state", False, [], - EXAMPLE_TEAM_SLUGS, + ["example-approvers"], ) - def test_passes_labels_and_author_teams_to_renderer(self) -> None: + @patch.object(publish_dashboard, "publish_accepted_dashboard") + def test_main_defaults_to_no_approver_teams(self, publish_accepted: object) -> None: + environment = { + key: value + for key, value in os.environ.items() + if key != "APPROVER_TEAMS_JSON" + } + with ( + patch.object( + sys, + "argv", + ["publish_dashboard.py", "--repo", "example", "--state-branch", "state"], + ), + patch.dict(os.environ, environment, clear=True), + ): + self.assertEqual(0, publish_dashboard.main()) + + self.assertEqual([], publish_accepted.call_args.args[4]) + + def test_passes_labels_and_approvers_to_renderer(self) -> None: prs = [ { "number": 1, @@ -356,7 +352,7 @@ def test_passes_labels_and_author_teams_to_renderer(self) -> None: patch.object( publish_dashboard, "load_team_members", - side_effect=lambda _org, slugs: {slugs[0].removeprefix("example-")}, + return_value={"approver"}, ) as load_team_members, ): self.assertEqual( @@ -365,17 +361,13 @@ def test_passes_labels_and_author_teams_to_renderer(self) -> None: "open-telemetry/example", False, ["size/*"], - EXAMPLE_TEAM_SLUGS, + ["example-approvers"], ), ) - self.assertEqual( - [ - (("open-telemetry", ["example-maintainers"]),), - (("open-telemetry", ["example-approvers"]),), - (("open-telemetry", ["example-triagers"]),), - ], - [(call.args,) for call in load_team_members.call_args_list], + load_team_members.assert_called_once_with( + "open-telemetry", + ["example-approvers"], ) render_pr_tables.assert_called_once_with( prs, @@ -383,14 +375,10 @@ def test_passes_labels_and_author_teams_to_renderer(self) -> None: max_rows_per_section=None, skip_drafts=False, labels_to_display=["size/*"], - author_teams=AuthorTeams( - maintainers=frozenset({"maintainers"}), - approvers=frozenset({"approvers"}), - triagers=frozenset({"triagers"}), - ), + approver_logins=frozenset({"approver"}), ) - def test_skips_team_lookups_without_author_teams(self) -> None: + def test_skips_team_lookup_without_approver_teams(self) -> None: with tempfile.TemporaryDirectory() as tmp: output_path = Path(tmp) / "dashboard.md" with ( diff --git a/.github/scripts/pull-request-dashboard/test_render.py b/.github/scripts/pull-request-dashboard/test_render.py index d1076fca4a45..fd5991ea81ec 100644 --- a/.github/scripts/pull-request-dashboard/test_render.py +++ b/.github/scripts/pull-request-dashboard/test_render.py @@ -8,7 +8,6 @@ stored_dashboard_result, ) from render import ( - AuthorTeams, author_cell_text, ci_cell, render_draft_pr_section, @@ -360,25 +359,20 @@ def test_author_legend_explains_badges(self) -> None: self.assertIn( "Author column: ๐Ÿ†• first-time contributor ยท ๐Ÿ”ญ open-telemetry member ยท " - "๐Ÿท๏ธ triager ยท ๐Ÿ” approver ยท ๐Ÿ› ๏ธ maintainer ยท ๐Ÿค– bot.", + "๐Ÿ› ๏ธ approver or maintainer ยท ๐Ÿค– bot.", markdown, ) def test_author_badges(self) -> None: - # Team membership nests: maintainers are approvers, and approvers are triagers. - teams = AuthorTeams( - maintainers=frozenset({"maintainer"}), - approvers=frozenset({"maintainer", "approver"}), - triagers=frozenset({"maintainer", "approver", "triager"}), - ) + # Approver team listings include members of child maintainer teams. + approvers = frozenset({"maintainer", "approver"}) cases = [ ("first-time contributor", {"login": "new"}, "FIRST_TIME_CONTRIBUTOR", "new", "new ๐Ÿ†•"), ("first-time GitHub user", {"login": "new"}, "FIRST_TIMER", "new", "new ๐Ÿ†•"), ("organization member", {"login": "member"}, "MEMBER", "member", "member ๐Ÿ”ญ"), ("maintainer", {"login": "Maintainer"}, "MEMBER", "Maintainer", "Maintainer ๐Ÿ› ๏ธ"), - ("approver", {"login": "approver"}, "MEMBER", "approver", "approver ๐Ÿ”"), - ("triager", {"login": "triager"}, "MEMBER", "triager", "triager ๐Ÿท๏ธ"), - ("team member without org association", {"login": "triager"}, "CONTRIBUTOR", "triager", "triager ๐Ÿท๏ธ"), + ("approver", {"login": "approver"}, "MEMBER", "approver", "approver ๐Ÿ› ๏ธ"), + ("approver without org association", {"login": "approver"}, "CONTRIBUTOR", "approver", "approver ๐Ÿ› ๏ธ"), ("bot account", {"login": "renovate[bot]", "type": "Bot"}, "CONTRIBUTOR", "renovate[bot]", "renovate[bot] ๐Ÿค–"), ("bot app login", {"login": "renovate[bot]", "type": "Bot"}, "NONE", "app/renovate", "app/renovate ๐Ÿค–"), ("bot user account", {"login": "opentelemetrybot"}, "MEMBER", "opentelemetrybot", "opentelemetrybot ๐Ÿค–"), @@ -390,7 +384,7 @@ def test_author_badges(self) -> None: for name, pr_author, association, author, expected in cases: with self.subTest(name): pr = {"author": pr_author, "authorAssociation": association} - self.assertEqual(expected, author_cell_text(pr, author, teams)) + self.assertEqual(expected, author_cell_text(pr, author, approvers)) def test_copilot_pr_human_author_does_not_inherit_copilot_association(self) -> None: pr = { @@ -401,11 +395,7 @@ def test_copilot_pr_human_author_does_not_inherit_copilot_association(self) -> N self.assertEqual("human", author_cell_text(pr, "human")) self.assertEqual( "maintainer ๐Ÿ› ๏ธ", - author_cell_text( - pr, - "maintainer", - AuthorTeams(maintainers=frozenset({"maintainer"})), - ), + author_cell_text(pr, "maintainer", frozenset({"maintainer"})), ) def test_renders_author_badges_in_route_and_draft_tables(self) -> None: @@ -427,7 +417,7 @@ def test_renders_author_badges_in_route_and_draft_tables(self) -> None: }, ], (stored_dashboard_result(127),), - author_teams=AuthorTeams(maintainers=frozenset({"maintainer"})), + approver_logins=frozenset({"maintainer"}), ) self.assertIn("| #127 Feature | member ๐Ÿ”ญ |", markdown) diff --git a/pull-request-dashboard/README.md b/pull-request-dashboard/README.md index 9badeb828075..e24f0cd09184 100644 --- a/pull-request-dashboard/README.md +++ b/pull-request-dashboard/README.md @@ -101,9 +101,7 @@ The dashboard groups open non-draft pull requests by who is expected to act next - **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, including in the draft section. For Copilot-authored PRs, this is the human assignee or first-commit committer when available. The login is followed by at most one badge, checked in this order: - ๐Ÿค– bot account - - ๐Ÿ› ๏ธ member of one of the repository's `maintainer_teams` - - ๐Ÿ” member of one of the repository's `approver_teams` - - ๐Ÿท๏ธ member of one of the repository's `triager_teams` + - ๐Ÿ› ๏ธ approver or maintainer: a member of one of the repository's `approver_teams`, including members of their child teams such as the maintainers team - ๐Ÿ”ญ other member of the `open-telemetry` organization - ๐Ÿ†• first-time contributor to the repository - Organization membership and first-time status come from GitHub's author association for the PR, so they are not shown when a Copilot-authored PR is attributed to its human assignee or committer. @@ -150,8 +148,6 @@ Open a pull request that adds your repository to [`.github/scripts/pull-request- { "name": "example-repo", "approver_teams": ["example-approvers"], - "maintainer_teams": ["example-maintainers"], - "triager_teams": ["example-triagers"], "required_approvals": 1, "non_blocking_check_patterns": [ "markdown-link-check / link-check", @@ -172,9 +168,7 @@ Fields: | Field | Required | Description | | ----- | -------- | ----------- | | `name` | yes | Name of the repository under `open-telemetry`. | -| `approver_teams` | yes | GitHub team slugs whose members count as approvers. Members are also shown with the ๐Ÿ” approver badge in the dashboard's Author column. | -| `maintainer_teams` | no | GitHub team slugs whose members are shown with the ๐Ÿ› ๏ธ maintainer badge in the dashboard's Author column. Display only; it does not affect routing. Defaults to `[]`, which shows no maintainer badges. | -| `triager_teams` | no | GitHub team slugs whose members are shown with the ๐Ÿท๏ธ triager badge in the dashboard's Author column. Display only; it does not affect routing. Defaults to `[]`, which shows no triager badges. | +| `approver_teams` | yes | GitHub team slugs whose members count as approvers. Members, including those of child teams such as the maintainers team, are also shown with the ๐Ÿ› ๏ธ badge in the dashboard's Author column. | | `required_approvals` | no | Number of active approvals required for an open PR to be marked ready to merge. An approval stops counting while that reviewer has a pending re-review request. 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. |