diff --git a/.github/scripts/pull-request-dashboard/github_cli.py b/.github/scripts/pull-request-dashboard/github_cli.py index efe85e0af3..3548be626b 100644 --- a/.github/scripts/pull-request-dashboard/github_cli.py +++ b/.github/scripts/pull-request-dashboard/github_cli.py @@ -541,6 +541,15 @@ def list_open_prs(repo: str) -> list[dict[str, Any]]: "isDraft": pull.get("draft", False), "updatedAt": pull.get("updated_at"), "url": pull.get("html_url"), + "labels": [ + label["name"] + for label in pull.get("labels") or [] + if ( + isinstance(label, dict) + and isinstance(label.get("name"), str) + and label["name"].strip() + ) + ], } for pull in _list_open_pulls(repo) ] diff --git a/.github/scripts/pull-request-dashboard/publish_dashboard.py b/.github/scripts/pull-request-dashboard/publish_dashboard.py index 55de00bc82..b13b7622bb 100644 --- a/.github/scripts/pull-request-dashboard/publish_dashboard.py +++ b/.github/scripts/pull-request-dashboard/publish_dashboard.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import json import sys from pathlib import Path from typing import Any @@ -27,6 +28,23 @@ LARGE_REPO_MAX_ROWS_PER_SECTION = 100 +def parse_labels_to_display_json(raw: str) -> list[str]: + try: + labels_to_display = 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}" + ) 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 + ): + raise RuntimeError( + "--labels-to-display-json must be a JSON array of non-blank strings" + ) + return labels_to_display + + # GraphQL is used instead of the REST `/repos/{repo}/issues` list endpoint # because that endpoint has been observed to sometimes omit the existing # open, `dashboard`-labeled issue from its results (across every sort, page @@ -149,7 +167,11 @@ def publishable_prs( ] -def render_dashboard_markdown(repo: str, large_repo: bool) -> Path: +def render_dashboard_markdown( + repo: str, + large_repo: bool, + labels_to_display: list[str] | None = None, +) -> Path: dashboard_state = load_dashboard_state_cache() if dashboard_state is None: raise RuntimeError("dashboard state not found") @@ -162,6 +184,7 @@ def render_dashboard_markdown(repo: str, large_repo: bool) -> Path: results, 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, ) output_path = dashboard_markdown_path() output_path.parent.mkdir(parents=True, exist_ok=True) @@ -179,12 +202,20 @@ def render_dashboard_markdown(repo: str, large_repo: bool) -> Path: return output_path -def publish_accepted_dashboard(repo: str, state_branch_name: str, large_repo: bool) -> None: +def publish_accepted_dashboard( + repo: str, + state_branch_name: str, + large_repo: bool, + labels_to_display: list[str] | None = None, +) -> None: with state_branch.accepted_state_dir(state_branch_name, required=True) as state_dir: if state_dir is None: raise RuntimeError(f"required state branch not found: {state_branch_name}") set_state_dir(state_dir / repo_state_key(repo)) - publish_dashboard(repo, render_dashboard_markdown(repo, large_repo)) + publish_dashboard( + repo, + render_dashboard_markdown(repo, large_repo, labels_to_display), + ) def main() -> int: @@ -200,10 +231,20 @@ def main() -> int: action="store_true", help="apply large-repo rendering presets", ) + parser.add_argument( + "--labels-to-display-json", + default="[]", + help="JSON array of label name patterns to display", + ) args = parser.parse_args() repo = normalize_repo(args.repo) if args.repo else detect_repo() - publish_accepted_dashboard(repo, args.state_branch, args.large_repo) + publish_accepted_dashboard( + repo, + args.state_branch, + args.large_repo, + parse_labels_to_display_json(args.labels_to_display_json), + ) return 0 diff --git a/.github/scripts/pull-request-dashboard/render.py b/.github/scripts/pull-request-dashboard/render.py index 40f33ebc9f..0b5f8e097d 100644 --- a/.github/scripts/pull-request-dashboard/render.py +++ b/.github/scripts/pull-request-dashboard/render.py @@ -1,6 +1,7 @@ from __future__ import annotations from datetime import datetime +from fnmatch import fnmatchcase from typing import Any from route_presentation import ROUTE_ORDER, route_label @@ -34,9 +35,36 @@ def _truncation_note(count: int) -> str: return f"_More {count} {plural} not shown_" +def _pr_cell_text( + pr: dict[str, Any], + labels_to_display: list[str] | None = None, +) -> str: + number = pr["number"] + title = _md_escape(pr.get("title", "")) + pr_cell = f"#{number} {title}" + if not labels_to_display: + return pr_cell + + matched_labels: list[str] = [] + seen: set[str] = set() + for label in pr.get("labels") or []: + if not isinstance(label, str) or not label or label in seen: + continue + if any(fnmatchcase(label, pattern) for pattern in labels_to_display): + matched_labels.append(label) + seen.add(label) + if not matched_labels: + return pr_cell + rendered_labels = " · ".join( + f"{_md_escape(label)}" for label in matched_labels + ) + return f"{pr_cell} · {rendered_labels}" + + def render_draft_pr_section( prs: list[dict[str, Any]], max_rows_per_section: int | None = None, + labels_to_display: list[str] | None = None, ) -> list[str]: drafts = [p for p in prs if p.get("isDraft")] if not drafts: @@ -47,13 +75,11 @@ def render_draft_pr_section( lines.append("| PR | Author | Updated |") lines.append("|---|---|:---:|") for pr in drafts: - number = pr["number"] - title = _md_escape(pr.get("title", "")) author = actor_login(pr.get("author") or {}) updated = activity_age(parse_ts(pr.get("updatedAt") or "")) # GitHub autolinks same-repo PR numbers; avoid full URLs so large # dashboards can show more PRs before hitting the issue body limit. - lines.append(f"| #{number} {title} | {author} | {updated} |") + lines.append(f"| {_pr_cell_text(pr, labels_to_display)} | {author} | {updated} |") lines.append("") if truncated: lines.append(_truncation_note(truncated)) @@ -236,6 +262,7 @@ def render_pr_tables( results: dict[int, dict[str, Any]], max_rows_per_section: int | None = None, skip_drafts: bool = False, + labels_to_display: list[str] | None = None, ) -> str: source_url = "https://github.com/open-telemetry/shared-workflows/blob/main/.github/scripts/pull-request-dashboard/dashboard.py" refresh_url = "https://github.com/open-telemetry/shared-workflows/actions/workflows/pull-request-dashboard.yml" @@ -288,7 +315,6 @@ def row_sort_key(pr: dict[str, Any]) -> tuple[int, int]: out.append("|---|---|---|:---:|:---:|:---:|") for pr in rows: number = pr["number"] - title = _md_escape(pr.get("title", "")) res = results.get(number) or {} facts = res.get("facts") or {} author = facts.get("author") or actor_login(pr.get("author") or {}) @@ -296,7 +322,7 @@ def row_sort_key(pr: dict[str, Any]) -> tuple[int, int]: activity_cell = age_cell(facts) # GitHub autolinks same-repo PR numbers; avoid full URLs so large # dashboards can show more PRs before hitting the issue body limit. - pr_cell = f"#{number} {title}" + pr_cell = _pr_cell_text(pr, labels_to_display) out.append( f"| {pr_cell} | {author} | {reviewers_cell} | {ci_cell(facts)} | " f"{conflicts_cell(facts)} | {activity_cell} |" @@ -307,7 +333,11 @@ def row_sort_key(pr: dict[str, Any]) -> tuple[int, int]: out.append("") if not skip_drafts: - out.extend(render_draft_pr_section(prs, max_rows_per_section)) + out.extend(render_draft_pr_section( + prs, + max_rows_per_section, + labels_to_display, + )) out.extend(render_diagnostics_section(results, max_rows_per_section)) out.append(f"_Approvers may [force a refresh]({refresh_url})._") out.append("") diff --git a/.github/scripts/pull-request-dashboard/test_github_cli.py b/.github/scripts/pull-request-dashboard/test_github_cli.py index d52ee979d8..55f8f2dbf1 100644 --- a/.github/scripts/pull-request-dashboard/test_github_cli.py +++ b/.github/scripts/pull-request-dashboard/test_github_cli.py @@ -161,6 +161,13 @@ def test_list_open_prs_uses_paginated_rest_api(self, gh_api) -> None: "draft": number == 501, "updated_at": "2026-07-17T00:00:00Z", "html_url": f"https://example.test/pull/{number}", + "labels": [ + {"name": "size/L"}, + {"name": ""}, + {"name": " "}, + {"color": "ffffff"}, + None, + ] if number == 501 else None, } for number in range(1, 502) ] @@ -176,9 +183,11 @@ def test_list_open_prs_uses_paginated_rest_api(self, gh_api) -> None: "isDraft": True, "updatedAt": "2026-07-17T00:00:00Z", "url": "https://example.test/pull/501", + "labels": ["size/L"], }, prs[-1], ) + self.assertEqual([], prs[0]["labels"]) gh_api.assert_called_once_with( "/repos/open-telemetry/example/pulls?state=open&per_page=100", paginate=True, diff --git a/.github/scripts/pull-request-dashboard/test_publish_dashboard.py b/.github/scripts/pull-request-dashboard/test_publish_dashboard.py index 612890e150..35550e1479 100644 --- a/.github/scripts/pull-request-dashboard/test_publish_dashboard.py +++ b/.github/scripts/pull-request-dashboard/test_publish_dashboard.py @@ -2,6 +2,7 @@ from contextlib import nullcontext from pathlib import Path +import tempfile import unittest from unittest.mock import patch @@ -9,6 +10,27 @@ class PublishablePrsTest(unittest.TestCase): + def test_parses_labels_to_display_json(self) -> None: + self.assertEqual( + ["size/*", "breaking change"], + publish_dashboard.parse_labels_to_display_json( + '["size/*", "breaking change"]' + ), + ) + + def test_rejects_invalid_labels_to_display_json(self) -> None: + invalid_values = ( + ("{", "must be valid JSON"), + ("{}", "must be a JSON array of non-blank strings"), + ('["size/*", 1]', "must be a JSON array of non-blank strings"), + ('[""]', "must be a JSON array of non-blank strings"), + ('[" "]', "must be a JSON array of non-blank strings"), + ) + for raw, message in invalid_values: + with self.subTest(raw=raw): + with self.assertRaisesRegex(RuntimeError, message): + publish_dashboard.parse_labels_to_display_json(raw) + def test_omits_uncached_non_draft_prs_and_retains_drafts(self) -> None: prs = [ {"number": 1, "isDraft": False}, @@ -40,13 +62,69 @@ def test_publishes_from_read_only_accepted_state( "open-telemetry/example", "state-branch", True, + ["size/*"], ) accepted_state_dir.assert_called_once_with("state-branch", required=True) set_state_dir.assert_called_once_with(checkout_dir / "example") - render_dashboard_markdown.assert_called_once_with("open-telemetry/example", True) + render_dashboard_markdown.assert_called_once_with( + "open-telemetry/example", + True, + ["size/*"], + ) publish.assert_called_once_with("open-telemetry/example", Path("dashboard.md")) + def test_passes_labels_to_renderer(self) -> None: + prs = [ + { + "number": 1, + "isDraft": False, + "labels": ["size/L"], + }, + ] + results = {1: {"route": "unknown"}} + with tempfile.TemporaryDirectory() as tmp: + output_path = Path(tmp) / "dashboard.md" + with ( + patch.object( + publish_dashboard, + "load_dashboard_state_cache", + return_value={"prs": {}}, + ), + patch.object(publish_dashboard, "list_open_prs", return_value=prs), + patch.object( + publish_dashboard, + "results_from_dashboard_state", + return_value=results, + ), + patch.object( + publish_dashboard, + "render_pr_tables", + return_value="dashboard\n", + ) as render_pr_tables, + patch.object( + publish_dashboard, + "dashboard_markdown_path", + return_value=output_path, + ), + ): + self.assertEqual( + output_path, + publish_dashboard.render_dashboard_markdown( + "open-telemetry/example", + False, + ["size/*"], + ), + ) + + render_pr_tables.assert_called_once_with( + prs, + results, + max_rows_per_section=None, + skip_drafts=False, + labels_to_display=["size/*"], + ) + if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() diff --git a/.github/scripts/pull-request-dashboard/test_render.py b/.github/scripts/pull-request-dashboard/test_render.py index 03a8b19014..8529aae539 100644 --- a/.github/scripts/pull-request-dashboard/test_render.py +++ b/.github/scripts/pull-request-dashboard/test_render.py @@ -122,6 +122,86 @@ def test_reviewer_legend_includes_top_level_feedback(self) -> None: markdown, ) + def test_renders_matching_labels_inline_without_filtering_prs(self) -> None: + prs = [ + { + "number": 123, + "title": "Feature", + "author": {"login": "author"}, + "isDraft": False, + "labels": [ + "size/L", + "breaking change", + "documentation", + "size/L", + "Size/XL", + "danger | [x] & @owner", + ], + }, + { + "number": 124, + "title": "Documentation", + "author": {"login": "author"}, + "isDraft": False, + "labels": ["documentation"], + }, + ] + results = { + 123: {"route": "unknown", "facts": {}}, + 124: {"route": "unknown", "facts": {}}, + } + + markdown = render_pr_tables( + prs, + results, + labels_to_display=["size/*", "size/L", "breaking change", "danger*"], + ) + + self.assertIn( + "#123 Feature · size/L · breaking change · " + "danger \\| \\[x\\] <tag> & @owner", + markdown, + ) + self.assertEqual(1, markdown.count("size/L")) + self.assertNotIn("Size/XL", markdown) + self.assertNotIn("documentation", markdown) + self.assertIn("#124 Documentation", markdown) + + def test_renders_matching_labels_on_draft_prs(self) -> None: + markdown = render_pr_tables( + [ + { + "number": 125, + "title": "Work in progress", + "author": {"login": "author"}, + "isDraft": True, + "labels": ["size/S"], + }, + ], + {}, + labels_to_display=["size/*"], + ) + + self.assertIn("| #125 Work in progress · size/S | author |", markdown) + + def test_omits_labels_when_none_are_configured(self) -> None: + prs = [ + { + "number": 126, + "title": "Feature", + "author": {"login": "author"}, + "isDraft": False, + "labels": ["size/L"], + }, + ] + results = {126: {"route": "unknown", "facts": {}}} + + self.assertEqual( + render_pr_tables(prs, results), + render_pr_tables(prs, results, labels_to_display=[]), + ) + self.assertNotIn("", render_pr_tables(prs, results)) + if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() diff --git a/.github/workflows/pull-request-dashboard-repo.yml b/.github/workflows/pull-request-dashboard-repo.yml index 5f1854a87b..c31b3df6a6 100644 --- a/.github/workflows/pull-request-dashboard-repo.yml +++ b/.github/workflows/pull-request-dashboard-repo.yml @@ -30,6 +30,10 @@ on: required: false default: false type: boolean + labels_to_display_json: + required: false + default: "[]" + type: string secrets: PR_DASHBOARD_PRIVATE_KEY: required: true @@ -272,12 +276,14 @@ jobs: GH_TOKEN: ${{ steps.dashboard-token.outputs.token }} REPO_NAME: ${{ inputs.repository }} LARGE_REPO: ${{ inputs.large_repo }} + LABELS_TO_DISPLAY_JSON: ${{ inputs.labels_to_display_json }} run: | set -euo pipefail state_branch="${DASHBOARD_STATE_BRANCH_PREFIX}/${REPO_NAME}" publish_args=( --state-branch "$state_branch" --repo "$REPO_NAME" + --labels-to-display-json "$LABELS_TO_DISPLAY_JSON" ) if [[ "${LARGE_REPO:-false}" == "true" ]]; then publish_args+=(--large-repo) diff --git a/.github/workflows/pull-request-dashboard.yml b/.github/workflows/pull-request-dashboard.yml index 920ad9ae09..b231897e15 100644 --- a/.github/workflows/pull-request-dashboard.yml +++ b/.github/workflows/pull-request-dashboard.yml @@ -150,6 +150,7 @@ jobs: slack_channel: ${{ matrix.slack_channel }} slack_user_mapping_json: ${{ toJSON(matrix.slack_user_mapping || fromJSON('{}')) }} large_repo: ${{ matrix.large_repo || false }} + labels_to_display_json: ${{ toJSON(matrix.labels_to_display || fromJSON('[]')) }} secrets: PR_DASHBOARD_PRIVATE_KEY: ${{ secrets.PR_DASHBOARD_PRIVATE_KEY }} COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} diff --git a/pull-request-dashboard/README.md b/pull-request-dashboard/README.md index 209e491c3c..f6aa277400 100644 --- a/pull-request-dashboard/README.md +++ b/pull-request-dashboard/README.md @@ -12,7 +12,7 @@ The classification cache reuses prior results for unchanged review threads, mini 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*, *Waiting on external*). 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. The number autolinks to the PR on GitHub. +- **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** — GitHub login of the PR author. - **Reviewers** — Reviewers who have engaged with the PR, each annotated with one or more icons: - ✅ approved @@ -48,6 +48,7 @@ Open a pull request that adds your repository to [`.github/scripts/pull-request- "name": "example-repo", "approver_teams": ["example-approvers"], "required_approvals": 1, + "labels_to_display": ["size/*", "breaking change"], "slack_channel": "#example-maintainers", "slack_user_mapping": { "octocat": "U0123456789" @@ -63,10 +64,13 @@ Fields: | `name` | yes | Name of the repository under `open-telemetry`. | | `approver_teams` | yes | GitHub team slugs whose members count as approvers. | | `required_approvals` | no | Number of approvals required for an open PR to be marked ready to merge. Defaults to `1`. | +| `labels_to_display` | no | Case-sensitive shell-style label name patterns to display inline after PR titles. Exact names such as `breaking change` and wildcard patterns such as `size/*` are supported. Defaults to `[]`, which displays no labels. | | `slack_channel` | no | Slack channel for notifications. Omit to skip Slack processing for this repository. | | `slack_user_mapping` | no | Map of GitHub login to Slack user ID for at-mentions. | | `large_repo` | no | If `true`, apply rendering presets that keep the dashboard body under GitHub's 65,536-character issue-body limit: cap each section (each *Waiting on …* table, the *Draft pull requests* table, and the *Diagnostics* block) at 100 rows, and omit the *Draft pull requests* section entirely. Truncated sections get a `_More X PRs not shown_` footer. Defaults to `false` (no cap, drafts shown). Enable this for very large repos with hundreds of PRs. | +`labels_to_display` only controls which labels are shown. It does not filter pull requests or affect dashboard routing, notifications, or status comments. All matching labels are displayed in the order returned by GitHub; a label matching more than one configured pattern is shown once. + Ask a maintainer or admin to add the repository under [Repository access](https://github.com/organizations/open-telemetry/settings/installations/133550497). Once the PR is merged, the dashboard will pick up your repository on its next hourly backfill run. To run it sooner, see [Manual backfill run](#manual-backfill-run). The dashboard issue is discovered dynamically in your repository by the `dashboard` label and `Pull Request Dashboard` title; if it does not exist, the publish step creates the label and issue.