Skip to content

Commit ccf87d8

Browse files
committed
fix
1 parent f577317 commit ccf87d8

3 files changed

Lines changed: 205 additions & 87 deletions

File tree

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

Lines changed: 190 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,27 +11,30 @@
1111
Output: pull-request-dashboard.md (one section per PR, grouped by category).
1212
1313
Usage:
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

1917
from __future__ import annotations
2018

21-
import argparse
2219
import json
20+
import os
2321
import re
2422
import subprocess
2523
import sys
24+
import tempfile
2625
import time
2726
from concurrent.futures import ThreadPoolExecutor, as_completed
2827
from datetime import datetime, timezone
2928
from pathlib import Path
3029
from 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
3538
PER_PR_TIMEOUT = 180
3639
MAX_COMMENTS = 20
3740
MAX_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

.github/workflows/code-review-sweep.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ name: Code Review Sweep
22

33
on:
44
schedule:
5-
# Every 15 minutes
6-
- cron: "*/15 * * * *"
5+
# Hourly
6+
- cron: "0 * * * *"
77
workflow_dispatch:
88

99
permissions:

.github/workflows/pr-review-dashboard.yml

Lines changed: 13 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -2,51 +2,27 @@ name: PR dashboard
22

33
on:
44
schedule:
5-
# hourly safety net
5+
# Hourly
66
- cron: "0 * * * *"
77
workflow_dispatch:
8-
pull_request_target:
9-
types:
10-
- opened
11-
- reopened
12-
- closed
13-
- ready_for_review
14-
- converted_to_draft
15-
- labeled
16-
- unlabeled
17-
- synchronize
18-
- review_requested
19-
- review_request_removed
20-
pull_request_review:
21-
types: [submitted, dismissed, edited]
22-
pull_request_review_comment:
23-
types: [created]
24-
issue_comment:
25-
types: [created]
268

27-
# Debounce bursts of PR events without canceling the in-flight run: at most
28-
# one run executes and one is queued. If more events arrive while a run is
29-
# queued, the older queued run is replaced by the newer one, so events
30-
# coalesce instead of stacking up indefinitely.
319
concurrency:
3210
group: pr-review-dashboard
33-
cancel-in-progress: false
11+
cancel-in-progress: true
3412

3513
permissions:
3614
contents: read
3715

3816
jobs:
3917
update-dashboard:
40-
# Skip issue_comment events that aren't on a PR (the event fires for both
41-
# issues and PRs, but only PR comments affect the dashboard).
42-
if: ${{ github.event_name != 'issue_comment' || github.event.issue.pull_request != null }}
4318
permissions:
19+
checks: read
20+
contents: read
4421
issues: write
22+
pull-requests: read
4523
environment: protected
4624
runs-on: ubuntu-latest
47-
env:
48-
DASHBOARD_TITLE: "Pull Request Dashboard"
49-
DASHBOARD_OUTPUT: pull-request-dashboard.md
25+
timeout-minutes: 130
5026
steps:
5127
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
5228

@@ -59,37 +35,15 @@ jobs:
5935
- name: Install Copilot CLI
6036
run: npm install -g @github/copilot
6137

62-
- name: Generate dashboard
38+
- name: Refresh dashboard
6339
env:
6440
# Use the otelbot app token so the script can read approver/maintainer
6541
# team membership via /orgs/.../teams/.../members (the default
66-
# GITHUB_TOKEN cannot read org team membership).
67-
GH_TOKEN: ${{ steps.otelbot-token.outputs.token }}
68-
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
69-
run: |
70-
python3 .github/scripts/pull-request-dashboard.py \
71-
--output "$DASHBOARD_OUTPUT"
72-
73-
- name: Update or create dashboard issue
74-
env:
42+
# GITHUB_TOKEN cannot read org team membership). After loading team
43+
# membership, the script uses GH_TOKEN for the long refresh loop so
44+
# the expiring app token is not needed again.
45+
OTELBOT_TOKEN: ${{ steps.otelbot-token.outputs.token }}
7546
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
47+
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
7648
run: |
77-
# Find the open issue with an exact title match. The search API
78-
# does not support exact phrases, so filter the results in jq.
79-
number=$(gh issue list \
80-
--search "in:title $DASHBOARD_TITLE" \
81-
--state open \
82-
--limit 20 \
83-
--json number,title \
84-
--jq ".[] | select(.title == \"$DASHBOARD_TITLE\") | .number" \
85-
| head -1)
86-
87-
if [[ -n "$number" ]]; then
88-
echo "Updating existing issue #$number"
89-
gh issue edit "$number" --body-file "$DASHBOARD_OUTPUT"
90-
else
91-
echo "Creating new dashboard issue"
92-
gh issue create \
93-
--title "$DASHBOARD_TITLE" \
94-
--body-file "$DASHBOARD_OUTPUT"
95-
fi
49+
python3 .github/scripts/pull-request-dashboard.py

0 commit comments

Comments
 (0)