Skip to content
2 changes: 1 addition & 1 deletion .github/scripts/pull-request-dashboard/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ diagnostics keep typed classification results and freeze only the source
discussion records.

`state.py` owns the JSON boundary. Its dashboard facts, stored-result, and state
codecs translate the immutable contracts to the version 15
codecs translate the immutable contracts to the version 16
`dashboard-state.json` shape. Malformed pull request entries are discarded
individually, so one bad entry does not prevent valid entries from loading.

Expand Down
14 changes: 13 additions & 1 deletion .github/scripts/pull-request-dashboard/RATIONALE.md
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,9 @@ the implementation understandable and operationally cheap.
CI failure, including when review feedback also needs author action.
Repository-configured `non_blocking_check_patterns` identify failed optional
checks in a note alongside this action, without changing required-check facts
or routing.
or routing. An optional check in `ACTION_REQUIRED` is not included in that
failure-only note: it does not block merge and has neither failed nor been
cancelled.
- A merge conflict does not decide who should act. Discussion, CI, and approval
routing still identify the owner, while the conflict remains visible as a
separate merge blocker. This lets maintainers handle routine conflicts, such
Expand All @@ -301,6 +303,16 @@ the implementation understandable and operationally cheap.
author is never held, because a failing check or new author-owned discussion
is evidence the gates cannot undo. Unavailable check results hold the handoff
for the same reason a pending one does, and resolve on a later run.
- An `ACTION_REQUIRED` check from the GitHub Actions app with an attached
workflow run is the exception to that hold. That metadata identifies a
workflow approval: a final, reported result that still blocks merge because
someone with repository write access must unblock the workflow. It remains
unsettled and appears as 🔐 in the CI column, but it does not hold routing
with the author. The PR routes to reviewers while approvals are outstanding,
then to maintainers once it has enough approvals. `ACTION_REQUIRED` from
another app, or without a workflow run, has unknown ownership and routes
conservatively as a failure. A genuine or unknown-owner required-check
failure alongside a workflow approval also routes to the author.
- A held PR is presented as waiting on its author rather than on the robot it
is waiting for, so a separate route would add a section that nobody is
expected to act on. What it waits for is named in the columns instead: the CI
Expand Down
2 changes: 1 addition & 1 deletion .github/scripts/pull-request-dashboard/copilot_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ def stale_request_reason(
failing = [
check
for check in checks
if check.bucket in ("fail", "cancel")
if check.bucket in ("fail", "cancel", "action_required")
]
if failing:
return f"required checks are failing: {named_checks(failing)}"
Expand Down
11 changes: 8 additions & 3 deletions .github/scripts/pull-request-dashboard/dashboard.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@
fetched.
ci_failing_since str (iso) Earliest completion time among
current required failures.
ci_maintainer_action_required_count
int Required checks that need a
maintainer-owned permission
action; absent when checks
could not be fetched.
ci_pending_count int Merge-blocking checks only;
absent when checks could not be
fetched, and excludes required
Expand Down Expand Up @@ -193,9 +198,9 @@
Copilot review are still
outstanding.
required_checks_settled bool Every required check has
reported on the current head,
so the computed route is not
provisional.
reported a final result, and
none is waiting for
maintainer action.
route_held_since str (iso) When the gates first kept this
PR off its reviewers on this
head. Cleared once every gate
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ class DashboardFacts:
last_approver_activity_at: str = ""
ci_failing_count: int | None = None
ci_failing_since: str | None = None
ci_maintainer_action_required_count: int | None = None
ci_pending_count: int | None = None
non_blocking_check_failures: tuple[str, ...] = ()
copilot_first_review_missing_since: str | None = None
Expand Down
32 changes: 26 additions & 6 deletions .github/scripts/pull-request-dashboard/github_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
DEFAULT_OWNER = "open-telemetry"
COPILOT_REVIEWER_BOT_ID = "BOT_kgDOCnlnWA"
CODE_SCANNING_APP_ID = 57789 # github-advanced-security
GITHUB_ACTIONS_APP_ID = 15368


REQUEST_COPILOT_REVIEW_MUTATION = """
Expand Down Expand Up @@ -362,12 +363,24 @@ def fetch_pr_reviews(owner: str, repo_name: str, number: int) -> list[dict[str,
"""


def check_bucket(state: str) -> str:
def check_bucket(
state: str,
*,
integration_id: int | None = None,
workflow_run_id: int | None = None,
) -> str:
if state == "SUCCESS":
return "pass"
if state in ("SKIPPED", "NEUTRAL"):
return "skipping"
if state in ("ERROR", "FAILURE", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"):
if state == "ACTION_REQUIRED":
if (
integration_id == GITHUB_ACTIONS_APP_ID
and workflow_run_id is not None
):
return "maintainer_action_required"
return "action_required"
Comment thread
trask marked this conversation as resolved.
if state in ("ERROR", "FAILURE", "TIMED_OUT", "STARTUP_FAILURE"):
return "fail"
if state == "CANCELLED":
return "cancel"
Expand All @@ -380,6 +393,8 @@ def normalize_check(node: dict[str, Any]) -> dict[str, Any]:
app = suite.get("app") or {}
workflow_run = suite.get("workflowRun") or {}
workflow = workflow_run.get("workflow") or {}
integration_id = None if is_status else app.get("databaseId")
workflow_run_id = None if is_status else workflow_run.get("databaseId")
state = (
node.get("state")
if is_status
Expand All @@ -388,15 +403,19 @@ def normalize_check(node: dict[str, Any]) -> dict[str, Any]:
return {
"name": (node.get("context") if is_status else node.get("name")) or "",
"state": state,
"bucket": check_bucket(state),
"bucket": check_bucket(
state,
integration_id=integration_id,
workflow_run_id=workflow_run_id,
),
"workflow": workflow.get("name") or "",
"workflow_run_id": workflow_run.get("databaseId"),
"workflow_run_id": workflow_run_id,
"description": node.get("description") or "",
"link": (node.get("targetUrl") if is_status else node.get("detailsUrl")) or "",
"started_at": (node.get("createdAt") if is_status else node.get("startedAt")) or "",
"completed_at": (node.get("createdAt") if is_status else node.get("completedAt")) or "",
"check_run_id": None if is_status else check_run_id(node["url"]),
"integration_id": None if is_status else app.get("databaseId"),
"integration_id": integration_id,
"status_context": is_status,
}

Expand Down Expand Up @@ -488,7 +507,8 @@ def gh_pr_check_rollup(
"non_blocking_failures": [
check
for check, is_required in checks
if not is_required and check.get("bucket") in ("fail", "cancel")
if not is_required
and check.get("bucket") in ("fail", "cancel")
],
"code_scanning": [
check
Expand Down
34 changes: 33 additions & 1 deletion .github/scripts/pull-request-dashboard/pr_status_comment.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,18 @@ def author_body(
return [fallback_next_step]


def workflow_action_required_summary(count: int) -> str:
if count == 1:
return (
"1 required check needs action from someone with write access "
"to this repository."
)
return (
f"{count} required checks need action from someone with write access "
"to this repository."
)


def is_terminal_pr(pr: dict[str, Any]) -> bool:
return bool(pr.get("merged")) or (pr.get("state") or "").lower() == "closed"

Expand All @@ -235,6 +247,9 @@ def render_status_comment(
top_level_feedback_urls = facts.author_action_top_level_feedback_urls
feedback_count = len(review_thread_urls) + len(top_level_feedback_urls)
failing_count = facts.ci_failing_count or 0
maintainer_action_required_count = (
facts.ci_maintainer_action_required_count or 0
)
non_blocking_check_failures = facts.non_blocking_check_failures

override_route = ""
Expand Down Expand Up @@ -289,7 +304,14 @@ def render_status_comment(
body = (
["Resolve merge conflicts, then merge when ready."]
if conflicted and route is DashboardRoute.MAINTAINER
else [next_step]
else (
["Approve or otherwise unblock the required workflow checks."]
if (
route is DashboardRoute.MAINTAINER
and maintainer_action_required_count
)
else [next_step]
)
)
abandoned_gates = (
abandoned_gate_note(facts)
Expand Down Expand Up @@ -317,6 +339,16 @@ def render_status_comment(
body.extend(["", f"**{label}:** {names}"])
if conflicted and route is not DashboardRoute.MAINTAINER:
body.extend(["", "**Also blocked by:** Merge conflicts."])
if maintainer_action_required_count:
body.extend([
"",
(
"**Workflow action required:** "
+ workflow_action_required_summary(
maintainer_action_required_count
)
),
])

lines = [
STATUS_MARKER,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,13 +194,18 @@ def _compute_facts(
failing = [
check
for check in checks or ()
if check.bucket in ("fail", "cancel")
if check.bucket in ("fail", "cancel", "action_required")
]
pending = [
check
for check in checks or ()
if check.bucket == "pending"
]
maintainer_action_required = [
check
for check in checks or ()
if check.bucket == "maintainer_action_required"
]
failing_timestamps = [parse_ts(check.completed_at) for check in failing]
failing_timestamps = [ts for ts in failing_timestamps if ts is not None]
created_ts = parse_ts(pr.created_at)
Expand Down Expand Up @@ -280,6 +285,11 @@ def _compute_facts(
if failing_timestamps
else None
),
ci_maintainer_action_required_count=(
len(maintainer_action_required)
if checks is not None
else None
),
ci_pending_count=len(pending) if checks is not None else None,
non_blocking_check_failures=non_blocking_check_failures,
)
Expand Down
24 changes: 22 additions & 2 deletions .github/scripts/pull-request-dashboard/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,24 @@ def render_draft_pr_section(


def ci_cell(facts: DashboardFacts) -> str:
if facts.ci_failing_count is None and facts.ci_pending_count is None:
if (
facts.ci_failing_count is None
and facts.ci_maintainer_action_required_count is None
and facts.ci_pending_count is None
):
return "?"
if (facts.ci_failing_count or 0) > 0:
failing = (facts.ci_failing_count or 0) > 0
write_access_required = (
facts.ci_maintainer_action_required_count or 0
) > 0
if failing and write_access_required:
return "❌ 🔐"
if failing:
return "❌"
if (facts.ci_pending_count or 0) > 0 and write_access_required:
return "⏳ 🔐"
if write_access_required:
return "🔐"
if (facts.ci_pending_count or 0) > 0:
return "⏳"
return "✅"
Expand Down Expand Up @@ -237,11 +251,17 @@ def render_pr_tables(
"⏳ review pending · 💬 open review thread · 📌 top-level feedback needs author action · "
"🔴 changes requested."
)
ci_note = (
"CI column: ✅ passing · ⏳ running · ❌ failing · "
"🔐 workflow action required."
)
out: list[str] = [
"> [!NOTE]",
f"> {grouping_note}",
">",
f"> {reviewers_note}",
">",
f"> {ci_note}",
"",
]

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

from dashboard_contracts import DashboardFacts, DashboardRoute
from utils import required_checks_unreported


ROUTE_PRESENTATION = {
Expand Down Expand Up @@ -73,7 +74,7 @@ def unreported_gate_phrase(facts: DashboardFacts) -> str:
# findings holds it but has reported, so naming it would send the reader
# after a gate that arrived.
gates = []
if not facts.required_checks_settled:
if required_checks_unreported(facts):
gates.append("the required status checks")
if facts.copilot_review_unreported:
gates.append("the Copilot review")
Expand All @@ -87,7 +88,7 @@ def held_gate_phrase(facts: DashboardFacts) -> str:
# review, which has already arrived. A held route always has one of these,
# so the phrase is never empty while the pull request is held.
gates = []
if not facts.required_checks_settled:
if required_checks_unreported(facts):
gates.append("the required status checks")
if facts.copilot_review_unreported:
gates.append("the Copilot review")
Expand Down
13 changes: 10 additions & 3 deletions .github/scripts/pull-request-dashboard/routing_decision.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@
set_copilot_review_request_needed,
)
from dashboard_contracts import DashboardFacts, DashboardRoute
from utils import format_ts, parse_ts, required_checks_settled, utc_now
from utils import (
format_ts,
parse_ts,
required_checks_settled,
required_checks_unreported,
utc_now,
)


@dataclass(frozen=True)
Expand Down Expand Up @@ -166,11 +172,12 @@ def _hold_route_until_gates_settle(
),
required_checks_settled=required_checks_settled(facts),
)
checks_unreported = required_checks_unreported(facts)
gates_outstanding = gates_enabled and (
not facts.required_checks_settled or facts.copilot_review_outstanding
checks_unreported or facts.copilot_review_outstanding
)
unreported_gates = gates_enabled and (
not facts.required_checks_settled or facts.copilot_review_unreported
checks_unreported or facts.copilot_review_unreported
Comment thread
trask marked this conversation as resolved.
)
would_hold = _route_progress(route) > _route_progress(effective_previous_route)
facts = _set_gate_hold_clock(
Expand Down
13 changes: 10 additions & 3 deletions .github/scripts/pull-request-dashboard/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
# current vector, ordinary state loaders may regenerate mismatched disposable
# caches. Every constant ending in _STATE_VERSION or _REVISION is included.
# dashboard-state.json: accepted PR routing results and backfill readiness.
DASHBOARD_STATE_VERSION = 15
DASHBOARD_STATE_VERSION = 16
# backfill-state.json: round-robin cursor used by full dashboard refreshes.
BACKFILL_STATE_VERSION = 3
# notification-state.json: pending and delivered Slack notification records.
Expand All @@ -49,7 +49,7 @@
STATUS_COMMENT_ROLLOUT_STATE_VERSION = 2
# Rendered status-comment behavior. Increment when existing comments need to
# adopt a change; hourly runs durably roll it out to all open PRs.
STATUS_COMMENT_REVISION = 16
STATUS_COMMENT_REVISION = 19
INITIAL_BACKFILL_COMPLETE_KEY = "initial_backfill_complete"
_state_dir: Path | None = None

Expand Down Expand Up @@ -560,6 +560,10 @@ def decode_dashboard_facts(value: Any) -> DashboardFacts:
value.get("ci_failing_since"),
"facts.ci_failing_since",
),
ci_maintainer_action_required_count=_optional_integer(
value.get("ci_maintainer_action_required_count"),
"facts.ci_maintainer_action_required_count",
),
ci_pending_count=_optional_integer(
value.get("ci_pending_count"),
"facts.ci_pending_count",
Expand Down Expand Up @@ -682,6 +686,10 @@ def encode_dashboard_facts(facts: DashboardFacts) -> dict[str, Any]:
stored["ci_failing_count"] = facts.ci_failing_count
if facts.ci_failing_since is not None:
stored["ci_failing_since"] = facts.ci_failing_since
if facts.ci_maintainer_action_required_count is not None:
stored["ci_maintainer_action_required_count"] = (
facts.ci_maintainer_action_required_count
)
if facts.ci_pending_count is not None:
stored["ci_pending_count"] = facts.ci_pending_count
if facts.non_blocking_check_failures:
Expand Down Expand Up @@ -819,7 +827,6 @@ def load_dashboard_state_cache() -> DashboardState | None:
state = load_state_file(
dashboard_state_path(),
DASHBOARD_STATE_VERSION,
compatible_versions=(11, 12, 13),
)
if state is None:
return None
Expand Down
Loading