Skip to content

Commit 84b403b

Browse files
committed
Add draft PRs to dashboard
1 parent 050f91f commit 84b403b

1 file changed

Lines changed: 126 additions & 6 deletions

File tree

.github/scripts/pull-request-dashboard.py

Lines changed: 126 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -519,12 +519,30 @@ def compute_conflicts(pr: dict[str, Any]) -> str:
519519
return "no"
520520

521521

522-
def compute_facts(raw: dict[str, Any], author: str) -> dict[str, Any]:
522+
def latest_substantive_activity(events: list[dict[str, Any]], actor_roles: set[str]) -> datetime | None:
523+
timestamps = [
524+
parse_ts(e["timestamp"])
525+
for e in events
526+
if e.get("actor_role") in actor_roles and is_substantive_activity(e)
527+
]
528+
timestamps = [ts for ts in timestamps if ts is not None]
529+
return max(timestamps) if timestamps else None
530+
531+
532+
def ts_text(ts: datetime | None) -> str:
533+
return ts.isoformat() if ts else ""
534+
535+
536+
def compute_facts(raw: dict[str, Any], author: str, events: list[dict[str, Any]]) -> dict[str, Any]:
523537
pr = raw["pr"]
524538
checks = raw["checks"]
525539
failing = [c for c in checks if (c.get("state") or "").upper() in ("FAILURE", "ERROR")]
526540
pending = [c for c in checks if (c.get("state") or "").upper() in ("PENDING", "QUEUED", "IN_PROGRESS")]
527541
last_activity_ts = parse_ts(pr["updatedAt"])
542+
created_ts = parse_ts(pr["createdAt"])
543+
author_activity_ts = latest_substantive_activity(events, {"author"})
544+
approver_activity_ts = latest_substantive_activity(events, {"approver"})
545+
external_activity_ts = latest_substantive_activity(events, {"outsider"})
528546
api_author = actor_login(pr.get("author") or {})
529547
return {
530548
"author": author,
@@ -534,6 +552,11 @@ def compute_facts(raw: dict[str, Any], author: str) -> dict[str, Any]:
534552
"ci_failing_count": len(failing),
535553
"ci_pending_count": len(pending),
536554
"conflicts": compute_conflicts(pr),
555+
"created_at": ts_text(created_ts),
556+
"last_activity_at": ts_text(last_activity_ts),
557+
"last_author_activity_at": ts_text(author_activity_ts),
558+
"last_approver_activity_at": ts_text(approver_activity_ts),
559+
"last_external_activity_at": ts_text(external_activity_ts),
537560
"seconds_since_last_activity": seconds_since(last_activity_ts),
538561
"last_activity_age": activity_age(last_activity_ts),
539562
}
@@ -821,6 +844,12 @@ def classify_threads(
821844
"unknown": "Unknown",
822845
}
823846
SIDE_ORDER = ["maintainer", "approver", "author", "external", "transient-failure", "unknown"]
847+
SIDE_THREAD_ACTIONS = {
848+
"author": "author",
849+
"approver": "reviewer",
850+
"maintainer": "reviewer",
851+
"external": "external",
852+
}
824853

825854

826855
def action_counts(classifications: list[dict[str, Any]]) -> dict[str, int]:
@@ -848,6 +877,61 @@ def route_pr(facts: dict[str, Any], classifications: list[dict[str, Any]]) -> st
848877
return "approver"
849878

850879

