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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 18 additions & 14 deletions .github/scripts/pull-request-dashboard/publish_dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,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 render_pr_tables
from render import ISSUE_BODY_MAX_CHARS, render_pr_tables
from state import (
dashboard_markdown_path,
load_dashboard_state_cache,
Expand All @@ -40,8 +40,6 @@
DASHBOARD_LABEL = "dashboard"
DASHBOARD_LABEL_COLOR = "cfd3d7"
DASHBOARD_LABEL_DESCRIPTION = "Pull request dashboard"
ISSUE_BODY_MAX_CHARS = 65536
LARGE_REPO_MAX_ROWS_PER_SECTION = 100


def _parse_string_list_json(raw: str, source: str) -> list[str]:
Expand Down Expand Up @@ -225,7 +223,6 @@ def add_draft_since(repo: str, prs: list[dict[str, Any]]) -> None:

def render_dashboard_markdown(
repo: str,
large_repo: bool,
labels_to_display: list[str] | None = None,
approver_teams: list[str] | None = None,
) -> Path:
Expand All @@ -239,16 +236,26 @@ def render_dashboard_markdown(
else ()
)
prs = list_open_prs(repo)
if not large_repo:
add_draft_authors(repo, prs)
add_draft_since(repo, prs)
open_non_draft_pr_numbers = {p["number"] for p in prs if not p.get("isDraft")}
results = results_from_dashboard_state(dashboard_state, open_non_draft_pr_numbers)
prs = publishable_prs(prs, results)
skip_drafts = False
if any(pr.get("isDraft") for pr in prs):
non_draft_md = render_pr_tables(
[pr for pr in prs if not pr.get("isDraft")],
results,
max_chars=None,
labels_to_display=labels_to_display,
approver_logins=approver_logins,
)
skip_drafts = len(non_draft_md) >= ISSUE_BODY_MAX_CHARS
if not skip_drafts:
add_draft_authors(repo, prs)
add_draft_since(repo, prs)
md = render_pr_tables(
publishable_prs(prs, results),
prs,
results,
max_rows_per_section=LARGE_REPO_MAX_ROWS_PER_SECTION if large_repo else None,
skip_drafts=large_repo,
skip_drafts=skip_drafts,
labels_to_display=labels_to_display,
approver_logins=approver_logins,
)
Expand All @@ -271,7 +278,6 @@ def render_dashboard_markdown(
def publish_accepted_dashboard(
repo: str,
state_branch_name: str,
large_repo: bool,
labels_to_display: list[str] | None = None,
approver_teams: list[str] | None = None,
) -> None:
Expand All @@ -283,7 +289,6 @@ def publish_accepted_dashboard(
repo,
render_dashboard_markdown(
repo,
large_repo,
labels_to_display,
approver_teams,
),
Expand All @@ -301,7 +306,7 @@ def main() -> int:
parser.add_argument(
"--large-repo",
action="store_true",
help="apply large-repo rendering presets",
help="deprecated compatibility flag; dashboard sizing is always automatic",
)
parser.add_argument(
"--labels-to-display-json",
Expand All @@ -314,7 +319,6 @@ def main() -> int:
publish_accepted_dashboard(
repo,
args.state_branch,
args.large_repo,
parse_labels_to_display_json(args.labels_to_display_json),
parse_approver_teams_json(os.environ.get(APPROVER_TEAMS_ENV) or "[]"),
)
Expand Down
197 changes: 138 additions & 59 deletions .github/scripts/pull-request-dashboard/render.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -26,17 +26,45 @@
)


def _limit_rows(rows: list[Any], max_rows: int | None) -> tuple[list[Any], int]:
if max_rows is None or max_rows <= 0 or len(rows) <= max_rows:
return rows, 0
return rows[:max_rows], len(rows) - max_rows
ISSUE_BODY_MAX_CHARS = 65536


def _truncation_note(count: int) -> str:
plural = "PR" if count == 1 else "PRs"
return f"_More {count} {plural} not shown_"


@dataclass
class _PrSection:
heading: str
table_header: tuple[str, str]
rows: list[str]
omitted: int = 0

def lines(self) -> list[str]:
lines = [self.heading, ""]
if self.rows:
lines.extend(self.table_header)
lines.extend(self.rows)
lines.append("")
if self.omitted:
lines.extend([_truncation_note(self.omitted), ""])
return lines

def trim_last_row(self) -> int:
previous_footer_chars = (
len(_truncation_note(self.omitted)) + 2 if self.omitted else 0
)
removed_chars = len(self.rows.pop()) + 1
self.omitted += 1
if not self.rows:
removed_chars += sum(len(line) + 1 for line in self.table_header) + 1
return (
removed_chars + previous_footer_chars
- len(_truncation_note(self.omitted)) - 2
)


def _pr_cell_text(
pr: dict[str, Any],
labels_to_display: list[str] | None = None,
Expand Down Expand Up @@ -105,20 +133,24 @@ def author_cell_text(
return f"{author}&nbsp;{badge}" if badge else author


def render_draft_pr_section(
def _draft_pr_section(
prs: list[dict[str, Any]],
max_rows_per_section: int | None = None,
labels_to_display: list[str] | None = None,
approver_logins: frozenset[str] = frozenset(),
) -> list[str]:
skip_drafts: bool = False,
) -> _PrSection | None:
drafts = [p for p in prs if p.get("isDraft")]
if not drafts:
return []
return None
section = _PrSection(
"## Draft pull requests",
("| PR | Author | Draft age |", "|---|---|:---:|"),
[],
)
if skip_drafts:
section.omitted = len(drafts)
return section
drafts.sort(key=lambda p: p.get("draftSince") or p.get("createdAt") or "")
drafts, truncated = _limit_rows(drafts, max_rows_per_section)
lines = ["## Draft pull requests", ""]
lines.append("| PR | Author | Draft age |")
lines.append("|---|---|:---:|")
for pr in drafts:
author = author_cell_text(
pr,
Expand All @@ -130,14 +162,19 @@ def render_draft_pr_section(
)
# GitHub autolinks same-repo PR numbers; avoid full URLs so large
# dashboards can show more PRs before hitting the issue body limit.
lines.append(
section.rows.append(
f"| {_pr_cell_text(pr, labels_to_display)} | {author} | {draft_age} |"
)
lines.append("")
if truncated:
lines.append(_truncation_note(truncated))
lines.append("")
return lines
return section


def render_draft_pr_section(
prs: list[dict[str, Any]],
labels_to_display: list[str] | None = None,
approver_logins: frozenset[str] = frozenset(),
) -> list[str]:
section = _draft_pr_section(prs, labels_to_display, approver_logins)
return section.lines() if section is not None else []


def ci_cell(facts: DashboardFacts) -> str:
Expand Down Expand Up @@ -275,25 +312,18 @@ def reviewers_cell_text(facts: DashboardFacts) -> str:
def render_pr_tables(
prs: list[dict[str, Any]],
results: Iterable[StoredDashboardResult],
max_rows_per_section: int | None = None,
max_chars: int | None = ISSUE_BODY_MAX_CHARS,
skip_drafts: bool = False,
labels_to_display: list[str] | None = None,
approver_logins: frozenset[str] = frozenset(),
) -> str:
if max_chars is not None and max_chars <= 0:
raise ValueError("max_chars must be positive")
results_by_number = {
result.pr_number: result
for result in results
}
source_url = "https://github.com/open-telemetry/shared-workflows/blob/main/.github/scripts/pull-request-dashboard/dashboard.py"
draft_note = (
"Draft PRs are omitted to keep this dashboard concise."
if skip_drafts
else "Draft PRs are listed separately."
)
grouping_note = (
f"Open non-draft PRs grouped by who is expected to act next. {draft_note} The grouping is "
f"partly performed by an LLM ([source]({source_url})) and could contain mistakes."
)
author_note = (
"Author column: 🆕 first-time contributor · 🔭 open-telemetry member · "
"🛠️ approver or maintainer · 🤖 bot."
Expand All @@ -308,18 +338,6 @@ def render_pr_tables(
"CI column: ✅ passing · ⏳ running · ❌ failing · "
"🔐 workflow action required."
)
out: list[str] = [
"> [!NOTE]",
f"> {grouping_note}",
">",
f"> {author_note}",
">",
f"> {reviewers_note}",
">",
f"> {ci_note}",
"",
]

by_route: dict[str, list[dict[str, Any]]] = {}
for pr in prs:
if pr.get("isDraft"):
Expand All @@ -340,16 +358,20 @@ def row_sort_key(pr: dict[str, Any]) -> tuple[int, int]:
activity = age_seconds(facts)
return (activity if activity is not None else -1, pr["number"])

sections: list[_PrSection] = []
for route in ROUTE_ORDER:
rows = by_route.get(route) or []
if not rows:
continue
rows.sort(key=row_sort_key, reverse=True)
rows, truncated = _limit_rows(rows, max_rows_per_section)
out.append(f"## {route_label(route)}")
out.append("")
out.append("| PR | Author | Reviewers | CI | Conflicts | Age |")
out.append("|---|---|---|:---:|:---:|:---:|")
section = _PrSection(
f"## {route_label(route)}",
(
"| PR | Author | Reviewers | CI | Conflicts | Age |",
"|---|---|---|:---:|:---:|:---:|",
),
[],
)
for pr in rows:
number = pr["number"]
res = results_by_number.get(number)
Expand All @@ -364,20 +386,77 @@ def row_sort_key(pr: dict[str, Any]) -> tuple[int, int]:
# GitHub autolinks same-repo PR numbers; avoid full URLs so large
# dashboards can show more PRs before hitting the issue body limit.
pr_cell = _pr_cell_text(pr, labels_to_display)
out.append(
section.rows.append(
f"| {pr_cell} | {author} | {reviewers_cell} | {ci_cell(facts)} | "
f"{conflicts_cell(facts)} | {activity_cell} |"
)
out.append("")
sections.append(section)

draft_section = _draft_pr_section(
prs, labels_to_display, approver_logins, skip_drafts,
)
if draft_section is not None:
sections.append(draft_section)
truncated = any(section.omitted for section in sections)

def intro_lines() -> list[str]:
draft_note = (
"All drafts are omitted to fit GitHub's issue-body limit."
if draft_section is not None and not draft_section.rows
else "Draft PRs are listed separately."
)
grouping_note = (
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."
)
lines = [
"> [!NOTE]",
f"> {grouping_note}",
">",
f"> {author_note}",
">",
f"> {reviewers_note}",
">",
f"> {ci_note}",
"",
]
if truncated:
out.append(_truncation_note(truncated))
out.append("")

if not skip_drafts:
out.extend(render_draft_pr_section(
prs,
max_rows_per_section,
labels_to_display,
approver_logins,
))
return "\n".join(out) + "\n"
lines.extend([
"> [!WARNING]",
"> This dashboard is truncated to fit GitHub's issue-body limit. "
"Sections are trimmed from bottom to top. "
"The longest-waiting PRs in each section are kept.",
"",
])
return lines

def markdown() -> str:
lines = intro_lines()
for section in sections:
lines.extend(section.lines())
return "\n".join(lines) + "\n"

body = markdown()
if max_chars is None or len(body) <= max_chars:
return body

intro_chars = sum(len(line) + 1 for line in intro_lines())
truncated = True
new_intro_chars = sum(len(line) + 1 for line in intro_lines())
body_chars = len(body) + new_intro_chars - intro_chars
intro_chars = new_intro_chars
for section in reversed(sections):
while section.rows and body_chars > max_chars:
body_chars -= section.trim_last_row()
new_intro_chars = sum(len(line) + 1 for line in intro_lines())
body_chars += new_intro_chars - intro_chars
intro_chars = new_intro_chars
if body_chars <= max_chars:
break

body = markdown()
if len(body) > max_chars:
raise RuntimeError(
f"dashboard headings and omission notices exceed the {max_chars}-character limit"
)
return body
Loading
Loading