Skip to content

Commit c96a37f

Browse files
authored
PR Dashboard: improve the note (open-telemetry#18566)
1 parent 9aa1fe5 commit c96a37f

1 file changed

Lines changed: 77 additions & 22 deletions

File tree

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

Lines changed: 77 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@
3030
DEFAULT_OUTPUT = "pull-request-dashboard.md"
3131
DEFAULT_JOBS = 4
3232
DEFAULT_MODEL = "gpt-5.4-mini"
33+
GH_RETRY_ATTEMPTS = 4
34+
GH_RETRY_DELAY_SECONDS = 1.5
3335
PER_THREAD_TIMEOUT = 180
3436
PR_COMMENT_WINDOW = 20
3537
MAX_BODY_CHARS = 1200
@@ -89,20 +91,49 @@
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+
92114
def 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

108139
def 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

793833
def 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

863903
def 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]:
908950
def 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

Comments
 (0)