1111Output: pull-request-dashboard.md (one section per PR, grouped by category).
1212
1313Usage:
14- python .github/scripts/pull-request-dashboard.py [--output FILE]
15- [--jobs N]
16- [--model NAME]
14+ python .github/scripts/pull-request-dashboard.py
1715"""
1816
1917from __future__ import annotations
2018
21- import argparse
2219import json
20+ import os
2321import re
2422import subprocess
2523import sys
24+ import tempfile
2625import time
2726from concurrent .futures import ThreadPoolExecutor , as_completed
2827from datetime import datetime , timezone
2928from pathlib import Path
3029from typing import Any
3130
32- DEFAULT_OUTPUT = "pull-request-dashboard.md"
33- DEFAULT_JOBS = 4
34- DEFAULT_MODEL = "gpt-5.4-mini"
31+ DASHBOARD_OUTPUT = "pull-request-dashboard.md"
32+ DASHBOARD_TITLE = "Pull Request Dashboard"
33+ PARALLEL_JOBS = 4
34+ COPILOT_MODEL = "gpt-5.4-mini"
35+ CACHE_PATH = Path (tempfile .gettempdir ()) / "pull-request-dashboard-cache.json"
36+ LOOP_MINUTES = 120
37+ REFRESH_INTERVAL_SECONDS = 120
3538PER_PR_TIMEOUT = 180
3639MAX_COMMENTS = 20
3740MAX_COMMITS = 5
@@ -829,27 +832,114 @@ def render_markdown_compact(
829832# ---------------------------------------------------------------- main
830833
831834
832- def main () -> int :
833- ap = argparse .ArgumentParser (description = __doc__ , formatter_class = argparse .RawDescriptionHelpFormatter )
834- ap .add_argument ("--output" , default = DEFAULT_OUTPUT , help = f"output file (default: { DEFAULT_OUTPUT } )" )
835- ap .add_argument ("--jobs" , type = int , default = DEFAULT_JOBS , help = f"parallel workers (default: { DEFAULT_JOBS } )" )
836- ap .add_argument ("--model" , default = DEFAULT_MODEL , help = f"copilot model (default: { DEFAULT_MODEL } )" )
837- args = ap .parse_args ()
835+ def load_cache (path : Path ) -> dict [int , dict [str , Any ]]:
836+ """Load the per-PR decision cache. Returns an empty dict on any error."""
837+ if not path .exists ():
838+ return {}
839+ try :
840+ raw = json .loads (path .read_text (encoding = "utf-8" ))
841+ except (OSError , json .JSONDecodeError ):
842+ return {}
843+ if not isinstance (raw , dict ):
844+ return {}
845+ out : dict [int , dict [str , Any ]] = {}
846+ for k , v in raw .items ():
847+ try :
848+ out [int (k )] = v
849+ except (TypeError , ValueError ):
850+ continue
851+ return out
838852
839- repo = detect_repo ()
840- owner , repo_name = repo .split ("/" , 1 )
841853
842- reviewers = load_reviewer_set (owner )
843- print (f"reviewer set ({ len (reviewers )} )" , file = sys .stderr )
854+ def save_cache (path : Path , cache : dict [int , dict [str , Any ]]) -> None :
855+ try :
856+ path .write_text (
857+ json .dumps ({str (k ): v for k , v in cache .items ()}, indent = 2 ),
858+ encoding = "utf-8" ,
859+ )
860+ except OSError as e :
861+ print (f"warning: failed to write cache to { path } : { e } " , file = sys .stderr )
862+
863+
864+ def publish_dashboard_issue (title : str , body_file : Path ) -> None :
865+ token = os .environ .get ("GH_TOKEN" )
866+ if not token :
867+ raise RuntimeError ("GH_TOKEN is not set" )
868+ env = {** os .environ , "GH_TOKEN" : token }
869+
870+ number_proc = subprocess .run (
871+ [
872+ "gh" , "issue" , "list" ,
873+ "--search" , f"in:title { title } " ,
874+ "--state" , "open" ,
875+ "--limit" , "20" ,
876+ "--json" , "number,title" ,
877+ ],
878+ capture_output = True ,
879+ text = True ,
880+ check = True ,
881+ encoding = "utf-8" ,
882+ errors = "replace" ,
883+ env = env ,
884+ )
885+ issues = json .loads (number_proc .stdout or "[]" )
886+ number = ""
887+ for issue in issues :
888+ if issue .get ("title" ) == title :
889+ number = str (issue .get ("number" ) or "" )
890+ break
844891
892+ if number :
893+ print (f"updating existing issue #{ number } " , file = sys .stderr )
894+ subprocess .run (
895+ ["gh" , "issue" , "edit" , number , "--body-file" , str (body_file )],
896+ check = True ,
897+ env = env ,
898+ )
899+ else :
900+ print ("creating new dashboard issue" , file = sys .stderr )
901+ subprocess .run (
902+ ["gh" , "issue" , "create" , "--title" , title , "--body-file" , str (body_file )],
903+ check = True ,
904+ env = env ,
905+ )
906+
907+
908+ def generate_dashboard_once (
909+ repo : str ,
910+ owner : str ,
911+ repo_name : str ,
912+ reviewers : set [str ],
913+ output : Path ,
914+ jobs : int ,
915+ model : str ,
916+ cache_path : Path ,
917+ cache : dict [int , dict [str , Any ]],
918+ ) -> dict [int , dict [str , Any ]]:
845919 prs = list_open_prs (repo )
846920 drafts = [p for p in prs if p .get ("isDraft" )]
847921 non_drafts = [p for p in prs if not p .get ("isDraft" )]
848922 if drafts :
849923 print (f"skipping { len (drafts )} draft PR(s)" , file = sys .stderr )
850924
851- print (f"processing { len (non_drafts )} PR(s) in { repo } (model={ args .model } , jobs={ args .jobs } )" ,
852- file = sys .stderr )
925+ # Partition PRs into cache hits (skip LLM) and misses (process normally).
926+ # The cache entry stores the PR's updatedAt at the time the decision was
927+ # made; if it matches the current updatedAt the conversation, commits,
928+ # labels, etc. have not changed and we can reuse the prior result.
929+ hits : dict [int , dict [str , Any ]] = {}
930+ misses : list [dict [str , Any ]] = []
931+ for pr in non_drafts :
932+ entry = cache .get (pr ["number" ])
933+ if entry and entry .get ("updatedAt" ) == pr .get ("updatedAt" ) and entry .get ("result" ):
934+ hits [pr ["number" ]] = entry ["result" ]
935+ else :
936+ misses .append (pr )
937+
938+ print (
939+ f"processing { len (misses )} PR(s) in { repo } "
940+ f"(cached: { len (hits )} , model={ model } , jobs={ jobs } )" ,
941+ file = sys .stderr ,
942+ )
853943
854944 def process_one (pr : dict [str , Any ]) -> dict [str , Any ]:
855945 number = pr ["number" ]
@@ -876,16 +966,26 @@ def process_one(pr: dict[str, Any]) -> dict[str, Any]:
876966 "effective_author" : ctx .get ("author" ) or "" ,
877967 }
878968 try :
879- r = run_llm (repo , number , context_text , args . model )
969+ r = run_llm (repo , number , context_text , model )
880970 except subprocess .TimeoutExpired :
881971 return {"pr" : number , "returncode" : - 1 , "decision" : None , "raw_stderr" : "timeout" , "facts" : facts }
882972 r ["pr" ] = number
883973 r ["facts" ] = facts
884974 return r
885975
886- results : dict [int , dict [str , Any ]] = {}
887- with ThreadPoolExecutor (max_workers = args .jobs ) as pool :
888- futures = {pool .submit (process_one , p ): p for p in non_drafts }
976+ results : dict [int , dict [str , Any ]] = dict (hits )
977+ new_cache : dict [int , dict [str , Any ]] = {}
978+ # Preserve cache hits (with their original updatedAt) so they survive
979+ # iterations where the PR doesn't change.
980+ for pr in non_drafts :
981+ if pr ["number" ] in hits :
982+ new_cache [pr ["number" ]] = {
983+ "updatedAt" : pr .get ("updatedAt" ),
984+ "result" : hits [pr ["number" ]],
985+ }
986+
987+ with ThreadPoolExecutor (max_workers = jobs ) as pool :
988+ futures = {pool .submit (process_one , p ): p for p in misses }
889989 for i , fut in enumerate (as_completed (futures ), 1 ):
890990 pr = futures [fut ]
891991 try :
@@ -894,12 +994,76 @@ def process_one(pr: dict[str, Any]) -> dict[str, Any]:
894994 res = {"pr" : pr ["number" ], "returncode" : - 1 , "decision" : None , "raw_stderr" : repr (e )}
895995 results [pr ["number" ]] = res
896996 side = (res .get ("decision" ) or {}).get ("side" , "?" )
897- print (f" [{ i } /{ len (non_drafts )} ] #{ pr ['number' ]} -> { side } " , file = sys .stderr )
997+ print (f" [{ i } /{ len (misses )} ] #{ pr ['number' ]} -> { side } " , file = sys .stderr )
998+ # Only cache successful decisions; failures (timeouts, parse
999+ # errors) should be retried on the next run.
1000+ if res .get ("decision" ) and res .get ("returncode" ) == 0 :
1001+ new_cache [pr ["number" ]] = {
1002+ "updatedAt" : pr .get ("updatedAt" ),
1003+ "result" : res ,
1004+ }
1005+
1006+ save_cache (cache_path , new_cache )
8981007
8991008 workflow_issues = fetch_workflow_failure_issues (repo )
9001009 md = render_markdown_compact (prs , results , workflow_issues )
901- Path (args .output ).write_text (md , encoding = "utf-8" )
902- print (f"wrote { args .output } " , file = sys .stderr )
1010+ output .write_text (md , encoding = "utf-8" )
1011+ print (f"wrote { output } " , file = sys .stderr )
1012+ return new_cache
1013+
1014+
1015+ def main () -> int :
1016+ if len (sys .argv ) > 1 :
1017+ if sys .argv [1 :] in (["-h" ], ["--help" ]):
1018+ print (__doc__ .strip ())
1019+ return 0
1020+ raise SystemExit (f"unexpected arguments: { ' ' .join (sys .argv [1 :])} " )
1021+
1022+ repo = detect_repo ()
1023+ owner , repo_name = repo .split ("/" , 1 )
1024+
1025+ workflow_token = os .environ .get ("GH_TOKEN" )
1026+ otelbot_token = os .environ .get ("OTELBOT_TOKEN" )
1027+ if otelbot_token :
1028+ # The otelbot app token is only needed for org team membership and
1029+ # expires after about an hour. Use it only for this initial lookup.
1030+ os .environ ["GH_TOKEN" ] = otelbot_token
1031+ reviewers = load_reviewer_set (owner )
1032+ print (f"reviewer set ({ len (reviewers )} )" , file = sys .stderr )
1033+ if workflow_token :
1034+ os .environ ["GH_TOKEN" ] = workflow_token
1035+
1036+ output = Path (DASHBOARD_OUTPUT )
1037+ cache = load_cache (CACHE_PATH )
1038+
1039+ end = time .monotonic () + (LOOP_MINUTES * 60 )
1040+ next_run = time .monotonic ()
1041+ iteration = 1
1042+
1043+ while True :
1044+ print (f"refreshing dashboard (iteration { iteration } )" , file = sys .stderr )
1045+ cache = generate_dashboard_once (
1046+ repo ,
1047+ owner ,
1048+ repo_name ,
1049+ reviewers ,
1050+ output ,
1051+ PARALLEL_JOBS ,
1052+ COPILOT_MODEL ,
1053+ CACHE_PATH ,
1054+ cache ,
1055+ )
1056+ publish_dashboard_issue (DASHBOARD_TITLE , output )
1057+
1058+ next_run += REFRESH_INTERVAL_SECONDS
1059+ if next_run > end :
1060+ break
1061+
1062+ sleep_for = next_run - time .monotonic ()
1063+ if sleep_for > 0 :
1064+ time .sleep (sleep_for )
1065+ iteration += 1
1066+
9031067 return 0
9041068
9051069
0 commit comments