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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/scripts/pull-request-dashboard/github_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,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)
]
Expand Down
49 changes: 45 additions & 4 deletions .github/scripts/pull-request-dashboard/publish_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path
from typing import Any
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand All @@ -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:
Expand All @@ -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


Expand Down
42 changes: 36 additions & 6 deletions .github/scripts/pull-request-dashboard/render.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"<code>{_md_escape(label)}</code>" 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:
Expand All @@ -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))
Expand Down Expand Up @@ -206,6 +232,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"
Expand Down Expand Up @@ -258,15 +285,14 @@ 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 {})
reviewers_cell = reviewers_cell_text(facts)
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} |"
Expand All @@ -277,7 +303,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("")
Expand Down
9 changes: 9 additions & 0 deletions .github/scripts/pull-request-dashboard/test_github_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,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)
]
Expand All @@ -40,9 +47,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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,35 @@

from contextlib import nullcontext
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch

import publish_dashboard


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},
Expand Down Expand Up @@ -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()
unittest.main()
Loading