880+
def thread_by_id(threads: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
881+
return {t["thread_id"]: t for t in threads}
882+
883+
884+
def thread_latest_comment_ts(thread: dict[str, Any] | None) -> datetime | None:
885+
comments = (thread or {}).get("comments") or []
886+
if not comments:
887+
return None
888+
return parse_ts(comments[-1].get("timestamp") or "")
889+
890+
891+
def oldest_thread_wait_ts(
892+
threads: list[dict[str, Any]],
893+
classifications: list[dict[str, Any]],
894+
action: str,
895+
) -> datetime | None:
896+
threads_by_id = thread_by_id(threads)
897+
timestamps = [
898+
thread_latest_comment_ts(threads_by_id.get(c.get("thread_id") or ""))
899+
for c in classifications
900+
if valid_thread_action((c.get("decision") or {}).get("thread_action") or "") == action
901+
]
902+
timestamps = [ts for ts in timestamps if ts is not None]
903+
return min(timestamps) if timestamps else None
904+
905+
906+
def fallback_wait_ts(side: str, facts: dict[str, Any]) -> tuple[datetime | None, str]:
907+
if side in ("approver", "maintainer"):
908+
return parse_ts(facts.get("last_author_activity_at") or ""), "last_author_activity"
909+
if side == "author":
910+
return parse_ts(facts.get("last_approver_activity_at") or ""), "last_approver_activity"
911+
if side == "external":
912+
return parse_ts(facts.get("last_external_activity_at") or ""), "last_external_activity"
913+
return parse_ts(facts.get("last_activity_at") or ""), "last_activity"
914+
915+
916+
def add_wait_age_facts(
917+
facts: dict[str, Any],
918+
side: str,
919+
threads: list[dict[str, Any]],
920+
classifications: list[dict[str, Any]],
921+
) -> None:
922+
action = SIDE_THREAD_ACTIONS.get(side)
923+
wait_ts = oldest_thread_wait_ts(threads, classifications, action) if action else None
924+
basis = "oldest_pending_thread" if wait_ts else ""
925+
if wait_ts is None:
926+
wait_ts, basis = fallback_wait_ts(side, facts)
927+
if wait_ts is None:
928+
wait_ts = parse_ts(facts.get("created_at") or "")
929+
basis = "created"
930+
facts["seconds_since_waiting"] = seconds_since(wait_ts)
931+
facts["waiting_age"] = activity_age(wait_ts)
932+
facts["waiting_age_basis"] = basis
933+
934+
851935
def _md_escape(s: str) -> str:
852936
return (s or "").replace("|", "\\|").replace("\n", " ").strip()
853937

@@ -893,6 +977,25 @@ def render_workflow_failure_section(issues: list[dict[str, Any]]) -> list[str]:
893977
return lines
894978

895979

980+
def render_draft_pr_section(prs: list[dict[str, Any]]) -> list[str]:
981+
drafts = [p for p in prs if p.get("isDraft")]
982+
if not drafts:
983+
return []
984+
drafts.sort(key=lambda p: p.get("updatedAt") or "")
985+
lines = ["## Draft pull requests", ""]
986+
lines.append("| PR | Author | Updated |")
987+
lines.append("|---|---|:---:|")
988+
for pr in drafts:
989+
number = pr["number"]
990+
title = _md_escape(pr.get("title", ""))
991+
url = pr.get("url", "")
992+
author = actor_login(pr.get("author") or {})
993+
updated = activity_age(parse_ts(pr.get("updatedAt") or ""))
994+
lines.append(f"| [{title} (#{number})]({url}) | {author} | {updated} |")
995+
lines.append("")
996+
return lines
997+
998+
896999
def ci_cell(facts: dict[str, Any]) -> str:
8971000
if "ci_failing_count" not in facts and "ci_pending_count" not in facts:
8981001
return "?"
@@ -918,6 +1021,18 @@ def approved_cell(facts: dict[str, Any]) -> str:
9181021
return "✅" if facts.get("approved") else " "
9191022

9201023

1024+
def age_seconds(facts: dict[str, Any]) -> int | None:
1025+
value = facts.get("seconds_since_waiting")
1026+
if isinstance(value, int):
1027+
return value
1028+
fallback = facts.get("seconds_since_last_activity")
1029+
return fallback if isinstance(fallback, int) else None
1030+
1031+
1032+
def age_cell(facts: dict[str, Any]) -> str:
1033+
return facts.get("waiting_age") or facts.get("last_activity_age") or "?"
1034+
1035+
9211036
def _html_escape(s: str) -> str:
9221037
return (s or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
9231038

@@ -931,6 +1046,8 @@ def render_diagnostics_section(results: dict[int, dict[str, Any]]) -> list[str]:
9311046
lines.append(f"PR #{number}")
9321047
lines.append(
9331048
f"facts: approved={facts.get('approved')} conflicts={facts.get('conflicts')} "
1049+
f"age={age_cell(facts)} "
1050+
f"age_basis={facts.get('waiting_age_basis')} "
9341051
f"last_activity_age={facts.get('last_activity_age')}"
9351052
)
9361053
lines.append("threads: " + " ".join(f"{k}={v}" for k, v in counts.items()))
@@ -957,7 +1074,8 @@ def render_markdown_compact(
9571074
refresh_url = f"https://github.com/{repo}/actions/workflows/pr-review-dashboard.yml"
9581075
out: list[str] = [
9591076
"> [!NOTE]",
960-
"> Open non-draft PRs grouped by who is expected to act next. The grouping is "
1077+
"> Open non-draft PRs grouped by who is expected to act next. Draft PRs are "
1078+
"listed separately. The grouping is "
9611079
f"partly performed by an LLM ([source]({source_url})) and could contain mistakes. "
9621080
f"Refreshed about every hour. Last refresh: {now}.",
9631081
"",
@@ -973,7 +1091,7 @@ def render_markdown_compact(
9731091
def row_sort_key(pr: dict[str, Any]) -> tuple[int, int]:
9741092
res = results.get(pr["number"]) or {}
9751093
facts = res.get("facts") or {}
976-
activity = facts.get("seconds_since_last_activity")
1094+
activity = age_seconds(facts)
9771095
return (activity if isinstance(activity, int) else -1, pr["number"])
9781096

9791097
for side in SIDE_ORDER:
@@ -983,7 +1101,7 @@ def row_sort_key(pr: dict[str, Any]) -> tuple[int, int]:
9831101
rows.sort(key=row_sort_key, reverse=True)
9841102
out.append(f"## {SIDE_LABELS.get(side, side)}")
9851103
out.append("")
986-
out.append("| PR | Author | CI | Conflicts | Inactive |")
1104+
out.append("| PR | Author | CI | Conflicts | Age |")
9871105
out.append("|---|---|:---:|:---:|:---:|")
9881106
for pr in rows:
9891107
number = pr["number"]
@@ -992,7 +1110,7 @@ def row_sort_key(pr: dict[str, Any]) -> tuple[int, int]:
9921110
res = results.get(number) or {}
9931111
facts = res.get("facts") or {}
9941112
author = facts.get("author") or actor_login(pr.get("author") or {})
995-
activity_cell = facts.get("last_activity_age") or "?"
1113+
activity_cell = age_cell(facts)
9961114
pr_cell = f"[{title} (#{number})]({url})"
9971115
if facts.get("approved"):
9981116
pr_cell += " ✅"
@@ -1003,6 +1121,7 @@ def row_sort_key(pr: dict[str, Any]) -> tuple[int, int]:
10031121
out.append("")
10041122

10051123
out.extend(render_workflow_failure_section(workflow_issues or []))
1124+
out.extend(render_draft_pr_section(prs))
10061125
out.extend(render_diagnostics_section(results))
10071126
out.append(f"_Approvers may [force a refresh]({refresh_url})._")
10081127
out.append("")
@@ -1025,10 +1144,11 @@ def build_pr_result(
10251144
raw = fetch_pr_raw(repo, owner, repo_name, pr_summary)
10261145
author, delegator = effective_author(raw)
10271146
events = normalize_events(raw, author, reviewers)
1028-
facts = compute_facts(raw, author)
1147+
facts = compute_facts(raw, author, events)
10291148
threads = group_discussion_threads(raw, events, author, reviewers, facts)
10301149
classifications = classify_threads(repo, number, raw["pr"], facts, threads, model)
10311150
side = route_pr(facts, classifications)
1151+
add_wait_age_facts(facts, side, threads, classifications)
10321152
return {
10331153
"pr": number,
10341154
"returncode": 0,

0 commit comments

Comments
 (0)