3030DEFAULT_OUTPUT = "pull-request-dashboard.md"
3131DEFAULT_JOBS = 4
3232DEFAULT_MODEL = "gpt-5.4-mini"
33+ GH_RETRY_ATTEMPTS = 4
34+ GH_RETRY_DELAY_SECONDS = 1.5
3335PER_THREAD_TIMEOUT = 180
3436PR_COMMENT_WINDOW = 20
3537MAX_BODY_CHARS = 1200
8991# ---------------------------------------------------------------- gh helpers
9092
9193
94+ class TransientGhError (RuntimeError ):
95+ pass
96+
97+
98+ def is_retryable_gh_error (stderr : str ) -> bool :
99+ text = stderr .lower ()
100+ return (
101+ "http 5" in text
102+ or "gateway timeout" in text
103+ or "timeout" in text
104+ or "temporarily unavailable" in text
105+ or "connection reset" in text
106+ or "connection refused" in text
107+ )
108+
109+
110+ def gh_retry_delay (attempt : int ) -> None :
111+ time .sleep (GH_RETRY_DELAY_SECONDS * (attempt + 1 ))
112+
113+
92114def run_gh_json (cmd : list [str ], token : str | None = None ) -> Any :
93115 env = {** os .environ , "GH_TOKEN" : token } if token else None
94- proc = subprocess .run (
95- cmd ,
96- capture_output = True ,
97- text = True ,
98- check = False ,
99- encoding = "utf-8" ,
100- errors = "replace" ,
101- env = env ,
102- )
103- if proc .returncode != 0 :
104- raise RuntimeError (f"{ ' ' .join (cmd )} failed: { proc .stderr .strip ()} " )
105- return json .loads (proc .stdout or "null" )
116+ last_stderr = ""
117+ for attempt in range (GH_RETRY_ATTEMPTS ):
118+ proc = subprocess .run (
119+ cmd ,
120+ capture_output = True ,
121+ text = True ,
122+ check = False ,
123+ encoding = "utf-8" ,
124+ errors = "replace" ,
125+ env = env ,
126+ )
127+ if proc .returncode == 0 :
128+ return json .loads (proc .stdout or "null" )
129+ last_stderr = proc .stderr .strip ()
130+ if attempt == GH_RETRY_ATTEMPTS - 1 or not is_retryable_gh_error (last_stderr ):
131+ break
132+ gh_retry_delay (attempt )
133+ message = f"{ ' ' .join (cmd )} failed: { last_stderr } "
134+ if is_retryable_gh_error (last_stderr ):
135+ raise TransientGhError (message )
136+ raise RuntimeError (message )
106137
107138
108139def gh_api (path : str , paginate : bool = False , token : str | None = None ) -> Any :
@@ -140,7 +171,8 @@ def gh_pr_view(repo: str, number: int) -> dict[str, Any]:
140171 "headRefOid" , "body" ,
141172 ])
142173 last : dict [str , Any ] = {}
143- for attempt in range (4 ):
174+ last_stderr = ""
175+ for attempt in range (GH_RETRY_ATTEMPTS ):
144176 proc = subprocess .run (
145177 ["gh" , "pr" , "view" , str (number ), "--repo" , repo , "--json" , fields ],
146178 capture_output = True ,
@@ -150,12 +182,19 @@ def gh_pr_view(repo: str, number: int) -> dict[str, Any]:
150182 errors = "replace" ,
151183 )
152184 if proc .returncode != 0 :
153- raise RuntimeError (f"gh pr view { number } failed: { proc .stderr .strip ()} " )
185+ last_stderr = proc .stderr .strip ()
186+ if attempt == GH_RETRY_ATTEMPTS - 1 or not is_retryable_gh_error (last_stderr ):
187+ message = f"gh pr view { number } failed: { last_stderr } "
188+ if is_retryable_gh_error (last_stderr ):
189+ raise TransientGhError (message )
190+ raise RuntimeError (message )
191+ gh_retry_delay (attempt )
192+ continue
154193 last = json .loads (proc .stdout or "{}" )
155194 if last .get ("mergeable" ) not in (None , "" , "UNKNOWN" ):
156195 return last
157- if attempt < 3 :
158- time . sleep ( 1.5 )
196+ if attempt < GH_RETRY_ATTEMPTS - 1 :
197+ gh_retry_delay ( attempt )
159198 return last
160199
161200
@@ -785,9 +824,10 @@ def classify_threads(
785824 "approver" : "Waiting on approvers" ,
786825 "author" : "Waiting on authors" ,
787826 "external" : "Waiting on external" ,
827+ "transient-failure" : "Transient GitHub failure retrieving PR data" ,
788828 "unknown" : "Unknown" ,
789829}
790- SIDE_ORDER = ["maintainer" , "approver" , "author" , "external" , "unknown" ]
830+ SIDE_ORDER = ["maintainer" , "approver" , "author" , "external" , "transient-failure" , " unknown" ]
791831
792832
793833def action_counts (classifications : list [dict [str , Any ]]) -> dict [str , int ]:
@@ -861,6 +901,8 @@ def render_workflow_failure_section(issues: list[dict[str, Any]]) -> list[str]:
861901
862902
863903def ci_cell (facts : dict [str , Any ]) -> str :
904+ if "ci_failing_count" not in facts and "ci_pending_count" not in facts :
905+ return "?"
864906 if facts .get ("ci_failing_count" , 0 ) > 0 :
865907 return "❌"
866908 if facts .get ("ci_pending_count" , 0 ) > 0 :
@@ -908,14 +950,17 @@ def render_diagnostics_section(results: dict[int, dict[str, Any]]) -> list[str]:
908950def render_markdown_compact (
909951 prs : list [dict [str , Any ]],
910952 results : dict [int , dict [str , Any ]],
953+ repo : str ,
911954 workflow_issues : list [dict [str , Any ]] | None = None ,
912955) -> str :
913956 now = datetime .now (timezone .utc ).strftime ("%Y-%m-%d %H:%M UTC" )
957+ source_url = f"https://github.com/{ repo } /blob/main/.github/scripts/pull-request-dashboard.py"
958+ refresh_url = f"https://github.com/{ repo } /actions/workflows/pr-review-dashboard.yml"
914959 out : list [str ] = [
915960 "> [!NOTE]" ,
916- "> Open PRs are grouped by deterministic routing over per-thread LLM classifications. "
917- "CI, conflicts, and activity age are computed deterministically and are shown as facts, "
918- "not used as standalone routing reasons ." ,
961+ "> Open non-draft PRs grouped by who is expected to act next. The grouping is "
962+ f"partly performed by an LLM ([source]( { source_url } )) and could contain mistakes. "
963+ f"Refreshed about every hour. Last refresh: { now } ." ,
919964 "" ,
920965 ]
921966
@@ -952,7 +997,7 @@ def render_markdown_compact(
952997
953998 out .extend (render_workflow_failure_section (workflow_issues or []))
954999 out .extend (render_diagnostics_section (results ))
955- out .append (f"_Generated { now } _" )
1000+ out .append (f"_Approvers may [force a refresh]( { refresh_url } ). _" )
9561001 out .append ("" )
9571002 return "\n " .join (out ) + "\n "
9581003
@@ -987,6 +1032,16 @@ def build_pr_result(
9871032 "classifications" : classifications ,
9881033 "side" : side ,
9891034 }
1035+ except TransientGhError as e :
1036+ return {
1037+ "pr" : number ,
1038+ "returncode" : - 1 ,
1039+ "facts" : {},
1040+ "threads" : [],
1041+ "classifications" : [],
1042+ "side" : "transient-failure" ,
1043+ "raw_stderr" : repr (e ),
1044+ }
9901045 except Exception as e :
9911046 return {
9921047 "pr" : number ,
@@ -1041,7 +1096,7 @@ def main() -> int:
10411096 )
10421097
10431098 workflow_issues = fetch_workflow_failure_issues (repo )
1044- md = render_markdown_compact (prs , results , workflow_issues )
1099+ md = render_markdown_compact (prs , results , repo , workflow_issues )
10451100 Path (args .output ).write_text (md , encoding = "utf-8" )
10461101 print (f"wrote { args .output } " , file = sys .stderr )
10471102 return 0
0 commit comments