@@ -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}
823846SIDE_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
826855def 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+
851935def _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+
896999def 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+
9211036def _html_escape (s : str ) -> str :
9221037 return (s or "" ).replace ("&" , "&" ).replace ("<" , "<" ).replace (">" , ">" )
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